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/README.md CHANGED
@@ -1,36 +1,67 @@
1
1
  # Async::Background
2
2
 
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.
3
+ A lightweight cron, interval, and job-queue scheduler for Ruby's
4
+ [Async](https://github.com/socketry/async) ecosystem. Built for
5
+ [Falcon](https://github.com/socketry/falcon), works with any Async app.
6
+
7
+ - **Cron & interval scheduling** on a single event loop with a min-heap.
8
+ - **Dynamic job queue** backed by SQLite, with delayed jobs
9
+ (`perform_in` / `perform_at`).
10
+ - **Cross-process wake-ups** over Unix domain sockets — web workers can
11
+ enqueue and instantly wake background workers.
12
+ - **Multi-process safe** — deterministic worker sharding, no duplicate
13
+ execution.
14
+ - **Per-job timeouts**, skip-on-overlap, startup jitter, optional metrics.
4
15
 
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
16
+ ---
17
+
18
+ ## Why Async? Why fibers?
19
+
20
+ The whole gem is built around the assumption that Falcon's reactor schedules
21
+ many fibers on top of one OS thread per process — so the dashboard's SSE
22
+ stream, the cron scheduler, and the queue worker all share that one thread
23
+ cooperatively. A blocked fiber yields; a blocked thread doesn't.
24
+
25
+ ![Threads vs fibers under different Ruby web servers](docs/fibers-vs-threads.svg)
26
+
27
+ That's also why the dashboard (since 1.0.1) runs its SSE loop entirely inside
28
+ the request fiber: zero extra threads, zero `ConditionVariable`, ~4 KB per
29
+ open tab.
30
+
31
+ ---
10
32
 
11
33
  ## Requirements
12
34
 
13
- - Ruby >= 3.3
14
- - `async ~> 2.0`, `fugit ~> 1.0`
15
- - `sqlite3 ~> 2.0` (optional, storage)
16
- - `async-utilization >= 0.3, < 0.5` (optional, for metrics)
35
+ | Dependency | Version | Required? |
36
+ | -------------------- | ---------------- | -------------------- |
37
+ | Ruby | `>= 3.3` | yes |
38
+ | `async` | `~> 2.0` | yes |
39
+ | `fugit` | `~> 1.0` | yes |
40
+ | `sqlite3` | `~> 2.0` | for the queue & dashboard |
41
+ | `async-utilization` | `>= 0.3, < 0.5` | for metrics |
42
+
43
+ ---
17
44
 
18
45
  ## Install
19
46
 
20
47
  ```ruby
21
48
  # Gemfile
22
49
  gem "async-background"
23
- gem "sqlite3", "~> 2.0" # optional
24
- gem "async-utilization", ">= 0.3", "< 0.5" # optional
50
+
51
+ gem "sqlite3", "~> 2.0" # if you use the queue or dashboard
52
+ gem "async-utilization", ">= 0.3", "< 0.5" # if you want worker metrics
25
53
  ```
26
54
 
27
- ## ➡️ [Get Started](docs/GET_STARTED.md)
55
+ ---
56
+
57
+ ## ➡️ [Get started](docs/GET_STARTED.md)
28
58
 
29
- Full setup walkthrough: schedule config, Falcon integration, Docker, queue, delayed jobs.
59
+ A four-step walkthrough: schedule config, Falcon integration, Docker, queue,
60
+ delayed jobs.
30
61
 
31
62
  ---
32
63
 
33
- ## Quick Look
64
+ ## Quick look
34
65
 
35
66
  ```ruby
36
67
  class SendEmailJob
@@ -59,114 +90,71 @@ daily_report:
59
90
  timeout: 120
60
91
  ```
61
92
 
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` |
93
+ | Key | Description |
94
+ | ---------------- | --------------------------------------------------------------- |
95
+ | `class` | Job class — must include `Async::Background::Job`. |
96
+ | `every` / `cron` | Interval in seconds, or a cron expression. Exactly one. |
97
+ | `timeout` | Max execution time in seconds. Default: 30. |
98
+ | `worker` | Pin to a specific worker. Default: `crc32(name) % total_workers`. |
68
99
 
69
100
  ---
70
101
 
71
102
  ## Gotchas
72
103
 
73
- ### Docker: SQLite requires a named volume
74
-
75
- The SQLite database **must not** live on Docker's `overlay2` filesystem. The `overlay2` driver breaks coherence between `write()` and `mmap()`, which corrupts SQLite WAL under concurrent access.
76
-
77
- ```yaml
78
- # docker-compose.yml
79
- services:
80
- app:
81
- volumes:
82
- - queue-data:/app/tmp/queue # ← named volume, NOT overlay2
83
-
84
- volumes:
85
- queue-data:
86
- ```
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. If you can't use a named volume, pass `queue_mmap: false` to disable mmap entirely.
89
-
90
- ### Other gotchas
104
+ **Docker + SQLite use a named volume.**
105
+ SQLite's database must not live on Docker's default `overlay2` filesystem:
106
+ `overlay2` breaks coherence between `write()` and `mmap()`, which corrupts
107
+ the WAL under concurrent access. Mount the queue directory as a named volume,
108
+ or pass `mmap: false` to `Store.new`. See
109
+ [Get Started → Docker](docs/GET_STARTED.md#step-3--docker-setup).
91
110
 
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
-
94
- ```ruby
95
- Async::Background::Queue.migrate!(path: db_path) # ← once, before fork
96
- # Every process opens its own Store lazily after fork.
97
- ```
111
+ **Don't share SQLite connections across `fork()`.**
112
+ The gem opens connections lazily after fork. If you build a `Store` manually
113
+ for schema setup, close it before forking.
98
114
 
99
- **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.
115
+ **Two clocks, on purpose.**
116
+ Interval jobs use `CLOCK_MONOTONIC` so NTP drift can't fire them twice. Cron
117
+ jobs use wall-clock time, because "every day at 3am" needs to mean 3am.
100
118
 
101
119
  ---
102
120
 
103
121
  ## How it works
104
122
 
123
+ A single Async task sleeps until the next entry is due, then dispatches it
124
+ under a semaphore that caps concurrency. Overlapping ticks are skipped and
125
+ rescheduled.
126
+
105
127
  ```
106
- schedule.yml ─► build_heap ─► MinHeap<Entry> ─► scheduler loop ─► Semaphore ─► run_job
128
+ schedule.yml build_heap MinHeap<Entry> scheduler loop Semaphore run_job
107
129
  ```
108
130
 
109
- 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.
110
-
111
131
  The dynamic queue runs alongside it:
112
132
 
113
133
  ```
114
- Producer (web/console) Consumer (background worker)
115
-
116
-
117
- Queue::Client Queue::Store#fetch
118
- push / push_in / push_at (run_at <= now)
119
-
120
-
121
- Queue::Store ──── SQLite (jobs) ──── SocketWaker
122
-
123
- └───────► SocketNotifier ───────────────┘
124
- (UNIX socket wake-up, ~80µs)
125
- ```
126
-
127
- 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.
128
-
129
- ### Queue-only workers
130
-
131
- Recurring schedules are optional. A worker that serves only dynamic jobs starts with
132
- `config_path: nil` and a `queue_socket_dir`; it does not need a placeholder schedule file.
133
- A supplied schedule path stays strict and raises when the file is missing or empty.
134
-
135
- ### Schema migration during deploy
136
-
137
- Run queue migrations once in the release/pre-deploy step, before starting new web or worker
138
- processes. This serializes the schema upgrade with `BEGIN IMMEDIATE`, records the version in
139
- SQLite, and avoids a first producer doing DDL under live queue traffic:
140
-
141
- ```ruby
142
- Async::Background::Queue.migrate!(path: ENV.fetch("QUEUE_DB_PATH"))
134
+ Producer (web / console) Consumer (background worker)
135
+
136
+
137
+ Queue::Client Queue::Store#fetch
138
+ push / push_in / push_at (run_at <= now)
139
+
140
+
141
+ Queue::Store ──── SQLite (jobs) ──── SocketWaker
142
+
143
+ └─────────► SocketNotifier ────────────────┘
144
+ (UNIX socket wake-up, ~80µs)
143
145
  ```
144
146
 
145
- A fresh database still self-initializes on first use for local development, but explicit
146
- migration is the production path. For an existing queue, finish or stop 0.7.1 producers/workers,
147
- run the migration once, then start 0.7.2 processes.
148
-
149
- ### Dashboard indexes
150
-
151
- The queue does **not** install dashboard indexes by default. They slow every enqueue even though
152
- pending rows never enter terminal or in-flight read-model indexes. Enable them once before
153
- mounting the dashboard:
154
-
155
- ```ruby
156
- Async::Background::Queue.prepare_dashboard!(path: ENV.fetch("QUEUE_DB_PATH"))
157
- ```
147
+ Jobs are persisted in SQLite, so a missed wake-up is never a lost job —
148
+ workers also poll every 5 seconds as a safety net.
158
149
 
159
- It adds four compact indexes: cursor-sorted `done` / `failed` history plus separate
160
- `executing` / `claimed` in-flight lists. It does not change queue behavior or rerun the core migration.
150
+ ---
161
151
 
162
152
  ## Metrics
163
153
 
164
- Metrics are an optional integration with `async-utilization` (`>= 0.3`, `< 0.5`). The
165
- background worker remains fully functional when that gem is absent. With it installed, each
166
- worker publishes counters to a shared-memory segment.
154
+ Metrics are an optional integration with `async-utilization`. With the gem
155
+ installed, each worker publishes counters to a shared-memory segment:
167
156
 
168
157
  ```ruby
169
- runner.metrics.enabled?
170
158
  runner.metrics.values
171
159
  # => { total_runs: 142, total_successes: 140, total_failures: 2,
172
160
  # total_timeouts: 0, total_skips: 5, active_jobs: 1, ... }
@@ -175,19 +163,13 @@ Async::Background::Metrics.read_all(total_workers: 2)
175
163
  # => [{ worker: 1, ... }, { worker: 2, ... }]
176
164
  ```
177
165
 
178
- `Metrics.read_all` returns `[]` until the optional gem is installed and a worker has created
179
- the file, so an observer can render an unavailable state without rescuing `LoadError`. Its
180
- snapshot is lock-free best effort: cumulative fields (`total_runs`, `total_successes`,
181
- `total_failures`, `total_timeouts`, `total_skips`) are counters; `active_jobs`,
182
- `last_run_at`, and `last_duration_ms` are gauges. Fields can describe adjacent moments in time
183
- rather than one globally atomic instant.
184
-
185
- By default the file is `/tmp/async-background.shm`. Set `ASYNC_BACKGROUND_METRICS_PATH`
186
- or pass `metrics_shm_path:` to `Runner.new` when another observer runs in a separate process
187
- or container; both sides must see the same mounted file.
188
-
166
+ Without the gem, `runner.metrics.enabled?` is `false` and `read_all` returns
167
+ `[]` no `LoadError` to rescue. Configuration and the cross-container
168
+ shared-memory path are covered in
169
+ [Get Started Optional metrics](docs/GET_STARTED.md#appendix-optional-metrics).
189
170
 
171
+ ---
190
172
 
191
173
  ## License
192
174
 
193
- MIT
175
+ MIT.
@@ -127,9 +127,7 @@ module Async
127
127
  end
128
128
 
129
129
  def decode_workers(buffer, schema, segment, total_workers)
130
- (1..total_workers).map do |worker|
131
- decode_worker(buffer, schema, segment, worker)
132
- end.freeze
130
+ (1..total_workers).map { |worker| decode_worker(buffer, schema, segment, worker) }.freeze
133
131
  end
134
132
 
135
133
  def decode_worker(buffer, schema, segment, worker)
@@ -2,10 +2,14 @@
2
2
 
3
3
  require 'socket'
4
4
 
5
+ require_relative '../clock'
6
+
5
7
  module Async
6
8
  module Background
7
9
  module Queue
8
10
  class SocketNotifier
11
+ include Clock
12
+
9
13
  # Errors that indicate a worker is unavailable - silently skip and try the next.
10
14
  UNAVAILABLE = [
11
15
  Errno::ENOENT, # Socket file doesn't exist (worker hasn't started yet)
@@ -14,41 +18,68 @@ module Async
14
18
  Errno::ECONNRESET # Connection reset by peer
15
19
  ].freeze
16
20
 
21
+ DEAD_WORKER_TTL = 5.0
22
+ WAKE_BYTE = "\x01".freeze
23
+
17
24
  def initialize(socket_dir:, total_workers:)
18
25
  @socket_dir = socket_dir
19
26
  @total_workers = total_workers
27
+ @paths = build_paths
28
+ @dead_until = Array.new([@total_workers, 0].max, 0.0)
29
+ @cursor = 0
20
30
  end
21
31
 
22
32
  def notify_all
23
- return if @total_workers <= 0
33
+ return false if @total_workers <= 0
34
+
35
+ now = monotonic_now
36
+ start = advance_cursor
24
37
 
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)
38
+ @total_workers.times do |offset|
39
+ index = (start + offset) % @total_workers
40
+ return true if notify_one(index, now)
29
41
  end
42
+
43
+ false
30
44
  end
31
45
 
32
46
  private
33
47
 
34
- def notify_one(worker_index)
35
- path = socket_path(worker_index)
36
- sock = UNIXSocket.new(path)
48
+ def build_paths
49
+ return [].freeze if @total_workers <= 0
50
+
51
+ Array.new(@total_workers) do |index|
52
+ File.join(@socket_dir, "async_bg_worker_#{index + 1}.sock").freeze
53
+ end.freeze
54
+ end
55
+
56
+ def advance_cursor
57
+ @cursor = (@cursor + 1) % @total_workers
58
+ end
59
+
60
+ def notify_one(index, now)
61
+ return false if @dead_until[index] > now
62
+
63
+ socket = UNIXSocket.new(@paths[index])
37
64
  begin
38
- sock.write_nonblock("\x01")
65
+ socket.write_nonblock(WAKE_BYTE)
39
66
  ensure
40
- sock.close rescue nil
67
+ socket.close rescue nil
41
68
  end
42
69
  true
70
+ rescue IO::WaitWritable
71
+ true
43
72
  rescue *UNAVAILABLE
73
+ mark_dead(index, now)
44
74
  false
45
75
  rescue => e
46
- Console.logger.warn(self) { "SocketNotifier#notify_one(#{worker_index}) failed: #{e.class} #{e.message}" } rescue nil
76
+ mark_dead(index, now)
77
+ Console.logger.warn(self) { "SocketNotifier#notify_one(#{index + 1}) failed: #{e.class} #{e.message}" } rescue nil
47
78
  false
48
79
  end
49
80
 
50
- def socket_path(worker_index)
51
- File.join(@socket_dir, "async_bg_worker_#{worker_index}.sock")
81
+ def mark_dead(index, now)
82
+ @dead_until[index] = now + DEAD_WORKER_TTL
52
83
  end
53
84
  end
54
85
  end
@@ -80,12 +80,15 @@ module Async
80
80
  parent_task.async do
81
81
  begin
82
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
83
+ begin
84
+ client.read_nonblock(256)
85
+ @notification.signal
86
+ rescue IO::WaitReadable
87
+ client.wait_readable
88
+ retry
89
+ rescue EOFError, Errno::ECONNRESET
90
+ break
91
+ end
89
92
  end
90
93
  rescue => e
91
94
  Console.logger.warn(self) { "SocketWaker client handler error: #{e.class} #{e.message}" }
@@ -9,7 +9,8 @@ module Async
9
9
  BUSY_TIMEOUT = 'PRAGMA busy_timeout'.freeze
10
10
  TABLE_INFO = 'PRAGMA table_info(jobs)'.freeze
11
11
  OPTIMIZE = 'PRAGMA optimize'.freeze
12
- INCREMENTAL_VACUUM = 'PRAGMA incremental_vacuum'.freeze
12
+ INCREMENTAL_VACUUM_PAGES = 64
13
+ INCREMENTAL_VACUUM = "PRAGMA incremental_vacuum(#{INCREMENTAL_VACUUM_PAGES})".freeze
13
14
  AUTO_VACUUM_INCREMENTAL = 'PRAGMA auto_vacuum = INCREMENTAL'.freeze
14
15
  BEGIN_IMMEDIATE = 'BEGIN IMMEDIATE'.freeze
15
16
  COMMIT = 'COMMIT'.freeze
@@ -22,6 +22,7 @@ module Async
22
22
  CLEANUP_INTERVAL = 300
23
23
  CLEANUP_AGE = 3600
24
24
  FAILED_RETENTION_AGE = 7 * 24 * 3600
25
+ CLEANUP_VACUUM_THRESHOLD = 100
25
26
  ERROR_MESSAGE_MAX_LEN = 2_000
26
27
  EMPTY_ARGS_JSON = '[]'.freeze
27
28
 
@@ -80,7 +81,13 @@ module Async
80
81
  def enqueue(class_name, args = EMPTY_ARGS, run_at = nil, options: EMPTY_OPTIONS)
81
82
  ensure_connection
82
83
  now = realtime_now
83
- @enqueue_stmt.execute(class_name, dump_args(args), dump_options(options), now, run_at || now)
84
+ stepped(@enqueue_stmt) do |statement|
85
+ statement.bind_param(1, class_name)
86
+ statement.bind_param(2, dump_args(args))
87
+ statement.bind_param(3, dump_options(options))
88
+ statement.bind_param(4, now)
89
+ statement.bind_param(5, run_at || now)
90
+ end
84
91
  @db.last_insert_row_id
85
92
  end
86
93
 
@@ -90,9 +97,14 @@ module Async
90
97
  now = realtime_now
91
98
 
92
99
  row = transaction do
93
- with_statement(@fetch_stmt) { |statement| statement.execute(worker_id, now, token, now).first }
100
+ stepped(@fetch_stmt) do |statement|
101
+ statement.bind_param(1, worker_id)
102
+ statement.bind_param(2, now)
103
+ statement.bind_param(3, token)
104
+ statement.bind_param(4, now)
105
+ end
94
106
  end
95
- return unless row
107
+ return if row.nil? || row.empty?
96
108
 
97
109
  maybe_cleanup
98
110
  job_from_row(row, token)
@@ -100,26 +112,28 @@ module Async
100
112
 
101
113
  def mark_started!(job_id, claim_token:, started_at: realtime_now)
102
114
  ensure_connection
103
- @mark_started_stmt.execute(started_at, job_id, claim_token)
115
+ stepped(@mark_started_stmt) do |statement|
116
+ statement.bind_param(1, started_at)
117
+ statement.bind_param(2, job_id)
118
+ statement.bind_param(3, claim_token)
119
+ end
104
120
  @db.changes.positive?
105
121
  end
106
122
 
107
123
  def complete(job_id, claim_token:, finished_at: realtime_now, duration_ms: nil)
108
124
  ensure_connection
109
- @complete_stmt.execute(finished_at, duration_ms, job_id, claim_token)
125
+ stepped(@complete_stmt) do |statement|
126
+ statement.bind_param(1, finished_at)
127
+ statement.bind_param(2, duration_ms)
128
+ statement.bind_param(3, job_id)
129
+ statement.bind_param(4, claim_token)
130
+ end
110
131
  @db.changes.positive?
111
132
  end
112
133
 
113
134
  def fail(job_id, claim_token:, error_class: nil, error_message: nil, finished_at: realtime_now, duration_ms: nil)
114
135
  ensure_connection
115
- @fail_stmt.execute(
116
- finished_at,
117
- duration_ms,
118
- error_class&.to_s,
119
- truncate_message(error_message),
120
- job_id,
121
- claim_token
122
- )
136
+ bind_failure(@fail_stmt, finished_at, duration_ms, error_class, error_message, job_id, claim_token)
123
137
  @db.changes.positive?
124
138
  end
125
139
 
@@ -146,13 +160,13 @@ module Async
146
160
 
147
161
  def recover(worker_id)
148
162
  ensure_connection
149
- @requeue_stmt.execute(worker_id)
163
+ stepped(@requeue_stmt) { |statement| statement.bind_param(1, worker_id) }
150
164
  @db.changes
151
165
  end
152
166
 
153
167
  def next_pending_run_at
154
168
  ensure_connection
155
- with_statement(@next_pending_stmt) { |statement| statement.execute.first&.first }
169
+ stepped(@next_pending_stmt)&.first
156
170
  end
157
171
 
158
172
  def data_version
@@ -243,9 +257,11 @@ module Async
243
257
  end
244
258
 
245
259
  def stored_options_for(job_id, claim_token)
246
- with_statement(@retry_state_stmt) do |statement|
247
- load_options(statement.execute(job_id, claim_token).first&.first)
260
+ row = stepped(@retry_state_stmt) do |statement|
261
+ statement.bind_param(1, job_id)
262
+ statement.bind_param(2, claim_token)
248
263
  end
264
+ load_options(row&.first)
249
265
  end
250
266
 
251
267
  def retry_policy(stored_options, fallback_options)
@@ -260,29 +276,33 @@ module Async
260
276
 
261
277
  def retry_job!(job_id, claim_token, policy, error_class, error_message)
262
278
  advanced = policy.with_attempt(policy.next_attempt)
263
- @retry_stmt.execute(
264
- realtime_now + advanced.next_retry_delay(advanced.attempt),
265
- dump_options(advanced.to_h.compact),
266
- error_class&.to_s,
267
- truncate_message(error_message),
268
- job_id,
269
- claim_token
270
- )
279
+ stepped(@retry_stmt) do |statement|
280
+ statement.bind_param(1, realtime_now + advanced.next_retry_delay(advanced.attempt))
281
+ statement.bind_param(2, dump_options(advanced.to_h.compact))
282
+ statement.bind_param(3, error_class&.to_s)
283
+ statement.bind_param(4, truncate_message(error_message))
284
+ statement.bind_param(5, job_id)
285
+ statement.bind_param(6, claim_token)
286
+ end
271
287
  @db.changes.positive? ? :retried : nil
272
288
  end
273
289
 
274
290
  def fail_job!(job_id, claim_token, error_class, error_message, finished_at, duration_ms)
275
- @fail_stmt.execute(
276
- finished_at,
277
- duration_ms,
278
- error_class&.to_s,
279
- truncate_message(error_message),
280
- job_id,
281
- claim_token
282
- )
291
+ bind_failure(@fail_stmt, finished_at, duration_ms, error_class, error_message, job_id, claim_token)
283
292
  @db.changes.positive? ? :failed : nil
284
293
  end
285
294
 
295
+ def bind_failure(statement, finished_at, duration_ms, error_class, error_message, job_id, claim_token)
296
+ stepped(statement) do |s|
297
+ s.bind_param(1, finished_at)
298
+ s.bind_param(2, duration_ms)
299
+ s.bind_param(3, error_class&.to_s)
300
+ s.bind_param(4, truncate_message(error_message))
301
+ s.bind_param(5, job_id)
302
+ s.bind_param(6, claim_token)
303
+ end
304
+ end
305
+
286
306
  def generate_claim_token = SecureRandom.hex(16)
287
307
 
288
308
  def truncate_message(message)
@@ -293,23 +313,30 @@ module Async
293
313
  end
294
314
 
295
315
  def lease_alive?(job_id, claim_token)
296
- with_statement(@lease_check_stmt) do |statement|
297
- !statement.execute(job_id, claim_token).first.nil?
298
- end
316
+ !stepped(@lease_check_stmt) do |statement|
317
+ statement.bind_param(1, job_id)
318
+ statement.bind_param(2, claim_token)
319
+ end.nil?
299
320
  end
300
321
 
301
322
  def transaction
302
- @db.execute(SQL::BEGIN_IMMEDIATE)
323
+ stepped(@begin_stmt)
303
324
  result = yield
304
- @db.execute(SQL::COMMIT)
325
+ stepped(@commit_stmt)
305
326
  result
306
327
  rescue StandardError
307
- @db.execute(SQL::ROLLBACK) rescue nil
328
+ begin
329
+ stepped(@rollback_stmt)
330
+ rescue StandardError
331
+ nil
332
+ end
308
333
  raise
309
334
  end
310
335
 
311
- def with_statement(statement)
312
- yield statement
336
+ def stepped(statement)
337
+ statement.reset!
338
+ yield statement if block_given?
339
+ statement.step
313
340
  ensure
314
341
  statement.reset! rescue nil
315
342
  end
@@ -346,6 +373,9 @@ module Async
346
373
  @cleanup_done_stmt = @db.prepare(SQL::CLEANUP_DONE)
347
374
  @cleanup_failed_stmt = @db.prepare(SQL::CLEANUP_FAILED)
348
375
  @next_pending_stmt = @db.prepare(SQL::NEXT_PENDING_RUN_AT)
376
+ @begin_stmt = @db.prepare(SQL::BEGIN_IMMEDIATE)
377
+ @commit_stmt = @db.prepare(SQL::COMMIT)
378
+ @rollback_stmt = @db.prepare(SQL::ROLLBACK)
349
379
  end
350
380
 
351
381
  def finalize_statements
@@ -366,7 +396,10 @@ module Async
366
396
  @requeue_stmt,
367
397
  @cleanup_done_stmt,
368
398
  @cleanup_failed_stmt,
369
- @next_pending_stmt
399
+ @next_pending_stmt,
400
+ @begin_stmt,
401
+ @commit_stmt,
402
+ @rollback_stmt
370
403
  ]
371
404
  end
372
405
 
@@ -376,6 +409,7 @@ module Async
376
409
  @retry_stmt = @requeue_stmt = nil
377
410
  @cleanup_done_stmt = @cleanup_failed_stmt = nil
378
411
  @next_pending_stmt = nil
412
+ @begin_stmt = @commit_stmt = @rollback_stmt = nil
379
413
  end
380
414
 
381
415
  def maybe_cleanup
@@ -387,9 +421,15 @@ module Async
387
421
  end
388
422
 
389
423
  def cleanup_finished_jobs(now)
390
- @cleanup_done_stmt.execute(now - CLEANUP_AGE)
391
- @cleanup_failed_stmt.execute(now - FAILED_RETENTION_AGE)
392
- @db.execute(SQL::INCREMENTAL_VACUUM) if @db.changes > 100
424
+ deleted = 0
425
+
426
+ stepped(@cleanup_done_stmt) { |statement| statement.bind_param(1, now - CLEANUP_AGE) }
427
+ deleted += @db.changes
428
+ stepped(@cleanup_failed_stmt) { |statement| statement.bind_param(1, now - FAILED_RETENTION_AGE) }
429
+ deleted += @db.changes
430
+
431
+ @db.execute(SQL::INCREMENTAL_VACUUM) if deleted > CLEANUP_VACUUM_THRESHOLD
432
+ deleted
393
433
  end
394
434
  end
395
435
  end