Skip to content

Commit 53fe706

Browse files
committed
fix: address review on flat-output in-flight hardening
- Revert unrelated comment-only churn in fetchHistoryAssets; the loadedJobIds all-job-dedup mechanism was dead code and the underlying "bug #2" does not exist (history pagination advances by offset, independent of loadedIds), so no fix is needed there. - Remove the loadedJobIds tests that passed incidentally on the base branch and validated a non-existent mechanism. - Rename flatOutputGeneration/generation to flatOutputRefreshEpoch/ capturedEpoch and document the discard invariant. - Add a reverse-ordering race test proving a stale loadMore that settles before a refresh does not pollute seenIds.
1 parent 6c7e756 commit 53fe706

2 files changed

Lines changed: 70 additions & 77 deletions

File tree

src/stores/assetsStore.test.ts

Lines changed: 51 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -774,78 +774,6 @@ describe('assetsStore - Refactored (Option A)', () => {
774774
})
775775
})
776776

777-
describe('assetsStore - loadedJobIds (all-job dedup)', () => {
778-
let store: ReturnType<typeof useAssetsStore>
779-
780-
const createFailedJobItem = (id: string): JobListItem => ({
781-
id,
782-
status: 'failed',
783-
create_time: 1000,
784-
update_time: 1000,
785-
last_state_update: 1000,
786-
priority: 1
787-
})
788-
789-
const createDisplayableJobItem = (id: string, index = 0): JobListItem => ({
790-
id,
791-
status: 'completed',
792-
create_time: 1000 + index,
793-
update_time: 1000 + index,
794-
last_state_update: 1000 + index,
795-
priority: 1000 + index,
796-
preview_output: {
797-
filename: `output_${id}.png`,
798-
subfolder: '',
799-
type: 'output',
800-
nodeId: 'node_1',
801-
mediaType: 'images'
802-
}
803-
})
804-
805-
beforeEach(() => {
806-
setActivePinia(createTestingPinia({ stubActions: false }))
807-
store = useAssetsStore()
808-
vi.clearAllMocks()
809-
})
810-
811-
it('does not false-negative dedup when the first page is all non-displayable jobs', async () => {
812-
const firstBatch = Array.from({ length: 200 }, (_, i) =>
813-
createFailedJobItem(`failed_${i}`)
814-
)
815-
vi.mocked(api.getHistory).mockResolvedValueOnce(firstBatch)
816-
await store.updateHistory()
817-
818-
expect(store.historyAssets).toHaveLength(0)
819-
820-
const secondBatch = [
821-
createFailedJobItem('failed_0'),
822-
createDisplayableJobItem('new_job', 0)
823-
]
824-
vi.mocked(api.getHistory).mockResolvedValueOnce(secondBatch)
825-
await store.loadMoreHistory()
826-
827-
const ids = store.historyAssets.map((a) => a.id)
828-
expect(ids).toContain('new_job')
829-
expect(ids).not.toContain('failed_0')
830-
})
831-
832-
it('loadedJobIds is cleared on reset (updateHistory)', async () => {
833-
const firstBatch = [createFailedJobItem('job_a')]
834-
vi.mocked(api.getHistory).mockResolvedValueOnce(firstBatch)
835-
await store.updateHistory()
836-
837-
const secondBatch = [createDisplayableJobItem('job_a', 0)]
838-
vi.mocked(api.getHistory)
839-
.mockResolvedValueOnce(secondBatch)
840-
.mockResolvedValueOnce([])
841-
842-
await store.updateHistory()
843-
844-
expect(store.historyAssets).toHaveLength(1)
845-
expect(store.historyAssets[0].id).toBe('job_a')
846-
})
847-
})
848-
849777
describe('assetsStore - Model Assets Cache (Cloud)', () => {
850778
beforeEach(() => {
851779
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -1914,6 +1842,57 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
19141842
expect(store.flatOutputAssets.map((a) => a.id)).not.toContain('extra-1')
19151843
})
19161844

1845+
it('a stale loadMore that resolves before the refresh does not corrupt seenIds', async () => {
1846+
const firstPage = Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
1847+
makeAsset(`a${i}`, `f${i}.png`)
1848+
)
1849+
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
1850+
makePage(firstPage, { hasMore: true })
1851+
)
1852+
const store = useAssetsStore()
1853+
await store.updateFlatOutputs()
1854+
1855+
vi.mocked(assetService.getAssetsPageByTag).mockClear()
1856+
1857+
let resolveLoadMore!: (page: AssetResponse) => void
1858+
const loadMorePromise = new Promise<AssetResponse>((res) => {
1859+
resolveLoadMore = res
1860+
})
1861+
let resolveRefresh!: (page: AssetResponse) => void
1862+
const refreshPromise = new Promise<AssetResponse>((res) => {
1863+
resolveRefresh = res
1864+
})
1865+
1866+
vi.mocked(assetService.getAssetsPageByTag)
1867+
.mockReturnValueOnce(loadMorePromise)
1868+
.mockReturnValueOnce(refreshPromise)
1869+
1870+
const loadMoreResult = store.loadMoreFlatOutputs()
1871+
const refreshResult = store.updateFlatOutputs()
1872+
1873+
// The stale loadMore settles first, before the refresh has replaced the
1874+
// list. Its page must be discarded rather than folded into seenIds.
1875+
resolveLoadMore(makePage([makeAsset('extra-1', 'extra.png')]))
1876+
resolveRefresh(
1877+
makePage([makeAsset('fresh-1', 'fresh.png')], { hasMore: true })
1878+
)
1879+
await Promise.all([loadMoreResult, refreshResult])
1880+
1881+
expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['fresh-1'])
1882+
1883+
// If the discarded loadMore had leaked 'extra-1' into seenIds, this
1884+
// legitimate next page would be filtered out and never shown.
1885+
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
1886+
makePage([makeAsset('extra-1', 'extra.png')])
1887+
)
1888+
await store.loadMoreFlatOutputs()
1889+
1890+
expect(store.flatOutputAssets.map((a) => a.id)).toEqual([
1891+
'fresh-1',
1892+
'extra-1'
1893+
])
1894+
})
1895+
19171896
it('a second concurrent refresh coalesces into the first refresh promise', async () => {
19181897
let resolvePage!: (page: AssetResponse) => void
19191898
const pagePromise = new Promise<AssetResponse>((res) => {

src/stores/assetsStore.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,38 +151,46 @@ export const useAssetsStore = defineStore('assets', () => {
151151
* @param loadMore - true for pagination (append), false for initial load (replace)
152152
*/
153153
const fetchHistoryAssets = async (loadMore = false): Promise<AssetItem[]> => {
154+
// Reset state for initial load
154155
if (!loadMore) {
155156
historyOffset.value = 0
156157
hasMoreHistory.value = true
157158
allHistoryItems.value = []
158159
loadedIds.clear()
159160
}
160161

162+
// Fetch from server with offset
161163
const history = await api.getHistory(BATCH_SIZE, {
162164
offset: historyOffset.value
163165
})
164166

167+
// Convert JobListItems to AssetItems
165168
const newAssets = mapHistoryToAssets(history)
166169

167170
if (loadMore) {
171+
// Filter out duplicates and insert in sorted order
168172
for (const asset of newAssets) {
169173
if (loadedIds.has(asset.id)) {
170-
continue
174+
continue // Skip duplicates
171175
}
172176
loadedIds.add(asset.id)
173177

178+
// Find insertion index to maintain sorted order (newest first)
174179
const assetTime = new Date(asset.created_at ?? 0).getTime()
175180
const insertIndex = allHistoryItems.value.findIndex(
176181
(item) => new Date(item.created_at ?? 0).getTime() < assetTime
177182
)
178183

179184
if (insertIndex === -1) {
185+
// Asset is oldest, append to end
180186
allHistoryItems.value.push(asset)
181187
} else {
188+
// Insert at the correct position
182189
allHistoryItems.value.splice(insertIndex, 0, asset)
183190
}
184191
}
185192
} else {
193+
// Initial load: replace all
186194
allHistoryItems.value = newAssets
187195
newAssets.forEach((asset) => loadedIds.add(asset.id))
188196
}
@@ -262,7 +270,9 @@ export const useAssetsStore = defineStore('assets', () => {
262270
let flatOutputNextCursor: string | undefined
263271
let flatOutputRefreshInFlight: Promise<AssetItem[]> | null = null
264272
let flatOutputLoadMoreInFlight: Promise<AssetItem[]> | null = null
265-
let flatOutputGeneration = 0
273+
// Incremented on each refresh; loadMore results captured from a prior epoch
274+
// are discarded so a stale page can't append onto a freshly-refreshed list.
275+
let flatOutputRefreshEpoch = 0
266276

267277
async function fetchFlatOutputs(loadMore: boolean): Promise<AssetItem[]> {
268278
if (loadMore) {
@@ -271,7 +281,7 @@ export const useAssetsStore = defineStore('assets', () => {
271281
flatOutputIsLoadingMore.value = true
272282
} else {
273283
if (flatOutputRefreshInFlight) return flatOutputRefreshInFlight
274-
flatOutputGeneration++
284+
flatOutputRefreshEpoch++
275285
flatOutputLoading.value = true
276286
flatOutputOffset.value = 0
277287
flatOutputNextCursor = undefined
@@ -280,7 +290,7 @@ export const useAssetsStore = defineStore('assets', () => {
280290
}
281291
flatOutputError.value = null
282292

283-
const generation = flatOutputGeneration
293+
const capturedEpoch = flatOutputRefreshEpoch
284294

285295
const inFlight = (async () => {
286296
const requestedAfter = loadMore ? flatOutputNextCursor : undefined
@@ -291,7 +301,7 @@ export const useAssetsStore = defineStore('assets', () => {
291301
? { after: requestedAfter }
292302
: { offset: flatOutputOffset.value })
293303
})
294-
if (loadMore && generation !== flatOutputGeneration) {
304+
if (loadMore && capturedEpoch !== flatOutputRefreshEpoch) {
295305
return flatOutputAssets.value
296306
}
297307
const batch = page.assets
@@ -325,6 +335,10 @@ export const useAssetsStore = defineStore('assets', () => {
325335
}
326336
})()
327337

338+
// The in-flight promise is assigned synchronously here, before any await
339+
// in inFlight can settle, so the loading flag set above and the guard below
340+
// stay in lockstep. This relies on single-entry via updateFlatOutputs /
341+
// loadMoreFlatOutputs; do not call fetchFlatOutputs directly.
328342
if (loadMore) {
329343
flatOutputLoadMoreInFlight = inFlight
330344
} else {

0 commit comments

Comments
 (0)