mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-26 09:44:06 +00:00
feat: periodically re-poll queue progress state (#9136)
## 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)
This commit is contained in:
committed by
GitHub
parent
e333ad459e
commit
78fe639540
173
src/platform/remote/comfyui/useQueuePolling.test.ts
Normal file
173
src/platform/remote/comfyui/useQueuePolling.test.ts
Normal file
@@ -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: '<div />',
|
||||
setup() {
|
||||
useQueuePolling()
|
||||
return {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('useQueuePolling', () => {
|
||||
let wrapper: VueWrapper
|
||||
const store = useQueueStore() as Partial<
|
||||
ReturnType<typeof useQueueStore>
|
||||
> as {
|
||||
activeJobsCount: number
|
||||
isLoading: boolean
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
47
src/platform/remote/comfyui/useQueuePolling.ts
Normal file
47
src/platform/remote/comfyui/useQueuePolling.ts
Normal file
@@ -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' }
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user