cogworker 0.6.0 → 0.8.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.
- checksums.yaml +4 -4
- data/README.md +93 -2
- data/lib/cogworker/config.rb +5 -1
- data/lib/cogworker/death_notifier.rb +64 -0
- data/lib/cogworker/failure_decision.rb +131 -0
- data/lib/cogworker/heartbeat.rb +3 -1
- data/lib/cogworker/job.rb +34 -0
- data/lib/cogworker/job_failed_error.rb +9 -0
- data/lib/cogworker/job_orphaned_error.rb +8 -0
- data/lib/cogworker/job_util.rb +36 -0
- data/lib/cogworker/key_guard.rb +1 -0
- data/lib/cogworker/memory_history.rb +68 -0
- data/lib/cogworker/processor.rb +67 -30
- data/lib/cogworker/redis_keys.rb +1 -0
- data/lib/cogworker/reliable_fetch.rb +39 -14
- data/lib/cogworker/status.rb +12 -0
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/routes/jobs.rb +8 -2
- data/lib/cogworker/web/routes/overview.rb +28 -3
- data/lib/cogworker/web/routes/workers.rb +156 -5
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 472527fd7492d45c642f017b2cdc788c0741ebe7226b42b416aee90431b61194
|
|
4
|
+
data.tar.gz: '078375dda087dfe1064ed79e35b92b4fb2e4dbf017e68fb681b939d96a45fdea'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4e66fb20f141b58faa5c13eaa1e9df971d890a8bc5a77c845f80fbdcc621beacf2b03c5d9e1dbae68a9b4981a2e322a4147d1e366677e607cb069b846d2d905e
|
|
7
|
+
data.tar.gz: f3297307cd6201b8421a0acd54217d5a79cdaa1f93fed083ccbd7bd492cb9867340ffc52b787847058a0ce030ed5b17bd1b9d389fbdbc6ef0c64195f83f3422a
|
data/README.md
CHANGED
|
@@ -266,8 +266,99 @@ real worker would receive Strings instead of Symbols.
|
|
|
266
266
|
### Retries and Dead
|
|
267
267
|
|
|
268
268
|
A failed job moves to the Retry set with a growing delay
|
|
269
|
-
(`count**4 + 15 + jitter` seconds
|
|
270
|
-
|
|
269
|
+
(`count**4 + 15 + jitter` seconds, `count` being the retry about to happen,
|
|
270
|
+
1 for the first). `retry:` sets the budget: `true` or unset gives 25
|
|
271
|
+
retries, an integer that many, `false` or `0` none. When it runs out of
|
|
272
|
+
retries, it moves to Dead. You can put it back on its queue by hand from the
|
|
273
|
+
Web UI.
|
|
274
|
+
|
|
275
|
+
#### Custom delay: `cogworker_retry_in`
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
class SendEventJob
|
|
279
|
+
include Cogworker::Worker
|
|
280
|
+
cogworker_options retry: 10
|
|
281
|
+
|
|
282
|
+
cogworker_retry_in do |count, exception, job|
|
|
283
|
+
next :kill if exception.is_a?(PermanentError)
|
|
284
|
+
|
|
285
|
+
60 * (2**count)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
`count` is the number of retries made before this failure (0 on the first
|
|
291
|
+
one), `exception` what the attempt raised, `job` a copy of the job hash (the
|
|
292
|
+
block may take fewer parameters). It returns:
|
|
293
|
+
|
|
294
|
+
| Value | Effect |
|
|
295
|
+
|---|---|
|
|
296
|
+
| Integer / Float ≥ 0 | retry after that many seconds, no jitter added; 0 means on the next poll |
|
|
297
|
+
| `nil` | the default delay |
|
|
298
|
+
| `:kill` | straight to Dead, retries left or not; the death hooks run |
|
|
299
|
+
| `:discard` | dropped: neither Retry nor Dead, no death hooks; counted as failed, its unique locks released |
|
|
300
|
+
| anything else | the default delay, with a warning in the log |
|
|
301
|
+
|
|
302
|
+
If the block raises, the default delay is used and the error is logged. It
|
|
303
|
+
isn't called for a failure that is the last one anyway (no retry left, or
|
|
304
|
+
`retry: false`), nor for a job whose class can't be loaded. It's asked as
|
|
305
|
+
soon as `perform` raises, before any server middleware sees the error — so
|
|
306
|
+
it also runs when a middleware then swallows that error; keep it free of
|
|
307
|
+
side effects. A delay is never capped (one over a year is logged as a
|
|
308
|
+
likely mistake), and the job's `until_executed` lock is extended by it, so
|
|
309
|
+
the lock never runs out before the retry — however long that is. If
|
|
310
|
+
recording the failure itself fails (Redis trouble), the job is retried
|
|
311
|
+
after a fixed `30 s × n` instead, whatever the hook said. The hook is
|
|
312
|
+
inherited, so a base class of your own (`class ApplicationJob`) is the place
|
|
313
|
+
for an app-wide policy. The Web UI's retry timeline shows `:kill` and
|
|
314
|
+
`:discard` as such.
|
|
315
|
+
|
|
316
|
+
#### Death hooks: `cogworker_retries_exhausted` and `death_handlers`
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
class SendEventJob
|
|
320
|
+
include Cogworker::Worker
|
|
321
|
+
|
|
322
|
+
cogworker_retries_exhausted do |job, exception|
|
|
323
|
+
Metrics.increment('events.lost')
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
Cogworker.configure_server do |config|
|
|
328
|
+
config.death_handlers << ->(job, exception) { ErrorTracker.notify(exception, job) }
|
|
329
|
+
end
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
When a job lands in Dead for good, its class's `cogworker_retries_exhausted`
|
|
333
|
+
runs first, then every `death_handlers` entry in order. `job` is the entry
|
|
334
|
+
as stored in Dead (with `error_class`, `error_message`, `retry_count`,
|
|
335
|
+
`failed_at`); each hook gets its own copy, so changing it changes nothing.
|
|
336
|
+
|
|
337
|
+
| How the job died | Class hook | `death_handlers` |
|
|
338
|
+
|---|---|---|
|
|
339
|
+
| out of retries, or `retry: false` / `0` | yes | yes |
|
|
340
|
+
| `cogworker_retry_in` returned `:kill` | yes | yes |
|
|
341
|
+
| its process kept dying while running it (`config.max_orphanings`) | yes, with a `Cogworker::JobOrphanedError` | yes |
|
|
342
|
+
| its failure was filed later, by a reconcile pass | yes, with a `Cogworker::JobFailedError` carrying the original message (the original class is in `job['error_class']`) | yes |
|
|
343
|
+
| its class can't be loaded (renamed, removed) | no | yes |
|
|
344
|
+
| `cogworker_retry_in` returned `:discard` | no | no |
|
|
345
|
+
| an unparseable payload | no | no |
|
|
346
|
+
| deleted or retried from the Web UI | no | no |
|
|
347
|
+
|
|
348
|
+
The hooks run only after the job is safely in Dead, at most once per death:
|
|
349
|
+
on the reconcile paths, only the process that actually filed it runs them.
|
|
350
|
+
They run synchronously, on the processor thread (once the job is
|
|
351
|
+
acknowledged, before the thread takes the next one) or on the reconcile
|
|
352
|
+
thread, with no timeout of their own — **keep them quick**. One that raises
|
|
353
|
+
is logged and doesn't stop the others, nor the job's ack or lock release.
|
|
354
|
+
|
|
355
|
+
A hook can be skipped or cut short, never repeated: a process that crashes
|
|
356
|
+
right after the write never runs it, and on shutdown a hook still running
|
|
357
|
+
when the drain timeout (25 s) is up may be cut off as the process exits.
|
|
358
|
+
The job itself is safe either way — already in Dead and acknowledged, so it
|
|
359
|
+
won't run again. `Testing.inline!` raises the job's error to the caller as before
|
|
360
|
+
and runs no hooks; test a hook directly through
|
|
361
|
+
`SomeJob.cogworker_retry_in_block` / `SomeJob.cogworker_retries_exhausted_block`.
|
|
271
362
|
|
|
272
363
|
### Unique jobs
|
|
273
364
|
|
data/lib/cogworker/config.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Cogworker
|
|
|
9
9
|
FETCH_MODES = %i[reliable basic].freeze
|
|
10
10
|
|
|
11
11
|
attr_reader :server_chain, :client_chain, :periodic_manager, :redis_options, :fetch, :fetch_idle_max_interval,
|
|
12
|
-
:orphan_threshold, :max_orphanings, :periodic_replace
|
|
12
|
+
:orphan_threshold, :max_orphanings, :periodic_replace, :death_handlers
|
|
13
13
|
attr_accessor :concurrency, :queues, :periodic_catch_up, :unique_lock_ttl
|
|
14
14
|
|
|
15
15
|
def initialize
|
|
@@ -57,6 +57,10 @@ module Cogworker
|
|
|
57
57
|
# that crashed is counted, so keep this above the number of crashes an
|
|
58
58
|
# innocent job might plausibly sit through.
|
|
59
59
|
@max_orphanings = 3
|
|
60
|
+
# Callables `(job, exception)` run, after the job class's own
|
|
61
|
+
# `cogworker_retries_exhausted`, whenever a job lands in dead for good
|
|
62
|
+
# (DeathNotifier). `config.death_handlers << ->(job, e) { ... }`.
|
|
63
|
+
@death_handlers = []
|
|
60
64
|
register_default_middleware
|
|
61
65
|
end
|
|
62
66
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Cogworker
|
|
6
|
+
# Runs the death hooks for a job that has just been written to dead for
|
|
7
|
+
# good: the job class's own `cogworker_retries_exhausted`, then every
|
|
8
|
+
# `config.death_handlers` entry, in order. Called only by whoever's write
|
|
9
|
+
# actually put it there (so at most once per death), after that write —
|
|
10
|
+
# and, from a processor, only once the job is acknowledged, so a slow
|
|
11
|
+
# hook can't leave it on the in-progress list to be requeued and re-run.
|
|
12
|
+
#
|
|
13
|
+
# Each hook gets its own copy of the job (as stored in dead) and runs on
|
|
14
|
+
# its own: one that raises is logged and never stops the others, nor the
|
|
15
|
+
# ack / lock releases / reconcile pass around the call. Synchronous, with
|
|
16
|
+
# no timeout — a hook should be quick.
|
|
17
|
+
module DeathNotifier
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def notify(job, exception)
|
|
21
|
+
hook = job_class(job['class'])&.cogworker_retries_exhausted_block
|
|
22
|
+
run('cogworker_retries_exhausted', job) { |copy| JobUtil.call_hook(hook, copy, exception) } if hook
|
|
23
|
+
Cogworker.config.death_handlers.each do |handler|
|
|
24
|
+
run('death handler', job) { |copy| JobUtil.call_hook(handler, copy, exception) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A dead entry filed from its stored record (no exception object left).
|
|
29
|
+
def notify_failed(payload)
|
|
30
|
+
job = parse(payload)
|
|
31
|
+
notify(job, JobFailedError.new(job['error_message'].to_s)) if job
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def notify_orphaned(job)
|
|
35
|
+
notify(job, JobOrphanedError.new("orphaned more than #{Cogworker.config.max_orphanings} times"))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def run(what, job)
|
|
39
|
+
yield JobUtil.deep_copy(job)
|
|
40
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- user code, isolated like `perform`
|
|
41
|
+
Cogworker.logger.error do
|
|
42
|
+
"#{what} failed for jid=#{job['jid']} (ignored): #{e.class}: #{JobUtil.error_message(e)}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# nil for a class that can't be loaded (renamed, removed, never defined)
|
|
47
|
+
# or doesn't use the job DSL: only `death_handlers` run then.
|
|
48
|
+
def job_class(name)
|
|
49
|
+
return nil unless name.is_a?(String) && !name.empty?
|
|
50
|
+
|
|
51
|
+
klass = Object.const_get(name)
|
|
52
|
+
klass.respond_to?(:cogworker_retries_exhausted_block) ? klass : nil
|
|
53
|
+
rescue StandardError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def parse(payload)
|
|
58
|
+
job = JSON.parse(payload)
|
|
59
|
+
job.is_a?(Hash) ? job : nil
|
|
60
|
+
rescue JSON::ParserError, TypeError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
# What becomes of a failed attempt: `retry` (after `delay` seconds — nil
|
|
5
|
+
# for the default backoff), `dead` or `discard`. Made once per failure:
|
|
6
|
+
# by the job class's `cogworker_retry_in` hook where it has one, otherwise
|
|
7
|
+
# by the retry budget and the default backoff.
|
|
8
|
+
#
|
|
9
|
+
# `Processor` makes it inside the middleware chain, the moment `perform`
|
|
10
|
+
# raises, and (when a hook took part) stamps it on the job as
|
|
11
|
+
# `failure_outcome` so `JobUtil.terminal_failure?` — and with it Status,
|
|
12
|
+
# UniqueJobs and Periodic middleware — gives the same answer as the
|
|
13
|
+
# retry/dead write. The stamp never reaches a stored entry.
|
|
14
|
+
class FailureDecision
|
|
15
|
+
OUTCOMES = %w[retry dead discard].freeze
|
|
16
|
+
# Used as given, never capped — but a delay past this is most likely a
|
|
17
|
+
# unit mix-up (ms for s), and it keeps an `until_executed` lock that long.
|
|
18
|
+
LONG_DELAY = 365 * 24 * 60 * 60
|
|
19
|
+
|
|
20
|
+
attr_reader :outcome, :delay
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# `for`, stamped on the job as `failure_outcome` when the class's hook
|
|
24
|
+
# took part (see the class comment). nil if deciding itself failed.
|
|
25
|
+
def decide!(job, error, klass)
|
|
26
|
+
decision = self.for(job, error, klass)
|
|
27
|
+
job['failure_outcome'] = decision.outcome if decision.hooked?
|
|
28
|
+
decision
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
Cogworker.logger.error { "couldn't decide the fate of jid=#{job['jid']}: #{e.class}: #{e.message}" }
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# `klass` — the job's class, or nil when it couldn't be resolved (no
|
|
35
|
+
# hook then). The hook isn't asked about a failure that is the last
|
|
36
|
+
# one anyway: there's no retry to time.
|
|
37
|
+
def for(job, error, klass)
|
|
38
|
+
count = job['retry_count'].to_i
|
|
39
|
+
return new('dead') if count + 1 > JobUtil.max_retries(job)
|
|
40
|
+
|
|
41
|
+
hook = klass.respond_to?(:cogworker_retry_in_block) ? klass.cogworker_retry_in_block : nil
|
|
42
|
+
return new('retry') unless hook
|
|
43
|
+
|
|
44
|
+
from_hook(hook, job, error, count)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Grows with the retry count, with jitter to avoid a thundering herd
|
|
48
|
+
# of retries all landing on the same second.
|
|
49
|
+
def default_delay(count)
|
|
50
|
+
(count**4) + 15 + (rand(30) * (count + 1))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def from_hook(hook, job, error, count)
|
|
56
|
+
value = JobUtil.call_hook(hook, count, error, JobUtil.deep_copy(job))
|
|
57
|
+
case value
|
|
58
|
+
when nil then new('retry', hooked: true)
|
|
59
|
+
when :kill then new('dead', killed: true, hooked: true)
|
|
60
|
+
when :discard then new('discard', hooked: true)
|
|
61
|
+
else
|
|
62
|
+
if valid_delay?(value)
|
|
63
|
+
warn_long_delay(job, value) if value > LONG_DELAY
|
|
64
|
+
return new('retry', value, hooked: true)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
Cogworker.logger.warn do
|
|
68
|
+
"cogworker_retry_in of #{job['class']} returned #{value.inspect} for jid=#{job['jid']}; " \
|
|
69
|
+
'using the default backoff'
|
|
70
|
+
end
|
|
71
|
+
new('retry', hooked: true)
|
|
72
|
+
end
|
|
73
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- user code, isolated like `perform`
|
|
74
|
+
Cogworker.logger.error do
|
|
75
|
+
"cogworker_retry_in of #{job['class']} raised for jid=#{job['jid']} (using the default backoff): " \
|
|
76
|
+
"#{e.class}: #{JobUtil.error_message(e)}"
|
|
77
|
+
end
|
|
78
|
+
new('retry', hooked: true)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def warn_long_delay(job, value)
|
|
82
|
+
Cogworker.logger.warn do
|
|
83
|
+
"cogworker_retry_in of #{job['class']} returned #{value}s (over a year) for jid=#{job['jid']}; " \
|
|
84
|
+
'using it as given'
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def valid_delay?(value)
|
|
89
|
+
(value.is_a?(Integer) || (value.is_a?(Float) && value.finite?)) && value >= 0
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def initialize(outcome, delay = nil, killed: false, hooked: false)
|
|
94
|
+
@outcome = outcome
|
|
95
|
+
@delay = delay
|
|
96
|
+
@killed = killed
|
|
97
|
+
@hooked = hooked
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def retry?
|
|
101
|
+
outcome == 'retry'
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def dead?
|
|
105
|
+
outcome == 'dead'
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def discard?
|
|
109
|
+
outcome == 'discard'
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Whether the job class's hook took part (only then is the decision
|
|
113
|
+
# stamped on the job: without one, `terminal_failure?` already agrees).
|
|
114
|
+
def hooked?
|
|
115
|
+
@hooked
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Ended before its retries ran out: `:kill`, or `:discard`.
|
|
119
|
+
def cut_short?
|
|
120
|
+
@killed || discard?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The outcome as the attempts log (and the Web UI's timeline) shows it.
|
|
124
|
+
def attempt_outcome
|
|
125
|
+
return 'retrying' if retry?
|
|
126
|
+
return 'discarded' if discard?
|
|
127
|
+
|
|
128
|
+
@killed ? 'killed' : 'dead'
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
data/lib/cogworker/heartbeat.rb
CHANGED
|
@@ -75,7 +75,8 @@ module Cogworker
|
|
|
75
75
|
|
|
76
76
|
def cleanup_presence!
|
|
77
77
|
Cogworker.config.redis do |c|
|
|
78
|
-
c.del(RedisKeys.process(Cogworker.identity), RedisKeys.workers(Cogworker.identity)
|
|
78
|
+
c.del(RedisKeys.process(Cogworker.identity), RedisKeys.workers(Cogworker.identity),
|
|
79
|
+
RedisKeys.memory_history(Cogworker.identity))
|
|
79
80
|
c.srem?(RedisKeys::PROCESSES, Cogworker.identity)
|
|
80
81
|
# Clean shutdown: Manager#stop! already requeued this process's
|
|
81
82
|
# in-progress list, so there's nothing left for recovery to find.
|
|
@@ -132,6 +133,7 @@ module Cogworker
|
|
|
132
133
|
'quiet', @manager.quiet?.to_s)
|
|
133
134
|
c.expire(RedisKeys.process(identity), TTL)
|
|
134
135
|
write_registries(c, identity, reliable)
|
|
136
|
+
secondary('memory history') { MemoryHistory.record(c, identity, info['rss_kb']) }
|
|
135
137
|
touch_running_periodic_locks(c, identity)
|
|
136
138
|
# Not via #secondary: Redis failing here, after the presence key went
|
|
137
139
|
# through, doesn't make this beat a failed one.
|
data/lib/cogworker/job.rb
CHANGED
|
@@ -30,6 +30,32 @@ module Cogworker
|
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# `cogworker_retry_in { |count, exception, job| ... }` — the pause
|
|
34
|
+
# (seconds) before the next attempt, or `:kill` (straight to dead) /
|
|
35
|
+
# `:discard` (dropped), or `nil` for the default backoff. See
|
|
36
|
+
# `FailureDecision`. Kept on the class, never in the job payload.
|
|
37
|
+
def cogworker_retry_in(&block)
|
|
38
|
+
raise ArgumentError, 'cogworker_retry_in needs a block' unless block
|
|
39
|
+
|
|
40
|
+
@cogworker_retry_in_block = block
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# `cogworker_retries_exhausted { |job, exception| ... }` — called once
|
|
44
|
+
# the job is in dead for good (see `DeathNotifier`).
|
|
45
|
+
def cogworker_retries_exhausted(&block)
|
|
46
|
+
raise ArgumentError, 'cogworker_retries_exhausted needs a block' unless block
|
|
47
|
+
|
|
48
|
+
@cogworker_retries_exhausted_block = block
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def cogworker_retry_in_block
|
|
52
|
+
inherited_hook(:@cogworker_retry_in_block, :cogworker_retry_in_block)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def cogworker_retries_exhausted_block
|
|
56
|
+
inherited_hook(:@cogworker_retries_exhausted_block, :cogworker_retries_exhausted_block)
|
|
57
|
+
end
|
|
58
|
+
|
|
33
59
|
def perform_async(*args)
|
|
34
60
|
Client.push(job_payload('args' => args))
|
|
35
61
|
end
|
|
@@ -61,6 +87,14 @@ module Cogworker
|
|
|
61
87
|
|
|
62
88
|
private
|
|
63
89
|
|
|
90
|
+
# Looked up at call time (not copied at definition), so a subclass
|
|
91
|
+
# sees a hook its parent defines later too.
|
|
92
|
+
def inherited_hook(ivar, reader)
|
|
93
|
+
return instance_variable_get(ivar) if instance_variable_defined?(ivar)
|
|
94
|
+
|
|
95
|
+
superclass.respond_to?(reader) ? superclass.public_send(reader) : nil
|
|
96
|
+
end
|
|
97
|
+
|
|
64
98
|
def job_payload(extra)
|
|
65
99
|
cogworker_options_hash.transform_keys(&:to_s).merge('class' => name).merge(extra)
|
|
66
100
|
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
# Handed to death hooks (DeathNotifier) for a failure filed in dead later,
|
|
5
|
+
# from its stored record, when the original exception object is long gone.
|
|
6
|
+
# The message is the original one; its class is in `job['error_class']`.
|
|
7
|
+
class JobFailedError < StandardError
|
|
8
|
+
end
|
|
9
|
+
end
|
data/lib/cogworker/job_util.rb
CHANGED
|
@@ -61,10 +61,46 @@ module Cogworker
|
|
|
61
61
|
# `Periodic::ReleaseMiddleware` (releases the running-lock only on the
|
|
62
62
|
# terminal attempt) — previously three separate, subtly different copies
|
|
63
63
|
# of this same check, two of which had the `max_retries` bug above.
|
|
64
|
+
#
|
|
65
|
+
# A job class's `cogworker_retry_in` can end a job early (`:kill`,
|
|
66
|
+
# `:discard`): `Processor` stamps that decision on the job as
|
|
67
|
+
# `failure_outcome` (FailureDecision) before the middleware sees the
|
|
68
|
+
# failure, and it wins here when present.
|
|
64
69
|
def terminal_failure?(job)
|
|
70
|
+
outcome = job['failure_outcome']
|
|
71
|
+
return outcome != 'retry' if FailureDecision::OUTCOMES.include?(outcome)
|
|
72
|
+
|
|
65
73
|
job['retry_count'].to_i >= max_retries(job)
|
|
66
74
|
end
|
|
67
75
|
|
|
76
|
+
# A copy of a job hash to hand to user code, so changes it makes can't
|
|
77
|
+
# leak back. Never raises: unlike a JSON round trip, it copes with
|
|
78
|
+
# whatever middleware may have put in the hash (the job being on its
|
|
79
|
+
# way to `interrupt` precisely because that doesn't serialize).
|
|
80
|
+
def deep_copy(value)
|
|
81
|
+
case value
|
|
82
|
+
when Hash then value.to_h { |k, v| [deep_copy(k), deep_copy(v)] }
|
|
83
|
+
when Array then value.map { |v| deep_copy(v) }
|
|
84
|
+
when String then value.dup
|
|
85
|
+
else value
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Calls a user-supplied hook (a block, lambda, Method or any `#call`
|
|
90
|
+
# object) with as many of `args` as it takes: a block with fewer
|
|
91
|
+
# parameters ignores the rest anyway, a lambda or method would raise.
|
|
92
|
+
# Counted from `parameters` (required + optional), not `arity`, which
|
|
93
|
+
# is negative as soon as there's an optional one.
|
|
94
|
+
def call_hook(hook, *args)
|
|
95
|
+
callable = hook.is_a?(Proc) || hook.is_a?(Method) ? hook : hook.method(:call)
|
|
96
|
+
return hook.call(*args) if callable.is_a?(Proc) && !callable.lambda?
|
|
97
|
+
|
|
98
|
+
params = callable.parameters
|
|
99
|
+
return hook.call(*args) if params.any? { |type, _| type == :rest }
|
|
100
|
+
|
|
101
|
+
hook.call(*args.first(params.count { |type, _| %i[req opt].include?(type) }))
|
|
102
|
+
end
|
|
103
|
+
|
|
68
104
|
# The job Hash a raw `schedule`/`retry`/`dead` entry would requeue as —
|
|
69
105
|
# or `nil` if it can't be: not JSON, not an object, or no String
|
|
70
106
|
# `queue` to push it onto (e.g. an `unparseable_job` wrapper, whose
|
data/lib/cogworker/key_guard.rb
CHANGED
|
@@ -65,6 +65,7 @@ module Cogworker
|
|
|
65
65
|
readable_members(RedisKeys::PROCESSES).each do |id|
|
|
66
66
|
types[RedisKeys.process(id)] = 'hash'
|
|
67
67
|
types[RedisKeys.workers(id)] = 'hash'
|
|
68
|
+
types[RedisKeys.memory_history(id)] = 'zset'
|
|
68
69
|
end
|
|
69
70
|
readable_fields(RedisKeys::PERIODIC_SCHEDULE).each do |pjid|
|
|
70
71
|
types[RedisKeys.periodic_running(pjid)] = 'string'
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
# Per-process RSS for the last 24 hours, in SLOTS fixed SAMPLE_SECONDS
|
|
5
|
+
# slots, feeding the memory sparkline on the Workers tab's process cards.
|
|
6
|
+
# Written by Heartbeat on every beat; a slot keeps the *highest* reading
|
|
7
|
+
# any beat in it took (a peak is what matters for memory, and a short
|
|
8
|
+
# spike between two slot boundaries would otherwise vanish). One ZSET per
|
|
9
|
+
# identity, scored by slot start, members "<slot_start>:<rss_kb>" — so a
|
|
10
|
+
# slot's sample is unique by score, not by value. Old slots are trimmed
|
|
11
|
+
# on every write, and the key itself expires a little after the window,
|
|
12
|
+
# so a process that's gone leaves nothing behind for long.
|
|
13
|
+
module MemoryHistory
|
|
14
|
+
SAMPLE_SECONDS = 900
|
|
15
|
+
SLOTS = 96 # 24h of 15-minute slots
|
|
16
|
+
WINDOW = SLOTS * SAMPLE_SECONDS
|
|
17
|
+
KEY_TTL = WINDOW + 3600
|
|
18
|
+
|
|
19
|
+
# KEYS[1] = the identity's ZSET; ARGV = slot start, rss_kb, window, TTL.
|
|
20
|
+
RECORD_SCRIPT = <<~LUA
|
|
21
|
+
local slot = tonumber(ARGV[1])
|
|
22
|
+
local kb = tonumber(ARGV[2])
|
|
23
|
+
for _, member in ipairs(redis.call("ZRANGEBYSCORE", KEYS[1], slot, slot)) do
|
|
24
|
+
local seen = tonumber(string.match(member, ":(%d+)$"))
|
|
25
|
+
if seen and seen >= kb then kb = nil break end
|
|
26
|
+
end
|
|
27
|
+
if kb then
|
|
28
|
+
redis.call("ZREMRANGEBYSCORE", KEYS[1], slot, slot)
|
|
29
|
+
redis.call("ZADD", KEYS[1], slot, slot .. ":" .. kb)
|
|
30
|
+
end
|
|
31
|
+
redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", "(" .. (slot - tonumber(ARGV[3])))
|
|
32
|
+
redis.call("EXPIRE", KEYS[1], ARGV[4])
|
|
33
|
+
return 1
|
|
34
|
+
LUA
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
def record(conn, identity, rss_kb, at: Time.now.to_i)
|
|
39
|
+
return unless rss_kb
|
|
40
|
+
|
|
41
|
+
LuaScript.run(conn, RECORD_SCRIPT, keys: [RedisKeys.memory_history(identity)],
|
|
42
|
+
argv: [slot_for(at), rss_kb.to_i, WINDOW, KEY_TTL])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Per identity, exactly SLOTS `[slot_start, rss_kb_or_nil]` pairs,
|
|
46
|
+
# oldest first, the last one being the current (still filling) slot —
|
|
47
|
+
# a slot no beat wrote to is `nil`, so the chart always spans the full
|
|
48
|
+
# 24h whatever data exists. One pipelined read for every identity.
|
|
49
|
+
def series(identities, now: Time.now.to_i)
|
|
50
|
+
return {} if identities.empty?
|
|
51
|
+
|
|
52
|
+
first = slot_for(now) - ((SLOTS - 1) * SAMPLE_SECONDS)
|
|
53
|
+
raw = Cogworker.config.redis do |c|
|
|
54
|
+
c.pipelined do |pipe|
|
|
55
|
+
identities.each { |id| pipe.zrangebyscore(RedisKeys.memory_history(id), first, '+inf') }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
identities.zip(raw).to_h do |id, members|
|
|
59
|
+
values = Array(members).to_h { |m| m.split(':', 2).map(&:to_i) }
|
|
60
|
+
[id, Array.new(SLOTS) { |i| [first + (i * SAMPLE_SECONDS), values[first + (i * SAMPLE_SECONDS)]] }]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def slot_for(time)
|
|
65
|
+
time.to_i - (time.to_i % SAMPLE_SECONDS)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/cogworker/processor.rb
CHANGED
|
@@ -90,6 +90,8 @@ module Cogworker
|
|
|
90
90
|
@busy = true
|
|
91
91
|
@ran = nil
|
|
92
92
|
@performed = false
|
|
93
|
+
@decision = nil
|
|
94
|
+
@death = nil
|
|
93
95
|
disposition = nil
|
|
94
96
|
begin
|
|
95
97
|
disposition = execute(work)
|
|
@@ -104,6 +106,10 @@ module Cogworker
|
|
|
104
106
|
# can't re-queue a job that already ran.
|
|
105
107
|
settle(work, disposition || @ran || :give_back)
|
|
106
108
|
@manager.job_finished(work.raw_job)
|
|
109
|
+
# Only now, with the job acknowledged (or its ack remembered): a
|
|
110
|
+
# slow hook used to hold it on the in-progress list, where a
|
|
111
|
+
# shutdown or orphan recovery requeued it and ran it again.
|
|
112
|
+
DeathNotifier.notify(*@death) if @death
|
|
107
113
|
end
|
|
108
114
|
end
|
|
109
115
|
|
|
@@ -139,9 +145,10 @@ module Cogworker
|
|
|
139
145
|
payload.merge!('interrupted_count' => count, 'retry_count' => attempt,
|
|
140
146
|
'error_class' => error.class.name, 'error_message' => JobUtil.error_message(error),
|
|
141
147
|
'failed_at' => Time.now.to_f)
|
|
142
|
-
# No retries left (`retry: false`/`0`, or the last one used up
|
|
143
|
-
# no extra attempt either —
|
|
144
|
-
|
|
148
|
+
# No retries left (`retry: false`/`0`, or the last one used up, or the
|
|
149
|
+
# job's `cogworker_retry_in` ended it) means no extra attempt either —
|
|
150
|
+
# straight to dead, like any terminal failure.
|
|
151
|
+
out_of_retries = attempt > JobUtil.max_retries(payload) || @decision&.cut_short?
|
|
145
152
|
delay = INTERRUPT_DELAY * count
|
|
146
153
|
set, score = if out_of_retries || count > MAX_INTERRUPTS
|
|
147
154
|
[RedisKeys::DEAD, Time.now.to_f]
|
|
@@ -150,25 +157,15 @@ module Cogworker
|
|
|
150
157
|
end
|
|
151
158
|
encoded = JSON.generate(payload)
|
|
152
159
|
begin
|
|
153
|
-
fetcher.interrupt(work, set, score, encoded)
|
|
160
|
+
written = fetcher.interrupt(work, set, score, encoded) != 0 # (0: no longer ours to file)
|
|
154
161
|
rescue StandardError
|
|
155
162
|
# Remembered with its payload: this process's reconcile (and its
|
|
156
163
|
# shutdown) keep trying to file it — never re-run it as a stray.
|
|
157
164
|
@manager.settle_later(work.raw_job, [set, score, encoded]) if fetcher.is_a?(ReliableFetch)
|
|
158
165
|
raise
|
|
159
166
|
end
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
else
|
|
163
|
-
best_effort('lock release') { release_locks(payload) }
|
|
164
|
-
end
|
|
165
|
-
reason = if set == RedisKeys::RETRY
|
|
166
|
-
"retrying in #{delay}s (interruption #{count} of at most #{MAX_INTERRUPTS})"
|
|
167
|
-
elsif out_of_retries
|
|
168
|
-
'moved to dead: no retries left'
|
|
169
|
-
else
|
|
170
|
-
"moved to dead: interrupted more than #{MAX_INTERRUPTS} times"
|
|
171
|
-
end
|
|
167
|
+
after_interrupt(job, payload, error, set, delay, written)
|
|
168
|
+
reason = interrupt_reason(set, out_of_retries, count, delay)
|
|
172
169
|
Cogworker.logger.error { "couldn't record the failure of jid=#{payload['jid']} (#{error.class}); #{reason}" }
|
|
173
170
|
rescue StandardError => e
|
|
174
171
|
# Whatever failed (even before anything was written), remember the job
|
|
@@ -181,6 +178,21 @@ module Cogworker
|
|
|
181
178
|
end
|
|
182
179
|
end
|
|
183
180
|
|
|
181
|
+
def after_interrupt(job, payload, error, set, delay, written)
|
|
182
|
+
if set == RedisKeys::RETRY
|
|
183
|
+
best_effort('lock extension') { extend_locks_for_retry(job, delay) }
|
|
184
|
+
else
|
|
185
|
+
best_effort('lock release') { release_locks(payload) }
|
|
186
|
+
@death = [payload, error] if written # run by `process_one`, once settled
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def interrupt_reason(set, out_of_retries, count, delay)
|
|
191
|
+
return "retrying in #{delay}s (interruption #{count} of at most #{MAX_INTERRUPTS})" if set == RedisKeys::RETRY
|
|
192
|
+
|
|
193
|
+
out_of_retries ? 'moved to dead: no retries left' : "moved to dead: interrupted more than #{MAX_INTERRUPTS} times"
|
|
194
|
+
end
|
|
195
|
+
|
|
184
196
|
def remember_for_dead(work, error)
|
|
185
197
|
# The original job's own fields where readable (so filing it releases
|
|
186
198
|
# its locks); just enough to show in Dead otherwise.
|
|
@@ -272,10 +284,18 @@ module Cogworker
|
|
|
272
284
|
nil
|
|
273
285
|
end
|
|
274
286
|
|
|
287
|
+
@job_class = worker&.class
|
|
275
288
|
Cogworker.config.server_chain.invoke(worker, job, work.queue) do
|
|
276
289
|
raise resolution_error if resolution_error
|
|
277
290
|
|
|
278
|
-
|
|
291
|
+
begin
|
|
292
|
+
worker.perform(*job['args'])
|
|
293
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
294
|
+
# Retry, dead or discard — decided here, while the middleware is
|
|
295
|
+
# still to see the failure (FailureDecision.decide!).
|
|
296
|
+
@decision = FailureDecision.decide!(job, e, @job_class)
|
|
297
|
+
raise
|
|
298
|
+
end
|
|
279
299
|
end
|
|
280
300
|
nil
|
|
281
301
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
@@ -349,22 +369,26 @@ module Cogworker
|
|
|
349
369
|
# must happen; everything after it is best-effort. False if that write
|
|
350
370
|
# failed (`finish_failure` then interrupts the job instead).
|
|
351
371
|
def route_failure(job, error)
|
|
372
|
+
@decided_late = @decision.nil? # raised by middleware, not `perform`: decided after the chain
|
|
373
|
+
@decision ||= FailureDecision.decide!(job, error, @job_class)
|
|
374
|
+
decision = @decision || FailureDecision.new(JobUtil.terminal_failure?(job) ? 'dead' : 'retry')
|
|
375
|
+
job.delete('failure_outcome')
|
|
352
376
|
job['error_class'] = error.class.name
|
|
353
377
|
job['error_message'] = JobUtil.error_message(error)
|
|
354
378
|
job['failed_at'] ||= Time.now.to_f
|
|
355
|
-
|
|
356
|
-
max_retries = JobUtil.max_retries(job)
|
|
357
379
|
new_count = job['retry_count'].to_i + 1
|
|
358
380
|
job['retry_count'] = new_count
|
|
359
|
-
|
|
360
|
-
|
|
381
|
+
if decision.discard? # neither retry nor dead, no death hooks: the job just ends, counted as failed
|
|
382
|
+
after_failure_recorded(job, error, decision, nil)
|
|
383
|
+
return true
|
|
384
|
+
end
|
|
361
385
|
|
|
386
|
+
delay = decision.delay || retry_delay(new_count) if decision.retry?
|
|
362
387
|
begin
|
|
363
388
|
payload = JSON.generate(job)
|
|
364
389
|
Cogworker.config.redis do |c|
|
|
365
|
-
if
|
|
366
|
-
c.zadd(RedisKeys::RETRY, Time.now.to_f + delay,
|
|
367
|
-
payload)
|
|
390
|
+
if decision.retry?
|
|
391
|
+
c.zadd(RedisKeys::RETRY, Time.now.to_f + delay, payload)
|
|
368
392
|
else
|
|
369
393
|
c.zadd(RedisKeys::DEAD, Time.now.to_f, payload)
|
|
370
394
|
end
|
|
@@ -374,11 +398,25 @@ module Cogworker
|
|
|
374
398
|
return false
|
|
375
399
|
end
|
|
376
400
|
|
|
377
|
-
|
|
401
|
+
after_failure_recorded(job, error, decision, delay)
|
|
402
|
+
@death = [JSON.parse(payload), error] if decision.dead? # run by `process_one`, once settled
|
|
403
|
+
true
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def after_failure_recorded(job, error, decision, delay)
|
|
407
|
+
if decision.retry?
|
|
408
|
+
best_effort('lock extension') { extend_locks_for_retry(job, delay) }
|
|
409
|
+
elsif decision.cut_short?
|
|
410
|
+
# Normally the release middleware's, on the terminal attempt — but
|
|
411
|
+
# a decision made after the chain (a middleware raised) never
|
|
412
|
+
# reached it. Owner-checked, so releasing twice is harmless. Same
|
|
413
|
+
# for the status the Status middleware wrote then: 'retrying'.
|
|
414
|
+
best_effort('lock release') { release_locks(job) }
|
|
415
|
+
best_effort('status') { Status.mark_failed(job, error) } if @decided_late
|
|
416
|
+
end
|
|
378
417
|
best_effort('attempts log') do
|
|
379
|
-
Attempts.record(job['jid'], attempt:
|
|
418
|
+
Attempts.record(job['jid'], attempt: job['retry_count'], error: error, outcome: decision.attempt_outcome)
|
|
380
419
|
end
|
|
381
|
-
true
|
|
382
420
|
end
|
|
383
421
|
|
|
384
422
|
# A job waiting out its retry backoff still holds its `until_executed`
|
|
@@ -404,10 +442,9 @@ module Cogworker
|
|
|
404
442
|
Cogworker.config.redis { |c| JobUtil.release_terminal_locks(c, job) }
|
|
405
443
|
end
|
|
406
444
|
|
|
407
|
-
#
|
|
408
|
-
# retries all landing on the same second.
|
|
445
|
+
# The default backoff, for a job whose class has no say in it.
|
|
409
446
|
def retry_delay(count)
|
|
410
|
-
(count
|
|
447
|
+
FailureDecision.default_delay(count)
|
|
411
448
|
end
|
|
412
449
|
|
|
413
450
|
def register_in_workers(queue, job)
|
data/lib/cogworker/redis_keys.rb
CHANGED
|
@@ -47,6 +47,7 @@ module Cogworker
|
|
|
47
47
|
def job_attempts(jid) = "cogworker:job_attempts:#{jid}"
|
|
48
48
|
def process(identity) = "cogworker:process:#{identity}"
|
|
49
49
|
def workers(identity) = "cogworker:workers:#{identity}"
|
|
50
|
+
def memory_history(identity) = "cogworker:memory:#{identity}"
|
|
50
51
|
def in_progress(identity) = "#{IN_PROGRESS_PREFIX}#{identity}"
|
|
51
52
|
def signal(identity) = "cogworker:signal:#{identity}"
|
|
52
53
|
def periodic_running(pjid) = "periodic:running:#{pjid}"
|
|
@@ -404,20 +404,27 @@ module Cogworker
|
|
|
404
404
|
# `config.max_orphanings` times) in `dead`, with an error saying why — whatever
|
|
405
405
|
# its `retry` setting, since each of those runs ended with its process
|
|
406
406
|
# gone rather than an error to retry on. Parked entries survive a
|
|
407
|
-
# failure here and are picked up on the next pass.
|
|
407
|
+
# failure here and are picked up on the next pass. Death hooks run
|
|
408
|
+
# only for the entries this call itself moved (the script's 1), so
|
|
409
|
+
# processes reconciling side by side don't run them twice.
|
|
408
410
|
def bury_repeat_orphans
|
|
411
|
+
buried_jobs = []
|
|
409
412
|
Cogworker.config.redis do |c|
|
|
410
413
|
c.lrange(RedisKeys::REPEAT_ORPHANS, 0, -1).each do |raw|
|
|
411
|
-
|
|
414
|
+
readable = parse_hash(raw)
|
|
415
|
+
job = readable || JobUtil.unparseable_job(raw, queue: nil, error: JSON::ParserError.new('unreadable'))
|
|
412
416
|
job.merge!(repeat_orphan_error)
|
|
413
417
|
buried = LuaScript.run(c, BURY_ORPHAN_SCRIPT, keys: [RedisKeys::REPEAT_ORPHANS, RedisKeys::DEAD],
|
|
414
418
|
argv: [raw, Time.now.to_f, JSON.generate(job)])
|
|
415
419
|
next unless buried == 1
|
|
416
420
|
|
|
421
|
+
buried_jobs << job if readable # it's in dead now, whatever the bookkeeping below does
|
|
417
422
|
Cogworker.logger.error { "jid=#{job['jid']} orphaned too often, moved to dead" }
|
|
418
423
|
buried_as_dead(c, job)
|
|
419
424
|
end
|
|
420
425
|
end
|
|
426
|
+
ensure
|
|
427
|
+
buried_jobs.each { |job| DeathNotifier.notify_orphaned(job) }
|
|
421
428
|
end
|
|
422
429
|
|
|
423
430
|
def recover_identity(identity)
|
|
@@ -472,18 +479,20 @@ module Cogworker
|
|
|
472
479
|
def settle_pending(identity, pending)
|
|
473
480
|
key = RedisKeys.in_progress(identity)
|
|
474
481
|
pending.each do |raw, how|
|
|
475
|
-
Cogworker.config.redis do |c|
|
|
482
|
+
buried = Cogworker.config.redis do |c|
|
|
476
483
|
if how == :ack
|
|
477
484
|
jid = parse_hash(raw)&.fetch('jid', nil)
|
|
478
485
|
LuaScript.run(c, ACK_SCRIPT, keys: [key, RedisKeys::ORPHANINGS_PREFIX + jid.to_s], argv: [raw])
|
|
486
|
+
nil
|
|
479
487
|
else
|
|
480
488
|
set, score, payload = how
|
|
481
489
|
filed = LuaScript.run(c, INTERRUPT_SCRIPT, keys: [key, set, orphanings_key(raw)],
|
|
482
490
|
argv: [raw, score, payload])
|
|
483
|
-
|
|
491
|
+
filed == 1 ? filed_in_dead(c, set, payload) : nil
|
|
484
492
|
end
|
|
485
493
|
end
|
|
486
494
|
yield raw if block_given?
|
|
495
|
+
DeathNotifier.notify_failed(buried) if buried
|
|
487
496
|
rescue StandardError => e
|
|
488
497
|
Cogworker.logger.error do
|
|
489
498
|
"still couldn't settle jid=#{parse_hash(raw)&.fetch('jid', nil)}: #{e.class}: #{e.message}"
|
|
@@ -516,7 +525,9 @@ module Cogworker
|
|
|
516
525
|
# Files what shutting-down processes handed over in UNSETTLED, head
|
|
517
526
|
# first, stopping at the first that still can't be filed (retried on
|
|
518
527
|
# the next pass).
|
|
528
|
+
# Death hooks run for what this call filed in dead (the script's 1).
|
|
519
529
|
def file_unsettled
|
|
530
|
+
buried = []
|
|
520
531
|
Cogworker.config.redis do |c|
|
|
521
532
|
c.lrange(RedisKeys::UNSETTLED, 0, -1).each do |record|
|
|
522
533
|
entry = parse_hash(record)
|
|
@@ -525,9 +536,11 @@ module Cogworker
|
|
|
525
536
|
filed = LuaScript.run(c, FILE_UNSETTLED_SCRIPT,
|
|
526
537
|
keys: [RedisKeys::UNSETTLED, orphanings_key(entry['payload'])],
|
|
527
538
|
argv: [record, entry['set'], entry['score'], entry['payload']])
|
|
528
|
-
|
|
539
|
+
buried << filed_in_dead(c, entry['set'], entry['payload']) if filed == 1
|
|
529
540
|
end
|
|
530
541
|
end
|
|
542
|
+
ensure
|
|
543
|
+
buried.compact.each { |payload| DeathNotifier.notify_failed(payload) }
|
|
531
544
|
end
|
|
532
545
|
|
|
533
546
|
# Jobs REQUEUE_SCRIPT had to file in dead raw (its parking list
|
|
@@ -538,13 +551,19 @@ module Cogworker
|
|
|
538
551
|
# its error, its job's locks held and its failure uncounted.
|
|
539
552
|
def complete_raw_burials(raws)
|
|
540
553
|
raws.each do |raw|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
554
|
+
readable = parse_hash(raw)
|
|
555
|
+
job = (readable || {}).merge(repeat_orphan_error)
|
|
556
|
+
swapped = Cogworker.config.redis do |c|
|
|
557
|
+
LuaScript.run(c, SWAP_DEAD_ENTRY_SCRIPT, keys: [RedisKeys::DEAD],
|
|
558
|
+
argv: [raw, Time.now.to_f, JSON.generate(job)])
|
|
546
559
|
end
|
|
547
560
|
@raw_burials_mutex.synchronize { @pending_raw_burials.delete(raw) }
|
|
561
|
+
next unless swapped == 1
|
|
562
|
+
|
|
563
|
+
# The swap is done: the rest must not be retried (it would find
|
|
564
|
+
# nothing to swap and skip the hooks), so it's best-effort.
|
|
565
|
+
BestEffort.call('Orphan recovery') { Cogworker.config.redis { |c| buried_as_dead(c, job) } }
|
|
566
|
+
DeathNotifier.notify_orphaned(job) if readable
|
|
548
567
|
rescue StandardError => e
|
|
549
568
|
@raw_burials_mutex.synchronize { @pending_raw_burials << raw unless @pending_raw_burials.include?(raw) }
|
|
550
569
|
RedisErrors.report('Orphan recovery', e) { "a repeat orphan's dead entry not completed (will retry)" }
|
|
@@ -565,11 +584,17 @@ module Cogworker
|
|
|
565
584
|
|
|
566
585
|
# `set` must be one of ours to file into — not any key a damaged record
|
|
567
586
|
# names (another type's, which would fail every pass at that record).
|
|
568
|
-
#
|
|
569
|
-
# locks, as the chain would for a terminal failure
|
|
570
|
-
|
|
587
|
+
# Just filed by this process: if that was a (readable) job into dead,
|
|
588
|
+
# release its locks, as the chain would for a terminal failure, and
|
|
589
|
+
# return the payload — its death hooks are due. The release is
|
|
590
|
+
# best-effort: the job is in dead either way, and a raise here used
|
|
591
|
+
# to cost it its hooks (the next pass's script finds nothing to file).
|
|
592
|
+
def filed_in_dead(conn, set, payload)
|
|
571
593
|
job = parse_hash(payload)
|
|
572
|
-
|
|
594
|
+
return nil unless set == RedisKeys::DEAD && job
|
|
595
|
+
|
|
596
|
+
BestEffort.call('Lock release') { JobUtil.release_terminal_locks(conn, job) }
|
|
597
|
+
payload
|
|
573
598
|
end
|
|
574
599
|
|
|
575
600
|
def valid_unsettled?(entry)
|
data/lib/cogworker/status.rb
CHANGED
|
@@ -40,5 +40,17 @@ module Cogworker
|
|
|
40
40
|
def get(jid)
|
|
41
41
|
Storage.read(jid)
|
|
42
42
|
end
|
|
43
|
+
|
|
44
|
+
# Corrects a status already written as 'retrying' to 'failed' — for a
|
|
45
|
+
# job whose `cogworker_retry_in` ended it (`:kill`/`:discard`) only
|
|
46
|
+
# after the middleware had seen the failure (a middleware raised, not
|
|
47
|
+
# `perform`). A no-op for a job nothing tracks the status of.
|
|
48
|
+
def mark_failed(job, error)
|
|
49
|
+
return unless Storage.read(job['jid'])
|
|
50
|
+
|
|
51
|
+
Storage.write(job['jid'], default_expiration || DEFAULT_EXPIRATION,
|
|
52
|
+
'status' => 'failed', 'update_time' => Time.now.to_f,
|
|
53
|
+
'error_class' => error.class.name, 'error_message' => JobUtil.error_message(error))
|
|
54
|
+
end
|
|
43
55
|
end
|
|
44
56
|
end
|
data/lib/cogworker/version.rb
CHANGED
|
@@ -34,6 +34,12 @@ module Cogworker
|
|
|
34
34
|
# (still capped at 25, but the same 25 either way) is one click
|
|
35
35
|
# away via the History link below, not lost.
|
|
36
36
|
ATTEMPTS_DISPLAY_LIMIT = 5
|
|
37
|
+
# How the retry timeline reads each `Attempts` outcome; `killed`/
|
|
38
|
+
# `discarded` are the job class's `cogworker_retry_in` ending it early.
|
|
39
|
+
ATTEMPT_OUTCOME_LABELS = {
|
|
40
|
+
'dead' => 'moved to dead set', 'killed' => 'killed by retry_in, moved to dead set',
|
|
41
|
+
'discarded' => 'discarded by retry_in'
|
|
42
|
+
}.freeze
|
|
37
43
|
TAG_CLASS = { 'Enqueued' => 'tag-neutral', 'Running' => 'tag-accent', 'Scheduled' => 'tag-accent-2',
|
|
38
44
|
'Retrying' => 'tag-warning', 'Dead' => 'tag-danger' }.freeze
|
|
39
45
|
|
|
@@ -378,14 +384,14 @@ module Cogworker
|
|
|
378
384
|
|
|
379
385
|
shown = attempts.first(ATTEMPTS_DISPLAY_LIMIT)
|
|
380
386
|
items = shown.map do |a|
|
|
381
|
-
dot = a['outcome'] == '
|
|
387
|
+
dot = a['outcome'] == 'retrying' ? 'var(--color-warning)' : 'var(--color-danger)'
|
|
382
388
|
<<~HTML
|
|
383
389
|
<div style="display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 10px;">
|
|
384
390
|
<div style="display: flex; flex-direction: column; align-items: center;">
|
|
385
391
|
<span style="width: 9px; height: 9px; border-radius: 50%; background: #{dot}; margin-top: 5px;"></span>
|
|
386
392
|
</div>
|
|
387
393
|
<div style="padding-bottom: 12px;">
|
|
388
|
-
<div style="font-size: 13px;">Attempt #{a['attempt']} · #{a['outcome']
|
|
394
|
+
<div style="font-size: 13px;">Attempt #{a['attempt']} · #{ATTEMPT_OUTCOME_LABELS.fetch(a['outcome'], 'failed')}</div>
|
|
389
395
|
<div style="font-size: 12px; color: var(--color-neutral-500);">#{Layout.h(Time.at(a['failed_at']).utc.strftime(Web.time_format))} · #{Layout.h(a['error_class'])}: #{Layout.h(a['error_message'])}</div>
|
|
390
396
|
</div>
|
|
391
397
|
</div>
|
|
@@ -53,6 +53,13 @@ module Cogworker
|
|
|
53
53
|
'connected_clients' => 'Connections', 'used_memory_human' => 'Memory Usage',
|
|
54
54
|
'used_memory_peak_human' => 'Peak Memory Usage'
|
|
55
55
|
}.freeze
|
|
56
|
+
# Runtime dependency gem name => card label, for the "Cogworker"
|
|
57
|
+
# section — read from `Gem.loaded_specs`, so it shows what this
|
|
58
|
+
# process actually loaded, not the gemspec's version constraint.
|
|
59
|
+
GEM_INFO_DEPENDENCIES = {
|
|
60
|
+
'redis' => 'redis gem', 'connection_pool' => 'connection_pool', 'rack' => 'Rack',
|
|
61
|
+
'zeitwerk' => 'Zeitwerk', 'fugit' => 'Fugit'
|
|
62
|
+
}.freeze
|
|
56
63
|
# Period switcher options for the "Runs per day" chart —
|
|
57
64
|
# `params['period']` (a plain query string, read fresh on every
|
|
58
65
|
# request) selects one of these by key. Ordered as displayed,
|
|
@@ -95,6 +102,9 @@ module Cogworker
|
|
|
95
102
|
# part of CONTENT_ID's own poll_div.
|
|
96
103
|
page_body += Layout.poll_div(REDIS_CONTENT_ID, request.script_name, 'overview/redis',
|
|
97
104
|
Overview.redis_section)
|
|
105
|
+
# Static (versions don't change while the process runs), so
|
|
106
|
+
# it needs no poll of its own.
|
|
107
|
+
page_body += Overview.gem_info_section
|
|
98
108
|
end
|
|
99
109
|
extra_head = layout == 'a' ? Overview.chart_head(request.script_name) : ''
|
|
100
110
|
Layout.wrap('Overview', page_body, script_name: request.script_name, extra_head: extra_head)
|
|
@@ -261,17 +271,32 @@ module Cogworker
|
|
|
261
271
|
# it pinned below the charts rather than above them.
|
|
262
272
|
def redis_section
|
|
263
273
|
info = Cogworker::Stats.new.redis_info
|
|
264
|
-
|
|
274
|
+
info_section('Redis', REDIS_INFO_FIELDS.to_h { |field, label| [label, info[field]] })
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# The basics about the gem itself and what it's running on — the
|
|
278
|
+
# last block on the page, below the Redis grid.
|
|
279
|
+
def gem_info_section
|
|
280
|
+
spec = Gem.loaded_specs['cogworker']
|
|
281
|
+
values = { 'Version' => Cogworker::VERSION, 'Ruby' => "#{RUBY_VERSION} (#{RUBY_PLATFORM})" }
|
|
282
|
+
GEM_INFO_DEPENDENCIES.each { |name, label| values[label] = Gem.loaded_specs[name]&.version&.to_s }
|
|
283
|
+
values['License'] = spec&.license
|
|
284
|
+
info_section('Cogworker', values)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# A titled grid of label/value cards; a nil value renders as "n/a".
|
|
288
|
+
def info_section(title, values)
|
|
289
|
+
cards = values.map do |label, value|
|
|
265
290
|
<<~HTML
|
|
266
291
|
<div class="card elev-sm" style="gap: 4px;">
|
|
267
292
|
<span class="card-kicker">#{Layout.h(label)}</span>
|
|
268
|
-
<span style="font-size:
|
|
293
|
+
<span style="font-size: 15px; font-family: var(--font-heading);">#{Layout.h(value || 'n/a')}</span>
|
|
269
294
|
</div>
|
|
270
295
|
HTML
|
|
271
296
|
end.join
|
|
272
297
|
<<~HTML
|
|
273
298
|
<section style="background: var(--color-surface); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); padding: 16px 18px; display: flex; flex-direction: column; gap: 12px;">
|
|
274
|
-
<h4 style="margin: 0; font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 17px;"
|
|
299
|
+
<h4 style="margin: 0; font-family: var(--font-heading); font-weight: var(--font-heading-weight); font-size: 17px;">#{Layout.h(title)}</h4>
|
|
275
300
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px;">#{cards}</div>
|
|
276
301
|
</section>
|
|
277
302
|
HTML
|
|
@@ -13,6 +13,7 @@ module Cogworker
|
|
|
13
13
|
# honest place to look at what's actually running right now.
|
|
14
14
|
module Workers
|
|
15
15
|
CONTENT_ID = 'workers-content'
|
|
16
|
+
SPARKLINE_HEIGHT_PX = 32
|
|
16
17
|
|
|
17
18
|
module_function
|
|
18
19
|
|
|
@@ -22,7 +23,8 @@ module Cogworker
|
|
|
22
23
|
if hx_request?
|
|
23
24
|
content
|
|
24
25
|
else
|
|
25
|
-
Layout.wrap('Workers', Layout.poll_div(CONTENT_ID, request.script_name, 'workers', content)
|
|
26
|
+
Layout.wrap('Workers', Layout.poll_div(CONTENT_ID, request.script_name, 'workers', content) +
|
|
27
|
+
Workers.sparkline_script,
|
|
26
28
|
script_name: request.script_name)
|
|
27
29
|
end
|
|
28
30
|
end
|
|
@@ -90,7 +92,9 @@ module Cogworker
|
|
|
90
92
|
work_set = Cogworker::WorkSet.new.to_a
|
|
91
93
|
busy_counts = work_set.each_with_object(Hash.new(0)) { |(identity, *), h| h[identity] += 1 }
|
|
92
94
|
|
|
93
|
-
|
|
95
|
+
memory = Cogworker::MemoryHistory.series(processes.map(&:identity))
|
|
96
|
+
|
|
97
|
+
cards = process_cards(processes, busy_counts, memory, quiet_path, resume_path, stop_path)
|
|
94
98
|
|
|
95
99
|
work_rows = work_set.map do |identity, tid, work|
|
|
96
100
|
[Layout.h(identity), Layout.h(tid), Layout.h(work.job['jid']), Layout.h(work.queue),
|
|
@@ -134,19 +138,19 @@ module Cogworker
|
|
|
134
138
|
HTML
|
|
135
139
|
end
|
|
136
140
|
|
|
137
|
-
def process_cards(processes, busy_counts, quiet_path, resume_path, stop_path)
|
|
141
|
+
def process_cards(processes, busy_counts, memory, quiet_path, resume_path, stop_path)
|
|
138
142
|
if processes.empty?
|
|
139
143
|
return %(<p class="text-muted" style="font-size: 13px; font-style: italic;">No worker processes ) +
|
|
140
144
|
'are reporting in right now.</p>'
|
|
141
145
|
end
|
|
142
146
|
|
|
143
147
|
items = processes.map do |p|
|
|
144
|
-
process_card(p, busy_counts[p.identity], quiet_path, resume_path, stop_path)
|
|
148
|
+
process_card(p, busy_counts[p.identity], memory[p.identity], quiet_path, resume_path, stop_path)
|
|
145
149
|
end.join
|
|
146
150
|
%(<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px;">#{items}</div>)
|
|
147
151
|
end
|
|
148
152
|
|
|
149
|
-
def process_card(process, busy, quiet_path, resume_path, stop_path)
|
|
153
|
+
def process_card(process, busy, memory_samples, quiet_path, resume_path, stop_path)
|
|
150
154
|
concurrency = process['concurrency'].to_i
|
|
151
155
|
pct = concurrency.positive? ? ((busy.to_f / concurrency) * 100).round : 0
|
|
152
156
|
quiet = [true, 'true'].include?(process['quiet'])
|
|
@@ -177,6 +181,7 @@ module Cogworker
|
|
|
177
181
|
<span>#{memory_cell(process['rss_kb'])}</span>
|
|
178
182
|
<span>#{beat_label(process.identity)}</span>
|
|
179
183
|
</div>
|
|
184
|
+
#{memory_sparkline(process.identity, memory_samples)}
|
|
180
185
|
<div style="height: 4px; border-radius: 2px; background: var(--color-neutral-800); overflow: hidden;">
|
|
181
186
|
<div style="height: 100%; border-radius: 2px; background: var(--color-accent); width: #{pct}%;"></div>
|
|
182
187
|
</div>
|
|
@@ -199,6 +204,152 @@ module Cogworker
|
|
|
199
204
|
end
|
|
200
205
|
end
|
|
201
206
|
|
|
207
|
+
# The last 24h of this process's RSS (MemoryHistory), always the
|
|
208
|
+
# full 24h wide — one MemoryHistory slot per x position, broken
|
|
209
|
+
# wherever a slot has no sample (before the process started, or
|
|
210
|
+
# while Redis was away). An inline SVG rather than a Chart.js chart:
|
|
211
|
+
# these cards are re-rendered by every htmx poll, and a Chart.js
|
|
212
|
+
# instance per swap leaks (see CLAUDE.md's Overview charts).
|
|
213
|
+
# Stretched to the card's width with `preserveAspectRatio="none"`,
|
|
214
|
+
# so it draws only lines and fills (`non-scaling-stroke` keeps the
|
|
215
|
+
# line's width) — no point markers, which that stretch would
|
|
216
|
+
# distort; the hover marker and tooltip are HTML, positioned by
|
|
217
|
+
# `sparkline_script` from the data attributes.
|
|
218
|
+
def memory_sparkline(identity, slots)
|
|
219
|
+
slots ||= Cogworker::MemoryHistory.series([identity])[identity]
|
|
220
|
+
values = slots.map(&:last)
|
|
221
|
+
low, high = sparkline_range(values.compact)
|
|
222
|
+
<<~HTML
|
|
223
|
+
<div class="cw-spark" data-cw-spark-id="#{Layout.h(identity)}" data-cw-spark="#{Layout.h(JSON.generate(values))}"
|
|
224
|
+
data-cw-spark-start="#{slots.first.first}" data-cw-spark-step="#{Cogworker::MemoryHistory::SAMPLE_SECONDS}"
|
|
225
|
+
data-cw-spark-min="#{low}" data-cw-spark-max="#{high}"
|
|
226
|
+
style="position: relative; height: #{SPARKLINE_HEIGHT_PX}px; cursor: crosshair; background: var(--color-bg); border-radius: 3px;">
|
|
227
|
+
<span class="cw-spark-label" style="position: absolute; top: 3px; left: 6px; font-size: 10px; line-height: 1; color: var(--color-neutral-500); pointer-events: none;">Memory · 24h</span>
|
|
228
|
+
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-label="Memory, last 24 hours"
|
|
229
|
+
style="display: block; width: 100%; height: 100%; overflow: visible;">
|
|
230
|
+
#{sparkline_segments(values, low, high)}
|
|
231
|
+
</svg>
|
|
232
|
+
</div>
|
|
233
|
+
HTML
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# One line + fill per run of consecutive slots that have a sample,
|
|
237
|
+
# each point at its slot's center; a lone sample gets a short
|
|
238
|
+
# horizontal stroke across its own slot, so it's still visible.
|
|
239
|
+
def sparkline_segments(values, low, high)
|
|
240
|
+
width = 100.0 / values.size
|
|
241
|
+
runs = values.each_with_index.chunk_while { |(a, _), (b, _)| a.nil? == b.nil? }.reject { |run| run[0][0].nil? }
|
|
242
|
+
runs.map do |run|
|
|
243
|
+
points = run.map { |kb, i| [(i + 0.5) * width, (1 - ((kb - low) / (high - low))) * 100] }
|
|
244
|
+
sparkline_segment(points, width)
|
|
245
|
+
end.join
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def sparkline_segment(points, width)
|
|
249
|
+
if points.one?
|
|
250
|
+
x, y = points.first
|
|
251
|
+
points = [[x - (width / 2), y], [x + (width / 2), y]]
|
|
252
|
+
end
|
|
253
|
+
coords = points.map { |x, y| format('%<x>.2f,%<y>.2f', x: x, y: y) }.join(' ')
|
|
254
|
+
area = format('%<x0>.2f,100 %<coords>s %<x1>.2f,100', x0: points.first[0], coords: coords, x1: points.last[0])
|
|
255
|
+
<<~HTML
|
|
256
|
+
<polygon points="#{area}" style="fill: var(--color-accent); opacity: 0.15;"></polygon>
|
|
257
|
+
<polyline points="#{coords}" vector-effect="non-scaling-stroke" style="fill: none; stroke: var(--color-accent); stroke-width: 1.5;"></polyline>
|
|
258
|
+
HTML
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# A little headroom above and below, so the line never hugs the
|
|
262
|
+
# card's edges; a flat series gets a range around its one value.
|
|
263
|
+
def sparkline_range(values)
|
|
264
|
+
return [0.0, 1.0] if values.empty?
|
|
265
|
+
|
|
266
|
+
low = values.min.to_f
|
|
267
|
+
high = values.max.to_f
|
|
268
|
+
pad = [(high - low) * 0.1, 1024].max
|
|
269
|
+
[(low - pad).clamp(0, nil), high + pad]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Hover readout for every `.cw-spark` on the page: the sample
|
|
273
|
+
# nearest the pointer, as a marker line + dot and a tooltip with
|
|
274
|
+
# its value and (local) time, hiding the "Memory" label meanwhile.
|
|
275
|
+
# Delegated from `document`, so it keeps working on cards htmx
|
|
276
|
+
# swaps in; a poll that replaces the card under the pointer
|
|
277
|
+
# re-shows the readout on the new one.
|
|
278
|
+
def sparkline_script
|
|
279
|
+
<<~HTML
|
|
280
|
+
<script>
|
|
281
|
+
(function () {
|
|
282
|
+
var hover = null;
|
|
283
|
+
function part(el, cls, css) {
|
|
284
|
+
var node = el.querySelector('.' + cls);
|
|
285
|
+
if (!node) {
|
|
286
|
+
node = document.createElement('div');
|
|
287
|
+
node.className = cls;
|
|
288
|
+
node.style.cssText = 'position: absolute; pointer-events: none; ' + css;
|
|
289
|
+
el.appendChild(node);
|
|
290
|
+
}
|
|
291
|
+
return node;
|
|
292
|
+
}
|
|
293
|
+
function label(el, visible) {
|
|
294
|
+
var node = el.querySelector('.cw-spark-label');
|
|
295
|
+
if (node) node.style.visibility = visible ? '' : 'hidden';
|
|
296
|
+
}
|
|
297
|
+
function hide(el) {
|
|
298
|
+
el.querySelectorAll('.cw-spark-line, .cw-spark-dot, .cw-spark-tip').forEach(function (n) { n.remove(); });
|
|
299
|
+
label(el, true);
|
|
300
|
+
}
|
|
301
|
+
function show(el, clientX) {
|
|
302
|
+
var values = JSON.parse(el.getAttribute('data-cw-spark'));
|
|
303
|
+
var start = parseInt(el.getAttribute('data-cw-spark-start'), 10);
|
|
304
|
+
var step = parseInt(el.getAttribute('data-cw-spark-step'), 10);
|
|
305
|
+
var lo = parseFloat(el.getAttribute('data-cw-spark-min'));
|
|
306
|
+
var hi = parseFloat(el.getAttribute('data-cw-spark-max'));
|
|
307
|
+
var rect = el.getBoundingClientRect();
|
|
308
|
+
label(el, false);
|
|
309
|
+
var frac = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1);
|
|
310
|
+
var i = Math.min(Math.floor(frac * values.length), values.length - 1);
|
|
311
|
+
var x = (i + 0.5) / values.length * rect.width;
|
|
312
|
+
var kb = values[i];
|
|
313
|
+
part(el, 'cw-spark-line', 'top: 0; bottom: 0; width: 1px; background: var(--color-neutral-500);').style.left = x + 'px';
|
|
314
|
+
var dot = part(el, 'cw-spark-dot', 'width: 6px; height: 6px; border-radius: 50%; margin: -3px 0 0 -3px; background: var(--color-accent);');
|
|
315
|
+
dot.style.display = kb === null ? 'none' : '';
|
|
316
|
+
dot.style.left = x + 'px';
|
|
317
|
+
if (kb !== null) dot.style.top = (1 - (kb - lo) / (hi - lo)) * rect.height + 'px';
|
|
318
|
+
var tip = part(el, 'cw-spark-tip', 'bottom: calc(100% + 4px); z-index: 10; white-space: nowrap; font-size: 11px; ' +
|
|
319
|
+
'padding: 3px 6px; border-radius: 4px; background: var(--color-surface); color: var(--color-text); ' +
|
|
320
|
+
'box-shadow: var(--shadow-md); border: 1px solid var(--color-neutral-800);');
|
|
321
|
+
var at = new Date((start + i * step) * 1000);
|
|
322
|
+
var when = window.cogworkerFormatTime ? window.cogworkerFormatTime(at) : at.toLocaleString();
|
|
323
|
+
tip.textContent = (kb === null ? 'no data' : (kb / 1024).toFixed(1) + 'M') + ' · ' + when;
|
|
324
|
+
tip.style.left = x + 'px';
|
|
325
|
+
tip.style.transform = 'translateX(-' + (frac * 100) + '%)';
|
|
326
|
+
}
|
|
327
|
+
function find(id) {
|
|
328
|
+
var all = document.querySelectorAll('.cw-spark');
|
|
329
|
+
for (var i = 0; i < all.length; i++) if (all[i].getAttribute('data-cw-spark-id') === id) return all[i];
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
document.addEventListener('mousemove', function (evt) {
|
|
333
|
+
var el = evt.target.closest && evt.target.closest('.cw-spark');
|
|
334
|
+
if (hover && (!el || el.getAttribute('data-cw-spark-id') !== hover.id)) {
|
|
335
|
+
var old = find(hover.id);
|
|
336
|
+
if (old) hide(old);
|
|
337
|
+
hover = null;
|
|
338
|
+
}
|
|
339
|
+
if (!el) return;
|
|
340
|
+
hover = { id: el.getAttribute('data-cw-spark-id'), x: evt.clientX };
|
|
341
|
+
show(el, evt.clientX);
|
|
342
|
+
});
|
|
343
|
+
document.addEventListener('htmx:afterSwap', function () {
|
|
344
|
+
if (!hover) return;
|
|
345
|
+
var el = find(hover.id);
|
|
346
|
+
if (el) show(el, hover.x);
|
|
347
|
+
});
|
|
348
|
+
})();
|
|
349
|
+
</script>
|
|
350
|
+
HTML
|
|
351
|
+
end
|
|
352
|
+
|
|
202
353
|
# `rss_kb` is absent on any process whose heartbeat predates this
|
|
203
354
|
# field (or that never measured cleanly — see
|
|
204
355
|
# `Heartbeat#current_rss_kb`) — render "n/a" rather than "0M".
|
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
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- vdonec
|
|
@@ -216,12 +216,16 @@ files:
|
|
|
216
216
|
- lib/cogworker/component.rb
|
|
217
217
|
- lib/cogworker/config.rb
|
|
218
218
|
- lib/cogworker/config_loader.rb
|
|
219
|
+
- lib/cogworker/death_notifier.rb
|
|
219
220
|
- lib/cogworker/deferred_releases.rb
|
|
221
|
+
- lib/cogworker/failure_decision.rb
|
|
220
222
|
- lib/cogworker/heartbeat.rb
|
|
221
223
|
- lib/cogworker/history.rb
|
|
222
224
|
- lib/cogworker/history/middleware.rb
|
|
223
225
|
- lib/cogworker/history/storage.rb
|
|
224
226
|
- lib/cogworker/job.rb
|
|
227
|
+
- lib/cogworker/job_failed_error.rb
|
|
228
|
+
- lib/cogworker/job_orphaned_error.rb
|
|
225
229
|
- lib/cogworker/job_record.rb
|
|
226
230
|
- lib/cogworker/job_util.rb
|
|
227
231
|
- lib/cogworker/key_guard.rb
|
|
@@ -229,6 +233,7 @@ files:
|
|
|
229
233
|
- lib/cogworker/logging.rb
|
|
230
234
|
- lib/cogworker/lua_script.rb
|
|
231
235
|
- lib/cogworker/manager.rb
|
|
236
|
+
- lib/cogworker/memory_history.rb
|
|
232
237
|
- lib/cogworker/middleware/chain.rb
|
|
233
238
|
- lib/cogworker/owned_key.rb
|
|
234
239
|
- lib/cogworker/periodic/claim.lua
|