rails_pod_kit 0.2.0 → 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: 85fea66a44c5b2198b5c3c7bfd05f5f1f4c3655559ed701748661d0e148650a5
4
- data.tar.gz: 9c921528f839ea1d5b962035600316ea37affe17cf8fdc910453c30108a248dd
3
+ metadata.gz: 0dda5e5202f1bb5e509fad032de8449c5d8e9eec78c0cedcd0a59d20cc4f0d32
4
+ data.tar.gz: ebccb551ba5dcfd23c1dc2e668d417f59fb1d76f06522e02c352cea1f5e0dca5
5
5
  SHA512:
6
- metadata.gz: d7fbfee31ce338a6dd7c11d65214dc442cd2cb261848ae2403a415dc4a6e1333fa55b39e2d06441b4e05fcacff2ec3971ee009613a532f384fcc19fbe84f070d
7
- data.tar.gz: 894f99f9fba4596aa7d69e143f5fa23d2ebc64ef85f6b7cac310096c902005dd003103cb12c3933e49dfe26263c68f9656cfc3e68c0d9551a8f118e79496fd48
6
+ metadata.gz: f6ab1b5432bf79341efabb50d0d3d1862dafa49523bbbc8f70ea718eb520d258b7c216da274f0d184dbeb1737c887ebfb19f04fbcc656e8fd39c6a0e057d6346
7
+ data.tar.gz: c2e8aa9a3b35a1ea4d557a47be04810c69e79de50a0297593388f90c1be48f8a144a281ad3d26a6c42c4564ba608d6ded064ba53b12f5f49279ddefa940e049c
data/README.md CHANGED
@@ -159,6 +159,8 @@ endpoint every few seconds); pass `silence_controller_log: false` to keep it.
159
159
  `sidekiq_queue_latency`, `sidekiq_active_processes`,
160
160
  `sidekiq_active_workers_count`, `sidekiq_jobs_retry_count`,
161
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).
162
164
  - **SolidQueue (DB-wide):** `solid_queue_backlog`,
163
165
  `solid_queue_latency_seconds`.
164
166
 
@@ -276,6 +278,12 @@ Sidekiq job class.
276
278
  | `sidekiq.active_processes` | — |
277
279
  | `sidekiq.active_workers_count` | — |
278
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
+
279
287
  **Sidekiq — per-process / job** (worker pod, `namespace: sidekiq`; emitted on job activity):
280
288
 
281
289
  | canonical Datadog metric | type | functional tags |
@@ -403,6 +411,32 @@ silently. The cron poller's own loop swallows StandardError, so a Redis blip
403
411
  costs one skipped tick; the supervisor makes anything it does *not* catch a
404
412
  skipped tick too.
405
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
+
406
440
  > **Every schedule entry must declare `active_job: true`.** This process has no
407
441
  > Rails, so it cannot resolve the job classes; sidekiq-cron then falls back to
408
442
  > pushing a raw message, and only that flag makes the message an ActiveJob
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.2.0
1
+ 0.3.0
@@ -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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rails_pod_kit/config'
4
+ require 'rails_pod_kit/global_scheduler/heartbeat'
4
5
  require 'rails_pod_kit/supervisor'
5
6
 
6
7
  module RailsPodKit
@@ -66,6 +67,8 @@ module RailsPodKit
66
67
  reschedule_grace_period: reschedule_grace_period)
67
68
  load_schedule!
68
69
 
70
+ Heartbeat.install!
71
+ Heartbeat.start!
69
72
  @supervisor = build_supervisor(supervision_interval).start
70
73
  end
71
74
 
@@ -74,6 +77,7 @@ module RailsPodKit
74
77
  def stop!
75
78
  @supervisor&.stop
76
79
  @supervisor = nil
80
+ Heartbeat.stop!
77
81
  end
78
82
 
79
83
  def poller
@@ -132,7 +136,25 @@ module RailsPodKit
132
136
  config[:cron_poll_interval] = ::Sidekiq::Cron.configuration.cron_poll_interval.to_i
133
137
  config[:cron_poll_process_count] = ::Sidekiq::Cron.configuration.cron_poll_process_count || 1
134
138
 
135
- ::Sidekiq::Cron::Poller.new(config)
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
136
158
  end
137
159
 
138
160
  # An entry whose class this process cannot load and which does not declare
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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Fabio Napoleoni
@@ -159,6 +159,7 @@ files:
159
159
  - lib/rails_pod_kit/exporter.rb
160
160
  - lib/rails_pod_kit/global_exporter.rb
161
161
  - lib/rails_pod_kit/global_scheduler.rb
162
+ - lib/rails_pod_kit/global_scheduler/heartbeat.rb
162
163
  - lib/rails_pod_kit/health.rb
163
164
  - lib/rails_pod_kit/puma.rb
164
165
  - lib/rails_pod_kit/railtie.rb