brainy/src/aggregation/materializer.ts

210 lines
5.9 KiB
TypeScript
Raw Normal View History

/**
* AggregateMaterializer - Writes aggregate results as NounType.Measurement entities
*
* Converts aggregate group states into Brainy entities that are automatically
* visible in OData, Google Sheets, SSE, and webhook integrations.
*
* Uses debouncing to avoid excessive writes during high-throughput ingestion.
*/
import type { NounType } from '../types/graphTypes.js'
import type {
AggregateDefinition,
AggregateGroupState,
AggregateMetricDef
} from '../types/brainy.types.js'
import { serializeGroupKey } from './AggregationIndex.js'
/**
* Callback interface for the materializer to create/update Brainy entities.
* This avoids a direct dependency on the Brainy class (breaks circular deps).
*/
export interface MaterializerBrainAccess {
add(params: {
data: string
type: NounType
metadata: Record<string, unknown>
id?: string
service?: string
}): Promise<string>
update(params: {
id: string
data?: string
metadata?: Record<string, unknown>
merge?: boolean
}): Promise<void>
}
interface PendingMaterialization {
aggName: string
definition: AggregateDefinition
groupKey: Record<string, string | number>
groupState: AggregateGroupState
}
const DEFAULT_DEBOUNCE_MS = 1000
export class AggregateMaterializer {
private brain: MaterializerBrainAccess
private pending = new Map<string, PendingMaterialization>()
private debounceTimers = new Map<string, ReturnType<typeof setTimeout>>()
private defaultDebounceMs: number
private flushing = false
constructor(brain: MaterializerBrainAccess, debounceMs?: number) {
this.brain = brain
this.defaultDebounceMs = debounceMs ?? DEFAULT_DEBOUNCE_MS
}
/**
* Schedule a group to be materialized as a Measurement entity.
* Debounces writes to avoid thrashing during batch ingestion.
*/
scheduleMaterialize(
aggName: string,
definition: AggregateDefinition,
groupKey: Record<string, string | number>,
groupState: AggregateGroupState
): void {
const materializeConfig = definition.materialize
if (materializeConfig === false || materializeConfig === undefined) return
const debounceMs = typeof materializeConfig === 'object'
? (materializeConfig.debounceMs ?? this.defaultDebounceMs)
: this.defaultDebounceMs
const key = `${aggName}:${serializeGroupKey(groupKey)}`
this.pending.set(key, { aggName, definition, groupKey, groupState })
// Reset debounce timer
const existing = this.debounceTimers.get(key)
if (existing) clearTimeout(existing)
this.debounceTimers.set(key, setTimeout(() => {
this.materializeOne(key).catch(() => {
// Non-fatal — materialization is derived data
})
}, debounceMs))
}
/**
* Flush all pending materializations immediately.
*/
async flush(): Promise<void> {
if (this.flushing) return
this.flushing = true
try {
// Cancel all timers
for (const timer of this.debounceTimers.values()) {
clearTimeout(timer)
}
this.debounceTimers.clear()
// Process all pending
const entries = Array.from(this.pending.entries())
this.pending.clear()
await Promise.all(entries.map(([key, entry]) => this.doMaterialize(entry)))
} finally {
this.flushing = false
}
}
/**
* Cancel all pending timers and discard pending materializations.
*/
close(): void {
for (const timer of this.debounceTimers.values()) {
clearTimeout(timer)
}
this.debounceTimers.clear()
this.pending.clear()
}
// ============= Internal =============
private async materializeOne(key: string): Promise<void> {
const entry = this.pending.get(key)
if (!entry) return
this.pending.delete(key)
this.debounceTimers.delete(key)
await this.doMaterialize(entry)
}
private async doMaterialize(entry: PendingMaterialization): Promise<void> {
const { aggName, definition, groupKey, groupState } = entry
// Compute metric values for the materialized entity
const metricValues: Record<string, number> = {}
let totalCount = 0
for (const [metricName, metricDef] of Object.entries(definition.metrics)) {
const state = groupState.metrics[metricName]
if (!state) continue
switch (metricDef.op) {
case 'count':
metricValues[metricName] = state.count
break
case 'sum':
metricValues[metricName] = state.sum
break
case 'avg':
metricValues[metricName] = state.count > 0 ? Math.round((state.sum / state.count) * 100) / 100 : 0
break
case 'min':
metricValues[metricName] = state.min === Infinity ? 0 : state.min
break
case 'max':
metricValues[metricName] = state.max === -Infinity ? 0 : state.max
break
}
totalCount = Math.max(totalCount, state.count)
}
// Build human-readable data string
const groupKeyStr = Object.entries(groupKey)
.map(([k, v]) => `${k}=${v}`)
.join(', ')
const metricsStr = Object.entries(metricValues)
.map(([k, v]) => `${k}=${v}`)
.join(', ')
const dataString = `${aggName}: ${groupKeyStr} -- ${metricsStr}`
// Build metadata
const metadata: Record<string, unknown> = {
__aggregate: aggName,
__aggregateGroup: serializeGroupKey(groupKey),
...groupKey,
...metricValues,
lastUpdated: Date.now()
}
const existingId = groupState.materializedEntityId
if (existingId) {
// Update existing materialized entity
await this.brain.update({
id: existingId,
data: dataString,
metadata,
merge: false
})
} else {
// Create new materialized entity
// NounType.Measurement = 'measurement'
const id = await this.brain.add({
data: dataString,
type: 'measurement' as NounType,
metadata,
service: 'brainy:aggregation'
})
groupState.materializedEntityId = id
}
}
}