solid-jobs 0.1.3 → 0.4.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 +73 -4
- data/README.md +149 -118
- data/docs/reliability.md +73 -91
- data/exe/solid-jobs +2 -3
- data/lib/active_job/queue_adapters/solid_jobs_adapter.rb +7 -8
- data/lib/solid_jobs/{config.rb → blueprint.rb} +51 -51
- data/lib/solid_jobs/catalog.rb +427 -0
- data/lib/solid_jobs/claim.rb +315 -0
- data/lib/solid_jobs/{server.rb → conductor.rb} +10 -10
- data/lib/solid_jobs/{cli.rb → console.rb} +12 -9
- data/lib/solid_jobs/{processor.rb → engine.rb} +33 -33
- data/lib/solid_jobs/errors.rb +1 -1
- 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/{testing.rb → lab.rb} +19 -19
- data/lib/solid_jobs/publisher.rb +186 -0
- data/lib/solid_jobs/rails_adapter.rb +13 -0
- data/lib/solid_jobs/{rails.rb → railtie.rb} +1 -2
- data/lib/solid_jobs/recovery.rb +8 -7
- data/lib/solid_jobs/task.rb +147 -0
- data/lib/solid_jobs/{scheduler.rb → timer.rb} +8 -8
- data/lib/solid_jobs/utilities.rb +5 -11
- data/lib/solid_jobs/version.rb +1 -1
- data/lib/solid_jobs.rb +28 -27
- metadata +19 -18
- data/lib/solid_jobs/active_job.rb +0 -16
- data/lib/solid_jobs/api.rb +0 -457
- 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
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SolidJobs
|
|
6
|
+
class FailurePolicy < Service::Base
|
|
7
|
+
|
|
8
|
+
def call
|
|
9
|
+
payload = @payload.dup
|
|
10
|
+
now = Time.now
|
|
11
|
+
now_ms = Utilities.realtime_milliseconds
|
|
12
|
+
failure_count = Integer(payload.fetch("failure_count", 0)) + 1
|
|
13
|
+
payload["exception_message"] = @error.message.to_s
|
|
14
|
+
payload["exception_type"] = @error.class.name
|
|
15
|
+
payload["first_failed_ms"] ||= now_ms
|
|
16
|
+
payload["last_failed_ms"] = now_ms
|
|
17
|
+
payload["failure_count"] = failure_count
|
|
18
|
+
payload["channel"] = payload["retry_channel"] if payload["retry_channel"]
|
|
19
|
+
|
|
20
|
+
if retry_allowed?(payload, failure_count, now)
|
|
21
|
+
decision = retry_decision(payload, failure_count)
|
|
22
|
+
return nil if decision == :drop
|
|
23
|
+
return archive(payload, now.to_f) if decision == :archive
|
|
24
|
+
|
|
25
|
+
next_attempt = now.to_f + (decision || retry_delay(failure_count))
|
|
26
|
+
@config.redis_pool.call("ZADD", Keyspace::RETRIES, next_attempt, JSON.generate(payload))
|
|
27
|
+
append_message("retry scheduled")
|
|
28
|
+
next_attempt
|
|
29
|
+
else
|
|
30
|
+
invoke_final_failure(payload)
|
|
31
|
+
archive(payload, now.to_f)
|
|
32
|
+
append_message("task discarded")
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def retry_allowed?(payload, failure_count, now)
|
|
40
|
+
if payload["retry_within"]
|
|
41
|
+
first_failure = Float(payload["first_failed_ms"]) / 1_000
|
|
42
|
+
now.to_f - first_failure < Float(payload["retry_within"])
|
|
43
|
+
else
|
|
44
|
+
maximum = Integer(payload.fetch("max_failures", 0))
|
|
45
|
+
failure_count <= maximum
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def retry_delay(failure_count)
|
|
50
|
+
ceiling = [
|
|
51
|
+
Float(@config.retry_base_delay) * (2**(failure_count - 1)),
|
|
52
|
+
Float(@config.retry_max_delay),
|
|
53
|
+
].min
|
|
54
|
+
(ceiling / 2) + (rand * ceiling / 2)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def retry_decision(payload, failure_count)
|
|
58
|
+
task = resolve_task(payload)
|
|
59
|
+
return unless task.respond_to?(:retry_delay_callback)
|
|
60
|
+
|
|
61
|
+
value = task.retry_delay_callback(failure_count, @error, payload)
|
|
62
|
+
return value if value.is_a?(Numeric) || %i[drop archive].include?(value)
|
|
63
|
+
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def invoke_final_failure(payload)
|
|
68
|
+
task = resolve_task(payload)
|
|
69
|
+
return unless task.respond_to?(:final_failure_callback)
|
|
70
|
+
|
|
71
|
+
task.final_failure_callback(payload, @error)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def resolve_task(payload)
|
|
75
|
+
Utilities.constantize(payload.fetch("task"))
|
|
76
|
+
rescue NameError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def archive(payload, timestamp)
|
|
81
|
+
@config.redis_pool.pipelined do |pipeline|
|
|
82
|
+
pipeline.call("ZADD", Keyspace::DISCARDED, timestamp, JSON.generate(payload))
|
|
83
|
+
pipeline.call(
|
|
84
|
+
"ZREMRANGEBYSCORE",
|
|
85
|
+
Keyspace::DISCARDED,
|
|
86
|
+
"-inf",
|
|
87
|
+
timestamp - @config.discarded_retention,
|
|
88
|
+
)
|
|
89
|
+
pipeline.call(
|
|
90
|
+
"ZREMRANGEBYRANK",
|
|
91
|
+
Keyspace::DISCARDED,
|
|
92
|
+
0,
|
|
93
|
+
-(@config.discarded_limit + 1),
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
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
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
3
5
|
module SolidJobs
|
|
4
|
-
module
|
|
5
|
-
MODES = %i[disable
|
|
6
|
-
STORAGE_KEY = :
|
|
6
|
+
module Lab
|
|
7
|
+
MODES = %i[disable capture execute].freeze
|
|
8
|
+
STORAGE_KEY = :solid_jobs_testing_captured
|
|
7
9
|
MODE_KEY = :solid_jobs_testing_mode
|
|
8
10
|
|
|
9
11
|
module_function
|
|
@@ -29,14 +31,14 @@ module SolidJobs
|
|
|
29
31
|
end
|
|
30
32
|
end
|
|
31
33
|
|
|
32
|
-
def
|
|
33
|
-
testing!(:
|
|
34
|
-
testing!(:
|
|
34
|
+
def capture!
|
|
35
|
+
testing!(:capture) { yield } if block_given?
|
|
36
|
+
testing!(:capture) unless block_given?
|
|
35
37
|
end
|
|
36
38
|
|
|
37
|
-
def
|
|
38
|
-
testing!(:
|
|
39
|
-
testing!(:
|
|
39
|
+
def execute!
|
|
40
|
+
testing!(:execute) { yield } if block_given?
|
|
41
|
+
testing!(:execute) unless block_given?
|
|
40
42
|
end
|
|
41
43
|
|
|
42
44
|
def disable!
|
|
@@ -44,22 +46,20 @@ module SolidJobs
|
|
|
44
46
|
testing!(:disable) unless block_given?
|
|
45
47
|
end
|
|
46
48
|
|
|
47
|
-
def
|
|
48
|
-
storage[
|
|
49
|
+
def captured_for(task)
|
|
50
|
+
storage[task.name] ||= []
|
|
49
51
|
end
|
|
50
52
|
|
|
51
53
|
def clear_all
|
|
52
54
|
storage.clear
|
|
53
55
|
end
|
|
54
56
|
|
|
55
|
-
def
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
SolidJobs.config.
|
|
61
|
-
instance.perform(*payload.fetch("args"))
|
|
62
|
-
end
|
|
57
|
+
def execute(envelope)
|
|
58
|
+
normalized = Utilities.stringify_hash_keys(envelope)
|
|
59
|
+
normalized["task"] = normalized.fetch("task").name if normalized["task"].is_a?(Class)
|
|
60
|
+
normalized["channel"] ||= "default"
|
|
61
|
+
normalized["id"] ||= SecureRandom.uuid
|
|
62
|
+
Executor.new(redis_config: SolidJobs.config.redis_config).execute(normalized)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def storage
|
|
@@ -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) ? Blueprint.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?(Lab)
|
|
92
|
+
case Lab.mode
|
|
93
|
+
when :capture
|
|
94
|
+
envelopes.each do |envelope|
|
|
95
|
+
Lab.captured_for(Utilities.constantize(envelope.fetch("task"))) << envelope
|
|
96
|
+
end
|
|
97
|
+
return true
|
|
98
|
+
when :execute
|
|
99
|
+
envelopes.each { |envelope| Lab.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
|