Skip to content

Add a deterministic cluster test for the relayed-origin resume gap - #796

Merged
kriszyp merged 4 commits into
mainfrom
test/relayed-origin-resume-gap
Sep 2, 2026
Merged

kriszyp merged 4 commits into
mainfrom
test/relayed-origin-resume-gap

Conversation

@kriszyp

@kriszyp kriszyp commented Sep 2, 2026 •

Copy link
Copy Markdown
Member

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-node backlogRecovery stress 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-#428 now - 60s start 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 with databases: ['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: 1 for 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 the else branch, which keeps the production startTime = 0 and 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

  1. Red-proof mode ships with no CI consumer. The env hook and the HARPER_TEST_DISABLE_CURSORLESS_FULL_COPY=1 mode 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 the PROTECTION_DISABLED branches).
  2. The 65 s ageing wait runs in the green mode too. Gating it on the red-proof would save 65 s per CI run, but then the green test's convergence assertion would also pass under the hook (phase 2 would be younger than the legacy floor), so "the same assertions go red when the protection is disabled" would hold only for the log oracle, not for the count. The wait is the scenario's clock, not a poll. One-line change either way.
  3. R is killed with zero grace rather than stopped cleanly. 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.
  4. The resume decision is asserted from log text. The oracle pins start time: 1 (a debug line, hence logging.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 the start time: 1 discriminator anyway.
  5. The fourth node is a witness on O, not a peer downstream of T (author's call, not from the review). Base-copied rows are audit-less snapshots on the receiver, so a peer subscribed to T's audit log would never receive the rows T recovers by full copy and could not converge. W instead proves O's backlog is live and replicable during the whole run. Worth knowing when W2 designs its heal path: a relay cannot heal a node that is directly connected to the origin, which the red-proof's second test pins.

Verification

  • End-to-end route: the new integration test itself. npm run test:integration -- integrationTests/cluster/relayedOriginResumeGap.test.mjs passed 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.
  • Red-proof, hook-gated mode: HARPER_TEST_DISABLE_CURSORLESS_FULL_COPY=1 npm run test:integration -- integrationTests/cluster/relayedOriginResumeGap.test.mjs passed 4/4 runs, asserting T frozen at 21 rows (phase 1 + the live write) while O and W hold 51, start time: 1 for 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.
  • Scratch run of the green assertions with the hook forced on T (a two-line local edit, not committed): both tests go red with the frozen-count signature — 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}.
  • Unit gate: npm run test:unit — 845 passing, including the 4 cases in unitTests/replication/maybeLegacyCursorlessResumeStartForTest.test.mjs.
  • Neighbouring cluster tests on the edited branch: proxiedResumeBacklog, proxiedLeadingDuplicateSkip, addNodeFullCopy, reverseBaseCopyDeclined — all pass. npm run lint:required clean; prettier applied to the changed files. The full test:integration:cluster suite is left to CI.
  • CI on this PR: the new test ran in cluster shard 6/6 and passed (66.6 s + 3.7 s). Cluster shard 1/6 is red on 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

kriszyp and others added 4 commits September 1, 2026 17:58
…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
@kriszyp kriszyp added this to the v5.2 milestone Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Release cherry-pick v5.2: merged

Cherry-picked onto v5.2.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +189 to +192
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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
  1. 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.

@kriszyp
kriszyp marked this pull request as ready for review September 2, 2026 23:05
@kriszyp
kriszyp requested a review from a team as a code owner September 2, 2026 23:05
@kriszyp
kriszyp merged commit 8cf4086 into main Sep 2, 2026
45 of 46 checks passed
@kriszyp
kriszyp deleted the test/relayed-origin-resume-gap branch September 2, 2026 23:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant