Skip to content

Commit c5dfde1

Browse files
committed
grafana
1 parent 2e74b92 commit c5dfde1

10 files changed

Lines changed: 519 additions & 13 deletions

File tree

Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ RUN NODE_OPTIONS=--max-old-space-size=4096 pnpm turbo run build --filter=@lattic
6565

6666
# Stage 3: slim store-indexer image (no Foundry, no build tools)
6767
FROM base AS store-indexer
68+
ARG GIT_SHA=unknown
69+
ENV GIT_SHA=${GIT_SHA}
6870
WORKDIR /app
6971
COPY --from=builder /app .
7072
WORKDIR /app/packages/store-indexer

docker-compose.indexer.yml

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ services:
1818
build:
1919
context: .
2020
target: store-indexer
21-
command: pnpm tsx src/bin/postgres-decoded-indexer.ts
21+
args:
22+
GIT_SHA: "${GIT_SHA:-unknown}"
23+
# exec node directly (not `pnpm tsx`) so SIGTERM reaches the Node process
24+
# that owns the graceful-shutdown handler; init: true reaps + forwards.
25+
command: node --import tsx src/bin/postgres-decoded-indexer.ts
26+
init: true
2227
extra_hosts:
2328
- "host.docker.internal:host-gateway"
2429
depends_on:
@@ -36,13 +41,22 @@ services:
3641
SUPABASE_URL: "${SUPABASE_URL:-}"
3742
SUPABASE_SERVICE_ROLE_KEY: "${SUPABASE_SERVICE_ROLE_KEY:-}"
3843
PUBLISH_RESULTS_TO_SUPABASE: "${PUBLISH_RESULTS_TO_SUPABASE:-}"
44+
# Grafana Cloud Loki log shipping (optional). Empty URL = stdout-only;
45+
# logs are always teed onto stdout regardless. Get URL + numeric user id +
46+
# token from the Grafana Cloud "Send Logs" page.
47+
LOG_SERVICE: "indexer-store"
48+
GRAFANA_LOKI_URL: "${GRAFANA_LOKI_URL:-}"
49+
GRAFANA_LOKI_USER: "${GRAFANA_LOKI_USER:-}"
50+
GRAFANA_LOKI_API_KEY: "${GRAFANA_LOKI_API_KEY:-}"
51+
GRAFANA_LOKI_ENV: "${GRAFANA_LOKI_ENV:-}"
3952
restart: unless-stopped
4053

4154
frontend:
4255
build:
4356
context: .
4457
target: store-indexer
45-
command: pnpm tsx src/bin/postgres-frontend.ts
58+
command: node --import tsx src/bin/postgres-frontend.ts
59+
init: true
4660
depends_on:
4761
db:
4862
condition: service_healthy
@@ -53,6 +67,13 @@ services:
5367
STORE_ADDRESS: "${STORE_ADDRESS}"
5468
HOST: "0.0.0.0"
5569
PORT: "1337"
70+
RPC_HTTP_URL: "${RPC_HTTP_URL}"
71+
ASCENSION_RECORD_SIGNER_PRIVATE_KEY: "${ASCENSION_RECORD_SIGNER_PRIVATE_KEY:-}"
72+
LOG_SERVICE: "indexer-api"
73+
GRAFANA_LOKI_URL: "${GRAFANA_LOKI_URL:-}"
74+
GRAFANA_LOKI_USER: "${GRAFANA_LOKI_USER:-}"
75+
GRAFANA_LOKI_API_KEY: "${GRAFANA_LOKI_API_KEY:-}"
76+
GRAFANA_LOKI_ENV: "${GRAFANA_LOKI_ENV:-}"
5677
ports:
5778
- "1337:1337"
5879
restart: unless-stopped
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
#!/usr/bin/env node
2+
// Signal-delivery acceptance gate (plan Phase 3.5).
3+
//
4+
// Proves the thing unit tests structurally cannot: that a REAL SIGTERM reaches
5+
// the Node process under the exec-direct command and triggers the graceful Loki
6+
// flush + clean exit. The unit suite calls close() in-process; only this gate
7+
// exercises the OS signal path.
8+
//
9+
// Two arms, both run the exec-direct command `node --import tsx src/bin/<entry>`:
10+
// - FRONTEND: registers its handler synchronously after server.listen(); a live
11+
// /api/logs-live SSE connection is opened first so the bounded httpServer.close()
12+
// path is exercised, not bypassed.
13+
// - INDEXER: registers its handler BEFORE the top-level await of the RPC connect.
14+
// RPC points at a black-hole server (accepts, never responds) so the process is
15+
// parked mid-connect when SIGTERM lands — proving the handler attaches at import
16+
// time, not after the connect resolves.
17+
//
18+
// CONTAINER form (CI gate against the built image), same shape:
19+
// docker run -d --network host -e GRAFANA_LOKI_URL=http://127.0.0.1:<port>/loki/api/v1/push \
20+
// -e LOG_SERVICE=indexer-api -e DATABASE_URL=... -e STORE_ADDRESS=0x... \
21+
// <image> node --import tsx src/bin/postgres-frontend.ts
22+
// docker kill --signal=TERM <cid> # SIGTERM to PID 1 (node, via init: true)
23+
// docker wait <cid> # must print 0
24+
25+
import { createServer } from "node:http";
26+
import { spawn } from "node:child_process";
27+
import { fileURLToPath } from "node:url";
28+
import path from "node:path";
29+
30+
const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
31+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
32+
33+
const received = [];
34+
const loki = createServer((req, res) => {
35+
let raw = "";
36+
req.on("data", (c) => (raw += c));
37+
req.on("end", () => {
38+
received.push(raw);
39+
res.statusCode = 204;
40+
res.end("ok");
41+
});
42+
});
43+
await new Promise((r) => loki.listen(0, "127.0.0.1", r));
44+
const lokiUrl = `http://127.0.0.1:${loki.address().port}/loki/api/v1/push`;
45+
46+
// Black-hole RPC: accepts the connection, never responds, so getChainId parks.
47+
const blackholeSockets = new Set();
48+
const blackhole = createServer(() => {});
49+
blackhole.on("connection", (s) => {
50+
blackholeSockets.add(s);
51+
s.on("close", () => blackholeSockets.delete(s));
52+
});
53+
await new Promise((r) => blackhole.listen(0, "127.0.0.1", r));
54+
const blackholeUrl = `http://127.0.0.1:${blackhole.address().port}`;
55+
56+
const baseEnv = {
57+
...process.env,
58+
GRAFANA_LOKI_URL: lokiUrl,
59+
GRAFANA_LOKI_USER: "u",
60+
GRAFANA_LOKI_API_KEY: "k",
61+
GRAFANA_LOKI_ENV: "acctest",
62+
LOG_LEVEL: "info",
63+
DATABASE_URL: "postgres://127.0.0.1:1/none",
64+
STORE_ADDRESS: "0x0000000000000000000000000000000000000001",
65+
START_BLOCK: "0",
66+
POLLING_INTERVAL: "600000",
67+
};
68+
69+
function shutdownServers() {
70+
for (const s of blackholeSockets) s.destroy();
71+
blackhole.close();
72+
loki.close();
73+
}
74+
75+
function abort(child, msg) {
76+
console.error(`FAIL: ${msg}`);
77+
try {
78+
child?.kill("SIGKILL");
79+
} catch {}
80+
shutdownServers();
81+
process.exit(1);
82+
}
83+
84+
async function runArm({ label, entry, env, needle, openSse }) {
85+
const before = received.length;
86+
const child = spawn("node", ["--import", "tsx", `src/bin/${entry}`], {
87+
cwd: pkgDir,
88+
env: { ...baseEnv, ...env },
89+
stdio: ["ignore", "pipe", "pipe"],
90+
});
91+
let stdout = "";
92+
child.stdout.on("data", (d) => (stdout += d));
93+
child.stderr.on("data", () => {});
94+
let earlyExit = null;
95+
child.on("exit", (code) => (earlyExit = code));
96+
97+
const bootStart = Date.now();
98+
while (!stdout.includes(needle) && earlyExit === null && Date.now() - bootStart < 20000) {
99+
await delay(150);
100+
}
101+
if (earlyExit !== null) abort(child, `[${label}] exited early (code ${earlyExit}) before SIGTERM`);
102+
if (!stdout.includes(needle)) abort(child, `[${label}] did not boot / emit startup log via exec-direct command`);
103+
console.log(`[${label}] boot OK: exec-direct command started node and emitted the startup log`);
104+
105+
let sseController;
106+
if (openSse) {
107+
sseController = new AbortController();
108+
fetch(`http://127.0.0.1:${env.PORT}/api/logs-live?input=${encodeURIComponent('{"filters":[]}')}&block_num=0`, {
109+
signal: sseController.signal,
110+
}).catch(() => {});
111+
await delay(300);
112+
} else {
113+
// Give the indexer a moment to reach its parked top-level await.
114+
await delay(400);
115+
}
116+
117+
const killAt = Date.now();
118+
child.kill("SIGTERM");
119+
const exitCode = await new Promise((resolve) => child.on("exit", (code) => resolve(code)));
120+
const elapsed = Date.now() - killAt;
121+
sseController?.abort();
122+
123+
if (exitCode !== 0) abort(child, `[${label}] expected exit 0 on SIGTERM, got ${exitCode}`);
124+
if (elapsed > 5000) abort(child, `[${label}] shutdown took ${elapsed}ms (> bounded grace)`);
125+
if (!received.slice(before).join("").includes(needle)) {
126+
abort(child, `[${label}] fake Loki never received the startup batch — SIGTERM->flush did not run`);
127+
}
128+
console.log(`[${label}] PASS: SIGTERM -> exit 0 in ${elapsed}ms; Loki received the final batch`);
129+
}
130+
131+
await runArm({
132+
label: "frontend",
133+
entry: "postgres-frontend.ts",
134+
env: { LOG_SERVICE: "indexer-api", HOST: "127.0.0.1", PORT: "37337" },
135+
needle: "starting postgres-frontend",
136+
openSse: true,
137+
});
138+
139+
await runArm({
140+
label: "indexer",
141+
entry: "postgres-decoded-indexer.ts",
142+
env: { LOG_SERVICE: "indexer-store", RPC_HTTP_URL: blackholeUrl },
143+
needle: "starting postgres-decoded-indexer",
144+
openSse: false,
145+
});
146+
147+
console.log("ALL ARMS PASS");
148+
shutdownServers();
149+
process.exit(0);

packages/store-indexer/src/bin/postgres-decoded-indexer.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { ReorgError } from "../postgres/ReorgError";
2424
import { createSupabasePushAdapter } from "../postgres/supabasePush";
2525
import { createTourneyAnnouncementProjector } from "../postgres/tourneyAnnouncementProjector";
2626
import { createNotificationEventProjector } from "../postgres/notificationEventProjector";
27-
import { logger } from "../logger";
27+
import { logger, flushLogs } from "../logger";
2828
import packageJson from "../../package.json";
2929

3030
const env = parseEnv(
@@ -51,7 +51,25 @@ const env = parseEnv(
5151
),
5252
);
5353

54-
logger.info("starting postgres-decoded-indexer", { version: packageJson.version });
54+
logger.info("starting postgres-decoded-indexer", {
55+
version: packageJson.version,
56+
commit: process.env.GIT_SHA ?? "unknown",
57+
node: process.version,
58+
});
59+
60+
// Register before the top-level awaits below so a SIGTERM during the initial
61+
// RPC/DB connect still flushes and exits 0 instead of hitting Node's default.
62+
let shuttingDown = false;
63+
async function shutdown(signal: NodeJS.Signals): Promise<void> {
64+
if (shuttingDown) return;
65+
shuttingDown = true;
66+
logger.info("shutting down", { signal });
67+
await flushLogs();
68+
process.exit(0);
69+
}
70+
71+
process.once("SIGTERM", () => void shutdown("SIGTERM"));
72+
process.once("SIGINT", () => void shutdown("SIGINT"));
5573

5674
const clientOptions = await getClientOptions(env);
5775
const publicClient = getRpcClient(clientOptions);
@@ -213,7 +231,8 @@ async function run(): Promise<void> {
213231
}
214232
}
215233

216-
run().catch((error) => {
234+
run().catch(async (error) => {
217235
logger.error("fatal error", { error });
236+
await flushLogs();
218237
process.exit(1);
219238
});

packages/store-indexer/src/bin/postgres-frontend.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ import { sentry } from "../koa-middleware/sentry";
1616
import { healthcheck } from "../koa-middleware/healthcheck";
1717
import { helloWorld } from "../koa-middleware/helloWorld";
1818
import { metrics } from "../koa-middleware/metrics";
19-
import { logsLive } from "../koa-middleware/logsLive";
19+
import { logsLive, closeLiveStreams } from "../koa-middleware/logsLive";
2020
import { createBlockLogsStream } from "../postgres/createBlockLogsStream";
2121
import { createLeaderboardCache } from "../postgres/aggregateCache";
22-
import { logger } from "../logger";
22+
import { logger, flushLogs } from "../logger";
2323
import packageJson from "../../package.json";
2424

2525
const env = parseEnv(
@@ -127,5 +127,26 @@ server.use(
127127
}),
128128
);
129129

130-
server.listen({ host: env.HOST, port: env.PORT });
130+
const httpServer = server.listen({ host: env.HOST, port: env.PORT });
131131
logger.info("starting postgres-frontend", { version: packageJson.version, host: env.HOST, port: env.PORT });
132+
133+
let shuttingDown = false;
134+
async function shutdown(signal: NodeJS.Signals): Promise<void> {
135+
if (shuttingDown) return;
136+
shuttingDown = true;
137+
logger.info("shutting down", { signal });
138+
// End live SSE responses first, then bound close() so an un-terminated
139+
// socket can never hold shutdown past the grace window.
140+
closeLiveStreams();
141+
await Promise.race([
142+
new Promise<void>((resolve) => httpServer.close(() => resolve())),
143+
new Promise<void>((resolve) => {
144+
setTimeout(resolve, 2000).unref();
145+
}),
146+
]);
147+
await flushLogs();
148+
process.exit(0);
149+
}
150+
151+
process.once("SIGTERM", () => void shutdown("SIGTERM"));
152+
process.once("SIGINT", () => void shutdown("SIGINT"));

packages/store-indexer/src/koa-middleware/logsLive.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ import { logger } from "../logger";
77

88
const log = logger.child({ component: "logs-live" });
99

10+
const activeStreams = new Set<() => void>();
11+
12+
// Graceful shutdown calls this so long-lived SSE responses end instead of
13+
// blocking httpServer.close() until the platform SIGKILLs the process.
14+
export function closeLiveStreams(): void {
15+
for (const teardown of activeStreams) {
16+
try {
17+
teardown();
18+
} catch {
19+
// one stuck stream must not block the rest of shutdown
20+
}
21+
}
22+
}
23+
1024
type LogsLiveOptions = {
1125
storedBlockLogs$: Observable<StorageAdapterBlock>;
1226
};
@@ -76,13 +90,20 @@ export function logsLive({ storedBlockLogs$ }: LogsLiveOptions): Middleware {
7690
let closeResolve: (() => void) | undefined;
7791

7892
function cleanup(): void {
93+
activeStreams.delete(teardown);
7994
subscription?.unsubscribe();
8095
if (heartbeatInterval) clearInterval(heartbeatInterval);
8196
subscription = undefined;
8297
heartbeatInterval = undefined;
8398
closeResolve?.();
8499
}
85100

101+
function teardown(): void {
102+
if (!ctx.res.writableEnded) ctx.res.end();
103+
cleanup();
104+
}
105+
activeStreams.add(teardown);
106+
86107
ctx.req.once("close", () => {
87108
log.info("client disconnected", { address: address ?? "*" });
88109
cleanup();

0 commit comments

Comments
 (0)