Visitar URL original
Stop /api/export from starving the server, and stop timers dying silently by CostardRouge · Pull Request #8 · CostardRouge/spotify-poller · GitHub
Skip to content

Stop /api/export from starving the server, and stop timers dying silently - #8

Merged
CostardRouge merged 1 commit into
mainfrom
claude/project-ui-crashes-502-404-m79wsb
Aug 11, 2026
Merged

CostardRouge merged 1 commit into
mainfrom
claude/project-ui-crashes-502-404-m79wsb

Conversation

@CostardRouge

Copy link
Copy Markdown
Owner

What was happening

The origin was very likely never crashing. /api/export was holding the event loop long enough for the container HEALTHCHECK to fail its full retry budget, and whatever sits in front of the container then stopped routing to it — 502 from nginx/Caddy, 404 from Traefik. It recovers on its own, which is why it reads as "the project restarted on a fatal error".

The export was built on a generator specifically so it would not materialise the whole history in memory, but the route consumed that generator inside ReadableStream's start(). start() runs to completion before anything reads the stream, so the laziness bought nothing: every event was encoded into the stream's internal queue — which applies no backpressure of its own — while the loop was held.

Measured

300k events (357 MB database), served to a deliberately slow 2 MB/s client, next start in production mode:

before after
server RSS 108 → 752 MB 108 → 150 MB
/api/health during the export 3× no response at all (5 s timeout), then 3.5 s steady ~7 ms, all 200

Three consecutive 5 s timeouts is exactly HEALTHCHECK --timeout=5s --retries=3.

Changes

app/api/export/route.ts — consume the generator from pull() instead of start(), so a batch is only produced once the consumer has drained the previous one, and cancel() stops querying when the client hangs up.

lib/server/export.ts — pull() alone was not enough. stmt.iterate() holds a cursor open on the connection, and better-sqlite3 rejects every write on a connection with an open cursor (This database connection is busy executing a query) — verified directly. Since the connection is the process-wide singleton from runtime.ts, consuming that cursor lazily would have made the collectors, the run log and every admin POST fail for the whole duration of an export. So the generator now reads fixed-size batches by keyset — each one a complete, closed query, with (ts_utc, id) as the cursor so events sharing a timestamp are neither skipped nor duplicated.

lib/server/scheduler.ts — three synchronous SQLite calls sat outside any try in the timer paths, where a throw is unrecoverable:

  • likedDue() / backupDue() are evaluated in the timer callback itself, so a throw escapes the scheduler and no further tick is ever scheduled;
  • in the playback ticker's finally, the hourly summary and the sample purge run before the setTimeout that re-arms the loop, so a throw there ends playback collection permanently.

Neither is what takes the UI down, but both fail silently — the only symptom is collection quietly stopping. They now log and carry on.

Verification

  • Export correctness against the same 300k rows: 300006 lines, all ids distinct, both sort orders monotonic, type= and from= filters matching the database's own counts, X-Event-Count consistent.
  • CLI scripts/export.ts (which shares the generator) still emits the same 300006 lines.
  • npm run typecheck, npm run lint (only the two pre-existing warnings), npm run build all clean.

Not addressed

raw_spotify has no retention policy — insertRaw stores the full response payload on every collector call and nothing ever purges it. That is what makes the database large enough for the above to bite, and on a home lab it grows toward a full disk. Worth a follow-up, but it is a separate change.


Generated by Claude Code

…ntly

The NDJSON export was built on a generator so it would not hold the whole
history in memory — but the route consumed it inside `ReadableStream`'s
`start()`, which runs to completion before anything reads the stream. The
laziness bought nothing: every event was encoded into the stream's queue,
which applies no backpressure, while the event loop was held.

Measured on 300k events (357 MB database), served to a 2 MB/s client:
RSS 108 -> 752 MB, and /api/health timed out entirely (no response in 5 s)
three probes in a row. That is exactly the container HEALTHCHECK's budget
(--timeout=5s --retries=3), so an export marks the container unhealthy and
whatever sits in front of it stops routing to the origin — the 502/404 you
see, with the origin never having crashed at all.

Consume the generator from `pull()` instead, so a batch is only produced
once the consumer has drained the previous one, and stop on `cancel()` when
the client hangs up. Same run now: RSS 108 -> 150 MB, health steady at ~7 ms
throughout.

The generator itself had to change with it. `stmt.iterate()` keeps a cursor
open on the connection, and better-sqlite3 refuses every WRITE on a
connection with an open cursor ("This database connection is busy executing
a query"). The connection is the process-wide singleton, so consuming the
cursor lazily would have made the collectors, the run log and every admin
POST fail for the duration of an export. Read fixed-size batches by keyset
instead — each one a complete, closed query, with `(ts_utc, id)` as the
cursor so events sharing a timestamp are neither skipped nor duplicated.
Verified against the same 300k rows: 300006 lines, all ids distinct, both
sort orders monotonic, and filtered exports matching the database's own
counts.

Separately, three synchronous SQLite reads/writes sat outside any try in the
timer paths:

  - `likedDue()` / `backupDue()` are called from the timer callback itself,
    so a throw escapes the scheduler entirely and no further tick is ever
    scheduled;
  - in the playback ticker's `finally`, the hourly summary and the sample
    purge run BEFORE the `setTimeout` that re-arms the loop, so a throw
    there ends playback collection for good.

Both now log and carry on. Neither is what takes the UI down, but both fail
silently and are only visible as collection quietly stopping.
@CostardRouge
CostardRouge marked this pull request as ready for review August 11, 2026 22:11
@CostardRouge
CostardRouge merged commit 588f03d into main Aug 11, 2026
CostardRouge pushed a commit that referenced this pull request Aug 16, 2026
The lifecycle boot report finally caught the killer in the act:

  #  next-server (v16.2.12)[1]: void node::RemoveEnvironmentCleanupHook(...)
  #  Assertion failed: (env) != nullptr

That is a native abort, below JavaScript — no handler can catch it, which
is why the report filed it as an unclean exit at a healthy 97 MB RSS.

Node 24.19.0 (2026-08-03) added cleanup hooks to node::ObjectWrap
(nodejs/node#63642), and NAN-style native addons — better-sqlite3 among
them, current v12 included, so upgrading the addon is no way out — now
abort the process at random when a hook is removed after its Environment
is already gone. Timing- and GC-dependent: a process can run for hours
and then die minutes after the next boot, which is exactly the shape of
this incident (17 h up, then a crash loop within seconds of each start).

The Dockerfile pulled the FLOATING node:24-alpine tag, so every CI image
built since 2026-08-03 silently carried the regression — the "random 502,
especially when I come back after a while" predates #8 and matches this
window. The event-loop starvation #8 fixed and the swallowed SIGTERM #12
fixed were real, but this is the crash that kept the story going.

Pin all stages (and Dockerfile.dev) to node:24.18.0-alpine, the last
release before the regression, verified present on Docker Hub; no fixed
24.19.x exists at the time of writing. The comment in the Dockerfile says
what to check (nodejs/node#63642) before ever bumping past it, and the
README's troubleshooting section gains the third failure mode: an abort
from below JavaScript, recognisable by the native stack trace sitting in
docker logs just above the next boot's startup lines.

Co-Authored-By: Claude <noreply@anthropic.com>
@CostardRouge
CostardRouge deleted the claude/project-ui-crashes-502-404-m79wsb branch August 19, 2026 19:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants