Skip to content

rmap: recover replicas from lost changes and expiry, and add Sync - #92

Merged
raphael merged 10 commits into
mainfrom
rmap-sync
Nov 1, 2026
Merged

raphael merged 10 commits into
mainfrom
rmap-sync

Conversation

@raphael

@raphael raphael commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

A replicated map (rmap.Map) serves reads from each process's in-memory copy, which Redis keeps current by broadcasting every change. Two existing bugs could leave that copy wrong for good:

  • TTL expiry restarted revisions. When a map with WithTTL or WithSlidingTTL expired, its revision counter disappeared with its data. The next write was numbered 1, and every process that had seen higher revisions ignored it and kept serving the expired values.
  • A dropped connection lost changes silently. go-redis reconnects and resubscribes by itself, without closing the subscription channel, so changes published during the gap never arrived and the map never reloaded.

Processes also had no way to make their reads include a write another process had just finished. For example, a request that saves settings through process A, followed by a request that reads them through process B, could see the old settings.

What changes

  • Each published change now carries a generation as well as a revision. A write that finds the map's hash missing (never written, or expired) starts a new generation (a random id, stored with the last change's kind in the existing =kind field) and revisions restart at 1. Revisions advance by exactly one per published change, so a process applies a change only when it is the next revision of its own generation, and reloads the whole map from Redis when a change comes from another generation or skips a revision.
  • Every go-redis resubscription reloads the map (the subscription now uses ChannelWithSubscriptions). The hand-written reconnect loop is deleted.
  • New Map.Sync(ctx) reads the whole map once and replaces the local copy if Redis holds newer data, so later reads include every write that finished before the call. An expired map becomes empty. SetAndWait is now Set followed by Sync, which deletes the waiter registry and its lock.
  • pool and streaming wrote map hashes from their own Lua scripts by copying rmap's message format. That format now lives once, in internal/rmapwire: NewScript adds shared Lua helpers (publishSet, publishDel, publishClear, deleteMap), and Run supplies the generation id, removing it before the script's own code reads its arguments. All 13 rmap, 31 pool, and 6 streaming publish sites use the helpers, and a guard test fails if any script writes the format by hand.
  • A reload hides only rmap's two bookkeeping fields (=rev and =kind). Every other field that pool or streaming stores in a map's hash stays visible, whatever its name.
  • Go 1.27.3 is now required (Goa v3.35.0 needs it), CI tests Go 1.27, and every dependency is updated to its latest release.

Upgrading: the published change format changes, so every process that shares a map, including users of pool and streaming, must run this version together. Downgrading Pulse only requires stopping every newer process before an older one starts; no data cleanup is needed. The rmap README documents both.

Tests commit a change in Redis without publishing it, let a map expire for real while three processes hold it, drop one process's own subscriber connection while another writes, and deliver a stale change from an expired generation. Each fails against the previous code. The full race suite passes on Go 1.27.3.

A replica applies changes as Redis broadcasts them, so a write that has
completed through another process can be missing from its local copy for
a moment. SetAndWait only covered the writer's own reads; a process that
serves the next request after another replica's write had no way to make
its reads include that write.

Map.Sync reads the map's revision from Redis once and waits until the
local copy has applied it, so the Get, GetValues, Keys, Len, and Map calls
that follow include every write that completed before Sync was called. It
writes nothing and needs no coordination with other replicas: changes are
applied in revision order, so reaching a revision means every earlier
write is applied. It returns immediately for a never-written map, and
returns an error when the context ends, the map is stopped, or Redis
cannot be read; a reconnect reloads the map and also completes a pending
Sync.

SetAndWait and Sync now share one revision wait (waitForRevision), and
setWaiter is renamed revWaiter. Tests hold back a committed write's
notification to prove Sync waits for it, check reads through a second
replica, and cover a never-written map, a timeout, and Close. The rmap
README explains when to use SetAndWait and Sync.
Goa v3.35.0 requires Go 1.27.3, so Pulse now does too: the go directive
is 1.27.3, CI tests Go 1.27 only, and the README states the new minimum.
Every other direct and indirect dependency moves to its latest release,
including go-redis v9.23.0, gRPC v1.85.0, and OpenTelemetry v1.47.0.
Two bugs could leave a replicated map's local copy wrong for good, and
both broke the guarantee Sync advertises.

TTL expiry deleted the map's hash, revision counter included, so the
next write restarted at revision 1 and every replica that had seen
higher revisions ignored it. And go-redis reconnects and resubscribes
on its own after a lost connection without closing the subscription
channel, so changes published meanwhile were never delivered and the
map never reloaded.

Writes now stamp each change with a generation as well as a revision.
A generation starts when a write finds the hash missing (a random id
stored under "=gen", revisions restart at 1); Reset and Destroy keep
it. Revisions advance by exactly one per published change. A replica
applies a change only when it is the next revision of its own
generation, and reloads the whole map from Redis when a change is from
another generation or skips a revision, and after every go-redis
resubscription (run reads ChannelWithSubscriptions). The hand-written
reconnect loop is deleted.

Sync now reads the whole map once and replaces the local copy when
Redis holds newer data (an expired map becomes empty); it writes
nothing and waits for nothing. SetAndWait is Set followed by Sync, so
the waiter registry, its lock, and the revision waits are deleted.
A reload that skips several changes notifies subscribers with
EventReset.

Package pool and package streaming wrote map hashes from their own Lua
scripts by copying rmap's wire format. The format now lives once, in
internal/rmapwire: NewScript prepends shared Lua helpers (publishSet,
publishDel, publishClear, deleteMap), and Script.Run appends the fresh
generation id, which the prelude removes before the script's own code
reads ARGV. All 13 rmap, 31 pool, and 6 streaming publish sites use
the helpers. A guard test fails if any Lua script in the module
hand-writes the protocol or calls a helper without rmapwire.NewScript.
Snapshots now drop every field containing "=", which can never be a
map key, so private fields pool's scheduler keeps in the same hash no
longer appear after a reload.

Upgrading: the published format changes, so every process that uses a
replicated map must run this version; old and new versions cannot
share a map.
An empty replica with no generation adopted the first change of a new
generation without asking Redis. A change from an expired generation
that was still queued when Sync found the map gone looked the same, so
the replica brought the expired value back and kept it. A replica now
reloads for every change from a generation it does not hold; a new test
delivers such a stale change after Sync and checks the value stays gone.

strongerEventKind and eventPriority lost their only caller when the
reconnect loop was deleted and are removed.

The rmap README now states the upgrade and rollback rules: every process
sharing a map, including pool and streaming, must switch versions
together; downgrading Pulse requires stopping every newer process and
removing each map's =gen field before older versions start.
Explain that a whole-copy reload (after missed changes, expiry, or Sync)
sends one notification, EventReset when the copy changed generation or
skipped several changes, and that subscribers should read the map again.
The regression test now hands the stale change to the replica's message
handler directly, as the run loop would, so it asserts after the change
is processed instead of after an arbitrary delay. It still fails with
the removed empty-copy adoption.

The downgrade instructions no longer suggest Destroy: it keeps the
generation field and deletes the entries. Removing =gen with HDEL is the
step that keeps the data.
@raphael raphael changed the title rmap: add Sync to read after writes made through other processes rmap: recover replicas from lost changes and expiry, and add Sync Nov 1, 2026
A reload hid every field whose name contained "=". pool writes map
hashes directly and some of its fields are real entries with "=" in
them: a ticker named "a=b" is stored as "p:a=b", and pool names may
start with "=". A reload made such a ticker disappear, and NewTicker
then waited for it until its context ended.

A snapshot now hides only rmap's own bookkeeping fields (=rev, =kind,
=gen) and keeps every other field. New tests reload a hash holding
"p:a=b" and "=pool:x" and create tickers named "a=b" and in a pool
named "=..."; the ticker test fails with the previous filter.

TestSchedule asserted the scheduler map was physically empty, which no
production code relies on: the scheduler reads only its own prefixed
fields and keeps private "=transition:" fields in the same hash. It now
checks that no field under the scheduler's ownership prefix remains.
NewTicker waits for its stored field before returning, so a regression
that hid the field would block creation until the suite timeout. The
test now creates each ticker with a five-second context, and fails with
a deadline error instead.
The generation lived in a new "=gen" field of the map's hash. Sink
names are unrestricted and streaming stores each sink's membership
under its name in a replicated map, so once any sink had initialized
the generation, creating a sink named "=gen" failed with
SINKMEMBERSHIPINVALID, and a reload hid that sink's membership.

The hash now keeps only the two fields Pulse has always reserved:
"=rev" holds the revision and "=kind" holds the last change's kind and
the generation as "<kind>:<gen>". stamp takes the change's kind, reads
the generation from "=kind", and starts one when there is none. Every
other field, "=gen" included, belongs to the map's writers again.

Downgrading no longer needs data cleanup: older versions already hide
"=kind" and treat its new value as an ordinary change, so stopping every
newer process before an older one starts is enough. The README, the
test helper's tombstone check, and the guard test follow. A new
streaming test creates a sink and then one named "=gen"; it fails with
the previous field.
refresh kept the local copy whenever the snapshot was in the same
generation with an equal or lower revision. A map written by an older
Pulse carries no generation, so when it expired before any new write,
Sync read an empty hash with revision 0 in the same empty generation,
kept the expired entries, and returned success.

refresh holds the map lock while it reads, so the snapshot is never
older than the copy: every change the copy holds was already in Redis.
It now skips only an identical generation and revision, and applies
anything else, including a lower revision, which means the data is
gone; that case notifies EventReset. A new test loads a map in the old
format, lets it expire for real, and checks Sync empties the copy and
notifies a reset; it fails with the previous check.
@raphael
raphael merged commit 4c9ec66 into main Nov 1, 2026
5 checks passed
@raphael
raphael deleted the rmap-sync branch November 1, 2026 16:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant