Repository navigation
Releases: rivet-lab/rivet
Release list
v0.32.2
Upgrading from 0.32.1 — read these first:
-
State migration v37 → v38 (the load spec keyed by export, table and source). The first run
of 0.32.2 migrates the state and leaves the v37 copy beside it; 0.32.1 refuses a v38 state
(RIVET_STATE_SCHEMA_NEWER), so the way back is that copy (docs/reference/state.md, "Rolling
back an upgrade"). -
A run now waits for a held lease for up to
--lock-waitseconds (default 600, also
RIVET_LOCK_WAIT) instead of refusing at once; when the wait runs out it stops with the new
RIVET_STATE_LOCK_TIMEOUT, exit 2. Pass--lock-wait 0to keep the immediate refusal. -
New refusals:
- two configs on one CDC checkpoint:
RIVET_SOURCE_CDC_CHECKPOINT_SHARED, exit 5, before a change
is read (a checkpoint 0.32.1 wrote is claimed by the first run of 0.32.2 that reads it); rivet initover a CDC checkpoint or destination another config names:RIVET_INIT_CDC_NAME_TAKEN,
exit 5, nothing written;rivet load/rivet compactwithout aload:block:RIVET_LOAD_BLOCK_MISSING, exit 1 (was
uncoded);- a whole-table pass over a base-and-buffer base that still holds uncompacted changes:
RIVET_LOAD_BUFFER_NOT_COMPACTED. - a 0.32.2 command started while a 0.32.1 run holds a lease on the same state:
RIVET_STATE_UPGRADE_BESIDE_LIVE_RUN, exit 2, after waiting up to--lock-wait; the state is
not migrated, so the 0.32.1 run and its next run go on (upgrade once it ends).
- two configs on one CDC checkpoint:
-
rivet init --mode cdc -o <file>names the slot,server_id, checkpoint and destination after
the file (-o rivet.yaml->./cdc/rivet_<table>.ckpt,./output/rivet/<table>_cdc/). Existing
configs are read as before. -
The SQLite lease file is named by a hash of its key; until 0.34.0 a run also holds the 0.32.1
name, so a 0.32.1 run still going when 0.32.2 starts the same export is refused as before. -
PostgreSQL CDC: the first 0.32.2 run of a stream 0.32.1 left with its slot past its checkpoint
takes the slot's position once (a warning names both); a slot another reader advanced is refused
RIVET_SOURCE_CDC_LOG_GAPas in every 0.32.x. -
Every MySQL run opened an extra source connection to detect a proxy. The fix that keeps
ProxySQL multiplexed (0.32.2, literal session SETs) detected the proxy on a connection dialled for
that alone, so a batch run opened 4 MySQL connections instead of 2 and a CDC run 3 instead of 2.
Now the proxy is detected once per source URL per process, on the pool's own first connection,
by statements that keep a ProxySQL session multiplexed (PROXYSQL INTERNAL SESSION,
SHOW GLOBAL VARIABLES LIKE …in place ofSELECT @@…): a batch run opens 2 and a CDC run 2
again. What to do: nothing. -
A rivet with a newer state schema, started beside a live run of an older release, migrated the
state under that run, even when it was then refused. Every command opened and migrated the state
before it took its lease, so arivet runorrivet loadrefused as busy (is writing,
RIVET_STATE_RUN_IN_PROGRESS) had already moved the state to the new schema, and the next run of
the older release on that state was refusedRIVET_STATE_SCHEMA_NEWER(every release with a state
migration; found by the 0.32.2 pre-release gate on the v37 → v38 migration). Now a migration
first asks whether another live process holds a lease on the state (a locked lease file beside a
SQLite state; an unexpiredstate_leaserow of a live holder on a PostgreSQL state). While one
does, it waits up to--lock-waitand then stops with the newRIVET_STATE_UPGRADE_BESIDE_LIVE_RUN
(busy, exit 2), the state not migrated and nothing written. Behaviour change: the first command
of a release with a newer state schema, started while a run of the older release holds any lease
on the same state, waits for that run or stops by this code instead of migrating the state beside
it; a state no live run holds is migrated as before, and state written before the upgrade is
unchanged. What to do: nothing; a scheduler that retries exit 2 runs the upgrade once the older
run ends. -
A MySQL CDC config written by
rivet init --mode cdcwas refused when another database on the
server held a table of the same name.initwrites the tables unqualified (tables: [orders]),
and every stream run stopped withRIVET_SOURCE_CDC_TABLE_UNRESOLVED("ordersis unqualified …
<other_db>also holds a table of that name"), at the catalog check or on the first foreign event
in the binlog (0.32.1 and earlier). Now a bare name means the connection's own database when
that database holds the table, and the same name's events from any other database are not
captured. When the connection's database does not hold it and exactly one other database does,
the stream captures that one (0.32.1 stopped there with MySQL error 1146 at the table probe).
With none or several, it is refused as before. The catalog check, the table probe, the schema
read, the baseline/backfill read and the binlog routing take that one rule, so the baseline and
the stream read one table. The destination, the warehouse tables and the state of an existing
stream are unchanged. What to do: nothing; a config that was refused for this runs as
generated. -
A first CDC load into a BigQuery base-and-buffer table reported nothing loaded. The load landed
the baseline in the base, but itsper_table[]entry (--json,--summary-output) and its
CDC LOAD OKline counted only the stream leg:rows: 0,target: <table>__changes, "no changes
buffered" for a 6000-row baseline (0.32.0 and 0.32.1). Nowrowsis both legs' rows,target
is the base when a baseline landed,buffernames the change log when the same load also
appended changes (ADR-0039 D3, amended), and the line says what the baseline landed and where.
What to do: nothing; a scheduler that readtargetof a CDC load as the change log now gets
the base on a cycle that landed a baseline. -
Harness / release process: a tag could be cut on a commit the full gate never graded, with seeded
recall,cargo auditand the generated docs outside the gate. Seeded-defect recall was opt-in, so
24 seed patches had stopped applying to main unseen. Nowmake release-oracle-fullruns the
recall (a MISSED or STALE seed fails the gate),cargo auditanddocgen --checkare gate stages,
the gate records its verdict with the full commit, its kind and whether the tree was clean at its
start, anddev/pytools/release.pyrefuses to tag a commit without a clean RELEASE-READY
release-oracle-fullrun of exactly that commit. The 24 patches are re-cut, and the offline
self-test fails a PR whose seed no longer applies. What to do: release from a commit the full
gate passed; pass--gate-record FILEwhen the gate ran on another machine. -
Harness: the rig oracle read rivet's ledger from the first schema that held a table of its name.
A schema an older test left in the stand's PostgreSQL state (rivet_wayback_nocreate) came first
forfile_log, so every cell graded on that state readfile_log records 0 of this run's N declared part(s)(the pool-ledger and state-ledger reds). Now the oracle resolves each table through the
state connection'ssearch_path, as rivet does, and the seeded recall's runs use its scratch state
DB under every state knob, never the shared one. What to do: nothing. -
rivet initscaffolded GCS and S3 destinations but not Azure. Now--azure-container <name> --azure-account <name>writesdestination: type: azurewith the sameprefix: exports/<table>/
(cdc/for a CDC stream),account_name:andaccount_key_env: AZURE_STORAGE_KEY, never a key;
--azure-endpoint <url>addsendpoint:(Azurite). It excludes--gcs-bucket,--s3-bucket
(RIVET_CLI_OPTION_UNSUPPORTED/ a clap conflict, as between those two) and--discover, and it is
a bucket--clickhouse-urlcan stage in. What to do: nothing; setAZURE_STORAGE_KEY(or swap in
sas_token_env:) before the first run. -
Two CDC configs written by
rivet init -ointo one directory shared one checkpoint and one
destination. Through 0.32.1init --mode cdcnamed the checkpoint after the table or schema
(./cdc/<name>.ckpt) and the CDC destinationcdc//./output/cdc/(per table<t>_cdc/)
whatever-osaid, so a second config of the same source, written beside the first, resumed the
first one's position: each stream skipped what the other had read, exit 0. Nowinit -o <stem>.yamlnames the checkpoint./cdc/<stem>_<name>.ckptand the destination
<stem>/cdc//./output/<stem>/cdc/(per tableexports/<stem>/<t>_cdc/), and refuses
RIVET_INIT_CDC_NAME_TAKEN, exit 5, writing nothing, when the checkpoint already exists or another
config in the directory names the same checkpoint or destination. A run records which config and
export own its checkpoint (<checkpoint>.owner) and refuses a checkpoint another config still
reads,RIVET_SOURCE_CDC_CHECKPOINT_SHARED, exit 5, before it reads a change. A checkpoint written
before 0.32.2 has no owner record and is claimed by the first run that reads it; one whose owner no
longer names it is claimed too. What to do: nothing for one config per checkpoint; for two
configs on one checkpoint, give one of them a checkpoint and destination of its own and re-baseline it. -
Two exports whose names differ only in characters a file name cannot hold shared one run lease
on a SQLite state. Through 0.32.1 the lease file was named by a lossy token of its key, so
orders#1andorders-1(ororders xandorders-x, or two Cyrillic names) locked one file, and
with--parallel-exportsone of them was refusedRIVET_STATE_RUN_IN_PROGRESSbeside no live run of
its own. Now the fil...
v0.32.1
Upgrading from 0.32.0 — read these first:
-
No state migration. The state schema stays v37; 0.32.0 and 0.32.1 open each other's state.
-
New refusals (exit 5) where 0.32.0 went on or failed generically:
- a MySQL session still off UTC after rivet's
SET time_zone(a proxy dropped it):
RIVET_SOURCE_SESSION_TIME_ZONE_NOT_APPLIED, before any row is read; - a remote PostgreSQL
RIVET_STATE_URLwithoutsslmode:RIVET_STATE_URL_TLS_REQUIRED
(add?sslmode=require, or?sslmode=disableon a private network); rivet checkthat logged an ERROR:RIVET_CLI_CHECK_REPORTED_ERRORSinstead ofLooks good.;rivet initwith aRIVET_STATE_URLthat does not answer writes no config.
- a MySQL session still off UTC after rivet's
-
rivet initnames a CDC export<table>_cdcwith a sibling prefix (new configs only; an
existing config is read as before). -
A state write beside a sibling writer in the same process could fail
database is locked.
SQLite's busy wait has no queue: a writer that committed back to back (chunk workers, the lease
keeper, a run's own store on one SQLite state file) took the lock in every gap, and the other
store'sBEGIN IMMEDIATEwas refused after 15 s, past the 10 s busy timeout (0.32.0 and
earlier; seen as a 1-in-1000 CI failure of a replan beside a writer). Now writers of one
state file in one process take turns at its write lock, first come first served; another
process still meets SQLite's busy wait. What to do: nothing. -
A MySQL source behind a proxy that dropped
SET time_zonedelivered every TIMESTAMP shifted
by the server's offset, exit 0. rivet reads MySQL in a UTC session; a ProxySQL that answered
the SET without forwarding it left the read at the server's zone (a pilot's Europe/Moscow:
12:00:00Zwritten for09:00:00Z) (0.32.0 and earlier). Now rivet checks on the
connection that reads, before the first row, that the session is at UTC, and refuses
RIVET_SOURCE_SESSION_TIME_ZONE_NOT_APPLIED, exit 5, naming the offset otherwise. What to
do: setdefault_time_zone = '+00:00'on the server, keep rivet's session variables on one
backend in the proxy, or connect directly; re-export what a proxied run delivered before. -
A
skippedentry of the run summary did not say why it was skipped.--summary-output
and--jsonofrivet run/rivet applywrotestatus: "skipped"for askip_emptyrun
with no reason, while the console card and the metrics row had it (0.32.0 and earlier).
Now everyper_export[]entry carriesskip_reason: the reason for askippedentry ("no
new rows since cursor 'updated_at'", "source returned 0 rows"),nullfor every other, on the
sequential, thread and process runners. What to do: nothing; a scheduler may read it. -
rivet initgave a table's batch and CDC exports one name and nested the CDC prefix in the
batch one. Both scaffolds named the export after the table, and the CDC one wrote
exports/<t>/cdc/insideexports/<t>/(./output/<t>/cdc/locally), so the two exports shared
a load spec in a shared state and a load of the batch prefix listed the stream's runs (0.32.0
and earlier). Now a CDC scaffold names its export<t>_cdcand writes to the sibling
exports/<t>_cdc/(./output/<t>_cdc/). What to do: nothing for new scaffolds; a config
generated earlier keeps working, andrivet loadalready skips a nested CDC export's runs. -
A schema-wide
rivet initon SQL Server scaffolded the system tables SQL Server ships. On a
database with CDC enableddbo.systranschemasbecame an export (0.32.0 and earlier). Now
tables and views markedIsMSShippedare left out. What to do: delete such an export from a
config generated earlier. -
A SQL Server login without
VIEW SERVER PERFORMANCE STATEfilled the log with permission
errors. Each run sampled the server counters (sys.dm_os_performance_countersand the wait
and query statistics) and the driver logged the server's refusal at ERROR, twice per run, on a
run that otherwise succeeded (0.32.0 and earlier). Now rivet asks once per connection
whether the login may read them and skips them quietly when it may not. What to do:
nothing; grantVIEW SERVER PERFORMANCE STATE(2022) orVIEW SERVER STATEto keep the
harm counters and the governor's pressure signal. -
The rollback steps left the previous release refusing every table the newer release
created. After putting the state copy back, a warehouse table first loaded after the upgrade
has no row in the restored load ledger, so the previous release'srivet loadrefused it on
every cycle ("no record of rivet loading it", exit 1), anddocs/reference/state.mddid not say
so (0.32.0). Now the rollback steps list those tables (rivet state loadsbefore and after
the restore) and drop them, and the previous release's next load lands them again from the runs
still staged. What to do: when rolling back, follow steps 2, 4 and 5 of "Rolling back an
upgrade". -
rivet init,rivet checkandrivet doctorwere green over a state no run could open.
WithRIVET_STATE_URLpointing at a state that does not answer,initwarned "primary keys
not recorded" and wrote the config, exit 0;checkprintedLooks good., exit 0;doctor
did not look at the state at all, so setup passed and every run after it failed (0.32.0 and
earlier). Nowdoctoropens the state the config uses without migrating it and prints
[OK] State (postgres …)/(sqlite …)or fails;checkexits non-zero
(RIVET_CLI_CHECK_REPORTED_ERRORS) when it logged an ERROR line, including a state that does not
answer;init -owithRIVET_STATE_URLset refuses before writing the config when that state
does not answer. What to do: nothing; fix what the new failure names. -
rivet --versionprinted(unknown)instead of the commit on the glibc 2.28 Linux assets.
The container that builds them checked the tree out without.git, so the build could not read
the commit (0.32.0). Now they printrivet <version> (<commit>)like every other asset, and
the release refuses to publish one that does not. What to do: nothing. -
A PostgreSQL state on a remote host without TLS could not be used at all. Every command
withRIVET_STATE_URLnaming a non-loopback host was refusedRIVET_STATE_BACKEND_FAILED,
exit 1, with a text that told the operator to addsource.tls, a key the state does not
have, and even?sslmode=disablewas refused (0.32.0 and earlier). Now?sslmode=disable
is honoured as the operator's opt-out with one warning line, and a remote URL that names no
sslmodeis refusedRIVET_STATE_URL_TLS_REQUIRED, exit 5, naming the two state remedies.
What to do: add?sslmode=requiretoRIVET_STATE_URLwith TLS on the state server, or
?sslmode=disableon a private network. -
A batch
rivet loadread the runs of a CDC export nested inside its prefix.rivet init
puts a table's CDC export atexports/<t>/cdc/inside its batch exportexports/<t>/, under
the same export name, and the load lists its prefix recursively: the batch table received the
stream's change rows, so a row deleted at the source stayed in it (a full load landed 7 rows
for 5, an incremental one kept the deleted key), exit 0 (0.32.0 and earlier). Now a full or
incremental load skips the CDC runs of its export that sit in a sub-directory holding none of
its batch runs, and says in one line which directories it skipped; the CDC export's own load
still loads them. What to do: nothing; reload a batch table that was loaded from such a
prefix (mode: fullreplaces it on the next load).
v0.32.0
Upgrading from 0.31.x or earlier — read these first:
- Exit codes follow the code's class. A coded refusal exits 5, an integrity stop 3, an internal
error 6, a usage error 1, an environment fault 2 when it is transient and 1 otherwise. Several
stops that exited 1 now exit 5; each is named in its entry below. A scheduler that retries on any
non-zero exit should stop retrying on 5. - The state schema moves from v32 to v37 on the first run. Before migrating, rivet now leaves a
copy of the old state (.rivet_state.db.v32.<stamp>.bakbeside a SQLite state, schema
rivet_state_backup_v32in a PostgreSQL state) and the way back to the previous release is in
docs/reference/state.md, "Rolling back an upgrade". If the copy cannot be written, the
migration does not run (RIVET_STATE_BACKUP_FAILED). - A relative
destination.pathis anchored to the config file's directory, not the working
directory. A run started from another directory while the export's files still sit under the old
place is refused (RIVET_DEST_RELATIVE_PATH_MOVED) until the files are moved or the path is made
absolute. - A dropped source table is refused
RIVET_SOURCE_TABLE_NOT_FOUNDon every SQL engine; remove
the export fromexports:if the table was dropped on purpose.
Known issue in this release (fixed in 0.32.1):
-
rivet loadof a batch export reads the manifests under its whole prefix, including another
export's nested under it.rivet initon SQL Server writes a batch prefixexports/<t>/and a
CDC prefixexports/<t>/cdc/; with both in one bucket,rivet loadof the batch export takes the
stream's snapshot and change parts as its own and applies their deletes to the batch table
(exit 0). Present since at least 0.31.0. What to do: never place one export's prefix inside
another's; in a configrivet initgenerated for SQL Server, move the CDC export'sprefixto a
sibling (for examplecdc/<t>/) before the first run. -
A plan applied after UTC midnight wrote under the planning day.
rivet planresolved
{date}in the destination into the sealed plan, so a plan made before midnight and applied
after it wrote under the planning day while the data and the part files were of the apply
(P-26, sincerivet planexists). Now the sealed plan keeps{date}as written and
rivet applyresolves it to the UTC day it runs on, through the same resolverrivet run
uses: a plan made before midnight UTC and applied after now writes under the apply date.
rivet plan --date YYYY-MM-DDfreezes that day into the plan instead. A plan sealed by an
earlier release holds an already-resolved path and applies as it did. What to do:
nothing; pass--datetorivet planwhen the planning day is the one wanted. -
rivet applyanswers a replay of an applied plan in the summary document, and gains--summary-output/--json.
A replay of a full, chunked or keyset plan that already applied to exit 0 (0.31.0, #616) answered
only in prose on stderr: no summary file, no JSON line, so a scheduler could not tell "already
applied by run X" from "succeeded, result missing" without parsing text. Nowrivet apply
takes--summary-output <PATH>and--jsonlikerivet run(in both plan-artifact and config
modes), and the replay writes the same document a run writes, built from the run that applied the
plan: itsrun_id, rows, files and bytes from itsexport_metricsrow,status: "already_applied"
andanswered_by_run: "<run_id>". Everyper_export[]entry now carriesanswered_by_run
(nullunless the entry answers a replay); the stderr line is unchanged, the exit stays 0, and
the replay still writes no run, manifest or metrics row. ADR-0039 lists the new status word and
key. What to do: a scheduler readsstatuson the entry; astatustable that treats an
unknown word as failed needsalready_appliedadded. -
An upgrade could not be rolled back: the first run of a newer rivet migrated the state schema in
place, and the previous release then refused it (RIVET_STATE_SCHEMA_NEWER, exit 5) with no copy
of what it had written. Release 0.31.0 knows schema v32; this release moves it to v37 (0.31.0
and earlier). Now a migration first writes a copy of the state as the previous release left it,
inside the migration lock and before its first statement: on SQLite the file
<state>.v<old>.<UTC time>.bakbeside the state (.rivet_state.db.v32.20261017T101530Z.bak), on
PostgreSQL the schemarivet_state_backup_v<old>in the state database, every state table with its
rows, keys, indexes and defaults, in the migration's own transaction. One log line names the copy.
A copy that cannot be written (disk full, a read-only directory, a role withoutCREATEon the
database) stops the migration withRIVET_STATE_BACKUP_FAILED(exit 1); the state is untouched and
the previous release still opens it. What to do: nothing to upgrade. To roll back, follow
Rolling back an upgrade: stop the schedulers,
install the previous release, put the copy back (SQLite: replace the file; PostgreSQL: the SQL
block there) and run once. Progress written after the upgrade is lost, so the rows delivered since
are delivered again, at least once; what that means per mode and forrivet loadis written there. -
Behaviour change: a replay of a sealed plan that already applied re-ran it, and an incremental plan could be applied only once.
rivet apply plan.jsonrun again after that plan's apply had exited 0 (a scheduler retry whose
task process died after rivet finished) extracted the whole table a second time formode: full,
chunked and keyset plans. An incremental plan made after a run had stored a cursor was refused
RIVET_PLAN_ARTIFACT_STALE(exit 5, "cursor has drifted") on every apply after its first, by its
own cursor commit, and any plan older than 24 h was refused (0.31.0 and earlier). Now a
full, chunked or keyset plan is a point-in-time snapshot: the run that applies it to exit 0
records the plan'splan_id(state schema v37,run_status.plan_id), and a replay of it exits 0
without extracting, printingapply: plan <plan_id> of export '<export>' was already applied by run <run_id> (...)with that run's rows, files and bytes; it writes no run, manifest or metrics
row. An incremental plan (mode: incremental,keyset_incremental) is a reusable description:
every apply of it, a retry included, reads the cursor from state and delivers what arrived since,
new rows or none; a cursor moved by an earlier apply of the same plan is not drift, and the 24 h
limit no longer applies to it. A cursor moved by another run is still refused
RIVET_PLAN_ARTIFACT_STALE, and a plan whose apply failed, was interrupted or died still
replays. What to do: nothing for a retry. To extract a full, chunked or keyset plan again,
make a new plan withrivet plan, or pass--force. Plans applied before the upgrade recorded no
plan_id, so a replay of one behaves as before. -
An export whose source table had been dropped failed with the raw driver error and no code.
rivet runover a table dropped since the config was generated ended with the engine's own words
as itsError:line and noRIVET_*code on MySQL and PostgreSQL (Error: MySqlError { ERROR 1146 (42S02): Table 'rivet.drop_probe_b' doesn't exist }, exit 1), orRIVET_SOURCE_QUERY_FAILED
on SQL Server and Oracle;rivet checkrefused the same table asRIVET_SOURCE_QUERY_FAILED
(0.30.0 and 0.31.0). Now one function recognises each engine's own verdict (MySQL 1146,
PostgreSQL SQLSTATE 42P01, SQL Server 208, Oracle ORA-00942) on every path that reads the table
(the batch read, the parallel runners, the chunk planner's probe, the CDC snapshot leg,check
andplan), and the export stops asError: [RIVET_SOURCE_TABLE_NOT_FOUND] export 'drop_probe_b': the table it reads does not exist (drop_probe_b, read by itsquery:). Correct the export'stable:orquery:, or remove the export fromexports:if the table was dropped on purpose: MySqlError { ERROR 1146 (42S02): Table 'rivet.drop_probe_b' doesn't exist }(exit 1). The exit
stays 1 and the other exports of the same run still deliver (1 success · 1 failed), so alerting
on the exit code sees no change;rivet plankeepsRIVET_PLAN_SOURCE_UNREADABLEas its outer
code, as it does for a missing MongoDB collection, with this refusal as the cause. What to do:
remove the export fromexports:if the table was dropped on purpose, or correct itstable:. -
A relative
destination.pathfollowed the working directory, not the config. A local
export whosepathis relative (./output/orders, whatrivet initwrites) was resolved
against the directory rivet was started from, while its cursor and state live beside the
config: the same config run from a cron entry and from a shell delivered ids 1..10 under one
directory and 11..13 under the other, each run green (P-27). Now a relative localpath
is anchored to the config file's directory, resolved once at config load and used the same
way byrun,plan/apply,validate,reconcile,repair,state,doctorand
load; a cursor stored under the old relative spelling continues under the anchored path.
Transition guard: a command started from another directory, whose working-directory
place still holds the export's files (itsmanifest.json) while the directory beside the
config holds none, is refused withRIVET_DEST_RELATIVE_PATH_MOVED(exit 5) naming both
directories, before anything is read or written; the check is one decision in the config
loader, sorun,load,reconcile,repair,validateand every other command that
resolves the path meet it, and ...
v0.31.0
-
A resumed chunked run with no tasks is no longer an empty success. A checkpointed chunked
run wrote its run row and its task rows in two writes. When the task write failed, the run row
stayed behind with no tasks; the next plain run resumed it, found nothing to do, exited 0 and
delivered 0 of 150 rows (measured). The run row and its tasks are now written in one
transaction, so a run opens with its tasks or not at all. -
A
keyset_incrementalexport loads as an append, not an overwrite. Each run of such an
export delivers only the keys past its anchor, butrivet loadtreated it as a full load and
replaced the warehouse table with the newest delta. Measured on a PostgreSQL source (ids
1..100, then 101..120, then no change): BigQuery and ClickHouse held 100, then 20, then 20
rows. It now loads as an incremental load ordered bychunk_by_key. A table that an earlier
release already overwrote is not repaired by the upgrade; it holds only the last delta until
the source is re-read in full. -
CDC refuses a value its column type cannot hold instead of writing NULL. The CDC sink
appended NULL when a change event's value did not fit its column (integers, Boolean, floats,
dates, timestamps, times, binary). Only a source NULL is NULL now; any other mismatch fails the
flush and names the column. -
The PostgreSQL batch reader refuses an undecodable cell instead of panicking or writing
NULL. A text or enum cell that is not valid UTF-8 fails with
RIVET_SOURCE_VALUE_UNREPRESENTABLEnaming the column (text used to panic, enum wrote NULL).
Integer overrides decode at the wire width and widen exactly; a narrowing value that does not
fit is refused by name. -
Oracle CDC re-mines when the redo log set changes under LogMiner. A log switch that reused
or archived an online redo file LogMiner had registered failed the run with ORA-01368 behind a
permissions hint, and a sequence archived between two catalog reads could be refused as lost.
The run now re-plans the logs and restarts mining after the last queued commit group, five
times with a doubling backoff, before failing with a message that names the cause. -
Oracle CDC no longer pins its low-water mark at SCN 0. A transaction with a slot but no
start SCN yet showsSTART_SCN = 0; the anchor took it as the minimum, and the next run
planned redo from SCN 0 and refused with a false "changes LOST". -
CDC refuses an unmet prerequisite before the anchor and the snapshot write. Under
initial: snapshotthe first run pinned its anchor and wrote the snapshot part, manifest and
marker before the engine's refusals ran, so a source that cannot be captured was refused only
after a part and a checkpoint were on disk. The refusals now run first (MySQL: row metadata,
binlog compression, replica re-log, routing; Oracle: container, table vetting, supplemental
logging; PostgreSQL: routing). -
The PostgreSQL missing-slot refusal names the evidence it found. When the replication slot
is gone but an earlier run left a checkpoint, a completed snapshot, or both, the refusal now
says which, and gives the recovery steps for that case. -
rivet init --mode cdcscaffolds a config that runs. SQL Server, MongoDB, a single table
and a non-publicPostgreSQL schema got a stream with no baseline under a base-and-buffer load
block, so the firstrivet compactrefused; Oracle got aload:block every command refuses.
Every CDC scaffold now takes a baseline, and none carries a load block the loader refuses. -
ClickHouse loads accept
partition:.partition: { column, granularity }maps to
PARTITION BYon the full-load table and both change logs. A part ClickHouse pulls is
footer-checked for the DateTime64 range before insert (it used to be stored clamped),
statements that are safe to repeat are retried up to five times, andload.ca_filesets the
CA bundle. -
Oracle batch: override decoders, case refusal at
check, non-Unicode national charsets.
Row decoders for int2, date, timestamp_ns, uuid, float8 and text overrides;rivet check
refuses a wrong-casecolumns:key as the run does; NVARCHAR2, NCHAR and NCLOB round-trip
byte-exact on a WE8ISO8859P1 database. -
The CDC sink builds exactly the planned column type. A column the CDC builder cannot build
is planned as server text and labelled so in the Parquet metadata; aDate32column refuses a
time of day; integer and float overrides accept decimal text through one parser. -
New:
rivet state vacuum. It shrinks a SQLite state database (VACUUM plus a truncating
checkpoint) and prints the size before and after; on a PostgreSQL state it does nothing.
Measured on a pilot state copy: 46.8 MB to 18.8 MB, with 32 concurrent runs on the same file
and no failed run. -
State schema v32. A run's load spec is stored once per distinct version instead of once
per run (a pilot state held 9,029 run rows for 154 distinct specs). The migration runs on the
first command; binaries older than this release refuse a v32 state, so upgrade every host
that shares a state together. The PostgreSQL migration replays safely over a partly migrated
schema, and the PostgreSQL state reads every integer width (a resumed parallel keyset run
could panic on an INT4). -
Dependencies.
mysql28.0.3 (28.0.0 was yanked),mongodb3.9.1 (a change-stream loop
and two CVEs),tiberius0.13 (SQL Serversql_variantand CLR types read as text).
cargo auditis clean. -
MySQL CDC
compactreads the base only in its MERGE.rivet compactmerges only the
partitions its changes name. To catch a row whose partition value changed, it used to look
every updated key up in the base on every cycle; a pilot paid 2 x 2.2 GiB per large table
per cycle for that, mostly for keys inserted and updated in the same cycle, and no row
there ever moved. Now the partitions come from the buffer alone. An UPDATE that moves a row
between two partitions of a base-and-buffer table is written byrivet runas a delete of
the old row and an insert of the new one, with a warning naming both values: the delete
carries the old partition into the buffer, so the MERGE reads it and the base keeps one row,
in its new partition. A change to or from NULL is written as an update, as before. -
Breaking: MySQL CDC refuses an UPDATE without the row's previous value on a partitioned
base-and-buffer table. Without it (binlog_row_imagenot FULL) the old partition is
unknown, so the run fails withRIVET_SOURCE_CDC_PREREQUISITEnaming
SET PERSIST binlog_row_image = FULL;, before any part is written. Other engines, and the
change-log layout, are unchanged. -
rivet compactno longer fails on a table that was never loaded. A source table that was
empty from the start loads nothing, so it has neither a base nor a__changesbuffer in the
warehouse;compactstill ranALTER TABLEon the base before looking for the buffer, and
BigQuery answeredNot found: Tableon every cycle, failing the cycle's exit status for the
other tables too (five of 153 tables in a pilot run). The buffer is now checked first, and such a
table is theCOMPACT SKIPit always should have been. -
A full load of an emptied source empties the warehouse table again. Since the change below,
amode: fullrun that exported 0 rows was consumed as "up to date" and the warehouse kept rows
that no longer exist in the source. That run now reaches the full load's replace, which leaves
the table empty (BigQuery, ClickHouse; Snowflake refuses loudly, as before). Empty incremental and
CDC runs are still consumed as a 0-row success. -
rivet loadconsumes the extraction runs that produced no files. An idle CDC cycle (no
changes) writes a manifest with no parts; the load skipped it but never recorded it, so every
later load re-read every such manifest again: a pilot with 110 idle tables read ~50 manifests per
table per cycle, one more each cycle. Such runs are now recorded as a 0-row success, and the
next load reads only the runs since. Measured on the stand over three idle cycles: 1, 2, 3
manifests re-read before; 1, 1, 1 after. -
Breaking: MySQL CDC refuses
binlog_row_metadata = MINIMAL. It used to warn and map binlog
values by position, so a column reorder orALTERdelivered them under the wrong names (source
(1, b='BBB', a='AAA')arrived asb='AAA', a='BBB'). A server that is not at FULL, or that
predates the variable (MySQL before 8.0.1), now fails the run at open with
RIVET_SOURCE_CDC_PREREQUISITE, before a first run pins its anchor. The message names
SET PERSIST binlog_row_metadata = FULL;. A rows event written under MINIMAL is refused when read,
even after the server is switched to FULL, withRIVET_SOURCE_CDC_UNDECODABLEnaming the table
and the binlog position. No part is written and the checkpoint does not move. Recovery: delete the
checkpoint so the next run anchors afresh first, then re-snapshot.rivet doctorfails the "CDC
binlog server config" check for the same settings. The CDC sink no longer maps any nameless image
by position. -
rivet runapplies the type policy before it reads data. A column whose mapping islossy
orunsupportedused to be reported only byrivet check --type-report, and the run wrote it
without a word. Batch and CDC runs, includingrivet cdc --output, now log one warning per
such column before the first row is read. The warning has the wordingcheckprints. The new
rivet run --strictrefuses the run instead, before any part or checkpoint is written, and
names every such column withRIVET_TYPE_UNSAFE_MAPPING(exit 1).rivet check --type-report --strictnow refuses with the same code, socheckandrunagree on the verdict.
v0.30.0
-
Breaking: a source URL whose scheme names another engine is refused.
source.typeis the
engine rivet anchors and types the stream as, and the URL's scheme was never compared with it:
type: mysqlwith apostgresql://URL got as far as the driver. Config load now refuses the
pair withRIVET_CONFIG_SOURCE_URL_SCHEME_MISMATCH(exit 1). A scheme rivet does not know
(jdbc:…) is still left to the driver. -
Breaking: a
cursor_columnorcolumns:key that matches a result column only when case is
ignored is refused. MySQL and SQL Server (case-insensitive collations) run
cursor_column: updatedatagainst a column the result set namesupdatedAt. rivet matched the
name exactly, found nothing, recorded no cursor, and every run re-exported the whole table
with exit 0. Acolumns:override spelled that way was skipped just as silently. Both are now
refused when the schema arrives, naming the exact spelling (RIVET_CONFIG_CURSOR_COLUMN_CASE,
RIVET_CONFIG_COLUMN_OVERRIDE_CASE, exit 1). This applies on every engine. Acolumns:key that
names no column in any case still passes, because multi-table override maps rely on that. -
Breaking: a MySQL keyset key under a
uuidcolumn override is refused. The override reads
BINARY(16)as a 16-byte UUID, but the page cursor was rendered as hyphenated hex text, which
MySQL compares against the raw bytes. Pages skipped and repeated rows while the run reported
success. An explicit or auto-selected keyset key with that override now fails at plan time with
RIVET_CONFIG_KEYSET_KEY_UUID_OVERRIDE(exit 1). PostgreSQL casts the literal touuidand is
unaffected. -
Breaking: a
columns:override whose type the column's value cannot be read as is refused,
not written as NULL. On PostgreSQL and SQL Server, adecimal(p,s)override on an integer
column produced a column that was entirely NULL, and on SQL Server auuidoverride on
nvarchar(36)did the same. The exit was 0 and both value checksums agreed on the NULL. Integers
under adecimaloverride are now scaled exactly, and SQL Serveruuidtext is parsed. Any
other mismatch fails naming the column (RIVET_SOURCE_OVERRIDE_WIRE_MISMATCH, exit 1). On
MySQL, auuidoverride now refuses a value it cannot parse instead of nulling it. -
Breaking: a PostgreSQL URL with a remote
host=orhostaddr=query parameter needs TLS. The
plaintext gate looked only at the URL's authority, while the driver also dials the query's
hostaddr=(in preference to the authority) andhost=. So
postgresql://u:p@localhost/db?hostaddr=203.0.113.5passed as loopback and sent credentials and
rows to the remote address in cleartext. Everyhost=/hostaddr=value must now be loopback
or a unix-socket path, or the connection needs atls:block. -
Breaking: MySQL
TIMEoutside 00:00–24:00 is refused, not written as garbage. A table
holding durations that exported with exit 0 before now fails every run. A MySQLTIMEis a
duration of up to ±838:59:59, and a ParquetTIMEholds one day.838:59:59read back as
'1$:59:59', a negative value made the file unreadable, and unparseable text became NULL, all
with exit 0. Such a value now fails withRIVET_SOURCE_VALUE_UNREPRESENTABLE(exit 5), naming a
cast that keeps it (TIME_TO_SEC(col),CAST(col AS CHAR)). -
Oracle Database as a batch source (preview).
source.type: oraclewith an
oracle://user:password@host:1521/SERVICEURL runsfull,incremental, range- and
keyset-chunked(alsoparallel > 1),time_windowandpartition_byexports. It uses
Oracle's pure-Rust thin driver, so no Oracle client install is needed.rivet initscaffolds
from the schema's catalog and never picks a key or cursor that the planner or the run would
refuse. rivet pins its session to UTC with ISO NLS formats andNLS_SORT = NLS_COMP = BINARY.
ATIMESTAMP(7..9)column is read at microseconds and refused as an incremental cursor
(RIVET_SOURCE_CURSOR_FINER_THAN_MICROSECOND). Known limits (docs/reference/oracle.md): the
driver is a pinned beta behind the default-onoraclecargo feature; TLS verifies only against
the public CA bundle built into the driver (notls.ca_file, no wallet); only 23ai/26ai Free
are tested. -
SQL Server keyset and range exports no longer skip or repeat rows under a non-default
collation or date format. A keyset page bound was sent asN'…', which avarcharkey under
a SQL collation compares by different rules than its ownORDER BY: 67 of 180 keys lost. A
timestamp range bound inYYYY-MM-DD HH:MM:SSform is read day-first under a British login
(DATEFORMAT dmy), and a window exported 39 rows for 24. An ASCII bound is now a plain'…'
literal and timestamps are sent in theTform, which does not depend onDATEFORMAT. -
Timestamp bounds keep their fraction and offset on PostgreSQL and SQL Server. The probe that
reads a range or keyset bound dropped the fraction on both engines, and the+00offset of a
PostgreSQLtimestamptz. Under a session in another zone the bound then named a different
instant (measured on PostgreSQL in Asia/Tokyo: 999 of 1000 rows, then 1914 rows for 1499 ids).
A SQL ServerDATETIMEbound was cut instead of rounded to its 1/300 s tick, and the newest row
stayed behind (199 of 200). -
Every PostgreSQL connection rivet opens runs in UTC with ISO formats. Only the export
transaction was pinned (SET LOCAL TimeZone = 'UTC'). The sampler, the bound probes,check
andinitran in the role's zone andDateStyle, so one run used two zones (the cause of the
Tokyo loss above). Each connection now setsTimeZone,DateStyle,IntervalStyleand
bytea_outputat connect, except behind a transaction-mode pooler, where a sessionSETwould
leak to other clients. Stored values do not move. Calendar arithmetic in your ownquery:
(current_date,ts::dateon atimestamptz) now counts UTC days everywhere, as it already
did inside the export. -
A keyset crash anchor is resumed only by the runner shape that wrote it. After a crash,
changingparallel:made the other runner continue the anchor: 1501 of 2000 rows
(sequential → parallel), or 2600 for 2000 (parallel → sequential). A plain keyset run now
warns and starts a fresh pass. Akeyset_incrementalrun switched toparallel:after a
sequential crash is refused instead, because the sequential cursor had already moved past
pages no manifest names (RIVET_STATE_KEYSET_SEQUENTIAL_ANCHOR_UNFINISHED, exit 5). Re-run
once withparallel: 1. -
--splitunits no longer reuse a stale checkpoint. A fresh--splitre-run re-sampled its
windows but kept each unit's keyset anchor and crashed chunk run, so units resumed against moved
windows: 525000 rows over 450000 ids, or a plan-fingerprint refusal on every run. A unit that
runs over a new window now drops both.rivet state reset-chunks(and--stuck-checkpoints)
now also accept the<export>#<n>and<parent>__<value>names that the chunk refusals print
for split units andpartition_bychildren. -
A chunked resume re-exports a chunk whose part it cannot find. Resuming into a prefix that
held a foreign or stale manifest declared every recorded part without looking, so a part deleted
between attempts was named in aSuccessmanifest. A chunk whose part record was never written
was skipped as completed and left out. Every resume now checks the destination and re-exports
what it cannot account for. -
A chunk that fails and is retried within one run is counted once. Its failed attempt's
parts stayed in theSuccessmanifest beside the retry's, so the chunk's leading rows appeared
twice andtotal_rowscounted them twice. Those parts are now pruned before the manifest is
written. In the parallel checkpoint runner, a chunk that failed and then completed on retry
still failed the run; now the chunk ledger alone decides. When chunks remain unfinished, the
error names each one's last error. -
A MySQL
FLOATkeyset key or cursor no longer re-reads its boundary row. The saved value
was the shortestf32text (0.1), which MySQL compares as aDOUBLEbelow the row's own
0.10000000149011612, so every page boundary and every incremental run re-read the last row.
The exact widened value is saved now. -
A
?or#in a URL password no longer merges different servers into one cursor scope. The
state key cut the URL's query before removing the credentials, so
postgresql://app:k?9@h1/aand…@h2/bboth keyed aspostgres://app:k. The two shared one
incremental cursor and crash anchor, and a prefix of the password was stored in the state DB.
Keys of well-formed URLs are unchanged, so no existing cursor moves. The same password shapes,
and a?password=query parameter, are now redacted fromplan.jsontoo. -
PostgreSQL CDC decodes
DOMAINcolumns instead of writing NULL.test_decodinglabels a
domain cell with the domain's name, so every integer, date or boolean domain column was NULL in
the change log. The reader now resolves each domain to its base type at open (nested domains
included). -
PostgreSQL CDC refuses a value it cannot decode instead of writing NULL. A BC date, a
time '24:00:00'or an unparseable value intest_decodingoutput became NULL with exit 0.
It is now refused, likeinfinityalready was. The reader also pinsTimeZone = 'UTC'. Under a
named server zone, an instant before that zone adopted a whole-minute offset rendered with a
seconds offset (+00:53:28). That was refused on every later run as bad source data. -
SQL Server CDC refuses a captured value it has no decoder for. An
xmlorsql_variant
cell (and any other type without a decoder) was written as NULL with exit 0. It now fails with
`RIVET_SOURCE_CDC_CELL_UNSUPPORTED...
v0.29.0
- A multi-table CDC cycle to GCS is 10–13× faster (60 tables, 330k changes,
measured against 0.28.0 on the same binlog span: 348 s → 35 s on a SQLite ledger,
376 s → 29 s on Postgres; an idle cycle 115 s → 10 s). The time was not data:
every roll uploaded each table's part, then rewrote EVERY table's run-unique
manifest, one PUT at a time. A roll now uploads all tables' parts and writes the
manifests concurrently (at most 16 at once), and rewrites a table's manifest only
when it gained a part. Parts and manifests are still durable before the
checkpoint and the ack. Peak RSS rises with the concurrency (+31% on the 60-table
stand);rollover_memory_mbbounds the change buffer, not the process. - MySQL CDC no longer fails when a flush outlasts the server's
net_write_timeout(60 s by default): the server dropped the binlog dump while
rivet was uploading and the run ended inCodecError { bytes remaining on stream }
(nothing was lost; the cycle was wasted). The binlog connection now sets a session
net_write_timeoutof 3600 s. --parallel-exportsruns at most 16 exports at once (it started one thread
per export: a 150-table config opened 150 source and 150 state connections). The
same pool now takes a lone CDC export's pending baseline snapshots, at most 16 at
once (a 60-table first cycle: 235 s → 48 s); without the flag they stay one at a
time. A panicking export or snapshot is reported as that item's failure and the
others finish (release builds;release-minaborts).- GCS: the ADC access token is minted once per identity per process, not once per
destination (a multi-table CDC run opened one per table, ~0.4 s each). - Internals behind the same behaviour (refactor-3): the commit ledger is the only
writer of a run's counters, and oneFanInowns every parallel runner's tail
(keyset, chunked, Mongo, parallel checkpoint). Fixes a resume after a failed chunked
run under-reporting rows (50 of 150) and a Mongo worker panic that lost a durable
part.rivet run --summary-outputinto a missing directory,applystopping at the
first plan rejection, and a leaked progress channel after a panic are fixed on the way. - A local multi-table CDC stream refuses tables that differ only by letter case
(Orders/orders): on a case-insensitive filesystem (macOS, Windows) their parts
overwrote each other and the lost rows were permanent whilerivet validatepassed.
Cloud prefixes are case-sensitive and keep both. - README rewritten around what rivet does: a diagram (sources → rivet → storage →
warehouse, light and dark), six claims, and three quickstarts — batch, CDC, and the
fullrun→load→compactcycle — each run as printed against a live stand.
v0.28.0
-
Behaviour changes an operator upgrading from 0.27 will see.
- A
mode: fullload whose newest run exported 0 rows (the source was emptied)
now EMPTIES the warehouse table (a freeTRUNCATE, or aCREATEwhen there is
none); 0.27 printedLOAD SKIP: up to dateand kept serving the deleted rows.
Runs that declare rows but whose Parquet is gone refuse before any write, as
before. Snowflake refuses the empty case by name (TRUNCATEby hand). - A MongoDB export of a collection that does not exist now fails (exit 1);
0.27 reported success with 0 rows.rivet init --table a.bon MongoDB now
scaffolds the collectiona.b, notb. rivet initno longer picks aDATE/smalldatetimecolumn, or one column
of a composite primary key, as the incremental cursor: a strict>on them
skips rows inserted later in the watermark's day/minute or under the same key
prefix. Existing configs are not rewritten — check anycursor_column:that
names such a column.rivet load/rivet compactrun 16 tables at once by default (--pool 1
for the old sequential pass), so their output interleaves across tables.- BigQuery: a UUID column (PostgreSQL
uuid, SQL Serveruniqueidentifier)
now lands asBYTES— the 16 bytes, as BigQuery autoloads them (it has no
UUID type); render text in a view withTO_HEX(col). 0.27 declared it
STRINGand stored the same 16 bytes as unreadable text. Afullload
replaces the column type by itself. A base-and-buffer base table loaded by
0.27 must be migrated once before the nextrivet compact(which otherwise
fails loudly, keeping the buffer), with its own partition and clustering:
CREATE OR REPLACE TABLE t PARTITION BY … CLUSTER BY … AS SELECT * REPLACE (CAST(uid AS BYTES) AS uid) FROM t— lossless, the bytes are intact. - SQL Server: a column declared
columns: <col>: stringwhose server type is not
text used to export as NULL in every row with status success; it now fails
naming the column (drop the override: rivet reads these types natively). rivet initon MongoDB leaves views and time-series collections out (their
scans cannot keep a cursor open) and names them.
- A
-
rivet check/rivet planread the relationrivet initwrites. init
quotes the table in itsquery:form ([dbo].[t],`t`,"public"."t"),
and the diagnostics only understood bare names — so every such export lost its
catalog row estimate and its index probe: a small table readDEGRADED, and an
indexed chunk columnNo index detected. The measured row count they prefer
over the catalog is now taken only from a run of the same engine into the same
destination: on a shared state store a same-named export of another source
(another engine, another prefix) used to supply the figure. -
rivet initrecords every export's primary key, even when one export cannot
be given a cursor. Recording validated the whole generated config first, so a
single cursor-less table (common under--mode incremental) left EVERY export
without the keyload.pk: autoresolves against, and the load then refused three
steps from the cause. Keys are now read per export. Init also names every export
it could not give acursor_column:/time_column:in one line (with
--excludeoffered only where it applies), and stops printing "Next steps" that
cannot run on the file it just wrote. -
rivet init --modewrites only what the table can do. A forcedchunkedon
a table with no integer column and no keysettable primary key wrote
chunk_column: idwhether or notidexisted; it is now written asmode: full
and named. Soft-delete stamps (deleted_atand friends, NULL on every live row)
are never a cursor, time column or partition key. An incremental cursor that does
not move on UPDATE (id,created_at) is marked in the YAML and every such
export is listed, since updated rows are never re-exported. The strategy recorded
in the state DB now agrees with the YAML for tables that cannot use thetable:
form (a mixed-case PostgreSQL name). -
One BigQuery access token per identity per run, not per table. Concurrent
first callers used to mint one token each, and every table built its own token
source; a 60-table compaction hit an ADC timeout. Minting is single-flight and the
source is shared per identity (keyed so twogcloudusers never share one). -
A column named with Cyrillic look-alike letters loads under its Latin name.
сomment(Cyrillic с) used to refuse the whole BigQuery load. The load now folds
such names, warns once per table with the statement to run at the source (per
engine, including the MySQL 5.7 form and the SQL Server CDC caveat, and the
export's own config keys that must change with it), and loads through a staging
table under the file's name, renamed and cloned (base) or appended with a free
copy job (buffer): BigQuery matches Parquet columns by name, so declaring the new
name alone loads the column NULL.rivet check --target bigquerygrades such a
columnwarn. Refused, loudly: a fold that collides with another column, a
renamed column that partitions or is written intocluster_by, a non-BigQuery
target, and pending runs that spell the column both ways (the older run's files
would load NULL). -
rivet loadandrivet compactcan work the config's tables in parallel.
--pool Nruns the per-table loop on N worker threads instead of one after
another: every freeing worker takes the next table, so one slow table no longer
holds up the ones queued behind it. The default is now 16, the ceiling, so
an operator who passes nothing gets the parallel pass;--pool 1restores the
strictly sequential one, andNis capped at the number of tables either way.
This widens what a plainrivet loaddoes on upgrade: up to 16 ledger
connections instead of one, and up to 16 concurrent warehouse loads, which
count against Postgresmax_connectionsand BigQuery's 100 concurrent
interactive queries per PROJECT. Measured before defaulting it: 16 workers over
16 tables on the DEFAULT (SQLite) ledger lost no table and surfaced no lock
error — SQLite's single writer is not a ceiling for this workload, because a
load reads each spec at the front and writes one record at the end, and the
per-table lease is aflocksidecar rather than a SQLite write. What was NOT
measured is several rivet processes pooling at once against one backend.Per-table FAULT ISOLATION is preserved (a failing table isolates to itself and
the rest keep loading) and so is the per-table lease, sorivet loadand
rivet compactstill refuse a table the other holds. Each worker opens its own state handle by reconnecting to the
backend the parent already resolved (open_at_ref) — once per WORKER, not per
table — and a worker that cannot reconnect says so and retires, rather than
passing for absent-by-design.The scheduling lives in a generic executor (
src/load/pool.rs) rather than in
the orchestrator, becauserun_loads/run_compactsare live-only bodies that
nothing offline grades; the executor's contracts — every table runs exactly
once, a failure isolates,initruns once per worker, results come back in
CONFIG order — are unit-tested against a fake closure, with no warehouse and no
credentials. Folding results in config order is a fix in its own right:
aggregate_load_failurespicks its representative withmax_by_key, which
returns the LAST maximum, so a completion-ordered fold would have reported a
different error out of a tie on each run of the same failing config. -
A pool worker that cannot reopen the state ledger takes no table. It used to
stay in the queue and refuse every table it took in microseconds, while the
healthy workers sat in BigQuery jobs, so one refused connection (a Postgres state
DB nearmax_connections, the default--pool 16) refused most of a large cycle.
It now retires before taking anything and the other workers drain the queue; if
every worker retires, each table still gets its own failure, and that message no
longer calls a state-backend outage a bug. -
A BigQuery job that never finishes now ends the wait instead of the run.
await_jobpolledfor attempt in 0..with no ceiling, nojobTimeoutMson the
job and nojobs.cancelanywhere, so a job BigQuery never completed hung rivet
with no diagnostic and no exit — the only bound in the file was the 120 s
per-REQUEST timeout, which says nothing about the job. The poll now carries a
budget (four hours, deliberately generous: a deadline that fires on healthy work
is worse than none) and gives up with a TYPED marker,load::JobWaitTimeout,
whichclassify_errorreads as PERMANENT — retrying a wait that already expired
only spends the budget twice. The message says what an operator actually needs to
know: the job may still be RUNNING in BigQuery, so look there before re-running,
because an append mode that re-consumes the same runs would double them. The
budget is a parameter rather than a constant read inline, so the deadline is
testable in milliseconds; the test drives a real socket that answersRUNNING
for ever, since the crash hook kills rather than stalls and cannot express this. -
Two retry decisions in the BigQuery transport were ungraded, and mutation
testing found them.insert_query_jobandget_responseboth guarded their
backoff with an inlineattempt > 0, and both> → <(every retry fires
IMMEDIATELY — no backoff at all, which on a 429 is exactly wrong) and> → >=
(a wastedPOLL_MINbefore the first request of every call) survived the whole
suite at BOTH sites.table_metadata's inline== Some("TABLE")survived too;
inverted, it makes every real table read as absent, which turns ...
v0.27.0
-
The cheat sheet was driven end to end, and corrected where it and the product
disagreed. Three scenarios on GENERATED configs against a live stand — batch +
compact, CDC, and a multi-tableplan/apply/--pool/--split— produced
eighteen divergences. The load-bearing ones are now fixed indocs/cheat-sheet.md:
§3's mode table promised thatincremental+layout: base_bufferflags deletes in
__is_deleted, but a cursor read never sees a DELETE, so the warehouse keeps the row
for ever (measured: 250,002 rows against a source of 250,001 — the surplus was the
deleted id); §2.1's sealed-replay line could not work as printed (-orequires
--format json);--pool 4on a generated config is the SLOWEST of the four paths,
becauserivet initwrites noparallel_safe:(measured on one 1.26M-row set: 66s
waves, 74s bare--pool, 57s after--annotate-waves, 42s with--split); §4.5's
WHERE NOT __is_deletederrors on anincrementalbase, where the flag does not
exist; §4.4's independent oracle cannot run after a load, because the generated
cleanup_source: truedeletes the very parts it reads; re-runningapplywithout
--resumeorphans the prior run's parts; PostgreSQL'sREPLICA IDENTITYwas absent
from the CDC prerequisites though rivet warns about it on every run; and
rivet state files/rivet metricscannot reach--splitsub-units (rivet state runscan). The split itself was verified correct by the independent DuckDB oracle —
39 fingerprint fields over 1,000,749 rows across four sub-exports sharing one prefix. -
rivet initexplained a decision it had not taken. A backfill recipe's mode comes
fromrecipe_mode, which pages whenever a usable key exists and never reads the row
count — but the inline rationale printedsuggest_mode's reason regardless, so a
200-row recipe was scaffoldedchunkedunder "~200 rows ≥ 100K threshold". The
threshold is now claimed only when it was actually crossed, mirroring what thefull
arm already did for exactly this class of false diagnostic. The generated header also
stopped namingrivet.yamlwhatever-osaid — it is built without the output path,
so it now points at<this file>instead of a config that may not exist. -
rivet validateblamed a missing manifest on rivet's age. The verdict read
legacy_run (no manifest at destination — pre-0.7.0 prefix), but the check establishes
only that no manifest is at the prefix — never when, or whether, anything was written
there. A prefix that was never written reaches the same verdict, which is what an
operator sees whenevervalidateruns from a different working directory than the
export did:destination.pathis the one relative path rivet resolves against the
CURRENT directory rather than the config's, andrivet initgenerates one. The line now
names both causes and asserts neither. Thelegacy_runlabel ADR-0012 M6 requires, and
the exit code, are unchanged. -
The LAST part of every export carries its partition count too. The footer note
the loader bounds a part by (rivet.partition_buckets) was written only when the
writer ROTATED; every runner closes its final part itself — and a keyset page is
one such part — through a barefinish(), so the one part every export has went out
unnoted and the loader fell back to the span. One close path now
(ExportSink::finish_writer), read back from the Parquet the sink wrote. -
The compaction reads a TIMESTAMP partition column as a UTC day.
DATE(col)with
no zone follows the project'sdefault_time_zone, while the bounds it fed are
rendered+00and the table's partitions ARE UTC days — under any other default the
two disagreed by up to a day at both ends (a skipped winner, a re-inserted key), and
the day list stopped pruning.DATE(col, 'UTC')wherever a TIMESTAMP is dated; a
DATE or DATETIME takes no zone. -
Docs and ledgers caught up with the layout.
layout/deleted_flag/
rivet compactare in the cheat sheet, the CDC full-cycle walkthrough (which
described a view where the config it shows builds a base and a buffer), the init
reference (the warehouse flags) and ADR-0034 (D7); the load-mode matrix no longer
calls the incremental base-and-buffer cyclena(it is proven by the generated-config
chain), the config-validation matrix carries the BigQuery-only refusal, and the
runner-coverage matrix carries the footer note as five honest gaps.
The cheat sheet also gained the step that was missing from it entirely: it explained
theload:block but never therivet initinvocation that WRITES one, so a reader
following it had to hand-add the block — against the rule that every config is
generated. A new §1.3 shows the three warehouse flags on both scaffolds (single table
and whole-database CDC), therun → load → compactcycle they enable from one file,
what in the generated block is a catalog guess worth reviewing, and why
--gcs-bucketis required with them. -
A
9999-12-31partition value no longer wedges compaction. On an hour / month /
year key the MERGE's upper bound washi + 1 day, which for the SCD "end of time"
sentinel renderedDATE '+10000-01-01'— past the warehouse's range, a hard error on
everyrivet compactof the table while the buffer grew. The last representable day
(andi64::MAXfor a range key) has no successor: the bound is simply open above. -
__is_deletedis a reserved source column, like__op/__pos/__seq: a
captured table carrying one is refused with the remedy. The compaction decides that
column and was silently overwriting the source's values, and its mere presence flipped
the tombstone arm on regardless ofdeleted_flag:. -
rivet initon SQL Server:DATA_TYPE = 'timestamp'isrowversion(an 8-byte
binary counter), not a time — read back under its real name, so it is neither a cursor
candidate (it scored above the integer PK and never advanced) nor a partition guess (a
BYTEScolumn BigQuery cannot partition by). Stamp names are folded (ModifiedDate,
CreatedDate,LAST_UPDATED…) so PascalCase schemas get the mutation stamp as cursor
and the creation stamp as partition, as snake_case ones do. A mixed-case PostgreSQL
table scaffolds a lowercase slot name (rivet_orders), the only spelling the server
accepts. The comment wrapper leaves a#inside`or[ ]identifier quotes
alone (a long generatedquery:lost its trailing columns to a "comment"). -
Two CDC checkpoints that differ only by case are one file on macOS / Windows — the
collision guard folds case, since a config written on Linux is run elsewhere. -
A whole-table pass over a base whose buffer still holds rows is refused. Under
layout: base_buffera re-baseline (a re-snapshot after a gap, a fresh whole-table
incremental run) replaced the base but left<table>__changesas it was, so the next
rivet compactmerged OLDER values over the new base — restoring exactly the pre-gap
rows the re-snapshot existed to fix,COMPACT OK. The load now refuses by name with
the two recoveries (compact first if the buffer belongs to the current base, drop it
if the pass re-snapshots past it); nothing is consumed. -
The baseline leg asks the load's own layout resolver.
initial: snapshotunder a
writtenload.layout: base_bufferderived its layout frombackfill:presence alone,
wrote the snapshot without__is_deleted, andLOAD DATAfilled every row's flag
with NULL — the whole backfill invisible toWHERE NOT __is_deleted. -
layout: base_bufferneeds a warehouse that compacts.rivet compactis
BigQuery-only; on Snowflake a stream with abackfill:derived a base that froze at
the backfill while its buffer grew for ever, with no view and every load prescribing
a command that could only fail. A writtenbase_bufferis refused at config load
outside BigQuery, and thebackfill:derivation yields the changelog and its view. -
Messages: init's next steps prescribe
rivet compactonly for a scaffold that
declares a base and a buffer (afullscaffold's load OVERWRITES its table); the
COMPACT SKIPreason names the levers that decide the layout (cdc.backfill:,
load.layout:), notinitial: snapshot, which decides nothing; the pinned-run
refusal names the command that raised it (compact, notload); the lease refusal
names both holders; "drop the buffer" says it discards every change since the last
compaction; "N of M failed" counts the tables compaction attempted. -
rivet compactmatches a base row under its OLD partition value. The MERGE
pruned the base to the days (or the range) found in the BUFFER, so a key whose
partition column changed between two cycles — a corrected business date, a row
partitioned by itsupdated_at— sat in the base under a day the buffer never named:
NOT MATCHED, re-INSERTed, two live rows for one key,COMPACT OK. Found by the
generated-config chain (init partitions bycreated_at; the delta moved a row's
date), and the shape a_rivet_exported_atpartition hits on EVERY change. The days
and the range are now collected over the buffer AND the base's rows of the buffer's
keys (one semi-join, the key and partition columns only), the base side is bounded by
the whole touched set plus its NULL partition — never by one window of the winners —
and a load-date key merges unbounded, as its doc always said. A range value outside
the key's[start, end)merges once, unbounded, instead of windowing up to it. -
A delete DELETES when the base carries no
__is_deleted. With
deleted_flag: falseon a stream the MERGE had no tombstone arm, so a
__op = 'delete'winner fell through to the upsert and overwrote the live row with
the delete's key-only before-image — NULLs in every non-key co...
v0.26.0
load: { partition }— the warehouse table's partitioning, per table
(ADR-0034).partition: { column: ts, granularity: day, expiration_days: 400, require_filter: true }partitions the table the load writes by a DATE /
DATETIME / TIMESTAMP column athour/day/month/year;{ range: { column, start, end, interval } }buckets an INT64 column;{ ingestion: day }partitions by load time;
none(the default) leaves the table flat. Rivet writes the BigQuery expression from the
column typerivet runrecorded (TIMESTAMP_TRUNC/DATETIME_TRUNC/DATE_TRUNC/
RANGE_BUCKET/_PARTITIONDATE) — the config never carries SQL — and refuses a form the
type cannot take (houron a DATE,rangeon a TIMESTAMP). The change log inherits the
table's partition through the rename, or takes it when rivet creates the log; it never
requires a partition filter and never expires load-date partitions. A full load onto its own
table with a different partition is refused naming both (the shape is read fromtables.get
and compared as metadata); a changedexpiration_days/require_filteris applied in
place. Before any job runs, the load reads the partition column's range from the Parquet
footers and refuses a span over BigQuery's 4,000 partitions per job, naming the granularity
that fits. On Snowflakepartitionbecomes a leadingDATE_TRUNCclustering expression
(range/ingestion/ the options are refused). The per-job Hive batching of the old
export-partition_byloader path — unreachable sincepartition_byandload:exclude
each other — is gone.- A change log follows a written
partition:/cluster_by:;rivet load --rebuild-changelogre-partitions it. With the defaults (partitionabsent,
cluster_by: auto)<table>__changeskeeps whatever shape it has. Once the config writes
cluster_by, a log clustered otherwise is re-clustered in place (new rows land clustered,
BigQuery re-clusters the rest in the background). Once it writespartition, a log
partitioned otherwise refuses the load naming both keys and the bytes a rebuild reads;
rivet load --rebuild-changelogcopies the log in the declared shape with a billed query,
checks the row count and swaps it in — never as a side effect of a scheduled load. A load
that finds the remains of an interrupted swap (<table>__changes__old/__rebuild) stops
before touching anything and says which to keep. A changedexpiration_daysis altered in
place on the log as on the table. - BigQuery load jobs are inserted under a client job id. A
jobs.insertthat times
out or is refused transiently is sent again with the same id, so the statement never runs
twice: a repeat of an insert that did land answers409 Already Exists, and the job is
fetched and polled instead — in the dataset's location (datasets.get), since a single-
region job cannot be fetched by id alone. Previously such a timeout failed the load outright. - A refused load never makes the table rivet's own. A load that stops before touching
the warehouse — a table rivet did not load, a shape that differs, a view under a full pass,
a partition over budget, an interrupted rebuild's leftovers — is journaled asrefused
(rivet state loads), and only a load that reached the write (failedorsuccess)
counts for ownership. Previously the refusal'sfailedrow made the foreign table rivet's
own, and the nextrivet load(a scheduler retry) overwrote or adopted it. - An incremental load never overwrites a table that already exists. A run with no
cursor to resume from holds the whole table (the first run aftermode: full, after
state reset, or on a lost state DB). It lands as a plain<table>only when that name
is free; when a table is already there it is renamed into<table>__changesfirst and
the run is appended to it, so rows the source has since deleted stay in the log. The
overlap between the kept baseline and the re-read pass is at-least-once and the
current-state view dedups it by key. Previously such a run replaced the table. - An incremental whole-table run onto its own view joins the change log. After
state reset, or on a stateless cycle that re-selects the first run, the run is appended
to<table>__changesat least once (the view keeps the latest row per key) instead of
refusing "a full pass cannot be appended" on every later cycle. pk: autoforgets a key the export no longer reads. A run that finds no source key
(aquery:export, a relation without one) clears the key an earlier run recorded, so the
dedup view never partitions by a column of a table the export moved away from; a key
rivet initrecorded for aquery:scaffold is kept. Apkcolumn the export does not
project is refused at plan time, not after the append.- A 0.25.0 incremental cursor is attributed to its column.
rivet staterows written
before v26 carry no cursor identity; a keyset run's was recovered from its metrics, an
incremental run's was not — so a changedcursor_columncompared the new column against
the old value (on MySQL: exit 0, zero rows, every run). The incremental run's key
descriptor (export_metrics.key_descriptor_json, v18+) now attributes it, and the switch
is refused naming both columns andstate reset(MT6). - An unwritten
cluster_byfollows the table. A full load onto its own table with
cluster_byleft atautokeeps the clustering the table has — a table an earlier
release created unclustered is overwritten as it is, not refused after the upgrade. A
writtencluster_bystill conflicts. --rebuild-changelogcarries the log's properties. The copy takes the description,
labels, friendly name, table expiry and KMS key of the log; a log with column policy tags
or row access policies is refused, since a copy carries neither.- Partition budget and expiry corner cases. A part without statistics no longer hides
the span the other parts prove; an INT32-stored range column (PostgreSQLinteger, MySQL
int) is budgeted like INT64;range+expiration_daysis refused when the config is
read (BigQuery has no expiry for integer ranges); a change log partitioned on
_rivet_exported_at— one value per run — never expires, like a load-date partition. - A wrong host or port in
url:is named as such. Every engine reported the driver's
text ("failed to lookup address information…", or MongoDB's whole topology dump); a
connection that fails before the server answers now sayscannot resolve host <host> — check the host name in url:,nothing is listening on <host>:<port>, orno answer from <host>:<port>, with the driver's text after it. MySQL dials at open, so the message comes
fromrivet init/rivet runstart, not from the first query. rivet init --include a b c— several globs after one--include/--exclude
(the repeated form still works).load: { pk, cluster_by }default to the source primary key (ADR-0034).
Both takeauto(the default),none, or a column list.pk: autois the primary key
rivet runrecorded, so an incremental / CDC load no longer needspk:for a table with
one;cluster_by: autoclusters the table the load writes (the full-load table or
<table>__changes) on that key, skipping columns BigQuery cannot cluster and keeping at
most four. An explicitcluster_bycolumn BigQuery cannot hold is refused at plan time.
<table>__changesis now clustered oncluster_byrather than always onpk.
rivet init -orecords each scaffolded export's key too, so aquery:export — which
rivet runcannot read a key from — also gets one.- An incremental export's first run lands as a plain table; the change log starts with the
first delta. A run that re-read the whole table (the first run, or one afterrivet state reset) is loaded into<table>exactly like a full load. The first delta then renames that
table to<table>__changes, adds__op/__pos/__seq(NULL on the existing rows) and
puts the current-state view under the old name — no copy, nothing dropped, and the change
log keeps the table's partitioning and clustering (arequire_partition_filteris lifted,
since the view reads the whole log). The same rename turns a full-load table into the log
when a keyset export continues as incremental on the same key, or a CDC stream starts over
it withoutinitial: snapshot. Previously the first append copied the table into the log
with a billedCREATE TABLE … AS SELECT, and after a full → incremental switch that copy
duplicated every row the first run had re-read. - A load never touches a table it does not own. A whole-table load onto an existing table
proceeds only when the load ledger shows rivet loaded it and its partitioning and clustering
match the config. A table rivet did not load, one whose shape differs (partitioned or
re-clustered by hand, orcluster_bychanged), or a view left by an incremental / CDC load
fails the load naming the table and the difference, and nothing is changed. A CDC load that
carries an initial snapshot over a table an earlier full load left is refused the same way:
keep one baseline. Without a ledger (a stateless load) the table's shape is the only
evidence, and the load says so. On BigQuery row counts come from table metadata, so a table
that requires a partition filter can be counted. rivet loadno longer reads the source (ADR-0034).
A successfulrivet runof an export in a config with aload:block records the
column types it wrote and the source primary key in the state DB (schema v27,
export_load_spec);rivet loadplans the warehouse schema from that record and opens
no source c...
v0.25.0
-
New:
rivet state runsandrivet state finish-run— the frozen-prefix
escape hatch.runslists the run-status ledger (gc/cleanup's activity
signal);finish-runterminal-stamps a row you KNOW is dead (hard crash with
no successful successor — nothing else can release it now that supersession
is success-only). The cleanup refusal names the commands. -
CDC re-baseline loads now REFUSE instead of appending silently-wrong
data. A snapshot-carryingrivet loadinto a<table>__changesthat
already holds change rows hard-fails with a per-warehouseTRUNCATE
prescription: a re-snapshot row cannot win the dedup against already-loaded
changes (and no snapshot can express a PK deleted during the gap), so the
view would silently keep serving pre-gap values. Stateless loads degrade to
a note; an errored state store refuses (fail-safe). -
Snapshot legs are anchor-stamped. On checkpointed MySQL/SQL
Server/MongoDB flows theinitial: snapshotparquet now carries constant
__pos(the CDC anchor) and__seq = -1columns, so a re-baseline orders
correctly in the warehouse dedup. PostgreSQL-without-a-checkpoint and
pre-existing snapshots keep the legacy NULL shape. Anyone querying
__changes WHERE __pos IS NULLto find baselines sees the new shape. -
rivet validategot stricter — previously-green runs can now exit 3.
CDC prefixes presence-check EVERY cycle's committed parts (the dataset is
the union of run-unique manifests; deleting a historical part used to
validate PASSED), and a declared part that is present at its size but does
not decode as Parquet is VERIFIED-WRONG even when the manifest records no
checksums. Budget accordingly:--depth fullon a long CDC stream now
honestly re-reads the accumulated history. -
Resume verifies the destination. A chunked resume re-exports committed
chunks whose parts were deleted between attempts (destination probe + the
M8 rewrite path); a keyset resume REFUSES over a deleted committed page
(namingrivet state reset) instead of finalizing Success over a hole. -
gc/census supersession requires a newer SUCCESS. A failed or
merely-running successor no longer retires a live run's marker; a failed
run's parts are spared until a Success supersedes them (checkpoint resumes
may still adopt them); deadrunningmarkers are retired via the
terminal-ledger sweep oncefinish-run/the split stamp closes their rows. -
New refusals of previously-accepted configs (each was a silent
misbehaviour):format: csvunder aload:block;load:on a non-GCS
destination (it silently listed a same-named GCS bucket); destination
prefixes with a..segment or an embedded URI scheme;--splitwith
{date}/{run_id}placeholders; equivalent checkpoint-path spellings; a
MySQL CDC open whose server-identity query fails (no more unverifiable
anchors); a MongoDB resume token whose_datais not a string. -
rivet initgenerates case-exact configs. Mixed-case tables route
through a quotedquery:(aCaseTwin/casetwintwin used to silently
export the WRONG table's rows), SELECT columns are quoted per engine (the
same twin bug at column level), Mongo always emitstable:(hyphenated
collections get an honest SKIPPED comment), and-orefuses to overwrite
an existing file. -
CSV: NULL and the empty string are distinct at last. A present empty
string renders as""; NULL stays the bare field (the PostgreSQL COPY
convention). Existing consumers see different bytes for empty strings;
DuckDB readers needallow_quoted_nulls=falseto recover the distinction. -
Secrets scrubbed from more places. URL query parameters carrying
token/password/api-key/signature families are redacted in errors and logs;
a Slack webhook delivery failure no longer prints the webhook URL (its
path IS the credential); load-path errors route through the redactor. -
Smaller: BigQuery DDL backticks every column (reserved-word columns
survive); a set-but-missingGOOGLE_APPLICATION_CREDENTIALSis a loud
error naming the path;RIVET_BQ_ACCESS_TOKENnow takes precedence over
ADC resolution;rivet checkpreflight understands Mongo keyset
(page_size) and stops sparse-alarming configs that took its own
chunk_countadvice; the doctor recognises MySQL's "Unknown database". -
CDC memory budgets measure memory now, with an operator-visible surface.
rollover_memory_mbgained a default (256 MiB) so absence no longer means "no
byte budget" — wide-row tables roll parts by resident size where they previously
buffered to the row cap; part counts can change for such configs. The
RIVET_CDC_MAX_TX_*caps are measured in RESIDENT cost (what the buffer actually
holds) instead of a payload estimate that under-counted ~12.7x, so a transaction
that slipped under the old 2 GiB figure may now refuse — the refusal was always
the intent, the old number was not a guard. Zero is rejected for both rollover
knobs at validation (0meant "roll a part on every transaction", not
"disable").bytes_readmetrics return to approximately the released DECODED-payload
unit (the old +32/event constant is gone — percent-level on realistic rows)
and stay comparable with batch exports. -
Opt-in transaction spilling (
RIVET_CDC_SPILL_DIR). A transaction past the
cap can spill its tail to disk instead of failing (PostgreSQL/MySQL/SQL Server;
MongoDB needs none by construction). Off by default: it moves the adapter's copy
only (~11% of peak RSS measured on PostgreSQL), so the caps stay the honest
guard. On the config-driven path every resolved location is config-anchored (the ad-hocrivet cdcCLI anchors to the invoking shell's directory — the directory the operator chose). -
A service-account key file is now minted IN PROCESS —
gcloudis off the
BigQuery path for it. WithGOOGLE_APPLICATION_CREDENTIALSpointing at a
service_accountJSON, rivet's shared Google auth seam
(destination::gcs_auth) returned "not my shape" and the BigQuery loader
shelled out togcloudfor a token. Rivet now builds the RFC 7523 claim set,
signs it RS256 with the file's ownprivate_key, and exchanges it for an
access token itself — the same seam, so the GCS destination and the BigQuery
loader mint from ONE implementation and a run's two legs act as ONE identity.
Verified against a real service account:INFORMATION_SCHEMA.JOBS_BY_PROJECT
recordsuser_email = rivet-export@….iam.gserviceaccount.comfor the load's
jobs, with thegcloudfallback compiled to abort so a green run could not
come from the subprocess. Two behaviour notes: a service account is billable
on its own, so rivet no longer sendsx-goog-user-projectfor it (the header
would demandserviceusage.services.use, which the roles that grant a load do
not include); andexternal_account/ workload identity plus GCE metadata
still use thegcloudfallback — an STS exchange rivet does not model, and a
half-implemented subset would be worse than the documented subprocess. The
crypto isjsonwebtoken, already in the tree viareqsign: zero new crates,
zero added build time. Extension seam (ADR-0026): a WIDENING, not a break —
AdcUserTokenLoader/try_authorized_user_loader()keep their names and
signatures and gaincredential_kind()/principal(). -
A multiplex CDC export (
mode: cdcwithtables:) can now be loaded.
The configrivet init --mode cdcemits for a whole schema carries no
table:orquery:, sorivet loadbailed on it outright ("must specify
exactly one of 'query', 'query_file', or 'table'") andrivet check --json
silently dropped itscolumnsblock. Both halves now fan out per table: the
type report emits one document per table (with a newtable:field and a
cdc/ordersdisplay name), and the loader builds one plan per table — its
own warehouse table, its own<base>/<table>/staging prefix. That prefix is
the load-critical half: the manifest listing is recursive, so N plans sharing
one base prefix would each sweep in every sibling table's parts and merge N
source tables into one warehouse table with every count agreeing. -
BigQuery loads no longer need the
bqCLI (or the Google Cloud SDK) on
PATH.rivet load --target bigquerytalks to the BigQuery JSON API
directly over HTTPS, using the same Application Default Credentials rivet
already uses for GCS — one Google auth path, not two. A container or CI image
that installed the Cloud SDK only for rivet can drop it. Job labels, the
4,000-partition batch split,PARSE_JSON, and the partition-quota error hint
are unchanged; error messages now name the BigQuery job id. If ADC is a user
credential with no usable refresh grant, rivet still asksgcloudfor a token
when it is present, and honoursRIVET_BQ_ACCESS_TOKENwhen it is not. -
rivet loadno longer shells out to itself. The load planner obtained
every column's warehouse type by SPAWNINGrivet check --target X --jsonand
parsing the child's stdout; it now calls the same resolver in process
(preflight::collect_type_reports, the function that command renders from).
Version skew between the loading binary and the type-resolving one is gone by
construction, so the--rivet-binflag that existed only to mitigate it is
removed (passing it is now an error — it had no other use), and the config
is parsed ONCE instead of twice with two different${VAR}resolutions. The
plan also keeps the resolver's wholeTargetColumnSpec—autoload_type, the
note and the L5cast_sqlwere silently dropped by the four-field JSON mirror.
rivet load's planning step is now reachable from offline tests for the first
time. -
ADR-0028: one finalize seam for the export tail. The post-write tail
(manifest schema-fingerprint pin,on_schema_driftgate, Form-B chec...