Skip to content

feat(sync): add syncNow that waits for the run it starts - #387

Merged
mohamede1945 merged 3 commits into
mainfrom
feat/await-manual-sync
Nov 7, 2026
Merged

mohamede1945 merged 3 commits into
mainfrom
feat/await-manual-sync

Conversation

@mohamede1945

@mohamede1945 mohamede1945 commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

triggerSync(immediately = true) doesn't wait. An app that wants a pull-to-refresh or "Sync now" spinner can't tell when the sync it asked for is over. Watching syncStatus doesn't settle it either: a sync already running finishes first and a follow-up run comes after it, and offline the status keeps alternating between retrying and syncing. quran-ios needs this for pull-to-refresh on Bookmarks and Notes.

Change

  • New @NativeCoroutines suspend fun QuranDataService.syncNow(): SyncNowResult. It starts the same manual run as triggerSync(immediately = true): it pushes even while pushes back off, replaces a run waiting to retry, and fetches the profile now. It returns once the caller can stop waiting:
    • Ended(run): the run succeeded, failed with no retries left, or was cancelled. It never only pulls, because the manual run always pushes. run is the same entry syncHistory records.
    • Retrying(failedAt, error, nextAttemptAt): the attempt failed and the run will retry on its own (offline, say), so the caller isn't held through the backoff.
    • SignedOut: no one is signed in, the signed-in account id is unknown, or the session ended before the run reached the server.
    • Unavailable: a logout or reset is running or began during the run, or clear() ran before it started. A clear() during the run returns Ended with a CANCELLED run instead.
  • If launch recovery failed, syncNow() logs the failure and rethrows it, like the other callers of awaitInitialization.
  • If a sync is already running, the call waits for the run that follows it. Calls made before that run starts all share it. Cancelling the caller stops only the wait, not the sync.
  • SyncScheduler gives each call a waiter, which it hands to the next run's first attempt. Cancel, close and logout complete every pending waiter with Unavailable. Only the scheduler's single command loop touches the waiters.
  • Behaviour change: an immediate trigger now replaces an automatic run that is due but hasn't fired (periodic, foreground, local change). Before, it kept that timer. The waited run is therefore the manual one. It only joins a run that is itself immediate, such as a sign-in's.
  • The scheduler now recovers from an attempt that throws or ends with a stray cancellation: every attempt sends Finished from its finally. Before, such an attempt left the scheduler "running" for good. Periodic sync stopped, and a later syncNow() would wait for a follow-up run that never started. A cancelled attempt's Finished carries the old generation, so it's ignored.
  • KDoc, README and the scheduler notes in AGENTS.md describe the new API.

SyncClient.kt is now 533 lines, over the ~500-line guideline. I didn't split it.

Tests

  • SyncClientSyncNowTest (new, 9 tests, virtual time, real engine on an in-memory database, mock HTTP):
    • idle → Ended, and an attempt that will retry → Retrying;
    • a final failure, and a call while a retry waits replaces the retry;
    • signed out → SignedOut;
    • a logout during the request or during the run → Unavailable;
    • a close during the run → Ended(CANCELLED);
    • cancelling the caller doesn't stop the run.
  • SyncSchedulerTest, 9 new:
    • while a run is in flight, the call waits for the follow-up run, including when the current attempt fails and would retry;
    • calls before the run starts share it;
    • a due local-change run is replaced by an immediate one, while a due sign-in run is shared;
    • cancel or close before the run → Unavailable;
    • an attempt that throws, or ends with a stray cancellation, leaves the scheduler working. These two fail without the finally fallback.
  • QuranDataServiceLifecycleTest:
    • a reset → Unavailable;
    • a failed launch recovery rethrows;
    • cancelling the caller during recovery cancels the call;
    • clear() before the run → Unavailable.
  • Integration scenario OBS-05: a sync now returns the succeeded run, matching the history and the server.
  • ./gradlew :sync-pipelines:allTests :umbrella:assembleSharedDebugXCFramework passes: 453 JVM, 150 Android host and 150 iOS simulator tests.

Two independent Opus reviews ran: one on the first version, one on the follow-up fixes. Neither left a must-fix or should-fix. A third review checked the final trim of comments and tests.

Consumers

🤖 Generated with Claude Code

QuranDataService.syncNow() runs the same manual sync as
triggerSync(immediately = true) and suspends until the user can stop
waiting for it, so an app can show a pull-to-refresh or "Sync now"
spinner for exactly as long as the sync runs.

- Returns SyncNowResult: Ended(run) when the run ends, Retrying when its
  attempt failed and it waits to retry (offline), SignedOut when nothing
  could sync, Unavailable during a logout, reset or after clear().
- A sync already running runs to its end first; the call waits for the
  run after it, and calls before that run starts share it.
- An immediate trigger now replaces an automatic run that is due but
  hasn't fired, so the waited run is the manual one; it only shares an
  immediate run already due, such as a sign-in's.
- The scheduler hands each call's waiter to the next run's first attempt;
  cancel, close and logout complete waiters with Unavailable. Cancelling
  the caller stops only the wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mohamede1945 and others added 2 commits November 6, 2026 20:49
Review follow-ups for syncNow.

- SyncScheduler: every attempt now sends Finished from its finally, so an
  attempt that throws, or ends with a stray CancellationException, no
  longer leaves the scheduler stuck "running". Before, later syncNow
  calls waited for a follow-up run that never started and periodic sync
  stopped. A cancelled attempt's Finished carries the old generation and
  is ignored; after close() the trySend fails quietly.
- QuranDataService.syncNow: a launch-recovery failure is logged and
  rethrown, like every other caller of awaitInitialization, instead of
  being reported as Unavailable.
- Tests: a throwing and a stray-cancelled attempt; a sync now during an
  attempt that will retry gets the follow-up run; a failed and a
  cancelled launch recovery. reportOf now encodes the trigger, so result
  assertions tell runs apart.
- Docs: Ended never pulls only, SignedOut covers an unknown account id,
  Unavailable vs Ended(CANCELLED) for a clear is spelled out, and
  AGENTS.md lists SyncNowResult.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comments and docs only in production code: public KDoc and README keep
the contract, internal comments keep only the non-obvious parts (waiter
hand-off, first report wins, the finally fallback, the due-timer
replacement). The PR's edits to triggerSync's and MANUAL's KDoc are
reverted.

Tests drop cases that another test already proves; one test per
behaviour stays, including the logout-during-request and
clear-during-run waiter paths and the throwing-attempt regressions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mohamede1945
mohamede1945 merged commit eb316c4 into main Nov 7, 2026
2 checks passed
@mohamede1945
mohamede1945 deleted the feat/await-manual-sync branch November 7, 2026 19:34
mohamede1945 added a commit to quran/mobile-sync-spm that referenced this pull request Nov 7, 2026
0.1.57 adds QuranDataService.syncNow (quran/mobile-sync#387), which the
syncNow wrapper calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mohamede1945 added a commit to quran/mobile-sync-spm that referenced this pull request Nov 7, 2026
* feat: wrap syncNow for a pull-to-refresh that waits for the sync

MobileSyncDataService.syncNow() async throws -> MobileSyncNowResult wraps
mobile-sync's QuranDataService.syncNow(): it runs the manual sync
triggerSync(immediately: true) starts and returns once the user can stop
waiting.

- MobileSyncNowResult: ended(MobileSyncRun), retrying(failedAt:error:
  nextAttemptAt:), signedOut, unavailable. It reuses MobileSyncRun and
  MobileSyncRunError; no Kotlin types are exposed.
- Task cancellation throws CancellationError and stops only the wait; a
  result this package doesn't know throws MobileSyncDataError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: align syncNow docs with mobile-sync's review fixes

- ended never carries a pulled-only run: syncNow's run always pushes.
- signedOut also covers an unknown signed-in account id.
- unavailable: MobileSync stopped before the run started; stopping
  during the run ends it cancelled instead.
- syncNow() throws MobileSyncDataError (usually .failed(underlying:))
  when launch recovery failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: trim syncNow docs and tests to what's needed

Docs keep the contract, one line per MobileSyncNowResult case. The
failed-run mapping test is dropped: the MobileSyncRun tests cover it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build: use mobile-sync 0.1.57

0.1.57 adds QuranDataService.syncNow (quran/mobile-sync#387), which the
syncNow wrapper calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
mohamede1945 added a commit to quran/quran-ios that referenced this pull request Nov 7, 2026
mobile-sync-spm 0.4.13 (quran/mobile-sync-spm#53) adds
MobileSyncDataService.syncNow on mobile-sync 0.1.57
(quran/mobile-sync#387), which pull-to-refresh calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mohamede1945 added a commit to quran/quran-ios that referenced this pull request Nov 7, 2026
* feat(sync): add pull-to-refresh to Bookmarks and Notes

In the sync build, pulling down on the Bookmarks list or the Notes list
runs a sync, and the spinner stays up until it's over (it ended, or it
failed and waits to retry). It awaits MobileSync's new syncNow().

- Offered only when signed in; it stays in edit mode so Edit/Done don't
  rebuild the list. Both view models follow authenticationStateChanges,
  so a sign-in or sign-out elsewhere updates the control and the banner.
- An empty Notes list is pullable when signed in.
- Failures are only logged; Settings > Sync shows the details.
- NoorUI gains refreshable(isEnabled:action:), documented to be tied
  only to rarely changing state because toggling it replaces the view.
- The no-sync build is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(sync): trim pull-to-refresh comments and tests

Comments and tests only: shorter docs on the view models and NoorUI's
refreshable(isEnabled:) (keeping the identity caveat and placement
rule), and leaner view-model tests with the same coverage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(deps): update mobile-sync-spm to 0.4.13

mobile-sync-spm 0.4.13 (quran/mobile-sync-spm#53) adds
MobileSyncDataService.syncNow on mobile-sync 0.1.57
(quran/mobile-sync#387), which pull-to-refresh calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant