Skip to content

Commit e9ffcbf

Browse files
committed
feat: error-context helper, DB pool startup log, last-page cursor test, indexer retry docs
- Add buildErrorContext() to extract a consistent, log-safe error context (name, message, normalized code, optional debug-only stack, requestId) from any caught value; use it in the global error middleware's structured log (#302). - Log the database connection-pool configuration (pool size, pool/connect timeouts, query timeout) at startup before accepting requests, parsed from DATABASE_URL with no credentials leaked (#298). - Add an integration test for the creator list cursor advancing to a partial last page: only the remaining item is returned and hasMore=false (#301). - Document the indexer retry policy, exponential backoff with jitter, and DLQ exhaustion behavior in docs/indexer/RETRY_BACKOFF.md (#297). Closes #297 Closes #298 Closes #301 Closes #302
1 parent 6263c76 commit e9ffcbf

6 files changed

Lines changed: 444 additions & 0 deletions

File tree

docs/indexer/RETRY_BACKOFF.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Indexer Retry Policy & Backoff
2+
3+
This document describes how indexer jobs retry on failure, the backoff strategy
4+
between attempts, and what happens when a job exhausts its retries. It exists so
5+
contributors adding or modifying indexer jobs understand the expected behavior
6+
and avoid silent job loss.
7+
8+
See also: [`FEATURE_FLAGS.md`](./FEATURE_FLAGS.md), [`DLQ_WORKFLOW.md`](./DLQ_WORKFLOW.md),
9+
[`EVENT_PROCESSING.md`](./EVENT_PROCESSING.md).
10+
11+
## Backoff strategy
12+
13+
Retry delays are computed by `getBackoffWithJitter()` in
14+
`src/utils/jitter.utils.ts`:
15+
16+
```
17+
delay = applyJitter( min(maxDelayMs, baseDelayMs * 2^attempt), jitterFactor )
18+
```
19+
20+
- **Exponential growth** — the base delay doubles with each attempt
21+
(`baseDelayMs * 2^attempt`), so retries back off progressively.
22+
- **Cap** — the delay is clamped to `maxDelayMs` so it never grows unbounded.
23+
- **Jitter**`applyJitter()` multiplies the delay by a random factor in
24+
`[1 - jitterFactor, 1 + jitterFactor]` to avoid a thundering herd when many
25+
jobs fail at once.
26+
27+
| Parameter | Default | Meaning |
28+
| -------------- | --------- | -------------------------------------------------------- |
29+
| `baseDelayMs` | `1000` | Delay before the first retry (`attempt = 0`). |
30+
| `maxDelayMs` | `30000` | Upper bound on any single retry delay. |
31+
| `jitterFactor` | env value | Random spread, from `INDEXER_JITTER_FACTOR` (see below). |
32+
33+
`attempt` is 0-indexed, so successive delays are roughly `1s, 2s, 4s, 8s, 16s,
34+
30s, 30s, …` (before jitter).
35+
36+
## Configuration values
37+
38+
The retry/backoff behavior is controlled by these environment variables
39+
(validated at boot by `runIndexerFeatureFlagsStartupCheck()`):
40+
41+
| Env var | Type | Default | Controls |
42+
| -------------------------------------- | --------------- | -------- | ----------------------------------------------------------------------- |
43+
| `INDEXER_JITTER_FACTOR` | number `[0, 1]` | `0.1` | Jitter spread applied to every backoff delay. |
44+
| `ENABLE_INDEXER_DLQ` | boolean | `true` | Route retry-exhausted / terminal jobs to the dead-letter queue. |
45+
| `ENABLE_INDEXER_DEDUPE` | boolean | `true` | Required when the DLQ is enabled; dedupe keys identify repeat failures. |
46+
| `INDEXER_HEARTBEAT_STALE_THRESHOLD_MS` | number ms | `300000` | When the indexer is considered stalled (no progress). |
47+
| `BACKGROUND_JOB_LOCK_TTL_MS` | integer ms | `300000` | Lock TTL that prevents two workers from retrying the same job at once. |
48+
49+
## Exhaustion behavior
50+
51+
When a job exhausts its retry attempts (or hits a terminal, non-retryable
52+
error), it is moved to the **Dead-Letter Queue** via `moveToDLQ()` in
53+
`src/utils/indexer-dlq.utils.ts`. The DLQ record (`indexerDLQ` table) captures:
54+
55+
- `jobType` — the kind of job that failed,
56+
- `payload` — the original job payload, so it can be replayed,
57+
- `retryCount` — how many attempts were made before giving up,
58+
- `failureReason` / `errorDetails` — why it ultimately failed.
59+
60+
Consequences:
61+
62+
- **Jobs are parked, not lost.** A retry-exhausted job is preserved in the DLQ
63+
for inspection and manual replay rather than disappearing.
64+
- **DLQ depth is observable.** `getDLQDepth()` and `syncDLQMetrics()` expose the
65+
current backlog to the metrics registry; a growing DLQ indicates a systemic
66+
failure that needs attention.
67+
- **DLQ disabled is risky.** If `ENABLE_INDEXER_DLQ=false`, retry-exhausted jobs
68+
are dropped with only a log line — enable the DLQ in any environment where
69+
silent job loss is unacceptable. (`ENABLE_INDEXER_DLQ=true` also requires
70+
`ENABLE_INDEXER_DEDUPE=true`.)
71+
72+
## Guidance for new indexer jobs
73+
74+
- Use `getBackoffWithJitter(attempt, …)` for retry delays instead of a fixed
75+
sleep, so behavior is consistent and jittered.
76+
- Choose a sensible maximum attempt count for the job, then call `moveToDLQ()`
77+
once it is reached — do not loop forever.
78+
- Keep payloads in the DLQ replayable: store everything needed to re-run the job.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Integration test: creator list cursor advancing to a PARTIAL last page.
2+
//
3+
// The existing cursor round-trip test covers an even split (6 items / two full
4+
// pages of 3). This test covers the end-of-list edge where the final page holds
5+
// fewer than `limit` items: a known total of 5 paginated at limit=2 yields pages
6+
// of [2, 2, 1]. It asserts the last page returns only the remaining item and
7+
// that the response indicates there are no further pages (hasMore=false).
8+
//
9+
// Uses Jest mocks — no database required.
10+
11+
import { httpListCreators } from './creators.controllers';
12+
import * as creatorsUtils from './creators.utils';
13+
import type { CreatorProfile } from '../../types/profile.types';
14+
15+
function makeReq(query: Record<string, string> = {}): any {
16+
return { query };
17+
}
18+
19+
function makeRes(): any {
20+
const res: any = {};
21+
res.status = jest.fn().mockReturnValue(res);
22+
res.json = jest.fn().mockReturnValue(res);
23+
res.setHeader = jest.fn().mockReturnValue(res);
24+
res.set = jest.fn().mockReturnValue(res);
25+
return res;
26+
}
27+
28+
function makeNext(): jest.Mock {
29+
return jest.fn();
30+
}
31+
32+
function makeFixture(index: number): CreatorProfile {
33+
return {
34+
id: `cuid-${index}`,
35+
userId: `user-${index}`,
36+
handle: `creator_${index}`,
37+
displayName: `Creator ${index}`,
38+
isVerified: false,
39+
createdAt: new Date(`2024-0${index}-01T00:00:00.000Z`),
40+
updatedAt: new Date(`2024-0${index}-01T00:00:00.000Z`),
41+
};
42+
}
43+
44+
// Known total of 5 → at limit=2 the pages are [2, 2, 1].
45+
const TOTAL = 5;
46+
const ALL_FIXTURES = [1, 2, 3, 4, 5].map(makeFixture);
47+
const LIMIT = 2;
48+
const LAST_PAGE_OFFSET = 4; // pages start at offsets 0, 2, 4
49+
const LAST_PAGE_FIXTURES = ALL_FIXTURES.slice(LAST_PAGE_OFFSET); // 1 item
50+
51+
async function fetchPage(offset: number, pageItems: CreatorProfile[]) {
52+
jest
53+
.spyOn(creatorsUtils, 'fetchCreatorList')
54+
.mockResolvedValue([pageItems, TOTAL]);
55+
56+
const res = makeRes();
57+
await httpListCreators(
58+
makeReq({ limit: String(LIMIT), offset: String(offset) }),
59+
res,
60+
makeNext()
61+
);
62+
jest.restoreAllMocks();
63+
return res.json.mock.calls[0][0].data;
64+
}
65+
66+
describe('creator list — cursor pointing to the last page', () => {
67+
afterEach(() => {
68+
jest.restoreAllMocks();
69+
});
70+
71+
it('returns only the remaining item and signals no further pages', async () => {
72+
const data = await fetchPage(LAST_PAGE_OFFSET, LAST_PAGE_FIXTURES);
73+
74+
// Only the remaining item (5th of 5) is returned, not a full page.
75+
expect(data.items).toHaveLength(1);
76+
expect(data.items[0].id).toBe('cuid-5');
77+
78+
// Meta reflects the end of the list.
79+
expect(data.meta.offset).toBe(LAST_PAGE_OFFSET);
80+
expect(data.meta.limit).toBe(LIMIT);
81+
expect(data.meta.total).toBe(TOTAL);
82+
expect(data.meta.hasMore).toBe(false);
83+
});
84+
85+
it('advances through every page and ends with hasMore=false on a partial page', async () => {
86+
const pages: CreatorProfile[][] = [
87+
ALL_FIXTURES.slice(0, 2),
88+
ALL_FIXTURES.slice(2, 4),
89+
ALL_FIXTURES.slice(4, 5),
90+
];
91+
92+
const collected: string[] = [];
93+
let lastHasMore = true;
94+
95+
for (let i = 0; i < pages.length; i++) {
96+
const data = await fetchPage(i * LIMIT, pages[i]);
97+
collected.push(...data.items.map((item: { id: string }) => item.id));
98+
lastHasMore = data.meta.hasMore;
99+
}
100+
101+
// Traversal reconstructs the full fixture set exactly once...
102+
expect(collected).toEqual(ALL_FIXTURES.map(f => f.id));
103+
// ...and the final page is the end of the list.
104+
expect(lastHasMore).toBe(false);
105+
});
106+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describeDatabasePoolConfig } from './db-pool-config.utils';
2+
3+
describe('describeDatabasePoolConfig', () => {
4+
it('parses pool params from the database URL', () => {
5+
const url =
6+
'postgresql://user:secret@db.example.com:5432/app?connection_limit=15&pool_timeout=20&connect_timeout=8';
7+
8+
const config = describeDatabasePoolConfig(url, 5000);
9+
10+
expect(config).toEqual({
11+
poolSize: 15,
12+
poolTimeoutSeconds: 20,
13+
connectTimeoutSeconds: 8,
14+
queryTimeoutMs: 5000,
15+
});
16+
});
17+
18+
it('reports defaults when pool params are absent', () => {
19+
const config = describeDatabasePoolConfig(
20+
'postgresql://user:secret@db.example.com:5432/app',
21+
3000
22+
);
23+
24+
expect(config).toEqual({
25+
poolSize: 'default',
26+
poolTimeoutSeconds: 'default',
27+
connectTimeoutSeconds: 'default',
28+
queryTimeoutMs: 3000,
29+
});
30+
});
31+
32+
it('never leaks credentials or host details', () => {
33+
const url =
34+
'postgresql://admin:topsecret@db.internal:5432/app?connection_limit=5';
35+
36+
const serialized = JSON.stringify(describeDatabasePoolConfig(url, 5000));
37+
38+
expect(serialized).not.toContain('topsecret');
39+
expect(serialized).not.toContain('admin');
40+
expect(serialized).not.toContain('db.internal');
41+
});
42+
43+
it('degrades to defaults on an unparseable URL', () => {
44+
const config = describeDatabasePoolConfig('not a url', 5000);
45+
expect(config.poolSize).toBe('default');
46+
expect(config.queryTimeoutMs).toBe(5000);
47+
});
48+
});

src/utils/db-pool-config.utils.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { envConfig } from '../config';
2+
3+
/**
4+
* Connection-pool settings safe to log at startup.
5+
*
6+
* Prisma reads pool settings from the `DATABASE_URL` query string
7+
* (`connection_limit`, `pool_timeout`, `connect_timeout`). When a value is not
8+
* present in the URL, Prisma applies its own default, reported here as
9+
* `'default'`. No host, credentials, or other connection-string details are
10+
* included so this object is safe to emit to logs.
11+
*/
12+
export interface DatabasePoolConfig {
13+
/** Max connections in the pool (`connection_limit`); Prisma default ≈ num_cpus * 2 + 1. */
14+
poolSize: number | 'default';
15+
/** Seconds to wait for a free connection before timing out (`pool_timeout`). */
16+
poolTimeoutSeconds: number | 'default';
17+
/** Seconds to wait when opening a new connection (`connect_timeout`). */
18+
connectTimeoutSeconds: number | 'default';
19+
/** Per-query timeout enforced by the Prisma client extension. */
20+
queryTimeoutMs: number;
21+
}
22+
23+
function readNumericParam(
24+
params: URLSearchParams | undefined,
25+
key: string
26+
): number | 'default' {
27+
const raw = params?.get(key);
28+
if (raw == null || raw === '') {
29+
return 'default';
30+
}
31+
const value = Number(raw);
32+
return Number.isFinite(value) ? value : 'default';
33+
}
34+
35+
/**
36+
* Extracts the loggable connection-pool configuration from the database URL.
37+
* Parsing failures degrade gracefully to all-default values rather than
38+
* throwing during startup.
39+
*/
40+
export function describeDatabasePoolConfig(
41+
databaseUrl: string = envConfig.DATABASE_URL,
42+
queryTimeoutMs: number = envConfig.DB_QUERY_TIMEOUT_MS
43+
): DatabasePoolConfig {
44+
let params: URLSearchParams | undefined;
45+
try {
46+
params = new URL(databaseUrl).searchParams;
47+
} catch {
48+
params = undefined;
49+
}
50+
51+
return {
52+
poolSize: readNumericParam(params, 'connection_limit'),
53+
poolTimeoutSeconds: readNumericParam(params, 'pool_timeout'),
54+
connectTimeoutSeconds: readNumericParam(params, 'connect_timeout'),
55+
queryTimeoutMs,
56+
};
57+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { buildErrorContext } from './error-context.utils';
2+
import { ErrorCode } from '../constants/error.constants';
3+
4+
describe('buildErrorContext', () => {
5+
it('produces a consistent shape from a plain Error', () => {
6+
const ctx = buildErrorContext(new Error('boom'), {
7+
requestId: 'req-1',
8+
});
9+
10+
expect(ctx).toMatchObject({
11+
name: 'Error',
12+
message: 'boom',
13+
code: ErrorCode.INTERNAL_ERROR,
14+
requestId: 'req-1',
15+
});
16+
expect(typeof ctx.timestamp).toBe('string');
17+
});
18+
19+
it('normalizes a Prisma error code (P*) to DATABASE_ERROR', () => {
20+
const prismaErr = Object.assign(new Error('unique'), {
21+
name: 'PrismaClientKnownRequestError',
22+
code: 'P2002',
23+
});
24+
expect(buildErrorContext(prismaErr).code).toBe(ErrorCode.PRISMA_ERROR);
25+
});
26+
27+
it('maps Zod and JWT errors by name', () => {
28+
expect(buildErrorContext({ name: 'ZodError', message: 'x' }).code).toBe(
29+
ErrorCode.VALIDATION_ERROR
30+
);
31+
expect(
32+
buildErrorContext({ name: 'TokenExpiredError', message: 'x' }).code
33+
).toBe(ErrorCode.JWT_ERROR);
34+
});
35+
36+
it('honours an explicit errorCode when it is a known code', () => {
37+
expect(
38+
buildErrorContext({ errorCode: ErrorCode.NOT_FOUND, message: 'x' })
39+
.code
40+
).toBe(ErrorCode.NOT_FOUND);
41+
});
42+
43+
it('handles non-Error values (string) without throwing', () => {
44+
const ctx = buildErrorContext('just a string');
45+
expect(ctx.message).toBe('just a string');
46+
expect(ctx.code).toBe(ErrorCode.INTERNAL_ERROR);
47+
});
48+
49+
it('omits the stack unless includeStack is set', () => {
50+
const err = new Error('boom');
51+
expect(buildErrorContext(err).stack).toBeUndefined();
52+
expect(
53+
buildErrorContext(err, { includeStack: true }).stack
54+
).toBeDefined();
55+
});
56+
});

0 commit comments

Comments
 (0)