Skip to content

Commit c5ecdfe

Browse files
authored
feat(Runtime): Add Rollout Percentage functionality (#81)
* *[ft] add rollout feat * *[m] add packageHash & random number to bucket prediction logic * *[m] rm random multiplier * *[m] address PR comments * *[m] address PR comments * *[m] address PR comments - refactor rollout.h
1 parent 96d6a3f commit c5ecdfe

File tree

12 files changed

+167
-1
lines changed

12 files changed

+167
-1
lines changed

CodePush.js

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,62 @@ import { SemverVersioning } from './versioning/SemverVersioning'
66

77
let NativeCodePush = require("react-native").NativeModules.CodePush;
88
const PackageMixins = require("./package-mixins")(NativeCodePush);
9+
const RolloutStorage = require("react-native").NativeModules.RolloutStorage;
910

10-
const DEPLOYMENT_KEY = 'deprecated_deployment_key';
11+
const DEPLOYMENT_KEY = 'deprecated_deployment_key',
12+
ROLLOUT_CACHE_PREFIX = 'CodePushRolloutDecision_',
13+
ROLLOUT_CACHE_KEY = 'CodePushRolloutKey';
14+
15+
function hashDeviceId(deviceId) {
16+
let hash = 0;
17+
for (let i = 0; i < deviceId.length; i++) {
18+
hash = ((hash << 5) - hash) + deviceId.charCodeAt(i);
19+
hash |= 0; // Convert to 32bit int
20+
}
21+
return Math.abs(hash);
22+
}
23+
24+
function getRolloutKey(label, rollout) {
25+
return `${ROLLOUT_CACHE_PREFIX}${label}_rollout_${rollout ?? 100}`;
26+
}
27+
28+
function getBucket(clientId, packageHash) {
29+
const hash = hashDeviceId(`${clientId ?? ''}_${packageHash ?? ''}`);
30+
return (Math.abs(hash) % 100);
31+
}
32+
33+
export async function shouldApplyCodePushUpdate(remotePackage, clientId, onRolloutSkipped) {
34+
if (remotePackage.rollout === undefined || remotePackage.rollout >= 100) {
35+
return true;
36+
}
37+
38+
const rolloutKey = getRolloutKey(remotePackage.label, remotePackage.rollout),
39+
cachedDecision = await RolloutStorage.getItem(rolloutKey);
40+
41+
if (cachedDecision != null) {
42+
// should apply if cachedDecision is true
43+
return cachedDecision === 'true';
44+
}
45+
46+
const bucket = getBucket(clientId, remotePackage.packageHash),
47+
inRollout = bucket < remotePackage.rollout,
48+
prevRolloutCacheKey = await RolloutStorage.getItem(ROLLOUT_CACHE_KEY);
49+
50+
console.log(`[CodePush] Bucket: ${bucket}, rollout: ${remotePackage.rollout}${inRollout ? 'IN' : 'OUT'}`);
51+
52+
if(prevRolloutCacheKey)
53+
await RolloutStorage.removeItem(prevRolloutCacheKey);
54+
55+
await RolloutStorage.setItem(ROLLOUT_CACHE_KEY, rolloutKey);
56+
await RolloutStorage.setItem(rolloutKey, inRollout.toString());
57+
58+
if (!inRollout) {
59+
console.log(`[CodePush] Skipping update due to rollout. Bucket ${bucket} >= rollout ${remotePackage.rollout}`);
60+
onRolloutSkipped?.(remotePackage.label);
61+
}
62+
63+
return inRollout;
64+
}
1165

1266
async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
1367
/*
@@ -121,6 +175,7 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
121175
package_size: 0,
122176
// not used at runtime.
123177
should_run_binary_version: false,
178+
rollout: latestReleaseInfo.rollout
124179
};
125180

126181
return mapToRemotePackageMetadata(updateInfo);
@@ -164,6 +219,13 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
164219
return null;
165220
} else {
166221
const remotePackage = { ...update, ...PackageMixins.remote() };
222+
223+
// Rollout filtering
224+
const shouldApply = await shouldApplyCodePushUpdate(remotePackage, nativeConfig.clientUniqueId, sharedCodePushOptions?.onRolloutSkipped);
225+
226+
if(!shouldApply)
227+
return { skipRollout: true };
228+
167229
remotePackage.failedInstall = await NativeCodePush.isFailedUpdate(remotePackage.packageHash);
168230
return remotePackage;
169231
}
@@ -193,6 +255,7 @@ function mapToRemotePackageMetadata(updateInfo) {
193255
packageHash: updateInfo.package_hash ?? '',
194256
packageSize: updateInfo.package_size ?? 0,
195257
downloadUrl: updateInfo.download_url ?? '',
258+
rollout: updateInfo.rollout ?? 100,
196259
};
197260
}
198261

@@ -493,6 +556,11 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
493556
return CodePush.SyncStatus.UPDATE_INSTALLED;
494557
};
495558

559+
if(remotePackage?.skipRollout){
560+
syncStatusChangeCallback(CodePush.SyncStatus.UP_TO_DATE);
561+
return CodePush.SyncStatus.UP_TO_DATE;
562+
}
563+
496564
const updateShouldBeIgnored = await shouldUpdateBeIgnored(remotePackage, syncOptions);
497565

498566
if (!remotePackage || updateShouldBeIgnored) {
@@ -609,6 +677,9 @@ let CodePush;
609677
*
610678
* onSyncError: (label: string, error: Error) => void | undefined,
611679
* setOnSyncError(onSyncErrorFunction: (label: string, error: Error) => void | undefined): void,
680+
*
681+
* onRolloutSkipped: (label: string, error: Error) => void | undefined,
682+
* setOnRolloutSkipped(onRolloutSkippedFunction: (label: string, error: Error) => void | undefined): void,
612683
* }}
613684
*/
614685
const sharedCodePushOptions = {
@@ -653,6 +724,12 @@ const sharedCodePushOptions = {
653724
if (typeof onSyncErrorFunction !== 'function') throw new Error('Please pass a function to onSyncError');
654725
this.onSyncError = onSyncErrorFunction;
655726
},
727+
onRolloutSkipped: undefined,
728+
setOnRolloutSkipped(onRolloutSkippedFunction) {
729+
if (!onRolloutSkippedFunction) return;
730+
if (typeof onRolloutSkippedFunction !== 'function') throw new Error('Please pass a function to onRolloutSkipped');
731+
this.onRolloutSkipped = onRolloutSkippedFunction;
732+
}
656733
}
657734

658735
function codePushify(options = {}) {
@@ -688,6 +765,7 @@ function codePushify(options = {}) {
688765
sharedCodePushOptions.setOnDownloadStart(options.onDownloadStart);
689766
sharedCodePushOptions.setOnDownloadSuccess(options.onDownloadSuccess);
690767
sharedCodePushOptions.setOnSyncError(options.onSyncError);
768+
sharedCodePushOptions.setOnRolloutSkipped(options.onRolloutSkipped);
691769

692770
const decorator = (RootComponent) => {
693771
class CodePushComponent extends React.Component {

android/app/src/main/java/com/microsoft/codepush/react/CodePush.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.facebook.react.bridge.JavaScriptModule;
1111
import com.facebook.react.bridge.NativeModule;
1212
import com.facebook.react.bridge.ReactApplicationContext;
13+
import com.microsoft.codepush.react.RolloutStorageModule;
1314
import com.facebook.react.devsupport.interfaces.DevSupportManager;
1415
import com.facebook.react.modules.debug.interfaces.DeveloperSettings;
1516
import com.facebook.react.uimanager.ViewManager;
@@ -404,10 +405,12 @@ static ReactInstanceManager getReactInstanceManager() {
404405
public List<NativeModule> createNativeModules(ReactApplicationContext reactApplicationContext) {
405406
CodePushNativeModule codePushModule = new CodePushNativeModule(reactApplicationContext, this, mUpdateManager, mTelemetryManager, mSettingsManager);
406407
CodePushDialog dialogModule = new CodePushDialog(reactApplicationContext);
408+
RolloutStorageModule rolloutStorageModule = new RolloutStorageModule(reactApplicationContext);
407409

408410
List<NativeModule> nativeModules = new ArrayList<>();
409411
nativeModules.add(codePushModule);
410412
nativeModules.add(dialogModule);
413+
nativeModules.add(rolloutStorageModule);
411414
return nativeModules;
412415
}
413416

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.microsoft.codepush.react;
2+
3+
import android.content.Context;
4+
import android.content.SharedPreferences;
5+
import com.facebook.react.bridge.ReactApplicationContext;
6+
import com.facebook.react.bridge.ReactContextBaseJavaModule;
7+
import com.facebook.react.bridge.ReactMethod;
8+
import com.facebook.react.bridge.Promise;
9+
10+
public class RolloutStorageModule extends ReactContextBaseJavaModule {
11+
12+
private final SharedPreferences prefs;
13+
14+
public RolloutStorageModule(ReactApplicationContext reactContext) {
15+
super(reactContext);
16+
prefs = reactContext.getSharedPreferences("CodePushPrefs", Context.MODE_PRIVATE);
17+
}
18+
19+
@Override
20+
public String getName() {
21+
return "RolloutStorage";
22+
}
23+
24+
@ReactMethod
25+
public void getItem(String key, Promise promise) {
26+
promise.resolve(prefs.getString(key, null));
27+
}
28+
29+
@ReactMethod
30+
public void setItem(String key, String value) {
31+
prefs.edit().putString(key, value).apply();
32+
}
33+
34+
@ReactMethod
35+
public void removeItem(String key) {
36+
prefs.edit().remove(key).apply();
37+
}
38+
}

cli/commands/createHistoryCommand/createReleaseHistory.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ async function createReleaseHistory(
2727
mandatory: false,
2828
downloadUrl: "",
2929
packageHash: "",
30+
rollout: 100
3031
};
3132

3233
/** @type {ReleaseHistoryInterface} */

cli/commands/releaseCommand/addToReleaseHistory.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const fs = require("fs");
2525
* @param identifier {string?}
2626
* @param mandatory {boolean?}
2727
* @param enable {boolean?}
28+
* @param rollout {number?}
2829
* @returns {Promise<void>}
2930
*/
3031
async function addToReleaseHistory(
@@ -38,6 +39,7 @@ async function addToReleaseHistory(
3839
identifier,
3940
mandatory,
4041
enable,
42+
rollout
4143
) {
4244
const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);
4345

@@ -54,6 +56,7 @@ async function addToReleaseHistory(
5456
mandatory: mandatory,
5557
downloadUrl: bundleDownloadUrl,
5658
packageHash: packageHash,
59+
rollout: rollout
5760
}
5861

5962
try {

cli/commands/releaseCommand/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ program.command('release')
1616
.option('-j, --js-bundle-name <string>', 'JS bundle file name (default-ios: "main.jsbundle" / default-android: "index.android.bundle")')
1717
.option('-m, --mandatory <bool>', 'make the release to be mandatory', parseBoolean, false)
1818
.option('--enable <bool>', 'make the release to be enabled', parseBoolean, true)
19+
.option('--rollout <number>', 'rollout percentage (0-100)', parseFloat)
1920
.option('--skip-bundle <bool>', 'skip bundle process', parseBoolean, false)
2021
.option('--skip-cleanup <bool>', 'skip cleanup process', parseBoolean, false)
2122
.option('--output-bundle-dir <string>', 'name of directory containing the bundle file created by the "bundle" command', OUTPUT_BUNDLE_DIR)
@@ -32,6 +33,7 @@ program.command('release')
3233
* @param {string} options.bundleName
3334
* @param {string} options.mandatory
3435
* @param {string} options.enable
36+
* @param {number} options.rollout
3537
* @param {string} options.skipBundle
3638
* @param {string} options.skipCleanup
3739
* @param {string} options.outputBundleDir
@@ -54,6 +56,7 @@ program.command('release')
5456
options.bundleName,
5557
options.mandatory,
5658
options.enable,
59+
options.rollout,
5760
options.skipBundle,
5861
options.skipCleanup,
5962
`${options.outputPath}/${options.outputBundleDir}`,

cli/commands/releaseCommand/release.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const { addToReleaseHistory } = require("./addToReleaseHistory");
3333
* @param jsBundleName {string}
3434
* @param mandatory {boolean}
3535
* @param enable {boolean}
36+
* @param rollout {number}
3637
* @param skipBundle {boolean}
3738
* @param skipCleanup {boolean}
3839
* @param bundleDirectory {string}
@@ -52,6 +53,7 @@ async function release(
5253
jsBundleName,
5354
mandatory,
5455
enable,
56+
rollout,
5557
skipBundle,
5658
skipCleanup,
5759
bundleDirectory,
@@ -82,6 +84,7 @@ async function release(
8284
identifier,
8385
mandatory,
8486
enable,
87+
rollout,
8588
)
8689

8790
if (!skipCleanup) {

cli/commands/updateHistoryCommand/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ program.command('update-history')
1212
.option('-c, --config <path>', 'set config file name (JS/TS)', CONFIG_FILE_NAME)
1313
.option('-m, --mandatory <bool>', 'make the release to be mandatory', parseBoolean, undefined)
1414
.option('-e, --enable <bool>', 'make the release to be enabled', parseBoolean, undefined)
15+
.option('--rollout <number>', 'rollout percentage (0-100)', parseFloat, undefined)
1516
/**
1617
* @param {Object} options
1718
* @param {string} options.appVersion
@@ -21,6 +22,7 @@ program.command('update-history')
2122
* @param {string} options.config
2223
* @param {string} options.mandatory
2324
* @param {string} options.enable
25+
* @param {number} options.rollout
2426
* @return {void}
2527
*/
2628
.action(async (options) => {
@@ -40,6 +42,7 @@ program.command('update-history')
4042
options.identifier,
4143
options.mandatory,
4244
options.enable,
45+
options.rollout
4346
)
4447
});
4548

cli/commands/updateHistoryCommand/updateReleaseHistory.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const path = require('path');
2323
* @param identifier {string?}
2424
* @param mandatory {boolean?}
2525
* @param enable {boolean?}
26+
* @param rollout {number?}
2627
* @returns {Promise<void>}
2728
*/
2829
async function updateReleaseHistory(
@@ -34,6 +35,7 @@ async function updateReleaseHistory(
3435
identifier,
3536
mandatory,
3637
enable,
38+
rollout
3739
) {
3840
const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);
3941

@@ -42,6 +44,7 @@ async function updateReleaseHistory(
4244

4345
if (typeof mandatory === "boolean") updateInfo.mandatory = mandatory;
4446
if (typeof enable === "boolean") updateInfo.enabled = enable;
47+
if (typeof rollout === "number") updateInfo.rollout = rollout;
4548

4649
try {
4750
const JSON_FILE_NAME = `${binaryVersion}.json`;

ios/CodePush/RolloutStorage.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#import <React/RCTBridgeModule.h>
2+
3+
@interface RolloutStorage : NSObject <RCTBridgeModule>
4+
@end

0 commit comments

Comments
 (0)