event_sourcery-postgres 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|