OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay / test / chunk-scheduler.test.js

112 lines · javascript ref:
// P2 — per-peer inflight window (hypercore replicator getMaxInflight model).
// Verifies the window math directly against the reference formula:
//   no-udx  → min(max, min*3)
//   udx     → clamp(round(min * (rtt<=50 ? 1 : rtt/50 * min(1, 2/peers))), min, max)
// with our tuned range INFLIGHT_RANGE = [4, 64].
import test from 'brittle'
import c from 'compact-encoding'
import b4a from 'b4a'
import { ChunkScheduler } from '../lib/chunk-scheduler.js'
import * as messages from '../lib/messages.js'

function roundtrip (codec, value) {
  const state = { start: 0, end: 0, buffer: null }
  codec.preencode(state, value)
  state.buffer = b4a.alloc(state.end)
  codec.encode(state, value)
  state.start = 0
  return codec.decode(state)
}

function mkSched (opts = {}) {
  const s = new ChunkScheduler({ path: 'content:x', destPath: '/tmp/none', transfer: {}, sendNeed: () => {}, ...opts })
  clearTimeout(s._timer) // don't leave the idle watchdog armed
  s._timer = null
  return s
}
const udx = (rtt) => ({ mux: { stream: { rawStream: { udx: {}, rtt } } } })
const plain = () => ({ mux: { stream: { rawStream: {} } } }) // no udx → no RTT signal

test('P2: no-udx stream gets a fixed min*3 window', (t) => {
  const s = mkSched(); s._peers.add('a')
  t.is(s._maxInflight(plain()), 12, 'min(64, 4*3) = 12')
})

test('P2: udx link at/under SCALE_LATENCY stays at min', (t) => {
  const s = mkSched(); s._peers.add('a')
  t.is(s._maxInflight(udx(20)), 4, 'rtt<=50 → scale 1 → min = 4')
  t.is(s._maxInflight(udx(50)), 4, 'rtt==50 boundary → min = 4')
})

test('P2: udx window scales up with RTT and clamps to max', (t) => {
  const s = mkSched(); s._peers.add('a') // 1 peer → min(1, 2/1) = 1
  t.is(s._maxInflight(udx(200)), 16, 'rtt 200 → 4 * (200/50) = 16')
  t.is(s._maxInflight(udx(100000)), 64, 'huge rtt clamps to max 64')
})

test('P2: more peers shrink the per-peer share (fairness divisor)', (t) => {
  const s = mkSched()
  for (const p of ['a', 'b', 'c', 'd']) s._peers.add(p) // 4 peers → 2/4 = 0.5
  t.is(s._maxInflight(udx(200)), 8, 'rtt 200, 4 peers → 4 * (200/50 * 0.5) = 8')
})

test('P2: opts.cap overrides the dynamic window (back-compat)', (t) => {
  const s = mkSched({ cap: 3 }); s._peers.add('a')
  t.is(s._maxInflight(udx(99999)), 3, 'fixed cap wins over RTT scaling')
  t.is(s._maxInflight(plain()), 3, 'fixed cap wins over the no-udx default')
})

test('P5 hotswap: an idle peer steals a chunk stalled on a slow peer', (t) => {
  const needs = []
  const cancels = []
  const s = mkSched({
    cap: 4, // fixed window so both peers have spare capacity deterministically
    hotswapStallMs: 50,
    sendNeed: (peer, idx) => needs.push([peer, idx]),
    sendCancel: (peer, idx) => cancels.push([peer, idx])
  })
  s._chunks = [{ hash: 'h0', offset: 0, length: 10 }]
  const slow = udx(10)
  const fast = udx(10)
  s._peers.add(slow) // iterated first (holds nothing to steal), then fast steals
  s._peers.add(fast)
  // chunk 0 is inflight to the slow peer and has been for a while (stalled).
  s._inflight.set(0, slow)
  s._inflightAt.set(0, Date.now() - 1000)
  s._peerInflight.set(slow, 1)
  s._peerInflight.set(fast, 0)
  // _needed is empty — nothing fresh to fetch, only the stalled chunk remains.

  s._assign()

  t.is(s._inflight.get(0), fast, 'chunk 0 reassigned from slow → fast')
  t.ok(needs.some(([p, idx]) => p === fast && idx.includes(0)), 'fast peer re-requested chunk 0')
  t.ok(cancels.some(([p, idx]) => p === slow && idx.includes(0)), 'slow peer told to cancel chunk 0')
  t.is(s._hotswaps, 1, 'one hotswap recorded')
  t.is(s._peerInflight.get(slow), 0, 'slow peer inflight decremented')
  t.is(s._peerInflight.get(fast), 1, 'fast peer inflight incremented')
})

test('P5 hotswap: fresh chunks are assigned before any stealing', (t) => {
  const needs = []
  const s = mkSched({ cap: 4, hotswapStallMs: 50, sendNeed: (peer, idx) => needs.push([peer, idx]) })
  s._chunks = [0, 1].map((i) => ({ hash: 'h' + i, offset: i * 10, length: 10 }))
  const a = udx(10)
  s._peers.add(a)
  s._needed.add(1) // chunk 1 is fresh/needed
  s._inflight.set(0, udx(10)) // chunk 0 inflight to some other (already-gone) peer, stalled
  s._inflightAt.set(0, Date.now() - 1000)
  s._assign()
  // peer a should take the FRESH chunk 1 first (not immediately hotswap chunk 0)
  t.ok(s._inflight.get(1) === a, 'fresh chunk 1 assigned to peer a')
})

test('P5 wire: chunkCancel round-trips with indices, back-compat without', (t) => {
  const withIdx = roundtrip(messages.chunkCancel, { path: 'content:abc', indices: [3, 7, 9] })
  t.is(withIdx.path, 'content:abc')
  t.alike(withIdx.indices, [3, 7, 9], 'per-chunk indices preserved')
  const noIdx = roundtrip(messages.chunkCancel, { path: 'content:abc' })
  t.is(noIdx.path, 'content:abc')
  t.absent(noIdx.indices, 'absent indices → bare {path} (whole-transfer cancel; pre-0.3.0 shape)')
})