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:
@@ -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