@gmod/bam 7.9.0 → 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/dist/bamFile.d.ts +1 -0
- package/dist/bamFile.js +115 -20
- package/dist/bamFile.js.map +1 -1
- package/dist/indexFile.d.ts +32 -1
- package/dist/indexFile.js +65 -6
- package/dist/indexFile.js.map +1 -1
- package/dist/util.d.ts +20 -0
- package/dist/util.js +28 -0
- package/dist/util.js.map +1 -1
- package/esm/bamFile.d.ts +1 -0
- package/esm/bamFile.js +116 -21
- package/esm/bamFile.js.map +1 -1
- package/esm/indexFile.d.ts +32 -1
- package/esm/indexFile.js +66 -7
- package/esm/indexFile.js.map +1 -1
- package/esm/util.d.ts +20 -0
- package/esm/util.js +27 -0
- package/esm/util.js.map +1 -1
- package/package.json +1 -2
- package/src/bamFile.ts +130 -24
- package/src/indexFile.ts +70 -7
- package/src/util.ts +28 -0
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'
|
|
@@ -65,9 +65,16 @@ interface ChunkEntry<T> {
|
|
|
65
65
|
|
|
66
66
|
interface InFlightChunk<T> {
|
|
67
67
|
promise: Promise<ChunkEntry<T>>
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
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
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
/**
|
|
@@ -392,6 +399,7 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
|
|
|
392
399
|
max: number,
|
|
393
400
|
opts?: BamOpts,
|
|
394
401
|
) {
|
|
402
|
+
throwIfAborted(opts?.signal)
|
|
395
403
|
const chrId = await this.getSeqId(chr, opts)
|
|
396
404
|
if (chrId === undefined || !this.index) {
|
|
397
405
|
return []
|
|
@@ -402,23 +410,85 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
|
|
|
402
410
|
|
|
403
411
|
// Read a chunk, publish it to the cache, and keep the in-flight promise
|
|
404
412
|
// discoverable while it runs.
|
|
405
|
-
|
|
406
|
-
|
|
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 => {
|
|
407
422
|
this.chunkFeatureCache.set(cacheKey, entry)
|
|
408
423
|
return entry
|
|
409
424
|
})
|
|
410
|
-
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
|
+
}
|
|
411
433
|
this.inFlightChunks.set(cacheKey, inFlight)
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
// an unhandled rejection.
|
|
434
|
+
// `.then(f, f)` rather than `.finally(f)` so the handler's own promise never
|
|
435
|
+
// carries an unhandled rejection.
|
|
415
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
|
|
416
443
|
if (this.inFlightChunks.get(cacheKey) === inFlight) {
|
|
417
444
|
this.inFlightChunks.delete(cacheKey)
|
|
418
445
|
}
|
|
419
446
|
}
|
|
420
447
|
promise.then(clear, clear)
|
|
421
|
-
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
|
+
}
|
|
422
492
|
}
|
|
423
493
|
|
|
424
494
|
// Parsed records for a chunk, reading and decompressing it only on a miss.
|
|
@@ -435,6 +505,15 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
|
|
|
435
505
|
chunk: Chunk,
|
|
436
506
|
opts: BaseOpts,
|
|
437
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
|
+
|
|
438
517
|
const cacheKey = chunkCacheKey(chunk)
|
|
439
518
|
const cached = this.chunkFeatureCache.get(cacheKey)
|
|
440
519
|
if (cached) {
|
|
@@ -446,23 +525,35 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
|
|
|
446
525
|
// they collapse onto very few chunk keys, so without this a query pays for
|
|
447
526
|
// the same inflate several times over — the dominant cost of a cold query
|
|
448
527
|
// (ADR 0003).
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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)
|
|
452
543
|
}
|
|
453
544
|
|
|
454
545
|
try {
|
|
455
|
-
|
|
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
|
|
456
550
|
} catch (e) {
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
throw e
|
|
464
|
-
}
|
|
465
|
-
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
|
|
466
557
|
}
|
|
467
558
|
}
|
|
468
559
|
|
|
@@ -669,6 +760,21 @@ export default class BamFile<T extends BamRecordLike = BAMFeature> {
|
|
|
669
760
|
signal: opts.signal,
|
|
670
761
|
},
|
|
671
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
|
+
|
|
672
778
|
const {
|
|
673
779
|
buffer: data,
|
|
674
780
|
cpositions,
|
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'
|
|
@@ -54,6 +54,13 @@ export default abstract class IndexFile<
|
|
|
54
54
|
public renameRefSeq: (s: string) => string
|
|
55
55
|
|
|
56
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
|
|
57
64
|
|
|
58
65
|
constructor({
|
|
59
66
|
filehandle,
|
|
@@ -121,14 +128,70 @@ export default abstract class IndexFile<
|
|
|
121
128
|
return optimizeChunks(chunks, this.getLowestChunk(ba, min))
|
|
122
129
|
}
|
|
123
130
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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) {
|
|
128
167
|
throw e
|
|
129
|
-
}
|
|
168
|
+
}
|
|
169
|
+
return this.parse(opts, true)
|
|
130
170
|
}
|
|
131
|
-
|
|
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
|
|
132
195
|
}
|
|
133
196
|
|
|
134
197
|
async lineCount(refId: number, opts?: BaseOpts) {
|
package/src/util.ts
CHANGED
|
@@ -39,6 +39,34 @@ export interface BaseOpts {
|
|
|
39
39
|
* underneath you. (The Chunk objects themselves are never mutated; a merged
|
|
40
40
|
* span produces a new instance.)
|
|
41
41
|
*/
|
|
42
|
+
/**
|
|
43
|
+
* `signal.throwIfAborted()`, without requiring either that method or `reason`.
|
|
44
|
+
*
|
|
45
|
+
* Two reasons not to call the built-in directly. It assumes a *real*
|
|
46
|
+
* `AbortSignal`, and callers pass duck-typed ones — `test/csi.test.ts` casts a
|
|
47
|
+
* bare `{ aborted }` through `as AbortSignal`, which is a fair model of what
|
|
48
|
+
* consumers do; calling a missing method there is a `TypeError` rather than the
|
|
49
|
+
* cancellation the caller asked for, which is a strictly worse failure.
|
|
50
|
+
*
|
|
51
|
+
* And it sets a browser floor. `AbortSignal.prototype.throwIfAborted` and
|
|
52
|
+
* `AbortSignal.reason` are Safari 15.4 / Chrome 100 / Firefox 97 (March 2022) —
|
|
53
|
+
* higher than anything else in this dependency tree needs, since
|
|
54
|
+
* `generic-filehandle2` only ever forwards a signal to `fetch`, and higher than
|
|
55
|
+
* every sibling gmod library, which use plain `.aborted`. Three lines here
|
|
56
|
+
* keeps that floor where it was.
|
|
57
|
+
*
|
|
58
|
+
* Faithful to the spec otherwise: an aborted signal throws its `reason`
|
|
59
|
+
* whatever that is, and only synthesizes an `AbortError` when there is none.
|
|
60
|
+
*/
|
|
61
|
+
export function throwIfAborted(signal?: AbortSignal) {
|
|
62
|
+
if (signal?.aborted) {
|
|
63
|
+
const { reason } = signal
|
|
64
|
+
throw reason === undefined
|
|
65
|
+
? new DOMException('This operation was aborted', 'AbortError')
|
|
66
|
+
: reason
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
42
70
|
export function optimizeChunks(chunks: Chunk[], lowest?: OffsetCoords) {
|
|
43
71
|
const n = chunks.length
|
|
44
72
|
if (n === 0) {
|