Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/ready-endpoint-bg-bootstrap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"ensrainbow": minor
"@ensnode/ensrainbow-sdk": minor
"ensindexer": patch
---

ENSRainbow now starts its HTTP server immediately and downloads/validates its database in the background, instead of blocking container startup behind a netcat placeholder.

- **New `GET /ready` endpoint**: returns `200 { status: "ok" }` once the database is attached, or `503 Service Unavailable` while ENSRainbow is still bootstrapping. `/health` is now a pure liveness probe that succeeds as soon as the HTTP server is listening.
- **503 responses for API routes during bootstrap**: `/v1/heal`, `/v1/labels/count`, and `/v1/config` return a structured `ServiceUnavailableError` (`errorCode: 503`) until the database is ready.
- **New Docker entrypoint**: the container now runs `pnpm --filter ensrainbow run entrypoint` (implemented in Node via `tsx src/cli.ts entrypoint`), which replaces `scripts/entrypoint.sh` and the `netcat` workaround.
- **Graceful shutdown during bootstrap**: SIGTERM/SIGINT now abort an in-flight bootstrap. Spawned `download`/`tar` child processes are terminated (SIGTERM → SIGKILL after a 5s grace period) and any partially-opened LevelDB handle is closed before the HTTP server and DB-backed server shut down, so the container exits promptly without leaking child processes or LevelDB locks.
- **SDK client**: added `EnsRainbowApiClient.ready()`, plus `EnsRainbow.ReadyResponse` / `EnsRainbow.ServiceUnavailableError` types and `ErrorCode.ServiceUnavailable`.
- **ENSIndexer**: `waitForEnsRainbowToBeReady` now polls `/ready` (via `ensRainbowClient.ready()`) instead of `/health`, so it correctly waits for the database to finish bootstrapping.

**Migration**: if you previously polled `GET /health` to gate traffic on database readiness, switch to `GET /ready` (or `client.ready()`). `/health` is still available and still returns `200`, but it now indicates liveness only.
10 changes: 5 additions & 5 deletions apps/ensindexer/src/lib/ensrainbow/singleton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,18 @@ export function waitForEnsRainbowToBeReady(): Promise<void> {
ensRainbowInstance: ensRainbowUrl.href,
});

waitForEnsRainbowToBeReadyPromise = pRetry(async () => ensRainbowClient.health(), {
waitForEnsRainbowToBeReadyPromise = pRetry(async () => ensRainbowClient.ready(), {
retries: 60, // This allows for a total of over 1 hour of retries with 1 minute between attempts.
minTimeout: secondsToMilliseconds(60),
maxTimeout: secondsToMilliseconds(60),
onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => {
logger.warn({
msg: `ENSRainbow health check failed`,
msg: `ENSRainbow readiness check failed`,
attempt: attemptNumber,
retriesLeft,
error: retriesLeft === 0 ? error : undefined,
ensRainbowInstance: ensRainbowUrl.href,
advice: `This might be due to ENSRainbow having a cold start, which can take 30+ minutes.`,
advice: `This might be due to ENSRainbow still bootstrapping its database, which can take 30+ minutes during a cold start.`,
});
},
})
Expand All @@ -81,12 +81,12 @@ export function waitForEnsRainbowToBeReady(): Promise<void> {
const errorMessage = error instanceof Error ? error.message : "Unknown error";

logger.error({
msg: `ENSRainbow health check failed after multiple attempts`,
msg: `ENSRainbow readiness check failed after multiple attempts`,
error,
ensRainbowInstance: ensRainbowUrl.href,
});

// Throw the error to terminate the ENSIndexer process due to the failed health check of a critical dependency
// Throw the error to terminate the ENSIndexer process due to the failed readiness check of a critical dependency
throw new Error(errorMessage, {
cause: error instanceof Error ? error : undefined,
});
Expand Down
26 changes: 12 additions & 14 deletions apps/ensrainbow/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
# Runtime image for ENSRainbow
FROM node:24-slim AS runtime

# Install only essential system dependencies for runtime
# netcat-openbsd: Used during container initialization to keep the service port open
# while the database is being downloaded and validated (which can take up to 20 minutes).
# Without a listener on the port during this phase, Render's health checks fail and orchestration
# systems may mark the container as unhealthy or restart it prematurely. See scripts/entrypoint.sh for implementation details.
# Note: The netcat listener only keeps the port open and accepts connections; it does not respond
# to HTTP requests, so it will not work with Docker HEALTHCHECK commands that expect HTTP responses. See https://github.com/namehash/ensnode/issues/1610
RUN apt-get update && apt-get install -y wget tar netcat-openbsd && rm -rf /var/lib/apt/lists/*
# Install only essential system dependencies for runtime.
# `wget` and `tar` are required by scripts/download-prebuilt-database.sh, which the in-process
# entrypoint spawns to fetch the pre-built database archive.
RUN apt-get update && apt-get install -y wget tar && rm -rf /var/lib/apt/lists/*

# Set up pnpm
ENV PNPM_HOME="/pnpm"
Expand All @@ -34,16 +30,18 @@ COPY apps/ensrainbow/tsconfig.json apps/ensrainbow/
COPY apps/ensrainbow/vitest.config.ts apps/ensrainbow/

# Make scripts executable
RUN chmod +x /app/apps/ensrainbow/scripts/entrypoint.sh
RUN chmod +x /app/apps/ensrainbow/scripts/download-prebuilt-database.sh

# Set environment variables
ENV NODE_ENV=production
# PORT will be used by entrypoint.sh, defaulting to 3223 if not set at runtime
# DB_SCHEMA_VERSION, LABEL_SET_ID, LABEL_SET_VERSION must be provided at runtime to the entrypoint
# PORT is consumed by the entrypoint command, defaulting to 3223 if not set at runtime.
# DB_SCHEMA_VERSION, LABEL_SET_ID, LABEL_SET_VERSION must be provided at runtime to the entrypoint.

# Default port, can be overridden by PORT env var for the entrypoint/serve command
# Default port, can be overridden by PORT env var for the entrypoint command
EXPOSE 3223

# Set the entrypoint
ENTRYPOINT ["/app/apps/ensrainbow/scripts/entrypoint.sh"]
# The entrypoint binds the HTTP server immediately (so /health and /ready respond while the
# database is still being downloaded) and runs download + validation in the background.
# See src/commands/entrypoint-command.ts for implementation details.
WORKDIR /app/apps/ensrainbow
ENTRYPOINT ["pnpm", "run", "entrypoint"]
1 change: 1 addition & 0 deletions apps/ensrainbow/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"homepage": "https://github.com/namehash/ensnode/tree/main/apps/ensrainbow",
"scripts": {
"serve": "tsx src/cli.ts serve",
"entrypoint": "tsx src/cli.ts entrypoint",
"ingest": "tsx src/cli.ts ingest",
"ingest-ensrainbow": "tsx src/cli.ts ingest-ensrainbow",
"validate": "tsx src/cli.ts validate",
Expand Down
158 changes: 0 additions & 158 deletions apps/ensrainbow/scripts/entrypoint.sh

This file was deleted.

84 changes: 83 additions & 1 deletion apps/ensrainbow/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ import type { ArgumentsCamelCase, Argv } from "yargs";
import { hideBin } from "yargs/helpers";
import yargs from "yargs/yargs";

import { buildLabelSetId } from "@ensnode/ensnode-sdk";
import { buildLabelSetId, buildLabelSetVersion } from "@ensnode/ensnode-sdk";
import { PortNumberSchema } from "@ensnode/ensnode-sdk/internal";

import { type ConvertSqlCommandCliArgs, convertCommand } from "@/commands/convert-command-sql";
import { type ConvertCsvCommandCliArgs, convertCsvCommand } from "@/commands/convert-csv-command";
import { entrypointCommand } from "@/commands/entrypoint-command";
import {
type IngestProtobufCommandCliArgs,
ingestProtobufCommand,
Expand All @@ -28,6 +29,22 @@ export interface CLIOptions {
exitProcess?: boolean;
}

/**
* yargs-parsed argument shape for the `entrypoint` command.
*
* `label-set-id` and `label-set-version` are coerced to their branded types via
* `buildLabelSetId` / `buildLabelSetVersion`, so the CLI layer works with primitive types
* and hands branded values to {@link entrypointCommand}.
*/
interface EntrypointCommandCliArgs {
port: number;
"data-dir": string;
"db-schema-version": number;
"label-set-id": string;
"label-set-version": number;
"download-temp-dir"?: string;
}
Comment thread
djstrong marked this conversation as resolved.

export function createCLI(options: CLIOptions = {}) {
const { exitProcess = true } = options;

Expand Down Expand Up @@ -111,6 +128,71 @@ export function createCLI(options: CLIOptions = {}) {
await serverCommand(serveCommandConfig);
},
)
.command(
"entrypoint",
"Start the ENS Rainbow API server immediately and bootstrap the database in the background",
(yargs: Argv) => {
return yargs
.option("port", {
type: "number",
description: "Port to listen on (overrides PORT env var if both are set)",
default: envConfig.port,
coerce: (port: number) => {
const result = PortNumberSchema.safeParse(port);
if (!result.success) {
const firstError = result.error.issues[0];
throw new Error(`Invalid port: ${firstError?.message ?? "invalid port number"}`);
}
return result.data;
},
})
.option("data-dir", {
type: "string",
description: "Directory containing LevelDB data",
default: envConfig.dataDir,
})
.option("db-schema-version", {
type: "number",
description:
"Expected database schema version (falls back to DB_SCHEMA_VERSION env var)",
default: envConfig.dbSchemaVersion,
})
.option("label-set-id", {
type: "string",
description: "Label set id to download (falls back to LABEL_SET_ID env var)",
default: process.env.LABEL_SET_ID,
demandOption: !process.env.LABEL_SET_ID,
})
.coerce("label-set-id", buildLabelSetId)
.option("label-set-version", {
type: "number",
description:
"Label set version to download (falls back to LABEL_SET_VERSION env var)",
default: process.env.LABEL_SET_VERSION,
demandOption: !process.env.LABEL_SET_VERSION,
})
.coerce("label-set-version", buildLabelSetVersion)
Comment thread
djstrong marked this conversation as resolved.
.option("download-temp-dir", {
type: "string",
description:
"Temporary directory used to stage downloaded archives before extraction " +
"(defaults to <data-dir>/.download-temp)",
default: process.env.DOWNLOAD_TEMP_DIR,
});
},
async (argv: ArgumentsCamelCase<EntrypointCommandCliArgs>) => {
const dataDir = parseDataDirFromCli(argv["data-dir"]);
await entrypointCommand({
port: argv.port,
dataDir,
dbSchemaVersion: argv["db-schema-version"],
labelSetId: argv["label-set-id"],
labelSetVersion: argv["label-set-version"],
downloadTempDir: argv["download-temp-dir"],
labelsetServerUrl: process.env.ENSRAINBOW_LABELSET_SERVER_URL,
});
},
)
.command(
"validate",
"Validate the integrity of the LevelDB database",
Expand Down
Loading
Loading