Skip to content

Commit c657151

Browse files
Add structured log for indexer lag threshold breach
1 parent a7d197e commit c657151

4 files changed

Lines changed: 219 additions & 59 deletions

File tree

src/modules/health/health.controllers.test.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,24 @@ jest.mock('../../utils/prisma.utils', () => ({
1919
},
2020
}));
2121

22+
jest.mock('../../utils/indexer-cursor-staleness.utils', () => ({
23+
checkIndexerCursorStalenessFromStore: jest.fn().mockResolvedValue(undefined),
24+
}));
25+
2226
import {
2327
indexerHeartbeatCheck,
2428
recordIndexerHeartbeat,
2529
readinessCheck,
2630
} from './health.controllers';
2731
import { indexerHeartbeat } from '../../utils/heartbeat.service';
32+
import { checkIndexerCursorStalenessFromStore } from '../../utils/indexer-cursor-staleness.utils';
2833
import { prisma } from '../../utils/prisma.utils';
2934

35+
const checkCursorStalenessMock =
36+
checkIndexerCursorStalenessFromStore as jest.MockedFunction<
37+
typeof checkIndexerCursorStalenessFromStore
38+
>;
39+
3040
const queryRawMock = prisma.$queryRaw as unknown as jest.Mock;
3141

3242
// ---------------------------------------------------------------------------
@@ -118,13 +128,14 @@ describe('Indexer Heartbeat Controllers', () => {
118128
describe('recordIndexerHeartbeat', () => {
119129
beforeEach(() => {
120130
indexerHeartbeat.reset();
131+
checkCursorStalenessMock.mockClear();
121132
});
122133

123-
it('records a heartbeat and returns 200', () => {
134+
it('records a heartbeat and returns 200', async () => {
124135
const req = mockRequest();
125136
const res = mockResponse();
126137

127-
recordIndexerHeartbeat(req, res);
138+
await recordIndexerHeartbeat(req, res);
128139

129140
expect(res.statusCode).toBe(200);
130141
expect(res.body).toEqual(
@@ -139,14 +150,23 @@ describe('Indexer Heartbeat Controllers', () => {
139150
);
140151
});
141152

142-
it('makes the indexer status healthy', () => {
153+
it('makes the indexer status healthy', async () => {
143154
const req = mockRequest();
144155
const res = mockResponse();
145156

146-
recordIndexerHeartbeat(req, res);
157+
await recordIndexerHeartbeat(req, res);
147158

148159
expect(indexerHeartbeat.getStatus().status).toBe('healthy');
149160
});
161+
162+
it('checks indexer cursor staleness after recording a heartbeat', async () => {
163+
const req = mockRequest();
164+
const res = mockResponse();
165+
166+
await recordIndexerHeartbeat(req, res);
167+
168+
expect(checkCursorStalenessMock).toHaveBeenCalledWith({ job: 'indexer' });
169+
});
150170
});
151171
});
152172

src/modules/health/health.controllers.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Request, Response } from 'express';
22
import { prisma } from '../../utils/prisma.utils';
33
import { envConfig } from '../../config';
44
import { indexerHeartbeat } from '../../utils/heartbeat.service';
5+
import { checkIndexerCursorStalenessFromStore } from '../../utils/indexer-cursor-staleness.utils';
56
import { sendSuccess } from '../../utils/api-response.utils';
67
import { PUBLIC_ENDPOINT_CACHE_SECONDS } from '../../constants/public-endpoint-cache.constants';
78

@@ -173,8 +174,12 @@ export const indexerHeartbeatCheck = (_: Request, res: Response): void => {
173174
* POST /health/indexer/heartbeat
174175
* Called by the indexer worker to record a successful run.
175176
*/
176-
export const recordIndexerHeartbeat = (_: Request, res: Response): void => {
177+
export const recordIndexerHeartbeat = async (
178+
_: Request,
179+
res: Response
180+
): Promise<void> => {
177181
const timestamp = indexerHeartbeat.recordHeartbeat();
182+
await checkIndexerCursorStalenessFromStore({ job: 'indexer' });
178183
sendSuccess(
179184
res,
180185
{ recorded: true, timestamp: timestamp.toISOString() },
Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
1-
import { logger } from './logger.utils';
21
import { envConfig } from '../config';
2+
import { prisma } from './prisma.utils';
3+
import { logger } from './logger.utils';
4+
5+
/** Correlates a staleness warning with the indexer job that observed it. */
6+
export interface IndexerCursorStalenessContext {
7+
/** Indexer job or worker surface (e.g. `indexer`, `ledger-indexer`). */
8+
job?: string;
9+
/** Opaque cursor value from the backing store, when available. */
10+
cursor?: string;
11+
/** Latest indexed ledger sequence, when available. */
12+
ledger?: number;
13+
}
314

415
/**
516
* Emits a structured warning when the indexer cursor has not been updated within the
@@ -11,20 +22,64 @@ import { envConfig } from '../config';
1122
* Default threshold: `INDEXER_CURSOR_STALE_AGE_WARNING_MS` env variable (300 000 ms / 5 min).
1223
* Override with the `thresholdMs` parameter for per-call control.
1324
*
25+
* No log is emitted when `ENABLE_INDEXER_CURSOR_STALENESS_WARNING` is false or when lag
26+
* is at or below the threshold.
27+
*
1428
* @param lastUpdatedAt - Timestamp of the cursor's most recent update
1529
* @param thresholdMs - Optional override; defaults to env config value
30+
* @param context - Optional fields to correlate the warning with an indexer job
1631
*/
1732
export function warnIfIndexerCursorStale(
18-
lastUpdatedAt: Date,
19-
thresholdMs: number = envConfig.INDEXER_CURSOR_STALE_AGE_WARNING_MS
33+
lastUpdatedAt: Date,
34+
thresholdMs: number = envConfig.INDEXER_CURSOR_STALE_AGE_WARNING_MS,
35+
context: IndexerCursorStalenessContext = {}
2036
): void {
21-
const ageMs = Date.now() - lastUpdatedAt.getTime();
22-
if (ageMs > thresholdMs) {
23-
logger.warn({
24-
msg: 'Indexer cursor is stale',
25-
lastUpdatedAt: lastUpdatedAt.toISOString(),
26-
ageMs,
27-
thresholdMs,
28-
});
29-
}
37+
if (!envConfig.ENABLE_INDEXER_CURSOR_STALENESS_WARNING) {
38+
return;
39+
}
40+
41+
const lagMs = Date.now() - lastUpdatedAt.getTime();
42+
if (lagMs > thresholdMs) {
43+
logger.warn({
44+
msg: 'Indexer cursor lag exceeded threshold',
45+
job: context.job ?? 'indexer',
46+
lagMs,
47+
thresholdMs,
48+
lastUpdatedAt: lastUpdatedAt.toISOString(),
49+
...(context.cursor !== undefined ? { cursor: context.cursor } : {}),
50+
...(context.ledger !== undefined ? { ledger: context.ledger } : {}),
51+
});
52+
}
53+
}
54+
55+
/**
56+
* Reads the latest indexed-ledger cursor from the database and emits a staleness
57+
* warning when its age exceeds the configured threshold.
58+
*
59+
* Intended for the indexer worker heartbeat path after a successful run.
60+
*/
61+
export async function checkIndexerCursorStalenessFromStore(
62+
context: IndexerCursorStalenessContext = {}
63+
): Promise<void> {
64+
if (!envConfig.ENABLE_INDEXER_CURSOR_STALENESS_WARNING) {
65+
return;
66+
}
67+
68+
const status = await prisma.indexedLedger.findFirst({
69+
orderBy: { updatedAt: 'desc' },
70+
});
71+
72+
if (!status) {
73+
return;
74+
}
75+
76+
warnIfIndexerCursorStale(
77+
status.updatedAt,
78+
envConfig.INDEXER_CURSOR_STALE_AGE_WARNING_MS,
79+
{
80+
job: context.job ?? 'indexer',
81+
cursor: status.cursor,
82+
ledger: status.ledger,
83+
}
84+
);
3085
}
Lines changed: 122 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,134 @@
1-
import { warnIfIndexerCursorStale } from '../indexer-cursor-staleness.utils';
1+
import {
2+
checkIndexerCursorStalenessFromStore,
3+
warnIfIndexerCursorStale,
4+
} from '../indexer-cursor-staleness.utils';
25
import { logger } from '../logger.utils';
6+
import { prisma } from '../prisma.utils';
7+
import { envConfig } from '../../config';
38

49
jest.mock('../logger.utils', () => ({
5-
logger: { warn: jest.fn() },
10+
logger: { warn: jest.fn() },
11+
}));
12+
13+
jest.mock('../prisma.utils', () => ({
14+
prisma: {
15+
indexedLedger: {
16+
findFirst: jest.fn(),
17+
},
18+
},
19+
}));
20+
21+
jest.mock('../../config', () => ({
22+
envConfig: {
23+
ENABLE_INDEXER_CURSOR_STALENESS_WARNING: true,
24+
INDEXER_CURSOR_STALE_AGE_WARNING_MS: 300_000,
25+
},
626
}));
727

828
const warnMock = logger.warn as jest.Mock;
29+
const findFirstMock = prisma.indexedLedger.findFirst as jest.Mock;
930

1031
beforeEach(() => {
11-
warnMock.mockClear();
32+
warnMock.mockClear();
33+
findFirstMock.mockReset();
34+
(envConfig as { ENABLE_INDEXER_CURSOR_STALENESS_WARNING: boolean }).ENABLE_INDEXER_CURSOR_STALENESS_WARNING = true;
1235
});
1336

1437
describe('warnIfIndexerCursorStale()', () => {
15-
it('emits a warning when cursor age exceeds the threshold', () => {
16-
const sixMinutesAgo = new Date(Date.now() - 360_000);
17-
warnIfIndexerCursorStale(sixMinutesAgo, 300_000);
18-
expect(warnMock).toHaveBeenCalledTimes(1);
19-
expect(warnMock).toHaveBeenCalledWith(
20-
expect.objectContaining({
21-
msg: 'Indexer cursor is stale',
22-
thresholdMs: 300_000,
23-
})
24-
);
25-
});
26-
27-
it('does not emit a warning when cursor age is within the threshold', () => {
28-
const oneMinuteAgo = new Date(Date.now() - 60_000);
29-
warnIfIndexerCursorStale(oneMinuteAgo, 300_000);
30-
expect(warnMock).not.toHaveBeenCalled();
31-
});
32-
33-
it('does not emit a warning when cursor age exactly equals the threshold', () => {
34-
const exactly = new Date(Date.now() - 300_000);
35-
warnIfIndexerCursorStale(exactly, 300_000);
36-
expect(warnMock).not.toHaveBeenCalled();
37-
});
38-
39-
it('includes lastUpdatedAt, ageMs and thresholdMs in the warning payload', () => {
40-
const ts = new Date(Date.now() - 400_000);
41-
warnIfIndexerCursorStale(ts, 300_000);
42-
const call = warnMock.mock.calls[0][0];
43-
expect(call.lastUpdatedAt).toBe(ts.toISOString());
44-
expect(typeof call.ageMs).toBe('number');
45-
expect(call.ageMs).toBeGreaterThan(300_000);
46-
expect(call.thresholdMs).toBe(300_000);
47-
});
48-
49-
it('respects a custom threshold override', () => {
50-
const twoSecondsAgo = new Date(Date.now() - 2_000);
51-
warnIfIndexerCursorStale(twoSecondsAgo, 1_000);
52-
expect(warnMock).toHaveBeenCalledTimes(1);
53-
});
38+
it('emits a warning when cursor lag exceeds the threshold', () => {
39+
const sixMinutesAgo = new Date(Date.now() - 360_000);
40+
warnIfIndexerCursorStale(sixMinutesAgo, 300_000, {
41+
job: 'indexer',
42+
cursor: 'cursor-1',
43+
ledger: 42,
44+
});
45+
expect(warnMock).toHaveBeenCalledTimes(1);
46+
expect(warnMock).toHaveBeenCalledWith(
47+
expect.objectContaining({
48+
msg: 'Indexer cursor lag exceeded threshold',
49+
job: 'indexer',
50+
cursor: 'cursor-1',
51+
ledger: 42,
52+
lagMs: expect.any(Number),
53+
thresholdMs: 300_000,
54+
})
55+
);
56+
});
57+
58+
it('does not emit a warning when cursor lag is within the threshold', () => {
59+
const oneMinuteAgo = new Date(Date.now() - 60_000);
60+
warnIfIndexerCursorStale(oneMinuteAgo, 300_000);
61+
expect(warnMock).not.toHaveBeenCalled();
62+
});
63+
64+
it('does not emit a warning when cursor lag exactly equals the threshold', () => {
65+
const exactly = new Date(Date.now() - 300_000);
66+
warnIfIndexerCursorStale(exactly, 300_000);
67+
expect(warnMock).not.toHaveBeenCalled();
68+
});
69+
70+
it('includes lastUpdatedAt, lagMs and thresholdMs in the warning payload', () => {
71+
const ts = new Date(Date.now() - 400_000);
72+
warnIfIndexerCursorStale(ts, 300_000);
73+
const call = warnMock.mock.calls[0][0];
74+
expect(call.lastUpdatedAt).toBe(ts.toISOString());
75+
expect(typeof call.lagMs).toBe('number');
76+
expect(call.lagMs).toBeGreaterThan(300_000);
77+
expect(call.thresholdMs).toBe(300_000);
78+
});
79+
80+
it('respects a custom threshold override', () => {
81+
const twoSecondsAgo = new Date(Date.now() - 2_000);
82+
warnIfIndexerCursorStale(twoSecondsAgo, 1_000);
83+
expect(warnMock).toHaveBeenCalledTimes(1);
84+
});
85+
86+
it('does not emit a warning when ENABLE_INDEXER_CURSOR_STALENESS_WARNING is false', () => {
87+
(envConfig as { ENABLE_INDEXER_CURSOR_STALENESS_WARNING: boolean }).ENABLE_INDEXER_CURSOR_STALENESS_WARNING = false;
88+
const sixMinutesAgo = new Date(Date.now() - 360_000);
89+
warnIfIndexerCursorStale(sixMinutesAgo, 300_000);
90+
expect(warnMock).not.toHaveBeenCalled();
91+
});
92+
});
93+
94+
describe('checkIndexerCursorStalenessFromStore()', () => {
95+
it('emits a warning when the stored cursor is stale', async () => {
96+
findFirstMock.mockResolvedValue({
97+
ledger: 99,
98+
cursor: 'abc',
99+
updatedAt: new Date(Date.now() - 400_000),
100+
});
101+
102+
await checkIndexerCursorStalenessFromStore({ job: 'indexer' });
103+
104+
expect(warnMock).toHaveBeenCalledWith(
105+
expect.objectContaining({
106+
job: 'indexer',
107+
cursor: 'abc',
108+
ledger: 99,
109+
lagMs: expect.any(Number),
110+
thresholdMs: 300_000,
111+
})
112+
);
113+
});
114+
115+
it('does not emit a warning when no indexed ledger row exists', async () => {
116+
findFirstMock.mockResolvedValue(null);
117+
118+
await checkIndexerCursorStalenessFromStore();
119+
120+
expect(warnMock).not.toHaveBeenCalled();
121+
});
122+
123+
it('does not emit a warning when the stored cursor is fresh', async () => {
124+
findFirstMock.mockResolvedValue({
125+
ledger: 1,
126+
cursor: 'fresh',
127+
updatedAt: new Date(),
128+
});
129+
130+
await checkIndexerCursorStalenessFromStore();
131+
132+
expect(warnMock).not.toHaveBeenCalled();
133+
});
54134
});

0 commit comments

Comments
 (0)