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:
+43
-30
@@ -1178,38 +1178,39 @@ pub async fn get_tag_cloud(
|
||||
db: State<'_, DbState>,
|
||||
folder_id: Option<i64>,
|
||||
) -> 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 {
|
||||
Some(id) => format!("folder_{id}"),
|
||||
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())?;
|
||||
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 n = embeddings_with_ids.len();
|
||||
let points: Vec<Vec<f32>> = embeddings_with_ids
|
||||
.into_iter()
|
||||
.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) {
|
||||
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)
|
||||
|
||||
@@ -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.
|
||||
/// 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(
|
||||
conn: &Connection,
|
||||
folder_id: Option<i64>,
|
||||
|
||||
Reference in New Issue
Block a user