Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 51 additions & 10 deletions lib/replicator.js
Original file line number Diff line number Diff line change
Expand Up @@ -2600,23 +2600,60 @@ module.exports = class Replicator {
}
}

_updateRanges(index, limit) {
if (this._ranges.length === 0) {
return { checked: 0, resolved: 0, index: 0 }
}

index = Math.min(index, this._ranges.length - 1)

let checked = 0
let resolved = 0
let remaining = Math.min(limit, this._ranges.length)

while (remaining-- > 0 && this._ranges.length > 0) {
if (index >= this._ranges.length) index = 0

const r = this._ranges[index]

clampRange(this.core, r)
checked++

if (r.end !== -1 && r.start >= r.end) {
this._resolveRangeRequest(r)
resolved++
} else {
index++
}
}

index = this._ranges.length === 0 ? 0 : index % this._ranges.length

return { checked, resolved, index }
}

// "slow" updates here - async but not allowed to ever throw
async _updateNonPrimary(updateAll) {
// Check if running, if so skip it and the running one will issue another update for us (debounce)
while (++this._updatesPending === 1) {
let len = Math.min(MAX_RANGES, this._ranges.length)
let checkedSinceResolve = 0
let rangeIndex = 0
const drain = this._inflight.idle || updateAll

for (let i = 0; i < len; i++) {
const r = this._ranges[i]
while (this._ranges.length > 0) {
const limit = Math.min(MAX_RANGES, this._ranges.length - checkedSinceResolve)
const result = this._updateRanges(rangeIndex, limit)
const { checked, resolved } = result
rangeIndex = result.index

clampRange(this.core, r)
if (resolved > 0) checkedSinceResolve = 0
else checkedSinceResolve += checked

if (r.end !== -1 && r.start >= r.end) {
this._resolveRangeRequest(r)
i--
if (len > this._ranges.length) len--
if (this._ranges.length === MAX_RANGES) updateAll = true

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the old this._ranges.length === MAX_RANGES special case because it only re-triggered updateAll when the queue shrank to exactly the cap, and I couldn’t find test coverage or PR discussion showing that boundary behavior is intentional.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The actual MAX_RANGES cap is still preserved. The new cursor loop drains completed ranges in capped chunks without relying on that exact effect.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont think this should be removed. The updateAll prompts requesting ranges from a random subset of peers potentially. Since the _updateNonPrimary function is called only when either receiving data or an upgrade, it could deadlock with requests queue in _ranges that haven't been requested of peers that can fulfill them if updateAll() isn't triggered.

The reason for the exact match is that we know in this scenario only 1 range was resolved so this check triggers as soon as there can be at least one range that could not have been requested of peers. Aka triggering another set of range requests could prompt a response which resolves a range not included in the last _updateNonPrimary run.

This doesn't prevent from large backlogs not getting stuck (hence the need for this PR) when there is more unfulfilled range request than the MAX_RANGES though. I don't think it should be updateAll = true for this._ranges.length >= MAX_RANGES though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ye, that make sense. I restored this in 5403eb5, preserving the exact this._ranges.length === MAX_RANGES boundary. I also added a regression test with 65 one-block ranges and two peers as an proof/argumetn for this. Though it is quite verbose right now, I will re-iterate on this tomorrow.

}
if (checked === 0 || checkedSinceResolve >= this._ranges.length) break
if (!drain) break

await yieldToLoop()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this here?

@marcus-pousette-hp marcus-pousette-hp Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous behavior had implied event loop starvation prevention mechanics:

hypercore/lib/replicator.js

Lines 2607 to 2609 in c2ee97e

let len = Math.min(MAX_RANGES, this._ranges.length)
for (let i = 0; i < len; i++) {

In this PR I am trying to preserve this behavior by simultaniously also fixing the bug:

while (this._ranges.length > 0) {
  this._updateRanges(...MAX_RANGES...)
  ...
  await yieldToLoop()
}

Also possible that debounce/coalescing mechanisms could be more probable to be invoked in _updateNonPrimary because of the yieldToLoop

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An additional improvement we can do here is to assert this behavior with a test

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@marcus-pousette-hp the debouncing/guard via _updatesPending could delay seeks from being processed assuming a large number of ranges. This prevents from seeks being added here:

if (this.replicator._updatesPending > 0) return false

Here's a test case for asserting that seeks are processed relatively quickly. Before the PR it passes and after this PR it fails:

test.solo('processing ranges doesnt block seeks', async function (t) {
  const core = await create(t)
  const clone = await create(t, core.key)

  // Well above MAX_RANGES so draining the backlog takes many batches: 50k/64
  const totalLength = 50_000

  await core.append(new Array(totalLength).fill('a'))

  // Make all range requests stay in `_ranges` forever so `_updateNonPrimary` has an oversized backlog to scan.
  await core.clear(0, totalLength)

  replicate(core, clone, t)

  // Populate ranges
  for (let i = 0; i < totalLength; i++) {
    clone.download({ start: i, end: i + 1 }).done().catch(noop)
  }

  t.is(clone.core.replicator._ranges.length, totalLength, 'large range backlog is pending')

  const bytesBefore = core.byteLength

  // Appending triggers an upgrade on clone, kicking off `_updateNonPrimary` to attempt the backlog.
  // Seeking to `bytesBefore` needs that same upgrade (so clone doesnt know about it).
  await core.append('end')

  // Baseline (no range backlog) resolves this in ~1ms; 900ms+ with the backlog
  const BUDGET = 100

  const start = Date.now()
  let delta = -1
  const seek = clone.seek(bytesBefore).then(() => {
    delta = Date.now() - start
  })
  await once(clone, 'append')

  // To allow merkle IO for seek
  await new Promise((resolve) => setImmediate(resolve))

  t.is(clone.core.replicator._seeks.length, 0, 'seek resolved after upgrade received')
  await seek

  t.ok(delta <= BUDGET, `seek resolved within ${BUDGET}ms despite a ${totalLength}-range backlog`)
  t.comment('seek time', delta)
  t.is(clone.core.replicator._ranges.length, totalLength, 'range backlog is still pending')
})

Test made with a litte AI assistance.

if (this.destroyed) break
}

for (let i = 0; i < this._seeks.length; i++) {
Expand Down Expand Up @@ -3384,6 +3421,10 @@ function incrementRx(stats1, stats2) {

function noop() {}

function yieldToLoop() {
return new Promise((resolve) => setTimeout(resolve, 0))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return new Promise((resolve) => setTimeout(resolve, 0))
return new Promise((resolve) => setImmediate(resolve))

setTimeout defaults to a delay of 1 though the exact timing can be variable I believe.

}

function backoff(times) {
const sleep = times < 2 ? 200 : times < 5 ? 500 : times < 40 ? 1000 : 5000
return new Promise((resolve) => setTimeout(resolve, sleep))
Expand Down
113 changes: 113 additions & 0 deletions test/replicate.js
Original file line number Diff line number Diff line change
Expand Up @@ -2891,6 +2891,119 @@ test('delayed updateAll timer doesnt keep event loop alive', async function (t)
t.is(r._updateAllBump, null, 'timer reset to null')
})

test('idle range completion drains past max range window', async function (t) {
const core = await create(t)
const clone = await create(t, core.key)

const pending = new Set()
const complete = []
const totalLength = 150
const availableStart = 100

for (let i = 0; i < totalLength; i++) {
await core.append('' + i)
}

await core.clear(0, availableStart)

const random = Math.random
Math.random = () => 0.999
t.teardown(() => {
Math.random = random
})

replicate(core, clone, t)

for (let i = 0; i < totalLength; i++) {
pending.add(i)

const done = clone.download({ start: i, end: i + 1 }).done()
done.then(() => pending.delete(i), noop)

if (i >= availableStart) complete.push(done)
}

t.is(clone.core.replicator._ranges.length, totalLength, 'all ranges are pending')
t.ok(await core.has(availableStart, totalLength), 'source has all available blocks')

const resolved = await Promise.race([
Promise.all(complete).then(() => true),
new Promise((resolve) => setTimeout(resolve, 1000, false))
])

t.ok(resolved, 'all locally complete ranges resolved')
t.is(pending.size, availableStart, 'only unavailable ranges remain pending')
t.is(clone.core.replicator._ranges.length, availableStart, 'resolved ranges were removed')

const outliers = []
for (const range of clone.core.replicator._ranges) {
if (range.userStart >= availableStart) outliers.push(range.userStart)
}
t.alike(outliers, [], 'no available ranges remain pending')
})

test('idle range completion keeps draining if update queues during yield', async function (t) {
const core = await create(t)
const pending = new Set()
const complete = []

const totalLength = 150
const availableStart = 100
const replicator = core.core.replicator

for (let i = 0; i < totalLength; i++) {
pending.add(i)

const done = core.download({ start: i, end: i + 1 }).done()
done.then(() => pending.delete(i), noop)

if (i >= availableStart) complete.push(done)
}

t.is(replicator._ranges.length, totalLength, 'all ranges are pending')

core.core._setBitfieldRanges(availableStart, totalLength, true)

const updateRanges = replicator._updateRanges
let updates = 0

replicator._updateRanges = function (index, limit) {
const result = updateRanges.call(this, index, limit)

if (++updates === 1) {
setTimeout(() => {
this._inflight._active++
this._updateNonPrimary(false).catch(noop)
}, 0)
}

return result
}

t.teardown(() => {
replicator._updateRanges = updateRanges
replicator._inflight._active = 0
})

await replicator._updateNonPrimary(false)
replicator._inflight._active = 0

const resolved = await Promise.race([
Promise.all(complete).then(() => true),
new Promise((resolve) => setTimeout(resolve, 1000, false))
])

t.ok(resolved, 'all locally complete ranges resolved')
t.is(pending.size, availableStart, 'only unavailable ranges remain pending')
t.is(replicator._ranges.length, availableStart, 'resolved ranges were removed')

const outliers = []
for (const range of replicator._ranges) {
if (range.userStart >= availableStart) outliers.push(range.userStart)
}
t.alike(outliers, [], 'no available ranges remain pending')
})

async function createAndDownload(t, core) {
const b = await create(t, core.key)
replicate(core, b, t, { teardown: false })
Expand Down
Loading