Skip to content

Make wrapClient idempotent to prevent stack overflow on pooled clients - #190

Merged
enko merged 3 commits into
mainfrom
claude/scheduler-stack-overflow-n3vxt9
Jun 25, 2026
Merged

Make wrapClient idempotent to prevent stack overflow on pooled clients#190
enko merged 3 commits into
mainfrom
claude/scheduler-stack-overflow-n3vxt9

Conversation

@enko

@enko enko commented Jun 25, 2026

Copy link
Copy Markdown
Member

Summary

Fixed a critical bug where reusing pooled database clients would cause stack overflow errors by preventing the query wrapper from being applied multiple times to the same client.

Key Changes

  • Made wrapClient() idempotent by tracking wrapped clients with a Symbol marker (WRAPPED)
  • When a client is checked out from the pool multiple times, wrapClient() now returns it unchanged instead of wrapping the query method again
  • Exported wrapClient() for testing purposes
  • Added comprehensive test coverage for idempotency and error handling

Implementation Details

  • Uses a Symbol property (WRAPPED) to mark clients that have already been wrapped, avoiding property name collisions
  • The wrapper still delegates to the original query exactly once, preserving the error handling and stack trace enhancement behavior
  • This prevents the wrapper from nesting deeper on each pool checkout, which would eventually cause "Maximum call stack size exceeded" errors during long-running applications that reuse pooled connections

https://claude.ai/code/session_01MKxNmLHzc7ygiEWQYHXwgP

The query-stack-trace wrapper re-wrapped client.query on every checkout.
Because pg pools reuse idle clients, wrapClient ran repeatedly on the same
physical client, nesting the query wrapper one level deeper each time:
wN -> wN-1 -> ... -> w1 -> realQuery. Over hours of traffic (the
every-minute notification scheduler plus all HTTP requests) the nesting
grew until invoking client.query blew the call stack with
"RangeError: Maximum call stack size exceeded".

The scheduler surfaced in the trace only because it is the most frequent
query path; every query shared the same fault.

Make wrapClient idempotent by marking a client once its query is wrapped
and skipping re-wrap on subsequent checkouts. Add regression tests
covering wrap-once-across-checkouts and stack-trace preservation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKxNmLHzc7ygiEWQYHXwgP

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 pull request addresses a critical backend reliability issue where pooled pg clients could be re-wrapped on every checkout, eventually causing a stack overflow when calling client.query() in long-running processes.

Changes:

  • Makes wrapClient() idempotent by marking wrapped clients with a Symbol and returning early on subsequent calls.
  • Exports wrapClient() and adds tests intended to cover idempotency and rejection/error behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
apps/backend/src/utils/db.ts Adds a Symbol marker and early-return logic to ensure pooled clients are wrapped only once; exports wrapClient() for testability.
apps/backend/tests/db.test.ts Adds tests for idempotent wrapping and query rejection behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apps/backend/tests/db.test.ts Outdated
Comment on lines +136 to +144
it('preserves the application stack trace on query rejection', async () => {
const dbError = new Error('boom');
const realQuery = vi.fn().mockRejectedValue(dbError);
const client = { query: realQuery } as unknown as pg.PoolClient;

wrapClient(client);

await expect(client.query('SELECT 1')).rejects.toThrow('boom');
});
The rejection test only checked the error message, so it would have passed
even if enhanceErrorWithStack regressed. Capture the thrown error and assert
the "--- Query initiated from ---" marker plus real call-site frames are
appended, so the test actually guards stack-trace enhancement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKxNmLHzc7ygiEWQYHXwgP

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

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

Comment thread apps/backend/src/utils/db.ts Outdated
// client once it's wrapped prevents re-wrapping its `query` on every checkout,
// which would otherwise nest the wrapper deeper each time until invoking
// `client.query` overflows the call stack ("Maximum call stack size exceeded").
const WRAPPED = Symbol('freundebuch.queryWrapped');
Comment on lines 6 to 10
closePool,
createPool,
setupGracefulShutdown,
wrapClient,
} from '../src/utils/db.js';
Symbol() is module-instance local, so if the db module is ever loaded under
two specifiers (db.js and db.ts) each instance gets a distinct marker and the
same pooled client could be wrapped twice, reintroducing the stack-overflow
risk. Symbol.for shares the marker across instances while still avoiding
string-key collisions.

Also drop the dead no-op vi.mock('../src/utils/db.ts') from the test, which
mixed module specifiers (the file imports from db.js) for no effect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKxNmLHzc7ygiEWQYHXwgP

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

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

@enko
enko merged commit d80d052 into main Jun 25, 2026
8 checks passed
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.89.2 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants