cogworker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/exe/cogworker +6 -0
  3. data/exe/cogworkerswarm +6 -0
  4. data/lib/cogworker/basic_fetch.rb +27 -0
  5. data/lib/cogworker/cli.rb +64 -0
  6. data/lib/cogworker/client.rb +61 -0
  7. data/lib/cogworker/component.rb +19 -0
  8. data/lib/cogworker/config.rb +84 -0
  9. data/lib/cogworker/config_loader.rb +22 -0
  10. data/lib/cogworker/heartbeat.rb +150 -0
  11. data/lib/cogworker/history/middleware.rb +18 -0
  12. data/lib/cogworker/history/storage.rb +81 -0
  13. data/lib/cogworker/history.rb +33 -0
  14. data/lib/cogworker/job.rb +75 -0
  15. data/lib/cogworker/job_record.rb +24 -0
  16. data/lib/cogworker/job_util.rb +60 -0
  17. data/lib/cogworker/launcher.rb +93 -0
  18. data/lib/cogworker/logging.rb +18 -0
  19. data/lib/cogworker/manager.rb +71 -0
  20. data/lib/cogworker/middleware/chain.rb +64 -0
  21. data/lib/cogworker/periodic/claim.lua +27 -0
  22. data/lib/cogworker/periodic/entry.rb +24 -0
  23. data/lib/cogworker/periodic/manager.rb +28 -0
  24. data/lib/cogworker/periodic/release_middleware.rb +27 -0
  25. data/lib/cogworker/periodic/ticker.rb +123 -0
  26. data/lib/cogworker/process.rb +36 -0
  27. data/lib/cogworker/process_set.rb +29 -0
  28. data/lib/cogworker/processor.rb +129 -0
  29. data/lib/cogworker/prometheus/exporter.rb +62 -0
  30. data/lib/cogworker/queue.rb +60 -0
  31. data/lib/cogworker/redis_connection.rb +37 -0
  32. data/lib/cogworker/redis_keys.rb +32 -0
  33. data/lib/cogworker/scheduled.rb +67 -0
  34. data/lib/cogworker/signals.rb +15 -0
  35. data/lib/cogworker/stats.rb +47 -0
  36. data/lib/cogworker/status/client_middleware.rb +19 -0
  37. data/lib/cogworker/status/server_middleware.rb +31 -0
  38. data/lib/cogworker/status/storage.rb +30 -0
  39. data/lib/cogworker/status/worker.rb +27 -0
  40. data/lib/cogworker/status.rb +40 -0
  41. data/lib/cogworker/swarm.rb +169 -0
  42. data/lib/cogworker/testing.rb +109 -0
  43. data/lib/cogworker/unique_jobs/client_middleware.rb +31 -0
  44. data/lib/cogworker/unique_jobs/release_middleware.rb +30 -0
  45. data/lib/cogworker/unique_jobs.rb +32 -0
  46. data/lib/cogworker/version.rb +5 -0
  47. data/lib/cogworker/web/action.rb +63 -0
  48. data/lib/cogworker/web/application.rb +62 -0
  49. data/lib/cogworker/web/assets/ag-grid/ag-grid-community.min.js +1 -0
  50. data/lib/cogworker/web/assets/ag-grid/ag-grid.min.css +7 -0
  51. data/lib/cogworker/web/assets/ag-grid/ag-theme-alpine.min.css +2 -0
  52. data/lib/cogworker/web/assets/chart.umd.min.js +13 -0
  53. data/lib/cogworker/web/assets/htmx.min.js +1 -0
  54. data/lib/cogworker/web/assets/tailwind.css +1 -0
  55. data/lib/cogworker/web/layout.rb +352 -0
  56. data/lib/cogworker/web/router.rb +27 -0
  57. data/lib/cogworker/web/routes/busy.rb +99 -0
  58. data/lib/cogworker/web/routes/dead.rb +93 -0
  59. data/lib/cogworker/web/routes/history.rb +226 -0
  60. data/lib/cogworker/web/routes/periodic.rb +67 -0
  61. data/lib/cogworker/web/routes/queues.rb +101 -0
  62. data/lib/cogworker/web/routes/retries.rb +89 -0
  63. data/lib/cogworker/web/routes/save_session.rb +21 -0
  64. data/lib/cogworker/web/routes/scheduled.rb +49 -0
  65. data/lib/cogworker/web/routes/stats.rb +298 -0
  66. data/lib/cogworker/web/views.rb +25 -0
  67. data/lib/cogworker/web.rb +257 -0
  68. data/lib/cogworker/work.rb +19 -0
  69. data/lib/cogworker/work_set.rb +23 -0
  70. data/lib/cogworker/worker.rb +5 -0
  71. data/lib/cogworker/workers.rb +8 -0
  72. data/lib/cogworker.rb +114 -0
  73. metadata +300 -0
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+
6
+ module Cogworker
7
+ # One thread of a Manager's pool: fetch -> register in-flight -> run the
8
+ # server middleware chain around perform -> stats/retry -> deregister.
9
+ class Processor
10
+ attr_reader :thread, :tid
11
+
12
+ def initialize(manager)
13
+ @manager = manager
14
+ @tid = SecureRandom.hex(6)
15
+ @fetcher = BasicFetch.new(manager.queues)
16
+ end
17
+
18
+ def start!
19
+ @thread = Thread.new { run }
20
+ end
21
+
22
+ private
23
+
24
+ def run
25
+ until @manager.stopping?
26
+ if @manager.quiet?
27
+ sleep(0.5)
28
+ next
29
+ end
30
+
31
+ work = @fetcher.retrieve_work
32
+ next unless work
33
+
34
+ @manager.processor_busy!
35
+ begin
36
+ execute(work)
37
+ ensure
38
+ @manager.processor_idle!
39
+ end
40
+ end
41
+ rescue StandardError => e
42
+ Cogworker.logger.error { "Processor thread died: #{e.class}: #{e.message}" }
43
+ end
44
+
45
+ def execute(work)
46
+ job = JSON.parse(work.raw_job)
47
+ register_in_workers(work.queue, job)
48
+ Cogworker.logger.info { "start: #{job['class']} jid=#{job['jid']}" }
49
+
50
+ begin
51
+ # `build_worker` itself can raise (most commonly `NameError`, e.g. a
52
+ # class the caller registered/enqueued but never actually defined —
53
+ # deliberately still routed through the *same* middleware chain
54
+ # below rather than caught here directly: `History::Middleware`/
55
+ # `Status::ServerMiddleware` (and any custom server middleware)
56
+ # only ever see a job by wrapping this `chain.invoke` call, so a
57
+ # resolution failure caught out here, before `invoke` ever runs,
58
+ # would never reach them — this job would fail/retry/die with no
59
+ # History entry and no status update at all, silently. Catching it
60
+ # here and re-raising it as the chain's own "final block" gives
61
+ # every middleware the same crack at observing this failure as any
62
+ # perform-time one, with `worker` as `nil` in that case (every
63
+ # built-in server middleware ignores the `worker` arg entirely
64
+ # already; a custom one that needs a real instance simply can't act
65
+ # on this failure either way — there's no worker to act on).
66
+ resolution_error = nil
67
+ worker = begin
68
+ build_worker(job)
69
+ rescue Exception => e # rubocop:disable Lint/RescueException
70
+ resolution_error = e
71
+ nil
72
+ end
73
+
74
+ Cogworker.config.server_chain.invoke(worker, job, work.queue) do
75
+ raise resolution_error if resolution_error
76
+
77
+ worker.perform(*job['args'])
78
+ end
79
+ Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_PROCESSED) }
80
+ Cogworker.logger.info { "done: #{job['class']} jid=#{job['jid']}" }
81
+ rescue Exception => e # rubocop:disable Lint/RescueException
82
+ Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_FAILED) }
83
+ route_failure(job, e)
84
+ Cogworker.logger.warn { "fail: #{job['class']} jid=#{job['jid']}: #{e.class}: #{e&.message}" }
85
+ end
86
+ ensure
87
+ deregister_from_workers
88
+ end
89
+
90
+ def build_worker(job)
91
+ klass = Object.const_get(job['class'])
92
+ worker = klass.new
93
+ worker.jid = job['jid'] if worker.respond_to?(:jid=)
94
+ worker
95
+ end
96
+
97
+ def route_failure(job, error)
98
+ job['error_class'] = error.class.name
99
+ job['error_message'] = error.message.to_s[0, 10_000]
100
+ job['failed_at'] ||= Time.now.to_f
101
+
102
+ max_retries = JobUtil.max_retries(job)
103
+ new_count = job['retry_count'].to_i + 1
104
+ job['retry_count'] = new_count
105
+
106
+ if new_count <= max_retries
107
+ delay = retry_delay(new_count)
108
+ Cogworker.config.redis { |c| c.zadd(RedisKeys::RETRY, Time.now.to_f + delay, JSON.generate(job)) }
109
+ else
110
+ Cogworker.config.redis { |c| c.zadd(RedisKeys::DEAD, Time.now.to_f, JSON.generate(job)) }
111
+ end
112
+ end
113
+
114
+ # Grows with the retry count, with jitter to avoid a thundering herd of
115
+ # retries all landing on the same second.
116
+ def retry_delay(count)
117
+ (count**4) + 15 + (rand(30) * (count + 1))
118
+ end
119
+
120
+ def register_in_workers(queue, job)
121
+ payload = JSON.generate('queue' => queue, 'payload' => job, 'run_at' => Time.now.to_i)
122
+ Cogworker.config.redis { |c| c.hset(RedisKeys.workers(Cogworker.identity), tid, payload) }
123
+ end
124
+
125
+ def deregister_from_workers
126
+ Cogworker.config.redis { |c| c.hdel(RedisKeys.workers(Cogworker.identity), tid) }
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ module Prometheus
5
+ # Own minimal exporter, built entirely on the introspection API — no
6
+ # dependency on any third-party Prometheus gem's internal hooks. Mounted
7
+ # exactly the way any other Web UI extension is: `Web.register(...)`
8
+ # adds its `/metrics` route through the normal extension mechanism, not
9
+ # a special-cased reserved path in the Web core.
10
+ module Exporter
11
+ def self.registered(app)
12
+ app.get('/metrics') do
13
+ # Mounted unconditionally by Web.load_routes! (see there); gated
14
+ # here, at request time, rather than by leaving the route
15
+ # unregistered, so Cogworker::Web.prometheus_exporter_enabled can
16
+ # be set any time before a request arrives, same as time_format/
17
+ # live_update_interval — and so a disabled /metrics still 404s
18
+ # for a reason visible right here, not a mysteriously-never-
19
+ # mounted route.
20
+ unless Cogworker::Web.prometheus_exporter_enabled
21
+ next [404, { 'content-type' => 'text/plain' }, ['Not Found']]
22
+ end
23
+
24
+ [200, { 'content-type' => 'text/plain; version=0.0.4' }, [Exporter.render]]
25
+ end
26
+ end
27
+
28
+ def self.render
29
+ stats = Stats.new
30
+ lines = []
31
+ lines << '# TYPE cogworker_processed_total counter'
32
+ lines << "cogworker_processed_total #{stats.processed}"
33
+ lines << '# TYPE cogworker_failed_total counter'
34
+ lines << "cogworker_failed_total #{stats.failed}"
35
+ lines << '# TYPE cogworker_retry_size gauge'
36
+ lines << "cogworker_retry_size #{stats.retry_size}"
37
+ lines << '# TYPE cogworker_scheduled_size gauge'
38
+ lines << "cogworker_scheduled_size #{stats.scheduled_size}"
39
+ lines << '# TYPE cogworker_dead_size gauge'
40
+ lines << "cogworker_dead_size #{stats.dead_size}"
41
+
42
+ lines << '# TYPE cogworker_queue_size gauge'
43
+ queue_names.each { |q| lines << %(cogworker_queue_size{queue="#{q}"} #{Queue.new(q).size}) }
44
+ lines << '# TYPE cogworker_queue_latency_seconds gauge'
45
+ queue_names.each do |q|
46
+ lines << %(cogworker_queue_latency_seconds{queue="#{q}"} #{Queue.new(q).latency.round(3)})
47
+ end
48
+
49
+ lines << '# TYPE cogworker_busy_workers gauge'
50
+ lines << "cogworker_busy_workers #{ProcessSet.new.sum { |p| p['busy'].to_i }}"
51
+
52
+ "#{lines.join("\n")}\n"
53
+ end
54
+
55
+ def self.queue_names
56
+ Cogworker.config.redis { |c| c.smembers(RedisKeys::QUEUES) }.sort
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ Cogworker::Web.register(Cogworker::Prometheus::Exporter, name: 'prometheus_exporter', tab: nil, index: nil)
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Cogworker
6
+ # One named queue: size/latency plus enumeration of its JobRecords, oldest
7
+ # (soon-to-be-popped) entries last, newest first.
8
+ class Queue
9
+ include Enumerable
10
+
11
+ def initialize(name = 'default')
12
+ @name = name
13
+ @key = RedisKeys.queue(name)
14
+ end
15
+
16
+ attr_reader :name
17
+
18
+ def size
19
+ Cogworker.config.redis { |c| c.llen(@key) }
20
+ end
21
+
22
+ # FIFO: LPUSH on push, so index 0 is the newest and -1 the oldest
23
+ # (next to be popped) — latency is measured off that tail entry.
24
+ def latency
25
+ oldest = Cogworker.config.redis { |c| c.lindex(@key, -1) }
26
+ return 0 unless oldest
27
+
28
+ job = JSON.parse(oldest)
29
+ enqueued_at = job['enqueued_at'] || job['created_at']
30
+ return 0 unless enqueued_at
31
+
32
+ [Time.now.to_f - enqueued_at, 0].max
33
+ end
34
+
35
+ # Removes every occurrence of this exact raw job entry (matched by full
36
+ # JSON string, same "raw" identity `Routes::Dead`/`Routes::Retries`
37
+ # already key their own delete/retry actions off).
38
+ def delete(raw)
39
+ Cogworker.config.redis { |c| c.lrem(@key, 0, raw) }
40
+ end
41
+
42
+ def clear
43
+ Cogworker.config.redis { |c| c.del(@key) }
44
+ end
45
+
46
+ def each
47
+ page = 0
48
+ per = 50
49
+ loop do
50
+ entries = Cogworker.config.redis { |c| c.lrange(@key, page * per, (page * per) + per - 1) }
51
+ break if entries.empty?
52
+
53
+ entries.each { |raw| yield JobRecord.new(raw) }
54
+ break if entries.size < per
55
+
56
+ page += 1
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'redis'
4
+ require 'connection_pool'
5
+
6
+ module Cogworker
7
+ # Builds a fork-safe ConnectionPool of redis-rb clients from either a
8
+ # `url:` or separate `host:`/`port:`/`password:`/`db:` keys. redis-rb
9
+ # connects lazily on first command, so building the pool before a
10
+ # cogworkerswarm fork is safe as long as nothing issues a command yet.
11
+ module RedisConnection
12
+ module_function
13
+
14
+ def create(options = {})
15
+ options = options.dup
16
+ size = options.delete(:size) || options.delete(:concurrency) || 5
17
+ client_opts = client_options(options)
18
+
19
+ ConnectionPool.new(size: size, timeout: options[:pool_timeout] || 5) do
20
+ ::Redis.new(client_opts)
21
+ end
22
+ end
23
+
24
+ def client_options(options)
25
+ if options[:url]
26
+ { url: options[:url] }
27
+ else
28
+ {
29
+ host: options[:host] || 'localhost',
30
+ port: options[:port] || 6379,
31
+ password: options[:password],
32
+ db: (options[:db] || 0).to_i
33
+ }.compact
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ # Every Redis key name/namespace this gem reads or writes, in one place —
5
+ # the client (push), the server (processor/scheduler/heartbeat/periodic
6
+ # ticker), and the Web UI all read and write the exact same keys through
7
+ # these constants/methods rather than retyping the literal, so a typo
8
+ # can't silently create a second, disconnected copy of a set/list/hash
9
+ # that nothing else reads.
10
+ module RedisKeys
11
+ QUEUE_PREFIX = 'cogworker:queue:'
12
+ QUEUES = 'cogworker:queues'
13
+ SCHEDULE = 'cogworker:schedule'
14
+ RETRY = 'cogworker:retry'
15
+ DEAD = 'cogworker:dead'
16
+ PROCESSES = 'cogworker:processes'
17
+ STATS_PROCESSED = 'cogworker:stats:processed'
18
+ STATS_FAILED = 'cogworker:stats:failed'
19
+ PERIODIC_SCHEDULE = 'periodic:schedule'
20
+
21
+ module_function
22
+
23
+ def queue(name) = "#{QUEUE_PREFIX}#{name}"
24
+ def process(identity) = "cogworker:process:#{identity}"
25
+ def workers(identity) = "cogworker:workers:#{identity}"
26
+ def signal(identity) = "cogworker:signal:#{identity}"
27
+ def periodic_running(pjid) = "periodic:running:#{pjid}"
28
+ def periodic_last_slot(pjid) = "periodic:last_slot:#{pjid}"
29
+ def periodic_lock(pjid, slot) = "periodic:lock:#{pjid}:#{slot}"
30
+ def unique_lock(digest) = "cogworker:unique:#{digest}"
31
+ end
32
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Cogworker
6
+ # Background poller: moves due jobs from the `cogworker:schedule` and
7
+ # `cogworker:retry` ZSETs into their target queue. Safe with many processes
8
+ # polling concurrently: `ZREM` returning 1 (vs 0) is the atomic "I won the
9
+ # race to graduate this job" signal — the same pattern the periodic
10
+ # scheduler's Lua claim later builds on for its own exactly-once guarantee.
11
+ class Scheduled
12
+ SETS = [RedisKeys::SCHEDULE, RedisKeys::RETRY].freeze
13
+ POLL_INTERVAL = 5
14
+
15
+ def initialize(manager)
16
+ @manager = manager
17
+ end
18
+
19
+ def start!
20
+ @thread = Thread.new { run }
21
+ end
22
+
23
+ # Killed outright, not gracefully joined: unlike a Processor (which may
24
+ # be mid-job), this thread only ever holds the GVL briefly between one
25
+ # POLL_INTERVAL sleep and the next, so there's nothing to drain. Without
26
+ # this, the thread would only notice `@manager.stopping?` after waking
27
+ # from its own up-to-5s sleep — and since it's a non-daemon thread, the
28
+ # OS process can't actually exit until then, which a caller blocked on a
29
+ # plain (no-timeout) `Process.waitpid` for this process — Swarm's
30
+ # phased restart — would otherwise just sit stuck behind.
31
+ def stop!
32
+ @thread&.kill
33
+ end
34
+
35
+ private
36
+
37
+ def run
38
+ until @manager.stopping?
39
+ enqueue_due_jobs unless @manager.quiet?
40
+ sleep(POLL_INTERVAL)
41
+ end
42
+ rescue StandardError => e
43
+ Cogworker.logger.error { "Scheduled poller died: #{e.class}: #{e.message}" }
44
+ end
45
+
46
+ def enqueue_due_jobs
47
+ now = Time.now.to_f
48
+ SETS.each do |set|
49
+ candidates = Cogworker.config.redis { |c| c.zrangebyscore(set, '-inf', now, limit: [0, 50]) }
50
+ candidates.each { |raw| graduate(set, raw) }
51
+ end
52
+ end
53
+
54
+ def graduate(set, raw)
55
+ Cogworker.config.redis do |c|
56
+ won = c.zrem(set, raw)
57
+ next unless won
58
+
59
+ job = JSON.parse(raw)
60
+ c.multi do |pipeline|
61
+ pipeline.sadd(RedisKeys::QUEUES, job['queue'])
62
+ pipeline.lpush(RedisKeys.queue(job['queue']), raw)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ # OS signal names used for process control. `Launcher` (one process) and
5
+ # `Swarm` (the multi-process supervisor) both trap the same quiet/stop
6
+ # pair identically, and `Swarm` relays them on to its children plus its
7
+ # own phased-restart trigger — named here once so the two trap sites and
8
+ # every relay/kill call site can't drift apart.
9
+ module Signals
10
+ QUIET = 'TSTP'
11
+ STOP = 'TERM'
12
+ INTERRUPT = 'INT'
13
+ RESTART = 'USR2'
14
+ end
15
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ # Aggregate counters. Cheap: a handful of GET/ZCARD/LLEN calls, no keyspace
5
+ # scanning.
6
+ class Stats
7
+ def enqueued
8
+ queue_names.sum { |q| Cogworker.config.redis { |c| c.llen(RedisKeys.queue(q)) } }
9
+ end
10
+
11
+ def processed
12
+ (Cogworker.config.redis { |c| c.get(RedisKeys::STATS_PROCESSED) } || 0).to_i
13
+ end
14
+
15
+ def failed
16
+ (Cogworker.config.redis { |c| c.get(RedisKeys::STATS_FAILED) } || 0).to_i
17
+ end
18
+
19
+ def retry_size
20
+ Cogworker.config.redis { |c| c.zcard(RedisKeys::RETRY) }
21
+ end
22
+
23
+ def scheduled_size
24
+ Cogworker.config.redis { |c| c.zcard(RedisKeys::SCHEDULE) }
25
+ end
26
+
27
+ def dead_size
28
+ Cogworker.config.redis { |c| c.zcard(RedisKeys::DEAD) }
29
+ end
30
+
31
+ # The raw `INFO` reply as a flat Hash (server/clients/memory/stats/...
32
+ # sections all merged together, same as the `redis` gem always returns
33
+ # it) — the Web UI's Stats tab picks a handful of fields (version,
34
+ # uptime, connected clients, memory usage) back out of this itself,
35
+ # rather than this class pre-selecting/renaming them, so a future
36
+ # consumer isn't limited to whatever subset this method chose.
37
+ def redis_info
38
+ Cogworker.config.redis(&:info)
39
+ end
40
+
41
+ private
42
+
43
+ def queue_names
44
+ Cogworker.config.redis { |c| c.smembers(RedisKeys::QUEUES) }
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ module Status
5
+ # Client middleware: writes the initial 'queued' status entry right
6
+ # after a job is successfully pushed.
7
+ class ClientMiddleware
8
+ def initialize(expiration)
9
+ @expiration = expiration
10
+ end
11
+
12
+ def call(_worker_class, job, queue, _redis_pool = nil)
13
+ yield
14
+ Storage.write(job['jid'], @expiration, 'status' => 'queued', 'update_time' => Time.now.to_f,
15
+ 'class' => job['class'], 'queue' => queue)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ module Status
5
+ # Server middleware: writes 'working' before perform, then
6
+ # 'complete'/'failed'/'retrying' after, per `JobUtil.terminal_failure?`.
7
+ class ServerMiddleware
8
+ def initialize(expiration)
9
+ @expiration = expiration
10
+ end
11
+
12
+ def call(_worker, job, queue)
13
+ write(job, queue, 'working')
14
+ yield
15
+ write(job, queue, 'complete')
16
+ rescue Exception => e # rubocop:disable Lint/RescueException
17
+ write(job, queue, JobUtil.terminal_failure?(job) ? 'failed' : 'retrying',
18
+ error_class: e.class.name, error_message: e.message.to_s[0, 10_000])
19
+ raise e
20
+ end
21
+
22
+ private
23
+
24
+ def write(job, queue, status, error_class: nil, error_message: nil)
25
+ Storage.write(job['jid'], @expiration, 'status' => status, 'update_time' => Time.now.to_f,
26
+ 'class' => job['class'], 'queue' => queue,
27
+ 'error_class' => error_class, 'error_message' => error_message)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ module Status
5
+ # Shared read/write for `status:<jid>` — a Hash with `status`,
6
+ # `update_time`, `class`, `queue`, and optional `error_class`/
7
+ # `error_message`/`pct`/`message`. TTL is refreshed on every write so a
8
+ # long-running job's status doesn't expire mid-flight relative to its
9
+ # own last update.
10
+ module Storage
11
+ module_function
12
+
13
+ def write(jid, expiration, fields)
14
+ cleaned = fields.compact.transform_values(&:to_s)
15
+ return if cleaned.empty?
16
+
17
+ key = "status:#{jid}"
18
+ Cogworker.config.redis do |c|
19
+ c.hset(key, *cleaned.to_a.flatten)
20
+ c.expire(key, expiration.to_i)
21
+ end
22
+ end
23
+
24
+ def read(jid)
25
+ hash = Cogworker.config.redis { |c| c.hgetall("status:#{jid}") }
26
+ hash.empty? ? nil : hash
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ module Status
5
+ # Include-only mixin (`include Cogworker::Status::Worker` alongside
6
+ # `Cogworker::Worker`). Safe to include and never call anything on — the
7
+ # #at/#store helpers below are optional, for a job that wants to report
8
+ # progress mid-perform; nothing about the required queued/working/
9
+ # complete/failed/retrying lifecycle depends on a job ever calling them.
10
+ module Worker
11
+ def at(pct, message = nil)
12
+ Storage.write(jid, expiration, 'status' => 'working', 'update_time' => Time.now.to_f,
13
+ 'pct' => pct, 'message' => message)
14
+ end
15
+
16
+ def store(fields)
17
+ Storage.write(jid, expiration, fields)
18
+ end
19
+
20
+ private
21
+
22
+ def expiration
23
+ Status.default_expiration || Status::DEFAULT_EXPIRATION
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ # Job status/progress tracking: `status:<jid>` in Redis, TTL = the
5
+ # `expiration:` passed to configure_*_middleware. Real usage reads a wider
6
+ # status vocabulary than the base queued/working/complete/failed set —
7
+ # `retrying` (a failed attempt with retries left) is produced here too.
8
+ # `stopped`/`interrupted` are valid values this schema supports (an
9
+ # external reconciler could write them for a job whose owning process
10
+ # vanished mid-flight) but nothing in this gem produces them automatically
11
+ # yet — a crashed process losing its in-flight job's status is the same
12
+ # known limitation as losing the job itself (see Processor/BasicFetch).
13
+ module Status
14
+ DEFAULT_EXPIRATION = 30 * 60 # seconds
15
+
16
+ class << self
17
+ attr_accessor :default_expiration
18
+ end
19
+
20
+ module_function
21
+
22
+ def configure_client_middleware(config, expiration: DEFAULT_EXPIRATION)
23
+ self.default_expiration ||= expiration.to_i
24
+ config.client_middleware { |chain| chain.add(ClientMiddleware, expiration.to_i) }
25
+ end
26
+
27
+ def configure_server_middleware(config, expiration: DEFAULT_EXPIRATION)
28
+ self.default_expiration ||= expiration.to_i
29
+ config.server_middleware { |chain| chain.add(ServerMiddleware, expiration.to_i) }
30
+ end
31
+
32
+ def status(jid)
33
+ Storage.read(jid)&.fetch('status', nil)
34
+ end
35
+
36
+ def get(jid)
37
+ Storage.read(jid)
38
+ end
39
+ end
40
+ end