Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 11 additions & 0 deletions core/sync/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,17 @@ const compareChanges = (
// Handle move to dir after dir addition
if (docA.path.startsWith(docB.path + sep)) return B_FIRST
}
if (
opA.type === 'MOVE' &&
opB.type === 'MOVE' &&
docA.moveFrom != null &&
docB.moveFrom != null
) {
// Handle chained rename: A frees the path B will take (e.g. i->i*
// must happen before j->i so the destination path is free).
if (docB.path === docA.moveFrom.path) return A_FIRST
if (docA.path === docB.moveFrom.path) return B_FIRST
}
}

return KEEP_ORDER
Expand Down
83 changes: 83 additions & 0 deletions test/unit/sync/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1745,5 +1745,88 @@ describe('Sync', function() {
})
}
)

context(
'with two chained moves where one frees the path the other takes',
() => {
let moveAToC, moveBToA
beforeEach(async function() {
// i -> i* (must happen first: frees the path "i")
const srcA = await builders
.metadir()
.path('i')
.upToDate()
.create()
const dstA = await builders
.metadir()
.moveFrom(srcA)
.path('i*')
.changedSide('local')
.create()
// j -> i (must happen second: takes the freed path "i")
const srcB = await builders
.metadir()
.path('j')
.upToDate()
.create()
const dstB = await builders
.metadir()
.moveFrom(srcB)
.path('i')
.changedSide('local')
.create()

moveAToC = makeChange(dstA, 'MOVE', this)
moveBToA = makeChange(dstB, 'MOVE', this)
})

it('returns -1 when the freeing move is passed first', () => {
should(compareChanges(moveAToC, moveBToA)).eql(-1)
})

it('returns 1 when the freeing move is passed second', () => {
should(compareChanges(moveBToA, moveAToC)).eql(1)
})
}
)

context(
'with two unrelated moves where neither frees a path the other takes',
() => {
let moveX, moveY
beforeEach(async function() {
const srcX = await builders
.metadir()
.path('x')
.upToDate()
.create()
const dstX = await builders
.metadir()
.moveFrom(srcX)
.path('x-renamed')
.changedSide('local')
.create()
const srcY = await builders
.metadir()
.path('y')
.upToDate()
.create()
const dstY = await builders
.metadir()
.moveFrom(srcY)
.path('y-renamed')
.changedSide('local')
.create()

moveX = makeChange(dstX, 'MOVE', this)
moveY = makeChange(dstY, 'MOVE', this)
})

it('returns 0', () => {
should(compareChanges(moveX, moveY)).eql(0)
should(compareChanges(moveY, moveX)).eql(0)
})
}
)
})
})
79 changes: 79 additions & 0 deletions test/unit/sync/multiple_errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,85 @@ describe('Multiple sync errors', function() {
this.sync.scheduleRetry.restore()
})

it('blocks a chained move when the move freeing its path fails', async function() {
// Reproduces the remote rename case: i->i* (blocked on linux because
// `*` is a reserved char) followed by j->i (must wait for i->i* to
// free the destination path). Before the fix, j->i was attempted
// anyway and failed with a "destination already exists" error.
const srcI = await builders
.metadir()
.path('i')
.upToDate()
.create()
const dstIStar = await builders
.metadir()
.moveFrom(srcI)
.path('i*')
.changedSide('local')
.create()
const srcJ = await builders
.metadir()
.path('j')
.upToDate()
.create()
const dstIFromJ = await builders
.metadir()
.moveFrom(srcJ)
.path('i')
.changedSide('local')
.create()

const changeI = {
changes: [{ rev: dstIStar._rev }],
doc: dstIStar,
id: dstIStar._id,
seq: 40,
operation: { type: 'MOVE', side: 'local' }
}
const changeJ = {
changes: [{ rev: dstIFromJ._rev }],
doc: dstIFromJ,
id: dstIFromJ._id,
seq: 41,
operation: { type: 'MOVE', side: 'local' }
}

sinon
.stub(this.sync, 'getNextChanges')
.onFirstCall()
.resolves([changeI, changeJ])
.onSecondCall()
.resolves([])

const applyStub = sinon.stub(this.sync, 'apply')
applyStub.callsFake(async change => {
if (change.id === dstIStar._id) throw blockingSyncError(change.doc)
if (change.id === dstIFromJ._id) {
throw new Error('j->i must not be applied while i->i* is blocked')
}
throw new Error(`Unexpected apply call for ${change.id}`)
})

sinon.stub(this.sync, 'scheduleRetry').resolves()

await this.sync.syncBatch()

// i->i* was attempted (and blocked)...
should(applyStub).have.been.calledOnce()
should(applyStub.args[0][0].id).equal(dstIStar._id)

// ...and j->i was skipped as a dependent of the blocked i->i*.
const appliedIds = applyStub.args.map(args => args[0].id)
should(appliedIds).not.containEql(dstIFromJ._id)

// localSeq frozen at the failed change's predecessor (no success).
should(await this.pouch.getLocalSeq()).equal(0)

this.sync.getNextChanges.restore()
this.sync.apply.restore()
this.sync.scheduleRetry.restore()
})

it('freezes localSeq at last success before first failure (crash recovery)', async function() {
const docA = await builders
.metafile()
Expand Down
Loading