Skip to content

Test DefaultArrivalsRepository's stale-fallback/CAS concurrency against the real class (#1909)#1932

Merged
bmander merged 1 commit into
mainfrom
test/arrivals-repository-cas-1909
Jul 17, 2026
Merged

Test DefaultArrivalsRepository's stale-fallback/CAS concurrency against the real class (#1909)#1932
bmander merged 1 commit into
mainfrom
test/arrivals-repository-cas-1909

Conversation

@bmander

@bmander bmander commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Fixes #1909 (from the 2026-07-16 debt review, finding 9).

Problem

The single-write LastGood holder and the fresh-wins-over-stale compareAndSet in DefaultArrivalsRepository were subtle race fixes (#1865) whose reasoning existed only in comments. ArrivalsViewModelTest uses a fake repository; nothing constructed the real class, so a future simplification could re-break the concurrency invisibly.

Why the class wasn't testable, and the fix

The real class couldn't be built on the JVM because of three incidental Android tendrils. Each is now a small, independently-justified seam — behavior unchanged, verified by the full unit suite on both flavors plus an on-device smoke test:

  • ArrivalsDisplay — the Android presentation edge (convertArrivals + stop error strings) behind an interface. DefaultArrivalsDisplay holds the Context, which leaves DefaultArrivalsRepository's constructor entirely — completing the quarantine its docstring already claimed. Key discovery: ArrivalInfo already tolerates a null context (the "Activity destroyed" guard), so the test fake builds real display models with real ETA math.
  • ElapsedClock (time/) — the injectable ElapsedTime.now() source, the monotonic counterpart of the existing TimeProvider and the class-boundary form of the "pass the now in" rule — so the stale projection is testable with an exact elapsed delta.
  • RouteFavorites — the narrow starring surface (interface segregation) of RouteFavoritesRepository, whose full dependency set (Context/analytics/app scope) isn't JVM-constructible. Its getArrivals-path use is nil; the @Singleton impl is @Binds-bound.

The tests (DefaultArrivalsRepositoryTest, 7, all against the real class)

Hand-rolled fakes (house style) over real StopArrivals wire fixtures:

  1. Fresh load publishes a lastLoaded() consistent with the displayed trips (real focusedTrips derivation)
  2. Empty-window widen loop grows the window (65 → 125 → 185)
  3. Stop recorded once per session, not per poll
  4. Failure with no prior data → the display error message
  5. Stale fallback: ETAs projected forward by exactly the elapsed device time (Use the server clock (currentTime) as the ETA baseline everywhere; audit the app for time-domain mixing #1612), windowEnd unmoved
  6. Uncontended CAS: the re-projection observably refreshes the derived map snapshot (a passed arrival drops out of focusedTrips)
  7. The interleaved race: the stale path is parked between its lastGood read and its CAS (a gated fake StopDao.userInfo — the same CompletableDeferred gate technique ArrivalsViewModelTest uses), a fresh load publishes, and the released CAS loses — a stale re-projection can never roll back a concurrently-published fresh snapshot.

Verification

  • testObaGoogleDebugUnitTest + testObaMaplibreDebugUnitTest green with -PwarningsAsErrors=true; Hilt graph compiles.
  • On-device smoke test (Puget Sound stop 1_75403): arrivals render fully through the new wiring — header, route rows with real labels, ETA pills, "Showing trips until…" footnote, Load more.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Improved arrival updates with more accurate elapsed-time handling and stale-data ETA projections.
    • Added clearer separation for arrival display formatting and route favorites services.
    • Failed refreshes now provide user-facing stop error messages while preserving available stale arrival data.
  • Bug Fixes

    • Prevented stale arrival data from overwriting newer results during concurrent updates.
    • Improved handling of empty arrival windows and expired projected arrivals.
  • Tests

    • Added coverage for refresh behavior, fallback data, ETA projections, favorite usage, and concurrency.

…st the real class (#1909)

The single-write LastGood holder and the fresh-wins-over-stale compareAndSet
were subtle race fixes (#1865) whose reasoning lived only in comments; no test
constructed the real class, so a future simplification could re-break them
invisibly.

The class couldn't be built on the JVM because of three incidental Android
tendrils, each now a small, independently-justified seam (behavior unchanged):

- ArrivalsDisplay: the Android presentation edge (convertArrivals + stop error
  strings) behind an interface; DefaultArrivalsDisplay holds the Context, which
  leaves DefaultArrivalsRepository's constructor entirely — completing the
  quarantine its docstring already claimed. ArrivalInfo already tolerates a
  null context, so a test fake builds real display models with real ETA math.
- ElapsedClock (time/): the injectable ElapsedTime.now() source — the
  monotonic counterpart of the existing TimeProvider, and the class-boundary
  form of the "pass the now in" rule — so the stale projection is testable
  with an exact elapsed delta.
- RouteFavorites: the narrow starring surface (interface segregation) of
  RouteFavoritesRepository, whose full dependency set (Context/analytics/app
  scope) isn't JVM-constructible.

DefaultArrivalsRepositoryTest exercises the real class with hand-rolled fakes
(house style; real StopArrivals wire fixtures): the fresh-load publish and its
lastLoaded()/focusedTrips derivation, the widen loop, once-per-session stop
recording, the no-prior-data failure message, the stale projection advancing
ETAs by exactly the elapsed device time (#1612), the uncontended CAS refreshing
the derived map snapshot, and the interleaved race — the stale path parked
between its lastGood read and its CAS (a gated fake StopDao.userInfo) while a
fresh load publishes — proving a stale re-projection can never roll back a
concurrently-published fresh snapshot.

Verified on-device: arrivals for a Puget Sound stop render fully through the
new wiring (rows, ETA pills, window footnote).

Fixes #1909

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

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds injectable seams for monotonic time, arrivals display conversion, and route favorites. Refactors DefaultArrivalsRepository to use them and adds JVM tests for fresh loads, stale fallback projection, snapshot consistency, and concurrent CAS behavior.

Changes

Arrivals repository seams and validation

Layer / File(s) Summary
Injectable contracts and production bindings
onebusaway-android/src/main/java/org/onebusaway/android/time/ElapsedClock.kt, onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsDisplay.kt, onebusaway-android/src/main/java/org/onebusaway/android/database/oba/RouteFavoritesRepository.kt, onebusaway-android/src/main/java/org/onebusaway/android/app/di/*
Defines ElapsedClock, ArrivalsDisplay, and RouteFavorites contracts, then binds their production implementations through Hilt.
Repository integration
onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsRepository.kt
Removes direct context and clock calls, injects the new seams, and routes conversion, error messages, receipt times, and stale projection through them.
Fresh and stale behavior tests
onebusaway-android/src/test/java/org/onebusaway/android/ui/arrivals/DefaultArrivalsRepositoryTest.kt
Adds fakes and tests for polling windows, stop usage, error handling, elapsed-time ETA projection, snapshot updates, and stale-versus-fresh CAS ordering.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main change: real-class tests for DefaultArrivalsRepository's stale-fallback/CAS concurrency.
Linked Issues check ✅ Passed The PR adds real-class tests for stale fallback, CAS interleaving, lastLoaded consistency, and trip derivation, matching #1909.
Out of Scope Changes check ✅ Passed The new seams and DI bindings support the repository testability goal and the linked concurrency tests, so no clear out-of-scope changes appear.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/arrivals-repository-cas-1909

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@onebusaway-android/src/test/java/org/onebusaway/android/ui/arrivals/DefaultArrivalsRepositoryTest.kt`:
- Around line 395-399: Update ArrivalsViewModel.refresh() to guard the
loaded.value = data assignment with latest-wins generation or cancellation
validation, so results from older overlapping refreshes are discarded before
updating UI state. Preserve the existing lastLoaded() behavior and allow only
the most recent refresh completion to publish loaded data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1e2591b2-9c7c-44ab-9848-79ea515a2c9f

📥 Commits

Reviewing files that changed from the base of the PR and between 4e3dcec and f60f58e.

📒 Files selected for processing (7)
  • onebusaway-android/src/main/java/org/onebusaway/android/app/di/AppModule.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/app/di/RepositoryModule.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/database/oba/RouteFavoritesRepository.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/time/ElapsedClock.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsDisplay.kt
  • onebusaway-android/src/main/java/org/onebusaway/android/ui/arrivals/ArrivalsRepository.kt
  • onebusaway-android/src/test/java/org/onebusaway/android/ui/arrivals/DefaultArrivalsRepositoryTest.kt

@bmander
bmander merged commit c4e00e0 into main Jul 17, 2026
3 checks passed
@bmander
bmander deleted the test/arrivals-repository-cas-1909 branch July 17, 2026 04:51
bmander added a commit that referenced this pull request Jul 17, 2026
…freshes (#1933) (#1934)

* Guard ArrivalsViewModel.refresh() against out-of-order overlapping refreshes (#1933)

refresh() applied every completed result unconditionally. The 60s poll loop
awaits its suspend refresh() sequentially so it never overlaps itself, but
manualRefresh() and loadMore() each viewModelScope.launch { refresh() } — so a
user refresh (toolbar button, or the alert-dialog dismiss in
ArrivalActionHandlers) can run concurrently with an in-flight poll and finish
out of order. The older-started completion's `loaded.value = data` would then
clobber the fresher result, surfacing a spurious isStale flag or older arrivals
until the next poll corrected it (≤60s). The map snapshot lastLoaded() was
already protected by the repository's compareAndSet (#1932); this closes the
same gap one layer up, in the ViewModel's `loaded` StateFlow.

Fix: a monotonic refreshGeneration bumped at the start of each refresh; a
completion applies its result only while it's still the latest, else it drops
out (no state write, no map-snapshot emit). All reads/writes are confined to the
ViewModel dispatcher — the only suspension in refresh() is the fetch — so a
plain Int needs no synchronization.

Test drives two overlapping refreshes (per-call gates on the fake repository)
completing out of order and asserts the older-started stale completion cannot
overwrite the fresher one; verified red without the guard.

Fixes #1933

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

* Update lastResponseTime only for the winning refresh completion

Move the lastResponseTime stamp after the latest-wins guard so a superseded
(later-completing) refresh no longer pushes the poll timer past the fresher
completion's timestamp — the poll cadence now measures its 60s interval from the
shown data's arrival. Addresses review feedback on #1934.

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

---------

Co-authored-by: Claude Opus 4.8 <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.

DefaultArrivalsRepository: stale-fallback/CAS concurrency logic has no tests against the real class

1 participant