eleven / messenger Public

server: stretch S3 control-plane cadences — lease TTL 15m, mesh beat 60s, gentler compaction (#836)

The fleet's S3 bill is ~100% Tier-1 requests, not data: at 20 instances it
ran ~$0.50/day (~$18/yr per always-awake instance vs €5/y plans). ~70% was
the 30s lease TTL's 10s renewal PUTs, ~12% the 15s mesh heartbeat, ~13% the
compaction/retention monitor LISTs. Data volume: 67 MB (~$0.0003/day).

- LeaseTTL 30s → 15m (renew 5m): 8,640 → 288 PUTs/day per awake instance.
  Failover/crash-rewake window widens to ~15m, but only for HARD crashes —
  graceful sleep/deploy/shutdown release the lease explicitly.
- meshHeartbeatInterval 15s → 60s: 11.5k → 2.9k req/day/box; dead-box
  detection 45s → 3m, still well inside the lease expiry that gates takeover.
- Compaction ladder 5m/30m/3h → 1h/6h/24h and L0 retention check 5m → 1h:
  ~1.3k → ~110 LISTs/day per awake instance; compaction remains purely a
  restore-time read-amplification optimisation.

Always-awake instance: ~$18/yr → ~$0.72/yr. Asleep instances stay zero.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 69e9b47114fcf236a64d2dbab077718def404639 parent 10eecad
3 files changed, +52 −29
  • docs/planet-scale.md +14 −7
  • internal/activator/replica.go +28 −17
  • mesh.go +10 −5
diff --git a/docs/planet-scale.md b/docs/planet-scale.md
index 95d7e8d..432827c 100644
--- a/docs/planet-scale.md
+++ b/docs/planet-scale.md
@@ -123,9 +123,14 @@ correction the plan earned by testing (recorded on #550): the issue assumed
the lease was CLI config, but **the lease exists only in the library API — the
CLI silently absorbs a second replicator**, orphaning the loser's writes with
no error signal. Embedding it turned that from a footgun into the mechanism.
-TTL is 30 s, renewed every 10 s; a failed renewal means leadership is lost and
-the holder stands down — a wedged process is claimable by a healthy peer
-within ~30 s, which is the failover window of the whole design. Restores run
+TTL is 15 m, renewed every 5 m (each renewal is one S3 Tier-1 PUT — at the
+original 30 s/10 s cadence the lease heartbeat alone was ~70% of the fleet's
+S3 request bill, ~$16/yr per always-awake instance against €5/y plans); a
+failed renewal means leadership is lost and the holder stands down. The
+failover window follows the TTL: a wedged or hard-killed process is claimable
+by a healthy peer within ~15 m of its last renewal — every graceful path
+(sleep, deploy, shutdown) releases explicitly and hands over in seconds, so
+the window prices only real crashes. Restores run
*under* the held lease (restoring outside it could resurrect a stale copy that
shadows the true holder's newer state).
@@ -249,10 +254,11 @@ The multi-box mechanism from Part 4 v2 shipped as PR #678, gated on
codepaths, and the current single-box deployment runs exactly as before.
- **Membership is S3 heartbeats**, not gossip: each box PUTs one object per
- 15 seconds under the hibernation prefix's `mesh/members/`, and the object
+ 60 seconds under the hibernation prefix's `mesh/members/`, and the object
*key* is the record (base64url of the peer URL) so a single LIST yields the
whole membership. Liveness reads S3's own `LastModified`, so a box with a
- wrong clock can't poison the staleness judgment. Stale after 45s.
+ wrong clock can't poison the staleness judgment. Stale after 3 m (three
+ missed beats — well inside the lease expiry that actually gates takeover).
- **Rendezvous (HRW) hashing is the placement hint** — `owner(host)` is a
pure function of the live member set; adding a box moves only the hosts
that land on it. **The lease stays the truth**: a box that wrongly believes
@@ -265,8 +271,9 @@ codepaths, and the current single-box deployment runs exactly as before.
- **Drain deletes the heartbeat first**, then checkpoints and releases as
usual — so deploys, scale-down, and box death are the same code path. The
kill-a-box property is a live test, both ways: a drained owner's instances
- are serving on the survivor ~2 seconds later; a SIGKILLed owner's within
- ~30 (the lease TTL), data intact in both.
+ are serving on the survivor ~2 seconds later; a SIGKILLed owner's once its
+ leases expire (the TTL — proven live at the original 30 s TTL, since raised
+ to 15 m for S3 request cost), data intact in both.
Hardening that same week (PR #681, both lessons from the first real
production flip): flipping a host refuses while the box-wide backup
diff --git a/internal/activator/replica.go b/internal/activator/replica.go
index 2a59389..b9b8faf 100644
--- a/internal/activator/replica.go
+++ b/internal/activator/replica.go
@@ -23,37 +23,46 @@ import (
lss3 "github.com/benbjohnson/litestream/s3"
)
-// LeaseTTL matches litestream's own default. Renewal runs at TTL/3, so a
-// wedged process loses the lease (and a healthy peer can claim it) within
-// ~30s of its last renewal — the failover window of the whole design.
-const LeaseTTL = 30 * time.Second
+// LeaseTTL is deliberately long — 30× litestream's 30s default. Renewal runs
+// at TTL/3 and each renewal is one S3 Tier-1 PUT, so at the old 30s TTL the
+// lease heartbeat alone was 8,640 PUTs/day per awake instance (~$16/yr —
+// ~70% of the fleet's entire S3 bill, and more than a €5/y space's revenue);
+// at 15m it's 288/day (~$0.50/yr). The price is the failover window: a
+// HARD-crashed holder (kill -9, box death) strands the lease until expiry,
+// so a peer's takeover — or a re-wake on the same box — can stall up to
+// ~15m. Every graceful path (sleep, deploy, shutdown) releases the lease
+// explicitly and is unaffected. Shorten this only if fast crash-failover
+// ever outranks per-instance cost.
+const LeaseTTL = 15 * time.Minute
// compactionLevels replaces litestream.DefaultCompactionLevels (L1 30s /
-// L2 5m / L3 1h) with a ~10× gentler ladder. The defaults are tuned for a
+// L2 5m / L3 1h) with a far gentler ladder. The defaults are tuned for a
// long-lived replicator where background compaction promptness matters; a
// hibernating instance instead checkpoints deterministically at sleep via
// SyncAndWait (which calls db.Sync + Replica.Sync directly, independent of
// every interval here), so aggressive background compaction buys nothing —
// it only burns S3 Tier-1 LISTs: each level's monitor tick costs up to two
-// LISTs per DB, and L1 at 30s dominated the fleet's idle request bill.
+// LISTs per DB even with zero writes — L1 at 30s dominated the fleet's idle
+// request bill, and the first fix (5m/30m/3h) still left ~1.3k LISTs/day per
+// awake instance once the lease PUTs were tamed.
// Correctness is untouched — compaction is purely a restore-time
// read-amplification optimisation (restore replays snapshot + whatever L0
// files exist), and each level's monitor still runs one immediate pass at
// store.Open, so every wake compacts anything a previous short awake window
// left pending. Same three-level geometric shape, so restore reads stay
-// bounded.
+// bounded: worst case a hard crash replays up to ~an hour of L0 files.
var compactionLevels = litestream.CompactionLevels{
{Level: 0, Interval: 0},
- {Level: 1, Interval: 5 * time.Minute},
- {Level: 2, Interval: 30 * time.Minute},
- {Level: 3, Interval: 3 * time.Hour},
+ {Level: 1, Interval: 1 * time.Hour},
+ {Level: 2, Interval: 6 * time.Hour},
+ {Level: 3, Interval: 24 * time.Hour},
}
// dbMonitorInterval slows litestream's DB monitor (default 1s) — the local
// WAL poll that shovels new frames into shadow LTX. It makes no S3 calls,
// but 1s polling per awake instance is pointless churn when the sleep-time
-// SyncAndWait ships everything regardless; 10s keeps an awake instance's
-// background replication lag negligible against the 30s lease TTL.
+// SyncAndWait ships everything regardless; 10s bounds an awake instance's
+// crash-loss window to ~10s, plenty tight for chat.
// (Replica.SyncInterval stays at its 1s default on purpose: idle replica
// ticks make zero S3 calls — the upload loop no-ops when the local TXID
// hasn't moved — and while awake it bounds crash-loss to ~1s.)
@@ -63,9 +72,10 @@ const dbMonitorInterval = 10 * time.Second
// 15s — tuned for litestream's VFS read replicas, which we don't run). Every
// tick LISTs L1 and usually L0 per DB even with zero writes — the single
// biggest idle-request source. L0 files only become reclaimable after an L1
-// compaction, so checking more often than the L1 interval (5m above) is pure
-// waste; matching it cuts the LISTs 20× with no change to what gets deleted.
-const l0RetentionCheckInterval = 5 * time.Minute
+// compaction, so checking more often than the L1 interval (1h above) is pure
+// waste; matching it cuts the LISTs 240× vs the default with no change to
+// what gets deleted.
+const l0RetentionCheckInterval = 1 * time.Hour
// renewer keeps a held lease alive on a ticker. A failed renewal is recorded
// and renewal stops — leadership is lost and the owner must stand down;
@@ -293,8 +303,9 @@ func (r *LeasedReplicator) Close(ctx context.Context) error {
if lease != nil {
// The release gets its OWN budget, never the caller's: a wedged final
// sync exhausts ctx, and releasing under a dead context leaves the
- // lease held until TTL — the next wake then 503s for up to 30s over
- // nothing (hit live on the first real instance flip). The release is
+ // lease held until TTL — the next wake then 503s for up to the full
+ // TTL over nothing (hit live on the first real instance flip). The
+ // release is
// one small S3 DELETE; five seconds is generous.
relCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
diff --git a/mesh.go b/mesh.go
index 12197c8..adb0c9f 100644
--- a/mesh.go
+++ b/mesh.go
@@ -75,14 +75,19 @@ const meshForwardedHeader = "X-Lchat-Mesh-Forwarded"
const (
// meshHeartbeatInterval paces the PUT + LIST beat. Membership changes are
- // rare events; this is liveness, not coordination.
- meshHeartbeatInterval = 15 * time.Second
+ // rare events; this is liveness, not coordination — and each beat is two
+ // S3 Tier-1 requests, so 15s cost ~11.5k requests/day per box (~$21/yr)
+ // for nothing membership-shaped in return. Slower still would work, but
+ // staleness (3 beats) rides on this, and 3m dead-box detection already
+ // sits well inside the lease expiry that actually gates takeover.
+ meshHeartbeatInterval = 60 * time.Second
// meshStaleAfter is how old a heartbeat may be before its box is presumed
// dead — three missed beats. It's judged against the reader's clock but
// the timestamp is S3's LastModified (the bucket's clock), so only
- // reader-vs-S3 skew matters, tiny against 45s. Together with the ~30s
- // lease TTL this bounds failover: a hard-killed box's hosts wake elsewhere
- // once its heartbeat is stale AND its leases expire.
+ // reader-vs-S3 skew matters, tiny against 3m. Together with the lease
+ // TTL (activator.LeaseTTL, 15m) this bounds failover: a hard-killed box's
+ // hosts wake elsewhere once its heartbeat is stale AND its leases expire —
+ // lease-dominated, so ~15m.
meshStaleAfter = 3 * meshHeartbeatInterval
)