cogworker 0.4.1 → 0.5.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/LICENSE.txt +21 -0
- data/README.md +596 -0
- data/lib/cogworker/basic_fetch.rb +4 -3
- data/lib/cogworker/best_effort.rb +23 -0
- data/lib/cogworker/cli.rb +3 -2
- data/lib/cogworker/client.rb +17 -4
- data/lib/cogworker/config.rb +16 -1
- data/lib/cogworker/deferred_releases.rb +46 -0
- data/lib/cogworker/heartbeat.rb +72 -15
- data/lib/cogworker/history/middleware.rb +9 -5
- data/lib/cogworker/job_util.rb +39 -4
- data/lib/cogworker/key_guard.rb +127 -0
- data/lib/cogworker/launcher.rb +6 -2
- data/lib/cogworker/lua_script.rb +3 -3
- data/lib/cogworker/manager.rb +76 -1
- data/lib/cogworker/periodic/claim.lua +5 -2
- data/lib/cogworker/periodic/release_middleware.rb +13 -2
- data/lib/cogworker/periodic/running_lock.rb +12 -0
- data/lib/cogworker/periodic/ticker.rb +148 -40
- data/lib/cogworker/process_set.rb +3 -0
- data/lib/cogworker/processor.rb +94 -19
- data/lib/cogworker/redis_errors.rb +93 -0
- data/lib/cogworker/redis_keys.rb +13 -0
- data/lib/cogworker/reliable_fetch.rb +477 -60
- data/lib/cogworker/scheduled.rb +101 -18
- data/lib/cogworker/status/server_middleware.rb +13 -7
- data/lib/cogworker/swarm.rb +2 -0
- data/lib/cogworker/unique_jobs/release_middleware.rb +16 -5
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/layout.rb +3 -1
- data/lib/cogworker/web/routes/jobs.rb +4 -4
- data/lib/cogworker/web/routes/overview.rb +4 -2
- data/lib/cogworker/work_set.rb +3 -0
- data/lib/cogworker.rb +1 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa6ea3cf9620f3c099c39fe6bba306cd78b037e8c0c7c8dab161586c98d1081f
|
|
4
|
+
data.tar.gz: b0b58c6118437e59c6748202ef21a8980ed22f916f27ed14ec3179de8bb435e0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ce3267edd66d5fcab03aec48a0ae8b3fbeb749a1c4e7db298158387c9fd7a8c6f7332dee99ed1b91499bc9c0b87f748dfb966c274b55ad6e643e76afbf5d57d9
|
|
7
|
+
data.tar.gz: d1706f96404137a3594ca6182988a10c990f77a15b4f5aa0a2f2e758eab6dcc4815f88a9c6ac5aada117bfe3f5660df93e489b9df311460c5d7d2829e76868d4
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vdonec
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
# Cogworker
|
|
2
|
+
|
|
3
|
+
Redis-backed background job processing for Ruby. It includes a worker DSL,
|
|
4
|
+
retries with backoff, delayed and periodic (cron) jobs, unique jobs, job
|
|
5
|
+
status and run history, a multi-process supervisor with phased restarts, a
|
|
6
|
+
Web UI, and a Prometheus exporter.
|
|
7
|
+
|
|
8
|
+
- Ruby ≥ 3.1, Redis
|
|
9
|
+
- The Web UI works fully offline. htmx, its stylesheet, fonts, icons, AG Grid
|
|
10
|
+
and Chart.js all ship inside the gem, with no CDN.
|
|
11
|
+
- MIT license
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# Gemfile
|
|
17
|
+
gem 'cogworker'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
bundle install
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Requires a standalone Redis (or a primary/replica setup) — Redis Cluster
|
|
25
|
+
isn't supported: several Lua scripts touch keys they don't declare up front.
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
### 1. Define a job
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
class GreetingJob
|
|
33
|
+
include Cogworker::Worker # or Cogworker::Job, the same module
|
|
34
|
+
|
|
35
|
+
def perform(name)
|
|
36
|
+
Cogworker.logger.info { "Hello, #{name}!" }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Arguments are serialized as JSON, so pass only simple values: strings,
|
|
42
|
+
numbers, `true`/`false`/`nil`, arrays and hashes. Hash keys arrive in
|
|
43
|
+
`perform` as **strings**, so don't use keyword arguments (`**opts`) in
|
|
44
|
+
`perform`.
|
|
45
|
+
|
|
46
|
+
### 2. Enqueue it
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
jid = GreetingJob.perform_async('world') # now
|
|
50
|
+
GreetingJob.perform_in(300, 'world') # in 5 minutes
|
|
51
|
+
GreetingJob.perform_at(Time.now + 3600, 'world') # at a specific time
|
|
52
|
+
|
|
53
|
+
# Low-level, without a job class method:
|
|
54
|
+
Cogworker::Client.push('class' => 'GreetingJob', 'args' => ['world'], 'queue' => 'default')
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 3. Write an init file
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# config/cogworker.rb
|
|
61
|
+
require 'cogworker'
|
|
62
|
+
require_relative '../app/jobs/greeting_job'
|
|
63
|
+
|
|
64
|
+
Cogworker.configure_server do |config|
|
|
65
|
+
config.redis = { url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
Cogworker.configure_client do |config|
|
|
69
|
+
config.redis = { url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') }
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The `configure_server` block runs only in a worker process (one started via
|
|
74
|
+
`exe/cogworker`/`exe/cogworkerswarm`). The `configure_client` block runs only
|
|
75
|
+
in every other process (web app, console). This lets one file be loaded in
|
|
76
|
+
both places.
|
|
77
|
+
|
|
78
|
+
Redis options are `url:`, or `host:`/`port:`/`password:`/`db:`. On the
|
|
79
|
+
server, the connection pool size is `concurrency + 5`.
|
|
80
|
+
|
|
81
|
+
Load your job classes up front, in the init file, rather than leaving them to
|
|
82
|
+
an autoloader. A worker resolves a job's class on whichever of its threads
|
|
83
|
+
picks the job up, so with an autoloader that isn't thread-safe, two threads
|
|
84
|
+
can race to load the same class and one of them sees it half-defined
|
|
85
|
+
(`undefined method 'perform'`). With Rails, keep `config.eager_load = true`
|
|
86
|
+
in the environment the worker runs in; with Zeitwerk on its own, call
|
|
87
|
+
`loader.eager_load` after `loader.setup`. Classes named in
|
|
88
|
+
`config.periodic` are resolved on the main thread at boot either way, and one
|
|
89
|
+
that can't be found is logged as a warning.
|
|
90
|
+
|
|
91
|
+
### 4. Start a worker
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
bundle exec cogworker -r ./config/cogworker.rb -C ./config/cogworker.yml
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Process configuration
|
|
98
|
+
|
|
99
|
+
### CLI flags
|
|
100
|
+
|
|
101
|
+
| Flag | Purpose |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `-r, --require PATH` | the app's init file (or a directory, in which case `PATH/config/environment` is required) |
|
|
104
|
+
| `-C, --config PATH` | YAML config file (see below) |
|
|
105
|
+
| `-c, --concurrency N` | number of worker threads (default 10) |
|
|
106
|
+
| `-q, --queue NAME[,WEIGHT]` | a queue with an optional weight; repeatable; overrides `:queues:` from the config file |
|
|
107
|
+
| `-L, --logfile PATH` | write the log to a file |
|
|
108
|
+
| `-e, --environment ENV` | environment (default `APP_ENV`/`RAILS_ENV`/`RACK_ENV`/`development`); exported as `APP_ENV`, `RAILS_ENV` and `RACK_ENV` before the config file and app code are loaded |
|
|
109
|
+
|
|
110
|
+
### YAML config
|
|
111
|
+
|
|
112
|
+
The file is rendered through ERB before it is parsed:
|
|
113
|
+
|
|
114
|
+
```yaml
|
|
115
|
+
---
|
|
116
|
+
:concurrency: <%= ENV["CONCURRENCY"] || 5 %>
|
|
117
|
+
:queues:
|
|
118
|
+
- default
|
|
119
|
+
- default # repeating a queue sets its weight: default is polled twice as often as low
|
|
120
|
+
- low
|
|
121
|
+
:fetch: reliable # or basic — see "Fetch modes" below
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
CLI flags take precedence over the file.
|
|
125
|
+
|
|
126
|
+
### Signals
|
|
127
|
+
|
|
128
|
+
| Signal | Effect |
|
|
129
|
+
|---|---|
|
|
130
|
+
| `TSTP` | quiet: stop fetching new jobs; jobs already running are finished |
|
|
131
|
+
| `TERM` / `INT` | graceful stop: wait for running jobs (up to 25 s), put any still unfinished back on their queues, then exit |
|
|
132
|
+
|
|
133
|
+
### Fetch modes
|
|
134
|
+
|
|
135
|
+
`config.fetch` (or `:fetch:` in the YAML file) picks how workers take jobs
|
|
136
|
+
off their queues:
|
|
137
|
+
|
|
138
|
+
- `:reliable` (default, needs Redis >= 6.2): a job is moved atomically onto
|
|
139
|
+
the process's own in-progress list and stays there until it has finished.
|
|
140
|
+
If the process dies mid-job (OOM, `SIGKILL`, a lost host), the job is put
|
|
141
|
+
back on its queue by another live process once the dead one has gone
|
|
142
|
+
`config.orphan_threshold` without a heartbeat (default 5 minutes, then up
|
|
143
|
+
to a minute until the next check). The threshold is deliberately well
|
|
144
|
+
above the heartbeat's own 60 s expiry: a process that is alive but
|
|
145
|
+
couldn't reach Redis for a while (an outage, a failover) mustn't have its
|
|
146
|
+
running jobs started a second time elsewhere. Lower it if crashed
|
|
147
|
+
processes' jobs must come back sooner; raise it if Redis blips longer
|
|
148
|
+
than that are expected. A worker with a single queue waits on Redis
|
|
149
|
+
for jobs (`BLMOVE`), picking a new one up immediately. With several
|
|
150
|
+
(weighted) queues they are polled instead: while they stay empty, each
|
|
151
|
+
worker thread waits 0.25 s, then
|
|
152
|
+
0.5 s, … up to `config.fetch_idle_max_interval` (default 1 s) between
|
|
153
|
+
polls, and goes back to 0.25 s as soon as it finds a job. That cap is the
|
|
154
|
+
longest an idle worker takes to notice a new job; lower it (e.g. `0.25`)
|
|
155
|
+
if that matters more than how often idle workers poll Redis.
|
|
156
|
+
- `:basic`: a single blocking `BRPOP` across all queues, as in earlier
|
|
157
|
+
versions. A job popped by a process that then dies is lost.
|
|
158
|
+
|
|
159
|
+
On Redis older than 6.2, `:reliable` falls back to `:basic` with a warning —
|
|
160
|
+
checked when workers start, and again on the first fetch if the server turns
|
|
161
|
+
out not to support it after all. A worker doesn't start taking jobs until it
|
|
162
|
+
has registered itself in Redis, so with Redis unreachable at boot it waits
|
|
163
|
+
(and still stops on `TERM`).
|
|
164
|
+
Either way delivery is *at least once*: a job that was partly done when its
|
|
165
|
+
process died or was stopped runs again from the start, so make jobs safe to
|
|
166
|
+
repeat. While the worker process stays up, a job is not re-run because of a
|
|
167
|
+
Redis error *after* it ran: once it has run it is acknowledged (and, if it
|
|
168
|
+
failed, recorded in Retry/Dead). If recording the failure itself fails, the
|
|
169
|
+
job goes to Retry after a short delay — or straight to Dead if it had no
|
|
170
|
+
retries left (`retry: false`/`0`, or its last one used up), and to Dead too
|
|
171
|
+
after 3 such interruptions. If even that, or the acknowledgement, can't be
|
|
172
|
+
written, the process remembers the job and keeps trying to settle it,
|
|
173
|
+
without running it again. That memory doesn't survive the process: if it
|
|
174
|
+
dies (or is stopped) while Redis is still failing, such a job can run once
|
|
175
|
+
more after recovery.
|
|
176
|
+
|
|
177
|
+
A job whose process dies while running it more than
|
|
178
|
+
`config.max_orphanings` times (default 3; one that runs the process out of
|
|
179
|
+
memory, say) goes to Dead with `Cogworker::ProcessDied` instead of being
|
|
180
|
+
recovered again — whatever its `retry` setting. A restart or deploy stopping
|
|
181
|
+
it cleanly doesn't count towards that, and the count resets once the job
|
|
182
|
+
gets to finish. It can't single out the culprit, though: every job running
|
|
183
|
+
in a process that crashed is counted, so a job that just happened to share a
|
|
184
|
+
process with a memory hog several times can end up in Dead too — raise the
|
|
185
|
+
limit if that's plausible for you.
|
|
186
|
+
|
|
187
|
+
With redis-rb 4.x, a connection that drops in the middle of a blocking
|
|
188
|
+
fetch is retried by the client itself; if Redis had already handed out a
|
|
189
|
+
job on the dropped connection, that job waits on the process's in-progress
|
|
190
|
+
list until it's noticed (about two minutes) rather than running at once.
|
|
191
|
+
It isn't lost.
|
|
192
|
+
|
|
193
|
+
### Damaged keys
|
|
194
|
+
|
|
195
|
+
Every worker checks this gem's Redis keys once a minute (and at boot). A
|
|
196
|
+
key holding the wrong type of value — which only a manual write or data
|
|
197
|
+
corruption produces, but which breaks every command on it — is renamed to
|
|
198
|
+
`cogworker:quarantine:<key>:<time>:<random>` (kept 30 days, at most the 5
|
|
199
|
+
newest copies per key) and logged as an error,
|
|
200
|
+
and everything carries on with a fresh key. In between checks, work around
|
|
201
|
+
such a key keeps going: a job that can't be put back on a broken queue
|
|
202
|
+
waits behind the others rather than blocking them, a retry/schedule entry
|
|
203
|
+
that can't be moved is postponed a minute, and a failed job that can't be
|
|
204
|
+
filed when its process shuts down is handed over to
|
|
205
|
+
`cogworker:unsettled` for another process to file — not run again. (One
|
|
206
|
+
corner isn't covered: a job that hit `config.max_orphanings` while the list
|
|
207
|
+
used to park such jobs was itself damaged goes into Dead raw first and is
|
|
208
|
+
completed with its error right after — retried until that succeeds, but
|
|
209
|
+
only by the process that put it there: if that process dies first, the raw
|
|
210
|
+
entry stays as it is.)
|
|
211
|
+
|
|
212
|
+
A lock release (`until_executed`, unique jobs) that fails because Redis is
|
|
213
|
+
briefly away when a job finishes is retried on every heartbeat (5 s) until
|
|
214
|
+
it goes through, rather than leaving the lock to expire on its own — kept in
|
|
215
|
+
the process's memory, so if the process stops first, the lock expires on its
|
|
216
|
+
TTL instead.
|
|
217
|
+
|
|
218
|
+
### Multiple processes (swarm)
|
|
219
|
+
|
|
220
|
+
```sh
|
|
221
|
+
COGWORKER_COUNT=4 PHASED_RESTART=true \
|
|
222
|
+
bundle exec cogworkerswarm -r ./config/cogworker.rb -C ./config/cogworker.yml
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`cogworkerswarm` accepts the same flags as `cogworker`. It forks
|
|
226
|
+
`COGWORKER_COUNT` child processes and restarts any that die. A child that
|
|
227
|
+
keeps crashing within 30 s of starting is restarted with an exponential
|
|
228
|
+
backoff (1 s, 2 s, 4 s, … up to 60 s) instead of in a tight loop. With
|
|
229
|
+
`PHASED_RESTART=true`, sending `USR2` to the parent restarts the children one
|
|
230
|
+
at a time, so processing never stops. `TERM`/`INT` are forwarded to all
|
|
231
|
+
children.
|
|
232
|
+
|
|
233
|
+
## Job options
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
class ReportJob
|
|
237
|
+
include Cogworker::Worker
|
|
238
|
+
|
|
239
|
+
cogworker_options queue: 'low', retry: 5
|
|
240
|
+
end
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
| Option | Meaning |
|
|
244
|
+
|---|---|
|
|
245
|
+
| `queue:` | queue name (default `default`) |
|
|
246
|
+
| `retry:` | `true` or unset: 25 attempts; an integer: that many retries; `false` or `0`: straight to Dead |
|
|
247
|
+
| `unique: :until_executed` | don't enqueue a duplicate (same class + queue + args) until the previous one has finished |
|
|
248
|
+
|
|
249
|
+
Subclasses inherit options. Any other keys (for example
|
|
250
|
+
`lock_run: :while_executing`) are copied unchanged into the job hash, where
|
|
251
|
+
your own middleware can read them.
|
|
252
|
+
|
|
253
|
+
### Job arguments
|
|
254
|
+
|
|
255
|
+
Arguments are stored as JSON and are neither validated nor coerced on the
|
|
256
|
+
way in, so `perform` receives whatever survives a JSON round trip: Symbols
|
|
257
|
+
come back as Strings, Hash keys as Strings, and objects like `Time` as their
|
|
258
|
+
`to_s`. Pass plain JSON types (String, Integer, Float, `true`/`false`/`nil`,
|
|
259
|
+
Array, Hash with String keys) to get back exactly what you pushed.
|
|
260
|
+
`Cogworker.strict_args!` is accepted but does nothing: since there's no
|
|
261
|
+
coercion, there's nothing to make stricter.
|
|
262
|
+
|
|
263
|
+
`Testing.fake!`/`inline!` skip that round trip, so a test can pass while a
|
|
264
|
+
real worker would receive Strings instead of Symbols.
|
|
265
|
+
|
|
266
|
+
### Retries and Dead
|
|
267
|
+
|
|
268
|
+
A failed job moves to the Retry set with a growing delay
|
|
269
|
+
(`count**4 + 15 + jitter` seconds). When it runs out of attempts, it moves to
|
|
270
|
+
Dead. You can put it back on its queue by hand from the Web UI.
|
|
271
|
+
|
|
272
|
+
### Unique jobs
|
|
273
|
+
|
|
274
|
+
With `unique: :until_executed`, calling `perform_async` again with the same
|
|
275
|
+
arguments returns `nil` instead of a jid while the original job is still
|
|
276
|
+
enqueued or running. The lock is released when the job succeeds or fails for
|
|
277
|
+
the last time. In case a process crashes, the lock also has a safety TTL,
|
|
278
|
+
`config.unique_lock_ttl` (default 24 h), counted from when the job is due (so
|
|
279
|
+
a `perform_in` further out than that keeps its lock) and extended by the
|
|
280
|
+
backoff each time an attempt fails and will be retried.
|
|
281
|
+
|
|
282
|
+
## Middleware
|
|
283
|
+
|
|
284
|
+
There are two chains: the client chain runs when a job is enqueued, and the
|
|
285
|
+
server chain runs around `perform`.
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
class TimingMiddleware
|
|
289
|
+
# server: call(worker, job, queue)
|
|
290
|
+
def call(_worker, job, queue)
|
|
291
|
+
start = Time.now
|
|
292
|
+
yield
|
|
293
|
+
Cogworker.logger.info { "#{job['class']} on #{queue}: #{Time.now - start}s" }
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
class TagMiddleware
|
|
298
|
+
# client: call(worker_class, job, queue, redis_pool)
|
|
299
|
+
def call(_worker_class, job, _queue, _redis_pool)
|
|
300
|
+
job['tags'] = ['api']
|
|
301
|
+
yield
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
Cogworker.configure_server do |config|
|
|
306
|
+
config.server_middleware { |chain| chain.add(TimingMiddleware) }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
Cogworker.configure_client do |config|
|
|
310
|
+
config.client_middleware { |chain| chain.add(TagMiddleware) }
|
|
311
|
+
end
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Anything a server middleware raises counts as the job failing — including
|
|
315
|
+
an error from its own bookkeeping after `yield` (writing to Redis during a
|
|
316
|
+
failover, say), which would retry a job that actually ran fine. The
|
|
317
|
+
built-in ones make every write around `yield` best-effort; do the same in
|
|
318
|
+
yours: rescue (and log) errors from your own writes, and only let the job's
|
|
319
|
+
own exception through.
|
|
320
|
+
|
|
321
|
+
`chain.add(Klass, *args)` passes `args` to the constructor. A new middleware
|
|
322
|
+
instance is created for every call, so keep any state shared between jobs at
|
|
323
|
+
the class level.
|
|
324
|
+
|
|
325
|
+
## Periodic jobs (cron)
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
Cogworker.configure_server do |config|
|
|
329
|
+
config.periodic do |mgr|
|
|
330
|
+
mgr.register '0 * * * *', 'HourlyCleanupJob'
|
|
331
|
+
mgr.register '*/5 * * * *', 'DailyReportJob', retry: 0, unique: :until_executed,
|
|
332
|
+
args: [{ section: 'digest' }]
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Each schedule slot runs **exactly once**, no matter how many processes are
|
|
338
|
+
running. (Two narrow exceptions: a push that times out after Redis already
|
|
339
|
+
applied it can't be told apart from one that never arrived, so that slot may
|
|
340
|
+
run twice; and if a push fails while Redis is unreachable, undoing that
|
|
341
|
+
slot's claim is retried from memory — lost if the process restarts first,
|
|
342
|
+
in which case the slot is skipped and, for an `until_executed` entry, the
|
|
343
|
+
entry stays locked until that lock's TTL, `config.unique_lock_ttl`.) An
|
|
344
|
+
entry that keeps failing (its push, say) is retried with a growing pause,
|
|
345
|
+
5 s up to 5 minutes, instead of on every 5 s tick — except while Redis
|
|
346
|
+
itself is unreachable or failing over, which is retried every 5 s and
|
|
347
|
+
resumes as soon as Redis is back. (Redis refusing writes for lack of memory,
|
|
348
|
+
`OOM`, counts as the entry failing: it backs off like any other error.)
|
|
349
|
+
Right after Redis comes back from an outage longer than a running
|
|
350
|
+
`until_executed` job's lock, there's a window of up to 5 s before its
|
|
351
|
+
process re-takes that lock, in which the next slot could start alongside
|
|
352
|
+
it. Conversely, a slot found blocked by the previous run's lock is retried
|
|
353
|
+
for 15 s before it counts as skipped — long enough for a release that the
|
|
354
|
+
outage delayed to go through. Processes claim a slot
|
|
355
|
+
atomically in Redis, with no leader. With
|
|
356
|
+
`unique: :until_executed`, a new slot is skipped while the previous run is
|
|
357
|
+
still in progress. If the process running it dies (OOM, `SIGKILL`), the
|
|
358
|
+
interrupted run is recovered and put back on its queue — still holding the
|
|
359
|
+
entry's lock — once `config.orphan_threshold` has passed (5 minutes by
|
|
360
|
+
default); the lock itself lapses on its own `config.orphan_threshold` plus
|
|
361
|
+
about two minutes after the process's last heartbeat (7 minutes by default).
|
|
362
|
+
|
|
363
|
+
On first start against an empty Redis, each entry's most recent due slot
|
|
364
|
+
fires right away. To turn that off, set `config.periodic_catch_up = false`.
|
|
365
|
+
|
|
366
|
+
## Job status
|
|
367
|
+
|
|
368
|
+
```ruby
|
|
369
|
+
Cogworker.configure_server do |config|
|
|
370
|
+
Cogworker::Status.configure_server_middleware(config, expiration: 1800)
|
|
371
|
+
end
|
|
372
|
+
Cogworker.configure_client do |config|
|
|
373
|
+
Cogworker::Status.configure_client_middleware(config, expiration: 1800)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
class ImportJob
|
|
377
|
+
include Cogworker::Worker
|
|
378
|
+
include Cogworker::Status::Worker
|
|
379
|
+
|
|
380
|
+
def perform(file)
|
|
381
|
+
at(50, 'half way') # progress percentage + message
|
|
382
|
+
store('rows' => 1000) # arbitrary fields
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
Cogworker::Status.status(jid) # => :queued | :working | :retrying | :complete | :failed (nil if unknown)
|
|
387
|
+
Cogworker::Status.get(jid) # => the full hash: status, pct, message, ...
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Status is stored in Redis with a TTL of `expiration` seconds.
|
|
391
|
+
|
|
392
|
+
## Run history
|
|
393
|
+
|
|
394
|
+
Status holds only the current state. History records every finished run:
|
|
395
|
+
its arguments, its duration and, on failure, the error class, message and
|
|
396
|
+
backtrace. You can browse it on the Web UI's History tab.
|
|
397
|
+
|
|
398
|
+
```ruby
|
|
399
|
+
Cogworker.configure_server do |config|
|
|
400
|
+
Cogworker::History.configure_server_middleware(
|
|
401
|
+
config,
|
|
402
|
+
retention_days: 30, # the main trim: by age
|
|
403
|
+
max_entries: 50_000, # safety ceiling by count
|
|
404
|
+
daily_stats_retention_days: 400 # counters behind the "Runs per day" chart
|
|
405
|
+
)
|
|
406
|
+
end
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
## Web UI
|
|
410
|
+
|
|
411
|
+
```ruby
|
|
412
|
+
# config.ru
|
|
413
|
+
require_relative 'config/cogworker'
|
|
414
|
+
|
|
415
|
+
Cogworker::Web.time_format = '%d.%m.%Y %H:%M:%S' # timestamp format (strftime)
|
|
416
|
+
Cogworker::Web.live_update_interval = 5 # auto-refresh interval, seconds
|
|
417
|
+
Cogworker::Web.history_per_page = 50 # rows per History page
|
|
418
|
+
|
|
419
|
+
map '/cogworker' do
|
|
420
|
+
run Cogworker::Web
|
|
421
|
+
end
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
Tabs:
|
|
425
|
+
|
|
426
|
+
- **Overview**: counters, latency per queue, the queue list (browse, delete,
|
|
427
|
+
pause, retry all), a 24-hour throughput chart, a "Runs per day" chart, and
|
|
428
|
+
Redis info. A second "Queue first" layout shows one queue in detail.
|
|
429
|
+
- **Jobs**: every job in one table (enqueued, running, scheduled, retrying,
|
|
430
|
+
dead), with a status filter, search, and a detail panel (args, attempt
|
|
431
|
+
history, last error, retry/reschedule/delete).
|
|
432
|
+
- **Schedules**: periodic jobs with their last and next run, plus
|
|
433
|
+
"Run now" and "Disable"/"Enable" buttons.
|
|
434
|
+
- **Workers**: live processes (queues, load, memory, heartbeat,
|
|
435
|
+
quiet/resume/stop) and the jobs running right now.
|
|
436
|
+
- **History**: the run log, with server-side sorting, filtering and
|
|
437
|
+
paging, and backtraces for failed runs.
|
|
438
|
+
|
|
439
|
+
The header also has these controls:
|
|
440
|
+
|
|
441
|
+
- **Live** toggles auto-refresh on every tab at once.
|
|
442
|
+
- **Pause intake** / **Resume intake** quiet or resume every worker process
|
|
443
|
+
at once.
|
|
444
|
+
- A width toggle switches between fixed-width and full-width layout.
|
|
445
|
+
|
|
446
|
+
Timestamps are shown in the browser's time zone. Every button also works
|
|
447
|
+
without JavaScript.
|
|
448
|
+
|
|
449
|
+
### Authentication and custom middleware
|
|
450
|
+
|
|
451
|
+
```ruby
|
|
452
|
+
Cogworker::Web.use(MyAuthMiddleware) # wraps the whole Web UI app
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Unsafe methods (POST etc.) are accepted only with
|
|
456
|
+
`Sec-Fetch-Site: same-origin`. To allow an exception (for example, an SSO
|
|
457
|
+
callback), override `Cogworker::Web.safe_request?(env)`.
|
|
458
|
+
|
|
459
|
+
The Web UI's session cookie (used only by middleware you add with `.use`) is
|
|
460
|
+
skipped automatically when the app mounting the Web UI already has a session
|
|
461
|
+
(the middleware then reads and writes the app's own session), and can be
|
|
462
|
+
turned off with `Cogworker::Web.builtin_session = false`. It is signed with a
|
|
463
|
+
random per-process secret by default. When running several
|
|
464
|
+
web processes (e.g. Puma workers), set the same secret for all of them —
|
|
465
|
+
`Cogworker::Web.session_secret = '...'` (at least 64 characters) or the
|
|
466
|
+
`COGWORKER_SESSION_SECRET` environment variable.
|
|
467
|
+
|
|
468
|
+
### Extensions
|
|
469
|
+
|
|
470
|
+
```ruby
|
|
471
|
+
module MyTab
|
|
472
|
+
def self.registered(app)
|
|
473
|
+
app.get('/my_tab') { ... }
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
Cogworker::Web.register(MyTab, tab: 'My tab', index: 'my_tab')
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
## Prometheus
|
|
481
|
+
|
|
482
|
+
The Web UI serves `GET <mount>/metrics` in Prometheus text format. The
|
|
483
|
+
metrics are:
|
|
484
|
+
|
|
485
|
+
- `cogworker_processed_total`
|
|
486
|
+
- `cogworker_failed_total`
|
|
487
|
+
- `cogworker_retry_size`
|
|
488
|
+
- `cogworker_scheduled_size`
|
|
489
|
+
- `cogworker_dead_size`
|
|
490
|
+
- `cogworker_queue_size{queue=…}`
|
|
491
|
+
- `cogworker_queue_latency_seconds{queue=…}`
|
|
492
|
+
- `cogworker_busy_workers`
|
|
493
|
+
|
|
494
|
+
To turn the endpoint off, set `Cogworker::Web.prometheus_exporter_enabled = false`.
|
|
495
|
+
|
|
496
|
+
## Introspection API
|
|
497
|
+
|
|
498
|
+
```ruby
|
|
499
|
+
stats = Cogworker::Stats.new
|
|
500
|
+
stats.processed; stats.failed; stats.enqueued
|
|
501
|
+
stats.retry_size; stats.scheduled_size; stats.dead_size
|
|
502
|
+
|
|
503
|
+
q = Cogworker::Queue.new('default')
|
|
504
|
+
q.size; q.latency # latency: age of the oldest job, in seconds
|
|
505
|
+
q.each { |job| job.klass; job.args; job.jid }
|
|
506
|
+
q.pause!; q.resume!; q.clear
|
|
507
|
+
|
|
508
|
+
Cogworker::ProcessSet.new.each do |process|
|
|
509
|
+
process['identity']; process['busy']
|
|
510
|
+
process.quiet! # remotely: stop fetching new jobs
|
|
511
|
+
process.resume! # remotely: start fetching again
|
|
512
|
+
process.stop! # remotely: finish running jobs and exit
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
Cogworker::WorkSet.new.each { |identity, thread_id, work| work.job } # jobs running right now
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
## Testing
|
|
519
|
+
|
|
520
|
+
```ruby
|
|
521
|
+
require 'cogworker'
|
|
522
|
+
|
|
523
|
+
Cogworker::Testing.fake! # jobs are collected in memory; Redis isn't touched
|
|
524
|
+
GreetingJob.perform_async('bob')
|
|
525
|
+
expect(GreetingJob.jobs.size).to eq(1)
|
|
526
|
+
expect(GreetingJob.jobs.first['args']).to eq(['bob'])
|
|
527
|
+
GreetingJob.perform_one # run the oldest recorded job (raises Testing::EmptyQueueError if none)
|
|
528
|
+
GreetingJob.drain # run them all, including jobs they enqueue
|
|
529
|
+
Cogworker::Testing.drain_all # the same, for every job class
|
|
530
|
+
GreetingJob.clear # or Cogworker::Testing.clear_jobs!
|
|
531
|
+
|
|
532
|
+
Cogworker::Testing.inline! do
|
|
533
|
+
GreetingJob.perform_async('bob') # runs synchronously through the server middleware chain
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
Cogworker::Testing.disable! # back to real Redis (the default mode)
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
All three methods accept a block: the mode applies only inside the block,
|
|
540
|
+
then the previous mode comes back. In `inline!` mode, an exception from
|
|
541
|
+
`perform` is raised to the caller, and the `perform_in` delay is ignored.
|
|
542
|
+
|
|
543
|
+
## Example app
|
|
544
|
+
|
|
545
|
+
`examples/` contains a working app: jobs, an init file, `cogworker.yml`,
|
|
546
|
+
and a `config.ru` that mounts the Web UI. See
|
|
547
|
+
[`examples/README.md`](examples/README.md) for how to run it.
|
|
548
|
+
|
|
549
|
+
## Upgrading
|
|
550
|
+
|
|
551
|
+
- **From 0.3.0, with a rolling upgrade:** until every process runs a newer
|
|
552
|
+
version, 0.3.0 processes still treat another process as dead as soon as
|
|
553
|
+
its 60 s heartbeat key expires (newer ones wait for
|
|
554
|
+
`config.orphan_threshold`), so a live process that couldn't reach Redis
|
|
555
|
+
for a minute can have its running jobs started a second time. Upgrade
|
|
556
|
+
the whole fleet together, or accept that window.
|
|
557
|
+
- `until_executed` periodic jobs already enqueued by 0.3.0 don't carry the
|
|
558
|
+
marker that lets a recovery re-take their running lock: if such a job is
|
|
559
|
+
recovered after a crash, the entry's next slot can run alongside it.
|
|
560
|
+
- `Status.status(jid)` returns a Symbol since 0.3.0 (it was a String).
|
|
561
|
+
|
|
562
|
+
## Development
|
|
563
|
+
|
|
564
|
+
```sh
|
|
565
|
+
bundle install
|
|
566
|
+
bundle exec rspec # needs a local Redis at redis://localhost:6379/15 (the db is flushed!)
|
|
567
|
+
bundle exec rubocop # must pass — CI runs it too
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
`.rubocop.yml` sets the limits for new code; files that already exceed
|
|
571
|
+
them are listed in `.rubocop_todo.yml`. Fix and remove entries there rather
|
|
572
|
+
than adding new ones.
|
|
573
|
+
|
|
574
|
+
To use a different test Redis, set `COGWORKER_TEST_REDIS_URL`. The browser
|
|
575
|
+
tests (`spec/cogworker/web_system_spec.rb`) need Chrome or Chromium
|
|
576
|
+
installed.
|
|
577
|
+
|
|
578
|
+
CI runs the suite on Ruby 3.1–4.0 against both redis-rb 4.8 and 5.x, via
|
|
579
|
+
`gemfiles/redis_4.gemfile` / `gemfiles/redis_5.gemfile`. To reproduce one
|
|
580
|
+
combination locally:
|
|
581
|
+
|
|
582
|
+
```sh
|
|
583
|
+
BUNDLE_GEMFILE=gemfiles/redis_4.gemfile bundle install
|
|
584
|
+
BUNDLE_GEMFILE=gemfiles/redis_4.gemfile bundle exec rspec
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
`COGWORKER_RELOAD=true` hot-reloads the code (via Zeitwerk) on every Web UI
|
|
588
|
+
request. Use it only in development, and only for the web process:
|
|
589
|
+
|
|
590
|
+
```sh
|
|
591
|
+
COGWORKER_RELOAD=true bundle exec rackup ./examples/config.ru -p 9394
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
## License
|
|
595
|
+
|
|
596
|
+
MIT, see [LICENSE.txt](LICENSE.txt).
|
|
@@ -68,7 +68,8 @@ module Cogworker
|
|
|
68
68
|
# that's what gets pushed back — onto the end its queue is popped from,
|
|
69
69
|
# so it runs next. Returns how many. One entry that can't be read is
|
|
70
70
|
# logged and skipped, never allowed to cost the others their requeue.
|
|
71
|
-
|
|
71
|
+
# (`except:` is ReliableFetch's — there's no in-progress list here.)
|
|
72
|
+
def self.requeue_in_progress(identity, except: [], keep: []) # rubocop:disable Lint/UnusedMethodArgument
|
|
72
73
|
Cogworker.config.redis do |c|
|
|
73
74
|
c.hvals(RedisKeys.workers(identity)).count do |raw|
|
|
74
75
|
entry = JSON.parse(raw)
|
|
@@ -86,8 +87,8 @@ module Cogworker
|
|
|
86
87
|
retake = job['periodic_until_executed'] && job['periodic_pjid'] && job['jid']
|
|
87
88
|
lock_key = retake ? RedisKeys.periodic_running(job['periodic_pjid']) : queue_key
|
|
88
89
|
LuaScript.run(conn, REQUEUE_ENTRY_SCRIPT, keys: [queue_key, lock_key],
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
argv: [JSON.generate(job), retake ? '1' : '0', job['jid'].to_s,
|
|
91
|
+
Periodic::RunningLock.queued_ttl])
|
|
91
92
|
end
|
|
92
93
|
private_class_method :requeue_entry
|
|
93
94
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
# Runs a write whose failure must not change a job's fate — the
|
|
5
|
+
# bookkeeping built-in server middleware does around `perform` (history,
|
|
6
|
+
# status, releasing locks). Its error is logged (collapsed by RedisErrors
|
|
7
|
+
# while Redis is away) and swallowed: raised from a middleware, it used to
|
|
8
|
+
# be indistinguishable from the job's own error, so a job that ran fine
|
|
9
|
+
# was routed as failed — retried (run again), or with `retry: false`,
|
|
10
|
+
# buried in dead.
|
|
11
|
+
module BestEffort
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def call(what)
|
|
15
|
+
result = yield
|
|
16
|
+
RedisErrors.recovered(what)
|
|
17
|
+
result
|
|
18
|
+
rescue StandardError => e
|
|
19
|
+
RedisErrors.report(what, e)
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/cogworker/cli.rb
CHANGED
|
@@ -46,8 +46,9 @@ module Cogworker
|
|
|
46
46
|
file_config = ConfigLoader.load(options[:config_path])
|
|
47
47
|
|
|
48
48
|
Cogworker.server_process!
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
config = Cogworker.config
|
|
50
|
+
config.concurrency = options[:concurrency] || file_config[:concurrency]&.to_i || config.concurrency
|
|
51
|
+
config.queues = options[:queues].any? ? options[:queues] : (file_config[:queues] || config.queues)
|
|
51
52
|
Cogworker.config.fetch = file_config[:fetch] if file_config[:fetch]
|
|
52
53
|
|
|
53
54
|
redirect_logfile(options[:logfile]) if options[:logfile]
|