Skip to content

Commit 2956f98

Browse files
DaxServerclaude
andcommitted
fix: harden upload-slice ack tracking and reconnect resend against concurrent dispatch
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent f4af268 commit 2956f98

3 files changed

Lines changed: 122 additions & 59 deletions

File tree

frontend/src/composables/__tests__/useCollections.test.ts

Lines changed: 98 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import type {
77
UploadUpdateItem,
88
} from '@backend/types/ws'
99
import { type Image, type Item, UPLOAD_STATUS } from '@frontend/types/image'
10-
import { type Mock, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'
11-
import { ref } from 'vue'
10+
import { type Mock, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'
11+
import { type EffectScope, effectScope, ref } from 'vue'
1212

1313
type BatchesListData = Extract<ServerMessage, { type: 'BATCHES_LIST' }>['data']
1414
type BatchUploadsListData = Extract<ServerMessage, { type: 'BATCH_UPLOADS_LIST' }>['data']
@@ -75,6 +75,7 @@ describe('useCollections Listeners', () => {
7575
let initCollectionsListeners: typeof InitCollectionsListenersType
7676
let listeners: ReturnType<typeof InitCollectionsListenersType>
7777
let store: ReturnType<typeof useCollectionsStore>
78+
let scope: EffectScope
7879

7980
beforeAll(async () => {
8081
const mod = await import('../useCollections')
@@ -86,7 +87,12 @@ describe('useCollections Listeners', () => {
8687
store = useCollectionsStore()
8788
mockSend.mockClear()
8889
mockSocketConnected.value = false
89-
listeners = initCollectionsListeners()
90+
scope = effectScope()
91+
listeners = scope.run(() => initCollectionsListeners())!
92+
})
93+
94+
afterEach(() => {
95+
scope.stop()
9096
})
9197

9298
describe('onUploadsUpdate', () => {
@@ -1090,6 +1096,7 @@ describe('useCollections Listeners', () => {
10901096

10911097
describe('onBatchCreated', () => {
10921098
it('should set batchId and send first slice', () => {
1099+
mockSocketConnected.value = true
10931100
// Mock selectedItems logic via items
10941101
const newItems: Record<string, Item> = {}
10951102
for (let i = 0; i < 15; i++) {
@@ -1110,7 +1117,7 @@ describe('useCollections Listeners', () => {
11101117
listeners.onBatchCreated(100)
11111118

11121119
expect(store.batchId).toBe(100)
1113-
expect(store.uploadSliceIndex).toBe(0)
1120+
expect(store.ackedSliceIds.size).toBe(0)
11141121

11151122
expect(mockSend).toHaveBeenCalled()
11161123
const calls = (mockSend as Mock<(data: unknown) => void>).mock.calls
@@ -1160,7 +1167,32 @@ describe('useCollections Listeners', () => {
11601167
expect(store.batchId).toBe(0)
11611168
})
11621169

1170+
it('does not send slices while offline, relying on the connected watcher to flush them later', () => {
1171+
mockSocketConnected.value = false
1172+
const newItems: Record<string, Item> = {}
1173+
for (let i = 0; i < 15; i++) {
1174+
const id = `img${i}`
1175+
newItems[id] = createMockItem({
1176+
id,
1177+
meta: {
1178+
selected: true,
1179+
license: '',
1180+
description: { value: '', language: 'en' },
1181+
categories: '',
1182+
},
1183+
image: createMockImage({ id }),
1184+
})
1185+
}
1186+
store.replaceItems(newItems)
1187+
1188+
listeners.onBatchCreated(100)
1189+
1190+
expect(store.batchId).toBe(100)
1191+
expect(mockSend).not.toHaveBeenCalled()
1192+
})
1193+
11631194
it(`should handle exactly ${UPLOAD_SLICE_SIZE} selected items (one full slice)`, () => {
1195+
mockSocketConnected.value = true
11641196
const newItems: Record<string, Item> = {}
11651197
for (let i = 0; i < UPLOAD_SLICE_SIZE; i++) {
11661198
const id = `img${i}`
@@ -1180,7 +1212,7 @@ describe('useCollections Listeners', () => {
11801212
listeners.onBatchCreated(100)
11811213

11821214
expect(store.batchId).toBe(100)
1183-
expect(store.uploadSliceIndex).toBe(0)
1215+
expect(store.ackedSliceIds.size).toBe(0)
11841216
expect(store.isBatchCreated).toBe(false) // Not created yet, need to wait for ACK
11851217

11861218
expect(mockSend).toHaveBeenCalled()
@@ -1201,6 +1233,7 @@ describe('useCollections Listeners', () => {
12011233
})
12021234

12031235
it(`should send all slices immediately for ${UPLOAD_SLICE_SIZE * 2} selected items`, () => {
1236+
mockSocketConnected.value = true
12041237
const newItems: Record<string, Item> = {}
12051238
for (let i = 0; i < UPLOAD_SLICE_SIZE * 2; i++) {
12061239
const id = `img${i}`
@@ -1230,9 +1263,8 @@ describe('useCollections Listeners', () => {
12301263
})
12311264

12321265
describe('onUploadSliceAck', () => {
1233-
it('should increment uploadSliceIndex but not send the next slice', () => {
1266+
it('adds the slice id to ackedSliceIds and does not send anything else', () => {
12341267
store.batchId = 100
1235-
store.uploadSliceIndex = 0
12361268

12371269
const newItems: Record<string, Item> = {}
12381270
for (let i = 0; i < UPLOAD_SLICE_SIZE + 5; i++) {
@@ -1252,23 +1284,48 @@ describe('useCollections Listeners', () => {
12521284

12531285
listeners.onUploadSliceAck(0, [])
12541286

1255-
expect(store.uploadSliceIndex).toBe(1)
1287+
expect(store.ackedSliceIds.has(0)).toBe(true)
12561288
expect(mockSend).not.toHaveBeenCalled()
12571289
})
12581290

1259-
it('should not send next slice if index does not match', () => {
1291+
it('ignores a duplicate ack for an already-acked slice id', () => {
12601292
store.batchId = 100
1261-
store.uploadSliceIndex = 1
1293+
store.ackedSliceIds = new Set([0])
12621294

12631295
listeners.onUploadSliceAck(0, [])
12641296

1265-
expect(store.uploadSliceIndex).toBe(1)
1297+
expect(store.ackedSliceIds.size).toBe(1)
12661298
expect(mockSend).not.toHaveBeenCalled()
12671299
})
12681300

1301+
it('acks arriving out of order still complete the batch once all are seen', () => {
1302+
store.batchId = 100
1303+
const newItems: Record<string, Item> = {}
1304+
for (let i = 0; i < UPLOAD_SLICE_SIZE * 3; i++) {
1305+
const id = `img${i}`
1306+
newItems[id] = createMockItem({
1307+
id,
1308+
meta: {
1309+
selected: true,
1310+
license: '',
1311+
description: { value: '', language: 'en' },
1312+
categories: '',
1313+
},
1314+
image: createMockImage({ id }),
1315+
})
1316+
}
1317+
store.replaceItems(newItems)
1318+
1319+
listeners.onUploadSliceAck(0, [])
1320+
listeners.onUploadSliceAck(2, [])
1321+
listeners.onUploadSliceAck(1, [])
1322+
1323+
expect(store.ackedSliceIds.size).toBe(3)
1324+
expect(store.isBatchCreated).toBe(true)
1325+
})
1326+
12691327
it('should update item statuses from ACK response', () => {
12701328
store.batchId = 100
1271-
store.uploadSliceIndex = 0
12721329

12731330
const newItems: Record<string, Item> = {}
12741331
for (let i = 0; i < 5; i++) {
@@ -1295,7 +1352,7 @@ describe('useCollections Listeners', () => {
12951352

12961353
listeners.onUploadSliceAck(0, ackItems)
12971354

1298-
expect(store.uploadSliceIndex).toBe(1)
1355+
expect(store.ackedSliceIds.has(0)).toBe(true)
12991356
expect(store.items.img0!.meta.status).toBe(UPLOAD_STATUS.Queued)
13001357
expect(store.items.img1!.meta.status).toBe(UPLOAD_STATUS.Queued)
13011358
expect(store.items.img2!.meta.status).toBe(UPLOAD_STATUS.InProgress)
@@ -1304,6 +1361,7 @@ describe('useCollections Listeners', () => {
13041361
})
13051362

13061363
it('each sent slice has correct batchid, handler, and up to UPLOAD_SLICE_SIZE items', () => {
1364+
mockSocketConnected.value = true
13071365
store.input = 'input'
13081366
store.globalLicense = ''
13091367

@@ -1343,57 +1401,43 @@ describe('useCollections Listeners', () => {
13431401

13441402
it('should handle undefined slice ID', () => {
13451403
store.batchId = 100
1346-
store.uploadSliceIndex = 0
13471404

13481405
listeners.onUploadSliceAck(undefined as unknown as number, [])
13491406

1350-
expect(store.uploadSliceIndex).toBe(0)
1407+
expect(store.ackedSliceIds.size).toBe(0)
13511408
expect(mockSend).not.toHaveBeenCalled()
13521409
})
13531410

13541411
it('should handle null slice ID', () => {
13551412
store.batchId = 100
1356-
store.uploadSliceIndex = 0
13571413

13581414
listeners.onUploadSliceAck(null as unknown as number, [])
13591415

1360-
expect(store.uploadSliceIndex).toBe(0)
1416+
expect(store.ackedSliceIds.size).toBe(0)
13611417
expect(mockSend).not.toHaveBeenCalled()
13621418
})
13631419

13641420
it('should handle NaN slice ID', () => {
13651421
store.batchId = 100
1366-
store.uploadSliceIndex = 0
13671422

13681423
listeners.onUploadSliceAck(NaN, [])
13691424

1370-
expect(store.uploadSliceIndex).toBe(0)
1425+
expect(store.ackedSliceIds.size).toBe(0)
13711426
expect(mockSend).not.toHaveBeenCalled()
13721427
})
13731428

13741429
it('should handle negative slice ID', () => {
13751430
store.batchId = 100
1376-
store.uploadSliceIndex = 0
13771431

13781432
listeners.onUploadSliceAck(-1, [])
13791433

1380-
expect(store.uploadSliceIndex).toBe(0)
1381-
expect(mockSend).not.toHaveBeenCalled()
1382-
})
1383-
1384-
it('should handle slice ID when uploadSliceIndex is negative', () => {
1385-
store.batchId = 100
1386-
store.uploadSliceIndex = -1
1387-
1388-
listeners.onUploadSliceAck(0, [])
1389-
1390-
expect(store.uploadSliceIndex).toBe(-1)
1434+
expect(store.ackedSliceIds.size).toBe(0)
13911435
expect(mockSend).not.toHaveBeenCalled()
13921436
})
13931437

13941438
it('should complete all slices and subscribe to batch', () => {
13951439
store.batchId = 100
1396-
store.uploadSliceIndex = 1
1440+
store.ackedSliceIds = new Set([0])
13971441
store.isLoading = true
13981442

13991443
const newItems: Record<string, Item> = {}
@@ -1414,22 +1458,17 @@ describe('useCollections Listeners', () => {
14141458

14151459
listeners.onUploadSliceAck(1, [])
14161460

1417-
expect(store.uploadSliceIndex).toBe(2)
1461+
expect(store.ackedSliceIds.size).toBe(2)
14181462
expect(store.isBatchCreated).toBe(true)
14191463
expect(mockSend).toHaveBeenCalled()
14201464
const calls = (mockSend as Mock<(data: unknown) => void>).mock.calls
1421-
expect(calls.length).toBeGreaterThan(0)
14221465
const arg = calls[calls.length - 1]![0]
14231466
const sentMsg = arg as ClientMessage
1424-
1425-
// With UPLOAD_SLICE_SIZE + 5 items, slice index 2 means start at UPLOAD_SLICE_SIZE * 2,
1426-
// which is >= UPLOAD_SLICE_SIZE + 5, so it should complete
1427-
if (sentMsg.type === 'UPLOAD_SLICE') {
1428-
expect(sentMsg.data.items).toHaveLength(0) // Empty slice, should trigger subscription
1429-
}
1467+
expect(sentMsg).toMatchObject({ type: 'SUBSCRIBE_BATCH', data: 100 })
14301468
})
14311469

14321470
it('sets copyright_override true when item license is set', () => {
1471+
mockSocketConnected.value = true
14331472
store.input = 'input'
14341473
store.globalLicense = ''
14351474

@@ -1460,6 +1499,7 @@ describe('useCollections Listeners', () => {
14601499
})
14611500

14621501
it('sets copyright_override true when globalLicense is set', () => {
1502+
mockSocketConnected.value = true
14631503
store.input = 'input'
14641504
store.globalLicense = 'CC-BY-4.0'
14651505

@@ -1510,9 +1550,10 @@ describe('useCollections Listeners', () => {
15101550
}
15111551

15121552
it('resends unACKed slices when reconnected during an active batch upload', () => {
1553+
mockSocketConnected.value = true
15131554
store.replaceItems(makeItems(UPLOAD_SLICE_SIZE * 3))
15141555
store.batchId = 100
1515-
store.uploadSliceIndex = 1
1556+
store.ackedSliceIds = new Set([0])
15161557
store.isBatchCreated = false
15171558

15181559
listeners.onSocketReconnect()
@@ -1524,7 +1565,20 @@ describe('useCollections Listeners', () => {
15241565
expect((sliceCalls[1]![0] as UploadSliceMsg).data.sliceid).toBe(2)
15251566
})
15261567

1568+
it('does not send anything while still offline', () => {
1569+
mockSocketConnected.value = false
1570+
store.replaceItems(makeItems(UPLOAD_SLICE_SIZE * 3))
1571+
store.batchId = 100
1572+
store.ackedSliceIds = new Set([0])
1573+
store.isBatchCreated = false
1574+
1575+
listeners.onSocketReconnect()
1576+
1577+
expect(mockSend).not.toHaveBeenCalled()
1578+
})
1579+
15271580
it('does nothing when no batch is in progress', () => {
1581+
mockSocketConnected.value = true
15281582
store.replaceItems(makeItems(UPLOAD_SLICE_SIZE * 2))
15291583
store.batchId = null
15301584
store.isBatchCreated = false
@@ -1535,6 +1589,7 @@ describe('useCollections Listeners', () => {
15351589
})
15361590

15371591
it('does nothing when batch upload is already complete', () => {
1592+
mockSocketConnected.value = true
15381593
store.replaceItems(makeItems(UPLOAD_SLICE_SIZE * 2))
15391594
store.batchId = 100
15401595
store.isBatchCreated = true
@@ -1545,9 +1600,10 @@ describe('useCollections Listeners', () => {
15451600
})
15461601

15471602
it('does nothing when all slices have been ACKed', () => {
1603+
mockSocketConnected.value = true
15481604
store.replaceItems(makeItems(UPLOAD_SLICE_SIZE))
15491605
store.batchId = 100
1550-
store.uploadSliceIndex = 1
1606+
store.ackedSliceIds = new Set([0])
15511607
store.isBatchCreated = false
15521608

15531609
listeners.onSocketReconnect()

0 commit comments

Comments
 (0)