[SDK-673] Add switchProject for runtime project switching - #1086
[SDK-673] Add switchProject for runtime project switching#1086joaodordio wants to merge 1 commit 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.
| final List<IterableInitializationCallback> initCallbacksToNotify = new ArrayList<>(); | ||
| final ExecutorService executor; | ||
| synchronized (initLock) { | ||
| isSwitchingProject = false; |
There was a problem hiding this comment.
The switching flags are currently cleared before the asynchronous drain starts, which could potentially allow newer calls to run before calls queued during the switch.
Consider keeping the gate raised until all queued calls have finished running.
Related comment on the iOS PR
|
|
||
| // Step 2: raise the switch gate synchronously, so calls made after this method returns are | ||
| // queued rather than executed against a half torn-down SDK. | ||
| if (!IterableBackgroundInitializer.beginProjectSwitch(callback)) { |
There was a problem hiding this comment.
A second switchProject call with a different API key is currently ignored, although its callback still runs.
For example, if ProjectA -> ProjectB is running and the app requests ProjectA -> ProjectC, both callbacks fire but the SDK ends on B.
This may be surprising behavior for a multi-region API, but it also seems to be intentional and covered by tests, so this comment might be more of a design discussion than a blocker.
Should we clarify or reconsider the behavior when a second call targets a different API key?
For example, only joining requests targeting the same project and either queueing or rejecting requests targeting a different one?
Related comment on the iOS PR
| // than being a key of its own, but remove it too so a build that starts writing it | ||
| // separately cannot carry a previous project's criteria id across a switch. | ||
| editor.remove(IterableConstants.SHARED_PREFS_CRITERIA_ID); | ||
| editor.remove(IterableConstants.SHARED_PREFS_ATTRIBUTION_INFO_KEY + IterableConstants.SHARED_PREFS_OBJECT_SUFFIX); |
There was a problem hiding this comment.
A deep-link redirect started on the previous project can finish after the switch and write its attribution into the new project’s shared storage.
Maybe the redirect could capture the project / API key it belongs to and discard the result if that project is no longer active?
Related comment on the iOS PR
📝 Summary
Adds
IterableApi.switchProject(context, apiKey, config, 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.
Callback contract
IterableProjectSwitchCallbackis a single-method interface,onProjectSwitched(boolean cleanTeardown), 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.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?
758 tests, 0 failures, checkstyle clean.
IterableSwitchProjectTest, 41 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, 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.