Skip to content

mongodb_cdc, postgres_cdc: respect nacks and marshal failures in ack paths (CON-504) - #4676

Open
squiidz wants to merge 8 commits into
mainfrom
con-504-mongodb-postgres-ack-fixes
Open

mongodb_cdc, postgres_cdc: respect nacks and marshal failures in ack paths (CON-504)#4676
squiidz wants to merge 8 commits into
mainfrom
con-504-mongodb-postgres-ack-fixes

Conversation

@squiidz

@squiidz squiidz commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Part of CON-504 (CDC at-least-once / ack-gated progress).

Two small fixes closing silent-loss paths found in the CON-504 audit:

mongodb_cdc — the snapshot batch ackFn ignored its error argument and resolved the checkpoint slot unconditionally. With auto_replay_nacks: false, a nacked snapshot batch freed its slot in the shared capped tracker, so later streaming batches advanced the resume token past the undelivered rows and a restart never re-read them. The ackFn (extracted as snapshotAckFn) now mirrors the streaming ackFn: a nack returns the error without resolving, keeping the tracker pinned so the resume token can never persist past undelivered snapshot rows.

postgres_cdc — a json.Marshal failure in the stream loop logged and break-ed, silently dropping that row plus the rest of its WAL batch while the stream kept running and checkpointed past them. This is reachable with real data: the decoder passes float8 NaN through as a float64, which encoding/json rejects (numeric NaN is already special-cased as a string). The stream now soft-stops instead — the LSN was never acked, so the restart resumes before the poison row. Note the behavior change: an unmarshalable row now stalls the stream loudly (restart loop with a clear error) rather than vanishing. Actually supporting NaN floats (e.g. encoding as a string like numeric does) is a possible follow-up, out of scope here.

Proof of Work

  • TestSnapshotAckFn unit tests: nack returns the error without resolving; ack resolves; unexpected resume token rejected.
  • TestIntegrationPostgresMarshalFailureStopsStream: live stream delivers a sentinel row, then a 'NaN'::double precision row followed by a normal row — asserts nothing is delivered past the poison row. Verified it fails against the pre-fix code, demonstrating the loss:
"[{"id":1,"value":1.5} {"id":3,"value":2.5}]" should have 1 item(s), but has 2
(id 2, the NaN row, was silently dropped while id 3 was delivered)

@squiidz squiidz changed the title CON-504 mongodb postgres ack fixes mongodb_cdc, postgres_cdc: respect nacks and marshal failures in ack paths (CON-504) Aug 6, 2026
@josephwoodward josephwoodward reopened this Aug 7, 2026
Comment thread internal/impl/postgresql/input_pg_stream.go Outdated
Comment thread internal/impl/mongodb/cdc/input.go Outdated
Comment thread internal/impl/mongodb/cdc/input.go Outdated
…f is an opt-in drop)

Reverts the nack guard added earlier on this branch. Per the framework's
documented contract for auto_replay_nacks ("If set to false these messages
will instead be deleted"), disabling replay is an explicit opt-in to drop
rejected messages - typically because failures are routed to a DLQ, which
acks. Pinning the checkpoint on nack contradicted that contract and
produced permanent backpressure once the checkpoint limit filled.
@squiidz

squiidz commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

The nack-handling changes from the earlier review rounds have been unwound in the latest commit. The framework's documented contract for auto_replay_nacks reads: "If set to false these messages will instead be deleted." — disabling replay is an explicit opt-in to drop rejected messages (typically because failures are routed to a DLQ, which acks them). Pinning the checkpoint on nack contradicted that contract and produced permanent backpressure once the in-flight limit filled. Ack functions now resolve their checkpoint slot on nack exactly like on ack.

Unwound here (0d016a2): the snapshot ackFn nack pin (back to resolve-always). The pre-existing streaming ackFn guard is untouched, and the postgres marshal-failure fix stays.

Comment thread internal/impl/postgresql/input_pg_stream.go Outdated
…uard

Review ask: the bare 'unexpected resume token' error gave no hint what it
meant. The message now states the invariant (snapshot slots carry no
token) and what a violation implies (snapshot and streaming acks
misrouted in the checkpoint tracker - a regression, not an operational
error).
Comment thread internal/impl/mongodb/cdc/input.go Outdated
The snapshot ack guard treated a non-nil resolved token as an internal
misroute, but it is a legitimate outcome: snapshot and streaming batches
share one ordered tracker and streaming tracking starts once snapshot
batches are enqueued, not acked. Under out-of-order acks (any output with
max_in_flight > 1) a snapshot slot's resolve can surface a streaming
batch's resume token as the new contiguous frontier - and since every
snapshot slot precedes every streaming slot, that frontier proves the
whole snapshot has settled. Erroring dropped that checkpoint. The token
now persists through the same path as a streaming ack (shared
persistResumeToken), matching how pg_stream handles the equivalent case.
Comment thread internal/impl/mongodb/cdc/input.go Outdated
Comment thread internal/impl/postgresql/input_pg_stream.go Outdated
…ign streaming nacks

Resolves the two open design threads on this PR:

- An unmarshalable WAL row (non-finite floats) previously soft-stopped
  the connector, which reconnected onto the same row forever - a restart
  loop that also pins the replication slot and risks WAL disk exhaustion
  on the server (reviewer finding). Per the reviewer's proposal the row
  is now published with its error set and a plain-text rendering as the
  payload: inspectable via errored(), routable with error-handling
  components, at-least-once preserved (the row IS delivered, flagged),
  and the checkpoint advances normally. The warn log names the table and
  LSN, the connector description documents the behavior, and the
  integration test asserts in-order delivery with the error flag instead
  of the stall.

- The streaming ack path still pinned its checkpoint slot on nack, which
  under auto_replay_nacks: false wedges the shared tracker permanently -
  the exact backpressure failure the ruling on this PR resolved for the
  snapshot path. Nacks now resolve and persist like acks, with the
  contract-drop logged at warn.
main's mongodb overhaul (token epochs, commitResumeToken, and the
storeSnapshotCheckpoint ack barrier) supersedes this branch's snapshot
ack changes: the barrier keeps the snapshot and streaming phases from
ever sharing the tracker concurrently, which makes the snapshot-token
guard a genuine invariant again - so the shared persistResumeToken
machinery and its tests are dropped in favour of main's design. What
survives of this branch on the mongodb side is the streaming nack
alignment (nacks resolve and commit through the epoch guard, with the
contract-drop logged), and on the postgres side the SetError routing
for unmarshalable rows, merged alongside main's control-signal
detection.
// at-least-once holds (the row IS delivered, flagged),
// and operators can inspect or route it with
// error-handling components.
p.logger.Warnf("Publishing unmarshalable row from table %s (LSN %v) with its error set for error-routing: %v", msg.Table, msg.LSN, marshalErr)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

msg.LSN is a *string (stream_message.go#L47-L49), so %v prints the pointer address (e.g. 0xc0000b4010) rather than the LSN. This warning is the only operator-facing signal that identifies which WAL row was published unmarshalable, so the position it is meant to convey is lost — see how the same value is dereferenced two lines below at input_pg_stream.go#L634-L636.

Suggested fix: dereference the pointer when non-nil (falling back to an empty/unknown rendering when msg.LSN == nil, which is the snapshot case) before formatting it into the log line.

Ref: CONTRIBUTING.md §1.2.2 — "Provides relevant logging to support troubleshooting."


// Give the input time to create the replication slot: streaming-only mode
// only sees rows inserted after the slot exists.
time.Sleep(5 * time.Second)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed sleep as a readiness gate makes this test flaky. With stream_snapshot: false the input only sees rows inserted after the replication slot exists, so if slot creation takes longer than 5s on a loaded CI runner the sentinel INSERT at line 328 is never captured, and the test fails 30s later at require.Eventually(len(received) == 1) — a hard failure with no retry path, since the sentinel is inserted exactly once.

Suggested fix: replace the sleep with a readiness poll — e.g. require.Eventually on SELECT 1 FROM pg_replication_slots WHERE slot_name = 'test_slot_marshal_failure' before inserting the sentinel, or retry the sentinel insert inside the Eventually until it is received.

Ref: .claude/agents/tester.md — "Readiness retries beyond wait strategy: When you need to retry application-level checks after the container is up ... use require.Eventually."

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants