Skip to content

Commit 78fe639

Browse files
authored
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)
1 parent e333ad4 commit 78fe639

File tree

3 files changed

+222
-0
lines changed

3 files changed

+222
-0
lines changed
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { mount } from '@vue/test-utils'
2+
import type { VueWrapper } from '@vue/test-utils'
3+
import { nextTick, reactive } from 'vue'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
import { useQueuePolling } from '@/platform/remote/comfyui/useQueuePolling'
7+
8+
vi.mock('@/stores/queueStore', () => {
9+
const state = reactive({
10+
activeJobsCount: 0,
11+
isLoading: false,
12+
update: vi.fn()
13+
})
14+
15+
return {
16+
useQueueStore: () => state
17+
}
18+
})
19+
20+
// Re-import to get the mock instance for assertions
21+
import { useQueueStore } from '@/stores/queueStore'
22+
23+
function mountUseQueuePolling() {
24+
return mount({
25+
template: '<div />',
26+
setup() {
27+
useQueuePolling()
28+
return {}
29+
}
30+
})
31+
}
32+
33+
describe('useQueuePolling', () => {
34+
let wrapper: VueWrapper
35+
const store = useQueueStore() as Partial<
36+
ReturnType<typeof useQueueStore>
37+
> as {
38+
activeJobsCount: number
39+
isLoading: boolean
40+
update: ReturnType<typeof vi.fn>
41+
}
42+
43+
beforeEach(() => {
44+
vi.useFakeTimers()
45+
store.activeJobsCount = 0
46+
store.isLoading = false
47+
store.update.mockReset()
48+
})
49+
50+
afterEach(() => {
51+
wrapper?.unmount()
52+
vi.useRealTimers()
53+
})
54+
55+
it('does not call update on creation', () => {
56+
wrapper = mountUseQueuePolling()
57+
expect(store.update).not.toHaveBeenCalled()
58+
})
59+
60+
it('polls when activeJobsCount is exactly 1', async () => {
61+
wrapper = mountUseQueuePolling()
62+
63+
store.activeJobsCount = 1
64+
await vi.advanceTimersByTimeAsync(8_000)
65+
66+
expect(store.update).toHaveBeenCalledOnce()
67+
})
68+
69+
it('does not poll when activeJobsCount > 1', async () => {
70+
wrapper = mountUseQueuePolling()
71+
72+
store.activeJobsCount = 2
73+
await vi.advanceTimersByTimeAsync(16_000)
74+
75+
expect(store.update).not.toHaveBeenCalled()
76+
})
77+
78+
it('stops polling when activeJobsCount drops to 0', async () => {
79+
store.activeJobsCount = 1
80+
wrapper = mountUseQueuePolling()
81+
82+
store.activeJobsCount = 0
83+
await vi.advanceTimersByTimeAsync(16_000)
84+
85+
expect(store.update).not.toHaveBeenCalled()
86+
})
87+
88+
it('resets timer when loading completes', async () => {
89+
store.activeJobsCount = 1
90+
wrapper = mountUseQueuePolling()
91+
92+
// Advance 5s toward the 8s timeout
93+
await vi.advanceTimersByTimeAsync(5_000)
94+
expect(store.update).not.toHaveBeenCalled()
95+
96+
// Simulate an external update (e.g. WebSocket-triggered) completing
97+
store.isLoading = true
98+
await nextTick()
99+
store.isLoading = false
100+
await nextTick()
101+
102+
// Timer should now be reset — should not fire 3s later (original 8s mark)
103+
await vi.advanceTimersByTimeAsync(3_000)
104+
expect(store.update).not.toHaveBeenCalled()
105+
106+
// Should fire 8s after the reset (5s more)
107+
await vi.advanceTimersByTimeAsync(5_000)
108+
expect(store.update).toHaveBeenCalledOnce()
109+
})
110+
111+
it('applies exponential backoff on successive polls', async () => {
112+
store.activeJobsCount = 1
113+
wrapper = mountUseQueuePolling()
114+
115+
// First poll at 8s
116+
await vi.advanceTimersByTimeAsync(8_000)
117+
expect(store.update).toHaveBeenCalledTimes(1)
118+
119+
// Simulate update completing to reschedule
120+
store.isLoading = true
121+
await nextTick()
122+
store.isLoading = false
123+
await nextTick()
124+
125+
// Second poll at 12s (8 * 1.5)
126+
await vi.advanceTimersByTimeAsync(11_000)
127+
expect(store.update).toHaveBeenCalledTimes(1)
128+
await vi.advanceTimersByTimeAsync(1_000)
129+
expect(store.update).toHaveBeenCalledTimes(2)
130+
})
131+
132+
it('skips poll when an update is already in-flight', async () => {
133+
store.activeJobsCount = 1
134+
wrapper = mountUseQueuePolling()
135+
136+
// Simulate an external update starting before the timer fires
137+
store.isLoading = true
138+
139+
await vi.advanceTimersByTimeAsync(8_000)
140+
expect(store.update).not.toHaveBeenCalled()
141+
142+
// Once the in-flight update completes, polling resumes
143+
store.isLoading = false
144+
145+
await vi.advanceTimersByTimeAsync(8_000)
146+
expect(store.update).toHaveBeenCalledOnce()
147+
})
148+
149+
it('resets backoff when activeJobsCount changes', async () => {
150+
store.activeJobsCount = 1
151+
wrapper = mountUseQueuePolling()
152+
153+
// First poll at 8s (backs off delay to 12s)
154+
await vi.advanceTimersByTimeAsync(8_000)
155+
expect(store.update).toHaveBeenCalledTimes(1)
156+
157+
// Simulate update completing
158+
store.isLoading = true
159+
await nextTick()
160+
store.isLoading = false
161+
await nextTick()
162+
163+
// Count changes — backoff should reset to 8s
164+
store.activeJobsCount = 0
165+
await nextTick()
166+
store.activeJobsCount = 1
167+
await nextTick()
168+
169+
store.update.mockClear()
170+
await vi.advanceTimersByTimeAsync(8_000)
171+
expect(store.update).toHaveBeenCalledOnce()
172+
})
173+
})
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { useTimeoutFn } from '@vueuse/core'
2+
import { ref, watch } from 'vue'
3+
4+
import { useQueueStore } from '@/stores/queueStore'
5+
6+
const BASE_INTERVAL_MS = 8_000
7+
const MAX_INTERVAL_MS = 32_000
8+
const BACKOFF_MULTIPLIER = 1.5
9+
10+
export function useQueuePolling() {
11+
const queueStore = useQueueStore()
12+
const delay = ref(BASE_INTERVAL_MS)
13+
14+
const { start, stop } = useTimeoutFn(
15+
() => {
16+
if (queueStore.activeJobsCount !== 1 || queueStore.isLoading) return
17+
delay.value = Math.min(delay.value * BACKOFF_MULTIPLIER, MAX_INTERVAL_MS)
18+
void queueStore.update()
19+
},
20+
delay,
21+
{ immediate: false }
22+
)
23+
24+
function scheduleNextPoll() {
25+
if (queueStore.activeJobsCount === 1 && !queueStore.isLoading) start()
26+
else stop()
27+
}
28+
29+
watch(
30+
() => queueStore.activeJobsCount,
31+
() => {
32+
delay.value = BASE_INTERVAL_MS
33+
scheduleNextPoll()
34+
},
35+
{ immediate: true }
36+
)
37+
38+
// Reschedule after any update completes (whether from polling or
39+
// WebSocket events) to avoid redundant requests.
40+
watch(
41+
() => queueStore.isLoading,
42+
(loading, wasLoading) => {
43+
if (wasLoading && !loading) scheduleNextPoll()
44+
},
45+
{ flush: 'sync' }
46+
)
47+
}

src/views/GraphView.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import InviteAcceptedToast from '@/platform/workspace/components/toasts/InviteAc
5151
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
5252
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
5353
import { useCoreCommands } from '@/composables/useCoreCommands'
54+
import { useQueuePolling } from '@/platform/remote/comfyui/useQueuePolling'
5455
import { useErrorHandling } from '@/composables/useErrorHandling'
5556
import { useProgressFavicon } from '@/composables/useProgressFavicon'
5657
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
@@ -209,6 +210,7 @@ useKeybindingService().registerCoreKeybindings()
209210
useSidebarTabStore().registerCoreSidebarTabs()
210211
void useBottomPanelStore().registerCoreBottomPanelTabs()
211212
213+
useQueuePolling()
212214
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
213215
const sidebarTabStore = useSidebarTabStore()
214216

0 commit comments

Comments
 (0)