Skip to content

Commit ae93407

Browse files
authored
Merge pull request #716 from greatest0fallt1me/feat/670-backfill-lag-metrics
Add backfill lag gauge
2 parents 84c3c07 + f267bf6 commit ae93407

6 files changed

Lines changed: 92 additions & 2 deletions

File tree

docs/routes/backfill-events.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ the persisted checkpoint.
6161
the DB transaction boundary — each batch and its checkpoint commit
6262
atomically.
6363

64+
## Lag metric and alerting
65+
66+
`backfill_lag_blocks{job,contract}` reports the difference between the current
67+
Starknet chain head and the last block persisted for each backfill job. The
68+
gauge is refreshed when progress is read and after each checkpoint advances.
69+
RPC failures are counted in `backfill_lag_rpc_errors_total` and do not fail the
70+
backfill itself. Alert when lag remains above the normal batch window (for
71+
example, more than 100 blocks for 15 minutes), adjusting the threshold for the
72+
configured indexing interval and expected chain throughput.
73+
6474
## Backward-compatibility contract (Issue #264)
6575

6676
1. **Input alias stability**`before`, `resumeToken`, and `cursor` are
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
ALTER TABLE "backfill_progress"
2+
ADD COLUMN "last_block_number" bigint,
3+
ADD COLUMN "last_contract_address" text;

src/db/schema.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,8 @@ export const backfillProgress = pgTable(
636636
jobName: text("job_name").primaryKey(),
637637
status: text("status").notNull().default("idle"),
638638
lastCursor: timestamp("last_cursor"),
639+
lastBlockNumber: bigint("last_block_number", { mode: "number" }),
640+
lastContractAddress: text("last_contract_address"),
639641
totalScanned: integer("total_scanned").notNull().default(0),
640642
totalCreated: integer("total_created").notNull().default(0),
641643
lastError: text("last_error"),
@@ -795,4 +797,3 @@ export const SCHEMA_TABLES: Array<{ name: string; table: PgTableWithColumns<any>
795797
{ name: "sessions", table: sessions },
796798
{ name: "backfillProgress", table: backfillProgress },
797799
];
798-

src/routes/backfill-events.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ vi.mock("../config.js", () => ({
3333
env: { ADMIN_ADDRESSES: ["0xabc1"] },
3434
}));
3535

36+
vi.mock("../starknet/client.js", () => ({
37+
provider: { getBlockNumber: vi.fn().mockResolvedValue(1200) },
38+
}));
39+
3640
const { dbMock, schemaMock, store } = vi.hoisted(() => {
3741
interface AgreementEventRow {
3842
id: string;
@@ -48,6 +52,8 @@ const { dbMock, schemaMock, store } = vi.hoisted(() => {
4852
jobName: string;
4953
status: string;
5054
lastCursor: Date | null;
55+
lastBlockNumber: number | null;
56+
lastContractAddress: string | null;
5157
totalScanned: number;
5258
totalCreated: number;
5359
lastError: string | null;
@@ -245,6 +251,8 @@ import {
245251
getBackfillProgress,
246252
} from "./backfill-events.js";
247253
import { requireSession } from "../auth/session.js";
254+
import { provider } from "../starknet/client.js";
255+
import { getStarknetMetricsSnapshot, resetStarknetMetrics } from "../starknet/client-metrics.js";
248256

249257
const ADMIN = "0xabc1";
250258
const NON_ADMIN = "0xdef2";
@@ -313,10 +321,28 @@ function makeDescendingRows(count: number, idOffset = 0) {
313321

314322
beforeEach(() => {
315323
vi.clearAllMocks();
324+
resetStarknetMetrics();
325+
vi.mocked(provider.getBlockNumber).mockResolvedValue(1200);
316326
store.reset();
317327
vi.mocked(requireSession).mockResolvedValue(true);
318328
});
319329

330+
describe("backfill lag metrics", () => {
331+
it("records chain-head lag with job and contract labels", async () => {
332+
queueRows([makeRow(1)]);
333+
vi.mocked(provider.getBlockNumber).mockResolvedValue(1200);
334+
335+
const response = await request(makeApp())
336+
.post("/api/v1/backfill/employee-events")
337+
.set(authHeaders(ADMIN));
338+
339+
expect(response.status).toBe(200);
340+
expect(getStarknetMetricsSnapshot().gauges[
341+
'backfill_lag_blocks{job="employee-events",contract="0xcontract"}'
342+
]).toBe(199);
343+
});
344+
});
345+
320346
interface JobConfig {
321347
jobName: "employee-events" | "milestone-events";
322348
path: string;
@@ -790,4 +816,3 @@ describe("Edge cases", () => {
790816
});
791817
});
792818

793-

src/routes/backfill-events.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import { requireAuth, requireAdmin } from "../auth/middleware.js";
33
import { z } from "zod";
44
import { db, schema } from "../db/index.js";
55
import { sql, eq } from "drizzle-orm";
6+
import { provider } from "../starknet/client.js";
7+
import {
8+
incStarknetMetric,
9+
labeledStarknetMetric,
10+
setStarknetGauge,
11+
} from "../starknet/client-metrics.js";
612

713
export const backfillEventsRouter = Router();
814

@@ -36,6 +42,7 @@ export const RESULTS_PREVIEW_SIZE = 10;
3642
* matches what is actually durable in `agreement_events`.
3743
*/
3844
export const BACKFILL_CHECKPOINT_BATCH_SIZE = 100;
45+
export const BACKFILL_LAG_METRIC = "backfill_lag_blocks";
3946

4047
// ---------------------------------------------------------------------------
4148
// Resume token freshness bounds (Issue #263)
@@ -61,6 +68,27 @@ export const CLOCK_SKEW_TOLERANCE_MS = 60 * 1000; // 60 seconds
6168

6269
export type BackfillJobName = "employee-events" | "milestone-events";
6370

71+
/** Refresh lag observability without allowing an RPC outage to stop a backfill. */
72+
async function updateBackfillLag(
73+
jobName: BackfillJobName,
74+
lastBlockNumber: number | null | undefined,
75+
contractAddress: string | null | undefined,
76+
): Promise<void> {
77+
try {
78+
const chainHead = await provider.getBlockNumber();
79+
const lag = Math.max(0, chainHead - (lastBlockNumber ?? 0));
80+
setStarknetGauge(
81+
labeledStarknetMetric(BACKFILL_LAG_METRIC, {
82+
job: jobName,
83+
contract: contractAddress ?? "unknown",
84+
}),
85+
lag,
86+
);
87+
} catch {
88+
incStarknetMetric("backfill_lag_rpc_errors_total");
89+
}
90+
}
91+
6492
export const EMPLOYEE_BACKFILL_JOB: BackfillJobName = "employee-events";
6593
export const MILESTONE_BACKFILL_JOB: BackfillJobName = "milestone-events";
6694
export const BACKFILL_JOB_NAMES: readonly BackfillJobName[] = [
@@ -261,6 +289,8 @@ export async function upsertBackfillProgress(
261289
fields: {
262290
status?: string;
263291
lastCursor?: Date | null;
292+
lastBlockNumber?: number | null;
293+
lastContractAddress?: string | null;
264294
totalScanned?: number;
265295
totalCreated?: number;
266296
lastError?: string | null;
@@ -309,6 +339,8 @@ export async function getBackfillProgress(
309339
): Promise<{
310340
status: string;
311341
lastCursor: Date | null;
342+
lastBlockNumber: number | null;
343+
lastContractAddress: string | null;
312344
totalScanned: number;
313345
totalCreated: number;
314346
lastError: string | null;
@@ -324,6 +356,8 @@ export async function getBackfillProgress(
324356
return {
325357
status: row.status,
326358
lastCursor: row.lastCursor,
359+
lastBlockNumber: row.lastBlockNumber,
360+
lastContractAddress: row.lastContractAddress,
327361
totalScanned: row.totalScanned,
328362
totalCreated: row.totalCreated,
329363
lastError: row.lastError,
@@ -384,6 +418,7 @@ export async function performBackfill(
384418
persistedTotalScanned = progress.totalScanned;
385419
persistedTotalCreated = progress.totalCreated;
386420
}
421+
await updateBackfillLag(jobName, progress?.lastBlockNumber, progress?.lastContractAddress);
387422
}
388423

389424
const conditions = sql`1=1`;
@@ -483,12 +518,20 @@ export async function performBackfill(
483518
totalScanned: batchTotalScanned + batch.length,
484519
totalCreated: batchCreatedCount + batchCreated,
485520
lastCursor: batchCursor,
521+
lastBlockNumber: Number(lastRow.block_number),
522+
lastContractAddress: String(lastRow.contract_address),
486523
});
487524

488525
batchCreatedCount += batchCreated;
489526
batchTotalScanned += batch.length;
490527
});
491528

529+
await updateBackfillLag(
530+
jobName,
531+
Number(batch[batch.length - 1].block_number),
532+
String(batch[batch.length - 1].contract_address),
533+
);
534+
492535
const insertedIds = new Set(insertedRows.map((r) => String(r.id)));
493536

494537
for (const row of batch) {

src/starknet/client-metrics.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ export function setStarknetGauge(name: string, value: number): void {
6565
gauges[name] = value;
6666
}
6767

68+
/** Return a Prometheus-style metric key with safely escaped label values. */
69+
export function labeledStarknetMetric(name: string, labels: Record<string, string>): string {
70+
const encoded = Object.entries(labels)
71+
.map(([key, value]) => `${key}="${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`)
72+
.join(",");
73+
return `${name}{${encoded}}`;
74+
}
75+
6876
/**
6977
* Point-in-time snapshot of every Starknet metric counter and gauge.
7078
* Returns shallow copies so callers cannot mutate the internal state.

0 commit comments

Comments
 (0)