From 78fe639540b02dec0ce931c5fe1ab9a7a7e54da1 Mon Sep 17 00:00:00 2001 From: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Date: Tue, 24 Feb 2026 06:27:24 +0100 Subject: [PATCH] feat: periodically re-poll queue progress state (#9136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `useQueuePolling` composable that polls `queueStore.update()` every 5s while jobs are active - Calls `update()` immediately on creation so the UI is current after page reload - Uses `useIntervalFn` + `watch` pattern (same as `assetDownloadStore`) to pause/resume based on `activeJobsCount` ## Related Issue - Related to #8136 ## QA - Queue a prompt, reload page mid-execution, verify queue UI updates every ~5s - Verify polling stops when queue empties - Verify polling resumes when new jobs are queued ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9136-feat-periodically-re-poll-queue-progress-state-3106d73d36508119a32fc5b9c8eda21c) by [Unito](https://www.unito.io) --- .../remote/comfyui/useQueuePolling.test.ts | 173 ++++++++++++++++++ .../remote/comfyui/useQueuePolling.ts | 47 +++++ src/views/GraphView.vue | 2 + 3 files changed, 222 insertions(+) create mode 100644 src/platform/remote/comfyui/useQueuePolling.test.ts create mode 100644 src/platform/remote/comfyui/useQueuePolling.ts diff --git a/src/platform/remote/comfyui/useQueuePolling.test.ts b/src/platform/remote/comfyui/useQueuePolling.test.ts new file mode 100644 index 0000000000..c27c8f9565 --- /dev/null +++ b/src/platform/remote/comfyui/useQueuePolling.test.ts @@ -0,0 +1,173 @@ +import { mount } from '@vue/test-utils' +import type { VueWrapper } from '@vue/test-utils' +import { nextTick, reactive } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useQueuePolling } from '@/platform/remote/comfyui/useQueuePolling' + +vi.mock('@/stores/queueStore', () => { + const state = reactive({ + activeJobsCount: 0, + isLoading: false, + update: vi.fn() + }) + + return { + useQueueStore: () => state + } +}) + +// Re-import to get the mock instance for assertions +import { useQueueStore } from '@/stores/queueStore' + +function mountUseQueuePolling() { + return mount({ + template: '
', + setup() { + useQueuePolling() + return {} + } + }) +} + +describe('useQueuePolling', () => { + let wrapper: VueWrapper + const store = useQueueStore() as Partial< + ReturnType + > as { + activeJobsCount: number + isLoading: boolean + update: ReturnType + } + + beforeEach(() => { + vi.useFakeTimers() + store.activeJobsCount = 0 + store.isLoading = false + store.update.mockReset() + }) + + afterEach(() => { + wrapper?.unmount() + vi.useRealTimers() + }) + + it('does not call update on creation', () => { + wrapper = mountUseQueuePolling() + expect(store.update).not.toHaveBeenCalled() + }) + + it('polls when activeJobsCount is exactly 1', async () => { + wrapper = mountUseQueuePolling() + + store.activeJobsCount = 1 + await vi.advanceTimersByTimeAsync(8_000) + + expect(store.update).toHaveBeenCalledOnce() + }) + + it('does not poll when activeJobsCount > 1', async () => { + wrapper = mountUseQueuePolling() + + store.activeJobsCount = 2 + await vi.advanceTimersByTimeAsync(16_000) + + expect(store.update).not.toHaveBeenCalled() + }) + + it('stops polling when activeJobsCount drops to 0', async () => { + store.activeJobsCount = 1 + wrapper = mountUseQueuePolling() + + store.activeJobsCount = 0 + await vi.advanceTimersByTimeAsync(16_000) + + expect(store.update).not.toHaveBeenCalled() + }) + + it('resets timer when loading completes', async () => { + store.activeJobsCount = 1 + wrapper = mountUseQueuePolling() + + // Advance 5s toward the 8s timeout + await vi.advanceTimersByTimeAsync(5_000) + expect(store.update).not.toHaveBeenCalled() + + // Simulate an external update (e.g. WebSocket-triggered) completing + store.isLoading = true + await nextTick() + store.isLoading = false + await nextTick() + + // Timer should now be reset — should not fire 3s later (original 8s mark) + await vi.advanceTimersByTimeAsync(3_000) + expect(store.update).not.toHaveBeenCalled() + + // Should fire 8s after the reset (5s more) + await vi.advanceTimersByTimeAsync(5_000) + expect(store.update).toHaveBeenCalledOnce() + }) + + it('applies exponential backoff on successive polls', async () => { + store.activeJobsCount = 1 + wrapper = mountUseQueuePolling() + + // First poll at 8s + await vi.advanceTimersByTimeAsync(8_000) + expect(store.update).toHaveBeenCalledTimes(1) + + // Simulate update completing to reschedule + store.isLoading = true + await nextTick() + store.isLoading = false + await nextTick() + + // Second poll at 12s (8 * 1.5) + await vi.advanceTimersByTimeAsync(11_000) + expect(store.update).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1_000) + expect(store.update).toHaveBeenCalledTimes(2) + }) + + it('skips poll when an update is already in-flight', async () => { + store.activeJobsCount = 1 + wrapper = mountUseQueuePolling() + + // Simulate an external update starting before the timer fires + store.isLoading = true + + await vi.advanceTimersByTimeAsync(8_000) + expect(store.update).not.toHaveBeenCalled() + + // Once the in-flight update completes, polling resumes + store.isLoading = false + + await vi.advanceTimersByTimeAsync(8_000) + expect(store.update).toHaveBeenCalledOnce() + }) + + it('resets backoff when activeJobsCount changes', async () => { + store.activeJobsCount = 1 + wrapper = mountUseQueuePolling() + + // First poll at 8s (backs off delay to 12s) + await vi.advanceTimersByTimeAsync(8_000) + expect(store.update).toHaveBeenCalledTimes(1) + + // Simulate update completing + store.isLoading = true + await nextTick() + store.isLoading = false + await nextTick() + + // Count changes — backoff should reset to 8s + store.activeJobsCount = 0 + await nextTick() + store.activeJobsCount = 1 + await nextTick() + + store.update.mockClear() + await vi.advanceTimersByTimeAsync(8_000) + expect(store.update).toHaveBeenCalledOnce() + }) +}) diff --git a/src/platform/remote/comfyui/useQueuePolling.ts b/src/platform/remote/comfyui/useQueuePolling.ts new file mode 100644 index 0000000000..c66c14a25a --- /dev/null +++ b/src/platform/remote/comfyui/useQueuePolling.ts @@ -0,0 +1,47 @@ +import { useTimeoutFn } from '@vueuse/core' +import { ref, watch } from 'vue' + +import { useQueueStore } from '@/stores/queueStore' + +const BASE_INTERVAL_MS = 8_000 +const MAX_INTERVAL_MS = 32_000 +const BACKOFF_MULTIPLIER = 1.5 + +export function useQueuePolling() { + const queueStore = useQueueStore() + const delay = ref(BASE_INTERVAL_MS) + + const { start, stop } = useTimeoutFn( + () => { + if (queueStore.activeJobsCount !== 1 || queueStore.isLoading) return + delay.value = Math.min(delay.value * BACKOFF_MULTIPLIER, MAX_INTERVAL_MS) + void queueStore.update() + }, + delay, + { immediate: false } + ) + + function scheduleNextPoll() { + if (queueStore.activeJobsCount === 1 && !queueStore.isLoading) start() + else stop() + } + + watch( + () => queueStore.activeJobsCount, + () => { + delay.value = BASE_INTERVAL_MS + scheduleNextPoll() + }, + { immediate: true } + ) + + // Reschedule after any update completes (whether from polling or + // WebSocket events) to avoid redundant requests. + watch( + () => queueStore.isLoading, + (loading, wasLoading) => { + if (wasLoading && !loading) scheduleNextPoll() + }, + { flush: 'sync' } + ) +} diff --git a/src/views/GraphView.vue b/src/views/GraphView.vue index 667a4f0150..ac23f31a9d 100644 --- a/src/views/GraphView.vue +++ b/src/views/GraphView.vue @@ -51,6 +51,7 @@ import InviteAcceptedToast from '@/platform/workspace/components/toasts/InviteAc import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue' import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle' import { useCoreCommands } from '@/composables/useCoreCommands' +import { useQueuePolling } from '@/platform/remote/comfyui/useQueuePolling' import { useErrorHandling } from '@/composables/useErrorHandling' import { useProgressFavicon } from '@/composables/useProgressFavicon' import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig' @@ -209,6 +210,7 @@ useKeybindingService().registerCoreKeybindings() useSidebarTabStore().registerCoreSidebarTabs() void useBottomPanelStore().registerCoreBottomPanelTabs() +useQueuePolling() const queuePendingTaskCountStore = useQueuePendingTaskCountStore() const sidebarTabStore = useSidebarTabStore()