Skip to content
Merged
11 changes: 11 additions & 0 deletions .changeset/ensrainbow-eager-public-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"ensrainbow": minor
---

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).

- 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.
- 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`.
- `/ready` continues to gate on full database readiness (`200` only after the database has been attached and the env-vs-DB validation has passed).
- `/v1/heal/{labelhash}` and `/v1/labels/count` continue to return `503 Service Unavailable` while the database is still bootstrapping.
- `/health` is unchanged and still returns `200` as soon as the HTTP server is listening.
74 changes: 74 additions & 0 deletions apps/ensrainbow/src/commands/entrypoint-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,14 @@ describe("entrypointCommand (existing DB on disk)", () => {
expect(healthRes.status).toBe(200);
const healthData = (await healthRes.json()) as EnsRainbow.HealthResponse;
expect(healthData).toEqual({ status: "ok" });

// /v1/config from CLI/env before bootstrap completes.
const earlyConfigRes = await fetch(`${endpoint}/v1/config`);
expect(earlyConfigRes.status).toBe(200);
const earlyConfigData = (await earlyConfigRes.json()) as EnsRainbow.ENSRainbowPublicConfig;
expect(earlyConfigData.serverLabelSet.labelSetId).toBe(labelSetId);
expect(earlyConfigData.serverLabelSet.highestLabelSetVersion).toBe(labelSetVersion);

await handle.bootstrapComplete;

const readyRes = await fetch(`${endpoint}/ready`);
Expand All @@ -130,6 +138,72 @@ describe("entrypointCommand (existing DB on disk)", () => {
});
});

describe("entrypointCommand (env-vs-DB label-set mismatch)", () => {
// DB path uses configured id/version; contents claim a different label set -> mismatch after attach.
const configuredLabelSetId = buildLabelSetId("entrypoint-mismatch-test");
const configuredLabelSetVersion = buildLabelSetVersion(0);
const dbLabelSetId = buildLabelSetId("different-labelset");
const dbLabelSetVersion = buildLabelSetVersion(1);
const port = 3228;
const endpoint = `http://localhost:${port}`;

let testDataDir: string;
let handle: EntrypointCommandHandle | undefined;

beforeEach(async () => {
testDataDir = await mkdtemp(join(tmpdir(), "ensrainbow-test-entrypoint-mismatch-"));
const dbSubdir = join(testDataDir, `data-${configuredLabelSetId}_${configuredLabelSetVersion}`);
const markerFile = join(testDataDir, DB_READY_MARKER_FILENAME);

const db = await ENSRainbowDB.create(dbSubdir);
await db.setPrecalculatedRainbowRecordCount(0);
await db.markIngestionFinished();
await db.setLabelSetId(dbLabelSetId);
await db.setHighestLabelSetVersion(dbLabelSetVersion);
await db.close();

await writeFile(markerFile, "");
});

afterEach(async () => {
if (handle) {
await handle.close().catch(() => {});
handle = undefined;
}
await rm(testDataDir, { recursive: true, force: true });
});

it("invokes the exit hook with code 1 and does not flip /ready to 200", async () => {
const exit = vi.fn<(code: number) => never>(() => undefined as never);

handle = await entrypointCommand({
port,
dataDir: testDataDir as AbsolutePath,
dbSchemaVersion: DB_SCHEMA_VERSION as DbSchemaVersion,
labelSetId: configuredLabelSetId,
labelSetVersion: configuredLabelSetVersion,
registerSignalHandlers: false,
exit,
});

// Still CLI/env public config; bootstrap fails before publishing db-backed state.
const configRes = await fetch(`${endpoint}/v1/config`);
expect(configRes.status).toBe(200);
const configData = (await configRes.json()) as EnsRainbow.ENSRainbowPublicConfig;
expect(configData.serverLabelSet.labelSetId).toBe(configuredLabelSetId);
expect(configData.serverLabelSet.highestLabelSetVersion).toBe(configuredLabelSetVersion);

await handle.bootstrapComplete;

expect(exit).toHaveBeenCalledTimes(1);
expect(exit).toHaveBeenCalledWith(1);

// /ready must NOT flip to 200 on mismatch - the cachedDbConfig is never set.
const readyRes = await fetch(`${endpoint}/ready`);
expect(readyRes.status).toBe(503);
});
});

describe("entrypointCommand (signal handlers)", () => {
const labelSetId = buildLabelSetId("entrypoint-signal-test");
const labelSetVersion = buildLabelSetVersion(0);
Expand Down
78 changes: 60 additions & 18 deletions apps/ensrainbow/src/commands/entrypoint-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import { fileURLToPath } from "node:url";

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

import type { EnsRainbowServerLabelSet } from "@ensnode/ensnode-sdk";
import { stringifyConfig } from "@ensnode/ensnode-sdk/internal";
import type { EnsRainbow } from "@ensnode/ensrainbow-sdk";

import { buildEnsRainbowPublicConfig } from "@/config/public";
import { buildEnsRainbowPublicConfigFromLabelSet } from "@/config/public";
import type { AbsolutePath, DbConfig, DbSchemaVersion } from "@/config/types";
import { createApi } from "@/lib/api";
import { ENSRainbowDB } from "@/lib/database";
Expand Down Expand Up @@ -50,6 +50,12 @@ export interface EntrypointCommandOptions {
* Tests should pass `false` to avoid leaking handlers across cases.
*/
registerSignalHandlers?: boolean;
/**
* Hook used to terminate the process on fatal bootstrap errors (download failure or
* env-vs-DB label-set mismatch). Defaults to `process.exit`. Tests can override this
* to assert termination without actually killing the test runner.
*/
exit?: (code: number) => void;
Comment thread
djstrong marked this conversation as resolved.
Outdated
}
Comment thread
djstrong marked this conversation as resolved.

/**
Expand All @@ -58,7 +64,8 @@ export interface EntrypointCommandOptions {
export interface EntrypointCommandHandle {
/**
* Resolves when bootstrap finishes or is aborted by shutdown.
* Never rejects: non-abort failures terminate the process via `process.exit(1)`.
* Never rejects: non-abort failures terminate the process via `options.exit(1)`
* (defaults to `process.exit(1)`).
*/
readonly bootstrapComplete: Promise<void>;
close(): Promise<void>;
Expand Down Expand Up @@ -87,13 +94,15 @@ export async function entrypointCommand(

const ensRainbowServer = ENSRainbowServer.createPending();

let cachedPublicConfig: EnsRainbow.ENSRainbowPublicConfig | null = null;
// Public config from CLI/env so `/v1/config` works before attach; validated against DB after bootstrap.
const argsServerLabelSet: EnsRainbowServerLabelSet = {
labelSetId: options.labelSetId,
highestLabelSetVersion: options.labelSetVersion,
};
const inMemoryPublicConfig = buildEnsRainbowPublicConfigFromLabelSet(argsServerLabelSet);

let cachedDbConfig: DbConfig | null = null;
const app = createApi(
ensRainbowServer,
() => cachedPublicConfig,
() => cachedDbConfig,
);
const app = createApi(ensRainbowServer, inMemoryPublicConfig, () => cachedDbConfig);

const httpServer = serve({
fetch: app.fetch,
Expand Down Expand Up @@ -172,13 +181,43 @@ export async function entrypointCommand(
process.once("SIGINT", signalHandler);
}

const exit = options.exit ?? ((code: number) => process.exit(code));
let exitRequested = false;
const requestExit = (code: number) => {
exitRequested = true;
try {
exit(code);
} catch (_error) {
// Tests may throw from a custom exit hook to short-circuit control flow.
// Swallow to avoid this surfacing as a bootstrap failure.
}
};

const bootstrapComplete = new Promise<void>((resolvePromise) => {
// Defer bootstrap so the HTTP server starts accepting requests first.
setTimeout(() => {
runDbBootstrap(options, ensRainbowServer, bootstrapAborter.signal)
.then(({ publicConfig, dbConfig }) => {
.then((dbConfig) => {
if (
dbConfig.serverLabelSet.labelSetId !== argsServerLabelSet.labelSetId ||
dbConfig.serverLabelSet.highestLabelSetVersion !==
argsServerLabelSet.highestLabelSetVersion
) {
logger.error(
`ENSRainbow database label set ` +
`${dbConfig.serverLabelSet.labelSetId}@${dbConfig.serverLabelSet.highestLabelSetVersion} ` +
`does not match the configured ` +
`LABEL_SET_ID=${argsServerLabelSet.labelSetId} / ` +
`LABEL_SET_VERSION=${argsServerLabelSet.highestLabelSetVersion}. ` +
`Refusing to serve a misconfigured database; please reconcile the env/CLI ` +
`arguments with the database in the data directory and restart.`,
);
resolvePromise();
requestExit(1);
return;
}
Comment thread
djstrong marked this conversation as resolved.

cachedDbConfig = dbConfig;
cachedPublicConfig = publicConfig;
logger.info(
"ENSRainbow database bootstrap complete. Service is ready to serve heal requests.",
);
Expand All @@ -190,8 +229,13 @@ export async function entrypointCommand(
resolvePromise();
return;
}
if (exitRequested) {
resolvePromise();
return;
}
logger.error(error, "ENSRainbow database bootstrap failed - exiting");
process.exit(1);
resolvePromise();
requestExit(1);
})
.finally(() => {
signalBootstrapSettled();
Expand All @@ -206,13 +250,13 @@ export async function entrypointCommand(
* Idempotent DB bootstrap pipeline.
*
* If marker + DB are present, reuse them; otherwise download + extract.
* Returns the public config and DB config for the attached DB.
* Returns the {@link DbConfig} read from the attached DB.
*/
async function runDbBootstrap(
options: EntrypointCommandOptions,
ensRainbowServer: ENSRainbowServer,
signal: AbortSignal,
): Promise<{ publicConfig: EnsRainbow.ENSRainbowPublicConfig; dbConfig: DbConfig }> {
): Promise<DbConfig> {
const { dataDir, dbSchemaVersion, labelSetId, labelSetVersion } = options;
const downloadTempDir = options.downloadTempDir ?? join(dataDir, ".download-temp");
const markerFile = join(dataDir, DB_READY_MARKER_FILENAME);
Expand All @@ -233,8 +277,7 @@ async function runDbBootstrap(
throwIfAborted(signal);
await ensRainbowServer.attachDb(existingDb);
existingDbAttached = true;
const dbConfig = await buildDbConfig(ensRainbowServer);
return { publicConfig: buildEnsRainbowPublicConfig(dbConfig), dbConfig };
return await buildDbConfig(ensRainbowServer);
} catch (error) {
// Always release any opened DB handle/lock first, even when aborting. This prevents
// a leaked LevelDB lock when SIGTERM races a non-abort failure (e.g. attachDb throws
Expand Down Expand Up @@ -296,8 +339,7 @@ async function runDbBootstrap(
// Write marker only after a successful attach.
await writeFile(markerFile, "");

const dbConfig = await buildDbConfig(ensRainbowServer);
return { publicConfig: buildEnsRainbowPublicConfig(dbConfig), dbConfig };
return await buildDbConfig(ensRainbowServer);
} catch (error) {
if (!dbAttached) {
await safeClose(db);
Expand Down
53 changes: 29 additions & 24 deletions apps/ensrainbow/src/commands/server-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,7 @@ describe("Server Command Tests", () => {
const ensRainbowServer = await ENSRainbowServer.init(db);
const dbConfig = await buildDbConfig(ensRainbowServer);
const publicConfig = buildEnsRainbowPublicConfig(dbConfig);
app = createApi(
ensRainbowServer,
() => publicConfig,
() => dbConfig,
);
app = createApi(ensRainbowServer, publicConfig, () => dbConfig);

// Start the server on a different port than what ENSRainbow defaults to
server = serve({
Expand Down Expand Up @@ -192,23 +188,27 @@ describe("Server Command Tests", () => {
});
});

describe("Pending server (no DB attached yet)", () => {
describe("Pending server (eagerly built public config, no DB attached yet)", () => {
const pendingPort = 3225;
const pendingLabelSetId = "pending-test";
const pendingLabelSetVersion = 7;
let pendingApp: Hono;
let pendingServer: ReturnType<typeof serve>;
let pendingEnsRainbowServer: ENSRainbowServer;
let pendingPublicConfig: EnsRainbow.ENSRainbowPublicConfig | null;
let pendingDbConfig: Awaited<ReturnType<typeof buildDbConfig>> | null;

beforeAll(async () => {
pendingEnsRainbowServer = ENSRainbowServer.createPending();
pendingPublicConfig = null;
pendingDbConfig = null;
pendingApp = createApi(
pendingEnsRainbowServer,
() => pendingPublicConfig,
() => pendingDbConfig,
);
// Mirror entrypoint: public config from declared label set before DB attach.
const eagerPublicConfig = buildEnsRainbowPublicConfig({
serverLabelSet: {
labelSetId: pendingLabelSetId,
highestLabelSetVersion: pendingLabelSetVersion,
},
recordsCount: 0,
});
pendingApp = createApi(pendingEnsRainbowServer, eagerPublicConfig, () => pendingDbConfig);
pendingServer = serve({
fetch: pendingApp.fetch,
port: pendingPort,
Expand Down Expand Up @@ -247,13 +247,19 @@ describe("Server Command Tests", () => {
expect(data.errorCode).toBe(ErrorCode.ServiceUnavailable);
});

it("GET /v1/labels/count and /v1/config return 503 while the DB is not attached", async () => {
const [countRes, configRes] = await Promise.all([
fetch(`http://localhost:${pendingPort}/v1/labels/count`),
fetch(`http://localhost:${pendingPort}/v1/config`),
]);
it("GET /v1/labels/count returns 503 while the DB is not attached", async () => {
const countRes = await fetch(`http://localhost:${pendingPort}/v1/labels/count`);
expect(countRes.status).toBe(503);
expect(configRes.status).toBe(503);
});

it("GET /v1/config returns 200 with the eagerly-built public config while the DB is not attached", async () => {
const configRes = await fetch(`http://localhost:${pendingPort}/v1/config`);
expect(configRes.status).toBe(200);
const configData = (await configRes.json()) as EnsRainbow.ENSRainbowPublicConfig;
expect(configData.serverLabelSet.labelSetId).toBe(pendingLabelSetId);
expect(configData.serverLabelSet.highestLabelSetVersion).toBe(pendingLabelSetVersion);
expect(typeof configData.versionInfo.ensRainbow).toBe("string");
expect(configData.versionInfo.ensRainbow.length).toBeGreaterThan(0);
});

it("After attachDb, /ready returns 200 and /v1/heal serves labels", async () => {
Expand All @@ -265,13 +271,12 @@ describe("Server Command Tests", () => {
try {
await attachDb.setPrecalculatedRainbowRecordCount(1);
await attachDb.markIngestionFinished();
await attachDb.setLabelSetId("pending-test");
await attachDb.setHighestLabelSetVersion(0);
await attachDb.setLabelSetId(pendingLabelSetId);
await attachDb.setHighestLabelSetVersion(pendingLabelSetVersion);
await attachDb.addRainbowRecord("pending-label", 0);

await pendingEnsRainbowServer.attachDb(attachDb);
pendingDbConfig = await buildDbConfig(pendingEnsRainbowServer);
pendingPublicConfig = buildEnsRainbowPublicConfig(pendingDbConfig);

const readyRes = await fetch(`http://localhost:${pendingPort}/ready`);
expect(readyRes.status).toBe(200);
Expand All @@ -288,8 +293,8 @@ describe("Server Command Tests", () => {
const configRes = await fetch(`http://localhost:${pendingPort}/v1/config`);
expect(configRes.status).toBe(200);
const configData = (await configRes.json()) as EnsRainbow.ENSRainbowPublicConfig;
expect(configData.serverLabelSet.labelSetId).toBe("pending-test");
expect(configData.serverLabelSet.highestLabelSetVersion).toBe(0);
expect(configData.serverLabelSet.labelSetId).toBe(pendingLabelSetId);
expect(configData.serverLabelSet.highestLabelSetVersion).toBe(pendingLabelSetVersion);

const countRes = await fetch(`http://localhost:${pendingPort}/v1/labels/count`);
expect(countRes.status).toBe(200);
Expand Down
6 changes: 1 addition & 5 deletions apps/ensrainbow/src/commands/server-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,7 @@ export async function serverCommand(options: ServerCommandOptions): Promise<void
console.log("ENSRainbow public config:");
console.log(stringifyConfig(publicConfig, { pretty: true }));

const app = createApi(
ensRainbowServer,
() => publicConfig,
() => dbConfig,
);
const app = createApi(ensRainbowServer, publicConfig, () => dbConfig);

const httpServer = serve({
fetch: app.fetch,
Expand Down
13 changes: 10 additions & 3 deletions apps/ensrainbow/src/config/public.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import packageJson from "@/../package.json" with { type: "json" };

import type { EnsRainbowVersionInfo } from "@ensnode/ensnode-sdk";
import type { EnsRainbowServerLabelSet, EnsRainbowVersionInfo } from "@ensnode/ensnode-sdk";
import type { EnsRainbow } from "@ensnode/ensrainbow-sdk";

import type { DbConfig } from "./types";

export function buildEnsRainbowPublicConfig(dbConfig: DbConfig): EnsRainbow.ENSRainbowPublicConfig {
/** Builds public config from a label set (CLI/env before DB open, or from DB after open). */
export function buildEnsRainbowPublicConfigFromLabelSet(
serverLabelSet: EnsRainbowServerLabelSet,
): EnsRainbow.ENSRainbowPublicConfig {
const versionInfo = {
ensRainbow: packageJson.version,
} satisfies EnsRainbowVersionInfo;

return {
serverLabelSet: dbConfig.serverLabelSet,
serverLabelSet,
versionInfo,
};
}

export function buildEnsRainbowPublicConfig(dbConfig: DbConfig): EnsRainbow.ENSRainbowPublicConfig {
return buildEnsRainbowPublicConfigFromLabelSet(dbConfig.serverLabelSet);
}
Loading
Loading