hotcell-server 0.4.1 → 0.6.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/lib/hot_cell/configuration.rb +3 -0
- data/lib/hot_cell/control.rb +5 -1
- data/lib/hot_cell/filesystem.rb +20 -2
- data/lib/hot_cell/health_operations.rb +42 -0
- data/lib/hot_cell/log.rb +7 -0
- data/lib/hot_cell/server/version.rb +1 -1
- data/lib/hot_cell/server.rb +1 -0
- data/lib/hot_cell/slot.rb +16 -2
- data/lib/hot_cell/supervisor.rb +162 -28
- data/lib/hot_cell/sweeper.rb +65 -0
- data/lib/hot_cell/test_cell.rb +3 -0
- data/lib/hot_cell/test_operations.rb +10 -0
- metadata +8 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 14fae075c3d4fef73a330453eff2c295ac7e67895d50e2b9ad73d800b50ff2f8
|
|
4
|
+
data.tar.gz: d3730524324b2283332bdf2f04c90923a6dc3b0ed4a8b6821b10dc46a7e1fd3a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 063e11ec9aac319a707f3ca11d2d92b02d4aeeeffc35fdf2ec7e169ab8e14b82f4949b0d6f03255a4285186d43620ccb102584feaa616c02677ce6cefbee39b4
|
|
7
|
+
data.tar.gz: f7ca005517c6bc32c6e3b803b3c577b4e5e103bcc34b603e64534526430189b8f5b39cc87ee3235832ec4d2c078088091251fa1c66e409111ef20020d6d3e5ff
|
|
@@ -23,6 +23,7 @@ module HotCell
|
|
|
23
23
|
queue_wait: 10, # seconds a queued connection may wait before it is answered `capacity`
|
|
24
24
|
max_requests_per_worker: 1, # requests a worker serves before it is discarded
|
|
25
25
|
control_deadline: 5, # seconds a control connection may take to send its request
|
|
26
|
+
sweep_interval: 10, # seconds between the supervisor's checks for a killed request's tree to unlink
|
|
26
27
|
}.freeze
|
|
27
28
|
|
|
28
29
|
LIMITS = {
|
|
@@ -51,6 +52,7 @@ module HotCell
|
|
|
51
52
|
# in describe's JSON, and they may arrive as Active Support durations.
|
|
52
53
|
@queue_wait = @queue_wait.to_f
|
|
53
54
|
@control_deadline = @control_deadline.to_f
|
|
55
|
+
@sweep_interval = @sweep_interval.to_f
|
|
54
56
|
|
|
55
57
|
# A nil is not "use the default" here, it is a missing number. A cell whose deadline is nil accepts
|
|
56
58
|
# every request and then dies on the first arithmetic the supervisor does with it, so an explicit nil
|
|
@@ -103,6 +105,7 @@ module HotCell
|
|
|
103
105
|
positive! :concurrency, integer: true
|
|
104
106
|
positive! :queue_wait
|
|
105
107
|
positive! :control_deadline
|
|
108
|
+
positive! :sweep_interval
|
|
106
109
|
|
|
107
110
|
unless queue_size.is_a?(Integer) && !queue_size.negative?
|
|
108
111
|
raise ConfigurationError, "queue_size: #{queue_size} must not be negative"
|
data/lib/hot_cell/control.rb
CHANGED
|
@@ -40,8 +40,12 @@ module HotCell
|
|
|
40
40
|
|
|
41
41
|
# Static, and called once per registered cell at app boot. It is the cheapest way to catch a client
|
|
42
42
|
# whose own timeout is below what this cell may take, and it is what `bin/hotcell describe` reads.
|
|
43
|
+
#
|
|
44
|
+
# `v` is the wire format and changes only when the format does; `server_version` says which release of
|
|
45
|
+
# hotcell-server is answering.
|
|
43
46
|
def describe
|
|
44
|
-
{ v: PROTOCOL_VERSION, operations: Registry.names, groups: groups,
|
|
47
|
+
{ v: PROTOCOL_VERSION, server_version: Server::VERSION, operations: Registry.names, groups: groups,
|
|
48
|
+
**@configuration.to_h }
|
|
45
49
|
end
|
|
46
50
|
|
|
47
51
|
private
|
data/lib/hot_cell/filesystem.rb
CHANGED
|
@@ -18,8 +18,12 @@ module HotCell
|
|
|
18
18
|
#
|
|
19
19
|
# `Dir.exist?` is not the guard, because it follows symlinks and answers false for a dangling one, and
|
|
20
20
|
# an entry a tool left in a directory's place is exactly what this has to remove.
|
|
21
|
+
#
|
|
22
|
+
# A tree that is gone by the time the removal fails is the outcome this wants, however it went. Two
|
|
23
|
+
# sweepers can meet on one discarded tree — the worker that answered on the slot and the supervisor's
|
|
24
|
+
# own — and the loser's walk fails on an entry the winner unlinked first.
|
|
21
25
|
def self.remove_tree(path)
|
|
22
|
-
return true
|
|
26
|
+
return true if gone?(path)
|
|
23
27
|
|
|
24
28
|
FileUtils.remove_entry path
|
|
25
29
|
true
|
|
@@ -27,12 +31,26 @@ module HotCell
|
|
|
27
31
|
repair_and_remove path
|
|
28
32
|
end
|
|
29
33
|
|
|
34
|
+
# `lstat` rather than `File.exist?`, which answers false for a path it cannot stat as well as for one that
|
|
35
|
+
# is gone. Only ENOENT means gone; a tree behind a directory a tool made unsearchable is still there.
|
|
36
|
+
def self.gone?(path)
|
|
37
|
+
File.lstat path
|
|
38
|
+
false
|
|
39
|
+
rescue Errno::ENOENT
|
|
40
|
+
true
|
|
41
|
+
rescue SystemCallError
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
private_class_method :gone?
|
|
45
|
+
|
|
30
46
|
def self.repair_and_remove(path)
|
|
47
|
+
return true if gone?(path)
|
|
48
|
+
|
|
31
49
|
FileUtils.chmod_R 0o700, path, force: true
|
|
32
50
|
FileUtils.remove_entry path
|
|
33
51
|
true
|
|
34
52
|
rescue SystemCallError
|
|
35
|
-
|
|
53
|
+
gone?(path)
|
|
36
54
|
end
|
|
37
55
|
private_class_method :repair_and_remove
|
|
38
56
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hot_cell/server"
|
|
4
|
+
|
|
5
|
+
# The two round trips an application runs against its own cell for a health check. Shipped in the
|
|
6
|
+
# gem because every deployment was copying them from examples/, and copies drift.
|
|
7
|
+
#
|
|
8
|
+
# A cell serves them only if it requires this file.
|
|
9
|
+
module HotCell
|
|
10
|
+
module Health
|
|
11
|
+
# The message goes in through the caller's input descriptor and out through the caller's output
|
|
12
|
+
# descriptor, with no copy onto scratch, so one round trip proves descriptor passing end to end.
|
|
13
|
+
class Echo < HotCell::Operation
|
|
14
|
+
operation "health.echo"
|
|
15
|
+
|
|
16
|
+
def perform(inputs, outputs)
|
|
17
|
+
bytes = outputs.first.to_io.write(inputs.first.to_io.read)
|
|
18
|
+
|
|
19
|
+
{ bytes: bytes, staged: inputs.first.staged? }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# `fd_path` is a fresh open by name — `/dev/fd/N` on Linux, the file's own path on macOS — checked
|
|
24
|
+
# against this process's uid and the file's mode, so this succeeds only when the cell can open the
|
|
25
|
+
# caller's files by name — which echo never checks.
|
|
26
|
+
# It reads the input and writes the output, because a tool may need either permission.
|
|
27
|
+
class Reopen < HotCell::Operation
|
|
28
|
+
operation "health.reopen"
|
|
29
|
+
|
|
30
|
+
def perform(inputs, outputs)
|
|
31
|
+
source, = inputs
|
|
32
|
+
destination, = outputs
|
|
33
|
+
|
|
34
|
+
bytes = File.open(source.fd_path, "rb") do |input|
|
|
35
|
+
File.open(destination.fd_path, "wb") { |output| output.write(input.read) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
{ bytes: bytes, staged: source.staged? || destination.staged? }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/hot_cell/log.rb
CHANGED
|
@@ -35,11 +35,18 @@ module HotCell
|
|
|
35
35
|
"worker.unforkable" => "ERROR",
|
|
36
36
|
"worker.undispatchable" => "ERROR",
|
|
37
37
|
"worker.unreadable_report" => "ERROR",
|
|
38
|
+
"sweeper.forked" => "INFO",
|
|
39
|
+
"sweeper.deadline" => "WARN",
|
|
40
|
+
"sweeper.unforkable" => "ERROR",
|
|
41
|
+
"sweeper.crashed" => "ERROR",
|
|
42
|
+
"scratch.swept" => "INFO",
|
|
38
43
|
"control.abandoned" => "WARN",
|
|
39
44
|
"control.unanswerable" => "WARN",
|
|
40
45
|
"slot.uncleaned" => "WARN",
|
|
41
46
|
"scratch.unswept" => "WARN",
|
|
42
47
|
"slot.undiscarded" => "WARN",
|
|
48
|
+
"slot.unswept" => "WARN",
|
|
49
|
+
"sweeper.died" => "WARN",
|
|
43
50
|
}.freeze
|
|
44
51
|
|
|
45
52
|
def self.null
|
data/lib/hot_cell/server.rb
CHANGED
data/lib/hot_cell/slot.rb
CHANGED
|
@@ -95,7 +95,8 @@ module HotCell
|
|
|
95
95
|
#
|
|
96
96
|
# A rename within one filesystem is O(1) and takes the tree out of the way. A worker sweeps it later,
|
|
97
97
|
# after it has answered and before it reports itself idle — see Worker#serve, which is the one window
|
|
98
|
-
# where the unlinking costs nobody's latency.
|
|
98
|
+
# where the unlinking costs nobody's latency. A worker killed at its deadline never reaches that window,
|
|
99
|
+
# so the supervisor also forks a Sweeper on a timer, which unlinks in a process of its own.
|
|
99
100
|
#
|
|
100
101
|
# The destination carries a random suffix rather than a counter, because the tool that filled the
|
|
101
102
|
# directory runs as this user and can write to the slot's directory. A predictable name lets it
|
|
@@ -133,12 +134,25 @@ module HotCell
|
|
|
133
134
|
# Unlinks whatever discard_home renamed out of the way. Partial progress is fine: a sweep killed
|
|
134
135
|
# part-way leaves fewer entries for the next one, so this converges rather than repeating.
|
|
135
136
|
def sweep
|
|
136
|
-
|
|
137
|
+
discarded.map { |path| Filesystem.remove_tree(path) }.all?
|
|
137
138
|
rescue SystemCallError
|
|
138
139
|
# The glob itself can fail, because the slot directory is a name a tool can replace — a symlink loop
|
|
139
140
|
# in its place answers ELOOP here rather than for any one entry. This runs from the worker's ensure,
|
|
140
141
|
# where a raise would replace the caller's response with a crash.
|
|
141
142
|
false
|
|
142
143
|
end
|
|
144
|
+
|
|
145
|
+
# What discard_home has renamed aside and nobody has unlinked yet.
|
|
146
|
+
def discarded
|
|
147
|
+
Dir.glob(File.join(directory, "discarded-*"))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Streams the directory and stops at the first match, because the supervisor asks this in its loop and
|
|
151
|
+
# a tool can put as many entries beside the discarded ones as it likes; a glob would list and sort them all.
|
|
152
|
+
def discarded?
|
|
153
|
+
Dir.each_child(directory).any? { |name| name.start_with?("discarded-") }
|
|
154
|
+
rescue Errno::ENOENT
|
|
155
|
+
false
|
|
156
|
+
end
|
|
143
157
|
end
|
|
144
158
|
end
|
data/lib/hot_cell/supervisor.rb
CHANGED
|
@@ -22,11 +22,27 @@ module HotCell
|
|
|
22
22
|
# the accept anyway, for the queue, for queued_ms, and to answer `capacity`. It also means the supervisor
|
|
23
23
|
# knows when every worker started its current request, which is what the deadline needs.
|
|
24
24
|
class Supervisor
|
|
25
|
+
# The one deadline the supervisor enforces, on a worker and on the sweeper alike: from `started_at`,
|
|
26
|
+
# for `deadline` seconds, killed once. `killed_for` is the one-kill latch — a killed process stays here
|
|
27
|
+
# until the reap, and without the latch it would be re-killed and re-logged on every pass until then.
|
|
28
|
+
# See `Child#overdue?` for the measurement behind that.
|
|
29
|
+
module Timed
|
|
30
|
+
def overdue?(now)
|
|
31
|
+
timed? && now - started_at >= deadline
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def expires_at
|
|
35
|
+
started_at + deadline if timed?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
25
39
|
# Owns "is this worker busy" and the two transitions that change the answer, because the supervisor asking
|
|
26
40
|
# `busy?` and the supervisor assigning the four fields `busy?` is computed from are the same fact. Spread
|
|
27
41
|
# across the caller, a new field is one the next transition forgets to clear.
|
|
28
42
|
Child = Struct.new(:slot, :pid, :control, :connection, :dispatched_at, :deadline, :served, :killed_for,
|
|
29
43
|
:op, :retired_at, :buffer, :stderr, :captured, keyword_init: true) do
|
|
44
|
+
include Timed
|
|
45
|
+
|
|
30
46
|
def self.build(slot:, pid:, control:, deadline:, stderr: nil)
|
|
31
47
|
new slot: slot, pid: pid, control: control, deadline: deadline, served: 0, buffer: "".b,
|
|
32
48
|
stderr: stderr, captured: "".b
|
|
@@ -87,12 +103,12 @@ module HotCell
|
|
|
87
103
|
# SIGKILLs and 72 synchronous stdout writes for one breach. The window is longest exactly when the host
|
|
88
104
|
# is already struggling — a worker in uninterruptible sleep, or one tearing down gigabytes of mappings —
|
|
89
105
|
# and the loop it starves is the one enforcing every other request's deadline.
|
|
90
|
-
def
|
|
91
|
-
busy? && killed_for.nil?
|
|
106
|
+
def timed?
|
|
107
|
+
busy? && killed_for.nil?
|
|
92
108
|
end
|
|
93
109
|
|
|
94
|
-
def
|
|
95
|
-
dispatched_at
|
|
110
|
+
def started_at
|
|
111
|
+
dispatched_at
|
|
96
112
|
end
|
|
97
113
|
|
|
98
114
|
# The retirement analogue of `overdue?`, and the only timer that can reach a worker whose idle
|
|
@@ -111,6 +127,18 @@ module HotCell
|
|
|
111
127
|
end
|
|
112
128
|
end
|
|
113
129
|
|
|
130
|
+
# The one sweeper the supervisor runs at a time. Not a `Child`: it is dispatched no request, holds no
|
|
131
|
+
# slot and answers nobody, so none of `busy?`, retirement or the idle report applies to it. What it
|
|
132
|
+
# shares with a worker is the deadline, and that is `Timed` — the same measurement, the same latch, the
|
|
133
|
+
# same `deadline` value from the configuration, and the same kill.
|
|
134
|
+
Sweep = Struct.new(:pid, :started_at, :deadline, :killed_for, keyword_init: true) do
|
|
135
|
+
include Timed
|
|
136
|
+
|
|
137
|
+
def timed?
|
|
138
|
+
killed_for.nil?
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
114
142
|
# A path longer than this fails to bind with an error that does not say so. Darwin allows four fewer
|
|
115
143
|
# bytes than Linux, and control.sock is the longer of the two names, so it overflows first.
|
|
116
144
|
SUN_PATH_MAX = RUBY_PLATFORM.include?("darwin") ? 104 : 108
|
|
@@ -151,6 +179,8 @@ module HotCell
|
|
|
151
179
|
@control_pending = []
|
|
152
180
|
@counters = Counters.new
|
|
153
181
|
@stopping = false
|
|
182
|
+
@sweep = nil
|
|
183
|
+
@next_sweep_at = Clock.now + configuration.sweep_interval
|
|
154
184
|
end
|
|
155
185
|
|
|
156
186
|
def boot
|
|
@@ -161,6 +191,7 @@ module HotCell
|
|
|
161
191
|
verify_scratches!
|
|
162
192
|
prepare_directories
|
|
163
193
|
preload
|
|
194
|
+
warm_up
|
|
164
195
|
@work = listen "work.sock"
|
|
165
196
|
@control = listen "control.sock"
|
|
166
197
|
@control_handler = Control.new(configuration: configuration, counters: counters)
|
|
@@ -172,16 +203,20 @@ module HotCell
|
|
|
172
203
|
end
|
|
173
204
|
|
|
174
205
|
def run
|
|
206
|
+
prefork
|
|
207
|
+
|
|
175
208
|
until stopped?
|
|
176
209
|
readable, = IO.select(sources, nil, nil, wait_for)
|
|
177
210
|
Array(readable).each { |source| handle source }
|
|
178
211
|
|
|
179
212
|
enforce_deadlines
|
|
180
213
|
enforce_retirements
|
|
214
|
+
enforce_sweep_deadline
|
|
181
215
|
expire_queue
|
|
182
216
|
expire_control
|
|
183
217
|
retire_idle if @stopping
|
|
184
218
|
pump
|
|
219
|
+
sweep_if_due
|
|
185
220
|
end
|
|
186
221
|
ensure
|
|
187
222
|
shutdown
|
|
@@ -204,13 +239,16 @@ module HotCell
|
|
|
204
239
|
end
|
|
205
240
|
|
|
206
241
|
# The nearest thing that needs doing without anybody knocking: a deadline, a queued connection that
|
|
207
|
-
# has waited long enough to be told so,
|
|
242
|
+
# has waited long enough to be told so, a control client that never said what it wanted, or the
|
|
243
|
+
# sweeper's next tick.
|
|
208
244
|
def wait_for
|
|
209
245
|
now = Clock.now
|
|
210
246
|
nearest = [ *@children.each_value.filter_map(&:expires_at),
|
|
211
247
|
*@children.each_value.filter_map { |child| child.lingers_until(Configuration::KILL_GRACE) },
|
|
212
248
|
*@queue.map { |(_, queued_at)| queued_at + configuration.queue_wait },
|
|
213
|
-
*@control_pending.map { |pending| pending.accepted_at + configuration.control_deadline }
|
|
249
|
+
*@control_pending.map { |pending| pending.accepted_at + configuration.control_deadline },
|
|
250
|
+
@sweep&.expires_at,
|
|
251
|
+
(@next_sweep_at unless @stopping) ].compact.min
|
|
214
252
|
return nil if nearest.nil?
|
|
215
253
|
|
|
216
254
|
[ nearest - now, 0 ].max
|
|
@@ -428,8 +466,20 @@ module HotCell
|
|
|
428
466
|
@children.each_value.find(&:available?) || spawn
|
|
429
467
|
end
|
|
430
468
|
|
|
469
|
+
# Forks a worker into each free slot, so that a request does not wait for `fork`. If a fork fails,
|
|
470
|
+
# `spawn` logs it and this stops. The next request forks its own worker.
|
|
471
|
+
def prefork
|
|
472
|
+
return if @stopping
|
|
473
|
+
|
|
474
|
+
while free_slot
|
|
475
|
+
break unless spawn
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
431
479
|
def spawn
|
|
432
|
-
number = free_slot
|
|
480
|
+
number = free_slot
|
|
481
|
+
return nil if number.nil?
|
|
482
|
+
|
|
433
483
|
slot = Slot.build(workspace, number)
|
|
434
484
|
supervisor_side, worker_side = UNIXSocket.pair(:STREAM)
|
|
435
485
|
stderr_reader, stderr_writer = IO.pipe
|
|
@@ -459,12 +509,12 @@ module HotCell
|
|
|
459
509
|
deadline: configuration.limits.deadline, stderr: stderr_reader)
|
|
460
510
|
end
|
|
461
511
|
|
|
462
|
-
# Everything the supervisor holds and the worker must not: the listener, the signal pipe, the other
|
|
463
|
-
# children's control sockets, and every connection the supervisor is still holding for somebody else.
|
|
464
512
|
# The connection this worker is about to serve arrives over SCM_RIGHTS a moment from now, so closing
|
|
465
|
-
# the inherited copy
|
|
513
|
+
# the inherited copy in `leave_supervisor` costs nothing and stops it lingering for the worker's whole
|
|
514
|
+
# life.
|
|
466
515
|
def become_worker(supervisor_side, stderr_reader, stderr_writer)
|
|
467
|
-
|
|
516
|
+
leave_supervisor
|
|
517
|
+
supervisor_side.close
|
|
468
518
|
|
|
469
519
|
# Its own process group, so the deadline reaches the tools this request started rather than only the
|
|
470
520
|
# Ruby process that started them. A tool is a grandchild — the worker spawns it — and killing the
|
|
@@ -487,20 +537,6 @@ module HotCell
|
|
|
487
537
|
# reasons that section records. Landlock is the candidate that fits, tracked at basecamp/hotcell#13.
|
|
488
538
|
Process.setpgid 0, 0
|
|
489
539
|
|
|
490
|
-
supervisor_side.close
|
|
491
|
-
@signals.close
|
|
492
|
-
@signal_writer.close
|
|
493
|
-
@work.close
|
|
494
|
-
@control.close
|
|
495
|
-
|
|
496
|
-
@children.each_value do |child|
|
|
497
|
-
child.control.close
|
|
498
|
-
child.connection&.close
|
|
499
|
-
child.stderr&.close
|
|
500
|
-
end
|
|
501
|
-
@queue.each { |(connection, _)| connection.close }
|
|
502
|
-
@control_pending.each { |pending| pending.connection.close }
|
|
503
|
-
|
|
504
540
|
# fd 2 becomes the pipe, and it stays non-blocking. `IO.pipe` already returns both ends O_NONBLOCK
|
|
505
541
|
# and `reopen` is a dup2, which shares the file description — so the flag would ride along on its
|
|
506
542
|
# own. It is set here anyway, because a decision this load-bearing should be in the code rather than
|
|
@@ -518,6 +554,80 @@ module HotCell
|
|
|
518
554
|
stderr_writer.close
|
|
519
555
|
end
|
|
520
556
|
|
|
557
|
+
# Everything the supervisor holds and a child of its must not: the listeners, the signal pipe, the
|
|
558
|
+
# children's control sockets, and every connection the supervisor is still holding for somebody else.
|
|
559
|
+
def leave_supervisor
|
|
560
|
+
[ "CHLD", "INT", "TERM" ].each { |signal| trap signal, "DEFAULT" }
|
|
561
|
+
|
|
562
|
+
@signals.close
|
|
563
|
+
@signal_writer.close
|
|
564
|
+
@work.close
|
|
565
|
+
@control.close
|
|
566
|
+
|
|
567
|
+
@children.each_value do |child|
|
|
568
|
+
child.control.close
|
|
569
|
+
child.connection&.close
|
|
570
|
+
child.stderr&.close
|
|
571
|
+
end
|
|
572
|
+
@queue.each { |(connection, _)| connection.close }
|
|
573
|
+
@control_pending.each { |pending| pending.connection.close }
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# Ticks whether or not it forks, so a sweep that outlives an interval is left to its deadline rather
|
|
577
|
+
# than joined by a second one. Forking only when a slot holds a tree keeps an idle cell from paying
|
|
578
|
+
# for a child every interval; the glob it costs is the one `Slot#discard_home` already runs inline.
|
|
579
|
+
def sweep_if_due
|
|
580
|
+
return if @stopping || Clock.now < @next_sweep_at
|
|
581
|
+
|
|
582
|
+
@next_sweep_at = Clock.now + configuration.sweep_interval
|
|
583
|
+
spawn_sweeper if @sweep.nil? && discarded_anywhere?
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# A glob that raises is a slot directory a tool replaced, and the sweeper is the process that reports
|
|
587
|
+
# that, so it is forked to find out.
|
|
588
|
+
def discarded_anywhere?
|
|
589
|
+
(0...configuration.concurrency).any? { |number| Slot.build(workspace, number).discarded? }
|
|
590
|
+
rescue SystemCallError
|
|
591
|
+
true
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Held to the cell's deadline: a tree that takes longer to unlink than a request is allowed to run is
|
|
595
|
+
# one an input built to, and the next tick sweeps what this one left. A fork that fails is the host
|
|
596
|
+
# under pressure, as in `spawn`; the trees wait for the next tick.
|
|
597
|
+
def spawn_sweeper
|
|
598
|
+
pid = fork do
|
|
599
|
+
leave_supervisor
|
|
600
|
+
Sweeper.new(workspace: workspace, configuration: configuration, log: log).run
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
log.write "sweeper.forked", pid: pid
|
|
604
|
+
@sweep = Sweep.new(pid: pid, started_at: Clock.now, deadline: configuration.limits.deadline)
|
|
605
|
+
rescue SystemCallError => error
|
|
606
|
+
log.write "sweeper.unforkable", error: error.class.name, message: error.message
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# Kill first, log second, as `enforce_deadlines` does. The sweeper spawns nothing and leads no group,
|
|
610
|
+
# so `kill_group` reaches it through its fallback to the bare pid.
|
|
611
|
+
def enforce_sweep_deadline
|
|
612
|
+
return unless @sweep&.overdue?(Clock.now)
|
|
613
|
+
|
|
614
|
+
@sweep.killed_for = Codes::DEADLINE
|
|
615
|
+
kill_group @sweep
|
|
616
|
+
log.write "sweeper.deadline", pid: @sweep.pid, deadline_s: @sweep.deadline
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# A sweeper this supervisor killed already has its `sweeper.deadline` line. Any other abnormal end —
|
|
620
|
+
# the OOM killer, a sibling's signal, a crash `Sweeper#run` could not catch — would otherwise leave
|
|
621
|
+
# only `sweeper.forked` behind, and look exactly like a sweep that finished.
|
|
622
|
+
def reap_sweeper(status)
|
|
623
|
+
unless @sweep.killed_for || status.success?
|
|
624
|
+
log.write "sweeper.died", pid: @sweep.pid, signal: signal_name(status), exit_code: status.exitstatus
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
@sweep = nil
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
|
|
521
631
|
# One bounded read per pass, never a loop until the pipe is empty: this runs inside the loop that
|
|
522
632
|
# enforces every request's deadline, and the peer is a worker that can print as fast as it likes.
|
|
523
633
|
#
|
|
@@ -762,18 +872,23 @@ module HotCell
|
|
|
762
872
|
# the sweep must not signal it. An empty group is the common case, so ESRCH is expected. It is safe to
|
|
763
873
|
# kill the group by the leader's pid even though the leader is reaped, because the supervisor is single
|
|
764
874
|
# threaded and mints group leaders only in `spawn`, which cannot run between the `wait2` above and here.
|
|
875
|
+
#
|
|
876
|
+
# macOS answers EPERM rather than ESRCH for a group whose only members are zombies, and a tool killed
|
|
877
|
+
# with its worker stays one until launchd reaps it. Raised, it unwound `run` and ended the cell before
|
|
878
|
+
# this reap answered the caller. EPERM means no member received the signal, the same outcome as ESRCH.
|
|
765
879
|
def sweep_group(child)
|
|
766
880
|
Process.kill :KILL, -child.pid
|
|
767
|
-
rescue Errno::ESRCH
|
|
881
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
768
882
|
nil
|
|
769
883
|
end
|
|
770
884
|
|
|
771
885
|
# The whole process group, which is the worker and everything it started. Negative pid is the group.
|
|
772
886
|
# Falls back to the worker alone if the group is already gone, so a worker that died between the check
|
|
773
|
-
# and the signal is not an error.
|
|
887
|
+
# and the signal is not an error. A group answering EPERM falls back as well, for the reason
|
|
888
|
+
# `sweep_group` gives; the bare pid does not, because macOS signals a zombie pid without complaint.
|
|
774
889
|
def kill_group(child)
|
|
775
890
|
Process.kill :KILL, -child.pid
|
|
776
|
-
rescue Errno::ESRCH
|
|
891
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
777
892
|
begin
|
|
778
893
|
Process.kill :KILL, child.pid
|
|
779
894
|
rescue Errno::ESRCH
|
|
@@ -810,6 +925,11 @@ module HotCell
|
|
|
810
925
|
pid, status = Process.wait2(-1, Process::WNOHANG)
|
|
811
926
|
break if pid.nil?
|
|
812
927
|
|
|
928
|
+
if @sweep&.pid == pid
|
|
929
|
+
reap_sweeper status
|
|
930
|
+
next
|
|
931
|
+
end
|
|
932
|
+
|
|
813
933
|
child = @children.each_value.find { |candidate| candidate.pid == pid }
|
|
814
934
|
next if child.nil?
|
|
815
935
|
|
|
@@ -836,6 +956,12 @@ module HotCell
|
|
|
836
956
|
|
|
837
957
|
log.write "worker.reaped", pid: pid, slot: child.slot.number, served: child.served,
|
|
838
958
|
signal: signal_name(status), exit_code: status.exitstatus
|
|
959
|
+
|
|
960
|
+
# A worker that exited without serving a request is not replaced. This guards against a worker
|
|
961
|
+
# that crashes during its startup, for example after a bad deploy. A replacement would crash too,
|
|
962
|
+
# and the supervisor would fork again, with no requests arriving. Instead, the next request forks
|
|
963
|
+
# its own worker.
|
|
964
|
+
prefork if child.served.positive?
|
|
839
965
|
end
|
|
840
966
|
rescue Errno::ECHILD
|
|
841
967
|
nil
|
|
@@ -1137,11 +1263,19 @@ module HotCell
|
|
|
1137
1263
|
Registry.operations.each { |operation| operation.before_fork.each(&:call) }
|
|
1138
1264
|
end
|
|
1139
1265
|
|
|
1266
|
+
# Runs after `preload`, so that the objects the operations loaded are compacted too. Runs before the
|
|
1267
|
+
# first fork, so that each worker inherits the compacted heap. The compacted objects use fewer pages,
|
|
1268
|
+
# so a worker copies fewer pages when it writes to them.
|
|
1269
|
+
def warm_up
|
|
1270
|
+
Process.warmup
|
|
1271
|
+
end
|
|
1272
|
+
|
|
1140
1273
|
|
|
1141
1274
|
def shutdown
|
|
1142
1275
|
refuse_queue "the cell is stopping"
|
|
1143
1276
|
@control_pending.each { |pending| pending.connection.close }
|
|
1144
1277
|
@children.each_value { |child| child.control.close unless child.control.socket.closed? }
|
|
1278
|
+
kill_group @sweep if @sweep
|
|
1145
1279
|
@work&.close
|
|
1146
1280
|
@control&.close
|
|
1147
1281
|
SOCKETS.each { |name| File.unlink socket_path(name) if File.socket?(socket_path(name)) }
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HotCell
|
|
4
|
+
# Unlinks what the supervisor renamed aside, in a process of its own.
|
|
5
|
+
#
|
|
6
|
+
# How long a recursive delete takes is chosen by the input that filled the tree, so it runs in neither
|
|
7
|
+
# the supervisor, whose loop enforces every deadline, nor a worker's request. A worker sweeps its own
|
|
8
|
+
# slot after it has answered, but a worker killed at its deadline never reaches that ensure — and a slot
|
|
9
|
+
# whose every request is killed stacked one tree per kill until the scratch was full. This is the sweep
|
|
10
|
+
# that needs no request to run: the supervisor forks it on a timer, holds it to a deadline, and never
|
|
11
|
+
# runs two at once.
|
|
12
|
+
class Sweeper
|
|
13
|
+
def initialize(workspace:, configuration:, log:)
|
|
14
|
+
@workspace = workspace
|
|
15
|
+
@configuration = configuration
|
|
16
|
+
@log = log
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# exit! for the reason Worker#run does: nothing inherited from the supervisor may run its teardown here.
|
|
20
|
+
#
|
|
21
|
+
# The cell's memory limit goes on first, and only that one. `FileUtils.remove_entry` lists a directory
|
|
22
|
+
# before it unlinks anything in it, so a tree one directory wide enough allocates in proportion to its
|
|
23
|
+
# width; RLIMIT_DATA makes that this process's NoMemoryError and a `sweeper.crashed` line. It is a bound
|
|
24
|
+
# on this process and not on the cell: the cgroup counts every worker and the tmpfs too, and can run out
|
|
25
|
+
# first. A sweep that dies this way makes no progress on that directory, and that is accepted here.
|
|
26
|
+
#
|
|
27
|
+
# Not `file_size`: this process writes nothing but log lines, and a log that is a regular file is past
|
|
28
|
+
# any worker's limit already, so the first line would have killed the sweeper with SIGXFSZ.
|
|
29
|
+
def run
|
|
30
|
+
configuration.limits.merge(file_size: nil, open_files: nil).apply
|
|
31
|
+
started = Clock.now
|
|
32
|
+
swept = slots.sum { |slot| sweep slot }
|
|
33
|
+
|
|
34
|
+
log.write "scratch.swept", pid: Process.pid, swept: swept, duration_ms: Clock.ms_since(started)
|
|
35
|
+
exit! 0
|
|
36
|
+
rescue Exception => error
|
|
37
|
+
log.write "sweeper.crashed", pid: Process.pid, error: error.class.name, message: Failure.sanitize(error.message)
|
|
38
|
+
exit! 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
attr_reader :workspace, :configuration, :log
|
|
43
|
+
|
|
44
|
+
def slots
|
|
45
|
+
(0...configuration.concurrency).map { |number| Slot.build(workspace, number) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A tree that would not go is the worker's `slot.unswept`, from this pid: the same fact, whoever
|
|
49
|
+
# noticed it. The glob can raise — the slot directory is a name a tool can replace — and that too is
|
|
50
|
+
# a slot left unswept rather than the end of the sweep.
|
|
51
|
+
def sweep(slot)
|
|
52
|
+
trees = slot.discarded
|
|
53
|
+
removed = trees.count { |path| Filesystem.remove_tree(path) }
|
|
54
|
+
report_unswept slot if removed < trees.size
|
|
55
|
+
removed
|
|
56
|
+
rescue SystemCallError
|
|
57
|
+
report_unswept slot
|
|
58
|
+
0
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def report_unswept(slot)
|
|
62
|
+
log.write "slot.unswept", pid: Process.pid, slot: slot.number, home: slot.directory
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/hot_cell/test_cell.rb
CHANGED
|
@@ -26,6 +26,9 @@ module HotCell
|
|
|
26
26
|
# which is the only place such a library may be loaded.
|
|
27
27
|
#
|
|
28
28
|
# HotCell::TestCell.boot(operations: -> { require "active_storage/hot_cell/server" })
|
|
29
|
+
#
|
|
30
|
+
# The cell's sockets go beneath `Dir.tmpdir`, and a Unix socket path holds 104 bytes on macOS, whose per-user
|
|
31
|
+
# `TMPDIR` spends about half of that. A suite on macOS should set a short `TMPDIR`, such as `/tmp`.
|
|
29
32
|
class TestCell
|
|
30
33
|
READY = "up"
|
|
31
34
|
|
|
@@ -70,6 +70,16 @@ module HotCell
|
|
|
70
70
|
end
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
# A worker inherits the supervisor's GC counters at the fork. So this reports what the supervisor did to
|
|
74
|
+
# its heap before the fork.
|
|
75
|
+
class HeapState < HotCell::Operation
|
|
76
|
+
operation "test.heap_state"
|
|
77
|
+
|
|
78
|
+
def perform(_inputs, _outputs)
|
|
79
|
+
{ compactions: GC.stat(:compact_count) }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
73
83
|
# Leaves its $HOME in a state the worker cannot remove, by taking write permission off a subdirectory
|
|
74
84
|
# that still has a file in it. A tool running as this user can do the same to a sibling's directory,
|
|
75
85
|
# which is why a removal that fails has to be reported rather than swallowed.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hotcell-server
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike Dalessio
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - '='
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.
|
|
18
|
+
version: 0.6.0
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - '='
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 0.
|
|
25
|
+
version: 0.6.0
|
|
26
26
|
description: |
|
|
27
27
|
Runs a HotCell container. A supervisor listens on two Unix sockets, forks a worker for each request,
|
|
28
28
|
and enforces a wall clock deadline and resource limits on it. Write the work as a subclass of
|
|
@@ -43,6 +43,7 @@ files:
|
|
|
43
43
|
- lib/hot_cell/control.rb
|
|
44
44
|
- lib/hot_cell/counters.rb
|
|
45
45
|
- lib/hot_cell/filesystem.rb
|
|
46
|
+
- lib/hot_cell/health_operations.rb
|
|
46
47
|
- lib/hot_cell/limits.rb
|
|
47
48
|
- lib/hot_cell/log.rb
|
|
48
49
|
- lib/hot_cell/operation.rb
|
|
@@ -52,6 +53,7 @@ files:
|
|
|
52
53
|
- lib/hot_cell/server/version.rb
|
|
53
54
|
- lib/hot_cell/slot.rb
|
|
54
55
|
- lib/hot_cell/supervisor.rb
|
|
56
|
+
- lib/hot_cell/sweeper.rb
|
|
55
57
|
- lib/hot_cell/test_cell.rb
|
|
56
58
|
- lib/hot_cell/test_operations.rb
|
|
57
59
|
- lib/hot_cell/timing.rb
|
|
@@ -62,8 +64,8 @@ licenses:
|
|
|
62
64
|
- MIT
|
|
63
65
|
metadata:
|
|
64
66
|
homepage_uri: https://github.com/basecamp/hotcell
|
|
65
|
-
source_code_uri: https://github.com/basecamp/hotcell/tree/v0.
|
|
66
|
-
changelog_uri: https://github.com/basecamp/hotcell/blob/v0.
|
|
67
|
+
source_code_uri: https://github.com/basecamp/hotcell/tree/v0.6.0/hotcell-server
|
|
68
|
+
changelog_uri: https://github.com/basecamp/hotcell/blob/v0.6.0/CHANGELOG.md
|
|
67
69
|
bug_tracker_uri: https://github.com/basecamp/hotcell/issues
|
|
68
70
|
rubygems_mfa_required: 'true'
|
|
69
71
|
rdoc_options: []
|
|
@@ -80,7 +82,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
80
82
|
- !ruby/object:Gem::Version
|
|
81
83
|
version: '0'
|
|
82
84
|
requirements: []
|
|
83
|
-
rubygems_version: 4.0.
|
|
85
|
+
rubygems_version: 4.0.6
|
|
84
86
|
specification_version: 4
|
|
85
87
|
summary: 'Run a HotCell: the supervisor, the worker, and the operation API.'
|
|
86
88
|
test_files: []
|