Skip to content

fix(database): isolate web and worker connection pools - #764

Merged
rajnandan1 merged 1 commit into
mainfrom
fix/db-pool-web-worker-split
Jun 18, 2026
Merged

fix(database): isolate web and worker connection pools#764
rajnandan1 merged 1 commit into
mainfrom
fix/db-pool-web-worker-split

Conversation

@rajnandan1

@rajnandan1 rajnandan1 commented Jun 18, 2026

Copy link
Copy Markdown
Owner

Why

GET / was 500ing in prod (kener-v4-alpha) with KnexTimeoutError: Timeout acquiring a connection. Confirmed against live pg_stat_activity: the database was healthy (97 free slots, no leak, no stuck query) — the bottleneck was our own pool.

One process runs SvelteKit + cron scheduler + ~7 BullMQ worker types (concurrency 5) all sharing one pool capped at 10, and a single GET / fans out ~6 queries. Two concurrent page loads, or a per-minute monitor burst overlapping a load, push demand past 10; queued acquires then blow past the 15s acquire timeout → 500. (Checkpoint logs only show writes, which is why the DB looked "idle".)

Immediate relief was already applied by bumping DATABASE_POOL_MAX to 30 on the service. This PR is the durable fix.

What

Split into two pools so background jobs can't starve page loads:

  • web poolDATABASE_POOL_MAX (default 10) — HTTP requests
  • worker poolDATABASE_WORKER_POOL_MAX (default 5, new) — background jobs

Routing is by execution context via AsyncLocalStorage:

  • q.createWorker is the single chokepoint all workers/schedulers flow through — it runs each processor inside a worker-pool context.
  • BaseRepository.knex resolves the pool from that context, defaulting to the web pool. So shared controllers stay correct whether they run in a request or a job.
  • SQLite has no real pool (single connection) — the split is a no-op there.

Budget: replicas × (web + worker) < max_connections. Defaults total 15, safe for small managed Postgres.

Verification

  • npm run check → 0 errors.
  • Routing unit test (two separate in-memory SQLite DBs against the real base.ts/poolContext.ts): web pool outside the context, worker pool inside, survives await, restores after.
  • Boot smoke on the real db singleton: construct + ping + runInWorkerContext + close() (no double-destroy).

Deploy note

After this rolls out, set on the service: DATABASE_POOL_MAX=20, DATABASE_WORKER_POOL_MAX=10. Safe if it deploys before retuning (web 30 + worker 5 = 35 < 97).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Database connection pools now separated into distinct web and worker pools, preventing background job processing from starving HTTP requests.
    • New configuration variables added for independent tuning of both pools.
  • Documentation

    • Enhanced database pool configuration guidance and connection timeout troubleshooting.

GET / was throwing KnexTimeoutError ("Timeout acquiring a connection")
in production. Root cause was the connection pool, not the database:
the single process (SvelteKit + cron scheduler + BullMQ workers) shared
one pool capped at 10, while one GET / fans out ~6 queries. A couple of
concurrent page loads, or a per-minute monitor burst overlapping a load,
exceeded 10 and queued acquires blew past the 15s timeout. Postgres
itself had 97 free slots the whole time and no leak.

Split into two pools so background work can't starve page loads:
- web pool (DATABASE_POOL_MAX, default 10) serves HTTP requests
- worker pool (DATABASE_WORKER_POOL_MAX, default 5) serves background jobs

Routing is by execution context via AsyncLocalStorage: q.createWorker
(the single chokepoint all workers/schedulers flow through) runs each
processor inside a worker-pool context, and BaseRepository.knex resolves
the pool from that context, defaulting to the web pool. This keeps shared
controllers correct whether they run in a request or a job. SQLite has no
real pool and reuses a single connection, so the split is a no-op there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 18, 2026 06:09
@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces a dual Knex connection pool architecture: knexfile.ts splits the single pool into separate WEB and WORKER pools via a buildPool helper. An AsyncLocalStorage-based poolContext.ts routes queries to the correct pool at execution time. BaseRepository resolves the active pool via a getter, DbImpl gains runInWorkerContext, and BullMQ processors are wrapped to activate the worker context automatically.

Changes

Dual DB Pool Routing

Layer / File(s) Summary
Dual-pool Knex configuration
knexfile.ts
Introduces PoolConfig type, buildPool(max) helper, idleTimeoutMillis/createTimeoutMillis with clamping, and separates webPool/workerPool. Assigns webPool to knexOb and constructs and exports workerKnexOb (null for SQLite).
AsyncLocalStorage routing and BaseRepository getter
src/lib/server/db/poolContext.ts, src/lib/server/db/repositories/base.ts
New poolContext.ts module uses AsyncLocalStorage to store a per-execution Knex instance; exports runWithWorkerKnex and getWorkerKnex. BaseRepository gains a protected get knex() getter that returns getWorkerKnex() ?? fallbackKnex, shifting pool selection from construction-time to query-time.
DbImpl worker pool support and singleton wiring
src/lib/server/db/dbimpl.ts, src/lib/server/db/db.ts
DbImpl adds workerKnex field, optional workerOpts constructor parameter, runInWorkerContext method, and conditional workerKnex destruction in close(). The db.ts singleton passes both knexOb and workerKnexOb to DbImpl.
BullMQ processor wrapping and documentation
src/lib/server/queues/q.ts, src/routes/(docs)/docs/content/v4/setup/database-setup.md, src/routes/(docs)/docs/content/v4/setup/environment-variables.md
createWorker wraps function-based processors inside db.runInWorkerContext. Documentation updated with two-pool model, DATABASE_WORKER_POOL_MAX variable, timeout/idle/keepalive settings, and revised KnexTimeoutError troubleshooting.

Sequence Diagram(s)

sequenceDiagram
  participant BullMQ as BullMQ Job
  participant q_ts as createWorker wrapper
  participant DbImpl as DbImpl.runInWorkerContext
  participant poolContext as poolContext (AsyncLocalStorage)
  participant BaseRepo as BaseRepository.knex getter
  participant workerKnex as Worker Knex Pool

  BullMQ->>q_ts: job execution
  q_ts->>DbImpl: runInWorkerContext(processor)
  DbImpl->>poolContext: runWithWorkerKnex(workerKnex, fn)
  poolContext->>poolContext: AsyncLocalStorage.run(workerKnex, fn)
  Note over poolContext: worker Knex stored in context
  poolContext->>q_ts: execute processor fn
  q_ts->>BaseRepo: repository method call
  BaseRepo->>poolContext: getWorkerKnex()
  poolContext-->>BaseRepo: workerKnex instance
  BaseRepo->>workerKnex: SQL query
  workerKnex-->>BaseRepo: result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 Two pools now flow where once was one,
Web and worker share no queue,
AsyncStorage holds the run,
Each job finds the knex that's true.
No starvation, no delay—
The rabbit hops the separate way! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: isolating web and worker connection pools to resolve timeout issues caused by shared pool contention.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/db-pool-web-worker-split

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses production KnexTimeoutError: Timeout acquiring a connection errors by preventing background work (BullMQ workers/schedulers) from exhausting the connection pool used by HTTP requests. It introduces a context-aware routing mechanism so repositories transparently use a dedicated worker pool when executing inside a job, improving reliability under concurrent load.

Changes:

  • Added a second Knex pool configuration (DATABASE_WORKER_POOL_MAX) alongside the existing web/request pool (DATABASE_POOL_MAX).
  • Routed BullMQ job processors into a worker execution context via AsyncLocalStorage, and made BaseRepository resolve the correct Knex instance per context.
  • Updated v4 documentation to describe the split pools, tuning guidance, and troubleshooting.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/routes/(docs)/docs/content/v4/setup/environment-variables.md Documents the new DATABASE_WORKER_POOL_MAX variable and clarifies DATABASE_POOL_MAX as the web pool.
src/routes/(docs)/docs/content/v4/setup/database-setup.md Adds explanation and operational guidance for budgeting/tuning the two pools and updates timeout troubleshooting text.
src/lib/server/queues/q.ts Wraps BullMQ processors so background jobs run inside a worker-pool context.
src/lib/server/db/repositories/base.ts Switches repository access to a context-resolved Knex instance (worker vs web fallback).
src/lib/server/db/poolContext.ts Introduces AsyncLocalStorage context plumbing for selecting the worker Knex instance.
src/lib/server/db/dbimpl.ts Instantiates and manages a dedicated worker Knex instance and exposes runInWorkerContext.
src/lib/server/db/db.ts Wires the DB singleton to use both web and worker Knex configs.
knexfile.ts Splits pool configuration into web vs worker pools and exports workerKnexOb.

Comment on lines +48 to +51
const wrapped: Processor<T, R> =
typeof processor === "function"
? (job, token) => db.runInWorkerContext(() => Promise.resolve(processor(job, token)))
: processor;
Comment on lines +6 to +8
// Kener runs SvelteKit requests, the cron scheduler, and the BullMQ workers in
// a single process, all sharing one Knex instance. A burst of background jobs
// could therefore exhaust the connection pool and time out user-facing page
@greptile-apps

greptile-apps Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Splits the single shared Knex connection pool into two isolated pools — a web pool (DATABASE_POOL_MAX, default 10) for SvelteKit HTTP requests and a worker pool (DATABASE_WORKER_POOL_MAX, default 5) for BullMQ job processors and schedulers — preventing background job bursts from starving page-load connections and causing KnexTimeoutError.

  • poolContext.ts is a new AsyncLocalStorage-based module that routes each query to the correct pool transparently; q.createWorker is the single injection point that wraps every job processor in the worker context, and BaseRepository.get knex() reads the context to select the right pool automatically.
  • knexfile.ts refactors pool construction into a buildPool helper and exports workerKnexOb; the worker config is a shallow spread of the web config with the worker pool substituted, so both pools share the same connection string, keepalive settings, and acquire timeout.
  • DbImpl.close() is fixed to destroy both pools independently, avoiding a double-destroy when SQLite reuses the same instance for both roles.

Confidence Score: 4/5

Safe to merge; the dual-pool split is correct and the routing via AsyncLocalStorage is sound. The only rough edge is in q.ts where a synchronously-throwing processor escapes the Promise chain, but BullMQ's own try/catch handles it in practice.

The core mechanism — AsyncLocalStorage routing, pool construction, close() guarding against double-destroy — is all correct. Every BullMQ worker goes through createWorker, which is the right chokepoint. The one rough edge in q.ts wraps processor(job, token) with Promise.resolve() rather than an async function, so a synchronous throw would escape the Promise chain; BullMQ catches it at a higher level so there is no observable failure, but it is a contract mismatch worth cleaning up.

src/lib/server/queues/q.ts — the processor wrapping uses Promise.resolve(processor(...)) rather than an async arrow function, which means synchronous throws do not become rejected Promises as callers would expect.

Important Files Changed

Filename Overview
knexfile.ts Adds workerKnexOb export with a second pool config for background jobs; refactors pool construction into buildPool helper. Clean change; acquireConnectionTimeout is intentionally shared by spread.
src/lib/server/db/poolContext.ts New file introducing AsyncLocalStorage-based pool routing between web and worker Knex instances. Well-scoped, no issues.
src/lib/server/db/repositories/base.ts Converts knex from a plain field to a getter that reads from AsyncLocalStorage, falling back to the web pool. Design is correct; all repositories inherit routing automatically.
src/lib/server/db/dbimpl.ts Adds workerKnex, runInWorkerContext, and fixes close() to destroy both pools. Repositories are all constructed with the web pool (correct; routing happens via getter).
src/lib/server/db/db.ts Passes workerKnexOb to DbImpl constructor; trivial wiring change.
src/lib/server/queues/q.ts Wraps every BullMQ processor in db.runInWorkerContext; correctly skips sandboxed (string/URL) processors. Minor: synchronous throws from processor(job, token) escape the Promise chain — see comment.
src/routes/(docs)/docs/content/v4/setup/database-setup.md Documents the new dual-pool model, DATABASE_WORKER_POOL_MAX, budgeting guidance, and updated troubleshooting entry. Accurate and helpful.
src/routes/(docs)/docs/content/v4/setup/environment-variables.md Adds DATABASE_WORKER_POOL_MAX row to the env-var reference table. Matches the implementation.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant SvelteKit as SvelteKit Request
    participant BullMQ as BullMQ Worker (q.createWorker)
    participant ALS as AsyncLocalStorage (poolContext.ts)
    participant BR as BaseRepository.knex getter
    participant WEB as Web Pool (DATABASE_POOL_MAX)
    participant WORKER as Worker Pool (DATABASE_WORKER_POOL_MAX)
    participant PG as PostgreSQL

    SvelteKit->>BR: calls repository method
    BR->>ALS: getWorkerKnex()
    ALS-->>BR: undefined (no context set)
    BR->>WEB: query via fallbackKnex
    WEB->>PG: execute SQL

    BullMQ->>ALS: runWithWorkerKnex(workerKnex, processor)
    ALS->>BR: getWorkerKnex() returns workerKnex
    BR->>WORKER: query via workerKnex
    WORKER->>PG: execute SQL
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant SvelteKit as SvelteKit Request
    participant BullMQ as BullMQ Worker (q.createWorker)
    participant ALS as AsyncLocalStorage (poolContext.ts)
    participant BR as BaseRepository.knex getter
    participant WEB as Web Pool (DATABASE_POOL_MAX)
    participant WORKER as Worker Pool (DATABASE_WORKER_POOL_MAX)
    participant PG as PostgreSQL

    SvelteKit->>BR: calls repository method
    BR->>ALS: getWorkerKnex()
    ALS-->>BR: undefined (no context set)
    BR->>WEB: query via fallbackKnex
    WEB->>PG: execute SQL

    BullMQ->>ALS: runWithWorkerKnex(workerKnex, processor)
    ALS->>BR: getWorkerKnex() returns workerKnex
    BR->>WORKER: query via workerKnex
    WORKER->>PG: execute SQL
Loading

Reviews (1): Last reviewed commit: "fix(database): isolate web and worker co..." | Re-trigger Greptile

Comment on lines +49 to +51
typeof processor === "function"
? (job, token) => db.runInWorkerContext(() => Promise.resolve(processor(job, token)))
: processor;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Synchronous throw escapes as a rejected Promise

processor(job, token) is evaluated synchronously before Promise.resolve() is called. If a processor throws synchronously (not returns a rejected promise), the arrow function body throws rather than returning a rejected Promise. AsyncLocalStorage.run() then propagates that throw synchronously through db.runInWorkerContext, so the outer wrapper function also throws synchronously instead of returning Promise.reject(...). BullMQ does try/catch around processor invocation so in practice this is handled, but the contract mismatch could be a surprise. Wrapping with an async arrow function ensures exceptions are always channeled through the Promise: async () => processor(job, token) instead of () => Promise.resolve(processor(job, token)).

Suggested change
typeof processor === "function"
? (job, token) => db.runInWorkerContext(() => Promise.resolve(processor(job, token)))
: processor;
typeof processor === "function"
? (job, token) => db.runInWorkerContext(async () => processor(job, token))
: processor;

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/server/db/dbimpl.ts`:
- Around line 869-873: The close() method currently awaits the web pool
destruction before attempting worker pool cleanup, which means if
this.knex.destroy() rejects, the worker pool cleanup is skipped and leaves the
resource open. Refactor the close() method to use a try/finally block or
Promise.allSettled pattern to ensure both this.knex.destroy() and
this.workerKnex.destroy() (when they are different) complete execution
regardless of whether either one throws an error.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f5d32c9c-8682-4b8c-9fe2-acdd1bc97de9

📥 Commits

Reviewing files that changed from the base of the PR and between acf1145 and e69fcdf.

📒 Files selected for processing (8)
  • knexfile.ts
  • src/lib/server/db/db.ts
  • src/lib/server/db/dbimpl.ts
  • src/lib/server/db/poolContext.ts
  • src/lib/server/db/repositories/base.ts
  • src/lib/server/queues/q.ts
  • src/routes/(docs)/docs/content/v4/setup/database-setup.md
  • src/routes/(docs)/docs/content/v4/setup/environment-variables.md

Comment on lines 869 to +873
async close(): Promise<void> {
return await this.knex.destroy();
await this.knex.destroy();
if (this.workerKnex !== this.knex) {
await this.workerKnex.destroy();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Ensure the worker pool is destroyed even if web pool cleanup fails.

Line 870 awaits the web pool destroy before starting worker cleanup, so a rejection skips Line 872 and can leave the new worker pool open during shutdown/test cleanup.

Proposed fix
   async close(): Promise<void> {
-    await this.knex.destroy();
-    if (this.workerKnex !== this.knex) {
-      await this.workerKnex.destroy();
-    }
+    const workerKnex = this.workerKnex !== this.knex ? this.workerKnex : undefined;
+    try {
+      await this.knex.destroy();
+    } finally {
+      if (workerKnex) {
+        await workerKnex.destroy();
+      }
+    }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/server/db/dbimpl.ts` around lines 869 - 873, The close() method
currently awaits the web pool destruction before attempting worker pool cleanup,
which means if this.knex.destroy() rejects, the worker pool cleanup is skipped
and leaves the resource open. Refactor the close() method to use a try/finally
block or Promise.allSettled pattern to ensure both this.knex.destroy() and
this.workerKnex.destroy() (when they are different) complete execution
regardless of whether either one throws an error.

@rajnandan1
rajnandan1 merged commit 95c341f into main Jun 18, 2026
2 checks passed
@rajnandan1
rajnandan1 deleted the fix/db-pool-web-worker-split branch June 18, 2026 11:30
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