improve sync logic

This commit is contained in:
Fabian Freund
2026-05-27 07:04:33 +02:00
parent c3d3f726b7
commit 4b74223a3f
2 changed files with 120 additions and 30 deletions
@@ -46,6 +46,7 @@ class SyncTabsResult {
@DriftAccessor() @DriftAccessor()
class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin { class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
final _undoHistory = <String, TabData>{}; final _undoHistory = <String, TabData>{};
final _pendingParentIds = <String, String>{};
Timer? _clearHistoryTimer; Timer? _clearHistoryTimer;
static const closedTabTombstoneTtl = Duration(hours: 24); static const closedTabTombstoneTtl = Duration(hours: 24);
@@ -167,6 +168,54 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
); );
} }
Future<void> _resolvePendingParents() async {
if (_pendingParentIds.isEmpty) return;
final pendingChildren = selectOnly(db.tab)
..addColumns([db.tab.id])
..where(
db.tab.id.isIn(_pendingParentIds.keys) &
db.tab.parentId.isNull() &
db.tab.source.isNotValue(TabSource.manual.index),
);
final pendingChildIds = (await pendingChildren.get())
.map((row) => row.read(db.tab.id)!)
.toSet();
if (pendingChildIds.isEmpty) {
_pendingParentIds.clear();
return;
}
final resolvableParents = await getExistingTabIds(
pendingChildIds.map((childId) => _pendingParentIds[childId]!).toSet(),
).get().then((ids) => ids.toSet());
await batch((batch) {
for (final childId in pendingChildIds) {
final parentId = _pendingParentIds[childId];
if (parentId == null || !resolvableParents.contains(parentId)) {
continue;
}
batch.update(
db.tab,
TabCompanion(
parentId: Value(parentId),
source: const Value(TabSource.manual),
),
where: (t) => t.id.equals(childId),
);
}
});
_pendingParentIds.removeWhere(
(childId, parentId) =>
!pendingChildIds.contains(childId) ||
resolvableParents.contains(parentId),
);
}
Future<String> _generateOrderKey({ Future<String> _generateOrderKey({
required Value<String?> parentId, required Value<String?> parentId,
required Value<String?> containerId, required Value<String?> containerId,
@@ -1081,35 +1130,33 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
// change from Gecko. // change from Gecko.
final parentSyncEligibleIds = next.isEmpty final parentSyncEligibleIds = next.isEmpty
? const <String>{} ? const <String>{}
: { : await (() async {
for (final tab in await (select( final query = selectOnly(db.tab)
db.tab, ..addColumns([db.tab.id, db.tab.source])
)..where((t) => t.id.isIn(next.keys))).get()) ..where(db.tab.id.isIn(next.keys) & db.tab.parentId.isNull());
if (tab.source != TabSource.manual && tab.parentId == null) return {
tab.id, for (final row in await query.get())
}; if (row.readWithConverter(db.tab.source) != TabSource.manual)
row.read(db.tab.id)!,
};
})();
// Collect parent IDs that need database validation // Validate every candidate parent against the database — a parentId
final parentIdsToValidate = <String>{}; // present in `next` is not proof the row has been persisted yet (e.g.
final validatedParentIds = <String, String?>{}; // engine state snapshot arriving before the matching tab-list insert).
// Without the DB check we could write a parent_id pointing at a row
for (final state in next.values) { // that does not exist, triggering the self-referential FK violation
if (parentSyncEligibleIds.contains(state.id) && // and aborting the whole batch.
state.parentId != null) { final parentIdsToValidate = <String>{
if (next.containsKey(state.parentId)) { for (final state in next.values)
// Parent exists in current state if (parentSyncEligibleIds.contains(state.id) &&
validatedParentIds[state.id] = state.parentId; state.parentId != null)
} else { state.parentId!,
// Need to validate against database };
parentIdsToValidate.add(state.parentId!);
}
}
}
// Batch validate parent IDs that aren't in the current state
final existingParentIds = await getExistingTabIds( final existingParentIds = await getExistingTabIds(
parentIdsToValidate, parentIdsToValidate,
).get().then((ids) => ids.toSet()); ).get().then((ids) => ids.toSet());
final validatedParentIds = <String, String?>{};
final containerRepairCandidates = { final containerRepairCandidates = {
for (final state in next.values) for (final state in next.values)
@@ -1137,17 +1184,22 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
entry.key: containerId, entry.key: containerId,
}; };
// Complete validation map
for (final state in next.values) { for (final state in next.values) {
if (!parentSyncEligibleIds.contains(state.id) || if (!parentSyncEligibleIds.contains(state.id)) {
validatedParentIds.containsKey(state.id) || _pendingParentIds.remove(state.id);
state.parentId == null) { continue;
}
if (state.parentId == null) {
_pendingParentIds.remove(state.id);
continue; continue;
} }
// This parent ID needed database validation
if (existingParentIds.contains(state.parentId)) { if (existingParentIds.contains(state.parentId)) {
validatedParentIds[state.id] = state.parentId; validatedParentIds[state.id] = state.parentId;
_pendingParentIds.remove(state.id);
} else {
_pendingParentIds[state.id] = state.parentId!;
} }
} }
@@ -1220,6 +1272,13 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
}); });
} }
final retainedTabIds = retainTabIds.toSet();
_pendingParentIds.removeWhere(
(childId, parentId) =>
!retainedTabIds.contains(childId) ||
!retainedTabIds.contains(parentId),
);
var currentOrderKey = await db.containerDao var currentOrderKey = await db.containerDao
.generateLeadingOrderKey(null) .generateLeadingOrderKey(null)
.getSingle(); .getSingle();
@@ -1242,6 +1301,8 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
onConflict: DoNothing(), onConflict: DoNothing(),
); );
await _resolvePendingParents();
return SyncTabsResult( return SyncTabsResult(
deletedIsolationContextIds: deletedIsolationContextIds, deletedIsolationContextIds: deletedIsolationContextIds,
deletedCount: deleted.length, deletedCount: deleted.length,
@@ -150,6 +150,35 @@ void main() {
}, },
); );
test(
'tab-list sync resolves an unresolved engine parent after inserting the parent row',
() async {
await _insertTabs(db, const [
_TabFixture('child', source: TabSource.addedEvent),
]);
await db.tabDao.updateTabs(null, {
'child': _tabState('child', parentId: 'late-parent'),
});
final unresolvedChild = await db.tabDao
.getTabDataById('child')
.getSingleOrNull();
expect(unresolvedChild, isNotNull);
expect(unresolvedChild!.parentId, isNull);
expect(unresolvedChild.source, TabSource.addedEvent);
await db.tabDao.syncTabs(retainTabIds: const ['late-parent', 'child']);
final resolvedChild = await db.tabDao
.getTabDataById('child')
.getSingleOrNull();
expect(resolvedChild, isNotNull);
expect(resolvedChild!.parentId, 'late-parent');
expect(resolvedChild.source, TabSource.manual);
},
);
test( test(
'content-state sync ignores parent-only changes with unresolved parents', 'content-state sync ignores parent-only changes with unresolved parents',
() async { () async {