Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8dcebcb
feat(ensrainbow): implement background database bootstrapping and new…
djstrong Apr 20, 2026
3c23870
Merge branch 'main' into 1610-start-ensrainbow-server-immediately-and…
djstrong Apr 20, 2026
5ec830f
feat(ensrainbow): enhance readiness checks and API response handling
djstrong Apr 20, 2026
243667f
feat(ensrainbow): implement graceful shutdown during bootstrap
djstrong Apr 20, 2026
1f9d822
refactor(ensrainbow): implement closeHttpServer utility for graceful …
djstrong Apr 20, 2026
d3f9c0d
fix(ensrainbow): improve database handling during bootstrap failure
djstrong Apr 20, 2026
74cf754
fix(ensrainbow): improve database closure handling in the close method
djstrong Apr 20, 2026
89ac55f
fix(ensrainbow): enhance shutdown handling and error management
djstrong Apr 20, 2026
a438a6a
fix(ensrainbow): enhance signal handling and database extraction cleanup
djstrong Apr 24, 2026
ac844ce
fix(ensrainbow): improve logging and error handling during database o…
djstrong Apr 24, 2026
2b498f9
fix(ensrainbow): refine readiness checks and documentation updates
djstrong Apr 24, 2026
cac82ed
fix(ensrainbow): enhance public config readiness handling
djstrong Apr 24, 2026
83a4453
Merge branch 'main' into 1610-start-ensrainbow-server-immediately-and…
djstrong Apr 24, 2026
66be6f7
Merge branch 'main' into 1610-start-ensrainbow-server-immediately-and…
djstrong Apr 29, 2026
a9c46de
refactor: enhance entrypoint command to include DB config in bootstra…
djstrong Apr 29, 2026
762036a
feat: introduce new /ready endpoint and enhance Docker entrypoint for…
djstrong Apr 29, 2026
c181652
refactor: remove unused comments and streamline code in entrypoint co…
djstrong Apr 29, 2026
f05bfe7
refactor: update test setup to use temporary directories for entrypoi…
djstrong Apr 29, 2026
1453be2
feat: implement structured error handling in EnsRainbowApiClient and …
djstrong Apr 29, 2026
6410edd
Merge branch 'main' into 1610-start-ensrainbow-server-immediately-and…
djstrong Apr 29, 2026
73848f8
refactor: simplify ENSRainbow readiness check by removing retry logic…
djstrong Apr 29, 2026
816bba5
refactor: replace ErrorCode.ServiceUnavailable with HTTP status code …
djstrong Apr 29, 2026
8dbe6df
Merge branch 'main' into 1610-start-ensrainbow-server-immediately-and…
djstrong Apr 29, 2026
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.
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changeset says the container runs pnpm --filter ensrainbow run entrypoint, but the Dockerfile now sets WORKDIR /app/apps/ensrainbow and runs pnpm run entrypoint. Consider updating this line to match the actual container invocation (or vice versa) so the migration note is unambiguous.

Copilot uses AI. Check for mistakes.
Comment thread
djstrong marked this conversation as resolved.
Outdated
Comment thread
djstrong marked this conversation as resolved.
Outdated
- **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),
Comment thread
djstrong marked this conversation as resolved.
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.`,
});
Comment thread
djstrong marked this conversation as resolved.
},
})
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