eleven / messenger Public

server: cut litestream's idle S3 chatter ~10x (both replication roles) (#747)

Litestream's defaults are tuned for hot failover; both of our roles are
gentler than that, yet the fleet burned ~113k S3 Tier-1 requests in ~14h
(~$0.56) with near-zero writes — dominated by per-database retention/
compaction LISTs on 15s/30s timers (v0.5.14: the L0 retention monitor
LISTs L1+L0 per DB every 15s; each compaction-level monitor costs up to
two LISTs per DB per tick, L1 every 30s).

Box-wide backup (deploy/fleet-backup.sh — generated /etc/litestream.yml):
this is a backup, not failover, so recency trades for request volume:
sync-interval 60s (RPO ~= one minute of a space's ciphertext; idle DBs
upload nothing at any interval), compaction ladder 5m/30m/3h (same
geometric shape as the 30s/5m/1h defaults), snapshots pinned daily with
72h retention (v0.5 enforces snapshot retention on the snapshot tick —
there is no separate retention-check knob), and L0 retention checks
hourly instead of every 15s (L0 files only become reclaimable after an
L1 compaction, so hourly sweeps delete exactly the same files). Re-run
the script on the box to regenerate the config and restart the unit.

Embedded activator (internal/activator/replica.go): hibernating
instances checkpoint deterministically at sleep via SyncAndWait, which
calls db.Sync + Replica.Sync directly — independent of every interval
here — so background compaction promptness buys nothing. Custom
CompactionLevels L1 5m / L2 30m / L3 3h replace DefaultCompactionLevels,
DB.MonitorInterval 1s -> 10s (local WAL poll, no S3), and the store's
L0RetentionCheckInterval 15s -> 5m (matching L1 — checking more often
than L1 compacts is pure waste). Replica.SyncInterval deliberately stays
1s: idle replica ticks make zero S3 calls, and while awake it bounds
crash-loss to ~1s against the 30s lease TTL. Every compaction monitor
still runs an immediate pass at store.Open, so each wake compacts
whatever a previous short awake window left pending.

Requests/day, current shape (6 always-on DBs + mostly-asleep fleet):
before ~750/h per always-on DB idle (L0 retention 240 ticks x 2 LISTs +
L1 120 x 2 + L2/L3) ~= 108k/day for the box backup alone, ~194k/day
observed fleet-wide. After: ~31/h per fleet-backup DB (~4.5k/day for 6)
and ~53/h per awake activator store; mostly-asleep instances pay a
handful of requests per wake/sleep cycle. Fleet total lands in the
mid-single-digit thousands/day — a 20-40x cut on the backup role and
>10x fleet-wide (~$0.97/day -> ~$0.03/day).

Verified: full go test ./... green; live suites against
LCHAT_TEST_S3_BUCKET (hibernation cycle, mesh drain failover, mesh
hard-kill failover, lease mutual exclusion) all pass with the new
cadences — logs confirm the monitors start at 5m/30m/3h/24h and the
sleep-time drain still ships the final TXID before the lease releases.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 01652fabdd9f419edf428b3ef7d2abd226789e94 parent 6397b9a
2 files changed, +79 −1
  • deploy/fleet-backup.sh +37 −0
  • internal/activator/replica.go +42 −1
diff --git a/deploy/fleet-backup.sh b/deploy/fleet-backup.sh
index b7dfa62..0cad248 100755
--- a/deploy/fleet-backup.sh
+++ b/deploy/fleet-backup.sh
@@ -72,6 +72,43 @@ fi
{
echo "# generated by deploy/fleet-backup.sh — re-run the script, don't edit"
+ # Cadence: this replicator is a BACKUP, not failover (the activator owns
+ # failover for hibernating hosts), so every timer below trades recency for
+ # S3 request volume. Litestream's defaults (sync 1s, L1 compaction 30s, L0
+ # retention LISTs every 15s) burned ~113k Tier-1 requests in 14h across the
+ # fleet with near-zero writes — the retention/compaction monitors LIST per
+ # database per tick whether or not anything changed.
+ cat <<'YAML'
+# RPO: at most ~60s of a space's ciphertext is lost if the box dies — for a
+# backup of family-chat instances that is plenty; idle databases upload
+# nothing regardless (an idle sync tick makes no S3 calls).
+sync-interval: 60s
+# Compaction ladder, ~10x the 30s/5m/1h defaults with the same geometric
+# shape (restore read-amplification stays bounded). L1's tick is the request
+# driver — each level's monitor costs up to two LISTs per database per tick.
+levels:
+ - interval: 5m
+ - interval: 30m
+ - interval: 3h
+# Snapshots stay daily — the gentle direction here is LESS often, not the
+# hourly one might reach for: each snapshot uploads a full database image.
+# Retention 72h keeps ~3 daily snapshots so one bad newest snapshot never
+# strands the restore chain. v0.5 enforces snapshot retention on the snapshot
+# tick itself (there is no separate retention-check knob), so retention
+# sweeps are daily too.
+snapshot:
+ interval: 24h
+ retention: 72h
+# The L0 retention monitor is the worst idle offender: every tick LISTs L1
+# (and usually L0) per database even with zero writes, and its 15s default
+# exists for litestream's VFS read replicas, which we don't run. L0 files
+# only become reclaimable after an L1 compaction, so hourly sweeps delete
+# exactly the same files — just 240x fewer LISTs. l0-retention is the minimum
+# age before a compacted L0 file may go; 15m costs pennies of storage and
+# keeps recent increments around for point-in-time restores.
+l0-retention: 15m
+l0-retention-check-interval: 1h
+YAML
echo "dbs:"
for f in "${dbs[@]}"; do
[ -f "$f" ] || { echo "warning: $f missing, skipped" >&2; continue; }
diff --git a/internal/activator/replica.go b/internal/activator/replica.go
index 29d2737..2a59389 100644
--- a/internal/activator/replica.go
+++ b/internal/activator/replica.go
@@ -28,6 +28,45 @@ import (
// ~30s of its last renewal — the failover window of the whole design.
const LeaseTTL = 30 * time.Second
+// compactionLevels replaces litestream.DefaultCompactionLevels (L1 30s /
+// L2 5m / L3 1h) with a ~10× 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.
+// 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.
+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},
+}
+
+// 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.
+// (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.)
+const dbMonitorInterval = 10 * time.Second
+
+// l0RetentionCheckInterval slows the store's L0 retention monitor (default
+// 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
+
// 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;
// retrying could fence with a new claimant who took over after expiry.
@@ -165,13 +204,15 @@ func (h *HeldLease) Replicate(ctx context.Context, dbPath string) (*LeasedReplic
// error loop until their meta dirs were cleared by hand. A distinct
// namespace ends the class.
db.SetMetaPath(filepath.Join(filepath.Dir(dbPath), ".activator-"+filepath.Base(dbPath)+"-litestream"))
+ db.MonitorInterval = dbMonitorInterval
client := lss3.NewReplicaClient()
client.Bucket = h.leaser.Bucket
client.Path = h.leaser.Path
client.Region = h.region
db.Replica = litestream.NewReplicaWithClient(db, client)
- store := litestream.NewStore([]*litestream.DB{db}, litestream.DefaultCompactionLevels)
+ store := litestream.NewStore([]*litestream.DB{db}, compactionLevels)
+ store.L0RetentionCheckInterval = l0RetentionCheckInterval
if err := store.Open(ctx); err != nil {
return nil, fmt.Errorf("open litestream store: %w", err)
}