Skip to content

feat: add configurable render budget with per-op stats#221

Open
Mido-sys wants to merge 1 commit into
gobuffalo:mainfrom
Mido-sys:feat_budget_compile
Open

feat: add configurable render budget with per-op stats#221
Mido-sys wants to merge 1 commit into
gobuffalo:mainfrom
Mido-sys:feat_budget_compile

Conversation

@Mido-sys
Copy link
Copy Markdown
Collaborator

Render Budget Enforcement — Implementation Notes

Overview

Adds a configurable work-unit budget to plush that kills a runaway template render when it exceeds a limit. A nil budget = unlimited, so all existing code is 100% unaffected.


Files Created

budget_config.go

Defines BudgetCosts — a struct of per-operation costs, all overridable by the caller.

Field Default Rationale
LoopIteration 1 Volume is the risk, not the operation itself
HelperCall 5 Arbitrary Go code can run inside helpers
FilterCall 3 Sort/map/where iterate internally
SubRender 10 Triggers a whole new template evaluation
ConditionCheck 1 Near free, but deep nesting adds up
Assignment 0 Rarely a bottleneck — opt-in to charge it
ObjectTraversal 1/segment a.b.c.d.e costs 5, not 1
FunctionCosts nil Per-function override map — key is the function name registered in context

Key functions:

  • DefaultBudgetCosts() BudgetCosts — production defaults
  • ZeroCosts() BudgetCosts — all zero, useful for isolating one operation in tests

budget.go

Core Budget struct. Uses sync/atomic.Int64 on the hot spend path for the main counter and per-category stat counters. Per-function stats use a sync.Mutex + plain map[string]int64 — faster than sync.Map for the small, low-contention key sets that occur in practice.

Budget {
    limit         int64
    counter       atomic.Int64     ← hot path, lock-free
    costs         BudgetCosts

    // per-category stat counters — all lock-free
    statLoop      atomic.Int64
    statFunction  atomic.Int64
    statFilter    atomic.Int64
    statSubRender atomic.Int64
    statCondition atomic.Int64
    statAssign    atomic.Int64
    statTraversal atomic.Int64

    // per-function breakdown — mutex-protected plain map
    statFuncsMu  sync.Mutex
    statFuncsMap map[string]int64
}

Constructors:

  • NewBudget(limit int64) *Budget — uses DefaultBudgetCosts
  • NewBudgetWithCosts(limit int64, costs BudgetCosts) *Budget — fully custom

Introspection:

  • b.Used() int64 — units consumed so far
  • b.Remaining() int64 — units left (clamped to 0)
  • b.Costs() BudgetCosts — active cost config
  • b.WithCosts(costs) *Budget — replace costs, returns self for chaining
  • b.Stats() BudgetStats — snapshot of units spent per operation category

BudgetStats fields:

Field What it measures
TotalUsed Sum of all units spent
LoopIterations Units from loop iterations
FunctionCalls Units from all function/helper calls
FilterCalls Units from filter calls
SubRenders Units from partial renders
ConditionChecks Units from if/unless evaluations
Assignments Units from variable assignments
ObjectTraversals Units from dot-notation traversal
ByFunction Per-function breakdown — map[string]int64 of name → units

Typed spend methods (all nil-safe — nil budget always returns nil):

  • SpendLoop() error
  • SpendHelperCall() error
  • SpendFunctionCall(name string) error — checks FunctionCosts[name] first, falls back to HelperCall
  • SpendFilter() error
  • SpendSubRender() error
  • SpendCondition() error
  • SpendAssignment() error
  • SpendObjectTraversal(segments int) error

Sentinel error:

var ErrBudgetExceeded = errors.New("render budget exceeded")

budget_test.go

Full test coverage in package plush:

Test What it verifies
TestBudget_LoopExceedsLimit 5-item loop with limit 3 → ErrBudgetExceeded
TestBudget_LoopWithinLimit 3-item loop with limit 100 → no error
TestBudget_NilIsUnlimited No budget attached → Render always succeeds
TestBudget_ZeroCostNeverExceeds Zero loop cost → 10 000 iterations never exceeds
TestBudget_CustomHelperCost 2 helper calls × 100 units, limit 150 → exceeds
TestBudget_UsedAndRemaining b.Used() > 0 and b.Remaining() < limit after render
TestBudget_ConditionExceedsLimit Two if-checks × 5 units, limit 7 → exceeds
TestBudget_SubRenderSharesParentBudget Direct SpendSubRender() calls share one counter
TestBudget_WithCosts WithCosts correctly replaces cost config
TestBudget_NewBudgetWithCosts Constructor sets limit, zero used, full remaining
TestBudget_FunctionCostOverride_Exceeds Per-function cost overrides HelperCall; 2 calls × 60, limit 100 → exceeds
TestBudget_FunctionCostOverride_FallsBackToHelperCall Unnamed function falls back to HelperCall; 2 calls × 10, limit 15 → exceeds
TestBudget_FunctionCostOverride_CheapFunctionDoesNotExceed Per-function cost of 1; 5 calls, limit 50 → no error
TestBudget_Stats_LoopIterations 3-item loop → LoopIterations == 3, other categories zero
TestBudget_Stats_FunctionCalls 3 calls × default cost 5 → FunctionCalls == 15, ByFunction["greet"] == 15
TestBudget_Stats_ByFunctionPerFunctionCost Two functions with different overrides tracked separately in ByFunction
TestBudget_Stats_MixedOperations Function calls + condition → correct per-category totals and TotalUsed
TestBudget_Stats_NilBudgetReturnsZero nil budget returns zero-value BudgetStats

Files Modified

context.go

Added budget *Budget field plus two methods:

type Context struct {
    context.Context
    data   map[string]interface{}
    outer  *Context
    moot   *sync.Mutex
    budget *Budget          // ← new
}

func (c *Context) WithBudget(b *Budget) *Context { ... }

// Budget walks the outer chain — child contexts (sub-renders) automatically
// inherit and share the parent's budget.
func (c *Context) Budget() *Budget { ... }

The outer-chain walk is intentional: when a partial/snippet creates a child context via ctx.New(), it automatically shares the parent's budget counter. No bypass possible.


compiler.go

Added a helper on *compiler to safely retrieve the budget from the current context:

func (c *compiler) budget() *Budget {
    if ctx, ok := c.ctx.(*Context); ok {
        return ctx.Budget()
    }
    return nil
}

Spend calls injected at every relevant AST evaluation site:

Site Method called Location
evalForExpression — Map iteration SpendLoop() top of each map key loop iteration
evalForExpression — Slice/Array iteration SpendLoop() top of each slice/array loop iteration
evalForExpression — Iterator loop SpendLoop() top of each Iterator.Next() loop
evalCallExpression SpendFunctionCall(name) before any function resolution; name resolved from AST
evalIfExpression SpendCondition() before condition evaluation
evalLetStatement SpendAssignment() before value evaluation
evalAssignExpression SpendAssignment() before value evaluation
evalIdentifier (with Callee) SpendObjectTraversal(1) before dot-field traversal

All calls follow the same pattern:

if err := c.budget().SpendXxx(); err != nil {
    return nil, err
}

Because every Spend* method is nil-safe, this pattern works even when no budget is attached.


partial_helper.go

Added SpendSubRender() at the top of partialHelper, before any work is done:

if ctx, ok := help.Context.(*Context); ok {
    if err := ctx.Budget().SpendSubRender(); err != nil {
        return "", err
    }
}

The child context inherits the parent budget via the outer chain, so every nested Render(part, help.Context) call also spends against the same counter.


plush.go

Two new public API functions added after Render():

// RenderWithBudget renders with a work-unit limit using default costs.
func RenderWithBudget(input string, limit int64, ctx *Context) (string, error)

// RenderWithBudgetConfig renders with a fully custom cost configuration.
func RenderWithBudgetConfig(input string, limit int64, costs BudgetCosts, ctx *Context) (string, error)

Render() is completely unchanged — zero breaking changes.


Usage

// Simple — default costs
b := NewBudget(10_000)
ctx := NewContext()
ctx.Set("products", products)
ctx.WithBudget(b)

html, err := Render(tmpl, ctx)
if errors.Is(err, ErrBudgetExceeded) {
    log.Printf("budget exceeded: used=%d remaining=%d", b.Used(), b.Remaining())
    return errorPage()
}

// One-liner convenience
html, err = RenderWithBudget(tmpl, 10_000, ctx)

// Custom — only charge loops and sub-renders
costs := ZeroCosts()
costs.LoopIteration = 1
costs.SubRender     = 25
html, err = RenderWithBudgetConfig(tmpl, 5_000, costs, ctx)

// Tight debug mode — catch expensive templates early
costs = BudgetCosts{
    LoopIteration:   10,
    HelperCall:      50,
    SubRender:       100,
    ConditionCheck:  5,
    Assignment:      1,
    ObjectTraversal: 5,
}
html, err = RenderWithBudgetConfig(tmpl, 1_000, costs, ctx)

// Per-function cost override — fine-grained control over individual helpers
costs = ZeroCosts()
costs.HelperCall = 5 // default for all helpers
costs.FunctionCosts = map[string]int64{
    "expensiveQuery": 50, // this function costs 50 per call
    "cheap":          1,  // this function costs only 1 per call
}
html, err = RenderWithBudgetConfig(tmpl, 10_000, costs, ctx)

Design Decisions

Decision Reason
atomic.Int64 for counter No mutex on the hot spend path; safe for concurrent renders
Nil *Budget = unlimited Zero breaking changes to all existing call sites
Nil guards on every Spend* method Callers don't need to check before calling; keeps compiler.go clean
Budget walks outer chain Sub-renders share parent budget automatically; no bypass possible
SpendSubRender in partialHelper The only place where recursive renders are triggered
RenderWithBudget accepts *Context Needs to call ctx.WithBudget() which isn't on hctx.Context
SpendFunctionCall(name) over SpendHelperCall() Enables per-function overrides while keeping nil-safe fallback to HelperCall
FunctionCosts is nil-safe map Missing keys fall back to HelperCall; nil map does the same — no init required
sync.Mutex + map[string]int64 for per-function stats sync.Map carries overhead (internal boxing, pointer indirection) suited for high-contention many-goroutine scenarios; per-function tracking has a small key set written sequentially and read once — a plain map under a mutex is faster and simpler

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
@paganotoni
Copy link
Copy Markdown
Member

@Mido-sys Can you explain the use case that raised this one? Just for curiosity.

@Mido-sys
Copy link
Copy Markdown
Collaborator Author

Mido-sys commented Jun 5, 2026

@paganotoni

Thanks for taking a look.

To give more context: we use Plush in a multi-tenant platform where external developers have access to client templates and can write Plush code directly through an online admin portal.

We started running into cases where template code unintentionally became very expensive. For example, a frontend/template developer might call a Plush helper that connects to a third-party service inside a loop while generating a menu. In more complex cases, this can happen through nested menus or recursive template structures with several levels of depth.

Because those developers do not have backend access, they usually cannot see which helper calls are expensive or how many times they are being executed. When a page becomes slow, a backend developer currently has to add temporary timing/logging around the helper calls to understand where the render time is being spent.

The goal of this feature is to make that easier and safer by:

Adding a configurable render budget/cost limit so a template render cannot consume unlimited work.
Allowing specific Plush functions/helpers to have an assigned cost.
Exposing render/debug stats so developers can see which helpers, partials, or loops are responsible for most of the work.
Keeping existing behaviour unchanged when no budget is configured.

This would help protect pages from runaway or unexpectedly expensive templates while also giving template developers a practical way to debug performance issues without requiring backend instrumentation every time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants