joblin 0.1.12 → 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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/app/models/joblin/background_task/api_access.rb +15 -2
  3. data/app/models/joblin/background_task/attachments.rb +2 -2
  4. data/app/models/joblin/background_task/retention_policy.rb +12 -4
  5. data/app/models/joblin/background_task.rb +8 -1
  6. data/db/migrate/20260709120000_add_indexes_to_joblin_background_tasks.rb +10 -0
  7. data/lib/joblin/batching/batch.rb +60 -33
  8. data/lib/joblin/batching/callback.rb +9 -1
  9. data/lib/joblin/batching/chain_builder.rb +3 -2
  10. data/lib/joblin/batching/compat/sidekiq/web/views/_batches_table.erb +2 -2
  11. data/lib/joblin/batching/compat/sidekiq/web/views/_jobs_table.erb +3 -3
  12. data/lib/joblin/batching/compat/sidekiq/web/views/batch.erb +5 -5
  13. data/lib/joblin/batching/compat/sidekiq/web/views/pool.erb +13 -13
  14. data/lib/joblin/batching/compat/sidekiq/web/views/pools.erb +3 -3
  15. data/lib/joblin/batching/compat/sidekiq/web.rb +5 -1
  16. data/lib/joblin/batching/context_hash.rb +1 -1
  17. data/lib/joblin/batching/jobs/managed_batch_job.rb +8 -2
  18. data/lib/joblin/batching/pool.rb +12 -3
  19. data/lib/joblin/batching/pool_refill.lua +2 -1
  20. data/lib/joblin/batching/schedule_callback.lua +6 -0
  21. data/lib/joblin/lazy_access.rb +108 -19
  22. data/lib/joblin/uniqueness/compat/active_job.rb +1 -1
  23. data/lib/joblin/uniqueness/job_uniqueness.rb +2 -0
  24. data/lib/joblin/uniqueness/lock_context.rb +4 -0
  25. data/lib/joblin/uniqueness/on_conflict/log.rb +1 -0
  26. data/lib/joblin/uniqueness/on_conflict/reject.rb +8 -1
  27. data/lib/joblin/uniqueness/strategy/until_expired.rb +1 -0
  28. data/lib/joblin/uniqueness/unique_job_common.rb +15 -7
  29. data/lib/joblin/version.rb +1 -1
  30. data/spec/internal/log/test.log +6550 -0
  31. data/spec/lazy_access_spec.rb +76 -0
  32. data/spec/models/api_access_spec.rb +57 -0
  33. data/spec/uniqueness/on_conflict/reject_spec.rb +42 -0
  34. data/spec/uniqueness/strategy/until_expired_spec.rb +7 -0
  35. metadata +8 -1
@@ -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
- val = LazyAccessHash.new(val)
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
- val = LazyAccessArray.new(val)
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.to_h.transform_values{|x| dump(x) }
62
+ dump_hash(val)
38
63
  when ActiveRecord::Base
39
- val.to_gid
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 [](key)
46
- if Thread.current[:lazy_access_read_raw]
47
- super
48
- else
49
- key = key.to_s if key.is_a?(Symbol)
50
- unless @access_cache.key?(key)
51
- val = super
52
- @access_cache[key] = LazyAccess.load(val)
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
@@ -12,7 +12,7 @@ module Joblin::Uniqueness
12
12
  job_class.set(
13
13
  queue: job_queue.to_sym,
14
14
  wait: schedule_in,
15
- priortity: job_instance.priority,
15
+ priority: job_instance.priority,
16
16
  ).perform_later(*job_instance.arguments)
17
17
  end
18
18
  end
@@ -33,6 +33,8 @@ module Joblin::Uniqueness
33
33
  end
34
34
  end
35
35
 
36
+ class StrategyNotFound < StandardError; end
37
+
36
38
  class << self
37
39
  def configure
38
40
  yield config
@@ -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
@@ -7,6 +7,7 @@ module Joblin::Uniqueness
7
7
  Joblin::Uniqueness.logger.info(<<~MESSAGE.chomp)
8
8
  Skipping job with id (#{lock_context.job_id}) because key: (#{lock_context.base_key}) is locked
9
9
  MESSAGE
10
+ nil
10
11
  end
11
12
  end
12
13
  end
@@ -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
- kwargs[:notify_failure] = false if Sidekiq::DeadSet.instance_method(:kill).arity > 1
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)
@@ -10,6 +10,7 @@ module Joblin::Uniqueness
10
10
 
11
11
  def on_perform
12
12
  lock!(:perform)
13
+ yield
13
14
  end
14
15
  end
15
16
  end
@@ -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
- # lock_ttl: 7.days, # seconds
15
- # lock_timeout: 0, # seconds
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
- queue = self.try(:default_queue_name) || try(:queue)
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
- locksmith = strategy.send(:locksmith)
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
- locksmith.unlock!()
72
+ locksmiths.each(&:unlock!)
65
73
  else
66
- locksmith.locked_jids.each do |jid|
67
- unlock!(jid, args: args, kwargs: kwargs, queue: queue)
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
@@ -1,3 +1,3 @@
1
1
  module Joblin
2
- VERSION = "0.1.12".freeze
2
+ VERSION = "0.2.0".freeze
3
3
  end