Skip to content

Commit 31ba1f0

Browse files
authored
Optimize run to avoid quadratic map cloning (tc39#15)
1 parent d1c42e4 commit 31ba1f0

File tree

9 files changed

+789
-173
lines changed

9 files changed

+789
-173
lines changed

.github/workflows/test.yml

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Node.js CI
2+
3+
on:
4+
push:
5+
branches: [ master ]
6+
pull_request:
7+
branches: [ master ]
8+
9+
jobs:
10+
test:
11+
name: "Test"
12+
runs-on: ubuntu-latest
13+
14+
strategy:
15+
matrix:
16+
node-version: [14.x, 16.x, 18.x]
17+
18+
steps:
19+
- uses: actions/checkout@v3
20+
- name: Use Node.js ${{ matrix.node-version }}
21+
uses: actions/setup-node@v3
22+
with:
23+
node-version: ${{ matrix.node-version }}
24+
cache: 'npm'
25+
- run: npm ci
26+
- run: npm test
27+
28+
lint:
29+
name: "Lint"
30+
runs-on: ubuntu-latest
31+
32+
strategy:
33+
matrix:
34+
node-version: [18.x]
35+
36+
steps:
37+
- uses: actions/checkout@v3
38+
- name: Use Node.js ${{ matrix.node-version }}
39+
uses: actions/setup-node@v3
40+
with:
41+
node-version: ${{ matrix.node-version }}
42+
cache: 'npm'
43+
- run: npm ci
44+
- run: npm run lint

package-lock.json

+21-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"description": "Async Context proposal for JavaScript",
66
"scripts": {
77
"build": "mkdir -p build && ecmarkup spec.html build/index.html",
8+
"lint": "tsc -p tsconfig.json",
89
"test": "mocha"
910
},
1011
"repository": "legendecas/proposal-async-context",
@@ -26,6 +27,7 @@
2627
"@types/mocha": "10.0.1",
2728
"@types/node": "18.11.18",
2829
"ecmarkup": "^3.1.1",
29-
"mocha": "10.2.0"
30+
"mocha": "10.2.0",
31+
"typescript": "4.9.4"
3032
}
3133
}

src/fork.ts

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { Mapping } from "./mapping";
2+
import type { AsyncContext } from "./index";
3+
4+
/**
5+
* FrozenRevert holds a frozen Mapping that will be simply restored when the
6+
* revert is run.
7+
*
8+
* This is used when we already know that the mapping is frozen, so that
9+
* reverting will not attempt to mutate the Mapping (and allocate a new
10+
* mapping) as a Revert would.
11+
*/
12+
export class FrozenRevert {
13+
#mapping: Mapping;
14+
15+
constructor(mapping: Mapping) {
16+
this.#mapping = mapping;
17+
}
18+
19+
/**
20+
* The Storage container will call restore when it wants to revert its
21+
* current Mapping to the state at the start of the fork.
22+
*
23+
* For FrozenRevert, that's as simple as returning the known-frozen Mapping,
24+
* because we know it can't have been modified.
25+
*/
26+
restore(_current: Mapping): Mapping {
27+
return this.#mapping;
28+
}
29+
}
30+
31+
/**
32+
* Revert holds the information on how to undo a modification to our Mappings,
33+
* and will attempt to modify the current state when we attempt to restore it
34+
* to its prior state.
35+
*
36+
* This is used when we know that the Mapping is unfrozen at start, because
37+
* it's possible that no one will snapshot this Mapping before we restore. In
38+
* that case, we can simply modify the Mapping without cloning. If someone did
39+
* snapshot it, then modifying will clone the current state and we restore the
40+
* clone to the prior state.
41+
*/
42+
export class Revert<T> {
43+
#key: AsyncContext<T>;
44+
#has: boolean;
45+
#prev: T | undefined;
46+
47+
constructor(mapping: Mapping, key: AsyncContext<T>) {
48+
this.#key = key;
49+
this.#has = mapping.has(key);
50+
this.#prev = mapping.get(key);
51+
}
52+
53+
/**
54+
* The Storage container will call restore when it wants to revert its
55+
* current Mapping to the state at the start of the fork.
56+
*
57+
* For Revert, we mutate the known-unfrozen-at-start mapping (which may
58+
* reallocate if anyone has since taken a snapshot) in the hopes that we
59+
* won't need to reallocate.
60+
*/
61+
restore(current: Mapping): Mapping {
62+
if (this.#has) {
63+
return current.set(this.#key, this.#prev);
64+
} else {
65+
return current.delete(this.#key);
66+
}
67+
}
68+
}

src/index.ts

+21-29
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,37 @@
1-
type AnyFunc = (...args: any) => any;
2-
type Storage = Map<AsyncContext<unknown>, unknown>;
1+
import { Storage } from "./storage";
32

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

65
export class AsyncContext<T> {
7-
static wrap<F extends AnyFunc>(fn: F): F {
8-
const current = __storage__;
6+
static wrap<F extends AnyFunc<any>>(fn: F): F {
7+
const snapshot = Storage.snapshot();
98

10-
function wrap(...args: Parameters<F>): ReturnType<F> {
11-
return run(fn, current, this, args);
12-
};
9+
function wrap(this: ThisType<F>, ...args: Parameters<F>): ReturnType<F> {
10+
const head = Storage.switch(snapshot);
11+
try {
12+
return fn.apply(this, args);
13+
} finally {
14+
Storage.restore(head);
15+
}
16+
}
1317

1418
return wrap as unknown as F;
1519
}
1620

17-
run<F extends AnyFunc>(
21+
run<F extends AnyFunc<null>>(
1822
value: T,
1923
fn: F,
2024
...args: Parameters<F>
2125
): ReturnType<F> {
22-
const next = new Map(__storage__);
23-
next.set(this, value);
24-
return run(fn, next, null, args);
26+
const revert = Storage.set(this, value);
27+
try {
28+
return fn.apply(null, args);
29+
} finally {
30+
Storage.restore(revert);
31+
}
2532
}
2633

27-
get(): T {
28-
return __storage__.get(this) as T;
29-
}
30-
}
31-
32-
function run<F extends AnyFunc>(
33-
fn: F,
34-
next: Storage,
35-
binding: ThisType<F>,
36-
args: Parameters<F>
37-
): ReturnType<F> {
38-
const previous = __storage__;
39-
try {
40-
__storage__ = next;
41-
return fn.apply(binding, args);
42-
} finally {
43-
__storage__ = previous;
34+
get(): T | undefined {
35+
return Storage.get(this);
4436
}
4537
}

src/mapping.ts

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { AsyncContext } from "./index";
2+
3+
/**
4+
* Stores all AsyncContext data, and tracks whether any snapshots have been
5+
* taken of the current data.
6+
*/
7+
export class Mapping {
8+
#data: Map<AsyncContext<unknown>, unknown> | null;
9+
10+
/**
11+
* If a snapshot of this data is taken, then further modifications cannot be
12+
* made directly. Instead, set/delete will clone this Mapping and modify
13+
* _that_ instance.
14+
*/
15+
#frozen: boolean;
16+
17+
constructor(data: Map<AsyncContext<unknown>, unknown> | null) {
18+
this.#data = data;
19+
this.#frozen = data === null;
20+
}
21+
22+
has<T>(key: AsyncContext<T>): boolean {
23+
return this.#data?.has(key) || false;
24+
}
25+
26+
get<T>(key: AsyncContext<T>): T | undefined {
27+
return this.#data?.get(key) as T | undefined;
28+
}
29+
30+
/**
31+
* Like the standard Map.p.set, except that we will allocate a new Mapping
32+
* instance if this instance is frozen.
33+
*/
34+
set<T>(key: AsyncContext<T>, value: T): Mapping {
35+
const mapping = this.#fork();
36+
mapping.#data!.set(key, value);
37+
return mapping;
38+
}
39+
40+
/**
41+
* Like the standard Map.p.delete, except that we will allocate a new Mapping
42+
* instance if this instance is frozen.
43+
*/
44+
delete<T>(key: AsyncContext<T>): Mapping {
45+
const mapping = this.#fork();
46+
mapping.#data!.delete(key);
47+
return mapping;
48+
}
49+
50+
/**
51+
* Prevents further modifications to this Mapping.
52+
*/
53+
freeze(): void {
54+
this.#frozen = true;
55+
}
56+
57+
isFrozen(): boolean {
58+
return this.#frozen;
59+
}
60+
61+
/**
62+
* We only need to fork if the Mapping is frozen (someone has a snapshot of
63+
* the current data), else we can just modify our data directly.
64+
*/
65+
#fork(): Mapping {
66+
if (this.#frozen) {
67+
return new Mapping(new Map(this.#data));
68+
}
69+
return this;
70+
}
71+
}

src/storage.ts

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Mapping } from "./mapping";
2+
import { FrozenRevert, Revert } from "./fork";
3+
import type { AsyncContext } from "./index";
4+
5+
/**
6+
* Storage is the (internal to the language) storage container of all
7+
* AsyncContext data.
8+
*
9+
* None of the methods here are exposed to users, they're only exposed to the AsyncContext class.
10+
*/
11+
export class Storage {
12+
static #current: Mapping = new Mapping(null);
13+
14+
/**
15+
* Get retrieves the current value assigned to the AsyncContext.
16+
*/
17+
static get<T>(key: AsyncContext<T>): T | undefined {
18+
return this.#current.get(key);
19+
}
20+
21+
/**
22+
* Set assigns a new value to the AsyncContext, returning a revert that can
23+
* undo the modification at a later time.
24+
*/
25+
static set<T>(key: AsyncContext<T>, value: T): FrozenRevert | Revert<T> {
26+
// If the Mappings are frozen (someone has snapshot it), then modifying the
27+
// mappings will return a clone containing the modification.
28+
const current = this.#current;
29+
const undo = current.isFrozen()
30+
? new FrozenRevert(current)
31+
: new Revert(current, key);
32+
this.#current = this.#current.set(key, value);
33+
return undo;
34+
}
35+
36+
/**
37+
* Restore will, well, restore the global storage state to state at the time
38+
* the revert was created.
39+
*/
40+
static restore<T>(revert: FrozenRevert | Revert<T>): void {
41+
this.#current = revert.restore(this.#current);
42+
}
43+
44+
/**
45+
* Snapshot freezes the current storage state, and returns a new revert which
46+
* can restore the global storage state to the state at the time of the
47+
* snapshot.
48+
*/
49+
static snapshot(): FrozenRevert {
50+
this.#current.freeze();
51+
return new FrozenRevert(this.#current);
52+
}
53+
54+
/**
55+
* Switch swaps the global storage state to the state at the time of a
56+
* snapshot, completely replacing the current state (and making it impossible
57+
* for the current state to be modified until the snapshot is reverted).
58+
*/
59+
static switch(snapshot: FrozenRevert): FrozenRevert {
60+
const previous = this.#current;
61+
this.#current = snapshot.restore(previous);
62+
63+
// Technically, previous may not be frozen. But we know its state cannot
64+
// change, because the only way to modify it is to restore it to the
65+
// Storage container, and the only way to do that is to have snapshot it.
66+
// So it's either snapshot (and frozen), or it's not and thus cannot be
67+
// modified.
68+
return new FrozenRevert(previous);
69+
}
70+
}

0 commit comments

Comments
 (0)