OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay commits / eab47e3

port(0.3.0): backpressure P2 — RTT-scaled per-peer inflight window
Replaces the crude fixed per-peer cap (8) with a dynamic request-side window modelled directly on hypercore replicator Peer.getMaxInflight (holepunchto/hypercore lib/replicator.js:590): no-udx stream (in-memory/relay, no RTT) -> min(max, min*3) udx stream -> clamp(round(min * (rtt<=50 ? 1 : rtt/50 * min(1, 2/peers))), min, max) Range tuned to [4,64] (vs hypercore [16,512]) because our chunks are ~64 KB, not small blocks. The min(1, 2/peers) fairness divisor shrinks each peer window as more peers join, so no single peer monopolises the assignment. opts.cap still overrides (back-compat). ChunkScheduler._maxInflight(peer) used by _assign. test/chunk-scheduler.js: 5 window-math cases vs the reference formula. Suite 15/15. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
eab47e3892008be769772c37c847364a6b191628
by plankatron <fleeky@gmail.com> · 2026-07-11T19:02:39.000Z

Changed files (2) +70 -3

lib/chunk-scheduler.js +25 -3
……@@ -21,7 +21,14 @@
2121
2222import { hashChunk } from './chunker.js'
2323
24const DEFAULT_CAP = 8
24// P2 — per-peer inflight request window, modelled on hypercore's replicator
25// Peer.getMaxInflight (holepunchto/hypercore lib/replicator.js:590). Our content
26// chunks are ~64 KB (tier 0 avg 16 KB … tier 3 max 4 MB) — much larger than
27// hypercore's blocks — so the range is tuned DOWN from its [16, 512]. A no-udx
28// stream (in-memory pair / relay, no RTT signal) gets a modest fixed min*3; a udx
29// stream scales the window with RTT and shrinks it as more peers join (fair share).
30const INFLIGHT_RANGE = [4, 64]
31const SCALE_LATENCY = 50 // ms — at/under this RTT the window stays at min
2532// Head-of-line fairness — cede the event loop every YIELD_EVERY accepted chunks
2633// so a burst of buffered chunkData frames (protomux dispatches every frame from
2734// one socket read synchronously) can't starve other listeners multiplexed on the
……@@ -59,7 +66,7 @@ export class ChunkScheduler {
5966 this.destPath = opts.destPath
6067 this._transfer = opts.transfer
6168 this._sendNeed = opts.sendNeed
62 this._cap = opts.cap || DEFAULT_CAP
69 this._cap = opts.cap || 0 // >0 = fixed override; 0 = dynamic RTT-scaled window (P2)
6370 this._onProgress = opts.onProgress || null
6471 this._onVerify = opts.onVerify || null
6572 this._onBaseline = opts.onBaseline || null
……@@ -313,13 +320,28 @@ export class ChunkScheduler {
313320 this._finish()
314321 }
315322
323 // P2: per-peer inflight window. A fixed opts.cap overrides it (back-compat / tests);
324 // otherwise scale with the peer's udx RTT and the peer count exactly as hypercore's
325 // Peer.getMaxInflight — window stays at min for low-RTT links, grows for high-RTT
326 // ones, and shrinks toward a fair share (×min(1, 2/peers)) as more peers join.
327 _maxInflight (peer) {
328 if (this._cap) return this._cap
329 const [min, max] = INFLIGHT_RANGE
330 const raw = peer && peer.mux && peer.mux.stream && peer.mux.stream.rawStream
331 if (!raw || !raw.udx || typeof raw.rtt !== 'number') return Math.min(max, min * 3)
332 const scale = raw.rtt <= SCALE_LATENCY
333 ? 1
334 : (raw.rtt / SCALE_LATENCY) * Math.min(1, 2 / Math.max(1, this._peers.size))
335 return Math.max(min, Math.round(Math.min(max, min * scale)))
336 }
337
316338 /** Assign needed-but-not-inflight chunks to peers with spare capacity. */
317339 _assign () {
318340 if (this._done || !this._chunks) return
319341 // Per peer, batch the indices we hand it this round.
320342 const batches = new Map()
321343 for (const peer of this._peers) {
322 let slots = this._cap - (this._peerInflight.get(peer) || 0)
344 let slots = this._maxInflight(peer) - (this._peerInflight.get(peer) || 0)
323345 if (slots <= 0) continue
324346 for (const index of this._needed) {
325347 if (slots <= 0) break
test/chunk-scheduler.test.js +45 -0
……@@ -0,0 +1,45 @@
1// P2 — per-peer inflight window (hypercore replicator getMaxInflight model).
2// Verifies the window math directly against the reference formula:
3// no-udx → min(max, min*3)
4// udx → clamp(round(min * (rtt<=50 ? 1 : rtt/50 * min(1, 2/peers))), min, max)
5// with our tuned range INFLIGHT_RANGE = [4, 64].
6import test from 'brittle'
7import { ChunkScheduler } from '../lib/chunk-scheduler.js'
8
9function mkSched (opts = {}) {
10 const s = new ChunkScheduler({ path: 'content:x', destPath: '/tmp/none', transfer: {}, sendNeed: () => {}, ...opts })
11 clearTimeout(s._timer) // don't leave the idle watchdog armed
12 s._timer = null
13 return s
14}
15const udx = (rtt) => ({ mux: { stream: { rawStream: { udx: {}, rtt } } } })
16const plain = () => ({ mux: { stream: { rawStream: {} } } }) // no udx → no RTT signal
17
18test('P2: no-udx stream gets a fixed min*3 window', (t) => {
19 const s = mkSched(); s._peers.add('a')
20 t.is(s._maxInflight(plain()), 12, 'min(64, 4*3) = 12')
21})
22
23test('P2: udx link at/under SCALE_LATENCY stays at min', (t) => {
24 const s = mkSched(); s._peers.add('a')
25 t.is(s._maxInflight(udx(20)), 4, 'rtt<=50 → scale 1 → min = 4')
26 t.is(s._maxInflight(udx(50)), 4, 'rtt==50 boundary → min = 4')
27})
28
29test('P2: udx window scales up with RTT and clamps to max', (t) => {
30 const s = mkSched(); s._peers.add('a') // 1 peer → min(1, 2/1) = 1
31 t.is(s._maxInflight(udx(200)), 16, 'rtt 200 → 4 * (200/50) = 16')
32 t.is(s._maxInflight(udx(100000)), 64, 'huge rtt clamps to max 64')
33})
34
35test('P2: more peers shrink the per-peer share (fairness divisor)', (t) => {
36 const s = mkSched()
37 for (const p of ['a', 'b', 'c', 'd']) s._peers.add(p) // 4 peers → 2/4 = 0.5
38 t.is(s._maxInflight(udx(200)), 8, 'rtt 200, 4 peers → 4 * (200/50 * 0.5) = 8')
39})
40
41test('P2: opts.cap overrides the dynamic window (back-compat)', (t) => {
42 const s = mkSched({ cap: 3 }); s._peers.add('a')
43 t.is(s._maxInflight(udx(99999)), 3, 'fixed cap wins over RTT scaling')
44 t.is(s._maxInflight(plain()), 3, 'fixed cap wins over the no-udx default')
45})