Skip to content

Commit 7c582cf

Browse files
committed
feat: add configurable render budget with per-op stats
Introduces a work-unit budget system that terminates runaway template renders (deep loops, recursive partials, expensive helpers) once a configurable limit is reached. A nil budget is always unlimited, so all existing call sites are completely unaffected. budget_config.go - BudgetCosts struct: per-operation cost values (LoopIteration, HelperCall, FilterCall, SubRender, ConditionCheck, Assignment, ObjectTraversal, FunctionCosts) - DefaultBudgetCosts() and ZeroCosts() constructors budget.go - Budget struct with atomic.Int64 main counter (lock-free hot path) - Per-category stat counters (atomic.Int64 each) - Per-function stats via sync.Mutex + map[string]int64 — faster than sync.Map for the small, low-contention key sets in practice - NewBudget / NewBudgetWithCosts constructors - SpendLoop / SpendHelperCall / SpendFunctionCall / SpendFilter / SpendSubRender / SpendCondition / SpendAssignment / SpendObjectTraversal — all nil-safe - SpendFunctionCall(name): checks FunctionCosts[name] first, falls back to HelperCall cost - Stats() BudgetStats: post-render snapshot of units per category and per named function (ByFunction map) - ErrBudgetExceeded sentinel error budget_test.go - 18 tests covering: limit enforcement, nil-unlimited, zero costs, per-function overrides, Used/Remaining introspection, and all Stats() fields context.go — budget *Budget field; WithBudget() / Budget() methods; Budget() walks the outer chain so child contexts (sub-renders) inherit and share the parent counter compiler.go — budget() helper; SpendFunctionCall injected at evalCallExpression; SpendLoop at all three for-loop branches; SpendCondition at evalIfExpression; SpendAssignment at evalLetStatement / evalAssignExpression; SpendObjectTraversal at evalIdentifier (Callee path) partial_helper.go — SpendSubRender() before any partial work begins plush.go — RenderWithBudget(input, limit, ctx) RenderWithBudgetConfig(input, limit, costs, ctx) README.md — new "Render Budget" section with quick-start, cost table, per-function example, and Stats API
1 parent da00ab2 commit 7c582cf

8 files changed

Lines changed: 738 additions & 3 deletions

File tree

README.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,3 +358,101 @@ This package absolutely 100% could not have been written without the help of Tho
358358
Not only did the book make understanding the process of writing lexers, parsers, and asts, but it also provided the basis for the syntax of Plush itself.
359359

360360
If you have yet to read Thorsten's book, I can't recommend it enough. Please go and buy it!
361+
362+
---
363+
364+
## Render Budget
365+
366+
Plush lets you attach a work-unit **budget** to any render to protect against runaway templates — deeply nested loops, recursive partials, or unexpectedly expensive helpers.
367+
368+
A **nil budget = unlimited**, so all existing code is completely unaffected.
369+
370+
### Quick start
371+
372+
```go
373+
b := plush.NewBudget(10_000)
374+
ctx := plush.NewContext()
375+
ctx.Set("products", products)
376+
ctx.WithBudget(b)
377+
378+
html, err := plush.Render(tmpl, ctx)
379+
if errors.Is(err, plush.ErrBudgetExceeded) {
380+
log.Printf("budget exceeded: used=%d remaining=%d", b.Used(), b.Remaining())
381+
return errorPage()
382+
}
383+
384+
// One-liner convenience wrapper
385+
html, err = plush.RenderWithBudget(tmpl, 10_000, ctx)
386+
```
387+
388+
### Default operation costs
389+
390+
| Operation | Default cost |
391+
|---|---|
392+
| Loop iteration | 1 |
393+
| Helper / function call | 5 |
394+
| Filter call | 3 |
395+
| Partial / sub-render | 10 |
396+
| Condition check (`if`) | 1 |
397+
| Variable assignment | 0 |
398+
| Object traversal (per segment) | 1 |
399+
400+
### Custom costs
401+
402+
Pass a `BudgetCosts` struct to override any cost:
403+
404+
```go
405+
costs := plush.ZeroCosts() // start from all-zero
406+
costs.LoopIteration = 1
407+
costs.SubRender = 25
408+
409+
html, err = plush.RenderWithBudgetConfig(tmpl, 5_000, costs, ctx)
410+
```
411+
412+
### Per-function costs
413+
414+
Override the cost for individual functions registered in the context:
415+
416+
```go
417+
costs := plush.DefaultBudgetCosts()
418+
costs.FunctionCosts = map[string]int64{
419+
"expensiveQuery": 50, // charged 50 per call instead of the default 5
420+
"cheapHelper": 1,
421+
}
422+
423+
html, err = plush.RenderWithBudgetConfig(tmpl, 10_000, costs, ctx)
424+
```
425+
426+
Functions not listed in `FunctionCosts` fall back to the `HelperCall` cost.
427+
428+
### Stats report
429+
430+
After rendering, call `b.Stats()` to see exactly where the budget was spent:
431+
432+
```go
433+
b := plush.NewBudget(10_000)
434+
ctx.WithBudget(b)
435+
plush.Render(tmpl, ctx)
436+
437+
s := b.Stats()
438+
fmt.Printf("total=%d loops=%d calls=%d conditions=%d\n",
439+
s.TotalUsed, s.LoopIterations, s.FunctionCalls, s.ConditionChecks)
440+
441+
for name, units := range s.ByFunction {
442+
fmt.Printf(" %s: %d units\n", name, units)
443+
}
444+
```
445+
446+
`BudgetStats` fields:
447+
448+
| Field | What it measures |
449+
|---|---|
450+
| `TotalUsed` | Sum of all units spent |
451+
| `LoopIterations` | Units from loop iterations |
452+
| `FunctionCalls` | Units from all function/helper calls |
453+
| `FilterCalls` | Units from filter calls |
454+
| `SubRenders` | Units from partial renders |
455+
| `ConditionChecks` | Units from `if`/`unless` evaluations |
456+
| `Assignments` | Units from variable assignments |
457+
| `ObjectTraversals` | Units from dot-notation traversal |
458+
| `ByFunction` | Per-function breakdown (map of name → units) |

budget.go

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
package plush
2+
3+
import (
4+
"errors"
5+
"sync"
6+
"sync/atomic"
7+
)
8+
9+
// ErrBudgetExceeded is returned when a render exhausts its budget.
10+
var ErrBudgetExceeded = errors.New("render budget exceeded")
11+
12+
// BudgetStats is a snapshot of work units consumed per operation category.
13+
// Retrieve it after rendering via b.Stats().
14+
type BudgetStats struct {
15+
// TotalUsed is the sum of all units spent (equals b.Used()).
16+
TotalUsed int64
17+
// LoopIterations is total units charged by loop iterations.
18+
LoopIterations int64
19+
// FunctionCalls is total units charged by all function/helper calls.
20+
FunctionCalls int64
21+
// FilterCalls is total units charged by filter calls.
22+
FilterCalls int64
23+
// SubRenders is total units charged by partial/snippet renders.
24+
SubRenders int64
25+
// ConditionChecks is total units charged by if/unless evaluations.
26+
ConditionChecks int64
27+
// Assignments is total units charged by variable assignments.
28+
Assignments int64
29+
// ObjectTraversals is total units charged by dot-notation traversal.
30+
ObjectTraversals int64
31+
// ByFunction breaks FunctionCalls down by name for calls made via
32+
// SpendFunctionCall. Functions without a FunctionCosts override appear
33+
// here using the generic HelperCall cost.
34+
ByFunction map[string]int64
35+
}
36+
37+
// Budget tracks render work units during template evaluation.
38+
// A nil Budget is always unlimited — zero breaking changes.
39+
type Budget struct {
40+
limit int64
41+
counter atomic.Int64
42+
costs BudgetCosts
43+
44+
// per-category stat counters — all lock-free
45+
statLoop atomic.Int64
46+
statFunction atomic.Int64 // total of all function/helper calls
47+
statFilter atomic.Int64
48+
statSubRender atomic.Int64
49+
statCondition atomic.Int64
50+
statAssign atomic.Int64
51+
statTraversal atomic.Int64
52+
53+
// per-function breakdown — mutex-protected plain map
54+
statFuncsMu sync.Mutex
55+
statFuncsMap map[string]int64
56+
}
57+
58+
// NewBudget creates a Budget with a limit and default costs.
59+
func NewBudget(limit int64) *Budget {
60+
return &Budget{
61+
limit: limit,
62+
costs: DefaultBudgetCosts(),
63+
statFuncsMap: make(map[string]int64),
64+
}
65+
}
66+
67+
// NewBudgetWithCosts creates a Budget with fully custom per-operation costs.
68+
func NewBudgetWithCosts(limit int64, costs BudgetCosts) *Budget {
69+
return &Budget{
70+
limit: limit,
71+
costs: costs,
72+
statFuncsMap: make(map[string]int64),
73+
}
74+
}
75+
76+
// WithCosts replaces the cost configuration. Returns self for chaining.
77+
func (b *Budget) WithCosts(costs BudgetCosts) *Budget {
78+
b.costs = costs
79+
return b
80+
}
81+
82+
// Costs returns the active cost configuration.
83+
func (b *Budget) Costs() BudgetCosts {
84+
return b.costs
85+
}
86+
87+
// Used returns total units consumed so far.
88+
func (b *Budget) Used() int64 {
89+
return b.counter.Load()
90+
}
91+
92+
// Remaining returns units left before the limit is hit.
93+
func (b *Budget) Remaining() int64 {
94+
r := b.limit - b.counter.Load()
95+
if r < 0 {
96+
return 0
97+
}
98+
return r
99+
}
100+
101+
// Stats returns a snapshot of work units consumed per operation category.
102+
// Safe to call at any point during or after rendering.
103+
func (b *Budget) Stats() BudgetStats {
104+
if b == nil {
105+
return BudgetStats{}
106+
}
107+
s := BudgetStats{
108+
TotalUsed: b.counter.Load(),
109+
LoopIterations: b.statLoop.Load(),
110+
FunctionCalls: b.statFunction.Load(),
111+
FilterCalls: b.statFilter.Load(),
112+
SubRenders: b.statSubRender.Load(),
113+
ConditionChecks: b.statCondition.Load(),
114+
Assignments: b.statAssign.Load(),
115+
ObjectTraversals: b.statTraversal.Load(),
116+
ByFunction: make(map[string]int64),
117+
}
118+
b.statFuncsMu.Lock()
119+
for k, v := range b.statFuncsMap {
120+
s.ByFunction[k] = v
121+
}
122+
b.statFuncsMu.Unlock()
123+
return s
124+
}
125+
126+
// SpendLoop spends the loop iteration cost.
127+
func (b *Budget) SpendLoop() error {
128+
if b == nil {
129+
return nil
130+
}
131+
b.statLoop.Add(b.costs.LoopIteration)
132+
return b.spend(b.costs.LoopIteration)
133+
}
134+
135+
// SpendHelperCall spends the helper call cost.
136+
func (b *Budget) SpendHelperCall() error {
137+
if b == nil {
138+
return nil
139+
}
140+
b.statFunction.Add(b.costs.HelperCall)
141+
return b.spend(b.costs.HelperCall)
142+
}
143+
144+
// SpendFilter spends the filter call cost.
145+
func (b *Budget) SpendFilter() error {
146+
if b == nil {
147+
return nil
148+
}
149+
b.statFilter.Add(b.costs.FilterCall)
150+
return b.spend(b.costs.FilterCall)
151+
}
152+
153+
// SpendSubRender spends the sub-render cost.
154+
func (b *Budget) SpendSubRender() error {
155+
if b == nil {
156+
return nil
157+
}
158+
b.statSubRender.Add(b.costs.SubRender)
159+
return b.spend(b.costs.SubRender)
160+
}
161+
162+
// SpendCondition spends the condition check cost.
163+
func (b *Budget) SpendCondition() error {
164+
if b == nil {
165+
return nil
166+
}
167+
b.statCondition.Add(b.costs.ConditionCheck)
168+
return b.spend(b.costs.ConditionCheck)
169+
}
170+
171+
// SpendAssignment spends the assignment cost.
172+
func (b *Budget) SpendAssignment() error {
173+
if b == nil {
174+
return nil
175+
}
176+
b.statAssign.Add(b.costs.Assignment)
177+
return b.spend(b.costs.Assignment)
178+
}
179+
180+
// SpendFunctionCall spends the cost for a named function call.
181+
// Uses FunctionCosts[name] if set, otherwise falls back to HelperCall cost.
182+
func (b *Budget) SpendFunctionCall(name string) error {
183+
if b == nil {
184+
return nil
185+
}
186+
cost := b.costs.HelperCall
187+
if c, ok := b.costs.FunctionCosts[name]; ok {
188+
cost = c
189+
}
190+
b.statFunction.Add(cost)
191+
b.statFuncsMu.Lock()
192+
b.statFuncsMap[name] += cost
193+
b.statFuncsMu.Unlock()
194+
return b.spend(cost)
195+
}
196+
197+
// SpendObjectTraversal spends ObjectTraversal * segments units.
198+
// e.g. product.variants.first = 3 segments → costs ObjectTraversal * 3
199+
func (b *Budget) SpendObjectTraversal(segments int) error {
200+
if b == nil {
201+
return nil
202+
}
203+
units := b.costs.ObjectTraversal * int64(segments)
204+
b.statTraversal.Add(units)
205+
return b.spend(units)
206+
}
207+
208+
// spend is the internal hot path. Uses atomic add with no locks.
209+
func (b *Budget) spend(units int64) error {
210+
if b == nil || units == 0 {
211+
return nil
212+
}
213+
if b.counter.Add(units) > b.limit {
214+
return ErrBudgetExceeded
215+
}
216+
return nil
217+
}

budget_config.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package plush
2+
3+
// BudgetCosts defines the work-unit cost for each operation type.
4+
type BudgetCosts struct {
5+
// LoopIteration is spent once per for-loop iteration.
6+
// Default: 1
7+
LoopIteration int64
8+
9+
// HelperCall is spent each time a registered helper is invoked.
10+
// Default: 5
11+
HelperCall int64
12+
13+
// FilterCall is spent per filter applied (sort, map, where).
14+
// Default: 3
15+
FilterCall int64
16+
17+
// SubRender is spent each time a partial/snippet is rendered.
18+
// Default: 10
19+
SubRender int64
20+
21+
// ConditionCheck is spent per if/unless/case evaluation.
22+
// Default: 1
23+
ConditionCheck int64
24+
25+
// Assignment is spent per variable assignment.
26+
// Default: 0 (free — rarely the bottleneck)
27+
Assignment int64
28+
29+
// ObjectTraversal is spent per dot-notation segment accessed.
30+
// e.g. product.variants.first = 3 segments = 3 units
31+
// Default: 1
32+
ObjectTraversal int64
33+
34+
// FunctionCosts overrides the default HelperCall cost for specific named
35+
// functions. The key is the function name as registered in the context.
36+
// If a name is present here, its cost is used instead of HelperCall.
37+
// e.g. costs.FunctionCosts = map[string]int64{"expensiveQuery": 50}
38+
FunctionCosts map[string]int64
39+
}
40+
41+
// DefaultBudgetCosts returns recommended production defaults.
42+
func DefaultBudgetCosts() BudgetCosts {
43+
return BudgetCosts{
44+
LoopIteration: 1,
45+
HelperCall: 5,
46+
FilterCall: 3,
47+
SubRender: 10,
48+
ConditionCheck: 1,
49+
Assignment: 0,
50+
ObjectTraversal: 1,
51+
}
52+
}
53+
54+
// ZeroCosts returns all-zero costs.
55+
// Useful for isolating one operation type in tests.
56+
func ZeroCosts() BudgetCosts {
57+
return BudgetCosts{}
58+
}

0 commit comments

Comments
 (0)