async-background 0.5.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/CHANGELOG.md +37 -0
- data/lib/async/background/queue/client.rb +1 -1
- data/lib/async/background/queue/notifier.rb +2 -0
- data/lib/async/background/queue/socket_notifier.rb +56 -0
- data/lib/async/background/queue/socket_waker.rb +118 -0
- data/lib/async/background/runner.rb +17 -11
- data/lib/async/background/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b7e4015afbfcd9ee8291c28caa34e69de838f283fd0d6374a81cc0d53a87b90d
|
|
4
|
+
data.tar.gz: b0333ec4f4626895e01c2b77da17abb569c912e6449def849fa8e0918fb06a58
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 245fa669ebf1573e37770eb16f86904921cbe0f0effb40099c613bda9732e4e8732854f24cdf84421efa877d69e25271544bc279bf5e35ff3c7e364cd8689252
|
|
7
|
+
data.tar.gz: aff2eeba3e4793b4d5e686fb5bf3f27c185f61492aee837d7100a9dde35c3d97cf904c0c037c4dacd1655dd0da49b9f556d5003620e0e0f2c256735539805f29
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
### Breaking Changes
|
|
6
|
+
- **Queue notification system completely rewritten** — replaced pipe-based `Notifier` with Unix domain socket-based architecture
|
|
7
|
+
- `Runner` now takes `queue_socket_dir:` parameter instead of `queue_notifier:`
|
|
8
|
+
- Removed `Notifier#for_producer!` and `Notifier#for_consumer!` — no longer needed
|
|
9
|
+
- `Client#push` now calls `notifier.notify_all` instead of `notifier.notify`
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
- **Unix domain socket-based notifications** — solves all cross-process notification problems
|
|
13
|
+
- New `SocketWaker` class (consumer-side) — each worker listens on its own Unix socket (`/tmp/queue/sockets/async_bg_worker_N.sock`)
|
|
14
|
+
- New `SocketNotifier` class (producer-side) — connects to all worker sockets to broadcast wake-ups
|
|
15
|
+
- **Cross-process wake-up now works correctly** — web workers → background workers, background workers → background workers
|
|
16
|
+
- **Fork-safe by design** — no shared file descriptors, each process creates its own socket after fork
|
|
17
|
+
- **Resilient to restarts** — stale socket cleanup on worker startup, graceful degradation if worker unavailable
|
|
18
|
+
- **Sub-100µs latency** — typical wake-up time 30-80µs vs previous 5-second polling fallback
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
- **CRITICAL: Notifier bug in recommended setup** — the old pipe-based `Notifier` was fundamentally broken in multi-fork scenarios:
|
|
22
|
+
- `for_consumer!` closed the writer end in each child process, making `Client#push → notify` fail silently with `IOError`
|
|
23
|
+
- All writes were caught by `WRITE_DROPPED` rescue block, causing jobs to use 5-second polling instead of instant wake-up
|
|
24
|
+
- Web workers had no way to notify background workers (no shared pipe after fork)
|
|
25
|
+
- The bug was masked by `WRITE_DROPPED` silently catching `IOError` — appeared to work but degraded to polling
|
|
26
|
+
- **Socket cleanup race conditions** — `SocketWaker#cleanup_stale_socket` now validates if socket is truly stale by attempting connection
|
|
27
|
+
|
|
28
|
+
### Improvements
|
|
29
|
+
- Updated `docs/GET_STARTED.md` with new socket-based setup for Falcon
|
|
30
|
+
- Added section on web worker → background worker job enqueuing with full example
|
|
31
|
+
- Changed environment variable from `QUEUE_SOCKET_PATH` to `QUEUE_SOCKET_DIR` (directory instead of single socket path)
|
|
32
|
+
- Better error handling in `SocketWaker` and `SocketNotifier` with comprehensive `UNAVAILABLE` error list
|
|
33
|
+
- Integrated with `Async::Notification` for local wake-ups (shutdown signals)
|
|
34
|
+
|
|
35
|
+
### Technical Details
|
|
36
|
+
- **Why sockets over pipes?** Pipes require shared FDs across fork boundaries. The recommended Falcon setup calls `for_consumer!` in each child, which closes the writer, breaking the notification chain. Sockets use filesystem paths — any process can connect without inherited FDs.
|
|
37
|
+
- **Performance impact:** Adding ~80µs per enqueue for 8 workers (8 socket connections) vs ~100µs for SQLite transaction = negligible overhead
|
|
38
|
+
- **Graceful degradation:** If worker socket unavailable (`ENOENT`, `ECONNREFUSED`), producer silently skips — job still in database, will be picked up on next poll (5s max delay)
|
|
39
|
+
|
|
3
40
|
## 0.5.1
|
|
4
41
|
|
|
5
42
|
### Testing Infrastructure
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
|
|
5
|
+
module Async
|
|
6
|
+
module Background
|
|
7
|
+
module Queue
|
|
8
|
+
class SocketNotifier
|
|
9
|
+
# Errors that indicate a worker is unavailable - silently skip
|
|
10
|
+
UNAVAILABLE = [
|
|
11
|
+
Errno::ENOENT, # Socket file doesn't exist (worker hasn't started yet)
|
|
12
|
+
Errno::ECONNREFUSED, # File exists but no one listening (worker died)
|
|
13
|
+
Errno::EPIPE, # Connection broken during write
|
|
14
|
+
Errno::EAGAIN, # Socket buffer full - wake-up already queued
|
|
15
|
+
IO::WaitWritable, # Same as EAGAIN on some platforms
|
|
16
|
+
Errno::ECONNRESET # Connection reset by peer
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(socket_dir:, total_workers:)
|
|
20
|
+
@socket_dir = socket_dir
|
|
21
|
+
@total_workers = total_workers
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def notify_all
|
|
25
|
+
(1..@total_workers).each do |worker_index|
|
|
26
|
+
notify_one(worker_index)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def notify_one(worker_index)
|
|
33
|
+
path = socket_path(worker_index)
|
|
34
|
+
sock = UNIXSocket.new(path)
|
|
35
|
+
begin
|
|
36
|
+
sock.write_nonblock("\x01")
|
|
37
|
+
ensure
|
|
38
|
+
sock.close rescue nil
|
|
39
|
+
end
|
|
40
|
+
rescue *UNAVAILABLE
|
|
41
|
+
# Worker is unavailable - not a problem.
|
|
42
|
+
# The job is already in the database. The worker will:
|
|
43
|
+
# - Pick it up on next poll (within QUEUE_POLL_INTERVAL seconds), or
|
|
44
|
+
# - Pick it up when it starts/restarts via normal fetch loop
|
|
45
|
+
rescue => e
|
|
46
|
+
# Unexpected error - log but don't crash the enqueue operation
|
|
47
|
+
Console.logger.warn(self) { "SocketNotifier#notify_one(#{worker_index}) failed: #{e.class} #{e.message}" } rescue nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def socket_path(worker_index)
|
|
51
|
+
File.join(@socket_dir, "async_bg_worker_#{worker_index}.sock")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'async/notification'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
|
|
7
|
+
module Async
|
|
8
|
+
module Background
|
|
9
|
+
module Queue
|
|
10
|
+
class SocketWaker
|
|
11
|
+
attr_reader :path
|
|
12
|
+
|
|
13
|
+
def initialize(path)
|
|
14
|
+
@path = path
|
|
15
|
+
@server = nil
|
|
16
|
+
@notification = ::Async::Notification.new
|
|
17
|
+
@running = false
|
|
18
|
+
@accept_task = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def open!
|
|
22
|
+
cleanup_stale_socket
|
|
23
|
+
ensure_directory
|
|
24
|
+
@server = UNIXServer.new(@path)
|
|
25
|
+
File.chmod(0600, @path)
|
|
26
|
+
@running = true
|
|
27
|
+
rescue Errno::EADDRINUSE
|
|
28
|
+
raise "Socket #{@path} is already in use by another process"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def start_accept_loop(parent_task)
|
|
32
|
+
@accept_task = parent_task.async do |task|
|
|
33
|
+
while @running
|
|
34
|
+
begin
|
|
35
|
+
client = @server.accept_nonblock
|
|
36
|
+
handle_client(task, client)
|
|
37
|
+
rescue IO::WaitReadable
|
|
38
|
+
@server.wait_readable
|
|
39
|
+
rescue Errno::EBADF, IOError
|
|
40
|
+
break
|
|
41
|
+
rescue => e
|
|
42
|
+
Console.logger.error(self) { "SocketWaker accept error: #{e.class} #{e.message}" }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
rescue => e
|
|
46
|
+
Console.logger.error(self) { "SocketWaker loop crashed: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}" }
|
|
47
|
+
ensure
|
|
48
|
+
@accept_task = nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def wait(timeout: nil)
|
|
53
|
+
if timeout
|
|
54
|
+
::Async::Task.current.with_timeout(timeout) { @notification.wait }
|
|
55
|
+
else
|
|
56
|
+
@notification.wait
|
|
57
|
+
end
|
|
58
|
+
rescue ::Async::TimeoutError
|
|
59
|
+
# Timeout is normal - listener will fall back to polling
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def signal
|
|
63
|
+
@notification.signal
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def close
|
|
67
|
+
@running = false
|
|
68
|
+
if @accept_task && !@accept_task.finished?
|
|
69
|
+
@accept_task.stop rescue nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
@server&.close rescue nil
|
|
73
|
+
@server = nil
|
|
74
|
+
File.unlink(@path) rescue nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def handle_client(parent_task, client)
|
|
80
|
+
parent_task.async do
|
|
81
|
+
begin
|
|
82
|
+
loop do
|
|
83
|
+
client.read_nonblock(256)
|
|
84
|
+
rescue IO::WaitReadable
|
|
85
|
+
client.wait_readable
|
|
86
|
+
retry
|
|
87
|
+
rescue EOFError, Errno::ECONNRESET
|
|
88
|
+
break
|
|
89
|
+
end
|
|
90
|
+
rescue => e
|
|
91
|
+
Console.logger.warn(self) { "SocketWaker client handler error: #{e.class} #{e.message}" }
|
|
92
|
+
ensure
|
|
93
|
+
client.close rescue nil
|
|
94
|
+
@notification.signal
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def cleanup_stale_socket
|
|
100
|
+
return unless File.exist?(@path)
|
|
101
|
+
|
|
102
|
+
begin
|
|
103
|
+
UNIXSocket.open(@path) { |s| s.close }
|
|
104
|
+
|
|
105
|
+
raise "Socket #{@path} is already in use by another process (worker_index conflict?)"
|
|
106
|
+
rescue Errno::ECONNREFUSED, Errno::ENOENT
|
|
107
|
+
File.unlink(@path) rescue nil
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def ensure_directory
|
|
112
|
+
dir = File.dirname(@path)
|
|
113
|
+
FileUtils.mkdir_p(dir) unless File.exist?(dir)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -19,7 +19,7 @@ module Async
|
|
|
19
19
|
|
|
20
20
|
def initialize(
|
|
21
21
|
config_path:, job_count: 2, worker_index:, total_workers:,
|
|
22
|
-
|
|
22
|
+
queue_socket_dir: nil, queue_db_path: nil, queue_mmap: true
|
|
23
23
|
)
|
|
24
24
|
@logger = Console.logger
|
|
25
25
|
@worker_index = worker_index
|
|
@@ -33,7 +33,7 @@ module Async
|
|
|
33
33
|
@semaphore = ::Async::Semaphore.new(job_count)
|
|
34
34
|
@heap = build_heap(config_path)
|
|
35
35
|
|
|
36
|
-
setup_queue(
|
|
36
|
+
setup_queue(queue_socket_dir, queue_db_path, queue_mmap)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def run
|
|
@@ -46,6 +46,7 @@ module Async
|
|
|
46
46
|
|
|
47
47
|
semaphore.acquire {}
|
|
48
48
|
@queue_store&.close
|
|
49
|
+
@queue_waker&.close
|
|
49
50
|
end
|
|
50
51
|
end
|
|
51
52
|
|
|
@@ -55,7 +56,7 @@ module Async
|
|
|
55
56
|
@running = false
|
|
56
57
|
logger.info { "Async::Background: stopping gracefully" }
|
|
57
58
|
shutdown.signal
|
|
58
|
-
@
|
|
59
|
+
@queue_waker&.signal
|
|
59
60
|
end
|
|
60
61
|
|
|
61
62
|
def running?
|
|
@@ -64,35 +65,40 @@ module Async
|
|
|
64
65
|
|
|
65
66
|
private
|
|
66
67
|
|
|
67
|
-
def setup_queue(
|
|
68
|
+
def setup_queue(queue_socket_dir, queue_db_path, queue_mmap)
|
|
68
69
|
@listen_queue = false
|
|
69
|
-
return unless
|
|
70
|
+
return unless queue_socket_dir
|
|
70
71
|
|
|
71
72
|
# Lazy require — only loaded when queue is actually used
|
|
72
73
|
require_relative 'queue/store'
|
|
73
|
-
require_relative 'queue/
|
|
74
|
+
require_relative 'queue/socket_waker'
|
|
74
75
|
require_relative 'queue/client'
|
|
75
76
|
|
|
76
77
|
isolated = ENV.fetch("ISOLATION_FORKS", "").split(",").map(&:to_i)
|
|
77
78
|
return if isolated.include?(worker_index)
|
|
78
79
|
|
|
79
|
-
@listen_queue
|
|
80
|
-
@
|
|
81
|
-
@queue_store = Queue::Store.new(
|
|
80
|
+
@listen_queue = true
|
|
81
|
+
@queue_store = Queue::Store.new(
|
|
82
82
|
path: queue_db_path || Queue::Store.default_path,
|
|
83
83
|
mmap: queue_mmap
|
|
84
84
|
)
|
|
85
85
|
|
|
86
|
+
socket_path = File.join(queue_socket_dir, "async_bg_worker_#{worker_index}.sock")
|
|
87
|
+
@queue_waker = Queue::SocketWaker.new(socket_path)
|
|
88
|
+
@queue_waker.open!
|
|
89
|
+
|
|
86
90
|
recovered = @queue_store.recover(worker_index)
|
|
87
91
|
logger.info { "Async::Background queue: recovered #{recovered} stale jobs" } if recovered > 0
|
|
88
92
|
end
|
|
89
93
|
|
|
90
94
|
def start_queue_listener(task)
|
|
95
|
+
@queue_waker.start_accept_loop(task)
|
|
96
|
+
|
|
91
97
|
task.async do
|
|
92
98
|
logger.info { "Async::Background queue: listening on worker #{worker_index}" }
|
|
93
99
|
|
|
94
100
|
while running?
|
|
95
|
-
@
|
|
101
|
+
@queue_waker.wait(timeout: QUEUE_POLL_INTERVAL)
|
|
96
102
|
|
|
97
103
|
while running?
|
|
98
104
|
job = @queue_store.fetch(worker_index)
|
|
@@ -196,7 +202,7 @@ module Async
|
|
|
196
202
|
@signal_r.wait_readable
|
|
197
203
|
@signal_r.read_nonblock(256) rescue nil
|
|
198
204
|
shutdown.signal
|
|
199
|
-
@
|
|
205
|
+
@queue_waker&.signal
|
|
200
206
|
break unless running?
|
|
201
207
|
end
|
|
202
208
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: async-background
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Roman Hajdarov
|
|
@@ -102,6 +102,8 @@ files:
|
|
|
102
102
|
- lib/async/background/min_heap.rb
|
|
103
103
|
- lib/async/background/queue/client.rb
|
|
104
104
|
- lib/async/background/queue/notifier.rb
|
|
105
|
+
- lib/async/background/queue/socket_notifier.rb
|
|
106
|
+
- lib/async/background/queue/socket_waker.rb
|
|
105
107
|
- lib/async/background/queue/store.rb
|
|
106
108
|
- lib/async/background/runner.rb
|
|
107
109
|
- lib/async/background/version.rb
|