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,437 @@
|
|
|
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
|
+
# Coordinator manages the main scheduler loop that calculates due fire times
|
|
13
|
+
# and dispatches cron work safely across multiple nodes using backend lease coordination.
|
|
14
|
+
#
|
|
15
|
+
# The coordinator:
|
|
16
|
+
# 1. Runs a background thread on tick_interval
|
|
17
|
+
# 2. For each registered cron, calculates due fire times within the window
|
|
18
|
+
# 3. Attempts to acquire a distributed lease for each due time
|
|
19
|
+
# 4. Calls the enqueue callback if the lease is acquired
|
|
20
|
+
# 5. Supports graceful shutdown and re-entrancy for testing
|
|
21
|
+
#
|
|
22
|
+
# @example Start the coordinator
|
|
23
|
+
# coordinator = Kaal::Coordinator.new
|
|
24
|
+
# coordinator.start!
|
|
25
|
+
#
|
|
26
|
+
# @example Manual tick execution (for testing)
|
|
27
|
+
# coordinator.tick!
|
|
28
|
+
#
|
|
29
|
+
# @example Stop the coordinator
|
|
30
|
+
# coordinator.stop!
|
|
31
|
+
class Coordinator
|
|
32
|
+
##
|
|
33
|
+
# Initialize a new Coordinator instance.
|
|
34
|
+
#
|
|
35
|
+
# @param configuration [Configuration] the scheduler configuration
|
|
36
|
+
# @param registry [Registry] the registered crons registry
|
|
37
|
+
def initialize(configuration:, registry:)
|
|
38
|
+
@configuration = configuration
|
|
39
|
+
@registry = registry
|
|
40
|
+
@thread = nil
|
|
41
|
+
@running = false
|
|
42
|
+
@stop_requested = false
|
|
43
|
+
@mutex = Mutex.new
|
|
44
|
+
@tick_cv = ConditionVariable.new
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
##
|
|
48
|
+
# Start the coordinator background thread.
|
|
49
|
+
#
|
|
50
|
+
# @return [Thread] the started thread, or nil if already running
|
|
51
|
+
# @safe
|
|
52
|
+
def start!
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
return nil if @running
|
|
55
|
+
|
|
56
|
+
# Run recovery before starting the main loop
|
|
57
|
+
recover_missed_runs
|
|
58
|
+
|
|
59
|
+
@running = true
|
|
60
|
+
@stop_requested = false
|
|
61
|
+
@thread = Thread.new { run_loop }
|
|
62
|
+
@thread.abort_on_exception = true
|
|
63
|
+
return @thread
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
##
|
|
68
|
+
# Stop the coordinator gracefully.
|
|
69
|
+
#
|
|
70
|
+
# Signals the coordinator to stop after the current tick completes,
|
|
71
|
+
# then waits for the thread to finish.
|
|
72
|
+
#
|
|
73
|
+
# @param timeout [Integer] seconds to wait for graceful shutdown (default: 30)
|
|
74
|
+
# @return [Boolean] true if stopped, false if timeout
|
|
75
|
+
# @safe
|
|
76
|
+
def stop!(timeout: 30) # rubocop:disable Naming/PredicateMethod
|
|
77
|
+
request_stop
|
|
78
|
+
|
|
79
|
+
# Wait for thread to finish outside the lock
|
|
80
|
+
result = @thread&.join(timeout)
|
|
81
|
+
|
|
82
|
+
# If we had a thread and join timed out, thread is still alive
|
|
83
|
+
return false if @thread && result.nil?
|
|
84
|
+
|
|
85
|
+
@thread = nil
|
|
86
|
+
@mutex.synchronize { @running = false }
|
|
87
|
+
|
|
88
|
+
true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
##
|
|
92
|
+
# Check if the coordinator is currently running.
|
|
93
|
+
#
|
|
94
|
+
# @return [Boolean] true if running, false otherwise
|
|
95
|
+
def running?
|
|
96
|
+
@mutex.synchronize { @running }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
##
|
|
100
|
+
# Restart the coordinator (stop then start).
|
|
101
|
+
#
|
|
102
|
+
# @return [Thread] the started thread
|
|
103
|
+
# @safe
|
|
104
|
+
def restart!
|
|
105
|
+
stop!
|
|
106
|
+
start!
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
##
|
|
110
|
+
# Execute a single tick manually.
|
|
111
|
+
#
|
|
112
|
+
# This is useful for testing and Rake tasks that want to trigger
|
|
113
|
+
# the scheduler without running the background loop.
|
|
114
|
+
#
|
|
115
|
+
# @return [void]
|
|
116
|
+
# @safe
|
|
117
|
+
def tick!
|
|
118
|
+
execute_tick
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
##
|
|
122
|
+
# Reset coordinator state for re-entrancy in tests.
|
|
123
|
+
#
|
|
124
|
+
# Stops any running thread before clearing state to avoid orphaning it.
|
|
125
|
+
# Raises an error if the thread cannot be stopped within the timeout.
|
|
126
|
+
#
|
|
127
|
+
# @return [void]
|
|
128
|
+
# @raise [RuntimeError] if stop! times out
|
|
129
|
+
# @safe
|
|
130
|
+
def reset!
|
|
131
|
+
# Stop any running thread first to prevent orphaned threads
|
|
132
|
+
if running?
|
|
133
|
+
stopped = stop!
|
|
134
|
+
raise 'Failed to stop coordinator thread within timeout' unless stopped
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Now safe to reset all state
|
|
138
|
+
@mutex.synchronize do
|
|
139
|
+
@running = false
|
|
140
|
+
@stop_requested = false
|
|
141
|
+
@thread = nil
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def request_stop
|
|
148
|
+
@mutex.synchronize do
|
|
149
|
+
return unless @running
|
|
150
|
+
|
|
151
|
+
@stop_requested = true
|
|
152
|
+
@tick_cv.signal
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def run_loop
|
|
157
|
+
loop do
|
|
158
|
+
break if stop_requested?
|
|
159
|
+
|
|
160
|
+
execute_tick
|
|
161
|
+
sleep_until_next_tick
|
|
162
|
+
end
|
|
163
|
+
ensure
|
|
164
|
+
@mutex.synchronize { @running = false }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def stop_requested?
|
|
168
|
+
@mutex.synchronize { @stop_requested }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def execute_tick
|
|
172
|
+
each_enabled_entry do |entry|
|
|
173
|
+
calculate_and_dispatch_due_times(entry)
|
|
174
|
+
end
|
|
175
|
+
rescue StandardError => e
|
|
176
|
+
# Log error but continue the loop
|
|
177
|
+
@configuration.logger&.error("Kaal coordinator tick failed: #{e.message}")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def calculate_and_dispatch_due_times(entry)
|
|
181
|
+
now = Time.current
|
|
182
|
+
window_start = now - @configuration.window_lookback
|
|
183
|
+
window_end = now + @configuration.window_lookahead
|
|
184
|
+
|
|
185
|
+
# Parse cron expression using fugit
|
|
186
|
+
cron = parse_cron(entry.cron)
|
|
187
|
+
return unless cron
|
|
188
|
+
|
|
189
|
+
# Find all occurrences within the window
|
|
190
|
+
occurrences = find_occurrences(cron, window_start, window_end)
|
|
191
|
+
@configuration.logger&.debug("Coordinator: Found #{occurrences.length} occurrences for #{entry.key} in window [#{window_start}, #{window_end}]")
|
|
192
|
+
|
|
193
|
+
# For each occurrence that's due (in the past or now), try to dispatch
|
|
194
|
+
occurrences.each do |fire_time|
|
|
195
|
+
dispatch_if_due(entry, fire_time, now)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def parse_cron(cron_expression)
|
|
200
|
+
result = Fugit.parse_cron(cron_expression)
|
|
201
|
+
raise ArgumentError, "Invalid cron expression: #{cron_expression}" unless result
|
|
202
|
+
|
|
203
|
+
result
|
|
204
|
+
rescue ArgumentError => e
|
|
205
|
+
@configuration.logger&.warn("Failed to parse cron expression '#{cron_expression}': #{e.message}")
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def find_occurrences(cron, start_time, end_time)
|
|
210
|
+
# Use fugit to find all occurrences between start and end times
|
|
211
|
+
occurrences = []
|
|
212
|
+
current_time = start_time
|
|
213
|
+
|
|
214
|
+
while current_time <= end_time
|
|
215
|
+
next_occurrence = cron.next_time(current_time)
|
|
216
|
+
break unless next_occurrence
|
|
217
|
+
|
|
218
|
+
break if next_occurrence > end_time
|
|
219
|
+
|
|
220
|
+
occurrences << next_occurrence
|
|
221
|
+
current_time = next_occurrence + 1.second # Move past this occurrence to find the next
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
occurrences
|
|
225
|
+
rescue StandardError => e
|
|
226
|
+
@configuration.logger&.error("Failed to calculate occurrences: #{e.message}")
|
|
227
|
+
[]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def dispatch_if_due(entry, fire_time, now)
|
|
231
|
+
# Only dispatch if fire_time is in the past or now
|
|
232
|
+
return if fire_time > now
|
|
233
|
+
|
|
234
|
+
logger = @configuration.logger
|
|
235
|
+
cron_key = entry.key
|
|
236
|
+
|
|
237
|
+
# Generate a unique backend coordination key for this fire time
|
|
238
|
+
lock_key = generate_lock_key(cron_key, fire_time)
|
|
239
|
+
|
|
240
|
+
# Try to acquire the coordination lease
|
|
241
|
+
if acquire_lock(lock_key)
|
|
242
|
+
dispatch_work(entry, fire_time)
|
|
243
|
+
else
|
|
244
|
+
logger&.debug("Failed to acquire lock for #{lock_key}")
|
|
245
|
+
end
|
|
246
|
+
rescue StandardError => e
|
|
247
|
+
cron_key ||= 'unknown'
|
|
248
|
+
logger&.error("Error dispatching work for #{cron_key}: #{e.message}")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
##
|
|
252
|
+
# Recover missed cron runs after downtime.
|
|
253
|
+
#
|
|
254
|
+
# Looks back over the recovery window to find cron jobs that should have executed
|
|
255
|
+
# but were missed due to downtime. Uses dispatch records (if enabled) to skip
|
|
256
|
+
# already-dispatched jobs, and relies on lease coordination for duplicate prevention.
|
|
257
|
+
#
|
|
258
|
+
# @return [void]
|
|
259
|
+
def recover_missed_runs
|
|
260
|
+
return unless @configuration.enable_dispatch_recovery
|
|
261
|
+
|
|
262
|
+
# Add random jitter to reduce lock contention when multiple nodes restart simultaneously
|
|
263
|
+
jitter = rand(0..@configuration.recovery_startup_jitter)
|
|
264
|
+
sleep(jitter) if jitter.positive?
|
|
265
|
+
|
|
266
|
+
current_time = Time.current
|
|
267
|
+
recovery_window = @configuration.recovery_window
|
|
268
|
+
recovery_start = current_time - recovery_window
|
|
269
|
+
recovery_end = current_time
|
|
270
|
+
|
|
271
|
+
logger = @configuration.logger
|
|
272
|
+
logger&.info("Starting missed-run recovery for window: #{recovery_start} to #{recovery_end}")
|
|
273
|
+
|
|
274
|
+
total_recovered = 0
|
|
275
|
+
each_enabled_entry do |entry|
|
|
276
|
+
recovered = recover_entry(entry, recovery_start, recovery_end)
|
|
277
|
+
total_recovered += recovered
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
logger&.info("Missed-run recovery completed: attempted #{total_recovered} dispatches")
|
|
281
|
+
|
|
282
|
+
# Clean up old dispatch records after recovery completes
|
|
283
|
+
cleanup_old_dispatch_records(recovery_window)
|
|
284
|
+
rescue StandardError => e
|
|
285
|
+
logger&.error("Error during missed-run recovery: #{e.message}")
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
##
|
|
289
|
+
# Recover missed runs for a single cron entry.
|
|
290
|
+
#
|
|
291
|
+
# @param entry [Kaal::Registry::Entry] the cron job entry
|
|
292
|
+
# @param start_time [Time] the start of the recovery window
|
|
293
|
+
# @param end_time [Time] the end of the recovery window
|
|
294
|
+
# @return [Integer] number of occurrences attempted to dispatch
|
|
295
|
+
def recover_entry(entry, start_time, end_time)
|
|
296
|
+
logger = @configuration.logger
|
|
297
|
+
entry_key = entry.key
|
|
298
|
+
cron = parse_cron(entry.cron)
|
|
299
|
+
return 0 unless cron
|
|
300
|
+
|
|
301
|
+
occurrences = find_occurrences(cron, start_time, end_time)
|
|
302
|
+
|
|
303
|
+
# Filter out already-dispatched runs if dispatch logging is enabled
|
|
304
|
+
occurrences.reject! { |fire_time| already_dispatched?(entry_key, fire_time) } if @configuration.enable_log_dispatch_registry
|
|
305
|
+
occurrences_size = occurrences.size
|
|
306
|
+
logger&.info("Recovering #{occurrences_size} missed runs for #{entry_key}")
|
|
307
|
+
|
|
308
|
+
# Attempt to dispatch each missed occurrence
|
|
309
|
+
occurrences.each do |fire_time|
|
|
310
|
+
dispatch_if_due(entry, fire_time, Time.current)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
occurrences_size
|
|
314
|
+
rescue StandardError => e
|
|
315
|
+
logger&.error("Error recovering entry #{entry_key}: #{e.message}")
|
|
316
|
+
0
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
##
|
|
320
|
+
# Clean up old dispatch records to prevent database bloat.
|
|
321
|
+
#
|
|
322
|
+
# Called after recovery completes. Deletes dispatch records older than
|
|
323
|
+
# the recovery window, since they are no longer needed for future recovery.
|
|
324
|
+
#
|
|
325
|
+
# @param recovery_window [Integer] seconds - records older than this are deleted
|
|
326
|
+
# @return [void]
|
|
327
|
+
def cleanup_old_dispatch_records(recovery_window)
|
|
328
|
+
logger = @configuration.logger
|
|
329
|
+
adapter = @configuration.backend
|
|
330
|
+
return if adapter.nil? || !adapter.respond_to?(:dispatch_registry)
|
|
331
|
+
|
|
332
|
+
registry = adapter.dispatch_registry
|
|
333
|
+
return unless registry.respond_to?(:cleanup)
|
|
334
|
+
|
|
335
|
+
deleted_count = registry.cleanup(recovery_window: recovery_window)
|
|
336
|
+
logger&.debug("Cleaned up #{deleted_count} old dispatch records") if deleted_count.positive?
|
|
337
|
+
rescue StandardError => e
|
|
338
|
+
logger&.warn("Error cleaning up old dispatch records: #{e.message}")
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
##
|
|
342
|
+
# Check if a cron job was already dispatched.
|
|
343
|
+
#
|
|
344
|
+
# @param key [String] the cron job key
|
|
345
|
+
# @param fire_time [Time] the fire time to check
|
|
346
|
+
# @return [Boolean] true if already dispatched, false otherwise
|
|
347
|
+
def already_dispatched?(key, fire_time)
|
|
348
|
+
adapter = @configuration.backend
|
|
349
|
+
return false if adapter.nil? || !adapter.respond_to?(:dispatch_registry)
|
|
350
|
+
|
|
351
|
+
adapter.dispatch_registry.dispatched?(key, fire_time)
|
|
352
|
+
rescue StandardError => e
|
|
353
|
+
@configuration.logger&.warn("Error checking dispatch status for #{key}: #{e.message}")
|
|
354
|
+
false
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def acquire_lock(lock_key)
|
|
358
|
+
backend = @configuration.backend
|
|
359
|
+
logger = @configuration.logger
|
|
360
|
+
|
|
361
|
+
# No adapter = no locking (dev/test)
|
|
362
|
+
return true unless backend
|
|
363
|
+
|
|
364
|
+
backend.acquire(lock_key, @configuration.lease_ttl)
|
|
365
|
+
rescue StandardError => e
|
|
366
|
+
logger&.error("Lock acquisition failed for #{lock_key}: #{e.message}")
|
|
367
|
+
false
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def each_enabled_entry(&)
|
|
371
|
+
use_registry_entries = false
|
|
372
|
+
logger = @configuration.logger
|
|
373
|
+
warn_iteration_failure = ->(target, error) { logger&.warn("Failed to iterate #{target}: #{error.message}") }
|
|
374
|
+
|
|
375
|
+
begin
|
|
376
|
+
definition_registry = Kaal.definition_registry
|
|
377
|
+
return each_registry_entry(&) unless definition_registry
|
|
378
|
+
|
|
379
|
+
definitions = definition_registry.enabled_definitions || []
|
|
380
|
+
use_registry_entries = definitions.empty? && definition_registry.all_definitions.to_a.empty?
|
|
381
|
+
|
|
382
|
+
definitions
|
|
383
|
+
.filter_map { |definition| build_entry_from_definition(definition) }
|
|
384
|
+
.each(&)
|
|
385
|
+
rescue StandardError => e
|
|
386
|
+
warn_iteration_failure.call('enabled definitions', e)
|
|
387
|
+
use_registry_entries = true
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
each_registry_entry(&) if use_registry_entries
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def build_entry_from_definition(definition)
|
|
394
|
+
key = definition[:key]
|
|
395
|
+
callback_entry = @registry.find(key)
|
|
396
|
+
unless callback_entry&.enqueue
|
|
397
|
+
@configuration.logger&.warn("No enqueue callback registered for definition '#{key}', skipping")
|
|
398
|
+
return nil
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
Registry::Entry.new(key: key, cron: definition[:cron], enqueue: callback_entry.enqueue).freeze
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def dispatch_work(entry, fire_time)
|
|
405
|
+
# Call the enqueue callback with fire_time and idempotency_key
|
|
406
|
+
cron_key = entry.key
|
|
407
|
+
logger = @configuration.logger
|
|
408
|
+
|
|
409
|
+
idempotency_key = generate_idempotency_key(cron_key, fire_time)
|
|
410
|
+
entry.enqueue.call(fire_time:, idempotency_key:)
|
|
411
|
+
logger&.debug("Dispatched work for #{cron_key} at #{fire_time}")
|
|
412
|
+
rescue StandardError => e
|
|
413
|
+
logger&.error("Work dispatch failed for #{cron_key}: #{e.message}")
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def generate_idempotency_key(cron_key, fire_time)
|
|
417
|
+
Kaal::IdempotencyKeyGenerator.call(cron_key, fire_time, configuration: @configuration)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def generate_lock_key(cron_key, fire_time)
|
|
421
|
+
namespace = @configuration.namespace || 'kaal'
|
|
422
|
+
"#{namespace}:dispatch:#{cron_key}:#{fire_time.to_i}"
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def sleep_until_next_tick
|
|
426
|
+
@mutex.synchronize do
|
|
427
|
+
@tick_cv.wait(@mutex, @configuration.tick_interval)
|
|
428
|
+
end
|
|
429
|
+
rescue StandardError => e
|
|
430
|
+
@configuration.logger&.error("Sleep interrupted: #{e.message}")
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def each_registry_entry(&)
|
|
434
|
+
@registry.each(&)
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
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
|
+
require 'i18n'
|
|
10
|
+
|
|
11
|
+
module Kaal
|
|
12
|
+
##
|
|
13
|
+
# Human-friendly cron phrase generation with i18n support.
|
|
14
|
+
module CronHumanizer
|
|
15
|
+
MACRO_PHRASES = {
|
|
16
|
+
'@yearly' => 'phrases.yearly',
|
|
17
|
+
'@monthly' => 'phrases.monthly',
|
|
18
|
+
'@weekly' => 'phrases.weekly',
|
|
19
|
+
'@daily' => 'phrases.daily',
|
|
20
|
+
'@hourly' => 'phrases.hourly'
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def to_human(expression, locale: nil)
|
|
26
|
+
normalized = CronUtils.safe_normalize_expression(expression)
|
|
27
|
+
raise ArgumentError, CronUtils.invalid_expression_error_message('') unless normalized
|
|
28
|
+
raise ArgumentError, CronUtils.invalid_expression_error_message(normalized) if normalized.empty?
|
|
29
|
+
|
|
30
|
+
resolved_locale = locale || I18n.locale
|
|
31
|
+
I18n.with_locale(resolved_locale) do
|
|
32
|
+
humanized = humanize_expression(normalized)
|
|
33
|
+
return humanized unless humanized.to_s.strip.empty?
|
|
34
|
+
|
|
35
|
+
translate_phrase('phrases.cron_expression', expression: normalized)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def humanize_expression(normalized)
|
|
40
|
+
error_message = CronUtils.invalid_expression_error_message(normalized)
|
|
41
|
+
return humanize_macro(normalized) if macro?(normalized)
|
|
42
|
+
|
|
43
|
+
cron = Fugit.parse_cron(normalized)
|
|
44
|
+
return humanize_cron(cron) if cron
|
|
45
|
+
|
|
46
|
+
raise ArgumentError, error_message
|
|
47
|
+
end
|
|
48
|
+
private_class_method :humanize_expression
|
|
49
|
+
|
|
50
|
+
def humanize_macro(expression)
|
|
51
|
+
macro = expression.downcase
|
|
52
|
+
raise ArgumentError, CronUtils.unsupported_macro_error_message(expression) unless CronUtils::MACRO_MAP.key?(macro)
|
|
53
|
+
|
|
54
|
+
canonical_macro = CronUtils::CANONICAL_MACROS.fetch(CronUtils::MACRO_MAP.fetch(macro), macro)
|
|
55
|
+
phrase_key = MACRO_PHRASES.fetch(canonical_macro, nil)
|
|
56
|
+
return translate_phrase(phrase_key) if phrase_key
|
|
57
|
+
|
|
58
|
+
translate_phrase('phrases.cron_expression', expression: expression)
|
|
59
|
+
end
|
|
60
|
+
private_class_method :humanize_macro
|
|
61
|
+
|
|
62
|
+
def humanize_cron(cron)
|
|
63
|
+
canonical = cron.to_cron_s
|
|
64
|
+
macro = CronUtils::CANONICAL_MACROS[canonical]
|
|
65
|
+
return humanize_macro(macro) if macro
|
|
66
|
+
|
|
67
|
+
interval_phrase = every_minute_interval_phrase(cron)
|
|
68
|
+
return interval_phrase if interval_phrase
|
|
69
|
+
|
|
70
|
+
weekday_phrase = at_time_weekday_phrase(cron)
|
|
71
|
+
return weekday_phrase if weekday_phrase
|
|
72
|
+
|
|
73
|
+
daily_phrase = at_time_daily_phrase(cron)
|
|
74
|
+
return daily_phrase if daily_phrase
|
|
75
|
+
|
|
76
|
+
translate_phrase('phrases.cron_expression', expression: canonical)
|
|
77
|
+
end
|
|
78
|
+
private_class_method :humanize_cron
|
|
79
|
+
|
|
80
|
+
def every_minute_interval_phrase(cron)
|
|
81
|
+
return nil if cron.hours || cron.monthdays || cron.months || cron.weekdays
|
|
82
|
+
|
|
83
|
+
minutes = cron.minutes
|
|
84
|
+
return nil unless minutes.is_a?(Array) && minutes.length > 1
|
|
85
|
+
|
|
86
|
+
interval = derive_interval(minutes)
|
|
87
|
+
return nil unless interval
|
|
88
|
+
|
|
89
|
+
unit = interval_unit(interval, singular: 'minute', plural: 'minutes')
|
|
90
|
+
translate_phrase('phrases.every_interval', count: interval, unit: unit)
|
|
91
|
+
end
|
|
92
|
+
private_class_method :every_minute_interval_phrase
|
|
93
|
+
|
|
94
|
+
def derive_interval(minutes)
|
|
95
|
+
return nil unless minutes.first.zero?
|
|
96
|
+
|
|
97
|
+
diffs = minutes.each_cons(2).map { |left, right| right - left }.uniq
|
|
98
|
+
return nil unless diffs.length == 1
|
|
99
|
+
|
|
100
|
+
interval = diffs.first
|
|
101
|
+
return nil unless interval.positive?
|
|
102
|
+
return nil unless minutes.last == 60 - interval
|
|
103
|
+
|
|
104
|
+
interval
|
|
105
|
+
end
|
|
106
|
+
private_class_method :derive_interval
|
|
107
|
+
|
|
108
|
+
def at_time_weekday_phrase(cron)
|
|
109
|
+
return nil unless single_time?(cron)
|
|
110
|
+
|
|
111
|
+
weekdays = cron.weekdays
|
|
112
|
+
return nil if cron.monthdays || cron.months || !weekdays
|
|
113
|
+
|
|
114
|
+
weekday = extract_weekday(weekdays)
|
|
115
|
+
return nil unless weekday
|
|
116
|
+
|
|
117
|
+
time = format_time(cron.hours.first, cron.minutes.first)
|
|
118
|
+
"#{translate_phrase('phrases.at_time', time: time)} #{translate_phrase('every')} #{weekday_name(weekday)}"
|
|
119
|
+
end
|
|
120
|
+
private_class_method :at_time_weekday_phrase
|
|
121
|
+
|
|
122
|
+
def at_time_daily_phrase(cron)
|
|
123
|
+
return nil unless single_time?(cron)
|
|
124
|
+
return nil if cron.monthdays || cron.months || cron.weekdays
|
|
125
|
+
|
|
126
|
+
time = format_time(cron.hours.first, cron.minutes.first)
|
|
127
|
+
"#{translate_phrase('phrases.at_time', time: time)} #{translate_phrase('every')} #{translate_phrase('time.day')}"
|
|
128
|
+
end
|
|
129
|
+
private_class_method :at_time_daily_phrase
|
|
130
|
+
|
|
131
|
+
def single_time?(cron)
|
|
132
|
+
minutes = cron.minutes
|
|
133
|
+
hours = cron.hours
|
|
134
|
+
|
|
135
|
+
minutes.is_a?(Array) && minutes.size == 1 &&
|
|
136
|
+
hours.is_a?(Array) && hours.size == 1
|
|
137
|
+
end
|
|
138
|
+
private_class_method :single_time?
|
|
139
|
+
|
|
140
|
+
def extract_weekday(weekdays)
|
|
141
|
+
return nil unless weekdays.is_a?(Array) && weekdays.size == 1
|
|
142
|
+
|
|
143
|
+
token = weekdays.first
|
|
144
|
+
return token if token.is_a?(Integer)
|
|
145
|
+
|
|
146
|
+
if token.is_a?(Array) && token.size == 1
|
|
147
|
+
candidate = token.first
|
|
148
|
+
return candidate if candidate.is_a?(Integer)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
private_class_method :extract_weekday
|
|
154
|
+
|
|
155
|
+
def weekday_name(day)
|
|
156
|
+
normalized_day = day == 7 ? 0 : day
|
|
157
|
+
translate_phrase("weekdays.#{normalized_day}")
|
|
158
|
+
end
|
|
159
|
+
private_class_method :weekday_name
|
|
160
|
+
|
|
161
|
+
def format_time(hour, minute)
|
|
162
|
+
format('%<hour>02d:%<minute>02d', hour: hour, minute: minute)
|
|
163
|
+
end
|
|
164
|
+
private_class_method :format_time
|
|
165
|
+
|
|
166
|
+
def interval_unit(count, singular:, plural:)
|
|
167
|
+
key = count == 1 ? singular : plural
|
|
168
|
+
translate_phrase("time.#{key}")
|
|
169
|
+
end
|
|
170
|
+
private_class_method :interval_unit
|
|
171
|
+
|
|
172
|
+
def macro?(expression)
|
|
173
|
+
expression.start_with?('@')
|
|
174
|
+
end
|
|
175
|
+
private_class_method :macro?
|
|
176
|
+
|
|
177
|
+
def translate_phrase(key, **)
|
|
178
|
+
I18n.t("kaal.#{key}", **)
|
|
179
|
+
end
|
|
180
|
+
private_class_method :translate_phrase
|
|
181
|
+
end
|
|
182
|
+
end
|