Skip to content

Commit 335d282

Browse files
kriszypclaude
andcommitted
feat(analytics): add replicated mode to get_analytics
Add a `replicated: true` option to the `get_analytics` operation that fans the query out to every peer node and merges each node's analytics into one cluster-wide result set. The local query is streamed first, then each peer's rows (already labeled with their origin `node` attribute) are appended. The operation is forwarded to peers with `replicated` cleared so they only return their own local metrics (no recursive fan-out), mirroring the existing `restart` fan-out convention. Fan-out is best-effort: a peer that errors is logged and omitted rather than failing the whole query. It is skipped when running standalone (no peers) and when `hdb_analytics` already replicates across the cluster (`analytics_replicate`), which would otherwise double-count. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a3eb997 commit 335d282

4 files changed

Lines changed: 263 additions & 5 deletions

File tree

resources/analytics/read.ts

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import { CONFIG_PARAMS } from '../../utility/hdbTerms.ts';
88
import { get as envGet } from '../../utility/environment/environmentManager.ts';
99
import { validateGetAnalytics } from '../../validation/analyticsValidator.ts';
1010
import { handleHDBError, hdbErrors } from '../../utility/errors/hdbError.ts';
11+
import { getThisNodeName } from '../../server/nodeName.ts';
1112

1213
// default to one week time window for finding custom metrics
1314
const defaultCustomMetricWindow = 1000 * 60 * 60 * 24 * 7;
1415

15-
const log = forComponent('analytics').conditional;
16+
const logger = forComponent('analytics');
17+
const log = logger.conditional;
1618

1719
async function lookupHostname(nodeId: number): Promise<string | undefined> {
1820
const result = await getAnalyticsHostnameTable().get(nodeId);
@@ -26,15 +28,19 @@ function isSelected(querySelect: string[], attr: string) {
2628
}
2729

2830
interface GetAnalyticsRequest {
31+
operation?: string;
2932
metric: string;
3033
start_time?: number;
3134
end_time?: number;
3235
get_attributes?: string[];
3336
coalesce_time?: boolean;
3437
conditions?: Conditions;
38+
// When true, fan the query out to every peer node and merge the results into one
39+
// cluster-wide response. Cleared before forwarding so peers only return their own.
40+
replicated?: boolean;
3541
}
3642

37-
type GetAnalyticsResponse = Metric[];
43+
type GetAnalyticsResponse = AsyncIterable<Metric> | Metric[];
3844

3945
/**
4046
* Validates the `get_analytics` request and returns the analytics results.
@@ -54,13 +60,75 @@ export async function getOp(req: GetAnalyticsRequest): Promise<GetAnalyticsRespo
5460
true
5561
);
5662
}
57-
return get(req.metric, {
58-
getAttributes: req.get_attributes,
63+
// `replicated` fans the query out to every peer node and merges each node's
64+
// analytics into one cluster-wide result set. Fan-out is skipped when:
65+
// - the request did not ask for it;
66+
// - this is standalone core, which has no `server.nodes` (harper-pro populates it);
67+
// - `hdb_analytics` already replicates across the cluster, in which case a local
68+
// query already holds every node's rows and fanning out would double-count.
69+
// The DB layer marks the table `replicate === false` only when it is *not*
70+
// replicated (`analytics_replicate: false`), which is exactly when fan-out helps.
71+
const analyticsReplicatedByDb = databases.system.hdb_analytics.replicate !== false;
72+
const peers = req.replicated && !analyticsReplicatedByDb && server.nodes?.length ? server.nodes : undefined;
73+
74+
// When merging across the cluster, make sure every row keeps its origin `node`
75+
// attribute so callers can tell the nodes apart. An empty/absent `get_attributes`
76+
// already selects everything (including `node`), so only an explicit list needs it.
77+
let getAttributes = req.get_attributes;
78+
if (peers && getAttributes?.length && !getAttributes.includes('node')) {
79+
getAttributes = [...getAttributes, 'node'];
80+
}
81+
82+
const localResults = await get(req.metric, {
83+
getAttributes,
5984
startTime: req.start_time,
6085
endTime: req.end_time,
6186
coalesceTime: req.coalesce_time,
6287
additionalConditions: req.conditions,
6388
});
89+
90+
if (!peers) return localResults;
91+
return mergeAnalyticsFromPeers(localResults, { ...req, get_attributes: getAttributes }, peers);
92+
}
93+
94+
/**
95+
* Streams the local analytics, then appends each peer node's analytics. The same
96+
* query is forwarded to every peer with `replicated` cleared so each returns only
97+
* its own local metrics (no recursive fan-out). Best-effort: a peer that fails is
98+
* logged and omitted rather than failing the whole query.
99+
*/
100+
async function* mergeAnalyticsFromPeers(
101+
localResults: AsyncIterable<Metric> | Iterable<Metric>,
102+
req: GetAnalyticsRequest,
103+
peers: { name: string }[]
104+
): AsyncGenerator<Metric> {
105+
const thisNode = getThisNodeName();
106+
const peerReq = { ...req, replicated: false };
107+
// `sendOperationToNode` is typed for a node name string, but the replication
108+
// implementation expects the full node object (the stub-vs-impl mismatch that
109+
// `restart` works around the same way).
110+
const sendOperationToNode = server.replication.sendOperationToNode as unknown as (
111+
node: { name: string },
112+
operation: unknown
113+
) => Promise<{ results?: Metric[] } | Metric[]>;
114+
115+
const peerResults = peers
116+
.filter((node) => node.name !== thisNode)
117+
.map((node) =>
118+
sendOperationToNode(node, peerReq).then(
119+
// an array response is wrapped as `{ results }` over the replication channel
120+
(response): Metric[] => (Array.isArray(response) ? response : (response?.results ?? [])),
121+
(error: Error): Metric[] => {
122+
logger.warn(`get_analytics replication to node '${node.name}' failed; omitting its results`, error);
123+
return [];
124+
}
125+
)
126+
);
127+
128+
yield* localResults;
129+
for (const peerResult of peerResults) {
130+
yield* await peerResult;
131+
}
64132
}
65133

66134
function conformCondition(condition: Condition): Condition {

unitTests/resources/analytics/read.test.js

Lines changed: 178 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,32 @@ const { expect } = require('chai');
44
const { describe, it } = require('mocha');
55
const sinon = require('sinon');
66
const { METRIC } = require('#src/resources/analytics/metadata');
7-
const { listMetrics, describeMetric /* collectDistinctValues */ } = require('#src/resources/analytics/read');
7+
const { getOp, listMetrics, describeMetric /* collectDistinctValues */ } = require('#src/resources/analytics/read');
8+
const { getThisNodeName } = require('#src/server/nodeName');
9+
const hostnames = require('#src/resources/analytics/hostnames');
10+
11+
// Mimics the Harper search iterable: array-like with a lazy async `.map`, which is
12+
// what resources/analytics/read.ts `get()` consumes.
13+
function mockSearchIterable(items) {
14+
return {
15+
[Symbol.asyncIterator]: async function* () {
16+
for (const item of items) yield item;
17+
},
18+
map(fn) {
19+
return {
20+
[Symbol.asyncIterator]: async function* () {
21+
for (const item of items) yield await fn(item);
22+
},
23+
};
24+
},
25+
};
26+
}
27+
28+
async function collect(result) {
29+
const out = [];
30+
for await (const item of result) out.push(item);
31+
return out;
32+
}
833

934
describe('listMetrics', () => {
1035
let searchStub;
@@ -301,3 +326,155 @@ describe('describeMetric', () => {
301326
}
302327
});
303328
});
329+
330+
describe('getOp (replicated fan-out)', () => {
331+
let searchStub;
332+
let sendOperationStub;
333+
let originalServer;
334+
let originalDatabases;
335+
336+
beforeEach(() => {
337+
// `server` and `databases` are process-wide globals established at module load;
338+
// stash and restore them rather than deleting so later test files still see them.
339+
originalServer = global.server;
340+
originalDatabases = global.databases;
341+
342+
searchStub = sinon.stub().returns(mockSearchIterable([]));
343+
// `replicate === false` => analytics are NOT replicated by the DB layer, so the
344+
// fan-out is needed (and enabled). The skip case is covered explicitly below.
345+
global.databases = { system: { hdb_analytics: { search: searchStub, replicate: false } } };
346+
347+
sendOperationStub = sinon.stub();
348+
global.server = {
349+
hostname: 'local-host',
350+
nodes: [],
351+
replication: { sendOperationToNode: sendOperationStub },
352+
};
353+
});
354+
355+
afterEach(() => {
356+
sinon.restore();
357+
global.server = originalServer;
358+
global.databases = originalDatabases;
359+
});
360+
361+
it('merges metrics from every peer node into one flat result set', async () => {
362+
global.server.nodes = [{ name: 'peer-a' }, { name: 'peer-b' }];
363+
sendOperationStub
364+
.withArgs(sinon.match({ name: 'peer-a' }))
365+
.resolves({ results: [{ id: 1, metric: 'm', node: 'peer-a' }] });
366+
sendOperationStub
367+
.withArgs(sinon.match({ name: 'peer-b' }))
368+
.resolves({ results: [{ id: 2, metric: 'm', node: 'peer-b' }] });
369+
370+
const result = await collect(await getOp({ operation: 'get_analytics', metric: 'm', replicated: true }));
371+
372+
expect(result).to.deep.equal([
373+
{ id: 1, metric: 'm', node: 'peer-a' },
374+
{ id: 2, metric: 'm', node: 'peer-b' },
375+
]);
376+
expect(sendOperationStub.calledTwice).to.be.true;
377+
});
378+
379+
it('forwards the query to peers with `replicated` cleared (no recursive fan-out)', async () => {
380+
global.server.nodes = [{ name: 'peer-a' }];
381+
sendOperationStub.resolves({ results: [] });
382+
383+
await collect(await getOp({ operation: 'get_analytics', metric: 'm', replicated: true }));
384+
385+
const forwarded = sendOperationStub.firstCall.args[1];
386+
expect(forwarded.replicated).to.equal(false);
387+
expect(forwarded.metric).to.equal('m');
388+
});
389+
390+
it('skips the local node when fanning out', async () => {
391+
const thisNode = getThisNodeName();
392+
global.server.nodes = [{ name: thisNode }, { name: 'peer-x' }];
393+
sendOperationStub.resolves({ results: [] });
394+
395+
await collect(await getOp({ metric: 'm', replicated: true }));
396+
397+
expect(sendOperationStub.calledOnce).to.be.true;
398+
expect(sendOperationStub.firstCall.args[0]).to.deep.equal({ name: 'peer-x' });
399+
});
400+
401+
it('omits a peer that errors and still returns the others (best-effort)', async () => {
402+
global.server.nodes = [{ name: 'peer-good' }, { name: 'peer-bad' }];
403+
sendOperationStub
404+
.withArgs(sinon.match({ name: 'peer-good' }))
405+
.resolves({ results: [{ id: 1, metric: 'm', node: 'peer-good' }] });
406+
sendOperationStub.withArgs(sinon.match({ name: 'peer-bad' })).rejects(new Error('connection refused'));
407+
408+
const result = await collect(await getOp({ metric: 'm', replicated: true }));
409+
410+
expect(result).to.deep.equal([{ id: 1, metric: 'm', node: 'peer-good' }]);
411+
});
412+
413+
it('accepts a bare-array peer response (defensive unwrap)', async () => {
414+
global.server.nodes = [{ name: 'peer-a' }];
415+
sendOperationStub.resolves([{ id: 5, metric: 'm', node: 'peer-a' }]);
416+
417+
const result = await collect(await getOp({ metric: 'm', replicated: true }));
418+
419+
expect(result).to.deep.equal([{ id: 5, metric: 'm', node: 'peer-a' }]);
420+
});
421+
422+
it('includes local node results ahead of peer results', async () => {
423+
sinon
424+
.stub(hostnames, 'getAnalyticsHostnameTable')
425+
.returns({ get: sinon.stub().resolves({ hostname: 'local-host' }) });
426+
searchStub.returns(mockSearchIterable([{ id: [10, 12345], metric: 'm', total: 1 }]));
427+
global.server.nodes = [{ name: 'peer-a' }];
428+
sendOperationStub.resolves({ results: [{ id: 20, metric: 'm', node: 'peer-a', total: 2 }] });
429+
430+
const result = await collect(await getOp({ metric: 'm', replicated: true }));
431+
432+
expect(result).to.deep.equal([
433+
{ id: 10, metric: 'm', total: 1, node: 'local-host' },
434+
{ id: 20, metric: 'm', node: 'peer-a', total: 2 },
435+
]);
436+
});
437+
438+
it('forces the `node` attribute into an explicit get_attributes list when replicated', async () => {
439+
global.server.nodes = [{ name: 'peer-a' }];
440+
sendOperationStub.resolves({ results: [] });
441+
442+
await collect(await getOp({ metric: 'm', get_attributes: ['metric', 'total'], replicated: true }));
443+
444+
const forwarded = sendOperationStub.firstCall.args[1];
445+
expect(forwarded.get_attributes).to.include('node');
446+
});
447+
448+
it('does not fan out when `replicated` is not set', async () => {
449+
searchStub.returns(mockSearchIterable([{ id: [10, 1], metric: 'm', total: 1 }]));
450+
global.server.nodes = [{ name: 'peer-a' }];
451+
452+
const result = await collect(await getOp({ metric: 'm', get_attributes: ['metric', 'total'] }));
453+
454+
expect(sendOperationStub.called).to.be.false;
455+
expect(result).to.deep.equal([{ id: 10, metric: 'm', total: 1 }]);
456+
});
457+
458+
it('does not fan out in standalone core (no server.nodes)', async () => {
459+
global.server.nodes = undefined;
460+
searchStub.returns(mockSearchIterable([{ id: [10, 1], metric: 'm' }]));
461+
462+
const result = await collect(await getOp({ metric: 'm', get_attributes: ['metric'], replicated: true }));
463+
464+
expect(sendOperationStub.called).to.be.false;
465+
expect(result).to.deep.equal([{ id: 10, metric: 'm' }]);
466+
});
467+
468+
it('does not fan out when the analytics table already replicates (replicate !== false)', async () => {
469+
// `analytics_replicate: true` leaves the table `replicate` undefined; a local query
470+
// already holds every node's rows, so fanning out would double-count.
471+
delete global.databases.system.hdb_analytics.replicate;
472+
global.server.nodes = [{ name: 'peer-a' }];
473+
searchStub.returns(mockSearchIterable([{ id: [10, 1], metric: 'm', total: 1 }]));
474+
475+
const result = await collect(await getOp({ metric: 'm', get_attributes: ['metric', 'total'], replicated: true }));
476+
477+
expect(sendOperationStub.called).to.be.false;
478+
expect(result).to.deep.equal([{ id: 10, metric: 'm', total: 1 }]);
479+
});
480+
});

unitTests/validation/analyticsValidator.test.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,4 +175,16 @@ describe('validateGetAnalytics', function () {
175175
const error = validateGetAnalytics({ metric: 'cpu-usage', conditions: [{ attribute: 'path' }] });
176176
assert.ok(error instanceof Error);
177177
});
178+
179+
// ── replicated ───────────────────────────────────────────────────────────
180+
181+
it('should accept replicated as a boolean', function () {
182+
assert.strictEqual(validateGetAnalytics({ metric: 'cpu-usage', replicated: true }), undefined);
183+
});
184+
185+
it('should reject replicated as the string "true"', function () {
186+
const error = validateGetAnalytics({ metric: 'cpu-usage', replicated: 'true' });
187+
assert.ok(error instanceof Error);
188+
assert.ok(error.message.includes('replicated'), `expected "replicated" in: ${error.message}`);
189+
});
178190
});

validation/analyticsValidator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const getAnalyticsSchema = Joi.object({
3636
get_attributes: Joi.array().items(Joi.string()),
3737
coalesce_time: Joi.boolean(),
3838
conditions: Joi.array().items(Joi.alternatives(groupConditionSchema, directConditionSchema)),
39+
replicated: Joi.boolean(),
3940
}).strict();
4041

4142
export function validateGetAnalytics(req: any): Error | undefined {

0 commit comments

Comments
 (0)