worker.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  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 logging.handlers import RotatingFileHandler
  17. from pps_client import PPSClient, PPSError
  18. log = logging.getLogger("pps.worker")
  19. # Dedicated operations/audit log — one line per message acted on. Written to its own
  20. # file (see _setup_ops_log) and NOT propagated to the root/stdout logger.
  21. ops_log = logging.getLogger("pps.ops")
  22. ops_log.propagate = False
  23. def _setup_ops_log(path: str) -> None:
  24. """Attach a rotating file handler to the ops logger (idempotent)."""
  25. for h in ops_log.handlers:
  26. if isinstance(h, RotatingFileHandler) and getattr(h, "baseFilename", "").endswith(path.split("/")[-1]):
  27. return # already configured
  28. handler = RotatingFileHandler(path, maxBytes=5_000_000, backupCount=5, encoding="utf-8")
  29. handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
  30. ops_log.addHandler(handler)
  31. ops_log.setLevel(logging.INFO)
  32. # Actions understood from the frontend.
  33. VALID_ACTIONS = {"release", "report_release", "delete", "move"}
  34. _POLL_SECONDS = 1.0
  35. def _now() -> str:
  36. return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
  37. class JobQueue:
  38. def __init__(self, db_path: str, pps: PPSClient, qconfig: dict,
  39. ops_log_path: str = "worker.log"):
  40. self.pps = pps
  41. self.cfg = qconfig
  42. self.chunk_size = int(qconfig.get("chunk_size", 25))
  43. self._lock = threading.Lock()
  44. self._conn = sqlite3.connect(db_path, check_same_thread=False)
  45. self._conn.row_factory = sqlite3.Row
  46. self._init_db()
  47. self._recover()
  48. self._stop = threading.Event()
  49. _setup_ops_log(ops_log_path)
  50. self.ops_log_path = ops_log_path
  51. # -------------------------------------------------------------- schema/setup
  52. def _init_db(self) -> None:
  53. with self._lock:
  54. self._conn.executescript(
  55. """
  56. CREATE TABLE IF NOT EXISTS jobs (
  57. id INTEGER PRIMARY KEY AUTOINCREMENT,
  58. action TEXT NOT NULL,
  59. folder TEXT NOT NULL,
  60. extra TEXT NOT NULL DEFAULT '{}',
  61. status TEXT NOT NULL DEFAULT 'pending',
  62. processed INTEGER NOT NULL DEFAULT 0,
  63. total INTEGER NOT NULL DEFAULT 0,
  64. error TEXT,
  65. created_at TEXT NOT NULL,
  66. updated_at TEXT NOT NULL
  67. );
  68. CREATE TABLE IF NOT EXISTS job_items (
  69. job_id INTEGER NOT NULL,
  70. localguid TEXT NOT NULL,
  71. guid TEXT,
  72. folder TEXT NOT NULL,
  73. subject TEXT,
  74. sender TEXT,
  75. recipient TEXT,
  76. FOREIGN KEY (job_id) REFERENCES jobs(id)
  77. );
  78. CREATE INDEX IF NOT EXISTS idx_job_items_folder ON job_items(folder);
  79. CREATE INDEX IF NOT EXISTS idx_job_items_job ON job_items(job_id);
  80. """
  81. )
  82. # Migrate older DBs that lack the display columns.
  83. existing = {r["name"] for r in self._conn.execute("PRAGMA table_info(job_items)")}
  84. for col in ("subject", "sender", "recipient"):
  85. if col not in existing:
  86. self._conn.execute(f"ALTER TABLE job_items ADD COLUMN {col} TEXT")
  87. self._conn.commit()
  88. def _recover(self) -> None:
  89. # Any job left 'running' when the process died is requeued.
  90. with self._lock:
  91. cur = self._conn.execute(
  92. "UPDATE jobs SET status='pending', updated_at=? WHERE status='running'",
  93. (_now(),),
  94. )
  95. self._conn.commit()
  96. if cur.rowcount:
  97. log.warning("requeued %d job(s) left running from a previous run", cur.rowcount)
  98. # -------------------------------------------------------------- public API
  99. def enqueue(self, action: str, folder: str, items: list[dict], extra: dict) -> int:
  100. """Insert a job. `items` is a list of dicts with keys:
  101. localguid (required), guid, subject, sender, recipient (all optional)."""
  102. if action not in VALID_ACTIONS:
  103. raise ValueError(f"unknown action: {action}")
  104. now = _now()
  105. with self._lock:
  106. cur = self._conn.execute(
  107. "INSERT INTO jobs (action, folder, extra, status, total, created_at, updated_at)"
  108. " VALUES (?,?,?,?,?,?,?)",
  109. (action, folder, json.dumps(extra or {}), "pending", len(items), now, now),
  110. )
  111. job_id = cur.lastrowid
  112. self._conn.executemany(
  113. "INSERT INTO job_items (job_id, localguid, guid, folder, subject, sender, recipient)"
  114. " VALUES (?,?,?,?,?,?,?)",
  115. [
  116. (job_id, it["localguid"], it.get("guid"), folder,
  117. it.get("subject"), it.get("sender"), it.get("recipient"))
  118. for it in items
  119. ],
  120. )
  121. self._conn.commit()
  122. log.info("enqueued job #%d: action=%s folder=%r items=%d", job_id, action, folder, len(items))
  123. return job_id
  124. def acted_localguids(self, folder: str) -> set[str]:
  125. """localguids in `folder` that belong to a non-failed job (hide them from the list)."""
  126. with self._lock:
  127. rows = self._conn.execute(
  128. "SELECT ji.localguid FROM job_items ji JOIN jobs j ON j.id = ji.job_id"
  129. " WHERE ji.folder = ? AND j.status != 'failed'",
  130. (folder,),
  131. ).fetchall()
  132. return {r["localguid"] for r in rows}
  133. def recent_jobs(self, limit: int = 25) -> list[dict]:
  134. with self._lock:
  135. rows = self._conn.execute(
  136. "SELECT id, action, folder, status, processed, total, error, created_at, updated_at"
  137. " FROM jobs ORDER BY id DESC LIMIT ?",
  138. (limit,),
  139. ).fetchall()
  140. return [dict(r) for r in rows]
  141. def has_active_jobs(self) -> bool:
  142. with self._lock:
  143. row = self._conn.execute(
  144. "SELECT 1 FROM jobs WHERE status IN ('pending','running') LIMIT 1"
  145. ).fetchone()
  146. return row is not None
  147. # -------------------------------------------------------------- worker loop
  148. def start(self) -> None:
  149. thread = threading.Thread(target=self._run_loop, name="job-worker", daemon=True)
  150. thread.start()
  151. def stop(self) -> None:
  152. self._stop.set()
  153. def _run_loop(self) -> None:
  154. while not self._stop.is_set():
  155. job = self._claim_next()
  156. if job is None:
  157. time.sleep(_POLL_SECONDS)
  158. continue
  159. try:
  160. self._process(job)
  161. except Exception: # noqa: BLE001 - never let the worker thread die
  162. self._mark_failed(job["id"], traceback.format_exc())
  163. def _claim_next(self) -> dict | None:
  164. with self._lock:
  165. row = self._conn.execute(
  166. "SELECT * FROM jobs WHERE status='pending' ORDER BY id ASC LIMIT 1"
  167. ).fetchone()
  168. if row is None:
  169. return None
  170. self._conn.execute(
  171. "UPDATE jobs SET status='running', updated_at=? WHERE id=?",
  172. (_now(), row["id"]),
  173. )
  174. self._conn.commit()
  175. return dict(row)
  176. def _process(self, job: dict) -> None:
  177. job_id = job["id"]
  178. action = job["action"]
  179. folder = job["folder"]
  180. extra = json.loads(job["extra"] or "{}")
  181. items = self._items_for(job_id)
  182. log.info(
  183. "job #%d start: action=%s folder=%r total=%d", job_id, action, folder, len(items)
  184. )
  185. dst = self._destination(action, extra)
  186. errors: list[str] = []
  187. processed = 0
  188. for chunk in _chunks(items, self.chunk_size):
  189. try:
  190. self._run_action(action, folder, chunk, extra)
  191. processed += len(chunk)
  192. self._set_processed(job_id, processed)
  193. self._log_ops(job_id, action, folder, dst, chunk, "ok", None)
  194. except PPSError as exc:
  195. log.error("job #%d chunk failed: %s", job_id, exc)
  196. errors.append(f"chunk failed: {exc}")
  197. self._log_ops(job_id, action, folder, dst, chunk, "FAILED", str(exc))
  198. except Exception as exc: # noqa: BLE001
  199. log.exception("job #%d chunk raised", job_id)
  200. errors.append(f"chunk failed: {exc}")
  201. self._log_ops(job_id, action, folder, dst, chunk, "FAILED", str(exc))
  202. if errors:
  203. log.error("job #%d FAILED: %d/%d processed, %d chunk error(s)",
  204. job_id, processed, len(items), len(errors))
  205. self._mark_failed(job_id, "\n".join(errors))
  206. else:
  207. log.info("job #%d done: %d/%d processed", job_id, processed, len(items))
  208. self._mark_done(job_id)
  209. def _destination(self, action: str, extra: dict) -> str | None:
  210. """The folder messages end up in, for the ops log."""
  211. if action == "move":
  212. return extra.get("targetfolder")
  213. if action == "report_release":
  214. return self.cfg.get("report_release_folder")
  215. if action in ("release", "delete"):
  216. return self.cfg.get("deleted_folder")
  217. return None
  218. def _log_ops(self, job_id: int, action: str, src: str, dst: str | None,
  219. chunk: list[dict], result: str, error: str | None) -> None:
  220. """Write one audit line per message in the chunk to the operations log."""
  221. for it in chunk:
  222. fields = [
  223. f"job=#{job_id}",
  224. f"action={action}",
  225. f"result={result}",
  226. f"src={src!r}",
  227. f"dst={dst!r}",
  228. f"localguid={it.get('localguid')}",
  229. f"guid={it.get('guid')}",
  230. f"from={_clip(it.get('sender'))!r}",
  231. f"rcpt={_clip(it.get('recipient'))!r}",
  232. f"subject={_clip(it.get('subject'))!r}",
  233. ]
  234. if error:
  235. fields.append(f"error={_clip(error, 300)!r}")
  236. ops_log.info(" ".join(fields))
  237. # -------------------------------------------------------------- action logic
  238. def _run_action(self, action: str, folder: str, chunk: list[dict], extra: dict) -> None:
  239. localguids = [it["localguid"] for it in chunk]
  240. deleted_folder = self.cfg.get("deleted_folder")
  241. if action == "release":
  242. self.pps.act("release", folder, localguids, deletedfolder=deleted_folder)
  243. elif action == "delete":
  244. self.pps.act("delete", folder, localguids, deletedfolder=deleted_folder)
  245. elif action == "move":
  246. target = extra.get("targetfolder")
  247. if not target:
  248. raise ValueError("move action requires targetfolder")
  249. self.pps.act("move", folder, localguids, targetfolder=target)
  250. elif action == "report_release":
  251. self._report_release(folder, chunk)
  252. else:
  253. raise ValueError(f"unknown action: {action}")
  254. def _report_release(self, folder: str, chunk: list[dict]) -> None:
  255. """Release in place (delivers, keeps message), then move a copy to the report folder.
  256. Fallback: if release relocated the message to the deleted folder (GUI-like
  257. behavior), the move from `folder` fails; re-find each message in the deleted
  258. folder by its stable guid and move it from there instead.
  259. """
  260. target = self.cfg.get("report_release_folder")
  261. localguids = [it["localguid"] for it in chunk]
  262. # Step 1: release without deletedfolder -> message stays put, localguid stable.
  263. self.pps.act("release", folder, localguids)
  264. # Step 2: move the (now released) copy into the report folder.
  265. try:
  266. self.pps.act("move", folder, localguids, targetfolder=target)
  267. except PPSError:
  268. self._move_from_deleted_by_guid(chunk, target)
  269. def _move_from_deleted_by_guid(self, chunk: list[dict], target: str) -> None:
  270. deleted_folder = self.cfg.get("deleted_folder")
  271. if not deleted_folder:
  272. raise PPSError("release relocated messages but no deleted_folder configured")
  273. wanted = {it["guid"]: it for it in chunk if it.get("guid")}
  274. if not wanted:
  275. raise PPSError("cannot recover messages: no guids stored for report_release")
  276. records = self.pps.search(
  277. deleted_folder,
  278. self.cfg.get("list_query", "from=*"),
  279. limit=1000,
  280. days_back=int(self.cfg.get("default_days_back", 7)),
  281. )
  282. found = [
  283. r["localguid"]
  284. for r in records
  285. if r.get("guid") in wanted and r.get("localguid")
  286. ]
  287. if not found:
  288. raise PPSError(
  289. "released messages not found in deleted folder to move to report folder"
  290. )
  291. self.pps.act("move", deleted_folder, found, targetfolder=target)
  292. # -------------------------------------------------------------- db helpers
  293. def _items_for(self, job_id: int) -> list[dict]:
  294. with self._lock:
  295. rows = self._conn.execute(
  296. "SELECT localguid, guid, subject, sender, recipient"
  297. " FROM job_items WHERE job_id=?", (job_id,)
  298. ).fetchall()
  299. return [dict(r) for r in rows]
  300. def _set_processed(self, job_id: int, processed: int) -> None:
  301. with self._lock:
  302. self._conn.execute(
  303. "UPDATE jobs SET processed=?, updated_at=? WHERE id=?",
  304. (processed, _now(), job_id),
  305. )
  306. self._conn.commit()
  307. def _mark_done(self, job_id: int) -> None:
  308. with self._lock:
  309. self._conn.execute(
  310. "UPDATE jobs SET status='done', updated_at=? WHERE id=?",
  311. (_now(), job_id),
  312. )
  313. self._conn.commit()
  314. def _mark_failed(self, job_id: int, error: str) -> None:
  315. with self._lock:
  316. self._conn.execute(
  317. "UPDATE jobs SET status='failed', error=?, updated_at=? WHERE id=?",
  318. (error, _now(), job_id),
  319. )
  320. self._conn.commit()
  321. def _chunks(seq: list, size: int):
  322. for i in range(0, len(seq), size):
  323. yield seq[i : i + size]
  324. def _clip(value, length: int = 120) -> str:
  325. """Collapse newlines and truncate a value for a single-line log field."""
  326. s = " ".join(str(value or "").split())
  327. return s[:length] + ("…" if len(s) > length else "")