evoq_store_subscription (evoq v1.29.2)

View Source

Bridge between event stores and evoq routing infrastructure.

Creates a single $all subscription to a reckon-db store, receiving ALL events in global store order. Events are filtered locally by checking against registered event types in the type registry.

This preserves causal ordering across event types within a store, which is critical when projections for related events must execute in the order they were appended (e.g., license_initiated before license_published).

How It Works

1. Replays all historical events from the store (catch-up phase) 2. Subscribes to the store's $all stream (by_stream, selector $all) 3. Receives ALL new events in store-global order 4. For each event, checks if any handler is registered for its type 5. Routes matching events to evoq_event_router and evoq_pm_router 6. Skips events with no registered handlers (zero cost)

Replay

Step 1 runs on every boot, so events this node had already consumed before it went down come around again. They are REPLAY: every event at a global position below the persisted $all checkpoint, read at boot before this boot's own ack moves it. Replay carries replaying => true in its metadata, from catch-up and from backfill alike. Process managers run handle/3 and apply/2 for it but do not dispatch the commands; event handlers declaring replay_policy() -> skip do not see it (see evoq_event_handler). Events at or above the checkpoint were appended while the node was down and are delivered exactly like live ones.

Limits: the checkpoint is acked every 200 events and on a clean stop, so after a CRASH up to 199 events delivered since the last ack arrive as new again; exactly-once would need a checkpoint per handler. A node whose checkpoint was never moved (evoq before 1.23.3 never acked it) sees its whole history as new once more, on its first boot with 1.24.0. An unreadable checkpoint is treated as "nothing is replay", logged.

Usage

Start one instance per event store:

  %% In your application supervisor or startup:
  evoq_store_subscription:start_link(plugins_store)
  evoq_store_subscription:start_link(settings_store, #{})

All modules implementing evoq behaviours that have registered with evoq_event_type_registry will automatically receive matching events.

Summary

Functions

Keep only the events matching EventType, in order. Exported for testing (pure, no store needed) -- backfill_loop/5 is the only real caller, and needs a live store to exercise past this point.

Start a store subscription with default options.

Start a store subscription with options.

Types

evoq_event/0

-type evoq_event() ::
          #evoq_event{event_id :: binary(),
                      event_type :: binary() | undefined,
                      stream_id :: binary(),
                      version :: non_neg_integer(),
                      data :: map() | binary(),
                      metadata :: map(),
                      tags :: [binary()] | undefined,
                      timestamp :: integer(),
                      epoch_us :: integer(),
                      data_content_type :: binary(),
                      metadata_content_type :: binary(),
                      prev_event_hash :: binary() | undefined}.

Functions

filter_by_type(Events, EventType)

-spec filter_by_type([evoq_event() | term()], binary()) -> [evoq_event() | term()].

Keep only the events matching EventType, in order. Exported for testing (pure, no store needed) -- backfill_loop/5 is the only real caller, and needs a live store to exercise past this point.

start_link(StoreId)

-spec start_link(atom()) -> {ok, pid()} | {error, term()}.

Start a store subscription with default options.

start_link(StoreId, Opts)

-spec start_link(atom(), map()) -> {ok, pid()} | {error, term()}.

Start a store subscription with options.

No options are currently read from Opts -- catch-up always starts at 0 and the live $all subscription's own starting point is always whatever catch_up_historical/2 actually scanned up to (see handle_continue/2), not a caller-supplied position. Opts is still accepted and threaded through (subscribe_to_all/3 receives it) for a future option this shape doesn't need yet -- e.g. pool_size.