solid-jobs 0.4.2 → 0.5.0
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 +8 -0
- data/README.md +307 -9
- data/docs/reliability.md +5 -0
- data/lib/solid_jobs/blueprint.rb +14 -2
- data/lib/solid_jobs/claim.rb +74 -25
- data/lib/solid_jobs/conductor.rb +30 -6
- data/lib/solid_jobs/engine.rb +63 -7
- data/lib/solid_jobs/failure_policy.rb +30 -2
- data/lib/solid_jobs/heartbeat.rb +74 -20
- data/lib/solid_jobs/instrumentation.rb +75 -0
- data/lib/solid_jobs/keyspace.rb +5 -0
- data/lib/solid_jobs/observations.rb +177 -0
- data/lib/solid_jobs/publication_barrier.rb +46 -0
- data/lib/solid_jobs/publisher.rb +37 -8
- data/lib/solid_jobs/recovery.rb +31 -2
- data/lib/solid_jobs/task.rb +35 -0
- data/lib/solid_jobs/version.rb +1 -1
- data/lib/solid_jobs.rb +11 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7c1b35b5001c3c0467635a9fb46e3cab6305700a629c23babb604fc026895ec4
|
|
4
|
+
data.tar.gz: 441859a1e6880c259a61a8e9bf1c7004caf9c25d967335a322558ec7227da3e2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0ece103959e94a949ad6c85e3591dbebcc3d82e495c9b4ab3d17f8729dd37bdaa591d12f5bdfe6adf8f4b7298078ef2c5326e1b36bffb3024d236a054980b999
|
|
7
|
+
data.tar.gz: 5f0e56b6900bec66f374f3446d4b553c0b84a934c06718abc77219286002b4b977de68072c3f524f295ceb0c0e81c5f4bd4b5b25a6b42776f791ddb2626614ab
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.5.0] - 2026-10-12
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Warm worker routing: `enqueue` reroutes tasks holding non Ractor-safe state
|
|
10
|
+
(`Mutex`, `Thread`, `Fiber`, pools) to persistent Unix-socket workers
|
|
11
|
+
(`SolidJobsGenericExecutor`, `script/warm_worker.rb`).
|
|
12
|
+
|
|
5
13
|
## [0.4.2] - 2026-10-05
|
|
6
14
|
|
|
7
15
|
### Changed
|
data/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
[](https://github.com/nicolasva/solid-jobs/actions/workflows/ci.yml)
|
|
4
4
|
[](https://rubygems.org/gems/solid-jobs)
|
|
5
5
|
[](https://rubygems.org/gems/solid-jobs)
|
|
6
|
+
[](https://www.rubydoc.info/gems/solid-jobs)
|
|
6
7
|
|
|
7
8
|
SolidJobs is a Ractor-oriented Redis task runner for Ruby. It provides its own
|
|
8
9
|
task API, Redis envelope, keyspace, interceptor model, failure policy, and
|
|
@@ -12,6 +13,39 @@ SolidJobs is **not compatible with Sidekiq**. It does not read Sidekiq queues,
|
|
|
12
13
|
does not write Sidekiq payloads, and does not expose Sidekiq's job API. Existing
|
|
13
14
|
applications and queued work must be migrated explicitly.
|
|
14
15
|
|
|
16
|
+
## SolidTrace observability
|
|
17
|
+
|
|
18
|
+
SolidJobs and SolidTrace have separate, complementary roles:
|
|
19
|
+
|
|
20
|
+
- **SolidJobs** is the job runner. It enqueues and executes background tasks,
|
|
21
|
+
manages channels, retries, recovery, and worker Ractors.
|
|
22
|
+
- **SolidTrace** is the observability dashboard. It consumes structured
|
|
23
|
+
lifecycle and operational telemetry emitted by SolidJobs and presents a
|
|
24
|
+
read-only view of job activity and system health.
|
|
25
|
+
|
|
26
|
+
With SolidTrace, operators can inspect recent job executions and their
|
|
27
|
+
observed lifecycle, monitor queues and worker Ractors, and review process
|
|
28
|
+
resource usage, Redis health, telemetry freshness, and gaps that require
|
|
29
|
+
resynchronization. SolidTrace does not enqueue, execute, retry, or control
|
|
30
|
+
SolidJobs tasks; SolidJobs remains responsible for processing them. The
|
|
31
|
+
dashboard is a separate Sinatra/Rack component that can be mounted in a Rails
|
|
32
|
+
application, with host authentication and access protection supplied by that
|
|
33
|
+
application.
|
|
34
|
+
|
|
35
|
+
SolidTrace uses structured telemetry rather than job arguments or business
|
|
36
|
+
payloads. Its trace views show retained lifecycle events and calculable
|
|
37
|
+
durations, and identify missing or unavailable information instead of
|
|
38
|
+
guessing. See the [SolidTrace project](https://github.com/nicolasva/SolidTrace)
|
|
39
|
+
for more information.
|
|
40
|
+
|
|
41
|
+
### SolidTrace dashboard
|
|
42
|
+
|
|
43
|
+

|
|
44
|
+
|
|
45
|
+

|
|
46
|
+
|
|
47
|
+
For access to SolidTrace, contact me at [nicolas.vandenbogaerde@gmail.com](mailto:nicolas.vandenbogaerde@gmail.com).
|
|
48
|
+
|
|
15
49
|
## Installation
|
|
16
50
|
|
|
17
51
|
```ruby
|
|
@@ -195,18 +229,282 @@ The Active Job adapter remains available as
|
|
|
195
229
|
`ActiveJob::QueueAdapters::SolidJobsAdapter`, but it writes only SolidJobs
|
|
196
230
|
envelopes and keys.
|
|
197
231
|
|
|
198
|
-
##
|
|
232
|
+
## Instrumentation
|
|
233
|
+
|
|
234
|
+
SolidJobs exposes an optional Ractor-local instrumenter and defaults to a
|
|
235
|
+
no-op. Any configured instrumenter must respond to `instrument(name, payload)`
|
|
236
|
+
and be Ractor-shareable before a conductor starts. Ordinary instrumentation
|
|
237
|
+
failures are logged when possible and never change publication, execution,
|
|
238
|
+
retry, acknowledgement, recovery, or shutdown behavior. Process-control
|
|
239
|
+
interruptions still propagate so shutdown can requeue in-flight work.
|
|
240
|
+
|
|
241
|
+
SolidJobs emits the v1 lifecycle events `job.enqueued`, `job.journaled`,
|
|
242
|
+
`job.reserved`, `job.started`, `job.completed`, `job.failed`,
|
|
243
|
+
`job.retry_scheduled`, `job.dead`, `job.acknowledged`, and `job.recovered`.
|
|
244
|
+
Ready jobs are not reservable until `job.enqueued` and `job.journaled` have
|
|
245
|
+
been emitted; an expiring Redis publication barrier preserves this order
|
|
246
|
+
without stranding work if a publisher exits.
|
|
247
|
+
They carry correlation identifiers and structured errors, but never task
|
|
248
|
+
arguments, business payloads, secrets, or textual logs. `node_id` is shared by
|
|
249
|
+
all Ractors in a conductor; `ractor_id` and `worker_id` are integer processor
|
|
250
|
+
identities. `reservation_id` is the reliable claim token.
|
|
251
|
+
|
|
252
|
+
The heartbeat Ractor also samples and emits `process.observed`, one
|
|
253
|
+
`ractor.observed` per logical processor, and one `redis.observed` per processor
|
|
254
|
+
every five seconds. Process observations are limited to process CPU time, RSS,
|
|
255
|
+
GC count/time, and total Ruby allocations. Sources are local Ruby/OS reads made
|
|
256
|
+
outside worker Ractors. A source that has never succeeded is `unavailable`; a
|
|
257
|
+
later failure preserves the last value as `stale` with its increasing age.
|
|
258
|
+
SolidJobs never estimates CPU, memory, GC, or allocations per Ractor.
|
|
259
|
+
|
|
260
|
+
Ractor observations expose only state, activity, integer
|
|
261
|
+
`ractor_id`/`worker_id`/`executor_id`, and current job IDs. Quiet workers report
|
|
262
|
+
`waiting`; shutdown reports `stopping` then `stopped` without inventing work.
|
|
263
|
+
Redis connection state comes from worker operations already being performed:
|
|
264
|
+
successful operations report `connected` and `SolidRedis::ConnectionError`
|
|
265
|
+
reports `disconnected`. No monitoring-only Redis command is added. Redis
|
|
266
|
+
latency, command, memory, and connection-count metrics remain explicitly
|
|
267
|
+
`unavailable` until a reliable source exists.
|
|
268
|
+
|
|
269
|
+
## Transparent Ractor-safe hybrid execution (warm worker routing)
|
|
270
|
+
|
|
271
|
+
Ractors enforce a shared-nothing model: referencing mutable global state, legacy
|
|
272
|
+
connection pools, or thread-based dependencies (ActiveRecord, ActionMailer, gems
|
|
273
|
+
using `Mutex`, `Thread` or `Fiber`) inside a Ractor raises
|
|
274
|
+
`ArgumentError: can't share mutable object`. `SolidJobs::Task#enqueue` detects
|
|
275
|
+
such tasks and reroutes them to a persistent **warm worker** process, so you do
|
|
276
|
+
not have to rewrite them.
|
|
277
|
+
|
|
278
|
+
### How routing is decided
|
|
279
|
+
|
|
280
|
+
`requires_warm_pool?(arguments)` returns `true` only when **all** of these hold:
|
|
281
|
+
|
|
282
|
+
1. `defined?(Rails)` is truthy.
|
|
283
|
+
2. At least one argument is not `Ractor.shareable?`.
|
|
284
|
+
3. The task class holds, in a class-level instance variable, a `Mutex`,
|
|
285
|
+
`Thread` or `Fiber`, or an object whose class name contains `Pool`.
|
|
286
|
+
|
|
287
|
+
Otherwise `enqueue` runs the normal Ractor-native path. When routed, the
|
|
288
|
+
arguments are converted to plain JSON data (deep-copied and frozen) and enqueued
|
|
289
|
+
as a `SolidJobsGenericExecutor` task, which forwards them over a Unix socket.
|
|
290
|
+
|
|
291
|
+
> Detection is heuristic and inspects only class-level instance variables. It
|
|
292
|
+
> does not detect constants such as `ActiveRecord::Base` referenced inside
|
|
293
|
+
> `execute_task`. Arguments must be JSON-serializable, and a task with
|
|
294
|
+
> shareable arguments always stays Ractor-native.
|
|
295
|
+
|
|
296
|
+
### Components
|
|
297
|
+
|
|
298
|
+
- `app/jobs/solid_jobs_generic_executor.rb` — `SolidJobsGenericExecutor`
|
|
299
|
+
connects to `/tmp/solid_jobs_rails_<worker_id>.sock`, sends
|
|
300
|
+
`{"target_class": ..., "arguments": [...]}` as one JSON line, and returns the
|
|
301
|
+
result or raises `SolidJobsGenericExecutor::RemoteError`. The worker is chosen
|
|
302
|
+
as `hash(task name) % SOLID_JOBS_WARM_WORKERS` (default `1`). Require this file
|
|
303
|
+
explicitly (it is loaded automatically on first routing).
|
|
304
|
+
- `script/warm_worker.rb` — a standalone process, run outside any Ractor, that
|
|
305
|
+
loads `config/environment.rb` from its working directory once, listens on the
|
|
306
|
+
socket, resolves the class with `Object.const_get`, runs `execute_task` (or
|
|
307
|
+
`perform`), and replies with `{"status":"ok","result":...}` or
|
|
308
|
+
`{"status":"error","error_class":...,"message":...}`. Requests are handled one
|
|
309
|
+
at a time per worker. The socket is created with mode `0600` and removed on
|
|
310
|
+
`INT`/`TERM`.
|
|
311
|
+
|
|
312
|
+
### Running warm workers
|
|
313
|
+
|
|
314
|
+
Warm workers are **not** spawned by `solid-jobs`; start and supervise them
|
|
315
|
+
yourself (systemd, foreman, Procfile, ...), one per worker id, from the Rails
|
|
316
|
+
root:
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
SOLID_JOBS_WARM_WORKERS=2 bundle exec ruby script/warm_worker.rb 0 &
|
|
320
|
+
SOLID_JOBS_WARM_WORKERS=2 bundle exec ruby script/warm_worker.rb 1 &
|
|
321
|
+
bundle exec solid-jobs --require ./config/environment --concurrency 8 --channel critical
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
The worker count used by the runner must match the number of workers started.
|
|
325
|
+
The boot cost is paid once per worker; the process then stays warm. Memory is
|
|
326
|
+
reclaimed only when a worker is restarted, so supervise and recycle them as you
|
|
327
|
+
would any long-lived Rails process.
|
|
328
|
+
|
|
329
|
+
### Example
|
|
330
|
+
|
|
331
|
+
```ruby
|
|
332
|
+
class SyncAccounts
|
|
333
|
+
include SolidJobs::Task
|
|
334
|
+
@lock = Mutex.new # class-level non-shareable state => warm worker routing
|
|
335
|
+
|
|
336
|
+
def execute_task(account_ids)
|
|
337
|
+
Account.where(id: account_ids).find_each(&:sync!)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
SyncAccounts.enqueue([1, 2, 3]) # routed to the warm worker
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Tests live in `test/warm_pool_test.rb` and spawn a real worker process.
|
|
345
|
+
|
|
346
|
+
## The Ractor concurrency challenge
|
|
347
|
+
|
|
348
|
+
Core Rails frameworks like `ActiveRecord` and `ActionMailer` rely on global
|
|
349
|
+
variables, shared connection pools, and mutable global state. Referencing these
|
|
350
|
+
modules directly inside a strict, isolated `Ractor` raises
|
|
351
|
+
`ArgumentError: can't share mutable object`.
|
|
352
|
+
|
|
353
|
+
To keep SolidJobs' Ractor-native execution while preserving your existing Rails
|
|
354
|
+
business logic, use a **hybrid execution bridge**: delegate mutable work to an
|
|
355
|
+
isolated sub-process via `bin/rails runner`. The worker stays Ractor-native and
|
|
356
|
+
the heavy Rails operations are offloaded to the operating system layer.
|
|
357
|
+
|
|
358
|
+
### Hybrid execution examples
|
|
359
|
+
|
|
360
|
+
> **Important:** These task classes must be loaded explicitly (e.g., inside
|
|
361
|
+
> `config/boot.rb`) outside of Zeitwerk management, as standard Rails
|
|
362
|
+
> autoloading is restricted inside isolated Ractors.
|
|
363
|
+
|
|
364
|
+
#### Isolated ActionMailer delivery task
|
|
365
|
+
|
|
366
|
+
Trigger an email workflow using your existing Rails mailers and templates
|
|
367
|
+
without memory isolation crashes:
|
|
368
|
+
|
|
369
|
+
```ruby
|
|
370
|
+
# frozen_string_literal: true
|
|
371
|
+
|
|
372
|
+
require "open3"
|
|
373
|
+
|
|
374
|
+
class SendEmailTask
|
|
375
|
+
include SolidJobs::Task
|
|
376
|
+
|
|
377
|
+
# Define your processing channel and failure thresholds
|
|
378
|
+
task_options channel: "default", max_failures: 3
|
|
379
|
+
|
|
380
|
+
APP_ROOT = File.expand_path("../..", __dir__).freeze
|
|
381
|
+
SCRIPT = "WelcomeMailer.hello(ARGV[0]).deliver_now"
|
|
382
|
+
|
|
383
|
+
def execute_task(recipient = "nicolas.vandenbogaerde@gmail.com")
|
|
384
|
+
output, status = Open3.capture2e(
|
|
385
|
+
{ "RAILS_ENV" => ENV.fetch("RAILS_ENV", "development") },
|
|
386
|
+
"bin/rails", "runner", SCRIPT, recipient,
|
|
387
|
+
chdir: APP_ROOT
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
unless status.success?
|
|
391
|
+
raise "Email delivery failed: #{output.lines.grep_v(/^\s+from /).first(5).join}"
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
#### Isolated ActiveRecord data sync task
|
|
398
|
+
|
|
399
|
+
Load database records and run model mutations safely outside of the Ractor:
|
|
400
|
+
|
|
401
|
+
```ruby
|
|
402
|
+
# frozen_string_literal: true
|
|
403
|
+
|
|
404
|
+
require "open3"
|
|
405
|
+
|
|
406
|
+
class ProcessUserDataTask
|
|
407
|
+
include SolidJobs::Task
|
|
408
|
+
|
|
409
|
+
# Dedicated data synchronization channel
|
|
410
|
+
task_options channel: "data_sync", max_failures: 3
|
|
411
|
+
|
|
412
|
+
APP_ROOT = File.expand_path("../..", __dir__).freeze
|
|
413
|
+
SCRIPT = "User.find(ARGV[0]).update_metrics!"
|
|
414
|
+
|
|
415
|
+
def execute_task(user_id)
|
|
416
|
+
output, status = Open3.capture2e(
|
|
417
|
+
{ "RAILS_ENV" => ENV.fetch("RAILS_ENV", "development") },
|
|
418
|
+
"bin/rails", "runner", SCRIPT, user_id.to_s,
|
|
419
|
+
chdir: APP_ROOT
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
unless status.success?
|
|
423
|
+
raise "ActiveRecord task failed: #{output.lines.grep_v(/^\s+from /).first(5).join}"
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
#### Complete business service object execution (zero-leak memory architecture)
|
|
430
|
+
|
|
431
|
+
In complex production monoliths, a single service object often encapsulates
|
|
432
|
+
multiple `ActiveRecord` updates, pricing mutations, external API calls, and
|
|
433
|
+
email notifications. Running this orchestration layer directly inside a
|
|
434
|
+
`Ractor` triggers memory isolation failures.
|
|
435
|
+
|
|
436
|
+
By serializing the service input into a lightweight, Ractor-safe **JSON
|
|
437
|
+
payload**, SolidJobs can hand the task details to an isolated `bin/rails runner`
|
|
438
|
+
sub-process.
|
|
439
|
+
|
|
440
|
+
A major benefit is **automatic memory recovery**: once the service has
|
|
441
|
+
allocated thousands of transient Ruby objects, the sub-process terminates and
|
|
442
|
+
the operating system reclaims all of its memory, keeping the worker immune to
|
|
443
|
+
memory bloat and leaks.
|
|
444
|
+
|
|
445
|
+
Example: processing a heavy order checkout service.
|
|
446
|
+
|
|
447
|
+
```ruby
|
|
448
|
+
# frozen_string_literal: true
|
|
449
|
+
|
|
450
|
+
require "open3"
|
|
451
|
+
require "json"
|
|
452
|
+
|
|
453
|
+
# Loaded explicitly (e.g., in config/boot.rb) outside of Zeitwerk.
|
|
454
|
+
# SolidJobs executors run within Ractors where standard Rails autoloading is restricted.
|
|
455
|
+
class ExecuteServiceTask
|
|
456
|
+
include SolidJobs::Task
|
|
457
|
+
|
|
458
|
+
# Dedicated high-priority business services channel
|
|
459
|
+
task_options channel: "services", max_failures: 2
|
|
460
|
+
|
|
461
|
+
APP_ROOT = File.expand_path("../..", __dir__).freeze
|
|
462
|
+
|
|
463
|
+
# The runner script parses the input JSON from the command line and executes the service
|
|
464
|
+
SCRIPT = <<~RUBY
|
|
465
|
+
require 'json'
|
|
466
|
+
params = JSON.parse(ARGV[0])
|
|
467
|
+
OrderCheckoutService.call(user_id: params['user_id'], cart_id: params['cart_id'])
|
|
468
|
+
RUBY
|
|
469
|
+
|
|
470
|
+
def execute_task(params_hash)
|
|
471
|
+
# Serialize the pure Hash into a strict JSON string for safe Ractor transmission
|
|
472
|
+
json_payload = params_hash.to_json
|
|
473
|
+
|
|
474
|
+
output, status = Open3.capture2e(
|
|
475
|
+
{ "RAILS_ENV" => ENV.fetch("RAILS_ENV", "development") },
|
|
476
|
+
"bin/rails", "runner", SCRIPT, json_payload,
|
|
477
|
+
chdir: APP_ROOT
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
unless status.success?
|
|
481
|
+
raise "Service object execution failed: #{output.lines.first(5).join}"
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
Enqueuing from your Rails application:
|
|
488
|
+
|
|
489
|
+
```ruby
|
|
490
|
+
# Construct a clean, 100% Ractor-safe arguments structure
|
|
491
|
+
task_payload = { user_id: 42, cart_id: 891 }
|
|
492
|
+
|
|
493
|
+
# Push into the Redis pipeline
|
|
494
|
+
ExecuteServiceTask.enqueue(task_payload)
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
## Commercial licensing & observability
|
|
199
498
|
|
|
200
|
-
|
|
201
|
-
real-time SolidJobs metrics while your application runs: jobs per second, CPU
|
|
202
|
-
and memory usage per Ractor, queue depth, Redis latency, job flow, and recent
|
|
203
|
-
job executions.
|
|
499
|
+
`SolidJobs` is open source under the **LGPL-3.0-or-later** license.
|
|
204
500
|
|
|
205
|
-
|
|
501
|
+
Running multi-core clusters blind in staging or production makes monitoring
|
|
502
|
+
hard. For visual insight into Ractor memory allocation, CPU thresholds, and live
|
|
503
|
+
queue states, pair this gem with **SolidTrace** (commercial licenses available
|
|
504
|
+
from $50 to $200 / month).
|
|
206
505
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
or via GitHub ([@nicolasva](https://github.com/nicolasva)).
|
|
506
|
+
For commercial keys, custom architecture audits, or enterprise support, contact
|
|
507
|
+
**licensing@solidtrace.io**.
|
|
210
508
|
|
|
211
509
|
## Performance: SolidJobs vs Sidekiq
|
|
212
510
|
|
data/docs/reliability.md
CHANGED
|
@@ -29,6 +29,11 @@ attempt monotonically increasing execution count
|
|
|
29
29
|
claimed_at wall-clock claim time
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
A ready payload has a short-lived publication barrier while its
|
|
33
|
+
`job.enqueued` and `job.journaled` events are emitted. Executors leave marked
|
|
34
|
+
payloads in READY until the publisher removes the marker. Markers expire after
|
|
35
|
+
30 seconds, so a publisher crash can delay a task but cannot strand it.
|
|
36
|
+
|
|
32
37
|
Completion and requeue are fenced by `claim_token`. Their Lua scripts verify
|
|
33
38
|
that the executor still owns the current journal generation before changing
|
|
34
39
|
state. A delayed or revived executor cannot complete or requeue a newer claim.
|
data/lib/solid_jobs/blueprint.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "logger"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "socket"
|
|
4
6
|
|
|
5
7
|
module SolidJobs
|
|
6
8
|
class Blueprint
|
|
@@ -11,9 +13,9 @@ module SolidJobs
|
|
|
11
13
|
|
|
12
14
|
attr_accessor :concurrency, :discarded_limit, :discarded_retention, :logger,
|
|
13
15
|
:on_complex_arguments, :poll_interval_average, :shutdown_timeout,
|
|
14
|
-
:reliable_fetch, :retry_base_delay, :retry_max_delay
|
|
16
|
+
:reliable_fetch, :retry_base_delay, :retry_max_delay, :instrumenter
|
|
15
17
|
attr_reader :default_task_options, :error_handlers, :execute_interceptors,
|
|
16
|
-
:publish_interceptors, :redis_config
|
|
18
|
+
:publish_interceptors, :redis_config, :identity
|
|
17
19
|
|
|
18
20
|
def initialize(redis: nil, concurrency: 5, channels: ["default"])
|
|
19
21
|
@redis_config = redis || SolidRedis.config(url: ENV.fetch("REDIS_URL", "redis://127.0.0.1:6379/0"))
|
|
@@ -34,6 +36,8 @@ module SolidJobs
|
|
|
34
36
|
@discarded_limit = 10_000
|
|
35
37
|
@discarded_retention = 180 * 24 * 60 * 60
|
|
36
38
|
@logger = Logger.new($stdout)
|
|
39
|
+
@identity = "#{Socket.gethostname}:#{::Process.pid}:#{SecureRandom.hex(6)}".freeze
|
|
40
|
+
@instrumenter = NullInstrumenter
|
|
37
41
|
@redis_pool = nil
|
|
38
42
|
end
|
|
39
43
|
|
|
@@ -115,6 +119,10 @@ module SolidJobs
|
|
|
115
119
|
end
|
|
116
120
|
|
|
117
121
|
def ractor_snapshot
|
|
122
|
+
unless Ractor.shareable?(instrumenter)
|
|
123
|
+
raise ArgumentError, "SolidJobs instrumenter must be Ractor-shareable"
|
|
124
|
+
end
|
|
125
|
+
|
|
118
126
|
Utilities.shareable_copy(
|
|
119
127
|
redis_config: redis_config,
|
|
120
128
|
concurrency: concurrency,
|
|
@@ -131,6 +139,8 @@ module SolidJobs
|
|
|
131
139
|
retry_max_delay: retry_max_delay,
|
|
132
140
|
publish_interceptors: publish_interceptors.export,
|
|
133
141
|
execute_interceptors: execute_interceptors.export,
|
|
142
|
+
identity: identity,
|
|
143
|
+
instrumenter: instrumenter,
|
|
134
144
|
)
|
|
135
145
|
end
|
|
136
146
|
|
|
@@ -152,6 +162,8 @@ module SolidJobs
|
|
|
152
162
|
config.retry_max_delay = snapshot.fetch(:retry_max_delay)
|
|
153
163
|
config.publish_interceptors.import(snapshot.fetch(:publish_interceptors))
|
|
154
164
|
config.execute_interceptors.import(snapshot.fetch(:execute_interceptors))
|
|
165
|
+
config.instance_variable_set(:@identity, snapshot.fetch(:identity))
|
|
166
|
+
config.instrumenter = snapshot.fetch(:instrumenter)
|
|
155
167
|
config
|
|
156
168
|
end
|
|
157
169
|
|
data/lib/solid_jobs/claim.rb
CHANGED
|
@@ -12,9 +12,14 @@ module SolidJobs
|
|
|
12
12
|
if not payload then
|
|
13
13
|
return nil
|
|
14
14
|
end
|
|
15
|
+
local job = cjson.decode(payload)
|
|
16
|
+
local publication = job["#{PublicationBarrier::FIELD}"]
|
|
17
|
+
if publication and redis.call("exists", ARGV[5] .. publication) == 1 then
|
|
18
|
+
redis.call("rpush", KEYS[1], payload)
|
|
19
|
+
return nil
|
|
20
|
+
end
|
|
15
21
|
|
|
16
22
|
redis.call("lpush", KEYS[2], payload)
|
|
17
|
-
local job = cjson.decode(payload)
|
|
18
23
|
local attempt = redis.call("hincrby", KEYS[4], job["id"], 1)
|
|
19
24
|
local metadata = {
|
|
20
25
|
task_id = job["id"],
|
|
@@ -28,6 +33,19 @@ module SolidJobs
|
|
|
28
33
|
redis.call("hset", KEYS[3], ARGV[3], cjson.encode(metadata))
|
|
29
34
|
return {payload, attempt}
|
|
30
35
|
LUA
|
|
36
|
+
POP_READY = <<~LUA.freeze
|
|
37
|
+
local payload = redis.call("rpop", KEYS[1])
|
|
38
|
+
if not payload then
|
|
39
|
+
return nil
|
|
40
|
+
end
|
|
41
|
+
local job = cjson.decode(payload)
|
|
42
|
+
local publication = job["#{PublicationBarrier::FIELD}"]
|
|
43
|
+
if publication and redis.call("exists", ARGV[1] .. publication) == 1 then
|
|
44
|
+
redis.call("rpush", KEYS[1], payload)
|
|
45
|
+
return nil
|
|
46
|
+
end
|
|
47
|
+
return payload
|
|
48
|
+
LUA
|
|
31
49
|
REGISTER = <<~LUA.freeze
|
|
32
50
|
local attempt = redis.call("hincrby", KEYS[2], ARGV[1], 1)
|
|
33
51
|
local metadata = cjson.decode(ARGV[3])
|
|
@@ -66,6 +84,7 @@ module SolidJobs
|
|
|
66
84
|
return removed
|
|
67
85
|
LUA
|
|
68
86
|
RESERVE_SHA = Digest::SHA1.hexdigest(RESERVE).freeze
|
|
87
|
+
POP_READY_SHA = Digest::SHA1.hexdigest(POP_READY).freeze
|
|
69
88
|
REGISTER_SHA = Digest::SHA1.hexdigest(REGISTER).freeze
|
|
70
89
|
ACK_SHA = Digest::SHA1.hexdigest(ACK).freeze
|
|
71
90
|
REQUEUE_SHA = Digest::SHA1.hexdigest(REQUEUE).freeze
|
|
@@ -131,6 +150,7 @@ module SolidJobs
|
|
|
131
150
|
end
|
|
132
151
|
|
|
133
152
|
def initialize(config, identity: nil, processor_id: nil)
|
|
153
|
+
@config = config
|
|
134
154
|
@redis_pool = config.redis_pool
|
|
135
155
|
@channel_order = config.channel_order
|
|
136
156
|
@reliable = config.reliable_fetch
|
|
@@ -167,12 +187,7 @@ module SolidJobs
|
|
|
167
187
|
if @claimed_key
|
|
168
188
|
claim_reliable(channels)
|
|
169
189
|
else
|
|
170
|
-
channels
|
|
171
|
-
result = @redis_pool.blocking_call(TIMEOUT, "BRPOP", *channels, TIMEOUT)
|
|
172
|
-
result && record(
|
|
173
|
-
result.fetch(1),
|
|
174
|
-
result.fetch(0).delete_prefix("#{Keyspace::PREFIX}:channel:"),
|
|
175
|
-
)
|
|
190
|
+
claim_unreliable(channels)
|
|
176
191
|
end
|
|
177
192
|
end
|
|
178
193
|
|
|
@@ -183,10 +198,10 @@ module SolidJobs
|
|
|
183
198
|
private
|
|
184
199
|
|
|
185
200
|
def record(payload, channel = nil, claim_token: nil, attempt: nil)
|
|
186
|
-
envelope = JSON.parse(payload)
|
|
201
|
+
envelope = PublicationBarrier.strip(JSON.parse(payload))
|
|
187
202
|
channel ||= envelope.fetch("channel")
|
|
188
203
|
claim_token, attempt = register(envelope, channel) unless claim_token
|
|
189
|
-
ClaimRecord.new(
|
|
204
|
+
claim = ClaimRecord.new(
|
|
190
205
|
channel: channel,
|
|
191
206
|
payload: payload,
|
|
192
207
|
envelope: envelope,
|
|
@@ -197,13 +212,22 @@ module SolidJobs
|
|
|
197
212
|
claim_token: claim_token,
|
|
198
213
|
attempt: attempt,
|
|
199
214
|
)
|
|
215
|
+
Instrumentation.emit(
|
|
216
|
+
@config,
|
|
217
|
+
:reserved,
|
|
218
|
+
envelope,
|
|
219
|
+
reservation_id: claim_token,
|
|
220
|
+
attempt: attempt,
|
|
221
|
+
**Instrumentation.worker_attributes(@processor_id),
|
|
222
|
+
)
|
|
223
|
+
claim
|
|
200
224
|
end
|
|
201
225
|
|
|
202
226
|
def existing_claim(payload)
|
|
203
227
|
raw = @redis_pool.call("HGET", @claims_key, @processor_id)
|
|
204
228
|
if raw
|
|
205
229
|
metadata = JSON.parse(raw)
|
|
206
|
-
envelope = JSON.parse(payload)
|
|
230
|
+
envelope = PublicationBarrier.strip(JSON.parse(payload))
|
|
207
231
|
if metadata["task_id"] == envelope["id"]
|
|
208
232
|
return ClaimRecord.new(
|
|
209
233
|
channel: envelope.fetch("channel"),
|
|
@@ -260,22 +284,33 @@ module SolidJobs
|
|
|
260
284
|
end
|
|
261
285
|
|
|
262
286
|
def claim_reliable(channels)
|
|
263
|
-
|
|
264
|
-
channels.each do |source|
|
|
265
|
-
claim = claim_now(source)
|
|
266
|
-
return claim if claim
|
|
267
|
-
end
|
|
268
|
-
source = channels.first
|
|
269
|
-
else
|
|
270
|
-
source = next_channel(channels)
|
|
287
|
+
ordered_sources(channels).each do |source|
|
|
271
288
|
claim = claim_now(source)
|
|
272
289
|
return claim if claim
|
|
273
290
|
end
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
291
|
+
sleep(TIMEOUT)
|
|
292
|
+
nil
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def claim_unreliable(channels)
|
|
296
|
+
ordered_sources(channels).each do |source|
|
|
297
|
+
payload = pop_ready(source)
|
|
298
|
+
return record(payload, @channel_names.fetch(source)) if payload
|
|
299
|
+
end
|
|
300
|
+
sleep(TIMEOUT)
|
|
301
|
+
nil
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def ordered_sources(channels)
|
|
305
|
+
case @channel_order
|
|
306
|
+
when :priority
|
|
307
|
+
channels.uniq
|
|
308
|
+
when :shuffle
|
|
309
|
+
channels.shuffle.uniq
|
|
310
|
+
else
|
|
311
|
+
preferred = next_channel(channels)
|
|
312
|
+
([preferred] + channels).uniq
|
|
313
|
+
end
|
|
279
314
|
end
|
|
280
315
|
|
|
281
316
|
def claim_now(source)
|
|
@@ -285,7 +320,7 @@ module SolidJobs
|
|
|
285
320
|
@redis_pool.call(
|
|
286
321
|
"EVALSHA", RESERVE_SHA, 4,
|
|
287
322
|
source, @claimed_key, @claims_key, Keyspace::ATTEMPTS,
|
|
288
|
-
claim_token, @identity, @processor_id, claimed_at,
|
|
323
|
+
claim_token, @identity, @processor_id, claimed_at, Keyspace::PUBLICATION_PREFIX,
|
|
289
324
|
)
|
|
290
325
|
rescue SolidRedis::CommandError => error
|
|
291
326
|
raise unless error.message.include?("NOSCRIPT")
|
|
@@ -293,7 +328,7 @@ module SolidJobs
|
|
|
293
328
|
@redis_pool.call(
|
|
294
329
|
"EVAL", RESERVE, 4,
|
|
295
330
|
source, @claimed_key, @claims_key, Keyspace::ATTEMPTS,
|
|
296
|
-
claim_token, @identity, @processor_id, claimed_at,
|
|
331
|
+
claim_token, @identity, @processor_id, claimed_at, Keyspace::PUBLICATION_PREFIX,
|
|
297
332
|
)
|
|
298
333
|
end
|
|
299
334
|
return unless result
|
|
@@ -307,6 +342,20 @@ module SolidJobs
|
|
|
307
342
|
)
|
|
308
343
|
end
|
|
309
344
|
|
|
345
|
+
def pop_ready(source)
|
|
346
|
+
@redis_pool.call(
|
|
347
|
+
"EVALSHA", POP_READY_SHA, 1,
|
|
348
|
+
source, Keyspace::PUBLICATION_PREFIX,
|
|
349
|
+
)
|
|
350
|
+
rescue SolidRedis::CommandError => error
|
|
351
|
+
raise unless error.message.include?("NOSCRIPT")
|
|
352
|
+
|
|
353
|
+
@redis_pool.call(
|
|
354
|
+
"EVAL", POP_READY, 1,
|
|
355
|
+
source, Keyspace::PUBLICATION_PREFIX,
|
|
356
|
+
)
|
|
357
|
+
end
|
|
358
|
+
|
|
310
359
|
def refresh_paused
|
|
311
360
|
now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
312
361
|
return if now < @paused_refresh_at
|