From cfa5e377760436c671ed588b7a41e366cfc6572f Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 27 Oct 2026 00:24:39 -0700 Subject: [PATCH] feat(logs): review and apply rotated-log cleanup as a maintenance plan POST /maintenance/preview {operation: "rotated_logs", options: {}} lists the rotated copies Rule R allows, with size and time, and what it keeps (the active log, links, other files); it writes nothing and answers the Plan shape with basenames only, backup policy none and no disk key. POST /maintenance/apply starts the "Rotated log copies" maintenance job (admission read: runs beside a scan, refused beside another maintenance job or an exclusive one). It deletes exactly the reviewed copies, or nothing and fails plan-changed when any of them changed after the review; a copy that cannot be deleted becomes an errors row. The token is consumed on the loop turn the job starts. (#329) --- py/api/__init__.py | 3 + py/api/log_cleanup.py | 203 ++++++++++++++++ py/api/maintenance.py | 21 +- tests/test_api_log_cleanup.py | 419 ++++++++++++++++++++++++++++++++++ 4 files changed, 644 insertions(+), 2 deletions(-) create mode 100644 py/api/log_cleanup.py create mode 100644 tests/test_api_log_cleanup.py diff --git a/py/api/__init__.py b/py/api/__init__.py index ba47bbb..b6466c1 100644 --- a/py/api/__init__.py +++ b/py/api/__init__.py @@ -5,6 +5,7 @@ Split into domain-specific mixins for maintainability: - ImageRoutesMixin: gallery, thumbnails, image serving and linking - AdminRoutesMixin: duplicates, stats, settings, diagnostics, maintenance, backup, scan - LoggingRoutesMixin: log management endpoints +- LogCleanupMixin: the reviewed rotated-log cleanup (a maintenance plan) - AutotagRoutesMixin: auto-tagging model management and tagging - JobRoutesMixin: the session job registry behind Activity (py/api/jobs.py) - ImageTagJobsMixin: bulk Add tags / Auto-tag as image-tags jobs @@ -65,6 +66,7 @@ from .duplicate_files import DuplicateFilesMixin from .maintenance_plans import PlanStore from .jobs import JobRegistry from .logging_routes import LoggingRoutesMixin +from .log_cleanup import LogCleanupMixin from .autotag_routes import AutotagRoutesMixin from .lora_integration import LoraIntegrationMixin from .lora_import_routes import LoraImportRoutesMixin @@ -323,6 +325,7 @@ class PromptManagerAPI( WorkflowStorageRoutesMixin, DuplicateFilesMixin, LoggingRoutesMixin, + LogCleanupMixin, AutotagRoutesMixin, LoraIntegrationMixin, LoraImportRoutesMixin, diff --git a/py/api/log_cleanup.py b/py/api/log_cleanup.py new file mode 100644 index 0000000..83eb5ea --- /dev/null +++ b/py/api/log_cleanup.py @@ -0,0 +1,203 @@ +"""Reviewed rotated-log cleanup as a maintenance plan (#329 System S12). + +``POST /prompt_manager/maintenance/preview {operation: "rotated_logs", +options: {}}`` lists the rotated copies of the active log that Rule R allows +(``utils/logging_config.py``: ``prompt_manager.log.`` regular files that +are not links, directly in the log folder) and writes nothing. The plan keeps +each copy's name, size, ``mtime_ns`` and inode under its token; the response +is the maintenance ``Plan`` shape with basenames only, a ``none`` backup +policy (no library record changes) and no ``disk`` key. + +``POST /prompt_manager/maintenance/apply {token}`` starts a ``maintenance`` +job titled "Rotated log copies" with admission ``read``: it may run beside a +scan and is refused only beside another maintenance job or an ``exclusive`` +one. The job deletes exactly the reviewed copies under the logger's files +lock and the file handler's lock, or nothing, failing ``plan-changed``, when +any of them is missing, is no longer a rotated copy or differs in size, +``mtime_ns`` or inode. A copy that cannot be deleted (open in another program +on Windows) is an ``errors`` row; the others are deleted. +""" + +import datetime +import json + +from aiohttp import web + +try: + from ...utils.logging_config import RotatedChanged, rotated_sort_key +except ImportError: # loaded as the top-level ``py`` package (tests) + from utils.logging_config import RotatedChanged, rotated_sort_key + +from .jobs import MAINTENANCE_JOB_KIND +from .maintenance_plans import ScopeTooLarge + +ROTATED_LOGS = "rotated_logs" +LOG_CLEANUP_TITLE = "Rotated log copies" +LOGS_CHANGED = ( + "The log files changed since the review; nothing was deleted. Review again." +) +LOG_CLEANUP_FAILED = "The log cleanup failed." +TOO_MANY_LOG_FILES = "Too many log files to review at once." +LOG_CLEANUP_MEDIA = ( + "Deletes only the listed log files. No image, video or library record changes." +) +_GROUPS = ("changed", "protected", "errors") + + +def size_label(size): + """``1000 bytes``, ``2.0 KB``, ``10.0 MB`` or ``1.2 GB`` (1 KB = 1024 bytes).""" + size = max(0, int(size or 0)) + if size < 1024: + return f"{size} {'byte' if size == 1 else 'bytes'}" + for unit, scale in (("KB", 2**10), ("MB", 2**20)): + if size < scale * 1024: + return f"{size / scale:.1f} {unit}" + return f"{size / 2**30:.1f} GB" + + +def time_label(mtime_ns): + """The local modification time as ``YYYY-MM-DD HH:MM``.""" + return datetime.datetime.fromtimestamp(mtime_ns / 1e9).strftime("%Y-%m-%d %H:%M") + + +def _count(count, singular, plural): + return f"{count} {singular if count == 1 else plural}" + + +def _protected_reasons(rows): + """The plan's Protected reasons: what the cleanup keeps, zero counts left out.""" + active = sum(1 for row in rows if row["kind"] == "active") + links = sum(1 for row in rows if row["kind"] == "link") + other = sum(1 for row in rows if row["kind"] == "other") + reasons = [] + if active: + reasons.append("Kept: the active log") + if links: + reasons.append(f"Kept: {_count(links, 'link', 'links')}") + if other: + reasons.append(f"Kept: {_count(other, 'other file', 'other files')}") + return active + links + other, reasons + + +def _scope_label(files): + if not files: + return "No rotated log copies" + total = sum(row["size"] for row in files) + return ( + f"{_count(len(files), 'rotated log copy', 'rotated log copies')}" + f" · {size_label(total)}" + ) + + +class LogCleanupMixin: + """The ``rotated_logs`` maintenance plan and its job. + + The logger manager is reached only through ``_get_logger_manager`` + (``logging_routes.py``), so the tests can point it at a temporary folder. + """ + + async def preview_log_cleanup(self): + """The ``rotated_logs`` plan (runs inside the preview's lease).""" + from . import maintenance + + manager = self._get_logger_manager() + try: + rows = await self._run_in_executor(manager.scan_log_folder) + except Exception: + self.logger.exception("Rotated log review failed") + return maintenance._failure("Failed to review maintenance", 500) + rotated = sorted( + (row for row in rows if row["kind"] == "rotated"), + key=rotated_sort_key, + ) + files = [ + { + "name": row["name"], + "size": row["size"], + "mtime_ns": row["mtime_ns"], + "ino": row["ino"], + } + for row in rotated + ] + protected, reasons = _protected_reasons(rows) + store = self._maintenance_plans + try: + token = store.issue( + ROTATED_LOGS, + json.dumps(files, sort_keys=True), + {"files": files, "protected": protected}, + (), + ) + except ScopeTooLarge: + return maintenance._failure(TOO_MANY_LOG_FILES, 413, "too-large") + plan = store.peek(token, ROTATED_LOGS) + return web.json_response( + { + "success": True, + "token": token, + "operation": ROTATED_LOGS, + "expires_at": maintenance._iso(plan.expires_at), + "scope": {"count": len(files), "label": _scope_label(files)}, + "protected": {"count": protected, "reasons": reasons}, + "backup": { + "policy": "none", + "destination_label": None, + "expected_bytes": None, + }, + "media": {"deletes_files": True, "label": LOG_CLEANUP_MEDIA}, + "details": [ + { + "label": row["name"], + "detail": ( + f"{size_label(row['size'])} · {time_label(row['mtime_ns'])}" + ), + } + for row in files + ], + }, + headers={"Cache-Control": "no-store"}, + ) + + def _start_log_cleanup(self, plan): + """Register the cleanup job (on the loop); raises JobBusy when refused.""" + return self._jobs.start( + MAINTENANCE_JOB_KIND, + LOG_CLEANUP_TITLE, + admission="read", + cancellable=False, + scope={"operation": ROTATED_LOGS}, + groups=_GROUPS, + runner=lambda job: self._run_log_cleanup(job, plan), + ) + + async def _run_log_cleanup(self, job, plan): + from . import maintenance + + files = plan.payload["files"] + sizes = {row["name"]: row["size"] for row in files} + job.update(total=len(files), status_line="Deleting rotated log copies") + manager = self._get_logger_manager() + try: + result = await self._run_in_executor(manager.delete_rotated, files) + except RotatedChanged: + return maintenance._fail_job(job, "plan-changed", LOGS_CHANGED) + except Exception: + self.logger.exception("Rotated log cleanup failed") + return maintenance._fail_job(job, None, LOG_CLEANUP_FAILED) + for name in result["removed"]: + detail = f"Deleted · {size_label(sizes.get(name, 0))}" + job.record( + "changed", {"path": name, "detail": detail}, f"- {name} · {detail}" + ) + for name, error in result["failed"]: + detail = self._public_error(error) + job.record( + "errors", {"path": name, "detail": detail}, f"! {name} · {detail}" + ) + job.record_counted("protected", plan.payload["protected"]) + removed, failed = len(result["removed"]), len(result["failed"]) + line = f"Deleted {_count(removed, 'rotated log copy', 'rotated log copies')}" + if failed: + line += f", {failed} could not be deleted" + job.log_line(line) + job.update(processed=len(files), progress=100, status_line=line) diff --git a/py/api/maintenance.py b/py/api/maintenance.py index 98cb648..dd103b8 100644 --- a/py/api/maintenance.py +++ b/py/api/maintenance.py @@ -2,7 +2,9 @@ ``POST /prompt_manager/maintenance/preview {operation, options:{}}`` reviews one operation (``duplicate_prompts``, ``optimize``, ``unused_components`` or -``missing_links``; ``database/maintenance.py``) off the event loop and +``missing_links``; ``database/maintenance.py``; ``duplicate_files``, +``py/api/duplicate_files.py``; ``rotated_logs``, the rotated log copies, +``py/api/log_cleanup.py``, which has its own apply job) off the event loop and answers a ``Plan``: ``{success, token, operation, expires_at, scope:{count, label}, protected:{count, reasons}, backup:{policy, destination_label}, media:{deletes_files, label}, details:[≤50 {label, detail}]}``. The preview @@ -54,6 +56,7 @@ from aiohttp import web from .admin import _prune_safety_backups, _scan_location, _scan_roots from .job_routes import busy_response from .jobs import MAINTENANCE_JOB_KIND, JobBusy +from .log_cleanup import ROTATED_LOGS from .maintenance_plans import PlanExpired, PlanUsed, ScopeTooLarge try: @@ -83,7 +86,8 @@ MAINTENANCE_PREVIEW_LEASE = "Maintenance preview" DUPLICATE_PREVIEW_BODY_MAX = 1024 * 1024 DUPLICATE_FILES = "duplicate_files" WORKFLOW_STORAGE = "workflow_storage" # applied by py/api/workflow_storage.py -PREVIEW_OPERATIONS = MAINTENANCE_OPERATIONS + (DUPLICATE_FILES,) +# rotated_logs (#329, py/api/log_cleanup.py) reviews log files, not records. +PREVIEW_OPERATIONS = MAINTENANCE_OPERATIONS + (DUPLICATE_FILES, ROTATED_LOGS) # Room left over beyond what a step writes, so a disk is never filled to 0. DISK_MARGIN_BYTES = 16 * 2**20 # The concept's Maintenance row titles, which name each operation's job. @@ -391,6 +395,8 @@ class MaintenanceRoutesMixin: return await self.preview_duplicate_files(options) if not isinstance(options, dict) or options: return _invalid(f"{operation} takes no options") + if operation == ROTATED_LOGS: + return await self.preview_log_cleanup() db = self.db identity = self._database_identity(db) try: @@ -492,6 +498,17 @@ class MaintenanceRoutesMixin: if refused is not None: return refused store = self._maintenance_plans + if plan.operation == ROTATED_LOGS: + # Log files only: no database identity, backup or disk check. + try: + job = self._start_log_cleanup(plan) + except JobBusy as busy: + return busy_response(busy) # the token stays usable + # Same loop turn as the peek and the start: the runner has not run. + store.consume(token, plan.operation, job_id=job.id) + return web.json_response( + {"success": True, "job": job.summary()}, status=202 + ) db = self.db if plan.db_identity != self._database_identity(db): store.discard(token) diff --git a/tests/test_api_log_cleanup.py b/tests/test_api_log_cleanup.py new file mode 100644 index 0000000..d928708 --- /dev/null +++ b/tests/test_api_log_cleanup.py @@ -0,0 +1,419 @@ +"""Reviewed rotated-log cleanup as a maintenance plan (#329, py/api/log_cleanup.py). + +``POST /maintenance/preview {operation: "rotated_logs"}`` lists exactly the +rotated copies Rule R allows and writes nothing; ``POST /maintenance/apply`` +deletes exactly those as a ``maintenance`` job (admission ``read``), or +nothing, failing ``plan-changed``, when any of them changed after the review. + +Every test runs on a real ``PromptManagerLogger`` whose log folder is a +temporary folder, never the real one. +""" + +import asyncio +import json +import logging +import ntpath +import os +import posixpath +import re +import sys +import threading +import unittest +import unittest.mock as mock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from py.api.maintenance_plans import PlanStore # noqa: E402 +from utils import logging_config # noqa: E402 +from utils.logging_config import PromptManagerLogger, get_logger_manager # noqa: E402 + +try: + from tests.open_handles import assert_closed # noqa: E402 + from tests.test_api_maintenance import MaintenanceAPITestCase # noqa: E402 +except ImportError: # discovered with tests/ as the top-level directory + from open_handles import assert_closed # noqa: E402 + from test_api_maintenance import MaintenanceAPITestCase # noqa: E402 + +WAIT = 10 +PREVIEW = "/prompt_manager/maintenance/preview" +APPLY = "/prompt_manager/maintenance/apply" +CHANGED = "The log files changed since the review; nothing was deleted. Review again." +MEDIA = "Deletes only the listed log files. No image, video or library record changes." +_WINDOWS_ABSOLUTE = re.compile(r"^[A-Za-z]:[\\/]") + + +def _strings(value): + if isinstance(value, str): + yield value + elif isinstance(value, dict): + for item in value.values(): + yield from _strings(item) + elif isinstance(value, (list, tuple)): + for item in value: + yield from _strings(item) + + +class LogCleanupTestCase(MaintenanceAPITestCase): + async def get_application(self): + app = await super().get_application() + package = os.path.join(self.tmpdir, "pkg") + os.makedirs(os.path.join(package, "utils")) + fake_file = os.path.join(package, "utils", "logging_config.py") + with mock.patch.object(logging_config, "__file__", fake_file): + self.manager = object.__new__(PromptManagerLogger) # bypass singleton + self.manager.__init__() + self.addCleanup(self._restore_global_logging) + self.manager.update_config({"console_logging": False}) + self._only_this_thread() + self.log_dir = str(self.manager.log_dir) + self.app_logger = logging.getLogger("prompt_manager.log_cleanup_test") + self.app_logger.info("active log line") + self.api._get_logger_manager = lambda: self.manager + self.outside = os.path.join(self.tmpdir, "link-targets") + os.makedirs(self.outside) + return app + + def _only_this_thread(self): + """Only this test's records reach the temporary log (not other threads').""" + this_thread = threading.get_ident() + for handler in self.manager.logger.handlers: + handler.filters.clear() + handler.addFilter(lambda record: record.thread == this_thread) + + def _restore_global_logging(self): + for handler in logging.getLogger("prompt_manager").handlers[:]: + handler.close() + assert_closed(self, self.log_dir) + get_logger_manager()._setup_loggers() + + def path(self, name): + return os.path.join(self.log_dir, name) + + def write(self, name, data=b"rotated\n"): + with open(self.path(name), "wb") as handle: + handle.write(data) + + def symlink(self, name): + target = os.path.join(self.outside, "secret.txt") + with open(target, "wb") as handle: + handle.write(b"secret " * 100) + try: + os.symlink(target, self.path(name)) + except (OSError, NotImplementedError): # pragma: no cover - no privilege + return False + return True + + def names(self): + return sorted(os.listdir(self.log_dir)) + + def snapshot(self): + """Every entry but the active log, with its bytes (None for a folder).""" + state = {} + for name in self.names(): + if name == "prompt_manager.log": + continue + path = self.path(name) + if os.path.islink(path): + state[name] = ("link", os.readlink(path)) + elif os.path.isdir(path): + state[name] = None + else: + with open(path, "rb") as handle: + state[name] = handle.read() + return state + + def seed(self): + """CUJ 5's folder: three rotated copies, a .bak, a folder and a link.""" + self.write("prompt_manager.log.1", b"a" * 1000) + self.write("prompt_manager.log.2", b"b" * 2000) + self.write("prompt_manager.log.3", b"c" * 3000) + self.write("prompt_manager.log.bak", b"mine") + os.makedirs(self.path("prompt_manager.log.9")) + return self.symlink("prompt_manager.log.4") + + async def preview(self, status=200, **body): + return await self.post( + PREVIEW, {"operation": "rotated_logs", "options": {}, **body}, status + ) + + async def apply(self, token, status=202): + return await self.post(APPLY, {"token": token}, status) + + async def run_apply(self, plan): + body = await self.apply(plan["token"]) + job = self.api._jobs.get(body["job"]["id"]) + await asyncio.wait_for(asyncio.shield(job.task), WAIT) + return body["job"], job + + +class TestPreview(LogCleanupTestCase): + async def test_preview_lists_only_rotated_copies(self): + linked = self.seed() + plan = await self.preview() + self.assertEqual( + set(plan), + { + "success", + "token", + "operation", + "expires_at", + "scope", + "protected", + "backup", + "media", + "details", + }, + ) + self.assertEqual(plan["operation"], "rotated_logs") + self.assertEqual( + plan["scope"], {"count": 3, "label": "3 rotated log copies · 5.9 KB"} + ) + self.assertEqual( + [row["label"] for row in plan["details"]], + ["prompt_manager.log.1", "prompt_manager.log.2", "prompt_manager.log.3"], + ) + self.assertRegex( + plan["details"][2]["detail"], r"^2\.9 KB · \d{4}-\d\d-\d\d \d\d:\d\d$" + ) + reasons = ["Kept: the active log"] + if linked: + reasons.append("Kept: 1 link") + reasons.append("Kept: 2 other files") + self.assertEqual( + plan["protected"], {"count": len(reasons) + 1, "reasons": reasons} + ) + self.assertEqual( + plan["backup"], + {"policy": "none", "destination_label": None, "expected_bytes": None}, + ) + self.assertEqual(plan["media"], {"deletes_files": True, "label": MEDIA}) + self.assertTrue(plan["expires_at"].endswith("+00:00")) + self.assertNotIn("mtime_ns", json.dumps(plan)) + + async def test_preview_writes_nothing(self): + self.seed() + before = self.snapshot() + await self.preview() + self.assertEqual(self.snapshot(), before) + + async def test_preview_has_no_paths(self): + self.seed() + plan = await self.preview() + for text in _strings(plan): + self.assertFalse(posixpath.isabs(text), text) + self.assertFalse(ntpath.isabs(text), text) + self.assertIsNone(_WINDOWS_ABSOLUTE.match(text), text) + + async def test_preview_rejects_non_empty_options(self): + for options in ({"all": True}, [], "x"): + with self.subTest(options=options): + reply = await self.preview(status=400, options=options) + self.assertEqual(reply["code"], "invalid-request") + + async def test_preview_too_large_is_413(self): + self.seed() + self.api._maintenance_plans = PlanStore(max_bytes=10) + reply = await self.preview(status=413) + self.assertEqual( + reply, + { + "success": False, + "code": "too-large", + "error": "Too many log files to review at once.", + }, + ) + + async def test_empty_folder_plan_has_scope_zero(self): + plan = await self.preview() + self.assertEqual(plan["scope"], {"count": 0, "label": "No rotated log copies"}) + self.assertEqual(plan["details"], []) + self.assertEqual( + plan["protected"], {"count": 1, "reasons": ["Kept: the active log"]} + ) + + async def test_preview_refused_while_an_exclusive_job_runs(self): + async with self.api._jobs.hold( + "restore", "Database restore", admission="exclusive" + ): + reply = await self.preview(status=409) + self.assertEqual(reply["code"], "busy") + + +class TestApply(LogCleanupTestCase): + async def test_apply_deletes_exactly_the_reviewed_files(self): + linked = self.seed() + plan = await self.preview() + summary, job = await self.run_apply(plan) + self.assertEqual(summary["kind"], "maintenance") + self.assertEqual(summary["title"], "Rotated log copies") + self.assertEqual(summary["admission"], "read") + self.assertFalse(summary["cancellable"]) + self.assertEqual(summary["scope"], {"operation": "rotated_logs"}) + self.assertEqual(job.state, "finished", job.log) + self.assertEqual( + job.counters, + {"changed": 3, "protected": plan["protected"]["count"], "errors": 0}, + ) + expected = [ + "prompt_manager.log", + "prompt_manager.log.9", + "prompt_manager.log.bak", + ] + if linked: + expected.append("prompt_manager.log.4") + self.assertEqual(self.names(), sorted(expected)) + self.assertEqual(job.status_line, "Deleted 3 rotated log copies") + report = json.dumps(job.report()) + self.assertNotIn(self.tmpdir, report) + + async def test_apply_rows_name_each_file(self): + self.seed() + plan = await self.preview() + _summary, job = await self.run_apply(plan) + self.assertEqual( + job.outcomes["changed"], + [ + { + "path": "prompt_manager.log.1", + "root": "", + "root_index": None, + "detail": "Deleted · 1000 bytes", + }, + { + "path": "prompt_manager.log.2", + "root": "", + "root_index": None, + "detail": "Deleted · 2.0 KB", + }, + { + "path": "prompt_manager.log.3", + "root": "", + "root_index": None, + "detail": "Deleted · 2.9 KB", + }, + ], + ) + log = "\n".join(job.log) + for n in (1, 2, 3): + self.assertIn(f"prompt_manager.log.{n}", log) + + async def test_apply_after_rollover_fails_plan_changed_and_deletes_nothing(self): + self.seed() + plan = await self.preview() + handler = next( + h + for h in self.manager.logger.handlers + if isinstance(h, logging.FileHandler) + ) + handler.acquire() + try: + handler.doRollover() # every listed name now holds another file + finally: + handler.release() + before = self.snapshot() + _summary, job = await self.run_apply(plan) + self.assertEqual(job.state, "failed") + self.assertEqual(job.scope["failure"], "plan-changed") + self.assertEqual(job.status_line, CHANGED) + self.assertEqual(job.counters["changed"], 0) + self.assertEqual(self.snapshot(), before) + + async def test_apply_reports_a_file_in_use(self): + self.seed() + plan = await self.preview() + real_unlink = os.unlink + held = self.path("prompt_manager.log.2") + + def unlink(path, *args, **kwargs): + if os.fspath(path) == held: + error = PermissionError( + 13, + "The process cannot access the file because it is being " + "used by another process", + held, + ) + error.winerror = 32 + raise error + return real_unlink(path, *args, **kwargs) + + with mock.patch.object(logging_config.os, "unlink", unlink): + _summary, job = await self.run_apply(plan) + self.assertEqual(job.counters["changed"], 2) + self.assertEqual(job.counters["errors"], 1) + [row] = job.outcomes["errors"] + self.assertEqual(row["path"], "prompt_manager.log.2") + self.assertIn("being used by another process", row["detail"]) + self.assertNotIn(self.tmpdir, json.dumps(job.report())) + self.assertIn("prompt_manager.log.2", self.names()) + self.assertEqual( + job.status_line, "Deleted 2 rotated log copies, 1 could not be deleted" + ) + + async def test_token_is_single_use(self): + self.seed() + plan = await self.preview() + summary, _job = await self.run_apply(plan) + again = await self.apply(plan["token"], 409) + self.assertEqual(again["code"], "plan-used") + self.assertEqual( + again["job"], {"id": summary["id"], "title": "Rotated log copies"} + ) + + async def test_apply_runs_beside_a_scan(self): + self.seed() + plan = await self.preview() + async with self.api._jobs.hold("scan", "Metadata scan", admission="records"): + _summary, job = await self.run_apply(plan) + self.assertEqual(job.state, "finished", job.log) + self.assertNotIn("prompt_manager.log.1", self.names()) + + async def test_apply_refused_while_restore_runs(self): + self.seed() + plan = await self.preview() + async with self.api._jobs.hold( + "restore", "Database restore", admission="exclusive" + ) as restore: + busy = await self.apply(plan["token"], 409) + self.assertEqual(busy["code"], "busy") + self.assertEqual( + busy["running"], {"id": restore.id, "title": "Database restore"} + ) + self.assertIn("prompt_manager.log.1", self.names()) + # The refused apply kept the token. + _summary, job = await self.run_apply(plan) + self.assertEqual(job.state, "finished", job.log) + + async def test_apply_refused_beside_another_maintenance_job(self): + self.seed() + plan = await self.preview() + async with self.api._jobs.hold( + "maintenance", "Optimize database", admission="records" + ): + busy = await self.apply(plan["token"], 409) + self.assertEqual(busy["code"], "busy") + + async def test_active_log_survives_and_keeps_growing(self): + self.seed() + plan = await self.preview() + await self.run_apply(plan) + self.app_logger.info("written after the cleanup") + with open(self.path("prompt_manager.log"), "rb") as handle: + content = handle.read() + self.assertIn(b"active log line", content) + self.assertIn(b"written after the cleanup", content) + + async def test_an_unexpected_failure_fails_the_job_without_details(self): + self.seed() + plan = await self.preview() + with mock.patch.object( + self.manager, "delete_rotated", side_effect=RuntimeError(self.tmpdir) + ): + with self.assertLogs("prompt_manager", "ERROR"): + _summary, job = await self.run_apply(plan) + self.assertEqual(job.state, "failed") + self.assertNotIn(self.tmpdir, json.dumps(job.report())) + self.assertEqual(job.status_line, "The log cleanup failed.") + + +if __name__ == "__main__": + unittest.main()