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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -8
- data/README.md +1 -0
- data/docs/admin_ui.md +5 -0
- data/docs/metrics.md +233 -0
- data/lib/pg_eventstore/chunks/subscription_events_index_chunk.rb +1 -3
- data/lib/pg_eventstore/queries/replica_queries.rb +8 -5
- data/lib/pg_eventstore/sql_builder.rb +1 -1
- data/lib/pg_eventstore/subscriptions/callback_handlers/subscription_runner_handlers.rb +1 -1
- data/lib/pg_eventstore/subscriptions/runner_recovery_strategies/report_subscription_unrecoverable_error.rb +31 -0
- data/lib/pg_eventstore/subscriptions/runner_recovery_strategies.rb +1 -0
- data/lib/pg_eventstore/subscriptions/subscriptions_manager.rb +5 -0
- data/lib/pg_eventstore/utils.rb +2 -0
- data/lib/pg_eventstore/version.rb +1 -1
- data/lib/pg_eventstore/web/application.rb +7 -0
- data/lib/pg_eventstore/web/metrics/application.rb +64 -0
- data/lib/pg_eventstore/web/metrics/collectors/base.rb +69 -0
- data/lib/pg_eventstore/web/metrics/collectors/subscriptions_health.rb +78 -0
- data/lib/pg_eventstore/web/metrics/collectors/subscriptions_latency.rb +161 -0
- data/lib/pg_eventstore/web/metrics/collectors/subscriptions_throughput.rb +59 -0
- data/lib/pg_eventstore/web/metrics/formatter.rb +45 -0
- data/lib/pg_eventstore/web/metrics/helpers.rb +30 -0
- data/lib/pg_eventstore/web/metrics/metric_family.rb +40 -0
- data/lib/pg_eventstore/web/paginator/helpers.rb +27 -23
- data/lib/pg_eventstore/web/views/home/partials/events.erb +8 -6
- data/lib/pg_eventstore/web/views/home/partials/stream_filter.erb +1 -1
- data/lib/pg_eventstore/web/views/streams/partials/streams.erb +1 -1
- data/lib/pg_eventstore/web.rb +8 -0
- data/rbs_collection.lock.yaml +1 -1
- data/sig/pg_eventstore/subscriptions/callback_handlers/subscription_runner_handlers.rbs +1 -1
- data/sig/pg_eventstore/subscriptions/runner_recovery_strategies/report_subscription_unrecoverable_error.rbs +15 -0
- data/sig/pg_eventstore/web/application.rbs +2 -0
- data/sig/pg_eventstore/web/metrics/application.rbs +11 -0
- data/sig/pg_eventstore/web/metrics/collectors/base.rbs +33 -0
- data/sig/pg_eventstore/web/metrics/collectors/subscriptions_health.rbs +15 -0
- data/sig/pg_eventstore/web/metrics/collectors/subscriptions_latency.rbs +31 -0
- data/sig/pg_eventstore/web/metrics/collectors/subscriptions_throughput.rbs +15 -0
- data/sig/pg_eventstore/web/metrics/formatter.rbs +19 -0
- data/sig/pg_eventstore/web/metrics/helpers.rbs +11 -0
- data/sig/pg_eventstore/web/metrics/metric_family.rbs +19 -0
- data/sig/pg_eventstore/web/paginator/helpers.rbs +5 -3
- metadata +20 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9181ca6342afc352a02779065731ea21bb9e8ce6632b924a25342e9726e88f3c
|
|
4
|
+
data.tar.gz: 6c80c69802d7efcca8be4440d22f2dac1621f379cb710520d755335378a94725
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
81
|
-
|
|
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
|
|
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
|
|
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 [
|
|
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
|
|
data/lib/pg_eventstore/utils.rb
CHANGED
|
@@ -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]
|
|
@@ -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
|