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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +340 -0
- data/Rakefile +6 -0
- data/app/models/kaal/cron_definition.rb +71 -0
- data/app/models/kaal/cron_dispatch.rb +50 -0
- data/app/models/kaal/cron_lock.rb +38 -0
- data/config/locales/en.yml +46 -0
- data/lib/generators/kaal/install/install_generator.rb +67 -0
- data/lib/generators/kaal/install/templates/create_kaal_definitions.rb.tt +21 -0
- data/lib/generators/kaal/install/templates/create_kaal_dispatches.rb.tt +20 -0
- data/lib/generators/kaal/install/templates/create_kaal_locks.rb.tt +17 -0
- data/lib/generators/kaal/install/templates/kaal.rb.tt +31 -0
- data/lib/generators/kaal/install/templates/scheduler.yml.tt +22 -0
- data/lib/kaal/backend/adapter.rb +147 -0
- data/lib/kaal/backend/dispatch_logging.rb +79 -0
- data/lib/kaal/backend/memory_adapter.rb +99 -0
- data/lib/kaal/backend/mysql_adapter.rb +170 -0
- data/lib/kaal/backend/postgres_adapter.rb +134 -0
- data/lib/kaal/backend/redis_adapter.rb +145 -0
- data/lib/kaal/backend/sqlite_adapter.rb +116 -0
- data/lib/kaal/configuration.rb +231 -0
- data/lib/kaal/coordinator.rb +437 -0
- data/lib/kaal/cron_humanizer.rb +182 -0
- data/lib/kaal/cron_utils.rb +233 -0
- data/lib/kaal/definition/database_engine.rb +45 -0
- data/lib/kaal/definition/memory_engine.rb +61 -0
- data/lib/kaal/definition/redis_engine.rb +93 -0
- data/lib/kaal/definition/registry.rb +46 -0
- data/lib/kaal/dispatch/database_engine.rb +94 -0
- data/lib/kaal/dispatch/memory_engine.rb +99 -0
- data/lib/kaal/dispatch/redis_engine.rb +103 -0
- data/lib/kaal/dispatch/registry.rb +62 -0
- data/lib/kaal/idempotency_key_generator.rb +26 -0
- data/lib/kaal/railtie.rb +183 -0
- data/lib/kaal/rake_tasks.rb +184 -0
- data/lib/kaal/register_conflict_support.rb +54 -0
- data/lib/kaal/registry.rb +242 -0
- data/lib/kaal/scheduler_config_error.rb +6 -0
- data/lib/kaal/scheduler_file_loader.rb +316 -0
- data/lib/kaal/scheduler_hash_transform.rb +40 -0
- data/lib/kaal/scheduler_placeholder_support.rb +80 -0
- data/lib/kaal/version.rb +10 -0
- data/lib/kaal.rb +571 -0
- data/lib/tasks/kaal_tasks.rake +10 -0
- 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
|