|
1 | 1 | import type { |
2 | 2 | CompletedWaitpointRecord, |
| 3 | + ReadClient, |
3 | 4 | ResolveCompletedWaitpointsArgs, |
4 | 5 | RunStore, |
5 | 6 | } from "@internal/run-store"; |
@@ -64,22 +65,29 @@ const RUN_OUTPUT_CHUNK_SIZE = 100; |
64 | 65 | * and it forces `id` into the projection so the map keys correctly even though this select |
65 | 66 | * names only `output`. |
66 | 67 | * |
67 | | - * Takes no read client on purpose. The router reads the owning store's REPLICA when no client is |
68 | | - * passed, and forces its PRIMARY for any client that is not replica-branded -- so accepting one |
69 | | - * would let a caller put a wide `TaskRun.output` read on the writer by reflex. This read does not |
70 | | - * need read-your-writes: the child run committed its output before it completed the waitpoint that |
71 | | - * unblocked this parent, so replica lag cannot hide it. That is the opposite of the envelope read |
72 | | - * in the legacy arm, which reads a waitpoint completed moments earlier and must use the writer. |
| 68 | + * The client is REQUIRED, and must be the writer, because this read needs read-your-writes. |
| 69 | + * |
| 70 | + * It is tempting to argue the replica is safe here: the child commits its output before it |
| 71 | + * completes the waitpoint that unblocks this parent, so the write happens first. That ordering is |
| 72 | + * real but it does not help, because the READER can observe the completion by another route -- |
| 73 | + * from Redis, or from the primary -- while the run-output replica still trails. The output then |
| 74 | + * reads null and the resolver refuses the resume as a lost output. A hard refusal is a far worse |
| 75 | + * outcome than a read on the writer, and this read is bounded and projected to one column. |
| 76 | + * |
| 77 | + * The router turns any client that is not replica-branded into the owning store's primary, and |
| 78 | + * turns NO client into its replica -- so an optional parameter would silently reintroduce the lag |
| 79 | + * window every time a caller omitted it. Hence required. |
73 | 80 | */ |
74 | 81 | export function createRunOutputsReader( |
75 | | - runStore: Pick<RunStore, "findRunsByIds"> |
| 82 | + runStore: Pick<RunStore, "findRunsByIds">, |
| 83 | + client: ReadClient |
76 | 84 | ): (taskRunIds: string[]) => Promise<Map<string, string>> { |
77 | 85 | return async (taskRunIds) => { |
78 | 86 | const outputs = new Map<string, string>(); |
79 | 87 |
|
80 | 88 | for (let i = 0; i < taskRunIds.length; i += RUN_OUTPUT_CHUNK_SIZE) { |
81 | 89 | const chunk = taskRunIds.slice(i, i + RUN_OUTPUT_CHUNK_SIZE); |
82 | | - const rows = await runStore.findRunsByIds(chunk, { select: { output: true } }); |
| 90 | + const rows = await runStore.findRunsByIds(chunk, { select: { output: true } }, client); |
83 | 91 |
|
84 | 92 | for (const [id, row] of rows) { |
85 | 93 | // A row present with a null output is the same absence as a missing row: either way the |
|
0 commit comments