Skip to content
Open
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.
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.
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 sets WORKDIR /app/apps/ensrainbow and uses ENTRYPOINT ["pnpm", "run", "entrypoint"] (no filter). Consider updating the changeset text to match the actual container entrypoint so release notes don’t mislead operators.

Suggested change
- **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.
- **New Docker entrypoint**: the container now runs `pnpm run entrypoint` (implemented in Node via `tsx src/cli.ts entrypoint`), which replaces `scripts/entrypoint.sh` and the `netcat` workaround.

Copilot uses AI. Check for mistakes.
- **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.
14 changes: 7 additions & 7 deletions apps/ensindexer/src/lib/ensrainbow/singleton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ let waitForEnsRainbowToBeReadyPromise: Promise<void> | undefined;
*
* Note: It may take 30+ minutes for the ENSRainbow instance to become ready in
* a cold start scenario. We use retries with a fixed interval between attempts
* for the ENSRainbow health check to allow for ample time for ENSRainbow to
* become ready.
* for the ENSRainbow readiness check to allow for ample time for bootstrap to
* complete.
*
* @throws When ENSRainbow fails to become ready after all configured retry attempts.
* This error will trigger termination of the ENSIndexer process.
Expand All @@ -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.`,
});
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
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,49 @@ describe("PublicConfigBuilder", () => {
expect(result).toBe(customConfig);
expect(result.isSubgraphCompatible).toBe(false);
});

it("awaits readiness before fetching ENSRainbow config", async () => {
const callOrder: string[] = [];
const ensRainbowClientMock = {
config: vi.fn().mockImplementation(async () => {
callOrder.push("config");
return mockEnsRainbowConfig;
}),
} as unknown as EnsRainbow.ApiClient;
const waitForReady = vi.fn().mockImplementation(async () => {
callOrder.push("wait");
});

setupStandardMocks();
const mockPublicConfig = createMockPublicConfig();
vi.mocked(validateEnsIndexerPublicConfig).mockReturnValue(mockPublicConfig);

const builder = new PublicConfigBuilder(ensRainbowClientMock, waitForReady);
const result = await builder.getPublicConfig();

expect(waitForReady).toHaveBeenCalledTimes(1);
expect(ensRainbowClientMock.config).toHaveBeenCalledTimes(1);
expect(callOrder).toEqual(["wait", "config"]);
expect(result).toBe(mockPublicConfig);
});
});

describe("getPublicConfig() - error handling", () => {
it("throws when readiness check fails and does not call config()", async () => {
const readinessError = new Error("ENSRainbow not ready");
const ensRainbowClientMock = {
config: vi.fn(),
} as unknown as EnsRainbow.ApiClient;
const waitForReady = vi.fn().mockRejectedValue(readinessError);

const builder = new PublicConfigBuilder(ensRainbowClientMock, waitForReady);

await expect(builder.getPublicConfig()).rejects.toThrow(readinessError);
expect(waitForReady).toHaveBeenCalledTimes(1);
expect(ensRainbowClientMock.config).not.toHaveBeenCalled();
expect(validateEnsIndexerPublicConfig).not.toHaveBeenCalled();
});

it("throws when ENSRainbow client config() fails", async () => {
// Arrange
const ensRainbowError = new Error("ENSRainbow service unavailable");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export class PublicConfigBuilder {
* the ENSIndexer Public Config.
*/
private ensRainbowClient: EnsRainbow.ApiClient;
private waitForEnsRainbowReady: () => Promise<void>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Document the new waitForEnsRainbowReady field/param for consistency.

Neighboring fields (ensRainbowClient, immutablePublicConfig) have JSDoc explaining their purpose, and the constructor's JSDoc only documents ensRainbowClient. The new readiness-hook field and parameter are introduced without similar context, which makes the "called once before the first ensRainbowClient.config()" contract non-obvious to readers.

📝 Proposed doc addition
   private ensRainbowClient: EnsRainbow.ApiClient;
+  /**
+   * Readiness hook awaited once before the first ENSRainbow `config()` call.
+   * Defaults to a no-op so tests and other callers can opt out.
+   */
   private waitForEnsRainbowReady: () => Promise<void>;
@@
   /**
    * `@param` ensRainbowClient ENSRainbow Client instance used to fetch ENSRainbow Public Config
+   * `@param` waitForEnsRainbowReady Optional readiness hook awaited before the first
+   *        `ensRainbowClient.config()` call on the initial `getPublicConfig()` invocation.
    */
   constructor(
     ensRainbowClient: EnsRainbow.ApiClient,
     waitForEnsRainbowReady: () => Promise<void> = async () => {},
   ) {

As per coding guidelines: "Maintain comment consistency within a file: if most types, schemas, or declarations lack comments, do not add a comment to a single one" — the inverse applies here since the other fields in this class are documented.

Also applies to: 31-40

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ensindexer/src/lib/public-config-builder/public-config-builder.ts` at
line 21, Add a short JSDoc description for the new readiness hook field and
constructor parameter so it matches neighboring docs: document the private field
waitForEnsRainbowReady and the corresponding constructor parameter as "a
one-time async readiness hook called before the first ensRainbowClient.config()
invocation" (or similar), and update the constructor JSDoc to include this
parameter alongside the existing ensRainbowClient and immutablePublicConfig
entries to make the contract explicit for readers.


/**
* Immutable ENSIndexer Public Config
Expand All @@ -30,8 +31,12 @@ export class PublicConfigBuilder {
/**
* @param ensRainbowClient ENSRainbow Client instance used to fetch ENSRainbow Public Config
*/
constructor(ensRainbowClient: EnsRainbow.ApiClient) {
constructor(
ensRainbowClient: EnsRainbow.ApiClient,
waitForEnsRainbowReady: () => Promise<void> = async () => {},
) {
this.ensRainbowClient = ensRainbowClient;
this.waitForEnsRainbowReady = waitForEnsRainbowReady;
}

/**
Expand All @@ -45,6 +50,8 @@ export class PublicConfigBuilder {
*/
async getPublicConfig(): Promise<EnsIndexerPublicConfig> {
if (typeof this.immutablePublicConfig === "undefined") {
await this.waitForEnsRainbowReady();

const [versionInfo, ensRainbowPublicConfig] = await Promise.all([
this.getEnsIndexerVersionInfo(),
this.ensRainbowClient.config(),
Expand Down
7 changes: 5 additions & 2 deletions apps/ensindexer/src/lib/public-config-builder/singleton.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { ensRainbowClient } from "@/lib/ensrainbow/singleton";
import { ensRainbowClient, waitForEnsRainbowToBeReady } from "@/lib/ensrainbow/singleton";
import { PublicConfigBuilder } from "@/lib/public-config-builder/public-config-builder";

export const publicConfigBuilder = new PublicConfigBuilder(ensRainbowClient);
export const publicConfigBuilder = new PublicConfigBuilder(
ensRainbowClient,
waitForEnsRainbowToBeReady,
);
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.

Loading
Loading