Repository navigation
refactor(server): session registry module with injected clock - #154
Merged
Merged
Conversation
tarakanof
commented
Sep 26, 2026
tarakanof
left a comment
Owner
Author
There was a problem hiding this comment.
Review: session registry (#147)
Verdict: merge as-is. Everything below is a nit.
What I checked
- Commit order:
4f775de(goldens) is first.TestStateGoldenpasses at4f775de(oldrenderLockedcode), atfba4468and at72ddcef. Thetestdata/state/*.jsonfiles never change after4f775de. OnlyseedStatechanges, from poking the map toUpserton a fake clock. So/stateis byte-identical, with timestamps masked. - Tests:
go vetandgo test -race -count=3pass on every package exceptember-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
ViewwithWinner/Count, andPolicy/Reaped). Behind it sit the map, the lock, per-state TTLs,UpdatedAtstamping, 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.Winneris a thin pass-through torender.PickWinning, but it's a cheap convenience and fine to keep. - Seam: the clock is a real seam with two adapters,
realClock{}.Nowand the fake in tests.policyandonReapare 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 toPolicyand the reap side effects sit in thesessions.goadapter. - Concurrency: no lock nesting.
metrics.rendernow readsView()before takingApp.mu, and doctor no longer takesApp.muat all. NoApp.musection (recordPublish,buildClockHealth,checkLastPublish, metrics) calls into the registry.onReapruns under the registry lock, but it only does an atomic add and anslogTextHandler 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 underApp.mu.a.metricsis assigned after the registry inNewApp, but it's read lazily and no reap can fire before then.PickWinningties 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
- An undocumented 4th behaviour change.
Registry.Deletereaps before it deletes, while the oldApp.Deletedeleted and then reaped. ADELETEof a session that is already stale now logs a Warnsession reapedand bumpsember_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 inDeleteand pin that with a test (TestDeleteAndClearonly covers fresh keys). - Stale comment at
cmd/ember/app.go:202: "legacy Render struct (renderLocked's text/color/counter output)".renderLockedno longer exists; it should saylegacyRender. TestConcurrentAccessnever reaps. It uses a 1-minute policy, the real clock and a nilonReap, so-racenever runs the delete-during-iteration andonReap-under-lock path concurrently. A short TTL plus a countingonReapwould cover it.TestSessionReapIsCountedWithoutRendertriggers the reap viaapp.Delete, which still builds alegacyRender. The name overstates what it shows.TestMetrics_SessionsActiveExcludesStaleis 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
force-pushed
the
refactor/147-session-registry
branch
from
September 26, 2026 14:27
b1b926b to
e076b1f
Compare
This was referenced Sep 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
/staterender (renderLocked), on the wall clock, inside the App god-struct. Tests had to pokeapp.sessionsand callSnapshot()just to trigger the reap. This PR moves that into a newinternal/sessionsRegistry, which owns:UpdatedAtstampingIt runs on an injected clock, and every access reaps, so no reader ever sees a stale session. Nothing depends on a render anymore.
Before / after
Commits
test: goldenGET /statebodies (testdata/state/*.json, timestamps masked), recorded against the old code.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.refactor(server): App switches over. The goldens are unchanged.docs: ARCHITECTURE session model and the STYLE file map.Behaviour changes
/state: none. The goldens are byte-identical.ember_sessions_activeand the/admin/doctorsessions summary no longer count sessions that are already stale, because they reap on read. This was the bug the issue describes."", and the old copy counts as a reap. Before, this depended on whether a tick had already reaped it (race-only).UpdatedAtis stamped by the registry clock instead ofnormalized(). It's the same wall clock in prod.Shared-file hunks (minimal)
app.go: the field changes tosessions *sessions.Registry, theApp.mucomment now covers only publish telemetry, and there's one line inNewApp(a.sessions = a.newSessionRegistry(realClock{}.Now)). The coordinator construction is untouched.metrics.go(1 line) anddoctor.go(4 lines) readapp.sessions.View()instead of the raw map.I didn't touch
*_config.go(#144) or coordinator/preview (#145).TestPickWinningTable(the SwiftpickWinningmirror) is unchanged, and so is Swift.Tests
go vet, go1.26gofmt -l(empty) andgo test -raceall pass on every package exceptember-claude-producer, which I excluded until #143 lands.