Skip to content

Commit 1103e89

Browse files
authored
dataconnect(change): Add 15 second grace before disconnecting a streaming connection with the backend (#8481)
1 parent cf716e0 commit 1103e89

3 files changed

Lines changed: 152 additions & 1 deletion

File tree

firebase-dataconnect/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
- [changed] Add grpc request headers for platform name and sdk version
1111
to enable metrics collection in cloud monitoring.
1212
([#8486](https://github.com/firebase/firebase-android-sdk/pull/8486))
13+
- [changed] Wait for 15 seconds before closing realtime streaming connection
14+
with backend after last subscriber unsubscribes (instead of closing the
15+
connection immediately).
16+
([#8481](https://github.com/firebase/firebase-android-sdk/pull/8481))
1317

1418
# 17.3.2
1519

firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectBidiConnectStream.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,11 @@ internal class DataConnectBidiConnectStream(
233233
.buffer(capacity = 64) // Use a finite buffer to activate gRPC flow control, when needed
234234
.shareIn(
235235
coroutineScope,
236-
started = SharingStarted.WhileSubscribed(replayExpirationMillis = 0),
236+
started =
237+
SharingStarted.WhileSubscribed(
238+
stopTimeoutMillis = 15_000,
239+
replayExpirationMillis = 0,
240+
),
237241
replay = 0,
238242
)
239243

firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/QuerySubscriptionImplUnitTest.kt

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ import android.content.Context.CONNECTIVITY_SERVICE
1919
import android.net.ConnectivityManager
2020
import androidx.test.ext.junit.runners.AndroidJUnit4
2121
import app.cash.turbine.ReceiveTurbine
22+
import app.cash.turbine.TurbineContext
2223
import app.cash.turbine.test
2324
import app.cash.turbine.turbineScope
2425
import com.google.firebase.appcheck.interop.InteropAppCheckTokenProvider
2526
import com.google.firebase.auth.internal.InternalAuthProvider
2627
import com.google.firebase.dataconnect.DataConnectSettings
2728
import com.google.firebase.dataconnect.FirebaseDataConnect.CallerSdkType
2829
import com.google.firebase.dataconnect.QueryRef
30+
import com.google.firebase.dataconnect.QuerySubscriptionResult
2931
import com.google.firebase.dataconnect.core.DataConnectAuth.GetAuthTokenResult
3032
import com.google.firebase.dataconnect.core.DataConnectBidiConnectStream.Companion.setReconnectPendingAuthTokenForTesting
3133
import com.google.firebase.dataconnect.core.DataConnectBidiConnectStream.Companion.unsetReconnectPendingAuthTokenForTesting
@@ -91,9 +93,11 @@ import io.kotest.assertions.print.print
9193
import io.kotest.assertions.withClue
9294
import io.kotest.common.DelicateKotest
9395
import io.kotest.common.ExperimentalKotest
96+
import io.kotest.matchers.booleans.shouldBeTrue
9497
import io.kotest.matchers.collections.shouldBeIn
9598
import io.kotest.matchers.collections.shouldContainExactly
9699
import io.kotest.matchers.maps.shouldBeEmpty
100+
import io.kotest.matchers.nulls.shouldBeNull
97101
import io.kotest.matchers.result.shouldBeSuccess
98102
import io.kotest.matchers.shouldBe
99103
import io.kotest.matchers.shouldNotBe
@@ -111,6 +115,7 @@ import io.kotest.property.arbitrary.az
111115
import io.kotest.property.arbitrary.distinct
112116
import io.kotest.property.arbitrary.enum
113117
import io.kotest.property.arbitrary.int
118+
import io.kotest.property.arbitrary.long
114119
import io.kotest.property.arbitrary.map
115120
import io.kotest.property.arbitrary.next
116121
import io.kotest.property.arbitrary.of
@@ -2036,6 +2041,133 @@ class QuerySubscriptionImplUnitTest {
20362041
}
20372042
}
20382043

2044+
@Test
2045+
fun `connection is kept alive for exactly the grace period after last subscriber unsubscribes`() =
2046+
testConnectionGracePeriod(Arb.long(0L until CONNECTION_GRACE_PERIOD_MS)) { context ->
2047+
context.clientCollector1.cancelAndIgnoreRemainingEvents()
2048+
2049+
// Wait a random duration less than CONNECTION_GRACE_PERIOD_MS
2050+
delay(context.delayMillis.milliseconds)
2051+
2052+
// Verify that the connection is still open (no close event received on serverCollector).
2053+
// Based on the timing, we _may_ receive the "cancel" event, which is expected.
2054+
val event = context.serverCollector.asChannel().tryReceive().getOrNull()
2055+
if (event != null) {
2056+
val streamRequest = event.shouldBeInstanceOf<StreamRequestReceived>().streamRequest
2057+
streamRequest.hasCancel().shouldBeTrue()
2058+
context.serverCollector.asChannel().tryReceive().getOrNull().shouldBeNull()
2059+
}
2060+
2061+
// Measure the remaining time until the client closes the connection
2062+
val time1 = @OptIn(ExperimentalCoroutinesApi::class) context.testScheduler.currentTime
2063+
context.serverCollector.awaitUntilClientClosesConnection()
2064+
val time2 = @OptIn(ExperimentalCoroutinesApi::class) context.testScheduler.currentTime
2065+
2066+
(time2 - time1) shouldBe (CONNECTION_GRACE_PERIOD_MS - context.delayMillis)
2067+
}
2068+
2069+
@Test
2070+
fun `re-subscribing within grace period keeps the connection alive and reuses it`() =
2071+
testConnectionGracePeriod(Arb.long(100L until CONNECTION_GRACE_PERIOD_MS)) { context ->
2072+
context.clientCollector1.cancelAndIgnoreRemainingEvents()
2073+
2074+
// Wait a random duration less than CONNECTION_GRACE_PERIOD_MS
2075+
delay(context.delayMillis.milliseconds)
2076+
2077+
// Re-subscribe clientCollector2
2078+
val clientCollector2 =
2079+
context.subscription.flow.testIn(context.backgroundScope, name = "clientCollector2")
2080+
2081+
// Verify that the server receives a subscribe request for the connection, but the connection
2082+
// ID remains the same (proving reuse)
2083+
val subscribeRequest = context.serverCollector.awaitUntilSubscribeStreamRequest()
2084+
subscribeRequest.connectionId shouldBe context.initialConnectionId
2085+
2086+
clientCollector2.cancelAndIgnoreRemainingEvents()
2087+
// Wait for the new grace period to expire so we don't leak connection closure errors/events
2088+
context.serverCollector.awaitUntilClientClosesConnection()
2089+
}
2090+
2091+
@Test
2092+
fun `re-subscribing after grace period establishes a new connection`() =
2093+
testConnectionGracePeriod(
2094+
Arb.long(CONNECTION_GRACE_PERIOD_MS..(CONNECTION_GRACE_PERIOD_MS * 4))
2095+
) { context ->
2096+
context.clientCollector1.cancelAndIgnoreRemainingEvents()
2097+
2098+
// Wait a random duration of at least CONNECTION_GRACE_PERIOD_MS
2099+
delay(context.delayMillis.milliseconds)
2100+
2101+
// Verify that the connection is closed
2102+
context.serverCollector.awaitUntilClientClosesConnection()
2103+
2104+
// Re-subscribe clientCollector2
2105+
val clientCollector2 =
2106+
context.subscription.flow.testIn(context.backgroundScope, name = "clientCollector2")
2107+
2108+
// Verify that a new connection is established
2109+
val connection2 = context.serverCollector.awaitConnectRpcStarted()
2110+
connection2.connectionId shouldNotBe context.initialConnectionId
2111+
context.serverCollector.awaitUntilInitStreamRequest()
2112+
context.serverCollector.awaitUntilSubscribeStreamRequest()
2113+
2114+
clientCollector2.cancelAndIgnoreRemainingEvents()
2115+
// Wait for connection to close to keep clean state
2116+
context.serverCollector.awaitUntilClientClosesConnection()
2117+
}
2118+
2119+
@OptIn(ExperimentalCoroutinesApi::class)
2120+
private fun testConnectionGracePeriod(
2121+
delayMillisArb: Arb<Long>,
2122+
block: suspend TurbineContext.(TestConnectionGracePeriodContext) -> Unit
2123+
) = runTest {
2124+
val server = runningInProcessDataConnectServer()
2125+
checkAll(propTestConfig, delayMillisArb, Arb.dataConnect.operationName(), testVariablesArb()) {
2126+
delayMillis,
2127+
operationName,
2128+
variables ->
2129+
runWithDataConnect(server) { dataConnect ->
2130+
val subscription = querySubscription(dataConnect, operationName, variables)
2131+
2132+
turbineScope {
2133+
val serverCollector = server.events.testIn(backgroundScope, name = "serverCollector")
2134+
val clientCollector1 =
2135+
subscription.flow.testIn(backgroundScope, name = "clientCollector1")
2136+
2137+
// Wait for initial connection and subscribe
2138+
val connection = serverCollector.awaitConnectRpcStarted()
2139+
serverCollector.awaitUntilInitStreamRequest()
2140+
serverCollector.awaitUntilSubscribeStreamRequest()
2141+
2142+
val context =
2143+
TestConnectionGracePeriodContext(
2144+
delayMillis = delayMillis,
2145+
serverCollector = serverCollector,
2146+
clientCollector1 = clientCollector1,
2147+
initialConnectionId = connection.connectionId,
2148+
subscription = subscription,
2149+
backgroundScope = backgroundScope,
2150+
testScheduler = testScheduler,
2151+
)
2152+
2153+
block(context)
2154+
2155+
serverCollector.cancelAndIgnoreRemainingEvents()
2156+
}
2157+
}
2158+
}
2159+
}
2160+
2161+
private data class TestConnectionGracePeriodContext(
2162+
val delayMillis: Long,
2163+
val serverCollector: ReceiveTurbine<InProcessDataConnectGrpcStreamingServer.Event>,
2164+
val clientCollector1: ReceiveTurbine<QuerySubscriptionResult<TestData, TestVariables>>,
2165+
val initialConnectionId: InProcessDataConnectGrpcStreamingServer.ConnectionId,
2166+
val subscription: QuerySubscriptionImpl<TestData, TestVariables>,
2167+
val backgroundScope: kotlinx.coroutines.CoroutineScope,
2168+
val testScheduler: TestCoroutineScheduler,
2169+
)
2170+
20392171
private fun runningInProcessDataConnectServer(): InProcessDataConnectGrpcStreamingServer {
20402172
val server = InProcessDataConnectGrpcStreamingServer()
20412173
cleanups.register(server)
@@ -2273,3 +2405,14 @@ private class SequenceRandom(jitters: Sequence<Double>) : Random() {
22732405
return lock.withLock { iterator.next() + 0.5 }
22742406
}
22752407
}
2408+
2409+
/**
2410+
* The amount of time, in milliseconds, that [DataConnectBidiConnectStream] keeps the physical
2411+
* connection with the backend alive after the last subscriber unsubscribes. By keeping the
2412+
* connection alive for a short amount of time rather than closing it immediately it improves the
2413+
* latency and reduces the backend load if a new subscriber were to subscribe within this grace
2414+
* period. This rapid unsubscription and resubscription could happen, for example, between activity
2415+
* or fragment transitions in an application where the old activity/fragment unsubscribes in its
2416+
* onDestory() and the new activity/fragment subscribes in its onCreate().
2417+
*/
2418+
const val CONNECTION_GRACE_PERIOD_MS = 15_000L

0 commit comments

Comments
 (0)