@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/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
- // the signal the read was started with, so a waiter can tell "the owner
69
- // aborted" apart from "the read genuinely failed"
70
- signal?: AbortSignal
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
- private _startChunkRead(cacheKey: string, chunk: Chunk, opts: BaseOpts) {
406
- const promise = this._readChunkFeatures(chunk, opts).then(entry => {
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> = { promise, signal: opts.signal }
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
- // Only clear our own entry: a retry may already have replaced it. `.then(f,
413
- // f)` rather than `.finally(f)` so the handler's own promise never carries
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 promise
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
- const pending = this.inFlightChunks.get(cacheKey)
450
- if (!pending) {
451
- return (await this._startChunkRead(cacheKey, chunk, opts)).features
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
- return (await pending.promise).features
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
- // The read we joined was started by another caller. If that caller
458
- // aborted and we did not, the failure is theirs and says nothing about
459
- // our query, so start over — which picks up the cache, joins a sibling's
460
- // retry, or reads under our own signal. Any other failure (and our own
461
- // abort) propagates as it would have without sharing.
462
- if (!pending.signal?.aborted || opts.signal?.aborted) {
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
- parse(opts: BaseOpts = {}): Promise<TParsed> {
125
- if (!this.setupP) {
126
- this.setupP = this._parse(opts).catch((e: unknown) => {
127
- this.setupP = undefined
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
- return this.setupP
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) {