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
data/lib/kaal.rb ADDED
@@ -0,0 +1,571 @@
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 'kaal/version'
9
+ require 'kaal/configuration'
10
+ require 'kaal/registry'
11
+ require 'kaal/dispatch/registry'
12
+ require 'kaal/dispatch/memory_engine'
13
+ require 'kaal/dispatch/redis_engine'
14
+ require 'kaal/dispatch/database_engine'
15
+ require 'kaal/definition/registry'
16
+ require 'kaal/definition/memory_engine'
17
+ require 'kaal/definition/redis_engine'
18
+ require 'kaal/definition/database_engine'
19
+ require 'kaal/backend/adapter'
20
+ require 'kaal/backend/memory_adapter'
21
+ require 'kaal/backend/redis_adapter'
22
+ require 'kaal/backend/postgres_adapter'
23
+ require 'kaal/backend/mysql_adapter'
24
+ require 'kaal/backend/sqlite_adapter'
25
+ require 'kaal/idempotency_key_generator'
26
+ require 'kaal/cron_utils'
27
+ require 'kaal/cron_humanizer'
28
+ require 'kaal/scheduler_config_error'
29
+ require 'kaal/scheduler_file_loader'
30
+ require 'kaal/register_conflict_support'
31
+ require 'kaal/rake_tasks'
32
+ require 'kaal/coordinator'
33
+ require 'kaal/railtie'
34
+
35
+ ##
36
+ # Kaal module is the main namespace for the gem.
37
+ # Provides configuration, job registration, and registry access.
38
+ #
39
+ # @example Configure Kaal
40
+ # Kaal.configure do |config|
41
+ # config.tick_interval = 5
42
+ # config.backend = Kaal::Backend::RedisAdapter.new(Redis.new(url: ENV["REDIS_URL"]))
43
+ # end
44
+ #
45
+ # @example Register a cron job
46
+ # Kaal.register(
47
+ # key: "reports:daily",
48
+ # cron: "0 9 * * *",
49
+ # enqueue: ->(fire_time:, idempotency_key:) { MyJob.perform_later }
50
+ # )
51
+ module Kaal
52
+ class << self
53
+ include RegisterConflictSupport
54
+
55
+ ##
56
+ # Get the current configuration instance.
57
+ #
58
+ # @return [Configuration] the global configuration object
59
+ def configuration
60
+ @configuration ||= Configuration.new
61
+ end
62
+
63
+ ##
64
+ # Get the current registry instance.
65
+ #
66
+ # @return [Registry] the global registry object
67
+ def registry
68
+ @registry ||= Registry.new
69
+ end
70
+
71
+ ##
72
+ # Get the coordinator instance.
73
+ #
74
+ # @return [Coordinator] the global coordinator object
75
+ def coordinator
76
+ @coordinator ||= Coordinator.new(configuration: configuration, registry: registry)
77
+ end
78
+
79
+ ##
80
+ # Reset configuration to defaults. Primarily used in tests.
81
+ #
82
+ # @return [Configuration] a fresh configuration object
83
+ def reset_configuration!
84
+ @configuration = Configuration.new
85
+ @coordinator = nil # Invalidate coordinator so it rebuilds with new config
86
+ @definition_registry = nil
87
+ end
88
+
89
+ ##
90
+ # Reset registry to empty state. Primarily used in tests.
91
+ #
92
+ # @return [Registry] a fresh registry object
93
+ def reset_registry!
94
+ @registry = Registry.new
95
+ @definition_registry&.clear
96
+ @coordinator = nil # Invalidate coordinator so it rebuilds with new registry
97
+ end
98
+
99
+ ##
100
+ # Reset coordinator to initial state. Primarily used in tests.
101
+ #
102
+ # Stops any running coordinator and creates a fresh instance.
103
+ #
104
+ # @return [Coordinator] a fresh coordinator object
105
+ # @raise [RuntimeError] if the running coordinator cannot be stopped within timeout
106
+ def reset_coordinator!
107
+ # Stop the existing coordinator if it's running
108
+ if @coordinator&.running?
109
+ stopped = @coordinator.stop!
110
+ raise 'Failed to stop coordinator thread within timeout' unless stopped
111
+ end
112
+
113
+ # Create and return a fresh coordinator
114
+ @coordinator = nil
115
+ coordinator
116
+ end
117
+
118
+ ##
119
+ # Configure Kaal via a block.
120
+ #
121
+ # @yield [config] yields the configuration object
122
+ # @yieldparam config [Configuration] the configuration instance to customize
123
+ # @return [void]
124
+ #
125
+ # @example
126
+ # Kaal.configure do |config|
127
+ # config.tick_interval = 10
128
+ # config.lease_ttl = 120
129
+ # end
130
+ def configure
131
+ yield(configuration) if block_given?
132
+ end
133
+
134
+ ##
135
+ # Register a new cron job.
136
+ #
137
+ # @param key [String] unique identifier for the cron task
138
+ # @param cron [String] cron expression (e.g., "0 9 * * *", "@daily")
139
+ # @param enqueue [Proc, Lambda] callable executed when cron fires
140
+ # @return [Registry::Entry] the registered entry
141
+ #
142
+ # @raise [ArgumentError] if parameters are invalid
143
+ # @raise [RegistryError] if key is already registered
144
+ #
145
+ # @example
146
+ # Kaal.register(
147
+ # key: "reports:weekly_summary",
148
+ # cron: "0 9 * * 1",
149
+ # enqueue: ->(fire_time:, idempotency_key:) { WeeklySummaryJob.perform_later }
150
+ # )
151
+ def register(key:, cron:, enqueue:)
152
+ existing_definition = definition_registry.find_definition(key)
153
+ existing_entry = registry.find(key)
154
+ if existing_entry
155
+ conflict_result = resolve_register_conflict(
156
+ key: key,
157
+ cron: cron,
158
+ enqueue: enqueue,
159
+ existing_definition: existing_definition,
160
+ existing_entry: existing_entry
161
+ )
162
+
163
+ return conflict_result if conflict_result
164
+
165
+ raise RegistryError, "Key '#{key}' is already registered"
166
+ end
167
+ persisted_attributes = {
168
+ enabled: true,
169
+ source: 'code',
170
+ metadata: {}
171
+ }.merge(existing_definition&.slice(:enabled, :metadata) || {})
172
+ definition_registry.upsert_definition(key: key, cron: cron, **persisted_attributes)
173
+ with_registered_definition_rollback(key, existing_definition) do
174
+ registry.add(key: key, cron: cron, enqueue: enqueue)
175
+ end
176
+ end
177
+
178
+ ##
179
+ # Load scheduler definitions from the configured scheduler YAML file.
180
+ #
181
+ # @return [Array<Hash>] normalized jobs applied from scheduler file
182
+ # @raise [SchedulerConfigError] if scheduler file is invalid
183
+ def load_scheduler_file!
184
+ loader = SchedulerFileLoader.new(
185
+ configuration: configuration,
186
+ definition_registry: definition_registry,
187
+ registry: registry,
188
+ logger: configuration.logger
189
+ )
190
+ loader.load
191
+ end
192
+
193
+ ##
194
+ # Unregister (remove) a cron job by key.
195
+ #
196
+ # @param key [String] the unique identifier of the job to remove
197
+ # @return [Registry::Entry, nil] the removed entry, or nil if not found
198
+ def unregister(key:)
199
+ definition_registry.remove_definition(key)
200
+ registry.remove(key)
201
+ end
202
+
203
+ def rollback_registered_definition(key, existing_definition)
204
+ if existing_definition
205
+ definition_registry.upsert_definition(
206
+ key: existing_definition[:key],
207
+ cron: existing_definition[:cron],
208
+ enabled: existing_definition[:enabled],
209
+ source: existing_definition[:source],
210
+ metadata: existing_definition[:metadata]
211
+ )
212
+ elsif !registry.registered?(key)
213
+ definition_registry.remove_definition(key)
214
+ end
215
+ end
216
+ private :rollback_registered_definition
217
+
218
+ ##
219
+ # Get all registered cron jobs.
220
+ #
221
+ # @return [Array<Registry::Entry>] array of all registered entries
222
+ #
223
+ # @example
224
+ # Kaal.registered.each { |entry| puts entry.key }
225
+ def registered
226
+ definition_registry.all_definitions.map do |definition|
227
+ key = definition[:key]
228
+ callback = registry.find(key)&.enqueue
229
+ Registry::Entry.new(key: key, cron: definition[:cron], enqueue: callback).freeze
230
+ end
231
+ end
232
+
233
+ ##
234
+ # Check if a cron job is registered by key.
235
+ #
236
+ # @param key [String] the unique identifier to check
237
+ # @return [Boolean] true if the key is registered, false otherwise
238
+ #
239
+ # @example
240
+ # Kaal.registered?(key: "reports:daily") # => true
241
+ def registered?(key:)
242
+ definition_registry.find_definition(key).present?
243
+ end
244
+
245
+ ##
246
+ # Enable a registered cron definition by key.
247
+ #
248
+ # @param key [String] the unique identifier to enable
249
+ # @return [Hash, nil] enabled definition hash or nil if missing
250
+ def enable(key:)
251
+ definition_registry.enable_definition(key)
252
+ end
253
+
254
+ ##
255
+ # Disable a registered cron definition by key.
256
+ #
257
+ # @param key [String] the unique identifier to disable
258
+ # @return [Hash, nil] disabled definition hash or nil if missing
259
+ def disable(key:)
260
+ definition_registry.disable_definition(key)
261
+ end
262
+
263
+ ##
264
+ # Start the scheduler background thread.
265
+ #
266
+ # The coordinator will calculate due fire times for each registered cron
267
+ # on each tick and attempt to dispatch work.
268
+ #
269
+ # @return [Thread] the started thread, or nil if already running
270
+ #
271
+ # @example
272
+ # Kaal.start!
273
+ def start!
274
+ coordinator.start!
275
+ end
276
+
277
+ ##
278
+ # Stop the scheduler gracefully.
279
+ #
280
+ # Signals the coordinator to stop after the current tick completes,
281
+ # then waits for the thread to finish.
282
+ #
283
+ # @param timeout [Integer] seconds to wait for graceful shutdown (default: 30)
284
+ # @return [Boolean] true if stopped successfully
285
+ #
286
+ # @example
287
+ # Kaal.stop!
288
+ # @example
289
+ # Kaal.stop!(timeout: 60)
290
+ def stop!(timeout: 30)
291
+ coordinator.stop!(timeout: timeout)
292
+ end
293
+
294
+ ##
295
+ # Check if the scheduler is currently running.
296
+ #
297
+ # @return [Boolean] true if running, false otherwise
298
+ #
299
+ # @example
300
+ # if Kaal.running?
301
+ # puts "Scheduler is active"
302
+ # end
303
+ def running?
304
+ coordinator.running?
305
+ end
306
+
307
+ ##
308
+ # Restart the scheduler (stop then start).
309
+ #
310
+ # @return [Thread] the started thread
311
+ #
312
+ # @example
313
+ # Kaal.restart!
314
+ def restart!
315
+ coordinator.restart!
316
+ end
317
+
318
+ ##
319
+ # Execute a single scheduler tick manually.
320
+ #
321
+ # This is useful for testing and Rake tasks that want to trigger
322
+ # the scheduler without running the background loop.
323
+ #
324
+ # @return [void]
325
+ #
326
+ # @example
327
+ # Kaal.tick!
328
+ def tick!
329
+ coordinator.tick!
330
+ end
331
+
332
+ ##
333
+ # Generate an idempotency key for a cron job and yield to a block.
334
+ #
335
+ # Useful for advanced use cases where you need to generate an idempotency key
336
+ # outside of the normal enqueue flow, or for internal utilities.
337
+ #
338
+ # @param key [String] the cron job key
339
+ # @param fire_time [Time] the fire time
340
+ # @yield [String] yields the generated idempotency key
341
+ # @return [Object] the result of the block
342
+ #
343
+ # @raise [ArgumentError] if no block is provided
344
+ #
345
+ # @example
346
+ # Kaal.with_idempotency('reports:daily', Time.current) do |idempotency_key|
347
+ # MyJob.perform_later(key: idempotency_key)
348
+ # end
349
+ def with_idempotency(key, fire_time)
350
+ raise ArgumentError, 'block required' unless block_given?
351
+
352
+ idempotency_key = IdempotencyKeyGenerator.call(key, fire_time, configuration: configuration)
353
+ yield(idempotency_key)
354
+ end
355
+
356
+ ##
357
+ # Check if a cron job has already been dispatched for a given fire time.
358
+ #
359
+ # Useful for implementing deduplication logic to prevent duplicate job enqueues.
360
+ # Returns true if dispatch logging is enabled and the job was previously dispatched,
361
+ # returns false if not found or dispatch logging is disabled.
362
+ #
363
+ # Safe to call from enqueue callbacks - will return false on any error (e.g., backend
364
+ # misconfiguration or temporary failure), log via configuration.logger, and never raise.
365
+ #
366
+ # @param key [String] the cron job key
367
+ # @param fire_time [Time] the fire time to check
368
+ # @return [Boolean] true if dispatch exists, false otherwise (never raises)
369
+ #
370
+ # @example
371
+ # Kaal.dispatched?('reports:daily', Time.current)
372
+ # # => true if already dispatched, false otherwise
373
+ def dispatched?(key, fire_time)
374
+ adapter = configuration.backend
375
+ return false if adapter.nil? || !adapter.respond_to?(:dispatch_registry)
376
+
377
+ registry = adapter.dispatch_registry
378
+ return false if registry.nil?
379
+
380
+ registry.dispatched?(key, fire_time)
381
+ rescue StandardError => e
382
+ configuration.logger&.warn("Error checking dispatch status for #{key}: #{e.message}")
383
+ false
384
+ end
385
+
386
+ ##
387
+ # Get the dispatch log registry for querying dispatch history.
388
+ #
389
+ # Returns the underlying dispatch registry engine which allows querying
390
+ # dispatch records. The specific methods available depend on the adapter type:
391
+ #
392
+ # **Common methods (all adapters):**
393
+ # - `find_dispatch(key, fire_time)` - Find a specific dispatch record
394
+ # - `dispatched?(key, fire_time)` - Check if a dispatch exists
395
+ #
396
+ # **Database adapter specific methods:**
397
+ # - `find_by_key(key)` - Find all dispatches for a job key
398
+ # - `find_by_node(node_id)` - Find all dispatches from a node, ordered by fire_time
399
+ # - `find_by_status(status)` - Find dispatches by status ('dispatched', 'failed', etc.)
400
+ # - `cleanup(recovery_window: 86400)` - Delete dispatch records older than window
401
+ #
402
+ # **Redis adapter:**
403
+ # - Uses automatic TTL expiration (no cleanup needed)
404
+ #
405
+ # **Memory adapter (development/testing):**
406
+ # - `clear()` - Clear all stored records
407
+ # - `size()` - Get count of stored records
408
+ #
409
+ # Safe for production diagnostics - will return nil on any error (e.g., backend
410
+ # misconfiguration or temporary failure), log via configuration.logger, and never raise.
411
+ #
412
+ # @return [Dispatch::Registry, nil] the dispatch registry instance, or nil if adapter doesn't support it or on error
413
+ #
414
+ # @example Query dispatches with database adapter
415
+ # registry = Kaal.dispatch_log_registry
416
+ # # Find all dispatches for a job
417
+ # registry.find_by_key('reports:daily')
418
+ # # Find failed attempts
419
+ # registry.find_by_status('failed')
420
+ # # Clean up old records (over 30 days old)
421
+ # registry.cleanup(recovery_window: 30 * 24 * 60 * 60)
422
+ #
423
+ # @example Query dispatches with memory adapter
424
+ # registry = Kaal.dispatch_log_registry
425
+ # record = registry.find_dispatch('reports:daily', Time.current)
426
+ # total = registry.size
427
+ # registry.clear
428
+ def dispatch_log_registry
429
+ adapter = configuration.backend
430
+ return nil if adapter.nil? || !adapter.respond_to?(:dispatch_registry)
431
+
432
+ registry = adapter.dispatch_registry
433
+ return nil if registry.nil?
434
+
435
+ registry
436
+ rescue StandardError => e
437
+ configuration.logger&.warn("Error accessing dispatch registry: #{e.message}")
438
+ nil
439
+ end
440
+
441
+ ##
442
+ # Configuration accessors for convenience.
443
+ def tick_interval
444
+ configuration.tick_interval
445
+ end
446
+
447
+ def tick_interval=(value)
448
+ configuration.tick_interval = value
449
+ end
450
+
451
+ def window_lookback
452
+ configuration.window_lookback
453
+ end
454
+
455
+ def window_lookback=(value)
456
+ configuration.window_lookback = value
457
+ end
458
+
459
+ def window_lookahead
460
+ configuration.window_lookahead
461
+ end
462
+
463
+ def window_lookahead=(value)
464
+ configuration.window_lookahead = value
465
+ end
466
+
467
+ def lease_ttl
468
+ configuration.lease_ttl
469
+ end
470
+
471
+ def lease_ttl=(value)
472
+ configuration.lease_ttl = value
473
+ end
474
+
475
+ def namespace
476
+ configuration.namespace
477
+ end
478
+
479
+ def namespace=(value)
480
+ configuration.namespace = value
481
+ end
482
+
483
+ def backend
484
+ configuration.backend
485
+ end
486
+
487
+ def backend=(value)
488
+ configuration.backend = value
489
+ end
490
+
491
+ ##
492
+ # Definition registry access.
493
+ #
494
+ # Uses backend-provided definition registry when available, otherwise a
495
+ # process-local in-memory fallback.
496
+ #
497
+ # @return [Kaal::Definition::Registry]
498
+ def definition_registry
499
+ configured_backend = configuration.backend
500
+ registry = configured_backend&.definition_registry
501
+ return registry if registry
502
+
503
+ @definition_registry ||= Definition::MemoryEngine.new
504
+ rescue NoMethodError
505
+ @definition_registry ||= Definition::MemoryEngine.new
506
+ end
507
+
508
+ def logger
509
+ configuration.logger
510
+ end
511
+
512
+ def logger=(value)
513
+ configuration.logger = value
514
+ end
515
+
516
+ def time_zone
517
+ configuration.time_zone
518
+ end
519
+
520
+ def time_zone=(value)
521
+ configuration.time_zone = value
522
+ end
523
+
524
+ def validate
525
+ configuration.validate
526
+ end
527
+
528
+ def validate!
529
+ configuration.validate!
530
+ end
531
+
532
+ ##
533
+ # Validate a cron expression.
534
+ #
535
+ # @param expression [String] cron expression
536
+ # @return [Boolean] true if valid, false otherwise
537
+ def valid?(expression)
538
+ CronUtils.valid?(expression)
539
+ end
540
+
541
+ ##
542
+ # Simplify a cron expression to a predefined macro when possible.
543
+ #
544
+ # @param expression [String] cron expression
545
+ # @return [String] simplified expression or canonical input
546
+ # @raise [ArgumentError] when expression is invalid
547
+ def simplify(expression)
548
+ CronUtils.simplify(expression)
549
+ end
550
+
551
+ ##
552
+ # Lint a cron expression and return warnings/errors.
553
+ #
554
+ # @param expression [String] cron expression
555
+ # @return [Array<String>] lint warnings/errors
556
+ def lint(expression)
557
+ CronUtils.lint(expression)
558
+ end
559
+
560
+ ##
561
+ # Convert a cron expression to a human-friendly phrase.
562
+ #
563
+ # @param expression [String] cron expression
564
+ # @param locale [Symbol, String, nil] locale override (defaults to current I18n.locale)
565
+ # @return [String] localized phrase
566
+ # @raise [ArgumentError] when expression is invalid
567
+ def to_human(expression, locale: nil)
568
+ CronHumanizer.to_human(expression, locale: locale)
569
+ end
570
+ end
571
+ end
@@ -0,0 +1,10 @@
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 'kaal/rake_tasks'
9
+
10
+ Kaal::RakeTasks.install