pg_eventstore 3.0.1 → 3.1.1

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -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 +1 -3
  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/subscriptions_manager.rb +5 -0
  13. data/lib/pg_eventstore/utils.rb +2 -0
  14. data/lib/pg_eventstore/version.rb +1 -1
  15. data/lib/pg_eventstore/web/application.rb +7 -0
  16. data/lib/pg_eventstore/web/metrics/application.rb +64 -0
  17. data/lib/pg_eventstore/web/metrics/collectors/base.rb +69 -0
  18. data/lib/pg_eventstore/web/metrics/collectors/subscriptions_health.rb +78 -0
  19. data/lib/pg_eventstore/web/metrics/collectors/subscriptions_latency.rb +161 -0
  20. data/lib/pg_eventstore/web/metrics/collectors/subscriptions_throughput.rb +59 -0
  21. data/lib/pg_eventstore/web/metrics/formatter.rb +45 -0
  22. data/lib/pg_eventstore/web/metrics/helpers.rb +30 -0
  23. data/lib/pg_eventstore/web/metrics/metric_family.rb +40 -0
  24. data/lib/pg_eventstore/web/paginator/helpers.rb +27 -23
  25. data/lib/pg_eventstore/web/views/home/partials/events.erb +8 -6
  26. data/lib/pg_eventstore/web/views/home/partials/stream_filter.erb +1 -1
  27. data/lib/pg_eventstore/web/views/streams/partials/streams.erb +1 -1
  28. data/lib/pg_eventstore/web.rb +8 -0
  29. data/rbs_collection.lock.yaml +1 -1
  30. data/sig/pg_eventstore/subscriptions/callback_handlers/subscription_runner_handlers.rbs +1 -1
  31. data/sig/pg_eventstore/subscriptions/runner_recovery_strategies/report_subscription_unrecoverable_error.rbs +15 -0
  32. data/sig/pg_eventstore/web/application.rbs +2 -0
  33. data/sig/pg_eventstore/web/metrics/application.rbs +11 -0
  34. data/sig/pg_eventstore/web/metrics/collectors/base.rbs +33 -0
  35. data/sig/pg_eventstore/web/metrics/collectors/subscriptions_health.rbs +15 -0
  36. data/sig/pg_eventstore/web/metrics/collectors/subscriptions_latency.rbs +31 -0
  37. data/sig/pg_eventstore/web/metrics/collectors/subscriptions_throughput.rbs +15 -0
  38. data/sig/pg_eventstore/web/metrics/formatter.rbs +19 -0
  39. data/sig/pg_eventstore/web/metrics/helpers.rbs +11 -0
  40. data/sig/pg_eventstore/web/metrics/metric_family.rbs +19 -0
  41. data/sig/pg_eventstore/web/paginator/helpers.rbs +5 -3
  42. 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: 9181ca6342afc352a02779065731ea21bb9e8ce6632b924a25342e9726e88f3c
4
+ data.tar.gz: 6c80c69802d7efcca8be4440d22f2dac1621f379cb710520d755335378a94725
5
5
  SHA512:
6
- metadata.gz: bc33cc4bcdda545ffa4476e80b55ca2541de4da036d715644f3957d1889170bbdf3b05b1391c7f981f28163da0ce36d06b0a8282ddeddb7560eb1892df43cfaa
7
- data.tar.gz: 64b63413791d2c993315d4012acb1a5bec19b237eae6761c6b9da8725483e4284f5fbf3b956df5c4a750719460087f30cec6c9db1f176ce728ad985f2aefa213
6
+ metadata.gz: a3d07d058fd23c8b6d19c09b2de3a5c600683205d6bd21df9a304dd0bc4e5428fd34f33dcbeea7cdd95415f8593fb99bac1801e1d08ce506c05b748d9f78ff78
7
+ data.tar.gz: c09027974c15266d0771c1375fbd7dedcc78841f7d36d9bb0c47f6f619b14922eb9d1561b9e1240a692c32aadef59d55ec3526479befab06e2c6c461e81d5680
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [3.1.1]
4
+
5
+ - Prevent arbitrary order in replica queries
6
+
7
+ ## [3.1.0]
8
+
9
+ - Hide "Delete stream" and "Delete" event buttons in admin UI for non-maintenance eventstore roles
10
+ - New feature: Prometheus metrics endpoints for subscriptions observability (lag, health, throughput), served by the
11
+ standalone `PgEventstore::Web::Metrics::Application` rack application. Read more [here](docs/metrics.md)
12
+ - Properly report internal errors via `failed_subscription_notifier` config option
13
+ - Fix inaccuracy that could lead to mistakenly handle connection error as subscription handler error
14
+ - Fix admin UI links to take into account mount path instead building a path from `'/'`
15
+
3
16
  ## [3.0.1]
4
17
 
5
18
  - Fix EventTracing bug: Respect user-supplied Event#correlation_id value when caused_by#correlation_id is absent
@@ -14,8 +27,10 @@
14
27
  middleware by default. Previously this wasn't the case - the deserialization phase was skipped. Such behavior was
15
28
  creating ambiguity about assumptions when deserialization happens. If you need old behavior - you have to create
16
29
  another middleware class which skips deserialization when publishing events and use it instead. Example:
17
- Let's say you have this middleware configured
30
+ Let's say you have this middleware configured
31
+
18
32
  ```ruby
33
+
19
34
  class MyMiddleware
20
35
  include PgEventstore::Middleware
21
36
 
@@ -36,9 +51,9 @@ end
36
51
  Now define another middleware that has empty `#deserialize` method
37
52
 
38
53
  ```ruby
54
+
39
55
  class MyMiddlewareWithoutDeserialize < MyMiddleware
40
- def deserialize(event)
41
- end
56
+ def deserialize(event) end
42
57
  end
43
58
 
44
59
  PgEventstore.configure do |config|
@@ -48,6 +63,7 @@ PgEventstore.configure do |config|
48
63
  }
49
64
  end
50
65
  ```
66
+
51
67
  and use only it when publishing events:
52
68
 
53
69
  ```ruby
@@ -76,12 +92,13 @@ PgEventstore.client(:write).append_to_stream(stream, event)
76
92
  - **Breaking change**: `Event#id` uniqueness is no longer guaranteed. It was dropped because there is not much usage of
77
93
  it internally. The default value was moved from the database(it was `gen_random_uuid()`) to the application level and
78
94
  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.
95
+ - **Breaking change**: event types, stream attributes, event metadata keys, markers that start from `▒`(`"\u2592"`
96
+ Unicode character) character are now reserved by pg_eventstore. It is less likely you have any, but if you do - you
97
+ have
98
+ to adjust your implementation to no longer rely on it.
82
99
  - 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)
100
+ using subscriptions or validate stream revision scoped to an event type and specific markers when publishing events as
101
+ a part of Dynamic Consistency Boundaries. Find more in [docs](docs/appending_events.md#event-markers)
85
102
  - New config option `config.events_subscription_position_update_interval`. See more
86
103
  in [Configuration](docs/configuration.md) docs
87
104
  - **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`
@@ -85,9 +85,7 @@ module PgEventstore
85
85
  rescue => exception
86
86
  @indexes.unshift(*indexes_to_resolve)
87
87
  @resolved = false
88
- raise Utils.wrap_exception(
89
- exception, global_positions: indexes_to_resolve.map(&:global_position)
90
- )
88
+ raise exception
91
89
  end
92
90
 
93
91
  # @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'
@@ -245,6 +245,11 @@ module PgEventstore
245
245
  restart_terminator:,
246
246
  failed_subscription_notifier:
247
247
  ),
248
+ # Catch-all recovery strategy that only reports an error. It is only invoked in case previous strategies
249
+ # rejected to recover an error. Must be stayed last.
250
+ RunnerRecoveryStrategies::ReportSubscriptionUnrecoverableError.new(
251
+ subscription:, failed_subscription_notifier:
252
+ ),
248
253
  ]
249
254
  end
250
255
 
@@ -110,6 +110,8 @@ module PgEventstore
110
110
  end
111
111
  # rubocop:enable Lint/SuppressedException
112
112
 
113
+ # Wraps subscription handler exception to correctly differentiate it from other errors that may happen inside
114
+ # the implementation. This is needed to correctly handle subscription handler errors vs internal errors.
113
115
  # @param exception [StandardError]
114
116
  # @param extra [Hash] additional exception info
115
117
  # @return [PgEventstore::WrappedException]
@@ -2,5 +2,5 @@
2
2
 
3
3
  module PgEventstore
4
4
  # @return [String]
5
- VERSION = '3.0.1'
5
+ VERSION = '3.1.1'
6
6
  end
@@ -176,6 +176,13 @@ module PgEventstore
176
176
  streams || []
177
177
  end
178
178
 
179
+ # Only maintainable roles can delete a stream or an event. This helper only supposes to hide delete buttons -
180
+ # backend has its own checks.
181
+ # @return [Boolean]
182
+ def can_delete?
183
+ PgEventstore::Config::NodeRole.maintainable.include?(PgEventstore.config(current_config).eventstore_role)
184
+ end
185
+
179
186
  private
180
187
 
181
188
  # @param hash [Hash]
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgEventstore
4
+ module Web
5
+ module Metrics
6
+ # Standalone rack application serving subscription metrics in the Prometheus text exposition format.
7
+ #
8
+ # It ships without any authentication - how the endpoint is protected is up to the application mounting it,
9
+ # the same way it is for the Admin UI (see docs/admin_ui.md#authorization).
10
+ #
11
+ # Which pg_eventstore database is queried is chosen per request with the "config" query param, so one mounted
12
+ # app can serve metrics of every configured store:
13
+ #
14
+ # GET /subscriptions/latency?config=db1
15
+ #
16
+ # An absent config means the default one; an unknown config is answered with 404 rather than silently served from
17
+ # the default store, so a misconfigured scrape shows up as a failing target instead of as wrong data. Add "set"
18
+ # params to report only some subscription sets - repeat the param for several: "?set=SetA&set=SetB".
19
+ class Application < Sinatra::Base
20
+ set :environment, -> { (ENV['RACK_ENV'] || ENV['RAILS_ENV'] || ENV['APP_ENV'])&.to_sym || :development }
21
+ set :logging, false
22
+ set :sessions, false
23
+ set :host_authorization, { allow_if: ->(_env) { true } }
24
+
25
+ helpers(Helpers) do
26
+ # @return [PgEventstore::Connection]
27
+ def metrics_connection
28
+ PgEventstore.connection(config_name)
29
+ end
30
+
31
+ # Name of the config to query: the "config" param when given, the default config otherwise. Halts with 404
32
+ # for a name that is not configured.
33
+ # @return [Symbol]
34
+ def config_name
35
+ requested = params[:config].to_s
36
+ return PgEventstore::DEFAULT_CONFIG if requested.empty?
37
+ return requested.to_sym if PgEventstore.available_configs.include?(requested.to_sym)
38
+
39
+ halt 404, { 'content-type' => 'text/plain' }, "Unknown config #{requested.inspect}"
40
+ end
41
+ end
42
+
43
+ get('/subscriptions') do
44
+ metrics_response(
45
+ [Collectors::SubscriptionsLatency, Collectors::SubscriptionsHealth, Collectors::SubscriptionsThroughput]
46
+ )
47
+ end
48
+
49
+ # The only route querying event positions - one index range scan per subscription.
50
+ get('/subscriptions/latency') do
51
+ metrics_response([Collectors::SubscriptionsLatency])
52
+ end
53
+
54
+ get('/subscriptions/health') do
55
+ metrics_response([Collectors::SubscriptionsHealth])
56
+ end
57
+
58
+ get('/subscriptions/throughput') do
59
+ metrics_response([Collectors::SubscriptionsThroughput])
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgEventstore
4
+ module Web
5
+ module Metrics
6
+ module Collectors
7
+ # Base class for metric collectors. A collector runs read-only queries and returns an array of
8
+ # {MetricFamily} objects.
9
+ class Base
10
+ # Milliseconds. Guards the store from a metrics query that got stuck - a scrape then fails visibly instead
11
+ # of piling up on the database.
12
+ # @return [Integer]
13
+ STATEMENT_TIMEOUT = 5_000
14
+
15
+ # @!attribute connection
16
+ # @return [PgEventstore::Connection]
17
+ attr_reader :connection
18
+ # @!attribute sets
19
+ # @return [Array<String>]
20
+ attr_reader :sets
21
+ private :connection, :sets
22
+
23
+ # @param connection [PgEventstore::Connection]
24
+ # @param sets [Array<String>] subscription sets to report on; an empty array means all of them
25
+ def initialize(connection, sets: [])
26
+ @connection = connection
27
+ @sets = sets
28
+ end
29
+
30
+ # @return [Array<PgEventstore::Web::Metrics::MetricFamily>]
31
+ def call
32
+ raise NotImplementedError, "#{self.class} must implement #call"
33
+ end
34
+
35
+ private
36
+
37
+ # @return [PgEventstore::SQLBuilder]
38
+ def subscriptions_sql_builder
39
+ sql_builder = SQLBuilder.new.from('subscriptions', table_alias: 's')
40
+ sql_builder.order('s.set, s.name')
41
+ sql_builder.where('s.set = any(?::varchar[])', sets) if sets.any?
42
+ sql_builder
43
+ end
44
+
45
+ # @return [Array<Hash>]
46
+ def with_safe_conn
47
+ transaction_queries.transaction(:read_committed, read_only: true) do
48
+ connection.with do |conn|
49
+ conn.exec("set local statement_timeout to #{STATEMENT_TIMEOUT}")
50
+ yield conn
51
+ end
52
+ end.to_a
53
+ end
54
+
55
+ # @param row [Hash]
56
+ # @return [Hash<Symbol => String>]
57
+ def subscription_labels(row)
58
+ { set: row['set'], name: row['name'] }
59
+ end
60
+
61
+ # @return [PgEventstore::TransactionQueries]
62
+ def transaction_queries
63
+ TransactionQueries.new(connection)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end