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
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
|
data/lib/odoshi/probe.rb
ADDED
|
@@ -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
|