Fix idle range completion scan#832
Conversation
| this._resolveRangeRequest(r) | ||
| i-- | ||
| if (len > this._ranges.length) len-- | ||
| if (this._ranges.length === MAX_RANGES) updateAll = true |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
The actual MAX_RANGES cap is still preserved. The new cursor loop drains completed ranges in capped chunks without relying on that exact effect.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
Previous behavior had implied event loop starvation prevention mechanics:
Lines 2607 to 2609 in c2ee97e
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
There was a problem hiding this comment.
An additional improvement we can do here is to assert this behavior with a test
There was a problem hiding this comment.
@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:
Line 1545 in ddd6c79
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
left a comment
There was a problem hiding this comment.
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:
- make it pick random subset of ranges to avoid stacking unfulfillable ranges at the front.
- 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
| function noop() {} | ||
|
|
||
| function yieldToLoop() { | ||
| return new Promise((resolve) => setTimeout(resolve, 0)) |
There was a problem hiding this comment.
| 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.
| if (checked === 0 || checkedSinceResolve >= this._ranges.length) break | ||
| if (!drain) break | ||
|
|
||
| await yieldToLoop() |
There was a problem hiding this comment.
@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:
Line 1545 in ddd6c79
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.
| this._resolveRangeRequest(r) | ||
| i-- | ||
| if (len > this._ranges.length) len-- | ||
| if (this._ranges.length === MAX_RANGES) updateAll = true |
There was a problem hiding this comment.
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.
|
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
left a comment
There was a problem hiding this comment.
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.
| const result = this._updateRanges(rangeIndex, limit) | ||
| const { checked, resolved } = result | ||
| rangeIndex = result.index | ||
|
|
||
| let err = null | ||
| let res = null | ||
| if (result.updateAll) updateAll = true |
There was a problem hiding this comment.
| 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 |
| if (seekIndex < this._seeks.length - 1) { | ||
| this._seeks[seekIndex] = this._seeks.pop() | ||
| } else this._seeks.pop() |
There was a problem hiding this comment.
This can be slightly simpler if we always pop and only swap if the popped version isn't the s. Something like this:
Lines 283 to 286 in 3cd8c50
| this._updatingSeeks = true | ||
|
|
||
| let checkedSeek = false | ||
| for (const s of this._seeks.slice()) { |
There was a problem hiding this comment.
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')
})| 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) |
There was a problem hiding this comment.
You don't need to disable the teardown for these replicate() calls. You can unreplicate with it in place.
| t.is(r._updateAllBump, null, 'timer reset to null') | ||
| }) | ||
|
|
||
| test('completed range exposes capped range to existing peer', async function (t) { |
There was a problem hiding this comment.
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.
| if (++updates === 1) { | ||
| setImmediate(() => { | ||
| peer.paused = false | ||
| seek = clone.seek(Math.floor(core.byteLength / 2)) |
There was a problem hiding this comment.
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.
co-authored with AI