Skip to content

Fix the spool's critical bugs before it ships - #881

Merged
sduchesneau merged 18 commits into
feature/sink-sql-local-cachefrom
feature/sink-sql-spool-fixes
Aug 14, 2026
Merged

Fix the spool's critical bugs before it ships#881
sduchesneau merged 18 commits into
feature/sink-sql-local-cachefrom
feature/sink-sql-spool-fixes

Conversation

@sduchesneau

@sduchesneau sduchesneau commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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 ./... and go vet clean, including the
container-backed integration tests against real PostgreSQL and ClickHouse.

Data loss, corruption, or a hung process

Commit Problem
Keep the spool within the budget it was given Row-log segments record their size in Manifest.LogBytes, which Seal never added in, so row-insert reported every segment as 0 bytes and --spool-max-size was never reached. A segment larger than the whole quota could also never be queued, leaving awaitQuota spinning forever.
Close the COPY stream's file when a segment is sealed pgcopy.Writer.Close leaves the file to whoever opened it, and a segment is sealed and forgotten: every sealed segment leaked one descriptor per table, and the applier's os.RemoveAll freed no space while they were held. The default format.
Let a cyclic schema undo a reorg HandleBlocksUndo propagated TableApplyOrder's cycle error, so the one schema row-insert exists to support could neither undo a reorg nor start — Run undoes from the stored cursor before any block arrives.
Write the ClickHouse cursor to its file when a segment lands The applier ended Apply with StoreCursor, 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.
Route ClickHouse block rows through the spool InsertBlock appended 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 meant RecordBlock was never reached, leaving every manifest with an empty block range.
Stop doubling backslashes in rendered SQL literals standard_conforming_strings has been on by default since PostgreSQL 9.1, so escaping the backslash stored two where the value had one — and only batch-insert and row-insert came through quoteLiteral, putting the write mode in the data.
Encode an empty repeated field in COPY mode Binary COPY resolves the encode plan from the Go type, so retyping an empty array to []string reached no plan at all on numeric[], bigint[], bytea[], bool[] or timestamp[]: the first block carrying an empty list refused the row and took the segment with it.
Encode a repeated enum field in COPY mode normalizeSlice had no case for the walk's EnumValue, so a TEXT[] enum column failed on the default write mode.
Stop dialing the ClickHouse database before creating it VerifySchemaCompatibility runs ahead of CreateDatabase and read the columns over a connection naming the schema, which on a first run does not exist. ClickHouse refuses that with UNKNOWN_DATABASE and newClient retries a failed dial forever, so setup never returned.
Match a recovered segment on both ends of its range Recovery answered "already applied?" from the first block alone, so a record left behind by an earlier run could answer for a different segment starting on the same block. Narrowing to the whole range is not sufficient alone — see the next row.
Seal a segment only once its own cursor is recorded A flush writes its rows and then its cursor, but the seal ran between the two, so a segment committed carrying the previous flush's cursor and claiming a range that cursor stops short of. The next run resumes there, the startup undo deletes the segment's own rows, and the record stays behind to answer AlreadyApplied for 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

Commit Problem
Size the next segment from the throughput actually measured Scaling the 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.
Have setup create the constraints it is asked for setup built 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 off setup — there the question is whether, not when — and --disable-all-constraints is the new shorthand for turning all three off, replacing the deprecated --no-constraints.
Seal the spool on every way out of the run The database was closed only when a bounded range completed, so Ctrl-C during a backfill dropped the open segment — up to --db-write-max-size of already-paid-for blocks, re-streamed.
Write a proto that parses from extract-proto --sql Nested enums and map entries were referenced but never declared, and the per-field annotation hints were emitted where a field option cannot go, so the file the command tells you to feed back through --proto-file-override did 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:

  • Backslashes — on PostgreSQL 16 with the default standard_conforming_strings=on,
    delta\path 'quoted' (19 chars) stored as 20 under batch-insert and row-insert, and
    correctly under copy. The write-modes integration test now carries that value, which is
    what pins the three modes to the same bytes.
  • Empty arrays[]string{} resolves no binary encode plan on 8 of the 10 array types
    tested. normalize_test.go now covers 14 array types against the real encoder.
  • ClickHouse setup — measured: never returns on a fresh server; ~4s with the fix. The
    new integration test hangs 24s without it.
  • Seal ordering — the new test drives four blocks through one-byte segments and reads
    every _segments_ row back: unfixed, the first reports "the segment covering blocks 1-2
    committed 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 / DropConstraints on a mixed-case table annotation); a rendered
tuple over 64 MiB, which writes fine, fails at apply and is then replayed by recover() on
every restart — needs a decision on what to do instead of failing; --live-block-time-delta
being excluded for DatabaseChanges runs too; the _segments_ table never being pruned;
and the transaction left open when Flush() or StoreCursor() fails in flushHolding.

sduchesneau and others added 9 commits August 13, 2026 18:11
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>
@sduchesneau

sduchesneau commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

🔍 Vulnerabilities of ghcr.io/streamingfast/substreams:7c3cb9a

📦 Image Reference ghcr.io/streamingfast/substreams:7c3cb9a
digestsha256:864174bcadc16d18dad9ea12dc60bf15f6aeb5b662a6e7b090a243f6632a3083
vulnerabilitiescritical: 0 high: 0 medium: 0 low: 0
platformlinux/amd64
size123 MB
packages382
📦 Base Image oisupport/staging-amd64:24.04
also known as
  • a215e986b44aae6f10795ded1e39ce93d9c236d8163d21a522ffd0ab3659f546
  • noble
  • noble-20260730.1
digestsha256:019e8eb29a85e74d64925745884f2ec79aa27e3feab36353d24656f4d6b89467
vulnerabilitiescritical: 0 high: 0 medium: 5 low: 4

sduchesneau and others added 2 commits August 14, 2026 08:47
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
sduchesneau force-pushed the feature/sink-sql-spool-fixes branch from 571dda6 to db2c117 Compare August 14, 2026 12:48
sduchesneau and others added 5 commits August 14, 2026 08:56
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
sduchesneau force-pushed the feature/sink-sql-spool-fixes branch from db2c117 to e6f4060 Compare August 14, 2026 12:57
sduchesneau and others added 2 commits August 14, 2026 09:03
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>
@sduchesneau
sduchesneau merged commit c7b45d2 into feature/sink-sql-local-cache Aug 14, 2026
9 checks passed
@sduchesneau
sduchesneau deleted the feature/sink-sql-spool-fixes branch August 14, 2026 13:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant