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 +4 -4
- data/README.md +207 -23
- data/VERSION +1 -1
- data/lib/rails_pod_kit/error_reporter.rb +34 -0
- data/lib/rails_pod_kit/exporter.rb +42 -0
- data/lib/rails_pod_kit/sidekiq.rb +5 -12
- data/lib/rails_pod_kit/solid_queue/metrics.rb +186 -0
- data/lib/rails_pod_kit/solid_queue/scheduler_runner.rb +130 -0
- data/lib/rails_pod_kit/solid_queue.rb +109 -0
- data/lib/rails_pod_kit.rb +8 -5
- metadata +28 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 72d44d2c9e2129f3ca4ae021cbc10a227838b5998a80fafc6bbcd8efd18f80d0
|
|
4
|
+
data.tar.gz: 1c591dcbf8dccf4dcb608483311fbff89363540a25f7a629c6aaa6cda34f6372
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
7
|
-
single `/metrics` endpoint (default port **9394**) in
|
|
8
|
-
worker
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
26
|
+
| `webrick` | HTTP server for the non-Puma exporters |
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 —
|
|
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,
|
|
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 `:
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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.
|
|
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
|
|
85
|
-
#
|
|
86
|
-
#
|
|
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
|
-
|
|
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
|
|
10
|
-
# format on an in-process /metrics endpoint (default port
|
|
11
|
-
# no separate collector process. A thin wrapper around the
|
|
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
|
|
26
|
-
# entrypoint
|
|
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.
|
|
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
|
|
127
|
-
|
|
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
|
|
171
|
-
and health checks'
|
|
191
|
+
summary: 'Operational endpoints for Rails pods: Prometheus metrics (Puma, Sidekiq,
|
|
192
|
+
SolidQueue) and health checks'
|
|
172
193
|
test_files: []
|