Skip to content

perf(api): hot-first routing + filter-context for get_actions account polls#176

Merged
igorls merged 3 commits into
devfrom
perf/get-actions-hot-first
Jun 3, 2026
Merged

perf(api): hot-first routing + filter-context for get_actions account polls#176
igorls merged 3 commits into
devfrom
perf/get-actions-hot-first

Conversation

@igorls
Copy link
Copy Markdown
Member

@igorls igorls commented Jun 3, 2026

Problem

Unbounded, newest-first get_actions account polls — e.g. ?account=eosio.token&limit=100 (desc, no time bound) — are a top search-CPU cost on history nodes. The query fans out across every <chain>-action-* shard, including old/warm ones, even though the latest N actions all live in the newest partition. On busy contracts (eosio.token on WAX) this is hammered by external pollers and lights up warm nodes that should never be touched for "latest 100".

Reported in the field by another operator monitoring inbound queries; the exact query is the standard should over notified / receipts.receiver / act.authorization.actor sorted by global_sequence desc.

Changes

Tier 1 — filter context (always on, behavior-preserving)

get_actions always sorts by global_sequence (never _score), so scoring the account should-array, generic term/range filters, and the code:name filter was wasted work on every poll. Moved them from bool.must / root-level should into bool.filter:

  • Identical result setsaccount AND generics AND (>=1 code-filter) — minus BM25 scoring, and now cacheable.
  • must_not left unchanged.

Tier 2 — hot-first routing (opt-in, default off)

New api.hot_first_actions (default false) + api.hot_first_window (default 2). When enabled, an eligible poll searches the newest window partitions first and widens to the full <chain>-action-* set only if that window returns fewer than limit hits.

  • Heavy pollers (eosio.token) fill the window → never fan out to old/warm shards.
  • Sparse / old-only accounts stay correct via the fallback (the newest N by global_sequence are provably in the newest partitions, so a full window = the true top N).
  • Eligible only for the default global_sequence-desc sort with skip=0 and no after/before. Bounded queries, pagination, asc, custom sortedBy, and explicit hot_only keep their existing path.
  • resolveHotIndices() resolves the newest physical partitions via a TTL-cached (30s), stampede-safe _cat/indices lookup and degrades to the <chain>-action-* wildcard on any error — never fails a request.

Bonus: repair ?hot_only=true

It previously targeted a <chain>-action alias that nothing in the codebase ever creates (would throw index_not_found). It now routes through the same resolver, so it actually works.

Operator usage (no reindex required)

"api": { "hot_first_actions": true, "hot_first_window": 2 }

Caveat

When hot-first is satisfied, the response total reflects the hot window only. These polls already cap total at 10k, so impact is low — worth a changelog line.

Testing

  • tsc --noEmit clean.
  • bun test tests/unit118 pass (12 new): filter-context.test.ts pins the filter-context placement; hot-index.test.ts covers resolver join order, caching/dedup, and wildcard fallback on error/empty.

Scope / follow-ups

  • Eligibility intentionally scoped to account polls (matches the reported query). Correct for filter-only / firehose polls too — easy to broaden later.
  • v1 get_actions has a separate older query path; out of scope here.
  • Docs: hot_first_actions / hot_first_window need an entry in eosrio/hyperion-docs when this ships.

igorls added 2 commits June 3, 2026 01:04
…ontext

get_actions always sorts by global_sequence (never _score), so scoring the account
should-array, generic term/range filters, and the code:name filter was wasted work on
every poll. Move them from bool.must / root-level should into bool.filter: identical result
sets (account AND generics AND >=1 code-filter), minus BM25 scoring, and now cacheable.
must_not is unchanged. Adds filter-context.test.ts pinning the placement.
…(default off)

An unbounded newest-first account poll (e.g. account=eosio.token, desc, no time bound)
only needs the most recent actions, which live in the newest action partition(s) -- yet today
it fans out across every <chain>-action-* shard, including old/warm ones, which is a top
search-CPU cost on history nodes.

Add opt-in hot-first routing (api.hot_first_actions, default off; api.hot_first_window,
default 2): search the newest partition(s) first and widen to the full set only if that
window returns fewer than 'limit' hits. Heavy pollers never touch old shards; sparse/old-only
accounts stay correct via the fallback. Eligible only for the default global_sequence-desc
sort with skip=0 and no time bound -- bounded queries, pagination, asc, custom sortedBy, and
explicit hot_only keep their existing path.

resolveHotIndices() resolves the newest physical partitions via a TTL-cached, stampede-safe
_cat/indices lookup and degrades to the <chain>-action-* wildcard on any error.

Also repairs ?hot_only=true, which targeted a <chain>-action alias the codebase never
creates (it would throw index_not_found); it now routes through the same resolver.
Copilot AI review requested due to automatic review settings June 3, 2026 04:04
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces "hot-first" routing for unbounded get_actions polls, optimizing performance by searching the newest index partitions first and only widening to the full wildcard set if necessary. It also refactors several Elasticsearch query builders to use filter context instead of scoring context to avoid unnecessary scoring overhead, and adds corresponding unit tests. Feedback highlights two key improvements: preventing a redundant fallback query when the hot index resolution degrades to the wildcard fallback, and safely checking if the Elasticsearch cat.indices response is an array to avoid potential runtime type errors.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +93 to +98
if (esResults.hits.hits.length < size) {
// The account is sparse within the hot window — widen to the full set for correctness.
esResults = await fastify.elastic.search<any>(esOpts);
} else {
hotFirstUsed = true;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

When resolveHotIndices degrades to the wildcard fallback (e.g., due to Elasticsearch errors or lack of physical indices), hotIndex will be identical to indexPattern (both being ${chain}-action-*). Under this condition, if the first search returns fewer than size hits, the fallback search is executed on the exact same wildcard pattern again.\n\nThis results in a redundant, heavy wildcard query on the entire cluster, doubling the load precisely when Elasticsearch might be struggling.\n\nWe should check if hotIndex !== indexPattern before executing the fallback query.

Suggested change
if (esResults.hits.hits.length < size) {
// The account is sparse within the hot window — widen to the full set for correctness.
esResults = await fastify.elastic.search<any>(esOpts);
} else {
hotFirstUsed = true;
}
if (esResults.hits.hits.length < size && hotIndex !== indexPattern) {
// The account is sparse within the hot window — widen to the full set for correctness.
esResults = await fastify.elastic.search<any>(esOpts);
} else {
hotFirstUsed = hotIndex !== indexPattern;
}

Comment thread src/api/helpers/hot-index.ts Outdated
Comment on lines +57 to +60
const names = (records as Array<{ index?: string }>)
.map(r => r.index)
.filter((n): n is string => typeof n === 'string' && n.length > 0)
.slice(0, win);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Depending on the Elasticsearch client version or configuration, cat.indices might return a wrapped response (e.g., { body: [...] } in v7) or a non-array structure under certain error/empty states. Casting records directly to an array and calling .map without checking if it is an array can lead to a runtime TypeError: records.map is not a function.\n\nUsing Array.isArray(records) ensures safe degradation to the fallback wildcard without throwing uncaught exceptions.

Suggested change
const names = (records as Array<{ index?: string }>)
.map(r => r.index)
.filter((n): n is string => typeof n === 'string' && n.length > 0)
.slice(0, win);
const names = (Array.isArray(records) ? records : [])
.map(r => r.index)
.filter((n): n is string => typeof n === 'string' && n.length > 0)
.slice(0, win);

Copy link
Copy Markdown

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

This PR reduces Elasticsearch CPU and shard fan-out for high-frequency, newest-first v2/get_actions account polls by moving non-scoring clauses into filter context and adding an opt-in “hot-first” index routing strategy that queries only the newest action partitions first.

Changes:

  • Move get_actions account/generic/code:name clauses from scoring context (must/root should) into bool.filter to avoid unnecessary scoring and enable caching.
  • Add opt-in “hot-first” routing (api.hot_first_actions, api.hot_first_window) using a TTL-cached, stampede-safe resolver of newest physical action indices, with safe wildcard fallback.
  • Add unit tests pinning filter-context placement and hot-index resolver behavior; update config schema/reference defaults.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/unit/hot-index.test.ts Adds unit coverage for hot-index resolution, caching, and wildcard fallback behavior.
tests/unit/filter-context.test.ts Pins get_actions query clause placement in bool.filter to prevent regressions back to scoring context.
src/interfaces/hyperionConfig.ts Adds config interface + Zod schema for hot_first_actions and hot_first_window.
src/api/routes/v2-history/get_actions/get_actions.ts Implements hot-only repair and opt-in hot-first routing for eligible account polls.
src/api/routes/v2-history/get_actions/functions.ts Moves generic/account/code-action clauses into filter context and preserves semantics with explicit minimum_should_match.
src/api/helpers/hot-index.ts Introduces resolver for newest physical indices with TTL caching and stampede protection.
references/config.ref.json Documents new config defaults (hot_first_actions, hot_first_window).

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

Comment on lines +90 to +101
if (hotFirstEligible) {
const hotIndex = await resolveHotIndices(fastify, 'action', hotWindow);
esResults = await fastify.elastic.search<any>({...esOpts, index: hotIndex});
if (esResults.hits.hits.length < size) {
// The account is sparse within the hot window — widen to the full set for correctness.
esResults = await fastify.elastic.search<any>(esOpts);
} else {
hotFirstUsed = true;
}
} else {
esResults = await fastify.elastic.search<any>(esOpts);
}
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b258df1fac

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 172 to 174
(queryStruct.bool.filter ??= []).push({
match: _qObj
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve scoring when clients request _score sort

When a request uses sortedBy=_score:desc (which addSortedBy still accepts), moving the @transfer.memo match query into bool.filter removes all scoring for that clause, so Elasticsearch returns matching actions with essentially arbitrary/tie-broken scores instead of the previous relevance order. This is especially visible for memo searches using fuzziness/operator options, because the result set remains the same but the requested _score ordering is no longer meaningful.

Useful? React with 👍 / 👎.

Comment on lines +51 to +55
const records = await fastify.elastic.cat.indices({
index: fallback,
h: 'index',
s: 'index:desc',
format: 'json'
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restrict hot resolution to the active index version

When a cluster contains multiple action index versions during/after a reindex, resolving <chain>-action-* by lexicographic index:desc can pick a higher version's low/old partition (for example chain-action-v2-000001) ahead of the live latest partition from another version (for example chain-action-v1-000999). With hot_first_actions enabled, if that selected window returns limit hits, the fallback full search never runs, so account polls can omit the true latest actions from the full wildcard result set.

Useful? React with 👍 / 👎.

- get_actions: when the resolver degrades to the wildcard (hotIndex === fullPattern), run
  the full search once instead of executing the same full-pattern query twice on shortfall,
  and don't set hot_first in that case (gemini/copilot).
- hot-index: scope the _cat/indices lookup to the active index_version so a multi-version
  cluster can't surface a higher-version low partition ahead of the live latest one (codex).
- hot-index: guard the _cat response with Array.isArray before .map (gemini).
- functions: keep the @transfer.memo full-text match in scoring context so sortedBy=_score
  still ranks by relevance; only the high-cardinality keyword clauses move to filter (codex).
- tests: cover version-scoped resolution, non-array _cat response, and memo-in-must.
@igorls
Copy link
Copy Markdown
Member Author

igorls commented Jun 3, 2026

Thanks for the reviews — all findings addressed in 0cdc4d2:

  • Redundant fallback query on degrade (gemini HIGH / copilot): when resolveHotIndices degrades to the wildcard, hotIndex === fullPattern, so the hot-first block now runs the full search once and skips the second identical query; hot_first is no longer set in that case.
  • Unsafe cat.indices cast (gemini MED): guarded with Array.isArray(records) before .map, degrading to the wildcard on any non-array response.
  • sortedBy=_score relevance (codex P2): the @transfer.memo full-text match stays in scoring context (must); only the high-cardinality keyword clauses move to bool.filter. Memo search is selective, so the scoring cost is negligible and _score ordering is preserved.
  • Multi-version reindex correctness (codex P2): the _cat/indices lookup is now scoped to the active index_version (<chain>-<type>-<version>-*), so a higher-version low partition can't sort ahead of the live latest; falls back to all versions when the version is unset.

New tests cover version-scoped resolution, a non-array _cat response, and memo-in-must. Full suite: 121 pass, tsc --noEmit clean.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0cdc4d2a68

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +101 to +102
} else {
hotFirstUsed = true;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve total hits when hot-first short-circuits

When api.hot_first_actions is enabled and the hot window returns at least limit hits for a busy account, this branch skips the full <chain>-action-* search and later exposes response.total from the hot-index-only result. For accounts with additional older matches, clients that rely on total (including track=true/numeric track, and even the default tracked count) now see only the hot-window count instead of the total for the requested query, even though the endpoint still advertises the normal full-history query semantics.

Useful? React with 👍 / 👎.

@igorls igorls merged commit 5bacf8c into dev Jun 3, 2026
2 checks passed
@igorls igorls deleted the perf/get-actions-hot-first branch June 3, 2026 04:43
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