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.
- checksums.yaml +4 -4
- data/README.md +40 -2
- data/app/controllers/wurk/api/serializers.rb +51 -1
- data/app/controllers/wurk/api_controller.rb +42 -6
- data/app/controllers/wurk/dashboard_controller.rb +67 -10
- data/config/routes.rb +30 -3
- data/exe/wurk +6 -2
- data/lib/generators/wurk/install/install_generator.rb +3 -3
- data/lib/sidekiq/job_retry.rb +4 -0
- data/lib/sidekiq/manager.rb +4 -0
- data/lib/sidekiq/processor.rb +4 -0
- data/lib/wurk/api/app.rb +186 -0
- data/lib/wurk/api/auth.rb +167 -0
- data/lib/wurk/api/flows.rb +66 -0
- data/lib/wurk/api/idempotency.rb +168 -0
- data/lib/wurk/api/jobs.rb +180 -0
- data/lib/wurk/api/page.rb +98 -0
- data/lib/wurk/api/problem.rb +121 -0
- data/lib/wurk/api/queues.rb +126 -0
- data/lib/wurk/api/read_only.rb +68 -0
- data/lib/wurk/api/request.rb +46 -0
- data/lib/wurk/api/response.rb +28 -0
- data/lib/wurk/api/roll_up.rb +126 -0
- data/lib/wurk/api/router.rb +88 -0
- data/lib/wurk/api/serializers.rb +232 -0
- data/lib/wurk/api/swarm.rb +297 -0
- data/lib/wurk/api/throttle.rb +109 -0
- data/lib/wurk/api/validation.rb +306 -0
- data/lib/wurk/api.rb +96 -0
- data/lib/wurk/batch/server_middleware.rb +2 -2
- data/lib/wurk/batch.rb +12 -3
- data/lib/wurk/capsule.rb +8 -1
- data/lib/wurk/cli.rb +110 -8
- data/lib/wurk/client/buffered.rb +1 -1
- data/lib/wurk/client.rb +42 -3
- data/lib/wurk/collapse.rb +383 -0
- data/lib/wurk/command_builder.rb +49 -0
- data/lib/wurk/compat.rb +19 -0
- data/lib/wurk/component.rb +16 -3
- data/lib/wurk/configuration.rb +303 -4
- data/lib/wurk/debounce.rb +125 -0
- data/lib/wurk/encryption.rb +6 -1
- data/lib/wurk/fetcher/capped.rb +218 -0
- data/lib/wurk/fetcher/reliable.rb +22 -66
- data/lib/wurk/fetcher/unit_of_work.rb +107 -0
- data/lib/wurk/flow/builder.rb +271 -0
- data/lib/wurk/flow/chain.rb +42 -0
- data/lib/wurk/flow/completion.rb +114 -0
- data/lib/wurk/flow/creation.rb +254 -0
- data/lib/wurk/flow/node.rb +124 -0
- data/lib/wurk/flow/status.rb +206 -0
- data/lib/wurk/flow.rb +255 -0
- data/lib/wurk/flow_set.rb +53 -0
- data/lib/wurk/health.rb +8 -7
- data/lib/wurk/heartbeat.rb +21 -3
- data/lib/wurk/job/options.rb +11 -1
- data/lib/wurk/job.rb +19 -0
- data/lib/wurk/job_logger.rb +6 -2
- data/lib/wurk/job_retry.rb +6 -1
- data/lib/wurk/job_util.rb +110 -9
- data/lib/wurk/keys.rb +125 -0
- data/lib/wurk/launcher.rb +1 -1
- data/lib/wurk/leader.rb +1 -1
- data/lib/wurk/limiter/bucket.rb +1 -1
- data/lib/wurk/limiter/points.rb +1 -1
- data/lib/wurk/lua/debounce.lua +75 -0
- data/lib/wurk/lua/fetch_slot.lua +83 -0
- data/lib/wurk/lua/flow_abandon.lua +65 -0
- data/lib/wurk/lua/flow_advance.lua +177 -0
- data/lib/wurk/lua/flow_create.lua +141 -0
- data/lib/wurk/lua/flow_fail.lua +52 -0
- data/lib/wurk/lua/limiter_bucket_acquire.lua +26 -0
- data/lib/wurk/lua/limiter_concurrent_acquire.lua +33 -0
- data/lib/wurk/lua/limiter_concurrent_release.lua +7 -0
- data/lib/wurk/lua/limiter_leaky_acquire.lua +31 -0
- data/lib/wurk/lua/limiter_list_sweep.lua +30 -0
- data/lib/wurk/lua/limiter_points_acquire.lua +34 -0
- data/lib/wurk/lua/limiter_points_refund.lua +18 -0
- data/lib/wurk/lua/limiter_register.lua +27 -0
- data/lib/wurk/lua/limiter_window_acquire.lua +35 -0
- data/lib/wurk/lua/limiter_window_status.lua +22 -0
- data/lib/wurk/lua/loader.rb +23 -0
- data/lib/wurk/lua/queue_slot.lua +83 -0
- data/lib/wurk/lua/refresh_slots.lua +38 -0
- data/lib/wurk/lua/status_write.lua +29 -0
- data/lib/wurk/lua/throttle_slot.lua +71 -0
- data/lib/wurk/lua.rb +3 -3
- data/lib/wurk/manager.rb +12 -0
- data/lib/wurk/metrics/history.rb +34 -2
- data/lib/wurk/metrics/query.rb +1 -1
- data/lib/wurk/metrics/statsd.rb +14 -0
- data/lib/wurk/middleware/expiry.rb +71 -10
- data/lib/wurk/middleware/status.rb +274 -0
- data/lib/wurk/middleware/timeout.rb +137 -0
- data/lib/wurk/pid_cache.rb +43 -0
- data/lib/wurk/processor.rb +66 -22
- data/lib/wurk/profiler.rb +5 -3
- data/lib/wurk/queue_slot.rb +285 -0
- data/lib/wurk/rails.rb +3 -3
- data/lib/wurk/redis_pool.rb +5 -2
- data/lib/wurk/status/progress.rb +103 -0
- data/lib/wurk/status/record.rb +81 -0
- data/lib/wurk/status.rb +155 -0
- data/lib/wurk/telemetry/client_middleware.rb +59 -0
- data/lib/wurk/telemetry/server_middleware.rb +152 -0
- data/lib/wurk/telemetry.rb +137 -0
- data/lib/wurk/throttle.rb +142 -0
- data/lib/wurk/unique.rb +10 -2
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/watchdog.rb +188 -0
- data/lib/wurk/web/config.rb +77 -10
- data/lib/wurk/web/extension.rb +12 -4
- data/lib/wurk/web/locale_negotiator.rb +67 -0
- data/lib/wurk/web.rb +1 -0
- data/lib/wurk/worker.rb +39 -4
- data/lib/wurk.rb +42 -1
- data/vendor/assets/dashboard/assets/ArgsValue-D-x_ifLY.js +1 -0
- data/vendor/assets/dashboard/assets/BatchDetail-C39NJuew.js +1 -0
- data/vendor/assets/dashboard/assets/Batches-CSwo7Asa.js +1 -0
- data/vendor/assets/dashboard/assets/Busy-BOFMu-sq.js +1 -0
- data/vendor/assets/dashboard/assets/Cron-Dy8RQzDI.js +1 -0
- data/vendor/assets/dashboard/assets/Dashboard-BuTHI-O1.js +1 -0
- data/vendor/assets/dashboard/assets/Dead-B9KRvQ0N.js +1 -0
- data/vendor/assets/dashboard/assets/Extension-BnBVHfux.js +1 -0
- data/vendor/assets/dashboard/assets/FilterBox-DC24zite.js +1 -0
- data/vendor/assets/dashboard/assets/FlowDetail-DyLuzUvt.js +1 -0
- data/vendor/assets/dashboard/assets/FlowState-DAPKUahm.js +1 -0
- data/vendor/assets/dashboard/assets/Flows-Fr3rjZM_.js +1 -0
- data/vendor/assets/dashboard/assets/JobDetailModal-N6kiJXq3.js +2 -0
- data/vendor/assets/dashboard/assets/Limiters-kbFA7uS1.js +1 -0
- data/vendor/assets/dashboard/assets/Metrics-Dj2uoZ3o.js +1 -0
- data/vendor/assets/dashboard/assets/PageHeader-B_F94azl.js +1 -0
- data/vendor/assets/dashboard/assets/Profiles-D_DjEezN.js +1 -0
- data/vendor/assets/dashboard/assets/Queues-CO4V9hAz.js +1 -0
- data/vendor/assets/dashboard/assets/Retries-DCWnzeLa.js +1 -0
- data/vendor/assets/dashboard/assets/Scheduled-BebDUjLU.js +1 -0
- data/vendor/assets/dashboard/assets/Search-Cvr5fy4Y.js +1 -0
- data/vendor/assets/dashboard/assets/Skeleton-Bu3Ke6rV.js +1 -0
- data/vendor/assets/dashboard/assets/charts-BCs9bQKz.js +1 -0
- data/vendor/assets/dashboard/assets/index-BIwyOC5Q.js +141 -0
- data/vendor/assets/dashboard/assets/index-DBQN6Jk8.css +1 -0
- data/vendor/assets/dashboard/assets/useResetPageOnEmpty-Bzh-BJyL.js +1 -0
- data/vendor/assets/dashboard/assets/useSort-COA3fVJ5.js +1 -0
- data/vendor/assets/dashboard/assets/utils-BIrvZ1hi.js +1 -0
- data/vendor/assets/dashboard/index.html +42 -11
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +102 -33
- data/vendor/assets/dashboard/assets/ArgsValue-CcR2ya6e.js +0 -1
- data/vendor/assets/dashboard/assets/BatchDetail-CUXJUQ3Q.js +0 -1
- data/vendor/assets/dashboard/assets/Batches-Cxan6Ngw.js +0 -1
- data/vendor/assets/dashboard/assets/Busy-DC5EGM0g.js +0 -1
- data/vendor/assets/dashboard/assets/Cron-Dlt8tXJA.js +0 -1
- data/vendor/assets/dashboard/assets/Dashboard-DNLu_WCg.js +0 -1
- data/vendor/assets/dashboard/assets/Dead-dZ7VGlKS.js +0 -1
- data/vendor/assets/dashboard/assets/Extension-DaFpEIJf.js +0 -1
- data/vendor/assets/dashboard/assets/FilterBox-CO3aYWIq.js +0 -1
- data/vendor/assets/dashboard/assets/JobDetailModal-DSWbT6G0.js +0 -2
- data/vendor/assets/dashboard/assets/Limiters-Cb4PKXNR.js +0 -1
- data/vendor/assets/dashboard/assets/Metrics-CCGzgCsT.js +0 -1
- data/vendor/assets/dashboard/assets/Modal-B86q6ruL.js +0 -1
- data/vendor/assets/dashboard/assets/PageHeader-fPrCcp_-.js +0 -1
- data/vendor/assets/dashboard/assets/Profiles-BnS82nR_.js +0 -1
- data/vendor/assets/dashboard/assets/Queues-CIyPevOy.js +0 -1
- data/vendor/assets/dashboard/assets/Retries-DopwXkXl.js +0 -1
- data/vendor/assets/dashboard/assets/Scheduled-1-Z7i1zE.js +0 -1
- data/vendor/assets/dashboard/assets/Search-ByA6eTma.js +0 -1
- data/vendor/assets/dashboard/assets/Skeleton-bC7HfQ9r.js +0 -1
- data/vendor/assets/dashboard/assets/charts-CLLzJ7vK.js +0 -1
- data/vendor/assets/dashboard/assets/index-B1N8hQUh.js +0 -141
- data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +0 -1
- data/vendor/assets/dashboard/assets/useResetPageOnEmpty-DpBjkf6_.js +0 -1
- data/vendor/assets/dashboard/assets/useSort-DvpwuNQE.js +0 -1
- data/vendor/assets/dashboard/assets/utils-DDJC7tJV.js +0 -1
data/lib/wurk/configuration.rb
CHANGED
|
@@ -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..
|
|
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
|
|
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
|
|
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
|
data/lib/wurk/encryption.rb
CHANGED
|
@@ -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
|
|
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.
|