OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / test / fetch-timeout.test.js

86 lines · javascript ref:
// ⛔ A byte fetch that never answers must not hang a listing.
//
// `ensureLocal` calls `overlay.fetchFile(oid)` to pull a record's bytes by hash. It was unbounded,
// and `_scan` awaits it once per record — so listing a feed whose bytes nobody is serving blocked
// for ever. That is `timeline`, `search` and `posts-by` alike; peek only made it visible, because
// it is the first thing that holds a feed you have never replicated.
//
// ★ The answer was already in the design. `log.js` distinguishes ABSENT / DELETED / UNRESOLVED, and
// UNRESOLVED means exactly "we hold the pointer and cannot read the bytes". A fetch that has not
// come back IS that — reported, not waited on, and resolvable on a later pass.

import test from 'brittle'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import { RecordStore } from '../lib/record-store.js'

const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-ft-'))

// An overlay that accepts a registration and then never serves anything back.
const deadOverlay = () => ({
  calls: 0,
  async registerFile () { return { oid: 'ab'.repeat(32), size: 1 } },
  hashFile: async () => null,
  async fetchFile () { this.calls++; return new Promise(() => {}) }, // never settles
  async unregisterFile () {}
})

test('★ an unanswerable fetch gives up and reports UNRESOLVED', async (t) => {
  const overlay = deadOverlay()
  const store = new RecordStore(tmp(), { overlay, fetchTimeoutMs: 200 })

  const started = Date.now()
  const got = await store.ensureLocal('post', 'p1', 'cd'.repeat(32))
  const took = Date.now() - started

  t.is(got, null, 'no bytes — which the log renders as UNRESOLVED, not as an absent record')
  t.ok(took < 3000, 'and it came back rather than hanging (' + took + 'ms)')
  t.is(overlay.calls, 1, 'it did try')
})

test('⚠ giving up is not caching a failure — the next pass tries again', async (t) => {
  const overlay = deadOverlay()
  const store = new RecordStore(tmp(), { overlay, fetchTimeoutMs: 100 })
  await store.ensureLocal('post', 'p1', 'cd'.repeat(32))
  await store.ensureLocal('post', 'p1', 'cd'.repeat(32))
  // The bytes may arrive at any moment — a peer could connect a second later. Remembering the
  // failure would make a feed that later replicates fine stay permanently unreadable.
  t.is(overlay.calls, 2, 'asked again')
})

test('bytes we already hold never pay the timeout', async (t) => {
  const overlay = deadOverlay()
  const store = new RecordStore(tmp(), { overlay, fetchTimeoutMs: 10000 })
  await store.put('post', 'p1', { frontmatter: {}, body: 'local' })

  const started = Date.now()
  const got = await store.ensureLocal('post', 'p1', null)
  t.ok(got, 'read from disk')
  t.ok(Date.now() - started < 1000, 'without touching the overlay at all')
  t.is(overlay.calls, 0)
})

test('★★ a whole listing costs ONE timeout, not one per record', async (t) => {
  const { RecordLog } = await import('../lib/log.js')
  const overlay = deadOverlay()
  const store = new RecordStore(tmp(), { overlay, fetchTimeoutMs: 300 })

  // Ten pointers whose bytes nobody serves — a freshly peeked feed.
  const kv = new Map()
  const log = new RecordLog({
    get: async (k) => kv.get(k) || null,
    set: async (k, v) => { kv.set(k, v) },
    list: async (prefix) => Object.fromEntries([...kv].filter(([k]) => k.startsWith(prefix)))
  }, store)
  for (let i = 0; i < 10; i++) kv.set('post/p' + i, { $oid: 'cd'.repeat(32), size: 1 })

  const started = Date.now()
  const rows = await log.unresolved('post')
  const took = Date.now() - started

  t.is(rows.length, 10, 'all ten reported as unresolved')
  // Serially this was 3000ms. ⛔ The assertion is the SHAPE — one timeout, not ten.
  t.ok(took < 1500, 'resolved concurrently (' + took + 'ms, serial would be ~3000)')
})