Skip to content

Commit 90b3681

Browse files
committed
fix(vnext): enforce exact disposal hooks
1 parent 8625315 commit 90b3681

5 files changed

Lines changed: 43 additions & 7 deletions

docs/adr/0005-parser-independent-relation-completion.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -488,7 +488,8 @@ The combined search coordinator also installs one package-owned disposal
488488
target. Epoch self-quarantine makes the outer coordinator inert before
489489
subscription cleanup continues, so search work cannot outlive its epoch
490490
authority. The target is receiver-free, invoked at most once, and its failure
491-
cannot reopen disposal.
491+
cannot reopen disposal. It is synchronously exact-return and must return
492+
`undefined`; any other runtime result is detached and rejection-drained.
492493

493494
A search that discovers a higher epoch supersedes itself instead of publishing
494495
against its older captured revision. Pages and cache entries from different

src/vnext/__tests__/relation-catalog-epoch-coordinator.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ describe("catalog epoch coordinator construction and membership", () => {
236236
expect(reads).toBe(0);
237237
});
238238

239-
it("validates and invokes the package disposal target exactly once", () => {
239+
it("validates, drains, and invokes the package disposal target exactly once", async () => {
240240
expect(
241241
Reflect.apply(createSqlCatalogEpochCoordinator, undefined, [
242242
capturedProvider(),
@@ -266,6 +266,26 @@ describe("catalog epoch coordinator construction and membership", () => {
266266
created.coordinator.dispose();
267267
}).not.toThrow();
268268
expect(disposalCalls).toBe(1);
269+
270+
const invalidReturn = Promise.reject(
271+
new Error("invalid async package disposal"),
272+
);
273+
const withInvalidReturn = Reflect.apply(
274+
createSqlCatalogEpochCoordinator,
275+
undefined,
276+
[
277+
capturedProvider(),
278+
undefined,
279+
() => invalidReturn,
280+
],
281+
);
282+
expect(withInvalidReturn.status).toBe("created");
283+
if (withInvalidReturn.status !== "created") {
284+
throw new Error("Expected a coordinator fixture");
285+
}
286+
withInvalidReturn.coordinator.dispose();
287+
await Promise.resolve();
288+
await Promise.resolve();
269289
});
270290

271291
it("validates exact bounded well-formed scopes without raw errors", () => {

src/vnext/relation-catalog-epoch-coordinator.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ interface CoordinatorState {
158158
readonly commands: EpochCommand[];
159159
readonly deferredCleanup: Set<SubscriptionState>;
160160
readonly memberships: Set<MembershipState>;
161-
onDispose: ((this: void) => void) | null;
161+
onDispose: ((this: void) => undefined) | null;
162162
prepareEpochTransition: Function | null;
163163
readonly providerId: string;
164164
readonly scopes: Map<string, ScopeEntry>;
@@ -1277,7 +1277,10 @@ function disposeCoordinator(state: CoordinatorState): void {
12771277
}
12781278
if (onDispose) {
12791279
try {
1280-
Reflect.apply(onDispose, undefined, []);
1280+
const result = Reflect.apply(onDispose, undefined, []);
1281+
if (result !== undefined) {
1282+
drainDetachedSettlement(result);
1283+
}
12811284
} catch {
12821285
// Disposal remains authoritative if its package owner fails.
12831286
}
@@ -1316,7 +1319,7 @@ function createCoordinatorHandle(
13161319
export function createSqlCatalogEpochCoordinator(
13171320
capturedProvider: unknown,
13181321
prepareEpochTransition?: SqlCatalogEpochTransitionTarget,
1319-
onDispose?: (this: void) => void,
1322+
onDispose?: (this: void) => undefined,
13201323
): SqlCatalogEpochCoordinatorResult {
13211324
const provider = resolveSqlRelationCatalogProvider(
13221325
capturedProvider,

src/vnext/relation-catalog-search-work.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1835,8 +1835,9 @@ export function createSqlCatalogSearchWorkCoordinator(
18351835
provider,
18361836
(scope): (() => undefined) | null =>
18371837
state ? prepareTransition(state, scope) : null,
1838-
(): void => {
1838+
(): undefined => {
18391839
if (state) disposeCoordinatorState(state);
1840+
return undefined;
18401841
},
18411842
);
18421843
if (epochResult.status !== "created") {

test/vnext-types/relation-catalog-search-work.test-d.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import type {
55
import type {
66
SqlCatalogRevisionTarget,
77
} from "../../src/vnext/relation-catalog-epoch-coordinator.js";
8+
import {
9+
createSqlCatalogEpochCoordinator,
10+
} from "../../src/vnext/relation-catalog-epoch-coordinator.js";
811
import type {
912
SqlCatalogSearchWorkCoordinator,
1013
SqlCatalogSearchWorkInput,
@@ -94,7 +97,6 @@ const invalidRequestReceiver: SqlCatalogSearchWorkOwner["request"] =
9497
const receiverDependentPrepareOwner = function (
9598
this: { readonly active: boolean },
9699
_scope: unknown,
97-
_dialectId: unknown,
98100
_dialect: unknown,
99101
_target: SqlCatalogRevisionTarget,
100102
): SqlCatalogSearchWorkOwnerResult {
@@ -105,6 +107,15 @@ const receiverDependentPrepareOwner = function (
105107
const invalidPrepareOwnerReceiver: SqlCatalogSearchWorkCoordinator["prepareOwner"] =
106108
receiverDependentPrepareOwner;
107109

110+
const asyncDisposalTarget = async (): Promise<void> => {};
111+
type EpochDisposalTarget = NonNullable<
112+
Parameters<typeof createSqlCatalogEpochCoordinator>[2]
113+
>;
114+
// @ts-expect-error package disposal targets are synchronously exact-undefined
115+
const invalidAsyncDisposalTarget: EpochDisposalTarget =
116+
asyncDisposalTarget;
117+
void invalidAsyncDisposalTarget;
118+
108119
const receiverDependentActivate = function (
109120
this: { readonly active: boolean },
110121
): ReturnType<SqlCatalogSearchWorkOwner["activate"]> {

0 commit comments

Comments
 (0)