rails_pod_kit 0.0.3 → 0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 24c0e428faf1a3c46646a1d7a0e693b5cde8373f6aea61f9dc005d6b6dcba192
4
- data.tar.gz: 968fc08545c75f5b22f91c2c882068b20069d7bb2bb6f48d1abf2aa09f4bc71b
3
+ metadata.gz: 72d44d2c9e2129f3ca4ae021cbc10a227838b5998a80fafc6bbcd8efd18f80d0
4
+ data.tar.gz: 1c591dcbf8dccf4dcb608483311fbff89363540a25f7a629c6aaa6cda34f6372
5
5
  SHA512:
6
- metadata.gz: 266b27abb0911f031e54522dfcea870d603e1ec143465b4441a206590654087c1604a9aeb4438745bc10600bf3696b7d4938c0c936e0145322b76cb0279a5513
7
- data.tar.gz: dedc4665fb51d38b9e713caead650ee875108aed4a24e9f27bb3053f2ec0152bdcc0f3840cc0ac76056858f330b723c502bc5f66d726261db47a7033e6ae2142
6
+ metadata.gz: 989f811cb7ad46f94466b334f4c5405dc46f6bd7df44830645822a14205f9f955451752fba35505488fd4e45a2e62b35ef7306423d6ea0a42432490d0d7c7895
7
+ data.tar.gz: 22711c7a9d6b4a333bf135133222706e994791ff7648eb8b92f327dfd44d67ae6fa5ce10d797e9ba29d74dd2243ac6563525177458adaeed7b086104fb23c0c0
data/README.md CHANGED
@@ -3,14 +3,16 @@
3
3
  The operational endpoints a Rails pod needs to be a good Kubernetes citizen,
4
4
  packaged behind a single, opinionated entry point:
5
5
 
6
- - **Prometheus metrics** for Puma and Sidekiq, served **in-process** on a
7
- single `/metrics` endpoint (default port **9394**) in both the web (Puma) and
8
- worker (Sidekiq) processes — no sidecar, no separate collector process. A
9
- metrics agent (e.g. the Datadog Agent via OpenMetrics autodiscovery) scrapes
10
- the pod directly.
6
+ - **Prometheus metrics** for Puma, Sidekiq and SolidQueue, served **in-process**
7
+ on a single `/metrics` endpoint (default port **9394**) in the web (Puma) and
8
+ worker processes — no sidecar, no separate collector process. A metrics agent
9
+ (e.g. the Datadog Agent via OpenMetrics autodiscovery) scrapes the pod
10
+ directly.
11
11
  - **Health checks** on `/healthz` (database, cache, optionally Redis and Sidekiq),
12
12
  wired for Kubernetes startup/liveness/readiness probes — a thin, opinionated
13
13
  wrapper around [health-monitor-rails](https://github.com/lbeder/health-monitor-rails).
14
+ - **A supervised SolidQueue scheduler thread**, so a SolidQueue job executor can
15
+ be autoscaled to zero without stranding its recurring and scheduled jobs.
14
16
 
15
17
  The metrics side is a thin wrapper around the
16
18
  [yabeda](https://github.com/yabeda-rb) ecosystem:
@@ -21,11 +23,14 @@ The metrics side is a thin wrapper around the
21
23
  | `yabeda-puma-plugin` | Puma thread-pool / worker stats + the `:yabeda` / `:yabeda_prometheus` Puma plugins |
22
24
  | `yabeda-sidekiq` | Sidekiq per-process job metrics + global/Redis-wide queue metrics |
23
25
  | `yabeda-prometheus-mmap` | Prometheus text exporter, multiprocess-safe via `prometheus-client-mmap` |
24
- | `webrick` | HTTP server for the Sidekiq exporter |
26
+ | `webrick` | HTTP server for the non-Puma exporters |
25
27
 
26
- > Scope: **runtime/worker metrics only** (Puma backlog/threads, Sidekiq queues
27
- > and jobs). HTTP request-level metrics are intentionally out of scope — that is
28
- > covered by APM.
28
+ The SolidQueue queue gauges are the gem's own: SolidQueue ships no metrics
29
+ endpoint and there is no `yabeda-solid_queue` plugin to wrap.
30
+
31
+ > Scope: **runtime/worker metrics only** (Puma backlog/threads, Sidekiq and
32
+ > SolidQueue queues and jobs). HTTP request-level metrics are intentionally out
33
+ > of scope — that is covered by APM.
29
34
 
30
35
  The gem is deliberately **connection-agnostic**: it never reads `REDIS_URL` and
31
36
  makes no TLS decisions. Wherever a Redis connection is needed (health checks,
@@ -67,6 +72,9 @@ RailsPodKit::Puma.activate(self)
67
72
  RailsPodKit::Sidekiq.install!(config)
68
73
  ```
69
74
 
75
+ On a SolidQueue stack there is no step 3 — see
76
+ [SolidQueue](#solidqueue-scale-to-zero) instead.
77
+
70
78
  ## Configuration
71
79
 
72
80
  `RailsPodKit::Config` is an [anyway_config](https://github.com/palkan/anyway_config)
@@ -148,6 +156,8 @@ endpoint every few seconds); pass `silence_controller_log: false` to keep it.
148
156
  `sidekiq_queue_latency`, `sidekiq_active_processes`,
149
157
  `sidekiq_active_workers_count`, `sidekiq_jobs_retry_count`,
150
158
  `sidekiq_jobs_dead_count`, `sidekiq_jobs_scheduled_count`.
159
+ - **SolidQueue (DB-wide):** `solid_queue_backlog`,
160
+ `solid_queue_latency_seconds`.
151
161
 
152
162
  Series are intentionally **untagged**: a scraping agent adds
153
163
  `service`/`env`/`version` and `kube_*` tags at scrape time, so the gem doesn't
@@ -182,35 +192,53 @@ instances:
182
192
  raw_metric_prefix: "sidekiq_" # sidekiq_queue_latency -> sidekiq.queue_latency
183
193
  metrics: [".*"]
184
194
  tag_by_endpoint: false
195
+
196
+ # SolidQueue (the pod publishing the queue gauges) — :9394/metrics
197
+ instances:
198
+ - openmetrics_endpoint: "http://%%host%%:9394/metrics"
199
+ namespace: "solid_queue"
200
+ raw_metric_prefix: "solid_queue_" # solid_queue_backlog -> solid_queue.backlog
201
+ metrics: [".*"]
202
+ tag_by_endpoint: false
185
203
  ```
186
204
 
205
+ **One prefix per endpoint.** `metrics: [".*"]` ingests *everything* served
206
+ there, so each block above assumes its endpoint carries a single group — which
207
+ is why the SolidQueue gauges are best published from their own pod (below).
208
+ Where one endpoint really must carry two groups (e.g. a web pod exposing both
209
+ `puma_*` and `solid_queue_*`), give **every** instance on it an explicit filter
210
+ — `metrics: ["puma_.*"]` and `metrics: ["solid_queue_.*"]` — or each namespace
211
+ will swallow the other's series un-stripped.
212
+
187
213
  On Kubernetes this is typically wired as pod-annotation autodiscovery. The
188
214
  `openmetrics_endpoint` above uses the Datadog Agent's `%%host%%` autodiscovery
189
215
  template (resolves to the pod IP) — a **non-k8s adopter** (a plain `conf.yaml`
190
216
  check) swaps `%%host%%:9394` for the real `host:port`; everything else
191
217
  (`namespace`, `raw_metric_prefix`, `metrics`) is identical.
192
218
 
193
- ### Invariant — the endpoint must stay prefix-pure
219
+ ### Invariant — every series must carry a group prefix
194
220
 
195
221
  The Datadog check uses `metrics: [".*"]`, which ingests **every** series on the
196
222
  endpoint. `raw_metric_prefix` only *strips* the prefix when present — **it does
197
223
  not filter**. So the naming scheme above holds only because the `/metrics`
198
- endpoint exposes **solely** yabeda-registered series, all sharing the
199
- `puma_` / `sidekiq_` group prefix:
224
+ endpoint exposes **solely** yabeda-registered series, each carrying its
225
+ `puma_` / `sidekiq_` / `solid_queue_` group prefix:
200
226
 
201
- - the gem registers only the `:puma` and `:sidekiq` yabeda groups (no process,
202
- GC, or Ruby-runtime collectors);
227
+ - the gem registers only the `:puma`, `:sidekiq` and `:solid_queue` yabeda
228
+ groups (no process, GC, or Ruby-runtime collectors);
203
229
  - the exposition serves Yabeda's registry only — the Prometheus client's HTTP
204
230
  request collector (`http_*`) is **not** mounted on the exporter.
205
231
 
206
- If a series without the `puma_` / `sidekiq_` prefix ever appeared on the
207
- endpoint, `metrics: [".*"]` would ingest it **un-stripped** under the namespace
208
- (e.g. `puma.http_requests_total`). **Do not** add cross-cutting metrics (process,
209
- runtime, HTTP request) to this exporter, and do not mount the Prometheus Rack
210
- collector on it. If you ever need such metrics, expose them on a separate
211
- endpoint with its own check rather than polluting this one. Conversely, any new
212
- metric you *do* add to the `:puma` / `:sidekiq` groups is picked up automatically
213
- by `[".*"]` — no check change needed.
232
+ If an unprefixed series ever appeared on the endpoint, `metrics: [".*"]` would
233
+ ingest it **un-stripped** under the namespace (e.g. `puma.http_requests_total`).
234
+ **Do not** add cross-cutting metrics (process, runtime, HTTP request) to this
235
+ exporter, and do not mount the Prometheus Rack collector on it. If you ever need
236
+ such metrics, expose them on a separate endpoint with its own check rather than
237
+ polluting this one. Conversely, any new metric you *do* add to an existing group
238
+ is picked up automatically by `[".*"]` no check change needed.
239
+
240
+ `spec/rails_pod_kit/metrics_invariant_spec.rb` guards this at both the registry
241
+ and the exposition level.
214
242
 
215
243
  > ⚠️ Renaming the namespace prefix is a **breaking metric rename** — existing
216
244
  > dashboards/monitors built on the old `puma.puma_*` / `sidekiq.sidekiq_*` series
@@ -258,6 +286,13 @@ Sidekiq job class.
258
286
  | `sidekiq.job_runtime` | histogram | `queue`, `worker` |
259
287
  | `sidekiq.job_latency` | histogram | `queue`, `worker` |
260
288
 
289
+ **SolidQueue — DB-wide queue state** (`namespace: solid_queue`):
290
+
291
+ | canonical Datadog metric | type | functional tags |
292
+ |---|---|---|
293
+ | `solid_queue.backlog` | gauge | `queue` |
294
+ | `solid_queue.latency_seconds` | gauge | `queue` |
295
+
261
296
  ### Where Sidekiq global metrics come from
262
297
 
263
298
  The global (Redis-wide) queue gauges — `sidekiq_jobs_waiting_count`,
@@ -313,10 +348,155 @@ declares the cluster gauges, starts the exporter and blocks until SIGTERM.
313
348
  Booting the full host app just to read a handful of Redis counters would cost
314
349
  ~300Mi RSS for nothing — this process sits at ~60Mi.
315
350
 
351
+ ## SolidQueue: scale-to-zero
352
+
353
+ SolidQueue's executor has nothing to do while the queue is empty, so it is the
354
+ natural candidate for **scale-to-zero** autoscaling (KEDA, or an HPA). Two things
355
+ stand in the way, and the gem covers both. Everything here is opt-in; requiring
356
+ the gem alone changes nothing.
357
+
358
+ ### 1. The scheduler has to move off the executor
359
+
360
+ With the executor at zero there is no scheduler, so nothing enqueues the
361
+ recurring and scheduled jobs that would wake one — the queue stays empty because
362
+ it is empty. A k8s CronJob can't take over either: it can't own **dynamic**
363
+ recurring tasks, the ones created and updated at runtime through
364
+ `SolidQueue.schedule_recurring_task`.
365
+
366
+ The fix is to run the *scheduler alone* on a process that is always on, and let
367
+ the executor be nothing but dispatcher + workers (`bin/jobs` with
368
+ `SOLID_QUEUE_SKIP_RECURRING=true`):
369
+
370
+ ```ruby
371
+ # config/puma.rb — after_booted only runs in the real Puma process, never in a
372
+ # console, a rake task or the test suite.
373
+ after_booted { RailsPodKit::SolidQueue.start_scheduler! }
374
+ at_exit { RailsPodKit::SolidQueue.stop_scheduler! }
375
+ ```
376
+
377
+ This is deliberately **not** `plugin :solid_queue`. That one runs the full
378
+ supervisor, which forks and whose watchdog takes Puma down when the supervisor
379
+ exits — and a transient Postgres disconnect is enough to cause that
380
+ ([rails/solid_queue#512](https://github.com/rails/solid_queue/issues/512)). Here
381
+ a DB blip at worst kills the scheduler thread; a `Concurrent::TimerTask` (the
382
+ same primitive SolidQueue supervises its own processes with) notices on the next
383
+ tick and starts a fresh one, the process itself never notices, and the scheduler
384
+ re-registers on recovery.
385
+
386
+ Running it on every replica is safe: enqueues stay exactly-once via the unique
387
+ index on `solid_queue_recurring_executions (task_key, run_at)`. Static tasks come
388
+ from `config/recurring.yml` (honouring `SOLID_QUEUE_RECURRING_SCHEDULE`), dynamic
389
+ ones from the DB.
390
+
391
+ | option | default | meaning |
392
+ |---|---|---|
393
+ | `polling_interval` | `5` | how often the scheduler re-reads the dynamic tasks |
394
+ | `supervision_interval` | `5` | how often we check the scheduler thread is alive |
395
+ | `recurring_schedule_file` | `config/recurring.yml` | static task definitions; skipped when absent |
396
+
397
+ `start_scheduler!` is **not** gated on `enabled` — that switch owns the metrics
398
+ exporter, and an app may well want the scheduler with metrics off. What keeps it
399
+ out of consoles and specs is *where* you call it from.
400
+
401
+ ### 2. Queue depth has to be visible
402
+
403
+ SolidQueue publishes no metrics, so the autoscaler and the dashboards have
404
+ nothing to read. `install_metrics!` adds two gauges, computed **at scrape time**
405
+ from the SolidQueue tables (a yabeda `collect` block — no background thread, no
406
+ cached snapshot):
407
+
408
+ ```ruby
409
+ # config/initializers/rails_pod_kit.rb
410
+ RailsPodKit::SolidQueue.install_metrics!
411
+ ```
412
+
413
+ | metric | meaning |
414
+ |---|---|
415
+ | `solid_queue_backlog` | how many jobs could be claimed right now, per `queue` |
416
+ | `solid_queue_latency_seconds` | how long the oldest of them has been waiting, per `queue` |
417
+
418
+ "Claimable right now" is ready executions **plus** scheduled ones whose time has
419
+ come — the dispatcher has only to move those across. A scheduled job's wait is
420
+ measured from its `scheduled_at`, not its `created_at`: enqueuing a week ahead of
421
+ the slot doesn't make it a week late.
422
+
423
+ Both matter, and neither alone is enough: backlog misses a small-but-stalled
424
+ queue, latency misses a large-but-moving one.
425
+
426
+ **An idle system reads 0, not no-data.** A queue that drains is explicitly zeroed
427
+ rather than left pinned at its last reading, and the zeroing starts from a
428
+ baseline of every queue the app is known to use — discovered once per process
429
+ from the jobs table, or pinned by the host:
430
+
431
+ ```ruby
432
+ RailsPodKit::SolidQueue.install_metrics!(queues: %w[default mailers])
433
+ ```
434
+
435
+ Without that baseline a process booting while the queue is empty — the steady
436
+ state of a scale-to-zero deployment — would publish no series at all, since a
437
+ gauge only exists once it has been set. Discovery is best-effort: the jobs table
438
+ is bounded by `clear_finished_jobs_after`, so a queue idle for longer than the
439
+ retention window leaves no trace in it. Pin `queues:` where the zero has to be
440
+ guaranteed.
441
+
442
+ **A collection error is always reported** (logged, plus handed to
443
+ `Rails.error`), then either swallowed or raised:
444
+
445
+ | | |
446
+ |---|---|
447
+ | `fail_scrape_on_error: false` (default) | serve the last reading. Right on an endpoint shared with the Puma or Sidekiq series, where failing the response would lose those too — at the cost of a stale gauge a consumer cannot tell apart from a live one. |
448
+ | `fail_scrape_on_error: true` (`run_exporter!`'s default) | fail the scrape. Right on the dedicated pod, where there is nothing else to protect: the gauges go to no-data and the scrape failure shows up in the scraper's own `up` series. |
449
+
450
+ ### The always-on pod
451
+
452
+ The cleanest home for both is a **1-replica Deployment** that hosts the scheduler
453
+ and publishes the gauges, decoupled from the web and the executor — so the
454
+ signals survive either scaling to zero, and each series has exactly one source.
455
+ `run_exporter!` is that process: it declares the gauges, starts the scheduler,
456
+ serves `/metrics` and blocks until SIGTERM (winding the scheduler down so it
457
+ deregisters rather than expiring).
458
+
459
+ Unlike the Sidekiq global exporter it is **not** Rails-free — SolidQueue is
460
+ ActiveRecord-backed and reads the app's own tables — so the host's entrypoint
461
+ boots the environment first. The gem ships no executable; e.g.
462
+ `bin/solid-queue-pod`:
463
+
464
+ ```ruby
465
+ #!/usr/bin/env ruby
466
+ require_relative '../config/environment'
467
+
468
+ RailsPodKit::SolidQueue.run_exporter!
469
+ ```
470
+
471
+ ```
472
+ command: ["bin/solid-queue-pod"]
473
+ ```
474
+
475
+ Pass `scheduler: false` to serve the gauges only, on an app whose web process
476
+ already hosts the scheduler, and `metrics:` to override the gauge options (this
477
+ endpoint is the collector's own, so `fail_scrape_on_error` defaults to `true`
478
+ here):
479
+
480
+ ```ruby
481
+ RailsPodKit::SolidQueue.run_exporter!(scheduler: false, metrics: { queues: %w[default mailers] })
482
+ ```
483
+
484
+ Keep the endpoint single-prefix (see
485
+ [One prefix per endpoint](#datadog-naming--the-canonical-metric-set)) — that pod
486
+ serves `solid_queue_*` and nothing else, so the check config needs no filters.
487
+
316
488
  ## Caveats
317
489
 
318
490
  - **Puma only.** The Puma plugins only activate under Puma; under any other
319
- app server the in-process `/metrics` endpoint is **not** exposed.
491
+ app server the in-process `/metrics` endpoint is **not** exposed. The non-Puma
492
+ entry points (Sidekiq, the global exporter, the SolidQueue pod) serve it from
493
+ a WEBrick thread instead, started at most once per process.
494
+ - **SolidQueue and Sidekiq are the host's.** The gem depends on neither; the
495
+ SolidQueue integration is inert until you call it, exactly like the Sidekiq one.
496
+ - **Queue-gauge query cost.** The gauges run four small grouped aggregates per
497
+ scrape. `MIN(created_at)` is not covered by SolidQueue's indexes, so on a
498
+ backlog of many thousands of rows it is a scan — cheap at a normal scrape
499
+ interval, worth knowing about if you scrape aggressively.
320
500
  - **Puma control app.** `yabeda-puma-plugin` reads Puma's thread-pool stats
321
501
  through Puma's control app, so `Puma.activate` activates one on a
322
502
  localhost-only socket (`no_token: true`, never network-exposed).
@@ -352,6 +532,10 @@ curl -s localhost:9394/metrics | grep '^puma_'
352
532
  bundle exec sidekiq
353
533
  curl -s localhost:9394/metrics | grep '^sidekiq_'
354
534
 
535
+ # SolidQueue: solid_queue_* series (enqueue a job first so the queue isn't empty)
536
+ bin/solid-queue-pod
537
+ curl -s localhost:9394/metrics | grep '^solid_queue_'
538
+
355
539
  # Health endpoint
356
540
  curl -s localhost:3000/healthz -H 'Accept: application/json'
357
541
  ```
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.0.3
1
+ 0.1.1
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsPodKit
4
+ # Where the gem's background work (the SolidQueue scheduler supervisor, the
5
+ # scrape-time metric collectors) sends the errors it swallows. Those errors
6
+ # must never propagate — one would kill the supervising timer or fail the whole
7
+ # /metrics response — but they must not vanish either.
8
+ #
9
+ # Always logs, and additionally hands the error to the Rails error reporter
10
+ # when there is one, so the host's Rollbar / Sentry / Datadog subscriber picks
11
+ # it up. The log line is not redundant: `Rails.error.report` only fans out to
12
+ # subscribers, so on an app with none — or with one that is not wired in a
13
+ # given environment — the failure would otherwise leave no trace at all, and
14
+ # the only symptom of a broken collector is a gauge quietly serving a stale
15
+ # value.
16
+ module ErrorReporter
17
+ module_function
18
+
19
+ def report(error, source:)
20
+ message = "[#{source}] #{error.class}: #{error.message}"
21
+ logger ? logger.error(message) : warn(message)
22
+
23
+ rails_reporter&.report(error, handled: true, source: source)
24
+ end
25
+
26
+ def logger
27
+ ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
28
+ end
29
+
30
+ def rails_reporter
31
+ ::Rails.error if defined?(::Rails) && ::Rails.respond_to?(:error)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_pod_kit/config'
4
+
5
+ module RailsPodKit
6
+ # The in-process WEBrick /metrics server, shared by every non-Puma entry
7
+ # point: the Sidekiq worker, the Rails-free global exporter and the SolidQueue
8
+ # exporter. Under Puma the exporter comes from the `:yabeda_prometheus` plugin
9
+ # instead (see RailsPodKit::Puma), so this is never used there.
10
+ module Exporter
11
+ module_function
12
+
13
+ # Starts the background exporter and returns whether it did. Idempotent: the
14
+ # latch keeps a re-entrant boot from double-binding the port.
15
+ def start!
16
+ return false unless RailsPodKit.enabled?
17
+ return false if @started
18
+
19
+ require 'yabeda/prometheus/mmap'
20
+
21
+ # `start_metrics_server!` reads the bind port from the env, so publish the
22
+ # configured one first.
23
+ ENV['PROMETHEUS_EXPORTER_PORT'] ||= RailsPodKit.config.port.to_s
24
+ # Drop the exporter's per-scrape access log (Rack::CommonLogger, which the
25
+ # mmap exporter mounts unless this is exactly 'false'). See
26
+ # Config#silence_exporter_access_log.
27
+ ENV['PROMETHEUS_EXPORTER_LOG_REQUESTS'] = 'false' if RailsPodKit.config.silence_exporter_access_log
28
+
29
+ Yabeda::Prometheus::Exporter.start_metrics_server!
30
+ @started = true
31
+ end
32
+
33
+ def started?
34
+ !!@started
35
+ end
36
+
37
+ # Test/reset hook — drops the "already started" latch.
38
+ def reset!
39
+ @started = false
40
+ end
41
+ end
42
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rails_pod_kit/config'
4
+ require 'rails_pod_kit/exporter'
4
5
 
5
6
  module RailsPodKit
6
7
  # Sidekiq integration. Called from inside `Sidekiq.configure_server`:
@@ -81,19 +82,11 @@ module RailsPodKit
81
82
  apply_retries_segmentation!
82
83
  end
83
84
 
84
- # Starts the background WEBrick exporter. `start_metrics_server!` reads the
85
- # bind port from PROMETHEUS_EXPORTER_PORT, so we set it from config first.
86
- # Guarded so a re-entrant Sidekiq boot doesn't try to double-bind the port.
85
+ # Starts the background WEBrick exporter shared with the other non-Puma
86
+ # entry points; guarded there so a re-entrant Sidekiq boot can't double-bind
87
+ # the port.
87
88
  def start_metrics_server!
88
- return if @server_started
89
-
90
- ENV['PROMETHEUS_EXPORTER_PORT'] ||= RailsPodKit.config.port.to_s
91
- # Drop the WEBrick exporter's per-scrape access log (Rack::CommonLogger,
92
- # which the mmap exporter mounts unless this is exactly 'false'). See
93
- # Config#silence_exporter_access_log.
94
- ENV['PROMETHEUS_EXPORTER_LOG_REQUESTS'] = 'false' if RailsPodKit.config.silence_exporter_access_log
95
- Yabeda::Prometheus::Exporter.start_metrics_server!
96
- @server_started = true
89
+ RailsPodKit::Exporter.start!
97
90
  end
98
91
  end
99
92
  end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_pod_kit/config'
4
+ require 'rails_pod_kit/error_reporter'
5
+
6
+ module RailsPodKit
7
+ module SolidQueue
8
+ # DB-backed queue gauges for SolidQueue, in the shape yabeda-sidekiq exposes
9
+ # for Redis: SolidQueue ships no metrics endpoint and there is no
10
+ # `yabeda-solid_queue`.
11
+ #
12
+ # Two series, both per `queue`, both computed at scrape time from a yabeda
13
+ # `collect` block — no background thread, no cached snapshot:
14
+ #
15
+ # solid_queue_backlog how many jobs could be claimed right now
16
+ # solid_queue_latency_seconds how long the oldest of them has been waiting
17
+ #
18
+ # "Claimable right now" is ready executions plus scheduled ones whose time
19
+ # has come (the dispatcher has yet to move them across). Backlog alone misses
20
+ # a small-but-stalled queue and latency alone misses a large-but-moving one,
21
+ # so the pair is what dashboards, alerts and an HPA feed actually need.
22
+ #
23
+ # `::SolidQueue` is the host's — the gem declares no dependency on it and
24
+ # nothing here loads until the host calls `install!`.
25
+ module Metrics
26
+ SOURCE = 'rails_pod_kit.solid_queue_metrics'
27
+
28
+ module_function
29
+
30
+ # Declares the gauges and registers the scrape-time collector. Safe before
31
+ # or after `Yabeda.configure!` (yabeda replays configurators either way).
32
+ #
33
+ # `queues:` pins the zero baseline (see #baseline_queues) instead of
34
+ # discovering it. `fail_scrape_on_error:` makes a collection failure fail
35
+ # the whole response rather than serve the last reading — right when this
36
+ # collector owns the endpoint, wrong when it shares one (see #collect!).
37
+ #
38
+ # Declaration is one-shot but the options are not: the dedicated pod boots
39
+ # the host's initializers before `run_exporter!` runs, so by the time the
40
+ # pod asks for `fail_scrape_on_error` the app's own `install_metrics!` has
41
+ # normally already declared the gauges. An option given here always wins;
42
+ # one left out keeps whatever an earlier call set.
43
+ def install!(queues: nil, fail_scrape_on_error: nil)
44
+ @baseline_queues = queues unless queues.nil?
45
+ @fail_scrape_on_error = fail_scrape_on_error unless fail_scrape_on_error.nil?
46
+
47
+ return false if @installed
48
+
49
+ require 'yabeda'
50
+ declare!
51
+ @installed = true
52
+ end
53
+
54
+ def declare!
55
+ Yabeda.configure do
56
+ group :solid_queue do
57
+ gauge :backlog,
58
+ tags: %i[queue],
59
+ comment: 'Jobs claimable right now: ready executions plus scheduled ones whose time has come'
60
+ gauge :latency,
61
+ unit: :seconds,
62
+ tags: %i[queue],
63
+ comment: 'How long the oldest claimable job has been waiting'
64
+
65
+ collect { RailsPodKit::SolidQueue::Metrics.collect! }
66
+ end
67
+ end
68
+ end
69
+
70
+ # Called by yabeda on every scrape.
71
+ #
72
+ # An error is always reported, and then either swallowed or re-raised
73
+ # depending on who owns the endpoint. Swallowing keeps a transient DB
74
+ # failure from taking the Puma series down with it on a shared endpoint —
75
+ # at the cost of serving the last reading, which a consumer cannot tell
76
+ # apart from a live one. On the dedicated pod (`run_exporter!`) there is
77
+ # nothing else to protect, so failing the scrape is the honest answer: the
78
+ # gauges go to no-data and the scraper's own `up` series carries the
79
+ # failure.
80
+ def collect!
81
+ now = ::Time.now.utc
82
+ with_connection { publish_all(claimable_by_queue(now), now) }
83
+ rescue StandardError => e
84
+ ErrorReporter.report(e, source: SOURCE)
85
+ raise if @fail_scrape_on_error
86
+ end
87
+
88
+ # Collection runs on the exporter's HTTP thread, which would otherwise
89
+ # check out a connection and pin it there for the life of the process.
90
+ def with_connection(&)
91
+ ::ActiveRecord::Base.connection_pool.with_connection(&)
92
+ end
93
+
94
+ # => { "default" => { backlog: 12, waiting_since: <Time> }, … }
95
+ def claimable_by_queue(now)
96
+ merge(ready_rows, due_rows(now))
97
+ end
98
+
99
+ def ready_rows
100
+ rows(::SolidQueue::ReadyExecution.all, :created_at)
101
+ end
102
+
103
+ # A scheduled execution whose time has come is claimable too. Its wait
104
+ # started at `scheduled_at`, not `created_at` — a job enqueued a week ahead
105
+ # of its slot is not a week late.
106
+ def due_rows(now)
107
+ rows(::SolidQueue::ScheduledExecution.where(scheduled_at: ..now), :scheduled_at)
108
+ end
109
+
110
+ # Two grouped aggregates over an indexed, normally-small table. Both go
111
+ # through ActiveRecord's calculations so the timestamp comes back
112
+ # type-cast on every adapter.
113
+ def rows(relation, waiting_since_column)
114
+ counts = relation.group(:queue_name).count
115
+ oldest = relation.group(:queue_name).minimum(waiting_since_column)
116
+
117
+ counts.map { |queue, count| [queue, count, oldest[queue]] }
118
+ end
119
+
120
+ def merge(*row_sets)
121
+ row_sets.flatten(1).each_with_object({}) do |(queue, count, waiting_since), acc|
122
+ entry = acc[queue] ||= { backlog: 0, waiting_since: nil }
123
+ entry[:backlog] += count
124
+ entry[:waiting_since] = [entry[:waiting_since], waiting_since].compact.min
125
+ end
126
+ end
127
+
128
+ def publish_all(queues, now)
129
+ queues.each do |queue, entry|
130
+ publish(queue, backlog: entry[:backlog], latency: age_in_seconds(entry[:waiting_since], now))
131
+ end
132
+
133
+ zero_drained_queues(queues.keys)
134
+ end
135
+
136
+ def publish(queue, backlog:, latency:)
137
+ Yabeda.solid_queue.backlog.set({ queue: queue }, backlog)
138
+ Yabeda.solid_queue.latency.set({ queue: queue }, latency)
139
+ end
140
+
141
+ # A gauge keeps its last value per label set, so a queue that just drained
142
+ # would stay pinned at its final backlog forever — the exact reading that
143
+ # would keep an alert firing on an idle system. Track the label sets this
144
+ # process has published and zero the ones missing from this round.
145
+ def zero_drained_queues(current)
146
+ seen = seen_queues
147
+ (seen - current).each { |queue| publish(queue, backlog: 0, latency: 0) }
148
+ @seen_queues = seen | current
149
+ end
150
+
151
+ # Seeded with the baseline, so the zeroing above also covers queues this
152
+ # process has never seen busy. A gauge only exists once it has been set:
153
+ # without the seed an exporter that boots while the queue is empty — the
154
+ # steady state of a scale-to-zero deployment — publishes no series at all,
155
+ # and every consumer reads no-data where it should read 0.
156
+ def seen_queues
157
+ @seen_queues ||= baseline_queues
158
+ end
159
+
160
+ # The queues the app is known to use, pinned by the host or discovered
161
+ # once per process from the jobs table (one index scan, never repeated).
162
+ # Discovery is best-effort by construction: that table is bounded by
163
+ # `clear_finished_jobs_after`, so a queue idle for longer than the
164
+ # retention window leaves no trace. Pin `queues:` where the zero has to be
165
+ # guaranteed.
166
+ def baseline_queues
167
+ @baseline_queues || ::SolidQueue::Job.distinct.pluck(:queue_name).compact
168
+ end
169
+
170
+ def age_in_seconds(waiting_since, now)
171
+ return 0 if waiting_since.nil?
172
+
173
+ [(now - waiting_since).to_f, 0].max
174
+ end
175
+
176
+ # Test/reset hook — drops the published-label-set memo and the install
177
+ # options.
178
+ def reset!
179
+ @seen_queues = nil
180
+ @baseline_queues = nil
181
+ @fail_scrape_on_error = false
182
+ @installed = false
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+ require 'active_support/configuration_file'
5
+ require 'active_support/core_ext/hash/keys'
6
+
7
+ require 'rails_pod_kit/config'
8
+ require 'rails_pod_kit/error_reporter'
9
+
10
+ module RailsPodKit
11
+ module SolidQueue
12
+ # Runs a SolidQueue *scheduler* — and only the scheduler — as a supervised
13
+ # background thread, so the process running jobs can scale to zero.
14
+ #
15
+ # Scaling the executor to zero is otherwise a chicken-and-egg problem: with
16
+ # no executor there is no scheduler, so nothing enqueues the recurring or
17
+ # scheduled jobs that would wake one. A k8s CronJob can't take over either,
18
+ # because it can't own *dynamic* recurring tasks (created and updated at
19
+ # runtime through `SolidQueue.schedule_recurring_task`). Moving just the
20
+ # scheduler onto an always-on process — the web, or the exporter pod —
21
+ # breaks the cycle: it keeps evaluating the crons and enqueueing, and the
22
+ # queue depth wakes the executor.
23
+ #
24
+ # Deliberately *not* the full supervisor (`plugin :solid_queue`): that one
25
+ # forks, and its Puma watchdog takes the host process down when the
26
+ # supervisor exits — which a transient Postgres disconnect is enough to
27
+ # cause (rails/solid_queue#512). Here a DB blip at worst kills the scheduler
28
+ # thread; the supervising timer notices on its next tick and starts a fresh
29
+ # one, and the host process never notices.
30
+ #
31
+ # Running it on every replica is safe: enqueues stay exactly-once via the
32
+ # unique index on `solid_queue_recurring_executions (task_key, run_at)`.
33
+ class SchedulerRunner
34
+ # How often the scheduler re-reads the dynamic tasks from the DB.
35
+ DEFAULT_POLLING_INTERVAL = 5
36
+ # How often we check that the scheduler thread is still alive.
37
+ DEFAULT_SUPERVISION_INTERVAL = 5
38
+
39
+ SOURCE = 'rails_pod_kit.solid_queue_scheduler'
40
+
41
+ def initialize(polling_interval: DEFAULT_POLLING_INTERVAL,
42
+ supervision_interval: DEFAULT_SUPERVISION_INTERVAL,
43
+ recurring_schedule_file: nil)
44
+ @polling_interval = polling_interval
45
+ @supervision_interval = supervision_interval
46
+ @recurring_schedule_file = recurring_schedule_file
47
+ @stopping = false
48
+ end
49
+
50
+ # Starts the supervisor, which starts the scheduler on its first (immediate)
51
+ # tick and returns without blocking.
52
+ def start
53
+ @supervisor = ::Concurrent::TimerTask.new(
54
+ execution_interval: @supervision_interval,
55
+ run_now: true
56
+ ) { supervise }
57
+ @supervisor.execute
58
+ self
59
+ end
60
+
61
+ # Graceful stop: drop the supervisor first so it can't resurrect the
62
+ # scheduler, then wind the scheduler down (unschedule its timers and
63
+ # deregister the process, instead of leaving a row to expire).
64
+ def stop
65
+ @stopping = true
66
+ @supervisor&.shutdown
67
+ @supervisor = nil
68
+ @scheduler&.stop
69
+ @scheduler = nil
70
+ end
71
+
72
+ def running?
73
+ !!@scheduler&.alive?
74
+ end
75
+
76
+ private
77
+
78
+ # Concurrent::TimerTask silently drops a raising block, so report here and
79
+ # let the next tick retry — a scheduler that can't start because Postgres
80
+ # is down must keep trying.
81
+ def supervise
82
+ ensure_scheduler_running
83
+ rescue StandardError => e
84
+ ErrorReporter.report(e, source: SOURCE)
85
+ end
86
+
87
+ # Idempotent: starts the scheduler on the first tick, and restarts it only
88
+ # once its thread has actually died.
89
+ def ensure_scheduler_running
90
+ return if @stopping || @scheduler&.alive?
91
+
92
+ scheduler = ::SolidQueue::Scheduler.new(
93
+ recurring_tasks: static_recurring_tasks,
94
+ dynamic_tasks_enabled: true,
95
+ polling_interval: @polling_interval
96
+ )
97
+ scheduler.mode = :async
98
+ scheduler.start # spawns the scheduler's own thread and returns
99
+ @scheduler = scheduler
100
+ end
101
+
102
+ # The static tasks from config/recurring.yml; the dynamic ones come from
103
+ # the DB via `dynamic_tasks_enabled`. `SolidQueue::Configuration#recurring_tasks`
104
+ # is private, so parse the file with the same public helper it uses.
105
+ def static_recurring_tasks
106
+ path = recurring_schedule_file
107
+ return [] unless path && ::File.exist?(path)
108
+
109
+ config = ::ActiveSupport::ConfigurationFile.parse(path).deep_symbolize_keys
110
+ config.fetch(environment.to_sym, {}).filter_map do |key, options|
111
+ ::SolidQueue::RecurringTask.from_configuration(key, **options) if options&.key?(:schedule)
112
+ end
113
+ end
114
+
115
+ # Same resolution order SolidQueue's own CLI uses.
116
+ def recurring_schedule_file
117
+ return @recurring_schedule_file if @recurring_schedule_file
118
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
119
+
120
+ ::Rails.root.join(ENV.fetch('SOLID_QUEUE_RECURRING_SCHEDULE', 'config/recurring.yml'))
121
+ end
122
+
123
+ def environment
124
+ return ::Rails.env if defined?(::Rails) && ::Rails.respond_to?(:env)
125
+
126
+ ENV.fetch('RAILS_ENV', 'development')
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_pod_kit/config'
4
+ require 'rails_pod_kit/exporter'
5
+ require 'rails_pod_kit/solid_queue/metrics'
6
+ require 'rails_pod_kit/solid_queue/scheduler_runner'
7
+
8
+ module RailsPodKit
9
+ # SolidQueue integration — the pieces a Rails 8 app needs to run its job
10
+ # executor with scale-to-zero autoscaling (KEDA/HPA). All three are opt-in;
11
+ # requiring this file only defines them.
12
+ #
13
+ # install_metrics! the `solid_queue_backlog` / `solid_queue_latency_seconds`
14
+ # gauges on /metrics (see Metrics)
15
+ # start_scheduler! the SolidQueue scheduler as a supervised background
16
+ # thread on an always-on process (see SchedulerRunner)
17
+ # run_exporter! both of the above in a dedicated always-on pod, with
18
+ # the /metrics server, blocking until SIGTERM
19
+ #
20
+ # Nothing here loads `solid_queue` itself: it is the host's dependency, the
21
+ # same way Puma and Sidekiq are.
22
+ #
23
+ # Wiring the two halves separately, when the always-on process is the web:
24
+ #
25
+ # # config/initializers/rails_pod_kit.rb
26
+ # RailsPodKit::SolidQueue.install_metrics!
27
+ #
28
+ # # config/puma.rb — after_booted only runs in the real Puma process,
29
+ # # never in a console, a rake task or the test suite.
30
+ # after_booted { RailsPodKit::SolidQueue.start_scheduler! }
31
+ # at_exit { RailsPodKit::SolidQueue.stop_scheduler! }
32
+ module SolidQueue
33
+ module_function
34
+
35
+ # Declares the queue gauges. Call from an initializer, in whichever process
36
+ # should publish them — see the README on keeping one source per series.
37
+ #
38
+ # Options are Metrics.install!'s: `queues:` to pin the zero baseline,
39
+ # `fail_scrape_on_error:` to fail the scrape instead of serving the last
40
+ # reading when the DB is unreachable (the default only holds on an endpoint
41
+ # this collector shares with another group).
42
+ def install_metrics!(**)
43
+ Metrics.install!(**)
44
+ end
45
+
46
+ # Starts the supervised scheduler thread and returns the runner. Idempotent:
47
+ # a second call returns the running one rather than starting a second
48
+ # scheduler in the same process.
49
+ #
50
+ # Not gated on `RailsPodKit.enabled?` — that switch owns the metrics
51
+ # exporter, and an app may well want the scheduler with metrics turned off.
52
+ # The guard against starting one in a console or in specs is *where* you
53
+ # call this from (`after_booted`, or the exporter entrypoint).
54
+ def start_scheduler!(**)
55
+ return scheduler_runner if scheduler_runner
56
+
57
+ @scheduler_runner = SchedulerRunner.new(**).start
58
+ end
59
+
60
+ def stop_scheduler!
61
+ @scheduler_runner&.stop
62
+ @scheduler_runner = nil
63
+ end
64
+
65
+ def scheduler_runner
66
+ @scheduler_runner
67
+ end
68
+
69
+ # Runs the dedicated always-on SolidQueue pod and blocks until SIGTERM:
70
+ # the queue gauges, the scheduler thread and the /metrics server in one
71
+ # 1-replica Deployment, so both survive the web and the executor scaling to
72
+ # zero and each series has exactly one source.
73
+ #
74
+ # Unlike GlobalExporter this one is *not* Rails-free — SolidQueue is
75
+ # ActiveRecord-backed and reads the app's own tables — so the host's
76
+ # entrypoint boots the environment first, e.g. `bin/solid-queue-pod`:
77
+ #
78
+ # #!/usr/bin/env ruby
79
+ # require_relative '../config/environment'
80
+ # RailsPodKit::SolidQueue.run_exporter!
81
+ #
82
+ # Pass `scheduler: false` to serve the gauges only (an app whose web process
83
+ # already hosts the scheduler), and `metrics:` to override the gauge options
84
+ # — this endpoint is the collector's own, so a collection failure fails the
85
+ # scrape here rather than serving the last reading.
86
+ def run_exporter!(scheduler: true, metrics: {}, **scheduler_options)
87
+ install_metrics!(fail_scrape_on_error: true, **metrics)
88
+ start_scheduler!(**scheduler_options) if scheduler
89
+
90
+ warn '[rails_pod_kit] disabled — /metrics not served by the SolidQueue exporter' unless Exporter.start!
91
+
92
+ require 'yabeda'
93
+ Yabeda.configure! unless Yabeda.already_configured?
94
+
95
+ await_shutdown
96
+ stop_scheduler!
97
+ end
98
+
99
+ # Blocks the main thread (the exporter and the scheduler both run on their
100
+ # own) until the kubelet signals. A self-pipe rather than a Queue or a
101
+ # Mutex: writing to an IO is one of the few things safe to do from a trap
102
+ # handler.
103
+ def await_shutdown
104
+ reader, writer = IO.pipe
105
+ %w[INT TERM].each { |signal| Signal.trap(signal) { writer.puts(signal) } }
106
+ reader.gets
107
+ end
108
+ end
109
+ end
data/lib/rails_pod_kit.rb CHANGED
@@ -6,9 +6,10 @@ require 'rails_pod_kit/config'
6
6
  # RailsPodKit packages the operational endpoints a Rails pod needs to be a good
7
7
  # Kubernetes citizen behind a single, opinionated entry point:
8
8
  #
9
- # - Prometheus metrics: Puma and Sidekiq runtime metrics in Prometheus text
10
- # format on an in-process /metrics endpoint (default port 9394) — no sidecar,
11
- # no separate collector process. A thin wrapper around the yabeda ecosystem.
9
+ # - Prometheus metrics: Puma, Sidekiq and SolidQueue runtime metrics in
10
+ # Prometheus text format on an in-process /metrics endpoint (default port
11
+ # 9394) — no sidecar, no separate collector process. A thin wrapper around the
12
+ # yabeda ecosystem, plus the SolidQueue queue gauges yabeda has no plugin for.
12
13
  # - Health checks: an opinionated health-monitor-rails configuration serving
13
14
  # liveness/readiness/startup probes on /healthz (see RailsPodKit::Health),
14
15
  # auto-mounted by the gem's Railtie.
@@ -22,8 +23,9 @@ require 'rails_pod_kit/config'
22
23
  # - config/initializers/sidekiq.rb -> RailsPodKit::Sidekiq.install!(config)
23
24
  # - config/initializers/rails_pod_kit.rb -> RailsPodKit.configure { ... }
24
25
  # RailsPodKit::Health.install!(redis: ...)
25
- # plus, only when running the dedicated global exporter, a host-owned
26
- # entrypoint (e.g. bin/pod-exporter) calling GlobalExporter.run!(redis: ...).
26
+ # plus, only when running a dedicated always-on exporter, a host-owned
27
+ # entrypoint calling GlobalExporter.run!(redis: ...) (Sidekiq, Rails-free) or
28
+ # SolidQueue.run_exporter! (SolidQueue, needs the app's ActiveRecord models).
27
29
  #
28
30
  # The gem is deliberately connection-agnostic: it never reads REDIS_URL or
29
31
  # makes TLS decisions. The host injects its Redis options where needed
@@ -57,5 +59,6 @@ require 'rails_pod_kit/config'
57
59
  require 'yabeda/prometheus/mmap'
58
60
 
59
61
  require 'rails_pod_kit/sidekiq'
62
+ require 'rails_pod_kit/solid_queue'
60
63
  require 'rails_pod_kit/health'
61
64
  require 'rails_pod_kit/railtie'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_pod_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.3
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Fabio Napoleoni
@@ -93,6 +93,20 @@ dependencies:
93
93
  - - "~>"
94
94
  - !ruby/object:Gem::Version
95
95
  version: '2.0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: concurrent-ruby
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '1.2'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '1.2'
96
110
  - !ruby/object:Gem::Dependency
97
111
  name: health-monitor-rails
98
112
  requirement: !ruby/object:Gem::Requirement
@@ -123,10 +137,12 @@ dependencies:
123
137
  version: '5.0'
124
138
  description: |
125
139
  Packages the yabeda ecosystem and health-monitor-rails into a single,
126
- opinionated kit for running Rails applications on Kubernetes: Puma and
127
- Sidekiq runtime metrics in Prometheus text format on an in-process /metrics
128
- endpoint (default port 9394), plus a /healthz endpoint wired for
129
- liveness/readiness/startup probes. No sidecar, no separate collector.
140
+ opinionated kit for running Rails applications on Kubernetes: Puma, Sidekiq
141
+ and SolidQueue runtime metrics in Prometheus text format on an in-process
142
+ /metrics endpoint (default port 9394), plus a /healthz endpoint wired for
143
+ liveness/readiness/startup probes. No sidecar, no separate collector. For
144
+ SolidQueue it also ships the supervised scheduler thread that lets the job
145
+ executor scale to zero.
130
146
  email:
131
147
  - f.napoleoni@gmail.com
132
148
  executables: []
@@ -138,11 +154,16 @@ files:
138
154
  - VERSION
139
155
  - lib/rails_pod_kit.rb
140
156
  - lib/rails_pod_kit/config.rb
157
+ - lib/rails_pod_kit/error_reporter.rb
158
+ - lib/rails_pod_kit/exporter.rb
141
159
  - lib/rails_pod_kit/global_exporter.rb
142
160
  - lib/rails_pod_kit/health.rb
143
161
  - lib/rails_pod_kit/puma.rb
144
162
  - lib/rails_pod_kit/railtie.rb
145
163
  - lib/rails_pod_kit/sidekiq.rb
164
+ - lib/rails_pod_kit/solid_queue.rb
165
+ - lib/rails_pod_kit/solid_queue/metrics.rb
166
+ - lib/rails_pod_kit/solid_queue/scheduler_runner.rb
146
167
  - lib/rails_pod_kit/version.rb
147
168
  homepage: https://github.com/fabn/rails_pod_kit
148
169
  licenses:
@@ -167,6 +188,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
167
188
  requirements: []
168
189
  rubygems_version: 3.6.9
169
190
  specification_version: 4
170
- summary: 'Operational endpoints for Rails pods: Prometheus metrics (Puma + Sidekiq)
171
- and health checks'
191
+ summary: 'Operational endpoints for Rails pods: Prometheus metrics (Puma, Sidekiq,
192
+ SolidQueue) and health checks'
172
193
  test_files: []