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.
Files changed (116) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/CONTRIBUTING.md +73 -0
  4. data/LICENSE +21 -0
  5. data/README.md +137 -0
  6. data/SECURITY.md +39 -0
  7. data/app/controllers/wurk/api/pagination.rb +67 -0
  8. data/app/controllers/wurk/api/serializers.rb +131 -0
  9. data/app/controllers/wurk/api_controller.rb +248 -0
  10. data/app/controllers/wurk/application_controller.rb +7 -0
  11. data/app/controllers/wurk/dashboard_controller.rb +48 -0
  12. data/config/locales/en.yml +15 -0
  13. data/config/routes.rb +34 -0
  14. data/exe/wurk +22 -0
  15. data/lib/active_job/queue_adapters/wurk_adapter.rb +96 -0
  16. data/lib/generators/wurk/install/install_generator.rb +22 -0
  17. data/lib/generators/wurk/install/templates/wurk.rb +16 -0
  18. data/lib/wurk/active_job/wrapper.rb +32 -0
  19. data/lib/wurk/api/fast.rb +78 -0
  20. data/lib/wurk/batch/buffer.rb +26 -0
  21. data/lib/wurk/batch/callback_job.rb +37 -0
  22. data/lib/wurk/batch/callbacks.rb +176 -0
  23. data/lib/wurk/batch/client_middleware.rb +27 -0
  24. data/lib/wurk/batch/death_handler.rb +39 -0
  25. data/lib/wurk/batch/empty.rb +21 -0
  26. data/lib/wurk/batch/server_middleware.rb +62 -0
  27. data/lib/wurk/batch/status.rb +140 -0
  28. data/lib/wurk/batch.rb +351 -0
  29. data/lib/wurk/batch_set.rb +67 -0
  30. data/lib/wurk/capsule.rb +176 -0
  31. data/lib/wurk/cli.rb +349 -0
  32. data/lib/wurk/client/buffered.rb +372 -0
  33. data/lib/wurk/client.rb +330 -0
  34. data/lib/wurk/compat.rb +136 -0
  35. data/lib/wurk/component.rb +136 -0
  36. data/lib/wurk/configuration.rb +373 -0
  37. data/lib/wurk/context.rb +35 -0
  38. data/lib/wurk/cron.rb +636 -0
  39. data/lib/wurk/dashboard_manifest.rb +39 -0
  40. data/lib/wurk/dead_set.rb +78 -0
  41. data/lib/wurk/deploy.rb +91 -0
  42. data/lib/wurk/embedded.rb +94 -0
  43. data/lib/wurk/encryption.rb +276 -0
  44. data/lib/wurk/engine.rb +81 -0
  45. data/lib/wurk/fetcher/reaper.rb +264 -0
  46. data/lib/wurk/fetcher/reliable.rb +138 -0
  47. data/lib/wurk/fetcher.rb +11 -0
  48. data/lib/wurk/health.rb +193 -0
  49. data/lib/wurk/heartbeat.rb +211 -0
  50. data/lib/wurk/iterable_job.rb +292 -0
  51. data/lib/wurk/job/options.rb +70 -0
  52. data/lib/wurk/job.rb +33 -0
  53. data/lib/wurk/job_logger.rb +68 -0
  54. data/lib/wurk/job_record.rb +156 -0
  55. data/lib/wurk/job_retry.rb +320 -0
  56. data/lib/wurk/job_set.rb +212 -0
  57. data/lib/wurk/job_util.rb +162 -0
  58. data/lib/wurk/keys.rb +52 -0
  59. data/lib/wurk/launcher.rb +289 -0
  60. data/lib/wurk/leader.rb +221 -0
  61. data/lib/wurk/limiter/base.rb +138 -0
  62. data/lib/wurk/limiter/bucket.rb +80 -0
  63. data/lib/wurk/limiter/concurrent.rb +132 -0
  64. data/lib/wurk/limiter/leaky.rb +91 -0
  65. data/lib/wurk/limiter/points.rb +89 -0
  66. data/lib/wurk/limiter/server_middleware.rb +77 -0
  67. data/lib/wurk/limiter/unlimited.rb +48 -0
  68. data/lib/wurk/limiter/window.rb +80 -0
  69. data/lib/wurk/limiter.rb +255 -0
  70. data/lib/wurk/logger.rb +81 -0
  71. data/lib/wurk/lua/loader.rb +53 -0
  72. data/lib/wurk/lua.rb +187 -0
  73. data/lib/wurk/manager.rb +132 -0
  74. data/lib/wurk/metrics/history.rb +151 -0
  75. data/lib/wurk/metrics/query.rb +173 -0
  76. data/lib/wurk/metrics/rollup.rb +169 -0
  77. data/lib/wurk/metrics/statsd.rb +197 -0
  78. data/lib/wurk/metrics.rb +7 -0
  79. data/lib/wurk/middleware/chain.rb +128 -0
  80. data/lib/wurk/middleware/current_attributes.rb +87 -0
  81. data/lib/wurk/middleware/expiry.rb +50 -0
  82. data/lib/wurk/middleware/i18n.rb +63 -0
  83. data/lib/wurk/middleware/interrupt_handler.rb +45 -0
  84. data/lib/wurk/middleware/poison_pill.rb +149 -0
  85. data/lib/wurk/middleware.rb +34 -0
  86. data/lib/wurk/process_set.rb +243 -0
  87. data/lib/wurk/processor.rb +247 -0
  88. data/lib/wurk/queue.rb +108 -0
  89. data/lib/wurk/queues.rb +80 -0
  90. data/lib/wurk/rails.rb +9 -0
  91. data/lib/wurk/railtie.rb +28 -0
  92. data/lib/wurk/redis_pool.rb +79 -0
  93. data/lib/wurk/retry_set.rb +17 -0
  94. data/lib/wurk/scheduled.rb +189 -0
  95. data/lib/wurk/scheduled_set.rb +18 -0
  96. data/lib/wurk/sorted_entry.rb +95 -0
  97. data/lib/wurk/stats.rb +190 -0
  98. data/lib/wurk/swarm/child_boot.rb +105 -0
  99. data/lib/wurk/swarm.rb +260 -0
  100. data/lib/wurk/testing.rb +102 -0
  101. data/lib/wurk/topology.rb +74 -0
  102. data/lib/wurk/unique.rb +240 -0
  103. data/lib/wurk/version.rb +5 -0
  104. data/lib/wurk/web/config.rb +180 -0
  105. data/lib/wurk/web/enterprise.rb +138 -0
  106. data/lib/wurk/web/search.rb +139 -0
  107. data/lib/wurk/web.rb +25 -0
  108. data/lib/wurk/work_set.rb +116 -0
  109. data/lib/wurk/worker/setter.rb +93 -0
  110. data/lib/wurk/worker.rb +216 -0
  111. data/lib/wurk.rb +238 -0
  112. data/vendor/assets/dashboard/assets/index-8P3N_m1X.js +152 -0
  113. data/vendor/assets/dashboard/assets/index-Bqz4_SOQ.css +1 -0
  114. data/vendor/assets/dashboard/index.html +13 -0
  115. data/vendor/assets/dashboard/wurk-manifest.json +4 -0
  116. metadata +232 -0
@@ -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
@@ -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