|
1 | | -// Repro for the run-ops split read-after-write hole on the TTL BATCH EXPIRY GUARD. |
| 1 | +// Guard for the run-ops split read-after-write hazard on the TTL BATCH EXPIRY path. |
2 | 2 | // |
3 | 3 | // TtlSystem.expireRunsBatch (internal-packages/run-engine/src/engine/systems/ttlSystem.ts:161) reads |
4 | 4 | // the runs it is about to expire with: |
5 | | -// runStore.findRuns({ where: { id: { in: runIds } }, select: {...} }) // NO client |
6 | | -// A client-less findRuns is NOT a read-your-writes signal, so RoutingRunStore routes each leg to the |
7 | | -// owning store's REPLICA (#ownPrimary(store, undefined) === undefined -> readOnlyPrisma). Any run whose |
8 | | -// row is not yet visible on that replica comes back absent, is bucketed `not_found` |
9 | | -// (ttlSystem.ts:196-200), skipped, and NEVER expired. Because the TTL Lua script has ALREADY removed |
10 | | -// the run from the Redis queue before this guard runs, a single missed read is not self-healing: the |
11 | | -// run is permanently ORPHANED (never expired, never emits `runExpired`, never re-queued). Contrast the |
12 | | -// single-run path expireRun (ttlSystem.ts:36) which passes `prisma` to findRun -> the OWNING store's |
13 | | -// primary, so it never misses. |
| 5 | +// runStore.findRuns({ where: { id: { in: runIds } }, select: {...} }, this.$.prisma) |
| 6 | +// It threads the owning primary (like the single-run path expireRun at ttlSystem.ts:36 does), so it |
| 7 | +// reads the run it just wrote. The hazard the primary read avoids: a client-less findRuns is NOT a |
| 8 | +// read-your-writes signal, so RoutingRunStore would route each leg to the owning store's REPLICA |
| 9 | +// (#ownPrimary(store, undefined) === undefined -> readOnlyPrisma). A run whose row is not yet visible |
| 10 | +// on that replica would come back absent, be bucketed `not_found` (ttlSystem.ts:196-200), skipped, and |
| 11 | +// NEVER expired. Because the TTL Lua script has ALREADY removed the run from the Redis queue before |
| 12 | +// this guard runs, a single missed read would not self-heal: the run would be permanently ORPHANED |
| 13 | +// (never expired, never emits `runExpired`, never re-queued). |
14 | 14 | // |
15 | | -// This is the SUSPECT/weakest of the read-after-write class: the run row is usually written well before |
16 | | -// TTL fires, so a lagging replica has normally caught up. The read-your-writes window only exists for |
17 | | -// a very-short-TTL run swept moments after creation. This test forces the lag with the shared |
18 | | -// lagging-replica primitive to determine whether the mechanism orphans the run: if the guard misses |
19 | | -// the run on the replica and buckets it `not_found`, the run is orphaned; if it is still found, the |
20 | | -// mechanism is not at fault. |
| 15 | +// This is the weakest of the read-after-write class: the run row is usually written well before TTL |
| 16 | +// fires, so a lagging replica has normally caught up. The read-your-writes window only exists for a |
| 17 | +// very-short-TTL run swept moments after creation. This test forces the lag with the shared |
| 18 | +// lagging-replica primitive and pins both paths: the primary-threaded read (the production path) |
| 19 | +// expires the run, while the client-less control path misses it on the replica and would orphan it. |
21 | 20 | // |
22 | 21 | // Deterministic via heteroRunOpsPostgresTest (real split topology, NEVER mocked): a LEGACY-resident |
23 | 22 | // (cuid) run is created on the control-plane writer; the control-plane replica lags (its `taskRun` |
@@ -164,12 +163,12 @@ async function guardExpireBatch( |
164 | 163 | return { expired, skipped }; |
165 | 164 | } |
166 | 165 |
|
167 | | -describe("run-ops split — TTL batch expiry guard reads a lagging replica and orphans the run", () => { |
| 166 | +describe("run-ops split — TTL batch expiry guard threads the owning primary; the client-less path would orphan the run", () => { |
168 | 167 | // A LEGACY-resident (cuid) run sits PENDING on the control-plane WRITER; the control-plane REPLICA |
169 | 168 | // lags (its `taskRun` reads come back empty, as just after a very-short-TTL run is created). The |
170 | 169 | // TTL Lua script has already dequeued the run, so this guard is its only chance to be expired. |
171 | 170 | heteroRunOpsPostgresTest( |
172 | | - "LEGACY cuid: client-less batch guard misses the run on the lagging replica -> orphaned as not_found", |
| 171 | + "LEGACY cuid: primary-threaded guard expires the run; the client-less control path misses it on the lagging replica -> orphaned as not_found", |
173 | 172 | async ({ prisma14, prisma17 }) => { |
174 | 173 | // Control-plane replica lags on `taskRun`: the just-created run row is not visible yet. |
175 | 174 | const legacyReplica = laggingReplica(prisma14, [{ model: "taskRun", mode: "missing" }]); |
@@ -210,16 +209,15 @@ describe("run-ops split — TTL batch expiry guard reads a lagging replica and o |
210 | 209 | expect(viaPrimary.expired).toEqual([runId]); |
211 | 210 | expect(viaPrimary.skipped).toEqual([]); |
212 | 211 |
|
213 | | - // The actual guard as ttlSystem.expireRunsBatch calls it: client-less findRuns → owning store's |
214 | | - // REPLICA (the buggy batch path). |
| 212 | + // The client-less control path (pre-fix shape): findRuns with NO client → owning store's REPLICA. |
215 | 213 | const guard = await guardExpireBatch(router, [runId]); |
216 | 214 |
|
217 | 215 | // Proves the misrouted read is what happened: the (stale) legacy replica was consulted. |
218 | 216 | expect(legacyReplica.wasHit("taskRun")).toBe(true); |
219 | 217 |
|
220 | | - // The HAZARD the fix avoids: the old client-less findRuns misses the run on the lagging replica, |
221 | | - // buckets it `not_found`, and the run is ORPHANED — never expired. Pinning that here documents |
222 | | - // why ttlSystem.ts:161 must thread this.$.prisma (as the viaPrimary path above now does). |
| 218 | + // The HAZARD the primary read avoids: a client-less findRuns misses the run on the lagging |
| 219 | + // replica, buckets it `not_found`, and the run would be ORPHANED — never expired. Pinning it here |
| 220 | + // documents why ttlSystem.ts:161 threads this.$.prisma (the viaPrimary path above). |
223 | 221 | expect(guard.expired).toEqual([]); |
224 | 222 | expect(guard.skipped).toEqual([{ runId, reason: "not_found" }]); |
225 | 223 | } |
|
0 commit comments