event_sourcery-postgres 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +13 -0
- data/.rspec +3 -0
- data/.travis.yml +12 -0
- data/CHANGELOG.md +32 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +102 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +15 -0
- data/event_sourcery-postgres.gemspec +33 -0
- data/lib/event_sourcery/postgres.rb +28 -0
- data/lib/event_sourcery/postgres/config.rb +47 -0
- data/lib/event_sourcery/postgres/event_store.rb +144 -0
- data/lib/event_sourcery/postgres/optimised_event_poll_waiter.rb +81 -0
- data/lib/event_sourcery/postgres/projector.rb +42 -0
- data/lib/event_sourcery/postgres/queue_with_interval_callback.rb +33 -0
- data/lib/event_sourcery/postgres/reactor.rb +72 -0
- data/lib/event_sourcery/postgres/schema.rb +150 -0
- data/lib/event_sourcery/postgres/table_owner.rb +74 -0
- data/lib/event_sourcery/postgres/tracker.rb +90 -0
- data/lib/event_sourcery/postgres/version.rb +5 -0
- data/script/bench_reading_events.rb +63 -0
- data/script/bench_writing_events.rb +47 -0
- data/script/demonstrate_event_sequence_id_gaps.rb +181 -0
- metadata +181 -0
@@ -0,0 +1,81 @@
|
|
1
|
+
module EventSourcery
|
2
|
+
module Postgres
|
3
|
+
# Optimise poll interval with Postgres listen/notify
|
4
|
+
class OptimisedEventPollWaiter
|
5
|
+
ListenThreadDied = Class.new(StandardError)
|
6
|
+
|
7
|
+
def initialize(pg_connection:, timeout: 30, after_listen: proc { })
|
8
|
+
@pg_connection = pg_connection
|
9
|
+
@timeout = timeout
|
10
|
+
@events_queue = QueueWithIntervalCallback.new
|
11
|
+
@after_listen = after_listen
|
12
|
+
end
|
13
|
+
|
14
|
+
def poll(after_listen: proc { }, &block)
|
15
|
+
@events_queue.callback = proc do
|
16
|
+
ensure_listen_thread_alive!
|
17
|
+
block.call
|
18
|
+
end
|
19
|
+
start_async(after_listen: after_listen)
|
20
|
+
catch(:stop) {
|
21
|
+
block.call
|
22
|
+
loop do
|
23
|
+
ensure_listen_thread_alive!
|
24
|
+
wait_for_new_event_to_appear
|
25
|
+
clear_new_event_queue
|
26
|
+
block.call
|
27
|
+
end
|
28
|
+
}
|
29
|
+
ensure
|
30
|
+
shutdown!
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def shutdown!
|
36
|
+
if @listen_thread.alive?
|
37
|
+
@listen_thread.kill
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def ensure_listen_thread_alive!
|
42
|
+
if !@listen_thread.alive?
|
43
|
+
raise ListenThreadDied
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def wait_for_new_event_to_appear
|
48
|
+
@events_queue.pop
|
49
|
+
end
|
50
|
+
|
51
|
+
def clear_new_event_queue
|
52
|
+
@events_queue.clear
|
53
|
+
end
|
54
|
+
|
55
|
+
def start_async(after_listen: nil)
|
56
|
+
after_listen_callback = if after_listen
|
57
|
+
proc {
|
58
|
+
after_listen.call
|
59
|
+
@after_listen.call if @after_listen
|
60
|
+
}
|
61
|
+
else
|
62
|
+
@after_listen
|
63
|
+
end
|
64
|
+
@listen_thread = Thread.new { listen_for_new_events(loop: true,
|
65
|
+
after_listen: after_listen_callback,
|
66
|
+
timeout: @timeout) }
|
67
|
+
end
|
68
|
+
|
69
|
+
def listen_for_new_events(loop: true, after_listen: nil, timeout: 30)
|
70
|
+
@pg_connection.listen('new_event',
|
71
|
+
loop: loop,
|
72
|
+
after_listen: after_listen,
|
73
|
+
timeout: timeout) do |_channel, _pid, _payload|
|
74
|
+
if @events_queue.empty?
|
75
|
+
@events_queue.push(:new_event_arrived)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module EventSourcery
|
2
|
+
module Postgres
|
3
|
+
module Projector
|
4
|
+
def self.included(base)
|
5
|
+
base.include(EventProcessing::EventStreamProcessor)
|
6
|
+
base.prepend(TableOwner)
|
7
|
+
base.include(InstanceMethods)
|
8
|
+
base.class_eval do
|
9
|
+
alias project process
|
10
|
+
|
11
|
+
class << self
|
12
|
+
alias project process
|
13
|
+
alias projects_events processes_events
|
14
|
+
alias projector_name processor_name
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
module InstanceMethods
|
20
|
+
def initialize(tracker: EventSourcery::Postgres.config.event_tracker,
|
21
|
+
db_connection: EventSourcery::Postgres.config.projections_database)
|
22
|
+
@tracker = tracker
|
23
|
+
@db_connection = db_connection
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def process_events(events, subscription_master)
|
29
|
+
events.each do |event|
|
30
|
+
subscription_master.shutdown_if_requested
|
31
|
+
db_connection.transaction do
|
32
|
+
process(event)
|
33
|
+
tracker.processed_event(processor_name, event.id)
|
34
|
+
end
|
35
|
+
EventSourcery.logger.debug { "[#{processor_name}] Processed event: #{event.inspect}" }
|
36
|
+
EventSourcery.logger.info { "[#{processor_name}] Processed up to event id: #{events.last.id}" }
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module EventSourcery
|
2
|
+
module Postgres
|
3
|
+
class QueueWithIntervalCallback < ::Queue
|
4
|
+
attr_accessor :callback
|
5
|
+
|
6
|
+
def initialize(callback: proc { }, callback_interval: EventSourcery::Postgres.config.callback_interval_if_no_new_events, poll_interval: 0.1)
|
7
|
+
@callback = callback
|
8
|
+
@callback_interval = callback_interval
|
9
|
+
@poll_interval = poll_interval
|
10
|
+
super()
|
11
|
+
end
|
12
|
+
|
13
|
+
def pop(non_block_without_callback = false)
|
14
|
+
return super if non_block_without_callback
|
15
|
+
pop_with_interval_callback
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def pop_with_interval_callback
|
21
|
+
time = Time.now
|
22
|
+
loop do
|
23
|
+
return pop(true) if !empty?
|
24
|
+
if @callback_interval && Time.now > time + @callback_interval
|
25
|
+
@callback.call
|
26
|
+
time = Time.now
|
27
|
+
end
|
28
|
+
sleep @poll_interval
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
module EventSourcery
|
2
|
+
module Postgres
|
3
|
+
module Reactor
|
4
|
+
UndeclaredEventEmissionError = Class.new(StandardError)
|
5
|
+
|
6
|
+
def self.included(base)
|
7
|
+
base.include(EventProcessing::EventStreamProcessor)
|
8
|
+
base.extend(ClassMethods)
|
9
|
+
base.prepend(TableOwner)
|
10
|
+
base.include(InstanceMethods)
|
11
|
+
end
|
12
|
+
|
13
|
+
module ClassMethods
|
14
|
+
def emits_events(*event_types)
|
15
|
+
@emits_event_types = event_types
|
16
|
+
end
|
17
|
+
|
18
|
+
def emit_events
|
19
|
+
@emits_event_types ||= []
|
20
|
+
end
|
21
|
+
|
22
|
+
def emits_events?
|
23
|
+
!emit_events.empty?
|
24
|
+
end
|
25
|
+
|
26
|
+
def emits_event?(event_type)
|
27
|
+
emit_events.include?(event_type)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
module InstanceMethods
|
32
|
+
def initialize(tracker: EventSourcery::Postgres.config.event_tracker,
|
33
|
+
db_connection: EventSourcery::Postgres.config.projections_database,
|
34
|
+
event_source: EventSourcery::Postgres.config.event_source,
|
35
|
+
event_sink: EventSourcery::Postgres.config.event_sink)
|
36
|
+
@tracker = tracker
|
37
|
+
@event_source = event_source
|
38
|
+
@event_sink = event_sink
|
39
|
+
@db_connection = db_connection
|
40
|
+
if self.class.emits_events?
|
41
|
+
if event_sink.nil? || event_source.nil?
|
42
|
+
raise ArgumentError, 'An event sink and source is required for processors that emit events'
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
DRIVEN_BY_EVENT_PAYLOAD_KEY = :_driven_by_event_id
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
attr_reader :event_sink, :event_source
|
53
|
+
|
54
|
+
def emit_event(event_or_hash, &block)
|
55
|
+
event = if Event === event_or_hash
|
56
|
+
event_or_hash
|
57
|
+
else
|
58
|
+
Event.new(event_or_hash)
|
59
|
+
end
|
60
|
+
raise UndeclaredEventEmissionError unless self.class.emits_event?(event.class)
|
61
|
+
event.body.merge!(DRIVEN_BY_EVENT_PAYLOAD_KEY => _event.id)
|
62
|
+
invoke_action_and_emit_event(event, block)
|
63
|
+
EventSourcery.logger.debug { "[#{self.processor_name}] Emitted event: #{event.inspect}" }
|
64
|
+
end
|
65
|
+
|
66
|
+
def invoke_action_and_emit_event(event, action)
|
67
|
+
action.call(event.body) if action
|
68
|
+
event_sink.sink(event)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,150 @@
|
|
1
|
+
module EventSourcery
|
2
|
+
module Postgres
|
3
|
+
module Schema
|
4
|
+
extend self
|
5
|
+
|
6
|
+
def create_event_store(db: EventSourcery::Postgres.config.event_store_database,
|
7
|
+
events_table_name: EventSourcery::Postgres.config.events_table_name,
|
8
|
+
aggregates_table_name: EventSourcery::Postgres.config.aggregates_table_name,
|
9
|
+
write_events_function_name: EventSourcery::Postgres.config.write_events_function_name)
|
10
|
+
create_events(db: db, table_name: events_table_name)
|
11
|
+
create_aggregates(db: db, table_name: aggregates_table_name)
|
12
|
+
create_or_update_functions(db: db, events_table_name: events_table_name, function_name: write_events_function_name, aggregates_table_name: aggregates_table_name)
|
13
|
+
end
|
14
|
+
|
15
|
+
def create_events(db: EventSourcery::Postgres.config.event_store_database,
|
16
|
+
table_name: EventSourcery::Postgres.config.events_table_name)
|
17
|
+
db.run 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'
|
18
|
+
db.create_table(table_name) do
|
19
|
+
primary_key :id, type: :Bignum
|
20
|
+
column :uuid, :uuid, null: false, default: Sequel.lit('uuid_generate_v4()')
|
21
|
+
column :aggregate_id, :uuid, null: false
|
22
|
+
column :type, :varchar, null: false, size: 255
|
23
|
+
column :body, :json, null: false
|
24
|
+
column :version, :bigint, null: false
|
25
|
+
column :correlation_id, :uuid
|
26
|
+
column :causation_id, :uuid
|
27
|
+
column :created_at, :'timestamp without time zone', null: false, default: Sequel.lit("(now() at time zone 'utc')")
|
28
|
+
index [:aggregate_id, :version], unique: true
|
29
|
+
index :uuid, unique: true
|
30
|
+
index :type
|
31
|
+
index :created_at
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def create_aggregates(db: EventSourcery::Postgres.config.event_store_database,
|
36
|
+
table_name: EventSourcery::Postgres.config.aggregates_table_name)
|
37
|
+
db.create_table(table_name) do
|
38
|
+
primary_key :aggregate_id, :uuid
|
39
|
+
column :version, :bigint, default: 1
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def create_or_update_functions(db: EventSourcery::Postgres.config.event_store_database,
|
44
|
+
function_name: EventSourcery::Postgres.config.write_events_function_name,
|
45
|
+
events_table_name: EventSourcery::Postgres.config.events_table_name,
|
46
|
+
aggregates_table_name: EventSourcery::Postgres.config.aggregates_table_name)
|
47
|
+
db.run <<-SQL
|
48
|
+
create or replace function #{function_name}(_aggregateId uuid,
|
49
|
+
_eventTypes varchar[],
|
50
|
+
_expectedVersion int,
|
51
|
+
_bodies json[],
|
52
|
+
_createdAtTimes timestamp without time zone[],
|
53
|
+
_eventUUIDs uuid[],
|
54
|
+
_correlationIds uuid[],
|
55
|
+
_causationIds uuid[],
|
56
|
+
_lockTable boolean) returns void as $$
|
57
|
+
declare
|
58
|
+
currentVersion int;
|
59
|
+
body json;
|
60
|
+
eventVersion int;
|
61
|
+
eventId text;
|
62
|
+
index int;
|
63
|
+
newVersion int;
|
64
|
+
numEvents int;
|
65
|
+
createdAt timestamp without time zone;
|
66
|
+
begin
|
67
|
+
numEvents := array_length(_bodies, 1);
|
68
|
+
select version into currentVersion from #{aggregates_table_name} where aggregate_id = _aggregateId;
|
69
|
+
if not found then
|
70
|
+
-- when we have no existing version for this aggregate
|
71
|
+
if _expectedVersion = 0 or _expectedVersion is null then
|
72
|
+
-- set the version to 1 if expected version is null or 0
|
73
|
+
insert into #{aggregates_table_name}(aggregate_id, version) values(_aggregateId, numEvents);
|
74
|
+
currentVersion := 0;
|
75
|
+
else
|
76
|
+
raise 'Concurrency conflict. Current version: 0, expected version: %', _expectedVersion;
|
77
|
+
end if;
|
78
|
+
else
|
79
|
+
if _expectedVersion is null then
|
80
|
+
-- automatically increment the version
|
81
|
+
update #{aggregates_table_name} set version = version + numEvents where aggregate_id = _aggregateId returning version into newVersion;
|
82
|
+
currentVersion := newVersion - numEvents;
|
83
|
+
else
|
84
|
+
-- increment the version if it's at our expected version
|
85
|
+
update #{aggregates_table_name} set version = version + numEvents where aggregate_id = _aggregateId and version = _expectedVersion;
|
86
|
+
if not found then
|
87
|
+
-- version was not at expected_version, raise an error.
|
88
|
+
-- currentVersion may not equal what it did in the database when the
|
89
|
+
-- above update statement is executed (it may have been incremented by another
|
90
|
+
-- process)
|
91
|
+
raise 'Concurrency conflict. Last known current version: %, expected version: %', currentVersion, _expectedVersion;
|
92
|
+
end if;
|
93
|
+
end if;
|
94
|
+
end if;
|
95
|
+
index := 1;
|
96
|
+
eventVersion := currentVersion + 1;
|
97
|
+
if _lockTable then
|
98
|
+
-- Ensure this transaction is the only one writing events to guarantee
|
99
|
+
-- linear growth of sequence IDs.
|
100
|
+
-- Any value that won't conflict with other advisory locks will work.
|
101
|
+
-- The Postgres tracker currently obtains an advisory lock using it's
|
102
|
+
-- integer row ID, so values 1 to the number of ESP's in the system would
|
103
|
+
-- be taken if the tracker is running in the same database as your
|
104
|
+
-- projections.
|
105
|
+
perform pg_advisory_xact_lock(-1);
|
106
|
+
end if;
|
107
|
+
foreach body IN ARRAY(_bodies)
|
108
|
+
loop
|
109
|
+
if _createdAtTimes[index] is not null then
|
110
|
+
createdAt := _createdAtTimes[index];
|
111
|
+
else
|
112
|
+
createdAt := now() at time zone 'utc';
|
113
|
+
end if;
|
114
|
+
|
115
|
+
insert into #{events_table_name}
|
116
|
+
(uuid, aggregate_id, type, body, version, correlation_id, causation_id, created_at)
|
117
|
+
values
|
118
|
+
(
|
119
|
+
_eventUUIDs[index],
|
120
|
+
_aggregateId,
|
121
|
+
_eventTypes[index],
|
122
|
+
body,
|
123
|
+
eventVersion,
|
124
|
+
_correlationIds[index],
|
125
|
+
_causationIds[index],
|
126
|
+
createdAt
|
127
|
+
)
|
128
|
+
returning id into eventId;
|
129
|
+
|
130
|
+
eventVersion := eventVersion + 1;
|
131
|
+
index := index + 1;
|
132
|
+
end loop;
|
133
|
+
perform pg_notify('new_event', eventId);
|
134
|
+
end;
|
135
|
+
$$ language plpgsql;
|
136
|
+
SQL
|
137
|
+
end
|
138
|
+
|
139
|
+
def create_projector_tracker(db: EventSourcery::Postgres.config.projections_database,
|
140
|
+
table_name: EventSourcery::Postgres.config.tracker_table_name)
|
141
|
+
db.create_table(table_name) do
|
142
|
+
primary_key :id, type: :Bignum
|
143
|
+
column :name, 'varchar(255) not null'
|
144
|
+
column :last_processed_event_id, 'bigint not null default 0'
|
145
|
+
index :name, unique: true
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module EventSourcery
|
2
|
+
module Postgres
|
3
|
+
module TableOwner
|
4
|
+
DefaultTableError = Class.new(StandardError)
|
5
|
+
NoSuchTableError = Class.new(StandardError)
|
6
|
+
|
7
|
+
def self.prepended(base)
|
8
|
+
base.extend(ClassMethods)
|
9
|
+
end
|
10
|
+
|
11
|
+
module ClassMethods
|
12
|
+
def tables
|
13
|
+
@tables ||= {}
|
14
|
+
end
|
15
|
+
|
16
|
+
def table(name, &block)
|
17
|
+
tables[name] = block
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def setup
|
22
|
+
self.class.tables.each do |table_name, schema_block|
|
23
|
+
prefixed_name = table_name_prefixed(table_name)
|
24
|
+
@db_connection.create_table?(prefixed_name, &schema_block)
|
25
|
+
end
|
26
|
+
super if defined?(super)
|
27
|
+
end
|
28
|
+
|
29
|
+
def reset
|
30
|
+
self.class.tables.keys.each do |table_name|
|
31
|
+
prefixed_name = table_name_prefixed(table_name)
|
32
|
+
if @db_connection.table_exists?(prefixed_name)
|
33
|
+
@db_connection.drop_table(prefixed_name, cascade: true)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
super if defined?(super)
|
37
|
+
setup
|
38
|
+
end
|
39
|
+
|
40
|
+
def truncate
|
41
|
+
self.class.tables.each do |table_name, _|
|
42
|
+
@db_connection.transaction do
|
43
|
+
prefixed_name = table_name_prefixed(table_name)
|
44
|
+
@db_connection[prefixed_name].truncate
|
45
|
+
tracker.reset_last_processed_event_id(self.class.processor_name)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
attr_reader :db_connection
|
53
|
+
attr_accessor :table_prefix
|
54
|
+
|
55
|
+
def table(name = nil)
|
56
|
+
if name.nil? && self.class.tables.length != 1
|
57
|
+
raise DefaultTableError, 'You must specify table name when when 0 or multiple tables are defined'
|
58
|
+
end
|
59
|
+
|
60
|
+
name ||= self.class.tables.keys.first
|
61
|
+
|
62
|
+
unless self.class.tables[name.to_sym]
|
63
|
+
raise NoSuchTableError, "There is no table with the name '#{name}' defined"
|
64
|
+
end
|
65
|
+
|
66
|
+
db_connection[table_name_prefixed(name)]
|
67
|
+
end
|
68
|
+
|
69
|
+
def table_name_prefixed(name)
|
70
|
+
[table_prefix, name].compact.join("_").to_sym
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|