@@ -4,7 +4,32 @@ const { expect } = require('chai');
44const { describe, it } = require ( 'mocha' ) ;
55const sinon = require ( 'sinon' ) ;
66const { 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
934describe ( '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+ } ) ;
0 commit comments