| author | |
| committer | |
| log | 6de4b47ecb5fc1cee11a3f3ef1b8279c35764cf6 |
| tree | fd2e62eec0d0336b308ff14df8931b484a2ec573 |
| parent | 27c2b145f8d214b5c578eefc463278d0916fb048 |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 285 insertions(+), 12 deletions(-)
apps/web/src/orchestrationRecovery.test.ts+177-4| ... | ... | @@ -1,6 +1,9 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | import { createOrchestrationRecoveryCoordinator } from "./orchestrationRecovery"; | |
| 3 | import { | |
| 4 | createOrchestrationRecoveryCoordinator, | |
| 5 | deriveReplayRetryDecision, | |
| 6 | } from "./orchestrationRecovery"; | |
| 4 | 7 | |
| 5 | 8 | describe("createOrchestrationRecoveryCoordinator", () => { |
| 6 | 9 | it("defers live events until bootstrap completes and then requests replay", () => { |
| ... | ... | @@ -59,10 +62,13 @@ describe("createOrchestrationRecoveryCoordinator", () => { |
| 59 | 62 | coordinator.classifyDomainEvent(7); |
| 60 | 63 | coordinator.markEventBatchApplied([{ sequence: 4 }, { sequence: 5 }, { sequence: 6 }]); |
| 61 | 64 | |
| 62 | expect(coordinator.completeReplayRecovery()).toBe(true); | |
| 65 | expect(coordinator.completeReplayRecovery()).toEqual({ | |
| 66 | replayMadeProgress: true, | |
| 67 | shouldReplay: true, | |
| 68 | }); | |
| 63 | 69 | }); |
| 64 | 70 | |
| 65 | it("does not immediately replay again when replay returns no new events", () => { | |
| 71 | it("retries replay when no progress was made but higher live sequences were observed", () => { | |
| 66 | 72 | const coordinator = createOrchestrationRecoveryCoordinator(); |
| 67 | 73 | |
| 68 | 74 | coordinator.beginSnapshotRecovery("bootstrap"); |
| ... | ... | @@ -70,7 +76,10 @@ describe("createOrchestrationRecoveryCoordinator", () => { |
| 70 | 76 | coordinator.classifyDomainEvent(5); |
| 71 | 77 | coordinator.beginReplayRecovery("sequence-gap"); |
| 72 | 78 | |
| 73 | expect(coordinator.completeReplayRecovery()).toBe(false); | |
| 79 | expect(coordinator.completeReplayRecovery()).toEqual({ | |
| 80 | replayMadeProgress: false, | |
| 81 | shouldReplay: true, | |
| 82 | }); | |
| 74 | 83 | expect(coordinator.getState()).toMatchObject({ |
| 75 | 84 | latestSequence: 3, |
| 76 | 85 | highestObservedSequence: 5, |
| ... | ... | @@ -79,6 +88,19 @@ describe("createOrchestrationRecoveryCoordinator", () => { |
| 79 | 88 | }); |
| 80 | 89 | }); |
| 81 | 90 | |
| 91 | it("does not request another replay when a replay made no progress and nothing newer was observed", () => { | |
| 92 | const coordinator = createOrchestrationRecoveryCoordinator(); | |
| 93 | ||
| 94 | coordinator.beginSnapshotRecovery("bootstrap"); | |
| 95 | coordinator.completeSnapshotRecovery(3); | |
| 96 | coordinator.beginReplayRecovery("sequence-gap"); | |
| 97 | ||
| 98 | expect(coordinator.completeReplayRecovery()).toEqual({ | |
| 99 | replayMadeProgress: false, | |
| 100 | shouldReplay: false, | |
| 101 | }); | |
| 102 | }); | |
| 103 | ||
| 82 | 104 | it("marks replay failure as unbootstrapped so snapshot fallback is recovery-only", () => { |
| 83 | 105 | const coordinator = createOrchestrationRecoveryCoordinator(); |
| 84 | 106 | |
| ... | ... | @@ -131,3 +153,154 @@ describe("createOrchestrationRecoveryCoordinator", () => { |
| 131 | 153 | }); |
| 132 | 154 | }); |
| 133 | 155 | }); |
| 156 | ||
| 157 | describe("deriveReplayRetryDecision", () => { | |
| 158 | it("retries immediately when replay made progress", () => { | |
| 159 | expect( | |
| 160 | deriveReplayRetryDecision({ | |
| 161 | previousTracker: { | |
| 162 | attempts: 2, | |
| 163 | latestSequence: 3, | |
| 164 | highestObservedSequence: 5, | |
| 165 | }, | |
| 166 | completion: { | |
| 167 | replayMadeProgress: true, | |
| 168 | shouldReplay: true, | |
| 169 | }, | |
| 170 | recoveryState: { | |
| 171 | latestSequence: 5, | |
| 172 | highestObservedSequence: 5, | |
| 173 | }, | |
| 174 | baseDelayMs: 100, | |
| 175 | maxNoProgressRetries: 3, | |
| 176 | }), | |
| 177 | ).toEqual({ | |
| 178 | shouldRetry: true, | |
| 179 | delayMs: 0, | |
| 180 | tracker: null, | |
| 181 | }); | |
| 182 | }); | |
| 183 | ||
| 184 | it("caps no-progress retries for the same frontier", () => { | |
| 185 | const first = deriveReplayRetryDecision({ | |
| 186 | previousTracker: null, | |
| 187 | completion: { | |
| 188 | replayMadeProgress: false, | |
| 189 | shouldReplay: true, | |
| 190 | }, | |
| 191 | recoveryState: { | |
| 192 | latestSequence: 3, | |
| 193 | highestObservedSequence: 5, | |
| 194 | }, | |
| 195 | baseDelayMs: 100, | |
| 196 | maxNoProgressRetries: 3, | |
| 197 | }); | |
| 198 | ||
| 199 | const second = deriveReplayRetryDecision({ | |
| 200 | previousTracker: first.tracker, | |
| 201 | completion: { | |
| 202 | replayMadeProgress: false, | |
| 203 | shouldReplay: true, | |
| 204 | }, | |
| 205 | recoveryState: { | |
| 206 | latestSequence: 3, | |
| 207 | highestObservedSequence: 5, | |
| 208 | }, | |
| 209 | baseDelayMs: 100, | |
| 210 | maxNoProgressRetries: 3, | |
| 211 | }); | |
| 212 | ||
| 213 | const third = deriveReplayRetryDecision({ | |
| 214 | previousTracker: second.tracker, | |
| 215 | completion: { | |
| 216 | replayMadeProgress: false, | |
| 217 | shouldReplay: true, | |
| 218 | }, | |
| 219 | recoveryState: { | |
| 220 | latestSequence: 3, | |
| 221 | highestObservedSequence: 5, | |
| 222 | }, | |
| 223 | baseDelayMs: 100, | |
| 224 | maxNoProgressRetries: 3, | |
| 225 | }); | |
| 226 | ||
| 227 | const fourth = deriveReplayRetryDecision({ | |
| 228 | previousTracker: third.tracker, | |
| 229 | completion: { | |
| 230 | replayMadeProgress: false, | |
| 231 | shouldReplay: true, | |
| 232 | }, | |
| 233 | recoveryState: { | |
| 234 | latestSequence: 3, | |
| 235 | highestObservedSequence: 5, | |
| 236 | }, | |
| 237 | baseDelayMs: 100, | |
| 238 | maxNoProgressRetries: 3, | |
| 239 | }); | |
| 240 | ||
| 241 | expect(first).toEqual({ | |
| 242 | shouldRetry: true, | |
| 243 | delayMs: 100, | |
| 244 | tracker: { | |
| 245 | attempts: 1, | |
| 246 | latestSequence: 3, | |
| 247 | highestObservedSequence: 5, | |
| 248 | }, | |
| 249 | }); | |
| 250 | expect(second).toEqual({ | |
| 251 | shouldRetry: true, | |
| 252 | delayMs: 200, | |
| 253 | tracker: { | |
| 254 | attempts: 2, | |
| 255 | latestSequence: 3, | |
| 256 | highestObservedSequence: 5, | |
| 257 | }, | |
| 258 | }); | |
| 259 | expect(third).toEqual({ | |
| 260 | shouldRetry: true, | |
| 261 | delayMs: 400, | |
| 262 | tracker: { | |
| 263 | attempts: 3, | |
| 264 | latestSequence: 3, | |
| 265 | highestObservedSequence: 5, | |
| 266 | }, | |
| 267 | }); | |
| 268 | expect(fourth).toEqual({ | |
| 269 | shouldRetry: false, | |
| 270 | delayMs: 0, | |
| 271 | tracker: null, | |
| 272 | }); | |
| 273 | }); | |
| 274 | ||
| 275 | it("resets the retry budget when the replay frontier changes", () => { | |
| 276 | const exhausted = { | |
| 277 | attempts: 3, | |
| 278 | latestSequence: 3, | |
| 279 | highestObservedSequence: 5, | |
| 280 | }; | |
| 281 | ||
| 282 | expect( | |
| 283 | deriveReplayRetryDecision({ | |
| 284 | previousTracker: exhausted, | |
| 285 | completion: { | |
| 286 | replayMadeProgress: false, | |
| 287 | shouldReplay: true, | |
| 288 | }, | |
| 289 | recoveryState: { | |
| 290 | latestSequence: 3, | |
| 291 | highestObservedSequence: 6, | |
| 292 | }, | |
| 293 | baseDelayMs: 100, | |
| 294 | maxNoProgressRetries: 3, | |
| 295 | }), | |
| 296 | ).toEqual({ | |
| 297 | shouldRetry: true, | |
| 298 | delayMs: 100, | |
| 299 | tracker: { | |
| 300 | attempts: 1, | |
| 301 | latestSequence: 3, | |
| 302 | highestObservedSequence: 6, | |
| 303 | }, | |
| 304 | }); | |
| 305 | }); | |
| 306 | }); |
apps/web/src/orchestrationRecovery.ts+72-6| ... | ... | @@ -13,8 +13,74 @@ export interface OrchestrationRecoveryState { |
| 13 | 13 | inFlight: OrchestrationRecoveryPhase | null; |
| 14 | 14 | } |
| 15 | 15 | |
| 16 | export interface ReplayRecoveryCompletion { | |
| 17 | replayMadeProgress: boolean; | |
| 18 | shouldReplay: boolean; | |
| 19 | } | |
| 20 | ||
| 21 | export interface ReplayRetryTracker { | |
| 22 | attempts: number; | |
| 23 | latestSequence: number; | |
| 24 | highestObservedSequence: number; | |
| 25 | } | |
| 26 | ||
| 27 | export interface ReplayRetryDecision { | |
| 28 | shouldRetry: boolean; | |
| 29 | delayMs: number; | |
| 30 | tracker: ReplayRetryTracker | null; | |
| 31 | } | |
| 32 | ||
| 16 | 33 | type SequencedEvent = Readonly<{ sequence: number }>; |
| 17 | 34 | |
| 35 | export function deriveReplayRetryDecision(input: { | |
| 36 | previousTracker: ReplayRetryTracker | null; | |
| 37 | completion: ReplayRecoveryCompletion; | |
| 38 | recoveryState: Pick<OrchestrationRecoveryState, "latestSequence" | "highestObservedSequence">; | |
| 39 | baseDelayMs: number; | |
| 40 | maxNoProgressRetries: number; | |
| 41 | }): ReplayRetryDecision { | |
| 42 | if (!input.completion.shouldReplay) { | |
| 43 | return { | |
| 44 | shouldRetry: false, | |
| 45 | delayMs: 0, | |
| 46 | tracker: null, | |
| 47 | }; | |
| 48 | } | |
| 49 | ||
| 50 | if (input.completion.replayMadeProgress) { | |
| 51 | return { | |
| 52 | shouldRetry: true, | |
| 53 | delayMs: 0, | |
| 54 | tracker: null, | |
| 55 | }; | |
| 56 | } | |
| 57 | ||
| 58 | const previousTracker = input.previousTracker; | |
| 59 | const sameFrontier = | |
| 60 | previousTracker !== null && | |
| 61 | previousTracker.latestSequence === input.recoveryState.latestSequence && | |
| 62 | previousTracker.highestObservedSequence === input.recoveryState.highestObservedSequence; | |
| 63 | ||
| 64 | const attempts = sameFrontier && previousTracker !== null ? previousTracker.attempts + 1 : 1; | |
| 65 | if (attempts > input.maxNoProgressRetries) { | |
| 66 | return { | |
| 67 | shouldRetry: false, | |
| 68 | delayMs: 0, | |
| 69 | tracker: null, | |
| 70 | }; | |
| 71 | } | |
| 72 | ||
| 73 | return { | |
| 74 | shouldRetry: true, | |
| 75 | delayMs: input.baseDelayMs * 2 ** (attempts - 1), | |
| 76 | tracker: { | |
| 77 | attempts, | |
| 78 | latestSequence: input.recoveryState.latestSequence, | |
| 79 | highestObservedSequence: input.recoveryState.highestObservedSequence, | |
| 80 | }, | |
| 81 | }; | |
| 82 | } | |
| 83 | ||
| 18 | 84 | export function createOrchestrationRecoveryCoordinator() { |
| 19 | 85 | let state: OrchestrationRecoveryState = { |
| 20 | 86 | latestSequence: 0, |
| ... | ... | @@ -120,16 +186,16 @@ export function createOrchestrationRecoveryCoordinator() { |
| 120 | 186 | return true; |
| 121 | 187 | }, |
| 122 | 188 | |
| 123 | completeReplayRecovery(): boolean { | |
| 189 | completeReplayRecovery(): ReplayRecoveryCompletion { | |
| 124 | 190 | const replayMadeProgress = |
| 125 | 191 | replayStartSequence !== null && state.latestSequence > replayStartSequence; |
| 126 | 192 | replayStartSequence = null; |
| 127 | 193 | state.inFlight = null; |
| 128 | if (!replayMadeProgress) { | |
| 129 | state.pendingReplay = false; | |
| 130 | return false; | |
| 131 | } | |
| 132 | return resolveReplayNeedAfterRecovery().shouldReplay; | |
| 194 | const replayResolution = resolveReplayNeedAfterRecovery(); | |
| 195 | return { | |
| 196 | replayMadeProgress, | |
| 197 | shouldReplay: replayResolution.shouldReplay, | |
| 198 | }; | |
| 133 | 199 | }, |
| 134 | 200 | |
| 135 | 201 | failReplayRecovery(): void { |
apps/web/src/routes/__root.tsx+36-2| ... | ... | @@ -42,6 +42,7 @@ import { projectQueryKeys } from "../lib/projectReactQuery"; |
| 42 | 42 | import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; |
| 43 | 43 | import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects"; |
| 44 | 44 | import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery"; |
| 45 | import { deriveReplayRetryDecision } from "../orchestrationRecovery"; | |
| 45 | 46 | import { getWsRpcClient } from "~/wsRpcClient"; |
| 46 | 47 | |
| 47 | 48 | export const Route = createRootRouteWithContext<{ |
| ... | ... | @@ -188,6 +189,9 @@ function coalesceOrchestrationUiEvents( |
| 188 | 189 | return coalesced; |
| 189 | 190 | } |
| 190 | 191 | |
| 192 | const REPLAY_RECOVERY_RETRY_DELAY_MS = 100; | |
| 193 | const MAX_NO_PROGRESS_REPLAY_RETRIES = 3; | |
| 194 | ||
| 191 | 195 | function ServerStateBootstrap() { |
| 192 | 196 | useEffect(() => startServerStateSync(getWsRpcClient().server), []); |
| 193 | 197 | |
| ... | ... | @@ -309,6 +313,7 @@ function EventRouter() { |
| 309 | 313 | let disposed = false; |
| 310 | 314 | disposedRef.current = false; |
| 311 | 315 | const recovery = createOrchestrationRecoveryCoordinator(); |
| 316 | let replayRetryTracker: import("../orchestrationRecovery").ReplayRetryTracker | null = null; | |
| 312 | 317 | let needsProviderInvalidation = false; |
| 313 | 318 | const pendingDomainEvents: OrchestrationEvent[] = []; |
| 314 | 319 | let flushPendingDomainEventsScheduled = false; |
| ... | ... | @@ -435,13 +440,42 @@ function EventRouter() { |
| 435 | 440 | applyEventBatch(events); |
| 436 | 441 | } |
| 437 | 442 | } catch { |
| 443 | replayRetryTracker = null; | |
| 438 | 444 | recovery.failReplayRecovery(); |
| 439 | 445 | void fallbackToSnapshotRecovery(); |
| 440 | 446 | return; |
| 441 | 447 | } |
| 442 | 448 | |
| 443 | if (!disposed && recovery.completeReplayRecovery()) { | |
| 444 | void recoverFromSequenceGap(); | |
| 449 | if (!disposed) { | |
| 450 | const replayCompletion = recovery.completeReplayRecovery(); | |
| 451 | const retryDecision = deriveReplayRetryDecision({ | |
| 452 | previousTracker: replayRetryTracker, | |
| 453 | completion: replayCompletion, | |
| 454 | recoveryState: recovery.getState(), | |
| 455 | baseDelayMs: REPLAY_RECOVERY_RETRY_DELAY_MS, | |
| 456 | maxNoProgressRetries: MAX_NO_PROGRESS_REPLAY_RETRIES, | |
| 457 | }); | |
| 458 | replayRetryTracker = retryDecision.tracker; | |
| 459 | ||
| 460 | if (retryDecision.shouldRetry) { | |
| 461 | if (retryDecision.delayMs > 0) { | |
| 462 | await new Promise<void>((resolve) => { | |
| 463 | setTimeout(resolve, retryDecision.delayMs); | |
| 464 | }); | |
| 465 | if (disposed) { | |
| 466 | return; | |
| 467 | } | |
| 468 | } | |
| 469 | void recoverFromSequenceGap(); | |
| 470 | } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") { | |
| 471 | console.warn( | |
| 472 | "[orchestration-recovery]", | |
| 473 | "Stopping replay recovery after no-progress retries.", | |
| 474 | { | |
| 475 | state: recovery.getState(), | |
| 476 | }, | |
| 477 | ); | |
| 478 | } | |
| 445 | 479 | } |
| 446 | 480 | }; |
| 447 | 481 |