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:
@@ -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 / A–Z / Z–A). The
|
filter input and a sort dropdown (most-used / least-used / A–Z / Z–A). 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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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 },
|
||||||
|
|||||||
Reference in New Issue
Block a user