Skip to content

Commit bc8f848

Browse files
committed
feat: new system maintenance features
1 parent 9f6a92c commit bc8f848

11 files changed

Lines changed: 2079 additions & 1500 deletions

File tree

server/routers/system.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,15 @@ def cleanup() -> dict:
189189
return maintenance.cleanup_report()
190190

191191

192+
@router.get("/cleanup/{category}")
193+
def cleanup_category(category: str) -> dict:
194+
"""Return one category's ``{count, bytes}`` (per-row lazy loader)."""
195+
try:
196+
return maintenance.category_report(category)
197+
except ValueError as exc:
198+
raise HTTPException(status_code=404, detail=str(exc)) from exc
199+
200+
192201
@router.post("/cleanup/{category}")
193202
def cleanup_purge(category: str) -> dict:
194203
"""Purge one cleanup category; return what was removed and reclaimed."""

src/maintenance.py

Lines changed: 158 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,24 @@
2727

2828
from src import crops, db, thumbnails, wm_compose
2929
from src import sqlite_store as store
30+
from src.constants import WATERMARK_BACKUPS_DIR
3031

3132
logger = logging.getLogger(__name__)
3233

3334
# The cleanup categories, in the order the System view lists them.
34-
CATEGORIES = ("media", "captions", "patches", "thumbs")
35+
CATEGORIES = (
36+
"media",
37+
"captions",
38+
"dataset_captions",
39+
"claims",
40+
"quality",
41+
"embeddings",
42+
"index",
43+
"crops",
44+
"patches",
45+
"wm_backups",
46+
"thumbs",
47+
)
3548

3649

3750
def _safe_size(path: Path) -> int:
@@ -103,12 +116,118 @@ def purge_orphan_media() -> dict:
103116

104117
def unused_caption_report() -> dict:
105118
"""Return ``{count, bytes}`` for the superseded caption revisions."""
106-
return {"count": store.unused_revision_count(), "bytes": 0}
119+
return {
120+
"count": store.unused_revision_count(),
121+
"bytes": store.unused_revision_bytes(),
122+
}
107123

108124

109125
def purge_unused_captions() -> dict:
110126
"""Prune the superseded caption revisions; return ``{purged, bytes}``."""
111-
return {"purged": store.prune_unused_revisions(), "bytes": 0}
127+
freed = store.unused_revision_bytes()
128+
return {"purged": store.prune_unused_revisions(), "bytes": freed}
129+
130+
131+
# -- captions of media in no dataset ------------------------------------------
132+
133+
134+
def unlinked_caption_report() -> dict:
135+
"""Return ``{count, bytes}`` for captions of media in no dataset."""
136+
return store.unlinked_caption_report()
137+
138+
139+
def purge_unlinked_captions() -> dict:
140+
"""Delete those caption histories; return ``{purged, bytes}``."""
141+
freed = store.unlinked_caption_report()["bytes"]
142+
return {"purged": store.purge_unlinked_captions(), "bytes": freed}
143+
144+
145+
# -- caption grounding (claims) history ---------------------------------------
146+
147+
148+
def claims_report() -> dict:
149+
"""Return ``{count, bytes}`` for the stored caption-claim history."""
150+
return store.grounding_history_report()
151+
152+
153+
def purge_claims() -> dict:
154+
"""Clear the whole grounding history; return ``{purged, bytes}``."""
155+
freed = store.grounding_history_report()["bytes"]
156+
return {"purged": store.purge_grounding_history(), "bytes": freed}
157+
158+
159+
# -- index data (quality / embeddings / dims+hashes) --------------------------
160+
161+
162+
def quality_report() -> dict:
163+
"""Return ``{count, bytes}`` for the stored quality scores."""
164+
return store.quality_scores_report()
165+
166+
167+
def purge_quality() -> dict:
168+
"""Delete every quality score; return ``{purged, bytes}``."""
169+
freed = store.quality_scores_report()["bytes"]
170+
return {"purged": store.purge_quality_scores(), "bytes": freed}
171+
172+
173+
def embeddings_report() -> dict:
174+
"""Return ``{count, bytes}`` for the stored embedding vectors."""
175+
return store.embeddings_report()
176+
177+
178+
def purge_embeddings() -> dict:
179+
"""Delete every embedding vector; return ``{purged, bytes}``."""
180+
freed = store.embeddings_report()["bytes"]
181+
return {"purged": store.purge_embeddings(), "bytes": freed}
182+
183+
184+
def media_index_report() -> dict:
185+
"""Return ``{count, bytes}`` for the per-media index columns."""
186+
return store.media_index_report()
187+
188+
189+
def purge_media_index() -> dict:
190+
"""Reset the per-media index columns; return ``{purged, bytes}``."""
191+
freed = store.media_index_report()["bytes"]
192+
return {"purged": store.purge_media_index(), "bytes": freed}
193+
194+
195+
# -- rendered-crop cache ------------------------------------------------------
196+
197+
198+
def crops_cache_report() -> dict:
199+
"""Return ``{count, bytes}`` for the rendered-crop cache."""
200+
count, total = _tree_size(crops.get_crops_dir())
201+
return {"count": count, "bytes": total}
202+
203+
204+
def purge_crops_cache() -> dict:
205+
"""Empty the rendered-crop cache; return ``{purged, bytes}``.
206+
207+
Crops are virtual (a rectangle over the parent media), so the PNGs are
208+
re-materialized on demand the next time something reads them.
209+
"""
210+
removed, freed = _empty_tree(crops.get_crops_dir())
211+
return {"purged": removed, "bytes": freed}
212+
213+
214+
# -- watermark backups --------------------------------------------------------
215+
216+
217+
def wm_backup_report() -> dict:
218+
"""Return ``{count, bytes}`` for the pre-patch original backups."""
219+
count, total = _tree_size(WATERMARK_BACKUPS_DIR)
220+
return {"count": count, "bytes": total}
221+
222+
223+
def purge_wm_backups() -> dict:
224+
"""Empty the watermark backups; return ``{purged, bytes}``.
225+
226+
Loses the "restore original" option of already-patched media; the
227+
patched pixels themselves are untouched.
228+
"""
229+
removed, freed = _empty_tree(WATERMARK_BACKUPS_DIR)
230+
return {"purged": removed, "bytes": freed}
112231

113232

114233
# -- orphan patch / composite / crop cache files ----------------------------
@@ -201,20 +320,42 @@ def purge_thumbnail_cache() -> dict:
201320
_REPORTS = {
202321
"media": orphan_media_report,
203322
"captions": unused_caption_report,
323+
"dataset_captions": unlinked_caption_report,
324+
"claims": claims_report,
325+
"quality": quality_report,
326+
"embeddings": embeddings_report,
327+
"index": media_index_report,
328+
"crops": crops_cache_report,
204329
"patches": orphan_patch_report,
330+
"wm_backups": wm_backup_report,
205331
"thumbs": thumbnail_report,
206332
}
207333

208334
_PURGES = {
209335
"media": purge_orphan_media,
210336
"captions": purge_unused_captions,
337+
"dataset_captions": purge_unlinked_captions,
338+
"claims": purge_claims,
339+
"quality": purge_quality,
340+
"embeddings": purge_embeddings,
341+
"index": purge_media_index,
342+
"crops": purge_crops_cache,
211343
"patches": purge_orphan_patches,
344+
"wm_backups": purge_wm_backups,
212345
"thumbs": purge_thumbnail_cache,
213346
}
214347

215348
# Only the categories that delete database rows benefit from a VACUUM; the
216349
# file-cache sweeps free their space on unlink.
217-
_VACUUM_AFTER = {"media", "captions"}
350+
_VACUUM_AFTER = {
351+
"media",
352+
"captions",
353+
"dataset_captions",
354+
"claims",
355+
"quality",
356+
"embeddings",
357+
"index",
358+
}
218359

219360

220361
def vacuum() -> None:
@@ -228,6 +369,19 @@ def cleanup_report() -> dict:
228369
return {category: report() for category, report in _REPORTS.items()}
229370

230371

372+
def category_report(category: str) -> dict:
373+
"""Return one category's ``{count, bytes}`` (the per-row loader).
374+
375+
The System view fetches each category independently so a slow scan
376+
(patch orphans, big cache trees) never blocks the cheap ones. Raises
377+
``ValueError`` on an unknown category.
378+
"""
379+
report = _REPORTS.get(category)
380+
if report is None:
381+
raise ValueError(f"unknown cleanup category: {category!r}")
382+
return report()
383+
384+
231385
def run_cleanup(category: str) -> dict:
232386
"""Purge one :data:`CATEGORIES` category.
233387

src/sqlite_store/__init__.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,10 @@
101101
unused_caption_count,
102102
delete_unused_captions,
103103
unused_revision_count,
104+
unused_revision_bytes,
104105
prune_unused_revisions,
106+
unlinked_caption_report,
107+
purge_unlinked_captions,
105108
)
106109

107110
from src.sqlite_store.media import (
@@ -326,6 +329,17 @@
326329
tag_grounding_for_media,
327330
tag_groundings_bulk,
328331
delete_tag_grounding,
332+
grounding_history_report,
333+
purge_grounding_history,
334+
)
335+
336+
from src.sqlite_store.sweeps import (
337+
quality_scores_report,
338+
purge_quality_scores,
339+
embeddings_report,
340+
purge_embeddings,
341+
media_index_report,
342+
purge_media_index,
329343
)
330344

331345
__all__ = [
@@ -531,6 +545,17 @@
531545
"prune_sub_libraries",
532546
"prune_uncategorized_if_empty",
533547
"prune_unused_revisions",
548+
"unused_revision_bytes",
549+
"unlinked_caption_report",
550+
"purge_unlinked_captions",
551+
"grounding_history_report",
552+
"purge_grounding_history",
553+
"quality_scores_report",
554+
"purge_quality_scores",
555+
"embeddings_report",
556+
"purge_embeddings",
557+
"media_index_report",
558+
"purge_media_index",
534559
"purge_media",
535560
"read_caption",
536561
"reconcile_resolutions",

src/sqlite_store/captions.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,3 +485,57 @@ def prune_unused_revisions() -> int:
485485
f"WHERE {_UNUSED_REVISION_WHERE}"
486486
)
487487
return cursor.rowcount
488+
489+
490+
def unused_revision_bytes() -> int:
491+
"""Return the text weight (bytes) of the prunable caption revisions."""
492+
row = _query_one(
493+
"SELECT COALESCE(SUM(LENGTH(content)), 0) AS b "
494+
f"FROM caption_revision WHERE {_UNUSED_REVISION_WHERE}"
495+
)
496+
return row["b"] if row else 0
497+
498+
499+
# Captions belonging to a media that sits in no dataset: browsable nowhere
500+
# in a dataset context, so their whole history is prunable. The media row
501+
# itself stays (it belongs to a library).
502+
_UNLINKED_CAPTION_WHERE = (
503+
"media_id NOT IN (SELECT media_id FROM dataset_media)"
504+
)
505+
506+
507+
def unlinked_caption_report() -> dict:
508+
"""Return ``{count, bytes}`` for captions of media in no dataset.
509+
510+
``count`` is caption rows (media × type); ``bytes`` the text weight of
511+
their entire revision histories.
512+
"""
513+
row = _query_one(
514+
"SELECT COUNT(DISTINCT c.id) AS n, "
515+
"COALESCE(SUM(LENGTH(r.content)), 0) AS b FROM caption c "
516+
"LEFT JOIN caption_revision r ON r.caption_id = c.id "
517+
f"WHERE c.{_UNLINKED_CAPTION_WHERE}"
518+
)
519+
if row is None:
520+
return {"count": 0, "bytes": 0}
521+
return {"count": row["n"], "bytes": row["b"]}
522+
523+
524+
def purge_unlinked_captions() -> int:
525+
"""Delete the caption histories of media in no dataset; return count.
526+
527+
Ancestry links are cleared first (``parent_revision_id`` carries no
528+
cascade); the caption rows then cascade their revisions, whose review /
529+
grounding / score children cascade in turn.
530+
"""
531+
with closing(db.connect()) as conn:
532+
with conn:
533+
conn.execute(
534+
"UPDATE caption_revision SET parent_revision_id = NULL "
535+
"WHERE caption_id IN "
536+
f"(SELECT id FROM caption WHERE {_UNLINKED_CAPTION_WHERE})"
537+
)
538+
cursor = conn.execute(
539+
f"DELETE FROM caption WHERE {_UNLINKED_CAPTION_WHERE}"
540+
)
541+
return cursor.rowcount

src/sqlite_store/grounding.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,3 +256,28 @@ def delete_tag_grounding(media_id: int, tag_id: int) -> None:
256256
"DELETE FROM media_tag_grounding WHERE media_id = ? AND tag_id = ?",
257257
(media_id, tag_id),
258258
)
259+
260+
261+
def grounding_history_report() -> dict:
262+
"""Return ``{count, bytes}`` for the stored caption-claim history.
263+
264+
``count`` is the claim rows across every grounded revision; ``bytes``
265+
their text weight (the scores themselves are a few bytes each).
266+
"""
267+
row = _query_one(
268+
"SELECT COUNT(*) AS n, COALESCE(SUM(LENGTH(text)), 0) AS b "
269+
"FROM caption_grounding_claim"
270+
)
271+
if row is None:
272+
return {"count": 0, "bytes": 0}
273+
return {"count": row["n"], "bytes": row["b"]}
274+
275+
276+
def purge_grounding_history() -> int:
277+
"""Delete every caption grounding (claims cascade); return the claims."""
278+
row = _query_one("SELECT COUNT(*) AS n FROM caption_grounding_claim")
279+
claims = row["n"] if row else 0
280+
with closing(db.connect()) as conn:
281+
with conn:
282+
conn.execute("DELETE FROM caption_grounding")
283+
return claims

0 commit comments

Comments
 (0)