wurk 1.9.0 → 1.10.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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/wurk/api_controller.rb +4 -1
  3. data/lib/wurk/boot_checks.rb +87 -0
  4. data/lib/wurk/capsule.rb +33 -8
  5. data/lib/wurk/cli.rb +73 -8
  6. data/lib/wurk/client/buffered.rb +240 -26
  7. data/lib/wurk/compat.rb +11 -0
  8. data/lib/wurk/configuration.rb +114 -16
  9. data/lib/wurk/cron.rb +45 -0
  10. data/lib/wurk/debounce.rb +2 -2
  11. data/lib/wurk/error_coalescer.rb +35 -0
  12. data/lib/wurk/fetcher/at_maxmemory.rb +79 -0
  13. data/lib/wurk/fetcher/capped.rb +4 -0
  14. data/lib/wurk/fetcher/many_queues.rb +17 -1
  15. data/lib/wurk/fetcher/oom_drain.rb +102 -0
  16. data/lib/wurk/fetcher/orphan_grace.rb +60 -44
  17. data/lib/wurk/fetcher/private_list_key.rb +1 -16
  18. data/lib/wurk/fetcher/reaper.rb +169 -127
  19. data/lib/wurk/fetcher/reliable.rb +26 -4
  20. data/lib/wurk/fetcher/self_reconcile.rb +175 -0
  21. data/lib/wurk/fetcher/unit_of_work.rb +32 -8
  22. data/lib/wurk/health.rb +23 -4
  23. data/lib/wurk/health_probe.rb +64 -0
  24. data/lib/wurk/heartbeat.rb +55 -3
  25. data/lib/wurk/job/options.rb +9 -1
  26. data/lib/wurk/job.rb +2 -24
  27. data/lib/wurk/job_record.rb +17 -9
  28. data/lib/wurk/job_retry.rb +38 -10
  29. data/lib/wurk/job_set.rb +13 -5
  30. data/lib/wurk/launcher.rb +134 -52
  31. data/lib/wurk/leader.rb +9 -3
  32. data/lib/wurk/limiter/base.rb +17 -1
  33. data/lib/wurk/lua/debounce.lua +11 -6
  34. data/lib/wurk/lua/loader.rb +13 -1
  35. data/lib/wurk/lua.rb +71 -8
  36. data/lib/wurk/manager.rb +1 -1
  37. data/lib/wurk/metrics/process_counters.rb +27 -0
  38. data/lib/wurk/metrics/prometheus.rb +35 -14
  39. data/lib/wurk/middleware/current_attributes.rb +26 -10
  40. data/lib/wurk/middleware/poison_pill.rb +1 -2
  41. data/lib/wurk/process_set.rb +3 -1
  42. data/lib/wurk/processor.rb +82 -18
  43. data/lib/wurk/queue_consumers.rb +134 -0
  44. data/lib/wurk/rails_boot.rb +40 -2
  45. data/lib/wurk/rails_reloader.rb +51 -0
  46. data/lib/wurk/railtie.rb +20 -1
  47. data/lib/wurk/redact.rb +12 -0
  48. data/lib/wurk/redis_pool.rb +34 -9
  49. data/lib/wurk/scheduled.rb +142 -67
  50. data/lib/wurk/sentry/death_handler.rb +29 -0
  51. data/lib/wurk/sentry/error_handler.rb +52 -10
  52. data/lib/wurk/sentry/middleware.rb +8 -1
  53. data/lib/wurk/sentry/retry_policy.rb +12 -3
  54. data/lib/wurk/sentry.rb +42 -5
  55. data/lib/wurk/sorted_entry.rb +38 -13
  56. data/lib/wurk/swarm/child_boot.rb +42 -31
  57. data/lib/wurk/swarm/crash_watch.rb +65 -0
  58. data/lib/wurk/swarm.rb +79 -11
  59. data/lib/wurk/testing.rb +66 -0
  60. data/lib/wurk/version.rb +1 -1
  61. data/lib/wurk/web/extension.rb +6 -1
  62. data/lib/wurk/work_set.rb +6 -0
  63. data/lib/wurk/worker/setter.rb +11 -6
  64. data/lib/wurk/worker.rb +17 -1
  65. data/lib/wurk.rb +1 -1
  66. data/vendor/assets/dashboard/assets/{ArgsValue-DDqX6XWH.js → ArgsValue-CmaXArv8.js} +1 -1
  67. data/vendor/assets/dashboard/assets/{BatchDetail-lIytcn2C.js → BatchDetail-nc5yePZW.js} +1 -1
  68. data/vendor/assets/dashboard/assets/{Batches-BEumt-1O.js → Batches-DgEcyOZ_.js} +1 -1
  69. data/vendor/assets/dashboard/assets/{Busy-Vk9HmZcN.js → Busy-CUZncvCy.js} +1 -1
  70. data/vendor/assets/dashboard/assets/{Cron-CWcM-Cfr.js → Cron-CIZbaaZs.js} +1 -1
  71. data/vendor/assets/dashboard/assets/{Dashboard-Bgv1kusv.js → Dashboard-BWmfAook.js} +1 -1
  72. data/vendor/assets/dashboard/assets/{Dead-Di5Rct2H.js → Dead-CGKsTZHq.js} +1 -1
  73. data/vendor/assets/dashboard/assets/{Extension-LZY5-imc.js → Extension-Doy4bJoA.js} +1 -1
  74. data/vendor/assets/dashboard/assets/{FilterBox-BDo-7IAy.js → FilterBox-C4bO26_d.js} +1 -1
  75. data/vendor/assets/dashboard/assets/{FlowDetail-CehwRLTt.js → FlowDetail-Dh3SJdFh.js} +1 -1
  76. data/vendor/assets/dashboard/assets/{FlowState-glK2Tnf8.js → FlowState-yb7virqR.js} +1 -1
  77. data/vendor/assets/dashboard/assets/{Flows-cgy-Fsbp.js → Flows-C5SMRuhq.js} +1 -1
  78. data/vendor/assets/dashboard/assets/{JobDetailModal-Cuk3jruU.js → JobDetailModal-Cg6sAsJB.js} +1 -1
  79. data/vendor/assets/dashboard/assets/{Limiters-Ci8G2yZG.js → Limiters-BIxwDOJM.js} +1 -1
  80. data/vendor/assets/dashboard/assets/{Metrics-CQFUeZTR.js → Metrics-Qp9q0nF8.js} +1 -1
  81. data/vendor/assets/dashboard/assets/{NotFound-CYjhuDsI.js → NotFound-DwjPxFjU.js} +1 -1
  82. data/vendor/assets/dashboard/assets/{PageHeader-DjIAFTyX.js → PageHeader-vWeBbSPF.js} +1 -1
  83. data/vendor/assets/dashboard/assets/{Profiles-DTowqIRg.js → Profiles-7dabYVe3.js} +1 -1
  84. data/vendor/assets/dashboard/assets/Queues-CMg-gyP8.js +1 -0
  85. data/vendor/assets/dashboard/assets/{Retries-SJ3BaKC0.js → Retries-CNX8ZtTH.js} +1 -1
  86. data/vendor/assets/dashboard/assets/{Scheduled-DxSRpmFZ.js → Scheduled-ClVBj0nc.js} +1 -1
  87. data/vendor/assets/dashboard/assets/{Search-bBbi5JNK.js → Search-BAsVE2qw.js} +1 -1
  88. data/vendor/assets/dashboard/assets/{charts-Bg2YBcaD.js → charts-DZs138BV.js} +1 -1
  89. data/vendor/assets/dashboard/assets/i18n-CBewVzQ_.js +1 -0
  90. data/vendor/assets/dashboard/assets/index--xvvNlYx.css +1 -0
  91. data/vendor/assets/dashboard/assets/{index-BOTVSP9M.js → index-CMzPFxNE.js} +3 -3
  92. data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-BRPNyV2M.js → useResetPageOnEmpty-DxkLZCIm.js} +1 -1
  93. data/vendor/assets/dashboard/assets/{useSort-B-BLJik7.js → useSort-jBYVwXvg.js} +1 -1
  94. data/vendor/assets/dashboard/assets/{utils-CGyrKpTh.js → utils-B0kQFNE8.js} +1 -1
  95. data/vendor/assets/dashboard/index.html +3 -3
  96. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  97. metadata +41 -30
  98. data/vendor/assets/dashboard/assets/Queues-DUGRPjME.js +0 -1
  99. data/vendor/assets/dashboard/assets/i18n-BZClP62Q.js +0 -1
  100. data/vendor/assets/dashboard/assets/index-Rewk4eSE.css +0 -1
@@ -2,65 +2,81 @@
2
2
 
3
3
  module Wurk
4
4
  class Fetcher
5
- # The Reaper's boot-grace check: has this heartbeat-judged orphan been
6
- # left alone for at least `grace` seconds? A booting worker can claim a job
7
- # a moment before its first heartbeat lands, and draining its list in that
8
- # window runs the job twice, concurrently.
5
+ # The Reaper's wait between judging a list orphaned by its heartbeat and
6
+ # draining it.
9
7
  #
10
- # The answer comes from OBJECT IDLETIME, which a live owner resets on every
11
- # claim and ACK. Some servers refuse it — Redis under an LFU maxmemory
12
- # policy, Dragonfly with no OBJECT at all — and the grace must not quietly
13
- # vanish with it. Without IDLETIME the clock is this reaper's own: a list
14
- # is drained only once it has been judged orphaned for `grace` seconds
15
- # across sweeps, by which time a booting owner has beaten and the
16
- # heartbeat re-check spares it. The switch is logged once.
8
+ # A live owner can read as having no heartbeat hash: its first beat failed on
9
+ # a Redis blip, CPU-bound jobs held the beat back, or Redis restarted from an
10
+ # older snapshot. Its next beat lands within the wait, and that spares it.
11
+ #
12
+ # The clock is this process's own and it is shared by nothing else, so it
13
+ # costs the same on every server and at every fleet size. That is the
14
+ # difference from what it replaces, OBJECT IDLETIME: any read of the list
15
+ # (a monitor's LLEN) reset that clock, some servers do not have the command,
16
+ # and a clock kept per sweep only advanced in a process that won the sweep
17
+ # lock twice. The loop thread therefore finishes what any sweep here started,
18
+ # with or without the lock (Reaper#drain_settled).
17
19
  class OrphanGrace
18
- NO_IDLETIME_WARNING = 'reaper: OBJECT IDLETIME is unavailable on this Redis (%s); the boot grace ' \
19
- 'before reclaiming a private list now runs on this process\'s own clock, so an ' \
20
- 'orphan is drained one sweep later than it would be. Expected under an LFU ' \
21
- 'maxmemory policy or on servers without OBJECT (e.g. Dragonfly).'
22
-
23
- # Lists remembered for the fallback clock. A list leaves on the pass
24
- # that clears it; one judged orphaned and then found alive can linger,
25
- # so the memory is bounded and simply restarts when full — every clock
26
- # restarting is the conservative direction.
20
+ # Lists remembered at once. Past it the memory restarts, and every clock
21
+ # restarting is the conservative direction: the next sweep judges them anew.
27
22
  MEMORY = 10_000
28
23
 
29
- def initialize(config, grace)
30
- @config = config
24
+ Entry = Struct.new(:since, :owner)
25
+
26
+ def initialize(grace)
31
27
  @grace = grace
32
- @idletime = true
33
- @first_seen = {}
28
+ @lock = ::Mutex.new
29
+ @entries = {}
34
30
  end
35
31
 
36
- # true while the list must be left alone.
37
- def recent?(key)
32
+ # true while the list must be left alone. `owner` is what #take_settled
33
+ # hands back when the wait is over.
34
+ def recent?(key, owner)
38
35
  return false unless @grace.positive?
39
- return local_recent?(key) unless @idletime
40
36
 
41
- idle = @config.redis(idempotent: true) { |c| c.call('OBJECT', 'IDLETIME', key) }
42
- # nil: the list is already gone, nothing to drain.
43
- idle.nil? || idle < @grace
44
- rescue RedisClient::CommandError => e
45
- idletime_unavailable!(e)
46
- local_recent?(key)
37
+ now = monotonic
38
+ @lock.synchronize do
39
+ @entries.clear if @entries.size >= MEMORY && !@entries.key?(key)
40
+ entry = (@entries[key] ||= Entry.new(now, owner))
41
+ next true if now - entry.since < @grace
42
+
43
+ @entries.delete(key)
44
+ false
45
+ end
47
46
  end
48
47
 
49
- private
48
+ # The owner came back: its next judgement starts a new wait.
49
+ def forget(key)
50
+ @lock.synchronize { @entries.delete(key) }
51
+ end
50
52
 
51
- def local_recent?(key)
52
- now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
53
- @first_seen.clear if @first_seen.size >= MEMORY && !@first_seen.key?(key)
54
- seen = (@first_seen[key] ||= now)
55
- return true if now - seen < @grace
53
+ # [[key, owner], ...] whose wait is over, removed from memory.
54
+ def take_settled
55
+ now = monotonic
56
+ @lock.synchronize do
57
+ settled = @entries.select { |_, entry| now - entry.since >= @grace }
58
+ settled.each_key { |key| @entries.delete(key) }
59
+ settled.map { |key, entry| [key, entry.owner] }
60
+ end
61
+ end
62
+
63
+ # Seconds until the next wait ends, nil with nothing waiting.
64
+ def next_due_in
65
+ now = monotonic
66
+ @lock.synchronize do
67
+ oldest = @entries.each_value.map(&:since).min
68
+ oldest && [(oldest + @grace) - now, 0].max
69
+ end
70
+ end
56
71
 
57
- @first_seen.delete(key)
58
- false
72
+ def size
73
+ @lock.synchronize { @entries.size }
59
74
  end
60
75
 
61
- def idletime_unavailable!(error)
62
- @idletime = false
63
- @config.logger.warn { format(NO_IDLETIME_WARNING, error.message.lines.first&.strip) }
76
+ private
77
+
78
+ def monotonic
79
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
64
80
  end
65
81
  end
66
82
  end
@@ -38,19 +38,6 @@ module Wurk
38
38
  nil
39
39
  end
40
40
 
41
- # `<public_q>|<host>|<pid>|<nonce>|<idx>` → [host, pid, nonce] (pid as
42
- # Integer), or all-nil when the suffix isn't a well-formed owner tail.
43
- # Splitting the suffix off the known public-queue prefix tolerates a `|`
44
- # inside the queue name itself, and leaves the tail unambiguous: exactly
45
- # 4 segments for the current shape, exactly 3 for the pre-nonce one.
46
- def parse_owner(public_q, key)
47
- suffix = key.delete_prefix("#{public_q}|")
48
- return [nil, nil, nil] if suffix == key
49
-
50
- host, pid, nonce = owner_tails(suffix.split('|')).first
51
- [host, pid, nonce]
52
- end
53
-
54
41
  # Owner segments of a private-list key, taken from the right, as
55
42
  # [host, pid, nonce, segment_count] readings in preference order (empty
56
43
  # when nothing parses). The wide shape is preferred: an all-digit nonce is
@@ -67,9 +54,7 @@ module Wurk
67
54
  # The nonce slot must carry PROCESS_NONCE's exact shape (NONCE_PATTERN):
68
55
  # on a pre-nonce key read wide it holds the real pid, which is never 12
69
56
  # hex, so the guard is what falls such a key through to narrow_tail
70
- # instead of misreading it. Guarded here rather than in parse_full
71
- # because parse_owner reads through owner_tails too — one guard, and the
72
- # two parsers agree on every input.
57
+ # instead of misreading it.
73
58
  def wide_tail(parts)
74
59
  [parts[-4], parts[-3].to_i, parts[-2], 4] if parts.size >= 4 && integer?(parts[-3]) && nonce?(parts[-2])
75
60
  end
@@ -3,10 +3,13 @@
3
3
  require_relative '../component'
4
4
  require_relative '../keys'
5
5
  require_relative '../middleware/poison_pill'
6
+ require_relative '../metrics/process_counters'
6
7
  require_relative '../timer_loop'
7
8
  require_relative 'private_list_key'
8
9
  require_relative 'unparseable_keys'
9
10
  require_relative 'orphan_grace'
11
+ require_relative 'oom_drain'
12
+ require_relative 'self_reconcile'
10
13
 
11
14
  module Wurk
12
15
  class Fetcher
@@ -31,87 +34,94 @@ module Wurk
31
34
  # supervisor respawns with a fresh pid, so it does not arise in practice.)
32
35
  # * any other incarnation — its pid means nothing in our namespace, so we
33
36
  # trust the heartbeat: the owner is alive iff its identity's `info`
34
- # hash still exists. Such reclaim waits out the 60s heartbeat TTL.
37
+ # hash still exists. Such reclaim waits out the 60s heartbeat TTL, the
38
+ # next sweep and then the grace: 80-150s after the owner died.
35
39
  #
36
- # The heartbeat verdict is a snapshot taken before a SCAN that can run for
37
- # a long time on a big keyspace, and a booting process can claim jobs a
38
- # moment before its first beat lands. Draining on that snapshot alone
39
- # reclaims a live worker's in-flight job — a double run. So a heartbeat-
40
- # judged orphan is re-checked right before its drain: its list must have
41
- # sat untouched for `grace` seconds (OBJECT IDLETIME; a live owner touches
42
- # it on every claim and ACK), and its identity must still have no `info`
43
- # hash. Our own incarnation skips both: kill(0) is not a snapshot.
40
+ # A missing hash is not proof of death on its own. The verdict is a snapshot
41
+ # taken before a SCAN that can run for a long time on a big keyspace, a beat
42
+ # can be held back by CPU-bound jobs, and a Redis restarted from an older
43
+ # snapshot brings back the `processes` SET with every hash lapsed. So a
44
+ # heartbeat-judged orphan must stay orphaned for `grace` seconds on this
45
+ # process's own clock (OrphanGrace), and its identity must still have no
46
+ # `info` hash. Our own incarnation skips both: kill(0) is not a snapshot.
44
47
  #
45
48
  # Re-pushed jobs run through Wurk::Middleware::PoisonPill, which caps a
46
49
  # job at RECOVERY_THRESHOLD recoveries within 72h: past the cap the job
47
50
  # is killed into the dead set instead of re-queued, so a job that crashes
48
51
  # its worker every time can't loop forever.
49
52
  #
50
- # The reaper runs two passes, exactly as super_fetch's sweeper does:
53
+ # One sweep an interval: one SCAN of `queue:*|*` acting on every list, gated
54
+ # by a cluster lock so about one process sweeps per interval. Every list, and
55
+ # not only those this process serves: the lock is shared across the fleet, so
56
+ # a scoped sweep would wait for its turn to land on one of the processes
57
+ # serving the queue. The list's own queue is where its jobs go back to,
58
+ # whichever process moves them.
51
59
  #
52
- # * a *scoped* sweep every interval ("1/min within process group"): SCANs
53
- # only the public queues this process serves, gated by a cluster `SET NX
54
- # EX` lock so one process sweeps per interval. The cheap common path.
55
- # * a *full* sweep at most once an hour ("full SCAN 1/hr"): SCANs the whole
56
- # `queue:*|*` keyspace, gated by its own hourly lock, so private lists
57
- # whose public queue no live process serves — a renamed/decommissioned
58
- # queue, or a dead host's queue no survivor consumes — are recovered too,
59
- # not stranded forever.
60
+ # Every tick also runs SelfReconcile over this process's own lists, which is
61
+ # the one case liveness cannot answer: the owner is alive and has lost track
62
+ # of a job.
60
63
  #
61
64
  # Spec: docs/target/sidekiq-pro.md §3.2.
62
- class Reaper
65
+ class Reaper # rubocop:disable Metrics/ClassLength
63
66
  include Component
64
67
 
65
- # Sweep cadence in seconds; also the cluster-lock TTL so exactly one
66
- # process sweeps per interval. 60s matches the heartbeat TTL — the
67
- # floor below which cross-host orphans can't be detected anyway.
68
+ # Sweep cadence in seconds. 60s matches the heartbeat TTL — the floor below
69
+ # which cross-host orphans can't be detected anyway.
68
70
  DEFAULT_INTERVAL = 60
69
71
 
70
- # Full-keyspace sweep cadence + its lock TTL: at most once per hour across
71
- # the fleet, since a global SCAN is far costlier than the scoped pass.
72
- FULL_INTERVAL = 3600
72
+ # The lock lapses before the winner's next tick, or a sole survivor would
73
+ # still find its own lock there and sweep every second interval.
74
+ LOCK_TTL_RATIO = 0.8
73
75
 
74
- # Seconds a heartbeat-judged private list must have sat idle before it is
75
- # drained. Far below the heartbeat TTL a dead owner's list already waits
76
- # out, so it never delays a real reclaim; well above the gap between a
77
- # booting process's first claim and its first beat.
76
+ # Seconds a heartbeat-judged private list stays orphaned before it is
77
+ # drained. A beat is due every 10s, and 25 CPU-bound jobs were measured
78
+ # holding one back 17.4s: at a 15s grace one trial in nine drained all 25
79
+ # live jobs. The price is paid only by a host lost for good.
78
80
  DEFAULT_GRACE = 30
79
81
 
80
82
  LOCK_KEY = 'super_fetch:reaper'
81
- FULL_LOCK_KEY = 'super_fetch:reaper:full'
82
- SCAN_COUNT = 100
83
-
84
- # The hourly sweep walks the whole keyspace, where almost nothing
85
- # matches: COUNT is the work per SCAN call, so a bigger batch is the
86
- # same server work in a tenth of the round trips — a million keys is
87
- # 1,000 calls instead of 10,000. Still small enough that one call never
88
- # holds Redis for more than a fraction of a millisecond.
89
- FULL_SCAN_COUNT = 1000
83
+
84
+ # A sweep walks the whole keyspace, where almost nothing matches: COUNT is
85
+ # the work per SCAN call, so a bigger batch is the same server work in a
86
+ # tenth of the round trips — a million keys is 1,000 calls instead of
87
+ # 10,000. Still small enough that one call never holds Redis for more than
88
+ # a fraction of a millisecond.
89
+ SCAN_COUNT = 1000
90
90
  THREAD_NAME = 'wurk-reaper'
91
91
 
92
92
  attr_reader :interval
93
93
 
94
- def initialize(config, interval: DEFAULT_INTERVAL, lock_key: LOCK_KEY,
95
- full_interval: FULL_INTERVAL, full_lock_key: FULL_LOCK_KEY, grace: DEFAULT_GRACE)
94
+ def initialize(config, interval: DEFAULT_INTERVAL, lock_key: LOCK_KEY, grace: DEFAULT_GRACE,
95
+ reconcile_confirm: SelfReconcile::DEFAULT_CONFIRM)
96
96
  @config = config
97
97
  @interval = interval
98
- @grace = OrphanGrace.new(config, grace)
98
+ @grace = OrphanGrace.new(grace)
99
99
  @lock_key = lock_key
100
- @full_interval = full_interval
101
- @full_lock_key = full_lock_key
102
100
  @thread = nil
103
101
  @done = false
104
102
  @mutex = ::Mutex.new
105
103
  @sleeper = ::ConditionVariable.new
106
104
  @unparseable = UnparseableKeys.new(config)
105
+ @next_sweep_at = 0.0
106
+ @self_reconcile = SelfReconcile.new(config, confirm: reconcile_confirm, stop: -> { done? },
107
+ on_moved: method(:recovered))
107
108
  end
108
109
 
109
110
  # Spawns the sweep loop. Idempotent. The loop waits one interval before
110
111
  # its first sweep so booting processes don't dogpile Redis and so an
111
112
  # un-stopped launcher in a unit test never touches the keyspace.
113
+ #
114
+ # A LIVE loop is the one and only sweep loop and is handed back as-is. A
115
+ # dead one is not: a stop whose bounded join timed out leaves its
116
+ # straggler referenced, and once that straggler has wound down the
117
+ # reference is a thread that will never sweep again — handing it back
118
+ # would leave the reaper dead for good, silently, since `start` looked
119
+ # like it had worked. A fresh loop takes its place; while the straggler
120
+ # is still alive it keeps the sweep to itself, so a restart still cannot
121
+ # run two loops at once.
112
122
  def start
113
123
  @mutex.synchronize do
114
- return @thread if @thread
124
+ return @thread if @thread&.alive?
115
125
 
116
126
  @done = false
117
127
  @thread = spawn_loop_thread
@@ -120,7 +130,7 @@ module Wurk
120
130
  end
121
131
 
122
132
  # Bounded at TimerLoop::JOIN_TIMEOUT like every other periodic component.
123
- # The full-keyspace sweep is exactly the tick that outlasts a stop: it
133
+ # A sweep is exactly the tick that outlasts a stop: it
124
134
  # SCANs the whole `queue:*|*` keyspace and drains what it finds, so on a
125
135
  # large or slow Redis it can still be running when shutdown lands. Waiting
126
136
  # it out held the entire process's teardown open past the swarm parent's
@@ -144,18 +154,15 @@ module Wurk
144
154
  !@thread.nil? && @thread.alive?
145
155
  end
146
156
 
147
- # One loop tick: the scoped sweep when this process wins the per-interval
148
- # lock, plus the full-keyspace sweep when it also wins the hourly lock.
149
- # Returns the total jobs reclaimed across both.
157
+ # One loop tick: a sweep when this process wins the per-interval lock.
158
+ # Returns the jobs reclaimed.
150
159
  def reap
151
- reclaimed = acquire_lock? ? reclaim! : 0
152
- reclaimed += reclaim_full! if acquire_full_lock?
153
- reclaimed
160
+ acquire_lock? ? reclaim! : 0
154
161
  end
155
162
 
156
- # One unguarded sweep over every served queue. Returns the number of
157
- # jobs reclaimed (re-queued or killed). Public so boot paths and tests
158
- # can drive a deterministic pass without the cluster lock.
163
+ # One unguarded sweep over every private list. Returns the number of jobs
164
+ # reclaimed (re-queued or killed). Public so boot paths and tests can drive
165
+ # a deterministic pass without the cluster lock.
159
166
  #
160
167
  # Honours #stop at each boundary: a stop bounding its join leaves the
161
168
  # sweep a straggler by design, and the straggler finishes the list it is
@@ -163,90 +170,65 @@ module Wurk
163
170
  # otherwise it keeps requeueing orphans and holding the sweep locks
164
171
  # past the stop, into whatever runs next.
165
172
  def reclaim!
166
- owners = live_owners
167
- reclaimed = 0
168
- served_queues.each do |public_q|
169
- break if done?
173
+ sweep.tap { wake_for_settling }
174
+ end
170
175
 
171
- reclaimed += reclaim_queue(public_q, owners)
172
- end
173
- reclaimed
176
+ # #reclaim! for a booting process. Deliberately under no lock, not even a
177
+ # short one: a replacement's sweep is what recovers the sibling it replaces,
178
+ # and the sibling may have died a moment after another process booted (a
179
+ # 2s lock turned a child killed right after boot from 1s into 61s). One
180
+ # pass per process costs keyspace / SCAN_COUNT calls.
181
+ def boot_reclaim!
182
+ reclaim!
174
183
  end
175
184
 
176
- # One unguarded full-keyspace sweep: every `queue:*|*` private list, even
177
- # ones whose public queue this process doesn't serve. Returns the number
178
- # of jobs reclaimed. Public so boot paths and tests can drive it without
179
- # the hourly lock.
180
- def reclaim_full!
181
- owners = live_owners
182
- reclaimed = 0
183
- each_full_private_list do |key, public_q, host, pid, nonce|
184
- break if done?
185
- next unless orphaned?(key, host, pid, nonce, owners)
185
+ # Lists whose grace has run out, whichever sweep started it. Runs on every
186
+ # tick with no lock: the sweep lock rotates between processes, and waiting
187
+ # to win it again is what made reclaim slower as a fleet grew. Two processes
188
+ # draining one list is safe, each move takes one job once.
189
+ def drain_settled
190
+ @grace.take_settled.sum do |key, (public_q, host, pid, nonce)|
191
+ next 0 if done?
186
192
 
187
- reclaimed += drain(key, public_q)
193
+ owner_gone?(host, pid, nonce) ? drain(key, public_q) : 0
188
194
  end
189
- reclaimed
190
195
  end
191
196
 
192
197
  private
193
198
 
194
- # Union of `queue:<name>` keys across every capsule this process serves.
195
- # Scoping the scan to these keeps the keyspace we touch bounded and lets
196
- # parallel test namespaces stay isolated.
197
- def served_queues
198
- @config.capsules.each_value
199
- .flat_map(&:queues)
200
- .uniq
201
- .map { |name| Keys.queue(name) }
199
+ # A sweep off the loop thread (the boot sweep) may have started a grace the
200
+ # loop's current wait knows nothing about.
201
+ def wake_for_settling
202
+ @mutex.synchronize { @sleeper.signal }
202
203
  end
203
204
 
204
- # SCAN for this public queue's private lists, reclaim the orphaned ones.
205
- def reclaim_queue(public_q, owners)
205
+ def sweep
206
+ owners = live_owners
206
207
  reclaimed = 0
207
- each_private_list(public_q) do |key, host, pid, nonce|
208
+ each_private_list do |key, owner|
208
209
  break if done?
209
- next unless orphaned?(key, host, pid, nonce, owners)
210
+ next unless orphaned?(key, owner, owners)
210
211
 
211
- reclaimed += drain(key, public_q)
212
+ reclaimed += drain(key, owner.first)
212
213
  end
213
214
  reclaimed
214
215
  end
215
216
 
216
- # Yields [private_list_key, host, pid, nonce] for each private list of
217
- # `public_q`. MATCH `<public_q>|*` matches only this queue's private
218
- # lists (public queue keys carry no `|`).
219
- def each_private_list(public_q)
220
- cursor = '0'
221
- loop do
222
- break if done?
223
-
224
- cursor, keys = redis(idempotent: true) do |c|
225
- c.call('SCAN', cursor, 'MATCH', "#{public_q}|*", 'COUNT', SCAN_COUNT)
226
- end
227
- keys.each do |key|
228
- host, pid, nonce = PrivateListKey.parse_owner(public_q, key)
229
- pid ? yield(key, host, pid, nonce) : @unparseable.report(key)
230
- end
231
- break if cursor == '0'
232
- end
233
- end
234
-
235
- # Yields [private_list_key, public_q, host, pid, nonce] for every list in
236
- # the keyspace. MATCH `queue:*|*` matches only private lists (public queue
217
+ # Yields private_list_key, [public_q, host, pid, nonce] for every list.
218
+ # MATCH `queue:*|*` matches only private lists (public queue
237
219
  # keys carry no `|`); PrivateListKey.parse_full drops anything that isn't a
238
220
  # well-formed `queue:<public>|<host>|<pid>|<nonce>|<idx>`.
239
- def each_full_private_list
221
+ def each_private_list
240
222
  cursor = '0'
241
223
  loop do
242
224
  break if done?
243
225
 
244
226
  cursor, keys = redis(idempotent: true) do |c|
245
- c.call('SCAN', cursor, 'MATCH', "#{Keys::QUEUE_PREFIX}*|*", 'COUNT', FULL_SCAN_COUNT)
227
+ c.call('SCAN', cursor, 'MATCH', "#{Keys::QUEUE_PREFIX}*|*", 'COUNT', SCAN_COUNT)
246
228
  end
247
229
  keys.each do |key|
248
230
  parsed = PrivateListKey.parse_full(key)
249
- parsed ? yield(key, *parsed) : @unparseable.report(key)
231
+ parsed ? yield(key, parsed) : @unparseable.report(key)
250
232
  end
251
233
  break if cursor == '0'
252
234
  end
@@ -271,11 +253,17 @@ module Wurk
271
253
  # proves it alive, and a nonce-bearing identity is alive on its hash
272
254
  # alone even once it has been pruned from the SET. A pre-nonce key can
273
255
  # only be matched on the `<host>:<pid>` prefix of that identity.
274
- def orphaned?(key, host, pid, nonce, owners)
256
+ def orphaned?(key, owner, owners)
257
+ _public_q, host, pid, nonce = owner
275
258
  return !local_pid_alive?(pid) if nonce == process_nonce && host == hostname
276
- return false if owners.include?(owner_key(host, pid, nonce))
277
259
 
278
- settled_orphan?(key, host, pid, nonce)
260
+ if owners.include?(owner_key(host, pid, nonce))
261
+ @grace.forget(key)
262
+ return false
263
+ end
264
+ return false if @grace.recent?(key, owner)
265
+
266
+ owner_gone?(host, pid, nonce)
279
267
  end
280
268
 
281
269
  def owner_key(host, pid, nonce)
@@ -283,11 +271,10 @@ module Wurk
283
271
  end
284
272
 
285
273
  # The re-check right before a heartbeat-judged drain (see the class doc):
286
- # the `owners` snapshot may predate the owner's first beat, or the whole
287
- # SCAN. Only reached for lists the snapshot already called orphaned, so
288
- # its round trips are paid per orphan, never per live list.
289
- def settled_orphan?(key, host, pid, nonce)
290
- return false if @grace.recent?(key)
274
+ # the `owners` snapshot may predate the owner's beat, or the whole SCAN.
275
+ # Only reached for lists the snapshot already called orphaned, so its round
276
+ # trips are paid per orphan, never per live list.
277
+ def owner_gone?(host, pid, nonce)
291
278
  return !live_owners.include?(owner_key(host, pid, nil)) unless nonce
292
279
 
293
280
  redis(idempotent: true) { |c| c.call('HGET', owner_key(host, pid, nonce), 'info') }.nil?
@@ -341,10 +328,11 @@ module Wurk
341
328
  queue_name = public_q.delete_prefix(Keys::QUEUE_PREFIX)
342
329
  count = 0
343
330
  loop do
344
- job = redis { |c| c.call('LMOVE', private_list, public_q, 'RIGHT', 'RIGHT') }
331
+ job = redis { |c| reclaim_one(c, private_list, public_q) }
345
332
  break unless job
346
333
 
347
334
  count += 1
335
+ Metrics::ProcessCounters.increment(:reaper_reclaimed)
348
336
  poison_off(public_q, job, queue_name)
349
337
  end
350
338
  count
@@ -353,20 +341,42 @@ module Wurk
353
341
  count
354
342
  end
355
343
 
344
+ # A full Redis must not hold a dead worker's jobs back from the workers
345
+ # still able to drain the queue.
346
+ def reclaim_one(conn, private_list, public_q)
347
+ Wurk::Lua::Loader.eval_cached(conn, :reliable_reclaim, keys: [private_list, public_q], argv: [])
348
+ rescue RedisClient::OutOfMemoryError => e
349
+ OomDrain.reclaim(conn, e, private_list, public_q)
350
+ end
351
+
352
+ # SelfReconcile put a payload back: the same accounting as a reclaim, so a
353
+ # job that kills its processor thread every time still hits the poison cap.
354
+ def recovered(public_q, job)
355
+ Metrics::ProcessCounters.increment(:reaper_reclaimed)
356
+ poison_off(public_q, job, public_q.delete_prefix(Keys::QUEUE_PREFIX))
357
+ end
358
+
359
+ # The job is back on its queue whatever happens here. Past maxmemory the
360
+ # count can't be taken (nor the job killed, leaving it where it is): raising
361
+ # would end the drain at one job per pass, with the rest of the list waiting
362
+ # on memory they would help free.
356
363
  def poison_off(public_q, job, queue_name)
357
364
  return unless Middleware::PoisonPill.track!(job, queue: queue_name, config: @config) == :poison
358
365
 
359
366
  # track! already ZADDed the payload to the dead set; pull the copy we
360
- # just LMOVE'd onto the public tail so it isn't also re-run.
367
+ # just moved onto the public tail so it isn't also re-run.
361
368
  redis { |c| c.call('LREM', public_q, -1, job) }
369
+ rescue RedisClient::OutOfMemoryError => e
370
+ report(e)
362
371
  end
363
372
 
364
373
  def acquire_lock?
365
- redis { |c| c.call('SET', @lock_key, '1', 'NX', 'EX', @interval) } == 'OK'
366
- end
367
-
368
- def acquire_full_lock?
369
- redis { |c| c.call('SET', @full_lock_key, '1', 'NX', 'EX', @full_interval) } == 'OK'
374
+ ttl = (@interval * LOCK_TTL_RATIO * 1000).ceil
375
+ redis do |c|
376
+ c.call('SET', @lock_key, '1', 'NX', 'PX', ttl)
377
+ rescue RedisClient::OutOfMemoryError => e
378
+ OomDrain.lock(c, e, @lock_key, ttl)
379
+ end == 'OK'
370
380
  end
371
381
 
372
382
  def spawn_loop_thread
@@ -377,6 +387,7 @@ module Wurk
377
387
  end
378
388
 
379
389
  def run_loop
390
+ @next_sweep_at = monotonic + @interval
380
391
  until done?
381
392
  wait_next
382
393
  break if done?
@@ -385,14 +396,45 @@ module Wurk
385
396
  end
386
397
  end
387
398
 
399
+ # Each step guarded on its own: a raise in the sweep (lock contention on a
400
+ # blip, a poison callback) must not cost this process its own reconcile.
401
+ # The wait is cut short for a grace running out (#wait_next), and that
402
+ # wake-up is not a sweep's turn.
388
403
  def tick_once
404
+ guarded { reap_when_due }
405
+ guarded { drain_settled }
406
+ guarded { @self_reconcile.sweep }
407
+ end
408
+
409
+ def reap_when_due
410
+ now = monotonic
411
+ return if now < @next_sweep_at
412
+
413
+ @next_sweep_at = now + @interval
389
414
  reap
415
+ end
416
+
417
+ def guarded
418
+ yield
390
419
  rescue StandardError => e
391
- handle_exception(e, context: THREAD_NAME) if @config.respond_to?(:handle_exception)
420
+ report(e)
421
+ end
422
+
423
+ # The reporter is host code and may raise too (a closed log stream); that
424
+ # must not end the only thread that recovers orphans.
425
+ def report(error)
426
+ handle_exception(error, context: THREAD_NAME) if @config.respond_to?(:handle_exception)
427
+ rescue StandardError => e
428
+ warn "wurk-reaper: error reporter raised #{e.class}: #{e.message}"
392
429
  end
393
430
 
394
431
  def wait_next
395
- @mutex.synchronize { @sleeper.wait(@mutex, @interval) unless @done }
432
+ wait = [@next_sweep_at - monotonic, @grace.next_due_in].compact.min
433
+ @mutex.synchronize { @sleeper.wait(@mutex, [wait, 0].max) unless @done }
434
+ end
435
+
436
+ def monotonic
437
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
396
438
  end
397
439
 
398
440
  def done?