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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7e4015afbfcd9ee8291c28caa34e69de838f283fd0d6374a81cc0d53a87b90d
4
- data.tar.gz: b0333ec4f4626895e01c2b77da17abb569c912e6449def849fa8e0918fb06a58
3
+ metadata.gz: 0f4392db5a752c9a07da3c140fedec1ad2234950ccce48d127eb7c4de188bfee
4
+ data.tar.gz: 31d3c8f2cc5b303361c081e95162d13e7ed04917f7ae93187abd9c3b3c175445
5
5
  SHA512:
6
- metadata.gz: 245fa669ebf1573e37770eb16f86904921cbe0f0effb40099c613bda9732e4e8732854f24cdf84421efa877d69e25271544bc279bf5e35ff3c7e364cd8689252
7
- data.tar.gz: aff2eeba3e4793b4d5e686fb5bf3f27c185f61492aee837d7100a9dde35c3d97cf904c0c037c4dacd1655dd0da49b9f556d5003620e0e0f2c256735539805f29
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, 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
 
@@ -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
- (1..@total_workers).each do |worker_index|
26
- notify_one(worker_index)
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
- # 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
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 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
 
@@ -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
- else
174
- entry.running = true
175
- semaphore.async do |job_task|
176
- run_job(job_task, entry)
177
- ensure
178
- entry.running = false
179
- 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
180
183
  end
181
184
 
182
185
  entry.reschedule(monotonic_now)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Async
4
4
  module Background
5
- VERSION = '0.6.0'
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.6.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-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