Fix the spool's critical bugs before it ships - #881
Merged
sduchesneau merged 18 commits intoAug 14, 2026
Conversation
Row-log segments record their size in Manifest.LogBytes, which Seal never added in, so row-insert mode reported every segment as zero bytes and the disk quota was never reached. A segment larger than the whole quota could also never be queued, leaving awaitQuota spinning forever. Segments are now capped at the spool budget, and one that still exceeds it goes through once the spool has drained. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pgcopy.Writer.Close writes the trailer and flushes, leaving the file to whoever opened it — which is the segment, and a segment is sealed and forgotten. Every sealed segment therefore leaked one descriptor per table, and the applier's os.RemoveAll freed no space while they were held. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HandleBlocksUndo propagated TableApplyOrder's cycle error, so the one schema row-insert mode exists to support could neither undo a reorg nor start, Run undoing from the stored cursor before any block arrives. The deletes run in one transaction and key on _block_number_ alone, so they fall back to the schema's own table order when no foreign key order exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The applier ended Apply with StoreCursor, which routes the cursor back into the spool whenever one is open. The cursor file was therefore never written for the whole backfill, and the cursor of the segment just applied was stamped over the newer one held by the segment still being written. A kill mid-backfill resumed from a cursor predating everything applied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
InsertBlock appended straight to the accumulator, which with a spool open belongs to the applier's goroutine: the sinker wrote the map the applier swaps out on every flush, so _blocks_ rows were dropped between a send and the swap when the race did not crash the process outright. It also meant RecordBlock was never reached, leaving every manifest with an empty block range — so AlreadyApplied always replayed, and the progress line never advanced through a spooled backfill. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A backslash carries no meaning inside a standard string literal, and standard_conforming_strings has been on by default since PostgreSQL 9.1, so escaping it stored two backslashes where the value had one. That also put the write mode in the data: COPY hands pgtype the string as it is, so only batch-insert and row-insert came through quoteLiteral. The write-mode test now carries a backslash and a quote, which is what pins the three modes to the same bytes. The two protojson branches escaped the same way, over output that is mostly backslashes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Binary COPY resolves the encode plan from the Go type rather than from the column, so retyping an empty array to []string reached no plan at all on a numeric[], bigint[], bytea[], bool[] or timestamp[] column: the first block carrying an empty list refused the row and took the segment with it. The walker's own []any encodes against every array OID, so it is left alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The walk hands an enum over as EnumValue and MapFieldType declares the column TEXT[], but normalizeSlice had no case for it, so the row was refused and the segment with it. Scalar enums were already fine, pgtype rendering them through their String method. The elements keep that type rather than being flattened to their names, which is the same text the rendered write modes emit for them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VerifySchemaCompatibility runs ahead of CreateDatabase and read the columns over a connection naming the schema, which on a first run does not exist yet. ClickHouse refuses that with UNKNOWN_DATABASE and newClient retries a failed dial forever on a context of its own, so setup never returned. It reads through 'default' now, as CreateDatabase already did. system.columns is global, so which database the connection names does not change the answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
Author
Recovery answered "already applied?" from the segment's first block alone, so a record left behind by an earlier run could answer for a different segment that happens to start on the same block. Comparing the last block too narrows that, and needs no change to the table. It is not enough on its own: the sizer restarts at its floor every run, so a re-streamed segment can end on the same block as the record it collides with. What lets such a record exist at all is dealt with separately, by sealing a segment only once its own cursor is recorded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scaling the current target by target/elapsed ignored how much the commit moved, so an idle or drain seal — fast because it was small — doubled the target while the database may well have been slow.
sduchesneau
force-pushed
the
feature/sink-sql-spool-fixes
branch
from
August 14, 2026 12:48
571dda6 to
db2c117
Compare
runFromProtoSetup built its policy from --apply-constraints, a flag only the run command declared, so the value read was always the empty string and the schema came out bare however the --disable-* flags were set. The flag stays off setup. There the question is not when the constraints are created but whether, since setup creates the schema and exits — two of the three values would collapse onto the same answer. What it creates is said by the --disable-* flags alone, and --disable-all-constraints is the shorthand for turning all three off at once, which is what --no-constraints said before it was deprecated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
db.Close was only reached when a bounded range completed, so a Ctrl-C during a backfill — the run the spool exists for — left the open segment unsealed and its blocks to be streamed again.
Map fields named an entry type nothing declared and nested enums were dropped altogether, so the file the command tells the operator to feed back through --proto-file-override did not parse. Field hints are now whole declarations, since an option has to sit inside the field's own brackets.
Both branches fixed the empty array and the repeated enum. The enum is rendered here rather than handed over as it is. Both encode identically today — pgtype picks the Stringer, which is what ValueToString renders with too — but pgtype chooses between TextValuer, driver.Valuer and fmt.Stringer in that order, so the day EnumValue gains an earlier one, binary COPY would start writing something the rendered write modes do not, and nothing would fail. Rendering here keeps that choice ours. The empty array is passed through untouched: with no element there is no value whose representation could drift, only a plan that has to resolve, and the walker's []any resolves against every array OID where a mapped one leaves any OID it does not enumerate. Takes both test files from that branch: they cover 14 array types against the real encoder, where the ones dropped here covered 10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A flush writes its rows and then its cursor, but the seal ran between the two: a segment closed carrying the cursor the previous flush stored, and committed claiming a range that cursor stops short of. The next run resumes there, Run's undo deletes every row above it — the segment's own — and the record stays behind to answer AlreadyApplied for the segment carrying exactly those rows. Recovery discards that one without marking a hole, replays the segments behind it, and moves the cursor over a gap it just created. Sealing when the cursor is recorded rather than at flush keeps every segment covered by its own cursor. Records reaching past the stored cursor are dropped at startup, which clears what a database synced by an earlier build is still carrying. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sduchesneau
force-pushed
the
feature/sink-sql-spool-fixes
branch
from
August 14, 2026 12:57
db2c117 to
e6f4060
Compare
Every segment applied recorded a row and nothing ever removed them, so a sink that stays live grew the table for as long as it ran: each restart backfills the little it is behind, seals a segment or two on the way, and reaches the head again. Reaching the head drains the spool and closes it for good, which leaves no directory on disk for a record to answer for — the records only ever told recovery whether a directory it found was already applied. A run that ends at its stop block keeps them instead, having no next restart to grow them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reader refused any record over 64MiB while the writer had no limit at all, so a large rendered tuple — a wide bytes column, a long repeated field — was written and counted into the manifest, verified intact, and then failed only at apply. Recovery replayed that same segment on every start, so the sink could not come up again until the directory was removed by hand. A record cannot be longer than what is left of the file holding it, which catches a corrupt length just as well without putting a ceiling on a row the writer accepted. The four-byte prefix is now a write-time error rather than a silent truncation, and a segment that genuinely cannot be applied says what removing it costs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
67 tasks
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.
Fixes for the spool / write-mode / constraints work in #869, on top of that branch so the
review stays separate from the 12k-line feature diff.
Twelve bugs, each in its own commit with a regression test that was checked to fail
without its fix. Whole-repo
go test ./...andgo vetclean, including thecontainer-backed integration tests against real PostgreSQL and ClickHouse.
Data loss, corruption, or a hung process
Manifest.LogBytes, whichSealnever added in, sorow-insertreported every segment as 0 bytes and--spool-max-sizewas never reached. A segment larger than the whole quota could also never be queued, leavingawaitQuotaspinning forever.pgcopy.Writer.Closeleaves the file to whoever opened it, and a segment is sealed and forgotten: every sealed segment leaked one descriptor per table, and the applier'sos.RemoveAllfreed no space while they were held. The default format.HandleBlocksUndopropagatedTableApplyOrder's cycle error, so the one schemarow-insertexists to support could neither undo a reorg nor start —Runundoes from the stored cursor before any block arrives.ApplywithStoreCursor, which routes the cursor back into the spool whenever one is open. The cursor file was never written for the whole backfill, and the segment just applied stamped its cursor over the newer one held by the segment still being written.InsertBlockappended straight to the accumulator, which with a spool open belongs to the applier's goroutine — an unsynchronised write to the map the applier swaps on every flush. It also meantRecordBlockwas never reached, leaving every manifest with an empty block range.standard_conforming_stringshas been on by default since PostgreSQL 9.1, so escaping the backslash stored two where the value had one — and onlybatch-insertandrow-insertcame throughquoteLiteral, putting the write mode in the data.[]stringreached no plan at all onnumeric[],bigint[],bytea[],bool[]ortimestamp[]: the first block carrying an empty list refused the row and took the segment with it.normalizeSlicehad no case for the walk'sEnumValue, so aTEXT[]enum column failed on the default write mode.VerifySchemaCompatibilityruns ahead ofCreateDatabaseand read the columns over a connection naming the schema, which on a first run does not exist. ClickHouse refuses that withUNKNOWN_DATABASEandnewClientretries a failed dial forever, so setup never returned.AlreadyAppliedfor the segment carrying exactly those rows — which recovery then discards while replaying the segments behind it, moving the cursor over the gap.Breaks a default-configuration run
target/elapsedignored how much the commit moved, so an idle or drain seal — fast because it was small — doubled the target while the database may well have been slow.setupbuilt its policy from--apply-constraints, a flag only the run command declared, so it always created a bare schema and the--disable-*flags it does carry had nothing to act on. The flag stays offsetup— there the question is whether, not when — and--disable-all-constraintsis the new shorthand for turning all three off, replacing the deprecated--no-constraints.--db-write-max-sizeof already-paid-for blocks, re-streamed.--proto-file-overridedid not parse.| Clear the applied-segment records at the chain head | Every segment applied recorded a row in
_segments_and nothing removed them, so a sink that stays live grew the table for as long as it ran — each restart backfills the little it is behind and seals a segment or two on the way. Reaching the head drains the spool and closes it for good, which leaves no directory for a record to answer for. || Bound a framed record by its file rather than a constant | The reader refused any record over 64MiB while the writer had no limit, so a large rendered tuple was written, counted into the manifest and verified intact, then failed only at apply — and recovery replayed that same segment on every start, leaving the sink unable to come up until the directory was removed by hand. |
Verification worth calling out
The three that were proven against a real server rather than argued from reading:
standard_conforming_strings=on,delta\path 'quoted'(19 chars) stored as 20 underbatch-insertandrow-insert, andcorrectly under
copy. The write-modes integration test now carries that value, which iswhat pins the three modes to the same bytes.
[]string{}resolves no binary encode plan on 8 of the 10 array typestested.
normalize_test.gonow covers 14 array types against the real encoder.new integration test hangs 24s without it.
every
_segments_row back: unfixed, the first reports "the segment covering blocks 1-2committed with a cursor at block 1, which does not cover it".
Not addressed
Left alone deliberately, all documented: constraint-name folding under identifier folding
(
MissingConstraints/DropConstraintson a mixed-case table annotation); a renderedtuple over 64 MiB, which writes fine, fails at apply and is then replayed by
recover()onevery restart — needs a decision on what to do instead of failing;
--live-block-time-deltabeing excluded for DatabaseChanges runs too; the
_segments_table never being pruned;and the transaction left open when
Flush()orStoreCursor()fails influshHolding.