wurk 1.5.0 → 1.7.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 (173) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -2
  3. data/app/controllers/wurk/api/serializers.rb +51 -1
  4. data/app/controllers/wurk/api_controller.rb +42 -6
  5. data/app/controllers/wurk/dashboard_controller.rb +67 -10
  6. data/config/routes.rb +30 -3
  7. data/exe/wurk +6 -2
  8. data/lib/generators/wurk/install/install_generator.rb +3 -3
  9. data/lib/sidekiq/job_retry.rb +4 -0
  10. data/lib/sidekiq/manager.rb +4 -0
  11. data/lib/sidekiq/processor.rb +4 -0
  12. data/lib/wurk/api/app.rb +186 -0
  13. data/lib/wurk/api/auth.rb +167 -0
  14. data/lib/wurk/api/flows.rb +66 -0
  15. data/lib/wurk/api/idempotency.rb +168 -0
  16. data/lib/wurk/api/jobs.rb +180 -0
  17. data/lib/wurk/api/page.rb +98 -0
  18. data/lib/wurk/api/problem.rb +121 -0
  19. data/lib/wurk/api/queues.rb +126 -0
  20. data/lib/wurk/api/read_only.rb +68 -0
  21. data/lib/wurk/api/request.rb +46 -0
  22. data/lib/wurk/api/response.rb +28 -0
  23. data/lib/wurk/api/roll_up.rb +126 -0
  24. data/lib/wurk/api/router.rb +88 -0
  25. data/lib/wurk/api/serializers.rb +232 -0
  26. data/lib/wurk/api/swarm.rb +297 -0
  27. data/lib/wurk/api/throttle.rb +109 -0
  28. data/lib/wurk/api/validation.rb +306 -0
  29. data/lib/wurk/api.rb +96 -0
  30. data/lib/wurk/batch/server_middleware.rb +2 -2
  31. data/lib/wurk/batch.rb +12 -3
  32. data/lib/wurk/capsule.rb +8 -1
  33. data/lib/wurk/cli.rb +110 -8
  34. data/lib/wurk/client/buffered.rb +1 -1
  35. data/lib/wurk/client.rb +42 -3
  36. data/lib/wurk/collapse.rb +383 -0
  37. data/lib/wurk/command_builder.rb +49 -0
  38. data/lib/wurk/compat.rb +19 -0
  39. data/lib/wurk/component.rb +16 -3
  40. data/lib/wurk/configuration.rb +303 -4
  41. data/lib/wurk/debounce.rb +125 -0
  42. data/lib/wurk/encryption.rb +6 -1
  43. data/lib/wurk/fetcher/capped.rb +218 -0
  44. data/lib/wurk/fetcher/reliable.rb +22 -66
  45. data/lib/wurk/fetcher/unit_of_work.rb +107 -0
  46. data/lib/wurk/flow/builder.rb +271 -0
  47. data/lib/wurk/flow/chain.rb +42 -0
  48. data/lib/wurk/flow/completion.rb +114 -0
  49. data/lib/wurk/flow/creation.rb +254 -0
  50. data/lib/wurk/flow/node.rb +124 -0
  51. data/lib/wurk/flow/status.rb +206 -0
  52. data/lib/wurk/flow.rb +255 -0
  53. data/lib/wurk/flow_set.rb +53 -0
  54. data/lib/wurk/health.rb +8 -7
  55. data/lib/wurk/heartbeat.rb +21 -3
  56. data/lib/wurk/job/options.rb +11 -1
  57. data/lib/wurk/job.rb +19 -0
  58. data/lib/wurk/job_logger.rb +6 -2
  59. data/lib/wurk/job_retry.rb +6 -1
  60. data/lib/wurk/job_util.rb +110 -9
  61. data/lib/wurk/keys.rb +125 -0
  62. data/lib/wurk/launcher.rb +1 -1
  63. data/lib/wurk/leader.rb +1 -1
  64. data/lib/wurk/limiter/bucket.rb +1 -1
  65. data/lib/wurk/limiter/points.rb +1 -1
  66. data/lib/wurk/lua/debounce.lua +75 -0
  67. data/lib/wurk/lua/fetch_slot.lua +83 -0
  68. data/lib/wurk/lua/flow_abandon.lua +65 -0
  69. data/lib/wurk/lua/flow_advance.lua +177 -0
  70. data/lib/wurk/lua/flow_create.lua +141 -0
  71. data/lib/wurk/lua/flow_fail.lua +52 -0
  72. data/lib/wurk/lua/limiter_bucket_acquire.lua +26 -0
  73. data/lib/wurk/lua/limiter_concurrent_acquire.lua +33 -0
  74. data/lib/wurk/lua/limiter_concurrent_release.lua +7 -0
  75. data/lib/wurk/lua/limiter_leaky_acquire.lua +31 -0
  76. data/lib/wurk/lua/limiter_list_sweep.lua +30 -0
  77. data/lib/wurk/lua/limiter_points_acquire.lua +34 -0
  78. data/lib/wurk/lua/limiter_points_refund.lua +18 -0
  79. data/lib/wurk/lua/limiter_register.lua +27 -0
  80. data/lib/wurk/lua/limiter_window_acquire.lua +35 -0
  81. data/lib/wurk/lua/limiter_window_status.lua +22 -0
  82. data/lib/wurk/lua/loader.rb +23 -0
  83. data/lib/wurk/lua/queue_slot.lua +83 -0
  84. data/lib/wurk/lua/refresh_slots.lua +38 -0
  85. data/lib/wurk/lua/status_write.lua +29 -0
  86. data/lib/wurk/lua/throttle_slot.lua +71 -0
  87. data/lib/wurk/lua.rb +3 -3
  88. data/lib/wurk/manager.rb +12 -0
  89. data/lib/wurk/metrics/history.rb +34 -2
  90. data/lib/wurk/metrics/query.rb +1 -1
  91. data/lib/wurk/metrics/statsd.rb +14 -0
  92. data/lib/wurk/middleware/expiry.rb +71 -10
  93. data/lib/wurk/middleware/status.rb +274 -0
  94. data/lib/wurk/middleware/timeout.rb +137 -0
  95. data/lib/wurk/pid_cache.rb +43 -0
  96. data/lib/wurk/processor.rb +66 -22
  97. data/lib/wurk/profiler.rb +5 -3
  98. data/lib/wurk/queue_slot.rb +285 -0
  99. data/lib/wurk/rails.rb +3 -3
  100. data/lib/wurk/redis_pool.rb +5 -2
  101. data/lib/wurk/status/progress.rb +103 -0
  102. data/lib/wurk/status/record.rb +81 -0
  103. data/lib/wurk/status.rb +155 -0
  104. data/lib/wurk/telemetry/client_middleware.rb +59 -0
  105. data/lib/wurk/telemetry/server_middleware.rb +152 -0
  106. data/lib/wurk/telemetry.rb +137 -0
  107. data/lib/wurk/throttle.rb +142 -0
  108. data/lib/wurk/unique.rb +10 -2
  109. data/lib/wurk/version.rb +1 -1
  110. data/lib/wurk/watchdog.rb +188 -0
  111. data/lib/wurk/web/config.rb +77 -10
  112. data/lib/wurk/web/extension.rb +12 -4
  113. data/lib/wurk/web/locale_negotiator.rb +67 -0
  114. data/lib/wurk/web.rb +1 -0
  115. data/lib/wurk/worker.rb +39 -4
  116. data/lib/wurk.rb +42 -1
  117. data/vendor/assets/dashboard/assets/ArgsValue-D-x_ifLY.js +1 -0
  118. data/vendor/assets/dashboard/assets/BatchDetail-C39NJuew.js +1 -0
  119. data/vendor/assets/dashboard/assets/Batches-CSwo7Asa.js +1 -0
  120. data/vendor/assets/dashboard/assets/Busy-BOFMu-sq.js +1 -0
  121. data/vendor/assets/dashboard/assets/Cron-Dy8RQzDI.js +1 -0
  122. data/vendor/assets/dashboard/assets/Dashboard-BuTHI-O1.js +1 -0
  123. data/vendor/assets/dashboard/assets/Dead-B9KRvQ0N.js +1 -0
  124. data/vendor/assets/dashboard/assets/Extension-BnBVHfux.js +1 -0
  125. data/vendor/assets/dashboard/assets/FilterBox-DC24zite.js +1 -0
  126. data/vendor/assets/dashboard/assets/FlowDetail-DyLuzUvt.js +1 -0
  127. data/vendor/assets/dashboard/assets/FlowState-DAPKUahm.js +1 -0
  128. data/vendor/assets/dashboard/assets/Flows-Fr3rjZM_.js +1 -0
  129. data/vendor/assets/dashboard/assets/JobDetailModal-N6kiJXq3.js +2 -0
  130. data/vendor/assets/dashboard/assets/Limiters-kbFA7uS1.js +1 -0
  131. data/vendor/assets/dashboard/assets/Metrics-Dj2uoZ3o.js +1 -0
  132. data/vendor/assets/dashboard/assets/PageHeader-B_F94azl.js +1 -0
  133. data/vendor/assets/dashboard/assets/Profiles-D_DjEezN.js +1 -0
  134. data/vendor/assets/dashboard/assets/Queues-CO4V9hAz.js +1 -0
  135. data/vendor/assets/dashboard/assets/Retries-DCWnzeLa.js +1 -0
  136. data/vendor/assets/dashboard/assets/Scheduled-BebDUjLU.js +1 -0
  137. data/vendor/assets/dashboard/assets/Search-Cvr5fy4Y.js +1 -0
  138. data/vendor/assets/dashboard/assets/Skeleton-Bu3Ke6rV.js +1 -0
  139. data/vendor/assets/dashboard/assets/charts-BCs9bQKz.js +1 -0
  140. data/vendor/assets/dashboard/assets/index-BIwyOC5Q.js +141 -0
  141. data/vendor/assets/dashboard/assets/index-DBQN6Jk8.css +1 -0
  142. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-Bzh-BJyL.js +1 -0
  143. data/vendor/assets/dashboard/assets/useSort-COA3fVJ5.js +1 -0
  144. data/vendor/assets/dashboard/assets/utils-BIrvZ1hi.js +1 -0
  145. data/vendor/assets/dashboard/index.html +42 -11
  146. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  147. metadata +102 -33
  148. data/vendor/assets/dashboard/assets/ArgsValue-CcR2ya6e.js +0 -1
  149. data/vendor/assets/dashboard/assets/BatchDetail-CUXJUQ3Q.js +0 -1
  150. data/vendor/assets/dashboard/assets/Batches-Cxan6Ngw.js +0 -1
  151. data/vendor/assets/dashboard/assets/Busy-DC5EGM0g.js +0 -1
  152. data/vendor/assets/dashboard/assets/Cron-Dlt8tXJA.js +0 -1
  153. data/vendor/assets/dashboard/assets/Dashboard-DNLu_WCg.js +0 -1
  154. data/vendor/assets/dashboard/assets/Dead-dZ7VGlKS.js +0 -1
  155. data/vendor/assets/dashboard/assets/Extension-DaFpEIJf.js +0 -1
  156. data/vendor/assets/dashboard/assets/FilterBox-CO3aYWIq.js +0 -1
  157. data/vendor/assets/dashboard/assets/JobDetailModal-DSWbT6G0.js +0 -2
  158. data/vendor/assets/dashboard/assets/Limiters-Cb4PKXNR.js +0 -1
  159. data/vendor/assets/dashboard/assets/Metrics-CCGzgCsT.js +0 -1
  160. data/vendor/assets/dashboard/assets/Modal-B86q6ruL.js +0 -1
  161. data/vendor/assets/dashboard/assets/PageHeader-fPrCcp_-.js +0 -1
  162. data/vendor/assets/dashboard/assets/Profiles-BnS82nR_.js +0 -1
  163. data/vendor/assets/dashboard/assets/Queues-CIyPevOy.js +0 -1
  164. data/vendor/assets/dashboard/assets/Retries-DopwXkXl.js +0 -1
  165. data/vendor/assets/dashboard/assets/Scheduled-1-Z7i1zE.js +0 -1
  166. data/vendor/assets/dashboard/assets/Search-ByA6eTma.js +0 -1
  167. data/vendor/assets/dashboard/assets/Skeleton-bC7HfQ9r.js +0 -1
  168. data/vendor/assets/dashboard/assets/charts-CLLzJ7vK.js +0 -1
  169. data/vendor/assets/dashboard/assets/index-B1N8hQUh.js +0 -141
  170. data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +0 -1
  171. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-DpBjkf6_.js +0 -1
  172. data/vendor/assets/dashboard/assets/useSort-DvpwuNQE.js +0 -1
  173. data/vendor/assets/dashboard/assets/utils-DDJC7tJV.js +0 -1
@@ -8,6 +8,7 @@ require_relative 'pool_checkout'
8
8
  require_relative 'context'
9
9
  require_relative 'topology'
10
10
  require_relative 'redis_options'
11
+ require_relative 'keys'
11
12
 
12
13
  module Wurk
13
14
  # Owns runtime knobs (concurrency, queues, timeouts, lifecycle events,
@@ -46,7 +47,58 @@ module Wurk
46
47
  backtrace_cleaner: ->(bt) { bt },
47
48
  logged_job_attributes: %w[bid tags],
48
49
  redis_idle_timeout: nil,
49
- redis_error_handlers: []
50
+ redis_error_handlers: [],
51
+ # Wurk extras, appended after the mirrored keys so the Sidekiq prefix
52
+ # above stays byte-for-byte what a gem reading @options expects.
53
+ #
54
+ # Lifetime of a `status:<jid>` row (Wurk::Status), re-stamped on every
55
+ # write, and the separate lifetime a `complete` row gets once the job
56
+ # succeeded. Retention is off by default — nil means a finished job's
57
+ # row expires on the same clock as a running one. Set it to seconds to
58
+ # keep succeeded jobs around longer than they ran, or to 0 for Sidekiq's
59
+ # own behavior, where a job that succeeds leaves nothing behind.
60
+ status_ttl: Keys::STATUS_TTL,
61
+ status_retention: nil,
62
+ # Distributed tracing (Wurk::Telemetry). Seeded here, not written on
63
+ # demand, so the read side answers on a config that `freeze!` has already
64
+ # closed — and so a gem inspecting @options sees the same key set whether
65
+ # or not the host traces. Flipping it is only half the gate; the
66
+ # opentelemetry-api gem has to be present too (Wurk::Telemetry.enabled?).
67
+ telemetry: false,
68
+ # Bearer tokens for the machine-facing HTTP API (Wurk::API), as
69
+ # `token => [scopes]`. Empty means the API does not exist: the engine
70
+ # skips the mount and a directly mounted app answers 404, so a host that
71
+ # never registers a token carries no new HTTP surface at all. Seeded here
72
+ # rather than written on demand so the read side answers on a config that
73
+ # `freeze!` has already closed.
74
+ api_tokens: {},
75
+ # Boundary caps for the HTTP API's produce plane. A body larger than
76
+ # `api_max_body_bytes` is refused before it is parsed; a job whose args
77
+ # serialize larger than `api_max_args_bytes` before it is pushed — the
78
+ # second is the one that bounds what a bulk request lands in Redis, since
79
+ # one request becomes many payloads. `api_idempotency_ttl` is how long an
80
+ # `Idempotency-Key` is remembered: a replay window for a producer
81
+ # retrying a lost response, not an audit log.
82
+ api_max_body_bytes: 1_048_576,
83
+ api_max_args_bytes: 65_536,
84
+ api_idempotency_ttl: 3_600,
85
+ # Allow-list of classes the HTTP API may enqueue. nil (the default) means
86
+ # only an `admin` token may enqueue at all — see #api_enqueue_classes=.
87
+ api_enqueue_classes: nil,
88
+ # Three-state on purpose — see #api_read_only=. nil is "inherit", which
89
+ # only mount mode 1 has anything to inherit from.
90
+ api_read_only: nil,
91
+ # Per-token request ceiling. nil is off: the API adds no Redis work to a
92
+ # request until a host asks for a limit, and no number picked here would
93
+ # be right for both a cron relay and a fan-out producer.
94
+ api_rate_limit: nil,
95
+ api_rate_limit_interval: :minute,
96
+ # Cluster-wide ceiling per queue (Wurk::QueueSlot), as
97
+ # `{ 'critical' => 20 }`. Empty is the whole point of seeding it here:
98
+ # the fetch path reads this once at boot, so an app that caps nothing
99
+ # resolves to "no capped queues" before the first fetch and never asks
100
+ # again.
101
+ global_concurrency: {}
50
102
  }.freeze
51
103
 
52
104
  # :fork fires only inside swarm children, after fork + internal AR/Redis
@@ -100,6 +152,12 @@ module Wurk
100
152
 
101
153
  def initialize(options = {})
102
154
  @options = deep_dup_defaults.merge(options)
155
+ # Through the same door `global_concurrency=` uses, so a cap passed to
156
+ # `Configuration.new` is validated rather than trusted, and the default
157
+ # Hash arrives frozen: the fetch path resolves caps once at boot, so a
158
+ # Hash still mutable here is one a caller can add a queue to and have
159
+ # nothing read it. FrozenError is the honest answer to that.
160
+ @options[:global_concurrency] = normalize_global_concurrency(@options[:global_concurrency])
103
161
  @options[:error_handlers] << ERROR_HANDLER if @options[:error_handlers].empty?
104
162
  @capsules = {}
105
163
  @directory = {}
@@ -109,6 +167,7 @@ module Wurk
109
167
  @web_redis_pool = nil
110
168
  @logger = nil
111
169
  @thread_priority = DEFAULT_THREAD_PRIORITY
170
+ @telemetry_installed = false
112
171
  @frozen = false
113
172
  end
114
173
 
@@ -382,6 +441,206 @@ module Wurk
382
441
  def history_interval = @options.fetch(:history_interval, HISTORY_DEFAULT_INTERVAL)
383
442
  def history_collector = @options[:history_collector]
384
443
 
444
+ # --- Distributed tracing (OpenTelemetry) ------------------------------
445
+
446
+ # @return [Boolean] whether the host asked for tracing. Half the gate;
447
+ # {Wurk::Telemetry.enabled?} is the whole one.
448
+ def telemetry? = @options[:telemetry] == true
449
+
450
+ # Opt into Wurk::Telemetry — a producer span carrying W3C trace context on
451
+ # the job hash at enqueue, a linked consumer span on execute:
452
+ #
453
+ # Wurk.configure_server { |config| config.telemetry = true }
454
+ #
455
+ # Loading `wurk/telemetry` is deferred to this call, which is the only
456
+ # reason `require "wurk"` can leave `opentelemetry-api` untouched in an app
457
+ # that doesn't trace.
458
+ #
459
+ # Warns when the host opted in but the gem is missing: tracing then stays
460
+ # off, and a silent no-op is indistinguishable from a broken exporter.
461
+ def telemetry=(enabled)
462
+ guard_frozen!
463
+ @options[:telemetry] = enabled ? true : false
464
+ # Turning tracing back off still has to unregister, but must not *load*
465
+ # the module to do it — that require is the whole cost this flag gates,
466
+ # and a config that never opted in has nothing registered to undo. Tracked
467
+ # per instance rather than off `defined?(Wurk::Telemetry)`: whether some
468
+ # *other* config loaded the module says nothing about this one's chain.
469
+ return if !@options[:telemetry] && !@telemetry_installed
470
+
471
+ require_relative 'telemetry'
472
+ @telemetry_installed = true
473
+ Wurk::Telemetry.install!(self)
474
+ return if !@options[:telemetry] || Wurk::Telemetry.available?
475
+
476
+ logger.warn { 'config.telemetry = true but opentelemetry-api is not installed; tracing stays off' }
477
+ end
478
+
479
+ # --- Machine-facing HTTP API (Wurk::API) ------------------------------
480
+
481
+ # Registers a bearer token for the HTTP API and the scopes it grants —
482
+ # any of `:enqueue`, `:read`, `:admin` (which grants the other two):
483
+ #
484
+ # # config/initializers/wurk.rb
485
+ # Wurk.configuration.api_token ENV.fetch('WURK_API_TOKEN'), scopes: %i[enqueue read]
486
+ #
487
+ # Registered unconditionally, not inside `configure_server`/`configure_client`
488
+ # — the process that serves the API has to be the one holding the credential,
489
+ # and neither block runs in every such process. A Puma-cluster web process
490
+ # never enters server mode (Wurk::RailsBoot refuses to fork a swarm from
491
+ # one), so a token registered in `configure_server` would be absent from the
492
+ # exact process serving the engine-nested mount.
493
+ #
494
+ # Registering the first token is what brings the API into existence; with
495
+ # none the app is never mounted. Re-registering a token replaces its
496
+ # scopes. Loading `wurk/api/auth` is deferred to this call so a host that
497
+ # serves no HTTP API never pays for openssl/digest in every swarm child.
498
+ def api_token(token, scopes:)
499
+ guard_frozen!
500
+ require_relative 'api/auth'
501
+ value, granted = Wurk::API::Auth.credential!(token, scopes)
502
+ @options[:api_tokens][value] = granted
503
+ nil
504
+ end
505
+
506
+ # @return [Hash{String => Array<Symbol>}] registered tokens and their scopes
507
+ def api_tokens = @options[:api_tokens]
508
+
509
+ # @return [Boolean] whether the HTTP API exists at all. The engine's mount
510
+ # is conditional on this.
511
+ def api_enabled? = !@options[:api_tokens].empty?
512
+
513
+ # @return [Integer] largest request body the produce plane will read.
514
+ def api_max_body_bytes = @options[:api_max_body_bytes]
515
+
516
+ def api_max_body_bytes=(bytes)
517
+ @options[:api_max_body_bytes] = api_limit!(:api_max_body_bytes, bytes)
518
+ end
519
+
520
+ # @return [Integer] largest `args` any single job may serialize to.
521
+ def api_max_args_bytes = @options[:api_max_args_bytes]
522
+
523
+ def api_max_args_bytes=(bytes)
524
+ @options[:api_max_args_bytes] = api_limit!(:api_max_args_bytes, bytes)
525
+ end
526
+
527
+ # @return [Integer] seconds an `Idempotency-Key` stays replayable.
528
+ def api_idempotency_ttl = @options[:api_idempotency_ttl]
529
+
530
+ def api_idempotency_ttl=(seconds)
531
+ @options[:api_idempotency_ttl] = api_limit!(:api_idempotency_ttl, seconds)
532
+ end
533
+
534
+ # @return [Array<String>, :any, nil] classes the HTTP API may enqueue.
535
+ def api_enqueue_classes = @options[:api_enqueue_classes]
536
+
537
+ # The allow-list `JobUtil::TRANSIENT_ATTRIBUTES` deliberately is not — that
538
+ # is a strip-list, right for a producer already running your code:
539
+ #
540
+ # config.api_enqueue_classes = %w[Billing::Charge ReportJob]
541
+ #
542
+ # Unset, only an `admin` token may enqueue at all. `class` is a code
543
+ # selector — a producer token that can name any constant in the host is
544
+ # choosing which of its jobs to run — so an `enqueue`-scoped credential has
545
+ # to be told exactly what it is for. Once a list exists it binds every
546
+ # token, `admin` included: a host that writes one means it.
547
+ #
548
+ # `:any` turns the check off, which is what an enqueue-scoped relay that
549
+ # legitimately pushes arbitrary classes needs — the alternative is handing
550
+ # it `admin` and the destructive routes that come with it.
551
+ def api_enqueue_classes=(classes)
552
+ guard_frozen!
553
+ require_relative 'api/validation'
554
+ @options[:api_enqueue_classes] = Wurk::API::Validation.enqueueable_classes!(classes)
555
+ end
556
+
557
+ # @return [Boolean, nil] true/false when the host said so, nil to inherit.
558
+ def api_read_only = @options[:api_read_only]
559
+
560
+ # Freezes the API's writes — enqueue included, not only the destructive
561
+ # `admin` routes. Same rule as the dashboard's `Wurk::Web.config.read_only`
562
+ # and deliberately the same sentence: a read-only deployment answers reads.
563
+ # Carving enqueue out would mean a viewer-only deploy could still be made
564
+ # to run arbitrary work, and would leave an operator holding two different
565
+ # definitions of the same word.
566
+ #
567
+ # Three states, because the two planes are not always one deployment:
568
+ #
569
+ # nil (default) inherit. Mounted inside the engine, the API is part of
570
+ # the dashboard's deployment and `WURK_WEB_READ_ONLY=1`
571
+ # freezes both. Mounted on its own path or run standalone
572
+ # there is nothing to inherit from and this means off.
573
+ # true frozen wherever it is mounted. `WURK_API_READ_ONLY=1`
574
+ # sets it, which is the only door mode 3 has.
575
+ # false live even inside a read-only dashboard — the host that
576
+ # wants a viewer-only UI and a working producer on the
577
+ # same mount, said out loud.
578
+ def api_read_only=(value)
579
+ guard_frozen!
580
+ @options[:api_read_only] = value.nil? ? nil : truthy_setting?(value)
581
+ end
582
+
583
+ # @return [Boolean] the resolved verdict for a mount with nothing to
584
+ # inherit; App layers mode 1's inherited flag over it.
585
+ def api_read_only?
586
+ value = @options[:api_read_only]
587
+ value.nil? ? ENV['WURK_API_READ_ONLY'] == '1' : value
588
+ end
589
+
590
+ # @return [Integer, nil] requests one token may make per interval.
591
+ def api_rate_limit = @options[:api_rate_limit]
592
+
593
+ # Requests per `api_rate_limit_interval`, per token, across the fleet —
594
+ # enforced by a `Wurk::Limiter` sliding window rather than a second
595
+ # limiter, so an API ceiling shows up on the Limiters page beside every
596
+ # other one and resets on the same Redis clock. nil turns it off.
597
+ def api_rate_limit=(count)
598
+ guard_frozen!
599
+ @options[:api_rate_limit] = count.nil? ? nil : api_limit!(:api_rate_limit, count)
600
+ end
601
+
602
+ # @return [Symbol, Integer] the window the limit is counted over.
603
+ def api_rate_limit_interval = @options[:api_rate_limit_interval]
604
+
605
+ # `:second :minute :hour :day` or a raw Integer of seconds — whatever
606
+ # `Limiter.window` accepts, validated here so a typo raises in the
607
+ # initializer that wrote it rather than on the first throttled request.
608
+ def api_rate_limit_interval=(interval)
609
+ guard_frozen!
610
+ Wurk::Limiter.interval_seconds(interval, allow_integer: true)
611
+ @options[:api_rate_limit_interval] = interval
612
+ end
613
+
614
+ # --- Global per-queue concurrency (Wurk::QueueSlot) -------------------
615
+
616
+ # @return [Hash{String => Integer}] queue name → cluster-wide ceiling.
617
+ # Always frozen — the default included, normalized in #initialize; see
618
+ # #global_concurrency=.
619
+ def global_concurrency = @options[:global_concurrency]
620
+
621
+ # Caps how many jobs from a queue may run at once across the whole
622
+ # cluster, whatever the worker count:
623
+ #
624
+ # Wurk.configure_server { |config| config.global_concurrency = { critical: 20 } }
625
+ #
626
+ # Not the same thing as `concurrency`, which is threads in one process, nor
627
+ # as a {Wurk::Limiter}, which is keyed per lock and gates inside the job
628
+ # (by which point the job is already claimed off the queue). This is a
629
+ # ceiling on the queue itself, enforced where the work is picked up.
630
+ #
631
+ # Names are normalized to Strings and the result is frozen, because the
632
+ # fetch path resolves it once at boot: a cap added by mutating this Hash
633
+ # afterwards would be read by nothing, so it raises rather than doing
634
+ # nothing. Assign a new Hash to change one.
635
+ def global_concurrency=(caps)
636
+ guard_frozen!
637
+ @options[:global_concurrency] = normalize_global_concurrency(caps)
638
+ end
639
+
640
+ # @return [Boolean] whether any queue is capped. The one question the
641
+ # fetcher asks at boot to decide whether it has a gate to run at all.
642
+ def global_concurrency? = !@options[:global_concurrency].empty?
643
+
385
644
  # --- Web dashboard ----------------------------------------------------
386
645
 
387
646
  # Web UI configuration: the authorization hook and read-only mode. Returns
@@ -409,7 +668,7 @@ module Wurk
409
668
  guard_frozen!
410
669
  p = Integer(port)
411
670
  rw = Integer(ready_window)
412
- raise ArgumentError, 'port must be between 0 and 65535' unless (0..65535).cover?(p)
671
+ raise ArgumentError, 'port must be between 0 and 65535' unless (0..65_535).cover?(p)
413
672
  raise ArgumentError, 'ready_window must be > 0' unless rw.positive?
414
673
 
415
674
  b = bind.to_s
@@ -557,7 +816,7 @@ module Wurk
557
816
  # native name, SIDEKIQ_COUNT the drop-in alias. Unparseable input falls back
558
817
  # to the CPU count; the result is floored at 1 so the swarm always forks.
559
818
  def default_child_count
560
- raw = ENV['WURK_COUNT'] || ENV['SIDEKIQ_COUNT']
819
+ raw = ENV['WURK_COUNT'] || ENV.fetch('SIDEKIQ_COUNT', nil)
561
820
  return Etc.nprocessors if raw.nil? || raw.strip.empty?
562
821
 
563
822
  value = Float(raw)
@@ -570,7 +829,7 @@ module Wurk
570
829
  # SIDEKIQ_MAXMEM_MB is the drop-in name; WURK_MAXMEM_MB the native alias.
571
830
  # Unparseable / empty input disables recycling rather than raising at boot.
572
831
  def env_memory_limit_mb
573
- raw = ENV['WURK_MAXMEM_MB'] || ENV['SIDEKIQ_MAXMEM_MB']
832
+ raw = ENV['WURK_MAXMEM_MB'] || ENV.fetch('SIDEKIQ_MAXMEM_MB', nil)
574
833
  return nil if raw.nil? || raw.strip.empty?
575
834
 
576
835
  Integer(raw)
@@ -582,6 +841,46 @@ module Wurk
582
841
  raise FrozenError, 'Wurk::Configuration is frozen' if @frozen
583
842
  end
584
843
 
844
+ # `config.api_read_only = ENV['SOMETHING']` is the shape a host reaches for,
845
+ # and `!!'false'` is true. Borrow the dashboard's list of strings that mean
846
+ # off rather than growing a second one that could disagree with it about the
847
+ # same env var.
848
+ def truthy_setting?(value)
849
+ return !!value unless value.is_a?(String)
850
+
851
+ require_relative 'web/config'
852
+ !Wurk::Web::Config::FALSEY_STRINGS.include?(value.strip.downcase)
853
+ end
854
+
855
+ # Boundary caps are refused where they are written, not where they are
856
+ # read: a cap that silently coerced to 0 is a cap that isn't one, and the
857
+ # request path is the wrong place to discover that.
858
+ def api_limit!(name, value)
859
+ guard_frozen!
860
+ size = Integer(value, exception: false)
861
+ raise ArgumentError, "#{name} must be a positive Integer" unless size&.positive?
862
+
863
+ size
864
+ end
865
+
866
+ # A cap is a promise about how much of a queue may run; a typo in one is a
867
+ # queue that silently runs unbounded (name) or never runs (count), and
868
+ # neither shows up until production is either overloaded or stalled. Both
869
+ # are refused in the initializer that wrote them.
870
+ def normalize_global_concurrency(caps)
871
+ raise ArgumentError, "global_concurrency must be a Hash, got #{caps.class}" unless caps.is_a?(Hash)
872
+
873
+ caps.each_with_object({}) do |(queue, cap), resolved|
874
+ name = queue.to_s
875
+ raise ArgumentError, 'global_concurrency queue names cannot be empty' if name.empty?
876
+
877
+ limit = Integer(cap, exception: false)
878
+ raise ArgumentError, "global_concurrency[#{name.inspect}] must be a positive Integer" unless limit&.positive?
879
+
880
+ resolved[name] = limit
881
+ end.freeze
882
+ end
883
+
585
884
  def deep_dup_defaults
586
885
  DEFAULTS.each_with_object({}) do |(k, v), h|
587
886
  h[k] = case v
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'job_util'
4
+ require_relative 'keys'
5
+ require_relative 'lua'
6
+ require_relative 'pool_checkout'
7
+ require_relative 'unique'
8
+
9
+ module Wurk
10
+ # Debounce — collapse a burst of enqueues into one job carrying the last
11
+ # payload, fired after a period of quiet. A Wurk extra, not a Sidekiq
12
+ # surface, and deliberately not a second flavour of {Wurk::Unique}: unique
13
+ # *rejects* the duplicate and keeps the first, debounce *replaces* the
14
+ # pending job and keeps the last.
15
+ #
16
+ # The classic use is a re-derivation triggered by something a user does
17
+ # repeatedly — reindex a record, warm a cache, recompute a projection. Fifty
18
+ # edits in a minute should produce one rebuild at the end, not fifty.
19
+ #
20
+ # Redis schema — one new key, plus an ordinary entry in the ZSET that already
21
+ # exists:
22
+ #
23
+ # debounce:<digest> HASH member (the pending job JSON), first (epoch)
24
+ # schedule ZSET the collapsed job, exactly as `perform_in`
25
+ # would have written it
26
+ #
27
+ # Nothing reading `schedule` can tell a debounced job from any other
28
+ # scheduled one — {Wurk::ScheduledSet}, the dashboard and a stock Sidekiq
29
+ # poller all handle it unchanged. There is no parallel delayed structure.
30
+ #
31
+ # Identity is {Wurk::Unique}'s digest of `[class, queue, args]`, so a class
32
+ # that already narrows its key with `sidekiq_unique_context` narrows its
33
+ # debounce key the same way — that hook is the supported "collapse on a
34
+ # subset of the args" escape hatch, and there is no second one.
35
+ module Debounce
36
+ # How long `debounce:<digest>` outlives the entry it points at.
37
+ #
38
+ # The key's only job is to remember which member to pull back out of
39
+ # `schedule` when the burst is extended, so it must not expire while that
40
+ # member is still pending — a burst that lost its key ZADDs a second entry
41
+ # next to the first and stops collapsing. The window that matters is
42
+ # therefore "fire time → poller promotes it", which the scheduler keeps at
43
+ # roughly `average_scheduled_poll_interval` across the cluster however many
44
+ # processes are running. Five minutes is far past that; overshooting costs
45
+ # one ~100-byte key per idle debounce identity, and the script treats an
46
+ # outlived key as a finished burst anyway.
47
+ GRACE = 300
48
+
49
+ # What one debounced enqueue did to the pending entry.
50
+ #
51
+ # `replaced` is the only part a caller cannot work out for itself: from
52
+ # outside the script, extending a live burst and opening a new one look
53
+ # identical. `at` is the epoch second the job will run — capped, so it is
54
+ # not necessarily `now + wait`. `opened_at` is the burst's own start, the
55
+ # clock `max_wait` is measured against.
56
+ Outcome = Struct.new(:replaced, :at, :opened_at) do
57
+ # True when this push displaced a still-pending sibling.
58
+ def replaced? = replaced
59
+ end
60
+
61
+ class << self
62
+ # @param job [Hash] a normalized job payload
63
+ # @return [String] `debounce:<digest>` for that job's identity
64
+ def key_for(job) = Keys.debounce(Unique.digest_for(job))
65
+
66
+ # Collapse `job` into the pending entry for its key, or open a new burst.
67
+ #
68
+ # @param job [Hash] normalized payload; stored minus `at`/`enqueued_at`,
69
+ # the same bytes {Wurk::Client#push_scheduled} would have written
70
+ # @param wait [Numeric] seconds of quiet before the job fires; every
71
+ # further enqueue of the same key pushes this out again
72
+ # @param max_wait [Numeric, nil] hard cap measured from the *first*
73
+ # enqueue of the burst. nil leaves the job able to starve — a key
74
+ # re-enqueued faster than `wait` never fires — so callers that cannot
75
+ # rule that out should always set it.
76
+ # @param pool [#with, nil] defaults to this process's pool
77
+ # @return [Outcome]
78
+ def schedule(job, wait:, max_wait: nil, pool: nil)
79
+ seconds = positive_seconds!(wait, 'wait')
80
+ cap = max_wait.nil? ? 0.0 : positive_seconds!(max_wait, 'max_wait')
81
+ reject_shorter_cap!(seconds, cap)
82
+
83
+ argv = [seconds, cap, JobUtil.scheduled_member(job), GRACE]
84
+ # Replay-safe: a re-run finds the member it just wrote, ZREMs it and
85
+ # ZADDs it back, so a pool retry after a lost reply converges on one
86
+ # entry rather than a duplicate — the whole point of doing this in a
87
+ # script rather than a pipeline.
88
+ #
89
+ # The replay is not byte-identical: it recomputes `now`, so the fire
90
+ # time moves out by the retry's own latency (and `first` is carried
91
+ # forward, so `max_wait` still caps where it lands). That is the claim
92
+ # worth making anyway. A lost reply means the write probably applied,
93
+ # and refusing the replay only converts a bounded shift into an
94
+ # exception out of `perform_async` for a job that is already scheduled
95
+ # — whose caller then re-enqueues and shifts the fire time regardless.
96
+ raw = with_pool(pool, idempotent: true) do |conn|
97
+ Lua::Loader.eval_cached(conn, :debounce, keys: [key_for(job), Keys::SCHEDULE], argv: argv)
98
+ end
99
+ Outcome.new(raw[0].to_i == 1, raw[1].to_f, raw[2].to_f)
100
+ end
101
+
102
+ private
103
+
104
+ def positive_seconds!(value, name)
105
+ raise ArgumentError, "debounce #{name} must be a positive number of seconds: #{value.inspect}" \
106
+ unless JobUtil.positive_seconds?(value)
107
+
108
+ value.to_f
109
+ end
110
+
111
+ # A cap below the quiet period fires every burst at `first + max_wait`
112
+ # regardless of traffic, which is a throttle wearing a debounce's name.
113
+ # Nobody writes that on purpose, so it fails where it was declared.
114
+ def reject_shorter_cap!(wait, cap)
115
+ return if cap.zero? || cap >= wait
116
+
117
+ raise ArgumentError, "debounce max_wait (#{cap}) must be at least wait (#{wait})"
118
+ end
119
+
120
+ def with_pool(pool, idempotent: false, &)
121
+ pool ? PoolCheckout.with(pool, idempotent, &) : Wurk.redis(idempotent:, &)
122
+ end
123
+ end
124
+ end
125
+ end
@@ -45,10 +45,15 @@ module Wurk
45
45
  # remain plaintext.
46
46
  # * Incompatible with Wurk::Unique (each ciphertext differs → digest
47
47
  # defeats the lock). Documented invariant.
48
+ # * A worker that also sets `track: true` gets its full Wurk::Status
49
+ # lifecycle but no stored result — `perform`'s return value is dropped and
50
+ # the row is flagged `result_withheld`, rather than landing in a plaintext
51
+ # HASH beside the arguments this envelope exists to protect. See
52
+ # Wurk::Middleware::Status#withhold_result?.
48
53
  # * Web UI redacts the last arg when `encrypt: true` is set on the job.
49
54
  #
50
55
  # Spec: docs/target/sidekiq-ent.md §4.
51
- module Encryption # rubocop:disable Metrics/ModuleLength
56
+ module Encryption
52
57
  CIPHER_NAME = 'aes-256-gcm'
53
58
  KEY_BYTES = 32
54
59
  IV_BYTES = 12 # GCM standard: 96-bit IV.