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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 72d44d2c9e2129f3ca4ae021cbc10a227838b5998a80fafc6bbcd8efd18f80d0
4
- data.tar.gz: 1c591dcbf8dccf4dcb608483311fbff89363540a25f7a629c6aaa6cda34f6372
3
+ metadata.gz: 85fea66a44c5b2198b5c3c7bfd05f5f1f4c3655559ed701748661d0e148650a5
4
+ data.tar.gz: 9c921528f839ea1d5b962035600316ea37affe17cf8fdc910453c30108a248dd
5
5
  SHA512:
6
- metadata.gz: 989f811cb7ad46f94466b334f4c5405dc46f6bd7df44830645822a14205f9f955451752fba35505488fd4e45a2e62b35ef7306423d6ea0a42432490d0d7c7895
7
- data.tar.gz: 22711c7a9d6b4a333bf135133222706e994791ff7648eb8b92f327dfd44d67ae6fa5ce10d797e9ba29d74dd2243ac6563525177458adaeed7b086104fb23c0c0
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
- - **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 |
@@ -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; 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.
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. What keeps it
399
- out of consoles and specs is *where* you call it from.
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.1
1
+ 0.2.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,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/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.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. 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,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: