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 ADDED
@@ -0,0 +1,6 @@
1
+ from .task import task
2
+ from .result import AsyncResult
3
+
4
+ __all__ = ["task", "AsyncResult"]
5
+ __version__ = "0.2.1"
6
+
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
@@ -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")
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pybgworker = pybgworker.cli:main
@@ -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