-
Notifications
You must be signed in to change notification settings - Fork 957
mssqlserver_cdc: ack-gate snapshot checkpoint and fix transaction-boundary resume (CON-504) #4677
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9908f83
c5fdc32
2539c00
0c9ee6f
c17934a
53aef1f
2fdb39b
5c04730
4e7ea3d
90d6ac3
c3f2ff9
16e7f8f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,6 +42,27 @@ type batchPublisher struct { | |
| log *service.Logger | ||
| cacheLSN func(ctx context.Context, lsn replication.LSN) error | ||
| shutSig *shutdown.Signaller | ||
|
|
||
| // snapshotAckWG counts published snapshot batches that have not yet been | ||
| // acknowledged downstream. The snapshot->streaming handoff blocks on it so | ||
| // the post-snapshot LSN is never persisted while snapshot rows are in flight. | ||
| snapshotAckWG sync.WaitGroup | ||
| // persistMu serializes resolve+persist pairs. The ordered tracker hands | ||
| // out monotonically increasing frontiers, but ack functions and | ||
| // CheckpointWindow run on different goroutines: without a shared critical | ||
| // section around resolveFn()+cacheLSN, two persists can land out of order | ||
| // and regress the cached resume position. | ||
| persistMu sync.Mutex | ||
| // pendingCheckpointLSN mirrors the CheckpointLSN of the most recently | ||
| // added message (or a stronger drained-window LSN, see CheckpointWindow): | ||
| // the start LSN of the last transaction whose rows are all published, the | ||
| // only value safe to persist as a resume position. Guarded by batcherMu, | ||
| // so at flush time it always belongs to the flushed batch's last message. | ||
| pendingCheckpointLSN replication.LSN | ||
| // buffered counts messages currently held by the batcher (guarded by | ||
| // batcherMu). CheckpointWindow uses it to decide between deferring the | ||
| // window checkpoint to the buffered batch and registering a marker. | ||
| buffered int | ||
| } | ||
|
|
||
| // newBatchPublisher creates an instance of batchPublisher. | ||
|
|
@@ -80,7 +101,11 @@ func (p *batchPublisher) loop() { | |
| return | ||
| } | ||
|
|
||
| // UntilNext reads the batcher's internal state, which concurrent | ||
| // Publish calls mutate under batcherMu — take the same lock. | ||
| p.batcherMu.Lock() | ||
| tNext, exists := p.batcher.UntilNext() | ||
| p.batcherMu.Unlock() | ||
| if !exists { | ||
| if flushBatchTicker != nil { | ||
| flushBatchTicker.Stop() | ||
|
|
@@ -104,9 +129,14 @@ func (p *batchPublisher) loop() { | |
| adjustTimedFlush() | ||
| select { | ||
| case <-flushBatch: | ||
| var sendBatch service.MessageBatch | ||
| var ( | ||
| tracked *trackedBatch | ||
| trackErr error | ||
| ) | ||
|
|
||
| // Wrap this in a closure to make locking/unlocking easier. | ||
| // Wrap this in a closure to make locking/unlocking easier. Track | ||
| // happens under the same lock as the flush so the checkpoint | ||
| // sequence matches flush order. | ||
| func() { | ||
| p.batcherMu.Lock() | ||
| defer p.batcherMu.Unlock() | ||
|
|
@@ -119,13 +149,19 @@ func (p *batchPublisher) loop() { | |
| return | ||
| } | ||
|
|
||
| var sendBatch service.MessageBatch | ||
| if sendBatch, _ = p.batcher.Flush(closeAtLeisureCtx); len(sendBatch) == 0 { | ||
| return | ||
| } | ||
| p.buffered = 0 | ||
| tracked, trackErr = p.trackBatchLocked(closeAtLeisureCtx, sendBatch) | ||
| }() | ||
| if trackErr != nil { | ||
| return | ||
| } | ||
|
|
||
| if len(sendBatch) > 0 { | ||
| if err := p.publishBatch(closeAtLeisureCtx, sendBatch); err != nil { | ||
| if tracked != nil { | ||
| if err := p.sendTracked(closeAtLeisureCtx, tracked); err != nil { | ||
| return | ||
| } | ||
| } | ||
|
|
@@ -176,60 +212,184 @@ func (b *batchPublisher) Publish(ctx context.Context, m replication.MessageEvent | |
| msg.MetaSetImmut("schema", service.ImmutableAny{V: s}) | ||
| } | ||
|
|
||
| var flushedBatch []*service.Message | ||
| // Flush and Track must be atomic: Track order defines the checkpoint | ||
| // sequence, so another flusher (the timed-flush loop) must not interleave | ||
| // between our flush and our Track. Only the channel send happens outside | ||
| // the lock. | ||
| var tracked *trackedBatch | ||
| b.batcherMu.Lock() | ||
| b.pendingCheckpointLSN = m.CheckpointLSN | ||
| if b.batcher.Add(msg) { | ||
| flushedBatch, err = b.batcher.Flush(ctx) | ||
| var flushedBatch []*service.Message | ||
| if flushedBatch, err = b.batcher.Flush(ctx); err == nil && len(flushedBatch) > 0 { | ||
| b.buffered = 0 | ||
| tracked, err = b.trackBatchLocked(ctx, flushedBatch) | ||
| } | ||
| } else { | ||
| b.buffered++ | ||
| } | ||
| b.batcherMu.Unlock() | ||
| if err != nil { | ||
| return fmt.Errorf("flushing batch due to reaching count limit: %w", err) | ||
| } | ||
|
|
||
| // If a batch was flushed, publish it outside the lock | ||
| if len(flushedBatch) > 0 { | ||
| if err := b.publishBatch(ctx, flushedBatch); err != nil { | ||
| if tracked != nil { | ||
| if err := b.sendTracked(ctx, tracked); err != nil { | ||
| return fmt.Errorf("publishing flushed batch: %w", err) | ||
| } | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func (b *batchPublisher) publishBatch(ctx context.Context, batch service.MessageBatch) error { | ||
| if len(batch) == 0 { | ||
| return nil | ||
| } | ||
| // trackedBatch pairs a ready-to-send asyncMessage with the bookkeeping needed | ||
| // to roll back its snapshot-gate slot if the send fails. | ||
| type trackedBatch struct { | ||
| msg asyncMessage | ||
| isSnapshot bool | ||
| } | ||
|
|
||
| // trackBatchLocked registers the batch with the ordered checkpoint tracker and | ||
| // builds its ack function. It MUST be called with batcherMu held: Track order | ||
| // defines the checkpoint sequence, so it has to match flush order exactly. | ||
| func (b *batchPublisher) trackBatchLocked(ctx context.Context, batch service.MessageBatch) (*trackedBatch, error) { | ||
| lastMsg := batch[len(batch)-1] | ||
| var checkpointLSN []byte | ||
| // snapshot records don't have a lsn as we don't track those | ||
| if lsn, ok := lastMsg.MetaGet("lsn"); ok { | ||
| checkpointLSN = replication.LSN(lsn) | ||
| // Checkpoint only the pending checkpoint LSN: the last transaction whose | ||
| // rows are all published. The row's own lsn must never be persisted — all | ||
| // rows of a transaction share a start LSN and resume is exclusive (> lsn), | ||
| // so persisting it mid-transaction would skip the transaction's remaining | ||
| // rows on restart. Snapshot rows never carry one; we don't track those. | ||
| checkpointLSN := []byte(b.pendingCheckpointLSN) | ||
|
|
||
| // Snapshot batches are tracked so the snapshot->streaming handoff can block | ||
| // until they are acknowledged downstream (see waitSnapshotAcks). | ||
| isSnapshotBatch := false | ||
| if op, ok := lastMsg.MetaGet("operation"); ok && op == replication.MessageOperationRead.String() { | ||
| isSnapshotBatch = true | ||
| } | ||
|
|
||
| resolveFn, err := b.checkpoint.Track(ctx, checkpointLSN, int64(len(batch))) | ||
| if err != nil { | ||
| return fmt.Errorf("tracking LSN checkpoint for batch: %w", err) | ||
| } | ||
| msg := asyncMessage{ | ||
| msg: batch, | ||
| ackFn: func(ctx context.Context, _ error) error { | ||
| lsn := resolveFn() | ||
| if lsn != nil && len(*lsn) != 0 { | ||
| return b.cacheLSN(ctx, *lsn) | ||
| } | ||
| return nil | ||
| return nil, fmt.Errorf("tracking LSN checkpoint for batch: %w", err) | ||
| } | ||
| if isSnapshotBatch { | ||
| b.snapshotAckWG.Add(1) | ||
| } | ||
| return &trackedBatch{ | ||
| isSnapshot: isSnapshotBatch, | ||
| msg: asyncMessage{ | ||
| msg: batch, | ||
| // The ack error is deliberately ignored: nacks are replayed by | ||
| // auto_replay_nacks (the default), and disabling that is a | ||
| // documented opt-in to DROP rejected messages, so the checkpoint | ||
| // must advance past them rather than pin the tracker. | ||
| ackFn: func(ctx context.Context, _ error) error { | ||
| if isSnapshotBatch { | ||
| defer b.snapshotAckWG.Done() | ||
| } | ||
| b.persistMu.Lock() | ||
| defer b.persistMu.Unlock() | ||
| lsn := resolveFn() | ||
| if lsn != nil && len(*lsn) != 0 { | ||
| return b.cacheLSN(ctx, *lsn) | ||
| } | ||
| return nil | ||
| }, | ||
| }, | ||
| }, nil | ||
| } | ||
|
|
||
| // sendTracked hands a tracked batch to ReadBatch. Must be called WITHOUT | ||
| // batcherMu held (the send blocks until consumed). A failed send releases the | ||
| // batch's snapshot-gate slot. | ||
| func (b *batchPublisher) sendTracked(ctx context.Context, tracked *trackedBatch) error { | ||
| select { | ||
| case b.msgChan <- tracked.msg: | ||
| return nil | ||
| case <-ctx.Done(): | ||
| if tracked.isSnapshot { | ||
| b.snapshotAckWG.Done() | ||
| } | ||
| return ctx.Err() | ||
| } | ||
| } | ||
|
|
||
| // waitSnapshotAcks blocks until every published snapshot batch has been | ||
| // acknowledged (or nacked) downstream, or until ctx is cancelled. Nacked | ||
| // batches release the gate too: redelivery is owned by auto_replay_nacks, | ||
| // and disabling that is a documented opt-in to drop rejections. The ctx | ||
| // escape prevents a permanently-failing downstream from wedging shutdown. | ||
| func (b *batchPublisher) waitSnapshotAcks(ctx context.Context) error { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The gate treats a nack exactly like an ack, but the redelivery guarantee it relies on is user-toggleable, so the barrier can still lose snapshot rows.
Failure scenario: Suggested fix: track nacked snapshot batches separately (e.g. a flag set from
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 53aef1f. A nacked batch no longer resolves its checkpoint slot (nothing can be persisted past its rows), and a nacked snapshot batch records the error so |
||
| drained := make(chan struct{}) | ||
| go func() { | ||
| // May outlive this call if ctx fires first; bounded by process lifetime. | ||
| b.snapshotAckWG.Wait() | ||
| close(drained) | ||
| }() | ||
| select { | ||
| case b.msgChan <- msg: | ||
| case <-drained: | ||
| return nil | ||
| case <-ctx.Done(): | ||
| return ctx.Err() | ||
| } | ||
| } | ||
|
Comment on lines
+323
to
336
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug:
Failure scenario, with
The result is an unbounded loop that re-runs and re-emits the full snapshot on every reconnect and can never make progress, rather than a one-shot retry. Reset the gate state ( This also violates CONTRIBUTING.md §3.1.4 ("The implementation is complete and correct, with no known bugs"). Worth a unit test covering two sequential snapshot runs on the same publisher where the first nacks and the second acks cleanly.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 5c04730.
Comment on lines
+318
to
336
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The result is a livelock: the full snapshot is re-emitted downstream on every reconnect and the connector never makes progress, which conflicts with the completeness/diagnosability bar in CONTRIBUTING.md §3.1.4 and §3.2.2. Suggested fix: reset the gate state (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 5c04730 — see the reply on the duplicate thread above: the gate error now resets per snapshot attempt via |
||
|
|
||
| // CheckpointWindow records that every transaction up to and including lsn is | ||
| // fully published (a polling window drained), giving the stream an exact | ||
| // resume position instead of lagging one transaction behind (which would | ||
| // re-deliver the final transaction of a burst on every restart). | ||
| // | ||
| // The user's batching policy stays in charge of batch sizes: if rows from the | ||
| // window are still buffered, the window-end LSN simply becomes their batch's | ||
| // checkpoint payload (safe, and stronger than the last row's transaction | ||
| // boundary). Only when the batcher is empty is an immediately-resolved marker | ||
| // slot registered, so lsn persists once every published batch is acked. | ||
| func (b *batchPublisher) CheckpointWindow(ctx context.Context, lsn replication.LSN) error { | ||
| b.batcherMu.Lock() | ||
| if b.buffered > 0 { | ||
| b.pendingCheckpointLSN = lsn | ||
| b.batcherMu.Unlock() | ||
| return nil | ||
| } | ||
| resolveFn, err := b.checkpoint.Track(ctx, lsn, 1) | ||
| b.batcherMu.Unlock() | ||
| if err != nil { | ||
| return fmt.Errorf("tracking window checkpoint: %w", err) | ||
| } | ||
| // Resolve the marker immediately: if everything before it is already | ||
| // acked this persists lsn now; otherwise the last outstanding ack's | ||
| // resolve will surface it. | ||
| b.persistMu.Lock() | ||
| defer b.persistMu.Unlock() | ||
| if resolved := resolveFn(); resolved != nil && len(*resolved) != 0 { | ||
| return b.cacheLSN(ctx, *resolved) | ||
| } | ||
|
Comment on lines
+355
to
+367
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Checkpoint can regress:
Failure scenario — batch
The persisted checkpoint is now Suggested fix: funnel all checkpoint persistence through one serialization point — e.g. hold a dedicated mutex across Refs:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 16e7f8f. A persistMu now makes each resolveFn()+cacheLSN pair a single critical section in both the batch ackFn and CheckpointWindow, so cache writes land in tracker order. New test (TestPersistOrderUnderConcurrentAcksAndWindows) drives concurrent per-batch acks against interleaved window markers and asserts the persisted sequence never regresses — proven red against the unfixed code (regression reproduced under -race within one run). Full 10-test integration suite re-verified green. |
||
| return nil | ||
| } | ||
|
|
||
| // flushCurrent flushes any partial batch still held by the batcher and | ||
| // publishes it, leaving the publisher loop running. Used at the | ||
| // snapshot->streaming handoff so every snapshot row is published (and can be | ||
| // awaited via waitSnapshotAcks) before the post-snapshot LSN is persisted. | ||
| func (b *batchPublisher) flushCurrent(ctx context.Context) error { | ||
| if b.batcher == nil { | ||
| return nil | ||
| } | ||
| var tracked *trackedBatch | ||
| b.batcherMu.Lock() | ||
| remaining, err := b.batcher.Flush(ctx) | ||
| if err == nil && len(remaining) > 0 { | ||
| b.buffered = 0 | ||
| tracked, err = b.trackBatchLocked(ctx, remaining) | ||
| } | ||
| b.batcherMu.Unlock() | ||
| if err != nil || tracked == nil { | ||
| return err | ||
| } | ||
| return b.sendTracked(ctx, tracked) | ||
| } | ||
|
|
||
| func (b *batchPublisher) msgs() <-chan asyncMessage { | ||
| return b.msgChan | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The failed-send rollback is only partial: it releases the snapshot-gate slot but never resolves the checkpoint slot that
trackBatchLockedjust registered. When this branch is taken thetrackedBatch(and with itresolveFn) is discarded, so that tracker entry stays pending forever.Because
checkpoint.Cappedonly advances contiguously (see thecheckpoint_limitdocs: "Any given Log Sequence Number (LSN) will not be acknowledged unless all messages under that offset are delivered"), and because both the publisher and the tracker are built once in the constructor (input_mssqlserver_cdc.go#L238-L273) and therefore outliveConnectretries, one orphaned slot permanently pins the resume position for the rest of the process.Reachable path:
Snapshot.Readuseserrgroup.WithContext, so one worker's error cancels the shared ctx while sibling workers are blocked inPublish→sendTracked(snapshot.go#L203-L215) — likely whenever the pipeline is slower than the snapshot readers andmax_parallel_snapshot_tables > 1.processSnapshotthen fails, the input reconnects, the snapshot re-runs (still no cached LSN), and every batch of the new attempt tracks behind the orphan: no LSN is ever persisted again, and the orphan'slen(batch)slots stay counted againstcheckpoint_limit.Suggested fix: resolve (or otherwise discard) the tracker slot on the failed-send path alongside the
snapshotAckWG.Done(), so the rollback is symmetric — the same way the gate slot is released. Relevant to CONTRIBUTING.md §5.4.1 (durable checkpoint + resume) and §3.2.2 (hard-to-diagnose failure modes), since the resulting stall is silent.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The orphaned-slot wedge is real, but resolving the slot on the failed-send path is unsafe — it opens the data-loss window this PR closes.
The failed send only proves this batch never reached the pipeline; it does not stop the other flusher.
Publishsends under the caller's context (the snapshot errgroup's) while the timed-flush loop sends under the publisher's own shutdown context, so this interleaving is reachable: batch S is tracked, a timed flush tracks B (after S) and sends it successfully, then S's send fails on the cancelled errgroup ctx. B is downstream with rows after S's rows. If the rollback resolved S's slot, B's ack would persist an LSN past S's undelivered rows and a restart would skip them — silent loss, exactly what §5.4.2 forbids. Keeping the slot pinned is the conservative half of the current behavior; the bug is only that the pin outlives the session because the publisher and tracker are constructor-lifetime.Proposed fix: treat a failed send as poisoning the publisher.
sendTracked's failure path sets a poisoned flag alongside the existing gate release;Connectchecks it and rebuilds the publisher (batcher + fresh tracker) before starting a session. The orphan then pins nothing beyond its own dead session, and the new session re-reads from the last durable LSN, which by construction is before the orphaned rows. Late acks from the old session resolve into the abandoned tracker; the only residual risk is a stalecacheLSNwrite racing the new session's, which the existing persistMu plus a last-persisted-LSN monotonic guard turns into bounded replay rather than regression.This is deliberately narrower than the per-Connect rebuild that was unwound: it triggers only on the crash path (failed send), involves no nack semantics, and keeps the drop-contract behavior untouched. Happy to implement it that way if there are no objections.