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.
@@ -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
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module Odoshi
3
+ VERSION = "0.3.0"
4
+ 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: []