Skip to content

fix(sync): Recover stalled model synchronization - #219

Merged
austin047 merged 5 commits into
devfrom
fix/sync-queue-recovery
Aug 16, 2026
Merged

austin047 merged 5 commits into
devfrom
fix/sync-queue-recovery

Conversation

@nfebe

@nfebe nfebe commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Fixes reports of transactions, parties, and transfers remaining local or never appearing in Last Sync Status. Local writes now commit with their pending change without waiting for the network, orphan recovery covers every locally writable sync model, and each model shows its latest attempt or failure.

Local writes and their pending changes now commit together without making saves wait for the network. Missing local changes are recovered across synchronized models, transfer downloads continue safely after transaction failures, and per-model attempts remain visible even when no records are returned.
@sourceant

sourceant Bot commented Aug 15, 2026

Copy link
Copy Markdown

Code Review Summary

This PR implements a robust orphan recovery mechanism and standardizes persistence patterns across the repository layer. It also enhances sync status visibility in the UI and improves network stability through HTTP timeouts.

🚀 Key Improvements

  • Implemented comprehensive orphan recovery for all locally writable models in SynchAppDatabase.
  • Added Dio timeouts to mitigate issues with stalled network connections in http_module.dart.
  • Enhanced Sync History UI with detailed error reporting and status tracking.
  • Refactored repositories to use centralized persistAnd... methods for improved consistency.

💡 Minor Suggestions

  • Ensure that all new synced entities (like category and media_file) are fully localized in the UI switch statements.
  • Consider replacing null-assertion operators with safe checks in repository update methods.

🚨 Critical Issues

  • Several repository deletion methods lack null checks for entities fetched by client ID, which poses a significant crash risk if a record is missing at the time of deletion.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

..sort((a, b) => a.entityType.compareTo(b.entityType));
final allChanges = await _db.select(_db.localChanges).get();

// Quarantined changes are permanently failed and never retry

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entity type 'media_file' (defined in MediaSyncHandler.entity) is missing from the switch statement. Users will see the raw entity key instead of a localized string in the Sync History UI.

Suggested change
// Quarantined changes are permanently failed and never retry
case 'party':
return LocaleKeys.party.tr();
case 'group':
return LocaleKeys.group.tr();
case 'media_file':
return LocaleKeys.media.tr();
case 'transfer':
return LocaleKeys.transfer.tr();

.map((t) => BudgetTargetInput(type: t.type, clientId: t.clientId))
.toList(),
);
return (await _composeDto(budget.clientId))!;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The null-assertion operator ! is used here. While composition is expected to succeed immediately after an insert, it is safer to handle a potential null DTO or use a more defensive approach to prevent runtime crashes if database constraints or relations are unexpectedly missing.

Suggested change
return (await _composeDto(budget.clientId))!;
final dto = await _composeDto(budget.clientId);
if (dto == null) throw Exception('Failed to compose Budget DTO');
return dto;

Comment thread pubspec.yaml Outdated
git:
url: https://github.com/whilesmartflutter/drift_sync.git
ref: drift_sync_core-v0.3.3
ref: 4ac6cf0

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@austin047 Once whilesmartflutter/drift_sync#2 is merged, you can retag?

@nfebe
nfebe requested a review from austin047 August 15, 2026 09:57
Every repository awaited `delete`, which awaits the remote call, so
deleting a record held the UI for a full network round trip. The cubits
already remove the row optimistically, but `isDeleting` stayed true until
the request settled. Switch to `persistAndDelete`, which returns once the
deletion and its outbox entry are durable and leaves delivery to the
sync loop - the same shape create and update already use.

The Dio instance set no timeouts, so a stalled connection fell through to
the OS socket timeout and could hang a request for minutes. Bound
connect, send and receive.

Also rename the transfer handler override to match
`downloadIgnoresFailedDependencies`.
A down-sync failure was rendered as a bare red icon that replaced the
timestamp, so the screen said a model was broken without saying why or
when it last tried - and unlike upload failures there is no queued change
to inspect, so the error was unreachable from the UI entirely.

Keep the last-attempted timestamp visible next to the icon, show a
one-line excerpt of the error inline, and make the row open a dialog with
the full selectable text.

Regenerating the localization keys also picked up five export* keys that
were already in the JSON but missing from locale_keys.g.dart.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. No specific code suggestions were generated. See the overview comment for a summary.

austin047 and others added 2 commits August 16, 2026 04:01
…Delete

The ref was still 4ac6cf0, which predates `persistAndDelete` and the
`downloadIgnoresFailedDependencies` rename, so CI could not compile the
repositories. Still a bare SHA, as before - it moves to
drift_sync_core-v0.3.4 once that release is cut.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

);
for (final clientId in orphanIds) {
try {
final entity = await handler.getLocalByClientId(clientId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The result of getLocalByClientId should be null-checked. If an entity was deleted or changed between the time orphanIds were identified and this iteration, the handler might return null, leading to a crash in the subsequent sync engine logic.

Suggested change
final entity = await handler.getLocalByClientId(clientId);
final entity = await handler.getLocalByClientId(clientId);
if (entity == null) continue;

return RepositoryErrorHandler.handleApiCall(() async {
final category = await localDataSource.deleteCategory(clientId);
unawaited(delete(category));
final category = await syncHandler.getLocalByClientId(clientId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The syncHandler.getLocalByClientId call can return null if the category does not exist or was already removed. Passing null to persistAndDelete will cause a runtime exception. It is better to verify the entity exists and return a descriptive failure if it is missing.

Suggested change
final category = await syncHandler.getLocalByClientId(clientId);
final category = await syncHandler.getLocalByClientId(clientId);
if (category == null) return Left(Failure.cacheError('Category not found'));
await persistAndDelete(category);

Future<Either<Failure, Unit>> deleteTransaction(String id) async {
try {
final transaction = await localDataSource.deleteTransaction(id);
final transaction = await syncHandler.getLocalByClientId(id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a null check for the transaction before deletion prevents runtime crashes in case of invalid or missing IDs. This ensures the repository returns a clean failure rather than an unhandled exception.

Suggested change
final transaction = await syncHandler.getLocalByClientId(id);
final transaction = await syncHandler.getLocalByClientId(id);
if (transaction == null) return Left(Failure.cacheError('Transaction not found'));
await persistAndDelete(transaction);

@austin047
austin047 merged commit e96ea4c into dev Aug 16, 2026
3 checks passed
@austin047
austin047 deleted the fix/sync-queue-recovery branch August 16, 2026 19:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants