EventStore

repository·master·Indexed 22 days ago

https://github.com/commanded/eventstore

An Elixir implementation of an event store using PostgreSQL (v9.5+) as its storage engine. It provides primitives for appending events to streams, reading streams, and subscribing to event changes. EventStore supports distributed Erlang clusters, JSON serialization via Jason or custom serializers, and utilizes PostgreSQL's LISTEN/NOTIFY and advisory locks for event publication and subscription management.

Tokens
17.5K
Snippets
70
Records
81
Agent score
77%

What's inside EventStore

  1. Configure subscription concurrency and partitioning

    master

    To implement the competing consumers pattern and increase throughput, you can configure a subscription to support multiple concurrent subscribers.

    Concurrency Options

    • concurrency_limit: The maximum number of concurrent subscribers allowed. Defaults to 1. If exceeded, returns {:error, :too_many_subscribers}.
    • buffer_size: Limits the number of in-flight events sent to the subscriber before an acknowledgement is required. Defaults to 1.
    • partition_by: A function used to distribute events to subscribers. It receives an EventStore.RecordedEvent and returns a partition key.

    Ordering Guarantee with Partitioning

    When using multiple subscribers, global ordering is lost. To maintain ordering for specific groups (e.g., ensuring all events for a single stream are processed in order), use partition_by to return a key like stream_uuid. This ensures all events for a specific stream go to the same subscriber, while different streams are processed concurrently by different subscribers.

    alias EventStore.RecordedEvent
    alias MyApp.EventStore
    
    # Partition by stream_uuid to guarantee per-stream ordering with 10 concurrent subscribers
    by_stream = fn %RecordedEvent{stream_uuid: stream_uuid} -> stream_uuid end
    
    {:ok, _subscription} =
      EventStore.subscribe_to_stream(stream_uuid, "example", self(),
        concurrency_limit: 10,
        partition_by: by_stream
      )
  2. Configure Postgres schemas for isolation

    master

    You can isolate multiple event stores within a single Postgres database by using different schemas. This can be achieved in three ways:

    1. Via the use macro: use EventStore, schema: "example".
    2. Via the init/1 callback: Return {:ok, Keyword.put(config, :schema, "example")}.
    3. Via configuration: Set schema: "example" in your application config.

    Note: mix event_store.<task> commands will automatically handle creating or dropping the specified schema.

    # Option 1: Macro
    defmodule MyApp.EventStore do
      use EventStore, otp_app: :my_app, schema: "example"
    end
    
    # Option 2: init/1
    defmodule MyApp.EventStore do
      use EventStore, otp_app: :my_app
    
      def init(config) do
        {:ok, Keyword.put(config, :schema, "example")}
      end
    end
    
    # Option 3: Config
    config :my_app, MyApp.EventStore, schema: "example"
  3. How event publication and subscriptions work in a cluster

    master

    EventStore supports running on multiple nodes via distributed Erlang or as multiple single-instance nodes.

    • Event Publication: Uses PostgreSQL's LISTEN / NOTIFY. Each node starts a listener database connection process that listens for events and publishes them to local subscription processes. This mechanism is consistent regardless of whether the nodes are part of a distributed Erlang cluster.
    • Subscriptions: Uses PostgreSQL advisory locks to ensure that a uniquely named subscription runs at most once across the entire cluster. This prevents duplicate processing of the same subscription on different nodes. Advisory locks are efficient, stored in memory, and automatically cleaned up when the session ends.
  4. How EventStore subscriptions work

    master

    EventStore provides two distinct subscription models for consuming events:

    1. Transient subscriptions: These broadcast new events to subscribers immediately after they are appended to storage. They do not require acknowledgement and terminate when the subscriber process stops. They are suitable for real-time notifications where missing an event during downtime is acceptable.
    2. Persistent subscriptions: These guarantee at-least-once delivery of every persisted event. They provide back-pressure and allow the subscriber to start, pause, and resume from any position (including the stream's origin). They are suitable for critical processing where every event must be handled.

    Under the hood, EventStore uses PostgreSQL's LISTEN and NOTIFY mechanism. An after-update trigger on the streams table executes a NOTIFY for each batch of inserted events. A single process connects to the database to listen for these notifications and broadcasts them to all active subscriptions. This architecture allows EventStore to run on multiple nodes without requiring a distributed Erlang cluster.

  5. Delete streams (Soft vs Hard delete)

    master

    EventStore supports two types of deletion:

    Soft Delete

    Marks a stream as deleted without removing its events.

    • Events still appear in the global $all stream and linked streams.
    • The stream cannot be read or appended to directly.
    • Subscriptions to the specific stream will stop receiving events, but subscriptions to linked streams (like $all) will still receive them.
    • Default behavior: delete_stream/3 uses :soft if no type is specified.

    Hard Delete

    Permanently removes the stream and all its events. This is irreversible and removes data from the $all stream and linked streams.

    • Disabled by default: You must explicitly enable hard deletes in your module definition or configuration.

    Enabling Hard Deletes

    # In module definition
    defmodule MyApp.EventStore do
      use EventStore, otp_app: :my_app, enable_hard_deletes: true
    end
    
    # OR in config
    config :my_app, MyApp.EventStore, enable_hard_deletes: true
    # Soft delete a stream
    :ok = MyApp.EventStore.delete_stream("stream1", :any_version, :soft)
    
    # Hard delete a stream (requires enable_hard_deletes: true)
    :ok = MyApp.EventStore.delete_stream("stream1", :any_version, :hard)
  6. Understand Persistent Subscriptions

    master

    Persistent subscriptions guarantee at least once delivery of every persisted event. They allow you to pause and resume subscriptions, as the EventStore tracks the last acknowledged event to support restarts.

    Key characteristics:

    • Uniqueness: Subscriptions must have unique names. By default, only one subscriber can connect to a named subscription. Connecting a second subscriber returns {:error, :subscription_already_exists}.
    • Messages: Subscribers receive {:subscribed, subscription} upon success and {:events, events} for batches of EventStore.RecordedEvent structs.
    • Back Pressure: A subscriber will not receive new events until it acknowledges all currently received events using EventStore.ack/2.
    # Example of acknowledging a batch of events
    :ok = EventStore.ack(subscription, events)
  7. Understand PostgreSQL connection requirements for EventStore

    master

    When using PostgreSQL with EventStore, the system maintains three distinct types of database connections. If you configure a pool_size of $N$, you should expect a total of $N + 2$ connections to the database.

    1. Pooled Connection: Used for standard database operations like reading and appending events. This is configured via config/config.exs and uses an :exp (exponential) back-off strategy.
    2. Notification Connection: A dedicated connection used to listen for event notifications via Postgres' LISTEN / NOTIFY mechanism.
    3. Subscription Connection: A dedicated connection used for managing advisory locks. This ensures that only one instance of a subscription runs across multiple nodes.

    Note that the Notification and Subscription connections use a :stop back-off strategy. This means the connection process terminates when the database connection is broken, allowing the EventStore.MonitoredServer to monitor the exit and trigger appropriate after_exit or after_restart callbacks to reacquire locks or notify related processes.

  8. Provide event_type when using JSON serializers

    master

    When using JSON-based serializers, you must explicitly set the event_type field in the %EventStore.EventData{} struct to a string representing the event type. This ensures the event can be correctly identified and deserialized. You can use Atom.to_string/1 on the event struct to generate this string.

    # Example of preparing event data for a JSON serializer
    event = %ExampleEvent{key: "value"}
    
    %EventStore.EventData{
      event_type: Atom.to_string(event.__struct__), # "Elixir.ExampleEvent"
      data: event,
      metadata: %{user: "someuser@example.com"}
    }
  9. Start a cluster using static topology and sys.config

    master

    To start a cluster using static configuration files:

    1. Run epmd:

      $ epmd -d
    2. Start each node passing the specific configuration file via the --erl flag:

      $ MIX_ENV=distributed iex --name node1@127.0.0.1 --erl "-config cluster/node1.sys.config" -S mix
      $ MIX_ENV=distributed iex --name node2@127.0.0.1 --erl "-config cluster/node2.sys.config" -S mix
      $ MIX_ENV=distributed iex --name node3@127.0.0.1 --erl "-config cluster/node3.sys.config" -S mix

    Once formed, you can use the EventStore module from any node in the cluster.

    $ epmd -d
    
    $ MIX_ENV=distributed iex --name node1@127.0.0.1 --erl "-config cluster/node1.sys.config" -S mix
    
    $ MIX_ENV=distributed iex --name node2@127.0.0.1 --erl "-config cluster/node2.sys.config" -S mix
    
    $ MIX_ENV=distributed iex --name node3@127.0.0.1 --erl "-config cluster/node3.sys.config" -S mix
  10. Start a cluster using libcluster and EPMD

    master

    To start a cluster using the libcluster automatic formation method:

    1. Run the Erlang Port Mapper Daemon (epmd):

      $ epmd -d
    2. Start an iex console for each node using the distributed environment:

      $ MIX_ENV=distributed iex --name node1@127.0.0.1 -S mix
      $ MIX_ENV=distributed iex --name node2@127.0.0.1 -S mix
      $ MIX_ENV=distributed iex --name node3@127.0.0.1 -S mix

    The cluster will form automatically upon startup.

    $ epmd -d
    
    $ MIX_ENV=distributed iex --name node1@127.0.0.1 -S mix
    
    $ MIX_ENV=distributed iex --name node2@127.0.0.1 -S mix
    
    $ MIX_ENV=distributed iex --name node3@127.0.0.1 -S mix
  11. Implement a custom JSON serializer using Poison

    master

    If you prefer using Poison for JSON serialization, you must implement the EventStore.Serializer behaviour. The implementation requires two functions: serialize/1 to convert a term to a JSON binary, and deserialize/2 to convert a binary back into a specific type. When deserializing, you can use the :type key in the config argument to specify the target struct.

    defmodule JsonSerializer do
      @moduledoc """
      A serializer that uses the JSON format.
      """
    
      @behaviour EventStore.Serializer
    
      @doc """
      Serialize given term to JSON binary data.
      """
      def serialize(term) do
        Poison.encode!(term)
      end
    
      @doc """
      Deserialize given JSON binary data to the expected type.
      """
      def deserialize(binary, config) do
        type = case Keyword.get(config, :type, nil) do
          nil -> []
          type -> type |> String.to_existing_atom |> struct
        end
        Poison.decode!(binary, as: type)
      end
    end
  12. Install and set up EventStore

    master

    To use EventStore in your Elixir application, follow these steps:

    1. Add dependency: Add {:eventstore, "~> 1.4"} to your mix.exs dependencies and run mix deps.get.
    2. Define your module: Create an event store module using the EventStore macro.
    3. Configure database: Add connection details (username, password, database, hostname, or a url) to your environment configuration.
    4. Register the module: Add your module to the event_stores list in your application's main configuration so mix tasks can find it.
    5. Initialize database: Run mix event_store.create and mix event_store.init to set up the tables.
    6. Supervise the module: Add your event store module to your application's supervision tree.
    # 1. mix.exs
    def deps do
      [{:eventstore, "~> 1.4"}]
    end
    
    # 2. Define module
    defmodule MyApp.EventStore do
      use EventStore, otp_app: :my_app
    
      def init(config) do
        {:ok, config}
      end
    end
    
    # 3. config/dev.exs
    config :my_app, MyApp.EventStore,
      serializer: EventStore.JsonSerializer,
      username: "postgres",
      password: "postgres",
      database: "eventstore",
      hostname: "localhost"
    
    # 4. config/config.exs
    config :my_app, event_stores: [MyApp.EventStore]
    
    # 5. Terminal
    $ mix do event_store.create, event_store.init
    
    # 6. lib/my_app/application.ex
    defmodule MyApp.Application do
      use Application
    
      def start(_type, _args) do
        children = [MyApp.EventStore]
        opts = [strategy: :one_for_one, name: MyApp.Supervisor]
        Supervisor.start_link(children, opts)
      end
    end