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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -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 +4 -9
- 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/subscription_feed_strategies/collection.rb +9 -7
- data/lib/pg_eventstore/subscriptions/subscription_feed_strategies/index_read_strategy.rb +16 -16
- data/lib/pg_eventstore/subscriptions/subscription_feed_strategies/replication_strategy.rb +15 -15
- data/lib/pg_eventstore/subscriptions/subscription_feed_strategy.rb +4 -3
- data/lib/pg_eventstore/subscriptions/subscription_runner.rb +0 -5
- data/lib/pg_eventstore/subscriptions/subscription_runners_feeder.rb +15 -4
- 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/subscriptions/subscription_feed_strategy/collection.rbs +1 -1
- data/sig/pg_eventstore/subscriptions/subscription_feed_strategy/index_read_strategy.rbs +1 -1
- data/sig/pg_eventstore/subscriptions/subscription_feed_strategy/replication_strategy.rbs +1 -1
- data/sig/pg_eventstore/subscriptions/subscription_feed_strategy.rbs +1 -1
- data/sig/pg_eventstore/subscriptions/subscription_runner.rbs +0 -2
- data/sig/pg_eventstore/subscriptions/subscription_runners_feeder.rbs +2 -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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PgEventstore
|
|
4
|
-
# This class pulls events from db and feeds
|
|
4
|
+
# This class decides which SubscriptionRunners to feed, pulls events from db and feeds them
|
|
5
5
|
# @!visibility private
|
|
6
6
|
class SubscriptionRunnersFeeder
|
|
7
7
|
# @param config_name [Symbol]
|
|
@@ -12,11 +12,15 @@ module PgEventstore
|
|
|
12
12
|
# @param runners [Array<PgEventstore::SubscriptionRunner>]
|
|
13
13
|
# @return [void]
|
|
14
14
|
def feed(runners)
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
runners_query_options = runners.select { |runner| runner.running? && time_to_feed?(runner) }.to_h do |runner|
|
|
16
|
+
[runner, runner.next_chunk_query_opts]
|
|
17
|
+
end
|
|
18
|
+
# A runner which has enough events in its queue estimates the number of events to fetch to 0
|
|
19
|
+
runners_query_options = runners_query_options.select { |_, query_options| query_options[:max_count] > 0 }
|
|
20
|
+
return if runners_query_options.empty?
|
|
17
21
|
|
|
18
22
|
feed_strategies_collection = SubscriptionFeedStrategy::Collection.create(
|
|
19
|
-
|
|
23
|
+
runners_query_options,
|
|
20
24
|
connection,
|
|
21
25
|
QueryStrategy::Async.new(connection)
|
|
22
26
|
)
|
|
@@ -29,6 +33,13 @@ module PgEventstore
|
|
|
29
33
|
|
|
30
34
|
private
|
|
31
35
|
|
|
36
|
+
# @param runner [PgEventstore::SubscriptionRunner]
|
|
37
|
+
# @return [Boolean]
|
|
38
|
+
def time_to_feed?(runner)
|
|
39
|
+
subscription = runner.subscription
|
|
40
|
+
subscription.last_chunk_fed_at + subscription.chunk_query_interval <= Time.now.utc
|
|
41
|
+
end
|
|
42
|
+
|
|
32
43
|
# @return [PgEventstore::Connection]
|
|
33
44
|
def connection
|
|
34
45
|
PgEventstore.connection(@config_name)
|
|
@@ -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
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgEventstore
|
|
4
|
+
module Web
|
|
5
|
+
module Metrics
|
|
6
|
+
module Collectors
|
|
7
|
+
# Liveness and error state of each reported subscription.
|
|
8
|
+
#
|
|
9
|
+
# The state column alone can not be trusted: a subscription killed without a graceful shutdown keeps
|
|
10
|
+
# state "running" and its lock forever. heartbeat_age_seconds is the discriminator - a subscription is
|
|
11
|
+
# really running only while its heartbeat stays below
|
|
12
|
+
# {PgEventstore::SubscriptionsLifecycle::HEARTBEAT_INTERVAL}.
|
|
13
|
+
class SubscriptionsHealth < Base
|
|
14
|
+
# @return [Array<PgEventstore::Web::Metrics::MetricFamily>]
|
|
15
|
+
def call
|
|
16
|
+
state = MetricFamily.new(
|
|
17
|
+
name: 'pg_eventstore_subscription_state',
|
|
18
|
+
type: 'gauge',
|
|
19
|
+
help: 'Last recorded state of the subscription. May be stale - correlate with ' \
|
|
20
|
+
'pg_eventstore_subscription_heartbeat_age_seconds.'
|
|
21
|
+
)
|
|
22
|
+
locked = MetricFamily.new(
|
|
23
|
+
name: 'pg_eventstore_subscription_locked',
|
|
24
|
+
type: 'gauge',
|
|
25
|
+
help: 'Whether the subscription is locked by a subscriptions set.'
|
|
26
|
+
)
|
|
27
|
+
heartbeat_age = MetricFamily.new(
|
|
28
|
+
name: 'pg_eventstore_subscription_heartbeat_age_seconds',
|
|
29
|
+
type: 'gauge',
|
|
30
|
+
help: 'Seconds since the subscription row was last touched by its runner. A locked subscription ' \
|
|
31
|
+
'with a stale heartbeat is a dead process that did not shut down gracefully.'
|
|
32
|
+
)
|
|
33
|
+
restarts = MetricFamily.new(
|
|
34
|
+
name: 'pg_eventstore_subscription_restarts_total',
|
|
35
|
+
type: 'counter',
|
|
36
|
+
help: 'Number of times the subscription was restarted after a failure.'
|
|
37
|
+
)
|
|
38
|
+
last_error_age = MetricFamily.new(
|
|
39
|
+
name: 'pg_eventstore_subscription_last_error_age_seconds',
|
|
40
|
+
type: 'gauge',
|
|
41
|
+
help: 'Seconds since the last error occurred. Absent when the subscription never failed.'
|
|
42
|
+
)
|
|
43
|
+
subscription_rows.each do |row|
|
|
44
|
+
labels = subscription_labels(row)
|
|
45
|
+
state.add_sample(labels: labels.merge(state: row['state']), value: 1)
|
|
46
|
+
locked.add_sample(labels:, value: row['locked'])
|
|
47
|
+
heartbeat_age.add_sample(labels:, value: row['heartbeat_age_seconds'])
|
|
48
|
+
restarts.add_sample(labels:, value: row['restart_count'])
|
|
49
|
+
last_error_age.add_sample(labels:, value: row['last_error_age_seconds']) if row['last_error_age_seconds']
|
|
50
|
+
end
|
|
51
|
+
[state, locked, heartbeat_age, restarts, last_error_age]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# @return [Array<Hash>]
|
|
57
|
+
def subscription_rows
|
|
58
|
+
builder = subscriptions_sql_builder
|
|
59
|
+
builder.select(<<~SQL)
|
|
60
|
+
s.set,
|
|
61
|
+
s.name,
|
|
62
|
+
s.state,
|
|
63
|
+
(s.locked_by is not null)::int as locked,
|
|
64
|
+
extract(epoch from ((now() at time zone 'utc') - s.updated_at))::float8 as heartbeat_age_seconds,
|
|
65
|
+
s.restart_count,
|
|
66
|
+
case when s.last_error_occurred_at is not null
|
|
67
|
+
then extract(epoch from ((now() at time zone 'utc') - s.last_error_occurred_at))::float8
|
|
68
|
+
end as last_error_age_seconds
|
|
69
|
+
SQL
|
|
70
|
+
with_safe_conn do |conn|
|
|
71
|
+
conn.exec_params(*builder.to_exec_params)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgEventstore
|
|
4
|
+
module Web
|
|
5
|
+
module Metrics
|
|
6
|
+
module Collectors
|
|
7
|
+
# How far behind each subscription is.
|
|
8
|
+
#
|
|
9
|
+
# A subscription checkpoint is measured in the same units as the subscription positions frontier, not in
|
|
10
|
+
# events.global_position units - the global position sequence contains gaps and runs ahead of the frontier,
|
|
11
|
+
# so comparing a checkpoint against it over-reports by orders of magnitude. Lag is therefore always measured
|
|
12
|
+
# against the frontier.
|
|
13
|
+
#
|
|
14
|
+
# - lag_events: how much of the store the subscription still has to walk through before it reaches the
|
|
15
|
+
# frontier - that is, before it starts processing newly appended events.
|
|
16
|
+
# - lag_seconds: age of the oldest event the subscription has not processed yet; 0 when caught up.
|
|
17
|
+
#
|
|
18
|
+
# The frontier only advances while at least one subscriptions process is running. When they are all down lag
|
|
19
|
+
# stops growing - that situation is reported by the heartbeat metric of the health collector, not here.
|
|
20
|
+
class SubscriptionsLatency < Base
|
|
21
|
+
# @return [Array<PgEventstore::Web::Metrics::MetricFamily>]
|
|
22
|
+
def call
|
|
23
|
+
lag_events = MetricFamily.new(
|
|
24
|
+
name: 'pg_eventstore_subscription_lag_events',
|
|
25
|
+
type: 'gauge',
|
|
26
|
+
help: 'How many events the subscription still has to catch up on before it reaches the edge of the ' \
|
|
27
|
+
'"all" stream.'
|
|
28
|
+
)
|
|
29
|
+
lag_seconds = MetricFamily.new(
|
|
30
|
+
name: 'pg_eventstore_subscription_lag_seconds',
|
|
31
|
+
type: 'gauge',
|
|
32
|
+
help: 'Age in seconds of the oldest event the subscription has not processed yet. 0 when caught up.'
|
|
33
|
+
)
|
|
34
|
+
frontier_position, head_global_position = *positions
|
|
35
|
+
rows = subscription_rows(frontier_position)
|
|
36
|
+
created_at_by_position = resolve_created_at(rows)
|
|
37
|
+
now = Time.now.utc
|
|
38
|
+
rows.each { add_samples(_1, created_at_by_position, now, lag_events, lag_seconds) }
|
|
39
|
+
[lag_events, lag_seconds, *store_families(frontier_position, head_global_position)]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# @param row [Hash]
|
|
45
|
+
# @param created_at_by_position [Hash<Integer => Time>]
|
|
46
|
+
# @param now [Time]
|
|
47
|
+
# @param lag_events [PgEventstore::Web::Metrics::MetricFamily]
|
|
48
|
+
# @param lag_seconds [PgEventstore::Web::Metrics::MetricFamily]
|
|
49
|
+
# @return [void]
|
|
50
|
+
def add_samples(row, created_at_by_position, now, lag_events, lag_seconds)
|
|
51
|
+
labels = subscription_labels(row)
|
|
52
|
+
lag_events.add_sample(labels:, value: row['lag_events'])
|
|
53
|
+
position = row['event_global_position']
|
|
54
|
+
# Caught up - nothing is left to process, so there is no unprocessed event to age. Deliberately a float:
|
|
55
|
+
# the metric is seconds everywhere else, and a gauge that renders as "0" here and "12.5" there is a wart.
|
|
56
|
+
return lag_seconds.add_sample(labels:, value: 0.0) if position.nil?
|
|
57
|
+
|
|
58
|
+
created_at = created_at_by_position[position]
|
|
59
|
+
# An unprocessed position whose event no longer exists (the event or its stream was deleted). Reporting 0
|
|
60
|
+
# would read as "caught up", the opposite of the truth, so report nothing - lag_events still carries the
|
|
61
|
+
# backlog.
|
|
62
|
+
return if created_at.nil?
|
|
63
|
+
|
|
64
|
+
lag_seconds.add_sample(labels:, value: [(now - created_at).to_f, 0].max)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# One index range scan per subscription over idx_event_subscription_positions_sposition_n_gposition. The
|
|
68
|
+
# cost does not grow with the size of the backlog: the oldest unprocessed position is taken with
|
|
69
|
+
# "order by subscription_position limit 1" rather than by aggregating over the whole backlog.
|
|
70
|
+
# @param frontier_position [Integer]
|
|
71
|
+
# @return [Array<Hash>]
|
|
72
|
+
def subscription_rows(frontier_position)
|
|
73
|
+
builder = subscriptions_sql_builder
|
|
74
|
+
builder.select(<<~SQL)
|
|
75
|
+
s.set,
|
|
76
|
+
s.name,
|
|
77
|
+
greatest(#{frontier_position} - coalesce(s.current_position, 0), 0) as lag_events,
|
|
78
|
+
next_event.global_position as event_global_position,
|
|
79
|
+
next_event.event_type_partition_id as event_type_partition_id
|
|
80
|
+
SQL
|
|
81
|
+
builder.join(<<~SQL)
|
|
82
|
+
left join lateral (
|
|
83
|
+
select egi.global_position, egi.event_type_partition_id
|
|
84
|
+
from event_subscription_positions esp
|
|
85
|
+
join events_global_index egi on egi.global_position = esp.global_position
|
|
86
|
+
where esp.subscription_position > coalesce(s.current_position, 0)
|
|
87
|
+
order by esp.subscription_position
|
|
88
|
+
limit 1
|
|
89
|
+
) next_event on true
|
|
90
|
+
SQL
|
|
91
|
+
with_safe_conn do |conn|
|
|
92
|
+
conn.exec_params(*builder.to_exec_params)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Resolves the creation time of every subscription's oldest unprocessed event.
|
|
97
|
+
#
|
|
98
|
+
# The events table is partitioned, and these events are known only by global position - which is not the
|
|
99
|
+
# partition key. Looking them up in events by global position alone would have to visit every partition and
|
|
100
|
+
# lock all of them, which stops being viable long before a store reaches five figures of partitions.
|
|
101
|
+
# events_global_index records the partition of each event, so the read API can resolve them partition-wise
|
|
102
|
+
# instead.
|
|
103
|
+
# @param subscription_rows [Array<Hash>]
|
|
104
|
+
# @return [Hash<Integer => Time>]
|
|
105
|
+
def resolve_created_at(subscription_rows)
|
|
106
|
+
indexes = subscription_rows.filter_map do |attrs|
|
|
107
|
+
next if attrs['event_global_position'].nil?
|
|
108
|
+
|
|
109
|
+
EventGlobalIndex::ReadApiRepr.new(
|
|
110
|
+
global_position: attrs['event_global_position'],
|
|
111
|
+
event_type_partition_id: attrs['event_type_partition_id']
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
return {} if indexes.empty?
|
|
115
|
+
|
|
116
|
+
resolved = events_global_index_queries.resolve_indexes(indexes, resolve_link_tos: false)
|
|
117
|
+
resolved.to_h { [_1['global_position'], _1['created_at']] }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @return [PgEventstore::EventsGlobalIndexQueries]
|
|
121
|
+
def events_global_index_queries
|
|
122
|
+
EventsGlobalIndexQueries.new(connection, QueryStrategy::Foreground.new(connection))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @param frontier_position [Integer]
|
|
126
|
+
# @param head_global_position [Integer]
|
|
127
|
+
# @return [Array<PgEventstore::Web::Metrics::MetricFamily>]
|
|
128
|
+
def store_families(frontier_position, head_global_position)
|
|
129
|
+
frontier = MetricFamily.new(
|
|
130
|
+
name: 'pg_eventstore_store_frontier_position',
|
|
131
|
+
type: 'gauge',
|
|
132
|
+
help: 'Latest assigned subscription position. Subscription checkpoints are measured against this.'
|
|
133
|
+
)
|
|
134
|
+
frontier.add_sample(labels: {}, value: frontier_position)
|
|
135
|
+
head = MetricFamily.new(
|
|
136
|
+
name: 'pg_eventstore_store_head_global_position',
|
|
137
|
+
type: 'gauge',
|
|
138
|
+
help: 'Global position of the newest event in the store. Contains gaps; do not compare subscription ' \
|
|
139
|
+
'checkpoints against it.'
|
|
140
|
+
)
|
|
141
|
+
head.add_sample(labels: {}, value: head_global_position)
|
|
142
|
+
[frontier, head]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [Array[Integer]]
|
|
146
|
+
def positions
|
|
147
|
+
res = with_safe_conn do |conn|
|
|
148
|
+
conn.exec(<<~SQL)
|
|
149
|
+
select
|
|
150
|
+
(select coalesce(max(subscription_position), 0) from event_subscription_positions)
|
|
151
|
+
as frontier_position,
|
|
152
|
+
(select coalesce(max(global_position), 0) from events_global_index) as head_global_position
|
|
153
|
+
SQL
|
|
154
|
+
end
|
|
155
|
+
res.first.values_at('frontier_position', 'head_global_position')
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgEventstore
|
|
4
|
+
module Web
|
|
5
|
+
module Metrics
|
|
6
|
+
module Collectors
|
|
7
|
+
# Processing volume and speed of each reported subscription.
|
|
8
|
+
#
|
|
9
|
+
# Two deliberately different numbers:
|
|
10
|
+
# - processed_events_total is a counter; rate() over it gives the actual current throughput and correctly
|
|
11
|
+
# drops to 0 when no matching events arrive.
|
|
12
|
+
# - capacity_events_per_second derives from the average handler execution time of the last
|
|
13
|
+
# {PgEventstore::SubscriptionHandlerPerformance::TIMINGS_TO_KEEP} processed events - whenever they
|
|
14
|
+
# happened. It answers "how fast can this handler go when fed", is sticky while the subscription is idle
|
|
15
|
+
# and must not be read as current throughput.
|
|
16
|
+
class SubscriptionsThroughput < Base
|
|
17
|
+
# @return [Array<PgEventstore::Web::Metrics::MetricFamily>]
|
|
18
|
+
def call
|
|
19
|
+
processed = MetricFamily.new(
|
|
20
|
+
name: 'pg_eventstore_subscription_processed_events_total',
|
|
21
|
+
type: 'counter',
|
|
22
|
+
help: 'Total number of events processed by the subscription. Use rate() for current throughput.'
|
|
23
|
+
)
|
|
24
|
+
capacity = MetricFamily.new(
|
|
25
|
+
name: 'pg_eventstore_subscription_capacity_events_per_second',
|
|
26
|
+
type: 'gauge',
|
|
27
|
+
help: 'Average processing speed over the last few processed events, whenever they happened. ' \
|
|
28
|
+
'Sticky while idle - this is handler capacity, not current throughput.'
|
|
29
|
+
)
|
|
30
|
+
subscription_rows.each do |row|
|
|
31
|
+
labels = subscription_labels(row)
|
|
32
|
+
processed.add_sample(labels:, value: row['total_processed_events'])
|
|
33
|
+
capacity.add_sample(labels:, value: row['capacity_eps']) if row['capacity_eps']
|
|
34
|
+
end
|
|
35
|
+
[processed, capacity]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# @return [Array<Hash>]
|
|
41
|
+
def subscription_rows
|
|
42
|
+
builder = subscriptions_sql_builder
|
|
43
|
+
builder.select(<<~SQL)
|
|
44
|
+
s.set,
|
|
45
|
+
s.name,
|
|
46
|
+
s.total_processed_events,
|
|
47
|
+
case when s.average_event_processing_time > 0
|
|
48
|
+
then (1.0 / s.average_event_processing_time)::float8
|
|
49
|
+
end as capacity_eps
|
|
50
|
+
SQL
|
|
51
|
+
with_safe_conn do |conn|
|
|
52
|
+
conn.exec_params(*builder.to_exec_params)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgEventstore
|
|
4
|
+
module Web
|
|
5
|
+
module Metrics
|
|
6
|
+
# Renders metric families into the Prometheus text exposition format (version 0.0.4).
|
|
7
|
+
class Formatter
|
|
8
|
+
# @return [String]
|
|
9
|
+
CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8'
|
|
10
|
+
|
|
11
|
+
# @param families [Array<PgEventstore::Web::Metrics::MetricFamily>]
|
|
12
|
+
# @return [String]
|
|
13
|
+
def call(families)
|
|
14
|
+
"#{families.map { |family| format_family(family) }.join("\n")}\n"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# @param family [PgEventstore::Web::Metrics::MetricFamily]
|
|
20
|
+
# @return [String]
|
|
21
|
+
def format_family(family)
|
|
22
|
+
lines = ["# HELP #{family.name} #{family.help}", "# TYPE #{family.name} #{family.type}"]
|
|
23
|
+
family.samples.each do |sample|
|
|
24
|
+
lines.push("#{family.name}#{format_labels(sample[:labels])} #{sample[:value]}")
|
|
25
|
+
end
|
|
26
|
+
lines.join("\n")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param labels [Hash<Symbol => String>]
|
|
30
|
+
# @return [String]
|
|
31
|
+
def format_labels(labels)
|
|
32
|
+
return '' if labels.empty?
|
|
33
|
+
|
|
34
|
+
"{#{labels.map { |name, value| %(#{name}="#{escape_label_value(value.to_s)}") }.join(',')}}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param value [String]
|
|
38
|
+
# @return [String]
|
|
39
|
+
def escape_label_value(value)
|
|
40
|
+
value.gsub('\\', '\\\\\\\\').gsub("\n", '\\n').gsub('"', '\\"')
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgEventstore
|
|
4
|
+
module Web
|
|
5
|
+
module Metrics
|
|
6
|
+
# Route helpers of {Metrics::Application}.
|
|
7
|
+
module Helpers
|
|
8
|
+
# @param collector_classes [Array<Class<PgEventstore::Web::Metrics::Collectors::Base>>]
|
|
9
|
+
# @return [String]
|
|
10
|
+
def metrics_response(collector_classes)
|
|
11
|
+
connection = metrics_connection
|
|
12
|
+
families = collector_classes.flat_map { _1.new(connection, sets: requested_sets).call }
|
|
13
|
+
content_type(Formatter::CONTENT_TYPE)
|
|
14
|
+
Formatter.new.call(families)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Subscription sets a scrape asks for. Accepts a repeated param ("?set=A&set=B" - what Prometheus emits for
|
|
18
|
+
# `params: {set: [A, B]}`) or a comma separated list ("?set=A,B"). Empty means every set.
|
|
19
|
+
#
|
|
20
|
+
# The query string is parsed directly because Sinatra's `params` keeps only the last value of a repeated key
|
|
21
|
+
# unless it is written as "set[]", which Prometheus does not do.
|
|
22
|
+
# @return [Array<String>]
|
|
23
|
+
def requested_sets
|
|
24
|
+
query = Rack::Utils.parse_query(request.query_string)
|
|
25
|
+
Array(query['set'] || query['set[]']).flat_map { _1.to_s.split(',') }.map(&:strip).reject(&:empty?)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgEventstore
|
|
4
|
+
module Web
|
|
5
|
+
module Metrics
|
|
6
|
+
# A collection of samples of a single Prometheus metric.
|
|
7
|
+
class MetricFamily
|
|
8
|
+
# @!attribute name
|
|
9
|
+
# @return [String]
|
|
10
|
+
attr_reader :name
|
|
11
|
+
# @!attribute type
|
|
12
|
+
# @return [String] "gauge" or "counter"
|
|
13
|
+
attr_reader :type
|
|
14
|
+
# @!attribute help
|
|
15
|
+
# @return [String]
|
|
16
|
+
attr_reader :help
|
|
17
|
+
# @!attribute samples
|
|
18
|
+
# @return [Array<Hash>]
|
|
19
|
+
attr_reader :samples
|
|
20
|
+
|
|
21
|
+
# @param name [String]
|
|
22
|
+
# @param type [String]
|
|
23
|
+
# @param help [String]
|
|
24
|
+
def initialize(name:, type:, help:)
|
|
25
|
+
@name = name
|
|
26
|
+
@type = type
|
|
27
|
+
@help = help
|
|
28
|
+
@samples = []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param labels [Hash<Symbol => String>]
|
|
32
|
+
# @param value [Integer, Float]
|
|
33
|
+
# @return [void]
|
|
34
|
+
def add_sample(labels:, value:)
|
|
35
|
+
@samples.push({ labels:, value: })
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|