Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ Critical learnings and performance patterns discovered in this codebase.
## 2026-08-13 - Use Switch Statements for Zero-Allocation Static Lookups
**Learning:** Defining static lookup map literals (such as `map[string]string{...}`) inside helper functions evaluated per-host (e.g., `osFamily` in `parseFacts`) causes Go to allocate and populate a new hash map on the heap on every invocation (~1.2 KB and 3 allocations per call).
**Action:** Prefer switch statements over map literals for fixed static lookups to achieve zero heap allocations, complete immutability, and zero race-condition risk.

## 2026-08-14 - Pre-parse and Cache Guard Expressions at Pack Validation
**Learning:** Re-parsing filter expressions or guard ASTs (such as `when` expressions in content packs) inside per-target evaluation loops (`Select`) during fleet-wide sweeps causes massive repeated string splitting, heap allocations, and CPU overhead ($O(N_{\text{hosts}} \cdot N_{\text{collectors}})$ parses).
**Action:** Parse guard expressions once into compiled AST structs (`*whenExpr`) during pack load/validation (`Pack.validate`), caching the pointer on the collector struct for direct zero-parse evaluations.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module nudgebee/forager

go 1.25.12
go 1.25.13

require (
cloud.google.com/go/auth v0.22.0
Expand Down
18 changes: 17 additions & 1 deletion pkg/proxy/discovery/example_pack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func TestExamplePackGuardsModularityTagForOldRpm(t *testing.T) {
}
}

func loadExamplePack(t *testing.T) *Pack {
func loadExamplePack(t testing.TB) *Pack {
t.Helper()
raw, err := os.ReadFile("../../../docs/content-packs/linux-inventory-example.yaml")
if err != nil {
Expand Down Expand Up @@ -118,3 +118,19 @@ func collectorCmd(t *testing.T, pack *Pack, id string) string {
}

func b64(b []byte) string { return base64.StdEncoding.EncodeToString(b) }

func BenchmarkExamplePackSelect(b *testing.B) {
pack := loadExamplePack(b)
facts := map[string]string{
"os_family": "rhel",
"os_id": "rocky",
"os_major": "9",
"arch": "x86_64",
}

b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = pack.Select(facts)
}
}
28 changes: 19 additions & 9 deletions pkg/proxy/discovery/pack.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ type Collector struct {
ID string `yaml:"id"`
When string `yaml:"when,omitempty"` // empty = run on every host
Cmd string `yaml:"cmd"`

expr *whenExpr // parsed once at pack validation time to avoid per-host re-parsing
}

// KindInventory is the only pack kind Phase 0 executes.
Expand Down Expand Up @@ -152,7 +154,8 @@ func (p *Pack) validate() error {
}

seen := make(map[string]bool, len(p.Collectors))
for i, c := range p.Collectors {
for i := range p.Collectors {
c := &p.Collectors[i]
if c.ID == "" {
return fmt.Errorf("collector %d has no id", i)
}
Expand All @@ -165,11 +168,14 @@ func (p *Pack) validate() error {
return fmt.Errorf("collector %q has no cmd", c.ID)
}
// Reject unparseable guards at load time rather than per host, so a
// malformed pack fails once and loudly.
// malformed pack fails once and loudly. Cache the parsed expression to
// avoid re-parsing guards on every host during inventory sweeps.
if c.When != "" {
if _, err := parseWhen(c.When); err != nil {
expr, err := parseWhen(c.When)
if err != nil {
return fmt.Errorf("collector %q: %w", c.ID, err)
}
c.expr = expr
}
}
return nil
Expand All @@ -179,19 +185,23 @@ func (p *Pack) validate() error {
// in pack order. A guard referencing an unknown fact does not match — see
// evalWhen.
func (p *Pack) Select(facts map[string]string) ([]Collector, []SkippedCollector) {
var run []Collector
run := make([]Collector, 0, len(p.Collectors))
var skipped []SkippedCollector

for _, c := range p.Collectors {
if c.When == "" {
run = append(run, c)
continue
}
expr, err := parseWhen(c.When)
if err != nil {
// validate() already rejected these; defensive.
skipped = append(skipped, SkippedCollector{ID: c.ID, Reason: err.Error()})
continue
expr := c.expr
if expr == nil {
var err error
expr, err = parseWhen(c.When)
if err != nil {
// validate() already rejected these; defensive.
skipped = append(skipped, SkippedCollector{ID: c.ID, Reason: err.Error()})
continue
}
}
match, err := expr.eval(facts)
if err != nil {
Expand Down
17 changes: 16 additions & 1 deletion pkg/proxy/discovery/pack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (

// signPack produces a signed pack document from a body (a pack without its
// signature line), mirroring what the publish pipeline will do in CI.
func signPack(t *testing.T, body string, priv ed25519.PrivateKey) string {
func signPack(t testing.TB, body string, priv ed25519.PrivateKey) string {
t.Helper()
sig := ed25519.Sign(priv, SignedBytes([]byte(body)))
return body + "\nsignature: " + base64.StdEncoding.EncodeToString(sig) + "\n"
Expand Down Expand Up @@ -334,3 +334,18 @@ func BenchmarkParseAndVerify(b *testing.B) {
_, _ = ParseAndVerify(doc, pub)
}
}

func BenchmarkPackSelect(b *testing.B) {
pub, priv := testKeys(b)
pack, err := ParseAndVerify([]byte(signPack(b, validBody, priv)), pub)
if err != nil {
b.Fatalf("verifying pack: %v", err)
}
facts := map[string]string{"os_family": "debian", "arch": "x86_64", "os_id": "ubuntu", "os_major": "22"}

b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = pack.Select(facts)
}
}
Loading