worker.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. """Background job queue for slow PPS quarantine actions.
  2. The frontend fires an action and returns immediately; the actual PPS API calls
  3. (which can be very slow, especially delete) run here on a daemon thread backed by
  4. SQLite so queued work survives an app restart.
  5. One JobQueue instance owns a single SQLite connection (guarded by a lock) shared
  6. between Flask request threads and the worker thread.
  7. """
  8. from __future__ import annotations
  9. import json
  10. import logging
  11. import sqlite3
  12. import threading
  13. import time
  14. import traceback
  15. from datetime import datetime, timezone
  16. from pps_client import PPSClient, PPSError
  17. log = logging.getLogger("pps.worker")
  18. # Actions understood from the frontend.
  19. VALID_ACTIONS = {"release", "report_release", "delete", "move"}
  20. _POLL_SECONDS = 1.0
  21. def _now() -> str:
  22. return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
  23. class JobQueue:
  24. def __init__(self, db_path: str, pps: PPSClient, qconfig: dict):
  25. self.pps = pps
  26. self.cfg = qconfig
  27. self.chunk_size = int(qconfig.get("chunk_size", 25))
  28. self._lock = threading.Lock()
  29. self._conn = sqlite3.connect(db_path, check_same_thread=False)
  30. self._conn.row_factory = sqlite3.Row
  31. self._init_db()
  32. self._recover()
  33. self._stop = threading.Event()
  34. # -------------------------------------------------------------- schema/setup
  35. def _init_db(self) -> None:
  36. with self._lock:
  37. self._conn.executescript(
  38. """
  39. CREATE TABLE IF NOT EXISTS jobs (
  40. id INTEGER PRIMARY KEY AUTOINCREMENT,
  41. action TEXT NOT NULL,
  42. folder TEXT NOT NULL,
  43. extra TEXT NOT NULL DEFAULT '{}',
  44. status TEXT NOT NULL DEFAULT 'pending',
  45. processed INTEGER NOT NULL DEFAULT 0,
  46. total INTEGER NOT NULL DEFAULT 0,
  47. error TEXT,
  48. created_at TEXT NOT NULL,
  49. updated_at TEXT NOT NULL
  50. );
  51. CREATE TABLE IF NOT EXISTS job_items (
  52. job_id INTEGER NOT NULL,
  53. localguid TEXT NOT NULL,
  54. guid TEXT,
  55. folder TEXT NOT NULL,
  56. FOREIGN KEY (job_id) REFERENCES jobs(id)
  57. );
  58. CREATE INDEX IF NOT EXISTS idx_job_items_folder ON job_items(folder);
  59. CREATE INDEX IF NOT EXISTS idx_job_items_job ON job_items(job_id);
  60. """
  61. )
  62. self._conn.commit()
  63. def _recover(self) -> None:
  64. # Any job left 'running' when the process died is requeued.
  65. with self._lock:
  66. cur = self._conn.execute(
  67. "UPDATE jobs SET status='pending', updated_at=? WHERE status='running'",
  68. (_now(),),
  69. )
  70. self._conn.commit()
  71. if cur.rowcount:
  72. log.warning("requeued %d job(s) left running from a previous run", cur.rowcount)
  73. # -------------------------------------------------------------- public API
  74. def enqueue(self, action: str, folder: str, items: list[dict], extra: dict) -> int:
  75. """Insert a job. `items` is a list of {'localguid':..., 'guid':...} dicts."""
  76. if action not in VALID_ACTIONS:
  77. raise ValueError(f"unknown action: {action}")
  78. now = _now()
  79. with self._lock:
  80. cur = self._conn.execute(
  81. "INSERT INTO jobs (action, folder, extra, status, total, created_at, updated_at)"
  82. " VALUES (?,?,?,?,?,?,?)",
  83. (action, folder, json.dumps(extra or {}), "pending", len(items), now, now),
  84. )
  85. job_id = cur.lastrowid
  86. self._conn.executemany(
  87. "INSERT INTO job_items (job_id, localguid, guid, folder) VALUES (?,?,?,?)",
  88. [(job_id, it["localguid"], it.get("guid"), folder) for it in items],
  89. )
  90. self._conn.commit()
  91. return job_id
  92. def acted_localguids(self, folder: str) -> set[str]:
  93. """localguids in `folder` that belong to a non-failed job (hide them from the list)."""
  94. with self._lock:
  95. rows = self._conn.execute(
  96. "SELECT ji.localguid FROM job_items ji JOIN jobs j ON j.id = ji.job_id"
  97. " WHERE ji.folder = ? AND j.status != 'failed'",
  98. (folder,),
  99. ).fetchall()
  100. return {r["localguid"] for r in rows}
  101. def recent_jobs(self, limit: int = 25) -> list[dict]:
  102. with self._lock:
  103. rows = self._conn.execute(
  104. "SELECT id, action, folder, status, processed, total, error, created_at, updated_at"
  105. " FROM jobs ORDER BY id DESC LIMIT ?",
  106. (limit,),
  107. ).fetchall()
  108. return [dict(r) for r in rows]
  109. def has_active_jobs(self) -> bool:
  110. with self._lock:
  111. row = self._conn.execute(
  112. "SELECT 1 FROM jobs WHERE status IN ('pending','running') LIMIT 1"
  113. ).fetchone()
  114. return row is not None
  115. # -------------------------------------------------------------- worker loop
  116. def start(self) -> None:
  117. thread = threading.Thread(target=self._run_loop, name="job-worker", daemon=True)
  118. thread.start()
  119. def stop(self) -> None:
  120. self._stop.set()
  121. def _run_loop(self) -> None:
  122. while not self._stop.is_set():
  123. job = self._claim_next()
  124. if job is None:
  125. time.sleep(_POLL_SECONDS)
  126. continue
  127. try:
  128. self._process(job)
  129. except Exception: # noqa: BLE001 - never let the worker thread die
  130. self._mark_failed(job["id"], traceback.format_exc())
  131. def _claim_next(self) -> dict | None:
  132. with self._lock:
  133. row = self._conn.execute(
  134. "SELECT * FROM jobs WHERE status='pending' ORDER BY id ASC LIMIT 1"
  135. ).fetchone()
  136. if row is None:
  137. return None
  138. self._conn.execute(
  139. "UPDATE jobs SET status='running', updated_at=? WHERE id=?",
  140. (_now(), row["id"]),
  141. )
  142. self._conn.commit()
  143. return dict(row)
  144. def _process(self, job: dict) -> None:
  145. job_id = job["id"]
  146. action = job["action"]
  147. folder = job["folder"]
  148. extra = json.loads(job["extra"] or "{}")
  149. items = self._items_for(job_id)
  150. log.info(
  151. "job #%d start: action=%s folder=%r total=%d", job_id, action, folder, len(items)
  152. )
  153. errors: list[str] = []
  154. processed = 0
  155. for chunk in _chunks(items, self.chunk_size):
  156. try:
  157. self._run_action(action, folder, chunk, extra)
  158. processed += len(chunk)
  159. self._set_processed(job_id, processed)
  160. except PPSError as exc:
  161. log.error("job #%d chunk failed: %s", job_id, exc)
  162. errors.append(f"chunk failed: {exc}")
  163. except Exception as exc: # noqa: BLE001
  164. log.exception("job #%d chunk raised", job_id)
  165. errors.append(f"chunk failed: {exc}")
  166. if errors:
  167. log.error("job #%d FAILED: %d/%d processed, %d chunk error(s)",
  168. job_id, processed, len(items), len(errors))
  169. self._mark_failed(job_id, "\n".join(errors))
  170. else:
  171. log.info("job #%d done: %d/%d processed", job_id, processed, len(items))
  172. self._mark_done(job_id)
  173. # -------------------------------------------------------------- action logic
  174. def _run_action(self, action: str, folder: str, chunk: list[dict], extra: dict) -> None:
  175. localguids = [it["localguid"] for it in chunk]
  176. deleted_folder = self.cfg.get("deleted_folder")
  177. if action == "release":
  178. self.pps.act("release", folder, localguids, deletedfolder=deleted_folder)
  179. elif action == "delete":
  180. self.pps.act("delete", folder, localguids, deletedfolder=deleted_folder)
  181. elif action == "move":
  182. target = extra.get("targetfolder")
  183. if not target:
  184. raise ValueError("move action requires targetfolder")
  185. self.pps.act("move", folder, localguids, targetfolder=target)
  186. elif action == "report_release":
  187. self._report_release(folder, chunk)
  188. else:
  189. raise ValueError(f"unknown action: {action}")
  190. def _report_release(self, folder: str, chunk: list[dict]) -> None:
  191. """Release in place (delivers, keeps message), then move a copy to the report folder.
  192. Fallback: if release relocated the message to the deleted folder (GUI-like
  193. behavior), the move from `folder` fails; re-find each message in the deleted
  194. folder by its stable guid and move it from there instead.
  195. """
  196. target = self.cfg.get("report_release_folder")
  197. localguids = [it["localguid"] for it in chunk]
  198. # Step 1: release without deletedfolder -> message stays put, localguid stable.
  199. self.pps.act("release", folder, localguids)
  200. # Step 2: move the (now released) copy into the report folder.
  201. try:
  202. self.pps.act("move", folder, localguids, targetfolder=target)
  203. except PPSError:
  204. self._move_from_deleted_by_guid(chunk, target)
  205. def _move_from_deleted_by_guid(self, chunk: list[dict], target: str) -> None:
  206. deleted_folder = self.cfg.get("deleted_folder")
  207. if not deleted_folder:
  208. raise PPSError("release relocated messages but no deleted_folder configured")
  209. wanted = {it["guid"]: it for it in chunk if it.get("guid")}
  210. if not wanted:
  211. raise PPSError("cannot recover messages: no guids stored for report_release")
  212. records = self.pps.search(
  213. deleted_folder,
  214. self.cfg.get("list_query", "from=*"),
  215. limit=1000,
  216. days_back=int(self.cfg.get("default_days_back", 7)),
  217. )
  218. found = [
  219. r["localguid"]
  220. for r in records
  221. if r.get("guid") in wanted and r.get("localguid")
  222. ]
  223. if not found:
  224. raise PPSError(
  225. "released messages not found in deleted folder to move to report folder"
  226. )
  227. self.pps.act("move", deleted_folder, found, targetfolder=target)
  228. # -------------------------------------------------------------- db helpers
  229. def _items_for(self, job_id: int) -> list[dict]:
  230. with self._lock:
  231. rows = self._conn.execute(
  232. "SELECT localguid, guid FROM job_items WHERE job_id=?", (job_id,)
  233. ).fetchall()
  234. return [dict(r) for r in rows]
  235. def _set_processed(self, job_id: int, processed: int) -> None:
  236. with self._lock:
  237. self._conn.execute(
  238. "UPDATE jobs SET processed=?, updated_at=? WHERE id=?",
  239. (processed, _now(), job_id),
  240. )
  241. self._conn.commit()
  242. def _mark_done(self, job_id: int) -> None:
  243. with self._lock:
  244. self._conn.execute(
  245. "UPDATE jobs SET status='done', updated_at=? WHERE id=?",
  246. (_now(), job_id),
  247. )
  248. self._conn.commit()
  249. def _mark_failed(self, job_id: int, error: str) -> None:
  250. with self._lock:
  251. self._conn.execute(
  252. "UPDATE jobs SET status='failed', error=?, updated_at=? WHERE id=?",
  253. (error, _now(), job_id),
  254. )
  255. self._conn.commit()
  256. def _chunks(seq: list, size: int):
  257. for i in range(0, len(seq), size):
  258. yield seq[i : i + size]