Handle binder transaction failures in CalendarSyncAdapter
* Created a facility for handling the newly implemented
TransactionTooLargeException that the framework can throw if
we send a too-large batch of data to a content provider.
* For now, we're just using this with CalendarSyncAdapter,
which is where we've seen problems in the past, and in
the referenced bug.
* The test account hits the exception, and then appars to
successfully finish syncing the calendar
Bug: 5516422
Change-Id: I3c7ce3ada940464d1ee1f69bd6192640ebbd8fa3
diff --git a/src/com/android/exchange/adapter/AbstractSyncAdapter.java b/src/com/android/exchange/adapter/AbstractSyncAdapter.java
index b4c3c78..52400c4 100644
--- a/src/com/android/exchange/adapter/AbstractSyncAdapter.java
+++ b/src/com/android/exchange/adapter/AbstractSyncAdapter.java
@@ -22,12 +22,21 @@
import com.android.exchange.CommandStatusException;
import com.android.exchange.Eas;
import com.android.exchange.EasSyncService;
+import com.google.common.annotations.VisibleForTesting;
+import android.content.ContentProviderOperation;
+import android.content.ContentProviderResult;
import android.content.ContentResolver;
+import android.content.ContentUris;
import android.content.Context;
+import android.content.OperationApplicationException;
+import android.net.Uri;
+import android.os.RemoteException;
+import android.os.TransactionTooLargeException;
import java.io.IOException;
import java.io.InputStream;
+import java.util.ArrayList;
/**
* Parent class of all sync adapters (EasMailbox, EasCalendar, and EasContacts)
@@ -43,6 +52,8 @@
protected static final String PIM_WINDOW_SIZE = "4";
+ private static final long SEPARATOR_ID = Long.MAX_VALUE;
+
public Mailbox mMailbox;
public EasSyncService mService;
public Context mContext;
@@ -134,5 +145,205 @@
public void setSyncKey(String syncKey, boolean inCommands) throws IOException {
mMailbox.mSyncKey = syncKey;
}
+
+ /**
+ * Operation is our binder-safe ContentProviderOperation (CPO) construct; an Operation can
+ * be created from a CPO, a CPO Builder, or a CPO Builder with a "back reference" column name
+ * and offset (that might be used in Builder.withValueBackReference). The CPO is not actually
+ * built until it is ready to be executed (with applyBatch); this allows us to recalculate
+ * back reference offsets if we are required to re-send a large batch in smaller chunks.
+ *
+ * NOTE: A failed binder transaction is something of an emergency case, and shouldn't happen
+ * with any frequency. When it does, and we are forced to re-send the data to the content
+ * provider in smaller chunks, we DO lose the sync-window atomicity, and thereby add another
+ * small risk to the data. Of course, this is far, far better than dropping the data on the
+ * floor, as was done before the framework implemented TransactionTooLargeException
+ */
+ protected static class Operation {
+ final ContentProviderOperation mOp;
+ final ContentProviderOperation.Builder mBuilder;
+ final String mColumnName;
+ final int mOffset;
+ // Is this Operation a separator? (a good place to break up a large transaction)
+ boolean mSeparator = false;
+
+ // For toString()
+ final String[] TYPES = new String[] {"???", "Ins", "Upd", "Del", "Assert"};
+
+ Operation(ContentProviderOperation.Builder builder, String columnName, int offset) {
+ mOp = null;
+ mBuilder = builder;
+ mColumnName = columnName;
+ mOffset = offset;
+ }
+
+ Operation(ContentProviderOperation.Builder builder) {
+ mOp = null;
+ mBuilder = builder;
+ mColumnName = null;
+ mOffset = 0;
+ }
+
+ Operation(ContentProviderOperation op) {
+ mOp = op;
+ mBuilder = null;
+ mColumnName = null;
+ mOffset = 0;
+ }
+
+ public String toString() {
+ StringBuilder sb = new StringBuilder("Op: ");
+ ContentProviderOperation op = operationToContentProviderOperation(this, 0);
+ int type = 0;
+ //DO NOT SHIP WITH THE FOLLOWING LINE (the API is hidden!)
+ //type = op.getType();
+ sb.append(TYPES[type]);
+ Uri uri = op.getUri();
+ sb.append(' ');
+ sb.append(uri.getPath());
+ if (mColumnName != null) {
+ sb.append(" Back value of " + mColumnName + ": " + mOffset);
+ }
+ return sb.toString();
+ }
+ }
+
+ /**
+ * We apply the batch of CPO's here. We synchronize on the service to avoid thread-nasties,
+ * and we just return quickly if the service has already been stopped.
+ */
+ private ContentProviderResult[] execute(String authority,
+ ArrayList<ContentProviderOperation> ops)
+ throws RemoteException, OperationApplicationException {
+ synchronized (mService.getSynchronizer()) {
+ if (!mService.isStopped()) {
+ if (!ops.isEmpty()) {
+ ContentProviderResult[] result = mContentResolver.applyBatch(authority, ops);
+ mService.userLog("Results: " + result.length);
+ return result;
+ }
+ }
+ }
+ return new ContentProviderResult[0];
+ }
+
+ /**
+ * Convert an Operation to a CPO; if the Operation has a back reference, apply it with the
+ * passed-in offset
+ */
+ @VisibleForTesting
+ static ContentProviderOperation operationToContentProviderOperation(Operation op, int offset) {
+ if (op.mOp != null) {
+ return op.mOp;
+ } else if (op.mBuilder == null) {
+ throw new IllegalArgumentException("Operation must have CPO.Builder");
+ }
+ ContentProviderOperation.Builder builder = op.mBuilder;
+ if (op.mColumnName != null) {
+ builder.withValueBackReference(op.mColumnName, op.mOffset - offset);
+ }
+ return builder.build();
+ }
+
+ /**
+ * Create a list of CPOs from a list of Operations, and then apply them in a batch
+ */
+ private ContentProviderResult[] applyBatch(String authority, ArrayList<Operation> ops,
+ int offset) throws RemoteException, OperationApplicationException {
+ // Handle the empty case
+ if (ops.isEmpty()) {
+ return new ContentProviderResult[0];
+ }
+ ArrayList<ContentProviderOperation> cpos = new ArrayList<ContentProviderOperation>();
+ for (Operation op: ops) {
+ cpos.add(operationToContentProviderOperation(op, offset));
+ }
+ return execute(authority, cpos);
+ }
+
+ /**
+ * Apply the list of CPO's in the provider and copy the "mini" result into our full result array
+ */
+ private void applyAndCopyResults(String authority, ArrayList<Operation> mini,
+ ContentProviderResult[] result, int offset) throws RemoteException {
+ // Empty lists are ok; we just ignore them
+ if (mini.isEmpty()) return;
+ try {
+ ContentProviderResult[] miniResult = applyBatch(authority, mini, offset);
+ // Copy the results from this mini-batch into our results array
+ System.arraycopy(miniResult, 0, result, offset, miniResult.length);
+ } catch (OperationApplicationException e) {
+ // Not possible since we're building the ops ourselves
+ }
+ }
+
+ /**
+ * Called by a sync adapter to execute a list of Operations in the ContentProvider handling
+ * the passed-in authority. If the attempt to apply the batch fails due to a too-large
+ * binder transaction, we split the Operations as directed by separators. If any of the
+ * "mini" batches fails due to a too-large transaction, we're screwed, but this would be
+ * vanishingly rare. Other, possibly transient, errors are handled by throwing a
+ * RemoteException, which the caller will likely re-throw as an IOException so that the sync
+ * can be attempted again.
+ *
+ * Callers MAY leave a dangling separator at the end of the list; note that the separators
+ * themselves are only markers and are not sent to the provider.
+ */
+ protected ContentProviderResult[] safeExecute(String authority, ArrayList<Operation> ops)
+ throws RemoteException {
+ mService.userLog("Try to execute ", ops.size(), " CPO's for " + authority);
+ ContentProviderResult[] result = null;
+ try {
+ // Try to execute the whole thing
+ return applyBatch(authority, ops, 0);
+ } catch (TransactionTooLargeException e) {
+ // Nope; split into smaller chunks, demarcated by the separator operation
+ mService.userLog("Transaction too large; spliting!");
+ ArrayList<Operation> mini = new ArrayList<Operation>();
+ // Build a result array with the total size we're sending
+ result = new ContentProviderResult[ops.size()];
+ int count = 0;
+ int offset = 0;
+ for (Operation op: ops) {
+ if (op.mSeparator) {
+ try {
+ mService.userLog("Try mini-batch of ", mini.size(), " CPO's");
+ applyAndCopyResults(authority, mini, result, offset);
+ mini.clear();
+ // Save away the offset here; this will need to be subtracted out of the
+ // value originally set by the adapter
+ offset = count + 1; // Remember to add 1 for the separator!
+ } catch (TransactionTooLargeException e1) {
+ throw new RuntimeException("Can't send transaction; sync stopped.");
+ } catch (RemoteException e1) {
+ throw e1;
+ }
+ } else {
+ mini.add(op);
+ }
+ count++;
+ }
+ // Check out what's left; if it's more than just a separator, apply the batch
+ int miniSize = mini.size();
+ if ((miniSize > 0) && !(miniSize == 1 && mini.get(0).mSeparator)) {
+ applyAndCopyResults(authority, mini, result, offset);
+ }
+ } catch (RemoteException e) {
+ throw e;
+ } catch (OperationApplicationException e) {
+ // Not possible since we're building the ops ourselves
+ }
+ return result;
+ }
+
+ /**
+ * Called by a sync adapter to indicate a relatively safe place to split a batch of CPO's
+ */
+ protected void addSeparatorOperation(ArrayList<Operation> ops, Uri uri) {
+ Operation op = new Operation(
+ ContentProviderOperation.newDelete(ContentUris.withAppendedId(uri, SEPARATOR_ID)));
+ op.mSeparator = true;
+ ops.add(op);
+ }
}
diff --git a/src/com/android/exchange/adapter/CalendarSyncAdapter.java b/src/com/android/exchange/adapter/CalendarSyncAdapter.java
index b467eb7..c899640 100644
--- a/src/com/android/exchange/adapter/CalendarSyncAdapter.java
+++ b/src/com/android/exchange/adapter/CalendarSyncAdapter.java
@@ -26,7 +26,6 @@
import android.content.Entity;
import android.content.Entity.NamedContentValues;
import android.content.EntityIterator;
-import android.content.OperationApplicationException;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.net.Uri;
@@ -131,8 +130,8 @@
// Used to indicate that upsyncs aren't allowed (we catch this in sendLocalChanges)
private static final String EXTENDED_PROPERTY_UPSYNC_PROHIBITED = "upsyncProhibited";
- private static final ContentProviderOperation PLACEHOLDER_OPERATION =
- ContentProviderOperation.newInsert(Uri.EMPTY).build();
+ private static final Operation PLACEHOLDER_OPERATION =
+ new Operation(ContentProviderOperation.newInsert(Uri.EMPTY));
private static final Object sSyncKeyLock = new Object();
@@ -164,9 +163,25 @@
private ArrayList<Long> mSendCancelIdList = new ArrayList<Long>();
private ArrayList<Message> mOutgoingMailList = new ArrayList<Message>();
+ private final Uri mAsSyncAdapterAttendees;
+ private final Uri mAsSyncAdapterEvents;
+ private final Uri mAsSyncAdapterReminders;
+ private final Uri mAsSyncAdapterExtendedProperties;
+
public CalendarSyncAdapter(EasSyncService service) {
super(service);
mEmailAddress = mAccount.mEmailAddress;
+
+ String amType = Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE;
+ mAsSyncAdapterAttendees =
+ asSyncAdapter(Attendees.CONTENT_URI, mEmailAddress, amType);
+ mAsSyncAdapterEvents =
+ asSyncAdapter(Events.CONTENT_URI, mEmailAddress, amType);
+ mAsSyncAdapterReminders =
+ asSyncAdapter(Reminders.CONTENT_URI, mEmailAddress, amType);
+ mAsSyncAdapterExtendedProperties =
+ asSyncAdapter(ExtendedProperties.CONTENT_URI, mEmailAddress, amType);
+
Cursor c = mService.mContentResolver.query(Calendars.CONTENT_URI,
new String[] {Calendars._ID}, CALENDAR_SELECTION,
new String[] {mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE}, null);
@@ -182,7 +197,7 @@
} finally {
c.close();
}
- }
+ }
@Override
public String getCollectionName() {
@@ -445,12 +460,9 @@
// This is an attendees-only update; just
// delete/re-add attendees
mBindArgument[0] = Long.toString(id);
- ops.add(ContentProviderOperation
- .newDelete(
- asSyncAdapter(Attendees.CONTENT_URI, mEmailAddress,
- Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE))
- .withSelection(ATTENDEES_EXCEPT_ORGANIZER, mBindArgument)
- .build());
+ ops.add(new Operation(ContentProviderOperation
+ .newDelete(mAsSyncAdapterAttendees)
+ .withSelection(ATTENDEES_EXCEPT_ORGANIZER, mBindArgument)));
eventId = id;
} else {
// Otherwise, delete the original event and recreate it
@@ -692,11 +704,8 @@
if (isValidEventValues(cv)) {
ops.set(eventOffset,
- ContentProviderOperation
- .newInsert(
- asSyncAdapter(Events.CONTENT_URI, mEmailAddress,
- Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE))
- .withValues(cv).build());
+ new Operation(ContentProviderOperation
+ .newInsert(mAsSyncAdapterEvents).withValues(cv)));
} else {
// If we can't add this event (it's invalid), remove all of the inserts
// we've built for it
@@ -718,6 +727,8 @@
}
}
}
+ // Mark the end of the event
+ addSeparatorOperation(ops, Events.CONTENT_URI);
}
private void logEventColumns(ContentValues cv, String reason) {
@@ -1187,11 +1198,11 @@
public void commit() throws IOException {
userLog("Calendar SyncKey saved as: ", mMailbox.mSyncKey);
// Save the syncKey here, using the Helper provider by Calendar provider
- mOps.add(SyncStateContract.Helpers.newSetOperation(
+ mOps.add(new Operation(SyncStateContract.Helpers.newSetOperation(
asSyncAdapter(SyncState.CONTENT_URI, mEmailAddress,
Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE),
mAccountManagerAccount,
- mMailbox.mSyncKey.getBytes()));
+ mMailbox.mSyncKey.getBytes())));
// We need to send cancellations now, because the Event won't exist after the commit
for (long eventId: mSendCancelIdList) {
@@ -1209,8 +1220,12 @@
}
}
- // Execute these all at once...
- mOps.execute();
+ // Execute our CPO's safely
+ try {
+ mOps.mResults = safeExecute(CalendarContract.AUTHORITY, mOps);
+ } catch (RemoteException e) {
+ throw new IOException("Remote exception caught; will retry");
+ }
if (mOps.mResults != null) {
// Clear dirty and mark flags for updates sent to server
@@ -1280,12 +1295,9 @@
cv.put(Events.SYNC_DATA2, clientId);
long id = c.getLong(0);
// Write the serverId into the Event
- mOps.add(ContentProviderOperation
- .newUpdate(
- asSyncAdapter(
- ContentUris.withAppendedId(Events.CONTENT_URI, id),
- mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE))
- .withValues(cv).build());
+ mOps.add(new Operation(ContentProviderOperation
+ .newUpdate(ContentUris.withAppendedId(mAsSyncAdapterEvents, id))
+ .withValues(cv)));
userLog("New event " + clientId + " was given serverId: " + serverId);
}
} finally {
@@ -1328,20 +1340,20 @@
}
}
- protected class CalendarOperations extends ArrayList<ContentProviderOperation> {
+ protected class CalendarOperations extends ArrayList<Operation> {
private static final long serialVersionUID = 1L;
public int mCount = 0;
private ContentProviderResult[] mResults = null;
private int mEventStart = 0;
@Override
- public boolean add(ContentProviderOperation op) {
+ public boolean add(Operation op) {
super.add(op);
mCount++;
return true;
}
- public int newEvent(ContentProviderOperation op) {
+ public int newEvent(Operation op) {
mEventStart = mCount;
add(op);
return mEventStart;
@@ -1358,31 +1370,29 @@
}
public void newAttendee(ContentValues cv, int eventStart) {
- add(ContentProviderOperation
- .newInsert(asSyncAdapter(Attendees.CONTENT_URI, mEmailAddress,
- Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE)).withValues(cv)
- .withValueBackReference(Attendees.EVENT_ID, eventStart).build());
+ add(new Operation(ContentProviderOperation.newInsert(mAsSyncAdapterAttendees)
+ .withValues(cv),
+ Attendees.EVENT_ID,
+ eventStart));
}
public void updatedAttendee(ContentValues cv, long id) {
cv.put(Attendees.EVENT_ID, id);
- add(ContentProviderOperation.newInsert(asSyncAdapter(Attendees.CONTENT_URI,
- mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE)).withValues(cv).build());
+ add(new Operation(ContentProviderOperation.newInsert(mAsSyncAdapterAttendees)
+ .withValues(cv)));
}
public void newException(ContentValues cv) {
- add(ContentProviderOperation.newInsert(
- asSyncAdapter(Events.CONTENT_URI, mEmailAddress,
- Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE)).withValues(cv).build());
+ add(new Operation(ContentProviderOperation.newInsert(mAsSyncAdapterEvents)
+ .withValues(cv)));
}
public void newExtendedProperty(String name, String value) {
- add(ContentProviderOperation
- .newInsert(asSyncAdapter(ExtendedProperties.CONTENT_URI, mEmailAddress,
- Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE))
+ add(new Operation(ContentProviderOperation.newInsert(mAsSyncAdapterExtendedProperties)
.withValue(ExtendedProperties.NAME, name)
- .withValue(ExtendedProperties.VALUE, value)
- .withValueBackReference(ExtendedProperties.EVENT_ID, mEventStart).build());
+ .withValue(ExtendedProperties.VALUE, value),
+ ExtendedProperties.EVENT_ID,
+ mEventStart));
}
public void updatedExtendedProperty(String name, String value, long id) {
@@ -1404,25 +1414,22 @@
// Either do an update or an insert, depending on whether one
// already exists
if (extendedPropertyId >= 0) {
- add(ContentProviderOperation
+ add(new Operation(ContentProviderOperation
.newUpdate(
- ContentUris.withAppendedId(
- asSyncAdapter(ExtendedProperties.CONTENT_URI,
- mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE),
+ ContentUris.withAppendedId(mAsSyncAdapterExtendedProperties,
extendedPropertyId))
- .withValue(ExtendedProperties.VALUE, value).build());
+ .withValue(ExtendedProperties.VALUE, value)));
} else {
newExtendedProperty(name, value);
}
}
public void newReminder(int mins, int eventStart) {
- add(ContentProviderOperation
- .newInsert(asSyncAdapter(Reminders.CONTENT_URI, mEmailAddress,
- Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE))
+ add(new Operation(ContentProviderOperation.newInsert(mAsSyncAdapterReminders)
.withValue(Reminders.MINUTES, mins)
- .withValue(Reminders.METHOD, Reminders.METHOD_ALERT)
- .withValueBackReference(ExtendedProperties.EVENT_ID, eventStart).build());
+ .withValue(Reminders.METHOD, Reminders.METHOD_ALERT),
+ ExtendedProperties.EVENT_ID,
+ eventStart));
}
public void newReminder(int mins) {
@@ -1430,35 +1437,12 @@
}
public void delete(long id, String syncId) {
- add(ContentProviderOperation.newDelete(
- asSyncAdapter(ContentUris.withAppendedId(Events.CONTENT_URI, id),
- mEmailAddress, Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE)).build());
- // Delete the exceptions for this Event (CalendarProvider doesn't do
- // this)
- add(ContentProviderOperation
- .newDelete(asSyncAdapter(Events.CONTENT_URI, mEmailAddress,
- Eas.EXCHANGE_ACCOUNT_MANAGER_TYPE))
- .withSelection(Events.ORIGINAL_SYNC_ID + "=?", new String[] {syncId}).build());
- }
-
- public void execute() {
- synchronized (mService.getSynchronizer()) {
- if (!mService.isStopped()) {
- try {
- if (!isEmpty()) {
- mService.userLog("Executing ", size(), " CPO's");
- mResults = mContext.getContentResolver().applyBatch(
- CalendarContract.AUTHORITY, this);
- }
- } catch (RemoteException e) {
- // There is nothing sensible to be done here
- Log.e(TAG, "problem inserting event during server update", e);
- } catch (OperationApplicationException e) {
- // There is nothing sensible to be done here
- Log.e(TAG, "problem inserting event during server update", e);
- }
- }
- }
+ add(new Operation(ContentProviderOperation.newDelete(
+ ContentUris.withAppendedId(mAsSyncAdapterEvents, id))));
+ // Delete the exceptions for this Event (CalendarProvider doesn't do this)
+ add(new Operation(ContentProviderOperation
+ .newDelete(mAsSyncAdapterEvents)
+ .withSelection(Events.ORIGINAL_SYNC_ID + "=?", new String[] {syncId})));
}
}
diff --git a/tests/src/com/android/exchange/adapter/CalendarSyncAdapterTests.java b/tests/src/com/android/exchange/adapter/CalendarSyncAdapterTests.java
index 4bb93f6..2e2f553 100644
--- a/tests/src/com/android/exchange/adapter/CalendarSyncAdapterTests.java
+++ b/tests/src/com/android/exchange/adapter/CalendarSyncAdapterTests.java
@@ -16,6 +16,7 @@
package com.android.exchange.adapter;
+import com.android.exchange.adapter.AbstractSyncAdapter.Operation;
import com.android.exchange.adapter.CalendarSyncAdapter.CalendarOperations;
import com.android.exchange.adapter.CalendarSyncAdapter.EasCalendarSyncParser;
import com.android.exchange.provider.MockProvider;
@@ -38,6 +39,7 @@
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
+import java.util.ArrayList;
import java.util.GregorianCalendar;
import java.util.List;
import java.util.TimeZone;
@@ -266,10 +268,12 @@
private int countInsertOperationsForTable(CalendarOperations ops, String tableName) {
int cnt = 0;
- for (ContentProviderOperation op: ops) {
- List<String> segments = op.getUri().getPathSegments();
+ for (Operation op: ops) {
+ ContentProviderOperation cpo =
+ AbstractSyncAdapter.operationToContentProviderOperation(op, 0);
+ List<String> segments = cpo.getUri().getPathSegments();
if (segments.get(0).equalsIgnoreCase(tableName) &&
- op.getType() == ContentProviderOperation.TYPE_INSERT) {
+ cpo.getType() == ContentProviderOperation.TYPE_INSERT) {
cnt++;
}
}
@@ -420,7 +424,11 @@
EasCalendarSyncParser p = event.getParser();
p.addEvent(p.mOps, "1:1", update);
// Send the CPO's to the mock provider
- mMockResolver.applyBatch(MockProvider.AUTHORITY, p.mOps);
+ ArrayList<ContentProviderOperation> cpos = new ArrayList<ContentProviderOperation>();
+ for (Operation op: p.mOps) {
+ cpos.add(AbstractSyncAdapter.operationToContentProviderOperation(op, 0));
+ }
+ mMockResolver.applyBatch(MockProvider.AUTHORITY, cpos);
return mMockResolver.query(MockProvider.uri(Attendees.CONTENT_URI), ATTENDEE_PROJECTION,
null, null, null);
}