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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7179d19681ddd3f238ba480d935c3a7e3829368ec011db4adfc571979d7174a9
4
- data.tar.gz: e2669fd2af3814e7d1b2b6a58626dbbeee5ab3322067e99343b7722f186d7cd8
3
+ metadata.gz: 7c1b35b5001c3c0467635a9fb46e3cab6305700a629c23babb604fc026895ec4
4
+ data.tar.gz: 441859a1e6880c259a61a8e9bf1c7004caf9c25d967335a322558ec7227da3e2
5
5
  SHA512:
6
- metadata.gz: 8905446cb4bfa04ab5c5341e018e69dac330dfef9c89f0240c4b1857c83b233da4b894421400a28381fd18079fa84ace67505bee530ac9c270d25ca23a68287b
7
- data.tar.gz: 235d3165daca0d685385afaa38df3b75612d5a53d4992d5585a55efd30cca21656f5cb350dad2ca0c204dd2080cfd8067fef12c33f6da92fe51c5d472a8c02e8
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
  [![Build Status](https://github.com/nicolasva/solid-jobs/actions/workflows/ci.yml/badge.svg)](https://github.com/nicolasva/solid-jobs/actions/workflows/ci.yml)
4
4
  [![Gem Version](https://badge.fury.io/rb/solid-jobs.svg)](https://rubygems.org/gems/solid-jobs)
5
5
  [![Downloads](https://img.shields.io/gem/dt/solid-jobs?style=flat)](https://rubygems.org/gems/solid-jobs)
6
+ [![Documentation Status](https://img.shields.io/badge/docs-RubyDoc.info-blue.svg)](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
+ ![SolidTrace dashboard overview](https://github.com/nicolasva/SolidTrace/blob/main/file_00000000708882108cd8ae9a1106f601.jpg)
44
+
45
+ ![SolidTrace dashboard pages](https://github.com/nicolasva/SolidTrace/blob/main/SolidTrace%20Futuristic%20Dashboard%20Showcase.png)
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
- ## Monitoring with SolidTrace
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
- **SolidTrace** is a statistics and observability tool for SolidJobs. It shows
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
- ![SolidTrace dashboard](docs/images/solid-trace.jpg)
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
- SolidTrace is a commercial product. If you would like to acquire it, please
208
- contact me at [nicolas.vandenbogaerde@gmail.com](mailto:nicolas.vandenbogaerde@gmail.com)
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.
@@ -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
 
@@ -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 = channels.shuffle if @channel_order == :shuffle
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
- if @channel_order == :priority
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
- payload = @redis_pool.blocking_call(
275
- TIMEOUT,
276
- "BLMOVE", source, @claimed_key, "RIGHT", "LEFT", TIMEOUT,
277
- )
278
- payload && record(payload, @channel_names.fetch(source))
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