kicks_liveness 0.1.3 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db97cf34081f6a62735363603c3139ff3ddea09c23d81305d2e3a2e2e84ff126
4
- data.tar.gz: ca1ee94ae412b24615b4df2eabe3d1344375721f4b50a33ca6af2590c6b60ae3
3
+ metadata.gz: faf817806eb5142b8271959b7856a16db10a99db8eed583bb5594887198b6dd8
4
+ data.tar.gz: 9c5f3f845c78a56e5da97204769e5c2b10161fbf56e5c40a731b904650ca469a
5
5
  SHA512:
6
- metadata.gz: 11b5aaed04e4e67c3607a25291d7f1443f5ca00885b0fec3f9931ebc967c02f11deb150697a78f0a936c832334ef895783ec532801d72e61d92e2f2b715a2c67
7
- data.tar.gz: dd47406061ce002cd058bd4d89577a383d0eafcc7808688f4d147a660ccd635b6141b75ffa97a6e7778ce661d51cde8b99f0d31e9ce7bf731e4633d84dc9438c
6
+ metadata.gz: a077fc085bed4c460ed9820940271fedb44e69a2778ca87997b23c172fe94d0622bbf334d9b5456827d1e231813ec5e903de6ebe6c655804f8b8c359ca26832c
7
+ data.tar.gz: 1d2efdab9aa59777cc14075703c9c75bfad76dc511d94659a8f2f40d2cc8a3a957779e4795f304beabbed04c4854f88e2413fd044216497df7338d6a7a32a864
data/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.1.4] - 2026-11-03
9
+
10
+ ### Fixed
11
+
12
+ - Bound the graceful-shutdown heartbeat exemption by a monotonic deadline
13
+ starting at the first stop notification; repeated callbacks cannot extend it.
14
+ At expiry the monitor logs one ERROR and stops refreshing its mark, but still
15
+ declares the fork count. A stuck drain then fails the ordinary freshness
16
+ check; the monitor does not kill the process.
17
+
18
+ ### Added
19
+
20
+ - Add `shutdown_grace` / `KICKS_LIVENESS_SHUTDOWN_GRACE` (default 60 seconds;
21
+ initializer wins). Set it to at least `terminationGracePeriodSeconds`.
22
+ Restart still waits for mark staleness and the kubelet's probe failure budget.
23
+ - Add `verify.sh shutdown` and `container-restart` to check a real drain and
24
+ same-pod container replacement, with deployment restoration afterwards.
25
+ - Add an `install!` setup hint when the probe finds no declared worker count.
26
+ It remains a failing check, not proof that the hooks were never installed.
27
+
8
28
  ## [0.1.3] - 2026-10-21
9
29
 
10
30
  ### Fixed
@@ -124,7 +144,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
124
144
  [docs/](https://github.com/PoroshkinaVV/kicks_liveness/tree/main/docs); start
125
145
  with `SETUP.md`, and read `LIMITATIONS.md` before relying on it.
126
146
 
127
- [Unreleased]: https://github.com/PoroshkinaVV/kicks_liveness/compare/v0.1.3...HEAD
147
+ [Unreleased]: https://github.com/PoroshkinaVV/kicks_liveness/compare/v0.1.4...HEAD
148
+ [0.1.4]: https://github.com/PoroshkinaVV/kicks_liveness/compare/v0.1.3...v0.1.4
128
149
  [0.1.3]: https://github.com/PoroshkinaVV/kicks_liveness/compare/v0.1.2...v0.1.3
129
150
  [0.1.2]: https://github.com/PoroshkinaVV/kicks_liveness/compare/v0.1.1...v0.1.2
130
151
  [0.1.1]: https://github.com/PoroshkinaVV/kicks_liveness/compare/v0.1.0...v0.1.1
data/README.md CHANGED
@@ -77,6 +77,9 @@ only when every expected worker mark is present and fresh.
77
77
  Use a startup probe for the boot budget and a liveness probe for steady state:
78
78
 
79
79
  ```yaml
80
+ env:
81
+ - name: KICKS_LIVENESS_SHUTDOWN_GRACE
82
+ value: "60" # at least terminationGracePeriodSeconds
80
83
  startupProbe:
81
84
  exec:
82
85
  command: ["bundle", "exec", "kicks-liveness"]
@@ -139,6 +142,7 @@ environment too:
139
142
  | `KICKS_LIVENESS_DIR` | `/opt/app/tmp/health` | heartbeat directory; must be on tmpfs | uses the default |
140
143
  | `KICKS_LIVENESS_MAX_AGE` | `45` | seconds before a mark is stale | uses the default |
141
144
  | `KICKS_LIVENESS_TICK` | `10` | seconds between worker checks | uses the default, then the safety fallback below if needed |
145
+ | `KICKS_LIVENESS_SHUTDOWN_GRACE` | `60` | seconds to keep publishing during shutdown | uses the default |
142
146
 
143
147
  If the effective environment tick is not smaller than `max_age`, the worker
144
148
  writes a WARN to stderr and uses `10` seconds when that is safe, or half of
@@ -146,10 +150,16 @@ writes a WARN to stderr and uses `10` seconds when that is safe, or half of
146
150
  while guaranteeing that a healthy heartbeat cannot become stale between
147
151
  ordinary ticks.
148
152
 
149
- An initializer value for `tick` wins over `KICKS_LIVENESS_TICK`. `dir` and
153
+ Initializer values for `tick` and `shutdown_grace` override their environment
154
+ variables. `dir` and
150
155
  `max_age` deliberately have no initializer setters: allowing two configuration
151
156
  sources could make the worker and probe silently disagree.
152
157
 
158
+ Set `shutdown_grace` to at least `terminationGracePeriodSeconds`. A drain that
159
+ exceeds this deadline stops refreshing its heartbeat and logs one ERROR; the
160
+ probe fails once the last mark becomes stale. See
161
+ [shutdown timing](docs/LIMITATIONS.md#graceful-shutdown-has-a-finite-heartbeat-exemption).
162
+
153
163
  If two runners share one pod — for example `sneakers:run` and
154
164
  `sneakers:active_job` — give each a different `KICKS_LIVENESS_DIR`. Separate
155
165
  pods already have separate `emptyDir` volumes.
data/docs/DESIGN.md CHANGED
@@ -235,13 +235,13 @@ with `respond_to?(:call)` rather than maintaining a separate queue list.
235
235
  | Setting | Where | Why |
236
236
  |---|---|---|
237
237
  | `logger`, `enabled`, `startup_grace_ticks` | application config block | only the worker needs them |
238
- | `tick` | either, and the config block wins | only the worker reads it, so two sources cannot contradict each other |
238
+ | `tick`, `shutdown_grace` | either, and the config block wins | only the worker reads them, so two sources cannot contradict each other |
239
239
  | `dir`, `max_age` | environment variables **only** | the probe is a separate process |
240
240
 
241
241
  The exec probe cannot read an application initializer, so `dir` and `max_age`
242
242
  have one source: the environment. Specs reject application setters for both.
243
- Only the worker reads `tick`, so `config.tick` may override
244
- `KICKS_LIVENESS_TICK` without contradicting the probe.
243
+ Only the worker reads `tick` and `shutdown_grace`, so initializer values may
244
+ override their environment values without contradicting the probe.
245
245
 
246
246
  Environment durations must be positive integers; missing, empty, unparseable,
247
247
  zero, and negative values use defaults. If the effective tick is at least
@@ -249,6 +249,10 @@ zero, and negative values use defaults. If the effective tick is at least
249
249
  `max_age` otherwise. The warning is deferred until the tick is read, so an
250
250
  initializer override or a disabled monitor does not produce a misleading WARN.
251
251
 
252
+ `KICKS_LIVENESS_SHUTDOWN_GRACE` defaults to 60 seconds. Its initializer override
253
+ accepts positive finite real numbers. Set it to at least the deployment's
254
+ `terminationGracePeriodSeconds`, which the gem cannot discover from a signal.
255
+
252
256
  Invalid ConfigMap input falls back so a deployment typo does not prevent worker
253
257
  startup. Initializer values are application code: invalid values raise
254
258
  `ArgumentError` at boot, where the developer can fix the error, rather than
@@ -288,8 +292,12 @@ Loop and startup exceptions do not use that flag.
288
292
 
289
293
  ERROR must survive the usual error-level logger filter, while normal startup
290
294
  and deliberate shutdown remain INFO. On shutdown the monitor suspends its
291
- predicate and refreshes the mark for the rest of the process, assuming an
292
- [external deadline](LIMITATIONS.md#graceful-shutdown-is-safe-only-when-something-else-owns-the-deadline).
295
+ predicate until a monotonic deadline: the first `Registry.stopping!` records the
296
+ stop time, and the monitor adds `shutdown_grace`. A delayed tick or repeated
297
+ callback cannot renew it. At expiry it logs one ERROR and stops `touch!` while
298
+ continuing `declare!`. Nothing is persisted, so a replacement fork does not
299
+ inherit a sibling's shutdown. Mark freshness still uses wall time; see the
300
+ [timing boundary](LIMITATIONS.md#graceful-shutdown-has-a-finite-heartbeat-exemption).
293
301
 
294
302
  Startup grace postpones the error for a slow initial subscription; a surviving
295
303
  monitor reports once when it expires. Fork-respawn loops use the persistent
data/docs/KUBERNETES.md CHANGED
@@ -3,6 +3,9 @@
3
3
  ## Manifest
4
4
 
5
5
  ```yaml
6
+ env:
7
+ - name: KICKS_LIVENESS_SHUTDOWN_GRACE
8
+ value: "60" # at least terminationGracePeriodSeconds
6
9
  startupProbe:
7
10
  exec:
8
11
  command: ["bundle", "exec", "kicks-liveness"]
@@ -142,11 +145,17 @@ image. One line, no cluster needed:
142
145
  docker run --rm your-image ruby -e "require 'kicks_liveness/probe'"
143
146
  ```
144
147
 
145
- A line like `no /opt/app/tmp/health/expected: worker has not started yet` and
146
- exit 1 means the plain command works — the probe found itself and correctly
147
- reported that no worker is running. A `LoadError` means it does not: keep
148
- `bundle exec kicks-liveness`, or use the explicit-path optimisation below. Do
149
- not put the plain command in a manifest without this check: its failure mode is
148
+ With no worker running, exit 1 and the following line mean the plain command
149
+ works — the probe loaded and found no declared worker count:
150
+
151
+ ```
152
+ no /opt/app/tmp/health/expected: worker has not started yet (is KicksLiveness.install! called before the runner starts? outside Rails it is not automatic)
153
+ ```
154
+
155
+ The question is a [setup hint](SETUP.md#checking-that-the-hooks-are-in-place),
156
+ not proof of missing installation. A `LoadError` means the command does not
157
+ work: keep `bundle exec kicks-liveness`, or use the explicit-path optimisation
158
+ below. Do not put the plain command in a manifest without this check: its failure mode is
150
159
  a pod that never passes `startupProbe` and lands in `CrashLoopBackOff`.
151
160
 
152
161
  What decides it is whether Bundler installed into `GEM_HOME` or somewhere of
@@ -313,6 +322,15 @@ several consumers per worker these add up. If the grace period is shorter than
313
322
  that, the container is SIGKILLed mid-shutdown and in-flight messages are
314
323
  redelivered.
315
324
 
325
+ Set `KICKS_LIVENESS_SHUTDOWN_GRACE` (or `config.shutdown_grace`) to at least
326
+ `terminationGracePeriodSeconds`; both are 60 seconds in the example. The
327
+ monitor keeps the mark fresh only within that shutdown grace. For a SIGTERM
328
+ sent outside pod deletion, a stuck drain stops publishing at the deadline;
329
+ its last mark then has to exceed `max_age`, followed by the liveness probe's
330
+ period and failure threshold. Under normal clock progression, restart
331
+ eligibility is bounded by `shutdown_grace + max_age`, with the probe budget
332
+ additional. During pod deletion the kubelet still owns the termination deadline.
333
+
316
334
  ## Environment variables
317
335
 
318
336
  | Variable | Default | Purpose | Invalid or blank value |
@@ -320,6 +338,7 @@ redelivered.
320
338
  | `KICKS_LIVENESS_DIR` | `/opt/app/tmp/health` | marks directory, **must be on tmpfs** | uses the default |
321
339
  | `KICKS_LIVENESS_MAX_AGE` | `45` | seconds after which a mark is stale | uses the default |
322
340
  | `KICKS_LIVENESS_TICK` | `10` | interval between ticks | uses the default, then the safety fallback below if needed |
341
+ | `KICKS_LIVENESS_SHUTDOWN_GRACE` | `60` | seconds to keep publishing during shutdown; at least `terminationGracePeriodSeconds` | uses the default |
323
342
 
324
343
  Keep `tick` well below `max_age`. A tick longer than half of `max_age` leaves no
325
344
  room for a single missed write, and a tick longer than `max_age` guarantees a
data/docs/LIMITATIONS.md CHANGED
@@ -70,29 +70,38 @@ flapping pod rather than a storm. And the supervisor respawning failing forks
70
70
  inside the container — which is *not* throttled by anything — is now reported:
71
71
  see the respawn escalation below.
72
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.
73
+ ## Graceful shutdown has a finite heartbeat exemption
74
+
75
+ `Sneakers::WorkerGroup#stop` removes consumers before waiting for their thread
76
+ pools to drain. `Registry.stopping!` therefore suspends the consumer predicate
77
+ before the first worker stops, allowing an orderly drain to keep its heartbeat
78
+ fresh without reporting a consumer failure on every deploy.
79
+
80
+ The exemption lasts for `shutdown_grace` seconds from the first stop callback
81
+ (default 60). Set `KICKS_LIVENESS_SHUTDOWN_GRACE` or `config.shutdown_grace` to at
82
+ least the deployment's `terminationGracePeriodSeconds`. During pod deletion the
83
+ kubelet still owns the outer termination deadline. The gem cannot determine
84
+ whether a SIGTERM came from the kubelet, an operator, or another process.
85
+
86
+ At the deadline, the monitor logs one ERROR and stops refreshing the slot mark.
87
+ It keeps declaring the expected fork count, so an existing mark eventually
88
+ fails as `worker-<slot> stale`, rather than claiming that the worker never
89
+ started. The monitor does not kill the process or resume checking consumers.
90
+ This bounds the formerly indefinite healthy result when an out-of-band SIGTERM
91
+ leaves a process stuck draining with no consumers.
92
+
93
+ Expiry is not an immediate probe failure: the last mark must still exceed
94
+ `max_age`. Under normal clock progression, the bound from stop notification to
95
+ restart eligibility is `shutdown_grace + max_age`; actual restart also depends
96
+ on the probe period and failure threshold. The first stop time and deadline are
97
+ process-local, so repeated stop callbacks do not renew them and a sibling
98
+ replacement fork starts without them.
99
+
100
+ The deadline uses the monotonic clock. A wall-clock jump cannot shorten or
101
+ extend the exemption, but it can change mark staleness: the probe still compares
102
+ filesystem mtime with wall time. Keep the external consumer alert enabled; the
103
+ shutdown grace and the probe budget deliberately allow a period with no
104
+ consumers before a restart.
96
105
 
97
106
  ## Changing the worker count at runtime is not supported
98
107
 
data/docs/SETUP.md CHANGED
@@ -148,10 +148,19 @@ config.logger = Rails.logger
148
148
  | `enabled` | `true` | start the monitor thread | `true` or `false` |
149
149
  | `tick` | `10` | seconds between checks | positive number smaller than `max_age` |
150
150
  | `startup_grace_ticks` | `6` | unhealthy startup ticks before one ERROR | positive integer |
151
+ | `shutdown_grace` | `60` | seconds to keep publishing during shutdown | positive finite number |
151
152
 
152
153
  Invalid combinations are rejected before the monitor starts instead of running
153
154
  one that is guaranteed to publish stale marks or never report a stalled startup.
154
155
 
156
+ `shutdown_grace` defaults from `KICKS_LIVENESS_SHUTDOWN_GRACE`; the initializer
157
+ wins when both are set. Set it to at least your Kubernetes
158
+ `terminationGracePeriodSeconds`. It bounds the shutdown exemption, not the
159
+ restart time: after expiry the last mark still has to exceed `max_age` and the
160
+ probe's failure budget still applies. The environment accepts positive integer
161
+ seconds and silently uses 60 for invalid or blank values. Invalid initializer
162
+ values raise `ArgumentError`; fractional positive seconds are accepted.
163
+
155
164
  `dir` and `max_age` are **not** here — they come from environment variables
156
165
  only. See
157
166
  [DESIGN.md](DESIGN.md#what-is-configurable-and-where) for why, and
@@ -169,6 +178,16 @@ Sneakers::WorkerGroup.ancestors.include?(KicksLiveness::Hooks::WorkerGroup) # =>
169
178
  Both must be `true` before the runner starts. If either is `false`, either
170
179
  `install!` has not run, or it ran before `kicks`/`sneakers` was loaded.
171
180
 
181
+ When `expected` is missing, the probe asks whether `KicksLiveness.install!` was
182
+ called before the runner started. This is a hint, not a diagnosis: a slow boot
183
+ or a removed marks directory can produce the same result. It does not install
184
+ hooks or change the failing exit status. The complete output is shown in the
185
+ [image check](KUBERNETES.md#where-your-image-puts-its-gems).
186
+
187
+ When migrating from another probe, confirm the `[liveness] slot 0: started:`
188
+ line and the `expected` file on dev before removing the old probe gem, its
189
+ initializer and its rake tasks.
190
+
172
191
  Once workers are running, the marks directory is the other half of the answer:
173
192
 
174
193
  ```
data/docs/VERIFYING.md CHANGED
@@ -35,9 +35,7 @@ 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.
38
+ The Docker form of scenario 11 and scenario 12 need no cluster or broker.
41
39
 
42
40
  Every `kubectl` call names its context and namespace explicitly, so the script
43
41
  never falls back to `current-context`. It will still operate on whichever target
@@ -45,6 +43,24 @@ you configure: run `status` and inspect both values before `up` or `down`. The
45
43
  script labels namespaces it creates and refuses to apply to or delete an
46
44
  existing namespace without that ownership label.
47
45
 
46
+ For another local cluster, export `KICKS_LIVENESS_K8S_CONTEXT` and
47
+ `KICKS_LIVENESS_DOCKER_CONTEXT` before building. `KICKS_LIVENESS_K8S_NAMESPACE`
48
+ selects the disposable namespace; `KICKS_LIVENESS_IMAGE` and
49
+ `KICKS_LIVENESS_RABBITMQ_IMAGE` select local worker and broker images.
50
+ Run the direct recipes below from the repository root with this guarded helper:
51
+
52
+ ```bash
53
+ export KICKS_LIVENESS_K8S_CONTEXT="${KICKS_LIVENESS_K8S_CONTEXT:-docker-desktop}"
54
+ export KICKS_LIVENESS_K8S_NAMESPACE="${KICKS_LIVENESS_K8S_NAMESPACE:-kicks-liveness}"
55
+ k() {
56
+ local owner
57
+ owner="$(kubectl --context "$KICKS_LIVENESS_K8S_CONTEXT" get namespace \
58
+ "$KICKS_LIVENESS_K8S_NAMESPACE" -o 'jsonpath={.metadata.labels.app\.kubernetes\.io/managed-by}')" || return
59
+ [ "$owner" = kicks-liveness-fixture ] || { echo 'not a fixture namespace' >&2; return 1; }
60
+ kubectl --context "$KICKS_LIVENESS_K8S_CONTEXT" --namespace "$KICKS_LIVENESS_K8S_NAMESPACE" "$@"
61
+ }
62
+ ```
63
+
48
64
  The default image uses Kicks. Build it with Sneakers instead without editing the
49
65
  fixture:
50
66
 
@@ -117,6 +133,14 @@ The recipe from KUBERNETES.md. Proves the probe's threshold and the
117
133
  self-repair, and nothing else — the mark is fixed on the next tick, well inside
118
134
  `failureThreshold`, so the container is never killed.
119
135
 
136
+ ```bash
137
+ pod="$(k get pods -l app=worker -o jsonpath='{.items[0].metadata.name}')"
138
+ k exec "$pod" -- touch -d '120 seconds ago' /opt/app/tmp/health/worker-0
139
+ spec/integration/verify.sh probe # both commands print stale and exit=1
140
+ sleep 12
141
+ spec/integration/verify.sh probe # both commands print healthy and exit=0
142
+ ```
143
+
120
144
  Worth running through **both** commands here, since this is the one scenario
121
145
  where the probe is supposed to answer `1`. `spec/integration/verify.sh probe`
122
146
  does exactly that: the standard `bundle exec kicks-liveness` and the optimised
@@ -132,7 +156,9 @@ worker-0 stale 120s > 45s # exit 1
132
156
  The one that matters. Delete a queue the worker consumes from:
133
157
 
134
158
  ```bash
135
- kubectl exec deploy/rabbitmq -- rabbitmqctl delete_queue kicks_liveness.alpha
159
+ k exec deploy/rabbitmq -- rabbitmqctl delete_queue kicks_liveness.alpha
160
+ k get pods -l app=worker -w # watch RESTARTS increase; Ctrl-C after recovery
161
+ spec/integration/verify.sh probe # exit=0 after the worker re-declares the queue
136
162
  ```
137
163
 
138
164
  Observed, from the moment of deletion:
@@ -166,7 +192,10 @@ not in the pod log. An exec probe's stdout goes nowhere else, which is why
166
192
  ### 4. The broker dies
167
193
 
168
194
  ```bash
169
- kubectl delete pod -l app=rabbitmq --grace-period=0 --force
195
+ k delete pod -l app=rabbitmq --grace-period=0 --force
196
+ k rollout status deploy/rabbitmq --timeout=300s # the Deployment recreates it
197
+ spec/integration/verify.sh logs 100
198
+ spec/integration/verify.sh probe
170
199
  ```
171
200
 
172
201
  The pod must **not** restart. "It survived" is not enough on its own — a broker
@@ -186,7 +215,16 @@ consumers back on its own.
186
215
 
187
216
  ### 5. The broker stays dead
188
217
 
189
- `kubectl scale deploy/rabbitmq --replicas=0`, then wait. Observed over 319 s
218
+ ```bash
219
+ k scale deploy/rabbitmq --replicas=0
220
+ sleep 150 # longer than max_age + periodSeconds * failureThreshold
221
+ spec/integration/verify.sh probe # still exit=0 during Bunny recovery
222
+ k get pods -l app=worker # RESTARTS must not increase
223
+ k scale deploy/rabbitmq --replicas=1 # restore, including after an interrupted check
224
+ k rollout status deploy/rabbitmq --timeout=300s
225
+ ```
226
+
227
+ With the broker scaled to zero, observed over 319 s
190
228
  — seven times `max_age`, more than twice the 135 s steady-state budget — the
191
229
  mark's age never exceeded 9 s and `restartCount` stayed 0. The monitor kept
192
230
  writing, because `recovering_from_network_failure?` never stops being true:
@@ -211,10 +249,82 @@ quickly — the monitor never gets to run again, so
211
249
  `shutting down: consumers are no longer checked` may not appear at all. The
212
250
  absence of ERROR is the observable, not the presence of that line.
213
251
 
252
+ The shutdown exemption is now bounded by `shutdown_grace` (60 seconds by
253
+ default). Keep `KICKS_LIVENESS_SHUTDOWN_GRACE` at least as long as the fixture's
254
+ `terminationGracePeriodSeconds`. An out-of-band SIGTERM does not start a kubelet
255
+ termination deadline: if the process remains stuck draining, the expected
256
+ monitor diagnostic after its grace is:
257
+
258
+ ```
259
+ [liveness] slot 0: shutdown grace expired: heartbeat is no longer refreshed
260
+ ```
261
+
262
+ The probe can still pass until the last mark exceeds `max_age`; afterwards it
263
+ must exit 1 with `worker-0 stale ...`, and the monitor must not refresh that
264
+ mark on later ticks. The deadline, repeated callbacks, wall-clock changes, and
265
+ a sibling fork's independent lifetime are covered without Kubernetes by:
266
+
267
+ ```bash
268
+ bundle exec rspec spec/kicks_liveness/configuration_spec.rb \
269
+ spec/kicks_liveness/monitor_spec.rb spec/probe_spec.rb
270
+ ```
271
+
272
+ The probe spec launches a separate Ruby process against the monitor's marks.
273
+ For the real worker and broker, run:
274
+
275
+ ```bash
276
+ spec/integration/verify.sh shutdown
277
+ ```
278
+
279
+ This temporarily uses `tick=1`, `max_age=3`, `shutdown_grace=10` and pod
280
+ termination grace 10 seconds, then restores the original deployment even on
281
+ failure. First it holds a job, rolls out a worker, releases the job only after
282
+ the old pod's first shutdown tick, and requires the job to finish and no
283
+ liveness ERROR; how long the replacement takes to become Ready does not matter.
284
+ Then it blocks a real pool drain, sends SIGTERM without deleting the pod,
285
+ removes the remaining idle consumer, and checks zero consumers, an initially
286
+ healthy probe, one expiry ERROR, an unchanged mark, a stale probe, and a kubelet
287
+ restart restoring both consumers. It releases the blocking job during recovery.
288
+
289
+ Recorded on 2026-10-21 with 0.1.4: both paths passed on OrbStack Kubernetes
290
+ v1.30.7+orb1, Linux arm64 6.12.10-orbstack-00297-gf8f6e015b993, Docker 27.4.1,
291
+ Ruby 3.4.11, Kicks 3.4.0, Bunny 3.4.0 and the openssl gem 4.0.2, using an image
292
+ built with `--no-cache --pull`. The stuck pod's `restartCount` changed from 0
293
+ to 1.
294
+
295
+ Replayed on 2026-10-21–22 on the same cluster with Sneakers 2.11.0 /
296
+ ServerEngine 2.0.7 and Sneakers 2.12.0 / ServerEngine 2.1.1, both on Ruby
297
+ 3.4.11 and Bunny 2.24.0: both drain paths passed, including the kubelet restart
298
+ and restored consumers. These images used cached layers and local gem packages,
299
+ not another clean online build.
300
+
301
+ Separate manual pod-deletion checks held the job beyond `shutdown_grace=3s`,
302
+ with `tick=1s`, `max_age=3s` and Kubernetes termination grace 20 seconds. On
303
+ both Sneakers versions and Kicks 3.4.0, an expired mark stayed unchanged and
304
+ both probe commands failed, but the terminating container was not restarted.
305
+ Releasing the job after expiry let it finish with exit 0 in 10.4–11.6 seconds
306
+ from deletion; leaving it blocked caused kubelet to kill it with exit 137 in
307
+ 20.8–20.9 seconds. Each run logged expiry once and recovered a healthy
308
+ replacement. The expiry ERROR is expected with this deliberately shorter
309
+ exemption; normal deployments should keep it at least as long as pod grace.
310
+
214
311
  ### 7. Bad broker credentials
215
312
 
216
313
  Point `AMQP_URL` at a wrong password. The fork cannot subscribe, so the
217
- supervisor respawns it every `start_worker_delay`, forever. Over 120 s:
314
+ supervisor respawns it every `start_worker_delay`, forever. The new pod never
315
+ passes its startup probe, so the rollout keeps the old healthy pod running and
316
+ `deploy/worker` may resolve to either one; read the newest pod by name. Over
317
+ 120 s:
318
+
319
+ ```bash
320
+ amqp="$(k get deploy worker -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="AMQP_URL")].value}')"
321
+ k set env deploy/worker AMQP_URL=amqp://guest:wrong@rabbitmq:5672
322
+ sleep 120
323
+ new="$(k get pods -l app=worker --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1:].metadata.name}')"
324
+ k logs "$new" --tail=30000 | grep liveness
325
+ k set env deploy/worker "AMQP_URL=$amqp" # restore even if the check fails
326
+ k rollout status deploy/worker --timeout=300s
327
+ ```
218
328
 
219
329
  | | lines |
220
330
  |---|---|
@@ -247,6 +357,18 @@ find them.
247
357
  `WORKER_COUNT=2` needs no rebuild; the rake task turns it into
248
358
  `config[:workers]`.
249
359
 
360
+ ```bash
361
+ k set env deploy/worker WORKER_COUNT=2
362
+ k rollout status deploy/worker --timeout=300s
363
+ spec/integration/verify.sh probe # 2 process(es) healthy
364
+ pod="$(k get pods -l app=worker -o jsonpath='{.items[0].metadata.name}')"
365
+ k exec "$pod" -- touch -d '120 seconds ago' /opt/app/tmp/health/worker-1
366
+ spec/integration/verify.sh probe # worker-1 alone makes it fail
367
+ sleep 12 # the monitor repairs the mark
368
+ k set env deploy/worker WORKER_COUNT=1
369
+ k rollout status deploy/worker --timeout=300s
370
+ ```
371
+
250
372
  ```
251
373
  INFO: [liveness] slot 0: started: ... processes=2 consumers=2
252
374
  INFO: [liveness] slot 1: started: ... processes=2 consumers=2
@@ -268,6 +390,17 @@ because KUBERNETES.md claims the startup probe *incidentally fixes rollouts
268
390
  where the old pod was removed before the new one had subscribed* — and that
269
391
  claim is about ordering, which needs two pods to be watched at once.
270
392
 
393
+ ```bash
394
+ k set env deploy/worker WORKER_COUNT=2
395
+ k rollout status deploy/worker --timeout=300s
396
+ # In a second shell with the same exports and helper, watch until rollout completes:
397
+ # while true; do k exec deploy/rabbitmq -- rabbitmqctl -q list_queues name consumers; sleep 1; done
398
+ k rollout restart deploy/worker
399
+ k rollout status deploy/worker --timeout=300s
400
+ k set env deploy/worker WORKER_COUNT=1 # restore; stop the watcher with Ctrl-C
401
+ k rollout status deploy/worker --timeout=300s
402
+ ```
403
+
271
404
  `kubectl rollout restart deploy/worker`, with `WORKER_COUNT=2`, so a fully
272
405
  subscribed pod is worth two consumers per queue. Counting consumers on the
273
406
  broker is what makes the ordering visible:
@@ -298,14 +431,14 @@ treatment as everything else here. The broker in this fixture is enough to check
298
431
  it; the plugin is not on by default:
299
432
 
300
433
  ```bash
301
- kubectl exec deploy/rabbitmq -- rabbitmq-plugins enable rabbitmq_prometheus
434
+ k exec deploy/rabbitmq -- rabbitmq-plugins enable rabbitmq_prometheus
302
435
  ```
303
436
 
304
437
  Then read all three endpoints from the worker pod, which has a Ruby but no
305
438
  `curl`:
306
439
 
307
440
  ```bash
308
- kubectl exec deploy/worker -- ruby -rnet/http -e \
441
+ k exec deploy/worker -- ruby -rnet/http -e \
309
442
  'puts Net::HTTP.get(URI("http://rabbitmq:15692/metrics/detailed?family=queue_consumer_count"))'
310
443
  ```
311
444
 
@@ -324,6 +457,15 @@ metric is a *different* name from the aggregate, the detailed endpoint needs the
324
457
  Then check the two halves of the alert against a throwaway queue — declare one
325
458
  with no consumers, scrape, delete it, scrape again:
326
459
 
460
+ ```bash
461
+ k exec deploy/worker -- bundle exec ruby -rbunny -e \
462
+ 'c = Bunny.new(ENV.fetch("AMQP_URL")).start; c.create_channel.queue("kicks_liveness.tmpcheck"); c.close'
463
+ # Repeat the scrape above: tmpcheck must have a zero-consumer series.
464
+ k exec deploy/rabbitmq -- rabbitmqctl delete_queue kicks_liveness.tmpcheck
465
+ # Repeat the scrape: tmpcheck must have no series, not a zero.
466
+ k exec deploy/rabbitmq -- rabbitmq-plugins disable rabbitmq_prometheus # restore
467
+ ```
468
+
327
469
  ```
328
470
  -- queue exists, zero consumers
329
471
  rabbitmq_detailed_queue_consumers{vhost="/",queue="kicks_liveness.tmpcheck"} 0
@@ -379,6 +521,23 @@ container restart and this guarantee cannot hold — scenario 12 is that case,
379
521
  showing both fail-closed rejection when the topology can be proved and the
380
522
  degraded outcomes when procfs access cannot prove it.
381
523
 
524
+ The Kubernetes counterpart also leaves `attempt-0` in the pod's `emptyDir`:
525
+
526
+ ```bash
527
+ spec/integration/verify.sh container-restart
528
+ ```
529
+
530
+ It makes PID 1 exit itself through a fixture-only WINCH handler, not an ignored
531
+ in-namespace SIGKILL or ServerEngine's USR2 reload. It requires the same pod,
532
+ an increased `restartCount`, exit 137, a surviving volume witness, a changed
533
+ generation, one replacement `started:` line without inherited escalation, and
534
+ a healthy probe; the original deployment is restored afterwards. On the
535
+ environment recorded in scenario 6, all checks passed with `restartCount=0->1`.
536
+ The same check also passed for both Sneakers versions recorded there.
537
+ The Docker form was also rerun: PID 1 start time changed from 1515589 to 1515784
538
+ with mount namespace inode 4026534862 unchanged; inherited marks were rejected
539
+ until the delayed replacement writer published.
540
+
382
541
  ### 12. Shared and host PID topology outcomes
383
542
 
384
543
  The four PID topologies, in one command. It needs the fixture image but neither
@@ -13,6 +13,8 @@ module KicksLiveness
13
13
  DEFAULT_TICK = 10
14
14
  # @return [Integer] unhealthy ticks tolerated at startup before one ERROR
15
15
  DEFAULT_STARTUP_GRACE_TICKS = 6
16
+ # @return [Integer] seconds to keep publishing while the workers drain
17
+ DEFAULT_SHUTDOWN_GRACE = 60
16
18
 
17
19
  # Seconds between checks. An incompatibility warning for an environment
18
20
  # fallback is delayed until this value is actually used, so an initializer
@@ -24,6 +26,8 @@ module KicksLiveness
24
26
  end
25
27
  # @return [Integer] unhealthy ticks tolerated at startup before one ERROR
26
28
  attr_reader :startup_grace_ticks
29
+ # @return [Numeric] seconds allowed for graceful shutdown, measured monotonically
30
+ attr_reader :shutdown_grace
27
31
  # @return [Logger, nil] explicit logger; defaults to +Sneakers.logger+
28
32
  attr_accessor :logger
29
33
  # @param value [Boolean] set false to start no monitor thread, e.g. in tests
@@ -34,6 +38,7 @@ module KicksLiveness
34
38
  @enabled = true
35
39
  @logger = nil
36
40
  @tick = environment_tick
41
+ @shutdown_grace = Heartbeat.env_int(:shutdown_grace, DEFAULT_SHUTDOWN_GRACE)
37
42
  end
38
43
 
39
44
  # @return [Boolean]
@@ -70,6 +75,21 @@ module KicksLiveness
70
75
  @startup_grace_ticks = ticks
71
76
  end
72
77
 
78
+ # Only the monitor reads this value, so an initializer may override the
79
+ # environment. Keep it at least as long as terminationGracePeriodSeconds.
80
+ # An infinite value would restore the unbounded shutdown exemption.
81
+ #
82
+ # @param seconds [Numeric] finite, positive duration
83
+ # @raise [ArgumentError] unless +seconds+ is a positive finite real number
84
+ # @return [Numeric]
85
+ def shutdown_grace=(seconds)
86
+ unless seconds.is_a?(Numeric) && seconds.real? && seconds.finite? && seconds.positive?
87
+ raise ArgumentError, "shutdown_grace must be a positive finite number, got #{seconds.inspect}"
88
+ end
89
+
90
+ @shutdown_grace = seconds
91
+ end
92
+
73
93
  # Read-only, sourced from the environment so that it matches what the probe
74
94
  # sees.
75
95
  # @return [String] marks directory
@@ -168,7 +168,8 @@ module KicksLiveness
168
168
  ENV_NAMES = {
169
169
  dir: 'KICKS_LIVENESS_DIR',
170
170
  max_age: 'KICKS_LIVENESS_MAX_AGE',
171
- tick: 'KICKS_LIVENESS_TICK'
171
+ tick: 'KICKS_LIVENESS_TICK',
172
+ shutdown_grace: 'KICKS_LIVENESS_SHUTDOWN_GRACE'
172
173
  }.freeze
173
174
 
174
175
  # Inspects this process's container generation and PID topology.
@@ -293,7 +294,13 @@ module KicksLiveness
293
294
  return [false, UNSUPPORTED_TOPOLOGY] if unsupported_topology?
294
295
 
295
296
  processes = expected
296
- return [false, "no #{expected_path}: worker has not started yet"] unless processes&.positive?
297
+ unless processes&.positive?
298
+ return [
299
+ false,
300
+ "no #{expected_path}: worker has not started yet " \
301
+ '(is KicksLiveness.install! called before the runner starts? outside Rails it is not automatic)'
302
+ ]
303
+ end
297
304
  return [false, 'heartbeat belongs to a previous container: worker has not started yet'] unless current_generation?
298
305
 
299
306
  problems = (0...processes).filter_map { |slot| problem_for(slot, now) }
@@ -45,10 +45,7 @@ module KicksLiveness
45
45
  # cannot recover a probe whose expected count is missing.
46
46
  @heartbeat.declare!(@processes)
47
47
 
48
- if Registry.stopping?
49
- shutdown_tick!
50
- return true
51
- end
48
+ return shutdown_tick! if Registry.stopping?
52
49
 
53
50
  healthy = Registry.healthy?(@consumers)
54
51
  if healthy
@@ -111,12 +108,24 @@ module KicksLiveness
111
108
  @unhealthy_ticks ||= 0
112
109
  end
113
110
 
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
117
- 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
+
118
124
  @heartbeat.touch!(@slot)
125
+ true
126
+ end
119
127
 
128
+ def report_shutdown_once
120
129
  return if @shutdown_reported
121
130
 
122
131
  log(:info, 'shutting down: consumers are no longer checked')
@@ -9,7 +9,7 @@ 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.
@@ -36,13 +36,23 @@ module KicksLiveness
36
36
  # Mark deliberate shutdown before unsubscription changes the predicate.
37
37
  #
38
38
  # @return [true]
39
- def stopping!
40
- @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
41
42
  end
42
43
 
43
44
  # @return [Boolean] whether shutdown has begun
44
45
  def stopping?
45
- @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 }
46
56
  end
47
57
 
48
58
  # Whether this process is consuming everything it is supposed to consume.
@@ -1,4 +1,4 @@
1
1
  module KicksLiveness
2
2
  # @return [String] gem version
3
- VERSION = '0.1.3'.freeze
3
+ VERSION = '0.1.4'.freeze
4
4
  end
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.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - PoroshkinaVV
@@ -46,7 +46,7 @@ licenses:
46
46
  - MIT
47
47
  metadata:
48
48
  source_code_uri: https://github.com/PoroshkinaVV/kicks_liveness
49
- documentation_uri: https://rubydoc.info/gems/kicks_liveness/0.1.3
49
+ documentation_uri: https://rubydoc.info/gems/kicks_liveness/0.1.4
50
50
  changelog_uri: https://github.com/PoroshkinaVV/kicks_liveness/blob/main/CHANGELOG.md
51
51
  bug_tracker_uri: https://github.com/PoroshkinaVV/kicks_liveness/issues
52
52
  rubygems_mfa_required: 'true'