pipeline.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  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: release → move → delete. Config only toggles
  4. which steps are included (a subsequence of ALLOWED_STEPS).
  5. Why the order is load-bearing (see AGENTS.md):
  6. * `release` MUST run first and MUST NOT pass `deletedfolder`. Releasing in place leaves
  7. the message in its folder so its `localguid` stays valid for a following `move`.
  8. Passing `deletedfolder` would relocate the message and invalidate every localguid —
  9. the exact bug the guid-refind fallback exists to paper over.
  10. * `move`/`delete` relocate the message, so afterwards the localguids are stale. A step
  11. returns `localguids=None` to signal that; the runner re-finds them by the stable
  12. `guid` — but LAZILY, only when another step actually follows. So the common
  13. `["release", "move"]` still issues exactly two PPS calls, identical to the PoC.
  14. Each step acts on the message's CURRENT location, threading (folder, localguids)
  15. forward. Adding a future action = one `@step(...)` function + an entry in ALLOWED_STEPS;
  16. no worker or frontend change.
  17. """
  18. from __future__ import annotations
  19. import logging
  20. import time
  21. from dataclasses import dataclass, replace
  22. from typing import Any, Callable, Mapping
  23. from pps_client import PPSError
  24. log = logging.getLogger("pps.pipeline")
  25. # Canonical order. Also the validation whitelist (config_store imports this).
  26. ALLOWED_STEPS: tuple[str, ...] = ("release", "move", "delete")
  27. # Seconds to wait between two steps on the same message when config omits the setting.
  28. # PPS's backend is eventually-consistent: acting again too soon (e.g. move right after
  29. # release) can hit a stale view. The delay lets the backend catch up.
  30. DEFAULT_STEP_DELAY = 60
  31. @dataclass(frozen=True)
  32. class StepContext:
  33. pps: Any
  34. cfg: Mapping[str, Any] # the quarantine config section
  35. folder: str # where the messages are RIGHT NOW
  36. localguids: list[str] | None # None => stale; runner must re-find by guid
  37. chunk: list[dict] # job_items rows: localguid, guid, subject, ...
  38. @dataclass(frozen=True)
  39. class StepResult:
  40. folder: str
  41. localguids: list[str] | None
  42. dst: str | None # folder this step lands messages in (for ops log)
  43. StepFn = Callable[[StepContext], StepResult]
  44. STEPS: dict[str, StepFn] = {}
  45. def step(name: str):
  46. def register(fn: StepFn) -> StepFn:
  47. STEPS[name] = fn
  48. return fn
  49. return register
  50. @step("release")
  51. def _release(ctx: StepContext) -> StepResult:
  52. # NO deletedfolder — this is the localguid-stability rule. Never add it here.
  53. ctx.pps.act("release", ctx.folder, ctx.localguids)
  54. return StepResult(folder=ctx.folder, localguids=ctx.localguids, dst=None)
  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:
  63. # Some deployments' release relocates the message to the deleted folder (like the
  64. # admin GUI). The move from `folder` then fails; re-find by stable guid and move
  65. # from the deleted folder instead.
  66. deleted = ctx.cfg.get("deleted_folder")
  67. if not deleted:
  68. raise PPSError("release relocated messages but no deleted_folder configured")
  69. found = refind_localguids(ctx.pps, ctx.cfg, deleted, ctx.chunk)
  70. ctx.pps.act("move", deleted, found, targetfolder=target)
  71. return StepResult(folder=target, localguids=None, dst=target)
  72. @step("delete")
  73. def _delete(ctx: StepContext) -> StepResult:
  74. deleted = ctx.cfg.get("deleted_folder")
  75. ctx.pps.act("delete", ctx.folder, ctx.localguids, deletedfolder=deleted)
  76. return StepResult(folder=deleted, localguids=None, dst=deleted)
  77. def run_pipeline(pps, cfg: Mapping[str, Any], folder: str, chunk: list[dict],
  78. sleep: Callable[[float], None] = time.sleep) -> None:
  79. """Execute the configured steps in order for one chunk of messages.
  80. Between two steps we wait `report_release.step_delay_seconds` (default 60) so PPS's
  81. eventually-consistent backend settles before the next action on the same message —
  82. without this, e.g. a move right after a release can act on a stale view. The wait
  83. blocks the worker thread; it does NOT block the HTTP request (actions are queued and
  84. return 202 immediately). `sleep` is injectable for tests.
  85. """
  86. steps = cfg["report_release"]["steps"]
  87. delay = int(cfg["report_release"].get("step_delay_seconds", DEFAULT_STEP_DELAY))
  88. ctx = StepContext(
  89. pps=pps,
  90. cfg=cfg,
  91. folder=folder,
  92. localguids=[it["localguid"] for it in chunk],
  93. chunk=chunk,
  94. )
  95. for i, name in enumerate(steps):
  96. # Delay before every step except the first — also lets the backend settle before
  97. # the refind search below finds a just-relocated message.
  98. if i > 0 and delay > 0:
  99. log.info("waiting %ds before %r step (PPS backend consistency)", delay, name)
  100. sleep(delay)
  101. if ctx.localguids is None: # a prior relocating step left them stale
  102. ctx = replace(ctx, localguids=refind_localguids(pps, cfg, ctx.folder, chunk))
  103. result = STEPS[name](ctx)
  104. ctx = replace(ctx, folder=result.folder, localguids=result.localguids)
  105. def refind_localguids(pps, cfg: Mapping[str, Any], folder: str, chunk: list[dict]) -> list[str]:
  106. """Recover current localguids in `folder` by matching the stable guid.
  107. Generalises the PoC's _move_from_deleted_by_guid. Raises PPSError if nothing matches.
  108. """
  109. wanted = {it["guid"]: it for it in chunk if it.get("guid")}
  110. if not wanted:
  111. raise PPSError("cannot recover messages: no guids stored for this chunk")
  112. records = pps.search(
  113. folder,
  114. cfg.get("list_query", "from=*"),
  115. limit=1000,
  116. days_back=int(cfg.get("default_days_back", 7)),
  117. )
  118. found = [
  119. r["localguid"] for r in records
  120. if r.get("guid") in wanted and r.get("localguid")
  121. ]
  122. if not found:
  123. raise PPSError(f"messages not found in {folder!r} to continue the pipeline")
  124. return found
  125. def pipeline_destination(cfg: Mapping[str, Any]) -> str | None:
  126. """Final folder after all steps, computed WITHOUT touching PPS (for the ops log)."""
  127. folder = None
  128. for name in cfg["report_release"]["steps"]:
  129. if name == "move":
  130. folder = cfg["report_release"]["move_target"]
  131. elif name == "delete":
  132. folder = cfg.get("deleted_folder")
  133. return folder
  134. def describe_pipeline(cfg: Mapping[str, Any]) -> str:
  135. """Human sentence for the admin panel, e.g.
  136. 'released in place → moved to "Debugging - Josef" → deleted to "Deleted"'."""
  137. rr = cfg.get("report_release", {})
  138. phrases = []
  139. for name in rr.get("steps", []):
  140. if name == "release":
  141. phrases.append("released in place")
  142. elif name == "move":
  143. phrases.append(f'moved to "{rr.get("move_target", "?")}"')
  144. elif name == "delete":
  145. phrases.append(f'deleted to "{cfg.get("deleted_folder", "?")}"')
  146. return " → ".join(phrases) if phrases else "(no steps configured)"