test_report_release.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. """The Report & Release pipeline — the highest-risk behaviour.
  2. Asserts on exact PPS call kwargs, not just counts: the move-first order, the re-read of
  3. localguids before every later step (never the cached ones), and the localguid-stability
  4. rule (release must NOT pass deletedfolder) are all invisible to a call-count check but
  5. critical to correctness. The last group covers the other half of the design — waiting
  6. between steps must not block the job queue.
  7. """
  8. import pytest
  9. from conftest import FakePPS
  10. from pipeline import (
  11. ALLOWED_STEPS,
  12. describe_pipeline,
  13. pipeline_destination,
  14. run_step,
  15. )
  16. from pps_client import PPSError
  17. def _cfg(steps, move_target="Rep", deleted="Deleted", step_delay=0):
  18. return {
  19. "deleted_folder": deleted,
  20. "list_query": "from=*",
  21. "default_days_back": 7,
  22. "chunk_size": 25,
  23. "report_release": {
  24. "steps": steps,
  25. "move_target": move_target,
  26. "step_delay_seconds": step_delay,
  27. },
  28. }
  29. def _chunk():
  30. return [
  31. {"localguid": "6:6:1", "guid": "g1"},
  32. {"localguid": "6:6:2", "guid": "g2"},
  33. ]
  34. def _moved_records():
  35. """What a search of the move target returns once the messages have landed there."""
  36. return [{"guid": "g1", "localguid": "R:1"}, {"guid": "g2", "localguid": "R:2"}]
  37. # ---------------------------------------------------------------- single steps
  38. def test_first_step_uses_the_job_localguids_without_searching():
  39. pps = FakePPS()
  40. folder = run_step(pps, _cfg(["move", "release"]), "move", "Quarantine", _chunk(),
  41. refresh=False)
  42. assert folder == "Rep"
  43. assert pps.searches() == []
  44. move = pps.acts()[0]
  45. assert move["action"] == "move" and move["folder"] == "Quarantine"
  46. assert move["localguids"] == ["6:6:1", "6:6:2"]
  47. assert move["targetfolder"] == "Rep"
  48. def test_later_step_rereads_and_never_uses_cached_localguids():
  49. pps = FakePPS(records=_moved_records())
  50. folder = run_step(pps, _cfg(["move", "release"]), "release", "Rep", _chunk(),
  51. refresh=True)
  52. assert folder == "Rep"
  53. assert [s["folder"] for s in pps.searches()] == ["Rep"] # re-read in the NEW folder
  54. release = pps.acts()[0]
  55. assert release["action"] == "release"
  56. assert release["folder"] == "Rep"
  57. assert release["localguids"] == ["R:1", "R:2"] # fresh, not "6:6:*"
  58. assert release["deletedfolder"] is None # THE stability rule
  59. def test_release_carries_no_deletedfolder_even_as_first_step():
  60. pps = FakePPS()
  61. run_step(pps, _cfg(["release"]), "release", "Quarantine", _chunk(), refresh=False)
  62. assert pps.acts()[0]["deletedfolder"] is None
  63. def test_reread_falls_back_to_the_deleted_folder():
  64. """Some deployments relocate messages to the deleted folder behind our back."""
  65. class OnlyDeleted(FakePPS):
  66. def search(self, folder, query, limit=200, days_back=7, enddate=None):
  67. self.calls.append({"m": "search", "folder": folder, "enddate": enddate})
  68. return _moved_records() if folder == "Deleted" else []
  69. pps = OnlyDeleted()
  70. run_step(pps, _cfg(["move", "release"]), "release", "Rep", _chunk(), refresh=True)
  71. assert [s["folder"] for s in pps.searches()] == ["Rep", "Deleted"]
  72. assert pps.acts()[0]["folder"] == "Deleted"
  73. assert pps.acts()[0]["localguids"] == ["R:1", "R:2"]
  74. def test_move_retries_once_after_rereading_when_the_stored_handles_are_stale():
  75. class StaleHandles(FakePPS):
  76. def act(self, action, folder, localguids, **kw):
  77. result = super().act(action, folder, localguids, **kw) # record the attempt
  78. if action == "move" and "6:6:1" in localguids:
  79. raise PPSError("no such localguid")
  80. return result
  81. pps = StaleHandles(records=_moved_records())
  82. run_step(pps, _cfg(["move"]), "move", "Quarantine", _chunk(), refresh=False)
  83. assert [s["folder"] for s in pps.searches()] == ["Quarantine"]
  84. assert [a["localguids"] for a in pps.acts()] == [["6:6:1", "6:6:2"], ["R:1", "R:2"]]
  85. assert pps.acts()[-1]["targetfolder"] == "Rep"
  86. def test_reread_without_guids_raises():
  87. pps = FakePPS()
  88. with pytest.raises(PPSError):
  89. run_step(pps, _cfg(["move", "release"]), "release", "Rep",
  90. [{"localguid": "6:6:1"}], refresh=True) # no guid stored
  91. # ---------------------------------------------------------------- descriptions
  92. def test_pipeline_destination():
  93. assert pipeline_destination(_cfg(["release"])) is None
  94. assert pipeline_destination(_cfg(["move"])) == "Rep"
  95. assert pipeline_destination(_cfg(["move", "release"])) == "Rep"
  96. def test_describe_pipeline_reads_in_execution_order():
  97. text = describe_pipeline(_cfg(["move", "release"]))
  98. assert text.index("Rep") < text.index("released")
  99. def test_allowed_steps_is_canonical_order_and_never_deletes():
  100. assert ALLOWED_STEPS == ("move", "release")
  101. # ---------------------------------------------------------------- worker driving
  102. def _enqueue(queue):
  103. return queue.enqueue("report_release", "Quarantine", _chunk(), {}, user="dev@x")
  104. def _run_next(queue):
  105. """Claim and process one job; returns the claimed job row (None if none is due)."""
  106. job = queue._claim_next()
  107. if job is not None:
  108. queue._process(job)
  109. return job
  110. def _job(queue, job_id):
  111. return next(j for j in queue.recent_jobs(limit=50) if j["id"] == job_id)
  112. def test_zero_delay_runs_every_step_in_one_pass(make_queue):
  113. pps = FakePPS(records=_moved_records())
  114. q = make_queue(pps, _cfg(["move", "release"], step_delay=0))
  115. job_id = _enqueue(q)
  116. _run_next(q)
  117. assert [a["action"] for a in pps.acts()] == ["move", "release"]
  118. assert _job(q, job_id)["status"] == "done"
  119. # Even with no wait, the release step re-reads rather than reusing the move's handles.
  120. assert pps.acts()[1]["localguids"] == ["R:1", "R:2"]
  121. def test_delay_parks_the_job_instead_of_blocking_the_queue(make_queue):
  122. pps = FakePPS(records=_moved_records())
  123. q = make_queue(pps, _cfg(["move", "release"], step_delay=60))
  124. rr_id = _enqueue(q)
  125. other_id = q.enqueue("delete", "Quarantine", [{"localguid": "x"}], {}, user="dev@x")
  126. # First pass: the move, then the job parks itself.
  127. assert _run_next(q)["id"] == rr_id
  128. assert [a["action"] for a in pps.acts()] == ["move"]
  129. parked = _job(q, rr_id)
  130. assert parked["status"] == "pending" and parked["run_after"]
  131. # The job queued behind it is NOT blocked by the wait — it runs now.
  132. assert _run_next(q)["id"] == other_id
  133. assert [a["action"] for a in pps.acts()] == ["move", "delete"]
  134. assert _job(q, other_id)["status"] == "done"
  135. # Nothing else is due while the wait is on.
  136. assert q._claim_next() is None
  137. # Once the delay has passed the job is re-claimed and finishes on fresh handles.
  138. q._conn.execute("UPDATE jobs SET run_after='2000-01-01 00:00:00' WHERE id=?", (rr_id,))
  139. q._conn.commit()
  140. assert _run_next(q)["id"] == rr_id
  141. release = [a for a in pps.acts() if a["action"] == "release"][0]
  142. assert [s["folder"] for s in pps.searches()] == ["Rep"]
  143. assert release["folder"] == "Rep" and release["localguids"] == ["R:1", "R:2"]
  144. assert release["deletedfolder"] is None
  145. assert _job(q, rr_id)["status"] == "done"
  146. def test_resume_keeps_the_steps_it_started_with(make_queue):
  147. """An admin edit during the wait must not change a job already in flight."""
  148. cfg = _cfg(["move", "release"], step_delay=60)
  149. pps = FakePPS(records=_moved_records())
  150. q = make_queue(pps, cfg)
  151. rr_id = _enqueue(q)
  152. _run_next(q)
  153. cfg["report_release"]["steps"] = ["move"] # live config change during the wait
  154. q._conn.execute("UPDATE jobs SET run_after=NULL WHERE id=?", (rr_id,))
  155. q._conn.commit()
  156. _run_next(q)
  157. # Still released: the plan came from the job's state, not the edited config.
  158. assert [a["action"] for a in pps.acts()] == ["move", "release"]
  159. assert _job(q, rr_id)["status"] == "done"
  160. def test_a_chunk_that_failed_a_step_is_skipped_by_later_steps(make_queue):
  161. class OneBadMove(FakePPS):
  162. def act(self, action, folder, localguids, **kw):
  163. result = super().act(action, folder, localguids, **kw) # record the attempt
  164. if action == "move" and localguids == ["6:6:2"]:
  165. raise PPSError("boom")
  166. return result
  167. cfg = _cfg(["move", "release"], step_delay=0)
  168. cfg["chunk_size"] = 1 # one message per chunk
  169. pps = OneBadMove(records=[{"guid": "g1", "localguid": "R:1"}])
  170. q = make_queue(pps, cfg)
  171. rr_id = _enqueue(q)
  172. _run_next(q)
  173. # The message whose move failed is never released; the other one is.
  174. assert [(a["action"], a["localguids"]) for a in pps.acts()] == [
  175. ("move", ["6:6:1"]), ("move", ["6:6:2"]), ("release", ["R:1"])]
  176. row = _job(q, rr_id)
  177. # The failed move re-read first (see the retry above) and couldn't locate it either.
  178. assert row["status"] == "failed" and "not found" in row["error"]
  179. assert row["processed"] == 1
  180. def test_retry_clears_the_parked_state(make_queue):
  181. pps = FakePPS(records=_moved_records())
  182. q = make_queue(pps, _cfg(["move", "release"], step_delay=60))
  183. rr_id = _enqueue(q)
  184. _run_next(q)
  185. q._conn.execute("UPDATE jobs SET status='failed' WHERE id=?", (rr_id,))
  186. q._conn.commit()
  187. assert q.retry(rr_id) is True
  188. row = q._conn.execute("SELECT run_after, state FROM jobs WHERE id=?", (rr_id,)).fetchone()
  189. assert row["run_after"] is None and row["state"] == "{}"
  190. # ...so it starts over at the first step, on the job's own localguids.
  191. _run_next(q)
  192. assert [a["action"] for a in pps.acts()] == ["move", "move"]