Skip to content

fix(ensapi): use native promises with swr cache#1301

Merged
tk-o merged 11 commits intomainfrom
fix/1200-ensapi-swr-use-native-promises
Nov 25, 2025
Merged

fix(ensapi): use native promises with swr cache#1301
tk-o merged 11 commits intomainfrom
fix/1200-ensapi-swr-use-native-promises

Conversation

@tk-o
Copy link
Copy Markdown
Contributor

@tk-o tk-o commented Nov 24, 2025

Please review this PR with Hide whitespaces option on.

Suggested review order:

  1. ENSNode SDK (changed the staleWhileRevalidate function input type).
  • packages/ensnode-sdk/src/shared/cache.ts
  1. ENSApi
  • apps/ensapi/src/middleware/indexing-status.middleware.ts documented ideas and guarantees around c.var.indexingStatus value, and how null being the cached value represents the state when indexing status has never been fetched successfully. Also, when the cached indexing status is available, the c.var.indexingStatus value represents the realtime indexing status projection that was created at the time of the request.
  • apps/ensapi/src/middleware/aggregated-referrer-snapshot-cache.middleware.ts updated the fn fetcher function to log a message for each time the aggregated referrer snapshot is built successfully.
  • apps/ensapi/src/middleware/is-realtime.middleware.ts dropped the obsolete logic as c.var.indexingStatus can never represent a response with responseCode: IndexingStatusResponseCodes.Error.
  • apps/ensapi/src/lib/subgraph/indexing-status-to-subgraph-meta.ts simplified the code according to the new guarantees of c.var.indexingStatus value, the c.var.indexingStatus can never represent a response with responseCode: IndexingStatusResponseCodes.Error.
  • apps/ensapi/src/lib/middleware/require-registrar-actions-plugins..middleware.ts simplified the code according to the new guarantees of c.var.indexingStatus value, the c.var.indexingStatus can never represent a response with responseCode: IndexingStatusResponseCodes.Error.
  • apps/ensapi/src/handlers/ensnode-api.ts updated code docs, added extra log message.

Context

This PR fixes an issue where the SWR cache query function would return pReflect result type, and not native promise. This caused the rejected promise to be seen as a correctly resolved one on the SWR cache level:
image

The SWR cache requires the query fn to return a native Promise object, so we cannot wrap the returned value with pReflect. On the other hand, in order to maintain the downstream logic which relies on pReflect result type, we still use pReflect, but this time on the middleware level, and not on the SWR cache query fn level.

This PR updates the c.var.IndexingStatus object type from IndexingStatusResponse to RealtimeIndexingStatusProjection. It means that all downstream request handlers won't need to check the responseCode of the cached response. Only the OK responses can get cached. The Error ones are discarded (with an appropriate error log).

The SWR cache requires `fn` to return a native Promise object, so we cannot wrap the returned value with `pReflect`. On the other hand, in order to maintain the downstream logic which relies on `pReflect` result type, we still use `pReflect`, but this time on the middleware level, and not on the cache query fn level.
@tk-o tk-o requested a review from a team as a code owner November 24, 2025 10:53
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Nov 24, 2025

🦋 Changeset detected

Latest commit: 451b076

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

This PR includes changesets to release 14 packages
Name Type
ensapi Minor
ensindexer Minor
ensadmin Minor
ensrainbow Minor
@ensnode/datasources Minor
@ensnode/ensrainbow-sdk Minor
@ensnode/ponder-metadata Minor
@ensnode/ensnode-schema Minor
@ensnode/ensnode-react Minor
@ensnode/ponder-subgraph Minor
@ensnode/ensnode-sdk Minor
@ensnode/shared-configs Minor
@docs/ensnode Minor
@docs/ensrainbow Minor

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
Copy Markdown
Contributor

vercel bot commented Nov 24, 2025

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

Project Deployment Preview Comments Updated (UTC)
admin.ensnode.io Ready Ready Preview Comment Nov 25, 2025 2:22pm
ensnode.io Ready Ready Preview Comment Nov 25, 2025 2:22pm
ensrainbow.io Ready Ready Preview Comment Nov 25, 2025 2:22pm

tk-o added 2 commits November 24, 2025 11:54
…. Make it accept optional `onResolved` and `onRejected` callaback functions.
…`responseCode: IndexingStatusResponseCodes.Ok`.
Copy link
Copy Markdown
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 Thanks for your updates here. Reviewed and shared feedback 👍

onResolved?: (value: T) => void;

/**
* Optional callback invoked when the wrapped function throws an error
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
* Optional callback invoked when the wrapped function throws an error
* Optional callback invoked when the wrapped function throws an error represented by `reason`.

*
* @param fn The async function to wrap with SWR caching
* @param ttl Time-to-live duration in seconds. After this duration, data is considered stale
* @param onResolved Optional callback invoked when the wrapped function resolves successfully
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
* @param onResolved Optional callback invoked when the wrapped function resolves successfully
* @param onResolved Optional callback invoked when `fn` resolves successfully (doesn't throw an error)

Is that fair?

* @param fn The async function to wrap with SWR caching
* @param ttl Time-to-live duration in seconds. After this duration, data is considered stale
* @param onResolved Optional callback invoked when the wrapped function resolves successfully
* @param onRejected Optional callback invoked when the wrapped function throws an error
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
* @param onRejected Optional callback invoked when the wrapped function throws an error
* @param onRejected Optional callback invoked when `fn` throws an error

Is that fair?

);
},
ttl: TTL,
onResolved() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It would be nice if onResolved had a param where it received the successfully resolved aggregated referrer snapshot.

Then, the log info message generated here could also include the sum total count of aggregated referrers in the snapshot it just built.

This will be helpful for debugging.

const indexingStatusResponse = c.var.indexingStatus.value;

if (indexingStatusResponse.responseCode === IndexingStatusResponseCodes.Error) {
return c.json(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't understand how it's possible to completely remove the logic for generating a API response with an error for the case that the registrar actions API is not available because no cache for the indexing status has ever been successfully built.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There's one if clause earlier in this scope, that checks for c.var.indexingStatus.isRejected. When it's true it means the Indexing Status has never been fetched successfully.

// reject response with 'error' responseCode
if (response.responseCode === IndexingStatusResponseCodes.Error) {
throw new Error(
"Received Indexing Status response with 'error' responseCode which will not be cached.",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
"Received Indexing Status response with 'error' responseCode which will not be cached.",
"Received Indexing Status response with 'error' responseCode. The cached indexing status snapshot (if any) will not be updated.",

fn: async () =>
client.indexingStatus().then((response) => {
// reject response with 'error' responseCode
if (response.responseCode === IndexingStatusResponseCodes.Error) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Rather than checking for error, better to check for not ok.

onRejected(reason) {
logger.error(
reason,
"Unable to fetch current indexing status. All fetch attempts have failed since service startup and no cached status is available. This may indicate the ENSIndexer service is unreachable or not responding.",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm getting confused. This error message is specifically for the case that ALL fetch attempts have failed. But as I understand, onRejected can be called after a fetch attempt was successful in initializing the cache.

// handle both success and failure cases, including error details.
const indexingStatus = await pReflect(
cachedIndexingStatus !== null
? Promise.resolve(cachedIndexingStatus)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this resolve a newly generated projection of the snapshot stored in cachedIndexingStatus?

Goal: downstream request handlers automatically get a freshly generated projection as of the current time.

If not relevant, no worries.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Will take care of that 👍

const indexingStatus = await pReflect(
cachedIndexingStatus !== null
? Promise.resolve(cachedIndexingStatus)
: Promise.reject(new Error("Unable to fetch current indexing status.")),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Where do we generate the JSON in the API response for the case that we need to return an API response error because the indexing status cache has never been able to be successfully built?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The JSON response is generated inside app.get("/indexing-status", async (c) => {}) handler in apps/ensapi/src/handlers/ensnode-api.ts file.

Document how `c.var.indexingStatus` can either represent 1) the realtime projection based on the last fetched `IndexingStatusResponseOk` value, or 2) the error while fetching the indexing status. Also, drops the `onResolved` and `onRejected` callback functions from `staleWhileRevalidate` function.
Co-authored-by: Tomasz Kopacki <tomasz@kopacki.net>
Copy link
Copy Markdown
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 Looks good 👍 😄

@tk-o tk-o merged commit 7baefbd into main Nov 25, 2025
10 checks passed
@tk-o tk-o deleted the fix/1200-ensapi-swr-use-native-promises branch November 25, 2025 15:07
@github-actions github-actions bot mentioned this pull request Nov 25, 2025
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.

2 participants