mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
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:
1 parent
8bee0fab4a
commit
5be001fea5
6 files changed
+147
-13
No files matched your search
@@ -4,7 +4,14 @@ import os from 'node:os'
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import type { PathProvider } from '@openchatlab/core'
|
import type { PathProvider } from '@openchatlab/core'
|
||||||
import { DatabaseManager, DataDirCompatibilityError, raiseDataDirMinRuntimeVersion } from '@openchatlab/node-runtime'
|
import {
|
||||||
|
DatabaseManager,
|
||||||
|
DataDirCompatibilityError,
|
||||||
|
IMPORT_IN_PROGRESS_ERROR_KEY,
|
||||||
|
raiseDataDirMinRuntimeVersion,
|
||||||
|
withDataDirImportLock,
|
||||||
|
} from '@openchatlab/node-runtime'
|
||||||
|
import { streamImport } from '../import/stream-import'
|
||||||
import { DirectImporter } from './adapters'
|
import { DirectImporter } from './adapters'
|
||||||
|
|
||||||
const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node')
|
const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node')
|
||||||
@@ -57,3 +64,40 @@ test('DirectImporter.sessionExists preserves session DBs when compatibility gate
|
|||||||
)
|
)
|
||||||
assert.equal(fs.existsSync(dbPath), true)
|
assert.equal(fs.existsSync(dbPath), true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('DirectImporter marks an incremental import lock conflict as retryable', async (t) => {
|
||||||
|
const root = makeTempDir()
|
||||||
|
const pathProvider = createPathProvider(root)
|
||||||
|
const manager = new DatabaseManager(pathProvider, {
|
||||||
|
nativeBinding,
|
||||||
|
runtime: { version: '0.30.2', kind: 'cli' },
|
||||||
|
})
|
||||||
|
t.after(() => {
|
||||||
|
manager.closeAll()
|
||||||
|
fs.rmSync(root, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const chatFile = path.join(root, 'chatlab.json')
|
||||||
|
fs.writeFileSync(
|
||||||
|
chatFile,
|
||||||
|
JSON.stringify({
|
||||||
|
chatlab: { version: '0.0.2', exportedAt: 100 },
|
||||||
|
meta: { name: 'Test Session', platform: 'instagram', type: 'private', ownerId: 'u1' },
|
||||||
|
members: [{ platformId: 'u1', accountName: 'Alice' }],
|
||||||
|
messages: [{ sender: 'u1', accountName: 'Alice', timestamp: 101, type: 0, content: 'hi' }],
|
||||||
|
}),
|
||||||
|
'utf-8'
|
||||||
|
)
|
||||||
|
|
||||||
|
const seeded = await streamImport(manager, chatFile, { sessionId: 'local_session' })
|
||||||
|
assert.equal(seeded.success, true)
|
||||||
|
|
||||||
|
const importer = new DirectImporter(manager)
|
||||||
|
const result = await withDataDirImportLock(manager.getUserDataDir(), () =>
|
||||||
|
importer.importFile(chatFile, 'local_session', 'external_session')
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(result.success, false)
|
||||||
|
assert.equal(result.error, IMPORT_IN_PROGRESS_ERROR_KEY)
|
||||||
|
assert.equal(result.retryable, true)
|
||||||
|
})
|
||||||
@@ -14,7 +14,7 @@ import type { HttpFetcher, DataImporter, SyncNotifier, ImportResult, FetchParams
|
|||||||
import { NOOP_LOGGER } from '@openchatlab/sync'
|
import { NOOP_LOGGER } from '@openchatlab/sync'
|
||||||
import { buildPullUrl } from '@openchatlab/sync'
|
import { buildPullUrl } from '@openchatlab/sync'
|
||||||
import type { DatabaseManager } from '@openchatlab/node-runtime'
|
import type { DatabaseManager } from '@openchatlab/node-runtime'
|
||||||
import { DataDirCompatibilityError } from '@openchatlab/node-runtime'
|
import { DataDirCompatibilityError, IMPORT_IN_PROGRESS_ERROR_KEY } from '@openchatlab/node-runtime'
|
||||||
import { streamImport, incrementalImport } from '../import/stream-import'
|
import { streamImport, incrementalImport } from '../import/stream-import'
|
||||||
|
|
||||||
function getTempFilePath(ext: string): string {
|
function getTempFilePath(ext: string): string {
|
||||||
@@ -120,6 +120,10 @@ export class DirectImporter implements DataImporter {
|
|||||||
if (result.error === 'error.session_not_found' || result.error?.includes('no such table')) {
|
if (result.error === 'error.session_not_found' || result.error?.includes('no such table')) {
|
||||||
return { success: false, newMessageCount: 0, sessionId, needFullResync: true }
|
return { success: false, newMessageCount: 0, sessionId, needFullResync: true }
|
||||||
}
|
}
|
||||||
|
if (result.error === IMPORT_IN_PROGRESS_ERROR_KEY) {
|
||||||
|
this.logger.info(`[DirectImporter] Incremental import deferred: another import is in progress`)
|
||||||
|
return { success: false, newMessageCount: 0, sessionId, error: result.error, retryable: true }
|
||||||
|
}
|
||||||
|
|
||||||
return { success: false, newMessageCount: 0, sessionId, error: result.error }
|
return { success: false, newMessageCount: 0, sessionId, error: result.error }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -137,6 +141,10 @@ export class DirectImporter implements DataImporter {
|
|||||||
this.logger.info(`[DirectImporter] Full import OK: +${newMessageCount} messages`)
|
this.logger.info(`[DirectImporter] Full import OK: +${newMessageCount} messages`)
|
||||||
return { success: true, newMessageCount, sessionId: result.sessionId ?? externalId }
|
return { success: true, newMessageCount, sessionId: result.sessionId ?? externalId }
|
||||||
}
|
}
|
||||||
|
if (result.error === IMPORT_IN_PROGRESS_ERROR_KEY) {
|
||||||
|
this.logger.info(`[DirectImporter] Full import deferred: another import is in progress`)
|
||||||
|
return { success: false, newMessageCount: 0, error: result.error, retryable: true }
|
||||||
|
}
|
||||||
|
|
||||||
return { success: false, newMessageCount: 0, error: result.error }
|
return { success: false, newMessageCount: 0, error: result.error }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { openDatabase } from '../database/core'
|
|||||||
import * as worker from '../worker/workerManager'
|
import * as worker from '../worker/workerManager'
|
||||||
import { getPathProvider } from '../path-context'
|
import { getPathProvider } from '../path-context'
|
||||||
import { assertDesktopDataDirCompatible, getDesktopAppVersion } from '../runtime-compat'
|
import { assertDesktopDataDirCompatible, getDesktopAppVersion } from '../runtime-compat'
|
||||||
|
import { IMPORT_IN_PROGRESS_ERROR_KEY } from '@openchatlab/node-runtime'
|
||||||
import type { HttpFetcher, DataImporter, SyncNotifier, ImportResult, FetchParams, SyncLogger } from '@openchatlab/sync'
|
import type { HttpFetcher, DataImporter, SyncNotifier, ImportResult, FetchParams, SyncLogger } from '@openchatlab/sync'
|
||||||
import { buildPullUrl, NOOP_LOGGER } from '@openchatlab/sync'
|
import { buildPullUrl, NOOP_LOGGER } from '@openchatlab/sync'
|
||||||
|
|
||||||
@@ -136,6 +137,10 @@ export class WorkerImporter implements DataImporter {
|
|||||||
this.logger.warn(`[Pull] Session ${sessionId} not found or schema invalid, need full resync`)
|
this.logger.warn(`[Pull] Session ${sessionId} not found or schema invalid, need full resync`)
|
||||||
return { success: false, newMessageCount: 0, sessionId, needFullResync: true }
|
return { success: false, newMessageCount: 0, sessionId, needFullResync: true }
|
||||||
}
|
}
|
||||||
|
if (result.error === IMPORT_IN_PROGRESS_ERROR_KEY) {
|
||||||
|
this.logger.info(`[Pull] Incremental import deferred: another import is in progress`)
|
||||||
|
return { success: false, newMessageCount: 0, sessionId, error: result.error, retryable: true }
|
||||||
|
}
|
||||||
this.logger.error(`[Pull] Incremental import failed: ${result.error}`)
|
this.logger.error(`[Pull] Incremental import failed: ${result.error}`)
|
||||||
return { success: false, newMessageCount: 0, sessionId, error: result.error }
|
return { success: false, newMessageCount: 0, sessionId, error: result.error }
|
||||||
}
|
}
|
||||||
@@ -148,6 +153,10 @@ export class WorkerImporter implements DataImporter {
|
|||||||
this.logger.info(`[Pull] streamImport OK: session=${result.sessionId}, messages=${msgCount}`)
|
this.logger.info(`[Pull] streamImport OK: session=${result.sessionId}, messages=${msgCount}`)
|
||||||
return { success: true, newMessageCount: msgCount, sessionId: result.sessionId }
|
return { success: true, newMessageCount: msgCount, sessionId: result.sessionId }
|
||||||
}
|
}
|
||||||
|
if (result.error === IMPORT_IN_PROGRESS_ERROR_KEY) {
|
||||||
|
this.logger.info(`[Pull] Full import deferred: another import is in progress`)
|
||||||
|
return { success: false, newMessageCount: 0, error: result.error, retryable: true }
|
||||||
|
}
|
||||||
this.logger.error(`[Pull] streamImport failed: ${result.error}`)
|
this.logger.error(`[Pull] streamImport failed: ${result.error}`)
|
||||||
return { success: false, newMessageCount: 0, error: result.error }
|
return { success: false, newMessageCount: 0, error: result.error }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,6 +215,75 @@ describe('PullEngine', () => {
|
|||||||
assert.deepEqual(checkedSessionIds, ['local_session_b'])
|
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 () => {
|
it('reports retry import failure instead of marking the pull successful', async () => {
|
||||||
const session = createSession()
|
const session = createSession()
|
||||||
const dataSource = createDataSource()
|
const dataSource = createDataSource()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
SyncLogger,
|
SyncLogger,
|
||||||
FetchParams,
|
FetchParams,
|
||||||
SyncMeta,
|
SyncMeta,
|
||||||
|
ImportResult,
|
||||||
PullSessionResult,
|
PullSessionResult,
|
||||||
PullProgress,
|
PullProgress,
|
||||||
} from './types'
|
} from './types'
|
||||||
@@ -223,17 +224,7 @@ export class PullEngine {
|
|||||||
return Array.from(this.progressMap.values())
|
return Array.from(this.progressMap.values())
|
||||||
}
|
}
|
||||||
|
|
||||||
private async importTempFile(
|
private async importTempFile(baseUrl: string, sess: ImportSession, tempFile: string): Promise<ImportResult> {
|
||||||
baseUrl: string,
|
|
||||||
sess: ImportSession,
|
|
||||||
tempFile: string
|
|
||||||
): Promise<{
|
|
||||||
success: boolean
|
|
||||||
newMessageCount: number
|
|
||||||
sessionId?: string
|
|
||||||
error?: string
|
|
||||||
needFullResync?: boolean
|
|
||||||
}> {
|
|
||||||
let targetId = sess.targetSessionId
|
let targetId = sess.targetSessionId
|
||||||
if (!targetId) {
|
if (!targetId) {
|
||||||
const derived = deriveLocalSessionId(baseUrl, sess.remoteSessionId)
|
const derived = deriveLocalSessionId(baseUrl, sess.remoteSessionId)
|
||||||
@@ -247,6 +238,13 @@ export class PullEngine {
|
|||||||
return this.importer.importFile(tempFile, targetId || undefined, externalId)
|
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> {
|
async executePullSession(sourceId: string, ds: DataSource, sess: ImportSession): Promise<PullSessionResult> {
|
||||||
const currentDs = this.dsManager.get(sourceId)
|
const currentDs = this.dsManager.get(sourceId)
|
||||||
if (!currentDs || !currentDs.sessions.some((s) => s.id === sess.id)) {
|
if (!currentDs || !currentDs.sessions.some((s) => s.id === sess.id)) {
|
||||||
@@ -342,6 +340,8 @@ export class PullEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!retryResult.success) {
|
if (!retryResult.success) {
|
||||||
|
if (retryResult.retryable) return this.deferRetryableImport(sess, retryResult.error)
|
||||||
|
|
||||||
const errMsg = retryResult.error || 'Import failed'
|
const errMsg = retryResult.error || 'Import failed'
|
||||||
this.dsManager.updateSession(sourceId, sess.id, {
|
this.dsManager.updateSession(sourceId, sess.id, {
|
||||||
lastPullAt: Math.floor(Date.now() / 1000),
|
lastPullAt: Math.floor(Date.now() / 1000),
|
||||||
@@ -421,6 +421,8 @@ export class PullEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
if (result.retryable) return this.deferRetryableImport(sess, result.error)
|
||||||
|
|
||||||
const errMsg = result.error || 'Import failed'
|
const errMsg = result.error || 'Import failed'
|
||||||
this.dsManager.updateSession(sourceId, sess.id, {
|
this.dsManager.updateSession(sourceId, sess.id, {
|
||||||
lastPullAt: Math.floor(Date.now() / 1000),
|
lastPullAt: Math.floor(Date.now() / 1000),
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ export interface ImportResult {
|
|||||||
sessionId?: string
|
sessionId?: string
|
||||||
error?: string
|
error?: string
|
||||||
needFullResync?: boolean
|
needFullResync?: boolean
|
||||||
|
/** The source page was not imported and should be retried without advancing the pull cursor. */
|
||||||
|
retryable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PullSessionResult {
|
export interface PullSessionResult {
|
||||||
|
|||||||
Reference in new issue
Block a user