Skip to content

Commit

Permalink
[Feature] Re-try mechanism for CodePush Rollbacks (#1467)
Browse files Browse the repository at this point in the history
  • Loading branch information
Yuri Kulikov authored and alexandergoncharov-zz committed Dec 6, 2018
1 parent ac5472e commit 693b769
Show file tree
Hide file tree
Showing 9 changed files with 292 additions and 4 deletions.
74 changes: 73 additions & 1 deletion CodePush.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ async function tryReportStatus(statusReport, resumeListener) {
log(`Reporting CodePush update success (${label})`);
} else {
log(`Reporting CodePush update rollback (${label})`);
await NativeCodePush.setLatestRollbackInfo(statusReport.package.packageHash);
}

config.deploymentKey = statusReport.package.deploymentKey;
Expand Down Expand Up @@ -225,6 +226,71 @@ async function tryReportStatus(statusReport, resumeListener) {
}
}

async function shouldUpdateBeIgnored(remotePackage, syncOptions) {
let { rollbackRetryOptions } = syncOptions;

const isFailedPackage = remotePackage && remotePackage.failedInstall;
if (!isFailedPackage || !syncOptions.ignoreFailedUpdates) {
return false;
}

if (!rollbackRetryOptions) {
return true;
}

if (typeof rollbackRetryOptions !== "object") {
rollbackRetryOptions = CodePush.DEFAULT_ROLLBACK_RETRY_OPTIONS;
} else {
rollbackRetryOptions = { ...CodePush.DEFAULT_ROLLBACK_RETRY_OPTIONS, ...rollbackRetryOptions };
}

if (!validateRollbackRetryOptions(rollbackRetryOptions)) {
return true;
}

const latestRollbackInfo = await NativeCodePush.getLatestRollbackInfo();
if (!validateLatestRollbackInfo(latestRollbackInfo, remotePackage.packageHash)) {
log("The latest rollback info is not valid.");
return true;
}

const { delayInHours, maxRetryAttempts } = rollbackRetryOptions;
const hoursSinceLatestRollback = (Date.now() - latestRollbackInfo.time) / (1000 * 60 * 60);
if (hoursSinceLatestRollback >= delayInHours && maxRetryAttempts >= latestRollbackInfo.count) {
log("Previous rollback should be ignored due to rollback retry options.");
return false;
}

return true;
}

function validateLatestRollbackInfo(latestRollbackInfo, packageHash) {
return latestRollbackInfo &&
latestRollbackInfo.time &&
latestRollbackInfo.count &&
latestRollbackInfo.packageHash &&
latestRollbackInfo.packageHash === packageHash;
}

function validateRollbackRetryOptions(rollbackRetryOptions) {
if (typeof rollbackRetryOptions.delayInHours !== "number") {
log("The 'delayInHours' rollback retry parameter must be a number.");
return false;
}

if (typeof rollbackRetryOptions.maxRetryAttempts !== "number") {
log("The 'maxRetryAttempts' rollback retry parameter must be a number.");
return false;
}

if (rollbackRetryOptions.maxRetryAttempts < 1) {
log("The 'maxRetryAttempts' rollback retry parameter cannot be less then 1.");
return false;
}

return true;
}

var testConfig;

// This function is only used for tests. Replaces the default SDK, configuration and native bridge
Expand Down Expand Up @@ -293,6 +359,7 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
const syncOptions = {
deploymentKey: null,
ignoreFailedUpdates: true,
rollbackRetryOptions: null,
installMode: CodePush.InstallMode.ON_NEXT_RESTART,
mandatoryInstallMode: CodePush.InstallMode.IMMEDIATE,
minimumBackgroundDuration: 0,
Expand Down Expand Up @@ -360,7 +427,8 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
return CodePush.SyncStatus.UPDATE_INSTALLED;
};

const updateShouldBeIgnored = remotePackage && (remotePackage.failedInstall && syncOptions.ignoreFailedUpdates);
const updateShouldBeIgnored = await shouldUpdateBeIgnored(remotePackage, syncOptions);

if (!remotePackage || updateShouldBeIgnored) {
if (updateShouldBeIgnored) {
log("An update is available, but it is being ignored due to having been previously rolled back.");
Expand Down Expand Up @@ -585,6 +653,10 @@ if (NativeCodePush) {
optionalInstallButtonLabel: "Install",
optionalUpdateMessage: "An update is available. Would you like to install it?",
title: "Update available"
},
DEFAULT_ROLLBACK_RETRY_OPTIONS: {
delayInHours: 24,
maxRetryAttempts: 1
}
});
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,8 @@ public class CodePushConstants {
public static final String UNZIPPED_FOLDER_NAME = "unzipped";
public static final String CODE_PUSH_APK_BUILD_TIME_KEY = "CODE_PUSH_APK_BUILD_TIME";
public static final String BUNDLE_JWT_FILE = ".codepushrelease";
public static final String LATEST_ROLLBACK_INFO_KEY = "LATEST_ROLLBACK_INFO";
public static final String LATEST_ROLLBACK_PACKAGE_HASH_KEY = "packageHash";
public static final String LATEST_ROLLBACK_TIME_KEY = "time";
public static final String LATEST_ROLLBACK_COUNT_KEY = "count";
}
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,33 @@ public void onHostDestroy() {
public void isFailedUpdate(String packageHash, Promise promise) {
try {
promise.resolve(mSettingsManager.isFailedHash(packageHash));
} catch(CodePushUnknownException e) {
} catch (CodePushUnknownException e) {
CodePushUtils.log(e);
promise.reject(e);
}
}

@ReactMethod
public void getLatestRollbackInfo(Promise promise) {
try {
JSONObject latestRollbackInfo = mSettingsManager.getLatestRollbackInfo();
if (latestRollbackInfo != null) {
promise.resolve(CodePushUtils.convertJsonObjectToWritable(latestRollbackInfo));
} else {
promise.resolve(null);
}
} catch (CodePushUnknownException e) {
CodePushUtils.log(e);
promise.reject(e);
}
}

@ReactMethod
public void setLatestRollbackInfo(String packageHash, Promise promise) {
try {
mSettingsManager.setLatestRollbackInfo(packageHash);
promise.resolve(null);
} catch (CodePushUnknownException e) {
CodePushUtils.log(e);
promise.reject(e);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ else if (obj instanceof String)
map.putString(key, (String) obj);
else if (obj instanceof Double)
map.putDouble(key, (Double) obj);
else if (obj instanceof Long)
map.putDouble(key, ((Long) obj).doubleValue());
else if (obj instanceof Integer)
map.putInt(key, (Integer) obj);
else if (obj instanceof Boolean)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,7 @@ public boolean isPendingUpdate(String packageHash) {
return pendingUpdate != null &&
!pendingUpdate.getBoolean(CodePushConstants.PENDING_UPDATE_IS_LOADING_KEY) &&
(packageHash == null || pendingUpdate.getString(CodePushConstants.PENDING_UPDATE_HASH_KEY).equals(packageHash));
}
catch (JSONException e) {
} catch (JSONException e) {
throw new CodePushUnknownException("Unable to read pending update metadata in isPendingUpdate.", e);
}
}
Expand All @@ -89,6 +88,15 @@ public void removePendingUpdate() {
}

public void saveFailedUpdate(JSONObject failedPackage) {
try {
if (isFailedHash(failedPackage.getString(CodePushConstants.PACKAGE_HASH_KEY))) {
// Do not need to add the package if it is already in the failedUpdates.
return;
}
} catch (JSONException e) {
throw new CodePushUnknownException("Unable to read package hash from package.", e);
}

String failedUpdatesString = mSettings.getString(CodePushConstants.FAILED_UPDATES_KEY, null);
JSONArray failedUpdates;
if (failedUpdatesString == null) {
Expand All @@ -107,6 +115,49 @@ public void saveFailedUpdate(JSONObject failedPackage) {
mSettings.edit().putString(CodePushConstants.FAILED_UPDATES_KEY, failedUpdates.toString()).commit();
}

public JSONObject getLatestRollbackInfo() {
String latestRollbackInfoString = mSettings.getString(CodePushConstants.LATEST_ROLLBACK_INFO_KEY, null);
if (latestRollbackInfoString == null) {
return null;
}

try {
return new JSONObject(latestRollbackInfoString);
} catch (JSONException e) {
// Should not happen.
CodePushUtils.log("Unable to parse latest rollback metadata " + latestRollbackInfoString +
" stored in SharedPreferences");
return null;
}
}

public void setLatestRollbackInfo(String packageHash) {
JSONObject latestRollbackInfo = getLatestRollbackInfo();
int count = 0;

if (latestRollbackInfo != null) {
try {
String latestRollbackPackageHash = latestRollbackInfo.getString(CodePushConstants.LATEST_ROLLBACK_PACKAGE_HASH_KEY);
if (latestRollbackPackageHash.equals(packageHash)) {
count = latestRollbackInfo.getInt(CodePushConstants.LATEST_ROLLBACK_COUNT_KEY);
}
} catch (JSONException e) {
CodePushUtils.log("Unable to parse latest rollback info.");
}
} else {
latestRollbackInfo = new JSONObject();
}

try {
latestRollbackInfo.put(CodePushConstants.LATEST_ROLLBACK_PACKAGE_HASH_KEY, packageHash);
latestRollbackInfo.put(CodePushConstants.LATEST_ROLLBACK_TIME_KEY, System.currentTimeMillis());
latestRollbackInfo.put(CodePushConstants.LATEST_ROLLBACK_COUNT_KEY, count + 1);
mSettings.edit().putString(CodePushConstants.LATEST_ROLLBACK_INFO_KEY, latestRollbackInfo.toString()).commit();
} catch (JSONException e) {
throw new CodePushUnknownException("Unable to save latest rollback info.", e);
}
}

public void savePendingUpdate(String packageHash, boolean isLoading) {
JSONObject pendingUpdate = new JSONObject();
try {
Expand Down
8 changes: 8 additions & 0 deletions docs/api-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,14 @@ The `codePush` decorator accepts an "options" object that allows you to customiz

* __title__ *(String)* - The text used as the header of an update notification that is displayed to the end user. Defaults to `"Update available"`.

* __rollbackRetryOptions__ *(RollbackRetryOptions)* - An "options" object used to determine whether a rollback retry mechanism should be enabled, and if so, what settings to use. Defaults to `null`, which has the effect of disabling the retry mechanism completely. Setting this to any truthy value will enable the retry mechanism with the default settings, and passing an object to this parameter allows enabling the retry mechanism as well as overriding one or more of the default values. The rollback retry mechanism allows the application to attempt to reinstall an update that was previously rolled back (with the restrictions specified in the options).

The following list represents the available options and their defaults:

* __delayInHours__ *(Number)* - Specifies the minimum time in hours that the app will wait after the latest rollback before attempting to reinstall the same rolled-back package. Defaults to `24`.

* __maxRetryAttempts__ *(Number)* - Specifies the maximum number of retry attempts that the app can make before it stops trying. Cannot be less than `1`. Defaults to `1`.

##### codePushStatusDidChange (event hook)

Called when the sync process moves from one stage to another in the overall update process. The event hook is called with a status code which represents the current state, and can be any of the [`SyncStatus`](#syncstatus) values.
Expand Down
19 changes: 19 additions & 0 deletions ios/CodePush/CodePush.h
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,25 @@
*/
+ (BOOL)isFailedHash:(NSString*)packageHash;


/*
* This method is used to get information about the latest rollback.
* This information will be used to decide whether the application
* should ignore the update or not.
*/
+ (NSDictionary*)getRollbackInfo;
/*
* This method is used to save information about the latest rollback.
* This information will be used to decide whether the application
* should ignore the update or not.
*/
+ (void)setLatestRollbackInfo:(NSString*)packageHash;
/*
* This method is used to get the count of rollback for the package
* using the latest rollback information.
*/
+ (int)getRollbackCountForPackage:(NSString*) packageHash fromLatestRollbackInfo:(NSMutableDictionary*) latestRollbackInfo;

/*
* This method checks to see whether a specific package hash
* represents a downloaded and installed update, that hasn't
Expand Down
83 changes: 83 additions & 0 deletions ios/CodePush/CodePush.m
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ @implementation CodePush {
static NSString *bundleResourceName = @"main";
static NSString *bundleResourceSubdirectory = nil;

// These keys represent the names we use to store information about the latest rollback
static NSString *const LatestRollbackInfoKey = @"LATEST_ROLLBACK_INFO";
static NSString *const LatestRollbackPackageHashKey = @"packageHash";
static NSString *const LatestRollbackTimeKey = @"time";
static NSString *const LatestRollbackCountKey = @"count";

+ (void)initialize
{
[super initialize];
Expand Down Expand Up @@ -403,6 +409,64 @@ - (void)initializeUpdateAfterRestart
}
}

/*
* This method is used to get information about the latest rollback.
* This information will be used to decide whether the application
* should ignore the update or not.
*/
+ (NSDictionary *)getLatestRollbackInfo
{
NSUserDefaults *preferences = [NSUserDefaults standardUserDefaults];
NSDictionary *latestRollbackInfo = [preferences objectForKey:LatestRollbackInfoKey];
return latestRollbackInfo;
}

/*
* This method is used to save information about the latest rollback.
* This information will be used to decide whether the application
* should ignore the update or not.
*/
+ (void)setLatestRollbackInfo:(NSString*)packageHash
{
if (packageHash == nil) {
return;
}

NSUserDefaults *preferences = [NSUserDefaults standardUserDefaults];
NSMutableDictionary *latestRollbackInfo = [preferences objectForKey:LatestRollbackInfoKey];
if (latestRollbackInfo == nil) {
latestRollbackInfo = [[NSMutableDictionary alloc] init];
} else {
latestRollbackInfo = [latestRollbackInfo mutableCopy];
}

int initialRollbackCount = [self getRollbackCountForPackage: packageHash fromLatestRollbackInfo: latestRollbackInfo];
NSNumber *count = [NSNumber numberWithInt: initialRollbackCount + 1];
NSNumber *currentTimeMillis = [NSNumber numberWithDouble: [[NSDate date] timeIntervalSince1970] * 1000];

[latestRollbackInfo setValue:count forKey:LatestRollbackCountKey];
[latestRollbackInfo setValue:currentTimeMillis forKey:LatestRollbackTimeKey];
[latestRollbackInfo setValue:packageHash forKey:LatestRollbackPackageHashKey];

[preferences setObject:latestRollbackInfo forKey:LatestRollbackInfoKey];
[preferences synchronize];
}

/*
* This method is used to get the count of rollback for the package
* using the latest rollback information.
*/
+ (int)getRollbackCountForPackage:(NSString*) packageHash fromLatestRollbackInfo:(NSMutableDictionary*) latestRollbackInfo
{
NSString *oldPackageHash = [latestRollbackInfo objectForKey:LatestRollbackPackageHashKey];
if ([packageHash isEqualToString: oldPackageHash]) {
NSNumber *oldCount = [latestRollbackInfo objectForKey:LatestRollbackCountKey];
return [oldCount intValue];
} else {
return 0;
}
}

/*
* This method checks to see whether a specific package hash
* has previously failed installation.
Expand Down Expand Up @@ -508,6 +572,10 @@ - (void)rollbackPackage
*/
- (void)saveFailedUpdate:(NSDictionary *)failedPackage
{
if ([[self class] isFailedHash:[failedPackage objectForKey:PackageHashKey]]) {
return;
}

NSUserDefaults *preferences = [NSUserDefaults standardUserDefaults];
NSMutableArray *failedUpdates = [preferences objectForKey:FailedUpdatesKey];
if (failedUpdates == nil) {
Expand Down Expand Up @@ -822,6 +890,21 @@ -(void)loadBundleOnTick:(NSTimer *)timer {
resolve(@(isFailedHash));
}

RCT_EXPORT_METHOD(setLatestRollbackInfo:(NSString *)packageHash
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)
{
[[self class] setLatestRollbackInfo:packageHash];
}


RCT_EXPORT_METHOD(getLatestRollbackInfo:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
NSDictionary *latestRollbackInfo = [[self class] getLatestRollbackInfo];
resolve(latestRollbackInfo);
}

/*
* This method isn't publicly exposed via the "react-native-code-push"
* module, and is only used internally to populate the LocalPackage.isFirstRun property.
Expand Down
Loading

0 comments on commit 693b769

Please sign in to comment.