kicks_liveness 0.1.2 → 0.1.4

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.
@@ -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,18 +41,11 @@ 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
- if Registry.stopping?
50
- shutdown_tick!
51
- return true
52
- end
48
+ return shutdown_tick! if Registry.stopping?
53
49
 
54
50
  healthy = Registry.healthy?(@consumers)
55
51
  if healthy
@@ -63,26 +59,30 @@ module KicksLiveness
63
59
 
64
60
  private
65
61
 
62
+ # Publish unsafe evidence without recording a start or launching the loop.
63
+ def reject_topology!
64
+ # Reported before the state is published: a marks directory that cannot be
65
+ # written to is exactly the case where the log line is all the operator
66
+ # will get.
67
+ report_topology
68
+ @heartbeat.reject_topology!
69
+ nil
70
+ end
71
+
72
+ # Claim before logging so simultaneous forks cannot report the same state.
73
+ def report_topology
74
+ level, message = TopologyReport.new(@heartbeat.dir, @heartbeat.topology).claim!
75
+
76
+ log(level, message) if level
77
+ end
78
+
66
79
  def report_first_start
67
80
  log(:info, "started: dir=#{@heartbeat.dir} max_age=#{@heartbeat.max_age}s tick=#{@config.tick}s " \
68
81
  "processes=#{@processes} consumers=#{@consumers}")
69
82
  end
70
83
 
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.
84
+ # Seed @previous to suppress repeated waiting messages. Escalation uses the
85
+ # persistent attempt window because unhealthy_ticks dies with each fork.
86
86
  def report_respawn(attempt, elapsed)
87
87
  @previous = false
88
88
 
@@ -96,8 +96,7 @@ module KicksLiveness
96
96
  @config.startup_grace_ticks * interval
97
97
  end
98
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.
99
+ # Clear once after recovery so a later failure starts a new unhealthy run.
101
100
  def clear_attempt_once!
102
101
  return if @attempt_cleared
103
102
 
@@ -109,27 +108,32 @@ module KicksLiveness
109
108
  @unhealthy_ticks ||= 0
110
109
  end
111
110
 
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!
111
+ # The first stop callback, not a delayed tick, starts the monotonic deadline.
112
+ # Expiry stops refreshes; ordinary mark staleness then fails the probe.
113
+ # @see file:docs/LIMITATIONS.md#graceful-shutdown-has-a-finite-heartbeat-exemption
114
+ def shutdown_tick! # rubocop:disable Naming/PredicateMethod -- Publishes the heartbeat and records transitions.
115
+ @shutdown_deadline ||= Registry.stopping_at + @config.shutdown_grace
116
+ report_shutdown_once
117
+
118
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @shutdown_deadline
119
+ log(:error, 'shutdown grace expired: heartbeat is no longer refreshed') unless @shutdown_expired
120
+ @shutdown_expired = true
121
+ return false
122
+ end
123
+
119
124
  @heartbeat.touch!(@slot)
125
+ true
126
+ end
120
127
 
128
+ def report_shutdown_once
121
129
  return if @shutdown_reported
122
130
 
123
131
  log(:info, 'shutting down: consumers are no longer checked')
124
132
  @shutdown_reported = true
125
133
  end
126
134
 
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.
135
+ # Guard tick and sleep together so an exception cannot kill the only
136
+ # heartbeat writer. Recovery uses the default interval.
133
137
  def run_loop
134
138
  loop do
135
139
  tick!
@@ -147,16 +151,13 @@ module KicksLiveness
147
151
  tick.is_a?(Numeric) && tick.positive? ? tick : Configuration::DEFAULT_TICK
148
152
  end
149
153
 
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.
154
+ # Log transitions and startup escalation; the periodic pulse is the mtime.
153
155
  def report(healthy)
154
156
  if healthy != @previous
155
157
  report_transition(healthy)
156
158
  @previous = healthy
157
159
  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
+ # Report once when a surviving monitor exhausts startup grace.
160
161
  log(:error, "not healthy #{unhealthy_ticks * @config.tick}s, expected #{@consumers} consumers")
161
162
  end
162
163
  end
@@ -174,10 +175,8 @@ module KicksLiveness
174
175
  def log(level, message)
175
176
  @config.resolved_logger&.public_send(level, "[liveness] slot #{@slot}: #{message}")
176
177
  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.
178
+ # Logger failures must not stop the heartbeat writer. Do not retry the
179
+ # same logger from its failure path.
181
180
  nil
182
181
  end
183
182
  end
@@ -9,16 +9,13 @@ module KicksLiveness
9
9
  # Initialised at file-load time: single-threaded, and before any fork.
10
10
  @workers = []
11
11
  @mutex = Mutex.new
12
- @stopping = false
12
+ @stopping_at = nil
13
13
 
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,25 +33,26 @@ 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
- def stopping!
52
- @stopping = true
39
+ def stopping! # rubocop:disable Naming/PredicateMethod -- Records the stop notification.
40
+ @mutex.synchronize { @stopping_at ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) }
41
+ true
53
42
  end
54
43
 
55
44
  # @return [Boolean] whether shutdown has begun
56
45
  def stopping?
57
- @stopping
46
+ !stopping_at.nil?
47
+ end
48
+
49
+ # Captured by the first stop callback, even if the monitor's next tick is
50
+ # delayed. Repeated callbacks cannot extend the shutdown exemption. This
51
+ # is process-local: a sibling replacement fork has not been asked to stop.
52
+ #
53
+ # @return [Float, nil] monotonic time of the first stop notification
54
+ def stopping_at
55
+ @mutex.synchronize { @stopping_at }
58
56
  end
59
57
 
60
58
  # Whether this process is consuming everything it is supposed to consume.
@@ -0,0 +1,110 @@
1
+ require 'fileutils'
2
+
3
+ module KicksLiveness
4
+ # Claims worker topology reports across fork respawns. Kept outside
5
+ # {Heartbeat} because the probe does not report these diagnostics.
6
+ #
7
+ # @see file:docs/LIMITATIONS.md#container-generations-need-linux-procfs-and-a-private-pid-namespace
8
+ # @api private
9
+ class TopologyReport
10
+ # @param dir [String] marks directory
11
+ # @param topology [ContainerGeneration::Result] what this worker proved
12
+ def initialize(dir, topology)
13
+ @dir = dir
14
+ @topology = topology
15
+ end
16
+
17
+ # Claims the right to report this state, once.
18
+ #
19
+ # Unverifiable results use a generation key; unavailable and unsafe results
20
+ # have no trustworthy generation and use only their state. Verification
21
+ # clears the flag so a later degradation can be reported again.
22
+ #
23
+ # @return [Array(Symbol, String), nil] the level and the line to log, or nil
24
+ # when there is nothing new to say
25
+ def claim!
26
+ key = key_for(@topology)
27
+
28
+ unless key
29
+ forget! if @topology.verified?
30
+ return nil
31
+ end
32
+
33
+ return nil unless claim(key)
34
+
35
+ report_for(@topology)
36
+ end
37
+
38
+ private
39
+
40
+ def path
41
+ File.join(@dir, 'topology-report')
42
+ end
43
+
44
+ def key_for(topology)
45
+ case topology.state
46
+ when :unsafe then 'unsafe'
47
+ when :unverifiable then "unverifiable #{topology.generation}"
48
+ when :unavailable then 'unavailable'
49
+ end
50
+ end
51
+
52
+ # Only unsafe changes the probe verdict and is reported at ERROR.
53
+ def report_for(topology)
54
+ case topology.state
55
+ when :unsafe
56
+ [:error, "#{Heartbeat::UNSUPPORTED_TOPOLOGY} (#{topology.detail}); " \
57
+ 'liveness probes fail until the pod is recreated without those settings']
58
+ when :unverifiable
59
+ [:warn, "PID topology unverified (#{topology.detail}): the container generation still scopes the heartbeat, " \
60
+ 'but shareProcessNamespace and hostPID cannot be ruled out']
61
+ else
62
+ [:warn, "no container generation (#{topology.detail}): falling back to freshness only, " \
63
+ 'which cannot reject a heartbeat inherited from a previous container']
64
+ end
65
+ end
66
+
67
+ # The runner starts all of its forks together, so the read and write have
68
+ # to be one inter-process operation: an atomic rename protects readers from
69
+ # a partial file, but does not stop every fork reading the old key and each
70
+ # claiming the same report. The state file is also the lock file, so no
71
+ # second path is added to the marks contract. Once it has existed it is
72
+ # cleared by truncation rather than unlink: every process must keep locking
73
+ # the same inode.
74
+ def claim(key)
75
+ synchronize(create: true) do |state|
76
+ state.rewind
77
+ next false if state.read.chomp == key
78
+
79
+ state.rewind
80
+ state.truncate(0)
81
+ state.write("#{key}\n")
82
+ state.flush
83
+ true
84
+ end
85
+ rescue StandardError
86
+ # A report that cannot be remembered must still be made. The cost is
87
+ # repetition on the next start, not silence about a lost guarantee.
88
+ true
89
+ end
90
+
91
+ def forget!
92
+ return unless File.exist?(path)
93
+
94
+ synchronize(create: false) { |state| state.truncate(0) }
95
+ rescue StandardError
96
+ nil
97
+ end
98
+
99
+ def synchronize(create:)
100
+ FileUtils.mkdir_p(@dir)
101
+ flags = File::RDWR
102
+ flags |= File::CREAT if create
103
+ File.open(path, flags, 0o644) do |state|
104
+ raise IOError, "cannot lock #{path}" unless state.flock(File::LOCK_EX)
105
+
106
+ yield state
107
+ end
108
+ end
109
+ end
110
+ end
@@ -1,4 +1,4 @@
1
1
  module KicksLiveness
2
2
  # @return [String] gem version
3
- VERSION = '0.1.2'.freeze
3
+ VERSION = '0.1.4'.freeze
4
4
  end
@@ -1,6 +1,7 @@
1
1
  require_relative 'kicks_liveness/version'
2
2
  require_relative 'kicks_liveness/heartbeat'
3
3
  require_relative 'kicks_liveness/attempts'
4
+ require_relative 'kicks_liveness/topology_report'
4
5
  require_relative 'kicks_liveness/configuration'
5
6
  require_relative 'kicks_liveness/registry'
6
7
  require_relative 'kicks_liveness/monitor'
@@ -76,7 +77,8 @@ module KicksLiveness
76
77
  # @param slot [Integer] supervisor slot of this fork
77
78
  # @param processes [Integer] how many forks the probe must wait for
78
79
  # @param consumers [Integer] how many workers must subscribe here
79
- # @return [Thread, nil] nil when disabled by configuration
80
+ # @return [Thread, nil] nil when disabled by configuration, or when the PID
81
+ # topology is proven unsupported
80
82
  # @api private
81
83
  def start!(slot:, processes:, consumers:)
82
84
  return unless config.enabled?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kicks_liveness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - PoroshkinaVV
@@ -39,13 +39,14 @@ files:
39
39
  - lib/kicks_liveness/probe.rb
40
40
  - lib/kicks_liveness/railtie.rb
41
41
  - lib/kicks_liveness/registry.rb
42
+ - lib/kicks_liveness/topology_report.rb
42
43
  - lib/kicks_liveness/version.rb
43
44
  homepage: https://github.com/PoroshkinaVV/kicks_liveness
44
45
  licenses:
45
46
  - MIT
46
47
  metadata:
47
48
  source_code_uri: https://github.com/PoroshkinaVV/kicks_liveness
48
- documentation_uri: https://rubydoc.info/gems/kicks_liveness/0.1.2
49
+ documentation_uri: https://rubydoc.info/gems/kicks_liveness/0.1.4
49
50
  changelog_uri: https://github.com/PoroshkinaVV/kicks_liveness/blob/main/CHANGELOG.md
50
51
  bug_tracker_uri: https://github.com/PoroshkinaVV/kicks_liveness/issues
51
52
  rubygems_mfa_required: 'true'