adr: 0032 title: "Persistent instrument idx + lineup-sourced instrument universe" status: accepted implementation-status: implemented date: 2026-06-09 implemented-date: 2026-06-09 implemented-in: - ftl-backend feat/squad-coverage-lineup-sourced deciders: [@amalkrsihna] affects-specs: [amm-pricing, event-ingestion] affects-code: - ftl-backend/migrations/000041_add_persistent_idx.up.sql - ftl-backend/internal/instrument/service.go - ftl-backend/internal/sportmonks/squadsync.go - ftl-backend/internal/sportmonks/ticker.go - ftl-backend/internal/sportmonks/scheduler.go supersedes: null superseded-by: null
ADR-0032: Persistent instrument idx + lineup-sourced instrument universe¶
Context¶
Players appearing in a live match generated chart events/stats but were not tradeable — e.g. Trinidad & Tobago's A. Rampersad and D. Smith. Evidence (live Sportmonks):
- The instrument universe was built from
squads/teams/{teamID}(squadsync.go), which for national teams is incomplete —squads/teamsfor T&T returned 14 players (pagination: None), while the fixturelineupsendpoint (the authoritative matchday roster, and the same data the live ticker prices from) had 21. - So 7+ lineup players per national-team fixture had no
instrumentsrow → their events/stats fired with aplayerNamebut no tradeable instrument.
The obvious fix (source the universe from the lineup, and/or auto-create an
instrument when the ticker meets an unknown lineup player) was blocked by a
second problem: idx — the 16-bit index used in the WS binary price frame —
was not persisted. It was recomputed positionally (ORDER BY id, n+1)
independently in instrument.HydrateRedis() (ws side) and instrument.GetAll()
(client side); they only agreed because both sorted identically. Adding ANY
instrument shifts every later instrument's idx, and the ws-server's idx cache
(hydrated at boot, refreshed only on a miss) would keep the stale value → live
prices map to the wrong player after any re-hydrate. So no instrument could
be added mid-session safely.
Decision¶
Two changes, in order:
1. Persistent idx column (the prerequisite)¶
Migration 000041 adds instruments.idx INTEGER NOT NULL UNIQUE, backfilled
with the exact legacy positional order (ROW_NUMBER() OVER (ORDER BY id)) so
ws/client idx maps stay valid across the cutover. HydrateRedis and GetAll
now read the column instead of recomputing positionally. New rows are
assigned MAX(idx)+1 in code, only on genuine insert — NOT via a sequence
DEFAULT: a default would evaluate nextval on every ON CONFLICT upsert of an
existing player (≈ one whole squad per sync), overflowing the uint16 wire index
over a 39-day tournament. Existing instruments' idx never change → the ws-server
keeps mapping prices correctly, and its live-miss fallback picks up new idx.
2. Lineup-sourced instrument universe¶
- Squad sync (
collectSquads) now unions each fixture'slineups.playerwithsquads/teams, so the full matchday squad is tradeable once lineups publish (~1h pre-kickoff). Players already insquads/teamskeep their richer metadata. (fetchSquadalso gainedper_page=50to stop a 26-man squad clipping at the default page.) - Ticker auto-register (
ensureLineupInstruments, run each poll) creates an instrument for any lineup player with nosportmonks_player:{id}mapping, then re-hydrates once. The ticker already receivesentry.Lineups, so this is the authoritative source and self-heals late call-ups in real time. Now safe because idx is persistent.
Alternatives considered¶
- Seed the current match's players. Rejected by the product owner — a one-off patch that doesn't fix the next match/tournament.
- Keep positional idx + refresh the ws cache via pub/sub. Rejected: idx still
reshuffles, so the client's idx map (from its last
GetAll) also goes stale; both sides would need coordinated refresh. A persistent idx fixes it at the source. - Sequence DEFAULT for new idx. Rejected: burns a value per
ON CONFLICTupsert → uint16 overflow risk over the tournament.MAX(idx)+1on genuine insert only. - Lineup-sourced sync OR auto-register alone. Both shipped: sync gives pre-match availability; auto-register is the real-time safety net.
Consequences¶
- Positive: every player who can appear in a live match is tradeable — pre-match (sync) and in real time (auto-register). idx is stable, so incremental instrument creation no longer corrupts the price→player mapping.
- Cost: auto-register inserts + one
HydrateRediswhen a genuinely-new player is first seen (rare after the first poll of a match). The wider livescoresinclude(lineups.player.position;…nationality) adds a little payload per poll. - Limitation: auto-registered players seed
base_pricefromcomputeSeedFormIndex(no history yet); the next squad sync enriches metadata. idx isMAX+1under a single-writer assumption (scheduler/squad-sync are Redis-leased) with the UNIQUE index as the backstop.
Migration / rollout¶
000041 is additive (ADD COLUMN, backfill, NOT NULL, UNIQUE INDEX). The backfill
matches the legacy positional idx exactly, so deploying the new code against the
migrated schema is seamless — existing ws/client idx maps remain valid. No data
loss; no destructive operation. Going-forward only.