Documentation
¶
Overview ¶
Package cascade is a headless dependency-graph recalculation engine: the layer a spreadsheet has above its formula language, extracted and made generic.
Expression libraries evaluate one expression safely and deliberately stop there. cascade owns everything above that — which cells depend on which, what order to recompute in, what is stale, and what happens when a formula fails — and evaluates nothing itself. You supply the compute function, so a formula can be an expr-lang program, a CEL program, or a plain Go closure, and cascade depends on none of them.
import "github.com/zkrebbekx/cascade"
Design ¶
A Graph holds keyed nodes. A node is either a value — a constant input you set — or a formula: a list of declared precedents plus a function that turns their values into one value.
g := cascade.New[string, float64]()
g.SetValue("qty", 3)
g.SetValue("unit", 19.99)
_ = g.SetFormula("net", []string{"qty", "unit"}, func(in cascade.Inputs[string, float64]) (float64, error) {
return in.MustGet("qty") * in.MustGet("unit"), nil
})
net, err := g.Get("net") // 59.97
A formula reads its inputs through Inputs, which only exposes the keys it declared. Reaching for an undeclared key fails with UndeclaredError instead of quietly succeeding, so the recorded edges stay an honest description of the data flow — the graph cannot drift away from the code.
Dirty propagation ¶
Mutating the graph computes nothing. Graph.SetValue, Graph.SetFormula and Graph.Delete mark the affected node and all of its transitive dependents stale, and stop. Work happens on demand: Graph.Get pulls, computing the node and only the stale precedents it needs, while Graph.Recalc pushes, settling every stale node in one pass. Graph.RecalcFrom settles just the region reachable from some keys.
Clean nodes are never recomputed, and a node reachable from a change by several paths is recomputed exactly once. In a diamond where B and C both read A and D reads both B and C, changing A recomputes B, C and D once each — D is not computed twice. Graph.OnCompute reports every node the engine actually computed, which makes that auditable rather than assumed.
Topological order ¶
Recalculation runs in dependency order, so a formula never observes a stale precedent. The order is deterministic for a given construction sequence: no map is iterated on any path that produces output, and both the sort and the dirty marking use explicit stacks rather than recursion, so a chain hundreds of thousands of nodes deep is bounded by the heap and cannot overflow the goroutine stack. Graph.TopoOrder exposes the same ordering for inspection.
Keys are only constrained to be comparable, so cascade cannot sort them. Deterministic here means definition order, not sorted order; sort the result yourself if you need that.
Cycles ¶
Graph.SetFormula rejects any edge that would close a cycle and returns a CycleError carrying the path, so the graph can never enter a state that cannot be evaluated. A rejected call leaves the graph exactly as it was — no partial mutation, so error handling never has to unwind anything.
Error semantics ¶
Failure is a value, not an exception, and it flows along the edges like any other value. A formula returning an error leaves that error on its node, and every transitive dependent fails with an EvalError naming both itself and the node where the failure originated — the spreadsheet #VALUE!/#REF! model, where a broken cell is visible from downstream and you can still see which input actually broke. EvalError.Unwrap reaches through the whole chain, so errors.Is and errors.As find the original cause.
Errors are cached exactly like values: a failed node is not retried until it or one of its precedents is marked stale again.
Declaring a dependency on a key that does not exist yet is legal — forward references are how you build a graph in any order — but evaluating such a node before the key is defined yields an UnresolvedError. Defining the key later marks the dependents stale and they resolve normally. Deleting a node that has dependents puts them back into exactly that state rather than silently substituting a zero value.
A compute function that panics is contained: the panic is recovered and becomes an EvalError with Panic set and the recovered value preserved. Formulas are frequently user-authored, and a bad one must fail its own node, not the process.
Concurrency ¶
A *Graph is not safe for concurrent use, and cascade deliberately ships no mutex-wrapped variant — locking belongs where the transaction boundaries are, which is your code, not here. Note that Graph.Get can compute and therefore writes, so concurrent Get calls race just as concurrent mutations do. Guard a shared Graph with your own lock, or give each goroutine its own via Graph.Clone. The test suite runs clean under the race detector.
Example ¶
Example builds a small invoice model and changes one input.
package main
import (
"fmt"
"github.com/zkrebbekx/cascade"
)
func main() {
g := cascade.New[string, float64]()
g.SetValue("qty", 3)
g.SetValue("unit", 20)
g.SetValue("taxRate", 0.2)
_ = g.SetFormula("net", []string{"qty", "unit"}, func(in cascade.Inputs[string, float64]) (float64, error) {
return in.MustGet("qty") * in.MustGet("unit"), nil
})
_ = g.SetFormula("tax", []string{"net", "taxRate"}, func(in cascade.Inputs[string, float64]) (float64, error) {
return in.MustGet("net") * in.MustGet("taxRate"), nil
})
_ = g.SetFormula("total", []string{"net", "tax"}, func(in cascade.Inputs[string, float64]) (float64, error) {
return in.MustGet("net") + in.MustGet("tax"), nil
})
total, _ := g.Get("total")
fmt.Printf("total: %.2f\n", total)
g.SetValue("qty", 10)
total, _ = g.Get("total")
fmt.Printf("total: %.2f\n", total)
}
Output: total: 72.00 total: 240.00
Example (Batch) ¶
Example_batch groups several edits so the graph settles in one pass.
package main
import (
"fmt"
"github.com/zkrebbekx/cascade"
)
func main() {
g := cascade.New[string, int]()
g.SetValue("a", 1)
g.SetValue("b", 2)
var computes int
_ = g.SetFormula("sum", []string{"a", "b"}, func(in cascade.Inputs[string, int]) (int, error) {
computes++
return in.MustGet("a") + in.MustGet("b"), nil
})
_ = g.Batch(func(b *cascade.Batch[string, int]) error {
b.SetValue("a", 10)
b.SetValue("b", 20)
return nil
})
sum, _ := g.Get("sum")
fmt.Println("sum:", sum, "computes:", computes)
}
Output: sum: 30 computes: 1
Example (Cycle) ¶
Example_cycle shows that a cyclic definition is rejected up front, with the offending path, and leaves the graph untouched.
package main
import (
"errors"
"fmt"
"github.com/zkrebbekx/cascade"
)
func main() {
g := cascade.New[string, int]()
g.SetValue("a", 1)
pass := func(k string) func(cascade.Inputs[string, int]) (int, error) {
return func(in cascade.Inputs[string, int]) (int, error) { return in.MustGet(k), nil }
}
_ = g.SetFormula("b", []string{"a"}, pass("a"))
_ = g.SetFormula("c", []string{"b"}, pass("b"))
err := g.SetFormula("a", []string{"c"}, pass("c"))
var ce *cascade.CycleError[string]
if errors.As(err, &ce) {
fmt.Println(ce)
fmt.Println("path:", ce.Path())
}
// The rejected call changed nothing.
a, _ := g.Get("a")
fmt.Println("a is still", a)
}
Output: cascade: cycle: a -> c -> b -> a path: [a c b a] a is still 1
Example (ErrorPropagation) ¶
Example_errorPropagation shows a failure travelling downstream while still naming the node that actually broke.
package main
import (
"errors"
"fmt"
"github.com/zkrebbekx/cascade"
)
func main() {
errNoRate := errors.New("no rate available")
g := cascade.New[string, float64]()
g.SetValue("amount", 100)
_ = g.SetFormula("rate", nil, func(cascade.Inputs[string, float64]) (float64, error) {
return 0, errNoRate
})
_ = g.SetFormula("converted", []string{"amount", "rate"}, func(in cascade.Inputs[string, float64]) (float64, error) {
return in.MustGet("amount") * in.MustGet("rate"), nil
})
_, err := g.Get("converted")
var ee *cascade.EvalError[string]
if errors.As(err, &ee) {
fmt.Println("failed node:", ee.Key)
fmt.Println("root cause: ", ee.Cause)
}
fmt.Println("is errNoRate:", errors.Is(err, errNoRate))
}
Output: failed node: converted root cause: rate is errNoRate: true
Example (ForwardReference) ¶
Example_forwardReference shows that a dependency may be declared before it exists; the dependent resolves once the key is defined.
package main
import (
"fmt"
"github.com/zkrebbekx/cascade"
)
func main() {
g := cascade.New[string, int]()
_ = g.SetFormula("greeting", []string{"name"}, func(in cascade.Inputs[string, int]) (int, error) {
return in.MustGet("name"), nil
})
_, err := g.Get("greeting")
fmt.Println(err)
g.SetValue("name", 7)
v, _ := g.Get("greeting")
fmt.Println("resolved to", v)
}
Output: cascade: unresolved: greeting references undefined key name resolved to 7
Example (MinimalRecompute) ¶
Example_minimalRecompute shows that a change recomputes each affected node exactly once, even where two paths reconverge.
package main
import (
"fmt"
"github.com/zkrebbekx/cascade"
)
func main() {
g := cascade.New[string, int]()
g.SetValue("a", 1)
double := func(k string) func(cascade.Inputs[string, int]) (int, error) {
return func(in cascade.Inputs[string, int]) (int, error) {
return in.MustGet(k) * 2, nil
}
}
_ = g.SetFormula("b", []string{"a"}, double("a"))
_ = g.SetFormula("c", []string{"a"}, double("a"))
_ = g.SetFormula("d", []string{"b", "c"}, func(in cascade.Inputs[string, int]) (int, error) {
return in.MustGet("b") + in.MustGet("c"), nil
})
_ = g.Recalc()
// Only report work caused by the change below.
g.OnCompute(func(k string, _ error) { fmt.Println("computed", k) })
g.SetValue("a", 5)
_ = g.Recalc()
d, _ := g.Get("d")
fmt.Println("d =", d)
}
Output: computed b computed c computed d d = 20
Example (PanicContainment) ¶
Example_panicContainment shows a misbehaving formula failing its own node instead of the process.
package main
import (
"errors"
"fmt"
"github.com/zkrebbekx/cascade"
)
func main() {
g := cascade.New[string, int]()
g.SetValue("n", 0)
_ = g.SetFormula("recip", []string{"n"}, func(in cascade.Inputs[string, int]) (int, error) {
return 100 / in.MustGet("n"), nil // divides by zero
})
_, err := g.Get("recip")
var ee *cascade.EvalError[string]
if errors.As(err, &ee) {
fmt.Println("panicked:", ee.Panic)
fmt.Println("node:", ee.Key)
}
fmt.Println("graph still usable:", g.Has("n"))
}
Output: panicked: true node: recip graph still usable: true
Example (TopoOrder) ¶
Example_topoOrder inspects the evaluation order the engine will use.
package main
import (
"fmt"
"github.com/zkrebbekx/cascade"
)
func main() {
g := cascade.New[string, int]()
g.SetValue("width", 3)
g.SetValue("height", 4)
_ = g.SetFormula("area", []string{"width", "height"}, func(in cascade.Inputs[string, int]) (int, error) {
return in.MustGet("width") * in.MustGet("height"), nil
})
_ = g.SetFormula("volume", []string{"area"}, func(in cascade.Inputs[string, int]) (int, error) {
return in.MustGet("area") * 10, nil
})
order, _ := g.TopoOrder()
fmt.Println(order)
fmt.Println("volume depends directly on", g.Precedents("volume"))
fmt.Println("area feeds", g.Dependents("area"))
}
Output: [width height area volume] volume depends directly on [area] area feeds [volume]
Index ¶
- type Batch
- type CycleError
- type EvalError
- type Graph
- func (g *Graph[K, V]) Batch(fn func(b *Batch[K, V]) error) error
- func (g *Graph[K, V]) Clone() *Graph[K, V]
- func (g *Graph[K, V]) Delete(k K)
- func (g *Graph[K, V]) Dependents(k K) []K
- func (g *Graph[K, V]) Get(k K) (V, error)
- func (g *Graph[K, V]) Has(k K) bool
- func (g *Graph[K, V]) Keys() []K
- func (g *Graph[K, V]) Len() int
- func (g *Graph[K, V]) OnCompute(fn func(k K, err error))
- func (g *Graph[K, V]) Precedents(k K) []K
- func (g *Graph[K, V]) Recalc() error
- func (g *Graph[K, V]) RecalcFrom(keys ...K) error
- func (g *Graph[K, V]) SetFormula(k K, deps []K, fn func(in Inputs[K, V]) (V, error)) error
- func (g *Graph[K, V]) SetValue(k K, v V)
- func (g *Graph[K, V]) Stale() []K
- func (g *Graph[K, V]) TopoOrder() ([]K, error)
- type Inputs
- type UndeclaredError
- type UnresolvedError
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Batch ¶
type Batch[K comparable, V any] struct { // contains filtered or unexported fields }
Batch groups a set of edits so they settle in a single recalculation pass. It is handed to the function passed to Graph.Batch and is only valid for the duration of that call; using it afterwards panics.
func (*Batch[K, V]) SetFormula ¶
SetFormula defines or replaces k as computed from deps. See Graph.SetFormula.
func (*Batch[K, V]) SetValue ¶
func (b *Batch[K, V]) SetValue(k K, v V)
SetValue defines or replaces k as a constant input. See Graph.SetValue.
type CycleError ¶
type CycleError[K comparable] struct { // contains filtered or unexported fields }
CycleError reports that a Graph.SetFormula call was rejected because the declared dependencies would close a cycle. The graph is left unchanged.
func (*CycleError[K]) Error ¶
func (e *CycleError[K]) Error() string
func (*CycleError[K]) Path ¶
func (e *CycleError[K]) Path() []K
Path returns the offending cycle, starting and ending at the same key — for example A, B, C, A means "A depends on B depends on C depends on A". The returned slice is a copy and may be modified freely.
type EvalError ¶
type EvalError[K comparable] struct { Key K Cause K Panic bool PanicValue any Err error }
EvalError reports that a node failed to compute. Key is the node holding this error; Cause is the node where the failure actually originated, which equals Key for a direct failure and names the upstream node for a propagated one — the spreadsheet #VALUE!/#REF! model.
If the compute function panicked, Panic is true and PanicValue holds the recovered value. EvalError.Unwrap exposes the underlying error, so errors.Is and errors.As reach through a whole propagation chain to the original failure.
type Graph ¶
type Graph[K comparable, V any] struct { // contains filtered or unexported fields }
Graph is a dependency graph of keyed values, where some values are constants supplied by the caller and others are computed from them by formulas.
A Graph is lazy: mutating it never computes anything, it only marks the affected nodes stale. Values are produced on demand by Graph.Get, or in bulk by Graph.Recalc and Graph.RecalcFrom. Clean nodes are never recomputed, and a node reachable by several paths from a change is recomputed exactly once.
The zero Graph is not usable; call New.
A Graph is not safe for concurrent use. Mutating one from several goroutines, or calling Get concurrently (Get can compute, and therefore writes), is a data race. Guard it with your own lock if you need to share one.
func (*Graph[K, V]) Batch ¶
Batch runs fn against the graph and then performs exactly one Graph.Recalc, returning its error. Because a Graph never computes on mutation, this is a readability and scoping device: it states that the edits inside belong together and that the graph should settle once when they are all applied.
If fn panics, the graph is left in whatever state the applied edits produced — consistently stale, never half-computed — and the panic propagates.
func (*Graph[K, V]) Clone ¶
Clone returns a deep copy of the graph: structure, cached values, cached errors and stale flags. The copy shares the caller's compute functions — they are values, not state — but no other memory, so mutating either graph leaves the other untouched. The OnCompute hook is carried over.
func (*Graph[K, V]) Delete ¶
func (g *Graph[K, V]) Delete(k K)
Delete removes k. Its dependents are not removed and are not zeroed: they become stale and will fail with *UnresolvedError until k is defined again. Deleting an unknown key is a no-op.
func (*Graph[K, V]) Dependents ¶
func (g *Graph[K, V]) Dependents(k K) []K
Dependents returns the keys that directly declared k as a dependency, in the order those edges were created. It reports dependents of a referenced but undefined key too. The returned slice is a copy.
func (*Graph[K, V]) Get ¶
Get returns the current value of k, computing it and any stale precedents it needs, in dependency order, on demand. Nodes that are already clean are returned from cache without recomputing.
A node that failed keeps its error: Get returns the cached failure rather than retrying, until the node or one of its precedents is marked stale again. Looking up a key that is not defined fails with *UnresolvedError.
func (*Graph[K, V]) Keys ¶
func (g *Graph[K, V]) Keys() []K
Keys returns every defined key in the order the keys were first defined. The order is deterministic for a given construction sequence. Keys are only constrained to be comparable, so cascade cannot sort them; if you need a sorted view, sort the result yourself.
func (*Graph[K, V]) Len ¶
Len reports the number of defined keys. Placeholders for keys that are referenced but not defined are not counted.
func (*Graph[K, V]) OnCompute ¶
OnCompute installs a hook called once for every node the engine actually computes, with the resulting error (nil on success). It is not called for nodes skipped as already clean, which makes it a direct audit of the work a change caused. Pass nil to remove the hook.
The hook must not mutate the graph.
func (*Graph[K, V]) Precedents ¶
func (g *Graph[K, V]) Precedents(k K) []K
Precedents returns the direct dependencies k declared, in declaration order. It returns nil for values, placeholders and unknown keys. The returned slice is a copy.
func (*Graph[K, V]) Recalc ¶
Recalc computes every stale node in the graph, in dependency order, so no formula ever reads a stale precedent. Individual node failures are recorded on their nodes and propagated downstream; Recalc returns the first failure it saw in topological order, or nil if everything computed cleanly.
Inspect Graph.Get per key, or install Graph.OnCompute, when you need the full picture rather than the first error.
func (*Graph[K, V]) RecalcFrom ¶
RecalcFrom computes only the part of the graph reachable from the given keys: those keys and everything transitively downstream of them, restricted to what is actually stale. Stale precedents that those nodes need are pulled in as well, so the result is never computed from a stale input, but unrelated stale regions of the graph are left alone.
It returns the first failure in topological order, or nil. Unknown keys are ignored.
func (*Graph[K, V]) SetFormula ¶
SetFormula defines or replaces k as computed from deps by fn. Duplicate deps are collapsed, and declaring a dep on a key that does not exist yet is legal: the node evaluates to *UnresolvedError until the key is defined, then resolves normally.
If the edges would close a cycle, SetFormula returns *CycleError and the graph is left exactly as it was — no partial mutation. fn must not be nil.
k and its transitive dependents are marked stale; nothing is computed.
func (*Graph[K, V]) SetValue ¶
func (g *Graph[K, V]) SetValue(k K, v V)
SetValue defines or replaces k as a constant input. Any formula previously attached to k is discarded along with its edges. Every transitive dependent of k is marked stale; nothing is computed.
func (*Graph[K, V]) Stale ¶
func (g *Graph[K, V]) Stale() []K
Stale returns the keys that are currently dirty and would be recomputed by the next Graph.Recalc, in definition order.
func (*Graph[K, V]) TopoOrder ¶
TopoOrder returns every defined key in dependency order: a key always appears after all of its precedents. The order is deterministic for a given construction sequence.
Since Graph.SetFormula rejects cycles, a well-formed graph never fails here; the error exists so the invariant is checked rather than assumed.
type Inputs ¶
type Inputs[K comparable, V any] struct { // contains filtered or unexported fields }
Inputs is the read-only handle a formula uses to reach its declared dependencies. It is valid only for the duration of the call that received it; retaining it and reading later yields the values captured at call time.
Reads are restricted to the keys the formula declared. Reaching for anything else fails with UndeclaredError rather than silently succeeding, which is what keeps the recorded edges an honest description of the data flow.
func (Inputs[K, V]) Get ¶
Get returns the value of a declared dependency. It fails with *UndeclaredError if k is not one of the formula's declared dependencies.
func (Inputs[K, V]) Keys ¶
func (in Inputs[K, V]) Keys() []K
Keys returns the declared dependencies in declaration order. The returned slice is a copy and may be modified freely.
type UndeclaredError ¶
type UndeclaredError[K comparable] struct { Key K }
UndeclaredError reports that a formula tried to read a key it did not declare as a dependency. Reads are restricted to declared dependencies so that the graph's edges always describe the real data flow.
func (*UndeclaredError[K]) Error ¶
func (e *UndeclaredError[K]) Error() string
type UnresolvedError ¶
type UnresolvedError[K comparable] struct { Key K Missing K }
UnresolvedError reports a reference to a key that is not defined in the graph. Missing is the undefined key; Key is the node that referenced it. When a key is looked up directly with Graph.Get and does not exist, Key and Missing are the same.
Declaring a dependency on a key that does not exist yet is legal — forward references are supported — but evaluating such a node before the key is defined yields this error. Defining the key later marks dependents stale and they resolve normally.
func (*UnresolvedError[K]) Error ¶
func (e *UnresolvedError[K]) Error() string