Refuse an /events aggregate that also asks for distinct_resources
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

distinct_resources sends /events to openvoxdb's legacy compiler, which has no
function or group_by, so every backend failed and the client saw a generic 502
instead of the reason.

- Refuse an aggregate carrying a truthy distinct_resources with 400, before any fan-out
- Read the param the way openvoxdb does, so any capitalisation of "true" counts
- Leave non-aggregate distinct_resources queries and every other route alone
- Document the refusal
This commit is contained in:
2026-09-07 18:23:48 +10:00
parent c886617d72
commit 8baa511ef9
5 changed files with 193 additions and 1 deletions
+7
View File
@@ -160,6 +160,13 @@ paths add two more headers `pdbmux` sets itself, `X-Cache` and `Age` — see
- A `/reports` or `/events` query with no `function` column is a projection of
real records, not an aggregate, and stays on the union path — so an event
stored identically in two backends is still served once.
- `distinct_resources=true` on an `/events` `extract` with a `function` column
is refused with **400** naming the incompatibility, before any fan-out.
openvoxdb answers the distinct-resources form of `/events` from its legacy
compiler, which supports neither `function` nor `group_by`, so every backend
fails and the mistake would otherwise surface as a `502`. A
`distinct_resources` query with no `function` column is untouched and still
fans out; no other endpoint `pdbmux` combines honours the parameter.
- `include_total=true` on a combined endpoint reports the **merged** row count,
not the sum of the backends' `X-Records`, since shared keys collapse.
+21
View File
@@ -2,7 +2,9 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"sort"
"strconv"
"strings"
@@ -167,6 +169,25 @@ func groupByClash(name string) error {
return fmt.Errorf("group_by names the aggregate column %q: pdbmux folds that column across backends, so it cannot also be a grouping key", name)
}
const distinctResourcesParam = "distinct_resources"
// errDistinctResourcesAggregate refuses an aggregate asking for the
// distinct-resources form of /events. openvoxdb answers that form from its
// legacy events compiler, whose operator map carries neither function nor
// group_by (src/puppetlabs/puppetdb/query_eng.clj:189-190 and
// src/puppetlabs/puppetdb/query/events.clj:183-187), so every backend fails and
// a fan-out could only report a client mistake as an outage.
var errDistinctResourcesAggregate = errors.New(
"distinct_resources cannot be combined with an extract function column: openvoxdb answers a distinct_resources /events query from its legacy compiler, which supports neither function nor group_by")
// distinctResources reports whether a request asks for the distinct-resources
// form. openvoxdb coerces the param with Boolean/parseBoolean
// (src/puppetlabs/puppetdb/http/query.clj:245-250, applied at query.clj:296),
// so any capitalisation of "true" turns it on and everything else reads false.
func distinctResources(params url.Values) bool {
return strings.EqualFold(params.Get(distinctResourcesParam), "true")
}
// rewriteAvg replaces the client's avg column with the sum and count of the same
// expression, so the true weighted average can be computed from the shards:
// Postgres avg(x) is sum(x)/count(x), and both of those do combine.
+48
View File
@@ -13,6 +13,7 @@ import (
"sort"
"strings"
"testing"
"time"
)
var allNodes = []string{nodeAlpha, nodeBeta, nodeGamma, nodeShared}
@@ -701,6 +702,53 @@ func TestEventsAggregatesAreSummed(t *testing.T) {
})
}
// distinct_resources moves /events onto openvoxdb's legacy compiler, which has
// no function or group_by: both backends fail identically, so without a refusal
// a client mistake reaches the caller as an outage-shaped 502.
func TestEventsAggregateWithDistinctResourcesIsRefused(t *testing.T) {
ctx := context.Background()
distinct := func(q string) url.Values {
v := url.Values{
"distinct_resources": {"true"},
"distinct_start_time": {fixtureTime(-24 * time.Hour)},
"distinct_end_time": {fixtureTime(time.Hour)},
}
if q != "" {
v.Set("query", q)
}
return v
}
params := distinct(`["extract",[["function","count"]]]`)
resp := rawGet(t, eventsPath, params)
if resp.status != http.StatusBadRequest {
t.Fatalf("status %d, want 400: %s", resp.status, resp.body)
}
if !strings.Contains(string(resp.body), "distinct_resources") {
t.Errorf("refusal %q does not name the incompatibility", resp.body)
}
if got := h.a.queryStatus(ctx, t, eventsPath, params); got == http.StatusOK {
t.Errorf("backend %s answered the aggregate with 200, so the refusal is unnecessary", h.a.name)
}
t.Run("a plain distinct_resources query is still served", func(t *testing.T) {
params := distinct("")
want := len(h.a.query(ctx, t, eventsPath, params)) + len(h.b.query(ctx, t, eventsPath, params))
if want == 0 {
t.Fatal("the fixture yields no distinct-resources events, so the union proves nothing")
}
rows := get(t, eventsPath, params).rows(t)
if len(rows) != want {
t.Fatalf("/events with distinct_resources returned %d records, want %d", len(rows), want)
}
for _, row := range rows {
if _, ok := row["resource_title"].(string); !ok {
t.Fatalf("/events record is not an event: %v", row)
}
}
})
}
// countsByCertname reads a ["function","count"] + group_by "certname" result set.
func countsByCertname(t *testing.T, rows []map[string]any) map[string]int {
t.Helper()
+10 -1
View File
@@ -204,12 +204,21 @@ func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) {
if rt.unsummed == "" {
// The one place an aggregate is read, so a query pdbmux cannot fold is
// refused here rather than by whichever handler happens to notice.
spec, err := parseAggregate(r.URL.Query().Get("query"))
in := r.URL.Query()
spec, err := parseAggregate(in.Get("query"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if spec != nil {
// Of the routes that reach an aggregate, only /events honours
// distinct_resources; every other one rejects it as an unsupported
// query parameter (src/puppetlabs/puppetdb/http/handlers.clj:178-189,
// 475-496 and src/puppetlabs/puppetdb/http/query.clj:273-277).
if r.URL.Path == eventsPath && distinctResources(in) {
http.Error(w, errDistinctResourcesAggregate.Error(), http.StatusBadRequest)
return
}
path := rt.fanOut
if path == "" {
path = r.URL.Path
+107
View File
@@ -95,6 +95,19 @@ func (fb *fakeBackend) params(path string) (url.Values, bool) {
return v, ok
}
// hits reports how many requests the backend received for a path.
func (fb *fakeBackend) hits(path string) int {
fb.mu.Lock()
defer fb.mu.Unlock()
n := 0
for _, p := range fb.rawPaths {
if p == path {
n++
}
}
return n
}
// sawRawPath reports whether the backend was asked for a path with exactly that
// escaping.
func (fb *fakeBackend) sawRawPath(p string) bool {
@@ -634,6 +647,100 @@ func TestHandler_EventsGroupedAggregatesCombinePerKey(t *testing.T) {
}
}
// distinct_resources sends /events to openvoxdb's legacy compiler, which has no
// function or group_by, so an aggregate asking for it is refused with the reason
// rather than fanned out into two identical failures and a 502.
func TestHandler_EventsAggregateWithDistinctResourcesIsRefused(t *testing.T) {
const agg = `["extract",[["function","count"]]]`
distinct := func(q, value string) url.Values {
v := url.Values{"distinct_start_time": {"2026-07-01T00:00:00Z"}, "distinct_end_time": {"2026-07-02T00:00:00Z"}}
if q != "" {
v.Set("query", q)
}
if value != "" {
v.Set(distinctResourcesParam, value)
}
return v
}
t.Run("refused without any fan-out", func(t *testing.T) {
for _, value := range []string{"true", "TRUE", "True"} {
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[eventsPath] = `[{"count":5}]`
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[eventsPath] = `[{"count":5}]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGetParams(t, srv.Handler(), eventsPath, distinct(agg, value))
if rec.Code != http.StatusBadRequest {
t.Fatalf("distinct_resources=%s: status %d, want 400: %s", value, rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), distinctResourcesParam) {
t.Errorf("distinct_resources=%s: refusal %q does not name the incompatibility", value, rec.Body.String())
}
if got := a.hits(eventsPath) + b.hits(eventsPath); got != 0 {
t.Errorf("distinct_resources=%s: backends saw %d requests, want 0", value, got)
}
}
})
// openvoxdb reads the param with Boolean/parseBoolean, so only "true" turns
// the distinct form on and any other spelling is an ordinary aggregate.
t.Run("a non-true value is still an ordinary aggregate", func(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[eventsPath] = `[{"count":5}]`
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[eventsPath] = `[{"count":5}]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGetParams(t, srv.Handler(), eventsPath, distinct(agg, "yes"))
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want 200: %s", rec.Code, rec.Body.String())
}
if got := counts(t, rec.Body.Bytes(), "count"); !slices.Equal(got, []float64{10}) {
t.Errorf("count = %v, want [10]", got)
}
})
t.Run("a plain distinct_resources query still fans out", func(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[eventsPath] = `[` + event("h1", "r1", "Package[nginx]") + `]`
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[eventsPath] = `[` + event("h1", "r2", "Service[nginx]") + `]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGetParams(t, srv.Handler(), eventsPath, distinct("", "true"))
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want 200: %s", rec.Code, rec.Body.String())
}
if a.hits(eventsPath) != 1 || b.hits(eventsPath) != 1 {
t.Fatalf("backends saw %d and %d requests, want 1 each", a.hits(eventsPath), b.hits(eventsPath))
}
got, _ := a.params(eventsPath)
if got.Get(distinctResourcesParam) != "true" {
t.Errorf("backend a got distinct_resources=%q, want %q", got.Get(distinctResourcesParam), "true")
}
})
// Only /events honours the param; on any other route openvoxdb refuses it
// itself, so pdbmux must not shadow that with a refusal of its own.
t.Run("another route keeps combining", func(t *testing.T) {
a := newFakeBackend(t, `[]`, `[]`)
a.bodies[resourcesPath] = `[{"count":5}]`
b := newFakeBackend(t, `[]`, `[]`)
b.bodies[resourcesPath] = `[{"count":5}]`
srv := newTestServer(testConfig(a.srv.URL, b.srv.URL, mergeStatic))
rec := doGetParams(t, srv.Handler(), resourcesPath, distinct(agg, "true"))
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want 200: %s", rec.Code, rec.Body.String())
}
if got := counts(t, rec.Body.Bytes(), "count"); !slices.Equal(got, []float64{10}) {
t.Errorf("count = %v, want [10]", got)
}
})
}
func TestHandler_ReportSubResourceFromHoldingBackend(t *testing.T) {
// Only a holds report r1, so its logs come from a; an unmerged pass-through
// to whichever backend answered first could have 404'd.