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:
Johnpaul Chiwetelu
2026-02-24 06:27:24 +01:00
committed by GitHub
parent e333ad459e
commit 78fe639540
3 changed files with 222 additions and 0 deletions

View 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()
})
})

View 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' }
)
}

View File

@@ -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()