Skip to content

fix: DB readonly-rollback recovery + cooperative import cancellation#255

Merged
ericgriffin merged 5 commits into
mainfrom
fix/db-recovery-and-import-cancel
Apr 22, 2026
Merged

fix: DB readonly-rollback recovery + cooperative import cancellation#255
ericgriffin merged 5 commits into
mainfrom
fix/db-recovery-and-import-cancel

Conversation

@ericgriffin
Copy link
Copy Markdown
Member

@ericgriffin ericgriffin commented Apr 21, 2026

Summary

Fixes the incident where a user couldn't relaunch the app after cancelling a 300-dive Perdix AI import. The force-cancel killed SQLite mid-transaction, leaving a hot rollback journal, and the next launch hit SqliteException(776) on PRAGMA user_version.

Two independent fixes ship together:

  • Task 2 — startup recovery (cbc110731dc): Detects SQLITE_READONLY_ROLLBACK (extended code 776) on launch and offers a "Recover database" action that reopens the DB in write mode to force the hot-journal rollback. If the reopen itself fails, shows a "Recovery did not complete" screen with Try again / Close and diagnostic details. (Automatic backup-restore fallback is out of scope for this PR — tracked as a follow-up; the primary recovery path solves the incident.) Replaces the previous "reinstall or contact support" dead-end.
  • Task 3 — cooperative cancellation (81c6cf6eed5): Adds an ImportCancellationToken polled at per-dive boundaries in DiveComputerAdapter, UddfEntityImporter, and HealthKitAdapter. The wizard's close button now shows a real "Cancel import?" dialog; already-imported dives are kept. For dive-computer imports, the fingerprint advances only for dives that actually processed, so the remainder can be re-imported next session. Removes the root cause that produced the hot journal in the first place.

All 7120+ tests pass; flutter analyze clean; dart format clean.

Test plan

  • Relaunch with an artificially-corrupted DB (rollback journal present) and verify the recovery UI appears and successfully reopens the DB
  • Start a 100+ dive Perdix/DC import, tap the close button mid-import, confirm the cancel dialog, verify the loop stops cleanly at the next dive boundary
  • Verify already-imported dives remain in the log after cancellation
  • Relaunch the app after a cancelled import and verify no SqliteException(776) on startup
  • Re-run a cancelled DC import next session and verify only the unprocessed dives reappear (fingerprint only advanced for processed dives)
  • Cancel a large UDDF file import (UniversalAdapter path) and confirm per-dive break works for all wrapped formats (CSV, XML, SSRF, UDDF)

Known limitations (by design)

  • Cancel takes effect at per-dive boundaries, not mid-dive — the UX copy "Finishing the current dive before stopping..." signals this
  • Parser-stage operations (XML/CSV parsing before the import loop starts) aren't cancellable — separate UX issue, doesn't cause the hot-journal bug

A user who cancelled a large dive-computer import could no longer
launch the app: every startup failed with `SqliteException(776):
attempt to write a readonly database` on `PRAGMA user_version`.

Root cause: `getStoredSchemaVersion` opened the db with
`OpenMode.readOnly`. When a crashed/cancelled transaction had left a
hot journal behind, SQLite's automatic rollback needs write access;
a read-only open throws SQLITE_READONLY_ROLLBACK (extended code 776)
before the first PRAGMA can run.

Fix:
- Open RW in `getStoredSchemaVersion` so SQLite can auto-recover the
  journal during the existence probe.
- Add `recoverHotJournal` + `isRecoverableReadonlyError` helpers as
  defense-in-depth, and an explicit recovery screen on the startup
  page ("Database needs recovery" → "Recover database" button) so
  users who still hit a readonly-family exception get a one-tap path
  instead of the actively-harmful "reinstall or contact support"
  copy.
- Generic-error copy updated to make clear the data is still on disk.

Adds unit tests for the new DatabaseService helpers and widget tests
for all three recovery-UI states (required, recovering, failed).
Previously, cancelling a large import (e.g. 300-dive Perdix download) showed
a "cannot be cancelled" dialog and the only option was to force-kill the app.
Killing mid-transaction left SQLite with a hot rollback journal, which caused
the next launch to hit SQLITE_READONLY_ROLLBACK (776) on PRAGMA user_version.

The wizard now offers a real Cancel button. Cancellation is polled cooperatively
at per-dive boundaries in DiveComputerAdapter, UddfEntityImporter, and
HealthKitAdapter, so the current dive finishes writing to its own transaction
before the loop breaks. Already-imported dives are kept. The fingerprint
advances only for dives that were actually processed, so the remainder can be
re-imported next session.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Addresses two production issues: (1) app startup failure after a cancelled import left SQLite in a hot-journal/readonly-rollback state, and (2) force-cancel import behavior that could kill SQLite mid-transaction. Adds startup recovery UI and introduces cooperative per-dive cancellation via a cancellation token passed through import adapters/services.

Changes:

  • Add ImportCancellationToken and plumb cooperative cancellation through import wizard notifier + adapters (DC/UDDF/HealthKit/Universal).
  • Add startup handling for SQLITE_READONLY* SqliteExceptions, with a recovery flow that attempts RW-open to force rollback of hot journal.
  • Expand widget/unit test coverage for cancellation UI/state and for readonly-rollback recovery behavior.

Reviewed changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
lib/core/presentation/pages/startup_page.dart Adds recovery states/UI and triggers recovery flow on readonly-family SQLite errors.
lib/core/services/database_service.dart Opens schema-version probe RW; adds hot-journal recovery helper + readonly error classifier.
lib/features/dive_import/data/services/uddf_entity_importer.dart Adds per-dive cancellation polling for UDDF entity import.
lib/features/import_wizard/data/adapters/dive_computer_adapter.dart Adds per-dive cancellation and fingerprint advancement based on processed dives when cancelled.
lib/features/import_wizard/data/adapters/healthkit_adapter.dart Adds per-dive cancellation polling in HealthKit import loop.
lib/features/import_wizard/data/adapters/universal_adapter.dart Passes cancellation token down into the universal import path.
lib/features/import_wizard/domain/adapters/import_source_adapter.dart Extends adapter API to accept optional cancelToken.
lib/features/import_wizard/domain/models/import_cancellation_token.dart Introduces cooperative cancellation token type.
lib/features/import_wizard/presentation/pages/unified_import_wizard.dart Updates close-button behavior to show cancel/confirm dialogs during import.
lib/features/import_wizard/presentation/providers/import_wizard_providers.dart Adds cancellation state/token lifecycle and passes token into adapter imports.
lib/features/import_wizard/presentation/widgets/import_progress_step.dart Adds cancel button + “cancelling” display state in progress UI.
test/core/presentation/pages/startup_page_test.dart Adds widget tests verifying recovery UI routing and recovery button behavior.
test/core/services/database_service_schema_version_test.dart Adds tests for recoverHotJournal and readonly-error classification.
test/features/import_wizard/data/adapters/dive_computer_adapter_test.dart Adds tests for cancellation breaking the loop + fingerprint advancement behavior.
test/features/import_wizard/data/adapters/universal_adapter_test.mocks.dart Regenerated mocks for new cancelToken parameter.
test/features/import_wizard/domain/adapters/import_source_adapter_test.dart Updates adapter test signature to include cancelToken.
test/features/import_wizard/domain/models/import_cancellation_token_test.dart New unit tests for token behavior (initial/after cancel/idempotent).
test/features/import_wizard/presentation/pages/unified_import_wizard_test.dart Updates fake adapter signature to include cancelToken.
test/features/import_wizard/presentation/providers/import_wizard_notifier_test.dart Adds/updates notifier tests to assert cancellation propagation/state.
test/features/import_wizard/presentation/providers/import_wizard_notifier_test.mocks.dart Regenerated mocks for new cancelToken parameter.
test/features/import_wizard/presentation/widgets/import_progress_step_test.dart Adds widget tests for cancel button enabled/disabled states.
test/features/import_wizard/presentation/widgets/import_summary_step_test.dart Updates fake adapter signature to include cancelToken.
test/features/import_wizard/presentation/widgets/review_step_pending_test.dart Updates test adapter signature to include cancelToken.
test/features/import_wizard/presentation/widgets/review_step_test.dart Updates fake adapter signature to include cancelToken.

Comment thread lib/features/import_wizard/data/adapters/dive_computer_adapter.dart
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 21, 2026

Codecov Report

❌ Patch coverage is 98.14815% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
lib/core/presentation/pages/startup_page.dart 98.80% 1 Missing ⚠️
...ive_import/data/services/uddf_entity_importer.dart 0.00% 1 Missing ⚠️
...import_wizard/data/adapters/healthkit_adapter.dart 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

- Remove dead importedDives variable in DiveComputerAdapter that was
  only appended to, never read.
- Reset isCancellationRequested in the wizard's finally block so a
  second import starts fresh instead of inheriting the stale flag.
- Localize "Cancel import" and "Cancelling..." on the progress step
  via two new l10n keys (settings_import_cancelButton,
  settings_import_cancelling) across all 11 supported locales.
Raise PR #255 patch coverage from 66% toward 85% by adding tests for
the branches introduced by the cancel/recovery work:

- UnifiedImportWizard cancel dialog (4 tests): confirm dialog opens,
  Keep importing dismisses without cancelImport, Cancel import fires
  cancelImport, already-cancelling notice renders instead of the
  confirm dialog.
- ImportProgressStep cancel button wires through to cancelImport.
- StartupPage recoveryFailed UI: catch-block routing, Try again
  re-invokes _runRecovery, Close invokes closeAppOverride.

Adds two @VisibleForTesting hooks to UnifiedImportWizard
(initialPageOverride, notifierFactoryOverride) so tests can jump to
the import-progress page and inject a spy notifier into the widget's
inner ProviderScope without driving the full bundle/import pipeline.
Copilot AI review requested due to automatic review settings April 22, 2026 02:32
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 47 out of 47 changed files in this pull request and generated 2 comments.

Comment thread lib/core/presentation/pages/startup_page.dart
Comment thread lib/core/presentation/pages/startup_page.dart
…ecover failure

When `DatabaseService.recoverHotJournal` returns false, the recoveryFailed
UI now surfaces a concrete message ("SQLite could not reopen the database
to roll back the interrupted transaction.") instead of leaving whatever
text `_errorMessage` last held. Also clears `_errorMessage` on entry to
the `recovering` state so a second attempt cannot leak text from the
previous failure into the next UI.

Adds a test that writes invalid SQLite bytes (0xAB) to exercise the
!recovered branch: the handle opens but `PRAGMA user_version` throws
SQLITE_NOTADB inside recoverHotJournal, which catches and returns false.

Addresses Copilot review comment on PR #255.
@ericgriffin ericgriffin merged commit 0d9c7fd into main Apr 22, 2026
16 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in Submersion Release Tracker Apr 22, 2026
@ericgriffin ericgriffin deleted the fix/db-recovery-and-import-cancel branch April 22, 2026 04:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants