Skip to content

Commit 0aa0c5a

Browse files
authored
Introduce indexing metadata context data model (#1997)
1 parent 1bff675 commit 0aa0c5a

40 files changed

Lines changed: 2369 additions & 1105 deletions

.changeset/clean-rivers-buy.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@ensnode/integration-test-env": minor
3+
"@ensnode/ensnode-sdk": minor
4+
"@ensnode/ensdb-sdk": minor
5+
"ensindexer": minor
6+
"ensapi": minor
7+
---
8+
9+
Introduced `IndexingMetadataContext` data model, a single record type in ENSNode Metadata table replacing three separate record types (`ensdb_version`, `ensindexer_public_config`, `ensindexer_indexing_status`). Also, consolidated startup init into `initIndexingOnchainEvents()` for reliable execution on every ENSIndexer startup.
10+
11+
**ensnode-sdk**: `EnsIndexerStackInfo` added as base type, `EnsNodeStackInfo` refactored to extend it.
12+
13+
**ensdb-sdk**: For `EnsDbReader`, added following method: `getIndexingMetadataContext()`, `isHealthy()`, `isReady()`. For `EnsDbWriter`, added `upsertIndexingMetadataContext()` method. Old per-record read/write methods removed. `EnsNodeMetadataKeys` reduced to single `IndexingMetadataContext` key.
14+
15+
**ensindexer**: `IndexingMetadataContextBuilder` and `StackInfoBuilder` added. `EnsDbWriterWorker` simplified to single recurring task. HTTP `/config` and `/indexing-status` endpoints now read from in-memory builders instead of ENSDb. `initializeIndexingSetup`/`initializeIndexingActivation` replaced by `initIndexingOnchainEvents`.
16+
17+
**ensapi**: `indexing-status.cache` and `stack-info.cache` updated to consume `IndexingMetadataContext`. Config schema updated to fetch `EnsIndexerPublicConfig` from `EnsNodeStackInfo`.
18+
19+
**integration-test-env**: `pollIndexingStatus` updated to use `getIndexingMetadataContext()`.

apps/ensapi/src/cache/indexing-status.cache.ts

Lines changed: 49 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,67 @@
11
import { EnsNodeMetadataKeys } from "@ensnode/ensdb-sdk";
2-
import { type CrossChainIndexingStatusSnapshot, SWRCache } from "@ensnode/ensnode-sdk";
2+
import {
3+
type CrossChainIndexingStatusSnapshot,
4+
IndexingMetadataContextStatusCodes,
5+
SWRCache,
6+
} from "@ensnode/ensnode-sdk";
37

48
import { ensDbClient } from "@/lib/ensdb/singleton";
59
import { lazyProxy } from "@/lib/lazy";
610
import { makeLogger } from "@/lib/logger";
711

812
const logger = makeLogger("indexing-status.cache");
913

14+
export type IndexingStatusCache = SWRCache<CrossChainIndexingStatusSnapshot>;
15+
1016
// lazyProxy defers construction until first use so that this module can be
1117
// imported without env vars being present (e.g. during OpenAPI generation).
1218
// SWRCache with proactivelyInitialize:true starts background polling immediately
1319
// on construction, which would trigger ensDbClient before env vars are available.
14-
export const indexingStatusCache = lazyProxy<SWRCache<CrossChainIndexingStatusSnapshot>>(
20+
/**
21+
* Cache for {@link CrossChainIndexingStatusSnapshot}, which is loaded
22+
* from ENSDb on demand. The cached value is expected to be updated
23+
* very frequently, following the update frequency of
24+
* {@link IndexingMetadataContextInitialized.indexingStatus} in ENSDb.
25+
* Therefore, the cache is configured with a very short TTL and
26+
* proactive revalidation interval to ensure that the cached value is
27+
* as fresh as possible.
28+
*/
29+
export const indexingStatusCache = lazyProxy<IndexingStatusCache>(
1530
() =>
1631
new SWRCache<CrossChainIndexingStatusSnapshot>({
17-
fn: async (_cachedResult) =>
18-
ensDbClient
19-
.getIndexingStatusSnapshot() // get the latest indexing status snapshot
20-
.then((snapshot) => {
21-
if (snapshot === undefined) {
22-
// An indexing status snapshot has not been found in ENSDb yet.
23-
// This might happen during application startup, i.e. when ENSDb
24-
// has not yet been populated with the first snapshot.
25-
// Therefore, throw an error to trigger the subsequent `.catch` handler.
26-
throw new Error("Indexing Status snapshot not found in ENSDb yet.");
27-
}
32+
fn: async function loadIndexingStatusSnapshot() {
33+
try {
34+
const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext();
35+
36+
if (
37+
indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized
38+
) {
39+
// The IndexingMetadataContext has not been initialized in ENSDb yet.
40+
// This might happen during application startup, i.e. when ENSDb
41+
// has not yet been populated with the IndexingMetadataContext record.
42+
// Therefore, throw an error to trigger the subsequent catch handler.
43+
throw new Error("Indexing Metadata Context was uninitialized in ENSDb.");
44+
}
2845

29-
// The indexing status snapshot has been fetched and successfully validated for caching.
30-
// Therefore, return it so that this current invocation of `readCache` will:
31-
// - Replace the currently cached value (if any) with this new value.
32-
// - Return this non-null value.
33-
return snapshot;
34-
})
35-
.catch((error) => {
36-
// Either the indexing status snapshot fetch failed, or the indexing status snapshot was not found in ENSDb yet.
37-
// Therefore, throw an error so that this current invocation of `readCache` will:
38-
// - Reject the newly fetched response (if any) such that it won't be cached.
39-
// - Return the most recently cached value from prior invocations, or `null` if no prior invocation successfully cached a value.
40-
logger.error(
41-
error,
42-
`Error occurred while loading Indexing Status snapshot record from ENSNode Metadata table in ENSDb. ` +
43-
`Where clause applied: ("ensIndexerSchemaName" = "${ensDbClient.ensIndexerSchemaName}", "key" = "${EnsNodeMetadataKeys.EnsIndexerIndexingStatus}"). ` +
44-
`The cached indexing status snapshot (if any) will not be updated.`,
45-
);
46-
throw error;
47-
}),
46+
// The CrossChainIndexingStatusSnapshot has been successfully loaded for caching.
47+
// Therefore, return it so that this current invocation of `readCache` will:
48+
// - Replace the currently cached value (if any) with this new value.
49+
// - Return this non-null value.
50+
return indexingMetadataContext.indexingStatus;
51+
} catch (error) {
52+
// IndexingMetadataContext was uninitialized in ENSDb.
53+
// Therefore, throw an error so that this current invocation of `readCache` will:
54+
// - Reject the newly fetched response (if any) such that it won't be cached.
55+
// - Return the most recently cached value from prior invocations, or `null` if no prior invocation successfully cached a value.
56+
logger.error(
57+
error,
58+
`Error occurred while loading Indexing Metadata Context record from ENSNode Metadata table in ENSDb. ` +
59+
`Where clause applied: ("ensIndexerSchemaName" = "${ensDbClient.ensIndexerSchemaName}", "key" = "${EnsNodeMetadataKeys.IndexingMetadataContext}"). ` +
60+
`The cached indexing status snapshot (if any) will not be updated.`,
61+
);
62+
throw error;
63+
}
64+
},
4865
// We need to refresh the indexing status cache very frequently.
4966
// ENSDb won't have issues handling this frequency of queries.
5067
ttl: 1, // 1 second

apps/ensapi/src/cache/stack-info.cache.ts

Lines changed: 62 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,76 +2,91 @@ import config from "@/config";
22

33
import { minutesToSeconds } from "date-fns";
44

5+
import { EnsNodeMetadataKeys } from "@ensnode/ensdb-sdk";
56
import {
67
buildEnsNodeStackInfo,
7-
type CachedResult,
88
type EnsNodeStackInfo,
9+
type IndexingMetadataContextInitialized,
10+
IndexingMetadataContextStatusCodes,
911
SWRCache,
1012
} from "@ensnode/ensnode-sdk";
1113

1214
import { buildEnsApiPublicConfig } from "@/config/config.schema";
1315
import { ensDbClient } from "@/lib/ensdb/singleton";
1416
import { lazyProxy } from "@/lib/lazy";
17+
import logger from "@/lib/logger";
1518

16-
/**
17-
* Loads the ENSNode stack info, either from cache if available,
18-
* or by building it from the public configs of ENSApi and ENSDb.
19-
*
20-
* The ENSNode Stack Info object is considered immutable for
21-
* the lifecycle of an ENSApi process instance, so once it is successfully
22-
* loaded, it will be cached indefinitely.
23-
*/
24-
async function loadEnsNodeStackInfo(
25-
cachedResult?: CachedResult<EnsNodeStackInfo>,
26-
): Promise<EnsNodeStackInfo> {
27-
if (cachedResult && !(cachedResult.result instanceof Error)) {
28-
return cachedResult.result;
29-
}
30-
31-
const ensApiPublicConfig = buildEnsApiPublicConfig(config);
32-
const ensDbPublicConfig = await ensDbClient.buildEnsDbPublicConfig();
33-
const ensIndexerPublicConfig = ensApiPublicConfig.ensIndexerPublicConfig;
34-
const ensRainbowPublicConfig = ensIndexerPublicConfig.ensRainbowPublicConfig;
35-
36-
return buildEnsNodeStackInfo(
37-
ensApiPublicConfig,
38-
ensDbPublicConfig,
39-
ensIndexerPublicConfig,
40-
ensRainbowPublicConfig,
41-
);
42-
}
19+
export type EnsNodeStackInfoCache = SWRCache<EnsNodeStackInfo>;
4320

4421
// lazyProxy defers construction until first use so that this module can be
4522
// imported without env vars being present (e.g. during OpenAPI generation).
4623
// SWRCache with proactivelyInitialize:true starts background polling immediately
4724
// on construction, which would trigger ensDbClient before env vars are available.
4825
/**
49-
* Cache for ENSNode stack info
50-
* Once successfully loaded, the ENSNode Stack Info is cached indefinitely and
51-
* never revalidated. This ensures the JSON is only fetched once during
52-
* the application lifecycle.
26+
* Cache for {@link EnsNodeStackInfo}, which is loaded from ENSDb on demand.
27+
* Once successfully loaded, the {@link EnsNodeStackInfo} is cached and kept up-to-date
28+
* by proactive revalidation, since the {@link EnsNodeStackInfo} might change during
29+
* the lifecycle of the ENSApi instance, for example, when
30+
* {@link IndexingMetadataContextInitialized.stackInfo} is updated in ENSDb.
31+
* This is unlikely to happen at all, and if it does happen, it is likely to be
32+
* very infrequent. However, proactive revalidation ensures that if such changes do happen,
33+
* the cached value will be updated in a reasonable time frame without requiring
34+
* a restart of the ENSApi application.
5335
*
5436
* Configuration:
55-
* - ttl: Infinity - Never expires once cached
56-
* - errorTtl: 1 minute - If loading fails, retry on next access after 1 minute
57-
* - proactiveRevalidationInterval: undefined - No proactive revalidation
37+
* - ttl: 1 minute - Allow cached value to be fresh for up to 1 minute.
38+
* - errorTtl: 1 minute - If loading fails, retry on next access after 1 minute.
39+
* - proactiveRevalidationInterval: 5 minutes - Refresh the cached value every 5 minutes.
5840
* - proactivelyInitialize: true - Load immediately on startup
5941
*/
60-
export const stackInfoCache = lazyProxy(
42+
export const stackInfoCache = lazyProxy<EnsNodeStackInfoCache>(
6143
() =>
62-
/**
63-
* Cache for ENSNode stack info
64-
*
65-
* Once initialized successfully, this cache will always return
66-
* the same stack info for the lifecycle of the ENSApi instance.
67-
*
68-
* If initialization fails, it will keep retrying on access until it succeeds, which is desirable because the stack info is critical for the functioning of the application and we want to recover from transient initialization failures without requiring a restart.
69-
*/
7044
new SWRCache<EnsNodeStackInfo>({
71-
fn: loadEnsNodeStackInfo,
72-
ttl: Number.POSITIVE_INFINITY,
45+
fn: async function loadEnsNodeStackInfo() {
46+
try {
47+
const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext();
48+
49+
if (
50+
indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized
51+
) {
52+
// The IndexingMetadataContext has not been initialized in ENSDb yet.
53+
// This might happen during application startup, i.e. when ENSDb
54+
// has not yet been populated with the IndexingMetadataContext record.
55+
// Therefore, throw an error to trigger the subsequent catch handler.
56+
throw new Error("Indexing Metadata Context was uninitialized in ENSDb.");
57+
}
58+
59+
const ensIndexerStackInfo = indexingMetadataContext.stackInfo;
60+
const ensNodeStackInfo = buildEnsNodeStackInfo(
61+
buildEnsApiPublicConfig(config, ensIndexerStackInfo.ensIndexer),
62+
ensIndexerStackInfo.ensDb,
63+
ensIndexerStackInfo.ensIndexer,
64+
ensIndexerStackInfo.ensRainbow,
65+
);
66+
67+
// The EnsNodeStackInfo has been successfully built for caching.
68+
// Therefore, return it so that this current invocation of `readCache` will:
69+
// - Replace the currently cached value (if any) with this new value.
70+
// - Return this non-null value.
71+
return ensNodeStackInfo;
72+
} catch (error) {
73+
// IndexingMetadataContext was uninitialized in ENSDb.
74+
// Therefore, throw an error so that this current invocation of `readCache` will:
75+
// - Reject the newly fetched response (if any) such that it won't be cached.
76+
// - Return the most recently cached value from prior invocations, or `null` if no prior invocation successfully cached a value.
77+
logger.error(
78+
error,
79+
`Error occurred while loading Indexing Metadata Context record from ENSNode Metadata table in ENSDb. ` +
80+
`Where clause applied: ("ensIndexerSchemaName" = "${ensDbClient.ensIndexerSchemaName}", "key" = "${EnsNodeMetadataKeys.IndexingMetadataContext}"). ` +
81+
`The cached EnsNodeStackInfo object (if any) will not be updated.`,
82+
);
83+
84+
throw error;
85+
}
86+
},
87+
ttl: minutesToSeconds(1),
7388
errorTtl: minutesToSeconds(1),
74-
proactiveRevalidationInterval: undefined,
89+
proactiveRevalidationInterval: minutesToSeconds(5),
7590
proactivelyInitialize: true,
7691
}),
7792
);
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import packageJson from "@/../package.json" with { type: "json" };
2+
3+
import {
4+
ChainIndexingStatusIds,
5+
CrossChainIndexingStrategyIds,
6+
deserializeIndexingMetadataContext,
7+
type EnsRainbowPublicConfig,
8+
type IndexingMetadataContextInitialized,
9+
IndexingMetadataContextStatusCodes,
10+
OmnichainIndexingStatusIds,
11+
PluginName,
12+
RangeTypeIds,
13+
type SerializedCrossChainIndexingStatusSnapshot,
14+
type SerializedEnsDbPublicConfig,
15+
type SerializedEnsIndexerPublicConfig,
16+
type SerializedEnsIndexerStackInfo,
17+
type SerializedIndexingMetadataContextInitialized,
18+
} from "@ensnode/ensnode-sdk";
19+
20+
import type { EnsApiEnvironment } from "@/config/environment";
21+
22+
export const VALID_RPC_URL = "https://eth-sepolia.g.alchemy.com/v2/1234";
23+
24+
export const ENSDB_PUBLIC_CONFIG = {
25+
versionInfo: {
26+
postgresql: "17.4",
27+
},
28+
} satisfies SerializedEnsDbPublicConfig;
29+
30+
export const ENSINDEXER_PUBLIC_CONFIG = {
31+
namespace: "mainnet",
32+
ensIndexerSchemaName: "ensindexer_0",
33+
ensRainbowPublicConfig: {
34+
serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 },
35+
versionInfo: {
36+
ensRainbow: packageJson.version,
37+
},
38+
},
39+
indexedChainIds: [1],
40+
isSubgraphCompatible: false,
41+
clientLabelSet: { labelSetId: "subgraph", labelSetVersion: 0 },
42+
plugins: [PluginName.Subgraph],
43+
versionInfo: {
44+
ensDb: packageJson.version,
45+
ensIndexer: packageJson.version,
46+
ensNormalize: "1.11.1",
47+
ponder: "0.8.0",
48+
},
49+
} satisfies SerializedEnsIndexerPublicConfig;
50+
51+
const ENSRAINBOW_PUBLIC_CONFIG = {
52+
serverLabelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 },
53+
versionInfo: {
54+
ensRainbow: packageJson.version,
55+
},
56+
} satisfies EnsRainbowPublicConfig;
57+
58+
export const INDEXING_STATUS = {
59+
strategy: CrossChainIndexingStrategyIds.Omnichain,
60+
slowestChainIndexingCursor: 1777147427,
61+
snapshotTime: 1777147440,
62+
omnichainSnapshot: {
63+
omnichainStatus: OmnichainIndexingStatusIds.Following,
64+
chains: {
65+
"1": {
66+
chainStatus: ChainIndexingStatusIds.Following,
67+
config: {
68+
rangeType: RangeTypeIds.LeftBounded,
69+
startBlock: {
70+
timestamp: 1489165544,
71+
number: 3327417,
72+
},
73+
},
74+
latestIndexedBlock: {
75+
timestamp: 1777147427,
76+
number: 24959286,
77+
},
78+
latestKnownBlock: {
79+
timestamp: 1777147427,
80+
number: 24959286,
81+
},
82+
},
83+
},
84+
omnichainIndexingCursor: 1777147427,
85+
},
86+
} satisfies SerializedCrossChainIndexingStatusSnapshot;
87+
88+
export const ENSINDEXER_STACK_INFO = {
89+
ensDb: ENSDB_PUBLIC_CONFIG,
90+
ensIndexer: ENSINDEXER_PUBLIC_CONFIG,
91+
ensRainbow: ENSRAINBOW_PUBLIC_CONFIG,
92+
} satisfies SerializedEnsIndexerStackInfo;
93+
94+
export const INDEXING_METADATA_CONTEXT = {
95+
statusCode: IndexingMetadataContextStatusCodes.Initialized,
96+
indexingStatus: INDEXING_STATUS,
97+
stackInfo: ENSINDEXER_STACK_INFO,
98+
} satisfies SerializedIndexingMetadataContextInitialized;
99+
100+
export const indexingMetadataContextInitialized = deserializeIndexingMetadataContext(
101+
INDEXING_METADATA_CONTEXT,
102+
) as IndexingMetadataContextInitialized;
103+
104+
export const BASE_ENV = {
105+
ENSDB_URL: "postgresql://user:password@localhost:5432/mydb",
106+
ENSINDEXER_SCHEMA_NAME: "ensindexer_0",
107+
RPC_URL_1: VALID_RPC_URL,
108+
} satisfies EnsApiEnvironment;

0 commit comments

Comments
 (0)