Repository navigation
Stop /api/export from starving the server, and stop timers dying silently - #8
Merged
Merged
Conversation
…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
marked this pull request as ready for review
August 11, 2026 22:11
This was referenced 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was happening
The origin was very likely never crashing.
/api/exportwas holding the event loop long enough for the containerHEALTHCHECKto 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'sstart().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 startin production mode:/api/healthduring the exportThree consecutive 5 s timeouts is exactly
HEALTHCHECK --timeout=5s --retries=3.Changes
app/api/export/route.ts— consume the generator frompull()instead ofstart(), so a batch is only produced once the consumer has drained the previous one, andcancel()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 fromruntime.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 anytryin 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;finally, the hourly summary and the sample purge run before thesetTimeoutthat 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
type=andfrom=filters matching the database's own counts,X-Event-Countconsistent.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 buildall clean.Not addressed
raw_spotifyhas no retention policy —insertRawstores 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