Skip to content

Fix idle range completion scan#832

Open
marcus-pousette-hp wants to merge 13 commits into
mainfrom
fix/idle-range-completion
Open

Fix idle range completion scan#832
marcus-pousette-hp wants to merge 13 commits into
mainfrom
fix/idle-range-completion

Conversation

@marcus-pousette-hp

@marcus-pousette-hp marcus-pousette-hp commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

co-authored with AI

Comment thread lib/replicator.js
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.

@marcus-pousette-hp
marcus-pousette-hp marked this pull request as ready for review June 23, 2026 16:01
@marcus-pousette-hp
marcus-pousette-hp requested a review from a team June 23, 2026 16:01
Comment thread lib/replicator.js Outdated
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.

@lejeunerenard lejeunerenard left a comment

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.

This approach is more thorough when completing range requests, but it blocks seek requests when processing large amounts of ranges. Accounting for this seems like it would add more state at the replicator level for tracking where the cursor left off etc.

If we keep _updateNonPrimary's workload small but:

  1. make it pick random subset of ranges to avoid stacking unfulfillable ranges at the front.
  2. trigger non-primary updates more often, maybe when inflight goes idle

We can allow it to yield to seeks while being a minor change. I have the following competing PR with the above, which still doesn't pass atm. #841

Comment thread lib/replicator.js Outdated
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.

Comment thread lib/replicator.js Outdated
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.

@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.

Comment thread lib/replicator.js
this._resolveRangeRequest(r)
i--
if (len > this._ranges.length) len--
if (this._ranges.length === MAX_RANGES) updateAll = true

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.

@holepunchto holepunchto deleted a comment from marcus-pousette Jul 13, 2026
@marcus-pousette-hp

Copy link
Copy Markdown
Contributor Author

One note, processing can get slow if there are many seeks at once or a very large backlog of ranges. Potentially optimization/perf improrvement can be made here but left it out to make the PR easier

@lejeunerenard lejeunerenard left a comment

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.

Did another pass. Still need to cover some of the tests but found a potential way that ranges now can be blocked instead of seeks.

Comment thread lib/replicator.js
Comment on lines +2674 to +2678
const result = this._updateRanges(rangeIndex, limit)
const { checked, resolved } = result
rangeIndex = result.index

let err = null
let res = null
if (result.updateAll) updateAll = true

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
const result = this._updateRanges(rangeIndex, limit)
const { checked, resolved } = result
rangeIndex = result.index
let err = null
let res = null
if (result.updateAll) updateAll = true
const { checked, resolved, index, updateAll: resultUpdateAll } = this._updateRanges(rangeIndex, limit)
rangeIndex = index
if (resultUpdateAll) updateAll = true

Comment thread lib/replicator.js
Comment on lines +2705 to +2707
if (seekIndex < this._seeks.length - 1) {
this._seeks[seekIndex] = this._seeks.pop()
} else this._seeks.pop()

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.

This can be slightly simpler if we always pop and only swap if the popped version isn't the s. Something like this:

hypercore/lib/replicator.js

Lines 283 to 286 in 3cd8c50

const h = this.ranges.pop()
if (h !== this) {
this.ranges[rangeIndex] = h
}

Comment thread lib/replicator.js
this._updatingSeeks = true

let checkedSeek = false
for (const s of this._seeks.slice()) {

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.

Just a note as I dont think it is an issue in practice (because seeks are mainly used for streaming), but there is no limit to the number of seeks processed. This means just like ranges held up seek resolution, the opposite is still true where a large number of seeks could hold up range updates (allowing the initial update of a batch of ranges).

I propose we limit the length as well and use RandomIterator. Here is a test I made to showcase the issue:

test('seeks cant block range updates', async function (t) {
  const a = await create(t)
  const b = await create(t, a.key)
  const totalBlocks = 1e5
  const totalRanges = 129 // above max range for at least 2x batches
  const totalSeeks = totalBlocks

  // Seed data
  await a.append(Array(totalBlocks).fill('a'))
  await a.clear(0, a.length - totalRanges) // Clear all but the end

  replicate(a, b, t)

  // Setup ranges at the end
  const ranges = []
  for (let i = 0; i < totalRanges; i++) {
    ranges.push(b.download({ start: a.length - (i + 1), end: a.length - i }).done())
  }

  // Setup large number of seeks
  const seeks = []
  for (let i = 0; i < totalSeeks; i++) {
    seeks.push(b.seek(i).catch(noop))
  }

  // Allow seeks to be added to the replicator
  await eventFlush()

  const result = await Promise.race([
    b.core.replicator._updateNonPrimary(true).then(() => 'resolved'),
    new Promise((resolve) => setTimeout(resolve, 100)).then(() => 'timeout')
  ])
  t.is(result, 'resolved', 'updating non-primary didnt block on seeks')
})

Comment thread test/replicate.js
Comment on lines +2947 to +2953
let streams = replicate(writer, firstPeer, t, { teardown: false })
await firstPeer.get(0)
await unreplicate(streams)

const cappedPeer = await create(t, writer.key)
streams = replicate(writer, cappedPeer, t, { teardown: false })
await cappedPeer.get(totalLength - 1)

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.

You don't need to disable the teardown for these replicate() calls. You can unreplicate with it in place.

Comment thread test/replicate.js
t.is(r._updateAllBump, null, 'timer reset to null')
})

test('completed range exposes capped range to existing peer', async function (t) {

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.

Not sure what the purpose of this test is besides that peers can get disparate ranges from different peers. It passes on main so isn't part of the idle range fix. Maybe it is trying to capture that there is a MAX_RANGES batch size per _updateNonPrimary() but it doesn't assert anything about the _updateNonPrimary() lifecycle.

Comment thread test/replicate.js
if (++updates === 1) {
setImmediate(() => {
peer.paused = false
seek = clone.seek(Math.floor(core.byteLength / 2))

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 understand why we need to wait to process a single batch of range requests to schedule the seek request for after the poll phase in the loop. Calling here let seek = clone.seek(...) works too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants