wurk 0.0.5 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -0
  3. data/app/controllers/wurk/api/serializers.rb +48 -2
  4. data/app/controllers/wurk/api_controller.rb +216 -1
  5. data/app/controllers/wurk/dashboard_controller.rb +20 -2
  6. data/app/controllers/wurk/extensions_controller.rb +56 -0
  7. data/app/controllers/wurk/profiles_controller.rb +68 -0
  8. data/config/routes.rb +54 -1
  9. data/exe/sidekiqswarm +8 -0
  10. data/exe/wurkswarm +23 -0
  11. data/lib/active_job/queue_adapters/wurk_adapter.rb +35 -0
  12. data/lib/generators/wurk/install/templates/wurk.rb +14 -3
  13. data/lib/sidekiq/api.rb +4 -0
  14. data/lib/sidekiq/cli.rb +9 -0
  15. data/lib/sidekiq/client.rb +4 -0
  16. data/lib/sidekiq/job.rb +4 -0
  17. data/lib/sidekiq/launcher.rb +4 -0
  18. data/lib/sidekiq/middleware/chain.rb +4 -0
  19. data/lib/sidekiq/middleware/server/statsd.rb +12 -0
  20. data/lib/sidekiq/rails.rb +10 -0
  21. data/lib/sidekiq/redis_connection.rb +4 -0
  22. data/lib/sidekiq/scheduled.rb +4 -0
  23. data/lib/sidekiq/testing.rb +4 -0
  24. data/lib/sidekiq/version.rb +4 -0
  25. data/lib/sidekiq/web.rb +4 -0
  26. data/lib/sidekiq/worker.rb +4 -0
  27. data/lib/sidekiq.rb +16 -0
  28. data/lib/wurk/batch/callbacks.rb +103 -13
  29. data/lib/wurk/batch/death_handler.rb +5 -2
  30. data/lib/wurk/batch/server_middleware.rb +35 -3
  31. data/lib/wurk/batch/status.rb +9 -0
  32. data/lib/wurk/batch.rb +23 -1
  33. data/lib/wurk/capsule.rb +20 -1
  34. data/lib/wurk/cli.rb +84 -1
  35. data/lib/wurk/client.rb +20 -17
  36. data/lib/wurk/compat.rb +44 -2
  37. data/lib/wurk/component.rb +5 -4
  38. data/lib/wurk/configuration.rb +120 -3
  39. data/lib/wurk/cron.rb +51 -9
  40. data/lib/wurk/dead_set.rb +8 -3
  41. data/lib/wurk/deploy.rb +8 -4
  42. data/lib/wurk/encryption.rb +6 -1
  43. data/lib/wurk/fetcher/reaper.rb +78 -11
  44. data/lib/wurk/fetcher/reliable.rb +14 -4
  45. data/lib/wurk/heartbeat.rb +45 -0
  46. data/lib/wurk/history.rb +174 -0
  47. data/lib/wurk/iterable_job/active_record_enumerator.rb +71 -0
  48. data/lib/wurk/iterable_job/csv_enumerator.rb +51 -0
  49. data/lib/wurk/iterable_job.rb +41 -0
  50. data/lib/wurk/iterable_job_query.rb +75 -0
  51. data/lib/wurk/job.rb +8 -0
  52. data/lib/wurk/job_record.rb +16 -1
  53. data/lib/wurk/job_set.rb +4 -4
  54. data/lib/wurk/job_util.rb +15 -6
  55. data/lib/wurk/keys.rb +10 -0
  56. data/lib/wurk/launcher.rb +35 -1
  57. data/lib/wurk/leader.rb +15 -6
  58. data/lib/wurk/limiter/bucket.rb +14 -3
  59. data/lib/wurk/limiter/concurrent.rb +1 -1
  60. data/lib/wurk/limiter/window.rb +2 -1
  61. data/lib/wurk/limiter.rb +12 -0
  62. data/lib/wurk/lua/loader.rb +10 -0
  63. data/lib/wurk/lua.rb +106 -14
  64. data/lib/wurk/metrics/history.rb +5 -0
  65. data/lib/wurk/metrics/query.rb +39 -0
  66. data/lib/wurk/metrics/queue_rollup.rb +151 -0
  67. data/lib/wurk/metrics/statsd.rb +11 -0
  68. data/lib/wurk/middleware/current_attributes.rb +29 -6
  69. data/lib/wurk/middleware/interrupt_handler.rb +5 -0
  70. data/lib/wurk/middleware/poison_pill.rb +35 -5
  71. data/lib/wurk/processor.rb +17 -8
  72. data/lib/wurk/profile_set.rb +65 -0
  73. data/lib/wurk/profiler.rb +127 -0
  74. data/lib/wurk/railtie.rb +19 -5
  75. data/lib/wurk/redis_client_adapter.rb +72 -0
  76. data/lib/wurk/redis_connection.rb +30 -0
  77. data/lib/wurk/redis_pool.rb +5 -1
  78. data/lib/wurk/scheduled.rb +42 -0
  79. data/lib/wurk/sorted_entry.rb +13 -11
  80. data/lib/wurk/stats.rb +11 -4
  81. data/lib/wurk/swarm/child_boot.rb +26 -4
  82. data/lib/wurk/swarm.rb +1 -1
  83. data/lib/wurk/transaction_aware_client.rb +69 -0
  84. data/lib/wurk/unique.rb +49 -7
  85. data/lib/wurk/version.rb +1 -1
  86. data/lib/wurk/web/batch_status.rb +42 -0
  87. data/lib/wurk/web/config.rb +219 -17
  88. data/lib/wurk/web/enterprise.rb +14 -0
  89. data/lib/wurk/web/extension.rb +348 -0
  90. data/lib/wurk/web/rack_app.rb +77 -0
  91. data/lib/wurk/web.rb +2 -0
  92. data/lib/wurk/worker/setter.rb +5 -1
  93. data/lib/wurk/worker.rb +17 -6
  94. data/lib/wurk.rb +44 -0
  95. data/vendor/assets/dashboard/assets/fa-brands-400-BP5tdqmh.woff2 +0 -0
  96. data/vendor/assets/dashboard/assets/fa-regular-400-nyy7hhHF.woff2 +0 -0
  97. data/vendor/assets/dashboard/assets/fa-solid-900-DRAAbZTg.woff2 +0 -0
  98. data/vendor/assets/dashboard/assets/index-9CFRWpfG.js +77 -0
  99. data/vendor/assets/dashboard/assets/index-CW8AFQIv.css +2 -0
  100. data/vendor/assets/dashboard/assets/wurk-logo-Vy3xW4K0.png +0 -0
  101. data/vendor/assets/dashboard/favicon.png +0 -0
  102. data/vendor/assets/dashboard/index.html +10 -3
  103. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  104. metadata +42 -3
  105. data/vendor/assets/dashboard/assets/index-D2XR0iGw.js +0 -60
  106. data/vendor/assets/dashboard/assets/index-DlPr4YXw.css +0 -1
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'redis_pool'
4
+
5
+ module Wurk
6
+ # Sidekiq-compatible pool constructor. `Sidekiq::RedisConnection.create(...)`
7
+ # is the documented way to build a standalone Redis pool (spec §26) — it's the
8
+ # default pool for `Sidekiq::Deploy` (§23) and the constructor for per-shard
9
+ # pools in multi-shard web mounts (Pro §10.2). Returns a `Wurk::RedisPool`
10
+ # (connection_pool-backed, redis-client adapter), which is `.with`-compatible
11
+ # with everything that expects a Sidekiq pool.
12
+ #
13
+ # Accepts Sidekiq's option keys (`url`, `size`, `pool_timeout`, `name`),
14
+ # string- or symbol-keyed. Anything omitted falls back to RedisPool's defaults
15
+ # (URL = ENV["REDIS_URL"] or redis://localhost:6379/0).
16
+ module RedisConnection
17
+ # Housekeeping/standalone default; per-capsule pools size to concurrency.
18
+ DEFAULT_POOL_SIZE = 10
19
+
20
+ def self.create(options = {})
21
+ opts = options.transform_keys(&:to_sym)
22
+ RedisPool.new(
23
+ size: opts[:size] || DEFAULT_POOL_SIZE,
24
+ url: opts[:url] || RedisPool::DEFAULT_URL,
25
+ timeout: opts[:pool_timeout] || RedisPool::DEFAULT_TIMEOUT,
26
+ name: opts[:name] || RedisPool::DEFAULT_NAME
27
+ )
28
+ end
29
+ end
30
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'redis-client'
4
4
  require 'connection_pool'
5
+ require_relative 'redis_client_adapter'
5
6
 
6
7
  module Wurk
7
8
  # Per-process pool over redis-client + connection_pool. Never share a socket
@@ -56,8 +57,11 @@ module Wurk
56
57
 
57
58
  private
58
59
 
60
+ # Wrapped in the CompatClient decorator so `Sidekiq.redis { |c| c.smembers }`
61
+ # method-style commands work like Sidekiq 7+ (#204). Wurk's own code paths
62
+ # use #call, which the decorator forwards.
59
63
  def build_client
60
- RedisClient.config(url: @url, timeout: @timeout).new_client
64
+ RedisClientAdapter::CompatClient.new(RedisClient.config(url: @url, timeout: @timeout).new_client)
61
65
  end
62
66
 
63
67
  def safe_close(conn)
@@ -67,6 +67,48 @@ module Wurk
67
67
  end
68
68
  end
69
69
 
70
+ # Reliable variant of Enq (Pro §4). The default Enq pops then pushes —
71
+ # a crash between the ZPOPBYSCORE and the client push loses the job.
72
+ # ReliableEnq instead promotes every due job from each set onto its target
73
+ # queue in a single atomic Lua (ZRANGEBYSCORE → LPUSH queue:<q> → ZREM),
74
+ # so there is no window where a job exists in neither place. Swapped in by
75
+ # `config.reliable_scheduler!` via the `scheduled_enq` seam.
76
+ #
77
+ # Spec: docs/target/sidekiq-pro.md §4.
78
+ class ReliableEnq
79
+ include Component
80
+
81
+ def initialize(container)
82
+ @config = container
83
+ @done = false
84
+ end
85
+
86
+ def enqueue_jobs(sorted_sets = SETS)
87
+ @config.redis do |conn|
88
+ sorted_sets.each { |sset| promote(conn, sset) }
89
+ end
90
+ end
91
+
92
+ def terminate
93
+ @done = true
94
+ end
95
+
96
+ private
97
+
98
+ def promote(conn, sset)
99
+ Wurk::Lua::Loader.eval_cached(
100
+ conn,
101
+ :reliable_schedule_promote,
102
+ keys: [sset, Keys::QUEUES_SET],
103
+ argv: [real_time.to_s, Keys::QUEUE_PREFIX]
104
+ )
105
+ end
106
+
107
+ def real_time
108
+ ::Process.clock_gettime(::Process::CLOCK_REALTIME)
109
+ end
110
+ end
111
+
70
112
  # Single thread that wakes on a randomized interval, drains both ZSETs,
71
113
  # then sleeps again. Random spread prevents the cluster from dogpiling
72
114
  # Redis at the top of each cadence.
@@ -42,35 +42,37 @@ module Wurk
42
42
  # ZINCRBY to shift the score; positive deltas reschedule into the future.
43
43
  # Sidekiq passes the absolute target time; we compute the delta here so
44
44
  # the call survives clock skew between caller and Redis.
45
- def reschedule(at) # rubocop:disable Naming/MethodParameterName
45
+ def reschedule(at)
46
46
  Wurk.redis { |conn| conn.call('ZINCRBY', @parent.name, at.to_f - @score, value) }
47
47
  end
48
48
 
49
- # Removes this entry, decrements `retry_count` by one (so the worker treats
50
- # the next attempt as a re-do, not a fresh retry), and re-enqueues via the
51
- # client. Wire-compat with Sidekiq's "Retry now" UI action.
49
+ # Removes this entry and re-enqueues it via the client with the payload
50
+ # untouched. Backs the scheduled/dead "add to queue" actions Sidekiq's
51
+ # add_to_queue does not touch `retry_count`.
52
52
  def add_to_queue
53
53
  remove_job do |message|
54
- message['retry_count'] = message['retry_count'].to_i - 1 if message['retry_count']
55
54
  Client.new.push(message)
56
55
  end
57
56
  end
58
57
 
59
- # Same flow as add_to_queue but keeps `retry_count` intact. Used for the
60
- # "retry" action from the retry set (count was already incremented when
61
- # the job entered retry; don't double-bump).
58
+ # Same flow but decrements `retry_count` first: the count was already
59
+ # incremented when the job entered the retry set, and the next failure
60
+ # bumps it again — without the decrement a manual "Retry now" would
61
+ # consume an attempt. Wire-compat with Sidekiq's SortedEntry#retry.
62
62
  def retry
63
63
  remove_job do |message|
64
+ message['retry_count'] = message['retry_count'].to_i - 1 if message['retry_count']
64
65
  Client.new.push(message)
65
66
  end
66
67
  end
67
68
 
68
69
  # Removes this entry from its parent set and writes it to the dead set.
69
- # `notify_failure: false` because the kill is user-initiated (UI action),
70
- # not a retry-exhausted event death_handlers don't fire.
70
+ # Death handlers fire with the synthesized "Job killed by API" exception
71
+ # (Sidekiq's default) so error trackers and the batch death path observe
72
+ # API/UI kills.
71
73
  def kill
72
74
  remove_job do |message|
73
- DeadSet.new.kill(Wurk.dump_json(message), notify_failure: false)
75
+ DeadSet.new.kill(Wurk.dump_json(message))
74
76
  end
75
77
  end
76
78
 
data/lib/wurk/stats.rb CHANGED
@@ -49,7 +49,11 @@ module Wurk
49
49
  end
50
50
  end
51
51
 
52
- # @return [Hash{String=>Integer}] queue name → LLEN.
52
+ # @return [Hash{String=>Integer}] queue name → LLEN, largest queue first.
53
+ # `SMEMBERS` order is arbitrary; Sidekiq sorts the name/size pairs by size
54
+ # descending before building the hash (Stats#queues, spec §19.1) — gems
55
+ # and dashboards reading this rely on that order. Match it exactly with
56
+ # `sort_by { |_, size| -size }`.
53
57
  def queues
54
58
  Wurk.redis do |conn|
55
59
  names = conn.call('SMEMBERS', Keys::QUEUES_SET)
@@ -58,11 +62,14 @@ module Wurk
58
62
  sizes = conn.pipelined do |pipe|
59
63
  names.each { |q| pipe.call('LLEN', Keys.queue(q)) }
60
64
  end
61
- names.zip(sizes.map(&:to_i)).to_h
65
+ names.zip(sizes.map(&:to_i)).sort_by { |_, size| -size }.to_h
62
66
  end
63
67
  end
64
68
 
65
- # @return [Array<QueueSummary>] one per known queue.
69
+ # @return [Array<QueueSummary>] one per known queue, largest first.
70
+ # Same size-descending order as Sidekiq's detailed queue list
71
+ # (`queue_summaries.sort_by { |qd| -qd.size }`) — this feeds the
72
+ # dashboard's queue table (api_controller#queues).
66
73
  def queue_summaries
67
74
  Wurk.redis do |conn|
68
75
  names = conn.call('SMEMBERS', Keys::QUEUES_SET)
@@ -75,7 +82,7 @@ module Wurk
75
82
  pipe.call('LRANGE', Keys.queue(q), -1, -1)
76
83
  end
77
84
  end
78
- build_summaries(names, results, paused_set)
85
+ build_summaries(names, results, paused_set).sort_by { |qd| -qd.size }
79
86
  end
80
87
  end
81
88
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../component'
3
4
  require_relative '../launcher'
4
5
  require_relative '../fetcher/reliable'
5
6
 
@@ -16,6 +17,8 @@ module Wurk
16
17
  # Kept separate from Wurk::Swarm so the parent supervisor stays
17
18
  # focused on PID supervision (SRP).
18
19
  class ChildBoot
20
+ include Component
21
+
19
22
  CHILD_SIGNALS = { 'TERM' => :term, 'INT' => :term, 'TSTP' => :tstp, 'USR2' => :usr2 }.freeze
20
23
 
21
24
  def initialize(config, slot, index)
@@ -29,11 +32,20 @@ module Wurk
29
32
  reset_inherited_signals
30
33
  reconnect_after_fork
31
34
  Wurk.server = true
35
+ # :fork runs in each child after our internal AR/Redis reconnect and
36
+ # before fetching, so apps can reopen sockets / restart threads /
37
+ # reconnect non-fork-safe libs (Ent §7.4). It never fires in the parent
38
+ # (which only forks + supervises). Like :startup, the child's forked
39
+ # copy of the bucket is cleared after dispatch, so siblings fire theirs.
40
+ fire_event(:fork)
32
41
  apply_slot_to_config
33
- launcher = Wurk::Launcher.new(@config)
34
- install_signal_handlers(launcher)
35
- launcher.run
36
- wait_loop(launcher)
42
+ # :startup must fire in each worker child before its managers spin up
43
+ # (Sidekiq contract, reraise: true). The parent supervisor never runs
44
+ # jobs, so the non-swarm CLI path fires it once per process — for the
45
+ # swarm, each child fires it here. Its own forked copy of the bucket is
46
+ # cleared after, so siblings still fire their own.
47
+ fire_event(:startup, reraise: true)
48
+ run_launcher
37
49
  exit 0
38
50
  rescue StandardError, ::Wurk::Shutdown => e
39
51
  @config.logger.error { "swarm child ##{@index} (#{::Process.pid}) crashed: #{e.class}: #{e.message}" }
@@ -42,6 +54,16 @@ module Wurk
42
54
 
43
55
  private
44
56
 
57
+ # Boot the launcher and block until shutdown. wait_loop joins the
58
+ # signal-dispatch thread, so the child can't fall through to `exit 0`
59
+ # mid-drain.
60
+ def run_launcher
61
+ launcher = Wurk::Launcher.new(@config)
62
+ install_signal_handlers(launcher)
63
+ launcher.run
64
+ wait_loop(launcher)
65
+ end
66
+
45
67
  # Parent installed traps for TERM/INT/TSTP/CONT/USR1 — the child
46
68
  # needs its own behavior, not the parent's.
47
69
  def reset_inherited_signals
data/lib/wurk/swarm.rb CHANGED
@@ -36,7 +36,7 @@ module Wurk
36
36
 
37
37
  attr_reader :topology, :children
38
38
 
39
- def initialize(topology:, config: Wurk.configuration, memory_limit: nil,
39
+ def initialize(topology:, config: Wurk.configuration, memory_limit: config.memory_limit_kb,
40
40
  shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT)
41
41
  @topology = topology
42
42
  @config = config
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require_relative 'client'
5
+ require_relative 'job_util'
6
+
7
+ module Wurk
8
+ # Opt-in enqueue client that holds each `push` until the surrounding
9
+ # ActiveRecord transaction commits, so a job never references a row a
10
+ # rollback erased — the canonical "enqueue after commit" pattern.
11
+ #
12
+ # Enable globally with `Wurk.transactional_push!` (aliased
13
+ # `Sidekiq.transactional_push!`), which sets `default_job_options
14
+ # ["client_class"]`; the worker DSL then builds this client instead of the
15
+ # plain `Wurk::Client`.
16
+ #
17
+ # The jid is pre-allocated and returned synchronously so the caller can
18
+ # reference it inside the transaction; the actual Redis write runs in an
19
+ # after-commit hook. `push_bulk` is intentionally NOT deferred — it matches
20
+ # Sidekiq, whose batching/scheduling machinery can't ride the commit hook.
21
+ #
22
+ # Spec: docs/target/sidekiq-free.md §8.
23
+ class TransactionAwareClient
24
+ def initialize(pool: nil, config: nil)
25
+ @redis_client = Wurk::Client.new(pool: pool, config: config)
26
+ end
27
+
28
+ # True inside a `Batch#jobs` block. The batch counts jobs at push time, so
29
+ # deferring would desync its totals — push immediately instead.
30
+ def batching?
31
+ !Thread.current[Wurk::Batch::THREAD_KEY].nil?
32
+ end
33
+
34
+ # @return [String] the pre-allocated jid (returned before the deferred push runs).
35
+ def push(item)
36
+ item['jid'] ||= SecureRandom.hex(12)
37
+
38
+ if batching?
39
+ @redis_client.push(item)
40
+ else
41
+ register_after_commit { @redis_client.push(item) }
42
+ end
43
+
44
+ item['jid']
45
+ end
46
+
47
+ # Bulk enqueue is never transactional (Sidekiq parity) — straight to Redis.
48
+ def push_bulk(items)
49
+ @redis_client.push_bulk(items)
50
+ end
51
+
52
+ private
53
+
54
+ # Routes the push to the host's after-commit hook. AR 7.2+ exposes
55
+ # `ActiveRecord.after_all_transactions_commit`, which runs the block now
56
+ # when there's no open transaction and after commit when there is — so the
57
+ # "not in a transaction" and "ActiveRecord absent" cases both degrade to an
58
+ # immediate push, exactly the graceful no-op the spec calls for.
59
+ def register_after_commit(&)
60
+ if defined?(::ActiveRecord) && ::ActiveRecord.respond_to?(:after_all_transactions_commit)
61
+ ::ActiveRecord.after_all_transactions_commit(&)
62
+ elsif defined?(::AfterCommitEverywhere)
63
+ ::AfterCommitEverywhere.after_commit(&)
64
+ else
65
+ yield
66
+ end
67
+ end
68
+ end
69
+ end
data/lib/wurk/unique.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require 'json'
4
4
  require 'digest'
5
5
  require_relative 'middleware'
6
+ require_relative 'lua'
6
7
 
7
8
  module Wurk
8
9
  # Sidekiq Enterprise unique jobs. Best-effort dedup at enqueue time keyed
@@ -16,6 +17,10 @@ module Wurk
16
17
  # *before* invoking perform; a duplicate can be enqueued while the
17
18
  # first is running.
18
19
  #
20
+ # A job that dies *automatically* (retries exhausted / discarded) releases
21
+ # its lock via a death handler; manual UI kills keep the lock until TTL
22
+ # expiry (Ent wiki, Ent-Unique-Jobs).
23
+ #
19
24
  # Wire-compat (§3.9): single-key Redis layout — `unique:<sha256>` STRING
20
25
  # holding the owning JID. Scheduled jobs extend the TTL by the delay so
21
26
  # the lock covers the entire wait+execution window (§3.4).
@@ -26,6 +31,31 @@ module Wurk
26
31
  DEFAULT_UNTIL = :success
27
32
  VALID_UNTIL = %i[success start].freeze
28
33
 
34
+ # Ent parity: a job that dies automatically releases its lock so a
35
+ # duplicate can enqueue immediately. Manual API/UI kills keep the lock
36
+ # until TTL expiry (Ent wiki, Ent-Unique-Jobs) — they reach death
37
+ # handlers too (Sidekiq fires them on API kills), so we recognize the
38
+ # synthesized kill exception and skip the release for it. Atomic
39
+ # CAS-DEL via the shared Lua script mirrors ServerMiddleware#release.
40
+ DEATH_HANDLER = lambda do |job, exception|
41
+ next unless Wurk::Unique.enabled?
42
+ next unless Wurk::Unique.coerce_ttl(job['unique_for'])
43
+ next if exception.instance_of?(::RuntimeError) && exception.message == DeadSet::API_KILL_MESSAGE
44
+
45
+ Wurk.redis { |conn| Wurk::Unique.release_if_owner(conn, Wurk::Unique.lock_key_for(job), job['jid']) }
46
+ end
47
+
48
+ # Atomic compare-and-delete of a unique lock key. Two-command
49
+ # GET-then-DEL is not a real CAS — the key can expire between the GET
50
+ # and DEL, letting a fresh enqueue grab it, and the bare DEL would
51
+ # then drop the new owner's lock. Routed through a single Lua script
52
+ # (`Wurk::Lua::RELEASE_IF_OWNER`) shared by `ServerMiddleware#release`
53
+ # (normal success/start release) and `DEATH_HANDLER` (automatic-death
54
+ # release) so the two paths cannot drift.
55
+ def self.release_if_owner(conn, key, jid)
56
+ Wurk::Lua::Loader.eval_cached(conn, :release_if_owner, keys: [key], argv: [jid])
57
+ end
58
+
29
59
  # `Sidekiq::Enterprise.unique!` flips this on. The middleware pair is
30
60
  # always loaded (so worker `sidekiq_options unique_for:` is a no-op
31
61
  # without `unique!`) — only when the flag is set does the client
@@ -91,6 +121,8 @@ module Wurk
91
121
  unless Wurk.configuration.client_middleware.exists?(ClientMiddleware)
92
122
  Wurk.configuration.server_middleware.add(ServerMiddleware) \
93
123
  unless Wurk.configuration.server_middleware.exists?(ServerMiddleware)
124
+ handlers = Wurk.configuration.death_handlers
125
+ handlers << DEATH_HANDLER unless handlers.include?(DEATH_HANDLER)
94
126
  end
95
127
  end
96
128
 
@@ -172,7 +204,17 @@ module Wurk
172
204
  pool.with do |conn|
173
205
  return yield if conn.call('SET', key, job['jid'], 'NX', 'EX', ttl) == 'OK'
174
206
 
175
- log_duplicate(job, conn.call('GET', key))
207
+ holder = conn.call('GET', key)
208
+ # The job's own jid holding the lock is a re-push, not a duplicate:
209
+ # scheduled/retry promotion re-runs this chain while the
210
+ # enqueue-time lock is still live (§3.4/§3.7) — dropping here would
211
+ # silently lose the job.
212
+ return yield if holder == job['jid']
213
+ # nil holder: the lock expired between the failed SET NX and the
214
+ # GET — re-acquire instead of dropping.
215
+ return yield if holder.nil? && conn.call('SET', key, job['jid'], 'NX', 'EX', ttl) == 'OK'
216
+
217
+ log_duplicate(job, holder)
176
218
  end
177
219
  nil
178
220
  end
@@ -225,13 +267,13 @@ module Wurk
225
267
  VALID_UNTIL.include?(sym) ? sym : DEFAULT_UNTIL
226
268
  end
227
269
 
228
- # CAS DEL: only drop the key if the owning JID still matches ours.
229
- # Prevents a long-overdue retry from releasing a fresh lock held by
230
- # a re-enqueued duplicate after the original TTL expired.
270
+ # Atomic CAS-DEL: only drop the key if the owning JID still matches
271
+ # ours. Prevents a long-overdue retry from releasing a fresh lock
272
+ # held by a re-enqueued duplicate after the original TTL expired.
273
+ # Shares the Lua script with `DEATH_HANDLER` so both release paths
274
+ # have identical semantics.
231
275
  def release(key, jid)
232
- redis_pool.with do |conn|
233
- conn.call('DEL', key) if conn.call('GET', key) == jid
234
- end
276
+ redis_pool.with { |conn| Wurk::Unique.release_if_owner(conn, key, jid) }
235
277
  rescue StandardError => e
236
278
  Wurk.logger&.warn { "Wurk::Unique release failed: #{e.class}: #{e.message}" }
237
279
  end
data/lib/wurk/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wurk
4
- VERSION = "0.0.5"
4
+ VERSION = "1.0.0"
5
5
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../batch/status'
4
+
5
+ module Wurk
6
+ class Web
7
+ # Lightweight Rack middleware for batch-progress polling without mounting
8
+ # the full dashboard. Drop into a rack stack and app JS can drive progress
9
+ # bars off plain JSON:
10
+ #
11
+ # # config.ru
12
+ # use Sidekiq::Pro::BatchStatus
13
+ # run Rails.application
14
+ #
15
+ # `GET /batch_status/<bid>.json` → `Wurk::Batch::Status#data` JSON
16
+ # (bid, total, pending, failures, complete, created_at, description, …).
17
+ # Unknown/expired bid → 404. Every other request passes straight through.
18
+ #
19
+ # Spec: docs/target/sidekiq-pro.md §10.3.
20
+ class BatchStatus
21
+ ROUTE = %r{\A/batch_status/(?<bid>[^/]+)\.json\z}
22
+
23
+ def initialize(app)
24
+ @app = app
25
+ end
26
+
27
+ def call(env)
28
+ match = env['REQUEST_METHOD'] == 'GET' && ROUTE.match(env['PATH_INFO'])
29
+ return @app.call(env) unless match
30
+
31
+ status = Wurk::Batch::Status.new(match[:bid])
32
+ status.exists? ? json(200, status.data) : json(404, { 'error' => 'not_found' })
33
+ end
34
+
35
+ private
36
+
37
+ def json(code, payload)
38
+ [code, { 'content-type' => 'application/json' }, [Wurk.dump_json(payload)]]
39
+ end
40
+ end
41
+ end
42
+ end