All files / src/annotations/worker cachedFile.ts

100% Statements 96/96
85.36% Branches 35/41
100% Functions 7/7
100% Lines 96/96

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155                                                            1x     1x     1x       1x   1x   1x 1x   1x   1x 34x 34x 34x 34x 34x 34x 34x     34x   34x   1x 97x 96x 96x         96x 96x 96x 97x 126x 126x 80x 80x 80x 80x 80x 114x 126x 97x 97x 35x 35x 35x 35x 46x 46x 35x 35x 35x 35x 96x   96x 96x 97x 126x 126x 126x 126x 126x 126x 126x 126x 126x     126x 126x 97x 97x   1x 35x 35x 35x 35x 35x 35x 35x 35x 35x 46x 46x     46x 46x 35x   1x 46x 46x 46x 46x 46x 3x 3x 3x 3x 3x 46x     1x 126x 126x 126x 1x  
/**
 * A remote file handle that reads in chunks and keeps them.
 *
 * ## Why
 *
 * `generic-filehandle2`'s `RemoteFile` turns every `read` into one ranged
 * request, and the readers behind it read the way a library reads: a bigWig's
 * header is four requests of 64, 2,000, 32 and 292 bytes, and the file is
 * opened three times on the way to a browser โ€” once to ask what genome it is
 * on, once to load it as a track, once per window after that. Over a bucket
 * next door that is a few tens of milliseconds a request and nobody notices;
 * over the Drive API, where a request is the better part of a second and is
 * metered, it is the difference between a file that opens and one a reader
 * gives up on (ยง24.9).
 *
 * So this handle reads whole chunks and keeps them, exactly as the main
 * thread's `RemoteRangeSource` does for a contact map and for the same reason:
 * the small reads a parser makes land in one chunk, and the second and third
 * open of the same file read nothing at all. One handle per address, held by
 * `annotations.worker.ts`, is what makes the three opens one.
 *
 * ## What it does not do
 *
 * `readFile` is left to the base class: a whole-file download is already one
 * request, and the bytes it hands back are the caller's to keep. And a read
 * past the end is not an error โ€” the base class answers a short read for a
 * range that runs off the file, and that short answer is how the size is
 * learnt here without a `HEAD`.
 */
 
import { RemoteFile } from 'generic-filehandle2'
 
/** 256 KiB: a bigWig's header, chromosome tree and zoom headers in one, and still small on a slow link. */
export const CHUNK_BYTES = 1 << 18
 
/** Chunks kept per handle. 64 MiB, the bound `RemoteRangeSource` keeps too. */
const BUDGET_BYTES = 64 << 20
 
type Options = ConstructorParameters<typeof RemoteFile>[1]
 
export class CachedRemoteFile extends RemoteFile {
  /** Cached chunks by index, insertion-ordered so the oldest is first to go. */
  private readonly chunks = new Map<number, Uint8Array>()
  /** Fetches in flight, by every chunk index each covers, so a second reader joins rather than repeats. */
  private readonly inflight = new Map<number, Promise<void>>()
  private held = 0
  /** The file's length, once a read has run off its end. */
  private end: number | null = null
 
  constructor(
    url: string,
    options?: Options,
    private readonly chunkBytes = CHUNK_BYTES,
    private readonly budgetBytes = BUDGET_BYTES,
  ) {
    super(url, options)
  }
 
  /** Bytes read from the host so far, for a test to count. */
  fetched = 0
  /** Requests made to the host so far, for a test to count. */
  requests = 0
 
  override async read(length: number, position: number, opts?: Parameters<RemoteFile['read']>[2]): Promise<Uint8Array<ArrayBuffer>> {
    if (length <= 0 || (this.end !== null && position >= this.end)) return new Uint8Array(0)
    const first = Math.floor(position / this.chunkBytes)
    const last = Math.floor((position + length - 1) / this.chunkBytes)
 
    // Missing chunks, in runs, so a read spanning many is one request rather
    // than one per chunk โ€” and chunks another read is already fetching are
    // waited for rather than fetched twice.
    const waits: Promise<void>[] = []
    let runFrom: number | null = null
    const runs: Array<[number, number]> = []
    for (let index = first; index <= last; index++) {
      const pending = this.inflight.get(index)
      if (this.chunks.has(index) || pending !== undefined) {
        if (pending !== undefined && !waits.includes(pending)) waits.push(pending)
        if (runFrom !== null) runs.push([runFrom, index - 1])
        runFrom = null
        continue
      }
      if (runFrom === null) runFrom = index
    }
    if (runFrom !== null) runs.push([runFrom, last])
    for (const [from, to] of runs) {
      const promise = this.fetchRun(from, to, opts)
      for (let index = from; index <= to; index++) this.inflight.set(index, promise)
      const done = (): void => {
        for (let index = from; index <= to; index++) {
          if (this.inflight.get(index) === promise) this.inflight.delete(index)
        }
      }
      promise.then(done, done)
      waits.push(promise)
    }
    await Promise.all(waits)
 
    const out = new Uint8Array(length)
    let filled = 0
    for (let index = first; index <= last; index++) {
      const chunk = this.chunks.get(index)
      if (chunk === undefined) break
      this.touch(index, chunk)
      const chunkStart = index * this.chunkBytes
      const from = Math.max(position, chunkStart) - chunkStart
      const to = Math.min(position + length, chunkStart + chunk.length) - chunkStart
      if (to <= from) break
      out.set(chunk.subarray(from, to), Math.max(position, chunkStart) - position)
      filled = Math.max(position, chunkStart) - position + (to - from)
      // A chunk shorter than the chunk size is the file's last, and the
      // caller's range ends with it.
      if (chunk.length < this.chunkBytes) break
    }
    return filled === length ? out : out.subarray(0, filled) as Uint8Array<ArrayBuffer>
  }
 
  private async fetchRun(from: number, to: number, opts?: Parameters<RemoteFile['read']>[2]): Promise<void> {
    const start = from * this.chunkBytes
    let want = (to - from + 1) * this.chunkBytes
    if (this.end !== null) want = Math.min(want, Math.max(0, this.end - start))
    if (want <= 0) return
    this.requests += 1
    const bytes = await super.read(want, start, opts)
    this.fetched += bytes.length
    if (bytes.length < want) this.end = start + bytes.length
    for (let index = from; index <= to; index++) {
      const at = (index - from) * this.chunkBytes
      if (at >= bytes.length) break
      // Copied, not sliced: `super.read` may hand back a view over a buffer it
      // reuses, and a kept chunk has to be its own bytes.
      this.store(index, bytes.slice(at, Math.min(at + this.chunkBytes, bytes.length)))
    }
  }
 
  private store(index: number, chunk: Uint8Array): void {
    const previous = this.chunks.get(index)
    if (previous !== undefined) this.held -= previous.length
    this.chunks.set(index, chunk)
    this.held += chunk.length
    while (this.held > this.budgetBytes && this.chunks.size > 1) {
      const oldest = this.chunks.keys().next().value as number
      const dropped = this.chunks.get(oldest) as Uint8Array
      this.chunks.delete(oldest)
      this.held -= dropped.length
    }
  }
 
  /** Mark a chunk most recently used, by reinserting it at the back. */
  private touch(index: number, chunk: Uint8Array): void {
    this.chunks.delete(index)
    this.chunks.set(index, chunk)
  }
}