Skip to content

ENSDb Writer Worker for ENSIndexer#1702

Open
tk-o wants to merge 17 commits intomainfrom
feat/ensdb-writer-worker-for-ensindexer
Open

ENSDb Writer Worker for ENSIndexer#1702
tk-o wants to merge 17 commits intomainfrom
feat/ensdb-writer-worker-for-ensindexer

Conversation

@tk-o
Copy link
Contributor

@tk-o tk-o commented Mar 1, 2026

Lite PR

Tip: Review docs on the ENSNode PR process

Summary

  • Implemented EnsDbClient class for ENSIndexer (includes queries and mutations).
  • Implemented EnsDbWriterWorker to handle tasks of writing ENSNode metadata into ENSDb
    • Task 1: write ENSDb client version
    • Task 2: write ENSIndexer Public Config
    • Task 3 (recurring): write Indexing Status snapshot
  • EnsDbWriterWorker uses AbortController internally to manage worker lifecycle (running, stopped).
  • startEnsDbWriterWorker function creates a singleton instance for EnsDbWriterWorker and sets up error handling.

Why

  • We need to decouple ENSIndexer and ENSApi, so that we need an integration point. This integration point is going to be ENSDb, and it needs all required data up-to-date.

Testing

  • Ran static code checks (lint, typechecks), and testing suite.
  • Ran local instance of ENSIndexer and confirmed it writes ENSNode Metadata correctly into ENSDb.
ENSIndexer logs demo
14:41:34.474 INFO  Created HTTP server port=42069 (4ms)
14:41:34.474 INFO  Started returning 200 responses endpoint=/health
[EnsDbWriterWorker]: Upserting ENSDb version into ENSDb...
[EnsDbWriterWorker]: ENSDb version upserted successfully: 1.5.1
[EnsDbWriterWorker]: Upserting ENSIndexer Public Config into ENSDb...
[EnsDbWriterWorker]: ENSIndexer Public Config upserted successfully
[EnsDbWriterWorker]: Error retrieving or validating Indexing Status Snapshot: Error: Invalid serialized Ponder Indexing Metrics: ✖ At least one 'ponder_sync_block' metric must include a 'chain' label.
✖ At least one 'ponder_sync_block_timestamp' metric must include a 'chain' label.
✖ At least one 'ponder_historical_total_blocks' metric must include a 'chain' label.
    at Module.deserializePonderIndexingMetrics (/Users/tko/dev/github/namehash/ensnode/packages/ponder-sdk/src/deserialize/indexing-metrics.ts:296:11)
    at LocalPonderClient.metrics (/Users/tko/dev/github/namehash/ensnode/packages/ponder-sdk/src/client.ts:48:12)
    at processTicksAndRejections (node:internal/process/task_queues:103:5)
    at LocalPonderClient.metrics (/Users/tko/dev/github/namehash/ensnode/packages/ponder-sdk/src/local-ponder-client.ts:154:21)
    at async Promise.all (index 0)
    at IndexingStatusBuilder.getOmnichainIndexingStatusSnapshot (/Users/tko/dev/github/namehash/ensnode/apps/ensindexer/src/lib/indexing-status-builder/indexing-status-builder.ts:49:61)
    at EnsDbWriterWorker.getIndexingStatusSnapshotStream (/Users/tko/dev/github/namehash/ensnode/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts:233:11)
    at EnsDbWriterWorker.run (/Users/tko/dev/github/namehash/ensnode/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts:96:22)
    at EnsDbWriterWorker.runWithRetries (/Users/tko/dev/github/namehash/ensnode/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts:119:9)
14:41:34.719 INFO  Started backfill indexing chain=84532 block_range=[13012458,38345272]
14:41:34.720 INFO  Started backfill indexing chain=11155111 block_range=[3702721,10368807]
14:41:34.720 INFO  Started backfill indexing chain=59141 block_range=[2395094,25953939]
14:41:34.721 INFO  Started fetching backfill JSON-RPC data chain=84532 cached_block=38345249 cache_rate=100%
14:41:34.721 INFO  Started fetching backfill JSON-RPC data chain=11155111 cached_block=10368804 cache_rate=100%
14:41:34.721 INFO  Started fetching backfill JSON-RPC data chain=59141 cached_block=25953913 cache_rate=100%
14:41:35.214 INFO  Finished fetching backfill JSON-RPC data chain=84532 (494ms)
14:41:35.327 INFO  Finished fetching backfill JSON-RPC data chain=59141 (606ms)
14:41:35.826 INFO  Indexed block range chain=11155111 event_count=2995 block_range=[3702721,4177830] (846ms)
14:41:36.052 INFO  Finished fetching backfill JSON-RPC data chain=11155111 (1s)
14:41:36.564 INFO  Indexed block range chain=11155111 event_count=2995 block_range=[4177831,4325397] (736ms)
14:41:37.005 INFO  Indexed block range chain=11155111 event_count=3005 block_range=[4325398,4447290] (439ms)
14:41:37.408 INFO  Indexed block range chain=11155111 event_count=2997 block_range=[4447291,4603398] (402ms)
14:41:38.077 INFO  Indexed block range chain=11155111 event_count=3005 block_range=[4603399,4646580] (668ms)
14:41:38.557 INFO  Indexed block range chain=11155111 event_count=3002 block_range=[4646581,4805136] (478ms)
14:41:39.293 INFO  Indexed block range chain=11155111 event_count=3001 block_range=[4805137,4831114] (735ms)
14:41:39.719 INFO  Updated backfill indexing progress progress=17.5%
[EnsDbWriterWorker]: Upserting Indexing Status Snapshot into ENSDb...
[EnsDbWriterWorker]: Indexing Status Snapshot upserted successfully
14:41:40.104 INFO  Indexed block range chain=11155111 event_count=3003 block_range=[4831115,4836708] (809ms)
14:41:40.919 INFO  Indexed block range chain=11155111 event_count=2997 block_range=[4836709,4845690] (813ms)

Notes for Reviewer (Optional)

  • Review commit-by-commit is highly encouraged.
  • There's a plan for implementing a read-only client (see EnsDbClientQuery interface) from ENSDb that will be available via a new package so our customers could use it in their backend workloads. That update is out of scope for this PR, but when it's live, the apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts implementation could simply extend the "read-only" client and only add the mutation methods (see EnsDbClientMutation interface).

Pre-Review Checklist (Blocking)

  • This PR does not introduce significant changes and is low-risk to review quickly.
    • PR updates have been carefully sliced into separate commits to make the PR review process more straightforward.
  • Relevant changesets are included (or are not required)

Resolves #1252

Copilot AI review requested due to automatic review settings March 1, 2026 17:59
@changeset-bot
Copy link

changeset-bot bot commented Mar 1, 2026

🦋 Changeset detected

Latest commit: 39b700a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 18 packages
Name Type
ensindexer Major
ensadmin Major
ensrainbow Major
ensapi Major
fallback-ensapi Major
@ensnode/datasources Major
@ensnode/ensrainbow-sdk Major
@ensnode/ensnode-schema Major
@ensnode/ensnode-react Major
@ensnode/ensnode-sdk Major
@ensnode/ponder-sdk Major
@ensnode/ponder-subgraph Major
@ensnode/shared-configs Major
@docs/ensnode Major
@docs/ensrainbow Major
@docs/mintlify Major
@namehash/ens-referrals Major
@namehash/namehash-ui Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Contributor

vercel bot commented Mar 1, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
admin.ensnode.io Ready Ready Preview, Comment Mar 3, 2026 7:41am
ensnode.io Ready Ready Preview, Comment Mar 3, 2026 7:41am
ensrainbow.io Ready Ready Preview, Comment Mar 3, 2026 7:41am

@tk-o
Copy link
Contributor Author

tk-o commented Mar 1, 2026

@greptile review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 1, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds ENSDb persistence and a background writer: Drizzle factory, ENSDb client and singleton, writer worker + starter singleton, indexer/indexing-status singletons, tests/mocks, package dependency additions, and updates the API handler to use singletons and start the writer.

Changes

Cohort / File(s) Summary
Dependencies
apps/ensindexer/package.json
Added @ponder/client, drizzle-orm to dependencies and @types/pg to devDependencies.
Drizzle factory
apps/ensindexer/src/lib/ensdb-client/drizzle.ts
New makeDrizzle factory: configures Drizzle ORM with provided schema, DB URL, DB schema name, and snake_case casing.
ENSDb client implementation
apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts, apps/ensindexer/src/lib/ensdb-client/singleton.ts
Added exported EnsDbClient class (read/upsert, serialize/deserialize, single-record enforcement) and singleton ensDbClient.
ENSDb tests & mocks
apps/ensindexer/src/lib/ensdb-client/ensdb-client.mock.ts, apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts
New strongly-typed mock data and comprehensive unit tests covering get/upsert behaviors and edge cases.
ENSDb writer worker & tests
apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts, .../ensdb-writer-worker.test.ts, .../singleton.ts
New EnsDbWriterWorker (run, runWithRetries, stop, streaming/upsert, validation) plus startEnsDbWriterWorker() singleton that manages lifecycle and signal handlers; tests for normal, retry, and failure scenarios.
Indexer & builder singletons
apps/ensindexer/src/lib/ensindexer-client/singleton.ts, apps/ensindexer/src/lib/indexing-status-builder/singleton.ts
New singletons: ensIndexerClient and indexingStatusBuilder (wired to local Ponder client).
API handler change
apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts
Replaced local IndexingStatusBuilder/local Ponder client instantiation with indexingStatusBuilder singleton import and invokes startEnsDbWriterWorker() at module initialization.

Sequence Diagram

sequenceDiagram
    participant API as "ensnode-api"
    participant Worker as "EnsDbWriterWorker (singleton)"
    participant Indexer as "IndexingStatusBuilder / EnsIndexerClient"
    participant Client as "EnsDbClient"
    participant DB as "PostgreSQL"

    API->>Worker: startEnsDbWriterWorker()

    rect rgba(100,150,255,0.5)
    Note over Worker,Indexer: initial validation & config fetch
    Worker->>Indexer: getValidatedEnsIndexerPublicConfig()
    Indexer->>Client: request stored config
    Client->>DB: SELECT metadata by key
    DB-->>Client: stored config or none
    Client-->>Worker: config (or undefined)
    end

    rect rgba(100,150,255,0.5)
    Note over Worker: initial upserts
    Worker->>Client: upsertEnsDbVersion()
    Client->>DB: upsert version metadata
    Worker->>Client: upsertEnsIndexerPublicConfig()
    Client->>DB: upsert config metadata
    end

    rect rgba(150,200,100,0.5)
    Note over Worker: streaming snapshots
    loop every interval
      Worker->>Indexer: buildIndexingStatusSnapshot()
      Indexer-->>Worker: snapshot
      Worker->>Client: upsertIndexingStatusSnapshot()
      Client->>DB: upsert serialized snapshot
      DB-->>Client: OK
    end
    end

    alt Worker stops normally
      Worker-->>API: stopped
    else Unrecoverable error / retries exhausted
      Worker-->>API: error -> process abort
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I stitched a schema by moonlit code,

Snapshots hop in on a steady road.
Singletons hum, a worker keeps pace,
Retries and tests tidy every place—
A rabbit saves bytes with a proud little bounce.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements key ENSIndexer-side objectives from issue #1252: EnsDbClient with queries/mutations, EnsDbWriterWorker for writing metadata (version, config, snapshots), and periodic snapshot upserting with error handling and retry logic.
Out of Scope Changes check ✅ Passed All changes align with issue #1252 ENSIndexer objectives: EnsDbClient, EnsDbWriterWorker, dependency management (drizzle-orm, @ponder/client), singleton modules, and handler integration are all required for ENSIndexer to push metadata to ENSDb.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The pull request title 'ENSDb Writer Worker for ENSIndexer' clearly and specifically summarizes the main change in the changeset.
Description check ✅ Passed The PR description follows the required template with all major sections: Summary (3 bullets), Why (clear rationale with issue link), Testing (static checks and local verification with logs), Notes for Reviewer (guidance on commit-by-commit review and future plans), and Pre-Review Checklist (both items addressed).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/ensdb-writer-worker-for-ensindexer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Introduces an ENSDb “writer worker” in ENSIndexer to persist ENSIndexer metadata into ENSDb, and updates the ensnode-sdk ENSDb client interface naming to be explicitly ENSIndexer-scoped.

Changes:

  • Add EnsDbClient (Drizzle-based) for reading/writing ENSNode metadata records in ENSDb, plus unit tests/mocks.
  • Add EnsDbWriterWorker that upserts ENSDb version + ENSIndexer public config once, then periodically upserts indexing status snapshots (with retries), plus unit tests.
  • Wire the worker + new singletons into ENSIndexer’s Hono API handler; add required deps (drizzle-orm, @ponder/client, @types/pg).

Reviewed changes

Copilot reviewed 13 out of 14 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
pnpm-lock.yaml Adds/threads @types/pg through relevant dependency snapshots.
packages/ensnode-sdk/src/ensdb/client.ts Renames ENSDb client query/mutation methods to ENSIndexer-specific names.
apps/ensindexer/src/lib/indexing-status-builder/singleton.ts Introduces singleton instance for IndexingStatusBuilder.
apps/ensindexer/src/lib/ensindexer-client/singleton.ts Introduces singleton EnsIndexerClient configured via app config.
apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts Creates a singleton EnsDbWriterWorker wired with required dependencies.
apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts Implements the worker loop, validation, and retry behavior.
apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts Adds unit tests for worker behavior (currently has signature mismatches).
apps/ensindexer/src/lib/ensdb-client/singleton.ts Adds singleton EnsDbClient for ENSIndexer.
apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts Implements ENSDb client queries/mutations for ENSNode metadata.
apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts Adds unit tests for ENSDb client behavior.
apps/ensindexer/src/lib/ensdb-client/ensdb-client.mock.ts Adds fixtures for ENSDb client tests (public config, serialized snapshot).
apps/ensindexer/src/lib/ensdb-client/drizzle.ts Adds makeDrizzle helper (adapted from ENSApi).
apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts Starts the writer worker on module load and swaps in singleton builder usage.
apps/ensindexer/package.json Adds needed runtime/dev deps for Drizzle and pg typings.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/ensindexer/package.json`:
- Line 49: Update the dependency entry for "@types/pg" in package.json to use
the workspace catalog reference (matching how "@types/node" is declared) instead
of the hardcoded "8.16.0"; if "@types/pg" is not yet listed in the pnpm
workspace catalog, add it there with the desired version and then change the
package.json entry to "catalog:`@types/pg`" (or the exact catalog key used in your
monorepo) so the monorepo versioning pattern remains consistent.

In `@apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts`:
- Around line 24-32: Replace the current un-awaited promise handling for
ensDbWriterWorker.runWithRetries so it doesn't create an unhandled rejection:
either move the call into a ponder.on("setup", ...) handler and await
ensDbWriterWorker.runWithRetries({ maxRetries: 3 }) so failures can be thrown to
fail setup, or keep it fire-and-forget but mark failure deterministically by
using void ensDbWriterWorker.runWithRetries(...).catch(...) and inside the catch
call ensDbWriterWorker.stop(), log the error and set process.exitCode = 1;
update the code around ensDbWriterWorker.runWithRetries and related stop/logging
accordingly.

In `@apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts`:
- Around line 131-136: Remove the redundant `@returns` JSDoc tag from the JSDoc
block that begins "Get ENSNode metadata record" in ensdb-client.ts (the doc for
the ENS node metadata retrieval method); keep the summary and other tags such as
`@throws` but delete the `@returns` line so the comment no longer restates the
method summary.

In `@apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts`:
- Line 242: The call to worker.runWithRetries currently passes a numeric
argument but the method signature expects an options object; change the
invocation of runWithRetries on the worker instance to pass an object like {
maxRetries: 2 } (mirror the same fix you applied at the earlier call near line
210) so the call matches the runWithRetries({ maxRetries: number }) signature.
- Line 210: The test calls worker.runWithRetries(2) but runWithRetries expects
an object parameter; change the call to pass an object like
worker.runWithRetries({ maxRetries: 2 }) (update the runPromise assignment and
any other test invocations of runWithRetries accordingly) so the signature for
runWithRetries and the worker variable usage match the expected { maxRetries:
number } shape.

In `@apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts`:
- Around line 158-160: Remove the redundant JSDoc `@returns` lines from the method
docblocks that describe returning the in-memory config object (the block that
currently says "In-memory config object, if the validation is successful or if
there is no stored config.") as well as the similar docblock around lines
204-207; keep the summary and `@throws` entries but delete the repetitive `@returns`
tags so the method documentation follows repo style.
- Around line 113-136: The retry loop in runWithRetries is unbounded because it
loops while (!this.isStopped) and only checks maxRetries after sleeping, which
can cause runaway timers; change the loop to explicitly bound retries (e.g.,
while (attempt < maxRetries && !this.isStopped)) or otherwise include an attempt
check in the loop condition so the delay on Line 130 cannot execute beyond
maxRetries; keep the existing behavior of throwing an Error with cause when
attempts are exhausted (the throw that references attempt and error should
remain inside the failure branch) and continue using
INDEXING_STATUS_RECORD_UPDATE_INTERVAL for the sleep duration.

ℹ️ Review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a13e206 and c6ce77f.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (13)
  • apps/ensindexer/package.json
  • apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts
  • apps/ensindexer/src/lib/ensdb-client/drizzle.ts
  • apps/ensindexer/src/lib/ensdb-client/ensdb-client.mock.ts
  • apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts
  • apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts
  • apps/ensindexer/src/lib/ensdb-client/singleton.ts
  • apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts
  • apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts
  • apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts
  • apps/ensindexer/src/lib/ensindexer-client/singleton.ts
  • apps/ensindexer/src/lib/indexing-status-builder/singleton.ts
  • packages/ensnode-sdk/src/ensdb/client.ts

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 1, 2026

Greptile Summary

This PR introduces EnsDbClient and EnsDbWriterWorker to decouple ENSIndexer from ENSApi by using ENSDb as a shared integration point. On startup, the worker upserts the ENSDb schema version and ENSIndexer public config (with a compatibility check against any previously-stored config), then begins a 1-second recurring write of the current indexing status snapshot. The implementation is well-structured, test coverage is thorough, and the singleton/entrypoint wiring is clean.

Key findings:

  • EnsDbWriterWorker.run() is not guarded against being called multiple times — a second call leaks the first interval, relying entirely on the singleton for protection rather than the class itself.
  • In singleton.ts, the pRetry pre-check fetches ensIndexerClient.config() for availability verification but discards the result; run() fetches it again immediately via getValidatedEnsIndexerPublicConfig(), causing a redundant network round-trip on startup.
  • EnsDbWriterWorker.stop() (and the SIGINT/SIGTERM handlers that call it) clears the interval but never closes the underlying pg.Pool. An open pool holds a liveliness timer that can prevent a clean event-loop drain on graceful shutdown; explicit pool.end() via a close() method on EnsDbClient would be safer.
  • Minor incorrect JSDoc @throws description on getEnsNodeMetadata — the method returns undefined for zero results, only throwing on > 1 results.

Confidence Score: 4/5

  • Safe to merge with minor improvements recommended around pool teardown and the redundant config fetch.
  • The core logic is correct and well-tested. No data-loss or security issues were found. The main concerns are a missing guard against double-calling run(), a redundant network round-trip on startup, and a missing pg.Pool close on shutdown — all style/robustness issues rather than blocking bugs. Ponder's explicit process exit mitigates the pool-teardown risk in practice.
  • Pay closest attention to apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts (redundant fetch + pool teardown) and apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts (missing run() re-entrancy guard).

Important Files Changed

Filename Overview
apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts Core worker class; logic is sound but run() is not guarded against multiple calls — a second invocation leaks the first interval. Relies solely on the singleton for protection.
apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts Two issues: (1) pRetry pre-fetches config but discards the result, causing a redundant second fetch inside run(); (2) SIGINT/SIGTERM handlers call stop() but the underlying pg.Pool is never closed, risking a hung process on graceful shutdown.
apps/ensindexer/src/lib/ensdb-client/ensdb-client.ts Clean implementation of query/mutation methods using Drizzle ORM; minor incorrect JSDoc @throws description on getEnsNodeMetadata. No close() method exposed for pool teardown.
apps/ensindexer/src/lib/ensdb-client/drizzle.ts Uses setDatabaseSchema as a global side-effect to set the Postgres schema on shared table objects — an established pattern in this codebase but a potential footgun if multiple clients with different schemas are ever instantiated in the same process.
apps/ensindexer/ponder/src/api/ensdb-writer-worker-entrypoint.ts Minimal fire-and-forget entrypoint; unhandled promise rejection is intentional per the developer, as Ponder handles it with a graceful shutdown.
apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts Comprehensive test coverage for all major worker paths: initial upserts, config incompatibility, snapshot validation errors, interval stopping, and DB error recovery.
apps/ensindexer/src/lib/ensdb-client/ensdb-client.test.ts Well-structured unit tests covering all query/mutation methods including the multiple-record edge case; Drizzle is correctly mocked to avoid real DB connections.
apps/ensindexer/ponder/src/api/index.ts Side-effect import of ensdb-writer-worker-entrypoint added cleanly; no issues.
apps/ensindexer/src/lib/ensdb-client/singleton.ts Simple singleton export; EnsDbClient is instantiated at module load time which is correct for this pattern.

Sequence Diagram

sequenceDiagram
    participant API as api/index.ts
    participant EP as ensdb-writer-worker-entrypoint.ts
    participant SNG as singleton.ts (worker)
    participant WRK as EnsDbWriterWorker
    participant IDX as EnsIndexerClient
    participant DB as EnsDbClient (pg.Pool)
    participant ISB as IndexingStatusBuilder

    API->>EP: import (side-effect)
    EP->>SNG: startEnsDbWriterWorker()
    SNG->>IDX: pRetry → config() [up to 4 attempts]
    IDX-->>SNG: EnsIndexerPublicConfig (availability check, result discarded)

    SNG->>WRK: new EnsDbWriterWorker(ensDbClient, ensIndexerClient, indexingStatusBuilder)
    SNG->>WRK: run()

    WRK->>DB: getEnsIndexerPublicConfig()
    WRK->>IDX: config()
    DB-->>WRK: storedConfig (or undefined)
    IDX-->>WRK: inMemoryConfig

    alt storedConfig exists
        WRK->>WRK: validateEnsIndexerPublicConfigCompatibility(stored, inMemory)
        Note over WRK: throws → catch in SNG → process.exitCode=1
    end

    WRK->>DB: upsertEnsDbVersion(versionInfo.ensDb)
    WRK->>DB: upsertEnsIndexerPublicConfig(inMemoryConfig)

    WRK->>WRK: setInterval(upsertIndexingStatusSnapshot, 1s)

    loop Every 1 second
        WRK->>ISB: getOmnichainIndexingStatusSnapshot()
        ISB-->>WRK: omnichainSnapshot
        WRK->>WRK: validate (not Unstarted)
        WRK->>WRK: buildCrossChainIndexingStatusSnapshotOmnichain(snapshot, time)
        WRK->>DB: upsertIndexingStatusSnapshot(crossChainSnapshot)
    end

    Note over SNG: process.on(SIGINT/SIGTERM) → ensDbWriterWorker.stop()
    SNG-->>WRK: stop() → clearInterval (pool NOT closed)
Loading

Last reviewed commit: 39b700a

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

18 files reviewed, 10 comments

Edit Code Review Agent Settings | Greptile

@tk-o tk-o force-pushed the feat/ensdb-writer-worker-for-ensindexer branch from c6ce77f to 328e5c1 Compare March 1, 2026 19:51
@vercel vercel bot temporarily deployed to Preview – admin.ensnode.io March 1, 2026 19:51 Inactive
@vercel vercel bot temporarily deployed to Preview – ensrainbow.io March 1, 2026 19:51 Inactive
@vercel vercel bot temporarily deployed to Preview – ensnode.io March 1, 2026 19:51 Inactive
@tk-o tk-o force-pushed the feat/ensdb-writer-worker-for-ensindexer branch from 328e5c1 to c39e3ea Compare March 1, 2026 19:52
@vercel vercel bot temporarily deployed to Preview – ensrainbow.io March 1, 2026 19:53 Inactive
@vercel vercel bot temporarily deployed to Preview – admin.ensnode.io March 1, 2026 19:53 Inactive
@vercel vercel bot temporarily deployed to Preview – ensnode.io March 1, 2026 19:53 Inactive
@tk-o tk-o force-pushed the feat/ensdb-writer-worker-for-ensindexer branch from c39e3ea to eed564c Compare March 1, 2026 19:55
@vercel vercel bot temporarily deployed to Preview – ensnode.io March 1, 2026 19:55 Inactive
@vercel vercel bot temporarily deployed to Preview – ensrainbow.io March 1, 2026 19:55 Inactive
@vercel vercel bot temporarily deployed to Preview – admin.ensnode.io March 1, 2026 19:55 Inactive
ENSIndexer modules can import the singleton instance from `@/lib/ensdb-client`.
Copilot AI review requested due to automatic review settings March 1, 2026 19:57
@tk-o tk-o force-pushed the feat/ensdb-writer-worker-for-ensindexer branch from eed564c to 8208a39 Compare March 1, 2026 19:57
@vercel vercel bot temporarily deployed to Preview – admin.ensnode.io March 2, 2026 14:12 Inactive
@vercel vercel bot temporarily deployed to Preview – ensrainbow.io March 2, 2026 14:12 Inactive
tk-o added 2 commits March 2, 2026 15:13
Implement `AbortSignal`, and ensure only one worker instance can be started.
…enable storing ENSNode metadata in ENSDb.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 14 changed files in this pull request and generated 3 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@vercel vercel bot temporarily deployed to Preview – admin.ensnode.io March 2, 2026 14:59 Inactive
@vercel vercel bot temporarily deployed to Preview – ensnode.io March 2, 2026 14:59 Inactive
@vercel vercel bot temporarily deployed to Preview – ensrainbow.io March 2, 2026 14:59 Inactive
Copy link
Member

@lightwalker-eth lightwalker-eth left a comment

Choose a reason for hiding this comment

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

@tk-o Hey great to see this. Reviewed and shared feedback 👍

"ensindexer": minor
---

Introduced `EnsDbClient` and `EnsDbWriterWorker` to enable storing ENSNode metadata in ENSDb.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
Introduced `EnsDbClient` and `EnsDbWriterWorker` to enable storing ENSNode metadata in ENSDb.
Introduced `EnsDbClient` and `EnsDbWriterWorker` to enable storing metadata in ENSDb.

Goal: Suggest we not call it ENSNode metadata and instead use more specific terminology for it. As I understand the metadata we store there is not for an entire ENSNode instance but just for the services like ENSIndexer and ENSRainbow that live "beneath" ENSDb.

const app = new Hono();
const indexingStatusBuilder = new IndexingStatusBuilder(localPonderClient);

startEnsDbWriterWorker();
Copy link
Member

Choose a reason for hiding this comment

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

Are you sure this is the ideal place for this idea? I note this file is an API handler.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, we should create a new file in ponder/src and to be executed at runtime 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Turns out, that our worker file must live inside ponder/src/api dir, so Ponder app builder does not complain with build error.

@@ -0,0 +1,24 @@
// This file was copied 1-to-1 from ENSApi.
Copy link
Member

Choose a reason for hiding this comment

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

@tk-o Can you please check this comment from Copilot?

@@ -0,0 +1,24 @@
// This file was copied 1-to-1 from ENSApi.
Copy link
Member

Choose a reason for hiding this comment

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

Why do we need to copy it? Why can't we move it into a package that can be reused across these apps?

I understand here we might be doing something special with Ponder -- but then we should be documenting here WHY we are doing what we are doing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, will add the comment saying that we don't currently have a backend-oriented package through which we could share backend-only modules.

*
* This client exists to provide an abstraction layer for interacting with ENSDb.
* It enables ENSIndexer and ENSApi to decouple from each other, and use
* the ENSDb Client as the integration point between the two.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
* the ENSDb Client as the integration point between the two.
* ENSDb as the integration point between the two (via ENSDb Client).

/**
* ENSDb Writer Worker
*
* A worker responsible for writing ENSIndexer-related data into ENSDb, including:
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
* A worker responsible for writing ENSIndexer-related data into ENSDb, including:
* A worker responsible for writing ENSIndexer-related metadata into ENSDb, including:

attempt += 1;

try {
await this.run();
Copy link
Member

Choose a reason for hiding this comment

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

I'm not so clear what happens here if an error happens. It seems that attempt is never reset back to 0 if there's a sequence of error, error, success?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll improve the retry logic with p-retry, no point in re-implementing such a common pattern 👍

try {
validateEnsIndexerPublicConfigCompatibility(storedConfig, inMemoryConfig);
} catch (error) {
const errorMessage = `In-memory ENSIndexer Public Config object is not compatible with its counterpart stored in ENSDb.`;
Copy link
Member

Choose a reason for hiding this comment

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

It will be very helpful for operators if this gives visibility on what caused the incompatibility.

* Validation criteria are defined in the function body.
* @returns void when the worker is stopped.
*/
private async *getIndexingStatusSnapshotStream(): AsyncGenerator<CrossChainIndexingStatusSnapshot> {
Copy link
Member

Choose a reason for hiding this comment

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

I was assuming we would use a similar strategy for infinite async loops both here and in SWRCache. Is there a special reason why we used different strategies in each of these?

When we have a similar need, prefer we reuse our design patterns unless there's a special reason not to. Otherwise if we have too many ways of doing what is fundamentally the same thing it makes it harder for people to work on our code.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The idea I had was to express the recurring task clearly. That's why I used a stream + for-await approach.

Sure, we can apply interval running the task as well, just like it's done in SWRCache 👍

* Interval in seconds between two consecutive attempts to upsert
* the Indexing Status Snapshot record into ENSDb.
*/
const INDEXING_STATUS_RECORD_UPDATE_INTERVAL: Duration = 5;
Copy link
Member

Choose a reason for hiding this comment

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

Maybe reduce to 1 second?

tk-o added 6 commits March 3, 2026 07:07
Applies background tasks approach known from `SWRCache` implementation.
Initially, I wanted to implement some helpers myself, but then, after seeing AI PR feedback, I figured I was rebuilding `p-retry` in a way... Here is the (now closed) PR: #1709
No point in running the worker if its dependencies are not available to be loaded.
@tk-o
Copy link
Contributor Author

tk-o commented Mar 3, 2026

@greptile review

Comment on lines +80 to +101
public async run(): Promise<void> {
// Fetch data required for task 1 and task 2.
const inMemoryConfig = await this.getValidatedEnsIndexerPublicConfig();

// Task 1: upsert ENSDb version into ENSDb.
console.log(`[EnsDbWriterWorker]: Upserting ENSDb version into ENSDb...`);
await this.ensDbClient.upsertEnsDbVersion(inMemoryConfig.versionInfo.ensDb);
console.log(
`[EnsDbWriterWorker]: ENSDb version upserted successfully: ${inMemoryConfig.versionInfo.ensDb}`,
);

// Task 2: upsert of EnsIndexerPublicConfig into ENSDb.
console.log(`[EnsDbWriterWorker]: Upserting ENSIndexer Public Config into ENSDb...`);
await this.ensDbClient.upsertEnsIndexerPublicConfig(inMemoryConfig);
console.log(`[EnsDbWriterWorker]: ENSIndexer Public Config upserted successfully`);

// Task 3: recurring upsert of Indexing Status Snapshot into ENSDb.
this.indexingStatusInterval = setInterval(
() => this.upsertIndexingStatusSnapshot(),
secondsToMilliseconds(INDEXING_STATUS_RECORD_UPDATE_INTERVAL),
);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

run() is not guarded against multiple calls

run() unconditionally calls setInterval every time it is invoked. A second call before stop() would start a second interval; the reference to the first interval is overwritten by this.indexingStatusInterval = setInterval(...), making it impossible to clear the first one. The singleton guard in singleton.ts prevents this in production, but the class itself has no self-protection — a test or any future caller can trigger the leak.

Consider adding an early guard:

Suggested change
public async run(): Promise<void> {
// Fetch data required for task 1 and task 2.
const inMemoryConfig = await this.getValidatedEnsIndexerPublicConfig();
// Task 1: upsert ENSDb version into ENSDb.
console.log(`[EnsDbWriterWorker]: Upserting ENSDb version into ENSDb...`);
await this.ensDbClient.upsertEnsDbVersion(inMemoryConfig.versionInfo.ensDb);
console.log(
`[EnsDbWriterWorker]: ENSDb version upserted successfully: ${inMemoryConfig.versionInfo.ensDb}`,
);
// Task 2: upsert of EnsIndexerPublicConfig into ENSDb.
console.log(`[EnsDbWriterWorker]: Upserting ENSIndexer Public Config into ENSDb...`);
await this.ensDbClient.upsertEnsIndexerPublicConfig(inMemoryConfig);
console.log(`[EnsDbWriterWorker]: ENSIndexer Public Config upserted successfully`);
// Task 3: recurring upsert of Indexing Status Snapshot into ENSDb.
this.indexingStatusInterval = setInterval(
() => this.upsertIndexingStatusSnapshot(),
secondsToMilliseconds(INDEXING_STATUS_RECORD_UPDATE_INTERVAL),
);
}
public async run(): Promise<void> {
if (this.indexingStatusInterval !== null) {
throw new Error("EnsDbWriterWorker is already running. Call stop() before run().");
}
// Fetch data required for task 1 and task 2.
const inMemoryConfig = await this.getValidatedEnsIndexerPublicConfig();

Comment on lines +35 to +43
await pRetry(() => ensIndexerClient.config(), {
retries: 3,
onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => {
console.warn(
`ENSIndexer Config fetch attempt ${attemptNumber} failed (${error.message}). ${retriesLeft} retries left.`,
);
},
});

Copy link
Contributor

Choose a reason for hiding this comment

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

Redundant config fetch — result is discarded

The pRetry block fetches ensIndexerClient.config() solely to verify availability, but the result is never used. ensDbWriterWorker.run() (line 44) then calls this.ensIndexerClient.config() again internally via getValidatedEnsIndexerPublicConfig(), causing two round-trips for the same data on startup.

Consider either:

  • Returning the config from pRetry and passing it into a modified run(config) signature, or
  • Moving the retry logic inside getValidatedEnsIndexerPublicConfig() so retries apply to both the availability check and the actual read in one call.

Comment on lines +44 to +58
await ensDbWriterWorker.run();
} catch (error) {
// Abort the worker on error to trigger resources cleanup
ensDbWriterWorker.stop();

console.error("EnsDbWriterWorker encountered an error:", error);

// Re-throw the error to ensure the application shuts down with a non-zero exit code.
process.exitCode = 1;
throw error;
}

// Handle graceful shutdown on process termination signals
process.on("SIGINT", () => ensDbWriterWorker.stop());
process.on("SIGTERM", () => ensDbWriterWorker.stop());
Copy link
Contributor

Choose a reason for hiding this comment

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

DB connection pool is never closed on shutdown

EnsDbClient creates a pg.Pool internally through drizzle(databaseUrl, ...). The stop() method only clears the setInterval but does not close the underlying pool. The SIGINT/SIGTERM handlers registered on lines 57–58 call stop() but leave the pool open.

An open pg.Pool holds a timer internally that keeps the Node.js event loop alive, which can prevent a clean graceful exit if the host process relies on the event loop draining naturally. If Ponder always calls process.exit() explicitly this is moot, but adding an explicit end() call would be safer and more explicit.

Consider exposing a close() method on EnsDbClient that calls db.$client.end() and wiring it into EnsDbWriterWorker.stop():

// In EnsDbClient
public async close(): Promise<void> {
  await this.db.$client.end();
}

// In EnsDbWriterWorker.stop()
public stop(): void {
  if (this.indexingStatusInterval) {
    clearInterval(this.indexingStatusInterval);
    this.indexingStatusInterval = null;
  }
  // optionally await this.ensDbClient.close()
}

Comment on lines +147 to +148
*
* @returns selected record in ENSDb.
Copy link
Contributor

Choose a reason for hiding this comment

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

Incorrect JSDoc @throws description

The method returns undefined when zero records are found (lines 159–161), so it does not throw in the "not found" case. It only throws when result.length > 1. The current JSDoc is misleading:

@throws when exactly one matching metadata record was not found

should read something like:

@throws when more than one matching metadata record is found (should be impossible given the PK constraint on 'key')

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated 3 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +97 to +100
this.indexingStatusInterval = setInterval(
() => this.upsertIndexingStatusSnapshot(),
secondsToMilliseconds(INDEXING_STATUS_RECORD_UPDATE_INTERVAL),
);
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

setInterval(() => this.upsertIndexingStatusSnapshot(), ...) schedules an async task every second without awaiting completion. If an upsert takes longer than the interval (slow DB / network hiccup), multiple upsertIndexingStatusSnapshot() calls can overlap and create concurrent writes/log spam. Consider switching to a self-scheduling setTimeout loop that awaits each iteration (or add a simple in-flight/lock guard) so only one snapshot write runs at a time.

Copilot uses AI. Check for mistakes.

import { startEnsDbWriterWorker } from "@/lib/ensdb-writer-worker/singleton";

startEnsDbWriterWorker();
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

This entrypoint invokes the async startEnsDbWriterWorker() without awaiting or attaching a .catch(...). If startup fails (e.g., incompatible config), the rejection becomes unhandled and shutdown behavior depends on Node's unhandled-rejection mode. Please explicitly handle the returned promise (e.g., top-level await or void startEnsDbWriterWorker().catch(...) that logs and terminates) so failures reliably stop the process.

Suggested change
startEnsDbWriterWorker();
void startEnsDbWriterWorker().catch((error) => {
console.error("Failed to start ENSDb Writer Worker:", error);
process.exit(1);
});

Copilot uses AI. Check for mistakes.
Comment on lines +14 to +15
* The worker will run indefinitely until its internal AbortSignal is triggered,
* for example due to a process termination signal or an internal error, at
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

The JSDoc claims the worker runs until its internal AbortSignal is triggered, but the current EnsDbWriterWorker implementation only uses setInterval + stop() and doesn't use an AbortController/signal. Please either update this documentation to match the actual lifecycle control or implement the abort-signal based shutdown described here (and in the PR description).

Suggested change
* The worker will run indefinitely until its internal AbortSignal is triggered,
* for example due to a process termination signal or an internal error, at
* The worker will run indefinitely until it is stopped via {@link EnsDbWriterWorker.stop},
* for example in response to a process termination signal or an internal error, at

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ENSApi: Persist ENSIndexerPublicConfig to the database

3 participants