forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy patharchiveStore.test.ts
More file actions
401 lines (322 loc) · 13.7 KB
/
Copy patharchiveStore.test.ts
File metadata and controls
401 lines (322 loc) · 13.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { get, set, del } from 'idb-keyval'
import {
deleteArchiveRecords,
deleteGeneration,
markComplete,
readArchive,
readArchiveSize,
readComplete,
readSegmentRun,
readSegments,
segmentKeyFor,
writeSegment,
} from './archiveStore'
// The record layout an archive is stored in (#553). What the download engine
// writes and what the map reads, with nothing in between - archiveDownload.ts's
// own suite covers the transfer; this covers the shape it leaves behind.
//
// Two properties carry the weight here, and both are about not destroying a map
// somebody is navigating by:
//
// - a finished archive is its SEGMENTS plus a marker, never a second copy, so
// completion needs no room at all; and
// - an in-flight transfer writes into the generation the finished archive is
// NOT in, so a re-download cannot overwrite the map it is replacing.
vi.mock('idb-keyval', () => ({ get: vi.fn(), set: vi.fn(), del: vi.fn() }))
const KEY = 'ourhike:corridor-archive'
function withStore(initial: Record<string, unknown> = {}) {
const store: Record<string, unknown> = { ...initial }
vi.mocked(get).mockImplementation(async (key) => store[key as string])
vi.mocked(set).mockImplementation(async (key, value) => {
store[key as string] = value
})
vi.mocked(del).mockImplementation(async (key) => {
delete store[key as string]
})
return store
}
/** Segment records for one generation, in order. */
function segments(generation: number, ...parts: string[]): Record<string, unknown> {
return Object.fromEntries(
parts.map((text, index) => [segmentKeyFor(KEY, generation, index), new Blob([text])]),
)
}
function complete(
generation: number,
segmentCount: number,
totalBytes: number,
): Record<string, unknown> {
return { [`${KEY}:complete`]: { generation, segments: segmentCount, totalBytes } }
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('readArchive', () => {
it('assembles the segments the marker names, in order', async () => {
withStore({ ...segments(0, 'one ', 'two ', 'three'), ...complete(0, 3, 14) })
expect(await (await readArchive(KEY))?.text()).toBe('one two three')
})
it('is undefined while a transfer is still unfinished', async () => {
// Segments on disk with no marker are a download in progress. Serving them
// would hand the map a truncated PMTiles archive, which opens fine and then
// returns nothing for tiles past the cut.
withStore(segments(0, 'half a map'))
expect(await readArchive(KEY)).toBeUndefined()
})
it('is undefined when the phone holds nothing at all', async () => {
withStore()
expect(await readArchive(KEY)).toBeUndefined()
})
it('serves a whole-archive record written by the pre-segment build', async () => {
// lib/packages.ts kept this key so an archive already in a tester's
// IndexedDB "stays readable after this change, rather than silently
// re-downloading". That promise outlived the layout it was written about.
const legacy = new Blob(['a map from the old build'])
withStore({ [KEY]: legacy })
expect(await readArchive(KEY)).toBe(legacy)
})
it('prefers the marker to a legacy record that outlived a crash', async () => {
// markComplete removes the legacy record after writing the marker. A phone
// holding both was interrupted in between, and the segments are the newer
// bytes.
withStore({
[KEY]: new Blob(['the older map']),
...segments(1, 'the newer map'),
...complete(1, 1, 13),
})
expect(await (await readArchive(KEY))?.text()).toBe('the newer map')
})
it('ignores a non-Blob under the package key', async () => {
withStore({ [KEY]: 'not a map' })
expect(await readArchive(KEY)).toBeUndefined()
})
})
describe('readArchiveSize', () => {
it('answers from the marker rather than reassembling the archive', async () => {
// The Downloads screen asks this for every package on every mount. At 36
// segments for the Fine tier, reassembling would be 36 record reads to
// learn a number the marker already holds.
withStore({ ...segments(0, 'one ', 'two '), ...complete(0, 2, 8) })
vi.mocked(get).mockClear()
expect(await readArchiveSize(KEY)).toBe(8)
expect(vi.mocked(get)).toHaveBeenCalledTimes(1)
})
it('measures a legacy whole-archive record', async () => {
withStore({ [KEY]: new Blob(['12345']) })
expect(await readArchiveSize(KEY)).toBe(5)
})
it('is null where there is no archive', async () => {
withStore(segments(0, 'unfinished'))
expect(await readArchiveSize(KEY)).toBeNull()
})
})
describe('readSegmentRun', () => {
it('reports the bytes and how many records they came in', async () => {
withStore(segments(0, 'aa', 'bb', 'cc'))
const run = await readSegmentRun(KEY, 0)
expect(await run.blob?.text()).toBe('aabbcc')
expect(run.count).toBe(3)
})
it('stops at the first gap, so a running transfer reads back its real prefix', async () => {
// Segments are written contiguously, so a gap can only mean the end. Reading
// past one would assemble bytes that are not adjacent in the archive.
withStore({
...segments(0, 'aa', 'bb'),
[segmentKeyFor(KEY, 0, 3)]: new Blob(['dd']),
})
const run = await readSegmentRun(KEY, 0)
expect(await run.blob?.text()).toBe('aabb')
expect(run.count).toBe(2)
})
it('reads each generation separately', async () => {
withStore({ ...segments(0, 'old'), ...segments(1, 'new') })
expect(await (await readSegments(KEY, 0))?.text()).toBe('old')
expect(await (await readSegments(KEY, 1))?.text()).toBe('new')
})
it('is empty where the generation holds nothing', async () => {
withStore(segments(0, 'only in generation zero'))
expect((await readSegmentRun(KEY, 1)).blob).toBeUndefined()
})
})
describe('markComplete', () => {
it('makes the segments the archive without copying them', async () => {
// The whole point: completion costs one small record. Copying the finished
// segments into a single archive record would need room for the archive AND
// its segments at once - #544's quota failure at 1.18 GB, the size where it
// hurts most - and would write every byte a second time.
const store = withStore(segments(0, 'one ', 'two '))
await markComplete(KEY, { generation: 0, segments: 2, totalBytes: 8 })
expect(await (await readArchive(KEY))?.text()).toBe('one two ')
const blobBytes = Object.values(store).reduce<number>(
(total, value) => total + (value instanceof Blob ? value.size : 0),
0,
)
expect(blobBytes).toBe(8)
})
it('frees the generation it replaced', async () => {
const store = withStore({
...segments(0, 'the old map'),
...segments(1, 'the new map'),
})
await markComplete(KEY, { generation: 1, segments: 1, totalBytes: 11 })
expect(store[segmentKeyFor(KEY, 0, 0)]).toBeUndefined()
expect(await (await readArchive(KEY))?.text()).toBe('the new map')
})
it('frees the legacy whole-archive record it replaced', async () => {
// Left standing it would waste up to 1.18 GB for good, since nothing would
// ever read it again.
const store = withStore({
[KEY]: new Blob(['a map from the old build']),
...segments(0, 'a map from this one'),
})
await markComplete(KEY, { generation: 0, segments: 1, totalBytes: 19 })
expect(store[KEY]).toBeUndefined()
})
it('writes the marker before freeing anything', async () => {
// Order is the correctness argument. Freeing first opens a window where the
// phone holds neither archive; this way a crash in between leaves the older
// one intact and the newer bytes as the unfinished transfer they are.
withStore({ ...segments(0, 'the old map'), ...segments(1, 'the new map') })
const order: string[] = []
vi.mocked(set).mockImplementation(async (key) => {
order.push(`set ${String(key)}`)
})
vi.mocked(del).mockImplementation(async (key) => {
order.push(`del ${String(key)}`)
})
await markComplete(KEY, { generation: 1, segments: 1, totalBytes: 11 })
expect(order[0]).toBe(`set ${KEY}:complete`)
})
})
describe('deleteArchiveRecords', () => {
it('takes both generations, the marker and the legacy record', async () => {
const store = withStore({
[KEY]: new Blob(['legacy']),
...segments(0, 'aa', 'bb'),
...segments(1, 'cc'),
...complete(0, 2, 4),
})
await deleteArchiveRecords(KEY)
expect(store).toEqual({})
})
it('deletes through a gap up to a claimed count', async () => {
// A segment write can fail on quota while earlier ones stand, so a gap is
// reachable. Probing alone stops there and would leave the rest on a phone
// whose owner is deleting a 1.18 GB map precisely to free the space (#554).
const store = withStore({
...segments(0, 'aa'),
[segmentKeyFor(KEY, 0, 2)]: new Blob(['cc']),
[segmentKeyFor(KEY, 0, 3)]: new Blob(['dd']),
})
await deleteArchiveRecords(KEY, 4)
expect(store).toEqual({})
})
it('leaves another package alone', async () => {
const other = 'ourhike:dem'
const store = withStore({
...segments(0, 'corridor'),
[segmentKeyFor(other, 0, 0)]: new Blob(['dem']),
[`${other}:complete`]: { generation: 0, segments: 1, totalBytes: 3 },
})
await deleteArchiveRecords(KEY)
expect(store[segmentKeyFor(other, 0, 0)]).toBeInstanceOf(Blob)
expect(await readComplete(other)).toEqual({
generation: 0,
segments: 1,
totalBytes: 3,
})
})
})
describe('deleteGeneration', () => {
it('takes one generation and leaves the other', async () => {
const store = withStore({ ...segments(0, 'keep'), ...segments(1, 'drop') })
await deleteGeneration(KEY, 1)
expect(store[segmentKeyFor(KEY, 0, 0)]).toBeInstanceOf(Blob)
expect(store[segmentKeyFor(KEY, 1, 0)]).toBeUndefined()
})
})
describe('writeSegment', () => {
it('appends under the generation and index it is given', async () => {
const store = withStore()
await writeSegment(KEY, 1, 4, new Blob(['bytes']))
expect(store[segmentKeyFor(KEY, 1, 4)]).toBeInstanceOf(Blob)
expect(segmentKeyFor(KEY, 1, 4)).toBe('ourhike:corridor-archive:g1:4')
})
})
describe('a torn delete cannot strand bytes (#648)', () => {
// The tear that makes gaps is a killed DELETE: it removes ascending from 0,
// so what survives is a tail behind a FRONT gap - and a probe that stops at
// the first gap concludes the generation is empty while most of it is still
// on the phone. Writes cannot make this shape; they are awaited in order.
/** A generation's tail: segments from `from` up to (not incl.) `to`. */
function tail(generation: number, from: number, to: number): Record<string, unknown> {
return Object.fromEntries(
Array.from({ length: to - from }, (_, i) => [
segmentKeyFor(KEY, generation, from + i),
new Blob(['x']),
]),
)
}
function segmentKeysIn(store: Record<string, unknown>): string[] {
return Object.keys(store).filter((key) => /:g\d+:/.test(key))
}
it('carries the replaced generation`s count in the new marker', async () => {
// The old marker is the only record of the outgoing generation's length,
// and markComplete overwrites it moments before starting the exact
// many-transaction free an OS kill interrupts. The count has to be in the
// new marker, or the crash takes it.
withStore({ ...segments(0, 'a', 'b', 'c', 'd', 'e', 'f'), ...complete(0, 6, 6) })
await markComplete(KEY, { generation: 1, segments: 2, totalBytes: 2 })
expect(await readComplete(KEY)).toEqual({
generation: 1,
segments: 2,
totalBytes: 2,
priorSegments: 6,
})
})
it('completing a download frees an old generation a previous tear already gapped', async () => {
// Generation 0 lost segments 0-1 to an interrupted free and kept 2-5.
// The marker still says six, and six is the floor the sweep probes to.
const store = withStore({
...tail(0, 2, 6),
...complete(0, 6, 6),
...segments(1, 'new'),
})
await markComplete(KEY, { generation: 1, segments: 1, totalBytes: 3 })
expect(segmentKeysIn(store)).toEqual([segmentKeyFor(KEY, 1, 0)])
})
it('deleting the archive reclaims the replaced generation through priorSegments', async () => {
// The #648 scenario end to end: the Fine-to-Standard swap's free was
// killed after segments 0-10, the marker now describes the new archive,
// and the hiker deletes the map to get the space back. The floor the
// caller can offer (the in-flight source record) knows only the NEW
// generation's count; the old one's survives in the marker.
const store = withStore({
...segments(1, 'the', 'new', 'map'),
...tail(0, 11, 36),
[`${KEY}:complete`]: {
generation: 1,
segments: 3,
totalBytes: 9,
priorSegments: 36,
},
})
await deleteArchiveRecords(KEY, 3)
expect(segmentKeysIn(store)).toEqual([])
expect(store[`${KEY}:complete`]).toBeUndefined()
})
it('reclaims a marker-less stranded tail by probing through the gap', async () => {
// A phone that tore before priorSegments existed has a tail and no record
// naming it. The delete paths tolerate a bounded run of absences rather
// than trusting the first gap, so even this state is reclaimed.
const store = withStore(tail(0, 10, 13))
await deleteArchiveRecords(KEY)
expect(segmentKeysIn(store)).toEqual([])
})
it('reads a marker without priorSegments as a marker still', async () => {
withStore({ ...segments(0, 'old build'), ...complete(0, 1, 9) })
expect(await readComplete(KEY)).toEqual({ generation: 0, segments: 1, totalBytes: 9 })
expect(await (await readArchive(KEY))?.text()).toBe('old build')
})
})