Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import com.onesignal.logger.IObservabilityEventRecorder
import com.onesignal.logger.LoggerFactory
import com.onesignal.notifications.INotificationsManager
import com.onesignal.notifications.internal.MisconfiguredNotificationsManager
import com.onesignal.session.internal.session.operations.SessionStateModelStore
import com.onesignal.user.internal.jwt.JwtTokenStore

internal class CoreModule : IModule {
Expand Down Expand Up @@ -92,6 +93,7 @@ internal class CoreModule : IModule {

// Operations
builder.register<OperationModelStore>().provides<OperationModelStore>()
builder.register<SessionStateModelStore>().provides<SessionStateModelStore>()
builder.register<OperationRepo>()
.provides<IOperationRepo>()
.provides<IStartableService>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import com.onesignal.common.modeling.ModelStore
import com.onesignal.core.internal.operations.Operation
import com.onesignal.core.internal.preferences.IPreferencesService
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.session.internal.session.operations.CreateSessionOperation
import com.onesignal.session.internal.session.operations.UpdateSessionOperation
import com.onesignal.session.internal.session.operations.impl.SessionOperationExecutor
import com.onesignal.user.internal.operations.CreateSubscriptionOperation
import com.onesignal.user.internal.operations.DeleteAliasOperation
import com.onesignal.user.internal.operations.DeleteSubscriptionOperation
Expand Down Expand Up @@ -63,6 +66,8 @@ internal class OperationModelStore(prefs: IPreferencesService) : ModelStore<Oper
UpdateUserOperationExecutor.TRACK_SESSION_END -> TrackSessionEndOperation()
UpdateUserOperationExecutor.TRACK_PURCHASE -> TrackPurchaseOperation()
CustomEventOperationExecutor.CUSTOM_EVENT -> TrackCustomEventOperation()
SessionOperationExecutor.CREATE_SESSION -> CreateSessionOperation()
SessionOperationExecutor.UPDATE_SESSION -> UpdateSessionOperation()
else -> throw Exception("Unrecognized operation: $operationName")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import com.onesignal.core.internal.startup.IStartableService
import com.onesignal.core.internal.time.ITime
import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.session.internal.session.operations.CreateSessionOperation
import com.onesignal.session.internal.session.operations.SessionStateModelStore
import com.onesignal.user.internal.jwt.JwtRequirement
import com.onesignal.user.internal.jwt.JwtTokenStore
import com.onesignal.user.internal.operations.LoginUserOperation
Expand All @@ -33,6 +35,7 @@ import kotlin.math.min
import kotlin.random.Random
import kotlin.reflect.KClass

@Suppress("LongParameterList")
internal class OperationRepo(
executors: List<IOperationExecutor>,
private val _operationModelStore: OperationModelStore,
Expand All @@ -41,6 +44,7 @@ internal class OperationRepo(
private val _newRecordState: NewRecordsState,
private val _jwtTokenStore: JwtTokenStore,
private val _identityVerificationService: IdentityVerificationService,
sessionStateStore: SessionStateModelStore,
) : IOperationRepo, IStartableService {

internal class OperationQueueItem(
Expand Down Expand Up @@ -107,6 +111,11 @@ internal class OperationRepo(
private val inFlightLogins = mutableMapOf<String, OperationQueueItem>()
private val waiter = WaiterWithValue<LoopWaiterMessage>()
private var paused = false
private val sessionQueuePolicy =
SessionQueuePolicy(
awaitingConsent = { _configModelStore.model.let { it.consentRequired == true && it.consentGiven != true } },
sessions = sessionStateStore,
)

// Guarded by queue. Set by a force that lands while ops are out of the queue executing.
private var forcedDuringExecute = false
Expand Down Expand Up @@ -187,7 +196,7 @@ internal class OperationRepo(

operation.id = UUID.randomUUID().toString()
scope.launch {
internalEnqueue(OperationQueueItem(operation, bucket = enqueueIntoBucket), flush, true)
enqueueNew(OperationQueueItem(operation, bucket = enqueueIntoBucket), flush)
}
}

Expand All @@ -202,7 +211,7 @@ internal class OperationRepo(
operation.id = UUID.randomUUID().toString()
val waiter = WaiterWithValue<OperationWaitResult>()
scope.launch {
internalEnqueue(OperationQueueItem(operation, waiter, bucket = enqueueIntoBucket), flush, true)
enqueueNew(OperationQueueItem(operation, waiter, bucket = enqueueIntoBucket), flush)
}
val timeoutMs = _configModelStore.model.opRepoAwaitTimeout
return withTimeoutOrNull(timeoutMs) {
Expand All @@ -218,12 +227,14 @@ internal class OperationRepo(
* they cannot be authenticated and would otherwise sit in the queue forever, blocked by
* `hasValidJwtIfRequired`. LoginUserOperation is exempt because it's enqueued
* intentionally during logout and purged later by [removeOperationsWithoutExternalId]
* if needed. Outer-gated on `newCodePathsRun` so Phase 1 customers stay byte-for-byte
* if needed. Ops with `requiresJwt = false` are exempt since they can still execute, except a
* session create that waits on an anonymous user or subscription that will never be created.
* Outer-gated on `newCodePathsRun` so Phase 1 customers stay byte-for-byte
* on the legacy enqueue path.
*/
private fun shouldSuppressAnonymousOp(op: Operation): Boolean {
if (!_identityVerificationService.newCodePathsRun) return false
if (op is LoginUserOperation) return false
if (op is LoginUserOperation || !(op.requiresJwt || op.waitsForAnonymousUser)) return false
val suppress =
_configModelStore.model.useIdentityVerification == JwtRequirement.REQUIRED &&
op.externalId == null
Expand All @@ -233,6 +244,23 @@ internal class OperationRepo(
return suppress
}

private fun enqueueNew(
queueItem: OperationQueueItem,
flush: Boolean,
) {
val accepted =
synchronized(queue) {
val result = sessionQueuePolicy.onEnqueue(queue, queueItem)
dropAndWake(result.removed)
result.accept
}
if (accepted) {
internalEnqueue(queueItem, flush, true)
} else {
queueItem.wakeWaiters(OperationWaitResult(false))
}
}

/**
* Only used inside this class, adds OperationQueueItem to queue
* WARNING: Never set flush=true until budget rules are added, even for internal use!
Expand Down Expand Up @@ -353,16 +381,18 @@ internal class OperationRepo(
}

/**
* Drops queued operations whose externalId is null. Called by the IV-aware HYDRATE
* Drops queued operations whose externalId is null and that require a JWT, or that wait for an
* anonymous user, which is never created under IV. Called by the IV-aware HYDRATE
* choreography in [OperationRepoIvExtensions] when `jwt_required` becomes REQUIRED
* to evict anon ops that can no longer execute.
*/
internal fun removeOperationsWithoutExternalId() {
val removedIds: List<String> =
synchronized(queue) {
val anonymous = queue.filter { it.operation.externalId == null }
val unsendable = queue.filter { it.operation.externalId == null && (it.operation.requiresJwt || it.operation.waitsForAnonymousUser) }
queue.removeAll(unsendable)
val anonymous = unsendable + unsendable.flatMap { sessionQueuePolicy.onDropped(queue, it.operation) }
anonymous.forEach { it.wakeWaiters(OperationWaitResult(false)) }
queue.removeAll(anonymous)
// IV=ON never transfers anonymous state; clear existingOnesignalId so the
// executor takes the createUser (upsert) path. The merge-anon-into-identified
// path can't dispatch under IV — anon user creation requires a JWT-less call
Expand All @@ -382,6 +412,10 @@ internal class OperationRepo(
removedIds.forEach { _operationModelStore.remove(it) }
}

// Session ops take no JWT, but a create can't be sent until its user and subscription exist.
private val Operation.waitsForAnonymousUser: Boolean
get() = this is CreateSessionOperation && (IDManager.isLocalId(onesignalId) || IDManager.isLocalId(subscriptionId))

/**
* Post-HYDRATE maintenance: scheduled on IO so it runs *after* `loadSavedOperations`
* populates the queue (fix for an earlier race where the purge ran against an empty
Expand Down Expand Up @@ -454,6 +488,7 @@ internal class OperationRepo(
ops.forEach { it.operation.translateIds(response.idTranslations) }
synchronized(queue) {
queue.forEach { it.operation.translateIds(response.idTranslations) }
sessionQueuePolicy.recordTranslations(response.idTranslations)
}
response.idTranslations.values.forEach { _newRecordState.add(it) }
}
Expand Down Expand Up @@ -560,7 +595,14 @@ internal class OperationRepo(
}

if (response.idTranslations != null) {
delayForPostCreate(_configModelStore.model.opRepoPostCreateDelay)
val postCreateDelay = _configModelStore.model.opRepoPostCreateDelay
// A new session's only dependents are its updates, which NewRecordsState holds back,
// so only the wake is delayed.
if (ops.all { it.operation is CreateSessionOperation }) {
scope.launch { delayForPostCreate(postCreateDelay) }
} else {
delayForPostCreate(postCreateDelay)
}
}
} catch (e: Throwable) {
Logging.log(LogLevel.ERROR, "Error attempting to execute operation: $ops", e)
Expand All @@ -582,6 +624,8 @@ internal class OperationRepo(
) {
ops.forEach { _operationModelStore.remove(it.operation.id) }
ops.forEach { it.wakeWaiters(response.toWaitResult(false)) }
val orphans = synchronized(queue) { ops.flatMap { sessionQueuePolicy.onDropped(queue, it.operation) } }
if (orphans.isNotEmpty()) dropAndWake(orphans)
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package com.onesignal.core.internal.operations.impl

import com.onesignal.common.IDManager
import com.onesignal.common.modeling.SimpleModelStore
import com.onesignal.core.internal.operations.Operation
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.session.internal.session.operations.CreateSessionOperation
import com.onesignal.session.internal.session.operations.SessionStateModel
import com.onesignal.session.internal.session.operations.UpdateSessionOperation

/**
* Queue rules for sessions API operations in [OperationRepo]. Every function must be called while
* holding the queue lock. Functions return the queue items they removed; the caller drops them
* from the store and wakes their waiters.
*/
internal class SessionQueuePolicy(
private val maxQueued: Int = MAX_QUEUED_SESSION_OPERATIONS,
// Without consent HttpClient fails every request, so these must not be stored or sent.
private val awaitingConsent: () -> Boolean = { false },
private val sessions: SimpleModelStore<SessionStateModel> = SimpleModelStore({ SessionStateModel() }),
) {
// Recent local -> backend ID translations, for an operation built from a local ID just before
// its record was created but enqueued after the queue was translated.
private val recentTranslations =
object : LinkedHashMap<String, String>() {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, String>?) = size > MAX_RECENT_TRANSLATIONS
}

fun recordTranslations(idTranslations: Map<String, String>) {
recentTranslations.putAll(idTranslations)
sessions.list().forEach { it.translateIds(idTranslations) }
}

class EnqueueResult(
val accept: Boolean,
val removed: List<OperationRepo.OperationQueueItem> = emptyList(),
)

fun onEnqueue(
queue: MutableList<OperationRepo.OperationQueueItem>,
incoming: OperationRepo.OperationQueueItem,
): EnqueueResult {
val op = incoming.operation
if (!op.isSessionOperation) return EnqueueResult(accept = true)
op.translateIds(recentTranslations)

val result =
when {
awaitingConsent() -> {
Logging.debug("SessionQueuePolicy: privacy consent not given, dropping $op")
EnqueueResult(accept = false)
}
op is UpdateSessionOperation -> admitUpdate(queue, incoming, op)
else -> admit(queue, incoming)
}
// Its updates could never be sent.
if (!result.accept && op is CreateSessionOperation) updateState(op.localSessionId, queue) { it.isClosed = true }
return result
}

private fun admitUpdate(
queue: MutableList<OperationRepo.OperationQueueItem>,
incoming: OperationRepo.OperationQueueItem,
op: UpdateSessionOperation,
): EnqueueResult {
val state = stateFor(op.sessionId)
state?.serverSessionId?.let { op.translateIds(mapOf(op.sessionId to it)) }
val rejection =
when {
state?.isClosed == true || hasQueuedEnd(queue, op.sessionId) -> "is closed"
IDManager.isLocalId(op.sessionId) && state?.onesignalId == null -> "has no create"
else -> null
}
if (rejection != null) {
Logging.debug("SessionQueuePolicy: session ${op.sessionId} $rejection, dropping $op")
return EnqueueResult(accept = false)
}
state?.let { op.useCreateIds(it.onesignalId, it.subscriptionId) }
return admit(queue, incoming)
}

private fun admit(
queue: MutableList<OperationRepo.OperationQueueItem>,
incoming: OperationRepo.OperationQueueItem,
): EnqueueResult {
val op = incoming.operation
val removed = mutableListOf<OperationRepo.OperationQueueItem>()
if (op is UpdateSessionOperation) {
val unsent = queue.filter { (it.operation as? UpdateSessionOperation)?.sessionId == op.sessionId }
// Durations are cumulative, so the newest update carries everything the unsent ones would.
unsent.forEach {
val replaced = it.operation as UpdateSessionOperation
op.activeDuration = maxOf(op.activeDuration, replaced.activeDuration)
incoming.retries = maxOf(incoming.retries, it.retries)
incoming.retryAt = maxOf(incoming.retryAt, it.retryAt)
val waiters = synchronized(it) { it.waiters.toList().also { _ -> it.waiters.clear() } }
incoming.attachOrWake(waiters)
}
queue.removeAll(unsent)
removed.addAll(unsent)
}

var accept = true
while (queue.count { it.operation.isSessionOperation } + (if (accept) 1 else 0) > maxQueued) {
val oldestSession = queue.first { it.operation.isSessionOperation }.operation.sessionKey
val dropped = queue.filter { it.operation.sessionKey == oldestSession }
Logging.warn("SessionQueuePolicy: more than $maxQueued sessions API operations queued, dropping session $oldestSession")
queue.removeAll(dropped)
removed.addAll(dropped)
if (op.sessionKey == oldestSession) accept = false
}
Comment thread
fadi-george marked this conversation as resolved.
if (accept && op is CreateSessionOperation) {
updateState(op.localSessionId, queue) {
it.onesignalId = op.onesignalId
it.subscriptionId = op.subscriptionId
}
}
if (accept && op is UpdateSessionOperation && op.isEnd) updateState(op.sessionId, queue) { it.isClosed = true }
return EnqueueResult(accept, removed)
}

/** Removes and rejects updates that can never send because [dropped] was their session's create. */
fun onDropped(
queue: MutableList<OperationRepo.OperationQueueItem>,
dropped: Operation,
): List<OperationRepo.OperationQueueItem> {
if (dropped !is CreateSessionOperation) return emptyList()
updateState(dropped.localSessionId, queue) { it.isClosed = true }
val orphans = queue.filter { (it.operation as? UpdateSessionOperation)?.sessionId == dropped.localSessionId }
if (orphans.isNotEmpty()) {
Logging.debug("SessionQueuePolicy: create for session ${dropped.localSessionId} was dropped, dropping ${orphans.size} updates")
queue.removeAll(orphans)
}
return orphans
}

private fun stateFor(sessionId: String) = sessions.list().firstOrNull { it.id == sessionId || it.serverSessionId == sessionId }

private fun updateState(
sessionKey: String,
queue: List<OperationRepo.OperationQueueItem>,
change: (SessionStateModel) -> Unit,
) {
val existing = stateFor(sessionKey)
if (existing != null) {
change(existing)
} else {
sessions.add(SessionStateModel().also { it.id = sessionKey }.also(change))
}
trimSessions(queue, sessionKey)
}

/** Drops the oldest sessions past the cap, but never one with operations still queued, which need its state. */
private fun trimSessions(
queue: List<OperationRepo.OperationQueueItem>,
keep: String,
) {
val all = sessions.list()
val excess = all.size - maxQueued
if (excess <= 0) return
val queuedSessions = queue.mapNotNull { it.operation.sessionKey }.toSet() + keep
all
.filter { it.id !in queuedSessions && it.serverSessionId !in queuedSessions }
.take(excess)
.forEach { sessions.remove(it.id) }
}

private fun hasQueuedEnd(
queue: List<OperationRepo.OperationQueueItem>,
sessionId: String,
) = queue.any { (it.operation as? UpdateSessionOperation)?.let { update -> update.sessionId == sessionId && update.isEnd } == true }

private val Operation.isSessionOperation get() = this is CreateSessionOperation || this is UpdateSessionOperation

private val Operation.sessionKey: String?
get() =
when (this) {
is CreateSessionOperation -> localSessionId
is UpdateSessionOperation -> sessionId
else -> null
}

companion object {
const val MAX_QUEUED_SESSION_OPERATIONS = 100
private const val MAX_RECENT_TRANSLATIONS = 50
}
}
Loading
Loading