From af26c2fd49dcbc2e7e594372bdaf73c51d040d3d Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Thu, 22 Oct 2026 05:48:59 -0700 Subject: [PATCH] fix(annotations): move image revisions when a fingerprint or the vocabulary changes A replayed save that observes a new verified (size, mtime_ns) now bumps image_index_revision, like refresh_image_fingerprint, so caches keyed on it see the record become current again; an identical replay still moves nothing. Vocabulary rename, merge and delete (native and legacy paths) bump the tag_revision of every image set carrying the source tag, in the same transaction and before the links move or cascade, plus one image_index_revision bump when any set was affected. A Tag dialog opened before the change therefore gets tag-conflict with the renamed set as current instead of recreating the old name. --- database/annotations.py | 21 ++++++--- database/operations.py | 27 ++++++++++- database/vocabulary.py | 2 + tests/test_annotations.py | 25 +++++++++- tests/test_pair_tag_maintenance.py | 73 ++++++++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 9 deletions(-) diff --git a/database/annotations.py b/database/annotations.py index 0fb68828..0a39be2c 100644 --- a/database/annotations.py +++ b/database/annotations.py @@ -49,7 +49,7 @@ _INSERT_OWNER = ( f"INSERT INTO {FILES_TABLE}(id,locator,created_at) VALUES(?,?,?)" # nosec B608 ) _BYTE = ( - f"SELECT id,tag_revision FROM {BYTES_TABLE} " # nosec B608 + f"SELECT id,tag_revision,size,mtime_ns FROM {BYTES_TABLE} " # nosec B608 "WHERE file_id=? AND sha256=?" ) _INSERT_BYTE = ( @@ -229,10 +229,13 @@ class AnnotationOperations: size: int, mtime_ns: int, now: str, - ) -> tuple[str, int, bool]: + ) -> tuple[str, int, bool, bool]: """The byte record for these bytes, refreshed or created. - Returns ``(id, tag_revision, created)``. + Returns ``(id, tag_revision, created, refingerprinted)``; the last is + True when an existing record's verified ``(size, mtime_ns)`` changed, + which moves its currency for cheap reads (the caller bumps the index + revision when nothing else does). """ row = conn.execute(_BYTE, (file_id, sha256)).fetchone() if row is None: @@ -240,9 +243,9 @@ class AnnotationOperations: conn.execute( _INSERT_BYTE, (byte_id, file_id, sha256, size, mtime_ns, now, now) ) - return byte_id, 0, True + return byte_id, 0, True, False conn.execute(_REFRESH_BYTE, (size, mtime_ns, now, row[0])) - return row[0], row[1], False + return row[0], row[1], False, (row[2], row[3]) != (size, mtime_ns) @staticmethod def _byte_tags(conn: sqlite3.Connection, byte_id: str) -> list[str]: @@ -311,7 +314,7 @@ class AnnotationOperations: epoch = self._library_epoch(conn, expected_epoch) now = utc_now_iso() file_id = self._annotation_owner(conn, locator, now) - byte_id, revision, created = self._annotation_byte( + byte_id, revision, created, refingerprinted = self._annotation_byte( conn, file_id, sha256, size, mtime_ns, now ) current = self._byte_tags(conn, byte_id) @@ -320,6 +323,8 @@ class AnnotationOperations: and current == tags and expected_tag_revision in (revision, revision - 1) ): + if refingerprinted: + conn.execute(_BUMP_INDEX) return _receipt(epoch, sha256, current, revision, True) if expected_tag_revision != revision: raise AnnotationCommandError( @@ -353,7 +358,7 @@ class AnnotationOperations: epoch = self._library_epoch(conn, expected_epoch) now = utc_now_iso() file_id = self._annotation_owner(conn, locator, now) - byte_id, revision, created = self._annotation_byte( + byte_id, revision, created, refingerprinted = self._annotation_byte( conn, file_id, sha256, size, mtime_ns, now ) current = self._byte_tags(conn, byte_id) @@ -367,6 +372,8 @@ class AnnotationOperations: {"sha256": sha256, "tags": current, "tag_revision": revision}, ) if not created and not added: + if refingerprinted: + conn.execute(_BUMP_INDEX) return {**_receipt(epoch, sha256, current, revision, True), "added": 0} revision = self._write_image_tags(conn, byte_id, merged) return { diff --git a/database/operations.py b/database/operations.py index dbbcbe90..8fa24dd8 100644 --- a/database/operations.py +++ b/database/operations.py @@ -1086,15 +1086,38 @@ class PromptDatabase( } @staticmethod + def _bump_image_tag_bytes(conn: sqlite3.Connection, tag_id: int) -> int: + """Move the tag revision of every image set that carries *tag_id*. + + Vocabulary rename, merge and delete change what an image's set reads + as, so a Tag dialog opened before the change must see ``tag-conflict`` + on Save instead of recreating the old name. Bumps the image index + revision once when any set was affected; returns that count. + """ + cursor = conn.execute( + "UPDATE media_annotation_bytes SET tag_revision = tag_revision + 1 " + "WHERE id IN (SELECT byte_id FROM media_image_tags WHERE tag_id = ?)", + (tag_id,), + ) + if cursor.rowcount > 0: + conn.execute( + "UPDATE media_annotation_metadata SET " + "image_index_revision = image_index_revision + 1 WHERE singleton_id = 1" + ) + return cursor.rowcount + + @classmethod def _move_tag_links( - conn: sqlite3.Connection, source_id: int, target_id: int + cls, conn: sqlite3.Connection, source_id: int, target_id: int ) -> None: """Repoint component, pair and image links from source to target. Owners that already carry the target keep that one link; the source's leftover duplicates are removed (an image set keeps the target's position). The source registry row is left for the caller to delete. + Image sets that carried the source get a new tag revision first. """ + cls._bump_image_tag_bytes(conn, source_id) for junction in ("prompt_tags", "prompt_pair_tags", "media_image_tags"): conn.execute( f"UPDATE OR IGNORE {junction} SET tag_id = ? WHERE tag_id = ?", @@ -1148,6 +1171,7 @@ class PromptDatabase( conn.execute("DELETE FROM tags WHERE id = ?", (old_tag_id,)) else: # Simple rename + self._bump_image_tag_bytes(conn, old_tag_id) conn.execute( "UPDATE tags SET name = ? WHERE id = ?", (new_name, old_tag_id) ) @@ -1189,6 +1213,7 @@ class PromptDatabase( affected = cursor.fetchone()["c"] # CASCADE delete handles prompt_tags entries + self._bump_image_tag_bytes(conn, tag_id) conn.execute("DELETE FROM tags WHERE id = ?", (tag_id,)) conn.commit() diff --git a/database/vocabulary.py b/database/vocabulary.py index 74dae568..5bca3fb3 100644 --- a/database/vocabulary.py +++ b/database/vocabulary.py @@ -274,6 +274,7 @@ class VocabularyOperations: "SELECT id FROM tags WHERE name=?", (new_name,) ).fetchone() if target is None: + self._bump_image_tag_bytes(conn, tag_id) conn.execute("UPDATE tags SET name=? WHERE id=?", (new_name, tag_id)) result_id = tag_id else: @@ -299,6 +300,7 @@ class VocabularyOperations: epoch = self._library_epoch(conn, expected_epoch) self._vocabulary_identity(conn, epoch, tag_id, name) counts = self._tag_owner_counts(conn, tag_id) + self._bump_image_tag_bytes(conn, tag_id) conn.execute("DELETE FROM tags WHERE id=?", (tag_id,)) return {"library_epoch": epoch, "deleted": True, **counts} diff --git a/tests/test_annotations.py b/tests/test_annotations.py index 048c0d34..a5aa6002 100644 --- a/tests/test_annotations.py +++ b/tests/test_annotations.py @@ -92,6 +92,9 @@ class AnnotationTestCase(unittest.TestCase): options.update(over) return self.db.add_image_tags(**options) + def read(self, loc, digest=DIGEST_A): + return self.db.read_image_tags(loc, digest) + def rows(self, *tables): """Every row of each table; ``verified_at`` is a fingerprint, not content.""" conn = self.db.model.get_connection() @@ -312,6 +315,22 @@ class ReplaceImageTagsTests(AnnotationTestCase): ) self.assertEqual(self.index_revision(), 2) + def test_replay_with_new_fingerprint_moves_only_the_index_revision(self): + loc = self.locator("a.png") + first = self.replace(loc, tags=["a"]) + index = self.index_revision() + same = self.replace(loc, tags=["a"], revision=1) + self.assertEqual(same, {**first, "replayed": True}) + self.assertEqual(self.index_revision(), index) # identical: nothing moved + touched = self.replace(loc, tags=["a"], revision=1, mtime_ns=MTIME + 5) + self.assertEqual(touched, {**first, "replayed": True}) + self.assertEqual(self.index_revision(), index + 1) # currency changed + self.assertEqual( + self.db.current_image_tags({loc: (SIZE, MTIME + 5)}), {loc: ["a"]} + ) + self.assertEqual(self.db.current_image_tags({loc: (SIZE, MTIME)}), {}) + self.assertEqual(self.read(loc)["tag_revision"], 1) + def test_epoch_checked_inside_transaction(self): loc = self.locator("a.png") seen = [] @@ -549,7 +568,11 @@ class AnnotationIsolationTests(AnnotationTestCase): self.db.delete_tag_all_prompts("gone") self.assertEqual( self.db.read_image_tags(loc, DIGEST_A), - {"tags": ["kept"], "tag_revision": 1, "prior_count": 0}, + { + "tags": ["kept"], + "tag_revision": 2, + "prior_count": 0, + }, # moved by the delete ) self.schema_clean() diff --git a/tests/test_pair_tag_maintenance.py b/tests/test_pair_tag_maintenance.py index ff5cc092..64020132 100644 --- a/tests/test_pair_tag_maintenance.py +++ b/tests/test_pair_tag_maintenance.py @@ -11,6 +11,7 @@ try: except ImportError: # discovered with tests/ as the top-level directory from open_handles import assert_closed +from database.library_commands import LibraryCommandError from database.operations import PromptDatabase @@ -235,6 +236,78 @@ class TestPairTagMaintenance(unittest.TestCase): conn.execute("SELECT COUNT(*) FROM media_annotation_bytes").fetchone()[0], 1 ) + def index_revision(self) -> int: + return ( + self.db.model.get_connection() + .execute("SELECT image_index_revision FROM media_annotation_metadata") + .fetchone()[0] + ) + + def revision(self, locator: str, digest: str = "a") -> int: + return self.db.read_image_tags( + locator, hashlib.sha256(digest.encode()).hexdigest() + )["tag_revision"] + + def tag_id(self, name: str) -> int: + conn = self.db.model.get_connection() + return conn.execute("SELECT id FROM tags WHERE name=?", (name,)).fetchone()[0] + + def test_vocabulary_maintenance_bumps_image_revisions_so_stale_saves_conflict( + self, + ): + forest = self.image("a.png", ["forest"]) + misty = self.image("b.png", ["mist"]) + self.pair("portrait", ["unlinked"]) + index = self.index_revision() + epoch = self.db.get_execution_epoch() + + # A rename of a tag no image carries moves nothing image-side. + self.db.rename_vocabulary_tag( + self.tag_id("unlinked"), expected_epoch=epoch, name="unlinked", new_name="u" + ) + self.assertEqual(self.index_revision(), index) + + self.db.rename_vocabulary_tag( + self.tag_id("forest"), expected_epoch=epoch, name="forest", new_name="woods" + ) + self.assertEqual(self.image_tags(forest), ["woods"]) + self.assertEqual((self.revision(forest), self.revision(misty)), (2, 1)) + self.assertEqual(self.index_revision(), index + 1) + # The Tag dialog still open at revision 1 cannot recreate "forest". + with self.assertRaises(LibraryCommandError) as caught: + self.db.replace_image_tags( + locator=forest, + sha256=hashlib.sha256(b"a").hexdigest(), + size=1, + mtime_ns=1, + expected_epoch=epoch, + expected_tag_revision=1, + tags=["forest", "mist"], + ) + self.assertEqual(caught.exception.code, "tag-conflict") + self.assertEqual(caught.exception.current["tags"], ["woods"]) + self.assertEqual(caught.exception.current["tag_revision"], 2) + self.assertNotIn("forest", self.db.get_all_tags()) + + self.db.rename_tag_all_prompts("woods", "trees") # legacy rename + self.assertEqual((self.revision(forest), self.revision(misty)), (3, 1)) + self.db.merge_tags(["trees"], "mist") # legacy merge: only the source side + self.assertEqual(self.image_tags(forest), ["mist"]) + self.assertEqual((self.revision(forest), self.revision(misty)), (4, 1)) + self.db.rename_tag_all_prompts("mist", "fog") # both carry it now + self.assertEqual((self.revision(forest), self.revision(misty)), (5, 2)) + self.db.delete_vocabulary_tag( + self.tag_id("fog"), expected_epoch=epoch, name="fog" + ) + self.assertEqual((self.revision(forest), self.revision(misty)), (6, 3)) + self.assertEqual((self.image_tags(forest), self.image_tags(misty)), ([], [])) + self.image("c.png", ["gone"]) + self.db.delete_tag_all_prompts("gone") # legacy delete + self.assertEqual(self.revision(forest), 6) + self.assertEqual( + self.index_revision(), index + 7 + ) # 6 maintenance ops + the c.png save + def test_image_only_and_prior_only_names_participate(self): only = self.image("only.png", ["image-only"]) prior = self.image("prior.png", ["prior-only"], digest="old")