Skip to content
94 changes: 40 additions & 54 deletions apps/ensapi/src/cache/indexing-status.cache.ts
Original file line number Diff line number Diff line change
@@ -1,76 +1,62 @@
import { EnsNodeMetadataKeys } from "@ensnode/ensdb-sdk";
import { type EnsDbReader, EnsNodeMetadataKeys } from "@ensnode/ensdb-sdk";
import {
type CrossChainIndexingStatusSnapshot,
IndexingMetadataContextStatusCodes,
SWRCache,
} from "@ensnode/ensnode-sdk";

import { lazyProxy } from "@/lib/lazy";
import { makeLogger } from "@/lib/logger";

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

export type IndexingStatusCache = SWRCache<CrossChainIndexingStatusSnapshot>;

// lazyProxy defers construction until first use so that this module can be
// imported without env vars being present (e.g. during OpenAPI generation).
// SWRCache with proactivelyInitialize:true starts background polling immediately
// on construction, which would trigger ensDbClient before env vars are available.
/**
* Cache for {@link CrossChainIndexingStatusSnapshot}, which is loaded
* Build SWR Cache for {@link CrossChainIndexingStatusSnapshot}, which is loaded
* from ENSDb on demand. The cached value is expected to be updated
* very frequently, following the update frequency of
* {@link IndexingMetadataContextInitialized.indexingStatus} in ENSDb.
* Therefore, the cache is configured with a very short TTL and
* proactive revalidation interval to ensure that the cached value is
* as fresh as possible.
*/
export const indexingStatusCache = lazyProxy<IndexingStatusCache>(
() =>
new SWRCache<CrossChainIndexingStatusSnapshot>({
fn: async function loadIndexingStatusSnapshot() {
// Async import `di` here to avoid circular dependency between this cache module and the DI container module.
// NOTE: It will not be required soon, as we plan to create a factory function for this cache
// that accepts the necessary dependencies as parameters, instead of importing from the DI container.
const di = await import("@/di").then((mod) => mod.default);
const { ensDbClient } = di.context;
export function buildIndexingStatusCache(ensDbClient: EnsDbReader): IndexingStatusCache {
return new SWRCache<CrossChainIndexingStatusSnapshot>({
fn: async function loadIndexingStatusSnapshot() {
try {
const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext();

try {
const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext();

if (
indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized
) {
// The IndexingMetadataContext has not been initialized in ENSDb yet.
// This might happen during application startup, i.e. when ENSDb
// has not yet been populated with the IndexingMetadataContext record.
// Therefore, throw an error to trigger the subsequent catch handler.
throw new Error("Indexing Metadata Context was uninitialized in ENSDb.");
}

// The CrossChainIndexingStatusSnapshot has been successfully loaded for caching.
// Therefore, return it so that this current invocation of `readCache` will:
// - Replace the currently cached value (if any) with this new value.
// - Return this non-null value.
return indexingMetadataContext.indexingStatus;
} catch (error) {
// IndexingMetadataContext was uninitialized in ENSDb.
// Therefore, throw an error so that this current invocation of `readCache` will:
// - Reject the newly fetched response (if any) such that it won't be cached.
// - Return the most recently cached value from prior invocations, or `null` if no prior invocation successfully cached a value.
logger.error(
error,
`Error occurred while loading Indexing Metadata Context record from ENSNode Metadata table in ENSDb. ` +
`Where clause applied: ("ensIndexerSchemaName" = "${ensDbClient.ensIndexerSchemaName}", "key" = "${EnsNodeMetadataKeys.IndexingMetadataContext}"). ` +
`The cached indexing status snapshot (if any) will not be updated.`,
);
throw error;
if (indexingMetadataContext.statusCode !== IndexingMetadataContextStatusCodes.Initialized) {
// The IndexingMetadataContext has not been initialized in ENSDb yet.
// This might happen during application startup, i.e. when ENSDb
// has not yet been populated with the IndexingMetadataContext record.
// Therefore, throw an error to trigger the subsequent catch handler.
throw new Error("Indexing Metadata Context was uninitialized in ENSDb.");
}
},
// We need to refresh the indexing status cache very frequently.
// ENSDb won't have issues handling this frequency of queries.
ttl: 1, // 1 second
proactiveRevalidationInterval: 1, // 1 second
proactivelyInitialize: true,
}),
);

// The CrossChainIndexingStatusSnapshot has been successfully loaded for caching.
// Therefore, return it so that this current invocation of `readCache` will:
// - Replace the currently cached value (if any) with this new value.
// - Return this non-null value.
return indexingMetadataContext.indexingStatus;
} catch (error) {
// IndexingMetadataContext was uninitialized in ENSDb.
// Therefore, throw an error so that this current invocation of `readCache` will:
// - Reject the newly fetched response (if any) such that it won't be cached.
// - Return the most recently cached value from prior invocations, or `null` if no prior invocation successfully cached a value.
logger.error(
error,
`Error occurred while loading Indexing Metadata Context record from ENSNode Metadata table in ENSDb. ` +
`Where clause applied: ("ensIndexerSchemaName" = "${ensDbClient.ensIndexerSchemaName}", "key" = "${EnsNodeMetadataKeys.IndexingMetadataContext}"). ` +
`The cached indexing status snapshot (if any) will not be updated.`,
);
throw error;
}
},
// We need to refresh the indexing status cache very frequently.
// ENSDb won't have issues handling this frequency of queries.
ttl: 1, // 1 second
proactiveRevalidationInterval: 1, // 1 second
proactivelyInitialize: true,
});
}
3 changes: 1 addition & 2 deletions apps/ensapi/src/cache/referral-edition-snapshots.cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ import { assumeReferralProgramEditionImmutablyClosed } from "@/lib/ensanalytics/
import { getReferralEditionSnapshot } from "@/lib/ensanalytics/referrer-leaderboard/get-referral-edition-snapshot";
import { makeLogger } from "@/lib/logger";

import { indexingStatusCache } from "./indexing-status.cache";

const logger = makeLogger("referral-edition-snapshots-cache");

/**
Expand Down Expand Up @@ -79,6 +77,7 @@ function createEditionSnapshotBuilder(
);
}

const { indexingStatusCache } = di.context;
const indexingStatus = await indexingStatusCache.read();
if (indexingStatus instanceof Error) {
logger.error(
Expand Down
124 changes: 54 additions & 70 deletions apps/ensapi/src/cache/referral-program-edition-set.cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { minutesToSeconds } from "date-fns";

import { type CachedResult, SWRCache } from "@ensnode/ensnode-sdk";

import { lazyProxy } from "@/lib/lazy";
import { makeLogger } from "@/lib/logger";

const logger = makeLogger("referral-program-edition-set-cache");
Expand All @@ -21,83 +20,68 @@ function partiallyRedactUrl(url: URL): string {
return `${url.origin}${url.pathname}`;
}

/**
* Loads the referral program edition config set from the configured URL.
*
* If no URL is configured, the referral program is treated as having zero configured editions
* and no network or ENSDb work is performed.
*/
async function loadReferralProgramEditionConfigSet(
_cachedResult?: CachedResult<ReferralProgramEditionConfigSet>,
): Promise<ReferralProgramEditionConfigSet> {
// Async import `di` here to avoid circular dependency between this cache module and the DI container module.
// NOTE: It will not be required soon, as we plan to create a factory function for this cache
// that accepts the necessary dependencies as parameters, instead of importing from the DI container.
const di = await import("@/di").then((mod) => mod.default);
const { referralProgramEditionConfigSetUrl } = di.context.ensApiConfig;

// If no URL is configured, treat the referral program as having zero editions.
if (!referralProgramEditionConfigSetUrl) {
logger.info(
"REFERRAL_PROGRAM_EDITIONS is not set; referral program edition config set is empty",
);
return buildReferralProgramEditionConfigSet([]);
}

const logSafeUrl = partiallyRedactUrl(referralProgramEditionConfigSetUrl);

logger.info(`Loading referral program edition config set from: ${logSafeUrl}`);
try {
const editionConfigSet = await ENSReferralsClient.getReferralProgramEditionConfigSet(
referralProgramEditionConfigSetUrl,
);

// Strip any unrecognized editions immediately — they are client-side forward-compatibility
// placeholders that must never enter the server's operational config set (they can't be
// serialized and would cause API handlers to crash).
for (const [slug, editionConfig] of editionConfigSet) {
if (editionConfig.rules.awardModel === ReferralProgramAwardModels.Unrecognized) {
logger.warn(
{ editionSlug: slug, originalAwardModel: editionConfig.rules.originalAwardModel },
`Skipping edition with unrecognized award model`,
);
editionConfigSet.delete(slug);
}
}

logger.info(`Successfully loaded ${editionConfigSet.size} referral program editions`);
return editionConfigSet;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(error, "Error occurred while loading referral program edition config set");
throw new Error(
`Failed to load referral program edition config set from ${logSafeUrl}: ${errorMessage}`,
);
}
}

export type ReferralProgramEditionConfigSetCache = SWRCache<ReferralProgramEditionConfigSet>;

/**
* SWR Cache for the referral program edition config set.
* Build SWR Cache for the referral program edition config set.
*
* Once successfully loaded, the edition config set is cached indefinitely and never revalidated.
* This ensures the JSON is only fetched once during the application lifecycle.
*
* Configuration:
* - ttl: Infinity - Never expires once cached
* - proactiveRevalidationInterval: undefined - No proactive revalidation
* - proactivelyInitialize: true - Load immediately on startup
* - proactivelyInitialize: true - Load immediately when the cache is constructed
*/
// lazyProxy defers construction until first use so that this module can be
// imported without env vars being present (e.g. during OpenAPI generation).
export const referralProgramEditionConfigSetCache = lazyProxy<ReferralProgramEditionConfigSetCache>(
() =>
new SWRCache<ReferralProgramEditionConfigSet>({
fn: loadReferralProgramEditionConfigSet,
ttl: Number.POSITIVE_INFINITY,
errorTtl: minutesToSeconds(1),
proactiveRevalidationInterval: undefined,
proactivelyInitialize: true,
}),
);
export function buildReferralProgramEditionConfigSetCache(
referralProgramEditionConfigSetUrl: URL | undefined,
): ReferralProgramEditionConfigSetCache {
return new SWRCache<ReferralProgramEditionConfigSet>({
fn: async function loadReferralProgramEditionConfigSet(
_cachedResult?: CachedResult<ReferralProgramEditionConfigSet>,
): Promise<ReferralProgramEditionConfigSet> {
// If no URL is configured, treat the referral program as having zero editions.
if (!referralProgramEditionConfigSetUrl) {
logger.info(
"REFERRAL_PROGRAM_EDITIONS is not set; referral program edition config set is empty",
);
return buildReferralProgramEditionConfigSet([]);
}

const logSafeUrl = partiallyRedactUrl(referralProgramEditionConfigSetUrl);

logger.info(`Loading referral program edition config set from: ${logSafeUrl}`);
try {
const editionConfigSet = await ENSReferralsClient.getReferralProgramEditionConfigSet(
referralProgramEditionConfigSetUrl,
);

// Strip any unrecognized editions immediately — they are client-side forward-compatibility
// placeholders that must never enter the server's operational config set (they can't be
// serialized and would cause API handlers to crash).
for (const [slug, editionConfig] of editionConfigSet) {
if (editionConfig.rules.awardModel === ReferralProgramAwardModels.Unrecognized) {
logger.warn(
{ editionSlug: slug, originalAwardModel: editionConfig.rules.originalAwardModel },
`Skipping edition with unrecognized award model`,
);
editionConfigSet.delete(slug);
}
}

logger.info(`Successfully loaded ${editionConfigSet.size} referral program editions`);
return editionConfigSet;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(error, "Error occurred while loading referral program edition config set");
throw new Error(
`Failed to load referral program edition config set from ${logSafeUrl}: ${errorMessage}`,
);
}
},
ttl: Number.POSITIVE_INFINITY,
errorTtl: minutesToSeconds(1),
proactiveRevalidationInterval: undefined,
proactivelyInitialize: true,
});
}
Loading
Loading