History

Skip the subject sighting read for a principal already seen this window

Rhys Sullivan committed 3 months ago

Commit ca65a6c1 parent 5044b512 files · +135 −3Browse files

Unified · Split

2 files
  1. packages/core/sdk/src/subject-registry.test.ts +136 −4
  2. packages/core/sdk/src/subject-registry.ts +0 −0
packages/core/sdk/src/subject-registry.test.ts+136−4
@@ -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 · 2 unmodified lines
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 = ( · 34 unmodified lines
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", () => { · 21 unmodified lines
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", () => { · 25 unmodified lines
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", () => { · 8 unmodified lines
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* () {
packages/core/sdk/src/subject-registry.ts+0−0

Binary file not shown.