Optimize SyncFavorite to only update a column

This commit is contained in:
Ming Ming 2022-07-28 03:00:42 +08:00
parent 450f43694a
commit 49b5901149
9 changed files with 224 additions and 75 deletions

View file

@ -113,10 +113,11 @@ class ListFavoriteBloc
emit(ListFavoriteBlocSuccess(ev.account, remote)); emit(ListFavoriteBlocSuccess(ev.account, remote));
if (cache != null) { if (cache != null) {
CacheFavorite(_c)(ev.account, remote, cache: cache) CacheFavorite(_c)(ev.account, remote.map((f) => f.fileId!))
.onError((e, stackTrace) { .onError((e, stackTrace) {
_log.shout( _log.shout(
"[_onEventQuery] Failed while CacheFavorite", e, stackTrace); "[_onEventQuery] Failed while CacheFavorite", e, stackTrace);
return -1;
}); });
} }
} catch (e, stackTrace) { } catch (e, stackTrace) {

View file

@ -291,7 +291,7 @@ class ScanAccountDirBloc
// no data in this bloc, ignore // no data in this bloc, ignore
return; return;
} }
if ((ev.newFavorites + ev.removedFavorites).any(_isFileOfInterest)) { if (ev.account.compareServerIdentity(account)) {
_refreshThrottler.trigger( _refreshThrottler.trigger(
maxResponceTime: const Duration(seconds: 3), maxResponceTime: const Duration(seconds: 3),
maxPendingCount: 10, maxPendingCount: 10,

View file

@ -12,6 +12,13 @@ class Favorite with EquatableMixin {
"fileId: '$fileId', " "fileId: '$fileId', "
"}"; "}";
Favorite copyWith({
int? fileId,
}) =>
Favorite(
fileId: fileId ?? this.fileId,
);
@override @override
get props => [ get props => [
fileId, fileId,

View file

@ -110,12 +110,9 @@ class ShareRemovedEvent {
} }
class FavoriteResyncedEvent { class FavoriteResyncedEvent {
const FavoriteResyncedEvent( const FavoriteResyncedEvent(this.account);
this.account, this.newFavorites, this.removedFavorites);
final Account account; final Account account;
final List<File> newFavorites;
final List<File> removedFavorites;
} }
class ThemeChangedEvent {} class ThemeChangedEvent {}

View file

@ -5,81 +5,92 @@ import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart'; import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/sqlite_table.dart' as sql; import 'package:nc_photos/entity/sqlite_table.dart' as sql;
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql; import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/event/event.dart'; import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/list_util.dart' as list_util; import 'package:nc_photos/list_util.dart' as list_util;
import 'package:nc_photos/use_case/list_favorite_offline.dart'; import 'package:nc_photos/object_extension.dart';
class CacheFavorite { class CacheFavorite {
CacheFavorite(this._c) CacheFavorite(this._c) : assert(require(_c));
: assert(require(_c)),
assert(ListFavoriteOffline.require(_c));
static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb); static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb);
/// Cache favorites /// Cache favorites using results from remote
Future<void> call( ///
Account account, /// Return number of files updated
List<File> remote, { Future<int> call(Account account, Iterable<int> remoteFileIds) async {
List<File>? cache, _log.info("[call] Cache favorites");
}) async { final remote = remoteFileIds.sorted(Comparable.compare);
cache ??= await ListFavoriteOffline(_c)(account); final updateCount = await _c.sqliteDb.use((db) async {
final remoteSorted = final dbAccount = await db.accountOf(account);
remote.sorted((a, b) => a.fileId!.compareTo(b.fileId!)); final cache = await _getCacheFavorites(db, dbAccount);
final cacheSorted = cache.sorted((a, b) => a.fileId!.compareTo(b.fileId!)); final cacheMap =
final result = list_util.diffWith<File>( Map.fromEntries(cache.map((e) => MapEntry(e.fileId, e.rowId)));
cacheSorted, remoteSorted, (a, b) => a.fileId!.compareTo(b.fileId!)); final diff =
final newFavorites = result.item1; list_util.diff(cacheMap.keys.sorted(Comparable.compare), remote);
final removedFavorites = final newFileIds = diff.item1;
result.item2.map((f) => f.copyWith(isFavorite: false)).toList(); _log.info("[call] New favorites: ${newFileIds.toReadableString()}");
final newFileIds = newFavorites.map((f) => f.fileId!).toList(); final removedFildIds = diff.item2;
final removedFileIds = removedFavorites.map((f) => f.fileId!).toList(); _log.info(
if (newFileIds.isEmpty && removedFileIds.isEmpty) { "[call] Removed favorites: ${removedFildIds.toReadableString()}");
return;
} var updateCount = 0;
await _c.sqliteDb.use((db) async { if (newFileIds.isNotEmpty) {
final rowIds = await db.accountFileRowIdsByFileIds( final rowIds = await db.accountFileRowIdsByFileIds(newFileIds,
newFileIds + removedFileIds, sqlAccount: dbAccount);
appAccount: account, final count = await (db.update(db.accountFiles)
); ..where(
final rowIdsMap = (t) => t.rowId.isIn(rowIds.map((id) => id.accountFileRowId))))
Map.fromEntries(rowIds.map((e) => MapEntry(e.fileId, e))); .write(
await db.batch((batch) { const sql.AccountFilesCompanion(isFavorite: sql.Value(true)));
for (final id in newFileIds) { _log.info("[call] Updated $count row (new)");
try { updateCount += count;
batch.update( }
db.accountFiles, if (removedFildIds.isNotEmpty) {
const sql.AccountFilesCompanion(isFavorite: sql.Value(true)), final count = await (db.update(db.accountFiles)
where: (sql.$AccountFilesTable t) => ..where((t) =>
t.rowId.equals(rowIdsMap[id]!.accountFileRowId), t.account.equals(dbAccount.rowId) &
); t.file.isIn(removedFildIds.map((id) => cacheMap[id]))))
} catch (e, stackTrace) { .write(
_log.shout("[call] File not found in DB: $id", e, stackTrace); const sql.AccountFilesCompanion(isFavorite: sql.Value(false)));
} _log.info("[call] Updated $count row (remove)");
} updateCount += count;
for (final id in removedFileIds) { }
try { return updateCount;
batch.update(
db.accountFiles,
const sql.AccountFilesCompanion(isFavorite: sql.Value(null)),
where: (sql.$AccountFilesTable t) =>
t.rowId.equals(rowIdsMap[id]!.accountFileRowId),
);
} catch (e, stackTrace) {
_log.shout("[call] File not found in DB: $id", e, stackTrace);
}
}
});
}); });
KiwiContainer() if (updateCount > 0) {
.resolve<EventBus>() KiwiContainer().resolve<EventBus>().fire(FavoriteResyncedEvent(account));
.fire(FavoriteResyncedEvent(account, newFavorites, removedFavorites)); }
return updateCount;
}
Future<List<_FileRowIdWithFileId>> _getCacheFavorites(
sql.SqliteDb db, sql.Account dbAccount) async {
final query = db.queryFiles().run((q) {
q
..setQueryMode(sql.FilesQueryMode.expression,
expressions: [db.files.rowId, db.files.fileId])
..setSqlAccount(dbAccount)
..byFavorite(true);
return q.build();
});
return await query
.map((r) => _FileRowIdWithFileId(
r.read(db.files.rowId)!, r.read(db.files.fileId)!))
.get();
} }
final DiContainer _c; final DiContainer _c;
static final _log = Logger("use_case.cache_favorite.CacheFavorite"); static final _log = Logger("use_case.cache_favorite.CacheFavorite");
} }
class _FileRowIdWithFileId {
const _FileRowIdWithFileId(this.rowId, this.fileId);
final int rowId;
final int fileId;
}

View file

@ -1,22 +1,34 @@
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart'; import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/use_case/cache_favorite.dart'; import 'package:nc_photos/use_case/cache_favorite.dart';
import 'package:nc_photos/use_case/list_favorite.dart';
class SyncFavorite { class SyncFavorite {
SyncFavorite(this._c) SyncFavorite(this._c)
: assert(require(_c)), : assert(require(_c)),
assert(CacheFavorite.require(_c)), assert(CacheFavorite.require(_c));
assert(ListFavorite.require(_c));
static bool require(DiContainer c) => true; static bool require(DiContainer c) => DiContainer.has(c, DiType.favoriteRepo);
/// Sync favorites in AppDb with remote server /// Sync favorites in cache db with remote server
Future<void> call(Account account) async { ///
/// Return number of files updated
Future<int> call(Account account) async {
_log.info("[call] Sync favorites with remote"); _log.info("[call] Sync favorites with remote");
final remote = await ListFavorite(_c)(account); final remote = await _getRemoteFavoriteFileIds(account);
await CacheFavorite(_c)(account, remote); return await CacheFavorite(_c)(account, remote);
}
Future<List<int>> _getRemoteFavoriteFileIds(Account account) async {
final fileIds = <int>[];
for (final r in account.roots) {
final favorites = await _c.favoriteRepo
.list(account, File(path: file_util.unstripPath(account, r)));
fileIds.addAll(favorites.map((f) => f.fileId));
}
return fileIds;
} }
final DiContainer _c; final DiContainer _c;

View file

@ -7,6 +7,7 @@ import 'package:nc_photos/account.dart';
import 'package:nc_photos/ci_string.dart'; import 'package:nc_photos/ci_string.dart';
import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/favorite.dart';
import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/entity/file_util.dart' as file_util;
@ -98,6 +99,29 @@ class MockEventBus implements EventBus {
final _streamController = StreamController.broadcast(); final _streamController = StreamController.broadcast();
} }
class MockFavoriteRepo implements FavoriteRepo {
@override
FavoriteDataSource get dataSrc => throw UnimplementedError();
@override
Future<List<Favorite>> list(Account account, File dir) {
throw UnimplementedError();
}
}
class MockFavoriteMemoryRepo extends MockFavoriteRepo {
MockFavoriteMemoryRepo([
List<Favorite> initialData = const [],
]) : favorite = initialData.map((a) => a.copyWith()).toList();
@override
list(Account account, File dir) async {
return favorite.toList();
}
final List<Favorite> favorite;
}
/// Mock of [FileDataSource] where all methods will throw UnimplementedError /// Mock of [FileDataSource] where all methods will throw UnimplementedError
class MockFileDataSource implements FileDataSource { class MockFileDataSource implements FileDataSource {
@override @override

View file

@ -38,6 +38,7 @@ class FilesBuilder {
DateTime? lastModified, DateTime? lastModified,
bool isCollection = false, bool isCollection = false,
bool hasPreview = true, bool hasPreview = true,
bool? isFavorite,
String ownerId = "admin", String ownerId = "admin",
String? ownerDisplayName, String? ownerDisplayName,
Metadata? metadata, Metadata? metadata,
@ -52,6 +53,7 @@ class FilesBuilder {
isCollection: isCollection, isCollection: isCollection,
hasPreview: hasPreview, hasPreview: hasPreview,
fileId: fileId++, fileId: fileId++,
isFavorite: isFavorite,
ownerId: ownerId.toCi(), ownerId: ownerId.toCi(),
ownerDisplayName: ownerDisplayName ?? ownerId.toString(), ownerDisplayName: ownerDisplayName ?? ownerId.toString(),
metadata: metadata, metadata: metadata,
@ -65,6 +67,7 @@ class FilesBuilder {
String? etag, String? etag,
DateTime? lastModified, DateTime? lastModified,
bool hasPreview = true, bool hasPreview = true,
bool? isFavorite,
String ownerId = "admin", String ownerId = "admin",
String? ownerDisplayName, String? ownerDisplayName,
}) => }) =>
@ -75,6 +78,7 @@ class FilesBuilder {
etag: etag, etag: etag,
lastModified: lastModified, lastModified: lastModified,
hasPreview: hasPreview, hasPreview: hasPreview,
isFavorite: isFavorite,
ownerId: ownerId, ownerId: ownerId,
ownerDisplayName: ownerDisplayName, ownerDisplayName: ownerDisplayName,
); );
@ -85,6 +89,7 @@ class FilesBuilder {
String? etag, String? etag,
DateTime? lastModified, DateTime? lastModified,
bool hasPreview = true, bool hasPreview = true,
bool? isFavorite,
String ownerId = "admin", String ownerId = "admin",
String? ownerDisplayName, String? ownerDisplayName,
OrNull<Metadata>? metadata, OrNull<Metadata>? metadata,
@ -96,6 +101,7 @@ class FilesBuilder {
etag: etag, etag: etag,
lastModified: lastModified, lastModified: lastModified,
hasPreview: hasPreview, hasPreview: hasPreview,
isFavorite: isFavorite,
ownerId: ownerId, ownerId: ownerId,
ownerDisplayName: ownerDisplayName, ownerDisplayName: ownerDisplayName,
metadata: metadata?.obj ?? metadata: metadata?.obj ??
@ -111,6 +117,7 @@ class FilesBuilder {
int contentLength = 1024, int contentLength = 1024,
String? etag, String? etag,
DateTime? lastModified, DateTime? lastModified,
bool? isFavorite,
String ownerId = "admin", String ownerId = "admin",
String? ownerDisplayName, String? ownerDisplayName,
}) => }) =>
@ -120,6 +127,7 @@ class FilesBuilder {
lastModified: lastModified, lastModified: lastModified,
isCollection: true, isCollection: true,
hasPreview: false, hasPreview: false,
isFavorite: isFavorite,
ownerId: ownerId, ownerId: ownerId,
ownerDisplayName: ownerDisplayName, ownerDisplayName: ownerDisplayName,
); );

View file

@ -0,0 +1,89 @@
import 'package:drift/drift.dart' as sql;
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/favorite.dart';
import 'package:nc_photos/entity/sqlite_table.dart' as sql;
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/use_case/sync_favorite.dart';
import 'package:test/test.dart';
import '../mock_type.dart';
import '../test_util.dart' as util;
void main() {
group("SyncFavorite", () {
test("new", _new);
test("remove", _remove);
});
}
Future<void> _new() async {
final account = util.buildAccount();
final files = (util.FilesBuilder(initialFileId: 100)
..addDir("admin")
..addJpeg("admin/test1.jpg", isFavorite: true)
..addJpeg("admin/test2.jpg", isFavorite: true)
..addJpeg("admin/test3.jpg")
..addJpeg("admin/test4.jpg")
..addJpeg("admin/test5.jpg"))
.build();
final c = DiContainer(
favoriteRepo: MockFavoriteMemoryRepo([
const Favorite(fileId: 101),
const Favorite(fileId: 102),
const Favorite(fileId: 103),
const Favorite(fileId: 104),
]),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
await SyncFavorite(c)(account);
expect(
await listSqliteDbFavoriteFileIds(c.sqliteDb),
{101, 102, 103, 104},
);
}
Future<void> _remove() async {
final account = util.buildAccount();
final files = (util.FilesBuilder(initialFileId: 100)
..addDir("admin")
..addJpeg("admin/test1.jpg", isFavorite: true)
..addJpeg("admin/test2.jpg", isFavorite: true)
..addJpeg("admin/test3.jpg", isFavorite: true)
..addJpeg("admin/test4.jpg", isFavorite: true)
..addJpeg("admin/test5.jpg"))
.build();
final c = DiContainer(
favoriteRepo: MockFavoriteMemoryRepo([
const Favorite(fileId: 103),
const Favorite(fileId: 104),
]),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
await SyncFavorite(c)(account);
expect(
await listSqliteDbFavoriteFileIds(c.sqliteDb),
{103, 104},
);
}
Future<Set<int>> listSqliteDbFavoriteFileIds(sql.SqliteDb db) async {
final query = db.selectOnly(db.files).join([
sql.innerJoin(
db.accountFiles, db.accountFiles.file.equalsExp(db.files.rowId)),
])
..addColumns([db.files.fileId])
..where(db.accountFiles.isFavorite.equals(true));
return (await query.map((r) => r.read(db.files.fileId)!).get()).toSet();
}