carlos / platform Public

The replication ladder gets a door, and a column anyone can read

Discussion #10: stream/batched/daily are defined, mapped to real
litestream sync intervals, and read on every wake — and nothing in the
tree could put a value in the column. A grep for anything setting
Replication: on a route returned one file, and it was an unrelated
health struct. So every route on the fleet ran on "", which
replicationSyncInterval deliberately maps to the same 0 as stream: three
named rungs, one of them reachable, and it was the most expensive one.

Measured cost, flagship, 2026-08-30: one tenant was 97% of the day's
replica writes — 4.77 GB across 11 objects — because its 610 MiB
database is compacted several times a day whether or not anything
changed. Low write volume, large file: exactly the shape batched and
daily exist for.

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

- internal/registry now OWNS the vocabulary (ReplicationStream/Batched/
  Daily + ValidReplication + ReplicationClasses); internal/activator's
  unexported constants become 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.
- SetReplication is a narrow UPDATE, like SetChannel and SetUnit, and it
  VALIDATES like SetUnit: replicationSyncInterval answers an unknown
  class with litestream's default, so `--replication dayly` would
  otherwise be written, reported as done, and discovered months later as
  a route that quietly never left the most expensive rung.
- Upsert now PRESERVES a non-empty replication, joining environment,
  caps, tunnel, provisioned, seed, unit and fleet_box. Without it the
  provisioning reconciler — which mints routes from instance records,
  and records carry no class — would return an operator's `daily` route
  to the default within seconds of the next serial bump. Silently, and
  at 100x the traffic the setting was chosen to avoid. Same shape, same
  reason, as environment.

The verb carries 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, so a rung set there would be
a column nothing reads.

And the column became visible at the same moment it became settable,
because #10's other half is that `carlos routes` could not print it:
REPL in the box-local `carlos instances` listing, REPLICATION in the
console-backed `carlos routes` (additive on the wire, omitempty). Both
name the empty value rather than dashing it — it is the rung every route
was on, it behaves exactly like stream, and "no answer" is what cost one
tenant 97% of a day's traffic.

Deliberately NOT decided here: whether a member may pick a rung, or
whether it belongs to a plan tier. The loss window is a durability
promise the platform makes, #10 filed that as an open design question,
and this is the operator's door only. Nothing here forecloses either
answer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVvK41AUKXndpcy5KkUZRS
Paul Campbell pushed by paul@keymail.dev 85b762190ba9ebfca0635234cd7f5dc94d9a2233 parent ae66fb6
9 files changed, +675 −35
  • apimode.go +1 −0
  • cli.go +140 −12
  • docs/site/cli.md +27 −3
  • internal/activator/activator.go +8 −3
  • internal/console/apiroutes.go +7 −0
  • internal/registry/registry.go +102 −9
  • internal/registry/replication_test.go +136 −0
  • route_replication_test.go +224 −0
  • routes_cli.go +30 −8
diff --git a/apimode.go b/apimode.go
index 0267d6b43..3a0186dd3 100644
--- a/apimode.go
+++ b/apimode.go
@@ -2517,6 +2517,7 @@ type apiRouteRow struct {
Unit string `json:"unit"`
Backing string `json:"backing"`
Provisioned bool `json:"provisioned"`
+ Replication string `json:"replication"`
}
// apiRoutePathRow is one path route of the same answer: a leading URL
diff --git a/cli.go b/cli.go
index 0652d5ac7..6e73df976 100644
--- a/cli.go
+++ b/cli.go
@@ -325,14 +325,23 @@ func runInstances(args []string) {
// app's default bundle, which is a real answer, so it prints blank
// rather than being singled out. Off-box, `carlos routes` answers the
// same question through the console.
- fmt.Fprintln(w, "HOST\tAPP\tKIND\tCHANNEL\tENV\tSTATUS\tVERSION\tHIB\tBACKING\tUNIT")
+ //
+ // REPL is the rung of the replication ladder this route's database is
+ // replicated on (discussion #10). It prints beside HIB because the two
+ // are one fact: the class is read only where the activator replicates,
+ // which is a hibernating route. An empty cell means the pre-ladder
+ // default — change-driven ~1s sync — and until `carlos route
+ // --replication` existed it was the honest answer for every route on
+ // every box, which is precisely why the column had to become visible at
+ // the same moment it became settable.
+ fmt.Fprintln(w, "HOST\tAPP\tKIND\tCHANNEL\tENV\tSTATUS\tVERSION\tHIB\tREPL\tBACKING\tUNIT")
for _, t := range routes {
hib := ""
if t.Hibernate {
hib = "yes"
}
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
- t.Host, t.App, t.Kind, t.Channel, t.Environment, t.Status, t.Version, hib, t.Backing, t.Unit)
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
+ t.Host, t.App, t.Kind, t.Channel, t.Environment, t.Status, t.Version, hib, t.Replication, t.Backing, t.Unit)
}
w.Flush()
}
@@ -475,6 +484,8 @@ func runRoute(args []string) {
`systemd unit that owns this route's process (e.g. console.service), so adoption can cycle it; "-" clears the stamp (host only, no --app)`)
tunnel := fs.String("tunnel", "",
`name of the reverse channel backing this route at the edge; "-" clears to a Socket/Addr-routed upstream (host only, no --app)`)
+ replication := fs.String("replication", "",
+ `replication ladder rung for this route's database: `+strings.Join(registry.ReplicationClasses(), " | ")+`; "-" clears to the default (host only, no --app)`)
allowVersionChange := fs.Bool("allow-version-change", false,
"allow repointing to a channel that RESOLVES but holds a different version than the route currently serves "+
"(never overrides an unresolvable target, a bad signature, or a missing pubkey)")
@@ -495,7 +506,11 @@ func runRoute(args []string) {
// "--unit -", the sentinel --environment and --tunnel already use, so that a
// bare `--unit ""` reads as the mistake it almost certainly is rather than
// as a silent unstamp.
- hibernateSet, backingSet, unitSet := false, false, false
+ // --replication is tri-state on --unit's exact grounds: "" is this
+ // column's default (the pre-ladder every-second sync), so a flag left
+ // unmentioned and a flag set to "" cannot be told apart by value, and its
+ // CLEAR spelling is the same "-" sentinel.
+ hibernateSet, backingSet, unitSet, replicationSet := false, false, false, false
fs.Visit(func(f *flag.Flag) {
switch f.Name {
case "hibernate":
@@ -504,6 +519,8 @@ func runRoute(args []string) {
backingSet = true
case "unit":
unitSet = true
+ case "replication":
+ replicationSet = true
}
})
@@ -513,7 +530,8 @@ func runRoute(args []string) {
" or: carlos route --host <host> (--grant | --revoke) <port:n|host-role> [--registry <db>] [--dry-run]\n" +
" or: carlos route --host <host> --hibernate[=false] [--registry <db>] [--dry-run]\n" +
" or: carlos route --host <host> --backing <unit|\"\"> [--registry <db>] [--dry-run]\n" +
- ` or: carlos route --host <host> --unit <name.service|-> [--registry <db>] [--dry-run]`
+ " or: carlos route --host <host> --unit <name.service|-> [--registry <db>] [--dry-run]\n" +
+ ` or: carlos route --host <host> --replication <stream|batched|daily|-> [--registry <db>] [--dry-run]`
if *host == "" && *app == "" {
log.Fatal(usage)
}
@@ -545,15 +563,23 @@ func runRoute(args []string) {
log.Fatal("route: --backing is mutually exclusive with --grant/--revoke and --hibernate — run them as two commands, so each meets its own rail alone. " +
"The order is not arbitrary either: --backing unit comes FIRST, because a grant onto a route that is not unit-backed is refused (it would materialize nothing)")
}
+ // --replication is held to the same exclusions as the other single-route
+ // axes, and for the reason recorded above --backing: the switch below
+ // runs one arm, so a command naming two axes would write one of them and
+ // drop the other on the floor without a word.
+ if replicationSet && (grantAxis || hibernateSet || backingSet || unitSet) {
+ log.Fatal("route: --replication is mutually exclusive with --grant/--revoke, --hibernate, --backing and --unit — run them as two commands, so each meets its own rail alone. " +
+ "--hibernate in particular decides whether this route's class is read AT ALL (the activator replicates hibernating routes; /etc/litestream.yml covers the rest), so the two in one command would leave the answer depending on which arm ran")
+ }
if unitSet && (grantAxis || hibernateSet || backingSet) {
log.Fatal("route: --unit is mutually exclusive with --grant/--revoke, --hibernate and --backing — run them as two commands, so each meets its own rail alone. " +
"--unit and --backing in particular are two different answers to the same question (who owns this route's process), and a command that wrote both would leave the row saying both")
}
- if (grantAxis || hibernateSet || backingSet || unitSet) && (*channel != "" || *environment != "" || *tunnel != "") {
- log.Fatal("route: --grant/--revoke/--hibernate/--backing/--unit cannot be combined with --channel, --environment or --tunnel — a capability grant and a repoint are different operations with different rails, and one command that did both would commit them together")
+ if (grantAxis || hibernateSet || backingSet || unitSet || replicationSet) && (*channel != "" || *environment != "" || *tunnel != "") {
+ log.Fatal("route: --grant/--revoke/--hibernate/--backing/--unit/--replication cannot be combined with --channel, --environment or --tunnel — a capability grant and a repoint are different operations with different rails, and one command that did both would commit them together")
}
- if (grantAxis || hibernateSet || backingSet || unitSet) && *host == "" {
- log.Fatal("route: --grant/--revoke/--hibernate/--backing/--unit require --host, never --app: grants, process backing and the owning unit are per route per box by design, and a bulk grant is precisely the operation nobody should be able to type by accident\n\n" + usage)
+ if (grantAxis || hibernateSet || backingSet || unitSet || replicationSet) && *host == "" {
+ log.Fatal("route: --grant/--revoke/--hibernate/--backing/--unit/--replication require --host, never --app: grants, process backing and the owning unit are per route per box by design, and a bulk grant is precisely the operation nobody should be able to type by accident\n\n" + usage)
}
// The token is checked BEFORE the registry is opened for writing: a typo
// should cost nothing and reach nothing.
@@ -598,11 +624,32 @@ func runRoute(args []string) {
log.Fatalf("route: unit %q: want a systemd service unit name (e.g. console.service), or \"-\" to clear the stamp", *unit)
}
}
+ // --replication's value is checked at the flag, before the registry is
+ // opened, on --unit's exact grounds: an unknown class reaches
+ // replicationSyncInterval, which answers anything it does not recognise
+ // with litestream's default — so `--replication dayly` would be written,
+ // reported as done, and discovered as a route that quietly never left the
+ // most expensive rung. "-" is the clear sentinel and resolves to "", the
+ // column's own default; a bare `--replication ""` is refused, because the
+ // doors that CLEAR say so out loud (see --unit).
+ replicationClear := *replication == "-"
+ resolvedReplication := *replication
+ if replicationClear {
+ resolvedReplication = ""
+ }
+ if replicationSet && !replicationClear {
+ if *replication == "" {
+ log.Fatalf(`route: --replication "" is refused — use "--replication -" to clear the rung, or name one: %s`, strings.Join(registry.ReplicationClasses(), ", "))
+ }
+ if !registry.ValidReplication(*replication) {
+ log.Fatalf("route: replication %q: want one of %s, or \"-\" to clear to the default", *replication, strings.Join(registry.ReplicationClasses(), ", "))
+ }
+ }
if *tunnel != "" && *app != "" {
log.Fatal("route: --tunnel is only supported with --host — bulk --app tunnel repointing is not implemented\n\n" + usage)
}
- if *channel == "" && *environment == "" && *tunnel == "" && !grantAxis && !hibernateSet && !backingSet && !unitSet {
- log.Fatal(`route: at least one of --channel, --environment, --tunnel, --grant, --revoke, --hibernate, --backing or --unit is required. "" only means stable-by-default at route CREATION (carlos add) — ` +
+ if *channel == "" && *environment == "" && *tunnel == "" && !grantAxis && !hibernateSet && !backingSet && !unitSet && !replicationSet {
+ log.Fatal(`route: at least one of --channel, --environment, --tunnel, --grant, --revoke, --hibernate, --backing, --unit or --replication is required. "" only means stable-by-default at route CREATION (carlos add) — ` +
"this verb sets an explicit binding, and needs an explicit target to set it to.")
}
if *channel != "" && !release.ValidChannel(*channel) {
@@ -664,7 +711,7 @@ func runRoute(args []string) {
// The single-route axes short-circuit here, before the channel/environment
// machinery below: they write one column of one row, through one narrow
// setter, and share none of that batch's all-or-nothing plumbing.
- if grantAxis || hibernateSet || backingSet || unitSet {
+ if grantAxis || hibernateSet || backingSet || unitSet || replicationSet {
rt, err := r.Get(*host)
if err != nil {
log.Fatal(err)
@@ -679,6 +726,8 @@ func runRoute(args []string) {
runRouteHibernate(r, *rt, *hibernate, *dryRun)
case backingSet:
runRouteBacking(r, *rt, *backing, *dryRun)
+ case replicationSet:
+ runRouteReplication(r, *rt, resolvedReplication, *dryRun)
default:
runRouteUnit(r, *rt, resolvedUnit, *dryRun)
}
@@ -1477,6 +1526,85 @@ func unitWord(unit string) string {
return unit
}
+// runRouteReplication puts one route on a rung of the replication ladder.
+//
+// The ladder was built and honoured and had no door: three named rungs, one
+// of them reachable, and it was the most expensive one. Every route on the
+// fleet therefore ran on the every-second default, which for one tenant was
+// about 100x the S3 traffic the alternative would have cost — a 610 MiB
+// database, low write volume, compacted several times a day (discussion
+// #10). This verb is the smallest thing that closes that: the activator
+// already reads the column on wake and hands the interval to litestream, so
+// nothing new happens on the box.
+//
+// It is deliberately the OPERATOR's door and not a member-facing knob. The
+// loss window is a durability promise the platform makes — `daily` means up
+// to a day of writes lost with the box — and whether a member may choose it,
+// or whether it belongs to a plan tier, is a design question #10 filed
+// rather than answered. Nothing here forecloses either answer.
+//
+// One rail, structural: 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 (internal/ops/litestream.go). So on a
+// non-hibernating route the column would be written, reported as set, and
+// read by nothing, which is the same silent no-op the missing door was.
+//
+// The value's shape is checked at the flag, before the registry is opened.
+func runRouteReplication(r *registry.Registry, rt registry.Route, class string, dryRun bool) {
+ if err := replicationRail(rt); err != nil {
+ log.Fatalf("route: %s: %v", rt.Host, err)
+ }
+ prefix := ""
+ if dryRun {
+ prefix = colorFor(os.Stdout).warn("(dry run)") + " "
+ }
+ if rt.Replication == class {
+ mutedf(os.Stdout, "%s: replication already %s — nothing to do\n", rt.Host, replicationWord(class))
+ return
+ }
+ if !dryRun {
+ if err := r.SetReplication(rt.Host, class); err != nil {
+ log.Fatalf("route: write: %v", err)
+ }
+ }
+ fmt.Printf("%s%s: replication %s -> %s\n", prefix, rt.Host, replicationWord(rt.Replication), replicationWord(class))
+ // Said every time, because the write is not the change: the activator
+ // hands the interval to litestream when it wakes the instance, so an
+ // instance that is awake right now keeps its current interval until it
+ // next sleeps and wakes.
+ fmt.Printf(" takes effect on this instance's next wake — the sync interval is handed to litestream at wake, so an instance awake now keeps the old one until it sleeps\n")
+}
+
+// replicationRail is runRouteReplication's one refusal, split out as a pure
+// function for the reason unitRail and backingRail are: a rail nobody can
+// exercise without running the process is a rail nobody tests.
+func replicationRail(rt registry.Route) error {
+ if !rt.Hibernate {
+ return fmt.Errorf("route does not hibernate — the replication class is read only where the activator replicates, on wake, and /etc/litestream.yml (which covers every NON-hibernating database on this box) has its own interval. "+
+ "Setting a rung here would write a column nothing reads. Use `carlos route --host %s --hibernate` first if this route should be activator-managed", rt.Host)
+ }
+ return nil
+}
+
+// replicationWord renders a replication column value for an operator — "" is
+// this column's default and a real answer, and printing it as "" reads as a
+// bug rather than as the rung every route was on before there was a door.
+func replicationWord(class string) string {
+ if class == "" {
+ return `"" (the default: change-driven ~1s sync, same as stream)`
+ }
+ switch class {
+ case registry.ReplicationStream:
+ return "stream (change-driven ~1s — loss window seconds)"
+ case registry.ReplicationBatched:
+ return "batched (at most every 5m — loss window minutes)"
+ case registry.ReplicationDaily:
+ return "daily (at wake and every 24h — loss window up to a day)"
+ }
+ return class
+}
+
// backingWord renders a backing column value for an operator — the empty
// string is a real value of this column, and printing it as "" reads as a
// bug rather than as an answer.
diff --git a/docs/site/cli.md b/docs/site/cli.md
index 22561c166..754ee0636 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -431,9 +431,12 @@ point them at the steering hostname the app's Settings page names.
### carlos routes
The app's routes on this deployment: where each one sends traffic, which
-channel it follows, and which config environment it is bound to. That last
-column is the one worth knowing about, because it decides which sealed
-secrets the route's process is delivered.
+channel it follows, which config environment it is bound to, and how often
+its database is replicated off the box. The environment column is the one
+worth knowing about, because it decides which sealed secrets the route's
+process is delivered. The replication column reads `default` until somebody
+sets a rung with `carlos route --replication`; the default syncs on every
+change.
```sh
carlos routes --app hello
@@ -739,6 +742,27 @@ carlos route --host hello.example.com --channel edge
when idle. `--backing` and `--unit` are two answers to one question — who owns
this route's process — so the command will not let you write both.
+`--replication` picks how often the route's database is copied off the box:
+`stream` syncs on every change, about once a second; `batched` at most every
+five minutes; `daily` at wake and then once a day. The rung you pick is a
+trade, and it is the loss window you are trading. `daily` on a box that dies
+loses up to a day of writes. What it buys is real money on a large database
+that is written rarely — a 600 MB file compacted a few times a day costs the
+same in traffic whether or not anything changed, and that traffic was 97% of
+one day's replica writes across a whole fleet before anyone could turn it
+down.
+
+```sh
+carlos route --host hello.example.com --replication batched
+```
+
+`--replication -` puts the route back on the default, which behaves exactly
+like `stream`. It only applies to routes that hibernate: those are the ones
+the activator replicates, and everything else on the box is covered by the
+box's own replicator at its own interval. The command refuses a route that
+does not hibernate rather than writing a setting nothing would read. A change
+takes effect the next time the instance wakes.
+
Repointing the channel is the half to be careful with. Adoption converges a
route onto whatever its channel currently points at, so moving the channel
changes what production serves on the next pass; the command resolves the
diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index 41f326995..f1b545544 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -208,10 +208,15 @@ type Proc interface {
// Replication classes — the registry's routes.replication vocabulary
// ("" | stream | batched | daily), mapped to litestream sync intervals by
// replicationSyncInterval.
+//
+// Aliases, not copies: internal/registry owns the vocabulary now that
+// `carlos route --replication` writes the column against it (discussion
+// #10), and two independent spellings of the same three strings is exactly
+// how a setter and a reader drift into disagreeing about what `daily` means.
const (
- 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
+ replicationStream = registry.ReplicationStream // change-driven ~1s sync — loss window seconds
+ replicationBatched = registry.ReplicationBatched // sync at most every 5m — loss window minutes
+ replicationDaily = registry.ReplicationDaily // sync at wake + every 24h — loss window up to a day
)
// replicationSyncInterval maps a class to the litestream replica sync
diff --git a/internal/console/apiroutes.go b/internal/console/apiroutes.go
index 4123bee93..bf16ca8dc 100644
--- a/internal/console/apiroutes.go
+++ b/internal/console/apiroutes.go
@@ -61,6 +61,12 @@ type apiRoute struct {
Unit string `json:"unit,omitempty"`
Backing string `json:"backing,omitempty"`
Provisioned bool `json:"provisioned"`
+ // Replication is the rung of the replication ladder this route's
+ // database is replicated on — "" (the pre-ladder default), stream,
+ // batched or daily. Additive, omitempty: an older client decodes the
+ // absent field as "", which is exactly what every route carried before
+ // there was a door onto the column (discussion #10).
+ Replication string `json:"replication,omitempty"`
}
// apiRoutePath is one path route: a leading URL segment on Host that is
@@ -145,6 +151,7 @@ func (s *Server) handleAPIRoutes(w http.ResponseWriter, r *http.Request) {
Channel: rt.Channel, Environment: rt.Environment, Status: rt.Status,
Health: health[rt.Host], Hibernate: rt.Hibernate, Unit: rt.Unit,
Backing: rt.Backing, Provisioned: rt.Provisioned,
+ Replication: rt.Replication,
})
}
// Sorted by host: registry order is not stable across passes, and a
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index 07baf89ce..5e4dcf188 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -121,9 +121,21 @@ type Route struct {
// routing, and the edge has no reason to reload for one.
Caps string
- Hibernate bool
- Canary bool
- Replication string // "" | "stream" | "batched" | "daily"
+ Hibernate bool
+ Canary bool
+ // Replication is the route's litestream sync class — the rung of the
+ // replication ladder its database is replicated on. The vocabulary is
+ // the four Replication* constants below, and internal/activator maps it
+ // to a real sync interval on every wake. "" is not a fifth rung: it is
+ // the pre-ladder default, and it deliberately buys what stream buys, so
+ // a registry written before the column existed keeps its behaviour.
+ //
+ // Upsert PRESERVES a non-empty value (see its doc comment), which is
+ // what stops the provisioning reconciler — which mints routes from
+ // instance records, and records carry no replication class — from
+ // silently returning an operator's `daily` route to the default within
+ // seconds of the next serial bump.
+ Replication string
NextDue int64
StatusNamed bool // publish this route's app by name on the status page — the app's own opt-in
@@ -303,6 +315,45 @@ func ValidUnitName(name string) bool {
return name == "" || unitNameRe.MatchString(name)
}
+// The replication ladder — the vocabulary of the routes.replication column,
+// and the ONE place it is written down. internal/activator maps these to
+// litestream sync intervals; `carlos route --replication` is the door that
+// sets one. They lived only in the activator, unexported, for as long as
+// nothing could set the column (discussion #10): three named rungs, one of
+// them reachable, and it was the most expensive one.
+const (
+ // ReplicationDefault is the unset column. It buys what ReplicationStream
+ // buys — change-driven ~1s sync — so a registry written before the ladder
+ // existed behaves exactly as it did. It is the pre-ladder default rather
+ // than a rung anyone chooses.
+ ReplicationDefault = ""
+ // ReplicationStream syncs on change, ~1s. Loss window: seconds.
+ ReplicationStream = "stream"
+ // ReplicationBatched syncs at most every 5m. Loss window: minutes.
+ ReplicationBatched = "batched"
+ // ReplicationDaily syncs at wake and every 24h. Loss window: up to a day.
+ ReplicationDaily = "daily"
+)
+
+// ReplicationClasses lists the rungs an operator may set, in order of
+// decreasing durability. ReplicationDefault is absent on purpose: it is what
+// a route has before anyone chooses, and the way back to it is the CLI's
+// clear sentinel, not a name to type.
+func ReplicationClasses() []string {
+ return []string{ReplicationStream, ReplicationBatched, ReplicationDaily}
+}
+
+// ValidReplication reports whether class is a value this column may hold.
+// "" is valid: it is the column's default and the state a cleared route
+// returns to, exactly as ValidUnitName accepts "".
+func ValidReplication(class string) bool {
+ switch class {
+ case ReplicationDefault, ReplicationStream, ReplicationBatched, ReplicationDaily:
+ return true
+ }
+ return false
+}
+
func Open(path string) (*Registry, error) {
d, err := sql.Open("sqlite", path)
if err != nil {
@@ -704,10 +755,10 @@ func (r *Registry) Delete(host string) error {
return err
}
-// Upsert inserts or updates a route by host. Seven columns are deliberately
+// Upsert inserts or updates a route by host. Eight columns are deliberately
// not plain overwrites — `environment`, `caps`, `tunnel`, `provisioned`,
-// `seed`, `unit` and `fleet_box` all keep the existing row's value when the
-// incoming Route carries the zero one.
+// `seed`, `unit`, `fleet_box` and `replication` all keep the existing row's
+// value when the incoming Route carries the zero one.
//
// The reason is the same for all seven, and it is structural: a full-row
// Upsert caller (`carlos add`, the domains reconciler's two mint paths, the
@@ -734,12 +785,20 @@ func (r *Registry) Delete(host string) error {
// the next promote would stamp X-Carlos-Version onto a console still serving
// the old build, which is issue #110 returning silently.
//
+// `replication` joined them when the ladder got a door (discussion #10). It
+// is the same shape as `environment` and the reason is the same sentence:
+// the provisioning reconciler mints routes from instance records, records
+// carry no replication class, and a plain excluded.replication would return
+// an operator's `daily` route to the every-second default within seconds of
+// the next serial bump — silently, and at roughly 100x the S3 traffic the
+// setting was chosen to avoid.
+//
// An explicit non-zero value still wins in every case; clearing keeps
// exactly one door per column, none of which goes through Upsert:
// SetEnvironment (`carlos route -environment -`), SetCaps (`carlos route
// -revoke`), SetTunnel (`carlos route -tunnel -`), SetUnit (`carlos route
-// -unit -`), SetSeed, SetFleetBox, and for `provisioned` the row's own
-// deletion.
+// -unit -`), SetReplication (`carlos route -replication -`), SetSeed,
+// SetFleetBox, and for `provisioned` the row's own deletion.
//
// `addrs` and `health` (multi-upstream routing, issue #152) are, by
// contrast, PLAIN overwrites — see their ON CONFLICT clause below — same as
@@ -800,12 +859,13 @@ ON CONFLICT(host) DO UPDATE SET
prev_version=excluded.prev_version, app=excluded.app, pool=excluded.pool,
region=excluded.region, channel=excluded.channel,
hibernate=excluded.hibernate, canary=excluded.canary,
- replication=excluded.replication, next_due=excluded.next_due,
+ next_due=excluded.next_due,
backing=excluded.backing, status_named=excluded.status_named,
platform=excluded.platform, dns_status=excluded.dns_status,
dns_checked_at=excluded.dns_checked_at,
cert_expires_at=excluded.cert_expires_at, account=excluded.account,
environment=CASE WHEN excluded.environment = '' THEN routes.environment ELSE excluded.environment END,
+ replication=CASE WHEN excluded.replication = '' THEN routes.replication ELSE excluded.replication END,
caps=CASE WHEN excluded.caps = '' THEN routes.caps ELSE excluded.caps END,
tunnel=CASE WHEN excluded.tunnel = '' THEN routes.tunnel ELSE excluded.tunnel END,
provisioned=CASE WHEN excluded.provisioned = 0 THEN routes.provisioned ELSE excluded.provisioned END,
@@ -1045,6 +1105,39 @@ func (r *Registry) SetUnit(host, unit string) error {
return nil
}
+// SetReplication puts a route on a rung of the replication ladder, as a
+// narrow UPDATE for the reason SetChannel and SetUnit are: a
+// read-modify-write Upsert racing a deploy's SetVersions or the activator's
+// SetNextDue would clobber those columns.
+//
+// It validates, like SetUnit and unlike SetBacking: the value reaches
+// internal/activator's replicationSyncInterval, which answers an unknown
+// class with litestream's default — so `--replication dayly` would be
+// accepted, written, and discovered as a route that quietly never left the
+// most expensive rung. That is the failure the column's whole existence is
+// meant to end.
+//
+// "" is a legal value: it is the pre-ladder default a cleared route returns
+// to. It reaches here only from `carlos route --replication -`, never from
+// a full-row Upsert, which preserves the column instead.
+func (r *Registry) SetReplication(host, class string) error {
+ if !ValidReplication(class) {
+ return fmt.Errorf("replication %q: want one of %s, or \"\" for the default", class, strings.Join(ReplicationClasses(), ", "))
+ }
+ res, err := r.sql.Exec(`UPDATE routes SET replication = ? WHERE host = ?`, class, host)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("no route for host %q", host)
+ }
+ return nil
+}
+
// SetUpstreams repoints a route's multi-upstream set as a narrow UPDATE —
// same rationale as SetChannel/SetUnit: a read-modify-write Upsert racing a
// deploy's SetVersions or the activator's SetNextDue would clobber those
diff --git a/internal/registry/replication_test.go b/internal/registry/replication_test.go
new file mode 100644
index 000000000..a904aa9aa
--- /dev/null
+++ b/internal/registry/replication_test.go
@@ -0,0 +1,136 @@
+package registry
+
+import (
+ "strings"
+ "testing"
+)
+
+// TestUpsertPreservesReplication is the fence that makes the new door worth
+// anything. The provisioning reconciler mints routes from instance records,
+// records carry no replication class, and it re-Upserts every owned row on
+// every pass. With a plain excluded.replication an operator's `daily` route
+// would be back on the every-second default within seconds of the next serial
+// bump — silently, and at roughly 100x the S3 traffic the setting was chosen
+// to avoid (discussion #10). Same shape, same reason, as `environment`.
+func TestUpsertPreservesReplication(t *testing.T) {
+ r := openTestRegistry(t)
+ host := "seapointish.example.com"
+ if err := r.Upsert(Route{Host: host, Kind: "instance", App: "seapointish", Hibernate: true}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ if err := r.SetReplication(host, ReplicationDaily); err != nil {
+ t.Fatalf("SetReplication: %v", err)
+ }
+
+ // The reconciler's mint: a full-row Upsert built from an instance record,
+ // which knows nothing about this column.
+ if err := r.Upsert(Route{Host: host, Kind: "instance", App: "seapointish", Hibernate: true, Channel: "edge"}); err != nil {
+ t.Fatalf("re-upsert: %v", err)
+ }
+ rt, err := r.Get(host)
+ if err != nil || rt == nil {
+ t.Fatalf("get: %+v %v", rt, err)
+ }
+ if rt.Replication != ReplicationDaily {
+ t.Fatalf("replication = %q after a re-mint, want %q — a reconcile pass silently returned the route to the most expensive rung", rt.Replication, ReplicationDaily)
+ }
+ if rt.Channel != "edge" {
+ t.Fatalf("channel = %q, want the re-mint's own value: preserving replication must not freeze the columns the reconciler DOES own", rt.Channel)
+ }
+}
+
+// TestUpsertHonoursAnExplicitReplication: preservation is for the zero value
+// only. A caller that carries a class still wins, exactly as with environment.
+func TestUpsertHonoursAnExplicitReplication(t *testing.T) {
+ r := openTestRegistry(t)
+ host := "a.example.com"
+ if err := r.Upsert(Route{Host: host, Replication: ReplicationDaily}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ if err := r.Upsert(Route{Host: host, Replication: ReplicationBatched}); err != nil {
+ t.Fatalf("re-upsert: %v", err)
+ }
+ rt, _ := r.Get(host)
+ if rt.Replication != ReplicationBatched {
+ t.Fatalf("replication = %q, want %q", rt.Replication, ReplicationBatched)
+ }
+}
+
+// TestSetReplicationValidates: the value reaches internal/activator's
+// replicationSyncInterval, which answers anything it does not recognise with
+// litestream's default — so an unvalidated write is a route that quietly
+// never leaves the most expensive rung. SetUnit validates for the same
+// reason; SetBacking does not need to, because its two legal values are
+// compared at every door.
+func TestSetReplicationValidates(t *testing.T) {
+ r := openTestRegistry(t)
+ host := "a.example.com"
+ if err := r.Upsert(Route{Host: host}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ err := r.SetReplication(host, "dayly")
+ if err == nil {
+ t.Fatal("SetReplication accepted an unknown class")
+ }
+ for _, want := range ReplicationClasses() {
+ if !strings.Contains(err.Error(), want) {
+ t.Errorf("refusal %q does not list %q", err, want)
+ }
+ }
+ rt, _ := r.Get(host)
+ if rt.Replication != "" {
+ t.Fatalf("a refused write still landed: %q", rt.Replication)
+ }
+
+ // "" is legal: it is the column's default and the state a cleared route
+ // returns to.
+ if err := r.SetReplication(host, ReplicationBatched); err != nil {
+ t.Fatalf("SetReplication batched: %v", err)
+ }
+ if err := r.SetReplication(host, ReplicationDefault); err != nil {
+ t.Fatalf("SetReplication to the default: %v", err)
+ }
+ rt, _ = r.Get(host)
+ if rt.Replication != "" {
+ t.Fatalf("clear left %q", rt.Replication)
+ }
+}
+
+// TestSetReplicationUnknownHost: like SetChannel and SetUnit, this is a
+// setter a human types a hostname into, so an unknown host is an error rather
+// than a silent no-op.
+func TestSetReplicationUnknownHost(t *testing.T) {
+ r := openTestRegistry(t)
+ err := r.SetReplication("nope.example.com", ReplicationDaily)
+ if err == nil || !strings.Contains(err.Error(), "nope.example.com") {
+ t.Fatalf("SetReplication(unknown host) = %v, want an error naming the host", err)
+ }
+}
+
+// TestValidReplicationVocabulary pins the set itself. It lives here now
+// rather than unexported in internal/activator, because a setter and a reader
+// with two independent spellings of the same three strings is how they drift
+// into disagreeing about what `daily` means.
+func TestValidReplicationVocabulary(t *testing.T) {
+ for _, ok := range []string{"", "stream", "batched", "daily"} {
+ if !ValidReplication(ok) {
+ t.Errorf("ValidReplication(%q) = false", ok)
+ }
+ }
+ for _, bad := range []string{"dayly", "STREAM", "hourly", "none", "5m"} {
+ if ValidReplication(bad) {
+ t.Errorf("ValidReplication(%q) = true", bad)
+ }
+ }
+ // The default is deliberately absent from the offered list: it is what a
+ // route has before anyone chooses, and the way back to it is the CLI's
+ // clear sentinel, not a name to type.
+ for _, c := range ReplicationClasses() {
+ if c == ReplicationDefault {
+ t.Errorf("ReplicationClasses() offers the default as a rung to type")
+ }
+ }
+ if len(ReplicationClasses()) != 3 {
+ t.Errorf("ReplicationClasses() = %v, want the three rungs", ReplicationClasses())
+ }
+}
diff --git a/route_replication_test.go b/route_replication_test.go
new file mode 100644
index 000000000..3837f3e04
--- /dev/null
+++ b/route_replication_test.go
@@ -0,0 +1,224 @@
+package main
+
+// `carlos route --replication` — the door the replication ladder did not
+// have. The ladder was built and honoured and unreachable: three named
+// rungs, one of them reachable, and it was the most expensive one
+// (discussion #10).
+
+import (
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// TestReplicationRailRefusesNonHibernatingRoutes pins the one refusal this
+// verb has, as a pure function for the reason unitRail and backingRail are:
+// 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. On a route that does not hibernate the
+// column would be written, reported as set, and read by nothing, which is the
+// same silent no-op the missing door was.
+func TestReplicationRailRefusesNonHibernatingRoutes(t *testing.T) {
+ awake := registry.Route{
+ Host: "console.carloku.com", Kind: "instance", App: "console",
+ Addr: "127.0.0.1:8090",
+ }
+ err := replicationRail(awake)
+ if err == nil {
+ t.Fatal("replicationRail(non-hibernating route) accepted; want a refusal")
+ }
+ for _, want := range []string{"hibernate", "litestream"} {
+ if !strings.Contains(err.Error(), want) {
+ t.Errorf("refusal %q does not mention %q — the operator has to be told which replicator owns this database instead", err, want)
+ }
+ }
+
+ hibernating := awake
+ hibernating.Hibernate = true
+ if err := replicationRail(hibernating); err != nil {
+ t.Fatalf("replicationRail(hibernating route) = %v, want accepted", err)
+ }
+}
+
+// TestReplicationWordNamesTheDefault: "" is this column's value on every
+// route that predates the door, it behaves exactly like stream, and printing
+// it as an empty string reads as a bug rather than as the answer it is.
+func TestReplicationWordNamesTheDefault(t *testing.T) {
+ if got := replicationWord(""); !strings.Contains(got, "default") {
+ t.Errorf(`replicationWord("") = %q, want it to name the default`, got)
+ }
+ for _, class := range registry.ReplicationClasses() {
+ got := replicationWord(class)
+ if !strings.Contains(got, class) {
+ t.Errorf("replicationWord(%q) = %q, want it to name the rung", class, got)
+ }
+ if !strings.Contains(got, "loss window") {
+ t.Errorf("replicationWord(%q) = %q, want the loss window spelled out — that is the whole trade", class, got)
+ }
+ }
+}
+
+// TestReplicationCellNamesTheDefault: the console-side listing has the same
+// duty. Unlike ENVIRONMENT, whose empty value is dashed, this one is named:
+// it is the column where "no answer" cost one tenant 97% of a day's replica
+// traffic.
+func TestReplicationCellNamesTheDefault(t *testing.T) {
+ if got := replicationCell(""); got != "default" {
+ t.Errorf(`replicationCell("") = %q, want "default"`, got)
+ }
+ if got := replicationCell(registry.ReplicationDaily); got != "daily" {
+ t.Errorf("replicationCell(daily) = %q", got)
+ }
+}
+
+// TestRouteReplicationSetsAndClearsTheRung drives the whole door as a
+// subprocess, the shape it ships in: the routes that need a cheaper rung
+// already exist on live boxes, so this stamps a LIVE registry row rather than
+// needing the route recreated.
+//
+// It pins the failure a careless door would cause as much as the one the
+// missing door did: replicationSyncInterval answers an unknown class with
+// litestream's default, so an accepted typo is a route that quietly never
+// left the most expensive rung.
+func TestRouteReplicationSetsAndClearsTheRung(t *testing.T) {
+ bin := buildCarlos(t)
+ tmp := t.TempDir()
+ scratch := t.TempDir()
+ regPath := filepath.Join(tmp, "registry.db")
+
+ run := func(args ...string) (string, error) {
+ t.Helper()
+ cmd := exec.Command(bin, args...)
+ cmd.Env = carlosTestEnv(t)
+ // pick() walks cwd ancestors for .carlos/config, so the subprocess
+ // must not run from the real checkout.
+ cmd.Dir = scratch
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+ replicationOf := func(host string) string {
+ t.Helper()
+ reg, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatalf("open registry: %v", err)
+ }
+ defer reg.Close()
+ rt, err := reg.Get(host)
+ if err != nil || rt == nil {
+ t.Fatalf("get %s: %+v, %v", host, rt, err)
+ }
+ return rt.Replication
+ }
+
+ // A hibernating instance as one exists on a box today: a large database,
+ // low write volume, on the every-second default because nothing could say
+ // otherwise.
+ if out, err := run("add", "-registry", regPath, "-app", "seapointish",
+ "-socket", filepath.Join(tmp, "s.sock"), "-hibernate", "seapointish.example.com"); err != nil {
+ t.Fatalf("add route: %v\n%s", err, out)
+ }
+ if got := replicationOf("seapointish.example.com"); got != "" {
+ t.Fatalf("a fresh route already carries a rung: %q", got)
+ }
+
+ // A typo must cost nothing and reach nothing.
+ out, err := run("route", "-registry", regPath, "-host", "seapointish.example.com", "-replication", "dayly")
+ if err == nil {
+ t.Fatalf("route --replication dayly: want non-zero exit, got 0\n%s", out)
+ }
+ if got := replicationOf("seapointish.example.com"); got != "" {
+ t.Fatalf("a refused --replication still wrote %q", got)
+ }
+
+ // A bare "" is refused rather than silently treated as a clear: the doors
+ // that clear say so out loud.
+ if out, err := run("route", "-registry", regPath, "-host", "seapointish.example.com", "-replication", ""); err == nil {
+ t.Fatalf("route --replication \"\": want non-zero exit, got 0\n%s", out)
+ }
+
+ // -dry-run runs the rail and prints the plan, writing nothing.
+ out, err = run("route", "-registry", regPath, "-host", "seapointish.example.com",
+ "-replication", "daily", "-dry-run")
+ if err != nil {
+ t.Fatalf("route --replication --dry-run: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "daily") {
+ t.Errorf("dry run output = %q, want it to name the rung", out)
+ }
+ if got := replicationOf("seapointish.example.com"); got != "" {
+ t.Fatalf("--dry-run wrote %q", got)
+ }
+
+ // The real write.
+ if out, err := run("route", "-registry", regPath, "-host", "seapointish.example.com",
+ "-replication", "daily"); err != nil {
+ t.Fatalf("route --replication daily: %v\n%s", err, out)
+ }
+ if got := replicationOf("seapointish.example.com"); got != registry.ReplicationDaily {
+ t.Fatalf("replication = %q, want %q", got, registry.ReplicationDaily)
+ }
+
+ // And the way back, which is the sentinel rather than a name to type.
+ if out, err := run("route", "-registry", regPath, "-host", "seapointish.example.com",
+ "-replication", "-"); err != nil {
+ t.Fatalf("route --replication -: %v\n%s", err, out)
+ }
+ if got := replicationOf("seapointish.example.com"); got != "" {
+ t.Fatalf("--replication - left %q, want the default", got)
+ }
+
+ // The listing has to answer the question the setter just decided, or an
+ // operator cannot see what they set — which was half of #10's complaint.
+ if out, err := run("route", "-registry", regPath, "-host", "seapointish.example.com",
+ "-replication", "batched"); err != nil {
+ t.Fatalf("route --replication batched: %v\n%s", err, out)
+ }
+ out, err = run("instances", "-registry", regPath)
+ if err != nil {
+ t.Fatalf("instances: %v\n%s", err, out)
+ }
+ if !strings.Contains(out, "REPL") || !strings.Contains(out, "batched") {
+ t.Errorf("`carlos instances` does not show the rung:\n%s", out)
+ }
+}
+
+// TestRouteReplicationRefusesCombinationWithOtherAxes: the dispatch runs one
+// arm, so a command naming two axes would write one and drop the other on the
+// floor without a word — the silent partial --backing's exclusions were added
+// for. --app is refused outright: a bulk durability change is precisely the
+// operation nobody should be able to type by accident.
+func TestRouteReplicationRefusesCombinationWithOtherAxes(t *testing.T) {
+ bin := buildCarlos(t)
+ tmp := t.TempDir()
+ scratch := t.TempDir()
+ regPath := filepath.Join(tmp, "registry.db")
+
+ run := func(args ...string) (string, error) {
+ t.Helper()
+ cmd := exec.Command(bin, args...)
+ cmd.Env = carlosTestEnv(t)
+ cmd.Dir = scratch
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+ if out, err := run("add", "-registry", regPath, "-app", "a",
+ "-socket", filepath.Join(tmp, "a.sock"), "-hibernate", "a.example.com"); err != nil {
+ t.Fatalf("add: %v\n%s", err, out)
+ }
+ for _, extra := range [][]string{
+ {"-hibernate=false"},
+ {"-channel", "edge"},
+ {"-unit", "a.service"},
+ } {
+ args := append([]string{"route", "-registry", regPath, "-host", "a.example.com", "-replication", "daily"}, extra...)
+ if out, err := run(args...); err == nil {
+ t.Errorf("route %v: want a refusal, got 0\n%s", extra, out)
+ }
+ }
+ if out, err := run("route", "-registry", regPath, "-app", "a", "-replication", "daily"); err == nil {
+ t.Errorf("route --app --replication: want a refusal, got 0\n%s", out)
+ }
+}
diff --git a/routes_cli.go b/routes_cli.go
index 209d3adb9..6e5a3a9b7 100644
--- a/routes_cli.go
+++ b/routes_cli.go
@@ -58,28 +58,38 @@ func runRoutes(args []string) {
}
shown := 0
w := newTable(stdout)
- fmt.Fprintln(w, "HOST\tKIND\tUPSTREAM\tCHANNEL\tENVIRONMENT\tSTATUS\tHEALTH")
+ fmt.Fprintln(w, "HOST\tKIND\tUPSTREAM\tCHANNEL\tENVIRONMENT\tREPLICATION\tSTATUS\tHEALTH")
for _, r := range rows {
if *match != "" && !strings.Contains(r.Host, *match) {
continue
}
shown++
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
r.Host, dashIfEmpty(r.Kind), dashIfEmpty(r.Upstream), dashIfEmpty(r.Channel),
// An empty ENVIRONMENT is a real answer — the route takes the
// app's default bundle — so it prints as a dash rather than a
// blank cell a reader would take for missing data.
- dashIfEmpty(r.Environment), dashIfEmpty(r.Status), dashIfEmpty(r.Health))
+ dashIfEmpty(r.Environment),
+ // REPLICATION reads the same way: empty is the pre-ladder
+ // default (change-driven ~1s sync), which is what every route
+ // carried until `carlos route --replication` gave the column a
+ // door (discussion #10). It prints "default" rather than a
+ // dash, because unlike ENVIRONMENT the empty value has a name
+ // and a behaviour worth saying out loud — this is the column
+ // where "no answer" cost one tenant 97% of a day's replica
+ // traffic.
+ replicationCell(r.Replication),
+ dashIfEmpty(r.Status), dashIfEmpty(r.Health))
// Path routes indented under the hostname whose traffic they split.
// A flipped prefix set has to be visible from the command operators
// already use, or a migration is invisible until something breaks.
//
// KIND says "path" and UPSTREAM names the TARGET ROUTE rather than
// an address, because that is all a path row is: a pointer, whose
- // channel, environment and health are the target's own row's, listed
- // wherever that route appears. Dashes rather than blanks in the
- // remaining columns, for the reason ENVIRONMENT prints one above —
- // a blank cell reads as data this client failed to fetch.
+ // channel, environment, replication and health are the target's own
+ // row's, listed wherever that route appears. Dashes rather than
+ // blanks in the remaining columns, for the reason ENVIRONMENT prints
+ // one above — a blank cell reads as data this client failed to fetch.
//
// Read-only, here and everywhere in the CLI: the tenant's own
// platform publishes these wholesale and is their source of truth,
@@ -88,7 +98,7 @@ func runRoutes(args []string) {
if p.Host != r.Host {
continue
}
- fmt.Fprintf(w, " /%s\tpath\t%s\t—\t—\t—\t—\n", p.Prefix, dashIfEmpty(p.Target))
+ fmt.Fprintf(w, " /%s\tpath\t%s\t—\t—\t—\t—\t—\n", p.Prefix, dashIfEmpty(p.Target))
}
}
w.Flush()
@@ -101,6 +111,18 @@ func runRoutes(args []string) {
mutedf(stdout, "(routes on this deployment's own box; other boxes in a fleet are not listed)")
}
+// replicationCell renders the replication rung. The empty column is named
+// rather than dashed: it is the default every route was on before the ladder
+// had a door, it behaves exactly like "stream", and a reader deciding
+// whether a large low-write database is costing more than it should needs to
+// see that stated, not inferred from a blank.
+func replicationCell(class string) string {
+ if class == "" {
+ return "default"
+ }
+ return class
+}
+
// dashIfEmpty renders an empty cell as an em dash, so a column that is
// legitimately blank reads as an answer rather than as missing data.
func dashIfEmpty(s string) string {