Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 187 additions & 27 deletions internal/impl/mssqlserver/batcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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()

Copy link
Copy Markdown

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 trackBatchLocked just registered. When this branch is taken the trackedBatch (and with it resolveFn) is discarded, so that tracker entry stays pending forever.

Because checkpoint.Capped only advances contiguously (see the checkpoint_limit docs: "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 outlive Connect retries, one orphaned slot permanently pins the resume position for the rest of the process.

Reachable path: Snapshot.Read uses errgroup.WithContext, so one worker's error cancels the shared ctx while sibling workers are blocked in PublishsendTracked (snapshot.go#L203-L215) — likely whenever the pipeline is slower than the snapshot readers and max_parallel_snapshot_tables > 1. processSnapshot then 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's len(batch) slots stay counted against checkpoint_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.

Copy link
Copy Markdown
Contributor Author

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. Publish sends 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; Connect checks 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 stale cacheLSN write 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.

}
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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ackFn discards the ack error (func(ctx context.Context, _ error) error) and unconditionally runs defer b.snapshotAckWG.Done(), so a nacked snapshot batch releases the gate. The doc comment justifies this with "redelivery is owned by auto_replay_nacks", but this input exposes that as an opt-out field — service.NewAutoRetryNacksToggleField() / AutoRetryNacksBatchedToggled.

Failure scenario: auto_replay_nacks: false, stream_snapshot: true, downstream rejects one snapshot batch. waitSnapshotAcks returns as if it had been delivered, cacheLSN(softCtx, maxLSN) persists the post-snapshot LSN, and on restart the snapshot is skipped — the rejected rows are gone. That is the exact data-loss window this PR set out to close, and it contradicts CONTRIBUTING.md §5.4.2 ("at-least-once delivery, with progress gated on downstream ack").

Suggested fix: track nacked snapshot batches separately (e.g. a flag set from ackFn when the error is non-nil) and have waitSnapshotAcks return an error in that case so the post-snapshot LSN is not persisted; alternatively gate the barrier on auto_replay_nacks actually being enabled.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 waitSnapshotAcks returns it — cacheLSN is skipped and the snapshot re-runs on restart. Covered by the reworked "a nack releases the gate but fails it" subtest plus a new "a nacked batch pins the checkpoint" streaming subtest. Note the same gap exists in postgres (#4584, merged) and oracledb (#4675) — follow-ups planned.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: snapshotNackErr is sticky across reconnects, so one snapshot nack permanently wedges the input.

snapshotNackErr is only ever written (in recordSnapshotNack) and read here — it is never cleared. The batchPublisher is built once in the constructor (input_mssqlserver_cdc.go#L272-L277) and reused for every Connect, which is explicitly a re-entrant path (Connect resets i.stopSig at L348-L349, and ReadBatch returns service.ErrNotConnected at L471-L472 to trigger the framework's reconnect).

Failure scenario, with auto_replay_nacks: false:

  1. Run 1 snapshot emits batches; one is nacked → snapshotNackErr set.
  2. waitSnapshotAcks fails → input_mssqlserver_cdc.go#L381-L385 logs and calls TriggerHasStopped(); cacheLSN(maxLSN) is skipped (correct so far).
  3. ReadBatchErrNotConnected → framework calls Connect again. Still no cached LSN, so a fresh snapshot runs and every row is re-delivered.
  4. waitSnapshotAcks drains the new run's batches, then returns the stale run-1 error — even if every run-2 batch was acked successfully. The post-snapshot LSN is never persisted.

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 (snapshotNackErr, and ideally assert the snapshotAckWG is drained) at the start of each snapshot run — e.g. an explicit resetSnapshotGate() called from Connect before processSnapshot — so the gate reflects only the current attempt.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5c04730. resetSnapshotGate() clears the recorded nack at the start of each snapshot attempt (called from Connect before processSnapshot), so a nack fails only the attempt it belongs to and a clean re-run persists the LSN and proceeds to streaming. The WaitGroup is deliberately left untouched — a previous attempt's in-flight batches can still ack or nack and must keep counting. Covered by the suggested two-run unit test: run 1 nacks and fails the gate, run 2 acks cleanly and passes.

Comment on lines +318 to 336

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

snapshotNackErr is sticky for the lifetime of the batchPublisher, but the publisher is created once in the constructor (input_mssqlserver_cdc.go#L272-L277) and reused across every Connect — only stopSig is reset. That makes the failure permanent rather than per-connection:

  1. auto_replay_nacks: false, a snapshot batch is nacked → recordSnapshotNack sets snapshotNackErr.
  2. waitSnapshotAcks fails, the goroutine calls TriggerHasStopped, so ReadBatch returns service.ErrNotConnected (input_mssqlserver_cdc.go#L467-L475) and the framework calls Connect again.
  3. No LSN was cached (by design), so len(cachedLSN) == 0 and the snapshot re-runs in full (input_mssqlserver_cdc.go#L338-L341).
  4. On the new run waitSnapshotAcks returns the stale error immediately even if every batch was acked, so the LSN is never persisted and streaming is never reached.

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 (snapshotNackErr, and ideally assert/reset the snapshotAckWG bookkeeping) at the start of each snapshot run — e.g. a resetSnapshotGate() called from Connect before the snapshot goroutine starts — so a nack fails only the connection attempt it belongs to.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 resetSnapshotGate(), with a two-run unit test.


// 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Checkpoint can regress: CheckpointWindow and ackFn write the cache from different goroutines with no ordering.

CheckpointWindow runs on the stream goroutine (ReadChangeTables), while the ack path runs on the pipeline's ack goroutine. Both call resolveFn() and then b.cacheLSN(...) outside any shared lock, so the ordered tracker's guarantee (resolve returns monotonically-increasing payloads) does not carry through to the cache write.

Failure scenario — batch B is tracked with payload L0 and is still in flight; the polling window drains with buffered == 0, so a marker M with payload L1 > L0 is registered:

  1. Ack goroutine: B.resolveFn() → returns L0 (B is head, M not yet resolved).
  2. Stream goroutine: M.resolveFn() → returns L1 (M is now head).
  3. Stream goroutine wins the scheduling race and runs cacheLSN(L1) first.
  4. Ack goroutine then runs cacheLSN(L0), overwriting the newer position.

The persisted checkpoint is now L0, so a restart replays everything in (L0, L1] — the exact non-regression invariant TestTrackOrderUnderConcurrentFlush asserts (that test only acks from a single goroutine, so it can't catch this). No data loss, but it defeats the "exact end position of each drained window" goal this path was added for.

Suggested fix: funnel all checkpoint persistence through one serialization point — e.g. hold a dedicated mutex across resolveFn() + cacheLSN(...) in both CheckpointWindow and the batch ackFn, or track the last persisted LSN and skip writes that would move it backwards.

Refs: batcher.go#L340-L359, ack path batcher.go#L281-L291. Relevant to CONTRIBUTING.md §5.4.1 / §5.4.2 (durable checkpoint + resume, correct progress gating).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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
}
Loading