Skip to content
Merged
78 changes: 78 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,15 @@ 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 must serve the eagerly-built public config from CLI/env args before the DB
// attach completes (issue #2020). Note we have NOT awaited bootstrapComplete yet.
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 +139,75 @@ describe("entrypointCommand (existing DB on disk)", () => {
});
});

describe("entrypointCommand (env-vs-DB label-set mismatch)", () => {
// The directory name is keyed off the *configured* label set. By seeding a database whose
// internal `labelSetId` / `highestLabelSetVersion` differ from those values, we trigger the
// post-bootstrap mismatch check.
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,
});

// /v1/config still serves the *configured* (in-memory) public config during the
// mismatch window: the DB-derived one is never published.
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
72 changes: 55 additions & 17 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) => never;
Comment thread
djstrong marked this conversation as resolved.
}
Comment thread
djstrong marked this conversation as resolved.

/**
Expand Down Expand Up @@ -87,13 +93,21 @@ export async function entrypointCommand(

const ensRainbowServer = ENSRainbowServer.createPending();

let cachedPublicConfig: EnsRainbow.ENSRainbowPublicConfig | null = null;
// Eagerly build an in-memory `EnsRainbowPublicConfig` from CLI/env args so that `/v1/config`
// can serve a non-503 response from the moment the HTTP server starts accepting requests.
// This removes the cold-start gap for downstream services (e.g. ENSIndexer) that need to
// read ENSRainbow's public config to decide how to behave.
// The value is `EnsRainbowServerLabelSet.highestLabelSetVersion` because the entrypoint is
// committing to download and serve exactly this version; the post-bootstrap validation below
// confirms the on-disk database matches.
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 +186,38 @@ export async function entrypointCommand(
process.once("SIGINT", signalHandler);
}

const exit = options.exit ?? ((code: number) => process.exit(code));

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) => {
// Validate that the on-disk database actually matches the label set the entrypoint
// was configured to serve. The eagerly-built `inMemoryPublicConfig` (as exposed via
// `/v1/config` from startup) must agree with the canonical state stored in the DB;
// otherwise downstream consumers would have been told a misleading config during the
// cold-start window.
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();
exit(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 @@ -191,7 +230,8 @@ export async function entrypointCommand(
return;
}
logger.error(error, "ENSRainbow database bootstrap failed - exiting");
process.exit(1);
resolvePromise();
exit(1);
})
.finally(() => {
signalBootstrapSettled();
Expand All @@ -206,13 +246,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 +273,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 +335,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
55 changes: 31 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,29 @@ 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,
);
// The entrypoint command builds this in-memory public config eagerly from CLI/env args
// before the DB is attached; mirror that here using a label set that does not yet exist
// in any database.
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 +249,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 +273,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 +295,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
20 changes: 17 additions & 3 deletions apps/ensrainbow/src/config/public.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,31 @@
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 {
/**
* Build an `EnsRainbowPublicConfig` from a known `EnsRainbowServerLabelSet`.
*
* Used by both:
* - the eager startup path (entrypoint command), where the label set comes from CLI/env args
* and the database has not yet been opened, and
* - the post-bootstrap path, where the label set comes from the opened database.
*/
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