Skip to content

*[ft] Add Rollout Percentage functionality #81

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from

Conversation

chiraag918
Copy link

@chiraag918 chiraag918 commented Jul 28, 2025

✨ Feature: Rollout Percentage with Persistent Bucketing & Release Awareness

📌 What this PR adds

This PR introduces client-side rollout percentage support for CodePush updates, with the following features:

  1. Rollout-based user bucketing

    • If a CodePush update contains a rollout percentage (e.g., 30), each device is deterministically assigned to a 0–99 bucket using a stable hash of its unique ID.
    • The update will only be applied if the device falls within the allowed rollout percentage.
  2. Persistent rollout decisions per update

    • Once a device is bucketed for a given update (packageHash and rollout), the decision is cached locally using AsyncStorage.
    • This avoids re-bucketing or re-evaluation on every app start, ensuring consistent behavior for the same update across app sessions.
  3. Rollout re-evaluation on update change

    • If the update label or the rollout value changes, the rollout decision is recalculated and the cache is reset automatically.
  4. Respects CodePush enabled flag

    • Codepush's enabled flag is respected in the logic to completely bypass rollout logic if enabled is false.

🧪 Behavior Summary

Scenario Behavior
Same update label & rollout Cached decision is reused
New label or new rollout value Re-bucketing is triggered
Device outside rollout bucket Update is skipped

✅ Why this is useful

  • Enables gradual rollout strategies at the client level..
  • Offers full control over rollout and update logic, useful in large-scale deployments or A/B testing.

🙏 Request to Maintainers

Dear maintainers, I'd love your feedback on this PR. If this feature aligns with your goals for the package, I kindly request a review and merge.

  • The logic is isolated and does not interfere with existing behavior if rollout.
  • If there’s anything you’d like me to revise, optimize, or test further, I’m happy to update the PR accordingly.

Thank you for maintaining this awesome package! 🙌


Let me know if you'd like me to add:

  • Documentation to the README
  • A usage example for the new feature

@floydkim floydkim self-requested a review July 28, 2025 05:09
@floydkim
Copy link
Member

Hello! @chiraag918
Thank you for implementing such a great feature.

Although I haven’t received any direct requests for it yet, I believe there are definitely use cases where the progressive rollout feature will be useful.
It’s been a few days since you submitted the PR, but I’ve been reviewing the code whenever I get the chance.

--

About the feature

The idea of using the device ID for bucketing is very interesting!
However, as far as I understand, the rollout percentage directly corresponds to the bucket number.
This means that devices in the smaller-numbered buckets will always be part of the early rollout, while those in larger-numbered buckets will always receive updates later.
In other words, certain user groups could repeatedly experience unstable releases.

I’ve been thinking about how we could add a bit of randomness to this.
One idea is to generate the hash not only from the deviceId, but also by including the package’s label or packageHash values.
What do you think? Would you be open to accepting this idea?

--

About the disabled flag

I’m not entirely convinced about the need for the disabled flag.
In what use cases would it be necessary?

Also, I couldn’t find the corresponding implementation in the diff. (unless I missed it)
I’d prefer that it not be included in the diff until I better understand its usefulness. 🙏
If it turns out to be needed, it would be better to implement it in a separate PR.

@chiraag918
Copy link
Author

chiraag918 commented Aug 2, 2025

Hello @floydkim

First of all, thank you so much for taking the time to review my contribution. I really appreciate your thoughtful feedback! 🙏

Latest Commit Changes - followed your suggestion to include packageHash
Based on your suggestion, I’ve updated the implementation in the latest commit to make the bucketing system more random. Instead of just using the device ID, I’ve now included the packageHash in the input used for generating the bucket. I’ve also added a random number multiplier factor to further reduce the chances of the same devices consistently falling into the same rollout phase. Hopefully, this approach makes the rollout behaviour fairer and more resilient.

About the disabled flag
Regarding the disabled flag, my apologies for the confusion. It's actually not a new flag or feature. The rollout logic simply respects the existing enabled flag from the CodePush metadata. If it’s set to false, the progressive rollout flow doesn’t execute. I've updated the PR description as well to avoid confusion.

About testing
I've tested my contribution by patching @bravemobile/react-native-code-push package on one of my react native apps. Is there a defined way of testing that is expected by the team for the contribution to be accepted?

Thanks again for the valuable feedback! Please let me know if you'd like me to make any further changes.

@floydkim
Copy link
Member

floydkim commented Aug 3, 2025

@chiraag918
Thank you for accepting my idea 😄

I noticed that you added a random factor along with the package hash.
However, this means that when a user deletes and reinstalls the app, they could end up in a different bucket, and there’s a chance they won’t be able to return to the app version they had before deleting it.
I believe this has both pros and cons. Personally, I think it appealing that our library could be distinguished by ensuring users maintain the same experience with the same update.

I also think that adding a random factor somewhat diminishes the meaning of using the device ID and package hash. With the random factor added, doesn’t the outcome essentially become the same as just using Math.random()? 🤔

If the random factor were removed, I believe deterministic bucketing could be achieved for the same device and the same CodePush update.

(If my previous comment using the phrase “a bit of randomness” caused any confusion, I sincerely apologize. 🙏)

@chiraag918
Copy link
Author

@floydkim true, if the user were to uninstall & reinstall the app, the previous decision can get changed depending on the random factor 😅. So in that case, keeping device ID and packageHash would solve for this use case right? I added the random factor to make it a bit more random. I can remove the random multiplier. Any other suggestions or thoughts?

(No worries, these conversations helps me with understanding these corner cases, thanks 😀)

@floydkim
Copy link
Member

floydkim commented Aug 3, 2025

@chiraag918
I think it would be better to remove the random factor and only use the device ID and package hash.

If the need ever arises, we could later add an option to make the bucketing completely random. :)

@chiraag918
Copy link
Author

@floydkim I've removed the random factor multiplier in my recent commit. Request you to please review the PR once. Thank you.

Copy link
Member

@floydkim floydkim left a comment

Choose a reason for hiding this comment

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

Hi @chiraag918 !
Sorry for the delay.

I reviewed the code thoroughly and had some questions, so I’ve asked them.
Please respond when you have time. 😄

Once I understand your answers, I’m thinking of merging the PR first and then making the changes myself, including some minor coding style tweaks.
What do you think about it?
I want to reduce the back-and-forth on feedback and move quickly toward releasing the feature.

Comment on lines +350 to +354
* @param deploymentKey The deployment key to use to query the CodePush server for an update.
*
* @param handleBinaryVersionMismatchCallback An optional callback for handling target binary version mismatch
*/
function checkForUpdate(handleBinaryVersionMismatchCallback?: HandleBinaryVersionMismatchCallback): Promise<RemotePackage | null>;
function checkForUpdate(deploymentKey?: string, handleBinaryVersionMismatchCallback?: HandleBinaryVersionMismatchCallback): Promise<RemotePackage | null>;
Copy link
Member

Choose a reason for hiding this comment

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

Since deploymentKey has been deprecated, it should not be added back.
Was this change intentional?

Comment on lines +566 to +569
if(remotePackage?.skipRollout){
syncStatusChangeCallback(CodePush.SyncStatus.UNKNOWN_ERROR);
return CodePush.SyncStatus.UNKNOWN_ERROR;
}
Copy link
Member

Choose a reason for hiding this comment

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

I think this status should be CODEPUSH_SKIPPED.

Comment on lines -166 to +225
const remotePackage = { ...update, ...PackageMixins.remote() };
const remotePackage = { ...PackageMixins.remote(), ...update };
Copy link
Member

Choose a reason for hiding this comment

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

Is it necessary to change the order of the spread operation?

// Rollout filtering
const shouldApply = await shouldApplyCodePushUpdate(remotePackage, nativeConfig.clientUniqueId, sharedCodePushOptions?.onRolloutSkipped);

if(!shouldApply && !remotePackage.enabled)
Copy link
Member

Choose a reason for hiding this comment

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

enabled property is not exists.

if(prevRolloutCacheKey)
await RolloutStorage.removeItem(prevRolloutCacheKey);

await RolloutStorage.setItem(ROLLOUT_CACHE_KEY, rolloutKey);
Copy link
Member

Choose a reason for hiding this comment

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

It seems possible to implement without adding ROLLOUT_CACHE_KEY..
I will take a look while refactoring the code.

}

function getRolloutKey(label, rollout) {
return `${ROLLOUT_CACHE_PREFIX}${label}_rollout_${rollout ?? 'full'}`;
Copy link
Member

Choose a reason for hiding this comment

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

When rollout parameter is 100, returns ${ROLLOUT_CACHE_PREFIX}${label}_rollout_${100},
and when it’s undefined, returns ${ROLLOUT_CACHE_PREFIX}${label}_rollout_${'full'}.

I don’t think the two return values need to be different, and it seems fine to just use 100 directly in the key.

Comment on lines +562 to +565
if(remotePackage?.skipRollout){
syncStatusChangeCallback(CodePush.SyncStatus.UNKNOWN_ERROR);
return CodePush.SyncStatus.UNKNOWN_ERROR;
}
Copy link
Member

Choose a reason for hiding this comment

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

Is there a particular reason why we need to distinguish the state where it’s skipped because it’s not within the rollout range?

In apps that used the UP_TO_DATE state to inform users that "the app is up to date", code changes will be necessary due to the addition of this new state.
(If not using the rollout feature, no changes would be needed, of course.)

There may be operational or management reasons, but I think it’s possible to collect that information through the onRolloutSkipped callback.

import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.Promise;

public class RolloutStorageModule extends ReactContextBaseJavaModule {
Copy link
Member

Choose a reason for hiding this comment

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

The SettingsManager already handles interactions with SharedPreference.
Was the reason you added a new module instead to separate concerns?

@@ -16,6 +16,7 @@ program.command('release')
.option('-j, --js-bundle-name <string>', 'JS bundle file name (default-ios: "main.jsbundle" / default-android: "index.android.bundle")')
.option('-m, --mandatory <bool>', 'make the release to be mandatory', parseBoolean, false)
.option('--enable <bool>', 'make the release to be enabled', parseBoolean, true)
.option('--rollout <number>', 'rollout percentage (0-100)', parseFloat, 100)
Copy link
Member

Choose a reason for hiding this comment

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

Personally, I think it would be better not to add the rollout property when the rollout feature is not used, so that the JSON data doesn’t grow unnecessarily.

Comment on lines +5 to +7
RCT_EXTERN_METHOD(setItem:(NSString *)key value:(NSString *)value)
RCT_EXTERN_METHOD(getItem:(NSString *)key resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(removeItem:(NSString *)key)
Copy link
Member

Choose a reason for hiding this comment

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

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.

2 participants