Skip to content
Merged
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
3 changes: 3 additions & 0 deletions assets/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,9 @@
"noPendingChanges": "No pending changes",
"failedChanges": "Failed changes",
"noFailedChanges": "No failed changes",
"needsAttention": "Needs attention",
"noQuarantinedChanges": "Nothing needs attention",
"quarantinedHint": "These changes keep being rejected and won't retry automatically. Fix the underlying data, then retry — or discard them.",
"dismiss": "Dismiss",
"categorySalary": "Salary",
"categorySalaryDesc": "Regular employment income",
Expand Down
1 change: 1 addition & 0 deletions drift_schemas/default/drift_schema_v8.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion lib/core/sync/sync_database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ class SynchAppDatabase extends DriftSynchronizer<AppDatabase> {
required super.requestAuthorizationService,
required super.logger,
super.crashReporter,
});
}) : super(
cursorRewind: const Duration(seconds: 1),
classifyFailure: restFailureClassifier,
);

final _syncStateController = StreamController<SyncState>.broadcast();

Expand Down
12 changes: 2 additions & 10 deletions lib/core/utils/date_util.dart
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
import 'package:easy_localization/easy_localization.dart';

DateTime _formatServerIsoDateTime(DateTime now) {
final formatted = DateFormat("yyyy-MM-dd'T'HH:mm:ss.mmm'Z'").format(now);
final dateTime = DateTime.parse(formatted);
return dateTime;
}

String formatServerIsoDateTimeString(DateTime utcDateTime) {
return _formatServerIsoDateTime(utcDateTime).toIso8601String();
String formatServerIsoDateTimeString(DateTime dateTime) {
return dateTime.toUtc().toIso8601String();
}

DateTime getNewFormattedUtcDateTime() {
Expand Down
126 changes: 121 additions & 5 deletions lib/data/database/app_database.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';

import 'package:drift/drift.dart';
Expand All @@ -18,6 +19,7 @@ import 'package:trakli/data/database/tables/budgets.dart';
import 'package:trakli/data/database/tables/categories.dart';
import 'package:trakli/data/database/tables/categorizables.dart';
import 'package:trakli/data/database/tables/configs.dart';
import 'package:trakli/data/database/tables/deferred_remote_items.dart';
import 'package:trakli/data/database/tables/financial_position_cache.dart';
import 'package:trakli/data/database/tables/groups.dart';
import 'package:trakli/data/database/tables/holdings.dart';
Expand Down Expand Up @@ -63,6 +65,7 @@ part 'app_database.g.dart';
Holdings,
FinancialPositionCache,
Reminders,
DeferredRemoteItems,
])
class AppDatabase extends _$AppDatabase with SynchronizerDb {
final Set<SyncTypeHandler> typeHandlers;
Expand All @@ -74,7 +77,7 @@ class AppDatabase extends _$AppDatabase with SynchronizerDb {
super(executor ?? _openConnection());

@override
int get schemaVersion => 7;
int get schemaVersion => 8;

@override
MigrationStrategy get migration {
Expand All @@ -96,12 +99,49 @@ class AppDatabase extends _$AppDatabase with SynchronizerDb {
});
}

/// Failed changes are retried automatically once this much time has passed
/// since the last attempt; a successful retry deletes the row.
/// Base delay before the first retry of a failed change. Each further
/// failed attempt doubles the wait ([retryBackoff]).
static const failedChangeRetryDelay = Duration(minutes: 1);

/// Upper bound on the exponential retry backoff.
static const failedChangeRetryCap = Duration(hours: 1);

/// Backoff before retrying a change that has failed [attemptCount] times:
/// base doubles per attempt, capped at [failedChangeRetryCap].
static Duration retryBackoff(int attemptCount) {
if (attemptCount <= 1) return failedChangeRetryDelay;
final shift = attemptCount - 1;
// Cap the exponent itself, not just the final duration: on Dart
// compiled to JS, bitwise shifts truncate to 32 bits, so a large shift
// can wrap negative before the cap check below ever runs. 6 is already
// past the point where the multiplication exceeds the cap.
final safeShift = shift > 6 ? 6 : shift;
final ms = failedChangeRetryDelay.inMilliseconds * (1 << safeShift);
return ms >= failedChangeRetryCap.inMilliseconds
? failedChangeRetryCap
: Duration(milliseconds: ms);
}

@override
Future<List<PendingLocalChange>> getPendingLocalChanges() async {
final rows =
await (select(localChanges)..where((lc) => lc.error.isNull())).get();
final now = DateTime.now();
// Quarantined and dismissed changes never retry, so both are excluded
// in SQL. The remaining rows are filtered by their per-row exponential
// backoff below (not expressible in SQL).
final rows = await (select(localChanges)
Comment thread
austin047 marked this conversation as resolved.
..where((lc) =>
lc.quarantinedAt.isNull() & lc.dismissed.equals(false)))
.get();

return rows
.where((row) {
if (row.error == null) return true; // never failed
final concluded = row.concludedMoment;
if (concluded == null) return true;
return now.isAfter(concluded.add(retryBackoff(row.attemptCount)));
})
.map((row) => PendingLocalChange(
entityType: row.entityType,
entityId: row.entityId,
Expand All @@ -113,6 +153,8 @@ class AppDatabase extends _$AppDatabase with SynchronizerDb {
concludedMoment: row.concludedMoment,
error: row.error,
dismissed: row.dismissed,
attemptCount: row.attemptCount,
quarantinedAt: row.quarantinedAt,
))
.toList();
}
Expand All @@ -122,6 +164,22 @@ class AppDatabase extends _$AppDatabase with SynchronizerDb {
await delete(localChanges).go();
}

/// True while transaction or transfer changes are still waiting to sync —
/// server /stats cannot include them yet. Dismissed and quarantined
/// changes don't count: dismissed changes never sync, and quarantined
/// ones won't sync without a user retry, so neither should hold reports
/// on "local estimate" indefinitely.
Future<bool> hasPendingTransactionChanges() async {
final row = await (select(localChanges)
..where((lc) =>
lc.entityType.isIn(const ['transaction', 'transfer']) &
lc.dismissed.equals(false) &
lc.quarantinedAt.isNull())
..limit(1))
.getSingleOrNull();
return row != null;
}

Future<List<Category>> getCategoriesForTransaction(
String transactionId, CategorizableType sourceType) async {
final query = select(categories).join([
Expand Down Expand Up @@ -150,15 +208,22 @@ class AppDatabase extends _$AppDatabase with SynchronizerDb {

@override
Future<void> concludeLocalChange(PendingLocalChange localChange,
{Object? error, bool persistedToRemote = false}) async {
{Object? error,
bool persistedToRemote = false,
bool quarantine = false}) async {
if (error != null) {
await (update(localChanges)
..where((lc) => lc.entityId.equals(localChange.entityId)))
..where((lc) =>
lc.entityType.equals(localChange.entityType) &
lc.entityId.equals(localChange.entityId)))
.write(
LocalChangesCompanion(
concludedMoment: Value(DateTime.now()),
error: Value(error.toString()),
concluded: const Value(true),
attemptCount: Value(localChange.attemptCount + 1),
quarantinedAt:
quarantine ? Value(DateTime.now()) : const Value.absent(),
),
);
}
Expand Down Expand Up @@ -189,6 +254,8 @@ class AppDatabase extends _$AppDatabase with SynchronizerDb {
concludedMoment: Value(pendingLocalChange.concludedMoment),
error: Value(pendingLocalChange.error),
deleted: Value(pendingLocalChange.deleted),
attemptCount: Value(pendingLocalChange.attemptCount),
quarantinedAt: Value(pendingLocalChange.quarantinedAt),
);

await into(localChanges).insert(
Expand Down Expand Up @@ -273,6 +340,43 @@ class AppDatabase extends _$AppDatabase with SynchronizerDb {
);
}

@override
Future<void> parkRemoteItem(ParkedRemoteItem item) async {
await deferredRemoteItems.insertOne(
DeferredRemoteItemsCompanion.insert(
entityType: item.entityType,
clientId: item.clientId,
data: jsonEncode(item.data),
parkedAt: Value(item.parkedAt ?? DateTime.now().toUtc()),
),
mode: InsertMode.insertOrReplace,
);
}

@override
Future<List<ParkedRemoteItem>> getParkedRemoteItems(
String entityType) async {
final rows = await (select(deferredRemoteItems)
..where((t) => t.entityType.equals(entityType)))
.get();
return rows
.map((row) => ParkedRemoteItem(
entityType: row.entityType,
clientId: row.clientId,
data: jsonDecode(row.data) as Map<String, dynamic>,
parkedAt: row.parkedAt,
))
.toList(growable: false);
}

@override
Future<void> unparkRemoteItem(String entityType, String clientId) async {
await (delete(deferredRemoteItems)
..where((t) =>
t.entityType.equals(entityType) & t.clientId.equals(clientId)))
.go();
}

@override
Future<void> clearDatabase() async {
await users.deleteAll();
Expand Down Expand Up @@ -343,5 +447,17 @@ extension Migrations on GeneratedDatabase {
// Reminders
await m.createTable(schema.reminders);
},
from7To8: (Migrator m, Schema8 schema) async {
// Parking store for down-synced items with unmet local
// dependencies (SyncTypeHandler.shouldPersistLocal).
await m.createTable(schema.deferredRemoteItems);

// Failure classification: retry accounting + quarantine for
// permanently failed local changes.
await m.addColumn(
schema.localChanges, schema.localChanges.attemptCount);
await m.addColumn(
schema.localChanges, schema.localChanges.quarantinedAt);
},
);
}
Loading
Loading