pybgworker 0.2.1__py3-none-any.whl
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.
- pybgworker/__init__.py +6 -0
- pybgworker/backends.py +47 -0
- pybgworker/cancel.py +29 -0
- pybgworker/cli.py +69 -0
- pybgworker/config.py +7 -0
- pybgworker/failed.py +24 -0
- pybgworker/inspect.py +42 -0
- pybgworker/logger.py +14 -0
- pybgworker/purge.py +14 -0
- pybgworker/queue.py +23 -0
- pybgworker/ratelimit.py +26 -0
- pybgworker/result.py +64 -0
- pybgworker/retry.py +30 -0
- pybgworker/scheduler.py +56 -0
- pybgworker/sqlite_queue.py +140 -0
- pybgworker/state.py +27 -0
- pybgworker/stats.py +27 -0
- pybgworker/task.py +63 -0
- pybgworker/utils.py +31 -0
- pybgworker/worker.py +122 -0
- pybgworker-0.2.1.dist-info/METADATA +209 -0
- pybgworker-0.2.1.dist-info/RECORD +26 -0
- pybgworker-0.2.1.dist-info/WHEEL +5 -0
- pybgworker-0.2.1.dist-info/entry_points.txt +2 -0
- pybgworker-0.2.1.dist-info/licenses/LICENSE +21 -0
- pybgworker-0.2.1.dist-info/top_level.txt +1 -0
pybgworker/__init__.py
ADDED
pybgworker/backends.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
import sqlite3
|
|
3
|
+
import json
|
|
4
|
+
from .config import DB_PATH
|
|
5
|
+
|
|
6
|
+
class BaseBackend(ABC):
|
|
7
|
+
@abstractmethod
|
|
8
|
+
def get_task(self, task_id):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def store_result(self, task_id, result):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def forget(self, task_id):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SQLiteBackend(BaseBackend):
|
|
21
|
+
def __init__(self, db_path=DB_PATH):
|
|
22
|
+
self.db_path = db_path
|
|
23
|
+
|
|
24
|
+
def get_task(self, task_id):
|
|
25
|
+
from .utils import get_conn
|
|
26
|
+
with get_conn() as conn:
|
|
27
|
+
conn.row_factory = sqlite3.Row
|
|
28
|
+
row = conn.execute(
|
|
29
|
+
"SELECT * FROM tasks WHERE id=?",
|
|
30
|
+
(task_id,)
|
|
31
|
+
).fetchone()
|
|
32
|
+
return dict(row) if row else None
|
|
33
|
+
|
|
34
|
+
def store_result(self, task_id, result):
|
|
35
|
+
from .utils import get_conn
|
|
36
|
+
with get_conn() as conn:
|
|
37
|
+
conn.execute(
|
|
38
|
+
"UPDATE tasks SET result=? WHERE id=?",
|
|
39
|
+
(json.dumps(result), task_id)
|
|
40
|
+
)
|
|
41
|
+
conn.commit()
|
|
42
|
+
|
|
43
|
+
def forget(self, task_id):
|
|
44
|
+
from .utils import get_conn
|
|
45
|
+
with get_conn() as conn:
|
|
46
|
+
conn.execute("DELETE FROM tasks WHERE id=?", (task_id,))
|
|
47
|
+
conn.commit()
|
pybgworker/cancel.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from .utils import get_conn, now
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def cancel(task_id):
|
|
5
|
+
with get_conn() as conn:
|
|
6
|
+
row = conn.execute(
|
|
7
|
+
"SELECT status FROM tasks WHERE id=?",
|
|
8
|
+
(task_id,)
|
|
9
|
+
).fetchone()
|
|
10
|
+
|
|
11
|
+
if not row:
|
|
12
|
+
print("โ Task not found")
|
|
13
|
+
return
|
|
14
|
+
|
|
15
|
+
if row[0] != "running":
|
|
16
|
+
print("โ Task is not running")
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
conn.execute("""
|
|
20
|
+
UPDATE tasks
|
|
21
|
+
SET status='cancelled',
|
|
22
|
+
finished_at=?,
|
|
23
|
+
updated_at=?
|
|
24
|
+
WHERE id=?
|
|
25
|
+
""", (now().isoformat(), now().isoformat(), task_id))
|
|
26
|
+
|
|
27
|
+
conn.commit()
|
|
28
|
+
|
|
29
|
+
print("๐ Task cancelled")
|
pybgworker/cli.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
import os
|
|
4
|
+
import importlib
|
|
5
|
+
|
|
6
|
+
from .worker import run_worker
|
|
7
|
+
from .inspect import inspect
|
|
8
|
+
from .retry import retry
|
|
9
|
+
from .purge import purge
|
|
10
|
+
from .cancel import cancel
|
|
11
|
+
from .failed import list_failed
|
|
12
|
+
from .stats import stats
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main():
|
|
16
|
+
parser = argparse.ArgumentParser("pybgworker")
|
|
17
|
+
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"command",
|
|
20
|
+
choices=["run", "inspect", "retry", "purge", "cancel", "failed", "stats"],
|
|
21
|
+
help="worker control commands"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"task_id",
|
|
26
|
+
nargs="?",
|
|
27
|
+
help="task id for retry/cancel"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--app",
|
|
32
|
+
help="module containing task definitions (required for run)"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
args = parser.parse_args()
|
|
36
|
+
|
|
37
|
+
if args.command == "run":
|
|
38
|
+
if not args.app:
|
|
39
|
+
parser.error("--app is required for 'run'")
|
|
40
|
+
|
|
41
|
+
sys.path.insert(0, os.getcwd())
|
|
42
|
+
importlib.import_module(args.app)
|
|
43
|
+
run_worker()
|
|
44
|
+
|
|
45
|
+
elif args.command == "inspect":
|
|
46
|
+
inspect()
|
|
47
|
+
|
|
48
|
+
elif args.command == "retry":
|
|
49
|
+
if not args.task_id:
|
|
50
|
+
parser.error("retry requires task_id")
|
|
51
|
+
retry(args.task_id)
|
|
52
|
+
|
|
53
|
+
elif args.command == "purge":
|
|
54
|
+
purge()
|
|
55
|
+
|
|
56
|
+
elif args.command == "cancel":
|
|
57
|
+
if not args.task_id:
|
|
58
|
+
parser.error("cancel requires task_id")
|
|
59
|
+
cancel(args.task_id)
|
|
60
|
+
|
|
61
|
+
elif args.command == "failed":
|
|
62
|
+
list_failed()
|
|
63
|
+
|
|
64
|
+
elif args.command == "stats":
|
|
65
|
+
stats()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
main()
|
pybgworker/config.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import os
|
|
2
|
+
WORKER_TIMEOUT = 15
|
|
3
|
+
RATE_LIMIT = 5 # tasks per second
|
|
4
|
+
DB_PATH = os.getenv("PYBGWORKER_DB", "pybgworker.db")
|
|
5
|
+
WORKER_NAME = os.getenv("PYBGWORKER_WORKER_NAME", "worker-1")
|
|
6
|
+
POLL_INTERVAL = float(os.getenv("PYBGWORKER_POLL_INTERVAL", 1.0))
|
|
7
|
+
LOCK_TIMEOUT = int(os.getenv("PYBGWORKER_LOCK_TIMEOUT", 60))
|
pybgworker/failed.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from .utils import get_conn
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def list_failed():
|
|
5
|
+
with get_conn() as conn:
|
|
6
|
+
rows = conn.execute("""
|
|
7
|
+
SELECT id, name, attempt, last_error
|
|
8
|
+
FROM tasks
|
|
9
|
+
WHERE status='failed'
|
|
10
|
+
ORDER BY updated_at DESC
|
|
11
|
+
""").fetchall()
|
|
12
|
+
|
|
13
|
+
if not rows:
|
|
14
|
+
print("โ
No failed tasks")
|
|
15
|
+
return
|
|
16
|
+
|
|
17
|
+
print("\nโ Failed Tasks\n")
|
|
18
|
+
|
|
19
|
+
for r in rows:
|
|
20
|
+
print(f"ID: {r[0]}")
|
|
21
|
+
print(f"Task: {r[1]}")
|
|
22
|
+
print(f"Attempts: {r[2]}")
|
|
23
|
+
print(f"Error: {r[3][:120] if r[3] else 'None'}")
|
|
24
|
+
print("-" * 40)
|
pybgworker/inspect.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from .utils import get_conn
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def inspect():
|
|
6
|
+
with get_conn() as conn:
|
|
7
|
+
conn.row_factory = dict_factory
|
|
8
|
+
|
|
9
|
+
print("\n๐ฆ Task Stats")
|
|
10
|
+
|
|
11
|
+
stats = conn.execute("""
|
|
12
|
+
SELECT status, COUNT(*) as count
|
|
13
|
+
FROM tasks
|
|
14
|
+
GROUP BY status
|
|
15
|
+
""").fetchall()
|
|
16
|
+
|
|
17
|
+
total = 0
|
|
18
|
+
for row in stats:
|
|
19
|
+
print(f"{row['status']:10} {row['count']}")
|
|
20
|
+
total += row["count"]
|
|
21
|
+
|
|
22
|
+
print(f"{'total':10} {total}")
|
|
23
|
+
|
|
24
|
+
print("\n๐ท Workers")
|
|
25
|
+
|
|
26
|
+
workers = conn.execute("""
|
|
27
|
+
SELECT name, last_seen
|
|
28
|
+
FROM workers
|
|
29
|
+
""").fetchall()
|
|
30
|
+
|
|
31
|
+
now = datetime.now(timezone.utc)
|
|
32
|
+
|
|
33
|
+
for w in workers:
|
|
34
|
+
last_seen = datetime.fromisoformat(w["last_seen"])
|
|
35
|
+
delta = (now - last_seen).total_seconds()
|
|
36
|
+
|
|
37
|
+
status = "alive" if delta < 15 else "dead"
|
|
38
|
+
print(f"{w['name']:10} {status:5} ({int(delta)}s ago)")
|
|
39
|
+
|
|
40
|
+
print()
|
|
41
|
+
def dict_factory(cursor, row):
|
|
42
|
+
return {col[0]: row[idx] for idx, col in enumerate(cursor.description)}
|
pybgworker/logger.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def log(event, **fields):
|
|
7
|
+
entry = {
|
|
8
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
9
|
+
"event": event,
|
|
10
|
+
**fields
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
sys.stdout.write(json.dumps(entry) + "\n")
|
|
14
|
+
sys.stdout.flush()
|
pybgworker/purge.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .utils import get_conn
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def purge():
|
|
5
|
+
with get_conn() as conn:
|
|
6
|
+
cursor = conn.execute("""
|
|
7
|
+
DELETE FROM tasks
|
|
8
|
+
WHERE status IN ('queued', 'retrying')
|
|
9
|
+
""")
|
|
10
|
+
|
|
11
|
+
deleted = cursor.rowcount
|
|
12
|
+
conn.commit()
|
|
13
|
+
|
|
14
|
+
print(f"๐งน Purged {deleted} queued tasks")
|
pybgworker/queue.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
class BaseQueue(ABC):
|
|
4
|
+
|
|
5
|
+
@abstractmethod
|
|
6
|
+
def enqueue(self, task: dict):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def fetch_next(self, worker_name: str):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def ack(self, task_id: str):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def fail(self, task_id: str, error: str):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def reschedule(self, task_id: str, run_at):
|
|
23
|
+
pass
|
pybgworker/ratelimit.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import threading
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RateLimiter:
|
|
6
|
+
def __init__(self, rate_per_sec):
|
|
7
|
+
self.rate = rate_per_sec
|
|
8
|
+
self.lock = threading.Lock()
|
|
9
|
+
self.timestamps = []
|
|
10
|
+
|
|
11
|
+
def acquire(self):
|
|
12
|
+
with self.lock:
|
|
13
|
+
now = time.time()
|
|
14
|
+
|
|
15
|
+
# remove old timestamps
|
|
16
|
+
self.timestamps = [
|
|
17
|
+
t for t in self.timestamps
|
|
18
|
+
if now - t < 1
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
if len(self.timestamps) >= self.rate:
|
|
22
|
+
sleep_time = 1 - (now - self.timestamps[0])
|
|
23
|
+
if sleep_time > 0:
|
|
24
|
+
time.sleep(sleep_time)
|
|
25
|
+
|
|
26
|
+
self.timestamps.append(time.time())
|
pybgworker/result.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import json
|
|
3
|
+
from .state import TaskState
|
|
4
|
+
from .backends import BaseBackend, SQLiteBackend
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AsyncResult:
|
|
8
|
+
def __init__(self, task_id, backend: BaseBackend = None):
|
|
9
|
+
self.task_id = task_id
|
|
10
|
+
self.backend = backend or SQLiteBackend()
|
|
11
|
+
|
|
12
|
+
def _fetch(self):
|
|
13
|
+
return self.backend.get_task(self.task_id)
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def task_info(self):
|
|
17
|
+
return self._fetch()
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def status(self):
|
|
21
|
+
task = self._fetch()
|
|
22
|
+
return task["status"] if task else None
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def result(self):
|
|
26
|
+
task = self._fetch()
|
|
27
|
+
if task and task["result"]:
|
|
28
|
+
return json.loads(task["result"])
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def error(self):
|
|
33
|
+
task = self._fetch()
|
|
34
|
+
return task["last_error"] if task else None
|
|
35
|
+
|
|
36
|
+
def ready(self):
|
|
37
|
+
return self.status in (
|
|
38
|
+
TaskState.SUCCESS.value,
|
|
39
|
+
TaskState.FAILED.value,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def successful(self):
|
|
43
|
+
return self.status == TaskState.SUCCESS.value
|
|
44
|
+
|
|
45
|
+
def failed(self):
|
|
46
|
+
return self.status == TaskState.FAILED.value
|
|
47
|
+
|
|
48
|
+
def get(self, timeout=None):
|
|
49
|
+
start_time = time.time()
|
|
50
|
+
while True:
|
|
51
|
+
if self.ready():
|
|
52
|
+
if self.successful():
|
|
53
|
+
return self.result
|
|
54
|
+
else:
|
|
55
|
+
raise Exception(self.error)
|
|
56
|
+
if timeout and time.time() - start_time > timeout:
|
|
57
|
+
raise TimeoutError("Timeout waiting for task to complete")
|
|
58
|
+
time.sleep(0.1)
|
|
59
|
+
|
|
60
|
+
def forget(self):
|
|
61
|
+
self.backend.forget(self.task_id)
|
|
62
|
+
|
|
63
|
+
def __repr__(self):
|
|
64
|
+
return f"<AsyncResult(task_id='{self.task_id}', status='{self.status}')>"
|
pybgworker/retry.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from .utils import get_conn, now
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def retry(task_id):
|
|
5
|
+
with get_conn() as conn:
|
|
6
|
+
row = conn.execute(
|
|
7
|
+
"SELECT status FROM tasks WHERE id=?",
|
|
8
|
+
(task_id,)
|
|
9
|
+
).fetchone()
|
|
10
|
+
|
|
11
|
+
if not row:
|
|
12
|
+
print("โ Task not found")
|
|
13
|
+
return
|
|
14
|
+
|
|
15
|
+
if row[0] != "failed":
|
|
16
|
+
print("โ Task is not failed")
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
conn.execute("""
|
|
20
|
+
UPDATE tasks
|
|
21
|
+
SET status='queued',
|
|
22
|
+
attempt=0,
|
|
23
|
+
last_error=NULL,
|
|
24
|
+
updated_at=?
|
|
25
|
+
WHERE id=?
|
|
26
|
+
""", (now().isoformat(), task_id))
|
|
27
|
+
|
|
28
|
+
conn.commit()
|
|
29
|
+
|
|
30
|
+
print("๐ Task requeued")
|
pybgworker/scheduler.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from croniter import croniter
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from .task import queue
|
|
5
|
+
from .utils import generate_id, now, dumps
|
|
6
|
+
from .state import TaskState
|
|
7
|
+
from .logger import log
|
|
8
|
+
|
|
9
|
+
CRON_REGISTRY = []
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cron(expr):
|
|
13
|
+
def decorator(func):
|
|
14
|
+
CRON_REGISTRY.append((expr, func))
|
|
15
|
+
return func
|
|
16
|
+
return decorator
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_scheduler():
|
|
20
|
+
log("scheduler_start")
|
|
21
|
+
|
|
22
|
+
next_run = {}
|
|
23
|
+
|
|
24
|
+
while True:
|
|
25
|
+
current = datetime.now(timezone.utc)
|
|
26
|
+
|
|
27
|
+
for expr, func in CRON_REGISTRY:
|
|
28
|
+
if expr not in next_run:
|
|
29
|
+
next_run[expr] = croniter(expr, current).get_next(datetime)
|
|
30
|
+
|
|
31
|
+
if current >= next_run[expr]:
|
|
32
|
+
task = {
|
|
33
|
+
"id": generate_id(),
|
|
34
|
+
"name": func.__name__,
|
|
35
|
+
"args": dumps(()),
|
|
36
|
+
"kwargs": dumps({}),
|
|
37
|
+
"status": TaskState.QUEUED.value,
|
|
38
|
+
"attempt": 0,
|
|
39
|
+
"max_retries": 0,
|
|
40
|
+
"run_at": now().isoformat(),
|
|
41
|
+
"priority": 5,
|
|
42
|
+
"locked_by": None,
|
|
43
|
+
"locked_at": None,
|
|
44
|
+
"last_error": None,
|
|
45
|
+
"result": None,
|
|
46
|
+
"created_at": now().isoformat(),
|
|
47
|
+
"updated_at": now().isoformat(),
|
|
48
|
+
"finished_at": None,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
queue.enqueue(task)
|
|
52
|
+
log("cron_fired", task_name=func.__name__)
|
|
53
|
+
|
|
54
|
+
next_run[expr] = croniter(expr, current).get_next(datetime)
|
|
55
|
+
|
|
56
|
+
time.sleep(1)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
from .queue import BaseQueue
|
|
4
|
+
from .config import DB_PATH, WORKER_TIMEOUT
|
|
5
|
+
from .utils import now, get_conn
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SQLiteQueue(BaseQueue):
|
|
9
|
+
|
|
10
|
+
def __init__(self, db_path=DB_PATH):
|
|
11
|
+
self._init_db()
|
|
12
|
+
|
|
13
|
+
# ---------------- DB init ----------------
|
|
14
|
+
|
|
15
|
+
def _init_db(self):
|
|
16
|
+
with get_conn() as conn:
|
|
17
|
+
conn.execute("""
|
|
18
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
19
|
+
id TEXT PRIMARY KEY,
|
|
20
|
+
name TEXT,
|
|
21
|
+
args TEXT,
|
|
22
|
+
kwargs TEXT,
|
|
23
|
+
status TEXT,
|
|
24
|
+
attempt INTEGER,
|
|
25
|
+
max_retries INTEGER,
|
|
26
|
+
run_at TEXT,
|
|
27
|
+
priority INTEGER DEFAULT 5,
|
|
28
|
+
locked_by TEXT,
|
|
29
|
+
locked_at TEXT,
|
|
30
|
+
last_error TEXT,
|
|
31
|
+
result TEXT,
|
|
32
|
+
created_at TEXT,
|
|
33
|
+
updated_at TEXT,
|
|
34
|
+
finished_at TEXT
|
|
35
|
+
)
|
|
36
|
+
""")
|
|
37
|
+
|
|
38
|
+
conn.execute("""
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_priority_runat
|
|
40
|
+
ON tasks(status, priority, run_at)
|
|
41
|
+
""")
|
|
42
|
+
|
|
43
|
+
conn.execute("""
|
|
44
|
+
CREATE TABLE IF NOT EXISTS workers (
|
|
45
|
+
name TEXT PRIMARY KEY,
|
|
46
|
+
last_seen TEXT
|
|
47
|
+
)
|
|
48
|
+
""")
|
|
49
|
+
|
|
50
|
+
conn.commit()
|
|
51
|
+
|
|
52
|
+
# ---------------- enqueue ----------------
|
|
53
|
+
|
|
54
|
+
def enqueue(self, task):
|
|
55
|
+
with get_conn() as conn:
|
|
56
|
+
conn.execute("""
|
|
57
|
+
INSERT INTO tasks VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
58
|
+
""", tuple(task.values()))
|
|
59
|
+
conn.commit()
|
|
60
|
+
|
|
61
|
+
# ---------------- atomic fetch ----------------
|
|
62
|
+
|
|
63
|
+
def fetch_next(self, worker):
|
|
64
|
+
stale_time = (now() - timedelta(seconds=WORKER_TIMEOUT)).isoformat()
|
|
65
|
+
|
|
66
|
+
with get_conn() as conn:
|
|
67
|
+
conn.row_factory = sqlite3.Row
|
|
68
|
+
|
|
69
|
+
row = conn.execute("""
|
|
70
|
+
UPDATE tasks
|
|
71
|
+
SET status='running',
|
|
72
|
+
locked_by=?,
|
|
73
|
+
locked_at=?,
|
|
74
|
+
updated_at=?
|
|
75
|
+
WHERE id = (
|
|
76
|
+
SELECT t.id FROM tasks t
|
|
77
|
+
LEFT JOIN workers w ON t.locked_by = w.name
|
|
78
|
+
WHERE
|
|
79
|
+
(
|
|
80
|
+
t.status IN ('queued','retrying')
|
|
81
|
+
OR
|
|
82
|
+
(t.status='running' AND w.last_seen < ?)
|
|
83
|
+
)
|
|
84
|
+
AND t.run_at <= ?
|
|
85
|
+
ORDER BY t.priority ASC, t.run_at ASC
|
|
86
|
+
LIMIT 1
|
|
87
|
+
)
|
|
88
|
+
RETURNING *
|
|
89
|
+
""", (
|
|
90
|
+
worker,
|
|
91
|
+
now().isoformat(),
|
|
92
|
+
now().isoformat(),
|
|
93
|
+
stale_time,
|
|
94
|
+
now().isoformat()
|
|
95
|
+
)).fetchone()
|
|
96
|
+
|
|
97
|
+
conn.commit()
|
|
98
|
+
return dict(row) if row else None
|
|
99
|
+
|
|
100
|
+
# ---------------- ack ----------------
|
|
101
|
+
|
|
102
|
+
def ack(self, task_id):
|
|
103
|
+
with get_conn() as conn:
|
|
104
|
+
conn.execute("""
|
|
105
|
+
UPDATE tasks
|
|
106
|
+
SET status='success',
|
|
107
|
+
finished_at=?,
|
|
108
|
+
updated_at=?
|
|
109
|
+
WHERE id=?
|
|
110
|
+
""", (now().isoformat(), now().isoformat(), task_id))
|
|
111
|
+
conn.commit()
|
|
112
|
+
|
|
113
|
+
# ---------------- fail ----------------
|
|
114
|
+
|
|
115
|
+
def fail(self, task_id, error):
|
|
116
|
+
with get_conn() as conn:
|
|
117
|
+
conn.execute("""
|
|
118
|
+
UPDATE tasks
|
|
119
|
+
SET status='failed',
|
|
120
|
+
last_error=?,
|
|
121
|
+
finished_at=?,
|
|
122
|
+
updated_at=?
|
|
123
|
+
WHERE id=?
|
|
124
|
+
""", (error, now().isoformat(), now().isoformat(), task_id))
|
|
125
|
+
conn.commit()
|
|
126
|
+
|
|
127
|
+
# ---------------- retry ----------------
|
|
128
|
+
|
|
129
|
+
def reschedule(self, task_id, delay):
|
|
130
|
+
run_at = now() + timedelta(seconds=delay)
|
|
131
|
+
with get_conn() as conn:
|
|
132
|
+
conn.execute("""
|
|
133
|
+
UPDATE tasks
|
|
134
|
+
SET status='retrying',
|
|
135
|
+
attempt=attempt+1,
|
|
136
|
+
run_at=?,
|
|
137
|
+
updated_at=?
|
|
138
|
+
WHERE id=?
|
|
139
|
+
""", (run_at.isoformat(), now().isoformat(), task_id))
|
|
140
|
+
conn.commit()
|
pybgworker/state.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TaskState(str, Enum):
|
|
5
|
+
QUEUED = "queued"
|
|
6
|
+
RUNNING = "running"
|
|
7
|
+
RETRYING = "retrying"
|
|
8
|
+
FAILED = "failed"
|
|
9
|
+
SUCCESS = "success"
|
|
10
|
+
CANCELLED = "cancelled"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
ALLOWED_TRANSITIONS = {
|
|
14
|
+
TaskState.QUEUED: {TaskState.RUNNING},
|
|
15
|
+
TaskState.RUNNING: {
|
|
16
|
+
TaskState.SUCCESS,
|
|
17
|
+
TaskState.RETRYING,
|
|
18
|
+
TaskState.FAILED,
|
|
19
|
+
TaskState.CANCELLED,
|
|
20
|
+
},
|
|
21
|
+
TaskState.RETRYING: {TaskState.RUNNING},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_transition(old, new):
|
|
26
|
+
if new not in ALLOWED_TRANSITIONS.get(TaskState(old), set()):
|
|
27
|
+
raise ValueError(f"Invalid transition {old} โ {new}")
|
pybgworker/stats.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from .utils import get_conn
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def stats():
|
|
6
|
+
with get_conn() as conn:
|
|
7
|
+
workers = conn.execute("""
|
|
8
|
+
SELECT name, last_seen FROM workers
|
|
9
|
+
""").fetchall()
|
|
10
|
+
|
|
11
|
+
queued = conn.execute("""
|
|
12
|
+
SELECT COUNT(*) FROM tasks
|
|
13
|
+
WHERE status IN ('queued', 'retrying')
|
|
14
|
+
""").fetchone()[0]
|
|
15
|
+
|
|
16
|
+
print("\n๐ท Worker Stats\n")
|
|
17
|
+
|
|
18
|
+
now = datetime.now(timezone.utc)
|
|
19
|
+
|
|
20
|
+
for w in workers:
|
|
21
|
+
last_seen = datetime.fromisoformat(w[1])
|
|
22
|
+
delta = (now - last_seen).total_seconds()
|
|
23
|
+
status = "alive" if delta < 15 else "dead"
|
|
24
|
+
|
|
25
|
+
print(f"{w[0]:10} {status:5} ({int(delta)}s ago)")
|
|
26
|
+
|
|
27
|
+
print(f"\n๐ฆ Queue depth: {queued}\n")
|
pybgworker/task.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
from .sqlite_queue import SQLiteQueue
|
|
4
|
+
from .utils import generate_id, dumps, now
|
|
5
|
+
from .state import TaskState
|
|
6
|
+
from .backends import SQLiteBackend
|
|
7
|
+
|
|
8
|
+
TASK_REGISTRY = {}
|
|
9
|
+
queue = SQLiteQueue()
|
|
10
|
+
backend = SQLiteBackend()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def task(name=None, retries=0, retry_delay=0, retry_for=(Exception,)):
|
|
14
|
+
if name is None:
|
|
15
|
+
raise ValueError("Task name is required to avoid __main__ issues")
|
|
16
|
+
|
|
17
|
+
def decorator(func):
|
|
18
|
+
task_name = name or f"{func.__module__}.{func.__name__}"
|
|
19
|
+
|
|
20
|
+
TASK_REGISTRY[task_name] = {
|
|
21
|
+
"func": func,
|
|
22
|
+
"retry_delay": retry_delay,
|
|
23
|
+
"retry_for": retry_for,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@wraps(func)
|
|
27
|
+
def delay(*args, countdown=None, eta=None, priority=5, **kwargs):
|
|
28
|
+
run_at = now()
|
|
29
|
+
|
|
30
|
+
if countdown:
|
|
31
|
+
run_at += timedelta(seconds=countdown)
|
|
32
|
+
|
|
33
|
+
if eta:
|
|
34
|
+
run_at = eta
|
|
35
|
+
|
|
36
|
+
task = {
|
|
37
|
+
"id": generate_id(),
|
|
38
|
+
"name": task_name,
|
|
39
|
+
"args": dumps(args),
|
|
40
|
+
"kwargs": dumps(kwargs),
|
|
41
|
+
"status": TaskState.QUEUED.value,
|
|
42
|
+
"attempt": 0,
|
|
43
|
+
"max_retries": retries,
|
|
44
|
+
"run_at": run_at.isoformat(),
|
|
45
|
+
"priority": priority, # โญ NEW
|
|
46
|
+
"locked_by": None,
|
|
47
|
+
"locked_at": None,
|
|
48
|
+
"last_error": None,
|
|
49
|
+
"result": None,
|
|
50
|
+
"created_at": now().isoformat(),
|
|
51
|
+
"updated_at": now().isoformat(),
|
|
52
|
+
"finished_at": None,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
queue.enqueue(task)
|
|
56
|
+
|
|
57
|
+
from .result import AsyncResult
|
|
58
|
+
return AsyncResult(task["id"], backend=backend)
|
|
59
|
+
|
|
60
|
+
func.delay = delay
|
|
61
|
+
return func
|
|
62
|
+
|
|
63
|
+
return decorator
|
pybgworker/utils.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
import json
|
|
3
|
+
import sqlite3
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from .config import DB_PATH
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def generate_id():
|
|
9
|
+
return str(uuid.uuid4())
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def now():
|
|
13
|
+
return datetime.now(timezone.utc)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def dumps(obj):
|
|
17
|
+
return json.dumps(obj)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def loads(data):
|
|
21
|
+
return json.loads(data)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_conn():
|
|
25
|
+
conn = sqlite3.connect(DB_PATH, timeout=30)
|
|
26
|
+
|
|
27
|
+
# production SQLite settings
|
|
28
|
+
conn.execute("PRAGMA journal_mode=WAL;")
|
|
29
|
+
conn.execute("PRAGMA busy_timeout=30000;") # wait 30 seconds if locked
|
|
30
|
+
|
|
31
|
+
return conn
|
pybgworker/worker.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import traceback
|
|
3
|
+
import threading
|
|
4
|
+
from multiprocessing import Process, Queue as MPQueue
|
|
5
|
+
|
|
6
|
+
from .logger import log
|
|
7
|
+
from .sqlite_queue import SQLiteQueue
|
|
8
|
+
from .task import TASK_REGISTRY
|
|
9
|
+
from .config import WORKER_NAME, POLL_INTERVAL, RATE_LIMIT
|
|
10
|
+
from .utils import loads, get_conn, now
|
|
11
|
+
from .backends import SQLiteBackend
|
|
12
|
+
from .scheduler import run_scheduler
|
|
13
|
+
from .ratelimit import RateLimiter
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
queue = SQLiteQueue()
|
|
17
|
+
backend = SQLiteBackend()
|
|
18
|
+
limiter = RateLimiter(RATE_LIMIT)
|
|
19
|
+
|
|
20
|
+
TASK_TIMEOUT = 150 # seconds
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def heartbeat():
|
|
24
|
+
while True:
|
|
25
|
+
try:
|
|
26
|
+
with get_conn() as conn:
|
|
27
|
+
conn.execute("""
|
|
28
|
+
INSERT INTO workers(name, last_seen)
|
|
29
|
+
VALUES (?, ?)
|
|
30
|
+
ON CONFLICT(name)
|
|
31
|
+
DO UPDATE SET last_seen=excluded.last_seen
|
|
32
|
+
""", (WORKER_NAME, now().isoformat()))
|
|
33
|
+
conn.commit()
|
|
34
|
+
except Exception as e:
|
|
35
|
+
log("heartbeat_error", error=str(e))
|
|
36
|
+
|
|
37
|
+
time.sleep(5)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run_task(func, args, kwargs, result_queue):
|
|
41
|
+
try:
|
|
42
|
+
result = func(*args, **kwargs)
|
|
43
|
+
result_queue.put(("success", result))
|
|
44
|
+
except Exception:
|
|
45
|
+
result_queue.put(("error", traceback.format_exc()))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run_worker():
|
|
49
|
+
log("worker_start", worker=WORKER_NAME)
|
|
50
|
+
|
|
51
|
+
threading.Thread(target=heartbeat, daemon=True).start()
|
|
52
|
+
threading.Thread(target=run_scheduler, daemon=True).start()
|
|
53
|
+
|
|
54
|
+
while True:
|
|
55
|
+
task = queue.fetch_next(WORKER_NAME)
|
|
56
|
+
|
|
57
|
+
if not task:
|
|
58
|
+
time.sleep(POLL_INTERVAL)
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
# โญ rate limiting happens here
|
|
62
|
+
limiter.acquire()
|
|
63
|
+
|
|
64
|
+
meta = TASK_REGISTRY.get(task["name"])
|
|
65
|
+
if not meta:
|
|
66
|
+
queue.fail(task["id"], "Task not registered")
|
|
67
|
+
log("task_invalid", task_id=task["id"])
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
func = meta["func"]
|
|
71
|
+
retry_delay = meta["retry_delay"]
|
|
72
|
+
|
|
73
|
+
args = loads(task["args"])
|
|
74
|
+
kwargs = loads(task["kwargs"])
|
|
75
|
+
|
|
76
|
+
start_time = now()
|
|
77
|
+
log("task_start", task_id=task["id"], worker=WORKER_NAME)
|
|
78
|
+
|
|
79
|
+
result_queue = MPQueue()
|
|
80
|
+
process = Process(target=run_task, args=(func, args, kwargs, result_queue))
|
|
81
|
+
|
|
82
|
+
process.start()
|
|
83
|
+
process.join(TASK_TIMEOUT)
|
|
84
|
+
|
|
85
|
+
if process.is_alive():
|
|
86
|
+
process.terminate()
|
|
87
|
+
|
|
88
|
+
info = backend.get_task(task["id"])
|
|
89
|
+
if info["status"] == "cancelled":
|
|
90
|
+
log("task_cancelled", task_id=task["id"])
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
queue.fail(task["id"], "Task timeout")
|
|
94
|
+
log("task_timeout", task_id=task["id"])
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
if result_queue.empty():
|
|
98
|
+
queue.fail(task["id"], "Task crashed without result")
|
|
99
|
+
log("task_crash", task_id=task["id"])
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
status, payload = result_queue.get()
|
|
103
|
+
duration = (now() - start_time).total_seconds()
|
|
104
|
+
|
|
105
|
+
if status == "success":
|
|
106
|
+
backend.store_result(task["id"], payload)
|
|
107
|
+
queue.ack(task["id"])
|
|
108
|
+
log("task_success",
|
|
109
|
+
task_id=task["id"],
|
|
110
|
+
duration=duration,
|
|
111
|
+
worker=WORKER_NAME)
|
|
112
|
+
|
|
113
|
+
else:
|
|
114
|
+
if task["attempt"] < task["max_retries"]:
|
|
115
|
+
queue.reschedule(task["id"], retry_delay)
|
|
116
|
+
log("task_retry",
|
|
117
|
+
task_id=task["id"],
|
|
118
|
+
attempt=task["attempt"] + 1,
|
|
119
|
+
max=task["max_retries"])
|
|
120
|
+
else:
|
|
121
|
+
queue.fail(task["id"], payload)
|
|
122
|
+
log("task_failed", task_id=task["id"])
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pybgworker
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Lightweight production-ready background task worker with cron, rate limiting and JSON observability
|
|
5
|
+
Author: Prabhat Verma
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/prabhat708/pybgworker
|
|
8
|
+
Project-URL: Repository, https://github.com/prabhat708/pybgworker
|
|
9
|
+
Project-URL: Issues, https://github.com/prabhat708/pybgworker/issues
|
|
10
|
+
Keywords: background-jobs,task-queue,sqlite,cron,worker,job-queue
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: croniter>=1.4.0
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# PyBgWorker
|
|
27
|
+
|
|
28
|
+
A lightweight, production-ready background task framework for Python.
|
|
29
|
+
|
|
30
|
+
PyBgWorker provides a durable SQLite-backed task queue, cron scheduling,
|
|
31
|
+
rate limiting, retries, and structured observability โ all without external
|
|
32
|
+
infrastructure.
|
|
33
|
+
|
|
34
|
+
It is designed to be simple, reliable, and easy to deploy.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## โจ Features
|
|
39
|
+
|
|
40
|
+
- Persistent SQLite task queue
|
|
41
|
+
- Multi-worker safe execution
|
|
42
|
+
- Retry + failure handling
|
|
43
|
+
- Crash isolation via subprocess
|
|
44
|
+
- Cron scheduler for recurring jobs
|
|
45
|
+
- JSON structured logging
|
|
46
|
+
- Task duration tracking
|
|
47
|
+
- Rate limiting (overload protection)
|
|
48
|
+
- Heartbeat monitoring
|
|
49
|
+
- CLI inspect / retry / purge / cancel
|
|
50
|
+
- Production-safe worker loop
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## ๐ Installation
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install pybgworker
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## ๐ง Basic Usage
|
|
63
|
+
|
|
64
|
+
### Define a task
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from pybgworker.task import task
|
|
68
|
+
|
|
69
|
+
@task(name="add")
|
|
70
|
+
def add(a, b):
|
|
71
|
+
return a + b
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Enqueue a task
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
add.delay(1, 2)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## โถ Run worker
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
python -m pybgworker.cli run --app example
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## โฐ Cron Scheduler
|
|
91
|
+
|
|
92
|
+
Run recurring tasks:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from pybgworker.scheduler import cron
|
|
96
|
+
from pybgworker.task import task
|
|
97
|
+
|
|
98
|
+
@task(name="heartbeat_task")
|
|
99
|
+
@cron("*/1 * * * *")
|
|
100
|
+
def heartbeat():
|
|
101
|
+
print("alive")
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Cron runs automatically inside the worker.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## ๐ JSON Logging
|
|
109
|
+
|
|
110
|
+
All worker events are structured JSON:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{"event":"task_start","task_id":"..."}
|
|
114
|
+
{"event":"task_success","duration":0.12}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
This enables:
|
|
118
|
+
|
|
119
|
+
- monitoring
|
|
120
|
+
- analytics
|
|
121
|
+
- alerting
|
|
122
|
+
- observability pipelines
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## ๐ฆ Rate Limiting
|
|
127
|
+
|
|
128
|
+
Protect infrastructure from overload:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
RATE_LIMIT = 5 # tasks per second
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Ensures predictable execution under heavy load.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## ๐ CLI Commands
|
|
139
|
+
|
|
140
|
+
Inspect queue:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
python -m pybgworker.cli inspect
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Retry failed task:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
python -m pybgworker.cli retry <task_id>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Cancel task:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
python -m pybgworker.cli cancel <task_id>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Purge queued tasks:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
python -m pybgworker.cli purge
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## ๐งช Observability
|
|
167
|
+
|
|
168
|
+
PyBgWorker logs:
|
|
169
|
+
|
|
170
|
+
- worker start
|
|
171
|
+
- cron events
|
|
172
|
+
- task start
|
|
173
|
+
- success
|
|
174
|
+
- retry
|
|
175
|
+
- failure
|
|
176
|
+
- timeout
|
|
177
|
+
- crash
|
|
178
|
+
- heartbeat errors
|
|
179
|
+
|
|
180
|
+
All machine-readable.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## ๐ฏ Design Goals
|
|
185
|
+
|
|
186
|
+
- zero external dependencies
|
|
187
|
+
- SQLite durability
|
|
188
|
+
- safe multiprocessing
|
|
189
|
+
- operator-friendly CLI
|
|
190
|
+
- production observability
|
|
191
|
+
- infrastructure protection
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## ๐ Roadmap
|
|
196
|
+
|
|
197
|
+
Future upgrades may include:
|
|
198
|
+
|
|
199
|
+
- dashboard web UI
|
|
200
|
+
- metrics endpoint
|
|
201
|
+
- Redis backend
|
|
202
|
+
- workflow pipelines
|
|
203
|
+
- cluster coordination
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## ๐ License
|
|
208
|
+
|
|
209
|
+
MIT License
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
pybgworker/__init__.py,sha256=YyQGbGdbLNexZU3tpt-zw1ABbVoZIiAAtB0lwGV_eYg,119
|
|
2
|
+
pybgworker/backends.py,sha256=vOpcY9lXfKm2-ffnFHEWcDvLAukB5KLgSGP3VO8bjEw,1239
|
|
3
|
+
pybgworker/cancel.py,sha256=jNfyKrhDf8gtL3uSgLaknTKuLpsx4umJVTKYGKXs39E,703
|
|
4
|
+
pybgworker/cli.py,sha256=lniULLMozN7CH9ijQqVUU6ujt_rqc6eAU5GHuxmQA3M,1576
|
|
5
|
+
pybgworker/config.py,sha256=emhKOpgx0L4qOuclA_gG3mLmiyN2_4ZqC8uKBNYyYWA,316
|
|
6
|
+
pybgworker/failed.py,sha256=6TB56fapJZ3tEYEM3H3FnzszoHIHRSD0oDTfd_Rbr5w,603
|
|
7
|
+
pybgworker/inspect.py,sha256=pMtSUItc1VVS3sdh3Mi931Dho_RP2uPcHFJsDQT7x6w,1148
|
|
8
|
+
pybgworker/logger.py,sha256=JzSJLX6NB8VXcTfiqH7NjBmwfCZ0Q6IO_2DNCkxTxl8,298
|
|
9
|
+
pybgworker/purge.py,sha256=hvJhL1jIhKamElNnLpv5PH_eroDcTQc2cPZ_0MlwTzo,321
|
|
10
|
+
pybgworker/queue.py,sha256=YPQgRLouqm9xXx7Azm80J19_eZ_4pkLZD4zNtJHvHEE,458
|
|
11
|
+
pybgworker/ratelimit.py,sha256=p30pQXY3V8M8iXFdDXsA2OnXvrGeddp65JD7QyvIMxA,686
|
|
12
|
+
pybgworker/result.py,sha256=uJzsVeA7Aj84f70vSDRxDQ68YxAI26zuXEQlgiNJ_0U,1767
|
|
13
|
+
pybgworker/retry.py,sha256=OLyBqnPxjMptVJ7zVFD7Bm66kdWAJSJpbFZOnhLTir0,707
|
|
14
|
+
pybgworker/scheduler.py,sha256=hJ4jK0IiEFfEjYQOVIn6XDNuWUcaDLipVK85wBtoni8,1648
|
|
15
|
+
pybgworker/sqlite_queue.py,sha256=sclzIY4M9KZjM5MsVK9YMm1ezKAp0qGnwGsQitaJG-Q,4355
|
|
16
|
+
pybgworker/state.py,sha256=LmUxPXSKC2GvFobOSvoHzLpFNM1M7yqtbPKgJ2Ts6Qk,650
|
|
17
|
+
pybgworker/stats.py,sha256=AYrPeZsd_IsU6GpmSEhwS_NmAFwIwwm3lasmgDvu920,751
|
|
18
|
+
pybgworker/task.py,sha256=l6oLVzU2Om-l_2R2HXBimPUo9OwZT2BiDpgWak_joAc,1884
|
|
19
|
+
pybgworker/utils.py,sha256=w7cX28o7K0_wFpRHegtDZ44LpJgBVv7wSdzDlrILGGQ,569
|
|
20
|
+
pybgworker/worker.py,sha256=wHCT-9RwM25j4dgztFgLlLrdeNzPzk6qXPjiMqJ--94,3697
|
|
21
|
+
pybgworker-0.2.1.dist-info/licenses/LICENSE,sha256=eZySKOWd_q-qQa3p01Llyvu5OWqpLnQV6UXUlIaVdHg,1091
|
|
22
|
+
pybgworker-0.2.1.dist-info/METADATA,sha256=0dbrYgliRgv47dGNBU9K3_ndKb7N0TVBZ5KnF49g9Oo,3780
|
|
23
|
+
pybgworker-0.2.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
24
|
+
pybgworker-0.2.1.dist-info/entry_points.txt,sha256=iJkiCne1tUtm8TlO3TnVHv1Axe81TxU3vaWK6Cusja4,51
|
|
25
|
+
pybgworker-0.2.1.dist-info/top_level.txt,sha256=0vv_-19bFyP0DL0lqlcA-tvz6tISlkYl3Z3v860To-s,11
|
|
26
|
+
pybgworker-0.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Prabhat Verma
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pybgworker
|