rails_pod_kit 0.1.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 72d44d2c9e2129f3ca4ae021cbc10a227838b5998a80fafc6bbcd8efd18f80d0
4
- data.tar.gz: 1c591dcbf8dccf4dcb608483311fbff89363540a25f7a629c6aaa6cda34f6372
3
+ metadata.gz: 0dda5e5202f1bb5e509fad032de8449c5d8e9eec78c0cedcd0a59d20cc4f0d32
4
+ data.tar.gz: ebccb551ba5dcfd23c1dc2e668d417f59fb1d76f06522e02c352cea1f5e0dca5
5
5
  SHA512:
6
- metadata.gz: 989f811cb7ad46f94466b334f4c5405dc46f6bd7df44830645822a14205f9f955451752fba35505488fd4e45a2e62b35ef7306423d6ea0a42432490d0d7c7895
7
- data.tar.gz: 22711c7a9d6b4a333bf135133222706e994791ff7648eb8b92f327dfd44d67ae6fa5ce10d797e9ba29d74dd2243ac6563525177458adaeed7b086104fb23c0c0
6
+ metadata.gz: f6ab1b5432bf79341efabb50d0d3d1862dafa49523bbbc8f70ea718eb520d258b7c216da274f0d184dbeb1737c887ebfb19f04fbcc656e8fd39c6a0e057d6346
7
+ data.tar.gz: c2e8aa9a3b35a1ea4d557a47be04810c69e79de50a0297593388f90c1be48f8a144a281ad3d26a6c42c4564ba608d6ded064ba53b12f5f49279ddefa940e049c
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
- - **A supervised SolidQueue scheduler thread**, so a SolidQueue job executor can
15
- be autoscaled to zero without stranding its recurring and scheduled jobs.
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`) or an optional
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; `false` ⇒ no exporter, no port bound |
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 |
@@ -156,6 +159,8 @@ endpoint every few seconds); pass `silence_controller_log: false` to keep it.
156
159
  `sidekiq_queue_latency`, `sidekiq_active_processes`,
157
160
  `sidekiq_active_workers_count`, `sidekiq_jobs_retry_count`,
158
161
  `sidekiq_jobs_dead_count`, `sidekiq_jobs_scheduled_count`.
162
+ - **Sidekiq (cron scheduler):** `sidekiq_cron_poll_age_seconds`, on the process
163
+ hosting the poller (see GlobalScheduler).
159
164
  - **SolidQueue (DB-wide):** `solid_queue_backlog`,
160
165
  `solid_queue_latency_seconds`.
161
166
 
@@ -273,6 +278,12 @@ Sidekiq job class.
273
278
  | `sidekiq.active_processes` | — |
274
279
  | `sidekiq.active_workers_count` | — |
275
280
 
281
+ **Sidekiq — cron scheduler** (only the process hosting the poller, `namespace: sidekiq`):
282
+
283
+ | canonical Datadog metric | functional tags |
284
+ |---|---|
285
+ | `sidekiq.cron_poll_age_seconds` | — |
286
+
276
287
  **Sidekiq — per-process / job** (worker pod, `namespace: sidekiq`; emitted on job activity):
277
288
 
278
289
  | canonical Datadog metric | type | functional tags |
@@ -348,6 +359,99 @@ declares the cluster gauges, starts the exporter and blocks until SIGTERM.
348
359
  Booting the full host app just to read a handful of Redis counters would cost
349
360
  ~300Mi RSS for nothing — this process sits at ~60Mi.
350
361
 
362
+ ## Sidekiq: scale-to-zero
363
+
364
+ Being an always-on singleton makes that same pod the right home for the
365
+ **sidekiq-cron poller**, which is what lets the worker fleet scale to zero.
366
+
367
+ sidekiq-cron installs its poller from inside `Sidekiq.configure_server`, so on
368
+ its own the schedule exists only while a Sidekiq server is alive. At zero
369
+ replicas nothing polls, nothing is enqueued, and nothing ever raises the queue
370
+ depth that would wake a worker back up — a closed loop that forces a permanent
371
+ floor of one replica just to keep a poller alive. Missed runs are not caught up
372
+ afterwards either: `reschedule_grace_period` (60s by default) discards any run
373
+ older than itself.
374
+
375
+ The poller has no such requirement of its own — `Sidekiq::Cron::Poller` is a
376
+ Redis-polling thread that runs in any process holding a Sidekiq config — so
377
+ `scheduler: true` hosts it here:
378
+
379
+ ```ruby
380
+ RailsPodKit::GlobalExporter.run!(
381
+ redis: { url: ENV['REDIS_URL'] },
382
+ scheduler: true,
383
+ schedule_file: File.expand_path('../config/schedule.yml', __dir__)
384
+ )
385
+ ```
386
+
387
+ `schedule_file:`, `poll_interval:` and `reschedule_grace_period:` override
388
+ sidekiq-cron's defaults (`config/schedule.yml` resolved against the working
389
+ directory, polled every 30s, catching up runs at most 60s late);
390
+ `supervision_interval:` tunes the liveness check. `RailsPodKit::GlobalScheduler`
391
+ is usable on its own (`start!` / `stop!`) if the always-on process is something
392
+ other than the exporter.
393
+
394
+ > **Size `reschedule_grace_period` over your worst restart.** It is what makes
395
+ > restarting the *only* scheduling process free: below it a missed occurrence is
396
+ > caught up on the next poll, above it the run is skipped silently. sidekiq-cron
397
+ > defaults to 60s, which a node drain or an evicted pod can easily exceed —
398
+ > rolling updates are covered anyway, since a `maxSurge` overlap means there is
399
+ > no gap at all. Catching up is bounded, not repeated: `last_enqueue_time` in
400
+ > Redis still gates each occurrence to exactly one enqueue.
401
+ >
402
+ > This is the reason a singleton scheduler does **not** need to become an HA
403
+ > pair. A second replica is safe for the poller (same `zadd` lock) but doubles
404
+ > every metric series the pod publishes, and a `PodDisruptionBudget` on a
405
+ > single-replica Deployment stalls node drains rather than protecting anything.
406
+
407
+ The poller runs under `RailsPodKit::Supervisor` — the same supervising timer
408
+ that keeps the SolidQueue scheduler thread alive, since both share the failure
409
+ mode: the thread dies, the host process notices nothing, and the schedule stops
410
+ silently. The cron poller's own loop swallows StandardError, so a Redis blip
411
+ costs one skipped tick; the supervisor makes anything it does *not* catch a
412
+ skipped tick too.
413
+
414
+ ### The heartbeat
415
+
416
+ The supervisor covers a poller thread that *dies*. It cannot see one that is
417
+ running and no longer enqueueing — which from the outside is indistinguishable
418
+ from an idle one: every gauge stays fresh, `/metrics` answers 200, the process
419
+ looks healthy. On the only process carrying the schedule that is a silently
420
+ stopped schedule, i.e. the very failure hosting the poller here was meant to
421
+ eliminate, back through another door.
422
+
423
+ So `start!` also publishes **`sidekiq_cron_poll_age_seconds`**: seconds since the
424
+ poller last completed a tick. It measures the loop turning, so it stays flat on a
425
+ healthy but idle schedule and climbs the moment ticks stop — the one shape an
426
+ alert can be written against:
427
+
428
+ ```
429
+ max:sidekiq.cron_poll_age_seconds{…} > 10 * <poll interval>
430
+ ```
431
+
432
+ Before the first tick it measures from `start!`, so a poller that never manages
433
+ one reads as climbing rather than as no-data; it is `nil` (and the series absent)
434
+ on any process that hosts no poller, and again once `stop!` runs.
435
+
436
+ Deliberately **not** "time since last enqueue", which would climb on any quiet
437
+ schedule and so alert on nothing happening. Answering *should this job have run
438
+ by now?* needs a per-job check against the cron expression, not a gauge.
439
+
440
+ > **Every schedule entry must declare `active_job: true`.** This process has no
441
+ > Rails, so it cannot resolve the job classes; sidekiq-cron then falls back to
442
+ > pushing a raw message, and only that flag makes the message an ActiveJob
443
+ > wrapper (naming the class as a *string*, which the worker resolves). Without it
444
+ > the job is pushed as a bare Sidekiq job and runs outside ActiveJob entirely.
445
+ > `start!` logs a warning naming any entry in that state. For the same reason the
446
+ > schedule file's ERB must not reach for Rails.
447
+
448
+ Leaving the workers' own poller in place is fine and costs nothing: enqueueing is
449
+ gated on a Redis `zadd` that exactly one caller wins — the same lock that already
450
+ lets multiple worker replicas coexist without double-firing. Both processes must
451
+ then read the *same* schedule file, though: `load_from_hash!` removes the
452
+ schedule-sourced jobs that are absent from the file it is given, so two processes
453
+ loading different files will delete each other's entries.
454
+
351
455
  ## SolidQueue: scale-to-zero
352
456
 
353
457
  SolidQueue's executor has nothing to do while the queue is empty, so it is the
@@ -378,10 +482,11 @@ This is deliberately **not** `plugin :solid_queue`. That one runs the full
378
482
  supervisor, which forks and whose watchdog takes Puma down when the supervisor
379
483
  exits — and a transient Postgres disconnect is enough to cause that
380
484
  ([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.
485
+ a DB blip at worst kills the scheduler thread; `RailsPodKit::Supervisor` — a
486
+ `Concurrent::TimerTask`, the same primitive SolidQueue supervises its own
487
+ processes with, and the same one that keeps the sidekiq-cron poller alive
488
+ notices on the next tick and starts a fresh one, the process itself never
489
+ notices, and the scheduler re-registers on recovery.
385
490
 
386
491
  Running it on every replica is safe: enqueues stay exactly-once via the unique
387
492
  index on `solid_queue_recurring_executions (task_key, run_at)`. Static tasks come
@@ -395,8 +500,10 @@ ones from the DB.
395
500
  | `recurring_schedule_file` | `config/recurring.yml` | static task definitions; skipped when absent |
396
501
 
397
502
  `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.
503
+ exporter, and an app may well want the scheduler with metrics off.
504
+ `scheduler_enabled` is the switch that does own it, shared with the sidekiq-cron
505
+ poller. Beyond that, what keeps it out of consoles and specs is *where* you call
506
+ it from.
400
507
 
401
508
  ### 2. Queue depth has to be visible
402
509
 
@@ -493,6 +600,8 @@ serves `solid_queue_*` and nothing else, so the check config needs no filters.
493
600
  a WEBrick thread instead, started at most once per process.
494
601
  - **SolidQueue and Sidekiq are the host's.** The gem depends on neither; the
495
602
  SolidQueue integration is inert until you call it, exactly like the Sidekiq one.
603
+ `sidekiq-cron` too: it is required only when `GlobalScheduler.start!` is called,
604
+ so an app that does not schedule anything need not carry it.
496
605
  - **Queue-gauge query cost.** The gauges run four small grouped aggregates per
497
606
  scrape. `MIN(created_at)` is not covered by SolidQueue's indexes, so on a
498
607
  backlog of many thousands of rows it is a scan — cheap at a normal scrape
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.1
1
+ 0.3.0
@@ -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
- def run!(redis:)
51
- unless RailsPodKit.enabled?
52
- warn '[rails_pod_kit] disabled global exporter not started'
53
- return
54
- end
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,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsPodKit
4
+ module GlobalScheduler
5
+ # The one failure the rest of the machinery cannot see.
6
+ #
7
+ # A poller thread that dies is restarted by the Supervisor, and a process
8
+ # that stops serving is caught by the exporter's own probe. But a poller
9
+ # that is *running and no longer enqueueing* is indistinguishable from an
10
+ # idle one from the outside: every gauge stays fresh, /metrics answers 200,
11
+ # the pod is Running and Ready. On the only process carrying the schedule
12
+ # that is a silently stopped schedule — exactly the failure hosting the
13
+ # poller here was meant to eliminate, coming back through another door.
14
+ #
15
+ # So publish the age of the last completed tick, as
16
+ # `sidekiq_cron_poll_age_seconds`. It measures the loop turning, which means
17
+ # it stays flat on a healthy but idle schedule and climbs the moment ticks
18
+ # stop — the one shape an alert can be written against.
19
+ #
20
+ # Deliberately *not* "time since last enqueue": that climbs on any quiet
21
+ # schedule, so it would alert on nothing happening. Answering "should this
22
+ # job have run by now?" needs a per-job check against the cron expression,
23
+ # not a gauge.
24
+ module Heartbeat
25
+ module_function
26
+
27
+ # Declares the gauge. One-shot, and safe either side of
28
+ # `Yabeda.configure!` — a metric declared after it is registered with the
29
+ # adapters immediately.
30
+ def install!
31
+ return false if @installed
32
+
33
+ require 'yabeda'
34
+ declare!
35
+ @installed = true
36
+ end
37
+
38
+ def declare!
39
+ Yabeda.configure do
40
+ group :sidekiq do
41
+ gauge :cron_poll_age,
42
+ unit: :seconds,
43
+ tags: [],
44
+ aggregation: :most_recent,
45
+ comment: 'Seconds since the sidekiq-cron poller last completed a tick'
46
+
47
+ collect do
48
+ age = RailsPodKit::GlobalScheduler::Heartbeat.age
49
+ Yabeda.sidekiq.cron_poll_age.set({}, age) if age
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ # Begins measuring, from before the first tick — so a poller that never
56
+ # manages one reads as climbing rather than as no-data.
57
+ def start!
58
+ @started_at = monotonic_now
59
+ @last_poll_at = nil
60
+ end
61
+
62
+ # Drops the series with the poller: a stopped scheduler should read as
63
+ # no-data, not as an age climbing forever.
64
+ def stop!
65
+ @started_at = nil
66
+ @last_poll_at = nil
67
+ end
68
+
69
+ def record!
70
+ @last_poll_at = monotonic_now
71
+ end
72
+
73
+ # nil until started, which is what keeps the series off any process that
74
+ # hosts no poller.
75
+ def age
76
+ reference = @last_poll_at || @started_at
77
+ return nil unless reference
78
+
79
+ monotonic_now - reference
80
+ end
81
+
82
+ # Monotonic: this is a duration, and a wall-clock step (NTP, a node coming
83
+ # back from suspend) must not read as the schedule having stalled.
84
+ def monotonic_now
85
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_pod_kit/config'
4
+ require 'rails_pod_kit/global_scheduler/heartbeat'
5
+ require 'rails_pod_kit/supervisor'
6
+
7
+ module RailsPodKit
8
+ # Runs the sidekiq-cron poller in a process that is *not* a Sidekiq server, so
9
+ # a Sidekiq deployment can be autoscaled to zero without losing its schedule.
10
+ #
11
+ # sidekiq-cron installs its poller from inside `Sidekiq.configure_server`, so
12
+ # the schedule exists only while a Sidekiq server is alive. At zero replicas
13
+ # nothing polls, nothing is enqueued, and nothing ever raises the queue depth
14
+ # that would wake a worker back up — a closed loop that forces a permanent
15
+ # floor of one replica just to keep a poller alive. Recurring jobs are not
16
+ # caught up afterwards either: `reschedule_grace_period` (60s by default)
17
+ # discards any run older than itself, so a worker started later skips it.
18
+ #
19
+ # The poller has no such requirement of its own. `Sidekiq::Cron::Poller` is a
20
+ # Redis-polling thread and runs in any process holding a Sidekiq config, so
21
+ # hosting it on an always-on singleton — the dedicated exporter pod, see
22
+ # GlobalExporter — breaks the cycle and leaves the workers free to scale to
23
+ # zero.
24
+ #
25
+ # Deliberately Rails-free, like the exporter it sits beside: enqueueing does
26
+ # not need the job classes. When `Job#enqueue!` cannot resolve the class it
27
+ # pushes a plain Sidekiq message naming the ActiveJob wrapper with the job
28
+ # class as a *string*, and the worker — which does have Rails — resolves it.
29
+ # That fallback is only correct for entries declaring `active_job: true`, so
30
+ # `start!` warns about any that would instead be pushed as bare Sidekiq jobs.
31
+ #
32
+ # The poller runs under the shared Supervisor, exactly like SolidQueue's
33
+ # scheduler thread. Its own loop swallows StandardError (`Poller#enqueue` and
34
+ # `#wait` both do), so a Redis blip costs one skipped tick — but anything it
35
+ # does not catch takes the thread down and, since this process is the only
36
+ # scheduler, the schedule with it, silently.
37
+ module GlobalScheduler
38
+ SOURCE = 'rails_pod_kit.global_scheduler'
39
+
40
+ module_function
41
+
42
+ # Loads the schedule and starts the supervisor, which starts the poller on
43
+ # its first (immediate) tick, then returns without blocking. Idempotent: a
44
+ # second call is a no-op rather than a second poller in the same process.
45
+ #
46
+ # `schedule_file:`, `poll_interval:` and `reschedule_grace_period:` override
47
+ # sidekiq-cron's own defaults (`config/schedule.yml`, resolved against the
48
+ # working directory, polled every 30s, catching up runs at most 60s late);
49
+ # `supervision_interval:` is the shared Supervisor's.
50
+ #
51
+ # Raising the grace period is what makes a restart of the single scheduling
52
+ # process free: below it a missed occurrence is caught up on the next poll,
53
+ # above it the run is skipped silently. Size it over the worst restart —
54
+ # eviction, reschedule, image pull, boot — not over the poll interval.
55
+ def start!(schedule_file: nil, poll_interval: nil, reschedule_grace_period: nil,
56
+ supervision_interval: Supervisor::DEFAULT_INTERVAL)
57
+ return @supervisor if @supervisor
58
+ return unless RailsPodKit.scheduler_enabled?('sidekiq-cron poller')
59
+
60
+ require 'sidekiq'
61
+ require 'sidekiq-cron'
62
+ # sidekiq-cron renders the schedule file through ERB without requiring it:
63
+ # under Rails it is always already loaded, here it is not.
64
+ require 'erb'
65
+
66
+ configure!(schedule_file: schedule_file, poll_interval: poll_interval,
67
+ reschedule_grace_period: reschedule_grace_period)
68
+ load_schedule!
69
+
70
+ Heartbeat.install!
71
+ Heartbeat.start!
72
+ @supervisor = build_supervisor(supervision_interval).start
73
+ end
74
+
75
+ # Winds the poller down so an in-flight tick finishes before the process
76
+ # exits, instead of being cut off mid-enqueue by the signal.
77
+ def stop!
78
+ @supervisor&.stop
79
+ @supervisor = nil
80
+ Heartbeat.stop!
81
+ end
82
+
83
+ def poller
84
+ @supervisor&.subject
85
+ end
86
+
87
+ def build_supervisor(interval)
88
+ Supervisor.new(
89
+ source: SOURCE,
90
+ interval: interval,
91
+ start: -> { build_poller.tap(&:start) },
92
+ alive: method(:poller_alive?),
93
+ stop: :terminate
94
+ )
95
+ end
96
+
97
+ # `Sidekiq::Scheduled::Poller` keeps its thread in `@thread` and `start` is a
98
+ # no-op once that is set, so there is no public way to ask whether the poller
99
+ # is still running, nor to revive it. Reading the ivar lets the supervisor
100
+ # replace a dead poller wholesale — schedule state lives in Redis, so a fresh
101
+ # one picks up exactly where the old one stopped. An unrecognised shape reads
102
+ # as alive, so an upstream rename costs the supervision, never a restart loop.
103
+ def poller_alive?(poller)
104
+ return true unless poller.instance_variable_defined?(:@thread)
105
+
106
+ !!poller.instance_variable_get(:@thread)&.alive?
107
+ end
108
+
109
+ def configure!(schedule_file: nil, poll_interval: nil, reschedule_grace_period: nil)
110
+ ::Sidekiq::Cron.configure do |cron|
111
+ cron.cron_schedule_file = schedule_file if schedule_file
112
+ cron.cron_poll_interval = poll_interval if poll_interval
113
+ cron.reschedule_grace_period = reschedule_grace_period if reschedule_grace_period
114
+ end
115
+ end
116
+
117
+ # sidekiq-cron reads the schedule file from a Sidekiq server's `:startup`
118
+ # lifecycle event, which never fires here, so load it explicitly.
119
+ def load_schedule!
120
+ return unless ::Sidekiq::Cron.configuration.enabled
121
+
122
+ loader = ::Sidekiq::Cron::ScheduleLoader.new
123
+ return unless loader.has_schedule_file?
124
+
125
+ loader.load_schedule
126
+ warn_unresolvable_entries!
127
+ end
128
+
129
+ # sidekiq-cron's own Launcher publishes these two into the Sidekiq config
130
+ # before instantiating the poller, which reads them straight back out.
131
+ # Pinning the process count keeps the poll interval at the configured value:
132
+ # the inherited default counts *live Sidekiq servers*, which here is a count
133
+ # of anything but cron pollers — and is zero while the workers are scaled in.
134
+ def build_poller
135
+ config = ::Sidekiq.default_configuration
136
+ config[:cron_poll_interval] = ::Sidekiq::Cron.configuration.cron_poll_interval.to_i
137
+ config[:cron_poll_process_count] = ::Sidekiq::Cron.configuration.cron_poll_process_count || 1
138
+
139
+ poller_class.new(config)
140
+ end
141
+
142
+ # A subclass rather than a prepended module, so the heartbeat hook is
143
+ # confined to the poller this module builds and never touches sidekiq-cron's
144
+ # own for a host that also runs a Sidekiq server. Built lazily because the
145
+ # superclass does not exist until `sidekiq-cron` is required.
146
+ #
147
+ # Records only on a normal return: `Poller#enqueue` rescues internally, so
148
+ # anything that still escapes it means the tick did not complete, and
149
+ # counting it as a heartbeat would be exactly the lie the gauge exists to
150
+ # prevent.
151
+ def poller_class
152
+ @poller_class ||= Class.new(::Sidekiq::Cron::Poller) do
153
+ def enqueue
154
+ super
155
+ Heartbeat.record!
156
+ end
157
+ end
158
+ end
159
+
160
+ # An entry whose class this process cannot load and which does not declare
161
+ # `active_job: true` is pushed as a bare Sidekiq job message, so the worker
162
+ # runs `perform` outside ActiveJob — no callbacks, no argument
163
+ # deserialization, no retry bookkeeping.
164
+ def warn_unresolvable_entries!
165
+ names = ::Sidekiq::Cron::Job.all('*').reject { |job| enqueueable_without_class?(job) }.map(&:name)
166
+ return if names.empty?
167
+
168
+ ::Sidekiq.logger.warn do
169
+ "[rails_pod_kit] cron entries #{names.join(', ')} name a class this Rails-free process cannot load and " \
170
+ 'are not marked `active_job: true`; they would be enqueued as plain Sidekiq jobs.'
171
+ end
172
+ end
173
+
174
+ def enqueueable_without_class?(job)
175
+ job.to_hash[:active_job] == '1' || !::Sidekiq::Cron::Support.safe_constantize(job.klass.to_s).nil?
176
+ end
177
+ end
178
+ 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/error_reporter'
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 = ::Concurrent::TimerTask.new(
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: 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).
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
- @stopping = true
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
- !!@scheduler&.alive?
64
+ !!@supervisor&.running?
74
65
  end
75
66
 
76
67
  private
77
68
 
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)
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
- # 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
-
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
- @scheduler = scheduler
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
- # The guard against starting one in a console or in specs is *where* you
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. 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
+ # own) until the kubelet signals.
103
104
  def await_shutdown
104
- reader, writer = IO.pipe
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.1.1
4
+ version: 0.3.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. For
144
- SolidQueue it also ships the supervised scheduler thread that lets the job
145
- executor scale to zero.
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,17 @@ 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
162
+ - lib/rails_pod_kit/global_scheduler/heartbeat.rb
160
163
  - lib/rails_pod_kit/health.rb
161
164
  - lib/rails_pod_kit/puma.rb
162
165
  - lib/rails_pod_kit/railtie.rb
166
+ - lib/rails_pod_kit/shutdown.rb
163
167
  - lib/rails_pod_kit/sidekiq.rb
164
168
  - lib/rails_pod_kit/solid_queue.rb
165
169
  - lib/rails_pod_kit/solid_queue/metrics.rb
166
170
  - lib/rails_pod_kit/solid_queue/scheduler_runner.rb
171
+ - lib/rails_pod_kit/supervisor.rb
167
172
  - lib/rails_pod_kit/version.rb
168
173
  homepage: https://github.com/fabn/rails_pod_kit
169
174
  licenses: