cogworker 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f0b88ae2031cced0f33c8de66892dbd952e7aba6ed64ad1fba49719bb335698d
4
- data.tar.gz: 6cd9c81f131107a8b04bb170dbedb29898899d248b900bfcf4fcbeebf69463bf
3
+ metadata.gz: 472527fd7492d45c642f017b2cdc788c0741ebe7226b42b416aee90431b61194
4
+ data.tar.gz: '078375dda087dfe1064ed79e35b92b4fb2e4dbf017e68fb681b939d96a45fdea'
5
5
  SHA512:
6
- metadata.gz: 867b3c3cc51e6ee56f2fa8e7433b52598b366873ba9609ac9cfa81904bb1e9f0fdb0c35b6816bee0a7605c6536c0a88749e77f972e8d611c7fd3df726772b5fd
7
- data.tar.gz: 1bf378d2c4e52a6034d47c2239a21539ae722e3877c015585ef446cfff6c0456c893c071e088b9c857759ac04849a7a481f804b816dafea85dc5e0fce46fe59a
6
+ metadata.gz: 4e66fb20f141b58faa5c13eaa1e9df971d890a8bc5a77c845f80fbdcc621beacf2b03c5d9e1dbae68a9b4981a2e322a4147d1e366677e607cb069b846d2d905e
7
+ data.tar.gz: f3297307cd6201b8421a0acd54217d5a79cdaa1f93fed083ccbd7bd492cb9867340ffc52b787847058a0ce030ed5b17bd1b9d389fbdbc6ef0c64195f83f3422a
@@ -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.
@@ -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
@@ -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}"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cogworker
4
- VERSION = '0.7.0'
4
+ VERSION = '0.8.0'
5
5
  end
@@ -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
- cards = REDIS_INFO_FIELDS.map do |field, label|
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: 22px; font-family: var(--font-heading);">#{Layout.h(info[field] || 'n/a')}</span>
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;">Redis</h4>
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
- cards = process_cards(processes, busy_counts, quiet_path, resume_path, stop_path)
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.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - vdonec
@@ -233,6 +233,7 @@ files:
233
233
  - lib/cogworker/logging.rb
234
234
  - lib/cogworker/lua_script.rb
235
235
  - lib/cogworker/manager.rb
236
+ - lib/cogworker/memory_history.rb
236
237
  - lib/cogworker/middleware/chain.rb
237
238
  - lib/cogworker/owned_key.rb
238
239
  - lib/cogworker/periodic/claim.lua