Repository navigation
driver/postgres: a reply buffered on the sync path is bounded by the cap and no longer holds 5x its data - #1148
Merged
Merged
Conversation
… after the append (Refs #1104) appendBuffered appends the row and only then tests maxDirectResultBytes, and nothing stops the poll, so a reply above the cap is buffered whole before ErrResultTooBig: the new tests fail, 85,536 rows buffered of a cap of 65,536 at 1 KiB, in the request and end to end in direct mode. The next commit fixes it.
… is not appended (Refs #1104) appendBuffered tested maxDirectResultBytes after it appended the row, and the poll cannot be stopped from there, so a reply above the cap was buffered whole (1.1 to 1.2 GB of heap for 240 MB on the wire, measured) before ErrResultTooBig. A row that would pass the cap is not appended; the request fails with the same error and the rest of the reply is read and dropped to keep the wire aligned. A reply that fits is buffered whole as before. The caller sees the rows buffered up to the cap, and then the error, where it used to see all the rows.
…rder after the cap (Refs #1104) The slab and the row fields of a buffered reply grew by copy, and the row views already handed out kept every earlier array alive: with 1.25x growth, 5 times the data (24 MB of rows held 199 MB of live heap; a reply of 2M rows 0.7 to 1.1 GB). The live-heap test fails here; the next commit fixes it. The order test pins that a row that fits is not buffered after one that did not.
A row view points into the slab and the row fields that were current when it was carved; growing by copy left every earlier generation reachable through the views already handed out, 5 times the data at 1.25x growth. A full chunk is not copied now: the next is started, doubling to 64K and then fixed, so the live heap is the data plus 24 bytes of field view and of row view per row. The cap counts the payload over all chunks (slabBytes). 24 MB of rows: 199 MB of live heap before, under 2.5 times the data after. Large replies also get faster (no copies): 10,000 rows of 3 fields in a pooled request: -72% ns/op, -69% B/op.
|
Caution Review failedThe pull request is closed.
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…ertion that can fail (Refs #1147 Refs #1104) A poll that trips the cap and returns before the reply is done leaves the rest to the event loop with syncMode cleared; rows dispatched then were buffered again and promoted to streaming behind the rows already buffered: 1124 of 1200 rows, a gap after row 1023 and io.EOF, in simpleQuery and doExtendedQuery (10 of 10 runs). The next commit fixes it. The cap test asserted len(rowSlab), which is only the current chunk; it asserts slabBytes and the summed payload now.
…odoc of the cap (Refs #1147 Refs #1104) The drop sat inside 'if syncMode', and the sync fast path clears syncMode when its poll returns before the reply is done, so the rest of the reply was buffered again behind the rows already there. It is outside now: appendBuffered returns false for every row after ErrResultTooBig (which also keeps promoteToStreaming from running). The payload size is summed once and passed to the append; slabBytes moves to the end of the struct; the comments and the godoc of ErrResultTooBig and maxDirectResultBytes say what the caller gets.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
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.
driver/postgres: a reply buffered on the sync path / direct mode is bounded by the cap, and grows by chunks instead of holding 5x its data. A product defect found through
TestStreamingLarge, separate from the test PR #1144. Refs #1147, Refs #1104SEMANTICS NOTE (read first)
This changes what a caller sees for a reply over
maxDirectResultBytes(64 MiB of payload): it used to get ALL the rows and thenErrResultTooBigat the end of the iteration; now it gets the rows buffered up to the cap and thenErrResultTooBig. The error value and the threshold (payload bytes over the cap) are unchanged, and a reply that fits is returned whole as before. A caller that ignoredrows.Err()and relied on getting everything from an over-cap query would now see a truncated result with the error it was not checking; a single row over the cap fails the query itself (main returned that row, then the error). No API change. The error value and the threshold are unchanged; the decision to take this behaviour is the maintainer's.The defect
On the sync fast path (
pollSync->onRecv->appendBuffered) and in direct mode a reply is buffered whole (syncMode: the caller's goroutine is the dispatcher, so it cannot promote to streaming). Two problems:appendBufferedappended the row and only then tested the cap, and the poll cannot be stopped from there, so a reply above the cap was buffered whole and the error came at the end: the cap was a flag, not a bound.Measured (linux/arm64 docker,
--cpus 2, the fake PG of the package tests): a 2M-row reply (about 86 MB payload, direct mode) peaked at 731 to 1097 MB of live heap; with only the cap moved before the append, 888 to 923 MB (the cap alone does not bound it); 400,000 rows of 64 bytes (24 MB) held 199 MB live. When it happens on the sync fast path: only when the client is not faster than the sender (instrumented, loaded, or a fast server); on an idle uninstrumented box the poll hits EAGAIN and the request streams. Direct mode always.The fix
slabBytes + row > maxDirectResultBytes); a row that does not fit is not appended, the request fails withErrResultTooBig, and the rest of the reply is read and dropped (the wire stays aligned). A row that would fit after a failed one is not buffered either (reply order, no hole).slabBytescounts the payload over all chunks. The earlier views stay valid exactly as they did after a copy. Live heap = the data plus 24 bytes of field view and 24 of row view per row, plus at most one row's worth of unused tail per chunk (worst case about twice the data, for rows just over half a chunk).growCaphad no other user and is gone.Residual, not addressed: the cap counts payload bytes, so many NULL or tiny fields cost about 24 bytes per field and per row beyond it.
if syncMode, and the sync fast path clearssyncModewhen its poll returns before the reply is done (EAGAIN), so the rest of the reply, dispatched by the event loop, was buffered again and promoted to streaming behind the rows already there: a gap andio.EOF, silent data loss (1124 of 1200 rows, gap after row 1023, insimpleQueryanddoExtendedQuery; main returned the whole result there, with the error lost). The guard is outsideif syncModenow: afterErrResultTooBigappendBufferedreturns false in every mode, which also keepspromoteToStreamingfrom running, so the caller wakes ondoneChwith the buffered rows and the error. The payload size is summed once and passed to the append.Every path by which
appendBufferedcan run after the cap trippedappendBufferedis reached only fromOnRow, called by the simple-query and extended-query parsers for a DataRow (nothing else, COPY included, calls it).driveDirect, syncMode pinned)TestDirectModeResultAboveTheCapIsBounded1104)simpleQuery/doExtendedQuerybuffered-rows branch)TestSyncFallThroughAfterTheCapKeepsOrderAndTheError1104, both query kinds; red on202f19910 of 10WithEngineasync (syncMode never set)OnRowsends torowChbeforeappendBufferedOnRowsimpleExec,doExtendedExec, no-tag)QueryContextreturnsErrResultTooBig(no row to hand back); main returned that one row and then the error fromNextonCopyRow, notOnRowslabBytesaccumulates over the request, aslen(rowSlab)didEvidence
golang:1.27.3,--cpus 2, 8 MiB memlock.-race -count=10of the new tests (commit 1 + 3 tests on main's code)TestAllocBudgetsstrict, linux,-count=5./driver/postgres,-race -cover -count=1, onceMutants, each on this PR's code, the named test then fails (the guard put back inside
if syncMode: the fall-through test red, 3 of 3): no cap check: cap tests red; no drop after the error: the order test red (a 512-byte row after a failed 2 KiB one was buffered); slab grows by copy: live-heap test red (and the same for the row fields).Small-result path,
-count=7medians, two rounds, docker--cpus 2:QueryContext, 1 col 1 rowOnRowon a pooled request, 1 row 1 fieldOnRowx100 rows x3 fieldsOnRowx10,000 rows x3 fields, FRESH requestOnRowx10,000 rows x3 fields, WARM pooled request (reviewer's measurement)Re-measured after the single size sum (5 interleaved rounds of 3 runs, medians, docker
--cpus 2,slabByteslast in the struct):OnRow1 row 1 field 354 -> 377 ns (+6%, 10 allocs both, struct 568 -> 576 bytes, same 576 size class), end to endQueryContext1 col 1 row 31.8 -> 33.2 us (+4.6%, 14 allocs both, 205 / 204 B), which is inside the run-to-run noise of a loopback round trip (earlier rounds swung from -13% to +16%). The pooled-request regression, stated plainly. The "-69% B/op" above holds for a cold request. A service that repeatedly fetches mid-size results (about 1,000 to 10,000 rows) used to reuse the grown slab from the request pool at 0 B/op and now allocates about the payload per query: more GC work, no measured latency change. It is the other side of bounding what the pool retains (main kept a slab of up to 64 MiB, and 5 times its data in generations, per pooled request) and of the 5x fix; I did not change the code. A possible follow-up is to keep up to N chunks (for example 256 KiB) on release.Measurement caveat: the reviewer measured
OnRow1x1 at 26 to 27 ns on main, on the earlier head and on this one; my +23 ns (354 -> 377 ns, the benchmark including the pool round trip) did not reproduce under his load. Differences under about 10% on this loaded Mac are noise, and I draw no conclusion from either figure beyond "no measurable change on the small-result path".So: the small-result path is unchanged within noise (allocs identical), and results of hundreds of rows and up get cheaper, with no copy of the growing slab.
Lint (CI pin v2.14): 0 issues;
go veton linux, darwin and windows.Release note
driver/postgres: a reply over the 64 MiB buffer cap (direct mode, and the sync fast path while its poll is reading) now returns the rows that fit under the cap and then
ErrResultTooBigfromrows.Next/rows.Err(), instead of every row and then the error (a caller that ignoresrows.Err()sees a truncated result; a single row over the cap fails the query itself); the rest of the reply is discarded and the connection stays usable. Fixed: a buffered reply no longer holds about five times its payload in live heap (2M rows, measured two ways: 731 to 1097 MB -> 205 MB here, 741 MB -> 145 MB by the reviewer). Trade-off: a warm pooled request no longer keeps a grown slab, so repeated replies over 64 KiB of payload allocate their chunks each time.