fix(sync): preserve pull cursor on lock conflict

Propagate retryable import lock conflicts through CLI and Desktop pull adapters so scheduled pulls defer without advancing past unimported messages.
This commit is contained in:
digua committed 2026-07-13 01:01:42 +08:00
1 parent 8bee0fab4a
commit 5be001fea5
6 files changed
+147 -13

No files matched your search

+69
View File
@@ -215,6 +215,75 @@ describe('PullEngine', () => {
assert.deepEqual(checkedSessionIds, ['local_session_b'])
})
it('keeps the pull cursor stable when an import is deferred by another writer', async () => {
const session = createSession()
const dataSource = createDataSource()
dataSource.sessions = [session]
const page = writeTempJson({
chatlab: { version: '0.0.2', exportedAt: 100 },
meta: { name: 'Test Session', platform: 'test', type: 'group' },
members: [{ platformId: 'u1', accountName: 'Alice' }],
messages: [{ sender: 'u1', timestamp: 101, type: 0, content: 'hi' }],
sync: { hasMore: false, nextSince: 101 },
})
const sessionUpdates: Array<{ sessionId: string; updates: Partial<ImportSession> }> = []
const engine = createEngine({
files: [page],
dataSource,
sessionUpdates,
importResult: {
success: false,
newMessageCount: 0,
error: 'error.import_in_progress',
retryable: true,
},
})
const result = await withImmediateTimers(() => engine.executePullSession(dataSource.id, dataSource, session))
assert.equal(result.success, false)
assert.equal(result.error, 'error.import_in_progress')
assert.deepEqual(sessionUpdates, [])
})
it('keeps the pull cursor stable when a retry-page import is deferred', async () => {
const session = createSession()
const dataSource = createDataSource()
dataSource.sessions = [session]
const emptyInitialPage = writeTempJson({
chatlab: { version: '0.0.2', exportedAt: 100 },
meta: { name: 'Test Session', platform: 'test', type: 'group' },
members: [],
messages: [],
sync: { hasMore: false, nextSince: 100 },
})
const retryPage = writeTempJson({
chatlab: { version: '0.0.2', exportedAt: 100 },
meta: { name: 'Test Session', platform: 'test', type: 'group' },
members: [{ platformId: 'u1', accountName: 'Alice' }],
messages: [{ sender: 'u1', timestamp: 101, type: 0, content: 'x'.repeat(1200) }],
sync: { hasMore: false, nextSince: 101 },
})
const sessionUpdates: Array<{ sessionId: string; updates: Partial<ImportSession> }> = []
const engine = createEngine({
files: [emptyInitialPage, retryPage],
dataSource,
sessionUpdates,
importResult: {
success: false,
newMessageCount: 0,
error: 'error.import_in_progress',
retryable: true,
},
})
const result = await withImmediateTimers(() => engine.executePullSession(dataSource.id, dataSource, session))
assert.equal(result.success, false)
assert.equal(result.error, 'error.import_in_progress')
assert.deepEqual(sessionUpdates, [])
})
it('reports retry import failure instead of marking the pull successful', async () => {
const session = createSession()
const dataSource = createDataSource()
+13 -11
View File
@@ -17,6 +17,7 @@ import type {
SyncLogger,
FetchParams,
SyncMeta,
ImportResult,
PullSessionResult,
PullProgress,
} from './types'
@@ -223,17 +224,7 @@ export class PullEngine {
return Array.from(this.progressMap.values())
}
private async importTempFile(
baseUrl: string,
sess: ImportSession,
tempFile: string
): Promise<{
success: boolean
newMessageCount: number
sessionId?: string
error?: string
needFullResync?: boolean
}> {
private async importTempFile(baseUrl: string, sess: ImportSession, tempFile: string): Promise<ImportResult> {
let targetId = sess.targetSessionId
if (!targetId) {
const derived = deriveLocalSessionId(baseUrl, sess.remoteSessionId)
@@ -247,6 +238,13 @@ export class PullEngine {
return this.importer.importFile(tempFile, targetId || undefined, externalId)
}
private deferRetryableImport(sess: ImportSession, error?: string): PullSessionResult {
const errMsg = error || 'Import deferred'
this.logger.info(`[Pull] Deferring "${sess.name}": ${errMsg}`)
this.markProgressDone(sess.id)
return { success: false, newMessageCount: 0, error: errMsg }
}
async executePullSession(sourceId: string, ds: DataSource, sess: ImportSession): Promise<PullSessionResult> {
const currentDs = this.dsManager.get(sourceId)
if (!currentDs || !currentDs.sessions.some((s) => s.id === sess.id)) {
@@ -342,6 +340,8 @@ export class PullEngine {
}
if (!retryResult.success) {
if (retryResult.retryable) return this.deferRetryableImport(sess, retryResult.error)
const errMsg = retryResult.error || 'Import failed'
this.dsManager.updateSession(sourceId, sess.id, {
lastPullAt: Math.floor(Date.now() / 1000),
@@ -421,6 +421,8 @@ export class PullEngine {
}
if (!result.success) {
if (result.retryable) return this.deferRetryableImport(sess, result.error)
const errMsg = result.error || 'Import failed'
this.dsManager.updateSession(sourceId, sess.id, {
lastPullAt: Math.floor(Date.now() / 1000),
+2
View File
@@ -93,6 +93,8 @@ export interface ImportResult {
sessionId?: string
error?: string
needFullResync?: boolean
/** The source page was not imported and should be retried without advancing the pull cursor. */
retryable?: boolean
}
export interface PullSessionResult {