perf(explore): instant tag-cloud cache hits + fix stale-on-switch loading

The get_tag_cloud cache key was built by loading and hashing every embedding
blob for the scope *before* checking the cache, so even a cache hit re-read
hundreds of MB on large libraries and stalled Explore for several seconds.
Validate the cache from a lightweight image-ID-set signature plus the embedding
revision instead, so a hit never loads embeddings. The ID-set hash keeps the key
membership-sensitive (add/remove/move between folders) and the revision covers
an image being re-embedded in place. Cache write failures are now logged rather
than silently ignored.

On the frontend, switching folders (or re-entering Explore) no longer leaves the
previous folder's clusters/tags on screen with no loading indicator:
loadTagCloud/loadExploreTags clear stale entries on a real folder switch. The
displayed folder is tracked separately (exploreTagsShownFolderId) from the
cache-dirty marker so a same-folder invalidation (tag edits, new AI tags) does
not masquerade as a switch and wipe the visible list mid-refresh.
This commit is contained in:
2026-06-29 20:25:38 +01:00
parent 23e9850c7a
commit ab7022e118
4 changed files with 120 additions and 32 deletions
+8
View File
@@ -59,6 +59,10 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
### Changed ### Changed
- **Faster Explore on large libraries** — revisiting a folder's visual clusters
is now near-instant. The cluster cache is validated from a lightweight
image-ID signature instead of re-reading every embedding, so big libraries no
longer stall for several seconds even when the cached result is reused.
- **Tag manager search and sort** — the Manage mode tag list now has a live - **Tag manager search and sort** — the Manage mode tag list now has a live
filter input and a sort dropdown (most-used / least-used / AZ / ZA). The filter input and a sort dropdown (most-used / least-used / AZ / ZA). The
list is virtualised so libraries with thousands of tags scroll without lag, list is virtualised so libraries with thousands of tags scroll without lag,
@@ -86,6 +90,10 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
### Fixed ### Fixed
- **Stale Explore results on folder switch** — switching folders (or re-entering
Explore) no longer briefly shows the previous folder's clusters/tags with no
loading indicator; the view now clears and shows a loading state until the new
folder's data arrives.
- **Rating no longer scrambles search results** — rating or favoriting an image - **Rating no longer scrambles search results** — rating or favoriting an image
while viewing similar-image, region, semantic, tag, or album results no longer while viewing similar-image, region, semantic, tag, or album results no longer
re-sorts the view back into the default order; the current result ordering is re-sorts the view back into the default order; the current result ordering is
+43 -30
View File
@@ -1178,38 +1178,39 @@ pub async fn get_tag_cloud(
db: State<'_, DbState>, db: State<'_, DbState>,
folder_id: Option<i64>, folder_id: Option<i64>,
) -> Result<Vec<TagCloudEntry>, String> { ) -> Result<Vec<TagCloudEntry>, String> {
let embeddings_with_ids = {
let conn = db.get().map_err(|e| e.to_string())?;
vector::get_all_image_embeddings_with_ids(&conn, folder_id).map_err(|e| e.to_string())?
};
let n = embeddings_with_ids.len();
if n < 5 {
return Ok(vec![]);
}
// Sort by ID for stable ordering; hash both IDs and embedding bytes so that
// replacing a file and regenerating its embedding invalidates the cache even
// when the set of image IDs hasn't changed.
let mut sorted_pairs: Vec<_> = embeddings_with_ids.iter().collect();
sorted_pairs.sort_unstable_by_key(|(id, _)| *id);
let current_hash = {
use xxhash_rust::xxh3::Xxh3;
let mut hasher = Xxh3::new();
for (id, embedding) in &sorted_pairs {
hasher.update(&id.to_le_bytes());
for val in embedding.iter() {
hasher.update(&val.to_le_bytes());
}
}
hasher.digest()
};
let folder_scope = match folder_id { let folder_scope = match folder_id {
Some(id) => format!("folder_{id}"), Some(id) => format!("folder_{id}"),
None => "all".to_string(), None => "all".to_string(),
}; };
// Try to return a valid SQLite cache // Build a cheap cache key from a hash of the embedded image-ID set plus the
// global monotonic embedding revision. The ID-set hash catches membership
// changes (add/remove, or moving an image between folders — even when the
// count is unchanged); the revision catches an image being re-embedded in
// place (same IDs, new vector). Together they validate the cache WITHOUT
// reading and unpacking every embedding blob — which for a large library is
// hundreds of MB and was the real cause of the multi-second Explore stall
// even on a cache hit.
let (count, current_hash) = {
let conn = db.get().map_err(|e| e.to_string())?;
let (count, ids_hash) =
vector::embedding_ids_signature(&conn, folder_id).map_err(|e| e.to_string())?;
let revision = vector::get_embedding_revision(&conn).map_err(|e| e.to_string())?;
let hash = {
use xxhash_rust::xxh3::Xxh3;
let mut hasher = Xxh3::new();
hasher.update(&ids_hash.to_le_bytes());
hasher.update(revision.as_bytes());
hasher.digest()
};
(count, hash)
};
if count < 5 {
return Ok(vec![]);
}
// Try to return a valid SQLite cache before loading any embeddings.
{ {
let conn = db.get().map_err(|e| e.to_string())?; let conn = db.get().map_err(|e| e.to_string())?;
if let Some(json) = db::get_tag_cloud_cache(&conn, &folder_scope, current_hash) if let Some(json) = db::get_tag_cloud_cache(&conn, &folder_scope, current_hash)
@@ -1225,8 +1226,17 @@ pub async fn get_tag_cloud(
} }
} }
// Cache miss — run k-means // Cache miss — now pay the cost of loading embeddings and running k-means.
let embeddings_with_ids = {
let conn = db.get().map_err(|e| e.to_string())?;
vector::get_all_image_embeddings_with_ids(&conn, folder_id).map_err(|e| e.to_string())?
};
if embeddings_with_ids.len() < 5 {
return Ok(vec![]);
}
let ids: Vec<i64> = embeddings_with_ids.iter().map(|(id, _)| *id).collect(); let ids: Vec<i64> = embeddings_with_ids.iter().map(|(id, _)| *id).collect();
let n = embeddings_with_ids.len();
let points: Vec<Vec<f32>> = embeddings_with_ids let points: Vec<Vec<f32>> = embeddings_with_ids
.into_iter() .into_iter()
.map(|(_, emb)| emb) .map(|(_, emb)| emb)
@@ -1276,9 +1286,12 @@ pub async fn get_tag_cloud(
}); });
} }
// Persist to SQLite — ignore write errors (cache is best-effort) // Persist to SQLite. The cache is best-effort, but a silent write failure here
// would defeat the whole optimization (every visit would recompute), so log it.
if let Ok(json) = serde_json::to_string(&entries) { if let Ok(json) = serde_json::to_string(&entries) {
let _ = db::set_tag_cloud_cache(&conn, &folder_scope, current_hash, &json); if let Err(e) = db::set_tag_cloud_cache(&conn, &folder_scope, current_hash, &json) {
eprintln!("failed to persist tag-cloud cache for {folder_scope}: {e}");
}
} }
Ok(entries) Ok(entries)
+38
View File
@@ -263,6 +263,44 @@ pub fn get_embedding_revision(conn: &Connection) -> Result<String> {
/// Returns all stored image embeddings with their image IDs, optionally filtered to one folder. /// Returns all stored image embeddings with their image IDs, optionally filtered to one folder.
/// Each entry is `(image_id, normalized_f32_embedding)`. /// Each entry is `(image_id, normalized_f32_embedding)`.
/// Returns `(count, hash)` over the stored embedding image IDs for the scope in a
/// single ordered pass, without loading any embedding blobs. The hash covers the
/// exact set of IDs, so it is membership-sensitive: adding, removing, or moving an
/// image between folders changes it even when the count happens to stay the same.
/// Used (together with the embedding revision, which catches an image being
/// re-embedded in place) as the cheap tag-cloud cache key so a cache hit doesn't
/// have to read and unpack hundreds of MB of embeddings just to validate freshness.
pub fn embedding_ids_signature(conn: &Connection, folder_id: Option<i64>) -> Result<(i64, u64)> {
use xxhash_rust::xxh3::Xxh3;
let mut hasher = Xxh3::new();
let mut count: i64 = 0;
let mut hash_row = |id: i64| {
hasher.update(&id.to_le_bytes());
count += 1;
};
match folder_id {
Some(fid) => {
let mut stmt = conn.prepare(
"SELECT image_id FROM image_vec
WHERE image_id IN (SELECT id FROM images WHERE folder_id = ?1)
ORDER BY image_id",
)?;
let mut rows = stmt.query([fid])?;
while let Some(row) = rows.next()? {
hash_row(row.get(0)?);
}
}
None => {
let mut stmt = conn.prepare("SELECT image_id FROM image_vec ORDER BY image_id")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
hash_row(row.get(0)?);
}
}
}
Ok((count, hasher.digest()))
}
pub fn get_all_image_embeddings_with_ids( pub fn get_all_image_embeddings_with_ids(
conn: &Connection, conn: &Connection,
folder_id: Option<i64>, folder_id: Option<i64>,
+31 -2
View File
@@ -381,7 +381,14 @@ interface GalleryState {
exploreTagEntries: ExploreTagEntry[]; exploreTagEntries: ExploreTagEntry[];
exploreTagLoading: boolean; exploreTagLoading: boolean;
exploreTagsFolderId: number | null | undefined; exploreTagsFolderId: number | null | undefined;
// Cache-freshness key: the folder the loaded tags belong to. Set to undefined
// by content mutations (tag add/remove/rename/delete) to mark the cache dirty
// and force the next load to refetch.
relatedTagsByKey: Record<string, RelatedTagEntry[]>; relatedTagsByKey: Record<string, RelatedTagEntry[]>;
// The folder whose tags are actually on screen. Kept separate from the dirty
// marker above so a same-folder invalidation isn't mistaken for a folder switch
// (which would wipe the visible list and remount manager UI mid-refresh).
exploreTagsShownFolderId: number | null | undefined;
indexingProgress: Record<number, IndexProgress>; indexingProgress: Record<number, IndexProgress>;
mediaJobProgress: Record<number, FolderJobProgress>; mediaJobProgress: Record<number, FolderJobProgress>;
cacheDir: string; cacheDir: string;
@@ -877,6 +884,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
indexingProgress: {}, indexingProgress: {},
mediaJobProgress: {}, mediaJobProgress: {},
cacheDir: "", cacheDir: "",
exploreTagsShownFolderId: undefined,
captionModelStatus: null, captionModelStatus: null,
captionModelPreparing: false, captionModelPreparing: false,
captionModelError: null, captionModelError: null,
@@ -1404,7 +1412,15 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
return; return;
} }
const requestToken = ++tagCloudRequestToken; const requestToken = ++tagCloudRequestToken;
set({ tagCloudLoading: true, tagCloudFolderId: selectedFolderId }); // On a real folder switch, drop the previous folder's clusters so the loading
// panel shows instead of lingering stale results. A same-folder refresh keeps
// them to avoid a flicker when the cache returns instantly.
const isFolderSwitch = tagCloudFolderId !== selectedFolderId;
set({
tagCloudLoading: true,
tagCloudFolderId: selectedFolderId,
...(isFolderSwitch ? { tagCloudEntries: [] } : {}),
});
try { try {
const entries = await invoke<TagCloudEntry[]>("get_tag_cloud", { const entries = await invoke<TagCloudEntry[]>("get_tag_cloud", {
folderId: selectedFolderId, folderId: selectedFolderId,
@@ -1425,7 +1441,20 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
return; return;
} }
const requestToken = ++exploreTagRequestToken; const requestToken = ++exploreTagRequestToken;
set({ exploreTagLoading: true, exploreTagsFolderId: selectedFolderId }); // A real folder switch is decided by what's currently *shown*, not by the
// dirty marker — a same-folder invalidation nulls exploreTagsFolderId but
// leaves exploreTagsShownFolderId pointing at the displayed folder, so the
// visible list (and manager UI state) survives the refresh. On an actual
// switch, drop the previous folder's tags so the loading panel shows.
const { exploreTagsShownFolderId } = get();
const isFolderSwitch =
exploreTagsShownFolderId !== undefined && exploreTagsShownFolderId !== selectedFolderId;
set({
exploreTagLoading: true,
exploreTagsFolderId: selectedFolderId,
exploreTagsShownFolderId: selectedFolderId,
...(isFolderSwitch ? { exploreTagEntries: [], relatedTagsByKey: {} } : {}),
});
try { try {
const entries = await invoke<ExploreTagEntry[]>("get_explore_tags", { const entries = await invoke<ExploreTagEntry[]>("get_explore_tags", {
params: { folder_id: selectedFolderId, limit: 180 }, params: { folder_id: selectedFolderId, limit: 180 },