diff --git a/docs/adr/0005-parser-independent-relation-completion.md b/docs/adr/0005-parser-independent-relation-completion.md index 6f21ab5..9c4cb42 100644 --- a/docs/adr/0005-parser-independent-relation-completion.md +++ b/docs/adr/0005-parser-independent-relation-completion.md @@ -463,6 +463,28 @@ A higher accepted invalidation or response atomically installs the new epoch, clears older scope cache entries, supersedes affected work, and then advances each subscribed session revision exactly once. +The epoch coordinator accepts one optional package-owned, receiver-free +transition-preparation closure. For an accepted invalidation baseline or +advance, and for a higher response, it snapshots the active revision audience, +installs the epoch and notification sequence, and then calls that closure with +a copied scope and the already-frozen epoch. The closure synchronously detaches +incompatible work, cache entries, and epoch-specific gates and may return one +receiver-free dispatch closure. The no-hook and no-dispatch paths allocate no +transition wrapper state. Only after transition and session preparation +has finished does the coordinator settle the producing response, invoke the +transition dispatch, and dispatch session listeners. Provider aborts and work +settlement therefore observe every prepared session revision, while listener +code observes already-retired catalog work. Reentrant transition dispatch +commands remain behind the current revision dispatch in the epoch FIFO. +A missing or `null` dispatch is valid. A thrown transition preparation or +non-function result is an internal contract failure and disposes the +coordinator fail-closed. Dispatch is synchronously exact-return: it must return +`undefined`. A throw or any other return value disposes the coordinator after +state preparation and suppresses later revision listeners; an accidental +Promise result receives best-effort detached rejection draining. Coordinator +disposal revokes the preparation closure before external cleanup. The hook +remains package-private and is not a provider or session extension point. + A search that discovers a higher epoch supersedes itself instead of publishing against its older captured revision. Pages and cache entries from different epochs are never merged. @@ -523,13 +545,21 @@ A thrown subscription or malformed returned cleanup value discards that buffer and disables automatic invalidation for the live scope; explicit search remains available. The returned cleanup closure is itself untrusted: the service captures only a -function, calls it with `this === undefined` at most once, and isolates -malformed values, thrown cleanup, and rejected or hostile thenable results. -Detached cleanup settlement retains no coordinator state. A closure avoids a -structural TypeScript contract that would accept class instances or inherited -methods which the hostile runtime boundary could not safely validate. Dropping -the last owner removes the complete scope incarnation before cleanup. A later -join creates a new unobserved incarnation and may attempt a fresh subscription. +function and calls it with `this === undefined` at most once. Its exact +synchronous contract returns `undefined`; TypeScript therefore rejects async +cleanup functions rather than silently accepting them through `void` +assignability. A throw or any other return value quarantines the coordinator +after the subscription is inert. Accidental ordinary Promise or thenable +returns receive best-effort detached rejection draining, but a same-realm +callback can always create a poisoned or independent unhandled rejection that +no JavaScript library can retroactively contain. Legitimate asynchronous +teardown must consume or report its own failure before the cleanup closure +returns `undefined`. The valid `undefined` path returns without Promise +allocation or a microtask. A closure avoids a structural TypeScript +contract that would accept class instances or inherited methods which the +hostile runtime boundary could not safely validate. Dropping the last owner +removes the complete scope incarnation before cleanup. A later join creates a +new unobserved incarnation and may attempt a fresh subscription. Malformed, duplicate, stale, or token-conflicting invalidations do not mutate state and do not tear down an otherwise valid subscription. Every raw callback diff --git a/src/vnext/__tests__/relation-catalog-epoch-coordinator.bench.ts b/src/vnext/__tests__/relation-catalog-epoch-coordinator.bench.ts index 66f0650..e8b327e 100644 --- a/src/vnext/__tests__/relation-catalog-epoch-coordinator.bench.ts +++ b/src/vnext/__tests__/relation-catalog-epoch-coordinator.bench.ts @@ -8,6 +8,7 @@ import { } from "../relation-catalog-epoch-coordinator.js"; import type { SqlCatalogEpochCoordinator, + SqlCatalogEpochTransitionTarget, SqlCatalogResponseEpochDecision, SqlCatalogResponseEpochSubmissionResult, SqlCatalogRevisionTarget, @@ -110,7 +111,10 @@ function requireDecision( return decision; } -function createFixture(memberCount: number): CoordinatorFixture { +function createFixture( + memberCount: number, + prepareEpochTransition?: SqlCatalogEpochTransitionTarget, +): CoordinatorFixture { let invalidationListener: | ((this: void, event: unknown) => void) | null = null; @@ -141,6 +145,7 @@ function createFixture(memberCount: number): CoordinatorFixture { } const created = createSqlCatalogEpochCoordinator( captured.value, + prepareEpochTransition, ); if (created.status !== "created") { return benchmarkFailure("coordinator creation was unavailable"); @@ -416,6 +421,32 @@ describe("relation catalog epoch coordinator", () => { }, ); + bench( + "process a bounded 256-event storm with a null transition dispatch", + () => { + let transitions = 0; + const fixture = createFixture(1, () => { + transitions += 1; + return null; + }); + for ( + let generation = 1; + generation <= MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW; + generation += 1 + ) { + fixture.emitInvalidation(generation); + } + if ( + transitions !== MAX_CATALOG_CALLBACKS_PER_RESET_WINDOW + ) { + benchmarkFailure( + "configured transition hook lost an accepted epoch", + ); + } + fixture.dispose(); + }, + ); + bench("fan out a bounded 256-event storm to 256 members", () => { const fixture = createFixture(256); for ( diff --git a/src/vnext/__tests__/relation-catalog-epoch-coordinator.test.ts b/src/vnext/__tests__/relation-catalog-epoch-coordinator.test.ts index f0d0a50..24413b3 100644 --- a/src/vnext/__tests__/relation-catalog-epoch-coordinator.test.ts +++ b/src/vnext/__tests__/relation-catalog-epoch-coordinator.test.ts @@ -15,6 +15,7 @@ import { MAX_CATALOG_MEMBERSHIPS_PER_SCOPE, type SqlCatalogEpochCapture, type SqlCatalogEpochCoordinator, + type SqlCatalogEpochTransitionTarget, type SqlCatalogResponseEpochDecision, type SqlCatalogResponseEpochSubmissionResult, type SqlCatalogRevisionTarget, @@ -61,9 +62,27 @@ function capturedProvider( function coordinator( subscribe?: RawSubscribe, id = "catalog", + prepareEpochTransition?: SqlCatalogEpochTransitionTarget, ): SqlCatalogEpochCoordinator { const result = createSqlCatalogEpochCoordinator( capturedProvider(subscribe, id), + prepareEpochTransition, + ); + expect(result.status).toBe("created"); + if (result.status !== "created") { + throw new Error("Expected a coordinator fixture"); + } + return result.coordinator; +} + +function coordinatorWithRawTransition( + prepareEpochTransition: unknown, + subscribe?: RawSubscribe, +): SqlCatalogEpochCoordinator { + const result = Reflect.apply( + createSqlCatalogEpochCoordinator, + undefined, + [capturedProvider(subscribe), prepareEpochTransition], ); expect(result.status).toBe("created"); if (result.status !== "created") { @@ -194,6 +213,29 @@ describe("catalog epoch coordinator construction and membership", () => { expect(Object.isFrozen(owner)).toBe(true); }); + it("rejects a non-function epoch transition target without inspecting it", () => { + let reads = 0; + const target = new Proxy( + {}, + { + get() { + reads += 1; + throw new Error("hostile transition target"); + }, + }, + ); + expect( + Reflect.apply(createSqlCatalogEpochCoordinator, undefined, [ + capturedProvider(), + target, + ]), + ).toEqual({ + reason: "invalid-transition-target", + status: "unavailable", + }); + expect(reads).toBe(0); + }); + it("validates exact bounded well-formed scopes without raw errors", () => { const owner = coordinator(); expect( @@ -722,6 +764,461 @@ describe("response epoch observations and capture authority", () => { }); }); +describe("service epoch transition ordering", () => { + it("prepares retirement after epoch installation and dispatches it before revision listeners", () => { + const harness = listenerProvider(); + const events: string[] = []; + let membership: SqlCatalogScopeMembership; + const owner = coordinator( + harness.subscribe, + "catalog", + function (this: void, scope, nextEpoch) { + events.push("transition-prepare"); + expect(this).toBeUndefined(); + expect(scope).toBe("scope"); + expect(Object.isFrozen(nextEpoch)).toBe(true); + expect(nextEpoch).toEqual(epoch(1)); + expect(capture(membership).expectedEpoch).toEqual(epoch(1)); + return () => { + events.push("transition-dispatch"); + }; + }, + ); + membership = active(owner, "scope", { + prepareCatalogChange: () => { + events.push("revision-prepare"); + return () => { + events.push("revision-dispatch"); + }; + }, + }); + + harness.listeners[0]?.(invalidation(1)); + expect(events).toEqual([ + "transition-prepare", + "revision-prepare", + "transition-dispatch", + "revision-dispatch", + ]); + }); + + it("runs only for response advances and settles the producer before retirement dispatch", () => { + const events: string[] = []; + const owner = coordinator( + undefined, + "catalog", + (_scope, nextEpoch) => { + events.push(`transition-prepare-${nextEpoch.generation}`); + return () => { + events.push( + `transition-dispatch-${nextEpoch.generation}`, + ); + }; + }, + ); + const membership = active(owner, "scope", { + prepareCatalogChange: () => { + events.push("revision-prepare"); + return () => { + events.push("revision-dispatch"); + }; + }, + }); + + const baseline = submit(owner, capture(membership), epoch(1)); + expect(baseline.decisions).toEqual([ + { + epoch: epoch(1), + observation: "baseline", + status: "usable", + }, + ]); + expect(events).toEqual([]); + + const advancedDecisions: SqlCatalogResponseEpochDecision[] = []; + expect( + owner.submitResponseEpoch( + capture(membership), + epoch(2), + (decision) => { + events.push(`decision-${decision.status}`); + advancedDecisions.push(decision); + }, + ), + ).toEqual({ status: "submitted" }); + expect(advancedDecisions).toEqual([ + { epoch: epoch(2), status: "superseded" }, + ]); + expect(events).toEqual([ + "transition-prepare-2", + "revision-prepare", + "decision-superseded", + "transition-dispatch-2", + "revision-dispatch", + ]); + + events.length = 0; + expect( + submit(owner, capture(membership), epoch(2)).decisions, + ).toEqual([ + { + epoch: epoch(2), + observation: "equal", + status: "usable", + }, + ]); + expect(events).toEqual([]); + }); + + it("snapshots the revision audience before transition preparation", () => { + const harness = listenerProvider(); + const first = counterTarget(); + const second = counterTarget(); + let secondMembership: SqlCatalogScopeMembership; + let joinedEpoch: SqlCatalogEpochCapture["expectedEpoch"] = null; + const owner = coordinator( + harness.subscribe, + "catalog", + (_scope, nextEpoch) => { + if (nextEpoch.generation === 1) { + expect(secondMembership.activate()).toEqual({ + status: "active", + }); + joinedEpoch = capture(secondMembership).expectedEpoch; + } + return null; + }, + ); + active(owner, "scope", first.target); + secondMembership = prepared(owner, "scope", second.target); + + harness.listeners[0]?.(invalidation(1)); + expect(joinedEpoch).toEqual(epoch(1)); + expect(first).toMatchObject({ dispatched: 1, prepared: 1 }); + expect(second).toMatchObject({ dispatched: 0, prepared: 0 }); + + harness.listeners[0]?.(invalidation(2)); + expect(first).toMatchObject({ dispatched: 2, prepared: 2 }); + expect(second).toMatchObject({ dispatched: 1, prepared: 1 }); + }); + + it("queues transition-dispatch reentrancy behind the current revision dispatch", () => { + const harness = listenerProvider(); + const events: string[] = []; + const owner = coordinator( + harness.subscribe, + "catalog", + (_scope, nextEpoch) => { + const generation = nextEpoch.generation; + events.push(`transition-prepare-${generation}`); + return () => { + events.push(`transition-dispatch-${generation}`); + if (generation === 1) { + harness.listeners[0]?.(invalidation(2)); + } + }; + }, + ); + active(owner, "scope", { + prepareCatalogChange: () => { + const generation = + events.filter((event) => + event.startsWith("transition-prepare"), + ).length; + events.push(`revision-prepare-${generation}`); + return () => { + events.push(`revision-dispatch-${generation}`); + }; + }, + }); + + harness.listeners[0]?.(invalidation(1)); + expect(events).toEqual([ + "transition-prepare-1", + "revision-prepare-1", + "transition-dispatch-1", + "revision-dispatch-1", + "transition-prepare-2", + "revision-prepare-2", + "transition-dispatch-2", + "revision-dispatch-2", + ]); + }); + + it("dispatches prepared retirement after reentrant coordinator disposal", () => { + const events: string[] = []; + let owner: SqlCatalogEpochCoordinator; + owner = coordinator( + undefined, + "catalog", + () => { + events.push("transition-prepare"); + owner.dispose(); + return () => { + events.push("transition-dispatch"); + }; + }, + ); + const membership = active(owner, "scope"); + submit(owner, capture(membership), epoch(1)); + + const decisions: SqlCatalogResponseEpochDecision[] = []; + expect( + owner.submitResponseEpoch( + capture(membership), + epoch(2), + (decision) => { + events.push(`decision-${decision.status}`); + decisions.push(decision); + }, + ), + ).toEqual({ status: "submitted" }); + expect(decisions).toEqual([ + { reason: "disposed", status: "discarded" }, + ]); + expect(events).toEqual([ + "transition-prepare", + "decision-discarded", + "transition-dispatch", + ]); + }); + + it("drains rejected and poisoned transition dispatch results", async () => { + const asyncHarness = listenerProvider(); + let asyncDispatchCalls = 0; + const asyncOwner = coordinatorWithRawTransition( + () => async () => { + asyncDispatchCalls += 1; + throw new Error("async transition dispatch failed"); + }, + asyncHarness.subscribe, + ); + active(asyncOwner, "scope"); + asyncHarness.listeners[0]?.(invalidation(1)); + + let thenReads = 0; + let catchReads = 0; + const thenProperty = ["th", "en"].join(""); + const poisonedResult = Promise.reject( + new Error("poisoned transition rejection"), + ); + Object.defineProperty(poisonedResult, thenProperty, { + get() { + thenReads += 1; + throw new Error("poisoned transition then"); + }, + }); + Object.defineProperty(poisonedResult, "catch", { + get() { + catchReads += 1; + throw new Error("poisoned transition catch"); + }, + }); + const poisonedHarness = listenerProvider(); + const poisonedOwner = coordinatorWithRawTransition( + () => () => poisonedResult, + poisonedHarness.subscribe, + ); + active(poisonedOwner, "scope"); + poisonedHarness.listeners[0]?.(invalidation(1)); + + let hostileThenReads = 0; + let hostileReentryStatus: string | undefined; + let hostileOwner: SqlCatalogEpochCoordinator; + const hostileThenable = Object.defineProperty( + {}, + thenProperty, + { + get() { + hostileThenReads += 1; + hostileReentryStatus = hostileOwner.prepareScopeMembership( + "reentrant", + counterTarget().target, + ).status; + throw new Error("hostile transition thenable"); + }, + }, + ); + const hostileHarness = listenerProvider(); + hostileOwner = coordinatorWithRawTransition( + () => () => hostileThenable, + hostileHarness.subscribe, + ); + active(hostileOwner, "scope"); + hostileHarness.listeners[0]?.(invalidation(1)); + + await Promise.resolve(); + await Promise.resolve(); + expect(asyncDispatchCalls).toBe(1); + expect(thenReads).toBe(0); + expect(catchReads).toBe(0); + expect(hostileThenReads).toBe(1); + expect(hostileReentryStatus).toBe("unavailable"); + }); + + it("quarantines thrown dispatch and invalid preparation", () => { + const harness = listenerProvider(); + const live = counterTarget(); + const throwingDispatchOwner = coordinator( + harness.subscribe, + "catalog", + () => () => { + throw new Error("transition dispatch failed"); + }, + ); + const throwingDispatchMembership = active( + throwingDispatchOwner, + "scope", + live.target, + ); + harness.listeners[0]?.(invalidation(1)); + expect(live).toMatchObject({ dispatched: 0, prepared: 1 }); + expect(throwingDispatchMembership.captureEpoch()).toEqual({ + reason: "disposed", + status: "unavailable", + }); + + const throwingHarness = listenerProvider(); + const throwingOwner = coordinator( + throwingHarness.subscribe, + "catalog", + () => { + throw new Error("transition preparation failed"); + }, + ); + const retired = active(throwingOwner, "scope"); + throwingHarness.listeners[0]?.(invalidation(1)); + expect(retired.captureEpoch()).toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect(throwingHarness.cleanupCalls).toBe(1); + + const invalidHarness = listenerProvider(); + const created = Reflect.apply( + createSqlCatalogEpochCoordinator, + undefined, + [ + capturedProvider(invalidHarness.subscribe), + () => 1, + ], + ); + expect(created.status).toBe("created"); + if (created.status !== "created") { + throw new Error("Expected a coordinator fixture"); + } + const invalidMembership = active( + created.coordinator, + "scope", + ); + invalidHarness.listeners[0]?.(invalidation(1)); + expect(invalidMembership.captureEpoch()).toEqual({ + reason: "disposed", + status: "unavailable", + }); + expect(invalidHarness.cleanupCalls).toBe(1); + + let responseOwner: SqlCatalogEpochCoordinator; + responseOwner = coordinator( + undefined, + "catalog", + () => { + throw new Error("response transition failed"); + }, + ); + const responseMembership = active(responseOwner, "scope"); + submit(responseOwner, capture(responseMembership), epoch(1)); + const responseDecisions: SqlCatalogResponseEpochDecision[] = []; + expect( + responseOwner.submitResponseEpoch( + capture(responseMembership), + epoch(2), + (decision) => responseDecisions.push(decision), + ), + ).toEqual({ status: "submitted" }); + expect(responseDecisions).toEqual([ + { reason: "disposed", status: "discarded" }, + ]); + expect(responseMembership.captureEpoch()).toEqual({ + reason: "disposed", + status: "unavailable", + }); + }); + + it("drains invalid preparation results after making state inert", async () => { + let asyncPreparationCalls = 0; + const asyncHarness = listenerProvider(); + const asyncOwner = coordinatorWithRawTransition( + async () => { + asyncPreparationCalls += 1; + throw new Error("async transition preparation failed"); + }, + asyncHarness.subscribe, + ); + active(asyncOwner, "scope"); + asyncHarness.listeners[0]?.(invalidation(1)); + + let thenReads = 0; + let catchReads = 0; + const thenProperty = ["th", "en"].join(""); + const poisonedResult = Promise.reject( + new Error("poisoned preparation rejection"), + ); + Object.defineProperty(poisonedResult, thenProperty, { + get() { + thenReads += 1; + throw new Error("poisoned preparation then"); + }, + }); + Object.defineProperty(poisonedResult, "catch", { + get() { + catchReads += 1; + throw new Error("poisoned preparation catch"); + }, + }); + const poisonedHarness = listenerProvider(); + const poisonedOwner = coordinatorWithRawTransition( + () => poisonedResult, + poisonedHarness.subscribe, + ); + active(poisonedOwner, "scope"); + poisonedHarness.listeners[0]?.(invalidation(1)); + + let hostileThenReads = 0; + let hostileReentryStatus: string | undefined; + let hostileOwner: SqlCatalogEpochCoordinator; + const hostileThenable = Object.defineProperty( + {}, + thenProperty, + { + get() { + hostileThenReads += 1; + hostileReentryStatus = hostileOwner.prepareScopeMembership( + "reentrant", + counterTarget().target, + ).status; + throw new Error("hostile preparation thenable"); + }, + }, + ); + const hostileHarness = listenerProvider(); + hostileOwner = coordinatorWithRawTransition( + () => hostileThenable, + hostileHarness.subscribe, + ); + active(hostileOwner, "scope"); + hostileHarness.listeners[0]?.(invalidation(1)); + + await Promise.resolve(); + await Promise.resolve(); + expect(asyncPreparationCalls).toBe(1); + expect(thenReads).toBe(0); + expect(catchReads).toBe(0); + expect(hostileThenReads).toBe(1); + expect(hostileReentryStatus).toBe("unavailable"); + }); +}); + describe("subscription invalidation ordering and isolation", () => { it("classifies baseline/equal/advance while preserving state-before-listener and insertion order", () => { const harness = listenerProvider(); @@ -1307,7 +1804,7 @@ describe("hostile subscription lifecycle", () => { expect(getterCalls).toBe(0); }); - it("isolates thrown cleanup and retains no live callback after failed subscribe", () => { + it("quarantines thrown cleanup and retains no live callback after failed subscribe", () => { let retained: RawInvalidationListener | undefined; const owner = coordinator((_scope, notify) => { retained = notify; @@ -1320,6 +1817,12 @@ describe("hostile subscription lifecycle", () => { expect(() => membership.dispose()).not.toThrow(); retained?.(invalidation(1)); expect(target.dispatched).toBe(0); + expect( + owner.prepareScopeMembership("replacement", target.target), + ).toEqual({ + reason: "disposed", + status: "unavailable", + }); let failedRetained: RawInvalidationListener | undefined; const failedOwner = coordinator((_scope, notify) => { @@ -1346,10 +1849,16 @@ describe("hostile subscription lifecycle", () => { let applyCalls = 0; let thenReads = 0; + let hostileReentryStatus: string | undefined; + let hostileOwner: SqlCatalogEpochCoordinator; const thenProperty = ["th", "en"].join(""); const hostileResult = Object.defineProperty({}, thenProperty, { get() { thenReads += 1; + hostileReentryStatus = hostileOwner.prepareScopeMembership( + "reentrant", + counterTarget().target, + ).status; throw new Error("hostile then getter"); }, }); @@ -1360,7 +1869,7 @@ describe("hostile subscription lifecycle", () => { return Reflect.apply(target, receiver, argumentsList); }, }); - const hostileOwner = coordinator(() => hostileCleanup); + hostileOwner = coordinator(() => hostileCleanup); const hostileMembership = active(hostileOwner, "scope"); hostileMembership.dispose(); hostileMembership.dispose(); @@ -1391,6 +1900,32 @@ describe("hostile subscription lifecycle", () => { ); active(nonCallableCatchOwner, "scope").dispose(); + let nativeThenReads = 0; + const throwingThenResult = Promise.reject( + new Error("shadowed then rejection"), + ); + Object.defineProperty(throwingThenResult, thenProperty, { + get() { + nativeThenReads += 1; + throw new Error("hostile native then getter"); + }, + }); + const throwingThenOwner = coordinator( + () => () => throwingThenResult, + ); + active(throwingThenOwner, "scope").dispose(); + + const nonCallableThenResult = Promise.reject( + new Error("non-callable then rejection"), + ); + Object.defineProperty(nonCallableThenResult, thenProperty, { + value: null, + }); + const nonCallableThenOwner = coordinator( + () => () => nonCallableThenResult, + ); + active(nonCallableThenOwner, "scope").dispose(); + let thenCalls = 0; let reentrantOwner: SqlCatalogEpochCoordinator; const reentrantResult = Object.defineProperty( @@ -1423,8 +1958,19 @@ describe("hostile subscription lifecycle", () => { expect(asyncCleanupCalls).toBe(1); expect(applyCalls).toBe(1); expect(thenReads).toBe(1); + expect(hostileReentryStatus).toBe("unavailable"); expect(catchReads).toBe(0); + expect(nativeThenReads).toBe(0); expect(thenCalls).toBe(1); + expect( + rejectingOwner.prepareScopeMembership( + "replacement", + counterTarget().target, + ), + ).toEqual({ + reason: "disposed", + status: "unavailable", + }); }); it("retries a failed subscription only in a new last-owner incarnation", () => { @@ -2018,7 +2564,9 @@ describe("service disposal and bounded command draining", () => { const cleanupScopes: string[] = []; const owner = coordinator((scope, notify) => { listeners.set(scope, notify); - return () => cleanupScopes.push(scope); + return () => { + cleanupScopes.push(scope); + }; }); const stormMemberships = Array.from({ length: 5 }, (_, index) => active(owner, `storm-${index}`), diff --git a/src/vnext/relation-catalog-epoch-coordinator.ts b/src/vnext/relation-catalog-epoch-coordinator.ts index ef96b25..3a3a1fe 100644 --- a/src/vnext/relation-catalog-epoch-coordinator.ts +++ b/src/vnext/relation-catalog-epoch-coordinator.ts @@ -20,6 +20,14 @@ export interface SqlCatalogRevisionTarget { ) => ((this: void) => void) | null; } +export type SqlCatalogEpochTransitionTarget = ( + this: void, + scope: string, + epoch: SqlCatalogEpoch, +) => + | ((this: void) => undefined) + | null; + const epochCaptureBrand: unique symbol = Symbol( "SqlCatalogEpochCapture", ); @@ -134,7 +142,9 @@ export type SqlCatalogEpochCoordinatorResult = } | { readonly status: "unavailable"; - readonly reason: "invalid-provider"; + readonly reason: + | "invalid-provider" + | "invalid-transition-target"; }; interface CoordinatorState { @@ -147,6 +157,7 @@ interface CoordinatorState { readonly commands: EpochCommand[]; readonly deferredCleanup: Set; readonly memberships: Set; + prepareEpochTransition: Function | null; readonly providerId: string; readonly scopes: Map; subscribe: SqlCapturedRelationCatalogProviderContext["subscribe"]; @@ -226,7 +237,11 @@ const ACTIVE_RESULT: SqlCatalogMembershipActivationResult = const SUBMITTED_RESULT: SqlCatalogResponseEpochSubmissionResult = Object.freeze({ status: "submitted" }); const NO_PREPARE_CATALOG_CHANGE = (): null => null; -const IGNORE_CLEANUP_REJECTION = (): void => {}; +const IGNORE_DETACHED_REJECTION = (): void => {}; +const INTRINSIC_PROMISE_THEN = Promise.prototype.then; +const FAILED_EPOCH_TRANSITION: unique symbol = Symbol( + "FailedSqlCatalogEpochTransition", +); function unavailableActivation( reason: Exclude< @@ -366,7 +381,36 @@ function captureCleanup(candidate: unknown): Function | null { return typeof candidate === "function" ? candidate : null; } -function cleanupSubscription(subscription: SubscriptionState): void { +function drainDetachedSettlement(result: unknown): void { + if ( + result === null || + (typeof result !== "object" && + typeof result !== "function") + ) { + return; + } + try { + Reflect.apply(INTRINSIC_PROMISE_THEN, result, [ + undefined, + IGNORE_DETACHED_REJECTION, + ]); + return; + } catch { + // Non-native thenables are assimilated through a fresh wrapper. + } + const settlement = new Promise((resolve) => { + resolve(result); + }); + Reflect.apply(INTRINSIC_PROMISE_THEN, settlement, [ + undefined, + IGNORE_DETACHED_REJECTION, + ]); +} + +function cleanupSubscription( + state: CoordinatorState, + subscription: SubscriptionState, +): void { if (subscription.cleanupCalled) { subscription.cleanup = null; return; @@ -377,12 +421,12 @@ function cleanupSubscription(subscription: SubscriptionState): void { subscription.cleanup = null; try { const result = Reflect.apply(cleanup, undefined, []); - const settlement = new Promise((resolve) => { - resolve(result); - }); - void settlement.then(undefined, IGNORE_CLEANUP_REJECTION); + if (result !== undefined) { + disposeCoordinator(state); + drainDetachedSettlement(result); + } } catch { - // Provider cleanup is isolated after state is inert. + disposeCoordinator(state); } } @@ -447,7 +491,7 @@ function flushDeferredCleanup(state: CoordinatorState): void { while (state.deferredCleanup.size > 0) { for (const subscription of state.deferredCleanup) { state.deferredCleanup.delete(subscription); - cleanupSubscription(subscription); + cleanupSubscription(state, subscription); break; } } @@ -668,6 +712,55 @@ interface PreparedRevision { readonly member: MembershipState; } +type PreparedEpochTransition = + | Function + | null + | typeof FAILED_EPOCH_TRANSITION; + +function prepareEpochTransition( + state: CoordinatorState, + scope: string, + epoch: SqlCatalogEpoch, +): PreparedEpochTransition { + const prepare = state.prepareEpochTransition; + if (!prepare) return null; + let candidate: unknown; + try { + candidate = Reflect.apply(prepare, undefined, [scope, epoch]); + } catch { + disposeCoordinator(state); + return FAILED_EPOCH_TRANSITION; + } + if (candidate === null) return null; + if (typeof candidate !== "function") { + disposeCoordinator(state); + drainDetachedSettlement(candidate); + return FAILED_EPOCH_TRANSITION; + } + return candidate; +} + +function dispatchEpochTransition( + state: CoordinatorState, + prepared: PreparedEpochTransition, +): void { + if ( + prepared === null || + prepared === FAILED_EPOCH_TRANSITION + ) { + return; + } + try { + const result = Reflect.apply(prepared, undefined, []); + if (result !== undefined) { + disposeCoordinator(state); + drainDetachedSettlement(result); + } + } catch { + disposeCoordinator(state); + } +} + function prepareRevisions( audience: readonly MembershipState[], entry: ScopeEntry, @@ -756,10 +849,17 @@ function processInvalidation( const audience = snapshotAudience(command.entry); command.entry.observedEpoch = comparison.epoch; command.entry.notificationSequence += 1; + const transition = prepareEpochTransition( + state, + command.entry.scope, + comparison.epoch, + ); + if (transition === FAILED_EPOCH_TRANSITION) return; const prepared = prepareRevisions( audience, command.entry, ); + dispatchEpochTransition(state, transition); dispatchRevisions(prepared, command.entry); } finally { leaveCleanupBarrier(state); @@ -860,9 +960,19 @@ function processResponse( const audience = snapshotAudience(entry); entry.observedEpoch = comparison.epoch; entry.notificationSequence += 1; + const transition = prepareEpochTransition( + state, + entry.scope, + comparison.epoch, + ); + if (transition === FAILED_EPOCH_TRANSITION) { + settleDecision(command.onDecision, discarded("disposed")); + return; + } const prepared = prepareRevisions(audience, entry); if (state.disposed) { settleDecision(command.onDecision, discarded("disposed")); + dispatchEpochTransition(state, transition); return; } if (isLiveCapture(state, command.capture)) { @@ -876,6 +986,7 @@ function processResponse( } else { settleDecision(command.onDecision, discarded("retired")); } + dispatchEpochTransition(state, transition); dispatchRevisions(prepared, entry); } finally { leaveCleanupBarrier(state); @@ -1137,6 +1248,7 @@ function submitResponse( function disposeCoordinator(state: CoordinatorState): void { if (state.disposed) return; state.disposed = true; + state.prepareEpochTransition = null; state.subscribe = null; const subscriptions: SubscriptionState[] = []; for (const entry of state.scopes.values()) { @@ -1192,6 +1304,7 @@ function createCoordinatorHandle( export function createSqlCatalogEpochCoordinator( capturedProvider: unknown, + prepareEpochTransition?: SqlCatalogEpochTransitionTarget, ): SqlCatalogEpochCoordinatorResult { const provider = resolveSqlRelationCatalogProvider( capturedProvider, @@ -1202,6 +1315,15 @@ export function createSqlCatalogEpochCoordinator( status: "unavailable", }); } + if ( + prepareEpochTransition !== undefined && + typeof prepareEpochTransition !== "function" + ) { + return Object.freeze({ + reason: "invalid-transition-target", + status: "unavailable", + }); + } const providerId = provider.id; const subscribe = provider.subscribe; const state: CoordinatorState = { @@ -1214,6 +1336,8 @@ export function createSqlCatalogEpochCoordinator( disposed: false, draining: false, memberships: new Set(), + prepareEpochTransition: + prepareEpochTransition ?? null, providerId, scopes: new Map(), subscribe, diff --git a/src/vnext/relation-completion-types.ts b/src/vnext/relation-completion-types.ts index f5a6e8c..1222d10 100644 --- a/src/vnext/relation-completion-types.ts +++ b/src/vnext/relation-completion-types.ts @@ -14,7 +14,7 @@ export interface SqlDisposable { export type SqlCatalogSubscriptionCleanup = ( this: void, -) => void | PromiseLike; +) => undefined; export type SqlCatalogContainerRole = | "catalog" diff --git a/test/vnext-types/marimo-relation-completion.test-d.ts b/test/vnext-types/marimo-relation-completion.test-d.ts index 1a6973f..721dfa4 100644 --- a/test/vnext-types/marimo-relation-completion.test-d.ts +++ b/test/vnext-types/marimo-relation-completion.test-d.ts @@ -23,6 +23,9 @@ import type { SqlRelationCatalogProvider, SqlRelationCompletionSession, } from "../../src/vnext/relation-completion-types.js"; +import type { + SqlCatalogEpochTransitionTarget, +} from "../../src/vnext/relation-catalog-epoch-coordinator.js"; interface MarimoSqlContext extends SqlDocumentContext { readonly engine: string; @@ -152,8 +155,9 @@ const receiverDependentProvider: SqlRelationCatalogProvider = { const receiverDependentDispose = function ( this: { readonly id: string }, -): void { +): undefined { void this.id; + return undefined; }; const receiverDependentDisposable: SqlDisposable = { // @ts-expect-error disposable callbacks are this-free closures @@ -165,6 +169,25 @@ const receiverDependentCatalogCleanup: SqlCatalogSubscriptionCleanup = receiverDependentDispose; void receiverDependentCatalogCleanup; +// @ts-expect-error catalog cleanup is synchronously exact-return +const asynchronousCatalogCleanup: SqlCatalogSubscriptionCleanup = + async () => {}; +void asynchronousCatalogCleanup; + +const nonUndefinedCatalogCleanup: SqlCatalogSubscriptionCleanup = + // @ts-expect-error catalog cleanup must return exactly undefined + () => 1; +void nonUndefinedCatalogCleanup; + +const synchronousEpochTransition: SqlCatalogEpochTransitionTarget = + () => () => undefined; +void synchronousEpochTransition; + +const asynchronousEpochTransition: SqlCatalogEpochTransitionTarget = + // @ts-expect-error epoch transition dispatch is synchronously exact-return + () => async () => {}; +void asynchronousEpochTransition; + const relationPath = [ { quoted: false, role: "catalog", value: "memory" }, { quoted: false, role: "schema", value: "main" },