Add AutoDiscoveryService to run auto discovery steps in parallel

This commit is contained in:
cketti 2023-05-31 13:04:29 +02:00
parent 2d0a11aab5
commit c314b79dba
6 changed files with 305 additions and 0 deletions

View file

@ -0,0 +1,10 @@
package app.k9mail.autodiscovery.api
import app.k9mail.core.common.mail.EmailAddress
/**
* Tries to find mail server settings for a given email address.
*/
interface AutoDiscoveryService {
suspend fun discover(email: EmailAddress): AutoDiscoveryResult?
}

View file

@ -0,0 +1,13 @@
plugins {
id(ThunderbirdPlugins.Library.jvm)
alias(libs.plugins.android.lint)
}
dependencies {
api(projects.feature.autodiscovery.autoconfig)
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.kxml2)
}

View file

@ -0,0 +1,65 @@
package app.k9mail.autodiscovery.service
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineStart.LAZY
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
/**
* Runs a list of [AutoDiscoveryRunnable]s with descending priority in parallel and returns the result with the highest
* priority.
*
* As soon as an [AutoDiscoveryRunnable] returns a non-`null` result, runnables with a lower priority are canceled.
*/
internal class PriorityParallelRunner(
private val runnables: List<AutoDiscoveryRunnable>,
private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun run(): AutoDiscoveryResult? {
return coroutineScope {
val deferredList = buildList(capacity = runnables.size) {
// Create coroutines in reverse order. So ones with lower priority are created first.
for (runnable in runnables.reversed()) {
val lowerPriorityCoroutines = toList()
val deferred = async(coroutineDispatcher, start = LAZY) {
runnable.run()?.also {
// We've got a non-null result, so cancel all coroutines with lower priority.
lowerPriorityCoroutines.cancelAll()
}
}
add(deferred)
}
}.asReversed()
for (deferred in deferredList) {
deferred.start()
}
@Suppress("SwallowedException")
deferredList
.map { deferred ->
try {
deferred.await()
} catch (e: CancellationException) {
null
}
}
.asSequence()
.filterNotNull()
.firstOrNull()
}
}
private fun List<Deferred<AutoDiscoveryResult?>>.cancelAll() {
for (deferred in this) {
deferred.cancel()
}
}
}

View file

@ -0,0 +1,40 @@
package app.k9mail.autodiscovery.service
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import app.k9mail.autodiscovery.api.AutoDiscoveryService
import app.k9mail.autodiscovery.autoconfig.AutoconfigUrlConfig
import app.k9mail.autodiscovery.autoconfig.createIspDbAutoconfigDiscovery
import app.k9mail.autodiscovery.autoconfig.createMxLookupAutoconfigDiscovery
import app.k9mail.autodiscovery.autoconfig.createProviderAutoconfigDiscovery
import app.k9mail.core.common.mail.EmailAddress
import okhttp3.OkHttpClient
/**
* Uses Thunderbird's Autoconfig mechanism to find mail server settings for a given email address.
*/
class RealAutoDiscoveryService(
private val okHttpClient: OkHttpClient,
) : AutoDiscoveryService {
override suspend fun discover(email: EmailAddress): AutoDiscoveryResult? {
val config = AutoconfigUrlConfig(
httpsOnly = false,
includeEmailAddress = false,
)
val providerDiscovery = createProviderAutoconfigDiscovery(okHttpClient, config)
val ispDbDiscovery = createIspDbAutoconfigDiscovery(okHttpClient)
val mxDiscovery = createMxLookupAutoconfigDiscovery(okHttpClient)
val runnables = listOf(
providerDiscovery,
ispDbDiscovery,
mxDiscovery,
).flatMap { discovery ->
discovery.initDiscovery(email)
}
val runner = PriorityParallelRunner(runnables)
return runner.run()
}
}

View file

@ -0,0 +1,176 @@
package app.k9mail.autodiscovery.service
import app.k9mail.autodiscovery.api.AuthenticationType.PasswordCleartext
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable
import app.k9mail.autodiscovery.api.ConnectionSecurity.StartTLS
import app.k9mail.autodiscovery.api.ConnectionSecurity.TLS
import app.k9mail.autodiscovery.api.ImapServerSettings
import app.k9mail.autodiscovery.api.SmtpServerSettings
import app.k9mail.core.common.net.toHostname
import app.k9mail.core.common.net.toPort
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNull
import assertk.assertions.isTrue
import kotlin.test.Test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
@OptIn(ExperimentalCoroutinesApi::class)
class PriorityParallelRunnerTest {
@Test
fun `first runnable returning a success result should cancel remaining runnables`() = runTest {
var runnableTwoStarted = false
var runnableThreeStarted = false
var runnableTwoCompleted = false
var runnableThreeCompleted = false
val runnableOne = AutoDiscoveryRunnable {
delay(100)
DISCOVERY_RESULT_ONE
}
val runnableTwo = AutoDiscoveryRunnable {
runnableTwoStarted = true
delay(200)
runnableTwoCompleted = true
DISCOVERY_RESULT_TWO
}
val runnableThree = AutoDiscoveryRunnable {
runnableThreeStarted = true
delay(200)
runnableThreeCompleted = false
DISCOVERY_RESULT_TWO
}
val runner = PriorityParallelRunner(
runnables = listOf(runnableOne, runnableTwo, runnableThree),
coroutineDispatcher = StandardTestDispatcher(testScheduler),
)
var result: AutoDiscoveryResult? = null
launch {
result = runner.run()
}
testScheduler.advanceTimeBy(50)
assertThat(result).isNull()
assertThat(runnableTwoStarted).isTrue()
assertThat(runnableTwoCompleted).isFalse()
assertThat(runnableThreeStarted).isTrue()
assertThat(runnableThreeCompleted).isFalse()
testScheduler.advanceUntilIdle()
assertThat(result).isEqualTo(DISCOVERY_RESULT_ONE)
assertThat(runnableTwoCompleted).isFalse()
assertThat(runnableThreeCompleted).isFalse()
}
@Test
fun `highest priority result should be used even if it takes longer to be produced`() = runTest {
var runnableTwoCompleted = false
val runnableOne = AutoDiscoveryRunnable {
delay(100)
DISCOVERY_RESULT_ONE
}
val runnableTwo = AutoDiscoveryRunnable {
runnableTwoCompleted = true
DISCOVERY_RESULT_TWO
}
val runner = PriorityParallelRunner(
runnables = listOf(runnableOne, runnableTwo),
coroutineDispatcher = StandardTestDispatcher(testScheduler),
)
var result: AutoDiscoveryResult? = null
launch {
result = runner.run()
}
testScheduler.advanceTimeBy(50)
assertThat(result).isNull()
assertThat(runnableTwoCompleted).isTrue()
testScheduler.advanceUntilIdle()
assertThat(result).isEqualTo(DISCOVERY_RESULT_ONE)
}
@Test
fun `wait for higher priority runnable to complete`() = runTest {
var runnableOneCompleted = false
var runnableTwoCompleted = false
val runnableOne = AutoDiscoveryRunnable {
delay(100)
runnableOneCompleted = true
NO_DISCOVERY_RESULT
}
val runnableTwo = AutoDiscoveryRunnable {
runnableTwoCompleted = true
DISCOVERY_RESULT_TWO
}
val runner = PriorityParallelRunner(
runnables = listOf(runnableOne, runnableTwo),
coroutineDispatcher = StandardTestDispatcher(testScheduler),
)
var result: AutoDiscoveryResult? = null
launch {
result = runner.run()
}
testScheduler.advanceTimeBy(50)
assertThat(result).isNull()
assertThat(runnableOneCompleted).isFalse()
assertThat(runnableTwoCompleted).isTrue()
testScheduler.advanceTimeBy(100)
assertThat(result).isEqualTo(DISCOVERY_RESULT_TWO)
assertThat(runnableOneCompleted).isTrue()
}
companion object {
private val NO_DISCOVERY_RESULT: AutoDiscoveryResult? = null
private val DISCOVERY_RESULT_ONE = AutoDiscoveryResult(
ImapServerSettings(
hostname = "imap.domain.example".toHostname(),
port = 993.toPort(),
connectionSecurity = TLS,
authenticationType = PasswordCleartext,
username = "user@domain.example",
),
SmtpServerSettings(
hostname = "smtp.domain.example".toHostname(),
port = 587.toPort(),
connectionSecurity = StartTLS,
authenticationType = PasswordCleartext,
username = "user@domain.example",
),
)
private val DISCOVERY_RESULT_TWO = AutoDiscoveryResult(
ImapServerSettings(
hostname = "imap.domain.example".toHostname(),
port = 143.toPort(),
connectionSecurity = StartTLS,
authenticationType = PasswordCleartext,
username = "user@domain.example",
),
SmtpServerSettings(
hostname = "smtp.domain.example".toHostname(),
port = 465.toPort(),
connectionSecurity = TLS,
authenticationType = PasswordCleartext,
username = "user@domain.example",
),
)
}
}

View file

@ -45,6 +45,7 @@ include(
":feature:autodiscovery:providersxml",
":feature:autodiscovery:srvrecords",
":feature:autodiscovery:autoconfig",
":feature:autodiscovery:service",
)
include(