solid-jobs 0.4.2 → 0.5.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.
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
4
- require "socket"
5
3
  require "timeout"
6
4
 
7
5
  module SolidJobs
@@ -20,7 +18,7 @@ module SolidJobs
20
18
  @scheduler = nil
21
19
  @heartbeat = nil
22
20
  @started = false
23
- @identity = "#{Socket.gethostname}:#{::Process.pid}:#{SecureRandom.hex(6)}".freeze
21
+ @identity = config.identity
24
22
  @started_at = Time.now.to_f
25
23
  end
26
24
 
@@ -29,7 +27,7 @@ module SolidJobs
29
27
 
30
28
  warn_ruby34_multi_ractor
31
29
  snapshot = Utilities.shareable_copy(
32
- config.ractor_snapshot.merge(identity: identity, started_at: @started_at),
30
+ config.ractor_snapshot.merge(started_at: @started_at),
33
31
  )
34
32
  Recovery.call(config: config)
35
33
  barrier = StartupBarrier.new(config.concurrency + 2)
@@ -60,6 +58,7 @@ module SolidJobs
60
58
  quiet = false
61
59
  stopping = false
62
60
  work = {}
61
+ redis = {}
63
62
  processed = failed = 0
64
63
  next_beat = 0.0
65
64
  until stopping
@@ -68,6 +67,7 @@ module SolidJobs
68
67
  case (message = messages.pop(true))
69
68
  when :quiet
70
69
  quiet = true
70
+ heartbeat.observe(quiet: true, work: work, redis: redis)
71
71
  when :stop
72
72
  stopping = true
73
73
  else
@@ -75,11 +75,21 @@ module SolidJobs
75
75
  case event
76
76
  when :work
77
77
  work[arguments[0].to_s] = arguments[1]
78
+ heartbeat.observe_ractor(
79
+ arguments[0],
80
+ arguments[1],
81
+ job_id: arguments[2],
82
+ quiet: quiet,
83
+ )
78
84
  when :done
79
85
  work.delete(arguments[0].to_s)
86
+ heartbeat.observe_ractor(arguments[0], nil, quiet: quiet)
80
87
  when :stats
81
88
  processed += arguments[0]
82
89
  failed += arguments[1]
90
+ when :redis
91
+ redis[arguments[0].to_s] = arguments[1]
92
+ heartbeat.observe_redis(arguments[0], arguments[1])
83
93
  end
84
94
  end
85
95
  end
@@ -89,7 +99,13 @@ module SolidJobs
89
99
  now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
90
100
  if now >= next_beat
91
101
  begin
92
- heartbeat.beat(quiet: quiet, work: work, processed: processed, failed: failed)
102
+ heartbeat.beat(
103
+ quiet: quiet,
104
+ work: work,
105
+ processed: processed,
106
+ failed: failed,
107
+ redis: redis,
108
+ )
93
109
  processed = failed = 0
94
110
  rescue SolidRedis::ConnectionError => error
95
111
  local_config.logger.warn("Redis heartbeat failed, retrying: #{error.class}: #{error.message}")
@@ -99,7 +115,15 @@ module SolidJobs
99
115
  sleep 0.05 unless stopping
100
116
  end
101
117
  begin
102
- heartbeat.beat(quiet: quiet, work: work, processed: processed, failed: failed)
118
+ heartbeat.observe(quiet: quiet, work: work, redis: redis, phase: :stopping)
119
+ heartbeat.beat(
120
+ quiet: quiet,
121
+ work: work,
122
+ processed: processed,
123
+ failed: failed,
124
+ redis: redis,
125
+ phase: :stopped,
126
+ )
103
127
  heartbeat.cleanup
104
128
  rescue SolidRedis::ConnectionError => error
105
129
  local_config.logger.warn("Redis heartbeat cleanup failed: #{error.class}: #{error.message}")
@@ -13,6 +13,7 @@ module SolidJobs
13
13
  @heartbeat = heartbeat
14
14
  @state_mutex = Mutex.new
15
15
  @busy = false
16
+ @redis_status = nil
16
17
  end
17
18
 
18
19
  def run(control:)
@@ -44,18 +45,40 @@ module SolidJobs
44
45
  @state_mutex.synchronize { @busy = true }
45
46
  envelope = claim.envelope
46
47
  register_work(claim, envelope)
48
+ attributes = telemetry_attributes(claim)
49
+ Instrumentation.emit(@config, :started, envelope, **attributes)
50
+ started_at = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
47
51
  begin
48
52
  @executor.execute(envelope)
49
53
  rescue ExecutionHalt
50
- requeue(claim)
51
- break
54
+ raise
52
55
  rescue StandardError => error
53
- FailurePolicy.call(config: @config, payload: envelope, error: error)
56
+ duration = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - started_at
57
+ Instrumentation.emit(
58
+ @config,
59
+ :failed,
60
+ envelope,
61
+ **attributes,
62
+ duration: duration,
63
+ error_class: error.class.name,
64
+ error_message: error.message.to_s,
65
+ )
66
+ FailurePolicy.call(
67
+ config: @config,
68
+ payload: envelope,
69
+ error: error,
70
+ telemetry_attributes: attributes,
71
+ )
54
72
  complete(claim)
55
73
  failed += 1
56
74
  else
75
+ duration = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - started_at
76
+ Instrumentation.emit(@config, :completed, envelope, **attributes, duration: duration)
57
77
  processed += 1 if complete(claim)
58
78
  end
79
+ rescue ExecutionHalt
80
+ requeue(claim)
81
+ break
59
82
  ensure
60
83
  @state_mutex.synchronize { @busy = false }
61
84
  clear_work
@@ -80,14 +103,24 @@ module SolidJobs
80
103
 
81
104
  def complete(claim)
82
105
  completed = claim.complete
106
+ report_redis("connected")
83
107
  unless completed
84
108
  @config.logger.warn(
85
109
  "Claim fencing rejected stale completion: #{claim.claim_token}",
86
110
  )
87
111
  end
112
+ if completed
113
+ Instrumentation.emit(
114
+ @config,
115
+ :acknowledged,
116
+ claim.envelope,
117
+ **telemetry_attributes(claim),
118
+ )
119
+ end
88
120
  completed
89
121
  rescue SolidRedis::ConnectionError => error
90
- @fetcher.connection_failed!
122
+ report_redis("disconnected")
123
+ @claims.connection_failed!
91
124
  @config.logger.warn(
92
125
  "Redis completion failed; claimed task will be replayed: #{error.class}: #{error.message}",
93
126
  )
@@ -96,17 +129,24 @@ module SolidJobs
96
129
 
97
130
  def requeue(claim)
98
131
  requeued = claim.requeue
132
+ report_redis("connected")
99
133
  unless requeued
100
134
  @config.logger.warn(
101
135
  "Claim fencing rejected stale requeue: #{claim.claim_token}",
102
136
  )
103
137
  end
104
138
  requeued
139
+ rescue SolidRedis::ConnectionError
140
+ report_redis("disconnected")
141
+ raise
105
142
  end
106
143
 
107
144
  def retrieve
108
- @claims.next
145
+ claim = @claims.next
146
+ report_redis("connected")
147
+ claim
109
148
  rescue SolidRedis::ConnectionError => error
149
+ report_redis("disconnected")
110
150
  @claims.connection_failed!
111
151
  @config.logger.warn("Redis claim failed, retrying: #{error.class}: #{error.message}")
112
152
  sleep 0.1
@@ -117,13 +157,14 @@ module SolidJobs
117
157
  return unless @identity
118
158
 
119
159
  work = JSON.generate(
160
+ "job_id" => envelope["id"],
120
161
  "channel" => claim.channel,
121
162
  "envelope" => envelope,
122
163
  "started_at" => Time.now.to_f,
123
164
  "claim_token" => claim.claim_token,
124
165
  "attempt" => claim.attempt,
125
166
  )
126
- send_heartbeat(:work, @processor_id, work)
167
+ send_heartbeat(:work, @processor_id, work, envelope["id"])
127
168
  end
128
169
 
129
170
  def clear_work
@@ -133,7 +174,14 @@ module SolidJobs
133
174
  end
134
175
 
135
176
  def send_heartbeat(*message)
136
- @heartbeat&.send(message, move: true)
177
+ @heartbeat&.send(Utilities.shareable_copy(message))
178
+ end
179
+
180
+ def report_redis(status)
181
+ return if @redis_status == status
182
+
183
+ @redis_status = status
184
+ send_heartbeat(:redis, @processor_id, status)
137
185
  end
138
186
 
139
187
  def receive_commands(control)
@@ -148,5 +196,13 @@ module SolidJobs
148
196
  rescue ThreadError
149
197
  nil
150
198
  end
199
+
200
+ def telemetry_attributes(claim)
201
+ {
202
+ reservation_id: claim.claim_token,
203
+ attempt: claim.attempt,
204
+ **Instrumentation.worker_attributes(@processor_id),
205
+ }
206
+ end
151
207
  end
152
208
  end
@@ -19,11 +19,22 @@ module SolidJobs
19
19
 
20
20
  if retry_allowed?(payload, failure_count, now)
21
21
  decision = retry_decision(payload, failure_count)
22
- return nil if decision == :drop
22
+ if decision == :drop
23
+ emit_dead(payload)
24
+ return nil
25
+ end
23
26
  return archive(payload, now.to_f) if decision == :archive
24
27
 
25
28
  next_attempt = now.to_f + (decision || retry_delay(failure_count))
26
29
  @config.redis_pool.call("ZADD", Keyspace::RETRIES, next_attempt, JSON.generate(payload))
30
+ Instrumentation.emit(
31
+ @config,
32
+ :retry_scheduled,
33
+ payload,
34
+ **telemetry_attributes,
35
+ error_class: @error.class.name,
36
+ error_message: @error.message.to_s,
37
+ )
27
38
  append_message("retry scheduled")
28
39
  next_attempt
29
40
  else
@@ -36,6 +47,10 @@ module SolidJobs
36
47
 
37
48
  private
38
49
 
50
+ def telemetry_attributes
51
+ @telemetry_attributes || {}
52
+ end
53
+
39
54
  def retry_allowed?(payload, failure_count, now)
40
55
  if payload["retry_within"]
41
56
  first_failure = Float(payload["first_failed_ms"]) / 1_000
@@ -78,7 +93,7 @@ module SolidJobs
78
93
  end
79
94
 
80
95
  def archive(payload, timestamp)
81
- @config.redis_pool.pipelined do |pipeline|
96
+ result = @config.redis_pool.pipelined do |pipeline|
82
97
  pipeline.call("ZADD", Keyspace::DISCARDED, timestamp, JSON.generate(payload))
83
98
  pipeline.call(
84
99
  "ZREMRANGEBYSCORE",
@@ -93,6 +108,19 @@ module SolidJobs
93
108
  -(@config.discarded_limit + 1),
94
109
  )
95
110
  end
111
+ emit_dead(payload)
112
+ result
113
+ end
114
+
115
+ def emit_dead(payload)
116
+ Instrumentation.emit(
117
+ @config,
118
+ :dead,
119
+ payload,
120
+ **telemetry_attributes,
121
+ error_class: @error.class.name,
122
+ error_message: @error.message.to_s,
123
+ )
96
124
  end
97
125
  end
98
126
  end
@@ -8,14 +8,15 @@ module SolidJobs
8
8
  INTERVAL = 5
9
9
  TTL = 60
10
10
 
11
- def initialize(config, identity:, started_at:, concurrency:)
11
+ def initialize(config, identity:, started_at:, concurrency:, observations: Observations.new)
12
12
  @config = config
13
13
  @identity = identity
14
14
  @started_at = started_at
15
15
  @concurrency = concurrency
16
+ @observations = observations
16
17
  end
17
18
 
18
- def beat(quiet: false, work: {}, processed: 0, failed: 0)
19
+ def beat(quiet: false, work: {}, processed: 0, failed: 0, redis: {}, phase: nil)
19
20
  now = Time.now.to_f
20
21
  info = {
21
22
  "hostname" => Socket.gethostname,
@@ -27,28 +28,81 @@ module SolidJobs
27
28
  }
28
29
  node_key = Keyspace.node(@identity)
29
30
  work_key = Keyspace.node_work(@identity)
30
- busy = @config.redis_pool.call("HLEN", work_key)
31
- @config.redis_pool.pipelined do |pipeline|
32
- pipeline.call("INCRBY", Keyspace::PROCESSED, processed) if processed.positive?
33
- pipeline.call("INCRBY", Keyspace::FAILED, failed) if failed.positive?
34
- pipeline.call("SADD", Keyspace::NODES, @identity)
35
- pipeline.call(
36
- "HSET",
37
- node_key,
38
- "info", JSON.generate(info),
39
- "beat", now,
40
- "busy", busy,
41
- "quiet", quiet ? "true" : "false",
42
- "concurrency", @concurrency,
43
- )
44
- pipeline.call("EXPIRE", node_key, TTL)
45
- pipeline.call("DEL", work_key)
46
- pipeline.call("HSET", work_key, *work.flatten(1)) unless work.empty?
47
- pipeline.call("EXPIRE", work_key, TTL)
31
+ begin
32
+ busy = @config.redis_pool.call("HLEN", work_key)
33
+ @config.redis_pool.pipelined do |pipeline|
34
+ pipeline.call("INCRBY", Keyspace::PROCESSED, processed) if processed.positive?
35
+ pipeline.call("INCRBY", Keyspace::FAILED, failed) if failed.positive?
36
+ pipeline.call("SADD", Keyspace::NODES, @identity)
37
+ pipeline.call(
38
+ "HSET",
39
+ node_key,
40
+ "info", JSON.generate(info),
41
+ "beat", now,
42
+ "busy", busy,
43
+ "quiet", quiet ? "true" : "false",
44
+ "concurrency", @concurrency,
45
+ )
46
+ pipeline.call("EXPIRE", node_key, TTL)
47
+ pipeline.call("DEL", work_key)
48
+ pipeline.call("HSET", work_key, *work.flatten(1)) unless work.empty?
49
+ pipeline.call("EXPIRE", work_key, TTL)
50
+ end
51
+ ensure
52
+ observe(quiet: quiet, work: work, redis: redis, phase: phase, observed_at: now)
48
53
  end
49
54
  now
50
55
  end
51
56
 
57
+ def observe(quiet: false, work: {}, redis: {}, phase: nil, observed_at: Time.now.to_f)
58
+ Instrumentation.observe(
59
+ @config,
60
+ "process.observed",
61
+ @observations.process(node_id: @identity, observed_at: observed_at),
62
+ )
63
+ @observations.ractors(
64
+ node_id: @identity,
65
+ concurrency: @concurrency,
66
+ work: work,
67
+ quiet: quiet,
68
+ phase: phase,
69
+ observed_at: observed_at,
70
+ ).each { |payload| Instrumentation.observe(@config, "ractor.observed", payload) }
71
+ @observations.redis(
72
+ node_id: @identity,
73
+ concurrency: @concurrency,
74
+ statuses: redis,
75
+ observed_at: observed_at,
76
+ ).each { |payload| Instrumentation.observe(@config, "redis.observed", payload) }
77
+ true
78
+ end
79
+
80
+ def observe_redis(processor_id, status, observed_at: Time.now.to_f)
81
+ payload = @observations.redis_worker(
82
+ node_id: @identity,
83
+ processor_id: processor_id,
84
+ status: status,
85
+ observed_at: observed_at,
86
+ )
87
+ Instrumentation.observe(@config, "redis.observed", payload)
88
+ end
89
+
90
+ def observe_ractor(
91
+ processor_id, work, job_id: nil, quiet: false, phase: nil,
92
+ observed_at: Time.now.to_f
93
+ )
94
+ payload = @observations.ractor_worker(
95
+ node_id: @identity,
96
+ processor_id: processor_id,
97
+ work: work,
98
+ job_id: job_id,
99
+ quiet: quiet,
100
+ phase: phase,
101
+ observed_at: observed_at,
102
+ )
103
+ Instrumentation.observe(@config, "ractor.observed", payload)
104
+ end
105
+
52
106
  def cleanup
53
107
  @config.redis_pool.pipelined do |pipeline|
54
108
  pipeline.call("SREM", Keyspace::NODES, @identity)
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SolidJobs
6
+ module NullInstrumenter
7
+ module_function
8
+
9
+ def instrument(_name, _payload = nil)
10
+ yield if block_given?
11
+ end
12
+ end
13
+
14
+ module Instrumentation
15
+ EVENTS = {
16
+ enqueued: "job.enqueued",
17
+ reserved: "job.reserved",
18
+ journaled: "job.journaled",
19
+ started: "job.started",
20
+ completed: "job.completed",
21
+ failed: "job.failed",
22
+ retry_scheduled: "job.retry_scheduled",
23
+ acknowledged: "job.acknowledged",
24
+ recovered: "job.recovered",
25
+ dead: "job.dead",
26
+ }.freeze
27
+
28
+ module_function
29
+
30
+ def emit(config, event, envelope, **attributes)
31
+ envelope = JSON.parse(envelope) if envelope.is_a?(String)
32
+ payload = {
33
+ job_id: envelope["id"],
34
+ queue: envelope["channel"],
35
+ job_class: envelope["task"],
36
+ node_id: config.identity,
37
+ }.merge(attributes).reject { |_key, value| value.nil? }
38
+ config.instrumenter.instrument(EVENTS.fetch(event), payload)
39
+ true
40
+ rescue ExecutionHalt
41
+ raise
42
+ rescue StandardError, ScriptError => error
43
+ begin
44
+ config.logger.warn("SolidJobs telemetry failed: #{error.class}: #{error.message}")
45
+ rescue ExecutionHalt
46
+ raise
47
+ rescue StandardError, ScriptError
48
+ nil
49
+ end
50
+ false
51
+ end
52
+
53
+ def worker_attributes(processor_id)
54
+ return {} if processor_id.nil?
55
+
56
+ { ractor_id: processor_id, worker_id: processor_id }
57
+ end
58
+
59
+ def observe(config, name, payload)
60
+ config.instrumenter.instrument(name, payload)
61
+ true
62
+ rescue ExecutionHalt
63
+ raise
64
+ rescue StandardError, ScriptError => error
65
+ begin
66
+ config.logger.warn("SolidJobs telemetry failed: #{error.class}: #{error.message}")
67
+ rescue ExecutionHalt
68
+ raise
69
+ rescue StandardError, ScriptError
70
+ nil
71
+ end
72
+ false
73
+ end
74
+ end
75
+ end
@@ -10,6 +10,7 @@ module SolidJobs
10
10
  DISCARDED = "#{PREFIX}:discarded".freeze
11
11
  NODES = "#{PREFIX}:nodes".freeze
12
12
  ATTEMPTS = "#{PREFIX}:attempts".freeze
13
+ PUBLICATION_PREFIX = "#{PREFIX}:publication:".freeze
13
14
  PROCESSED = "#{PREFIX}:metrics:processed".freeze
14
15
  FAILED = "#{PREFIX}:metrics:failed".freeze
15
16
 
@@ -19,6 +20,10 @@ module SolidJobs
19
20
  "#{PREFIX}:channel:#{name}"
20
21
  end
21
22
 
23
+ def publication(digest)
24
+ "#{PUBLICATION_PREFIX}#{digest}"
25
+ end
26
+
22
27
  def node(identity)
23
28
  "#{PREFIX}:node:#{identity}"
24
29
  end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "socket"
6
+
7
+ module SolidJobs
8
+ class Observations
9
+ PROCESS_METRICS = Ractor.make_shareable({
10
+ cpu_time: ["seconds", "ruby-process-clock"],
11
+ rss: ["bytes", "ps"],
12
+ gc_count: ["collections", "ruby-gc"],
13
+ gc_time: ["nanoseconds", "ruby-gc"],
14
+ allocations: ["objects", "ruby-gc"],
15
+ })
16
+ REDIS_METRICS = Ractor.make_shareable({
17
+ latency: ["seconds", "solid-jobs"],
18
+ commands: ["commands", "solid-jobs"],
19
+ memory: ["bytes", "solid-jobs"],
20
+ connections: ["connections", "solid-jobs"],
21
+ })
22
+
23
+ def initialize(
24
+ clock: -> { Time.now.to_f },
25
+ sources: nil
26
+ )
27
+ @clock = clock
28
+ @sources = sources || default_sources
29
+ @last_success = {}
30
+ end
31
+
32
+ def process(node_id:, observed_at: @clock.call)
33
+ {
34
+ node_id: node_id,
35
+ process_id: node_id,
36
+ hostname: Socket.gethostname,
37
+ observed_at: observed_at,
38
+ metrics: PROCESS_METRICS.to_h do |name, (unit, source)|
39
+ [name, sample(name, unit, source, observed_at)]
40
+ end,
41
+ }
42
+ end
43
+
44
+ def ractors(node_id:, concurrency:, work:, quiet: false, phase: nil, observed_at: @clock.call)
45
+ Array.new(concurrency) do |processor_id|
46
+ ractor_worker(
47
+ node_id: node_id,
48
+ processor_id: processor_id,
49
+ work: work[processor_id.to_s],
50
+ quiet: quiet,
51
+ phase: phase,
52
+ observed_at: observed_at,
53
+ )
54
+ end
55
+ end
56
+
57
+ def ractor_worker(
58
+ node_id:, processor_id:, work:, job_id: nil, quiet: false, phase: nil,
59
+ observed_at: @clock.call
60
+ )
61
+ job_id ||= current_job_id(work)
62
+ state, activity = worker_state(job_id, quiet, phase)
63
+ {
64
+ node_id: node_id,
65
+ observed_at: observed_at,
66
+ ractor_id: processor_id,
67
+ worker_id: processor_id,
68
+ executor_id: processor_id,
69
+ state: state,
70
+ activity: activity,
71
+ current_job_ids: job_id ? [job_id] : [],
72
+ }
73
+ end
74
+
75
+ def redis(node_id:, concurrency:, statuses:, observed_at: @clock.call)
76
+ Array.new(concurrency) do |processor_id|
77
+ redis_worker(
78
+ node_id: node_id,
79
+ processor_id: processor_id,
80
+ status: statuses.fetch(processor_id.to_s, "unavailable"),
81
+ observed_at: observed_at,
82
+ )
83
+ end
84
+ end
85
+
86
+ def redis_worker(node_id:, processor_id:, status:, observed_at: @clock.call)
87
+ {
88
+ node_id: node_id,
89
+ observed_at: observed_at,
90
+ ractor_id: processor_id,
91
+ worker_id: processor_id,
92
+ executor_id: processor_id,
93
+ connection_status: status,
94
+ metrics: REDIS_METRICS.to_h do |name, (unit, source)|
95
+ [name, unavailable(unit, source)]
96
+ end,
97
+ }
98
+ end
99
+
100
+ private
101
+
102
+ def sample(name, unit, source, observed_at)
103
+ value = @sources.fetch(name).call
104
+ raise RangeError, "#{name} must be non-negative" unless value.is_a?(Numeric) && value.finite? && !value.negative?
105
+
106
+ @last_success[name] = [value, observed_at]
107
+ measurement(value, unit, "available", source, observed_at, 0.0)
108
+ rescue StandardError, NotImplementedError
109
+ last = @last_success[name]
110
+ return unavailable(unit, source) unless last
111
+
112
+ value, sampled_at = last
113
+ measurement(value, unit, "stale", source, sampled_at, observed_at - sampled_at)
114
+ end
115
+
116
+ def measurement(value, unit, status, source, observed_at, age_seconds)
117
+ {
118
+ value: value,
119
+ unit: unit,
120
+ status: status,
121
+ source: source,
122
+ observed_at: observed_at,
123
+ age_seconds: age_seconds,
124
+ }
125
+ end
126
+
127
+ def unavailable(unit, source)
128
+ measurement(nil, unit, "unavailable", source, nil, nil)
129
+ end
130
+
131
+ def current_job_id(raw)
132
+ return unless raw
133
+
134
+ record = JSON.parse(raw)
135
+ direct_job_id = record["job_id"]
136
+ return direct_job_id if direct_job_id.is_a?(String) && !direct_job_id.empty?
137
+
138
+ envelope = record.fetch("envelope")
139
+ envelope = JSON.parse(envelope) if envelope.is_a?(String)
140
+ job_id = envelope["id"]
141
+ job_id if job_id.is_a?(String) && !job_id.empty?
142
+ rescue JSON::ParserError, KeyError, TypeError
143
+ nil
144
+ end
145
+
146
+ def worker_state(job_id, quiet, phase)
147
+ case phase
148
+ when :stopping then %w[stopping stopping]
149
+ when :stopped then %w[stopped stopped]
150
+ else
151
+ job_id ? %w[busy executing] : ["idle", quiet ? "waiting" : "waiting"]
152
+ end
153
+ end
154
+
155
+ def default_sources
156
+ {
157
+ cpu_time: -> { Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) },
158
+ rss: method(:read_rss),
159
+ gc_count: -> { GC.count },
160
+ gc_time: lambda {
161
+ raise NotImplementedError, "GC.total_time unavailable" unless GC.respond_to?(:total_time)
162
+
163
+ GC.measure_total_time = true if GC.respond_to?(:measure_total_time=)
164
+ GC.total_time
165
+ },
166
+ allocations: -> { GC.stat.fetch(:total_allocated_objects) },
167
+ }
168
+ end
169
+
170
+ def read_rss
171
+ stdout, stderr, status = Open3.capture3("ps", "-o", "rss=", "-p", Process.pid.to_s)
172
+ raise "ps failed: #{stderr.strip}" unless status.success?
173
+
174
+ Integer(stdout.strip) * 1024
175
+ end
176
+ end
177
+ end