Skip to content

Commit bf0463c

Browse files
authored
Merge pull request #52 from commanded/bug/projector-concurrency
Use an UPSERT SQL query to insert or update the projection version
2 parents d77c087 + f20f1c8 commit bf0463c

6 files changed

Lines changed: 127 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,29 @@
11
# Changelog
22

3+
## Next release
4+
5+
### Enhancements
6+
7+
- Use an UPSERT SQL query to insert or update the projection version ([#52](https://github.com/commanded/commanded-ecto-projections/pull/52)).
8+
39
## 1.3.0
410

11+
### Enhancements
12+
513
- Fix Elixir 1.14 compilation warnings ([#45](https://github.com/commanded/commanded-ecto-projections/pull/45)).
614

15+
---
16+
717
## 1.2.1
818

19+
### Enhancements
20+
921
- Allow exceptions to be rescued by Commanded's event handler ([#37](https://github.com/commanded/commanded-ecto-projections/pull/37)).
1022

1123
## 1.2.0
1224

25+
### Enhancements
26+
1327
- Support runtime projector names ([#32](https://github.com/commanded/commanded-ecto-projections/pull/32)).
1428
- Support `schema_prefix/2` function ([#33](https://github.com/commanded/commanded-ecto-projections/pull/33)).
1529

config/test.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,5 @@ config :commanded_ecto_projections, Commanded.Projections.Repo,
1313

1414
config :ex_unit, capture_log: true
1515

16-
# Print only warnings and errors during test
16+
# Print only warning and above log messages during tests
1717
config :logger, :console, level: :warning, format: "[$level] $message\n"

lib/projections/ecto.ex

Lines changed: 28 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -51,45 +51,47 @@ defmodule Commanded.Projections.Ecto do
5151
use Ecto.Schema
5252
use Commanded.Event.Handler, @handler_opts
5353

54-
import Ecto.Changeset
5554
import Ecto.Query
5655
import unquote(__MODULE__)
5756

5857
def update_projection(event, metadata, multi_fn) do
5958
projection_name = Map.fetch!(metadata, :handler_name)
6059
event_number = Map.fetch!(metadata, :event_number)
6160

62-
changeset =
63-
%ProjectionVersion{projection_name: projection_name}
64-
|> ProjectionVersion.changeset(%{last_seen_event_number: event_number})
61+
projection_version = %ProjectionVersion{
62+
projection_name: projection_name,
63+
last_seen_event_number: event_number
64+
}
6565

6666
prefix = schema_prefix(event, metadata)
6767

68+
# Query to update an existing projection version with the last seen event number with
69+
# a check to ensure that the event has not already been projected.
70+
update_projection_version =
71+
from(pv in ProjectionVersion,
72+
where:
73+
pv.projection_name == ^projection_name and pv.last_seen_event_number < ^event_number,
74+
update: [set: [last_seen_event_number: ^event_number]]
75+
)
76+
6877
multi =
6978
Ecto.Multi.new()
70-
|> Ecto.Multi.run(:verify_projection_version, fn repo, _changes ->
71-
version =
72-
case repo.get(ProjectionVersion, projection_name, prefix: prefix) do
73-
nil ->
74-
repo.insert!(
75-
%ProjectionVersion{
76-
projection_name: projection_name,
77-
last_seen_event_number: 0
78-
},
79-
prefix: prefix
80-
)
81-
82-
version ->
83-
version
84-
end
85-
86-
if version.last_seen_event_number < event_number do
87-
{:ok, %{version: version}}
88-
else
89-
{:error, :already_seen_event}
79+
|> Ecto.Multi.run(:track_projection_version, fn repo, _changes ->
80+
try do
81+
repo.insert(projection_version,
82+
prefix: prefix,
83+
on_conflict: update_projection_version,
84+
conflict_target: [:projection_name]
85+
)
86+
rescue
87+
exception in Ecto.StaleEntryError ->
88+
# Attempted to insert a projection version for an already seen event
89+
{:error, :already_seen_event}
90+
91+
exception ->
92+
reraise exception, __STACKTRACE__
9093
end
9194
end)
92-
|> Ecto.Multi.update(:projection_version, changeset, prefix: prefix)
9395

9496
with %Ecto.Multi{} = multi <- apply(multi_fn, [multi]),
9597
{:ok, changes} <- transaction(multi) do
@@ -99,7 +101,7 @@ defmodule Commanded.Projections.Ecto do
99101
:ok
100102
end
101103
else
102-
{:error, :verify_projection_version, :already_seen_event, _changes} -> :ok
104+
{:error, :track_projection_version, :already_seen_event, _changes} -> :ok
103105
{:error, _stage, error, _changes} -> {:error, error}
104106
{:error, _error} = reply -> reply
105107
end
@@ -202,24 +204,15 @@ defmodule Commanded.Projections.Ecto do
202204
quote do
203205
defmodule ProjectionVersion do
204206
@moduledoc false
205-
206207
use Ecto.Schema
207208

208-
import Ecto.Changeset
209-
210209
@primary_key {:projection_name, :string, []}
211210

212211
schema "projection_versions" do
213212
field(:last_seen_event_number, :integer)
214213

215214
timestamps(type: :naive_datetime_usec)
216215
end
217-
218-
@required_fields ~w(last_seen_event_number)a
219-
220-
def changeset(model, params \\ :invalid) do
221-
cast(model, params, @required_fields)
222-
end
223216
end
224217
end
225218
end

mix.exs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,13 @@ defmodule Commanded.Projections.Ecto.Mixfile do
3737
{:postgrex, ">= 0.0.0", only: :test},
3838

3939
# Optional dependencies
40-
{:jason, "~> 1.3", optional: true},
40+
{:jason, "~> 1.4", optional: true},
4141

4242
# Test & build tooling
43-
{:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false},
43+
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
4444
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false},
4545
{:mix_test_watch, "~> 1.1", only: :dev, runtime: false},
46-
{:mox, "~> 1.0", only: :test}
46+
{:mox, "~> 1.1", only: :test}
4747
]
4848
end
4949

test/projections/ecto_projection_test.exs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,64 @@ defmodule Commanded.Projections.EctoProjectionTest do
6868
assert_seen_event("Projector", 3)
6969
end
7070

71+
test "should prevent first event being projected more than once" do
72+
tasks =
73+
Enum.map(1..5, fn _index ->
74+
Task.async(Projector, :handle, [
75+
%AnEvent{name: "Event1"},
76+
%{handler_name: "Projector", event_number: 1}
77+
])
78+
end)
79+
80+
results = Task.await_many(tasks)
81+
82+
assert Enum.uniq(results) == [:ok]
83+
84+
assert_projections(Projection, ["Event1"])
85+
assert_seen_event("Projector", 1)
86+
end
87+
88+
test "should prevent an event being projected more than once" do
89+
Projector.handle(%AnEvent{name: "Event1"}, %{handler_name: "Projector", event_number: 1})
90+
Projector.handle(%AnEvent{name: "Event2"}, %{handler_name: "Projector", event_number: 2})
91+
92+
tasks =
93+
Enum.map(1..5, fn _index ->
94+
Task.async(Projector, :handle, [
95+
%AnEvent{name: "Event3"},
96+
%{handler_name: "Projector", event_number: 3}
97+
])
98+
end)
99+
100+
results = Task.await_many(tasks)
101+
102+
assert Enum.uniq(results) == [:ok]
103+
104+
assert_projections(Projection, ["Event1", "Event2", "Event3"])
105+
assert_seen_event("Projector", 3)
106+
end
107+
108+
test "should prevent an event being projected more than once after an ignored event" do
109+
Projector.handle(%AnEvent{name: "Event1"}, %{handler_name: "Projector", event_number: 1})
110+
Projector.handle(%AnEvent{name: "Event2"}, %{handler_name: "Projector", event_number: 2})
111+
Projector.handle(%IgnoredEvent{name: "Event2"}, %{handler_name: "Projector", event_number: 3})
112+
113+
tasks =
114+
Enum.map(1..5, fn _index ->
115+
Task.async(Projector, :handle, [
116+
%AnEvent{name: "Event4"},
117+
%{handler_name: "Projector", event_number: 4}
118+
])
119+
end)
120+
121+
results = Task.await_many(tasks)
122+
123+
assert Enum.uniq(results) == [:ok]
124+
125+
assert_projections(Projection, ["Event1", "Event2", "Event4"])
126+
assert_seen_event("Projector", 4)
127+
end
128+
71129
test "should return an error on failure" do
72130
assert {:error, :failure} ==
73131
Projector.handle(%ErrorEvent{}, %{handler_name: "Projector", event_number: 1})

test/projections/runtime_config_projector_test.exs

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
defmodule Commanded.Projections.RuntimeConfigProjectorTest do
22
use ExUnit.Case
33

4-
import Commanded.Projections.ProjectionAssertions
5-
4+
alias Commanded.EventStore.Adapters.Mock, as: MockEventStore
65
alias Commanded.EventStore.RecordedEvent
76
alias Commanded.Projections.Events.AnEvent
8-
alias Commanded.Projections.Projection
9-
alias Commanded.Projections.Repo
10-
alias Commanded.Projections.RuntimeConfigProjector
7+
alias Commanded.Projections.{Projection, ProjectionAssertions, Repo, RuntimeConfigProjector}
118
alias Commanded.UUID
129

10+
import Mox
11+
import ProjectionAssertions
12+
13+
setup [:set_mox_global, :stub_event_store, :verify_on_exit!]
14+
1315
setup do
14-
start_supervised!(TestApplication)
16+
start_supervised!({TestApplication, event_store: [adapter: MockEventStore]})
1517
Ecto.Adapters.SQL.Sandbox.checkout(Repo)
1618
end
1719

@@ -54,4 +56,19 @@ defmodule Commanded.Projections.RuntimeConfigProjectorTest do
5456
defp send_events(projector, events) do
5557
send(projector, {:events, events})
5658
end
59+
60+
defp stub_event_store(_context) do
61+
stub(MockEventStore, :ack_event, fn _adapter_meta, _pid, _event -> :ok end)
62+
63+
stub(MockEventStore, :child_spec, fn _application, _config ->
64+
{:ok, [], %{}}
65+
end)
66+
67+
stub(MockEventStore, :subscribe_to, fn
68+
_event_store, :all, _handler_name, _handler, _subscribe_from, _opts ->
69+
{:ok, self()}
70+
end)
71+
72+
:ok
73+
end
5774
end

0 commit comments

Comments
 (0)