Skip to content

fix(replication): guard copy cursor write against undefined table name - #322

Merged
kriszyp merged 2 commits into
mainfrom
kris/fix-copy-cursor-undefined
Jun 10, 2026
Merged

kriszyp merged 2 commits into
mainfrom
kris/fix-copy-cursor-undefined

Conversation

@kriszyp

@kriszyp kriszyp commented Jun 10, 2026

Copy link
Copy Markdown
Member

Summary

One-line guard change in the bulk-copy resume path to prevent persisting a cursor with currentTable: undefined.

What this fixes

When the last decoded event in a copy frame has no table name (event.table is undefined), the previous guard if (isCopyFrame && event && ...) still passes (the event object is truthy) and writes currentTable: undefined to the persisted copyCursor. On every subsequent reconnect the leader evaluates tables[copyResume.currentTable] → tables[undefined] is always falsy, which triggers the "Copy-resume table missing or unreplicated, restarting full copy" path and restarts from zero — completely defeating the resumable-copy feature (#255).

Change: event && → event?.table &&, with an else if warn log when we have an event but no table name to help surface the underlying cause (why a decoded event has no table name is still an open question in #321).

Closes #321. Reported in Slack thread (spectrum-et cluster on 5.1.0-beta.2, system DB 800M+, copy looping endlessly with 67 MB SSTs landing each cycle but never finalizing).

What to look at

  • The single changed condition at replication/replicationConnection.ts ~line 2100. The warn branch is the only new code worth scrutiny.
  • The root cause of event.table being undefined is still unknown. The fix prevents the corrupted cursor; the warn log will surface instances in logs so we can trace it.

Generated by claude-sonnet-4-6

If the last event decoded in a copy frame has no table name (event.table
is undefined), the persisted copyCursor would store currentTable:
undefined. On every subsequent reconnect the leader checks
tables[copyResume.currentTable] — tables[undefined] is always falsy —
and the full copy restarts from zero, defeating the resumable-copy
feature entirely (#255).

Add event.table to the cursor-write guard so a batch whose final decoded
event carries no table name leaves the existing (valid) cursor in place
instead of overwriting it with an invalid one. A warn log fires in that
case to surface the underlying cause.

Fixes #321.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@kriszyp
kriszyp requested a review from heskew June 10, 2026 16:42
@claude

claude Bot commented Jun 10, 2026 •

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

Summary: Two-commit PR. The core fix (29a67cb) tightens the copy-cursor guard in replicationConnection.ts — changing event && to event?.table && so a batch whose last decoded event has no table name doesn't corrupt the persisted copyCursor with currentTable: undefined. The second commit (d756bf3) removes an unused waitForAvailableStatus helper from the stress test that caused a lint failure.

Core fix observations:

  • The guard change is correct. The persisted cursor value is now only updated when event.table is a non-empty string, so a reconnect can't hit the tables[undefined] → falsy → restart-from-zero path.
  • The else if warn branch follows the established logger.warn?.(connectionId, '...', databaseName) pattern and uses proper optional chaining on the conditional logger.
  • The warn condition mirrors the positive-case condition (copyFromNodeId !== undefined). Gemini's suggestion to drop that check from the warn is reasonable — it would also fire when getIdOfRemoteNode returned undefined and no cursor was ever managed — but it's not a correctness issue. Either choice is defensible.
  • The else if branch is theoretically unreachable today (the only code path that sets event always sets event.table = tableDecoder.name, which is always a string). The PR description acknowledges the root cause is still unknown; the guard is defensive and the warn log will surface it if it ever fires.

Stress-test cleanup:

  • waitForAvailableStatus was defined but its logic was already inlined in the poll loop; removing it is correct. Line-reformatting only.

No security, auth, or API contract concerns.

@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 updates the replication connection logic to prevent advancing the copy cursor if the event is missing a table name, and adds a warning log for this scenario. The reviewer suggested removing the copyFromNodeId !== undefined check from the warning condition to ensure the diagnostic log is always captured when an event lacks a table name.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +2106 to 2108
} else if (isCopyFrame && event && !event.table && copyFromNodeId !== undefined) {
logger.warn?.(connectionId, 'copy cursor not advanced: event has no table name', databaseName);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The else if block is intended to log a warning when an event is missing its table name during a copy frame, which is a key diagnostic for debugging the root cause of this issue. However, by requiring copyFromNodeId !== undefined in the else if condition, this warning will be silently skipped if copyFromNodeId happens to be undefined.

Since event.table being missing is an unexpected decoder anomaly, we should log this warning regardless of whether copyFromNodeId is valid. Removing the copyFromNodeId !== undefined check from the else if condition ensures we always capture this diagnostic log.

Suggested change
} else if (isCopyFrame && event && !event.table && copyFromNodeId !== undefined) {
logger.warn?.(connectionId, 'copy cursor not advanced: event has no table name', databaseName);
}
} else if (isCopyFrame && event && !event.table) {
logger.warn?.(connectionId, 'copy cursor not advanced: event has no table name', databaseName);
}

The function was defined but its logic is duplicated inline in the
test's polling loop. This caused a lint error blocking CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@kriszyp
kriszyp marked this pull request as ready for review June 10, 2026 16:52
@kriszyp
kriszyp requested a review from a team as a code owner June 10, 2026 16:52
@kriszyp
kriszyp merged commit 9930fea into main Jun 10, 2026
27 checks passed
@kriszyp
kriszyp deleted the kris/fix-copy-cursor-undefined branch June 10, 2026 22:03
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.

replication: malformed copy-resume cursor (currentTable undefined) causes permanent full-copy restart loop

3 participants