pg_eventstore 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +1 -0
  4. data/db/migrations/10_create_subscription_commands.sql +15 -0
  5. data/db/migrations/11_create_subscriptions_set_commands.sql +15 -0
  6. data/db/migrations/12_improve_events_indexes.sql +1 -0
  7. data/db/migrations/9_create_subscriptions.sql +46 -0
  8. data/docs/configuration.md +42 -21
  9. data/docs/subscriptions.md +170 -0
  10. data/lib/pg_eventstore/callbacks.rb +122 -0
  11. data/lib/pg_eventstore/client.rb +2 -2
  12. data/lib/pg_eventstore/config.rb +35 -3
  13. data/lib/pg_eventstore/errors.rb +63 -0
  14. data/lib/pg_eventstore/{pg_result_deserializer.rb → event_deserializer.rb} +11 -14
  15. data/lib/pg_eventstore/extensions/callbacks_extension.rb +95 -0
  16. data/lib/pg_eventstore/extensions/options_extension.rb +25 -23
  17. data/lib/pg_eventstore/extensions/using_connection_extension.rb +35 -0
  18. data/lib/pg_eventstore/queries/event_queries.rb +5 -26
  19. data/lib/pg_eventstore/queries/event_type_queries.rb +13 -0
  20. data/lib/pg_eventstore/queries/subscription_command_queries.rb +81 -0
  21. data/lib/pg_eventstore/queries/subscription_queries.rb +160 -0
  22. data/lib/pg_eventstore/queries/subscriptions_set_command_queries.rb +76 -0
  23. data/lib/pg_eventstore/queries/subscriptions_set_queries.rb +89 -0
  24. data/lib/pg_eventstore/queries.rb +6 -0
  25. data/lib/pg_eventstore/query_builders/events_filtering_query.rb +14 -2
  26. data/lib/pg_eventstore/sql_builder.rb +54 -10
  27. data/lib/pg_eventstore/subscriptions/basic_runner.rb +220 -0
  28. data/lib/pg_eventstore/subscriptions/command_handlers/subscription_feeder_commands.rb +52 -0
  29. data/lib/pg_eventstore/subscriptions/command_handlers/subscription_runners_commands.rb +68 -0
  30. data/lib/pg_eventstore/subscriptions/commands_handler.rb +62 -0
  31. data/lib/pg_eventstore/subscriptions/events_processor.rb +72 -0
  32. data/lib/pg_eventstore/subscriptions/runner_state.rb +45 -0
  33. data/lib/pg_eventstore/subscriptions/subscription.rb +141 -0
  34. data/lib/pg_eventstore/subscriptions/subscription_feeder.rb +171 -0
  35. data/lib/pg_eventstore/subscriptions/subscription_handler_performance.rb +39 -0
  36. data/lib/pg_eventstore/subscriptions/subscription_runner.rb +125 -0
  37. data/lib/pg_eventstore/subscriptions/subscription_runners_feeder.rb +38 -0
  38. data/lib/pg_eventstore/subscriptions/subscriptions_manager.rb +105 -0
  39. data/lib/pg_eventstore/subscriptions/subscriptions_set.rb +97 -0
  40. data/lib/pg_eventstore/tasks/setup.rake +1 -1
  41. data/lib/pg_eventstore/utils.rb +66 -0
  42. data/lib/pg_eventstore/version.rb +1 -1
  43. data/lib/pg_eventstore.rb +19 -1
  44. metadata +30 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e22bb2356955ece89a16f5d43b803670d0304eaf27d2fedbf1daf12aa908812
4
- data.tar.gz: 31d95e0380be2ad8dd7306a1b04a8a14e6f4f32f6bcdb8864e42205c0a1fe9bc
3
+ metadata.gz: 256d7be42cb993955eb3c387b27fa63bf9992852da2375c785f181b873fe7568
4
+ data.tar.gz: 4101d8f0706f402a8e97bce4864c2ab2c61a587845e1051826d7d26417c199bd
5
5
  SHA512:
6
- metadata.gz: 8cda13e213beec47c83818301b415d1dbf5b80e0659e548c6d166a3e136172c38802b697353f13344bb0d6b15b99b54f59e950ab9fb2f07e7bf494adedb2c280
7
- data.tar.gz: 5f0743afb46b2dcd00c9c164b51beb6f4ea6284b839a0036418d46e60e7d7b8a53d378a1eb265fc063364970ae6fb2b0091fabae51429775bec2f8c1c2e843fa
6
+ metadata.gz: 548512c6c8ec7161380848b1975313222173dd41db13d45c0216948bf769eb513d8593dc0e22263c4cc59c9210a7c90fab21f7a64b26a38217253422457582b4
7
+ data.tar.gz: 777aecbd9e7ad84882fbf0c20847fc79e8495addac77a5887db3dface85b7dec7aa3679e91849272109f6f6745cdbfd143628d8dc53d78b73559ba9fad31722f
data/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## [0.4.0] - 2024-01-29
2
+
3
+ - Implement asynchronous subscriptions. Refer to the documentation for more info
4
+
1
5
  ## [0.3.0] - 2024-01-24
2
6
 
3
7
  - Log SQL queries when `PgEvenstore.logger` is set and it is in `:debug` mode
data/README.md CHANGED
@@ -53,6 +53,7 @@ Documentation chapters:
53
53
  - [Events and streams definitions](docs/events_and_streams.md)
54
54
  - [Appending events](docs/appending_events.md)
55
55
  - [Reading events](docs/reading_events.md)
56
+ - [Subscriptions](docs/subscriptions.md)
56
57
  - [Writing middlewares](docs/writing_middleware.md)
57
58
  - [How to make multiple commands atomic](docs/multiple_commands.md)
58
59
 
@@ -0,0 +1,15 @@
1
+ CREATE TABLE public.subscription_commands
2
+ (
3
+ id bigserial NOT NULL,
4
+ name character varying NOT NULL,
5
+ subscription_id bigint NOT NULL,
6
+ created_at timestamp without time zone NOT NULL DEFAULT now()
7
+ );
8
+
9
+ ALTER TABLE ONLY public.subscription_commands
10
+ ADD CONSTRAINT subscription_commands_pkey PRIMARY KEY (id);
11
+
12
+ CREATE UNIQUE INDEX idx_subscription_commands_subscription_id_and_name ON public.subscription_commands USING btree (subscription_id, name);
13
+
14
+ ALTER TABLE ONLY public.subscription_commands
15
+ ADD CONSTRAINT subscription_commands_subscription_fk FOREIGN KEY (subscription_id) REFERENCES public.subscriptions (id) ON DELETE CASCADE;
@@ -0,0 +1,15 @@
1
+ CREATE TABLE public.subscriptions_set_commands
2
+ (
3
+ id bigserial NOT NULL,
4
+ name character varying NOT NULL,
5
+ subscriptions_set_id uuid NOT NULL,
6
+ created_at timestamp without time zone NOT NULL DEFAULT now()
7
+ );
8
+
9
+ ALTER TABLE ONLY public.subscriptions_set_commands
10
+ ADD CONSTRAINT subscriptions_set_commands_pkey PRIMARY KEY (id);
11
+
12
+ CREATE UNIQUE INDEX idx_subscr_set_commands_subscriptions_set_id_and_name ON public.subscriptions_set_commands USING btree (subscriptions_set_id, name);
13
+
14
+ ALTER TABLE ONLY public.subscriptions_set_commands
15
+ ADD CONSTRAINT subscriptions_set_commands_subscriptions_set_fk FOREIGN KEY (subscriptions_set_id) REFERENCES public.subscriptions_set (id) ON DELETE CASCADE;
@@ -0,0 +1 @@
1
+ CREATE INDEX idx_events_event_type_id_and_global_position ON public.events USING btree (event_type_id, global_position);
@@ -0,0 +1,46 @@
1
+ CREATE TABLE public.subscriptions_set
2
+ (
3
+ id uuid DEFAULT public.gen_random_uuid() NOT NULL,
4
+ name character varying NOT NULL,
5
+ state character varying NOT NULL DEFAULT 'initial',
6
+ restart_count integer NOT NULL DEFAULT 0,
7
+ max_restarts_number int2 NOT NULL DEFAULT 10,
8
+ time_between_restarts int2 NOT NULL DEFAULT 1,
9
+ last_restarted_at timestamp without time zone,
10
+ last_error jsonb,
11
+ last_error_occurred_at timestamp without time zone,
12
+ created_at timestamp without time zone NOT NULL DEFAULT now(),
13
+ updated_at timestamp without time zone NOT NULL DEFAULT now()
14
+ );
15
+
16
+ ALTER TABLE ONLY public.subscriptions_set
17
+ ADD CONSTRAINT subscriptions_set_pkey PRIMARY KEY (id);
18
+
19
+ CREATE TABLE public.subscriptions
20
+ (
21
+ id bigserial NOT NULL,
22
+ set character varying NOT NULL,
23
+ name character varying NOT NULL,
24
+ options jsonb NOT NULL DEFAULT '{}'::jsonb,
25
+ total_processed_events bigint NOT NULL DEFAULT 0,
26
+ current_position bigint,
27
+ average_event_processing_time float4,
28
+ state character varying NOT NULL DEFAULT 'initial',
29
+ restart_count integer NOT NULL DEFAULT 0,
30
+ max_restarts_number int2 NOT NULL DEFAULT 100,
31
+ time_between_restarts int2 NOT NULL DEFAULT 1,
32
+ last_restarted_at timestamp without time zone,
33
+ last_error jsonb,
34
+ last_error_occurred_at timestamp without time zone,
35
+ chunk_query_interval int2 NOT NULL DEFAULT 5,
36
+ last_chunk_fed_at timestamp without time zone NOT NULL DEFAULT to_timestamp(0),
37
+ last_chunk_greatest_position bigint,
38
+ locked_by uuid,
39
+ created_at timestamp without time zone NOT NULL DEFAULT now(),
40
+ updated_at timestamp without time zone NOT NULL DEFAULT now()
41
+ );
42
+
43
+ ALTER TABLE ONLY public.subscriptions
44
+ ADD CONSTRAINT subscriptions_pkey PRIMARY KEY (id);
45
+
46
+ CREATE UNIQUE INDEX idx_subscriptions_set_and_name ON public.subscriptions USING btree (set, name);
@@ -2,21 +2,23 @@
2
2
 
3
3
  Configuration options:
4
4
 
5
- | name | value | default value | description |
6
- |-------------------------|----------|--------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
7
- | pg_uri | String | `'postgresql://postgres:postgres@localhost:5432/eventstore'` | PostgreSQL connection string. See PostgreSQL [docs](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS) for more information. |
8
- | max_count | Integer | `1000` | Number of events to return in one response when reading from a stream. |
9
- | middlewares | Array | `{}` | A hash where a key is a name of your middleware and value is an object that respond to `#serialize` and `#deserialize` methods. See [**Writing middleware**](writing_middleware.md) chapter. |
10
- | event_class_resolver | `#call` | `PgEventstore::EventClassResolver.new` | A `#call`-able object that accepts a string and returns an event's class. See **Resolving events classes** chapter bellow for more info. |
11
- | logger | `Logger` | `nil` | A logger that logs messages from `pg_eventstore` gem. |
12
- | connection_pool_size | Integer | `5` | Max number of connections per ruby process. It must equal the number of threads of your application. |
13
- | connection_pool_timeout | Integer | `5` | Time in seconds to wait for the connection in pool to be released. If no connections are available during this time - `ConnectionPool::TimeoutError` will be raised. See `connection_pool` gem [docs](https://github.com/mperham/connection_pool#usage) for more info. |
5
+ | name | value | default value | description |
6
+ |------------------------------------|---------|--------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
7
+ | pg_uri | String | `'postgresql://postgres:postgres@localhost:5432/eventstore'` | PostgreSQL connection string. See PostgreSQL [docs](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS) for more information. |
8
+ | max_count | Integer | `1000` | Number of events to return in one response when reading from a stream. |
9
+ | middlewares | Array | `{}` | A hash where a key is a name of your middleware and value is an object that respond to `#serialize` and `#deserialize` methods. See [**Writing middleware**](writing_middleware.md) chapter. |
10
+ | event_class_resolver | `#call` | `PgEventstore::EventClassResolver.new` | A `#call`-able object that accepts a string and returns an event's class. See **Resolving events classes** chapter bellow for more info. |
11
+ | connection_pool_size | Integer | `5` | Max number of connections per ruby process. It must equal the number of threads of your application. When using subscriptions it is recommended to set it to the number of subscriptions divided by two or greater. See [**Picking max connections number**](#picking-max-connections-number) chapter of this section. |
12
+ | connection_pool_timeout | Integer | `5` | Time in seconds to wait for a connection in the pool to be released. If no connections are available during this time - `ConnectionPool::TimeoutError` will be raised. See `connection_pool` gem [docs](https://github.com/mperham/connection_pool#usage) for more info. |
13
+ | subscription_pull_interval | Integer | `2` | How often to pull new subscription events in seconds. |
14
+ | subscription_max_retries | Integer | `5` | Max number of retries of failed subscription. |
15
+ | subscription_retries_interval | Integer | `1` | Interval in seconds between retries of failed subscriptions. |
16
+ | subscriptions_set_max_retries | Integer | `10` | Max number of retries for failed subscription sets. |
17
+ | subscriptions_set_retries_interval | Integer | `1` | interval in seconds between retries of failed subscription sets. |
14
18
 
15
19
  ## Multiple configurations
16
20
 
17
- `pg_eventstore` allows you to have as many configs as you want. This allows you, for example, to have different
18
- databases, or to have a different set of middlewares for specific cases only. To do so, you have to name your
19
- configuration, and later provide that name to `PgEventstore` client.
21
+ `pg_eventstore` allows you to have as many configs as you want. This allows you, for example, to have different databases, or to have a different set of middlewares for specific cases only. To do so, you have to name your configuration, and later provide that name to `PgEventstore` client.
20
22
 
21
23
  Setup your configs:
22
24
 
@@ -42,8 +44,7 @@ PgEventstore.client(:pg_db_2).read(PgEventstore::Stream.all_stream)
42
44
 
43
45
  ### Default config
44
46
 
45
- If you have one config only - you don't have to bother naming it or passing a config name to the client when performing
46
- any operations. You can configure it as usual.
47
+ If you have one config only - you don't have to bother naming it or passing a config name to the client when performing any operations. You can configure it as usual.
47
48
 
48
49
  Setup your default config:
49
50
 
@@ -64,19 +65,39 @@ EventStoreClient.client.read(PgEventstore::Stream.all_stream)
64
65
 
65
66
  ## Resolving event classes
66
67
 
67
- During the deserialization process `pg_eventstore` tries to pick the correct class for an event. By default it does it
68
- using the `PgEventstore::EventClassResolver` class. All it does is `Object.const_get(event_type)`. By default, if you
69
- don't provide the `type` attribute for an event explicitly, it will grab the event's class name, meaning by default:
68
+ During the deserialization process `pg_eventstore` tries to pick the correct class for an event. By default it does it using the `PgEventstore::EventClassResolver` class. All it does is `Object.const_get(event_type)`. By default, if you don't provide the `type` attribute for an event explicitly, it will grab the event's class name, meaning by default:
70
69
 
71
70
  - event's type is event's class name
72
- - when instantiating an event - `pg_eventstore` tries to lookup an event class based on the value of event's `type`
73
- attribute with a fallback to `PgEventstore::Event` class
71
+ - when instantiating an event - `pg_eventstore` tries to lookup an event class based on the value of event's `type` attribute with a fallback to `PgEventstore::Event` class
74
72
 
75
- You can override the default event class resolver by providing any `#call`-able object. It should accept event's type
76
- and return event's class based on it. Example:
73
+ You can override the default event class resolver by providing any `#call`-able object. It should accept event's type and return event's class based on it. Example:
77
74
 
78
75
  ```ruby
79
76
  PgEventstore.configure do |config|
80
77
  config.event_class_resolver = proc { |event_type| Object.const_get(event_type.gsub('Foo', 'Bar')) rescue PgEventstore::Event }
81
78
  end
82
79
  ```
80
+
81
+ ## Picking max connections number
82
+
83
+ A connection is hold from the connection pool to perform the request and it is released back to the connection pool once the request is finished. If you run into the (theoretical) edge case, when all your application's threads (or subscriptions) are performing `pg_eventstore` queries at the same time and all those queries take more than `connection_pool_timeout` seconds to complete, you have to have `connection_pool_size` set to the exact amount of your application's threads (or to the number of subscriptions when using subscriptions) to prevent timeout errors. Practically this is not the case, as all `pg_eventstore` queries are pretty fast. So, a good value for the `connection_pool_size` option is **half the number ** of your application's threads(or half the number of Subscriptions).
84
+
85
+ ### Exception scenario
86
+
87
+ If you are using the [`#multiple`](multiple_commands.md) method - you have to take into account the execution time of the whole block you pass in it. This is because the connection will be released only after the block's execution is finished. So, for example, if you perform several commands within the block, as well as some API request, the connection will be release only after all those steps:
88
+
89
+ ```ruby
90
+ PgEventstore.client.multiple do
91
+ # Connection is hold from the connection pool
92
+ PgEventstore.client.read(some_stream)
93
+ Stripe::Payment.create(some_attrs)
94
+ PgEventstore.client.append_to_stream(some_stream, some_event)
95
+ # Connection is released
96
+ end
97
+ ```
98
+
99
+ Taking this into account you may want to increase `connection_pool_size` up to the number of your application's threads(or subscriptions).
100
+
101
+ ### Usage of external connection pooler
102
+
103
+ `pg_eventstore` does not use any session-specific features of PostgreSQL. You can use any PostgreSQL connection pooler you like, such as [PgBouncer](https://www.pgbouncer.org/) for example.
@@ -0,0 +1,170 @@
1
+ # Subscriptions
2
+
3
+ In order to process new events in your microservices you have to have the ability to listen for them. `pg_eventstore` implements a subscription feature for this matter. It is implemented as a background thread that pulls new events according to your filters from time to time (see `subscription_pull_interval` setting option under [**Configuration**](configuration.md) chapter).
4
+
5
+ ## PgEventstore::Subscription
6
+
7
+ `pg_eventstore` stores various subscription information in the database. The corresponding object that describes the database records is the `PgEventstore::Subscription` object. It is used in the `config.subscription_restart_terminator` setting for example. You can find its attributes summary [here](https://rubydoc.info/gems/pg_eventstore/PgEventstore/Subscription).
8
+
9
+ ## PgEventstore::SubscriptionsSet
10
+
11
+ `pg_eventstore` also stores information about which subscriptions are set. The corresponding object that describes the database records is `PgEventstore::SubscriptionsSet`. You can find its attributes summary [here](https://rubydoc.info/gems/pg_eventstore/PgEventstore/SubscriptionsSet).
12
+
13
+ This record is created when you start your subscriptions. All subscriptions created using a single subscriptions manager instance are locked using a single `PgEventstore::SubscriptionsSet`. When subscriptions are locked, they can't be managed anywhere else. When you stop your subscriptions, the `PgEventstore::SubscriptionsSet` is deleted, unlocking the subscriptions. The `SubscriptionSet` also holds information about the state, number of restarts, the restart interval and last error of the background runner which is responsible for pulling the subscription's events. You can set the max number of restarts and the restarts interval of your subscriptions set via `config.subscriptions_set_max_retries` and `config.subscriptions_set_retries_interval` settings. See [**Configuration**](configuration.md) chapter for more info.
14
+
15
+ ## Creating a subscription
16
+
17
+ First step you need to do is to create a `PgEventstore::SubscriptionsManager` object and provide the `subscription_set` keyword argument. Optionally you can provide a config name to use, override the `config.subscriptions_set_max_retries` and `config.subscriptions_set_retries_interval` settings:
18
+
19
+ ```ruby
20
+ subscriptions_manager = PgEventstore.subscriptions_manager(subscription_set: 'SubscriptionsOfMyAwesomeMicroservice')
21
+ another_subscriptions_manager = PgEventstore.subscriptions_manager(:my_custom_config, subscription_set: 'SubscriptionsOfMyAwesomeMicroservice', max_retries: 5, retries_interval: 2)
22
+ ```
23
+
24
+ The required `subscription_set` option groups your subscriptions into a set. For example, you could refer to your service's name in the subscription set name.
25
+
26
+ Now we can use the `#subscribe` method to create the subscription:
27
+
28
+ ```ruby
29
+ subscriptions_manager.subscribe('MyAwesomeSubscription', handler: proc { |event| puts event })
30
+ ```
31
+
32
+ First argument is the subscription's name. **It must be unique within the subscription set**. Second argument is your subscription's handler where you will be processing your events as they arrive. The example shows the minimum set of arguments required to create the subscription.
33
+
34
+ In the given state it will be listening to all events from all streams. You can define various filters by providing the `:filter` key of `options` argument:
35
+
36
+ ```ruby
37
+ subscriptions_manager.subscribe(
38
+ 'MyAwesomeSubscription',
39
+ handler: proc { |event| puts event },
40
+ options: { filter: { streams: [{ context: 'MyAwesomeContext' }], event_types: ['Foo', 'Bar'] } }
41
+ )
42
+ ```
43
+
44
+ `:filter` supports the same options as the `#read` method supports when reading from the `"all"` stream. See [*"all" stream filtering*](reading_events.md#all-stream-filtering) section of **Reading events** chapter.
45
+
46
+ After you added all necessary subscriptions, it is time to start them:
47
+
48
+ ```ruby
49
+ subscriptions_manager.start
50
+ ```
51
+
52
+ After calling `#start` all subscriptions are locked behind the given subscription set and can't be locked by any other subscription set. This measure is needed to prevent running the same subscription under the same subscription set using different processes/subscription managers. Such situation will lead to a malformed subscription state and will break its position, meaning the same event will be processed several times.
53
+
54
+ To "unlock" the subscription you should gracefully stop the subscription manager:
55
+
56
+ ```ruby
57
+ subscriptions_manager.stop
58
+ ```
59
+
60
+ If you shut down the process which runs your subscriptions without calling the `#stop` method, subscriptions will remain locked, and the only way to unlock them will be to call the `#force_lock!` method before calling the `#start` method:
61
+
62
+ ```ruby
63
+ subscriptions_manager.force_lock!
64
+ subscriptions_manager.start
65
+ ```
66
+
67
+ A complete example of the subscription setup process looks like this:
68
+
69
+ ```ruby
70
+ require 'pg_eventstore'
71
+
72
+ PgEventstore.configure do |config|
73
+ config.pg_uri = ENV.fetch('PG_EVENTSTORE_URI') { 'postgresql://postgres:postgres@localhost:5532/eventstore' }
74
+ end
75
+
76
+ subscriptions_manager = PgEventstore.subscriptions_manager(subscription_set: 'MyAwesomeSubscriptions')
77
+ subscriptions_manager.subscribe(
78
+ 'Foo events Subscription',
79
+ handler: proc { |event| p "Foo events Subscription: #{event.inspect}" },
80
+ options: { filter: { event_types: ['Foo'] } }
81
+ )
82
+ subscriptions_manager.subscribe(
83
+ '"BarCtx" context Subscription',
84
+ handler: proc { |event| p "'BarCtx' context Subscription: #{event.inspect}" },
85
+ options: { filter: { streams: [{ context: 'BarCtx' }] }
86
+ }
87
+ )
88
+ subscriptions_manager.force_lock! if ENV['FORCE_LOCK'] == 'true'
89
+ subscriptions_manager.start
90
+
91
+ Kernel.trap('TERM') do
92
+ puts "Received TERM signal. Stopping Subscriptions Manager and exiting..."
93
+ # It is important to wrap subscriptions_manager.stop into another Thread, because it uses Thread::Mutex#synchronize
94
+ # internally, but its usage is not allowed inside Kernel.trap block
95
+ Thread.new { subscriptions_manager.stop }.join
96
+ exit
97
+ end
98
+
99
+ loop do
100
+ sleep 5
101
+ subscriptions_manager.subscriptions.each do |subscription|
102
+ puts <<~TEXT
103
+ Subscription <<#{subscription.name.inspect}>> is at position #{subscription.current_position}. \
104
+ Events processed: #{subscription.total_processed_events}
105
+ TEXT
106
+ end
107
+ puts "Current SubscriptionsSet: #{subscriptions_manager.subscriptions_set}"
108
+ puts ""
109
+ end
110
+ ```
111
+
112
+ You can save this script in `subscriptions.rb`, run it with `bundle exec ruby subscriptions.rb`, open another ruby console and test posting different events:
113
+
114
+ ```ruby
115
+ require 'pg_eventstore'
116
+
117
+ PgEventstore.configure do |config|
118
+ config.pg_uri = ENV.fetch('PG_EVENTSTORE_URI') { 'postgresql://postgres:postgres@localhost:5532/eventstore' }
119
+ end
120
+
121
+ foo_stream = PgEventstore::Stream.new(context: 'FooCtx', stream_name: 'MyAwesomeStream', stream_id: '1')
122
+ bar_stream = PgEventstore::Stream.new(context: 'BarCtx', stream_name: 'MyAwesomeStream', stream_id: '1')
123
+ PgEventstore.client.append_to_stream(foo_stream, PgEventstore::Event.new(type: 'Foo', data: { foo: :bar }))
124
+ PgEventstore.client.append_to_stream(bar_stream, PgEventstore::Event.new(type: 'Foo', data: { foo: :bar }))
125
+ ```
126
+
127
+ You will then see the output of your subscription handlers. To gracefully stop the subscriptions process, use `kill -TERM <pid>` command.
128
+
129
+ ## Overriding Subscription config values
130
+
131
+ You can override `subscription_pull_interval`, `subscription_max_retries`, `subscription_retries_interval` and `subscription_restart_terminator` config values (see [**Configuration**](configuration.md) chapter for details) for the specific subscription by providing the corresponding arguments. Example:
132
+
133
+ ```ruby
134
+ subscriptions_manager.subscribe(
135
+ 'MyAwesomeSubscription',
136
+ handler: proc { |event| puts event },
137
+ # overrides config.subscription_pull_interval
138
+ pull_interval: 1,
139
+ # overrides config.subscription_max_retries
140
+ max_retries: 10,
141
+ # overrides config.subscription_retries_interval
142
+ retries_interval: 2,
143
+ # overrides config.subscription_restart_terminator
144
+ restart_terminator: proc { |subscription| subscription.last_error['class'] == 'NoMethodError' },
145
+ )
146
+ ```
147
+
148
+ ## Middlewares
149
+
150
+ If you would like to skip some of your registered middlewares from processing events after they are being pulled by the subscription, you should use the `:middlewares` argument which allows you to override the list of middlewares you would like to use.
151
+
152
+ Let's say you have these registered middlewares:
153
+
154
+ ```ruby
155
+ PgEventstore.configure do |config|
156
+ config.middlewares = { foo: FooMiddleware.new, bar: BarMiddleware.new, baz: BazMiddleware.new }
157
+ end
158
+ ```
159
+
160
+ And you want to skip `FooMiddleware` and `BazMiddleware`. You simply have to provide an array of corresponding middleware keys you would like to use when creating the subscription:
161
+
162
+ ```ruby
163
+ subscriptions_manager.subscribe('MyAwesomeSubscription', handler: proc { |event| puts event }, middlewares: %i[bar])
164
+ ```
165
+
166
+ See the [Writing middleware](writing_middleware.md) chapter for info about what is middleware and how to implement it.
167
+
168
+ ## How many subscriptions I should put in one process?
169
+
170
+ It depends on the nature of your subscription handlers. If they spend more time on ruby code execution than on IO operations, you should limit the number of subscriptions per single process. This can be especially noticed when you rebuild the read models of your microservice, processing all events from the start.
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgEventstore
4
+ # Allows you to define before, around and after callbacks withing the certain action. It is especially useful during
5
+ # asynchronous programming when you need to be able to react on asynchronous actions from the outside.
6
+ # Example:
7
+ # class MyAwesomeClass
8
+ # attr_reader :callbacks
9
+ #
10
+ # def initialize
11
+ # @callbacks = PgEventstore::Callbacks.new
12
+ # end
13
+ #
14
+ # def do_something
15
+ # Thread.new do
16
+ # @callbacks.run_callbacks(:something_happened) do
17
+ # puts "I did something useful!"
18
+ # end
19
+ # end
20
+ # end
21
+ #
22
+ # def do_something_else
23
+ # @callbacks.run_callbacks(:something_else_happened, :foo, bar: :baz) do
24
+ # puts "Something else happened!"
25
+ # end
26
+ # end
27
+ # end
28
+ #
29
+ # obj = MyAwesomeClass.new
30
+ # obj.callbacks.define_callback(:something_happened, :before, proc { puts "In before callback" })
31
+ # obj.callbacks.define_callback(:something_happened, :after, proc { puts "In after callback" })
32
+ # obj.callbacks.define_callback(
33
+ # :something_happened, :around,
34
+ # proc { |action| puts "In around before action"; action.call; puts "In around after action" }
35
+ # )
36
+ # obj.do_something
37
+ # Outputs:
38
+ # In before callback
39
+ # In around callback before action
40
+ # I did something useful!
41
+ # In around callback after action
42
+ # In after callback
43
+ # Please note, that it is important to call *action.call* in around callback. Otherwise action simply won't be called.
44
+ #
45
+ # Optionally you can provide any set of arguments to {#run_callbacks} method. They will be passed to your callbacks
46
+ # functions then. Example:
47
+ #
48
+ # obj = MyAwesomeClass.new
49
+ # obj.callbacks.define_callback(
50
+ # :something_else_happened, :before,
51
+ # proc { |*args, **kwargs| puts "In before callback. Args: #{args}, kwargs: #{kwargs}." }
52
+ # )
53
+ # obj.callbacks.define_callback(
54
+ # :something_else_happened, :after,
55
+ # proc { |*args, **kwargs| puts "In after callback. Args: #{args}, kwargs: #{kwargs}." }
56
+ # )
57
+ # obj.callbacks.define_callback(
58
+ # :something_else_happened, :around,
59
+ # proc { |action, *args, **kwargs|
60
+ # puts "In around before action. Args: #{args}, kwargs: #{kwargs}."
61
+ # action.call
62
+ # puts "In around after action. Args: #{args}, kwargs: #{kwargs}."
63
+ # }
64
+ # )
65
+ # obj.do_something_else
66
+ # Outputs:
67
+ # In before callback. Args: [:foo], kwargs: {:bar=>:baz}.
68
+ # In around before action. Args: [:foo], kwargs: {:bar=>:baz}.
69
+ # I did something useful!
70
+ # In around after action. Args: [:foo], kwargs: {:bar=>:baz}.
71
+ # In after callback. Args: [:foo], kwargs: {:bar=>:baz}.
72
+ class Callbacks
73
+ def initialize
74
+ @callbacks = {}
75
+ end
76
+
77
+ # @param action [Object] an object, that represents your action. In most cases you want to use a symbol there
78
+ # @param filter [Symbol] callback filter. Supported values are :before, :after and :around
79
+ # @param callback [#call]
80
+ # @return [void]
81
+ def define_callback(action, filter, callback)
82
+ @callbacks[action] ||= {}
83
+ @callbacks[action][filter] ||= []
84
+ @callbacks[action][filter].push(callback)
85
+ end
86
+
87
+ # @param action [Object] an action to run
88
+ # @return [Object] the result of passed block
89
+ def run_callbacks(action, *args, **kwargs, &blk)
90
+ return (yield if block_given?) unless @callbacks[action]
91
+
92
+ run_before_callbacks(action, *args, **kwargs)
93
+ result = run_around_callbacks(action, *args, **kwargs, &blk)
94
+ run_after_callbacks(action, *args, **kwargs)
95
+ result
96
+ end
97
+
98
+ private
99
+
100
+ def run_before_callbacks(action, *args, **kwargs)
101
+ @callbacks[action][:before]&.each do |callback|
102
+ callback.call(*args, **kwargs)
103
+ end
104
+ end
105
+
106
+ def run_around_callbacks(action, *args, **kwargs, &blk)
107
+ result = nil
108
+ stack = [proc { result = yield if block_given? }]
109
+ @callbacks[action][:around]&.reverse_each&.with_index do |callback, index|
110
+ stack.push(proc { callback.call(stack[index], *args, **kwargs); result })
111
+ end
112
+ stack.last.call(*args, **kwargs)
113
+ result
114
+ end
115
+
116
+ def run_after_callbacks(action, *args, **kwargs)
117
+ @callbacks[action][:after]&.each do |callback|
118
+ callback.call(*args, **kwargs)
119
+ end
120
+ end
121
+ end
122
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative 'commands'
4
4
  require_relative 'event_serializer'
5
- require_relative 'pg_result_deserializer'
5
+ require_relative 'event_deserializer'
6
6
  require_relative 'queries'
7
7
 
8
8
  module PgEventstore
@@ -140,7 +140,7 @@ module PgEventstore
140
140
  EventQueries.new(
141
141
  connection,
142
142
  EventSerializer.new(middlewares),
143
- PgResultDeserializer.new(middlewares, config.event_class_resolver)
143
+ EventDeserializer.new(middlewares, config.event_class_resolver)
144
144
  )
145
145
  end
146
146
  end
@@ -6,14 +6,46 @@ module PgEventstore
6
6
 
7
7
  attr_reader :name
8
8
 
9
- # PostgreSQL connection URI docs https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS
9
+ # @!attribute pg_uri
10
+ # @return [String] PostgreSQL connection URI docs
11
+ # https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS
10
12
  option(:pg_uri) { 'postgresql://postgres:postgres@localhost:5432/eventstore' }
13
+ # @!attribute max_count
14
+ # @return [Integer] Number of events to return in one response when reading from a stream
11
15
  option(:max_count) { 1000 }
16
+ # @!attribute middlewares
17
+ # @return [Hash{Symbol => <#serialize, #deserialize>}] A set of identified(by key) objects that respond to
18
+ # #serialize and #deserialize
12
19
  option(:middlewares) { {} }
13
- # Object that responds to #call. Should accept a string and return a class
20
+ # @!attribute event_class_resolver
21
+ # @return [#call] A callable object that must accept a string and return a class. It is used when resolving
22
+ # event's class during event's deserialization process
14
23
  option(:event_class_resolver) { EventClassResolver.new }
24
+ # @!attribute connection_pool_size
25
+ # @return [Integer] Max number of connections per ruby process
15
26
  option(:connection_pool_size) { 5 }
16
- option(:connection_pool_timeout) { 5 } # seconds
27
+ # @!attribute connection_pool_timeout
28
+ # @return [Integer] Time in seconds to wait for the connection in pool to be released
29
+ option(:connection_pool_timeout) { 5 }
30
+ # @!attribute subscription_pull_interval
31
+ # @return [Integer] How often Subscription should pull new events
32
+ option(:subscription_pull_interval) { 2 }
33
+ # @!attribute subscription_max_retries
34
+ # @return [Integer] max number of retries of failed Subscription
35
+ option(:subscription_max_retries) { 100 }
36
+ # @!attribute subscription_retries_interval
37
+ # @return [Integer] interval in seconds between retries of failed Subscription
38
+ option(:subscription_retries_interval) { 1 }
39
+ # @!attribute subscription_restart_terminator
40
+ # @return [#call, nil] provide callable object that accepts Subscription object to decide whether to prevent
41
+ # further Subscription restarts
42
+ option(:subscription_restart_terminator)
43
+ # @!attribute subscriptions_set_max_retries
44
+ # @return [Integer] max number of retries of failed SubscriptionsSet
45
+ option(:subscriptions_set_max_retries) { 10 }
46
+ # @!attribute subscriptions_set_retries_interval
47
+ # @return [Integer] interval in seconds between retries of failed SubscriptionsSet
48
+ option(:subscriptions_set_retries_interval) { 1 }
17
49
 
18
50
  # @param name [Symbol] config's name. Its value matches the appropriate key in PgEventstore.config hash
19
51
  def initialize(name:, **options)
@@ -104,4 +104,67 @@ module PgEventstore
104
104
  TEXT
105
105
  end
106
106
  end
107
+
108
+ class RecordNotFound < Error
109
+ attr_reader :table_name, :id
110
+
111
+ # @param table_name [String]
112
+ # @param id [Integer, String]
113
+ def initialize(table_name, id)
114
+ @table_name = table_name
115
+ @id = id
116
+ super(user_friendly_message)
117
+ end
118
+
119
+ # @return [String]
120
+ def user_friendly_message
121
+ "Could not find/update #{table_name.inspect} record with #{id.inspect} id."
122
+ end
123
+ end
124
+
125
+ class SubscriptionAlreadyLockedError < Error
126
+ attr_reader :set, :name, :lock_id
127
+
128
+ # @param set [String] subscriptions set name
129
+ # @param name [String] subscription's name
130
+ # @param lock_id [String] UUIDv4
131
+ def initialize(set, name, lock_id)
132
+ @set = set
133
+ @name = name
134
+ @lock_id = lock_id
135
+ super(user_friendly_message)
136
+ end
137
+
138
+ # @return [String]
139
+ def user_friendly_message
140
+ <<~TEXT
141
+ Could not lock Subscription from #{set.inspect} set with #{name.inspect} name. It is already locked by \
142
+ #{lock_id.inspect} set.
143
+ TEXT
144
+ end
145
+ end
146
+
147
+ class SubscriptionUnlockError < Error
148
+ attr_reader :set, :name, :expected_locked_by, :actual_locked_by
149
+
150
+ # @param set [String] subscription's set name
151
+ # @param name [String] subscription's name
152
+ # @param expected_locked_by [String] UUIDv4
153
+ # @param actual_locked_by [String, nil] UUIDv4
154
+ def initialize(set, name, expected_locked_by, actual_locked_by)
155
+ @set = set
156
+ @name = name
157
+ @expected_locked_by = expected_locked_by
158
+ @actual_locked_by = actual_locked_by
159
+ super(user_friendly_message)
160
+ end
161
+
162
+ # @return [String]
163
+ def user_friendly_message
164
+ <<~TEXT
165
+ Failed to unlock Subscription from #{set.inspect} set with #{name.inspect} name by \
166
+ #{expected_locked_by.inspect} lock id. It is currently locked by #{actual_locked_by.inspect} lock id.
167
+ TEXT
168
+ end
169
+ end
107
170
  end