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:
+14
-7
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user