async-background 0.6.0 → 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 +11 -0
- data/README.md +63 -110
- data/lib/async/background/queue/socket_notifier.rb +10 -10
- data/lib/async/background/queue/store.rb +9 -4
- data/lib/async/background/runner.rb +10 -7
- data/lib/async/background/version.rb +1 -1
- metadata +2 -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,16 @@
|
|
|
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
|
+
|
|
3
14
|
## 0.6.0
|
|
4
15
|
|
|
5
16
|
### Breaking Changes
|
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
|
|
|
@@ -6,13 +6,11 @@ module Async
|
|
|
6
6
|
module Background
|
|
7
7
|
module Queue
|
|
8
8
|
class SocketNotifier
|
|
9
|
-
# Errors that indicate a worker is unavailable - silently skip
|
|
9
|
+
# Errors that indicate a worker is unavailable - silently skip and try the next.
|
|
10
10
|
UNAVAILABLE = [
|
|
11
11
|
Errno::ENOENT, # Socket file doesn't exist (worker hasn't started yet)
|
|
12
12
|
Errno::ECONNREFUSED, # File exists but no one listening (worker died)
|
|
13
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
14
|
Errno::ECONNRESET # Connection reset by peer
|
|
17
15
|
].freeze
|
|
18
16
|
|
|
@@ -22,8 +20,12 @@ module Async
|
|
|
22
20
|
end
|
|
23
21
|
|
|
24
22
|
def notify_all
|
|
25
|
-
|
|
26
|
-
|
|
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)
|
|
27
29
|
end
|
|
28
30
|
end
|
|
29
31
|
|
|
@@ -37,14 +39,12 @@ module Async
|
|
|
37
39
|
ensure
|
|
38
40
|
sock.close rescue nil
|
|
39
41
|
end
|
|
42
|
+
true
|
|
40
43
|
rescue *UNAVAILABLE
|
|
41
|
-
|
|
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
|
|
44
|
+
false
|
|
45
45
|
rescue => e
|
|
46
|
-
# Unexpected error - log but don't crash the enqueue operation
|
|
47
46
|
Console.logger.warn(self) { "SocketNotifier#notify_one(#{worker_index}) failed: #{e.class} #{e.message}" } rescue nil
|
|
47
|
+
false
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def socket_path(worker_index)
|
|
@@ -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
|
|
|
@@ -170,13 +170,16 @@ module Async
|
|
|
170
170
|
if entry.running
|
|
171
171
|
logger.warn('Async::Background') { "#{entry.name}: skipped, previous run still active" }
|
|
172
172
|
metrics.job_skipped(entry)
|
|
173
|
-
|
|
174
|
-
entry
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
180
183
|
end
|
|
181
184
|
|
|
182
185
|
entry.reschedule(monotonic_now)
|
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.6.
|
|
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
|