async-background 0.5.1 → 0.6.1

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: 26e459d9e86a150ff8b3126ce8899ac9beb60355e3313d5510c1c0c6dec0bfd7
4
- data.tar.gz: 93a52a9139f902e1acee7b58ea98dceadba2219537329b785f3a103009f72401
3
+ metadata.gz: 0f4392db5a752c9a07da3c140fedec1ad2234950ccce48d127eb7c4de188bfee
4
+ data.tar.gz: 31d3c8f2cc5b303361c081e95162d13e7ed04917f7ae93187abd9c3b3c175445
5
5
  SHA512:
6
- metadata.gz: 3dcfcc9089b2bb8b5b3fb01341039fa1dea32896f6e3845f137bd9e307b366b2716208d4ecb533b266cf31c3b0466535817d7dc9d6c1a0876e113519417a0512
7
- data.tar.gz: ece53f421338c16a02548754a6c1a2e8a62988659a83f4575e8ad23d01fd987958e6d3fb94360607444ff34a4dc0fb8844fdfdbca9228c2c8af09b1aec486b36
6
+ metadata.gz: 90225c64139b4ec18ed9bb72bf82161f9642f14f39f9031ad1d64ddf1b6a074bb2b7690722017054aac521fda6395ddcae755ed17fb264fc32620639a7a64219
7
+ data.tar.gz: b7ff64e05abc6e5a9a4f0122c86f4e73b2f7f170978213b307104b96bd907384a5ab16ff61db96bb50c91bb5999cf98f0cc0318601741bd3b78357b6a0302f27
data/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.1
4
+
5
+ ### Bug Fixes
6
+ - **Runner: cron jobs busy-loop on overlap skip** — when a scheduled run was skipped because the previous one was still active, the entry was re-pushed to the heap without calling `reschedule`. For cron jobs (where `interval` is `nil`), this meant `next_run_at` was never advanced to the next cron tick, causing the entry to be picked up again immediately on the next loop iteration. Skip branch now calls `entry.reschedule(monotonic_now)` like the normal path
7
+ - **Store: prepared statement not reset on fetch error** — `@fetch_stmt.reset!` was called after `execute` returned, so an exception inside `execute` left the statement in a dirty state and the next `fetch` could fail. Wrapped in `begin/ensure` to guarantee reset
8
+
9
+ ### Improvements
10
+ - **SocketNotifier: non-blocking enqueue with ring fallback** — `notify_all` no longer connects to all N worker sockets on every enqueue. `UNIXSocket.new` is a blocking, non-fiber-aware syscall, and notifying every worker blocked the Falcon reactor for N `connect()` calls on the hot HTTP enqueue path. Now wakes a single worker chosen by random offset, falling back through the ring only if the chosen worker is dead (`ECONNREFUSED` etc.). Happy path: 1 connect. Worst case (all workers down): N connects — same as before, but only when actually needed. Safe because the queue is shared in SQLite, not sharded per worker
11
+ - **SocketNotifier: cleaned up `UNAVAILABLE` error list** — removed `IO::WaitWritable` and `Errno::EAGAIN`. They implied "socket buffer full", but `write_nonblock` of a single byte to a freshly-opened connection cannot fill the kernel buffer. Listing them only misled readers
12
+ - **Store: partial index for pending lookup** — replaced `idx_jobs_status_run_at_id(status, run_at, id)` with partial index `idx_jobs_pending(run_at, id) WHERE status = 'pending'`. Smaller on disk, cheaper to update, and matches the only query that uses it (`fetch`). `done`/`failed`/`running` rows no longer occupy index pages
13
+
14
+ ## 0.6.0
15
+
16
+ ### Breaking Changes
17
+ - **Queue notification system completely rewritten** — replaced pipe-based `Notifier` with Unix domain socket-based architecture
18
+ - `Runner` now takes `queue_socket_dir:` parameter instead of `queue_notifier:`
19
+ - Removed `Notifier#for_producer!` and `Notifier#for_consumer!` — no longer needed
20
+ - `Client#push` now calls `notifier.notify_all` instead of `notifier.notify`
21
+
22
+ ### Features
23
+ - **Unix domain socket-based notifications** — solves all cross-process notification problems
24
+ - New `SocketWaker` class (consumer-side) — each worker listens on its own Unix socket (`/tmp/queue/sockets/async_bg_worker_N.sock`)
25
+ - New `SocketNotifier` class (producer-side) — connects to all worker sockets to broadcast wake-ups
26
+ - **Cross-process wake-up now works correctly** — web workers → background workers, background workers → background workers
27
+ - **Fork-safe by design** — no shared file descriptors, each process creates its own socket after fork
28
+ - **Resilient to restarts** — stale socket cleanup on worker startup, graceful degradation if worker unavailable
29
+ - **Sub-100µs latency** — typical wake-up time 30-80µs vs previous 5-second polling fallback
30
+
31
+ ### Bug Fixes
32
+ - **CRITICAL: Notifier bug in recommended setup** — the old pipe-based `Notifier` was fundamentally broken in multi-fork scenarios:
33
+ - `for_consumer!` closed the writer end in each child process, making `Client#push → notify` fail silently with `IOError`
34
+ - All writes were caught by `WRITE_DROPPED` rescue block, causing jobs to use 5-second polling instead of instant wake-up
35
+ - Web workers had no way to notify background workers (no shared pipe after fork)
36
+ - The bug was masked by `WRITE_DROPPED` silently catching `IOError` — appeared to work but degraded to polling
37
+ - **Socket cleanup race conditions** — `SocketWaker#cleanup_stale_socket` now validates if socket is truly stale by attempting connection
38
+
39
+ ### Improvements
40
+ - Updated `docs/GET_STARTED.md` with new socket-based setup for Falcon
41
+ - Added section on web worker → background worker job enqueuing with full example
42
+ - Changed environment variable from `QUEUE_SOCKET_PATH` to `QUEUE_SOCKET_DIR` (directory instead of single socket path)
43
+ - Better error handling in `SocketWaker` and `SocketNotifier` with comprehensive `UNAVAILABLE` error list
44
+ - Integrated with `Async::Notification` for local wake-ups (shutdown signals)
45
+
46
+ ### Technical Details
47
+ - **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.
48
+ - **Performance impact:** Adding ~80µs per enqueue for 8 workers (8 socket connections) vs ~100µs for SQLite transaction = negligible overhead
49
+ - **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)
50
+
3
51
  ## 0.5.1
4
52
 
5
53
  ### Testing Infrastructure
data/README.md CHANGED
@@ -1,76 +1,74 @@
1
1
  # Async::Background
2
2
 
3
- A lightweight, production-grade cron/interval scheduler for Ruby's [Async](https://github.com/socketry/async) ecosystem. Designed for [Falcon](https://github.com/socketry/falcon) but works with any Async-based application.
3
+ A lightweight cron, interval, and job-queue scheduler for Ruby's [Async](https://github.com/socketry/async) ecosystem. Built for [Falcon](https://github.com/socketry/falcon), works with any Async app.
4
4
 
5
- ## What It Does
6
-
7
- - **Cron & interval scheduling**single event loop + min-heap, scales to hundreds of jobs
8
- - **Dynamic job queue** — enqueue jobs at runtime via SQLite, pick up by background workers
9
- - **Delayed jobs** schedule jobs for future execution with `perform_in` / `perform_at` (Sidekiq-like API)
10
- - **Multi-process safe** — deterministic worker sharding via `Zlib.crc32`, no duplicate execution
11
- - **Skip overlapping** — if a job is still running when its next tick arrives, the tick is skipped
12
- - **Timeout protection** — per-job configurable timeout via `Async::Task#with_timeout`
13
- - **Startup jitter** — random delay to prevent thundering herd after restart
14
- - **Optional metrics** — shared memory performance tracking with `async-utilization`
5
+ - **Cron & interval scheduling** on a single event loop with a min-heap
6
+ - **Dynamic job queue** backed by SQLite, with delayed jobs (`perform_in` / `perform_at`)
7
+ - **Cross-process wake-ups** over Unix domain sockets web workers can enqueue and instantly wake background workers
8
+ - **Multi-process safe** — deterministic worker sharding, no duplicate execution
9
+ - **Per-job timeouts**, skip-on-overlap, startup jitter, optional metrics
15
10
 
16
11
  ## Requirements
17
12
 
18
- - **Ruby >= 3.3** — Fiber Scheduler production-ready ([why?](#why-ruby-33))
19
- - **Async ~> 2.0** Fiber Scheduler-based concurrency
20
- - **Fugit ~> 1.0** cron expression parsing
13
+ - Ruby >= 3.3
14
+ - `async ~> 2.0`, `fugit ~> 1.0`
15
+ - `sqlite3 ~> 2.0` (optional, for the job queue)
16
+ - `async-utilization ~> 0.3` (optional, for metrics)
21
17
 
22
- ## Installation
18
+ ## Install
23
19
 
24
20
  ```ruby
25
21
  # Gemfile
26
22
  gem "async-background"
27
-
28
- # Optional
29
- gem "sqlite3", "~> 2.0" # for dynamic job queue
30
- gem "async-utilization", "~> 0.3" # for metrics
23
+ gem "sqlite3", "~> 2.0" # optional
24
+ gem "async-utilization", "~> 0.3" # optional
31
25
  ```
32
26
 
33
27
  ## ➡️ [Get Started](docs/GET_STARTED.md)
34
28
 
35
- Step-by-step setup guide: schedule config, Falcon integration, Docker, dynamic queue, delayed jobs.
29
+ Full setup walkthrough: schedule config, Falcon integration, Docker, queue, delayed jobs.
36
30
 
37
31
  ---
38
32
 
39
- ## Quick Example: Job Module
40
-
41
- Include `Async::Background::Job` for a Sidekiq-like interface:
33
+ ## Quick Look
42
34
 
43
35
  ```ruby
44
36
  class SendEmailJob
45
37
  include Async::Background::Job
46
38
 
47
39
  def perform(user_id, template)
48
- user = User.find(user_id)
49
- Mailer.send(user, template)
40
+ Mailer.send(User.find(user_id), template)
50
41
  end
51
42
  end
52
43
 
53
- # Immediate execution in the queue
54
44
  SendEmailJob.perform_async(user_id, "welcome")
55
-
56
- # Execute after 5 minutes
57
45
  SendEmailJob.perform_in(300, user_id, "reminder")
58
-
59
- # Execute at a specific time
60
- SendEmailJob.perform_at(Time.new(2026, 4, 1, 9, 0, 0), user_id, "scheduled")
46
+ SendEmailJob.perform_at(Time.new(2026, 4, 1, 9), user_id, "scheduled")
61
47
  ```
62
48
 
63
- Or use the lower-level API directly:
49
+ Schedule recurring jobs in `config/schedule.yml`:
64
50
 
65
- ```ruby
66
- Async::Background::Queue.enqueue(SendEmailJob, user_id, "welcome")
67
- Async::Background::Queue.enqueue_in(300, SendEmailJob, user_id, "reminder")
68
- Async::Background::Queue.enqueue_at(Time.new(2026, 4, 1, 9, 0, 0), SendEmailJob, user_id, "scheduled")
51
+ ```yaml
52
+ sync_products:
53
+ class: SyncProductsJob
54
+ every: 60
55
+
56
+ daily_report:
57
+ class: DailyReportJob
58
+ cron: "0 3 * * *"
59
+ timeout: 120
69
60
  ```
70
61
 
62
+ | Key | Description |
63
+ |---|---|
64
+ | `class` | Job class — must include `Async::Background::Job` |
65
+ | `every` / `cron` | One of: interval in seconds, or cron expression |
66
+ | `timeout` | Max execution time in seconds (default: 30) |
67
+ | `worker` | Pin to a specific worker. Default: `crc32(name) % total_workers` |
68
+
71
69
  ---
72
70
 
73
- ## ⚠️ Important Notes
71
+ ## Gotchas
74
72
 
75
73
  ### Docker: SQLite requires a named volume
76
74
 
@@ -78,18 +76,20 @@ The SQLite database **must not** live on Docker's `overlay2` filesystem. The `ov
78
76
 
79
77
  ```yaml
80
78
  # docker-compose.yml
81
- volumes:
82
- - queue-data:/app/tmp/queue # ← named volume, NOT overlay2
79
+ services:
80
+ app:
81
+ volumes:
82
+ - queue-data:/app/tmp/queue # ← named volume, NOT overlay2
83
83
 
84
84
  volumes:
85
85
  queue-data:
86
86
  ```
87
87
 
88
- Without this, you will get database crashes in multi-process mode. See [Get Started → Step 3](docs/GET_STARTED.md#step-3-docker) for details.
88
+ Without this, you will get database crashes in multi-process mode. See [Get Started → Step 3](docs/GET_STARTED.md#step-3-docker) for details. If you can't use a named volume, pass `queue_mmap: false` to disable mmap entirely.
89
89
 
90
- ### Fork safety
90
+ ### Other gotchas
91
91
 
92
- SQLite connections **must not** cross `fork()` boundaries. Always open connections **after** fork (inside `container.run` block), never before. The gem handles this internally via lazy `ensure_connection`, but if you create a `Queue::Store` manually for schema setup, close it before fork:
92
+ **Don't share SQLite connections across `fork()`.** The gem opens connections lazily after fork, but if you create a `Queue::Store` manually for schema setup, close it before forking:
93
93
 
94
94
  ```ruby
95
95
  store = Async::Background::Queue::Store.new(path: db_path)
@@ -97,96 +97,49 @@ store.ensure_database!
97
97
  store.close # ← before fork
98
98
  ```
99
99
 
100
- ### Clock handling
101
-
102
- The `Clock` module provides shared time helpers used across the codebase:
103
-
104
- - **`monotonic_now`** (`CLOCK_MONOTONIC`) — for in-process intervals and durations, immune to NTP drift / wall-clock jumps
105
- - **`realtime_now`** (`CLOCK_REALTIME`) — for persisted timestamps (SQLite `run_at`, `created_at`, `locked_at`)
106
-
107
- Interval jobs use monotonic clock. Cron jobs use `Time.now` because "every day at 3am" must respect real time. These are different clocks by design.
100
+ **Two clocks, on purpose.** Interval jobs use `CLOCK_MONOTONIC` (immune to NTP drift). Cron jobs use wall-clock time, because "every day at 3am" needs to mean 3am.
108
101
 
109
102
  ---
110
103
 
111
- ## Architecture
104
+ ## How it works
112
105
 
113
106
  ```
114
- schedule.yml
115
-
116
-
117
- build_heap ← parse config, validate, assign workers
118
-
119
-
120
- MinHeap<Entry> ← O(log N) push/pop, sorted by next_run_at
121
-
122
-
123
- 1 scheduler loop ← single Async task, sleeps until next entry
124
-
125
-
126
- Semaphore ← limits concurrent job execution
127
-
128
-
129
- run_job ← timeout, logging, error handling
107
+ schedule.yml ─► build_heap ─► MinHeap<Entry> ─► scheduler loop ─► Semaphore ─► run_job
130
108
  ```
131
109
 
132
- ### Queue Architecture
110
+ A single Async task sleeps until the next entry is due, then dispatches it under a semaphore that caps concurrency. Overlapping ticks are skipped and rescheduled.
111
+
112
+ The dynamic queue runs alongside it:
133
113
 
134
114
  ```
135
- Producer (web/console) Consumer (background worker)
136
-
137
-
138
- Queue::Client Queue::Store.fetch
139
- (WHERE run_at <= now)
140
- ├─ push(class, args, run_at)
141
- ├─ push_in(delay, class, args)
142
- └─ push_at(time, class, args) run_queue_job
143
-
144
- ▼ ▼
145
- Queue::Store ──── SQLite ──── Queue::Notifier
146
- (INSERT job) (jobs table) (IO.pipe wakeup)
115
+ Producer (web/console) Consumer (background worker)
116
+
117
+
118
+ Queue::Client Queue::Store#fetch
119
+ push / push_in / push_at (run_at <= now)
120
+
121
+
122
+ Queue::Store ──── SQLite (jobs) ──── SocketWaker
123
+
124
+ └───────► SocketNotifier ───────────────┘
125
+ (UNIX socket wake-up, ~80µs)
147
126
  ```
148
127
 
149
- ## Schedule Config
150
-
151
- | Key | Required | Description |
152
- |---|---|---|
153
- | `class` | yes | Must include `Async::Background::Job` |
154
- | `every` | one of | Interval in seconds between runs |
155
- | `cron` | one of | Cron expression (parsed by Fugit) |
156
- | `timeout` | no | Max execution time in seconds (default: 30) |
157
- | `worker` | no | Pin to specific worker index. If omitted — `crc32(name) % total_workers` |
158
-
159
- ## SQLite Pragmas
160
-
161
- | Pragma | Value | Why |
162
- |---|---|---|
163
- | `journal_mode` | WAL | Concurrent reads during writes |
164
- | `synchronous` | NORMAL | Safe with WAL, lower fsync overhead |
165
- | `mmap_size` | 256 MB | Fast reads ([requires proper filesystem](#docker-sqlite-requires-a-named-volume)) |
166
- | `cache_size` | 16000 pages | ~64 MB page cache |
167
- | `busy_timeout` | 5000 ms | Wait instead of failing on lock contention |
128
+ Jobs are persisted in SQLite, so a missed wake-up is never a lost job — workers also poll every 5 seconds as a safety net.
168
129
 
169
130
  ## Metrics
170
131
 
171
- When `async-utilization` gem is available, metrics are collected in shared memory (`/tmp/async-background.shm`) with lock-free updates per worker.
132
+ With `async-utilization` installed, per-worker stats land in shared memory at `/tmp/async-background.shm` with lock-free updates.
172
133
 
173
134
  ```ruby
174
135
  runner.metrics.values
175
136
  # => { total_runs: 142, total_successes: 140, total_failures: 2,
176
- # total_timeouts: 0, total_skips: 5, active_jobs: 1,
177
- # last_run_at: 1774445243, last_duration_ms: 1250 }
137
+ # total_timeouts: 0, total_skips: 5, active_jobs: 1, ... }
178
138
 
179
- # Read all workers at once (no server needed)
180
139
  Async::Background::Metrics.read_all(total_workers: 2)
181
140
  ```
182
141
 
183
- Without the gem metrics are silently disabled, zero overhead.
184
-
185
- ## Why Ruby 3.3?
186
-
187
- - Ruby 3.0 introduced Fiber Scheduler but had critical bugs
188
- - Ruby 3.2 is the first production-ready release (per Samuel Williams)
189
- - `io-event >= 1.14` (pulled by latest `async`) requires Ruby `>= 3.3`
142
+ Without the gem, metrics are silently disabled zero overhead.
190
143
 
191
144
  ## License
192
145
 
@@ -15,7 +15,7 @@ module Async
15
15
 
16
16
  def push(class_name, args = [], run_at = nil)
17
17
  id = @store.enqueue(class_name, args, run_at)
18
- @notifier&.notify
18
+ @notifier&.notify_all
19
19
  id
20
20
  end
21
21
 
@@ -34,6 +34,8 @@ module Async
34
34
  # one poll interval.
35
35
  end
36
36
 
37
+ alias notify_all notify
38
+
37
39
  def wait(timeout: nil)
38
40
  @reader.wait_readable(timeout)
39
41
  drain
@@ -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 and try the next.
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::ECONNRESET # Connection reset by peer
15
+ ].freeze
16
+
17
+ def initialize(socket_dir:, total_workers:)
18
+ @socket_dir = socket_dir
19
+ @total_workers = total_workers
20
+ end
21
+
22
+ def notify_all
23
+ return if @total_workers <= 0
24
+
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)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def notify_one(worker_index)
35
+ path = socket_path(worker_index)
36
+ sock = UNIXSocket.new(path)
37
+ begin
38
+ sock.write_nonblock("\x01")
39
+ ensure
40
+ sock.close rescue nil
41
+ end
42
+ true
43
+ rescue *UNAVAILABLE
44
+ false
45
+ rescue => e
46
+ Console.logger.warn(self) { "SocketNotifier#notify_one(#{worker_index}) failed: #{e.class} #{e.message}" } rescue nil
47
+ false
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
@@ -21,7 +21,7 @@ module Async
21
21
  locked_by INTEGER,
22
22
  locked_at REAL
23
23
  );
24
- CREATE INDEX IF NOT EXISTS idx_jobs_status_run_at_id ON jobs(status, run_at, id);
24
+ CREATE INDEX IF NOT EXISTS idx_jobs_pending ON jobs(run_at, id) WHERE status = 'pending';
25
25
  SQL
26
26
 
27
27
  MMAP_SIZE = 268_435_456
@@ -72,9 +72,14 @@ module Async
72
72
  ensure_connection
73
73
  now = realtime_now
74
74
  @db.execute("BEGIN IMMEDIATE")
75
- results = @fetch_stmt.execute(worker_id, now, now)
76
- row = results.first
77
- @fetch_stmt.reset!
75
+
76
+ begin
77
+ results = @fetch_stmt.execute(worker_id, now, now)
78
+ row = results.first
79
+ ensure
80
+ @fetch_stmt.reset! rescue nil
81
+ end
82
+
78
83
  @db.execute("COMMIT")
79
84
  return unless row
80
85
 
@@ -19,7 +19,7 @@ module Async
19
19
 
20
20
  def initialize(
21
21
  config_path:, job_count: 2, worker_index:, total_workers:,
22
- queue_notifier: nil, queue_db_path: nil, queue_mmap: true
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(queue_notifier, queue_db_path, queue_mmap)
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
- @queue_notifier&.notify
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(queue_notifier, queue_db_path, queue_mmap)
68
+ def setup_queue(queue_socket_dir, queue_db_path, queue_mmap)
68
69
  @listen_queue = false
69
- return unless queue_notifier
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/notifier'
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 = true
80
- @queue_notifier = queue_notifier
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
- @queue_notifier.wait(timeout: QUEUE_POLL_INTERVAL)
101
+ @queue_waker.wait(timeout: QUEUE_POLL_INTERVAL)
96
102
 
97
103
  while running?
98
104
  job = @queue_store.fetch(worker_index)
@@ -164,13 +170,16 @@ module Async
164
170
  if entry.running
165
171
  logger.warn('Async::Background') { "#{entry.name}: skipped, previous run still active" }
166
172
  metrics.job_skipped(entry)
167
- else
168
- entry.running = true
169
- semaphore.async do |job_task|
170
- run_job(job_task, entry)
171
- ensure
172
- entry.running = false
173
- end
173
+ entry.reschedule(monotonic_now)
174
+ heap.replace_top(entry)
175
+ next
176
+ end
177
+
178
+ entry.running = true
179
+ semaphore.async do |job_task|
180
+ run_job(job_task, entry)
181
+ ensure
182
+ entry.running = false
174
183
  end
175
184
 
176
185
  entry.reschedule(monotonic_now)
@@ -196,7 +205,7 @@ module Async
196
205
  @signal_r.wait_readable
197
206
  @signal_r.read_nonblock(256) rescue nil
198
207
  shutdown.signal
199
- @queue_notifier&.notify
208
+ @queue_waker&.signal
200
209
  break unless running?
201
210
  end
202
211
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Async
4
4
  module Background
5
- VERSION = '0.5.1'
5
+ VERSION = '0.6.1'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-background
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Hajdarov
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-07 00:00:00.000000000 Z
11
+ date: 2026-04-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async
@@ -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