solid-jobs 0.1.3 → 0.2.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 +29 -0
- data/README.md +137 -118
- data/docs/reliability.md +71 -89
- data/lib/active_job/queue_adapters/solid_jobs_adapter.rb +6 -7
- data/lib/solid_jobs/active_job.rb +2 -5
- data/lib/solid_jobs/api.rb +133 -156
- data/lib/solid_jobs/claim.rb +315 -0
- data/lib/solid_jobs/cli.rb +9 -6
- data/lib/solid_jobs/config.rb +50 -50
- data/lib/solid_jobs/executor.rb +34 -0
- data/lib/solid_jobs/failure_policy.rb +98 -0
- data/lib/solid_jobs/heartbeat.rb +15 -13
- data/lib/solid_jobs/integrity_check.rb +39 -36
- data/lib/solid_jobs/interceptor_registry.rb +57 -0
- data/lib/solid_jobs/keyspace.rb +42 -0
- data/lib/solid_jobs/processor.rb +30 -30
- data/lib/solid_jobs/publisher.rb +186 -0
- data/lib/solid_jobs/recovery.rb +8 -7
- data/lib/solid_jobs/server.rb +2 -2
- data/lib/solid_jobs/task.rb +147 -0
- data/lib/solid_jobs/testing.rb +18 -18
- data/lib/solid_jobs/{scheduler.rb → timer.rb} +8 -8
- data/lib/solid_jobs/version.rb +1 -1
- data/lib/solid_jobs.rb +18 -17
- metadata +11 -10
- data/lib/solid_jobs/client.rb +0 -202
- data/lib/solid_jobs/fetch.rb +0 -310
- data/lib/solid_jobs/job.rb +0 -156
- data/lib/solid_jobs/middleware/chain.rb +0 -104
- data/lib/solid_jobs/retry_service.rb +0 -100
- data/lib/solid_jobs/worker.rb +0 -35
data/lib/solid_jobs/heartbeat.rb
CHANGED
|
@@ -21,37 +21,39 @@ module SolidJobs
|
|
|
21
21
|
"hostname" => Socket.gethostname,
|
|
22
22
|
"pid" => ::Process.pid,
|
|
23
23
|
"started_at" => @started_at,
|
|
24
|
-
"
|
|
24
|
+
"channels" => @config.channels,
|
|
25
25
|
"labels" => [],
|
|
26
26
|
"identity" => @identity,
|
|
27
27
|
}
|
|
28
|
-
|
|
28
|
+
node_key = Keyspace.node(@identity)
|
|
29
|
+
work_key = Keyspace.node_work(@identity)
|
|
30
|
+
busy = @config.redis_pool.call("HLEN", work_key)
|
|
29
31
|
@config.redis_pool.pipelined do |pipeline|
|
|
30
|
-
pipeline.call("INCRBY",
|
|
31
|
-
pipeline.call("INCRBY",
|
|
32
|
-
pipeline.call("SADD",
|
|
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)
|
|
33
35
|
pipeline.call(
|
|
34
36
|
"HSET",
|
|
35
|
-
|
|
37
|
+
node_key,
|
|
36
38
|
"info", JSON.generate(info),
|
|
37
39
|
"beat", now,
|
|
38
40
|
"busy", busy,
|
|
39
41
|
"quiet", quiet ? "true" : "false",
|
|
40
42
|
"concurrency", @concurrency,
|
|
41
43
|
)
|
|
42
|
-
pipeline.call("EXPIRE",
|
|
43
|
-
pipeline.call("DEL",
|
|
44
|
-
pipeline.call("HSET",
|
|
45
|
-
pipeline.call("EXPIRE",
|
|
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)
|
|
46
48
|
end
|
|
47
49
|
now
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
def cleanup
|
|
51
53
|
@config.redis_pool.pipelined do |pipeline|
|
|
52
|
-
pipeline.call("SREM",
|
|
53
|
-
pipeline.call("DEL", @identity)
|
|
54
|
-
pipeline.call("DEL",
|
|
54
|
+
pipeline.call("SREM", Keyspace::NODES, @identity)
|
|
55
|
+
pipeline.call("DEL", Keyspace.node(@identity))
|
|
56
|
+
pipeline.call("DEL", Keyspace.node_work(@identity))
|
|
55
57
|
end
|
|
56
58
|
ensure
|
|
57
59
|
@config.close
|
|
@@ -5,10 +5,10 @@ require "set"
|
|
|
5
5
|
|
|
6
6
|
module SolidJobs
|
|
7
7
|
class IntegrityCheck
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
ACTIVE_COLLECTIONS = {
|
|
9
|
+
Keyspace::PLANNED => "PLANNED",
|
|
10
|
+
Keyspace::RETRIES => "RETRYING",
|
|
11
|
+
Keyspace::DISCARDED => "DISCARDED",
|
|
12
12
|
}.freeze
|
|
13
13
|
|
|
14
14
|
Report = Struct.new(
|
|
@@ -51,10 +51,10 @@ module SolidJobs
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
def call
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
read_channels
|
|
55
|
+
read_timed_collections
|
|
56
|
+
claimed = read_claimed
|
|
57
|
+
validate_claim_metadata(claimed)
|
|
58
58
|
@acked.each { |job_id| add_state(job_id, "ACKED", "acknowledged") }
|
|
59
59
|
|
|
60
60
|
observed = @states.keys.to_set
|
|
@@ -71,56 +71,57 @@ module SolidJobs
|
|
|
71
71
|
|
|
72
72
|
private
|
|
73
73
|
|
|
74
|
-
def
|
|
75
|
-
scan("
|
|
74
|
+
def read_channels
|
|
75
|
+
scan("#{Keyspace::PREFIX}:channel:*").each do |key|
|
|
76
76
|
Array(@redis.call("LRANGE", key, 0, -1)).each_with_index do |raw, index|
|
|
77
77
|
add_payload(raw, "READY", "#{key}[#{index}]")
|
|
78
78
|
end
|
|
79
79
|
end
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
def
|
|
83
|
-
|
|
82
|
+
def read_timed_collections
|
|
83
|
+
ACTIVE_COLLECTIONS.each do |key, state|
|
|
84
84
|
Array(@redis.call("ZRANGE", key, 0, -1)).each_with_index do |raw, index|
|
|
85
85
|
add_payload(raw, state, "#{key}[#{index}]")
|
|
86
86
|
end
|
|
87
87
|
end
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
def
|
|
91
|
-
scan("
|
|
90
|
+
def read_claimed
|
|
91
|
+
scan("#{Keyspace::PREFIX}:node:*:claimed:*").to_h do |key|
|
|
92
92
|
records = Array(@redis.call("LRANGE", key, 0, -1)).each_with_index.map do |raw, index|
|
|
93
93
|
payload = parse_payload(raw, "#{key}[#{index}]")
|
|
94
|
-
add_state(payload["
|
|
94
|
+
add_state(payload["id"], "CLAIMED", "#{key}[#{index}]") if payload
|
|
95
95
|
payload
|
|
96
96
|
end.compact
|
|
97
97
|
[key, records]
|
|
98
98
|
end
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
-
def
|
|
102
|
-
metadata =
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
def validate_claim_metadata(claimed)
|
|
102
|
+
metadata = claim_metadata
|
|
103
|
+
claimed.each do |key, payloads|
|
|
104
|
+
node_key, executor_id = key.split(":claimed:", 2)
|
|
105
|
+
identity = node_key.delete_prefix("#{Keyspace::PREFIX}:node:")
|
|
106
|
+
entry = metadata.delete([identity, executor_id])
|
|
106
107
|
if payloads.empty?
|
|
107
|
-
@invalid_reservations << issue(key, "
|
|
108
|
+
@invalid_reservations << issue(key, "empty_claimed_list")
|
|
108
109
|
elsif payloads.length > 1
|
|
109
|
-
@invalid_reservations << issue(key, "
|
|
110
|
+
@invalid_reservations << issue(key, "multiple_tasks_for_executor")
|
|
110
111
|
end
|
|
111
112
|
payload = payloads.first
|
|
112
113
|
unless entry
|
|
113
|
-
@invalid_reservations << issue(key, "missing_metadata", job_id: payload&.[]("
|
|
114
|
+
@invalid_reservations << issue(key, "missing_metadata", job_id: payload&.[]("id"))
|
|
114
115
|
next
|
|
115
116
|
end
|
|
116
117
|
next unless payload
|
|
117
118
|
|
|
118
119
|
attributes = entry.fetch(:attributes)
|
|
119
120
|
{
|
|
120
|
-
"
|
|
121
|
-
"
|
|
122
|
-
"
|
|
123
|
-
"
|
|
121
|
+
"task_id" => payload["id"],
|
|
122
|
+
"node_id" => identity,
|
|
123
|
+
"executor_id" => executor_id,
|
|
124
|
+
"channel" => payload["channel"],
|
|
124
125
|
}.each do |field, expected|
|
|
125
126
|
next if attributes[field].to_s == expected.to_s
|
|
126
127
|
|
|
@@ -138,14 +139,16 @@ module SolidJobs
|
|
|
138
139
|
entry.fetch(:key),
|
|
139
140
|
"metadata_without_payload",
|
|
140
141
|
field: entry.fetch(:field),
|
|
141
|
-
job_id: entry.fetch(:attributes)["
|
|
142
|
+
job_id: entry.fetch(:attributes)["task_id"],
|
|
142
143
|
)
|
|
143
144
|
end
|
|
144
145
|
end
|
|
145
146
|
|
|
146
|
-
def
|
|
147
|
-
scan("
|
|
148
|
-
identity = key
|
|
147
|
+
def claim_metadata
|
|
148
|
+
scan("#{Keyspace::PREFIX}:node:*:claims").each_with_object({}) do |key, entries|
|
|
149
|
+
identity = key
|
|
150
|
+
.delete_prefix("#{Keyspace::PREFIX}:node:")
|
|
151
|
+
.delete_suffix(":claims")
|
|
149
152
|
hash_entries(key).each do |field, raw|
|
|
150
153
|
attributes = parse_json(raw, "#{key}[#{field}]")
|
|
151
154
|
next unless attributes
|
|
@@ -160,10 +163,10 @@ module SolidJobs
|
|
|
160
163
|
end
|
|
161
164
|
|
|
162
165
|
def dangling_attempts
|
|
163
|
-
hash_entries(
|
|
166
|
+
hash_entries(Keyspace::ATTEMPTS).filter_map do |job_id, attempt|
|
|
164
167
|
next if @states[job_id]&.any? { |location| location.fetch(:state) != "ACKED" }
|
|
165
168
|
|
|
166
|
-
{job_id: job_id, attempt: Integer(attempt), index:
|
|
169
|
+
{job_id: job_id, attempt: Integer(attempt), index: Keyspace::ATTEMPTS}
|
|
167
170
|
end
|
|
168
171
|
end
|
|
169
172
|
|
|
@@ -177,15 +180,15 @@ module SolidJobs
|
|
|
177
180
|
|
|
178
181
|
def add_payload(raw, state, location)
|
|
179
182
|
payload = parse_payload(raw, location)
|
|
180
|
-
add_state(payload["
|
|
183
|
+
add_state(payload["id"], state, location) if payload
|
|
181
184
|
end
|
|
182
185
|
|
|
183
186
|
def parse_payload(raw, location)
|
|
184
187
|
payload = parse_json(raw, location)
|
|
185
188
|
return unless payload
|
|
186
|
-
return payload if payload["
|
|
189
|
+
return payload if payload["id"]
|
|
187
190
|
|
|
188
|
-
@malformed_payloads << {location: location, error: "missing
|
|
191
|
+
@malformed_payloads << {location: location, error: "missing id", raw: raw}
|
|
189
192
|
nil
|
|
190
193
|
end
|
|
191
194
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidJobs
|
|
4
|
+
class InterceptorRegistry
|
|
5
|
+
Definition = Data.define(:type, :arguments)
|
|
6
|
+
|
|
7
|
+
include Enumerable
|
|
8
|
+
|
|
9
|
+
def initialize(definitions = [])
|
|
10
|
+
@definitions = definitions.map do |definition|
|
|
11
|
+
Definition.new(definition.type, definition.arguments.dup.freeze)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def each(&block)
|
|
16
|
+
@definitions.each(&block)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def use(type, *arguments)
|
|
20
|
+
discard(type)
|
|
21
|
+
@definitions = (@definitions + [Definition.new(type, arguments.freeze)]).freeze
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def discard(type)
|
|
26
|
+
@definitions = @definitions.reject { |definition| definition.type == type }.freeze
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def empty?
|
|
31
|
+
@definitions.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(context, &operation)
|
|
35
|
+
continuation = operation
|
|
36
|
+
@definitions.reverse_each do |definition|
|
|
37
|
+
interceptor = definition.type.new(*definition.arguments)
|
|
38
|
+
downstream = continuation
|
|
39
|
+
continuation = -> { interceptor.around(context, &downstream) }
|
|
40
|
+
end
|
|
41
|
+
continuation.call
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def export
|
|
45
|
+
Utilities.shareable_copy(
|
|
46
|
+
@definitions.map { |definition| [definition.type, definition.arguments] },
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def import(records)
|
|
51
|
+
@definitions = records.map do |type, arguments|
|
|
52
|
+
Definition.new(type, arguments.freeze)
|
|
53
|
+
end.freeze
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidJobs
|
|
4
|
+
module Keyspace
|
|
5
|
+
PREFIX = "solid_jobs".freeze
|
|
6
|
+
CHANNELS = "#{PREFIX}:channels".freeze
|
|
7
|
+
PAUSED_CHANNELS = "#{PREFIX}:paused_channels".freeze
|
|
8
|
+
PLANNED = "#{PREFIX}:planned".freeze
|
|
9
|
+
RETRIES = "#{PREFIX}:retries".freeze
|
|
10
|
+
DISCARDED = "#{PREFIX}:discarded".freeze
|
|
11
|
+
NODES = "#{PREFIX}:nodes".freeze
|
|
12
|
+
ATTEMPTS = "#{PREFIX}:attempts".freeze
|
|
13
|
+
PROCESSED = "#{PREFIX}:metrics:processed".freeze
|
|
14
|
+
FAILED = "#{PREFIX}:metrics:failed".freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def channel(name)
|
|
19
|
+
"#{PREFIX}:channel:#{name}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def node(identity)
|
|
23
|
+
"#{PREFIX}:node:#{identity}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def node_work(identity)
|
|
27
|
+
"#{node(identity)}:work"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def node_signals(identity)
|
|
31
|
+
"#{node(identity)}:signals"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def claims(identity)
|
|
35
|
+
"#{node(identity)}:claims"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def claimed(identity, executor_id)
|
|
39
|
+
"#{node(identity)}:claimed:#{executor_id}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/solid_jobs/processor.rb
CHANGED
|
@@ -6,8 +6,8 @@ module SolidJobs
|
|
|
6
6
|
class Processor
|
|
7
7
|
def initialize(config, identity: nil, processor_id: nil, heartbeat: nil)
|
|
8
8
|
@config = config
|
|
9
|
-
@
|
|
10
|
-
@
|
|
9
|
+
@claims = Claim.new(config, identity: identity, processor_id: processor_id)
|
|
10
|
+
@executor = Executor.new(redis_config: config.redis_config, config: config)
|
|
11
11
|
@identity = identity
|
|
12
12
|
@processor_id = processor_id
|
|
13
13
|
@heartbeat = heartbeat
|
|
@@ -29,12 +29,12 @@ module SolidJobs
|
|
|
29
29
|
next
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
next unless
|
|
32
|
+
claim = retrieve
|
|
33
|
+
next unless claim
|
|
34
34
|
|
|
35
35
|
receive_commands(control)
|
|
36
36
|
if @stopping || @quiet
|
|
37
|
-
requeue(
|
|
37
|
+
requeue(claim)
|
|
38
38
|
break if @stopping
|
|
39
39
|
|
|
40
40
|
next
|
|
@@ -42,19 +42,19 @@ module SolidJobs
|
|
|
42
42
|
|
|
43
43
|
begin
|
|
44
44
|
@state_mutex.synchronize { @busy = true }
|
|
45
|
-
|
|
46
|
-
register_work(
|
|
45
|
+
envelope = claim.envelope
|
|
46
|
+
register_work(claim, envelope)
|
|
47
47
|
begin
|
|
48
|
-
@
|
|
48
|
+
@executor.execute(envelope)
|
|
49
49
|
rescue Shutdown
|
|
50
|
-
requeue(
|
|
50
|
+
requeue(claim)
|
|
51
51
|
break
|
|
52
52
|
rescue StandardError => error
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
FailurePolicy.call(config: @config, payload: envelope, error: error)
|
|
54
|
+
complete(claim)
|
|
55
55
|
failed += 1
|
|
56
56
|
else
|
|
57
|
-
processed += 1 if
|
|
57
|
+
processed += 1 if complete(claim)
|
|
58
58
|
end
|
|
59
59
|
ensure
|
|
60
60
|
@state_mutex.synchronize { @busy = false }
|
|
@@ -78,50 +78,50 @@ module SolidJobs
|
|
|
78
78
|
|
|
79
79
|
private
|
|
80
80
|
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
unless
|
|
81
|
+
def complete(claim)
|
|
82
|
+
completed = claim.complete
|
|
83
|
+
unless completed
|
|
84
84
|
@config.logger.warn(
|
|
85
|
-
"
|
|
85
|
+
"Claim fencing rejected stale completion: #{claim.claim_token}",
|
|
86
86
|
)
|
|
87
87
|
end
|
|
88
|
-
|
|
88
|
+
completed
|
|
89
89
|
rescue SolidRedis::ConnectionError => error
|
|
90
90
|
@fetcher.connection_failed!
|
|
91
91
|
@config.logger.warn(
|
|
92
|
-
"Redis
|
|
92
|
+
"Redis completion failed; claimed task will be replayed: #{error.class}: #{error.message}",
|
|
93
93
|
)
|
|
94
94
|
false
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
-
def requeue(
|
|
98
|
-
requeued =
|
|
97
|
+
def requeue(claim)
|
|
98
|
+
requeued = claim.requeue
|
|
99
99
|
unless requeued
|
|
100
100
|
@config.logger.warn(
|
|
101
|
-
"
|
|
101
|
+
"Claim fencing rejected stale requeue: #{claim.claim_token}",
|
|
102
102
|
)
|
|
103
103
|
end
|
|
104
104
|
requeued
|
|
105
105
|
end
|
|
106
106
|
|
|
107
107
|
def retrieve
|
|
108
|
-
@
|
|
108
|
+
@claims.next
|
|
109
109
|
rescue SolidRedis::ConnectionError => error
|
|
110
|
-
@
|
|
111
|
-
@config.logger.warn("Redis
|
|
110
|
+
@claims.connection_failed!
|
|
111
|
+
@config.logger.warn("Redis claim failed, retrying: #{error.class}: #{error.message}")
|
|
112
112
|
sleep 0.1
|
|
113
113
|
nil
|
|
114
114
|
end
|
|
115
115
|
|
|
116
|
-
def register_work(
|
|
116
|
+
def register_work(claim, envelope)
|
|
117
117
|
return unless @identity
|
|
118
118
|
|
|
119
119
|
work = JSON.generate(
|
|
120
|
-
"
|
|
121
|
-
"
|
|
122
|
-
"
|
|
123
|
-
"
|
|
124
|
-
"attempt" =>
|
|
120
|
+
"channel" => claim.channel,
|
|
121
|
+
"envelope" => envelope,
|
|
122
|
+
"started_at" => Time.now.to_f,
|
|
123
|
+
"claim_token" => claim.claim_token,
|
|
124
|
+
"attempt" => claim.attempt,
|
|
125
125
|
)
|
|
126
126
|
send_heartbeat(:work, @processor_id, work)
|
|
127
127
|
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module SolidJobs
|
|
7
|
+
class Publisher
|
|
8
|
+
DEFAULT_CHUNK_SIZE = 1_000
|
|
9
|
+
PLANNED_CHUNK_SIZE = 100
|
|
10
|
+
Publication = Data.define(:task, :envelope, :redis_pool)
|
|
11
|
+
|
|
12
|
+
def self.publish(envelope)
|
|
13
|
+
new.publish(envelope)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.publish_many(envelope)
|
|
17
|
+
new.publish_many(envelope)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(config: nil, configuration: nil)
|
|
21
|
+
selected = config || configuration || SolidJobs.config
|
|
22
|
+
@config = selected.is_a?(SolidRedis::Config) ? Config.new(redis: selected) : selected
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def publish(envelope)
|
|
26
|
+
normalized = normalize(envelope)
|
|
27
|
+
normalized = intercept(normalized)
|
|
28
|
+
return unless normalized
|
|
29
|
+
|
|
30
|
+
validate_arguments!(normalized.fetch("arguments"))
|
|
31
|
+
persist([normalized])
|
|
32
|
+
normalized.fetch("id")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def publish_many(envelope)
|
|
36
|
+
raise InvalidJobError, "Task envelope must be a Hash" unless envelope.is_a?(Hash)
|
|
37
|
+
|
|
38
|
+
source = Utilities.stringify_hash_keys(envelope)
|
|
39
|
+
argument_sets = source.delete("arguments")
|
|
40
|
+
unless argument_sets.respond_to?(:each)
|
|
41
|
+
raise InvalidJobError, "Task 'arguments' must be an Array or Enumerable"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
run_times = source.delete("run_at")
|
|
45
|
+
unless run_times.nil? || run_times.is_a?(Numeric) || run_times.is_a?(Array)
|
|
46
|
+
raise InvalidJobError, "Task 'run_at' must be Numeric or an Array"
|
|
47
|
+
end
|
|
48
|
+
if run_times.is_a?(Array) && argument_sets.respond_to?(:size) && run_times.size != argument_sets.size
|
|
49
|
+
raise InvalidJobError, "Task 'run_at' Array must match the number of argument sets"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
chunk_size = Integer(
|
|
53
|
+
source.delete("chunk_size") || (run_times ? PLANNED_CHUNK_SIZE : DEFAULT_CHUNK_SIZE),
|
|
54
|
+
)
|
|
55
|
+
raise ArgumentError, "chunk_size must be positive" unless chunk_size.positive?
|
|
56
|
+
|
|
57
|
+
explicit_id = source.delete("id")
|
|
58
|
+
if explicit_id && argument_sets.respond_to?(:size) && argument_sets.size > 1
|
|
59
|
+
raise InvalidJobError, "An explicit id is only valid for a single task"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
template = normalize(source.merge("arguments" => []))
|
|
63
|
+
template.delete("id")
|
|
64
|
+
ids = []
|
|
65
|
+
argument_sets.each_slice(chunk_size).with_index do |chunk, chunk_index|
|
|
66
|
+
envelopes = chunk.filter_map.with_index do |arguments, index|
|
|
67
|
+
raise InvalidJobError, "Each task's arguments must be an Array" unless arguments.is_a?(Array)
|
|
68
|
+
|
|
69
|
+
item = template.merge(
|
|
70
|
+
"arguments" => arguments,
|
|
71
|
+
"id" => explicit_id || SecureRandom.uuid,
|
|
72
|
+
)
|
|
73
|
+
item["run_at"] = run_times.is_a?(Array) ? run_times[chunk_index * chunk_size + index] : run_times if run_times
|
|
74
|
+
intercepted = intercept(item)
|
|
75
|
+
if intercepted
|
|
76
|
+
validate_arguments!(intercepted.fetch("arguments"))
|
|
77
|
+
ids << intercepted.fetch("id")
|
|
78
|
+
else
|
|
79
|
+
ids << nil
|
|
80
|
+
end
|
|
81
|
+
intercepted
|
|
82
|
+
end
|
|
83
|
+
persist(envelopes)
|
|
84
|
+
end
|
|
85
|
+
ids
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def persist(envelopes)
|
|
89
|
+
return true if envelopes.empty?
|
|
90
|
+
|
|
91
|
+
if defined?(Testing)
|
|
92
|
+
case Testing.mode
|
|
93
|
+
when :capture
|
|
94
|
+
envelopes.each do |envelope|
|
|
95
|
+
Testing.captured_for(Utilities.constantize(envelope.fetch("task"))) << envelope
|
|
96
|
+
end
|
|
97
|
+
return true
|
|
98
|
+
when :execute
|
|
99
|
+
envelopes.each { |envelope| Testing.execute(envelope) }
|
|
100
|
+
return true
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
@config.redis_pool.pipelined do |pipeline|
|
|
105
|
+
planned, ready = envelopes.partition { |envelope| envelope["run_at"] }
|
|
106
|
+
planned.each do |envelope|
|
|
107
|
+
score = envelope.delete("run_at")
|
|
108
|
+
pipeline.call("ZADD", Keyspace::PLANNED, score, JSON.generate(envelope))
|
|
109
|
+
end
|
|
110
|
+
persist_ready(pipeline, ready)
|
|
111
|
+
end
|
|
112
|
+
true
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def normalize(envelope)
|
|
118
|
+
raise InvalidJobError, "Task envelope must be a Hash" unless envelope.is_a?(Hash)
|
|
119
|
+
|
|
120
|
+
item = @config.default_task_options.merge(Utilities.stringify_hash_keys(envelope))
|
|
121
|
+
task = item["task"]
|
|
122
|
+
arguments = item["arguments"]
|
|
123
|
+
unless task.is_a?(Class) || (task.is_a?(String) && !task.empty?)
|
|
124
|
+
raise InvalidJobError, "Task envelope requires a non-empty task"
|
|
125
|
+
end
|
|
126
|
+
raise InvalidJobError, "Task arguments must be an Array" unless arguments.is_a?(Array)
|
|
127
|
+
|
|
128
|
+
item["task"] = task.name if task.is_a?(Class)
|
|
129
|
+
item["channel"] = String(item.fetch("channel"))
|
|
130
|
+
item["id"] ||= SecureRandom.uuid
|
|
131
|
+
item["created_ms"] ||= Utilities.realtime_milliseconds
|
|
132
|
+
item
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def intercept(envelope)
|
|
136
|
+
context = Publication.new(envelope.fetch("task"), envelope, @config.redis_pool)
|
|
137
|
+
@config.publish_interceptors.call(context) { envelope }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def persist_ready(pipeline, envelopes)
|
|
141
|
+
envelopes.group_by { |envelope| envelope.fetch("channel") }.each do |channel, group|
|
|
142
|
+
pipeline.call("SADD", Keyspace::CHANNELS, channel)
|
|
143
|
+
queued_ms = Utilities.realtime_milliseconds
|
|
144
|
+
encoded = group.map do |envelope|
|
|
145
|
+
envelope["queued_ms"] = queued_ms
|
|
146
|
+
JSON.generate(envelope)
|
|
147
|
+
end
|
|
148
|
+
pipeline.call("LPUSH", Keyspace.channel(channel), *encoded)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def validate_arguments!(arguments)
|
|
153
|
+
invalid = invalid_json_value(arguments)
|
|
154
|
+
return unless invalid
|
|
155
|
+
|
|
156
|
+
message = "Task arguments must contain JSON-native values with string hash keys; found #{invalid.inspect}"
|
|
157
|
+
case @config.on_complex_arguments
|
|
158
|
+
when :raise then raise InvalidArgumentError, message
|
|
159
|
+
when :warn then @config.logger.warn(message)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def invalid_json_value(value)
|
|
164
|
+
case value
|
|
165
|
+
when nil, true, false, String, Integer, Float
|
|
166
|
+
nil
|
|
167
|
+
when Array
|
|
168
|
+
value.each do |nested|
|
|
169
|
+
invalid = invalid_json_value(nested)
|
|
170
|
+
return invalid if invalid
|
|
171
|
+
end
|
|
172
|
+
nil
|
|
173
|
+
when Hash
|
|
174
|
+
value.each do |key, nested|
|
|
175
|
+
return key unless key.is_a?(String)
|
|
176
|
+
|
|
177
|
+
invalid = invalid_json_value(nested)
|
|
178
|
+
return invalid if invalid
|
|
179
|
+
end
|
|
180
|
+
nil
|
|
181
|
+
else
|
|
182
|
+
value
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
data/lib/solid_jobs/recovery.rb
CHANGED
|
@@ -5,12 +5,12 @@ require "socket"
|
|
|
5
5
|
|
|
6
6
|
module SolidJobs
|
|
7
7
|
class Recovery < Service::Base
|
|
8
|
-
|
|
8
|
+
CLAIMED_PATTERN = "#{Keyspace::PREFIX}:node:*:claimed:*".freeze
|
|
9
9
|
RESTORE_ONE = <<~LUA.freeze
|
|
10
10
|
local payload = redis.call("rpop", KEYS[1])
|
|
11
11
|
if payload then
|
|
12
|
-
local
|
|
13
|
-
redis.call("rpush", "
|
|
12
|
+
local envelope = cjson.decode(payload)
|
|
13
|
+
redis.call("rpush", "#{Keyspace::PREFIX}:channel:" .. envelope["channel"], payload)
|
|
14
14
|
return payload
|
|
15
15
|
end
|
|
16
16
|
LUA
|
|
@@ -18,14 +18,15 @@ module SolidJobs
|
|
|
18
18
|
def call
|
|
19
19
|
recovered = 0
|
|
20
20
|
scan_keys.each do |key|
|
|
21
|
-
|
|
21
|
+
node_key = key.split(":claimed:", 2).first
|
|
22
|
+
identity = node_key.delete_prefix("#{Keyspace::PREFIX}:node:")
|
|
22
23
|
next if alive?(identity)
|
|
23
24
|
|
|
24
25
|
while @config.redis_pool.call("EVAL", RESTORE_ONE, 1, key)
|
|
25
26
|
recovered += 1
|
|
26
27
|
end
|
|
27
28
|
@config.redis_pool.call("DEL", key)
|
|
28
|
-
@config.redis_pool.call("DEL",
|
|
29
|
+
@config.redis_pool.call("DEL", Keyspace.claims(identity))
|
|
29
30
|
end
|
|
30
31
|
recovered
|
|
31
32
|
end
|
|
@@ -36,7 +37,7 @@ module SolidJobs
|
|
|
36
37
|
hostname, pid = identity.split(":", 3)
|
|
37
38
|
return false if hostname == Socket.gethostname && !process_alive?(Integer(pid))
|
|
38
39
|
|
|
39
|
-
beat = @config.redis_pool.call("HGET", identity, "beat")
|
|
40
|
+
beat = @config.redis_pool.call("HGET", Keyspace.node(identity), "beat")
|
|
40
41
|
beat && Time.now.to_f - Float(beat) < Heartbeat::TTL
|
|
41
42
|
end
|
|
42
43
|
|
|
@@ -54,7 +55,7 @@ module SolidJobs
|
|
|
54
55
|
keys = []
|
|
55
56
|
loop do
|
|
56
57
|
cursor, found = @config.redis_pool.call(
|
|
57
|
-
"SCAN", cursor, "MATCH",
|
|
58
|
+
"SCAN", cursor, "MATCH", CLAIMED_PATTERN, "COUNT", 100,
|
|
58
59
|
)
|
|
59
60
|
keys.concat(found)
|
|
60
61
|
break if cursor == "0"
|