---
title: Fix the league live scoreboard poll to survive transient DB errors
type: task
status: completed
description: >-
  Scoreboard.poll() in api/sockets/scoreboard.mjs has no try/catch; a single statement-timeout
  unhandled-rejection (signal 130947) killed the live scoreboard feed on 2026-09-19 and it stays
  dead until process restart.
base_uri: user:task/league/fix-scoreboard-poll-survives-transient-db-errors.md
created_at: '2026-09-19T15:19:31.415Z'
entity_id: f8c535a9-bc8a-40cc-93eb-570cdc5c3b06
finished_at: '2026-10-10T01:54:52.175Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: High
public_read: true
started_at: '2026-09-19T15:59:57.065Z'
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-10-10T01:55:23.631Z'
visibility_analyzed_at: '2026-11-06T00:17:55.207Z'
---

## Context

On 2026-09-19 14:27 UTC the █████████████ process threw an unhandled rejection in the live scoreboard socket and has not recovered. Signal 130947 (error code 57014): `canceling statement due to statement timeout` on `Scoreboard.poll()`'s query — the shared `getPlayByPlayQuery` over `nfl_plays` inner-joined to `nfl_games`, filtered by `season_year / week / season_type / updated > $4`.

`Scoreboard.poll()` in `api/sockets/scoreboard.mjs` has **no try / catch / finally**. Its only reschedule is the tail recursion after a successful body (`await wait(5000); if (this._users.size) this.poll()`). On the 14:27 throw that reschedule never ran and `_isPolling` stayed `true`; `register()` only starts a poll when `_isPolling` is false, so no new client restarts the loop. The server process has not restarted since 10:27 (pid 837745), so the live scoreboard feed is currently dead and stays dead until this fix lands or the process restarts.

The query itself is healthy and must NOT be changed (no schema or data-view change):

- Exact-query `EXPLAIN (ANALYZE, BUFFERS)` run against the league postgres: 0.9 ms execution, nested loop via the season-2026 partition index on `(season_year, season_type, week)` then esbid into `nfl_games`.
- `pg_stat_statements` for the same query shape: 131,545 calls, mean 1.3 ms, max 18.9 ms. The 40 s cancel was a ~2000x single-event outlier during concurrent heavy data-view work at 14:26-14:27 (a charting-supplier plays-in-game pass was mid-flight in `server.mjs`).

## Status

Fixed by league `971c095d8` (2026-09-19): `poll()` is a `while` loop over `tick()` with an inner catch, so a transient query failure logs and the loop continues without a reschedule to duplicate. The first guard, `6ad35912d`, saturated its own knex pool through an unbounded retry and was reverted in `5304f8a7a`. The live outage itself resolved independently on a sibling's 2026-09-19 15:54 UTC restart. Measured 2026-10-09: the deployed league checkout contains `971c095d8`, and the server error logs from 2026-10-01 to 2026-10-10 hold zero `unhandled_rejection` lines while scoreboard register and remove lines run throughout.

## Plan

- [x] In `api/sockets/scoreboard.mjs`, wrap the body of `Scoreboard.poll()` in `try`/`catch` so a DB error is logged to the `scoreboard` debug namespace instead of surfacing as an unhandled rejection. The body moved to a `tick()` method and `poll()` wraps the call.
- [x] Reschedule after a failed tick: the `await wait(5000)` and the `this.poll()` recursion now sit outside the guarded body, so they run on the failing path as well as the succeeding one.
- [x] `_isPolling` bookkeeping released on both paths, so `register()` can restart a drained loop.
- [x] Verified by two trajectory specs in `test/scoreboard.socket.spec.mjs`, controlled both ways — both go red against the unguarded structure and green with the guard.
- [x] Confirm on the league host after the next deploy that the loop survives — `971c095d8` is deployed and ten days of logs (2026-10-01 to 2026-10-10) show the feed live with zero unhandled rejections; no poll rejection has occurred live since, so the catch path stays proven by the trajectory specs.

## Key locations

- `api/sockets/scoreboard.mjs` — `Scoreboard.poll()`, `build_scoreboard_current_plays_query`
- `libs-server/index.mjs` — `getPlayByPlayQuery` (shared builder; do not change)
- Signal 130947; evidence recorded 2026-09-19 (EXPLAIN 0.9 ms, 131,545-call history).
