Summary
JulcTransactionEvaluator is not thread-safe. A single instance cannot be shared across threads because each evaluateTx(...) call reconfigures cost-model state on a shared, cached VM provider and then evaluates against it — a non-atomic configure-then-use sequence — and the evaluator also holds mutable per-instance state. Today the only safe usage is one JulcTransactionEvaluator per thread / per evaluation context, which is documented in the class Javadoc but not enforced.
This is an enhancement to make a single instance safe to share concurrently.
Background
The individual JulcVmProvider backends (Java, Truffle, Scalus) are themselves safe for concurrent evaluation once configured. The thread-safety gap is in JulcTransactionEvaluator, in two places:
1. Configure-then-evaluate on a shared provider instance
evaluateTx(...) resolves a single lazily-cached provider via getProvider() and then, per call:
var vmProvider = getProvider(); // shared, cached instance
// configure: mutates provider instance state
CostModelUtil.getCostModelFromProtocolParams(params, lang)
.ifPresent(cm -> vmProvider.setCostModelParams(cm.getCosts(), ..., pvMajor, pvMinor));
// evaluate: reads that state
EvalResult evalResult = vmProvider.evaluateWithArgs(program, language, args, budget, options);
setCostModelParams(...) writes per-instance fields (JavaVmProvider.customV3CostModel, TruffleVmProvider.customV3CostModel,
ScalusVmProvider.machineParams). Even though each provider method is individually safe, the set → evaluate sequence is not atomic: with two threads sharing the evaluator, thread A can overwrite the cost model (e.g. a different protocol version / custom params) while thread B is mid-evaluation, producing wrong budgets.
Note: this same per-instance cost-model storage was the root cause of the provider-instance fix (commit dea4633), where configuring one provider instance and evaluating on another silently used the built-in default. The fix consolidated to a single shared instance — correct for single-threaded use, but it makes the shared-mutable-state shape explicit for the concurrent case.
2. Mutable evaluator instance state
scriptSourceMaps and scriptPrograms are plain HashMaps; executionTraceEnabled / builtinTraceEnabled are plain fields; lastTraces is mutated on each call. These are written during setup/registration and read (and lastTraces written) during evaluation, with no synchronization.
Impact
- Any server-side or batch use that shares one evaluator across a thread pool (e.g. concurrent
QuickTx builds) can intermittently bake incorrect ExUnits into transactions — especially when concurrent transactions target different protocol versions or custom cost models.
- Failures are non-deterministic and hard to reproduce; budgets may be silently wrong rather than throwing.
Current mitigation
Documented contract in the class Javadoc: create a new JulcTransactionEvaluator per transaction / evaluation context, or confine one
instance to a single thread. This is sufficient for the common single-threaded QuickTx flow but is a footgun for shared use.
Proposed approaches (in order of preference)
-
Eliminate provider cost-model state — make configuration per-call (recommended). Pass the resolved cost models into the evaluate call (via EvalOptions or a new parameter) instead of via stateful setCostModelParams. The provider becomes stateless w.r.t. cost models, removing the configure-then-use window entirely and preserving full concurrency. Requires a small JulcVmProvider SPI change; EvalOptions already exists as the per-evaluation config carrier, so this fits the existing design.
-
Per-call configured "session" object. Provider exposes something like configure(costModels, pv) -> EvalSession, and evaluation runs on the returned session. No shared mutable state across calls.
-
Make the evaluator's own state immutable-after-construction / thread-confined. Move scriptSourceMaps, scriptPrograms, and trace flags to construction time (or concurrent/immutable structures), and return traces from evaluateTx rather than storing lastTraces as mutable instance state. Needed regardless of which provider approach is chosen.
-
(Fallback, not preferred) Synchronize the configure+evaluate region. Simple, but serializes evaluation and defeats the concurrency the providers already support. Only acceptable as a stopgap.
Acceptance criteria
- A single
JulcTransactionEvaluator instance can be safely used by multiple threads concurrently, with each evaluateTx call producing budgets consistent with its own protocol params / cost model (no cross-call interference).
- No shared mutable cost-model state is read/written across concurrent evaluations without proper isolation or synchronization.
- A concurrency test: N threads evaluating transactions that resolve to different cost models / protocol versions on one shared evaluator, asserting each gets the correct, isolated budget (would fail today).
- Update or remove the "not thread-safe" note in the class Javadoc to reflect the new contract.
Summary
JulcTransactionEvaluator is not thread-safe. A single instance cannot be shared across threads because each evaluateTx(...) call reconfigures cost-model state on a shared, cached VM provider and then evaluates against it — a non-atomic configure-then-use sequence — and the evaluator also holds mutable per-instance state. Today the only safe usage is one JulcTransactionEvaluator per thread / per evaluation context, which is documented in the class Javadoc but not enforced.
This is an enhancement to make a single instance safe to share concurrently.
Background
The individual
JulcVmProviderbackends (Java, Truffle, Scalus) are themselves safe for concurrent evaluation once configured. The thread-safety gap is inJulcTransactionEvaluator, in two places:1. Configure-then-evaluate on a shared provider instance
evaluateTx(...)resolves a single lazily-cached provider viagetProvider()and then, per call:setCostModelParams(...)writes per-instance fields (JavaVmProvider.customV3CostModel,TruffleVmProvider.customV3CostModel,ScalusVmProvider.machineParams). Even though each provider method is individually safe, the set → evaluate sequence is not atomic: with two threads sharing the evaluator, thread A can overwrite the cost model (e.g. a different protocol version / custom params) while thread B is mid-evaluation, producing wrong budgets.2. Mutable evaluator instance state
scriptSourceMapsandscriptProgramsare plainHashMaps;executionTraceEnabled/builtinTraceEnabledare plain fields;lastTracesis mutated on each call. These are written during setup/registration and read (andlastTraceswritten) during evaluation, with no synchronization.Impact
QuickTxbuilds) can intermittently bake incorrect ExUnits into transactions — especially when concurrent transactions target different protocol versions or custom cost models.Current mitigation
Documented contract in the class Javadoc: create a new
JulcTransactionEvaluatorper transaction / evaluation context, or confine oneinstance to a single thread. This is sufficient for the common single-threaded
QuickTxflow but is a footgun for shared use.Proposed approaches (in order of preference)
Eliminate provider cost-model state — make configuration per-call (recommended). Pass the resolved cost models into the evaluate call (via
EvalOptionsor a new parameter) instead of via statefulsetCostModelParams. The provider becomes stateless w.r.t. cost models, removing the configure-then-use window entirely and preserving full concurrency. Requires a smallJulcVmProviderSPI change;EvalOptionsalready exists as the per-evaluation config carrier, so this fits the existing design.Per-call configured "session" object. Provider exposes something like
configure(costModels, pv) -> EvalSession, and evaluation runs on the returned session. No shared mutable state across calls.Make the evaluator's own state immutable-after-construction / thread-confined. Move
scriptSourceMaps,scriptPrograms, and trace flags to construction time (or concurrent/immutable structures), and return traces fromevaluateTxrather than storinglastTracesas mutable instance state. Needed regardless of which provider approach is chosen.(Fallback, not preferred) Synchronize the configure+evaluate region. Simple, but serializes evaluation and defeats the concurrency the providers already support. Only acceptable as a stopgap.
Acceptance criteria
JulcTransactionEvaluatorinstance can be safely used by multiple threads concurrently, with eachevaluateTxcall producing budgets consistent with its own protocol params / cost model (no cross-call interference).