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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +405 -276
- data/README.md +91 -109
- data/lib/async/background/metrics.rb +1 -3
- data/lib/async/background/queue/socket_notifier.rb +44 -13
- data/lib/async/background/queue/socket_waker.rb +9 -6
- data/lib/async/background/queue/sql.rb +2 -1
- data/lib/async/background/queue/store.rb +85 -45
- data/lib/async/background/runner.rb +4 -14
- data/lib/async/background/version.rb +1 -1
- data/lib/async/background/web/app.rb +39 -18
- data/lib/async/background/web/auth.rb +7 -2
- data/lib/async/background/web/configuration.rb +17 -3
- data/lib/async/background/web/event_hub.rb +25 -148
- data/lib/async/background/web/response.rb +31 -9
- data/lib/async/background/web/router.rb +3 -1
- data/lib/async/background/web/stream.rb +54 -15
- metadata +3 -3
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
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
+

|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
24
|
-
gem "
|
|
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
|
-
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## ➡️ [Get started](docs/GET_STARTED.md)
|
|
28
58
|
|
|
29
|
-
|
|
59
|
+
A four-step walkthrough: schedule config, Falcon integration, Docker, queue,
|
|
60
|
+
delayed jobs.
|
|
30
61
|
|
|
31
62
|
---
|
|
32
63
|
|
|
33
|
-
## Quick
|
|
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
|
|
63
|
-
|
|
64
|
-
| `class`
|
|
65
|
-
| `every` / `cron` |
|
|
66
|
-
| `timeout`
|
|
67
|
-
| `worker`
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
#
|
|
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()`.**
|
|
93
|
-
|
|
94
|
-
|
|
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.**
|
|
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
|
|
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)
|
|
115
|
-
│
|
|
116
|
-
▼
|
|
117
|
-
Queue::Client
|
|
118
|
-
push / push_in / push_at
|
|
119
|
-
│
|
|
120
|
-
▼
|
|
121
|
-
Queue::Store ──── SQLite (jobs) ──── SocketWaker
|
|
122
|
-
│
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
|
165
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
65
|
+
socket.write_nonblock(WAKE_BYTE)
|
|
39
66
|
ensure
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
51
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
@
|
|
323
|
+
stepped(@begin_stmt)
|
|
303
324
|
result = yield
|
|
304
|
-
@
|
|
325
|
+
stepped(@commit_stmt)
|
|
305
326
|
result
|
|
306
327
|
rescue StandardError
|
|
307
|
-
|
|
328
|
+
begin
|
|
329
|
+
stepped(@rollback_stmt)
|
|
330
|
+
rescue StandardError
|
|
331
|
+
nil
|
|
332
|
+
end
|
|
308
333
|
raise
|
|
309
334
|
end
|
|
310
335
|
|
|
311
|
-
def
|
|
312
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
@
|
|
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
|