patient_http-sidekiq 1.2.0 → 1.4.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +31 -10
  3. data/CHANGELOG.md +32 -0
  4. data/README.md +126 -2
  5. data/VERSION +1 -1
  6. data/lib/patient_http/sidekiq/configuration.rb +258 -1
  7. data/lib/patient_http/sidekiq/direct_task_handler.rb +46 -0
  8. data/lib/patient_http/sidekiq/processor_observer.rb +162 -21
  9. data/lib/patient_http/sidekiq/redis_pool.rb +88 -0
  10. data/lib/patient_http/sidekiq/request_executor.rb +40 -10
  11. data/lib/patient_http/sidekiq/request_worker.rb +5 -2
  12. data/lib/patient_http/sidekiq/stats.rb +230 -33
  13. data/lib/patient_http/sidekiq/task_handler.rb +9 -3
  14. data/lib/patient_http/sidekiq/task_monitor.rb +410 -121
  15. data/lib/patient_http/sidekiq/task_monitor_thread.rb +41 -7
  16. data/lib/patient_http/sidekiq/web_ui/assets/patient-http/css/patient_http.css +29 -71
  17. data/lib/patient_http/sidekiq/web_ui/locales/ar.yml +10 -5
  18. data/lib/patient_http/sidekiq/web_ui/locales/cs.yml +10 -5
  19. data/lib/patient_http/sidekiq/web_ui/locales/da.yml +10 -5
  20. data/lib/patient_http/sidekiq/web_ui/locales/de.yml +10 -5
  21. data/lib/patient_http/sidekiq/web_ui/locales/el.yml +10 -5
  22. data/lib/patient_http/sidekiq/web_ui/locales/en.yml +10 -5
  23. data/lib/patient_http/sidekiq/web_ui/locales/es.yml +10 -5
  24. data/lib/patient_http/sidekiq/web_ui/locales/fa.yml +10 -5
  25. data/lib/patient_http/sidekiq/web_ui/locales/fr.yml +10 -5
  26. data/lib/patient_http/sidekiq/web_ui/locales/gd.yml +10 -5
  27. data/lib/patient_http/sidekiq/web_ui/locales/he.yml +10 -5
  28. data/lib/patient_http/sidekiq/web_ui/locales/hi.yml +10 -5
  29. data/lib/patient_http/sidekiq/web_ui/locales/it.yml +10 -5
  30. data/lib/patient_http/sidekiq/web_ui/locales/ja.yml +10 -5
  31. data/lib/patient_http/sidekiq/web_ui/locales/ko.yml +10 -5
  32. data/lib/patient_http/sidekiq/web_ui/locales/lt.yml +10 -5
  33. data/lib/patient_http/sidekiq/web_ui/locales/nb.yml +10 -5
  34. data/lib/patient_http/sidekiq/web_ui/locales/nl.yml +10 -5
  35. data/lib/patient_http/sidekiq/web_ui/locales/pl.yml +10 -5
  36. data/lib/patient_http/sidekiq/web_ui/locales/pt-BR.yml +10 -5
  37. data/lib/patient_http/sidekiq/web_ui/locales/pt.yml +10 -5
  38. data/lib/patient_http/sidekiq/web_ui/locales/ru.yml +10 -5
  39. data/lib/patient_http/sidekiq/web_ui/locales/sv.yml +10 -5
  40. data/lib/patient_http/sidekiq/web_ui/locales/ta.yml +10 -5
  41. data/lib/patient_http/sidekiq/web_ui/locales/tr.yml +10 -5
  42. data/lib/patient_http/sidekiq/web_ui/locales/uk.yml +10 -5
  43. data/lib/patient_http/sidekiq/web_ui/locales/ur.yml +10 -5
  44. data/lib/patient_http/sidekiq/web_ui/locales/vi.yml +10 -5
  45. data/lib/patient_http/sidekiq/web_ui/locales/zh-CN.yml +10 -5
  46. data/lib/patient_http/sidekiq/web_ui/locales/zh-TW.yml +10 -5
  47. data/lib/patient_http/sidekiq/web_ui/views/patient_http.html.erb +99 -44
  48. data/lib/patient_http/sidekiq/web_ui.rb +53 -1
  49. data/lib/patient_http/sidekiq.rb +303 -31
  50. data/patient_http-sidekiq.gemspec +1 -1
  51. metadata +6 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e5ae5287613de3682de817fb6c434e6a1a33c21c1b8aa1dde7b7808c1733b927
4
- data.tar.gz: 350e8f054d1047fe70482d9c3fc2f5473b077e1e7f1c64cc6f023c8bf76f62b4
3
+ metadata.gz: 0f463cc156486ac04ea624baf032e3ebd37d219149a720fd5044a864a9abf201
4
+ data.tar.gz: 0b294c6cb0df108ca53fd872c71d284fd82ab0a42284769eb2af7f04ca25bd76
5
5
  SHA512:
6
- metadata.gz: d843ce929d22f48c20bc7f487b35d11421166218081f4fea2490440781d0a00839266c3cb1dec039611a6728e2213c3a71f0a966454ba40c1efc6ab466648ca7
7
- data.tar.gz: 006cf97e99a6d27847b038c19065a88b09e28a94c7392fbee5af4253e9483652e8022b21e42c271840cfc8bff326fcfd490b137d1b08c8984760c68f6f7bc3df
6
+ metadata.gz: b9ab7cebbc773fb92baba7d58212a8fd3733392b4a93d995bdfa7ad3b03d70e1cd3637d8ebddc68c7178ca400b1adc1219ab65ef8cd3c7b9e4b852a0245b11e3
7
+ data.tar.gz: 994e51c5f8d7df504f6be4ee32bdd1e32607e62672bf7f0d228d9758af8f2e1b473844063c20951726b4d2ccc37f5eae7549ce4a3796633004195a82af1bcc23
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,38 @@ 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.4.0
8
+
9
+ ### Added
10
+
11
+ - Dedicated Redis connection pool for the gem's own threads (`redis_pool_size`, default automatic; `redis_pool_timeout`, default 5 seconds). Registry writes, stats, and job pushes made from the processor's completion worker threads and the monitor thread no longer go through Sidekiq's small internal pool, which was a serialization point under load and could time out and lose completions.
12
+ - Named processors: declare profiles with `config.processor(:llm, max_connections: 200)` and route requests with `PatientHttp::Sidekiq.execute(request, processor: :llm)`, a `processor:` option on the request itself, or `with_sidekiq_options("processor" => "llm")`. Each profile runs as an independent processor with its own capacity, timeouts, and threads, so one workload class cannot starve another. The processor name is serialized into the job arguments, so retries and crash recovery keep their routing. Jobs from older gem versions run on the `:default` processor.
13
+ - Local stats aggregation (`stats_flush_interval`, default 5 seconds; 0 restores synchronous writes). Request metrics accumulate in memory and flush to Redis in one pipelined write per interval, removing the per-request write to the shared totals hash key. `get_totals` reports per-processor requests, duration, errors, and capacity rejections when more than one profile is configured.
14
+ - A result that can never be delivered no longer keeps its crash-recovery record. Delivery is retried and then recovered as before, but a failure that means the result cannot be serialized (`JSON::GeneratorError` and the `Encoding` errors, in the failure or in its cause chain) is permanent: the request is counted as an `undeliverable_result` error and its job is moved to the Sidekiq dead set. Such a request previously stayed in the registry, counted as in flight, and was re-enqueued and failed again after every process restart.
15
+ - The Web UI dashboard reports the high-water mark of requests in flight for each processor. The count only rises when a processor accepts a request, so the mark is recorded exactly rather than sampled, and it costs one comparison in memory. It is the most requests one process held at once, so it compares with `max_connections`, which is also per process. Like the other statistics it covers everything since they were last cleared.
16
+ - The Web UI dashboard lists the requests that have been in flight the longest, with the URL, HTTP method, processor, and age of each. The details are written next to the crash-recovery record, so a request left behind by a process that died stays listed until the orphan collector re-enqueues it. The URL is sanitized first: the user name, password, query string, and fragment are removed. Use `config.inflight_url_sanitizer` to redact more, or `config.inflight_details = false` to record nothing.
17
+ - The Web UI dashboard breaks the statistics down by processor when more than one profile is configured, reporting each processor's inflight requests, capacity, utilization, requests, errors, capacity rejections, and average duration. Each process publishes the capacity of its processors with its heartbeat, which the monitor thread now sends on every pass so the inflight counts stay current.
18
+ - Capacity fast path: requests are rejected with a cheap in-memory capacity check before any Redis registration. A rejection previously cost three Redis round trips (register, unregister, stat); it now costs none.
19
+ - The `completion_failed` processor event is handled by keeping the crash-recovery registry entry, so a request whose result could not be delivered is re-enqueued by the orphan collector instead of being silently lost, unless the failure is permanent (see above). Requires patient_http 1.5.0.
20
+ - A warning is logged at startup when the hiredis Redis driver is detected, because its blocking I/O can stall the reactor thread if application code calls Redis from processor callbacks.
21
+
22
+ ### Changed
23
+
24
+ - The orphan collector removes orphans in batches of 100 with a single Lua call per batch (invoked by `EVALSHA`), instead of one `EVAL` with the full script body per orphan. Releasing the collector's lock is now a single compare-and-delete script call instead of a WATCH/GET/MULTI sequence.
25
+ - The shutdown re-enqueue path no longer unregisters a task twice or records a completion stat for a request that never completed.
26
+ - The task monitor thread and stats are now shared across all processors in the process; `ProcessorObserver.new` takes `stats:` and `task_monitor:` keyword arguments.
27
+ - The `patient_http` dependency floor is now 1.5.0.
28
+
29
+ ## 1.3.0
30
+
31
+ ### Added
32
+
33
+ - 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.
34
+
35
+ ### Changed
36
+
37
+ - 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.
38
+
7
39
  ## 1.2.0
8
40
 
9
41
  ### Added
data/README.md CHANGED
@@ -246,6 +246,52 @@ 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.
262
+
263
+ ### Named Processors
264
+
265
+ By default all requests share one processor and one `max_connections` cap. When one process serves workload classes with very different profiles (for example, large slow API calls and small fast webhook deliveries), a burst of one class can consume all of the capacity the other class needs. Named processor profiles isolate them:
266
+
267
+ ```ruby
268
+ PatientHttp::Sidekiq.configure do |config|
269
+ config.processor(:llm, max_connections: 200, request_timeout: 120)
270
+ config.processor(:webhooks, max_connections: 64, request_timeout: 10)
271
+ end
272
+ ```
273
+
274
+ Each profile runs as an independent processor in the process, with its own capacity, timeouts, and threads. Profile options override the top-level configuration; anything not overridden (secrets, preprocessors, payload stores, encryption, logger) is shared. The `:default` processor always exists; declare `config.processor(:default, ...)` to override its options.
275
+
276
+ Route a request to a processor in any of these ways:
277
+
278
+ ```ruby
279
+ # Explicit option on execute
280
+ PatientHttp::Sidekiq.execute(request, callback: MyCallback, processor: :llm)
281
+
282
+ # On the request itself (survives serialization, retries, and crash recovery)
283
+ request = PatientHttp::Request.new(:get, url, processor: :llm)
284
+
285
+ # Through a request template
286
+ template = PatientHttp::RequestTemplate.new(base_url: url, processor: :llm)
287
+
288
+ # Scoped for a block
289
+ PatientHttp::Sidekiq.with_sidekiq_options("processor" => "webhooks") do
290
+ PatientHttp::Sidekiq.execute(request, callback: MyCallback)
291
+ end
292
+ ```
293
+
294
+ The processor name is serialized into the job arguments, so Sidekiq retries and crash recovery keep their routing. A job that names a processor that is not configured in the executing process raises `PatientHttp::UnknownProcessorError` and goes through the normal Sidekiq retry mechanism; this makes new profile names safe to roll out gradually. Jobs enqueued by older gem versions run on the `:default` processor.
249
295
 
250
296
  ### Using Request Templates
251
297
 
@@ -464,6 +510,42 @@ PatientHttp::Sidekiq.configure do |config|
464
510
  # (use PatientHttp::Sidekiq.with_sidekiq_options to override per request)
465
511
  config.sidekiq_options = {queue: "patient_http", retry: 5}
466
512
 
513
+ # Whether the URL, HTTP method, and processor of each in-flight request are
514
+ # recorded so the Web UI can list them (default: true).
515
+ config.inflight_details = true
516
+
517
+ # Sanitizer applied to a URL before it is recorded. Without one, the user
518
+ # name, password, query string, and fragment are removed.
519
+ config.inflight_url_sanitizer { |url| url.sub(%r{/users/\d+}, "/users/:id") }
520
+
521
+ # Whether requests made in a process with a running processor skip the
522
+ # Sidekiq queue and go straight to the processor (default: true).
523
+ # Sidekiq options, including a queue, do not apply to direct-executed
524
+ # requests; set this to false to route every request through the queue.
525
+ config.direct_execution = true
526
+
527
+ # Size of the gem's dedicated Redis pool used by its own threads
528
+ # (default: nil, sized automatically from completion_threads with a
529
+ # floor of 10)
530
+ config.redis_pool_size = nil
531
+
532
+ # Checkout timeout in seconds for the dedicated Redis pool (default: 5)
533
+ config.redis_pool_timeout = 5
534
+
535
+ # Seconds between flushes of locally aggregated stats to Redis
536
+ # (default: 5; 0 writes every event synchronously)
537
+ config.stats_flush_interval = 5
538
+
539
+ # Number of threads that decode responses and deliver results (default: 2)
540
+ config.completion_threads = 2
541
+
542
+ # Maximum connections per host (default: nil, unlimited)
543
+ config.max_connections_per_host = 32
544
+
545
+ # Named processor profiles for workload isolation (see Named Processors)
546
+ config.processor(:llm, max_connections: 200, request_timeout: 120)
547
+ config.processor(:webhooks, max_connections: 64, request_timeout: 10)
548
+
467
549
  # Handler called when a callback job exhausts all Sidekiq retries
468
550
  config.on_retries_exhausted { |error| MyAlertService.notify(error) }
469
551
 
@@ -488,6 +570,13 @@ See the [Configuration](lib/patient_http/sidekiq/configuration.rb) class for all
488
570
  - `connection_timeout`: Set this if you need to fail fast on connection establishment. Useful for detecting network issues quickly.
489
571
  - `retries`: Number of times to retry a failed request before calling the error callback.
490
572
  - `max_response_size`: Set this to limit the maximum size of HTTP responses. This helps prevent excessive memory usage from unexpectedly large responses. Responses need to be serialized to Redis as Sidekiq jobs and very large responses may cause performance issues in Redis. If a response body is text content, it will be compressed to save space in Redis. However, binary content needs to be Base64 encoded which increases size by ~33%.
573
+ - `max_connections_per_host`: Bounds sockets per host. Verify the process file descriptor limit covers `max_connections` plus pooled idle host connections plus the application's own connections; raise the limit if needed.
574
+ - `shutdown_timeout`: Must be below the process supervisor's termination window so the drain finishes before a hard kill. The default derives it from Sidekiq's own shutdown timeout; check any additional supervisor (container orchestrator, init system) stop timeout as well.
575
+ - `completion_threads`: Increase when result callbacks do heavier work (serialization, encryption) and completions back up behind them.
576
+ - `redis_pool_size`: The automatic size covers the gem's own threads. Increase it when a high request rate makes registration or completion pushes wait on checkouts.
577
+
578
+ > [!WARNING]
579
+ > Do not install `hiredis-client` in processes that run the async processor. The hiredis driver performs blocking I/O that does not yield to the fiber scheduler, so a Redis call made on the reactor thread (for example, from a custom processor observer) stalls every in-flight HTTP request. The gem logs a warning at startup when it detects the hiredis driver.
491
580
 
492
581
  > [!IMPORTANT]
493
582
  >
@@ -511,10 +600,41 @@ mount Sidekiq::Web => "/sidekiq"
511
600
  ```
512
601
 
513
602
  The Web UI shows:
514
- - Total requests, errors, and average duration
515
- - Current capacity utilization
603
+ - Total requests, errors, average duration, and current capacity utilization
604
+ - Per-processor capacity, utilization, requests, errors, average duration, and the high-water mark of
605
+ requests in flight, when more than one [named processor](#named-processors) is configured
606
+ - The requests that have been in flight the longest, with their URL, HTTP method, processor, and age
516
607
  - Per-process inflight request counts
517
608
 
609
+ The per-processor numbers come from the capacity each process publishes with its heartbeat, so they
610
+ cover the processes that are currently running and can lag by a few seconds.
611
+
612
+ The high-water mark is the most requests one process held on that processor at once, so compare it
613
+ with `max_connections`, which is also per process, rather than with the capacity column, which is the
614
+ sum across the running processes. The count only rises when a processor accepts a request, so the
615
+ mark is exact rather than sampled. It covers everything since the statistics were last cleared.
616
+
617
+ #### In-Flight Requests
618
+
619
+ The URL, HTTP method, and processor of each in-flight request are recorded next to its
620
+ crash-recovery record, and the dashboard lists the 50 oldest. A request left behind by a process
621
+ that died stays listed until the orphan collector re-enqueues it, so this is also where you see what
622
+ a process was working on when it stopped.
623
+
624
+ The URL is sanitized before it is recorded: the user name, password, query string, and fragment are
625
+ removed and the scheme, host, and path are kept. Paths can still carry identifiers, so you can
626
+ redact more, or record nothing at all:
627
+
628
+ ```ruby
629
+ PatientHttp::Sidekiq.configure do |config|
630
+ # Redact more of the URL.
631
+ config.inflight_url_sanitizer { |url| url.sub(%r{/users/\d+}, "/users/:id") }
632
+
633
+ # Or keep URLs out of Redis entirely.
634
+ config.inflight_details = false
635
+ end
636
+ ```
637
+
518
638
  ### Callbacks for Custom Monitoring
519
639
 
520
640
  You can register callbacks to integrate with your monitoring system using the `after_completion` and `after_error` hooks:
@@ -588,6 +708,10 @@ The gem includes crash recovery to handle process failures:
588
708
 
589
709
  This ensures that if a Sidekiq process crashes, its in-flight requests will be retried by another process.
590
710
 
711
+ 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.
712
+
713
+ A request whose result cannot be handed to a callback job keeps its registry entry as well, so the same recovery re-enqueues it. When the failure is one that trying again cannot fix, because the result cannot be serialized, the request is not kept: it is counted as an `undeliverable_result` error and its job is moved to the Sidekiq dead set, where you can inspect it and retry it by hand.
714
+
591
715
  ## Testing
592
716
 
593
717
  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.4.0
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "delegate"
4
+
3
5
  module PatientHttp
4
6
  module Sidekiq
5
7
  # Configuration for the Sidekiq integration.
@@ -24,6 +26,25 @@ module PatientHttp
24
26
  # @return [Hash, nil] Sidekiq options to apply to RequestWorker and CallbackWorker
25
27
  attr_reader :sidekiq_options
26
28
 
29
+ # @return [Boolean] Whether requests execute directly on a processor running in the
30
+ # current process instead of being enqueued through Sidekiq
31
+ attr_reader :direct_execution
32
+
33
+ # @return [Boolean] Whether the URL, HTTP method, and processor of each in-flight
34
+ # request are recorded so the Web UI can list them
35
+ attr_reader :inflight_details
36
+
37
+ # @return [Integer, nil] Size of the gem's dedicated Redis pool; nil sizes it
38
+ # automatically from completion_threads
39
+ attr_reader :redis_pool_size
40
+
41
+ # @return [Numeric] Checkout timeout in seconds for the gem's dedicated Redis pool
42
+ attr_reader :redis_pool_timeout
43
+
44
+ # @return [Numeric] Seconds between flushes of locally aggregated stats to Redis
45
+ # (0 flushes synchronously on every recorded event)
46
+ attr_reader :stats_flush_interval
47
+
27
48
  # @return [#call, nil] Handler invoked when a CallbackWorker job exhausts all retries.
28
49
  # @overload on_retries_exhausted
29
50
  # Returns the current handler.
@@ -51,6 +72,8 @@ module PatientHttp
51
72
  # @param orphan_threshold [Integer] Age threshold for detecting orphaned requests in seconds
52
73
  # @param sidekiq_options [Hash, nil] Sidekiq options to apply to RequestWorker and CallbackWorker
53
74
  # @param on_retries_exhausted [#call, nil] Handler called when a CallbackWorker job exhausts retries
75
+ # @param direct_execution [Boolean] Whether requests execute directly on a processor
76
+ # running in the current process instead of being enqueued through Sidekiq
54
77
  # @param pool_options [Hash] Options passed through to PatientHttp::Configuration.
55
78
  # Sidekiq-aware defaults are applied for shutdown_timeout and logger
56
79
  # if not explicitly provided.
@@ -60,6 +83,11 @@ module PatientHttp
60
83
  sidekiq_options: nil,
61
84
  payload_store_threshold: DEFAULT_PAYLOAD_STORE_THRESHOLD,
62
85
  on_retries_exhausted: nil,
86
+ direct_execution: true,
87
+ redis_pool_size: nil,
88
+ redis_pool_timeout: 5,
89
+ stats_flush_interval: 5,
90
+ inflight_details: true,
63
91
  **pool_options
64
92
  )
65
93
  pool_options[:shutdown_timeout] ||= (::Sidekiq.default_configuration[:timeout] || 25) - 2
@@ -68,11 +96,17 @@ module PatientHttp
68
96
  super(**pool_options)
69
97
 
70
98
  @observers = []
99
+ @processor_profiles = {default: {}}
71
100
  self.sidekiq_options = sidekiq_options
72
101
  self.heartbeat_interval = heartbeat_interval
73
102
  self.orphan_threshold = orphan_threshold
74
103
  self.payload_store_threshold = payload_store_threshold || DEFAULT_PAYLOAD_STORE_THRESHOLD
75
104
  self.on_retries_exhausted = on_retries_exhausted
105
+ self.direct_execution = direct_execution
106
+ self.redis_pool_size = redis_pool_size
107
+ self.redis_pool_timeout = redis_pool_timeout
108
+ self.stats_flush_interval = stats_flush_interval
109
+ self.inflight_details = inflight_details
76
110
  end
77
111
 
78
112
  # Set the on_retries_exhausted handler.
@@ -144,6 +178,179 @@ module PatientHttp
144
178
  apply_sidekiq_options(options)
145
179
  end
146
180
 
181
+ # Set whether requests execute directly on a processor running in the current
182
+ # process. When enabled, requests made in a process with a running processor
183
+ # skip the Sidekiq queue and go straight to the processor. The value is
184
+ # coerced to a boolean.
185
+ #
186
+ # @param value [Boolean] true to enable direct execution
187
+ # @return [void]
188
+ def direct_execution=(value)
189
+ @direct_execution = !!value
190
+ end
191
+
192
+ # Check if direct execution is enabled.
193
+ #
194
+ # @return [Boolean]
195
+ def direct_execution?
196
+ @direct_execution
197
+ end
198
+
199
+ # Set the size of the gem's dedicated Redis pool.
200
+ #
201
+ # @param value [Integer, nil] pool size; nil sizes the pool automatically
202
+ # @return [void]
203
+ def redis_pool_size=(value)
204
+ if value.nil?
205
+ @redis_pool_size = nil
206
+ return
207
+ end
208
+
209
+ validate_positive_integer(:redis_pool_size, value)
210
+ @redis_pool_size = value
211
+ end
212
+
213
+ # Set the checkout timeout for the gem's dedicated Redis pool.
214
+ #
215
+ # @param value [Numeric] timeout in seconds
216
+ # @return [void]
217
+ def redis_pool_timeout=(value)
218
+ unless value.is_a?(Numeric) && value.positive?
219
+ raise ArgumentError.new("redis_pool_timeout must be a positive number, got: #{value.inspect}")
220
+ end
221
+
222
+ @redis_pool_timeout = value
223
+ end
224
+
225
+ # Set the flush interval for locally aggregated stats.
226
+ #
227
+ # @param value [Numeric] seconds between flushes; 0 flushes synchronously on
228
+ # every recorded event
229
+ # @return [void]
230
+ def stats_flush_interval=(value)
231
+ unless value.is_a?(Numeric) && value >= 0
232
+ raise ArgumentError.new("stats_flush_interval must be a non-negative number, got: #{value.inspect}")
233
+ end
234
+
235
+ @stats_flush_interval = value
236
+ end
237
+
238
+ # Set whether the details of each in-flight request are recorded.
239
+ #
240
+ # The URL (see +inflight_url_sanitizer+), HTTP method, and processor name are
241
+ # written next to the crash-recovery record so the Web UI can list the requests
242
+ # that are in flight. Turn this off to keep URLs out of Redis entirely.
243
+ #
244
+ # @param value [Boolean] true to record the details
245
+ # @return [void]
246
+ def inflight_details=(value)
247
+ @inflight_details = !!value
248
+ end
249
+
250
+ # Check if in-flight request details are recorded.
251
+ #
252
+ # @return [Boolean]
253
+ def inflight_details?
254
+ @inflight_details
255
+ end
256
+
257
+ # Set the sanitizer applied to a request URL before it is recorded for the
258
+ # Web UI, or read the current one.
259
+ #
260
+ # The sanitizer receives the full URL and returns what to display. Without
261
+ # one, the user name, password, query string, and fragment are removed and
262
+ # the scheme, host, and path are kept. Use it to redact more, such as an
263
+ # identifier in the path.
264
+ #
265
+ # @example
266
+ # config.inflight_url_sanitizer { |url| url.sub(%r{/users/\d+}, "/users/:id") }
267
+ #
268
+ # @overload inflight_url_sanitizer
269
+ # @return [#call, nil] the current sanitizer
270
+ # @overload inflight_url_sanitizer(&block)
271
+ # @yield [url] block that returns the URL to display
272
+ # @yieldparam url [String] the full request URL
273
+ def inflight_url_sanitizer(&block)
274
+ if block
275
+ @inflight_url_sanitizer = block
276
+ else
277
+ @inflight_url_sanitizer
278
+ end
279
+ end
280
+
281
+ # Set the sanitizer applied to a request URL before it is recorded.
282
+ #
283
+ # @param value [#call, nil] a callable object, or nil to use the default
284
+ # @raise [ArgumentError] If value is not callable and not nil
285
+ def inflight_url_sanitizer=(value)
286
+ if value && !value.respond_to?(:call)
287
+ raise ArgumentError.new("inflight_url_sanitizer must respond to #call, got: #{value.class}")
288
+ end
289
+
290
+ @inflight_url_sanitizer = value
291
+ end
292
+
293
+ # Declare a named processor profile, or read one back.
294
+ #
295
+ # Each profile becomes an independent processor with its own capacity,
296
+ # timeouts, and threads. Options are overrides applied on top of this
297
+ # configuration's HTTP pool options. Requests select a processor with the
298
+ # +processor:+ option on +PatientHttp::Sidekiq.execute+ (or on the
299
+ # request itself). The +:default+ profile always exists; declaring it
300
+ # overrides options for the default processor.
301
+ #
302
+ # @example
303
+ # PatientHttp::Sidekiq.configure do |config|
304
+ # config.processor(:llm, max_connections: 200, request_timeout: 120)
305
+ # config.processor(:webhooks, max_connections: 64, request_timeout: 10)
306
+ # end
307
+ #
308
+ # @param name [Symbol, String] the processor name
309
+ # @param options [Hash] overrides for PatientHttp::Configuration options
310
+ # @return [Hash] the stored options for the profile
311
+ def processor(name, **options)
312
+ key = normalize_processor_name(name)
313
+
314
+ if options.any?
315
+ validate_profile_options!(options)
316
+ @processor_profiles[key] = options
317
+ end
318
+
319
+ @processor_profiles[key]
320
+ end
321
+
322
+ # All declared processor profiles. Always includes :default.
323
+ #
324
+ # @return [Hash{Symbol => Hash}] profile options by processor name
325
+ def processor_profiles
326
+ @processor_profiles.dup
327
+ end
328
+
329
+ # Whether more than one processor profile is declared.
330
+ #
331
+ # @return [Boolean]
332
+ def multiple_processors?
333
+ @processor_profiles.size > 1
334
+ end
335
+
336
+ # Build the effective configuration for a named processor. The default
337
+ # profile with no overrides is this configuration itself; other profiles
338
+ # get a view of this configuration with their overrides applied, so they
339
+ # share secrets, preprocessors, payload stores, and encryption.
340
+ #
341
+ # @param name [Symbol, String] the processor name
342
+ # @return [PatientHttp::Configuration] the configuration for the processor
343
+ # @raise [ArgumentError] if the profile is not declared
344
+ def processor_config(name)
345
+ key = normalize_processor_name(name)
346
+ profile = @processor_profiles[key]
347
+ raise ArgumentError.new("Unknown processor profile: #{name.inspect}") unless profile
348
+
349
+ return self if profile.empty?
350
+
351
+ ProfileConfiguration.new(self, profile)
352
+ end
353
+
147
354
  # Convert to hash for inspection
148
355
  # @return [Hash] hash representation with string keys
149
356
  def to_h
@@ -152,12 +359,62 @@ module PatientHttp
152
359
  "heartbeat_interval" => heartbeat_interval,
153
360
  "orphan_threshold" => orphan_threshold,
154
361
  "sidekiq_options" => sidekiq_options,
155
- "on_retries_exhausted" => on_retries_exhausted ? "defined" : nil
362
+ "direct_execution" => direct_execution,
363
+ "on_retries_exhausted" => on_retries_exhausted ? "defined" : nil,
364
+ "redis_pool_size" => redis_pool_size,
365
+ "redis_pool_timeout" => redis_pool_timeout,
366
+ "stats_flush_interval" => stats_flush_interval,
367
+ "inflight_details" => inflight_details,
368
+ "processor_profiles" => processor_profiles.keys.map(&:to_s)
156
369
  )
157
370
  end
158
371
 
372
+ # View of a base configuration with a profile's option overrides applied.
373
+ # Everything not overridden (secrets, preprocessors, payload stores,
374
+ # logging) delegates to the base configuration, so all processors share
375
+ # those registries.
376
+ #
377
+ # Overrides are applied through a real PatientHttp::Configuration so that
378
+ # each option's writer performs its own normalization. Readers whose
379
+ # value a writer derives from an overridden option are taken from that
380
+ # configuration as well, so an override cannot be half applied.
381
+ class ProfileConfiguration < SimpleDelegator
382
+ # Readers populated as a side effect of another option's writer.
383
+ DERIVED_READERS = {
384
+ encryption_key: [:encryption, :decryption, :encryptor]
385
+ }.freeze
386
+
387
+ def initialize(base_configuration, overrides)
388
+ super(base_configuration)
389
+ normalized = PatientHttp::Configuration.new(**overrides)
390
+ readers = overrides.keys.flat_map { |key| [key.to_sym, *DERIVED_READERS[key.to_sym]] }.uniq
391
+ readers.each do |reader|
392
+ next unless normalized.respond_to?(reader)
393
+
394
+ define_singleton_method(reader) do |*args, &block|
395
+ normalized.public_send(reader, *args, &block)
396
+ end
397
+ end
398
+ end
399
+ end
400
+
159
401
  private
160
402
 
403
+ # Profile options must be valid PatientHttp::Configuration options. A
404
+ # throwaway configuration exercises each option's own validation.
405
+ def validate_profile_options!(options)
406
+ PatientHttp::Configuration.new(**options)
407
+ rescue ArgumentError => e
408
+ raise ArgumentError.new("Invalid processor profile options: #{e.message}")
409
+ end
410
+
411
+ def normalize_processor_name(name)
412
+ key = name.to_s
413
+ raise ArgumentError.new("processor name cannot be empty") if key.empty?
414
+
415
+ key.to_sym
416
+ end
417
+
161
418
  def apply_sidekiq_options(options)
162
419
  PatientHttp::Sidekiq::RequestWorker.sidekiq_options(options)
163
420
  PatientHttp::Sidekiq::CallbackWorker.sidekiq_options(options)
@@ -0,0 +1,46 @@
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
+ PatientHttp::Sidekiq.with_redis_pool do
27
+ RequestWorker.perform_async(*@args)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # Minimal pushable job record for the crash-recovery registry.
34
+ # TaskMonitor serializes it to Redis and the orphan GC pushes it
35
+ # verbatim, possibly from another process, so it cannot enqueue
36
+ # through this handler. The worker options are included because
37
+ # Sidekiq::Client.push does not apply them when "class" is a String.
38
+ #
39
+ # @return [Hash]
40
+ def minimal_job_record
41
+ RequestWorker.get_sidekiq_options
42
+ .merge("class" => RequestWorker.name, "args" => @args)
43
+ end
44
+ end
45
+ end
46
+ end