feat(ingest): mirror Tinybird writes into a second workspace - #638
Merged
Conversation
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.
🍁 Maple PR previewWarning Preview cleanup could not be confirmed. The Alchemy teardown outcome was Final commit |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The Tinybird workspace (
api.tinybird.co— GCP Frankfurt) and the ingest fleet (AWSus-east-1) are on different clouds and continents, so every exported byte pays the public-internet egress rate rather than the same-region one.resolveAwsRegionin stage.ts:43 already spells out the stakes and ends with "Verify the instance'sTINYBIRD_HOSTbefore 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::TinybirdMirrorjoinsTinybirdandClickHouse. 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:ExportDestination::ALLleaves the existing lane ordinals (0, 1) untouched.shard-000-tinybird_mirror.wal), not lane index, so bumpingLANES_PER_SHARD2→3 does not orphan in-flight files.Wire tag
3is documented as permanently reserved — a mirror-written WAL file must still decode under a rolled-back binary.Best-effort is structural, not conventional.
commit_framessplits into a fail-closedcommit_frames_to_lane(unchanged behaviour, runs first) and acommit_mirror_framesthat cannot return an error. A full lane, an exhausted org byte budget, or a WAL failure each meter toingest_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_percentramps 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
destinationlabel ontinybird_export_{succeeded,dropped,retry}and onexport_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_HOSTandTINYBIRD_MIRROR_TOKENare set; half a config fails at startup rather than 401-ing a lane forever.Reviewer notes
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_BYTESin alchemy.run.ts needs raising (within Fargate's ephemeral ceiling) for the duration of the window, andINGEST_ORG_QUEUE_MAX_BYTESroughly doubling. Comments record this at both constants.optionalPlain, notprocess.env— alchemy reads--env-file/.envthrough its own ConfigProvider and never copies those intoprocess.env, so a bare read works in CI and silently misses locally.production-usleg. 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_checksis 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 andingest_tinybird_mirror_dropped_totalstays 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 returnOkwhile 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 clippyclean on both touched files;cargo fmtdiffs limited to those already present at HEAD.bun typecheckandtsc -p tsconfig.alchemy.jsonpass.Not run: the two-container
tinybird-localend-to-end. Its unique value overmirror_lane_receives_the_same_rows_as_the_primarywould 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
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.