Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1f6932c
feat: [SDK-5581] gate dispatcher bootstrap behind sdk_dispatcher_boot…
Oct 20, 2026
bf8d7d3
fix: [SDK-5581] latch dispatcher bootstrap off the pools lock
Oct 20, 2026
bdd9206
fix: [SDK-5581] keep a failed lazy prewarm retryable
Oct 20, 2026
8da43f6
Merge branch 'ar/startup-flags' into ar/sdk-5581
Oct 21, 2026
f6f8370
fix: [SDK-5581] pick dispatcher pools from the startup flags snapshot
Oct 21, 2026
5cddc7d
Merge branch 'ar/startup-flags' into ar/sdk-5581
Oct 21, 2026
539f555
refactor: [SDK-5581] map KMP startup flag sources in the dispatcher c…
Oct 21, 2026
53baf05
Merge branch 'ar/startup-flags' into ar/sdk-5581
Oct 21, 2026
03cbceb
fix: [SDK-5581] address review findings on dispatcher pools
Oct 21, 2026
5775d3d
Merge branch 'ar/startup-flags' into ar/sdk-5581
Oct 21, 2026
4383f91
Merge branch 'ar/startup-flags' into ar/sdk-5581
Oct 21, 2026
cb95776
fix: [SDK-5581] report degraded dispatcher pool choices to remote tel…
Oct 21, 2026
1cb8f84
fix: [SDK-5581] run the bootstrap and lazy prewarm threads at normal …
Oct 21, 2026
b9f7c86
Merge branch 'ar/startup-flags' into ar/sdk-5581
Oct 21, 2026
9ee13e7
Merge branch 'ar/startup-flags' into ar/sdk-5581
Oct 21, 2026
c9f4e66
Merge branch 'ar/startup-flags' into ar/sdk-5581
Oct 22, 2026
a7d0af8
fix: [SDK-5581] dispatcher logcat lines follow the SDK log level
Oct 22, 2026
35e6b0c
Merge ar/startup-flags: pools latch from AppContext, prewarm(context)
Oct 22, 2026
2f03bd2
Merge branch 'ar/startup-flags' into ar/sdk-5581
Oct 22, 2026
fe13b18
fix: [SDK-5581] hand off the degraded pool choice report atomically
Oct 22, 2026
7314e14
fix: [SDK-5581] dispatcher specs restore clean pools after each test
Oct 22, 2026
c015fb4
fix: [SDK-5581] cover reset landing mid-build in lazy prewarm
Oct 22, 2026
7b51785
fix: [SDK-5581] report Fallback for lanes running on Dispatchers
Oct 22, 2026
767f93a
Merge branch 'ar/startup-flags' into ar/sdk-5581
Oct 22, 2026
5028a56
Merge branch 'ar/startup-flags' into ar/sdk-5581
Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.onesignal.common.threading

import com.onesignal.core.internal.startup.IStartableService

/** Sends a degraded pool choice to remote telemetry, which init installs before services start. */
internal class DispatcherChoiceReporter : IStartableService {
override fun start() = OneSignalDispatchers.reportChoiceToTelemetry()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.onesignal.common.threading

import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import java.util.concurrent.ThreadPoolExecutor

internal interface DispatcherLane {
val dispatcher: CoroutineDispatcher

fun executor(): ThreadPoolExecutor?

fun status(): String
}

internal interface DispatcherPools {
val io: DispatcherLane
val defaultLane: DispatcherLane
val serialIO: DispatcherLane
val ingress: DispatcherLane
val ioScope: CoroutineScope
val defaultScope: CoroutineScope
val serialIOScope: CoroutineScope
val ingressScope: CoroutineScope

fun prewarm()

fun shutdown()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
package com.onesignal.common.threading

import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.ThreadFactory
import java.util.concurrent.ThreadPoolExecutor
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference

/**
* Pre-bootstrap pools. The first dispatch constructs the executor on the caller thread.
* [prewarm] tries to do that on a daemon thread, and a faster dispatch can still win.
*/
@Suppress("TooGenericExceptionCaught")
internal class LazyDispatcherPools : DispatcherPools {
private val closed = AtomicBoolean(false)
private val ioLane =
LazyExecutorLane("IO", IO_CORE, IO_MAX, QUEUE_CAPACITY, "$BASE-IO", Thread.NORM_PRIORITY - 1) {
Dispatchers.IO
}
private val defaultExecutorLane =
LazyExecutorLane("DEFAULT", DEFAULT_CORE, DEFAULT_MAX, QUEUE_CAPACITY, "$BASE-Default", Thread.NORM_PRIORITY) {
Dispatchers.Default
}
private val serialLane =
LazyExecutorLane("SERIAL_IO", 1, 1, Int.MAX_VALUE, "$BASE-SerialIO", Thread.NORM_PRIORITY - 1) {
@Suppress("OPT_IN_USAGE")
Dispatchers.IO.limitedParallelism(1)
}
private val ingressLane =
LazyExecutorLane("INGRESS", 1, 1, Int.MAX_VALUE, "$BASE-Ingress", Thread.NORM_PRIORITY - 1) {
@Suppress("OPT_IN_USAGE")
Dispatchers.IO.limitedParallelism(1)
}

override val io: DispatcherLane = ioLane
override val defaultLane: DispatcherLane = defaultExecutorLane
override val serialIO: DispatcherLane = serialLane
override val ingress: DispatcherLane = ingressLane

private val ioScopeLazy = lazy { CoroutineScope(SupervisorJob() + ioLane.dispatcher) }
private val defaultScopeLazy = lazy { CoroutineScope(SupervisorJob() + defaultExecutorLane.dispatcher) }
private val serialScopeLazy = lazy { CoroutineScope(SupervisorJob() + serialLane.dispatcher) }
private val ingressScopeLazy = lazy { CoroutineScope(SupervisorJob() + ingressLane.dispatcher) }

override val ioScope: CoroutineScope get() = ioScopeLazy.value
override val defaultScope: CoroutineScope get() = defaultScopeLazy.value
override val serialIOScope: CoroutineScope get() = serialScopeLazy.value
override val ingressScope: CoroutineScope get() = ingressScopeLazy.value

private val prewarmThread = AtomicReference<Thread?>(null)

override fun prewarm() {
if (closed.get()) return
val thread =
Thread(
{
// Ingress first: receivers have a ~10s broadcast budget.
warm("INGRESS") { ingressScope }
warm("IO") { ioScope }
warm("DEFAULT") { defaultScope }
warm("SERIAL_IO") { serialIOScope }
},
"$BASE-prewarm",
)
thread.isDaemon = true
// Not lowered: a launch on main blocks behind this thread's lazy builds, so it must not be starved.
thread.priority = Thread.NORM_PRIORITY
if (prewarmThread.compareAndSet(null, thread)) startPrewarm(thread)
}

private fun startPrewarm(thread: Thread) {
if (closed.get()) {
prewarmThread.compareAndSet(thread, null)
return
}
try {
OneSignalDispatchers.beforeLazyPrewarmStartForTest?.invoke()
thread.start()
} catch (t: Throwable) {
// start() failed after the ref was published. Clear it or every retry hits a failed CAS.
prewarmThread.compareAndSet(thread, null)
throw t
}
}

override fun shutdown() {
closed.set(true)
prewarmThread.getAndSet(null)?.let { thread ->
thread.interrupt()
runCatching { thread.join(PREWARM_JOIN_MS) }
}
listOf(ioScopeLazy, defaultScopeLazy, serialScopeLazy, ingressScopeLazy).forEach { scope ->
if (scope.isInitialized()) runCatching { scope.value.cancel() }
}
listOf(ioLane, defaultExecutorLane, serialLane, ingressLane).forEach { it.shutdown() }
}

private fun warm(
lane: String,
resolve: () -> CoroutineScope,
) {
OneSignalDispatchers.beforeLazyWarmForTest?.invoke(lane)
if (closed.get()) return
val scope = resolveScope(resolve) ?: return
if (closed.get()) {
runCatching { scope.cancel() }
} else {
runCatching { scope.launch { } }.onFailure(::logWarmFailure)
}
}

private fun resolveScope(resolve: () -> CoroutineScope): CoroutineScope? =
try {
resolve()
} catch (t: Throwable) {
logWarmFailure(t)
null
}

private fun logWarmFailure(t: Throwable) {
if (closed.get()) return
OneSignalDispatchers.logFailure("lazy prewarm failed", t)
}

private inner class LazyExecutorLane(
private val lane: String,
private val core: Int,
private val max: Int,
private val queueCapacity: Int,
private val threadName: String,
private val priority: Int,
private val fallback: () -> CoroutineDispatcher,
) : DispatcherLane {
private val executorLazy: Lazy<ThreadPoolExecutor?> = lazy { createExecutor() }

private fun createExecutor(): ThreadPoolExecutor? {
if (closed.get()) return null
return try {
buildExecutor()
} catch (t: Throwable) {
OneSignalDispatchers.logFailure("lazy $lane fell back", t)
OneSignalDispatchers.beforeFallbackCreateForTest?.invoke(lane)
null
}
}

private fun buildExecutor(): ThreadPoolExecutor? {
OneSignalDispatchers.beforeLaneCreateForTest?.invoke(lane)
if (closed.get()) return null
val created = newExecutor()
OneSignalDispatchers.afterExecutorCreateForTest?.invoke(lane, created)
if (closed.get()) created.shutdownNow()
return created.takeUnless { it.isShutdown }
}

override val dispatcher: CoroutineDispatcher by lazy {
val executor = executorLazy.value
if (executor == null) {
fallback()
} else {
try {
executor.asCoroutineDispatcher()
} catch (t: Throwable) {
OneSignalDispatchers.logFailure("lazy $lane fell back", t)
fallback()
}
}
}

override fun executor(): ThreadPoolExecutor? =
if (executorLazy.isInitialized()) executorLazy.value else null

override fun status(): String =
try {
val executor = if (executorLazy.isInitialized()) executorLazy.value else null
when {
closed.get() -> "Shutdown"
executor == null && executorLazy.isInitialized() -> "Fallback"
executor == null -> "Cold"
executor.isShutdown -> "Shutdown"
else -> "Active"
}
} catch (_: Throwable) {
"Cold"
}

fun shutdown() {
if (!executorLazy.isInitialized()) return
runCatching { executorLazy.value?.shutdownNow() }
}

private fun newExecutor(): ThreadPoolExecutor =
ThreadPoolExecutor(
core,
max,
KEEP_ALIVE_SECONDS,
TimeUnit.SECONDS,
LinkedBlockingQueue(queueCapacity),
NamedThreadFactory(threadName, priority),
).apply {
allowCoreThreadTimeOut(false)
}
}

private class NamedThreadFactory(
private val namePrefix: String,
private val priority: Int,
) : ThreadFactory {
private val threadNumber = AtomicInteger(1)

override fun newThread(r: Runnable): Thread {
val thread = Thread(r, "$namePrefix-${threadNumber.getAndIncrement()}")
thread.isDaemon = true
thread.priority = priority
return thread
}
}

private companion object {
private const val BASE = OneSignalDispatchers.BASE_THREAD_NAME
private const val IO_CORE = 2
private const val IO_MAX = 3
private const val DEFAULT_CORE = 2
private const val DEFAULT_MAX = 3
private const val QUEUE_CAPACITY = 200
private const val KEEP_ALIVE_SECONDS = 30L
private const val PREWARM_JOIN_MS = 250L
}
}
Loading
Loading