From 0c554467d66886ff51c02a7fd636ea848b45711f Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Thu, 29 Oct 2026 10:18:51 -0700 Subject: [PATCH] perf(images): cache the removal marks so a listing page reads no extra table (#480) The marks reload only when this process writes or clears one, or a restore replaces the database. The route snapshot gains the preview route. --- database/image_removal_schema.py | 17 +++++++++++++++++ py/api/__init__.py | 6 ++++++ py/api/admin.py | 2 +- py/api/image_delete.py | 19 +++++++++++++++++-- tests/fixtures/legacy_contract_snapshot.json | 1 + 5 files changed, 42 insertions(+), 3 deletions(-) diff --git a/database/image_removal_schema.py b/database/image_removal_schema.py index 3fad49a6..8be0c321 100644 --- a/database/image_removal_schema.py +++ b/database/image_removal_schema.py @@ -140,6 +140,21 @@ BUMP_MARKS_SQL = ( ) +# Bumped whenever this process writes or clears a mark (even in a transaction +# that later rolls back): readers that cache the marks reload when it moves. +_generation = 0 + + +def generation() -> int: + """The marks generation of this process (see ``_generation``).""" + return _generation + + +def _bump() -> None: + global _generation + _generation += 1 + + def has_marks_table(conn: sqlite3.Connection) -> bool: return ( conn.execute( @@ -167,6 +182,7 @@ def write_mark( ): conn.execute(_MARK_SQL, (locator, int(size), int(mtime_ns), now)) conn.execute(BUMP_MARKS_SQL) + _bump() def clear_marks(conn: sqlite3.Connection, locators) -> int: @@ -183,6 +199,7 @@ def clear_marks(conn: sqlite3.Connection, locators) -> int: removed += conn.execute(_UNMARK_SQL, (locator,)).rowcount if removed: conn.execute(BUMP_MARKS_SQL) + _bump() return removed diff --git a/py/api/__init__.py b/py/api/__init__.py index 2b54b9c9..47a51803 100644 --- a/py/api/__init__.py +++ b/py/api/__init__.py @@ -425,6 +425,12 @@ class PromptManagerAPI( # Reviewed maintenance plans under single-use tokens; a restart # forgets them (py/api/maintenance.py). self._maintenance_plans = PlanStore() + # #480: the removal marks the workspace listing hides, read once now so + # a listing page reads no table until a mark or the database changes. + try: + self._removal_marks() + except Exception: + self.logger.warning("Removal marks could not be read", exc_info=True) # A safety backup interrupted by a crash leaves its temp copy behind. try: diff --git a/py/api/admin.py b/py/api/admin.py index 466f6ebe..a6b123d2 100644 --- a/py/api/admin.py +++ b/py/api/admin.py @@ -884,7 +884,7 @@ class AdminRoutesMixin: media_files = [] seen = set() # #480: a file removed from the library is never a copy or a keeper. - marks = self.db.removal_marks() + marks = self._removal_marks() for slot, root in enumerate(roots): for path in _collect_media_files([root], extensions): try: diff --git a/py/api/image_delete.py b/py/api/image_delete.py index 3e74c3e1..fe011149 100644 --- a/py/api/image_delete.py +++ b/py/api/image_delete.py @@ -46,9 +46,11 @@ from .job_routes import busy_response from .maintenance import MAINTENANCE_PREVIEW_LEASE, _fail_job, _failure, backup_note try: + from ...database import image_removal_schema from ...database.annotation_schema import MAX_LOCATOR_LENGTH from ...utils import trash except ImportError: + from database import image_removal_schema from database.annotation_schema import MAX_LOCATOR_LENGTH from utils import trash @@ -417,6 +419,19 @@ class ImageDeleteMixin: # ── the workspace listing ───────────────────────────────────────── + def _removal_marks(self) -> dict: + """The removal marks, cached until a mark changes or the database does. + + A listing page reads no table while nothing changed: the cache follows + this process's marks generation and the database identity (a restore). + """ + key = (self._database_identity(self.db), image_removal_schema.generation()) + cached = getattr(self, "_marks_cache", None) + if cached is None or cached[0] != key: + cached = (key, self.db.removal_marks()) + self._marks_cache = cached + return cached[1] + def _hidden_entries(self, listings) -> set: """Normalized paths of listed files a removal mark hides (blocking). @@ -424,7 +439,7 @@ class ImageDeleteMixin: ``lstat``'ed: a mark hides a file while its size and ``mtime_ns`` are still the ones it was removed with. """ - marks = self.db.removal_marks() + marks = self._removal_marks() if not marks: return set() located = self._workspace_locators(listings) @@ -443,7 +458,7 @@ class ImageDeleteMixin: def _removed_paths(self, paths) -> set: """The absolute paths among *paths* a removal mark hides (blocking).""" - marks = self.db.removal_marks() + marks = self._removal_marks() if not marks: return set() hidden = set() diff --git a/tests/fixtures/legacy_contract_snapshot.json b/tests/fixtures/legacy_contract_snapshot.json index b24dcc86..4d3dc248 100644 --- a/tests/fixtures/legacy_contract_snapshot.json +++ b/tests/fixtures/legacy_contract_snapshot.json @@ -118,6 +118,7 @@ "POST /prompt_manager/image-annotations/observe", "POST /prompt_manager/image-annotations/suggest", "POST /prompt_manager/images/clear-thumbnails", + "POST /prompt_manager/images/delete/preview", "POST /prompt_manager/images/generate-thumbnails", "POST /prompt_manager/images/generate-thumbnails/progress", "POST /prompt_manager/images/link",