carlos / platform Public

The replication ladder is unreachable: stream/batched/daily exist and are honoured, but nothing can set the column Issue open

The replication ladder is built and honoured, and nothing can select a rung. Every route on the fleet runs on the default, and for at least one tenant that default is costing about 100× more S3 traffic than the alternative would.

What exists

internal/activator/activator.go defines the vocabulary and the behaviour:

replicationStream  = "stream"  // change-driven ~1s sync — loss window seconds
replicationBatched = "batched" // sync at most every 5m — loss window minutes
replicationDaily   = "daily"   // sync at wake + every 24h — loss window up to a day

replicationSyncInterval maps those to real litestream sync intervals, and registry.Route.Replication is a real column that round-trips through scanRoute/upsert.

What is missing

Nothing writes it. A grep for anything setting Replication: on a route across the whole tree returns one file, and that is internal/status/snapshot.go's unrelated health struct. There is no carlos route flag, no console door, no adoption path that ever puts stream, batched or daily into that column. Every route is therefore on "", which replicationSyncInterval deliberately maps to the same 0 as stream so pre-ladder registries keep today's behaviour.

So the ladder is dead configuration: three named rungs, one of them reachable, and it is the most expensive one.

carlos routes does not print the class either, so an operator cannot see which rung a route is on even in principle — and right now the honest answer for every route is the same one.

What it costs, measured

Flagship, 2026-08-30, one day of instances/ replica writes in carlos-data-ie:

| instance | objects | GB | |---|---|---| | seapointish.bag | 11 | 4.77 | | jam.bac | 18 | 0.13 | | everything else combined | ~230 | ~0.00 |

One tenant is 97% of the day's replica traffic. The driver is database size, not request volume: seapointish's full-range snapshot object is 640,271,640 bytes (~610 MiB), and compaction rewrites it several times a day. Its actual write volume is low — it is a sync-every-few-hours app.

That is the exact shape batched or daily exists for: a large database with low write volume, where a one-second change-driven sync buys a loss window nobody asked for at a price nobody sees.

Worth noting alongside: internal/ops/litestream.go justifies its 7-day snapshot retention as "seven daily snapshots per database, of files measured in megabytes". For this app they are measured in hundreds of megabytes — three orders of magnitude out from the assumption the retention budget was set against.

What would close it

Smallest useful version: a carlos route flag that writes the column, and the class printed in carlos routes so the setting is visible after it is made. The activator already does the rest — it reads the column on wake and hands the interval to litestream, so no new machinery is needed on the box side.

The choice of who picks the rung is a real design question and not obviously the member's: the loss window is a durability promise the platform makes, and daily means up to a day of writes lost on a box failure. It may want to be an operator setting, or a per-tier default, rather than a member-facing knob. Filing the gap rather than the answer.

Found while investigating instance residency (see #370's neighbour on the GitHub side, and the paulca/seapointish discussion for the app-side half).

Fixed on branch route-replication-flag. CI green.

Built the smallest useful version you described, and deliberately not the design question you left open.

carlos route --host seapointish.bag.oncarlos.com --replication daily carlos route --host ... --replication - # back to the default

The column is visible at the same moment it became settable, which was the other half of the complaint: REPL in the box-local carlos instances, and REPLICATION in the console-backed carlos routes (additive on the wire). Both NAME the empty value rather than dashing it — it prints as default — for the reason your measurement gives: this is the column where "no answer" was 97% of a day's replica traffic, so a blank cell would be the same silence in a different font.

Three things worth flagging, one of which was a live bug sitting under the feature:

Upsert was a plain overwrite on replication. So even with a setter, the provisioning reconciler — which mints routes from instance records, and records carry no class — would have returned your daily route to the every-second default within seconds of the next serial bump. Silently, at 100x the traffic the setting was chosen to avoid. It now preserves a non-empty value, joining environment, caps, tunnel, provisioned, seed, unit and fleet_box. This repo's "consumer list is everything that PARSES the shape" rule, arriving for the eighth time.

The vocabulary moved to internal/registry and the activator's constants are now aliases of it. Two independent spellings of the same three strings is how a setter and a reader drift into disagreeing about what daily means, and SetReplication validates against it — replicationSyncInterval answers an unknown class with litestream's default, so an accepted --replication dayly would be a route that quietly never left the most expensive rung.

One rail: a route that does not hibernate is refused. The class is read in exactly one place, the activator's wake path, and /etc/litestream.yml deliberately EXCLUDES hibernating databases and covers everything else at its own interval. Setting a rung on a non-hibernating route would write a column nothing reads — the same silent no-op the missing door was.

Not decided: whether a member may pick a rung, or whether it belongs to a plan tier. You filed that as a real design question and it still is; the loss window is a durability promise the platform makes, and daily means up to a day of writes lost with the box. This is the operator's door only, and nothing in it forecloses either answer.

Also unaddressed, and still true: internal/ops/litestream.go justifies its 7-day snapshot retention as "files measured in megabytes", against a database measured in hundreds. Worth its own thread rather than a quiet change to a retention budget.

Triage, 2026-09-05. route-replication-flag reviewed. CI green. Verdict: merge with fixes. The door, the validation and the wire shape are right. Two of the claims around them are not.

Confirmed clean: the wire shape is additive both directions (omitempty server-side, zero-value decode client-side, "" rendering as default). Validation is on the only writer, SetReplication, plus the CLI — no console or API door writes the column, so --replication dayly cannot get through. docs/site/cli.md documents the flag under existing headings with examples that run real verbs, so the docsite gate is satisfied.

The headline claim is wrong, though the code is fine. The commit and replication_test.go both say the provisioning reconciler "re-Upserts every owned row on every pass". It does not. The only reg.Upsert in internal/provision/reconcile.go:797 is the MINT branch (case !ok — no existing row); the owned branch uses narrow setters (convergeOwned, SetHibernate, SetSeed), and adoption uses SetProvisioned. The domains reconciler's two Upserts are likewise mint-only. The one real Upsert over an existing row is carlos add.

So the excluded.replication preservation is correct and worth keeping — it is the eighth column to carry that rule — but it did not fix a live 100x-traffic regression, and a future reader who checks the claim will find it false and may revert it. Fix the two comments, keep the code and the test.

The rail is weaker than its comment. cli.go:1580 refuses a route that does not hibernate, at the moment you set it. Nothing holds that afterwards: internal/provision/reconcile.go:810-818 converges hibernate OFF from a record, and runRouteHibernate and a carlos add -hibernate=false both flip it without a word about a rung already set. The result is a route where carlos routes prints daily while box-wide /etc/litestream.yml replicates it at its own interval — a lying column, which is the failure this door exists to end, in a new place.

Related: the rail's premise misses DBPath. internal/ops/litestream.go:349 excludes only r.Hibernate && r.DBPath != "", so a hibernating route with an empty db_path (what carlos add --hibernate mints) is swept into box-wide litestream anyway. --replication daily is accepted there and buys nothing.

Two smaller ones: shapeMismatch was not updated, so a hand-set daily row is still adoptable even though replication is now exactly the kind of box-scoped column it refuses Backing/Tunnel/Caps for; and nothing asserts the console wire hop, so dropping the struct field would leave every route printing default with a green suite.

Your original design question is untouched by all of this and still open: whether a member may pick a rung, or whether it belongs to a plan tier. Nothing here forecloses either answer. Also still true and still unaddressed: internal/ops/litestream.go justifies 7-day snapshot retention as "files measured in megabytes" against a database measured in hundreds — that wants its own thread rather than a quiet change to a retention budget.