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 +4 -4
- data/CHANGELOG.md +48 -0
- data/README.md +63 -110
- 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/queue/store.rb +9 -4
- data/lib/async/background/runner.rb +27 -18
- data/lib/async/background/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f4392db5a752c9a07da3c140fedec1ad2234950ccce48d127eb7c4de188bfee
|
|
4
|
+
data.tar.gz: 31d3c8f2cc5b303361c081e95162d13e7ed04917f7ae93187abd9c3b3c175445
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
- **
|
|
8
|
-
- **
|
|
9
|
-
- **
|
|
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
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
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
|
-
##
|
|
18
|
+
## Install
|
|
23
19
|
|
|
24
20
|
```ruby
|
|
25
21
|
# Gemfile
|
|
26
22
|
gem "async-background"
|
|
27
|
-
|
|
28
|
-
#
|
|
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
|
-
|
|
29
|
+
Full setup walkthrough: schedule config, Falcon integration, Docker, queue, delayed jobs.
|
|
36
30
|
|
|
37
31
|
---
|
|
38
32
|
|
|
39
|
-
## Quick
|
|
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
|
-
|
|
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
|
-
|
|
49
|
+
Schedule recurring jobs in `config/schedule.yml`:
|
|
64
50
|
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
###
|
|
90
|
+
### Other gotchas
|
|
91
91
|
|
|
92
|
-
SQLite connections
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
-
|
|
168
|
-
entry
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
@
|
|
208
|
+
@queue_waker&.signal
|
|
200
209
|
break unless running?
|
|
201
210
|
end
|
|
202
211
|
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.
|
|
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-
|
|
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
|