wurk 1.3.1 → 1.5.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -1
  3. data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +22 -5
  4. data/lib/wurk/batch/callbacks.rb +82 -12
  5. data/lib/wurk/batch/death_handler.rb +7 -4
  6. data/lib/wurk/batch/server_middleware.rb +1 -1
  7. data/lib/wurk/batch.rb +121 -15
  8. data/lib/wurk/capsule.rb +45 -16
  9. data/lib/wurk/cli.rb +48 -14
  10. data/lib/wurk/client/buffered.rb +200 -43
  11. data/lib/wurk/client.rb +181 -38
  12. data/lib/wurk/compat.rb +1 -1
  13. data/lib/wurk/component.rb +36 -4
  14. data/lib/wurk/configuration.rb +47 -7
  15. data/lib/wurk/context.rb +1 -1
  16. data/lib/wurk/cron.rb +94 -37
  17. data/lib/wurk/deploy.rb +5 -3
  18. data/lib/wurk/embedded.rb +13 -0
  19. data/lib/wurk/engine.rb +41 -1
  20. data/lib/wurk/errors.rb +15 -0
  21. data/lib/wurk/fetcher/reaper.rb +125 -58
  22. data/lib/wurk/fetcher/reliable.rb +366 -51
  23. data/lib/wurk/fetcher.rb +6 -0
  24. data/lib/wurk/heartbeat.rb +24 -12
  25. data/lib/wurk/history.rb +13 -1
  26. data/lib/wurk/job_logger.rb +16 -7
  27. data/lib/wurk/job_set.rb +3 -2
  28. data/lib/wurk/job_util.rb +44 -24
  29. data/lib/wurk/launcher.rb +230 -119
  30. data/lib/wurk/leader.rb +63 -14
  31. data/lib/wurk/limiter/base.rb +8 -10
  32. data/lib/wurk/limiter/bucket.rb +1 -1
  33. data/lib/wurk/limiter/concurrent.rb +27 -22
  34. data/lib/wurk/limiter/window.rb +13 -11
  35. data/lib/wurk/limiter.rb +7 -4
  36. data/lib/wurk/logger.rb +1 -1
  37. data/lib/wurk/lua/loader.rb +9 -3
  38. data/lib/wurk/lua.rb +97 -14
  39. data/lib/wurk/manager.rb +29 -13
  40. data/lib/wurk/metrics/accumulator.rb +95 -0
  41. data/lib/wurk/metrics/flusher.rb +70 -0
  42. data/lib/wurk/metrics/history.rb +102 -32
  43. data/lib/wurk/metrics/queue_rollup.rb +13 -1
  44. data/lib/wurk/metrics/rollup.rb +13 -1
  45. data/lib/wurk/metrics/statsd.rb +32 -18
  46. data/lib/wurk/middleware/chain.rb +31 -14
  47. data/lib/wurk/middleware/interrupt_handler.rb +7 -6
  48. data/lib/wurk/middleware/poison_pill.rb +93 -31
  49. data/lib/wurk/middleware.rb +2 -2
  50. data/lib/wurk/pool_checkout.rb +39 -0
  51. data/lib/wurk/process_set.rb +10 -5
  52. data/lib/wurk/processor.rb +83 -9
  53. data/lib/wurk/profiler.rb +9 -4
  54. data/lib/wurk/queue.rb +18 -7
  55. data/lib/wurk/rails_boot.rb +38 -7
  56. data/lib/wurk/redis_client_adapter.rb +49 -5
  57. data/lib/wurk/redis_pool.rb +71 -25
  58. data/lib/wurk/scheduled.rb +30 -2
  59. data/lib/wurk/shutdown_gate.rb +79 -0
  60. data/lib/wurk/stats.rb +19 -10
  61. data/lib/wurk/swarm/child_boot.rb +36 -4
  62. data/lib/wurk/swarm.rb +258 -43
  63. data/lib/wurk/timer_loop.rb +14 -0
  64. data/lib/wurk/version.rb +1 -1
  65. data/lib/wurk/web/config.rb +11 -7
  66. data/lib/wurk/web/enterprise.rb +58 -6
  67. data/lib/wurk/web/extension.rb +1 -1
  68. data/lib/wurk/web/search.rb +5 -3
  69. data/lib/wurk.rb +12 -10
  70. data/vendor/assets/dashboard/assets/{ArgsValue-D74zX0MI.js → ArgsValue-CcR2ya6e.js} +1 -1
  71. data/vendor/assets/dashboard/assets/{BatchDetail-YRymNsrB.js → BatchDetail-CUXJUQ3Q.js} +1 -1
  72. data/vendor/assets/dashboard/assets/{Batches-HY4hHdQU.js → Batches-Cxan6Ngw.js} +1 -1
  73. data/vendor/assets/dashboard/assets/{Busy-FCEN1Bpx.js → Busy-DC5EGM0g.js} +1 -1
  74. data/vendor/assets/dashboard/assets/{Cron-DO3J2zcp.js → Cron-Dlt8tXJA.js} +1 -1
  75. data/vendor/assets/dashboard/assets/Dashboard-DNLu_WCg.js +1 -0
  76. data/vendor/assets/dashboard/assets/{Dead-Bi4GGk9a.js → Dead-dZ7VGlKS.js} +1 -1
  77. data/vendor/assets/dashboard/assets/Extension-DaFpEIJf.js +1 -0
  78. data/vendor/assets/dashboard/assets/{FilterBox-IJkHYpdm.js → FilterBox-CO3aYWIq.js} +1 -1
  79. data/vendor/assets/dashboard/assets/{JobDetailModal-DS1ypyoc.js → JobDetailModal-DSWbT6G0.js} +1 -1
  80. data/vendor/assets/dashboard/assets/{Limiters-Nz7UbNeJ.js → Limiters-Cb4PKXNR.js} +1 -1
  81. data/vendor/assets/dashboard/assets/Metrics-CCGzgCsT.js +1 -0
  82. data/vendor/assets/dashboard/assets/Modal-B86q6ruL.js +1 -0
  83. data/vendor/assets/dashboard/assets/{PageHeader-C44KNMGm.js → PageHeader-fPrCcp_-.js} +1 -1
  84. data/vendor/assets/dashboard/assets/{Profiles-xEVTyS2N.js → Profiles-BnS82nR_.js} +1 -1
  85. data/vendor/assets/dashboard/assets/{Queues-D9PH_THs.js → Queues-CIyPevOy.js} +1 -1
  86. data/vendor/assets/dashboard/assets/{Retries-CAKzDgYG.js → Retries-DopwXkXl.js} +1 -1
  87. data/vendor/assets/dashboard/assets/{Scheduled-DTYw1X8S.js → Scheduled-1-Z7i1zE.js} +1 -1
  88. data/vendor/assets/dashboard/assets/{Search-c4vFxDG_.js → Search-ByA6eTma.js} +1 -1
  89. data/vendor/assets/dashboard/assets/{Skeleton-DzR7XNxz.js → Skeleton-bC7HfQ9r.js} +1 -1
  90. data/vendor/assets/dashboard/assets/{charts-BVHHGof7.js → charts-CLLzJ7vK.js} +1 -1
  91. data/vendor/assets/dashboard/assets/index-B1N8hQUh.js +141 -0
  92. data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +1 -0
  93. data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-B_FsMah6.js → useResetPageOnEmpty-DpBjkf6_.js} +1 -1
  94. data/vendor/assets/dashboard/assets/{useSort-BeYbztkN.js → useSort-DvpwuNQE.js} +1 -1
  95. data/vendor/assets/dashboard/index.html +3 -3
  96. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  97. metadata +32 -27
  98. data/vendor/assets/dashboard/assets/Dashboard-B9rOrkzk.js +0 -1
  99. data/vendor/assets/dashboard/assets/Extension-BSv8ddW_.js +0 -1
  100. data/vendor/assets/dashboard/assets/Metrics-BBTDxcaE.js +0 -1
  101. data/vendor/assets/dashboard/assets/Modal-Crrsu64-.js +0 -1
  102. data/vendor/assets/dashboard/assets/index-BxjdeuOa.css +0 -1
  103. data/vendor/assets/dashboard/assets/index-DQu7WY9y.js +0 -141
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2b1f8eac5cefbd9f645598b9a37dca543aeebb361967d820f369cc14420ad3f6
4
- data.tar.gz: 813958b643c78cdd2ffa5063798bb3994f6510b1b0d50a0a54db1622f3bb325a
3
+ metadata.gz: '0995fd5827870e14584104a8998db58e9927339197460596567c05f2dacc7da0'
4
+ data.tar.gz: 8159d245826bf646a9c6025e4e06a49717f957aabe18dad5d2a5b58ed99818c0
5
5
  SHA512:
6
- metadata.gz: 65ca6b60bf079d626f3887d9e1e12b7d1d1f91ab264c5fcba7c77f54667961159fb8a63c8be89d44e9583ef7c264669e546eb15ab4bc13e392ef85b35dfea6ad
7
- data.tar.gz: b865d8cd28483589bad0f6d879d80a85c9f9d5cb9b79a494634c90ebaf0f5371169ed7012303dabdc736b32be3596dbffd2ac60b2bbeb3d24c1b40514e483b54
6
+ metadata.gz: d45c20c9a8c29695c7ea2e04066dda58454fea0ce56453ed866cd111e38977fb1739ae47429a60b68380fac2f4d7fe85f232b420f92f95cba330c389ee435b5b
7
+ data.tar.gz: 94c5599bc28408e1e5eb30967d1b0fad4806d415a17e515774c057120d0833660f3c2860d9cf0cb2ea320ae3a27bd9ce683385ce5ace1c347d46530a96cd9a1a
data/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  <p align="center"><strong>Wurk, wurk.</strong> 🪓 <em>Ready to work. Zug zug.</em></p>
8
8
 
9
- <p align="center"><strong>A 100% drop-in replacement for Sidekiq + Sidekiq Pro + Sidekiq Enterprise. Free forever. Faster.</strong></p>
9
+ <p align="center"><strong>A 100% drop-in replacement for Sidekiq + Sidekiq Pro + Sidekiq Enterprise. Free forever.</strong></p>
10
10
 
11
11
  <div align="center">
12
12
 
@@ -21,6 +21,8 @@
21
21
 
22
22
  Wurk is wire-compatible with Sidekiq — same Redis keys, same job JSON, same Ruby DSL. Swap one line in your `Gemfile` and your existing jobs, batches, limiters, cron entries, and live Redis data keep working untouched. The Pro and Enterprise feature sets ship in the same free gem, with no license check and no tiers.
23
23
 
24
+ **On speed:** Wurk is not currently faster than stock Sidekiq — it runs at roughly 0.87×–1.02× depending on workload shape, with parity on CPU and I/O but still behind on framework overhead (noop) and boot time. Numbers, method, and the reproduction command are in [docs/benchmarks.md](docs/benchmarks.md); run them yourself with `rake bench:vs_sidekiq`.
25
+
24
26
  ## Install
25
27
 
26
28
  ```ruby
@@ -61,6 +63,7 @@ Plus Wurk extras: a worker topology DSL, a Kubernetes liveness/readiness listene
61
63
  - **[Starting the worker](https://github.com/developerz-ai/wurk/blob/main/docs/running.md)** — Rails auto-start, the `wurk`/`wurkswarm` runners, and running standalone without Rails.
62
64
  - **[Configuration reference](https://github.com/developerz-ai/wurk/blob/main/docs/configuration.md)** — every option, env var, YAML key, and CLI flag, with precedence and pool sizing.
63
65
  - **[Deploying](https://github.com/developerz-ai/wurk/blob/main/docs/deployment.md)** — systemd, Capistrano, Heroku, Docker, Kubernetes, rolling restarts, memory limits.
66
+ - **[Secrets & credentials](https://github.com/developerz-ai/wurk/blob/main/docs/secrets.md)** — which values are secret vs config, how to supply them (ENV, Rails credentials, an init file), precedence, and what to never commit.
64
67
  - **[Active Job adapter](https://github.com/developerz-ai/wurk/blob/main/docs/active-job.md)** — run `ActiveJob`/`deliver_later` on Wurk with `queue_adapter = :wurk`.
65
68
  - **[Testing jobs](https://github.com/developerz-ai/wurk/blob/main/docs/testing.md)** — fake/inline modes, the jobs array, Minitest and RSpec setup.
66
69
  - **[Migrating from Sidekiq](#migrating-from-sidekiq)** — the one-line swap and what to expect.
@@ -7,28 +7,45 @@ module Wurk
7
7
  # we 503 with Retry-After — the SPA's EventSource reconnects (and its polling
8
8
  # fallback honors Retry-After) once a slot frees. Per-process is the right
9
9
  # scope: it's this process's own thread pool we're protecting.
10
+ #
11
+ # Slots are held as thread references rather than tallied in a counter so the
12
+ # cap can heal itself. A stream whose thread is killed mid-flight never
13
+ # reaches the `ensure` below (Puma hard-reaps worker threads past
14
+ # `force_shutdown_after`, and a thread killed inside an uninterruptible read
15
+ # can skip its ensure), which a counter would record as a slot held by nobody
16
+ # — ten of those and `/api/stream` 503s for the life of the process. A dead
17
+ # holder is instead evicted by the next acquire.
10
18
  module StreamConcurrencyGuard
11
19
  extend ActiveSupport::Concern
12
20
 
13
21
  MAX_CONCURRENT_STREAMS = 10
14
22
  RETRY_AFTER_SECONDS = 3
15
23
 
16
- @open = 0
24
+ @holders = []
17
25
  @lock = Mutex.new
18
26
 
19
27
  class << self
20
- # Reserve a stream slot; false when the cap is already reached.
28
+ # Reserve a stream slot for the calling thread; false when the cap is
29
+ # already reached by threads that are still alive.
21
30
  def acquire
22
31
  @lock.synchronize do
23
- return false if @open >= MAX_CONCURRENT_STREAMS
32
+ @holders.keep_if(&:alive?)
33
+ return false if @holders.size >= MAX_CONCURRENT_STREAMS
24
34
 
25
- @open += 1
35
+ @holders << Thread.current
26
36
  true
27
37
  end
28
38
  end
29
39
 
40
+ # Drops one slot held by the calling thread. Acquire and release always
41
+ # bracket a single block on one thread (`#with_stream_slot`), so a call
42
+ # from a thread holding nothing is a no-op rather than a slot taken away
43
+ # from whoever is actually streaming.
30
44
  def release
31
- @lock.synchronize { @open -= 1 if @open.positive? }
45
+ @lock.synchronize do
46
+ index = @holders.rindex(Thread.current)
47
+ @holders.delete_at(index) if index
48
+ end
32
49
  end
33
50
  end
34
51
 
@@ -26,8 +26,9 @@ module Wurk
26
26
  # parent's *own* last job acks while a child batch is still running,
27
27
  # nothing fires here; the last child's propagate_to_parent re-invokes
28
28
  # this and fires then. The SREM in pkids_drained? happens before that
29
- # re-invocation, so exactly one of the racing paths fires (dedup_set
30
- # absorbs the overlap).
29
+ # re-invocation, so at most one of the racing paths reaches a fire and
30
+ # the callback markers absorb the rest (see `fire_complete` for the one
31
+ # window that can still duplicate).
31
32
  def maybe_fire(bid, pending:, live:)
32
33
  return unless live.zero?
33
34
  return unless kids_finished?(bid)
@@ -49,15 +50,38 @@ module Wurk
49
50
  # the dedup guard so it is restored on re-death; the callback enqueue
50
51
  # and parent cascade stay behind the guard so `:death` is enqueued at
51
52
  # most once per batch.
53
+ #
54
+ # That claim-before-enqueue ordering is kept deliberately, against the
55
+ # enqueue-before-mark rule `fire_complete` explains: everything that
56
+ # makes the batch *look* dead is already persisted above the guard, so a
57
+ # crash in the window costs the notification while `Status`, the
58
+ # dashboard and `subtree_dead?` all still see a dead batch. `:complete`
59
+ # and `:success` have no such fallback — the callback is their whole
60
+ # signal — and this claim additionally gates `cascade_death`, which
61
+ # would otherwise re-walk the ancestor chain on every re-invocation.
52
62
  def fire_death(bid)
53
63
  record_event(bid, 'death_at')
54
- Wurk.redis { |conn| conn.call('ZADD', 'dead-batches', Time.now.to_f.to_s, bid) }
64
+ index_dead(bid)
55
65
  return unless dedup_set(bid, 'death')
56
66
 
57
67
  enqueue_callbacks(bid, 'death')
58
68
  cascade_death(bid)
59
69
  end
60
70
 
71
+ # Index the batch as dead and bound the set in the same round trip. The
72
+ # score stays `Time.now.to_f` (wire format, spec §2.8); `Batch.trim_index`
73
+ # reads it as the epoch seconds it is. See there for why the set needs a
74
+ # trim at all — only `Status#delete` and the death-recovery ZREM ever
75
+ # remove a member, and neither runs for a batch left to expire.
76
+ def index_dead(bid)
77
+ Wurk.redis do |conn|
78
+ conn.pipelined do |pipe|
79
+ pipe.call('ZADD', 'dead-batches', Time.now.to_f.to_s, bid)
80
+ Batch.trim_index(pipe, 'dead-batches')
81
+ end
82
+ end
83
+ end
84
+
61
85
  # A child's death means the parent — and every ancestor — can never
62
86
  # fully succeed, so `:death` propagates up the parent chain. The
63
87
  # recursion bottoms out at the root (empty parent_bid); fire_death's own
@@ -70,19 +94,45 @@ module Wurk
70
94
  fire_death(parent_bid)
71
95
  end
72
96
 
97
+ # `:complete` and `:success` mark their dedup key *after* the enqueue,
98
+ # never before (F16). Nothing re-drives a fire once the acking job's
99
+ # BATCH_ACK_SUCCESS has SREM'd its jid — that job's retry gets
100
+ # `pending == -1` and returns before maybe_fire — so a claim-then-enqueue
101
+ # ordering turns a crash in between into callbacks that are never
102
+ # enqueued by anyone, ever. Enqueuing first makes the durable side effect
103
+ # happen before the marker that suppresses it.
104
+ #
105
+ # The accepted direction is a duplicate over a lost callback: callback
106
+ # jobs retry like any other job and must already be idempotent (spec
107
+ # §2.4, §12 "Callback retries"), so firing one twice is a cost the app
108
+ # is required to absorb, while losing one silently strands the batch.
109
+ #
110
+ # `dedup_marked?` still collapses every *sequential* re-invocation — a
111
+ # reclaimed child re-running propagate_to_parent, a second DeathHandler
112
+ # pass — so the duplicate window is only two genuinely concurrent acks
113
+ # interleaving between each other's check and mark.
114
+ #
115
+ # `record_event` stays ahead of the enqueue: the callback job reads a
116
+ # Status snapshot and must see `complete_at`/`success_at` already set.
73
117
  def fire_complete(bid)
74
- return unless dedup_set(bid, 'complete')
118
+ return if dedup_marked?(bid, 'complete')
75
119
 
76
120
  record_event(bid, 'complete_at')
77
121
  enqueue_callbacks(bid, 'complete')
122
+ dedup_set(bid, 'complete')
78
123
  end
79
124
 
125
+ # Same enqueue-then-mark ordering as fire_complete. `apply_linger` runs
126
+ # last of all: it EXPIREs `b-<bid>-success` down to the linger window,
127
+ # which only holds if the marker already exists — `dedup_set`'s 30d
128
+ # `EX` would otherwise re-create it outside that window.
80
129
  def fire_success(bid)
81
- return unless dedup_set(bid, 'success')
130
+ return if dedup_marked?(bid, 'success')
82
131
 
83
132
  record_event(bid, 'success_at')
84
133
  emit_duration_metric(bid)
85
134
  enqueue_callbacks(bid, 'success')
135
+ dedup_set(bid, 'success')
86
136
  apply_linger(bid)
87
137
  end
88
138
 
@@ -90,10 +140,11 @@ module Wurk
90
140
  # full success. `created_at` shares the CLOCK_REALTIME epoch we record it
91
141
  # with. No-op without a dogstatsd client.
92
142
  #
93
- # Strictly best-effort: `fire_success` has already burned the `success`
94
- # dedup key by the time we run, so a raise here (e.g. a Redis hiccup on the
95
- # HGET) would permanently strand the success callbacks and linger that
96
- # followa retry can't re-fire them. Swallow and log instead.
143
+ # Strictly best-effort: this runs on the acking job's thread ahead of the
144
+ # enqueue, and that ack already removed the jid, so a raise here (e.g. a
145
+ # Redis hiccup on the HGET) would abort `fire_success` with nothing left
146
+ # to re-drive it the success callbacks and linger would be stranded for
147
+ # good. Swallow and log instead.
97
148
  def emit_duration_metric(bid)
98
149
  created = Wurk.redis { |conn| conn.call('HGET', "b-#{bid}", 'created_at') }
99
150
  return if created.nil? || created.to_s.empty?
@@ -116,9 +167,22 @@ module Wurk
116
167
  end
117
168
  end
118
169
 
119
- # Atomically marks `bid` as having fired `event`. Returns true the
120
- # first time, false thereafter caller skips the enqueue when false.
121
- # SET NX makes this safe under racing acks.
170
+ # True once `b-<bid>-<event>` exists, i.e. an enqueue pass for `event`
171
+ # has completed. The read-side half of the enqueue-then-mark ordering in
172
+ # `fire_complete`/`fire_success`; `fire_death` needs no equivalent
173
+ # because its `dedup_set` still doubles as the claim.
174
+ def dedup_marked?(bid, event)
175
+ Wurk.redis { |conn| conn.call('EXISTS', "b-#{bid}-#{event}") }.to_i == 1
176
+ end
177
+
178
+ # Writes `b-<bid>-<event>`, the marker that `event`'s callbacks have been
179
+ # enqueued. Returns true when this call created it, false when it was
180
+ # already there.
181
+ #
182
+ # Two usages, deliberately different: `fire_death` calls it *before* its
183
+ # enqueue and treats the return as a claim (at most once); `fire_complete`
184
+ # and `fire_success` call it *after* theirs and ignore the return, gating
185
+ # on `dedup_marked?` instead. SET NX keeps both safe under racing acks.
122
186
  def dedup_set(bid, event)
123
187
  Wurk.redis do |conn|
124
188
  ok = conn.call('SET', "b-#{bid}-#{event}", '1', 'NX', 'EX', Batch::CALLBACK_NOTIFY_TTL)
@@ -126,11 +190,17 @@ module Wurk
126
190
  end
127
191
  end
128
192
 
193
+ # The HSETs resurrect the hash when a callback fires for a batch whose keys
194
+ # already expired (a child batch outliving its parent's 30d window), so the
195
+ # write is followed by an NX stamp — without it the resurrected hash would
196
+ # have no clock at all. NX leaves a live batch's expiry, and the shorter
197
+ # post-success `linger` window, untouched.
129
198
  def record_event(bid, field)
130
199
  now = ::Process.clock_gettime(::Process::CLOCK_REALTIME)
131
200
  Wurk.redis do |conn|
132
201
  conn.call('HSET', "b-#{bid}", field, now.to_s)
133
202
  conn.call('HSET', "b-#{bid}", field.to_s.sub('_at', ''), '1')
203
+ conn.call('EXPIRE', "b-#{bid}", Batch::DEFAULT_EXPIRY_SECONDS, 'NX')
134
204
  end
135
205
  end
136
206
 
@@ -21,7 +21,7 @@ module Wurk
21
21
  conn,
22
22
  :batch_ack_complete,
23
23
  keys: ["b-#{bid}", "b-#{bid}-jids", "b-#{bid}-died", "b-#{bid}-failed"],
24
- argv: [job['jid']]
24
+ argv: [job['jid'], Batch::DEFAULT_EXPIRY_SECONDS]
25
25
  )
26
26
  end
27
27
  live, _died, first_death = Array(result).map(&:to_i)
@@ -38,9 +38,12 @@ module Wurk
38
38
  Wurk::Batch::Callbacks.maybe_fire(bid, pending: Wurk::Batch::Callbacks.pending_for(bid), live: 0)
39
39
  end
40
40
 
41
- # A stale job can die AFTER its batch keys expired; the ack writes
42
- # recreate them with no TTL permanent key leakage. EXPIRE NX stamps
43
- # only keys that lost their TTL, leaving live batches' clocks alone.
41
+ # BATCH_ACK_COMPLETE stamps the two keys it can itself resurrect; this
42
+ # sweeps the rest of the batch (`-jids`, `-failed`, `-kids`, `-pkids`,
43
+ # callback markers). A death is the one moment we know the batch is
44
+ # winding down, so it is worth a round trip to leave nothing without a
45
+ # clock. EXPIRE NX touches only keys that have none, so a live batch's
46
+ # clock and a post-success `linger` window both survive.
44
47
  def self.restamp_ttls(bid)
45
48
  Wurk.redis do |conn|
46
49
  conn.pipelined do |pipe|
@@ -85,7 +85,7 @@ module Wurk
85
85
  conn,
86
86
  :batch_ack_failed,
87
87
  keys: ["b-#{bid}", "b-#{bid}-failed"],
88
- argv: [jid]
88
+ argv: [jid, Batch::DEFAULT_EXPIRY_SECONDS]
89
89
  )
90
90
  end
91
91
  end
data/lib/wurk/batch.rb CHANGED
@@ -41,6 +41,22 @@ module Wurk
41
41
  POST_SUCCESS_EXPIRY_SECONDS = 24 * 60 * 60
42
42
  CALLBACK_NOTIFY_TTL = 30 * 24 * 60 * 60
43
43
 
44
+ # Member ceiling for the two batch index ZSETs (`batches`, `dead-batches`).
45
+ # The score axis in `.trim_index` retires entries in step with the batch data
46
+ # itself, so this is only the backstop for a workload creating batches faster
47
+ # than that window retires them. Deliberately generous: a cap that bites drops
48
+ # batches whose data is still live out of `BatchSet`, and at this scale the
49
+ # per-batch hashes dwarf the index anyway.
50
+ INDEX_MAX = 1_000_000
51
+
52
+ # Ceiling on the `callbacks` array of one batch hash, enforced by
53
+ # BATCH_APPEND_CALLBACK. Every registration re-encodes the whole array,
54
+ # and every entry becomes a callback job when the event fires, so an
55
+ # unbounded array is both a hot-path cost and a fan-out. Far above any
56
+ # legitimate batch — real ones register a handful — so hitting it means a
57
+ # loop is registering callbacks it should have registered once.
58
+ CALLBACKS_MAX = 1_000
59
+
44
60
  # Bid is URL-safe base64 of 10 random bytes — matches Sidekiq Pro's BID
45
61
  # generator. Length matters: third-party gems that key off bid prefix
46
62
  # (sharded batches in Pro 8) inspect the first character.
@@ -48,10 +64,21 @@ module Wurk
48
64
 
49
65
  VALID_EVENTS = %i[success complete death].freeze
50
66
 
67
+ # Every key a batch owns, for the sweep paths: `Status#delete` (UNLINK),
68
+ # `Callbacks#apply_linger` and `DeathHandler.restamp_ttls` (EXPIRE).
69
+ #
51
70
  # The 'live' set tracks jobs that have not yet reached a terminal state.
52
71
  # When it's empty, every job has either succeeded or died → `:complete`
53
72
  # is allowed to fire.
54
- KEY_SUFFIXES = %w[jids failed died notify cbsucc kids pkids tags].freeze
73
+ #
74
+ # `complete`/`success`/`death` are the callback dedup markers written by
75
+ # `Callbacks#dedup_set`; they belong to the batch and must die with it.
76
+ # `notify`/`cbsucc`/`tags` are Sidekiq Pro's own key layout (spec §2.8) that
77
+ # Wurk never writes — Wurk dedups on the three markers above and indexes
78
+ # tags at `tags:<tag>`. They stay listed so a Redis dataset carried over
79
+ # from Sidekiq Pro on the gem swap gets swept too; EXPIRE/UNLINK of a
80
+ # missing key is a no-op for batches Wurk created itself.
81
+ KEY_SUFFIXES = %w[jids failed died complete success death notify cbsucc kids pkids tags].freeze
55
82
 
56
83
  THREAD_KEY = :wurk_current_batch
57
84
 
@@ -68,6 +95,33 @@ module Wurk
68
95
  [base, *KEY_SUFFIXES.map { |s| "#{base}-#{s}" }]
69
96
  end
70
97
 
98
+ # Two-axis trim of a batch index ZSET (`batches`, `dead-batches`), in the
99
+ # shape of the morgue trim (`DeadSet#trim`): `ZREMRANGEBYSCORE` evicts
100
+ # entries older than `timeout`, `ZREMRANGEBYRANK 0 -max` caps the member
101
+ # count — and, like the morgue, that bound keeps `max - 1` of a full set.
102
+ # Appended to the caller's pipeline so bounding the index costs neither
103
+ # writer an extra round trip.
104
+ #
105
+ # Nothing else ever shrinks either set: `Status#delete` and the
106
+ # death-recovery `ZREM` are manual, so an index entry outlives the batch it
107
+ # points at and both sets grow for the life of the Redis without this.
108
+ #
109
+ # Both index in epoch seconds — `batches` from CLOCK_REALTIME,
110
+ # `dead-batches` from `Time.now.to_f` — so one cutoff serves both. The
111
+ # default window is the batch hash TTL: past it `b-<bid>` is gone and the
112
+ # entry only yields an empty Status. A batch that overrode `expires_in`
113
+ # beyond that window outlives its index entry — still reachable by bid,
114
+ # just no longer enumerated by `BatchSet`.
115
+ #
116
+ # `max:` / `timeout:` override the defaults for one call, so parallel tests
117
+ # can drive the trim on isolated limits without mutating the process-global
118
+ # `Wurk.configuration`.
119
+ def self.trim_index(pipe, key, max: nil, timeout: nil)
120
+ cutoff = ::Process.clock_gettime(::Process::CLOCK_REALTIME) - (timeout || DEFAULT_EXPIRY_SECONDS)
121
+ pipe.call('ZREMRANGEBYSCORE', key, '-inf', "(#{cutoff}")
122
+ pipe.call('ZREMRANGEBYRANK', key, 0, -(max || INDEX_MAX))
123
+ end
124
+
71
125
  def initialize(bid = nil)
72
126
  @bid = bid || SecureRandom.urlsafe_base64(BID_BYTES)
73
127
  @existing = !bid.nil?
@@ -79,6 +133,11 @@ module Wurk
79
133
  @linger = nil
80
134
  @parent_bid = nil
81
135
  @callbacks = []
136
+ # Dedup index over `@callbacks`, keyed on the encoded entry. Only the
137
+ # pre-flush staging path feeds it — once flushed, Redis holds the array
138
+ # and BATCH_APPEND_CALLBACK does the deduping — so a batch reopened by
139
+ # bid never pays to build it.
140
+ @callback_index = Set.new
82
141
  @expires_in = DEFAULT_EXPIRY_SECONDS
83
142
  @mutable = !@existing
84
143
  @flushed_once = @existing
@@ -166,17 +225,22 @@ module Wurk
166
225
  self
167
226
  end
168
227
 
169
- # Register a callback. Multiple callbacks of the same event are allowed.
170
- # The callback target may be a Class, "Foo#bar" string spec, or anything
171
- # responding to `name`. `options` must be JSON-serializable.
228
+ # Register a callback. Any number of *distinct* callbacks may be attached
229
+ # to one event; re-registering an identical `[event, target, options]`
230
+ # triple is a no-op, and past `CALLBACKS_MAX` entries the registration is
231
+ # dropped with a warning. The callback target may be a Class, "Foo#bar"
232
+ # string spec, or anything responding to `name`. `options` must be
233
+ # JSON-serializable.
172
234
  def on(event, callback, options = {})
173
235
  sym = event.to_sym
174
236
  raise ArgumentError, "invalid event #{event.inspect}" unless VALID_EVENTS.include?(sym)
175
237
  raise ArgumentError, 'callback options must be a Hash' unless options.is_a?(Hash)
176
238
 
177
239
  entry = [sym.to_s, callback_target(callback), options]
178
- @callbacks << entry
179
- persist_callback!(entry) if @flushed_once
240
+ # Before the first flush the array lives only in memory; after it, Redis
241
+ # is authoritative and `@callbacks` is a stale mirror nothing reads —
242
+ # appending to it there would just leak one entry per registration.
243
+ @flushed_once ? persist_callback!(entry) : stage_callback(entry)
180
244
  self
181
245
  end
182
246
 
@@ -246,22 +310,52 @@ module Wurk
246
310
  Wurk::Client.new.flush_batched(payloads) unless payloads.empty?
247
311
  end
248
312
 
313
+ # Pre-flush counterpart to `persist_callback!`. Entries registered before
314
+ # the first flush only exist in memory until `first_flush_hash` writes the
315
+ # whole array in one HSET, so BATCH_APPEND_CALLBACK never sees them — the
316
+ # same dedup and cap have to be applied here or the very first write can
317
+ # already ship duplicates and an unbounded array.
318
+ #
319
+ # Keyed on the encoded entry, which is what actually lands in the hash:
320
+ # `{a: 1}` and `{'a' => 1}` are one callback once persisted, so they must
321
+ # be one entry here too.
322
+ def stage_callback(entry)
323
+ json = entry.to_json
324
+ return if @callback_index.include?(json)
325
+
326
+ if @callbacks.size >= CALLBACKS_MAX
327
+ Wurk.logger.warn("batch #{@bid}: #{entry[0]} callback dropped — #{CALLBACKS_MAX} callback limit reached")
328
+ return
329
+ end
330
+
331
+ @callbacks << entry
332
+ @callback_index << json
333
+ end
334
+
249
335
  # Like `linger=`, anything registered after the first flush must reach
250
336
  # Redis — `Callbacks.enqueue_callbacks` reads specs from the hash, so an
251
337
  # in-memory-only append would silently never fire (#213). Covers both
252
338
  # `on` after `#jobs` and batches reopened by bid. The append runs
253
339
  # server-side (Lua) so concurrent registrations from different processes
254
- # can't lose each other to a read-modify-write race.
340
+ # can't lose each other to a read-modify-write race, and it dedups
341
+ # identical triples so the reopen-per-job shape stops growing the array.
255
342
  def persist_callback!(entry)
256
343
  event = entry[0]
257
- fired = Wurk.redis do |conn|
344
+ status = Wurk.redis do |conn|
258
345
  Wurk::Lua::Loader.eval_cached(conn, :batch_append_callback,
259
- keys: ["b-#{@bid}"], argv: [entry.to_json, event])
346
+ keys: ["b-#{@bid}"], argv: [entry.to_json, event, CALLBACKS_MAX])
347
+ end
348
+ raise ArgumentError, "cannot register #{event} callback: batch #{@bid} no longer exists" if status == -1
349
+
350
+ # Sentinels are Integers, the fired flag is the String the `<event>`
351
+ # hash field holds — asymmetric on purpose, so a sentinel can never be
352
+ # read as a fired event.
353
+ case status
354
+ when -2
355
+ Wurk.logger.warn("batch #{@bid}: #{event} callback dropped — #{CALLBACKS_MAX} callback limit reached")
356
+ when '1'
357
+ Wurk.logger.warn("batch #{@bid}: #{event} callback registered after #{event} already fired — it will never run")
260
358
  end
261
- raise ArgumentError, "cannot register #{event} callback: batch #{@bid} no longer exists" if fired == -1
262
- return unless fired == '1'
263
-
264
- Wurk.logger.warn("batch #{@bid}: #{event} callback registered after #{event} already fired — it will never run")
265
359
  end
266
360
 
267
361
  # First flush writes the core hash, registers in the global `batches`
@@ -278,10 +372,15 @@ module Wurk
278
372
  Wurk::Metrics::Statsd.increment('batch.created')
279
373
  end
280
374
 
375
+ # Only `b-#{@bid}` is stamped here — none of the sub-keys exist yet at first
376
+ # flush (BATCH_PUSH/BATCH_SCHEDULE create `-jids`, the acks create
377
+ # `-failed`/`-died`), and EXPIRE on a missing key is a no-op. Each key is
378
+ # stamped `NX` where it is created instead; see BATCH_PUSH in lua.rb.
281
379
  def pipelined_first_flush(pipe, now)
282
380
  pipe.call('HSET', "b-#{@bid}", *first_flush_hash(now).flatten)
283
381
  pipe.call('EXPIRE', "b-#{@bid}", @expires_in)
284
382
  pipe.call('ZADD', 'batches', now.to_s, @bid)
383
+ Batch.trim_index(pipe, 'batches')
285
384
  @tags.each { |t| pipe.call('SADD', "tags:#{t}", @bid) }
286
385
  link_to_parent(pipe) if current_parent_bid
287
386
  end
@@ -312,9 +411,16 @@ module Wurk
312
411
  outer.bid
313
412
  end
314
413
 
414
+ # The only place `-kids`/`-pkids` are created, so this is where they get
415
+ # their clock. TTL is the default, not this batch's `@expires_in`: the keys
416
+ # belong to the *parent*, and NX means the first link sets the retention for
417
+ # every sibling that follows.
315
418
  def link_to_parent(pipe)
316
- pipe.call('SADD', "b-#{current_parent_bid}-kids", @bid)
317
- pipe.call('SADD', "b-#{current_parent_bid}-pkids", @bid)
419
+ parent_key = "b-#{current_parent_bid}"
420
+ pipe.call('SADD', "#{parent_key}-kids", @bid)
421
+ pipe.call('SADD', "#{parent_key}-pkids", @bid)
422
+ pipe.call('EXPIRE', "#{parent_key}-kids", DEFAULT_EXPIRY_SECONDS, 'NX')
423
+ pipe.call('EXPIRE', "#{parent_key}-pkids", DEFAULT_EXPIRY_SECONDS, 'NX')
318
424
  end
319
425
 
320
426
  def job_count
data/lib/wurk/capsule.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'redis_pool'
4
+ require_relative 'pool_checkout'
4
5
  require_relative 'middleware/chain'
5
6
  require_relative 'fetcher/reliable'
6
7
 
@@ -16,6 +17,11 @@ module Wurk
16
17
  attr_reader :name, :queues, :mode, :weights, :config
17
18
  attr_accessor :concurrency, :fetcher
18
19
 
20
+ # Capsule-hosted components (Manager, Processor, Fetcher) hand their capsule
21
+ # to Component as `config`, and `safe_thread` reads the priority off it.
22
+ # Sidekiq delegates the same accessor (capsule.rb:30).
23
+ def thread_priority = @config.thread_priority
24
+
19
25
  def initialize(name, config)
20
26
  @name = name.to_s
21
27
  @config = config
@@ -24,8 +30,15 @@ module Wurk
24
30
  @mode = :strict
25
31
  @weights = { 'default' => 0 }
26
32
  @fetcher = nil
27
- @redis_pool = nil
28
- @fetch_redis_pool = nil
33
+ # One mutable Hash rather than two ivars: the pools are the only part of a
34
+ # capsule that legitimately changes after Configuration#freeze! — fork
35
+ # closes them, Launcher#stop releases them, an embedded host that boots
36
+ # again rebuilds them. `Object#freeze` is shallow, so the Hash stays
37
+ # writable and freezing a capsule keeps meaning "no more configuration"
38
+ # instead of "these sockets are yours forever". Before this, a reset on a
39
+ # frozen capsule disconnected the pool and then raised FrozenError on the
40
+ # memo, leaving `redis_pool` answering with a shut-down pool for good.
41
+ @pools = {}
29
42
  @client_chain = nil
30
43
  @server_chain = nil
31
44
  end
@@ -63,9 +76,26 @@ module Wurk
63
76
  # by hand; centralizing it here covers the standalone CLI and embedded
64
77
  # paths too (the bug behind a nil `fetcher` in `exe/wurk`). Idempotent.
65
78
  def prepare!
79
+ prepare_shared!
66
80
  @fetcher ||= build_fetcher
67
81
  redis_pool
68
82
  fetch_redis_pool
83
+ self
84
+ end
85
+
86
+ # The half of `prepare!` a forking parent can run on every child's behalf:
87
+ # the chains are a pure function of this capsule's identity, not of the slot
88
+ # (queues + concurrency) a swarm child is assigned later, and `copy_for`
89
+ # opens nothing. Run before the fork, the entries are allocated once and
90
+ # inherited copy-on-write instead of rebuilt in every child.
91
+ #
92
+ # The rest of `prepare!` deliberately stays post-fork: both pools are sized
93
+ # off the slot's concurrency and one built here would hand every child an
94
+ # inherited socket, and `build_fetcher` fires the host's
95
+ # `config[:fetch_setup]` hook, which is per-child — running it in the parent
96
+ # would let a custom fetcher snapshot the wrong queues, or leak whatever the
97
+ # hook opened across the fork. Idempotent.
98
+ def prepare_shared!
69
99
  client_middleware
70
100
  server_middleware
71
101
  self
@@ -98,7 +128,7 @@ module Wurk
98
128
  MIN_POOL_SIZE = 10
99
129
 
100
130
  def redis_pool
101
- @redis_pool ||= build_pool(size: main_pool_size, name: "#{@name}-main")
131
+ @pools[:main] ||= build_pool(size: main_pool_size, name: "#{@name}-main")
102
132
  end
103
133
 
104
134
  # Dedicated pool for the reliable fetcher's blocking BLMOVE: one slot per
@@ -106,28 +136,27 @@ module Wurk
106
136
  # fetch at once. Keeping fetch off the main pool is what lets an idle worker
107
137
  # hold zero main-pool connections again.
108
138
  def fetch_redis_pool
109
- @fetch_redis_pool ||= build_pool(size: @concurrency, name: "#{@name}-fetch")
139
+ @pools[:fetch] ||= build_pool(size: @concurrency, name: "#{@name}-fetch")
110
140
  end
111
141
 
112
- # Disconnect and drop cached pools. Called by Wurk::Swarm just before
113
- # fork (parent side: close inherited sockets) and just after fork
114
- # (child side: rebuild lazily). Connection_pool#shutdown is terminal,
115
- # so dropping the reference is required — `redis_pool` will rebuild.
142
+ # Disconnect and drop cached pools. Called by Wurk::Swarm just before fork
143
+ # (parent side: close inherited sockets), just after fork (child side:
144
+ # rebuild lazily), and by Launcher#stop (release what this process held).
145
+ # Connection_pool#shutdown is terminal, so dropping the reference is
146
+ # required — `redis_pool` will rebuild.
116
147
  def reset_redis_pools!
117
- @redis_pool&.disconnect!
118
- @redis_pool = nil
119
- @fetch_redis_pool&.disconnect!
120
- @fetch_redis_pool = nil
148
+ @pools.each_value(&:disconnect!)
149
+ @pools.clear
121
150
  end
122
151
 
123
- def redis(&)
124
- redis_pool.with(&)
152
+ def redis(idempotent: false, &)
153
+ PoolCheckout.with(redis_pool, idempotent, &)
125
154
  end
126
155
 
127
156
  # Checkout from the dedicated fetch pool. Only the reliable fetcher's
128
157
  # blocking BLMOVE uses this, so a parked fetch never holds a main-pool slot.
129
- def fetch_redis(&)
130
- fetch_redis_pool.with(&)
158
+ def fetch_redis(idempotent: false, &)
159
+ PoolCheckout.with(fetch_redis_pool, idempotent, &)
131
160
  end
132
161
 
133
162
  def lookup(name)