Skip to content
Draft
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
5 changes: 5 additions & 0 deletions cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ func NewRunCommand() *cobra.Command {
bytesize.ByteSizeVar(runCmd, new(bytesize.ByteSize), "spool-segment-max-bytes", 0, "Maximum spool segment size before rotation/sealing (0 = use default 256Mi)")
bytesize.ByteSizeVar(runCmd, new(bytesize.ByteSize), "backup-max-segment-bytes", 0, "Maximum incremental-backup export segment size before splitting into a new segment (0 = use default 4Gi)")
runCmd.Flags().Int("numscript-cache-size", 1024, "Maximum number of parsed Numscript programs to cache (LRU eviction)")
runCmd.Flags().String("numscript-engine", "interpreter", "Numscript execution engine on the FSM apply path: 'interpreter' (tree-walking) or 'vm' (bytecode). Must be identical on every node.")
runCmd.Flags().Int("mirror-max-batch-size", 500, "Maximum allowed batch size for mirror sync (server-side cap on user-configured batch size)")
runCmd.Flags().Int("max-execution-plan-size", 4096, "Maximum number of AttributePlan entries an ExecutionPlan may carry; admission rejects proposals beyond this (0 = unlimited)")

Expand Down Expand Up @@ -532,6 +533,10 @@ func LoadConfig(ctx context.Context, cmd *cobra.Command) (*bootstrap.Config, err
cfg.SpoolSegmentMaxBytes = bytesize.Get(cmd, "spool-segment-max-bytes").Int64()
cfg.BackupMaxSegmentBytes = bytesize.Get(cmd, "backup-max-segment-bytes").Int64()
cfg.NumscriptCacheSize = getInt("numscript-cache-size", 1024)
// Numscript execution engine. Only "vm" enables the bytecode VM; any other
// value (including the default "interpreter") uses the tree-walking
// interpreter. Must match on every node (FSM determinism).
cfg.NumscriptUseVM = getString("numscript-engine", "interpreter") == "vm"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [major] Invalid numscript-engine values silently fall back to interpreter

When --numscript-engine / NUMSCRIPT_ENGINE is set to an unrecognised value (e.g. VM, vmm), the code silently falls back to the interpreter instead of failing startup. Because the selected engine affects the FSM apply path, a misconfigured node can diverge from correctly-configured peers, creating an avoidable mixed-engine cluster.

Suggestion: Validate the flag value as exactly interpreter or vm at startup and return a configuration error otherwise, preventing silent misconfiguration.

cfg.MirrorMaxBatchSize = getInt("mirror-max-batch-size", 500)
cfg.MaxExecutionPlanSize = getInt("max-execution-plan-size", 4096)

Expand Down
18 changes: 18 additions & 0 deletions docs/ops/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -3805,6 +3805,24 @@ ledger run --node-id 1 --cluster-id prod-ledger --bootstrap ...
ledger run --node-id 1 --cluster-id prod-ledger --bootstrap --numscript-cache-size 4096 ...
```

### Server `--numscript-engine` Flag

Selects the Numscript execution engine used on the FSM apply path.

| Flag | Default | Description |
|------|---------|-------------|
| `--numscript-engine` | `interpreter` | Numscript execution engine: `interpreter` (tree-walking) or `vm` (bytecode). Any value other than `vm` selects the interpreter. |

**This is cluster-wide configuration and MUST be identical on every node.** The FSM apply path must be deterministic (see the "FSM must be deterministic" invariant), and the two engines are not yet fully equivalent — the VM does not map `set_tx_meta` / `set_account_meta` output — so a mixed-engine cluster would diverge on metadata-setting scripts. Change it via a full cluster restart, not a rolling update, and only after confirming your workload does not depend on script-set metadata when running on the VM.

```bash
# Default: tree-walking interpreter
ledger run --node-id 1 --cluster-id prod-ledger --bootstrap ...

# Opt into the bytecode VM (all nodes must set this)
ledger run --node-id 1 --cluster-id prod-ledger --bootstrap --numscript-engine vm ...
```

### Server `--mirror-max-batch-size` Flag

Server-side cap on the mirror batch size. Each mirror ledger can request a custom batch size via its source config, but the server clamps it to this maximum. This prevents a user from overwhelming the cluster with oversized batches.
Expand Down
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ require (
github.com/databricks/databricks-sql-go v1.10.0
github.com/formancehq/go-libs/v5 v5.7.0
github.com/formancehq/invariants v0.11.0
github.com/formancehq/numscript v0.0.24
github.com/formancehq/numscript v0.0.25-0.20260716123501-ec9561f35a95
github.com/fsnotify/fsnotify v1.5.4
github.com/go-chi/chi/v5 v5.2.5
github.com/go-jose/go-jose/v4 v4.1.4
Expand Down Expand Up @@ -163,7 +163,7 @@ require (
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/getkin/kin-openapi v0.134.0 // indirect
github.com/getsentry/sentry-go v0.35.1 // indirect
github.com/getsentry/sentry-go v0.43.0 // indirect
github.com/go-chi/chi v4.1.2+incompatible // indirect
github.com/go-chi/render v1.0.3 // indirect
github.com/go-faster/city v1.0.1 // indirect
Expand Down Expand Up @@ -303,7 +303,7 @@ require (
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect
Expand Down
30 changes: 14 additions & 16 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -244,24 +244,22 @@ github.com/formancehq/go-libs/v5 v5.7.0 h1:2Z2S3vtOJr45tKpofhPqd0qKrPr6KB/LZZ+Ef
github.com/formancehq/go-libs/v5 v5.7.0/go.mod h1:+jfCYWJ4Z10NGbhmbfon0hGoLe5pysbVQgePrg8M8W4=
github.com/formancehq/invariants v0.11.0 h1:AHFBhK7U8Ak/qoJwgaNU+cAb9w61Ba7dwlcrJTkWBE4=
github.com/formancehq/invariants v0.11.0/go.mod h1:ywzSexCUdhw2dC9Njiv0t4u2KfPVKj6SecFujPgmXno=
github.com/formancehq/numscript v0.0.24 h1:YBiDZ9zLVxTZVhtQ+taRcb6q2jArAvznWMfoWRVYGT0=
github.com/formancehq/numscript v0.0.24/go.mod h1:hC/VY5Vg04F5QkgdPPc6z/YsS/vh8V1qVJVa1VWnYMA=
github.com/formancehq/numscript v0.0.25-0.20260716123501-ec9561f35a95 h1:yewiBd3+upbXb7SRAo3mQg38PuOxJ1jWYlra0QdhdRA=
github.com/formancehq/numscript v0.0.25-0.20260716123501-ec9561f35a95/go.mod h1:DUR23FgL3NVEYvjr8KHt5bGFDpcheydwbvs+iTGohAU=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI=
github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
github.com/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU=
github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE=
github.com/getsentry/sentry-go v0.35.1 h1:iopow6UVLE2aXu46xKVIs8Z9D/YZkJrHkgozrxa+tOQ=
github.com/getsentry/sentry-go v0.35.1/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE=
github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4=
github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0=
github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9 h1:r5GgOLGbza2wVHRzK7aAj6lWZjfbAwiu/RDCVOKjRyM=
github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9/go.mod h1:106OIgooyS7OzLDOpUGgm9fA3bQENb/cFSyyBmMoJDs=
github.com/gkampitakis/ciinfo v0.3.3 h1:28PgAHtW3wG7UCAKuCK+17rBib9iqtLjajuWsVLUPQY=
github.com/gkampitakis/ciinfo v0.3.3/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
github.com/gkampitakis/ciinfo v0.3.4 h1:5eBSibVuSMbb/H6Elc0IIEFbkzCJi3lm94n0+U7Z0KY=
github.com/gkampitakis/ciinfo v0.3.4/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
github.com/gkampitakis/go-snaps v0.5.21 h1:SvhSFeZviQXwlT+dnGyAIATVehkhqRVW6qfQZhCZH+Y=
github.com/gkampitakis/go-snaps v0.5.21/go.mod h1:gC3YqxQTPyIXvQrw/Vpt3a8VqR1MO8sVpZFWN4DGwNs=
github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
Expand Down Expand Up @@ -299,8 +297,8 @@ github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.9.11 h1:/pAaQDLHEoCq/5FFmSKBswWmK6H0e8g4159Kc/X/nqk=
github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
Expand Down Expand Up @@ -439,8 +437,8 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
Expand Down Expand Up @@ -780,8 +778,8 @@ golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
Expand Down
2 changes: 1 addition & 1 deletion internal/application/check/checker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func newTestEngine(t *testing.T) *testEngine {
attrs := attributes.New()

meter := noop.NewMeterProvider().Meter("test")
proc, err := processing.NewRequestProcessor(meter, 0)
proc, err := processing.NewRequestProcessor(meter, 0, false)
require.NoError(t, err)

c, err := cache.New(1000, meter)
Expand Down
6 changes: 5 additions & 1 deletion internal/bootstrap/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,11 @@ type Config struct {
// backup default (4Gi).
BackupMaxSegmentBytes int64
NumscriptCacheSize int
MirrorMaxBatchSize int
// NumscriptUseVM selects the numscript execution engine on the FSM apply
// path: true runs the bytecode VM, false the tree-walking interpreter.
// Cluster-wide config — must be identical on every node (FSM determinism).
NumscriptUseVM bool
MirrorMaxBatchSize int
// MaxExecutionPlanSize caps the number of AttributeCoverage entries an
// ExecutionPlan may carry. 0 disables the cap. See plan.Builder.
MaxExecutionPlanSize int
Expand Down
1 change: 1 addition & 0 deletions internal/bootstrap/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,7 @@ func Module() fx.Option {
bloomFilters,
cfg.ClusterID,
cfg.NumscriptCacheSize,
cfg.NumscriptUseVM,
membership.WriteConfChange,
)
if err != nil {
Expand Down
110 changes: 94 additions & 16 deletions internal/domain/processing/numscript/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"context"
"sync"

"github.com/zeebo/blake3"
"github.com/zeebo/xxh3"
"go.opentelemetry.io/otel/metric"

numscriptlib "github.com/formancehq/numscript"
Expand All @@ -20,7 +20,7 @@ import (
// write-locking on the hot path.
type NumscriptCache struct {
mu sync.RWMutex
cache map[[32]byte]*list.Element
cache map[[16]byte]*list.Element
order *list.List
maxSize int

Expand All @@ -30,14 +30,27 @@ type NumscriptCache struct {

// lruEntry holds the cache key and value for an LRU list element.
type lruEntry struct {
hash [32]byte
hash [16]byte
script parsedScript
}

// parsedScript wraps a parsed Numscript program with any parsing errors.
// parsedScript wraps a parsed Numscript program with any parsing errors, plus
// lazily-populated bytecode-compilation artifacts (the VM execution path). The
// vars encoder and the machine are only produced on the first GetOrCompileVM
// call for a given script and memoized for reuse, mirroring how the parsed AST
// is memoized for the interpreter path.
type parsedScript struct {
program numscriptlib.ParseResult
err domain.Describable

compiledDone bool
encoder numscriptlib.VarsEncoder
compiledErr domain.Describable
// vm is a reusable machine bound to the compiled program (nil when
// compilation failed). It holds mutable per-run register banks + runstate
// that numscriptlib.ExecVm resets on every call, so it may be reused across
// executions — but MUST NOT be executed concurrently (see GetOrCompileVM).
vm *numscriptlib.Vm
}

// NewNumscriptCache creates a new NumscriptCache with the given maximum size.
Expand All @@ -48,23 +61,19 @@ func NewNumscriptCache(maxSize int) *NumscriptCache {
}

return &NumscriptCache{
cache: make(map[[32]byte]*list.Element, maxSize),
cache: make(map[[16]byte]*list.Element, maxSize),
order: list.New(),
maxSize: maxSize,
}
}

// hashScript computes the blake3 hash of the script content.
// Lock-free: allocates a hasher per call (blake3.New is cheap).
func HashScript(script string) [32]byte {
h := blake3.New()
_, _ = h.WriteString(script)

var result [32]byte

h.Sum(result[:0])

return result
// HashScript computes the xxh3 128-bit hash of the script content, used as the
// cache key. xxh3 is a fast non-cryptographic hash; the cache key needs no
// cryptographic collision resistance (the audit hash chain lives elsewhere),
// and 128 bits makes accidental collisions negligible. Lock-free and
// allocation-free.
func HashScript(script string) [16]byte {
Comment thread
NumaryBot marked this conversation as resolved.
Comment thread
NumaryBot marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [major] Non-cryptographic xxh3 hash used as sole cache key risks script collision
reported by NumaryBot, paul-nicolas

Both GetOrParse and GetOrCompileVM use an xxh3 hash as the sole cache lookup key. xxh3 is optimised for speed and is not collision-resistant. A hash collision between two distinct scripts causes the second to silently execute using the first script's compiled AST or VM program. Whether a collision is hit depends on cache warmth, making transaction semantics non-deterministic across nodes and restarts.

Suggestion: Use a collision-resistant key — either the full script text as the map key, or a SHA-256/SHA-512 hash — or verify the cached entry's original script text on every cache hit before returning the cached program.

return xxh3.HashString128(script).Bytes()
}

// GetOrParse retrieves a parsed script from the cache or parses it if not found.
Expand Down Expand Up @@ -131,6 +140,75 @@ func (c *NumscriptCache) GetOrParse(script string) (numscriptlib.ParseResult, do
return parsed, parseErr
}

// GetOrCompileVM returns the vars encoder and a reusable bytecode VM for a
// script, compiling the program and building the machine once and memoizing
// both. Compilation subsumes parsing, so a parse error is surfaced here too.
// The compile + VM build happen outside the write lock; the result is stored on
// the shared cache entry so subsequent runs of the same script skip both.
//
// The returned *Vm is SHARED and holds mutable per-run state (register banks +
// runstate); numscriptlib.ExecVm resets it on every call, so it may be reused
// across executions but MUST NOT be executed concurrently. In the ledger this
// is safe because numscript VM execution only happens on the single-threaded
// FSM apply path — admission dependency discovery uses the interpreter, not the
// VM. Reuse avoids reallocating the machine's register banks
// ([256]big.Int/big.Rat/monetary/string — the dominant per-transaction
// allocation) on every call.
func (c *NumscriptCache) GetOrCompileVM(script string) (numscriptlib.VarsEncoder, *numscriptlib.Vm, domain.Describable) {
// Ensure the script is parsed and cached first; a parse failure is terminal.
parsed, parseErr := c.GetOrParse(script)
if parseErr != nil {
return numscriptlib.VarsEncoder{}, nil, parseErr
}

hash := HashScript(script)

// Fast path: encoder + machine already memoized on the entry.
c.mu.RLock()
if elem, ok := c.cache[hash]; ok {
if entry, _ := elem.Value.(*lruEntry); entry.script.compiledDone {
enc, machine, cErr := entry.script.encoder, entry.script.vm, entry.script.compiledErr
c.mu.RUnlock()

return enc, machine, cErr
}
}
c.mu.RUnlock()

// Compile + build the machine outside the lock — the expensive operations.
enc, prog, err := parsed.Compile()

var (
compileErr domain.Describable
machine *numscriptlib.Vm
)

if err != nil {
compileErr = &domain.ErrNumscriptParse{Details: err.Error()}
} else {
machine = numscriptlib.NewVm(prog)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] Reused VM is execution-history-dependent state on the deterministic FSM path; determinism is unproven (invariant #2)

GetOrCompileVM builds one *numscriptlib.Vm per script and reuses it across every transaction. The Vm holds mutable register banks (stringsRegs/intsRegs/portionsRegs/monetariesRegs [256]…). On each run vm.Exec calls vm.runstate.Reset(...) which clears only the RunState (balances/sources/postings/currentAsset) — the register banks are never cleared.

So a freshly-booted node (fresh NewVm, zeroed regs) and a long-running node (reused VM carrying the previous transaction's register contents) run the same command against different starting register state. Output is identical only if numscript's codegen guarantees write-before-read for every register on every control-flow path — an unenforced, undocumented property of an experimental (feat/exp/vm) library. If it ever fails, two nodes diverge for the same applied index → cache/FSM divergence (invariants #1/#2). The burden of proving determinism is on this change, and there is no test that fresh-VM and reused-VM produce identical results.

Fix: until the library documents/guarantees register hygiene, build a fresh NewVm(prog) per apply (keep the compiled program + encoder memoized — those are immutable). Alternatively, add a differential determinism test (see the test-file comment).

Comment thread
NumaryBot marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [major] Reused VM instance carries uncleared register state across transactions
reported by NumaryBot, paul-nicolas

GetOrCompileVM builds a single *numscriptlib.Vm per script and reuses it across every transaction. vm.Exec calls vm.runstate.Reset(...) which clears only the RunState; the underlying register banks (stringsRegs, intsRegs, portionsRegs, monetariesRegs) are never cleared. A freshly-booted node (zeroed registers) and a long-running node (registers contain previous-transaction state) therefore execute the same instruction stream against different starting register state. Output is identical only if numscript's codegen guarantees write-before-read for every register — an unenforced, undocumented property of an experimental library. If that guarantee fails, two nodes diverge for the same applied Raft index.

Suggestion: Until the library explicitly documents and guarantees that register banks are safe to reuse across invocations, allocate a fresh *Vm per transaction (or call the appropriate reset API if one exists). Add a test that runs a fresh VM and a reused VM with identical inputs and asserts identical outputs across varied scripts.

}

// Store the artifacts on the shared entry (if it still exists).
c.mu.Lock()
defer c.mu.Unlock()

if elem, ok := c.cache[hash]; ok {
entry, _ := elem.Value.(*lruEntry)
if !entry.script.compiledDone {
entry.script.encoder = enc
entry.script.compiledErr = compileErr
entry.script.vm = machine
entry.script.compiledDone = true
}

return entry.script.encoder, entry.script.vm, entry.script.compiledErr
}

// The entry was evicted between parse and compile; return the fresh result.
return enc, machine, compileErr
}

// InitCacheMetrics initializes the cache metrics on the NumscriptCache.
func (c *NumscriptCache) InitCacheMetrics(m metric.Meter) error {
size, err := m.Int64Gauge(
Expand Down
38 changes: 19 additions & 19 deletions internal/domain/processing/numscript/emulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,20 @@ func (s *discoveryStore) GetBalances(_ context.Context, query numscriptlib.Balan

s.balancesCalled = true

balances := make(numscriptlib.Balances, len(query))
for account, assets := range query {
accountBalance := make(numscriptlib.AccountBalance, len(assets))

balances[account] = accountBalance
for _, asset := range assets {
s.queriedVolumes[domain.VolumeKey{
AccountKey: domain.AccountKey{Account: account},
Asset: asset,
}] = struct{}{}
accountBalance[asset] = new(big.Int).Set(MaxForceBalance)
}
balances := make(numscriptlib.Balances, 0, len(query))
for _, item := range query {
s.queriedVolumes[domain.VolumeKey{
AccountKey: domain.AccountKey{Account: item.Account},
Asset: item.Asset,
}] = struct{}{}

balances = append(balances, numscriptlib.BalanceRow{
Account: item.Account,
Asset: item.Asset,
Color: item.Color,
Scope: item.Scope,
Amount: new(big.Int).Set(MaxForceBalance),
})
}

return balances, nil
Expand Down Expand Up @@ -158,13 +160,11 @@ func DiscoverNumscriptDependencies(cache *NumscriptCache, script string, vars ma
var writtenMetadata map[domain.MetadataKey]struct{}
if len(execResult.AccountsMetadata) > 0 {
writtenMetadata = make(map[domain.MetadataKey]struct{})
for account, acctMeta := range execResult.AccountsMetadata {
for key := range acctMeta {
writtenMetadata[domain.MetadataKey{
AccountKey: domain.AccountKey{LedgerName: ledgerName, Account: account},
Key: key,
}] = struct{}{}
}
for _, row := range execResult.AccountsMetadata {
writtenMetadata[domain.MetadataKey{
AccountKey: domain.AccountKey{LedgerName: ledgerName, Account: row.Account},
Key: row.Key,
}] = struct{}{}
}
}

Expand Down
Loading
Loading