Skip to content

postgres_cdc: batch streamed rows per transaction over a buffered channel (ENG-1344) - #4812

Merged
squiidz merged 22 commits into
mainfrom
eng-1344-postgres-cdc-stream-batching
Oct 19, 2026
Merged

squiidz merged 22 commits into
mainfrom
eng-1344-postgres-cdc-stream-batching

Conversation

@squiidz

@squiidz squiidz commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Streaming postgres_cdc plateaued 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:

  • batches decoded rows in the reader and flushes at commit boundaries or at a row cap of min(1000, checkpoint_limit/2) (512 by default) / 4 MiB of WAL;
  • buffers the reader→consumer channel (depth 4);
  • reuses the receive deadline context across rows instead of allocating one per row;
  • makes the input honour a configured batching policy per message now that reader batches are multi-row (this also fixes a pre-existing overshoot on snapshot pages);
  • adds an opt-in batch_transactions field (default false). With it set and no batching policy, 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;
  • adds 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 with batch_transactions, an honoured count, 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 streamBatch and the flush closure in streamMessages). One new config field, batch_transactions.

Why batch_transactions is opt-in

A whole-transaction batch changes the unit of failure downstream. With a multi-row sql_insert behind fallback, one row violating a constraint fails the batch with a plain (non-indexed) error, so fallback routes the whole transaction to the DLQ; without fallback, the whole transaction is retried rather than one row. The field description documents this, and batch_transactions is rejected alongside a batching policy, 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 in docs/benchmark-results/postgres.md):

ns/row MB/s allocs/row
main + benchmark only (38060f7) 3485-3564 326-333 59
this PR 1952-2024 574-595 48

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, and batching.count: 1000: pending (requires an enterprise license on the bench host).
AWS orders-cdc rerun: pending.

Notes

  • Any configured batching policy, including an explicit count: 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 explicit count: 1 now yields one row per batch during the snapshot (on main it was byte-identical to the default and gave a page per batch). Leave batching unset to keep page-sized snapshot batches.
  • A transaction whose row count lands exactly on the streaming cap is still stamped with its commit: the caps apply before the next row is appended, so the commit record closes the batch.
  • When batching.processors drop 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 park confirmed_flush_lsn.
  • The reader keeps a ring of recent (last-row LSN, commit LSN) pairs, indexed by a map, and remaps downstream acks against it, so an ack for a transaction still sitting in the channel buffer at shutdown is confirmed as its commit record rather than a mid-transaction position (commit remap survives buffering).
  • Streaming reader batches are capped at half of checkpoint_limit so 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).
  • On a graceful stop, an in-flight batch that cannot be handed on is dropped silently (re-read on restart); only a non-shutdown flush failure logs at Error and restarts the stream.
  • When batching.processors fail 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.
  • The reader's byte cap counts WAL wire bytes; buffered decoded rows (maps, schema references) can exceed the design's rough 16 MiB estimate.
  • The buffered channel also applies to the snapshot path, so up to 4 snapshot pages (snapshot_batch_size rows each) can be in flight where previously it was one.
  • In-flight messages can exceed checkpoint_limit by up to one batch (a 1000-row streaming batch or one snapshot page), since a batch is admitted whenever nothing is pending.
  • Rows decoded but not yet handed to the consumer at shutdown are dropped and re-read on restart (at-least-once).
  • With a configured batching policy, a batch flush error now restarts the stream instead of continuing past the rest of the transaction.

Testing

  • streamBatch and commitRemap unit tests (commit flush, row cap, byte cap, empty suppressed commit, LSN promotion, slice independence, ring eviction and lookup).
  • resolveBatchingPolicy / passesThrough table tests, including the batch_transactions + batching rejection.
  • Postgres integration suite (Docker) green, including new tests: default is one row per batch; batch_transactions gives transaction-sized batches; batching.count honoured exactly; a single transaction larger than the streaming row cap (512 with the default checkpoint_limit) arrives as several batches with no rows lost or replayed across a restart on the same slot.
  • Follow-ons under the epic: parallel decode/marshal; binary tuple format.

Comment thread internal/impl/postgresql/input_pg_stream.go Outdated
Comment thread CHANGELOG.md Outdated
@squiidz
squiidz force-pushed the eng-1344-postgres-cdc-stream-batching branch from 8446e10 to 4f00298 Compare September 14, 2026 16:57
Comment thread internal/impl/postgresql/pglogicalstream/logical_stream.go Outdated
@Jeffail

Jeffail commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

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.

@Leward

Leward commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

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]
Version Rows landed in sink Rows landed in dql
main 1, 3 2
this PR none 1, 2, 3

Why it happens:

Setup: sink already holds id=2. One transaction inserts rows 1, 2, 3 into src. No batching policy.

main

  1. No policy, so batching.Count = 1. Three 1-row batches: [1], [2], [3].
  2. [1] inserts fine. [2] hits the unique constraint, sql_insert returns a plain error.
  3. fallback sends the whole failed batch to the DLQ. That batch is just row 2.
  4. [3] inserts fine.

this PR

  1. No policy, so the whole transaction passes through as one batch: [1, 2, 3].
  2. sql_insert builds one multi-row INSERT. Row 2 fails it, Postgres rolls back all three rows.
  3. The error is a plain error, not an indexed *batch.Error, so fallback cannot tell which part failed. It sends the whole batch to the DLQ.
  4. Rows 1 and 3 never reach sink. The batch is acked and the LSN moves on.

Neither sql_insert nor fallback changed. Only the batch shape did, and with it the blast radius of one bad row: one row on main, the whole transaction here. Without fallback the same config stalls the stream on the PR (whole transaction nacked and retried forever) where main only retries row 2.

@Leward

Leward commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Could we split this in two?

First PR: the internal changes only. The reader still groups rows per transaction, the remap ring and the checkpoint_limit cap stay, but with no batching policy we still send one row per message, like today. Nothing changes for existing configs, so this should be quick to merge.

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.

squiidz added a commit that referenced this pull request Sep 17, 2026
…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.
…ches by checkpoint_limit, quiet clean-stop logs (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.
@squiidz

squiidz commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

@Jeffail Agreed the default should not move. Pushed as postgres_cdc: gate whole-transaction batches behind an opt-in batch_transactions field: whole-transaction batches are now behind a new batch_transactions bool, default false. With it off and no batching policy, streaming rows go through the count-1 batcher exactly as on main, one message per batch; snapshot pages still arrive whole, as they always did. With it on, each transaction (capped at min(1000, checkpoint_limit/2) rows or 4 MiB of WAL) is one batch. Setting it alongside a batching policy is a config error, since the policy would re-batch the transactions. The field description spells out the failure-unit change from the fallback/DLQ example below as the reason it is opt-in. The reader-side batching, buffered channel and commit remap stay in place either way, so the default path keeps the decode/marshal overlap.

@squiidz

squiidz commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

@Leward Confirmed: sql_insert.WriteBatch returns a plain error, so fallback has no index to isolate row 2 and the whole batch goes to the DLQ. One nuance: without fallback, main also stalls on this transaction (row 2 is retried forever and the LSN cannot advance past it); the difference is that rows 1 and 3 land first. Either way the default is now preserved: no policy means one message per batch during streaming. TestIntegrationPostgresDefaultBatchingIsOneRowPerMessage pins it, and the batch_transactions description carries this scenario as the reason it is off by default.

@squiidz

squiidz commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

@Leward Keeping this as one PR, but with the shape you describe: the internal changes (reader batching, buffered channel, remap ring, checkpoint_limit cap) land with the default output unchanged, and the transaction-sized batch is the opt-in batch_transactions field with its own changelog entry under Added.

…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.
@squiidz
squiidz force-pushed the eng-1344-postgres-cdc-stream-batching branch from 943e637 to d562dde Compare September 17, 2026 18:44
Comment thread internal/impl/postgresql/input_pg_stream.go Outdated
…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.
Comment thread internal/impl/postgresql/input_pg_stream.go
- 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

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.

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.

Comment thread docs/modules/components/pages/inputs/postgres_cdc.adoc Outdated
=== `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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

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.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

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.

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

Comment thread internal/impl/postgresql/pglogicalstream/logical_stream.go Outdated
…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.
@squiidz

squiidz commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Merged main, which brought incremental snapshot and the shared control-signal decoding into the same loop. How the two meet:

  • The consumer keeps main's checkpoint tracker (LSN plus snapshot state), the row-less checkpoint sentinel and the blocking-snapshot barrier gate; this branch's pass-through and stamped ack positions feed it. The checkpoint payload is the stamp of the last change row added to the batcher, kept when snapshot rows follow it, which replaces the backward search over lsn metadata for a mixed batch. TestFlushBatchLSNFromMixedBatch is rewritten against that seam.
  • A chunk the incremental snapshot releases at a commit is sent from inside processChange, so with transaction batching it now precedes that transaction's own rows on the channel. The consumer therefore tracks the snapshot state before the commit position, which is the safe order: the state can never be persisted as progress past a commit that was not confirmed first.
  • The reader still deduplicates and dispatches signals row by row in WAL order; only the hand-off to the consumer is batched.

Comment thread internal/impl/postgresql/integration_test.go Outdated
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.
Comment thread CHANGELOG.md Outdated

All notable changes to this project will be documented in this file.

## Unreleased

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

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.

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.
@squiidz

squiidz commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

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 -race, and a throwaway restart test under a count: 7 policy). Two real gaps, both fixed with tests in the latest commit:

  • Cap-aligned transactions were stamped with their last row, not their commit. The caps were checked right after a row landed, so a transaction with exactly 512 rows (default checkpoint_limit) flushed before its commit was seen. If another transaction went out before that batch was acknowledged, the slot was confirmed mid-transaction and a restart replayed all 512 rows. The caps now apply before the next row goes in, so such a transaction waits for its commit. Larger transactions still split the same way.
  • A batch whose rows were all dropped by batching.processors never acknowledged its position. Pre-existing on main, but with the stamp travelling alongside the pending rows it is now a few lines: the position is tracked as a row-less checkpoint and resolved behind any batch still in flight. Without it a filter-only stretch left confirmed_flush_lsn parked and WAL retained.

Smaller: the pass-through path shares pendingRows with the batcher path instead of its own scan; the batch_transactions + batching rejection names the real conflict (processors included) and is tested with a processors-only policy; the warning for rows republished after a processor failure no longer calls them "unprocessed", since an earlier processor in the chain may already have altered them.

Findings reviewed and deliberately left as they are: explicit batching: {count: 1} is now honoured per message on snapshot pages too (the exact-honouring change this PR makes, called out in the description); channel depth 4 and the default path's per-row batcher hop are performance knobs for the benchmark follow-up under ENG-1344.

@squiidz
squiidz merged commit b1ca5e6 into main Oct 19, 2026
11 checks passed
@squiidz
squiidz deleted the eng-1344-postgres-cdc-stream-batching branch October 19, 2026 17:13
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.

4 participants