Skip to content

Commit 3b91bc1

Browse files
authored
fix: Properly order dependent moves in sync (#2447)
`compareChanges` returned `KEEP_ORDER` for two chained moves where one frees the path the other takes (e.g. `i` → `i*` followed by `j` → `i`). The dependent move could run before its destination was freed, failing with a "destination already exists" error. We now detect this case in `compareChanges` — both ops are `MOVE` with a `moveFrom` and one's destination equals the other's source — and order the freeing move first. Fixes #2445
1 parent 5954011 commit 3b91bc1

3 files changed

Lines changed: 173 additions & 0 deletions

File tree

core/sync/index.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,17 @@ const compareChanges = (
204204
// Handle move to dir after dir addition
205205
if (docA.path.startsWith(docB.path + sep)) return B_FIRST
206206
}
207+
if (
208+
opA.type === 'MOVE' &&
209+
opB.type === 'MOVE' &&
210+
docA.moveFrom != null &&
211+
docB.moveFrom != null
212+
) {
213+
// Handle chained rename: A frees the path B will take (e.g. i->i*
214+
// must happen before j->i so the destination path is free).
215+
if (docB.path === docA.moveFrom.path) return A_FIRST
216+
if (docA.path === docB.moveFrom.path) return B_FIRST
217+
}
207218
}
208219

209220
return KEEP_ORDER

test/unit/sync/index.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1745,5 +1745,88 @@ describe('Sync', function() {
17451745
})
17461746
}
17471747
)
1748+
1749+
context(
1750+
'with two chained moves where one frees the path the other takes',
1751+
() => {
1752+
let moveAToC, moveBToA
1753+
beforeEach(async function() {
1754+
// i -> i* (must happen first: frees the path "i")
1755+
const srcA = await builders
1756+
.metadir()
1757+
.path('i')
1758+
.upToDate()
1759+
.create()
1760+
const dstA = await builders
1761+
.metadir()
1762+
.moveFrom(srcA)
1763+
.path('i*')
1764+
.changedSide('local')
1765+
.create()
1766+
// j -> i (must happen second: takes the freed path "i")
1767+
const srcB = await builders
1768+
.metadir()
1769+
.path('j')
1770+
.upToDate()
1771+
.create()
1772+
const dstB = await builders
1773+
.metadir()
1774+
.moveFrom(srcB)
1775+
.path('i')
1776+
.changedSide('local')
1777+
.create()
1778+
1779+
moveAToC = makeChange(dstA, 'MOVE', this)
1780+
moveBToA = makeChange(dstB, 'MOVE', this)
1781+
})
1782+
1783+
it('returns -1 when the freeing move is passed first', () => {
1784+
should(compareChanges(moveAToC, moveBToA)).eql(-1)
1785+
})
1786+
1787+
it('returns 1 when the freeing move is passed second', () => {
1788+
should(compareChanges(moveBToA, moveAToC)).eql(1)
1789+
})
1790+
}
1791+
)
1792+
1793+
context(
1794+
'with two unrelated moves where neither frees a path the other takes',
1795+
() => {
1796+
let moveX, moveY
1797+
beforeEach(async function() {
1798+
const srcX = await builders
1799+
.metadir()
1800+
.path('x')
1801+
.upToDate()
1802+
.create()
1803+
const dstX = await builders
1804+
.metadir()
1805+
.moveFrom(srcX)
1806+
.path('x-renamed')
1807+
.changedSide('local')
1808+
.create()
1809+
const srcY = await builders
1810+
.metadir()
1811+
.path('y')
1812+
.upToDate()
1813+
.create()
1814+
const dstY = await builders
1815+
.metadir()
1816+
.moveFrom(srcY)
1817+
.path('y-renamed')
1818+
.changedSide('local')
1819+
.create()
1820+
1821+
moveX = makeChange(dstX, 'MOVE', this)
1822+
moveY = makeChange(dstY, 'MOVE', this)
1823+
})
1824+
1825+
it('returns 0', () => {
1826+
should(compareChanges(moveX, moveY)).eql(0)
1827+
should(compareChanges(moveY, moveX)).eql(0)
1828+
})
1829+
}
1830+
)
17481831
})
17491832
})

test/unit/sync/multiple_errors.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,85 @@ describe('Multiple sync errors', function() {
244244
this.sync.scheduleRetry.restore()
245245
})
246246

247+
it('blocks a chained move when the move freeing its path fails', async function() {
248+
// Reproduces the remote rename case: i->i* (blocked on linux because
249+
// `*` is a reserved char) followed by j->i (must wait for i->i* to
250+
// free the destination path). Before the fix, j->i was attempted
251+
// anyway and failed with a "destination already exists" error.
252+
const srcI = await builders
253+
.metadir()
254+
.path('i')
255+
.upToDate()
256+
.create()
257+
const dstIStar = await builders
258+
.metadir()
259+
.moveFrom(srcI)
260+
.path('i*')
261+
.changedSide('local')
262+
.create()
263+
const srcJ = await builders
264+
.metadir()
265+
.path('j')
266+
.upToDate()
267+
.create()
268+
const dstIFromJ = await builders
269+
.metadir()
270+
.moveFrom(srcJ)
271+
.path('i')
272+
.changedSide('local')
273+
.create()
274+
275+
const changeI = {
276+
changes: [{ rev: dstIStar._rev }],
277+
doc: dstIStar,
278+
id: dstIStar._id,
279+
seq: 40,
280+
operation: { type: 'MOVE', side: 'local' }
281+
}
282+
const changeJ = {
283+
changes: [{ rev: dstIFromJ._rev }],
284+
doc: dstIFromJ,
285+
id: dstIFromJ._id,
286+
seq: 41,
287+
operation: { type: 'MOVE', side: 'local' }
288+
}
289+
290+
sinon
291+
.stub(this.sync, 'getNextChanges')
292+
.onFirstCall()
293+
.resolves([changeI, changeJ])
294+
.onSecondCall()
295+
.resolves([])
296+
297+
const applyStub = sinon.stub(this.sync, 'apply')
298+
applyStub.callsFake(async change => {
299+
if (change.id === dstIStar._id) throw blockingSyncError(change.doc)
300+
if (change.id === dstIFromJ._id) {
301+
throw new Error('j->i must not be applied while i->i* is blocked')
302+
}
303+
throw new Error(`Unexpected apply call for ${change.id}`)
304+
})
305+
306+
sinon.stub(this.sync, 'scheduleRetry').resolves()
307+
308+
await this.sync.syncBatch()
309+
310+
// i->i* was attempted (and blocked)...
311+
should(applyStub).have.been.calledOnce()
312+
should(applyStub.args[0][0].id).equal(dstIStar._id)
313+
314+
// ...and j->i was skipped as a dependent of the blocked i->i*.
315+
const appliedIds = applyStub.args.map(args => args[0].id)
316+
should(appliedIds).not.containEql(dstIFromJ._id)
317+
318+
// localSeq frozen at the failed change's predecessor (no success).
319+
should(await this.pouch.getLocalSeq()).equal(0)
320+
321+
this.sync.getNextChanges.restore()
322+
this.sync.apply.restore()
323+
this.sync.scheduleRetry.restore()
324+
})
325+
247326
it('freezes localSeq at last success before first failure (crash recovery)', async function() {
248327
const docA = await builders
249328
.metafile()

0 commit comments

Comments
 (0)