Skip to content

Commit 5bacf8c

Browse files
authored
Merge pull request #176 from eosrio/perf/get-actions-hot-first
perf(api): hot-first routing + filter-context for get_actions account polls
2 parents 3aeb3ce + 0cdc4d2 commit 5bacf8c

7 files changed

Lines changed: 330 additions & 13 deletions

File tree

references/config.ref.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
"rate_limit_allow": [],
3939
"disable_tx_cache": false,
4040
"tx_cache_expiration_sec": 3600,
41+
"hot_first_actions": false,
42+
"hot_first_window": 2,
4143
"v1_chain_cache": [
4244
{"path": "get_block","ttl": 3000},
4345
{"path": "get_info","ttl": 500}

src/api/helpers/hot-index.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import {FastifyInstance} from "fastify";
2+
import {hLog} from "../../indexer/helpers/common_functions.js";
3+
4+
// How long a resolved hot-index set is reused before re-querying ES. Short enough that a
5+
// freshly rolled-over partition is picked up quickly; long enough that high-frequency polling
6+
// does not turn into a _cat/indices storm.
7+
const HOT_INDEX_TTL_MS = 30_000;
8+
9+
interface CacheEntry {
10+
value: string;
11+
expires: number;
12+
// Shared in-flight refresh so concurrent requests past expiry trigger a single _cat lookup.
13+
inflight?: Promise<string>;
14+
}
15+
16+
const cache = new Map<string, CacheEntry>();
17+
18+
/**
19+
* Resolve the newest `window` physical partitions of a tiered type (`<chain>-<type>-<version>-<part>`)
20+
* as a comma-joined index string for "hot-first" searches. Physical index names sort lexicographically
21+
* by recency (zero-padded partition, version-prefixed), so the newest `window` names are simply the
22+
* top of a descending sort.
23+
*
24+
* The result is cached per (chain, type, window) with a short TTL and a shared in-flight promise so a
25+
* burst of polls past expiry costs one `_cat/indices` call. On any error — or when no physical index
26+
* matches yet — it degrades to the `<chain>-<type>-*` wildcard, so callers always get a searchable
27+
* target and never fail because of this optimization.
28+
*/
29+
export async function resolveHotIndices(
30+
fastify: FastifyInstance,
31+
type: 'action' | 'delta',
32+
window: number
33+
): Promise<string> {
34+
const chain = fastify.manager.chain;
35+
const win = Math.max(1, Math.floor(window));
36+
const key = `${chain}-${type}-${win}`;
37+
const fallback = `${chain}-${type}-*`;
38+
// Scope the lookup to the active index_version so that on a multi-version cluster (during/after a
39+
// reindex) a higher-version low partition (e.g. <chain>-<type>-v2-000001) can't sort ahead of the
40+
// live latest partition of the running version and cause newest actions to be missed. When the
41+
// version is unknown, fall back to all versions (correctness is still preserved by the caller's
42+
// widen-on-shortfall step).
43+
const version = fastify.manager.config?.settings?.index_version;
44+
const searchPattern = version ? `${chain}-${type}-${version}-*` : fallback;
45+
const now = Date.now();
46+
47+
const cached = cache.get(key);
48+
if (cached && cached.expires > now) {
49+
return cached.value;
50+
}
51+
// A refresh is already running — ride along instead of issuing a second _cat call.
52+
if (cached?.inflight) {
53+
return cached.inflight;
54+
}
55+
56+
const inflight = (async () => {
57+
try {
58+
const records = await fastify.elastic.cat.indices({
59+
index: searchPattern,
60+
h: 'index',
61+
s: 'index:desc',
62+
format: 'json'
63+
});
64+
// Some client/transport configurations can return a non-array under error/empty states;
65+
// guard so we degrade to the wildcard rather than throwing on .map.
66+
const names = (Array.isArray(records) ? records : [])
67+
.map((r: { index?: string }) => r.index)
68+
.filter((n): n is string => typeof n === 'string' && n.length > 0)
69+
.slice(0, win);
70+
const value = names.length > 0 ? names.join(',') : fallback;
71+
cache.set(key, {value, expires: Date.now() + HOT_INDEX_TTL_MS});
72+
return value;
73+
} catch (e: any) {
74+
// Degrade to the wildcard and cache it briefly so a flapping cluster does not get
75+
// hammered with _cat retries on every request.
76+
hLog(`hot-index resolve failed for ${key}, using wildcard: ${e?.message ?? e}`);
77+
cache.set(key, {value: fallback, expires: Date.now() + HOT_INDEX_TTL_MS});
78+
return fallback;
79+
}
80+
})();
81+
82+
// Publish the in-flight promise (keeping any stale value for readers that prefer it) so
83+
// concurrent callers dedupe onto this single refresh.
84+
cache.set(key, {value: cached?.value ?? fallback, expires: cached?.expires ?? 0, inflight});
85+
return inflight;
86+
}

src/api/routes/v2-history/get_actions/functions.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,20 @@ export function processMultiVars(queryStruct, parts, field) {
2626
});
2727

2828
if (must.length > 1) {
29-
queryStruct.bool.must.push({
29+
(queryStruct.bool.filter ??= []).push({
3030
bool: {
3131
should: must.map(elem => {
3232
const _q = {};
3333
_q[field] = elem;
3434
return {term: _q}
35-
})
35+
}),
36+
minimum_should_match: 1
3637
}
3738
});
3839
} else if (must.length === 1) {
3940
const mustQuery = {};
4041
mustQuery[field] = must[0];
41-
queryStruct.bool.must.push({term: mustQuery});
42+
(queryStruct.bool.filter ??= []).push({term: mustQuery});
4243
}
4344

4445
if (mustNot.length > 1) {
@@ -65,7 +66,7 @@ function addRangeQuery(queryStruct, prop, pkey, query) {
6566
"gte": parts[0],
6667
"lte": parts[1]
6768
};
68-
queryStruct.bool.must.push({range: _termQuery});
69+
(queryStruct.bool.filter ??= []).push({range: _termQuery});
6970
}
7071

7172
// A bound is a block number when it is a bare positive integer; any other value
@@ -168,6 +169,11 @@ export function applyGenericFilters(query, queryStruct, allowedExtraParams: Set<
168169
_qObj[pkey].operator = query.match_operator;
169170
}
170171

172+
// Keep the memo full-text match in scoring context: it is the only
173+
// relevance-bearing clause, so an explicit sortedBy=_score (with
174+
// fuzziness/operator) must still rank by it. It is selective and rare,
175+
// so its scoring cost is negligible — unlike the high-cardinality
176+
// keyword clauses moved to filter context.
171177
queryStruct.bool.must.push({
172178
match: _qObj
173179
});
@@ -177,15 +183,15 @@ export function applyGenericFilters(query, queryStruct, allowedExtraParams: Set<
177183
andParts.forEach(value => {
178184
const _q = {};
179185
_q[pkey] = value;
180-
queryStruct.bool.must.push({term: _q});
186+
(queryStruct.bool.filter ??= []).push({term: _q});
181187
});
182188
} else {
183189
if (parts[0].startsWith("!")) {
184190
_qObj[pkey] = parts[0].replace("!", "");
185191
queryStruct.bool.must_not.push({term: _qObj});
186192
} else {
187193
_qObj[pkey] = parts[0];
188-
queryStruct.bool.must.push({term: _qObj});
194+
(queryStruct.bool.filter ??= []).push({term: _qObj});
189195
}
190196
}
191197
}
@@ -228,8 +234,9 @@ export function applyCodeActionFilters(query, queryStruct) {
228234
}
229235
}
230236
if (filterObj.length > 0) {
231-
queryStruct.bool['should'] = filterObj;
232-
queryStruct.bool['minimum_should_match'] = 1;
237+
// Code:name filter in filter context (was a scoring root-level should+msm). Semantics
238+
// are identical — "match >= 1 of the code:name pairs" — minus the wasted scoring.
239+
(queryStruct.bool.filter ??= []).push({bool: {should: filterObj, minimum_should_match: 1}});
233240
}
234241
}
235242
}
@@ -306,6 +313,10 @@ export function getSortDir(query, maxAscWindowDays = 90) {
306313

307314
export function applyAccountFilters(query, queryStruct) {
308315
if (query.account) {
309-
queryStruct.bool.must.push({"bool": {should: makeShouldArray(query)}});
316+
// Filter context: the account match is a pure include and results are sorted by
317+
// global_sequence (never _score), so scoring this should-clause across millions of docs
318+
// is wasted work. filter context skips scoring and is cacheable. minimum_should_match is
319+
// explicit (a should-only bool defaults to 1, but filter context makes it worth stating).
320+
(queryStruct.bool.filter ??= []).push({bool: {should: makeShouldArray(query), minimum_should_match: 1}});
310321
}
311322
}

src/api/routes/v2-history/get_actions/get_actions.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {FastifyInstance, FastifyReply, FastifyRequest} from "fastify";
22
import {getTrackTotalHits, mergeActionMeta, timedQuery} from "../../../helpers/functions.js";
3+
import {resolveHotIndices} from "../../../helpers/hot-index.js";
34
import {
45
addSortedBy,
56
applyAccountFilters,
@@ -47,22 +48,63 @@ async function getActions(fastify: FastifyInstance, request: FastifyRequest) {
4748
addSortedBy(query, query_body, sort_direction);
4849

4950
// Perform search
51+
const fullPattern = fastify.manager.chain + '-action-*';
52+
const hotWindow = fastify.manager.config.api.hot_first_window ?? 2;
5053

51-
let indexPattern = fastify.manager.chain + '-action-*';
54+
let indexPattern = fullPattern;
5255
if (query.hot_only) {
53-
indexPattern = fastify.manager.chain + '-action';
56+
// hot_only restricts the search to the newest action partition(s), resolved from the live
57+
// index set. (The legacy `<chain>-action` alias this used to target is never created, so the
58+
// old behavior threw index_not_found; resolveHotIndices degrades to the wildcard on failure.)
59+
indexPattern = await resolveHotIndices(fastify, 'action', hotWindow);
5460
}
5561

5662
const queryTimeout = fastify.manager.config.api.query_timeout || '10s';
63+
const size = (limit > maxActions ? maxActions : limit) || 10;
5764
const esOpts = {
5865
"index": indexPattern,
5966
"from": skip || 0,
60-
"size": (limit > maxActions ? maxActions : limit) || 10,
67+
"size": size,
6168
"timeout": queryTimeout,
6269
...query_body
6370
};
6471

65-
const esResults = await fastify.elastic.search<any>(esOpts);
72+
// Hot-first routing (opt-in via api.hot_first_actions): an unbounded, newest-first account poll
73+
// only ever needs the most recent actions, which live in the newest partition(s). Search the hot
74+
// window first and widen to the full <chain>-action-* set only if it returns fewer than `size`
75+
// hits — so heavy pollers (e.g. account=eosio.token) never fan out across old/warm shards. Only
76+
// the default global_sequence-desc sort qualifies; bounded queries, pagination (skip>0), asc
77+
// sorts, custom sortedBy, and explicit hot_only stay on their existing path.
78+
const hotFirstEligible =
79+
fastify.manager.config.api.hot_first_actions === true &&
80+
!query.hot_only &&
81+
!!query.account &&
82+
!query.sortedBy &&
83+
sort_direction === 'desc' &&
84+
!query.after &&
85+
!query.before &&
86+
(skip || 0) === 0;
87+
88+
let esResults;
89+
let hotFirstUsed = false;
90+
if (hotFirstEligible) {
91+
const hotIndex = await resolveHotIndices(fastify, 'action', hotWindow);
92+
if (hotIndex === fullPattern) {
93+
// Resolver degraded to the wildcard — phase 1 would already be the full search, so run it
94+
// once and don't claim the hot-first path (no redundant second query, accurate flag).
95+
esResults = await fastify.elastic.search<any>(esOpts);
96+
} else {
97+
esResults = await fastify.elastic.search<any>({...esOpts, index: hotIndex});
98+
if (esResults.hits.hits.length < size) {
99+
// The account is sparse within the hot window — widen to the full set for correctness.
100+
esResults = await fastify.elastic.search<any>(esOpts);
101+
} else {
102+
hotFirstUsed = true;
103+
}
104+
}
105+
} else {
106+
esResults = await fastify.elastic.search<any>(esOpts);
107+
}
66108

67109
const results = esResults.hits;
68110
const response: any = {
@@ -74,6 +116,9 @@ async function getActions(fastify: FastifyInstance, request: FastifyRequest) {
74116
if (query.hot_only) {
75117
response.hot_only = true;
76118
}
119+
if (hotFirstUsed) {
120+
response.hot_first = true;
121+
}
77122

78123
if (query.checkLib) {
79124
response.lib = (await fastify.antelope.chain.get_info()).last_irreversible_block_num;

src/interfaces/hyperionConfig.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,12 @@ interface ApiConfigs {
168168
explorer?: ExplorerConfigs;
169169
query_timeout?: string; // ES search timeout (e.g., "5s"), default: "10s"
170170
max_asc_window_days?: number; // max range in days for sort=asc queries, default: 90
171+
// Hot-first routing for unbounded latest-N get_actions polls (e.g. account=eosio.token,
172+
// desc, no time bound). When enabled, the recent action partition(s) are searched first and
173+
// the full <chain>-action-* set is queried only if that window returns fewer than `limit`
174+
// hits — so heavy polling never fans out to old/warm shards. Default off.
175+
hot_first_actions?: boolean;
176+
hot_first_window?: number; // number of newest action partitions in the hot window, default: 2
171177
}
172178

173179
interface ExplorerConfigs {
@@ -324,6 +330,8 @@ export const HyperionApiConfigSchema = z.object({
324330
explorer: ExplorerConfigsSchema.optional(),
325331
query_timeout: z.string().optional(),
326332
max_asc_window_days: z.number().optional(),
333+
hot_first_actions: z.boolean().optional(),
334+
hot_first_window: z.number().optional(),
327335
});
328336

329337
// Zod schema for tiered index allocation settings

tests/unit/filter-context.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, it, expect } from 'bun:test';
2+
import {
3+
applyAccountFilters,
4+
applyGenericFilters,
5+
applyCodeActionFilters
6+
} from '../../src/api/routes/v2-history/get_actions/functions.js';
7+
8+
// get_actions builds its query with these clauses in *filter* context (not must/should), because
9+
// results are always sorted by global_sequence and never by _score — so scoring is wasted work.
10+
// These tests pin that placement so a regression back to scoring context is caught.
11+
12+
const newQueryStruct = () => ({ bool: { must: [] as any[], must_not: [] as any[], boost: 1.0 } });
13+
14+
describe('applyAccountFilters — filter context', () => {
15+
it('puts the account should-array in bool.filter with minimum_should_match, not bool.must', () => {
16+
const qs = newQueryStruct();
17+
applyAccountFilters({ account: 'eosio.token' }, qs);
18+
19+
expect(qs.bool.must).toHaveLength(0);
20+
expect(qs.bool.filter).toHaveLength(1);
21+
const clause = qs.bool.filter[0];
22+
expect(clause.bool.minimum_should_match).toBe(1);
23+
// notified, receipts.receiver, act.authorization.actor — all bound to the account
24+
expect(clause.bool.should).toEqual([
25+
{ term: { notified: 'eosio.token' } },
26+
{ term: { 'receipts.receiver': 'eosio.token' } },
27+
{ term: { 'act.authorization.actor': 'eosio.token' } }
28+
]);
29+
});
30+
31+
it('is a no-op when no account is given', () => {
32+
const qs = newQueryStruct();
33+
applyAccountFilters({}, qs);
34+
expect(qs.bool.filter).toBeUndefined();
35+
expect(qs.bool.must).toHaveLength(0);
36+
});
37+
});
38+
39+
describe('applyGenericFilters — filter context', () => {
40+
it('puts a single primary-term match in bool.filter', () => {
41+
const qs = newQueryStruct();
42+
applyGenericFilters({ producer: 'eosio' }, qs, new Set());
43+
expect(qs.bool.must).toHaveLength(0);
44+
expect(qs.bool.filter).toEqual([{ term: { producer: 'eosio' } }]);
45+
});
46+
47+
it('puts a comma multi-value clause in bool.filter as a should with minimum_should_match', () => {
48+
const qs = newQueryStruct();
49+
applyGenericFilters({ producer: 'a,b' }, qs, new Set());
50+
expect(qs.bool.must).toHaveLength(0);
51+
expect(qs.bool.filter).toHaveLength(1);
52+
expect(qs.bool.filter[0].bool.minimum_should_match).toBe(1);
53+
expect(qs.bool.filter[0].bool.should).toEqual([
54+
{ term: { producer: 'a' } },
55+
{ term: { producer: 'b' } }
56+
]);
57+
});
58+
59+
it('puts a range clause in bool.filter', () => {
60+
const qs = newQueryStruct();
61+
applyGenericFilters({ block_num: '100-200' }, qs, new Set());
62+
expect(qs.bool.must).toHaveLength(0);
63+
expect(qs.bool.filter).toEqual([{ range: { block_num: { gte: '100', lte: '200' } } }]);
64+
});
65+
66+
it('keeps negation in must_not (unchanged)', () => {
67+
const qs = newQueryStruct();
68+
applyGenericFilters({ producer: '!eosio' }, qs, new Set());
69+
expect(qs.bool.must_not).toEqual([{ term: { producer: 'eosio' } }]);
70+
expect(qs.bool.filter).toBeUndefined();
71+
});
72+
73+
it('keeps the @transfer.memo full-text match in must so sortedBy=_score still ranks by relevance', () => {
74+
const qs = newQueryStruct();
75+
applyGenericFilters({ 'transfer.memo': 'hello' }, qs, new Set(['transfer']));
76+
expect(qs.bool.must).toEqual([{ match: { '@transfer.memo': { query: 'hello' } } }]);
77+
expect(qs.bool.filter).toBeUndefined();
78+
});
79+
});
80+
81+
describe('applyCodeActionFilters — filter context', () => {
82+
it('puts the code:name filter in bool.filter (not root-level should)', () => {
83+
const qs = newQueryStruct();
84+
applyCodeActionFilters({ filter: 'eosio.token:transfer' }, qs);
85+
expect((qs.bool as any).should).toBeUndefined();
86+
expect(qs.bool.filter).toHaveLength(1);
87+
expect(qs.bool.filter[0].bool.minimum_should_match).toBe(1);
88+
expect(qs.bool.filter[0].bool.should).toEqual([
89+
{ bool: { must: [{ term: { 'act.account': 'eosio.token' } }, { term: { 'act.name': 'transfer' } }] } }
90+
]);
91+
});
92+
});

0 commit comments

Comments
 (0)