Skip to content

refactor(server): session registry module with injected clock - #154

Merged
tarakanof merged 5 commits into
mainfrom
refactor/147-session-registry
Sep 26, 2026
Merged

tarakanof merged 5 commits into
mainfrom
refactor/147-session-registry

Conversation

@tarakanof

Copy link
Copy Markdown
Owner

Closes #147

The design (both interface sketches and the choice) is summarised in the issue comment on #147.

What and why

Stale-session reaping used to happen as a side effect of the legacy /state render (renderLocked), on the wall clock, inside the App god-struct. Tests had to poke app.sessions and call Snapshot() just to trigger the reap. This PR moves that into a new internal/sessions Registry, which owns:

  • the map
  • per-state staleness and the done/error linger
  • UpdatedAt stamping
  • newest-first ordering
  • the winner/count view

It runs on an injected clock, and every access reaps, so no reader ever sees a stale session. Nothing depends on a render anymore.

func New(now func() time.Time, policy func() Policy, onReap func(Reaped)) *Registry
func (r *Registry) Upsert(s render.Session) (View, string) // prior state, one lock
func (r *Registry) Delete(key string) View
func (r *Registry) Clear() View
func (r *Registry) View() View
type View struct{ Now time.Time; Sessions []render.Session } // newest first
func (v View) Winner() *render.Session // render.PickWinning
func (v View) Count(state string) int

Before / after

Before                                   After
GET /state ─┐                            GET /state ─┐
coord tick ─┼─ App.Snapshot              coord tick ─┼─ App.Snapshot ─┐
POST/DELETE ┘   └ renderLocked(time.Now) POST/DELETE ┘                ├─ sessions.Registry (clock, policy, reap)
                   ├ reap (log, metric)  /metrics, /admin/doctor ─────┘       │ View
                   └ label render                                     legacyRender(View)  (pure /state label)
/metrics, doctor ─ len(app.sessions) (never reaped)

Commits

  1. test: golden GET /state bodies (testdata/state/*.json, timestamps masked), recorded against the old code.
  2. feat(sessions): the registry, with fake-clock tests covering per-state boundaries, reap on every access, prior state, ordering, delete/clear, policy hot swap, winner/count and concurrency.
  3. refactor(server): App switches over. The goldens are unchanged.
  4. docs: ARCHITECTURE session model and the STYLE file map.

Behaviour changes

  • /state: none. The goldens are byte-identical.
  • ember_sessions_active and the /admin/doctor sessions summary no longer count sessions that are already stale, because they reap on read. This was the bug the issue describes.
  • A session re-posted after going stale now always reports prior "", and the old copy counts as a reap. Before, this depended on whether a tick had already reaped it (race-only).
  • UpdatedAt is stamped by the registry clock instead of normalized(). It's the same wall clock in prod.

Shared-file hunks (minimal)

  • app.go: the field changes to sessions *sessions.Registry, the App.mu comment now covers only publish telemetry, and there's one line in NewApp (a.sessions = a.newSessionRegistry(realClock{}.Now)). The coordinator construction is untouched.
  • metrics.go (1 line) and doctor.go (4 lines) read app.sessions.View() instead of the raw map.

I didn't touch *_config.go (#144) or coordinator/preview (#145). TestPickWinningTable (the Swift pickWinning mirror) is unchanged, and so is Swift.

Tests

go vet, go1.26 gofmt -l (empty) and go test -race all pass on every package except ember-claude-producer, which I excluded until #143 lands.

@tarakanof tarakanof left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: session registry (#147)

Verdict: merge as-is. Everything below is a nit.

What I checked

  • Commit order: 4f775de (goldens) is first. TestStateGolden passes at 4f775de (old renderLocked code), at fba4468 and at 72ddcef. The testdata/state/*.json files never change after 4f775de. Only seedState changes, from poking the map to Upsert on a fake clock. So /state is byte-identical, with timestamps masked.
  • Tests: go vet and go test -race -count=3 pass on every package except ember-claude-producer, which I excluded because the branch predates #143. CI is green, and the branch merges cleanly with main (e17b477).
  • Depth: a small interface (4 methods, a View with Winner/Count, and Policy/Reaped). Behind it sit the map, the lock, per-state TTLs, UpdatedAt stamping, ordering, reaping and prior-state-under-one-lock. The deletion test passes: without the module, reaping and staleness would have to reappear in App, metrics and doctor, and each would have to decide when to reap (which is exactly the bug). View.Winner is a thin pass-through to render.PickWinning, but it's a cheap convenience and fine to keep.
  • Seam: the clock is a real seam with two adapters, realClock{}.Now and the fake in tests. policy and onReap are injected, so the hot-reload and metrics/log wiring can be tested at the App level.
  • Locality: all the lifetime rules live in internal/sessions/registry.go. The mapping from config to Policy and the reap side effects sit in the sessions.go adapter.
  • Concurrency: no lock nesting. metrics.render now reads View() before taking App.mu, and doctor no longer takes App.mu at all. No App.mu section (recordPublish, buildClockHealth, checkLastPublish, metrics) calls into the registry. onReap runs under the registry lock, but it only does an atomic add and an slog TextHandler write to stdout (which has its own mutex), so nothing re-enters and it can't deadlock. That stdout write under the lock is the same as before, when it happened under App.mu. a.metrics is assigned after the registry in NewApp, but it's read lazily and no reap can fire before then. PickWinning ties now resolve on sorted order rather than map order, which is not observable.
  • Behaviour changes: the three listed are all acceptable. The gauge/doctor fix is the point of the issue, and the prior-"" after a stale re-POST only changes a race.

Nits

  1. An undocumented 4th behaviour change. Registry.Delete reaps before it deletes, while the old App.Delete deleted and then reaped. A DELETE of a session that is already stale now logs a Warn session reaped and bumps ember_sessions_evicted_total. In practice the coordinator tick usually reaped it already, so this only shows in a race. Either list it in the PR body, or delete before reaping in Delete and pin that with a test (TestDeleteAndClear only covers fresh keys).
  2. Stale comment at cmd/ember/app.go:202: "legacy Render struct (renderLocked's text/color/counter output)". renderLocked no longer exists; it should say legacyRender.
  3. TestConcurrentAccess never reaps. It uses a 1-minute policy, the real clock and a nil onReap, so -race never runs the delete-during-iteration and onReap-under-lock path concurrently. A short TTL plus a counting onReap would cover it.
  4. TestSessionReapIsCountedWithoutRender triggers the reap via app.Delete, which still builds a legacyRender. The name overstates what it shows. TestMetrics_SessionsActiveExcludesStale is the real "no render needed" proof, so consider renaming this one (for example ...CountedOnAnyAccess).

Byte-level guard for the session-registry refactor (#147): session order,
the legacy Render label/colour/counters and which sessions the per-state
staleness policy drops. Timestamps are masked because the map still runs
on the wall clock.
The session map, per-state staleness and done/error linger, UpdatedAt
stamping, newest-first ordering and the winner/count view move behind one
module. Every access reaps against the registry clock, so reaping no longer
depends on anyone rendering /state, and tests drive time with a fake clock
instead of back-dating map entries (#147).
…ts view

App.sessions is now the internal/sessions registry, so reaping happens on
every access with the registry clock instead of inside the /state render.
The legacy /state label becomes a pure function of the registry view, and
App.mu guards only the publish telemetry. /metrics sessions_active and the
doctor sessions summary read the reaped view, so they stop counting sessions
that are already stale. Tests drive time with a fake clock instead of poking
the map; the /state goldens are unchanged (#147).
Deleting a session that has already gone stale is a producer's explicit
delete, not an eviction: restore the old order so it isn't logged as
'session reaped' or counted in ember_sessions_evicted_total. The
concurrency test now reaps under -race, a test name says what it proves,
and a stale renderLocked comment is updated (#147 review).
@tarakanof
tarakanof force-pushed the refactor/147-session-registry branch from b1b926b to e076b1f Compare September 26, 2026 14:27
@tarakanof
tarakanof merged commit 705e1af into main Sep 26, 2026
3 checks passed
@tarakanof
tarakanof deleted the refactor/147-session-registry branch September 26, 2026 14:32
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.

refactor(server): session registry module with injected clock

1 participant