adr: 0011 title: "FT freeze must span both legacy auto-sell and CFD close-out steps" status: accepted implementation-status: implemented date: 2026-06-01 implemented-date: 2026-06-01 implemented-in: - ftl-backend bug/ft-freeze-span-cfd-closeout deciders: [@amalkrsihna] affects-specs: [amm-pricing] affects-code: - ftl-backend/internal/sportmonks/postmatch.go supersedes: null superseded-by: null
ADR-0011: FT freeze must span both legacy auto-sell and CFD close-out steps¶
Context¶
ADR-0005 §1 mandates that HSET instrument:<id> frozen 1 must reject all new user opens and closes during the FT close-out. However, the implementation delegated freeze management to each helper (liquidateHolders, liquidateCFDPositions) independently:
liquidateHolderssets frozen=1, runs the legacy system-sell, then defers unfreeze (fires on function return).liquidateCFDPositionsonly sets its own freeze whenautoSeller == nil(i.e. in single-path deployments). In production where both are wired, it skips the freeze entirely.
This created a race window: the instrument was unfrozen the moment liquidateHolders returned, before liquidateCFDPositions started its close-out loop. A user could open a new CFD position during this window and have it immediately force-closed at the FT snapshot price.
Decision¶
The freeze is lifted to ProcessCompletedFixture, which orchestrates both steps. Concrete changes:
ProcessCompletedFixture: Before calling either helper, setfrozen=1, defer a singleHDel(frozen)(captured via closure overinstKey), and sleep 500ms. Guard withif p.autoSeller != nil || p.cfdCloser != nilso instruments with no liquidation path are not unnecessarily frozen.liquidateHolders: Remove its freeze/defer/sleep block. Add a comment noting it assumes the instrument is already frozen by the caller.liquidateCFDPositions: Remove theownFreezegate and its freeze/defer/sleep block. Add a comment noting it assumes the instrument is already frozen by the caller.
The 500ms drain-wait is preserved at the orchestrator level so both steps still benefit from the flusher having time to converge legacy Redis dirty-state to PG before the holder enumeration.
Consequences¶
- Positive: No user can open a CFD position between the legacy auto-sell and CFD close-out. The freeze is now a single atomic span over the entire FT liquidation sequence.
- Positive:
liquidateHoldersandliquidateCFDPositionsare now simpler — they do not need to manage their own freeze lifecycle. - Neutral: The freeze duration extends by however long
liquidateCFDPositionstakes (milliseconds to low seconds for typical player instruments). Trading is already paused at FT so this has no user-visible downside. - Risk: If
liquidateHoldersis ever called independently ofProcessCompletedFixture, the caller must ensure the instrument is frozen. Currently there are no other callers (grep liquidateHoldersconfirms single call site). Add a comment to the function signature to document this precondition.
Alternatives considered¶
Alternative A: Fix liquidateCFDPositions to always freeze independently¶
Make liquidateCFDPositions unconditionally set frozen=1 at entry, regardless of whether autoSeller is nil.
Rejected. This preserves two separate freeze/unfreeze cycles. The unfreeze from liquidateHolders still fires before liquidateCFDPositions runs, so the race window remains. The gap is structurally inherent to per-helper freeze management — it cannot be closed without lifting the freeze to the orchestrator.
Alternative B: Remove liquidateHolders and migrate all legacy holders to CFD positions¶
Retire the legacy system-sell path entirely. All users trade CFD-only (as per the CFD-only migration in progress), so there are no "holders" to liquidate.
Rejected. The legacy liquidateHolders path must run until all pre-migration share-based positions are closed. Removing it before migration completes would strand any residual legacy balance. The freeze race is a narrower bug fix; the migration is a separate, longer-horizon workstream. Fix the race now, retire the path later.
Alternative C: Sequence with a named lock (Redis distributed lock)¶
Instead of relying on the frozen field, acquire a Redis lock on lock:instrument:<id>:ft at the start of ProcessCompletedFixture and hold it across both helpers.
Rejected. Over-engineered for this call site. frozen is already the ADR-0003/0005 specified gate; all trade-path code checks it. A separate lock adds a second mechanism to maintain. The real fix is simply to own the frozen flag at the right scope level — the orchestrator — not to introduce a parallel locking primitive.