Skip to content

Commit 2b66021

Browse files
committed
fix(vnext): bound auxiliary catalog refresh
1 parent d8590db commit 2b66021

6 files changed

Lines changed: 285 additions & 23 deletions

File tree

docs/vnext/column-catalog-authority.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ Status: internal vertical-slice contract
55
Column discovery is lazy, provider-owned, and batched. A completion request
66
sends every unresolved relation reference in one provider invocation. Each
77
reference carries a caller-local `requestKey`, a decoded identifier path, and
8-
an optional previously authenticated relation entity ID. The provider resolves
9-
paths against the supplied catalog scope, search paths, and dialect.
8+
no unauthenticated entity identity. The provider resolves paths against the
9+
supplied catalog scope, search paths, and dialect.
1010

1111
The provider returns stable relation and column entity IDs. Every accepted
1212
column contains:
@@ -37,11 +37,11 @@ entries from another epoch. The cache is LRU-bounded by relation entries.
3737
Only complete ready results are cached. Partial, loading, and failed results
3838
remain visible to the caller but are eligible for another provider request.
3939

40-
The initial coordinator does not subscribe to catalog invalidations. Until it
41-
is connected to the relation catalog's private epoch coordinator, a host must
42-
supersede or dispose the column owner when catalog authority changes. Session
43-
integration must not treat a cached complete result as current across a known
44-
catalog revision.
40+
Relation-catalog subscription events invalidate the session's relation, column,
41+
and namespace observations together. Hosts without a subscribed relation
42+
provider call `session.invalidateCatalog()` when schema authority changes. The
43+
method advances the session revision, cancels retained completion work, rebuilds
44+
all catalog owners, and emits one `catalog` change event.
4545

4646
## Lifecycle
4747

@@ -55,3 +55,8 @@ cannot publish or populate the cache.
5555
The coordinator batches a request once, never once per relation. Cache hits and
5656
misses are composed deterministically while only misses are sent to the
5757
provider.
58+
59+
Provider-declared loading receives at most one automatic retry for the same
60+
document, context, and completion position. A persistent loading response is
61+
then returned without a refresh token; hosts resume it through catalog
62+
invalidation instead of an unbounded polling loop.

docs/vnext/namespace-catalog-authority.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,10 @@ disposes it when catalog authority changes. Query-site integration calls
3636
passes the outcome plus the site's replacement range and dialect prefix matcher
3737
to `composeSqlNamespaceCompletion`. Namespace items are merged with local and
3838
relation-catalog items under the same bounded completion response budget.
39+
Relation-catalog subscription events invalidate namespace observations.
40+
Namespace-only hosts, and hosts whose relation provider has no subscription,
41+
call `session.invalidateCatalog()` after a schema-authority change.
42+
43+
Provider-declared loading receives at most one automatic retry for a stable
44+
document, context, and completion position. Further loading responses do not
45+
schedule polling; a host-provided catalog invalidation resumes discovery.

src/vnext/__tests__/session.test.ts

Lines changed: 175 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1463,6 +1463,64 @@ describe("column completion session integration", () => {
14631463
}
14641464
});
14651465

1466+
it("bounds provider-declared column loading to one automatic retry", async () => {
1467+
vi.useFakeTimers();
1468+
try {
1469+
let calls = 0;
1470+
const service = serviceWithColumns(async (request) => {
1471+
calls += 1;
1472+
return {
1473+
epoch: { generation: 1, token: "epoch-1" },
1474+
relations: request.relations.map((relation) => ({
1475+
requestKey: relation.requestKey,
1476+
status: "loading",
1477+
})),
1478+
};
1479+
});
1480+
const text = "SELECT u. FROM users u";
1481+
const session = service.openDocument({
1482+
context: {
1483+
catalog: { scope: "connection:columns-loading" },
1484+
dialect: "duckdb",
1485+
engine: "local",
1486+
},
1487+
text,
1488+
});
1489+
const events: SqlSessionChangeEvent[] = [];
1490+
session.onDidChange((event) => events.push(event));
1491+
const result = await session.complete({
1492+
position: "SELECT u.".length,
1493+
trigger: { kind: "invoked" },
1494+
});
1495+
if (result.status !== "ready" || result.refreshToken === null) {
1496+
throw new Error("Expected retained column loading");
1497+
}
1498+
1499+
await vi.advanceTimersByTimeAsync(100);
1500+
expect(events).toMatchObject([{
1501+
reason: "catalog-availability",
1502+
refreshToken: result.refreshToken,
1503+
}]);
1504+
const retry = await session.complete({
1505+
position: "SELECT u.".length,
1506+
trigger: { kind: "invoked" },
1507+
});
1508+
expect(retry).toMatchObject({
1509+
refreshToken: null,
1510+
sources: [{
1511+
feature: "column-catalog",
1512+
outcome: "loading",
1513+
}],
1514+
});
1515+
await vi.advanceTimersByTimeAsync(500);
1516+
expect(calls).toBe(2);
1517+
expect(events).toHaveLength(1);
1518+
service.dispose();
1519+
} finally {
1520+
vi.useRealTimers();
1521+
}
1522+
});
1523+
14661524
it("emits readiness when a timed-out column batch settles", async () => {
14671525
vi.useFakeTimers();
14681526
try {
@@ -1812,10 +1870,14 @@ describe("namespace completion session integration", () => {
18121870
it("polls a provider-declared loading namespace with token correlation", async () => {
18131871
vi.useFakeTimers();
18141872
try {
1815-
const service = serviceWithNamespaces(async () => ({
1816-
epoch: { generation: 1, token: "epoch-1" },
1817-
status: "loading",
1818-
}));
1873+
let calls = 0;
1874+
const service = serviceWithNamespaces(async () => {
1875+
calls += 1;
1876+
return {
1877+
epoch: { generation: 1, token: "epoch-1" },
1878+
status: "loading",
1879+
};
1880+
});
18191881
const text = "SELECT * FROM ma";
18201882
const session = service.openDocument({
18211883
context: {
@@ -1846,6 +1908,24 @@ describe("namespace completion session integration", () => {
18461908
reason: "catalog-availability",
18471909
refreshToken: result.refreshToken,
18481910
}]);
1911+
1912+
const retry = await session.complete({
1913+
position: text.length,
1914+
trigger: { kind: "invoked" },
1915+
});
1916+
expect(retry).toMatchObject({
1917+
refreshToken: null,
1918+
sources: [
1919+
{ feature: "relation-catalog" },
1920+
{
1921+
feature: "namespace-catalog",
1922+
outcome: "loading",
1923+
},
1924+
],
1925+
});
1926+
await vi.advanceTimersByTimeAsync(500);
1927+
expect(calls).toBe(2);
1928+
expect(events).toHaveLength(1);
18491929
service.dispose();
18501930
} finally {
18511931
vi.useRealTimers();
@@ -1987,6 +2067,97 @@ describe("namespace completion session integration", () => {
19872067
});
19882068
service.dispose();
19892069
});
2070+
2071+
it("manually invalidates auxiliary-only catalog authority", async () => {
2072+
let generation = 1;
2073+
let columnCalls = 0;
2074+
let namespaceCalls = 0;
2075+
const service = createSqlLanguageService<TestContext>({
2076+
columns: {
2077+
id: "columns",
2078+
loadColumns: async (request) => {
2079+
columnCalls += 1;
2080+
return {
2081+
epoch: { generation, token: `epoch-${generation}` },
2082+
relations: request.relations.map((relation) => ({
2083+
columns: [],
2084+
coverage: "complete",
2085+
relationEntityId: relation.requestKey,
2086+
requestKey: relation.requestKey,
2087+
status: "ready",
2088+
})),
2089+
};
2090+
},
2091+
},
2092+
dialects: [duckdb],
2093+
namespaces: {
2094+
id: "namespaces",
2095+
search: async () => {
2096+
namespaceCalls += 1;
2097+
return {
2098+
containers: [],
2099+
coverage: "complete",
2100+
epoch: { generation, token: `epoch-${generation}` },
2101+
status: "ready",
2102+
};
2103+
},
2104+
},
2105+
});
2106+
const columns = service.openDocument({
2107+
context: {
2108+
catalog: { scope: "connection:manual-invalidate" },
2109+
dialect: "duckdb",
2110+
engine: "local",
2111+
},
2112+
text: "SELECT u. FROM users u",
2113+
});
2114+
const namespaces = service.openDocument({
2115+
context: {
2116+
catalog: { scope: "connection:manual-invalidate" },
2117+
dialect: "duckdb",
2118+
engine: "local",
2119+
},
2120+
text: "SELECT * FROM ma",
2121+
});
2122+
const completeColumns = () =>
2123+
columns.complete({
2124+
position: "SELECT u.".length,
2125+
trigger: { kind: "invoked" },
2126+
});
2127+
const completeNamespaces = () =>
2128+
namespaces.complete({
2129+
position: "SELECT * FROM ma".length,
2130+
trigger: { kind: "invoked" },
2131+
});
2132+
2133+
await completeColumns();
2134+
await completeNamespaces();
2135+
await completeColumns();
2136+
await completeNamespaces();
2137+
expect({ columnCalls, namespaceCalls }).toEqual({
2138+
columnCalls: 1,
2139+
namespaceCalls: 1,
2140+
});
2141+
2142+
generation = 2;
2143+
const columnRevision = columns.invalidateCatalog();
2144+
const namespaceRevision = namespaces.invalidateCatalog();
2145+
expect(columns.revision).toBe(columnRevision);
2146+
expect(namespaces.revision).toBe(namespaceRevision);
2147+
await completeColumns();
2148+
await completeNamespaces();
2149+
expect({ columnCalls, namespaceCalls }).toEqual({
2150+
columnCalls: 2,
2151+
namespaceCalls: 2,
2152+
});
2153+
2154+
columns.dispose();
2155+
expect(() => columns.invalidateCatalog()).toThrow(
2156+
"SQL document session is disposed",
2157+
);
2158+
namespaces.dispose();
2159+
service.dispose();
2160+
});
19902161
});
19912162

19922163
describe("statement-index session cache", () => {

src/vnext/codemirror/__tests__/sql-editor.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ function fakeService(
161161
disposed = true;
162162
sessionDisposalCount += 1;
163163
},
164+
invalidateCatalog: () => {
165+
revision = createSqlRevisionToken();
166+
return revision;
167+
},
164168
isCurrent: (candidate) => !disposed && candidate === revision,
165169
onDidChange: (nextListener) => {
166170
listener = nextListener;

0 commit comments

Comments
 (0)