Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optimize run to avoid quadratic map cloning #15

Merged
merged 16 commits into from
Jan 24, 2023
Merged
44 changes: 44 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Node.js CI

on:
push:
branches: [ master ]
pull_request:
branches: [ master ]

jobs:
test:
name: "Test"
runs-on: ubuntu-latest

strategy:
matrix:
node-version: [14.x, 16.x, 18.x]

steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test

lint:
name: "Lint"
runs-on: ubuntu-latest

strategy:
matrix:
node-version: [18.x]

steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run lint
22 changes: 21 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"description": "Async Context proposal for JavaScript",
"scripts": {
"build": "mkdir -p build && ecmarkup spec.html build/index.html",
"lint": "tsc -p tsconfig.json",
"test": "mocha"
},
"repository": "legendecas/proposal-async-context",
Expand All @@ -26,6 +27,7 @@
"@types/mocha": "10.0.1",
"@types/node": "18.11.18",
"ecmarkup": "^3.1.1",
"mocha": "10.2.0"
"mocha": "10.2.0",
"typescript": "4.9.4"
}
}
67 changes: 67 additions & 0 deletions src/fork.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Mapping } from "./mapping";
import type { AsyncContext } from "./index";

/**
* FrozenFork holds a frozen Mapping that will be simply restored when the fork is
* rejoined.
*
* This is used when we already know that the mapping is frozen, so that
* rejoining will not attempt to mutate the Mapping (and allocate a new
* mapping) as an OwnedFork would.
*/
export class FrozenFork {
#mapping: Mapping;

constructor(mapping: Mapping) {
this.#mapping = mapping;
}

/**
* The Storage container will call join when it wants to restore its current
* Mapping to the state at the start of the fork.
*
* For FrozenFork, that's as simple as returning the known-frozen Mapping,
* because we know it can't have been modified.
*/
join(_current: Mapping): Mapping {
return this.#mapping;
}
}

/**
* OwnedFork holds an unfrozen Mapping that we will attempt to modify when
* rejoining to attempt to restore it to its prior state.
*
* This is used when we know that the Mapping is unfrozen at start, because
* it's possible that no one will snapshot this Mapping before we rejoin. In
* that case, we can simply modify the Mapping (without cloning) to restore it
* to its prior state. If someone does snapshot it, then modifying will clone
* the current state and we restore the clone to the prior state.
*/
export class OwnedFork<T> {
#key: AsyncContext<T>;
#has: boolean;
#prev: T | undefined;

constructor(mapping: Mapping, key: AsyncContext<T>) {
this.#key = key;
this.#has = mapping.has(key);
this.#prev = mapping.get(key);
}

/**
* The Storage container will call join when it wants to restore its current
* Mapping to the state at the start of the fork.
*
* For OwnedFork, we mutate the known-unfrozen-at-start mapping (which may
* reallocate if anyone has since taken a snapshot) in the hopes that we
* won't need to reallocate.
*/
join(current: Mapping): Mapping {
if (this.#has) {
return current.set(this.#key, this.#prev);
} else {
return current.delete(this.#key);
}
}
}
51 changes: 22 additions & 29 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,38 @@
type AnyFunc = (...args: any) => any;
type Storage = Map<AsyncContext<unknown>, unknown>;
import { Storage } from "./storage";

let __storage__: Storage = new Map();
type AnyFunc<T> = (this: T, ...args: any) => any;

export class AsyncContext<T> {
static wrap<F extends AnyFunc>(fn: F): F {
const current = __storage__;
static wrap<F extends AnyFunc<any>>(fn: F): F {
const snapshot = Storage.snapshot();

function wrap(...args: Parameters<F>): ReturnType<F> {
return run(fn, current, this, args);
};
function wrap(this: ThisType<F>, ...args: Parameters<F>): ReturnType<F> {
const fork = Storage.restore(snapshot);
try {
return fn.apply(this, args);
} finally {
Storage.join(fork);
}
}

return wrap as unknown as F;
}

run<F extends AnyFunc>(
run<F extends AnyFunc<null>>(
value: T,
fn: F,
...args: Parameters<F>
): ReturnType<F> {
const next = new Map(__storage__);
next.set(this, value);
return run(fn, next, null, args);
const fork = Storage.fork(this);
Storage.set(this, value);
try {
return fn.apply(null, args);
} finally {
Storage.join(fork);
}
}

get(): T {
return __storage__.get(this) as T;
}
}

function run<F extends AnyFunc>(
fn: F,
next: Storage,
binding: ThisType<F>,
args: Parameters<F>
): ReturnType<F> {
const previous = __storage__;
try {
__storage__ = next;
return fn.apply(binding, args);
} finally {
__storage__ = previous;
get(): T | undefined {
return Storage.get(this);
}
}
71 changes: 71 additions & 0 deletions src/mapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { AsyncContext } from "./index";

/**
* Stores all AsyncContext data, and tracks whether any snapshots have been
* taken of the current data.
*/
export class Mapping {
#data: Map<AsyncContext<unknown>, unknown> | null;

/**
* If a snapshot of this data is taken, then further modifications cannot be
* made directly. Instead, set/delete will clone this Mapping and modify
* _that_ instance.
*/
#frozen: boolean;

constructor(data: Map<AsyncContext<unknown>, unknown> | null) {
this.#data = data;
this.#frozen = data === null;
}

has<T>(key: AsyncContext<T>): boolean {
return this.#data?.has(key) || false;
}

get<T>(key: AsyncContext<T>): T | undefined {
return this.#data?.get(key) as T | undefined;
}

/**
* Like the standard Map.p.set, except that we will allocate a new Mapping
* instance if this instance is frozen.
*/
set<T>(key: AsyncContext<T>, value: T): Mapping {
const mapping = this.#fork();
mapping.#data!.set(key, value);
return mapping;
}

/**
* Like the standard Map.p.delete, except that we will allocate a new Mapping
* instance if this instance is frozen.
*/
delete<T>(key: AsyncContext<T>): Mapping {
const mapping = this.#fork();
mapping.#data!.delete(key);
return mapping;
}

/**
* Prevents further modifications to this Mapping.
*/
freeze(): void {
this.#frozen = true;
}

isFrozen(): boolean {
return this.#frozen;
}

/**
* We only need to fork if the Mapping is frozen (someone has a snapshot of
* the current data), else we can just modify our data directly.
*/
#fork(): Mapping {
if (this.#frozen) {
return new Mapping(new Map(this.#data));
}
return this;
}
}
79 changes: 79 additions & 0 deletions src/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Mapping } from "./mapping";
import { FrozenFork, OwnedFork } from "./fork";
import type { AsyncContext } from "./index";

/**
* Storage is the (internal to the language) storage container of all
* AsyncContext data.
*
* None of the methods here are exposed to users, they're only exposed to the AsyncContext class.
*/
export class Storage {
static #current: Mapping = new Mapping(null);

/**
* Get retrieves the current value assigned to the AsyncContext.
*/
static get<T>(key: AsyncContext<T>): T | undefined {
return this.#current.get(key);
}

/**
* Set assigns a new value to the AsyncContext.
*/
static set<T>(key: AsyncContext<T>, value: T): void {
// If the Mappings are frozen (someone has snapshot it), then modifying the
// mappings will return a clone containing the modification.
this.#current = this.#current.set(key, value);
}

/**
* Fork is called before modifying the global storage state (either by
* replacing the current mappings or assigning a new value to an individual
* AsyncContext).
*
* The Fork instance returned will be able to restore the mappings to the
* unmodified state.
*/
static fork<T>(key: AsyncContext<T>): FrozenFork | OwnedFork<T> {
const current = this.#current;
if (current.isFrozen()) {
return new FrozenFork(current);
}
return new OwnedFork(current, key);
}

/**
* Join will restore the global storage state to state at the time of the
* fork.
*/
static join<T>(fork: FrozenFork | OwnedFork<T>): void {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this method can be merged with Storage.restore. They all restore the global storage state to state at the time of the fork.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite. join and restore (now restore and switch respectively) have different assumptions about what can happen to the current global state.

For join, we assume the the current mappings will be modified (or if it's frozen, reallocated then modified). For restore, we want to switch back to the state of a wrap's snapshot and return a FrozenFork so the wrapper can restore the prior state after running. It wouldn't be valid for join to return a fork.

this.#current = fork.join(this.#current);
}

/**
* Snapshot freezes the current storage state, and returns a new fork which
* can restore the global storage state to the state at the time of the
* snapshot.
*/
static snapshot(): FrozenFork {
this.#current?.freeze();
jridgewell marked this conversation as resolved.
Show resolved Hide resolved
return new FrozenFork(this.#current);
}

/**
* Restore restores the global storage state to the state at the time of the
* snapshot.
*/
static restore(snapshot: FrozenFork): FrozenFork {
const previous = this.#current;
this.#current = snapshot.join(previous);

// Technically, previous may not be frozen. But we know its state cannot
// change, because the only way to modify it is to restore it to the
// Storage container, and the only way to do that is to have snapshot it.
// So it's either snapshot (and frozen), or it's not and thus cannot be
// modified.
return new FrozenFork(previous);
}
}
Loading