kicks_liveness 0.1.1 → 0.1.3

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.
@@ -1,19 +1,157 @@
1
1
  module KicksLiveness
2
- # The liveness mark on the filesystem: written by the worker, read by the
3
- # probe.
2
+ # Identifies the container generation and PID topology from Linux procfs,
3
+ # independently in the worker and each exec probe.
4
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.
5
+ # @see file:docs/LIMITATIONS.md#container-generations-need-linux-procfs-and-a-private-pid-namespace
6
+ # @api private
7
+ module ContainerGeneration
8
+ # @return [String] this process's mount namespace link
9
+ SELF_NAMESPACE = '/proc/self/ns/mnt'.freeze
10
+ # @return [String] PID 1's mount namespace link
11
+ PID1_NAMESPACE = '/proc/1/ns/mnt'.freeze
12
+ # @return [String] PID 1's status line, which carries its start time
13
+ PID1_STAT = '/proc/1/stat'.freeze
14
+
15
+ # One procfs inspection, as a value.
16
+ #
17
+ # +:verified+:: matching mount namespaces, with a generation.
18
+ # +:unsafe+:: different mount namespaces; no trustworthy generation.
19
+ # +:unverifiable+:: a generation, but PID 1's namespace could not be read.
20
+ # +:unavailable+:: Linux procfs yielded no generation.
21
+ # +:non_linux+:: another operating system.
22
+ #
23
+ # @api private
24
+ class Result
25
+ class << self
26
+ # @param generation [String]
27
+ # @return [Result]
28
+ def verified(generation) = new(:verified, generation: generation)
29
+
30
+ # @param detail [String] which namespaces differ
31
+ # @return [Result]
32
+ def unsafe(detail) = new(:unsafe, detail: detail)
33
+
34
+ # @param generation [String]
35
+ # @param detail [String] what could not be read
36
+ # @return [Result]
37
+ def unverifiable(generation, detail) = new(:unverifiable, generation: generation, detail: detail)
38
+
39
+ # @param detail [String] what could not be read
40
+ # @return [Result]
41
+ def unavailable(detail) = new(:unavailable, detail: detail)
42
+
43
+ # @return [Result]
44
+ def non_linux = new(:non_linux)
45
+ end
46
+
47
+ # @return [Symbol] +:verified+, +:unsafe+, +:unverifiable+, +:unavailable+ or +:non_linux+
48
+ attr_reader :state
49
+ # @return [String, nil] the container incarnation, in the two states that have one
50
+ attr_reader :generation
51
+ # @return [String, nil] what could not be read, or which namespaces differ
52
+ attr_reader :detail
53
+
54
+ # @param state [Symbol]
55
+ # @param generation [String, nil]
56
+ # @param detail [String, nil]
57
+ def initialize(state, generation: nil, detail: nil)
58
+ @state = state
59
+ @generation = generation
60
+ @detail = detail
61
+ freeze
62
+ end
63
+
64
+ # @return [Boolean]
65
+ def verified? = @state == :verified
66
+
67
+ # @return [Boolean]
68
+ def unsafe? = @state == :unsafe
69
+ end
70
+
71
+ class << self
72
+ # Reads the three procfs entries the protocol needs, in the one order that
73
+ # cannot be talked out of a rejection: an unsafe topology is decided before
74
+ # anything is derived from PID 1, because in that topology PID 1 belongs to
75
+ # the pod sandbox or the node and its start time means nothing here.
76
+ #
77
+ # @param linux [Boolean] injected in specs, where procfs is stubbed
78
+ # @return [Result]
79
+ def current(linux: linux?)
80
+ return Result.non_linux unless linux
81
+
82
+ mine, mine_error = read_link(SELF_NAMESPACE)
83
+ pid1, pid1_error = read_link(PID1_NAMESPACE)
84
+ return Result.unsafe("PID 1 is in #{pid1}, this process in #{mine}") if another_namespace?(mine, pid1)
85
+
86
+ started_at, stat_error = pid1_started_at
87
+ return Result.unavailable(mine_error || stat_error) unless mine && started_at
88
+
89
+ generation = "#{mine}:#{started_at}"
90
+ pid1 ? Result.verified(generation) : Result.unverifiable(generation, pid1_error)
91
+ end
92
+
93
+ private
94
+
95
+ # Missing procfs does not identify the OS. JRuby reports "java", so use
96
+ # its predefined RbConfig to identify the host. CRuby on Linux answers
97
+ # first, including under --disable-gems where RbConfig may be undefined.
98
+ # https://bugs.ruby-lang.org/issues/8090
99
+ def linux?
100
+ return true if RUBY_PLATFORM.include?('linux')
101
+ return false unless defined?(RbConfig)
102
+
103
+ RbConfig::CONFIG['host_os'].to_s.include?('linux')
104
+ end
105
+
106
+ # Only a comparison both sides of which were read can prove anything.
107
+ # @param mine [String, nil] this process's mount namespace
108
+ # @param pid1 [String, nil] PID 1's mount namespace
109
+ # @return [Boolean]
110
+ def another_namespace?(mine, pid1)
111
+ !mine.nil? && !pid1.nil? && mine != pid1
112
+ end
113
+
114
+ # @param path [String] a procfs namespace link
115
+ # @return [Array(String, nil), Array(nil, String)] the link target, or why it could not be read
116
+ def read_link(path)
117
+ [File.readlink(path), nil]
118
+ rescue StandardError => e
119
+ [nil, "cannot read #{path} (#{e.class})"]
120
+ end
121
+
122
+ # @return [Array(String, nil), Array(nil, String)] PID 1's start time, or why it is missing
123
+ def pid1_started_at
124
+ started_at = start_time_field(File.read(PID1_STAT))
125
+ return [started_at.to_s, nil] if started_at&.positive?
126
+ return [nil, 'PID 1 start time is zero; possible hostPID or virtualized procfs'] if started_at&.zero?
127
+
128
+ [nil, "cannot parse #{PID1_STAT}"]
129
+ rescue StandardError => e
130
+ [nil, "cannot read #{PID1_STAT} (#{e.class})"]
131
+ end
132
+
133
+ # @param stat [String] the contents of /proc/1/stat
134
+ # @return [Integer, nil]
135
+ def start_time_field(stat)
136
+ closing_parenthesis = stat.rindex(') ')
137
+ return unless closing_parenthesis
138
+
139
+ # After the command in parentheses, field 3 (`state`) is index 0. Process
140
+ # start time is field 22, therefore index 19 in this tail.
141
+ started_at = stat[(closing_parenthesis + 2)..].split[19]
142
+ Integer(started_at) if started_at
143
+ rescue ArgumentError, TypeError
144
+ nil
145
+ end
146
+ end
147
+ end
148
+
149
+ # Filesystem contract shared by the worker and probe. The marks directory
150
+ # must be on tmpfs (an emptyDir with medium: Memory in Kubernetes).
9
151
  #
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.
152
+ # The probe loads this file alone, including under --disable-gems. Keep its
153
+ # read path free of requires and references to other gem code; fileutils is
154
+ # loaded lazily only when the worker needs to create parent directories.
17
155
  #
18
156
  # @see file:docs/DESIGN.md#why-the-heartbeat-file-has-no-require-of-its-own
19
157
  class Heartbeat
@@ -22,6 +160,10 @@ module KicksLiveness
22
160
  # @return [Integer] seconds after which a mark is stale, by default
23
161
  DEFAULT_MAX_AGE = 45
24
162
 
163
+ # Shared verdict for unsafe topology evidence from either participant.
164
+ # @return [String]
165
+ UNSUPPORTED_TOPOLOGY = 'unsupported PID topology: pod must not set shareProcessNamespace or hostPID'.freeze
166
+
25
167
  # @return [Hash{Symbol => String}] setting name to environment variable
26
168
  ENV_NAMES = {
27
169
  dir: 'KICKS_LIVENESS_DIR',
@@ -29,6 +171,14 @@ module KicksLiveness
29
171
  tick: 'KICKS_LIVENESS_TICK'
30
172
  }.freeze
31
173
 
174
+ # Inspects this process's container generation and PID topology.
175
+ # @return [ContainerGeneration::Result] what this process can prove about
176
+ # its own incarnation
177
+ # @api private
178
+ def self.topology
179
+ ContainerGeneration.current
180
+ end
181
+
32
182
  # An empty string counts as unset: in a ConfigMap that is what you get by
33
183
  # declaring a key and leaving it blank.
34
184
  #
@@ -39,15 +189,9 @@ module KicksLiveness
39
189
  value unless value.nil? || value.empty?
40
190
  end
41
191
 
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.
192
+ # Environment durations require positive integers. Invalid input falls
193
+ # back to the default; accepting zero or negatives can stop the monitor
194
+ # or make healthy marks stale immediately.
51
195
  #
52
196
  # @param key [Symbol] one of the keys of {ENV_NAMES}
53
197
  # @param default [Integer]
@@ -72,80 +216,95 @@ module KicksLiveness
72
216
 
73
217
  # @param dir [String] marks directory
74
218
  # @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)
219
+ # @param topology [ContainerGeneration::Result] what this process proved
220
+ # about its incarnation; injected in specs
221
+ def initialize(
222
+ dir: Heartbeat.env_dir,
223
+ max_age: Heartbeat.env_max_age,
224
+ topology: Heartbeat.topology
225
+ )
76
226
  @dir = dir
77
227
  @max_age = max_age
228
+ @topology = topology
229
+ @generation = topology.generation
78
230
  end
79
231
 
80
- attr_reader :dir, :max_age
232
+ # @return [String] marks directory
233
+ attr_reader :dir
234
+ # @return [Integer] seconds after which a mark is considered stale
235
+ attr_reader :max_age
236
+ # @return [ContainerGeneration::Result] what this process proved about its
237
+ # incarnation, and the incarnation itself where there is one
238
+ attr_reader :topology
81
239
 
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.
240
+ # Declares how many fork marks the probe requires. Atomic publication
241
+ # prevents a concurrent probe from reading a truncated count or generation.
91
242
  #
92
243
  # @param processes [Integer]
93
- # @return [void]
244
+ # @return [Integer] bytes written
94
245
  def declare!(processes)
246
+ return reject_topology! if @topology.unsafe?
247
+
95
248
  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)
249
+ atomic_write(generation_path, @generation) if @generation
250
+ atomic_write(expected_path, processes)
100
251
  end
101
252
 
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.
253
+ # Refreshes the supervisor slot, so a respawn replaces the old mark instead
254
+ # of leaving a stale PID-named file. The probe checks generation and mtime;
255
+ # the remaining contents are diagnostic.
111
256
  #
112
257
  # @param slot [Integer] supervisor slot of this fork
113
258
  # @return [Integer] bytes written
114
259
  def touch!(slot)
260
+ return reject_topology! if @topology.unsafe?
261
+
262
+ make_dir
263
+ contents = "#{Time.now.utc.strftime('%FT%TZ')} pid=#{Process.pid} slot=#{slot}"
264
+ contents = "#{contents} generation=#{@generation}" if @generation
265
+ atomic_write(slot_path(slot), "#{contents}\n")
266
+ end
267
+
268
+ # Publishes the terminal fail-closed state for a proven unsafe topology,
269
+ # instead of any mark.
270
+ #
271
+ # Explicit evidence is needed because inherited generation and slot marks
272
+ # may still be fresh. It precedes normal writes and is never removed during
273
+ # the marks volume's lifetime.
274
+ #
275
+ # @return [Integer, nil] bytes written, or nil when this process has not
276
+ # proven the topology unsafe and so has nothing to publish
277
+ # @api private
278
+ def reject_topology!
279
+ return unless @topology.unsafe?
280
+
115
281
  make_dir
116
- File.write(slot_path(slot), "#{Time.now.utc.strftime('%FT%TZ')} pid=#{Process.pid} slot=#{slot}\n")
282
+ atomic_write(topology_path, "#{[UNSUPPORTED_TOPOLOGY, @topology.detail].compact.join("\n")}\n")
117
283
  end
118
284
 
119
285
  # The probe side: is every declared fork's mark present and fresh?
120
286
  #
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.
287
+ # Published or local unsafe evidence takes precedence over every generation
288
+ # and freshness check, including when this probe cannot verify the topology.
123
289
  #
124
290
  # @param now [Time] injected in specs
125
291
  # @return [Array(Boolean, String)] health, and the reason to print on stdout
126
292
  def check(now: Time.now.utc)
293
+ return [false, UNSUPPORTED_TOPOLOGY] if unsupported_topology?
294
+
127
295
  processes = expected
128
296
  return [false, "no #{expected_path}: worker has not started yet"] unless processes&.positive?
297
+ return [false, 'heartbeat belongs to a previous container: worker has not started yet'] unless current_generation?
129
298
 
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
299
+ problems = (0...processes).filter_map { |slot| problem_for(slot, now) }
136
300
 
137
301
  problems.empty? ? [true, "#{processes} process(es) healthy"] : [false, problems.join('; ')]
138
302
  end
139
303
 
140
304
  private
141
305
 
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.
306
+ # Try one mkdir first; competing forks may already have created it.
307
+ # Only missing parent directories need fileutils, loaded on the worker path.
149
308
  def make_dir
150
309
  Dir.mkdir(@dir)
151
310
  rescue Errno::EEXIST
@@ -163,12 +322,63 @@ module KicksLiveness
163
322
  File.join(@dir, "worker-#{slot}")
164
323
  end
165
324
 
325
+ def generation_path
326
+ File.join(@dir, 'generation')
327
+ end
328
+
329
+ def topology_path
330
+ File.join(@dir, 'topology-unsafe')
331
+ end
332
+
333
+ # The worker's published proof first, then this process's own: either is
334
+ # enough, and no weaker result from the other side can take it back.
335
+ #
336
+ # Existence is the whole signal, so this guard cannot be defeated by a
337
+ # partial read and holds even where the file itself is not readable. Its
338
+ # contents are there for a human running `cat`.
339
+ def unsupported_topology?
340
+ File.exist?(topology_path) || @topology.unsafe?
341
+ end
342
+
166
343
  def expected
167
344
  Integer(File.read(expected_path))
168
345
  rescue StandardError
169
346
  nil
170
347
  end
171
348
 
349
+ def declared_generation
350
+ File.read(generation_path)
351
+ rescue StandardError
352
+ nil
353
+ end
354
+
355
+ def current_generation?
356
+ !@generation || declared_generation == @generation
357
+ end
358
+
359
+ def previous_generation?(path)
360
+ @generation && File.read(path)[/\bgeneration=(\S+)/, 1] != @generation
361
+ end
362
+
363
+ def problem_for(slot, now)
364
+ path = slot_path(slot)
365
+ age = age_of(path, now)
366
+ return "worker-#{slot} missing" if age.nil?
367
+ return "worker-#{slot} belongs to a previous container" if previous_generation?(path)
368
+
369
+ "worker-#{slot} stale #{age.round}s > #{@max_age}s" if age > @max_age
370
+ rescue StandardError
371
+ "worker-#{slot} unreadable"
372
+ end
373
+
374
+ def atomic_write(path, contents)
375
+ # The pid keeps concurrent forks from sharing the temporary file.
376
+ tmp = "#{path}.#{Process.pid}"
377
+ bytes = File.write(tmp, contents)
378
+ File.rename(tmp, path)
379
+ bytes
380
+ end
381
+
172
382
  def age_of(path, now)
173
383
  now - File.mtime(path)
174
384
  rescue StandardError
@@ -25,29 +25,17 @@ module KicksLiveness
25
25
 
26
26
  # Prepended to +Sneakers::WorkerGroup+.
27
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+.
28
+ # ServerEngine extends each worker instance with WorkerGroup. A prepend to
29
+ # the module still intercepts that singleton-class chain; worker_id and
30
+ # config come from ServerEngine::Worker.
33
31
  #
34
32
  # @see file:docs/DESIGN.md#where-the-hooks-attach
35
33
  # @api private
36
34
  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.
35
+ # Run after the application's after_fork restores inherited connections.
36
+ # Resolving callable worker_classes earlier could run application code before
37
+ # those connections are ready. Rescue startup errors so instrumentation
38
+ # cannot prevent the worker itself from starting.
51
39
  #
52
40
  # @return [void]
53
41
  def after_fork
@@ -64,13 +52,8 @@ module KicksLiveness
64
52
  end
65
53
  end
66
54
 
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.
55
+ # Set the flag before any worker unsubscribes or starts draining, so the
56
+ # monitor does not treat deliberate removal as a consumer failure.
74
57
  #
75
58
  # @return [void]
76
59
  def stop
@@ -91,9 +74,8 @@ module KicksLiveness
91
74
  nil
92
75
  end
93
76
 
94
- # The same set the worker gem itself builds its workers from, so the
95
- # queue list is never duplicated and cannot drift. An array of classes
96
- # under sneakers:run, a callable registry under sneakers:active_job.
77
+ # Use the worker gem's own set: an array for sneakers:run, a callable registry
78
+ # for sneakers:active_job. This must run after the application's after_fork.
97
79
  def kicks_liveness_expected_consumers
98
80
  classes = config[:worker_classes]
99
81
  classes = classes.call if classes.respond_to?(:call)
@@ -17,15 +17,18 @@ module KicksLiveness
17
17
  @consumers = consumers
18
18
  @config = config
19
19
  @heartbeat = heartbeat || Heartbeat.new
20
- @attempts = Attempts.new(@heartbeat.dir)
20
+ @attempts = Attempts.new(@heartbeat.dir, generation: @heartbeat.topology.generation)
21
21
  end
22
22
 
23
23
  # Declares the expected fork count and starts the tick thread.
24
- # @return [Thread]
24
+ # @return [Thread, nil] nil when the PID topology is proven unsupported
25
25
  def start!
26
+ return reject_topology! if @heartbeat.topology.unsafe?
27
+
26
28
  attempt, elapsed = @attempts.record!(@slot)
27
29
  @heartbeat.declare!(@processes)
28
30
  attempt > 1 ? report_respawn(attempt, elapsed) : report_first_start
31
+ report_topology
29
32
 
30
33
  Thread.new do
31
34
  Thread.current.name = 'kicks-liveness'
@@ -38,12 +41,8 @@ module KicksLiveness
38
41
  #
39
42
  # @return [Boolean] whether the process is healthy at this moment
40
43
  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.
44
+ # Restore expected after directory removal; refreshing a slot alone
45
+ # cannot recover a probe whose expected count is missing.
47
46
  @heartbeat.declare!(@processes)
48
47
 
49
48
  if Registry.stopping?
@@ -63,26 +62,30 @@ module KicksLiveness
63
62
 
64
63
  private
65
64
 
65
+ # Publish unsafe evidence without recording a start or launching the loop.
66
+ def reject_topology!
67
+ # Reported before the state is published: a marks directory that cannot be
68
+ # written to is exactly the case where the log line is all the operator
69
+ # will get.
70
+ report_topology
71
+ @heartbeat.reject_topology!
72
+ nil
73
+ end
74
+
75
+ # Claim before logging so simultaneous forks cannot report the same state.
76
+ def report_topology
77
+ level, message = TopologyReport.new(@heartbeat.dir, @heartbeat.topology).claim!
78
+
79
+ log(level, message) if level
80
+ end
81
+
66
82
  def report_first_start
67
83
  log(:info, "started: dir=#{@heartbeat.dir} max_age=#{@heartbeat.max_age}s tick=#{@config.tick}s " \
68
84
  "processes=#{@processes} consumers=#{@consumers}")
69
85
  end
70
86
 
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.
87
+ # Seed @previous to suppress repeated waiting messages. Escalation uses the
88
+ # persistent attempt window because unhealthy_ticks dies with each fork.
86
89
  def report_respawn(attempt, elapsed)
87
90
  @previous = false
88
91
 
@@ -96,8 +99,7 @@ module KicksLiveness
96
99
  @config.startup_grace_ticks * interval
97
100
  end
98
101
 
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.
102
+ # Clear once after recovery so a later failure starts a new unhealthy run.
101
103
  def clear_attempt_once!
102
104
  return if @attempt_cleared
103
105
 
@@ -109,12 +111,9 @@ module KicksLiveness
109
111
  @unhealthy_ticks ||= 0
110
112
  end
111
113
 
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.
114
+ # Deliberate drain suspends the predicate for the rest of the process.
115
+ # An external deadline must bound it; the monitor does not own that deadline.
116
+ # @see file:docs/LIMITATIONS.md#graceful-shutdown-is-safe-only-when-something-else-owns-the-deadline
118
117
  def shutdown_tick!
119
118
  @heartbeat.touch!(@slot)
120
119
 
@@ -124,12 +123,8 @@ module KicksLiveness
124
123
  @shutdown_reported = true
125
124
  end
126
125
 
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.
126
+ # Guard tick and sleep together so an exception cannot kill the only
127
+ # heartbeat writer. Recovery uses the default interval.
133
128
  def run_loop
134
129
  loop do
135
130
  tick!
@@ -147,16 +142,13 @@ module KicksLiveness
147
142
  tick.is_a?(Numeric) && tick.positive? ? tick : Configuration::DEFAULT_TICK
148
143
  end
149
144
 
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.
145
+ # Log transitions and startup escalation; the periodic pulse is the mtime.
153
146
  def report(healthy)
154
147
  if healthy != @previous
155
148
  report_transition(healthy)
156
149
  @previous = healthy
157
150
  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.
151
+ # Report once when a surviving monitor exhausts startup grace.
160
152
  log(:error, "not healthy #{unhealthy_ticks * @config.tick}s, expected #{@consumers} consumers")
161
153
  end
162
154
  end
@@ -174,10 +166,8 @@ module KicksLiveness
174
166
  def log(level, message)
175
167
  @config.resolved_logger&.public_send(level, "[liveness] slot #{@slot}: #{message}")
176
168
  rescue StandardError
177
- # Logging is diagnostic, while the heartbeat is the liveness contract. A
178
- # broken custom logger must not prevent a mark from being written or kill
179
- # the only thread that can refresh it. There is deliberately no fallback
180
- # log here: calling the same logger again would only repeat the failure.
169
+ # Logger failures must not stop the heartbeat writer. Do not retry the
170
+ # same logger from its failure path.
181
171
  nil
182
172
  end
183
173
  end
@@ -14,11 +14,8 @@ module KicksLiveness
14
14
  class << self
15
15
  # Records a worker as subscribed.
16
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.
17
+ # Duplicate objects must not inflate the expected-count check. Use identity
18
+ # rather than == because worker classes can override equality.
22
19
  #
23
20
  # @param worker [Sneakers::Worker]
24
21
  # @return [Array] the registry
@@ -36,16 +33,7 @@ module KicksLiveness
36
33
  @mutex.synchronize { @workers.delete(worker) }
37
34
  end
38
35
 
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.
36
+ # Mark deliberate shutdown before unsubscription changes the predicate.
49
37
  #
50
38
  # @return [true]
51
39
  def stopping!