Repository navigation
Add a deterministic cluster test for the relayed-origin resume gap - #796
Conversation
…pro#432) Forces, on every run, the condition only the intermittent 4-node backlogRecovery stress test ever caught: a target that holds an origin's rows delivered only through a relay, and so has no resume cursor for that origin, loses the [T, head] backlog written while the relay was dead when its later direct subscription to the origin resolves cursorless. Chain O <- R <- T plus witness W on O, `databases: ['data']` so the system database cannot re-mesh the chain. R is SIGKILLed, O writes the backlog, it ages past the pre-#428 resume floor, and T adds O directly as a non-leader. Green asserts convergence on all four nodes plus the resume decision in T's log (`start time: 1` for O, then the #428 full-copy request). With HARPER_TEST_DISABLE_CURSORLESS_FULL_COPY=1 forwarded to T, the same scenario asserts the loss signature: a live T<-O link, no phase-2 row on T, and T frozen below every peer even after R returns. The hook (`cursorlessResumeStartForTest`) restores the pre-#428 `now - 60s` start for a non-leader cursorless source; it is env-gated, default-off, and unit-covered. Refs #432 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoGMRMSjqsUHVetXqfrzVL
…ut grace
Every Harper child inherits the runner's environment, so red-proof mode was enabling
HARPER_TEST_DISABLE_CURSORLESS_FULL_COPY on O, R and W as well as T. The hook is now set
explicitly per node ('0' on every control node, '1' only on T), and each control node's
log is asserted to carry no legacy-resume line. R is killed with graceMs: 0, and the
header no longer claims a plain SIGKILL. Comments trimmed to the why.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoGMRMSjqsUHVetXqfrzVL
…d-proof snapshot The hook now only supplies an override (undefined when unarmed), so the production `startTime = 0` no longer flows through a *ForTest helper that a hook sweep could delete. The red-proof waits for O and W to hold the live write before snapshotting the frozen counts, instead of assuming W's independent socket delivered it as fast as T's. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoGMRMSjqsUHVetXqfrzVL
… its mechanism After a zero-grace kill R may resume from its persisted O cursor or full-copy cursorless; the wait description no longer claims the former. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoGMRMSjqsUHVetXqfrzVL
Release cherry-pick
|
There was a problem hiding this comment.
Code Review
This pull request introduces a new integration test, relayedOriginResumeGap.test.mjs, and associated unit tests to verify the relayed-origin resume gap behavior, ensuring that a node without a resume cursor requests a full copy rather than silently resuming from a legacy timestamp. It also adds a test-only override function, maybeLegacyCursorlessResumeStartForTest, to simulate the legacy behavior when a specific environment variable is set. The review feedback suggests improving the parallel process startup in the integration test by assigning successfully started instances within their respective promise chains inside Promise.all to prevent resource leaks if one of the processes fails to start.
| await Promise.all( | ||
| contexts.map((nodeCtx, i) => startHarper(nodeCtx, nodeConfig(hostnames[i], hookEnv(roles[i] === 'T')))) | ||
| ); | ||
| [ctx.O, ctx.R, ctx.T, ctx.W] = contexts.map((nodeCtx) => nodeCtx.harper); |
There was a problem hiding this comment.
When starting multiple asynchronous processes in parallel using Promise.all, assign the successfully started instances within their respective promise chains (e.g., using .then()) rather than waiting for Promise.all to resolve. This ensures that if one process fails to start, the already started processes are still recorded in ctx and can be properly cleaned up during teardown, preventing resource leaks.
await Promise.all(
contexts.map((nodeCtx, i) =>
startHarper(nodeCtx, nodeConfig(hostnames[i], hookEnv(roles[i] === 'T'))).then(() => {
ctx[roles[i]] = nodeCtx.harper;
})
)
);References
- When starting multiple asynchronous processes in parallel using Promise.all, assign the successfully started instances within their respective promise chains (e.g. using .then()) rather than waiting for Promise.all to resolve. This ensures that if one process fails, the already started processes are still recorded and can be properly cleaned up during teardown.
Adds
integrationTests/cluster/relayedOriginResumeGap.test.mjs, a deterministic 4-node cluster test for the relayed-origin resume gap (the W2 acceptance test proposed on #432). Until now only the intermittent 4-nodebacklogRecoverystress test ever reproduced this class: a node that holds an origin's rows delivered only through a relay has no resume cursor for that origin at all, so when its later subscription to that origin resolves cursorless the pre-#428now - 60sstart silently dropped the backlog written while the relay was down. The test forces that condition on every run — a chain O ← R ← T plus a witness W on O withdatabases: ['data']so the system database cannot re-mesh the chain, asserted up front: T holds every O-origin row yet has no connection to O; R killed with no grace, the backlog written and aged past the legacy floor, then T adds O directly as a non-leader — and asserts exact-count convergence on all four nodes plus the resume decision in T's log (start time: 1for O, then the #428 full-copy request). One env-gated hook,maybeLegacyCursorlessResumeStartForTest(HARPER_TEST_DISABLE_CURSORLESS_FULL_COPY=1, default-off, unit-tested), supplies the pre-#428 start as an override only; look hardest at theelsebranch, which keeps the productionstartTime = 0and its two warn lines unchanged. The same file doubles as a red-proof: with the hook injected into T and pinned off on every other node (every child inherits the runner's environment, so each control node's log is asserted free of the legacy resume), the scenario asserts the loss signature — a live T←O link, no phase-2 row on T and T frozen below O and W, and still frozen below every peer after R returns.For the human reviewer
HARPER_TEST_DISABLE_CURSORLESS_FULL_COPY=1mode only run when someone runs them by hand; CI's cluster job runs the green mode. Kept because the dispatch brief asked for a hook-gated, CI-excluded red-proof so anyone can re-run the loss demonstration, and because W2's gap detection is meant to flip that mode green as its own acceptance signal. Alternative: drop the hook and rely on the green test's two log discriminators. Reversal is mechanical (delete the helper, its unit test, and thePROTECTION_DISABLEDbranches).killHarper({ graceMs: 0 })sends SIGTERM followed at once by SIGKILL, the closest the harness gets to the OOM kill the stress scenario models. The trade: after an abrupt kill R may come back with its persisted O cursor or resume cursorless and full-copy, so test 2 proves R converges without pinning which mechanism did it (its wait is named accordingly). A clean stop would make that deterministic at the cost of crash realism. Trivially reversible.start time: 1(a debug line, hencelogging.level: 'debug'on all four nodes), the full-copy warn line, and the hook's warn line. There is no non-log way to observe the decision today; adding one would be a small product API change outside this test-only brief. W4's per-origin cursors are expected to retire thestart time: 1discriminator anyway.Verification
npm run test:integration -- integrationTests/cluster/relayedOriginResumeGap.test.mjspassed 7/7 runs on this branch (five on the first commit: 81 s, 119 s, 105 s, 82 s, 82 s; one after each review-fix commit: 82 s, 86 s; the slower runs overlapped other suites). The first test carries the 65 s ageing window, the second restarts R and converges all four nodes.HARPER_TEST_DISABLE_CURSORLESS_FULL_COPY=1 npm run test:integration -- integrationTests/cluster/relayedOriginResumeGap.test.mjspassed 4/4 runs, asserting T frozen at 21 rows (phase 1 + the live write) while O and W hold 51,start time: 1for O in T's log with no full-copy request, no legacy-resume line in any control node's log, and T still at 21 after R returns.Timed out after 90000ms waiting for T convergence through its direct O subscription: every node at 50, last saw {"O":50,"T":20,"W":50}and, after R returns,last saw {"O":50,"R":50,"T":20,"W":50}.npm run test:unit— 845 passing, including the 4 cases inunitTests/replication/maybeLegacyCursorlessResumeStartForTest.test.mjs.proxiedResumeBacklog,proxiedLeadingDuplicateSkip,addNodeFullCopy,reverseBaseCopyDeclined— all pass.npm run lint:requiredclean; prettier applied to the changed files. The fulltest:integration:clustersuite is left to CI.txnlogTearReplication("B receives the entries written after a torn frame in A's local log"), the same failure main's latest integration run (33548173687) shows on all three Node versions, so it is pre-existing and unrelated to this change.Refs #432
Complexity: medium
🤖 Generated with Claude Code
https://claude.ai/code/session_01BoGMRMSjqsUHVetXqfrzVL
Review-Coverage: authored=claude; ran=codex; blocked=gemini(auth); declined=cursor-grok,cursor-composer,domain; rounds=3 @ 8a13685
Human-Review-Need: 4 @ 8a13685