wurk 1.3.1 → 1.4.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/lib/wurk/batch/callbacks.rb +82 -12
  4. data/lib/wurk/batch/death_handler.rb +7 -4
  5. data/lib/wurk/batch/server_middleware.rb +1 -1
  6. data/lib/wurk/batch.rb +121 -15
  7. data/lib/wurk/capsule.rb +5 -4
  8. data/lib/wurk/cli.rb +48 -14
  9. data/lib/wurk/client/buffered.rb +193 -43
  10. data/lib/wurk/client.rb +87 -14
  11. data/lib/wurk/compat.rb +1 -1
  12. data/lib/wurk/component.rb +2 -2
  13. data/lib/wurk/configuration.rb +3 -2
  14. data/lib/wurk/cron.rb +94 -37
  15. data/lib/wurk/deploy.rb +5 -3
  16. data/lib/wurk/embedded.rb +13 -0
  17. data/lib/wurk/fetcher/reaper.rb +113 -56
  18. data/lib/wurk/fetcher/reliable.rb +62 -9
  19. data/lib/wurk/heartbeat.rb +22 -10
  20. data/lib/wurk/history.rb +13 -1
  21. data/lib/wurk/launcher.rb +133 -66
  22. data/lib/wurk/leader.rb +29 -10
  23. data/lib/wurk/limiter/base.rb +8 -10
  24. data/lib/wurk/limiter/bucket.rb +1 -1
  25. data/lib/wurk/limiter/concurrent.rb +27 -22
  26. data/lib/wurk/limiter/window.rb +13 -11
  27. data/lib/wurk/limiter.rb +7 -4
  28. data/lib/wurk/lua.rb +97 -14
  29. data/lib/wurk/manager.rb +29 -13
  30. data/lib/wurk/metrics/history.rb +4 -3
  31. data/lib/wurk/metrics/queue_rollup.rb +13 -1
  32. data/lib/wurk/metrics/rollup.rb +13 -1
  33. data/lib/wurk/middleware/interrupt_handler.rb +7 -6
  34. data/lib/wurk/middleware/poison_pill.rb +70 -29
  35. data/lib/wurk/middleware.rb +2 -2
  36. data/lib/wurk/pool_checkout.rb +29 -0
  37. data/lib/wurk/process_set.rb +10 -5
  38. data/lib/wurk/processor.rb +6 -0
  39. data/lib/wurk/profiler.rb +3 -2
  40. data/lib/wurk/queue.rb +10 -7
  41. data/lib/wurk/rails_boot.rb +38 -7
  42. data/lib/wurk/redis_client_adapter.rb +48 -4
  43. data/lib/wurk/redis_pool.rb +70 -25
  44. data/lib/wurk/scheduled.rb +30 -2
  45. data/lib/wurk/stats.rb +14 -9
  46. data/lib/wurk/swarm/child_boot.rb +12 -0
  47. data/lib/wurk/swarm.rb +174 -33
  48. data/lib/wurk/timer_loop.rb +14 -0
  49. data/lib/wurk/version.rb +1 -1
  50. data/lib/wurk/web/enterprise.rb +58 -6
  51. data/lib/wurk/web/extension.rb +1 -1
  52. data/lib/wurk/web/search.rb +5 -3
  53. data/lib/wurk.rb +10 -2
  54. data/vendor/assets/dashboard/assets/{BatchDetail-YRymNsrB.js → BatchDetail-OmC5NPgw.js} +1 -1
  55. data/vendor/assets/dashboard/assets/{Batches-HY4hHdQU.js → Batches-CIpai7St.js} +1 -1
  56. data/vendor/assets/dashboard/assets/{Busy-FCEN1Bpx.js → Busy-A_kwSR6Q.js} +1 -1
  57. data/vendor/assets/dashboard/assets/{Cron-DO3J2zcp.js → Cron-BG7HTqlp.js} +1 -1
  58. data/vendor/assets/dashboard/assets/{Dashboard-B9rOrkzk.js → Dashboard-A_ToqHoo.js} +1 -1
  59. data/vendor/assets/dashboard/assets/{Dead-Bi4GGk9a.js → Dead-8J21jMyK.js} +1 -1
  60. data/vendor/assets/dashboard/assets/Extension-B4Q9FIQu.js +1 -0
  61. data/vendor/assets/dashboard/assets/{FilterBox-IJkHYpdm.js → FilterBox-Fh_Ae7UW.js} +1 -1
  62. data/vendor/assets/dashboard/assets/{JobDetailModal-DS1ypyoc.js → JobDetailModal-Ceng0PMB.js} +1 -1
  63. data/vendor/assets/dashboard/assets/{Limiters-Nz7UbNeJ.js → Limiters-CruDWvNZ.js} +1 -1
  64. data/vendor/assets/dashboard/assets/{Metrics-BBTDxcaE.js → Metrics-CIT7VCoN.js} +1 -1
  65. data/vendor/assets/dashboard/assets/Modal-CN3rdKA_.js +1 -0
  66. data/vendor/assets/dashboard/assets/{Queues-D9PH_THs.js → Queues-D86FYohJ.js} +1 -1
  67. data/vendor/assets/dashboard/assets/{Retries-CAKzDgYG.js → Retries-Bz1O1D-i.js} +1 -1
  68. data/vendor/assets/dashboard/assets/{Scheduled-DTYw1X8S.js → Scheduled-B6h2akTu.js} +1 -1
  69. data/vendor/assets/dashboard/assets/{Search-c4vFxDG_.js → Search-OOu22e5s.js} +1 -1
  70. data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +1 -0
  71. data/vendor/assets/dashboard/assets/index-D_lSDwKw.js +141 -0
  72. data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-B_FsMah6.js → useResetPageOnEmpty-dVPGEWzn.js} +1 -1
  73. data/vendor/assets/dashboard/index.html +2 -2
  74. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  75. metadata +21 -20
  76. data/vendor/assets/dashboard/assets/Extension-BSv8ddW_.js +0 -1
  77. data/vendor/assets/dashboard/assets/Modal-Crrsu64-.js +0 -1
  78. data/vendor/assets/dashboard/assets/index-BxjdeuOa.css +0 -1
  79. 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: 6ec30c1dfc5983b7182b0f5765ec40acd60d9682a8ef69dd2f5f5abb547c9a59
4
+ data.tar.gz: 1d6a8578a6743782463582302d240a3be94d4474a2b1ed8c26209ebcc78c2836
5
5
  SHA512:
6
- metadata.gz: 65ca6b60bf079d626f3887d9e1e12b7d1d1f91ab264c5fcba7c77f54667961159fb8a63c8be89d44e9583ef7c264669e546eb15ab4bc13e392ef85b35dfea6ad
7
- data.tar.gz: b865d8cd28483589bad0f6d879d80a85c9f9d5cb9b79a494634c90ebaf0f5371169ed7012303dabdc736b32be3596dbffd2ac60b2bbeb3d24c1b40514e483b54
6
+ metadata.gz: 529fb49080a16a23f3834b99f37d4f34dd2d080e772529cb9afff46f1f0ca1d9694b35804d1a538fdf7ccd9a59e297c0ab3bd712792874fe37a05792fb991a3b
7
+ data.tar.gz: a75598d81e4182d798fbf220febbf50c2960134f3273f238f4182fcf4f03baac65613cfc3efdbeb871e14a38f0d20a775b90962315a8a21473a44f5cb4d0c037
data/README.md CHANGED
@@ -61,6 +61,7 @@ Plus Wurk extras: a worker topology DSL, a Kubernetes liveness/readiness listene
61
61
  - **[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
62
  - **[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
63
  - **[Deploying](https://github.com/developerz-ai/wurk/blob/main/docs/deployment.md)** — systemd, Capistrano, Heroku, Docker, Kubernetes, rolling restarts, memory limits.
64
+ - **[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
65
  - **[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
66
  - **[Testing jobs](https://github.com/developerz-ai/wurk/blob/main/docs/testing.md)** — fake/inline modes, the jobs array, Minitest and RSpec setup.
66
67
  - **[Migrating from Sidekiq](#migrating-from-sidekiq)** — the one-line swap and what to expect.
@@ -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
 
@@ -120,14 +121,14 @@ module Wurk
120
121
  @fetch_redis_pool = nil
121
122
  end
122
123
 
123
- def redis(&)
124
- redis_pool.with(&)
124
+ def redis(idempotent: false, &)
125
+ PoolCheckout.with(redis_pool, idempotent, &)
125
126
  end
126
127
 
127
128
  # Checkout from the dedicated fetch pool. Only the reliable fetcher's
128
129
  # blocking BLMOVE uses this, so a parked fetch never holds a main-pool slot.
129
- def fetch_redis(&)
130
- fetch_redis_pool.with(&)
130
+ def fetch_redis(idempotent: false, &)
131
+ PoolCheckout.with(fetch_redis_pool, idempotent, &)
131
132
  end
132
133
 
133
134
  def lookup(name)
data/lib/wurk/cli.rb CHANGED
@@ -106,16 +106,24 @@ module Wurk
106
106
  enter_server_mode
107
107
  boot_application if boot_app
108
108
  self_read, self_write = ::IO.pipe
109
- trap_signals(self_write)
110
- validate_redis!
111
- validate_pool_sizes!
112
- @config[:identity] = identity
113
- # Force lazy server-middleware chain so worker threads don't race
114
- # against each other constructing it. Spec: Sidekiq::CLI line 104.
115
- @config.server_middleware
116
- ::Process.warmup if warmup && ::Process.respond_to?(:warmup) && ENV['RUBY_DISABLE_WARMUP'] != '1'
117
- fire_event(:startup, reverse: false, reraise: true)
118
- launch(self_read)
109
+ begin
110
+ trap_signals(self_write)
111
+ validate_redis!
112
+ validate_pool_sizes!
113
+ @config[:identity] = identity
114
+ # Force lazy server-middleware chain so worker threads don't race
115
+ # against each other constructing it. Spec: Sidekiq::CLI line 104.
116
+ @config.server_middleware
117
+ warm_up_process if warmup
118
+ fire_event(:startup, reverse: false, reraise: true)
119
+ launch(self_read)
120
+ ensure
121
+ # Every exit path — clean return, a raise out of validate/startup, and
122
+ # the SystemExit that `launch` unwinds on Interrupt. Embedded and test
123
+ # callers survive `run`, so a leaked pair is a real FD leak.
124
+ self_read.close
125
+ self_write.close
126
+ end
119
127
  end
120
128
 
121
129
  # Standalone multi-process boot — the `sidekiqswarm` entry point (Ent §7).
@@ -141,11 +149,18 @@ module Wurk
141
149
  validate_redis!
142
150
  validate_pool_sizes!
143
151
  @config[:identity] = identity
144
- ::Process.warmup if warmup && ::Process.respond_to?(:warmup) && ENV['RUBY_DISABLE_WARMUP'] != '1'
152
+ warm_up_process if warmup
145
153
  @swarm = Wurk::Swarm.new(topology: @config.topology, config: @config,
146
154
  shutdown_timeout: @config[:timeout] || Swarm::DEFAULT_SHUTDOWN_TIMEOUT)
147
- @swarm.boot(install_signals: true)
148
- @swarm.supervise
155
+ begin
156
+ @swarm.boot(install_signals: true)
157
+ @swarm.supervise
158
+ ensure
159
+ # A fork failure part-way through `boot`, or anything raising out of
160
+ # `supervise`, otherwise leaves live children behind with no supervisor.
161
+ # A no-op once the loop already drained (no children, pipe closed).
162
+ @swarm.shutdown
163
+ end
149
164
  end
150
165
 
151
166
  def handle_signal(sig)
@@ -164,6 +179,15 @@ module Wurk
164
179
  Wurk.enter_server_mode(@config)
165
180
  end
166
181
 
182
+ # Pre-fault and compact the heap before workers start — in `run_swarm` that
183
+ # happens before the fork, so children share the warmed pages copy-on-write.
184
+ # RUBY_DISABLE_WARMUP=1 is the escape hatch for hosts that already warmed up.
185
+ def warm_up_process
186
+ return unless ::Process.respond_to?(:warmup) && ENV['RUBY_DISABLE_WARMUP'] != '1'
187
+
188
+ ::Process.warmup
189
+ end
190
+
167
191
  def launch(self_read)
168
192
  @launcher = Wurk::Launcher.new(@config)
169
193
  begin
@@ -187,13 +211,23 @@ module Wurk
187
211
  # over `self_read` in `launch`. Same approach as Sidekiq.
188
212
  def trap_signals(self_write)
189
213
  signal_names.each do |sig|
190
- ::Signal.trap(sig) { self_write.puts(sig) }
214
+ ::Signal.trap(sig) { emit_signal(self_write, sig) }
191
215
  rescue ArgumentError
192
216
  # JRuby and platforms without certain signals — log and move on.
193
217
  warn("Signal #{sig} not supported")
194
218
  end
195
219
  end
196
220
 
221
+ # Non-blocking write from trap context (same shape as Swarm#emit_signal): a
222
+ # blocking `puts` can stall signal delivery once the pipe fills, and the
223
+ # traps outlive `run`'s ensure — a signal landing after the pipe is closed
224
+ # would otherwise raise IOError out of a trap and into the main thread.
225
+ def emit_signal(pipe, sig)
226
+ pipe.write_nonblock("#{sig}\n", exception: false)
227
+ rescue ::IOError, ::Errno::EPIPE, ::Errno::EBADF
228
+ nil
229
+ end
230
+
197
231
  def signal_names
198
232
  %w[INT TERM TSTP TTIN INFO USR2]
199
233
  end