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:
- Opens a fresh
ActionPlanbound viaScopedValue. - Invokes
Action.perform(principal, params), which stages additions, updates, and events on the plan. - Groups the staged changes by shard via each repository’s
ShardingStrategy. - Writes everything in
TransactionManager.inTransactionChecked(...)per shard — domain rows and the correspondingaction_eventrows 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:
| Span | Wraps |
|---|---|
ekbatan.action.execute | The whole call, including all retries |
ekbatan.action.perform | Phase 1 (Action.perform()), one per retry attempt |
ekbatan.action.persist | Phase 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
- Concepts → Actions, ActionPlan, ActionExecutor — the conceptual two-phase lifecycle
- Action — the units this executor dispatches
- ActionPlan — what
perform()writes to - TransactionManager — the per-shard machinery the executor calls into
- Source:
ekbatan-core/src/main/java/io/ekbatan/core/action/ActionExecutor.java