@gmod/bam 7.8.2 → 7.9.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 +2 -2
- package/dist/bai.js +42 -17
- package/dist/bai.js.map +1 -1
- package/dist/bamFile.d.ts +5 -1
- package/dist/bamFile.js +155 -35
- package/dist/bamFile.js.map +1 -1
- package/dist/csi.js +20 -0
- package/dist/csi.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/indexFile.d.ts +32 -1
- package/dist/indexFile.js +70 -8
- package/dist/indexFile.js.map +1 -1
- package/dist/record.d.ts +16 -5
- package/dist/record.js +151 -13
- package/dist/record.js.map +1 -1
- package/dist/util.d.ts +30 -0
- package/dist/util.js +46 -13
- package/dist/util.js.map +1 -1
- package/esm/bai.js +42 -17
- package/esm/bai.js.map +1 -1
- package/esm/bamFile.d.ts +5 -1
- package/esm/bamFile.js +156 -36
- package/esm/bamFile.js.map +1 -1
- package/esm/csi.js +20 -0
- package/esm/csi.js.map +1 -1
- package/esm/index.d.ts +3 -1
- package/esm/indexFile.d.ts +32 -1
- package/esm/indexFile.js +71 -9
- package/esm/indexFile.js.map +1 -1
- package/esm/record.d.ts +16 -5
- package/esm/record.js +151 -13
- package/esm/record.js.map +1 -1
- package/esm/util.d.ts +30 -0
- package/esm/util.js +45 -13
- package/esm/util.js.map +1 -1
- package/package.json +4 -2
- package/src/bai.ts +43 -17
- package/src/bamFile.ts +177 -45
- package/src/csi.ts +20 -0
- package/src/index.ts +6 -1
- package/src/indexFile.ts +75 -9
- package/src/record.ts +152 -20
- package/src/util.ts +46 -13
package/src/bamFile.ts
CHANGED
|
@@ -7,7 +7,7 @@ import CSI from './csi.ts'
|
|
|
7
7
|
import NullFilehandle from './nullFilehandle.ts'
|
|
8
8
|
import BAMFeature from './record.ts'
|
|
9
9
|
import { parseHeaderText } from './sam.ts'
|
|
10
|
-
import { appendInRange, parseRefSeqs } from './util.ts'
|
|
10
|
+
import { appendInRange, parseRefSeqs, throwIfAborted } from './util.ts'
|
|
11
11
|
|
|
12
12
|
import type Chunk from './chunk.ts'
|
|
13
13
|
import type { BamOpts, BaseOpts } from './util.ts'
|
|
@@ -37,10 +37,13 @@ export const BAM_MAGIC = 21840194
|
|
|
37
37
|
|
|
38
38
|
const blockLen = 1 << 16
|
|
39
39
|
|
|
40
|
-
// Ceiling on the header read. A million contigs is roughly 10MB of
|
|
41
|
-
// @SQ lines and ref-seq table, so
|
|
42
|
-
// claiming a huge n_ref rather than a real one, and
|
|
43
|
-
// just downloads the file.
|
|
40
|
+
// Ceiling on GROWING the header read. A million contigs is roughly 10MB of
|
|
41
|
+
// compressed @SQ lines and ref-seq table, so a read that has doubled past this
|
|
42
|
+
// is chasing a corrupt header claiming a huge n_ref rather than a real one, and
|
|
43
|
+
// growing further just downloads the file. It does not cap the FIRST read: that
|
|
44
|
+
// length comes from the index's firstDataLine, which is a real offset rather
|
|
45
|
+
// than a guess, so a header that genuinely runs past this still gets its one
|
|
46
|
+
// exact read. See getHeaderPre.
|
|
44
47
|
const maxHeaderReadLen = 32 * 1024 * 1024
|
|
45
48
|
|
|
46
49
|
function resolveFilehandle(
|
|
@@ -62,9 +65,16 @@ interface ChunkEntry<T> {
|
|
|
62
65
|
|
|
63
66
|
interface InFlightChunk<T> {
|
|
64
67
|
promise: Promise<ChunkEntry<T>>
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
+
// Signals of the callers still waiting on this read. The read is cancelled
|
|
69
|
+
// only once every one of them has given up — see joinChunkRead and ADR 0007.
|
|
70
|
+
signals: Set<AbortSignal>
|
|
71
|
+
// true once a caller joins without a signal, which pins the read
|
|
72
|
+
pinned: boolean
|
|
73
|
+
// aborts when every caller has given up. what the read actually runs under
|
|
74
|
+
controller: AbortController
|
|
75
|
+
// aborted to take this read's listeners back off its callers' signals
|
|
76
|
+
dispose: AbortController
|
|
77
|
+
settled: boolean
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
/**
|
|
@@ -118,12 +128,24 @@ export const DEFAULT_MAX_CACHE_BYTES = 100 * 1024 * 1024
|
|
|
118
128
|
const MAX_CONCURRENT_CHUNK_READS = 6
|
|
119
129
|
|
|
120
130
|
class ChunkFeatureCache<T> {
|
|
121
|
-
|
|
131
|
+
private _maxBytes: number
|
|
122
132
|
private entries = new Map<string, ChunkEntry<T>>()
|
|
123
133
|
private bytes = 0
|
|
124
134
|
|
|
125
135
|
constructor(maxBytes: number) {
|
|
126
|
-
this.
|
|
136
|
+
this._maxBytes = maxBytes
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
get maxBytes() {
|
|
140
|
+
return this._maxBytes
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Accessor rather than a plain field so lowering the budget frees memory now.
|
|
144
|
+
// As a field, a caller trimming the cache under memory pressure got nothing
|
|
145
|
+
// back until the next chunk read happened to call set().
|
|
146
|
+
set maxBytes(maxBytes: number) {
|
|
147
|
+
this._maxBytes = maxBytes
|
|
148
|
+
this.evict()
|
|
127
149
|
}
|
|
128
150
|
|
|
129
151
|
get size() {
|
|
@@ -148,11 +170,15 @@ class ChunkFeatureCache<T> {
|
|
|
148
170
|
this.delete(key)
|
|
149
171
|
this.entries.set(key, entry)
|
|
150
172
|
this.bytes += entry.bytes
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
173
|
+
this.evict()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Evict from the least-recently-used end. The size > 1 guard means a single
|
|
177
|
+
// chunk larger than the whole budget is still kept: the caller needs it for
|
|
178
|
+
// the query in flight, and dropping it would only force a re-decompress.
|
|
179
|
+
private evict() {
|
|
154
180
|
const lru = this.entries.keys()
|
|
155
|
-
while (this.bytes > this.
|
|
181
|
+
while (this.bytes > this._maxBytes && this.entries.size > 1) {
|
|
156
182
|
this.delete(lru.next().value!)
|
|
157
183
|
}
|
|
158
184
|
}
|
|
@@ -285,16 +311,21 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
|
|
|
285
311
|
? blockLen
|
|
286
312
|
: indexData.firstDataLine.blockPosition + blockLen
|
|
287
313
|
|
|
314
|
+
// do/while, so the index-derived length is always read once and only the
|
|
315
|
+
// doubling is bounded. Testing readLen before the first read instead meant
|
|
316
|
+
// a BAM whose header genuinely exceeds maxHeaderReadLen — millions of
|
|
317
|
+
// contigs — was rejected without a single byte being fetched, and reported
|
|
318
|
+
// as 'Insufficient data for reference sequences' when the data was there.
|
|
288
319
|
let samHeader
|
|
289
|
-
let atEof
|
|
290
|
-
|
|
320
|
+
let atEof: boolean
|
|
321
|
+
do {
|
|
291
322
|
const buffer = await this.bam.read(readLen, 0, { signal: opts.signal })
|
|
292
323
|
// a short read means readLen ran past the end of the file, so there are
|
|
293
324
|
// no more bytes to grow into
|
|
294
325
|
atEof = buffer.length < readLen
|
|
295
326
|
samHeader = this.applyHeader(await unzip(buffer))
|
|
296
327
|
readLen *= 2
|
|
297
|
-
}
|
|
328
|
+
} while (samHeader === undefined && !atEof && readLen <= maxHeaderReadLen)
|
|
298
329
|
if (samHeader === undefined) {
|
|
299
330
|
throw new Error('Insufficient data for reference sequences')
|
|
300
331
|
}
|
|
@@ -368,6 +399,7 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
|
|
|
368
399
|
max: number,
|
|
369
400
|
opts?: BamOpts,
|
|
370
401
|
) {
|
|
402
|
+
throwIfAborted(opts?.signal)
|
|
371
403
|
const chrId = await this.getSeqId(chr, opts)
|
|
372
404
|
if (chrId === undefined || !this.index) {
|
|
373
405
|
return []
|
|
@@ -378,23 +410,85 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
|
|
|
378
410
|
|
|
379
411
|
// Read a chunk, publish it to the cache, and keep the in-flight promise
|
|
380
412
|
// discoverable while it runs.
|
|
381
|
-
|
|
382
|
-
|
|
413
|
+
//
|
|
414
|
+
// The read runs under this entry's own controller rather than any one
|
|
415
|
+
// caller's signal, because the read is shared: it must survive until every
|
|
416
|
+
// caller waiting on it has given up. joinChunkRead is what registers them.
|
|
417
|
+
private _startChunkRead(cacheKey: string, chunk: Chunk) {
|
|
418
|
+
const controller = new AbortController()
|
|
419
|
+
const promise = this._readChunkFeatures(chunk, {
|
|
420
|
+
signal: controller.signal,
|
|
421
|
+
}).then(entry => {
|
|
383
422
|
this.chunkFeatureCache.set(cacheKey, entry)
|
|
384
423
|
return entry
|
|
385
424
|
})
|
|
386
|
-
const inFlight: InFlightChunk<T> = {
|
|
425
|
+
const inFlight: InFlightChunk<T> = {
|
|
426
|
+
promise,
|
|
427
|
+
signals: new Set(),
|
|
428
|
+
pinned: false,
|
|
429
|
+
controller,
|
|
430
|
+
dispose: new AbortController(),
|
|
431
|
+
settled: false,
|
|
432
|
+
}
|
|
387
433
|
this.inFlightChunks.set(cacheKey, inFlight)
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
// an unhandled rejection.
|
|
434
|
+
// `.then(f, f)` rather than `.finally(f)` so the handler's own promise never
|
|
435
|
+
// carries an unhandled rejection.
|
|
391
436
|
const clear = () => {
|
|
437
|
+
inFlight.settled = true
|
|
438
|
+
// nothing reads these once the read has settled, and holding them would
|
|
439
|
+
// pin each caller's AbortController behind this entry
|
|
440
|
+
inFlight.dispose.abort()
|
|
441
|
+
inFlight.signals.clear()
|
|
442
|
+
// only clear our own entry: a later read for this chunk may have replaced it
|
|
392
443
|
if (this.inFlightChunks.get(cacheKey) === inFlight) {
|
|
393
444
|
this.inFlightChunks.delete(cacheKey)
|
|
394
445
|
}
|
|
395
446
|
}
|
|
396
447
|
promise.then(clear, clear)
|
|
397
|
-
return
|
|
448
|
+
return inFlight
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Register a caller's interest, so the read survives until that caller has
|
|
452
|
+
// given up too.
|
|
453
|
+
//
|
|
454
|
+
// A caller with no signal cannot give up, so it pins the read: there is no
|
|
455
|
+
// longer any set of aborts that should stop it. That is the honest reading of
|
|
456
|
+
// a caller that never asked to be cancellable, and it means one signal-free
|
|
457
|
+
// query makes that chunk's read uncancellable for everyone joined to it.
|
|
458
|
+
private joinChunkRead(inFlight: InFlightChunk<T>, signal?: AbortSignal) {
|
|
459
|
+
if (signal === undefined) {
|
|
460
|
+
inFlight.pinned = true
|
|
461
|
+
} else if (signal.aborted) {
|
|
462
|
+
// A caller that has already given up is not a waiter, and must not be
|
|
463
|
+
// counted as one: an `abort` listener never fires on a signal that
|
|
464
|
+
// aborted before it was added, so nothing would ever take this signal
|
|
465
|
+
// back out of the set. The count would never reach zero and the read
|
|
466
|
+
// would be uncancellable for everyone joined to it, silently.
|
|
467
|
+
//
|
|
468
|
+
// `_cachedChunkFeatures` rejects such a caller before it reaches here,
|
|
469
|
+
// with no `await` in between, so this is unreachable today. It is here
|
|
470
|
+
// because this is the bug that shipped, and an invariant that fails this
|
|
471
|
+
// quietly should not rest on a check twenty lines away. `@gmod/cram`
|
|
472
|
+
// guards the same spot for the same reason; see its ADR 0003.
|
|
473
|
+
if (!inFlight.pinned && inFlight.signals.size === 0) {
|
|
474
|
+
inFlight.controller.abort(signal.reason)
|
|
475
|
+
}
|
|
476
|
+
} else if (!inFlight.signals.has(signal)) {
|
|
477
|
+
// guarded so one signal joining twice — a viewAsPairs query reaching the
|
|
478
|
+
// same chunk for a read and for its mate — does not add two listeners
|
|
479
|
+
inFlight.signals.add(signal)
|
|
480
|
+
signal.addEventListener(
|
|
481
|
+
'abort',
|
|
482
|
+
() => {
|
|
483
|
+
inFlight.signals.delete(signal)
|
|
484
|
+
if (!inFlight.pinned && inFlight.signals.size === 0) {
|
|
485
|
+
inFlight.controller.abort(signal.reason)
|
|
486
|
+
}
|
|
487
|
+
},
|
|
488
|
+
// `once` covers the abort firing; `dispose` covers it never firing
|
|
489
|
+
{ once: true, signal: inFlight.dispose.signal },
|
|
490
|
+
)
|
|
491
|
+
}
|
|
398
492
|
}
|
|
399
493
|
|
|
400
494
|
// Parsed records for a chunk, reading and decompressing it only on a miss.
|
|
@@ -411,6 +505,15 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
|
|
|
411
505
|
chunk: Chunk,
|
|
412
506
|
opts: BaseOpts,
|
|
413
507
|
): Promise<T[]> {
|
|
508
|
+
// Before anything else, including the cache hit. A caller reaches here with
|
|
509
|
+
// a signal that has already fired on the ordinary pan — the abort lands
|
|
510
|
+
// while blocksForRange is still reading the index, and nothing between
|
|
511
|
+
// there and here looks at it, since bai.ts and csi.ts never read the
|
|
512
|
+
// signal. Such a caller must not start a read it has no interest in, and
|
|
513
|
+
// must not be registered as a waiter on someone else's: see joinChunkRead.
|
|
514
|
+
// @gmod/cram checks in exactly this position, in SliceRecordCache.getOrFill.
|
|
515
|
+
throwIfAborted(opts.signal)
|
|
516
|
+
|
|
414
517
|
const cacheKey = chunkCacheKey(chunk)
|
|
415
518
|
const cached = this.chunkFeatureCache.get(cacheKey)
|
|
416
519
|
if (cached) {
|
|
@@ -422,23 +525,35 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
|
|
|
422
525
|
// they collapse onto very few chunk keys, so without this a query pays for
|
|
423
526
|
// the same inflate several times over — the dominant cost of a cold query
|
|
424
527
|
// (ADR 0003).
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
528
|
+
let pending = this.inFlightChunks.get(cacheKey)
|
|
529
|
+
// A read every caller has abandoned is on its way out but may not have
|
|
530
|
+
// noticed yet. Start a fresh one rather than joining one already doomed.
|
|
531
|
+
if (pending?.controller.signal.aborted && !pending.settled) {
|
|
532
|
+
if (this.inFlightChunks.get(cacheKey) === pending) {
|
|
533
|
+
this.inFlightChunks.delete(cacheKey)
|
|
534
|
+
}
|
|
535
|
+
pending = undefined
|
|
536
|
+
}
|
|
537
|
+
pending ??= this._startChunkRead(cacheKey, chunk)
|
|
538
|
+
// Only a read still running has anything to cancel. Joining a settled one
|
|
539
|
+
// would add this caller to a set nothing will ever take it out of, since
|
|
540
|
+
// the entry drops its abort listeners when it settles.
|
|
541
|
+
if (!pending.settled) {
|
|
542
|
+
this.joinChunkRead(pending, opts.signal)
|
|
428
543
|
}
|
|
429
544
|
|
|
430
545
|
try {
|
|
431
|
-
|
|
546
|
+
const entry = await pending.promise
|
|
547
|
+
// the read finished, but this caller gave up while waiting for it
|
|
548
|
+
throwIfAborted(opts.signal)
|
|
549
|
+
return entry.features
|
|
432
550
|
} catch (e) {
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
throw e
|
|
440
|
-
}
|
|
441
|
-
return this._cachedChunkFeatures(chunk, opts)
|
|
551
|
+
// Prefer this caller's own cancellation to whatever the shared read
|
|
552
|
+
// reported. If we asked to stop, that is the answer we want — and when
|
|
553
|
+
// the read itself was cancelled it is because we, and everyone else,
|
|
554
|
+
// asked it to.
|
|
555
|
+
throwIfAborted(opts.signal)
|
|
556
|
+
throw e
|
|
442
557
|
}
|
|
443
558
|
}
|
|
444
559
|
|
|
@@ -499,9 +614,7 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
|
|
|
499
614
|
// unbarriered pool exactly as before. That caps the cost of being wrong
|
|
500
615
|
// at 0.92x-0.95x, against 0.82x-0.88x for barriering every wave.
|
|
501
616
|
const batch = Math.min(MAX_CONCURRENT_CHUNK_READS, chunks.length)
|
|
502
|
-
await Promise.all(
|
|
503
|
-
Array.from({ length: batch }, (_, ci) => readOne(ci)),
|
|
504
|
-
)
|
|
617
|
+
await Promise.all(Array.from({ length: batch }, (_, ci) => readOne(ci)))
|
|
505
618
|
let stopped = false
|
|
506
619
|
for (let ci = 0; ci < batch; ci++) {
|
|
507
620
|
if (isPastQuery(featureLists[ci], chrId, max)) {
|
|
@@ -615,9 +728,13 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
|
|
|
615
728
|
const mateRecs = [] as T[]
|
|
616
729
|
for (let i = 0, l = features.length; i < l; i++) {
|
|
617
730
|
const feature = features[i]!
|
|
731
|
+
// fileOffset first: it is a number already on the record, where
|
|
732
|
+
// `name` decodes a string per record. A mate chunk usually overlaps
|
|
733
|
+
// the query region, so this skips the decode for every record the
|
|
734
|
+
// caller is already holding.
|
|
618
735
|
if (
|
|
619
|
-
|
|
620
|
-
|
|
736
|
+
!readIds.has(feature.fileOffset) &&
|
|
737
|
+
readNameCounts.get(feature.name) === 1
|
|
621
738
|
) {
|
|
622
739
|
mateRecs.push(feature)
|
|
623
740
|
}
|
|
@@ -643,6 +760,21 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
|
|
|
643
760
|
signal: opts.signal,
|
|
644
761
|
},
|
|
645
762
|
)
|
|
763
|
+
// The last chance to bail before the expensive part, and the reason it is
|
|
764
|
+
// worth having: honouring the signal is optional in the filehandle.
|
|
765
|
+
// `RemoteFile` hands it to `fetch`, but `LocalFile.read(length, position)`
|
|
766
|
+
// does not even take an options argument, so every read under it runs to
|
|
767
|
+
// completion and arrives here with the cancellation unnoticed. Without this
|
|
768
|
+
// a fully abandoned chunk still gets inflated and decoded — measured at 6
|
|
769
|
+
// chunks for one 20kb query on out.bam — which is the dominant cost of a
|
|
770
|
+
// cold query (ADR 0003), spent on records nobody will ever look at.
|
|
771
|
+
//
|
|
772
|
+
// Safe precisely because this is the SHARED signal, not a caller's: it
|
|
773
|
+
// fires only once every waiter has given up, so there is never a live
|
|
774
|
+
// caller left to be denied the result. @gmod/cram checks in the same place
|
|
775
|
+
// and for the same reason, before the decode loop in `_fetchRecords`.
|
|
776
|
+
throwIfAborted(opts.signal)
|
|
777
|
+
|
|
646
778
|
const {
|
|
647
779
|
buffer: data,
|
|
648
780
|
cpositions,
|
|
@@ -691,9 +823,9 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
|
|
|
691
823
|
blockEnd,
|
|
692
824
|
hasCpositions
|
|
693
825
|
? cpositions[pos]! * (1 << 8) +
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
826
|
+
(blockStart - dpositions[pos]!) +
|
|
827
|
+
chunk.minv.dataPosition +
|
|
828
|
+
1
|
|
697
829
|
: crc32(ba.subarray(blockStart, blockEnd)) >>> 0,
|
|
698
830
|
dataView,
|
|
699
831
|
)
|
package/src/csi.ts
CHANGED
|
@@ -96,6 +96,20 @@ export default class CSI extends IndexFile {
|
|
|
96
96
|
this.maxBinNumber = ((1 << ((this.depth + 1) * 3)) - 1) / 7
|
|
97
97
|
const maxBinNumber = this.maxBinNumber
|
|
98
98
|
const auxLength = dataView.getInt32(12, true)
|
|
99
|
+
// A tabix-only branch, which is why parseAuxData and the parseNameBytes it
|
|
100
|
+
// calls show as uncovered. CSI is shared between `samtools index -c` and
|
|
101
|
+
// `tabix -C`, and the aux block is how a tabix index carries what a reader
|
|
102
|
+
// of bgzipped TEXT needs: which columns hold ref/start/end, the comment
|
|
103
|
+
// character, lines to skip, and the reference names — which a BAM takes
|
|
104
|
+
// from its own header instead. So a BAM .csi sets l_aux to 0 and never
|
|
105
|
+
// reaches here (checked: 0 of the 19 .csi fixtures carry one).
|
|
106
|
+
//
|
|
107
|
+
// Kept rather than deleted for two reasons. CSI is exported from
|
|
108
|
+
// index.ts, so pointing it at a tabix index is reachable (@gmod/tabix is
|
|
109
|
+
// the right tool, but this would half-work and then silently not).
|
|
110
|
+
// More importantly parseNameBytes is marked SYNC: with its tabix-js
|
|
111
|
+
// counterpart, and dropping one side of a deliberately-paired pair of
|
|
112
|
+
// implementations is exactly what that marker exists to prevent.
|
|
99
113
|
const aux = auxLength >= 30 ? this.parseAuxData(bytes, 16) : undefined
|
|
100
114
|
const refCount = dataView.getInt32(16 + auxLength, true)
|
|
101
115
|
|
|
@@ -200,6 +214,12 @@ export default class CSI extends IndexFile {
|
|
|
200
214
|
for (; l <= this.depth; s -= 3, t += lshift(1, l * 3), l += 1) {
|
|
201
215
|
const b = t + rshift(beg, s)
|
|
202
216
|
const e = t + rshift(end, s)
|
|
217
|
+
// Unreachable as long as the clamp above stands, which is why it shows
|
|
218
|
+
// as uncovered: with end bounded to 2^(minShift + depth*3), level l
|
|
219
|
+
// spans at most 8^l - 1 bins, so the worst case is 8^depth - 1 + depth
|
|
220
|
+
// against a maxBinNumber of (8^(depth+1) - 1)/7, i.e. about 1.14 * 8^depth.
|
|
221
|
+
// Kept as a guard on the arithmetic rather than deleted — reg2bins is
|
|
222
|
+
// shared with tabix-js, whose callers reach it by other routes.
|
|
203
223
|
if (e - b + bins.length > this.maxBinNumber) {
|
|
204
224
|
throw new Error(
|
|
205
225
|
`query ${beg}-${end} is too large for current binning scheme (shift ${this.minShift}, depth ${this.depth}), try a smaller query or a coarser index binning scheme`,
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,12 @@ export { default as CSI } from './csi.ts'
|
|
|
4
4
|
export { default as BamRecord } from './record.ts'
|
|
5
5
|
export { default as HtsgetFile } from './htsget.ts'
|
|
6
6
|
|
|
7
|
-
export type {
|
|
7
|
+
export type { NumericCigar } from './record.ts'
|
|
8
8
|
export type { BamRecordClass, BamRecordLike } from './bamFile.ts'
|
|
9
|
+
// the options every query method takes, and the shapes they hand back. The
|
|
10
|
+
// package has no subpath exports, so a consumer typing a wrapper around
|
|
11
|
+
// getRecordsForRange/indexCov can only name these if they come out of here.
|
|
12
|
+
export type { BamOpts, BaseOpts } from './util.ts'
|
|
13
|
+
export type { IndexCovEntry } from './bai.ts'
|
|
9
14
|
// for typing the HtsgetFile `fetch` option
|
|
10
15
|
export type { Fetcher } from 'generic-filehandle2'
|
package/src/indexFile.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import QuickLRU from '@jbrowse/quick-lru'
|
|
2
2
|
|
|
3
|
-
import { optimizeChunks } from './util.ts'
|
|
3
|
+
import { optimizeChunks, throwIfAborted } from './util.ts'
|
|
4
4
|
|
|
5
5
|
import type Chunk from './chunk.ts'
|
|
6
6
|
import type { BaseOpts } from './util.ts'
|
|
@@ -33,8 +33,11 @@ export function memoizeByRefId<T>(
|
|
|
33
33
|
) {
|
|
34
34
|
const cache = new QuickLRU<number, T>({ maxSize })
|
|
35
35
|
return (refId: number) => {
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
// one lookup, not has()+get(): only truthy results are ever cached, so a
|
|
37
|
+
// miss and a cached value are already distinguishable
|
|
38
|
+
const cached = cache.get(refId)
|
|
39
|
+
if (cached !== undefined) {
|
|
40
|
+
return cached
|
|
38
41
|
}
|
|
39
42
|
const result = getIndices(refId)
|
|
40
43
|
if (result) {
|
|
@@ -51,6 +54,13 @@ export default abstract class IndexFile<
|
|
|
51
54
|
public renameRefSeq: (s: string) => string
|
|
52
55
|
|
|
53
56
|
private setupP?: Promise<TParsed>
|
|
57
|
+
/**
|
|
58
|
+
* The signal `setupP` was started under, while it is still in flight. The
|
|
59
|
+
* index is parsed once and shared by every query against the file, so without
|
|
60
|
+
* this the first query to arrive would own a read all the others depend on —
|
|
61
|
+
* see {@link parse}.
|
|
62
|
+
*/
|
|
63
|
+
private setupSignal?: AbortSignal
|
|
54
64
|
|
|
55
65
|
constructor({
|
|
56
66
|
filehandle,
|
|
@@ -118,14 +128,70 @@ export default abstract class IndexFile<
|
|
|
118
128
|
return optimizeChunks(chunks, this.getLowestChunk(ba, min))
|
|
119
129
|
}
|
|
120
130
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Parse the index, or join the parse already running.
|
|
133
|
+
*
|
|
134
|
+
* The index is downloaded and parsed once for the life of this object, so it
|
|
135
|
+
* is the one read here that is shared between queries — and therefore the one
|
|
136
|
+
* place a cancellation can leak from the query that asked for it to a query
|
|
137
|
+
* that did not. `_parse` hands `opts` straight to `filehandle.readFile`, so
|
|
138
|
+
* without this the first query to arrive owns a read every other query
|
|
139
|
+
* depends on: when it pans away, every concurrent query fails with its abort.
|
|
140
|
+
*
|
|
141
|
+
* A caller that joined someone else's parse and saw it fail because *they*
|
|
142
|
+
* aborted starts over rather than inheriting the failure — once, then
|
|
143
|
+
* propagates. Bounding it at one attempt is what jbrowse's
|
|
144
|
+
* `RemoteFileWithRangeCache.joinChunk` does with the same retry one layer
|
|
145
|
+
* down, and for the reason it gives: the pathological case becomes one
|
|
146
|
+
* duplicate parse rather than a recursion whose depth depends on how the
|
|
147
|
+
* aborts interleave.
|
|
148
|
+
*
|
|
149
|
+
* A retry rather than the reference count `_cachedChunkFeatures` uses, for
|
|
150
|
+
* the reason `@gmod/cram` gives for the same split in `CraiIndex`: the index
|
|
151
|
+
* is parsed once for the life of the object, so there is no repeated waste to
|
|
152
|
+
* recover, and this is a dozen lines against restructuring the memo.
|
|
153
|
+
*/
|
|
154
|
+
async parse(opts: BaseOpts = {}, retried = false): Promise<TParsed> {
|
|
155
|
+
throwIfAborted(opts.signal)
|
|
156
|
+
const pending = this.setupP
|
|
157
|
+
if (!pending) {
|
|
158
|
+
return this.startParse(opts)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// read before awaiting: the owner is forgotten as soon as the parse settles
|
|
162
|
+
const ownerSignal = this.setupSignal
|
|
163
|
+
try {
|
|
164
|
+
return await pending
|
|
165
|
+
} catch (e) {
|
|
166
|
+
if (retried || !ownerSignal?.aborted || opts.signal?.aborted) {
|
|
125
167
|
throw e
|
|
126
|
-
}
|
|
168
|
+
}
|
|
169
|
+
return this.parse(opts, true)
|
|
127
170
|
}
|
|
128
|
-
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private startParse(opts: BaseOpts) {
|
|
174
|
+
const pending = this._parse(opts)
|
|
175
|
+
this.setupP = pending
|
|
176
|
+
this.setupSignal = opts.signal
|
|
177
|
+
// Drop a rejection rather than keeping it, so one transient failure does not
|
|
178
|
+
// poison the index for the lifetime of the file. Both branches are
|
|
179
|
+
// identity-checked so a retry started after this settles is not cleared by
|
|
180
|
+
// the attempt it already replaced.
|
|
181
|
+
pending.then(
|
|
182
|
+
() => {
|
|
183
|
+
if (this.setupP === pending) {
|
|
184
|
+
this.setupSignal = undefined
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
() => {
|
|
188
|
+
if (this.setupP === pending) {
|
|
189
|
+
this.setupP = undefined
|
|
190
|
+
this.setupSignal = undefined
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
)
|
|
194
|
+
return pending
|
|
129
195
|
}
|
|
130
196
|
|
|
131
197
|
async lineCount(refId: number, opts?: BaseOpts) {
|