Skip to content
Open
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
24 changes: 23 additions & 1 deletion lib/cloud/sync/sync_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ part 'sync_engine_status.dart';
part 'sync_engine_realtime.dart';
part 'sync_engine_profile.dart';
part 'sync_engine_apply.dart';
part 'sync_engine_category_guard.dart';
part 'sync_engine_serialization.dart';
part 'sync_engine_pull.dart';

Expand Down Expand Up @@ -1180,6 +1181,14 @@ class SyncEngine implements app.SyncService {
logger.info('SyncEngine',
'pull #$pageIndex: applied ${outcome.applied}/${result.changes.length} (apply ${applyMs}ms, page total ${DateTime.now().difference(pageStart).inMilliseconds}ms)');
totalApplied += outcome.applied;
if (outcome.deferred) {
// Replay may start behind the saved cursor. Persist the retry point,
// not the old cursor, so the next ordinary pull can retry this page.
await appCursor.commit(nextSince ?? 0);
logger.info('SyncEngine',
'pull deferred at cursor=$nextSince until local category changes are pushed');
break;
}
if (outcome.blocked) {
logger.warning(
'SyncEngine', 'pull 被错误阻塞 cursor 停在 $nextSince — UI 应显示同步异常');
Expand Down Expand Up @@ -1213,6 +1222,7 @@ class SyncEngine implements app.SyncService {
}

/// 单页 apply。整页事务 try/catch:
/// - 待上传分类变更 → 整页暂缓,保留 cursor,不记录同步错误
/// - 不可恢复异常 → rollback + 错误入 [pullErrors] + return blocked
/// - SQLite busy/locked → 单条 retry 2 次
Future<_PullPageOutcome> _applyPullPage(
Expand All @@ -1223,6 +1233,7 @@ class SyncEngine implements app.SyncService {

try {
await db.transaction(() async {
await _ensureNoPendingCategoryChanges(changes);
for (final ch in changes) {
failingChange = ch;
final ok = await _applyOneWithBusyRetry(ch);
Expand All @@ -1237,6 +1248,10 @@ class SyncEngine implements app.SyncService {
logger.info('SyncEngine', 'pull: 应用 $applied / 跳过 $skipped (本页)');
}
return _PullPageOutcome(applied: applied, blocked: false);
} on _PendingCategoryChange catch (e) {
logger.info(
'SyncEngine', 'pull page deferred for unpushed category ${e.syncId}');
return const _PullPageOutcome(applied: 0, blocked: false, deferred: true);
} catch (e, st) {
// 整页 rollback 已自动完成(Drift transaction 抛错回滚)
logger.error(
Expand Down Expand Up @@ -1473,11 +1488,18 @@ class SyncHealthReport {

/// pull 单页处理结果。详见 [SyncEngine._applyPullPage]。
class _PullPageOutcome {
const _PullPageOutcome({required this.applied, required this.blocked});
const _PullPageOutcome({
required this.applied,
required this.blocked,
this.deferred = false,
});

/// 本页成功 apply 的条数。整页 rollback 时为 0。
final int applied;

/// 是否被错误阻塞(整页 rollback,cursor 不推进)。
final bool blocked;

/// The page waits for local category changes to leave the outbox.
final bool deferred;
}
55 changes: 55 additions & 0 deletions lib/cloud/sync/sync_engine_category_guard.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
part of 'sync_engine.dart';

class _PendingCategoryChange implements Exception {
const _PendingCategoryChange(this.syncId);

final String syncId;
}

extension SyncEngineCategoryGuardExt on SyncEngine {
/// Check inside the page transaction, before any writes or icon cleanup.
/// A deferred page must be retried after push, not skipped past its cursor.
Future<void> _ensureNoPendingCategoryChanges(
List<BeeCountCloudSyncChange> changes) async {
final categoryChanges = changes
.where((c) =>
c.entityType == 'category' &&
(c.action == 'delete' || c.payload != null))
.toList();
if (categoryChanges.isEmpty) return;

final deviceId = await _getDeviceId();
final applicable =
categoryChanges.where((c) => c.updatedByDeviceId != deviceId).toList();
if (applicable.isEmpty) return;

final affectedSyncIds = applicable.map((c) => c.entitySyncId).toSet();
final deletedSyncIds = applicable
.where((c) => c.action == 'delete')
.map((c) => c.entitySyncId)
.toSet();
if (deletedSyncIds.isNotEmpty) {
final parents = await (db.select(db.categories)
..where((c) => c.syncId.isIn(deletedSyncIds)))
.get();
if (parents.isNotEmpty) {
// Category deletion also removes direct children. Their unpushed
// edits must be protected even without a child event in this page.
final children = await (db.select(db.categories)
..where((c) => c.parentId.isIn(parents.map((p) => p.id))))
.get();
affectedSyncIds
.addAll(children.map((c) => c.syncId).whereType<String>());
}
}

final pending = await (db.select(db.localChanges)
..where((c) =>
c.entityType.equals('category') &
c.entitySyncId.isIn(affectedSyncIds) &
c.pushedAt.isNull())
..limit(1))
.getSingleOrNull();
if (pending != null) throw _PendingCategoryChange(pending.entitySyncId);
}
}
177 changes: 96 additions & 81 deletions lib/data/repositories/local/local_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1269,27 +1269,29 @@ class LocalRepository extends BaseRepository {
int? parentId,
String? syncId,
}) async {
final id = await _categoryRepo.createCategory(
name: name,
kind: kind,
icon: icon,
sortOrder: sortOrder,
level: level,
parentId: parentId,
syncId: syncId,
);
if (changeTracker != null) {
final cat = await _categoryRepo.getCategoryById(id);
if (cat?.syncId != null) {
await changeTracker!.recordUserGlobalChange(
entityType: 'category',
entityId: id,
entitySyncId: cat!.syncId!,
action: 'create',
);
return db.transaction(() async {
final id = await _categoryRepo.createCategory(
name: name,
kind: kind,
icon: icon,
sortOrder: sortOrder,
level: level,
parentId: parentId,
syncId: syncId,
);
if (changeTracker != null) {
final cat = await _categoryRepo.getCategoryById(id);
if (cat?.syncId != null) {
await changeTracker!.recordUserGlobalChange(
entityType: 'category',
entityId: id,
entitySyncId: cat!.syncId!,
action: 'create',
);
}
}
}
return id;
return id;
});
}

@override
Expand All @@ -1301,43 +1303,48 @@ class LocalRepository extends BaseRepository {
int? sortOrder,
String? syncId,
}) async {
final id = await _categoryRepo.createSubCategory(
parentId: parentId,
name: name,
kind: kind,
icon: icon,
sortOrder: sortOrder,
syncId: syncId,
);
if (changeTracker != null) {
final cat = await _categoryRepo.getCategoryById(id);
return db.transaction(() async {
final id = await _categoryRepo.createSubCategory(
parentId: parentId,
name: name,
kind: kind,
icon: icon,
sortOrder: sortOrder,
syncId: syncId,
);
if (changeTracker != null) {
final cat = await _categoryRepo.getCategoryById(id);
if (cat?.syncId != null) {
await changeTracker!.recordUserGlobalChange(
entityType: 'category',
entityId: id,
entitySyncId: cat!.syncId!,
action: 'create',
);
}
}
return id;
});
}

@override
Future<void> updateCategory(int id,
{String? name, String? icon, int? parentId, int? level}) async {
await db.transaction(() async {
final cat = changeTracker != null
? await _categoryRepo.getCategoryById(id)
: null;
await _categoryRepo.updateCategory(id,
name: name, icon: icon, parentId: parentId, level: level);
if (cat?.syncId != null) {
await changeTracker!.recordUserGlobalChange(
entityType: 'category',
entityId: id,
entitySyncId: cat!.syncId!,
action: 'create',
action: 'update',
);
}
}
return id;
}

@override
Future<void> updateCategory(int id,
{String? name, String? icon, int? parentId, int? level}) async {
final cat =
changeTracker != null ? await _categoryRepo.getCategoryById(id) : null;
await _categoryRepo.updateCategory(id,
name: name, icon: icon, parentId: parentId, level: level);
if (cat?.syncId != null) {
await changeTracker!.recordUserGlobalChange(
entityType: 'category',
entityId: id,
entitySyncId: cat!.syncId!,
action: 'update',
);
}
});
}

@override
Expand Down Expand Up @@ -1653,42 +1660,49 @@ class LocalRepository extends BaseRepository {
String? customIconPath,
String? communityIconId,
}) async {
await _categoryRepo.updateCategoryIcon(
id,
iconType: iconType,
icon: icon,
customIconPath: customIconPath,
communityIconId: communityIconId,
);
// 图标改动也需要 push 到服务端 —— 否则 mobile 改了图标 web 永远看不到。
// ledgerId=0:分类是 user-scoped,和 name 改动走同一条 push 通道。
if (changeTracker != null) {
final cat = await _categoryRepo.getCategoryById(id);
if (cat?.syncId != null) {
await changeTracker!.recordUserGlobalChange(
entityType: 'category',
entityId: id,
entitySyncId: cat!.syncId!,
action: 'update',
);
// Commit the edit and outbox marker together, so pull cannot observe an
// edited category without the pending change that protects it.
await db.transaction(() async {
await _categoryRepo.updateCategoryIcon(
id,
iconType: iconType,
icon: icon,
customIconPath: customIconPath,
communityIconId: communityIconId,
);
// 图标改动也需要 push 到服务端 —— 否则 mobile 改了图标 web 永远看不到。
// ledgerId=0:分类是 user-scoped,和 name 改动走同一条 push 通道。
if (changeTracker != null) {
final cat = await _categoryRepo.getCategoryById(id);
if (cat?.syncId != null) {
await changeTracker!.recordUserGlobalChange(
entityType: 'category',
entityId: id,
entitySyncId: cat!.syncId!,
action: 'update',
);
}
}
}
});
}

@override
Future<void> clearCategoryCustomIcon(int id, {String? materialIcon}) async {
await _categoryRepo.clearCategoryCustomIcon(id, materialIcon: materialIcon);
if (changeTracker != null) {
final cat = await _categoryRepo.getCategoryById(id);
if (cat?.syncId != null) {
await changeTracker!.recordUserGlobalChange(
entityType: 'category',
entityId: id,
entitySyncId: cat!.syncId!,
action: 'update',
);
await db.transaction(() async {
await _categoryRepo.clearCategoryCustomIcon(id,
materialIcon: materialIcon);
if (changeTracker != null) {
final cat = await _categoryRepo.getCategoryById(id);
if (cat?.syncId != null) {
await changeTracker!.recordUserGlobalChange(
entityType: 'category',
entityId: id,
entitySyncId: cat!.syncId!,
action: 'update',
);
}
}
}
});
}

@override
Expand Down Expand Up @@ -1922,7 +1936,8 @@ class LocalRepository extends BaseRepository {
throw StateError(resolveAppLocalizations(null).ledgerMissing);
}
if (ledger.isShared && ledger.myRole != 'owner') {
throw StateError(resolveAppLocalizations(null).sharedLedgerBalanceRestricted);
throw StateError(
resolveAppLocalizations(null).sharedLedgerBalanceRestricted);
}

final account = await getAccount(accountId);
Expand Down
Loading