odoshi 0.3.0 → 0.3.1

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: 470bf921f6ef90d3d251f69d22aea25085c61ff8ca722436a933f7e0a05fb898
4
- data.tar.gz: 02e357c5e4191bcbbf2bdd838046a9d6d89fa98c1714db74d60e2e090d32c680
3
+ metadata.gz: 37c7e2f930a701add4d309f524f1bb3f929e67f2df29f124400749fd2454caea
4
+ data.tar.gz: 855cdc3692d053be6a91252f92019b082cc09207844fa4830613c3901e63c5f1
5
5
  SHA512:
6
- metadata.gz: 3a61f368455b2ef8faae1f6140feaefd4c6c5bbe10b1cc67eeb5f35c905ba10f21dfac0e003141ae3f759c0d40be95a99128cf7060b9f03467c19529c022c3a2
7
- data.tar.gz: 5e4bcd0c4385aa3d02a45f64e19c6657126f72efb208b8d514c5577f7140c97cafde64363b6be5ce1d2334969392c4c830702814db3b76d90bf5e5fc503f6f8f
6
+ metadata.gz: 05c598c1dc53ec1c7299b99243e094a6b311a086ab64bef72765e60f896d23e8858bfc2110882d9513acd03a5e43847a7f79fbaf41a9a74d10d23dde58b9f239
7
+ data.tar.gz: 9cf2c1e96c32ffeda5639584d6092c4f2ec22935e856b0bfd4be7979cd50a6148966e051bec4c67a2a24119918f3d1a649689bc8bcb106ef81ad0e868ca9e953
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.1 — 2026-09-14
4
+
5
+ Two fixes from odoshi-bench findings:
6
+ - **Puma plugin heartbeat no longer masks a wedged app (#49).** Under §5 active-first
7
+ health a "healthy" heartbeat out-votes a failing probe; the plugin's stats-only
8
+ heartbeat therefore silently disabled wedge detection on any :puma child running it.
9
+ The plugin now reports the WORSE of worker topology and a /up probe on the first tcp
10
+ bind (`ODOSHI_HEALTH_URL` overrides; unix-only binds fall back to topology-only), and
11
+ `meta` gains `up: true/false`.
12
+ - **Immediate first restart for `:exponential` backoff (odoshi-template#1).** OTP
13
+ convention: a one-off crash costs no delay — the ladder starts at `base` from the
14
+ second consecutive attempt (crash-loops stay bounded by intensity, and a healthy
15
+ interval resets attempts). Benchmarked recovery drops by ~1s under the default config.
16
+
3
17
  ## v0.3.0 — 2026-09-13 — RENAMED: otp-rails → odoshi
4
18
 
5
19
  The gem is now **odoshi** (威し — the active half of [shishi-odoshi](https://github.com/shishi-odoshi),
data/README.md CHANGED
@@ -50,7 +50,7 @@ Top-level directives in `config/supervisor.rb`:
50
50
  |---|---|---|
51
51
  | `strategy KIND` | `:one_for_one` | `:one_for_one` restarts only the failed child; `:rest_for_one` also restarts children declared after it; `:one_for_all` restarts every child |
52
52
  | `max_restarts N, within: S` | `5, within: 60` | Sliding-window restart intensity; exceeding it escalates (exit 70) |
53
- | `backoff KIND, **opts` | `:exponential, base: 1, cap: 30` | `:none`, `:constant`, or `:exponential` delay between restarts |
53
+ | `backoff KIND, **opts` | `:exponential, base: 1, cap: 30` | `:none`, `:constant`, or `:exponential` delay between restarts. `:exponential`'s FIRST restart is immediate (OTP convention); the ladder starts at `base` from the second consecutive attempt |
54
54
  | `socket PATH` | `"tmp/odoshi.sock"` | Heartbeat/control Unix socket; `socket nil` disables it |
55
55
  | `child ID, adapter:, **opts` | — | Declares a child; declaration order is start order |
56
56
  | `supervisor ID do ... end` | — | Nested subtree with its own strategy/intensity/backoff; subtree escalation is an ordinary child exit in the parent |
@@ -5,12 +5,17 @@ module Odoshi
5
5
  @kind, @base, @cap = kind, base.to_f, cap.to_f
6
6
  end
7
7
 
8
- # attempt is 1-based
8
+ # attempt is 1-based. :exponential follows OTP convention: the FIRST
9
+ # restart is immediate — a one-off crash costs no delay (crash-loops are
10
+ # bounded by restart intensity, and a healthy interval resets attempts) —
11
+ # and the ladder starts at `base` from the second consecutive attempt.
12
+ # :constant stays constant every time: explicit is explicit.
13
+ # (odoshi-template#1: `base: 1` was putting a ~1s floor on every recovery.)
9
14
  def delay(attempt)
10
15
  case @kind
11
16
  when :none then 0.0
12
17
  when :constant then @base
13
- when :exponential then [@base * (2**(attempt - 1)), @cap].min
18
+ when :exponential then attempt <= 1 ? 0.0 : [@base * (2**(attempt - 2)), @cap].min
14
19
  else raise ConfigError, "unknown backoff #{@kind}"
15
20
  end
16
21
  end
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Odoshi
3
- VERSION = "0.3.0"
3
+ VERSION = "0.3.1"
4
4
  end
@@ -8,17 +8,29 @@
8
8
  # dependencies) holds. It reuses the frozen heartbeat protocol untouched:
9
9
  # worker detail travels in `state` and `meta`, never in new fields.
10
10
  #
11
- # The master heartbeats every ODOSHI_HEARTBEAT_INTERVAL (default 2s):
12
- # state = "degraded" while any worker is missing (booted < configured),
13
- # "healthy" otherwise (single mode is always "healthy"),
14
- # meta = { workers:, booted:, phase: } (cluster) | { mode: "single" }.
11
+ # The master heartbeats every ODOSHI_HEARTBEAT_INTERVAL (default 2s). The
12
+ # reported state is the WORSE of two views (issue #49 — a heartbeat must
13
+ # never vouch for an app its own health endpoint says is sick, because
14
+ # active heartbeats out-vote passive probes in the supervisor's §5
15
+ # active-first health):
16
+ # - worker topology: "degraded" while any worker is missing (booted <
17
+ # configured); single mode has no workers to count;
18
+ # - app health: /up probed on the first tcp bind — "starting" until the
19
+ # first 2xx, "degraded" if it stops answering 2xx afterwards.
20
+ # meta = { workers:, booted:, phase: } (cluster) | { mode: "single" },
21
+ # plus up: true/false once a tcp bind is probeable.
22
+ # ODOSHI_HEALTH_URL overrides the probe URL (e.g. unix-socket binds);
23
+ # with no tcp bind and no override, state falls back to topology only.
15
24
  # The supervisor turns a reported "degraded" into [:odoshi, :child,
16
25
  # :degraded] telemetry via its normal health loop — no event added, and no
17
26
  # lifecycle change: puma still replaces its own workers.
18
27
  require "puma/plugin"
19
28
  require "odoshi/heartbeat"
29
+ require "odoshi/probe"
20
30
 
21
31
  Puma::Plugin.create do
32
+ HEALTH_PATH = "/up"
33
+
22
34
  def start(launcher)
23
35
  beat = Odoshi::Heartbeat.start(
24
36
  id: ENV["ODOSHI_CHILD_ID"] || "web",
@@ -35,17 +47,49 @@ Puma::Plugin.create do
35
47
  # lambdas degrade to a safe value instead (stats can raise mid-boot).
36
48
  def odoshi_state(launcher)
37
49
  stats = launcher.stats
38
- return "healthy" unless stats[:workers] # single mode
39
- stats[:booted_workers] < stats[:workers] ? "degraded" : "healthy"
50
+ worker_missing = stats[:workers] && stats[:booted_workers] < stats[:workers]
51
+ return "degraded" if worker_missing
52
+ case odoshi_up_state(launcher)
53
+ when :down then "degraded"
54
+ when :booting then "starting"
55
+ else "healthy"
56
+ end
40
57
  rescue StandardError
41
58
  "starting"
42
59
  end
43
60
 
44
61
  def odoshi_meta(launcher)
45
62
  stats = launcher.stats
46
- return { mode: "single" } unless stats[:workers]
47
- { workers: stats[:workers], booted: stats[:booted_workers], phase: stats[:phase] }
63
+ meta = stats[:workers] ? { workers: stats[:workers], booted: stats[:booted_workers], phase: stats[:phase] } : { mode: "single" }
64
+ up = odoshi_up_state(launcher)
65
+ meta[:up] = (up == :up) unless up == :unprobeable
66
+ meta
48
67
  rescue StandardError
49
68
  {}
50
69
  end
70
+
71
+ # :up | :down | :booting (no 2xx yet) | :unprobeable (no tcp bind, no override)
72
+ def odoshi_up_state(launcher)
73
+ url = odoshi_health_url(launcher)
74
+ return :unprobeable unless url
75
+ if Odoshi::Probe.http?(url)
76
+ @odoshi_up_once = true
77
+ :up
78
+ else
79
+ @odoshi_up_once ? :down : :booting
80
+ end
81
+ end
82
+
83
+ def odoshi_health_url(launcher)
84
+ return @odoshi_health_url if defined?(@odoshi_health_url)
85
+ @odoshi_health_url =
86
+ if (override = ENV["ODOSHI_HEALTH_URL"]) && !override.empty?
87
+ override
88
+ elsif (bind = Array(launcher.options[:binds]).find { |b| b.start_with?("tcp://") })
89
+ # tcp://host:port — probe loopback on the bound port ("0.0.0.0" isn't a
90
+ # connectable address)
91
+ port = bind.split(":").last.to_i
92
+ "http://127.0.0.1:#{port}#{HEALTH_PATH}"
93
+ end
94
+ end
51
95
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: odoshi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - timimsms