Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions frontend/src/hooks/useScenarioRunProgress.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
} from '@/types'

import {
MAX_PROGRESS_PAGES_PER_CYCLE,
SCENARIO_RUN_POLL_INTERVAL_MS,
useScenarioRunProgress,
} from './useScenarioRunProgress'
Expand Down Expand Up @@ -598,4 +599,105 @@ describe('useScenarioRunProgress', () => {
)
unmount()
})

it('stops paging instead of looping forever when the cursor does not advance', async () => {
jest.useFakeTimers()
mockGetRunProgress.mockImplementation(() =>
Promise.resolve(makePage({ has_more: true, next_cursor: undefined })),
)

renderHook(() => useScenarioRunProgress('run-1'))
await act(async () => {})

const afterFirstCycle = mockGetRunProgress.mock.calls.length
expect(afterFirstCycle).toBe(1)

await act(async () => { await jest.advanceTimersByTimeAsync(SCENARIO_RUN_POLL_INTERVAL_MS) })
await act(async () => { await jest.advanceTimersByTimeAsync(SCENARIO_RUN_POLL_INTERVAL_MS) })
await act(async () => { await jest.advanceTimersByTimeAsync(SCENARIO_RUN_POLL_INTERVAL_MS) })

// One request per poll interval, not an endless recursion of the same page.
expect(mockGetRunProgress.mock.calls.length).toBeLessThanOrEqual(afterFirstCycle + 3)
})

it('recovers a finished run whose cursor goes missing once', async () => {
jest.useFakeTimers()
let call = 0
mockGetRunProgress.mockImplementation(() => {
call += 1
const stuckFirst = call === 1
const final = call === 3
return Promise.resolve(
makePage({
has_more: !final,
next_cursor: final ? null : (stuckFirst ? undefined : 'cursor-' + String(call)),
run: { ...makePage().run, status: 'COMPLETED' },
}),
)
})

renderHook(() => useScenarioRunProgress('run-1'))
await act(async () => {})

// The completed run answered has_more without a cursor: polling continues,
// and once the cursor recovers the same cycle drains to the final page.
expect(mockGetRunProgress.mock.calls.length).toBe(1)
await act(async () => { await jest.advanceTimersByTimeAsync(SCENARIO_RUN_POLL_INTERVAL_MS) })
expect(mockGetRunProgress.mock.calls.length).toBe(3)

// The final page arrived without has_more, so polling stops for good.
await act(async () => { await jest.advanceTimersByTimeAsync(SCENARIO_RUN_POLL_INTERVAL_MS * 2) })
expect(mockGetRunProgress.mock.calls.length).toBe(3)
})

it('follows an advancing cursor across pages', async () => {
jest.useFakeTimers()
mockGetRunProgress
.mockResolvedValueOnce(makePage({ has_more: true, next_cursor: 'cursor-1' }))
.mockResolvedValueOnce(makePage({ has_more: true, next_cursor: 'cursor-2' }))
.mockResolvedValueOnce(makePage({ has_more: false, next_cursor: 'cursor-3' }))

renderHook(() => useScenarioRunProgress('run-1'))
await act(async () => {})

expect(mockGetRunProgress).toHaveBeenCalledTimes(3)
expect(mockGetRunProgress.mock.calls[1][1]).toEqual({ since: 'cursor-1', limit: 500 })
expect(mockGetRunProgress.mock.calls[2][1]).toEqual({ since: 'cursor-2', limit: 500 })
})

it('loads the tail of a finished run that exceeds the page budget', async () => {
jest.useFakeTimers()
let call = 0
mockGetRunProgress.mockImplementation(() => {
call += 1
const final = call === MAX_PROGRESS_PAGES_PER_CYCLE + 3
return Promise.resolve(
makePage({
has_more: !final,
next_cursor: final ? null : 'cursor-' + String(call),
run: { ...makePage().run, status: final ? 'COMPLETED' : 'IN_PROGRESS' },
results: final ? [makeResult('tail-attempt')] : [],
}),
)
})

renderHook(() => useScenarioRunProgress('run-1'))
await act(async () => {})

// The first cycle stops at the budget with pages remaining.
expect(mockGetRunProgress.mock.calls.length).toBe(MAX_PROGRESS_PAGES_PER_CYCLE + 1)

// The next scheduled cycle drains the remaining pages of the finished run.
await act(async () => { await jest.advanceTimersByTimeAsync(SCENARIO_RUN_POLL_INTERVAL_MS) })
expect(mockGetRunProgress.mock.calls.length).toBe(MAX_PROGRESS_PAGES_PER_CYCLE + 3)
expect(mockGetRunProgress).toHaveBeenLastCalledWith(
'run-1',
{ since: 'cursor-' + String(MAX_PROGRESS_PAGES_PER_CYCLE + 2), limit: 500 },
expect.any(AbortSignal),
)

// Polling stops for good once the final page arrived.
await act(async () => { await jest.advanceTimersByTimeAsync(SCENARIO_RUN_POLL_INTERVAL_MS * 2) })
expect(mockGetRunProgress.mock.calls.length).toBe(MAX_PROGRESS_PAGES_PER_CYCLE + 3)
})
})
48 changes: 40 additions & 8 deletions frontend/src/hooks/useScenarioRunProgress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {

export const SCENARIO_RUN_POLL_INTERVAL_MS = 2_500
const PROGRESS_PAGE_LIMIT = 500
// Same bounding idea as fetchAllPages: a misbehaving cursor must not page forever.
export const MAX_PROGRESS_PAGES_PER_CYCLE = 50

export interface UseScenarioRunProgressResult {
readonly state: ScenarioRunProgressState
Expand Down Expand Up @@ -49,7 +51,15 @@ export function useScenarioRunProgress(scenarioResultId: string): UseScenarioRun
}
}

const fetchPage = async (since: string | null): Promise<void> => {
let seenCursors = new Set<string>()
const schedulePoll = (): void => {
clearPollTimer()
timerRef.current = setTimeout(() => {
timerRef.current = null
void beginFetch()
}, SCENARIO_RUN_POLL_INTERVAL_MS)
}
const fetchPage = async (since: string | null, depth: number = 0): Promise<void> => {
if (!active || pollingStoppedRef.current) {
return
}
Expand All @@ -71,18 +81,33 @@ export function useScenarioRunProgress(scenarioResultId: string): UseScenarioRun
dispatch({ type: 'apply-page', page, fresh: since === null })

if (page.has_more) {
await fetchPage(appliedCursor)
const nextCursor = page.next_cursor
const cursorAdvances =
nextCursor != null && nextCursor !== since && !seenCursors.has(nextCursor)
if (cursorAdvances && depth < MAX_PROGRESS_PAGES_PER_CYCLE) {
seenCursors.add(nextCursor!)
Comment thread
romanlutz marked this conversation as resolved.
await fetchPage(nextCursor!, depth + 1)
return
}
if (cursorAdvances) {
// The page budget ran out while pages remain. Schedule another bounded
// cycle from the saved cursor so the tail of a long or finished run is
// still loaded; only a page without has_more ends polling for good.
schedulePoll()
return
}
// The cursor did not advance, so refetching would request the same page
// forever (the same guard fetchAllPages uses). Schedule the next bounded
// poll instead: a finished run only stops after a page without has_more,
// and the next response may carry a valid cursor again.
schedulePoll()
return
}
if (isTerminalRunState(page.run.status)) {
pollingStoppedRef.current = true
return
}
clearPollTimer()
timerRef.current = setTimeout(() => {
timerRef.current = null
void fetchPage(cursorRef.current)
}, SCENARIO_RUN_POLL_INTERVAL_MS)
schedulePoll()
} catch (error: unknown) {
if (!active || controller.signal.aborted) {
return
Expand All @@ -107,14 +132,21 @@ export function useScenarioRunProgress(scenarioResultId: string): UseScenarioRun
}
}

const beginFetch = (): Promise<void> => {
// Each cycle gets a fresh cursor budget, so a long run paged to the limit
// can continue from the saved cursor on the next scheduled cycle.
seenCursors = new Set<string>()
return fetchPage(cursorRef.current)
}

if (!scenarioResultId.trim()) {
dispatch({
type: 'request-failed',
message: 'The scenario run ID in this URL is missing or invalid.',
notFound: true,
})
} else {
void fetchPage(cursorRef.current)
void beginFetch()
}

return () => {
Expand Down
Loading