Files
pdbmux/aggregate.go
T
unkin-agent 2391f56a11 config: drop primary/prefer and treat all backends equally
- unmerged /pdb/query/v4/* paths now go to the first backend that answers, not a designated primary
2026-09-05 13:49:02 +10:00

271 lines
7.5 KiB
Go

package main
import (
"encoding/json"
"sort"
"strconv"
"strings"
)
// aggregateSpec names the columns of an extract/group_by result set: keys
// identify a row across backends, sums are the numeric aggregate columns added
// together.
type aggregateSpec struct {
keys []string
sums []string
}
// parseAggregate reads a PuppetDB AST query and returns the aggregate shape of
// its response, or nil when the query is not an aggregate — only a top-level
// `extract` carrying at least one `["function", ...]` column produces summable
// rows. Key columns are the plain (non-function) extract fields, unioned with an
// explicit `group_by` clause when the query has one.
func parseAggregate(query string) *aggregateSpec {
if strings.TrimSpace(query) == "" {
return nil
}
var ast []json.RawMessage
if json.Unmarshal([]byte(query), &ast) != nil || len(ast) < 2 {
return nil
}
var op string
if json.Unmarshal(ast[0], &op) != nil || op != "extract" {
return nil
}
var cols []json.RawMessage
if json.Unmarshal(ast[1], &cols) != nil {
return nil
}
spec := &aggregateSpec{}
for _, col := range cols {
var name string
if json.Unmarshal(col, &name) == nil {
spec.keys = appendUnique(spec.keys, name)
continue
}
if fn, ok := functionName(col); ok {
spec.sums = appendUnique(spec.sums, fn)
}
}
if len(spec.sums) == 0 {
return nil
}
for _, node := range ast[2:] {
for _, f := range groupByFields(node) {
spec.keys = appendUnique(spec.keys, f)
}
}
return spec
}
// functionName returns the response column an extract function produces, which
// PuppetDB names after the function itself: ["function","count","certname"]
// yields a "count" column.
func functionName(col json.RawMessage) (string, bool) {
var parts []json.RawMessage
if json.Unmarshal(col, &parts) != nil || len(parts) < 2 {
return "", false
}
var head, name string
if json.Unmarshal(parts[0], &head) != nil || head != "function" {
return "", false
}
if json.Unmarshal(parts[1], &name) != nil || name == "" {
return "", false
}
return name, true
}
// groupByFields returns the field names of a ["group_by", ...] AST node, or nil
// for any other node.
func groupByFields(node json.RawMessage) []string {
var parts []json.RawMessage
if json.Unmarshal(node, &parts) != nil || len(parts) < 2 {
return nil
}
var head string
if json.Unmarshal(parts[0], &head) != nil || head != "group_by" {
return nil
}
var out []string
for _, p := range parts[1:] {
var name string
if json.Unmarshal(p, &name) == nil {
out = append(out, name)
}
}
return out
}
func appendUnique(s []string, v string) []string {
if contains(s, v) {
return s
}
return append(s, v)
}
// columns reports which fields of a row form its grouping key and which are
// summed. A spec is fixed by the query, so the row is ignored.
func (a *aggregateSpec) columns(map[string]json.RawMessage) ([]string, []string) {
return a.keys, a.sums
}
// inferredColumns derives an event-counts row's shape from the row itself: the
// counts to add (successes, failures, noops, skips, total) are its numeric
// fields, plus any null one — PuppetDB nulls an aggregate column when a backend
// matched nothing — and everything else, subject_type/subject/summarize_by,
// identifies the row. Those endpoints have a fixed response shape with no
// numeric key field, so nothing summable is mistaken for identity.
func inferredColumns(row map[string]json.RawMessage) ([]string, []string) {
var keys, sums []string
for name, val := range row {
if isJSONNumber(val) || isJSONNull(val) {
sums = append(sums, name)
continue
}
keys = append(keys, name)
}
sort.Strings(keys)
sort.Strings(sums)
return keys, sums
}
// isJSONNumber reports whether a raw JSON value is a number.
func isJSONNumber(raw json.RawMessage) bool {
v := strings.TrimSpace(string(raw))
if v == "" {
return false
}
return v[0] == '-' || (v[0] >= '0' && v[0] <= '9')
}
func isJSONNull(raw json.RawMessage) bool {
return strings.TrimSpace(string(raw)) == "null"
}
// sumGroup accumulates the rows sharing one grouping key.
type sumGroup struct {
raw json.RawMessage // first contributing row, verbatim
row map[string]json.RawMessage // its decoded fields
totals map[string]float64 // running sum per aggregate column
merged bool // a second row was folded in
}
// sumRows folds each backend's aggregate rows into one row per grouping key,
// adding the numeric aggregate columns. columns decides, per row, which fields
// are the key and which are summed.
//
// A row that is not a JSON object passes through untouched, as does the sole row
// of a key only one backend reported — those keep their upstream bytes. An
// aggregate column that is absent or non-numeric in a later row is left at the
// earlier backend's value rather than being coerced to zero. results come in
// configured backend order and the output keeps first-seen order, a tie-break
// only.
func sumRows(results []backendResult, columns func(map[string]json.RawMessage) ([]string, []string)) []json.RawMessage {
type slot struct {
raw json.RawMessage // passthrough row, when group is nil
group *sumGroup
}
var order []slot
groups := map[string]*sumGroup{}
for _, res := range results {
for _, rec := range res.records {
var row map[string]json.RawMessage
if json.Unmarshal(rec.Raw, &row) != nil {
order = append(order, slot{raw: rec.Raw})
continue
}
keys, sums := columns(row)
k := groupKey(row, keys)
g, ok := groups[k]
if !ok {
g = &sumGroup{raw: rec.Raw, row: row, totals: map[string]float64{}}
for _, s := range sums {
if n, ok := numberOf(row[s]); ok {
g.totals[s] = n
}
}
groups[k] = g
order = append(order, slot{group: g})
continue
}
for _, s := range sums {
n, ok := numberOf(row[s])
if !ok {
continue
}
if _, seen := g.totals[s]; !seen {
// First numeric value for a column the earlier row lacked.
g.totals[s] = 0
}
g.totals[s] += n
g.merged = true
}
}
}
out := make([]json.RawMessage, 0, len(order))
for _, sl := range order {
if sl.group == nil {
out = append(out, sl.raw)
continue
}
out = append(out, sl.group.encode())
}
return out
}
// encode renders a group back to JSON, reusing the first row's bytes when
// nothing was added to it.
func (g *sumGroup) encode() json.RawMessage {
if !g.merged {
return g.raw
}
row := make(map[string]json.RawMessage, len(g.row))
for k, v := range g.row {
row[k] = v
}
for col, total := range g.totals {
row[col] = json.RawMessage(strconv.FormatFloat(total, 'f', -1, 64))
}
raw, err := json.Marshal(row)
if err != nil {
return g.raw
}
return raw
}
// groupKey builds a row's identity from the named fields' verbatim JSON values.
// Every backend runs the same PuppetDB serialiser, so byte equality is a sound
// comparison for object-valued keys such as event-counts' subject. An absent
// field is distinct from any present value.
func groupKey(row map[string]json.RawMessage, keys []string) string {
var b strings.Builder
for _, k := range keys {
b.WriteString(k)
b.WriteByte(0)
if v, ok := row[k]; ok {
b.Write(v)
} else {
b.WriteByte(1)
}
b.WriteByte(0)
}
return b.String()
}
// numberOf decodes a raw JSON number, reporting ok=false for anything else so
// non-numeric aggregate columns are carried through instead of summed.
func numberOf(raw json.RawMessage) (float64, bool) {
if !isJSONNumber(raw) {
return 0, false
}
var n float64
if json.Unmarshal(raw, &n) != nil {
return 0, false
}
return n, true
}