Bound the libpq pipeline backlog with byte-based syncs - #51
Merged
Conversation
The CDC apply pipelines statements and only synced at COMMIT/KEEPALIVE boundaries (1s timer) or at 512 MB of EXECUTE parameter bytes. Inside a large transaction nothing bounds libpq's buffers, and libpq memmoves its whole outstanding buffer contents on every partial send and read, making apply quadratic in the backlog size: a production migration measured 99% of apply CPU in __memmove_avx512_unaligned_erms at ~350 statements/s. Count every applied statement's bytes and sync the pipeline each time 1 MB has been sent since the last sync, mid-transaction included. The 1-second timer sync at transaction boundaries remains as the latency bound. Applying a single 100k-statement transaction takes 2.71s before and 0.74s after this change; at 25k statements the two builds are equal (0.23s vs 0.20s), the gap being quadratic growth in transaction size.
stream_apply_sql charged strlen(sql) for every line reaching the end of the switch, including repeated PREPARE lines that are skipped once the statement is prepared, and SWITCH records that send nothing at all. The transform emits one PREPARE per EXECUTE and those lines are slightly longer, so roughly half the counted bytes never reached the wire and the sync fired at about 500 KB of real backlog rather than the 1 MB PIPELINE_BYTES_SYNC_THRESHOLD documents. Track the bytes actually handed to libpq instead. On the 25k-row single transaction fixture in tests/cdc-low-level this takes the pipeline sync count from 9 down to 5.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
During a production CDC migration, apply fell behind the source and never converged: each 64 MiB WAL segment (~130 MB of transformed SQL, ~388k statements) took ~18 minutes to apply while the source produced one every ~13.6 minutes.
perfon the apply process showed 99.3% of CPU inside__memmove_avx512_unaligned_ermswith the call graphstream_apply_file → pgsql_sync_pipeline → PQconsumeInput → memmove, ~350 statements/s reaching the target (which was idle at 0.06 ms/statement), and apply RSS ballooned to 1.13 GB.Apply pipelines every statement on a nonblocking connection and only drains the pipeline at COMMIT/KEEPALIVE boundaries behind a 1-second timer, or mid-transaction after 512 MB of accumulated EXECUTE parameter bytes. The timer bounds time, not bytes — the loop can enqueue tens of MB into libpq's buffers before the first sync fires — and the parameter-only 512 MB threshold effectively never triggers. libpq shifts the entire outstanding contents of its send/receive buffers with memmove on every partial send and read (
pqSendSome/pqReadDatain fe-misc.c), so per-statement cost grows with the backlog and applying a batch is quadratic in its size. At the observed ~65 MB mean backlog that is ~25 TB of memory traffic per 130 MB file: one core pinned at memory bandwidth.Fix
Count every applied statement's bytes (
strlen(sql)plus a small framing allowance) and sync the pipeline each time 1 MB has been sent since the last sync, mid-transaction included. A pipeline sync only fetches pending results; the explicit BEGIN/COMMIT transaction is unaffected — the same argument as the existing 512 MB mid-transaction sync this replaces. The 1-second timer sync at transaction boundaries remains as the latency bound. The EXECUTE-parameter-only accounting is removed (parameters are part of the counted statement line), and the two replay-path sync sites that didn't reset the byte counter now do.With the backlog capped at 1 MB, each libpq buffer shift is KB-scale and apply becomes target-bound instead of memmove-bound.
Testing
New regression test in
tests/cdc-low-level: generates a single-transaction, 25k-statement (~5.6 MB) .sql file, applies it with--trace, and asserts the full row count plus ≥5 pipeline syncs. Pre-fix the assertion fails with exactly 2 syncs (final COMMIT + end-of-file, zero mid-transaction); post-fix it passes with 9.Benchmarks on a production-shaped fixture (85 MB, 420k lines, 35k transactions of ~10 statements), unpatched vs patched, same databases:
PQconsumeInputbefore; 0% after, with every sample idle inselect()waiting on the targetmake build(docker, PG16 image)make tests/cdc-low-level(includes new test)make tests/cdc-endpos-between-transactiontests/cdc-filtering(run directly, see note)tests/cdc-message-handling(run directly, see note)ci/banned.h.shcitus_indent --checkNote:
make tests/cdc-filteringis silently a no-op — including in CI — because the directory name shadows the missing make target, andcdc-message-handlingis wired into neithertests/Makefilenor the CI matrix. Both suites were run directly here viamake -C tests/<dir> test DOCKER=docker. Worth fixing in a separate PR.