zenpulse-scheduler 0.1.0__tar.gz
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.
- zenpulse_scheduler-0.1.0/PKG-INFO +10 -0
- zenpulse_scheduler-0.1.0/README.md +575 -0
- zenpulse_scheduler-0.1.0/pyproject.toml +22 -0
- zenpulse_scheduler-0.1.0/setup.cfg +4 -0
- zenpulse_scheduler-0.1.0/zenpulse_scheduler/__init__.py +0 -0
- zenpulse_scheduler-0.1.0/zenpulse_scheduler/admin.py +49 -0
- zenpulse_scheduler-0.1.0/zenpulse_scheduler/apps.py +5 -0
- zenpulse_scheduler-0.1.0/zenpulse_scheduler/engine.py +87 -0
- zenpulse_scheduler-0.1.0/zenpulse_scheduler/listeners.py +82 -0
- zenpulse_scheduler-0.1.0/zenpulse_scheduler/locks.py +107 -0
- zenpulse_scheduler-0.1.0/zenpulse_scheduler/management/commands/run_zenpulse_scheduler.py +27 -0
- zenpulse_scheduler-0.1.0/zenpulse_scheduler/models.py +86 -0
- zenpulse_scheduler-0.1.0/zenpulse_scheduler/registry.py +34 -0
- zenpulse_scheduler-0.1.0/zenpulse_scheduler/sync.py +76 -0
- zenpulse_scheduler-0.1.0/zenpulse_scheduler/triggers.py +42 -0
- zenpulse_scheduler-0.1.0/zenpulse_scheduler.egg-info/PKG-INFO +10 -0
- zenpulse_scheduler-0.1.0/zenpulse_scheduler.egg-info/SOURCES.txt +18 -0
- zenpulse_scheduler-0.1.0/zenpulse_scheduler.egg-info/dependency_links.txt +1 -0
- zenpulse_scheduler-0.1.0/zenpulse_scheduler.egg-info/requires.txt +2 -0
- zenpulse_scheduler-0.1.0/zenpulse_scheduler.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zenpulse_scheduler
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A DB-driven APScheduler for Django
|
|
5
|
+
Classifier: Framework :: Django
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: django>=3.2
|
|
10
|
+
Requires-Dist: apscheduler>=3.6.3
|
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
# ⚡ ZenPulse Scheduler for Django
|
|
2
|
+
|
|
3
|
+
**Modern, Hybrid, and Zero-Latency Job Scheduler**
|
|
4
|
+
|
|
5
|
+
ZenPulse Scheduler is a **Developer-First** background job scheduler for Django. Unlike traditional schedulers that either rely on heavy external infrastructure (Redis/Celery) or spam your database with polling queries, ZenPulse uses a **Smart Hybrid Architecture**.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 🌟 Features
|
|
10
|
+
|
|
11
|
+
- ✅ **No Redis/Celery Required** - Pure Django solution
|
|
12
|
+
- ✅ **Zero DB Impact** - Smart file-based signaling system
|
|
13
|
+
- ✅ **Hybrid Execution** - Config in DB, Runtime in RAM
|
|
14
|
+
- ✅ **Selective Logging** - Log everything, nothing, or only failures
|
|
15
|
+
- ✅ **Live Updates** - Change schedules without restarting
|
|
16
|
+
- ✅ **Multi-Process Safe** - Built-in locking mechanisms
|
|
17
|
+
- ✅ **Interval & Cron Support** - Flexible scheduling options
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 📦 Installation
|
|
22
|
+
|
|
23
|
+
### Step 1: Install the Package
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install zenpulse-scheduler
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or if installing from source:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
cd /path/to/package
|
|
33
|
+
pip install -e .
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Step 2: Add to Django Settings
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
# settings.py
|
|
40
|
+
INSTALLED_APPS = [
|
|
41
|
+
# ... other apps
|
|
42
|
+
'zenpulse_scheduler',
|
|
43
|
+
]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Step 3: Run Migrations
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
python manage.py migrate
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 🚀 Quick Start Guide
|
|
55
|
+
|
|
56
|
+
### 1. Define Your Job
|
|
57
|
+
|
|
58
|
+
Create a file (e.g., `yourapp/jobs.py`) and register your job:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from zenpulse_scheduler.registry import zenpulse_job
|
|
62
|
+
|
|
63
|
+
@zenpulse_job("send_daily_report")
|
|
64
|
+
def send_daily_report():
|
|
65
|
+
"""Send daily sales report via email"""
|
|
66
|
+
print("📧 Sending daily report...")
|
|
67
|
+
# Your business logic here
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Important:** Make sure this file is imported when Django starts. You can do this by:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
# yourapp/apps.py
|
|
74
|
+
from django.apps import AppConfig
|
|
75
|
+
|
|
76
|
+
class YourAppConfig(AppConfig):
|
|
77
|
+
name = 'yourapp'
|
|
78
|
+
|
|
79
|
+
def ready(self):
|
|
80
|
+
import yourapp.jobs # Import to register jobs
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Or import it in your `urls.py`:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
# yourapp/urls.py
|
|
87
|
+
import yourapp.jobs # Ensure jobs are registered
|
|
88
|
+
|
|
89
|
+
urlpatterns = [
|
|
90
|
+
# your urls
|
|
91
|
+
]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### 2. Start the Scheduler
|
|
95
|
+
|
|
96
|
+
Open a **separate terminal** and run:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
python manage.py run_zenpulse_scheduler
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
You should see:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
Starting ZenPulse Scheduler (Sync: 10s, Lock: False)...
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### 3. Configure via Admin
|
|
109
|
+
|
|
110
|
+
1. Go to **Django Admin** → **ZenPulse Scheduler** → **Schedule Configs**
|
|
111
|
+
2. Click **Add Schedule Config**
|
|
112
|
+
3. Fill in the details (see Configuration Guide below)
|
|
113
|
+
4. Save
|
|
114
|
+
|
|
115
|
+
Your job will start running automatically!
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 📚 Complete Configuration Guide
|
|
120
|
+
|
|
121
|
+
All job scheduling is controlled through the **ScheduleConfig** model in Django Admin.
|
|
122
|
+
|
|
123
|
+
### Core Fields
|
|
124
|
+
|
|
125
|
+
#### **Job Key** (Required)
|
|
126
|
+
- **Type:** Text (Unique)
|
|
127
|
+
- **Description:** Must match the name you used in `@zenpulse_job("name")`
|
|
128
|
+
- **Example:** `send_daily_report`
|
|
129
|
+
|
|
130
|
+
#### **Enabled** (Required)
|
|
131
|
+
- **Type:** Checkbox
|
|
132
|
+
- **Description:** Controls whether the job runs
|
|
133
|
+
- **Default:** ✅ Checked (Enabled)
|
|
134
|
+
- **Behavior:**
|
|
135
|
+
- ✅ Checked → Job runs according to schedule
|
|
136
|
+
- ❌ Unchecked → Job stops immediately (within next sync interval)
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
### Trigger Configuration
|
|
141
|
+
|
|
142
|
+
#### **Trigger Type** (Required)
|
|
143
|
+
|
|
144
|
+
Choose how your job should be scheduled:
|
|
145
|
+
|
|
146
|
+
| Choice | When to Use | Example |
|
|
147
|
+
|--------|-------------|---------|
|
|
148
|
+
| **Interval** | Repeating tasks that run in a loop | "Check for new emails every 5 minutes" |
|
|
149
|
+
| **Cron** | Tasks that run at specific calendar times | "Send report every Monday at 9 AM" |
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
### Interval Configuration
|
|
154
|
+
|
|
155
|
+
Use when **Trigger Type** = `Interval`
|
|
156
|
+
|
|
157
|
+
#### **Interval Value** (Required for Interval)
|
|
158
|
+
- **Type:** Number
|
|
159
|
+
- **Description:** How many units to wait between runs
|
|
160
|
+
- **Example:** `30` (combined with unit below)
|
|
161
|
+
|
|
162
|
+
#### **Interval Unit** (Required for Interval)
|
|
163
|
+
|
|
164
|
+
| Unit | Best For | Example Usage |
|
|
165
|
+
|------|----------|---------------|
|
|
166
|
+
| **Seconds** | High-frequency monitoring, heartbeats | `30` seconds = runs every 30 seconds |
|
|
167
|
+
| **Minutes** | Standard periodic tasks | `15` minutes = runs every 15 minutes |
|
|
168
|
+
| **Hours** | Regular updates, data sync | `4` hours = runs every 4 hours |
|
|
169
|
+
| **Days** | Daily routines | `1` day = runs once per day |
|
|
170
|
+
| **Weeks** | Weekly maintenance | `2` weeks = runs every 2 weeks |
|
|
171
|
+
|
|
172
|
+
**Example Configurations:**
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
Interval Value: 10, Unit: Minutes → Runs every 10 minutes
|
|
176
|
+
Interval Value: 1, Unit: Hours → Runs every hour
|
|
177
|
+
Interval Value: 30, Unit: Seconds → Runs every 30 seconds
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
### Cron Configuration
|
|
183
|
+
|
|
184
|
+
Use when **Trigger Type** = `Cron`
|
|
185
|
+
|
|
186
|
+
All cron fields support:
|
|
187
|
+
- Specific values (e.g., `8` for 8 AM)
|
|
188
|
+
- Wildcards (`*` means "every")
|
|
189
|
+
- Lists (e.g., `mon,wed,fri`)
|
|
190
|
+
- Ranges (e.g., `1-5`)
|
|
191
|
+
|
|
192
|
+
#### **Cron Minute**
|
|
193
|
+
- **Valid Values:** `0-59` or `*`
|
|
194
|
+
- **Default:** `*` (every minute)
|
|
195
|
+
- **Examples:**
|
|
196
|
+
- `0` = At the top of the hour
|
|
197
|
+
- `30` = At 30 minutes past the hour
|
|
198
|
+
- `*/15` = Every 15 minutes
|
|
199
|
+
|
|
200
|
+
#### **Cron Hour**
|
|
201
|
+
- **Valid Values:** `0-23` or `*`
|
|
202
|
+
- **Default:** `*` (every hour)
|
|
203
|
+
- **Examples:**
|
|
204
|
+
- `9` = 9 AM
|
|
205
|
+
- `14` = 2 PM
|
|
206
|
+
- `22` = 10 PM
|
|
207
|
+
|
|
208
|
+
#### **Cron Day (of Month)**
|
|
209
|
+
- **Valid Values:** `1-31` or `*`
|
|
210
|
+
- **Default:** `*` (every day)
|
|
211
|
+
- **Examples:**
|
|
212
|
+
- `1` = 1st of the month
|
|
213
|
+
- `15` = 15th of the month
|
|
214
|
+
|
|
215
|
+
#### **Cron Month**
|
|
216
|
+
- **Valid Values:** `1-12` or `*`
|
|
217
|
+
- **Default:** `*` (every month)
|
|
218
|
+
- **Examples:**
|
|
219
|
+
- `1` = January
|
|
220
|
+
- `12` = December
|
|
221
|
+
|
|
222
|
+
#### **Cron Day of Week**
|
|
223
|
+
- **Valid Values:** `0-6` (0=Sunday) or `mon,tue,wed,thu,fri,sat,sun` or `*`
|
|
224
|
+
- **Default:** `*` (every day)
|
|
225
|
+
- **Examples:**
|
|
226
|
+
- `mon` = Every Monday
|
|
227
|
+
- `0` = Every Sunday
|
|
228
|
+
- `mon,wed,fri` = Monday, Wednesday, Friday
|
|
229
|
+
|
|
230
|
+
**Example Cron Configurations:**
|
|
231
|
+
|
|
232
|
+
```
|
|
233
|
+
Daily at 8:30 AM:
|
|
234
|
+
Minute: 30, Hour: 8, Day: *, Month: *, Day of Week: *
|
|
235
|
+
|
|
236
|
+
Every Monday at 9 AM:
|
|
237
|
+
Minute: 0, Hour: 9, Day: *, Month: *, Day of Week: mon
|
|
238
|
+
|
|
239
|
+
First day of every month at midnight:
|
|
240
|
+
Minute: 0, Hour: 0, Day: 1, Month: *, Day of Week: *
|
|
241
|
+
|
|
242
|
+
Every weekday at 6 PM:
|
|
243
|
+
Minute: 0, Hour: 18, Day: *, Month: *, Day of Week: mon,tue,wed,thu,fri
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
### Advanced Options
|
|
249
|
+
|
|
250
|
+
#### **Max Instances**
|
|
251
|
+
- **Type:** Number
|
|
252
|
+
- **Default:** `1`
|
|
253
|
+
- **Description:** Maximum number of this job that can run simultaneously
|
|
254
|
+
- **Use Case:** Set to `3` if you want to allow up to 3 parallel executions
|
|
255
|
+
|
|
256
|
+
#### **Coalesce**
|
|
257
|
+
- **Type:** Checkbox
|
|
258
|
+
- **Default:** ✅ Checked
|
|
259
|
+
- **Description:** If multiple runs were missed (e.g., scheduler was down), combine them into one
|
|
260
|
+
- **Behavior:**
|
|
261
|
+
- ✅ Checked → Run once to catch up
|
|
262
|
+
- ❌ Unchecked → Run all missed executions
|
|
263
|
+
|
|
264
|
+
#### **Misfire Grace Time**
|
|
265
|
+
- **Type:** Number (seconds)
|
|
266
|
+
- **Default:** `60`
|
|
267
|
+
- **Description:** How long after the scheduled time the job can still run
|
|
268
|
+
- **Example:** If job was supposed to run at 10:00 but scheduler was busy, it can still run until 10:01 (60 seconds grace)
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
### Logging Configuration
|
|
273
|
+
|
|
274
|
+
#### **Log Policy**
|
|
275
|
+
|
|
276
|
+
Control how much execution history is saved to the database:
|
|
277
|
+
|
|
278
|
+
| Policy | What Gets Logged | Database Impact | Best For |
|
|
279
|
+
|--------|------------------|-----------------|----------|
|
|
280
|
+
| **None** | Nothing | Zero writes | High-frequency jobs (every few seconds) |
|
|
281
|
+
| **Failures Only** | Only when job crashes | Minimal writes | Production critical jobs (recommended) |
|
|
282
|
+
| **All Executions** | Every success and failure | High writes | Debugging, auditing |
|
|
283
|
+
|
|
284
|
+
**Recommendation:** Use `Failures Only` for production. This way:
|
|
285
|
+
- ✅ You get full error traces when something breaks
|
|
286
|
+
- ✅ Database stays clean
|
|
287
|
+
- ✅ You can still monitor job health
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## 🖥️ Admin Panel Examples
|
|
292
|
+
|
|
293
|
+
### Example 1: Send Email Every 30 Minutes
|
|
294
|
+
|
|
295
|
+
```
|
|
296
|
+
Job Key: send_email_digest
|
|
297
|
+
Enabled: ✅
|
|
298
|
+
Trigger Type: Interval
|
|
299
|
+
Interval Value: 30
|
|
300
|
+
Interval Unit: Minutes
|
|
301
|
+
Log Policy: Failures Only
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Example 2: Daily Report at 8:30 AM
|
|
305
|
+
|
|
306
|
+
```
|
|
307
|
+
Job Key: generate_daily_report
|
|
308
|
+
Enabled: ✅
|
|
309
|
+
Trigger Type: Cron
|
|
310
|
+
Cron Minute: 30
|
|
311
|
+
Cron Hour: 8
|
|
312
|
+
Cron Day: *
|
|
313
|
+
Cron Month: *
|
|
314
|
+
Cron Day of Week: *
|
|
315
|
+
Log Policy: All Executions
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### Example 3: Weekly Cleanup Every Sunday at Midnight
|
|
319
|
+
|
|
320
|
+
```
|
|
321
|
+
Job Key: weekly_cleanup
|
|
322
|
+
Enabled: ✅
|
|
323
|
+
Trigger Type: Cron
|
|
324
|
+
Cron Minute: 0
|
|
325
|
+
Cron Hour: 0
|
|
326
|
+
Cron Day: *
|
|
327
|
+
Cron Month: *
|
|
328
|
+
Cron Day of Week: sun
|
|
329
|
+
Log Policy: Failures Only
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
## ⚙️ Running the Scheduler
|
|
335
|
+
|
|
336
|
+
### Development
|
|
337
|
+
|
|
338
|
+
```bash
|
|
339
|
+
python manage.py run_zenpulse_scheduler
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
### Production (with Safety Lock)
|
|
343
|
+
|
|
344
|
+
```bash
|
|
345
|
+
python manage.py run_zenpulse_scheduler --lock
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
The `--lock` flag prevents multiple scheduler instances from running simultaneously:
|
|
349
|
+
- **PostgreSQL:** Uses advisory locks (recommended)
|
|
350
|
+
- **MySQL:** Uses `GET_LOCK`
|
|
351
|
+
- **SQLite/Others:** Uses file-based locking
|
|
352
|
+
|
|
353
|
+
### Custom Sync Interval
|
|
354
|
+
|
|
355
|
+
By default, the scheduler checks for config changes every 10 seconds. To reduce database queries:
|
|
356
|
+
|
|
357
|
+
```bash
|
|
358
|
+
python manage.py run_zenpulse_scheduler --sync-every 60
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
This checks only once per minute. Trade-off: Changes take up to 60 seconds to apply.
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## 🏗️ Architecture
|
|
366
|
+
|
|
367
|
+
```
|
|
368
|
+
┌─────────────────┐
|
|
369
|
+
│ Django Admin │ ← You configure jobs here
|
|
370
|
+
└────────┬────────┘
|
|
371
|
+
│ Save Config
|
|
372
|
+
↓
|
|
373
|
+
┌─────────────────┐
|
|
374
|
+
│ Database │ ← Stores ScheduleConfig
|
|
375
|
+
└────────┬────────┘
|
|
376
|
+
│ Sync (every 10s)
|
|
377
|
+
↓
|
|
378
|
+
┌─────────────────┐
|
|
379
|
+
│ Scheduler │ ← Runs in separate process
|
|
380
|
+
│ (Memory) │
|
|
381
|
+
└────────┬────────┘
|
|
382
|
+
│ Execute
|
|
383
|
+
↓
|
|
384
|
+
┌─────────────────┐
|
|
385
|
+
│ Your Job │ ← @zenpulse_job decorated function
|
|
386
|
+
└─────────────────┘
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
**Key Points:**
|
|
390
|
+
1. Configuration lives in **Database** (persistent)
|
|
391
|
+
2. Execution happens in **Memory** (fast)
|
|
392
|
+
3. Scheduler syncs config changes automatically
|
|
393
|
+
4. No Redis or external dependencies needed
|
|
394
|
+
|
|
395
|
+
---
|
|
396
|
+
|
|
397
|
+
## 📊 Monitoring Job Execution
|
|
398
|
+
|
|
399
|
+
### View Execution Logs
|
|
400
|
+
|
|
401
|
+
Go to **Django Admin** → **ZenPulse Scheduler** → **Job Execution Logs**
|
|
402
|
+
|
|
403
|
+
You'll see:
|
|
404
|
+
- **Job Key:** Which job ran
|
|
405
|
+
- **Status:** Success or Fail
|
|
406
|
+
- **Run Time:** When it executed (with seconds precision)
|
|
407
|
+
- **Duration:** How long it took (in milliseconds)
|
|
408
|
+
- **Exception Details:** Full traceback if it failed
|
|
409
|
+
|
|
410
|
+
### Understanding Log Entries
|
|
411
|
+
|
|
412
|
+
```
|
|
413
|
+
Job Key: send_email_digest
|
|
414
|
+
Status: Success
|
|
415
|
+
Run Time: 2026-02-01 14:30:45
|
|
416
|
+
Duration: 234.5 ms
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
If a job fails:
|
|
420
|
+
|
|
421
|
+
```
|
|
422
|
+
Job Key: send_email_digest
|
|
423
|
+
Status: Fail
|
|
424
|
+
Run Time: 2026-02-01 14:30:45
|
|
425
|
+
Exception Type: SMTPException
|
|
426
|
+
Exception Message: Connection refused
|
|
427
|
+
Traceback: [Full Python traceback here]
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
## 🔧 Troubleshooting
|
|
433
|
+
|
|
434
|
+
### Job Not Running?
|
|
435
|
+
|
|
436
|
+
1. **Check if job is registered:**
|
|
437
|
+
```python
|
|
438
|
+
# In Django shell
|
|
439
|
+
from zenpulse_scheduler.registry import JobRegistry
|
|
440
|
+
print(JobRegistry.get_all_jobs())
|
|
441
|
+
```
|
|
442
|
+
Your job should appear in the list.
|
|
443
|
+
|
|
444
|
+
2. **Check if enabled in Admin:**
|
|
445
|
+
- Go to Schedule Configs
|
|
446
|
+
- Verify `Enabled` is checked
|
|
447
|
+
|
|
448
|
+
3. **Check scheduler is running:**
|
|
449
|
+
- Look for `Starting ZenPulse Scheduler...` in terminal
|
|
450
|
+
- Check for any error messages
|
|
451
|
+
|
|
452
|
+
### Duplicate Executions?
|
|
453
|
+
|
|
454
|
+
- Make sure only **one** `run_zenpulse_scheduler` process is running
|
|
455
|
+
- Use `--lock` flag in production
|
|
456
|
+
- Check if you accidentally started it in multiple terminals
|
|
457
|
+
|
|
458
|
+
### Jobs Running at Wrong Time?
|
|
459
|
+
|
|
460
|
+
- Verify your `TIME_ZONE` setting in Django settings
|
|
461
|
+
- Check cron configuration carefully
|
|
462
|
+
- Use `Interval` for simple repeating tasks
|
|
463
|
+
|
|
464
|
+
### Database Performance Issues?
|
|
465
|
+
|
|
466
|
+
- Set `Log Policy` to `None` or `Failures Only`
|
|
467
|
+
- Increase `--sync-every` to reduce config checks
|
|
468
|
+
- Consider archiving old logs periodically
|
|
469
|
+
|
|
470
|
+
---
|
|
471
|
+
|
|
472
|
+
## 🚀 Production Deployment
|
|
473
|
+
|
|
474
|
+
### Using Systemd (Linux)
|
|
475
|
+
|
|
476
|
+
Create `/etc/systemd/system/zenpulse-scheduler.service`:
|
|
477
|
+
|
|
478
|
+
```ini
|
|
479
|
+
[Unit]
|
|
480
|
+
Description=ZenPulse Scheduler
|
|
481
|
+
After=network.target
|
|
482
|
+
|
|
483
|
+
[Service]
|
|
484
|
+
Type=simple
|
|
485
|
+
User=www-data
|
|
486
|
+
WorkingDirectory=/path/to/your/project
|
|
487
|
+
ExecStart=/path/to/venv/bin/python manage.py run_zenpulse_scheduler --lock --sync-every 30
|
|
488
|
+
Restart=always
|
|
489
|
+
RestartSec=10
|
|
490
|
+
|
|
491
|
+
[Install]
|
|
492
|
+
WantedBy=multi-user.target
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
Enable and start:
|
|
496
|
+
|
|
497
|
+
```bash
|
|
498
|
+
sudo systemctl enable zenpulse-scheduler
|
|
499
|
+
sudo systemctl start zenpulse-scheduler
|
|
500
|
+
sudo systemctl status zenpulse-scheduler
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
### Using Supervisor
|
|
504
|
+
|
|
505
|
+
```ini
|
|
506
|
+
[program:zenpulse-scheduler]
|
|
507
|
+
command=/path/to/venv/bin/python manage.py run_zenpulse_scheduler --lock
|
|
508
|
+
directory=/path/to/your/project
|
|
509
|
+
user=www-data
|
|
510
|
+
autostart=true
|
|
511
|
+
autorestart=true
|
|
512
|
+
redirect_stderr=true
|
|
513
|
+
stdout_logfile=/var/log/zenpulse-scheduler.log
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
### Using Docker
|
|
517
|
+
|
|
518
|
+
```dockerfile
|
|
519
|
+
# In your Dockerfile
|
|
520
|
+
CMD ["python", "manage.py", "run_zenpulse_scheduler", "--lock"]
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
Or in `docker-compose.yml`:
|
|
524
|
+
|
|
525
|
+
```yaml
|
|
526
|
+
services:
|
|
527
|
+
scheduler:
|
|
528
|
+
build: .
|
|
529
|
+
command: python manage.py run_zenpulse_scheduler --lock
|
|
530
|
+
depends_on:
|
|
531
|
+
- db
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
---
|
|
535
|
+
|
|
536
|
+
## ❓ FAQ
|
|
537
|
+
|
|
538
|
+
**Q: Can I run this with Gunicorn/Uvicorn?**
|
|
539
|
+
A: Yes, but run the scheduler in a **separate process/container**. Never run it inside web workers.
|
|
540
|
+
|
|
541
|
+
**Q: What happens if I restart the server?**
|
|
542
|
+
A: Configuration is in the database, so it persists. Just start the scheduler command again.
|
|
543
|
+
|
|
544
|
+
**Q: Can jobs accept parameters?**
|
|
545
|
+
A: Currently, jobs should be parameter-less functions. For different parameters, create separate job functions.
|
|
546
|
+
|
|
547
|
+
**Q: How do I delete old logs?**
|
|
548
|
+
A: You can manually delete from Admin or create a cleanup job:
|
|
549
|
+
```python
|
|
550
|
+
@zenpulse_job("cleanup_old_logs")
|
|
551
|
+
def cleanup_old_logs():
|
|
552
|
+
from datetime import timedelta
|
|
553
|
+
from django.utils import timezone
|
|
554
|
+
from zenpulse_scheduler.models import JobExecutionLog
|
|
555
|
+
|
|
556
|
+
cutoff = timezone.now() - timedelta(days=30)
|
|
557
|
+
JobExecutionLog.objects.filter(run_time__lt=cutoff).delete()
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
**Q: Is this production-ready?**
|
|
561
|
+
A: Yes! It's built on APScheduler (battle-tested library) with Django best practices.
|
|
562
|
+
|
|
563
|
+
---
|
|
564
|
+
|
|
565
|
+
## 📝 License
|
|
566
|
+
|
|
567
|
+
[Your License Here]
|
|
568
|
+
|
|
569
|
+
## 🤝 Contributing
|
|
570
|
+
|
|
571
|
+
Contributions welcome! Please open an issue or PR.
|
|
572
|
+
|
|
573
|
+
---
|
|
574
|
+
|
|
575
|
+
**Made with ❤️ for Django developers who want simple, powerful scheduling.**
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "zenpulse_scheduler"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"django>=3.2",
|
|
10
|
+
"apscheduler>=3.6.3",
|
|
11
|
+
]
|
|
12
|
+
description = "A DB-driven APScheduler for Django"
|
|
13
|
+
readme = "zenpulse_scheduler/README.md"
|
|
14
|
+
requires-python = ">=3.8"
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Framework :: Django",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
where = ["."]
|
|
22
|
+
include = ["zenpulse_scheduler*"]
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
from .models import ScheduleConfig, JobExecutionLog
|
|
3
|
+
from .registry import JobRegistry
|
|
4
|
+
|
|
5
|
+
@admin.register(ScheduleConfig)
|
|
6
|
+
class ScheduleConfigAdmin(admin.ModelAdmin):
|
|
7
|
+
list_display = (
|
|
8
|
+
'job_key', 'enabled', 'trigger_type', 'schedule_display',
|
|
9
|
+
'log_policy', 'updated_at'
|
|
10
|
+
)
|
|
11
|
+
list_filter = ('enabled', 'trigger_type', 'log_policy')
|
|
12
|
+
search_fields = ('job_key',)
|
|
13
|
+
|
|
14
|
+
def schedule_display(self, obj):
|
|
15
|
+
if obj.trigger_type == 'interval':
|
|
16
|
+
return f"Every {obj.interval_value} {obj.interval_unit}"
|
|
17
|
+
else:
|
|
18
|
+
return f"{obj.cron_minute} {obj.cron_hour} {obj.cron_day} {obj.cron_month} {obj.cron_day_of_week}"
|
|
19
|
+
|
|
20
|
+
schedule_display.short_description = "Schedule"
|
|
21
|
+
|
|
22
|
+
def formfield_for_choice_field(self, db_field, request, **kwargs):
|
|
23
|
+
if db_field.name == 'job_key':
|
|
24
|
+
# Populate with registered jobs dynamically?
|
|
25
|
+
# Standard CharField with choices is tricky if we want to allow typing new ones (if code updated but registry not loaded in admin process context fully).
|
|
26
|
+
# But if we can inspect registry, we can offer choices.
|
|
27
|
+
# Admin runs in WSGI, Registry jobs might be loaded if apps.ready imports them?
|
|
28
|
+
# Usually jobs are in app/jobs.py. If those aren't imported, Registry is empty.
|
|
29
|
+
# Let's simple Text Input for now with help_text.
|
|
30
|
+
pass
|
|
31
|
+
return super().formfield_for_choice_field(db_field, request, **kwargs)
|
|
32
|
+
|
|
33
|
+
@admin.register(JobExecutionLog)
|
|
34
|
+
class JobExecutionLogAdmin(admin.ModelAdmin):
|
|
35
|
+
list_display = ('job_key', 'status', 'run_time_display', 'duration_ms')
|
|
36
|
+
list_filter = ('job_key', 'status', 'run_time')
|
|
37
|
+
readonly_fields = ('run_time', 'traceback', 'exception_message', 'hostname', 'pid')
|
|
38
|
+
|
|
39
|
+
def run_time_display(self, obj):
|
|
40
|
+
return obj.run_time.strftime("%Y-%m-%d %H:%M:%S")
|
|
41
|
+
|
|
42
|
+
run_time_display.admin_order_field = 'run_time'
|
|
43
|
+
run_time_display.short_description = 'Run Time'
|
|
44
|
+
|
|
45
|
+
def has_add_permission(self, request):
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
def has_change_permission(self, request, obj=None):
|
|
49
|
+
return False
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import signal
|
|
3
|
+
import sys
|
|
4
|
+
import logging
|
|
5
|
+
from apscheduler.schedulers.background import BackgroundScheduler
|
|
6
|
+
from apscheduler.jobstores.memory import MemoryJobStore
|
|
7
|
+
from apscheduler.executors.pool import ThreadPoolExecutor
|
|
8
|
+
from apscheduler.events import EVENT_JOB_EXECUTED, EVENT_JOB_ERROR
|
|
9
|
+
|
|
10
|
+
from django.conf import settings
|
|
11
|
+
from .sync import sync_jobs
|
|
12
|
+
from .listeners import handle_job_execution
|
|
13
|
+
from .locks import get_best_lock
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
class ZenPulseEngine:
|
|
18
|
+
def __init__(self, sync_interval=10, use_lock=False):
|
|
19
|
+
self.sync_interval = sync_interval
|
|
20
|
+
self.use_lock = use_lock
|
|
21
|
+
self.lock = get_best_lock() if use_lock else None
|
|
22
|
+
self.running = False
|
|
23
|
+
|
|
24
|
+
self.scheduler = BackgroundScheduler(
|
|
25
|
+
jobstores={'default': MemoryJobStore()},
|
|
26
|
+
executors={'default': ThreadPoolExecutor(20)},
|
|
27
|
+
job_defaults={
|
|
28
|
+
'coalesce': True,
|
|
29
|
+
'max_instances': 1
|
|
30
|
+
},
|
|
31
|
+
timezone=getattr(settings, 'TIME_ZONE', 'UTC')
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def start(self):
|
|
35
|
+
# 1. Acquire Lock
|
|
36
|
+
if self.use_lock:
|
|
37
|
+
logger.info("Acquiring lock...")
|
|
38
|
+
if not self.lock.acquire():
|
|
39
|
+
logger.error("Could not acquire lock. Another instance is likely running. Exiting.")
|
|
40
|
+
return
|
|
41
|
+
logger.info("Lock acquired.")
|
|
42
|
+
|
|
43
|
+
# 2. Setup Signal Handlers
|
|
44
|
+
signal.signal(signal.SIGINT, self.shutdown)
|
|
45
|
+
signal.signal(signal.SIGTERM, self.shutdown)
|
|
46
|
+
|
|
47
|
+
# 3. Setup Listeners
|
|
48
|
+
self.scheduler.add_listener(handle_job_execution, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR)
|
|
49
|
+
|
|
50
|
+
# 4. Start Scheduler
|
|
51
|
+
self.scheduler.start()
|
|
52
|
+
self.running = True
|
|
53
|
+
logger.info("ZenPulse Scheduler started.")
|
|
54
|
+
|
|
55
|
+
# Cache for simple change detection: {job_key: (enabled, updated_at_ts)}
|
|
56
|
+
self.last_synced_data = {}
|
|
57
|
+
|
|
58
|
+
# 5. Main Sync Loop
|
|
59
|
+
try:
|
|
60
|
+
while self.running:
|
|
61
|
+
try:
|
|
62
|
+
sync_jobs(self.scheduler, self.last_synced_data)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.error(f"Error in sync loop: {e}")
|
|
65
|
+
|
|
66
|
+
# Sleep in small chunks to handle shutdown signals faster
|
|
67
|
+
for _ in range(self.sync_interval):
|
|
68
|
+
if not self.running:
|
|
69
|
+
break
|
|
70
|
+
time.sleep(1)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
logger.critical(f"Engine crashed: {e}")
|
|
73
|
+
finally:
|
|
74
|
+
self.shutdown()
|
|
75
|
+
|
|
76
|
+
def shutdown(self, signum=None, frame=None):
|
|
77
|
+
if not self.running: return # Already stopped
|
|
78
|
+
|
|
79
|
+
logger.info("Shutting down ZenPulse Scheduler...")
|
|
80
|
+
self.running = False
|
|
81
|
+
self.scheduler.shutdown()
|
|
82
|
+
|
|
83
|
+
if self.use_lock:
|
|
84
|
+
self.lock.release()
|
|
85
|
+
logger.info("Lock released.")
|
|
86
|
+
|
|
87
|
+
logger.info("Shutdown complete.")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import traceback
|
|
3
|
+
import socket
|
|
4
|
+
import os
|
|
5
|
+
from apscheduler.events import EVENT_JOB_EXECUTED, EVENT_JOB_ERROR
|
|
6
|
+
from django.utils import timezone
|
|
7
|
+
from .models import ScheduleConfig, JobExecutionLog
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
def get_config_log_policy(job_id):
|
|
12
|
+
"""
|
|
13
|
+
Helper to fetch log policy for a given job_id.
|
|
14
|
+
Cache this or fetch from DB? Fetching from DB is safer for real-time updates,
|
|
15
|
+
but we might want to optimize if high throughput.
|
|
16
|
+
"""
|
|
17
|
+
try:
|
|
18
|
+
# job_id in APScheduler will be the job_key from our model
|
|
19
|
+
config = ScheduleConfig.objects.filter(job_key=job_id).first()
|
|
20
|
+
if config:
|
|
21
|
+
return config.log_policy
|
|
22
|
+
except Exception:
|
|
23
|
+
pass
|
|
24
|
+
return 'none' # Default to none if not found
|
|
25
|
+
|
|
26
|
+
def handle_job_execution(event):
|
|
27
|
+
"""
|
|
28
|
+
Listener for SUCCESS and ERROR events.
|
|
29
|
+
"""
|
|
30
|
+
job_id = event.job_id
|
|
31
|
+
policy = get_config_log_policy(job_id)
|
|
32
|
+
|
|
33
|
+
if policy == 'none':
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
is_error = event.exception is not None
|
|
37
|
+
|
|
38
|
+
# Logic:
|
|
39
|
+
# FAILURES: Record only if is_error
|
|
40
|
+
# ALL: Record everything
|
|
41
|
+
if policy == 'failures' and not is_error:
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
# Prepare Log Entry
|
|
45
|
+
status = 'fail' if is_error else 'success'
|
|
46
|
+
|
|
47
|
+
# Calculate duration (APScheduler events might not have duration directly in all versions,
|
|
48
|
+
# but let's check `event.retval` or simple generic logging)
|
|
49
|
+
# Actually `event` object in APScheduler has `scheduled_run_time` and loop time.
|
|
50
|
+
# We can infer valid duration if we wrap jobs, but here we might rely on estimated diff logic
|
|
51
|
+
# or just record 0 if unavailable.
|
|
52
|
+
# NOTE: APScheduler `JobExecutionEvent` does not strictly capture duration easily without wrapper.
|
|
53
|
+
# However, we can just log the event.
|
|
54
|
+
|
|
55
|
+
duration = 0.0 # Placeholder, or implement wrapper logic for precise timing later.
|
|
56
|
+
|
|
57
|
+
exception_type = None
|
|
58
|
+
exception_message = None
|
|
59
|
+
tb = None
|
|
60
|
+
|
|
61
|
+
if is_error:
|
|
62
|
+
exception_type = type(event.exception).__name__
|
|
63
|
+
exception_message = str(event.exception)
|
|
64
|
+
try:
|
|
65
|
+
tb = "".join(traceback.format_tb(event.traceback))
|
|
66
|
+
except:
|
|
67
|
+
tb = str(event.traceback)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
JobExecutionLog.objects.create(
|
|
71
|
+
job_key=job_id,
|
|
72
|
+
status=status,
|
|
73
|
+
duration_ms=duration, # Update if we implement wrapper timing
|
|
74
|
+
exception_type=exception_type,
|
|
75
|
+
exception_message=exception_message,
|
|
76
|
+
traceback=tb,
|
|
77
|
+
hostname=socket.gethostname(),
|
|
78
|
+
pid=os.getpid()
|
|
79
|
+
)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.error(f"Failed to write execution log for job {job_id}: {e}")
|
|
82
|
+
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import atexit
|
|
3
|
+
import os
|
|
4
|
+
import logging
|
|
5
|
+
from django.db import connection
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
class BaseLock:
|
|
10
|
+
def acquire(self):
|
|
11
|
+
raise NotImplementedError
|
|
12
|
+
|
|
13
|
+
def release(self):
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
|
|
16
|
+
class PIDFileLock(BaseLock):
|
|
17
|
+
def __init__(self, key="zenpulse_scheduler"):
|
|
18
|
+
self.lockfile = f"/tmp/{key}.lock" if sys.platform != "win32" else f"{os.getenv('TEMP')}/{key}.lock"
|
|
19
|
+
self._f = None
|
|
20
|
+
|
|
21
|
+
def acquire(self):
|
|
22
|
+
try:
|
|
23
|
+
if os.path.exists(self.lockfile):
|
|
24
|
+
# Check if pid exists
|
|
25
|
+
with open(self.lockfile, 'r') as f:
|
|
26
|
+
pid = int(f.read().strip())
|
|
27
|
+
|
|
28
|
+
# Check if process is running
|
|
29
|
+
try:
|
|
30
|
+
# Signal 0 checks if process exists (Unix) or OpenProcess (Windows)
|
|
31
|
+
os.kill(pid, 0)
|
|
32
|
+
logger.warning(f"Lock file exists and process {pid} is running.")
|
|
33
|
+
return False
|
|
34
|
+
except OSError:
|
|
35
|
+
# Process dead, safe to take over
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
self._f = open(self.lockfile, 'w')
|
|
39
|
+
self._f.write(str(os.getpid()))
|
|
40
|
+
self._f.flush()
|
|
41
|
+
atexit.register(self.release)
|
|
42
|
+
return True
|
|
43
|
+
except Exception as e:
|
|
44
|
+
logger.error(f"Failed to acquire PID lock: {e}")
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
def release(self):
|
|
48
|
+
try:
|
|
49
|
+
if self._f:
|
|
50
|
+
self._f.close()
|
|
51
|
+
os.remove(self.lockfile)
|
|
52
|
+
self._f = None
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
class DatabaseAdvisoryLock(BaseLock):
|
|
57
|
+
"""
|
|
58
|
+
Attempts to use DB-specific advisory locks.
|
|
59
|
+
Supports PostgreSQL and MySQL.
|
|
60
|
+
"""
|
|
61
|
+
LOCK_ID = 808080 # arbitrary integer for advisory lock
|
|
62
|
+
|
|
63
|
+
def __init__(self):
|
|
64
|
+
self.acquired = False
|
|
65
|
+
|
|
66
|
+
def acquire(self):
|
|
67
|
+
if connection.vendor == 'postgresql':
|
|
68
|
+
with connection.cursor() as cursor:
|
|
69
|
+
cursor.execute("SELECT pg_try_advisory_lock(%s)", [self.LOCK_ID])
|
|
70
|
+
row = cursor.fetchone()
|
|
71
|
+
if row and row[0]:
|
|
72
|
+
self.acquired = True
|
|
73
|
+
return True
|
|
74
|
+
return False
|
|
75
|
+
elif connection.vendor == 'mysql':
|
|
76
|
+
with connection.cursor() as cursor:
|
|
77
|
+
# GET_LOCK returns 1 if success, 0 if timeout, NULL on error
|
|
78
|
+
cursor.execute("SELECT GET_LOCK(%s, 0)", [str(self.LOCK_ID)])
|
|
79
|
+
row = cursor.fetchone()
|
|
80
|
+
if row and row[0] == 1:
|
|
81
|
+
self.acquired = True
|
|
82
|
+
return True
|
|
83
|
+
return False
|
|
84
|
+
else:
|
|
85
|
+
logger.warning("DatabaseAdvisoryLock not supported for this vendor. Falling back to PID lock.")
|
|
86
|
+
return PIDFileLock().acquire() # Fallback
|
|
87
|
+
|
|
88
|
+
def release(self):
|
|
89
|
+
if not self.acquired:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
if connection.vendor == 'postgresql':
|
|
93
|
+
with connection.cursor() as cursor:
|
|
94
|
+
cursor.execute("SELECT pg_advisory_unlock(%s)", [self.LOCK_ID])
|
|
95
|
+
elif connection.vendor == 'mysql':
|
|
96
|
+
with connection.cursor() as cursor:
|
|
97
|
+
cursor.execute("SELECT RELEASE_LOCK(%s)", [str(self.LOCK_ID)])
|
|
98
|
+
|
|
99
|
+
self.acquired = False
|
|
100
|
+
|
|
101
|
+
def get_best_lock():
|
|
102
|
+
# Prefer DB lock usually, but for simplicity/universality check config or default to PID?
|
|
103
|
+
# User asked for DB lock optional.
|
|
104
|
+
# Let's try DB lock, if not supported (sqlite), fallback to PID.
|
|
105
|
+
if connection.vendor in ('postgresql', 'mysql'):
|
|
106
|
+
return DatabaseAdvisoryLock()
|
|
107
|
+
return PIDFileLock()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from django.core.management.base import BaseCommand
|
|
2
|
+
from zenpulse_scheduler.engine import ZenPulseEngine
|
|
3
|
+
|
|
4
|
+
class Command(BaseCommand):
|
|
5
|
+
help = 'Runs the ZenPulse Scheduler'
|
|
6
|
+
|
|
7
|
+
def add_arguments(self, parser):
|
|
8
|
+
parser.add_argument(
|
|
9
|
+
'--sync-every',
|
|
10
|
+
type=int,
|
|
11
|
+
default=10,
|
|
12
|
+
help='Seconds between DB config syncs (default: 10)'
|
|
13
|
+
)
|
|
14
|
+
parser.add_argument(
|
|
15
|
+
'--lock',
|
|
16
|
+
action='store_true',
|
|
17
|
+
help='Enable single-instance locking (DB or File)'
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def handle(self, *args, **options):
|
|
21
|
+
sync_interval = options['sync_every']
|
|
22
|
+
use_lock = options['lock']
|
|
23
|
+
|
|
24
|
+
self.stdout.write(self.style.SUCCESS(f"Starting ZenPulse Scheduler (Sync: {sync_interval}s, Lock: {use_lock})..."))
|
|
25
|
+
|
|
26
|
+
engine = ZenPulseEngine(sync_interval=sync_interval, use_lock=use_lock)
|
|
27
|
+
engine.start()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
from django.utils.translation import gettext_lazy as _
|
|
3
|
+
|
|
4
|
+
class ScheduleConfig(models.Model):
|
|
5
|
+
TRIGGER_CHOICES = (
|
|
6
|
+
('interval', 'Interval'),
|
|
7
|
+
('cron', 'Cron'),
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
LOG_POLICY_CHOICES = (
|
|
11
|
+
('none', 'None'),
|
|
12
|
+
('failures', 'Failures Only'),
|
|
13
|
+
('all', 'All Executions'),
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
INTERVAL_UNIT_CHOICES = (
|
|
17
|
+
('seconds', 'Seconds'),
|
|
18
|
+
('minutes', 'Minutes'),
|
|
19
|
+
('hours', 'Hours'),
|
|
20
|
+
('days', 'Days'),
|
|
21
|
+
('weeks', 'Weeks'),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
job_key = models.CharField(
|
|
25
|
+
max_length=255,
|
|
26
|
+
unique=True,
|
|
27
|
+
help_text="Unique key identifier for the job (must match the registered job name)."
|
|
28
|
+
)
|
|
29
|
+
enabled = models.BooleanField(default=True)
|
|
30
|
+
trigger_type = models.CharField(max_length=20, choices=TRIGGER_CHOICES, default='interval')
|
|
31
|
+
|
|
32
|
+
# Interval Fields
|
|
33
|
+
interval_value = models.IntegerField(null=True, blank=True, help_text="Value for interval trigger.")
|
|
34
|
+
interval_unit = models.CharField(
|
|
35
|
+
max_length=20,
|
|
36
|
+
choices=INTERVAL_UNIT_CHOICES,
|
|
37
|
+
default='minutes',
|
|
38
|
+
null=True, blank=True
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Cron Fields
|
|
42
|
+
cron_minute = models.CharField(max_length=100, default='*', help_text="Cron minute (0-59 or *)")
|
|
43
|
+
cron_hour = models.CharField(max_length=100, default='*', help_text="Cron hour (0-23 or *)")
|
|
44
|
+
cron_day = models.CharField(max_length=100, default='*', help_text="Cron day of month (1-31 or *)")
|
|
45
|
+
cron_month = models.CharField(max_length=100, default='*', help_text="Cron month (1-12 or *)")
|
|
46
|
+
cron_day_of_week = models.CharField(max_length=100, default='*', help_text="Cron day of week (0-6 or mon,tue... or *)")
|
|
47
|
+
|
|
48
|
+
# Options
|
|
49
|
+
max_instances = models.IntegerField(default=1, help_text="Maximum number of concurrently running instances allowed.")
|
|
50
|
+
coalesce = models.BooleanField(default=True, help_text="Combine missed runs into one.")
|
|
51
|
+
misfire_grace_time = models.IntegerField(default=60, help_text="Seconds after the designated run time that the job is still allowed to run.")
|
|
52
|
+
|
|
53
|
+
log_policy = models.CharField(max_length=20, choices=LOG_POLICY_CHOICES, default='failures')
|
|
54
|
+
|
|
55
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
56
|
+
|
|
57
|
+
def __str__(self):
|
|
58
|
+
return f"{self.job_key} ({self.trigger_type})"
|
|
59
|
+
|
|
60
|
+
class JobExecutionLog(models.Model):
|
|
61
|
+
STATUS_CHOICES = (
|
|
62
|
+
('success', 'Success'),
|
|
63
|
+
('fail', 'Fail'),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
job_key = models.CharField(max_length=255, db_index=True)
|
|
67
|
+
run_time = models.DateTimeField(auto_now_add=True)
|
|
68
|
+
status = models.CharField(max_length=10, choices=STATUS_CHOICES)
|
|
69
|
+
duration_ms = models.FloatField(help_text="Execution duration in milliseconds")
|
|
70
|
+
|
|
71
|
+
exception_type = models.CharField(max_length=255, null=True, blank=True)
|
|
72
|
+
exception_message = models.TextField(null=True, blank=True)
|
|
73
|
+
traceback = models.TextField(null=True, blank=True)
|
|
74
|
+
|
|
75
|
+
hostname = models.CharField(max_length=255, null=True, blank=True)
|
|
76
|
+
pid = models.IntegerField(null=True, blank=True)
|
|
77
|
+
|
|
78
|
+
class Meta:
|
|
79
|
+
ordering = ['-run_time']
|
|
80
|
+
indexes = [
|
|
81
|
+
models.Index(fields=['job_key', 'status']),
|
|
82
|
+
models.Index(fields=['run_time']),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
def __str__(self):
|
|
86
|
+
return f"{self.job_key} - {self.status} at {self.run_time}"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
logger = logging.getLogger(__name__)
|
|
4
|
+
|
|
5
|
+
class JobRegistry:
|
|
6
|
+
_registry = {}
|
|
7
|
+
|
|
8
|
+
@classmethod
|
|
9
|
+
def register(cls, name):
|
|
10
|
+
def decorator(func):
|
|
11
|
+
if name in cls._registry:
|
|
12
|
+
logger.warning(f"Job with key '{name}' already registered. Overwriting.")
|
|
13
|
+
cls._registry[name] = func
|
|
14
|
+
return func
|
|
15
|
+
return decorator
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def get_job(cls, name):
|
|
19
|
+
return cls._registry.get(name)
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def get_all_jobs(cls):
|
|
23
|
+
return cls._registry
|
|
24
|
+
|
|
25
|
+
# Initializer for the decorator
|
|
26
|
+
def zenpulse_job(name):
|
|
27
|
+
"""
|
|
28
|
+
Decorator to register a function as a ZenPulse job.
|
|
29
|
+
Usage:
|
|
30
|
+
@zenpulse_job('my_unique_job_key')
|
|
31
|
+
def my_job_function():
|
|
32
|
+
pass
|
|
33
|
+
"""
|
|
34
|
+
return JobRegistry.register(name)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from .models import ScheduleConfig
|
|
3
|
+
from .registry import JobRegistry
|
|
4
|
+
from .triggers import build_trigger
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
def sync_jobs(scheduler, last_synced_data):
|
|
9
|
+
"""
|
|
10
|
+
Reconciles the DB ScheduleConfig with the in-memory APScheduler.
|
|
11
|
+
last_synced_data: dict {job_key: (enabled, updated_at_timestamp)}
|
|
12
|
+
"""
|
|
13
|
+
logger.debug("Starting sync_jobs...")
|
|
14
|
+
# print("DEBUG: Syncing jobs...")
|
|
15
|
+
configs = ScheduleConfig.objects.all()
|
|
16
|
+
# print(f"DEBUG: Found {len(configs)} configs in DB.")
|
|
17
|
+
|
|
18
|
+
# Track which jobs we've seen in the DB to handle removals
|
|
19
|
+
active_db_jobs = set()
|
|
20
|
+
|
|
21
|
+
for config in configs:
|
|
22
|
+
job_key = config.job_key
|
|
23
|
+
active_db_jobs.add(job_key)
|
|
24
|
+
|
|
25
|
+
# Check cache to see if update is needed
|
|
26
|
+
current_state = (config.enabled, config.updated_at.timestamp())
|
|
27
|
+
if job_key in last_synced_data and last_synced_data[job_key] == current_state:
|
|
28
|
+
# No changes, skip
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
last_synced_data[job_key] = current_state
|
|
32
|
+
|
|
33
|
+
# 1. Check if job is in registry
|
|
34
|
+
func = JobRegistry.get_job(job_key)
|
|
35
|
+
if not func:
|
|
36
|
+
logger.warning(f"Job '{job_key}' found in config but NOT in registry. Skipping.")
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
# 2. Check if job exists in scheduler
|
|
40
|
+
existing_job = scheduler.get_job(job_key)
|
|
41
|
+
|
|
42
|
+
# 3. Handle Enabled/Disabled
|
|
43
|
+
if not config.enabled:
|
|
44
|
+
# If exists, remove it
|
|
45
|
+
if existing_job:
|
|
46
|
+
logger.info(f"Removing disabled job: {job_key}")
|
|
47
|
+
scheduler.remove_job(job_key)
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
# 4. Handle Active Jobs
|
|
51
|
+
trigger = build_trigger(config)
|
|
52
|
+
|
|
53
|
+
kwargs = {
|
|
54
|
+
'id': job_key,
|
|
55
|
+
'name': job_key,
|
|
56
|
+
'func': func,
|
|
57
|
+
'trigger': trigger,
|
|
58
|
+
'replace_existing': True,
|
|
59
|
+
'coalesce': config.coalesce,
|
|
60
|
+
'max_instances': config.max_instances,
|
|
61
|
+
'misfire_grace_time': config.misfire_grace_time,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Update or Add
|
|
65
|
+
logger.info(f"Syncing job: {job_key}")
|
|
66
|
+
try:
|
|
67
|
+
scheduler.add_job(**kwargs)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
logger.error(f"Failed to add/update job {job_key}: {e}")
|
|
70
|
+
|
|
71
|
+
# 5. Remove jobs that are in Scheduler but NOT in Config (or deleted from DB)
|
|
72
|
+
# Be careful not to remove internal scheduler jobs if any (usually none in MemoryJobStore unless added manually)
|
|
73
|
+
for job in scheduler.get_jobs():
|
|
74
|
+
if job.id not in active_db_jobs:
|
|
75
|
+
logger.info(f"Job {job.id} not in DB config. Removing.")
|
|
76
|
+
scheduler.remove_job(job.id)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from apscheduler.triggers.interval import IntervalTrigger
|
|
2
|
+
from apscheduler.triggers.cron import CronTrigger
|
|
3
|
+
|
|
4
|
+
def build_trigger(config):
|
|
5
|
+
"""
|
|
6
|
+
Builds an APScheduler trigger (IntervalTrigger or CronTrigger)
|
|
7
|
+
based on the ScheduleConfig model instance.
|
|
8
|
+
"""
|
|
9
|
+
if config.trigger_type == 'interval':
|
|
10
|
+
# Default to minutes if not specified or invalid
|
|
11
|
+
unit = config.interval_unit or 'minutes'
|
|
12
|
+
value = config.interval_value or 1
|
|
13
|
+
|
|
14
|
+
# Mapping unit to arguments for IntervalTrigger
|
|
15
|
+
kwargs = {}
|
|
16
|
+
if unit == 'seconds':
|
|
17
|
+
kwargs['seconds'] = value
|
|
18
|
+
elif unit == 'minutes':
|
|
19
|
+
kwargs['minutes'] = value
|
|
20
|
+
elif unit == 'hours':
|
|
21
|
+
kwargs['hours'] = value
|
|
22
|
+
elif unit == 'days':
|
|
23
|
+
kwargs['days'] = value
|
|
24
|
+
elif unit == 'weeks':
|
|
25
|
+
kwargs['weeks'] = value
|
|
26
|
+
else:
|
|
27
|
+
kwargs['minutes'] = value # Fallback
|
|
28
|
+
|
|
29
|
+
return IntervalTrigger(**kwargs)
|
|
30
|
+
|
|
31
|
+
elif config.trigger_type == 'cron':
|
|
32
|
+
# Use config fields for CronTrigger
|
|
33
|
+
return CronTrigger(
|
|
34
|
+
minute=config.cron_minute,
|
|
35
|
+
hour=config.cron_hour,
|
|
36
|
+
day=config.cron_day,
|
|
37
|
+
month=config.cron_month,
|
|
38
|
+
day_of_week=config.cron_day_of_week,
|
|
39
|
+
timezone=config.timezone if hasattr(config, 'timezone') and config.timezone else None
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return None
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zenpulse_scheduler
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A DB-driven APScheduler for Django
|
|
5
|
+
Classifier: Framework :: Django
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: django>=3.2
|
|
10
|
+
Requires-Dist: apscheduler>=3.6.3
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
zenpulse_scheduler/__init__.py
|
|
4
|
+
zenpulse_scheduler/admin.py
|
|
5
|
+
zenpulse_scheduler/apps.py
|
|
6
|
+
zenpulse_scheduler/engine.py
|
|
7
|
+
zenpulse_scheduler/listeners.py
|
|
8
|
+
zenpulse_scheduler/locks.py
|
|
9
|
+
zenpulse_scheduler/models.py
|
|
10
|
+
zenpulse_scheduler/registry.py
|
|
11
|
+
zenpulse_scheduler/sync.py
|
|
12
|
+
zenpulse_scheduler/triggers.py
|
|
13
|
+
zenpulse_scheduler.egg-info/PKG-INFO
|
|
14
|
+
zenpulse_scheduler.egg-info/SOURCES.txt
|
|
15
|
+
zenpulse_scheduler.egg-info/dependency_links.txt
|
|
16
|
+
zenpulse_scheduler.egg-info/requires.txt
|
|
17
|
+
zenpulse_scheduler.egg-info/top_level.txt
|
|
18
|
+
zenpulse_scheduler/management/commands/run_zenpulse_scheduler.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zenpulse_scheduler
|