Drop log in time-bucketed Redis streams
Date: 2026-09-02 Status: approved for implementationProblem
The WebSocket gateway’s drop log is one Redis stream,ws:droplog, holding 24
hours of drop events: about 2,000,000 entries a day at roughly 4 KB each, so an
8 GB value in a single key. Dragonfly splits --maxmemory (13 GiB) across its
32 shards and rejects new-key inserts in any shard whose budget (about 416 MiB)
is exhausted, even when global usage is far below the cap. One 8 GB key
therefore turns one shard permanently over budget. On 1 to 2 Sep 2026 this
rejected 246,777 writes across every service that uses Dragonfly, wedged the
WebSocket replay writer, and OOM-killed v3 twice.
The stopgap is DROPLOG_MAX_LEN=90000, which caps the log at about one hour of
history instead of 24. This spec restores 24 hours without any single key
outgrowing a shard.
Goals
- No drop log key larger than a small fraction of a shard budget at any volume the gateway can plausibly reach (target under 200 MB at twice today’s peak).
- 24 hour retention with no trim job doing the work.
- No change to the cursor contract:
/internal/dropsand the hub keep passing<ms>-<seq>stream ids assince,min,max,cursorandhead. - No change to entry encoding, dedupe semantics, decode-skip handling, the in-memory hour cache’s behaviour, or the board rebuild.
- Zero-downtime cutover: entries written before the deploy stay readable until they age out.
- Everything Redis-touching unit-tested against a real stream implementation.
Non-goals
- Shrinking entries, changing what is persisted, or moving the log out of Redis.
- Touching the limit log (
ws:limitlog, 37 MB) or the replay store. - Multi-writer support. The drop pump is the single writer today and stays so.
Design
Keys
Each entry is written to the bucket stream for its id’s millisecond:EXPIRE the first time
a process writes to a bucket (tracked in a small in-memory set of bucket starts
this process has already stamped, so a restart re-stamps at most one bucket).
Retention therefore needs no trim job. A per-bucket MAXLEN ~ of
DROPLOG_BUCKET_MAX_LEN (default 60,000, about 240 MB) remains as a runaway
backstop only; the near-cap warning moves to the bucket level.
Ids
The writer assigns complete ids explicitly as<ms>-<seq>. ms is the pump’s
wall clock at append time, clamped to be non-decreasing within the process;
seq is 0 for a new millisecond and increments while consecutive appends share
one. (Dragonfly 1.40 also accepts the <ms>-* shorthand, verified 2 Sep 2026,
but miniredis does not, and a complete id costs nothing.) The bucket is derived
from that same ms, so the invariant every reader relies on holds by
construction:
An entry with idBecause ids are globally ordered by<ms>-<seq>lives in bucketbucket(ms), and all entries in bucketbhaveb <= ms < b + dropLogBucketMs.
(ms, seq) and every bucket covers a
disjoint time range, reading buckets in time order yields the same global
order the single stream had.
Before its first append a process seeds the allocator from the newest live
bucket (it probes bucket(now)+1, bucket(now), bucket(now)-1 for a top id),
so a restart on a clock a few seconds behind its predecessor continues after
the existing ids instead of writing behind them into an older bucket that
cursor readers have already passed.
If Redis answers “ID specified in XADD is equal or smaller than the target
stream top item” (a process restarted on a clock behind its predecessor, or a
second writer), the writer reads the bucket’s top id, retries once with
ms = top.ms and seq = top.seq + 1, and clamps its monotonic clock to that
value. top.ms is inside the bucket by the invariant, so the retried id is
too. If the retry also fails the batch returns the error and the pump’s
existing hold-and-retry handles it.
Reading
One iterator module owns bucket enumeration and both directions:forwardScan(start exclusiveId|"", end inclusiveId|"", page, visit)Buckets frombucket(start.ms)(or the retention floor) up tobucket(end.ms)(orbucket(now + bucketMs)).XRANGEwith(starton the first bucket and-on later ones;endbounds the last bucket,+elsewhere.backwardScan(end exclusiveOrInclusive|"", min inclusiveId|"", page, visit)Buckets frombucket(end.ms)(orbucket(now + bucketMs)) down tobucket(min.ms)(or the retention floor).XREVRANGEwith the caller’s end bound on the first bucket and+on later ones;minbounds the last bucket,-elsewhere.
visit receives one page at a time and returns whether to continue, and the
page size is a callback so limit-bounded readers ask each bucket only for what
they still need. Callers keep their existing paging, limit, dedupe, decode-skip
and scan-cap logic unchanged.
Empty buckets cost one round trip each. To keep the steady state at one or two
round trips: the bucket window’s floor is clamped to bucket(cutover_ms) once
the cutover is known (no bucket can exist before it), and the bucket after
bucket(now) is probed only within one minute of a boundary. A full 24 hour
sweep over a sparse log is therefore at most ~145 extra round trips, within the
existing 60k scan cap’s cost profile.
The retention floor is now - 24h - dropLogBucketMs. Readers never enumerate
buckets older than the floor, so expired keys are never requested.
Consumers and how they map:
Cutover
The legacy keyws:droplog is read as one extra pseudo-bucket that sits below
every real bucket, but only while it can still hold data:
- On its first bucketed append the new code does
SET NX ws:droplog:cutover_ms <ms>. - Readers cache that value (re-read every minute until found). A scan whose
range reaches below
cutover_msalso scans the legacy key with the same bounds, in the correct position (after all real buckets in a backward scan, before them in a forward scan). A cursor whosems < cutover_msmaps to the legacy key. - Once
now > cutover_ms + 25hthe legacy key is never consulted again. - Railway keeps the previous deployment serving until the new one is healthy,
so for up to about a minute the old process still appends to the legacy key
while the new one writes buckets. Those few legacy entries carry ids newer
than
cutover_ms; readers still return them, but in the legacy slot rather than interleaved by id. The hub dedupes by drop id, so the only effect is a one-time ordering wobble inside one page during the cutover minute.
runDropLogTrim keeps running every 10 minutes. It keeps trimming the legacy
key to 24 hours and deletes it once empty, and as a belt-and-braces sweep it
UNLINKs any bucket key between floor - 6h and floor (a bounded list of at
most 36 computed keys, no SCAN). The near-cap warning is emitted per bucket.
DROPLOG_MAX_LEN is retired; the Railway variable is removed at deploy.
Volume at the memory cache
The hour cache (memCacheRetainMs, 65 minutes) spans 7 buckets. Its warm and
tail sync change only in which iterator they call.
Failure handling
- Append errors surface exactly as today; the pump holds the unpersisted tail and retries with backoff.
- A failed
EXPIREis retried on the next append to that bucket (the bucket is only recorded as stamped afterEXPIREsucceeds). Worst case a bucket without a TTL is caught by the trim job’sUNLINKsweep. - Decode failures per entry are counted and skipped exactly as today.
- A cursor pointing into a bucket that has expired simply starts at the oldest
live bucket; no error, matching how
XRANGEpast a trimmed id behaved.
Testing
Addgithub.com/alicebob/miniredis/v2 as a test dependency and run the real
go-redis client against it. Streams with explicit ids, XRANGE, XREVRANGE,
EXPIRE and TTL are all supported. A dropLogNow func() time.Time seam
provides a controllable clock.
Coverage required before merge:
- Bucket key math: boundaries, floor, id parsing, invariant on written ids.
- Append: entries land in the right bucket across a boundary;
EXPIREset once per bucket per process; monotonic clamp; retry after “equal or smaller”. ReadDropEventsSince: newest window across buckets; cursor paging across a boundary; cursor exactly on a boundary; empty bucket in the middle; dedupe across a bucket boundary.ReadDropEventsBack:max/cursorandminin different buckets;head;hasMore.- Matched sweep: matches across buckets, dedupe across boundary, scan cap.
- Memory cache warm and tail sync across buckets.
- Legacy fallback: entries written to
ws:droplogbeforecutover_msare returned in order behind bucketed ones; ignored after the 25 hour window. - Trim job: legacy trimmed then deleted; stale bucket keys unlinked.
- Existing droplog, cache, board and sweep tests keep passing unchanged.
Rollout
- Merge and deploy v3. First append creates
cutover_ms; history stays complete because reads span both stores. - Remove
DROPLOG_MAX_LENfrom the v3 service. The legacy stream trims itself away within 24 hours. - Verify with
INFO stats(oom_rejectionsflat),MEMORY USAGEon a few bucket keys (under 200 MB),TTLon a bucket (under 25h), and the monitor’sdropslayer staying green. - After 25 hours confirm
ws:droplogis gone and/internal/dropswithmin24 hours back returns a full day.
Alternatives considered
- Postgres partitioned table: cleanest query model, but v3 production has no Postgres write path, so it needs credentials, DigitalOcean firewall changes for Railway egress, and a new id scheme mapped onto the cursor contract.
- Dragonfly
--num_shards=2on a 32 GB container: config only, but doubles the Dragonfly bill, cuts parallelism, and leaves the 8 GB key to grow again. - Hash-sharded streams (
ws:droplog:<hash % N>): even key sizes, but every range read becomes an N-way merge and cursors stop mapping to one stream.