Skip to main content

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.Getenv read 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
Interfaces:
  • Produces: func (p *Poller) fetchAllLiveEventStateUpdates(ctx context.Context) []*models.UpdateItem
The method signature matches 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.
This query typically returns O(tens) of rows during peak load (only concurrent live matches), so it is cheap.
  • Step 1: Write the failing test
Add to v3/websocket/poller_scores_test.go:
  • Step 2: Run test to verify it fails
Expected: compile error — fetchAllLiveEventStateUpdates undefined.
  • Step 3: Implement fetchAllLiveEventStateUpdates in poller_scores.go
Add at the bottom of v3/websocket/poller_scores.go, after the existing nullStr function:
  • Step 4: Run test to verify it passes
Expected: 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
Files:
  • Modify: v3/websocket/poller.go
  • Test: v3/websocket/poller_scores_test.go
Interfaces:
  • 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
Add to v3/websocket/poller_scores_test.go:
  • Step 2: Run test to verify it fails
Expected: compile error — scorePollInterval undefined.
  • Step 3: Add scorePollInterval() helper and pollScores() to poller.go
Add near the bottom of v3/websocket/poller.go, before the closing of the file:
  • Step 4: Wire pollScores into Start()
In v3/websocket/poller.go, in the Start() function, add after the existing SafeGoWithRestart calls:
The full Start() body should look like:
  • Step 5: Run all tests
Expected: all PASS, including TestScorePollInterval.
  • Step 6: Build check
Expected: no errors.
  • Step 7: Commit

Self-Review

Spec coverage:
  • Score/status delays when markets are suspended → covered by pollScores polling 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=0 disables goroutine
  • No duplicate broadcasts → reuses existing lastScoreSig/lastStatus diff maps, same locks
  • No memory leaks → goroutine exits on ctx.Done(), ticker deferred Stop
Placeholder scan: None found. Type consistency: fetchAllLiveEventStateUpdates returns []*models.UpdateItem — same as fetchEventStateUpdates, consumed by broadcastUpdates which already accepts that type. ✓