whenly 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.
- whenly-0.1.0/PKG-INFO +108 -0
- whenly-0.1.0/README.md +83 -0
- whenly-0.1.0/pyproject.toml +44 -0
- whenly-0.1.0/setup.cfg +4 -0
- whenly-0.1.0/src/whenly/__init__.py +6 -0
- whenly-0.1.0/src/whenly/cli.py +95 -0
- whenly-0.1.0/src/whenly/models.py +70 -0
- whenly-0.1.0/src/whenly/runner.py +113 -0
- whenly-0.1.0/src/whenly/scheduler.py +360 -0
- whenly-0.1.0/src/whenly/store.py +247 -0
- whenly-0.1.0/src/whenly.egg-info/PKG-INFO +108 -0
- whenly-0.1.0/src/whenly.egg-info/SOURCES.txt +18 -0
- whenly-0.1.0/src/whenly.egg-info/dependency_links.txt +1 -0
- whenly-0.1.0/src/whenly.egg-info/entry_points.txt +2 -0
- whenly-0.1.0/src/whenly.egg-info/requires.txt +6 -0
- whenly-0.1.0/src/whenly.egg-info/top_level.txt +1 -0
- whenly-0.1.0/tests/test_adversarial.py +500 -0
- whenly-0.1.0/tests/test_integration.py +693 -0
- whenly-0.1.0/tests/test_round2.py +663 -0
- whenly-0.1.0/tests/test_scheduler.py +331 -0
whenly-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: whenly
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight persistent job scheduler for Python
|
|
5
|
+
Author: Teja
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: scheduler,cron,jobs,tasks,periodic
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: croniter>=1.3.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
24
|
+
Requires-Dist: ruff; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# whenly
|
|
27
|
+
|
|
28
|
+
Lightweight persistent job scheduler for Python — SQLite-backed, minimal dependencies.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from whenly import Scheduler
|
|
32
|
+
|
|
33
|
+
s = Scheduler()
|
|
34
|
+
|
|
35
|
+
@s.every(5, minutes)
|
|
36
|
+
def sync_data():
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
@s.cron("0 9 * * MON")
|
|
40
|
+
def weekly_report():
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
s.start() # background thread
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Features
|
|
47
|
+
|
|
48
|
+
- **SQLite persistence** — jobs survive restarts
|
|
49
|
+
- **Cron expressions** via croniter
|
|
50
|
+
- **Interval scheduling** — every N seconds/minutes/hours
|
|
51
|
+
- **One-off delayed jobs** — run once after a delay
|
|
52
|
+
- **Decorator or programmatic API**
|
|
53
|
+
- **Thread-safe** background runner
|
|
54
|
+
- **CLI** for basic management
|
|
55
|
+
- **Zero external dependencies** (except croniter for cron support)
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install whenly
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Quick Start
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from whenly import Scheduler
|
|
67
|
+
|
|
68
|
+
s = Scheduler()
|
|
69
|
+
|
|
70
|
+
# Interval jobs
|
|
71
|
+
@s.every(30, seconds)
|
|
72
|
+
def poll_api():
|
|
73
|
+
print("Polling...")
|
|
74
|
+
|
|
75
|
+
# Cron jobs
|
|
76
|
+
@s.cron("0 */2 * * *")
|
|
77
|
+
def cleanup():
|
|
78
|
+
print("Running cleanup...")
|
|
79
|
+
|
|
80
|
+
# One-off delayed job
|
|
81
|
+
s.later(10, minutes, send_notification, msg="Hello")
|
|
82
|
+
|
|
83
|
+
# Start the scheduler (non-blocking)
|
|
84
|
+
s.start()
|
|
85
|
+
|
|
86
|
+
# Or run blocking
|
|
87
|
+
# s.run()
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Programmatic API
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
s.add_job("poll", interval=60, unit="seconds", fn=poll_api)
|
|
94
|
+
s.add_job("cleanup", cron="0 3 * * *", fn=cleanup)
|
|
95
|
+
s.remove_job("poll")
|
|
96
|
+
s.list_jobs()
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## CLI
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
whenly list
|
|
103
|
+
whenly run
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT
|
whenly-0.1.0/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# whenly
|
|
2
|
+
|
|
3
|
+
Lightweight persistent job scheduler for Python — SQLite-backed, minimal dependencies.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from whenly import Scheduler
|
|
7
|
+
|
|
8
|
+
s = Scheduler()
|
|
9
|
+
|
|
10
|
+
@s.every(5, minutes)
|
|
11
|
+
def sync_data():
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
@s.cron("0 9 * * MON")
|
|
15
|
+
def weekly_report():
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
s.start() # background thread
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- **SQLite persistence** — jobs survive restarts
|
|
24
|
+
- **Cron expressions** via croniter
|
|
25
|
+
- **Interval scheduling** — every N seconds/minutes/hours
|
|
26
|
+
- **One-off delayed jobs** — run once after a delay
|
|
27
|
+
- **Decorator or programmatic API**
|
|
28
|
+
- **Thread-safe** background runner
|
|
29
|
+
- **CLI** for basic management
|
|
30
|
+
- **Zero external dependencies** (except croniter for cron support)
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install whenly
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from whenly import Scheduler
|
|
42
|
+
|
|
43
|
+
s = Scheduler()
|
|
44
|
+
|
|
45
|
+
# Interval jobs
|
|
46
|
+
@s.every(30, seconds)
|
|
47
|
+
def poll_api():
|
|
48
|
+
print("Polling...")
|
|
49
|
+
|
|
50
|
+
# Cron jobs
|
|
51
|
+
@s.cron("0 */2 * * *")
|
|
52
|
+
def cleanup():
|
|
53
|
+
print("Running cleanup...")
|
|
54
|
+
|
|
55
|
+
# One-off delayed job
|
|
56
|
+
s.later(10, minutes, send_notification, msg="Hello")
|
|
57
|
+
|
|
58
|
+
# Start the scheduler (non-blocking)
|
|
59
|
+
s.start()
|
|
60
|
+
|
|
61
|
+
# Or run blocking
|
|
62
|
+
# s.run()
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Programmatic API
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
s.add_job("poll", interval=60, unit="seconds", fn=poll_api)
|
|
69
|
+
s.add_job("cleanup", cron="0 3 * * *", fn=cleanup)
|
|
70
|
+
s.remove_job("poll")
|
|
71
|
+
s.list_jobs()
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## CLI
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
whenly list
|
|
78
|
+
whenly run
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "whenly"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Lightweight persistent job scheduler for Python"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{name = "Teja"}]
|
|
13
|
+
keywords = ["scheduler", "cron", "jobs", "tasks", "periodic"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
"Operating System :: OS Independent",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"croniter>=1.3.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = ["pytest>=7.0", "pytest-cov", "ruff"]
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
whenly = "whenly.cli:main"
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["src"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
target-version = "py310"
|
|
44
|
+
line-length = 100
|
whenly-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""CLI for whenly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
|
|
9
|
+
from .models import JobStatus
|
|
10
|
+
from .store import Store
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> None:
|
|
14
|
+
parser = argparse.ArgumentParser(prog="whenly", description="Lightweight job scheduler")
|
|
15
|
+
parser.add_argument("--db", default="whenly.db", help="Database path")
|
|
16
|
+
sub = parser.add_subparsers(dest="command")
|
|
17
|
+
|
|
18
|
+
sub.add_parser("list", help="List all jobs and next run time")
|
|
19
|
+
sub.add_parser("history", help="Show job run history").add_argument("name")
|
|
20
|
+
|
|
21
|
+
run_parser = sub.add_parser("run", help="Manually trigger a job")
|
|
22
|
+
run_parser.add_argument("name")
|
|
23
|
+
|
|
24
|
+
args = parser.parse_args(argv)
|
|
25
|
+
if not args.command:
|
|
26
|
+
parser.print_help()
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
|
|
29
|
+
store = Store(args.db)
|
|
30
|
+
|
|
31
|
+
if args.command == "list":
|
|
32
|
+
jobs = store.list_jobs()
|
|
33
|
+
if not jobs:
|
|
34
|
+
print("No jobs registered.")
|
|
35
|
+
return
|
|
36
|
+
for j in jobs:
|
|
37
|
+
next_run = j.next_run_at.strftime("%Y-%m-%d %H:%M:%S") if j.next_run_at else "—"
|
|
38
|
+
status = "✓" if j.enabled else "✗"
|
|
39
|
+
sched = _fmt_schedule(j)
|
|
40
|
+
print(f" {status} {j.name:<30} [{sched:<12}] next: {next_run}")
|
|
41
|
+
|
|
42
|
+
elif args.command == "history":
|
|
43
|
+
runs = store.get_runs(args.name, limit=20)
|
|
44
|
+
if not runs:
|
|
45
|
+
print(f"No runs found for '{args.name}'.")
|
|
46
|
+
return
|
|
47
|
+
for r in runs:
|
|
48
|
+
ts = r.started_at.strftime("%Y-%m-%d %H:%M:%S") if r.started_at else "?"
|
|
49
|
+
dur = f"{r.duration_seconds:.1f}s" if r.duration_seconds else "?"
|
|
50
|
+
status_icon = {
|
|
51
|
+
JobStatus.SUCCESS: "✓",
|
|
52
|
+
JobStatus.FAILED: "✗",
|
|
53
|
+
JobStatus.TIMEOUT: "⏱",
|
|
54
|
+
JobStatus.RUNNING: "⟳",
|
|
55
|
+
}.get(r.status, "?")
|
|
56
|
+
err = f" — {r.error_message.splitlines()[-1][:60]}" if r.error_message else ""
|
|
57
|
+
print(f" {status_icon} {ts} {dur:>8} {r.status.value:<8}{err}")
|
|
58
|
+
|
|
59
|
+
elif args.command == "run":
|
|
60
|
+
# For run, we need to try to import and execute the function
|
|
61
|
+
job = store.get_job(args.name)
|
|
62
|
+
if not job:
|
|
63
|
+
print(f"Job '{args.name}' not found.")
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
try:
|
|
66
|
+
parts = job.func_path.rsplit(".", 1)
|
|
67
|
+
if len(parts) == 2:
|
|
68
|
+
import importlib
|
|
69
|
+
mod = importlib.import_module(parts[0])
|
|
70
|
+
func = getattr(mod, parts[1])
|
|
71
|
+
print(f"Running {args.name}...")
|
|
72
|
+
func()
|
|
73
|
+
print("Done.")
|
|
74
|
+
else:
|
|
75
|
+
print(f"Cannot resolve function: {job.func_path}")
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
print(f"Error: {e}")
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
|
|
81
|
+
store.close()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _fmt_schedule(job) -> str:
|
|
85
|
+
if job.schedule_type.value == "interval":
|
|
86
|
+
return f"every {job.interval_seconds}s"
|
|
87
|
+
elif job.schedule_type.value == "cron":
|
|
88
|
+
return job.cron_expr or "?"
|
|
89
|
+
elif job.schedule_type.value == "once":
|
|
90
|
+
return f"once at {job.scheduled_for or '?'}"
|
|
91
|
+
return job.schedule_type.value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
main()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Data models for whenly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _utcnow() -> datetime:
|
|
13
|
+
return datetime.utcnow()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _uuid() -> str:
|
|
17
|
+
return uuid.uuid4().hex
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ScheduleType(str, Enum):
|
|
21
|
+
INTERVAL = "interval"
|
|
22
|
+
CRON = "cron"
|
|
23
|
+
ONCE = "once"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MissedPolicy(str, Enum):
|
|
27
|
+
RUN_ONCE = "run_once"
|
|
28
|
+
RUN_ALL = "run_all"
|
|
29
|
+
SKIP = "skip"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class JobStatus(str, Enum):
|
|
33
|
+
SUCCESS = "success"
|
|
34
|
+
FAILED = "failed"
|
|
35
|
+
TIMEOUT = "timeout"
|
|
36
|
+
RUNNING = "running"
|
|
37
|
+
PENDING = "pending"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Job:
|
|
42
|
+
name: str
|
|
43
|
+
func_path: str
|
|
44
|
+
schedule_type: ScheduleType
|
|
45
|
+
interval_seconds: int | float | None = None
|
|
46
|
+
cron_expr: str | None = None
|
|
47
|
+
scheduled_for: datetime | None = None
|
|
48
|
+
missed_policy: MissedPolicy = MissedPolicy.RUN_ONCE
|
|
49
|
+
max_concurrent: int = 1
|
|
50
|
+
timeout_seconds: int | None = None
|
|
51
|
+
enabled: bool = True
|
|
52
|
+
id: str = field(default_factory=_uuid)
|
|
53
|
+
next_run_at: datetime | None = None
|
|
54
|
+
last_run_at: datetime | None = None
|
|
55
|
+
created_at: datetime = field(default_factory=_utcnow)
|
|
56
|
+
updated_at: datetime = field(default_factory=_utcnow)
|
|
57
|
+
# Runtime only — not persisted
|
|
58
|
+
func: Any = field(default=None, repr=False, compare=False)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class JobRun:
|
|
63
|
+
job_id: str
|
|
64
|
+
status: JobStatus = JobStatus.PENDING
|
|
65
|
+
started_at: datetime | None = None
|
|
66
|
+
finished_at: datetime | None = None
|
|
67
|
+
duration_seconds: float | None = None
|
|
68
|
+
error_message: str | None = None
|
|
69
|
+
scheduled_for: datetime | None = None
|
|
70
|
+
id: str = field(default_factory=_uuid)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Job execution engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import traceback
|
|
8
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
from .models import Job, JobRun, JobStatus
|
|
13
|
+
from .store import Store
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class JobRunner:
|
|
19
|
+
"""Executes jobs in a thread pool."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, store: Store, max_workers: int = 10) -> None:
|
|
22
|
+
self._store = store
|
|
23
|
+
self._max_workers = max_workers
|
|
24
|
+
self._executor: ThreadPoolExecutor | None = None
|
|
25
|
+
self._futures: dict[str, Future[None]] = {}
|
|
26
|
+
self._lock = threading.Lock()
|
|
27
|
+
|
|
28
|
+
def _ensure_executor(self) -> ThreadPoolExecutor:
|
|
29
|
+
if self._executor is None:
|
|
30
|
+
self._executor = ThreadPoolExecutor(max_workers=self._max_workers)
|
|
31
|
+
return self._executor
|
|
32
|
+
|
|
33
|
+
def submit(self, job: Job, scheduled_for: datetime | None = None) -> bool:
|
|
34
|
+
"""Submit a job for execution. Returns True if submitted."""
|
|
35
|
+
if not job.enabled:
|
|
36
|
+
return False
|
|
37
|
+
running = self._store.get_running_count(job.id)
|
|
38
|
+
if running >= job.max_concurrent:
|
|
39
|
+
logger.debug("Job %s at max concurrent (%d), skipping", job.name, job.max_concurrent)
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
run = JobRun(
|
|
43
|
+
job_id=job.id,
|
|
44
|
+
status=JobStatus.RUNNING,
|
|
45
|
+
started_at=datetime.utcnow(),
|
|
46
|
+
scheduled_for=scheduled_for,
|
|
47
|
+
)
|
|
48
|
+
self._store.save_run(run)
|
|
49
|
+
|
|
50
|
+
executor = self._ensure_executor()
|
|
51
|
+
with self._lock:
|
|
52
|
+
self._futures[run.id] = executor.submit(self._execute, job, run)
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
def _execute(self, job: Job, run: JobRun) -> None:
|
|
56
|
+
func: Callable[..., Any] = job.func
|
|
57
|
+
if func is None:
|
|
58
|
+
func = _resolve_func(job.func_path)
|
|
59
|
+
if func is None:
|
|
60
|
+
run.status = JobStatus.FAILED
|
|
61
|
+
run.error_message = f"Cannot resolve function: {job.func_path}"
|
|
62
|
+
self._store.update_run(run)
|
|
63
|
+
with self._lock:
|
|
64
|
+
self._futures.pop(run.id, None)
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
args = getattr(job, '_args', ()) or ()
|
|
69
|
+
kwargs = getattr(job, '_kwargs', {}) or {}
|
|
70
|
+
result = func(*args, **kwargs)
|
|
71
|
+
# Check if result is a future we should wait on
|
|
72
|
+
if isinstance(result, Future):
|
|
73
|
+
result.result(timeout=job.timeout_seconds)
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
if isinstance(exc, TimeoutError):
|
|
76
|
+
run.status = JobStatus.TIMEOUT
|
|
77
|
+
else:
|
|
78
|
+
run.status = JobStatus.FAILED
|
|
79
|
+
run.error_message = traceback.format_exc()
|
|
80
|
+
logger.error("Job %s failed: %s", job.name, exc)
|
|
81
|
+
finally:
|
|
82
|
+
run.finished_at = datetime.utcnow()
|
|
83
|
+
run.duration_seconds = (run.finished_at - (run.started_at or run.finished_at)).total_seconds()
|
|
84
|
+
if run.status == JobStatus.RUNNING:
|
|
85
|
+
run.status = JobStatus.SUCCESS
|
|
86
|
+
self._store.update_run(run)
|
|
87
|
+
self._store.update_last_run(job.id, run.finished_at)
|
|
88
|
+
|
|
89
|
+
with self._lock:
|
|
90
|
+
self._futures.pop(run.id, None)
|
|
91
|
+
|
|
92
|
+
def shutdown(self, wait: bool = True) -> None:
|
|
93
|
+
with self._lock:
|
|
94
|
+
executor = self._executor
|
|
95
|
+
self._executor = None
|
|
96
|
+
if executor is not None:
|
|
97
|
+
executor.shutdown(wait=wait)
|
|
98
|
+
with self._lock:
|
|
99
|
+
self._futures.clear()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _resolve_func(func_path: str) -> Callable[..., Any] | None:
|
|
103
|
+
"""Import a function from its dotted path (e.g. 'mymodule.jobs.sync_data')."""
|
|
104
|
+
try:
|
|
105
|
+
parts = func_path.rsplit(".", 1)
|
|
106
|
+
if len(parts) == 2:
|
|
107
|
+
mod_path, func_name = parts
|
|
108
|
+
import importlib
|
|
109
|
+
mod = importlib.import_module(mod_path)
|
|
110
|
+
return getattr(mod, func_name, None)
|
|
111
|
+
except Exception:
|
|
112
|
+
logger.debug("Cannot resolve %s", func_path, exc_info=True)
|
|
113
|
+
return None
|