Current section
Files
Jump to
Current section
Files
src/evoq_command_router.erl
%% @doc Command dispatcher with middleware pipeline.
%%
%% Dispatches commands through the middleware chain to aggregates.
%% Supports idempotency, retries, and consistency guarantees.
%%
%% == Dispatch Flow ==
%%
%% 1. Check idempotency cache
%% 2. Create execution context
%% 3. Run before_dispatch middleware
%% 4. Get or start aggregate
%% 5. Execute command on aggregate
%% 6. Run after_dispatch or after_failure middleware
%% 7. Handle consistency (wait for handlers if strong)
%% 8. Cache result for idempotency
%%
%% @author rgfaber
-module(evoq_command_router).
-include("evoq.hrl").
-include("evoq_telemetry.hrl").
%% API
-export([dispatch/2, dispatch_with_state/2]).
%%====================================================================
%% API
%%====================================================================
%% @doc Dispatch a command through the middleware pipeline.
%%
%% If command_id is undefined, auto-generates one.
%% If idempotency_key is set, uses it for deduplication cache.
%% Otherwise, command_id is used (each dispatch is unique).
-spec dispatch(#evoq_command{}, map()) -> {ok, non_neg_integer(), [map()]} | {error, term()}.
dispatch(Command0, Opts) ->
%% Auto-generate command_id if not provided
Command = evoq_command:ensure_id(Command0),
TTL = application:get_env(evoq, idempotency_ttl, ?DEFAULT_IDEMPOTENCY_TTL),
%% Use idempotency_key for cache if provided, otherwise command_id
CacheKey = case Command#evoq_command.idempotency_key of
undefined -> Command#evoq_command.command_id;
Key -> Key
end,
evoq_idempotency:check_and_store(CacheKey, fun() ->
dispatch_internal(Command, Opts)
end, TTL).
%% @doc Dispatch a command and return the post-event aggregate state.
%%
%% Like dispatch/2 but includes the aggregate state after applying all
%% new events. Enables session-level consistency where the caller
%% receives immediate truth about the resulting state.
-spec dispatch_with_state(#evoq_command{}, map()) ->
{ok, non_neg_integer(), [map()], term()} | {error, term()}.
dispatch_with_state(Command0, Opts) ->
Command = evoq_command:ensure_id(Command0),
TTL = application:get_env(evoq, idempotency_ttl, ?DEFAULT_IDEMPOTENCY_TTL),
CacheKey = case Command#evoq_command.idempotency_key of
undefined -> Command#evoq_command.command_id;
Key -> Key
end,
evoq_idempotency:check_and_store(CacheKey, fun() ->
dispatch_internal_with_state(Command, Opts)
end, TTL).
%%====================================================================
%% Internal functions
%%====================================================================
%% @private
dispatch_internal(Command, Opts) ->
StartTime = erlang:system_time(microsecond),
%% Create execution context
Context = evoq_execution_context:new(Command, Opts),
%% Create pipeline
Pipeline = #evoq_pipeline{
command = Command,
context = Context,
assigns = #{},
halted = false,
response = undefined
},
%% Get middleware chain
DefaultMiddleware = application:get_env(evoq, middleware, []),
ExtraMiddleware = maps:get(middleware, Opts, []),
Middleware = DefaultMiddleware ++ ExtraMiddleware,
%% Emit start telemetry
telemetry:execute(?TELEMETRY_DISPATCH_START, #{
system_time => StartTime
}, #{
command_id => Command#evoq_command.command_id,
command_type => Command#evoq_command.command_type,
aggregate_id => Command#evoq_command.aggregate_id
}),
%% Run before_dispatch middleware
Pipeline2 = run_before_dispatch(Pipeline, Middleware),
%% Check if pipeline was halted
case evoq_middleware:halted(Pipeline2) of
true ->
Response = evoq_middleware:get_response(Pipeline2),
emit_stop_telemetry(StartTime, Command, Response),
Response;
false ->
%% Execute the command
execute_and_finalize(Pipeline2, Middleware, StartTime)
end.
%% @private
dispatch_internal_with_state(Command, Opts) ->
StartTime = erlang:system_time(microsecond),
Context = evoq_execution_context:new(Command, Opts),
Pipeline = #evoq_pipeline{
command = Command,
context = Context,
assigns = #{},
halted = false,
response = undefined
},
DefaultMiddleware = application:get_env(evoq, middleware, []),
ExtraMiddleware = maps:get(middleware, Opts, []),
Middleware = DefaultMiddleware ++ ExtraMiddleware,
telemetry:execute(?TELEMETRY_DISPATCH_START, #{
system_time => StartTime
}, #{
command_id => Command#evoq_command.command_id,
command_type => Command#evoq_command.command_type,
aggregate_id => Command#evoq_command.aggregate_id
}),
Pipeline2 = run_before_dispatch(Pipeline, Middleware),
case evoq_middleware:halted(Pipeline2) of
true ->
Response = evoq_middleware:get_response(Pipeline2),
emit_stop_telemetry(StartTime, Command, Response),
Response;
false ->
execute_and_finalize_with_state(Pipeline2, Middleware, StartTime)
end.
%% @private
execute_and_finalize_with_state(Pipeline, Middleware, StartTime) ->
Command = Pipeline#evoq_pipeline.command,
Context = Pipeline#evoq_pipeline.context,
AggregateType = Command#evoq_command.aggregate_type,
AggregateId = Command#evoq_command.aggregate_id,
StoreId = Context#evoq_execution_context.store_id,
case evoq_aggregate_registry:get_or_start(AggregateType, AggregateId, StoreId) of
{ok, Pid} ->
execute_on_aggregate_with_state(Pipeline, Pid, Middleware, StartTime, Context);
{error, Reason} ->
handle_failure(Pipeline, {error, Reason}, Middleware, StartTime)
end.
%% @private
execute_on_aggregate_with_state(Pipeline, Pid, Middleware, StartTime, Context) ->
Command = Pipeline#evoq_pipeline.command,
case evoq_aggregate:execute_command_with_state(Pid, Command) of
{ok, Version, Events, AggState} ->
BaseResult = {ok, Version, Events},
Pipeline2 = evoq_middleware:assign(events, Events, Pipeline),
Pipeline3 = evoq_middleware:assign(version, Version, Pipeline2),
Pipeline4 = evoq_middleware:respond(BaseResult, Pipeline3),
Pipeline5 = evoq_middleware:chain(Pipeline4, after_dispatch, Middleware),
ConsistencyResult = handle_consistency(Pipeline5, Context),
FinalResult = with_agg_state(ConsistencyResult, AggState),
emit_stop_telemetry(StartTime, Command, ConsistencyResult),
FinalResult;
{error, wrong_expected_version} = Error ->
maybe_retry(Pipeline, Error, Middleware, StartTime, Context);
{error, _Reason} = Error ->
handle_failure(Pipeline, Error, Middleware, StartTime)
end.
%% @private Carry the post-command aggregate state into a successful result.
with_agg_state({ok, V, E}, AggState) -> {ok, V, E, AggState};
with_agg_state(Other, _AggState) -> Other.
%% @private
run_before_dispatch(Pipeline, Middleware) ->
evoq_middleware:chain(Pipeline, before_dispatch, Middleware).
%% @private
execute_and_finalize(Pipeline, Middleware, StartTime) ->
Command = Pipeline#evoq_pipeline.command,
Context = Pipeline#evoq_pipeline.context,
%% Get or start the aggregate with the store_id from context
AggregateType = Command#evoq_command.aggregate_type,
AggregateId = Command#evoq_command.aggregate_id,
StoreId = Context#evoq_execution_context.store_id,
case evoq_aggregate_registry:get_or_start(AggregateType, AggregateId, StoreId) of
{ok, Pid} ->
execute_on_aggregate(Pipeline, Pid, Middleware, StartTime, Context);
{error, Reason} ->
handle_failure(Pipeline, {error, Reason}, Middleware, StartTime)
end.
%% @private
execute_on_aggregate(Pipeline, Pid, Middleware, StartTime, Context) ->
Command = Pipeline#evoq_pipeline.command,
case evoq_aggregate:execute_command(Pid, Command) of
{ok, Version, Events} = Result ->
%% Success - run after_dispatch middleware
Pipeline2 = evoq_middleware:assign(events, Events, Pipeline),
Pipeline3 = evoq_middleware:assign(version, Version, Pipeline2),
Pipeline4 = evoq_middleware:respond(Result, Pipeline3),
Pipeline5 = evoq_middleware:chain(Pipeline4, after_dispatch, Middleware),
%% Handle consistency
FinalResult = handle_consistency(Pipeline5, Context),
emit_stop_telemetry(StartTime, Command, FinalResult),
FinalResult;
{error, wrong_expected_version} = Error ->
%% Retry on version conflict
maybe_retry(Pipeline, Error, Middleware, StartTime, Context);
{error, _Reason} = Error ->
handle_failure(Pipeline, Error, Middleware, StartTime)
end.
%% @private
maybe_retry(Pipeline, Error, Middleware, StartTime, Context) ->
case evoq_execution_context:retry(Context) of
{ok, NewContext} ->
%% Retry with updated context
NewPipeline = Pipeline#evoq_pipeline{context = NewContext},
Command = Pipeline#evoq_pipeline.command,
AggregateType = Command#evoq_command.aggregate_type,
AggregateId = Command#evoq_command.aggregate_id,
StoreId = NewContext#evoq_execution_context.store_id,
retry_on_aggregate(
evoq_aggregate_registry:get_or_start(AggregateType, AggregateId, StoreId),
NewPipeline, Pipeline, Middleware, StartTime, NewContext);
{error, too_many_attempts} ->
handle_failure(Pipeline, Error, Middleware, StartTime)
end.
%% @private
retry_on_aggregate({ok, Pid}, NewPipeline, _Pipeline, Middleware, StartTime, NewContext) ->
execute_on_aggregate(NewPipeline, Pid, Middleware, StartTime, NewContext);
retry_on_aggregate({error, Reason}, _NewPipeline, Pipeline, Middleware, StartTime, _NewContext) ->
handle_failure(Pipeline, {error, Reason}, Middleware, StartTime).
%% @private
handle_failure(Pipeline, Error, Middleware, StartTime) ->
Command = Pipeline#evoq_pipeline.command,
Pipeline2 = evoq_middleware:assign(error, Error, Pipeline),
Pipeline3 = evoq_middleware:respond(Error, Pipeline2),
Pipeline4 = evoq_middleware:chain(Pipeline3, after_failure, Middleware),
%% Emit exception telemetry
Duration = erlang:system_time(microsecond) - StartTime,
telemetry:execute(?TELEMETRY_DISPATCH_EXCEPTION, #{
duration => Duration
}, #{
command_id => Command#evoq_command.command_id,
error => Error
}),
evoq_middleware:get_response(Pipeline4).
%% @private
handle_consistency(Pipeline, Context) ->
Response = evoq_middleware:get_response(Pipeline),
Consistency = Context#evoq_execution_context.consistency,
StoreId = Context#evoq_execution_context.store_id,
case {Consistency, Response} of
{eventual, _} ->
Response;
{_, {error, _}} ->
Response;
{strong, {ok, Version, _Events}} ->
AggregateId = Context#evoq_execution_context.aggregate_id,
consistency_outcome(
evoq_consistency:wait_for(StoreId, AggregateId, Version, #{}), Response);
{{handlers, Handlers}, {ok, Version, _Events}} ->
AggregateId = Context#evoq_execution_context.aggregate_id,
consistency_outcome(
evoq_consistency:wait_for(StoreId, AggregateId, Version, #{handlers => Handlers}),
Response);
_ ->
Response
end.
%% @private Map a consistency wait outcome onto the response.
consistency_outcome(ok, Response) -> Response;
consistency_outcome({error, timeout}, _Response) -> {error, consistency_timeout}.
%% @private
emit_stop_telemetry(StartTime, Command, Response) ->
Duration = erlang:system_time(microsecond) - StartTime,
EventCount = case Response of
{ok, _, Events} -> length(Events);
_ -> 0
end,
telemetry:execute(?TELEMETRY_DISPATCH_STOP, #{
duration => Duration,
event_count => EventCount
}, #{
command_id => Command#evoq_command.command_id,
response => Response
}).