Design spec: lantern extinguish/relight transaction cluster (#898) โ
Status: DRAFT, for operator review. Spec only, no code. Tracks #898. Author: heavy-hitters session, 2026-08-18. Grounded in services/api/lanterns/src/services/lantern.service.js and schedule.service.js at commit fe07699c. Precedent: the #889 fix, which unified the two LIGHT doors (manual + scheduled) onto one guard contract (activeLanternGuard* helpers). This spec asks whether to extend that move to the EXTINGUISH and RELIGHT paths.
The problem โ
The lantern lifecycle has one well-disciplined writer and several under-disciplined ones. lightLantern runs as a Firestore transaction with the activeLanterns/{userId} guard doc as its serialization point, so the single-lantern and venue-counter invariants hold there. The other writers that mutate the same state were never brought into that discipline:
| Writer | Location | Transaction? | Serialization point |
|---|---|---|---|
lightLantern | lantern.service.js:185 | yes | guard doc (tx.set) |
| scheduled activation | schedule.service.js:369 | yes (shares guard helpers, fixed in #889/#8) | guard doc |
extinguishLantern | lantern.service.js:402 | no (read-then-write) | none |
| auto-extinguish on read | getActiveLanterns :495 | batch (atomic write, no read-guard) | none |
| relight retire path | inside lightLantern :348 | yes, but blind-updates the old venue | old-venue doc |
The four #898 findings are one disease in different writers: a state transition mutates {lantern.status, public pin, venue.activeLanternCount, guard doc} without all four moving as one serialized atomic unit. activeLanternCount is denormalized, server-owned, and never recomputed, so any drift is permanent, it never self-heals.
Goals / non-goals โ
Goals
- Close the four findings: #3 (venue hotspot 500s), #4 (stranded guard), #5 (double-decrement drift), #13 (relight NOT_FOUND on a deleted venue).
- Decide explicitly whether to route every lantern-state writer through one shared transaction layer, or fix the four sites in place.
- State the invariants the layer must hold, so the next writer added (a new admin force-extinguish, a moderation extinguish) inherits them instead of re-deriving them.
Non-goals
- No change to the lantern data model, the public-pin de-identification, or the 2h TTL.
- No counter backfill/reconciliation job in this pass (raised as an open question, not built here).
- Not touching the scheduled-cancel
scheduledLanternCountpaths except where they share the active-lantern invariants. - No product code in this issue; this is the design pass #898 asked for.
Invariants the lantern transaction layer must hold โ
These are the column-independent truths every writer must uphold. Each finding is a specific writer breaking one.
- INV-1 Single active lantern. At most one active (unexpired) lantern per user. Enforced by the guard doc as the serialization point: two concurrent activations collide on
tx.set(guardRef, ...), production Firestore aborts one, and its retry sees the guard. (Holds today on both light doors.) - INV-2 Guard reflects reality. The guard doc exists-and-unexpired iff the user has an active lantern, and it names that lantern. A transition that ends the lantern must clear (or overwrite) the guard in the same atomic unit. Broken by #4: extinguish clears the guard best-effort AFTER commit, so a failure strands a future-dated guard and INV-2 breaks in the direction that 409s a legitimate relight for up to the TTL, with no user self-recovery (the collection is server-only).
- INV-3 Venue counter is exact.
venue.activeLanternCountequals the number of active lanterns at that venue. Each lifecycle transition moves it by exactly ยฑ1, exactly once, and never against a venue that does not exist. Broken by #5 (two concurrent extinguishes both decrement, drift โ1) and #13 (decrement against a deleted venue throws NOT_FOUND and takes the whole relight down). - INV-4 Atomic transition. Every transition (light, extinguish, expire-retire, relight-in-place) commits
{status, pin, counter, guard}as one unit, so no reader sees them out of sync. Broken wherever the writer is non-transactional (#5) or splits the guard/counter writes across separate awaits (#4). - INV-5 Serialize on a written doc, not a query. A concurrency guard only holds if the contenders write the SAME ref (an empty query takes no lock, the #889 production lesson). Light serializes on the guard doc; extinguish must serialize on the lantern doc (two concurrent extinguishes of one lantern collide on its status write).
Recommendation โ
Build a small shared lantern-transaction layer and route every writer through it, extending the #889 one-contract move from the light doors to the extinguish and relight paths. The four findings are not four bugs; they are one missing abstraction showing up four times. Fixing them in place closes the four tickets but leaves the next writer free to diverge again, which is exactly the shape #889 already taught us costs a production incident. A shared layer of transaction-body helpers (compose the {status, pin, counter, guard} write-set with the correct serialization point) makes the invariants above enforceable in one place and testable once.
This is a bounded refactor, not a rewrite: the helpers wrap logic that already exists, and the write-sets are small and already understood.
Key decisions โ
Decision A: how to fix extinguish (#5, #4, INV-3/4/5) โ
Recommend A2. Make extinguish a transaction serialized on the lantern doc, clearing the guard inside it.
| Option | Mechanism | Fixes | Risk |
|---|---|---|---|
| A1 patch in place | keep read-then-write, add a status compare-and-set + move guard delete before return | partial | still non-atomic across status/counter/guard; INV-4 not met |
| A2 (recommend) | extinguishLanternTx(tx): tx.get(lanternRef) inside the tx, re-check status==='active', then tx.update(status) + tx.delete(pin) + tx.update(venue, -1) + tx.delete(guard) (guard only if it still names THIS lantern) | #5, #4, INV-2/3/4/5 | one more transaction on the hot lantern path; negligible (a user extinguishes rarely) |
| A3 batch | atomic batch instead of a transaction | write-atomicity only | no read-guard, so two concurrent extinguishes still both pass the status check (INV-5 unmet); this is the getActiveLanterns bug |
A2 makes the lantern doc the serialization point: two concurrent extinguishes both tx.get then tx.update the same lantern doc, so one aborts and its retry sees status !== 'active' and 409s correctly (exactly once decremented).
Decision B: the venue hotspot (#3, INV-3) โ
Recommend B1. Read the venue OUTSIDE the transaction for the geofence check; keep only the blind increment inside.
| Option | What it does | Trade |
|---|---|---|
| B1 (recommend) | venueRef.get() before the tx for lat/lng/radius/name; inside the tx only tx.update(venueRef, increment(+1)) (a blind write needs no read) | venue config can be milliseconds stale for the geofence, which is fine (venue location/radius is stable config, not contended state). Removes the venue from the read-set, so concurrent lights at one venue no longer serialize on it, killing the retry-exhaustion 500 at the core busy-bar use case |
| B2 leave as-is | venue stays in the read-set | correctness identical, but the busy-venue 500 remains |
The blind increment is already how extinguish and the relight-retire touch the counter, so B1 makes lightLantern consistent with them, not an exception.
Decision C: relight against a deleted old venue (#13, INV-3) โ
Recommend C2. Existence-guard the old-venue decrement; skip it if the venue is gone.
| Option | What it does | Risk |
|---|---|---|
C1 set(merge:true) | upsert the counter on the old venue | resurrects a ghost venue as a partial doc holding only activeLanternCount, which then leaks into venue reads. Rejected |
| C2 (recommend) | tx.get(oldVenueRef); decrement only if it exists, else skip | adds the old venue to the read-set, but old-venue contention on a relight-elsewhere is rare (not the busy-same-venue path), so no hotspot. A deleted venue's count is meaningless, so skipping is correct |
| C3 tolerate the throw | catch NOT_FOUND around the decrement | a transaction cannot partially catch a write; the get-first C2 is the clean form |
Note C2 pulls the OLD venue into the read-set while B1 pulls the CURRENT venue out. These do not conflict: the hotspot is many users lighting at ONE current venue (B1 removes that), whereas the old-venue read only happens on a relight whose old lantern was at a DIFFERENT, now-deleted venue (rare, uncontended).
Decision D: defense-in-depth on the guard check (INV-2) โ
Recommend D1 as part of A2, D2 optional. With A2 clearing the guard atomically, the strand is fixed at the source. D2 is a cheap backstop worth considering.
- D1: guard deletion inside the extinguish transaction (already in A2).
- D2 (optional): in
lightLantern's guard-held check, when the guard names a lantern, also confirm that lantern is still active before 409-ing; a guard that points at an extinguished/missing lantern is treated as absent. This heals any historical strands and any future non-A2 writer, at the cost of one more read on the light path. Open for her call: correctness backstop vs a read on the hot path.
Decision E: scope of the shared layer โ
Recommend E2. Build the shared transaction-body helpers and route the existing writers through them now.
| Option | Scope | Argument |
|---|---|---|
| E1 point fixes | fix the four sites in place, no shared layer | smaller diff today; leaves five writers each owning their own discipline, so writer six diverges again. This is the pre-#889 state that produced the scheduled-path bypass |
| E2 (recommend) | extract extinguishLanternTx, retireExpiredLanternTx, reuse the existing activeLanternGuard* + buildActiveLanternGuard; route extinguishLantern, the getActiveLanterns auto-extinguish, and the relight-retire through them | one place enforces INV-1..5; one test suite covers the transition; the next writer composes the helpers instead of re-deriving them. The argument is exactly #889's, one door short of the set |
Argue-against acknowledged: E2 is more change than four patches, and a shared helper can over-abstract. The guard against that: the helpers are transaction-body functions taking (tx, refs, data) and returning nothing, not a framework. If the extinguish and auto-extinguish write-sets turn out to differ more than expected once written, fall back to E1 for the outlier and keep the helper for the rest. The recommendation is E2 unless the write-sets refuse to converge.
Open questions for the operator โ
- Auto-extinguish on read (
getActiveLanternsbatch, :495) is the same disease as #5 but is not named in #898. It runs on every active-lanterns read, so its double-decrement race is arguably more reachable than manual double-extinguish. Fold it into this pass (route throughextinguishLanternTx), or leave it and file separately? - Counter reconciliation.
activeLanternCountis never recomputed, so today's drift is permanent regardless of this fix (it only stops NEW drift). Do we want a periodic reconcile sweep (recompute from the active-lantern query) as a belt-and-suspenders, or is stopping new drift enough for alpha? This is a separate, larger scope; flagging, not proposing. - D2 backstop: worth one extra read on the light path to self-heal stranded guards and future non-conforming writers, or keep the light path lean and rely on A2 alone?
- Rollout: this touches the hot lantern path. Land behind the emulator concurrency suites (the twoDevices suite probes double-LIGHT; this pass needs a double-EXTINGUISH and a relight-deleted-venue probe), then a browser pass. Any preference on sequencing against the other alpha work?
Verification shape (for the build day, not now) โ
Per the operator's test-first rule, the build will need emulator concurrency probes that collide on a WRITTEN ref (INV-5), not a query:
- double-extinguish of one lantern: exactly one decrement, one 409.
- extinguish-then-relight: guard cleared atomically, relight succeeds (no strand 409).
- relight whose old venue was deleted: succeeds, no NOT_FOUND.
- N concurrent lights at ONE venue: no retry-exhaustion 500 (B1).
The existing claim-race / wave-race / two-devices emulator suites are the template; each collides on a deterministic shared ref, which is what makes them valid on the serializing emulator.
Links โ
- Issue: #898
- Precedent: PR #876 (the #889 guard-contract unification of the two light doors)
- Related deferrals on #898's lower-severity list (migrate
--restore, adminClaim non-atomic copy, /stats scan) are out of scope for this transaction-layer pass and stay on #898.