[SDK-673] Add switchProject for runtime project switching - #1086
joaodordio wants to merge 6 commits into
Conversation
Adds IterableApi.switchProject(context, apiKey, config, callback), which moves a running app from one Iterable project to another in place, with no app restart and no state from the previous project leaking into the new one. Aimed at multi-region apps that need to move between a US and an EU project without restarting. The call returns immediately and runs the whole sequence on the background executor: disable the push token on the previous project, reset the in-app, embedded and unknown-user managers, purge the offline queue apart from queued device disables, clear identity and the rest of the previous project's storage, then re-initialize against the new key. The auth manager is rebuilt against the new config and the request processor re-bound to it, which iOS gets for free from its instance swap. Callback contract: IterableProjectSwitchCallback is a single-method interface so a lambda receives the result. true means every teardown step completed cleanly, false means the SDK is on the new project but a cleanup step was noisy or no device disable could be confirmed. false never means the switch failed or was rolled back. An app that does not use push always sees false, which is not an error. Notable details: - The previous project's disable captures that project's API key and its region endpoint when it is initiated, because the FCM token lookup is asynchronous and the live key can change underneath it. Without the captured key the disable lands on the new project; without the endpoint a cross-region switch sends the old key to the new region and is rejected. The switch waits up to 2 seconds for the disable to reach the request layer before swapping. - Offline tasks now persist the endpoint they were created for, so a rehydrated request goes to the region it was built for instead of whichever region is live at flush time. Tasks already on disk keep resolving the old way. - trackPushOpen is not queued behind the switch gate. A push payload carries the sending project's campaignId, templateId and messageId, so replaying it would report it against a project where those IDs do not exist. It runs inline instead. Initialization queueing is unaffected. - The longest track and updateEmail overloads are now queued like their shorter siblings. They were public and ran inline, so mid-switch behaviour depended on which overload the caller happened to use. - Per-project state held on the shared instance is cleared: inbox session ID, push payload, notification data and device attributes. iOS drops all of this when it replaces its instance; Android reuses sharedInstance so it has to be explicit. - The gate check and enqueue are a single atomic step, so a call cannot pass the check just before the gate is raised and then run against a half torn-down SDK. - Recovers if the background executor is shut down when the teardown or drain is submitted, which could happen when switchProject was called from inside a switch callback. checkstyle: FileLength stays suppressed for IterableApi.java only, tracked in SDK-677.
switchProject took (apiKey, config) as two independently supplied halves of one thing. IterableConfig carries the dataRegion and the IterableAuthHandler, so nothing stopped one project's key being paired with another project's region or auth handler. That mismatch produces an auth failure, or a request sent to the wrong region, that looks unrelated to the project it came from, and the adoption guide had to warn about it at length. Pairing the two makes it unrepresentable. The public signature is now switchProject(context, project, callback). The apiKey/config form stays as the package-private switcher entry point. Replacing rather than overloading because this is unreleased: keeping both public would leave the loose form available forever and reintroduce exactly what the pair removes. IterableProject rejects a null or blank API key at construction, so an unusable project is not constructible and a blank key can no longer reach a teardown. The switcher keeps its own blank-key guard, because the internal entry point is also reachable from initialize-time paths and from Kotlin callers whose platform types can carry a null through. iOS uses a failable initializer for the same case: the guarantee is identical, the mechanism follows each platform's convention.
The callback handed back a bare boolean, and the meaning of false had to be explained everywhere it appeared: it does not mean the switch failed, it does not mean it was rolled back, it is expected in normal operation for any app without push, and the correct response to it is identical to the correct response to true. A return value that needs that much prose to avoid being misread is carrying the wrong shape. onProjectSwitched now takes an IterableProjectSwitchResult of SWITCHED_CLEANLY or SWITCHED_WITH_WARNINGS. Both names lead with "switched" so the thing that is true in both cases, that the SDK is on the new project, is unmissable at the call site rather than something the reader has to remember from the docs. Internal plumbing still carries the teardown boolean set by the individual steps. It converts once at the callback boundary through IterableProjectSwitchResult.from, so the public contract is the named result while the diff stays confined to the edge. Deliberately two cases and no reason detail. Java enums can gain constants later without breaking callers, so leaving room is cheap here, and inventing a warning taxonomy before anyone has asked for one would be guesswork.
…n review Three defects and one parity correction, all from PR review. A switchProject for a second project while a switch was still running was silently coalesced with the first. The SDK finished on the first project while the second caller's callback reported a completed switch, so an app whose region or brand picker was tapped twice sent every later event to the project the user had already moved away from, with nothing in the API to detect it. beginProjectSwitch now tells the two kinds of second request apart by where they are headed: one asking for the project already in flight joins it, so a picker tapped twice on the same destination still runs one teardown, and one asking for a different project is parked in pendingSwitches and run when the switch in flight lands. The gate is handed to that parked request rather than lowered and raised again. Lowering it in between leaves a window in which a brand new switchProject takes the gate first and is then overtaken by the older parked request, so the SDK settles on the project the app asked for second-to-last while every callback still reports success. The switch callback is exactly where an app makes that next call from, which puts it in that window. A parked request therefore runs with resumingGate set, and owns releasing the gate it inherited if it bails out before the teardown starts, since a gate left raised would queue every later SDK call and never drain it. That also covers startPendingSwitch finding no application context, which previously dropped the request's callbacks entirely and left the app with no result at all. The gate was also lowered before the queue was drained, so a setEmail made as the switch landed could run ahead of the calls already waiting behind the gate and then be overwritten by the older one drained behind it, which is the FIFO order the gate exists to provide. The drain now decides the queue is empty under initLock, the same lock enqueue takes, and lowers the gate only then. Attribution resolved by a deep link redirect that was started on the previous project was written into the new project's storage. The redirect is a network round trip, so a switch can land in the middle of one, and the campaignId, templateId and messageId it returns exist only in the project that was left, so the new project's first attributed event would carry a campaignId it has never heard of. RedirectTask now captures the API key that was live when the link was clicked and hands it to a new setAttributionInfo overload. The key alone is not enough, because between clearing the previous project's storage and the new project coming up the previous project's key is still the live one, so a projectScopedStorageCleared flag covers that window and both are checked under projectStateLock together with the write. iOS already guarded this. The flag is lowered after initialize rather than as soon as it publishes the new key, on purpose: initialize runs processPendingAction, and a push tapped before the switch carries no key of its own, so the flag is what stops it writing the project it came from into the new project's attribution. A switch called before initialize now reports SWITCHED_CLEANLY rather than SWITCHED_WITH_WARNINGS, matching iOS. There is no previous project, so no teardown step could have been noisy and there was no device to disable, and an app using switchProject as its entry point would otherwise see a warning on every cold start.
…K-673-switch-project The base had moved five commits ahead, including a 3.10.1 release prep and a merge of master, and the branch had been conflicted against it since 25 August. GitHub cannot build the merge ref for a conflicted PR, so no pull_request workflow had run on SDK-673 since then and the last two commits landed with no CI at all. One conflict, in IterableApi.getAuthManager(). SDK-566 renamed the constructor argument to config.expiringAuthTokenRefreshPeriodMillis when it started accepting fractional seconds; SDK-673 wrapped the lazy build in synchronized (projectStateLock) so a background thread cannot build an auth manager from a config IterableProjectSwitcher is halfway through replacing. Resolved by keeping both.
| @Nullable IterableProjectSwitchCallback callback) { | ||
| synchronized (initLock) { | ||
| if (isSwitchingProject) { | ||
| if (apiKey.equals(inFlightSwitchApiKey)) { |
There was a problem hiding this comment.
Once a different destination is pending, a later request for the in-flight key cannot safely join the earlier switch.
For B in flight with C queued, a subsequent B request joins B's callbacks; those callbacks fire, then C runs and the SDK finishes on C even though B was requested last.
The global scan in parkPendingSwitchLocked similarly reorders non-adjacent duplicates such as C → D → C.
Suggest preserving request order and only coalesce an adjacent equivalent request.
| // finished, which were posted to the same looper. Whatever the app does in those, in | ||
| // particular re-identifying the user, then runs against the project it was told it was on | ||
| // before this teardown starts. | ||
| new Handler(Looper.getMainLooper()).post(() -> |
There was a problem hiding this comment.
The inherited gate is still raised while B's callback runs.
When C is pending after A → B and B's callback follows the public contract by calling setEmail or setUserId, that call is queued; C starts next, and C's completion drains the identity call against C. This sends B's intended identity to the wrong project.
Suggest ensuring callback work for B executes before C takes over, or delay B's callback until its calls cannot be replayed into C.
…r last Mirrors the iOS fix for the same four ordering defects, found in review. The first three are one mistake: the gate asked what project the SDK was on, when the question is what project the app most recently asked to be on. Those two differ for the whole length of a teardown, which includes the device disable wait. - A request for the project being left matched _apiKey, so it reported a clean switch, tore nothing down, and let the switch in flight land anyway. The app was then told it was on A while every event went to B. - A repeat of the destination in flight joined it even with another destination queued behind it, so the chain settled past the project asked for last. - parkPendingSwitchLocked scanned the whole queue, so C, D, C ran C then D and reported the last request when the first one landed. One routing rule replaces all three, and pendingSwitches becomes an ArrayDeque so the tail is reachable. The requested project is the tail of the queue, or the switch in flight when the queue is empty, or the live key when nothing is running. A request for it is absorbed by whatever is going to deliver it, anything else goes on the tail, so adjacent repeats still share a single teardown and a double tapped picker behaves as before. The fourth was introduced by the previous handover fix. The gate both queued calls and serialized switch requests, so holding it across a handover queued the setEmail the contract tells apps to make from the callback and then replayed it into the next project in the chain. lowerGate now always drops the call queueing gate, isInitializing and isBackgroundInitialized, while isSwitchingProject marks the chain and keeps new requests ordered behind it. A switch that inherits the chain raises the call gate again through resumeProjectSwitch. Each defect has a test in the new IterableSwitchProjectRoutingTest that fails without the fix.
📝 Summary
Adds
IterableApi.switchProject(context, project, callback), moving a running app between Iterable projects in place with no restart and no state carried over.🎟️ Jira Ticket: SDK-673
📖 Description
For multi-region apps that need to move between a US and an EU project without restarting. Previously the only reliable way to change projects was an app restart.
The call returns immediately and runs the whole sequence on the background executor: disable the push token on the previous project, reset the in-app, embedded and unknown-user managers, purge the offline queue apart from queued device disables, clear identity and the rest of the previous project's storage, then re-initialize against the new key. The auth manager is rebuilt against the new config and the request processor re-bound to it, which iOS gets for free from its instance swap.
The project pair
switchProjecttakes anIterableProject, which pairs a project's API key with theIterableConfigto run it with:The two halves are paired because
IterableConfigcarries thedataRegionand theIterableAuthHandler. Passing a loose key and config allowed one project's key to be combined with another project's region or auth handler, and that mismatch surfaces as an auth failure, or a request sent to the wrong region, that looks unrelated to the project it came from. Pairing them makes it unrepresentable.The constructor rejects a null or blank key with
IllegalArgumentException, so an unusable project is not constructible and a blank key can no longer reach a teardown. The switcher keeps its own blank-key guard anyway, because the package-private entry point is also reachable from initialize-time paths and from Kotlin callers whose platform types can carry a null through.The
(apiKey, config)form is now the package-private switcher entry point rather than public API. Replaced rather than overloaded, because this is unreleased: keeping both public would leave the loose form available forever and reintroduce exactly what the pair removes. iOS mirrors this withIterableProjectand a failable initializer, so the guarantee matches while each platform keeps its own idiom for reporting an invalid value.Callback contract
IterableProjectSwitchCallbackis a single-method interface,onProjectSwitched(IterableProjectSwitchResult result), delivered on the main thread.truemeans every teardown step completed cleanly.falsemeans the SDK is on the new project but a cleanup step was noisy, or no device disable could be confirmed.falsenever means the switch failed or was rolled back. An app that does not use push always seesfalse, which is normal and not an error. The right response is the same either way: carry on and re-identify the user.It is a single-method interface deliberately. Reusing
IterableInitializationCallbackdoes not work: its only abstract method takes no arguments, so a lambda would bind to that one and silently discard the boolean.The result type
The callback reports an
IterableProjectSwitchResult, eitherSWITCHED_CLEANLYorSWITCHED_WITH_WARNINGS, rather than a boolean.The boolean was the problem the guide kept having to write around:
falsedid not mean the switch failed, did not mean it was rolled back, was the expected outcome for any app without push, and called for exactly the same handling astrue. A value needing that much explanation to avoid being misread is the wrong shape. Both case names lead with "switched" so the part that holds in either case, that the SDK is on the new project, is visible at the call site instead of being something the reader has to recall.Internal plumbing still carries the teardown boolean the individual steps set, and converts once at the callback boundary via
IterableProjectSwitchResult.from, so the change stays at the edge.Two cases and no reason detail, deliberately. Java enums can gain constants later without breaking callers, so there is no cost to leaving room, and inventing a warning taxonomy before anyone has asked for one would be guesswork. If you want the noisy step identified in the result rather than only in the logs, say so on this PR.
Details worth a reviewer's attention
The previous project's device disable. The disable captures that project's API key and its region endpoint when it is initiated, because the FCM token lookup is asynchronous and the live key can change underneath it. Without the captured key the disable lands on the new project, leaving the old project still delivering push to the device. Without the captured endpoint a cross-region switch sends the old key to the new region and is rejected. The switch waits up to 2 seconds for the disable to reach the request layer before swapping; if it times out the switch still completes and reports
false, and the disable still reaches the project it was created for.Region binding for offline tasks. Offline tasks now persist the endpoint they were created for, so a rehydrated request goes to the region it was built for rather than whichever region is live at flush time. Tasks already on disk from an earlier version keep resolving the old way, so nothing queued is lost on upgrade.
trackPushOpenis not queued behind the switch gate. A push payload carries the sending project'scampaignId,templateIdandmessageId, so replaying it after the switch would report it against a project where those IDs do not exist. It runs inline instead. This needed care because the switch gate and the background-init gate are the same state on Android, soqueueOrExecuteUnlessSwitchingsplits them: still queued during initialization, inline during a switch. iOS does not gate push handling at all, for the same reason.Gate consistency. The longest
trackandupdateEmailoverloads were public and ran inline while every shorter overload was queued, so mid-switch behaviour depended on which overload the caller used. Both now wrap private*Internalmethods, matching the existingsetEmail/setUserIdtreatment.Per-project state on the shared instance. iOS drops this when it replaces its SDK instance; Android reuses
sharedInstance, so it has to be explicit. The switch now clears the inbox session ID, stored push payload, notification data and device attributes. The inbox session ID was the one producing cross-project data: it would otherwise be attached to the new project's first in-app tracking call. Device ID and visitor consent are project-agnostic and deliberately kept.Concurrency. The gate check and enqueue are a single atomic step, so a call cannot pass the check just before the gate is raised and then run against a half torn-down SDK. Twelve fields are now
volatileandgetAuthManager()'s rebuild is guarded by a lock. The switch also recovers if the background executor is shut down when the teardown or drain is submitted, which could happen whenswitchProjectwas called from inside a switch callback.Guards. A null
contextorapiKeythrowsIllegalArgumentException, since both are@NonNulland a null is a programmer error. An empty or whitespace-only key is a runtime condition, so it is refused without tearing anything down and reported asfalse, matching iOS.Known limitations, called out deliberately
trackPurchasewith acampaignId, andtrackwith acampaignId, are replayed against the new project carrying the previous project's IDs if they are issued during the switch window. iOS queues its campaign-carryingtrackPurchasetoo, so this is consistent across platforms rather than an Android-only gap. Fixing it properly means per-call key binding on both SDKs and belongs in its own ticket.initializeimmediately followed byswitchProjectis torn down underneath. That divergence needs an iOS follow-up.🧪 How to test?
759 tests, 0 failures, checkstyle clean.
IterableSwitchProjectTest, 43 tests: the guard cases, each teardown step, identity and storage clearing, manager rebuild, keychain rebuild, offline queue region binding, rapid and nested switches, a throwing teardown step, callback delivery and thread, blank keys, per-project instance state, and gate consistency for the longest overloads.IterableSwitchProjectQueueDrainTest, 4 tests: a drain no executor will accept, a switch started off the main thread, and the two push-open cases (inline during a switch, still queued during initialization).IterableSwitchProjectDisableRegionTest: the disable dispatch timeout path, now asserting the warnings result, driven with an overridden timeout so it does not burn real time.IterableOfflineTaskRegionTest, 13 tests: persistedbaseUrl, rehydrated task region, old-schema fallback, cross-region isolation, disable preservation.IterablePushRegistrationTaskTest: the disable carries the captured key and endpoint rather than the live ones, and is sent before the key swap.Every new test was checked to fail with its fix reverted, not just to pass alongside it.
Manual check: initialize against project A, identify a user, then call
switchProjectwith project B's key from the callback of a region lookup. Confirm the device is disabled on A and registered on B, the inbox is empty immediately after, and no event reaches A after the callback.🧾 Changelog
Added to
CHANGELOG.mdunder Unreleased. OneAddedentry forswitchProjectwith sub-bullets covering the callback contract, the queued and inline call sets, the state that is cleared and preserved, and the edge cases. SevenFixedentries covering offline endpoint persistence, disable preservation across a switch, the overload gating, the in-flight-initialize deferral, executor recovery, the duplicate auth listener, and the atomic gate check.📹 Loom recording if applicable
Not recorded.
🐞 Github Issues solved
None known.
📚 Docs PR if applicable
A docs PR is required and does not exist yet. This adds public API. There is an adoption guide written for the first customer team, which should be the basis for the
iterable-docsentry, and it needs to cover the callback contract, the per-platform queued call sets, and the iOS in-flight-initialize caveat.Note on the base: this stack sits on
5d726694andorigin/masterhas moved 4 commits ahead, including a 3.10.1 release prep and SDK-547, which touches JWT auth timing. Worth rebasing both branches onto latest master before merge, since SDK-547 is adjacent to the auth manager rebuild here.