cogworker 0.5.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e908112cee4c6a9f98588b6cca08c292437d1713ec351ac6287e1f5ef605c452
4
- data.tar.gz: 0c271ea997d7ddf51df5aa5a16b45cb08c0b3231f71dcdb253956160c589bde3
3
+ metadata.gz: fa6ea3cf9620f3c099c39fe6bba306cd78b037e8c0c7c8dab161586c98d1081f
4
+ data.tar.gz: b0b58c6118437e59c6748202ef21a8980ed22f916f27ed14ec3179de8bb435e0
5
5
  SHA512:
6
- metadata.gz: fb759fcff852bfa57315e4428250daeac0a0ae27771dc108e1879f3847616c073ce5d2fa8e079a77d73a51d7db3f18154d5cd6c7ea45c69b1282163cf817aa75
7
- data.tar.gz: caea49e5286f9bf98e93c2931c2e8a77e18b9cce59e88c7ee7854f80bfe30b7b45649a148ed08c605ae87faeeb5ce87ae7f6fe23a523625db1728db1f215b120
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).
@@ -87,8 +87,8 @@ module Cogworker
87
87
  retake = job['periodic_until_executed'] && job['periodic_pjid'] && job['jid']
88
88
  lock_key = retake ? RedisKeys.periodic_running(job['periodic_pjid']) : queue_key
89
89
  LuaScript.run(conn, REQUEUE_ENTRY_SCRIPT, keys: [queue_key, lock_key],
90
- argv: [JSON.generate(job), retake ? '1' : '0', job['jid'].to_s,
91
- Periodic::RunningLock.queued_ttl])
90
+ argv: [JSON.generate(job), retake ? '1' : '0', job['jid'].to_s,
91
+ Periodic::RunningLock.queued_ttl])
92
92
  end
93
93
  private_class_method :requeue_entry
94
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
- Cogworker.config.concurrency = options[:concurrency] || file_config[:concurrency]&.to_i || Cogworker.config.concurrency
50
- Cogworker.config.queues = options[:queues].any? ? options[:queues] : (file_config[:queues] || Cogworker.config.queues)
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]