Skip to content

Commit 46644e2

Browse files
fix(metadata-fs): FileSystemRepository.close() terminates every live watch() iterator (#11127) (#11198)
* test(metadata-fs): pin the invariant-8 shutdown contract (red baseline for #11127) * fix(metadata-fs): close() terminates every live watch() iterator (#11127) * chore(changeset): FileSystemRepository.close() terminates watch iterators --------- Co-authored-by: Claude <pm@objectstack.ai>
1 parent 3b2af5e commit 46644e2

6 files changed

Lines changed: 386 additions & 7 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/metadata-fs": patch
3+
---
4+
5+
`FileSystemRepository.close()` now terminates every live `watch()` iterator
6+
instead of leaving it parked (#11127). A consumer holding a `for await` over
7+
`watch()` at shutdown never saw its loop end — on a repository that was already
8+
gone.
9+
10+
`close()` retired the chokidar watcher and the resync sweep and stopped there.
11+
It never reached the event broker, and the broker had no teardown of its own:
12+
`subscribe`/`unsubscribe` add to and delete from a plain `Set`, and nothing else
13+
emptied it. Each iterator parks its pending `next()` on a `waiter` that only two
14+
things can settle — a broker `push`, or the iterator's own terminator, which ran
15+
from `iterator.return()`/`throw()` and from nowhere else. After `close()` the
16+
chokidar source was gone so no `push` could arrive, and the subscriber was still
17+
registered with nothing left to run its terminator.
18+
19+
Unlike the sibling defect in `SysMetadataRepository` (#11021) this was not
20+
filter-dependent: there was no drain attempt at all, so every subscription shape
21+
hung, `watch({})` included. Measured before the fix: nine cases —
22+
`watch({org}, seq)`, `watch({org})`, `watch({})`, a ref-exact filter, a watcher
23+
over the real chokidar watcher, a watcher with no pull outstanding, four
24+
concurrent watchers, and the `return()`-symmetry comparison — were all still
25+
unsettled 2s after `close()`. `MetadataManager.startRepositoryWatch()`, which
26+
awaits `iter.next()` in a loop, is exactly the shape that hung.
27+
28+
The broker now holds each subscription's terminator next to its event sink, and
29+
`close()` runs every terminator — the same routine the consumer's own
30+
`iterator.return()` runs, so a parked `next()` settles with `{ done: true }` and
31+
no value, and so does every later one. Shutdown is deliberately **not** delivered
32+
as an event: a synthetic drain event is subject to the very filters `watch()`
33+
applies to real ones, and delivering an event has never ended an iterator
34+
(invariant 8, `@objectstack/metadata-core`'s `repository.ts`).
35+
36+
One narrower path is closed with it. `watch()` returns a deferred iterable whose
37+
subscriber registers only once the eager log read resolves, so a `close()`
38+
landing inside that window swept a broker the subscription had not yet joined —
39+
the same forever-parked shape by a different route. `watch()` now carries the
40+
close generation it was opened under, and a subscription that arrives after a
41+
shutdown terminates on arrival.
42+
43+
Invariant 8 named `FileSystemRepository` as its one known non-conformance. With
44+
this change the invariant has no declared exceptions, and its text says so.

packages/metadata-core/src/repository.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,18 @@
7474
*
7575
* Stated conditionally because `close()` is not on the interface below;
7676
* it is offered by some implementations and not others. Where it is
77-
* offered, this is what it owes. Measured across today's three:
78-
* `SysMetadataRepository` conforms; `InMemoryRepository` offers no
79-
* repository-level shutdown at all, so its iterators end only through
80-
* `return()`; `FileSystemRepository.close()` retires the filesystem watcher
81-
* and the resync sweep but never reaches its event broker, so a parked
82-
* iterator stays parked — the one non-conformance, filed as #11127 rather
83-
* than quietly omitted from this row.
77+
* offered, this is what it owes. Measured across today's three, and there
78+
* are **no declared exceptions**: `SysMetadataRepository` conforms (#11021);
79+
* `FileSystemRepository` conforms (#11127 — its `close()` used to retire
80+
* the filesystem watcher and the resync sweep without ever reaching its
81+
* event broker, leaving a parked iterator parked for every subscription
82+
* shape, `watch({})` included; it now runs each subscription's terminator);
83+
* `InMemoryRepository` offers no repository-level shutdown at all, so its
84+
* iterators end only through `return()`.
85+
*
86+
* A new implementation that offers `close()` joins that list or it does not
87+
* conform — this row carries the measurement, so an implementation added
88+
* without one is the omission, not an exception.
8489
*/
8590

8691
import type {

packages/metadata-fs/src/repository.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
* - The root directory is created **on the first write, not on attach**
2020
* (#7000). Attaching and reading a repository whose root does not exist
2121
* is legal and answers "empty"; see `start()` / `ensureRoot()`.
22+
* - `close()` ENDS every live `watch()` iterator — the same observation the
23+
* consumer's own `iterator.return()` produces, never a synthetic event
24+
* standing in for shutdown (#11127; invariant 8 in `metadata-core`).
2225
*/
2326

2427
import fs from 'node:fs/promises';
@@ -132,6 +135,15 @@ export class FileSystemRepository implements MetadataRepository {
132135
* the first degradation). An entry is cleared when that path reads again.
133136
*/
134137
private readonly resyncFaults = new Set<string>();
138+
/**
139+
* Bumped by every `close()`. `watch()` reads it before its deferred log
140+
* replay starts and hands the comparison to `createWatchIterable`, so a
141+
* subscription that registers AFTER the shutdown sweep terminates on
142+
* arrival instead of parking forever (#11127). A counter rather than a
143+
* boolean because `start()` may follow `close()`: a repository restart must
144+
* not poison the watchers opened after it.
145+
*/
146+
private closeGeneration = 0;
135147

136148
constructor(opts: FileSystemRepositoryOptions) {
137149
this.org = opts.org;
@@ -195,10 +207,42 @@ export class FileSystemRepository implements MetadataRepository {
195207
if (this.started && !this.disableWatch && !this.watcher) this.startWatcher();
196208
}
197209

210+
/**
211+
* Shut the repository down, ending every live `watch()` iterator.
212+
*
213+
* **Shutdown terminates; it does not emit** — invariant 8 in
214+
* `@objectstack/metadata-core`'s `repository.ts`, and the reason this method
215+
* reaches the broker at all. It used to retire the chokidar watcher and the
216+
* resync sweep and stop there. The broker has no teardown of its own
217+
* (`subscribe`/`unsubscribe` add to and delete from a plain `Set`), and each
218+
* iterator parks its pending `next()` on a `waiter` that only a broker
219+
* `push` or the iterator's own terminator can settle. After `close()` the
220+
* chokidar source was gone, so no `push` could arrive; the subscriber was
221+
* still registered, and nothing ran its terminator. A consumer holding a
222+
* `for await` at shutdown — `MetadataManager.startRepositoryWatch()` is
223+
* exactly that shape — therefore never saw its loop end, for EVERY
224+
* subscription shape including `watch({})`.
225+
*
226+
* Termination is expressed as termination: each subscription's
227+
* `terminate()`, which is the same routine the consumer's own
228+
* `iterator.return()` runs, so no consumer has to tell "the repository shut
229+
* down under me" apart from "I broke my own loop". A synthetic drain event
230+
* would be the wrong shape and was measured to be so (#11021): the
231+
* subscriptions most in need of draining are exactly the ones whose filter
232+
* or numeric `since` drops it, and delivering an event has never ended an
233+
* iterator.
234+
*/
198235
async close(): Promise<void> {
199236
// Retire the sweep BEFORE awaiting the watcher, so a sweep that lands
200237
// during `watcher.close()` cannot reschedule itself behind our back.
201238
this.stopResync();
239+
// Terminate BEFORE the await for the same reason: a `watcher.close()` that
240+
// rejects must not leave a consumer's `for await` parked forever, and a
241+
// straggler event from the dying watcher has no one left to reach. Events
242+
// still queued or unreplayed at this moment MAY be dropped (invariant 8),
243+
// on this path and on `return()` alike.
244+
this.closeGeneration++;
245+
this.broker.terminateAll();
202246
if (this.watcher) {
203247
await this.watcher.close();
204248
this.watcher = null;
@@ -284,6 +328,11 @@ export class FileSystemRepository implements MetadataRepository {
284328
if (matchEvent(evt, filter)) replay.push(evt);
285329
}
286330
})();
331+
// Read BEFORE the read above can complete: the subscriber below is
332+
// registered only when it does, which is a window `close()`'s sweep cannot
333+
// see (#11127). Compared on arrival, a shutdown inside that window ends
334+
// this iterator instead of parking it.
335+
const generation = this.closeGeneration;
287336
// We must await replay before returning, but the public API is
288337
// sync-returning AsyncIterable. Wrap in a deferred iterable.
289338
return deferredIterable(promise.then(() =>
@@ -294,6 +343,7 @@ export class FileSystemRepository implements MetadataRepository {
294343
broker: this.broker,
295344
matches: matchEvent,
296345
branchKeyOf: (e) => e.ref.org,
346+
arrivesClosed: () => this.closeGeneration !== generation,
297347
}),
298348
));
299349
}

packages/metadata-fs/src/sync.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,42 @@ export class KeyedMutex {
3131
}
3232
}
3333

34+
/**
35+
* One live `watch()` subscription, as a record rather than a bare event sink.
36+
*
37+
* Both halves live together because SHUTDOWN NEEDS THE SECOND ONE. A registry
38+
* of event sinks can only express shutdown as "send an event", and an event is
39+
* precisely what a filtered or numeric-`since` subscriber is entitled to drop
40+
* — and delivering one has never ended an iterator anyway. See invariant 8 in
41+
* `@objectstack/metadata-core`'s `repository.ts` (#11021, #11127).
42+
*/
3443
export interface BrokerSubscriber {
3544
filter: WatchFilter;
3645
closed: boolean;
3746
push(evt: MetadataEvent): void;
47+
/**
48+
* Ends this subscription's iterator: settles a parked `next()` with
49+
* `{ done: true }` and no value, and unregisters. The SAME routine
50+
* `iterator.return()` runs, so a consumer that breaks its loop and a
51+
* consumer whose repository shut down under it observe the same thing.
52+
*/
53+
terminate(): void;
3854
}
3955

4056
export interface EventBroker {
4157
subscribe(sub: BrokerSubscriber): void;
4258
unsubscribe(sub: BrokerSubscriber): void;
4359
publish(evt: MetadataEvent): void;
60+
/**
61+
* Terminate every live subscription. This is what `FileSystemRepository`'s
62+
* repository-level `close()` owes a pending iterator (#11127): before it
63+
* existed, `close()` retired the chokidar watcher and the resync sweep and
64+
* stopped there, so the source that could settle a parked `next()` was gone
65+
* while the subscriber stayed registered with nothing left to settle it.
66+
*
67+
* Idempotent, and a no-op when nothing is watching.
68+
*/
69+
terminateAll(): void;
4470
}
4571

4672
export function createBroker(matches: (evt: MetadataEvent, filter: WatchFilter) => boolean): EventBroker {
@@ -55,5 +81,19 @@ export function createBroker(matches: (evt: MetadataEvent, filter: WatchFilter)
5581
s.push(evt);
5682
}
5783
},
84+
terminateAll: () => {
85+
// Snapshot and clear BEFORE terminating: `terminate()` unregisters
86+
// itself, and mutating a Set under its own iteration is how the second
87+
// subscriber gets skipped.
88+
const snapshot = Array.from(subs);
89+
subs.clear();
90+
for (const s of snapshot) {
91+
try {
92+
s.terminate();
93+
} catch {
94+
/* one wedged consumer must not strand the rest */
95+
}
96+
}
97+
},
5898
};
5999
}

packages/metadata-fs/src/watch-iterable.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ export interface CreateWatchIteratorArgs {
1818
/** Returns true if `evt.ref` matches `filter`. */
1919
matches: (evt: MetadataEvent, filter: WatchFilter) => boolean;
2020
branchKeyOf: (evt: MetadataEvent) => string;
21+
/**
22+
* Checked ONCE, immediately after this subscription is registered. True
23+
* means the repository shut down while `watch()`'s deferred log replay was
24+
* still in flight, so this subscription arrived after `close()` had already
25+
* swept the broker. It is terminated on arrival rather than left parked on a
26+
* broker nobody will publish to or drain again (#11127).
27+
*/
28+
arrivesClosed?: () => boolean;
2129
}
2230

2331
export function createWatchIterable(
@@ -32,6 +40,10 @@ export function createWatchIterable(
3240
const subscriber: BrokerSubscriber = {
3341
filter: args.filter,
3442
closed: false,
43+
// Assigned below, once `close` exists. Termination and the consumer's own
44+
// `return()` are ONE routine, deliberately: invariant 8 requires shutdown
45+
// to be indistinguishable from `iterator.return()`.
46+
terminate: () => undefined,
3547
push: (evt) => {
3648
if (subscriber.closed) return;
3749
const k = evtKey(evt);
@@ -84,6 +96,13 @@ export function createWatchIterable(
8496
return { value: undefined, done: true };
8597
};
8698

99+
// The terminator `repo.close()` runs. Same routine as `return()` below.
100+
subscriber.terminate = close;
101+
102+
// Shutdown that landed while the deferred log replay was in flight — this
103+
// subscription missed the sweep, so it terminates on arrival (#11127).
104+
if (args.arrivesClosed?.()) close();
105+
87106
const iterator: AsyncIterator<MetadataEvent> = {
88107
next: () => {
89108
if (closed) return Promise.resolve({ value: undefined, done: true });

0 commit comments

Comments
 (0)