pipeline.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. """Configurable Report & Release pipeline.
  2. "Report & Release" is a sequence of steps run per message chunk, chosen in the admin
  3. panel by checkboxes. The order is FIXED: move → release. Config only toggles which steps
  4. are included (a subsequence of ALLOWED_STEPS).
  5. Deleting is deliberately NOT part of this pipeline — Delete is its own action/button, so
  6. Report & Release can never destroy mail as a side effect.
  7. Why the order and the re-reads are load-bearing (see AGENTS.md):
  8. * `move` runs FIRST, straight out of the quarantine folder, using the localguids the job
  9. was enqueued with. The message reaches its destination folder before anything else
  10. touches it.
  11. * `release` then runs where the message NOW is, and MUST NOT pass `deletedfolder` —
  12. that would relocate it again and invalidate every localguid.
  13. * localguids are NEVER carried from one step to the next. They are folder-local, and
  14. PPS's backend is eventually consistent: after a relocating step the cached handles are
  15. stale, and acting on stale handles is exactly the race this pipeline kept hitting.
  16. Every step after the first waits `step_delay_seconds` and then re-reads where the
  17. messages actually are, by their stable `guid` (`locate_messages`).
  18. The worker drives one step at a time via `run_step` and parks the job on the queue
  19. during the delay, so a waiting pipeline never blocks jobs queued behind it
  20. (`worker.JobQueue._process`).
  21. Each step acts on the message's CURRENT location. Adding a future action = one
  22. `@step(...)` function + an entry in ALLOWED_STEPS; no worker or frontend change.
  23. """
  24. from __future__ import annotations
  25. import logging
  26. from dataclasses import dataclass
  27. from typing import Any, Callable, Mapping
  28. from pps_client import PPSError
  29. log = logging.getLogger("pps.pipeline")
  30. # Canonical order. Also the validation whitelist (config_store imports this).
  31. ALLOWED_STEPS: tuple[str, ...] = ("move", "release")
  32. # Seconds to wait between two steps on the same message when config omits the setting.
  33. # PPS's backend is eventually-consistent: acting again too soon (e.g. releasing a
  34. # just-moved message) can hit a stale view. The delay lets the backend catch up — and
  35. # only after it has passed do we re-read where the messages are.
  36. DEFAULT_STEP_DELAY = 60
  37. @dataclass(frozen=True)
  38. class StepContext:
  39. pps: Any
  40. cfg: Mapping[str, Any] # the quarantine config section
  41. folder: str # where the messages are RIGHT NOW
  42. localguids: list[str] # freshly read handles, valid in `folder`
  43. chunk: list[dict] # job_items rows: localguid, guid, subject, ...
  44. @dataclass(frozen=True)
  45. class StepResult:
  46. folder: str # where the messages are after this step
  47. dst: str | None # folder this step lands messages in (for ops log)
  48. StepFn = Callable[[StepContext], StepResult]
  49. STEPS: dict[str, StepFn] = {}
  50. def step(name: str):
  51. def register(fn: StepFn) -> StepFn:
  52. STEPS[name] = fn
  53. return fn
  54. return register
  55. @step("move")
  56. def _move(ctx: StepContext) -> StepResult:
  57. target = ctx.cfg["report_release"]["move_target"]
  58. if not target:
  59. raise ValueError("report_release 'move' step requires move_target")
  60. try:
  61. ctx.pps.act("move", ctx.folder, ctx.localguids, targetfolder=target)
  62. except PPSError as exc:
  63. # As the first step, `move` runs on the localguids captured when the job was
  64. # enqueued — which may have gone stale in the meantime (or the message may
  65. # already have been relocated). Re-read the messages' real location and retry
  66. # once; a genuine failure raises again from `locate_messages` or the retry.
  67. log.warning("move from %r failed (%s) — re-reading message locations", ctx.folder, exc)
  68. folder, localguids = locate_messages(ctx.pps, ctx.cfg, ctx.folder, ctx.chunk)
  69. ctx.pps.act("move", folder, localguids, targetfolder=target)
  70. return StepResult(folder=target, dst=target)
  71. @step("release")
  72. def _release(ctx: StepContext) -> StepResult:
  73. # NO deletedfolder — this is the localguid-stability rule. Never add it here.
  74. ctx.pps.act("release", ctx.folder, ctx.localguids)
  75. return StepResult(folder=ctx.folder, dst=None)
  76. # ---------------------------------------------------------------- driving the steps
  77. def job_steps(cfg: Mapping[str, Any]) -> list[str]:
  78. """The configured steps, in canonical order."""
  79. return list(cfg["report_release"]["steps"])
  80. def step_delay(cfg: Mapping[str, Any]) -> int:
  81. """Seconds to wait between steps (0 disables the wait)."""
  82. return int(cfg["report_release"].get("step_delay_seconds", DEFAULT_STEP_DELAY))
  83. def run_step(pps, cfg: Mapping[str, Any], name: str, folder: str, chunk: list[dict],
  84. *, refresh: bool) -> str:
  85. """Run one pipeline step for one chunk; return the folder the messages end up in.
  86. `refresh=True` (every step after the first) discards the job's cached localguids and
  87. re-reads the messages' current handles in `folder` by their stable guid. The caller
  88. is responsible for having waited `step_delay` before asking for a refresh.
  89. """
  90. if refresh:
  91. folder, localguids = locate_messages(pps, cfg, folder, chunk)
  92. else:
  93. localguids = [it["localguid"] for it in chunk]
  94. result = STEPS[name](StepContext(pps=pps, cfg=cfg, folder=folder,
  95. localguids=localguids, chunk=chunk))
  96. return result.folder
  97. def step_destination(cfg: Mapping[str, Any], name: str, folder: str) -> str | None:
  98. """Folder the messages are in after `name`, computed WITHOUT touching PPS."""
  99. if name == "move":
  100. return cfg["report_release"]["move_target"]
  101. return folder
  102. def locate_messages(pps, cfg: Mapping[str, Any], folder: str,
  103. chunk: list[dict]) -> tuple[str, list[str]]:
  104. """Re-read where `chunk`'s messages are right now: `(folder, localguids)`.
  105. Looks in `folder` first; if nothing matches there and a `deleted_folder` is
  106. configured, looks there too — some deployments' actions relocate messages to the
  107. deleted folder behind our back. Raises PPSError if the messages are nowhere.
  108. """
  109. if not any(it.get("guid") for it in chunk):
  110. raise PPSError("cannot locate messages: no guids stored for this chunk")
  111. candidates = [folder]
  112. deleted = cfg.get("deleted_folder")
  113. if deleted and deleted != folder:
  114. candidates.append(deleted)
  115. for i, candidate in enumerate(candidates):
  116. try:
  117. return candidate, refind_localguids(pps, cfg, candidate, chunk)
  118. except PPSError:
  119. if i == len(candidates) - 1:
  120. raise
  121. log.warning("messages not in %r — looking in %r", candidate, candidates[i + 1])
  122. raise AssertionError("unreachable")
  123. def refind_localguids(pps, cfg: Mapping[str, Any], folder: str, chunk: list[dict]) -> list[str]:
  124. """Recover current localguids in `folder` by matching the stable guid.
  125. Raises PPSError if nothing matches.
  126. """
  127. wanted = {it["guid"]: it for it in chunk if it.get("guid")}
  128. if not wanted:
  129. raise PPSError("cannot recover messages: no guids stored for this chunk")
  130. records = pps.search(
  131. folder,
  132. cfg.get("list_query", "from=*"),
  133. limit=1000,
  134. days_back=int(cfg.get("default_days_back", 7)),
  135. )
  136. found = [
  137. r["localguid"] for r in records
  138. if r.get("guid") in wanted and r.get("localguid")
  139. ]
  140. if not found:
  141. raise PPSError(f"messages not found in {folder!r} to continue the pipeline")
  142. log.info("re-read %d/%d message(s) in %r", len(found), len(wanted), folder)
  143. return found
  144. def pipeline_destination(cfg: Mapping[str, Any]) -> str | None:
  145. """Final folder after all steps, computed WITHOUT touching PPS (for the ops log)."""
  146. folder = None
  147. for name in cfg["report_release"]["steps"]:
  148. if name == "move":
  149. folder = cfg["report_release"]["move_target"]
  150. return folder
  151. def describe_pipeline(cfg: Mapping[str, Any]) -> str:
  152. """Human sentence for the admin panel, e.g.
  153. 'moved to "Debugging - Josef" → released in place'."""
  154. rr = cfg.get("report_release", {})
  155. phrases = []
  156. for name in rr.get("steps", []):
  157. if name == "move":
  158. phrases.append(f'moved to "{rr.get("move_target", "?")}"')
  159. elif name == "release":
  160. phrases.append("released in place")
  161. return " → ".join(phrases) if phrases else "(no steps configured)"