brainy/src/aggregation/materializer.ts
David Snelling 02eff64b78 fix: aggregation surfaces materialize/state-load failures loudly
The debounced materialization caught its failure with an empty `catch (() => {})`,
silently leaving the materialized Measurement entity stale; the aggregation-index
init() state-load failure was swallowed the same way, leaving aggregates reading
empty with no signal. Both are non-fatal (values rebuild via backfill-on-query),
but a silent stale/empty read violates loud-errors-never-quiet-losses. Both now
emit a loud warning naming the affected group / cause.
2026-07-13 09:49:38 -07:00

226 lines
7 KiB
TypeScript

/**
* 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'
import { prodLog } from '../utils/logger.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
/** Sub-classification of the materialized entity (7.30.1+). */
subtype?: string
metadata: Record<string, unknown>
id?: string
service?: string
}): Promise<string>
update(params: {
id: string
data?: string
subtype?: 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((err) => {
// Materialization is derived data (rebuildable via backfill-on-query), so
// a failure is non-fatal — but it leaves the materialized Measurement
// entity STALE. Surface it loudly rather than swallow it silently.
prodLog.warn(
`[Aggregation] Failed to materialize aggregate group '${key}': ` +
`${(err as Error).message}. The materialized value is stale until the ` +
`next successful materialization or a backfill-on-query.`
)
})
}, 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'
// Subtype `materialized-aggregate` distinguishes engine-emitted Measurement entities
// from any user-authored ones, and makes them queryable: `brain.find({ type: 'measurement',
// subtype: 'materialized-aggregate' })` enumerates every active aggregate output. Also
// ensures the aggregation engine itself doesn't trip enforcement when a consumer
// registers a vocabulary on NounType.Measurement (added 7.30.1).
const id = await this.brain.add({
data: dataString,
type: 'measurement' as NounType,
subtype: 'materialized-aggregate',
metadata,
service: 'brainy:aggregation'
})
groupState.materializedEntityId = id
}
}
}