Skip to content

Commit c2047ad

Browse files
authored
Merge pull request #2330 from Giveth/fix/db-connection-resilience
fix: make API resilient to DB/pooler connection loss
2 parents 9f307f2 + e65c563 commit c2047ad

6 files changed

Lines changed: 120 additions & 10 deletions

File tree

config/example.env

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ TYPEORM_DATABASE_PASSWORD=
77
TYPEORM_DATABASE_HOST=
88
TYPEORM_DATABASE_PORT=
99
TYPEORM_LOGGING=
10+
# Per-process node-postgres pool size (default 10 if empty). The effective DB
11+
# connection count is this value x number of app processes (each graphql
12+
# instance + the jobs process). Keep the total well under the database/pooler
13+
# connection limit. Behind DigitalOcean managed Postgres / PgBouncer,
14+
# oversizing this (e.g. 97 per process across 5 processes) exhausts the pooler
15+
# and triggers "server login has been failing ... (server_login_retry)" errors.
16+
# (The jobs process also opens a separate CronDataSource pool that does NOT
17+
# honor this setting — it uses node-postgres' default of ~10 connections — so
18+
# count ~10 extra for that process on top of this value.)
1019
TYPEORM_DATABASE_POOL_SIZE=
1120
DROP_DATABASE=
1221
APOLLO_KEY=

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
import 'reflect-metadata';
2+
import { registerGlobalErrorHandlers } from './utils/globalErrorHandlers';
23
import { bootstrap } from './server/bootstrap';
34

5+
// Register process-level error handlers before anything async runs so a
6+
// transient DB/pooler error rejecting a promise cannot crash the whole API.
7+
registerGlobalErrorHandlers();
8+
49
bootstrap();

src/orm.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ import { CronJob } from './entities/CronJob';
55
import { getEntities } from './entities/entities';
66
import { redisConfig } from './redis';
77

8+
// Shared connection-pool tuning for DataSources that run behind a Postgres
9+
// connection pooler (DigitalOcean managed Postgres / PgBouncer).
10+
const poolerExtraConfig = {
11+
// Recycling idle connections every 500ms (the previous idleTimeoutMillis)
12+
// caused constant reconnect + login churn against the pooler, surfacing in
13+
// production as "server login has been failing ... (server_login_retry)" errors.
14+
idleTimeoutMillis: 30000,
15+
// Fail fast instead of hanging forever when a connection cannot be acquired
16+
// during a pooler stall, so requests error out quickly and the pool can recover.
17+
connectionTimeoutMillis: 10000,
18+
// (maxWaitingClients / evictionRunIntervalMillis were generic-pool options
19+
// that node-postgres ignores, so they were removed.)
20+
};
21+
822
export class AppDataSource {
923
private static datasource: DataSource;
1024

@@ -52,11 +66,7 @@ export class AppDataSource {
5266
},
5367
},
5468
poolSize,
55-
extra: {
56-
maxWaitingClients: 10,
57-
evictionRunIntervalMillis: 500,
58-
idleTimeoutMillis: 500,
59-
},
69+
extra: poolerExtraConfig,
6070
});
6171
await AppDataSource.datasource.initialize();
6272
}
@@ -81,11 +91,7 @@ export class CronDataSource {
8191
entities: [CronJob],
8292
synchronize: false,
8393
dropSchema: false,
84-
extra: {
85-
maxWaitingClients: 10,
86-
evictionRunIntervalMillis: 500,
87-
idleTimeoutMillis: 500,
88-
},
94+
extra: poolerExtraConfig,
8995
});
9096
await CronDataSource.datasource.initialize();
9197
}

src/sentryLogger.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ Sentry.init({
1010
// We recommend adjusting this value in production, or using tracesSampler
1111
// for finer control
1212
tracesSampleRate: 1.0,
13+
14+
// Crash/rejection handling lives in src/utils/globalErrorHandlers.ts, which
15+
// registers our own process-level handlers (keep-alive on unhandledRejection,
16+
// clean exit on uncaughtException). Disable Sentry's built-in global handlers
17+
// so errors aren't captured twice and the handlers don't race to exit.
18+
integrations: defaults =>
19+
defaults.filter(
20+
integration =>
21+
integration.name !== 'OnUncaughtException' &&
22+
integration.name !== 'OnUnhandledRejection',
23+
),
1324
});
1425

1526
export default Sentry;

src/server/bootstrap.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
translationErrorMessagesKeys,
3636
} from '../utils/errorMessages';
3737
import { logger } from '../utils/logger';
38+
import { flushSentryAndExit } from '../utils/globalErrorHandlers';
3839
import { isTrustedVercelRequest } from '../utils/ipWhitelist';
3940
import { adminJsRootPath, getAdminJsRouter } from './adminJs/adminJs';
4041
// import { apiGivRouter } from '../routers/apiGivRoutes';
@@ -426,6 +427,17 @@ export async function bootstrap() {
426427
});
427428
} catch (err) {
428429
logger.fatal('bootstrap() error', err);
430+
SentryLogger.captureException(err as Error);
431+
// A failure during startup (e.g. the database/pooler being unreachable when
432+
// AppDataSource.initialize() runs) leaves the process with no HTTP listener
433+
// on port 4000 — a zombie that `restart: always` never recovers, because the
434+
// restart policy only fires on process exit. Exit so the container is
435+
// recreated cleanly and self-heals once the dependency is reachable again.
436+
// Skipped under tests so a failed bootstrap is reported by the test runner
437+
// instead of abruptly terminating it.
438+
if (!isTestEnv) {
439+
flushSentryAndExit();
440+
}
429441
}
430442

431443
async function continueDbSetup() {

src/utils/globalErrorHandlers.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { logger } from './logger';
2+
import SentryLogger from '../sentryLogger';
3+
4+
/**
5+
* Registers process-level handlers for errors that escape application code.
6+
*
7+
* Why this exists: the API runs behind a Postgres connection pooler
8+
* (DigitalOcean managed Postgres / PgBouncer). When the pooler or database has
9+
* a transient problem, in-flight DB queries reject. Without an
10+
* `unhandledRejection` listener, Node (>= 15) terminates the whole process on
11+
* the first such rejection, taking the entire API down for a momentary DB blip.
12+
*
13+
* Behaviour:
14+
* - unhandledRejection: log + report to Sentry, then KEEP the process alive.
15+
* A single rejected promise (often a transient DB error) must not tear down
16+
* the server; it should keep serving and recover once the DB is reachable.
17+
* - uncaughtException: log + report to Sentry, then EXIT. The process state is
18+
* undefined after an uncaught exception, so we let the container restart
19+
* policy (`restart: always`) recreate a clean process.
20+
*/
21+
let handlersRegistered = false;
22+
23+
export function registerGlobalErrorHandlers(): void {
24+
// Idempotent: never attach the listeners more than once.
25+
if (handlersRegistered) {
26+
return;
27+
}
28+
handlersRegistered = true;
29+
30+
process.on('unhandledRejection', (reason: unknown) => {
31+
logger.error('unhandledRejection - process kept alive', reason);
32+
captureToSentry(reason);
33+
});
34+
35+
process.on('uncaughtException', (error: Error) => {
36+
logger.fatal('uncaughtException - exiting for a clean restart', error);
37+
captureToSentry(error);
38+
flushSentryAndExit();
39+
});
40+
}
41+
42+
function captureToSentry(error: unknown): void {
43+
try {
44+
SentryLogger.captureException(
45+
error instanceof Error ? error : new Error(String(error)),
46+
);
47+
} catch {
48+
// Never let error reporting throw from inside an error handler.
49+
}
50+
}
51+
52+
/**
53+
* Flushes pending Sentry events (best-effort, max 2s) and then exits with a
54+
* non-zero code so the container restart policy (`restart: always`) recreates a
55+
* clean process. Used by the uncaughtException handler above and by the
56+
* bootstrap() startup-failure path.
57+
*/
58+
export function flushSentryAndExit(): void {
59+
// A hard fallback guarantees the process exits even if flushing stalls.
60+
const forceExit = setTimeout(() => process.exit(1), 3000);
61+
void SentryLogger.close(2000)
62+
.catch(() => undefined)
63+
.then(() => {
64+
clearTimeout(forceExit);
65+
process.exit(1);
66+
});
67+
}

0 commit comments

Comments
 (0)