Skip to content

feat(merge): run the merge pipeline in a worker thread off the event loop (#242 Phase 3) - #249

Merged
benders merged 2 commits into
mainfrom
feature/242-phase3-merge-worker
Jul 11, 2026
Merged

feat(merge): run the merge pipeline in a worker thread off the event loop (#242 Phase 3)#249
benders merged 2 commits into
mainfrom
feature/242-phase3-merge-worker

Conversation

@benders

@benders benders commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Closes #242 (Phase 3 of 3 — Phases 1–2 landed in #248 and are validated on the live instance).

better-sqlite3 is synchronous, so the merge pipeline — snapshot → mergeLibraries → id-remap → orphan audit, one transaction — stalled the entire hub event loop for the duration of every merge (200k–600k track scale envelope). This PR moves that transaction onto a node:worker_threads worker with its own connection to the same file: WAL readers on the main connection are never blocked by the worker's write transaction, so streams, Subsonic clients, and federation requests keep being served while a merge runs.

Design

  • runMergePipelineAsync(db, { logger, inProcess? }) is now the production entry point; all four call sites (sync.ts, auto-sync.ts ×2, admin peer-data reset) await it. For a file-backed DB it spawns merge-worker.ts, which opens new Database(db.name) + WAL/foreign_keys pragmas (no schema/migration re-run) and runs the same executeMergePipeline core. In-memory DBs (:memory:/anonymous — the unit-test path) fall back to the in-process synchronous run.
  • Single in-flight merge: a module-level promise-chain mutex serializes merges process-wide; concurrent callers queue, each still gets its own run. A rejected merge doesn't wedge the queue.
  • Shutdown: shutdownMergeWorker() is wired into server.ts onClose before db.close() — terminating mid-merge just rolls the transaction back.
  • The merge transaction is now run.immediate() (was deferred): the write lock is acquired up front instead of upgrading mid-transaction, which matters once two connections write to the same file.
  • Worker entry resolution: .js under compiled dist/ (the production image has no tsx), .ts under tsx dev/vitest. execArgv: ["--import", "tsx"] alone does not reliably register tsx's hooks inside a worker on Node 22; the worker instead loads its pipeline import via tsx/esm/api's tsImport when running from .ts source (Node 22.18+'s native type-stripping loads the entry file itself but can't remap .js specifiers to .ts siblings). In prod the compiled tree resolves normally and tsx is never imported.
  • Worker does no logging; logPipelineReport runs on the main thread when the report message arrives.

Also in this PR

  • Integration tests wired into CI (they previously ran nowhere): root test:integration script + a CI step in the hub job.
  • Doing that exposed hub/test/subsonic.integration.test.ts carrying a hardcoded live Navidrome credential and failing 8/8 against a 502ing host. It is now env-gated (NAVIDROME_TEST_URL / _USER / _PASSWORD, describe.skipIf) and the plaintext password is removed from the source. ⚠️ The old password remains in git history — rotate it on navidrome-west.

Verification

  • pnpm verify green: hub 694/694 (59 files — +3 new merge-pipeline unit tests), frontend 108/108, typecheck + lint:boundary clean.
  • pnpm test:integration green: 21 passed, 8 skipped (Navidrome-gated). New merge-worker.integration.test.ts seeds a 15k-track file DB, starts the worker merge, and proves the main connection answers reads and the event loop ticks (<100 ms) while the merge is still running, then asserts a clean report and fully populated unified tables.
  • pnpm lint zero findings in hub/ and frontend/.
  • Federation compat suite: 84/84 passed against the Docker hub-a/b/c stack — the containers run node dist/server.js, so this exercises the compiled .js worker path (merges run during the suite), not just the tsx dev path.

Acceptance criteria from #242 (Phase 3)

  • ✅ Merge runs off the event loop in a worker with its own connection; WAL confirmed on both connections.
  • ✅ Single in-flight merge enforced; worker terminated on app.onClose.
  • ✅ Main-connection reads + event-loop responsiveness proven under a running merge (integration test; <100 ms tick bound).

🤖 Generated with Claude Code

https://claude.ai/code/session_011Q3riZ78G26ohMgPDh8F7u

claude added 2 commits July 11, 2026 08:20
…ardcoded credential

The suite hit a hardcoded external Navidrome with a plaintext password
committed in the file, and failed 8/8 whenever that host was down. Now
gated on NAVIDROME_TEST_URL (+ _USER/_PASSWORD) via describe.skipIf so
it skips cleanly in CI and on machines without a configured server.
The old password remains in git history and should be rotated. (refs #242)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q3riZ78G26ohMgPDh8F7u
…loop (closes #242)

runMergePipelineAsync() spawns a node:worker_threads worker with its own
better-sqlite3 connection (WAL readers on the main connection stay
unblocked for the whole merge); in-memory DBs fall back in-process. A
promise-chain mutex enforces one in-flight merge; shutdownMergeWorker()
terminates the worker on app close before db.close(). The pipeline
transaction is now run.immediate() so the write lock is taken up front.
Worker entry resolves .ts (tsx dev/vitest, via tsx/esm/api tsImport) or
compiled .js (prod image has no tsx). Integration tests are now wired
into CI (root test:integration script + hub-job step); the new
merge-worker integration test proves main-connection reads and event-
loop ticks stay responsive while a 15k-track merge runs on the worker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q3riZ78G26ohMgPDh8F7u
@benders
benders marked this pull request as ready for review July 11, 2026 15:24
@benders
benders merged commit 40e213d into main Jul 11, 2026
2 checks passed
@benders
benders deleted the feature/242-phase3-merge-worker branch July 11, 2026 15:24
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.

fix(merge): protect user data across re-merges (orphan audit + ID remap) and move merge off the event loop

2 participants