Unified · Split
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { describe, expect, it } from "@effect/vitest"; | |
| 1 | +import { beforeEach, describe, expect, it } from "@effect/vitest"; | |
| 2 | 2 | import { Effect } from "effect"; |
| 3 | 3 | import { withQueryContext } from "@executor-js/fumadb/query"; |
| 4 | 4 | |
| @@ -7,7 +7,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ToolName } from "./i · 2 unmodified lines | ||
| 7 | 7 | import type { ExecutorOwnerPolicyContext } from "./owner-policy"; |
| 8 | 8 | import { definePlugin } from "./plugin"; |
| 9 | 9 | import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; |
| 10 | −import { touchSubject } from "./subject-registry"; | |
| 10 | +import { resetSubjectTouchCache, touchSubject } from "./subject-registry"; | |
| 11 | 11 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 12 | 12 | |
| 13 | 13 | // `touchSubject` is the only writer of the `subject` table. Written against the |
| @@ -48,6 +48,14 @@ const storedSubjects = ( · 34 unmodified lines | ||
| 48 | 48 | })); |
| 49 | 49 | }); |
| 50 | 50 | |
| 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 | + | |
| 51 | 59 | describe("touchSubject", () => { |
| 52 | 60 | it.effect("creates the row on first sight", () => |
| 53 | 61 | withDb((db) => |
| @@ -75,8 +83,8 @@ describe("touchSubject", () => { · 21 unmodified lines | ||
| 75 | 83 | }); |
| 76 | 84 | const [first] = yield* storedSubjects(db); |
| 77 | 85 | |
| 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. | |
| 80 | 88 | yield* Effect.forEach([1, 2, 3], () => |
| 81 | 89 | touchSubject(db.db, { |
| 82 | 90 | tenant: TENANT, |
| @@ -108,6 +116,11 @@ describe("touchSubject", () => { · 25 unmodified lines | ||
| 108 | 116 | set: { last_seen_at: BigInt(stale) }, |
| 109 | 117 | }), |
| 110 | 118 | ); |
| 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(); | |
| 111 | 124 | |
| 112 | 125 | yield* touchSubject(db.db, { |
| 113 | 126 | tenant: TENANT, |
| @@ -122,6 +135,125 @@ describe("touchSubject", () => { · 8 unmodified lines | ||
| 122 | 135 | ), |
| 123 | 136 | ); |
| 124 | 137 | |
| 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 | + | |
| 125 | 257 | it.effect("records nothing for a pure-org executor", () => |
| 126 | 258 | withDb((db) => |
| 127 | 259 | Effect.gen(function* () { |
Binary file not shown.