@gmod/tabix 3.6.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmod/tabix",
3
- "version": "3.6.0",
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",
@@ -52,7 +52,7 @@
52
52
  ],
53
53
  "dependencies": {
54
54
  "@gmod/bgzf-filehandle": "^6.3.2",
55
- "@gmod/shared-read-cache": "^1.4.1",
55
+ "@gmod/shared-read-cache": "^1.4.4",
56
56
  "@jbrowse/quick-lru": "^7.3.5",
57
57
  "generic-filehandle2": "^2.2.1"
58
58
  },
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, throwIfAborted } from './util.ts'
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 signal `parseP` was started under, while it is still in flight. The
57
- * index is parsed once and shared by every query against the file, so without
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 parseSignal?: AbortSignal
59
+ private parseCache = new SharedReadCache<string, IndexData>({})
62
60
 
63
61
  constructor({ filehandle }: { filehandle: GenericFilehandle }) {
64
62
  this.filehandle = filehandle
@@ -158,75 +156,38 @@ export default abstract class IndexFile {
158
156
  return optimizeChunks(chunks, this.lowestOffset(ba, min, indexData))
159
157
  }
160
158
 
161
- // SYNC: ~/src/gmod/bam-js/src/indexFile.ts parse — same owner-signal
162
- // tracking and one-attempt retry, and the same reasoning below.
159
+ // SYNC: ~/src/gmod/bam-js/src/indexFile.ts parse — same shape and the same
160
+ // reasoning below.
163
161
  /**
164
162
  * Parse the index, or join the parse already running.
165
163
  *
166
164
  * The index is downloaded and parsed once for the life of this object, so it
167
165
  * is the one read here that is shared between queries — and therefore the one
168
166
  * place a cancellation can leak from the query that asked for it to a query
169
- * that did not. `_parse` hands `opts` straight to `readIndexBytes`, so
170
- * without this the first query to arrive owns a read every other query
171
- * depends on: when it pans away, every concurrent query fails with its abort.
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.
172
171
  *
173
- * A caller that joined someone else's parse and saw it fail because *they*
174
- * aborted starts over rather than inheriting the failure — once, then
175
- * propagates. Bounding it at one attempt is what jbrowse's
176
- * `RemoteFileWithRangeCache.joinChunk` does with the same retry one layer
177
- * down, and for the reason it gives: the pathological case becomes one
178
- * duplicate parse rather than a recursion whose depth depends on how the
179
- * aborts interleave.
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.
180
179
  *
181
- * A retry rather than the reference count `ChunkCache` uses, because the
182
- * index is parsed once for the life of the object: there is no repeated waste
183
- * to recover, and this is a dozen lines against restructuring the memo.
184
- * `@gmod/bam`'s `IndexFile` and `@gmod/cram`'s `CraiIndex` make the same
185
- * 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.
186
184
  *
187
185
  * @internal
188
186
  */
189
- async parse(opts: Options = {}, retried = false): Promise<IndexData> {
190
- throwIfAborted(opts.signal)
191
- const pending = this.parseP
192
- if (!pending) {
193
- return this.startParse(opts)
194
- }
195
-
196
- // read before awaiting: the owner is forgotten as soon as the parse settles
197
- const ownerSignal = this.parseSignal
198
- try {
199
- return await pending
200
- } catch (e) {
201
- if (retried || !ownerSignal?.aborted || opts.signal?.aborted) {
202
- throw e
203
- }
204
- return this.parse(opts, true)
205
- }
206
- }
207
-
208
- private startParse(opts: Options) {
209
- const pending = this._parse(opts)
210
- this.parseP = pending
211
- this.parseSignal = opts.signal
212
- // Drop a rejection rather than keeping it, so one transient failure does not
213
- // poison the index for the lifetime of the file. Both branches are
214
- // identity-checked so a retry started after this settles is not cleared by
215
- // the attempt it already replaced.
216
- pending.then(
217
- () => {
218
- if (this.parseP === pending) {
219
- this.parseSignal = undefined
220
- }
221
- },
222
- () => {
223
- if (this.parseP === pending) {
224
- this.parseP = undefined
225
- this.parseSignal = undefined
226
- }
227
- },
187
+ parse(opts: Options = {}): Promise<IndexData> {
188
+ return this.parseCache.get('index', opts.signal, signal =>
189
+ this._parse({ ...opts, signal }),
228
190
  )
229
- return pending
230
191
  }
231
192
 
232
193
  /** @internal */
@@ -4,7 +4,7 @@ import { LocalFile, RemoteFile } from 'generic-filehandle2'
4
4
 
5
5
  import CSI from './csi.ts'
6
6
  import TBI from './tbi.ts'
7
- import { optimizeChunks, throwIfAborted } from './util.ts'
7
+ import { optimizeChunks } from './util.ts'
8
8
 
9
9
  import type Chunk from './chunk.ts'
10
10
  import type IndexFile from './indexFile.ts'
@@ -277,14 +277,14 @@ export default class TabixIndexedFile {
277
277
  private filehandle: GenericFilehandle
278
278
  private index: IndexFile
279
279
  public chunkCache: SharedReadCache<Chunk, ReadChunk>
280
- private headerP?: Promise<{ header: string; skippedLines: string[] }>
281
280
  /**
282
- * The signal `headerP` was started under, while it is still in flight. The
283
- * header is parsed once and shared by every caller, so without this the first
284
- * one to arrive would own a read all the others depend on — see
285
- * {@link getParsedHeader}.
281
+ * The parsed header, as a shared read — see {@link getParsedHeader}. One
282
+ * entry, never evicted, which is what a memo is.
286
283
  */
287
- private headerSignal?: AbortSignal
284
+ private headerCache = new SharedReadCache<
285
+ string,
286
+ { header: string; skippedLines: string[] }
287
+ >({})
288
288
 
289
289
  constructor({
290
290
  path,
@@ -641,58 +641,24 @@ export default class TabixIndexedFile {
641
641
  * charge of a read every later caller joins: when it aborted, they failed
642
642
  * with its cancellation, their own signals untouched.
643
643
  *
644
- * A caller that joined someone else's parse and saw it fail because *they*
645
- * aborted starts over rather than inheriting the failure — once, then
646
- * propagates. Same bounded retry, and the same reasoning, as
647
- * `IndexFile.parse`.
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`.
648
654
  *
649
655
  * SYNC: ~/src/gmod/bam-js/src/bamFile.ts getHeader — same shape for the same
650
656
  * reason, on the header rather than the index.
651
657
  */
652
- private async getParsedHeader(
653
- opts: Options = {},
654
- retried = false,
655
- ): Promise<{ header: string; skippedLines: string[] }> {
656
- throwIfAborted(opts.signal)
657
- const pending = this.headerP
658
- if (!pending) {
659
- return this.startHeaderParse(opts)
660
- }
661
-
662
- // read before awaiting: the owner is forgotten as soon as the parse settles
663
- const ownerSignal = this.headerSignal
664
- try {
665
- return await pending
666
- } catch (e) {
667
- if (retried || !ownerSignal?.aborted || opts.signal?.aborted) {
668
- throw e
669
- }
670
- return this.getParsedHeader(opts, true)
671
- }
672
- }
673
-
674
- private startHeaderParse(opts: Options) {
675
- const pending = this.parseHeader(opts)
676
- this.headerP = pending
677
- this.headerSignal = opts.signal
678
- // Drop a rejection rather than keeping it, so one transient failure does not
679
- // poison the header for the lifetime of the file. Both branches are
680
- // identity-checked so a retry started after this settles is not cleared by
681
- // the attempt it already replaced.
682
- pending.then(
683
- () => {
684
- if (this.headerP === pending) {
685
- this.headerSignal = undefined
686
- }
687
- },
688
- () => {
689
- if (this.headerP === pending) {
690
- this.headerP = undefined
691
- this.headerSignal = undefined
692
- }
693
- },
658
+ private getParsedHeader(opts: Options = {}) {
659
+ return this.headerCache.get('header', opts.signal, signal =>
660
+ this.parseHeader({ ...opts, signal }),
694
661
  )
695
- return pending
696
662
  }
697
663
 
698
664
  /**