kicks_liveness 0.1.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.
@@ -0,0 +1,178 @@
1
+ module KicksLiveness
2
+ # The liveness mark on the filesystem: written by the worker, read by the
3
+ # probe.
4
+ #
5
+ # The directory must live on tmpfs — in Kubernetes, an emptyDir with
6
+ # <tt>medium: Memory</tt>. Put it on a real disk and the probe starts
7
+ # depending on the disk again, and a stalling disk is one of the most common
8
+ # causes of false restarts.
9
+ #
10
+ # This file deliberately runs no +require+ on the probe's path and refers to
11
+ # nothing else in the gem: the probe can load it alone, with no application
12
+ # code and no Rails behind it. That keeps the optional direct form at ~43 ms
13
+ # and lets it run on a bare interpreter with +--disable-gems+ when the image
14
+ # allows it -- whether Bundler is in the picture depends on where the image
15
+ # put its gems, not on this file. The one +require+ it does contain is lazy,
16
+ # in a branch only the worker reaches.
17
+ #
18
+ # @see file:docs/DESIGN.md#why-the-heartbeat-file-has-no-require-of-its-own
19
+ class Heartbeat
20
+ # @return [String] marks directory used when the environment says nothing
21
+ DEFAULT_DIR = '/opt/app/tmp/health'.freeze
22
+ # @return [Integer] seconds after which a mark is stale, by default
23
+ DEFAULT_MAX_AGE = 45
24
+
25
+ # @return [Hash{Symbol => String}] setting name to environment variable
26
+ ENV_NAMES = {
27
+ dir: 'KICKS_LIVENESS_DIR',
28
+ max_age: 'KICKS_LIVENESS_MAX_AGE',
29
+ tick: 'KICKS_LIVENESS_TICK'
30
+ }.freeze
31
+
32
+ # An empty string counts as unset: in a ConfigMap that is what you get by
33
+ # declaring a key and leaving it blank.
34
+ #
35
+ # @param key [Symbol] one of the keys of {ENV_NAMES}
36
+ # @return [String, nil]
37
+ def self.env_raw(key)
38
+ value = ENV.fetch(ENV_NAMES.fetch(key), nil)
39
+ value unless value.nil? || value.empty?
40
+ end
41
+
42
+ # Values arrive from a ConfigMap, and a typo there is no reason to bring a
43
+ # worker down — garbage falls back to the default.
44
+ #
45
+ # Both settings are durations, so a value has to be parseable *and*
46
+ # positive. Zero and negative numbers parse perfectly well and are the more
47
+ # dangerous half: a tick of zero turns the monitor into a hot loop, a
48
+ # negative one used to kill the monitor thread on its first sleep, and a
49
+ # negative max_age makes every mark stale on arrival, so the probe can never
50
+ # pass again.
51
+ #
52
+ # @param key [Symbol] one of the keys of {ENV_NAMES}
53
+ # @param default [Integer]
54
+ # @return [Integer] the value from the environment when it is a positive
55
+ # integer, the default otherwise
56
+ def self.env_int(key, default)
57
+ value = Integer(env_raw(key) || default)
58
+ value.positive? ? value : default
59
+ rescue ArgumentError, TypeError
60
+ default
61
+ end
62
+
63
+ # @return [String] marks directory
64
+ def self.env_dir
65
+ env_raw(:dir) || DEFAULT_DIR
66
+ end
67
+
68
+ # @return [Integer] seconds after which a mark is considered stale
69
+ def self.env_max_age
70
+ env_int(:max_age, DEFAULT_MAX_AGE)
71
+ end
72
+
73
+ # @param dir [String] marks directory
74
+ # @param max_age [Integer] seconds after which a mark is considered stale
75
+ def initialize(dir: Heartbeat.env_dir, max_age: Heartbeat.env_max_age)
76
+ @dir = dir
77
+ @max_age = max_age
78
+ end
79
+
80
+ attr_reader :dir, :max_age
81
+
82
+ # Records how many forks the probe must wait for. Every fork writes the same
83
+ # value. Without it the probe would pass as soon as any single mark was
84
+ # fresh, while half the workers had not subscribed yet.
85
+ #
86
+ # Written through a rename, because a plain write truncates first: a probe
87
+ # reading in that window finds the file empty and reports that the worker
88
+ # has not started. The window is real and recurring — every fork rewrites
89
+ # this file on every tick, while the liveness probe reads it on a schedule
90
+ # of its own. Rename is atomic on tmpfs.
91
+ #
92
+ # @param processes [Integer]
93
+ # @return [void]
94
+ def declare!(processes)
95
+ make_dir
96
+ # The pid keeps concurrent forks from sharing the temporary file.
97
+ tmp = "#{expected_path}.#{Process.pid}"
98
+ File.write(tmp, processes)
99
+ File.rename(tmp, expected_path)
100
+ end
101
+
102
+ # Refreshes this fork's mark.
103
+ #
104
+ # The file is named by supervisor slot, not by PID: a fork killed with
105
+ # SIGKILL is respawned into the same slot and overwrites its own file. With a
106
+ # PID in the name that file would stay stale forever and the probe would fail
107
+ # permanently.
108
+ #
109
+ # The contents exist only for a human running <tt>kubectl exec ... cat</tt>;
110
+ # the probe decides on mtime alone.
111
+ #
112
+ # @param slot [Integer] supervisor slot of this fork
113
+ # @return [Integer] bytes written
114
+ def touch!(slot)
115
+ make_dir
116
+ File.write(slot_path(slot), "#{Time.now.utc.strftime('%FT%TZ')} pid=#{Process.pid} slot=#{slot}\n")
117
+ end
118
+
119
+ # The probe side: is every declared fork's mark present and fresh?
120
+ #
121
+ # The returned message names slots the way the files are named, so that a
122
+ # human reading the Unhealthy event knows which file to look at.
123
+ #
124
+ # @param now [Time] injected in specs
125
+ # @return [Array(Boolean, String)] health, and the reason to print on stdout
126
+ def check(now: Time.now.utc)
127
+ processes = expected
128
+ return [false, "no #{expected_path}: worker has not started yet"] unless processes&.positive?
129
+
130
+ problems = (0...processes).filter_map do |slot|
131
+ age = age_of(slot_path(slot), now)
132
+ next "worker-#{slot} missing" if age.nil?
133
+
134
+ "worker-#{slot} stale #{age.round}s > #{@max_age}s" if age > @max_age
135
+ end
136
+
137
+ problems.empty? ? [true, "#{processes} process(es) healthy"] : [false, problems.join('; ')]
138
+ end
139
+
140
+ private
141
+
142
+ # One syscall on the happy path, and whichever fork gets there first wins.
143
+ #
144
+ # +Dir.mkdir+ creates a single level and raises ENOENT when the parent is
145
+ # missing, which any nested KICKS_LIVENESS_DIR hits. FileUtils is required
146
+ # here rather than at the top of the file: only the worker ever creates the
147
+ # directory, and the probe's path through {#check} must stay free of
148
+ # requires.
149
+ def make_dir
150
+ Dir.mkdir(@dir)
151
+ rescue Errno::EEXIST
152
+ nil
153
+ rescue Errno::ENOENT
154
+ require 'fileutils'
155
+ FileUtils.mkdir_p(@dir)
156
+ end
157
+
158
+ def expected_path
159
+ File.join(@dir, 'expected')
160
+ end
161
+
162
+ def slot_path(slot)
163
+ File.join(@dir, "worker-#{slot}")
164
+ end
165
+
166
+ def expected
167
+ Integer(File.read(expected_path))
168
+ rescue StandardError
169
+ nil
170
+ end
171
+
172
+ def age_of(path, now)
173
+ now - File.mtime(path)
174
+ rescue StandardError
175
+ nil
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,93 @@
1
+ module KicksLiveness
2
+ # The two modules prepended into the worker gem. Installed together by
3
+ # {KicksLiveness.install!}.
4
+ #
5
+ # @see file:docs/DESIGN.md#where-the-hooks-attach
6
+ # @api private
7
+ module Hooks
8
+ # Prepended to +Sneakers::Worker+. The worker is registered only after a
9
+ # successful subscribe: if +subscribe+ raised, it never reaches the registry
10
+ # and the probe honestly fails.
11
+ # @api private
12
+ module Worker
13
+ # @return [void]
14
+ def run
15
+ super
16
+ Registry.add(self)
17
+ end
18
+
19
+ # @return [void]
20
+ def stop
21
+ Registry.remove(self)
22
+ super
23
+ end
24
+ end
25
+
26
+ # Prepended to +Sneakers::WorkerGroup+.
27
+ #
28
+ # +ServerEngine::Server#create_worker+ calls +w.extend(worker_module)+, so
29
+ # +WorkerGroup+ lands in the singleton class of the instance rather than
30
+ # being included in a class. A prepend to the module still sits ahead of it
31
+ # in that lookup chain; +worker_id+ and +config+ come from
32
+ # +ServerEngine::Worker+.
33
+ #
34
+ # @see file:docs/DESIGN.md#where-the-hooks-attach
35
+ # @api private
36
+ module WorkerGroup
37
+ # Starts the monitor from +after_fork+ rather than from +run+.
38
+ #
39
+ # +Sneakers::WorkerGroup#run+ calls +after_fork+ as its very first
40
+ # statement and only afterwards resolves +worker_classes+, so this is the
41
+ # earliest point in the fork at which the application's own +after_fork+
42
+ # hook has already run. That ordering matters because resolving the
43
+ # expected consumer count may run application code: under
44
+ # sneakers:active_job the set is a callable registry, and the standard use
45
+ # of +after_fork+ is to re-establish the connections a fork inherited.
46
+ # Resolving it first would run that code against a fork that is not ready.
47
+ #
48
+ # Wrapped in a rescue: instrumentation has no right to prevent a worker
49
+ # from starting, so the worst outcome is a missing mark and a pod restart
50
+ # rather than a pod that stays up with silent queues.
51
+ #
52
+ # @return [void]
53
+ def after_fork
54
+ super
55
+
56
+ begin
57
+ KicksLiveness.start!(
58
+ slot: worker_id,
59
+ processes: config[:workers] || 1,
60
+ consumers: kicks_liveness_expected_consumers
61
+ )
62
+ rescue StandardError => e
63
+ KicksLiveness.config.resolved_logger&.error("[liveness] failed to start: #{e.class}: #{e.message}")
64
+ end
65
+ end
66
+
67
+ # Marks the shutdown as deliberate before the workers unsubscribe.
68
+ #
69
+ # +Sneakers::WorkerGroup#stop+ calls +stop+ on each worker, and
70
+ # +Sneakers::Worker#stop+ waits for the thread pool to drain, which takes
71
+ # as long as the longest in-flight job. The registry empties at the start
72
+ # of that, so without this flag the monitor would report a fault on every
73
+ # ordinary deploy.
74
+ #
75
+ # @return [void]
76
+ def stop
77
+ Registry.stopping!
78
+ super
79
+ end
80
+
81
+ private
82
+
83
+ # The same set the worker gem itself builds its workers from, so the
84
+ # queue list is never duplicated and cannot drift. An array of classes
85
+ # under sneakers:run, a callable registry under sneakers:active_job.
86
+ def kicks_liveness_expected_consumers
87
+ classes = config[:worker_classes]
88
+ classes = classes.call if classes.respond_to?(:call)
89
+ classes.size
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,178 @@
1
+ module KicksLiveness
2
+ # The thread that publishes the liveness mark. Created inside the fork,
3
+ # because threads do not survive +fork+.
4
+ #
5
+ # @see file:docs/DESIGN.md#logging-events-not-the-pulse
6
+ # @api private
7
+ class Monitor
8
+ # @param slot [Integer] supervisor slot of this fork; names the mark file
9
+ # @param processes [Integer] how many forks the probe must wait for
10
+ # @param consumers [Integer] how many workers must subscribe in this process
11
+ # @param config [Configuration]
12
+ # @param heartbeat [Heartbeat, nil] injected in specs; {Attempts} follows its
13
+ # directory, so injecting it separately is not needed
14
+ def initialize(slot:, processes:, consumers:, config:, heartbeat: nil)
15
+ @slot = slot
16
+ @processes = processes
17
+ @consumers = consumers
18
+ @config = config
19
+ @heartbeat = heartbeat || Heartbeat.new
20
+ @attempts = Attempts.new(@heartbeat.dir)
21
+ end
22
+
23
+ # Declares the expected fork count and starts the tick thread.
24
+ # @return [Thread]
25
+ def start!
26
+ attempt, elapsed = @attempts.record!(@slot)
27
+ @heartbeat.declare!(@processes)
28
+ attempt > 1 ? report_respawn(attempt, elapsed) : report_first_start
29
+
30
+ Thread.new do
31
+ Thread.current.name = 'kicks-liveness'
32
+ run_loop
33
+ end
34
+ end
35
+
36
+ # A single step: check, mark if healthy, log any transition. Public so that
37
+ # specs do not have to drive the thread.
38
+ #
39
+ # @return [Boolean] whether the process is healthy at this moment
40
+ def tick!
41
+ # Re-declared on every tick, not only at startup. This file is the probe's
42
+ # only source for how many forks to expect, and nothing else restores it:
43
+ # if the directory is wiped, `touch!` brings back the slot marks while
44
+ # `expected` stays missing, and the probe reports "worker has not started"
45
+ # for the rest of the pod's life. It also lets a respawned set of forks
46
+ # correct the count after the supervisor was told to run fewer of them.
47
+ @heartbeat.declare!(@processes)
48
+
49
+ if Registry.stopping?
50
+ shutdown_tick!
51
+ return true
52
+ end
53
+
54
+ healthy = Registry.healthy?(@consumers)
55
+ if healthy
56
+ @heartbeat.touch!(@slot)
57
+ clear_attempt_once!
58
+ end
59
+ @unhealthy_ticks = healthy ? 0 : unhealthy_ticks + 1
60
+ report(healthy)
61
+ healthy
62
+ end
63
+
64
+ private
65
+
66
+ def report_first_start
67
+ log(:info, "started: dir=#{@heartbeat.dir} max_age=#{@heartbeat.max_age}s tick=#{@config.tick}s " \
68
+ "processes=#{@processes} consumers=#{@consumers}")
69
+ end
70
+
71
+ # A slot that starts again without ever having become healthy is a respawn
72
+ # loop: the supervisor brings the fork back after `start_worker_delay`,
73
+ # which is a fraction of a second, so the process dies long before any
74
+ # single monitor's grace period expires. Two things follow.
75
+ #
76
+ # The start line and the first transition are suppressed. Seeding
77
+ # `@previous` is what does the second part: without it the first tick reads
78
+ # a nil previous state and logs `waiting for N consumers`, which in a storm
79
+ # means hundreds of identical INFO lines a minute and no diagnosis in any of
80
+ # them.
81
+ #
82
+ # The escalation is driven off the marks directory instead of off
83
+ # `unhealthy_ticks`, because that counter dies with the fork and can never
84
+ # reach the threshold. Reporting once per grace window rather than once per
85
+ # respawn is the difference between one line a minute and five a second.
86
+ def report_respawn(attempt, elapsed)
87
+ @previous = false
88
+
89
+ return if elapsed < grace_seconds
90
+
91
+ log(:error, "not healthy #{elapsed.round}s over #{attempt} starts, expected #{@consumers} consumers")
92
+ @attempts.restart_window!(@slot)
93
+ end
94
+
95
+ def grace_seconds
96
+ @config.startup_grace_ticks * interval
97
+ end
98
+
99
+ # Once the slot is healthy the respawn counter has done its job, and the
100
+ # next start of this slot deserves to be treated as a fresh one.
101
+ def clear_attempt_once!
102
+ return if @attempt_cleared
103
+
104
+ @attempts.clear!(@slot)
105
+ @attempt_cleared = true
106
+ end
107
+
108
+ def unhealthy_ticks
109
+ @unhealthy_ticks ||= 0
110
+ end
111
+
112
+ # From the moment shutdown begins the consumer count says nothing about
113
+ # health: the workers unsubscribe one by one while the pool drains, and the
114
+ # process is doing exactly what it was told. So the predicate is dropped and
115
+ # the mark is kept fresh, which leaves a slow drain the whole of
116
+ # terminationGracePeriodSeconds instead of having the probe cut it short at
117
+ # max_age.
118
+ def shutdown_tick!
119
+ @heartbeat.touch!(@slot)
120
+
121
+ return if @shutdown_reported
122
+
123
+ log(:info, 'shutting down: consumers are no longer checked')
124
+ @shutdown_reported = true
125
+ end
126
+
127
+ # Nothing inside this loop may be allowed to kill the thread. It is the only
128
+ # thing writing the mark, and its death is close to invisible: the exception
129
+ # surfaces as a bare backtrace on stderr, never through the gem's logger,
130
+ # while the pod restarts on every probe budget with nothing in the
131
+ # application log to explain it. `sleep` used to sit outside the guarded
132
+ # block, which is exactly how a non-positive tick killed the monitor.
133
+ def run_loop
134
+ loop do
135
+ tick!
136
+ sleep interval
137
+ rescue StandardError => e
138
+ log(:error, "monitor loop failed: #{e.class}: #{e.message}")
139
+ sleep Configuration::DEFAULT_TICK
140
+ end
141
+ end
142
+
143
+ # Values coming from the environment are already clamped by
144
+ # {Heartbeat.env_int}; this covers a tick set programmatically.
145
+ def interval
146
+ tick = @config.tick
147
+ tick.is_a?(Numeric) && tick.positive? ? tick : Configuration::DEFAULT_TICK
148
+ end
149
+
150
+ # Events are logged, not the pulse — the pulse lives in the mark's mtime.
151
+ # ERROR is reserved for genuine failure, because a logger usually comes up
152
+ # with LOG_LEVEL defaulting to error.
153
+ def report(healthy)
154
+ if healthy != @previous
155
+ report_transition(healthy)
156
+ @previous = healthy
157
+ elsif unhealthy_ticks == @config.startup_grace_ticks
158
+ # Startup took longer than normal, which is a failure by now. Staying
159
+ # silent would leave a pod that never comes up saying nothing about it.
160
+ log(:error, "not healthy #{unhealthy_ticks * @config.tick}s, expected #{@consumers} consumers")
161
+ end
162
+ end
163
+
164
+ def report_transition(healthy)
165
+ if healthy
166
+ log(:info, 'healthy')
167
+ elsif @previous.nil?
168
+ log(:info, "waiting for #{@consumers} consumers")
169
+ else
170
+ log(:error, "became unhealthy (expected #{@consumers} consumers)")
171
+ end
172
+ end
173
+
174
+ def log(level, message)
175
+ @config.resolved_logger&.public_send(level, "[liveness] slot #{@slot}: #{message}")
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,16 @@
1
+ # Probe entry point. Requiring this file performs the check and terminates the
2
+ # process, so do not require it from an application.
3
+ #
4
+ # In a container manifest:
5
+ # command: ["bundle", "exec", "kicks-liveness"]
6
+ #
7
+ # The gem namespace is deliberately not loaded: this pulls in one
8
+ # dependency-free file. Images that install gems in +GEM_HOME+ can invoke this
9
+ # file directly as a faster, optional form; see
10
+ # docs/KUBERNETES.md#where-your-image-puts-its-gems.
11
+ require_relative 'heartbeat'
12
+
13
+ ok, message = KicksLiveness::Heartbeat.new.check
14
+
15
+ puts message
16
+ exit(ok ? 0 : 1)
@@ -0,0 +1,18 @@
1
+ require 'rails/railtie'
2
+
3
+ module KicksLiveness
4
+ # Installs the hooks by itself, so a Rails application only needs the
5
+ # configuration block.
6
+ #
7
+ # +to_prepare+ rather than an initializer: an application's lib is normally
8
+ # managed by Zeitwerk and reloadable, and such constants must not be
9
+ # referenced while the application is initialising.
10
+ #
11
+ # @see file:docs/SETUP.md#rails
12
+ # @api private
13
+ class Railtie < ::Rails::Railtie
14
+ initializer 'kicks_liveness.install' do |app|
15
+ app.config.to_prepare { KicksLiveness.install! }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,102 @@
1
+ module KicksLiveness
2
+ # Registry of the workers that have subscribed in the current process, and the
3
+ # health predicate computed over them.
4
+ #
5
+ # The predicate reads only in-process state: no network calls, no disk access.
6
+ # @see file:docs/DESIGN.md#the-health-predicate
7
+ # @api private
8
+ module Registry
9
+ # Initialised at file-load time: single-threaded, and before any fork.
10
+ @workers = []
11
+ @mutex = Mutex.new
12
+ @stopping = false
13
+
14
+ class << self
15
+ # Records a worker as subscribed.
16
+ #
17
+ # Registering the same object twice would break the size comparison in
18
+ # {healthy?} and leave the process permanently unhealthy, so identity is
19
+ # checked first. +equal?+ rather than +==+ on purpose: this is a registry
20
+ # of objects, and a worker class is free to define equality however it
21
+ # likes.
22
+ #
23
+ # @param worker [Sneakers::Worker]
24
+ # @return [Array] the registry
25
+ def add(worker)
26
+ @mutex.synchronize do
27
+ @workers << worker unless @workers.any? { |registered| registered.equal?(worker) }
28
+ @workers
29
+ end
30
+ end
31
+
32
+ # Drops a worker from the registry, on shutdown.
33
+ # @param worker [Sneakers::Worker]
34
+ # @return [Sneakers::Worker, nil]
35
+ def remove(worker)
36
+ @mutex.synchronize { @workers.delete(worker) }
37
+ end
38
+
39
+ # @return [Integer] how many workers are currently registered
40
+ def size
41
+ @mutex.synchronize { @workers.size }
42
+ end
43
+
44
+ # Records that this process is shutting down on purpose.
45
+ #
46
+ # Without it a graceful shutdown is indistinguishable from a failure: the
47
+ # registry empties as each worker unsubscribes, so the monitor would see
48
+ # an incomplete set and report a genuine fault on every deploy.
49
+ #
50
+ # @return [true]
51
+ def stopping!
52
+ @stopping = true
53
+ end
54
+
55
+ # @return [Boolean] whether shutdown has begun
56
+ def stopping?
57
+ @stopping
58
+ end
59
+
60
+ # Whether this process is consuming everything it is supposed to consume.
61
+ #
62
+ # The registry size is compared against +expected+ rather than only
63
+ # checking the workers that did register: four healthy workers out of five
64
+ # look fine one object at a time.
65
+ #
66
+ # @param expected [Integer] how many workers must have subscribed in this process
67
+ # @return [Boolean]
68
+ def healthy?(expected)
69
+ workers = @mutex.synchronize { @workers.dup }
70
+ return false unless expected.positive? && workers.size == expected
71
+
72
+ workers.all? { |worker| alive?(worker) }
73
+ end
74
+
75
+ private
76
+
77
+ def alive?(worker)
78
+ channel = worker.queue.channel
79
+ # No subscription yet; the startup probe is what holds the pod.
80
+ return false if channel.nil?
81
+
82
+ connection = channel.connection
83
+ return true if recovering?(connection)
84
+
85
+ # Connection open but no consumers: the worker silently stopped
86
+ # consuming, and a restart is the only cure.
87
+ channel.open? && connection.open? && channel.any_consumers?
88
+ rescue StandardError
89
+ false
90
+ end
91
+
92
+ # Bunny marks this method @private, so an upgrade may remove it. The
93
+ # guard keeps that from turning every tick into NoMethodError -> rescued
94
+ # -> unhealthy; the cost is that the recovery exemption would then vanish
95
+ # silently.
96
+ def recovering?(connection)
97
+ connection.respond_to?(:recovering_from_network_failure?) &&
98
+ connection.recovering_from_network_failure?
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,4 @@
1
+ module KicksLiveness
2
+ # @return [String] gem version
3
+ VERSION = '0.1.0'.freeze
4
+ end
@@ -0,0 +1,86 @@
1
+ require_relative 'kicks_liveness/version'
2
+ require_relative 'kicks_liveness/heartbeat'
3
+ require_relative 'kicks_liveness/attempts'
4
+ require_relative 'kicks_liveness/configuration'
5
+ require_relative 'kicks_liveness/registry'
6
+ require_relative 'kicks_liveness/monitor'
7
+ require_relative 'kicks_liveness/hooks'
8
+
9
+ # Liveness probe for Kicks and Sneakers workers, backed by a tmpfs heartbeat.
10
+ #
11
+ # The worker publishes a mark from inside its own process, checking its Bunny
12
+ # consumers in memory; the probe reads only the mark's mtime. No Rails, no call
13
+ # to the broker.
14
+ #
15
+ # @see file:docs/SETUP.md
16
+ # @see file:docs/DESIGN.md
17
+ module KicksLiveness
18
+ class << self
19
+ # @return [Configuration] the process-wide configuration
20
+ def config
21
+ @config ||= Configuration.new
22
+ end
23
+
24
+ # Yields the configuration for the application to adjust.
25
+ #
26
+ # @example
27
+ # KicksLiveness.configure do |config|
28
+ # config.enabled = !Rails.env.local?
29
+ # end
30
+ #
31
+ # @yieldparam config [Configuration]
32
+ # @return [Configuration]
33
+ def configure
34
+ yield(config)
35
+ config
36
+ end
37
+
38
+ # Installs the two hooks the gem works through. Must run before the runner
39
+ # starts; in Rails a Railtie does it. Idempotent — prepending the same
40
+ # module twice is a no-op.
41
+ #
42
+ # Two requires, not one: +Sneakers::Worker+ is defined by lib/sneakers.rb,
43
+ # while +Sneakers::WorkerGroup+ comes only with sneakers/workergroup, which
44
+ # ships with the runner and is absent in web processes. Relying on the
45
+ # application having required sneakers itself is not safe: with
46
+ # <tt>gem 'kicks', require: false</tt> the to_prepare hook runs earlier and
47
+ # would fail on NameError.
48
+ #
49
+ # @raise [LoadError] if neither +kicks+ nor +sneakers+ is available
50
+ # @return [Module]
51
+ # @see file:docs/SETUP.md#installing-the-hooks
52
+ def install!
53
+ begin
54
+ require 'sneakers'
55
+ require 'sneakers/workergroup'
56
+ rescue LoadError => e
57
+ # The original message is kept: this rescue fires just as readily when
58
+ # the worker gem is installed but something inside it fails to load — a
59
+ # bunny or serverengine version that cannot be required, an extension
60
+ # not built for the image. Reporting that as "the gem is missing" sends
61
+ # the reader to check a Gemfile.lock that is perfectly fine.
62
+ raise LoadError, 'kicks_liveness requires the kicks (>= 3.0) or sneakers (>= 2.11) gem ' \
63
+ "(#{e.message})"
64
+ end
65
+
66
+ ::Sneakers::Worker.prepend(Hooks::Worker)
67
+ ::Sneakers::WorkerGroup.prepend(Hooks::WorkerGroup)
68
+ end
69
+
70
+ # Starts the monitor thread. Called from the WorkerGroup hook, already
71
+ # inside the fork.
72
+ #
73
+ # @param slot [Integer] supervisor slot of this fork
74
+ # @param processes [Integer] how many forks the probe must wait for
75
+ # @param consumers [Integer] how many workers must subscribe here
76
+ # @return [Thread, nil] nil when disabled by configuration
77
+ # @api private
78
+ def start!(slot:, processes:, consumers:)
79
+ return unless config.enabled?
80
+
81
+ Monitor.new(slot: slot, processes: processes, consumers: consumers, config: config).start!
82
+ end
83
+ end
84
+ end
85
+
86
+ require_relative 'kicks_liveness/railtie' if defined?(Rails::Railtie)