async-background 0.7.2 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +348 -198
- data/README.md +91 -103
- data/async-background.gemspec +3 -1
- data/lib/async/background/metrics.rb +3 -1
- data/lib/async/background/queue/schema.rb +6 -1
- data/lib/async/background/queue/sql.rb +15 -4
- data/lib/async/background/runner/schedule.rb +2 -0
- data/lib/async/background/runner.rb +17 -2
- data/lib/async/background/version.rb +1 -1
- data/lib/async/background/web/app.rb +159 -0
- data/lib/async/background/web/assets.rb +726 -0
- data/lib/async/background/web/auth.rb +24 -0
- data/lib/async/background/web/configuration.rb +172 -0
- data/lib/async/background/web/cursor.rb +58 -0
- data/lib/async/background/web/errors.rb +14 -0
- data/lib/async/background/web/event_hub.rb +71 -0
- data/lib/async/background/web/metrics_reader.rb +96 -0
- data/lib/async/background/web/request.rb +36 -0
- data/lib/async/background/web/response.rb +107 -0
- data/lib/async/background/web/router.rb +32 -0
- data/lib/async/background/web/serializer.rb +154 -0
- data/lib/async/background/web/snapshot.rb +247 -0
- data/lib/async/background/web/sql.rb +88 -0
- data/lib/async/background/web/stream.rb +82 -0
- data/lib/async/background/web.rb +52 -0
- metadata +46 -2
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,108 +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
|
|
91
|
-
|
|
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:
|
|
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).
|
|
93
110
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
### Schema migration during deploy
|
|
130
|
-
|
|
131
|
-
Run queue migrations once in the release/pre-deploy step, before starting new web or worker
|
|
132
|
-
processes. This serializes the schema upgrade with `BEGIN IMMEDIATE`, records the version in
|
|
133
|
-
SQLite, and avoids a first producer doing DDL under live queue traffic:
|
|
134
|
-
|
|
135
|
-
```ruby
|
|
136
|
-
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)
|
|
137
145
|
```
|
|
138
146
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
run the migration once, then start 0.7.2 processes.
|
|
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.
|
|
142
149
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
The queue does **not** install dashboard indexes by default. They slow every enqueue even though
|
|
146
|
-
pending rows never enter terminal or in-flight read-model indexes. When the 1.0 dashboard module
|
|
147
|
-
is enabled, its installer will run this once in the same release step:
|
|
148
|
-
|
|
149
|
-
```ruby
|
|
150
|
-
Async::Background::Queue.prepare_dashboard!(path: ENV.fetch("QUEUE_DB_PATH"))
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
It adds three compact indexes: one each for cursor-sorted done and failed jobs, plus one for
|
|
154
|
-
the bounded in-flight list. It does not change queue behavior or rerun the core migration.
|
|
150
|
+
---
|
|
155
151
|
|
|
156
152
|
## Metrics
|
|
157
153
|
|
|
158
|
-
Metrics are an optional integration with `async-utilization
|
|
159
|
-
|
|
160
|
-
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:
|
|
161
156
|
|
|
162
157
|
```ruby
|
|
163
|
-
runner.metrics.enabled?
|
|
164
158
|
runner.metrics.values
|
|
165
159
|
# => { total_runs: 142, total_successes: 140, total_failures: 2,
|
|
166
160
|
# total_timeouts: 0, total_skips: 5, active_jobs: 1, ... }
|
|
@@ -169,19 +163,13 @@ Async::Background::Metrics.read_all(total_workers: 2)
|
|
|
169
163
|
# => [{ worker: 1, ... }, { worker: 2, ... }]
|
|
170
164
|
```
|
|
171
165
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
`last_run_at`, and `last_duration_ms` are gauges. Fields can describe adjacent moments in time
|
|
177
|
-
rather than one globally atomic instant.
|
|
178
|
-
|
|
179
|
-
By default the file is `/tmp/async-background.shm`. Set `ASYNC_BACKGROUND_METRICS_PATH`
|
|
180
|
-
or pass `metrics_shm_path:` to `Runner.new` when another observer runs in a separate process
|
|
181
|
-
or container; both sides must see the same mounted file.
|
|
182
|
-
|
|
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).
|
|
183
170
|
|
|
171
|
+
---
|
|
184
172
|
|
|
185
173
|
## License
|
|
186
174
|
|
|
187
|
-
MIT
|
|
175
|
+
MIT.
|
data/async-background.gemspec
CHANGED
|
@@ -33,12 +33,14 @@ Gem::Specification.new do |spec|
|
|
|
33
33
|
spec.add_dependency 'async', '~> 2.0'
|
|
34
34
|
spec.add_dependency 'console', '~> 1.0'
|
|
35
35
|
spec.add_dependency 'fugit', '~> 1.0'
|
|
36
|
+
spec.add_dependency 'base64', '~> 0.2'
|
|
36
37
|
|
|
37
38
|
# Optional: add to your own Gemfile if you need these features
|
|
38
|
-
# gem 'sqlite3',
|
|
39
|
+
# gem 'sqlite3', '~> 2.0'
|
|
39
40
|
# gem 'async-utilization', '>= 0.3', '< 0.5' # shared-memory worker metrics
|
|
40
41
|
|
|
41
42
|
spec.add_development_dependency 'rake', '~> 13.0'
|
|
42
43
|
spec.add_development_dependency 'rspec', '~> 3.12'
|
|
44
|
+
spec.add_development_dependency 'rack', '~> 3.0'
|
|
43
45
|
spec.add_development_dependency 'async-utilization', '>= 0.3', '< 0.5'
|
|
44
46
|
end
|
|
@@ -191,10 +191,12 @@ module Async
|
|
|
191
191
|
|
|
192
192
|
def ensure_shm!(total_workers, path)
|
|
193
193
|
required_size = self.class.segment_size * total_workers
|
|
194
|
+
page_size = IO::Buffer::PAGE_SIZE
|
|
195
|
+
mapped_size = ((required_size + page_size - 1) / page_size) * page_size
|
|
194
196
|
|
|
195
197
|
File.open(path, File::CREAT | File::RDWR, 0o644) do |file|
|
|
196
198
|
file.flock(File::LOCK_EX)
|
|
197
|
-
file.truncate(
|
|
199
|
+
file.truncate(mapped_size) if file.size < mapped_size
|
|
198
200
|
ensure
|
|
199
201
|
file.flock(File::LOCK_UN) rescue nil
|
|
200
202
|
end
|
|
@@ -13,7 +13,12 @@ module Async
|
|
|
13
13
|
VERSION = 1
|
|
14
14
|
MIGRATION_BUSY_TIMEOUT_MS = 30_000
|
|
15
15
|
CORE_INDEXES = %w[idx_jobs_pending].freeze
|
|
16
|
-
DASHBOARD_INDEXES = %w[
|
|
16
|
+
DASHBOARD_INDEXES = %w[
|
|
17
|
+
idx_jobs_done_finished_at
|
|
18
|
+
idx_jobs_failed_finished_at
|
|
19
|
+
idx_jobs_executing_started_at
|
|
20
|
+
idx_jobs_claimed_locked_at
|
|
21
|
+
].freeze
|
|
17
22
|
REQUIRED_INDEXES = CORE_INDEXES
|
|
18
23
|
|
|
19
24
|
module_function
|
|
@@ -192,13 +192,24 @@ module Async
|
|
|
192
192
|
WHERE status = 'failed'
|
|
193
193
|
SQL
|
|
194
194
|
|
|
195
|
-
|
|
196
|
-
CREATE INDEX IF NOT EXISTS
|
|
195
|
+
CREATE_EXECUTING_INDEX = <<~SQL.freeze
|
|
196
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_executing_started_at
|
|
197
|
+
ON jobs(started_at)
|
|
198
|
+
WHERE status = 'running' AND started_at IS NOT NULL
|
|
199
|
+
SQL
|
|
200
|
+
|
|
201
|
+
CREATE_CLAIMED_INDEX = <<~SQL.freeze
|
|
202
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_claimed_locked_at
|
|
197
203
|
ON jobs(locked_at)
|
|
198
|
-
WHERE status = 'running'
|
|
204
|
+
WHERE status = 'running' AND started_at IS NULL
|
|
199
205
|
SQL
|
|
200
206
|
|
|
201
|
-
CREATE_DASHBOARD_INDEXES = [
|
|
207
|
+
CREATE_DASHBOARD_INDEXES = [
|
|
208
|
+
CREATE_DONE_INDEX,
|
|
209
|
+
CREATE_FAILED_INDEX,
|
|
210
|
+
CREATE_EXECUTING_INDEX,
|
|
211
|
+
CREATE_CLAIMED_INDEX
|
|
212
|
+
].freeze
|
|
202
213
|
end
|
|
203
214
|
end
|
|
204
215
|
end
|
|
@@ -29,8 +29,11 @@ module Async
|
|
|
29
29
|
:metrics,
|
|
30
30
|
:queue_store
|
|
31
31
|
|
|
32
|
+
# `config_path: nil` explicitly disables recurring jobs. This keeps the
|
|
33
|
+
# dynamic SQLite queue usable on its own; a supplied path remains strict
|
|
34
|
+
# so a typo cannot silently disable scheduled work.
|
|
32
35
|
def initialize(
|
|
33
|
-
config_path
|
|
36
|
+
config_path: nil,
|
|
34
37
|
job_count: 2,
|
|
35
38
|
worker_index:,
|
|
36
39
|
total_workers:,
|
|
@@ -53,8 +56,9 @@ module Async
|
|
|
53
56
|
|
|
54
57
|
@drain_barrier = ::Async::Barrier.new
|
|
55
58
|
@semaphore = ::Async::Semaphore.new(job_count, parent: @drain_barrier)
|
|
56
|
-
@heap = build_heap(config_path)
|
|
59
|
+
@heap = config_path.nil? ? MinHeap.new : build_heap(config_path)
|
|
57
60
|
setup_queue(queue_socket_dir, queue_db_path, queue_mmap)
|
|
61
|
+
validate_work_source!(config_path)
|
|
58
62
|
end
|
|
59
63
|
|
|
60
64
|
def run
|
|
@@ -82,6 +86,11 @@ module Async
|
|
|
82
86
|
private
|
|
83
87
|
|
|
84
88
|
def scheduler_loop(task)
|
|
89
|
+
# Queue-only workers have no heap entry to sleep on. Keep the runner
|
|
90
|
+
# alive until #stop / SIGTERM wakes this condition; the queue listener
|
|
91
|
+
# continues independently in its own Async task.
|
|
92
|
+
return shutdown.wait if heap.empty? && @listen_queue
|
|
93
|
+
|
|
85
94
|
loop do
|
|
86
95
|
entry = heap.peek
|
|
87
96
|
break unless entry
|
|
@@ -93,6 +102,12 @@ module Async
|
|
|
93
102
|
end
|
|
94
103
|
end
|
|
95
104
|
|
|
105
|
+
def validate_work_source!(config_path)
|
|
106
|
+
return unless config_path.nil? && !@listen_queue
|
|
107
|
+
|
|
108
|
+
raise ConfigError, 'Runner requires config_path or queue_socket_dir'
|
|
109
|
+
end
|
|
110
|
+
|
|
96
111
|
def wait_for_next_entry(task, entry)
|
|
97
112
|
wait = [entry.next_run_at - monotonic_now, MIN_SLEEP_TIME].max
|
|
98
113
|
wait_with_shutdown(task, wait)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Async
|
|
4
|
+
module Background
|
|
5
|
+
module Web
|
|
6
|
+
class App
|
|
7
|
+
def initialize(config)
|
|
8
|
+
@config = config.validate!
|
|
9
|
+
@logger = @config.logger
|
|
10
|
+
@auth = Auth.new(@config.auth, logger: @logger)
|
|
11
|
+
@snapshot = Snapshot.new(path: @config.queue_path, counts_cache_ttl: @config.counts_cache_ttl).open!
|
|
12
|
+
@metrics_reader = build_metrics_reader
|
|
13
|
+
@serializer = Serializer.new(@config)
|
|
14
|
+
@event_hub = build_event_hub
|
|
15
|
+
@router = Router.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(env)
|
|
19
|
+
head = env['REQUEST_METHOD'] == 'HEAD'
|
|
20
|
+
response = handle(env, head: head)
|
|
21
|
+
return response unless head
|
|
22
|
+
|
|
23
|
+
status, headers, _body = response
|
|
24
|
+
[status, headers, []]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def close
|
|
28
|
+
@event_hub&.close
|
|
29
|
+
@snapshot.close
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def handle(env, head:)
|
|
36
|
+
return Response.unauthorized unless @auth.authorized?(env)
|
|
37
|
+
|
|
38
|
+
route = @router.match(env)
|
|
39
|
+
return Response.not_found unless route
|
|
40
|
+
|
|
41
|
+
if head && route == :stream
|
|
42
|
+
return @config.transport == :sse ? [200, Response.sse_headers, []] : Response.not_found
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
dispatch(route, env)
|
|
46
|
+
rescue RequestError => error
|
|
47
|
+
Response.bad_request(error.message)
|
|
48
|
+
rescue UnavailableError, ClosedError
|
|
49
|
+
Response.unavailable
|
|
50
|
+
rescue StandardError => error
|
|
51
|
+
# Do not turn internal class names, paths or database errors into an
|
|
52
|
+
# unauthenticated information disclosure channel — but do surface
|
|
53
|
+
# them to the operator via the configured logger.
|
|
54
|
+
log_internal_error(env, error)
|
|
55
|
+
Response.internal_error
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def build_metrics_reader
|
|
59
|
+
return unless @config.metrics_enabled?
|
|
60
|
+
|
|
61
|
+
MetricsReader.new(path: @config.metrics_path, total_workers: @config.total_workers)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_event_hub
|
|
65
|
+
return unless @config.transport == :sse
|
|
66
|
+
|
|
67
|
+
EventHub.new(@snapshot, @serializer, metrics_reader: @metrics_reader)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def dispatch(route, env)
|
|
71
|
+
case route
|
|
72
|
+
when :index then Response.html(Assets.render_index(@config))
|
|
73
|
+
when :javascript then Response.javascript(Assets::JS)
|
|
74
|
+
when :stylesheet then Response.stylesheet(Assets::CSS)
|
|
75
|
+
when :overview then overview_response
|
|
76
|
+
when :executing then in_flight_response(:executing, env)
|
|
77
|
+
when :claimed then in_flight_response(:claimed, env)
|
|
78
|
+
when :done then terminal_response(:done, env)
|
|
79
|
+
when :failed then terminal_response(:failed, env)
|
|
80
|
+
when :pending then pending_response(env)
|
|
81
|
+
when :metrics then metrics_response
|
|
82
|
+
when :config then config_response
|
|
83
|
+
when :stream then stream_response
|
|
84
|
+
else Response.not_found
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def overview_response
|
|
89
|
+
Response.json(@serializer.overview(@snapshot.overview, metrics_payload))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def in_flight_response(kind, env)
|
|
93
|
+
request = Request.new(env, @config)
|
|
94
|
+
rows = kind == :executing ? @snapshot.executing(limit: request.limit) : @snapshot.claimed(limit: request.limit)
|
|
95
|
+
payload = kind == :executing ? @serializer.executing(rows) : @serializer.claimed(rows)
|
|
96
|
+
Response.json({items: payload})
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def terminal_response(kind, env)
|
|
100
|
+
request = Request.new(env, @config)
|
|
101
|
+
cursor = request.finished_cursor
|
|
102
|
+
rows = kind == :done ? @snapshot.recent_done(limit: request.limit, cursor: cursor) :
|
|
103
|
+
@snapshot.recent_failed(limit: request.limit, cursor: cursor)
|
|
104
|
+
payload = kind == :done ? @serializer.done(rows) : @serializer.failed(rows)
|
|
105
|
+
Response.json(payload)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def pending_response(env)
|
|
109
|
+
request = Request.new(env, @config)
|
|
110
|
+
rows = @snapshot.pending(limit: request.limit, cursor: request.pending_cursor)
|
|
111
|
+
Response.json(@serializer.pending(rows))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def metrics_response
|
|
115
|
+
Response.json(metrics_payload || {available: false, workers: [], totals: MetricsReader::EMPTY_TOTALS})
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def metrics_payload
|
|
119
|
+
@metrics_reader&.aggregated
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def config_response
|
|
123
|
+
Response.json(
|
|
124
|
+
{
|
|
125
|
+
title: @config.title,
|
|
126
|
+
poll_interval_ms: @config.poll_interval_ms,
|
|
127
|
+
transport: @config.transport.to_s,
|
|
128
|
+
expose_args: @config.expose_args,
|
|
129
|
+
list_limit: @config.list_limit,
|
|
130
|
+
mount_path: @config.mount_path
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def stream_response
|
|
136
|
+
return Response.not_found unless @config.transport == :sse
|
|
137
|
+
|
|
138
|
+
Response.sse(
|
|
139
|
+
Stream.new(
|
|
140
|
+
@event_hub,
|
|
141
|
+
heartbeat_seconds: @config.stream_heartbeat_seconds,
|
|
142
|
+
retry_ms: @config.stream_retry_ms,
|
|
143
|
+
poll_seconds: @config.stream_poll_seconds,
|
|
144
|
+
logger: @logger
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def log_internal_error(env, error)
|
|
150
|
+
@logger&.error(
|
|
151
|
+
"[async-background-web] internal error on " \
|
|
152
|
+
"#{env['REQUEST_METHOD']} #{env['PATH_INFO']}: " \
|
|
153
|
+
"#{error.class}: #{error.message}"
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|