Skip to content

feat(ingest): mirror Tinybird writes into a second workspace - #638

Merged
Makisuo merged 5 commits into
mainfrom
feat/ingest-tinybird-mirror
Aug 25, 2026
Merged

feat(ingest): mirror Tinybird writes into a second workspace#638
Makisuo merged 5 commits into
mainfrom
feat/ingest-tinybird-mirror

Conversation

@Makisuo

@Makisuo Makisuo commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Why

The Tinybird workspace (api.tinybird.co — GCP Frankfurt) and the ingest fleet (AWS us-east-1) are on different clouds and continents, so every exported byte pays the public-internet egress rate rather than the same-region one. resolveAwsRegion in stage.ts:43 already spells out the stakes and ends with "Verify the instance's TINYBIRD_HOST before changing a mapping" — this is the first step of that change.

A single-step cutover would strand every read behind an empty warehouse: the new workspace's materialized views only build rollups from rows inserted into it. So the gateway needs to write both for a while, and a month of live dual-emit is what makes the new workspace readable at cutover.

This PR is the ingest half. Read cutover, JWT/signing-key migration, and history backfill are separate steps.

What changed

A third export lane, not a fan-out. ExportDestination::TinybirdMirror joins Tinybird and ClickHouse. The lane machinery already exists for exactly this reason — each destination gets its own WAL file, cursor, bounded channel and worker, so "a stalled destination can only back-pressure its own lane" (telemetry.rs:82). Two properties make this safe on a rolling deploy:

  • Appending to ExportDestination::ALL leaves the existing lane ordinals (0, 1) untouched.
  • WAL files are addressed by destination name (shard-000-tinybird_mirror.wal), not lane index, so bumping LANES_PER_SHARD 2→3 does not orphan in-flight files.

Wire tag 3 is documented as permanently reserved — a mirror-written WAL file must still decode under a rolled-back binary.

Best-effort is structural, not conventional. commit_frames splits into a fail-closed commit_frames_to_lane (unchanged behaviour, runs first) and a commit_mirror_frames that cannot return an error. A full lane, an exhausted org byte budget, or a WAL failure each meter to ingest_tinybird_mirror_dropped_total{reason} and continue. Because the primary commits first, it always wins the race for the last reservable bytes.

ClickHouse-routed orgs are never mirrored: a BYO-ClickHouse org's rows never reached the workspace being migrated and must not start reaching its replacement.

Mirror config carries its own endpoint/token, a smaller retry budget (5 vs the primary's 20) and a per-attempt timeout the primary deliberately lacks — a sick workspace should release its lane worker in seconds rather than hold a batch through the full budget. sample_percent ramps by org hash, so an org is consistently in or out; a percentage that split one org's stream would leave it half-mirrored, which no backfill boundary can describe.

Metrics gained a destination label on tinybird_export_{succeeded,dropped,retry} and on export_batch_completed / wal_exported_bytes. Comparing per-datasource row counts between the two workspaces is the acceptance test for the whole window, and without the label the mirror lane's drain was indistinguishable from the primary's in the same shard.

Feature is off by default. Present only when both TINYBIRD_MIRROR_HOST and TINYBIRD_MIRROR_TOKEN are set; half a config fails at startup rather than 401-ing a lane forever.

Reviewer notes

  • Capacity is the thing to check before enabling. The per-lane WAL budget is queue_max_bytes / num_lanes, which just went /2/3 — so the primary Tinybird lane's disk share shrinks by a third at the same moment its traffic doubles. WAL_MAX_BYTES in alchemy.run.ts needs raising (within Fargate's ephemeral ceiling) for the duration of the window, and INGEST_ORG_QUEUE_MAX_BYTES roughly doubling. Comments record this at both constants.
  • Mirror host/token resolve through optionalPlain, not process.env — alchemy reads --env-file/.env through its own ConfigProvider and never copies those into process.env, so a bare read works in CI and silently misses locally.
  • During the window the fleet pays both egress rates. That is the price of the month.
  • The CD matrix gains a production-us leg. Both production workspaces must receive every schema deploy for the whole window — a month of drift between them is the quiet way this fails. It no-ops until the environment's secrets exist.
  • alert_checks is deliberately not mirrored (it is written by the API worker, not the gateway). It cuts over at the end and starts empty in the new workspace.

Rollout

Deploy with INGEST_TINYBIRD_MIRROR_SAMPLE_PERCENT=1, confirm rows land and ingest_tinybird_mirror_dropped_total stays flat, then ramp 1 → 10 → 100. The signal that decides whether this was safe is the primary lane's shed rate, which must not move at all.

Testing

cargo test --locked — 200 pass, 0 fail (122 lib + 78 bin), including 8 new tests:

  • a_stalled_mirror_never_fails_the_accept_path — the load-bearing one. Mirror points at an unbound port, its lane backs up, and 20 accepts all still return Ok while the primary keeps receiving.
  • mirror_lane_receives_the_same_rows_as_the_primary — two real HTTP servers, byte-identical gzip NDJSON, each authenticated with its own token.
  • clickhouse_bound_rows_are_never_mirrored, destination_tag_round_trips_all_variants, mirror_lane_ordinals_stay_stable_when_a_destination_is_appended, mirror_sampling_is_all_or_nothing_per_org, mirror_config_rejects_half_configuration, tinybird_target_resolves_per_destination.

cargo clippy clean on both touched files; cargo fmt diffs limited to those already present at HEAD. bun typecheck and tsc -p tsconfig.alchemy.json pass.

Not run: the two-container tinybird-local end-to-end. Its unique value over mirror_lane_receives_the_same_rows_as_the_primary would be validating against real datasource schemas, which this change does not touch — same schema, different host. Worth running before the staging ramp.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

The Tinybird workspace is on GCP Frankfurt while the ingest fleet runs in
AWS us-east-1, so every exported byte crosses cloud and continent at the
public-internet egress rate instead of the same-region one. Moving the
workspace to us-east-1 in one step would strand every read behind an empty
warehouse, so the gateway needs to write both for a while: the new
workspace's materialized views only build rollups from rows inserted into
it, and a month of live dual-emit is what makes it readable at cutover.

Adds `ExportDestination::TinybirdMirror` as a third export lane rather than
a fan-out inside the export call. The lane machinery already exists for
exactly this reason — its own WAL file, cursor, bounded channel and worker,
so a stalled destination can only back-pressure itself. Appending to
`ExportDestination::ALL` keeps the existing lane ordinals, and WAL files are
addressed by destination name, so a rolling deploy does not renumber lanes
under frames already committed. Wire tag 3 is reserved permanently: a
mirror-written WAL file has to decode under a rolled-back binary.

The mirror is best-effort, and that is enforced structurally rather than by
convention. `commit_frames` splits into a fail-closed primary commit and a
mirror commit that cannot return an error — a full lane, an exhausted org
byte budget or a WAL failure is metered and dropped. The primary commits
first, so it always wins the race for the last reservable bytes. A
ClickHouse-routed org is never mirrored: its rows never reached the
workspace being migrated and must not start reaching its replacement.

Mirror credentials carry a smaller retry budget and a per-attempt timeout
the primary deliberately lacks, so a sick workspace releases its lane worker
in seconds instead of holding a batch through the full budget. A
sample-percent knob ramps by org hash, so an org is consistently in or out
and no org's stream is ever half-mirrored — a backfill boundary can only be
described if that holds.

Export metrics gain a `destination` label. Comparing per-datasource row
counts between the two workspaces is the acceptance test for the whole
window, and `ingest_tinybird_mirror_dropped_total` is the only signal that
the mirror has a gap, since a mirror shed is invisible to clients and to
every other counter.

Feature is off unless both halves of the mirror config are set; half a
config fails at startup rather than 401-ing a lane forever. Also adds a
us-east-1 leg to the Tinybird CD matrix — both production workspaces must
receive every schema deploy for the duration of the window, since a month of
drift between them is the quiet way this migration fails.
…flag

`tinybird deploy --host X` exits `unknown option '--host'` on the pinned
@tinybirdco/sdk 0.0.78, so the CD job's HOST_FLAG has never worked: an
environment with TINYBIRD_HOST set fails the deploy outright, and one
without it silently deploys to the tinybird.json default. Nobody noticed
because the only leg with a token also had no host secret.

Found while validating the us-east-1 leg added in the previous commit, which
depends entirely on being able to point a deploy at a second workspace.

The CLI takes its host from the config file's `baseUrl` and resolves config
files in priority order, `tinybird.config.json` ahead of `tinybird.json`.
Writing one in the job overrides the repo default for that deploy only,
without making TINYBIRD_HOST mandatory for `tb build` locally — which is
what putting `${TINYBIRD_HOST}` in the committed tinybird.json would do,
since interpolation throws on an unset variable.

Verified by reproducing the step against a local Tinybird: the deploy
reaches the config-file host rather than api.tinybird.co.
The migration is now mirror-and-cut: losing data older than 30 days is
acceptable, so there is no backfill phase. That inverts what this counter
means operationally. The old comment told whoever is watching the ramp that a
non-zero value marks a window to repair later; nothing repairs it, and the
rows are gone from the new workspace for good.

Says so instead, because this is the number the ramp is gated on.
The mirror reserved from the same `org_queue_bytes` map as the primary, and
bytes are only released once a frame exports. So a stalled mirror lane held
an org's bytes for as long as it stayed stuck, and once they reached
`org_queue_max_bytes` that org's PRIMARY commits failed to reserve — 429s on
an org whose primary lane was perfectly healthy.

That is precisely the coupling the per-destination lanes exist to prevent. A
best-effort destination must not be able to reject a request for a
fail-closed one, and "the primary reserves first" only holds within a single
batch, not across the minutes a lane can stay stuck.

Separate counter, and the export worker releases into whichever map its lane
reserved from. The accompanying test fails with `Throttled("Telemetry org
queue byte limit exceeded")` when the two share a counter.

Also removes the need to raise INGEST_ORG_QUEUE_MAX_BYTES for the migration,
which was a workaround for this bug rather than a real capacity requirement.
The per-lane WAL budget is INGEST_QUEUE_MAX_BYTES / (WAL_SHARDS * lanes). The
mirror adds a third lane per shard, so at 8 GiB every lane would have dropped
from 1 GiB to 683 MiB — including the primary Tinybird lane, at the exact
moment its traffic doubled and a second full copy of every payload started
competing for the same disk.

12 GiB over 12 lanes restores the 1 GiB per lane that 8 GiB gave across 8,
and leaves ~8 GB of Fargate's 20 GB ephemeral allowance for the image and OS.
@Makisuo
Makisuo merged commit 660ceba into main Aug 25, 2026
28 checks passed
@Makisuo
Makisuo deleted the feat/ingest-tinybird-mirror branch August 25, 2026 23:52
@github-actions

Copy link
Copy Markdown

🍁 Maple PR preview

Warning

Preview cleanup could not be confirmed. The Alchemy teardown outcome was skipped.

Final commit b7d5072 · View workflow run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant