Skip to content

Commit ca65a6c

Browse files
committed
Skip the subject sighting read for a principal already seen this window
1 parent 5044b51 commit ca65a6c

2 files changed

Lines changed: 136 additions & 4 deletions

File tree

packages/core/sdk/src/subject-registry.test.ts

Lines changed: 136 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it } from "@effect/vitest";
1+
import { beforeEach, describe, expect, it } from "@effect/vitest";
22
import { Effect } from "effect";
33
import { withQueryContext } from "@executor-js/fumadb/query";
44

@@ -7,7 +7,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolName } from "./i
77
import type { ExecutorOwnerPolicyContext } from "./owner-policy";
88
import { definePlugin } from "./plugin";
99
import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db";
10-
import { touchSubject } from "./subject-registry";
10+
import { resetSubjectTouchCache, touchSubject } from "./subject-registry";
1111
import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
1212

1313
// `touchSubject` is the only writer of the `subject` table. Written against the
@@ -48,6 +48,14 @@ const storedSubjects = (
4848
}));
4949
});
5050

51+
// `touchSubject` remembers, per process, which principals it already filed
52+
// inside the throttle window. Every case below reuses the same tenant/principal
53+
// against a FRESH database, so that memory has to be dropped between them or a
54+
// later case would skip the query its assertion depends on.
55+
beforeEach(() => {
56+
resetSubjectTouchCache();
57+
});
58+
5159
describe("touchSubject", () => {
5260
it.effect("creates the row on first sight", () =>
5361
withDb((db) =>
@@ -75,8 +83,8 @@ describe("touchSubject", () => {
7583
});
7684
const [first] = yield* storedSubjects(db);
7785

78-
// The hot path: N requests in quick succession must cost N reads and
79-
// zero writes, so the persisted stamp is unchanged afterwards.
86+
// The hot path: N requests in quick succession must cost zero writes,
87+
// so the persisted stamp is unchanged afterwards.
8088
yield* Effect.forEach([1, 2, 3], () =>
8189
touchSubject(db.db, {
8290
tenant: TENANT,
@@ -108,6 +116,11 @@ describe("touchSubject", () => {
108116
set: { last_seen_at: BigInt(stale) },
109117
}),
110118
);
119+
// Backdating the row stands in for elapsed time, and elapsed time ages
120+
// the process-local sighting memory too — otherwise this would assert
121+
// against a clock the memory never saw. Expiring it is what the passage
122+
// of time does; the assertion below is unchanged.
123+
resetSubjectTouchCache();
111124

112125
yield* touchSubject(db.db, {
113126
tenant: TENANT,
@@ -122,6 +135,125 @@ describe("touchSubject", () => {
122135
),
123136
);
124137

138+
it.effect("issues no query at all for a repeat sighting inside the throttle", () =>
139+
withDb((db) =>
140+
Effect.gen(function* () {
141+
// THE regression this guards: the request seam runs `touchSubject` on
142+
// every HTTP request and MCP session, so a sighting that still costs an
143+
// indexed read per request adds a round-trip to every request in the
144+
// product. Dropping the table after the first sighting makes any
145+
// further query fail loudly — and `touchSubject` swallows storage
146+
// failures, so a surviving query would be invisible in the row state.
147+
// Counting statements is the only way to see it.
148+
yield* touchSubject(db.db, {
149+
tenant: TENANT,
150+
externalId: "user_a",
151+
lastSeenThrottleMs: THROTTLE_MS,
152+
});
153+
154+
let queries = 0;
155+
const client = db.client as { execute: (...args: never[]) => unknown };
156+
const execute = client.execute.bind(client) as (...args: never[]) => unknown;
157+
client.execute = (...args: never[]) => {
158+
queries += 1;
159+
return execute(...args);
160+
};
161+
162+
yield* Effect.forEach([1, 2, 3], () =>
163+
touchSubject(db.db, {
164+
tenant: TENANT,
165+
externalId: "user_a",
166+
lastSeenThrottleMs: THROTTLE_MS,
167+
}),
168+
);
169+
170+
client.execute = execute as typeof client.execute;
171+
expect(queries).toBe(0);
172+
}),
173+
),
174+
);
175+
176+
it.effect("re-reads once the remembered sighting ages past the throttle", () =>
177+
withDb((db) =>
178+
Effect.gen(function* () {
179+
// The memory must expire with the throttle, not pin a principal as
180+
// "recently seen" forever: `last_seen_at` still has to advance for a
181+
// principal who keeps calling across windows.
182+
yield* touchSubject(db.db, {
183+
tenant: TENANT,
184+
externalId: "user_a",
185+
lastSeenThrottleMs: THROTTLE_MS,
186+
});
187+
const [first] = yield* storedSubjects(db);
188+
189+
// A zero throttle makes every remembered sighting instantly stale,
190+
// which is what a caller crossing the window observes.
191+
yield* touchSubject(db.db, {
192+
tenant: TENANT,
193+
externalId: "user_a",
194+
lastSeenThrottleMs: 0,
195+
});
196+
197+
const rows = yield* storedSubjects(db);
198+
expect(rows).toHaveLength(1);
199+
expect(rows[0]?.lastSeenMs).toBeGreaterThanOrEqual(first?.lastSeenMs ?? 0);
200+
}),
201+
),
202+
);
203+
204+
it.effect("remembers a first sighting per tenant, not per principal", () =>
205+
withDb((db) =>
206+
Effect.gen(function* () {
207+
// One account acting in two orgs is two rows. A memory keyed on the
208+
// principal alone would file the first tenant and skip the second,
209+
// losing the row the other tenant's admin view depends on.
210+
yield* touchSubject(db.db, {
211+
tenant: TENANT,
212+
externalId: "user_a",
213+
lastSeenThrottleMs: THROTTLE_MS,
214+
});
215+
yield* touchSubject(db.db, {
216+
tenant: OTHER_TENANT,
217+
externalId: "user_a",
218+
lastSeenThrottleMs: THROTTLE_MS,
219+
});
220+
221+
expect((yield* storedSubjects(db, OTHER_TENANT)).map((row) => row.externalId)).toEqual([
222+
"user_a",
223+
]);
224+
}),
225+
),
226+
);
227+
228+
it.effect("does not remember a sighting that failed to persist", () =>
229+
withDb((db) =>
230+
Effect.gen(function* () {
231+
// A storage failure is swallowed so the request survives. It must not
232+
// also be recorded as a successful sighting, or the principal would
233+
// stay unfiled for a whole throttle window after the fault cleared.
234+
yield* Effect.promise(() => db.client.execute("DROP TABLE subject"));
235+
yield* touchSubject(db.db, {
236+
tenant: TENANT,
237+
externalId: "user_a",
238+
lastSeenThrottleMs: THROTTLE_MS,
239+
});
240+
241+
yield* Effect.promise(() =>
242+
db.client.execute(
243+
"CREATE TABLE subject (row_id text PRIMARY KEY NOT NULL, tenant text NOT NULL, external_id text NOT NULL, created_at integer NOT NULL, last_seen_at blob, status text)",
244+
),
245+
);
246+
yield* touchSubject(db.db, {
247+
tenant: TENANT,
248+
externalId: "user_a",
249+
lastSeenThrottleMs: THROTTLE_MS,
250+
});
251+
252+
expect((yield* storedSubjects(db)).map((row) => row.externalId)).toEqual(["user_a"]);
253+
}),
254+
),
255+
);
256+
125257
it.effect("records nothing for a pure-org executor", () =>
126258
withDb((db) =>
127259
Effect.gen(function* () {
2.73 KB
Binary file not shown.

0 commit comments

Comments
 (0)