event_sourcery-postgres 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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