Skip to content

Conversation

dzinad
Copy link
Contributor

@dzinad dzinad commented Nov 15, 2022

Description

The main idea is that we have 2 types of refresh now: planned (once in refreshInterval) and on-demand (the user triggers the refresh immediately). The planned refresh was present before.
RouteRefreshController coordinates these 2 types of refresh. Don't look at the diff, see it as a new file. It changed tremendously.
PlannedRefreshController is responsible for planned refresh.
ImmediateRefreshController is responsible for on-demand refresh.
RouteRefresher executes one refresh request.
RouteRefresherExecutor makes sure only one refresh request is executed at a time and if the queue is too big, only the current request and the last one from the queue are executed, the intermediate ones are ignored.
CancellableHandler is responsible for posting tasks after a timeout and cancelling them on-demand, also notifying the tasks that they were cancelled.
RouteRefreshStateHolder is responsible for receiving and keeping route refresh state updates and notifying observers registered via MapboxNavigation#registerRouteRefreshStateObserver.

The previous flow (with only planned refresh):

planned_route_refresh

The new flow (on-demand + planned):

new_route_refresh

@dzinad dzinad requested a review from a team as a code owner November 15, 2022 15:27
@codecov
Copy link

codecov bot commented Nov 15, 2022

Codecov Report

Merging #6610 (059119a) into main (e0c5710) will increase coverage by 0.26%.
The diff coverage is 98.35%.

❗ Current head 059119a differs from pull request most recent head e043aec. Consider uploading reports for the commit e043aec to get more accurate results

Impacted file tree graph

@@             Coverage Diff              @@
##               main    #6610      +/-   ##
============================================
+ Coverage     72.68%   72.95%   +0.26%     
- Complexity     5576     5633      +57     
============================================
  Files           783      793      +10     
  Lines         30186    30399     +213     
  Branches       3571     3581      +10     
============================================
+ Hits          21941    22177     +236     
+ Misses         6819     6798      -21     
+ Partials       1426     1424       -2     
Impacted Files Coverage Δ
...pbox/navigation/core/RoutesProgressDataProvider.kt 100.00% <ø> (ø)
...box/navigation/core/routerefresh/RouteRefresher.kt 93.68% <93.68%> (ø)
...avigation/core/routerefresh/ExpiringDataRemover.kt 97.36% <97.36%> (ø)
...ava/com/mapbox/navigation/core/MapboxNavigation.kt 70.58% <100.00%> (-0.36%) ⬇️
...x/navigation/core/internal/utils/CoroutineUtils.kt 100.00% <100.00%> (ø)
...re/routerefresh/ImmediateRouteRefreshController.kt 100.00% <100.00%> (ø)
...core/routerefresh/PlannedRouteRefreshController.kt 100.00% <100.00%> (ø)
...ation/core/routerefresh/RefreshObserversManager.kt 100.00% <100.00%> (ø)
...ion/core/routerefresh/RetryRouteRefreshStrategy.kt 100.00% <100.00%> (ø)
...gation/core/routerefresh/RouteRefreshController.kt 100.00% <100.00%> (+9.09%) ⬆️
... and 10 more

@dzinad dzinad force-pushed the NAVAND-777-dd branch 3 times, most recently from b855099 to da27db1 Compare November 23, 2022 08:28
val requestedRoutes = requestRoutes(enableRefresh = false)
mapboxNavigation.setNavigationRoutes(requestedRoutes)

delay(7000) // refresh interval + accuracy

Choose a reason for hiding this comment

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

Same as above, these can really add up to the execution time. Wouldn't we be able to cover these granular cases with unit tests instead? Why are we preferring an instrumentation tests here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think it can be tested properly in unit tests. They test many parts of the SDK along with the server interaction. While writing these tests I've discovered a couple of bugs and inconsistencies. What's more, they give you a feel of how the SDK works which might not have been so obvious before writing/reading them. I find them very useful and would like to keep them.
What exactly is the problem with additional 2 mins? Aren't the tests paralleled somehow?

Choose a reason for hiding this comment

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

This is 2 minutes for a single test class. As we keep expanding at some point these types of granular addition will just unbearably extend the CI execution time. We're not at the critical point for the instrumentation tests job yet but we should be mindful of that as we go. If we can trim the execution down (for example, 7 seconds of a delay really shouldn't be necessary), let's do that.

Aren't the tests paralleled somehow?

No, we're not doing test sharding yet but that idea has been floated in the past. Something to consider cc @SevaZhukov @dchernousov.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I just mean that we shouldn't be held back from writing tests because they require some time to execute... In order not to be flaky at the same time. I don't know how to reduce the delay here:

  1. I want to check states triggered by refresh on demand;
  2. in order to do that I have to trigger refresh in-between planned attempts;
  3. I have to wait for the first planned attempt;

If I make the interval 2-3 seconds instead of 5, code execution time will play a much bigger role in this test being stable.
In general, it's a common problem for tests that check that something is done with correct interval. And I think we should have such tests. For example, it would check that we don't do by mistake any unnecessary requests and so on.

Choose a reason for hiding this comment

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

I just mean that we shouldn't be held back from writing tests because they require some time to execute...

Of course not, test coverage always prevails over test execution time. I just want us to be mindful, that's all. If you think that we can't optimize execution time any further without impacting stability, then let's leave this as is.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I redid some of the tests into Robolectric tests.

finishRequest(result)
startRequest()

verify(exactly = 0) { stateHolder.onStarted() }

Choose a reason for hiding this comment

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

I understand the retry shouldn't call start again but here it looks like we didn't call start at all.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's because I did finishRequest without startRequest. First startRequest is not required for the unit test.

@dzinad dzinad requested a review from LukasPaczos November 24, 2022 12:26
@dzinad dzinad force-pushed the NAVAND-777-dd branch 3 times, most recently from 3b62d7b to b606eb6 Compare November 25, 2022 14:15
@dzinad dzinad marked this pull request as draft November 25, 2022 14:24
@dzinad dzinad force-pushed the NAVAND-777-dd branch 2 times, most recently from 3c0a44a to a70bb9b Compare November 25, 2022 15:24
@dzinad dzinad marked this pull request as ready for review November 25, 2022 15:24
@VysotskiVadim VysotskiVadim self-requested a review November 30, 2022 16:15
Copy link
Contributor

@VysotskiVadim VysotskiVadim left a comment

Choose a reason for hiding this comment

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

I'm very biased about route refresh system as I'm the one who migrated it to coroutines. I have a suggestion of how to achieve the same result writing less code. See the diff. @dzinad , do you see any refresh cases which the diff doesn't cover?

I must admit that new route refresh system may be easier and more comfortable for not biased developers(not the ones who implemented new or current route refresh system).
@LukasPaczos, @RingerJK, which way do you think should we go, modifying existing coroutine based system or switching to the new presented in this PR?

Index: libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshControllerTest.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshControllerTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshControllerTest.kt
--- a/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshControllerTest.kt	(revision 592818bbba6bf65accf7a4082311e94ebf938efe)
+++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshControllerTest.kt	(date 1669824835092)
@@ -129,6 +129,27 @@
             }
         }
 
+    @Test
+    fun `route refreshes immediately`() =
+        runBlockingTest {
+            val (initialRoute, refreshedRoute) = createTestInitialAndRefreshedTestRoutes()
+            val routeRefreshStub = RouteRefreshStub().apply {
+                setRefreshedRoute(refreshedRoute)
+            }
+            val routeRefreshController = createRouteRefreshController(
+                routeRefresh = routeRefreshStub,
+                routeRefreshOptions = RouteRefreshOptions.Builder()
+                    .intervalMillis(30_000)
+                    .build(),
+            )
+
+            val refreshJob = async { routeRefreshController.refresh(listOf(initialRoute)) }
+            assertTrue(refreshJob.isActive)
+            routeRefreshController.refreshImmediately()
+            assertEquals(listOf(refreshedRoute), refreshJob.getCompletedTest().routes)
+            refreshJob.cancel()
+        }
+
     @Test
     fun `should refresh route with any annotation`() = runBlockingTest {
         val routeWithoutAnnotations = createNavigationRoute(
Index: libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshController.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshController.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshController.kt
--- a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshController.kt	(revision 592818bbba6bf65accf7a4082311e94ebf938efe)
+++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshController.kt	(date 1669825162219)
@@ -15,14 +15,21 @@
 import com.mapbox.navigation.core.RouteProgressDataProvider
 import com.mapbox.navigation.core.directions.session.RouteRefresh
 import com.mapbox.navigation.core.ev.EVRefreshDataProvider
+import com.mapbox.navigation.utils.internal.logD
 import com.mapbox.navigation.utils.internal.logE
 import com.mapbox.navigation.utils.internal.logI
+import com.mapbox.navigation.utils.internal.logW
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Deferred
 import kotlinx.coroutines.async
 import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.Channel.Factory.RENDEZVOUS
 import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.delay
+import kotlinx.coroutines.selects.select
 import kotlinx.coroutines.suspendCancellableCoroutine
 import kotlinx.coroutines.withTimeoutOrNull
 import java.util.Date
@@ -43,6 +50,8 @@
     private val localDateProvider: () -> Date,
 ) {
 
+    private val immediateRefreshRequestsChannel = Channel<Unit>(RENDEZVOUS)
+
     private var state: RouteRefreshStateResult? = null
         set(value) {
             if (field == value) return
@@ -92,6 +101,12 @@
         }
     }
 
+    fun refreshImmediately() {
+        if (immediateRefreshRequestsChannel.trySend(Unit).isFailure) {
+            logW { "immediate refresh is ignored as route refresh is in progress already" }
+        }
+    }
+
     fun registerRouteRefreshStateObserver(observer: RouteRefreshStatesObserver) {
         observers.add(observer)
         state?.let { observer.onNewState(it) }
@@ -150,7 +165,8 @@
         var timeUntilNextAttempt = async { delay(routeRefreshOptions.intervalMillis) }
         try {
             repeat(FAILED_ATTEMPTS_TO_INVALIDATE_EXPIRING_DATA) {
-                timeUntilNextAttempt.await()
+                waitForIntervalOrImmediateRefreshSignal(timeUntilNextAttempt)
+
                 if (it == 0) {
                     onNewState(RouteRefreshExtra.REFRESH_STATE_STARTED)
                 }
@@ -180,6 +196,25 @@
         )
     }
 
+    // inspired by https://github.com/Kotlin/kotlinx.coroutines/issues/2867#issuecomment-998553524
+    private suspend fun waitForIntervalOrImmediateRefreshSignal(
+        timeUntilNextAttempt: Deferred<Unit>
+    ) {
+        coroutineScope {
+            select<Unit> {
+                timeUntilNextAttempt.onAwait {
+                    logD { "starting refresh by interval" }
+                }
+                immediateRefreshRequestsChannel.onReceive {
+                    logD { "starting immediate refresh" }
+                }
+            }.also {
+                timeUntilNextAttempt.cancel()
+                coroutineContext.cancelChildren()
+            }
+        }
+    }
+
     private fun removeExpiringDataFromRoute(
         route: NavigationRoute,
         currentLegIndex: Int,
@@ -344,4 +379,4 @@
         data class Success(val route: NavigationRoute) : RouteRefreshResult()
         data class Fail(val error: NavigationRouterRefreshError) : RouteRefreshResult()
     }
-}
+}
\ No newline at end of file

@dzinad
Copy link
Contributor Author

dzinad commented Dec 7, 2022

Just rebasing, don't pay attention ^

@dzinad
Copy link
Contributor Author

dzinad commented Dec 7, 2022

@VysotskiVadim
I've run my tests with your code and the following ones fail:
route_refresh_on_demand_executes_before_refresh_interval,
routeRefreshOnDemandBetweenPlannedAttemptsTest,
routeRefreshOnDemandFailsThenPlannedTest,
routeRefreshOnDemandForInvalidRoutes,
successfulRouteRefreshOnDemandTest,
failedRouteRefreshOnDemandTest,
routeRefreshOnDemandFailsBetweenPlannedAttemptsTest

In general, Im not against using coroutines here. But I found RouteRefreshContoller class too heavy and definitely didn't want to make it grow even more. And still strongly don't want to. I tried to break it up into several classes making them follow if not single-responsibility, then at least smaller-number-reponsibility pattern. And with that approach I found it easier to use callbacks. If you have any idea how to make that code more synchronous using coroutines, I'm all in. But I would be against returning to 1 class RouteRefreshController that handles everything refresh-related.

@VysotskiVadim
Copy link
Contributor

@dzinad ,
I don't see any violations of SRP in old RouteRefreshController 🙂
As for failed tests I believe the old coroutines based system can be adopted for those requirements. I'm not sure if all the requirements verified in tests are strict, maybe some of them debatable. I'm also not sure if it makes sense to put some effort in old system to adopt just to prove my point before we decide the way we want to go.

I'm not agains your implementation. But I'm a biased author of the old coroutines-based implementation and for me it seems so much simpler and understandable. Code is always more understandable for the authors as the code represents the way authors see their features. You see route refresh a different way, and I'm sure you find your implementation to be much understandable then the old one. That's why it's very interesting for me which way @LukasPaczos and @RingerJK find the more simple and understandable. I think their opinion won't be as biased as mine.

stateHolder.onSuccess()
} else {
stateHolder.onFailure(null)
plannedRefreshController.resume()
Copy link
Contributor

Choose a reason for hiding this comment

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

why it's not resume(d)() on success result?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Because success result means new refreshed routes -> we don't have to resume refreshing the old ones. We'll get a notification with reason = REFRESH and relaunch planned refresh.

}
}

override fun pause() {
Copy link
Contributor

Choose a reason for hiding this comment

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

the API is a bit tricky: if we have public (public internally) pause-resume API, it should contain a counter of invoked pause-resume, because invoking unred the hood from different sources

pause()
pause()
resume()

actually does not work correctly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure I'm getting you.

it should contain a counter of invoked pause-resume

It's not like this but it has a boolean flag to check if the state is correct (paused for resume and resumed for pause). If you invoke pause, pause, resume, you'll get 1 pause and 1 resume (the second pause will have no effect). I think it's a valid scheme. We use the same approach for starting a trip session, for example.


fun postDelayed(timeout: Long, block: Runnable, cancellationCallback: () -> Unit) {
val job = scope.launch {
delay(timeout)
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: if delay would be 0(or negative?): doesn't it invoked in sync? It might affect invokeOnCompletion logic

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's OK. From the docs:

Registers handler that is synchronously invoked once on completion of this job. When the job is already complete, then the handler is immediately invoked with the job's exception or cancellation cause or null. Otherwise, the handler will be invoked once when this job is complete.

I'll add tests for this.

message: String? = null
) {
val oldState = this.state?.state
if (oldState != state && RouteRefreshStateChanger.canChange(from = oldState, to = state)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

@dzinad not sure that I follow this. Seems like the function might prevent the update state. Could you describe why does it need?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, it can.
It's just the generalization of what we have on main. But there we only switch to CANCELED if we are in STARTED state. Here I introduced the table for all the states.

@github-actions
Copy link

github-actions bot commented Jan 19, 2023

Changelog

Features

  • Added MapboxTripStarter to simplify the solution for managing the trip session and replaying routes. This also makes it possible to share the replay state between drop-in-ui and android-auto. #6794
  • Added RouteRefreshController interface to manage route refreshes. Retrieve it via MapboxNavigation#routeRefreshController. #6610
  • Added RouteRefreshController#requestImmediateRouteRefresh to trigger route refresh request immediately. #6610
  • Moved MapboxNavigation#registerRouteRefreshStateObserver to RouteRefreshController#registerRouteRefreshStateObserver. To migrate, change: #6610
    mapboxNavigation.registerRouteRefreshStateObserver(observer)
    to
    mapboxNavigation.routeRefreshController.registerRouteRefreshStateObserver(observer)
  • Moved MapboxNavigation#unregisterRouteRefreshStateObserver to RouteRefreshController#unregisterRouteRefreshStateObserver. To migrate, change: #6610
    mapboxNavigation.unregisterRouteRefreshStateObserver(observer)
    to
    mapboxNavigation.routeRefreshController.unregisterRouteRefreshStateObserver(observer)

Bug fixes and improvements

  • Removed NavigationRoute#hasUnexpectedClosures and added RouteProgress#hasUnexpectedUpcomingClosures instead that checks whether route has upcoming unexpected closures (the algorithm does not take into account closures that the puck has already been on) #6841
  • Fixed an issue where alternative routes might have had an incorrect or incomplete portion of the route refreshed or occasionally fail to refresh. #6848
  • Increased max distance from the user indicator to route line valid to continue vanishing updates from 3m to 10m. #6854
  • Improved NavigationView camera behavior to go back into overview state if routes change during route preview state. #6840

Known issues ⚠️

Other changes

Android Auto Changelog

Features

Bug fixes and improvements

@dzinad
Copy link
Contributor Author

dzinad commented Jan 25, 2023

@LukasPaczos I addressed your comments.
BTW, adding REFRESH_STATE_CLEARED_EXPIRED and controlling it was super easy after refactoring. So I think it did make sense to refactor at least partially.

@dzinad dzinad requested a review from LukasPaczos February 3, 2023 14:42
@@ -0,0 +1,18 @@
- Added `RouteRefreshController` interface to manage route refreshes. Retrieve it via `MapboxNavigation#routeRefreshController`.
Copy link
Contributor

Choose a reason for hiding this comment

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

Does exposing RouteRefreshController makes our SDK easier to harder to change?
I found myself in a complex situations with reroute logic a few times. It was very hard to change its structure, because we exposed it to the users in our API. Maybe exposing MapboxNavigation#refreshRoute() leaves us more flexibility for future changes? 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's just an interface that accumulates refresh-related API.

result.refreshedRoutesData
)
stateHolder.onClearedExpired()
if (result.refreshedRoutesData != newRoutesData) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@VysotskiVadim I can't move this check to ObserversManager because here I compare the original data with the data we got after removing the expired part. I don't think that the ObserversManager should know anything about that.

@dzinad dzinad requested a review from VysotskiVadim February 21, 2023 14:01
- Added `RouteRefreshController#requestImmediateRouteRefresh` to trigger route refresh request immediately.
- Moved `MapboxNavigation#registerRouteRefreshStateObserver` to `RouteRefreshController#registerRouteRefreshStateObserver`. To migrate, change:
```kotlin
mapboxNavigation.registerRouteRefreshStateObserver(observer)
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe it makes sense to leave mapboxNavigation.registerRouteRefreshStateObserver for the time being and mark as deprecated to avoid breaking the API? I know, it's experimental, but if the price of keeping it is low, why not to leave it for a few more releases to leave customers a time window to migrate.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Idk, I'd like to avoid duplicating API. What's more, we will probably forget to delete it. And when it's not experimental anymore, it won't be possible.
I know we've already removed experimental API (for Drop-In UI) with migration guide in the CHANGELOG.

Comment on lines +24 to +26
every {
localDateProvider()
} returns parseISO8601DateToLocalTimeOrNull("2022-06-30T20:00:00Z")!!
Copy link
Contributor

Choose a reason for hiding this comment

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

Is that simple API worth using external mock library? 🙂

Suggested change
every {
localDateProvider()
} returns parseISO8601DateToLocalTimeOrNull("2022-06-30T20:00:00Z")!!
val testDateProvider = { parseISO8601DateToLocalTimeOrNull("2022-06-30T20:00:00Z")!! }

Copy link
Contributor

Choose a reason for hiding this comment

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

it's probably more of the matter or preferences though

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Well, here I set the time it should return in the test (because the test holds the context of what time we should return), while the object is created earlier. Since there is only one test, I can move the object creation into the test, but I'm not sure if it will make a lot of sense. If I add another test in the future, I may want different time.

@dzinad dzinad enabled auto-merge (squash) March 1, 2023 14:30
@dzinad
Copy link
Contributor Author

dzinad commented Mar 1, 2023

@VysotskiVadim I'm gonna merge it into alpha. If you find there is something important I haven't fixed here, I can always follow-up in a separate PR.

@dzinad dzinad merged commit 2ce2d19 into main Mar 1, 2023
@dzinad dzinad deleted the NAVAND-777-dd branch March 1, 2023 15:12
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.

5 participants