@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmod/tabix",
3
- "version": "3.5.7",
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.0"
57
+ "generic-filehandle2": "^2.2.1"
56
58
  },
57
59
  "devDependencies": {
58
60
  "@eslint/js": "^10.0.1",
59
- "@types/node": "^26.1.1",
61
+ "@types/node": "^26.1.2",
60
62
  "@vitest/coverage-v8": "^4.1.10",
61
- "eslint": "^10.7.0",
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.0",
68
+ "typescript": "^6.0.3",
67
69
  "typescript-eslint": "^8.65.0",
68
70
  "vitest": "^4.1.10",
69
- "webpack": "^5.108.4",
70
- "webpack-cli": "^7.2.1"
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, 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,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
- * without this the first query to arrive owns a read every other query
169
- * 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.
170
171
  *
171
- * A caller that joined someone else's parse and saw it fail because *they*
172
- * aborted starts over rather than inheriting the failure — once, then
173
- * propagates. Bounding it at one attempt is what jbrowse's
174
- * `RemoteFileWithRangeCache.joinChunk` does with the same retry one layer
175
- * down, and for the reason it gives: the pathological case becomes one
176
- * duplicate parse rather than a recursion whose depth depends on how the
177
- * 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.
178
179
  *
179
- * A retry rather than the reference count `chunkCache` gets from
180
- * `@gmod/abortable-promise-cache`, because the index is parsed once for the
181
- * life of the object: there is no repeated waste to recover, and this is a
182
- * dozen lines against restructuring the memo. `@gmod/bam`'s `IndexFile` and
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
- async parse(opts: Options = {}, retried = false): Promise<IndexData> {
188
- throwIfAborted(opts.signal)
189
- const pending = this.parseP
190
- if (!pending) {
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 */
@@ -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
- const DEFAULT_CHUNK_CACHE_BYTES = 100 * 2 ** 20
33
-
34
- // The entry type AbortablePromiseCache stores in its backing cache. Not exported
35
- // by the package, so it is recovered from the constructor signature rather than
36
- // restated (and left to drift) here.
37
- type CacheEntry = NonNullable<
38
- ReturnType<
39
- ConstructorParameters<
40
- typeof AbortablePromiseCache<Chunk, ReadChunk>
41
- >[0]['cache']['get']
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
- // Evict from the least-recently-used end. The size > 1 guard means a single
122
- // chunk larger than the whole budget is still kept: the caller needs it for
123
- // the query in flight, and dropping it would only force a re-decompress.
124
- private evict() {
125
- const lru = this.entries.keys()
126
- while (this.bytes > this.maxBytes && this.entries.size > 1) {
127
- this.delete(lru.next().value!)
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
- private chunkCache: AbortablePromiseCache<Chunk, ReadChunk>
357
- private headerP?: Promise<{ header: string; skippedLines: string[] }>
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
- /** budget for the decompressed chunk cache, in bytes */
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 AbortablePromiseCache<Chunk, ReadChunk>({
396
- cache: new ByteBoundedChunkCache(chunkCacheSize),
397
- fill: (args: Chunk, signal?: AbortSignal) =>
398
- this.readChunk(args, { signal }),
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.toString(), c, signal)
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
- private async getParsedHeader(opts: Options = {}) {
674
- this.headerP ??= this.parseHeader(opts).catch((error: unknown) => {
675
- this.headerP = undefined
676
- throw error
677
- })
678
- return this.headerP
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
- * `signal.throwIfAborted()`, without requiring either that method or `reason`.
10
- *
11
- * Two reasons not to call the built-in directly. It assumes a *real*
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