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.
This commit is contained in:
Vito Sansevero
2026-10-22 05:48:59 -07:00
parent 43e70514d1
commit af26c2fd49
5 changed files with 139 additions and 9 deletions
+14 -7
View File
@@ -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 {
+26 -1
View File
@@ -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()
+2
View File
@@ -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}
+24 -1
View File
@@ -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()
+73
View File
@@ -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")