kaal 0.2.1 → 0.3.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +81 -286
  3. data/Rakefile +4 -2
  4. data/config/kaal.rb +15 -0
  5. data/config/scheduler.yml +12 -0
  6. data/{lib/tasks/kaal_tasks.rake → exe/kaal} +5 -3
  7. data/lib/kaal/backend/adapter.rb +0 -1
  8. data/lib/kaal/backend/dispatch_attempt_logger.rb +33 -0
  9. data/lib/kaal/backend/dispatch_logging.rb +36 -23
  10. data/lib/kaal/backend/dispatch_registry_accessor.rb +43 -0
  11. data/lib/kaal/backend/memory_adapter.rb +7 -5
  12. data/lib/kaal/backend/redis_adapter.rb +6 -6
  13. data/lib/kaal/cli.rb +230 -0
  14. data/lib/kaal/{configuration.rb → config/configuration.rb} +0 -1
  15. data/lib/kaal/{scheduler_config_error.rb → config/scheduler_config_error.rb} +0 -1
  16. data/lib/kaal/config/scheduler_time_zone_resolver.rb +50 -0
  17. data/lib/kaal/config.rb +19 -0
  18. data/lib/kaal/{coordinator.rb → core/coordinator.rb} +42 -62
  19. data/lib/kaal/core/enabled_entry_enumerator.rb +51 -0
  20. data/lib/kaal/core/occurrence_finder.rb +38 -0
  21. data/lib/kaal/core.rb +18 -0
  22. data/lib/kaal/definition/memory_engine.rb +11 -18
  23. data/lib/kaal/definition/persistence_helpers.rb +31 -0
  24. data/lib/kaal/definition/redis_engine.rb +9 -6
  25. data/lib/kaal/definition/registry.rb +24 -2
  26. data/lib/kaal/definitions/registration_service.rb +62 -0
  27. data/lib/kaal/definitions/registry_accessor.rb +33 -0
  28. data/lib/kaal/dispatch/memory_engine.rb +3 -4
  29. data/lib/kaal/dispatch/redis_engine.rb +2 -3
  30. data/lib/kaal/dispatch/registry.rb +0 -1
  31. data/lib/kaal/register_conflict_support.rb +0 -1
  32. data/lib/kaal/registry.rb +0 -1
  33. data/lib/kaal/runtime/runtime_context.rb +41 -0
  34. data/lib/kaal/runtime/scheduler_boot_loader.rb +52 -0
  35. data/lib/kaal/runtime/signal_handler_chain.rb +42 -0
  36. data/lib/kaal/runtime/signal_handler_installer.rb +39 -0
  37. data/lib/kaal/runtime.rb +20 -0
  38. data/lib/kaal/scheduler_file/hash_transform.rb +22 -0
  39. data/lib/kaal/scheduler_file/helper_bundle.rb +28 -0
  40. data/lib/kaal/scheduler_file/job_applier.rb +242 -0
  41. data/lib/kaal/scheduler_file/job_normalizer.rb +90 -0
  42. data/lib/kaal/scheduler_file/loader.rb +152 -0
  43. data/lib/kaal/scheduler_file/payload_loader.rb +95 -0
  44. data/lib/kaal/{scheduler_placeholder_support.rb → scheduler_file/placeholder_support.rb} +0 -1
  45. data/lib/kaal/scheduler_file.rb +18 -0
  46. data/lib/kaal/support/hash_tools.rb +93 -0
  47. data/lib/kaal/{cron_humanizer.rb → utils/cron_humanizer.rb} +19 -1
  48. data/lib/kaal/{cron_utils.rb → utils/cron_utils.rb} +0 -1
  49. data/lib/kaal/{idempotency_key_generator.rb → utils/idempotency_key_generator.rb} +3 -3
  50. data/lib/kaal/utils.rb +18 -0
  51. data/lib/kaal/version.rb +1 -2
  52. data/lib/kaal.rb +77 -397
  53. metadata +64 -44
  54. data/app/models/kaal/cron_definition.rb +0 -76
  55. data/app/models/kaal/cron_dispatch.rb +0 -50
  56. data/app/models/kaal/cron_lock.rb +0 -38
  57. data/lib/generators/kaal/install/install_generator.rb +0 -72
  58. data/lib/generators/kaal/install/templates/create_kaal_definitions.rb.tt +0 -21
  59. data/lib/generators/kaal/install/templates/create_kaal_dispatches.rb.tt +0 -20
  60. data/lib/generators/kaal/install/templates/create_kaal_locks.rb.tt +0 -17
  61. data/lib/generators/kaal/install/templates/kaal.rb.tt +0 -31
  62. data/lib/generators/kaal/install/templates/scheduler.yml.tt +0 -22
  63. data/lib/kaal/backend/mysql_adapter.rb +0 -170
  64. data/lib/kaal/backend/postgres_adapter.rb +0 -134
  65. data/lib/kaal/backend/sqlite_adapter.rb +0 -116
  66. data/lib/kaal/definition/database_engine.rb +0 -50
  67. data/lib/kaal/dispatch/database_engine.rb +0 -94
  68. data/lib/kaal/railtie.rb +0 -183
  69. data/lib/kaal/rake_tasks.rb +0 -184
  70. data/lib/kaal/scheduler_file_loader.rb +0 -321
  71. data/lib/kaal/scheduler_hash_transform.rb +0 -45
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3171d908e9cb61c59b265540ca3f8fbfece4ac67081b4fab19b45c5b371994e8
4
- data.tar.gz: 19a7540e6594434fe227532fcd4a92435c57cb436a945c7b1f8a05d77e3c94bd
3
+ metadata.gz: 54a0eb1cebfc4adc18c4b4ed105d47bb26a84d3512fbce891243a356bca5715a
4
+ data.tar.gz: dd1fb18c3060c4688ea0f96c5628cd40001fb318b7745d411c46108fa06b45b3
5
5
  SHA512:
6
- metadata.gz: 2b28d2b723591834f9425b0ae9044a73e83fc8aa61c9b186a283c18e7282d91d461b8601b59c62e536c2fdc9d27c4a64b518fa438802577556166b8a529e996a
7
- data.tar.gz: 5a1ea96d68f3e8b482b0e9d28639e390e2af5764a8a82138a8a5cd5e7c60c1d8ef7acf0fe808471066f5f3f462dbaab4c3cb96e12fca33d2ba13ffd46cbb797b
6
+ metadata.gz: fda7173306897889b750707be46778bfc0b4893ce87759f30f060a37879212eab2731316a31c875acdcf0e7aad9db1caddbc11e7c0b0b16e3707edbfd1b1eec3
7
+ data.tar.gz: a8d64f8ca728665167e8be7ad5e1e6a417066f6016ff2b838aa47cde5aa3369045b1ea2c3aaf326ce4b00cc94d0f519ebe464978660ad13cc29f6db90cf3c834
data/README.md CHANGED
@@ -1,340 +1,135 @@
1
- # Kaal
1
+ # Kaal
2
2
 
3
- > Kaal is a distributed cron scheduler for Ruby that safely executes scheduled tasks across multiple nodes.
3
+ Distributed cron scheduling for plain Ruby.
4
4
 
5
- [![Gem](https://img.shields.io/gem/v/kaal.svg?style=flat-square)](https://rubygems.org/gems/kaal)
6
- [![CI](https://github.com/Code-Vedas/kaal/actions/workflows/ci.yml/badge.svg)](https://github.com/Code-Vedas/kaal/actions/workflows/ci.yml)
7
- [![Maintainability](https://qlty.sh/gh/Code-Vedas/projects/kaal/maintainability.svg)](https://qlty.sh/gh/Code-Vedas/projects/kaal)
8
- [![Code Coverage](https://qlty.sh/gh/Code-Vedas/projects/kaal/coverage.svg)](https://qlty.sh/gh/Code-Vedas/projects/kaal)
9
- ![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)
10
- ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-12%2B-336791?style=flat-square&logo=postgresql&logoColor=white)
11
- ![Redis](https://img.shields.io/badge/Redis-6%2B-d92b2b?style=flat-square&logo=redis&logoColor=white)
5
+ `kaal` is the core engine gem. It owns scheduler/runtime behavior, the registry APIs, and the plain Ruby CLI. SQL persistence lives in adapter gems such as `kaal-sequel` and `kaal-activerecord`.
12
6
 
13
- ---
7
+ ## Installation
14
8
 
15
- ## Overview
16
-
17
- `kaal` lets you **bind cron expressions to Ruby code or shell commands** without depending on any specific job system or scheduler.
18
-
19
- It guarantees that:
20
-
21
- - Each scheduled tick **enqueues work exactly once** across all running Ruby nodes.
22
- - You remain **agnostic** to your background job system (`ActiveJob`, `Sidekiq`, `Resque`, etc.).
23
- - Locks are coordinated safely via **Redis** or **PostgreSQL advisory locks**.
24
- - Cron syntax can be **validated, linted, humanized, and translated** with i18n.
25
-
26
- ---
27
-
28
- ## 🧩 Why Kaal?
29
-
30
- | Problem | Kaal Solution |
31
- | ------------------------------------ | ----------------------------------------------------- |
32
- | Multiple nodes running the same cron | Distributed locks → exactly-once execution |
33
- | Cron syntax not human-friendly | Built-in parser + `to_human` translations |
34
- | Missed runs during downtime | Configurable _lookback_ window replays missed ticks |
35
- | Coupled to job system | Scheduler-agnostic, works with any Ruby queue backend |
36
-
37
- ---
38
-
39
- ## ⚙️ Installation
40
-
41
- Add to your Gemfile:
9
+ Use `kaal` by itself when you want the engine plus non-SQL coordination backends.
42
10
 
43
11
  ```ruby
44
- gem "kaal"
12
+ gem 'kaal'
45
13
  ```
46
14
 
47
- Then run:
15
+ Then install and initialize:
48
16
 
49
17
  ```bash
50
18
  bundle install
51
- bin/rails g kaal:install --backend=sqlite
19
+ bundle exec kaal init --backend=memory
52
20
  ```
53
21
 
54
- The install generator creates `config/initializers/kaal.rb` and, for database-backed backends, generates only the migrations you need:
22
+ `kaal init` creates:
55
23
 
56
- - `--backend=sqlite`: dispatches, locks, and definitions tables
57
- - `--backend=postgres` or `--backend=mysql`: dispatches and definitions tables
58
- - `--backend=redis` or `--backend=memory`: no database migrations
59
-
60
- Example initializer (`config/initializers/kaal.rb`):
61
-
62
- ```ruby
63
- Kaal.configure do |c|
64
- # Choose your backend adapter
65
- # See the Kaal documentation for backend-specific setup and the full
66
- # configuration reference.
67
- #
68
- # Redis (recommended)
69
- # c.backend = Kaal::Backend::RedisAdapter.new(Redis.new(url: ENV["REDIS_URL"]))
70
-
71
- # or Postgres advisory locks
72
- # c.backend = Kaal::Backend::PostgresAdapter.new
73
-
74
- c.tick_interval = 5 # seconds between scheduler ticks
75
- c.window_lookback = 120 # recover missed runs (seconds)
76
- c.lease_ttl = 125 # must be >= window_lookback + tick_interval
77
- c.recovery_window = 3600
78
- c.enable_dispatch_recovery = true
79
- c.enable_log_dispatch_registry = false
80
-
81
- # Scheduler file loading
82
- c.scheduler_config_path = "config/scheduler.yml"
83
- c.scheduler_conflict_policy = :error # :error, :code_wins, :file_wins
84
- c.scheduler_missing_file_policy = :warn # :warn, :error
85
- end
86
- ```
87
-
88
- Example scheduler file (`config/scheduler.yml`):
89
-
90
- ```yaml
91
- defaults:
92
- jobs:
93
- - key: "reports:weekly_summary"
94
- cron: "0 9 * * 1"
95
- job_class: "WeeklySummaryJob"
96
- enabled: true
97
- queue: "default"
98
- args:
99
- - "{{fire_time.iso8601}}"
100
- kwargs:
101
- idempotency_key: "{{idempotency_key}}"
102
- metadata:
103
- owner: "ops"
24
+ - `config/kaal.rb`
25
+ - `config/scheduler.yml`
104
26
 
105
- production:
106
- jobs:
107
- - key: "reports:daily_digest"
108
- cron: "<%= ENV.fetch('DAILY_DIGEST_CRON', '0 7 * * *') %>"
109
- job_class: "DailyDigestJob"
110
- ```
27
+ Supported backends:
111
28
 
112
- `scheduler.yml` supports ERB and environment sections (`defaults`, `development`, `test`, `production`, etc.).
113
- Allowed runtime placeholders in `args` and `kwargs` values (not keys): `{{fire_time.iso8601}}`, `{{fire_time.unix}}`, `{{idempotency_key}}`, `{{key}}`.
29
+ - `memory`
30
+ - `redis`
114
31
 
115
- 👉 [See full installation guide →](https://kaal.codevedas.com/install)
32
+ If you want SQL persistence instead, add one of:
116
33
 
117
- ---
34
+ - `kaal-sequel` for Sequel-backed SQL in plain Ruby
35
+ - `kaal-activerecord` for Active Record-backed SQL in plain Ruby
36
+ - `kaal-rails` for Rails
118
37
 
119
- ## 🚀 Quick Start
38
+ ## Configuration
120
39
 
121
- Register a scheduled job anywhere during boot (e.g., `config/initializers/kaal_jobs.rb`):
40
+ Generated `config/kaal.rb` is the primary entrypoint:
122
41
 
123
42
  ```ruby
124
- Kaal.register(
125
- key: "reports:weekly_summary",
126
- cron: "0 9 * * 1", # every Monday at 9 AM
127
- enqueue: ->(fire_time:, idempotency_key:) {
128
- WeeklySummaryJob.perform_later(fire_time: fire_time, key: idempotency_key)
129
- }
130
- )
131
- ```
132
-
133
- Start the scheduler:
134
-
135
- `Kaal` does **not** auto-start by default. Start it explicitly via `Kaal.start!` or `kaal:start`.
136
-
137
- ```bash
138
- bundle exec rails kaal:start
139
- ```
140
-
141
- > 💡 Recommended: run as a **dedicated process** in production (Procfile, systemd, Kubernetes).
142
-
143
- ---
144
-
145
- ## 🧰 CLI & Rake Tasks
146
-
147
- ### CLI Examples
148
-
149
- ```bash
150
- $ kaal explain "*/15 * * * *"
151
- Every 15 minutes
152
-
153
- $ kaal next "0 9 * * 1" --count 3
154
- 2025-11-03 09:00:00 UTC
155
- 2025-11-10 09:00:00 UTC
156
- 2025-11-17 09:00:00 UTC
157
- ```
158
-
159
- ### Rails Tasks
160
-
161
- ```bash
162
- bin/rails kaal:start # Start scheduler loop
163
- bin/rails kaal:status # Show registry & configuration
164
- bin/rails kaal:tick # Trigger one tick manually
165
- bin/rails kaal:explain["*/5 * * * *"] # Humanize cron expression
43
+ require 'kaal'
44
+
45
+ Kaal.configure do |config|
46
+ config.backend = Kaal::Backend::MemoryAdapter.new
47
+ config.tick_interval = 5
48
+ config.window_lookback = 120
49
+ config.lease_ttl = 125
50
+ config.scheduler_config_path = 'config/scheduler.yml'
51
+ end
166
52
  ```
167
53
 
168
- ---
169
-
170
- ## 🧠 Cron Utilities
54
+ Redis path:
171
55
 
172
56
  ```ruby
173
- Kaal.valid?("0 * * * *") # => true
174
- Kaal.simplify("0 0 * * *") # => "@daily"
175
- Kaal.lint("*/61 * * * *") # => ["invalid minute step: 61"]
176
-
177
- I18n.locale = :fr
178
- Kaal.to_human("0 9 * * 1") # => "À 09h00 chaque lundi"
179
- ```
180
-
181
- > 🈶 All tokens are i18n-based — override translations under `kaal.*` keys.
57
+ require 'redis'
182
58
 
183
- ---
59
+ redis = Redis.new(url: ENV.fetch('REDIS_URL'))
184
60
 
185
- ## 🧱 Running the Scheduler
186
-
187
- Run the scheduler as a dedicated process in production.
188
- Do not run it inside web server processes by default.
61
+ Kaal.configure do |config|
62
+ config.backend = Kaal::Backend::RedisAdapter.new(redis)
63
+ config.scheduler_config_path = 'config/scheduler.yml'
64
+ end
65
+ ```
189
66
 
190
- **Procfile (process manager):**
67
+ Time zone behavior is explicit:
191
68
 
192
- ```procfile
193
- web: bundle exec puma -C config/puma.rb
194
- scheduler: bundle exec rails kaal:start
195
- ```
69
+ - use `config.time_zone = 'America/Toronto'` when needed
70
+ - otherwise scheduling runs in `UTC`
196
71
 
197
- **systemd unit:**
198
-
199
- ```ini
200
- [Unit]
201
- Description=Kaal scheduler
202
- After=network.target
203
-
204
- [Service]
205
- Type=simple
206
- User=deploy
207
- WorkingDirectory=/var/apps/myapp/current
208
- Environment=RAILS_ENV=production
209
- ExecStart=/usr/bin/bash -lc 'bundle exec rails kaal:start'
210
- ExecStartPre=/usr/bin/bash -lc 'bundle exec rails kaal:status'
211
- ExecReload=/bin/kill -TERM $MAINPID
212
- Restart=always
213
- RestartSec=5
214
-
215
- [Install]
216
- WantedBy=multi-user.target
217
- ```
72
+ ## Scheduler File
218
73
 
219
- **Kubernetes Deployment:**
74
+ Default scheduler definitions live at `config/scheduler.yml`:
220
75
 
221
76
  ```yaml
222
- apiVersion: apps/v1
223
- kind: Deployment
224
- metadata:
225
- name: kaal-scheduler
226
- spec:
227
- replicas: 1
228
- selector:
229
- matchLabels:
230
- app: kaal-scheduler
231
- template:
232
- metadata:
233
- labels:
234
- app: kaal-scheduler
235
- spec:
236
- containers:
237
- - name: scheduler
238
- image: your-app:latest
239
- command: ["bash", "-lc", "bundle exec rails kaal:start"]
77
+ defaults:
78
+ jobs:
79
+ - key: "example:heartbeat"
80
+ cron: "*/5 * * * *"
81
+ job_class: "ExampleHeartbeatJob"
82
+ enabled: true
83
+ args:
84
+ - "{{fire_time.iso8601}}"
85
+ kwargs:
86
+ idempotency_key: "{{idempotency_key}}"
240
87
  ```
241
88
 
242
- For Kubernetes, the scheduler process is the container's main process; if it exits, Kubernetes restarts it.
243
- Do not use `kaal:status` as a liveness/readiness probe for scheduler thread health because it runs in a separate process.
244
-
245
- Use one of these for health checks:
246
-
247
- - Process-level checks from your runtime/supervisor for the main scheduler process.
248
- - A shared heartbeat/lease signal (Redis, Postgres, pidfile, etc.) written by the scheduler and read by probes.
249
-
250
- ---
251
-
252
- ## 🔍 Troubleshooting
253
-
254
- | Symptom | Likely Cause | Fix |
255
- | -------------------------- | --------------------- | ---------------------------------------- |
256
- | Jobs run multiple times | Using memory lock | Use Redis or Postgres adapter |
257
- | Missed jobs after downtime | Short lookback window | Increase `window_lookback` |
258
- | Scheduler exits early | Normal SIGTERM | Exits gracefully after tick |
259
- | Redis timeout | Network latency | Increase Redis timeout or switch adapter |
89
+ `job_class` must resolve to a Ruby constant that responds to one of:
260
90
 
261
- 📖 [See FAQ →](https://kaal.codevedas.com/faq)
91
+ - `.perform(*args, **kwargs)`
92
+ - `.perform_later(*args, **kwargs)`
93
+ - `.set(queue: ...).perform_later(*args, **kwargs)`
262
94
 
263
- ---
95
+ For plain Ruby `.perform` jobs, Kaal treats the dispatch as successful unless the job raises an exception. Return values are ignored.
264
96
 
265
- ## 🧪 Testing Example
266
-
267
- ```ruby
268
- RSpec.describe "multi-node safety" do
269
- it "dispatches exactly once across two threads" do
270
- redis = FakeRedis::Redis.new
271
- lock = Kaal::Backend::RedisAdapter.new(redis)
272
- Kaal.configure { |c| c.backend = lock }
273
-
274
- threads = 2.times.map { Thread.new { Kaal.tick! } }
275
- threads.each(&:join)
276
-
277
- expect(redis.keys.grep(/dispatch/).size).to eq(1)
278
- end
279
- end
280
- ```
281
-
282
- ### Local Test Commands
283
-
284
- Run the fast unit suite from the gem directory:
97
+ ## CLI
285
98
 
286
99
  ```bash
287
- bin/rspec-unit
100
+ bundle exec kaal init --backend=memory
101
+ bundle exec kaal start
102
+ bundle exec kaal status
103
+ bundle exec kaal tick
104
+ bundle exec kaal explain "*/15 * * * *"
105
+ bundle exec kaal next "0 9 * * 1" --count 3
288
106
  ```
289
107
 
290
- Run end-to-end adapter coverage with the same entrypoint used in CI:
108
+ ## E2E Verification
291
109
 
292
110
  ```bash
293
111
  bin/rspec-e2e memory
294
- bin/rspec-e2e sqlite
295
- DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/kaal_test bin/rspec-e2e pg
296
- DATABASE_URL=mysql2://root:rootROOT\!1@127.0.0.1:3306/kaal_test bin/rspec-e2e mysql
297
112
  REDIS_URL=redis://127.0.0.1:6379/0 bin/rspec-e2e redis
298
113
  ```
299
114
 
300
- `pg` and `mysql` require `DATABASE_URL`. `redis` requires `REDIS_URL`. `memory` and `sqlite` use local test defaults.
301
-
302
- ---
303
-
304
- ## 🧩 Roadmap
305
-
306
- | Area | Description | Label |
307
- | ---------------- | -------------------------------- | --------- |
308
- | Registry & API | Cron registration and validation | `feature` |
309
- | Coordinator Loop | Safe ticking and dispatch | `feature` |
310
- | Lock Adapters | Redis / Postgres | `build` |
311
- | CLI Tool | `kaal` executable | `build` |
312
- | i18n Humanizer | Multi-language support | `lang` |
313
- | Docs & Examples | Developer onboarding | `lang` |
115
+ ## Runtime API
314
116
 
315
- ---
316
-
317
- ## 🤝 Contributing
318
-
319
- 1. Fork the repository
320
- 2. Create a feature branch (`git checkout -b feature/my-feature`)
321
- 3. Run tests (`bundle exec rspec`)
322
- 4. Open a PR using the [Feature Request template](.github/ISSUE_TEMPLATE/feature_request.md)
323
-
324
- Labels: `feature`, `build`, `ci`, `lang`
325
-
326
- ---
327
-
328
- ## 📚 Documentation
117
+ ```ruby
118
+ Kaal.register(
119
+ key: 'reports:daily',
120
+ cron: '0 9 * * *',
121
+ enqueue: ->(fire_time:, idempotency_key:) {
122
+ ReportsJob.perform(fire_time: fire_time, idempotency_key: idempotency_key)
123
+ }
124
+ )
329
125
 
330
- - [Overview & Motivation](https://kaal.codevedas.com)
331
- - [Installation](https://kaal.codevedas.com/install)
332
- - [Usage](https://kaal.codevedas.com/usage)
333
- - [FAQ / Troubleshooting](https://kaal.codevedas.com/faq)
126
+ Kaal.start!
127
+ ```
334
128
 
335
- ---
129
+ ## Adapter Gems
336
130
 
337
- ## 📄 License
131
+ Use adapter gems when you want persisted SQL registries:
338
132
 
339
- Released under the [MIT License](LICENSE).
340
- © 2025 **Codevedas Inc.** All rights reserved.
133
+ - `kaal-sequel` for Sequel-backed persistence
134
+ - `kaal-activerecord` for Active Record-backed persistence
135
+ - `kaal-rails` for Rails plugin integration over `kaal-activerecord`
data/Rakefile CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
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.
3
7
  require 'bundler/setup'
4
- APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__)
5
- load 'rails/tasks/engine.rake'
6
8
  require 'bundler/gem_tasks'
data/config/kaal.rb ADDED
@@ -0,0 +1,15 @@
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
+ require 'kaal'
8
+
9
+ Kaal.configure do |config|
10
+ config.backend = Kaal::Backend::MemoryAdapter.new
11
+ config.tick_interval = 5
12
+ config.window_lookback = 120
13
+ config.lease_ttl = 125
14
+ config.scheduler_config_path = 'config/scheduler.yml'
15
+ end
@@ -0,0 +1,12 @@
1
+ defaults:
2
+ jobs:
3
+ - key: "example:heartbeat"
4
+ cron: "*/5 * * * *"
5
+ job_class: "ExampleHeartbeatJob"
6
+ enabled: true
7
+ args:
8
+ - "{{fire_time.iso8601}}"
9
+ kwargs:
10
+ idempotency_key: "{{idempotency_key}}"
11
+ metadata:
12
+ owner: "ops"
@@ -1,10 +1,12 @@
1
+ #!/usr/bin/env ruby
1
2
  # frozen_string_literal: true
2
3
 
3
4
  # Copyright Codevedas Inc. 2025-present
4
5
  #
5
6
  # This source code is licensed under the MIT license found in the
6
7
  # LICENSE file in the root directory of this source tree.
8
+ gemfile_path = File.expand_path('../Gemfile', __dir__)
9
+ require 'bundler/setup' if File.exist?(gemfile_path)
10
+ require 'kaal/cli'
7
11
 
8
- require 'kaal/rake_tasks'
9
-
10
- Kaal::RakeTasks.install
12
+ Kaal::CLI.start(ARGV)
@@ -4,7 +4,6 @@
4
4
  #
5
5
  # This source code is licensed under the MIT license found in the
6
6
  # LICENSE file in the root directory of this source tree.
7
-
8
7
  module Kaal
9
8
  module Backend
10
9
  ##
@@ -0,0 +1,33 @@
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
+ require 'socket'
8
+
9
+ module Kaal
10
+ module Backend
11
+ # Logs dispatch attempts through a backend-provided dispatch registry.
12
+ class DispatchAttemptLogger
13
+ def initialize(configuration:, dispatch_registry_provider:, logger: nil, node_id_provider: Socket.method(:gethostname))
14
+ @configuration = configuration
15
+ @dispatch_registry_provider = dispatch_registry_provider
16
+ @logger = logger
17
+ @node_id_provider = node_id_provider
18
+ end
19
+
20
+ def call(lock_key)
21
+ return unless @configuration.enable_log_dispatch_registry
22
+
23
+ registry = @dispatch_registry_provider.call
24
+ return unless registry
25
+
26
+ cron_key, fire_time = DispatchLogging.parse_lock_key(lock_key)
27
+ registry.log_dispatch(cron_key, fire_time, @node_id_provider.call, 'dispatched')
28
+ rescue StandardError => e
29
+ (@logger || @configuration.logger)&.error("Failed to log dispatch for #{lock_key}: #{e.message}")
30
+ end
31
+ end
32
+ end
33
+ end
@@ -4,8 +4,7 @@
4
4
  #
5
5
  # This source code is licensed under the MIT license found in the
6
6
  # LICENSE file in the root directory of this source tree.
7
-
8
- require 'socket'
7
+ require_relative 'dispatch_attempt_logger'
9
8
 
10
9
  module Kaal
11
10
  module Backend
@@ -25,6 +24,10 @@ module Kaal
25
24
  # end
26
25
  # end
27
26
  module DispatchLogging
27
+ def dispatch_registry
28
+ nil
29
+ end
30
+
28
31
  ##
29
32
  # Log a dispatch attempt via the dispatch registry.
30
33
  #
@@ -33,23 +36,7 @@ module Kaal
33
36
  # @param key [String] the lock key (format: "namespace:dispatch:cron_key:fire_time")
34
37
  # @return [void]
35
38
  def log_dispatch_attempt(key)
36
- logger = nil
37
- logging_enabled = Kaal.configuration.then do |configuration|
38
- logger = configuration.logger
39
- configuration.enable_log_dispatch_registry
40
- end
41
- return unless logging_enabled
42
- return unless respond_to?(:dispatch_registry)
43
-
44
- registry = dispatch_registry
45
- return unless registry
46
-
47
- cron_key, fire_time = parse_lock_key(key)
48
- node_id = Socket.gethostname
49
-
50
- registry.log_dispatch(cron_key, fire_time, node_id, 'dispatched')
51
- rescue StandardError => e
52
- logger&.error("Failed to log dispatch for #{key}: #{e.message}")
39
+ dispatch_attempt_logger.call(key)
53
40
  end
54
41
 
55
42
  ##
@@ -67,13 +54,39 @@ module Kaal
67
54
 
68
55
  def self.parse_lock_key(key)
69
56
  parts = key.split(':')
70
- fire_time_unix = parts.pop.to_i
71
- 2.times { parts.shift } # Remove namespace and "dispatch"
72
- cron_key = parts.join(':')
73
- fire_time = Time.at(fire_time_unix)
57
+ invalid_message = "Invalid dispatch lock key format: #{key.inspect}"
58
+ dispatch_index = parts[0...-1].rindex('dispatch')
59
+ timestamp = parts[-1]
60
+ valid_key = parts.length >= 4 && dispatch_index&.positive? && timestamp.match?(/\A\d+\z/)
61
+ validate_lock_key!(valid_key, invalid_message)
62
+
63
+ fire_time_unix = timestamp.to_i
64
+ cron_key = parts[(dispatch_index + 1)...-1].join(':')
65
+ validate_lock_key!(!cron_key.empty?, invalid_message)
66
+
67
+ fire_time = Time.at(fire_time_unix).utc
74
68
 
75
69
  [cron_key, fire_time]
76
70
  end
71
+
72
+ def self.validate_lock_key!(valid, message)
73
+ invalid_dispatch_lock_key!(message) unless valid
74
+ end
75
+ private_class_method :validate_lock_key!
76
+
77
+ def self.invalid_dispatch_lock_key!(message)
78
+ raise ArgumentError, message
79
+ end
80
+ private_class_method :invalid_dispatch_lock_key!
81
+
82
+ private
83
+
84
+ def dispatch_attempt_logger
85
+ @dispatch_attempt_logger ||= DispatchAttemptLogger.new(
86
+ configuration: Kaal.configuration,
87
+ dispatch_registry_provider: -> { dispatch_registry }
88
+ )
89
+ end
77
90
  end
78
91
  end
79
92
  end
@@ -0,0 +1,43 @@
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
+ module Kaal
8
+ module Backend
9
+ # Reads dispatch registry state through the configured backend adapter.
10
+ class DispatchRegistryAccessor
11
+ def initialize(configuration:)
12
+ @configuration = configuration
13
+ end
14
+
15
+ def dispatched?(key, fire_time)
16
+ registry = fetch_registry
17
+ return false unless registry
18
+
19
+ registry.dispatched?(key, fire_time)
20
+ rescue StandardError => e
21
+ @configuration.logger&.warn("Error checking dispatch status for #{key}: #{e.message}")
22
+ false
23
+ end
24
+
25
+ def registry
26
+ fetch_registry
27
+ rescue StandardError => e
28
+ @configuration.logger&.warn("Error accessing dispatch registry: #{e.message}")
29
+ nil
30
+ end
31
+
32
+ private
33
+
34
+ def fetch_registry
35
+ adapter = @configuration.backend
36
+ return nil unless adapter
37
+ return nil unless adapter.respond_to?(:dispatch_registry)
38
+
39
+ adapter.dispatch_registry
40
+ end
41
+ end
42
+ end
43
+ end