Repository navigation
feat: [SDK-5378] persistent queue and retry for sessions API requests - #2777
fadi-george wants to merge 9 commits into
Conversation
📊 Diff Coverage ReportDiff Coverage Report (Changed Lines Only)Gate: aggregate coverage on changed executable lines must be ≥ 80% (JaCoCo line data for lines touched in the diff). Changed Files Coverage
Overall (aggregate gate)336/353 touched executable lines covered (95.2% — requires ≥ 80%) |
There was a problem hiding this comment.
Multi-model review (Opus 5.5, GPT 5.6 Sol, Grok 4.7)
Intent: Persist sessions API create/update as OperationRepo ops with idempotent retries, enqueue coalescing, a 100-op cap, and in-process ID translation so requests survive restarts and offline periods. Nothing enqueues yet (SDK-5382).
Act on
- (3/3)
serverSessionIdcheck-then-write is racy.createSessionreadssession.localSessionIdthen writesserverSessionIdon the op-repo thread.SessionService.handleOnFocusassigns a newsessionIdand later setsserverSessionId = nullon serial IO. If the check still sees the old session, the write can land after rollover and persist the previous backend ID on the new session. - (3/3) Coalescing only sees
queue.getNextOpshas already removed the in-flight op;FAIL_RETRYrequeues at index 0 without coalescing;loadSavedOperations(..., addToStore=false)skips it. A duration update can PATCH after an end, and a crash that persisted both ops reloads both.
Consider
- (2/3)
appliedIdTranslationsis process-local andSessionModel.onesignalId/subscriptionIdare never rewritten. A session that starts on local IDs can enqueue later updates that staycanStartExecute == falseafter a restart. - (1/3)
dropOrphanedSessionUpdatesonly drops updates already queued when a create is dropped; later updates for that local session stall until the cap evicts them. - (1/3) Cap uses
sessionItems.first(); retry requeue at index 0 can make a newer op look oldest. - (1/3) Coalesce persists the delete before the replacement is stored.
- (3/3) Absorbed
enqueueAndWaitwaiters are wokentruebefore the survivor runs (same as login dedupe).
Noted
requiresJwt = falseonly on these two op types (default is true).- Successful creates take the shared 5s
delayForPostCreatepath and can back up the whole repo. appliedIdTranslationsapplies to all new ops by design; watchLoginUserOperation.existingOnesignalIdmerge.
Dismissed
- IV purge/suppress changing non-session ops — only session ops override
requiresJwt.
Sent by Cursor Automation: PR Reviews
3c3de96 to
2a2b5f1
Compare
2a2b5f1 to
0cbdd86
Compare
74dec0a to
18aa76a
Compare
18aa76a to
75ca624
Compare
abdulraqeeb33
left a comment
There was a problem hiding this comment.
Queue policy looks right overall. Three things before this merges.
- A 2xx create without a session_id returns FAIL_RETRY with no cap (inline). FAIL_RETRY re-adds at index 0 and the backoff is queue wide, so a captive portal or a sessions service outage now stalls user and subscription ops too. Please bound session op attempts and drop after N.
- The pin translation into OperationRepo promised on #2774 is not here.
PinnedLocalIdTranslatoris still in SessionService.kt (84 to 122), and applied translations are in memory only, so an update rebuilt from pins after a restart can carry alocal-id. If it moves to the sender PR, please say so in the description. - Under required identity verification, session ops are
requiresJwt = false, so they skip the purge. A session pinned to an anonymous local onesignal_id never translates,canStartExecutestays false, and the ops sit persisted until the 100 cap evicts them. Drop them when the pinned id can no longer resolve.
|
Thanks @abdulraqeeb33, all three addressed in 6f3c6f0.
|
abdulraqeeb33
left a comment
There was a problem hiding this comment.
The attempt cap holds for creates, and the identity-verification drop is right for LoginUserOperation. Two holes.
- Coalescing an update keeps activeDuration and drops failedAttempts, so a session that keeps enqueueing updates never reaches 5. A 5xx update then stalls the queue again.
- translateIds writes subscriptionId whenever that local id is in the map. Login's create-user response includes it, so a session still pinned to the previous user takes the new user's subscription. The old appliesTo check stopped this.
Smaller: the pin write at OperationRepo.kt:435 does not wait, and SUCCESS removes the create right after, so a kill in that gap loses serverSessionId. Join it, as the old code did. translateIds also reads localSessionId before the first session, and sessionId is unset until focus, so that throws and the error is only logged. Guard it. LoginUserFromSubscriptionOperation is not treated as a login that will create the user.
52f25eb to
a6c4159
Compare
…tion queue Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…return Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ard updates after in-flight end or for orphaned sessions Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… order Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…he session executor Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ssion pins in OperationRepo, drop unsendable ops under IV - Session ops drop after 5 failed attempts the backend answered. The count is persisted, and network errors don't count, so offline still retries until reconnect. - OperationRepo writes each ID translation it applies into the current session's pinned IDs and server session ID, replacing SessionService's PinnedLocalIdTranslator. - Under required identity verification, session ops for a local OneSignal ID that no identified login will create are discarded at enqueue and dropped with the anonymous purge. - CreateSessionOperation and UpdateSessionOperation share a SessionOperation base. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cope session subscription translation, join pin write - Coalesced updates keep the highest failedAttempts, so a session that keeps enqueueing updates still reaches the attempt limit. - The session's pinned subscription ID is only translated when the same response translates its pinned OneSignal ID, since login reuses a local push subscription for the new user. - The pin write is joined again so IDs are saved before a successful create is removed from the store, and it is skipped before the first session starts. - LoginUserFromSubscriptionOperation counts as a login that will create the user under identity verification. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… race on queue order Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
a6c4159 to
a4da351
Compare
abdulraqeeb33
left a comment
There was a problem hiding this comment.
The attempt cap, the joined pin write, and the guard before the first session all look right.
One blocker: translateIds returns before writing subscriptionId unless the same map also translates the pinned onesignalId. A push subscription create returns only the subscription id, and that executor does update pushSubscriptionId on the config model. A session that already has a backend OneSignal id keeps the local subscription pin, and that pin is persisted. After a restart, ops built from it never pass canStartExecute. Translate subscriptionId when it is in the map, and skip that write only when the map translates a different user's OneSignal id.
Not blocking: under identity verification LoginUserFromSubscriptionOperationExecutor returns FAIL_NORETRY and never creates the user, but isUnsendable still treats it as the creator when externalId is set. The session op then stays queued with a local OneSignal id. Nothing enqueues session ops yet, and the v4 migration usually has a null external id. Remove that branch when you touch this.
| if (!hasProperty(::sessionId.name)) return | ||
|
|
||
| map[localSessionId]?.let { serverSessionId = it } | ||
| onesignalId = onesignalId?.let { map[it] } ?: return |
There was a problem hiding this comment.
This returns for any map that does not translate the pinned onesignalId, including a create-subscription response whose only entry is the subscription id. That pin stays local and is persisted. Skip the subscription write only when the map translates a different user's OneSignal id.
|
Closing this one. #2790 is merged and covers the retry side (per-op backoff, Retry-After, foreground and network wake), so this PR's retryNow and OperationRetryTrigger are now redundant. I'll open a fresh PR from main with just the session parts. |


Description
One Line Summary
Queue sessions API requests as persisted
CreateSessionOperation/UpdateSessionOperations inOperationRepo, with idempotent retries, coalescing, and a queue cap.Details
Motivation
Session requests need to survive process death, restarts, and offline periods, and be retried safely without double counting. Stacked on #2775 (sessions API client).
Scope
CreateSessionOperation/UpdateSessionOperation, persisted throughOperationModelStore. Each stores an idempotency key generated once and reused on every retry, including after a restart.SessionOperationExecutorcatchesBackendExceptionand classifies withNetworkUtils.getResponseStatusType:RETRYABLEmaps toFAIL_RETRY(passesRetry-Afterto the existingdelayBeforeNextExecution), except 4xx other than408/429(e.g.413,422), which won't succeed on retry. Everything else isFAIL_NORETRY. A create success without a session ID retries.OperationReporetries at the head of the queue, so a session op is dropped after 5 failed attempts the backend answered (5xx, 408, 429, or a 2xx without a session ID). The count is persisted on the op and kept when updates are coalesced. Network errors (no response) don't count, so offline requests keep retrying until reconnect.onesignal_id/subscription_id. Updates reference the session bySessionModel.localSessionIdand wait until the create's ID translation gives them the backend session ID. - Session pinned IDs:OperationRepowrites each ID translation it applies into the currentSessionModel(onesignalId,subscriptionId, andserverSessionIdkeyed bylocalSessionId), on SerialIO so it can't interleave with a new session starting, and joined so the IDs are saved before a successful create leaves the store. The subscription ID is only translated when the same response translates the session's OneSignal ID, since login reuses a local push subscription for the new user. This replacesPinnedLocalIdTranslatorinSessionServicefrom feat: [SDK-5369] session model and sessions v2 feature flag #2774, so the session model and its queued ops always agree on the owner, including after a restart.OperationRepochanges:requiresJwt = false(the session ops) are no longer suppressed or purged as anonymous under identity verification. Instead, under required identity verification, session ops for a local OneSignal ID that no identified login will create are discarded at enqueue and dropped with the anonymous purge, since they could never be sent.IOperationRepo.retryNow()ends the failure backoff early but still waits out a backendRetry-After.forceExecuteOperations()behaves as before.OperationRetryTriggercallsretryNow()on app foreground and when a network becomes available while the app is foregrounded (registerNetworkCallback, API 21). The foreground check avoids attempts that Android 15+ blocks for cached background apps, which would only lengthen the backoff. It only acts while the repo is waiting to retry, so it never shortens a later backoff. This applies to all operations, not just session ones.SessionService(SDK-5382), so nothing is enqueued yet and the flag-off path is unchanged.Testing
Unit testing
SessionOperationQueueTests: coalescing, end replaces update, update after end discarded, per-session isolation, cap plus orphan drop, create-before-update ordering with translation, late-enqueued update translated, failed create drops updates, retry keeps idempotency key, not purged under IV, anonymous-user session ops discarded at enqueue and dropped on purge under IV, translations written to session pins, earlier session's create doesn't set the current server session ID.OperationRepoTests:retryNowends the backoff early, still waits outRetry-After, and is a no-op when not waiting.OperationRetryTriggerTests: retries on network available only while foregrounded and on foreground, and still starts if callback registration throws.SessionOperationExecutorTests: request mapping (ms to s), translation, retry/Retry-Afterfor network errors,408,429,5xx, drop for other 4xx, retry on missing session ID, drop after the attempt cap, network errors don't count toward it,canStartExecuteand translation, persistence round-trip keeps keys.:onesignal:core:testReleaseUnitTest,spotlessCheck, andassembleReleasepass.detektreports only the 3 findings already present in files this PR doesn't touch.Manual testing
Not done. Nothing enqueues these ops until SDK-5382.
Affected code checklist
Checklist
Overview
Testing
Final pass
🤖 Generated with Claude Code