async-background 1.0.0 → 1.0.2

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.
data/CHANGELOG.md CHANGED
@@ -1,358 +1,487 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 1.0.2
4
+
5
+ Queue maintenance bug fix plus profiler-driven work on the hot paths that
6
+ actually showed up in StackProf: the sqlite3 statement wrapper, transaction
7
+ control, and the socket notifier.
8
+
9
+ ### Fixed
10
+
11
+ - `Store#cleanup_finished_jobs` tested `@db.changes` after running *both*
12
+ DELETEs. `sqlite3_changes()` reports only the most recent statement, so the
13
+ incremental-vacuum decision saw the failed-job count alone and ignored every
14
+ deleted done-job. On a busy queue, where done-jobs vastly outnumber failed
15
+ ones, the vacuum effectively never ran and the database file grew without
16
+ bound. Both counts are now summed.
17
+ - `PRAGMA incremental_vacuum` ran without a page limit, releasing every free
18
+ page in a single blocking call — an unbounded reactor stall proportional to
19
+ the accumulated free list. Now capped at 64 pages per call
20
+ (`SQL::INCREMENTAL_VACUUM_PAGES`).
21
+ - `SocketWaker` signalled its notification only from the `ensure` block, so a
22
+ wake-up was really driven by the client disconnecting rather than by the
23
+ wake byte arriving. It happened to work because `SocketNotifier` closes
24
+ immediately after writing, but it made the protocol depend on a disconnect.
25
+ The signal now fires for every byte received; the `ensure` still signals so a
26
+ disconnect racing with a read cannot drop a wake-up.
27
+
28
+ ### Performance
29
+
30
+ Measured on the `enqueue_stress` CI scenario (2.1M inserts / 30s, two producer
31
+ processes). `SQLite3::Statement#step` accounts for ~82% of producer wall time
32
+ and is irreducible; the changes below target what surrounded it.
33
+
34
+ - `Store#enqueue` and `Store#fetch` bind and step their prepared statements
35
+ directly instead of calling `Statement#execute`. The wrapper built a splat
36
+ array, ran `Array#flatten` over it and allocated a `ResultSet` — roughly 8%
37
+ of producer wall time, and `Array#flatten` alone was 5.8% of all object
38
+ allocations. Behaviour is unchanged: `execute` only steps when
39
+ `column_count == 0`, which is exactly what the INSERT path needed, and the
40
+ `UPDATE ... RETURNING` fetch still consumes a single row.
41
+ - `SocketNotifier` caches its socket paths at construction. It previously ran
42
+ `File.join` plus a string interpolation on every enqueue attempt —
43
+ 13.3% of allocations in the normal scenario.
44
+ - `SocketNotifier` remembers unreachable workers for `DEAD_WORKER_TTL` (5s)
45
+ instead of reconnecting to them on every enqueue. Because the scan started at
46
+ a random index, a dead worker was re-probed indefinitely and each attempt
47
+ raised and swallowed an `Errno`; `SystemCallError#initialize` alone was 6.8%
48
+ of allocations. A worker that starts during the TTL window is still picked up
49
+ by the listener's own polling fallback.
50
+ - `SocketNotifier` rotates its scan start with a cursor rather than calling
51
+ `rand` per enqueue, which also spreads wake-ups deterministically.
52
+ - `Errno::EAGAIN` on the wake byte (`IO::WaitWritable`) is now treated as
53
+ success rather than falling through to the generic rescue and logging a
54
+ warning: a full send buffer means the worker already has unread wake-ups.
55
+ - `Store#transaction` runs `BEGIN IMMEDIATE`, `COMMIT` and `ROLLBACK` as
56
+ prepared statements. They previously went through `Database#execute`, which
57
+ compiles a fresh `Statement` and builds a `ResultSet` on every call: 26
58
+ allocations per BEGIN+COMMIT pair against 2 prepared, and 6x the wall time
59
+ over 30k pairs. Every `fetch` paid this twice, and it was 11.8% of worker
60
+ allocations in the object profile.
61
+ - `mark_started!`, `complete`, `fail`, `retry_job!`, `recover`,
62
+ `stored_options_for`, `lease_alive?`, `next_pending_run_at` and the two
63
+ cleanup DELETEs now bind and step directly, like `enqueue` and `fetch`
64
+ already did. `Statement#execute` is gone from the Store entirely;
65
+ `Database#execute` remains only on the cold pragma path. This is what was
66
+ still leaving `Array#flatten` at 4.9% of worker allocations and
67
+ `Statement#execute` at 10.8% of worker wall time.
68
+ - Combined effect on a full job cycle (enqueue omitted; fetch + mark_started +
69
+ complete, 8000 jobs, measured twice): **49 -> 18 allocations per job**, wall
70
+ time down roughly 10-20% depending on run.
71
+ - `@db.changes` and `@db.last_insert_row_id` are read after the statement is
72
+ reset; both were verified to survive `sqlite3_reset()`, so the claim-token
73
+ lease checks behave exactly as before (valid token true, stale token false,
74
+ repeat completion false).
75
+
76
+ ### Notes
77
+
78
+ - `SocketNotifier` still opens a fresh connection per notification. This is
79
+ the largest remaining win and it is paid on both sides: 8.5% of producer wall
80
+ time in the scenario where workers are actually up, plus 16.4% of worker
81
+ allocations, because every connection makes `SocketWaker#handle_client` spawn
82
+ a fresh `Async::Task` and fiber. Persistent connections require the
83
+ `SocketWaker` fix above to be deployed on every worker first — a 1.0.2 producer holding a connection open
84
+ to a 1.0.1 worker would never wake it, silently degrading queue latency to
85
+ the 5s poll interval. Deferred until 1.0.2 is the deployed floor.
86
+ - No schema change; `Schema::VERSION` is untouched and 1.0.2 is a drop-in
87
+ replacement for 1.0.1 on an existing database.
88
+
89
+ ## 1.0.1
90
+
91
+ Dashboard security headers and a fiber-native rewrite of the SSE stream.
92
+
93
+ ### Security
94
+
95
+ - HTML shell now ships a strict CSP and `X-Frame-Options: DENY`. The CSP is
96
+ `default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'
97
+ data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none';
98
+ form-action 'none'`.
99
+ - Every response carries `X-Content-Type-Options: nosniff`,
100
+ `Referrer-Policy: no-referrer`, and `Cross-Origin-Resource-Policy:
101
+ same-origin`.
102
+ - `401` and `404` responses are now JSON like every other error — minor
103
+ breaking change for clients that parsed the previous `text/plain` body.
104
+ - `HEAD` is accepted on every route that accepts `GET` (RFC 9110 §9.3.2).
105
+ `HEAD /api/stream` returns the SSE headers without opening a stream.
106
+ - `mount_path` validation is stricter: must start with `/`, no trailing
107
+ slash, no control characters, no whitespace.
108
+ - New `Configuration#logger` (any object responding to `#warn` / `#error`).
109
+ When set, auth-callable exceptions, internal `rescue StandardError`, and
110
+ SSE stream errors are surfaced instead of being silently swallowed.
111
+
112
+ ### Architecture: fiber-native SSE
113
+
114
+ - Removed the per-process monitor `Thread.new` and per-subscription
115
+ `Mutex` + `ConditionVariable`. The dashboard web subsystem spawns **zero**
116
+ native threads of its own.
117
+ - `EventHub` is now a tiny mutex-guarded frame cache keyed by `data_version`.
118
+ No subscriptions, no monitor.
119
+ - `Stream#each` runs the entire poll-and-yield loop inside the per-request
120
+ Falcon fiber: `sleep` (fiber-aware), read `PRAGMA data_version`, yield the
121
+ `overview` frame when the version moves, yield a heartbeat otherwise.
122
+ - Each tab now polls `data_version` independently. At default 0.5 s and
123
+ realistic operator fan-out that's well under 50 SQLite header reads /
124
+ second per process. JSON rendering still happens at most once per version
125
+ thanks to the hub cache.
126
+ - Shutdown is clean: `App#close` marks the hub closed, the next poll raises
127
+ `ClosedError`, the loop exits. No thread to join, no `Subscription` to
128
+ unsubscribe.
129
+
130
+
131
+
132
+  
4
133
 
5
- ### Dashboard SSE hardening
134
+ ## 1.0.0
6
135
 
7
- - Make SSE the default dashboard transport. `:polling` remains an explicit compatibility fallback.
8
- - Replace one `PRAGMA data_version` loop **per browser connection** with one shared `EventHub` watcher per Rack app process while at least one dashboard tab is connected. It fans complete overview snapshots to every SSE body through latest-value mailboxes, so slow tabs do not accumulate unbounded event queues.
9
- - Use complete snapshots on connect and after a change rather than a replay log: reconnecting EventSource clients cannot miss the current queue state.
10
- - Coalesce client-side list refreshes after a burst of changes, cancel stale list fetches on tab switches, and retain previous pages when `Load more` is used.
11
- - Add SSE retry control, 25-second heartbeat frames, `no-cache, no-transform`, and `X-Accel-Buffering: no`. Remove the hop-by-hop `Connection` response header.
12
- - Fingerprint asset URLs and cache immutable assets by digest, so a dashboard deploy cannot leave a browser on incompatible HTML/JS/CSS.
13
- - Add coverage for event fan-out, latest-value coalescing, clean stream shutdown, forced overview reads, and the SSE configuration constraints.
136
+ First stable release. The queue execution contract from 0.7.2 (claim-token
137
+ CAS, lifecycle columns, barrier-based shutdown drain, per-status partial
138
+ indexes, versioned migrations) is now considered the public API.
139
+
140
+ ### Dashboard
141
+
142
+ A read-only Rack-mountable UI under `require 'async/background/web'`:
143
+ vanilla HTML / CSS / JS, no framework, no npm.
144
+
145
+ - Endpoints: `GET /`, `GET /assets/{app.css,app.js}`,
146
+ `GET /api/{overview,executing,claimed,pending,done,failed,metrics,config,stream}`.
147
+ - The read path runs through `Async::Background::Web::Snapshot`, which
148
+ opens SQLite with `file:?mode=ro`, wraps a `Mutex` around a single shared
149
+ connection, and uses one read transaction per endpoint plus a TTL'd
150
+ overview cache (`counts_cache_ttl`, default 3 s).
151
+ - Distinguishes **executing** (`status='running' AND started_at IS NOT
152
+ NULL`) from **claimed** (`status='running' AND started_at IS NULL`).
153
+ - Cursor pagination for `done` / `failed` / `pending` using
154
+ `(finished_at, id)` / `(run_at, id)` tuples. Stable on ties.
155
+ - Args hidden by default (`expose_args: false`); when enabled, content
156
+ runs through `redact_args`. All user content rendered through
157
+ `textContent`, never `innerHTML`.
158
+ - `auth` is **mandatory**. `Configuration#validate!` rejects an
159
+ unconfigured `auth`. There is no permissive default — a falsey result
160
+ returns `401`.
161
+
162
+ ### SSE transport
163
+
164
+ The dashboard uses a single long-lived `text/event-stream` connection per
165
+ browser tab instead of polling `/api/overview` every 2 seconds. One HTTP
166
+ connection per tab regardless of how long it stays open.
167
+
168
+ - `Configuration#transport` accepts `:sse` (default) or `:polling`. Anything
169
+ else raises `ConfigurationError`. The chosen transport is exposed at
170
+ `/api/config` so the client knows which path to take.
171
+ - Client opens `EventSource(mount_path + '/api/stream')` once; the server
172
+ pushes an `overview` event when `PRAGMA data_version` changes and a
173
+ `:keepalive` comment frame every 25 s.
174
+ - Server-supplied 5 s reconnect delay; each reconnect begins from a full
175
+ current snapshot (no event log).
176
+ - Asset URLs are fingerprinted and cached immutably by digest, so a
177
+ dashboard deploy can't leave a browser on incompatible HTML / JS / CSS.
178
+
179
+ ### Server compatibility for SSE
180
+
181
+ SSE holds the response open for the lifetime of the dashboard tab.
182
+
183
+ - **Falcon** — recommended. Handles long-lived connections via fibers.
184
+ - **Puma** — works. Each open tab holds one worker thread for its lifetime;
185
+ fine for a handful of operators, problematic if many concurrent operators
186
+ would starve the worker pool.
187
+ - **Unicorn** — doesn't work. Blocking worker model can't hold long-lived
188
+ connections without timeouts. Stay on `:polling`.
189
+
190
+ See the picture in the README for what each server is actually holding.
14
191
 
15
- ## 1.1.0
192
+ ### Configuration
16
193
 
17
- Server-Sent Events transport for the dashboard. Replaces HTTP polling as the recommended transport.
194
+ ```ruby
195
+ require 'async/background/web'
18
196
 
19
- ### Added
197
+ Async::Background::Queue::Store.prepare_dashboard!(path: '/var/lib/app/queue.db')
20
198
 
21
- - **SSE transport for the dashboard.** Set `c.transport = :sse` in `Async::Background::Web.configure` and the dashboard now uses a single long-lived `text/event-stream` connection per browser tab instead of polling `/api/overview` every 2 seconds. The browser opens `EventSource(mount_path + '/api/stream')` once; the server pushes an `overview` event whenever `PRAGMA data_version` changes, and a `:keepalive` comment frame every 30 seconds. Result: 1 HTTP connection per dashboard tab regardless of how long it stays open, instead of 30 req/min per tab.
199
+ Async::Background::Web.configure do |c|
200
+ c.queue_path = '/var/lib/app/queue.db'
201
+ c.auth = ->(env) { env['warden'].user&.admin? }
202
+ c.expose_args = false
203
+ c.metrics_path = '/run/app/async-background.shm'
204
+ c.total_workers = 4
205
+ c.counts_cache_ttl = 3.0
206
+ c.poll_interval_ms = 2000
207
+ c.list_limit = 50
208
+ c.mount_path = '/admin/background'
209
+ c.title = 'My App background jobs'
210
+ end
22
211
 
23
- - New module `Async::Background::Web::Stream` implements the event loop as a Rack streaming body (responds to `#each`, yields SSE frames). Holds no state across requests.
24
- - New route `GET /api/stream` returns `200 text/event-stream` when `transport == :sse`, `404` otherwise. Subject to the same auth gate as every other endpoint.
25
- - New `Response.sse(body)` helper sets the correct headers including `x-accel-buffering: no` (disables nginx buffering for the streaming response).
26
- - JS client (`assets.rb`) detects `state.config.transport === 'sse'` at boot and chooses `EventSource` over `setInterval(tick, ...)`. Both transports share the same `applyOverview()` and `refreshActiveList()` handlers, so the UI behaves identically.
212
+ run Async::Background::Web.app
213
+ ```
27
214
 
28
- - **`Configuration#transport`** with default `:polling` (backward compatible) and accepted values `:polling | :sse`. Validation rejects anything else with `ConfigurationError`. The chosen transport is exposed at `/api/config` so the client knows which path to take.
215
+ ### Dependencies
29
216
 
30
- ### Migration
217
+ `rack` is optional. Required only when `require 'async/background/web'` is
218
+ loaded. Core gem and worker processes don't require it.
31
219
 
32
- Existing deployments keep working unchanged. To opt into SSE:
220
+ ### Breaking changes from 0.7.x
33
221
 
34
- ```ruby
35
- Async::Background::Web.configure do |c|
36
- c.queue_path = ...
37
- c.auth = ->(env) { ... }
38
- c.transport = :sse
39
- end
40
- ```
222
+ None beyond what 0.7.2 already shipped. The 1.0 line locks the existing
223
+ contract:
41
224
 
42
- ### Server compatibility note
225
+ - `Queue::Store#fetch` returns `claim_token` in the result hash.
226
+ - All terminal `Queue::Store` methods (`complete`, `fail`, `retry_or_fail`)
227
+ require the `claim_token:` kwarg and return CAS success boolean /
228
+ `:retried` / `:failed` / `nil`.
229
+ - Schema is versioned via `PRAGMA user_version`. Use
230
+ `Queue::Store.migrate!(path:)` to upgrade. Use
231
+ `Queue::Store.prepare_dashboard!(path:)` from the dashboard process to
232
+ add dashboard-only indexes.
43
233
 
44
- SSE holds the request thread/fiber open for the lifetime of the dashboard tab. **Recommended for Falcon**, which handles long-lived connections natively via fibers. **Puma works** but each open dashboard tab holds one worker thread for its lifetime — fine for an admin dashboard with a handful of operators, problematic if many concurrent operators would starve the worker pool. **Unicorn does not work** for SSE since its blocking worker model can't hold long-lived connections without timeouts; stay on `:polling` there.
45
234
 
46
- ### Backend-side polling
47
235
 
48
- The server still polls `PRAGMA data_version` every 500ms inside the snapshot connection to detect changes. This is a connection-local PRAGMA call, microseconds, never hits a rate limiter. Client-facing transport is push.
236
+  
49
237
 
50
- ### Tests
238
+ ## 0.7.2
51
239
 
52
- - New `spec/async/background/web/stream_spec.rb` — covers overview event on data_version change, heartbeat after idle, graceful exit on `EPIPE`/`IOError`, error frame on `ClosedError`/`UnavailableError`.
53
- - Extended `spec/async/background/web/app_spec.rb` — `/api/stream` returns 404 on polling default, 200 text/event-stream on `:sse`, 401 without auth.
54
- - Extended `spec/async/background/web/configuration_spec.rb` — accepts `:sse`, rejects unknown transports.
240
+ Harden queue execution, retries, shutdown, and metrics. Adds schema v1,
241
+ optional dashboard indexes, and a faster enqueue path.
55
242
 
56
- ## 1.0.0
57
243
 
58
- First stable release. The queue execution contract from 0.7.2 (claim-token CAS, lifecycle columns, barrier-based shutdown drain, per-status partial indexes, versioned migrations) is now considered the public API.
59
244
 
60
- ### Features
245
+  
61
246
 
62
- - **Web dashboard.** Rack-mountable read-only UI under `require 'async/background/web'`. Vanilla HTML/CSS/JS, no JS framework, no npm.
63
- - Endpoints: `GET /`, `GET /assets/app.css`, `GET /assets/app.js`, `GET /api/overview`, `GET /api/executing`, `GET /api/claimed`, `GET /api/pending`, `GET /api/done`, `GET /api/failed`, `GET /api/metrics`, `GET /api/config`.
64
- - Default transport is JSON polling (`poll_interval_ms`, default 2000). SSE adapter for Falcon is intentionally deferred to a later release; the dashboard already coalesces work via a shared overview cache, so adding SSE later is a backward-compatible change.
65
- - Read path runs through `Async::Background::Web::Snapshot`, which opens SQLite with `file:?mode=ro`, wraps a `Mutex` around a single shared connection, and uses one read transaction per endpoint and caches each overview as one consistent snapshot.
66
- - Distinguishes `Executing` (`status='running' AND started_at IS NOT NULL`) from `Claimed` (`status='running' AND started_at IS NULL`).
67
- - Overview snapshot cache for `counts_cache_ttl` seconds (default 3.0) so a busy queue does not turn the dashboard into a hot reader.
68
- - Cursor pagination for `done`/`failed`/`pending` using `(finished_at, id)` / `(run_at, id)` tuples. Stable on ties.
69
- - Args hidden by default (`expose_args: false`); when enabled, content runs through `redact_args`. All user content rendered through `textContent`, never `innerHTML`.
70
- - Auth hook is **mandatory**. `Configuration#validate!` rejects an unconfigured `auth`. There is no permissive default.
247
+ ## 0.7.1
71
248
 
72
- - Add the optional Rack dashboard for the SQLite queue.
73
- - Make sqlite3 an explicit runtime dependency for queue/dashboard installs.
249
+ `Store` exposes three SQLite tuning knobs via `StoreOptions`, validated at
250
+ construction time so misconfigurations fail fast:
74
251
 
75
- ### Configuration
252
+ - `mmap` (`true` / `false`, default `true`) — memory-mapped I/O.
253
+ - `synchronous` (`:normal` / `:full` / `:extra`, default `:normal`) —
254
+ durability vs throughput.
255
+ - `wal_autocheckpoint` (`Integer` in `100..10_000`, default `1_000`) — WAL
256
+ checkpoint frequency in pages.
76
257
 
77
- ```ruby
78
- require 'async/background/web'
258
+ **Breaking change.** `Store.new(path:, mmap:)` → `Store.new(path:, options:
259
+ { mmap: ... })`. The direct `mmap:` kwarg is removed in favor of the
260
+ unified `options:` hash. Update any call site that constructs `Store`
261
+ manually.
79
262
 
80
- Async::Background::Queue::Store.prepare_dashboard!(path: '/var/lib/app/queue.db')
263
+ See [Get Started → Store tuning](docs/GET_STARTED.md#appendix-store-tuning)
264
+ for trade-offs.
81
265
 
82
- Async::Background::Web.configure do |c|
83
- c.queue_path = '/var/lib/app/queue.db'
84
- c.auth = ->(env) { env['warden'].user&.admin? }
85
- c.expose_args = false
86
- c.metrics_path = '/run/app/async-background.shm'
87
- c.total_workers = 4
88
- c.counts_cache_ttl = 3.0
89
- c.poll_interval_ms = 2000
90
- c.list_limit = 50
91
- c.mount_path = '/admin/background'
92
- c.title = 'My App background jobs'
266
+
267
+
268
+  
269
+
270
+ ## 0.6.2
271
+
272
+ Queue jobs gain a **configurable timeout** at three levels — call-site
273
+ `options:`, class-level `.options`, default 120 s — merged at enqueue time
274
+ so the runner just reads the final value from the payload:
275
+
276
+ ```ruby
277
+ class HeavyImportJob
278
+ include Async::Background::Job
279
+ options timeout: 600
93
280
  end
94
281
 
95
- run Async::Background::Web.app
282
+ HeavyImportJob.perform_async(user_id, options: { timeout: 120 }) # wins
96
283
  ```
97
284
 
98
- ### Dependencies
99
-
100
- - `rack` is an optional dependency. Required only when `require 'async/background/web'` is loaded. Core gem and worker processes do not require it.
285
+ Side effects: an `options TEXT` column in SQLite (added idempotently via
286
+ `ALTER TABLE … rescue nil` on existing databases), an extensible `options:`
287
+ hash across the entire enqueue chain, a `Job::Options` schema via
288
+ `Data.define` (unknown keys raise `ArgumentError`), and queue-timeout
289
+ failure logs now include the actual value (`"timed out after 120s"`).
101
290
 
102
- ### Breaking changes from 0.7.x
103
291
 
104
- None beyond what 0.7.2 already shipped. The 1.0 line locks the existing contract:
105
292
 
106
- - `Queue::Store#fetch` returns `claim_token` in the result hash.
107
- - All terminal `Queue::Store` methods (`complete`, `fail`, `retry_or_fail`) require the `claim_token:` kwarg and return CAS success boolean / `:retried` / `:failed` / `nil`.
108
- - Schema is versioned via `PRAGMA user_version`. Use `Queue::Store.migrate!(path:)` to upgrade. Use `Queue::Store.prepare_dashboard!(path:)` from the dashboard process to lazily create dashboard-only indexes (per-status partial indexes for `done` / `failed`, plus separate `executing` and `claimed` indexes).
293
+  
109
294
 
110
- ## 0.7.2
295
+ ## 0.6.1
111
296
 
112
- - Harden queue execution, retries, shutdown, and metrics.
113
- - Add schema v1, optional dashboard indexes, and a faster enqueue path.
297
+ Two scheduler fixes and one notification fast path:
114
298
 
115
- ## 0.7.1
299
+ - **Cron busy-loop on overlap skip.** When a scheduled run was skipped
300
+ because the previous one was still active, the entry was re-pushed to the
301
+ heap without `reschedule`. `next_run_at` never advanced, so the next
302
+ iteration picked it up immediately. Skip branch now calls
303
+ `entry.reschedule(monotonic_now)` like the normal path.
304
+ - **Prepared statement reset on fetch error.** `@fetch_stmt.reset!` ran
305
+ after `execute` returned, so an exception inside `execute` left the
306
+ statement dirty and the next `fetch` could fail. Wrapped in
307
+ `begin / ensure`.
308
+ - **SocketNotifier: 1 connect per enqueue.** `notify_all` no longer
309
+ connects to all N worker sockets on every enqueue. Wakes a single worker
310
+ chosen by random offset, falls back through the ring only if the chosen
311
+ worker is dead. Happy path: 1 connect; worst case (all workers down): N.
312
+ - Pending lookup now uses a partial index
313
+ `idx_jobs_pending(run_at, id) WHERE status = 'pending'`. Smaller on disk,
314
+ cheaper to update, and matches the only query that uses it.
116
315
 
117
- ### Features
118
- - **Tunable `Store` options via `StoreOptions`** — three knobs exposed for SQLite tuning, validated at construction time so misconfigurations fail fast at boot:
119
- - `mmap` (`true`/`false`, default `true`) — toggle memory-mapped I/O
120
- - `synchronous` (`:normal`/`:full`/`:extra`, default `:normal`) — durability vs throughput
121
- - `wal_autocheckpoint` (`Integer` in `100..10_000`, default `1_000`) — WAL checkpoint frequency in pages
122
316
 
123
- Range and enum validation prevent foot-guns (e.g. `wal_autocheckpoint: 100_000` would bloat WAL beyond `journal_size_limit`). See [Get Started → Store tuning](docs/GET_STARTED.md) for trade-offs of each knob
124
317
 
125
- ### Breaking changes
126
- - `Store.new(path:, mmap:)` → `Store.new(path:, options: { mmap: ... })`. Direct `mmap:` keyword argument removed in favor of the unified `options:` hash. Users who construct `Store` manually (e.g. for web-worker enqueue) need to update the call site
318
+  
127
319
 
128
- ## 0.6.2
320
+ ## 0.6.0
129
321
 
130
- ### Features
131
- - **Configurable timeout for queue jobs** — queue jobs previously used a hardcoded 30-second timeout (`DEFAULT_TIMEOUT`). Now configurable via `options` hash at two levels:
132
- ```ruby
133
- # Class-level default
134
- class HeavyImportJob
135
- include Async::Background::Job
136
- options timeout: 600
137
-
138
- def perform(user_id) = # ...
139
- end
140
-
141
- # Call-site override (wins over class-level)
142
- HeavyImportJob.perform_async(user_id, options: { timeout: 120 })
143
- ```
144
- Priority: call-site `options:` → class-level `options` → `DEFAULT_TIMEOUT` (30s). Options are merged at enqueue time so the runner simply reads the final value from the payload
145
- - **`options:` hash across the entire enqueue chain** — single extensible contract from `perform_async` through `Client` down to `Store`. Currently supports `:timeout`, designed to accommodate future keys (e.g. `:retry`) without API changes
146
- - **`Job::Options` schema via `Data.define`** — declares known option keys with types and defaults. Unknown keys raise `ArgumentError`, invalid types raise `TypeError`. No manual validation code
147
- - **`options TEXT` column in SQLite** — stores the merged options hash as JSON. Extensible without schema changes when new options are added
148
-
149
- ### Improvements
150
- - **Queue timeout logged on failure** — `run_queue_job` error log now includes actual timeout value: `"timed out after 120s"` instead of generic `"timed out"`
151
- - **Idempotent schema migration** — existing databases get `ALTER TABLE jobs ADD COLUMN options TEXT` on first connection, wrapped in `rescue nil` for safe re-runs. New databases include the column in `CREATE TABLE`
322
+ **Queue notification system rewritten.** The pipe-based `Notifier` is
323
+ replaced with a Unix-domain-socket architecture: each worker listens on its
324
+ own socket (`<dir>/async_bg_worker_N.sock`), producers broadcast wake-ups
325
+ via `SocketNotifier`. Fork-safe by design (no shared FDs), resilient to
326
+ restarts (stale-socket cleanup), and sub-100 µs wake-up latency
327
+ (30–80 µs typical).
152
328
 
153
- ## 0.6.1
329
+ **Why.** The pipe-based notifier was fundamentally broken in the
330
+ recommended multi-fork setup: `for_consumer!` closed the writer end in each
331
+ child, making `Client#push → notify` fail silently with `IOError`. All
332
+ writes hit `WRITE_DROPPED`, so the queue silently degraded to 5-second
333
+ polling.
154
334
 
155
- ### Bug Fixes
156
- - **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
157
- - **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
335
+ **Breaking changes.** `Runner` now takes `queue_socket_dir:` instead of
336
+ `queue_notifier:`. `Notifier#for_producer!` / `Notifier#for_consumer!` are
337
+ removed. `Client#push` calls `notifier.notify_all`. Environment variable
338
+ `QUEUE_SOCKET_PATH` is replaced by `QUEUE_SOCKET_DIR` (a directory now).
158
339
 
159
- ### Improvements
160
- - **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
161
- - **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
162
- - **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
163
340
 
164
- ## 0.6.0
165
341
 
166
- ### Breaking Changes
167
- - **Queue notification system completely rewritten** — replaced pipe-based `Notifier` with Unix domain socket-based architecture
168
- - `Runner` now takes `queue_socket_dir:` parameter instead of `queue_notifier:`
169
- - Removed `Notifier#for_producer!` and `Notifier#for_consumer!` — no longer needed
170
- - `Client#push` now calls `notifier.notify_all` instead of `notifier.notify`
171
-
172
- ### Features
173
- - **Unix domain socket-based notifications** — solves all cross-process notification problems
174
- - New `SocketWaker` class (consumer-side) — each worker listens on its own Unix socket (`/tmp/queue/sockets/async_bg_worker_N.sock`)
175
- - New `SocketNotifier` class (producer-side) — connects to all worker sockets to broadcast wake-ups
176
- - **Cross-process wake-up now works correctly** — web workers → background workers, background workers → background workers
177
- - **Fork-safe by design** — no shared file descriptors, each process creates its own socket after fork
178
- - **Resilient to restarts** — stale socket cleanup on worker startup, graceful degradation if worker unavailable
179
- - **Sub-100µs latency** — typical wake-up time 30-80µs vs previous 5-second polling fallback
180
-
181
- ### Bug Fixes
182
- - **CRITICAL: Notifier bug in recommended setup** — the old pipe-based `Notifier` was fundamentally broken in multi-fork scenarios:
183
- - `for_consumer!` closed the writer end in each child process, making `Client#push → notify` fail silently with `IOError`
184
- - All writes were caught by `WRITE_DROPPED` rescue block, causing jobs to use 5-second polling instead of instant wake-up
185
- - Web workers had no way to notify background workers (no shared pipe after fork)
186
- - The bug was masked by `WRITE_DROPPED` silently catching `IOError` — appeared to work but degraded to polling
187
- - **Socket cleanup race conditions** — `SocketWaker#cleanup_stale_socket` now validates if socket is truly stale by attempting connection
188
-
189
- ### Improvements
190
- - Updated `docs/GET_STARTED.md` with new socket-based setup for Falcon
191
- - Added section on web worker → background worker job enqueuing with full example
192
- - Changed environment variable from `QUEUE_SOCKET_PATH` to `QUEUE_SOCKET_DIR` (directory instead of single socket path)
193
- - Better error handling in `SocketWaker` and `SocketNotifier` with comprehensive `UNAVAILABLE` error list
194
- - Integrated with `Async::Notification` for local wake-ups (shutdown signals)
195
-
196
- ### Technical Details
197
- - **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.
198
- - **Performance impact:** Adding ~80µs per enqueue for 8 workers (8 socket connections) vs ~100µs for SQLite transaction = negligible overhead
199
- - **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)
342
+ &nbsp;
200
343
 
201
344
  ## 0.5.1
202
345
 
203
- ### Testing Infrastructure
204
- - **Comprehensive CI setup** — full Docker-based integration testing environment with `Dockerfile.ci`, `docker-compose.ci.yml`, and `Gemfile.ci`
205
- - **End-to-end scenario testing** new `ci/scenario_test.rb` validates real-world scenarios with forked workers:
206
- - Normal execution of fast/slow/failing jobs across multiple workers
207
- - Crash recovery after SIGKILL with automatic job pickup by remaining workers
208
- - No duplicate execution guarantees under worker crashes
209
- - Proper job distribution validation across worker pool
210
- - **Test fixtures** dedicated `ci/fixtures/jobs.rb` and `ci/fixtures/schedule.yml` for scenario testing
211
-
212
- ### Bug Fixes
213
- - **SQLite busy timeout** — added `PRAGMA busy_timeout = 5000` to `Queue::Store` to prevent `SQLITE_BUSY` errors under concurrent multi-process database access
214
- - **Enhanced Queue::Notifier error handling** — restructured IO error handling with clearer categorization:
215
- - `WRITE_DROPPED` for write failures (`IO::WaitWritable`, `Errno::EAGAIN`, `IOError`, `Errno::EPIPE`) — all non-fatal as job is already in store
216
- - `READ_EXHAUSTED` for read exhaustion (`IO::WaitReadable`, `EOFError`, `IOError`) — normal drain completion
217
- - Added explanatory comments for each error type and handling strategy
346
+ CI infrastructure: full Docker-based integration testing (`Dockerfile.ci`,
347
+ `docker-compose.ci.yml`, `Gemfile.ci`) plus an end-to-end scenario test that
348
+ validates forked-worker behaviornormal execution, crash recovery after
349
+ SIGKILL, no duplicate execution under crashes, proper distribution across
350
+ the pool.
351
+
352
+ Also: `PRAGMA busy_timeout = 5000` on `Queue::Store` to prevent
353
+ `SQLITE_BUSY` under concurrent multi-process access; cleaner IO error
354
+ categorization in `Queue::Notifier` (`WRITE_DROPPED` vs `READ_EXHAUSTED`)
355
+ with explanatory comments.
356
+
357
+
358
+
359
+ &nbsp;
218
360
 
219
361
  ## 0.5.0
220
362
 
221
- ### Features
222
- - **Delayed jobs** — full support for scheduling jobs in the future
223
- - `Queue::Client#push_in(delay, class_name, args)` — enqueue with delay in seconds
224
- - `Queue::Client#push_at(time, class_name, args)` — enqueue at a specific time
225
- - `Queue.enqueue_in(delay, job_class, *args)` — class-level delayed enqueue
226
- - `Queue.enqueue_at(time, job_class, *args)` — class-level scheduled enqueue
227
- - New `run_at` column in SQLite `jobs` table — jobs are only fetched when `run_at <= now`
228
- - **Job module** Sidekiq-like `include Async::Background::Job` interface
229
- - `perform_async(*args)` immediate queue execution
230
- - `perform_in(delay, *args)` — delayed execution after N seconds
231
- - `perform_at(time, *args)` — scheduled execution at a specific time
232
- - Instance-level `#perform` with class-level `perform_now` delegation
233
- - **Clock module** — shared `monotonic_now` / `realtime_now` helpers extracted into `Async::Background::Clock`, included by `Runner`, `Queue::Store`, and `Queue::Client`
234
-
235
- ### Bug Fixes
236
- - **Runner: incorrect task in `with_timeout`** — `semaphore.async { |job_task| ... }` now correctly receives the child task instead of capturing the parent `task` from the outer scope. Previously, `with_timeout` was applied to the parent task, which could cancel unrelated work
237
-
238
- ### Improvements
239
- - **Job API: `#perform` instead of `#perform_now`** — job classes now define `#perform` instance method. The class-level `perform_now` creates instance and calls `#perform`, aligning with ActiveJob / Sidekiq conventions
240
- - Updated error messages: validation failures now suggest `must include Async::Background::Job` instead of `must implement .perform_now`
241
- - `Queue::Client` — extracted private `ensure_configured!` and `resolve_class_name` methods for cleaner validation and class name resolution logic
242
- - `Queue::Notifier` — extracted `IO_ERRORS` constant (`IO::WaitReadable`, `EOFError`, `IOError`) for cleaner `rescue` in `drain`
243
- - `Queue::Store` — replaced index `idx_jobs_status_id(status, id)` with `idx_jobs_status_run_at_id(status, run_at, id)` for efficient delayed job lookups
244
- - `Queue::Store` — `fetch` SQL now uses `WHERE status = 'pending' AND run_at <= ?` with `ORDER BY run_at, id` to process jobs in scheduled order
245
- - Removed duplicated `monotonic_now` / `realtime_now` from `Runner` and `Store` — now provided by `Clock` module
246
- - Updated documentation: README (Job module examples, Queue architecture diagram, Clock section), GET_STARTED (delayed jobs guide, Job module usage, minimal queue-only example)
363
+ **Delayed jobs.** Full support for scheduling jobs in the future:
364
+
365
+ ```ruby
366
+ SomeJob.perform_in(60, *args)
367
+ SomeJob.perform_at(time, *args)
368
+ ```
369
+
370
+ Backed by a new `run_at` column in the SQLite `jobs` table — jobs are only
371
+ fetched when `run_at <= now`.
372
+
373
+ **Job module.** Sidekiq-like `include Async::Background::Job` adds
374
+ `perform_async`, `perform_in`, `perform_at`, instance-level `#perform`, and
375
+ class-level `perform_now` delegation.
376
+
377
+ **Clock module.** Shared `monotonic_now` / `realtime_now` helpers extracted
378
+ to `Async::Background::Clock` and included by `Runner`, `Queue::Store`, and
379
+ `Queue::Client`.
380
+
381
+
382
+
383
+ &nbsp;
247
384
 
248
385
  ## 0.4.5
249
386
 
250
- ### Breaking Changes
251
- - `PRAGMAS` is now a frozen lambda `PRAGMAS.call(mmap_size)` instead of a static string if you referenced this constant directly, update your code
387
+ **Fetch race condition fixed.** Wrapped `UPDATE ... RETURNING` in
388
+ `BEGIN IMMEDIATE` to prevent two workers from picking up the same job
389
+ simultaneously.
390
+
391
+ **mmap on Docker overlay2.** `overlay2` does not guarantee `write()` /
392
+ `mmap()` coherence, which corrupts the WAL under concurrent multi-process
393
+ access. mmap is now configurable via `queue_mmap: false` instead of being
394
+ hardcoded. Proper Docker setup with named volumes is documented in
395
+ [Get Started → Docker](docs/GET_STARTED.md#step-3--docker-setup).
396
+
397
+ Also: `PRAGMA optimize` on shutdown wrapped in `rescue nil`,
398
+ `PRAGMA incremental_vacuum` actually works now (`PRAGMA auto_vacuum =
399
+ INCREMENTAL` added to schema; only takes effect on new databases),
400
+ composite index `idx_jobs_status_id(status, id)` to eliminate a sort in
401
+ `fetch`. New `queue_mmap:` / `mmap:` parameters and a public
402
+ `attr_reader :queue_store` on `Runner`.
403
+
404
+ **Breaking-ish.** `PRAGMAS` is now a frozen lambda `PRAGMAS.call(mmap_size)`
405
+ instead of a static string; update any direct reference.
252
406
 
253
- ### Features
254
- - New `queue_mmap:` parameter on `Runner` (default: `true`) — allows disabling SQLite mmap for environments where it's unsafe (Docker overlay2)
255
- - New `mmap:` parameter on `Queue::Store` (default: `true`) — controls `PRAGMA mmap_size` (256 MB when enabled, 0 when disabled)
256
- - Public `attr_reader :queue_store` on `Runner` — eliminates need for `instance_variable_get` when sharing Store with Client
257
407
 
258
- ### Bug Fixes
259
- - **CRITICAL: fetch race condition** — wrapped `UPDATE ... RETURNING` in `BEGIN IMMEDIATE` transaction to prevent two workers from picking up the same job simultaneously
260
- - **CRITICAL: mmap + Docker overlay2** — `overlay2` filesystem does not guarantee `write()`/`mmap()` coherence, causing SQLite WAL corruption under concurrent multi-process access. mmap is now configurable via `queue_mmap: false` instead of being hardcoded. Documented proper Docker setup with named volumes in `docs/GET_STARTED.md`
261
- - **`PRAGMA optimize` on shutdown** — wrapped in `rescue nil` to prevent `SQLite3::BusyException` when another process holds the write lock during graceful shutdown
262
- - **`PRAGMA incremental_vacuum` was a no-op** — added `PRAGMA auto_vacuum = INCREMENTAL` to schema. Without it, `incremental_vacuum` does nothing. Note: only takes effect on newly created databases; existing databases require a one-time `VACUUM`
263
408
 
264
- ### Improvements
265
- - Replaced index `idx_jobs_status(status)` with composite `idx_jobs_status_id(status, id)` — eliminates sort step in `fetch` query (`ORDER BY id LIMIT 1` is now a direct B-tree lookup)
266
- - Fixed `finalize_statements` — changed `%i[@enqueue_stmt ...]` to `%i[enqueue_stmt ...]` with `:"@#{name}"` interpolation for idiomatic `instance_variable_get`/`set` usage
267
- - Added documentation: `README.md` (concise, with warning markers) and `docs/GET_STARTED.md` (step-by-step guide covering schedule config, Falcon integration, Docker setup, dynamic queue)
409
+ &nbsp;
268
410
 
269
411
  ## 0.4.0
270
412
 
271
- ### Features
272
- - **Dynamic job queue** — enqueue jobs at runtime from any process (web, console, rake) with automatic execution by background workers
273
- - `Queue::Store` — SQLite-backed persistent storage with WAL mode, prepared statements, and optimized pragmas
274
- - `Queue::Notifier` — `IO.pipe`-based zero-cost wakeup between producer and consumer processes (no polling)
275
- - `Queue::Client` — public API: `Async::Background::Queue.enqueue(JobClass, *args)`
276
- - Automatic recovery of stale `running` jobs on worker restart
277
- - Periodic cleanup of completed jobs (piggyback on fetch, every 5 minutes)
278
- - `PRAGMA incremental_vacuum` when cleanup removes 100+ rows
279
- - Worker isolation via `ISOLATION_FORKS` env variable — exclude specific workers from queue processing
280
- - Custom database path via `queue_db_path` parameter
281
- - Requires optional `sqlite3` gem (`~> 2.0`) — not included by default, must be added to Gemfile explicitly
282
- - New Runner parameters: `queue_notifier:` and `queue_db_path:`
283
-
284
- ### Improvements
285
- - Unified `monotonic_now` usage across `run_job` and `run_queue_job` (was using direct `Process.clock_gettime` call in `run_job`)
286
- - `Queue::Notifier#drain` — moved `rescue` inside the loop to avoid stack unwinding on each drain cycle
413
+ **Dynamic job queue.** Enqueue jobs at runtime from any process (web,
414
+ console, rake) with automatic execution by background workers.
287
415
 
288
- ## 0.3.0
416
+ - `Queue::Store` — SQLite-backed persistent storage with WAL mode,
417
+ prepared statements, and optimized pragmas.
418
+ - `Queue::Notifier` — `IO.pipe`-based zero-cost wake-up between producer
419
+ and consumer processes.
420
+ - `Queue::Client` — public API: `Async::Background::Queue.enqueue
421
+ (JobClass, *args)`.
422
+ - Automatic recovery of stale `running` jobs on worker restart.
423
+ - Periodic cleanup of completed jobs (piggybacked on fetch, every 5 min);
424
+ `PRAGMA incremental_vacuum` when cleanup removes 100+ rows.
425
+ - `ISOLATION_FORKS` env var excludes specific workers from queue processing.
426
+ - Custom database path via `queue_db_path:` on `Runner`.
289
427
 
290
- ### Features
291
- - Added optional metrics collection system using shared memory
292
- - New `Metrics` class with worker-specific performance tracking
293
- - Public API: `runner.metrics.enabled?`, `runner.metrics.values`, `Metrics.read_all()`
294
- - Tracks total runs, successes, failures, timeouts, skips, active jobs, and execution times
295
- - Requires optional `async-utilization` gem dependency
296
- - Metrics stored in `/tmp/async-background.shm` with lock-free updates per worker
428
+ Requires the optional `sqlite3` gem (`~> 2.0`).
297
429
 
298
- ## 0.2.6
430
+ (The 0.6.0 socket-based architecture supersedes the pipe-based notifier
431
+ introduced here.)
299
432
 
300
- ### Improvements
301
- - Micro-optimization in `wait_with_shutdown` method: use passed `task` parameter instead of `Async::Task.current` for better consistency and slight performance improvement
302
433
 
303
- ## 0.2.5
304
434
 
305
- ### Features
306
- - Added graceful shutdown via signal handlers for SIGINT and SIGTERM
307
- - Enhanced process lifecycle management with proper signal handling using `Signal.trap` and IO.pipe for async communication
308
- - Improved robustness for production deployments and container orchestration
309
- - Updated dependencies to work with latest Async 2.x API (removed deprecated `:parent` parameter usage)
435
+ &nbsp;
310
436
 
311
- ## 0.2.4
437
+ ## 0.3.0
312
438
 
313
- ### Improvements
314
- - Removed hardcoded version warning from main module (was checking against fixed list: 0.1.0, 0.2.2, 0.2.3). Use semantic versioning with pre-release suffixes for unstable versions (e.g., 0.3.0.alpha1) instead
315
- - Removed hardcoded stable versions list from gemspec description — metadata should describe functionality, not versioning
316
- - Changed `while true` to idiomatic `loop do` in run method
317
- - Added `Gemfile.lock` to .gitignore (gems should not commit lockfile)
318
- - Updated README: clarified that job class must respond to `.perform_now` class method (removed confusing mention of instance `#perform`)
439
+ Optional metrics collection via shared memory. `Metrics` tracks per-worker
440
+ counters: `total_runs`, `total_successes`, `total_failures`,
441
+ `total_timeouts`, `total_skips`, `active_jobs`, plus last-run timestamp and
442
+ duration. Public API: `runner.metrics.enabled?`, `runner.metrics.values`,
443
+ `Metrics.read_all(total_workers:)`. Requires the optional
444
+ `async-utilization` gem; absent that, `enabled?` is `false` and `read_all`
445
+ returns `[]`. Default file: `/tmp/async-background.shm`.
319
446
 
320
- ## 0.2.2
321
447
 
322
- ### Bug Fixes
323
- - **CRITICAL**: Removed logger parameter from Runner initialize (was unused). Fixed initialization to use Console.logger directly which now properly initializes in forked processes with correct context
324
448
 
325
- ## 0.2.1
449
+ &nbsp;
326
450
 
327
- ### Bug Fixes
328
- - **CRITICAL**: Added missing `require 'console'` in main module. Logger was nil because Console gem was not imported, causing `undefined method 'info' for nil` errors on worker initialization
451
+ ## 0.2.x
329
452
 
330
- ## 0.2.0
453
+ - **0.2.6** — `wait_with_shutdown` uses the passed `task` parameter
454
+ instead of `Async::Task.current`.
455
+ - **0.2.5** — Graceful shutdown via `SIGINT` / `SIGTERM` signal handlers
456
+ using `Signal.trap` and `IO.pipe`. Compatible with Async 2.x API
457
+ (removed deprecated `:parent`).
458
+ - **0.2.4** — Removed hardcoded version warning. Use semver pre-release
459
+ suffixes for unstable versions (e.g. `0.3.0.alpha1`).
460
+ - **0.2.2** — Removed unused `logger` parameter from `Runner#initialize`;
461
+ use `Console.logger` directly, which now initializes correctly in
462
+ forked processes.
463
+ - **0.2.1** — Added missing `require 'console'` in main module. Logger
464
+ was `nil`, causing `undefined method 'info' for nil` on worker
465
+ initialization.
466
+ - **0.2.0** — Removed hidden ActiveSupport dependency
467
+ (`safe_constantize` → `Object.const_get` + `NameError`). Job validation
468
+ now checks for `.perform_now` (class method) instead of `.perform`
469
+ (instance method). Fixed a race where an entry could disappear from the
470
+ heap during execution. Added `stop()` and `running?()` to `Runner`.
331
471
 
332
- ### Bug Fixes
333
- - **CRITICAL**: Removed hidden ActiveSupport dependency. Replaced `safe_constantize` with `Object.const_get` + `NameError` handling
334
- - **CRITICAL**: Fixed validator mismatch: now validates `.perform_now` (class method) instead of `.perform` (instance method)
335
- - **CRITICAL**: Fixed race condition where entry could disappear from heap during execution. `reschedule` and `heap.push` now always execute after job processing
336
- - Added full exception backtrace to error logs for production debugging
337
- - Improved YAML security by removing `Symbol` from `permitted_classes`
338
- - Removed Mutex from graceful shutdown (anti-pattern in Async). Boolean assignment is atomic in MRI
339
472
 
340
- ### Features
341
- - Added optional `logger` parameter to Runner constructor for custom loggers (Rails.logger, etc.)
342
- - Added `stop()` method for graceful shutdown
343
- - Added `running?()` method to check scheduler status
344
473
 
345
- ### Breaking Changes
346
- - Job class validation now checks for `.perform_now` class method (was checking for `.perform` instance method)
474
+ &nbsp;
347
475
 
348
476
  ## 0.1.0
349
477
 
350
- - Initial release
351
- - Single event loop with min-heap timer (O(log N) scheduling)
352
- - Skip overlapping execution
353
- - Startup jitter to prevent thundering herd
354
- - Monotonic clock for interval jobs, wall clock for cron jobs
355
- - Deterministic worker sharding via Zlib.crc32
356
- - Semaphore-based concurrency control
357
- - Per-job timeout protection
358
- - Structured logging via Console
478
+ Initial release.
479
+
480
+ - Single event loop with min-heap timer (`O(log N)` scheduling).
481
+ - Skip overlapping execution.
482
+ - Startup jitter to prevent thundering herd.
483
+ - Monotonic clock for interval jobs, wall clock for cron jobs.
484
+ - Deterministic worker sharding via `Zlib.crc32`.
485
+ - Semaphore-based concurrency control.
486
+ - Per-job timeout protection.
487
+ - Structured logging via Console.