Skip to content

Commit 7d81c88

Browse files
os-zhuangclaude
andauthored
fix(metadata-protocol): close() terminates watch iterators instead of emitting a drain event (#11021) (#11136)
`SysMetadataRepository.close()` modelled shutdown as a metadata event — `{ seq: -1, ref: { org: '', type: 'view', name: '_close' } }` broadcast through the same dispatch closure real events pass, then `watchers.clear()`. Both of that closure's guards reject it: `matchesFilter` drops it for any subscription naming an org (the synthetic ref's org is the empty string), a type other than `view`, or a name; and `evt.seq <= since` holds for -1 against every real seq. Dropped and then unsubscribed, nothing could settle the parked promise and the consumer's `for await` never returned. The subscriptions that passed both guards were no better off: they received the synthetic event as a real one — a `view` named `_close`, deleted, at seq -1, which MetadataManager turns into a cache invalidation and re-emits to Studio's HMR stream — and hung on the next pull anyway, because delivering an event does not end an iterator. The watcher registry now holds each subscription's terminator next to its event sink, and `close()` runs the terminator — the same routine `iterator.return()` runs. Invariant 8 in metadata-core's repository.ts states the contract that was unstated, and records FileSystemRepository's non-conformance (#11127). Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y Co-authored-by: Claude <noreply@anthropic.com>
1 parent bbe643c commit 7d81c88

4 files changed

Lines changed: 300 additions & 27 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
`SysMetadataRepository.close()` now terminates every live `watch()` iterator
6+
instead of broadcasting a synthetic drain event (#11021). A consumer holding a
7+
`for await` over `watch()` at shutdown could hang forever, and the hang was
8+
worst for the subscription shapes most likely to be in use.
9+
10+
Shutdown was modelled as a metadata event — `{ seq: -1, ref: { org: '', type:
11+
'view', name: '_close' } }` — pushed through the same dispatch closure real
12+
events pass, followed by clearing the watcher registry. Both of that closure's
13+
guards reject it:
14+
15+
- `matchesFilter` drops it for any subscription naming an `org` (the synthetic
16+
ref's org is the empty string), a `type` other than `view`, or a `name`
17+
`MetadataCache.start()` with any non-empty `watchFilter` is exactly that
18+
shape;
19+
- the `since` drop-filter drops it for every numeric-`since` subscription,
20+
since `-1 <= since` holds against every real seq.
21+
22+
Dropped and then unsubscribed, nothing could settle the parked promise. Measured
23+
before the fix: `watch({org:'system'}, seq)` and `watch({org:'system'})` were
24+
both still unsettled 500ms after `close()`. The empty-filter case looked drained
25+
and was not — it received the synthetic event as a *real* one (a `view` named
26+
`_close`, deleted, at seq -1, which `MetadataManager` turns into a cache
27+
invalidation and re-emits to Studio's HMR stream) and then hung on the next pull
28+
anyway, because delivering an event does not end an iterator.
29+
30+
`close()` now runs each subscription's terminator — the same routine the
31+
consumer's own `iterator.return()` runs — so a parked `next()` settles with
32+
`{ done: true }` and no value, and so does every later one. Consumers no longer
33+
need to recognise a shutdown event, because there is no longer one to recognise;
34+
nothing in the repo ever named the `_close` sentinel.
35+
36+
The contract this repairs was unstated, which is why the two defensible repair
37+
shapes were both arguable. It is stated now: invariant 8 in
38+
`packages/metadata-core/src/repository.ts` ("shutdown terminates; it does not
39+
emit") says what a repository-level `close()` owes a pending iterator, and
40+
records the one measured non-conformance among today's implementations
41+
(`FileSystemRepository`, filed as #11127).

packages/metadata-core/src/repository.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,37 @@
5050
* implementation was read first.
5151
* 7. **Tombstones, not holes.** `delete` produces a `delete` event;
5252
* `get` returns null but `history` still shows the lineage.
53+
* 8. **Shutdown terminates; it does not emit.** An implementation that offers
54+
* a repository-level shutdown (`close()`) MUST end every live `watch()`
55+
* iterator: a `next()` parked at that moment settles with `done: true` and
56+
* no value, and every later `next()` does the same. That is the identical
57+
* observation the consumer's own `iterator.return()` produces, deliberately
58+
* — so no consumer has to tell "the repository shut down under me" apart
59+
* from "I broke my own loop". Events still queued or unreplayed at that
60+
* moment MAY be dropped, on both paths alike.
61+
*
62+
* **Shutdown MUST NOT be delivered AS an event.** Written as a MUST NOT
63+
* because it was tried, and both of its halves were measured (#11021). A
64+
* synthetic "we are closing" event is subject to the very filters `watch()`
65+
* applies to real ones, so the subscriptions that most need draining are
66+
* exactly the ones that drop it: any non-empty `filter` rejects a ref
67+
* invented to belong to no org, and any numeric `since` rejects a seq
68+
* invented to precede every real one. Those consumers then wait forever,
69+
* because the same shutdown unsubscribes them. Meanwhile a consumer whose
70+
* filter happens to admit it is not rescued either — it reads a real
71+
* metadata change for a ref that never existed (invalidating caches and
72+
* re-emitting downstream), and its iterator hangs on the *next* pull
73+
* regardless, because delivering an event has never ended one.
74+
*
75+
* Stated conditionally because `close()` is not on the interface below;
76+
* 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.
5384
*/
5485

5586
import type {
@@ -112,7 +143,10 @@ export interface MetadataRepository {
112143
* already committed MAY also be delivered, but callers MUST NOT rely
113144
* on it; a caller that needs them passes a numeric `since` or reads
114145
* `history()`. See invariant 6.
115-
* - Stay open until the consumer breaks the loop.
146+
* - Stay open until the consumer breaks the loop — or until the
147+
* repository shuts down under it, where an implementation offers a
148+
* `close()`. Both end the stream the same way: `done: true`, no value,
149+
* never a synthetic event standing in for shutdown. See invariant 8.
116150
* - Survive transient backend disconnects (implementation's choice
117151
* how to resume — Postgres LISTEN reconnect, JSONL tail, etc.).
118152
*/

packages/metadata-protocol/src/sys-metadata-repository.contract.test.ts

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ import {
5858
hashSpec,
5959
} from '@objectstack/metadata-core';
6060
import { runRepositoryContractTests } from '@objectstack/metadata-core/testing';
61-
import type { MetadataEvent } from '@objectstack/metadata-core';
61+
import type { MetadataEvent, WatchFilter } from '@objectstack/metadata-core';
6262
import { SysMetadataRepository } from './sys-metadata-repository.js';
6363

6464
interface Row {
@@ -448,3 +448,134 @@ describe('SysMetadataRepository — invariant 6, both halves (#10842)', () => {
448448
await iter.return?.(undefined);
449449
});
450450
});
451+
452+
/**
453+
* #11021 — what `close()` owes a pending iterator.
454+
*
455+
* `close()` used to model shutdown as a metadata EVENT: it broadcast a
456+
* synthetic `{ seq: -1, ref: { org: '', type: 'view', name: '_close' } }`
457+
* through the same `dispatch` closure every real event passes, and then
458+
* cleared the watcher set. Both of that closure's guards reject it:
459+
*
460+
* - `matchesFilter` — the synthetic ref's org is the EMPTY STRING and its
461+
* type is always `view`, so any subscription naming an `org`, a `type`
462+
* other than `view`, or a `name` drops it;
463+
* - the `since` drop — `-1 <= since` holds against every real seq, so every
464+
* numeric-`since` subscription drops it too.
465+
*
466+
* Dropped, and then unsubscribed by `watchers.clear()`: nothing could ever
467+
* settle the promise, and the consumer's `for await` never returned. The
468+
* matrix below is the one the card was filed on, plus the row that is easy to
469+
* misread — an EMPTY filter with no `since` passed both guards, so the pending
470+
* pull settled, but it settled with `done: false` carrying the synthetic event
471+
* as though a view named `_close` had been deleted at seq -1. The iterator
472+
* then hung on the NEXT pull just like the other two.
473+
*
474+
* The repair is that shutdown is not an event. `close()` runs the same
475+
* termination routine `iterator.return()` runs, on every live watcher — which
476+
* is what these cases assert, and it is why the assertion is on `done: true`
477+
* with NO value rather than on "something arrived".
478+
*/
479+
describe('SysMetadataRepository — close() terminates every live watcher (#11021)', () => {
480+
const ref = { org: 'system', type: 'view' as const, name: 'sample_view' };
481+
482+
const PENDING = Symbol('still-pending');
483+
484+
/**
485+
* Settle-or-report-pending. Every case here has to tell "settled with
486+
* `done: true`" apart from "still unsettled", and a bare `await` on the
487+
* unsettled shape hangs the RUN rather than failing the case.
488+
*/
489+
function within<T>(p: Promise<T>, ms: number): Promise<T | typeof PENDING> {
490+
return Promise.race([
491+
p,
492+
new Promise<typeof PENDING>((resolve) => setTimeout(() => resolve(PENDING), ms)),
493+
]);
494+
}
495+
496+
/**
497+
* Let the durable-replay promise settle, so the pull under test is genuinely
498+
* PARKED on the live listener rather than still inside `await replayReady`.
499+
* Without this the numeric-`since` row would prove less than it claims.
500+
*/
501+
const parked = () => new Promise((resolve) => setTimeout(resolve, 50));
502+
503+
it.each([
504+
['filtered + numeric `since`', { org: 'system' } as WatchFilter, true],
505+
// ⭐ The row that proves the org-filter half bites ON ITS OWN. A fix
506+
// tested only against the `since` half looks complete and leaves this —
507+
// `MetadataCache.start()` with any non-empty `watchFilter` — hanging.
508+
['filtered, no `since` at all', { org: 'system' } as WatchFilter, false],
509+
// The row that looked drained and was not: it received the synthetic
510+
// event, then hung on the next pull.
511+
['empty filter, no `since`', {} as WatchFilter, false],
512+
])('close() settles the pending next() with done:true — %s', async (_label, filter, withSince) => {
513+
const repo = makeRepo();
514+
const a = await repo.put(ref, { label: '1' }, { parentVersion: null, actor: 't' });
515+
516+
const iter = (withSince ? repo.watch(filter, a.seq) : repo.watch(filter))[
517+
Symbol.asyncIterator
518+
]();
519+
const pending = iter.next();
520+
await parked();
521+
522+
repo.close();
523+
524+
// Termination — not a synthetic event wearing `done: false`.
525+
expect(await within(pending, 500)).toEqual({ value: undefined, done: true });
526+
// …and the iterator is FINISHED, not merely unblocked once. This is the
527+
// half the old empty-filter row hid: one pull settled, the next hung.
528+
expect(await within(iter.next(), 500)).toEqual({ value: undefined, done: true });
529+
});
530+
531+
it('finishes a watcher that has no pull outstanding at close() time', async () => {
532+
const repo = makeRepo();
533+
const iter = repo.watch({ org: 'system' })[Symbol.asyncIterator]();
534+
await parked();
535+
536+
repo.close();
537+
538+
expect(await within(iter.next(), 500)).toEqual({ value: undefined, done: true });
539+
});
540+
541+
it('terminates EVERY live watcher, and is idempotent', async () => {
542+
const repo = makeRepo();
543+
const iters = [
544+
repo.watch({ org: 'system' })[Symbol.asyncIterator](),
545+
repo.watch({ type: 'view' })[Symbol.asyncIterator](),
546+
repo.watch({ org: 'system', type: 'view', name: 'sample_view' })[Symbol.asyncIterator](),
547+
repo.watch({})[Symbol.asyncIterator](),
548+
];
549+
const pendings = iters.map((it) => it.next());
550+
await parked();
551+
552+
repo.close();
553+
repo.close();
554+
555+
for (const p of pendings) {
556+
expect(await within(p, 500)).toEqual({ value: undefined, done: true });
557+
}
558+
});
559+
560+
it('ends the stream exactly the way the consumer’s own `return()` does', async () => {
561+
// The contract sentence, as a comparison rather than a claim: a consumer
562+
// that breaks its loop and a consumer whose repository shut down under it
563+
// observe the SAME thing, so neither has to special-case the other.
564+
const byReturn = makeRepo();
565+
const a = byReturn.watch({ org: 'system' })[Symbol.asyncIterator]();
566+
const aPending = a.next();
567+
await parked();
568+
void a.return?.(undefined);
569+
570+
const byClose = makeRepo();
571+
const b = byClose.watch({ org: 'system' })[Symbol.asyncIterator]();
572+
const bPending = b.next();
573+
await parked();
574+
byClose.close();
575+
576+
const viaReturn = await within(aPending, 500);
577+
const viaClose = await within(bPending, 500);
578+
expect(viaClose).toEqual(viaReturn);
579+
expect(viaClose).toEqual({ value: undefined, done: true });
580+
});
581+
});

0 commit comments

Comments
 (0)