Skip to content

feat: [SDK-5379] persistent session requests through the operation repo - #1768

Merged
fadi-george merged 10 commits into
new-session-eventsfrom
fadi/sdk-5379
Oct 19, 2026
Merged

fadi-george merged 10 commits into
new-session-eventsfrom
fadi/sdk-5379

Conversation

@fadi-george

@fadi-george fadi-george commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Description

One Line Summary

Send sessions API requests through the operation repo with an OSSessionOperationExecutor, persisted like the other executors, with ordering, coalescing, a size cap, and retries through the shared backoff from #1775.

Details

Motivation

Session requests need to survive restarts, process death, and offline periods, and be retried without double counting. Built on the existing executor pattern so it reads like the rest of the SDK. Now that #1775 (SDK-5481) gives every executor same-run retry, this PR uses that instead of its own retry code.

Scope

  • OSRequestCreateSession / OSRequestUpdateSession are OSUserRequests (NSCoding), persisted with saveCodeableData like the other executors' requests. They replace the OSCore sessions backend service from feat: [SDK-5375] add sessions API client #1763. The create's idempotency key is the local session ID; an update's is generated once and persisted.
  • OSSessionOperationExecutor is registered with OSOperationRepo. The User manager enqueues OS_CREATE_SESSION_DELTA / OS_UPDATE_SESSION_DELTA Deltas (enqueueSessionCreate / enqueueSessionUpdate), which do nothing unless record.usesSessionsApi.
  • Ordering: only the first queued request of a session is sent. A create waits for its user's onesignal_id and the push subscription_id. An update waits for its create's server session ID, then the post-create delay through OSNewRecordsState.
  • IDs are fixed by the create: its onesignal_id and subscription_id are set on the first send, and every update carries exactly those, never a fallback. The executor keeps one persisted state per session, the create's IDs and server session ID plus whether it ended, and updates read the IDs from it when sent.
  • Coalescing: queued updates collapse to the highest duration (an in-flight one counts), an end replaces them, and updates after an end are discarded. An update with no create and no server session ID is discarded. A replacement takes over the replaced update's backoff.
  • Cap: 100 requests. The oldest is dropped, with its updates if it was a create.
  • Retry: 0, 408, 429 and 5xx go through the shared OSRequestRetryBackoff and the User manager's OSOperationRetryTrigger from fix: [SDK-5481] retry failed operations within the same run #1775. No session-specific backoff, attempt limit, or trigger. A 404 is retried while the record it depends on was created in the last 60 seconds (OSNewRecordsState.isInMissingRetryWindow): the create's onesignal_id or subscription_id, or the update's server session ID. It may be backend replication lag. After that window a 404 drops, like other 4xx. A create answered with a 2xx but no session_id is retried 3 times, then dropped with its updates. OneSignalClient's own retries apply, as for other executors.
  • Age and restarts: session requests age out after 30 days, including the current user's. The per-session state is capped at 100, never dropping a session with requests still queued, so after a kill an update still gets its create's IDs and one after a sent end is still discarded.
  • Privacy consent: the repo already blocks enqueues and flushes without consent; the executor also skips sending.
  • A 409 always drops. This matches Android #2794.
  • Identity Verification does not apply: session requests are unsigned, and session Deltas are exempt from the repo's anonymous drop, like push subscription updates. While it is required, the anonymous purge only drops creates for anonymous users without a onesignal_id, which will never be created, along with their updates.
  • Requests recorded for another app are dropped, and the usual OSRequestAging limits apply.
  • Not in this PR: enqueueing from the session lifecycle, so nothing is enqueued yet.

Testing

Unit testing

OSSessionOperationExecutorTests covers the request bodies, create waiting for its user, update waiting for its create and the post-create delay, updates carrying the create's IDs after the push subscription changes, coalescing, in-flight durations, orphan discard, the cap, backoff through the shared OSRequestRetryBackoff, Retry-After, retryNow, a replaced update keeping its backoff, 4xx drops, 404 retries inside the new-record window and drops after it, 409 drops, the missing session_id cap, persistence across a restart with the same keys, an update built before its create succeeded surviving a kill, an update after a restart using the earlier create, an update after a sent end discarded across a restart, the 30-day age limit, other-app drops, anonymous requests under Identity Verification, no sends before privacy consent, and the flag-off enqueue. OSOperationRepoIdentityVerificationTests covers the repo exemption.

UserJwtLifecycleTests.testUpdateUserJwtSendsTheCreateUserThatARejectedTokenHeld had a race (it swapped the mock response before the 401 was delivered) that the extra executor exposed; it now waits for the 401 to be handled. Other full-suite runs passed, with occasional timing flakes in OneSignalUserTests and SwitchUserIntegrationTests.

Manual testing

Not done. Nothing enqueues requests until the session lifecycle is wired up.

Affected code checklist

  • Notifications
    • Display
    • Open
    • Push Processing
    • Confirm Deliveries
  • Outcomes
  • Sessions
  • In-App Messaging
  • REST API requests
  • Public API changes

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing
  • Any Public API changes are explained in the PR details and conform to existing APIs

Testing

  • I have included test coverage for these changes, or explained why they are not needed
  • All automated tests pass, or I explained why that is not possible
  • I have personally tested this on my device, or explained why that is not possible

Final pass

  • Code is as readable as possible.
  • I have reviewed this PR myself, ensuring it meets each checklist item

Redone on top of #1775 as one commit. The previous head was eca3dd6. Earlier review is on #1766.

🤖 Generated with Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-model review (Opus 5.5, GPT 5.6 Sol, Grok 4.7)

The follow-up already lands the #1766 items: clear the queue on app-id change, skip OneSignalClient retries, drop never-ready IV creates, keep coalesce key + failedAttempts, rebuild serverSessionIds on load, create key = sessionId, honor Retry-After on the last attempt, monotonic notBefore, and processNext after load.

enqueueCreate / enqueueUpdate are not called from OSSessionService yet. The queue only runs via retryNow (focus / pin refresh) and restore. The notes below assume that wiring.

Act on

None remaining as a 3/3 production-path blocker.

Consider

  • sessionUserCanBeCreated treats a missing identity model as anonymous (Opus). Cached IV .on plus processNext after load() can drop a persisted create before User restores the model.
  • retryNow cancels failure backoff on every onFocus / refreshPinnedIds (Opus). Five foregrounds during 5xx with no Retry-After burn the attempt limit.
  • First PATCH after create is immediate; 404 is .drop (Sol). OP_REPO_POST_CREATE_DELAY_SECONDS and Android #2777 wait for replication.
  • In-flight update is left out of the duration max (Grok). A later lower update or end can shrink duration_seconds.
  • Unchanged-body coalesce copies only serverSessionId (Grok) and drops incoming pin IDs, so an earlier session can stall.
  • resetAndClearStoredQueue / handle vs isRetired (3/3 race, 2/3 late completion): delete happens after reset(), and a late success still writes through the new shared service.

Noted

Consent 0 retries forever (documented on #1763). Flag-off does not stop a saved queue. Uncapped Retry-After. One decode failure drops the whole blob.

Dismissed

App-id reload as a likely race on today's main-thread handleAppIdChange / onFocus path.

Open in Web View Automation 

Sent by Cursor Automation: PR Reviews

@fadi-george

Copy link
Copy Markdown
Collaborator Author

Pushed bfed2d5 for the review above:

  • Missing identity model: now only dropped when the model is loaded and anonymous under .on. A model that isn't loaded stays queued.
  • refreshPinnedIds now calls the new processPending(), which leaves a failure backoff running. App focus still calls retryNow(), same as Android's foreground trigger.
  • Post-create delay: updates for a just-created session wait OP_REPO_POST_CREATE_DELAY_SECONDS (3s), then the queue wakes itself.
  • An in-flight update now counts toward the highest duration.
  • An unchanged-body coalesce now keeps the incoming pin IDs when the queued request has none.
  • resetAndClearStoredQueue retires and deletes under the lock, and handle ignores completions once retired.

Not changed: Retry-After is uncapped, flag-off doesn't stop a saved queue, and a decode failure drops the saved blob. Happy to revisit any of these.

@nan-li nan-li left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing else blocking

Comment thread iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionService.swift Outdated
Base automatically changed from fadi/sdk-5375 to new-session-events October 9, 2026 20:20
@fadi-george
fadi-george requested a review from nan-li October 9, 2026 20:41
@fadi-george
fadi-george added this pull request to stack #1771 October 9, 2026 21:52

@nan-li nan-li left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think everything is correct, the trouble I am having with this review is the implementation doesn't follow the SDK’s existing request pattern. Calling out complexity in this separate system that is quite different. The other user executors persist the request object itself, retry the same ways, and run through the operation repo. This queue persists its own record type as JSON in standard defaults, retries in the same run with backoff, retry after and an attempt cap, its own network monitoring and background tasks, and never goes through the operation repo. Which becomes harder to grasp the SDK.

Comment thread iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift Outdated
Comment thread iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSSessionRequest.swift Outdated
@fadi-george

Copy link
Copy Markdown
Collaborator Author

I think everything is correct, the trouble I am having with this review is the implementation doesn't follow the SDK’s existing request pattern. Calling out complexity in this separate system that is quite different. The other user executors persist the request object itself, retry the same ways, and run through the operation repo. This queue persists its own record type as JSON in standard defaults, retries in the same run with backoff, retry after and an attempt cap, its own network monitoring and background tasks, and never goes through the operation repo. Which becomes harder to grasp the SDK.

Yeah mb, Ill follow existing patterns and rewrite the pr.

@fadi-george fadi-george changed the title feat: [SDK-5379] persistent queue and retry for sessions API requests feat: [SDK-5379] persistent session requests with retry through the operation repo Oct 10, 2026
@fadi-george

Copy link
Copy Markdown
Collaborator Author

@nan-li reworked in 29e1a4e. Session requests are now OSUserRequests persisted like the other executors, sent by an OSSessionOperationExecutor registered with the operation repo. The separate queue, network monitor, scheduler, and the OSCore backend service are gone. The one difference from the other executors is in-run retry: a failed session request resets sentToClient and goes out again on a later repo flush once its backoff passes, which the ticket asks for. Post-create delay uses OSNewRecordsState.

@fadi-george
fadi-george requested a review from nan-li October 10, 2026 00:12
@fadi-george fadi-george changed the title feat: [SDK-5379] persistent session requests with retry through the operation repo feat: [SDK-5379] persistent session requests through the operation repo Oct 14, 2026

@abdulraqeeb33 abdulraqeeb33 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes.

  1. After a create succeeds, nothing re-enters the queue. The updates wait for the next repo flush, and that flush holds no background task.
  2. createdSessions and endedSessions are rebuilt only from what is still in the request queue, so a restart can drop an update whose create already succeeded, or send one after an end that already left the queue.
  3. typeLimit nil keeps the current user's session requests forever.

fadi-george and others added 4 commits October 15, 2026 16:16
Session requests are persisted and sent by OSSessionOperationExecutor, with
ordering, coalescing, and a size cap. Retries use the shared
OSRequestRetryBackoff and retry trigger from SDK-5481 instead of their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wiftLint file length

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s sent IDs

An end leaves the queue once sent or dropped, so remember ended sessions
and discard later updates for them. Persist the queue after sending a
create, so a replay after a kill reuses the IDs it was sent with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fadi-george and others added 2 commits October 15, 2026 16:16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An update built while its create was in flight has no server session ID.
If the process is killed after the create succeeds, the in-memory map was
lost and the update was discarded. Persist it, capped at the newest 100.

Session requests for the current user now age out after 30 days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@nan-li nan-li left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing major blocking, just a couple of suggestions

fadi-george and others added 2 commits October 16, 2026 14:32
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…place

The executor's persisted per-session state is now the only record of a
session's create and end. Updates read the created IDs from it when sent,
and the session record no longer stores the server session ID.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@nan-li nan-li left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

approving from my side, AR still has changes requested so we can wait to merge until he has taken a look

@abdulraqeeb33 abdulraqeeb33 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The persisted session state and the 30-day limit on a queued request both look right. Three leftovers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@abdulraqeeb33 abdulraqeeb33 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aging is fixed in 527919e. The request takes the delta timestamp, and a stale one is dropped before admit.

The other three can stay. The 3s window matches the other executors, an end is only enqueued once, and a 204 update was accepted.

@abdulraqeeb33 abdulraqeeb33 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timestamp fix looks right. The request takes the delta's time, and a stale one is dropped before admit, so it cannot evict a queued request.

The other three can stay. One correction on the 404 note: the post-create delay is 3s, and the missing-record retry window is 60s.

…quest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fadi-george
fadi-george merged commit 3a1eee6 into new-session-events Oct 19, 2026
4 checks passed
@fadi-george
fadi-george deleted the fadi/sdk-5379 branch October 19, 2026 17:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants