[client] WIP: Better local change tracking

This commit is contained in:
Abhishek Shroff
2025-01-15 15:36:34 +05:30
parent e444afbfc2
commit 0901083624
12 changed files with 121 additions and 109 deletions

View File

@@ -3,7 +3,7 @@ import 'dart:async';
import 'package:drift/drift.dart';
import 'package:offtheline/offtheline.dart';
import 'package:phylum/libphylum/actions/action_resource_create.dart';
import 'package:phylum/libphylum/db/resource_helpers.dart';
import 'package:phylum/libphylum/db/db.dart';
import 'package:phylum/libphylum/phylum_api_types.dart';
class ResourceCopyAction extends ResourceCreateAction with JsonApiAction {
@@ -94,11 +94,11 @@ class ResourceCopyAction extends ResourceCreateAction with JsonApiAction {
@override
Future<void> applyOptimisticUpdate() async {
await account.db.createResource(resourceId, parent, localName, dir, timestamp);
await account.localChangeTracker.create(resourceId, parent, localName, dir, timestamp);
if (!dir) {
await account.db.updateResource(
await account.localChangeTracker.update(
resourceId,
(o) => o(
ResourcesCompanion(
contentType: Value(contentType),
contentLength: Value(contentLength),
contentSha256: Value(contentSha256),
@@ -106,15 +106,15 @@ class ResourceCopyAction extends ResourceCreateAction with JsonApiAction {
);
}
if (deletedId != null) {
await account.db.updateResource(deletedId!, (u) => u(parent: Value(null)));
await account.localChangeTracker.update(deletedId!, ResourcesCompanion(parent: Value(null)));
}
}
@override
Future<void> revertOptimisticUpdate() async {
await account.db.deleteResource(resourceId);
await account.localChangeTracker.revert(resourceId);
if (deletedId != null) {
await account.serverResourceTracker.revert(deletedId!);
await account.localChangeTracker.revert(deletedId!);
}
}

View File

@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:drift/drift.dart';
import 'package:offtheline/offtheline.dart';
import 'package:phylum/libphylum/db/db.dart';
import 'package:phylum/libphylum/db/resource_helpers.dart';
import 'package:phylum/libphylum/phylum_api_types.dart';
import 'action_resource.dart';
@@ -56,12 +55,12 @@ class ResourceDeleteAction extends ResourceAction with JsonApiAction {
@override
Future<void> applyOptimisticUpdate() {
return account.db.updateResource(resourceId, (o) => o(deleted: const Value(true)));
return account.localChangeTracker.update(resourceId, ResourcesCompanion(deleted: const Value(true)));
}
@override
Future<void> revertOptimisticUpdate() {
return account.db.deleteResource(resourceId);
return account.localChangeTracker.revert(resourceId);
}
@override

View File

@@ -3,7 +3,7 @@ import 'dart:async';
import 'package:drift/drift.dart';
import 'package:offtheline/offtheline.dart';
import 'package:phylum/libphylum/actions/action_resource_create.dart';
import 'package:phylum/libphylum/db/resource_helpers.dart';
import 'package:phylum/libphylum/db/db.dart';
import 'package:phylum/libphylum/phylum_api_types.dart';
class ResourceMkdirAction extends ResourceCreateAction with JsonApiAction {
@@ -70,17 +70,17 @@ class ResourceMkdirAction extends ResourceCreateAction with JsonApiAction {
@override
Future<void> applyOptimisticUpdate() async {
await account.db.createResource(resourceId, parent, localName, true, timestamp);
await account.localChangeTracker.create(resourceId, parent, localName, true, timestamp);
if (deletedId != null) {
await account.db.updateResource(deletedId!, (u) => u(parent: Value(null)));
await account.localChangeTracker.update(deletedId!, ResourcesCompanion(parent: Value(null)));
}
}
@override
Future<void> revertOptimisticUpdate() async {
await account.db.deleteResource(resourceId);
await account.localChangeTracker.revert(resourceId);
if (deletedId != null) {
await account.serverResourceTracker.revert(deletedId!);
await account.localChangeTracker.revert(deletedId!);
}
}

View File

@@ -4,7 +4,7 @@ import 'package:drift/drift.dart';
import 'package:offtheline/offtheline.dart';
import 'package:phylum/libphylum/actions/action_resource_bind.dart';
import 'package:phylum/libphylum/actions/action_resource_create.dart';
import 'package:phylum/libphylum/db/resource_helpers.dart';
import 'package:phylum/libphylum/db/db.dart';
import 'package:phylum/libphylum/phylum_api_types.dart';
class ResourceMoveAction extends ResourceBindAction with JsonApiAction {
@@ -74,24 +74,24 @@ class ResourceMoveAction extends ResourceBindAction with JsonApiAction {
@override
Future<void> applyOptimisticUpdate() async {
await account.db.updateResource(
await account.localChangeTracker.update(
resourceId,
(o) => o(
ResourcesCompanion(
name: Value.absentIfNull(localName),
parent: Value.absentIfNull(parent),
modified: Value(modified),
),
);
if (deletedId != null) {
await account.db.updateResource(deletedId!, (u) => u(parent: Value(null)));
await account.localChangeTracker.update(deletedId!, ResourcesCompanion(parent: Value(null)));
}
}
@override
Future<void> revertOptimisticUpdate() async {
await account.serverResourceTracker.revert(resourceId);
await account.localChangeTracker.revert(resourceId);
if (deletedId != null) {
await account.serverResourceTracker.revert(deletedId!);
await account.localChangeTracker.revert(deletedId!);
}
}

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:offtheline/offtheline.dart';
import 'package:phylum/libphylum/db/db.dart';
import 'package:phylum/libphylum/db/resource_helpers.dart';
import 'package:phylum/libphylum/phylum_api_types.dart';
import 'package:phylum/util/permissions.dart';
@@ -92,9 +93,9 @@ class ResourceShareAction extends ResourceAction with JsonApiAction {
p[username] = permission;
}
final serialized = p.isEmpty ? null : json.encode(p);
account.db.updateResource(
return account.localChangeTracker.update(
resourceId,
(o) => o(
ResourcesCompanion(
permissions: Value(serialized),
modified: Value(modified),
),
@@ -103,7 +104,7 @@ class ResourceShareAction extends ResourceAction with JsonApiAction {
@override
Future<void> revertOptimisticUpdate() {
return account.serverResourceTracker.revert(resourceId);
return account.localChangeTracker.revert(resourceId);
}
@override

View File

@@ -5,7 +5,7 @@ import 'package:http/http.dart';
import 'package:http_parser/http_parser.dart';
import 'package:offtheline/offtheline.dart';
import 'package:phylum/libphylum/actions/action_resource_create.dart';
import 'package:phylum/libphylum/db/resource_helpers.dart';
import 'package:phylum/libphylum/db/db.dart';
import 'package:phylum/libphylum/phylum_api_types.dart';
class ResourceUploadAction extends ResourceCreateAction with FileUploadApiAction {
@@ -20,7 +20,8 @@ class ResourceUploadAction extends ResourceCreateAction with FileUploadApiAction
String get endpoint => '/api/v1/fs/upload/$resourceId';
@override
Future<MultipartFile> get file => MultipartFile.fromPath('contents', path, filename: resourceName, contentType: MediaType.parse(contentType));
Future<MultipartFile> get file =>
MultipartFile.fromPath('contents', path, filename: resourceName, contentType: MediaType.parse(contentType));
final String path;
final int size;
@@ -78,25 +79,25 @@ class ResourceUploadAction extends ResourceCreateAction with FileUploadApiAction
@override
Future<void> applyOptimisticUpdate() async {
await account.db.createResource(resourceId, parent, localName, false, timestamp);
await account.db.updateResource(
await account.localChangeTracker.create(resourceId, parent, localName, false, timestamp);
await account.localChangeTracker.update(
resourceId,
(o) => o(
ResourcesCompanion(
contentType: Value(contentType),
contentLength: Value(size),
contentSha256: Value("<unknown>"),
),
);
if (deletedId != null) {
await account.db.updateResource(deletedId!, (u) => u(parent: Value(null)));
await account.localChangeTracker.update(deletedId!, ResourcesCompanion(parent: Value(null)));
}
}
@override
Future<void> revertOptimisticUpdate() async {
await account.db.deleteResource(resourceId);
await account.localChangeTracker.revert(resourceId);
if (deletedId != null) {
await account.serverResourceTracker.revert(deletedId!);
await account.localChangeTracker.revert(deletedId!);
}
}

View File

@@ -19,7 +19,9 @@ extension ResourceHelpers on AppDatabase {
}
Stream<List<Resource>> watchChildren(String id) {
return (_selectChildren(id)..orderBy([(u) => OrderingTerm.desc(u.dir), (u) => OrderingTerm.asc(u.name.collate(Collate.noCase))])).watch();
return (_selectChildren(id)
..orderBy([(u) => OrderingTerm.desc(u.dir), (u) => OrderingTerm.asc(u.name.collate(Collate.noCase))]))
.watch();
}
Stream<List<ParentsResult>> watchParents(String id) {
@@ -41,26 +43,4 @@ extension ResourceHelpers on AppDatabase {
SimpleSelectStatement<Resources, Resource> _selectResources(Iterable<String> ids) {
return resources.select()..where((f) => f.id.isIn(ids));
}
Future<int> createResource(String id, String parent, String name, bool dir, DateTime timestamp) {
return resources.insertOne(
ResourcesCompanion.insert(
id: id,
name: name,
parent: Value(parent),
dir: dir,
created: Value(timestamp),
modified: Value(timestamp),
),
mode: InsertMode.insertOrReplace,
);
}
Future<int> updateResource(String id, Insertable<Resource> Function($ResourcesUpdateCompanionBuilder o) fn) {
return managers.resources.filter((f) => f.id.equals(id)).update(fn);
}
Future<int> deleteResource(String id) {
return resources.deleteWhere((f) => f.id.equals(id));
}
}

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart';
@@ -6,27 +7,82 @@ import 'package:phylum/libphylum/db/db.dart';
import 'package:phylum/libphylum/db/resource_helpers.dart';
import 'package:phylum/libphylum/phylum_account.dart';
class ServerResourceStateTracker {
class LocalChangeTracker {
final PhylumAccount account;
late final Box<Resource?> _box;
late final Box<Resource?> _serverDataBox;
// Map<String, Resource?> _pending = {};
ServerResourceStateTracker({required this.account});
LocalChangeTracker({required this.account});
Future<void> initialize() async {
if (!Hive.isAdapterRegistered(const ResourceAdapter().typeId)) {
Hive.registerAdapter(const ResourceAdapter());
}
_box = await account.openBox('server_data');
_serverDataBox = await account.openBox('server_data');
}
Resource? getServerResource(String id) {
return _box.get(id);
Future<int> create(String id, String parent, String name, bool dir, DateTime timestamp) {
// This will create a key with null value, which is exactly what we want
_retainOriginal(id);
return account.db.resources.insertOne(
ResourcesCompanion.insert(
id: id,
name: name,
parent: Value(parent),
dir: dir,
created: Value(timestamp),
modified: Value(timestamp),
),
mode: InsertMode.insertOrReplace,
);
}
Future<void> revert(String id) async {
final r = _box.get(id);
if (r != null) {
await account.db.resources.replaceOne(r);
Future<void> update(String id, Insertable<Resource> update) {
_retainOriginal(id);
return (account.db.resources.update()..where((f) => f.id.equals(id))).write(update);
}
Future<void> _retainOriginal(String id) async {
final pending = Zone.current[#pending];
pending[id] ??= _serverDataBox.get(id) ?? await account.db.getResource(id);
}
Future<void> track(Future<void> Function() fn, {bool clearUntouched = true}) async {
final pending = await runZonedGuarded(() async {
await fn();
return (Zone.current[#pending] as Map).cast<String, Resource?>();
}, (err, stack) {}, zoneValues: {
#pending: <String, Resource?>{},
});
if (pending != null) {
var untouchedKeys = const <String>{};
if (clearUntouched) {
untouchedKeys = Set.from(_serverDataBox.keys);
untouchedKeys.retainWhere((key) => !pending.containsKey(key));
}
// Avoid unnecessary disk writes
pending.removeWhere((key, value) => _serverDataBox.containsKey(key) && _serverDataBox.get(key) == value);
await _serverDataBox.putAll(pending);
await _serverDataBox.deleteAll(untouchedKeys);
await _serverDataBox.flush();
}
}
Future<void> revert(String id) {
final r = _serverDataBox.get(id);
if (r == null) {
return account.db.resources.deleteWhere((f) => f.id.equals(id));
} else {
return account.db.resources.replaceOne(r);
}
}
void processServerUpdates(Iterable<ResourcesCompanion> updates) {
for (final r in updates) {
if (_serverDataBox.containsKey(r.id.value)) {
_serverDataBox.put(r.id.value, _serverDataBox.get(r.id.value)?.copyWithCompanion(r));
}
}
}
@@ -34,30 +90,11 @@ class ServerResourceStateTracker {
return Interceptor(tracker: this, clearUntouched: clearUntouched);
}
Future<void> _trackServerResources(Map<String, Resource?> serverResources, bool clearUntouched) async {
var untouchedKeys = const <String>{};
if (clearUntouched) {
untouchedKeys = Set.from(_box.keys);
untouchedKeys.retainWhere((key) => !serverResources.containsKey(key));
}
// Avoid unnecessary disk writes
serverResources.removeWhere((key, value) => _box.containsKey(key) && _box.get(key) == value);
await _box.putAll(serverResources);
await _box.deleteAll(untouchedKeys);
await _box.flush();
}
void update(Iterable<ResourcesCompanion> updates) {
for (final r in updates) {
if (_box.containsKey(r.id.value)) {
_box.put(r.id.value, _box.get(r.id.value)?.copyWithCompanion(r));
}
}
}
Future<void> _trackServerResources(Map<String, Resource?> serverResources, bool clearUntouched) async {}
}
class Interceptor extends QueryInterceptor {
final ServerResourceStateTracker tracker;
final LocalChangeTracker tracker;
final bool clearUntouched;
late final AppDatabase db;
final Map<String, Resource?> serverData = {};
@@ -89,7 +126,7 @@ class Interceptor extends QueryInterceptor {
}
Future<void> retainOriginal(String id) async {
serverData[id] ??= tracker._box.get(id) ?? await db.getResource(id);
serverData[id] ??= tracker._serverDataBox.get(id) ?? await db.getResource(id);
}
@override

View File

@@ -6,7 +6,7 @@ import 'package:phylum/libphylum/phylum_api_types.dart';
import 'package:phylum/libphylum/repositories/my_lists_repository.dart';
import 'package:phylum/libphylum/repositories/resource_repository.dart';
import 'package:phylum/libphylum/repositories/user_repository.dart';
import 'package:phylum/libphylum/server_data_tracker.dart';
import 'package:phylum/libphylum/local_updates_tracker.dart';
import 'package:phylum/util/logging.dart';
const _persistKeyAccessToken = 'accessToken';
@@ -19,7 +19,7 @@ class PhylumAccount extends Account<PhylumAccount> {
late final resourceRepository = ResourceRepository(account: this);
late final userRepository = UserRepository(account: this);
late final myListsRepository = MyListsRepository(account: this);
late final serverResourceTracker = ServerResourceStateTracker(account: this);
late final localChangeTracker = LocalChangeTracker(account: this);
final _dispatcher = SimulatedBadNetworkDispatcher.good(dropRate: 0);
// final _dispatcher = HttpClientDispatcher();
@@ -62,7 +62,7 @@ class PhylumAccount extends Account<PhylumAccount> {
// Set Authorization header
_accessToken = _initialAccessToken ?? accessToken;
await serverResourceTracker.initialize();
await localChangeTracker.initialize();
await userRepository.initialize();
await myListsRepository.initialize();
@@ -79,20 +79,13 @@ class PhylumAccount extends Account<PhylumAccount> {
}
@override
Future<void> transaction(
Future<void> Function() fn, {
bool retainServerData = false,
bool clearUntouched = true,
}) {
if (retainServerData) {
return db.runWithInterceptor(
() => db.transaction(fn),
interceptor: serverResourceTracker.newInterceptor(clearUntouched),
);
}
return db.transaction(fn);
Future<void> localChanges(Future<void> Function() fn, {bool clearUntouched = true}) {
return localChangeTracker.track(fn, clearUntouched: clearUntouched);
}
@override
Future<void> transaction(Future<void> Function() fn) => db.transaction(fn);
@override
Future<void> cleanup() async {
await super.cleanup();

View File

@@ -219,19 +219,20 @@ class ResourceRepository {
}
batch.insertAllOnConflictUpdate(db.resources, parsed);
});
account.serverResourceTracker.update(parsed);
account.serverResourceTracker.update(previousChildren);
account.localChangeTracker.processServerUpdates(parsed);
account.localChangeTracker.processServerUpdates(previousChildren);
}
Future<ResourcesCompanion> processResourceUpdateResponse(String id, Map<String, dynamic> data) async {
final r = parsePartialResourceObject(data, id);
final update = (account.db.resources.update()..where((o) => o.id.equals(r.id.value)));
await update.write(r);
account.serverResourceTracker.update([r]);
account.localChangeTracker.processServerUpdates([r]);
return r;
}
Future<(Iterable<ResourcesCompanion>, Iterable<ResourcesCompanion>)> parseFullResourceObject(Map<String, dynamic> data) async {
Future<(Iterable<ResourcesCompanion>, Iterable<ResourcesCompanion>)> parseFullResourceObject(
Map<String, dynamic> data) async {
final ancestors = (data['ancestors'] as List).cast<Map>().map(
(a) => parseResourceAncestor(a.cast<String, dynamic>()).copyWith(dir: Value(true)),
);

View File

@@ -618,8 +618,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: ab233bd87a0c7e0d77ce211a5d9c929fd18f33b6
resolved-ref: ab233bd87a0c7e0d77ce211a5d9c929fd18f33b6
ref: "3471261985806a93ced64fa7b425240c7d57d79b"
resolved-ref: "3471261985806a93ced64fa7b425240c7d57d79b"
url: "https://codeberg.org/shroff/offtheline.git"
source: git
version: "0.16.0"

View File

@@ -24,7 +24,7 @@ dependencies:
offtheline:
git:
url: https://codeberg.org/shroff/offtheline.git
ref: ab233bd87a0c7e0d77ce211a5d9c929fd18f33b6
ref: 3471261985806a93ced64fa7b425240c7d57d79b
open_file:
path:
path_provider: