├── .formatter.exs ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── config ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── coveralls.json ├── dialyzer.ignore-warnings ├── lib ├── maestro │ ├── aggregate │ │ ├── command_handler.ex │ │ ├── event_handler.ex │ │ ├── projection_handler.ex │ │ ├── root.ex │ │ └── supervisor.ex │ ├── application.ex │ ├── errors.ex │ ├── store.ex │ ├── store │ │ ├── adapter.ex │ │ ├── in_memory.ex │ │ └── postgres.ex │ └── types │ │ ├── command.ex │ │ ├── event.ex │ │ └── snapshot.ex └── mix │ └── tasks │ └── maestro.create.event_store_migration.ex ├── mix.exs ├── mix.lock ├── priv └── repo │ ├── migrations │ ├── 20171005204551_event_log_and_snapshots.exs │ └── 20180423183715_named_aggregates.exs │ └── seeds.exs └── test ├── maestro ├── aggregate_test.exs ├── in_memory_test.exs └── postgres_test.exs ├── support ├── commands │ ├── conditional_increment.ex │ ├── decrement_counter.ex │ ├── increment_counter.ex │ ├── name_counter.ex │ └── raise_command.ex ├── events │ ├── counter_decremented.ex │ ├── counter_incremented.ex │ └── counter_named.ex ├── generators.ex ├── projections │ └── name_projection_handler.ex ├── repo.ex ├── sample_aggregate.ex └── schemas │ └── named_aggregate.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto], 3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | subdirectories: ["priv/*/migrations"], 5 | line_length: 80 6 | ] 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # App artifacts 2 | /_build 3 | /db 4 | /deps 5 | /*.ez 6 | /doc/ 7 | cover/ 8 | 9 | # Generated on crash by the VM 10 | erl_crash.dump 11 | 12 | # Files matching config/*.secret.exs pattern contain sensitive 13 | # data and you should not commit them into version control. 14 | # 15 | # Alternatively, you may comment the line below and commit the 16 | # secrets files as long as you replace their contents by environment 17 | # variables. 18 | /config/*.secret.exs 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.7.0 4 | otp_release: 5 | - 21.0 6 | 7 | services: 8 | - postgresql 9 | 10 | addons: 11 | postgresql: "9.6" 12 | 13 | script: 14 | - "mix compile --warnings-as-errors --force" 15 | - "mix format --check-formatted" 16 | - "mix test" 17 | - "mix credo --strict" 18 | - "mix coveralls.travis" 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Maestro 2 | 3 | Maestro is an event sourcing _library_. It is inspired by CQRS and re-uses 4 | terminology where appropriate. The divergence from being a CQRS framework is 5 | intentional as Maestro focuses on processing commands in a consistent manner and 6 | replaying events in a consistent order. 7 | 8 | Currently, the only storage adapter suited to a multi-node environment is the 9 | `Maestro.Store.Postgres` adapter. The `Maestro.Store.InMemory` adapter exists 10 | for testing purposes only. 11 | 12 | ## Status 13 | [![Hex](http://img.shields.io/hexpm/v/maestro.svg?style=flat)](https://hex.pm/packages/maestro) 14 | [![Build Status](https://travis-ci.org/toniqsystems/maestro.svg?branch=master)](https://travis-ci.org/toniqsystems/maestro) 15 | [![Coverage](https://coveralls.io/repos/github/toniqsystems/maestro/badge.svg)](https://coveralls.io/github/toniqsystems/maestro) 16 | 17 | Documentation is available [here](https://hexdocs.pm/maestro/). 18 | 19 | ## Installation 20 | 21 | ```elixir 22 | def deps do 23 | [{:maestro, "~> 0.2"}] 24 | end 25 | ``` 26 | 27 | ## Database Configuration 28 | 29 | Maestro is intended to be used alongside an existing database/ecto repo. 30 | 31 | ```elixir 32 | config :maestro, 33 | storage_adapter: Maestro.Store.Postgres, 34 | repo: MyApp.Repo 35 | ``` 36 | 37 | To generate the migrations for the snapshot and event logs, do: 38 | 39 | ```bash 40 | mix maestro.create.event_store_migration 41 | ``` 42 | 43 | ## Example 44 | 45 | There are three behaviours that make the command/event lifecycle flow: 46 | `Maestro.Aggregate.CommandHandler`, `Maestro.Aggregate.EventHandler`, and 47 | `Maestro.Aggregate.ProjectionHandler`. Modules implementing the command and 48 | event handler behaviours are looked up via a configurable `:command_prefix` and 49 | `:event_prefix` respectively. Projections are reserved for maintaining other 50 | models/representations within the event's transaction. 51 | 52 | ```elixir 53 | defmodule MyApp.Aggregate do 54 | use Maestro.Aggregate.Root, 55 | command_prefix: MyApp.Aggregate.Commands, 56 | event_prefix: MyApp.Aggregate.Events 57 | 58 | def initial_state, do: %{"value" => 0} 59 | 60 | def prepare_snapshot(state), do: state 61 | 62 | def use_snapshot(_curr, %Maestro.Types.Snapshot{body: state}), do: state 63 | end 64 | 65 | defmodule MyApp.Aggregate.Commands.IncrementCounter do 66 | 67 | @behaviour Maestro.Aggregate.CommandHandler 68 | 69 | alias Maestro.Types.Event 70 | 71 | def eval(aggregate, _command) do 72 | [ 73 | %Event{ 74 | aggregate_id: aggregate.id, 75 | type: "counter_incremented", 76 | body: %{} 77 | } 78 | ] 79 | end 80 | end 81 | 82 | defmodule MyApp.Aggregate.Events.CounterIncremented do 83 | 84 | @behaviour Maestro.Aggregate.EventHandler 85 | 86 | def apply(state, _event), do: Map.update!(state, "value", &(&1 + 1)) 87 | end 88 | ``` 89 | 90 | ```elixir 91 | iex(1)> {:ok, id} = MyApp.Aggregate.new() 92 | iex(2)> :ok = MyApp.Aggregate.evaluate(%Maestro.Types.Command{aggregate_id: id, type: "increment_counter", data: %{}}) 93 | iex(3)> {:ok, %{"value" => 1}} = MyApp.Aggregate.get(id) 94 | ``` 95 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | use Mix.Config 7 | 8 | # Configures Elixir's Logger 9 | config :logger, :console, 10 | format: "$time $metadata[$level] $message\n", 11 | metadata: [:request_id] 12 | 13 | # Import environment specific config. This must remain at the bottom 14 | # of this file so it overrides the configuration defined above. 15 | import_config "#{Mix.env()}.exs" 16 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Do not include metadata nor timestamps in development logs 4 | config :logger, :console, format: "[$level] $message\n" 5 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Do not print debug messages in production 4 | config :logger, level: :info 5 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Print only warnings and errors during test 4 | config :logger, level: :warn 5 | 6 | config :maestro, ecto_repos: [Maestro.Repo] 7 | 8 | config :maestro, :repo, Maestro.Repo 9 | 10 | # Configure your database 11 | config :maestro, Maestro.Repo, 12 | username: "postgres", 13 | password: "postgres", 14 | database: "maestro_test", 15 | hostname: "localhost", 16 | pool: Ecto.Adapters.SQL.Sandbox 17 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "lib/mix" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /dialyzer.ignore-warnings: -------------------------------------------------------------------------------- 1 | lib/mix/tasks/maestro.create.event_store_migration.ex:1: Callback info about the 'Elixir.Mix.Task' behaviour is not available 2 | :0: Unknown function 'Elixir.EEx.Engine':'fetch_assign!'/2 3 | :0: Unknown function 'Elixir.Mix.Generator':create_directory/1 4 | :0: Unknown function 'Elixir.Mix.Generator':create_file/2 -------------------------------------------------------------------------------- /lib/maestro/aggregate/command_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.Aggregate.CommandHandler do 2 | @moduledoc """ 3 | Simple behaviour for properly implementing command handlers the way that 4 | maestro expects. Its use is not required but is encouraged. 5 | """ 6 | 7 | @type root :: Maestro.Aggregate.Root.t() 8 | @type command :: Maestro.Types.Command.t() 9 | @type uncommitted_event :: Maestro.Types.Event.uncommitted() 10 | 11 | @doc """ 12 | Command handlers in maestro should implement an `eval` function that expects 13 | to receive the current `Root` object complete with sequence number and 14 | aggregate ID and the incoming command. They should return a list of events or 15 | raise an error which can be used to short circuit the command processing 16 | cycle. 17 | """ 18 | @callback eval(root(), command()) :: [uncommitted_event()] 19 | end 20 | -------------------------------------------------------------------------------- /lib/maestro/aggregate/event_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.Aggregate.EventHandler do 2 | @moduledoc """ 3 | Minimal behaviour for a proper event handler. Like the `CommandHandler`, the 4 | use of the behaviour is not strictly required. 5 | """ 6 | 7 | @type event :: Maestro.Types.Event.t() 8 | 9 | @doc """ 10 | Event handlers must succeed in their application of the event. 11 | Validation and other forms of rejection/failure should be done in the command 12 | handler. This is made evident in the spec for `apply` in that the result 13 | should always be a new valid state. 14 | """ 15 | @callback apply(any(), event()) :: any() 16 | end 17 | -------------------------------------------------------------------------------- /lib/maestro/aggregate/projection_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.Aggregate.ProjectionHandler do 2 | @moduledoc """ 3 | `ProjectionHandler`s are used to manage alternate representations of an 4 | aggregate. 5 | 6 | This defines a minimal behaviour for use within the aggregate command/event 7 | lifecycle. For projections that should be updated immediately iff the relevant 8 | events are committed, the relevant `ProjectionHandler` should by included in 9 | the list of `:projections` on the aggregate root. 10 | """ 11 | 12 | @type event :: Maestro.Types.Event.t() 13 | 14 | @doc """ 15 | Projections registered with an aggregate root are invoked for _every_ event, 16 | so they should ignore unrelated events explicitly. 17 | """ 18 | @callback project(event()) :: value :: any() 19 | end 20 | -------------------------------------------------------------------------------- /lib/maestro/aggregate/root.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.Aggregate.Root do 2 | @moduledoc """ 3 | Core behaviour and functionality provided by Maestro for processing commands 4 | and managing aggregate state. 5 | 6 | Traditional domain entities are referred to as aggregates in the literature. 7 | At the outermost edge of a bounded context, you find an aggregate root. The 8 | goal of this library is to greatly simplify the process of implementing an 9 | event sourced application by owning the flow of non-domain data (i.e. 10 | commands, events, and snapshots) to allow you to focus on the business logic 11 | of evaluating your commands and applying the subsequent events to your domain 12 | objects. 13 | 14 | The most crucial piece to this is the aggregate root. 15 | `Maestro.Aggregate.CommandHandler` defines a `behaviour` with the goal of 16 | isolating a single command handler's `eval`. Similarly, there is the 17 | `Maestro.Aggregate.EventHandler` behaviour which defines how to `apply` that 18 | event to the aggregate. With these key components modeled explicitly, the 19 | `Maestro.Aggregate.Root` focuses on the dataflow and ensuring that queries to 20 | aggregate state flow properly. 21 | 22 | The aggregate root dispatches to the particular command handlers and event 23 | handlers by means of an opinionated dynamic dispatch. To ensure that these 24 | things are handled in a consistent manner, the aggregate root is modeled as a 25 | `GenServer` and provides the requisite lifecycle hooks. 26 | 27 | `use Maestro.Aggregate.Root` takes the following options: 28 | * `:command_prefix` - module prefix for finding commands 29 | * `:event_prefix` - module prefix for finding events 30 | * `:projections` - zero or more modules that implement the `ProjectionHandler` 31 | behaviour for the events that are generated by this aggregate root. These 32 | projections are invoked within the transaction that commits the events. 33 | """ 34 | 35 | alias Maestro.{InvalidHandlerError, Store} 36 | 37 | alias Maestro.Aggregate.Supervisor 38 | alias Maestro.Types.Snapshot 39 | 40 | defstruct [ 41 | :id, 42 | :sequence, 43 | :state, 44 | :module, 45 | :command_prefix, 46 | :event_prefix, 47 | :projections 48 | ] 49 | 50 | @type id :: HLClock.Timestamp.t() 51 | 52 | @type stack :: Exception.stacktrace() 53 | 54 | @type sequence :: non_neg_integer() 55 | 56 | @type command :: Maestro.Types.Command.t() 57 | 58 | @type t :: %__MODULE__{ 59 | id: id(), 60 | sequence: sequence(), 61 | state: any(), 62 | module: module(), 63 | command_prefix: module(), 64 | event_prefix: module(), 65 | projections: [module()] 66 | } 67 | 68 | defmacro __using__(opts) do 69 | quote location: :keep do 70 | use GenServer 71 | 72 | alias HLClock.Timestamp 73 | alias unquote(__MODULE__) 74 | 75 | @behaviour unquote(__MODULE__) 76 | @before_compile unquote(__MODULE__) 77 | @opts unquote(opts) 78 | @command_prefix Keyword.get(@opts, :command_prefix, __MODULE__) 79 | @event_prefix Keyword.get(@opts, :event_prefix, __MODULE__) 80 | @projections Keyword.get(@opts, :projections, []) 81 | 82 | # Public API 83 | 84 | def get(agg_id), do: call(agg_id, :get_current) 85 | 86 | def fetch(agg_id), do: call(agg_id, :fetch) 87 | 88 | def replay(agg_id, seq), do: call(agg_id, {:replay, seq}) 89 | 90 | def evaluate(%Maestro.Types.Command{} = command) do 91 | call(command.aggregate_id, {:eval_command, command}) 92 | end 93 | 94 | def evaluate(_), do: raise(ArgumentError, "invalid command") 95 | 96 | def snapshot(agg_id) do 97 | with {:ok, snap} <- call(agg_id, :get_snapshot) do 98 | Root.persist_snapshot(snap) 99 | end 100 | rescue 101 | err -> {:error, err, __STACKTRACE__} 102 | end 103 | 104 | def call(agg_id, msg) do 105 | agg_id 106 | |> Root.whereis(__MODULE__) 107 | |> GenServer.call(msg) 108 | end 109 | 110 | def new do 111 | with {:ok, agg_id} <- HLClock.now() do 112 | Root.whereis(agg_id, __MODULE__) 113 | {:ok, agg_id} 114 | end 115 | end 116 | 117 | # Callback Functions 118 | 119 | def initial_state, do: %{} 120 | 121 | def prepare_snapshot(state), do: state 122 | 123 | def use_snapshot(_, snapshot), do: snapshot.body 124 | 125 | defoverridable initial_state: 0, prepare_snapshot: 1, use_snapshot: 2 126 | 127 | # GenServer Functions 128 | 129 | def init(%Timestamp{} = id) do 130 | send(self(), :init) 131 | 132 | agg = 133 | Root.create_aggregate( 134 | id, 135 | __MODULE__, 136 | @command_prefix, 137 | @event_prefix, 138 | @projections 139 | ) 140 | 141 | {:ok, agg} 142 | end 143 | 144 | def handle_call(:fetch, _from, agg) do 145 | agg = Root.update_aggregate(agg) 146 | {:reply, {:ok, agg.state}, agg} 147 | rescue 148 | err -> 149 | {:reply, {:error, err, __STACKTRACE__}, agg} 150 | end 151 | 152 | def handle_call(:get_current, _from, agg) do 153 | {:reply, {:ok, agg.state}, agg} 154 | end 155 | 156 | def handle_call({:replay, seq}, _from, agg) do 157 | {:reply, {:ok, Root.replay(agg, seq)}, agg} 158 | rescue 159 | err -> {:reply, {:error, err, __STACKTRACE__}, agg} 160 | end 161 | 162 | def handle_call(:get_snapshot, _from, agg) do 163 | body = prepare_snapshot(agg.state) 164 | {:reply, {:ok, Root.to_snapshot(agg, body)}, agg} 165 | rescue 166 | err -> {:reply, {:error, err, __STACKTRACE__}, agg} 167 | end 168 | 169 | def handle_call({:eval_command, command}, _from, agg) do 170 | {:reply, :ok, Root.eval_command(agg, command)} 171 | rescue 172 | err -> {:reply, {:error, err, __STACKTRACE__}, agg} 173 | end 174 | 175 | def handle_info(:init, agg), do: {:noreply, Root.update_aggregate(agg)} 176 | end 177 | end 178 | 179 | defmacro __before_compile__(_) do 180 | quote do 181 | def start_link(%HLClock.Timestamp{} = agg_id) do 182 | name = {:via, Registry, {Maestro.Aggregate.Registry, agg_id}} 183 | GenServer.start_link(__MODULE__, agg_id, name: name) 184 | end 185 | 186 | def handle_info(_msg, agg), do: {:noreply, agg} 187 | end 188 | end 189 | 190 | @doc """ 191 | Create a new aggregate along with the provided `initial_state` function. This 192 | function should only fail if there was a problem generating an HLC timestamp. 193 | """ 194 | @callback new() :: {:ok, id()} | {:error, any()} 195 | 196 | @doc """ 197 | When an aggregate root is created, this callback is invoked to generate the 198 | state 199 | """ 200 | @callback initial_state() :: any() 201 | 202 | @doc """ 203 | Snapshots are stored in a single-row-per-aggregate manner and are used to make 204 | it easier/faster to hydrate the aggregate root. This function should return 205 | the map which will be JSON encoded when moving to a durable store. 206 | """ 207 | @callback prepare_snapshot(root :: t()) :: map() 208 | 209 | @doc """ 210 | Moving from the snapshotted representation to the aggregate root's structure 211 | can be a complicated process that requires custom hooks. Otherwise, a default 212 | implementation is provided that simply lifts the map out of the snapshot and 213 | uses it as the state of the aggregate. 214 | """ 215 | @callback use_snapshot(root :: t(), snapshot :: Snapshot.t()) :: any() 216 | 217 | @doc """ 218 | A (potentially) stale read of the aggregate's state. If you want to ensure the 219 | state is as up-to-date as possible, see `fetch/1`. 220 | """ 221 | @callback get(id()) :: {:ok, any()} | {:error, any(), stack()} 222 | 223 | @doc """ 224 | Forces the aggregate to retrieve any events. Since Maestro operates in a 225 | node-local manner, it's entirely possible some other node has processed 226 | commands/events. 227 | """ 228 | @callback fetch(id()) :: {:ok, any()} | {:error, any(), stack()} 229 | 230 | @doc """ 231 | Recover a past version of the aggregate's state by specifying a maximum 232 | sequence number. The aggregate's snapshot and any/all events will be used to 233 | get the state back to that point. 234 | """ 235 | @callback replay(id(), sequence()) :: {:ok, any()} | {:error, any(), stack()} 236 | 237 | @doc """ 238 | Evaluate the command within the aggregate's context. 239 | """ 240 | @callback evaluate(command()) :: :ok | {:error, any(), stack()} 241 | 242 | @doc """ 243 | Using the aggregate root's `prepare_snapshot` function, generate and store a 244 | snapshot. Useful if there are a lot of events, big events, or just a healthy 245 | amount of aggregate state to compose. 246 | """ 247 | @callback snapshot(id()) :: :ok | {:error, any(), stack()} 248 | 249 | @doc """ 250 | If you extend the aggregate to provide other functionality, `call` is 251 | available to assist in pushing that functionality into the aggregate's 252 | context. 253 | """ 254 | @callback call(id(), msg :: any()) :: any() 255 | 256 | @optional_callbacks initial_state: 0, prepare_snapshot: 1, use_snapshot: 2 257 | 258 | def child_spec(opts) do 259 | %{ 260 | id: __MODULE__, 261 | start: {__MODULE__, :start_link, [opts]}, 262 | type: :worker, 263 | restart: :temporary, 264 | shutdown: 500 265 | } 266 | end 267 | 268 | def start_link(opts) do 269 | mod = Keyword.fetch!(opts, :module) 270 | id = Keyword.fetch!(opts, :aggregate_id) 271 | mod.start_link(id) 272 | end 273 | 274 | @doc """ 275 | struct to event type of the form "The.ModuleName -> the.module_name" dropping 276 | the provided prefix for conciseness 277 | """ 278 | @spec event_type(prefix :: module(), struct()) :: String.t() 279 | def event_type(pre, str) do 280 | len = 281 | pre 282 | |> Module.split() 283 | |> Enum.count() 284 | 285 | str.__struct__ 286 | |> Module.split() 287 | |> Stream.drop(len) 288 | |> Stream.map(&Macro.underscore/1) 289 | |> Enum.join(".") 290 | end 291 | 292 | @doc false 293 | def create_aggregate(agg_id, mod, com_pref, eve_pref, projs) do 294 | %__MODULE__{ 295 | id: agg_id, 296 | sequence: 0, 297 | state: mod.initial_state(), 298 | module: mod, 299 | command_prefix: com_pref, 300 | event_prefix: eve_pref, 301 | projections: projs 302 | } 303 | end 304 | 305 | @doc false 306 | def update_aggregate(agg, max_seq \\ Store.max_sequence()) do 307 | # from latest snapshot 308 | agg = 309 | case Store.get_snapshot( 310 | agg.id, 311 | agg.sequence, 312 | max_sequence: max_seq 313 | ) do 314 | nil -> 315 | agg 316 | 317 | %Snapshot{} = snap -> 318 | %{ 319 | agg 320 | | state: agg.module.use_snapshot(agg, snap), 321 | sequence: snap.sequence 322 | } 323 | end 324 | 325 | # plus trailing events 326 | events = Store.get_events(agg.id, agg.sequence, max_sequence: max_seq) 327 | apply_events(agg, events) 328 | end 329 | 330 | @doc false 331 | def replay(agg, target_seq) do 332 | agg.id 333 | |> create_aggregate( 334 | agg.module, 335 | agg.command_prefix, 336 | agg.event_prefix, 337 | agg.projections 338 | ) 339 | |> update_aggregate(target_seq) 340 | |> Map.get(:state) 341 | end 342 | 343 | @doc false 344 | def eval_command(agg, command) do 345 | with agg <- update_aggregate(agg), 346 | com_module <- lookup_module(agg.command_prefix, command.type), 347 | events <- com_module.eval(agg, command), 348 | events <- prepare_events(agg, events) do 349 | persist_events(agg, command, events) 350 | end 351 | end 352 | 353 | @doc false 354 | def persist_snapshot(snapshot) do 355 | Store.commit_snapshot(snapshot) 356 | end 357 | 358 | defp prepare_events(agg, events) do 359 | events 360 | |> Enum.with_index(agg.sequence + 1) 361 | |> Enum.reduce([], fn {event, seq}, evs -> 362 | with {:ok, ts} <- HLClock.now() do 363 | [%{event | timestamp: ts, sequence: seq} | evs] 364 | end 365 | end) 366 | end 367 | 368 | defp persist_events(agg, command, events) do 369 | case Store.commit_all(events, agg.projections) do 370 | :ok -> 371 | apply_events(agg, events) 372 | 373 | {:error, :retry_command} -> 374 | agg 375 | |> update_aggregate() 376 | |> eval_command(command) 377 | end 378 | end 379 | 380 | @doc false 381 | def apply_events(agg, []), do: agg 382 | 383 | @doc false 384 | def apply_events(agg, events) do 385 | state = 386 | Enum.reduce(events, agg.state, fn event, state -> 387 | module = lookup_module(agg.event_prefix, event.type) 388 | module.apply(state, event) 389 | end) 390 | 391 | %{agg | state: state, sequence: max_seq(events)} 392 | end 393 | 394 | @doc false 395 | def to_snapshot(agg, body) do 396 | %Snapshot{ 397 | aggregate_id: agg.id, 398 | sequence: agg.sequence, 399 | body: body 400 | } 401 | end 402 | 403 | @doc false 404 | def lookup_module(prefix, type) do 405 | name = 406 | type 407 | |> String.split(".") 408 | |> Enum.map_join(".", &Macro.camelize/1) 409 | 410 | case Code.ensure_loaded(Module.safe_concat(prefix, name)) do 411 | {:module, module} -> module 412 | end 413 | rescue 414 | _ -> reraise(InvalidHandlerError, [type: type], __STACKTRACE__) 415 | end 416 | 417 | @doc """ 418 | Look up an aggregate by its ID. The module is provided to start the right type 419 | of aggregate should it not already be started. 420 | """ 421 | def whereis(agg_id, mod), do: Supervisor.get_child(agg_id, mod) 422 | 423 | defp max_seq(events), do: events |> List.last() |> Map.get(:sequence) 424 | end 425 | -------------------------------------------------------------------------------- /lib/maestro/aggregate/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.Aggregate.Supervisor do 2 | @moduledoc """ 3 | Supervisor for `Maestro.Aggregate.Root`s across any/all domains. 4 | 5 | All aggregate roots, no matter how many different kinds you may have, are 6 | managed by a single supervisor/registry (for now). Given that aggregates are 7 | independently configurable and extensible, the need for a 1:1 on supervisors 8 | per aggregate is a premature optimization. Furthermore, aggregate IDs are HLC 9 | timestamps and are thus unique even across aggregates. 10 | """ 11 | 12 | use DynamicSupervisor 13 | 14 | alias Maestro.Aggregate.Root 15 | 16 | def start_link(args) do 17 | DynamicSupervisor.start_link(__MODULE__, args, name: __MODULE__) 18 | end 19 | 20 | def get_child(key, mod) do 21 | spec = {Root, aggregate_id: key, module: mod} 22 | 23 | case DynamicSupervisor.start_child(__MODULE__, spec) do 24 | {:ok, pid} -> pid 25 | {:error, {:already_started, pid}} -> pid 26 | end 27 | end 28 | 29 | def init(_args) do 30 | DynamicSupervisor.init(strategy: :one_for_one) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/maestro/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | def start(_type, _args) do 7 | children = [ 8 | {Registry, keys: :unique, name: Maestro.Aggregate.Registry}, 9 | {Maestro.Aggregate.Supervisor, []} 10 | ] 11 | 12 | opts = [strategy: :one_for_all, name: __MODULE__] 13 | Supervisor.start_link(children, opts) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/maestro/errors.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.InvalidHandlerError do 2 | @moduledoc """ 3 | An exception that will be raised by `Maestro.Aggregate.Root.lookup_module/2` 4 | if it fails to find the module implied by the prefix and type provided. 5 | """ 6 | 7 | defexception type: "" 8 | 9 | def message(exception) do 10 | """ 11 | Could not find the matching Command or Event Handler for #{exception.type}. 12 | """ 13 | end 14 | end 15 | 16 | defmodule Maestro.InvalidCommandError do 17 | @moduledoc """ 18 | The preferred exception for informing the client that their command was 19 | rejected for any reason. 20 | """ 21 | 22 | defexception message: "" 23 | end 24 | -------------------------------------------------------------------------------- /lib/maestro/store.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.Store do 2 | @moduledoc """ 3 | Concise API for events and snapshots. 4 | 5 | If you are using the `Maestro.Store.Postgres` adapter, an `Ecto.Repo` should 6 | be provided. 7 | """ 8 | 9 | @default_options [max_sequence: 2_147_483_647] 10 | 11 | @type id :: HLClock.Timestamp.t() 12 | 13 | @type sequence :: non_neg_integer() 14 | 15 | @type event :: Maestro.Types.Event.t() 16 | 17 | @type events :: [event()] 18 | 19 | @type snapshot :: Maestro.Types.Snapshot.t() 20 | 21 | @type opts :: [{:max_sequence, sequence()}] 22 | 23 | @doc """ 24 | Commit the events and apply all projections within a transaction. If there's a 25 | sequence number conflict, the events and projections will be discarded such 26 | that the command generating these components could be retried. 27 | """ 28 | @spec commit_all(events(), [module()]) :: :ok | {:error, :retry_command} 29 | def commit_all(events, projections) do 30 | adapter().commit_all(events, projections) 31 | end 32 | 33 | @doc """ 34 | Commit the events provided iff there is no sequence number conflict. 35 | Otherwise, the command should be retried as indicated by the specific error 36 | tuple. 37 | """ 38 | @spec commit_events(events()) :: :ok | {:error, :retry_command} 39 | def commit_events(events) do 40 | adapter().commit_events(events) 41 | end 42 | 43 | @doc """ 44 | Store the snapshot iff the sequence number is greater than what is in the 45 | store. This allows nodes that are partitioned from each other to treat the 46 | store as the source of truth even when writing snapshots. 47 | """ 48 | @spec commit_snapshot(snapshot()) :: :ok 49 | def commit_snapshot(snapshot) do 50 | adapter().commit_snapshot(snapshot) 51 | end 52 | 53 | @doc """ 54 | Retrieve all events for a specific aggregate by id and minimum sequence number. 55 | 56 | Options include: 57 | * `:max_sequence` - useful hydration purposes (defaults to `max_sequence/0`) 58 | """ 59 | @spec get_events(id(), sequence(), opts()) :: events() 60 | def get_events(aggregate_id, seq, opts \\ []) do 61 | options = 62 | @default_options 63 | |> Keyword.merge(opts) 64 | |> Enum.into(%{}) 65 | 66 | adapter().get_events(aggregate_id, seq, options) 67 | end 68 | 69 | @doc """ 70 | Retrieve a snapshot by aggregate id and minimum sequence number. If no 71 | snapshot is found, nil is returned. 72 | 73 | Options include: 74 | * `:max_sequence` - useful hydration purposes (defaults to `max_sequence/0`) 75 | """ 76 | @spec get_snapshot(id(), sequence(), opts()) :: snapshot() | nil 77 | def get_snapshot(aggregate_id, seq, opts \\ []) do 78 | options = 79 | @default_options 80 | |> Keyword.merge(opts) 81 | |> Enum.into(%{}) 82 | 83 | adapter().get_snapshot(aggregate_id, seq, options) 84 | end 85 | 86 | defp adapter do 87 | Application.get_env(:maestro, :storage_adapter, Maestro.Store.InMemory) 88 | end 89 | 90 | @doc """ 91 | Return the maximum allowable sequence number permitted by the durable storage 92 | adapter. 93 | """ 94 | @spec max_sequence :: non_neg_integer() 95 | def max_sequence, do: @default_options[:max_sequence] 96 | end 97 | -------------------------------------------------------------------------------- /lib/maestro/store/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.Store.Adapter do 2 | @moduledoc """ 3 | Defines the minimal API for a well-behaved storage implementation. 4 | """ 5 | alias Maestro.Types.{Event, Snapshot} 6 | 7 | @type id :: Event.aggregate_id() 8 | 9 | @type seq :: Event.sequence() 10 | 11 | @type options :: map() 12 | 13 | @doc """ 14 | If any transactional projections are present, this function is an extension of 15 | `commit_events` that within the same transaction applies all projections to 16 | the store as well. Otherwise, this function dispatches to `commit_events`. 17 | """ 18 | @callback commit_all([Event.t()], [module()]) :: 19 | :ok 20 | | {:error, :retry_command} 21 | 22 | @doc """ 23 | Events are validated according to the `Event.changeset/1` function. If 24 | successful, events are committed transactionally. In the event of a conflict 25 | on sequence number, the storage mechanism should indicate that the command 26 | _could be_ retried by returning `{:error, :retry_command}`. The `Aggregate`'s 27 | command lifecycle will see the conflict and update the aggregate's state 28 | before attempting to evaluate the command again. This allows for making 29 | stricter evaluation rules for commands. If the events could not be committed 30 | for any other reason, the storage mechanism should raise an appropriate 31 | exception. 32 | """ 33 | @callback commit_events([Event.t()]) :: 34 | :ok 35 | | {:error, :retry_command} 36 | | :no_return 37 | 38 | @doc """ 39 | Snapshots are committed iff the proposed version is newer than the version 40 | already stored. This allows disconnected nodes to optimistically write their 41 | snapshots and still have a single version stored without conflicts. 42 | """ 43 | @callback commit_snapshot(Snapshot.t()) :: :ok | :no_return 44 | 45 | @doc """ 46 | Events are retrieved by aggregate_id and with at least a minimum sequence 47 | number, `seq`. They should be ordered by sequence number to ensure that 48 | aggregates always process events in the same order. 49 | 50 | Additional option(s): 51 | * `:max_sequence` (integer): a hard upper limit on the sequence number. 52 | This is useful when attempting to recreate a past state of an aggregate. 53 | """ 54 | @callback get_events(id, seq, options) :: [Event.t()] 55 | 56 | @doc """ 57 | Snapshots can also be retrieved by aggregate_id and with at least a minimum 58 | sequence number, `seq`. 59 | 60 | Additional option(s): 61 | * `:max_sequence` (integer): a hard upper limit on the sequence number. 62 | This is useful when attempting to recreate a past state of an aggregate. 63 | """ 64 | @callback get_snapshot(id, seq, options) :: nil | Snapshot.t() 65 | end 66 | -------------------------------------------------------------------------------- /lib/maestro/store/in_memory.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.Store.InMemory do 2 | @moduledoc """ 3 | Agent-based implementation of the event/snapshot storage mechanism 4 | """ 5 | 6 | @behaviour Maestro.Store.Adapter 7 | 8 | use Agent 9 | 10 | defstruct events: %{}, snapshots: %{} 11 | 12 | def start_link do 13 | Agent.start_link( 14 | &new_store/0, 15 | name: __MODULE__ 16 | ) 17 | end 18 | 19 | def commit_all(events, _projections), do: commit_events(events) 20 | 21 | def commit_events([]), do: :ok 22 | 23 | def commit_events(events) do 24 | Agent.get_and_update(__MODULE__, &update_events(&1, events)) 25 | end 26 | 27 | def commit_snapshot(snapshot) do 28 | Agent.get_and_update(__MODULE__, &update_snapshot(&1, snapshot)) 29 | end 30 | 31 | def get_events(id, min_seq, %{max_sequence: max_seq}) do 32 | Agent.get(__MODULE__, &return_events(&1, id, min_seq, max_seq)) 33 | end 34 | 35 | def get_snapshot(id, min_seq, %{max_sequence: max_seq}) do 36 | Agent.get(__MODULE__, &return_snapshot(&1, id, min_seq, max_seq)) 37 | end 38 | 39 | def reset, do: Agent.update(__MODULE__, &new_store/1) 40 | 41 | defp update_events(%{events: all_events} = state, new_events) do 42 | aid = new_events |> List.first() |> aggregate_id() 43 | old_events = Map.get(all_events, aid, []) 44 | 45 | if overlapping?(old_events, new_events) do 46 | {{:error, :retry_command}, state} 47 | else 48 | all_events = 49 | Map.put( 50 | all_events, 51 | aid, 52 | old_events ++ new_events 53 | ) 54 | 55 | {:ok, %{state | events: all_events}} 56 | end 57 | end 58 | 59 | defp update_snapshot(%{snapshots: snaps} = state, new_snap) do 60 | aid = aggregate_id(new_snap) 61 | prev_snap = Map.get(snaps, aid, %{sequence: -1}) 62 | 63 | if prev_snap.sequence > new_snap.sequence do 64 | {:ok, state} 65 | else 66 | {:ok, %{state | snapshots: Map.put(snaps, aid, new_snap)}} 67 | end 68 | end 69 | 70 | defp return_events(%{events: events}, id, min_seq, max_seq) do 71 | events 72 | |> Map.get(id, []) 73 | |> Enum.filter(&in_range?(&1, min_seq, max_seq)) 74 | end 75 | 76 | defp return_snapshot(%{snapshots: snaps}, id, min_seq, max_seq) do 77 | snap = snaps |> Map.get(id, %{sequence: -1}) 78 | 79 | if in_range?(snap, min_seq, max_seq) do 80 | snap 81 | else 82 | nil 83 | end 84 | end 85 | 86 | def in_range?(%{sequence: s}, min, max), do: s > min and s <= max 87 | 88 | defp new_store, do: %__MODULE__{} 89 | defp new_store(_), do: new_store() 90 | 91 | defp overlapping?(old_events, new_events) do 92 | pseqs = Enum.map(old_events, &sequence/1) 93 | cseqs = Enum.map(new_events, &sequence/1) 94 | 95 | Enum.count(pseqs -- cseqs) != Enum.count(pseqs) 96 | end 97 | 98 | defp sequence(%{sequence: sequence}), do: sequence 99 | 100 | defp aggregate_id(%{aggregate_id: a}), do: a 101 | end 102 | -------------------------------------------------------------------------------- /lib/maestro/store/postgres.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.Store.Postgres do 2 | @moduledoc """ 3 | Ecto+Postgres implementation of the storage mechanism. 4 | 5 | Events are never replayed outside of the aggregate's context, so the 6 | implementation doesn't support retrieval without an aggregate ID. 7 | """ 8 | 9 | @behaviour Maestro.Store.Adapter 10 | 11 | import Ecto.Query 12 | 13 | alias Ecto.Multi 14 | alias Maestro.Types.{Event, Snapshot} 15 | 16 | def commit_all(events, projections) do 17 | events 18 | |> Stream.map(&Event.changeset/1) 19 | |> Enum.reduce(Multi.new(), &append_changeset/2) 20 | |> with_projections(events, projections) 21 | |> apply_all() 22 | end 23 | 24 | def commit_events(events), do: commit_all(events, []) 25 | 26 | def commit_snapshot(%Snapshot{} = s) do 27 | upstmt = 28 | from( 29 | s in Snapshot, 30 | where: fragment("s0.sequence < excluded.sequence"), 31 | update: [ 32 | set: [ 33 | sequence: fragment("excluded.sequence"), 34 | body: fragment("excluded.body") 35 | ] 36 | ] 37 | ) 38 | 39 | repo = get_repo() 40 | 41 | case repo.insert_all( 42 | Snapshot, 43 | for_insert(s), 44 | conflict_target: [:aggregate_id], 45 | on_conflict: upstmt 46 | ) do 47 | {x, _} when x >= 0 and x <= 1 -> :ok 48 | end 49 | end 50 | 51 | def get_events(aggregate_id, min_seq, %{max_sequence: max_seq}) do 52 | repo = get_repo() 53 | 54 | event_query() 55 | |> bounded_sequence(min_seq, max_seq) 56 | |> ordered() 57 | |> for_aggregate(aggregate_id) 58 | |> repo.all() 59 | end 60 | 61 | def get_snapshot(aggregate_id, min_seq, %{max_sequence: max_seq}) do 62 | repo = get_repo() 63 | 64 | snapshot_query() 65 | |> bounded_sequence(min_seq, max_seq) 66 | |> for_aggregate(aggregate_id) 67 | |> repo.one() 68 | end 69 | 70 | defp event_query, do: from(e in Event) 71 | 72 | defp snapshot_query, do: from(s in Snapshot) 73 | 74 | defp bounded_sequence(query, min_seq, max_seq) do 75 | from( 76 | r in query, 77 | where: r.sequence > ^min_seq, 78 | where: r.sequence <= ^max_seq 79 | ) 80 | end 81 | 82 | defp for_aggregate(query, agg_id) do 83 | from( 84 | r in query, 85 | where: r.aggregate_id == ^agg_id, 86 | select: r 87 | ) 88 | end 89 | 90 | defp ordered(query) do 91 | from( 92 | r in query, 93 | order_by: r.timestamp 94 | ) 95 | end 96 | 97 | defp apply_all(multi) do 98 | repo = get_repo() 99 | 100 | multi 101 | |> repo.transaction() 102 | |> case do 103 | {:error, _, %{errors: [sequence: {"dupe_seq_agg", _}]}, _} -> 104 | {:error, :retry_command} 105 | 106 | {:error, _name, err, _changes_so_far} -> 107 | raise err 108 | 109 | {:ok, _} -> 110 | :ok 111 | end 112 | end 113 | 114 | defp with_projections(multi, _events, []), do: multi 115 | 116 | defp with_projections(multi, events, projections) do 117 | Multi.run(multi, :projections, fn _repo, _completed -> 118 | run_projections(events, projections) 119 | end) 120 | end 121 | 122 | defp run_projections(events, projections) do 123 | for handler <- projections, 124 | event <- events, 125 | do: handler.project(event) 126 | 127 | {:ok, :ok} 128 | rescue 129 | e -> {:error, e} 130 | end 131 | 132 | defp append_changeset(cs, mult), do: Multi.insert(mult, changeset_key(cs), cs) 133 | 134 | defp changeset_key(cs) do 135 | "#{cs.data.aggregate_id}:#{cs.data.sequence}" 136 | end 137 | 138 | defp for_insert(%{} = struct) do 139 | struct 140 | |> Map.from_struct() 141 | |> Map.delete(:__meta__) 142 | |> List.wrap() 143 | end 144 | 145 | defp get_repo, do: Application.fetch_env!(:maestro, :repo) 146 | end 147 | -------------------------------------------------------------------------------- /lib/maestro/types/command.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.Types.Command do 2 | @moduledoc """ 3 | Commands are the primary way clients express a desire to change the system. In 4 | Maestro, commands are always executed within the context of an aggregate in a 5 | consistent manner. 6 | """ 7 | 8 | @type t :: %__MODULE__{ 9 | type: String.t(), 10 | aggregate_id: HLClock.Timestamp.t(), 11 | data: map() 12 | } 13 | 14 | defstruct [:type, :aggregate_id, data: %{}] 15 | end 16 | -------------------------------------------------------------------------------- /lib/maestro/types/event.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.Types.Event do 2 | @moduledoc """ 3 | Events are the key component from which state changes are made and projections 4 | can be built. 5 | 6 | In order to ensure consistent application of events, they are always retrieved 7 | in order by sequence number. Additionally, events with conflicting sequence 8 | numbers will be rejected, and the aggregate can retry the command that 9 | generated the events that were committed second. 10 | """ 11 | 12 | use Ecto.Schema 13 | 14 | import Ecto.Changeset 15 | 16 | alias __MODULE__ 17 | 18 | @type sequence :: integer() 19 | 20 | @type aggregate_id :: HLClock.Timestamp.t() 21 | 22 | @type t :: %__MODULE__{ 23 | timestamp: HLClock.Timestamp.t(), 24 | aggregate_id: aggregate_id(), 25 | sequence: sequence(), 26 | type: String.t(), 27 | body: map() 28 | } 29 | 30 | # timestamp and sequence are nil since command handlers don't generate HLC's 31 | # or decide sequence numbers; the database doesn't actually allow these to be 32 | # nil outside of this particular use case 33 | @type uncommitted :: %__MODULE__{ 34 | aggregate_id: aggregate_id(), 35 | type: String.t(), 36 | body: map(), 37 | timestamp: nil, 38 | sequence: nil 39 | } 40 | 41 | @primary_key false 42 | schema "event_log" do 43 | field(:timestamp, Ecto.HLClock, primary_key: true) 44 | field(:aggregate_id, Ecto.HLClock) 45 | field(:sequence, :integer) 46 | field(:type, :string) 47 | field(:body, :map) 48 | end 49 | 50 | @doc """ 51 | Ensure that events are well formed and that sequence conflicts surface 52 | properly when attempting to commit them to the log. 53 | """ 54 | def changeset(%Event{} = e) do 55 | e 56 | |> change() 57 | |> validate_required([:timestamp, :aggregate_id, :sequence, :type, :body]) 58 | |> unique_constraint( 59 | :sequence, 60 | name: :aggregate_sequence_index, 61 | message: "dupe_seq_agg" 62 | ) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/maestro/types/snapshot.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.Types.Snapshot do 2 | @moduledoc """ 3 | `Maestro.Aggregate.Root`s can commit state that has been computed from events. 4 | 5 | Roots can commit state that has been computed from the application 6 | of events. This is useful if events are expensive to apply or if there are a 7 | sufficiently large number of events that replaying from sequence=1 would be 8 | impractical. 9 | 10 | With `Maestro.Types.Event`s, `:body` is the necessary information to apply the 11 | event. In the case of `Snapshot`s, the body is the actual computed state of 12 | the entity. 13 | """ 14 | 15 | use Ecto.Schema 16 | 17 | @type sequence :: integer() 18 | 19 | @type aggregate_id :: HLClock.Timestamp.t() 20 | 21 | @type t :: %__MODULE__{ 22 | aggregate_id: aggregate_id(), 23 | sequence: sequence(), 24 | body: map() 25 | } 26 | 27 | @primary_key false 28 | schema "snapshots" do 29 | field(:aggregate_id, Ecto.HLClock, primary_key: true) 30 | field(:sequence, :integer) 31 | field(:body, :map) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/mix/tasks/maestro.create.event_store_migration.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Maestro.Create.EventStoreMigration do 2 | @moduledoc """ 3 | Using the work already done in `Mix.Ecto`, generate a migration that creates 4 | the event_log and snapshot tables with HLC constraints. 5 | 6 | ## Example 7 | 8 | mix maestro.create.event_store_migration --repo MyApp.Repo 9 | mix maestro.create.event_store_migration -n diff_name_for_migration 10 | """ 11 | 12 | use Mix.Task 13 | 14 | import Mix.Generator, 15 | only: [ 16 | embed_template: 2, 17 | create_directory: 1, 18 | create_file: 2 19 | ] 20 | 21 | import Mix.Ecto, only: [parse_repo: 1, ensure_repo: 2] 22 | 23 | @change """ 24 | create table(:event_log, primary_key: false) do 25 | add :timestamp, :binary, null: false, primary_key: true 26 | add :aggregate_id, :binary, null: false 27 | add :sequence, :integer, null: false 28 | add :type, :string, size: 256, null: false 29 | add :body, :map, null: false 30 | end 31 | 32 | create table(:snapshots, primary_key: false) do 33 | add :aggregate_id, :binary, null: false, primary_key: true 34 | add :sequence, :integer, null: false 35 | add :body, :map, null: false 36 | end 37 | 38 | Ecto.HLClock.Migration.create_hlc_constraint(:event_log, :timestamp) 39 | Ecto.HLClock.Migration.create_hlc_constraint(:event_log, :aggregate_id) 40 | Ecto.HLClock.Migration.create_hlc_constraint(:snapshots, :aggregate_id) 41 | 42 | create constraint(:event_log, :sequence, check: "sequence > 0") 43 | create unique_index(:event_log, [:aggregate_id, :sequence], 44 | name: "aggregate_sequence_index") 45 | """ 46 | 47 | @doc false 48 | def run(args) do 49 | [repo | _] = parse_repo(args) 50 | ensure_repo(repo, args) 51 | 52 | migration_name = parse_migration_name(args) 53 | 54 | file = 55 | Path.join("priv/repo/migrations/", "#{timestamp()}_#{migration_name}.exs") 56 | 57 | create_directory(Path.dirname(file)) 58 | 59 | assigns = [ 60 | mod: Module.concat([repo, Migrations, EventLogAndSnapshots]), 61 | change: @change 62 | ] 63 | 64 | create_file(file, migration_template(assigns)) 65 | end 66 | 67 | defp parse_migration_name(args) do 68 | {parsed, _, _} = 69 | OptionParser.parse( 70 | args, 71 | aliases: [n: :name], 72 | strict: [name: :string] 73 | ) 74 | 75 | Keyword.get(parsed, :name, "event_log_and_snapshots") 76 | end 77 | 78 | defp timestamp do 79 | {{y, m, d}, {hh, mm, ss}} = :calendar.universal_time() 80 | "#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}" 81 | end 82 | 83 | defp pad(i) when i < 10, do: <> 84 | defp pad(i), do: to_string(i) 85 | 86 | embed_template(:migration, """ 87 | defmodule <%= inspect @mod %> do 88 | use Ecto.Migration 89 | 90 | def change do 91 | <%= @change %> 92 | end 93 | end 94 | """) 95 | end 96 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Maestro.Mixfile do 2 | use Mix.Project 3 | 4 | @version "0.3.4" 5 | @source_url "https://github.com/toniqsystems/maestro" 6 | 7 | def project do 8 | [ 9 | app: :maestro, 10 | version: @version, 11 | elixir: "~> 1.7", 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | start_permanent: Mix.env() == :prod, 14 | aliases: aliases(), 15 | package: package(), 16 | description: description(), 17 | deps: deps(), 18 | name: "Maestro", 19 | source_url: @source_url, 20 | docs: [ 21 | source_url: @source_url, 22 | extras: ["README.md"] 23 | ], 24 | dialyzer: [ignore_warnings: "dialyzer.ignore-warnings"], 25 | test_coverage: [tool: ExCoveralls], 26 | preferred_cli_env: [ 27 | coveralls: :test, 28 | "coveralls.html": :test, 29 | "coveralls.post": :test, 30 | "coveralls.travis": :test 31 | ] 32 | ] 33 | end 34 | 35 | def application do 36 | [ 37 | mod: {Maestro.Application, []}, 38 | extra_applications: [:logger, :runtime_tools] 39 | ] 40 | end 41 | 42 | defp elixirc_paths(:test), do: ["lib", "test/support"] 43 | defp elixirc_paths(_), do: ["lib"] 44 | 45 | defp deps do 46 | [ 47 | {:ecto, "~> 3.0"}, 48 | {:ecto_sql, "~> 3.0"}, 49 | {:postgrex, ">= 0.0.0"}, 50 | {:ecto_hlclock, "~> 0.1"}, 51 | {:jason, "~> 1.1"}, 52 | {:mock, "~> 0.3", only: :test, runtime: false}, 53 | {:credo, "~> 1.0", only: [:dev, :test]}, 54 | {:stream_data, "~> 0.3", only: [:test]}, 55 | {:dialyxir, "~> 0.5", only: [:dev], runtime: false}, 56 | {:benchee, "~> 1.0", only: :dev}, 57 | {:ex_doc, "~> 0.16", only: :dev}, 58 | {:excoveralls, "~> 0.8", only: :test} 59 | ] 60 | end 61 | 62 | defp description do 63 | """ 64 | Maestro: event sourcing 65 | """ 66 | end 67 | 68 | defp aliases do 69 | [ 70 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 71 | "ecto.reset": ["ecto.drop", "ecto.setup"], 72 | test: ["ecto.create --quiet", "ecto.migrate", "test"] 73 | ] 74 | end 75 | 76 | defp package do 77 | [ 78 | name: :maestro, 79 | files: ["lib", "mix.exs", "README.md"], 80 | maintainers: ["Neil Menne", "Chris Keathley", "Brent Spell"], 81 | licenses: ["Apache 2.0"], 82 | links: %{ 83 | "GitHub" => @source_url, 84 | "Docs" => "http://hexdocs.pm/maestro" 85 | } 86 | ] 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm"}, 3 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, 4 | "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, 6 | "credo": {:hex, :credo, "1.0.5", "fdea745579f8845315fe6a3b43e2f9f8866839cfbc8562bb72778e9fdaa94214", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "db_connection": {:hex, :db_connection, "2.0.5", "ddb2ba6761a08b2bb9ca0e7d260e8f4dd39067426d835c24491a321b7f92a4da", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "decimal": {:hex, :decimal, "1.7.0", "30d6b52c88541f9a66637359ddf85016df9eb266170d53105f02e4a67e00c5aa", [:mix], [], "hexpm"}, 9 | "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm"}, 10 | "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"}, 11 | "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm"}, 12 | "ecto": {:hex, :ecto, "3.0.7", "44dda84ac6b17bbbdeb8ac5dfef08b7da253b37a453c34ab1a98de7f7e5fec7f", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, 13 | "ecto_hlclock": {:hex, :ecto_hlclock, "0.1.2", "432f8f71cd3e426884a64ddba7762d2a61c4bff2707e2e46d30f6c9599c8d2a1", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:hlclock, "~> 0.1", [hex: :hlclock, repo: "hexpm", optional: false]}], "hexpm"}, 14 | "ecto_sql": {:hex, :ecto_sql, "3.0.5", "7e44172b4f7aca4469f38d7f6a3da394dbf43a1bcf0ca975e958cb957becd74e", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0.6", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.3.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, 15 | "ex_doc": {:hex, :ex_doc, "0.20.2", "1bd0dfb0304bade58beb77f20f21ee3558cc3c753743ae0ddbb0fd7ba2912331", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 16 | "excoveralls": {:hex, :excoveralls, "0.10.6", "e2b9718c9d8e3ef90bc22278c3f76c850a9f9116faf4ebe9678063310742edc2", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, 17 | "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, 18 | "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 19 | "hlclock": {:hex, :hlclock, "0.1.6", "ca4de3f8b3eb410e29de8273cb060a265fe89d5498a272efc58619753764d0c7", [:mix], [], "hexpm"}, 20 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 21 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 22 | "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"}, 23 | "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 24 | "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 25 | "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm"}, 26 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, 27 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, 28 | "mock": {:hex, :mock, "0.3.3", "42a433794b1291a9cf1525c6d26b38e039e0d3a360732b5e467bfc77ef26c914", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, 29 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, 30 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, 31 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, 32 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"}, 33 | "postgrex": {:hex, :postgrex, "0.14.1", "63247d4a5ad6b9de57a0bac5d807e1c32d41e39c04b8a4156a26c63bcd8a2e49", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, 34 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, 35 | "stream_data": {:hex, :stream_data, "0.4.2", "fa86b78c88ec4eaa482c0891350fcc23f19a79059a687760ddcf8680aac2799b", [:mix], [], "hexpm"}, 36 | "telemetry": {:hex, :telemetry, "0.3.0", "099a7f3ce31e4780f971b4630a3c22ec66d22208bc090fe33a2a3a6a67754a73", [:rebar3], [], "hexpm"}, 37 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, 38 | } 39 | -------------------------------------------------------------------------------- /priv/repo/migrations/20171005204551_event_log_and_snapshots.exs: -------------------------------------------------------------------------------- 1 | defmodule Maestro.Repo.Migrations.EventLogAndSnapshots do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:event_log, primary_key: false) do 6 | add :timestamp, :binary, null: false, primary_key: true 7 | add :aggregate_id, :binary, null: false 8 | add :sequence, :integer, null: false 9 | add :type, :string, size: 256, null: false 10 | add :body, :map, null: false 11 | end 12 | 13 | Ecto.HLClock.Migration.create_hlc_constraint(:event_log, :timestamp) 14 | Ecto.HLClock.Migration.create_hlc_constraint(:event_log, :aggregate_id) 15 | 16 | create constraint(:event_log, :sequence, check: "sequence > 0") 17 | create unique_index(:event_log, [:aggregate_id, :sequence], 18 | name: "aggregate_sequence_index") 19 | 20 | create table(:snapshots, primary_key: false) do 21 | add :aggregate_id, :binary, null: false, primary_key: true 22 | add :sequence, :integer, null: false 23 | add :body, :map, null: false 24 | end 25 | 26 | Ecto.HLClock.Migration.create_hlc_constraint(:snapshots, :aggregate_id) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180423183715_named_aggregates.exs: -------------------------------------------------------------------------------- 1 | defmodule Maestro.Repo.Migrations.NamedAggregates do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:named_aggregates, primary_key: false) do 6 | add(:id, :binary_id, primary_key: true) 7 | add(:name, :string, size: 50, null: false) 8 | add(:aggregate_id, :binary, null: false) 9 | end 10 | 11 | Ecto.HLClock.Migration.create_hlc_constraint( 12 | :named_aggregates, 13 | :aggregate_id 14 | ) 15 | 16 | create( 17 | unique_index( 18 | :named_aggregates, 19 | [:name], 20 | name: "unique_aggregate_names_index" 21 | ) 22 | ) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # Maestro.Repo.insert!(%Maestro.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /test/maestro/aggregate_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Maestro.AggregateTest do 2 | use ExUnit.Case 3 | 4 | import Ecto.Query 5 | import ExUnitProperties 6 | import Maestro.Generators 7 | import Mock 8 | 9 | alias DBConnection.ConnectionError 10 | alias Ecto.Adapters.SQL.Sandbox 11 | alias HLClock.Server, as: HLCServer 12 | alias Maestro.Aggregate.Root 13 | alias Maestro.{InvalidCommandError, InvalidHandlerError} 14 | alias Maestro.{Repo, SampleAggregate} 15 | alias Maestro.Types.{Command, Event} 16 | 17 | setup_all do 18 | Application.put_env( 19 | :maestro, 20 | :storage_adapter, 21 | Maestro.Store.Postgres 22 | ) 23 | 24 | HLCServer.start_link() 25 | 26 | :ok = Sandbox.checkout(Repo) 27 | Sandbox.mode(Repo, {:shared, self()}) 28 | 29 | :ok 30 | end 31 | 32 | describe "command/event lifecycle" do 33 | property "commands and events without snapshots" do 34 | check all agg_id <- timestamp(), 35 | coms <- commands(agg_id, max_commands: 200) do 36 | for com <- coms do 37 | :ok = SampleAggregate.evaluate(com) 38 | end 39 | 40 | {:ok, %{"value" => value}} = SampleAggregate.get(agg_id) 41 | assert value == increments(coms) - decrements(coms) 42 | 43 | {:ok, %{"value" => value}} = SampleAggregate.fetch(agg_id) 44 | assert value == increments(coms) - decrements(coms) 45 | end 46 | end 47 | 48 | test "commands, events, and snapshots" do 49 | {:ok, agg_id} = SampleAggregate.new() 50 | 51 | {:ok, %{"value" => value}} = SampleAggregate.get(agg_id) 52 | assert value == 0 53 | 54 | SampleAggregate.evaluate(%Command{ 55 | type: "increment_counter", 56 | aggregate_id: agg_id, 57 | data: %{} 58 | }) 59 | 60 | SampleAggregate.evaluate(%Command{ 61 | type: "increment_counter", 62 | aggregate_id: agg_id, 63 | data: %{} 64 | }) 65 | 66 | SampleAggregate.snapshot(agg_id) 67 | 68 | agg_id |> Root.whereis(SampleAggregate) |> GenServer.stop() 69 | 70 | {:ok, _pid} = SampleAggregate.start_link(agg_id) 71 | 72 | SampleAggregate.evaluate(%Command{ 73 | type: "increment_counter", 74 | aggregate_id: agg_id, 75 | data: %{} 76 | }) 77 | 78 | {:ok, %{"value" => value}} = SampleAggregate.get(agg_id) 79 | assert value == 3 80 | end 81 | 82 | test "recover an intermediate state" do 83 | {:ok, agg_id} = SampleAggregate.new() 84 | 85 | base_command = %Command{ 86 | type: "increment_counter", 87 | aggregate_id: agg_id, 88 | data: %{} 89 | } 90 | 91 | commands = repeat(base_command, 10) 92 | 93 | for com <- commands do 94 | res = SampleAggregate.evaluate(com) 95 | 96 | case res do 97 | :ok -> :ok 98 | {:error, err, stack} -> reraise err, stack 99 | end 100 | end 101 | 102 | {:ok, %{"value" => value}} = SampleAggregate.replay(agg_id, 2) 103 | assert value == 2 104 | {:ok, %{"value" => current}} = SampleAggregate.get(agg_id) 105 | assert current == 10 106 | end 107 | end 108 | 109 | describe "communicating/handling failure" do 110 | test "invalid command" do 111 | {:ok, agg_id} = SampleAggregate.new() 112 | 113 | com = %Command{ 114 | type: "invalid", 115 | aggregate_id: agg_id, 116 | data: %{} 117 | } 118 | 119 | {:error, err, _stack} = SampleAggregate.evaluate(com) 120 | 121 | assert err == InvalidHandlerError.exception(type: "invalid") 122 | 123 | assert_raise(ArgumentError, fn -> 124 | SampleAggregate.evaluate(%{ 125 | type: "increment_counter", 126 | data: %{} 127 | }) 128 | end) 129 | end 130 | 131 | test "handler rejected command" do 132 | {:ok, agg_id} = SampleAggregate.new() 133 | 134 | com = %Command{ 135 | type: "conditional_increment", 136 | aggregate_id: agg_id, 137 | data: %{"do_inc" => false} 138 | } 139 | 140 | {:error, err, _stack} = SampleAggregate.evaluate(com) 141 | 142 | assert err == 143 | InvalidCommandError.exception( 144 | message: "command incorrectly specified" 145 | ) 146 | end 147 | 148 | test "handler raised an unexpected error" do 149 | {:ok, agg_id} = SampleAggregate.new() 150 | 151 | com = %Command{ 152 | type: "raise_command", 153 | aggregate_id: agg_id, 154 | data: %{"raise" => true} 155 | } 156 | 157 | {:error, err, _stack} = SampleAggregate.evaluate(com) 158 | 159 | assert err == 160 | ArgumentError.exception( 161 | message: "commands can raise arbitrary exceptions as well" 162 | ) 163 | end 164 | 165 | test "store error" do 166 | {:ok, agg_id} = SampleAggregate.new() 167 | 168 | com = %Command{ 169 | type: "increment_counter", 170 | aggregate_id: agg_id, 171 | data: %{} 172 | } 173 | 174 | with_mock Maestro.Store, 175 | max_sequence: fn -> 2_147_483_647 end, 176 | get_snapshot: fn _, _, _ -> 177 | raise(ConnectionError, "some") 178 | end do 179 | {:error, err, _stack} = SampleAggregate.evaluate(com) 180 | assert err == ConnectionError.exception("some") 181 | end 182 | end 183 | end 184 | 185 | test "event_type/2" do 186 | assert Root.event_type(Maestro.SampleAggregate.Events, %{ 187 | __struct__: Maestro.SampleAggregate.Events.TypedEvent.Completed 188 | }) == "typed_event.completed" 189 | end 190 | 191 | describe "projections" do 192 | test "strong projections are invoked/called" do 193 | {:ok, agg_id} = SampleAggregate.new() 194 | 195 | com = %Command{ 196 | type: "name_counter", 197 | aggregate_id: agg_id, 198 | data: %{"name" => "sample"} 199 | } 200 | 201 | :ok = SampleAggregate.evaluate(com) 202 | 203 | {:error, err, _stack} = SampleAggregate.evaluate(com) 204 | 205 | assert err == 206 | InvalidCommandError.exception( 207 | message: "altering names is prohibited" 208 | ) 209 | 210 | {:ok, agg_id_2} = SampleAggregate.new() 211 | 212 | com_2 = Map.put(com, :aggregate_id, agg_id_2) 213 | 214 | {:error, err, _stack} = SampleAggregate.evaluate(com_2) 215 | 216 | assert err.__struct__ == Ecto.ConstraintError 217 | 218 | # no events were committed 219 | assert Repo.one( 220 | from( 221 | e in Event, 222 | where: e.aggregate_id == ^agg_id_2, 223 | select: count(e.timestamp) 224 | ) 225 | ) == 0 226 | end 227 | end 228 | 229 | def repeat(val, times) do 230 | Enum.map(0..(times - 1), fn _ -> val end) 231 | end 232 | 233 | def increments(commands) do 234 | commands 235 | |> Enum.filter(&is_increment/1) 236 | |> Enum.count() 237 | end 238 | 239 | def decrements(commands) do 240 | commands 241 | |> Enum.filter(&is_decrement/1) 242 | |> Enum.count() 243 | end 244 | 245 | defp is_increment(%{type: t}), do: t == "increment_counter" 246 | 247 | defp is_decrement(%{type: t}), do: t == "decrement_counter" 248 | end 249 | -------------------------------------------------------------------------------- /test/maestro/in_memory_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Maestro.InMemoryTest do 2 | use ExUnit.Case, async: false 3 | import StreamData 4 | import ExUnitProperties 5 | import Maestro.Generators 6 | 7 | alias Maestro.Store 8 | alias Maestro.Store.InMemory 9 | alias Maestro.Types.{Event, Snapshot} 10 | 11 | setup_all do 12 | Application.put_env( 13 | :maestro, 14 | :storage_adapter, 15 | Maestro.Store.InMemory 16 | ) 17 | 18 | {:ok, pid} = InMemory.start_link() 19 | 20 | on_exit(fn -> 21 | Process.exit(pid, :normal) 22 | end) 23 | 24 | :ok 25 | end 26 | 27 | describe "commit_events/1" do 28 | property "no conflict events are committed" do 29 | check all agg_id <- timestamp(), 30 | times <- 31 | uniq_list_of( 32 | timestamp(), 33 | min_length: 1, 34 | max_length: 10 35 | ) do 36 | InMemory.reset() 37 | 38 | times 39 | |> Enum.with_index(1) 40 | |> Enum.map(&to_event(&1, agg_id)) 41 | |> Store.commit_events() 42 | 43 | events = Store.get_events(agg_id, 0) 44 | 45 | assert Enum.count(times) == Enum.count(events) 46 | end 47 | end 48 | 49 | property "sequence conflicts are marked for retry" do 50 | check all agg_id <- timestamp(), 51 | ts0 <- timestamp(), 52 | times <- uniq_list_of(timestamp(), min_length: 1) do 53 | InMemory.reset() 54 | 55 | times 56 | |> Enum.with_index(1) 57 | |> Enum.map(&to_event(&1, agg_id)) 58 | |> Store.commit_events() 59 | 60 | e = to_event({ts0, 1}, agg_id) 61 | 62 | {:error, reason} = 63 | e 64 | |> List.wrap() 65 | |> Store.commit_events() 66 | 67 | assert reason == :retry_command 68 | end 69 | end 70 | end 71 | 72 | describe "get_events/2" do 73 | property "returns empty list when no relevant events exist" do 74 | check all agg_id <- timestamp(), 75 | times <- uniq_list_of(timestamp()) do 76 | InMemory.reset() 77 | 78 | times 79 | |> Enum.with_index(1) 80 | |> Enum.map(&to_event(&1, agg_id)) 81 | |> Store.commit_events() 82 | 83 | assert [] == Store.get_events(agg_id, Enum.count(times) + 1) 84 | end 85 | end 86 | 87 | property "returns events otherwise" do 88 | check all agg_id <- timestamp(), 89 | times <- uniq_list_of(timestamp(), min_length: 1) do 90 | InMemory.reset() 91 | 92 | total = Enum.count(times) 93 | 94 | times 95 | |> Enum.with_index(1) 96 | |> Enum.map(&to_event(&1, agg_id)) 97 | |> Store.commit_events() 98 | 99 | seq = 100 | times 101 | |> Enum.with_index(1) 102 | |> Enum.random() 103 | |> elem(1) 104 | 105 | assert agg_id 106 | |> Store.get_events(seq) 107 | |> Enum.count() == total - seq 108 | end 109 | end 110 | end 111 | 112 | describe "commit_snapshot/1" do 113 | property "commits if newer" do 114 | check all agg_id <- timestamp(), 115 | [seq0, seq1] <- uniq_list_of(integer(1..100_000), length: 2) do 116 | InMemory.reset() 117 | 118 | agg_id 119 | |> to_snapshot(seq0, %{"seq" => seq0}) 120 | |> Store.commit_snapshot() 121 | 122 | agg_id 123 | |> to_snapshot(seq1, %{"seq" => seq1}) 124 | |> Store.commit_snapshot() 125 | 126 | snapshot = Store.get_snapshot(agg_id, 0) 127 | assert Map.get(snapshot.body, "seq") == max(seq0, seq1) 128 | end 129 | end 130 | end 131 | 132 | describe "get_snapshot/2" do 133 | property "retrieve if newer" do 134 | check all agg_id <- timestamp(), 135 | [seq0, seq1] <- uniq_list_of(integer(1..100_000), length: 2) do 136 | InMemory.reset() 137 | 138 | agg_id 139 | |> to_snapshot(seq0, %{"seq" => seq0}) 140 | |> Store.commit_snapshot() 141 | 142 | case Store.get_snapshot(agg_id, seq1) do 143 | nil -> 144 | assert seq1 > seq0 145 | 146 | %Snapshot{} -> 147 | assert seq1 < seq0 148 | end 149 | end 150 | end 151 | end 152 | 153 | def to_snapshot(agg_id, seq, body \\ %{}), 154 | do: %Snapshot{ 155 | aggregate_id: agg_id, 156 | sequence: seq, 157 | body: body 158 | } 159 | 160 | def to_event({ts, seq}, agg_id, body \\ %{}), 161 | do: %Event{ 162 | timestamp: ts, 163 | aggregate_id: agg_id, 164 | sequence: seq, 165 | body: body 166 | } 167 | end 168 | -------------------------------------------------------------------------------- /test/maestro/postgres_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Maestro.PostgresTest do 2 | use ExUnit.Case, async: false 3 | 4 | import StreamData 5 | import ExUnitProperties 6 | 7 | import Ecto.Query 8 | 9 | import Maestro.Generators 10 | 11 | alias Ecto.Adapters.SQL.Sandbox 12 | alias Maestro.{Repo, Store} 13 | alias Maestro.Types.{Event, Snapshot} 14 | 15 | setup do 16 | Application.put_env( 17 | :maestro, 18 | :storage_adapter, 19 | Maestro.Store.Postgres 20 | ) 21 | 22 | :ok = Sandbox.checkout(Repo) 23 | end 24 | 25 | describe "commit_events/1" do 26 | property "no conflict events are committed" do 27 | check all agg_id <- timestamp(), 28 | times <- 29 | uniq_list_of( 30 | timestamp(), 31 | min_length: 1, 32 | max_length: 10 33 | ) do 34 | times 35 | |> Enum.with_index(1) 36 | |> Enum.map(&to_event(&1, agg_id)) 37 | |> Store.commit_events() 38 | 39 | assert Enum.count(times) == num_events(agg_id) 40 | end 41 | end 42 | 43 | property "sequence conflicts are marked for retry" do 44 | check all agg_id <- timestamp(), 45 | ts0 <- timestamp(), 46 | times <- uniq_list_of(timestamp(), min_length: 1) do 47 | times 48 | |> Enum.with_index(1) 49 | |> Enum.map(&to_event(&1, agg_id)) 50 | |> Store.commit_events() 51 | 52 | e = to_event({ts0, 1}, agg_id) 53 | 54 | {:error, reason} = 55 | e 56 | |> List.wrap() 57 | |> Store.commit_events() 58 | 59 | assert reason == :retry_command 60 | end 61 | end 62 | end 63 | 64 | describe "get_events/2" do 65 | property "returns empty list when no relevant events exist" do 66 | check all agg_id <- timestamp(), 67 | times <- uniq_list_of(timestamp()) do 68 | times 69 | |> Enum.with_index(1) 70 | |> Enum.map(&to_event(&1, agg_id)) 71 | |> Store.commit_events() 72 | 73 | assert [] == Store.get_events(agg_id, Enum.count(times) + 1) 74 | end 75 | end 76 | 77 | property "returns events otherwise" do 78 | check all agg_id <- timestamp(), 79 | times <- uniq_list_of(timestamp(), min_length: 1) do 80 | total = Enum.count(times) 81 | 82 | times 83 | |> Enum.with_index(1) 84 | |> Enum.map(&to_event(&1, agg_id)) 85 | |> Store.commit_events() 86 | 87 | seq = 88 | times 89 | |> Enum.with_index(1) 90 | |> Enum.random() 91 | |> elem(1) 92 | 93 | assert agg_id 94 | |> Store.get_events(seq) 95 | |> Enum.count() == total - seq 96 | end 97 | end 98 | end 99 | 100 | describe "commit_snapshot/1" do 101 | property "commits if newer" do 102 | check all agg_id <- timestamp(), 103 | [seq0, seq1] <- uniq_list_of(integer(1..100_000), length: 2) do 104 | agg_id 105 | |> to_snapshot(seq0, %{"seq0" => seq0}) 106 | |> Store.commit_snapshot() 107 | 108 | agg_id 109 | |> to_snapshot(seq1, %{"seq1" => seq1}) 110 | |> Store.commit_snapshot() 111 | 112 | in_db = 113 | Repo.one( 114 | from( 115 | s in Snapshot, 116 | where: s.aggregate_id == ^agg_id, 117 | select: s 118 | ) 119 | ) 120 | 121 | case seq0 > seq1 do 122 | true -> assert Map.get(in_db.body, "seq0") == seq0 123 | false -> assert Map.get(in_db.body, "seq1") == seq1 124 | end 125 | end 126 | end 127 | end 128 | 129 | describe "get_snapshot/2" do 130 | property "retrieve if newer" do 131 | check all agg_id <- timestamp(), 132 | [seq0, seq1] <- uniq_list_of(integer(1..100_000), length: 2) do 133 | agg_id 134 | |> to_snapshot(seq0, %{"seq0" => seq0}) 135 | |> Store.commit_snapshot() 136 | 137 | case Store.get_snapshot(agg_id, seq1) do 138 | nil -> assert seq1 > seq0 139 | %Snapshot{} = _snap -> assert seq1 < seq0 140 | end 141 | end 142 | end 143 | end 144 | 145 | def num_events(agg_id) do 146 | Repo.one!( 147 | from( 148 | e in Event, 149 | where: e.aggregate_id == ^agg_id, 150 | select: count(e.aggregate_id) 151 | ) 152 | ) 153 | end 154 | 155 | def to_snapshot(agg_id, seq, body \\ %{}), 156 | do: %Snapshot{ 157 | aggregate_id: agg_id, 158 | sequence: seq, 159 | body: body 160 | } 161 | 162 | def to_event({ts, seq}, agg_id, body \\ %{}), 163 | do: %Event{ 164 | timestamp: ts, 165 | aggregate_id: agg_id, 166 | sequence: seq, 167 | type: "random_event", 168 | body: body 169 | } 170 | end 171 | -------------------------------------------------------------------------------- /test/support/commands/conditional_increment.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.SampleAggregate.Commands.ConditionalIncrement do 2 | @moduledoc false 3 | 4 | @behaviour Maestro.Aggregate.CommandHandler 5 | 6 | alias Maestro.InvalidCommandError 7 | 8 | def eval(_aggregate, _com) do 9 | raise InvalidCommandError, "command incorrectly specified" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/support/commands/decrement_counter.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.SampleAggregate.Commands.DecrementCounter do 2 | @moduledoc """ 3 | decrement counter command 4 | """ 5 | 6 | alias HLClock 7 | alias Maestro.Types.Event 8 | 9 | @behaviour Maestro.Aggregate.CommandHandler 10 | 11 | def eval(aggregate, _command) do 12 | [ 13 | %Event{ 14 | aggregate_id: aggregate.id, 15 | type: "counter_decremented", 16 | body: %{"message" => "decrement"} 17 | } 18 | ] 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/support/commands/increment_counter.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.SampleAggregate.Commands.IncrementCounter do 2 | @moduledoc """ 3 | increment counter command 4 | """ 5 | 6 | alias Maestro.Types.Event 7 | 8 | @behaviour Maestro.Aggregate.CommandHandler 9 | 10 | def eval(aggregate, _command) do 11 | [ 12 | %Event{ 13 | aggregate_id: aggregate.id, 14 | type: "counter_incremented", 15 | body: %{"message" => "increment"} 16 | } 17 | ] 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/support/commands/name_counter.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.SampleAggregate.Commands.NameCounter do 2 | @moduledoc """ 3 | Provides a naive implementation of claiming a unique name. 4 | 5 | This is only feasible with the strong projections in place to enforce the 6 | unique name constraint. 7 | """ 8 | 9 | @behaviour Maestro.Aggregate.CommandHandler 10 | 11 | alias Maestro.InvalidCommandError 12 | alias Maestro.Types.Event 13 | 14 | def eval(%{state: %{"name" => cur}}, _command) when is_binary(cur) do 15 | raise InvalidCommandError, "altering names is prohibited" 16 | end 17 | 18 | def eval(aggregate, %{data: %{"name" => new_name}}) do 19 | [ 20 | %Event{ 21 | aggregate_id: aggregate.id, 22 | type: "counter_named", 23 | body: %{"name" => new_name} 24 | } 25 | ] 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/support/commands/raise_command.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.SampleAggregate.Commands.RaiseCommand do 2 | @moduledoc false 3 | 4 | @behaviour Maestro.Aggregate.CommandHandler 5 | 6 | def eval(_aggregate, %{data: %{"raise" => _any}}) do 7 | raise ArgumentError, "commands can raise arbitrary exceptions as well" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/support/events/counter_decremented.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.SampleAggregate.Events.CounterDecremented do 2 | @moduledoc """ 3 | decrement counter event 4 | """ 5 | 6 | @behaviour Maestro.Aggregate.EventHandler 7 | 8 | defp dec(v), do: v - 1 9 | 10 | def apply(state, _), do: Map.update!(state, "value", &dec/1) 11 | end 12 | -------------------------------------------------------------------------------- /test/support/events/counter_incremented.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.SampleAggregate.Events.CounterIncremented do 2 | @moduledoc """ 3 | increment counter event 4 | """ 5 | 6 | @behaviour Maestro.Aggregate.EventHandler 7 | 8 | defp inc(v), do: v + 1 9 | 10 | def apply(state, _), do: Map.update!(state, "value", &inc/1) 11 | end 12 | -------------------------------------------------------------------------------- /test/support/events/counter_named.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.SampleAggregate.Events.CounterNamed do 2 | @moduledoc """ 3 | Counter was able to successfully claim the name, so update state 4 | """ 5 | 6 | @behaviour Maestro.Aggregate.EventHandler 7 | 8 | def apply(state, %{body: %{"name" => new_name}}) do 9 | Map.put(state, "name", new_name) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/support/generators.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.Generators do 2 | @moduledoc """ 3 | Property testing utilities including: 4 | * HLC Timestamp generator 5 | """ 6 | 7 | import StreamData 8 | import ExUnitProperties 9 | 10 | alias HLClock.Timestamp 11 | alias Maestro.Types.Command 12 | 13 | @max_node_size 18_446_744_073_709_551_615 14 | @max_counter_size 65_535 15 | @max_time_size 2_147_483_647 16 | 17 | def timestamp do 18 | gen all time <- integer(0..max_time()), 19 | counter <- integer(0..max_counter()), 20 | node_id <- integer(0..max_node()) do 21 | {:ok, timestamp} = Timestamp.new(time, counter, node_id) 22 | timestamp 23 | end 24 | end 25 | 26 | def max_node, do: @max_node_size 27 | def max_time, do: @max_time_size 28 | def max_counter, do: @max_counter_size 29 | 30 | def commands(agg_id, opts \\ []) do 31 | defaults = [max_commands: 10] 32 | [max_commands: max_commands] = Keyword.merge(defaults, opts) 33 | 34 | gen all com_flags <- 35 | list_of( 36 | boolean(), 37 | max_length: max_commands, 38 | min_length: 1 39 | ) do 40 | com_flags 41 | |> Enum.map(&to_command(&1, agg_id)) 42 | end 43 | end 44 | 45 | def to_command(true, agg_id) do 46 | %Command{ 47 | type: "increment_counter", 48 | aggregate_id: agg_id, 49 | data: %{} 50 | } 51 | end 52 | 53 | def to_command(false, agg_id) do 54 | %Command{ 55 | type: "decrement_counter", 56 | aggregate_id: agg_id, 57 | data: %{} 58 | } 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/support/projections/name_projection_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.SampleAggregate.Projections.NameProjectionHandler do 2 | @moduledoc """ 3 | Attempt to update the named aggregate projection transactionally with the 4 | corresponding event 5 | """ 6 | 7 | @behaviour Maestro.Aggregate.ProjectionHandler 8 | 9 | alias Maestro.Repo 10 | alias Maestro.Schemas.NamedAggregate 11 | 12 | def project(%{ 13 | aggregate_id: agg_id, 14 | type: "counter_named", 15 | body: %{"name" => new_name} 16 | }) do 17 | Repo.insert(%NamedAggregate{name: new_name, aggregate_id: agg_id}) 18 | end 19 | 20 | def project(_), do: nil 21 | end 22 | -------------------------------------------------------------------------------- /test/support/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.Repo do 2 | use Ecto.Repo, 3 | otp_app: :maestro, 4 | adapter: Ecto.Adapters.Postgres 5 | 6 | @doc """ 7 | Dynamically loads the repository url from the 8 | DATABASE_URL environment variable. 9 | """ 10 | def init(_, opts) do 11 | {:ok, Keyword.put(opts, :url, System.get_env("DATABASE_URL"))} 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/support/sample_aggregate.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.SampleAggregate do 2 | @moduledoc """ 3 | Test implementation of an Aggregate behaviour 4 | """ 5 | 6 | use Maestro.Aggregate.Root, 7 | command_prefix: Maestro.SampleAggregate.Commands, 8 | event_prefix: Maestro.SampleAggregate.Events, 9 | projections: [Maestro.SampleAggregate.Projections.NameProjectionHandler] 10 | 11 | def initial_state, do: %{"value" => 0, "name" => nil} 12 | 13 | def prepare_snapshot(state), do: state 14 | 15 | def use_snapshot(_, %{body: state}), do: state 16 | end 17 | -------------------------------------------------------------------------------- /test/support/schemas/named_aggregate.ex: -------------------------------------------------------------------------------- 1 | defmodule Maestro.Schemas.NamedAggregate do 2 | @moduledoc """ 3 | A simple DB schema that will be used to provide a working example/test case 4 | for strong consistency w.r.t. projections. 5 | """ 6 | 7 | use Ecto.Schema 8 | 9 | @primary_key {:id, :binary_id, autogenerate: true} 10 | schema "named_aggregates" do 11 | field(:name, :string) 12 | field(:aggregate_id, Ecto.HLClock) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | Maestro.Repo.start_link() 4 | Ecto.Adapters.SQL.Sandbox.mode(Maestro.Repo, :manual) 5 | --------------------------------------------------------------------------------