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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +307 -9
- data/docs/reliability.md +5 -0
- data/lib/solid_jobs/blueprint.rb +14 -2
- data/lib/solid_jobs/claim.rb +74 -25
- data/lib/solid_jobs/conductor.rb +30 -6
- data/lib/solid_jobs/engine.rb +63 -7
- data/lib/solid_jobs/failure_policy.rb +30 -2
- data/lib/solid_jobs/heartbeat.rb +74 -20
- data/lib/solid_jobs/instrumentation.rb +75 -0
- data/lib/solid_jobs/keyspace.rb +5 -0
- data/lib/solid_jobs/observations.rb +177 -0
- data/lib/solid_jobs/publication_barrier.rb +46 -0
- data/lib/solid_jobs/publisher.rb +37 -8
- data/lib/solid_jobs/recovery.rb +31 -2
- data/lib/solid_jobs/task.rb +35 -0
- data/lib/solid_jobs/version.rb +1 -1
- data/lib/solid_jobs.rb +11 -0
- metadata +4 -1
data/lib/solid_jobs/conductor.rb
CHANGED
|
@@ -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 =
|
|
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(
|
|
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(
|
|
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.
|
|
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}")
|
data/lib/solid_jobs/engine.rb
CHANGED
|
@@ -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
|
-
|
|
51
|
-
break
|
|
54
|
+
raise
|
|
52
55
|
rescue StandardError => error
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/solid_jobs/heartbeat.rb
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
data/lib/solid_jobs/keyspace.rb
CHANGED
|
@@ -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
|