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.
data/lib/odoshi/cli.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+ module Odoshi
3
+ module CLI
4
+ USAGE = <<~TXT
5
+ usage: odoshi run [config/supervisor.rb] # start the tree, block until shutdown
6
+ odoshi check [config/supervisor.rb] # validate config, print the tree
7
+ odoshi version
8
+ TXT
9
+
10
+ def self.run(argv)
11
+ cmd, path = argv[0], (argv[1] || "config/supervisor.rb")
12
+ case cmd
13
+ when "run"
14
+ Telemetry::Subscribers.logger
15
+ sup = DSL.load_file(path)
16
+ %w[INT TERM].each { |sig| trap(sig) { sup.stop } }
17
+ sup.run
18
+ 0
19
+ when "check"
20
+ sup = DSL.load_file(path)
21
+ puts "strategy: #{sup.strategy}"
22
+ sup.children.each { |c| puts " #{c.id} (#{c.adapter}, #{c.restart}, shutdown=#{c.shutdown}s)" }
23
+ 0
24
+ when "version" then puts VERSION; 0
25
+ else $stderr.puts USAGE; 1
26
+ end
27
+ rescue Escalation => e
28
+ $stderr.puts "odoshi: #{e.message}"; 70 # EX_SOFTWARE — the platform is the final supervisor
29
+ rescue ConfigError, Errno::ENOENT => e
30
+ $stderr.puts "odoshi: #{e.message}"; 78 # EX_CONFIG
31
+ end
32
+ end
33
+ end
data/lib/odoshi/dsl.rb ADDED
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+ module Odoshi
3
+ # Evaluates config/supervisor.rb WITHOUT Rails loaded (DESIGN §9).
4
+ #
5
+ # strategy :rest_for_one
6
+ # max_restarts 5, within: 60
7
+ # backoff :exponential, base: 1, cap: 30
8
+ # child :web, adapter: :command, cmd: "bundle exec puma -C config/puma.rb"
9
+ # child :jobs, adapter: :command, cmd: "bin/jobs"
10
+ # supervisor :background do # DESIGN §3.1: nested subtree with its
11
+ # strategy :one_for_all # own strategy/intensity/backoff
12
+ # child :cron, adapter: :command, cmd: "bin/rails cron"
13
+ # end
14
+ class DSL
15
+ def self.load_file(path)
16
+ dsl = new
17
+ dsl.instance_eval(File.read(path), path, 1)
18
+ dsl.build
19
+ end
20
+
21
+ def initialize(root: true)
22
+ @root = root
23
+ @strategy = :one_for_one
24
+ @intensity = { max_restarts: 5, within: 60 }
25
+ @backoff = { kind: :exponential, base: 1, cap: 30 }
26
+ @children = []
27
+ # DESIGN §5/§9 default; socket nil disables. Only the ROOT supervisor
28
+ # listens — subtrees never bind their own socket.
29
+ @socket_path = root ? "tmp/odoshi.sock" : nil
30
+ end
31
+
32
+ def strategy(kind) = @strategy = kind
33
+ def max_restarts(n, within:) = @intensity = { max_restarts: n, within: within }
34
+ def backoff(kind, **opts) = @backoff = { kind: kind, **opts }
35
+
36
+ def socket(path)
37
+ raise ConfigError, "socket can only be set on the root supervisor" unless @root
38
+ @socket_path = path
39
+ end
40
+
41
+ # DESIGN §3.1: `supervisor :background do ... end` creates a subtree — a
42
+ # :supervisor child whose block supports the full DSL (strategy /
43
+ # max_restarts / backoff / child, and further nesting). The block is kept
44
+ # as a builder so every (re)start constructs a FRESH child Supervisor:
45
+ # RestartIntensity is stateful, and a restarted subtree must start with a
46
+ # clean intensity window.
47
+ def supervisor(id, restart: :permanent, shutdown: 30, start_timeout: 30, &block)
48
+ raise ConfigError, "supervisor #{id.inspect} requires a block" unless block
49
+ builder = lambda do
50
+ sub = DSL.new(root: false)
51
+ sub.instance_eval(&block)
52
+ sub.build
53
+ end
54
+ builder.call # fail fast at config load, not at spawn time
55
+ # health_interval nil: subtree liveness arrives via link (thread death),
56
+ # and its internal health is the subtree supervisor's own business — no
57
+ # probe monitor needed in the parent.
58
+ @children << ChildSpec.new(id: id, adapter: :supervisor, restart: restart,
59
+ shutdown: shutdown, start_timeout: start_timeout,
60
+ health_interval: nil, opts: { builder: builder })
61
+ end
62
+
63
+ def child(id, adapter:, restart: :permanent, shutdown: 30, start_timeout: 30,
64
+ health_interval: 5, degraded_restart_after: nil, **opts)
65
+ @children << ChildSpec.new(id: id, adapter: adapter, restart: restart, shutdown: shutdown,
66
+ start_timeout: start_timeout, health_interval: health_interval,
67
+ degraded_restart_after: degraded_restart_after, opts: opts)
68
+ end
69
+
70
+ def build
71
+ sup = Supervisor.new(strategy: @strategy,
72
+ intensity: RestartIntensity.new(**@intensity),
73
+ backoff: Backoff.new(**@backoff),
74
+ socket_path: @socket_path)
75
+ @children.each { |c| sup.add_child(c) }
76
+ sup
77
+ end
78
+ end
79
+
80
+ def self.supervise(&block)
81
+ dsl = DSL.new
82
+ dsl.instance_eval(&block)
83
+ dsl.build
84
+ end
85
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+ require "socket"
3
+ require "json"
4
+
5
+ # Deliberately self-contained (PLAN 1.6): any child process can
6
+ # require "odoshi/heartbeat"
7
+ # without loading the rest of the gem — e.g. from a Rails initializer or a
8
+ # Solid Queue hook — and never Rails itself (hard rule 1 / DESIGN §9).
9
+ module Odoshi
10
+ # Sends DESIGN §5 NDJSON heartbeats to the supervising socket:
11
+ #
12
+ # beat = Odoshi::Heartbeat.start(id: "jobs") # => Heartbeat or nil
13
+ # beat&.stop
14
+ #
15
+ # Odoshi::Heartbeat.start(id: "jobs", interval: 2,
16
+ # state: -> { queue_backlog_ok? ? "healthy" : "degraded" },
17
+ # meta: -> { { backlog: backlog_size } })
18
+ #
19
+ # Silently a no-op (returns nil) when ODOSHI_SOCK / ODOSHI_TOKEN are
20
+ # absent — the child is running unsupervised and that must not be an error.
21
+ #
22
+ # The beating thread is unkillable by bad input (issue #26): a raising
23
+ # state/meta lambda falls back to the last good state / empty meta, an
24
+ # unencodable payload (bad UTF-8) drops down to a bare healthy beat, and
25
+ # socket failures close the socket (issue #29) and retry next beat. Going
26
+ # silent is the one thing this thread must never do while its process is
27
+ # healthy — silence is what gets the child restarted.
28
+ class Heartbeat
29
+ # Returns the Heartbeat (so #stop works — issue #23), or nil when
30
+ # unsupervised.
31
+ def self.start(id:, interval: 2, state: -> { "healthy" }, meta: -> { {} })
32
+ new(id: id, interval: interval, state: state, meta: meta).start
33
+ end
34
+
35
+ def initialize(id:, interval: 2, state: -> { "healthy" }, meta: -> { {} })
36
+ @id, @interval, @state, @meta = id.to_s, interval, state, meta
37
+ @sock_path, @token = ENV["ODOSHI_SOCK"], ENV["ODOSHI_TOKEN"]
38
+ @last_state = "healthy"
39
+ end
40
+
41
+ def start
42
+ return nil unless @sock_path && @token
43
+ @thread ||= Thread.new { run_loop }
44
+ self
45
+ end
46
+
47
+ def stop
48
+ @thread&.kill
49
+ close_socket
50
+ end
51
+
52
+ def alive? = !!@thread&.alive?
53
+
54
+ private
55
+
56
+ def run_loop
57
+ loop do
58
+ begin
59
+ payload = build_payload
60
+ @sock ||= UNIXSocket.new(@sock_path)
61
+ @sock.puts(payload)
62
+ rescue StandardError
63
+ close_socket # supervisor gone or restarting; reconnect next beat
64
+ end
65
+ sleep @interval
66
+ end
67
+ end
68
+
69
+ def build_payload
70
+ state = safe_state
71
+ meta = begin
72
+ @meta.call
73
+ rescue StandardError
74
+ {}
75
+ end
76
+ encode(state, meta) || encode(state, {}) || encode("healthy", {})
77
+ end
78
+
79
+ def safe_state
80
+ @last_state = @state.call.to_s
81
+ rescue StandardError
82
+ @last_state
83
+ end
84
+
85
+ def encode(state, meta)
86
+ JSON.generate(id: @id, state: state, ts: Time.now.to_i, token: @token, meta: meta)
87
+ rescue StandardError
88
+ nil
89
+ end
90
+
91
+ def close_socket
92
+ @sock&.close
93
+ @sock = nil
94
+ rescue IOError, SystemCallError
95
+ @sock = nil
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+ module Odoshi
3
+ # PLAN 1.1 orphan prevention: if the supervisor is SIGKILLed, children should
4
+ # still receive SIGTERM. Linux delivers this via prctl(PR_SET_PDEATHSIG),
5
+ # armed in the forked child between fork and exec. macOS/BSD have no
6
+ # equivalent: children there are re-parented to launchd/init and keep running
7
+ # until the platform supervisor reaps them (documented limitation, README).
8
+ module OrphanGuard
9
+ PR_SET_PDEATHSIG = 1
10
+
11
+ def self.available?
12
+ return @available if defined?(@available)
13
+ @available = RUBY_PLATFORM.include?("linux") && fiddle?
14
+ end
15
+
16
+ def self.fiddle?
17
+ require "fiddle"
18
+ true
19
+ rescue LoadError
20
+ false
21
+ end
22
+
23
+ # Runs in the forked child, pre-exec. parent_pid is the supervisor's pid at
24
+ # fork time: pdeathsig is not delivered if the parent died before prctl ran,
25
+ # so re-check the parent afterwards and exit rather than run orphaned.
26
+ def self.arm!(parent_pid, signal: "TERM")
27
+ return false unless available?
28
+ libc = Fiddle.dlopen(nil)
29
+ prctl = Fiddle::Function.new(libc["prctl"], [Fiddle::TYPE_INT] * 5, Fiddle::TYPE_INT)
30
+ armed = prctl.call(PR_SET_PDEATHSIG, Signal.list.fetch(signal), 0, 0, 0).zero?
31
+ Process.exit!(0) if Process.ppid != parent_pid
32
+ armed
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+ require "socket"
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Odoshi
7
+ # Passive probes (DESIGN §4.1/§5): a child declared with probe: opts is
8
+ # :starting until the probe answers, then :healthy. Probes are adapter
9
+ # plumbing behind health(), not part of the Adapter interface (hard rule 2).
10
+ #
11
+ # probe: { tcp: 5432 }
12
+ # probe: { http: "http://127.0.0.1:3000/up" }
13
+ module Probe
14
+ CONNECT_TIMEOUT = 0.25
15
+ READ_TIMEOUT = 0.5
16
+
17
+ # true when the probe answers (or the spec declares no probe).
18
+ def self.answering?(spec)
19
+ probe = spec.opts[:probe] or return true
20
+ if (port = probe[:tcp])
21
+ tcp?(port)
22
+ elsif (url = probe[:http])
23
+ http?(url)
24
+ else
25
+ raise ConfigError, "#{spec.id}: probe must be {tcp: PORT} or {http: URL}, got #{probe.inspect}"
26
+ end
27
+ end
28
+
29
+ def self.tcp?(port, host = "127.0.0.1")
30
+ Socket.tcp(host, port, connect_timeout: CONNECT_TIMEOUT) { true }
31
+ rescue SystemCallError, IO::TimeoutError
32
+ false
33
+ end
34
+
35
+ def self.http?(url)
36
+ uri = URI(url)
37
+ Net::HTTP.start(uri.host, uri.port, open_timeout: CONNECT_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
38
+ http.get(uri.path.empty? ? "/" : uri.path).code.to_i.between?(200, 299)
39
+ end
40
+ rescue SystemCallError, IO::TimeoutError, Net::OpenTimeout, Net::ReadTimeout, EOFError
41
+ false
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+ module Odoshi
3
+ # OTP restart intensity: more than `max_restarts` within `within` seconds => escalate.
4
+ class RestartIntensity
5
+ attr_reader :max_restarts, :within
6
+
7
+ def initialize(max_restarts: 5, within: 60, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
8
+ @max_restarts, @within, @clock = max_restarts, within, clock
9
+ @events = []
10
+ end
11
+
12
+ # Records a restart. Returns true if the supervisor should escalate (give up).
13
+ def record!
14
+ now = @clock.call
15
+ @events << now
16
+ @events.reject! { |t| now - t > @within }
17
+ @events.size > @max_restarts
18
+ end
19
+
20
+ def count = @events.size
21
+ end
22
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+ require "socket"
3
+ require "json"
4
+ require "securerandom"
5
+ require "fileutils"
6
+
7
+ module Odoshi
8
+ # DESIGN §5 active heartbeats + §9 control transport, PLAN 1.4.
9
+ # Newline-delimited JSON over a Unix socket, mode 0600, per-boot token —
10
+ # no MessagePack, no length prefixes, no versions (hard rule 4). This wire
11
+ # format is the contract the Elixir `beam` repo consumes.
12
+ #
13
+ # Heartbeat: {"id":"jobs","state":"healthy","ts":1757700000,"token":"…","meta":{}}
14
+ # Control: {"cmd":"restart","id":"jobs","token":"…"}
15
+ # Any line with a missing or wrong token is dropped without a reply.
16
+ class SocketServer
17
+ MAX_LINE = 64 * 1024 # §5: longer lines are malformed and dropped
18
+ MAX_CONNS = 64 # excess connections are refused (closed immediately)
19
+
20
+ attr_reader :path, :token
21
+
22
+ def initialize(path:, on_heartbeat:, on_control:)
23
+ @path, @on_heartbeat, @on_control = path, on_heartbeat, on_control
24
+ @token = SecureRandom.hex(16)
25
+ @conns = []
26
+ end
27
+
28
+ # Binds, chmods, and exports ODOSHI_SOCK / ODOSHI_TOKEN so children
29
+ # spawned afterwards inherit them (DESIGN §9). Call before starting children.
30
+ def start
31
+ begin
32
+ FileUtils.mkdir_p(File.dirname(@path))
33
+ File.unlink(@path) if File.exist?(@path) # stale socket from a dead boot
34
+ @server = UNIXServer.new(@path)
35
+ rescue ArgumentError, SystemCallError => e
36
+ # e.g. > ~104-byte path on macOS, unwritable dir: config problem, not a crash
37
+ raise ConfigError, "heartbeat socket #{@path.inspect}: #{e.message}"
38
+ end
39
+ @server.listen(128) # default backlog is 5 on macOS; bursts got ECONNREFUSED
40
+ File.chmod(0o600, @path)
41
+ ENV["ODOSHI_SOCK"] = @path
42
+ ENV["ODOSHI_TOKEN"] = @token
43
+ @acceptor = Thread.new do
44
+ loop do
45
+ conn = @server.accept
46
+ @conns.reject! { |c| !c[:thread].alive? }
47
+ if @conns.size >= MAX_CONNS
48
+ close_quietly(conn)
49
+ next
50
+ end
51
+ @conns << { conn: conn, thread: Thread.new { serve(conn) } }
52
+ rescue IOError, SystemCallError
53
+ break # server closed during shutdown
54
+ end
55
+ end
56
+ end
57
+
58
+ def stop
59
+ @server&.close
60
+ @acceptor&.kill
61
+ @conns.each do |c| # connection threads must not outlive the server
62
+ c[:thread].kill
63
+ close_quietly(c[:conn])
64
+ end
65
+ @conns.clear
66
+ File.unlink(@path) if File.exist?(@path)
67
+ rescue SystemCallError
68
+ nil
69
+ end
70
+
71
+ private
72
+
73
+ def serve(conn)
74
+ loop do
75
+ line = conn.gets("\n", MAX_LINE)
76
+ break if line.nil?
77
+ unless line.end_with?("\n")
78
+ # over-long line: memory stays capped at MAX_LINE — discard the rest
79
+ # of the line, then resume at the next newline
80
+ line = conn.gets("\n", MAX_LINE) while !line.nil? && !line.end_with?("\n")
81
+ break if line.nil?
82
+ next
83
+ end
84
+ handle_line(line)
85
+ end
86
+ rescue IOError, SystemCallError
87
+ nil
88
+ ensure
89
+ close_quietly(conn)
90
+ end
91
+
92
+ # §5: token, cmd, id, and state are JSON strings; anything else in those
93
+ # fields is malformed and silently dropped — same as a bad token. A bad
94
+ # line must never take the connection (or the supervisor) down.
95
+ def handle_line(line)
96
+ msg = begin
97
+ JSON.parse(line)
98
+ rescue StandardError
99
+ return
100
+ end
101
+ return unless msg.is_a?(Hash) && msg["token"] == @token
102
+ if msg.key?("cmd")
103
+ @on_control.call(msg) if msg["cmd"].is_a?(String)
104
+ elsif msg["id"].is_a?(String) && msg["state"].is_a?(String)
105
+ @on_heartbeat.call(msg)
106
+ end
107
+ end
108
+
109
+ def close_quietly(conn)
110
+ conn.close
111
+ rescue IOError, SystemCallError
112
+ nil
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+ module Odoshi
3
+ # DESIGN §3.3. Given the ordered child ids and the failed id,
4
+ # return the ordered ids that must be stopped and restarted.
5
+ module Strategy
6
+ KINDS = %i[one_for_one rest_for_one one_for_all].freeze
7
+
8
+ def self.affected(kind, ordered_ids, failed_id)
9
+ raise ConfigError, "unknown strategy #{kind}" unless KINDS.include?(kind)
10
+ idx = ordered_ids.index(failed_id) or raise ArgumentError, "#{failed_id} not in tree"
11
+ case kind
12
+ when :one_for_one then [failed_id]
13
+ when :rest_for_one then ordered_ids[idx..]
14
+ when :one_for_all then ordered_ids.dup
15
+ end
16
+ end
17
+ end
18
+ end