hotcell-server 0.0.0 → 0.1.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 +4 -4
- data/MIT-LICENSE +20 -0
- data/README.md +3 -0
- data/exe/hotcell +11 -0
- data/exe/hotcell-health +38 -0
- data/lib/hot_cell/configuration.rb +124 -0
- data/lib/hot_cell/control.rb +62 -0
- data/lib/hot_cell/counters.rb +50 -0
- data/lib/hot_cell/limits.rb +142 -0
- data/lib/hot_cell/log.rb +120 -0
- data/lib/hot_cell/operation.rb +205 -0
- data/lib/hot_cell/registry.rb +51 -0
- data/lib/hot_cell/server/errors.rb +16 -0
- data/lib/hot_cell/server/version.rb +7 -0
- data/lib/hot_cell/server.rb +45 -0
- data/lib/hot_cell/slot.rb +106 -0
- data/lib/hot_cell/supervisor.rb +884 -0
- data/lib/hot_cell/test_cell.rb +156 -0
- data/lib/hot_cell/test_operations.rb +425 -0
- data/lib/hot_cell/timing.rb +47 -0
- data/lib/hot_cell/worker.rb +268 -0
- data/lib/hotcell-server.rb +6 -0
- metadata +59 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: db6e168c58fc1e84ea4411d8935574649f5464059f1e785d43d2388440bcef21
|
|
4
|
+
data.tar.gz: 9c2e43635ad2233707d59aa48d76f4b20823ae64bf67d7fc651f8e6d8178a88b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 81c839e7cd18af7add06ba34c0e3953787734eaf7906f9c0c6eaf7144e039d4d5dc7caef119fb9d98c006a7c09ce93b99576fa615cddff65c1868bb102a1567a
|
|
7
|
+
data.tar.gz: 0744a62b685fd7e96b39f9169869813e7ccc11e9331052742cc3a017be1464090aad6ee6cc0a36de4ec52e0e2802ced1d04215230a2d186e23c6ad2cfd5d41d1
|
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
data/exe/hotcell
ADDED
data/exe/hotcell-health
ADDED
|
@@ -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
|
data/lib/hot_cell/log.rb
ADDED
|
@@ -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
|