odoshi 0.3.0
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 +7 -0
- data/CHANGELOG.md +93 -0
- data/LICENSE +21 -0
- data/README.md +168 -0
- data/exe/odoshi +4 -0
- data/lib/odoshi/adapter.rb +21 -0
- data/lib/odoshi/adapters/command.rb +87 -0
- data/lib/odoshi/adapters/puma.rb +59 -0
- data/lib/odoshi/adapters/solid_queue.rb +45 -0
- data/lib/odoshi/adapters/supervisor_adapter.rb +95 -0
- data/lib/odoshi/backoff.rb +18 -0
- data/lib/odoshi/child_spec.rb +33 -0
- data/lib/odoshi/cli.rb +33 -0
- data/lib/odoshi/dsl.rb +85 -0
- data/lib/odoshi/heartbeat.rb +98 -0
- data/lib/odoshi/orphan_guard.rb +35 -0
- data/lib/odoshi/probe.rb +44 -0
- data/lib/odoshi/restart_intensity.rb +22 -0
- data/lib/odoshi/socket_server.rb +115 -0
- data/lib/odoshi/strategy.rb +18 -0
- data/lib/odoshi/supervisor.rb +266 -0
- data/lib/odoshi/telemetry.rb +64 -0
- data/lib/odoshi/version.rb +4 -0
- data/lib/odoshi.rb +31 -0
- data/lib/puma/plugin/odoshi.rb +51 -0
- metadata +72 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Odoshi
|
|
3
|
+
# DESIGN §3.1 / §3.3. One supervisor, an ordered set of children, one strategy.
|
|
4
|
+
# Nested supervisors (subtrees) are ordinary children via Adapters::SupervisorAdapter:
|
|
5
|
+
# a subtree escalating shows up here as a crashed child exit.
|
|
6
|
+
class Supervisor
|
|
7
|
+
attr_reader :children, :strategy, :intensity, :backoff
|
|
8
|
+
|
|
9
|
+
def initialize(strategy: :one_for_one, intensity: RestartIntensity.new, backoff: Backoff.new,
|
|
10
|
+
socket_path: nil)
|
|
11
|
+
raise ConfigError, "unknown strategy #{strategy}" unless Strategy::KINDS.include?(strategy)
|
|
12
|
+
@strategy, @intensity, @backoff = strategy, intensity, backoff
|
|
13
|
+
@children = [] # ordered ChildSpecs
|
|
14
|
+
@live = {} # id => { adapter:, handle:, attempts:, generation:, monitor: }
|
|
15
|
+
@queue = Queue.new
|
|
16
|
+
@stopping = false
|
|
17
|
+
@stop_requested = false
|
|
18
|
+
@heartbeats = {} # id => { at: monotonic ts of last heartbeat, state: reported state }
|
|
19
|
+
return unless socket_path
|
|
20
|
+
@socket = SocketServer.new(
|
|
21
|
+
path: socket_path,
|
|
22
|
+
# Heartbeats for ids that aren't children of THIS supervisor are
|
|
23
|
+
# dropped at intake: ghost ids must not grow the table unboundedly
|
|
24
|
+
# (issue #16/#27). Subtree-grandchild routing is the open flat-id
|
|
25
|
+
# question — until it's decided, their beats are dropped, not hoarded.
|
|
26
|
+
on_heartbeat: lambda { |msg|
|
|
27
|
+
id = msg["id"].to_sym
|
|
28
|
+
@heartbeats[id] = { at: mono_now, state: msg["state"] } if @children.any? { |c| c.id == id }
|
|
29
|
+
},
|
|
30
|
+
on_control: ->(msg) { @queue << { type: :control, cmd: msg["cmd"], id: msg["id"].to_s.to_sym } }
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The per-boot token children must echo in every heartbeat (nil when the
|
|
35
|
+
# socket is disabled). Exported to children as ODOSHI_TOKEN.
|
|
36
|
+
def heartbeat_token = @socket&.token
|
|
37
|
+
|
|
38
|
+
def add_child(spec)
|
|
39
|
+
raise ConfigError, "duplicate child id #{spec.id}" if @children.any? { |c| c.id == spec.id }
|
|
40
|
+
@children << spec
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Blocks until the tree is shut down. Raises Escalation if intensity is exceeded.
|
|
45
|
+
def run
|
|
46
|
+
Telemetry.emit(:"supervisor.start", {}, { strategy: strategy, children: ids })
|
|
47
|
+
@socket&.start # before children, so they inherit ODOSHI_SOCK/_TOKEN
|
|
48
|
+
@children.each do |spec|
|
|
49
|
+
break if @stop_requested
|
|
50
|
+
start_child(spec)
|
|
51
|
+
end
|
|
52
|
+
loop do
|
|
53
|
+
msg = @queue.pop
|
|
54
|
+
case msg[:type]
|
|
55
|
+
when :exit then handle_exit(msg[:id], msg[:generation], msg[:status])
|
|
56
|
+
when :health_dead then handle_health_dead(msg[:id], msg[:generation])
|
|
57
|
+
when :control then handle_control(msg)
|
|
58
|
+
when :stop then break
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
ensure
|
|
62
|
+
stop_all
|
|
63
|
+
@socket&.stop
|
|
64
|
+
Telemetry.emit(:"supervisor.stop")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Sets the flag first: the main loop may be stuck in a wait_healthy poll
|
|
68
|
+
# or a backoff sleep for up to start_timeout/backoff seconds, and shutdown
|
|
69
|
+
# must not wait for those (issue #28 — platforms SIGKILL after their grace
|
|
70
|
+
# period, which resurrects the orphan problem).
|
|
71
|
+
def stop
|
|
72
|
+
@stop_requested = true
|
|
73
|
+
@queue << { type: :stop }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Public remediation API (DESIGN §7). Over IPC in the real thing; direct call here.
|
|
77
|
+
def restart!(id)
|
|
78
|
+
spec = spec_for(id)
|
|
79
|
+
stop_child(spec)
|
|
80
|
+
start_child(spec)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def ids = @children.map(&:id)
|
|
84
|
+
|
|
85
|
+
# Debug/test hook. Not public API.
|
|
86
|
+
def live_pid(id) = @live.dig(id, :handle)&.pid
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def spec_for(id) = @children.find { |c| c.id == id } || raise(ArgumentError, "no child #{id}")
|
|
91
|
+
|
|
92
|
+
def start_child(spec)
|
|
93
|
+
@heartbeats.delete(spec.id) # a replaced child's heartbeats must not vouch for its successor
|
|
94
|
+
adapter = Adapter.lookup(spec.adapter).new
|
|
95
|
+
handle = adapter.spawn(spec)
|
|
96
|
+
prev = @live[spec.id] || {}
|
|
97
|
+
generation = (prev[:generation] || 0) + 1
|
|
98
|
+
@live[spec.id] = { adapter: adapter, handle: handle, attempts: prev[:attempts] || 0, generation: generation }
|
|
99
|
+
adapter.link(handle) do |status|
|
|
100
|
+
@queue << { type: :exit, id: spec.id, generation: generation, status: status } unless @stopping
|
|
101
|
+
end
|
|
102
|
+
Telemetry.emit(:"child.spawn", {}, { id: spec.id, adapter: spec.adapter, pid: handle.respond_to?(:pid) ? handle.pid : nil })
|
|
103
|
+
start_monitor(spec, generation) if wait_healthy(spec, adapter, handle) == :healthy
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# PLAN 1.3: per-child polling thread. :degraded emits telemetry only,
|
|
107
|
+
# unless degraded_restart_after consecutive reports accumulate; :dead from
|
|
108
|
+
# a probe (not just SIGCHLD) goes through the exit queue like any crash.
|
|
109
|
+
def start_monitor(spec, generation)
|
|
110
|
+
return unless spec.health_interval
|
|
111
|
+
entry = @live[spec.id]
|
|
112
|
+
adapter, handle = entry[:adapter], entry[:handle]
|
|
113
|
+
degraded = 0
|
|
114
|
+
entry[:monitor] = Thread.new do
|
|
115
|
+
loop do
|
|
116
|
+
sleep spec.health_interval
|
|
117
|
+
break if @stopping || @live.dig(spec.id, :generation) != generation
|
|
118
|
+
case effective_health(spec, adapter, handle)
|
|
119
|
+
when :healthy
|
|
120
|
+
degraded = 0
|
|
121
|
+
# One healthy interval resets the backoff ladder (#19): a child
|
|
122
|
+
# that crashes rarely should not converge to permanent max
|
|
123
|
+
# backoff. Crash-looping children never reach a monitor, so flap
|
|
124
|
+
# damping is unaffected.
|
|
125
|
+
entry[:attempts] = 0
|
|
126
|
+
when :degraded
|
|
127
|
+
degraded += 1
|
|
128
|
+
Telemetry.emit(:"child.degraded", { consecutive: degraded }, { id: spec.id })
|
|
129
|
+
if spec.degraded_restart_after && degraded >= spec.degraded_restart_after
|
|
130
|
+
@queue << { type: :health_dead, id: spec.id, generation: generation }
|
|
131
|
+
break
|
|
132
|
+
end
|
|
133
|
+
when :dead
|
|
134
|
+
@queue << { type: :health_dead, id: spec.id, generation: generation }
|
|
135
|
+
break
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def wait_healthy(spec, adapter, handle)
|
|
142
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + spec.start_timeout
|
|
143
|
+
loop do
|
|
144
|
+
return :stopping if @stop_requested # shutdown must not wait out start_timeout (#28)
|
|
145
|
+
case effective_health(spec, adapter, handle)
|
|
146
|
+
when :healthy then Telemetry.emit(:"child.healthy", {}, { id: spec.id }); return :healthy
|
|
147
|
+
when :dead then return :dead # the exit message arrives via link
|
|
148
|
+
end
|
|
149
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
150
|
+
# PLAN 1.2: start_timeout exceeded ⇒ drain. The resulting exit flows
|
|
151
|
+
# through the normal link → handle_exit path, so it counts as a
|
|
152
|
+
# crash and the strategy + intensity apply.
|
|
153
|
+
stop_child(spec)
|
|
154
|
+
return :timeout
|
|
155
|
+
end
|
|
156
|
+
sleep 0.05
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
HEARTBEAT_STATES = { "starting" => :starting, "healthy" => :healthy,
|
|
161
|
+
"degraded" => :degraded, "dead" => :dead }.freeze
|
|
162
|
+
|
|
163
|
+
# DESIGN §5/§9: health is active-first. A child that has heartbeated is
|
|
164
|
+
# judged by heartbeat freshness and its own reported state — missing 3
|
|
165
|
+
# intervals ⇒ :degraded, 6 ⇒ :dead. Children that never heartbeat fall
|
|
166
|
+
# back to the adapter's passive probe.
|
|
167
|
+
def effective_health(spec, adapter, handle)
|
|
168
|
+
hb = @heartbeats[spec.id]
|
|
169
|
+
# No heartbeat ⇒ passive probe. A nil health_interval (subtree specs)
|
|
170
|
+
# also falls through: freshness aging needs an interval, and dividing
|
|
171
|
+
# by nil crashed the whole tree when a heartbeat named such an id (#25).
|
|
172
|
+
return adapter.health(handle) unless hb && spec.health_interval
|
|
173
|
+
missed = (mono_now - hb[:at]) / spec.health_interval
|
|
174
|
+
return :dead if missed >= 6
|
|
175
|
+
return :degraded if missed >= 3
|
|
176
|
+
HEARTBEAT_STATES.fetch(hb[:state], :healthy)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def mono_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
180
|
+
|
|
181
|
+
# DESIGN §7/§9: {"cmd":"restart","id":...} over the socket — the transport
|
|
182
|
+
# Rails.supervisor.restart! rides later. Unknown commands and ids are
|
|
183
|
+
# ignored; the token was already checked at the socket layer.
|
|
184
|
+
def handle_control(msg)
|
|
185
|
+
return unless msg[:cmd] == "restart"
|
|
186
|
+
return unless @children.any? { |c| c.id == msg[:id] }
|
|
187
|
+
restart!(msg[:id])
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# A monitor thread declared this child unhealthy enough to replace. Drain
|
|
191
|
+
# it; the resulting real exit flows through link → handle_exit, so the
|
|
192
|
+
# strategy and intensity apply exactly as for a crash (same path as the
|
|
193
|
+
# 1.2 start_timeout drain). If the child already exited and was replaced,
|
|
194
|
+
# the generation guard makes this a no-op.
|
|
195
|
+
def handle_health_dead(id, generation)
|
|
196
|
+
entry = @live[id]
|
|
197
|
+
return if entry.nil? || entry[:generation] != generation
|
|
198
|
+
stop_child(spec_for(id))
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def handle_exit(id, generation, status)
|
|
202
|
+
spec = spec_for(id)
|
|
203
|
+
entry = @live[id]
|
|
204
|
+
return if entry.nil? || entry[:generation] != generation # stale exit from a child we already replaced
|
|
205
|
+
|
|
206
|
+
uptime = Process.clock_gettime(Process::CLOCK_MONOTONIC) - entry[:handle].started_at
|
|
207
|
+
Telemetry.emit(:"child.exit", { exit_code: status&.exitstatus, uptime_ms: (uptime * 1000).round }, { id: id })
|
|
208
|
+
unless spec.restart?(status)
|
|
209
|
+
# The child is gone for good: keep no stale entry, or stop_all and the
|
|
210
|
+
# monitor emit spurious drains for a corpse later (issue #20).
|
|
211
|
+
entry[:monitor]&.kill
|
|
212
|
+
@live.delete(id)
|
|
213
|
+
return
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
if intensity.record!
|
|
217
|
+
Telemetry.emit(:"supervisor.escalate", { restarts: intensity.count }, { within: intensity.within })
|
|
218
|
+
raise Escalation, "restart intensity exceeded (#{intensity.count} in #{intensity.within}s)"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
affected = Strategy.affected(strategy, ids, id)
|
|
222
|
+
# OTP semantics (#14): declaration order encodes dependency, so first
|
|
223
|
+
# terminate ALL affected children in reverse start order — a
|
|
224
|
+
# replacement :b must never boot while an old :c that depended on the
|
|
225
|
+
# dead :b is still running — then restart them in start order.
|
|
226
|
+
affected.reverse_each do |aid|
|
|
227
|
+
stop_child(spec_for(aid)) unless aid == id
|
|
228
|
+
end
|
|
229
|
+
affected.each do |aid|
|
|
230
|
+
break if @stop_requested # shutdown preempts the restart fan-out (#28)
|
|
231
|
+
entry = @live[aid]
|
|
232
|
+
next unless entry # a temporary/clean-transient sibling is gone for good (#20)
|
|
233
|
+
attempts = (entry[:attempts] += 1)
|
|
234
|
+
delay = backoff.delay(attempts)
|
|
235
|
+
Telemetry.emit(:"child.restart", { backoff_ms: (delay * 1000).round }, { id: aid, attempt: attempts, strategy: strategy })
|
|
236
|
+
interruptible_sleep(delay)
|
|
237
|
+
break if @stop_requested
|
|
238
|
+
start_child(spec_for(aid))
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Backoff must not delay shutdown (#28): sleep in slices, bail on stop.
|
|
243
|
+
def interruptible_sleep(seconds)
|
|
244
|
+
deadline = mono_now + seconds
|
|
245
|
+
while mono_now < deadline
|
|
246
|
+
return if @stop_requested
|
|
247
|
+
sleep [0.1, deadline - mono_now].min
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def stop_child(spec)
|
|
252
|
+
entry = @live[spec.id] or return
|
|
253
|
+
entry[:monitor]&.kill
|
|
254
|
+
entry[:monitor] = nil
|
|
255
|
+
Telemetry.emit(:"child.drain", {}, { id: spec.id })
|
|
256
|
+
return if entry[:adapter].drain(entry[:handle], timeout: spec.shutdown)
|
|
257
|
+
Telemetry.emit(:"child.kill", {}, { id: spec.id })
|
|
258
|
+
entry[:adapter].kill(entry[:handle])
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def stop_all
|
|
262
|
+
@stopping = true
|
|
263
|
+
@children.reverse_each { |spec| stop_child(spec) }
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Odoshi
|
|
5
|
+
# Minimal event bus. Event names mirror Elixir :telemetry (DESIGN §6):
|
|
6
|
+
# event: [:odoshi, :child, :restart], measurements: {...}, metadata: {...}
|
|
7
|
+
# A railtie will bridge these into ActiveSupport::Notifications inside children.
|
|
8
|
+
module Telemetry
|
|
9
|
+
EVENTS = %i[
|
|
10
|
+
supervisor.start supervisor.stop supervisor.escalate
|
|
11
|
+
child.spawn child.healthy child.degraded child.exit child.restart child.drain child.kill
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
@subscribers = []
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def subscribe(&block)
|
|
19
|
+
@mutex.synchronize { @subscribers << block }
|
|
20
|
+
block
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def unsubscribe(block)
|
|
24
|
+
@mutex.synchronize { @subscribers.delete(block) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def reset!
|
|
28
|
+
@mutex.synchronize { @subscribers.clear }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# name: Symbol like :"child.restart" (flat in Ruby; split on "." for the Elixir side)
|
|
32
|
+
def emit(name, measurements = {}, metadata = {})
|
|
33
|
+
raise ArgumentError, "unknown event #{name}" unless EVENTS.include?(name)
|
|
34
|
+
event = { event: [:odoshi, *name.to_s.split(".").map(&:to_sym)],
|
|
35
|
+
measurements: measurements, metadata: metadata,
|
|
36
|
+
ts: Process.clock_gettime(Process::CLOCK_REALTIME) }
|
|
37
|
+
subs = @mutex.synchronize { @subscribers.dup }
|
|
38
|
+
subs.each do |s|
|
|
39
|
+
s.call(event)
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
# A subscriber must never break the bus: emit is called from the
|
|
42
|
+
# supervisor loop and monitor threads, and other subscribers (the
|
|
43
|
+
# Elixir sidecar exporter, the resilience bridge) must keep
|
|
44
|
+
# receiving events even when one subscriber raises (resilience#1).
|
|
45
|
+
warn "[odoshi] telemetry subscriber raised: #{e.class}: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
event
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Default subscribers shipped in v0.1
|
|
52
|
+
module Subscribers
|
|
53
|
+
def self.logger(io = $stderr)
|
|
54
|
+
Telemetry.subscribe do |e|
|
|
55
|
+
io.puts("[odoshi] #{e[:event].join('.')} #{e[:metadata].inspect} #{e[:measurements].inspect}")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.json_lines(io = $stdout)
|
|
60
|
+
Telemetry.subscribe { |e| io.puts(JSON.generate(e)) }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/odoshi.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# odoshi: a slim, Rails-free process supervisor with OTP semantics.
|
|
4
|
+
# Nothing under lib/odoshi may require Rails, ActiveSupport, or ActiveRecord (DESIGN §9).
|
|
5
|
+
# The Rails-side bridge (railtie) is a later deliverable and is only ever loaded inside children.
|
|
6
|
+
|
|
7
|
+
require_relative "odoshi/version"
|
|
8
|
+
|
|
9
|
+
module Odoshi
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
class ConfigError < Error; end
|
|
12
|
+
class Escalation < Error; end # raised when restart intensity is exceeded
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
require_relative "odoshi/telemetry"
|
|
16
|
+
require_relative "odoshi/child_spec"
|
|
17
|
+
require_relative "odoshi/strategy"
|
|
18
|
+
require_relative "odoshi/restart_intensity"
|
|
19
|
+
require_relative "odoshi/backoff"
|
|
20
|
+
require_relative "odoshi/adapter"
|
|
21
|
+
require_relative "odoshi/probe"
|
|
22
|
+
require_relative "odoshi/orphan_guard"
|
|
23
|
+
require_relative "odoshi/adapters/command"
|
|
24
|
+
require_relative "odoshi/adapters/puma"
|
|
25
|
+
require_relative "odoshi/adapters/solid_queue"
|
|
26
|
+
require_relative "odoshi/socket_server"
|
|
27
|
+
require_relative "odoshi/heartbeat"
|
|
28
|
+
require_relative "odoshi/supervisor"
|
|
29
|
+
require_relative "odoshi/adapters/supervisor_adapter" # after supervisor: wraps a child Supervisor
|
|
30
|
+
require_relative "odoshi/dsl"
|
|
31
|
+
require_relative "odoshi/cli"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Puma plugin (DESIGN §4.2 step 2, PLAN 2.5): worker-level visibility for a
|
|
3
|
+
# :puma child, over the §5 heartbeat socket. Enable with `plugin :odoshi`
|
|
4
|
+
# in config/puma.rb.
|
|
5
|
+
#
|
|
6
|
+
# Lives under lib/puma/ (NOT lib/odoshi/) because it may require puma —
|
|
7
|
+
# it is only ever loaded BY puma, so hard rule 1 (the supervisor has zero
|
|
8
|
+
# dependencies) holds. It reuses the frozen heartbeat protocol untouched:
|
|
9
|
+
# worker detail travels in `state` and `meta`, never in new fields.
|
|
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" }.
|
|
15
|
+
# The supervisor turns a reported "degraded" into [:odoshi, :child,
|
|
16
|
+
# :degraded] telemetry via its normal health loop — no event added, and no
|
|
17
|
+
# lifecycle change: puma still replaces its own workers.
|
|
18
|
+
require "puma/plugin"
|
|
19
|
+
require "odoshi/heartbeat"
|
|
20
|
+
|
|
21
|
+
Puma::Plugin.create do
|
|
22
|
+
def start(launcher)
|
|
23
|
+
beat = Odoshi::Heartbeat.start(
|
|
24
|
+
id: ENV["ODOSHI_CHILD_ID"] || "web",
|
|
25
|
+
interval: Float(ENV.fetch("ODOSHI_HEARTBEAT_INTERVAL", 2)),
|
|
26
|
+
state: -> { odoshi_state(launcher) },
|
|
27
|
+
meta: -> { odoshi_meta(launcher) }
|
|
28
|
+
)
|
|
29
|
+
launcher.events.on_stopped { beat.stop } if beat
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Raising inside the heartbeat thread would silently kill it, so both
|
|
35
|
+
# lambdas degrade to a safe value instead (stats can raise mid-boot).
|
|
36
|
+
def odoshi_state(launcher)
|
|
37
|
+
stats = launcher.stats
|
|
38
|
+
return "healthy" unless stats[:workers] # single mode
|
|
39
|
+
stats[:booted_workers] < stats[:workers] ? "degraded" : "healthy"
|
|
40
|
+
rescue StandardError
|
|
41
|
+
"starting"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def odoshi_meta(launcher)
|
|
45
|
+
stats = launcher.stats
|
|
46
|
+
return { mode: "single" } unless stats[:workers]
|
|
47
|
+
{ workers: stats[:workers], booted: stats[:booted_workers], phase: stats[:phase] }
|
|
48
|
+
rescue StandardError
|
|
49
|
+
{}
|
|
50
|
+
end
|
|
51
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: odoshi
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.3.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- timimsms
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-14 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: A slim supervisor that starts, links, health-checks, and restarts the
|
|
14
|
+
processes of a Rails app (web, jobs, cable, cron) with OTP strategies.
|
|
15
|
+
email:
|
|
16
|
+
executables:
|
|
17
|
+
- odoshi
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- CHANGELOG.md
|
|
22
|
+
- LICENSE
|
|
23
|
+
- README.md
|
|
24
|
+
- exe/odoshi
|
|
25
|
+
- lib/odoshi.rb
|
|
26
|
+
- lib/odoshi/adapter.rb
|
|
27
|
+
- lib/odoshi/adapters/command.rb
|
|
28
|
+
- lib/odoshi/adapters/puma.rb
|
|
29
|
+
- lib/odoshi/adapters/solid_queue.rb
|
|
30
|
+
- lib/odoshi/adapters/supervisor_adapter.rb
|
|
31
|
+
- lib/odoshi/backoff.rb
|
|
32
|
+
- lib/odoshi/child_spec.rb
|
|
33
|
+
- lib/odoshi/cli.rb
|
|
34
|
+
- lib/odoshi/dsl.rb
|
|
35
|
+
- lib/odoshi/heartbeat.rb
|
|
36
|
+
- lib/odoshi/orphan_guard.rb
|
|
37
|
+
- lib/odoshi/probe.rb
|
|
38
|
+
- lib/odoshi/restart_intensity.rb
|
|
39
|
+
- lib/odoshi/socket_server.rb
|
|
40
|
+
- lib/odoshi/strategy.rb
|
|
41
|
+
- lib/odoshi/supervisor.rb
|
|
42
|
+
- lib/odoshi/telemetry.rb
|
|
43
|
+
- lib/odoshi/version.rb
|
|
44
|
+
- lib/puma/plugin/odoshi.rb
|
|
45
|
+
homepage: https://github.com/shishi-odoshi/odoshi
|
|
46
|
+
licenses:
|
|
47
|
+
- MIT
|
|
48
|
+
metadata:
|
|
49
|
+
homepage_uri: https://github.com/shishi-odoshi/odoshi
|
|
50
|
+
source_code_uri: https://github.com/shishi-odoshi/odoshi
|
|
51
|
+
changelog_uri: https://github.com/shishi-odoshi/odoshi/blob/main/CHANGELOG.md
|
|
52
|
+
bug_tracker_uri: https://github.com/shishi-odoshi/odoshi/issues
|
|
53
|
+
post_install_message:
|
|
54
|
+
rdoc_options: []
|
|
55
|
+
require_paths:
|
|
56
|
+
- lib
|
|
57
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '3.2'
|
|
62
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '0'
|
|
67
|
+
requirements: []
|
|
68
|
+
rubygems_version: 3.4.10
|
|
69
|
+
signing_key:
|
|
70
|
+
specification_version: 4
|
|
71
|
+
summary: OTP-style supervision trees for Rails processes
|
|
72
|
+
test_files: []
|