Skip to content

Make JulcTransactionEvaluator thread-safe (currently requires one instance per thread) #40

Description

@satran004

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)

  1. 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.

  2. 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.

  3. 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.

  4. (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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions