perf(explore): reduce tag cloud refresh pressure
github/actions/ci GitHub Actions CI finished: success

Debounce Explore tag refreshes while AI tagging is active so the tag cloud catches up after worker activity settles instead of continuously re-querying.

Optimize the tag cloud aggregate query by fetching representative thumbnails in one pass and adding a supporting tag/image index.
This commit is contained in:
2026-07-03 19:32:37 +01:00
parent fe65bc6f38
commit 3ab9357d6f
3 changed files with 85 additions and 26 deletions
+3
View File
@@ -63,6 +63,9 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
so the info panel shows more at a glance with less scrolling. so the info panel shows more at a glance with less scrolling.
- **Faster Explore revisits** — returning to a folder's visual clusters should - **Faster Explore revisits** — returning to a folder's visual clusters should
feel much faster now, even in big libraries. feel much faster now, even in big libraries.
- **Calmer Tag Cloud during AI tagging** — Explore no longer keeps hammering the
tag list while a folder is actively being tagged, so tagging stays smoother and
the cloud catches up once the work settles.
- **Faster first-time clustering** — large libraries build their first visual - **Faster first-time clustering** — large libraries build their first visual
clusters much more quickly, while still keeping the groups nicely balanced. clusters much more quickly, while still keeping the groups nicely balanced.
- **Better tag browsing** — the Tag manager now has live search, sorting - **Better tag browsing** — the Tag manager now has live search, sorting
+27 -16
View File
@@ -303,6 +303,7 @@ pub fn migrate(conn: &Connection) -> Result<()> {
CREATE INDEX IF NOT EXISTS idx_image_tags_image_id ON image_tags(image_id); CREATE INDEX IF NOT EXISTS idx_image_tags_image_id ON image_tags(image_id);
CREATE INDEX IF NOT EXISTS idx_image_tags_source ON image_tags(source); CREATE INDEX IF NOT EXISTS idx_image_tags_source ON image_tags(source);
CREATE INDEX IF NOT EXISTS idx_image_tags_tag ON image_tags(tag); CREATE INDEX IF NOT EXISTS idx_image_tags_tag ON image_tags(tag);
CREATE INDEX IF NOT EXISTS idx_image_tags_tag_image_id ON image_tags(tag, image_id);
CREATE TABLE IF NOT EXISTS albums ( CREATE TABLE IF NOT EXISTS albums (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -2446,32 +2447,42 @@ pub fn get_explore_tags(
limit: usize, limit: usize,
) -> Result<Vec<ExploreTagEntry>> { ) -> Result<Vec<ExploreTagEntry>> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id, "WITH tag_counts AS (
MAX(CASE WHEN t.source = 'ai' THEN 1 ELSE 0 END) AS has_ai_source, SELECT t.tag,
MAX(CASE WHEN t.source = 'user' THEN 1 ELSE 0 END) AS has_user_source COUNT(DISTINCT t.image_id) AS tag_count,
FROM image_tags t MIN(t.image_id) AS representative_image_id,
JOIN images i ON i.id = t.image_id MAX(CASE WHEN t.source = 'ai' THEN 1 ELSE 0 END) AS has_ai_source,
WHERE (?1 IS NULL OR i.folder_id = ?1) MAX(CASE WHEN t.source = 'user' THEN 1 ELSE 0 END) AS has_user_source
GROUP BY t.tag FROM image_tags t
HAVING COUNT(DISTINCT t.image_id) >= 1 JOIN images i ON i.id = t.image_id
ORDER BY tag_count DESC, t.tag ASC WHERE (?1 IS NULL OR i.folder_id = ?1)
LIMIT ?2", GROUP BY t.tag
HAVING COUNT(DISTINCT t.image_id) >= 1
ORDER BY tag_count DESC, t.tag ASC
LIMIT ?2
)
SELECT c.tag,
c.tag_count,
c.representative_image_id,
i.thumbnail_path,
c.has_ai_source,
c.has_user_source
FROM tag_counts c
JOIN images i ON i.id = c.representative_image_id
ORDER BY c.tag_count DESC, c.tag ASC",
)?; )?;
let rows = stmt let rows = stmt
.query_map(params![folder_id, limit as i64], |row| { .query_map(params![folder_id, limit as i64], |row| {
let representative_image_id = row.get::<_, i64>(2)?; let representative_image_id = row.get::<_, i64>(2)?;
let thumbnail_path = get_image_by_id(conn, representative_image_id)
.ok()
.and_then(|image| image.thumbnail_path);
Ok(ExploreTagEntry { Ok(ExploreTagEntry {
tag: row.get(0)?, tag: row.get(0)?,
count: row.get(1)?, count: row.get(1)?,
representative_image_id, representative_image_id,
thumbnail_path, thumbnail_path: row.get(3)?,
has_ai_source: row.get::<_, i64>(3)? != 0, has_ai_source: row.get::<_, i64>(4)? != 0,
has_user_source: row.get::<_, i64>(4)? != 0, has_user_source: row.get::<_, i64>(5)? != 0,
}) })
})? })?
.collect::<rusqlite::Result<Vec<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
+55 -10
View File
@@ -651,6 +651,34 @@ let galleryRequestToken = 0;
let visualClusterRequestToken = 0; let visualClusterRequestToken = 0;
let exploreTagRequestToken = 0; let exploreTagRequestToken = 0;
let exploreTagRefreshTimer: ReturnType<typeof setTimeout> | null = null; let exploreTagRefreshTimer: ReturnType<typeof setTimeout> | null = null;
const EXPLORE_TAG_REFRESH_IDLE_MS = 900;
const EXPLORE_TAG_REFRESH_WHILE_TAGGING_MS = 4500;
function scopeHasTaggingPending(
progressByFolder: Record<number, FolderJobProgress>,
folderId: number | null,
): boolean {
if (folderId === null) {
return Object.values(progressByFolder).some((progress) => progress.tagging_pending > 0);
}
return (progressByFolder[folderId]?.tagging_pending ?? 0) > 0;
}
function taggingProgressAffectsScope(progressFolderId: number, scopeFolderId: number | null): boolean {
return scopeFolderId === null || scopeFolderId === progressFolderId;
}
function imagesAffectScope(images: ImageRecord[], scopeFolderId: number | null): boolean {
return scopeFolderId === null || images.some((image) => image.folder_id === scopeFolderId);
}
function scheduleExploreTagRefresh(load: () => void, delayMs: number) {
if (exploreTagRefreshTimer) clearTimeout(exploreTagRefreshTimer);
exploreTagRefreshTimer = setTimeout(() => {
exploreTagRefreshTimer = null;
load();
}, delayMs);
}
function initialAiCaptionsEnabled(): boolean { function initialAiCaptionsEnabled(): boolean {
if (typeof window === "undefined") return false; if (typeof window === "undefined") return false;
@@ -1498,6 +1526,9 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
loadExploreTags: async (options) => { loadExploreTags: async (options) => {
const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get(); const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get();
const force = options?.force ?? false; const force = options?.force ?? false;
if (!force && exploreTagLoading) {
return;
}
if (!force && !exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) { if (!force && !exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) {
return; return;
} }
@@ -2984,9 +3015,18 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
notificationTimers.set(taggingKey, setTimeout(() => { notificationTimers.set(taggingKey, setTimeout(() => {
notificationTimers.delete(taggingKey); notificationTimers.delete(taggingKey);
void notifyTaskComplete("AI tagging complete", body); void notifyTaskComplete("AI tagging complete", body);
// New tags landed — invalidate Explore tag cache.
set({ exploreTagsFolderId: undefined });
}, NOTIFICATION_DEBOUNCE_MS)); }, NOTIFICATION_DEBOUNCE_MS));
const state = get();
if (taggingProgressAffectsScope(progress.folder_id, state.selectedFolderId)) {
// New tags landed — refresh the Explore tag cloud after the worker
// settles instead of waiting for the user to revisit Explore.
set({ exploreTagsFolderId: undefined });
if (state.activeView === "explore" && state.exploreMode === "tags") {
scheduleExploreTagRefresh(() => {
void get().loadExploreTags({ force: true });
}, EXPLORE_TAG_REFRESH_IDLE_MS);
}
}
} else if (previous.tagging_pending === 0 && progress.tagging_pending > 0) { } else if (previous.tagging_pending === 0 && progress.tagging_pending > 0) {
clearTimeout(notificationTimers.get(taggingKey)); clearTimeout(notificationTimers.get(taggingKey));
notificationTimers.delete(taggingKey); notificationTimers.delete(taggingKey);
@@ -3067,15 +3107,16 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const unlistenThumbnails = await listen<ThumbnailBatch>("media-updated", (event) => { const unlistenThumbnails = await listen<ThumbnailBatch>("media-updated", (event) => {
const batch = event.payload; const batch = event.payload;
const taggingUpdated = batch.images.some((image) => image.ai_tagged_at !== null || image.ai_tagger_error !== null); const taggingUpdated = batch.images.some((image) => image.ai_tagged_at !== null || image.ai_tagger_error !== null);
if (taggingUpdated) { const state = get();
if (taggingUpdated && imagesAffectScope(batch.images, state.selectedFolderId)) {
set({ exploreTagsFolderId: undefined }); set({ exploreTagsFolderId: undefined });
if (get().activeView === "explore") { if (state.activeView === "explore" && state.exploreMode === "tags") {
if (!exploreTagRefreshTimer) { const delay = scopeHasTaggingPending(state.mediaJobProgress, state.selectedFolderId)
exploreTagRefreshTimer = setTimeout(() => { ? EXPLORE_TAG_REFRESH_WHILE_TAGGING_MS
exploreTagRefreshTimer = null; : EXPLORE_TAG_REFRESH_IDLE_MS;
void get().loadExploreTags({ force: true }); scheduleExploreTagRefresh(() => {
}, 700); void get().loadExploreTags({ force: true });
} }, delay);
} }
} }
@@ -3179,6 +3220,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
); );
return () => { return () => {
if (exploreTagRefreshTimer) {
clearTimeout(exploreTagRefreshTimer);
exploreTagRefreshTimer = null;
}
unlistenProgress(); unlistenProgress();
unlistenMediaJobs(); unlistenMediaJobs();
unlistenCaptionModelProgress(); unlistenCaptionModelProgress();