wurk 0.0.1
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 +7 -0
- data/CHANGELOG.md +43 -0
- data/CONTRIBUTING.md +73 -0
- data/LICENSE +21 -0
- data/README.md +137 -0
- data/SECURITY.md +39 -0
- data/app/controllers/wurk/api/pagination.rb +67 -0
- data/app/controllers/wurk/api/serializers.rb +131 -0
- data/app/controllers/wurk/api_controller.rb +248 -0
- data/app/controllers/wurk/application_controller.rb +7 -0
- data/app/controllers/wurk/dashboard_controller.rb +48 -0
- data/config/locales/en.yml +15 -0
- data/config/routes.rb +34 -0
- data/exe/wurk +22 -0
- data/lib/active_job/queue_adapters/wurk_adapter.rb +96 -0
- data/lib/generators/wurk/install/install_generator.rb +22 -0
- data/lib/generators/wurk/install/templates/wurk.rb +16 -0
- data/lib/wurk/active_job/wrapper.rb +32 -0
- data/lib/wurk/api/fast.rb +78 -0
- data/lib/wurk/batch/buffer.rb +26 -0
- data/lib/wurk/batch/callback_job.rb +37 -0
- data/lib/wurk/batch/callbacks.rb +176 -0
- data/lib/wurk/batch/client_middleware.rb +27 -0
- data/lib/wurk/batch/death_handler.rb +39 -0
- data/lib/wurk/batch/empty.rb +21 -0
- data/lib/wurk/batch/server_middleware.rb +62 -0
- data/lib/wurk/batch/status.rb +140 -0
- data/lib/wurk/batch.rb +351 -0
- data/lib/wurk/batch_set.rb +67 -0
- data/lib/wurk/capsule.rb +176 -0
- data/lib/wurk/cli.rb +349 -0
- data/lib/wurk/client/buffered.rb +372 -0
- data/lib/wurk/client.rb +330 -0
- data/lib/wurk/compat.rb +136 -0
- data/lib/wurk/component.rb +136 -0
- data/lib/wurk/configuration.rb +373 -0
- data/lib/wurk/context.rb +35 -0
- data/lib/wurk/cron.rb +636 -0
- data/lib/wurk/dashboard_manifest.rb +39 -0
- data/lib/wurk/dead_set.rb +78 -0
- data/lib/wurk/deploy.rb +91 -0
- data/lib/wurk/embedded.rb +94 -0
- data/lib/wurk/encryption.rb +276 -0
- data/lib/wurk/engine.rb +81 -0
- data/lib/wurk/fetcher/reaper.rb +264 -0
- data/lib/wurk/fetcher/reliable.rb +138 -0
- data/lib/wurk/fetcher.rb +11 -0
- data/lib/wurk/health.rb +193 -0
- data/lib/wurk/heartbeat.rb +211 -0
- data/lib/wurk/iterable_job.rb +292 -0
- data/lib/wurk/job/options.rb +70 -0
- data/lib/wurk/job.rb +33 -0
- data/lib/wurk/job_logger.rb +68 -0
- data/lib/wurk/job_record.rb +156 -0
- data/lib/wurk/job_retry.rb +320 -0
- data/lib/wurk/job_set.rb +212 -0
- data/lib/wurk/job_util.rb +162 -0
- data/lib/wurk/keys.rb +52 -0
- data/lib/wurk/launcher.rb +289 -0
- data/lib/wurk/leader.rb +221 -0
- data/lib/wurk/limiter/base.rb +138 -0
- data/lib/wurk/limiter/bucket.rb +80 -0
- data/lib/wurk/limiter/concurrent.rb +132 -0
- data/lib/wurk/limiter/leaky.rb +91 -0
- data/lib/wurk/limiter/points.rb +89 -0
- data/lib/wurk/limiter/server_middleware.rb +77 -0
- data/lib/wurk/limiter/unlimited.rb +48 -0
- data/lib/wurk/limiter/window.rb +80 -0
- data/lib/wurk/limiter.rb +255 -0
- data/lib/wurk/logger.rb +81 -0
- data/lib/wurk/lua/loader.rb +53 -0
- data/lib/wurk/lua.rb +187 -0
- data/lib/wurk/manager.rb +132 -0
- data/lib/wurk/metrics/history.rb +151 -0
- data/lib/wurk/metrics/query.rb +173 -0
- data/lib/wurk/metrics/rollup.rb +169 -0
- data/lib/wurk/metrics/statsd.rb +197 -0
- data/lib/wurk/metrics.rb +7 -0
- data/lib/wurk/middleware/chain.rb +128 -0
- data/lib/wurk/middleware/current_attributes.rb +87 -0
- data/lib/wurk/middleware/expiry.rb +50 -0
- data/lib/wurk/middleware/i18n.rb +63 -0
- data/lib/wurk/middleware/interrupt_handler.rb +45 -0
- data/lib/wurk/middleware/poison_pill.rb +149 -0
- data/lib/wurk/middleware.rb +34 -0
- data/lib/wurk/process_set.rb +243 -0
- data/lib/wurk/processor.rb +247 -0
- data/lib/wurk/queue.rb +108 -0
- data/lib/wurk/queues.rb +80 -0
- data/lib/wurk/rails.rb +9 -0
- data/lib/wurk/railtie.rb +28 -0
- data/lib/wurk/redis_pool.rb +79 -0
- data/lib/wurk/retry_set.rb +17 -0
- data/lib/wurk/scheduled.rb +189 -0
- data/lib/wurk/scheduled_set.rb +18 -0
- data/lib/wurk/sorted_entry.rb +95 -0
- data/lib/wurk/stats.rb +190 -0
- data/lib/wurk/swarm/child_boot.rb +105 -0
- data/lib/wurk/swarm.rb +260 -0
- data/lib/wurk/testing.rb +102 -0
- data/lib/wurk/topology.rb +74 -0
- data/lib/wurk/unique.rb +240 -0
- data/lib/wurk/version.rb +5 -0
- data/lib/wurk/web/config.rb +180 -0
- data/lib/wurk/web/enterprise.rb +138 -0
- data/lib/wurk/web/search.rb +139 -0
- data/lib/wurk/web.rb +25 -0
- data/lib/wurk/work_set.rb +116 -0
- data/lib/wurk/worker/setter.rb +93 -0
- data/lib/wurk/worker.rb +216 -0
- data/lib/wurk.rb +238 -0
- data/vendor/assets/dashboard/assets/index-8P3N_m1X.js +152 -0
- data/vendor/assets/dashboard/assets/index-Bqz4_SOQ.css +1 -0
- data/vendor/assets/dashboard/index.html +13 -0
- data/vendor/assets/dashboard/wurk-manifest.json +4 -0
- metadata +232 -0
data/lib/wurk/compat.rb
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Sidekiq aliases. This is the drop-in contract — every public Wurk::*
|
|
4
|
+
# class is exposed under its Sidekiq::* name. Never break.
|
|
5
|
+
#
|
|
6
|
+
# Spec: docs/target/sidekiq-{free,pro,ent}.md.
|
|
7
|
+
|
|
8
|
+
module Sidekiq
|
|
9
|
+
# Version stamps mirror Sidekiq's OSS release Wurk targets for compat.
|
|
10
|
+
# Third-party gems version-gate on these; raise the MAJOR only when the
|
|
11
|
+
# upstream Sidekiq major bumps and Wurk has matching surface.
|
|
12
|
+
NAME = 'Sidekiq'
|
|
13
|
+
LICENSE = 'See LICENSE'
|
|
14
|
+
VERSION = '8.1.5'
|
|
15
|
+
MAJOR = 8
|
|
16
|
+
|
|
17
|
+
# Namespace sentinels for Pro/Ent feature subclasses (Sidekiq::Pro::Web,
|
|
18
|
+
# Sidekiq::Enterprise::Crypto, …). Defined so downstream code can nest
|
|
19
|
+
# classes under them — but `Sidekiq.pro?` / `Sidekiq.ent?` still return
|
|
20
|
+
# `false` per docs/target/sidekiq-free.md §32 (Wurk advertises as free OSS).
|
|
21
|
+
module Pro; end
|
|
22
|
+
|
|
23
|
+
# Sidekiq Enterprise feature surface (`unique!`, `Crypto`, `Unique.locked?`).
|
|
24
|
+
# Wurk ships these free; the namespace exists for drop-in compat.
|
|
25
|
+
# Implementations live under `Wurk::*`; this module just delegates.
|
|
26
|
+
module Enterprise
|
|
27
|
+
class << self
|
|
28
|
+
# Installs the Wurk::Unique client+server middleware pair globally.
|
|
29
|
+
# Spec: docs/target/sidekiq-ent.md §3.1.
|
|
30
|
+
def unique!
|
|
31
|
+
Wurk::Unique.enable!
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def unique?
|
|
35
|
+
Wurk::Unique.enabled?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Wurk::Unique introspection: `Sidekiq::Enterprise::Unique.locked?(...)`.
|
|
40
|
+
# Spec §3.6.
|
|
41
|
+
module Unique
|
|
42
|
+
def self.locked?(*)
|
|
43
|
+
Wurk::Unique.locked?(*)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# AES-256-GCM args encryption. `Sidekiq::Enterprise::Crypto.enable(...)`
|
|
48
|
+
# delegates to `Wurk::Encryption.enable`. Spec: docs/target/sidekiq-ent.md §4.
|
|
49
|
+
module Crypto
|
|
50
|
+
class << self
|
|
51
|
+
def enable(active_version:, &)
|
|
52
|
+
Wurk::Encryption.enable(active_version: active_version, &)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def enabled?
|
|
56
|
+
Wurk::Encryption.enabled?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
BasicFetch = Wurk::Fetcher::Reliable
|
|
63
|
+
Batch = Wurk::Batch
|
|
64
|
+
BatchSet = Wurk::BatchSet
|
|
65
|
+
Capsule = Wurk::Capsule
|
|
66
|
+
CLI = Wurk::CLI
|
|
67
|
+
Client = Wurk::Client
|
|
68
|
+
Component = Wurk::Component
|
|
69
|
+
Config = Wurk::Configuration
|
|
70
|
+
Context = Wurk::Context
|
|
71
|
+
Cron = Wurk::Cron
|
|
72
|
+
Periodic = Wurk::Cron
|
|
73
|
+
DeadSet = Wurk::DeadSet
|
|
74
|
+
Deploy = Wurk::Deploy
|
|
75
|
+
Embedded = Wurk::Embedded
|
|
76
|
+
Encryption = Wurk::Encryption
|
|
77
|
+
IterableJob = Wurk::IterableJob
|
|
78
|
+
Job = Wurk::Job
|
|
79
|
+
JobLogger = Wurk::JobLogger
|
|
80
|
+
JobRecord = Wurk::JobRecord
|
|
81
|
+
JobRetry = Wurk::JobRetry
|
|
82
|
+
JobUtil = Wurk::JobUtil
|
|
83
|
+
Keys = Wurk::Keys
|
|
84
|
+
Launcher = Wurk::Launcher
|
|
85
|
+
Limiter = Wurk::Limiter
|
|
86
|
+
Logger = Wurk::Logger
|
|
87
|
+
Manager = Wurk::Manager
|
|
88
|
+
Metrics = Wurk::Metrics
|
|
89
|
+
Middleware = Wurk::Middleware
|
|
90
|
+
ServerMiddleware = Wurk::Middleware::ServerMiddleware
|
|
91
|
+
ClientMiddleware = Wurk::Middleware::ClientMiddleware
|
|
92
|
+
Process = Wurk::Process
|
|
93
|
+
ProcessSet = Wurk::ProcessSet
|
|
94
|
+
Processor = Wurk::Processor
|
|
95
|
+
Queue = Wurk::Queue
|
|
96
|
+
RetrySet = Wurk::RetrySet
|
|
97
|
+
Scheduled = Wurk::Scheduled
|
|
98
|
+
ScheduledSet = Wurk::ScheduledSet
|
|
99
|
+
Shutdown = Wurk::Shutdown
|
|
100
|
+
Stats = Wurk::Stats
|
|
101
|
+
Testing = Wurk::Testing
|
|
102
|
+
Queues = Wurk::Queues
|
|
103
|
+
EmptyQueueError = Wurk::Testing::EmptyQueueError
|
|
104
|
+
Web = Wurk::Web
|
|
105
|
+
Work = Wurk::Work
|
|
106
|
+
Worker = Wurk::Worker
|
|
107
|
+
Workers = Wurk::Workers
|
|
108
|
+
WorkSet = Wurk::WorkSet
|
|
109
|
+
|
|
110
|
+
# Top-level Sidekiq.configure_server / configure_client / redis / logger
|
|
111
|
+
# delegate to Wurk's class methods. Third-party gems treat these as the
|
|
112
|
+
# canonical entry points.
|
|
113
|
+
class << self
|
|
114
|
+
def configure_server(&) = Wurk.configure_server(&)
|
|
115
|
+
def configure_client(&) = Wurk.configure_client(&)
|
|
116
|
+
def configure_embed(&) = Wurk.configure_embed(&)
|
|
117
|
+
def default_configuration = Wurk.default_configuration
|
|
118
|
+
def redis(&) = Wurk.redis(&)
|
|
119
|
+
def redis_pool = Wurk.redis_pool
|
|
120
|
+
def logger = Wurk.logger
|
|
121
|
+
def server? = Wurk.server?
|
|
122
|
+
def pro? = Wurk.pro?
|
|
123
|
+
def ent? = Wurk.ent?
|
|
124
|
+
def default_job_options = Wurk.default_job_options
|
|
125
|
+
|
|
126
|
+
def default_job_options=(hash)
|
|
127
|
+
Wurk.default_job_options = hash
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def strict_args!(mode = :raise) = Wurk.strict_args!(mode)
|
|
131
|
+
def testing!(mode = :fake, &) = Wurk.testing!(mode, &)
|
|
132
|
+
def testing? = Wurk.testing?
|
|
133
|
+
def load_json(str) = Wurk.load_json(str)
|
|
134
|
+
def dump_json(obj) = Wurk.dump_json(obj)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module Wurk
|
|
7
|
+
# Shared mixin for runtime components (Launcher, Manager, Processor, Fetcher,
|
|
8
|
+
# Scheduler, Cron). Wraps clock readings, identity, thread spawning,
|
|
9
|
+
# lifecycle event dispatch, and exception forwarding so each component
|
|
10
|
+
# stays single-purpose.
|
|
11
|
+
#
|
|
12
|
+
# Host class must expose `#config` returning either a Wurk::Configuration
|
|
13
|
+
# or a Wurk::Capsule — both duck-type the methods we delegate to.
|
|
14
|
+
#
|
|
15
|
+
# Spec: docs/target/sidekiq-free.md §11 (Sidekiq::Component).
|
|
16
|
+
module Component
|
|
17
|
+
DEFAULT_THREAD_PRIORITY = -1
|
|
18
|
+
|
|
19
|
+
# Stable for the life of the process — survives fork (children inherit
|
|
20
|
+
# the same nonce). Identity differs across forks because Process.pid does.
|
|
21
|
+
PROCESS_NONCE = SecureRandom.hex(6)
|
|
22
|
+
|
|
23
|
+
attr_reader :config
|
|
24
|
+
|
|
25
|
+
# --- clocks ---------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
def real_ms
|
|
28
|
+
::Process.clock_gettime(::Process::CLOCK_REALTIME, :millisecond)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def mono_ms
|
|
32
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :millisecond)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# --- identity -------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def tid
|
|
38
|
+
(Thread.current.object_id ^ ::Process.pid).to_s(36)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def hostname
|
|
42
|
+
ENV['DYNO'] || Socket.gethostname
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def process_nonce
|
|
46
|
+
PROCESS_NONCE
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def identity
|
|
50
|
+
"#{hostname}:#{::Process.pid}:#{process_nonce}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def default_tag(dir = Dir.pwd)
|
|
54
|
+
File.basename(dir)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# --- delegated to config -------------------------------------------
|
|
58
|
+
|
|
59
|
+
def logger
|
|
60
|
+
config.logger
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def redis(&)
|
|
64
|
+
config.redis(&)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def handle_exception(ex, ctx = {})
|
|
68
|
+
config.handle_exception(ex, ctx)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# --- cluster leadership --------------------------------------------
|
|
72
|
+
|
|
73
|
+
# True iff this process currently holds the cluster `dear-leader` lock.
|
|
74
|
+
# Per spec, the check is performed at call time (Wurk does not cache);
|
|
75
|
+
# callers must not poll faster than the 60s follower cadence. Returns
|
|
76
|
+
# false unconditionally when `WURK_LEADER=false` is set on the process
|
|
77
|
+
# (opt-out hot-standby). Any Redis error is swallowed → false, so a
|
|
78
|
+
# transient partition can't propagate as an exception into user code.
|
|
79
|
+
#
|
|
80
|
+
# Spec: docs/target/sidekiq-ent.md §6.1.
|
|
81
|
+
def leader?
|
|
82
|
+
return false if ENV[Wurk::Leader::OPT_OUT_ENV].to_s.downcase == 'false'
|
|
83
|
+
|
|
84
|
+
redis { |c| c.call('GET', Wurk::Leader::DEFAULT_KEY) } == identity
|
|
85
|
+
rescue StandardError
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# --- thread boundaries ---------------------------------------------
|
|
90
|
+
|
|
91
|
+
# Wraps a block at a thread boundary: any unhandled exception is reported
|
|
92
|
+
# via handle_exception (so it lands in error_handlers / the log) and then
|
|
93
|
+
# re-raised. `last_words` is the component label included in the context.
|
|
94
|
+
def watchdog(last_words)
|
|
95
|
+
yield
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
handle_exception(e, { context: last_words })
|
|
98
|
+
raise
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Spawns a named thread that runs `block` under `watchdog(name)`. The
|
|
102
|
+
# parent must retain the returned Thread; otherwise GC may not, but
|
|
103
|
+
# report_on_exception is disabled so we don't double-log on death.
|
|
104
|
+
def safe_thread(name, priority: nil, &block)
|
|
105
|
+
Thread.new do
|
|
106
|
+
Thread.current.name = name
|
|
107
|
+
Thread.current.priority = priority || DEFAULT_THREAD_PRIORITY
|
|
108
|
+
Thread.current.report_on_exception = false
|
|
109
|
+
watchdog(name, &block)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Invokes lifecycle hooks for `event`. Hooks run in registration order
|
|
114
|
+
# (or LIFO when `reverse: true`, used for teardown). A raise in one hook
|
|
115
|
+
# is reported via handle_exception and does NOT stop the next hook unless
|
|
116
|
+
# `reraise: true` (used in tests / fail-fast boot). `oneshot: true`
|
|
117
|
+
# clears the bucket after dispatch so the event can't fire twice.
|
|
118
|
+
def fire_event(event, oneshot: true, reverse: false, reraise: false)
|
|
119
|
+
bucket = config[:lifecycle_events][event]
|
|
120
|
+
return if bucket.nil? || bucket.empty?
|
|
121
|
+
|
|
122
|
+
iter = reverse ? bucket.reverse : bucket
|
|
123
|
+
iter.each { |hook| run_lifecycle_hook(hook, event, reraise) }
|
|
124
|
+
bucket.clear if oneshot
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def run_lifecycle_hook(hook, event, reraise)
|
|
130
|
+
hook.call
|
|
131
|
+
rescue StandardError => e
|
|
132
|
+
handle_exception(e, { event: event })
|
|
133
|
+
raise if reraise
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
require_relative 'middleware/chain'
|
|
5
|
+
require_relative 'capsule'
|
|
6
|
+
require_relative 'context'
|
|
7
|
+
require_relative 'topology'
|
|
8
|
+
|
|
9
|
+
module Wurk
|
|
10
|
+
# Owns runtime knobs (concurrency, queues, timeouts, lifecycle events,
|
|
11
|
+
# error/death handlers) and the registry of Capsules. Single source of truth
|
|
12
|
+
# for everything the swarm / managers / processors need to boot.
|
|
13
|
+
#
|
|
14
|
+
# Spec: docs/target/sidekiq-free.md §4 (Sidekiq::Config).
|
|
15
|
+
class Configuration # rubocop:disable Metrics/ClassLength
|
|
16
|
+
# Mirrors Sidekiq::Config::DEFAULTS. Order and keys are part of the
|
|
17
|
+
# drop-in contract — third-party gems read @options via [] / fetch / dig.
|
|
18
|
+
DEFAULTS = {
|
|
19
|
+
labels: Set.new,
|
|
20
|
+
require: '.',
|
|
21
|
+
environment: nil,
|
|
22
|
+
concurrency: 5,
|
|
23
|
+
timeout: 25,
|
|
24
|
+
poll_interval_average: nil,
|
|
25
|
+
average_scheduled_poll_interval: 5,
|
|
26
|
+
on_complex_arguments: :raise,
|
|
27
|
+
max_iteration_runtime: nil,
|
|
28
|
+
error_handlers: [],
|
|
29
|
+
death_handlers: [],
|
|
30
|
+
lifecycle_events: {
|
|
31
|
+
startup: [],
|
|
32
|
+
quiet: [],
|
|
33
|
+
shutdown: [],
|
|
34
|
+
exit: [],
|
|
35
|
+
heartbeat: [],
|
|
36
|
+
beat: [],
|
|
37
|
+
leader: []
|
|
38
|
+
},
|
|
39
|
+
dead_max_jobs: 10_000,
|
|
40
|
+
dead_timeout_in_seconds: 180 * 24 * 60 * 60,
|
|
41
|
+
reloader: proc { |&b| b.call },
|
|
42
|
+
backtrace_cleaner: ->(bt) { bt },
|
|
43
|
+
logged_job_attributes: %w[bid tags],
|
|
44
|
+
redis_idle_timeout: nil
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
LIFECYCLE_EVENTS = %i[startup quiet shutdown exit heartbeat beat leader].freeze
|
|
48
|
+
DEFAULT_THREAD_PRIORITY = -1
|
|
49
|
+
|
|
50
|
+
# Default error handler. Wraps the report in the thread-local
|
|
51
|
+
# Wurk::Context so logger formatters/JSON layouts can pick up jid/bid/tags.
|
|
52
|
+
# `full_message` (with backtrace) in dev/debug, `detailed_message` in prod —
|
|
53
|
+
# mirrors the Sidekiq behavior so log scrapers built for one work for both.
|
|
54
|
+
#
|
|
55
|
+
# Spec: docs/target/sidekiq-free.md §4.3.
|
|
56
|
+
ERROR_HANDLER = lambda do |ex, ctx, cfg = Wurk.configuration|
|
|
57
|
+
safe_ctx = ctx || {}
|
|
58
|
+
Wurk::Context.with(safe_ctx) do
|
|
59
|
+
dev = $DEBUG || ENV['WURK_DEBUG'] || cfg.logger.debug?
|
|
60
|
+
msg = dev ? ex.full_message : ex.detailed_message
|
|
61
|
+
cfg.logger.info { msg }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
attr_reader :capsules, :directory, :redis_config
|
|
66
|
+
attr_accessor :thread_priority
|
|
67
|
+
|
|
68
|
+
# Pro parity: callable that builds the statsd / dogstatsd client.
|
|
69
|
+
# Invoked once per process AFTER fork; see Wurk::Metrics::Statsd.client.
|
|
70
|
+
# Assignable as a Proc, lambda, or any object responding to #call:
|
|
71
|
+
#
|
|
72
|
+
# config.dogstatsd = -> { Datadog::Statsd.new('host', 8125) }
|
|
73
|
+
#
|
|
74
|
+
# Spec: docs/target/sidekiq-pro.md §9.1.
|
|
75
|
+
attr_accessor :dogstatsd
|
|
76
|
+
|
|
77
|
+
def initialize(options = {})
|
|
78
|
+
@options = deep_dup_defaults.merge(options)
|
|
79
|
+
@options[:error_handlers] << ERROR_HANDLER if @options[:error_handlers].empty?
|
|
80
|
+
@capsules = {}
|
|
81
|
+
@directory = {}
|
|
82
|
+
@client_chain = Middleware::Chain.new
|
|
83
|
+
@server_chain = Middleware::Chain.new
|
|
84
|
+
@redis_config = { url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') }
|
|
85
|
+
@logger = nil
|
|
86
|
+
@thread_priority = DEFAULT_THREAD_PRIORITY
|
|
87
|
+
@frozen = false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# --- Hash-like options access -----------------------------------------
|
|
91
|
+
|
|
92
|
+
def [](key) = @options.[](key)
|
|
93
|
+
|
|
94
|
+
def []=(key, val)
|
|
95
|
+
guard_frozen!
|
|
96
|
+
@options[key] = val
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def fetch(*, &) = @options.fetch(*, &)
|
|
100
|
+
def key?(key) = @options.key?(key)
|
|
101
|
+
alias has_key? key?
|
|
102
|
+
def merge!(other)
|
|
103
|
+
guard_frozen!
|
|
104
|
+
@options.merge!(other)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def dig(*keys) = @options.dig(*keys)
|
|
108
|
+
|
|
109
|
+
# --- Default capsule shortcuts ---------------------------------------
|
|
110
|
+
|
|
111
|
+
def concurrency = default_capsule.concurrency
|
|
112
|
+
|
|
113
|
+
def concurrency=(val)
|
|
114
|
+
default_capsule.concurrency = val
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def queues = default_capsule.queues
|
|
118
|
+
|
|
119
|
+
def queues=(val)
|
|
120
|
+
default_capsule.queues = val
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def total_concurrency
|
|
124
|
+
@capsules.each_value.sum(&:concurrency)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def default_capsule(&)
|
|
128
|
+
capsule('default', &)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def capsule(name)
|
|
132
|
+
name = name.to_s
|
|
133
|
+
cap = @capsules[name] ||= Capsule.new(name, self)
|
|
134
|
+
yield cap if block_given?
|
|
135
|
+
cap
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# --- Middleware -------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def client_middleware
|
|
141
|
+
yield @client_chain if block_given?
|
|
142
|
+
@client_chain
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def server_middleware
|
|
146
|
+
yield @server_chain if block_given?
|
|
147
|
+
@server_chain
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# --- Redis ------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def redis=(hash)
|
|
153
|
+
guard_frozen!
|
|
154
|
+
@redis_config = @redis_config.merge(hash.transform_keys(&:to_sym))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def redis_pool
|
|
158
|
+
default_capsule.redis_pool
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def local_redis_pool
|
|
162
|
+
@local_redis_pool ||= build_redis_pool(size: 10, name: 'internal')
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Disconnect and drop every cached pool — the per-capsule mains plus
|
|
166
|
+
# the config-level internal pool. Used by Wurk::Swarm so the parent
|
|
167
|
+
# never leaks sockets into forks and each child can build fresh ones.
|
|
168
|
+
def reset_redis_pools!
|
|
169
|
+
@capsules.each_value(&:reset_redis_pools!)
|
|
170
|
+
@local_redis_pool&.disconnect!
|
|
171
|
+
@local_redis_pool = nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def new_redis_pool(size, name = 'custom')
|
|
175
|
+
build_redis_pool(size: size, name: name)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def redis(&)
|
|
179
|
+
redis_pool.with(&)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# --- Service locator (extension registry) ----------------------------
|
|
183
|
+
|
|
184
|
+
def register(name, instance)
|
|
185
|
+
guard_frozen!
|
|
186
|
+
@directory[name] = instance
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def lookup(name, default_class = nil)
|
|
190
|
+
@directory[name] ||= default_class&.new
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# --- Handlers ---------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def error_handlers
|
|
196
|
+
@options[:error_handlers]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def death_handlers
|
|
200
|
+
@options[:death_handlers]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def average_scheduled_poll_interval=(interval)
|
|
204
|
+
@options[:average_scheduled_poll_interval] = interval
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# --- Periodic (Cron) registration ------------------------------------
|
|
208
|
+
|
|
209
|
+
# Yields a Wurk::Cron::Manager so the host app can register periodic
|
|
210
|
+
# jobs at boot. Manager state is shared per-process so multiple
|
|
211
|
+
# `config.periodic` blocks accumulate (matches Sidekiq Ent §2.1).
|
|
212
|
+
#
|
|
213
|
+
# Spec: docs/target/sidekiq-ent.md §2.
|
|
214
|
+
def periodic
|
|
215
|
+
require_relative 'cron'
|
|
216
|
+
@periodic_manager ||= Wurk::Cron::Manager.new(self)
|
|
217
|
+
yield @periodic_manager if block_given?
|
|
218
|
+
@periodic_manager
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# --- Web dashboard ----------------------------------------------------
|
|
222
|
+
|
|
223
|
+
# Web UI configuration: the authorization hook and read-only mode. Returns
|
|
224
|
+
# the process-wide `Wurk::Web.config` singleton so `config.web.read_only =
|
|
225
|
+
# true` and the engine middleware share one source of truth. Lazy-requires
|
|
226
|
+
# the web layer to keep standalone boot lean.
|
|
227
|
+
#
|
|
228
|
+
# Spec: docs/target/sidekiq-ent.md §9.2.
|
|
229
|
+
def web
|
|
230
|
+
require_relative 'web/config'
|
|
231
|
+
Wurk::Web.config
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# --- K8s liveness/readiness probes -----------------------------------
|
|
235
|
+
|
|
236
|
+
# Opt-in thin HTTP listener inside the worker process for k8s probes.
|
|
237
|
+
# When called, the Launcher will start a TCP server on `port` bound to
|
|
238
|
+
# `bind` exposing `GET /live` (200 while not stopping) and `GET /ready`
|
|
239
|
+
# (200 only when Redis is reachable AND heartbeat fired within
|
|
240
|
+
# `ready_window` seconds).
|
|
241
|
+
#
|
|
242
|
+
# Off by default — call this in a `configure_server` block to enable.
|
|
243
|
+
# Spec: docs/target/sidekiq-ent.md §7.1.2.
|
|
244
|
+
def health_check(port:, bind: '0.0.0.0', ready_window: 30)
|
|
245
|
+
guard_frozen!
|
|
246
|
+
p = Integer(port)
|
|
247
|
+
rw = Integer(ready_window)
|
|
248
|
+
raise ArgumentError, 'port must be between 0 and 65535' unless (0..65535).cover?(p)
|
|
249
|
+
raise ArgumentError, 'ready_window must be > 0' unless rw.positive?
|
|
250
|
+
|
|
251
|
+
b = bind.to_s
|
|
252
|
+
raise ArgumentError, 'bind must be a non-empty string' if b.empty?
|
|
253
|
+
|
|
254
|
+
@options[:health_check_options] = { port: p, bind: b, ready_window: rw }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# --- Lifecycle hooks --------------------------------------------------
|
|
258
|
+
|
|
259
|
+
def on(event, &block)
|
|
260
|
+
raise ArgumentError, "block required for on(#{event.inspect})" unless block
|
|
261
|
+
unless LIFECYCLE_EVENTS.include?(event)
|
|
262
|
+
raise ArgumentError, "invalid event #{event.inspect}, must be one of #{LIFECYCLE_EVENTS.inspect}"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
@options[:lifecycle_events][event] << block
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# --- Logger -----------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
def logger
|
|
271
|
+
@logger ||= default_logger
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
attr_writer :logger
|
|
275
|
+
|
|
276
|
+
def handle_exception(ex, ctx = {})
|
|
277
|
+
if error_handlers.empty?
|
|
278
|
+
logger.error("#{ctx} #{ex.class}: #{ex.message}")
|
|
279
|
+
else
|
|
280
|
+
error_handlers.each do |handler|
|
|
281
|
+
handler.call(ex, ctx, self)
|
|
282
|
+
rescue StandardError => e
|
|
283
|
+
logger.error("error_handler raised: #{e.class}: #{e.message}")
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# --- Configure blocks (Sidekiq.configure_server / _client) -----------
|
|
289
|
+
|
|
290
|
+
def configure_server(&block)
|
|
291
|
+
yield self if block && server?
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def configure_client(&block)
|
|
295
|
+
yield self if block && !server?
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def server?
|
|
299
|
+
@options[:server] == true
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Worker topology for the swarm. When the host hasn't declared one (the
|
|
303
|
+
# railtie path), default to a single flat fork running the default
|
|
304
|
+
# capsule's queues + concurrency. Assign a custom Wurk::Topology (via
|
|
305
|
+
# `topology=`) for specialized slots.
|
|
306
|
+
def topology
|
|
307
|
+
@topology ||= default_topology
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def topology=(value)
|
|
311
|
+
guard_frozen!
|
|
312
|
+
@topology = value
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def freeze!
|
|
316
|
+
return self if @frozen
|
|
317
|
+
|
|
318
|
+
@capsules.each_value(&:freeze)
|
|
319
|
+
@capsules.freeze
|
|
320
|
+
@options.freeze
|
|
321
|
+
@directory.freeze
|
|
322
|
+
@frozen = true
|
|
323
|
+
self
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def frozen?
|
|
327
|
+
@frozen
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def inspect
|
|
331
|
+
"#<#{self.class} capsules=#{@capsules.keys} concurrency=#{total_concurrency}>"
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
private
|
|
335
|
+
|
|
336
|
+
# One flat fork running the default capsule's queues + concurrency. The
|
|
337
|
+
# railtie boots this when a Rails host mounts the engine without declaring
|
|
338
|
+
# a topology. queue_specs (not queues) so weights survive the round-trip.
|
|
339
|
+
def default_topology
|
|
340
|
+
cap = default_capsule
|
|
341
|
+
Wurk::Topology.flat(count: 1, queues: cap.queue_specs, concurrency: cap.concurrency)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def guard_frozen!
|
|
345
|
+
raise FrozenError, 'Wurk::Configuration is frozen' if @frozen
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def deep_dup_defaults
|
|
349
|
+
DEFAULTS.each_with_object({}) do |(k, v), h|
|
|
350
|
+
h[k] = case v
|
|
351
|
+
when Hash then v.transform_values { |inner| inner.respond_to?(:dup) ? inner.dup : inner }
|
|
352
|
+
when Array, Set then v.dup
|
|
353
|
+
else v
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def default_logger
|
|
359
|
+
logger = Wurk::Logger.new($stdout)
|
|
360
|
+
logger.level = ::Logger::INFO
|
|
361
|
+
logger
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def build_redis_pool(size:, name:)
|
|
365
|
+
RedisPool.new(
|
|
366
|
+
size: size,
|
|
367
|
+
url: @redis_config[:url] || RedisPool::DEFAULT_URL,
|
|
368
|
+
timeout: @redis_config[:timeout] || RedisPool::DEFAULT_TIMEOUT,
|
|
369
|
+
name: name
|
|
370
|
+
)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
data/lib/wurk/context.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wurk
|
|
4
|
+
# Thread-local logging context. Job state (jid, bid, tags, elapsed, …) is
|
|
5
|
+
# attached here so loggers, middleware, and error handlers can pick it up
|
|
6
|
+
# without threading the hash through every call. Storage lives in
|
|
7
|
+
# `Thread.current[:wurk_context]` — never share across threads or forks.
|
|
8
|
+
#
|
|
9
|
+
# Spec: docs/target/sidekiq-free.md §29.
|
|
10
|
+
module Context
|
|
11
|
+
KEY = :wurk_context
|
|
12
|
+
|
|
13
|
+
# Run `block` with `hash` merged onto the thread-local context. The prior
|
|
14
|
+
# context is restored on exit, even if the block raises — safe to nest.
|
|
15
|
+
def self.with(hash)
|
|
16
|
+
prior = Thread.current[KEY]
|
|
17
|
+
Thread.current[KEY] = (prior || {}).merge(hash)
|
|
18
|
+
yield
|
|
19
|
+
ensure
|
|
20
|
+
Thread.current[KEY] = prior
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Add a single key to the current thread's context. Creates the hash
|
|
24
|
+
# if no enclosing `with` block has been opened yet.
|
|
25
|
+
def self.add(key, value)
|
|
26
|
+
Thread.current[KEY] ||= {}
|
|
27
|
+
Thread.current[KEY][key] = value
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Read the current thread's context (or an empty hash if unset).
|
|
31
|
+
def self.current
|
|
32
|
+
Thread.current[KEY] || {}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|