Repository navigation
postgres_cdc: batch streamed rows per transaction over a buffered channel (ENG-1344) - #4812
Conversation
8446e10 to
4f00298
Compare
|
It feels a bit risky rolling this out as a default behavior for users without a batching policy already configured. Could we propose an alternative where we instead added a new field so that this would be opt-in? If we're of the opinion that the UX of an opt-in is poor then I think it's potentially fine to change the default batching behavior but I'd like to consider it. |
|
An example of where this new behavior could break an existing pipeline: input:
postgres_cdc:
dsn: postgres://pg:pw@localhost:15432/db?sslmode=disable
schema: public
tables: [src]
slot_name: test_slot
stream_snapshot: false
temporary_slot: true
output:
fallback:
- sql_insert:
driver: postgres
dsn: postgres://pg:pw@localhost:15432/db?sslmode=disable
table: sink
columns: [id, v]
args_mapping: root = [this.id, this.v]
- sql_insert:
driver: postgres
dsn: postgres://pg:pw@localhost:15432/db?sslmode=disable
table: dlq
columns: [id, v, err]
args_mapping: root = [this.id, this.v, @fallback_error]
Why it happens: Setup: main
this PR
Neither |
|
Could we split this in two? First PR: the internal changes only. The reader still groups rows per transaction, the remap ring and the Second PR: change the default (or add an opt-in) to send whole transactions as one batch. That one needs its own changelog entry and a discussion of the fallback/DLQ case above. |
…ransactions field (ENG-1344) Review feedback on #4812: changing the default output shape from one message per batch to one transaction per batch changes the unit of failure downstream. A multi-row sql_insert behind fallback sends the whole transaction to the DLQ when one row violates a constraint, and without fallback the whole transaction is retried instead of one row. The default now matches main: with no batching policy, streaming rows go through the count-1 batcher one message at a time, and snapshot pages still arrive whole. A new batch_transactions bool (default false) enables the per-transaction pass-through, capped at min(1000, checkpoint_limit/2) rows or 4 MiB of WAL. It is rejected alongside a batching policy, which would re-batch the transactions. The reader-side batching, buffered channel and commit remap are unchanged, so the default path keeps the decode/marshal overlap. Tests: resolveBatchingPolicy covers the rejection and the processors-only policy; passesThrough is table-tested; the default integration test now asserts one row per batch, and the transaction-sized and large-transaction tests set batch_transactions.
…ader batches (ENG-1344)
…saction cap path (ENG-1344)
…ches by checkpoint_limit, quiet clean-stop logs (ENG-1344)
…_limit-derived batch cap (ENG-1344)
…k remap to checkpoint_limit (ENG-1344) A failing batching.processors chain restarted the stream, which re-read the same rows and failed the same way for good. The input now keeps its own copy of the messages it hands the batcher and publishes them with the error set instead, as it already does for an unmarshalable row. The four flush error sites become one pair of helpers, so a clean stop racing a flush no longer logs a restart. The commit remap ring is sized to checkpoint_limit plus the channel depth rather than a fixed 16, and a heartbeat updates the newest entry in place rather than taking a slot. commitLSN builds its hard-stop context once instead of once per frame. Document what no batching policy means, the real streaming batch cap, the aggregate channel buffer bound and the batch_created metric; poll the slot instead of sleeping in the new integration tests.
…er WAL frame (ENG-1344) Sizing the ring to checkpoint_limit made lookup a linear scan of about a thousand entries, and commitLSN runs it on every received frame, missing on every call whenever a batching policy is configured. Keep a map from last row to ring slot alongside the ring, cleared as slots are reused.
|
@Jeffail Agreed the default should not move. Pushed as |
|
@Leward Confirmed: |
|
@Leward Keeping this as one PR, but with the shape you describe: the internal changes (reader batching, buffered channel, remap ring, |
…ransactions field (ENG-1344) Review feedback on #4812: changing the default output shape from one message per batch to one transaction per batch changes the unit of failure downstream. A multi-row sql_insert behind fallback sends the whole transaction to the DLQ when one row violates a constraint, and without fallback the whole transaction is retried instead of one row. The default now matches main: with no batching policy, streaming rows go through the count-1 batcher one message at a time, and snapshot pages still arrive whole. A new batch_transactions bool (default false) enables the per-transaction pass-through, capped at min(1000, checkpoint_limit/2) rows or 4 MiB of WAL. It is rejected alongside a batching policy, which would re-batch the transactions. The reader-side batching, buffered channel and commit remap are unchanged, so the default path keeps the decode/marshal overlap. Tests: resolveBatchingPolicy covers the rejection and the processors-only policy; passesThrough is table-tested; the default integration test now asserts one row per batch, and the transaction-sized and large-transaction tests set batch_transactions.
943e637 to
d562dde
Compare
…at the end of the benchmark replay (ENG-1344) The fake walsender closes its connection as soon as the last frame is flushed, and the reader now hands the final transaction over at that same frame (its commit), so it can hit EOF on the wire while the batch is still in the channel. The harness treated that as a premature exit whenever the select happened to pick it over the delivery signal, which the Go framework's N=1 warm-up run made likely. The consumer is now given a moment to drain before the exit counts as a failure. Before/after runs of the documented invocation are recorded in docs/benchmark-results/postgres.md as a dated streaming section; the real-endpoint runs remain pending on a license.
| - The gain is a lower per-row reader cost. The reader/consumer overlap the buffered channel enables is not visible here because the consumer is a counter; it shows up only in the end-to-end runs below. | ||
| - With no `batching` policy and `batch_transactions` off (the default), the input's consumer still emits one message per row, so end-to-end throughput in that configuration is bounded by the consumer stage rather than the reader. | ||
|
|
||
| **Pending (real-endpoint phase):** the Docker harness in [`internal/impl/postgresql/bench/`](../../internal/impl/postgresql/bench/) (cart table, GOMAXPROCS 1/2/4/8) for the default configuration, `batch_transactions: true`, and `batching.count: 1000`, plus a rerun of the AWS orders-cdc scenario. Both need an enterprise license on the bench host; results will be appended here as a further dated section when run. |
There was a problem hiding this comment.
Only the local benchmarking phase is covered (CONTRIBUTING.md §1.3.4: "Benchmarking covers two distinct phases, both of which are required: … Real-endpoint benchmarking: benchmarks run against a real deployed server / real endpoint, exercising the connector against the actual target system rather than a local stand-in.")
This PR's performance claim (~1.75× on the reader path) rests entirely on BenchmarkStreamMessages against an in-process fake walsender over loopback, which is the local phase. The real-endpoint phase is recorded here as pending, and §1.3.5 ("benchmarks have been run at various throughput levels so that we can determine CPU and memory trendlines") is likewise unaddressed — the microbenchmark runs a single fixed workload with a counter-only consumer, which the section itself notes hides the reader/consumer overlap that the buffered channel exists to create.
The decision to land with the real-endpoint phase outstanding is the reviewers' call, but it should be an explicit one rather than deferred. If the license blocker means it genuinely cannot run before merge, consider linking a tracking issue here so the follow-up is recorded outside this file.
There was a problem hiding this comment.
Agreed that landing with the real-endpoint phase outstanding is the reviewers' call, and the section records it as pending rather than done. The follow-up is tracked under the epic this PR belongs to, ENG-1344, whose remaining scope is exactly the Docker and AWS orders-cdc runs for the default configuration, batch_transactions: true and batching.count: 1000.
| === `batching` | ||
|
|
||
| Allows you to configure a xref:configuration:batching.adoc[batching policy]. | ||
| Optional batching of the emitted messages, honoured exactly on both the snapshot and streaming paths: `count: 1` gives one message per batch. With no policy configured, streaming rows arrive one message at a time and each snapshot page (`snapshot_batch_size` rows) arrives as one batch; see `batch_transactions` to receive whole transactions instead. If the policy's `processors` fail on a batch, its rows are published unprocessed with their error set rather than dropped or retried, so error-handling components can route them. |
There was a problem hiding this comment.
We are losing the reference to xref:configuration:batching.adoc[batching policy].
Optional xref:configuration:batching.adoc[batching policy] for the emitted messages.
By default, streaming rows arrive one message at a time and snapshot rows arrive one page at a time.
To change this:
* Set a policy to control batch size and timing. It applies to both the snapshot and the stream.
* Set `batch_transactions` to receive each replication transaction as one batch. This cannot be combined with a policy.
There was a problem hiding this comment.
Applied, with the cross-reference restored. I kept one closing sentence on what happens when the policy's processors fail, since that behaviour is new in this PR.
| // checkpoint_limit transactions can await acks downstream, plus the | ||
| // batches buffered in the channel, the one the reader is filling and the | ||
| // one the consumer holds. | ||
| ackRemapWindow := checkpointLimit + streamChannelDepth + 2 |
There was a problem hiding this comment.
IMO the hardest part to review of this PR is the commitRemap ring and the checkpointLimit + streamChannelDepth + 2 bound that sizes it. While the comment explains the underlying logic it seems complex and easy to make a mistake there.
I think we could get away with a simpler approach: stamp the ack target on each StreamMessage when the batch is flushed (its own LSN, or the commit LSN for the last row of a committed transaction).
flushBatch then tracks that target in the checkpointer and the reader has nothing to remap. No ring, no window to size.
There was a problem hiding this comment.
Done, in postgres_cdc: stamp each row's ack position at flush instead of remapping acks through a ring. The reader stamps every message it flushes with the position to acknowledge for it (StreamMessage.AckLSN: its own LSN, or the commit LSN for the last row of a transaction closed in that batch). The consumer keeps the stamp of the last change row it handed to the batcher alongside the rows it already mirrors, and flushBatch tracks that stamp as the checkpoint payload. The ring, its sizing and the CheckpointLimit plumbing into the reader are gone. The reader keeps only the last emitted stamp and the newest commit, so an ack for the last emitted row can still advance over commits that emitted nothing (heartbeats, untracked tables).
…ping acks through a ring (ENG-1344) The reader remembered (last row, commit) LSN pairs in a ring sized from checkpoint_limit so an ack for a transaction's last row could be resolved to its commit record. That bound assumed every batch with an ack outstanding held at least one tracked message. With a batching policy the batcher holds rows before they are tracked, and Capped admits an oversize batch when nothing is pending, so the reader could flush far more than checkpoint_limit transactions between one output batch being tracked and its ack. The pair was evicted, the ack confirmed the row LSN, and Postgres replayed the transaction on restart. Silent, and not fixable by picking a larger window for a period-based policy. Instead the reader stamps every message it flushes with the position to acknowledge for it (StreamMessage.AckLSN): its own LSN, or the commit LSN when it is the last row of a transaction closed in that batch. The consumer already mirrors what the batcher holds since its last flush; it now keeps the stamp of the last row added alongside, and flushBatch tracks that stamp as the checkpoint payload. Whatever the batching processors do to the messages, acknowledging the batch they produce means every row handed over so far is processed, and the last row's stamp is the position that says so. The reader keeps only the last emitted stamp and the newest commit, so an ack for the last emitted row can still advance over commits that emitted nothing. The ring, its sizing and the CheckpointLimit plumbing into the reader are removed. The reviewer's reproduction is kept as TestFlushBatcherAcksTheLastRowAddedUnderABatchingPolicy: ten thousand one-row transactions in one output batch, ten thousand more before its ack, and the confirmed position is the batch's last commit. Also rewords the batch_transactions and batching field descriptions per review: one idea per paragraph, no internal terms, batching-policy cross-reference kept.
Main brought incremental snapshot (#4811, #4891) and shared control-signal decoding (#4919) into the same consumer loop and reader this branch reworks. Resolved by hand: - processStream keeps main's checkpoint tracker (LSN plus incremental snapshot state), the row-less checkpoint sentinel and the blocking snapshot barrier gate, and this branch's pass-through, per-message batcher flushes and stamped ack positions. The consumer-loop state is gathered in flushState; the number of rows the tracker cannot see yet is len(pending.msgs) rather than a separate counter. - The checkpoint payload is the stamp of the last change row added (pendingRows.add keeps it when snapshot rows follow), which replaces main's backward search over the "lsn" metadata of a mixed batch. TestFlushBatchLSNFromMixedBatch is rewritten against that seam as TestFlushBatchChecksTheStampOfTheLastChangeRow. - processChange takes main's ctx and xid parameters and keeps its incremental snapshot hooks (advanceIncrementalSnapshot on commit, dispatchSnapshotSignal, deduplicateStreamedRow) but returns the decoded message instead of sending it; streamMessages batches it as before. A chunk released at a commit is therefore sent before that transaction's own rows, so the consumer tracks the snapshot state before the commit position, which is the safe order. - CHANGELOG: this branch's entries move to a new Unreleased section above 4.112.0; the snowflake entry they sat beside shipped in 4.110.0. batch_transactions is now versioned 4.113.0.
|
Merged main, which brought incremental snapshot and the shared control-signal decoding into the same loop. How the two meet:
|
Main released 4.113.0, stopped committing the generated reference docs (docs/modules is gitignored now) and moved checkpoint_limit onto the shared replication.CheckpointLimitField helper. Resolved by taking the shared helper (the batch cap it used to mention is already stated on batch_transactions), deleting this branch's copy of the generated postgres_cdc page, placing the Unreleased entries above 4.113.0, and versioning batch_transactions as 4.114.0, the next release.
…ests (ENG-1344) The four tests added for batch_transactions and the batching policy ran the stream with its error discarded, so a failure in newPgStreamInput or Connect (a regression in resolveBatchingPolicy, a slot error on the restart run) would only show up later as a slot-never-active or EventuallyWithT timeout. They now report any error other than context.Canceled through t.Error, the form the rest of the file already uses.
|
|
||
| All notable changes to this project will be documented in this file. | ||
|
|
||
| ## Unreleased |
There was a problem hiding this comment.
Please remove these entries. The release workflow generates CHANGELOG.md from the merged PRs (release-notes.yml#L34-L103). The last run is #4933.
The workflow also inserts the new version heading after line 5 (L96-L101). A manual ## Unreleased section would then stay below that heading as stale text.
There was a problem hiding this comment.
Gone: the branch now carries no changelog entries of its own, and CHANGELOG.md is byte-identical to main (the merge that brought in the 4.114.0 release regenerated it). The red docs and test jobs were the introduced-in check, since 4.114.0 shipped after the last merge from main; batch_transactions is now versioned 4.115.0.
4.114.0 was released after this branch last merged main, so the introduced-in version moves to the next release; the docs and test jobs both fail TestNewSpecsHaveVersions otherwise.
…wledge fully filtered batches (ENG-1344) Two gaps found in a review pass over the branch, both pinned by tests. The reader applied its row and byte caps right after a row landed, so a transaction with exactly the cap's worth of rows was flushed before its commit record was seen and its last row was stamped with its own LSN. If a later transaction was emitted before the consumer acknowledged that batch, the slot was confirmed mid-transaction and a restart replayed the whole transaction. The caps now apply before the next row goes in, so a transaction ending exactly on a cap still waits for its commit and its last row carries it. Batch sizes for larger transactions are unchanged. When batching.processors dropped every row of a flush (a filter, say), nothing reached the pipeline, so nothing ever acknowledged the position those rows reached and the slot stopped advancing for as long as only filtered rows arrived. The position is now tracked as a row-less checkpoint and resolved at once, behind any earlier batch still in flight, the way a row-less incremental snapshot checkpoint is. This gap predates the branch; the stamp the branch carries alongside the pending rows is what makes the fix a few lines. Also: the pass-through path reuses pendingRows instead of its own slice and a backward scan for the last stamp; the rejection of batch_transactions with a batching policy names the real conflict (the pass-through bypasses the policy, processors included) and is tested with a processors-only policy; the rows republished after a processor failure are described as the batcher held them, since an earlier processor may already have altered them; the hard-stop context cached for the standby status update is gone, as the update ignores its context.
|
Self-review pass over the whole branch before merge (automated review plus a hand audit of the ack and LSN paths, both packages' integration suites under
Smaller: the pass-through path shares Findings reviewed and deliberately left as they are: explicit |
Summary
Streaming
postgres_cdcplateaued at ~81-85 MB/s regardless of cores (ENG-1344). The reader goroutine sent one row per unbuffered channel send, so reader and consumer ran in lockstep. This PR lets the reader and the batching/marshalling stages overlap rather than run in lockstep; the core-scaling claim rests on the pending Docker/AWS end-to-end numbers, not on the microbenchmark:min(1000, checkpoint_limit/2)(512 by default) / 4 MiB of WAL;batchingpolicy per message now that reader batches are multi-row (this also fixes a pre-existing overshoot on snapshot pages);batch_transactionsfield (defaultfalse). With it set and nobatchingpolicy, each reader batch passes straight through as one output batch, so streaming yields transaction-sized batches. The default output shape is unchanged from main: one message per batch during streaming, one page per batch during the snapshot;BenchmarkStreamMessages, a reader benchmark against an in-process fake walsender, and four integration tests locking in the batching behaviours (one row per batch by default, transaction-sized batches withbatch_transactions, an honouredcount, and a 1500-row transaction splitting at the cap with no replay after restart).LSN/ack semantics are unchanged: "emitted" still means "handed to the consumer"; the commit-LSN remap is promoted only after a successful hand-off (see
streamBatchand theflushclosure instreamMessages). One new config field,batch_transactions.Why
batch_transactionsis opt-inA whole-transaction batch changes the unit of failure downstream. With a multi-row
sql_insertbehindfallback, one row violating a constraint fails the batch with a plain (non-indexed) error, sofallbackroutes the whole transaction to the DLQ; withoutfallback, the whole transaction is retried rather than one row. The field description documents this, andbatch_transactionsis rejected alongside abatchingpolicy, which would re-batch the transactions.Numbers
Reader microbenchmark, both trees measured 2026-09-17 (Apple M4 Pro, 1200 B rows, 100 rows/txn,
-benchtime 200000x -count 3; full section with raw output indocs/benchmark-results/postgres.md):The microbenchmark's consumer only counts rows, so this gain reflects fewer channel operations and context allocations, not the reader/consumer overlap the change exists to enable.
Local Docker CDC bench (cart table), MB/s at GOMAXPROCS 1/2/4/8, for the default configuration,
batch_transactions: true, andbatching.count: 1000: pending (requires an enterprise license on the bench host).AWS orders-cdc rerun: pending.
Notes
batchingpolicy, including an explicitcount: 1, is honoured exactly per message, on the snapshot path as well: a snapshot page is no longer admitted whole under a policy, so an explicitcount: 1now yields one row per batch during the snapshot (on main it was byte-identical to the default and gave a page per batch). Leavebatchingunset to keep page-sized snapshot batches.batching.processorsdrop every row of a flush, the position those rows reached is still acknowledged (tracked as a row-less checkpoint behind any batch in flight), so a filter-only stretch cannot parkconfirmed_flush_lsn.checkpoint_limitso two can be in flight under the checkpoint cap; a snapshot page is still admitted whole, so in-flight messages can exceed the limit by up to one page (documented on the field).batching.processorsfail on a batch, its rows are published as the batcher held them, with the error set, instead of being silently dropped; a processor earlier in the chain may already have altered them.snapshot_batch_sizerows each) can be in flight where previously it was one.checkpoint_limitby up to one batch (a 1000-row streaming batch or one snapshot page), since a batch is admitted whenever nothing is pending.batchingpolicy, a batch flush error now restarts the stream instead of continuing past the rest of the transaction.Testing
streamBatchandcommitRemapunit tests (commit flush, row cap, byte cap, empty suppressed commit, LSN promotion, slice independence, ring eviction and lookup).resolveBatchingPolicy/passesThroughtable tests, including thebatch_transactions+batchingrejection.batch_transactionsgives transaction-sized batches;batching.counthonoured exactly; a single transaction larger than the streaming row cap (512 with the defaultcheckpoint_limit) arrives as several batches with no rows lost or replayed across a restart on the same slot.