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
1 change: 1 addition & 0 deletions app/application.ml
Original file line number Diff line number Diff line change
Expand Up @@ -2560,6 +2560,7 @@ let diagnostic_phase_rows ~snapshot ~(graph : Logseq_db_worker.graph_state) =
; "Startup phase", startup_phase_name startup.phase
; "Graph phase", graph_phase_name graph.phase
]
@ Option.fold ~none:[] ~some:(fun message -> [ "Last error", message ]) snapshot.last_error
;;

let format_bytes bytes =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Graph startup failure and unlock recovery

## Problem

The downloaded graph snapshot is schema 65.29, below admission's minimum 65.33.
Snapshot activation rejects it before decryption, but the worker publishes only a
Diagnostic that the LUI service ignores. Startup remains Bootstrapping indefinitely.
The loading route and Diagnostics do not tell the user why opening stopped.
Separately, a first cached wrapped-key miss enters Failed even though the existing
online preparation could immediately obtain a valid password challenge; users
have to press Retry before reaching the encryption-password form.

## Decision

Retain actual/minimum versions in the existing Unsupported_schema error at its
admission owner. Preserve a fixed, content-free rejection message through snapshot
preparation. Route the failed worker completion through the existing scoped
Snapshot_activation_failed event. Use the existing startup Failed page and render
the current snapshot error in Diagnostics. Do not migrate, delete, lower admission
requirements, change cloud data, or promise recovery. No spec/ or Dune edits.
Cold-bootstrap Retry reselects its current graph when no sync view exists; new graph selection clears obsolete failure facts.

The user explicitly approved the actual/minimum payload in lib/admission.mli.
No other protected interface or build definitions changed.

The user additionally requested that an unavailable cached wrapped key begin the
existing online key preparation automatically, once per open attempt. The Core
owns this transition: it must obtain both encrypted materials before publishing a
password challenge; network/authentication failure stays visible with explicit
Retry and graph-selection exits. Cancellation, graph re-entry and late results
must not resurrect an obsolete challenge. A wrong password remains retryable.
This reuses existing events and errors without another interface change.

## Alternatives considered

### Timeout detection

A timeout guesses at the cause after production already returned a deterministic
error. Propagate that completion instead. Do not duplicate version constants in UI
or parse private snapshot metadata outside the admission owner for presentation.

## Acceptance criteria

- Current activation failure exits Bootstrapping and publishes the exact safe error.
- The loading route displays actual and minimum schema versions supplied by admission.
- Diagnostics exposes the current failure when diagnostic groups are empty.
- A cached-key miss starts one online preparation without Retry; neither material
alone advertises a password challenge. Remote network/authentication failure is
visible and does not automatically retry; explicit Retry and graph selection work.
- Cancellation/re-entry retires old key-access tickets, including account-material
and private-unlock work scoped to the graph challenge. Duplicate recovery does
not start unrelated sync inspection. Wrong-password resubmission remains usable.
- Same-account catalog reconciliation preserves an E2EE preparation failure.
- Success, explicit retry, duplicate completions, graph switches and late completions
preserve ownership and do not restore an obsolete failure.
- Tests are RED before implementation and GREEN afterward. Identify each production
owner first; use public pure worker events/completions for its propagation defect.
Admission message retention requires the public snapshot-preparation boundary;
Diagnostics visibility requires the existing application presentation boundary.
- Simulator acceptance uses an isolated synthetic graph/application and retains the
original Invest installation, process, snapshot and evidence unchanged.

## Risks

- Generic parse/SQLite errors may contain paths or data: expose only owner-authored,
fixed error messages and numeric schema metadata, never arbitrary parser strings.
- Cached-key failures lack a typed reason at the Core boundary; automatic online
recovery is therefore bounded to the failed local key-access operation, without
parsing error strings. Remote preparation failure must never loop automatically.
- Experimental Simulator native restamping is not full cross-ABI validation.
- This UI repair does not make schema 65.29 compatible or verify iPhone recovery.

## Consequences

- A rejected snapshot produces a visible terminal error instead of indefinite
loading. Diagnostics includes its safe current reason even with empty groups.
- Parser/SQLite exception text is discarded at snapshot import; AES failure and
decrypted Transit parse errors also use fixed text at the E2EE decoder boundary.
Independent review identified this additional privacy boundary; its two real
public-runner regressions failed before sanitization and passed afterward.
Version rejection is formatted from numeric metadata owned by admission.
- A missing cached key now directly starts one online material preparation. A
remote failure remains visible with explicit recovery, and same-account catalog
reconciliation cannot discard it. New selection retires old key-access tickets;
challenge work using shared account credentials still carries its graph scope.
- Opening the graph picker alone retains the current session. Selecting that same
graph resumes its valid password challenge and previous error; it must remain
editable and resubmittable. Choosing a different graph discards the old challenge.
- Retry repeats the opening attempt and may fail again with the same compatibility
error. It does not upgrade data. Choosing another graph clears the obsolete error.
- Complete native build and tests passed: 898 Alcotest cases and 87 custom checks.
Initial transport tests could not bind loopback ports inside the shell sandbox;
the full suite passed after the local-port run received approval.
- Five actual Simulator XCTest scenarios passed (79.998 seconds, zero failures):
direct password/wrong-password/current-session resume; bounded network/auth
failure recovery; switching graphs before late key material; schema rejection/
Retry/Diagnostics; and switching to a supported snapshot. The first UI run's
expectation that selecting the current graph clears its error was corrected to
the existing session-preservation contract, then all five scenarios passed.
Acceptance uses synthetic authentication/transport with production DB/Core/Runner
and Application. It does not exercise cloud sync or prove iPhone recovery.
- Fifteen new Core regressions cover automatic preparation, missing-material
guards, bounded failure and success/error completions after cancel/re-entry.
Existing runner/transport fixtures consume the new automatic transition; no
new duplicate regression coverage was added outside the owning pure reducer.
- The probe is an experimental host-restamped ARM64 object with
cross_abi_validated=false; it is not full iOS cross-compilation acceptance.
7 changes: 5 additions & 2 deletions logseq_db_storage/lib/admission.ml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ type observed =
}

type error =
| Unsupported_schema
| Unsupported_schema of
{ actual : Graph_types.schema_version
; minimum : Graph_types.schema_version
}
| Remote_graph
| Ambiguous_sync_state
| Unsupported_value
Expand Down Expand Up @@ -83,7 +86,7 @@ let synced_facts graph_id (observed : observed) =

let admit ~target (observed : observed) =
if not (schema_is_supported observed.schema)
then Error Unsupported_schema
then Error (Unsupported_schema { actual = observed.schema; minimum = minimum_schema })
else if not observed.codec_lossless
then Error Unsupported_value
else if not observed.required_schema_present
Expand Down
5 changes: 4 additions & 1 deletion logseq_db_storage/lib/admission.mli
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ type observed =
}

type error =
| Unsupported_schema
| Unsupported_schema of
{ actual : Graph_types.schema_version
; minimum : Graph_types.schema_version
}
| Remote_graph
| Ambiguous_sync_state
| Unsupported_value
Expand Down
1 change: 1 addition & 0 deletions logseq_db_worker/lib/effect_runner/effect_runner.ml
Original file line number Diff line number Diff line change
Expand Up @@ -1588,6 +1588,7 @@ let handle_sync_worker_effect t = function
~expected_checksum:None
~expected_rows:(Sync.staged_artifact_expected_rows request.artifact)
with
| Error (Overlay.Snapshot_parse_error message) -> Error (effect_error message)
| Error _ -> Error (effect_error "The snapshot activation could not start.")
| Ok prepared ->
Fun.protect
Expand Down
7 changes: 7 additions & 0 deletions logseq_db_worker/lib/pure_reducer/core.ml
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,13 @@ let step state event =
| Error error ->
let message = Logseq_db_worker_contract.Error.message error in
(match worker_effect with
| Some (Sync.Activate_snapshot request) ->
translate_sync
(Sync.step
state.sync_core
(Sync.Snapshot_activation_failed
{ scope = Sync.effect_scope_of_graph request.scope; message }))
state
| Some (Sync.Apply_authoritative_batch request) ->
let transitioned =
translate_sync
Expand Down
113 changes: 112 additions & 1 deletion logseq_db_worker/test/test_pure_reducer.ml
Original file line number Diff line number Diff line change
Expand Up @@ -979,10 +979,121 @@ let test_upload_recovery_failure () =
(Core.upload_recovery_current stale.next fresh)
;;

(* The worker owns translating its runner's terminal result into Sync state.
Drive a complete cold bootstrap using only public events and issued tickets. *)
let snapshot_activation () =
let graph : Sync.graph =
{ graph_id = uuid "71111111-1111-4111-8111-111111111111"
; name = "Synthetic schema fixture"
; schema = { major = 65; minor = 29; exact = true }
; encrypted = false
}
in
let auth = Core.step (initial ())
(Sync_event (Sync.Account_authenticated { user_id = Some "synthetic" })) in
let catalog = List.find_map (function
| Core.Run_sync (Sync.Request (ticket, Sync.Fetch_catalog _)) ->
Some (Core.step auth.next (Sync_event
(Sync.Runner_completed (Sync.Completion (ticket, Ok [graph])))))
| _ -> None) auth.effects |> Option.get in
let selected = Core.step catalog.next (Sync_event (Sync.Graph_selected graph.graph_id)) in
let missing, scope = List.find_map (function
| Core.Run_worker (Core.Request (ticket,
Core.Handle_sync_worker_effect (Sync.Inspect_mirror request))) ->
Some (Core.step selected.next (Runner_completed
(Sync_worker_effect_completed (ticket, Ok
{ event = Some (Sync.Mirror_inspected (Mirror_absent request.scope))
; lifecycle = Lifecycle_unchanged }))), request.scope)
| _ -> None) selected.effects |> Option.get in
let baseline = List.find_map (function
| Core.Run_sync (Sync.Request (ticket, Sync.Fetch_snapshot_baseline _)) ->
Some (Core.step missing.next (Sync_event (Sync.Runner_completed
(Sync.Completion (ticket, Ok {|{"type":"pull/ok","t":42}|})))))
| _ -> None) missing.effects |> Option.get in
let download = List.find_map (function
| Core.Run_sync (Sync.Request (ticket, Sync.Fetch_snapshot_metadata _)) ->
Some (Core.step baseline.next (Sync_event (Sync.Runner_completed
(Sync.Completion (ticket, Ok {|{"ok":true,"url":"https://example.invalid/snapshot"}|})))))
| _ -> None) baseline.effects |> Option.get in
let activation = List.find_map (function
| Core.Run_sync (Sync.Request (ticket, Sync.Download_snapshot _)) ->
let artifact = Sync.staged_artifact ~id:"synthetic" ~scope
~path:"/tmp/synthetic-schema.snapshot" ~expected_rows:1 in
Some (Core.step download.next (Sync_event (Sync.Runner_completed
(Sync.Completion (ticket, Ok artifact)))))
| _ -> None) download.effects |> Option.get in
let error = Logseq_db_worker.Error.create ~code:Closed_session
~message:"Graph schema 65.29 is not supported. Journal requires schema 65.33 or later."
~details:[] |> Result.get_ok in
let failure, success = List.find_map (function
| Core.Run_worker (Core.Request (ticket,
Core.Handle_sync_worker_effect (Sync.Activate_snapshot request))) ->
Some (Core.Runner_completed (Sync_worker_effect_completed (ticket, Error error)),
Core.Runner_completed (Sync_worker_effect_completed (ticket, Ok
{ event = Some (Sync.Snapshot_activated {scope = request.scope})
; lifecycle = Lifecycle_unchanged })))
| _ -> None) activation.effects |> Option.get in
activation, failure, success, Logseq_db_worker.Error.message error
;;

let test_snapshot_failure_is_terminal_and_retryable () =
let activation, failure, _, message = snapshot_activation () in
let failed = Core.step activation.next failure in
let snapshot = (Core.view failed.next).sync.snapshot in
Alcotest.(check bool) "failure terminates bootstrap" true
(snapshot.startup.failure = Some Sync.During_bootstrap && snapshot.sync_phase = Sync.Failed);
Alcotest.(check (option string)) "specific reason retained" (Some message) snapshot.last_error;
Alcotest.(check bool) "failure published to UI" true
(List.exists (function Core.Publish (Sync_output (Sync.State_changed state)) ->
state.snapshot.last_error = Some message | _ -> false) failed.effects);
let duplicate = Core.step failed.next failure in
Alcotest.(check bool) "duplicate completion inert" true
(Core.equal_view (Core.view failed.next) (Core.view duplicate.next) && duplicate.effects = []);
let retry = Core.step failed.next (Sync_event Sync.Online_recovery_requested) in
Alcotest.(check (option string)) "explicit retry clears failure" None
(Core.view retry.next).sync.snapshot.last_error;
let late = Core.step retry.next failure in
Alcotest.(check bool) "old failure cannot poison retry" true
(Core.equal_view (Core.view retry.next) (Core.view late.next) && late.effects = [])
;;

let test_snapshot_success_and_late_switch_completions () =
let activation, failure, success, _ = snapshot_activation () in
let succeeded = Core.step activation.next success in
let attaching = List.find_map (function
| Core.Run_worker (Core.Request (ticket,
Core.Handle_sync_worker_effect (Sync.Inspect_mirror request))) ->
Some (Core.step succeeded.next (Runner_completed (Sync_worker_effect_completed
(ticket, Ok { event = Some (Sync.Mirror_inspected (Mirror_available request))
; lifecycle = Lifecycle_unchanged }))))
| _ -> None) succeeded.effects |> Option.get in
Alcotest.(check bool) "success proceeds to attachment" true
(List.exists (function Core.Run_worker (Core.Request (_,
Core.Handle_sync_worker_effect (Sync.Attach_graph _))) -> true | _ -> false) attaching.effects);
Alcotest.(check (option string)) "success has no failure" None
(Core.view succeeded.next).sync.snapshot.last_error;
let switched = Core.step activation.next (Sync_event Sync.Graph_picker_requested) in
List.iter (fun completion ->
let late = Core.step switched.next completion in
Alcotest.(check bool) "retired graph keeps selected state" true
((Core.view switched.next).sync = (Core.view late.next).sync);
Alcotest.(check bool) "retired graph keeps lifecycle" true
((Core.view switched.next).graph = (Core.view late.next).graph);
let work = List.filter (function
| Core.Run_worker (Core.Request (_, Core.Handle_sync_worker_effect (Sync.Detach_graph _))) -> false
| _ -> true) late.effects in
Alcotest.(check (list string)) "retired graph emits only pending close cleanup" []
(List.map Core.instruction_diagnostic work))
[failure; success]
;;

let () =
Alcotest.run
"logseq db worker pure reducer"
[ ( "asset session"
[ ( "snapshot failure", [
Alcotest.test_case "terminal failure and explicit retry" `Quick test_snapshot_failure_is_terminal_and_retryable;
Alcotest.test_case "success and late graph switch completions" `Quick test_snapshot_success_and_late_switch_completions ])
; ( "asset session"
, [ Alcotest.test_case "bounded upload recovery" `Quick test_upload_recovery
; Alcotest.test_case
"upload recovery backpressure"
Expand Down
15 changes: 12 additions & 3 deletions logseq_overlay_db/lib/mirror.ml
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,18 @@ let inspect_database database_path graph_id =
~storage_schema:startup.schema
with
| Ok admitted -> Ok (database, admitted.schema, callbacks)
| Error _ ->
| Error error ->
ignore (Storage.close callbacks);
Error "snapshot graph admission failed")
Error
(match error with
| Unsupported_schema { actual; minimum } ->
Printf.sprintf
"Graph schema %d.%d is not supported. Journal requires schema %d.%d or later."
actual.major actual.minor minimum.major minimum.minor
| Remote_graph -> "The downloaded graph has an unexpected remote identity."
| Ambiguous_sync_state -> "The downloaded graph has an invalid synchronization identity."
| Unsupported_value -> "The downloaded graph contains unsupported values."
| Corrupt_storage -> "The downloaded graph storage is corrupt."))
| Error _, _ | _, Error _ ->
ignore (Storage.close callbacks);
Error "snapshot SQLite storage is corrupt")
Expand All @@ -137,7 +146,7 @@ let stage ~root ~graph_id ~snapshot_path ~expected_rows =
Error message
in
match import_snapshot_rows ~snapshot_path ~expected_rows database_path with
| Error message -> cleanup message
| Error _ -> cleanup "The downloaded graph snapshot could not be imported."
| Ok () ->
(match inspect_database database_path graph_id with
| Error message -> cleanup message
Expand Down
Loading
Loading