rails_pod_kit 0.1.1 → 0.2.0
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 +85 -10
- data/VERSION +1 -1
- data/lib/rails_pod_kit/config.rb +28 -2
- data/lib/rails_pod_kit/global_exporter.rb +27 -9
- data/lib/rails_pod_kit/global_scheduler.rb +156 -0
- data/lib/rails_pod_kit/shutdown.rb +18 -0
- data/lib/rails_pod_kit/solid_queue/scheduler_runner.rb +17 -29
- data/lib/rails_pod_kit/solid_queue.rb +6 -7
- data/lib/rails_pod_kit/supervisor.rb +90 -0
- metadata +8 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 85fea66a44c5b2198b5c3c7bfd05f5f1f4c3655559ed701748661d0e148650a5
|
|
4
|
+
data.tar.gz: 9c921528f839ea1d5b962035600316ea37affe17cf8fdc910453c30108a248dd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d7fbfee31ce338a6dd7c11d65214dc442cd2cb261848ae2403a415dc4a6e1333fa55b39e2d06441b4e05fcacff2ec3971ee009613a532f384fcc19fbe84f070d
|
|
7
|
+
data.tar.gz: 894f99f9fba4596aa7d69e143f5fa23d2ebc64ef85f6b7cac310096c902005dd003103cb12c3933e49dfe26263c68f9656cfc3e68c0d9551a8f118e79496fd48
|
data/README.md
CHANGED
|
@@ -11,8 +11,9 @@ packaged behind a single, opinionated entry point:
|
|
|
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
|
-
- **
|
|
15
|
-
|
|
14
|
+
- **Scheduler hosting for scale-to-zero**, so a job executor can be autoscaled to
|
|
15
|
+
zero without stranding its recurring and scheduled jobs: a supervised
|
|
16
|
+
SolidQueue scheduler thread, and a supervised sidekiq-cron poller for Sidekiq.
|
|
16
17
|
|
|
17
18
|
The metrics side is a thin wrapper around the
|
|
18
19
|
[yabeda](https://github.com/yabeda-rb) ecosystem:
|
|
@@ -81,13 +82,15 @@ On a SolidQueue stack there is no step 3 — see
|
|
|
81
82
|
config: besides the `RailsPodKit.configure` block (which runs last and wins),
|
|
82
83
|
every setting can come from a `RAILS_POD_KIT_*` env var (e.g.
|
|
83
84
|
`RAILS_POD_KIT_ENABLED=false`, `RAILS_POD_KIT_PORT=9500`,
|
|
84
|
-
`RAILS_POD_KIT_SIDEKIQ_GLOBAL_METRICS=off
|
|
85
|
+
`RAILS_POD_KIT_SIDEKIQ_GLOBAL_METRICS=off`,
|
|
86
|
+
`RAILS_POD_KIT_SCHEDULER_ENABLED=false`) or an optional
|
|
85
87
|
`config/rails_pod_kit.yml` — handy for the Rails-free exporter pod, which runs
|
|
86
88
|
no initializers.
|
|
87
89
|
|
|
88
90
|
| setting | default | meaning |
|
|
89
91
|
|---------|---------|---------|
|
|
90
|
-
| `enabled` | on, except in `test` | master switch
|
|
92
|
+
| `enabled` | on, except in `test` | master switch for the **exporter**; `false` ⇒ no exporter, no port bound |
|
|
93
|
+
| `scheduler_enabled` | on, everywhere | kill switch for the **hosted schedulers** (sidekiq-cron poller, SolidQueue scheduler thread). Separate from `enabled` — an app may want one without the other, and this is the one you may need to flip in a hurry, since the scheduler is a single point of failure for the whole schedule. On even in `test`: nothing starts a scheduler implicitly, so there is no port to protect, and a switch that failed closed on a typo would silently stop a schedule. Turning it off logs a warning naming the scheduler that did not start. |
|
|
91
94
|
| `port` | `9394` (env `PROMETHEUS_EXPORTER_PORT`) | exporter bind port for Puma **and** Sidekiq |
|
|
92
95
|
| `sidekiq_global_metrics` | `:web` | who exports the Redis-wide queue metrics: `:web` = only the always-on web process (no per-worker duplication); `:all` = every worker; `:off` = nobody |
|
|
93
96
|
| `puma_control_url` | `tcp://127.0.0.1:9293` (env `PUMA_CONTROL_URL`) | localhost-only Puma control app the stats reader queries |
|
|
@@ -348,6 +351,73 @@ declares the cluster gauges, starts the exporter and blocks until SIGTERM.
|
|
|
348
351
|
Booting the full host app just to read a handful of Redis counters would cost
|
|
349
352
|
~300Mi RSS for nothing — this process sits at ~60Mi.
|
|
350
353
|
|
|
354
|
+
## Sidekiq: scale-to-zero
|
|
355
|
+
|
|
356
|
+
Being an always-on singleton makes that same pod the right home for the
|
|
357
|
+
**sidekiq-cron poller**, which is what lets the worker fleet scale to zero.
|
|
358
|
+
|
|
359
|
+
sidekiq-cron installs its poller from inside `Sidekiq.configure_server`, so on
|
|
360
|
+
its own the schedule exists only while a Sidekiq server is alive. At zero
|
|
361
|
+
replicas nothing polls, nothing is enqueued, and nothing ever raises the queue
|
|
362
|
+
depth that would wake a worker back up — a closed loop that forces a permanent
|
|
363
|
+
floor of one replica just to keep a poller alive. Missed runs are not caught up
|
|
364
|
+
afterwards either: `reschedule_grace_period` (60s by default) discards any run
|
|
365
|
+
older than itself.
|
|
366
|
+
|
|
367
|
+
The poller has no such requirement of its own — `Sidekiq::Cron::Poller` is a
|
|
368
|
+
Redis-polling thread that runs in any process holding a Sidekiq config — so
|
|
369
|
+
`scheduler: true` hosts it here:
|
|
370
|
+
|
|
371
|
+
```ruby
|
|
372
|
+
RailsPodKit::GlobalExporter.run!(
|
|
373
|
+
redis: { url: ENV['REDIS_URL'] },
|
|
374
|
+
scheduler: true,
|
|
375
|
+
schedule_file: File.expand_path('../config/schedule.yml', __dir__)
|
|
376
|
+
)
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
`schedule_file:`, `poll_interval:` and `reschedule_grace_period:` override
|
|
380
|
+
sidekiq-cron's defaults (`config/schedule.yml` resolved against the working
|
|
381
|
+
directory, polled every 30s, catching up runs at most 60s late);
|
|
382
|
+
`supervision_interval:` tunes the liveness check. `RailsPodKit::GlobalScheduler`
|
|
383
|
+
is usable on its own (`start!` / `stop!`) if the always-on process is something
|
|
384
|
+
other than the exporter.
|
|
385
|
+
|
|
386
|
+
> **Size `reschedule_grace_period` over your worst restart.** It is what makes
|
|
387
|
+
> restarting the *only* scheduling process free: below it a missed occurrence is
|
|
388
|
+
> caught up on the next poll, above it the run is skipped silently. sidekiq-cron
|
|
389
|
+
> defaults to 60s, which a node drain or an evicted pod can easily exceed —
|
|
390
|
+
> rolling updates are covered anyway, since a `maxSurge` overlap means there is
|
|
391
|
+
> no gap at all. Catching up is bounded, not repeated: `last_enqueue_time` in
|
|
392
|
+
> Redis still gates each occurrence to exactly one enqueue.
|
|
393
|
+
>
|
|
394
|
+
> This is the reason a singleton scheduler does **not** need to become an HA
|
|
395
|
+
> pair. A second replica is safe for the poller (same `zadd` lock) but doubles
|
|
396
|
+
> every metric series the pod publishes, and a `PodDisruptionBudget` on a
|
|
397
|
+
> single-replica Deployment stalls node drains rather than protecting anything.
|
|
398
|
+
|
|
399
|
+
The poller runs under `RailsPodKit::Supervisor` — the same supervising timer
|
|
400
|
+
that keeps the SolidQueue scheduler thread alive, since both share the failure
|
|
401
|
+
mode: the thread dies, the host process notices nothing, and the schedule stops
|
|
402
|
+
silently. The cron poller's own loop swallows StandardError, so a Redis blip
|
|
403
|
+
costs one skipped tick; the supervisor makes anything it does *not* catch a
|
|
404
|
+
skipped tick too.
|
|
405
|
+
|
|
406
|
+
> **Every schedule entry must declare `active_job: true`.** This process has no
|
|
407
|
+
> Rails, so it cannot resolve the job classes; sidekiq-cron then falls back to
|
|
408
|
+
> pushing a raw message, and only that flag makes the message an ActiveJob
|
|
409
|
+
> wrapper (naming the class as a *string*, which the worker resolves). Without it
|
|
410
|
+
> the job is pushed as a bare Sidekiq job and runs outside ActiveJob entirely.
|
|
411
|
+
> `start!` logs a warning naming any entry in that state. For the same reason the
|
|
412
|
+
> schedule file's ERB must not reach for Rails.
|
|
413
|
+
|
|
414
|
+
Leaving the workers' own poller in place is fine and costs nothing: enqueueing is
|
|
415
|
+
gated on a Redis `zadd` that exactly one caller wins — the same lock that already
|
|
416
|
+
lets multiple worker replicas coexist without double-firing. Both processes must
|
|
417
|
+
then read the *same* schedule file, though: `load_from_hash!` removes the
|
|
418
|
+
schedule-sourced jobs that are absent from the file it is given, so two processes
|
|
419
|
+
loading different files will delete each other's entries.
|
|
420
|
+
|
|
351
421
|
## SolidQueue: scale-to-zero
|
|
352
422
|
|
|
353
423
|
SolidQueue's executor has nothing to do while the queue is empty, so it is the
|
|
@@ -378,10 +448,11 @@ This is deliberately **not** `plugin :solid_queue`. That one runs the full
|
|
|
378
448
|
supervisor, which forks and whose watchdog takes Puma down when the supervisor
|
|
379
449
|
exits — and a transient Postgres disconnect is enough to cause that
|
|
380
450
|
([rails/solid_queue#512](https://github.com/rails/solid_queue/issues/512)). Here
|
|
381
|
-
a DB blip at worst kills the scheduler thread;
|
|
382
|
-
same primitive SolidQueue supervises its own
|
|
383
|
-
|
|
384
|
-
|
|
451
|
+
a DB blip at worst kills the scheduler thread; `RailsPodKit::Supervisor` — a
|
|
452
|
+
`Concurrent::TimerTask`, the same primitive SolidQueue supervises its own
|
|
453
|
+
processes with, and the same one that keeps the sidekiq-cron poller alive —
|
|
454
|
+
notices on the next tick and starts a fresh one, the process itself never
|
|
455
|
+
notices, and the scheduler re-registers on recovery.
|
|
385
456
|
|
|
386
457
|
Running it on every replica is safe: enqueues stay exactly-once via the unique
|
|
387
458
|
index on `solid_queue_recurring_executions (task_key, run_at)`. Static tasks come
|
|
@@ -395,8 +466,10 @@ ones from the DB.
|
|
|
395
466
|
| `recurring_schedule_file` | `config/recurring.yml` | static task definitions; skipped when absent |
|
|
396
467
|
|
|
397
468
|
`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.
|
|
399
|
-
|
|
469
|
+
exporter, and an app may well want the scheduler with metrics off.
|
|
470
|
+
`scheduler_enabled` is the switch that does own it, shared with the sidekiq-cron
|
|
471
|
+
poller. Beyond that, what keeps it out of consoles and specs is *where* you call
|
|
472
|
+
it from.
|
|
400
473
|
|
|
401
474
|
### 2. Queue depth has to be visible
|
|
402
475
|
|
|
@@ -493,6 +566,8 @@ serves `solid_queue_*` and nothing else, so the check config needs no filters.
|
|
|
493
566
|
a WEBrick thread instead, started at most once per process.
|
|
494
567
|
- **SolidQueue and Sidekiq are the host's.** The gem depends on neither; the
|
|
495
568
|
SolidQueue integration is inert until you call it, exactly like the Sidekiq one.
|
|
569
|
+
`sidekiq-cron` too: it is required only when `GlobalScheduler.start!` is called,
|
|
570
|
+
so an app that does not schedule anything need not carry it.
|
|
496
571
|
- **Queue-gauge query cost.** The gauges run four small grouped aggregates per
|
|
497
572
|
scrape. `MIN(created_at)` is not covered by SolidQueue's indexes, so on a
|
|
498
573
|
backlog of many thousands of rows it is a scan — cheap at a normal scrape
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.2.0
|
data/lib/rails_pod_kit/config.rb
CHANGED
|
@@ -60,17 +60,29 @@ module RailsPodKit
|
|
|
60
60
|
# transports — the Puma plugin's exporter (`prometheus_silence_logger`)
|
|
61
61
|
# and the WEBrick exporter used by Sidekiq / the dedicated global exporter
|
|
62
62
|
# (`Rack::CommonLogger`). Flip to false only to debug the exporter itself.
|
|
63
|
+
# scheduler_enabled: kill switch for the hosted schedulers (the sidekiq-cron
|
|
64
|
+
# poller and the SolidQueue scheduler thread). Separate from `enabled`,
|
|
65
|
+
# which owns the metrics exporter: an app may well want one without the
|
|
66
|
+
# other, and the scheduler is the piece you may need to turn off in a
|
|
67
|
+
# hurry — it is a single point of failure for the whole schedule, and
|
|
68
|
+
# `RAILS_POD_KIT_SCHEDULER_ENABLED=false` + a restart beats a deploy at
|
|
69
|
+
# 3am. Defaults to on *including* in the test env, unlike `enabled`:
|
|
70
|
+
# nothing starts a scheduler implicitly (it takes an explicit call from an
|
|
71
|
+
# entry point), so there is no port to protect, and a flag that fails
|
|
72
|
+
# closed on a typo would silently stop a schedule.
|
|
63
73
|
attr_config :enabled,
|
|
64
74
|
:port,
|
|
65
75
|
:puma_control_url,
|
|
66
76
|
sidekiq_global_metrics: :web,
|
|
67
77
|
retries_segmented_by_queue: false,
|
|
68
|
-
silence_exporter_access_log: true
|
|
78
|
+
silence_exporter_access_log: true,
|
|
79
|
+
scheduler_enabled: true
|
|
69
80
|
|
|
70
81
|
coerce_types port: :integer,
|
|
71
82
|
enabled: :boolean,
|
|
72
83
|
retries_segmented_by_queue: :boolean,
|
|
73
|
-
silence_exporter_access_log: :boolean
|
|
84
|
+
silence_exporter_access_log: :boolean,
|
|
85
|
+
scheduler_enabled: :boolean
|
|
74
86
|
|
|
75
87
|
def initialize(overrides = nil)
|
|
76
88
|
super
|
|
@@ -87,6 +99,10 @@ module RailsPodKit
|
|
|
87
99
|
!!enabled
|
|
88
100
|
end
|
|
89
101
|
|
|
102
|
+
def scheduler_enabled?
|
|
103
|
+
!!scheduler_enabled
|
|
104
|
+
end
|
|
105
|
+
|
|
90
106
|
# True unless we're clearly in a test environment. Kept independent of
|
|
91
107
|
# Rails so the gem's own specs (which don't load the host app) get a safe
|
|
92
108
|
# default and never bind a socket.
|
|
@@ -114,5 +130,15 @@ module RailsPodKit
|
|
|
114
130
|
def enabled?
|
|
115
131
|
config.enabled?
|
|
116
132
|
end
|
|
133
|
+
|
|
134
|
+
# Gate shared by both hosted schedulers. Says so out loud when it turns one
|
|
135
|
+
# off: a process that starts, stays up and quietly schedules nothing is the
|
|
136
|
+
# one failure this whole feature exists to avoid.
|
|
137
|
+
def scheduler_enabled?(what = 'scheduler')
|
|
138
|
+
return true if config.scheduler_enabled?
|
|
139
|
+
|
|
140
|
+
warn "[rails_pod_kit] scheduler_enabled=false — #{what} not started"
|
|
141
|
+
false
|
|
142
|
+
end
|
|
117
143
|
end
|
|
118
144
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'rails_pod_kit/config'
|
|
4
|
+
require 'rails_pod_kit/global_scheduler'
|
|
5
|
+
require 'rails_pod_kit/shutdown'
|
|
4
6
|
|
|
5
7
|
module RailsPodKit
|
|
6
8
|
# Standalone, always-on exporter for the Sidekiq global (Redis-wide) queue
|
|
@@ -26,6 +28,10 @@ module RailsPodKit
|
|
|
26
28
|
# require 'bundler/setup'
|
|
27
29
|
# require 'rails_pod_kit/global_exporter'
|
|
28
30
|
# RailsPodKit::GlobalExporter.run!(redis: { url: ENV['REDIS_URL'] })
|
|
31
|
+
#
|
|
32
|
+
# Being an always-on singleton also makes it the natural host for the
|
|
33
|
+
# sidekiq-cron poller (`scheduler: true`, see GlobalScheduler), which is what
|
|
34
|
+
# lets the workers scale to zero without losing their schedule.
|
|
29
35
|
module GlobalExporter
|
|
30
36
|
module_function
|
|
31
37
|
|
|
@@ -47,22 +53,34 @@ module RailsPodKit
|
|
|
47
53
|
# `redis:` takes the same options hash the host passes to its own
|
|
48
54
|
# `Sidekiq.configure_*` blocks (`url:`, `ssl_params:`, …), so the connection
|
|
49
55
|
# config stays a host decision with a single source of truth.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
#
|
|
57
|
+
# `scheduler: true` additionally runs the sidekiq-cron poller here; any
|
|
58
|
+
# extra keywords are GlobalScheduler.start!'s (`schedule_file:`,
|
|
59
|
+
# `poll_interval:`, `supervision_interval:`). The scheduler is not gated on
|
|
60
|
+
# `RailsPodKit.enabled?` —
|
|
61
|
+
# that switch owns the metrics exporter, and an app may well want the
|
|
62
|
+
# singleton scheduler with metrics turned off.
|
|
63
|
+
def run!(redis:, scheduler: false, **scheduler_options)
|
|
64
|
+
serve_metrics = RailsPodKit.enabled?
|
|
65
|
+
warn '[rails_pod_kit] disabled — /metrics not served by the global exporter' unless serve_metrics
|
|
66
|
+
return unless serve_metrics || scheduler
|
|
55
67
|
|
|
56
68
|
configure_redis!(redis)
|
|
69
|
+
start_metrics! if serve_metrics
|
|
70
|
+
GlobalScheduler.start!(**scheduler_options) if scheduler
|
|
71
|
+
|
|
72
|
+
# Both the exporter and the poller serve from background threads; block
|
|
73
|
+
# the main thread so the process stays up until the kubelet sends SIGTERM.
|
|
74
|
+
Shutdown.await
|
|
75
|
+
GlobalScheduler.stop! if scheduler
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def start_metrics!
|
|
57
79
|
install!
|
|
58
80
|
RailsPodKit::Sidekiq.start_metrics_server!
|
|
59
81
|
|
|
60
82
|
require 'yabeda'
|
|
61
83
|
Yabeda.configure! unless Yabeda.already_configured?
|
|
62
|
-
|
|
63
|
-
# The exporter serves from a background thread; block the main thread so
|
|
64
|
-
# the process stays up until the kubelet sends SIGTERM.
|
|
65
|
-
sleep
|
|
66
84
|
end
|
|
67
85
|
|
|
68
86
|
# Configure the Sidekiq client's Redis connection so this Rails-free process
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails_pod_kit/config'
|
|
4
|
+
require 'rails_pod_kit/supervisor'
|
|
5
|
+
|
|
6
|
+
module RailsPodKit
|
|
7
|
+
# Runs the sidekiq-cron poller in a process that is *not* a Sidekiq server, so
|
|
8
|
+
# a Sidekiq deployment can be autoscaled to zero without losing its schedule.
|
|
9
|
+
#
|
|
10
|
+
# sidekiq-cron installs its poller from inside `Sidekiq.configure_server`, so
|
|
11
|
+
# the schedule exists only while a Sidekiq server is alive. At zero replicas
|
|
12
|
+
# nothing polls, nothing is enqueued, and nothing ever raises the queue depth
|
|
13
|
+
# that would wake a worker back up — a closed loop that forces a permanent
|
|
14
|
+
# floor of one replica just to keep a poller alive. Recurring jobs are not
|
|
15
|
+
# caught up afterwards either: `reschedule_grace_period` (60s by default)
|
|
16
|
+
# discards any run older than itself, so a worker started later skips it.
|
|
17
|
+
#
|
|
18
|
+
# The poller has no such requirement of its own. `Sidekiq::Cron::Poller` is a
|
|
19
|
+
# Redis-polling thread and runs in any process holding a Sidekiq config, so
|
|
20
|
+
# hosting it on an always-on singleton — the dedicated exporter pod, see
|
|
21
|
+
# GlobalExporter — breaks the cycle and leaves the workers free to scale to
|
|
22
|
+
# zero.
|
|
23
|
+
#
|
|
24
|
+
# Deliberately Rails-free, like the exporter it sits beside: enqueueing does
|
|
25
|
+
# not need the job classes. When `Job#enqueue!` cannot resolve the class it
|
|
26
|
+
# pushes a plain Sidekiq message naming the ActiveJob wrapper with the job
|
|
27
|
+
# class as a *string*, and the worker — which does have Rails — resolves it.
|
|
28
|
+
# That fallback is only correct for entries declaring `active_job: true`, so
|
|
29
|
+
# `start!` warns about any that would instead be pushed as bare Sidekiq jobs.
|
|
30
|
+
#
|
|
31
|
+
# The poller runs under the shared Supervisor, exactly like SolidQueue's
|
|
32
|
+
# scheduler thread. Its own loop swallows StandardError (`Poller#enqueue` and
|
|
33
|
+
# `#wait` both do), so a Redis blip costs one skipped tick — but anything it
|
|
34
|
+
# does not catch takes the thread down and, since this process is the only
|
|
35
|
+
# scheduler, the schedule with it, silently.
|
|
36
|
+
module GlobalScheduler
|
|
37
|
+
SOURCE = 'rails_pod_kit.global_scheduler'
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# Loads the schedule and starts the supervisor, which starts the poller on
|
|
42
|
+
# its first (immediate) tick, then returns without blocking. Idempotent: a
|
|
43
|
+
# second call is a no-op rather than a second poller in the same process.
|
|
44
|
+
#
|
|
45
|
+
# `schedule_file:`, `poll_interval:` and `reschedule_grace_period:` override
|
|
46
|
+
# sidekiq-cron's own defaults (`config/schedule.yml`, resolved against the
|
|
47
|
+
# working directory, polled every 30s, catching up runs at most 60s late);
|
|
48
|
+
# `supervision_interval:` is the shared Supervisor's.
|
|
49
|
+
#
|
|
50
|
+
# Raising the grace period is what makes a restart of the single scheduling
|
|
51
|
+
# process free: below it a missed occurrence is caught up on the next poll,
|
|
52
|
+
# above it the run is skipped silently. Size it over the worst restart —
|
|
53
|
+
# eviction, reschedule, image pull, boot — not over the poll interval.
|
|
54
|
+
def start!(schedule_file: nil, poll_interval: nil, reschedule_grace_period: nil,
|
|
55
|
+
supervision_interval: Supervisor::DEFAULT_INTERVAL)
|
|
56
|
+
return @supervisor if @supervisor
|
|
57
|
+
return unless RailsPodKit.scheduler_enabled?('sidekiq-cron poller')
|
|
58
|
+
|
|
59
|
+
require 'sidekiq'
|
|
60
|
+
require 'sidekiq-cron'
|
|
61
|
+
# sidekiq-cron renders the schedule file through ERB without requiring it:
|
|
62
|
+
# under Rails it is always already loaded, here it is not.
|
|
63
|
+
require 'erb'
|
|
64
|
+
|
|
65
|
+
configure!(schedule_file: schedule_file, poll_interval: poll_interval,
|
|
66
|
+
reschedule_grace_period: reschedule_grace_period)
|
|
67
|
+
load_schedule!
|
|
68
|
+
|
|
69
|
+
@supervisor = build_supervisor(supervision_interval).start
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Winds the poller down so an in-flight tick finishes before the process
|
|
73
|
+
# exits, instead of being cut off mid-enqueue by the signal.
|
|
74
|
+
def stop!
|
|
75
|
+
@supervisor&.stop
|
|
76
|
+
@supervisor = nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def poller
|
|
80
|
+
@supervisor&.subject
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_supervisor(interval)
|
|
84
|
+
Supervisor.new(
|
|
85
|
+
source: SOURCE,
|
|
86
|
+
interval: interval,
|
|
87
|
+
start: -> { build_poller.tap(&:start) },
|
|
88
|
+
alive: method(:poller_alive?),
|
|
89
|
+
stop: :terminate
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# `Sidekiq::Scheduled::Poller` keeps its thread in `@thread` and `start` is a
|
|
94
|
+
# no-op once that is set, so there is no public way to ask whether the poller
|
|
95
|
+
# is still running, nor to revive it. Reading the ivar lets the supervisor
|
|
96
|
+
# replace a dead poller wholesale — schedule state lives in Redis, so a fresh
|
|
97
|
+
# one picks up exactly where the old one stopped. An unrecognised shape reads
|
|
98
|
+
# as alive, so an upstream rename costs the supervision, never a restart loop.
|
|
99
|
+
def poller_alive?(poller)
|
|
100
|
+
return true unless poller.instance_variable_defined?(:@thread)
|
|
101
|
+
|
|
102
|
+
!!poller.instance_variable_get(:@thread)&.alive?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def configure!(schedule_file: nil, poll_interval: nil, reschedule_grace_period: nil)
|
|
106
|
+
::Sidekiq::Cron.configure do |cron|
|
|
107
|
+
cron.cron_schedule_file = schedule_file if schedule_file
|
|
108
|
+
cron.cron_poll_interval = poll_interval if poll_interval
|
|
109
|
+
cron.reschedule_grace_period = reschedule_grace_period if reschedule_grace_period
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# sidekiq-cron reads the schedule file from a Sidekiq server's `:startup`
|
|
114
|
+
# lifecycle event, which never fires here, so load it explicitly.
|
|
115
|
+
def load_schedule!
|
|
116
|
+
return unless ::Sidekiq::Cron.configuration.enabled
|
|
117
|
+
|
|
118
|
+
loader = ::Sidekiq::Cron::ScheduleLoader.new
|
|
119
|
+
return unless loader.has_schedule_file?
|
|
120
|
+
|
|
121
|
+
loader.load_schedule
|
|
122
|
+
warn_unresolvable_entries!
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# sidekiq-cron's own Launcher publishes these two into the Sidekiq config
|
|
126
|
+
# before instantiating the poller, which reads them straight back out.
|
|
127
|
+
# Pinning the process count keeps the poll interval at the configured value:
|
|
128
|
+
# the inherited default counts *live Sidekiq servers*, which here is a count
|
|
129
|
+
# of anything but cron pollers — and is zero while the workers are scaled in.
|
|
130
|
+
def build_poller
|
|
131
|
+
config = ::Sidekiq.default_configuration
|
|
132
|
+
config[:cron_poll_interval] = ::Sidekiq::Cron.configuration.cron_poll_interval.to_i
|
|
133
|
+
config[:cron_poll_process_count] = ::Sidekiq::Cron.configuration.cron_poll_process_count || 1
|
|
134
|
+
|
|
135
|
+
::Sidekiq::Cron::Poller.new(config)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# An entry whose class this process cannot load and which does not declare
|
|
139
|
+
# `active_job: true` is pushed as a bare Sidekiq job message, so the worker
|
|
140
|
+
# runs `perform` outside ActiveJob — no callbacks, no argument
|
|
141
|
+
# deserialization, no retry bookkeeping.
|
|
142
|
+
def warn_unresolvable_entries!
|
|
143
|
+
names = ::Sidekiq::Cron::Job.all('*').reject { |job| enqueueable_without_class?(job) }.map(&:name)
|
|
144
|
+
return if names.empty?
|
|
145
|
+
|
|
146
|
+
::Sidekiq.logger.warn do
|
|
147
|
+
"[rails_pod_kit] cron entries #{names.join(', ')} name a class this Rails-free process cannot load and " \
|
|
148
|
+
'are not marked `active_job: true`; they would be enqueued as plain Sidekiq jobs.'
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def enqueueable_without_class?(job)
|
|
153
|
+
job.to_hash[:active_job] == '1' || !::Sidekiq::Cron::Support.safe_constantize(job.klass.to_s).nil?
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsPodKit
|
|
4
|
+
# Blocks the main thread of an always-on entry point until the orchestrator
|
|
5
|
+
# signals. A self-pipe rather than a Queue or a Mutex: writing to an IO is one
|
|
6
|
+
# of the few things safe to do from a trap handler.
|
|
7
|
+
module Shutdown
|
|
8
|
+
SIGNALS = %w[INT TERM].freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def await(signals: SIGNALS)
|
|
13
|
+
reader, writer = IO.pipe
|
|
14
|
+
signals.each { |signal| Signal.trap(signal) { writer.puts(signal) } }
|
|
15
|
+
reader.gets
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'concurrent'
|
|
4
3
|
require 'active_support/configuration_file'
|
|
5
4
|
require 'active_support/core_ext/hash/keys'
|
|
6
5
|
|
|
7
6
|
require 'rails_pod_kit/config'
|
|
8
|
-
require 'rails_pod_kit/
|
|
7
|
+
require 'rails_pod_kit/supervisor'
|
|
9
8
|
|
|
10
9
|
module RailsPodKit
|
|
11
10
|
module SolidQueue
|
|
@@ -44,51 +43,40 @@ module RailsPodKit
|
|
|
44
43
|
@polling_interval = polling_interval
|
|
45
44
|
@supervision_interval = supervision_interval
|
|
46
45
|
@recurring_schedule_file = recurring_schedule_file
|
|
47
|
-
@stopping = false
|
|
48
46
|
end
|
|
49
47
|
|
|
50
48
|
# Starts the supervisor, which starts the scheduler on its first (immediate)
|
|
51
49
|
# tick and returns without blocking.
|
|
52
50
|
def start
|
|
53
|
-
@supervisor =
|
|
54
|
-
execution_interval: @supervision_interval,
|
|
55
|
-
run_now: true
|
|
56
|
-
) { supervise }
|
|
57
|
-
@supervisor.execute
|
|
51
|
+
@supervisor = build_supervisor.start
|
|
58
52
|
self
|
|
59
53
|
end
|
|
60
54
|
|
|
61
|
-
# Graceful stop:
|
|
62
|
-
# scheduler
|
|
63
|
-
#
|
|
55
|
+
# Graceful stop: the supervisor drops its timer before winding the
|
|
56
|
+
# scheduler down (unschedule its timers and deregister the process,
|
|
57
|
+
# instead of leaving a row to expire).
|
|
64
58
|
def stop
|
|
65
|
-
@
|
|
66
|
-
@supervisor&.shutdown
|
|
59
|
+
@supervisor&.stop
|
|
67
60
|
@supervisor = nil
|
|
68
|
-
@scheduler&.stop
|
|
69
|
-
@scheduler = nil
|
|
70
61
|
end
|
|
71
62
|
|
|
72
63
|
def running?
|
|
73
|
-
!!@
|
|
64
|
+
!!@supervisor&.running?
|
|
74
65
|
end
|
|
75
66
|
|
|
76
67
|
private
|
|
77
68
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
69
|
+
def build_supervisor
|
|
70
|
+
Supervisor.new(
|
|
71
|
+
source: SOURCE,
|
|
72
|
+
interval: @supervision_interval,
|
|
73
|
+
start: -> { build_scheduler },
|
|
74
|
+
alive: :alive?,
|
|
75
|
+
stop: :stop
|
|
76
|
+
)
|
|
85
77
|
end
|
|
86
78
|
|
|
87
|
-
|
|
88
|
-
# once its thread has actually died.
|
|
89
|
-
def ensure_scheduler_running
|
|
90
|
-
return if @stopping || @scheduler&.alive?
|
|
91
|
-
|
|
79
|
+
def build_scheduler
|
|
92
80
|
scheduler = ::SolidQueue::Scheduler.new(
|
|
93
81
|
recurring_tasks: static_recurring_tasks,
|
|
94
82
|
dynamic_tasks_enabled: true,
|
|
@@ -96,7 +84,7 @@ module RailsPodKit
|
|
|
96
84
|
)
|
|
97
85
|
scheduler.mode = :async
|
|
98
86
|
scheduler.start # spawns the scheduler's own thread and returns
|
|
99
|
-
|
|
87
|
+
scheduler
|
|
100
88
|
end
|
|
101
89
|
|
|
102
90
|
# The static tasks from config/recurring.yml; the dynamic ones come from
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'rails_pod_kit/config'
|
|
4
4
|
require 'rails_pod_kit/exporter'
|
|
5
|
+
require 'rails_pod_kit/shutdown'
|
|
5
6
|
require 'rails_pod_kit/solid_queue/metrics'
|
|
6
7
|
require 'rails_pod_kit/solid_queue/scheduler_runner'
|
|
7
8
|
|
|
@@ -49,10 +50,12 @@ module RailsPodKit
|
|
|
49
50
|
#
|
|
50
51
|
# Not gated on `RailsPodKit.enabled?` — that switch owns the metrics
|
|
51
52
|
# exporter, and an app may well want the scheduler with metrics turned off.
|
|
52
|
-
#
|
|
53
|
+
# `scheduler_enabled` is the switch that does own this (see Config); beyond
|
|
54
|
+
# it, the guard against starting one in a console or in specs is *where* you
|
|
53
55
|
# call this from (`after_booted`, or the exporter entrypoint).
|
|
54
56
|
def start_scheduler!(**)
|
|
55
57
|
return scheduler_runner if scheduler_runner
|
|
58
|
+
return unless RailsPodKit.scheduler_enabled?('SolidQueue scheduler')
|
|
56
59
|
|
|
57
60
|
@scheduler_runner = SchedulerRunner.new(**).start
|
|
58
61
|
end
|
|
@@ -97,13 +100,9 @@ module RailsPodKit
|
|
|
97
100
|
end
|
|
98
101
|
|
|
99
102
|
# Blocks the main thread (the exporter and the scheduler both run on their
|
|
100
|
-
# own) until the kubelet signals.
|
|
101
|
-
# Mutex: writing to an IO is one of the few things safe to do from a trap
|
|
102
|
-
# handler.
|
|
103
|
+
# own) until the kubelet signals.
|
|
103
104
|
def await_shutdown
|
|
104
|
-
|
|
105
|
-
%w[INT TERM].each { |signal| Signal.trap(signal) { writer.puts(signal) } }
|
|
106
|
-
reader.gets
|
|
105
|
+
Shutdown.await
|
|
107
106
|
end
|
|
108
107
|
end
|
|
109
108
|
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent'
|
|
4
|
+
|
|
5
|
+
require 'rails_pod_kit/error_reporter'
|
|
6
|
+
|
|
7
|
+
module RailsPodKit
|
|
8
|
+
# Keeps a background worker alive on a process that must not lose it.
|
|
9
|
+
#
|
|
10
|
+
# Both schedulers the gem hosts (SolidQueue's, and sidekiq-cron's) run on
|
|
11
|
+
# their own thread inside an always-on process, and both share the same
|
|
12
|
+
# failure mode: the thread dies, the host process notices nothing, and the
|
|
13
|
+
# schedule stops silently. This is that supervision, once — a timer that
|
|
14
|
+
# rebuilds the worker as soon as it stops being alive.
|
|
15
|
+
#
|
|
16
|
+
# The three things that genuinely differ between workers are injected: how to
|
|
17
|
+
# build and start one, how to ask whether it is still alive, and how to wind
|
|
18
|
+
# it down. Everything else — the immediate first tick, the stop latch that
|
|
19
|
+
# keeps a shutdown from being undone by a tick already in flight, reporting
|
|
20
|
+
# instead of dying — is identical and lives here.
|
|
21
|
+
#
|
|
22
|
+
# Deliberately free of Rails: the sidekiq-cron scheduler runs in a Rails-free
|
|
23
|
+
# process, and ErrorReporter degrades to a warning where there is no Rails
|
|
24
|
+
# error reporter to hand the failure to.
|
|
25
|
+
class Supervisor
|
|
26
|
+
DEFAULT_INTERVAL = 30
|
|
27
|
+
|
|
28
|
+
attr_reader :subject
|
|
29
|
+
|
|
30
|
+
# `start:` is a callable returning the started worker. `alive:` and `stop:`
|
|
31
|
+
# take either a method name to send to that worker or a callable receiving
|
|
32
|
+
# it, so the common case stays declarative and a liveness check the worker
|
|
33
|
+
# does not expose itself is still expressible. `stop:` is optional — a
|
|
34
|
+
# worker with no teardown is simply dropped.
|
|
35
|
+
def initialize(source:, start:, alive:, stop: nil, interval: DEFAULT_INTERVAL)
|
|
36
|
+
@source = source
|
|
37
|
+
@start = start
|
|
38
|
+
@alive = alive
|
|
39
|
+
@stop = stop
|
|
40
|
+
@interval = interval
|
|
41
|
+
@stopping = false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Starts the timer, which starts the worker on its first (immediate) tick,
|
|
45
|
+
# and returns without blocking.
|
|
46
|
+
def start
|
|
47
|
+
@stopping = false
|
|
48
|
+
@timer = ::Concurrent::TimerTask.new(execution_interval: @interval, run_now: true) { supervise }
|
|
49
|
+
@timer.execute
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Graceful stop: drop the timer first so it cannot resurrect the worker,
|
|
54
|
+
# then wind the worker down rather than leaving it to be cut off.
|
|
55
|
+
def stop
|
|
56
|
+
@stopping = true
|
|
57
|
+
@timer&.shutdown
|
|
58
|
+
@timer = nil
|
|
59
|
+
invoke(@stop, @subject) if @subject && @stop
|
|
60
|
+
@subject = nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def running?
|
|
64
|
+
!!@subject && invoke(@alive, @subject)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def invoke(hook, worker)
|
|
70
|
+
hook.is_a?(::Symbol) ? worker.public_send(hook) : hook.call(worker)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Concurrent::TimerTask silently drops a raising block, so report here and
|
|
74
|
+
# let the next tick retry — a worker that cannot start because its backing
|
|
75
|
+
# store is down must keep trying.
|
|
76
|
+
def supervise
|
|
77
|
+
ensure_running
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
ErrorReporter.report(e, source: @source)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Idempotent: starts the worker on the first tick, and replaces it only once
|
|
83
|
+
# it has actually stopped being alive.
|
|
84
|
+
def ensure_running
|
|
85
|
+
return if @stopping || running?
|
|
86
|
+
|
|
87
|
+
@subject = @start.call
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
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.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Fabio Napoleoni
|
|
@@ -140,9 +140,10 @@ description: |
|
|
|
140
140
|
opinionated kit for running Rails applications on Kubernetes: Puma, Sidekiq
|
|
141
141
|
and SolidQueue runtime metrics in Prometheus text format on an in-process
|
|
142
142
|
/metrics endpoint (default port 9394), plus a /healthz endpoint wired for
|
|
143
|
-
liveness/readiness/startup probes. No sidecar, no separate collector.
|
|
144
|
-
|
|
145
|
-
|
|
143
|
+
liveness/readiness/startup probes. No sidecar, no separate collector. It
|
|
144
|
+
also hosts the schedulers that let a job executor scale to zero without
|
|
145
|
+
stranding its recurring jobs: a supervised SolidQueue scheduler thread, and
|
|
146
|
+
a supervised sidekiq-cron poller for Sidekiq.
|
|
146
147
|
email:
|
|
147
148
|
- f.napoleoni@gmail.com
|
|
148
149
|
executables: []
|
|
@@ -157,13 +158,16 @@ files:
|
|
|
157
158
|
- lib/rails_pod_kit/error_reporter.rb
|
|
158
159
|
- lib/rails_pod_kit/exporter.rb
|
|
159
160
|
- lib/rails_pod_kit/global_exporter.rb
|
|
161
|
+
- lib/rails_pod_kit/global_scheduler.rb
|
|
160
162
|
- lib/rails_pod_kit/health.rb
|
|
161
163
|
- lib/rails_pod_kit/puma.rb
|
|
162
164
|
- lib/rails_pod_kit/railtie.rb
|
|
165
|
+
- lib/rails_pod_kit/shutdown.rb
|
|
163
166
|
- lib/rails_pod_kit/sidekiq.rb
|
|
164
167
|
- lib/rails_pod_kit/solid_queue.rb
|
|
165
168
|
- lib/rails_pod_kit/solid_queue/metrics.rb
|
|
166
169
|
- lib/rails_pod_kit/solid_queue/scheduler_runner.rb
|
|
170
|
+
- lib/rails_pod_kit/supervisor.rb
|
|
167
171
|
- lib/rails_pod_kit/version.rb
|
|
168
172
|
homepage: https://github.com/fabn/rails_pod_kit
|
|
169
173
|
licenses:
|