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.
@@ -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