feat: add configurable render budget with per-op stats#221
Conversation
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
|
@Mido-sys Can you explain the use case that raised this one? Just for curiosity. |
|
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. 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. |
Render Budget Enforcement — Implementation Notes
Overview
Adds a configurable work-unit budget to
plushthat 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.goDefines
BudgetCosts— a struct of per-operation costs, all overridable by the caller.LoopIterationHelperCallFilterCallSubRenderConditionCheckAssignmentObjectTraversala.b.c.d.ecosts 5, not 1FunctionCostsnilKey functions:
DefaultBudgetCosts() BudgetCosts— production defaultsZeroCosts() BudgetCosts— all zero, useful for isolating one operation in testsbudget.goCore
Budgetstruct. Usessync/atomic.Int64on the hot spend path for the main counter and per-category stat counters. Per-function stats use async.Mutex+ plainmap[string]int64— faster thansync.Mapfor the small, low-contention key sets that occur in practice.Constructors:
NewBudget(limit int64) *Budget— usesDefaultBudgetCostsNewBudgetWithCosts(limit int64, costs BudgetCosts) *Budget— fully customIntrospection:
b.Used() int64— units consumed so farb.Remaining() int64— units left (clamped to 0)b.Costs() BudgetCosts— active cost configb.WithCosts(costs) *Budget— replace costs, returns self for chainingb.Stats() BudgetStats— snapshot of units spent per operation categoryBudgetStatsfields:TotalUsedLoopIterationsFunctionCallsFilterCallsSubRendersConditionChecksif/unlessevaluationsAssignmentsObjectTraversalsByFunctionmap[string]int64of name → unitsTyped spend methods (all nil-safe — nil budget always returns nil):
SpendLoop() errorSpendHelperCall() errorSpendFunctionCall(name string) error— checksFunctionCosts[name]first, falls back toHelperCallSpendFilter() errorSpendSubRender() errorSpendCondition() errorSpendAssignment() errorSpendObjectTraversal(segments int) errorSentinel error:
budget_test.goFull test coverage in
package plush:TestBudget_LoopExceedsLimitErrBudgetExceededTestBudget_LoopWithinLimitTestBudget_NilIsUnlimitedRenderalways succeedsTestBudget_ZeroCostNeverExceedsTestBudget_CustomHelperCostTestBudget_UsedAndRemainingb.Used() > 0andb.Remaining() < limitafter renderTestBudget_ConditionExceedsLimitTestBudget_SubRenderSharesParentBudgetSpendSubRender()calls share one counterTestBudget_WithCostsWithCostscorrectly replaces cost configTestBudget_NewBudgetWithCostsTestBudget_FunctionCostOverride_ExceedsHelperCall; 2 calls × 60, limit 100 → exceedsTestBudget_FunctionCostOverride_FallsBackToHelperCallHelperCall; 2 calls × 10, limit 15 → exceedsTestBudget_FunctionCostOverride_CheapFunctionDoesNotExceedTestBudget_Stats_LoopIterationsLoopIterations == 3, other categories zeroTestBudget_Stats_FunctionCallsFunctionCalls == 15,ByFunction["greet"] == 15TestBudget_Stats_ByFunctionPerFunctionCostByFunctionTestBudget_Stats_MixedOperationsTotalUsedTestBudget_Stats_NilBudgetReturnsZeronilbudget returns zero-valueBudgetStatsFiles Modified
context.goAdded
budget *Budgetfield plus two methods:The
outer-chain walk is intentional: when a partial/snippet creates a child context viactx.New(), it automatically shares the parent's budget counter. No bypass possible.compiler.goAdded a helper on
*compilerto safely retrieve the budget from the current context:Spend calls injected at every relevant AST evaluation site:
evalForExpression— Map iterationSpendLoop()evalForExpression— Slice/Array iterationSpendLoop()evalForExpression— Iterator loopSpendLoop()evalCallExpressionSpendFunctionCall(name)evalIfExpressionSpendCondition()evalLetStatementSpendAssignment()evalAssignExpressionSpendAssignment()evalIdentifier(with Callee)SpendObjectTraversal(1)All calls follow the same pattern:
Because every
Spend*method is nil-safe, this pattern works even when no budget is attached.partial_helper.goAdded
SpendSubRender()at the top ofpartialHelper, before any work is done:The child context inherits the parent budget via the
outerchain, so every nestedRender(part, help.Context)call also spends against the same counter.plush.goTwo new public API functions added after
Render():Render()is completely unchanged — zero breaking changes.Usage
Design Decisions
atomic.Int64for counter*Budget= unlimitedSpend*methodouterchainSpendSubRenderinpartialHelperRenderWithBudgetaccepts*Contextctx.WithBudget()which isn't onhctx.ContextSpendFunctionCall(name)overSpendHelperCall()HelperCallFunctionCostsisnil-safe mapHelperCall;nilmap does the same — no init requiredsync.Mutex+map[string]int64for per-function statssync.Mapcarries 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