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.
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module SolidJobs
6
+ module PublicationBarrier
7
+ TIMEOUT_MS = 30_000
8
+ FIELD = "_solid_jobs_publication"
9
+ Entry = Data.define(:key, :publication, :owner)
10
+ RELEASE = <<~LUA.freeze
11
+ local released = 0
12
+ for index, key in ipairs(KEYS) do
13
+ if redis.call("get", key) == ARGV[index] then
14
+ released = released + redis.call("del", key)
15
+ end
16
+ end
17
+ return released
18
+ LUA
19
+
20
+ module_function
21
+
22
+ def prepare(publication: SecureRandom.hex(16), owner: SecureRandom.hex(16))
23
+ Entry.new(Keyspace.publication(publication), publication, owner)
24
+ end
25
+
26
+ def mark(target, entry)
27
+ target.call("SET", entry.key, entry.owner, "PX", TIMEOUT_MS)
28
+ end
29
+
30
+ def release(redis_pool, entries)
31
+ entries = Array(entries)
32
+ return 0 if entries.empty?
33
+
34
+ redis_pool.call(
35
+ "EVAL", RELEASE, entries.size,
36
+ *entries.map(&:key),
37
+ *entries.map(&:owner),
38
+ )
39
+ end
40
+
41
+ def strip(envelope)
42
+ envelope.delete(FIELD)
43
+ envelope
44
+ end
45
+ end
46
+ end
@@ -28,7 +28,8 @@ module SolidJobs
28
28
  return unless normalized
29
29
 
30
30
  validate_arguments!(normalized.fetch("arguments"))
31
- persist([normalized])
31
+ barriers = persist([normalized])
32
+ emit_persisted([normalized], barriers)
32
33
  normalized.fetch("id")
33
34
  end
34
35
 
@@ -80,13 +81,14 @@ module SolidJobs
80
81
  end
81
82
  intercepted
82
83
  end
83
- persist(envelopes)
84
+ barriers = persist(envelopes)
85
+ emit_persisted(envelopes, barriers)
84
86
  end
85
87
  ids
86
88
  end
87
89
 
88
90
  def persist(envelopes)
89
- return true if envelopes.empty?
91
+ return [] if envelopes.empty?
90
92
 
91
93
  if defined?(Lab)
92
94
  case Lab.mode
@@ -94,26 +96,35 @@ module SolidJobs
94
96
  envelopes.each do |envelope|
95
97
  Lab.captured_for(Utilities.constantize(envelope.fetch("task"))) << envelope
96
98
  end
97
- return true
99
+ return []
98
100
  when :execute
99
101
  envelopes.each { |envelope| Lab.execute(envelope) }
100
- return true
102
+ return []
101
103
  end
102
104
  end
103
105
 
106
+ planned, ready = envelopes.partition { |envelope| envelope["run_at"] }
107
+ barriers = []
104
108
  @config.redis_pool.pipelined do |pipeline|
105
- planned, ready = envelopes.partition { |envelope| envelope["run_at"] }
106
109
  planned.each do |envelope|
107
110
  score = envelope.delete("run_at")
108
111
  pipeline.call("ZADD", Keyspace::PLANNED, score, JSON.generate(envelope))
109
112
  end
110
- persist_ready(pipeline, ready)
113
+ barriers = persist_ready(pipeline, ready)
111
114
  end
112
- true
115
+ barriers
113
116
  end
114
117
 
115
118
  private
116
119
 
120
+ def emit_persisted(envelopes, barriers)
121
+ envelopes.each do |envelope|
122
+ Instrumentation.emit(@config, :enqueued, envelope)
123
+ Instrumentation.emit(@config, :journaled, envelope)
124
+ end
125
+ release_publications(barriers)
126
+ end
127
+
117
128
  def normalize(envelope)
118
129
  raise InvalidJobError, "Task envelope must be a Hash" unless envelope.is_a?(Hash)
119
130
 
@@ -138,15 +149,33 @@ module SolidJobs
138
149
  end
139
150
 
140
151
  def persist_ready(pipeline, envelopes)
152
+ barriers = []
141
153
  envelopes.group_by { |envelope| envelope.fetch("channel") }.each do |channel, group|
142
154
  pipeline.call("SADD", Keyspace::CHANNELS, channel)
143
155
  queued_ms = Utilities.realtime_milliseconds
144
156
  encoded = group.map do |envelope|
145
157
  envelope["queued_ms"] = queued_ms
158
+ envelope[PublicationBarrier::FIELD] = SecureRandom.hex(16)
146
159
  JSON.generate(envelope)
147
160
  end
161
+ group.zip(encoded).each do |envelope, _payload|
162
+ barrier = PublicationBarrier.prepare(
163
+ publication: envelope.fetch(PublicationBarrier::FIELD),
164
+ )
165
+ PublicationBarrier.mark(pipeline, barrier)
166
+ barriers << barrier
167
+ end
148
168
  pipeline.call("LPUSH", Keyspace.channel(channel), *encoded)
149
169
  end
170
+ barriers
171
+ end
172
+
173
+ def release_publications(barriers)
174
+ PublicationBarrier.release(@config.redis_pool, barriers)
175
+ rescue SolidRedis::ConnectionError, SolidRedis::CommandError => error
176
+ @config.logger.warn(
177
+ "SolidJobs publication barrier release failed; waiting for expiry: #{error.class}: #{error.message}",
178
+ )
150
179
  end
151
180
 
152
181
  def validate_arguments!(arguments)
@@ -10,8 +10,21 @@ module SolidJobs
10
10
  local payload = redis.call("rpop", KEYS[1])
11
11
  if payload then
12
12
  local envelope = cjson.decode(payload)
13
+ local publication = envelope["#{PublicationBarrier::FIELD}"]
14
+ if not publication then
15
+ publication = ARGV[3]
16
+ envelope["#{PublicationBarrier::FIELD}"] = publication
17
+ payload = cjson.encode(envelope)
18
+ end
19
+ redis.call(
20
+ "set",
21
+ ARGV[1] .. publication,
22
+ ARGV[4],
23
+ "PX",
24
+ ARGV[2]
25
+ )
13
26
  redis.call("rpush", "#{Keyspace::PREFIX}:channel:" .. envelope["channel"], payload)
14
- return payload
27
+ return {payload, publication, ARGV[4]}
15
28
  end
16
29
  LUA
17
30
 
@@ -22,8 +35,15 @@ module SolidJobs
22
35
  identity = node_key.delete_prefix("#{Keyspace::PREFIX}:node:")
23
36
  next if alive?(identity)
24
37
 
25
- while @config.redis_pool.call("EVAL", RESTORE_ONE, 1, key)
38
+ while (restored = @config.redis_pool.call(
39
+ "EVAL", RESTORE_ONE, 1, key,
40
+ Keyspace::PUBLICATION_PREFIX, PublicationBarrier::TIMEOUT_MS,
41
+ SecureRandom.hex(16), SecureRandom.hex(16),
42
+ ))
43
+ payload, publication, owner = restored
26
44
  recovered += 1
45
+ Instrumentation.emit(@config, :recovered, payload)
46
+ release_publication(publication, owner)
27
47
  end
28
48
  @config.redis_pool.call("DEL", key)
29
49
  @config.redis_pool.call("DEL", Keyspace.claims(identity))
@@ -33,6 +53,15 @@ module SolidJobs
33
53
 
34
54
  private
35
55
 
56
+ def release_publication(publication, owner)
57
+ entry = PublicationBarrier.prepare(publication: publication, owner: owner)
58
+ PublicationBarrier.release(@config.redis_pool, [entry])
59
+ rescue SolidRedis::ConnectionError, SolidRedis::CommandError => error
60
+ @config.logger.warn(
61
+ "SolidJobs recovery barrier release failed; waiting for expiry: #{error.class}: #{error.message}",
62
+ )
63
+ end
64
+
36
65
  def alive?(identity)
37
66
  hostname, pid = identity.split(":", 3)
38
67
  return false if hostname == Socket.gethostname && !process_alive?(Integer(pid))
@@ -1,9 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module SolidJobs
4
6
  module Task
7
+ # Intercepts `enqueue` and reroutes non Ractor-safe tasks to the warm pool.
8
+ module WarmPoolRouting
9
+ NON_SHAREABLE_TYPES = [Mutex, Thread, Fiber].freeze
10
+
11
+ def enqueue(*arguments)
12
+ return super unless requires_warm_pool?(arguments)
13
+
14
+ require_relative "../../app/jobs/solid_jobs_generic_executor"
15
+ SolidJobsGenericExecutor.enqueue(
16
+ SolidJobsGenericExecutor.worker_id_for(name),
17
+ name,
18
+ Ractor.make_shareable(JSON.parse(JSON.generate(arguments))),
19
+ )
20
+ end
21
+
22
+ def requires_warm_pool?(args)
23
+ return false if equal?(defined?(::SolidJobsGenericExecutor) && ::SolidJobsGenericExecutor)
24
+ return false unless defined?(::Rails)
25
+
26
+ !args.all? { |argument| Ractor.shareable?(argument) } && holds_blocking_state?
27
+ end
28
+
29
+ private
30
+
31
+ def holds_blocking_state?
32
+ instance_variables.any? do |ivar|
33
+ value = instance_variable_get(ivar)
34
+ NON_SHAREABLE_TYPES.any? { |type| value.is_a?(type) } || value.class.name.to_s.include?("Pool")
35
+ end
36
+ end
37
+ end
38
+
5
39
  def self.included(base)
6
40
  base.extend(TaskMethods)
41
+ base.singleton_class.prepend(WarmPoolRouting)
7
42
  base.include(InstanceMethods)
8
43
  base.task_options
9
44
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidJobs
4
- VERSION = "0.4.2"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/solid_jobs.rb CHANGED
@@ -11,6 +11,8 @@ require_relative "solid_jobs/keyspace"
11
11
  require_relative "solid_jobs/ractor_support"
12
12
  require_relative "solid_jobs/startup_barrier"
13
13
  require_relative "solid_jobs/interceptor_registry"
14
+ require_relative "solid_jobs/instrumentation"
15
+ require_relative "solid_jobs/publication_barrier"
14
16
  require_relative "solid_jobs/blueprint"
15
17
  require_relative "solid_jobs/publisher"
16
18
  require_relative "solid_jobs/lab"
@@ -20,6 +22,7 @@ require_relative "solid_jobs/claim"
20
22
  require_relative "solid_jobs/failure_policy"
21
23
  require_relative "solid_jobs/timer"
22
24
  require_relative "solid_jobs/engine"
25
+ require_relative "solid_jobs/observations"
23
26
  require_relative "solid_jobs/heartbeat"
24
27
  require_relative "solid_jobs/recovery"
25
28
  require_relative "solid_jobs/integrity_check"
@@ -44,6 +47,14 @@ module SolidJobs
44
47
  config
45
48
  end
46
49
 
50
+ def instrumenter
51
+ config.instrumenter
52
+ end
53
+
54
+ def instrumenter=(value)
55
+ config.instrumenter = value
56
+ end
57
+
47
58
  def use_config(configuration)
48
59
  Ractor.current[CONFIG_KEY]&.close
49
60
  Ractor.current[CONFIG_KEY] = configuration
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid-jobs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicolas Vandenbogaerde
@@ -129,10 +129,13 @@ files:
129
129
  - lib/solid_jobs/executor.rb
130
130
  - lib/solid_jobs/failure_policy.rb
131
131
  - lib/solid_jobs/heartbeat.rb
132
+ - lib/solid_jobs/instrumentation.rb
132
133
  - lib/solid_jobs/integrity_check.rb
133
134
  - lib/solid_jobs/interceptor_registry.rb
134
135
  - lib/solid_jobs/keyspace.rb
135
136
  - lib/solid_jobs/lab.rb
137
+ - lib/solid_jobs/observations.rb
138
+ - lib/solid_jobs/publication_barrier.rb
136
139
  - lib/solid_jobs/publisher.rb
137
140
  - lib/solid_jobs/ractor_support.rb
138
141
  - lib/solid_jobs/rails_adapter.rb