hotcell-server 0.0.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a38580413ef3b772a435be5c014e81e0b259fcf30d0f8644915185ab4b62959d
4
- data.tar.gz: dae5536d6035f67546136f12ffe1fc52d602bdffc8dd5eeba120fec2313c4cd7
3
+ metadata.gz: 50d90e8b9f7ecb2a139d9c678a3cfdb228288d590f33cdf19390b6cbd92a924e
4
+ data.tar.gz: 66c3bcb911c378507bc9623cb1badc8c3372b6a58c3cd0312eae81c72b003877
5
5
  SHA512:
6
- metadata.gz: cfd2cef68fba36bd8fb723e0c08468a91b1f99a5bd2b5e9cb657d8a267df5e7b438f922af66511439f92f6cf4335389566adf7a2b7e6add8d6dc25dd543c5c97
7
- data.tar.gz: 8cf8d948c812075b068479e25737a0d43999831f2dea87a018d6d62fe8c1049d96bb27076f88173ed91d4bd4ae1939669d768402ee9e8ce8e5c215c14014d909
6
+ metadata.gz: c489f02a8b5670abda9df8c52f62fc4657a725abf01c6e49a39ad1065671535f96942e9ed5168fed5062d9b1ce3f55cef5aba0c2dfab2ec2f1c9c35b14980490
7
+ data.tar.gz: 5b633520243d9f70ab9adfaa1f2427516012ca477e3acd4d5917cbfe5a02ca01dc830e6d6855faeda5e11e8d3b8978634cadfa39712938aeeaeb6560e9f8032c
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 37signals, LLC
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # hotcell-server
2
+
3
+ Part of [HotCell](https://github.com/basecamp/hotcell). See the repository README.
data/exe/hotcell ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "hot_cell/server"
5
+
6
+ HotCell.load!
7
+
8
+ HotCell::Supervisor
9
+ .new(directory: ENV.fetch("HOTCELL_DIR", "/run/hotcell/cell"), workspace: ENV["HOTCELL_WORKSPACE"])
10
+ .boot
11
+ .run
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Exits 0 when the local cell answers describe on its control socket, and 1 with a reason otherwise.
5
+ #
6
+ # Made for a Docker HEALTHCHECK, which execs inside the container — the one place a probe can reach a
7
+ # cell that has no network. The control socket answers even when the work socket is saturated, so
8
+ # healthy means the supervisor is alive and answering, not that a worker is free.
9
+
10
+ require "hot_cell/core"
11
+ require "socket"
12
+
13
+ def unhealthy(reason)
14
+ puts "unhealthy: #{reason}"
15
+ exit 1
16
+ end
17
+
18
+ directory = ENV.fetch("HOTCELL_DIR", "/run/hotcell/cell")
19
+ deadline = HotCell::Clock.now + Float(ENV.fetch("HOTCELL_HEALTH_TIMEOUT", "5"))
20
+
21
+ begin
22
+ socket = UNIXSocket.new(File.join(directory, "control.sock"))
23
+ connection = HotCell::Connection.new(socket)
24
+
25
+ connection.write_line HotCell::Request.new(op: HotCell::DESCRIBE).to_line
26
+
27
+ # nil is end of stream, which is a supervisor that accepted and then died. Passing it to the parser
28
+ # raised TypeError from inside JSON, so the probe reported unhealthy with a backtrace instead of a reason.
29
+ line = connection.read_line(deadline: deadline)
30
+ unhealthy "the cell closed the connection without answering" if line.nil?
31
+
32
+ response = HotCell::Response.parse(line)
33
+ unhealthy "the cell answered #{response.failure}" unless response.ok?
34
+ rescue SystemCallError, IOError, HotCell::Error => error
35
+ unhealthy "#{error.class}: #{error.message}"
36
+ end
37
+
38
+ puts "healthy"
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HotCell
4
+ # A cell is configured once, and everything about scheduling lives here rather than on an operation.
5
+ #
6
+ # These numbers are arithmetic against the container's own flags, not defaults to copy. Against a cell
7
+ # given two CPUs, 2GB, and a 512MB tmpfs: concurrency 4 because a request spends much of its life off
8
+ # the CPU, so twice `cpus` is where to start; file_size 64MB because it bounds what one worker writes
9
+ # onto that tmpfs, and four of them share it; and memory 1536MB because that is the measured working
10
+ # value for RLIMIT_DATA, which is per worker and mostly reservation rather than resident bytes.
11
+ #
12
+ # What a worker writes is its outputs plus a copy of any input an operation asked for by path. An
13
+ # operation that consumes the descriptor never pays for its input, which is what lets a small file_size
14
+ # accept a large upload — but the kernel does not distinguish the two writes, so one number covers both.
15
+ #
16
+ # memory does not multiply by concurrency, and that is the easiest mistake to make here. It is an
17
+ # address-space charge on one worker. The cgroup limit is what bounds real memory across the cell, and
18
+ # it counts the tmpfs too, so size the two separately.
19
+ class Configuration
20
+ SCHEDULING = {
21
+ concurrency: 4, # workers running at once, and therefore the number of slots
22
+ queue_size: 8, # connections that may be accepted and waiting for one
23
+ queue_wait: 10, # seconds a queued connection may wait before it is answered `capacity`
24
+ max_requests_per_worker: 1, # requests a worker serves before it is discarded
25
+ control_deadline: 5, # seconds a control connection may take to send its request
26
+ }.freeze
27
+
28
+ LIMITS = {
29
+ deadline: 60,
30
+ memory: 1536 * 1024**2,
31
+ file_size: 64 * 1024**2,
32
+ open_files: 256,
33
+ }.freeze
34
+
35
+ UNLIMITED = :unlimited
36
+
37
+ # Noticing an overdue worker, signalling it, reaping it, and writing the answer. Also the budget a
38
+ # retired worker gets to exit before the supervisor kills its group — see Supervisor#enforce_retirements.
39
+ KILL_GRACE = 1
40
+
41
+ attr_reader(*SCHEDULING.keys)
42
+ attr_reader :limits
43
+
44
+ def initialize(**options)
45
+ unknown = options.keys - SCHEDULING.keys - Limits::KEYS
46
+ raise ConfigurationError, "unknown setting #{unknown.join(", ")}" if unknown.any?
47
+
48
+ SCHEDULING.each { |key, default| instance_variable_set :"@#{key}", options.fetch(key, default) }
49
+
50
+ # The two second-valued settings, coerced for the same reason Limits coerces deadline: they appear
51
+ # in describe's JSON, and they may arrive as Active Support durations.
52
+ @queue_wait = @queue_wait.to_f
53
+ @control_deadline = @control_deadline.to_f
54
+
55
+ # A nil is not "use the default" here, it is a missing number. A cell whose deadline is nil accepts
56
+ # every request and then dies on the first arithmetic the supervisor does with it, so an explicit nil
57
+ # has to be refused where it is written rather than where it is used.
58
+ declared = options.slice(*Limits::KEYS)
59
+ if (empty = declared.select { |_, value| value.nil? }.keys).any?
60
+ raise ConfigurationError, "#{empty.join(", ")} cannot be nil; leave it out to take the default"
61
+ end
62
+
63
+ @limits = Limits.new(**LIMITS.merge(declared))
64
+
65
+ verify!
66
+ end
67
+
68
+ def unlimited_requests?
69
+ max_requests_per_worker == UNLIMITED
70
+ end
71
+
72
+ def retire?(served)
73
+ !unlimited_requests? && served >= max_requests_per_worker
74
+ end
75
+
76
+ # What this cell expects to answer within, and deliberately not a bound it can keep: a worker in
77
+ # uninterruptible sleep does not die when it is signalled, and the supervisor answers only after the
78
+ # reap. Treat it as the threshold a client's timeout should clear, not as a guarantee to build a
79
+ # correctness argument on.
80
+ #
81
+ # Derived here rather than reassembled in the client: a client adding up `queue_wait + deadline` plus its
82
+ # own guess at the kill-to-answer step cannot follow a change to any of them, and the error points the
83
+ # unsafe way — the client believes its timeout is generous and takes a transport failure instead of the
84
+ # cell's verdict. A stage added later that costs a caller time belongs in this sum.
85
+ def answer_within
86
+ queue_wait + limits.deadline + KILL_GRACE
87
+ end
88
+
89
+ # Goes on the wire as the answer to hotcell.describe, so every value has to be JSON-native.
90
+ # `max_requests_per_worker` is the one that is not: :unlimited is a Symbol, and a cell configured with it could
91
+ # not describe itself at all.
92
+ def to_h
93
+ SCHEDULING.keys.to_h { |key| [ key, public_send(key) ] }
94
+ .merge(limits.to_h)
95
+ .merge(answer_within: answer_within,
96
+ max_requests_per_worker: unlimited_requests? ? UNLIMITED.to_s : max_requests_per_worker)
97
+ end
98
+
99
+ private
100
+ # `integer:` is a real distinction rather than an oversight, so it is written as one: a count of workers
101
+ # or of queue places has to be whole, where a number of seconds does not.
102
+ def verify!
103
+ positive! :concurrency, integer: true
104
+ positive! :queue_wait
105
+ positive! :control_deadline
106
+
107
+ unless queue_size.is_a?(Integer) && !queue_size.negative?
108
+ raise ConfigurationError, "queue_size: #{queue_size} must not be negative"
109
+ end
110
+
111
+ unless unlimited_requests? || (max_requests_per_worker.is_a?(Integer) && max_requests_per_worker.positive?)
112
+ raise ConfigurationError, "max_requests_per_worker: #{max_requests_per_worker.inspect} must be " \
113
+ "a positive Integer or :unlimited"
114
+ end
115
+ end
116
+
117
+ def positive!(key, integer: false)
118
+ value = public_send(key)
119
+ return if value.positive? && (!integer || value.is_a?(Integer))
120
+
121
+ raise ConfigurationError, "#{key}: #{value} must be positive"
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HotCell
4
+ # The two things a cell answers that are not conversions, on control.sock rather than work.sock.
5
+ #
6
+ # Keeping control off the data path buys three things. It answers when the cell is saturated, where a
7
+ # health check or a metrics scrape sharing the work queue would fail under load and report the same thing
8
+ # as an outage. It gets its own allowance and its own much shorter deadline, which are not the same
9
+ # numbers — a scrape is milliseconds and a conversion is seconds. And the socket a connection arrived on
10
+ # is itself the discriminator, so routing costs nothing and cannot be confused by a payload.
11
+ #
12
+ # The supervisor answers these itself rather than forking a worker for them, which is a deliberate
13
+ # departure from the original design. Forking would work — a child inherits the counters and could read
14
+ # them without being told — but the whole value of this channel is being available when nothing else is,
15
+ # and a channel that needs a fork to answer is a channel that goes quiet exactly when a fork is what is
16
+ # failing. Neither operation takes a descriptor, touches a tool, or evaluates a byte of image data,
17
+ # so none of the reasons the supervisor stays out of a conversion apply. Reading a bounded control line
18
+ # from the trusted side starts no thread pool and cannot deadlock a later fork.
19
+ class Control
20
+ def initialize(configuration:, counters:)
21
+ @configuration = configuration
22
+ @counters = counters
23
+ end
24
+
25
+ def answer(line, running:, queued:)
26
+ request = Request.parse(line)
27
+
28
+ unless request.current_version?
29
+ return failed("protocol", request.version_mismatch)
30
+ end
31
+
32
+ case request.op
33
+ when DESCRIBE then Response.ok(result: describe)
34
+ when METRICS then Response.ok(result: @counters.to_h(running: running, queued: queued))
35
+ else failed "unsupported", "control.sock answers #{CONTROL_OPERATIONS.join(" and ")}, not #{request.op.inspect}"
36
+ end
37
+ rescue MessageError => error
38
+ failed "invalid", error.message
39
+ end
40
+
41
+ # Static, and called once per registered cell at app boot. It is the cheapest way to catch a client
42
+ # pointed at a cell that does not carry the operation it wants, which is otherwise an `unsupported` on
43
+ # the first real request, and to catch a client whose own timeout is below what this cell may take.
44
+ def describe
45
+ { v: PROTOCOL_VERSION, operations: Registry.names, groups: groups, **@configuration.to_h }
46
+ end
47
+
48
+ private
49
+ # What a caller's files must carry for this cell to open one by name. The number in an application's
50
+ # deploy file has to agree with the gid baked into this image, and nothing else compares them — so a
51
+ # cell whose gid moved is a boot warning in the client rather than an EACCES on every conversion.
52
+ #
53
+ # The primary gid is added because getgroups is not required to report it.
54
+ def groups
55
+ (Process.groups + [ Process.gid ]).uniq.sort
56
+ end
57
+
58
+ def failed(code, message)
59
+ Response.failed Failure.new(code: code, message: message)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HotCell
4
+ # These live in the supervisor, and a forked worker can still report them: a child inherits the
5
+ # supervisor's memory at fork, so the worker answering a metrics request reads a consistent snapshot of
6
+ # counters it never had to be told. This is the one place fork-per-request is a convenience rather than
7
+ # a cost.
8
+ #
9
+ # Three of these are not derivable on the client side, which is why the control channel exists at all.
10
+ # queue_high_water is the leading saturation signal and no single caller sees it. killed_by separates a
11
+ # decompression bomb from a slow afternoon in aggregate. cancelled counts callers that gave up before
12
+ # the cell answered, which by definition appears on no response.
13
+ #
14
+ # cancelled is a floor rather than a total, and the reason is where each answer is written from. The
15
+ # supervisor writes refusals, kills and deadline breaches, so it sees the broken pipe and counts it. A
16
+ # successful conversion is written by the worker, which exits without telling anyone — so a caller that
17
+ # hangs up during one is not counted. Read it as "at least this many", and read a rise as real.
18
+ class Counters
19
+ def initialize
20
+ @started_at = Clock.now
21
+ @requests = Hash.new(0)
22
+ @killed_by = Hash.new(0)
23
+ @cancelled = 0
24
+ @queue_high_water = 0
25
+ end
26
+
27
+ def record(code)
28
+ @requests[:total] += 1
29
+ @requests[(code || :ok).to_sym] += 1
30
+ end
31
+
32
+ def record_kill(cause)
33
+ @killed_by[cause.to_sym] += 1
34
+ end
35
+
36
+ def cancelled!
37
+ @cancelled += 1
38
+ end
39
+
40
+ def observe_queue(depth)
41
+ @queue_high_water = depth if depth > @queue_high_water
42
+ end
43
+
44
+ def to_h(running: 0, queued: 0)
45
+ { uptime_s: (Clock.now - @started_at).round, running: running, queued: queued,
46
+ queue_high_water: @queue_high_water, requests: @requests, killed_by: @killed_by,
47
+ cancelled: @cancelled }
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HotCell
4
+ # What a worker may consume, and what a cell will let an operation ask for. `deadline` is seconds;
5
+ # `memory` and `file_size` are bytes; `open_files` is a count. Active Support's helpers work —
6
+ # `1280.megabytes` is a plain Integer already, and a `30.seconds` duration flattens to one on arrival.
7
+ #
8
+ # There is deliberately no RLIMIT_CPU. The deadline strictly covers it — anything that burns CPU also
9
+ # burns wall clock, and the deadline additionally catches a worker blocked on a wedged subprocess,
10
+ # which trips no CPU limit because a stuck worker consumes no CPU at all. The two numbers are related
11
+ # by a factor nobody can predict, measuring 1.0x at libvips concurrency 1 and about 1.5x at 4, so a CPU
12
+ # limit cannot be derived from a latency budget. And RLIMIT_CPU is cumulative over a process's life, so
13
+ # it stops meaning "per request" the moment a worker serves a second one.
14
+ #
15
+ # What is given up is real: RLIMIT_CPU was kernel-enforced and would still fire if the supervisor's
16
+ # timer logic were wrong. That is the reason to keep the supervisor's loop boring.
17
+ class Limits
18
+ # Below this a worker does not fail gracefully, it dies before it can answer, as SIGABRT or ENOMEM
19
+ # during boot. Roughly 450MB of any RLIMIT_DATA is Ruby's own: since 3.3 the interpreter reserves a
20
+ # single ~404MB writable anonymous region at boot that it never touches, and RLIMIT_DATA charges all
21
+ # of it. So this is not "how much a bomb may consume" — subtract 450MB before reading it that way.
22
+ MEMORY_FLOOR = 1024 * 1024**2
23
+
24
+ # RLIMIT_DATA rather than RLIMIT_AS. RLIMIT_DATA charges private writable anonymous mappings and
25
+ # ignores PROT_NONE reservations, read-only file mappings, and MAP_SHARED of any kind. Against a real
26
+ # variant whose peak RSS is 45MB, RLIMIT_DATA works from 704MB where RLIMIT_AS needs 1536MB, and
27
+ # RLIMIT_AS fails nondeterministically for a 400MB band below its floor. Do not add RLIMIT_AS as a
28
+ # backstop: any value clearing that band already exceeds the container's own memory limit.
29
+ RESOURCES = {
30
+ memory: Process::RLIMIT_DATA,
31
+ file_size: Process::RLIMIT_FSIZE,
32
+ open_files: Process::RLIMIT_NOFILE,
33
+ }.freeze
34
+
35
+ KEYS = [ :deadline, *RESOURCES.keys ].freeze
36
+
37
+ attr_reader(*KEYS)
38
+
39
+ # to_f and to_i, because these travel as JSON and 30.seconds is an ActiveSupport::Duration until it
40
+ # is asked to be a number.
41
+ def initialize(deadline: nil, memory: nil, file_size: nil, open_files: nil)
42
+ @deadline = deadline&.to_f
43
+ @memory = memory&.to_i
44
+ @file_size = file_size&.to_i
45
+ @open_files = open_files&.to_i
46
+
47
+ verify_positive!
48
+ verify_memory_floor!
49
+ end
50
+
51
+ def [](key)
52
+ public_send key
53
+ end
54
+
55
+ def to_h
56
+ KEYS.to_h { |key| [ key, self[key] ] }
57
+ end
58
+
59
+ def declared
60
+ to_h.compact
61
+ end
62
+
63
+ # A new Limits with these values over this one's — what a redeclaration means. A key that is not
64
+ # named keeps its value; one that is named to nil is withdrawn, so a redeclaration can also hand a
65
+ # limit back to the cell.
66
+ def merge(**values)
67
+ self.class.new(**to_h.merge(values))
68
+ end
69
+
70
+ # An operation cannot exceed its cell's limits, whatever it declares. This is invariant 6, and a
71
+ # clamp that silently stops clamping looks exactly like a clamp, which is why it is tested.
72
+ def clamped_to(ceiling)
73
+ self.class.new(**KEYS.to_h { |key| [ key, smaller(self[key], ceiling[key]) ] })
74
+ end
75
+
76
+ # The soft limit narrows to the operation and the hard limit stays at the cell's ceiling. An
77
+ # unprivileged process can raise a soft limit up to its hard limit but can never raise a hard one, so
78
+ # this is what lets a reused worker widen back for an operation with a different budget. Setting both
79
+ # to the operation's value would make the first request the tightest the worker could ever be.
80
+ def apply(ceiling: self)
81
+ Process.setrlimit Process::RLIMIT_CORE, 0
82
+
83
+ RESOURCES.each do |key, resource|
84
+ soft = self[key]
85
+ next if soft.nil?
86
+ next if key == :memory && !self.class.memory_enforceable?
87
+
88
+ begin
89
+ Process.setrlimit resource, soft, ceiling[key] || soft
90
+ rescue Errno::EINVAL
91
+ raise unless key == :memory
92
+
93
+ self.class.memory_unenforceable!
94
+ end
95
+ end
96
+ end
97
+
98
+ # macOS/XNU has no finite RLIMIT_DATA and returns EINVAL for any value, so the memory clamp cannot be set
99
+ # there. Rather than crash every worker, warn once and run unclamped — the memory limit is a Linux property
100
+ # and production is Linux. The suite skips the enforcement assertions off Linux.
101
+ @memory_enforceable = true
102
+
103
+ class << self
104
+ def memory_enforceable?
105
+ @memory_enforceable
106
+ end
107
+
108
+ def memory_unenforceable!
109
+ return unless @memory_enforceable
110
+
111
+ @memory_enforceable = false
112
+ warn "hotcell: RLIMIT_DATA is not settable on #{RUBY_PLATFORM}; the cell's memory limit is not enforced"
113
+ end
114
+ end
115
+
116
+ private
117
+ def smaller(mine, theirs)
118
+ return theirs if mine.nil?
119
+ return mine if theirs.nil?
120
+
121
+ [ mine, theirs ].min
122
+ end
123
+
124
+ def verify_positive!
125
+ KEYS.each do |key|
126
+ value = self[key]
127
+ next if value.nil? || value.positive?
128
+
129
+ raise ConfigurationError, "#{key}: #{value} must be positive"
130
+ end
131
+ end
132
+
133
+ def verify_memory_floor!
134
+ return if memory.nil? || memory >= MEMORY_FLOOR
135
+
136
+ raise ConfigurationError,
137
+ "memory: #{memory} is below the #{MEMORY_FLOOR} byte floor. About 450MB of RLIMIT_DATA is " \
138
+ "Ruby's own untouched reservation, so a worker under the floor dies during boot rather " \
139
+ "than failing gracefully."
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module HotCell
7
+ # A cell has no network, so nothing inside it can dial a collector. That rules out less than it appears
8
+ # to, because stdout is not a network: it is a pipe to the container runtime, whose log driver runs in
9
+ # that daemon with the host's network. So logs ship normally under network: none, and a cell writes
10
+ # structured JSON lines with the standard library and gets a real log for free.
11
+ #
12
+ # This is the channel for everything no response can carry: deadline kills, reaps that found a signal,
13
+ # boot checks, and queue high-water.
14
+ #
15
+ # Lines follow docs/LOGS.md: ECS field names, domain fields under the hotcell namespace. The fleet's
16
+ # collector routes on service.name, takes the record timestamp from @timestamp, and severity from
17
+ # log.level, so those three are load-bearing: renaming any of them silently drops or mislabels every
18
+ # cell log line in production.
19
+ class Log
20
+ # Severity lives here rather than in the collector so that adding an event never needs a collector
21
+ # change. An event missing from this table logs as INFO rather than not at all.
22
+ LEVELS = {
23
+ "cell.boot" => "INFO",
24
+ "cell.stopping" => "INFO",
25
+ "cell.stopped" => "INFO",
26
+ "cell.ptrace_scope_unknown" => "ERROR",
27
+ "request" => "INFO",
28
+ "request.abandoned" => "WARN",
29
+ "worker.forked" => "INFO",
30
+ "worker.reaped" => "INFO",
31
+ "worker.crashed" => "ERROR",
32
+ "worker.killed" => "WARN",
33
+ "worker.deadline" => "WARN",
34
+ "worker.lingered" => "WARN",
35
+ "worker.unforkable" => "ERROR",
36
+ "worker.undispatchable" => "ERROR",
37
+ "worker.unreadable_report" => "ERROR",
38
+ "control.abandoned" => "WARN",
39
+ "control.unanswerable" => "WARN",
40
+ "slot.uncleaned" => "WARN",
41
+ "slot.undiscarded" => "WARN",
42
+ }.freeze
43
+
44
+ def self.null
45
+ new File.open(File::NULL, "w")
46
+ end
47
+
48
+ def initialize(io = $stdout)
49
+ @io = io
50
+ @io.sync = true
51
+ end
52
+
53
+ # A log line is never worth the cell. This runs inside the loop that enforces every request's deadline,
54
+ # and the sink is a pipe to a runtime that can go away or stop draining. Losing the line is the correct
55
+ # trade against losing the process.
56
+ def write(event, **fields)
57
+ emit line(event, fields)
58
+ rescue JSON::GeneratorError
59
+ emit line(event, unloggable: fields.keys)
60
+ end
61
+
62
+ private
63
+ # Never blocks. A closed reader is EPIPE and a full disk behind the driver is ENOSPC, both rescued —
64
+ # but a reader that is alive and not draining is neither: a blocking write would simply park the
65
+ # deadline loop until the runtime resumed. A non-blocking write answers `:wait_writable` for the full
66
+ # pipe instead, and the line is dropped like any other.
67
+ #
68
+ # A short count would be a torn line, which a write at or under PIPE_BUF cannot produce. The one line
69
+ # that can exceed it is cell.boot's inventory, written once before the loop enforces anything and
70
+ # against an empty pipe.
71
+ def emit(line)
72
+ @io.write_nonblock line, exception: false
73
+ rescue SystemCallError, IOError
74
+ nil
75
+ end
76
+
77
+ def line(event, fields)
78
+ JSON.generate(document(event, fields.dup)) << "\n"
79
+ end
80
+
81
+ def document(event, fields)
82
+ {
83
+ "@timestamp": Time.now.utc.iso8601(3),
84
+ service: { name: "hotcell" },
85
+ event: event_fields(event, fields),
86
+ log: { level: LEVELS.fetch(event, "INFO") },
87
+ **process_fields(fields),
88
+ **prose_fields(fields),
89
+ **({ hotcell: fields } unless fields.empty?).to_h,
90
+ }
91
+ end
92
+
93
+ def event_fields(event, fields)
94
+ { action: event }.tap do |result|
95
+ result[:outcome] = fields.delete(:outcome) if fields.key?(:outcome)
96
+ result[:duration] = { ms: fields.delete(:duration_ms) } if fields.key?(:duration_ms)
97
+ end
98
+ end
99
+
100
+ def process_fields(fields)
101
+ process = {
102
+ pid: fields.delete(:pid),
103
+ exit_code: fields.delete(:exit_code),
104
+ }.compact
105
+
106
+ process.empty? ? {} : { process: process }
107
+ end
108
+
109
+ # `message` beside an exception is that exception's message; alone it is the line's prose.
110
+ def prose_fields(fields)
111
+ if fields.key?(:error)
112
+ { error: { type: fields.delete(:error), message: fields.delete(:message) }.compact }
113
+ elsif fields.key?(:message)
114
+ { message: fields.delete(:message) }
115
+ else
116
+ {}
117
+ end
118
+ end
119
+ end
120
+ end