Flush-only class, first review answered within the approved behaviour: not offered to operators, unit-backed routes refused at the rail, the note and comments say what litestream does, the test checks rows and log files
Opus accepted with fixes, Astra rejected: the ten-year interval does not give 'wake and sleep only' (the snapshot monitor uploads daily; the awake backlog waits for a thirty-second flush at sleep; the local log is not trimmed). What the class should promise is a decision (10a), so nothing of its options is built: the class stays a word the column may hold and the activator maps, withdrawn from the rungs on offer and refused at the CLI. The rail now refuses any rung on a unit-backed route (the activator does not replicate those). The lease test's control waits for the awake rows to restore, and its long case counts log files only. The vocabularies are checked in both directions. The inherited Close defect (Astra 1) is stated open and filed for its own fix.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
12 files changed,
+463
−70
cli.go+24 −0docs/superpowers/specs/2026-10-02-flush-only-replication-class.md+29 −16docs/superpowers/specs/CODEX-FINDINGS-flush-only-class-review1.md+118 −0docs/superpowers/specs/OPUS-REVIEW-flush-only-class-review1.md+128 −0internal/activator/activator.go+16 −6internal/activator/activator_test.go+3 −2internal/console/apiroutes.go+2 −1internal/entitlement/replication_vocab_test.go+24 −0internal/lease/flushonly_test.go+53 −33internal/registry/registry.go+21 −9internal/registry/replication_test.go+15 −3route_replication_test.go+30 −0
CI passed — run details
diff --git a/cli.go b/cli.go| index cb53a0d3b..6f9e630fa 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -647,6 +647,9 @@ func runRoute(args []string) { |
| 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 err := replicationOffered(*replication); err != nil { |
| + log.Fatalf("route: %v", err) |
| + } |
| } |
| if *tunnel != "" && *app != "" { |
| log.Fatal("route: --tunnel is only supported with --host — bulk --app tunnel repointing is not implemented\n\n" + usage) |
| @@ -1682,6 +1685,13 @@ func runRouteReplication(r *registry.Registry, rt registry.Route, class string, |
| // 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 { |
| + // A unit-backed route's database is not replicated by the activator at |
| + // all (its lease is inert: the unit's own replication owns the file), |
| + // so a rung written here would be listed and honoured by nothing. |
| + // (Review of the flush-only class, 2026-10-02: the rail accepted it.) |
| + if rt.Backing == registry.BackingUnit { |
| + return fmt.Errorf("route is unit-backed — the activator does not replicate a unit-backed route's database (the unit's own replication does), so a rung set here would be written, listed and honoured by nothing") |
| + } |
| 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) |
| @@ -1722,10 +1732,24 @@ func replicationWord(class string) string { |
| 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)" |
| + case registry.ReplicationHibernateFlush: |
| + // Not a rung on offer (replicationOffered); named for a listing |
| + // that meets it. |
| + return "hibernate-flush (after wake and at sleep; not offered: its contract is not decided — loss window not stated)" |
| } |
| return class |
| } |
| +// replicationOffered refuses a class the column may hold but an operator |
| +// may not set. One today: the flush-only class, whose durability contract |
| +// is not decided. |
| +func replicationOffered(class string) error { |
| + if registry.ValidReplication(class) && !registry.ReplicationOffered(class) && class != "" { |
| + return fmt.Errorf("replication %q is not offered: what it promises is not decided. want one of %s, or \"-\" to clear to the default", class, strings.Join(registry.ReplicationClasses(), ", ")) |
| + } |
| + return nil |
| +} |
| + |
| // 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/superpowers/specs/2026-10-02-flush-only-replication-class.md b/docs/superpowers/specs/2026-10-02-flush-only-replication-class.md| index 698629072..9948182d8 100644 |
| --- a/docs/superpowers/specs/2026-10-02-flush-only-replication-class.md |
| +++ b/docs/superpowers/specs/2026-10-02-flush-only-replication-class.md |
| @@ -1,29 +1,42 @@ |
| # The flush-only replication class |
| -Status: built on `feat/carloku-flush-only-class-20261002` (off the paid-launch branch), 2026-10-02. Nothing sets the class from a plan; nothing is deployed. |
| +Status: on `feat/carloku-flush-only-class-20261002`, 2026-10-02. **Not mergeable as a feature: its first review (Opus accept with fixes, Astra reject; both reports in this directory) showed that what the class promises is not what the vendored litestream does, and what it should promise is a decision (`DECISIONS-FOR-PAUL.md` 10a on the launch branch).** Until that is decided the class is a word the registry's column may hold and the activator maps; it is not offered to operators and nothing sets it. Nothing is deployed. |
| -The approved pricing design (`2026-09-07-carloku-pricing-plans-design.md`, "The free tier and the pool") says: "Durability on free: hibernate-flush only", and "This needs a new replication class. The existing classes are stream, five-minute and daily, and the unset value means stream, so a free app left alone would replicate continuously, the opposite of the intent. Flush-only must be added as a class and propagated from the account's plan." This branch adds the class. It does not propagate it: which accounts are Free, and from when, is not decided (`OUTSTANDING.md` 15). |
| +The approved pricing design (`2026-09-07-carloku-pricing-plans-design.md`, "The free tier and the pool") says: "Durability on free: hibernate-flush only", and "This needs a new replication class... Flush-only must be added as a class and propagated from the account's plan." |
| -## What it is |
| +## What is on the branch |
| -A fourth rung of the registry's `routes.replication` vocabulary, `hibernate-flush`, the word the entitlement record already carries (`entitlement.ReplicationHibernateFlush`). The activator maps it to a litestream sync interval no awake period reaches (ten years), so the database is replicated at the wake-time pass and at the sleep-time flush, both of which `lease.Replicate` and `Replicator.Close` run in full whatever the interval, and at no time between. |
| +- The word `hibernate-flush` in the registry's `routes.replication` vocabulary (`ValidReplication`), the same word the entitlement record carries, with a test in each direction that the two vocabularies agree. |
| +- It is **not** in `ReplicationClasses()` (the rungs on offer); `carlos route --replication hibernate-flush` is refused as not offered (`replicationOffered`); a listing that meets the word says so. |
| +- The activator maps it to a sync interval no awake period reaches (ten years). |
| +- The CLI rail refuses any rung on a unit-backed route (the activator does not replicate a unit-backed route's database; the rail accepted the write before, for every class). |
| -- Loss window: everything written since the instance woke, if the host is lost while it is awake. Hibernation is durable, as for every class. |
| -- An operator can set it (`carlos route --replication hibernate-flush`); `SetReplication` accepts it; it is listed last, the least durable rung. |
| -- A wake-time pass that fails is not retried until the sleep-time flush (litestream retries on the interval's tick). For `daily` that is up to a day; here it is until sleep. |
| -- An instance that never sleeps never replicates after its wake under this class. The class is meant for a plan that hibernates at two minutes idle. |
| +## What the ten-year interval does, against the vendored litestream |
| + |
| +- The replica monitor syncs the local log to the bucket once after the store opens, at the database monitor's first ten-second tick, not at the wake itself, and then not again until Close. |
| +- `Replicator.Close` forces a sync and then closes the store, which flushes what was written while awake. |
| +- **The snapshot monitor still uploads the whole database** at its first pass and at each 00:00 UTC while an instance is awake, whatever the interval, and compactions still write. So "nothing between wake and sleep" is false: for an awake period under a day the class behaves as `daily` does. |
| +- **Everything written while awake waits on the box for Close**, and the stop path gives Close thirty seconds. An instance awake for hours with steady writes holds thousands of log files; if they do not ship in time the flush fails, the lease is still released, and the instance is still reported hibernated with its outcome recorded as uncertain. Under `daily` the backlog is at most a day's; here it has no bound. So "hibernation is durable" does not hold unconditionally for this class. |
| +- The local log is not trimmed until uploaded (retention works from the remote listing), so a busy instance that does not sleep fills the box. |
| +- After a failed first sync the monitor waits its interval and then a backoff of the same length: about 48 hours for `daily`, never (before Close) here. |
| +- The restore drill (`internal/ops`) allows fifteen minutes of lag and knows nothing of classes: an awake instance on this class, or on `daily`, reads as stale. |
| + |
| +## Open, each needing the decision or its own change |
| + |
| +1. What the class promises (decision 10a): a backstop interval, an unbounded flush at sleep, or no such class. Not chosen here; the ten-year interval is what was built first and is not a proposal. |
| +2. Inherited, every class (Astra 1, P1): `Replicator.Close` drops the error of its forced sync; if the database never initialised (a drain right after a wake whose remote lookup failed), litestream's `DB.Close` skips its final sync and Close returns nil having uploaded nothing. And a flush that does fail still ends in hibernation. Filed for its own fix; not changed on this branch. |
| +3. Inherited (Astra 4): the first sync after a wake is asynchronous and can ship an older log position than the wake's. |
| +4. The restore drill's class-blind freshness check (Astra 6). |
| +5. Plan propagation, the Free entitlement, the per-plan idle window, the app cap, storage refusal (`OUTSTANDING.md` 15). |
| ## Mixed versions |
| -An older activator reads an unknown class as litestream's default (the stream class): a route set to `hibernate-flush` on a box whose binary predates this replicates continuously, which is more durable and more S3 traffic, never less durable. An older CLI refuses the name. |
| +An older activator reads an unknown class as litestream's default (stream): never less durable. An older CLI refuses the word. The column is text with no check. |
| ## Tests |
| -- `internal/lease` `TestALongSyncIntervalReplicatesAtWakeAndAtCloseOnly`: litestream against a local file replica. The seeded rows reach the replica at the wake with no call; rows written while awake do not, for longer than the default interval needed to replicate the same writes (the control); Close replicates them all. It shows when litestream syncs, not what S3 does. |
| +- `internal/lease` `TestALongSyncIntervalSyncsTheLogAfterOpenAndAtCloseOnly`: litestream against a local file replica. The control waits until the awake rows restore under the default interval; the long interval adds no log (level 0) file and restores the pre-write rows for longer than the control needed; Close restores all rows. It does not show that nothing is uploaded while awake (the snapshot is), and says so. |
| - `internal/activator` `TestActivatorWakeThreadsReplicationClass`: the class threads the long interval, not 0. |
| -- `internal/registry` `TestValidReplicationVocabulary`: the word is valid, near-misses are not, four rungs in order. |
| -- `internal/console` `TestEntitlementReplicationClassesAreRegistryClasses`: the entitlement's classes are the registry's. |
| - |
| -## Not in this branch |
| - |
| -Propagating a plan's class to its routes; the Free entitlement; the per-plan idle window; the app cap; storage refusal. Each is listed in `OUTSTANDING.md` 15 with the decision it waits on. |
| +- `internal/registry` `TestValidReplicationVocabulary`: valid, not offered; three rungs on offer. |
| +- `internal/console` and `internal/entitlement`: the two vocabularies agree in both directions. |
| +- CLI: `TestReplicationOfferedRefusesTheFlushOnlyClass`, `TestReplicationRailRefusesAUnitBackedRoute`. |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-flush-only-class-review1.md b/docs/superpowers/specs/CODEX-FINDINGS-flush-only-class-review1.md| new file mode 100644 |
| index 000000000..8a1e39a76 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-flush-only-class-review1.md |
| @@ -0,0 +1,118 @@ |
| +## Findings |
| + |
| +All references are at **`0e8ea227`**. Findings explicitly marked inherited also affect `daily`. |
| + |
| +1. **P1 — Close can report success without flushing; failed flushing still ends in hibernation.** |
| + **`internal/lease/lease.go:396`**, **`internal/activator/activator.go:2178`**. |
| + |
| + Concrete scenario: drain immediately after wake, before the first database-monitor tick. `SyncAndWait` attempts initialization, but the remote-position lookup fails (`third_party/litestream/db.go:1427`). Initialization resets `db.db` to nil (`db.go:1025`). `Replicator.Close` discards that error; `DB.Close` skips both final syncs when `db.db == nil` (`db.go:788`, `db.go:796`). A successful lease release can therefore leave `Close` returning nil despite uploading nothing. |
| + |
| + Even when an initialized database’s flush returns an error, `finishStop` records uncertainty, deletes the entry and logs “hibernated” (`activator.go:2180`). Losing the host afterward loses unflushed writes **while asleep**. |
| + |
| + **Smallest acceptable fix:** preserve initialization/sync errors unless a subsequent flush demonstrably succeeds; retain an explicit failed-checkpoint state with retry/recovery instead of declaring durable hibernation. This is an **inherited dependency defect**, not a newly introduced shutdown implementation, but it invalidates this branch’s unconditional durability claim. |
| + |
| +2. **P2 — The ten-year interval does not prevent uploads while awake.** |
| + **`internal/activator/activator.go:310`**, **`internal/lease/lease.go:316`**. |
| + |
| + `newStore` leaves compaction and snapshot monitors enabled. Snapshots run initially and at daily wall-clock boundaries (`third_party/litestream/store.go:205`, `store.go:540`). Their path calls `db.Snapshot` directly (`store.go:765`), which uploads the live database/WAL through `WriteLTXFile`, without consulting `Replica.SyncInterval` (`third_party/litestream/db.go:2609`). |
| + |
| + Concrete scenario: an instance wakes before midnight, completes its initial replication, receives writes and remains awake across midnight. The snapshot monitor uploads those writes despite the ten-year ticker. L1–L3 compactions also upload while awake, although their inputs are already-replicated files (`third_party/litestream/compactor.go:113`, `compactor.go:169`). |
| + |
| + **Smallest acceptable fix:** make flush-only an explicit lifecycle policy that disables background snapshot/compaction uploads for this class and performs required maintenance at the permitted boundaries. Changing only `SyncInterval` cannot implement the stated contract. |
| + |
| +3. **P2 — Unuploaded local L0 files accumulate throughout a long awake period.** |
| + **`internal/activator/activator.go:329`**, **`internal/lease/lease.go:302`**. |
| + |
| + The database monitor continues generating local L0 files every ten seconds. L0 retention discovers deletion candidates from the **remote** L0 listing and requires L1 coverage (`third_party/litestream/db.go:2696`, `db.go:2718`, `db.go:2754`). Consequently, it cannot reclaim files withheld by the ten-year upload interval. Daily snapshots do not advance the L0 replica position. |
| + |
| + Concrete scenario: a continuously busy instance generates 1 MiB of L0 data per monitor pass. Roughly 8.4 GiB/day accumulates locally until sleep, eventually exhausting a shared volume. Healthy `daily` replication lets that backlog become eligible for compaction and retention after its periodic upload. |
| + |
| + **Smallest acceptable fix:** bound retained local replication data through a snapshot/coalescing mechanism compatible with flush-only, or enforce a durable stop before a defined backlog limit. Do not delete unuploaded files while `Replica.Sync` still requires their contiguous transaction sequence (`third_party/litestream/replica.go:170`). |
| + |
| +4. **P2 — The “full wake-time pass” is neither synchronous nor guaranteed to include the wake-time database state.** |
| + **`internal/lease/lease.go:283`**, **`third_party/litestream/db.go:1090`**. |
| + |
| + `Replicate` returns after starting monitors. The database monitor waits for its first ten-second tick (`db.go:2829`). During initialization it starts the replica monitor **before** finishing WAL-to-L0 synchronization. |
| + |
| + Concrete scenario: after an agent crash, the local database contains committed WAL newer than its existing L0 files. On re-wake, the replica monitor can successfully synchronize the old L0 position before the database monitor publishes the new one. Its next iteration then waits ten years (`third_party/litestream/replica.go:340`). A current-day snapshot may already exist, so the snapshot monitor need not rescue this immediately. Losing the host can lose writes from **before this wake**, contradicting the stated window. |
| + |
| + **Smallest acceptable fix:** explicitly complete and verify WAL-to-L0-to-replica synchronization at wake before treating the wake boundary as durable, then suppress subsequent background synchronization. This ordering weakness is inherited; the new interval makes relying on a later L0 tick untenable. |
| + |
| +5. **P2 — The operator can assign the class to a backend that ignores it.** |
| + **`cli.go:1684`**, **`internal/activator/backend_unit.go:285`**. |
| + |
| + The CLI checks hibernation and a nonempty database path, but does not reject `BackingUnit`. That backend’s `Replicate` ignores the supplied interval and returns an inert replicator; its database remains the external replicator’s responsibility. |
| + |
| + Concrete scenario: assign `hibernate-flush` to a hibernating, unit-backed route with a database path. The command succeeds and route listings advertise the class, but replication continues under the external configuration. |
| + |
| + **Smallest acceptable fix:** reject this class on backends that cannot honor it, including the operator CLI rail. The unsupported-backend hole predates this branch, but the new class is exposed through it. |
| + |
| +6. **P2 — The restore verifier treats permitted awake lag as failed replication.** |
| + **`internal/ops/hibernate_verify.go:96`**, **`internal/ops/freshness.go:108`**. |
| + |
| + The verifier carries only host and database path (`hibernate_verify.go:59`), then applies a fixed fifteen-minute freshness allowance without knowing the replication class or lifecycle state. |
| + |
| + Concrete scenario: an instance writes, checkpoints locally, then stays awake through read traffic for more than fifteen minutes. Its remote copy intentionally remains behind. The drill reports `STALE` and “replication has stopped,” producing a failed per-host verdict. |
| + |
| + **Smallest acceptable fix:** carry the class and reliable lifecycle/checkpoint state into freshness evaluation. Check restorability while flush-only instances are awake; require the completed hibernation boundary to be durable. Merely increasing the grace period would hide failed sleep-time flushing. `daily` already has this mismatch. |
| + |
| +7. **P2 — The new integration test does not establish “wake and Close only.”** |
| + **`internal/lease/flushonly_test.go:51`**, **`internal/lease/flushonly_test.go:82`**, **`internal/lease/flushonly_test.go:99`**. |
| + |
| + The wake condition accepts **any** `.ltx` file, including a snapshot. The control accepts any increase in file count without verifying that its ten new rows were replicated. A delayed initial snapshot or compaction can therefore satisfy the control. |
| + |
| + Concrete scenarios: |
| + - The test passes during an ordinary short run, although the daily snapshot later uploads awake writes. |
| + - A delayed startup snapshot appears after `before` is counted, making the long-interval subtest fail depending on scheduling. |
| + - The replica monitor’s initial L0 pass is broken, but the snapshot monitor supplies the fifty seeded rows and masks that failure. |
| + |
| + The final restore checks usefully verify the observed fifty/sixty-row states, but they cannot prove absence of uploads outside that short observation window, or detect redundant uploads that preserve the same state. |
| + |
| + **Smallest acceptable fix:** observe replica-client writes by level and transaction, verify the control’s restored contents, synchronize startup completion, and drive snapshot/compaction boundaries deterministically. Cover initialization failure, warm re-wake and immediate Close. Register teardown immediately so failed assertions also close the replicator. |
| + |
| +8. **P3 — Operator guidance still enumerates only three classes.** |
| + **`cli.go:537`**, **`cli.go:1718`**, **`docs/site/cli.md:1180`**. |
| + |
| + Concrete scenario: an operator consults command usage or the CLI documentation and sees only `stream`, `batched` and `daily`. Setting the new class prints its raw name without the loss-window explanation supplied for the others. |
| + |
| + **Smallest acceptable fix:** update the usage alternatives, documentation and `replicationWord` together, using the corrected durability contract. |
| + |
| +## Claims the code contradicts |
| + |
| +- **Design note `docs/superpowers/specs/2026-10-02-flush-only-replication-class.md:9`: “at no time between.”** Background snapshots upload current data; compactions upload existing replicated data. The ten-year interval controls only the replica monitor’s L0 synchronization loop. |
| + |
| +- **Design note `:9` and `:11`: both boundary passes run “in full,” and hibernation is durable.** A successful, initialized Close normally does flush independently of the interval: `DB.SyncAndWait` calls both synchronization stages directly (`third_party/litestream/db.go:678`), and `Store.Close` joins background maintenance before closing databases (`third_party/litestream/store.go:240`). But wake is asynchronous, initialization errors can disappear at Close, and an explicitly failed Close still ends in hibernation. |
| + |
| +- **Design note `:13`: a failed wake pass retries on the interval tick, “up to a day” for `daily`.** For an ordinary replica-sync error, the monitor waits for the next ticker **and then** an additional backoff initialized to `SyncInterval` (`third_party/litestream/replica.go:340`, `replica.go:349`, `replica.go:387`). An early first failure therefore yields approximately **48 hours for `daily`**, or **twenty years for the new interval**, absent Close. Conversely, failures before replica startup are retried by the database monitor, and snapshots have their own schedule. |
| + |
| +- **Design note `:14`: a never-sleeping instance never replicates after wake.** Daily snapshots contradict this today. Even the L0 ticker is finite. |
| + |
| +- **Design note `:22`: the test demonstrates the claimed lifecycle.** It demonstrates particular restored states over a short, timing-dependent run. It does not establish the universal claim. |
| + |
| +The failure behavior is consequently: |
| + |
| +| Scenario | `hibernate-flush`, compared with `daily` | |
| +|---|---| |
| +| Host lost while awake | Loses changes absent from the latest successful remote copy. After a sound wake boundary, that can be the whole awake period; background snapshots may reduce it. Healthy `daily` additionally uploads L0 periodically. | |
| +| Failed wake-time pass | Neither class guarantees a current wake boundary. Pre-wake changes can remain vulnerable; the retry behavior is described above. | |
| +| Failed Close flush | Neither class guarantees durable hibernation. `hibernate-flush` may have a larger outstanding backlog. | |
| +| Crash loop | Ordinary instance exits enter the stop/Close path (`internal/activator/activator.go:2322`). Worker crashes can restart in place, preserving the same replicator (`activator.go:2313`); those restarts provide no new wake flush. | |
| +| Instance never sleeps | Background snapshots still occur, while unuploaded L0 accumulates. Healthy `daily` also drains L0 periodically. | |
| +| SQLite checkpoint under the lease | Copies WAL into local L0 around the checkpoint; it does not itself upload (`third_party/litestream/db.go:2236`). Explicit **`Held.Checkpoint`** does force remote synchronization and joins errors (`internal/lease/checkpoint.go:40`, `checkpoint.go:47`), independently of the class. | |
| +| Drain immediately after wake | `Close` explicitly forces initialization and synchronization, so the ten-year ticker does not delay a healthy drain. Initialization failure exposes finding 1. | |
| + |
| +Other audited paths do **not** reveal a new vocabulary or billing rejection: |
| + |
| +- **Registry and reconcilers:** `SetReplication` accepts the new word (`internal/registry/registry.go:1224`). Upsert preserves a nonempty class on hibernating rows and clears it when hibernation is disabled (`registry.go:964`); `SetHibernate(false)` also clears it (`registry.go:1619`). Provisioning uses narrow updates for existing rows (`internal/provision/reconcile.go:888`); domain minting does not propagate the source route’s class (`internal/domains/reconcile.go:842`). Neither reconciler implements plan propagation. |
| + |
| +- **API, pages and meters:** the console API passes the string through (`internal/console/apiroutes.go:154`), and CLI listings render it verbatim (`routes_cli.go:119`). The console instance pages do not display replication class at all (`internal/console/templates/app_instance.html:13`, `app_instances.html:34`). Status does not measure per-instance replication lag (`internal/host/agent.go:2785`). Durability billing counts observed operations and purpose, not class names (`internal/usage/durability.go:241`): wake, Close, snapshot and compaction writes through this replicator retain `PurposeReplicate` (`internal/activator/backend_exec.go:238`). Thus unintended uploads remain metered; the new word itself does not mis-bill. |
| + |
| +- **Retention and heartbeats:** L0 retention lists/deletes already-uploaded, compacted files; it does not upload pending data. Litestream’s optional heartbeat is unconfigured here and only checks sync health (`third_party/litestream/store.go:665`, `store.go:705`). The separate lease heartbeat still renews every five minutes (`internal/lease/health.go:109`); that is a lock write, not database replication. |
| + |
| +- **Ten-year arithmetic:** `315360000000000000` nanoseconds fits `time.Duration`; doubling it once also fits before the backoff is capped. The ticker and backoff waits remain cancellation-aware. Embedded setup imposes no maximum; vendored CLI validation rejects only nonpositive intervals (`third_party/litestream/cmd/litestream/main.go:532`). Logging formats the duration without narrowing it (`third_party/litestream/cmd/litestream/replicate.go:330`). |
| + |
| +- **Mixed versions:** the pre-change activator falls through to stream for this unknown word. Older class-aware console/API and listing code preserve the raw string; the older setter CLI refuses it. This build needs no new registry schema: replication is unrestricted text, and older missing columns migrate to `""` (`internal/registry/registry.go:460`). `""` still maps to stream. |
| + |
| +- **Tests and entitlement:** removing the new mapping makes the activator assertion fail; removing vocabulary acceptance makes registry/console assertions fail. The lease test supplies its own ten-year duration and does not depend on the branch’s production changes. Registry ordering checks only length and the two endpoints (`internal/registry/replication_test.go:217`). Entitlement already accepts the identical four named classes plus unset (`internal/entitlement/entitlement.go:105`, `entitlement.go:147`); no wire/schema change is needed. The vocabularies now agree, but remain independently defined, and the new console test checks only one-way acceptance—not a shared definition or plan propagation. |
| + |
| +**Verdict: reject.** |
| \ No newline at end of file |
diff --git a/docs/superpowers/specs/OPUS-REVIEW-flush-only-class-review1.md b/docs/superpowers/specs/OPUS-REVIEW-flush-only-class-review1.md| new file mode 100644 |
| index 000000000..e27737455 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/OPUS-REVIEW-flush-only-class-review1.md |
| @@ -0,0 +1,128 @@ |
| +# Review: feat/carloku-flush-only-class-20261002 @ 0e8ea227 (base 65363554) |
| + |
| +**Verdict: accept with fixes.** I'd merge after three changes: correct the design note and comments to match litestream's real behaviour, make the lease test's control check rows rather than file counts, and decide on the unbounded sleep-time backlog (the shortest fix is a bounded interval). |
| + |
| +The core mechanism holds. A ten-year `SyncInterval` gives one wake-time pass, then the replica monitor stays on its ticker, and `Replicator.Close` flushes everything. But "no sync at all while awake" is not true. "Hibernation is durable" also only holds while the backlog fits Close's 30-second budget, and under this class that backlog has no bound. One more thing the note should say: under the vendored litestream, for any awake period shorter than 24 hours, `hibernate-flush` behaves exactly like `daily`. |
| + |
| +## Findings |
| + |
| +### P2-1. The sleep-time backlog has no bound, but the flush that ships it has 30 seconds |
| + |
| +- **Where:** `internal/activator/activator.go:316` (interval), `:2174` (`finishStop` gives Close 30s); `internal/lease/lease.go:396-397`; vendored `third_party/litestream/replica.go` `Sync` (one PUT per transaction, in order). |
| +- **Why:** the DB monitor writes one local L0 file each 10-second tick that has writes (`dbMonitorInterval`, `lease.go:71`). Under this class none of those files leave the box until Close. Close then runs `SyncAndWait` and `syncReplicaWithRetry` under the same 30-second context. |
| +- **Scenario:** a Free app gets a write every couple of minutes, from a bot or an uptime pinger. That keeps it inside the 2-minute idle window, so it never sleeps. After 6 hours awake it holds up to 2,160 L0 files. At about 20–40 ms per PUT, plus a durability-ledger record for each request, Close cannot ship them in 30 seconds. |
| +- **What happens then:** |
| + - `repErr` is non-nil and `replicaClosed=false`, but the lease is still released, because the release has its own context (`lease.go:399-409`). |
| + - The local copy stays on the box. If the next wake happens on another box (a move, #175) or the host is lost, everything after the partial upload is gone. |
| + - Local L0 files also pile up in `.activator-*-litestream` while the app is awake. Local deletion only happens through remote-L0 → L1 retention, and under this class there is no remote L0 to retain. |
| +- **Compared with `daily`:** `daily` has the same 30-second budget but ships its backlog every 24 hours, so its worst case is a day's worth. This class makes it unbounded. |
| +- **Smallest fix (pick one):** |
| + - Map `hibernate-flush` to a bounded interval. 24h makes it `daily` in all but name; 6h is also fine. Then state that. |
| + - Or keep 10 years and make the activator size Close's budget to the backlog (for example, `Pos().TXID − replica pos`), and refuse to report the instance hibernated while the flush is incomplete. |
| + |
| + Either way, the note must stop saying hibernation is unconditionally durable. |
| + |
| +### P2-2. A snapshot uploads awake writes once a day, so "no sync at all while awake" and "never replicates after its wake" are false |
| + |
| +- **Where:** `third_party/litestream/store.go:540-605` (`monitorCompactionLevel(SnapshotLevel)`), `store.go:765` (`CompactDB` → `db.Snapshot`), `db.go:2604` (`Snapshot` uploads the local DB at its current position). `NewStore` enables this by default (`SnapshotInterval` 24h, `CompactionMonitorEnabled: true`), and `lease.newStore` does not turn it off. |
| +- **Scenario:** an instance is awake across 00:00 UTC. `PrevCompactionAt` is `now.Truncate(24h)`, so at midnight a full level-9 snapshot holding every awake write goes to S3. The same thing happens on the first wake of each UTC day. |
| +- **Effect on durability:** it is more durable than claimed. A never-sleeping app's loss window is bounded at about 24 hours, the same as `daily`. But the comments and the note are wrong, and every S3 snapshot is a full-DB PUT. |
| +- **Other monitors:** |
| + - L1–L3 compaction only reads remote files (`compactor.go:113`), so it never ships awake writes. It does still issue PUTs and LISTs while awake. |
| + - The heartbeat monitor is off (`Heartbeat` is nil), and so is validation (`ValidationInterval` is 0). |
| + - L0 retention only deletes files that are already in remote L1, so it can't drop the flush. |
| +- **Fix:** correct the claims (see the table below). If "no copy while awake" is really wanted, set `store.SnapshotInterval` for this class. Don't do that without P2-1's fix, because the midnight snapshot is currently the only thing bounding a never-sleeper's loss. |
| + |
| +### P2-3. The lease test's control and its no-sync check count files, not rows, and race the start-up compaction and snapshot passes |
| + |
| +- **Where:** `internal/lease/flushonly_test.go:51` (`start` returns as soon as any `.ltx` exists), `:79`/`:95` (`before := ltxFiles(replica)`), `:104` (`after != before`). |
| +- **The race:** at `Store.Open` the snapshot monitor and the L1/L2/L3 monitors each retry every second until the DB initialises, which happens at the first 10-second tick. Within about a second of the wake pass they write a level-9 snapshot and, depending on goroutine order, L1, L2 and L3 files. |
| +- **Scenario 1 (the long-interval subtest can fail for nothing):** the L0 file lands, `start` returns, `before` is taken as 1, then the snapshot and L1 files land. The subtest then fails with "want no sync while awake" even though litestream did nothing wrong. |
| +- **Scenario 2 (the control can pass for nothing):** the control counts those same late files as "replicated while awake". `streamTook` comes out near zero, so the wait falls back to its 12-second floor. The control never checks that the 10 awake rows restore. |
| +- **Midnight:** a run whose wait spans 00:00 UTC also fails, through P2-2's snapshot. |
| +- **It can't hide a real sync while awake:** any extra upload changes the count. The problem is false failures and a control that proves nothing. |
| +- **Fix:** |
| + - In the control, wait until `restoreCount` returns 60 while the store is open; time that. |
| + - In the long-interval case, assert the restore count is still 50 and that the L0 file count (`ltx/0/`) is unchanged, not the count across all levels. |
| + - Take `before` only after the start-up compaction and snapshot passes have settled, or set `store.SnapshotInterval`, or count L0 only. |
| + - The test takes about 45 seconds and has no `testing.Short` guard; consider adding one. |
| + |
| +### P3-1. The CLI's word table and usage line still know only three classes |
| + |
| +- `cli.go:1714-1727`: `replicationWord` has no case for `hibernate-flush`. `carlos route --replication hibernate-flush` therefore prints `stream (...) -> hibernate-flush`, with no loss window. That line is the operator's only statement of the promise. |
| +- `cli.go:537`: the usage line hard-codes `<stream|batched|daily|->`. |
| +- **Fix:** add the case (`hibernate-flush (at wake and at sleep only — loss window: everything since the wake)`) and build the usage line from `ReplicationClasses()`. |
| + |
| +### P3-2. Three places where the class can be set but does nothing, or will later misreport |
| + |
| +- **Unit-backed routes:** the activator ignores the class for unit-backed hibernating routes (`backend_unit.go:285`, the inert lease), but `replicationRail` (`cli.go:1695`ff) still accepts the write. |
| +- **Plan propagation:** when the class is later set from a plan, Free apps the rail refuses (non-hibernating rows, an empty `db_path`, unit-backed rows) will stay on the box-wide litestream interval. |
| +- **Lag alerts:** `metrics/alert.go:29` sets a 900-second lag threshold, and `status/snapshot.go:352` treats 15 minutes of lag as unhealthy. Nothing feeds lag yet (`host/metrics_loop.go:574`). Once something does, every `hibernate-flush` and `daily` route that is awake will read as unhealthy. |
| +- **Fix:** none needed for this branch beyond recording these in OUTSTANDING 15 and the note. |
| + |
| +### P3-3. The entitlement package still spells the words itself, and the pin only checks one direction |
| + |
| +- `internal/entitlement/entitlement.go:106-109` keeps its own constants. `internal/console/replicationclass_test.go` checks that every entitlement word is valid in the registry, and that the two `hibernate-flush` spellings are equal. |
| +- It does not check the reverse: a future registry rung with no entitlement word would pass. |
| +- **Fix:** also loop over `registry.ReplicationClasses()` against the entitlement's valid set. That needs `entitlement.ValidReplication` exported, or keep the test in the entitlement package with a registry import. |
| +- Otherwise it is one vocabulary now, and nothing else in `internal/entitlement` needs to change. |
| + |
| +## Answers to A–G |
| + |
| +**A. Is the claim true against the vendored litestream?** |
| +- **Wake pass:** it happens once, at the DB's first 10-second tick, not at the moment of wake. That is the same for every class. |
| +- **No sync while awake:** true of the replica monitor, but not of the snapshot monitor (P2-2). |
| +- **Close:** a full flush whatever the interval, but limited to 30 seconds (P2-1). Nothing can skip it: `Store.Close` cancels the monitors before `db.Close`, and `Replicator.Close` forces initialisation with `SyncAndWait` first. |
| +- **What a ten-year ticker does in each place the interval is used:** |
| + - `NewTicker(10y)` is fine. |
| + - On a sync error the backoff starts at 10 years, which is unreachable. The doubling is capped at 5 minutes and nothing overflows (3.15e17 ns, ×2 = 6.3e17 ns). |
| + - Nothing in the vendored package validates `SyncInterval`. |
| + - Logs print the backoff as `87600h`. |
| + - The compaction and snapshot levels never use this interval. |
| + |
| +**B. What each failure loses:** |
| + |
| +| Failure | `hibernate-flush` | `daily` | |
| +|---|---|---| |
| +| Host lost while awake | Everything since the later of the wake pass and the last 00:00 UTC snapshot | The same for awake periods under 24h | |
| +| Failed wake-time pass | Next copy is at Close; the previous Close's copy is still on S3 | About 48h before a retry (24h ticker plus 24h backoff), not the "up to a day" the note says | |
| +| Failed Close flush | Lease released, outcome recorded as uncertain, local copy kept. If the next wake is elsewhere, the whole awake period is lost | The same, but the backlog is at most a day | |
| +| Crash loop | Each restart's wake pass ships whatever the previous Close missed | The same | |
| +| Never sleeps | Midnight snapshots only: about 24h loss window, plus an unbounded local and Close backlog | Bounded | |
| +| Checkpoint under the lease | No loss: litestream holds a read lock and checkpoints only after writing the local LTX | The same | |
| +| Drain right after a wake | Covered by `SyncAndWait` | The same | |
| + |
| +The only case worse than the stated window is the failed Close, which is P2-1. |
| + |
| +**C. Readers of the column and the class list:** |
| +- The registry setter, Upsert preservation and `SetHibernate` clearing are correct. |
| +- The provisioning reconciler (`provision/reconcile.go:904`) and the console API (`apiroutes.go:154`) pass the string through. |
| +- `carlos routes` prints the raw word. |
| +- The durability meters and billing are not class-aware, so nothing mis-bills. |
| +- Gaps: P3-1 and P3-2. |
| + |
| +**D. Mixed versions:** |
| +- An older activator maps the unknown word to stream (`replicationSyncInterval` returns 0), so it is never less durable. |
| +- An older CLI refuses to set the word but lists it. |
| +- The column is TEXT with no CHECK, so this build against an older registry is fine. |
| + |
| +**E. Do the tests fail without the code?** |
| +- The activator, registry and console tests do. |
| +- The lease test does not: it only characterises litestream and passes at the base commit, which is fine. |
| +- Its weaknesses are P2-3. |
| + |
| +**F.** One vocabulary in practice, held together by a test (P3-3). |
| + |
| +## Claims the code contradicts |
| + |
| +| Claim | Where it's made | What the code does | |
| +|---|---|---| |
| +| "litestream makes no replica sync at all" between wake and sleep; "at no time between" | `activator.go:310-315`, registry comment, note "What it is" | A full snapshot is uploaded at each 00:00 UTC while awake (P2-2) | |
| +| "An instance that never sleeps never replicates after its wake" | Note | Snapshotted daily; loss is at most about a day | |
| +| "Hibernation is durable, as for every class" | Note | Only if the backlog ships in 30 seconds (P2-1) | |
| +| A failed wake pass under `daily` waits "up to a day" | Note | About 48 hours | |
| +| The control shows "rows written while awake" being replicated | Note "Tests", test comment | It counts any new `.ltx` file, which start-up snapshots and compactions also produce (P2-3) | |
| +| The class gives "no restore to an earlier moment" | `registry.go` comment | Not enforced. Snapshots are kept for 24h and L0 files remain, so restore to a wake or sleep point is still possible; this is a plan statement only | |
| +| The class differs from `daily` | Implied throughout | Identical for awake periods under 24h | |
| + |
| +I reviewed read-only with `git show` at 0e8ea227 and ran no tests. I deleted the scratch copies I made under `$TMPDIR`. |
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| index 752ef1040..fbadd8c4f 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -303,16 +303,26 @@ const ( |
| 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 |
| - // sync at wake and at sleep only — loss window: everything since the wake |
| + // no sync of the write-ahead log while awake; not offered, not set by anything |
| replicationHibernateFlush = registry.ReplicationHibernateFlush |
| ) |
| // flushOnlySyncInterval is the hibernate-flush class's tick: a spacing no |
| -// awake period reaches, so between the wake-time pass and the sleep-time |
| -// flush (both of which run in full whatever the interval: lease.Replicate) |
| -// litestream makes no replica sync at all. Ten years, not the largest |
| -// Duration: a ticker is armed with it, and arithmetic on it must not |
| -// overflow. |
| +// awake period reaches, so litestream's replica monitor syncs the local |
| +// log to the bucket once after the wake (at the database monitor's first |
| +// tick, not at the wake itself) and then not again until Close. Ten years, |
| +// not the largest Duration: a ticker is armed with it, and arithmetic on |
| +// it must not overflow. |
| +// |
| +// What this does NOT give, as the first review of the class found against |
| +// the vendored litestream (2026-10-02-flush-only-replication-class.md): |
| +// the snapshot monitor still uploads the whole database once a day while |
| +// an instance is awake, and compactions still write; everything written |
| +// while awake waits on the box for Close, whose flush has the stop path's |
| +// thirty seconds, so a long-awake instance's sleep may not be durable; and |
| +// the local log is not trimmed until it has been uploaded. The class is |
| +// therefore not offered and not set by anything until its contract is |
| +// decided. |
| const flushOnlySyncInterval = 10 * 365 * 24 * time.Hour |
| // replicationSyncInterval maps a class to the litestream replica sync |
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go| index 7260dad62..9eb56fa72 100644 |
| --- a/internal/activator/activator_test.go |
| +++ b/internal/activator/activator_test.go |
| @@ -970,8 +970,9 @@ func TestActivatorWakeThreadsReplicationClass(t *testing.T) { |
| {replicationStream, 0}, |
| {replicationBatched, 5 * time.Minute}, |
| {replicationDaily, 24 * time.Hour}, |
| - // The Free plan's class: no tick an awake period reaches. Not 0, |
| - // which would be the stream class, the opposite of its intent. |
| + // The flush-only class (not offered, not set by anything yet): no |
| + // tick an awake period reaches. Not 0, which would be the stream |
| + // class, the opposite of its intent. |
| {replicationHibernateFlush, flushOnlySyncInterval}, |
| } { |
| rt := testRoute(fmt.Sprintf("ladder%d.example.com", i)) |
diff --git a/internal/console/apiroutes.go b/internal/console/apiroutes.go| index bf16ca8dc..b314b748a 100644 |
| --- a/internal/console/apiroutes.go |
| +++ b/internal/console/apiroutes.go |
| @@ -63,7 +63,8 @@ type apiRoute struct { |
| 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 |
| + // batched or daily (or hibernate-flush, which the column may hold and |
| + // nothing sets yet). 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"` |
diff --git a/internal/entitlement/replication_vocab_test.go b/internal/entitlement/replication_vocab_test.go| new file mode 100644 |
| index 000000000..7eea1042d |
| --- /dev/null |
| +++ b/internal/entitlement/replication_vocab_test.go |
| @@ -0,0 +1,24 @@ |
| +package entitlement |
| + |
| +import ( |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// Every class the registry's replication column may hold is one a record |
| +// may pin, and the reverse: a rung added to one vocabulary alone fails |
| +// here. (The console package checks the entitlement's words against the |
| +// registry; this is the other direction, where the valid set is visible.) |
| +func TestReplicationVocabularyIsTheRegistrys(t *testing.T) { |
| + for _, class := range append(registry.ReplicationClasses(), registry.ReplicationHibernateFlush, registry.ReplicationDefault) { |
| + if !validReplication[class] { |
| + t.Errorf("the registry's class %q is not one an entitlement record may pin", class) |
| + } |
| + } |
| + for class := range validReplication { |
| + if !registry.ValidReplication(class) { |
| + t.Errorf("the entitlement's class %q is not one the registry's column may hold", class) |
| + } |
| + } |
| +} |
diff --git a/internal/lease/flushonly_test.go b/internal/lease/flushonly_test.go| index 760bcc045..9d57a1c77 100644 |
| --- a/internal/lease/flushonly_test.go |
| +++ b/internal/lease/flushonly_test.go |
| @@ -13,20 +13,32 @@ import ( |
| lsfile "github.com/benbjohnson/litestream/file" |
| ) |
| -// The hibernate-flush class rests on one property of Replicate's sync |
| -// interval: with an interval no awake period reaches, the database is |
| -// replicated at the wake-time pass and at Close, and at no time between. |
| -// The control runs the same writes under the default interval (the stream |
| -// class) and sees them replicated while the store is still open, so the |
| -// wait below is long enough to have shown a sync had one happened. |
| +// What a sync interval no awake period reaches does to litestream's |
| +// replica monitor: the local log is synced to the replica once after the |
| +// store opens, then not again while the store is open, and Close flushes |
| +// what was written meanwhile. The control runs the same writes under the |
| +// default interval (the stream class) and waits until those rows RESTORE |
| +// from the replica while the store is still open, so the long-interval wait |
| +// is long enough to have shown a log sync had one happened. |
| // |
| -// It runs litestream against a local file replica, not S3: what it shows is |
| -// when litestream syncs, not what S3 does. |
| -func TestALongSyncIntervalReplicatesAtWakeAndAtCloseOnly(t *testing.T) { |
| +// What it does not show (the flush-only class's review, 2026-10-02): that |
| +// nothing at all is uploaded while awake. litestream's snapshot monitor |
| +// uploads the whole database once a day, and at the first pass after the |
| +// store opens, whatever the interval; compactions write too. So the long |
| +// case counts level-0 files (the log) only, and the restored row count |
| +// while awake is read from a replica whose snapshot was taken before the |
| +// awake writes. A run that spans 00:00 UTC meets the daily snapshot and is |
| +// skipped rather than failed. |
| +// |
| +// It runs litestream against a local file replica, not S3. |
| +func TestALongSyncIntervalSyncsTheLogAfterOpenAndAtCloseOnly(t *testing.T) { |
| + if testing.Short() { |
| + t.Skip("about 45 seconds of real litestream ticks") |
| + } |
| const longInterval = 10 * 365 * 24 * time.Hour // the activator's flushOnlySyncInterval |
| - ltxFiles := func(dir string) int { |
| + level0 := func(dir string) int { |
| n := 0 |
| - _ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { |
| + _ = filepath.WalkDir(filepath.Join(dir, "ltx", "0"), func(p string, d fs.DirEntry, err error) error { |
| if err == nil && !d.IsDir() && strings.HasSuffix(p, ".ltx") { |
| n++ |
| } |
| @@ -45,17 +57,22 @@ func TestALongSyncIntervalReplicatesAtWakeAndAtCloseOnly(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - // The wake-time pass: the seeded rows reach the replica with no |
| - // call from here. |
| + // Closed whatever an assertion does (a second Close is harmless). |
| + t.Cleanup(func() { _ = rep.Close(context.Background()) }) |
| + // The first pass after the store opens: the seeded rows reach the |
| + // log level of the replica with no call from here. |
| deadline := time.Now().Add(40 * time.Second) |
| - for ltxFiles(replica) == 0 { |
| + for level0(replica) == 0 { |
| if time.Now().After(deadline) { |
| - t.Fatal("the wake-time pass replicated nothing") |
| + t.Fatal("the first pass synced no log file") |
| } |
| time.Sleep(250 * time.Millisecond) |
| } |
| - if n := restoreCount(t, replica, filepath.Join(t.TempDir(), "at-wake.db")); n != 50 { |
| - t.Fatalf("the wake-time pass restored %d rows; want the 50 seeded", n) |
| + // The start-up snapshot and compaction passes land within about a |
| + // second of it; let them, so nothing counted below is theirs. |
| + time.Sleep(3 * time.Second) |
| + if n := restoreCount(t, replica, filepath.Join(t.TempDir(), "at-open.db")); n != 50 { |
| + t.Fatalf("the first pass restored %d rows; want the 50 seeded", n) |
| } |
| return rep, path, replica |
| } |
| @@ -72,27 +89,27 @@ func TestALongSyncIntervalReplicatesAtWakeAndAtCloseOnly(t *testing.T) { |
| } |
| } |
| } |
| + day := func() int { return time.Now().UTC().YearDay() } |
| + began := day() |
| var streamTook time.Duration |
| - t.Run("the default interval replicates while awake (the control)", func(t *testing.T) { |
| - rep, path, replica := start(t, 0) |
| - defer rep.Close(context.Background()) |
| - before := ltxFiles(replica) |
| + t.Run("the default interval replicates the rows while awake (the control)", func(t *testing.T) { |
| + _, path, replica := start(t, 0) |
| write(t, path) |
| - began := time.Now() |
| - for ltxFiles(replica) == before { |
| - if time.Since(began) > 40*time.Second { |
| - t.Fatal("the default interval replicated nothing while awake: the control does not hold, so the long-interval wait proves nothing") |
| + at := time.Now() |
| + for restoreCount(t, replica, filepath.Join(t.TempDir(), "awake.db")) != 60 { |
| + if time.Since(at) > 40*time.Second { |
| + t.Fatal("the default interval did not replicate the awake rows while the store was open: the control does not hold, so the long-interval wait proves nothing") |
| } |
| - time.Sleep(250 * time.Millisecond) |
| + time.Sleep(500 * time.Millisecond) |
| } |
| - streamTook = time.Since(began) |
| + streamTook = time.Since(at) |
| }) |
| if streamTook == 0 { |
| t.Fatal("no control") |
| } |
| - t.Run("the long interval replicates nothing while awake, and everything at Close", func(t *testing.T) { |
| + t.Run("the long interval syncs no log while awake, and everything at Close", func(t *testing.T) { |
| rep, path, replica := start(t, longInterval) |
| - before := ltxFiles(replica) |
| + before := level0(replica) |
| write(t, path) |
| // Half as long again as the control needed, and never less than |
| // the database monitor's own interval. |
| @@ -101,17 +118,20 @@ func TestALongSyncIntervalReplicatesAtWakeAndAtCloseOnly(t *testing.T) { |
| wait = dbMonitorInterval + 2*time.Second |
| } |
| time.Sleep(wait) |
| - if after := ltxFiles(replica); after != before { |
| - t.Fatalf("%d replica files after the writes, %d before; want no sync while awake", after, before) |
| + if day() != began { |
| + t.Skip("the run spanned 00:00 UTC: the daily snapshot uploads the awake rows, which is what litestream does and not what this case measures") |
| + } |
| + if after := level0(replica); after != before { |
| + t.Fatalf("%d log files on the replica after the writes, %d before; want no log sync while awake", after, before) |
| } |
| if n := restoreCount(t, replica, filepath.Join(t.TempDir(), "awake.db")); n != 50 { |
| - t.Fatalf("the replica restores %d rows while awake; want the 50 from the wake", n) |
| + t.Fatalf("the replica restores %d rows while awake; want the 50 from before the writes", n) |
| } |
| if err := rep.Close(context.Background()); err != nil { |
| t.Fatal(err) |
| } |
| if n := restoreCount(t, replica, filepath.Join(t.TempDir(), "asleep.db")); n != 60 { |
| - t.Fatalf("the replica restores %d rows after Close; want all 60: hibernation's flush is in full whatever the interval", n) |
| + t.Fatalf("the replica restores %d rows after Close; want all 60", n) |
| } |
| }) |
| } |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index 8735cb634..209dece07 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -365,14 +365,14 @@ const ( |
| ReplicationBatched = "batched" |
| // ReplicationDaily syncs at wake and every 24h. Loss window: up to a day. |
| ReplicationDaily = "daily" |
| - // ReplicationHibernateFlush syncs at wake and when the instance goes |
| - // to sleep, and at no other time: the platform's baseline promise |
| - // (hibernate = durable) and nothing more. Loss window: everything |
| - // written since the instance woke, if the host is lost while it is |
| - // awake. It is the Free plan's class in the approved pricing design |
| - // (no continuous replication, no restore to an earlier moment), and |
| - // the same word the entitlement record carries. Nothing sets it from a |
| - // plan yet; an operator can. |
| + // ReplicationHibernateFlush is the Free plan's class in the approved |
| + // pricing design ("hibernate-flush only": no continuous replication), |
| + // and the word the entitlement record carries. The column may hold it |
| + // and the activator maps it (no change-driven or periodic sync of the |
| + // write-ahead log while awake; see the activator's interval for what |
| + // the vendored replicator still does), but it is NOT offered as a rung |
| + // (ReplicationClasses) and nothing sets it: what it promises is not |
| + // decided (docs/superpowers/specs/2026-10-02-flush-only-replication-class.md). |
| ReplicationHibernateFlush = "hibernate-flush" |
| ) |
| @@ -381,7 +381,19 @@ const ( |
| // 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, ReplicationHibernateFlush} |
| + return []string{ReplicationStream, ReplicationBatched, ReplicationDaily} |
| +} |
| + |
| +// ReplicationOffered reports whether class is a rung an operator may set. |
| +// The flush-only class is valid in the column and not offered: its |
| +// contract is not decided. |
| +func ReplicationOffered(class string) bool { |
| + for _, c := range ReplicationClasses() { |
| + if c == class { |
| + return true |
| + } |
| + } |
| + return false |
| } |
| // ValidReplication reports whether class is a value this column may hold. |
diff --git a/internal/registry/replication_test.go b/internal/registry/replication_test.go| index aa47b79c1..6927b76cb 100644 |
| --- a/internal/registry/replication_test.go |
| +++ b/internal/registry/replication_test.go |
| @@ -213,8 +213,20 @@ func TestValidReplicationVocabulary(t *testing.T) { |
| t.Errorf("ReplicationClasses() offers the default as a rung to type") |
| } |
| } |
| - // Four rungs, most durable first; the flush-only class last. |
| - if got := ReplicationClasses(); len(got) != 4 || got[0] != ReplicationStream || got[3] != ReplicationHibernateFlush { |
| - t.Errorf("ReplicationClasses() = %v, want the four rungs in order of decreasing durability", got) |
| + if len(ReplicationClasses()) != 3 { |
| + t.Errorf("ReplicationClasses() = %v, want the three rungs", ReplicationClasses()) |
| + } |
| + // The flush-only class is a word the column may hold and not a rung |
| + // on offer: what it promises is not decided. |
| + if !ValidReplication(ReplicationHibernateFlush) || ReplicationOffered(ReplicationHibernateFlush) { |
| + t.Errorf("hibernate-flush: valid %v, offered %v; want valid and not offered", ValidReplication(ReplicationHibernateFlush), ReplicationOffered(ReplicationHibernateFlush)) |
| + } |
| + for _, c := range ReplicationClasses() { |
| + if !ReplicationOffered(c) { |
| + t.Errorf("ReplicationOffered(%q) = false", c) |
| + } |
| + } |
| + if ReplicationOffered("") { |
| + t.Error("the default is offered as a rung") |
| } |
| } |
diff --git a/route_replication_test.go b/route_replication_test.go| index ec6fc74e0..937dde13e 100644 |
| --- a/route_replication_test.go |
| +++ b/route_replication_test.go |
| @@ -331,3 +331,33 @@ func TestRouteHibernateOffClearsTheRung(t *testing.T) { |
| t.Errorf("route --replication on a woken route: want a refusal, got 0\n%s", out) |
| } |
| } |
| + |
| +// The flush-only class is a word the column may hold and not one an |
| +// operator may set: what it promises is not decided. The three rungs and |
| +// the clear sentinel's empty value are not refused here. |
| +func TestReplicationOfferedRefusesTheFlushOnlyClass(t *testing.T) { |
| + if err := replicationOffered(registry.ReplicationHibernateFlush); err == nil || !strings.Contains(err.Error(), "not offered") { |
| + t.Fatalf("hibernate-flush: %v; want it refused as not offered", err) |
| + } |
| + for _, class := range append(registry.ReplicationClasses(), "") { |
| + if err := replicationOffered(class); err != nil { |
| + t.Errorf("replicationOffered(%q) = %v", class, err) |
| + } |
| + } |
| + if got := replicationWord(registry.ReplicationHibernateFlush); !strings.Contains(got, "not offered") { |
| + t.Errorf("replicationWord(hibernate-flush) = %q; want it to say it is not offered", got) |
| + } |
| +} |
| + |
| +// A unit-backed route's database is not replicated by the activator, so |
| +// the rail refuses a rung for it, whatever the rung. |
| +func TestReplicationRailRefusesAUnitBackedRoute(t *testing.T) { |
| + rt := registry.Route{Host: "u.example.com", Hibernate: true, DBPath: "/var/lib/carlos-app/u.example.com/main.db", Backing: registry.BackingUnit} |
| + if err := replicationRail(rt); err == nil || !strings.Contains(err.Error(), "unit-backed") { |
| + t.Fatalf("a unit-backed route: %v; want it refused", err) |
| + } |
| + rt.Backing = "" |
| + if err := replicationRail(rt); err != nil { |
| + t.Fatalf("the same route, not unit-backed: %v", err) |
| + } |
| +} |