joblin 0.1.11 → 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/app/models/joblin/background_task/api_access.rb +15 -2
- data/app/models/joblin/background_task/attachments.rb +2 -2
- data/app/models/joblin/background_task/retention_policy.rb +12 -4
- data/app/models/joblin/background_task.rb +8 -1
- data/db/migrate/20260709120000_add_indexes_to_joblin_background_tasks.rb +10 -0
- data/lib/joblin/batching/batch.rb +60 -33
- data/lib/joblin/batching/callback.rb +9 -1
- data/lib/joblin/batching/chain_builder.rb +3 -2
- data/lib/joblin/batching/compat/sidekiq/web/views/_batches_table.erb +2 -2
- data/lib/joblin/batching/compat/sidekiq/web/views/_jobs_table.erb +3 -3
- data/lib/joblin/batching/compat/sidekiq/web/views/batch.erb +5 -5
- data/lib/joblin/batching/compat/sidekiq/web/views/pool.erb +13 -13
- data/lib/joblin/batching/compat/sidekiq/web/views/pools.erb +3 -3
- data/lib/joblin/batching/compat/sidekiq/web.rb +17 -1
- data/lib/joblin/batching/context_hash.rb +1 -1
- data/lib/joblin/batching/jobs/managed_batch_job.rb +8 -2
- data/lib/joblin/batching/pool.rb +12 -3
- data/lib/joblin/batching/pool_refill.lua +2 -1
- data/lib/joblin/batching/schedule_callback.lua +6 -0
- data/lib/joblin/lazy_access.rb +108 -19
- data/lib/joblin/uniqueness/compat/active_job.rb +1 -1
- data/lib/joblin/uniqueness/job_uniqueness.rb +2 -0
- data/lib/joblin/uniqueness/lock_context.rb +4 -0
- data/lib/joblin/uniqueness/on_conflict/log.rb +1 -0
- data/lib/joblin/uniqueness/on_conflict/reject.rb +8 -1
- data/lib/joblin/uniqueness/strategy/until_expired.rb +1 -0
- data/lib/joblin/uniqueness/unique_job_common.rb +15 -7
- data/lib/joblin/version.rb +1 -1
- data/spec/internal/log/test.log +6619 -0
- data/spec/lazy_access_spec.rb +76 -0
- data/spec/models/api_access_spec.rb +57 -0
- data/spec/uniqueness/on_conflict/reject_spec.rb +42 -0
- data/spec/uniqueness/strategy/until_expired_spec.rb +7 -0
- metadata +8 -1
data/lib/joblin/lazy_access.rb
CHANGED
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
module Joblin
|
|
2
|
+
# Serialization coder (used by `serialize ..., coder: Joblin::LazyAccess`) that
|
|
3
|
+
# lazily rehydrates ActiveRecord references stored as GlobalIDs.
|
|
4
|
+
#
|
|
5
|
+
# Records are NOT auto-located from any string that happens to start with
|
|
6
|
+
# "gid://". Instead, `dump` records which keys held records in a reserved
|
|
7
|
+
# `_joblin_` envelope, and `load` only resolves those keys — and only when the
|
|
8
|
+
# key is actually read. This closes the object-injection/data-loss hole where
|
|
9
|
+
# an attacker-supplied "gid://..." string was located into an arbitrary record.
|
|
10
|
+
#
|
|
11
|
+
# Envelope shapes:
|
|
12
|
+
# Hash with record values -> { ...values (records as gid strings)...,
|
|
13
|
+
# "_joblin_" => { "gid_keys" => [<key>, ...] } }
|
|
14
|
+
# A standalone record (e.g. an Array element) -> { "_joblin_" => { "gid" => "gid://..." } }
|
|
2
15
|
module LazyAccess
|
|
3
16
|
extend ActiveSupport::Concern
|
|
4
17
|
|
|
18
|
+
MARKER = "_joblin_".freeze
|
|
19
|
+
|
|
5
20
|
def initialize(...)
|
|
6
21
|
super
|
|
7
22
|
@access_cache = {}
|
|
8
23
|
end
|
|
9
24
|
|
|
25
|
+
# For a loaded LazyAccessHash: the set of keys whose values are GlobalIDs.
|
|
26
|
+
# Tracked out-of-band so the reserved marker isn't exposed via enumeration.
|
|
27
|
+
attr_accessor :lazy_gid_keys
|
|
28
|
+
|
|
10
29
|
def self.raw
|
|
11
30
|
current = Thread.current[:lazy_access_read_raw]
|
|
12
31
|
Thread.current[:lazy_access_read_raw] = true
|
|
@@ -15,44 +34,104 @@ module Joblin
|
|
|
15
34
|
Thread.current[:lazy_access_read_raw] = current
|
|
16
35
|
end
|
|
17
36
|
|
|
37
|
+
# ---- Serialization coder interface ----
|
|
38
|
+
|
|
18
39
|
def self.load(val)
|
|
19
40
|
case val
|
|
20
|
-
when String
|
|
21
|
-
if val.start_with?("gid://")
|
|
22
|
-
val = GlobalID::Locator.locate(val)
|
|
23
|
-
end
|
|
24
41
|
when Hash
|
|
25
|
-
|
|
42
|
+
marker = marker_of(val)
|
|
43
|
+
|
|
44
|
+
# A per-object envelope resolves to its single record reference.
|
|
45
|
+
return locate(marker["gid"]) if marker && marker.key?("gid")
|
|
46
|
+
|
|
47
|
+
gid_keys = marker ? Array(marker["gid_keys"]).map(&:to_s) : []
|
|
48
|
+
data = val.reject { |k, _| k.to_s == MARKER }
|
|
49
|
+
LazyAccessHash.new(data).tap { |h| h.lazy_gid_keys = gid_keys }
|
|
26
50
|
when Array
|
|
27
|
-
|
|
51
|
+
LazyAccessArray.new(val)
|
|
52
|
+
else
|
|
53
|
+
val
|
|
28
54
|
end
|
|
29
|
-
val
|
|
30
55
|
end
|
|
31
56
|
|
|
32
57
|
def self.dump(val)
|
|
33
58
|
case val
|
|
34
59
|
when Array
|
|
35
|
-
val.to_a.map {|x| dump(x) }
|
|
60
|
+
val.to_a.map { |x| dump(x) }
|
|
36
61
|
when Hash
|
|
37
|
-
val
|
|
62
|
+
dump_hash(val)
|
|
38
63
|
when ActiveRecord::Base
|
|
39
|
-
|
|
64
|
+
# A record outside a hash-key context (Array element / standalone):
|
|
65
|
+
# wrap it so load can resolve it back.
|
|
66
|
+
{ MARKER => { "gid" => val.to_gid.to_s } }
|
|
40
67
|
else
|
|
41
68
|
val
|
|
42
69
|
end
|
|
43
70
|
end
|
|
44
71
|
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
72
|
+
def self.dump_hash(hash)
|
|
73
|
+
# Keys known to hold GlobalIDs even when their value is still a raw gid
|
|
74
|
+
# string (loaded but never accessed): a LazyAccessHash carries them on an
|
|
75
|
+
# ivar; a raw storage hash carries them in its embedded marker.
|
|
76
|
+
carried =
|
|
77
|
+
if hash.respond_to?(:lazy_gid_keys) && hash.lazy_gid_keys
|
|
78
|
+
Array(hash.lazy_gid_keys).map(&:to_s)
|
|
79
|
+
elsif (m = marker_of(hash))
|
|
80
|
+
Array(m["gid_keys"]).map(&:to_s)
|
|
81
|
+
else
|
|
82
|
+
[]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
gid_keys = []
|
|
86
|
+
out = {}
|
|
87
|
+
hash.to_h.each do |k, v|
|
|
88
|
+
ks = k.to_s
|
|
89
|
+
next if ks == MARKER
|
|
90
|
+
|
|
91
|
+
if v.is_a?(ActiveRecord::Base)
|
|
92
|
+
out[ks] = v.to_gid.to_s
|
|
93
|
+
gid_keys << ks
|
|
94
|
+
elsif carried.include?(ks) && v.is_a?(String)
|
|
95
|
+
out[ks] = v
|
|
96
|
+
gid_keys << ks
|
|
97
|
+
else
|
|
98
|
+
out[ks] = dump(v)
|
|
53
99
|
end
|
|
54
|
-
@access_cache[key]
|
|
55
100
|
end
|
|
101
|
+
out[MARKER] = { "gid_keys" => gid_keys } unless gid_keys.empty?
|
|
102
|
+
out
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.locate(gid_string)
|
|
106
|
+
return gid_string unless gid_string.is_a?(String)
|
|
107
|
+
GlobalID::Locator.locate(gid_string)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Read the reserved marker hash regardless of string/symbol keying.
|
|
111
|
+
def self.marker_of(hash)
|
|
112
|
+
m = hash[MARKER]
|
|
113
|
+
m = hash[MARKER.to_sym] if m.nil?
|
|
114
|
+
m.is_a?(Hash) ? m.with_indifferent_access : nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# ---- Lazy element access ----
|
|
118
|
+
|
|
119
|
+
def [](*args)
|
|
120
|
+
# Only the single-key form is lazily resolved/cached. Multi-arg or Range
|
|
121
|
+
# access (e.g. Array#[start, length] / arr[1..3]) delegates to super.
|
|
122
|
+
if Thread.current[:lazy_access_read_raw] || args.length != 1 || args.first.is_a?(Range)
|
|
123
|
+
return super
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
key = args.first
|
|
127
|
+
key = key.to_s if key.is_a?(Symbol)
|
|
128
|
+
return nil if key == MARKER
|
|
129
|
+
|
|
130
|
+
unless @access_cache.key?(key)
|
|
131
|
+
val = super
|
|
132
|
+
@access_cache[key] = _resolve_lazy_value(key, val)
|
|
133
|
+
end
|
|
134
|
+
@access_cache[key]
|
|
56
135
|
end
|
|
57
136
|
|
|
58
137
|
def []=(key, value)
|
|
@@ -60,6 +139,16 @@ module Joblin
|
|
|
60
139
|
@access_cache[key] = value
|
|
61
140
|
super
|
|
62
141
|
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def _resolve_lazy_value(key, val)
|
|
146
|
+
if lazy_gid_keys && lazy_gid_keys.include?(key.to_s)
|
|
147
|
+
LazyAccess.locate(val)
|
|
148
|
+
else
|
|
149
|
+
LazyAccess.load(val)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
63
152
|
end
|
|
64
153
|
|
|
65
154
|
class LazyAccessHash < HashWithIndifferentAccess
|
|
@@ -61,6 +61,10 @@ module Joblin::Uniqueness
|
|
|
61
61
|
lock_strategy.send(:"on_#{stage}", *args, **kwargs, &blk)
|
|
62
62
|
rescue CouldNotLockError => e
|
|
63
63
|
call_conflict_strategy(stage)
|
|
64
|
+
# A conflict means the original job must NOT be enqueued/run. Sidekiq's
|
|
65
|
+
# client treats a truthy middleware return as the payload to push, so we
|
|
66
|
+
# must return nil here regardless of what the conflict strategy returned.
|
|
67
|
+
nil
|
|
64
68
|
end
|
|
65
69
|
|
|
66
70
|
def lock_strategy
|
|
@@ -10,8 +10,15 @@ module Joblin::Uniqueness
|
|
|
10
10
|
return
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
+
# Sidekiq::DeadSet lives in sidekiq/api, which isn't loaded server-side
|
|
14
|
+
# by default; require it so we don't blow up with NameError here.
|
|
15
|
+
require "sidekiq/api"
|
|
16
|
+
|
|
13
17
|
kwargs = {}
|
|
14
|
-
|
|
18
|
+
# Sidekiq's DeadSet#kill is `kill(message, opts = {})` (arity -2) on 7/8
|
|
19
|
+
# and `kill(message)` (arity 1) on very old versions. Pass notify_failure
|
|
20
|
+
# only when the method actually accepts options.
|
|
21
|
+
kwargs[:notify_failure] = false if Sidekiq::DeadSet.instance_method(:kill).arity != 1
|
|
15
22
|
|
|
16
23
|
sidekiq_message = lock_context.instance_variable_get(:@job_instance)
|
|
17
24
|
Sidekiq::DeadSet.new.kill(JSON.dump(sidekiq_message), **kwargs)
|
|
@@ -11,8 +11,8 @@ module Joblin::Uniqueness
|
|
|
11
11
|
# ensure_uniqueness(
|
|
12
12
|
# strategy: :until_executed, # :until_executed, :until_executing, :until_expired, :until_and_while_executing, :while_executing
|
|
13
13
|
# on_conflict: :raise, # :raise, :log, :reject, :reschedule, { enqueue: ..., perform: ... }, proc
|
|
14
|
-
#
|
|
15
|
-
#
|
|
14
|
+
# ttl: 7.days, # seconds
|
|
15
|
+
# timeout: 0, # seconds
|
|
16
16
|
|
|
17
17
|
# scope: :per_queue, # :global, :per_queue, string ("<class>-<queue>"), proc
|
|
18
18
|
# hash: ->{ { ... } },
|
|
@@ -41,6 +41,7 @@ module Joblin::Uniqueness
|
|
|
41
41
|
kwargs[:ttl] ||= 30.days.to_i
|
|
42
42
|
kwargs[:timeout] ||= 0
|
|
43
43
|
kwargs[:limit] ||= 1
|
|
44
|
+
kwargs[:unlock_on_failure] ||= :stagnant
|
|
44
45
|
|
|
45
46
|
self.unique_job_options = kwargs
|
|
46
47
|
|
|
@@ -53,18 +54,25 @@ module Joblin::Uniqueness
|
|
|
53
54
|
|
|
54
55
|
class_methods do
|
|
55
56
|
def unlock!(jid = nil, args: nil, kwargs: nil, queue: nil)
|
|
56
|
-
|
|
57
|
+
# Only fall back to the class default when the caller didn't pass a queue.
|
|
58
|
+
queue ||= self.try(:default_queue_name) || try(:queue)
|
|
57
59
|
raise ArgumentError, "Must specify queue:" unless queue.is_a?(String) || queue.is_a?(Symbol)
|
|
58
60
|
|
|
59
61
|
temp_context = LockContext.new({ job_clazz: self, jid: jid, queue: queue, args: args, kwargs: kwargs })
|
|
60
62
|
strategy = temp_context.lock_strategy
|
|
61
|
-
|
|
63
|
+
|
|
64
|
+
# until_and_while_executing keeps a second (":RUN") lock via its runtime
|
|
65
|
+
# strategy; clear both so those locks aren't stranded.
|
|
66
|
+
locksmiths = [strategy.send(:locksmith)]
|
|
67
|
+
if strategy.respond_to?(:runtime_lock, true)
|
|
68
|
+
locksmiths << strategy.send(:runtime_lock).send(:locksmith)
|
|
69
|
+
end
|
|
62
70
|
|
|
63
71
|
if jid
|
|
64
|
-
|
|
72
|
+
locksmiths.each(&:unlock!)
|
|
65
73
|
else
|
|
66
|
-
|
|
67
|
-
unlock!(
|
|
74
|
+
locksmiths.flat_map(&:locked_jids).uniq.each do |locked_jid|
|
|
75
|
+
unlock!(locked_jid, args: args, kwargs: kwargs, queue: queue)
|
|
68
76
|
end
|
|
69
77
|
end
|
|
70
78
|
end
|
data/lib/joblin/version.rb
CHANGED