hotcell-server 0.4.1 → 0.5.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/filesystem.rb +20 -2
- 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 +125 -24
- data/lib/hot_cell/sweeper.rb +65 -0
- metadata +6 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b69f25f352406df26617692f42c1ce78c7191df4d7b617360c62c2371e488bed
|
|
4
|
+
data.tar.gz: 34ca8c1eea5a9de3cf739ce548151ac5c9487e185739f684d179844c13257b1d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5667ac9be32cd60ab131df56fb4be0a0354f007a19d31990c6210076dcd6e72c019a903b8e4f4f89ed5bf4ee444269de2b30d4776aa6373750f02ab21fa2869b
|
|
7
|
+
data.tar.gz: 9567d005d357239081a4c804b081939714c1ead9afb2588fcbd95d6795c638770348959d00549d49d9b76ec7cdc7bb0e3b2f7dd226a1a001d0c532fb524a05d2
|
|
@@ -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/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
|
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
|
|
@@ -178,10 +208,12 @@ module HotCell
|
|
|
178
208
|
|
|
179
209
|
enforce_deadlines
|
|
180
210
|
enforce_retirements
|
|
211
|
+
enforce_sweep_deadline
|
|
181
212
|
expire_queue
|
|
182
213
|
expire_control
|
|
183
214
|
retire_idle if @stopping
|
|
184
215
|
pump
|
|
216
|
+
sweep_if_due
|
|
185
217
|
end
|
|
186
218
|
ensure
|
|
187
219
|
shutdown
|
|
@@ -204,13 +236,16 @@ module HotCell
|
|
|
204
236
|
end
|
|
205
237
|
|
|
206
238
|
# The nearest thing that needs doing without anybody knocking: a deadline, a queued connection that
|
|
207
|
-
# has waited long enough to be told so,
|
|
239
|
+
# has waited long enough to be told so, a control client that never said what it wanted, or the
|
|
240
|
+
# sweeper's next tick.
|
|
208
241
|
def wait_for
|
|
209
242
|
now = Clock.now
|
|
210
243
|
nearest = [ *@children.each_value.filter_map(&:expires_at),
|
|
211
244
|
*@children.each_value.filter_map { |child| child.lingers_until(Configuration::KILL_GRACE) },
|
|
212
245
|
*@queue.map { |(_, queued_at)| queued_at + configuration.queue_wait },
|
|
213
|
-
*@control_pending.map { |pending| pending.accepted_at + configuration.control_deadline }
|
|
246
|
+
*@control_pending.map { |pending| pending.accepted_at + configuration.control_deadline },
|
|
247
|
+
@sweep&.expires_at,
|
|
248
|
+
(@next_sweep_at unless @stopping) ].compact.min
|
|
214
249
|
return nil if nearest.nil?
|
|
215
250
|
|
|
216
251
|
[ nearest - now, 0 ].max
|
|
@@ -459,12 +494,12 @@ module HotCell
|
|
|
459
494
|
deadline: configuration.limits.deadline, stderr: stderr_reader)
|
|
460
495
|
end
|
|
461
496
|
|
|
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
497
|
# The connection this worker is about to serve arrives over SCM_RIGHTS a moment from now, so closing
|
|
465
|
-
# the inherited copy
|
|
498
|
+
# the inherited copy in `leave_supervisor` costs nothing and stops it lingering for the worker's whole
|
|
499
|
+
# life.
|
|
466
500
|
def become_worker(supervisor_side, stderr_reader, stderr_writer)
|
|
467
|
-
|
|
501
|
+
leave_supervisor
|
|
502
|
+
supervisor_side.close
|
|
468
503
|
|
|
469
504
|
# Its own process group, so the deadline reaches the tools this request started rather than only the
|
|
470
505
|
# Ruby process that started them. A tool is a grandchild — the worker spawns it — and killing the
|
|
@@ -487,20 +522,6 @@ module HotCell
|
|
|
487
522
|
# reasons that section records. Landlock is the candidate that fits, tracked at basecamp/hotcell#13.
|
|
488
523
|
Process.setpgid 0, 0
|
|
489
524
|
|
|
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
525
|
# fd 2 becomes the pipe, and it stays non-blocking. `IO.pipe` already returns both ends O_NONBLOCK
|
|
505
526
|
# and `reopen` is a dup2, which shares the file description — so the flag would ride along on its
|
|
506
527
|
# own. It is set here anyway, because a decision this load-bearing should be in the code rather than
|
|
@@ -518,6 +539,80 @@ module HotCell
|
|
|
518
539
|
stderr_writer.close
|
|
519
540
|
end
|
|
520
541
|
|
|
542
|
+
# Everything the supervisor holds and a child of its must not: the listeners, the signal pipe, the
|
|
543
|
+
# children's control sockets, and every connection the supervisor is still holding for somebody else.
|
|
544
|
+
def leave_supervisor
|
|
545
|
+
[ "CHLD", "INT", "TERM" ].each { |signal| trap signal, "DEFAULT" }
|
|
546
|
+
|
|
547
|
+
@signals.close
|
|
548
|
+
@signal_writer.close
|
|
549
|
+
@work.close
|
|
550
|
+
@control.close
|
|
551
|
+
|
|
552
|
+
@children.each_value do |child|
|
|
553
|
+
child.control.close
|
|
554
|
+
child.connection&.close
|
|
555
|
+
child.stderr&.close
|
|
556
|
+
end
|
|
557
|
+
@queue.each { |(connection, _)| connection.close }
|
|
558
|
+
@control_pending.each { |pending| pending.connection.close }
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# Ticks whether or not it forks, so a sweep that outlives an interval is left to its deadline rather
|
|
562
|
+
# than joined by a second one. Forking only when a slot holds a tree keeps an idle cell from paying
|
|
563
|
+
# for a child every interval; the glob it costs is the one `Slot#discard_home` already runs inline.
|
|
564
|
+
def sweep_if_due
|
|
565
|
+
return if @stopping || Clock.now < @next_sweep_at
|
|
566
|
+
|
|
567
|
+
@next_sweep_at = Clock.now + configuration.sweep_interval
|
|
568
|
+
spawn_sweeper if @sweep.nil? && discarded_anywhere?
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# A glob that raises is a slot directory a tool replaced, and the sweeper is the process that reports
|
|
572
|
+
# that, so it is forked to find out.
|
|
573
|
+
def discarded_anywhere?
|
|
574
|
+
(0...configuration.concurrency).any? { |number| Slot.build(workspace, number).discarded? }
|
|
575
|
+
rescue SystemCallError
|
|
576
|
+
true
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# Held to the cell's deadline: a tree that takes longer to unlink than a request is allowed to run is
|
|
580
|
+
# one an input built to, and the next tick sweeps what this one left. A fork that fails is the host
|
|
581
|
+
# under pressure, as in `spawn`; the trees wait for the next tick.
|
|
582
|
+
def spawn_sweeper
|
|
583
|
+
pid = fork do
|
|
584
|
+
leave_supervisor
|
|
585
|
+
Sweeper.new(workspace: workspace, configuration: configuration, log: log).run
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
log.write "sweeper.forked", pid: pid
|
|
589
|
+
@sweep = Sweep.new(pid: pid, started_at: Clock.now, deadline: configuration.limits.deadline)
|
|
590
|
+
rescue SystemCallError => error
|
|
591
|
+
log.write "sweeper.unforkable", error: error.class.name, message: error.message
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Kill first, log second, as `enforce_deadlines` does. The sweeper spawns nothing and leads no group,
|
|
595
|
+
# so `kill_group` reaches it through its fallback to the bare pid.
|
|
596
|
+
def enforce_sweep_deadline
|
|
597
|
+
return unless @sweep&.overdue?(Clock.now)
|
|
598
|
+
|
|
599
|
+
@sweep.killed_for = Codes::DEADLINE
|
|
600
|
+
kill_group @sweep
|
|
601
|
+
log.write "sweeper.deadline", pid: @sweep.pid, deadline_s: @sweep.deadline
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# A sweeper this supervisor killed already has its `sweeper.deadline` line. Any other abnormal end —
|
|
605
|
+
# the OOM killer, a sibling's signal, a crash `Sweeper#run` could not catch — would otherwise leave
|
|
606
|
+
# only `sweeper.forked` behind, and look exactly like a sweep that finished.
|
|
607
|
+
def reap_sweeper(status)
|
|
608
|
+
unless @sweep.killed_for || status.success?
|
|
609
|
+
log.write "sweeper.died", pid: @sweep.pid, signal: signal_name(status), exit_code: status.exitstatus
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
@sweep = nil
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
|
|
521
616
|
# One bounded read per pass, never a loop until the pipe is empty: this runs inside the loop that
|
|
522
617
|
# enforces every request's deadline, and the peer is a worker that can print as fast as it likes.
|
|
523
618
|
#
|
|
@@ -810,6 +905,11 @@ module HotCell
|
|
|
810
905
|
pid, status = Process.wait2(-1, Process::WNOHANG)
|
|
811
906
|
break if pid.nil?
|
|
812
907
|
|
|
908
|
+
if @sweep&.pid == pid
|
|
909
|
+
reap_sweeper status
|
|
910
|
+
next
|
|
911
|
+
end
|
|
912
|
+
|
|
813
913
|
child = @children.each_value.find { |candidate| candidate.pid == pid }
|
|
814
914
|
next if child.nil?
|
|
815
915
|
|
|
@@ -1142,6 +1242,7 @@ module HotCell
|
|
|
1142
1242
|
refuse_queue "the cell is stopping"
|
|
1143
1243
|
@control_pending.each { |pending| pending.connection.close }
|
|
1144
1244
|
@children.each_value { |child| child.control.close unless child.control.socket.closed? }
|
|
1245
|
+
kill_group @sweep if @sweep
|
|
1145
1246
|
@work&.close
|
|
1146
1247
|
@control&.close
|
|
1147
1248
|
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
|
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.5.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.5.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.5.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
|
|
@@ -52,6 +52,7 @@ files:
|
|
|
52
52
|
- lib/hot_cell/server/version.rb
|
|
53
53
|
- lib/hot_cell/slot.rb
|
|
54
54
|
- lib/hot_cell/supervisor.rb
|
|
55
|
+
- lib/hot_cell/sweeper.rb
|
|
55
56
|
- lib/hot_cell/test_cell.rb
|
|
56
57
|
- lib/hot_cell/test_operations.rb
|
|
57
58
|
- lib/hot_cell/timing.rb
|
|
@@ -62,8 +63,8 @@ licenses:
|
|
|
62
63
|
- MIT
|
|
63
64
|
metadata:
|
|
64
65
|
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.
|
|
66
|
+
source_code_uri: https://github.com/basecamp/hotcell/tree/v0.5.0/hotcell-server
|
|
67
|
+
changelog_uri: https://github.com/basecamp/hotcell/blob/v0.5.0/CHANGELOG.md
|
|
67
68
|
bug_tracker_uri: https://github.com/basecamp/hotcell/issues
|
|
68
69
|
rubygems_mfa_required: 'true'
|
|
69
70
|
rdoc_options: []
|