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.
data/docs/LIMITATIONS.md CHANGED
@@ -39,25 +39,17 @@ aware that this probe no longer covers that failure mode.
39
39
 
40
40
  ## Broker unavailability is invisible by design
41
41
 
42
- While Bunny is recovering from a network failure, the probe reports healthy. This
43
- is deliberate: the alternative is that a ten-second broker hiccup restarts every
44
- replica simultaneously and reconnects them all at once, which is what a
45
- struggling broker least needs. Bunny reconnects and re-subscribes its consumers
46
- by itself, so a restart at that moment cures nothing.
47
-
48
- The consequence, plainly: a pod whose broker is unreachable looks healthy to
49
- this probe. Cover it with the alert.
42
+ The probe reports healthy while Bunny recovers an established connection and
43
+ re-subscribes consumers. This avoids restarting every replica during a broker
44
+ outage, but also means an unreachable broker is invisible to this probe. Cover
45
+ that case with the external consumer alert.
50
46
 
51
47
  ## The recovery exemption covers an established connection, not a failed subscribe
52
48
 
53
- While Bunny is recovering from a network failure the probe reports healthy, so
54
- that a broker hiccup does not restart every replica at once. That exemption is
55
- asked of a `connection` object, which has to exist before it can be asked.
56
-
57
- If the broker is unreachable at the moment a worker subscribes, there is no
58
- connection at all: the registry stays empty and no mark is ever written. So the
59
- cascade the exemption avoids for a live connection is still possible at startup
60
- — broker down, every replica restarting.
49
+ The exemption requires a connection object. If the initial subscription fails,
50
+ the worker never registers and no mark is written. A broker outage at startup
51
+ can therefore restart every replica even though an established connection would
52
+ be exempt.
61
53
 
62
54
  The budget here is the **startup** one, not the steady-state one:
63
55
 
@@ -78,6 +70,30 @@ flapping pod rather than a storm. And the supervisor respawning failing forks
78
70
  inside the container — which is *not* throttled by anything — is now reported:
79
71
  see the respawn escalation below.
80
72
 
73
+ ## Graceful shutdown is safe only when something else owns the deadline
74
+
75
+ `Registry.stopping!` suspends the consumer predicate before `WorkerGroup#stop`
76
+ unsubscribes workers and drains their pools. The monitor keeps the heartbeat
77
+ fresh for the rest of the process so liveness does not interrupt an in-flight
78
+ job. During pod deletion the kubelet enforces `terminationGracePeriodSeconds`,
79
+ which bounds this exemption externally.
80
+
81
+ A SIGTERM from an operator, sidecar, or internal supervisor enters the same
82
+ path without deleting the pod. If Sneakers or Bunny then drains or reconnects
83
+ indefinitely, the heartbeat stays fresh with zero consumers and the pod can
84
+ remain Ready forever. The process cannot infer an external deadline from the
85
+ signal.
86
+
87
+ This is existing shutdown behaviour, not a 0.1.3 regression. Until the exemption
88
+ is bounded — planned for 0.1.4 — send SIGTERM only through a termination path
89
+ that has its own enforced deadline. Use pod deletion for Kubernetes operations.
90
+ An internal supervisor must eventually make PID 1 exit itself, or ask the
91
+ container runtime to stop it, if graceful shutdown does not complete; an
92
+ in-namespace `kill -9 1` is not a reliable escalation even when `kill` reports
93
+ success. Keep the external consumer alert enabled as well: it still sees the
94
+ zero-consumer state that the process-local predicate has intentionally stopped
95
+ evaluating.
96
+
81
97
  ## Changing the worker count at runtime is not supported
82
98
 
83
99
  `ServerEngine` can re-read its configuration on SIGHUP and scale the fork set,
@@ -92,25 +108,133 @@ count and the retired slots eventually go stale. Neither direction is safe.
92
108
 
93
109
  If you change `workers`, restart the runner. Runtime scaling is not supported.
94
110
 
95
- ## A respawn loop is reported once per grace window, not once per respawn
111
+ ## Container generations need Linux procfs and a private PID namespace
112
+
113
+ Kubernetes keeps an `emptyDir` across restarts of a container in the same pod.
114
+ To stop the next container inheriting a fresh heartbeat, the worker and its exec
115
+ probe independently identify their shared incarnation from Linux procfs: the
116
+ mount namespace plus PID 1 start time. No application cache is removed.
117
+
118
+ That is an incarnation only where the container owns PID 1. With
119
+ `shareProcessNamespace: true` PID 1 is the pod sandbox; with `hostPID: true` it
120
+ is the node's init process. Neither restarts when the worker container does.
121
+ If the replacement container also receives the same mount namespace inode,
122
+ the complete identifier repeats and an inherited fresh mark can satisfy the
123
+ 0.1.2 startup probe.
124
+
125
+ Rejection in 0.1.3 depends on procfs access. On the measured Kubernetes node,
126
+ default capabilities left shared PID `unverifiable` and host PID `unavailable`;
127
+ node init reported start time zero. Adding `CAP_SYS_PTRACE` made shared PID
128
+ provably `unsafe`. This is not a recommendation to add the capability.
129
+ The full restart reproduction and Kubernetes measurement are in
130
+ [VERIFYING.md](VERIFYING.md#12-shared-and-host-pid-topology-outcomes).
131
+
132
+ ### Proven unsafe: the probe fails, and keeps failing
133
+
134
+ Both links readable and different is proof that PID 1 belongs to something
135
+ outside this container. The probe exits 1 with
136
+
137
+ ```
138
+ unsupported PID topology: pod must not set shareProcessNamespace or hostPID
139
+ ```
140
+
141
+ Procfs access can differ between the worker and probe. A worker that proves the
142
+ topology unsafe publishes `topology-unsafe` instead of a heartbeat; a probe
143
+ rejects either that file or its own unsafe result before checking marks.
144
+ Inherited generations and another fork's marks cannot override the verdict.
145
+
146
+ The worker logs the reason and recovery at ERROR once for the uninterrupted
147
+ unsafe state, across forks and container restarts sharing the volume. Later
148
+ containers in that pod therefore do not repeat the ERROR; read the verdict from
149
+ the kubelet's `Unhealthy` event or `topology-unsafe`.
150
+
151
+ This state is terminal for the marks volume. Neither PID setting can change on
152
+ a live pod, and its `emptyDir` dies with it. **Recovery is recreating the pod
153
+ without those settings.** Until then probes fail and the container restarts.
154
+ Unreadable namespace links do not establish this verdict; they take a degraded
155
+ branch below.
96
156
 
97
- When a fork cannot subscribe at all, the supervisor brings it back after
98
- `start_worker_delay` — a fraction of a second — and the monitor in each
99
- incarnation is a brand-new object. Any counter it holds is destroyed before it
100
- can reach a threshold, which is how a real outage used to produce thousands of
101
- identical INFO lines and not a single ERROR.
157
+ ### Unverifiable: the 0.1.2 guarantee, and one warning
158
+
159
+ Reading `/proc/1/ns/mnt` is a ptrace-level operation and may be denied, including
160
+ when PID 1 runs as a different user. Denial cannot distinguish a valid private
161
+ PID namespace from an unsupported topology. If the other procfs entries yield a
162
+ generation, the probe retains its 0.1.2 check and output; the worker logs:
163
+
164
+ ```
165
+ WARN [liveness] slot 0: PID topology unverified (cannot read /proc/1/ns/mnt (Errno::EACCES)):
166
+ the container generation still scopes the heartbeat, but shareProcessNamespace and hostPID
167
+ cannot be ruled out
168
+ ```
169
+
170
+ Read this as unverifiable, not safe: an unsupported topology still has the
171
+ 0.1.2 inherited-heartbeat weakness. The WARN is once per derived generation,
172
+ across fork respawns. The measured Kubernetes shared-PID case reached this state;
173
+ its credentials and capability comparison are recorded in VERIFYING.md above.
174
+
175
+ There is a narrower startup window when the worker and probe have asymmetric
176
+ access: the worker can prove the topology unsafe, but the probe cannot. Before
177
+ that worker reaches the monitor and publishes `topology-unsafe`, the probe can
178
+ accept inherited marks only if the volume already contains a fresh heartbeat
179
+ from an older worker, or from one that also could not prove the topology. Both
180
+ conditions are required. A normal Kubernetes exec probe inherits the
181
+ container's credentials and reaches the same procfs verdict itself; after a
182
+ 0.1.3 worker has proved the topology, `topology-unsafe` also survives every
183
+ later restart in the same documented `emptyDir`. The residual window therefore
184
+ requires an in-place restart that also changes the image or effective access,
185
+ or a marks volume that outlives the pod contrary to this setup. In that case the
186
+ probe retains the explicitly degraded 0.1.2 guarantee until the current worker
187
+ publishes its verdict.
188
+
189
+ ### No generation at all
190
+
191
+ If `/proc/self/ns/mnt` or `/proc/1/stat` cannot yield a generation, the probe
192
+ falls back to the 0.1.1 freshness-only check and may accept fresh inherited
193
+ marks. The worker warns once for the uninterrupted unavailable state. A verified
194
+ topology clears the report flag so a later degradation is reported again:
195
+
196
+ ```
197
+ WARN [liveness] slot 0: no container generation (cannot read /proc/1/stat (Errno::EACCES)):
198
+ falling back to freshness only, which cannot reject a heartbeat inherited from a previous
199
+ container
200
+ ```
201
+
202
+ A zero start time uses the detail `PID 1 start time is zero; possible hostPID or
203
+ virtualized procfs` in that warning. The field counts clock ticks after boot,
204
+ so zero is valid, not a parse error. It was observed for node init in a host-PID
205
+ pod, but may also be exposed for a legitimate PID 1 by virtualized procfs.
206
+ Without measurements of those runtimes, zero alone remains `unavailable`.
207
+
208
+ Off Linux the same freshness-only fallback is **silent**, an intentional
209
+ compatibility mode. Degraded Linux warnings also stay off the probe's stdout:
210
+ they belong in the worker log, not repeated beside unrelated failures in
211
+ `Unhealthy` events. A proven unsafe topology is different: it is the probe's
212
+ verdict.
213
+
214
+ ### The probe must run in the worker's container
215
+
216
+ The worker and its exec probe must run in the **same container**. A neighbouring
217
+ sidecar can mount the same `emptyDir`, but it has a different mount namespace and
218
+ therefore treats the worker's marks as belonging to another container.
219
+ Kubernetes exec probes already run inside the container they check; do not move
220
+ `kicks-liveness` into a separate health sidecar.
221
+
222
+ ## A respawn loop is reported once per grace window, not once per respawn
102
223
 
103
- The count is therefore kept in the marks directory, which survives the fork.
104
- The start line is logged once, repeated starts within the grace window
105
- (`startup_grace_ticks × tick`) are silent, and after that one ERROR per window
106
- reports the elapsed time and the number of starts:
224
+ The supervisor respawns a fork that cannot subscribe after `start_worker_delay`,
225
+ potentially before its monitor reaches startup grace. `attempt-<slot>` therefore
226
+ keeps the count and window start outside process memory. The first incarnation
227
+ logs startup, further starts within `startup_grace_ticks × tick` are silent, and
228
+ each expired window produces one ERROR with elapsed time and cumulative starts:
107
229
 
108
230
  ```
109
231
  [liveness] slot 0: not healthy 62s over 305 starts, expected 5 consumers
110
232
  ```
111
233
 
112
- The number of starts is the diagnosis: it separates a slow start from a respawn
113
- loop at a glance.
234
+ The generation in the file rejects an earlier container's count. A replacement
235
+ logs its own `started:` and cannot escalate over starts it did not take; the
236
+ first healthy tick clears the counter. Without a generation, the old volume-wide
237
+ scope remains: read it as starts since this slot was last healthy in the pod.
114
238
 
115
239
  ## Do not install both `kicks` and `sneakers`
116
240
 
data/docs/SETUP.md CHANGED
@@ -174,11 +174,24 @@ Once workers are running, the marks directory is the other half of the answer:
174
174
  ```
175
175
  $ ls -l /opt/app/tmp/health/
176
176
  expected
177
+ generation
177
178
  worker-0
178
179
  worker-1
179
180
  ```
180
181
 
181
- One `worker-<slot>` file per fork, plus `expected`. If `expected` is there and
182
- the slot files are not, the workers have not finished subscribing. A slot that
183
- keeps restarting without ever subscribing also leaves an `attempt-<slot>` file,
184
- which is removed as soon as that slot becomes healthy.
182
+ One `worker-<slot>` file per fork, plus `expected` and the current container
183
+ `generation`. If `expected` is there and the slot files are not, the workers
184
+ have not finished subscribing. A slot that keeps restarting without ever
185
+ subscribing also leaves an `attempt-<slot>` file, which is removed as soon as
186
+ that slot becomes healthy.
187
+
188
+ The `generation` file is authoritative only while procfs generation detection is
189
+ available: where it is not, the worker writes no generation and a file left by
190
+ an earlier container stays visible, so `kubectl exec ... cat <dir>/generation`
191
+ can show a stale incarnation. It is not deleted in that state on purpose —
192
+ removing it could fail a concurrently running probe that does have a valid
193
+ generation, for a reason that has nothing to do with the worker's health. Two
194
+ further files appear only when something is wrong: `topology-unsafe`, which
195
+ fails every probe until the pod is recreated, and `topology-report`, which is
196
+ how the worker keeps what it says about the topology to one line. Both are explained in
197
+ [LIMITATIONS.md](LIMITATIONS.md#container-generations-need-linux-procfs-and-a-private-pid-namespace).
data/docs/VERIFYING.md CHANGED
@@ -35,6 +35,10 @@ spec/integration/verify.sh up # namespace, broker, worker
35
35
  spec/integration/verify.sh down # deletes the namespace
36
36
  ```
37
37
 
38
+ Scenarios 11 and 12 are the exception: they need the image and Docker, but no
39
+ cluster and no broker, because both are about the container itself rather than
40
+ about consuming.
41
+
38
42
  Every `kubectl` call names its context and namespace explicitly, so the script
39
43
  never falls back to `current-context`. It will still operate on whichever target
40
44
  you configure: run `status` and inspect both values before `up` or `down`. The
@@ -97,11 +101,15 @@ spec/integration/verify.sh probe
97
101
  INFO: [liveness] slot 0: started: dir=/opt/app/tmp/health max_age=45s tick=10s processes=1 consumers=2
98
102
  INFO: [liveness] slot 0: waiting for 2 consumers
99
103
  INFO: [liveness] slot 0: healthy # one tick later
100
- expected worker-0 # in the marks directory
104
+ expected generation worker-0 # in the marks directory
101
105
  1 process(es) healthy # probe, exit 0
102
106
  ```
103
107
 
104
- The hooks fired inside a real ServerEngine fork, which no double can show.
108
+ The hooks fired inside a real ServerEngine fork, which no double can show. The
109
+ `started:` line is also valid proof after a same-pod container restart: since
110
+ 0.1.3 the respawn counter carries the container generation, so a replacement
111
+ container reports its own first start rather than continuing the previous
112
+ container's unhealthy run.
105
113
 
106
114
  ### 2. A stale mark
107
115
 
@@ -242,9 +250,9 @@ find them.
242
250
  ```
243
251
  INFO: [liveness] slot 0: started: ... processes=2 consumers=2
244
252
  INFO: [liveness] slot 1: started: ... processes=2 consumers=2
245
- expected worker-0 worker-1 # expected contains "2"
246
- 2 process(es) healthy # exit 0
247
- worker-1 stale 120s > 45s # exit 1 — staling either mark is enough
253
+ expected generation worker-0 worker-1 # expected contains "2"
254
+ 2 process(es) healthy # exit 0
255
+ worker-1 stale 120s > 45s # exit 1 — staling either mark is enough
248
256
  ```
249
257
 
250
258
  Both marks are required, not just the first.
@@ -331,6 +339,131 @@ pod is restarted. That is why the documented rule set pairs it with
331
339
  `absent_over_time`, and why an `up == 0` on the scrape job is worth having
332
340
  beside both.
333
341
 
342
+ ### 11. A private-PID container restart cannot inherit a healthy mark
343
+
344
+ This scenario needs the fixture image but not Kubernetes or RabbitMQ. It starts
345
+ a container that publishes a healthy mark into a named volume, restarts that
346
+ same container, and delays the new writer for 15 seconds:
347
+
348
+ ```bash
349
+ spec/integration/verify.sh build
350
+ spec/integration/verify.sh generation-restart
351
+ ```
352
+
353
+ During the delay the inherited mark is still fresh, but the probe must reject
354
+ its old generation. Once the replacement writer publishes its own mark, the
355
+ same probe must become healthy again. The command checks both transitions and
356
+ prints output in this form:
357
+
358
+ ```
359
+ before=mnt:[4026532686]:123456
360
+ after=mnt:[4026532686]:123789
361
+ inherited=heartbeat belongs to a previous container: worker has not started yet
362
+ recovered=1 process(es) healthy
363
+ ```
364
+
365
+ The namespace inode is allowed to be reused, as in the example above; the PID 1
366
+ start time still distinguishes the two incarnations. The test removes its
367
+ container and named volume on exit.
368
+
369
+ The command also prints the topology it ran under, which for an ordinary
370
+ container is the fully verified one:
371
+
372
+ ```
373
+ generation-restart: topology=verified
374
+ ```
375
+
376
+ This scenario covers the default container-private PID namespace. With
377
+ `shareProcessNamespace: true` or `hostPID: true`, PID 1 survives the worker
378
+ container restart and this guarantee cannot hold — scenario 12 is that case,
379
+ showing both fail-closed rejection when the topology can be proved and the
380
+ degraded outcomes when procfs access cannot prove it.
381
+
382
+ ### 12. Shared and host PID topology outcomes
383
+
384
+ The four PID topologies, in one command. It needs the fixture image but neither
385
+ Kubernetes nor RabbitMQ, because everything here turns on which PID namespace
386
+ the worker container is in:
387
+
388
+ ```bash
389
+ spec/integration/verify.sh build
390
+ spec/integration/verify.sh topology
391
+ ```
392
+
393
+ Recorded on Linux 6.12 (arm64), Docker 27.4.1, ruby 3.4.10:
394
+
395
+ ```
396
+ shared-pid: topology=unsafe
397
+ shared-pid: 0.1.2 generation before=mnt:[4026532693]:17207878 after=mnt:[4026532693]:17207878
398
+ shared-pid inherited heartbeat: exit=1 unsupported PID topology: pod must not set shareProcessNamespace or hostPID
399
+ shared-pid: marks directory holds expected generation topology-unsafe worker-0
400
+ shared-pid after the worker published: exit=1 unsupported PID topology: pod must not set shareProcessNamespace or hostPID
401
+ host-pid: topology=unsafe
402
+ host-pid: 0.1.2 generation before=mnt:[4026532699]:105 after=mnt:[4026532699]:105
403
+ host-pid inherited heartbeat: exit=1 unsupported PID topology: pod must not set shareProcessNamespace or hostPID
404
+ host-pid: marks directory holds expected generation topology-unsafe worker-0
405
+ host-pid after the worker published: exit=1 unsupported PID topology: pod must not set shareProcessNamespace or hostPID
406
+ private-pid: topology=verified
407
+ private-pid: exit=0 1 process(es) healthy
408
+ unverifiable: topology=unverifiable
409
+ unverifiable: warnings across three fork respawns=1
410
+ unverifiable inherited heartbeat: exit=1 heartbeat belongs to a previous container: worker has not started yet
411
+ unverifiable own heartbeat: exit=0 1 process(es) healthy
412
+ ```
413
+
414
+ Four cases, and each line is the point of one of them.
415
+
416
+ A separate run on a real Kubernetes v1.25.0 node on Docker Desktop used
417
+ containerd, no `securityContext`, a root worker and the default capability set.
418
+ It produced a different and operationally important table:
419
+
420
+ | pod topology | PID 1 | PID 1 UID | `/proc/1/ns/mnt` | start time | 0.1.3 state |
421
+ |---|---|---:|---|---:|---|
422
+ | `hostPID: true` | node init | 0 | denied | 0 | `unavailable` |
423
+ | `shareProcessNamespace: true` | pod sandbox | 65535 | denied | 19031096 | `unverifiable` |
424
+ | private PID | container process | 0 | readable | 19031243 | `verified` |
425
+ | shared PID plus `CAP_SYS_PTRACE` | pod sandbox | 65535 | readable | — | `unsafe` |
426
+
427
+ The Docker matrix proves the fail-closed branch by granting the access it needs;
428
+ the Kubernetes measurement shows that this is not the default result for either
429
+ unsupported setting. The degraded states still warn, but they retain the weaker
430
+ generation or freshness-only guarantees described in LIMITATIONS.md.
431
+
432
+ **`shared-pid`** joins another container's PID namespace, which is what
433
+ Kubernetes `shareProcessNamespace: true` does — there PID 1 is the pod sandbox.
434
+ **`host-pid`** uses the node's PID namespace, which is `hostPID: true`. In both,
435
+ the run publishes a complete, fresh heartbeat in the **0.1.2 format**, restarts
436
+ only the worker container, and recomputes the 0.1.2 identifier by the 0.1.2
437
+ rule. The `before`/`after` pair is the defect being reproduced rather than
438
+ argued about: both halves repeat, including the mount namespace inode, which the
439
+ kernel handed straight back to the replacement container. A 0.1.2 probe would
440
+ have accepted those inherited marks. The revised probe rejects the topology
441
+ before it ever looks at them, and the worker then adds `topology-unsafe` beside
442
+ the inherited files rather than a heartbeat of its own.
443
+
444
+ Reading PID 1's namespace link is a ptrace-level operation, so the `host-pid`
445
+ case runs with `CAP_SYS_PTRACE`; without it the link is unreadable and the
446
+ outcome depends on whether a generation can still be derived. Node init on the
447
+ measured Kubernetes node reported start time zero, so that pod was
448
+ `unavailable`. A readable positive start time would instead leave the topology
449
+ `unverifiable`. The fourth case below constructs that latter branch explicitly;
450
+ both are honest limits on what a container can prove about a topology it is not
451
+ allowed to inspect.
452
+
453
+ **`private-pid`** is the fully verified positive control: an ordinary container,
454
+ owning PID 1, probe green.
455
+
456
+ **`unverifiable`** is the second positive control, and the one that has to stay
457
+ green: a private-PID container whose checker cannot read `/proc/1/ns/mnt`. The
458
+ 0.1.2 generation check is kept in full — a heartbeat from another generation is
459
+ still rejected, its own is still accepted — and the cost is exactly one worker
460
+ warning across three fork respawns, not a probe that is permanently red.
461
+
462
+ That case is constructed by running the checker as a user other than PID 1's.
463
+ It is **not** a model of a Kubernetes exec probe, which has no user setting of
464
+ its own and normally inherits the container process credentials; it is simply
465
+ the one way to make a namespace link unreadable without a custom procfs mount.
466
+
334
467
  ## What to do with a disagreement
335
468
 
336
469
  Record it, then fix whichever is wrong — the code or the document. The numbers
@@ -3,23 +3,18 @@ require 'fileutils'
3
3
  module KicksLiveness
4
4
  # Counts starts of a supervisor slot that have not reached healthy yet.
5
5
  #
6
- # Deliberately separate from {Heartbeat}. Heartbeat is the contract shared
7
- # with the probe and is loaded by it alone, so nothing the probe does not need
8
- # belongs there — including the +fileutils+ this class requires freely.
6
+ # Persistent state survives fork respawns; its generation separates container
7
+ # incarnations when available. Kept outside {Heartbeat} because the probe does
8
+ # not read these counters.
9
9
  #
10
- # The count lives in the marks directory rather than in memory because a
11
- # respawn storm destroys process state every few hundred milliseconds: the
12
- # supervisor brings a failing fork back after +start_worker_delay+, and every
13
- # incarnation of the monitor believes it is the first. That is how an outage
14
- # can produce thousands of identical INFO lines and not one ERROR. The
15
- # directory is the only state that survives the fork.
16
- #
17
- # @see file:docs/DESIGN.md#logging-events-not-the-pulse
10
+ # @see file:docs/LIMITATIONS.md#a-respawn-loop-is-reported-once-per-grace-window-not-once-per-respawn
18
11
  # @api private
19
12
  class Attempts
20
13
  # @param dir [String] marks directory
21
- def initialize(dir)
14
+ # @param generation [String, nil] container incarnation the count belongs to
15
+ def initialize(dir, generation: nil)
22
16
  @dir = dir
17
+ @generation = generation
23
18
  end
24
19
 
25
20
  # Records a start of this slot.
@@ -62,22 +57,32 @@ module KicksLiveness
62
57
  File.join(@dir, "attempt-#{slot}")
63
58
  end
64
59
 
65
- # A missing or unreadable counter means "no previous start", never an
66
- # exception: this is instrumentation, and it may not break a worker.
60
+ # Missing, unreadable, or foreign-generation counts start a new run.
61
+ # Counter failures must not prevent worker startup.
67
62
  def read(slot)
68
- count, epoch = File.read(path(slot)).split
63
+ count, epoch, generation = File.read(path(slot)).split
64
+ return [0, nil] unless same_generation?(generation)
65
+
69
66
  [Integer(count), Time.at(Integer(epoch)).utc]
70
67
  rescue StandardError
71
68
  [0, nil]
72
69
  end
73
70
 
71
+ def same_generation?(declared)
72
+ return true unless @generation
73
+
74
+ declared == "generation=#{@generation}"
75
+ end
76
+
74
77
  # Failing to write degrades the reporting back to one line per respawn,
75
78
  # which is noisy but harmless — so a read-only directory is swallowed here
76
79
  # rather than escalated.
77
80
  def write(slot, count, first)
78
81
  FileUtils.mkdir_p(@dir)
82
+ contents = "#{count} #{first.to_i}"
83
+ contents = "#{contents} generation=#{@generation}" if @generation
79
84
  tmp = "#{path(slot)}.#{Process.pid}"
80
- File.write(tmp, "#{count} #{first.to_i}\n")
85
+ File.write(tmp, "#{contents}\n")
81
86
  File.rename(tmp, path(slot))
82
87
  rescue StandardError
83
88
  nil
@@ -3,11 +3,8 @@ require_relative 'heartbeat'
3
3
  module KicksLiveness
4
4
  # What the application may configure, and what comes from the environment only.
5
5
  #
6
- # +dir+ and +max_age+ are deliberately read-only here: the probe runs as a
7
- # separate process launched by the container runtime and cannot read the
8
- # application's initializer. Were they settable in both places, a mismatch
9
- # between what the worker writes and what the probe expects would pass
10
- # unnoticed.
6
+ # dir and max_age are environment-only: the separate exec probe cannot read
7
+ # an application initializer and must resolve the same values as the worker.
11
8
  #
12
9
  # @see file:docs/SETUP.md#configuration
13
10
  # @see file:docs/DESIGN.md#what-is-configurable-and-where
@@ -44,10 +41,8 @@ module KicksLiveness
44
41
  @enabled
45
42
  end
46
43
 
47
- # A tick is what the monitor thread sleeps on, so a non-positive value is
48
- # not a setting but a broken monitor. An initializer is code, and code
49
- # should fail loudly at boot, where the developer is looking. Environment
50
- # input is resolved separately by {#environment_tick}, with a safe fallback.
44
+ # Initializer values fail at boot; environment input uses a safe fallback
45
+ # through {#environment_tick}.
51
46
  #
52
47
  # @param seconds [Numeric]
53
48
  # @raise [ArgumentError] if not a positive number