Skip to content

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:

  1. liquidateHolders sets frozen=1, runs the legacy system-sell, then defers unfreeze (fires on function return).
  2. liquidateCFDPositions only sets its own freeze when autoSeller == 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:

  1. ProcessCompletedFixture: Before calling either helper, set frozen=1, defer a single HDel(frozen) (captured via closure over instKey), and sleep 500ms. Guard with if p.autoSeller != nil || p.cfdCloser != nil so instruments with no liquidation path are not unnecessarily frozen.
  2. liquidateHolders: Remove its freeze/defer/sleep block. Add a comment noting it assumes the instrument is already frozen by the caller.
  3. liquidateCFDPositions: Remove the ownFreeze gate 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: liquidateHolders and liquidateCFDPositions are now simpler — they do not need to manage their own freeze lifecycle.
  • Neutral: The freeze duration extends by however long liquidateCFDPositions takes (milliseconds to low seconds for typical player instruments). Trading is already paused at FT so this has no user-visible downside.
  • Risk: If liquidateHolders is ever called independently of ProcessCompletedFixture, the caller must ensure the instrument is frozen. Currently there are no other callers (grep liquidateHolders confirms 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.