pg_eventstore 3.0.1 → 3.1.2

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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -8
  3. data/README.md +1 -0
  4. data/docs/admin_ui.md +5 -0
  5. data/docs/metrics.md +233 -0
  6. data/lib/pg_eventstore/chunks/subscription_events_index_chunk.rb +4 -9
  7. data/lib/pg_eventstore/queries/replica_queries.rb +8 -5
  8. data/lib/pg_eventstore/sql_builder.rb +1 -1
  9. data/lib/pg_eventstore/subscriptions/callback_handlers/subscription_runner_handlers.rb +1 -1
  10. data/lib/pg_eventstore/subscriptions/runner_recovery_strategies/report_subscription_unrecoverable_error.rb +31 -0
  11. data/lib/pg_eventstore/subscriptions/runner_recovery_strategies.rb +1 -0
  12. data/lib/pg_eventstore/subscriptions/subscription_feed_strategies/collection.rb +9 -7
  13. data/lib/pg_eventstore/subscriptions/subscription_feed_strategies/index_read_strategy.rb +16 -16
  14. data/lib/pg_eventstore/subscriptions/subscription_feed_strategies/replication_strategy.rb +15 -15
  15. data/lib/pg_eventstore/subscriptions/subscription_feed_strategy.rb +4 -3
  16. data/lib/pg_eventstore/subscriptions/subscription_runner.rb +0 -5
  17. data/lib/pg_eventstore/subscriptions/subscription_runners_feeder.rb +15 -4
  18. data/lib/pg_eventstore/subscriptions/subscriptions_manager.rb +5 -0
  19. data/lib/pg_eventstore/utils.rb +2 -0
  20. data/lib/pg_eventstore/version.rb +1 -1
  21. data/lib/pg_eventstore/web/application.rb +7 -0
  22. data/lib/pg_eventstore/web/metrics/application.rb +64 -0
  23. data/lib/pg_eventstore/web/metrics/collectors/base.rb +69 -0
  24. data/lib/pg_eventstore/web/metrics/collectors/subscriptions_health.rb +78 -0
  25. data/lib/pg_eventstore/web/metrics/collectors/subscriptions_latency.rb +161 -0
  26. data/lib/pg_eventstore/web/metrics/collectors/subscriptions_throughput.rb +59 -0
  27. data/lib/pg_eventstore/web/metrics/formatter.rb +45 -0
  28. data/lib/pg_eventstore/web/metrics/helpers.rb +30 -0
  29. data/lib/pg_eventstore/web/metrics/metric_family.rb +40 -0
  30. data/lib/pg_eventstore/web/paginator/helpers.rb +27 -23
  31. data/lib/pg_eventstore/web/views/home/partials/events.erb +8 -6
  32. data/lib/pg_eventstore/web/views/home/partials/stream_filter.erb +1 -1
  33. data/lib/pg_eventstore/web/views/streams/partials/streams.erb +1 -1
  34. data/lib/pg_eventstore/web.rb +8 -0
  35. data/rbs_collection.lock.yaml +1 -1
  36. data/sig/pg_eventstore/subscriptions/callback_handlers/subscription_runner_handlers.rbs +1 -1
  37. data/sig/pg_eventstore/subscriptions/runner_recovery_strategies/report_subscription_unrecoverable_error.rbs +15 -0
  38. data/sig/pg_eventstore/subscriptions/subscription_feed_strategy/collection.rbs +1 -1
  39. data/sig/pg_eventstore/subscriptions/subscription_feed_strategy/index_read_strategy.rbs +1 -1
  40. data/sig/pg_eventstore/subscriptions/subscription_feed_strategy/replication_strategy.rbs +1 -1
  41. data/sig/pg_eventstore/subscriptions/subscription_feed_strategy.rbs +1 -1
  42. data/sig/pg_eventstore/subscriptions/subscription_runner.rbs +0 -2
  43. data/sig/pg_eventstore/subscriptions/subscription_runners_feeder.rbs +2 -0
  44. data/sig/pg_eventstore/web/application.rbs +2 -0
  45. data/sig/pg_eventstore/web/metrics/application.rbs +11 -0
  46. data/sig/pg_eventstore/web/metrics/collectors/base.rbs +33 -0
  47. data/sig/pg_eventstore/web/metrics/collectors/subscriptions_health.rbs +15 -0
  48. data/sig/pg_eventstore/web/metrics/collectors/subscriptions_latency.rbs +31 -0
  49. data/sig/pg_eventstore/web/metrics/collectors/subscriptions_throughput.rbs +15 -0
  50. data/sig/pg_eventstore/web/metrics/formatter.rbs +19 -0
  51. data/sig/pg_eventstore/web/metrics/helpers.rbs +11 -0
  52. data/sig/pg_eventstore/web/metrics/metric_family.rbs +19 -0
  53. data/sig/pg_eventstore/web/paginator/helpers.rbs +5 -3
  54. metadata +20 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 24899ca6d10bac21341f41f3a8cfee09e10ce10bf81fb4525e9660f6c708d3af
4
- data.tar.gz: 07cbbca86a3cb488d9123d663e77cb66cdbf850f93e12f1f15e46dc8f465879c
3
+ metadata.gz: b54fbf71f1dd082e5895ab8b1431f2a92442c0d0865e656b4fe65430503151b5
4
+ data.tar.gz: 45a0bfd8a606466495643cb41f503643bd6151f915d1e36cdf374eded7dab8b3
5
5
  SHA512:
6
- metadata.gz: bc33cc4bcdda545ffa4476e80b55ca2541de4da036d715644f3957d1889170bbdf3b05b1391c7f981f28163da0ce36d06b0a8282ddeddb7560eb1892df43cfaa
7
- data.tar.gz: 64b63413791d2c993315d4012acb1a5bec19b237eae6761c6b9da8725483e4284f5fbf3b956df5c4a750719460087f30cec6c9db1f176ce728ad985f2aefa213
6
+ metadata.gz: 59028bfa802198da04912ba0f339a165eb3d9c35e0cb940b9fb0437ae42909f3570b2f4bbda88d5234fb622420d4ff7425dd1fafa4a8951b4092879ca33fcf77
7
+ data.tar.gz: 671e391234a4f8b6a008b19bdbc533c94a7e3cc562fab9cdfd4dbef77968fd2ed03814f7ff132a8326aee859cc2e65db04ef63a8667e2e67b18615b102e08102
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [3.1.2]
4
+
5
+ - Fix subscriptions skipping events when the estimated number of events to fetch drops to 0 during the pull
6
+
7
+ ## [3.1.1]
8
+
9
+ - Prevent arbitrary order in replica queries
10
+
11
+ ## [3.1.0]
12
+
13
+ - Hide "Delete stream" and "Delete" event buttons in admin UI for non-maintenance eventstore roles
14
+ - New feature: Prometheus metrics endpoints for subscriptions observability (lag, health, throughput), served by the
15
+ standalone `PgEventstore::Web::Metrics::Application` rack application. Read more [here](docs/metrics.md)
16
+ - Properly report internal errors via `failed_subscription_notifier` config option
17
+ - Fix inaccuracy that could lead to mistakenly handle connection error as subscription handler error
18
+ - Fix admin UI links to take into account mount path instead building a path from `'/'`
19
+
3
20
  ## [3.0.1]
4
21
 
5
22
  - Fix EventTracing bug: Respect user-supplied Event#correlation_id value when caused_by#correlation_id is absent
@@ -14,8 +31,10 @@
14
31
  middleware by default. Previously this wasn't the case - the deserialization phase was skipped. Such behavior was
15
32
  creating ambiguity about assumptions when deserialization happens. If you need old behavior - you have to create
16
33
  another middleware class which skips deserialization when publishing events and use it instead. Example:
17
- Let's say you have this middleware configured
34
+ Let's say you have this middleware configured
35
+
18
36
  ```ruby
37
+
19
38
  class MyMiddleware
20
39
  include PgEventstore::Middleware
21
40
 
@@ -36,9 +55,9 @@ end
36
55
  Now define another middleware that has empty `#deserialize` method
37
56
 
38
57
  ```ruby
58
+
39
59
  class MyMiddlewareWithoutDeserialize < MyMiddleware
40
- def deserialize(event)
41
- end
60
+ def deserialize(event) end
42
61
  end
43
62
 
44
63
  PgEventstore.configure do |config|
@@ -48,6 +67,7 @@ PgEventstore.configure do |config|
48
67
  }
49
68
  end
50
69
  ```
70
+
51
71
  and use only it when publishing events:
52
72
 
53
73
  ```ruby
@@ -76,12 +96,13 @@ PgEventstore.client(:write).append_to_stream(stream, event)
76
96
  - **Breaking change**: `Event#id` uniqueness is no longer guaranteed. It was dropped because there is not much usage of
77
97
  it internally. The default value was moved from the database(it was `gen_random_uuid()`) to the application level and
78
98
  is `SecureRandom.uuid_v7` now.
79
- - **Breaking change**: event types, stream attributes, event metadata keys, markers that start from `▒`(`"\u2592"`
80
- Unicode character) character are now reserved by pg_eventstore. It is less likely you have any, but if you do - you have
81
- to adjust your implementation to no longer rely on it.
99
+ - **Breaking change**: event types, stream attributes, event metadata keys, markers that start from `▒`(`"\u2592"`
100
+ Unicode character) character are now reserved by pg_eventstore. It is less likely you have any, but if you do - you
101
+ have
102
+ to adjust your implementation to no longer rely on it.
82
103
  - New feature: event markers. You can now mark an event and later use those markers to read events, build a projection
83
- using subscriptions or validate stream revision scoped to an event type and specific markers when publishing events as
84
- a part of Dynamic Consistency Boundaries. Find more in [docs](docs/appending_events.md#event-markers)
104
+ using subscriptions or validate stream revision scoped to an event type and specific markers when publishing events as
105
+ a part of Dynamic Consistency Boundaries. Find more in [docs](docs/appending_events.md#event-markers)
85
106
  - New config option `config.events_subscription_position_update_interval`. See more
86
107
  in [Configuration](docs/configuration.md) docs
87
108
  - **Breaking change**: `:from_position` option of `SubscriptionManager#subscribe`, "Current position" column in admin
data/README.md CHANGED
@@ -66,6 +66,7 @@ Documentation chapters:
66
66
  - [Writing middlewares](docs/writing_middleware.md)
67
67
  - [How to make multiple commands atomic](docs/multiple_commands.md)
68
68
  - [Admin UI](docs/admin_ui.md)
69
+ - [Prometheus metrics](docs/metrics.md)
69
70
  - [Replication](docs/replication.md)
70
71
 
71
72
  To help your AI assistant to better recognize the capabilities of this gem - there is a crafted [instructions](docs/AGENTS.fragment.md)
data/docs/admin_ui.md CHANGED
@@ -41,3 +41,8 @@ run PgEventstore::Web::Application
41
41
  ```
42
42
 
43
43
  Now you can use any web server to run it.
44
+
45
+ ## Prometheus metrics
46
+
47
+ Subscriptions observability data in the Prometheus text format is served by a separate rack application, mounted
48
+ independently of the Admin UI. See [Metrics](metrics.md).
data/docs/metrics.md ADDED
@@ -0,0 +1,233 @@
1
+ # Prometheus metrics
2
+
3
+ `pg_eventstore` ships rack endpoints which expose subscriptions observability data in the
4
+ [Prometheus text exposition format](https://prometheus.io/docs/instrumenting/exposition_formats/). They answer the
5
+ questions the [Admin UI](admin_ui.md) subscriptions page answers, but in a form Prometheus can scrape and Grafana can
6
+ graph and alert on.
7
+
8
+ ## Endpoints
9
+
10
+ Metrics are grouped into domains, and split per path within a domain so that each scrape runs only the query it
11
+ needs and cheap families can be polled at a different interval than the expensive one. Paths below are relative to
12
+ wherever the app is mounted - mounted at `/pg_eventstore/metrics` the first one is
13
+ `/pg_eventstore/metrics/subscriptions/latency`.
14
+
15
+ | Path | Metrics | Query cost |
16
+ |---|---|---|
17
+ | `/subscriptions/latency` | lag of every reported subscription + store positions | the only one looking at event positions (one index range scan per subscription) |
18
+ | `/subscriptions/health` | state, lock, heartbeat age, restarts, last error age | single read of the `subscriptions` table |
19
+ | `/subscriptions/throughput` | processed events counter, handler capacity | single read of the `subscriptions` table |
20
+ | `/subscriptions` | all of the above | all of the above |
21
+
22
+ The domain path (`/subscriptions`) is meant for humans and ad-hoc checks; point your scrape jobs at the split paths.
23
+
24
+ **There is deliberately no route serving every domain at once.** `/metrics` is the Prometheus convention, so a route
25
+ there would invite pointing scrape jobs at it by reflex - and every such scrape would pay for every query, including
26
+ the expensive ones, which is exactly what the per-path split exists to avoid. Keeping aggregates per domain also
27
+ keeps each response bounded as more domains are added.
28
+
29
+ Every query is guarded by a `statement_timeout` of 5 seconds - a stuck scrape fails visibly instead of piling up on
30
+ the database.
31
+
32
+ ### Which subscriptions are reported
33
+
34
+ Every subscription in the queried database, unless you narrow it down with `set` params (see
35
+ [Reporting only some subscription sets](#reporting-only-some-subscription-sets)). Note that the subscriptions registry
36
+ never removes rows, so handlers that were renamed or removed keep theirs and are reported too.
37
+
38
+ A subscription that died *without releasing its lock* is reported like any other - detecting it is what
39
+ `pg_eventstore_subscription_heartbeat_age_seconds` is for, and it is the most useful thing to alert on: neither the
40
+ state column nor the lock can be trusted to notice a process that went away.
41
+
42
+ ## Mounting
43
+
44
+ `PgEventstore::Web::Metrics::Application` is a separate rack application designed to be a scrape target. In your
45
+ `config/routes.rb`:
46
+
47
+ ```ruby
48
+ require 'pg_eventstore/web'
49
+
50
+ mount PgEventstore::Web::Metrics::Application, at: '/pg_eventstore/metrics'
51
+ ```
52
+
53
+ Or as a standalone `config.ru`:
54
+
55
+ ```ruby
56
+ require 'pg_eventstore/web'
57
+
58
+ run PgEventstore::Web::Metrics::Application
59
+ ```
60
+
61
+ ### Authorization
62
+
63
+ The application ships without authentication - how you protect the endpoint is up to you, exactly as it is for the
64
+ [Admin UI](admin_ui.md#authorization). Wrap it in whatever middleware your setup already uses, for example:
65
+
66
+ ```ruby
67
+ metrics_app = Rack::Builder.new do
68
+ use Rack::Auth::Basic do |_username, password|
69
+ Rack::Utils.secure_compare(ENV.fetch('PG_EVENTSTORE_METRICS_PASSWORD'), password)
70
+ end
71
+ run PgEventstore::Web::Metrics::Application
72
+ end
73
+
74
+ mount metrics_app, at: '/pg_eventstore/metrics'
75
+ ```
76
+
77
+ ### Choosing the database
78
+
79
+ Which store is queried is decided per request by the `config` query param, so one mounted application can serve the
80
+ metrics of every configured database:
81
+
82
+ ```ruby
83
+ PgEventstore.configure(name: :db1) do |config|
84
+ config.pg_uri = ENV['DB1_URI']
85
+ config.connection_pool_size = 1
86
+ end
87
+
88
+ PgEventstore.configure(name: :db2) do |config|
89
+ config.pg_uri = ENV['DB2_URI']
90
+ config.connection_pool_size = 1
91
+ end
92
+ ```
93
+
94
+ ```
95
+ GET /pg_eventstore/metrics/subscriptions/latency?config=db1
96
+ ```
97
+
98
+ A missing `config` means the default configuration. An unknown `config` is answered with `404` instead of being
99
+ served from the default store, so a typo in the scrape config shows up as a failing target rather than as another
100
+ database's numbers under the wrong labels.
101
+
102
+ ### Reporting only some subscription sets
103
+
104
+ Rows of the subscriptions registry are never removed, so a database that has been running for a while also holds
105
+ handlers that were renamed, removed, or never ran against it. Pass one or more `set` params to report only the
106
+ subscriptions you care about:
107
+
108
+ ```
109
+ GET /pg_eventstore/metrics/subscriptions/health?set=MyAppSet&set=MyOtherSet
110
+ GET /pg_eventstore/metrics/subscriptions/health?set=MyAppSet,MyOtherSet
111
+ ```
112
+
113
+ Both forms work - a repeated param (what Prometheus produces from `params: {set: [...]}`) and a comma separated list.
114
+
115
+ Without a `set` param every subscription in the database is reported. `pg_eventstore` puts no constraints on how
116
+ subscription sets are named, but it is convenient to name a set after the application that owns it - scoping the
117
+ scrape by set is then the straightforward way to keep one application's dashboards to its own subscriptions.
118
+
119
+ ## Prometheus scrape config
120
+
121
+ ```yaml
122
+ scrape_configs:
123
+ - job_name: 'pg-eventstore-subscriptions-latency'
124
+ metrics_path: /pg_eventstore/metrics/subscriptions/latency
125
+ scrape_interval: 30s
126
+ params:
127
+ config: ['db1']
128
+ set: ['MyAppSet']
129
+ static_configs:
130
+ - targets: ['your-app-host:port']
131
+ - job_name: 'pg-eventstore-subscriptions-health'
132
+ metrics_path: /pg_eventstore/metrics/subscriptions/health
133
+ scrape_interval: 30s
134
+ params:
135
+ config: ['db1']
136
+ set: ['MyAppSet']
137
+ static_configs:
138
+ - targets: ['your-app-host:port']
139
+ - job_name: 'pg-eventstore-subscriptions-throughput'
140
+ metrics_path: /pg_eventstore/metrics/subscriptions/throughput
141
+ scrape_interval: 60s
142
+ params:
143
+ config: ['db1']
144
+ set: ['MyAppSet']
145
+ static_configs:
146
+ - targets: ['your-app-host:port']
147
+ ```
148
+
149
+ Add whatever credentials your chosen protection needs to these jobs - Prometheus supports `basic_auth`,
150
+ `authorization` and `tls_config` per job.
151
+
152
+ The split into three jobs is intentional - do not collapse them into a single `/subscriptions` scrape unless you
153
+ are fine with every scrape paying the latency query.
154
+
155
+ ## Metrics reference
156
+
157
+ All per-subscription metrics carry `set` and `name` labels.
158
+
159
+ ### Latency
160
+
161
+ `pg_eventstore_subscription_lag_events` (gauge)
162
+
163
+ How many events the subscription still has to catch up on before it reaches the edge of the `"all"` stream and starts
164
+ processing newly appended events.
165
+
166
+ Note on filters: a subscription's filter is accounted for by the store, so a caught-up subscription reports `0` no
167
+ matter how narrow its filter is - traffic it does not care about never shows up as its lag. For a *lagging*
168
+ subscription the value counts everything in the range it has not reached yet, matching its filter or not. Read it as
169
+ staleness ("how far behind is this subscription"), not as the number of events its handler is about to run: that
170
+ number is usually much smaller, because non-matching ranges are skipped without invoking the handler.
171
+
172
+ `pg_eventstore_subscription_lag_seconds` (gauge)
173
+
174
+ Age of the oldest event the subscription has not processed yet. `0` when fully caught up.
175
+
176
+ The metric is absent for a subscription whose oldest unprocessed event no longer exists, which happens when that
177
+ event or its stream was deleted. Reporting `0` there would read as "caught up", so nothing is reported instead and
178
+ `lag_events` remains the source of truth for the backlog.
179
+
180
+ `pg_eventstore_store_frontier_position` (gauge)
181
+
182
+ Latest position assigned to an event, which is what subscription checkpoints are measured against. It only advances
183
+ while at least one subscriptions process is running; when they are all down, lag stops growing - that situation shows
184
+ up in the heartbeat metric below, not in the lag metrics.
185
+
186
+ `pg_eventstore_store_head_global_position` (gauge)
187
+
188
+ Global position of the newest event in the store. Contains gaps; do not compare subscription checkpoints against it.
189
+
190
+ ### Health
191
+
192
+ `pg_eventstore_subscription_state` (gauge, extra `state` label, value is always 1)
193
+
194
+ Last *recorded* state. A subscription killed without a graceful shutdown keeps `state="running"` and its lock
195
+ forever - correlate with the heartbeat age below.
196
+
197
+ `pg_eventstore_subscription_locked` (gauge, 0/1)
198
+
199
+ Whether the subscription is currently locked by a subscriptions set.
200
+
201
+ `pg_eventstore_subscription_heartbeat_age_seconds` (gauge)
202
+
203
+ Seconds since the subscription row was last touched by its runner. Alive subscriptions update it about every 10
204
+ seconds. **A locked subscription with a stale heartbeat is a dead process** - this is the signal to alert on for
205
+ process death, since neither `state` nor the lock can be trusted for that.
206
+
207
+ `pg_eventstore_subscription_restarts_total` (counter)
208
+
209
+ Times the subscription was restarted after a failure.
210
+
211
+ `pg_eventstore_subscription_last_error_age_seconds` (gauge)
212
+
213
+ Seconds since the last error. Absent when the subscription never failed.
214
+
215
+ ### Throughput
216
+
217
+ `pg_eventstore_subscription_processed_events_total` (counter)
218
+
219
+ Total number of events processed. Use `rate()` over it for the actual current throughput - it correctly drops to 0
220
+ when no matching events arrive.
221
+
222
+ `pg_eventstore_subscription_capacity_events_per_second` (gauge)
223
+
224
+ Derived from the average handler execution time of the last few processed events, *whenever they happened*. It
225
+ answers "how fast can this handler go when it is fed" and is sticky while the subscription is idle - do not read it
226
+ as current throughput. The ratio `rate(processed_events_total) / capacity` is a useful saturation signal: a
227
+ subscription running close to its capacity has no headroom left, and a traffic spike will turn directly into lag.
228
+
229
+ ## Alerting suggestions
230
+
231
+ - Lagging read models: `pg_eventstore_subscription_lag_seconds > 300 for 10m`
232
+ - Dead subscription process: `pg_eventstore_subscription_locked == 1 and pg_eventstore_subscription_heartbeat_age_seconds > 60 for 10m`
233
+ - No headroom: `rate(pg_eventstore_subscription_processed_events_total[5m]) / pg_eventstore_subscription_capacity_events_per_second > 0.8 for 15m`
@@ -64,9 +64,8 @@ module PgEventstore
64
64
 
65
65
  # @return [void]
66
66
  def resolve_indexes
67
- indexes_to_resolve = @indexes.slice!(
68
- Utils.range_to_slice(@indexes.map(&:event_type_partition_id), MAX_PARTITIONS_TO_RESOLVE_PER_CALL)
69
- )
67
+ range = Utils.range_to_slice(@indexes.map(&:event_type_partition_id), MAX_PARTITIONS_TO_RESOLVE_PER_CALL)
68
+ indexes_to_resolve = @indexes[range]
70
69
  global_to_sub_position_map = indexes_to_resolve.to_h { [_1.global_position, _1.subscription_position] }
71
70
  raw_events = events_global_index_queries.resolve_indexes(
72
71
  indexes_to_resolve,
@@ -80,14 +79,10 @@ module PgEventstore
80
79
  )
81
80
  end
82
81
  raw_events = raw_events.sort_by(&:subscription_position)
82
+ # Remove indexes only after their events are resolved, so #size keeps counting them while the query runs
83
+ @indexes.slice!(range)
83
84
  @raw_events.push(*raw_events)
84
85
  @resolved = @indexes.empty?
85
- rescue => exception
86
- @indexes.unshift(*indexes_to_resolve)
87
- @resolved = false
88
- raise Utils.wrap_exception(
89
- exception, global_positions: indexes_to_resolve.map(&:global_position)
90
- )
91
86
  end
92
87
 
93
88
  # @return [PgEventstore::EventsGlobalIndexQueries]
@@ -27,7 +27,10 @@ module PgEventstore
27
27
  return [] unless exists
28
28
 
29
29
  @query_strategy.exec_params(<<~SQL, [indexes.map(&:subscription_position)]).map { _1['subscription_position'] }
30
- select subscription_position from event_subscription_positions where subscription_position = any($1::bigint[])
30
+ select subscription_position
31
+ from event_subscription_positions
32
+ where subscription_position = any($1::bigint[])
33
+ order by subscription_position
31
34
  SQL
32
35
  end
33
36
 
@@ -50,7 +53,7 @@ module PgEventstore
50
53
  # @return [Array<PgEventstore::EventGlobalIndex>]
51
54
  def load_events_global_index(indexes)
52
55
  result = @query_strategy.exec_params(<<~SQL, [indexes.map(&:global_position)])
53
- select * from events_global_index where global_position = any($1::bigint[])
56
+ select * from events_global_index where global_position = any($1::bigint[]) order by global_position
54
57
  SQL
55
58
  result.map { EventGlobalIndex.new(**_1.transform_keys(&:to_sym)) }
56
59
  end
@@ -59,7 +62,7 @@ module PgEventstore
59
62
  # @return [Array<PgEventstore::StreamGlobalIndex>]
60
63
  def load_streams_global_index(indexes)
61
64
  result = @query_strategy.exec_params(<<~SQL, [indexes.map(&:streams_global_index_id)])
62
- select * from streams_global_index where id = any($1::bigint[])
65
+ select * from streams_global_index where id = any($1::bigint[]) order by id
63
66
  SQL
64
67
  stream_revisions_map = indexes.group_by(&:streams_global_index_id).to_h do |stream_id, events_idx|
65
68
  [stream_id, events_idx.max_by(&:stream_revision).stream_revision]
@@ -75,7 +78,7 @@ module PgEventstore
75
78
  # @return [Array<PgEventstore::EventMarkerIndex>]
76
79
  def load_event_markers_index(indexes)
77
80
  result = @query_strategy.exec_params(<<~SQL, [indexes.map(&:global_position)])
78
- select * from event_markers_index where global_position = any($1::bigint[])
81
+ select * from event_markers_index where global_position = any($1::bigint[]) order by global_position
79
82
  SQL
80
83
  result.map { EventMarkerIndex.new(**_1.transform_keys(&:to_sym)) }
81
84
  end
@@ -84,7 +87,7 @@ module PgEventstore
84
87
  # @return [Array<PgEventstore::EventMarker>]
85
88
  def load_markers(indexes)
86
89
  result = @query_strategy.exec_params(<<~SQL, [indexes.map(&:marker_id).uniq])
87
- select * from event_markers where id = any($1::bigint[])
90
+ select * from event_markers where id = any($1::bigint[]) order by id
88
91
  SQL
89
92
  result.map { EventMarker.new(**_1.transform_keys(&:to_sym)) }
90
93
  end
@@ -215,7 +215,7 @@ module PgEventstore
215
215
 
216
216
  # @return [String]
217
217
  def from_sql
218
- return @from_value if @from_value.is_a?(String)
218
+ return "#{@from_value} #{@table_alias if @table_alias != @from_value}" if @from_value.is_a?(String)
219
219
 
220
220
  sql = merge(@from_value)
221
221
  "(#{sql}) #{@table_alias}"
@@ -29,7 +29,7 @@ module PgEventstore
29
29
  end
30
30
 
31
31
  # @param subscription [PgEventstore::Subscription]
32
- # @param error [PgEventstore::WrappedException]
32
+ # @param error [StandardError]
33
33
  # @return [void]
34
34
  def update_subscription_error(subscription, error)
35
35
  subscription.update(last_error: Utils.error_info(error), last_error_occurred_at: Time.now.utc)
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgEventstore
4
+ module RunnerRecoveryStrategies
5
+ # Reports a subscription death that no other strategy handles, then leaves it dead.
6
+ # @!visibility private
7
+ class ReportSubscriptionUnrecoverableError
8
+ include RunnerRecoveryStrategy
9
+
10
+ # @param subscription [PgEventstore::Subscription]
11
+ # @param failed_subscription_notifier [#call, nil]
12
+ def initialize(subscription:, failed_subscription_notifier:)
13
+ @subscription = subscription
14
+ @failed_subscription_notifier = failed_subscription_notifier
15
+ end
16
+
17
+ # @param _error [StandardError]
18
+ # @return [true]
19
+ def recovers?(_error)
20
+ true
21
+ end
22
+
23
+ # @param error [StandardError]
24
+ # @return [false] the subscription stays dead - this strategy reports, it does not recover
25
+ def recover(error)
26
+ @failed_subscription_notifier&.call(@subscription.dup, error)
27
+ false
28
+ end
29
+ end
30
+ end
31
+ end
@@ -3,3 +3,4 @@
3
3
  require_relative 'runner_recovery_strategies/restore_connection'
4
4
  require_relative 'runner_recovery_strategies/restore_subscription_runner'
5
5
  require_relative 'runner_recovery_strategies/restore_subscription_feeder'
6
+ require_relative 'runner_recovery_strategies/report_subscription_unrecoverable_error'
@@ -9,23 +9,25 @@ module PgEventstore
9
9
  DEFAULT_SUBSCRIPTIONS_NUM_PER_QUERY = 10
10
10
 
11
11
  class << self
12
- # @param runners [Array<PgEventstore::SubscriptionRunner>]
12
+ # @param runners_query_options [Hash{PgEventstore::SubscriptionRunner => Hash}]
13
13
  # @param connection [PgEventstore::Connection]
14
14
  # @param query_strategy [PgEventstore::QueryStrategy]
15
15
  # @param subscriptions_per_query [Integer]
16
16
  # @return [PgEventstore::SubscriptionFeedStrategy::Collection]
17
- def create(runners, connection, query_strategy, subscriptions_per_query: DEFAULT_SUBSCRIPTIONS_NUM_PER_QUERY)
17
+ def create(runners_query_options, connection, query_strategy,
18
+ subscriptions_per_query: DEFAULT_SUBSCRIPTIONS_NUM_PER_QUERY)
18
19
  instance = new
19
- replica_runners = runners.grep(ReplicaSubscriptionRunner)
20
- common_runners = runners - replica_runners
21
- common_runners.each_slice(subscriptions_per_query).each do |runners_slice|
20
+ replica_runners, common_runners = runners_query_options.partition do |runner, _|
21
+ runner.is_a?(ReplicaSubscriptionRunner)
22
+ end
23
+ common_runners.each_slice(subscriptions_per_query) do |runners_slice|
22
24
  strategy = IndexReadStrategy.new(connection, query_strategy)
23
- strategy.add(*runners_slice)
25
+ runners_slice.each { |runner, query_options| strategy.add(runner, query_options) }
24
26
  instance.push(strategy)
25
27
  end
26
28
  replica_runners.each_slice(subscriptions_per_query) do |runners_slice|
27
29
  strategy = ReplicationStrategy.new(connection, query_strategy)
28
- strategy.add(*runners_slice)
30
+ runners_slice.each { |runner, query_options| strategy.add(runner, query_options) }
29
31
  instance.push(strategy)
30
32
  end
31
33
  instance
@@ -18,45 +18,45 @@ module PgEventstore
18
18
  def initialize(connection, query_strategy)
19
19
  @connection = connection
20
20
  @query_strategy = query_strategy
21
- @runners = []
21
+ @runners_query_options = {}
22
22
  end
23
23
 
24
- # @param runners [Array<PgEventstore::SubscriptionRunner>]
25
- # @return [Array<PgEventstore::SubscriptionRunner>]
26
- def add(*runners)
27
- @runners.push(*runners)
24
+ # @param runner [PgEventstore::SubscriptionRunner]
25
+ # @param query_options [Hash]
26
+ # @return [void]
27
+ def add(runner, query_options)
28
+ @runners_query_options[runner] = query_options
28
29
  end
29
30
 
30
31
  # @return [Integer]
31
32
  def size
32
- @runners.size
33
+ @runners_query_options.size
33
34
  end
34
35
 
35
36
  # @return [Boolean]
36
37
  def any?
37
- @runners.any?
38
+ @runners_query_options.any?
38
39
  end
39
40
 
40
41
  # @return [void]
41
42
  def feed
42
- runners_query_options = @runners.to_h do |runner|
43
- next_chunk_query_opts = runner.next_chunk_query_opts
44
- next_chunk_query_opts[:to_position] =
45
- [next_chunk_query_opts[:from_position] + INDEX_LOOK_UP_DISTANCE, safe_position].min
46
- [runner.id, next_chunk_query_opts]
43
+ @runners_query_options.each_value do |query_options|
44
+ query_options[:to_position] = [query_options[:from_position] + INDEX_LOOK_UP_DISTANCE, safe_position].min
47
45
  end
48
- grouped_indexes = events_global_index_queries.fetch_indexes_for_subscriptions(runners_query_options)
49
- @runners.each do |runner|
46
+ grouped_indexes = events_global_index_queries.fetch_indexes_for_subscriptions(
47
+ @runners_query_options.transform_keys(&:id)
48
+ )
49
+ @runners_query_options.each do |runner, query_options|
50
50
  if grouped_indexes[runner.id]
51
51
  chunk = Chunks::SubscriptionEventsIndexChunk.new(
52
52
  grouped_indexes[runner.id],
53
53
  @connection,
54
54
  QueryStrategy::Foreground.new(@connection),
55
- runners_query_options[runner.id][:resolve_link_tos]
55
+ query_options[:resolve_link_tos]
56
56
  )
57
57
  runner.feed(chunk)
58
58
  else
59
- runner.feed(Chunks::SubscriptionCheckpointChunk.new(runners_query_options[runner.id][:to_position]))
59
+ runner.feed(Chunks::SubscriptionCheckpointChunk.new(query_options[:to_position]))
60
60
  end
61
61
  end
62
62
  end
@@ -16,40 +16,40 @@ module PgEventstore
16
16
  def initialize(connection, query_strategy)
17
17
  @connection = connection
18
18
  @query_strategy = query_strategy
19
- @runners = []
19
+ @runners_query_options = {}
20
20
  end
21
21
 
22
- # @param runners [Array<PgEventstore::ReplicaSubscriptionRunner>]
23
- # @return [Array<PgEventstore::ReplicaSubscriptionRunner>]
24
- def add(*runners)
25
- @runners.push(*runners)
22
+ # @param runner [PgEventstore::ReplicaSubscriptionRunner]
23
+ # @param query_options [Hash]
24
+ # @return [void]
25
+ def add(runner, query_options)
26
+ @runners_query_options[runner] = query_options
26
27
  end
27
28
 
28
29
  # @return [Integer]
29
30
  def size
30
- @runners.size
31
+ @runners_query_options.size
31
32
  end
32
33
 
33
34
  # @return [Boolean]
34
35
  def any?
35
- @runners.any?
36
+ @runners_query_options.any?
36
37
  end
37
38
 
38
39
  # @return [void]
39
40
  def feed
40
- runners_query_options = @runners.to_h do |runner|
41
- next_chunk_query_opts = runner.next_chunk_query_opts
42
- next_chunk_query_opts[:to_position] =
43
- [next_chunk_query_opts[:from_position] + INDEX_LOOK_UP_DISTANCE, safe_position].min
44
- [runner.id, next_chunk_query_opts]
41
+ @runners_query_options.each_value do |query_options|
42
+ query_options[:to_position] = [query_options[:from_position] + INDEX_LOOK_UP_DISTANCE, safe_position].min
45
43
  end
46
- grouped_indexes = events_global_index_queries.fetch_indexes_for_subscriptions(runners_query_options)
47
- @runners.each do |runner|
44
+ grouped_indexes = events_global_index_queries.fetch_indexes_for_subscriptions(
45
+ @runners_query_options.transform_keys(&:id)
46
+ )
47
+ @runners_query_options.each do |runner, query_options|
48
48
  if grouped_indexes[runner.id]
49
49
  chunk = Chunks::ReplicaEventsIndexChunk.new(grouped_indexes[runner.id])
50
50
  runner.feed(chunk)
51
51
  else
52
- runner.feed(Chunks::SubscriptionCheckpointChunk.new(runners_query_options[runner.id][:to_position]))
52
+ runner.feed(Chunks::SubscriptionCheckpointChunk.new(query_options[:to_position]))
53
53
  end
54
54
  end
55
55
  end
@@ -3,9 +3,10 @@
3
3
  module PgEventstore
4
4
  # @!visibility private
5
5
  module SubscriptionFeedStrategy
6
- # @param runners [PgEventstore::SubscriptionRunner, Array<PgEventstore::SubscriptionRunner>]
7
- # @return [Array<PgEventstore::SubscriptionRunner>]
8
- def add(*runners)
6
+ # @param runner [PgEventstore::SubscriptionRunner]
7
+ # @param query_options [Hash]
8
+ # @return [void]
9
+ def add(runner, query_options)
9
10
  raise NotImplementedError
10
11
  end
11
12
 
@@ -50,11 +50,6 @@ module PgEventstore
50
50
  }
51
51
  end
52
52
 
53
- # @return [Boolean]
54
- def time_to_feed?
55
- estimate_events_number > 0 && @subscription.last_chunk_fed_at + @subscription.chunk_query_interval <= Time.now.utc
56
- end
57
-
58
53
  private
59
54
 
60
55
  # @return [Integer]