async-background 1.0.1 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 823b13ecbf3740e79a15a9c24f0195a2d09bbc49bb189885d0738a0636cf913c
4
- data.tar.gz: 4424725707108aaf8249b8ea62e500ccaa884bbc5392a4691cdcdc540ea5fda4
3
+ metadata.gz: 1da83cfa99bb2a77a32b8bd72dd5bee692f94f87b453706f4443cc015e065ac4
4
+ data.tar.gz: b0c60214b0e13a7a0c346f5e681d78b1701a7de95e5e816f84193d6593f2c282
5
5
  SHA512:
6
- metadata.gz: 3e0d13f6b354efdd47fe4d93c464eb4411eb4658bd91fc9f3a2a154fd0300d72982680d98b454180c958ac93c15c3a7500df6c0ec346508c0791f6503525c042
7
- data.tar.gz: 53a18f4ce6f47db152a303f456f5ba80cd3b706ceaee4e1aac35d752e8067c2e1eb807765117a77837e838376740a61c91690ac4714dd5853ae062c41cbd6c3e
6
+ metadata.gz: 6cdc11bc1e0bbfb5d76e8d26bb80a71d498c3a04a0fbd1a7e3212640c3f0ae1513fb8bb18784e1dfdd7b96aafc1771ed8cf0db00ac1fedde8e63579943df1e2b
7
+ data.tar.gz: ed97b4b602abb304db44199247b533d2fce410ab8a3a80182e9a08ac65eff680d0df198f529ef53b87e947ccf8114b4e89e333f5695928fc1b0137b6dff90c53
data/CHANGELOG.md CHANGED
@@ -1,5 +1,158 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0
4
+
5
+ The gem no longer depends on `async`. The same worker runs under any
6
+ `Fiber::Scheduler` the host installs — Falcon/Async, Itsi, or another
7
+ implementation.
8
+
9
+ ### Breaking
10
+
11
+ - `Runner#run` no longer starts a reactor. It needs an active
12
+ `Fiber.scheduler` and raises `Runtime::SchedulerRequired` without one:
13
+
14
+ ```ruby
15
+ Async { runner.run } # Falcon / Async
16
+ Async::Background::Scheduler.run { runner.run } # async or itsi via ENV
17
+ ```
18
+
19
+ - Job timeouts raise `Async::Background::Runtime::TimeoutError` instead of
20
+ `Async::TimeoutError`. Update rescue sites and any stored `error_class`.
21
+ - `Runner#drain_jobs` is bounded (`drain_timeout:` default 30s). In-flight jobs
22
+ that outlive it are cancelled. Pass `drain_timeout: nil` for an unbounded wait.
23
+ - `TaskGroup#stop_all(grace = nil)` returns whether the group drained, not a
24
+ count of cancelled tasks.
25
+ - `Async::Background::Error` is the base class for `Runtime::Error` and
26
+ `ConfigError`. Both are still `StandardError`.
27
+
28
+ ### Added
29
+
30
+ - `Async::Background::Runtime` — `Task`, `TaskGroup`, `Semaphore`,
31
+ `Notification`, `with_timeout`, `native_timeouts?`, `with_error_handler`.
32
+ - `Async::Background::Scheduler` — optional bootstrap
33
+ (`require "async/background/scheduler"`) that installs `async` or
34
+ `itsi-scheduler` from `ASYNC_BACKGROUND_SCHEDULER`. `Scheduler.preload!`
35
+ loads the gem before fork. `Scheduler.run` on Itsi uses the current thread;
36
+ `ASYNC_BACKGROUND_SCHEDULER_THREAD=1` restores a dedicated thread.
37
+
38
+ ### Fixed
39
+
40
+ - The saturated-queue wake-up fires from `TaskGroup#on_release`, after the job
41
+ has left `@jobs`. Signalling from the task's `ensure` left the listener
42
+ seeing a full group on any scheduler that resumes `unblock` immediately.
43
+ - `Runtime.with_timeout` calls `scheduler.timeout_after` when the hook exists.
44
+ The `::Timeout.timeout` fallback uses `Thread#raise` and can hit the wrong
45
+ fiber; a missing hook now warns once per scheduler class.
46
+ - `SocketWaker#close` stops the accept loop before the self-connect that
47
+ unblocks it, and hangs up tracked client sockets. A fiber parked in
48
+ `IO#wait_readable` is not cancellable via `Task#stop` on a scheduler without
49
+ `#fiber_interrupt`.
50
+ - `Runtime.error_handler` is scoped per runner (`on_error:` /
51
+ `with_error_handler`). A process-global handler was overwritten by the next
52
+ runner and never restored.
53
+ - A failure awaited via `Task#wait` is no longer also sent to the error handler.
54
+ - `Semaphore.new(0)` raises `ArgumentError` instead of deadlocking on acquire.
55
+
56
+ ### Changed
57
+
58
+ - `async` is a development dependency. Runtime gems are `console`, `fugit`
59
+ and `base64`.
60
+ - `SocketWaker#start_accept_loop` no longer needs a parent task (the
61
+ argument is still accepted and ignored).
62
+ - Shutdown closes the waker before the store, so the listener cannot be inside
63
+ `Store#fetch` when the connection disappears.
64
+
65
+ ### Unchanged
66
+
67
+ - Dashboard, `perform_async` / `perform_in` / `perform_at`, SQLite schema,
68
+ and the cross-process wake protocol.
69
+
70
+ ## 1.0.2
71
+
72
+ Queue maintenance bug fix plus profiler-driven work on the hot paths that
73
+ actually showed up in StackProf: the sqlite3 statement wrapper, transaction
74
+ control, and the socket notifier.
75
+
76
+ ### Fixed
77
+
78
+ - `Store#cleanup_finished_jobs` tested `@db.changes` after running *both*
79
+ DELETEs. `sqlite3_changes()` reports only the most recent statement, so the
80
+ incremental-vacuum decision saw the failed-job count alone and ignored every
81
+ deleted done-job. On a busy queue, where done-jobs vastly outnumber failed
82
+ ones, the vacuum effectively never ran and the database file grew without
83
+ bound. Both counts are now summed.
84
+ - `PRAGMA incremental_vacuum` ran without a page limit, releasing every free
85
+ page in a single blocking call — an unbounded reactor stall proportional to
86
+ the accumulated free list. Now capped at 64 pages per call
87
+ (`SQL::INCREMENTAL_VACUUM_PAGES`).
88
+ - `SocketWaker` signalled its notification only from the `ensure` block, so a
89
+ wake-up was really driven by the client disconnecting rather than by the
90
+ wake byte arriving. It happened to work because `SocketNotifier` closes
91
+ immediately after writing, but it made the protocol depend on a disconnect.
92
+ The signal now fires for every byte received; the `ensure` still signals so a
93
+ disconnect racing with a read cannot drop a wake-up.
94
+
95
+ ### Performance
96
+
97
+ Measured on the `enqueue_stress` CI scenario (2.1M inserts / 30s, two producer
98
+ processes). `SQLite3::Statement#step` accounts for ~82% of producer wall time
99
+ and is irreducible; the changes below target what surrounded it.
100
+
101
+ - `Store#enqueue` and `Store#fetch` bind and step their prepared statements
102
+ directly instead of calling `Statement#execute`. The wrapper built a splat
103
+ array, ran `Array#flatten` over it and allocated a `ResultSet` — roughly 8%
104
+ of producer wall time, and `Array#flatten` alone was 5.8% of all object
105
+ allocations. Behaviour is unchanged: `execute` only steps when
106
+ `column_count == 0`, which is exactly what the INSERT path needed, and the
107
+ `UPDATE ... RETURNING` fetch still consumes a single row.
108
+ - `SocketNotifier` caches its socket paths at construction. It previously ran
109
+ `File.join` plus a string interpolation on every enqueue attempt —
110
+ 13.3% of allocations in the normal scenario.
111
+ - `SocketNotifier` remembers unreachable workers for `DEAD_WORKER_TTL` (5s)
112
+ instead of reconnecting to them on every enqueue. Because the scan started at
113
+ a random index, a dead worker was re-probed indefinitely and each attempt
114
+ raised and swallowed an `Errno`; `SystemCallError#initialize` alone was 6.8%
115
+ of allocations. A worker that starts during the TTL window is still picked up
116
+ by the listener's own polling fallback.
117
+ - `SocketNotifier` rotates its scan start with a cursor rather than calling
118
+ `rand` per enqueue, which also spreads wake-ups deterministically.
119
+ - `Errno::EAGAIN` on the wake byte (`IO::WaitWritable`) is now treated as
120
+ success rather than falling through to the generic rescue and logging a
121
+ warning: a full send buffer means the worker already has unread wake-ups.
122
+ - `Store#transaction` runs `BEGIN IMMEDIATE`, `COMMIT` and `ROLLBACK` as
123
+ prepared statements. They previously went through `Database#execute`, which
124
+ compiles a fresh `Statement` and builds a `ResultSet` on every call: 26
125
+ allocations per BEGIN+COMMIT pair against 2 prepared, and 6x the wall time
126
+ over 30k pairs. Every `fetch` paid this twice, and it was 11.8% of worker
127
+ allocations in the object profile.
128
+ - `mark_started!`, `complete`, `fail`, `retry_job!`, `recover`,
129
+ `stored_options_for`, `lease_alive?`, `next_pending_run_at` and the two
130
+ cleanup DELETEs now bind and step directly, like `enqueue` and `fetch`
131
+ already did. `Statement#execute` is gone from the Store entirely;
132
+ `Database#execute` remains only on the cold pragma path. This is what was
133
+ still leaving `Array#flatten` at 4.9% of worker allocations and
134
+ `Statement#execute` at 10.8% of worker wall time.
135
+ - Combined effect on a full job cycle (enqueue omitted; fetch + mark_started +
136
+ complete, 8000 jobs, measured twice): **49 -> 18 allocations per job**, wall
137
+ time down roughly 10-20% depending on run.
138
+ - `@db.changes` and `@db.last_insert_row_id` are read after the statement is
139
+ reset; both were verified to survive `sqlite3_reset()`, so the claim-token
140
+ lease checks behave exactly as before (valid token true, stale token false,
141
+ repeat completion false).
142
+
143
+ ### Notes
144
+
145
+ - `SocketNotifier` still opens a fresh connection per notification. This is
146
+ the largest remaining win and it is paid on both sides: 8.5% of producer wall
147
+ time in the scenario where workers are actually up, plus 16.4% of worker
148
+ allocations, because every connection makes `SocketWaker#handle_client` spawn
149
+ a fresh `Async::Task` and fiber. Persistent connections require the
150
+ `SocketWaker` fix above to be deployed on every worker first — a 1.0.2 producer holding a connection open
151
+ to a 1.0.1 worker would never wake it, silently degrading queue latency to
152
+ the 5s poll interval. Deferred until 1.0.2 is the deployed floor.
153
+ - No schema change; `Schema::VERSION` is untouched and 1.0.2 is a drop-in
154
+ replacement for 1.0.1 on an existing database.
155
+
3
156
  ## 1.0.1
4
157
 
5
158
  Dashboard security headers and a fiber-native rewrite of the SSE stream.
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Async::Background
2
2
 
3
- A lightweight cron, interval, and job-queue scheduler for Ruby's
4
- [Async](https://github.com/socketry/async) ecosystem. Built for
5
- [Falcon](https://github.com/socketry/falcon), works with any Async app.
3
+ A lightweight cron, interval, and job-queue scheduler for Ruby fibers.
4
+ Works with any `Fiber::Scheduler` — [Falcon](https://github.com/socketry/falcon) /
5
+ [Async](https://github.com/socketry/async), [Itsi](https://itsi.fyi), or your own.
6
6
 
7
7
  - **Cron & interval scheduling** on a single event loop with a min-heap.
8
8
  - **Dynamic job queue** backed by SQLite, with delayed jobs
@@ -15,11 +15,11 @@ A lightweight cron, interval, and job-queue scheduler for Ruby's
15
15
 
16
16
  ---
17
17
 
18
- ## Why Async? Why fibers?
18
+ ## Why fibers?
19
19
 
20
- The whole gem is built around the assumption that Falcon's reactor schedules
21
- many fibers on top of one OS thread per process — so the dashboard's SSE
22
- stream, the cron scheduler, and the queue worker all share that one thread
20
+ The whole gem is built around the assumption that the host schedules many
21
+ fibers on top of one OS thread per process — so the dashboard's SSE stream,
22
+ the cron scheduler, and the queue worker all share that one thread
23
23
  cooperatively. A blocked fiber yields; a blocked thread doesn't.
24
24
 
25
25
  ![Threads vs fibers under different Ruby web servers](docs/fibers-vs-threads.svg)
@@ -30,13 +30,31 @@ open tab.
30
30
 
31
31
  ---
32
32
 
33
+ ## Fiber scheduler
34
+
35
+ `Runner#run` does not start a reactor. The host must install a
36
+ `Fiber.scheduler` first:
37
+
38
+ ```ruby
39
+ Async { runner.run } # Falcon / Async
40
+
41
+ require "async/background/scheduler"
42
+ Async::Background::Scheduler.run { runner.run } # ASYNC_BACKGROUND_SCHEDULER=async|itsi
43
+ ```
44
+
45
+ Without a scheduler it raises `Runtime::SchedulerRequired`.
46
+ Worker setup for Falcon and Itsi is in [Get started](docs/GET_STARTED.md).
47
+
48
+ ---
49
+
33
50
  ## Requirements
34
51
 
35
52
  | Dependency | Version | Required? |
36
53
  | -------------------- | ---------------- | -------------------- |
37
54
  | Ruby | `>= 3.3` | yes |
38
- | `async` | `~> 2.0` | yes |
55
+ | a `Fiber::Scheduler` | `async ~> 2.0`, `itsi-scheduler`, … | yes — installed by the host, not by this gem |
39
56
  | `fugit` | `~> 1.0` | yes |
57
+ | `console` | `~> 1.0` | yes |
40
58
  | `sqlite3` | `~> 2.0` | for the queue & dashboard |
41
59
  | `async-utilization` | `>= 0.3, < 0.5` | for metrics |
42
60
 
@@ -48,16 +66,16 @@ open tab.
48
66
  # Gemfile
49
67
  gem "async-background"
50
68
 
51
- gem "sqlite3", "~> 2.0" # if you use the queue or dashboard
52
- gem "async-utilization", ">= 0.3", "< 0.5" # if you want worker metrics
69
+ gem "async", "~> 2.0" # or `itsi-scheduler`
70
+ gem "sqlite3", "~> 2.0" # queue / dashboard
71
+ gem "async-utilization", ">= 0.3", "< 0.5" # optional metrics
53
72
  ```
54
73
 
55
74
  ---
56
75
 
57
76
  ## ➡️ [Get started](docs/GET_STARTED.md)
58
77
 
59
- A four-step walkthrough: schedule config, Falcon integration, Docker, queue,
60
- delayed jobs.
78
+ Schedule config, Falcon or Itsi workers, Docker, queue, delayed jobs.
61
79
 
62
80
  ---
63
81
 
@@ -120,9 +138,12 @@ jobs use wall-clock time, because "every day at 3am" needs to mean 3am.
120
138
 
121
139
  ## How it works
122
140
 
123
- A single Async task sleeps until the next entry is due, then dispatches it
124
- under a semaphore that caps concurrency. Overlapping ticks are skipped and
125
- rescheduled.
141
+ A single fiber sleeps until the next entry is due, then dispatches it under a
142
+ semaphore that caps concurrency. Overlapping ticks are skipped and rescheduled.
143
+ Every dispatched job is tracked in a `Runtime::TaskGroup`, so shutdown drains
144
+ in-flight work before the SQLite store and the wake-up socket are closed. The
145
+ drain is bounded by `drain_timeout:` (30s by default; pass `nil` for the old
146
+ unbounded wait) so one wedged job cannot turn SIGTERM into SIGKILL.
126
147
 
127
148
  ```
128
149
  schedule.yml → build_heap → MinHeap<Entry> → scheduler loop → Semaphore → run_job
@@ -8,12 +8,13 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ['Roman Hajdarov']
9
9
  spec.email = ['romnhajdarov@gmail.com']
10
10
 
11
- spec.summary = 'Lightweight heap-based cron/interval scheduler for Async.'
12
- spec.description = 'A production-grade lightweight scheduler built on top of Async. ' \
13
- 'Single event loop with min-heap timer, skip-overlapping execution, ' \
14
- 'jitter, monotonic clock intervals, semaphore concurrency control, ' \
15
- 'and deterministic worker sharding. Designed for Falcon but works ' \
16
- 'with any Async-based application.'
11
+ spec.summary = 'Lightweight heap-based cron/interval scheduler for any Fiber scheduler.'
12
+ spec.description = 'A production-grade lightweight background job scheduler built on the ' \
13
+ 'Fiber::Scheduler interface itself, not on any one implementation: it ' \
14
+ 'runs under Async/Falcon, Itsi, or anything else that installs a ' \
15
+ 'scheduler. Single event loop with min-heap timer, skip-overlapping ' \
16
+ 'execution, jitter, monotonic clock intervals, semaphore concurrency ' \
17
+ 'control, and deterministic worker sharding.'
17
18
 
18
19
  spec.homepage = 'https://github.com/roman-haidarov/async-background'
19
20
  spec.license = 'MIT'
@@ -30,15 +31,20 @@ Gem::Specification.new do |spec|
30
31
 
31
32
  spec.required_ruby_version = '>= 3.3'
32
33
 
33
- spec.add_dependency 'async', '~> 2.0'
34
+ # No scheduler dependency on purpose. The gem uses the Fiber::Scheduler
35
+ # interface (Fiber.schedule, #block/#unblock, Timeout) and requires the host
36
+ # to install an implementation.
34
37
  spec.add_dependency 'console', '~> 1.0'
35
38
  spec.add_dependency 'fugit', '~> 1.0'
36
39
  spec.add_dependency 'base64', '~> 0.2'
37
40
 
38
41
  # Optional: add to your own Gemfile if you need these features
42
+ # gem 'async', '~> 2.0' # or any other Fiber scheduler
43
+ # gem 'itsi-scheduler' # ... such as this one
39
44
  # gem 'sqlite3', '~> 2.0'
40
45
  # gem 'async-utilization', '>= 0.3', '< 0.5' # shared-memory worker metrics
41
46
 
47
+ spec.add_development_dependency 'async', '~> 2.0'
42
48
  spec.add_development_dependency 'rake', '~> 13.0'
43
49
  spec.add_development_dependency 'rspec', '~> 3.12'
44
50
  spec.add_development_dependency 'rack', '~> 3.0'
@@ -127,9 +127,7 @@ module Async
127
127
  end
128
128
 
129
129
  def decode_workers(buffer, schema, segment, total_workers)
130
- (1..total_workers).map do |worker|
131
- decode_worker(buffer, schema, segment, worker)
132
- end.freeze
130
+ (1..total_workers).map { |worker| decode_worker(buffer, schema, segment, worker) }.freeze
133
131
  end
134
132
 
135
133
  def decode_worker(buffer, schema, segment, worker)
@@ -29,11 +29,9 @@ module Async
29
29
 
30
30
  enable_incremental_vacuum!(db) unless jobs_table?(db)
31
31
 
32
- with_migration_timeout(db) do
33
- immediate_transaction(db) do
34
- reject_future_version!(db)
35
- upgrade!(db) unless current?(db)
36
- end
32
+ synchronized_change(db) do
33
+ reject_future_version!(db)
34
+ upgrade!(db) unless current?(db)
37
35
  end
38
36
  end
39
37
 
@@ -41,20 +39,20 @@ module Async
41
39
  migrate!(db)
42
40
  return if dashboard_indexes_current?(db)
43
41
 
44
- with_migration_timeout(db) do
45
- immediate_transaction(db) do
46
- create_dashboard_indexes!(db) unless dashboard_indexes_current?(db)
47
- end
42
+ synchronized_change(db) do
43
+ create_dashboard_indexes!(db) unless dashboard_indexes_current?(db)
48
44
  end
49
45
  end
50
46
 
47
+ def synchronized_change(db, &change)
48
+ with_migration_timeout(db) { immediate_transaction(db, &change) }
49
+ end
50
+
51
51
  def current?(db)
52
52
  jobs_table?(db) && version(db) == VERSION && core_indexes_current?(db)
53
53
  end
54
54
 
55
- def dashboard_indexes_current?(db)
56
- (DASHBOARD_INDEXES - index_names(db)).empty?
57
- end
55
+ def dashboard_indexes_current?(db) = indexes_present?(db, DASHBOARD_INDEXES)
58
56
 
59
57
  def version(db)
60
58
  db.get_first_value(SQL::USER_VERSION).to_i
@@ -101,8 +99,10 @@ module Async
101
99
  SQL::CREATE_DASHBOARD_INDEXES.each { |statement| db.execute(statement) }
102
100
  end
103
101
 
104
- def core_indexes_current?(db)
105
- (CORE_INDEXES - index_names(db)).empty?
102
+ def core_indexes_current?(db) = indexes_present?(db, CORE_INDEXES)
103
+
104
+ def indexes_present?(db, names)
105
+ (names - index_names(db)).empty?
106
106
  end
107
107
 
108
108
  def jobs_table?(db)
@@ -2,10 +2,14 @@
2
2
 
3
3
  require 'socket'
4
4
 
5
+ require_relative '../clock'
6
+
5
7
  module Async
6
8
  module Background
7
9
  module Queue
8
10
  class SocketNotifier
11
+ include Clock
12
+
9
13
  # Errors that indicate a worker is unavailable - silently skip and try the next.
10
14
  UNAVAILABLE = [
11
15
  Errno::ENOENT, # Socket file doesn't exist (worker hasn't started yet)
@@ -14,41 +18,68 @@ module Async
14
18
  Errno::ECONNRESET # Connection reset by peer
15
19
  ].freeze
16
20
 
21
+ DEAD_WORKER_TTL = 5.0
22
+ WAKE_BYTE = "\x01".freeze
23
+
17
24
  def initialize(socket_dir:, total_workers:)
18
25
  @socket_dir = socket_dir
19
26
  @total_workers = total_workers
27
+ @paths = build_paths
28
+ @dead_until = Array.new([@total_workers, 0].max, 0.0)
29
+ @cursor = 0
20
30
  end
21
31
 
22
32
  def notify_all
23
- return if @total_workers <= 0
33
+ return false if @total_workers <= 0
34
+
35
+ now = monotonic_now
36
+ start = advance_cursor
24
37
 
25
- start = rand(@total_workers)
26
- @total_workers.times do |i|
27
- worker_index = ((start + i) % @total_workers) + 1
28
- return if notify_one(worker_index)
38
+ @total_workers.times do |offset|
39
+ index = (start + offset) % @total_workers
40
+ return true if notify_one(index, now)
29
41
  end
42
+
43
+ false
30
44
  end
31
45
 
32
46
  private
33
47
 
34
- def notify_one(worker_index)
35
- path = socket_path(worker_index)
36
- sock = UNIXSocket.new(path)
48
+ def build_paths
49
+ return [].freeze if @total_workers <= 0
50
+
51
+ Array.new(@total_workers) do |index|
52
+ File.join(@socket_dir, "async_bg_worker_#{index + 1}.sock").freeze
53
+ end.freeze
54
+ end
55
+
56
+ def advance_cursor
57
+ @cursor = (@cursor + 1) % @total_workers
58
+ end
59
+
60
+ def notify_one(index, now)
61
+ return false if @dead_until[index] > now
62
+
63
+ socket = UNIXSocket.new(@paths[index])
37
64
  begin
38
- sock.write_nonblock("\x01")
65
+ socket.write_nonblock(WAKE_BYTE)
39
66
  ensure
40
- sock.close rescue nil
67
+ socket.close rescue nil
41
68
  end
42
69
  true
70
+ rescue IO::WaitWritable
71
+ true
43
72
  rescue *UNAVAILABLE
73
+ mark_dead(index, now)
44
74
  false
45
75
  rescue => e
46
- Console.logger.warn(self) { "SocketNotifier#notify_one(#{worker_index}) failed: #{e.class} #{e.message}" } rescue nil
76
+ mark_dead(index, now)
77
+ Console.logger.warn(self) { "SocketNotifier#notify_one(#{index + 1}) failed: #{e.class} #{e.message}" } rescue nil
47
78
  false
48
79
  end
49
80
 
50
- def socket_path(worker_index)
51
- File.join(@socket_dir, "async_bg_worker_#{worker_index}.sock")
81
+ def mark_dead(index, now)
82
+ @dead_until[index] = now + DEAD_WORKER_TTL
52
83
  end
53
84
  end
54
85
  end