@dxos/async 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/async",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Async utilities.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -14,12 +14,14 @@
14
14
  "type": "module",
15
15
  "exports": {
16
16
  ".": {
17
+ "source": "./src/index.ts",
17
18
  "types": "./dist/types/src/index.d.ts",
18
- "browser": "./dist/lib/browser/index.mjs",
19
- "node": {
20
- "require": "./dist/lib/node/index.cjs",
21
- "default": "./dist/lib/node-esm/index.mjs"
22
- }
19
+ "import": "./dist/lib/index.mjs"
20
+ },
21
+ "./testing": {
22
+ "source": "./src/test-stream.ts",
23
+ "types": "./dist/types/src/test-stream.d.ts",
24
+ "import": "./dist/lib/testing.mjs"
23
25
  }
24
26
  },
25
27
  "types": "dist/types/src/index.d.ts",
@@ -30,12 +32,12 @@
30
32
  "dependencies": {
31
33
  "zen-observable": "^0.10.0",
32
34
  "zen-push": "^0.3.1",
33
- "@dxos/context": "0.10.0",
34
- "@dxos/debug": "0.10.0",
35
- "@dxos/invariant": "0.10.0",
36
- "@dxos/log": "0.10.0",
37
- "@dxos/node-std": "0.10.0",
38
- "@dxos/util": "0.10.0"
35
+ "@dxos/context": "0.11.1",
36
+ "@dxos/debug": "0.11.1",
37
+ "@dxos/invariant": "0.11.1",
38
+ "@dxos/log": "0.11.1",
39
+ "@dxos/node-std": "0.11.1",
40
+ "@dxos/util": "0.11.1"
39
41
  },
40
42
  "devDependencies": {
41
43
  "@types/zen-observable": "^0.8.3"
package/src/index.ts CHANGED
@@ -16,7 +16,6 @@ export * from './persistent-lifecycle';
16
16
  export * from './push-iterable';
17
17
  export * from './stream-to-array';
18
18
  export * from './task-scheduling';
19
- export * from './test-stream';
20
19
  export * from './testing';
21
20
  export * from './timeout';
22
21
  export * from './timer';
@@ -40,6 +40,10 @@ export class DeferredTask {
40
40
 
41
41
  scheduleTask(this._ctx, async () => {
42
42
  // The previous task might still be running, so we need to wait for it to finish.
43
+ //
44
+ // A single await suffices only because this is the one claim site and `_scheduled` collapses
45
+ // schedules into one waiter. A second path claiming `_currentTask` directly would let two
46
+ // callbacks run at once — see dxos/edge#758.
43
47
  await this._currentTask; // Can't be rejected.
44
48
 
45
49
  // Reset the flag. New tasks can now be scheduled. They would wait for the callback to finish.
@@ -2,7 +2,7 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { describe, expect, test } from 'vitest';
5
+ import { describe, test } from 'vitest';
6
6
 
7
7
  import { Context } from '@dxos/context';
8
8
 
@@ -10,7 +10,7 @@ import { sleep } from './timeout';
10
10
  import { UpdateScheduler } from './update-scheduler';
11
11
 
12
12
  describe('update-scheduler', () => {
13
- test('schedules updates', async () => {
13
+ test('schedules updates', async ({ expect }) => {
14
14
  let updates = 0;
15
15
 
16
16
  const ctx = new Context();
@@ -24,4 +24,194 @@ describe('update-scheduler', () => {
24
24
  await sleep(5);
25
25
  expect(updates).to.eq(1);
26
26
  });
27
+
28
+ // The flush-barrier contract: when `runBlocking` resolves, everything that was queued at call time
29
+ // has been fully processed — not claimed by a still-running pass the barrier failed to observe.
30
+ test('runBlocking does not resolve while a pass that claimed the queue is still in flight', async ({ expect }) => {
31
+ const ctx = new Context();
32
+ const queue: string[] = [];
33
+ const processed: string[] = [];
34
+ let inFlight = 0;
35
+ const gate = { resolve: () => {} };
36
+ const scheduler: UpdateScheduler = new UpdateScheduler(ctx, async () => {
37
+ const claimed = queue.splice(0, queue.length); // Claim synchronously, like _sendUpdates.
38
+ inFlight += claimed.length;
39
+ if (claimed.length > 0) {
40
+ await new Promise<void>((resolve) => {
41
+ gate.resolve = resolve;
42
+ setTimeout(resolve, 50); // The claimed batch takes real time to deliver.
43
+ });
44
+ }
45
+ processed.push(...claimed);
46
+ inFlight -= claimed.length;
47
+ });
48
+
49
+ // A long pass is running (claimed nothing of interest)...
50
+ queue.push('warmup');
51
+ scheduler.trigger();
52
+ await sleep(5);
53
+
54
+ // ...now the real work arrives; a trigger parks behind the running pass...
55
+ queue.push('document-data');
56
+ scheduler.trigger();
57
+ await sleep(0); // Let the trigger's task start and park on the running pass, as in production.
58
+
59
+ // ...and a flush arrives after it.
60
+ await scheduler.runBlocking();
61
+
62
+ // Everything that was queued when runBlocking was called must be fully processed — not claimed
63
+ // by a still-running pass the barrier failed to observe.
64
+ expect(inFlight, 'runBlocking resolved with a claimed batch still in flight').toEqual(0);
65
+ expect(processed).toContain('document-data');
66
+ await ctx.dispose();
67
+ });
68
+
69
+ // The callback is not reentrant: it typically drains shared queued state, so two passes running at
70
+ // once each claim part of the queue. Historically a throttled `trigger` checked for a running pass
71
+ test('the callback does not overlap itself when runBlocking races a throttled trigger', async ({ expect }) => {
72
+ const ctx = new Context();
73
+ let calls = 0;
74
+ let active = 0;
75
+ let maxActive = 0;
76
+ const scheduler = new UpdateScheduler(
77
+ ctx,
78
+ async () => {
79
+ const call = ++calls;
80
+ active++;
81
+ maxActive = Math.max(maxActive, active);
82
+ // Make the `runBlocking` pass outlast the triggered pass's throttle delay.
83
+ await sleep(call === 2 ? 200 : 10);
84
+ active--;
85
+ },
86
+ { maxFrequency: 10 }, // 100ms between passes.
87
+ );
88
+
89
+ // First pass consumes the throttle allowance, so the next `trigger` has to wait one out.
90
+ scheduler.trigger();
91
+ await sleep(40);
92
+
93
+ scheduler.trigger(); // Parks in its delay, having seen nothing running.
94
+ const blocking = scheduler.runBlocking(); // Starts a long pass while it sleeps.
95
+ await blocking;
96
+ await sleep(300); // Let the triggered pass run.
97
+
98
+ expect(calls, 'the coalesced flush run must have happened').toBeGreaterThanOrEqual(2);
99
+ expect(maxActive, 'two callbacks ran at once').toEqual(1);
100
+ await ctx.dispose();
101
+ });
102
+
103
+ // `runBlocking` is a flush barrier: a caller must observe that its batch was NOT handed off. The
104
+ // error belongs to the callers waiting on that run — and must not poison later barriers (a
105
+ // retained rejected pass would make `join`/dispose throw someone else's stale error).
106
+ test('runBlocking rejects with its run error and later barriers stay clean', async ({ expect }) => {
107
+ const ctx = new Context();
108
+ let fail = true;
109
+ const scheduler = new UpdateScheduler(ctx, async () => {
110
+ if (fail) {
111
+ throw new Error('send failed');
112
+ }
113
+ });
114
+
115
+ await expect(scheduler.runBlocking()).rejects.toThrow('send failed');
116
+
117
+ fail = false;
118
+ await expect(scheduler.join()).resolves.toBeUndefined();
119
+ await expect(scheduler.runBlocking()).resolves.toBeUndefined();
120
+ await ctx.dispose();
121
+ });
122
+
123
+ // The outcome handle must be read AFTER `trigger()`: when a run is already in flight the handle is
124
+ // still the finished-and-woken one from that run, so reading it first would return immediately and
125
+ // report a flush that never covered the caller's work.
126
+ test('runBlocking waits for a run that starts after it, not one already in flight', async ({ expect }) => {
127
+ const ctx = new Context();
128
+ const queue: string[] = [];
129
+ const drained: string[][] = [];
130
+ const scheduler = new UpdateScheduler(ctx, async () => {
131
+ drained.push(queue.splice(0, queue.length));
132
+ await sleep(30);
133
+ });
134
+
135
+ queue.push('first');
136
+ scheduler.trigger();
137
+ await sleep(5); // The first run has claimed 'first' and is still in flight.
138
+
139
+ queue.push('second'); // Enqueued while that run is running, so it cannot be in its drain.
140
+ await scheduler.runBlocking();
141
+
142
+ expect(drained.flat(), 'runBlocking resolved without draining later work').toContain('second');
143
+ await ctx.dispose();
144
+ });
145
+
146
+ // Concurrent flushes coalesce onto one run that observes everything enqueued before either call —
147
+ // they are all asking the same question ("has what I queued been handed off?"), so one drain
148
+ // answers all of them.
149
+ test('concurrent runBlocking callers coalesce into a single run', async ({ expect }) => {
150
+ const ctx = new Context();
151
+ const queue: string[] = [];
152
+ const drained: string[][] = [];
153
+ const scheduler = new UpdateScheduler(ctx, async () => {
154
+ drained.push(queue.splice(0, queue.length));
155
+ await sleep(10);
156
+ });
157
+
158
+ queue.push('a', 'b');
159
+ await Promise.all([scheduler.runBlocking(), scheduler.runBlocking()]);
160
+
161
+ expect(drained.length).toEqual(1);
162
+ expect(drained[0]).toEqual(['a', 'b']);
163
+ await ctx.dispose();
164
+ });
165
+
166
+ // Flush is urgent: it must not sit out the `maxFrequency` delay of a pass that was scheduled
167
+ // before it (the delay exists to pace background triggers, not barriers).
168
+ test('runBlocking skips the throttle delay', async ({ expect }) => {
169
+ const ctx = new Context();
170
+ let runs = 0;
171
+ const scheduler = new UpdateScheduler(
172
+ ctx,
173
+ async () => {
174
+ runs++;
175
+ },
176
+ { maxFrequency: 2 }, // 500ms between passes.
177
+ );
178
+
179
+ // Consume the allowance, then queue a throttled pass.
180
+ await scheduler.runBlocking();
181
+ scheduler.trigger();
182
+
183
+ const started = performance.now();
184
+ await scheduler.runBlocking();
185
+ const elapsed = performance.now() - started;
186
+
187
+ expect(runs).toBeGreaterThanOrEqual(2);
188
+ expect(elapsed, `flush waited out the throttle (${Math.round(elapsed)}ms)`).toBeLessThan(400);
189
+ await ctx.dispose();
190
+ });
191
+
192
+ // A dispose must release a parked flush rather than strand it: the runner it was waiting on may
193
+ // never reach its claim once the context is gone.
194
+ test('dispose releases a parked runBlocking', async ({ expect }) => {
195
+ const ctx = new Context();
196
+ const scheduler = new UpdateScheduler(
197
+ ctx,
198
+ async () => {
199
+ await sleep(5);
200
+ },
201
+ { maxFrequency: 1 }, // 1000ms delay — the flush parks behind it.
202
+ );
203
+
204
+ await scheduler.runBlocking(); // Consume the allowance.
205
+ scheduler.trigger();
206
+ // Park a flush on the throttled runner, then dispose before it can claim.
207
+ const parked = (async () => {
208
+ // Re-arm the delay skip is deliberately NOT done here: dispose must be what releases it.
209
+ const completionBefore = scheduler.runBlocking();
210
+ return completionBefore;
211
+ })();
212
+ await sleep(10);
213
+ await ctx.dispose();
214
+
215
+ await expect(parked).resolves.toBeUndefined();
216
+ });
27
217
  });
@@ -5,6 +5,7 @@
5
5
  import { type Context } from '@dxos/context';
6
6
 
7
7
  import { scheduleMicroTask } from './task-scheduling';
8
+ import { Trigger } from './trigger';
8
9
 
9
10
  export type UpdateSchedulerOptions = {
10
11
  /**
@@ -18,13 +19,41 @@ export type UpdateSchedulerOptions = {
18
19
  */
19
20
  const TIME_PERIOD = 1_000;
20
21
 
22
+ /** A claimed run's error (or `undefined`). Resolves, never rejects — `runBlocking` re-throws it. */
23
+ type RunResult = Promise<Error | undefined>;
24
+
25
+ /**
26
+ * Runs a non-reentrant callback at most once at a time, coalescing triggers (optionally rate-limited
27
+ * via {@link UpdateSchedulerOptions.maxFrequency}).
28
+ *
29
+ * The runner inside {@link trigger} is the only place the callback starts; `runBlocking` and
30
+ * `forceTrigger` funnel into it. That, plus `_scheduled` collapsing triggers into one pending
31
+ * runner, is what makes mutual exclusion hold by construction (dxos/edge#758).
32
+ */
21
33
  export class UpdateScheduler {
34
+ /** The running pass; never rejects, non-null exactly while a run is executing. */
35
+ private _currentTask: Promise<void> | null = null;
36
+ private _scheduled = false;
37
+
22
38
  /**
23
- * Promise that resolves when the callback is done.
24
- * Never rejects.
39
+ * Armed by the `trigger` that schedules a runner, woken by that runner when it claims — with the
40
+ * run's promise, which `Trigger` adopts, so waiters resume when the run *finishes*. Lets
41
+ * `runBlocking` await a run that does not exist in `_currentTask` yet.
42
+ *
43
+ * `reset()` abandons parked waiters, so it may only be armed in `trigger`'s non-coalesced branch:
44
+ * waiters park only while `_scheduled` is true, and that branch requires it false.
25
45
  */
26
- private _promise: Promise<any> | null = null;
27
- private _scheduled = false;
46
+ private _runOutcome = new Trigger<RunResult>();
47
+
48
+ /** Woken to make the pending runner skip its throttle delay. Replaced at claim time. */
49
+ private _skipDelay = new Trigger();
50
+
51
+ /**
52
+ * `runBlocking` callers in flight, incremented synchronously before triggering. A failing run uses
53
+ * it to tell whether someone will throw the error; counting microtask hops to a waiter's resume
54
+ * would be a race.
55
+ */
56
+ private _observers = 0;
28
57
 
29
58
  private _lastUpdateTime = -TIME_PERIOD;
30
59
 
@@ -34,24 +63,37 @@ export class UpdateScheduler {
34
63
  private readonly _params: UpdateSchedulerOptions = {},
35
64
  ) {
36
65
  _ctx.onDispose(async () => {
37
- await this._promise; // Context waits for callback to finish.
66
+ await this._currentTask; // Context waits for callback to finish.
67
+ // Release `runBlocking` callers parked on a runner that never claimed. NOOP if already woken.
68
+ this._runOutcome.wake(Promise.resolve(undefined));
38
69
  });
39
70
  }
40
71
 
72
+ get scheduled() {
73
+ return this._scheduled;
74
+ }
75
+
76
+ /**
77
+ * Schedule the callback to run asynchronously. Triggers issued while a run is pending or in flight
78
+ * coalesce into the next run.
79
+ */
41
80
  trigger(): void {
42
81
  if (this._scheduled) {
43
82
  return;
44
83
  }
84
+ this._scheduled = true;
85
+ this._runOutcome.reset(); // Only here — never on a coalesced trigger (see `_runOutcome`).
45
86
 
87
+ // The only claim site. A single await suffices: nothing else claims, so this is the only waiter.
46
88
  scheduleMicroTask(this._ctx, async () => {
47
- // The previous task might still be running, so we need to wait for it to finish.
48
- await this._promise; // Can't be rejected.
89
+ await this._currentTask; // Never rejects.
49
90
 
50
- // Check if the callback was called recently.
91
+ // Rate limiting. Suspending before the claim is safe only because no other claimant exists.
51
92
  if (this._params.maxFrequency) {
52
93
  const now = performance.now();
53
94
  const delay = this._lastUpdateTime + TIME_PERIOD / this._params.maxFrequency - now;
54
95
  if (delay > 0) {
96
+ const skipDelay = this._skipDelay;
55
97
  await new Promise<void>((resolve) => {
56
98
  const timeoutId = setTimeout(() => {
57
99
  clearContext();
@@ -62,53 +104,85 @@ export class UpdateScheduler {
62
104
  clearTimeout(timeoutId);
63
105
  resolve();
64
106
  });
107
+
108
+ // `runBlocking`/`forceTrigger` cut the delay short.
109
+ void skipDelay.wait().then(() => {
110
+ clearTimeout(timeoutId);
111
+ clearContext();
112
+ resolve();
113
+ });
65
114
  });
66
115
  }
67
116
  }
68
117
 
69
118
  if (this._ctx.disposed) {
119
+ this._runOutcome.wake(Promise.resolve(undefined));
70
120
  return;
71
121
  }
72
122
 
73
- this._lastUpdateTime = performance.now();
74
-
75
- // Reset the flag. New tasks can now be scheduled. They would wait for the callback to finish.
123
+ // The claim: one synchronous slice from here to the `wake` below.
76
124
  this._scheduled = false;
77
- this._promise = this._callback().then(
78
- () => {
79
- this._promise = null;
80
- },
81
- (error) => {
82
- this._promise = null;
83
- this._ctx.raise(error);
125
+ this._skipDelay = new Trigger();
126
+ this._lastUpdateTime = performance.now();
127
+ const run: RunResult = this._callback().then(
128
+ () => undefined,
129
+ (error): Error => {
130
+ // A thrown non-Error is typed away here, matching `Context.raise`'s signature.
131
+ const failure = error as Error;
132
+ // Unobserved failures go to the context, as trigger-path failures always have.
133
+ if (this._observers === 0) {
134
+ this._ctx.raise(failure);
135
+ }
136
+ return failure;
84
137
  },
85
138
  );
139
+ const task: Promise<void> = run.then(() => {
140
+ if (this._currentTask === task) {
141
+ this._currentTask = null;
142
+ }
143
+ });
144
+ this._currentTask = task;
145
+ this._runOutcome.wake(run);
86
146
  });
87
-
88
- this._scheduled = true;
89
147
  }
90
148
 
149
+ /**
150
+ * Run as soon as possible: schedules (coalescing with any pending run) and skips the throttle
151
+ * delay. Does not start a concurrent callback.
152
+ */
91
153
  forceTrigger(): void {
92
- scheduleMicroTask(this._ctx, async () => {
93
- this._callback().catch((err) => this._ctx.raise(err));
94
- });
154
+ this.trigger();
155
+ this._skipDelay.wake();
95
156
  }
96
157
 
97
158
  /**
98
159
  * Waits for the current task to finish if it is running.
99
- * Does not schedule a new task.
160
+ * Does not schedule a new task, and does not wait for one that is merely scheduled.
100
161
  */
101
162
  async join(): Promise<void> {
102
- await this._promise;
163
+ await this._currentTask;
103
164
  }
104
165
 
105
166
  /**
106
- * Force schedule the task to run and wait for it to finish.
167
+ * Ensure a run that observes everything enqueued so far, and wait for it to finish. Waits for the
168
+ * pending run to claim, then for its result; that run necessarily claims after this call, so its
169
+ * drain includes everything enqueued before it. Rejects with the run's error so a flush barrier can
170
+ * see its batch was not handed off. No-op once disposed.
107
171
  */
108
172
  async runBlocking(): Promise<void> {
109
- // The previous task might still be running, so we need to wait for it to finish.
110
- await this._promise; // Can't be rejected.
111
- this._promise = this._callback();
112
- await this._promise;
173
+ if (this._ctx.disposed) {
174
+ return;
175
+ }
176
+ this._observers++;
177
+ try {
178
+ this.trigger();
179
+ this._skipDelay.wake();
180
+ const error = await this._runOutcome.wait();
181
+ if (error !== undefined) {
182
+ throw error;
183
+ }
184
+ } finally {
185
+ this._observers--;
186
+ }
113
187
  }
114
188
  }