fix(images): filename search survives a failed prompt-link read

On main a workspace q matched only file paths, so search worked with a
broken database. Since search also matches linked prompts, any q turned
a failed link read into a 500. A q without category, tags or the rating
sort now falls back to filename matches with a null positive_count and
logs the failure with its traceback; those linked filters still fail.
This commit is contained in:
Vito Sansevero
2026-10-19 23:31:10 -07:00
parent 46e0f6cf79
commit 21d6fe4356
3 changed files with 54 additions and 7 deletions
+4 -2
View File
@@ -490,8 +490,10 @@ set, not the page:
- Absent on a plain listing's cursor pages, which read no links. The frontend
keeps the first page's value.
- Null when a plain first page's link read failed. The listing still returns 200,
and consumers then hide "· K positive prompts". A failure under a filter or
the rating sort stays a safe 500.
and consumers then hide "· K positive prompts". A `q` search alone also
survives the failure: it matches filenames only, as before linked search, and
every page's count is null (logged with the traceback). A failure under
`category`, `tags` or the rating sort stays a safe 500.
Library parents (list, detail and command receipts) gain `run_count` (an int; the
D23 migration estimate for rows from before 3.2.4) and `last_used_at`, a UTC ISO
+13 -3
View File
@@ -1141,7 +1141,8 @@ class ImageRoutesMixin:
count is _COUNT_OMITTED, so the response leaves ``positive_count`` out
and the frontend keeps the first page's value. On that plain first
page a failed read leaves the listing usable and the count unknown
(None).
(None); so does a failed read under ``q`` alone, whose search then
matches filenames only (as before the linked search existed).
"""
order, category, tags = filters["order"], filters["category"], filters["tags"]
needed = (
@@ -1161,10 +1162,19 @@ class ImageRoutesMixin:
wanted=self._workspace_wanted(listings),
)
except Exception:
if needed:
# Category, tags and the rating sort cannot be served without
# the links. A plain listing and a filename search can: the
# search then matches filenames only, and the count is None.
if category is not None or tags or order == "rating":
raise
self.logger.warning(
"Workspace positive count could not be read", exc_info=True
"Workspace prompt links could not be read; %s",
(
"searching filenames only"
if filters["query"]
else "positive count unknown"
),
exc_info=True,
)
if order == "rating":
+37 -2
View File
@@ -988,8 +988,14 @@ class TestWorkspaceImages(ImageAPITestCase):
self.assertEqual(data["total"], 10)
self.assertIsNone(data["positive_count"])
self.assertNotIn("database gone", json.dumps(data))
# A filter or the rating sort needs the read, so its failure is an error.
for params in ({"q": "zebra"}, {"category": "Night"}, {"sort": "rating"}):
# A linked filter or the rating sort needs the read, so its failure is
# an error (filename-only search survives it, see below).
for params in (
{"category": "Night"},
{"tags": "a"},
{"q": "zebra", "category": "Night"},
{"sort": "rating"},
):
with (
self.subTest(failing=params),
patch.object(
@@ -1000,6 +1006,35 @@ class TestWorkspaceImages(ImageAPITestCase):
self.assertEqual(response.status, 500, data)
self.assertNotIn("images", data)
async def test_filename_search_survives_a_database_failure(self):
for name, mtime in (("zebra-1.png", 300), ("zebra-2.png", 200)):
self.media(name, mtime)
self.media("other.png", 100)
with (
patch.object(
self.api.db,
"get_image_link_matches",
side_effect=RuntimeError("database gone"),
),
self.assertLogs(self.api.logger, "WARNING") as logged,
):
response, first = await self.get(q="ZEBRA", limit=1)
self.assertEqual(response.status, 200, first)
response, second = await self.get(
q="ZEBRA", limit=1, cursor=first["next_cursor"]
)
self.assertEqual(response.status, 200, second)
# Filename matches only; the count of linked positives is unknown.
self.assertEqual(self._names(first), ["zebra-1.png"])
self.assertEqual(self._names(second), ["zebra-2.png"])
for page in (first, second):
self.assertEqual(page["total"], 2)
self.assertIsNone(page["positive_count"])
self.assertNotIn("database gone", json.dumps(page))
self.assertTrue(
all(record.exc_info for record in logged.records), logged.output
)
async def test_search_matches_filename_prompt_text_and_tags(self):
from database.models import normalize_image_path