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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d9c81a663455de1a4bc16743a0ec58df8e42ac5d49ec6fa2bf291f131af6772b
|
|
4
|
+
data.tar.gz: d803aba6a93b004c5b97da505a0c7021e0a50b0243c31bd537c2a5d811d04f55
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5f4142e84f230f1d82b6601162124ee9e846df08322486c613d6a35c049fc4d40054c7d1266c41929d9ac63a873609fb00d6b9ba50a9cf92c9d4e003bc6cc2a1
|
|
7
|
+
data.tar.gz: 0caaa0787fa7fb8e13aeb917221be83b657568fda4a6238df2e523fe0945a9a87a9a163ac638d4040cd9ef277c5a88c3c37612643105c0852d12693d04aff44e
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 The kaal Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# ⏰ Kaal
|
|
2
|
+
|
|
3
|
+
> Kaal is a distributed cron scheduler for Ruby that safely executes scheduled tasks across multiple nodes.
|
|
4
|
+
|
|
5
|
+
[](https://rubygems.org/gems/kaal)
|
|
6
|
+
[](https://github.com/Code-Vedas/kaal/actions/workflows/ci.yml)
|
|
7
|
+
[](https://qlty.sh/gh/Code-Vedas/projects/kaal)
|
|
8
|
+
[](https://qlty.sh/gh/Code-Vedas/projects/kaal)
|
|
9
|
+

|
|
10
|
+

|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
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:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
gem "kaal"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Then run:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
bundle install
|
|
51
|
+
bin/rails g kaal:install --backend=sqlite
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The install generator creates `config/initializers/kaal.rb` and, for database-backed backends, generates only the migrations you need:
|
|
55
|
+
|
|
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"
|
|
104
|
+
|
|
105
|
+
production:
|
|
106
|
+
jobs:
|
|
107
|
+
- key: "reports:daily_digest"
|
|
108
|
+
cron: "<%= ENV.fetch('DAILY_DIGEST_CRON', '0 7 * * *') %>"
|
|
109
|
+
job_class: "DailyDigestJob"
|
|
110
|
+
```
|
|
111
|
+
|
|
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}}`.
|
|
114
|
+
|
|
115
|
+
👉 [See full installation guide →](https://kaal.codevedas.com/install)
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 🚀 Quick Start
|
|
120
|
+
|
|
121
|
+
Register a scheduled job anywhere during boot (e.g., `config/initializers/kaal_jobs.rb`):
|
|
122
|
+
|
|
123
|
+
```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
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## 🧠 Cron Utilities
|
|
171
|
+
|
|
172
|
+
```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.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
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.
|
|
189
|
+
|
|
190
|
+
**Procfile (process manager):**
|
|
191
|
+
|
|
192
|
+
```procfile
|
|
193
|
+
web: bundle exec puma -C config/puma.rb
|
|
194
|
+
scheduler: bundle exec rails kaal:start
|
|
195
|
+
```
|
|
196
|
+
|
|
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
|
+
```
|
|
218
|
+
|
|
219
|
+
**Kubernetes Deployment:**
|
|
220
|
+
|
|
221
|
+
```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"]
|
|
240
|
+
```
|
|
241
|
+
|
|
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 |
|
|
260
|
+
|
|
261
|
+
📖 [See FAQ →](https://kaal.codevedas.com/faq)
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
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:
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
bin/rspec-unit
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Run end-to-end adapter coverage with the same entrypoint used in CI:
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
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
|
+
REDIS_URL=redis://127.0.0.1:6379/0 bin/rspec-e2e redis
|
|
298
|
+
```
|
|
299
|
+
|
|
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` |
|
|
314
|
+
|
|
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
|
|
329
|
+
|
|
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)
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## 📄 License
|
|
338
|
+
|
|
339
|
+
Released under the [MIT License](LICENSE).
|
|
340
|
+
© 2025 **Codevedas Inc.** — All rights reserved.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaal
|
|
4
|
+
# Persistent scheduler definition model.
|
|
5
|
+
class CronDefinition < ApplicationRecord
|
|
6
|
+
self.table_name = 'kaal_definitions'
|
|
7
|
+
|
|
8
|
+
before_validation :ensure_metadata
|
|
9
|
+
|
|
10
|
+
validates :key, presence: true, uniqueness: true
|
|
11
|
+
validates :cron, presence: true
|
|
12
|
+
validates :source, presence: true
|
|
13
|
+
validates :enabled, inclusion: { in: [true, false] }
|
|
14
|
+
|
|
15
|
+
scope :enabled, -> { where(enabled: true) }
|
|
16
|
+
scope :disabled, -> { where(enabled: false) }
|
|
17
|
+
scope :by_source, ->(source) { where(source: source) }
|
|
18
|
+
|
|
19
|
+
def self.upsert_definition!(key:, cron:, enabled:, source:, metadata:)
|
|
20
|
+
persist_definition(find_or_initialize_by(key: key), cron:, enabled:, source:, metadata:)
|
|
21
|
+
rescue ActiveRecord::RecordNotUnique
|
|
22
|
+
persist_definition(find_by!(key: key), cron:, enabled:, source:, metadata:)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_definition_hash
|
|
26
|
+
{
|
|
27
|
+
key: key,
|
|
28
|
+
cron: cron,
|
|
29
|
+
enabled: enabled,
|
|
30
|
+
source: source,
|
|
31
|
+
metadata: metadata || {},
|
|
32
|
+
created_at: created_at,
|
|
33
|
+
updated_at: updated_at,
|
|
34
|
+
disabled_at: disabled_at
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def destroy_and_return_definition_hash
|
|
39
|
+
definition_hash = to_definition_hash
|
|
40
|
+
destroy!
|
|
41
|
+
definition_hash
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.persist_definition(record, cron:, enabled:, source:, metadata:)
|
|
45
|
+
disabled_at = if enabled
|
|
46
|
+
nil
|
|
47
|
+
elsif record.new_record? || record.enabled != false
|
|
48
|
+
Time.current
|
|
49
|
+
else
|
|
50
|
+
record.disabled_at
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
record.assign_attributes(
|
|
54
|
+
cron: cron,
|
|
55
|
+
enabled: enabled,
|
|
56
|
+
source: source,
|
|
57
|
+
metadata: metadata,
|
|
58
|
+
disabled_at:
|
|
59
|
+
)
|
|
60
|
+
record.save!
|
|
61
|
+
record
|
|
62
|
+
end
|
|
63
|
+
private_class_method :persist_definition
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def ensure_metadata
|
|
68
|
+
self.metadata ||= {}
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
module Kaal
|
|
9
|
+
##
|
|
10
|
+
# Audit model for tracking cron job dispatch attempts across the cluster.
|
|
11
|
+
#
|
|
12
|
+
# CronDispatch records information about each cron job that was dispatched,
|
|
13
|
+
# including the cron key, scheduled fire time, actual dispatch time,
|
|
14
|
+
# the node that dispatched it, and the dispatch status.
|
|
15
|
+
#
|
|
16
|
+
# This model is used by the PostgreSQL lock adapter for observability and
|
|
17
|
+
# debugging in distributed deployments. You can use this table to:
|
|
18
|
+
# - Verify that jobs are only dispatched once per fire_time
|
|
19
|
+
# - Track which node dispatched each job
|
|
20
|
+
# - Monitor dispatch latency
|
|
21
|
+
# - Audit job execution history
|
|
22
|
+
#
|
|
23
|
+
# @example Query dispatch history
|
|
24
|
+
# Kaal::CronDispatch.where(
|
|
25
|
+
# key: 'send_emails',
|
|
26
|
+
# status: 'dispatched'
|
|
27
|
+
# ).order(fire_time: :desc).limit(10)
|
|
28
|
+
#
|
|
29
|
+
# @example Find all dispatches from a specific node
|
|
30
|
+
# Kaal::CronDispatch.where(node_id: 'worker1').order(dispatched_at: :desc)
|
|
31
|
+
class CronDispatch < ApplicationRecord
|
|
32
|
+
self.table_name = 'kaal_dispatches'
|
|
33
|
+
|
|
34
|
+
##
|
|
35
|
+
# Validations
|
|
36
|
+
validates :key, presence: true
|
|
37
|
+
validates :fire_time, presence: true
|
|
38
|
+
validates :dispatched_at, presence: true
|
|
39
|
+
validates :node_id, presence: true
|
|
40
|
+
validates :status, presence: true, inclusion: { in: %w[dispatched failed] }
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
# Scopes for common queries
|
|
44
|
+
scope :recent, ->(limit = 100) { order(dispatched_at: :desc).limit(limit) }
|
|
45
|
+
scope :by_key, ->(key) { where(key: key) }
|
|
46
|
+
scope :by_node, ->(node_id) { where(node_id: node_id) }
|
|
47
|
+
scope :by_status, ->(status) { where(status: status) }
|
|
48
|
+
scope :since, ->(timestamp) { where('dispatched_at >= ?', timestamp) }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
module Kaal
|
|
9
|
+
##
|
|
10
|
+
# Model for distributed lock records (database-backed).
|
|
11
|
+
#
|
|
12
|
+
# Used by the database-backed adapter (SQLiteAdapter) to store lock state in a database table.
|
|
13
|
+
# Works with any ActiveRecord-supported SQL database (SQLite, PostgreSQL, MySQL, etc.).
|
|
14
|
+
# Each row represents a held lock with an expiration time.
|
|
15
|
+
class CronLock < ApplicationRecord
|
|
16
|
+
self.table_name = 'kaal_locks'
|
|
17
|
+
|
|
18
|
+
validates :key, presence: true, uniqueness: true
|
|
19
|
+
validates :acquired_at, presence: true
|
|
20
|
+
validates :expires_at, presence: true
|
|
21
|
+
|
|
22
|
+
##
|
|
23
|
+
# Find and delete any expired locks (cleanup).
|
|
24
|
+
#
|
|
25
|
+
# @return [Integer] number of locks deleted
|
|
26
|
+
def self.cleanup_expired
|
|
27
|
+
where('expires_at < ?', Time.current).delete_all
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
##
|
|
31
|
+
# Check if this lock is still valid (not expired).
|
|
32
|
+
#
|
|
33
|
+
# @return [Boolean] true if not expired
|
|
34
|
+
def not_expired?
|
|
35
|
+
expires_at > Time.current
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
en:
|
|
2
|
+
kaal:
|
|
3
|
+
every: "every"
|
|
4
|
+
at: "at"
|
|
5
|
+
and: "and"
|
|
6
|
+
time:
|
|
7
|
+
minute: "minute"
|
|
8
|
+
minutes: "minutes"
|
|
9
|
+
hour: "hour"
|
|
10
|
+
hours: "hours"
|
|
11
|
+
day: "day"
|
|
12
|
+
days: "days"
|
|
13
|
+
week: "week"
|
|
14
|
+
weeks: "weeks"
|
|
15
|
+
month: "month"
|
|
16
|
+
months: "months"
|
|
17
|
+
weekdays:
|
|
18
|
+
"0": "Sunday"
|
|
19
|
+
"1": "Monday"
|
|
20
|
+
"2": "Tuesday"
|
|
21
|
+
"3": "Wednesday"
|
|
22
|
+
"4": "Thursday"
|
|
23
|
+
"5": "Friday"
|
|
24
|
+
"6": "Saturday"
|
|
25
|
+
months:
|
|
26
|
+
"1": "January"
|
|
27
|
+
"2": "February"
|
|
28
|
+
"3": "March"
|
|
29
|
+
"4": "April"
|
|
30
|
+
"5": "May"
|
|
31
|
+
"6": "June"
|
|
32
|
+
"7": "July"
|
|
33
|
+
"8": "August"
|
|
34
|
+
"9": "September"
|
|
35
|
+
"10": "October"
|
|
36
|
+
"11": "November"
|
|
37
|
+
"12": "December"
|
|
38
|
+
phrases:
|
|
39
|
+
daily: "Daily"
|
|
40
|
+
weekly: "Weekly"
|
|
41
|
+
monthly: "Monthly"
|
|
42
|
+
hourly: "Hourly"
|
|
43
|
+
yearly: "Yearly"
|
|
44
|
+
at_time: "At %{time}"
|
|
45
|
+
every_interval: "Every %{count} %{unit}"
|
|
46
|
+
cron_expression: "Cron: %{expression}"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'rails/generators/active_record'
|
|
5
|
+
|
|
6
|
+
module Kaal
|
|
7
|
+
module Generators
|
|
8
|
+
# Installs the database migrations needed for the selected backend.
|
|
9
|
+
class InstallGenerator < Rails::Generators::Base
|
|
10
|
+
include Rails::Generators::Migration
|
|
11
|
+
|
|
12
|
+
source_root File.expand_path('templates', __dir__)
|
|
13
|
+
|
|
14
|
+
class_option :backend,
|
|
15
|
+
type: :string,
|
|
16
|
+
default: 'sqlite',
|
|
17
|
+
desc: 'Backend to install migrations for: sqlite, postgres, mysql, redis, memory'
|
|
18
|
+
|
|
19
|
+
def create_initializer
|
|
20
|
+
template 'kaal.rb.tt', 'config/initializers/kaal.rb'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def create_scheduler_config
|
|
24
|
+
template 'scheduler.yml.tt', 'config/scheduler.yml'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def install_migrations
|
|
28
|
+
templates = migration_templates
|
|
29
|
+
return say_status(:skip, "No database migrations required for #{normalized_backend} backend", :yellow) if templates.empty?
|
|
30
|
+
|
|
31
|
+
templates.each do |template_name|
|
|
32
|
+
migration_template "#{template_name}.rb.tt", "db/migrate/#{template_name}.rb"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.next_migration_number(dirname)
|
|
37
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def migration_templates
|
|
43
|
+
case normalized_backend
|
|
44
|
+
when 'sqlite', 'database'
|
|
45
|
+
%w[
|
|
46
|
+
create_kaal_dispatches
|
|
47
|
+
create_kaal_locks
|
|
48
|
+
create_kaal_definitions
|
|
49
|
+
]
|
|
50
|
+
when 'postgres', 'mysql'
|
|
51
|
+
%w[
|
|
52
|
+
create_kaal_dispatches
|
|
53
|
+
create_kaal_definitions
|
|
54
|
+
]
|
|
55
|
+
when 'memory', 'redis'
|
|
56
|
+
[]
|
|
57
|
+
else
|
|
58
|
+
raise Thor::Error, "Unsupported backend '#{options['backend']}'. Use sqlite, postgres, mysql, redis, or memory."
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def normalized_backend
|
|
63
|
+
options['backend'].to_s.downcase
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Creates the persisted cron definitions table for database-backed definitions.
|
|
4
|
+
class CreateKaalDefinitions < ActiveRecord::Migration[7.0]
|
|
5
|
+
def change
|
|
6
|
+
create_table :kaal_definitions do |t|
|
|
7
|
+
t.string :key, null: false, limit: 255
|
|
8
|
+
t.string :cron, null: false, limit: 255
|
|
9
|
+
t.boolean :enabled, null: false, default: true
|
|
10
|
+
t.string :source, null: false, limit: 50, default: 'code'
|
|
11
|
+
t.json :metadata, null: false
|
|
12
|
+
t.datetime :disabled_at
|
|
13
|
+
|
|
14
|
+
t.timestamps
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
add_index :kaal_definitions, :key, unique: true
|
|
18
|
+
add_index :kaal_definitions, :enabled
|
|
19
|
+
add_index :kaal_definitions, :source
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Creates the dispatch records table for database-backed dispatch logging.
|
|
4
|
+
class CreateKaalDispatches < ActiveRecord::Migration[7.0]
|
|
5
|
+
def change
|
|
6
|
+
create_table :kaal_dispatches do |t|
|
|
7
|
+
t.string :key, null: false, limit: 255
|
|
8
|
+
t.datetime :fire_time, null: false
|
|
9
|
+
t.datetime :dispatched_at, null: false
|
|
10
|
+
t.string :node_id, null: false, limit: 255
|
|
11
|
+
t.string :status, null: false, limit: 50, default: 'dispatched'
|
|
12
|
+
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
add_index :kaal_dispatches, %i[key fire_time], unique: true
|
|
17
|
+
add_index :kaal_dispatches, :dispatched_at
|
|
18
|
+
add_index :kaal_dispatches, :status
|
|
19
|
+
end
|
|
20
|
+
end
|