cogworker 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bfb90fe3a477f31658c58fd09a41e4404a0c09999b74614789bd343ee704fad2
4
- data.tar.gz: 407aa20293538fa35c8219f88f21fd8b1c15150d96e77d2b495d017ef21f924e
3
+ metadata.gz: 680a06059bb5853e9d7843ff6d5313d7cd9431b222b846b496c9ca965d6b8844
4
+ data.tar.gz: fd688cdc23fa56e86e6d4c50a7f319e98698d4c6166d8213359c38eb0f94aa71
5
5
  SHA512:
6
- metadata.gz: c7df3eba977499a45acca66e8d25fd87cfe4669b9098c1e99be798f587de2ddf76edc75adb01953e8fe311ac324d7e4eb44cce66a33be264efe4f266e67d8f3e
7
- data.tar.gz: 7fa4d7ba18f7be8ae8e80bd7b260be8bd8e9387122a623802812abd166652859f48669bbb42c16f72f15df1104d855934f4cfdb6cf6aede2737d740540d27278
6
+ metadata.gz: fc5d3f1a358e0c826b2801ddd73f2b039b31b1e29616a38947da6df0cd85edfdf3b9b8c877e7123fc6283617f7f34317b9503c76db4eeb89a881677e335c5cfb
7
+ data.tar.gz: 486aff800ab67a15ce9c815f659a70ee95abcfd28218e7c862329a26a0f3643dce3a3c24dc79c539531a9b29b50f745e81482e617ba908c7d80ea131fe7b6d60
@@ -24,7 +24,7 @@ module Cogworker
24
24
  def record(jid, attempt:, error:, outcome:)
25
25
  entry = JSON.generate(
26
26
  'attempt' => attempt, 'failed_at' => Time.now.to_f, 'outcome' => outcome,
27
- 'error_class' => error.class.name, 'error_message' => error.message.to_s[0, 10_000]
27
+ 'error_class' => error.class.name, 'error_message' => JobUtil.error_message(error)
28
28
  )
29
29
  key = RedisKeys.job_attempts(jid)
30
30
  Cogworker.config.redis do |c|
@@ -17,8 +17,12 @@ module Cogworker
17
17
  class BasicFetch
18
18
  TIMEOUT = 2 # seconds; also how often a stopped/quieted processor notices and exits its fetch loop.
19
19
 
20
- def initialize(queues)
20
+ # `stopping:` lets a fetcher cut its own waits short once shutdown has
21
+ # begun (Processor passes the Manager's `stopping?`), so a long idle
22
+ # pause can't hold up `Manager#stop!`.
23
+ def initialize(queues, stopping: -> { false })
21
24
  @queue_keys = Array(queues).map { |q| RedisKeys.queue(q) }
25
+ @stopping = stopping
22
26
  end
23
27
 
24
28
  # Re-reads `cogworker:paused_queues` on every single fetch (not once at
@@ -33,7 +37,7 @@ module Cogworker
33
37
  def retrieve_work
34
38
  keys = active_keys
35
39
  if keys.empty?
36
- sleep(TIMEOUT)
40
+ interruptible_sleep(TIMEOUT)
37
41
  return nil
38
42
  end
39
43
 
@@ -47,6 +51,12 @@ module Cogworker
47
51
  # Nothing to do: BRPOP already removed the job from Redis.
48
52
  def acknowledge(_work); end
49
53
 
54
+ # `Processor#interrupt`: the job ran, recording its failure failed —
55
+ # file it under `set` (retry/dead) at `score` instead.
56
+ def interrupt(_work, set, score, payload)
57
+ Cogworker.config.redis { |c| c.zadd(set, score, payload) }
58
+ end
59
+
50
60
  # Returns a fetched-but-unstarted job to the end of its queue that's
51
61
  # popped next.
52
62
  def give_back(work)
@@ -75,14 +85,24 @@ module Cogworker
75
85
  queue_key = RedisKeys.queue(queue)
76
86
  retake = job['periodic_until_executed'] && job['periodic_pjid'] && job['jid']
77
87
  lock_key = retake ? RedisKeys.periodic_running(job['periodic_pjid']) : queue_key
78
- conn.eval(REQUEUE_ENTRY_SCRIPT, keys: [queue_key, lock_key],
79
- argv: [JSON.generate(job), retake ? '1' : '0', job['jid'].to_s,
88
+ LuaScript.run(conn, REQUEUE_ENTRY_SCRIPT, keys: [queue_key, lock_key],
89
+ argv: [JSON.generate(job), retake ? '1' : '0', job['jid'].to_s,
80
90
  Periodic::RunningLock.queued_ttl])
81
91
  end
82
92
  private_class_method :requeue_entry
83
93
 
84
94
  private
85
95
 
96
+ def interruptible_sleep(seconds)
97
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + seconds
98
+ until @stopping.call
99
+ left = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
100
+ break if left <= 0
101
+
102
+ sleep([left, 0.25].min)
103
+ end
104
+ end
105
+
86
106
  def active_keys
87
107
  paused = Cogworker.config.redis { |c| c.smembers(RedisKeys::PAUSED_QUEUES) }
88
108
  return @queue_keys if paused.empty?
@@ -127,12 +127,22 @@ module Cogworker
127
127
  c.expire(RedisKeys.workers(identity), TTL)
128
128
  if reliable
129
129
  c.sadd?(RedisKeys::IN_PROGRESS_IDENTITIES, identity)
130
- c.hset(RedisKeys::LAST_BEAT, identity, c.time.first) # Redis's clock, not this host's
130
+ c.hset(RedisKeys::LAST_BEAT, identity, redis_now(c))
131
131
  end
132
132
  touch_running_periodic_locks(c, identity)
133
133
  end
134
134
  end
135
135
 
136
+ # Redis's clock, not this host's (so host clock skew doesn't matter to
137
+ # orphan detection) — falling back to this host's where `TIME` isn't
138
+ # allowed (some proxies/managed services): better a skewed stamp than a
139
+ # process that can never complete its first beat.
140
+ def redis_now(conn)
141
+ conn.time.first
142
+ rescue Redis::CommandError
143
+ Time.now.to_i
144
+ end
145
+
136
146
  # Keeps every in-flight `until_executed` periodic run's
137
147
  # `periodic:running:<pjid>` lock alive for as long as this process is —
138
148
  # see Periodic::RunningLock. Reads the same `cogworker:workers:<identity>`
@@ -30,8 +30,8 @@ module Cogworker
30
30
  }
31
31
  if error
32
32
  entry['error_class'] = error.class.name
33
- entry['error_message'] = error.message.to_s[0, 10_000]
34
- entry['backtrace'] = (error.backtrace || []).first(200)
33
+ entry['error_message'] = JobUtil.error_message(error)
34
+ entry['backtrace'] = (error.backtrace || []).first(200).map { |line| JobUtil.safe_string(line) }
35
35
  end
36
36
 
37
37
  raw = JSON.generate(entry)
@@ -96,12 +96,34 @@ module Cogworker
96
96
  return :invalid unless job
97
97
 
98
98
  job['enqueued_at'] = Time.now.to_f
99
- won = conn.eval(CLAIM_AND_REQUEUE_SCRIPT,
100
- keys: [set, RedisKeys::QUEUES, RedisKeys.queue(job['queue'])],
101
- argv: [raw, job['queue'], JSON.generate(job)])
99
+ won = LuaScript.run(conn, CLAIM_AND_REQUEUE_SCRIPT,
100
+ keys: [set, RedisKeys::QUEUES, RedisKeys.queue(job['queue'])],
101
+ argv: [raw, job['queue'], JSON.generate(job)])
102
102
  won == 1 ? :requeued : :gone
103
103
  end
104
104
 
105
+ ERROR_MESSAGE_LIMIT = 10_000
106
+
107
+ # Valid UTF-8, whatever came in — every string this gem stores from
108
+ # outside its control (an exception's message, a raw payload) goes
109
+ # through here before `JSON.generate`, which raises on invalid UTF-8.
110
+ # An unchecked `raise "bad \xff".b` used to make every attempt to record
111
+ # the failure itself raise, deterministically. Strings in another
112
+ # (valid) encoding are transcoded; binary/broken ones are scrubbed.
113
+ def safe_string(value, limit = nil)
114
+ str = value.to_s
115
+ str = if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(str.encoding)
116
+ str.dup.force_encoding(Encoding::UTF_8).scrub
117
+ else
118
+ str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
119
+ end
120
+ limit ? str[0, limit] : str
121
+ end
122
+
123
+ def error_message(error)
124
+ safe_string(error.message, ERROR_MESSAGE_LIMIT)
125
+ end
126
+
105
127
  UNPARSEABLE_CLASS = '(unparseable)'
106
128
 
107
129
  # A payload that isn't a JSON object can't be run, retried or even
@@ -113,8 +135,8 @@ module Cogworker
113
135
  {
114
136
  'class' => UNPARSEABLE_CLASS, 'args' => [], 'queue' => queue, 'jid' => SecureRandom.hex(12),
115
137
  'retry' => false, 'retry_count' => 0, 'enqueued_at' => now, 'failed_at' => now,
116
- 'error_class' => error.class.name, 'error_message' => error.message.to_s[0, 10_000],
117
- 'raw_payload' => raw.to_s[0, 100_000]
138
+ 'error_class' => error.class.name, 'error_message' => error_message(error),
139
+ 'raw_payload' => safe_string(raw, 100_000)
118
140
  }
119
141
  end
120
142
  end
@@ -13,8 +13,14 @@ module Cogworker
13
13
  @manager = Manager.new(config)
14
14
  @scheduled = Scheduled.new(@manager)
15
15
  @heartbeat = Heartbeat.new(@manager)
16
+ # The first tick waits for the first orphan check: after the whole
17
+ # fleet was down longer than a running `until_executed` job's lock
18
+ # lives, the ticker would otherwise claim that entry's next slot before
19
+ # recovery had requeued the interrupted run (and re-taken its lock) —
20
+ # two runs at once.
16
21
  @ticker = Periodic::Ticker.new(
17
- @manager, config.periodic_manager.entries, catch_up: config.periodic_catch_up
22
+ @manager, config.periodic_manager.entries, catch_up: config.periodic_catch_up,
23
+ ready: -> { @scheduled.recovered_once? }
18
24
  )
19
25
  @signal_queue = ::Queue.new
20
26
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest/sha1'
4
+
5
+ module Cogworker
6
+ # Runs a Lua script by its SHA1 (EVALSHA) instead of shipping the whole
7
+ # source on every call, falling back to EVAL — which also loads it into
8
+ # the server's script cache for next time — on NOSCRIPT (first use, a
9
+ # restarted or failed-over server, SCRIPT FLUSH). Every script in this
10
+ # gem goes through here.
11
+ module LuaScript
12
+ SHAS = {} # source => sha1; memoized, the source strings are frozen constants
13
+ SHAS_MUTEX = Mutex.new
14
+
15
+ module_function
16
+
17
+ def run(conn, source, keys: [], argv: [])
18
+ begin
19
+ return conn.evalsha(sha(source), keys: keys, argv: argv)
20
+ rescue Redis::CommandError => e
21
+ raise unless e.message.start_with?('NOSCRIPT')
22
+ end
23
+ conn.eval(source, keys: keys, argv: argv)
24
+ end
25
+
26
+ def sha(source)
27
+ SHAS_MUTEX.synchronize { SHAS[source] ||= Digest::SHA1.hexdigest(source) }
28
+ end
29
+ end
30
+ end
@@ -18,11 +18,11 @@ module Cogworker
18
18
  @config.queues
19
19
  end
20
20
 
21
- # Resolved on first use — from a processor thread's first fetch, after
22
- # the Heartbeat's first beat proved Redis reachable — not in the
23
- # constructor: that needed Redis just to build a Manager, and when
24
- # Redis was unreachable at that moment, the version check couldn't run
25
- # at all (see ReliableFetch.supported?).
21
+ # Resolved on first use, not in the constructor (that needed Redis
22
+ # just to build a Manager). First use is normally the Heartbeat's first
23
+ # beat; if Redis can't be reached then, ReliableFetch.supported? raises
24
+ # and nothing is memoized — the beat fails and is retried, and this
25
+ # resolves again next time — rather than memoizing a guess.
26
26
  def fetch_class
27
27
  @fetch_mutex.synchronize { @fetch_class ||= resolve_fetch_class }
28
28
  end
@@ -32,9 +32,9 @@ module Cogworker
32
32
  end
33
33
 
34
34
  def run(conn, script, key, argv)
35
- return conn.eval(script, keys: [key], argv: argv) if conn
35
+ return LuaScript.run(conn, script, keys: [key], argv: argv) if conn
36
36
 
37
- Cogworker.config.redis { |c| c.eval(script, keys: [key], argv: argv) }
37
+ Cogworker.config.redis { |c| LuaScript.run(c, script, keys: [key], argv: argv) }
38
38
  end
39
39
  end
40
40
  end
@@ -36,12 +36,14 @@ module Cogworker
36
36
  return 1
37
37
  LUA
38
38
 
39
- def initialize(manager, entries, catch_up: true)
39
+ def initialize(manager, entries, catch_up: true, ready: -> { true })
40
40
  @manager = manager
41
41
  @entries = entries
42
42
  @catch_up = catch_up
43
+ @ready = ready
43
44
  @last_checked_slot = {}
44
45
  @cron_cache = {}
46
+ @pending_rollbacks = []
45
47
  end
46
48
 
47
49
  def start!
@@ -82,7 +84,7 @@ module Cogworker
82
84
  def run
83
85
  until @manager.stopping?
84
86
  begin
85
- tick unless @manager.quiet?
87
+ tick unless @manager.quiet? || !@ready.call
86
88
  rescue StandardError => e
87
89
  Cogworker.logger.error { "Periodic tick failed: #{e.class}: #{e.message}" }
88
90
  end
@@ -91,6 +93,7 @@ module Cogworker
91
93
  end
92
94
 
93
95
  def tick
96
+ retry_pending_rollbacks
94
97
  now = Time.now
95
98
  @entries.each do |entry|
96
99
  slot = cron_for(entry).previous_time(now).to_i
@@ -114,13 +117,28 @@ module Cogworker
114
117
  # persisted last_slot) still advances either way, exactly as it
115
118
  # already did before this entry ever had a disabled state — it only
116
119
  # stops this same tick loop from re-evaluating the same slot twice.
120
+ # The usual reason a push fails — Redis unreachable — makes this fail
121
+ # too; it's then kept and retried at the start of every tick until it
122
+ # goes through (before that tick looks at the slot again), rather than
123
+ # just logged and the slot lost. (A push that timed out *after* Redis
124
+ # had applied it is the one case this turns into a second run of the
125
+ # slot: indistinguishable from one that never arrived.)
117
126
  def rollback_claim(entry, slot, previous_slot)
118
127
  Cogworker.config.redis do |c|
119
128
  keys = [RedisKeys.periodic_last_slot(entry.pjid), RedisKeys.periodic_lock(entry.pjid, slot)]
120
- c.eval(ROLLBACK_SCRIPT, keys: keys, argv: [slot, previous_slot])
129
+ LuaScript.run(c, ROLLBACK_SCRIPT, keys: keys, argv: [slot, previous_slot])
121
130
  end
131
+ true
122
132
  rescue StandardError => e
123
- Cogworker.logger.error { "periodic #{entry.pjid}: couldn't roll back slot #{slot}: #{e.class}: #{e.message}" }
133
+ Cogworker.logger.error { "periodic #{entry.pjid}: couldn't roll back slot #{slot} (will retry): #{e.class}: #{e.message}" }
134
+ @pending_rollbacks << [entry, slot, previous_slot]
135
+ false
136
+ end
137
+
138
+ def retry_pending_rollbacks
139
+ pending = @pending_rollbacks
140
+ @pending_rollbacks = []
141
+ pending.each { |args| rollback_claim(*args) }
124
142
  end
125
143
 
126
144
  def disabled?(entry)
@@ -137,7 +155,7 @@ module Cogworker
137
155
  return nil if !@catch_up && priming_first_slot?(entry, slot)
138
156
 
139
157
  result = Cogworker.config.redis do |c|
140
- c.eval(CLAIM_SCRIPT,
158
+ LuaScript.run(c, CLAIM_SCRIPT,
141
159
  keys: [RedisKeys.periodic_running(entry.pjid), RedisKeys.periodic_last_slot(entry.pjid),
142
160
  RedisKeys.periodic_lock(entry.pjid, slot)],
143
161
  argv: [slot, entry.unique.to_s, LOCK_TTL, jid, RunningLock.queued_ttl])
@@ -6,9 +6,26 @@ require 'securerandom'
6
6
  module Cogworker
7
7
  # One thread of a Manager's pool: fetch -> register in-flight -> run the
8
8
  # server middleware chain around perform -> stats/retry -> deregister.
9
+ #
10
+ # What happens to the fetched job afterwards depends on how far it got —
11
+ # the one rule being that a job is never re-run because of a failure that
12
+ # happened *after* it ran:
13
+ # - failed before `perform` (registering it in `cogworker:workers`):
14
+ # `give_back` — straight back on its queue, it hasn't run yet;
15
+ # - ran (succeeded, or failed and was recorded in retry/dead): acknowledge.
16
+ # Everything after `perform` other than that one retry/dead write —
17
+ # stats, throughput, deregistering, lock extension, the attempts log —
18
+ # is best-effort: logged, never raised;
19
+ # - ran and failed, but the retry/dead write itself failed: `interrupt` —
20
+ # into `retry` after INTERRUPT_DELAY, counting `interrupted_count`, and
21
+ # into `dead` once that passes MAX_INTERRUPTS. Never straight back on
22
+ # the queue: a write that fails deterministically used to make that
23
+ # loop forever, re-running the job every second.
9
24
  class Processor
10
25
  ERROR_BACKOFF = 1 # seconds
11
26
  ACK_ATTEMPTS = 3
27
+ MAX_INTERRUPTS = 3
28
+ INTERRUPT_DELAY = 30 # seconds, times the interruption count
12
29
 
13
30
  attr_reader :thread, :tid
14
31
 
@@ -70,29 +87,58 @@ module Cogworker
70
87
 
71
88
  @manager.processor_busy!
72
89
  @busy = true
73
- finished = false
90
+ disposition = :give_back
74
91
  begin
75
- execute(work)
76
- finished = true
92
+ disposition = execute(work)
77
93
  ensure
78
94
  @busy = false
79
95
  @manager.processor_idle!
80
- finished ? acknowledge(work) : give_back(work)
96
+ settle(work, disposition)
81
97
  end
82
98
  end
83
99
 
84
- # `execute` raising means bookkeeping failed (Redis, mid-way through
85
- # registering the job or routing its failure to retry/dead) — not the
86
- # job itself, whose own errors `execute` always handles. Acknowledging
87
- # here would drop a job that never made it to retry/dead; instead it
88
- # goes straight back on its queue (it may run again: at-least-once). If
89
- # even that fails, it simply stays on the in-progress list, which
90
- # `Manager#stop!`/orphan recovery requeue later.
100
+ def settle(work, disposition)
101
+ case disposition
102
+ when :acknowledge then acknowledge(work)
103
+ when :give_back then give_back(work)
104
+ else interrupt(work, *disposition.drop(1))
105
+ end
106
+ end
107
+
108
+ # Only for a job that hasn't run yet (see the class comment). If even
109
+ # this fails, it stays on the in-progress list, which `Manager#stop!` /
110
+ # orphan recovery / `ReliableFetch.reconcile` requeue later.
91
111
  def give_back(work)
92
112
  fetcher.give_back(work)
93
113
  rescue StandardError => e
94
114
  Cogworker.logger.error do
95
- "couldn't hand back jid=#{jid_of(work)} after a failed run (left in progress): #{e.class}: #{e.message}"
115
+ "couldn't hand back jid=#{jid_of(work)} (left in progress): #{e.class}: #{e.message}"
116
+ end
117
+ end
118
+
119
+ # The job ran and failed, but recording that in retry/dead failed. Built
120
+ # from the original payload (always serializable — it came from JSON)
121
+ # plus the failure fields, never from the in-memory job hash: whatever
122
+ # made that one fail to serialize must not do it again here.
123
+ def interrupt(work, job, error)
124
+ payload = JSON.parse(work.raw_job)
125
+ count = payload['interrupted_count'].to_i + 1
126
+ payload.merge!('interrupted_count' => count, 'retry_count' => job['retry_count'],
127
+ 'error_class' => error.class.name, 'error_message' => JobUtil.error_message(error),
128
+ 'failed_at' => Time.now.to_f)
129
+ set, score = if count > MAX_INTERRUPTS
130
+ [RedisKeys::DEAD, Time.now.to_f]
131
+ else
132
+ [RedisKeys::RETRY, Time.now.to_f + (INTERRUPT_DELAY * count)]
133
+ end
134
+ fetcher.interrupt(work, set, score, JSON.generate(payload))
135
+ Cogworker.logger.error do
136
+ "couldn't record the failure of jid=#{payload['jid']} (#{error.class}); " \
137
+ "moved to #{set.delete_prefix('cogworker:')} (interruption #{count}/#{MAX_INTERRUPTS})"
138
+ end
139
+ rescue StandardError => e
140
+ Cogworker.logger.error do
141
+ "couldn't record or interrupt jid=#{jid_of(work)} (left in progress): #{e.class}: #{e.message}"
96
142
  end
97
143
  end
98
144
 
@@ -123,53 +169,80 @@ module Cogworker
123
169
  '?'
124
170
  end
125
171
 
172
+ # Returns how `settle` should dispose of the fetched job; raises only
173
+ # before the job has run (then `process_one`'s default, :give_back,
174
+ # applies).
126
175
  def execute(work)
127
176
  job = parse_job(work)
128
- return unless job
177
+ return :acknowledge unless job
129
178
 
130
179
  register_in_workers(work.queue, job)
131
180
  Cogworker.logger.info { "start: #{job['class']} jid=#{job['jid']}" }
132
-
133
181
  begin
134
- # `build_worker` itself can raise (most commonly `NameError`, e.g. a
135
- # class the caller registered/enqueued but never actually defined —
136
- # deliberately still routed through the *same* middleware chain
137
- # below rather than caught here directly: `History::Middleware`/
138
- # `Status::ServerMiddleware` (and any custom server middleware)
139
- # only ever see a job by wrapping this `chain.invoke` call, so a
140
- # resolution failure caught out here, before `invoke` ever runs,
141
- # would never reach them — this job would fail/retry/die with no
142
- # History entry and no status update at all, silently. Catching it
143
- # here and re-raising it as the chain's own "final block" gives
144
- # every middleware the same crack at observing this failure as any
145
- # perform-time one, with `worker` as `nil` in that case (every
146
- # built-in server middleware ignores the `worker` arg entirely
147
- # already; a custom one that needs a real instance simply can't act
148
- # on this failure either way — there's no worker to act on).
149
- resolution_error = nil
150
- worker = begin
151
- build_worker(job)
152
- rescue Exception => e # rubocop:disable Lint/RescueException
153
- resolution_error = e
154
- nil
155
- end
182
+ error = run_job(work, job)
183
+ error ? finish_failure(job, error) : finish_success(job)
184
+ ensure
185
+ best_effort('deregister') { deregister_from_workers }
186
+ end
187
+ end
156
188
 
157
- Cogworker.config.server_chain.invoke(worker, job, work.queue) do
158
- raise resolution_error if resolution_error
189
+ # The job's own outcome: nil, or what it (or its middleware) raised.
190
+ def run_job(work, job)
191
+ # `build_worker` itself can raise (most commonly `NameError`, e.g. a
192
+ # class the caller registered/enqueued but never actually defined —
193
+ # deliberately still routed through the *same* middleware chain
194
+ # below rather than caught here directly: `History::Middleware`/
195
+ # `Status::ServerMiddleware` (and any custom server middleware)
196
+ # only ever see a job by wrapping this `chain.invoke` call, so a
197
+ # resolution failure caught out here, before `invoke` ever runs,
198
+ # would never reach them — this job would fail/retry/die with no
199
+ # History entry and no status update at all, silently. Catching it
200
+ # here and re-raising it as the chain's own "final block" gives
201
+ # every middleware the same crack at observing this failure as any
202
+ # perform-time one, with `worker` as `nil` in that case (every
203
+ # built-in server middleware ignores the `worker` arg entirely
204
+ # already; a custom one that needs a real instance simply can't act
205
+ # on this failure either way — there's no worker to act on).
206
+ resolution_error = nil
207
+ worker = begin
208
+ build_worker(job)
209
+ rescue Exception => e # rubocop:disable Lint/RescueException
210
+ resolution_error = e
211
+ nil
212
+ end
159
213
 
160
- worker.perform(*job['args'])
161
- end
214
+ Cogworker.config.server_chain.invoke(worker, job, work.queue) do
215
+ raise resolution_error if resolution_error
216
+
217
+ worker.perform(*job['args'])
218
+ end
219
+ nil
220
+ rescue Exception => e # rubocop:disable Lint/RescueException
221
+ e
222
+ end
223
+
224
+ def finish_success(job)
225
+ best_effort('stats') do
162
226
  Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_PROCESSED) }
163
227
  Throughput.record('processed')
164
- Cogworker.logger.info { "done: #{job['class']} jid=#{job['jid']}" }
165
- rescue Exception => e # rubocop:disable Lint/RescueException
228
+ end
229
+ Cogworker.logger.info { "done: #{job['class']} jid=#{job['jid']}" }
230
+ :acknowledge
231
+ end
232
+
233
+ def finish_failure(job, error)
234
+ best_effort('stats') do
166
235
  Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_FAILED) }
167
236
  Throughput.record('failed')
168
- route_failure(job, e)
169
- Cogworker.logger.warn { "fail: #{job['class']} jid=#{job['jid']}: #{e.class}: #{e&.message}" }
170
237
  end
171
- ensure
172
- deregister_from_workers
238
+ Cogworker.logger.warn { "fail: #{job['class']} jid=#{job['jid']}: #{error.class}: #{JobUtil.error_message(error)}" }
239
+ route_failure(job, error) ? :acknowledge : [:interrupt, job, error]
240
+ end
241
+
242
+ def best_effort(what)
243
+ yield
244
+ rescue StandardError => e
245
+ Cogworker.logger.error { "#{what} failed (ignored): #{e.class}: #{e.message}" }
173
246
  end
174
247
 
175
248
  # A payload that isn't a JSON object goes straight to `dead`
@@ -199,7 +272,7 @@ module Cogworker
199
272
  # Built on first use, not in the constructor (see Manager#fetch_class),
200
273
  # and rebuilt after a fall-back to BasicFetch.
201
274
  def fetcher
202
- @fetcher ||= @manager.fetch_class.new(@manager.queues)
275
+ @fetcher ||= @manager.fetch_class.new(@manager.queues, stopping: -> { @manager.stopping? })
203
276
  end
204
277
 
205
278
  def build_worker(job)
@@ -209,24 +282,35 @@ module Cogworker
209
282
  worker
210
283
  end
211
284
 
285
+ # True once the failure is recorded in retry/dead — the one write that
286
+ # must happen; everything after it is best-effort. False if that write
287
+ # failed (`finish_failure` then interrupts the job instead).
212
288
  def route_failure(job, error)
213
289
  job['error_class'] = error.class.name
214
- job['error_message'] = error.message.to_s[0, 10_000]
290
+ job['error_message'] = JobUtil.error_message(error)
215
291
  job['failed_at'] ||= Time.now.to_f
216
292
 
217
293
  max_retries = JobUtil.max_retries(job)
218
294
  new_count = job['retry_count'].to_i + 1
219
295
  job['retry_count'] = new_count
296
+ retrying = new_count <= max_retries
297
+ delay = retry_delay(new_count) if retrying
298
+
299
+ begin
300
+ payload = JSON.generate(job)
301
+ Cogworker.config.redis do |c|
302
+ retrying ? c.zadd(RedisKeys::RETRY, Time.now.to_f + delay, payload) : c.zadd(RedisKeys::DEAD, Time.now.to_f, payload)
303
+ end
304
+ rescue StandardError => e
305
+ Cogworker.logger.error { "couldn't record failure of jid=#{job['jid']}: #{e.class}: #{e.message}" }
306
+ return false
307
+ end
220
308
 
221
- if new_count <= max_retries
222
- delay = retry_delay(new_count)
223
- Cogworker.config.redis { |c| c.zadd(RedisKeys::RETRY, Time.now.to_f + delay, JSON.generate(job)) }
224
- extend_locks_for_retry(job, delay)
225
- Attempts.record(job['jid'], attempt: new_count, error: error, outcome: 'retrying')
226
- else
227
- Cogworker.config.redis { |c| c.zadd(RedisKeys::DEAD, Time.now.to_f, JSON.generate(job)) }
228
- Attempts.record(job['jid'], attempt: new_count, error: error, outcome: 'dead')
309
+ best_effort('lock extension') { extend_locks_for_retry(job, delay) } if retrying
310
+ best_effort('attempts log') do
311
+ Attempts.record(job['jid'], attempt: new_count, error: error, outcome: retrying ? 'retrying' : 'dead')
229
312
  end
313
+ true
230
314
  end
231
315
 
232
316
  # A job waiting out its retry backoff still holds its `until_executed`
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'digest/sha1'
4
+ require 'json'
4
5
 
5
6
  module Cogworker
6
7
  # The default fetch (`config.fetch = :reliable`): every job is moved
@@ -41,109 +42,181 @@ module Cogworker
41
42
  return false
42
43
  LUA
43
44
 
44
- # The hot path runs this by hash (EVALSHA), not by shipping the whole
45
- # script text on every poll; see #run_fetch_script.
46
- FETCH_SCRIPT_SHA = Digest::SHA1.hexdigest(FETCH_SCRIPT)
47
-
48
- # KEYS[1] = an in-progress list; ARGV[1] = queue key prefix; ARGV[2] =
49
- # queue for a payload whose own `queue` can't be read (the Processor
50
- # then buries it as unparseable); ARGV[3] = periodic running-lock key
51
- # prefix, ARGV[4] = its queued TTL. Oldest first, each onto the end its
52
- # queue is popped from, so recovered jobs run next, in their original
53
- # order. Atomic per call: two processes recovering the same list can't
54
- # both move one job. A job from an `until_executed` periodic entry
55
- # re-takes its running lock on the way (same rule as
56
- # Periodic::RunningLock::RETAKE_FUNCTION), so the ticker can't start
57
- # the next slot alongside the recovered run.
58
- REQUEUE_SCRIPT = Periodic::RunningLock::RETAKE_FUNCTION + <<~LUA
59
- local moved = 0
60
- while true do
61
- local job = redis.call("LPOP", KEYS[1])
62
- if not job then
63
- return moved
64
- end
65
- local queue = ARGV[2]
45
+ # (Run by hash, like every script here — see LuaScript.)
46
+ FETCH_SCRIPT_SHA = LuaScript.sha(FETCH_SCRIPT)
47
+
48
+ # Lua shared by every script here that puts a job back on its queue:
49
+ # onto the end its queue is popped from (so it runs next), onto
50
+ # `default_queue` if its own `queue` can't be read (the Processor then
51
+ # buries it as unparseable) — and a job from an `until_executed`
52
+ # periodic entry re-takes its running lock on the way
53
+ # (Periodic::RunningLock::RETAKE_FUNCTION), so the ticker can't start
54
+ # the next slot alongside the requeued run.
55
+ REQUEUE_JOB_FUNCTION = Periodic::RunningLock::RETAKE_FUNCTION + <<~LUA
56
+ local function requeue_job(job, queue_prefix, default_queue, lock_prefix, lock_ttl)
57
+ local queue = default_queue
66
58
  local ok, decoded = pcall(cjson.decode, job)
67
59
  if ok and type(decoded) == "table" then
68
- if type(decoded["queue"]) == "string" then
60
+ if type(decoded["queue"]) == "string" and decoded["queue"] ~= "" then
69
61
  queue = decoded["queue"]
70
62
  end
71
63
  local pjid, jid = decoded["periodic_pjid"], decoded["jid"]
72
64
  if decoded["periodic_until_executed"] == true and type(pjid) == "string" and type(jid) == "string" then
73
- retake_running_lock(ARGV[3] .. pjid, jid, ARGV[4])
65
+ retake_running_lock(lock_prefix .. pjid, jid, lock_ttl)
74
66
  end
75
67
  end
76
- redis.call("RPUSH", ARGV[1] .. queue, job)
68
+ redis.call("RPUSH", queue_prefix .. queue, job)
69
+ end
70
+ LUA
71
+
72
+ # KEYS[1] = an in-progress list, KEYS[2] = its owner's presence key,
73
+ # KEYS[3] = LAST_BEAT; ARGV[1..4] = requeue_job's prefix/default
74
+ # queue/lock prefix/lock TTL, ARGV[5] = owner identity, ARGV[6] = orphan
75
+ # threshold, or "" to requeue unconditionally (this process's own list,
76
+ # on shutdown). Returns how many it moved, or -1 if the owner turned
77
+ # out to be alive. Oldest first, so recovered jobs keep their order. The
78
+ # "is the owner really dead?" check (see ReliableFetch.dead?) is made
79
+ # here, atomically with the requeue: checked separately, an owner that
80
+ # beat in between would lose jobs it is still running to a second run.
81
+ # Atomic per call, so two processes recovering one list can't both move
82
+ # a job.
83
+ REQUEUE_SCRIPT = REQUEUE_JOB_FUNCTION + <<~LUA
84
+ if ARGV[6] ~= "" then
85
+ if redis.call("EXISTS", KEYS[2]) == 1 then
86
+ return -1
87
+ end
88
+ local last = redis.call("HGET", KEYS[3], ARGV[5])
89
+ if last then
90
+ if redis.replicate_commands then redis.replicate_commands() end
91
+ local now = tonumber(redis.call("TIME")[1])
92
+ if now - tonumber(last) <= tonumber(ARGV[6]) then
93
+ return -1
94
+ end
95
+ end
96
+ end
97
+ local moved = 0
98
+ while true do
99
+ local job = redis.call("LPOP", KEYS[1])
100
+ if not job then
101
+ return moved
102
+ end
103
+ requeue_job(job, ARGV[1], ARGV[2], ARGV[3], ARGV[4])
77
104
  moved = moved + 1
78
105
  end
79
106
  LUA
80
107
 
81
- # KEYS[1] = in-progress list, KEYS[2] = the job's queue; ARGV[1] = job.
82
- GIVE_BACK_SCRIPT = <<~LUA
108
+ # KEYS[1] = an in-progress list; ARGV[1] = one job on it, ARGV[2..5] =
109
+ # requeue_job's arguments. That one job, if still on the list, back on
110
+ # its queue: `give_back`, and `reconcile`'s strays.
111
+ REQUEUE_ONE_SCRIPT = REQUEUE_JOB_FUNCTION + <<~LUA
83
112
  if redis.call("LREM", KEYS[1], 1, ARGV[1]) == 1 then
84
- redis.call("RPUSH", KEYS[2], ARGV[1])
113
+ requeue_job(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5])
114
+ return 1
85
115
  end
116
+ return 0
117
+ LUA
118
+
119
+ # KEYS[1] = in-progress list, KEYS[2] = retry/dead ZSET; ARGV[1] = the
120
+ # job as fetched, ARGV[2] = score, ARGV[3] = payload to file. Off the
121
+ # in-progress list and into the ZSET in one step; a no-op if it's no
122
+ # longer in progress (already requeued by a shutdown).
123
+ INTERRUPT_SCRIPT = <<~LUA
124
+ if redis.call("LREM", KEYS[1], 1, ARGV[1]) == 1 then
125
+ redis.call("ZADD", KEYS[2], ARGV[2], ARGV[3])
126
+ return 1
127
+ end
128
+ return 0
86
129
  LUA
87
130
 
88
131
  class << self
132
+ # A connection error propagates: can't tell yet, so the caller
133
+ # (Manager#fetch_class) resolves again on its next use, instead of
134
+ # memoizing a guess made while Redis was unreachable. Anything else
135
+ # (INFO restricted, an odd version string) assumes a current server —
136
+ # the runtime fall-back (UnsupportedError) still catches a wrong guess.
89
137
  def supported?
90
138
  version = Cogworker.config.redis { |c| c.info('server')['redis_version'] }
91
139
  Gem::Version.new(version) >= MIN_REDIS_VERSION
140
+ rescue Redis::BaseConnectionError
141
+ raise
92
142
  rescue StandardError
93
- true # can't tell (Redis not reachable yet): assume a current server
143
+ true
94
144
  end
95
145
 
96
146
  # Puts every job still on `identity`'s in-progress list back on its
97
- # queue; returns how many. Used for this process's own unfinished jobs
98
- # on shutdown (`Manager#stop!`) and for dead processes' lists.
99
- def requeue_in_progress(identity)
147
+ # queue; returns how many — or -1 if `if_dead_for:` is given and the
148
+ # owner turns out not to be dead (see REQUEUE_SCRIPT). Used without
149
+ # it for this process's own unfinished jobs on shutdown
150
+ # (`Manager#stop!`), with it for presumed-dead processes' lists.
151
+ def requeue_in_progress(identity, if_dead_for: nil)
100
152
  Cogworker.config.redis do |c|
101
- c.eval(REQUEUE_SCRIPT, keys: [RedisKeys.in_progress(identity)],
102
- argv: [RedisKeys::QUEUE_PREFIX, 'default', RedisKeys.periodic_running(''),
103
- Periodic::RunningLock.queued_ttl])
153
+ LuaScript.run(c, REQUEUE_SCRIPT,
154
+ keys: [RedisKeys.in_progress(identity), RedisKeys.process(identity), RedisKeys::LAST_BEAT],
155
+ argv: [*requeue_args, identity, if_dead_for.to_s])
104
156
  end
105
157
  end
106
158
 
107
- # Requeues the in-progress list of every process that has gone
108
- # `config.orphan_threshold` without a heartbeat — i.e. that died
109
- # without a clean shutdown (see #dead?). A process always
110
- # writes its heartbeat before its first fetch (`Heartbeat#start!`), so
111
- # a live process's list is never mistaken for an orphan. Walks
112
- # IN_PROGRESS_IDENTITIES (each reliable-fetch process registers itself
113
- # there on every beat) rather than SCANning the keyspace; `scan: true`
114
- # (once per process, at boot) also picks up lists from before that
115
- # set existed.
159
+ def requeue_args
160
+ [RedisKeys::QUEUE_PREFIX, 'default', RedisKeys.periodic_running(''), Periodic::RunningLock.queued_ttl]
161
+ end
162
+
163
+ # Requeues the in-progress list of every process that has died without
164
+ # a clean shutdown. "Dead" means its presence key is gone (no beat for
165
+ # Heartbeat::TTL) *and* `config.orphan_threshold` has passed since its
166
+ # last recorded beat (LAST_BEAT, on Redis's clock) — the presence key
167
+ # alone expires after a minute, too eagerly for a live process that
168
+ # merely couldn't beat for a while (a Redis outage or failover, a long
169
+ # GVL-holding call), whose running jobs would then run twice. With no
170
+ # recorded beat at all (lists from before LAST_BEAT existed), the
171
+ # presence key alone decides. Checked inside REQUEUE_SCRIPT, atomically
172
+ # with the requeue. A process always beats before its first fetch
173
+ # (`Heartbeat#start!`). Walks IN_PROGRESS_IDENTITIES (each
174
+ # reliable-fetch process registers itself there on every beat) rather
175
+ # than SCANning the keyspace; `scan: true` (once per reliable process,
176
+ # at boot) also picks up lists from before that set existed.
116
177
  def recover_orphans(scan: false)
117
178
  own = Cogworker.identity
118
179
  candidate_identities(scan: scan).sum do |identity|
119
- next 0 if identity == own || !dead?(identity)
180
+ next 0 if identity == own
120
181
 
121
- requeue_in_progress(identity).tap do |moved|
122
- forget_if_empty(identity)
123
- next unless moved.positive?
182
+ moved = requeue_in_progress(identity, if_dead_for: Cogworker.config.orphan_threshold)
183
+ next 0 if moved.negative? # alive after all: keep its registration and last beat
124
184
 
125
- Cogworker.logger.warn { "requeued #{moved} job(s) left in progress by dead process #{identity}" }
126
- end
185
+ forget_if_empty(identity)
186
+ Cogworker.logger.warn { "requeued #{moved} job(s) left in progress by dead process #{identity}" } if moved.positive?
187
+ moved
127
188
  end
128
189
  end
129
190
 
130
- # Its presence key gone (no beat for Heartbeat::TTL) is not enough on
131
- # its own: a live process that just couldn't beat for a minute (a
132
- # Redis outage or failover, a long GVL-holding call) would lose its
133
- # in-progress jobs to a second, parallel run. It also has to have gone
134
- # `orphan_threshold` since its last recorded beat — compared on
135
- # Redis's clock on both sides, so host clock skew doesn't matter. No
136
- # recorded beat at all (a list from before LAST_BEAT existed) falls
137
- # back to the presence key alone.
138
- def dead?(identity)
191
+ # This process's own in-progress entries that no processor thread is
192
+ # running (not in `cogworker:workers:<identity>`) — left behind when
193
+ # neither acknowledging nor handing a job back worked. Requeued once
194
+ # they've been seen stray on two calls in a row (the caller passes
195
+ # back what the previous call returned), which rules out a job caught
196
+ # in the instant between being fetched and registered. Returns this
197
+ # call's strays that weren't requeued yet.
198
+ def reconcile(identity, previous_strays = [])
139
199
  Cogworker.config.redis do |c|
140
- next false if c.exists?(RedisKeys.process(identity))
200
+ listed = c.lrange(RedisKeys.in_progress(identity), 0, -1)
201
+ running = c.hvals(RedisKeys.workers(identity)).filter_map { |raw| parse_hash(raw)&.dig('payload', 'jid') }
202
+ strays = listed.reject { |raw| running.include?(parse_hash(raw)&.fetch('jid', nil)) }
203
+ (strays & previous_strays).each do |raw|
204
+ next unless LuaScript.run(c, REQUEUE_ONE_SCRIPT, keys: [RedisKeys.in_progress(identity)],
205
+ argv: [raw, *requeue_args]) == 1
141
206
 
142
- last = c.hget(RedisKeys::LAST_BEAT, identity)
143
- last.nil? || c.time.first - last.to_f > Cogworker.config.orphan_threshold
207
+ Cogworker.logger.warn { "requeued stray in-progress job jid=#{parse_hash(raw)&.fetch('jid', nil)}" }
208
+ end
209
+ strays - previous_strays
144
210
  end
145
211
  end
146
212
 
213
+ def parse_hash(raw)
214
+ value = JSON.parse(raw)
215
+ value.is_a?(Hash) ? value : nil
216
+ rescue JSON::ParserError
217
+ nil
218
+ end
219
+
147
220
  def candidate_identities(scan:)
148
221
  Cogworker.config.redis do |c|
149
222
  identities = c.smembers(RedisKeys::IN_PROGRESS_IDENTITIES)
@@ -169,10 +242,13 @@ module Cogworker
169
242
  def retrieve_work
170
243
  keys = active_keys
171
244
  if keys.empty?
172
- sleep(TIMEOUT)
245
+ interruptible_sleep(TIMEOUT)
173
246
  return nil
174
247
  end
175
248
 
249
+ distinct = keys.uniq
250
+ return blocking_fetch(distinct.first) if distinct.size == 1
251
+
176
252
  result = run_fetch_script(keys.shuffle.uniq + [RedisKeys.in_progress(Cogworker.identity)])
177
253
  unless result
178
254
  idle_sleep
@@ -189,8 +265,15 @@ module Cogworker
189
265
  # no longer in progress (already requeued by `requeue_in_progress`).
190
266
  def give_back(work)
191
267
  Cogworker.config.redis do |c|
192
- c.eval(GIVE_BACK_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity), RedisKeys.queue(work.queue)],
193
- argv: [work.raw_job])
268
+ LuaScript.run(c, REQUEUE_ONE_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity)],
269
+ argv: [work.raw_job, *self.class.requeue_args])
270
+ end
271
+ end
272
+
273
+ def interrupt(work, set, score, payload)
274
+ Cogworker.config.redis do |c|
275
+ LuaScript.run(c, INTERRUPT_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity), set],
276
+ argv: [work.raw_job, score, payload])
194
277
  end
195
278
  end
196
279
 
@@ -200,14 +283,22 @@ module Cogworker
200
283
 
201
284
  private
202
285
 
203
- def run_fetch_script(keys)
204
- Cogworker.config.redis do |c|
205
- c.evalsha(FETCH_SCRIPT_SHA, keys: keys)
206
- rescue Redis::CommandError => e
207
- raise unless e.message.start_with?('NOSCRIPT')
208
-
209
- c.eval(FETCH_SCRIPT, keys: keys) # loads it into the script cache for next time
286
+ # With a single (unpaused) queue there's no weighting to honor, so no
287
+ # need to poll: a blocking BLMOVE waits on the server and returns the
288
+ # moment a job arrives, for up to TIMEOUT — same as BasicFetch's BRPOP.
289
+ def blocking_fetch(queue_key)
290
+ raw_job = Cogworker.config.redis do |c|
291
+ c.blmove(queue_key, RedisKeys.in_progress(Cogworker.identity), 'RIGHT', 'LEFT', timeout: TIMEOUT)
210
292
  end
293
+ raw_job && UnitOfWork.new(queue_key.delete_prefix(RedisKeys::QUEUE_PREFIX), raw_job)
294
+ rescue Redis::CommandError => e
295
+ raise unless e.message.match?(/unknown (redis )?command/i)
296
+
297
+ raise UnsupportedError, e.message
298
+ end
299
+
300
+ def run_fetch_script(keys)
301
+ Cogworker.config.redis { |c| LuaScript.run(c, FETCH_SCRIPT, keys: keys) }
211
302
  rescue Redis::CommandError => e
212
303
  raise unless e.message.match?(/unknown (redis )?command/i)
213
304
 
@@ -219,7 +310,7 @@ module Cogworker
219
310
  def idle_sleep
220
311
  max = Cogworker.config.fetch_idle_max_interval
221
312
  @idle_interval = @idle_interval ? [@idle_interval * 2, max].min : [EMPTY_POLL_INTERVAL, max].min
222
- sleep(@idle_interval * (0.75 + (rand * 0.25)))
313
+ interruptible_sleep(@idle_interval * (0.75 + (rand * 0.25)))
223
314
  end
224
315
  end
225
316
  end
@@ -55,6 +55,12 @@ module Cogworker
55
55
  @thread&.kill
56
56
  end
57
57
 
58
+ # True once the first orphan check has completed — what the periodic
59
+ # Ticker waits for before its first tick (see Launcher#initialize).
60
+ def recovered_once?
61
+ @recovered_once == true
62
+ end
63
+
58
64
  private
59
65
 
60
66
  # A failed poll is logged and retried next interval rather than ending
@@ -72,13 +78,17 @@ module Cogworker
72
78
  end
73
79
  end
74
80
 
81
+ # Next check scheduled only once this one has completed: a failed one
82
+ # (Redis down) is retried on the next poll rather than a minute later.
75
83
  def recover_orphans_if_due
76
84
  now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
77
85
  return if @next_orphan_check && now < @next_orphan_check
78
86
 
79
- scan = @next_orphan_check.nil? && @manager.fetch_class == ReliableFetch
87
+ reliable = @manager.fetch_class == ReliableFetch
88
+ ReliableFetch.recover_orphans(scan: !@recovered_once && reliable)
89
+ @strays = ReliableFetch.reconcile(Cogworker.identity, @strays || []) if reliable
90
+ @recovered_once = true
80
91
  @next_orphan_check = now + ORPHAN_CHECK_INTERVAL
81
- ReliableFetch.recover_orphans(scan: scan)
82
92
  end
83
93
 
84
94
  def enqueue_due_jobs
@@ -104,8 +114,8 @@ module Cogworker
104
114
  def bury(conn, set, raw)
105
115
  error = JSON::ParserError.new("not a job (JSON object with a queue) in #{set}")
106
116
  job = JobUtil.unparseable_job(raw, queue: nil, error: error)
107
- buried = conn.eval(BURY_SCRIPT, keys: [set, RedisKeys::DEAD, RedisKeys::STATS_FAILED],
108
- argv: [raw, job['failed_at'], JSON.generate(job)])
117
+ buried = LuaScript.run(conn, BURY_SCRIPT, keys: [set, RedisKeys::DEAD, RedisKeys::STATS_FAILED],
118
+ argv: [raw, job['failed_at'], JSON.generate(job)])
109
119
  return unless buried == 1
110
120
 
111
121
  Throughput.record('failed') # counted like Processor#bury_unparseable
@@ -15,7 +15,7 @@ module Cogworker
15
15
  write(job, queue, 'complete')
16
16
  rescue Exception => e # rubocop:disable Lint/RescueException
17
17
  write(job, queue, JobUtil.terminal_failure?(job) ? 'failed' : 'retrying',
18
- error_class: e.class.name, error_message: e.message.to_s[0, 10_000])
18
+ error_class: e.class.name, error_message: JobUtil.error_message(e))
19
19
  raise e
20
20
  end
21
21
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cogworker
4
- VERSION = '0.4.0'
4
+ VERSION = '0.4.1'
5
5
  end
@@ -20,6 +20,13 @@ module Cogworker
20
20
  def call(env)
21
21
  env['rack.session'] ? @app.call(env) : @with_session.call(env)
22
22
  end
23
+
24
+ # A session middleware added via `Web.use` sits *after* this one in
25
+ # the stack, so it can't be detected per request the way an upstream
26
+ # one is — `Web.build_app` checks for one up front instead.
27
+ def self.session_middleware?(middleware)
28
+ middleware.is_a?(Class) && middleware <= Rack::Session::Abstract::Persisted
29
+ end
23
30
  end
24
31
  end
25
32
  end
data/lib/cogworker/web.rb CHANGED
@@ -104,9 +104,10 @@ module Cogworker
104
104
  @session_secret ||= ENV.fetch('COGWORKER_SESSION_SECRET') { SecureRandom.hex(32) }
105
105
  end
106
106
 
107
- # `false` drops the built-in session cookie entirely (it's already
108
- # skipped automatically whenever the host app provides a session —
109
- # see OptionalSession). Default true.
107
+ # `false` drops the built-in session cookie entirely. It's already
108
+ # skipped automatically whenever the host app provides a session
109
+ # (OptionalSession) or one is added via `Web.use` (a
110
+ # `Rack::Session::*` class — see `build_app`). Default true.
110
111
  def builtin_session
111
112
  @builtin_session.nil? || @builtin_session
112
113
  end
@@ -258,7 +259,7 @@ module Cogworker
258
259
 
259
260
  def build_app
260
261
  builder = Rack::Builder.new
261
- if builtin_session
262
+ if builtin_session && middlewares.none? { |mw, _| Cogworker::Web::OptionalSession.session_middleware?(mw) }
262
263
  builder.use(Cogworker::Web::OptionalSession, secret: session_secret, key: 'cogworker.session')
263
264
  end
264
265
  # No `cache_control:` (no `immutable`/long `max-age`): these files
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cogworker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - vdonec
@@ -222,6 +222,7 @@ files:
222
222
  - lib/cogworker/job_util.rb
223
223
  - lib/cogworker/launcher.rb
224
224
  - lib/cogworker/logging.rb
225
+ - lib/cogworker/lua_script.rb
225
226
  - lib/cogworker/manager.rb
226
227
  - lib/cogworker/middleware/chain.rb
227
228
  - lib/cogworker/owned_key.rb