§ reference

ActionExecutor

The framework's main entry point. Executes Actions atomically against the configured shards — typical application code calls actionExecutor.execute(principal, MyAction.class, params) and never touches an Action directly.

Type signature

package io.ekbatan.core.action;

public class ActionExecutor {

    public <P, R, A extends Action<P, R>> R execute(Principal principal, Class<A> actionClass, P params) throws Exception;
    public <P, R, A extends Action<P, R>> R execute(Principal principal, Class<A> actionClass, P params, ExecutionConfiguration cfg) throws Exception;

    public final EventPersister eventPersister;   // exposed for backfill jobs and tests

    public static final class Builder {
        public static Builder actionExecutor();
        public Builder namespace(String namespace);
        public Builder databaseRegistry(DatabaseRegistry databaseRegistry);
        public Builder objectMapper(ObjectMapper objectMapper);
        public Builder repositoryRegistry(RepositoryRegistry repositoryRegistry);
        public Builder actionRegistry(ActionRegistry actionRegistry);
        public Builder eventPersister(EventPersister eventPersister);  // optional
        public Builder clock(Clock clock);                              // defaults to UTC
        public Builder defaultExecutionConfiguration(ExecutionConfiguration config);
        public ActionExecutor build();
    }
}

What “atomically” means

Each execute(...) call:

  1. Opens a fresh ActionPlan bound via ScopedValue.
  2. Invokes Action.perform(principal, params), which stages additions, updates, and events on the plan.
  3. Groups the staged changes by shard via each repository’s ShardingStrategy.
  4. Writes everything in TransactionManager.inTransactionChecked(...) per shard — domain rows and the corresponding action_event rows committed in the same transaction.

The outbox is therefore always consistent with the data it describes — by construction, not by convention.

Cross-shard behaviour

By default an action that touches more than one shard is rejected with CrossShardException:

if (shards.size() > 1 && !executionConfiguration.allowCrossShard) {
    throw new CrossShardException(shard1, shard2);
}

Set ExecutionConfiguration.allowCrossShard to true to opt in to per-shard commits — each shard commits independently, there is no 2PC, and the framework logs and traces the cross-shard count and shard set:

LOG.warn("{} spans {} shards: {} [allowCrossShard=true]", actionName, shards.size(), shards);

See Concepts → Sharding strategies → Cross-shard actions for the consistency model and the saga mitigation pattern.

Retries

Each execute(...) call is wrapped in a Retry driver keyed on the configured RetryConfigs. The default ExecutionConfiguration retries StaleRecordException once after 100ms — enough to absorb a transient optimistic-lock conflict without hiding a deeper problem. A retry builds a brand-new ActionPlan so each attempt is logically independent.

Tracing

Every execution emits three nested OpenTelemetry spans:

SpanWraps
ekbatan.action.executeThe whole call, including all retries
ekbatan.action.performPhase 1 (Action.perform()), one per retry attempt
ekbatan.action.persistPhase 2 (per-shard transactions)

Attributes include ekbatan.action.name, ekbatan.action.principal, ekbatan.action.outcome (success / error), and — for cross-shard actions — ekbatan.shard.cross_shard plus the shard set.

Building one

var executor = ActionExecutor.Builder.actionExecutor()
        .namespace("com.example.finance")        // recorded on every event row
        .databaseRegistry(databaseRegistry)      // per-shard pools + transaction managers
        .objectMapper(objectMapper)              // for serializing event payloads
        .repositoryRegistry(repositoryRegistry)  // discovered @EkbatanRepository beans
        .actionRegistry(actionRegistry)          // discovered @EkbatanAction beans
        .clock(Clock.systemUTC())                // the default
        .build();

In a DI-managed app (Spring Boot, Quarkus, Micronaut) the starter wires this for you — see Reference → DI integration for per-framework details. Without DI, see Plain Java wiring.

Custom event persister

eventPersister is exposed as a public final field so applications that want to write events outside of an action (e.g. a backfill job replaying historical state) can reuse the configured persister rather than building one ad-hoc:

// inside a backfill job — write events directly without staging an action
executor.eventPersister.persistEvents(namespace, sourceAction, params, startedAt, events, shard, actionId);

The default is SingleTableJsonEventPersister; override via the builder if you need encrypted payloads or a custom table layout.

See also