cogworker 0.2.1 → 0.3.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/lib/cogworker/basic_fetch.rb +37 -2
- data/lib/cogworker/cli.rb +11 -1
- data/lib/cogworker/client.rb +1 -1
- data/lib/cogworker/config.rb +26 -1
- data/lib/cogworker/heartbeat.rb +54 -6
- data/lib/cogworker/job.rb +9 -0
- data/lib/cogworker/job_util.rb +56 -0
- data/lib/cogworker/launcher.rb +20 -1
- data/lib/cogworker/manager.rb +43 -4
- data/lib/cogworker/owned_key.rb +40 -0
- data/lib/cogworker/periodic/claim.lua +9 -0
- data/lib/cogworker/periodic/release_middleware.rb +31 -13
- data/lib/cogworker/periodic/running_lock.rb +44 -0
- data/lib/cogworker/periodic/ticker.rb +39 -10
- data/lib/cogworker/process.rb +2 -2
- data/lib/cogworker/process_set.rb +1 -1
- data/lib/cogworker/processor.rb +116 -14
- data/lib/cogworker/queue.rb +4 -4
- data/lib/cogworker/redis_keys.rb +2 -0
- data/lib/cogworker/reliable_fetch.rb +137 -0
- data/lib/cogworker/scheduled.rb +37 -10
- data/lib/cogworker/stats.rb +1 -1
- data/lib/cogworker/status.rb +5 -1
- data/lib/cogworker/swarm.rb +64 -4
- data/lib/cogworker/testing/empty_queue_error.rb +9 -0
- data/lib/cogworker/testing.rb +27 -3
- data/lib/cogworker/unique_jobs/client_middleware.rb +13 -4
- data/lib/cogworker/unique_jobs/release_middleware.rb +4 -1
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/history_query.rb +181 -0
- data/lib/cogworker/web/layout.rb +3 -3
- data/lib/cogworker/web/routes/history.rb +111 -68
- data/lib/cogworker/web/routes/jobs.rb +17 -17
- data/lib/cogworker/web/routes/overview.rb +6 -12
- data/lib/cogworker/web/routes/schedules.rb +2 -2
- data/lib/cogworker/web.rb +21 -8
- data/lib/cogworker.rb +5 -1
- metadata +6 -1
data/lib/cogworker/swarm.rb
CHANGED
|
@@ -20,14 +20,30 @@ module Cogworker
|
|
|
20
20
|
# that never exits (a hung Redis call outliving even Manager#stop!'s
|
|
21
21
|
# own 25s join, or anything else) must not be able to wedge the whole
|
|
22
22
|
# restart cycle, and the supervisor's entire signal-handling loop with
|
|
23
|
-
# it, forever.
|
|
24
|
-
|
|
23
|
+
# it, forever. Kept well above a child's own worst-case clean shutdown
|
|
24
|
+
# (Manager#stop!'s 25s drain + up to BasicFetch::TIMEOUT + 1 waiting out
|
|
25
|
+
# fetching threads + ~1s for Heartbeat#stop!) so a child is never killed
|
|
26
|
+
# mid-`requeue_unfinished` — for `:basic`, that would lose the jobs.
|
|
27
|
+
GRACEFUL_STOP_TIMEOUT = 35
|
|
28
|
+
|
|
29
|
+
# Respawn backoff for a child that exits unexpectedly: none for the
|
|
30
|
+
# first crash after a healthy run, then doubling from
|
|
31
|
+
# RESPAWN_BASE_DELAY up to RESPAWN_MAX_DELAY while the slot keeps dying
|
|
32
|
+
# within HEALTHY_UPTIME of starting. Without it, a child crashing during
|
|
33
|
+
# boot (bad deploy, Redis unreachable, a typo in the init file) made the
|
|
34
|
+
# supervisor fork/crash in a tight loop forever.
|
|
35
|
+
HEALTHY_UPTIME = 30
|
|
36
|
+
RESPAWN_BASE_DELAY = 1
|
|
37
|
+
RESPAWN_MAX_DELAY = 60
|
|
25
38
|
|
|
26
39
|
def initialize(argv, count: ENV.fetch('COGWORKER_COUNT', 1).to_i, phased: ENV['PHASED_RESTART'] == 'true')
|
|
27
40
|
@argv = argv
|
|
28
41
|
@count = [count, 1].max
|
|
29
42
|
@phased = phased
|
|
30
43
|
@children = {} # pid => slot index
|
|
44
|
+
@started_at = {} # slot => monotonic time of its current child's fork
|
|
45
|
+
@crash_streak = Hash.new(0) # slot => consecutive quick crashes
|
|
46
|
+
@pending_respawns = {} # slot => monotonic time it may be forked again
|
|
31
47
|
@signal_queue = ::Queue.new
|
|
32
48
|
@stopping = false
|
|
33
49
|
end
|
|
@@ -56,6 +72,7 @@ module Cogworker
|
|
|
56
72
|
::Process.exit!(true)
|
|
57
73
|
end
|
|
58
74
|
@children[pid] = slot
|
|
75
|
+
@started_at[slot] = monotonic_now
|
|
59
76
|
Cogworker.logger.info { "swarm: started child pid=#{pid} slot=#{slot}" }
|
|
60
77
|
end
|
|
61
78
|
|
|
@@ -70,13 +87,16 @@ module Cogworker
|
|
|
70
87
|
until @stopping && @children.empty?
|
|
71
88
|
drain_signals
|
|
72
89
|
reap_children
|
|
90
|
+
respawn_due_children
|
|
91
|
+
kill_stragglers
|
|
73
92
|
sleep 0.2
|
|
74
93
|
end
|
|
75
94
|
end
|
|
76
95
|
|
|
77
96
|
def drain_signals
|
|
78
97
|
until @signal_queue.empty?
|
|
79
|
-
|
|
98
|
+
signal = @signal_queue.pop
|
|
99
|
+
case signal
|
|
80
100
|
when :quiet then relay(Signals::QUIET)
|
|
81
101
|
when :stop then initiate_stop
|
|
82
102
|
when :restart
|
|
@@ -93,9 +113,25 @@ module Cogworker
|
|
|
93
113
|
|
|
94
114
|
def initiate_stop
|
|
95
115
|
@stopping = true
|
|
116
|
+
@stop_deadline = monotonic_now + GRACEFUL_STOP_TIMEOUT
|
|
117
|
+
@pending_respawns.clear
|
|
96
118
|
relay(Signals::STOP)
|
|
97
119
|
end
|
|
98
120
|
|
|
121
|
+
# Same bound as a phased restart's `wait_for_exit`: a child that hasn't
|
|
122
|
+
# exited GRACEFUL_STOP_TIMEOUT after the stop was relayed is
|
|
123
|
+
# force-killed (once), so one hung child can't keep the supervisor —
|
|
124
|
+
# and whatever is waiting on it — alive forever.
|
|
125
|
+
def kill_stragglers
|
|
126
|
+
return unless @stop_deadline && monotonic_now > @stop_deadline
|
|
127
|
+
|
|
128
|
+
@stop_deadline = nil
|
|
129
|
+
@children.each_key do |pid|
|
|
130
|
+
Cogworker.logger.warn { "swarm: child pid=#{pid} didn't stop within #{GRACEFUL_STOP_TIMEOUT}s, killing" }
|
|
131
|
+
safe_kill(pid, 'KILL')
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
99
135
|
# Non-phased: signal every child at once; the normal reap/respawn path
|
|
100
136
|
# below brings each one back as it exits, so there's a brief capacity
|
|
101
137
|
# dip but no explicit special-casing needed. Phased: replace children
|
|
@@ -155,7 +191,31 @@ module Cogworker
|
|
|
155
191
|
if @stopping
|
|
156
192
|
Cogworker.logger.info { "swarm: child pid=#{pid} slot=#{slot} exited" }
|
|
157
193
|
else
|
|
158
|
-
|
|
194
|
+
schedule_respawn(pid, slot)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def schedule_respawn(pid, slot)
|
|
199
|
+
uptime = monotonic_now - @started_at.fetch(slot, monotonic_now)
|
|
200
|
+
@crash_streak[slot] = uptime < HEALTHY_UPTIME ? @crash_streak[slot] + 1 : 0
|
|
201
|
+
delay = respawn_delay(@crash_streak[slot])
|
|
202
|
+
Cogworker.logger.warn do
|
|
203
|
+
"swarm: child pid=#{pid} slot=#{slot} exited unexpectedly after #{uptime.round(1)}s, " \
|
|
204
|
+
"respawning in #{delay}s"
|
|
205
|
+
end
|
|
206
|
+
@pending_respawns[slot] = monotonic_now + delay
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def respawn_delay(streak)
|
|
210
|
+
return 0 if streak <= 1
|
|
211
|
+
|
|
212
|
+
[RESPAWN_BASE_DELAY * (2**(streak - 2)), RESPAWN_MAX_DELAY].min
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def respawn_due_children
|
|
216
|
+
now = monotonic_now
|
|
217
|
+
@pending_respawns.select { |_slot, at| at <= now }.each_key do |slot|
|
|
218
|
+
@pending_respawns.delete(slot)
|
|
159
219
|
fork_child(slot)
|
|
160
220
|
end
|
|
161
221
|
end
|
data/lib/cogworker/testing.rb
CHANGED
|
@@ -14,8 +14,9 @@ module Cogworker
|
|
|
14
14
|
# but the normalized job hash is appended to `jobs_for(klass_name)`
|
|
15
15
|
# instead of touching Redis — nothing is scheduled or executed. Use
|
|
16
16
|
# `SomeJob.jobs` (added to `Job::ClassMethods`) to assert what got
|
|
17
|
-
# pushed, `SomeJob.
|
|
18
|
-
#
|
|
17
|
+
# pushed, `SomeJob.perform_one`/`SomeJob.drain`/`Testing.drain_all` to
|
|
18
|
+
# then actually run them, `SomeJob.clear`/`Testing.clear_jobs!` to reset
|
|
19
|
+
# between examples.
|
|
19
20
|
# - `:inline` — the job runs synchronously, right here in the calling
|
|
20
21
|
# thread, through the real server middleware chain
|
|
21
22
|
# (`Cogworker.config.server_chain`) — same as `Processor#execute`, minus
|
|
@@ -75,8 +76,31 @@ module Cogworker
|
|
|
75
76
|
registry.clear
|
|
76
77
|
end
|
|
77
78
|
|
|
79
|
+
# Fake mode's "now run them": `perform_one` takes the oldest recorded
|
|
80
|
+
# job for a class off its fake queue and runs it via `perform_inline`
|
|
81
|
+
# (real server middleware chain, errors propagate); `drain` repeats
|
|
82
|
+
# until that class's queue is empty, `drain_all` until every class's
|
|
83
|
+
# is — both also running whatever those jobs push in turn. Also
|
|
84
|
+
# reachable as `SomeJob.perform_one`/`SomeJob.drain`.
|
|
85
|
+
def perform_one(klass_name)
|
|
86
|
+
job = jobs_for(klass_name).shift
|
|
87
|
+
raise EmptyQueueError, "no #{klass_name} jobs recorded" unless job
|
|
88
|
+
|
|
89
|
+
perform_inline(job)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def drain(klass_name)
|
|
93
|
+
perform_one(klass_name) until jobs_for(klass_name).empty?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def drain_all
|
|
97
|
+
while (klass_name = registry.find { |_name, jobs| jobs.any? }&.first)
|
|
98
|
+
drain(klass_name)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
78
102
|
# Runs one job synchronously through the real server middleware chain.
|
|
79
|
-
#
|
|
103
|
+
# Called from `Client.push` while `inline?`, and by `perform_one`.
|
|
80
104
|
def perform_inline(job)
|
|
81
105
|
klass = Object.const_get(job['class'])
|
|
82
106
|
worker = klass.new
|
|
@@ -9,15 +9,14 @@ module Cogworker
|
|
|
9
9
|
# continue down the chain to the real push. A job that loses the claim
|
|
10
10
|
# (another copy is already enqueued/scheduled/running) never reaches
|
|
11
11
|
# `dispatch` at all — it sets `job['unique_skipped']` instead, which
|
|
12
|
-
# `Client.push` reads to return `nil` rather than a jid
|
|
13
|
-
# "nothing was actually pushed" signal
|
|
12
|
+
# `Client.push` reads to return `nil` rather than a jid — the
|
|
13
|
+
# "nothing was actually pushed" signal to the caller.
|
|
14
14
|
class ClientMiddleware
|
|
15
15
|
def call(_worker_class, job, _queue, redis_pool = Cogworker.config.redis_pool)
|
|
16
16
|
return yield unless UniqueJobs.until_executed?(job)
|
|
17
17
|
|
|
18
18
|
acquired = redis_pool.with do |c|
|
|
19
|
-
c.set(RedisKeys.unique_lock(UniqueJobs.digest(job)), job['jid'],
|
|
20
|
-
nx: true, ex: Cogworker.config.unique_lock_ttl)
|
|
19
|
+
c.set(RedisKeys.unique_lock(UniqueJobs.digest(job)), job['jid'], nx: true, ex: lock_ttl(job))
|
|
21
20
|
end
|
|
22
21
|
|
|
23
22
|
if acquired
|
|
@@ -26,6 +25,16 @@ module Cogworker
|
|
|
26
25
|
job['unique_skipped'] = true
|
|
27
26
|
end
|
|
28
27
|
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Counted from when the job is due, not from now: a `perform_in`/
|
|
32
|
+
# `perform_at` further out than `unique_lock_ttl` used to lose its
|
|
33
|
+
# lock before it even ran, letting a duplicate in.
|
|
34
|
+
def lock_ttl(job)
|
|
35
|
+
wait = job['at'] ? [job['at'].to_f - Time.now.to_f, 0].max : 0
|
|
36
|
+
(Cogworker.config.unique_lock_ttl + wait).ceil
|
|
37
|
+
end
|
|
29
38
|
end
|
|
30
39
|
end
|
|
31
40
|
end
|
|
@@ -22,8 +22,11 @@ module Cogworker
|
|
|
22
22
|
|
|
23
23
|
private
|
|
24
24
|
|
|
25
|
+
# Owner-checked: if this job's lock already expired and passed to a
|
|
26
|
+
# newer copy, this must not release *that* one's lock (which would let
|
|
27
|
+
# a third copy in while the newer one is still running).
|
|
25
28
|
def release(job)
|
|
26
|
-
|
|
29
|
+
OwnedKey.delete(RedisKeys.unique_lock(UniqueJobs.digest(job)), job['jid'])
|
|
27
30
|
end
|
|
28
31
|
end
|
|
29
32
|
end
|
data/lib/cogworker/version.rb
CHANGED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Cogworker
|
|
6
|
+
class Web
|
|
7
|
+
# Server side of the History tab's AG Grid (Infinite Row Model): one
|
|
8
|
+
# block of rows at a time — `[rows, total]` for `offset`/`limit` —
|
|
9
|
+
# instead of handing the browser every retained entry up front.
|
|
10
|
+
#
|
|
11
|
+
# `sort`/`filters` arrive in AG Grid's own `sortModel`/`filterModel`
|
|
12
|
+
# shapes (`[{ 'colId' => ..., 'sort' => 'asc'|'desc' }]` /
|
|
13
|
+
# `{ colId => { 'filterType' => 'text'|'number', 'type' => ..., ... } }`,
|
|
14
|
+
# optionally combined via `operator`/`conditions`), and `COLUMNS` below
|
|
15
|
+
# computes each column's value the same way `Routes::History#grid_script`
|
|
16
|
+
# displays it, so a server-side filter matches what the viewer sees.
|
|
17
|
+
#
|
|
18
|
+
# Two paths:
|
|
19
|
+
# - no filters, sorted by Finished (the default): a plain `ZREVRANGE`/
|
|
20
|
+
# `ZRANGE` of just the requested slice, total from `ZCARD` — the
|
|
21
|
+
# everyday case stays O(limit), whatever `History.max_entries` is.
|
|
22
|
+
# - anything else (a column filter, `?jid=`, sorting by another column):
|
|
23
|
+
# a scan of the whole status ZSET in chunks, filtered/sorted in Ruby,
|
|
24
|
+
# then sliced. Bounded by retention (`History.retention_days`/
|
|
25
|
+
# `max_entries`), and only the requested slice ever leaves the server.
|
|
26
|
+
class HistoryQuery
|
|
27
|
+
SCAN_CHUNK = 1000
|
|
28
|
+
MAX_LIMIT = 1000
|
|
29
|
+
DEFAULT_SORT = 'finished_at'
|
|
30
|
+
|
|
31
|
+
COLUMNS = {
|
|
32
|
+
'finished_at' => ->(e) { e['finished_at'].to_f },
|
|
33
|
+
'class' => ->(e) { e['class'].to_s },
|
|
34
|
+
'queue' => ->(e) { e['queue'].to_s },
|
|
35
|
+
'jid' => ->(e) { e['jid'].to_s },
|
|
36
|
+
'args' => ->(e) { JSON.generate(e['args']) },
|
|
37
|
+
'status' => ->(e) { e['status'].to_s },
|
|
38
|
+
'duration' => ->(e) { ((e['finished_at'].to_f - e['started_at'].to_f) * 1000).round },
|
|
39
|
+
'error' => ->(e) { e['error_class'] ? "#{e['error_class']}: #{e['error_message']}" : '' }
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
def initialize(status:, jid: '', sort: [], filters: {})
|
|
43
|
+
@key = Cogworker::History::Storage::LIST_KEYS.fetch(status, Cogworker::History::Storage::LIST_KEYS.fetch('all'))
|
|
44
|
+
@jid = jid.to_s
|
|
45
|
+
@sort = Array(sort).find { |s| s.is_a?(Hash) && COLUMNS.key?(s['colId']) }
|
|
46
|
+
@filters = filters.is_a?(Hash) ? filters.select { |col, _| COLUMNS.key?(col) } : {}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def fetch(offset, limit)
|
|
50
|
+
offset = [offset.to_i, 0].max
|
|
51
|
+
limit = limit.to_i.clamp(0, MAX_LIMIT)
|
|
52
|
+
fast_path? ? fetch_slice(offset, limit) : fetch_scanned(offset, limit)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def sort_col
|
|
58
|
+
@sort ? @sort['colId'] : DEFAULT_SORT
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def ascending?
|
|
62
|
+
@sort && @sort['sort'] == 'asc'
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def fast_path?
|
|
66
|
+
@jid.empty? && @filters.empty? && sort_col == DEFAULT_SORT
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def fetch_slice(offset, limit)
|
|
70
|
+
return [[], Cogworker.config.redis { |c| c.zcard(@key) }] if limit.zero?
|
|
71
|
+
|
|
72
|
+
stop = offset + limit - 1
|
|
73
|
+
total, raw = Cogworker.config.redis do |c|
|
|
74
|
+
[c.zcard(@key), ascending? ? c.zrange(@key, offset, stop) : c.zrevrange(@key, offset, stop)]
|
|
75
|
+
end
|
|
76
|
+
[raw.map { |r| JSON.parse(r) }, total]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def fetch_scanned(offset, limit)
|
|
80
|
+
matched = scan.select { |entry| matches?(entry) }
|
|
81
|
+
[sort(matched)[offset, limit] || [], matched.size]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Ties stay newest-first (the scan's own order) either direction —
|
|
85
|
+
# the index tiebreaker also makes Ruby's unstable `sort_by` stable.
|
|
86
|
+
def sort(entries)
|
|
87
|
+
getter = COLUMNS.fetch(sort_col)
|
|
88
|
+
keyed = entries.each_with_index.map { |entry, i| [getter.call(entry), i, entry] }
|
|
89
|
+
if ascending?
|
|
90
|
+
keyed.sort_by { |value, i, _| [value, i] }.map(&:last)
|
|
91
|
+
else
|
|
92
|
+
keyed.sort_by { |value, i, _| [value, -i] }.reverse.map(&:last)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def scan
|
|
97
|
+
needle = @jid.empty? ? nil : JSON.generate('jid' => @jid)[1..-2]
|
|
98
|
+
entries = []
|
|
99
|
+
Cogworker.config.redis do |c|
|
|
100
|
+
start = 0
|
|
101
|
+
loop do
|
|
102
|
+
chunk = c.zrevrange(@key, start, start + SCAN_CHUNK - 1)
|
|
103
|
+
chunk.each do |raw|
|
|
104
|
+
next if needle && !raw.include?(needle) # cheap pre-check before parsing
|
|
105
|
+
|
|
106
|
+
entry = JSON.parse(raw)
|
|
107
|
+
entries << entry if @jid.empty? || entry['jid'] == @jid
|
|
108
|
+
end
|
|
109
|
+
break if chunk.size < SCAN_CHUNK
|
|
110
|
+
|
|
111
|
+
start += SCAN_CHUNK
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
entries
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def matches?(entry)
|
|
118
|
+
@filters.all? { |col, model| model_matches?(model, COLUMNS.fetch(col).call(entry)) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def model_matches?(model, value)
|
|
122
|
+
return true unless model.is_a?(Hash)
|
|
123
|
+
|
|
124
|
+
if model['conditions'].is_a?(Array)
|
|
125
|
+
results = model['conditions'].map { |cond| condition_matches?(model['filterType'], cond, value) }
|
|
126
|
+
model['operator'] == 'OR' ? results.any? : results.all?
|
|
127
|
+
else
|
|
128
|
+
condition_matches?(model['filterType'], model, value)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def condition_matches?(filter_type, cond, value)
|
|
133
|
+
return true unless cond.is_a?(Hash)
|
|
134
|
+
|
|
135
|
+
case cond['filterType'] || filter_type
|
|
136
|
+
when 'number' then number_matches?(cond, value)
|
|
137
|
+
else text_matches?(cond, value.to_s)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# AG Grid's default text filter semantics: case-insensitive.
|
|
142
|
+
def text_matches?(cond, value)
|
|
143
|
+
value = value.downcase
|
|
144
|
+
needle = cond['filter'].to_s.downcase
|
|
145
|
+
case cond['type']
|
|
146
|
+
when 'contains' then value.include?(needle)
|
|
147
|
+
when 'notContains' then !value.include?(needle)
|
|
148
|
+
when 'equals' then value == needle
|
|
149
|
+
when 'notEqual' then value != needle
|
|
150
|
+
when 'startsWith' then value.start_with?(needle)
|
|
151
|
+
when 'endsWith' then value.end_with?(needle)
|
|
152
|
+
when 'blank' then value.empty?
|
|
153
|
+
when 'notBlank' then !value.empty?
|
|
154
|
+
else true
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# AG Grid's default number filter semantics (`inRange` exclusive).
|
|
159
|
+
def number_matches?(cond, value)
|
|
160
|
+
return value.nil? if cond['type'] == 'blank'
|
|
161
|
+
return !value.nil? if cond['type'] == 'notBlank'
|
|
162
|
+
|
|
163
|
+
target = Float(cond['filter'], exception: false)
|
|
164
|
+
return true if target.nil?
|
|
165
|
+
|
|
166
|
+
case cond['type']
|
|
167
|
+
when 'equals' then value == target
|
|
168
|
+
when 'notEqual' then value != target
|
|
169
|
+
when 'lessThan' then value < target
|
|
170
|
+
when 'lessThanOrEqual' then value <= target
|
|
171
|
+
when 'greaterThan' then value > target
|
|
172
|
+
when 'greaterThanOrEqual' then value >= target
|
|
173
|
+
when 'inRange'
|
|
174
|
+
to = Float(cond['filterTo'], exception: false)
|
|
175
|
+
to.nil? || (value > target && value < to)
|
|
176
|
+
else true
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
data/lib/cogworker/web/layout.rb
CHANGED
|
@@ -178,8 +178,8 @@ module Cogworker
|
|
|
178
178
|
end
|
|
179
179
|
|
|
180
180
|
# Cluster-wide "quiet every process" (`Routes::Workers`'s own `POST
|
|
181
|
-
# /workers/pause_all`) — the mock's "Pause intake" button.
|
|
182
|
-
#
|
|
181
|
+
# /workers/pause_all`) — the mock's "Pause intake" button. Quiet isn't
|
|
182
|
+
# a one-way trip that only a fresh process can undo:
|
|
183
183
|
# `Manager#quiet` here is a plain in-memory flag, so `resume_intake_
|
|
184
184
|
# button` below is a genuine, symmetric undo rather than a full
|
|
185
185
|
# process restart.
|
|
@@ -237,7 +237,7 @@ module Cogworker
|
|
|
237
237
|
# each one's visible text to the *browser's local* time, in
|
|
238
238
|
# `Web.time_format`. Registered on `htmx:load`, which htmx fires once
|
|
239
239
|
# for the initial page **and** again after every fragment swap — so
|
|
240
|
-
# newly-swapped-in timestamps (an auto-refreshed
|
|
240
|
+
# newly-swapped-in timestamps (an auto-refreshed Workers/Jobs/Schedules
|
|
241
241
|
# tab) get the same treatment without a full reload.
|
|
242
242
|
#
|
|
243
243
|
# `window.cogworkerFormatTime` is the same formatter exposed globally
|