Skip to content

Sink from-proto through a disk spool, and load without constraints - #869

Merged
sduchesneau merged 55 commits into
developfrom
feature/sink-sql-local-cache
Aug 17, 2026
Merged

Sink from-proto through a disk spool, and load without constraints#869
sduchesneau merged 55 commits into
developfrom
feature/sink-sql-local-cache

Conversation

@sduchesneau

@sduchesneau sduchesneau commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

The from-proto SQL sink wrote to the database on the stream's own goroutine, with the
constraints in place while it loaded, through a flag surface that had grown to say
different things depending on the driver and on where the stream was. This changes all
three, and adds the index the reorg path always needed.

What changes

Rows go through a spool. They land on local disk, pre-encoded into whatever the target
can take unchanged, and a background goroutine applies whole segments. The stream stops
waiting on the database, and blocks already downloaded survive a restart instead of being
streamed — and paid for — twice. Against erc20-balance-changes over 50,000 Ethereum
mainnet blocks (17M rows) into a containerised PostgreSQL 17, this took the run from 190.9s
to 70.9s and the stream from 262 to 673 messages per second, with an identical table.

Both drivers spool. ClickHouse keeps the guarantees it already had — no transactions, its
cursor in a file — so applying a segment there is the same inserts followed by the same
cursor write, in the same order.

Constraints are created after the load, not during it. Measured through binary COPY
over 500k rows, loading with foreign keys in place costs 27.7x against 3.3x for building
the same ones afterwards. --apply-constraints says when: auto (default) has the sink do
it when the stream reaches chain HEAD, manual leaves it to sink postgres constraints apply, always keeps the old behaviour. auto is the default despite being a
stop-the-world pass — a backfill that ends with no primary keys and no foreign keys is a
silent wrong result that looks like success.

A stop block deliberately does not trigger it. Reaching -t ends the run without saying
the backfill is done — a range is routinely one chunk of several — and building the
constraints there would leave every later chunk loading into a constrained schema, the
27.7x case this exists to avoid. The run says on the way out what it left behind.

Each statement commits on its own, so a run killed by the OOM killer keeps what it
finished. --constraints-parallelism runs them side by side (keys first, then the foreign
keys that reference them), and --constraints-work-mem sets maintenance_work_mem for the
duration of each: at the usual 64MB default an index build over a large table spills to an
external merge sort.

_block_number_ is indexed, at startup, concurrently. Every table carries that column
and every reorg deletes from every table by it, and a foreign key indexes only its
referenced side — so each undo was a sequential scan per table. Measured over 10GiB: 2.6s
to build, 218MiB against 10GiB, and one table's undo goes from 1.296s to 15ms. It is
deliberately not part of the constraint pass: those describe the schema and are the
operator's to schedule, where this one the sink depends on.

--write-mode replaces guessing. Which write path ran used to be derived from whether
a directory flag was set and whether the schema's foreign keys happened to form a cycle,
and an unorderable schema was silently downgraded to row-at-a-time inserts — an order of
magnitude slower, announced only in a log line. It is now copy, batch-insert,
row-insert or auto, and an explicit mode the driver or schema cannot support is an
error that names the fix.

Flags are named after what they spend. --decode-* is CPU, --db-write-* is one
commit to the database, --spool-* is how far ahead of it the stream may run. Commit size
is not a setting: the sink measures each one and sizes the next segment toward
--db-write-target-duration, block payloads differing by orders of magnitude across chains
being exactly what makes a fixed block count give unstable durations.

substreams tools extract-proto --sql writes a module's output proto back out with
every SQL annotation commented out beside the message and field it applies to, and the
annotations file next to it so the result parses as-is. Starting from a package whose
author never annotated it was otherwise a matter of finding the right proto and the right
extension names by hand.

ClickHouse takes a package with no annotations. It required order_by_fields on every
table, so a Substreams not written for the sink could neither be set up nor run. Tables now
default to ORDER BY (_block_number_, _row_id_), PRIMARY KEY (_block_number_) and
PARTITION BY (toYYYYMM(_block_timestamp_)). _row_id_ numbers the rows one block writes
to one table: the tables are ReplacingMergeTree, so a sorting key that cannot tell those
rows apart collapses a whole block into one row at merge time. It is added only where no
order_by_fields is declared, and PostgreSQL is untouched. Three things were in the way
behind the error message — the ClickHouse database was built with "use proto options"
hardcoded to true, so an unannotated package inserted nothing at all; setup exited with
Flag "sink-info-folder" does not exist, those flags being registered on the run command
only; and an undo wrote tombstones without _row_id_, landing them on a different sorting
key where they removed nothing. erc20-balance-changes / map_balance_changes now fills a
ClickHouse database as-is.

Reviewing

Fourteen commits, each building and vetting clean on its own. The first four are the shape
of it:

commit what
Encode rows in PostgreSQL's binary COPY wire format leaf package, no callers yet
Hold rows on disk and apply whole segments in the background the spool: segment lifecycle, disk budget, sizing. Codec and Applier are the driver's business
Write through the spool, load without constraints the behaviour change and the flag surface — the one to read closely
Cover the spool, the write modes and the constraint timings container-backed tests

The rest are fixes and follow-ups found while using it, each self-contained: draining the
spool before the stop block, the startup report of missing constraints, committing the
constraint pass in pieces, the module argument on the schema commands, and the block number
index with its measurement.

Compatibility

--block-batch-size and --no-constraints still work for a release and warn. Everything
else here is new, so nothing else needs an alias. A from-proto flag typed for a
DatabaseChanges Substreams is now an error rather than silently ignored, and so is the
reverse — a DatabaseChanges Substreams owns its schema, so sink postgres constraints
refuses it outright.

setup and constraints take the output module as an optional second argument, as the run
command always has; a package with more than one candidate had no way to say which.

Exhaustive list of changes

Commands

  • add [] optional argument to sink postgres|clickhouse setup — was inferred from the package with no way to override
  • add sink postgres constraints apply [], replacing sink postgres apply-constraints
  • add sink postgres constraints drop [] — escape hatch after --apply-constraints=always, or before resuming a backfill
  • add substreams tools extract-proto [ []] — writes a module's output proto back out, with --sql and --output-dir
  • --sql annotates every message/field with commented-out (schema.table)/(schema.field) options and writes schema.proto beside it so it parses as-is

Flags added

  • add --write-mode=auto|copy|batch-insert|row-insert — how a sealed spool segment reaches the database; unsupported explicit mode is an error, not a downgrade
  • add --decode-workers (0 = one per core less one, capped at 8) — CPU only, does not change what the database sees
  • add --decode-batch-size (0 = 4 × workers) — blocks held in memory and decoded together
  • add --db-write-target-duration (3s) — how long one commit should take; the sizer measures each and sizes the next segment toward it
  • add --db-write-max-size (512MiB) — ceiling on the segment size the sizer may choose
  • add --spool-dir (./localdata/spool) — where pending segments are written
  • add --spool-max-size (8GiB) — disk budget, and the only bound on how far ahead of the database the stream may run
  • add --spool-max-idle (10s) — commit the open segment when no new row has reached it for that long; 0 disables
  • add --apply-constraints=auto|manual|always (default auto) — when the schema's constraints are created
  • add --disable-foreign-keys, --disable-primary-keys=<tables|all>, --disable-unique-constraints=<tables|all> — which constraints the schema declares
  • add --disable-block-number-index — leave out the block_number index; only sensible for a run that can never reorg
  • add --constraints-per-transaction (1), on constraints apply|drop only — how many are committed at a time

Flags deprecated / moved

  • deprecate --block-batch-size in favour of --decode-batch-size; honoured one release with a warning
  • deprecate --no-constraints in favour of --disable-foreign-keys --disable-primary-keys=all --disable-unique-constraints=all (it had been deleted outright on this branch)
  • stop registering the from-proto run flags on setup and on the constraints commands — they never decode a block or write a row
  • group the run command's --help under From-proto mode flags: / DatabaseChanges mode flags: / Flags (both modes):, and drop the now-redundant [mode] prefixes

Mode guards

  • error on any from-proto flag typed for a DatabaseChanges Substreams (Flags().Changed, so defaults never trip it)
  • error on any DatabaseChanges flag typed for a from-proto Substreams — was a warning on setup only
  • sink postgres constraints apply|drop refuses a DatabaseChanges module outright: its schema comes from the manifest's schema.sql

Spool

  • add sink/sql/db_proto/sql/spool — owns the segment lifecycle, disk budget and sizing; Codec says what the bytes are, Applier how they land
  • add four on-disk formats: binary COPY per table, rendered SQL tuples per table, one interleaved row log in walk order, typed values for ClickHouse
  • row-insert gets the interleaved log because a cyclic FK graph has no table order that keeps a parent ahead of its children — only the walk does
  • give ClickHouse the spool, on the guarantees it already had: same inserts, same cursor-file write, same order; no dedup token, no cursor table, no migration
  • remove the hardcoded QueueDepth: 2, which capped the spool at ~2GiB and made the 8GiB budget unreachable; the byte quota is now the only ceiling
  • check the disk quota before the manifest is written rather than after, and count the open segment against it
  • seal the open segment on idle, from a timer goroutine, with a mutex on current — a stalled sinker is blocked in a read and cannot check anything
  • persist a cursor-only segment when a flush carries no rows, so a long empty stretch is not re-streamed on restart
  • generalise Buffer.resize into a shared sizer: one dial (segment bytes) in every write mode, floor an internal constant rather than a flag

Constraints

  • load without constraints and create them afterwards — 27.7x vs 3.3x through binary COPY over 500k rows
  • order table operations by a topological sort over the foreign keys, so the fast paths work with constraints in place
  • undo a reorg by deleting from every table explicitly, children first, instead of relying on a cascade that only exists when constraints do
  • commit the constraint pass one statement at a time instead of one transaction for all of it — an OOM used to lose the entire pass
  • key constraints by (relation, name), not name: fk_block exists on every table, so one present anywhere counted as present everywhere
  • fix constraints drop failing with cannot drop constraint block_pk ... other objects depend on it — same root cause
  • warn at startup naming the constraints the schema is meant to have and does not, with what will create them
  • warn instead of erroring when the output has no schema.proto annotations, on both the run and constraints apply

Block number index

  • create an index on block_number on every table — a foreign key indexes its referenced side only, so every undo was a sequential scan per table
  • create it at sink startup and CONCURRENTLY, decoupled from the constraint pass: that runs in transactions, which a concurrent build cannot
  • drop and rebuild an index a previous interrupted concurrent build left invalid — IF NOT EXISTS would otherwise keep it forever

Behaviour fixes

  • drain the spool at a bounded run's stop block — it panicked on a nil transaction — and leave the constraints alone there, a stop block ending the run without saying the backfill is done
  • switch to direct inserts at chain head: drain the spool, close it for good, and say which flags stop apply
  • flush the blocks still held when a bounded run reaches its stop block — they were dropped along with their cursor, and the run reported success

Observability

  • report downloaded_through / applied_through / blocks_ahead / blocks_buffered / peak_blocks_ahead; past half the spool's disk budget it logs at warning level, a block count saying nothing once rows are on disk
  • log the resolved write mode, decode workers, batch size, spool settings and constraint policy in one line
  • log why auto resolved to row-insert, and warn when --apply-constraints=always picks the measured 27x path

Tests and measurements

  • add TestDbProtoPostgresWriteModes — the three PostgreSQL modes must produce identical tables
  • add TestDbProtoClickhouseSpool — a spooled ClickHouse run must match an unspooled one
  • add TestDbProtoConstraintsParallelism, TestDbProtoPostgresMissingConstraints, TestDbProtoPostgresBlockNumberIndex, TestDbProtoConstraintsAtEndOfRangeWithSpool
  • add TestConstraintTarget / TestConstraintTargetDistinguishesTables — same name on two tables must be two c
  • add TestRenderProtoFileParsesWithAnnotations — the scaffold must compile once its options are uncommented
  • add the benchmarks package: TestBlockNumberIndexCost, TestConstraintCost, TestCopyVsInsert, client-ceilingus live-benchmark.sh and its README
  • measured: index costs 2.6s on a 44s load and 218MiB against 10GiB, and takes one table's undo from 1.296s to 15ms

Docs

  • rewrite docs/references/sql/proto-annotations.md: flag groups, write modes, constraint timings, the block index, and starting from an unannotated package
  • changelog entries for the spool, the write modes, the flag rename, the mode guards, the constraint pass, t

ClickHouse without proto annotations

  • default a table with no 'order_by_fields' to ORDER BY (block_number, row_id), PRIMARY KEY (block_number), PARTITION BY (toYYYYMM(block_timestamp)) — a declared primary key leads the key, ClickHouse requiring it as a prefix
  • add the row_id column: the count of rows the block has already written to that table, threaded through the walk in a per-block map rather than held on the database, the decoder walking blocks on parallel goroutines
  • pass options.UseProtoOption to the ClickHouse database instead of a hardcoded true — an unannotated package was walked and inserted nothing
  • register the ClickHouse state flags on setup as well, which exited with Flag "sink-info-folder" does not exist
  • carry row_id into the undo tombstone, without which it lands on a different sorting key and removes nothing
  • refuse to start on a database whose tables disagree with the package about row_id — annotating a message after the fact changes the sorting key of a table that already holds rows, and CREATE TABLE IF NOT EXISTS would keep the old one and write values into the wrong columns
  • add TestDbProtoClickhouseWithoutAnnotations and ...Undo against a real server: three rows survive OPTIMIZE FINAL with distinct ids, an undo removes exactly the undone block's rows, and a mismatched table is rejected
  • add unit cover for the DDL defaults and for the numbering, including that it resets per block and is absent on PostgreSQL

Measured end to end

Uniswap v4 on Base, map_events, blocks 25,350,988 → 36,712,209 (11,361,221 blocks), into
containerised ClickHouse and PostgreSQL 17. Both engines wrote 370,507,376 rows and
agreed on every table to the row, which is the correctness result as much as the timing one.

PostgreSQL (no constraints) PostgreSQL (w/constraints) ClickHouse
wall time 18m 36s 37m 08s 23m 04s
— fill 18m 36s 18m 36s 23m 04s
— constraint pass 18m 32s n/a
rows/s (fill) ~332k ~332k ~268k
database size 107 GB 138 GB

The constraint pass costing as much as loading all 370M rows is what makes the stop-block
change worth having: a ten-chunk backfill used to pay that at every chunk boundary, and
have every chunk after the first load the 27.7x path.

Per-block sink cost is now small enough that the run is usually delivery-bound: block
insert 0.02ms, entities 0.01ms, flush 0.00ms, with 77% of wall time waiting on the stream
over the full range.

Fixes folded in

#881 merged into this branch: twelve bugs found reviewing the work above, each with a
regression test checked to fail without its fix — among them the ClickHouse cursor never
reaching its file while spooling, _blocks_ rows racing the applier, a descriptor leaked
per sealed COPY segment, reorgs failing on cyclic schemas, backslashes doubled in rendered
literals, and empty or enum arrays aborting a segment in the default write mode.

@sduchesneau

sduchesneau commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

🔍 Vulnerabilities of ghcr.io/streamingfast/substreams:b5e97ac

📦 Image Reference ghcr.io/streamingfast/substreams:b5e97ac
digestsha256:93f147d56dc616e61f337d89cc77fb85180fd9259c54698a30436efe055ef17d
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
sduchesneau force-pushed the feature/sink-sql-local-cache branch from 4ddddca to db6e303 Compare August 10, 2026 19:48
@sduchesneau
sduchesneau requested a review from maoueh August 10, 2026 20:00
@sduchesneau
sduchesneau force-pushed the feature/sink-sql-local-cache branch from db6e303 to fb0b784 Compare August 11, 2026 17:02
Base automatically changed from feature/sink-sql-decode-workers to develop August 11, 2026 18:52
@maoueh
maoueh force-pushed the feature/sink-sql-local-cache branch from 6408e54 to c810ac2 Compare August 11, 2026 18:52
maoueh
maoueh previously requested changes Aug 11, 2026
// parameterised INSERT of the same value produces. Binary COPY does no coercion, so an
// encoder bug shows up as either a hard COPY failure or, worse, silently wrong data.
func TestPgCopyBinaryRoundTrip(t *testing.T) {
requireDocker(t)

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.

Tests that are relatively fast to perform, even within Docker, should all be run by default. If this can easily launch testcontainers to make the test, it should be enabled automatically.

Comment on lines +233 to +241
func sortedKeys[V any](in map[string]V) []string {
out := make([]string, 0, len(in))
for key := range in {
out = append(out, key)
}
sort.Strings(out)

return out
}

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.

Doesnt maps + slices could do that in one line?

// sinker is unchanged: what differs is that a "flush" only seals a segment, and the
// database is written to later, by the buffer's own goroutine. That is the whole point —
// the stream stops waiting on PostgreSQL.
type bufferInserter struct {

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 have also now BufferedInserter, not sure if we could not find a better name

Comment on lines +129 to +139
func humanBytes(n int64) string {
value := float64(n)
for _, unit := range []string{"B", "KiB", "MiB", "GiB"} {
if value < 1024 {
return fmt.Sprintf("%.1f%s", value, unit)
}
value /= 1024
}

return fmt.Sprintf("%.1fTiB", value)
}

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.

I'm adding a formatx package to hold all this, once existing, will point Agents.md to it.

@sduchesneau
sduchesneau force-pushed the feature/sink-sql-local-cache branch 2 times, most recently from bcc27da to 048215b Compare August 12, 2026 02:08
@sduchesneau
sduchesneau force-pushed the feature/sink-sql-local-cache branch 2 times, most recently from 3b39425 to 64d1b76 Compare August 12, 2026 19:16
@sduchesneau sduchesneau changed the title Buffer rows on local disk and load them with binary COPY Sink from-proto through a disk spool, and load without constraints Aug 12, 2026
A writer that turns a walk's []any into the bytes `COPY ... FROM STDIN (FORMAT
BINARY)` expects, and the normalisation that gets there: binary COPY does no
coercion, so every value has to match the column type the server reported.
Substreams throughput is paid for, so a slow or stalled database should cost disk
rather than download progress, and blocks already paid for should survive a
restart rather than be streamed again.

The package owns the segment lifecycle, the disk budget and the sizing. What the
bytes on disk look like is a Codec, how a sealed segment reaches the server is an
Applier, and both are the driver's business.

Segments are sized by measured commit duration rather than by a block count:
block payloads differ by orders of magnitude across chains, so a fixed count
gives wildly unstable durations. The disk budget is the only ceiling on how far
ahead of the database the stream may run, and the open segment counts against it.
An idle window commits what is open when the stream goes quiet, so a stall cannot
leave the cursor where it was.
Two things the from-proto sink used to do inline now happen off the stream's
path, and the flags say which resource each one spends.

Rows go to the spool rather than to the database, in the format the chosen
--write-mode can send unchanged: binary COPY files, rendered SQL tuples per
table, an interleaved log replayed in walk order, or typed values on ClickHouse,
whose inserts are columnar. Which path runs used to be derived from whether a
directory flag was set and whether the schema's foreign keys happened to form a
cycle, and an unorderable schema was silently downgraded to row-at-a-time
inserts — an order of magnitude slower, announced only in a log line. An explicit
mode the driver or the schema cannot support is now an error that names the fix.

Constraints are created after the load rather than during it: measured through
binary COPY, loading with foreign keys in place costs 27.7x against 3.3x for
building the same ones afterwards. --apply-constraints says when, defaulting to
auto — a backfill that ends with no primary keys and no foreign keys is a silent
wrong result that looks like success, where the stop-the-world pass is at least a
visible one. `constraints apply` and `constraints drop` are how that pass goes
into a maintenance window instead, and what setup recorded is what they use.

The flags are grouped by what they spend: --decode-* is CPU, --db-write-* is one
commit to the database, --spool-* is how far ahead of it the stream may run. Both
mode vocabularies still land on one command, the mode being read from the module
long after the flags are registered, so the help groups them under headings and a
flag typed for the other mode fails rather than being ignored.

At the chain head the spool is drained and left behind: a block should be
queryable when it arrives, not when the segment it lands in is full.
The container-backed tests: every write mode produces the same table, a spooled
ClickHouse run matches an unspooled one, a bounded run writes what it held at the
stop block, an undo removes the right rows with and without constraints, and the
switch to direct inserts at the chain head leaves nothing behind.

Plus the two that need no container: the constraint policy round-trips through
what setup records, and the table order really is a topological sort over the
foreign keys.
The numbers the defaults rest on, so they can be re-run rather than trusted:
binary COPY against multi-row INSERT under each set of constraints, the cost of
loading with constraints in place against building them afterwards, and where the
client's own ceiling is.
The reference gets a table per flag group and the constraint timings; the plan
records the decisions and what they rejected, and the local-buffer plan it
supersedes says so at the top.
@sduchesneau
sduchesneau force-pushed the feature/sink-sql-local-cache branch from 64d1b76 to 2e999da Compare August 12, 2026 19:19
A bounded run that spooled panicked at its stop block: the spool owns the
transactions while it is open, so BeginTransaction was a no-op and the constraint
pass dereferenced a nil one.

Draining first also puts the rows in before the constraints go on, which is the
whole point of creating them afterwards — the tail of the range was otherwise
loaded against foreign keys that were already there.
A run interrupted before the backfill ended, or whose constraint pass was killed
part-way, leaves a database with no primary keys and no foreign keys. It answers
queries, slowly and rejecting nothing, and looks exactly like a database that is
fine — so the sink now compares the policy against the catalog on every start and
names what is absent. One indexed query, against a pass that takes minutes.

It only reports. Creating them stays the timing's business: auto does it when the
backfill ends, manual leaves it to `constraints apply`.

Also drops the constraint policy that setup recorded in _sink_info_. setup is
optional — the run path creates the schema when it finds none — so what it stored
was there or not depending on a step nobody has to take, and `constraints apply`
would quietly mean different things on two identical deployments.
Every index build and foreign key validation was held open in a single
transaction, so a schema large enough to exhaust memory lost the whole pass and
the next run started from nothing. They are committed one at a time now, which
bounds the memory and leaves a killed run's finished work in place.
--constraints-per-transaction raises that on the `constraints` commands; the run
keeps to one, more deliberate scheduling being what the command is for.

Two bugs the batching exposed, both from keying constraints by name alone. Names
are unique per table, not per schema, and every table carries a foreign key
called fk_block: one present on any table counted as present on all of them, so
the rest were never created, and `constraints drop` removed one and then failed
on the primary key the others still referenced. They are keyed by relation and
name now, and reported that way too.
`constraints apply|drop` and `setup` inferred the output module from the package
and had no way to be told otherwise, so on a package with more than one candidate
they derived a schema from a different module than the run they accompany — and
then created constraints for tables that are not there.

They take it as an optional second argument now, the same shape the run command
has always had.
An output without schema.proto annotations gets tables derived from the message
structure alone: no primary keys, no unique constraints, no foreign keys, and so
no indexes at all. The run said nothing, and `constraints apply` said only that
there was nothing to apply, which reads like the schema is already fine.
Every table carries _block_number_ and every reorg deletes from every table by it,
but nothing indexed it: a foreign key indexes its referenced side only. Each undo
was a sequential scan per table, and the cascade the undo used to rely on would
have been worse still, PostgreSQL looking the child rows up once per deleted
parent row.

The index is created in the same pass as the constraints, being the same kind of
expensive and wanted at the same moment. --disable-block-number-index leaves it
out for a run that can never reorg.

It is also the one thing that pass can do for an output with no schema
annotations, so `constraints apply` now warns there instead of refusing: nothing
declares a primary key, but the reorg path still deletes by block number.

Starting from an unannotated package meant finding the right proto and the right
extension names by hand, so `substreams tools extract-proto --sql` writes the
output message back out with every option commented out beside the message and
field it applies to, and the annotations file beside it so the result parses.
10GiB of erc20-balance-changes-shaped rows, locally: building the index after the
load costs 2.6s on a 44s load and 218MiB against 10GiB, and having it in place
while the rows arrive costs nothing measurable — _block_number_ only increases
during a backfill, so every insert lands on the rightmost page of the btree.

It takes one table's undo from 1.296s to 15ms. The reorg path deletes from every
table by that column.

ANALYZE is part of the method rather than incidental: a COPY leaves no statistics,
and without them the planner sequentially scans a predicate matching 0.2% of the
table. The first version of this measurement reported 4.7s for an indexed undo for
exactly that reason, so the chosen plan is now reported next to every number.
sduchesneau and others added 4 commits August 13, 2026 18:24
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 and others added 21 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.
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>
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>
Fix the spool's critical bugs before it ships
A stop block ends the run; it does not say the backfill is over. A range is
routinely one chunk of several, and creating the constraints at the end of one
left every chunk after it loading into a constrained schema — measured at
27.7x, which is the case this whole arrangement exists to avoid.

Reaching chain HEAD is the one signal that says there is nothing left to load,
so that is now the only place 'auto' acts. A bounded run says on the way out
what it left behind, since a schema with no primary keys and no foreign keys
answers queries and looks exactly like one that is fine.

Two log lines named things that do not exist while they were at it: an
--apply-constraints=head value, and an 'apply-constraints' subcommand where it
is 'constraints apply'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--apply-constraints goes back on setup, which reads it: 'always' creates them
with the schema, while 'auto' and 'manual' both leave the tables bare, the
first for the run to constrain at chain HEAD and the second for
`constraints apply`.

--disable-all-constraints goes away with it. The three --disable-* switches
already say what the schema declares, and --no-constraints deprecates to them
again rather than to a fourth spelling of the same thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "falling behind" warning tripped on how many blocks the buffer spanned,
which says nothing once rows are on disk: the sparse start of a large backfill
covers millions of blocks holding a few hundred kilobytes, so a healthy run
warned continuously from its first minute. It now measures the spool against
the disk budget it was given, which is the number the operator set.

The startup warning also promised constraints "will be created once the
backfill reaches chain HEAD", which a run given a stop block may never do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--constraints-per-transaction read as an execution knob and only bundled
statements into one transaction, which is not what it says and not what the
pass needed. It becomes --constraints-parallelism, and now does what the name
promised: statements inside a wave go to the server together.

Constraints sit on independent relations, so the only ordering that matters is
that a foreign key needs the key it references — the keys are one wave and the
foreign keys the next, the reverse for a drop. Each statement still commits on
its own, so a killed pass keeps what it finished.

--constraints-work-mem sets maintenance_work_mem for the duration of each
statement. Most servers default to 64MB, at which an index build over a large
table spills to an external merge sort; measured over 370M rows the pass took
as long as loading every row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wait for the relayer, not the reader node, in devenv
The container wait matched "serving gRPC", which the reader node logs for its
own port well before the relayer serves the one tier1 connects to, and the
host-side dial that followed proves nothing: Docker binds the mapped port when
the container starts. Tier1 starting in that window gets a live stream with no
partial blocks at all, which TestPartialBlocksWithStores saw as every block
arriving full, with no undos.
Comment thread bin/test.sh
Comment on lines +22 to +29
# tests_e2e is its own Go module, so `go test ./...` above never reaches it. It stays
# out of this script until it passes on Linux: the node writes root-owned files into
# the bind-mounted t.TempDir, which fails every container test's cleanup on a CI
# runner. See the fix/tests-e2e-on-linux branch.
#
# pushd tests_e2e &> /dev/null
# go test ./... "$@"
# popd
# popd &> /dev/null

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.

Useless, still not running ...

setupFlags.Bool("system-tables-only", false, "[DatabaseChanges mode] will only create/update the systems tables (cursors, substreams_history) and ignore the schema from the manifest")
setupFlags.Bool("ignore-duplicate-table-errors", false, "[DatabaseChanges mode][Dev] Use this if you want to ignore duplicate table errors, take caution that this means the 'schema.sql' file will not have run fully!")
setupFlags.Bool("postgraphile", false, "Will append the necessary 'comments' on cursors table to fully support postgraphile")
setupFlags.Bool("system-tables-only", false, "will only create/update the systems tables (cursors, substreams_history) and ignore the schema from the manifest")

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.

Why we lost [DatabaseChanges] note, if it's because it's not adding system tables in both, we should list all possible system tables for both mode.

Comment on lines +133 to +139
func stringFlag(cmd *cobra.Command, name string) string {
if sflags.FlagDefined(cmd, name) {
return sflags.MustGetString(cmd, name)
}

return ""
}

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.

I'm thinking of adding those generated version on sflags now, maybe OptionalGetString with default, thinking about it.

manifestPath,
outputModule,
"sink_database_changes",
sinkUserAgent("sink_database_changes", sinkPostgresDriver),

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.

sink_database_changes and even sinkUserAgent don't really use more "standard" agent formats which uses a lot the / for namespacing.

We should do the same + show full name postgres, clickhouse.

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 should also embedded CLI version in the user agent too

// addDatabaseChangesModeRunFlags registers the run flags that only apply when the
// selected module outputs DatabaseChanges.
func addDatabaseChangesModeRunFlags(flags *pflag.FlagSet) {
flags.Int("undo-buffer-size", 0, "[DatabaseChanges mode] If non-zero, handling of reorgs in the database is disabled. Instead, a buffer is introduced to only process blocks once they have been confirmed by that many blocks, introducing a latency but slightly reducing the load on the database when close to head. Set to 0 to enable reorg handling in the database (required for some databases like Postgres).")

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.

Are we sure some of them [DatabaseChanges mode] should keep those?

Comment thread cmd/substreams/sink_sql_common.go Outdated
Comment on lines +845 to +846
"This one outputs DatabaseChanges, where rows are written from the module's own database changes "+
"and the schema is the 'schema.sql' bundled in the manifest")

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.

Each rejectFlags because should have a link to some docs reference.

Comment thread cmd/substreams/sink_sql_common.go Outdated
Comment on lines +824 to +826
if err := dropDatabaseConstraints(database); err != nil {
return err
}

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.

A confirmation should be asked just in case IMO

Comment on lines +935 to +937
zlog.Warn("the module's output carries no schema.proto annotations, so its tables get no primary keys, no unique constraints and no foreign keys. " +
"Queries against them are sequential scans and duplicate ids are not rejected. " +
"`substreams tools extract-proto --sql` writes the annotated proto to start from, and --proto-file-override points the sink at it")

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.

Same here, revisit those integrity warnings/errors and ensure we have proper backlinking to docs or README or some other material.

Comment on lines +1279 to +1287
func parseByteSize(cmd *cobra.Command, name string) (int64, error) {
raw := sflags.MustGetString(cmd, name)
parsed, err := humanize.ParseBytes(raw)
if err != nil {
return 0, fmt.Errorf("invalid --%s %q: %w", name, raw, err)
}

return int64(parsed), nil
}

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.

Very good candidate for sflags addition too.

Comment on lines +1289 to +1296
// flagChanged reports whether the operator actually typed the flag, as opposed to it
// carrying its default. Every mode and deprecation check keys off this: a default value
// must never be read as an instruction.
func flagChanged(cmd *cobra.Command, name string) bool {
flag := cmd.Flags().Lookup(name)

return flag != nil && flag.Changed
}

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.

sflags has Provided variant on all getter MustGetBoolProvided returns two value T, bool where second is if the flag.Changed

(Maybe Changed would be a better word in sflags actually).

@sduchesneau
sduchesneau merged commit 72cbfaf into develop Aug 17, 2026
9 of 10 checks passed
@sduchesneau
sduchesneau deleted the feature/sink-sql-local-cache branch August 17, 2026 18:45
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.

3 participants