Skip to content

Releases: rivet-lab/rivet

v0.32.2

Choose a tag to compare

@github-actions github-actions released this 22 Oct 19:17
289744e

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-wait seconds (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 0 to 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 init over a CDC checkpoint or destination another config names: RIVET_INIT_CDC_NAME_TAKEN,
      exit 5, nothing written;
    • rivet load / rivet compact without a load: 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).
  • 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_GAP as 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 of SELECT @@…): 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 a rivet run or rivet load refused 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 refused RIVET_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 unexpired state_lease row of a live holder on a PostgreSQL state). While one
    does, it waits up to --lock-wait and then stops with the new RIVET_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 cdc was refused when another database on the
    server held a table of the same name.
    init writes the tables unqualified (tables: [orders]),
    and every stream run stopped with RIVET_SOURCE_CDC_TABLE_UNRESOLVED ("orders is 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 its per_table[] entry (--json, --summary-output) and its
    CDC LOAD OK line 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). Now rows is both legs' rows, target
    is the base when a baseline landed, buffer names 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 read target of 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 audit and the generated docs outside the gate.
    Seeded-defect recall was opt-in, so
    24 seed patches had stopped applying to main unseen. Now make release-oracle-full runs the
    recall (a MISSED or STALE seed fails the gate), cargo audit and docgen --check are gate stages,
    the gate records its verdict with the full commit, its kind and whether the tree was clean at its
    start, and dev/pytools/release.py refuses to tag a commit without a clean RELEASE-READY
    release-oracle-full run 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 FILE when 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
    for file_log, so every cell graded on that state read file_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's search_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 init scaffolded GCS and S3 destinations but not Azure. Now --azure-container <name> --azure-account <name> writes destination: type: azure with the same prefix: exports/<table>/
    (cdc/ for a CDC stream), account_name: and account_key_env: AZURE_STORAGE_KEY, never a key;
    --azure-endpoint <url> adds endpoint: (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-url can stage in. What to do: nothing; set AZURE_STORAGE_KEY (or swap in
    sas_token_env:) before the first run.

  • Two CDC configs written by rivet init -o into one directory shared one checkpoint and one
    destination.
    Through 0.32.1 init --mode cdc named the checkpoint after the table or schema
    (./cdc/<name>.ckpt) and the CDC destination cdc/ / ./output/cdc/ (per table <t>_cdc/)
    whatever -o said, 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. Now init -o <stem>.yaml names the checkpoint ./cdc/<stem>_<name>.ckpt and the destination
    <stem>/cdc/ / ./output/<stem>/cdc/ (per table exports/<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#1 and orders-1 (or orders x and orders-x, or two Cyrillic names) locked one file, and
    with --parallel-exports one of them was refused RIVET_STATE_RUN_IN_PROGRESS beside no live run of
    its own. Now the fil...

Read more

v0.32.1

Choose a tag to compare

@github-actions github-actions released this 19 Oct 14:29
5ee08c0

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_URL without sslmode: RIVET_STATE_URL_TLS_REQUIRED
      (add ?sslmode=require, or ?sslmode=disable on a private network);
    • rivet check that logged an ERROR: RIVET_CLI_CHECK_REPORTED_ERRORS instead of Looks good.;
    • rivet init with a RIVET_STATE_URL that does not answer writes no config.
  • rivet init names a CDC export <table>_cdc with 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's BEGIN IMMEDIATE was 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_zone delivered 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:00Z written for 09: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:
    set default_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 skipped entry of the run summary did not say why it was skipped. --summary-output
    and --json of rivet run / rivet apply wrote status: "skipped" for a skip_empty run
    with no reason, while the console card and the metrics row had it (0.32.0 and earlier).
    Now every per_export[] entry carries skip_reason: the reason for a skipped entry ("no
    new rows since cursor 'updated_at'", "source returned 0 rows"), null for every other, on the
    sequential, thread and process runners. What to do: nothing; a scheduler may read it.

  • rivet init gave 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/ inside exports/<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>_cdc and writes to the sibling
    exports/<t>_cdc/ (./output/<t>_cdc/). What to do: nothing for new scaffolds; a config
    generated earlier keeps working, and rivet load already skips a nested CDC export's runs.

  • A schema-wide rivet init on SQL Server scaffolded the system tables SQL Server ships. On a
    database with CDC enabled dbo.systranschemas became an export (0.32.0 and earlier). Now
    tables and views marked IsMSShipped are left out. What to do: delete such an export from a
    config generated earlier.

  • A SQL Server login without VIEW SERVER PERFORMANCE STATE filled the log with permission
    errors.
    Each run sampled the server counters (sys.dm_os_performance_counters and 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; grant VIEW SERVER PERFORMANCE STATE (2022) or VIEW SERVER STATE to 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's rivet load refused it on
    every cycle ("no record of rivet loading it", exit 1), and docs/reference/state.md did not say
    so (0.32.0). Now the rollback steps list those tables (rivet state loads before 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 check and rivet doctor were green over a state no run could open.
    With RIVET_STATE_URL pointing at a state that does not answer, init warned "primary keys
    not recorded" and wrote the config, exit 0; check printed Looks 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). Now doctor opens the state the config uses without migrating it and prints
    [OK] State (postgres …) / (sqlite …) or fails; check exits non-zero
    (RIVET_CLI_CHECK_REPORTED_ERRORS) when it logged an ERROR line, including a state that does not
    answer; init -o with RIVET_STATE_URL set refuses before writing the config when that state
    does not answer. What to do: nothing; fix what the new failure names.

  • rivet --version printed (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 print rivet <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
    with RIVET_STATE_URL naming a non-loopback host was refused RIVET_STATE_BACKEND_FAILED,
    exit 1, with a text that told the operator to add source.tls, a key the state does not
    have, and even ?sslmode=disable was 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
    sslmode is refused RIVET_STATE_URL_TLS_REQUIRED, exit 5, naming the two state remedies.
    What to do: add ?sslmode=require to RIVET_STATE_URL with TLS on the state server, or
    ?sslmode=disable on a private network.

  • A batch rivet load read the runs of a CDC export nested inside its prefix. rivet init
    puts a table's CDC export at exports/<t>/cdc/ inside its batch export exports/<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: full replaces it on the next load).

v0.32.0

Choose a tag to compare

@github-actions github-actions released this 18 Oct 11:25
8afde97

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>.bak beside a SQLite state, schema
    rivet_state_backup_v32 in 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.path is 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_FOUND on every SQL engine; remove
    the export from exports: if the table was dropped on purpose.

Known issue in this release (fixed in 0.32.1):

  • rivet load of a batch export reads the manifests under its whole prefix, including another
    export's nested under it.
    rivet init on SQL Server writes a batch prefix exports/<t>/ and a
    CDC prefix exports/<t>/cdc/; with both in one bucket, rivet load of 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 config rivet init generated for SQL Server, move the CDC export's prefix to a
    sibling (for example cdc/<t>/) before the first run.

  • A plan applied after UTC midnight wrote under the planning day. rivet plan resolved
    {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, since rivet plan exists). Now the sealed plan keeps {date} as written and
    rivet apply resolves it to the UTC day it runs on, through the same resolver rivet run
    uses: a plan made before midnight UTC and applied after now writes under the apply date.
    rivet plan --date YYYY-MM-DD freezes 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 --date to rivet plan when the planning day is the one wanted.

  • rivet apply answers 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. Now rivet apply
    takes --summary-output <PATH> and --json like rivet 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: its run_id, rows, files and bytes from its export_metrics row, status: "already_applied"
    and answered_by_run: "<run_id>". Every per_export[] entry now carries answered_by_run
    (null unless 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 reads status on the entry; a status table that treats an
    unknown word as failed needs already_applied added.

  • 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>.bak beside the state (.rivet_state.db.v32.20261017T101530Z.bak), on
    PostgreSQL the schema rivet_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 without CREATE on the
    database) stops the migration with RIVET_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 for rivet load is 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.json run 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 for mode: 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's plan_id (state schema v37, run_status.plan_id), and a replay of it exits 0
    without extracting, printing apply: 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 with rivet 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 run over a table dropped since the config was generated ended with the engine's own words
    as its Error: line and no RIVET_* code on MySQL and PostgreSQL (Error: MySqlError { ERROR 1146 (42S02): Table 'rivet.drop_probe_b' doesn't exist }, exit 1), or RIVET_SOURCE_QUERY_FAILED
    on SQL Server and Oracle; rivet check refused the same table as RIVET_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
    and plan), and the export stops as Error: [RIVET_SOURCE_TABLE_NOT_FOUND] export 'drop_probe_b': the table it reads does not exist (drop_probe_b, read by its query:). Correct the export's table:orquery:, or remove the export from exports: 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 plan keeps RIVET_PLAN_SOURCE_UNREADABLE as its outer
    code, as it does for a missing MongoDB collection, with this refusal as the cause. What to do:
    remove the export from exports: if the table was dropped on purpose, or correct its table:.

  • A relative destination.path followed the working directory, not the config. A local
    export whose path is relative (./output/orders, what rivet init writes) 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 local path
    is anchored to the config file's directory, resolved once at config load and used the same
    way by run, plan/apply, validate, reconcile, repair, state, doctor and
    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 (its manifest.json) while the directory beside the
    config holds none, is refused with RIVET_DEST_RELATIVE_PATH_MOVED (exit 5) naming both
    directories, before anything is read or written; the check is one decision in the config
    loader, so run, load, reconcile, repair, validate and every other command that
    resolves the path meet it, and ...

Read more

v0.31.0

Choose a tag to compare

@github-actions github-actions released this 04 Oct 10:56
4ac2c89
  • 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_incremental export loads as an append, not an overwrite. Each run of such an
    export delivers only the keys past its anchor, but rivet load treated 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 by chunk_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_UNREPRESENTABLE naming 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 shows START_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: snapshot the 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 cdc scaffolds a config that runs. SQL Server, MongoDB, a single table
    and a non-public PostgreSQL schema got a stream with no baseline under a base-and-buffer load
    block, so the first rivet compact refused; Oracle got a load: 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 BY on 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, and load.ca_file sets 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-case columns: 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; a Date32 column 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. mysql 28.0.3 (28.0.0 was yanked), mongodb 3.9.1 (a change-stream loop
    and two CVEs), tiberius 0.13 (SQL Server sql_variant and CLR types read as text).
    cargo audit is clean.

  • MySQL CDC compact reads the base only in its MERGE. rivet compact merges 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 by rivet run as 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_image not FULL) the old partition is
    unknown, so the run fails with RIVET_SOURCE_CDC_PREREQUISITE naming
    SET PERSIST binlog_row_image = FULL;, before any part is written. Other engines, and the
    change-log layout, are unchanged.

  • rivet compact no 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 __changes buffer in the
    warehouse; compact still ran ALTER TABLE on the base before looking for the buffer, and
    BigQuery answered Not found: Table on 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 the COMPACT SKIP it always should have been.

  • A full load of an emptied source empties the warehouse table again. Since the change below,
    a mode: full run 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 load consumes 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 or ALTER delivered them under the wrong names (source
    (1, b='BBB', a='AAA') arrived as b='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, with RIVET_SOURCE_CDC_UNDECODABLE naming 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 doctor fails the "CDC
    binlog server config" check for the same settings. The CDC sink no longer maps any nameless image
    by position.

  • rivet run applies the type policy before it reads data. A column whose mapping is lossy
    or unsupported used to be reported only by rivet check --type-report, and the run wrote it
    without a word. Batch and CDC runs, including rivet cdc --output, now log one warning per
    such column before the first row is read. The warning has the wording check prints. The new
    rivet run --strict refuses the run instead, before any part or checkpoint is written, and
    names every such column with RIVET_TYPE_UNSAFE_MAPPING (exit 1). rivet check --type-report --strict now refuses with the same code, so check and run agree on the verdict.

v0.30.0

Choose a tag to compare

@github-actions github-actions released this 29 Sep 19:39
216de2d
  • Breaking: a source URL whose scheme names another engine is refused. source.type is the
    engine rivet anchors and types the stream as, and the URL's scheme was never compared with it:
    type: mysql with a postgresql:// URL got as far as the driver. Config load now refuses the
    pair with RIVET_CONFIG_SOURCE_URL_SCHEME_MISMATCH (exit 1). A scheme rivet does not know
    (jdbc:…) is still left to the driver.

  • Breaking: a cursor_column or columns: key that matches a result column only when case is
    ignored is refused.
    MySQL and SQL Server (case-insensitive collations) run
    cursor_column: updatedat against a column the result set names updatedAt. rivet matched the
    name exactly, found nothing, recorded no cursor, and every run re-exported the whole table
    with exit 0. A columns: 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. A columns: 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 uuid column 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 to uuid and 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, a decimal(p,s) override on an integer
    column produced a column that was entirely NULL, and on SQL Server a uuid override on
    nvarchar(36) did the same. The exit was 0 and both value checksums agreed on the NULL. Integers
    under a decimal override are now scaled exactly, and SQL Server uuid text is parsed. Any
    other mismatch fails naming the column (RIVET_SOURCE_OVERRIDE_WIRE_MISMATCH, exit 1). On
    MySQL, a uuid override now refuses a value it cannot parse instead of nulling it.

  • Breaking: a PostgreSQL URL with a remote host= or hostaddr= 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) and host=. So
    postgresql://u:p@localhost/db?hostaddr=203.0.113.5 passed as loopback and sent credentials and
    rows to the remote address in cleartext. Every host=/hostaddr= value must now be loopback
    or a unix-socket path, or the connection needs a tls: block.

  • Breaking: MySQL TIME outside 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 MySQL TIME is a
    duration of up to ±838:59:59, and a Parquet TIME holds one day. 838:59:59 read 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 with RIVET_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: oracle with an
    oracle://user:password@host:1521/SERVICE URL runs full, incremental, range- and
    keyset-chunked (also parallel > 1), time_window and partition_by exports. It uses
    Oracle's pure-Rust thin driver, so no Oracle client install is needed. rivet init scaffolds
    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 and NLS_SORT = NLS_COMP = BINARY.
    A TIMESTAMP(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-on oracle cargo feature; TLS verifies only against
    the public CA bundle built into the driver (no tls.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 as N'…', which a varchar key under
    a SQL collation compares by different rules than its own ORDER BY: 67 of 180 keys lost. A
    timestamp range bound in YYYY-MM-DD HH:MM:SS form 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 the T form, which does not depend on DATEFORMAT.

  • 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 +00 offset of a
    PostgreSQL timestamptz. 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 Server DATETIME bound 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
    and init ran in the role's zone and DateStyle, so one run used two zones (the cause of the
    Tokyo loss above). Each connection now sets TimeZone, DateStyle, IntervalStyle and
    bytea_output at connect, except behind a transaction-mode pooler, where a session SET would
    leak to other clients. Stored values do not move. Calendar arithmetic in your own query:
    (current_date, ts::date on a timestamptz) 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,
    changing parallel: 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. A keyset_incremental run switched to parallel: 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 with parallel: 1.

  • --split units no longer reuse a stale checkpoint. A fresh --split re-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 and partition_by children.

  • 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 a Success manifest. 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 the Success manifest beside the retry's, so the chunk's leading rows appeared
    twice and total_rows counted 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 FLOAT keyset key or cursor no longer re-reads its boundary row. The saved value
    was the shortest f32 text (0.1), which MySQL compares as a DOUBLE below 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/a and …@h2/b both keyed as postgres://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 from plan.json too.

  • PostgreSQL CDC decodes DOMAIN columns instead of writing NULL. test_decoding labels 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 in test_decoding output became NULL with exit 0.
    It is now refused, like infinity already was. The reader also pins TimeZone = '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 xml or sql_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...

Read more

v0.29.0

Choose a tag to compare

@github-actions github-actions released this 25 Sep 13:30
9df5fd8
  • 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_mb bounds 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 in CodecError { bytes remaining on stream }
    (nothing was lost; the cycle was wasted). The binlog connection now sets a session
    net_write_timeout of 3600 s.
  • --parallel-exports runs 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-min aborts).
  • 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 one FanIn owns 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-output into a missing directory, apply stopping 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 while rivet validate passed.
    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
    full run → load → compact cycle — each run as printed against a live stand.

v0.28.0

Choose a tag to compare

@github-actions github-actions released this 23 Sep 18:46
c6da13c
  • Behaviour changes an operator upgrading from 0.27 will see.

    • A mode: full load whose newest run exported 0 rows (the source was emptied)
      now EMPTIES the warehouse table (a free TRUNCATE, or a CREATE when there is
      none); 0.27 printed LOAD SKIP: up to date and 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 (TRUNCATE by 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.b on MongoDB now
      scaffolds the collection a.b, not b.
    • rivet init no longer picks a DATE / smalldatetime column, 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 any cursor_column: that
      names such a column.
    • rivet load / rivet compact run 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 Server uniqueidentifier)
      now lands as BYTES
      — the 16 bytes, as BigQuery autoloads them (it has no
      UUID type); render text in a view with TO_HEX(col). 0.27 declared it
      STRING and stored the same 16 bytes as unreadable text. A full load
      replaces the column type by itself. A base-and-buffer base table loaded by
      0.27 must be migrated once before the next rivet 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>: string whose 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 init on MongoDB leaves views and time-series collections out (their
      scans cannot keep a cursor open) and names them.
  • rivet check / rivet plan read the relation rivet init writes. init
    quotes the table in its query: 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 read DEGRADED, and an
    indexed chunk column No 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 init records 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 key load.pk: auto resolves 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 a cursor_column: / time_column: in one line (with
    --exclude offered only where it applies), and stops printing "Next steps" that
    cannot run on the file it just wrote.

  • rivet init --mode writes only what the table can do. A forced chunked on
    a table with no integer column and no keysettable primary key wrote
    chunk_column: id whether or not id existed; it is now written as mode: full
    and named. Soft-delete stamps (deleted_at and 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 the table:
    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 two gcloud users 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 bigquery grades such a
    column warn. Refused, loudly: a fold that collides with another column, a
    renamed column that partitions or is written into cluster_by, a non-BigQuery
    target, and pending runs that spell the column both ways (the older run's files
    would load NULL).

  • rivet load and rivet compact can work the config's tables in parallel.
    --pool N runs 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 1 restores the
    strictly sequential one, and N is capped at the number of tables either way.
    This widens what a plain rivet load does on upgrade: up to 16 ledger
    connections instead of one, and up to 16 concurrent warehouse loads, which
    count against Postgres max_connections and 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 a flock sidecar 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, so rivet load and
    rivet compact still 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, because run_loads / run_compacts are live-only bodies that
    nothing offline grades; the executor's contracts — every table runs exactly
    once, a failure isolates, init runs 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_failures picks its representative with max_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 near max_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_job polled for attempt in 0.. with no ceiling, no jobTimeoutMs on the
    job and no jobs.cancel anywhere, 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,
    which classify_error reads 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 answers RUNNING
    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_job and get_response both guarded their
    backoff with an inline attempt > 0, and both > → < (every retry fires
    IMMEDIATELY — no backoff at all, which on a 429 is exactly wrong) and > → >=
    (a wasted POLL_MIN before 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 ...

Read more

v0.27.0

Choose a tag to compare

@github-actions github-actions released this 21 Sep 13:36
1fe81ae
  • 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-table plan/apply/--pool/--split — produced
    eighteen divergences. The load-bearing ones are now fixed in docs/cheat-sheet.md:
    §3's mode table promised that incremental + layout: base_buffer flags 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 (-o requires
    --format json); --pool 4 on a generated config is the SLOWEST of the four paths,
    because rivet init writes no parallel_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_deleted errors on an incremental base, where the flag does not
    exist; §4.4's independent oracle cannot run after a load, because the generated
    cleanup_source: true deletes the very parts it reads; re-running apply without
    --resume orphans the prior run's parts; PostgreSQL's REPLICA IDENTITY was absent
    from the CDC prerequisites though rivet warns about it on every run; and
    rivet state files / rivet metrics cannot reach --split sub-units (rivet state runs can). 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 init explained a decision it had not taken. A backfill recipe's mode comes
    from recipe_mode, which pages whenever a usable key exists and never reads the row
    count — but the inline rationale printed suggest_mode's reason regardless, so a
    200-row recipe was scaffolded chunked under "~200 rows ≥ 100K threshold". The
    threshold is now claimed only when it was actually crossed, mirroring what the full
    arm already did for exactly this class of false diagnostic. The generated header also
    stopped naming rivet.yaml whatever -o said — it is built without the output path,
    so it now points at <this file> instead of a config that may not exist.

  • rivet validate blamed 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 whenever validate runs from a different working directory than the
    export did: destination.path is the one relative path rivet resolves against the
    CURRENT directory rather than the config's, and rivet init generates one. The line now
    names both causes and asserts neither. The legacy_run label 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 bare finish(), 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's default_time_zone, while the bounds it fed are
    rendered +00 and 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 compact are 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 cycle na (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
    the load: block but never the rivet init invocation 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), the run → load → compact cycle they enable from one file,
    what in the generated block is a catalog guess worth reviewing, and why
    --gcs-bucket is required with them.

  • A 9999-12-31 partition value no longer wedges compaction. On an hour / month /
    year key the MERGE's upper bound was hi + 1 day, which for the SCD "end of time"
    sentinel rendered DATE '+10000-01-01' — past the warehouse's range, a hard error on
    every rivet compact of the table while the buffer grew. The last representable day
    (and i64::MAX for a range key) has no successor: the bound is simply open above.

  • __is_deleted is 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 of deleted_flag:.

  • rivet init on SQL Server: DATA_TYPE = 'timestamp' is rowversion (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
    BYTES column 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 generated query: 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_buffer a re-baseline (a re-snapshot after a gap, a fresh whole-table
    incremental run) replaced the base but left <table>__changes as it was, so the next
    rivet compact merged 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: snapshot under a
    written load.layout: base_buffer derived its layout from backfill: presence alone,
    wrote the snapshot without __is_deleted, and LOAD DATA filled every row's flag
    with NULL — the whole backfill invisible to WHERE NOT __is_deleted.

  • layout: base_buffer needs a warehouse that compacts. rivet compact is
    BigQuery-only; on Snowflake a stream with a backfill: 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 written base_buffer is refused at config load
    outside BigQuery, and the backfill: derivation yields the changelog and its view.

  • Messages: init's next steps prescribe rivet compact only for a scaffold that
    declares a base and a buffer (a full scaffold's load OVERWRITES its table); the
    COMPACT SKIP reason names the levers that decide the layout (cdc.backfill:,
    load.layout:), not initial: snapshot, which decides nothing; the pinned-run
    refusal names the command that raised it (compact, not load); 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 compact matches 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 its updated_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 by created_at; the delta moved a row's
    date), and the shape a _rivet_exported_at partition 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: false on 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...

Read more

v0.26.0

Choose a tag to compare

@github-actions github-actions released this 15 Sep 06:59
9a7c759
  • 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 at hour / 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 type rivet run recorded (TIMESTAMP_TRUNC / DATETIME_TRUNC / DATE_TRUNC /
    RANGE_BUCKET / _PARTITIONDATE) — the config never carries SQL — and refuses a form the
    type cannot take (hour on a DATE, range on 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 from tables.get
    and compared as metadata); a changed expiration_days / require_filter is 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 Snowflake partition becomes a leading DATE_TRUNC clustering expression
    (range / ingestion / the options are refused). The per-job Hive batching of the old
    export-partition_by loader path — unreachable since partition_by and load: exclude
    each other — is gone.
  • A change log follows a written partition: / cluster_by:; rivet load --rebuild-changelog re-partitions it. With the defaults (partition absent,
    cluster_by: auto) <table>__changes keeps 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 writes partition, a log
    partitioned otherwise refuses the load naming both keys and the bytes a rebuild reads;
    rivet load --rebuild-changelog copies 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 changed expiration_days is altered in
    place on the log as on the table.
  • BigQuery load jobs are inserted under a client job id. A jobs.insert that 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 answers 409 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 as refused
    (rivet state loads), and only a load that reached the write (failed or success)
    counts for ownership. Previously the refusal's failed row made the foreign table rivet's
    own, and the next rivet 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 after mode: 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>__changes first 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>__changes at least once (the view keeps the latest row per key) instead of
    refusing "a full pass cannot be appended" on every later cycle.
  • pk: auto forgets a key the export no longer reads. A run that finds no source key
    (a query: 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 init recorded for a query: scaffold is kept. A pk column 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 state rows written
    before v26 carry no cursor identity; a keyset run's was recovered from its metrics, an
    incremental run's was not — so a changed cursor_column compared 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 and state reset (MT6).
  • An unwritten cluster_by follows the table. A full load onto its own table with
    cluster_by left at auto keeps the clustering the table has — a table an earlier
    release created unclustered is overwritten as it is, not refused after the upgrade. A
    written cluster_by still conflicts.
  • --rebuild-changelog carries 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 (PostgreSQL integer, MySQL
    int) is budgeted like INT64; range + expiration_days is 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 says cannot resolve host <host> — check the host name in url:, nothing is listening on <host>:<port>, or no answer from <host>:<port>, with the driver's text after it. MySQL dials at open, so the message comes
    from rivet init / rivet run start, 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 take auto (the default), none, or a column list. pk: auto is the primary key
    rivet run recorded, so an incremental / CDC load no longer needs pk: for a table with
    one; cluster_by: auto clusters 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 explicit cluster_by column BigQuery cannot hold is refused at plan time.
    <table>__changes is now clustered on cluster_by rather than always on pk.
    rivet init -o records each scaffolded export's key too, so a query: export — which
    rivet run cannot 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 after rivet 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 (a require_partition_filter is 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 without initial: snapshot. Previously the first append copied the table into the log
    with a billed CREATE 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, or cluster_by changed), 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 load no longer reads the source (ADR-0034).
    A successful rivet run of an export in a config with a load: block records the
    column types it wrote and the source primary key in the state DB (schema v27,
    export_load_spec); rivet load plans the warehouse schema from that record and opens
    no source c...
Read more

v0.25.0

Choose a tag to compare

@github-actions github-actions released this 30 Aug 13:41
a5ca538
  • New: rivet state runs and rivet state finish-run — the frozen-prefix
    escape hatch. runs lists the run-status ledger (gc/cleanup's activity
    signal); finish-run terminal-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-carrying rivet load into a <table>__changes that
    already holds change rows hard-fails with a per-warehouse TRUNCATE
    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 the initial: snapshot parquet now carries constant
    __pos (the CDC anchor) and __seq = -1 columns, 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 NULL to find baselines sees the new shape.

  • rivet validate got 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 full on 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
    (naming rivet 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); dead running markers are retired via the
    terminal-ledger sweep once finish-run/the split stamp closes their rows.

  • New refusals of previously-accepted configs (each was a silent
    misbehaviour): format: csv under a load: 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; --split with
    {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 _data is not a string.

  • rivet init generates case-exact configs. Mixed-case tables route
    through a quoted query: (a CaseTwin/casetwin twin used to silently
    export the WRONG table's rows), SELECT columns are quoted per engine (the
    same twin bug at column level), Mongo always emits table: (hyphenated
    collections get an honest SKIPPED comment), and -o refuses 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 need allow_quoted_nulls=false to 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-missing GOOGLE_APPLICATION_CREDENTIALS is a loud
    error naming the path; RIVET_BQ_ACCESS_TOKEN now takes precedence over
    ADC resolution; rivet check preflight understands Mongo keyset
    (page_size) and stops sparse-alarming configs that took its own
    chunk_count advice; the doctor recognises MySQL's "Unknown database".

  • CDC memory budgets measure memory now, with an operator-visible surface.
    rollover_memory_mb gained 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 (0 meant "roll a part on every transaction", not
    "disable"). bytes_read metrics 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-hoc rivet cdc CLI anchors to the invoking shell's directory — the directory the operator chose).

  • A service-account key file is now minted IN PROCESS — gcloud is off the
    BigQuery path for it.
    With GOOGLE_APPLICATION_CREDENTIALS pointing at a
    service_account JSON, rivet's shared Google auth seam
    (destination::gcs_auth) returned "not my shape" and the BigQuery loader
    shelled out to gcloud for a token. Rivet now builds the RFC 7523 claim set,
    signs it RS256 with the file's own private_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
    records user_email = rivet-export@….iam.gserviceaccount.com for the load's
    jobs, with the gcloud fallback 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 sends x-goog-user-project for it (the header
    would demand serviceusage.services.use, which the roles that grant a load do
    not include); and external_account / workload identity plus GCE metadata
    still use the gcloud fallback — an STS exchange rivet does not model, and a
    half-implemented subset would be worse than the documented subprocess. The
    crypto is jsonwebtoken, already in the tree via reqsign: 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 gain credential_kind() / principal().

  • A multiplex CDC export (mode: cdc with tables:) can now be loaded.
    The config rivet init --mode cdc emits for a whole schema carries no
    table: or query:, so rivet load bailed on it outright ("must specify
    exactly one of 'query', 'query_file', or 'table'") and rivet check --json
    silently dropped its columns block. Both halves now fan out per table: the
    type report emits one document per table (with a new table: field and a
    cdc/orders display 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 bq CLI (or the Google Cloud SDK) on
    PATH.
    rivet load --target bigquery talks 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 asks gcloud for a token
    when it is present, and honours RIVET_BQ_ACCESS_TOKEN when it is not.

  • rivet load no longer shells out to itself. The load planner obtained
    every column's warehouse type by SPAWNING rivet check --target X --json and
    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-bin flag 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 whole TargetColumnSpec — autoload_type, the
    note and the L5 cast_sql were 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_drift gate, Form-B chec...

Read more