mongodb_cdc, postgres_cdc: respect nacks and marshal failures in ack paths (CON-504) - #4676
mongodb_cdc, postgres_cdc: respect nacks and marshal failures in ack paths (CON-504)#4676squiidz wants to merge 8 commits into
Conversation
…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.
|
The nack-handling changes from the earlier review rounds have been unwound in the latest commit. The framework's documented contract for 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. |
…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).
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.
…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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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."
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 assnapshotAckFn) 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.Marshalfailure in the stream loop logged andbreak-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 passesfloat8NaN through as afloat64, whichencoding/jsonrejects (numericNaN 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 likenumericdoes) is a possible follow-up, out of scope here.Proof of Work
TestSnapshotAckFnunit tests: nack returns the error without resolving; ack resolves; unexpected resume token rejected.TestIntegrationPostgresMarshalFailureStopsStream: live stream delivers a sentinel row, then a'NaN'::double precisionrow followed by a normal row — asserts nothing is delivered past the poison row. Verified it fails against the pre-fix code, demonstrating the loss: