Score Poller Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Add a dedicated goroutine that polls live scores from the Events bookie rows on a short interval, independent of market update notifications, so goal/score changes are pushed to WebSocket clients within seconds even when bookmakers suspend markets.
Architecture: A new pollScores goroutine runs on a fixed ticker (default 5 s, env-tunable). It queries all matches rows with bookie = 'Events' and status = 'live' (or recently-settled), diffs against in-memory state already maintained by lastScoreSig/lastStatus, and broadcasts only changed rows. It reuses all existing diff logic in fetchEventStateUpdates — the only new code is the query that fetches all live Events rows instead of a specific batch of match IDs. No schema changes needed; no new tables.
Tech Stack: Go, database/sql, existing database.Postgres pool, existing models.UpdateItem / dto.Score types, existing broadcastUpdates + Hub fan-out pipeline.
Global Constraints
- No new dependencies — reuse
database.Postgres, existing types,utils.SafeGoWithRestart. - Follow existing env-var kill-switch pattern (
os.Getenvread deferred until runtime, not init). - All new public-surface behaviour must have a unit test; DB-touching functions are tested through existing mock patterns (pure-logic extraction where needed).
- No comments that restate what the code does — only non-obvious “why” comments.
- Run
go test ./websocket/...before every commit; must pass green.
File Map
Task 1: Add fetchAllLiveEventStateUpdates to poller_scores.go
This is the query that fetches every live Events row instead of a specific batch. It reuses the existing per-row diff logic inside fetchEventStateUpdates — we extract the inner loop into a shared helper so both callers stay DRY.
Files:
- Modify:
v3/websocket/poller_scores.go - Test:
v3/websocket/poller_scores_test.go
- Produces:
func (p *Poller) fetchAllLiveEventStateUpdates(ctx context.Context) []*models.UpdateItem
fetchEventStateUpdates except it takes no matchIDs slice — it queries all live rows itself.
Why this query shape
bookie = 'Events'— scores are stored only on the synthetic Events row, never on bookmaker rows.status IN ('live', 'settled', 'cancelled')— we still want to push a settled/cancelled transition; prematch rows are excluded so the query stays small.date >= NOW() - INTERVAL '4 hours'— belt-and-suspenders cap so a stale prematch row that somehow has status=‘live’ doesn’t pollute results; 4 h covers any match that kicked off recently and might still be live.
- Step 1: Write the failing test
v3/websocket/poller_scores_test.go:
- Step 2: Run test to verify it fails
fetchAllLiveEventStateUpdates undefined.
- Step 3: Implement
fetchAllLiveEventStateUpdatesin poller_scores.go
v3/websocket/poller_scores.go, after the existing nullStr function:
- Step 4: Run test to verify it passes
PASS
- Step 5: Commit
Task 2: Add pollScores goroutine to poller.go
Wire the new query into a dedicated goroutine with a configurable interval and clean shutdown. The goroutine must:
- Check for subscribers before hitting DB (cheap guard)
- Log only when it actually broadcasts something (avoids logspam)
- Be skippable via env var for emergencies
- Modify:
v3/websocket/poller.go - Test:
v3/websocket/poller_scores_test.go
- Consumes:
p.fetchAllLiveEventStateUpdates(ctx) []*models.UpdateItem(Task 1) - Consumes:
p.broadcastUpdates(updates)(existing)
Env var: WS_SCORE_POLL_INTERVAL
Read as a time.Duration string (e.g. "5s", "10s"). Default: 5s. Set to "0" or "off" to disable the goroutine entirely (kill switch for debugging).
This follows the same deferred-read pattern as WS_COMPRESS in hub.go — read inside the goroutine, not at struct init, so env is loaded before it’s consumed.
- Step 1: Write the failing test for interval parsing
v3/websocket/poller_scores_test.go:
- Step 2: Run test to verify it fails
scorePollInterval undefined.
- Step 3: Add
scorePollInterval()helper andpollScores()to poller.go
v3/websocket/poller.go, before the closing of the file:
- Step 4: Wire
pollScoresintoStart()
v3/websocket/poller.go, in the Start() function, add after the existing SafeGoWithRestart calls:
Start() body should look like:
- Step 5: Run all tests
TestScorePollInterval.
- Step 6: Build check
- Step 7: Commit
Self-Review
Spec coverage:- Score/status delays when markets are suspended → covered by
pollScorespolling all live Events rows every 5 s - High performance → query limited to live + recent rows (O(tens) rows, cheap); subscriber guard skips DB entirely when no one is subscribed
- Kill switch →
WS_SCORE_POLL_INTERVAL=0disables goroutine - No duplicate broadcasts → reuses existing
lastScoreSig/lastStatusdiff maps, same locks - No memory leaks → goroutine exits on
ctx.Done(), ticker deferred Stop
fetchAllLiveEventStateUpdates returns []*models.UpdateItem — same as fetchEventStateUpdates, consumed by broadcastUpdates which already accepts that type. ✓