kaal 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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +340 -0
  4. data/Rakefile +6 -0
  5. data/app/models/kaal/cron_definition.rb +71 -0
  6. data/app/models/kaal/cron_dispatch.rb +50 -0
  7. data/app/models/kaal/cron_lock.rb +38 -0
  8. data/config/locales/en.yml +46 -0
  9. data/lib/generators/kaal/install/install_generator.rb +67 -0
  10. data/lib/generators/kaal/install/templates/create_kaal_definitions.rb.tt +21 -0
  11. data/lib/generators/kaal/install/templates/create_kaal_dispatches.rb.tt +20 -0
  12. data/lib/generators/kaal/install/templates/create_kaal_locks.rb.tt +17 -0
  13. data/lib/generators/kaal/install/templates/kaal.rb.tt +31 -0
  14. data/lib/generators/kaal/install/templates/scheduler.yml.tt +22 -0
  15. data/lib/kaal/backend/adapter.rb +147 -0
  16. data/lib/kaal/backend/dispatch_logging.rb +79 -0
  17. data/lib/kaal/backend/memory_adapter.rb +99 -0
  18. data/lib/kaal/backend/mysql_adapter.rb +170 -0
  19. data/lib/kaal/backend/postgres_adapter.rb +134 -0
  20. data/lib/kaal/backend/redis_adapter.rb +145 -0
  21. data/lib/kaal/backend/sqlite_adapter.rb +116 -0
  22. data/lib/kaal/configuration.rb +231 -0
  23. data/lib/kaal/coordinator.rb +437 -0
  24. data/lib/kaal/cron_humanizer.rb +182 -0
  25. data/lib/kaal/cron_utils.rb +233 -0
  26. data/lib/kaal/definition/database_engine.rb +45 -0
  27. data/lib/kaal/definition/memory_engine.rb +61 -0
  28. data/lib/kaal/definition/redis_engine.rb +93 -0
  29. data/lib/kaal/definition/registry.rb +46 -0
  30. data/lib/kaal/dispatch/database_engine.rb +94 -0
  31. data/lib/kaal/dispatch/memory_engine.rb +99 -0
  32. data/lib/kaal/dispatch/redis_engine.rb +103 -0
  33. data/lib/kaal/dispatch/registry.rb +62 -0
  34. data/lib/kaal/idempotency_key_generator.rb +26 -0
  35. data/lib/kaal/railtie.rb +183 -0
  36. data/lib/kaal/rake_tasks.rb +184 -0
  37. data/lib/kaal/register_conflict_support.rb +54 -0
  38. data/lib/kaal/registry.rb +242 -0
  39. data/lib/kaal/scheduler_config_error.rb +6 -0
  40. data/lib/kaal/scheduler_file_loader.rb +316 -0
  41. data/lib/kaal/scheduler_hash_transform.rb +40 -0
  42. data/lib/kaal/scheduler_placeholder_support.rb +80 -0
  43. data/lib/kaal/version.rb +10 -0
  44. data/lib/kaal.rb +571 -0
  45. data/lib/tasks/kaal_tasks.rake +10 -0
  46. metadata +142 -0
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Codevedas Inc. 2025-present
4
+ #
5
+ # This source code is licensed under the MIT license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ require 'fugit'
9
+
10
+ module Kaal
11
+ ##
12
+ # Utility helpers for cron expression validation, simplification, and linting.
13
+ module CronUtils
14
+ MACRO_MAP = {
15
+ '@yearly' => '0 0 1 1 *',
16
+ '@annually' => '0 0 1 1 *',
17
+ '@monthly' => '0 0 1 * *',
18
+ '@weekly' => '0 0 * * 0',
19
+ '@daily' => '0 0 * * *',
20
+ '@midnight' => '0 0 * * *',
21
+ '@hourly' => '0 * * * *'
22
+ }.freeze
23
+
24
+ CANONICAL_MACROS = {
25
+ '0 0 1 1 *' => '@yearly',
26
+ '0 0 1 * *' => '@monthly',
27
+ '0 0 * * 0' => '@weekly',
28
+ '0 0 * * 7' => '@weekly',
29
+ '0 0 * * *' => '@daily',
30
+ '0 * * * *' => '@hourly'
31
+ }.freeze
32
+
33
+ FIELD_SPECS = [
34
+ { name: 'minute', min: 0, max: 59, names: nil },
35
+ { name: 'hour', min: 0, max: 23, names: nil },
36
+ { name: 'day-of-month', min: 1, max: 31, names: nil },
37
+ {
38
+ name: 'month',
39
+ min: 1,
40
+ max: 12,
41
+ names: {
42
+ 'jan' => 1, 'feb' => 2, 'mar' => 3, 'apr' => 4, 'may' => 5, 'jun' => 6,
43
+ 'jul' => 7, 'aug' => 8, 'sep' => 9, 'oct' => 10, 'nov' => 11, 'dec' => 12
44
+ }
45
+ },
46
+ {
47
+ name: 'day-of-week',
48
+ min: 0,
49
+ max: 7,
50
+ names: {
51
+ 'sun' => 0, 'mon' => 1, 'tue' => 2, 'wed' => 3, 'thu' => 4, 'fri' => 5, 'sat' => 6
52
+ }
53
+ }
54
+ ].freeze
55
+
56
+ module_function
57
+
58
+ def normalize_expression(expression)
59
+ expression.to_s.strip.gsub(/\s+/, ' ')
60
+ end
61
+
62
+ def safe_normalize_expression(expression)
63
+ normalize_expression(expression)
64
+ rescue StandardError
65
+ nil
66
+ end
67
+
68
+ def unsupported_macro_error_message(expression)
69
+ supported = MACRO_MAP.keys.sort.join(', ')
70
+ "Unsupported cron macro '#{expression}'. Supported macros: #{supported}."
71
+ end
72
+
73
+ def invalid_expression_error_message(expression)
74
+ shown = expression.empty? ? '<empty>' : expression
75
+ "Invalid cron expression '#{shown}'. Examples: '*/5 * * * *', '@daily'."
76
+ end
77
+
78
+ def valid?(expression)
79
+ normalized = normalize_expression(expression)
80
+ return false if normalized.empty?
81
+
82
+ return MACRO_MAP.key?(normalized.downcase) if macro?(normalized)
83
+
84
+ return false unless five_fields?(normalized)
85
+
86
+ !!Fugit.parse_cron(normalized)
87
+ rescue StandardError
88
+ false
89
+ end
90
+
91
+ def simplify(expression)
92
+ normalized = safe_normalize_expression(expression)
93
+ raise ArgumentError, invalid_expression_error_message('') unless normalized
94
+
95
+ downcased = normalized.downcase
96
+
97
+ if macro?(normalized)
98
+ return canonical_macro_for(downcased) if MACRO_MAP.key?(downcased)
99
+
100
+ raise ArgumentError, unsupported_macro_error_message(normalized)
101
+ end
102
+
103
+ raise ArgumentError, invalid_expression_error_message(normalized) unless valid?(normalized)
104
+
105
+ CANONICAL_MACROS.fetch(normalized, normalized)
106
+ end
107
+
108
+ def lint(expression)
109
+ normalized = safe_normalize_expression(expression)
110
+ return [invalid_expression_error_message('')] unless normalized
111
+
112
+ invalid_message = invalid_expression_error_message(normalized)
113
+ return [invalid_message] if normalized.empty?
114
+
115
+ if macro?(normalized)
116
+ downcased = normalized.downcase
117
+ return [] if MACRO_MAP.key?(downcased)
118
+
119
+ return [unsupported_macro_error_message(normalized)]
120
+ end
121
+
122
+ return [field_count_message(normalized)] unless five_fields?(normalized)
123
+
124
+ warnings = normalized.split.each_with_index.flat_map do |field, index|
125
+ lint_field(field, FIELD_SPECS[index])
126
+ end
127
+
128
+ warnings << invalid_message unless valid?(normalized)
129
+ warnings.uniq
130
+ end
131
+
132
+ def lint_field(field, spec)
133
+ field.split(',').flat_map { |segment| lint_segment(segment, spec) }
134
+ end
135
+ private_class_method :lint_field
136
+
137
+ def lint_segment(segment, spec)
138
+ return [] if segment == '*'
139
+
140
+ if (star_step_matches = segment.match(%r{\A\*/(\d+)\z}))
141
+ step = star_step_matches[1].to_i
142
+ return lint_step_only(step, spec)
143
+ end
144
+
145
+ if (range_matches = segment.match(%r{\A([^/-]+)-([^/]+)(?:/(\d+))?\z}))
146
+ range_start = range_matches[1]
147
+ range_end = range_matches[2]
148
+ step_token = range_matches[3]
149
+ start = parse_value(range_start, spec)
150
+ ending = parse_value(range_end, spec)
151
+ step = step_token&.to_i
152
+ return lint_range_segment(segment, start, ending, step, spec)
153
+ end
154
+
155
+ if (base_step_matches = segment.match(%r{\A([^/]+)/(\d+)\z}))
156
+ base_token = base_step_matches[1]
157
+ step_token = base_step_matches[2]
158
+ base = parse_value(base_token, spec)
159
+ step = step_token.to_i
160
+ return lint_base_step_segment(segment, base, step, spec)
161
+ end
162
+
163
+ value = parse_value(segment, spec)
164
+ return ["#{spec[:name]} value '#{segment}' is out of range (#{spec[:min]}-#{spec[:max]})."] unless value
165
+
166
+ []
167
+ end
168
+ private_class_method :lint_segment
169
+
170
+ def lint_step_only(step, spec)
171
+ field_size = spec[:max] - spec[:min] + 1
172
+ return [] if step.between?(1, field_size)
173
+
174
+ ["#{spec[:name]} step '#{step}' is out of range. Allowed step: 1-#{field_size}."]
175
+ end
176
+ private_class_method :lint_step_only
177
+
178
+ def lint_range_segment(segment, start_value, end_value, step, spec)
179
+ field_name = spec[:name]
180
+ return ["#{field_name} range '#{segment}' contains an out-of-range value."] unless start_value && end_value
181
+ return ["#{field_name} range '#{segment}' has start greater than end."] if start_value > end_value
182
+ return [] unless step
183
+
184
+ span = end_value - start_value + 1
185
+ return [] if step.between?(1, span)
186
+
187
+ ["#{field_name} step '#{step}' is out of range for range '#{segment}'. Allowed step: 1-#{span}."]
188
+ end
189
+ private_class_method :lint_range_segment
190
+
191
+ def lint_base_step_segment(segment, base, step, spec)
192
+ field_name = spec[:name]
193
+ return ["#{field_name} value '#{segment}' contains an out-of-range value."] unless base
194
+ return [] if step.positive?
195
+
196
+ ["#{field_name} step '#{step}' is out of range. Allowed step: 1 or greater."]
197
+ end
198
+ private_class_method :lint_base_step_segment
199
+
200
+ def parse_value(token, spec)
201
+ names = spec[:names]
202
+ token_key = token.downcase
203
+ return names[token_key] if names&.key?(token_key)
204
+ return nil unless token.match?(/\A\d+\z/)
205
+
206
+ value = token.to_i
207
+ return nil unless value.between?(spec[:min], spec[:max])
208
+
209
+ value
210
+ end
211
+ private_class_method :parse_value
212
+
213
+ def macro?(expression)
214
+ expression.start_with?('@')
215
+ end
216
+ private_class_method :macro?
217
+
218
+ def five_fields?(expression)
219
+ expression.split.length == 5
220
+ end
221
+ private_class_method :five_fields?
222
+
223
+ def canonical_macro_for(macro)
224
+ CANONICAL_MACROS.fetch(MACRO_MAP[macro], macro)
225
+ end
226
+ private_class_method :canonical_macro_for
227
+
228
+ def field_count_message(expression)
229
+ "Invalid cron expression '#{expression}'. Expected 5 fields: minute hour day-of-month month day-of-week."
230
+ end
231
+ private_class_method :field_count_message
232
+ end
233
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'registry'
4
+
5
+ module Kaal
6
+ module Definition
7
+ # ActiveRecord-backed definition registry persisted in kaal_definitions.
8
+ class DatabaseEngine < Registry
9
+ def initialize
10
+ super
11
+ @definition_model = ::Kaal::CronDefinition
12
+ end
13
+
14
+ def upsert_definition(key:, cron:, enabled: true, source: 'code', metadata: {})
15
+ @definition_model.upsert_definition!(
16
+ key: key,
17
+ cron: cron,
18
+ enabled: enabled,
19
+ source: source,
20
+ metadata: metadata
21
+ ).to_definition_hash
22
+ end
23
+
24
+ def remove_definition(key)
25
+ record = @definition_model.find_by(key: key)
26
+ return nil unless record
27
+
28
+ record.destroy_and_return_definition_hash
29
+ end
30
+
31
+ def find_definition(key)
32
+ record = @definition_model.find_by(key: key)
33
+ record&.to_definition_hash
34
+ end
35
+
36
+ def all_definitions
37
+ @definition_model.order(:key).map(&:to_definition_hash)
38
+ end
39
+
40
+ def enabled_definitions
41
+ @definition_model.where(enabled: true).order(:key).map(&:to_definition_hash)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/object/deep_dup'
4
+ require_relative 'registry'
5
+
6
+ module Kaal
7
+ module Definition
8
+ # In-memory definition registry used when no persistent backend is configured.
9
+ class MemoryEngine < Registry
10
+ def initialize
11
+ super
12
+ @definitions = {}
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def upsert_definition(key:, cron:, enabled: true, source: 'code', metadata: {})
17
+ @mutex.synchronize do
18
+ now = Time.current
19
+ existing = @definitions[key]
20
+ stored_metadata = (metadata || {}).deep_dup
21
+ disabled_at = if enabled
22
+ nil
23
+ elsif existing && existing[:enabled] == false
24
+ existing[:disabled_at]
25
+ else
26
+ now
27
+ end
28
+ definition = {
29
+ key: key,
30
+ cron: cron,
31
+ enabled: enabled,
32
+ source: source,
33
+ metadata: stored_metadata,
34
+ created_at: existing ? existing[:created_at] : now,
35
+ updated_at: now,
36
+ disabled_at:
37
+ }
38
+ @definitions[key] = definition
39
+
40
+ definition.deep_dup
41
+ end
42
+ end
43
+
44
+ def remove_definition(key)
45
+ @mutex.synchronize { @definitions.delete(key)&.deep_dup }
46
+ end
47
+
48
+ def find_definition(key)
49
+ @mutex.synchronize { @definitions[key]&.deep_dup }
50
+ end
51
+
52
+ def all_definitions
53
+ @mutex.synchronize { @definitions.values.map(&:deep_dup) }
54
+ end
55
+
56
+ def clear
57
+ @mutex.synchronize { @definitions.clear }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+ require_relative 'registry'
6
+
7
+ module Kaal
8
+ module Definition
9
+ # Redis-backed definition registry shared across processes.
10
+ class RedisEngine < Registry
11
+ def initialize(redis, namespace: 'kaal')
12
+ super()
13
+ @redis = redis
14
+ @namespace = namespace
15
+ end
16
+
17
+ def upsert_definition(key:, cron:, enabled: true, source: 'code', metadata: {})
18
+ now = Time.current
19
+ existing = find_definition(key)
20
+ payload = {
21
+ key: key,
22
+ cron: cron,
23
+ enabled: enabled,
24
+ source: source,
25
+ metadata: metadata,
26
+ created_at: existing ? existing[:created_at] : now,
27
+ updated_at: now,
28
+ disabled_at: enabled ? nil : now
29
+ }
30
+
31
+ @redis.hset(storage_key, key, JSON.generate(self.class.serialize_payload(payload)))
32
+ payload
33
+ end
34
+
35
+ def remove_definition(key)
36
+ raw = @redis.hget(storage_key, key)
37
+ @redis.hdel(storage_key, key)
38
+ deserialize(raw)
39
+ end
40
+
41
+ def find_definition(key)
42
+ raw = @redis.hget(storage_key, key)
43
+ deserialize(raw)
44
+ end
45
+
46
+ def all_definitions
47
+ @redis.hvals(storage_key).filter_map { |raw| self.class.deserialize_payload(raw) }
48
+ end
49
+
50
+ private
51
+
52
+ def storage_key
53
+ "#{@namespace}:definitions"
54
+ end
55
+
56
+ def deserialize(raw)
57
+ self.class.deserialize_payload(raw)
58
+ end
59
+
60
+ class << self
61
+ def serialize_payload(payload)
62
+ payload.transform_values do |value|
63
+ value.is_a?(Time) ? value.iso8601 : value
64
+ end
65
+ end
66
+
67
+ def deserialize_payload(raw)
68
+ return nil unless raw
69
+
70
+ parsed = JSON.parse(raw)
71
+ {
72
+ key: parsed['key'],
73
+ cron: parsed['cron'],
74
+ enabled: parsed['enabled'] == true,
75
+ source: parsed['source'],
76
+ metadata: parsed['metadata'] || {},
77
+ created_at: parse_time(parsed['created_at']),
78
+ updated_at: parse_time(parsed['updated_at']),
79
+ disabled_at: parse_time(parsed['disabled_at'])
80
+ }
81
+ rescue JSON::ParserError
82
+ nil
83
+ end
84
+
85
+ def parse_time(value)
86
+ Time.iso8601(value.to_s)
87
+ rescue ArgumentError
88
+ nil
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaal
4
+ module Definition
5
+ # Base abstraction for cron definition storage.
6
+ class Registry
7
+ def upsert_definition(**)
8
+ raise NotImplementedError, "#{self.class.name} must implement #upsert_definition"
9
+ end
10
+
11
+ def remove_definition(_key)
12
+ raise NotImplementedError, "#{self.class.name} must implement #remove_definition"
13
+ end
14
+
15
+ def find_definition(_key)
16
+ raise NotImplementedError, "#{self.class.name} must implement #find_definition"
17
+ end
18
+
19
+ def all_definitions
20
+ raise NotImplementedError, "#{self.class.name} must implement #all_definitions"
21
+ end
22
+
23
+ def enabled_definitions
24
+ all_definitions.select { |definition| definition[:enabled] }
25
+ end
26
+
27
+ def enable_definition(key)
28
+ update_definition_enabled_state(key, enabled: true)
29
+ end
30
+
31
+ def disable_definition(key)
32
+ update_definition_enabled_state(key, enabled: false)
33
+ end
34
+
35
+ private
36
+
37
+ def update_definition_enabled_state(key, enabled:)
38
+ definition = find_definition(key)
39
+ return nil unless definition
40
+
41
+ attributes = definition.slice(:key, :cron, :source, :metadata).merge(enabled: enabled)
42
+ upsert_definition(**attributes)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Codevedas Inc. 2025-present
4
+ #
5
+ # This source code is licensed under the MIT license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ require_relative 'registry'
9
+
10
+ module Kaal
11
+ module Dispatch
12
+ ##
13
+ # Database-backed dispatch registry using ActiveRecord.
14
+ #
15
+ # Stores dispatch records in the database using the CronDispatch model.
16
+ # Provides persistent, queryable audit logs across all nodes.
17
+ #
18
+ # @example Usage
19
+ # registry = Kaal::Dispatch::DatabaseEngine.new
20
+ # registry.log_dispatch('daily_report', Time.current, 'node-1')
21
+ # registry.dispatched?('daily_report', Time.current) # => true
22
+ class DatabaseEngine < Registry
23
+ ##
24
+ # Log a dispatch attempt in the database.
25
+ #
26
+ # @param key [String] the cron job key
27
+ # @param fire_time [Time] when the job was scheduled to fire
28
+ # @param node_id [String] identifier for the dispatching node
29
+ # @param status [String] dispatch status ('dispatched', 'failed', etc.)
30
+ # @return [Kaal::CronDispatch] the created dispatch record
31
+ # @raise [ActiveRecord::RecordInvalid] if the record is invalid
32
+ def log_dispatch(key, fire_time, node_id, status = 'dispatched')
33
+ ::Kaal::CronDispatch.create!(
34
+ key: key,
35
+ fire_time: fire_time,
36
+ dispatched_at: Time.current,
37
+ node_id: node_id,
38
+ status: status
39
+ )
40
+ end
41
+
42
+ ##
43
+ # Find a dispatch record for a specific job and fire time.
44
+ #
45
+ # @param key [String] the cron job key
46
+ # @param fire_time [Time] when the job was scheduled to fire
47
+ # @return [Kaal::CronDispatch, nil] dispatch record or nil if not found
48
+ def find_dispatch(key, fire_time)
49
+ ::Kaal::CronDispatch.find_by(key: key, fire_time: fire_time)
50
+ end
51
+
52
+ ##
53
+ # Find all dispatch records for a specific job key.
54
+ #
55
+ # @param key [String] the cron job key
56
+ # @return [ActiveRecord::Relation] collection of dispatch records
57
+ def find_by_key(key)
58
+ ::Kaal::CronDispatch.where(key: key).order(fire_time: :desc)
59
+ end
60
+
61
+ ##
62
+ # Find all dispatch records by node ID.
63
+ #
64
+ # @param node_id [String] the node identifier
65
+ # @return [ActiveRecord::Relation] collection of dispatch records
66
+ def find_by_node(node_id)
67
+ ::Kaal::CronDispatch.where(node_id: node_id).order(fire_time: :desc)
68
+ end
69
+
70
+ ##
71
+ # Find all dispatch records with a specific status.
72
+ #
73
+ # @param status [String] the dispatch status
74
+ # @return [ActiveRecord::Relation] collection of dispatch records
75
+ def find_by_status(status)
76
+ ::Kaal::CronDispatch.where(status: status).order(fire_time: :desc)
77
+ end
78
+
79
+ ##
80
+ # Delete old dispatch records older than the specified time.
81
+ #
82
+ # This cleanup prevents unbounded database growth by removing records
83
+ # that are older than the recovery window, making them irrelevant for
84
+ # future recovery operations.
85
+ #
86
+ # @param recovery_window [Integer] seconds to keep records for (e.g., 86400 for 24h)
87
+ # @return [Integer] number of records deleted
88
+ def cleanup(recovery_window: 86_400)
89
+ cutoff_time = Time.current - recovery_window
90
+ ::Kaal::CronDispatch.where('fire_time < ?', cutoff_time).delete_all
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Codevedas Inc. 2025-present
4
+ #
5
+ # This source code is licensed under the MIT license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ require_relative 'registry'
9
+
10
+ module Kaal
11
+ module Dispatch
12
+ ##
13
+ # In-memory dispatch registry using a Hash for storage.
14
+ #
15
+ # Stores dispatch records in memory. Suitable for development, testing,
16
+ # or single-node deployments where persistence is not required.
17
+ #
18
+ # @example Usage
19
+ # registry = Kaal::Dispatch::MemoryEngine.new
20
+ # registry.log_dispatch('daily_report', Time.current, 'node-1')
21
+ # registry.dispatched?('daily_report', Time.current) # => true
22
+ class MemoryEngine < Registry
23
+ ##
24
+ # Initialize a new in-memory registry.
25
+ def initialize
26
+ super
27
+ @dispatches = {}
28
+ @mutex = Mutex.new
29
+ end
30
+
31
+ ##
32
+ # Log a dispatch attempt in memory.
33
+ #
34
+ # @param key [String] the cron job key
35
+ # @param fire_time [Time] when the job was scheduled to fire
36
+ # @param node_id [String] identifier for the dispatching node
37
+ # @param status [String] dispatch status ('dispatched', 'failed', etc.)
38
+ # @return [Hash] the stored dispatch record
39
+ def log_dispatch(key, fire_time, node_id, status = 'dispatched')
40
+ @mutex.synchronize do
41
+ storage_key = build_key(key, fire_time)
42
+ @dispatches[storage_key] = {
43
+ key: key,
44
+ fire_time: fire_time,
45
+ dispatched_at: Time.current,
46
+ node_id: node_id,
47
+ status: status
48
+ }
49
+ end
50
+ end
51
+
52
+ ##
53
+ # Find a dispatch record for a specific job and fire time.
54
+ #
55
+ # @param key [String] the cron job key
56
+ # @param fire_time [Time] when the job was scheduled to fire
57
+ # @return [Hash, nil] dispatch record or nil if not found
58
+ def find_dispatch(key, fire_time)
59
+ @mutex.synchronize do
60
+ storage_key = build_key(key, fire_time)
61
+ @dispatches[storage_key]
62
+ end
63
+ end
64
+
65
+ ##
66
+ # Clear all stored dispatch records.
67
+ # Useful for testing.
68
+ #
69
+ # @return [void]
70
+ def clear
71
+ @mutex.synchronize do
72
+ @dispatches.clear
73
+ end
74
+ end
75
+
76
+ ##
77
+ # Get the number of stored dispatch records.
78
+ #
79
+ # @return [Integer] number of dispatch records
80
+ def size
81
+ @mutex.synchronize do
82
+ @dispatches.size
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ ##
89
+ # Build a storage key from job key and fire time.
90
+ #
91
+ # @param key [String] the cron job key
92
+ # @param fire_time [Time] the fire time
93
+ # @return [String] storage key
94
+ def build_key(key, fire_time)
95
+ "#{key}:#{fire_time.to_i}"
96
+ end
97
+ end
98
+ end
99
+ end