Skip to content

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

@satran004

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions