Skip to content

Commit 1db1637

Browse files
authored
feat(ensrainbow): enhance entrypoint command with eager public config and mismatch handling (#2037)
1 parent f5f4430 commit 1db1637

11 files changed

Lines changed: 224 additions & 143 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"ensrainbow": minor
3+
---
4+
5+
ENSRainbow's `GET /v1/config` is now available immediately at startup, removing the cold-start gap that previously forced downstream services (e.g. ENSIndexer) to wait for the entire database download/validation before they could read public config (issue #2020).
6+
7+
- The entrypoint command now builds the `EnsRainbowPublicConfig` in-memory from its CLI/env arguments (`LABEL_SET_ID`, `LABEL_SET_VERSION`) before the HTTP server starts accepting requests, so `/v1/config` returns `200` from the first request.
8+
- After the background bootstrap finishes, ENSRainbow verifies that the on-disk database's stored label set (`labelSetId` and `highestLabelSetVersion`) matches the configured one. On mismatch it logs a helpful error naming both the expected and actual label sets, refuses to serve, and terminates with exit code `1`.
9+
- `/ready` continues to gate on full database readiness (`200` only after the database has been attached and the env-vs-DB validation has passed).
10+
- `/v1/heal/{labelhash}` and `/v1/labels/count` continue to return `503 Service Unavailable` while the database is still bootstrapping.
11+
- `/health` is unchanged and still returns `200` as soon as the HTTP server is listening.

apps/ensindexer/src/lib/public-config-builder/public-config-builder.test.ts

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -230,49 +230,9 @@ describe("PublicConfigBuilder", () => {
230230
expect(result).toBe(customConfig);
231231
expect(result.isSubgraphCompatible).toBe(false);
232232
});
233-
234-
it("awaits readiness before fetching ENSRainbow config", async () => {
235-
const callOrder: string[] = [];
236-
const ensRainbowClientMock = {
237-
config: vi.fn().mockImplementation(async () => {
238-
callOrder.push("config");
239-
return mockEnsRainbowConfig;
240-
}),
241-
} as unknown as EnsRainbow.ApiClient;
242-
const waitForReady = vi.fn().mockImplementation(async () => {
243-
callOrder.push("wait");
244-
});
245-
246-
setupStandardMocks();
247-
const mockPublicConfig = createMockPublicConfig();
248-
vi.mocked(validateEnsIndexerPublicConfig).mockReturnValue(mockPublicConfig);
249-
250-
const builder = new PublicConfigBuilder(ensRainbowClientMock, waitForReady);
251-
const result = await builder.getPublicConfig();
252-
253-
expect(waitForReady).toHaveBeenCalledTimes(1);
254-
expect(ensRainbowClientMock.config).toHaveBeenCalledTimes(1);
255-
expect(callOrder).toEqual(["wait", "config"]);
256-
expect(result).toBe(mockPublicConfig);
257-
});
258233
});
259234

260235
describe("getPublicConfig() - error handling", () => {
261-
it("throws when readiness check fails and does not call config()", async () => {
262-
const readinessError = new Error("ENSRainbow not ready");
263-
const ensRainbowClientMock = {
264-
config: vi.fn(),
265-
} as unknown as EnsRainbow.ApiClient;
266-
const waitForReady = vi.fn().mockRejectedValue(readinessError);
267-
268-
const builder = new PublicConfigBuilder(ensRainbowClientMock, waitForReady);
269-
270-
await expect(builder.getPublicConfig()).rejects.toThrow(readinessError);
271-
expect(waitForReady).toHaveBeenCalledTimes(1);
272-
expect(ensRainbowClientMock.config).not.toHaveBeenCalled();
273-
expect(validateEnsIndexerPublicConfig).not.toHaveBeenCalled();
274-
});
275-
276236
it("throws when ENSRainbow client config() fails", async () => {
277237
// Arrange
278238
const ensRainbowError = new Error("ENSRainbow service unavailable");

apps/ensindexer/src/lib/public-config-builder/public-config-builder.ts

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,6 @@ export class PublicConfigBuilder {
1919
*/
2020
private ensRainbowClient: EnsRainbow.ApiClient;
2121

22-
/**
23-
* One-time async readiness hook awaited before the first
24-
* `ensRainbowClient.config()` invocation, so callers don't race ENSRainbow's
25-
* background bootstrap. Defaults to a no-op for callers that don't need to
26-
* gate on readiness (e.g. tests or environments where ENSRainbow is already
27-
* known to be ready).
28-
*/
29-
private waitForEnsRainbowReady: () => Promise<void>;
30-
3122
/**
3223
* Immutable ENSIndexer Public Config
3324
*
@@ -38,15 +29,9 @@ export class PublicConfigBuilder {
3829

3930
/**
4031
* @param ensRainbowClient ENSRainbow Client instance used to fetch ENSRainbow Public Config
41-
* @param waitForEnsRainbowReady One-time async readiness hook awaited before the first
42-
* `ensRainbowClient.config()` invocation. Defaults to a no-op.
4332
*/
44-
constructor(
45-
ensRainbowClient: EnsRainbow.ApiClient,
46-
waitForEnsRainbowReady: () => Promise<void> = async () => {},
47-
) {
33+
constructor(ensRainbowClient: EnsRainbow.ApiClient) {
4834
this.ensRainbowClient = ensRainbowClient;
49-
this.waitForEnsRainbowReady = waitForEnsRainbowReady;
5035
}
5136

5237
/**
@@ -62,8 +47,6 @@ export class PublicConfigBuilder {
6247
*/
6348
async getPublicConfig(): Promise<EnsIndexerPublicConfig> {
6449
if (typeof this.immutablePublicConfig === "undefined") {
65-
await this.waitForEnsRainbowReady();
66-
6750
const [versionInfo, ensRainbowPublicConfig] = await Promise.all([
6851
this.getEnsIndexerVersionInfo(),
6952
// TODO: remove dependency on ENSRainbow by dropping `ensRainbowPublicConfig` from `EnsIndexerPublicConfig`.
Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
import { ensRainbowClient, waitForEnsRainbowToBeReady } from "@/lib/ensrainbow/singleton";
1+
import { ensRainbowClient } from "@/lib/ensrainbow/singleton";
22
import { PublicConfigBuilder } from "@/lib/public-config-builder/public-config-builder";
33

4-
export const publicConfigBuilder = new PublicConfigBuilder(
5-
ensRainbowClient,
6-
waitForEnsRainbowToBeReady,
7-
);
4+
export const publicConfigBuilder = new PublicConfigBuilder(ensRainbowClient);

apps/ensrainbow/src/commands/entrypoint-command.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,14 @@ describe("entrypointCommand (existing DB on disk)", () => {
109109
expect(healthRes.status).toBe(200);
110110
const healthData = (await healthRes.json()) as EnsRainbow.HealthResponse;
111111
expect(healthData).toEqual({ status: "ok" });
112+
113+
// /v1/config from CLI/env before bootstrap completes.
114+
const earlyConfigRes = await fetch(`${endpoint}/v1/config`);
115+
expect(earlyConfigRes.status).toBe(200);
116+
const earlyConfigData = (await earlyConfigRes.json()) as EnsRainbow.ENSRainbowPublicConfig;
117+
expect(earlyConfigData.serverLabelSet.labelSetId).toBe(labelSetId);
118+
expect(earlyConfigData.serverLabelSet.highestLabelSetVersion).toBe(labelSetVersion);
119+
112120
await handle.bootstrapComplete;
113121

114122
const readyRes = await fetch(`${endpoint}/ready`);
@@ -130,6 +138,74 @@ describe("entrypointCommand (existing DB on disk)", () => {
130138
});
131139
});
132140

141+
describe("entrypointCommand (env-vs-DB label-set mismatch)", () => {
142+
// DB path uses configured id/version; contents claim a different label set -> mismatch after attach.
143+
const configuredLabelSetId = buildLabelSetId("entrypoint-mismatch-test");
144+
const configuredLabelSetVersion = buildLabelSetVersion(0);
145+
const dbLabelSetId = buildLabelSetId("different-labelset");
146+
const dbLabelSetVersion = buildLabelSetVersion(1);
147+
const port = 3228;
148+
const endpoint = `http://localhost:${port}`;
149+
150+
let testDataDir: string;
151+
let handle: EntrypointCommandHandle | undefined;
152+
153+
beforeEach(async () => {
154+
testDataDir = await mkdtemp(join(tmpdir(), "ensrainbow-test-entrypoint-mismatch-"));
155+
const dbSubdir = join(testDataDir, `data-${configuredLabelSetId}_${configuredLabelSetVersion}`);
156+
const markerFile = join(testDataDir, DB_READY_MARKER_FILENAME);
157+
158+
const db = await ENSRainbowDB.create(dbSubdir);
159+
await db.setPrecalculatedRainbowRecordCount(0);
160+
await db.markIngestionFinished();
161+
await db.setLabelSetId(dbLabelSetId);
162+
await db.setHighestLabelSetVersion(dbLabelSetVersion);
163+
await db.close();
164+
165+
await writeFile(markerFile, "");
166+
});
167+
168+
afterEach(async () => {
169+
if (handle) {
170+
await handle.close().catch(() => {});
171+
handle = undefined;
172+
}
173+
await rm(testDataDir, { recursive: true, force: true });
174+
});
175+
176+
it("invokes the exit hook with code 1 and does not flip /ready to 200", async () => {
177+
const exit = vi.fn((code: number): never => {
178+
throw new Error(`test exit hook (${code})`);
179+
});
180+
181+
handle = await entrypointCommand({
182+
port,
183+
dataDir: testDataDir as AbsolutePath,
184+
dbSchemaVersion: DB_SCHEMA_VERSION as DbSchemaVersion,
185+
labelSetId: configuredLabelSetId,
186+
labelSetVersion: configuredLabelSetVersion,
187+
registerSignalHandlers: false,
188+
exit,
189+
});
190+
191+
// Still CLI/env public config; bootstrap fails before publishing db-backed state.
192+
const configRes = await fetch(`${endpoint}/v1/config`);
193+
expect(configRes.status).toBe(200);
194+
const configData = (await configRes.json()) as EnsRainbow.ENSRainbowPublicConfig;
195+
expect(configData.serverLabelSet.labelSetId).toBe(configuredLabelSetId);
196+
expect(configData.serverLabelSet.highestLabelSetVersion).toBe(configuredLabelSetVersion);
197+
198+
await handle.bootstrapComplete;
199+
200+
expect(exit).toHaveBeenCalledTimes(1);
201+
expect(exit).toHaveBeenCalledWith(1);
202+
203+
// /ready must NOT flip to 200 on mismatch - the cachedDbConfig is never set.
204+
const readyRes = await fetch(`${endpoint}/ready`);
205+
expect(readyRes.status).toBe(503);
206+
});
207+
});
208+
133209
describe("entrypointCommand (signal handlers)", () => {
134210
const labelSetId = buildLabelSetId("entrypoint-signal-test");
135211
const labelSetVersion = buildLabelSetVersion(0);

apps/ensrainbow/src/commands/entrypoint-command.ts

Lines changed: 75 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import { fileURLToPath } from "node:url";
66

77
import { serve } from "@hono/node-server";
88

9+
import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk";
910
import { stringifyConfig } from "@ensnode/ensnode-sdk/internal";
10-
import type { EnsRainbow } from "@ensnode/ensrainbow-sdk";
1111

12-
import { buildEnsRainbowPublicConfig } from "@/config/public";
12+
import { buildEnsRainbowPublicConfigFromLabelSet } from "@/config/public";
1313
import type { AbsolutePath, DbConfig, DbSchemaVersion } from "@/config/types";
1414
import { createApi } from "@/lib/api";
1515
import { ENSRainbowDB } from "@/lib/database";
@@ -50,6 +50,16 @@ export interface EntrypointCommandOptions {
5050
* Tests should pass `false` to avoid leaking handlers across cases.
5151
*/
5252
registerSignalHandlers?: boolean;
53+
/**
54+
* Hook used to terminate the process on fatal bootstrap errors (download failure or
55+
* env-vs-DB label-set mismatch). Defaults to `process.exit`. Implementations must not
56+
* return normally (same contract as `process.exit`). If a custom hook returns anyway,
57+
* {@link entrypointCommand} calls `process.exit(code)` as a fallback so the process
58+
* cannot keep serving after a fatal bootstrap error. Tests should throw from the hook
59+
* (caught internally) instead of returning, so the test runner is not killed by that
60+
* fallback.
61+
*/
62+
exit?: (code: number) => never;
5363
}
5464

5565
/**
@@ -58,7 +68,8 @@ export interface EntrypointCommandOptions {
5868
export interface EntrypointCommandHandle {
5969
/**
6070
* Resolves when bootstrap finishes or is aborted by shutdown.
61-
* Never rejects: non-abort failures terminate the process via `process.exit(1)`.
71+
* Never rejects: non-abort failures terminate the process via `options.exit(1)`
72+
* (defaults to `process.exit(1)`).
6273
*/
6374
readonly bootstrapComplete: Promise<void>;
6475
close(): Promise<void>;
@@ -87,13 +98,15 @@ export async function entrypointCommand(
8798

8899
const ensRainbowServer = ENSRainbowServer.createPending();
89100

90-
let cachedPublicConfig: EnsRainbow.ENSRainbowPublicConfig | null = null;
101+
// Public config from CLI/env so `/v1/config` works before attach; validated against DB after bootstrap.
102+
const argsServerLabelSet: EnsRainbowServerLabelSet = {
103+
labelSetId: options.labelSetId,
104+
highestLabelSetVersion: options.labelSetVersion,
105+
};
106+
const inMemoryPublicConfig = buildEnsRainbowPublicConfigFromLabelSet(argsServerLabelSet);
107+
91108
let cachedDbConfig: DbConfig | null = null;
92-
const app = createApi(
93-
ensRainbowServer,
94-
() => cachedPublicConfig,
95-
() => cachedDbConfig,
96-
);
109+
const app = createApi(ensRainbowServer, inMemoryPublicConfig, () => cachedDbConfig);
97110

98111
const httpServer = serve({
99112
fetch: app.fetch,
@@ -172,13 +185,54 @@ export async function entrypointCommand(
172185
process.once("SIGINT", signalHandler);
173186
}
174187

188+
const exit = options.exit ?? ((code: number) => process.exit(code));
189+
let exitRequested = false;
190+
const requestExit = (code: number) => {
191+
exitRequested = true;
192+
let exitHookThrew = false;
193+
try {
194+
exit(code);
195+
} catch (_error) {
196+
exitHookThrew = true;
197+
// Tests may throw from a custom exit hook to short-circuit control flow.
198+
// Swallow to avoid this surfacing as a bootstrap failure.
199+
}
200+
if (!exitHookThrew) {
201+
// TypeScript cannot enforce `never` at runtime; a buggy hook could return and leave
202+
// HTTP up after resolvePromise() + fatal bootstrap — force termination.
203+
logger.error(
204+
new Error("ENSRainbow exit hook returned without terminating the process"),
205+
"Exit hook violated non-returning contract; calling process.exit as fallback",
206+
);
207+
process.exit(code);
208+
}
209+
};
210+
175211
const bootstrapComplete = new Promise<void>((resolvePromise) => {
176212
// Defer bootstrap so the HTTP server starts accepting requests first.
177213
setTimeout(() => {
178214
runDbBootstrap(options, ensRainbowServer, bootstrapAborter.signal)
179-
.then(({ publicConfig, dbConfig }) => {
215+
.then((dbConfig) => {
216+
if (
217+
dbConfig.serverLabelSet.labelSetId !== argsServerLabelSet.labelSetId ||
218+
dbConfig.serverLabelSet.highestLabelSetVersion !==
219+
argsServerLabelSet.highestLabelSetVersion
220+
) {
221+
logger.error(
222+
`ENSRainbow database label set ` +
223+
`${dbConfig.serverLabelSet.labelSetId}@${dbConfig.serverLabelSet.highestLabelSetVersion} ` +
224+
`does not match the configured ` +
225+
`LABEL_SET_ID=${argsServerLabelSet.labelSetId} / ` +
226+
`LABEL_SET_VERSION=${argsServerLabelSet.highestLabelSetVersion}. ` +
227+
`Refusing to serve a misconfigured database; please reconcile the env/CLI ` +
228+
`arguments with the database in the data directory and restart.`,
229+
);
230+
resolvePromise();
231+
requestExit(1);
232+
return;
233+
}
234+
180235
cachedDbConfig = dbConfig;
181-
cachedPublicConfig = publicConfig;
182236
logger.info(
183237
"ENSRainbow database bootstrap complete. Service is ready to serve heal requests.",
184238
);
@@ -190,8 +244,13 @@ export async function entrypointCommand(
190244
resolvePromise();
191245
return;
192246
}
247+
if (exitRequested) {
248+
resolvePromise();
249+
return;
250+
}
193251
logger.error(error, "ENSRainbow database bootstrap failed - exiting");
194-
process.exit(1);
252+
resolvePromise();
253+
requestExit(1);
195254
})
196255
.finally(() => {
197256
signalBootstrapSettled();
@@ -206,13 +265,13 @@ export async function entrypointCommand(
206265
* Idempotent DB bootstrap pipeline.
207266
*
208267
* If marker + DB are present, reuse them; otherwise download + extract.
209-
* Returns the public config and DB config for the attached DB.
268+
* Returns the {@link DbConfig} read from the attached DB.
210269
*/
211270
async function runDbBootstrap(
212271
options: EntrypointCommandOptions,
213272
ensRainbowServer: ENSRainbowServer,
214273
signal: AbortSignal,
215-
): Promise<{ publicConfig: EnsRainbow.ENSRainbowPublicConfig; dbConfig: DbConfig }> {
274+
): Promise<DbConfig> {
216275
const { dataDir, dbSchemaVersion, labelSetId, labelSetVersion } = options;
217276
const downloadTempDir = options.downloadTempDir ?? join(dataDir, ".download-temp");
218277
const markerFile = join(dataDir, DB_READY_MARKER_FILENAME);
@@ -233,8 +292,7 @@ async function runDbBootstrap(
233292
throwIfAborted(signal);
234293
await ensRainbowServer.attachDb(existingDb);
235294
existingDbAttached = true;
236-
const dbConfig = await buildDbConfig(ensRainbowServer);
237-
return { publicConfig: buildEnsRainbowPublicConfig(dbConfig), dbConfig };
295+
return await buildDbConfig(ensRainbowServer);
238296
} catch (error) {
239297
// Always release any opened DB handle/lock first, even when aborting. This prevents
240298
// a leaked LevelDB lock when SIGTERM races a non-abort failure (e.g. attachDb throws
@@ -296,8 +354,7 @@ async function runDbBootstrap(
296354
// Write marker only after a successful attach.
297355
await writeFile(markerFile, "");
298356

299-
const dbConfig = await buildDbConfig(ensRainbowServer);
300-
return { publicConfig: buildEnsRainbowPublicConfig(dbConfig), dbConfig };
357+
return await buildDbConfig(ensRainbowServer);
301358
} catch (error) {
302359
if (!dbAttached) {
303360
await safeClose(db);

0 commit comments

Comments
 (0)