Skip to content

crdb stack/6 resolved buffering - #4710

Merged
jgao54 merged 1 commit into
mainfrom
crdb-stack/6-resolved-buffering
Aug 24, 2026
Merged

crdb stack/6 resolved buffering#4710
jgao54 merged 1 commit into
mainfrom
crdb-stack/6-resolved-buffering

Conversation

@viragtripathi

Copy link
Copy Markdown
Collaborator

Follow-up to #4669, implementing the buffering discussed on the dedupe thread there and on Slack.

Changefeed events now wait in an in-memory buffer keyed by commit timestamp and row key. When a resolved timestamp arrives, everything it covers is emitted in commit order and the checkpoint advances to that same value, so nothing handed to the stream ever lies past the persisted checkpoint. Batch boundaries stop producing duplicates: records still buffered when a batch ends are dropped and re-read by the next batch from the cursor. Session reconnect replays land on the same buffer keys and collapse.

The buffer is bounded in bytes computed from the raw changefeed message sizes, not record count, since single events can be large. Crossing the cap (128 MiB) or a resolved stream that stalls past the batch deadline force-emits the buffer past the checkpoint with a warning, which is exactly the pre-existing at-least-once behavior, so the degraded mode is the previously reviewed semantics. Delivery overall remains at least once: a crash between emit and checkpoint persist still replays.

Validation: new unit tests for the replay-collapse, partial-emit-at-resolved and force-emit ordering paths, plus a new e2e test (Test_CDC_Exactly_Once_Across_Batches) asserting 82 events arrive exactly once across two consecutive pulls. Full suite run against a 5-node v26.2.5 cluster behind haproxy, 17/17 passing, and unit suites green.

@viragtripathi
viragtripathi requested a review from a team as a code owner August 20, 2026 03:46
@viragtripathi
viragtripathi requested a review from jgao54 August 20, 2026 03:48
@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown

Code review

One issue found. Checked for bugs and CLAUDE.md compliance.

(The inline-comment tool was unavailable in this session, so this is posted as a top-level comment with permalinks.)

Grace-deadline batch cut discards the buffer, so the batch can return zero records and the mirror stalls

now := time.Now()
if !state.graceDeadline.IsZero() && !now.Before(state.graceDeadline) {
return true, nil
}
if (state.recordCount > 0 || len(state.buffer) > 0) &&
!now.Before(state.batchDeadline.Add(2*state.resolvedInterval)) {

This branch returns true, nil without calling emitBuffered, so everything still buffered is dropped. That is fine when the buffer only holds a tail past the last resolved timestamp — but graceDeadline is now armed off recordCount + len(state.buffer):

}
}
if state.recordCount+uint32(len(state.buffer)) >= state.req.MaxBatchSize && state.graceDeadline.IsZero() {
// full batch: allow a short grace period for a final resolved
// timestamp so the checkpoint covers the pulled records
state.graceDeadline = time.Now().Add(2 * state.resolvedInterval)
}

Since recordCount is only incremented inside emitBuffered, a batch can reach MaxBatchSize entirely with buffered records and recordCount == 0. graceDeadline is then fillTime + 2*resolvedInterval (~2s, with changefeedResolvedInterval = 1s), while the new force-emit valve immediately below fires at batchDeadline + 2*resolvedInterval = firstBufferedRecord + IdleTimeout + 2s. Whenever the buffer fills in less than IdleTimeout, the grace branch is checked first and wins, so the valve at L483 is unreachable in exactly the case it was written for.

The result: PullRecords returns an empty batch, state.cursor never advances (it is only assigned in handleResolved), and the next batch restarts the changefeed from the same cursor and reproduces the identical state — no forward progress. Pre-PR this path returned a full MaxBatchSize of already-emitted records, so it always advanced. A realistic trigger is a resolved-timestamp stall with rows still flowing, e.g. a CockroachDB catch-up scan, which would then restart from the same cursor on every batch and never complete.

Suggested fix: either force-emit before cutting the batch here, or gate the grace deadline on state.recordCount >= state.req.MaxBatchSize so a buffered-only batch falls through to the force-emit valve at L483. Note that no current test drives the grace-deadline branch of runChangefeedSession with a non-empty buffer.

@viragtripathi
viragtripathi force-pushed the crdb-stack/6-resolved-buffering branch from 3388ef7 to 314cb61 Compare August 20, 2026 04:12
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Flaky Test Detected

Analysis: All four failures are fixed wall-clock deadline expiries (60s mirror-startup budget in SetupCDCFlowStatusQuery, plus one EnvWaitFor resync timeout) in MySQL/MariaDB→ClickHouse e2e tests on a CockroachDB-focused branch, with disjoint failure sets across the two failing matrix jobs and a third matrix job passing all of them — consistent with runner contention rather than a regression.
Confidence: 0.85

✅ Automatically retrying the workflow

View workflow run

@viragtripathi
viragtripathi force-pushed the crdb-stack/6-resolved-buffering branch from 314cb61 to 37584f3 Compare August 21, 2026 02:56

@jgao54 jgao54 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

one small nit, otherwise lgtm!

// stashRecord buffers a converted record under its commit timestamp and row
// key. A replayed message (same timestamp, table and key) overwrites its
// earlier copy instead of queueing a duplicate.
func (state *changefeedPullState) stashRecord(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ah this is neat, since it also dedupes duplicated messages send from server-side retries

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeah that fell out of keying the buffer by timestamp and row key, reconnect and server side replays just land on the same slot.

Comment thread flow/connectors/cockroachdb/cdc.go Outdated
// no data loss: nothing buffered was emitted, so the persisted
// cursor still precedes these records and the next batch re-reads
// them from the changefeed
c.logger.Info("[cockroachdb] leaving records past the final resolved timestamp for the next batch",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: this logline would get a bit noisy as it would be emitted every PullRecord on a busy pipe, can be removed or emit at debug level

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Moved it to debug. And thanks for the review, and for all of them across the stack, the connector came out a lot better for how thorough you've been.

Events now wait in an in-memory buffer keyed by commit timestamp and row
key, are emitted in commit order once a resolved timestamp at or beyond
them arrives, and the checkpoint advances to that same value, so batches
no longer re-deliver events on the happy path. Session reconnect replays
land on the same buffer keys and collapse. The buffer is bounded by raw
message bytes (128 MiB); crossing the cap or a stalled resolved stream
force-emits past the checkpoint with a warning, which is the pre-existing
at-least-once behavior. Records still buffered at batch end are left for
the next batch to re-read from the cursor.
@viragtripathi
viragtripathi force-pushed the crdb-stack/6-resolved-buffering branch from 37584f3 to 9f51504 Compare August 24, 2026 06:52
@viragtripathi
viragtripathi requested a review from jgao54 August 24, 2026 07:16
@jgao54
jgao54 merged commit e547990 into main Aug 24, 2026
27 checks passed
@jgao54
jgao54 deleted the crdb-stack/6-resolved-buffering branch August 24, 2026 18:56
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.

2 participants