Skip to content

Migrate wear-sync onto the shared DurableEventQueue - #296

Open
ScottMorris wants to merge 4 commits into
feat/255-p2a-alarm-manager-queuefrom
feat/255-p2b-wear-sync-queue
Open

Migrate wear-sync onto the shared DurableEventQueue#296
ScottMorris wants to merge 4 commits into
feat/255-p2a-alarm-manager-queuefrom
feat/255-p2b-wear-sync-queue

Conversation

@ScottMorris

@ScottMorris ScottMorris commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

What this is

Phase 2B of #255's implementation plan: migrates wear-sync's WearSyncQueue (which the new DurableEventQueue in #294 was actually modelled on) onto the shared substrate too, so both plugins that need "queue events until Rust is up" logic share one implementation. Behaviour-preserving — nothing about what's sent/received to the watch or Rust changes, only the internal persistence mechanism.

What's here

  • WearSyncEventQueue wraps DurableEventQueue (path → topic, data → payload, timestamp → publishedAt); WearSyncQueue.kt deleted, no remaining callers.
  • A one-time migration of the legacy pending_messages key into the new log.
  • onWatchMessage now always enqueues then drains-if-ready (single code path, matching Design: shared native event-queue layer for plugin↔Rust handoff, plus cross-plugin native fan-out #255's "the log is the only delivery path" decision) instead of the old ready/queued split.
  • Delivery is peek-then-commit-after-success (not drain-then-deliver), so a mid-batch delivery failure leaves undelivered entries in the queue for the next drain instead of losing them.
  • A process-wide singleton accessor so concurrent callers (WearSyncPlugin, WearMessageService) share one queue instance — the earlier per-call-construction pattern would have made the queue's internal @Synchronized locking meaningless (different instances, different monitors, no real mutual exclusion).
  • Field-diagnostic logging restored via NativeEventLog on enqueue/commit/migration, matching what the old WearSyncQueue surfaced through the user-exported log.
  • .github/workflows/test.yml: both alarm-manager's and wear-sync's synthesized CI settings now include the native-bus project, and every affected gradlew invocation uses a qualified :testDebugUnitTest task path — this was the actual fix for the Gradle-sweep risk flagged during review (better than the "defer the include" workaround Add the shared native event bus plugin (native-bus) #294 shipped with), so native-bus's tests no longer run redundantly inside other plugins' CI steps.

Testing checklist (automated, no device needed)

  • 13/13 JUnit (migration correctness, peek-without-commit, partial-commit-on-failure, path/topic round-trip)
  • cargo check --workspace clean
  • Full pnpm tauri android build succeeds end-to-end; confirmed the qualified-task-path fix actually eliminates the sweep (38 Gradle tasks run for wear-sync's build vs. 50 before, with no tauri-plugin-native-bus:testDebugUnitTest in the list)

Review status

Reviewed via /code-review high twice. First pass found a real data-loss bug (the original drainAll() removed entries from the log before delivery was confirmed, so a mid-batch failure silently dropped the rest) and a real concurrency bug (a fresh queue instance per call defeated its own locking). Both fixed and re-verified with new test coverage. A flagged performance note (always persisting to SharedPreferences even on the already-ready fast path) was confirmed as an intentional tradeoff from #255's design, not a regression — left as-is.

Stacked on #295, which is stacked on #294. Part of #255.

@ScottMorris ScottMorris added the android Android toolchain and mobile CI concerns label Aug 13, 2026
@ScottMorris ScottMorris changed the title feat/255 p2b wear sync queue Migrate wear-sync onto the shared DurableEventQueue Aug 13, 2026
@ScottMorris ScottMorris added architecture System design plugin Plugin work wear-os Wear OS platform labels Aug 13, 2026
@ScottMorris
ScottMorris marked this pull request as ready for review August 14, 2026 04:44
@ScottMorris

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6fe149f10d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/wear-sync/README.md Outdated
ScottMorris and others added 4 commits August 18, 2026 12:46
…rableEventQueue

Replaces the plugin's bespoke `WearSyncQueue` (a hand-rolled SharedPreferences
JSON array) with `WearSyncEventQueue`, a thin wrapper around native-bus's shared
`DurableEventQueue` log. Each Play Services message path (e.g.
`/threshold/save_alarm`) becomes the queue's topic; the raw message string is
its opaque payload. This is Phase 2B of issue #255 -- a behaviour-preserving
refactor, so nothing about what data flows to/from Rust or the watch changes,
only how pending messages are stored/drained internally on the Kotlin side.

**Why:** `WearSyncQueue` was the design template `DurableEventQueue` was
modelled on in Phase 1 (see native-bus's own KDoc), so consolidating onto the
shared implementation removes a duplicate, independently-maintained queue
format now that a reusable one exists.

**What changed:**
- `WearSyncPlugin.onWatchMessage` now always enqueues first, then drains
  immediately if the pipeline is ready -- "immediate dispatch" is just
  "enqueue, then drain right away" rather than a separate code path, so there
  is exactly one way a message ever reaches Rust (through
  `drainQueuedMessages`), matching the Unified design's decision 7.
- `WearSyncEventQueue` migrates any leftover `WearSyncQueue`-format entries
  (the old `pending_messages` SharedPreferences key) into the new
  `DurableEventQueue` log the first time either `enqueue` or `drainAll` runs,
  preserving each entry's original `timestamp` as `publishedAt`, then removes
  the old key. This is one-way -- documented in the plugin's README, since
  `RELEASE_NOTES.md` is only written at actual release-tagging time, not
  per-phase.
- Added the real Gradle project dependency
  (`implementation(project(":tauri-plugin-native-bus"))`) and the matching CI
  settings-synthesis line for wear-sync's `test-kotlin-plugins` job, both
  deliberately deferred from Phase 1.
- Deleted `WearSyncQueue.kt` -- its logic has no remaining callers.

**Verification:**
- `./gradlew -p plugins/wear-sync/android testDebugUnitTest` and the
  equivalent native-bus task both green in the CI mobile container, matching
  `.github/workflows/test.yml`'s exact settings-synthesis pattern.
- `cargo check --workspace` clean -- confirms the Rust side is untouched.
- A full `pnpm tauri android build --debug -t aarch64 --ci` succeeded end to
  end, proving the new Gradle dependency resolves in the real app build, not
  just the isolated per-plugin CI job.
…round-trip

Plain JUnit 4 tests against an in-memory `KeyValueStore` fake, following the
same convention native-bus's own test suite established (no Robolectric,
`InMemoryKeyValueStore` duplicated here rather than shared across module test
source sets, mirroring `NativeEventLog`'s existing duplication-over-sharing
rationale in this plugin).

`DurableEventQueue`'s own drain/commit/corrupt-entry behaviour is already
covered by native-bus's suite, so these focus on what's new here: the legacy
`WearSyncQueue`-format migration (topic/payload/publishedAt mapping, the old
key being removed, malformed/corrupt entries not blocking the rest), and the
enqueue/drain round-trip that replaces `WearSyncPlugin.onWatchMessage`'s old
direct `WearSyncQueue` calls.

Note: `WearSyncPlugin.onWatchMessage`/`drainQueuedMessages` themselves aren't
exercised directly, since this codebase's plain-JUnit Kotlin setup has no
mocking framework or Robolectric to stand in for the `Activity`/`Channel`
those methods need -- `WearSyncEventQueue` is the actual unit that carries the
enqueue/drain/migration logic they delegate to.
…and restored field logs

Addresses code review findings on the DurableEventQueue migration.

**Data loss on partial delivery failure:** `WearSyncEventQueue.drainAll()`
removed every envelope from the log before the caller had actually delivered
any of them, defeating `DurableEventQueue`'s peek-then-commit design. If
`channel.send()` threw partway through a batch in
`WearSyncPlugin.drainQueuedMessages`, every remaining queued message was
already gone -- silently lost, not retried. Replaced `drainAll()` with
`peekAll()` (read-only) and `commit(eventIds)`; `drainQueuedMessages` now
delivers each entry first and commits only the ones actually handed to the
Channel, via a try/finally so a mid-batch failure still commits whatever
succeeded before it and leaves the rest queued for the next drain.

**Cross-instance race:** `WearMessageService.handleOfflineWrite` constructed a
brand-new `WearSyncEventQueue` (and thus `DurableEventQueue`) on every call.
`@Synchronized` locks each instance's own monitor, so two independently-
constructed instances over the same SharedPreferences file gave zero mutual
exclusion between `WearSyncPlugin`'s and `WearMessageService`'s enqueues --
two watch messages arriving close together could race and silently clobber
each other. Added `WearSyncEventQueue.getInstance(context)`, a process-wide
singleton accessor (mirroring how this plugin already holds other shared
state via `WearSyncPlugin.instance`); both call sites now go through it.

**Observability regression:** the old `WearSyncQueue` called `NativeEventLog`
(which feeds the user-exported log via `requestWatchLogs`, no ADB needed) on
every enqueue/drain/migration; the replacement only logged to logcat. Restored
the equivalent `NativeEventLog` calls, gated on an optional `Context` so tests
can still construct this class against an in-memory `KeyValueStore` with no
Android framework available.

**CI:** the settings-synthesis fix for wear-sync's native-bus dependency
re-introduced the "unqualified `testDebugUnitTest` sweeps every included
subproject" problem Phase 1 deliberately avoided. Fixed properly this time:
both wear-sync's and native-bus's own `gradlew` invocations now use the
qualified `:testDebugUnitTest` task path (leading colon = root project's own
task only), so including native-bus in wear-sync's settings is genuinely
harmless -- no redundant sweep, no cross-plugin failure misattribution.
alarm-manager's invocation is intentionally left untouched (separate branch).

**Verification:**
- `./gradlew -p plugins/wear-sync/android :testDebugUnitTest` -- 13/13 pass
  (grew from 10: added peek-without-commit and partial-commit coverage).
- `./gradlew -p plugins/native-bus/android :testDebugUnitTest` -- 16/16 pass,
  and confirmed via task output that it no longer runs as a side effect of
  wear-sync's own build (38 tasks executed vs. 50 before the qualified-path
  fix, with no `tauri-plugin-native-bus:testDebugUnitTest` task in the list).
- `cargo check --workspace` clean.
…d Markdown, per code review

Two real bugs from automated review on PR #296's WearSyncEventQueue, plus reflowing hard-wrapped Markdown paragraphs this branch had introduced.

**Unsynchronized legacy migration.** WearMessageService and WearSyncPlugin both go through the same singleton WearSyncEventQueue instance, but migrateLegacyEntriesIfNeeded's check-then-act (read the legacy key, decide whether to migrate, write back) ran outside any lock -- DurableEventQueue's own `@Synchronized` methods only guard its own delegated calls, not this wrapper-level sequence, which reads/writes the same underlying key directly. Two concurrent callers could each pass the "has legacy entries" check before either wrote back, and the later migration write would silently clobber whatever the other caller had just enqueued. Fixed with a `lock` field shared across `enqueue`, `peekAll`, and `commit`, held for the full migration-decision-plus-delegated-operation sequence.

**Non-atomic legacy migration write.** The migration wrote the new log and removed the legacy key as two separate SharedPreferences calls; a process death between them left both copies on disk, and the next access re-migrated the same entries again with fresh event IDs, delivering the same watch action twice. Fixed by routing that write through `KeyValueStore.batch()` (already used by alarm-manager's identical fix for the same class of bug), so the new log and legacy-key removal land as one atomic transaction.

**Markdown formatting.** Reflowed every hard-wrapped prose paragraph this branch had introduced (in README.md, WearSyncEventQueue.kt, WearSyncPlugin.kt, WearMessageService.kt, build.gradle.kts, and the test files) into single unwrapped source lines per AGENTS.md's Markdown convention.

**Tests:** WearSyncEventQueueTest grows to 15 cases (from 13), adding a new RecordingKeyValueStore fake (mirroring AlarmManagerPluginTest's) to assert migration goes through exactly one atomic batch() call, and a many-threads-racing-past-a-start-gate test proving no event is lost when concurrent enqueue calls race a pending legacy migration.

Verified: full plugins/wear-sync/android + plugins/native-bus/android JUnit suites (15 + 12 tests, all passing) via `gradlew testDebugUnitTest` against the tauri-ci-mobile CI image.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WVm6JTTpiSY8KSKJ3p5Qq
@ScottMorris
ScottMorris force-pushed the feat/255-p2b-wear-sync-queue branch from 6fe149f to 6f605ec Compare August 18, 2026 16:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

android Android toolchain and mobile CI concerns architecture System design plugin Plugin work wear-os Wear OS platform

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant