@gmod/tabix 3.5.7 → 3.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -12
- package/dist/indexFile.d.ts +19 -23
- package/dist/indexFile.js +23 -67
- package/dist/indexFile.js.map +1 -1
- package/dist/tabix-bundle.js +1 -1
- package/dist/tabixIndexedFile.d.ts +57 -4
- package/dist/tabixIndexedFile.js +72 -85
- package/dist/tabixIndexedFile.js.map +1 -1
- package/dist/util.d.ts +1 -18
- package/dist/util.js +6 -31
- package/dist/util.js.map +1 -1
- package/esm/indexFile.d.ts +19 -23
- package/esm/indexFile.js +24 -68
- package/esm/indexFile.js.map +1 -1
- package/esm/tabixIndexedFile.d.ts +57 -4
- package/esm/tabixIndexedFile.js +72 -85
- package/esm/tabixIndexedFile.js.map +1 -1
- package/esm/util.d.ts +1 -18
- package/esm/util.js +4 -30
- package/esm/util.js.map +1 -1
- package/package.json +11 -9
- package/src/indexFile.ts +26 -68
- package/src/tabixIndexedFile.ts +96 -113
- package/src/util.ts +4 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gmod/tabix",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.1",
|
|
4
4
|
"packageManager": "pnpm@11.15.1",
|
|
5
5
|
"description": "Read Tabix-indexed files, supports both .tbi and .csi indexes",
|
|
6
6
|
"type": "module",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"license": "MIT",
|
|
14
14
|
"repository": {
|
|
15
15
|
"type": "git",
|
|
16
|
-
"url": "https://github.com/GMOD/tabix-js.git"
|
|
16
|
+
"url": "git+https://github.com/GMOD/tabix-js.git"
|
|
17
17
|
},
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Robert Buels",
|
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
"test": "vitest",
|
|
30
30
|
"clean": "rimraf dist esm",
|
|
31
31
|
"format": "prettier --write .",
|
|
32
|
+
"format:check": "prettier --check .",
|
|
32
33
|
"lint": "eslint --report-unused-disable-directives --max-warnings 0",
|
|
34
|
+
"typecheck": "tsc --noEmit -p tsconfig.lint.json",
|
|
33
35
|
"prebuild": "pnpm clean",
|
|
34
36
|
"build:esm": "tsc --outDir esm",
|
|
35
37
|
"build:es5": "tsc --module commonjs --moduleResolution bundler --outDir dist",
|
|
@@ -49,25 +51,25 @@
|
|
|
49
51
|
"genomics"
|
|
50
52
|
],
|
|
51
53
|
"dependencies": {
|
|
52
|
-
"@gmod/abortable-promise-cache": "^3.0.4",
|
|
53
54
|
"@gmod/bgzf-filehandle": "^6.3.2",
|
|
55
|
+
"@gmod/shared-read-cache": "^1.4.4",
|
|
54
56
|
"@jbrowse/quick-lru": "^7.3.5",
|
|
55
|
-
"generic-filehandle2": "^2.2.
|
|
57
|
+
"generic-filehandle2": "^2.2.1"
|
|
56
58
|
},
|
|
57
59
|
"devDependencies": {
|
|
58
60
|
"@eslint/js": "^10.0.1",
|
|
59
|
-
"@types/node": "^26.1.
|
|
61
|
+
"@types/node": "^26.1.2",
|
|
60
62
|
"@vitest/coverage-v8": "^4.1.10",
|
|
61
|
-
"eslint": "^10.
|
|
63
|
+
"eslint": "^10.8.0",
|
|
62
64
|
"eslint-plugin-import-x": "^4.17.1",
|
|
63
65
|
"git-cliff": "^2.13.1",
|
|
64
66
|
"prettier": "^3.9.6",
|
|
65
67
|
"rimraf": "^6.1.3",
|
|
66
|
-
"typescript": "^6.0.
|
|
68
|
+
"typescript": "^6.0.3",
|
|
67
69
|
"typescript-eslint": "^8.65.0",
|
|
68
70
|
"vitest": "^4.1.10",
|
|
69
|
-
"webpack": "^5.
|
|
70
|
-
"webpack-cli": "^7.2.
|
|
71
|
+
"webpack": "^5.109.2",
|
|
72
|
+
"webpack-cli": "^7.2.2"
|
|
71
73
|
},
|
|
72
74
|
"publishConfig": {
|
|
73
75
|
"access": "public"
|
package/src/indexFile.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { unzip } from '@gmod/bgzf-filehandle'
|
|
2
|
+
import { SharedReadCache } from '@gmod/shared-read-cache'
|
|
2
3
|
|
|
3
|
-
import { optimizeChunks
|
|
4
|
+
import { optimizeChunks } from './util.ts'
|
|
4
5
|
|
|
5
6
|
import type Chunk from './chunk.ts'
|
|
6
7
|
import type VirtualOffset from './virtualOffset.ts'
|
|
@@ -51,14 +52,11 @@ export interface IndexData {
|
|
|
51
52
|
|
|
52
53
|
export default abstract class IndexFile {
|
|
53
54
|
public filehandle: GenericFilehandle
|
|
54
|
-
private parseP?: Promise<IndexData>
|
|
55
55
|
/**
|
|
56
|
-
* The
|
|
57
|
-
*
|
|
58
|
-
* this the first query to arrive would own a read all the others depend on —
|
|
59
|
-
* see {@link parse}.
|
|
56
|
+
* The parsed index, as a shared read — see {@link parse}. One entry, never
|
|
57
|
+
* evicted, which is what a memo is.
|
|
60
58
|
*/
|
|
61
|
-
private
|
|
59
|
+
private parseCache = new SharedReadCache<string, IndexData>({})
|
|
62
60
|
|
|
63
61
|
constructor({ filehandle }: { filehandle: GenericFilehandle }) {
|
|
64
62
|
this.filehandle = filehandle
|
|
@@ -158,78 +156,38 @@ export default abstract class IndexFile {
|
|
|
158
156
|
return optimizeChunks(chunks, this.lowestOffset(ba, min, indexData))
|
|
159
157
|
}
|
|
160
158
|
|
|
159
|
+
// SYNC: ~/src/gmod/bam-js/src/indexFile.ts parse — same shape and the same
|
|
160
|
+
// reasoning below.
|
|
161
161
|
/**
|
|
162
162
|
* Parse the index, or join the parse already running.
|
|
163
163
|
*
|
|
164
164
|
* The index is downloaded and parsed once for the life of this object, so it
|
|
165
165
|
* is the one read here that is shared between queries — and therefore the one
|
|
166
166
|
* place a cancellation can leak from the query that asked for it to a query
|
|
167
|
-
* that did not. `_parse` hands `opts` straight to `readIndexBytes`, so
|
|
168
|
-
*
|
|
169
|
-
* depends on: when it pans away, every concurrent query fails
|
|
167
|
+
* that did not. `_parse` hands `opts` straight to `readIndexBytes`, so a bare
|
|
168
|
+
* memoized promise makes the first query to arrive the owner of a read every
|
|
169
|
+
* other query depends on: when it pans away, every concurrent query fails
|
|
170
|
+
* with its abort.
|
|
170
171
|
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
172
|
+
* The same cache `ChunkCache` uses, for the same reason and with the same
|
|
173
|
+
* rule: the parse runs under a signal of its own and is cancelled only once
|
|
174
|
+
* every caller waiting on it has given up, so one query's abort is reported
|
|
175
|
+
* to that query alone and a bystander gets the parse already in flight rather
|
|
176
|
+
* than having to re-read the index. A rejection is dropped rather than
|
|
177
|
+
* cached, so a transient failure does not poison the index for the life of
|
|
178
|
+
* the file.
|
|
178
179
|
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
* `@gmod/cram`'s `CraiIndex` make the same split for the same reason.
|
|
180
|
+
* The fill is per call rather than on the cache so that the caller who starts
|
|
181
|
+
* the parse has its `onProgress` reach `readIndexBytes` — the index is a
|
|
182
|
+
* whole-file read, and a determinate "downloading index" bar is what that
|
|
183
|
+
* callback exists for.
|
|
184
184
|
*
|
|
185
185
|
* @internal
|
|
186
186
|
*/
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
return this.startParse(opts)
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// read before awaiting: the owner is forgotten as soon as the parse settles
|
|
195
|
-
const ownerSignal = this.parseSignal
|
|
196
|
-
try {
|
|
197
|
-
return await pending
|
|
198
|
-
} catch (e) {
|
|
199
|
-
if (retried || !ownerSignal?.aborted || opts.signal?.aborted) {
|
|
200
|
-
throw e
|
|
201
|
-
}
|
|
202
|
-
return this.parse(opts, true)
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
private startParse(opts: Options) {
|
|
207
|
-
const pending = this._parse(opts)
|
|
208
|
-
this.parseP = pending
|
|
209
|
-
this.parseSignal = opts.signal
|
|
210
|
-
// Drop a rejection rather than keeping it, so one transient failure does not
|
|
211
|
-
// poison the index for the lifetime of the file. Identity-checked so a retry
|
|
212
|
-
// started after this settles is not cleared by the attempt it replaced.
|
|
213
|
-
//
|
|
214
|
-
// Written as one try/catch rather than `.then(onFulfilled, onRejected)`
|
|
215
|
-
// because `unicorn/prefer-then-catch` rewrites the two-argument form to
|
|
216
|
-
// `.then(...).catch(...)`, which is not the same thing — that catch would
|
|
217
|
-
// also swallow anything the fulfilment handler threw.
|
|
218
|
-
void (async () => {
|
|
219
|
-
let failed = false
|
|
220
|
-
try {
|
|
221
|
-
await pending
|
|
222
|
-
} catch {
|
|
223
|
-
failed = true
|
|
224
|
-
}
|
|
225
|
-
if (this.parseP === pending) {
|
|
226
|
-
if (failed) {
|
|
227
|
-
this.parseP = undefined
|
|
228
|
-
}
|
|
229
|
-
this.parseSignal = undefined
|
|
230
|
-
}
|
|
231
|
-
})()
|
|
232
|
-
return pending
|
|
187
|
+
parse(opts: Options = {}): Promise<IndexData> {
|
|
188
|
+
return this.parseCache.get('index', opts.signal, signal =>
|
|
189
|
+
this._parse({ ...opts, signal }),
|
|
190
|
+
)
|
|
233
191
|
}
|
|
234
192
|
|
|
235
193
|
/** @internal */
|
package/src/tabixIndexedFile.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import AbortablePromiseCache from '@gmod/abortable-promise-cache'
|
|
2
1
|
import { unzip, unzipChunkSlice } from '@gmod/bgzf-filehandle'
|
|
2
|
+
import { SharedReadCache } from '@gmod/shared-read-cache'
|
|
3
3
|
import { LocalFile, RemoteFile } from 'generic-filehandle2'
|
|
4
4
|
|
|
5
5
|
import CSI from './csi.ts'
|
|
@@ -29,105 +29,28 @@ const MAX_READ_AHEAD_CHUNKS = 6
|
|
|
29
29
|
// a dense VCF (test/data/1kg.chr1.subset.vcf.gz — 213MB over 600kb of chr1)
|
|
30
30
|
// has single index bins of 17MB compressed, 120MB decompressed. Panning it
|
|
31
31
|
// under the old 80-entry cache peaked at 2GB RSS.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
interface CachedChunk {
|
|
46
|
-
entry: CacheEntry
|
|
47
|
-
/** 0 until the read settles and the decompressed size is known */
|
|
48
|
-
bytes: number
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Backing store for the chunk cache, bounded by the decompressed size of the
|
|
53
|
-
* chunks it holds rather than by entry count.
|
|
54
|
-
*
|
|
55
|
-
* A chunk's size is only known once its read settles, so `set` records the
|
|
56
|
-
* entry immediately and charges it to the budget later. Unsettled entries are
|
|
57
|
-
* therefore free, which is what we want: they are reads a query is waiting on.
|
|
58
|
-
*/
|
|
59
|
-
class ByteBoundedChunkCache {
|
|
60
|
-
private entries = new Map<string, CachedChunk>()
|
|
61
|
-
private bytes = 0
|
|
62
|
-
private maxBytes: number
|
|
63
|
-
|
|
64
|
-
constructor(maxBytes: number) {
|
|
65
|
-
this.maxBytes = maxBytes
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
get byteSize() {
|
|
69
|
-
return this.bytes
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
get size() {
|
|
73
|
-
return this.entries.size
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
has(key: string) {
|
|
77
|
-
return this.entries.has(key)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
get(key: string) {
|
|
81
|
-
const cached = this.entries.get(key)
|
|
82
|
-
if (cached) {
|
|
83
|
-
// re-insert so Map iteration order stays least-recently-used first
|
|
84
|
-
this.entries.delete(key)
|
|
85
|
-
this.entries.set(key, cached)
|
|
86
|
-
}
|
|
87
|
-
return cached?.entry
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
set(key: string, entry: CacheEntry) {
|
|
91
|
-
this.delete(key)
|
|
92
|
-
const cached = { entry, bytes: 0 }
|
|
93
|
-
this.entries.set(key, cached)
|
|
94
|
-
void entry.promise
|
|
95
|
-
.then(chunk => {
|
|
96
|
-
// a later set() may have replaced this key while the read was in
|
|
97
|
-
// flight; charging these bytes to it would then double-count
|
|
98
|
-
if (this.entries.get(key) === cached) {
|
|
99
|
-
cached.bytes = chunk.buffer.byteLength
|
|
100
|
-
this.bytes += cached.bytes
|
|
101
|
-
this.evict()
|
|
102
|
-
}
|
|
103
|
-
})
|
|
104
|
-
.catch(() => {
|
|
105
|
-
// a failed or aborted read caches nothing and costs nothing
|
|
106
|
-
})
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
delete(key: string) {
|
|
110
|
-
const cached = this.entries.get(key)
|
|
111
|
-
if (cached) {
|
|
112
|
-
this.entries.delete(key)
|
|
113
|
-
this.bytes -= cached.bytes
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
keys() {
|
|
118
|
-
return this.entries.keys()
|
|
119
|
-
}
|
|
32
|
+
//
|
|
33
|
+
// That 120MB figure is also why this is no longer 100MB. A budget below one
|
|
34
|
+
// query's working set does not cache less, it caches NOTHING: each entry is
|
|
35
|
+
// evicted before the next pan can reuse it, so the hit rate is zero and the
|
|
36
|
+
// decompress is paid again every time. On that same fixture, a six-window 50kb
|
|
37
|
+
// pan measured 17 refills out of 17 — a total miss — at 100MB, against 0 at
|
|
38
|
+
// 800MB, and 2596ms against 600ms. The working set plateaus at 497MB held, so
|
|
39
|
+
// 1GB clears it with headroom and nothing above 800MB buys anything.
|
|
40
|
+
//
|
|
41
|
+
// Affordable as a ceiling only because of the idle timeout below: it is a peak
|
|
42
|
+
// under panning, not a level a parked consumer holds. A small file is
|
|
43
|
+
// unaffected either way, this being a ceiling and not an allocation.
|
|
44
|
+
const DEFAULT_CHUNK_CACHE_BYTES = 1024 * 2 ** 20
|
|
120
45
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
}
|
|
46
|
+
// SYNC: ~/src/gmod/bam-js/src/bamFile.ts DEFAULT_CACHE_IDLE_TIMEOUT_MS
|
|
47
|
+
//
|
|
48
|
+
// Drop a chunk nothing has looked at for three minutes. The budget above is
|
|
49
|
+
// only applied when a read settles, so it does nothing at all for a consumer
|
|
50
|
+
// sitting still — and a genome browser holds one of these per track for as long
|
|
51
|
+
// as the track is open. Timed from the last read, not the fetch, so panning
|
|
52
|
+
// back and forth over one region never expires it.
|
|
53
|
+
const DEFAULT_CHUNK_CACHE_IDLE_TIMEOUT_MS = 3 * 60 * 1000
|
|
131
54
|
|
|
132
55
|
type GetLinesCallback = (
|
|
133
56
|
line: string,
|
|
@@ -353,8 +276,15 @@ function parseIntFromBytes(buffer: Uint8Array, start: number, end: number) {
|
|
|
353
276
|
export default class TabixIndexedFile {
|
|
354
277
|
private filehandle: GenericFilehandle
|
|
355
278
|
private index: IndexFile
|
|
356
|
-
|
|
357
|
-
|
|
279
|
+
public chunkCache: SharedReadCache<Chunk, ReadChunk>
|
|
280
|
+
/**
|
|
281
|
+
* The parsed header, as a shared read — see {@link getParsedHeader}. One
|
|
282
|
+
* entry, never evicted, which is what a memo is.
|
|
283
|
+
*/
|
|
284
|
+
private headerCache = new SharedReadCache<
|
|
285
|
+
string,
|
|
286
|
+
{ header: string; skippedLines: string[] }
|
|
287
|
+
>({})
|
|
358
288
|
|
|
359
289
|
constructor({
|
|
360
290
|
path,
|
|
@@ -367,6 +297,7 @@ export default class TabixIndexedFile {
|
|
|
367
297
|
csiUrl,
|
|
368
298
|
csiFilehandle,
|
|
369
299
|
chunkCacheSize = DEFAULT_CHUNK_CACHE_BYTES,
|
|
300
|
+
chunkCacheIdleTimeoutMs = DEFAULT_CHUNK_CACHE_IDLE_TIMEOUT_MS,
|
|
370
301
|
}: {
|
|
371
302
|
path?: string
|
|
372
303
|
filehandle?: GenericFilehandle
|
|
@@ -377,8 +308,24 @@ export default class TabixIndexedFile {
|
|
|
377
308
|
csiPath?: string
|
|
378
309
|
csiUrl?: string
|
|
379
310
|
csiFilehandle?: GenericFilehandle
|
|
380
|
-
/**
|
|
311
|
+
/**
|
|
312
|
+
* Budget for the decompressed chunk cache, in bytes. Default 1GB.
|
|
313
|
+
*
|
|
314
|
+
* A retention bound, not a bound on peak memory: reads in flight are never
|
|
315
|
+
* evicted and the last settled entry is kept whatever the budget. Size it
|
|
316
|
+
* to hold several queries — below one query's working set the hit rate
|
|
317
|
+
* drops to zero while the memory is retained anyway, so a number between
|
|
318
|
+
* the two is the worst available choice.
|
|
319
|
+
*/
|
|
381
320
|
chunkCacheSize?: number
|
|
321
|
+
/**
|
|
322
|
+
* Drop a cached chunk once nothing has read it for this many milliseconds.
|
|
323
|
+
* Default 3 minutes; `0` keeps chunks until `chunkCacheSize` evicts them.
|
|
324
|
+
*
|
|
325
|
+
* The only thing that lowers the cache while nothing is happening, and what
|
|
326
|
+
* makes the budget above a peak rather than a resting level.
|
|
327
|
+
*/
|
|
328
|
+
chunkCacheIdleTimeoutMs?: number
|
|
382
329
|
}) {
|
|
383
330
|
this.filehandle = resolveFilehandle(filehandle, path, url)
|
|
384
331
|
this.index = resolveIndex({
|
|
@@ -392,13 +339,29 @@ export default class TabixIndexedFile {
|
|
|
392
339
|
url,
|
|
393
340
|
})
|
|
394
341
|
|
|
395
|
-
this.chunkCache = new
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
342
|
+
this.chunkCache = new SharedReadCache<Chunk, ReadChunk>({
|
|
343
|
+
maxSize: chunkCacheSize,
|
|
344
|
+
// decompressed bytes, not entry count: we fetch compressed and cache
|
|
345
|
+
// decompressed, and an entry is a whole chunk
|
|
346
|
+
sizeOf: read => read.buffer.byteLength,
|
|
347
|
+
cacheKey: chunk => chunk.toString(),
|
|
348
|
+
idleTimeoutMs: chunkCacheIdleTimeoutMs,
|
|
349
|
+
fill: (chunk, signal) => this.readChunk(chunk, { signal }),
|
|
399
350
|
})
|
|
400
351
|
}
|
|
401
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Drops every decompressed chunk held by the cache, and stops the idle sweep
|
|
355
|
+
* until something is cached again.
|
|
356
|
+
*
|
|
357
|
+
* `chunkCacheIdleTimeoutMs` reclaims a view the user has wandered away from;
|
|
358
|
+
* this is for a consumer that knows it is finished — a closed track, a
|
|
359
|
+
* changed assembly — and should not have to wait it out.
|
|
360
|
+
*/
|
|
361
|
+
clearChunkCache() {
|
|
362
|
+
this.chunkCache.clear()
|
|
363
|
+
}
|
|
364
|
+
|
|
402
365
|
/**
|
|
403
366
|
* Estimates the compressed byte size of the index chunks covering the given
|
|
404
367
|
* regions. Useful for byte budgeting before issuing a `getLines` call to
|
|
@@ -505,7 +468,7 @@ export default class TabixIndexedFile {
|
|
|
505
468
|
const ensureReadsStarted = (count: number) => {
|
|
506
469
|
while (reads.length < Math.min(count, chunks.length)) {
|
|
507
470
|
const c = chunks[reads.length]!
|
|
508
|
-
const read = this.chunkCache.get(c
|
|
471
|
+
const read = this.chunkCache.get(c, signal)
|
|
509
472
|
void read.catch(() => {
|
|
510
473
|
// a prefetch the early return skips is never awaited, so swallow its
|
|
511
474
|
// rejection here rather than let it surface unhandled
|
|
@@ -670,12 +633,32 @@ export default class TabixIndexedFile {
|
|
|
670
633
|
}
|
|
671
634
|
}
|
|
672
635
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
636
|
+
/**
|
|
637
|
+
* Parse the header, or join the parse already running.
|
|
638
|
+
*
|
|
639
|
+
* `parseHeader` threads `opts` into both `getMetadata` and the header read,
|
|
640
|
+
* so memoizing it on the first caller's opts put that caller's signal in
|
|
641
|
+
* charge of a read every later caller joins: when it aborted, they failed
|
|
642
|
+
* with its cancellation, their own signals untouched.
|
|
643
|
+
*
|
|
644
|
+
* The same cache the chunk reads use, and the same rule: the parse runs under
|
|
645
|
+
* a signal of its own and is cancelled only once every caller waiting on it
|
|
646
|
+
* has given up, so one caller's abort is reported to that caller alone and a
|
|
647
|
+
* bystander gets the parse already in flight. There is no retry here because
|
|
648
|
+
* there is nothing to retry — the parse a bystander joined is not cancelled by
|
|
649
|
+
* someone else's abort. A rejection is dropped rather than cached, so a
|
|
650
|
+
* transient failure does not poison the header for the life of the file.
|
|
651
|
+
*
|
|
652
|
+
* The fill is per call rather than on the cache, so the caller that starts the
|
|
653
|
+
* parse has its `onProgress` reach the index download inside `getMetadata`.
|
|
654
|
+
*
|
|
655
|
+
* SYNC: ~/src/gmod/bam-js/src/bamFile.ts getHeader — same shape for the same
|
|
656
|
+
* reason, on the header rather than the index.
|
|
657
|
+
*/
|
|
658
|
+
private getParsedHeader(opts: Options = {}) {
|
|
659
|
+
return this.headerCache.get('header', opts.signal, signal =>
|
|
660
|
+
this.parseHeader({ ...opts, signal }),
|
|
661
|
+
)
|
|
679
662
|
}
|
|
680
663
|
|
|
681
664
|
/**
|
package/src/util.ts
CHANGED
|
@@ -5,36 +5,10 @@ import { longFromBytesToUnsigned } from './long.ts'
|
|
|
5
5
|
import VirtualOffset from './virtualOffset.ts'
|
|
6
6
|
|
|
7
7
|
// SYNC: ~/src/gmod/bam-js/src/util.ts optimizeChunks
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
* `AbortSignal`, and callers pass duck-typed ones, where calling a missing
|
|
13
|
-
* method is a `TypeError` rather than the cancellation the caller asked for —
|
|
14
|
-
* a strictly worse failure.
|
|
15
|
-
*
|
|
16
|
-
* And it sets a browser floor. `AbortSignal.prototype.throwIfAborted` and
|
|
17
|
-
* `AbortSignal.reason` are Safari 15.4 / Chrome 100 / Firefox 97 (March 2022),
|
|
18
|
-
* higher than anything else here needs: this package otherwise touches only
|
|
19
|
-
* `.aborted`, and `generic-filehandle2` only forwards a signal to `fetch`.
|
|
20
|
-
*
|
|
21
|
-
* Faithful to the spec otherwise: an aborted signal throws its `reason`
|
|
22
|
-
* whatever that is, and only synthesizes an `AbortError` when there is none.
|
|
23
|
-
* Kept in sync with the copy in `@gmod/bam`'s `src/util.ts`.
|
|
24
|
-
*/
|
|
25
|
-
export function throwIfAborted(signal?: AbortSignal) {
|
|
26
|
-
if (signal?.aborted) {
|
|
27
|
-
const reason: unknown = signal.reason
|
|
28
|
-
// Spec-faithful: throwIfAborted throws `reason` verbatim, and `reason` is
|
|
29
|
-
// whatever the caller passed to abort() — `controller.abort('too slow')`
|
|
30
|
-
// makes it a string. Coercing it to an Error here would hide that from a
|
|
31
|
-
// consumer who set it deliberately.
|
|
32
|
-
// eslint-disable-next-line @typescript-eslint/only-throw-error
|
|
33
|
-
throw reason === undefined
|
|
34
|
-
? new DOMException('This operation was aborted', 'AbortError')
|
|
35
|
-
: reason
|
|
36
|
-
}
|
|
37
|
-
}
|
|
8
|
+
// Re-exported so the internal import path stays './util.ts'. The
|
|
9
|
+
// implementation moved to @gmod/shared-read-cache, which needs it anyway --
|
|
10
|
+
// every consumer of that package was carrying an identical copy.
|
|
11
|
+
export { throwIfAborted } from '@gmod/shared-read-cache'
|
|
38
12
|
|
|
39
13
|
export function optimizeChunks(chunks: Chunk[], lowest?: VirtualOffset) {
|
|
40
14
|
const n = chunks.length
|