patient_http-sidekiq 1.2.0 → 1.3.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: e5ae5287613de3682de817fb6c434e6a1a33c21c1b8aa1dde7b7808c1733b927
4
- data.tar.gz: 350e8f054d1047fe70482d9c3fc2f5473b077e1e7f1c64cc6f023c8bf76f62b4
3
+ metadata.gz: 47d12842c42be5d8953e2e54afcaa7f702ead028c6aef09c9f01bb16e48d0193
4
+ data.tar.gz: 1ab374564b701fd7201b36a208bdd31a24e60829eaa4522a4cdbcae27380721e
5
5
  SHA512:
6
- metadata.gz: d843ce929d22f48c20bc7f487b35d11421166218081f4fea2490440781d0a00839266c3cb1dec039611a6728e2213c3a71f0a966454ba40c1efc6ab466648ca7
7
- data.tar.gz: 006cf97e99a6d27847b038c19065a88b09e28a94c7392fbee5af4253e9483652e8022b21e42c271840cfc8bff326fcfd490b137d1b08c8984760c68f6f7bc3df
6
+ metadata.gz: b26e488319bde0bc5d778e19281897b8244e7e70a82df90f8c7824329fe449b2d1cc52664ffb090d62c6671edd99e6c98c9e12c669c7888560ad082b8d1db2b6
7
+ data.tar.gz: 0ff5a1cfe9e6ce4ee41e4a3a50ce8af4b88ec47020be8d3d6117ebe96a0cb7b402f0ec77e0911b7dd2e803ea5b70fb1e1acd939dcd69b15e865855e4a1a35f42
data/ARCHITECTURE.md CHANGED
@@ -112,14 +112,21 @@ sequenceDiagram
112
112
  participant Callback as Callback Service
113
113
 
114
114
  App->>Module: get(url, callback: MyCallback)
115
- Module->>Sidekiq: Enqueue RequestWorker
116
- Sidekiq->>ReqWorker: Execute job
117
- ReqWorker->>Processor: submit(request, handler)
118
- activate Processor
119
- Note over Processor: Request queued<br/>in memory
120
- Processor-->>ReqWorker: Returns immediately
121
- ReqWorker-->>Sidekiq: Job completes
122
- deactivate Processor
115
+
116
+ alt Processor running in this process (direct execution)
117
+ Module->>Processor: submit(request, handler)
118
+ Note over Module: DirectTaskHandler keeps the<br/>RequestWorker args for re-enqueue
119
+ Processor-->>Module: Returns immediately
120
+ else Processor not in this process
121
+ Module->>Sidekiq: Enqueue RequestWorker
122
+ Sidekiq->>ReqWorker: Execute job
123
+ ReqWorker->>Processor: submit(request, handler)
124
+ activate Processor
125
+ Note over Processor: Request queued<br/>in memory
126
+ Processor-->>ReqWorker: Returns immediately
127
+ ReqWorker-->>Sidekiq: Job completes
128
+ deactivate Processor
129
+ end
123
130
 
124
131
  Note over Sidekiq: Worker thread free<br/>to process other jobs
125
132
 
@@ -149,6 +156,8 @@ Key integration points:
149
156
  3. **CallbackWorker** invokes the user's callback service methods
150
157
  4. **ExternalStorage** handles large payloads transparently at each step
151
158
 
159
+ Direct execution (enabled by default with `config.direct_execution`) skips the `RequestWorker` enqueue when the processor runs in the current process. The request gets a `DirectTaskHandler` that holds the `RequestWorker` job arguments, so every re-enqueue path (processor shutdown, crash recovery, and the at-capacity fallback) can enqueue the request as a normal `RequestWorker` job. The handler exposes a minimal job record for the crash-recovery registry because the orphan sweep pushes the stored record from another process. Requests made in a `with_sidekiq_options` block and requests made while `Sidekiq::Testing` is enabled always go through the queue, so that Sidekiq applies the options. Options set with `config.sidekiq_options` (including a `queue`) do not apply to direct-executed requests, because no Sidekiq job is created; set `config.direct_execution = false` to route every request through the configured queue. A failure to write the crash-recovery registry entry rejects the request and raises to the caller, the same as a failed enqueue.
160
+
152
161
  ## Component Relationships
153
162
 
154
163
  ```mermaid
@@ -377,13 +386,15 @@ In-flight requests are tracked in Redis to enable recovery when Sidekiq processe
377
386
  - Re-enqueues orphaned requests via `Sidekiq::Client.push`
378
387
 
379
388
  ### Recovery Process
380
- 1. `ProcessorObserver` notifies `TaskMonitor` when requests start/complete
381
- 2. `TaskMonitorThread` updates heartbeat timestamps in Redis
389
+ 1. `ProcessorObserver` registers a request with `TaskMonitor` when the processor accepts it (before `Processor#enqueue` returns) and unregisters it when the request completes or when a Sidekiq job owns the request again (rejected or re-enqueued)
390
+ 2. `TaskMonitorThread` updates heartbeat timestamps in Redis for all tracked requests (queued, pending, and in-flight)
382
391
  3. If a process crashes, heartbeat updates stop
383
392
  4. Other processes' monitor threads detect stale timestamps
384
393
  5. Orphaned requests are atomically removed and re-enqueued
385
394
  6. Prevents lost work during deployments or crashes
386
395
 
396
+ Recovery gives at-least-once delivery. A crash between a re-enqueue and the removal of the registry entry can execute a request more than once, so callbacks must be idempotent. A request is durable once the submitting call returns; a crash during the call behaves like a failed enqueue.
397
+
387
398
  **Redis Keys:**
388
399
  - `sidekiq:patient_http:inflight_index` - Sorted set of request IDs by timestamp
389
400
  - `sidekiq:patient_http:inflight_jobs` - Hash of request payloads
@@ -401,6 +412,9 @@ PatientHttp::Sidekiq.configure do |config|
401
412
  # Sidekiq worker options (applied to both RequestWorker and CallbackWorker)
402
413
  config.sidekiq_options = {queue: "patient_http", retry: 5}
403
414
 
415
+ # Skip the Sidekiq queue when the processor runs in the current process
416
+ config.direct_execution = true
417
+
404
418
  # Encryption (for sensitive data in Sidekiq jobs; inherited from PatientHttp::Configuration)
405
419
  config.encryption_key = ENV["PATIENT_HTTP_ENCRYPTION_KEY"]
406
420
 
@@ -465,6 +479,13 @@ Fiber reactor processes request
465
479
  Response/Error received
466
480
  ```
467
481
 
482
+ With direct execution (the default), a request made in a process with a running
483
+ processor skips the enqueue and the `RequestWorker#perform` steps. A
484
+ `DirectTaskHandler` holds the `RequestWorker` job arguments, and the request
485
+ goes straight to the processor. The rest of the flow is identical, and the
486
+ re-enqueue paths use the handler to enqueue a normal `RequestWorker` job when
487
+ needed.
488
+
468
489
  ### Processing a Response
469
490
 
470
491
  ```
data/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 1.3.0
8
+
9
+ ### Added
10
+
11
+ - Requests made in a process with a running processor now go straight to the processor instead of being enqueued through Sidekiq. The request can always be re-enqueued as a normal `RequestWorker` job, so all of the re-enqueue paths (processor shutdown, crash recovery, and the at-capacity fallback) behave the same as the enqueued path. Requests made in a `with_sidekiq_options` block always go through the Sidekiq queue, so that Sidekiq applies the options; options set with `config.sidekiq_options` do not apply to direct-executed requests because no Sidekiq job is created. The new `direct_execution` configuration option (default: `true`) turns this behavior off.
12
+
13
+ ### Changed
14
+
15
+ - Requests are now registered in the crash-recovery registry when the processor accepts them, before the enqueue call returns, instead of when the request starts processing. A request handed to the processor is durable from that point on, and heartbeats now cover queued requests as well as in-flight ones. The entry is removed when the request completes or when a Sidekiq job owns the request again. A failure to write the registry entry rejects the request and raises to the caller, the same as a failed enqueue. This requires patient_http 1.4.0.
16
+
7
17
  ## 1.2.0
8
18
 
9
19
  ### Added
data/README.md CHANGED
@@ -246,6 +246,19 @@ The options are applied with Sidekiq's `set` method, so any Sidekiq job option (
246
246
 
247
247
  - If the options include a `queue`, the callback job that invokes your `on_complete`/`on_error` methods is enqueued on that queue as well, so the whole request keeps one priority end to end.
248
248
  - Nested blocks merge their options, and the innermost values take precedence. The previous options are restored when the block exits, even if the block raises an error.
249
+ - Requests made in the block always go through the Sidekiq queue, even when the processor runs in the current process, so that Sidekiq applies the options (see Direct Execution below).
250
+
251
+ ### Direct Execution
252
+
253
+ When a request is made in a process where the processor is running (normally a Sidekiq server process), the request skips the Sidekiq queue and goes straight to the processor. This removes a round trip through Redis. The behavior is the same as the enqueued path:
254
+
255
+ - The request can always be re-enqueued. It is registered in the crash-recovery registry before the call returns, so if the processor shuts down or the process crashes, the request is enqueued as a normal `RequestWorker` job. If the registry entry cannot be written (for example, Redis is unavailable), the call raises, the same as a failed enqueue.
256
+ - If the processor is at max capacity or stops accepting requests, the request is enqueued through Sidekiq instead, and the normal Sidekiq retry behavior applies from there.
257
+ - Requests made in a `with_sidekiq_options` block always go through the Sidekiq queue, so that Sidekiq applies the options (queue routing, scheduling, retry). Use this to route specific requests to a dedicated Sidekiq process.
258
+ - Options set with `config.sidekiq_options` (including a `queue`) do not apply to direct-executed requests, because no Sidekiq job is created. If every request must go through the configured queue (for example, to run all requests on a dedicated Sidekiq process), set `config.direct_execution = false`.
259
+ - Direct execution is disabled when `Sidekiq::Testing` is enabled, so tests can observe enqueued jobs as usual.
260
+
261
+ You can turn this off with `config.direct_execution = false`. Do this if you route all requests to a dedicated queue with `config.sidekiq_options`, if you need Sidekiq client or server middleware to run for every request, or if you want every request to be visible as an enqueued job in Sidekiq metrics and the Web UI.
249
262
 
250
263
  ### Using Request Templates
251
264
 
@@ -464,6 +477,12 @@ PatientHttp::Sidekiq.configure do |config|
464
477
  # (use PatientHttp::Sidekiq.with_sidekiq_options to override per request)
465
478
  config.sidekiq_options = {queue: "patient_http", retry: 5}
466
479
 
480
+ # Whether requests made in a process with a running processor skip the
481
+ # Sidekiq queue and go straight to the processor (default: true).
482
+ # Sidekiq options, including a queue, do not apply to direct-executed
483
+ # requests; set this to false to route every request through the queue.
484
+ config.direct_execution = true
485
+
467
486
  # Handler called when a callback job exhausts all Sidekiq retries
468
487
  config.on_retries_exhausted { |error| MyAlertService.notify(error) }
469
488
 
@@ -588,6 +607,8 @@ The gem includes crash recovery to handle process failures:
588
607
 
589
608
  This ensures that if a Sidekiq process crashes, its in-flight requests will be retried by another process.
590
609
 
610
+ Crash recovery gives at-least-once delivery. If a process crashes at the wrong moment (for example, between a re-enqueue and the removal of the registry entry), a request can execute more than once and its callback can fire more than once. Make your callbacks idempotent. A request is durable once the call that submits it returns; a crash during the call behaves like a failed enqueue, and the caller never received an acknowledgment.
611
+
591
612
  ## Testing
592
613
 
593
614
  The gem supports `Sidekiq::Testing.inline!` mode for synchronous testing. When in inline mode, async HTTP requests are executed immediately within the worker thread, blocking until completion. This allows you to write tests that verify the full request/response cycle without needing the async processor to be running.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.2.0
1
+ 1.3.0
@@ -24,6 +24,10 @@ module PatientHttp
24
24
  # @return [Hash, nil] Sidekiq options to apply to RequestWorker and CallbackWorker
25
25
  attr_reader :sidekiq_options
26
26
 
27
+ # @return [Boolean] Whether requests execute directly on a processor running in the
28
+ # current process instead of being enqueued through Sidekiq
29
+ attr_reader :direct_execution
30
+
27
31
  # @return [#call, nil] Handler invoked when a CallbackWorker job exhausts all retries.
28
32
  # @overload on_retries_exhausted
29
33
  # Returns the current handler.
@@ -51,6 +55,8 @@ module PatientHttp
51
55
  # @param orphan_threshold [Integer] Age threshold for detecting orphaned requests in seconds
52
56
  # @param sidekiq_options [Hash, nil] Sidekiq options to apply to RequestWorker and CallbackWorker
53
57
  # @param on_retries_exhausted [#call, nil] Handler called when a CallbackWorker job exhausts retries
58
+ # @param direct_execution [Boolean] Whether requests execute directly on a processor
59
+ # running in the current process instead of being enqueued through Sidekiq
54
60
  # @param pool_options [Hash] Options passed through to PatientHttp::Configuration.
55
61
  # Sidekiq-aware defaults are applied for shutdown_timeout and logger
56
62
  # if not explicitly provided.
@@ -60,6 +66,7 @@ module PatientHttp
60
66
  sidekiq_options: nil,
61
67
  payload_store_threshold: DEFAULT_PAYLOAD_STORE_THRESHOLD,
62
68
  on_retries_exhausted: nil,
69
+ direct_execution: true,
63
70
  **pool_options
64
71
  )
65
72
  pool_options[:shutdown_timeout] ||= (::Sidekiq.default_configuration[:timeout] || 25) - 2
@@ -73,6 +80,7 @@ module PatientHttp
73
80
  self.orphan_threshold = orphan_threshold
74
81
  self.payload_store_threshold = payload_store_threshold || DEFAULT_PAYLOAD_STORE_THRESHOLD
75
82
  self.on_retries_exhausted = on_retries_exhausted
83
+ self.direct_execution = direct_execution
76
84
  end
77
85
 
78
86
  # Set the on_retries_exhausted handler.
@@ -144,6 +152,24 @@ module PatientHttp
144
152
  apply_sidekiq_options(options)
145
153
  end
146
154
 
155
+ # Set whether requests execute directly on a processor running in the current
156
+ # process. When enabled, requests made in a process with a running processor
157
+ # skip the Sidekiq queue and go straight to the processor. The value is
158
+ # coerced to a boolean.
159
+ #
160
+ # @param value [Boolean] true to enable direct execution
161
+ # @return [void]
162
+ def direct_execution=(value)
163
+ @direct_execution = !!value
164
+ end
165
+
166
+ # Check if direct execution is enabled.
167
+ #
168
+ # @return [Boolean]
169
+ def direct_execution?
170
+ @direct_execution
171
+ end
172
+
147
173
  # Convert to hash for inspection
148
174
  # @return [Hash] hash representation with string keys
149
175
  def to_h
@@ -152,6 +178,7 @@ module PatientHttp
152
178
  "heartbeat_interval" => heartbeat_interval,
153
179
  "orphan_threshold" => orphan_threshold,
154
180
  "sidekiq_options" => sidekiq_options,
181
+ "direct_execution" => direct_execution,
155
182
  "on_retries_exhausted" => on_retries_exhausted ? "defined" : nil
156
183
  )
157
184
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatientHttp
4
+ module Sidekiq
5
+ # TaskHandler for requests executed directly on the local processor
6
+ # without an enqueued Sidekiq job. Requests with scoped Sidekiq options
7
+ # always go through the queue, so the handler only deals with the
8
+ # default RequestWorker options.
9
+ #
10
+ # Retry enqueues a normal RequestWorker job with the original arguments,
11
+ # so the fail-back behavior matches the enqueued path. The sidekiq_job
12
+ # hash is a minimal job record kept for the crash-recovery registry; it
13
+ # has no jid because no Sidekiq job exists until the request is
14
+ # re-enqueued, so job_id returns nil.
15
+ class DirectTaskHandler < TaskHandler
16
+ # @param args [Array] the RequestWorker job arguments
17
+ def initialize(args)
18
+ @args = args
19
+ super(minimal_job_record)
20
+ end
21
+
22
+ # Re-enqueue the request as a normal RequestWorker job.
23
+ #
24
+ # @return [String] the job ID
25
+ def retry
26
+ RequestWorker.perform_async(*@args)
27
+ end
28
+
29
+ private
30
+
31
+ # Minimal pushable job record for the crash-recovery registry.
32
+ # TaskMonitor serializes it to Redis and the orphan GC pushes it
33
+ # verbatim, possibly from another process, so it cannot enqueue
34
+ # through this handler. The worker options are included because
35
+ # Sidekiq::Client.push does not apply them when "class" is a String.
36
+ #
37
+ # @return [Hash]
38
+ def minimal_job_record
39
+ RequestWorker.get_sidekiq_options
40
+ .merge("class" => RequestWorker.name, "args" => @args)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -4,6 +4,12 @@ module PatientHttp
4
4
  module Sidekiq
5
5
  # Procesor Observer that collect stats in Redis for the WebUI and
6
6
  # monitors for crashed processes in order to re-enqueue workers.
7
+ #
8
+ # Tasks are registered in the crash-recovery registry when the processor
9
+ # accepts them, before Processor#enqueue returns, so a request always has
10
+ # a durable record from the moment the caller hands it off. The entry is
11
+ # removed when the request completes or when a Sidekiq job owns the
12
+ # request again (the task was rejected or re-enqueued).
7
13
  class ProcessorObserver < PatientHttp::ProcessorObserver
8
14
  attr_reader :task_monitor
9
15
 
@@ -14,7 +20,7 @@ module PatientHttp
14
20
  @monitor_thread = TaskMonitorThread.new(
15
21
  processor.config,
16
22
  @task_monitor,
17
- -> { @processor.inflight_request_ids }
23
+ -> { @processor.tracked_request_ids }
18
24
  )
19
25
  end
20
26
 
@@ -31,10 +37,18 @@ module PatientHttp
31
37
  @stats.record_capacity_exceeded
32
38
  end
33
39
 
34
- def request_start(request_task)
40
+ def request_enqueued(request_task)
35
41
  task_monitor.register(request_task)
36
42
  end
37
43
 
44
+ def request_rejected(request_task)
45
+ task_monitor.unregister(request_task)
46
+ end
47
+
48
+ def request_requeued(request_task)
49
+ task_monitor.unregister(request_task)
50
+ end
51
+
38
52
  def request_end(request_task)
39
53
  task_monitor.unregister(request_task)
40
54
  @stats.record_request(request_task.response&.status, request_task.duration)
@@ -7,9 +7,8 @@ module PatientHttp
7
7
  class << self
8
8
  # Execute the request directly on the async processor.
9
9
  #
10
- # This method enqueues the request directly to the async processor. It must be
11
- # called from within a Sidekiq job context (the sidekiq_job parameter is required).
12
- # Used internally by RequestWorker.
10
+ # This method enqueues the request directly to the async processor. Used internally
11
+ # by RequestWorker.
13
12
  #
14
13
  # When the request completes, the callback's +on_complete+ method is called with
15
14
  # a Response object. If an error occurs (network error, timeout, or non-2xx response
@@ -22,6 +21,9 @@ module PatientHttp
22
21
  # If not provided, uses PatientHttp::Sidekiq::Context.current_job.
23
22
  # This requires the PatientHttp::Sidekiq::Context::Middleware to be added
24
23
  # to the Sidekiq server middleware chain.
24
+ # @param task_handler [PatientHttp::TaskHandler, nil] A prebuilt task handler.
25
+ # When provided, the sidekiq_job parameter is ignored and no handler is
26
+ # built from it. Used for direct execution on the local processor.
25
27
  # @param synchronous [Boolean] If true, runs the request inline (for testing).
26
28
  # @param callback_args [#to_h, nil] Arguments to pass to callback via the
27
29
  # Response/Error object. Must respond to +to_h+ and contain only JSON-native types
@@ -38,14 +40,14 @@ module PatientHttp
38
40
  request,
39
41
  callback:,
40
42
  sidekiq_job: nil,
43
+ task_handler: nil,
41
44
  synchronous: false,
42
45
  callback_args: nil,
43
46
  raise_error_responses: false,
44
47
  request_id: nil
45
48
  )
46
- sidekiq_job = validate_sidekiq_job(sidekiq_job)
49
+ task_handler ||= TaskHandler.new(validate_sidekiq_job(sidekiq_job))
47
50
  config = PatientHttp::Sidekiq.configuration
48
- task_handler = TaskHandler.new(sidekiq_job)
49
51
 
50
52
  task = PatientHttp::RequestTask.new(
51
53
  request: request,
@@ -20,12 +20,13 @@ module PatientHttp
20
20
  #
21
21
  # @param config [Configuration] the configuration object
22
22
  # @param task_monitor [TaskMonitor] the inflight request registry
23
- # @param inflight_ids_callback [Proc] callback to get current inflight request IDs
23
+ # @param tracked_ids_callback [Proc] callback to get the IDs of all requests the
24
+ # processor is tracking (queued, pending, and in-flight)
24
25
  # @return [void]
25
- def initialize(config, task_monitor, inflight_ids_callback)
26
+ def initialize(config, task_monitor, tracked_ids_callback)
26
27
  @config = config
27
28
  @task_monitor = task_monitor
28
- @inflight_ids_callback = inflight_ids_callback
29
+ @tracked_ids_callback = tracked_ids_callback
29
30
  @thread = nil
30
31
  @running = Concurrent::AtomicBoolean.new(false)
31
32
  @stop_signal = Concurrent::Event.new
@@ -107,16 +108,16 @@ module PatientHttp
107
108
  @config.logger&.info("[PatientHttp::Sidekiq] Monitor thread stopped")
108
109
  end
109
110
 
110
- # Update heartbeats for all inflight requests.
111
+ # Update heartbeats for all tracked requests.
111
112
  #
112
113
  # @return [void]
113
114
  def update_heartbeats
114
- request_ids = @inflight_ids_callback.call
115
+ request_ids = @tracked_ids_callback.call
115
116
  return if request_ids.empty?
116
117
 
117
118
  @task_monitor.update_heartbeats(request_ids)
118
119
 
119
- @config.logger&.debug("[PatientHttp::Sidekiq] Updated heartbeats for #{request_ids.size} inflight requests")
120
+ @config.logger&.debug("[PatientHttp::Sidekiq] Updated heartbeats for #{request_ids.size} tracked requests")
120
121
  rescue => e
121
122
  @config.logger&.error("[PatientHttp::Sidekiq] Failed to update heartbeats: #{e.class} - #{e.message}")
122
123
  raise if PatientHttp.testing?
@@ -77,6 +77,7 @@ module PatientHttp
77
77
  autoload :CallbackWorker, File.join(__dir__, "sidekiq/callback_worker")
78
78
  autoload :Configuration, File.join(__dir__, "sidekiq/configuration")
79
79
  autoload :Context, File.join(__dir__, "sidekiq/context")
80
+ autoload :DirectTaskHandler, File.join(__dir__, "sidekiq/direct_task_handler")
80
81
  autoload :ProcessorObserver, File.join(__dir__, "sidekiq/processor_observer")
81
82
  autoload :RequestExecutor, File.join(__dir__, "sidekiq/request_executor")
82
83
  autoload :RequestWorker, File.join(__dir__, "sidekiq/request_worker")
@@ -220,7 +221,9 @@ module PatientHttp
220
221
  # Nested calls merge options with the innermost values taking precedence.
221
222
  # If the options include a queue, the callback job for the request is
222
223
  # enqueued on that queue as well. Options only apply to requests enqueued
223
- # in the same fiber as the block. This method has no effect
224
+ # in the same fiber as the block. Requests made in the block always go
225
+ # through the Sidekiq queue, even when direct execution is enabled, so
226
+ # that Sidekiq applies the options. This method has no effect
224
227
  # when jobs run inline with Sidekiq::Testing.inline!.
225
228
  #
226
229
  # @param options [Hash] Sidekiq job options (symbol or string keys)
@@ -260,7 +263,8 @@ module PatientHttp
260
263
  callback_args = PatientHttp::CallbackValidator.validate_callback_args(callback_args)
261
264
  request_id = SecureRandom.uuid
262
265
 
263
- encrypted = encrypt(request.as_json)
266
+ request_json = request.as_json
267
+ encrypted = encrypt(request_json)
264
268
 
265
269
  data = if external_storage.enabled?
266
270
  external_storage.store(encrypted, max_size: configuration.payload_store_threshold)
@@ -272,9 +276,22 @@ module PatientHttp
272
276
  if options&.any?
273
277
  queue = options["queue"]
274
278
  options = options.merge("patient_http_callback_queue" => queue.to_s) if queue
275
- RequestWorker.set(options).perform_async(data, callback_name, raise_error_responses, callback_args, request_id)
279
+ end
280
+ args = [data, callback_name, raise_error_responses, callback_args, request_id]
281
+
282
+ if direct_execution?(options)
283
+ execute_on_local_processor(
284
+ request_json,
285
+ args,
286
+ callback_name: callback_name,
287
+ raise_error_responses: raise_error_responses,
288
+ callback_args: callback_args,
289
+ request_id: request_id
290
+ )
291
+ elsif options&.any?
292
+ RequestWorker.set(options).perform_async(*args)
276
293
  else
277
- RequestWorker.perform_async(data, callback_name, raise_error_responses, callback_args, request_id)
294
+ RequestWorker.perform_async(*args)
278
295
  end
279
296
 
280
297
  request_id
@@ -395,6 +412,56 @@ module PatientHttp
395
412
  def current_sidekiq_options
396
413
  Thread.current[:patient_http_sidekiq_options]
397
414
  end
415
+
416
+ # Check if the request can go directly to a processor running in the current
417
+ # process. Requests made in a with_sidekiq_options block always go through
418
+ # the queue so that Sidekiq applies the options (queue routing, scheduling,
419
+ # retry), and Sidekiq testing modes must keep their normal enqueue semantics.
420
+ #
421
+ # @param options [Hash, nil] scoped Sidekiq options for the request
422
+ # @return [Boolean]
423
+ def direct_execution?(options)
424
+ return false unless options.nil?
425
+ return false unless configuration.direct_execution?
426
+ return false unless running?
427
+ return false if defined?(::Sidekiq::Testing) && ::Sidekiq::Testing.enabled?
428
+
429
+ true
430
+ end
431
+
432
+ # Hand the request to the processor running in the current process. If the
433
+ # processor cannot accept the request (at capacity or shutting down), the
434
+ # request is enqueued as a normal RequestWorker job instead through the
435
+ # same retry path the processor uses when it drains.
436
+ #
437
+ # @param request_json [Hash] the serialized request
438
+ # @param args [Array] the RequestWorker job arguments
439
+ # @param callback_name [String] the callback service class name
440
+ # @param raise_error_responses [Boolean] whether non-2xx responses are errors
441
+ # @param callback_args [Hash, nil] arguments to pass to the callback
442
+ # @param request_id [String] unique request ID
443
+ # @return [void]
444
+ def execute_on_local_processor(request_json, args, callback_name:, raise_error_responses:, callback_args:, request_id:)
445
+ task_handler = DirectTaskHandler.new(args)
446
+
447
+ begin
448
+ # Reload the request from its serialized form so the direct path
449
+ # processes the same reconstructed request a RequestWorker job would.
450
+ RequestExecutor.execute(
451
+ PatientHttp::Request.load(request_json),
452
+ callback: callback_name,
453
+ raise_error_responses: raise_error_responses,
454
+ callback_args: callback_args,
455
+ task_handler: task_handler,
456
+ request_id: request_id
457
+ )
458
+ rescue PatientHttp::NotRunningError, PatientHttp::MaxCapacityError => e
459
+ configuration.logger&.info(
460
+ "[PatientHttp::Sidekiq] Falling back to enqueuing request: #{e.message}"
461
+ )
462
+ task_handler.retry
463
+ end
464
+ end
398
465
  end
399
466
  end
400
467
 
@@ -40,5 +40,5 @@ Gem::Specification.new do |spec|
40
40
  spec.required_ruby_version = ">= 3.2"
41
41
 
42
42
  spec.add_dependency "sidekiq", ">= 7.0"
43
- spec.add_dependency "patient_http", ">= 1.3.0"
43
+ spec.add_dependency "patient_http", ">= 1.4.0"
44
44
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: patient_http-sidekiq
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: 1.3.0
32
+ version: 1.4.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: 1.3.0
39
+ version: 1.4.0
40
40
  description: This gem provides a mechanism to offload long-running HTTP requests from
41
41
  Sidekiq workers to a dedicated async I/O processor running in the same process,
42
42
  freeing the worker thread immediately while the HTTP request is in flight.
@@ -56,6 +56,7 @@ files:
56
56
  - lib/patient_http/sidekiq/callback_worker.rb
57
57
  - lib/patient_http/sidekiq/configuration.rb
58
58
  - lib/patient_http/sidekiq/context.rb
59
+ - lib/patient_http/sidekiq/direct_task_handler.rb
59
60
  - lib/patient_http/sidekiq/lifecycle_hooks.rb
60
61
  - lib/patient_http/sidekiq/processor_observer.rb
61
62
  - lib/patient_http/sidekiq/request_executor.rb