Bug 1291821 - Move bulk insert logic for new history to BrowserProvider r=rnewman
authorGrisha Kruglov <gkruglov@mozilla.com>
Tue, 29 Nov 2016 13:42:53 -0800
changeset 344873 28164480660dba21fba8e217c74a32b53edf8246
parent 344872 c1069ad96647a8d0117ec976d70c6bf3523c48aa
child 344874 3c89bba23c2d344c514b2d59ca52c377d55c541f
push id37970
push usergkruglov@mozilla.com
push dateSat, 25 Feb 2017 01:09:28 +0000
treeherderautoland@bd232d46a396 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrnewman
bugs1291821
milestone54.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1291821 - Move bulk insert logic for new history to BrowserProvider r=rnewman This commit does two things: 1) It simplifies history insertion logic, which wrongly assumed that history which was being inserted might be not new. As such, it was necessary to check for collisions of visit inserts, record number of visits actually inserted, and update remote visit counts correspondingly in a separate step, making history insert a three step operation (insert history record, insert its visits, update history record with a count). However, bulkInsert runs only for records which were determined to be entirely new, so it's possible to drop the third step. 2) Makes all of the insertions (history records and their visits) run in one transaction. Prepared statements for both history and visit inserts are used are used as a performance optimization measure. MozReview-Commit-ID: 48T4G5IsQNS
mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryDataAccessor.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryRepositorySession.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/db/DelegatingTestContentProvider.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryTest.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryVisitsTestBase.java
--- a/mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
@@ -53,16 +53,21 @@ public class BrowserContract {
     public static final String PARAM_INSERT_IF_NEEDED = "insert_if_needed";
     public static final String PARAM_INCREMENT_VISITS = "increment_visits";
     public static final String PARAM_INCREMENT_REMOTE_AGGREGATES = "increment_remote_aggregates";
     public static final String PARAM_NON_POSITIONED_PINS = "non_positioned_pins";
     public static final String PARAM_EXPIRE_PRIORITY = "priority";
     public static final String PARAM_DATASET_ID = "dataset_id";
     public static final String PARAM_GROUP_BY = "group_by";
 
+    public static final String METHOD_INSERT_HISTORY_WITH_VISITS_FROM_SYNC = "insertHistoryWithVisitsSync";
+    public static final String METHOD_RESULT = "methodResult";
+    public static final String METHOD_PARAM_OBJECT = "object";
+    public static final String METHOD_PARAM_DATA = "data";
+
     static public enum ExpirePriority {
         NORMAL,
         AGGRESSIVE
     }
 
     /**
      * Produces a SQL expression used for sorting results of the "combined" view by frecency.
      * Combines remote and local frecency calculations, weighting local visits much heavier.
--- a/mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java
@@ -27,16 +27,17 @@ import org.mozilla.gecko.db.BrowserContr
 import org.mozilla.gecko.db.BrowserContract.Tabs;
 import org.mozilla.gecko.db.BrowserContract.Thumbnails;
 import org.mozilla.gecko.db.BrowserContract.TopSites;
 import org.mozilla.gecko.db.BrowserContract.UrlAnnotations;
 import org.mozilla.gecko.db.BrowserContract.PageMetadata;
 import org.mozilla.gecko.db.DBUtils.UpdateOperation;
 import org.mozilla.gecko.icons.IconsHelper;
 import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.repositories.android.BrowserContractHelpers;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import android.content.BroadcastReceiver;
 import android.content.ContentProviderOperation;
 import android.content.ContentProviderResult;
 import android.content.ContentUris;
 import android.content.ContentValues;
 import android.content.Context;
@@ -44,20 +45,25 @@ import android.content.Intent;
 import android.content.IntentFilter;
 import android.content.OperationApplicationException;
 import android.content.UriMatcher;
 import android.database.Cursor;
 import android.database.DatabaseUtils;
 import android.database.MatrixCursor;
 import android.database.MergeCursor;
 import android.database.SQLException;
+import android.database.sqlite.SQLiteConstraintException;
 import android.database.sqlite.SQLiteCursor;
 import android.database.sqlite.SQLiteDatabase;
 import android.database.sqlite.SQLiteQueryBuilder;
+import android.database.sqlite.SQLiteStatement;
 import android.net.Uri;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
 import android.support.v4.content.LocalBroadcastManager;
 import android.text.TextUtils;
 import android.util.Log;
 
 public class BrowserProvider extends SharedBrowserDatabaseProvider {
     public static final String ACTION_SHRINK_MEMORY = "org.mozilla.gecko.db.intent.action.SHRINK_MEMORY";
 
     private static final String LOGTAG = "GeckoBrowserProvider";
@@ -2202,16 +2208,240 @@ public class BrowserProvider extends Sha
                 + " WHERE " + Bookmarks.IS_DELETED + " = 0"
                 + " AND " + Bookmarks.URL + " IS NOT NULL)";
 
         return deleteFavicons(uri, faviconSelection, null) +
                deleteThumbnails(uri, thumbnailSelection, null) +
                getURLImageDataTable().deleteUnused(getWritableDatabase(uri));
     }
 
+    @Nullable
+    @Override
+    public Bundle call(@NonNull String method, String uriArg, Bundle extras) {
+        if (uriArg == null) {
+            throw new IllegalArgumentException("Missing required Uri argument.");
+        }
+        final Bundle result = new Bundle();
+        switch (method) {
+            case BrowserContract.METHOD_INSERT_HISTORY_WITH_VISITS_FROM_SYNC:
+                try {
+                    final Uri uri = Uri.parse(uriArg);
+                    final SQLiteDatabase db = getWritableDatabase(uri);
+                    bulkInsertHistoryWithVisits(db, extras);
+                    result.putSerializable(BrowserContract.METHOD_RESULT, null);
+
+                // If anything went wrong during insertion, we know that changes were rolled back.
+                // Inform our caller that we have failed.
+                } catch (Exception e) {
+                    Log.e(LOGTAG, "Unexpected error while bulk inserting history", e);
+                    result.putSerializable(BrowserContract.METHOD_RESULT, e);
+                }
+                break;
+            default:
+                throw new IllegalArgumentException("Unknown method call: " + method);
+        }
+
+        return result;
+    }
+
+    private void bulkInsertHistoryWithVisits(final SQLiteDatabase db, @NonNull Bundle dataBundle) {
+        // NB: dataBundle structure:
+        // Key METHOD_PARAM_DATA=[Bundle,...]
+        // Each Bundle has keys METHOD_PARAM_OBJECT=ContentValues{HistoryRecord}, VISITS=ContentValues[]{visits}
+        final Bundle[] recordBundles = (Bundle[]) dataBundle.getSerializable(BrowserContract.METHOD_PARAM_DATA);
+
+        if (recordBundles == null) {
+            throw new IllegalArgumentException("Received null recordBundle while bulk inserting history.");
+        }
+
+        if (recordBundles.length == 0) {
+            return;
+        }
+
+        final ContentValues[][] visitsValueSet = new ContentValues[recordBundles.length][];
+        final ContentValues[] historyValueSet = new ContentValues[recordBundles.length];
+        for (int i = 0; i < recordBundles.length; i++) {
+            historyValueSet[i] = recordBundles[i].getParcelable(BrowserContract.METHOD_PARAM_OBJECT);
+            visitsValueSet[i] = (ContentValues[]) recordBundles[i].getSerializable(History.VISITS);
+        }
+
+        // Wrap the whole operation in a transaction.
+        beginBatch(db);
+
+        final int historyInserted;
+        try {
+            // First, insert history records.
+            historyInserted = bulkInsertHistory(db, historyValueSet);
+            if (historyInserted != recordBundles.length) {
+                Log.w(LOGTAG, "Expected to insert " + recordBundles.length + " history records, " +
+                        "but actually inserted " + historyInserted);
+            }
+
+            // Second, insert visit records.
+            bulkInsertVisits(db, visitsValueSet);
+
+            // Finally, commit all of the insertions we just made.
+            markBatchSuccessful(db);
+
+        // We're done with our database operations.
+        } finally {
+            endBatch(db);
+        }
+
+        // Notify listeners that we've just inserted new history records.
+        if (historyInserted > 0) {
+            getContext().getContentResolver().notifyChange(
+                    BrowserContractHelpers.HISTORY_CONTENT_URI, null,
+                    // Do not sync these changes.
+                    false
+            );
+        }
+    }
+
+    private int bulkInsertHistory(final SQLiteDatabase db, ContentValues[] values) {
+        int inserted = 0;
+        final String fullInsertSqlStatement = "INSERT INTO " + History.TABLE_NAME + " (" +
+                History.GUID + "," +
+                History.TITLE + "," +
+                History.URL + "," +
+                History.DATE_LAST_VISITED + "," +
+                History.REMOTE_DATE_LAST_VISITED + "," +
+                History.VISITS + "," +
+                History.REMOTE_VISITS + ") VALUES (?, ?, ?, ?, ?, ?, ?)";
+        final String shortInsertSqlStatement = "INSERT INTO " + History.TABLE_NAME + " (" +
+                History.GUID + "," +
+                History.TITLE + "," +
+                History.URL + ") VALUES (?, ?, ?)";
+        final SQLiteStatement compiledFullStatement = db.compileStatement(fullInsertSqlStatement);
+        final SQLiteStatement compiledShortStatement = db.compileStatement(shortInsertSqlStatement);
+        SQLiteStatement statementToExec;
+
+        beginWrite(db);
+        try {
+            for (ContentValues cv : values) {
+                final String guid = cv.getAsString(History.GUID);
+                final String title = cv.getAsString(History.TITLE);
+                final String url = cv.getAsString(History.URL);
+                final Long dateLastVisited = cv.getAsLong(History.DATE_LAST_VISITED);
+                final Long remoteDateLastVisited = cv.getAsLong(History.REMOTE_DATE_LAST_VISITED);
+                final Integer visits = cv.getAsInteger(History.VISITS);
+
+                // If dateLastVisited is null, so will be remoteDateLastVisited and visits.
+                // We will use the short compiled statement in this case.
+                // See implementation in AndroidBrowserHistoryDataAccessor@getContentValues.
+                if (dateLastVisited == null) {
+                    statementToExec = compiledShortStatement;
+                } else {
+                    statementToExec = compiledFullStatement;
+                }
+
+                statementToExec.clearBindings();
+                statementToExec.bindString(1, guid);
+                // Title is allowed to be null.
+                if (title != null) {
+                    statementToExec.bindString(2, title);
+                } else {
+                    statementToExec.bindNull(2);
+                }
+                statementToExec.bindString(3, url);
+                if (dateLastVisited != null) {
+                    statementToExec.bindLong(4, dateLastVisited);
+                    statementToExec.bindLong(5, remoteDateLastVisited);
+
+                    // NB:
+                    // Both of these count values might be slightly off unless we recalculate them
+                    // from data in the visits table at some point.
+                    // See note about visit insertion failures below in the bulkInsertVisits method.
+
+                    // Visit count
+                    statementToExec.bindLong(6, visits);
+                    // Remote visit count.
+                    statementToExec.bindLong(7, visits);
+                }
+
+                try {
+                    if (statementToExec.executeInsert() != -1) {
+                        inserted += 1;
+                    }
+
+                // NB: Constraint violation might occur if we're trying to insert a duplicate GUID.
+                // This should not happen but it does in practice, possibly due to reconciliation bugs.
+                // For now we catch and log the error without failing the whole bulk insert.
+                } catch (SQLiteConstraintException e) {
+                    Log.w(LOGTAG, "Unexpected constraint violation while inserting history with GUID " + guid, e);
+                }
+            }
+            markWriteSuccessful(db);
+        } finally {
+            endWrite(db);
+        }
+
+        if (inserted != values.length) {
+            Log.w(LOGTAG, "Failed to insert some of the history. " +
+                    "Expected: " + values.length + ", actual: " + inserted);
+        }
+
+        return inserted;
+    }
+
+    private int bulkInsertVisits(SQLiteDatabase db, ContentValues[][] valueSets) {
+        final String insertSqlStatement = "INSERT INTO " + Visits.TABLE_NAME + " (" +
+                Visits.DATE_VISITED + "," +
+                Visits.VISIT_TYPE + "," +
+                Visits.HISTORY_GUID + "," +
+                Visits.IS_LOCAL + ") VALUES (?, ?, ?, ?)";
+        final SQLiteStatement compiledInsertStatement = db.compileStatement(insertSqlStatement);
+
+        int totalInserted = 0;
+        beginWrite(db);
+        try {
+            for (ContentValues[] valueSet : valueSets) {
+                int inserted = 0;
+                for (ContentValues values : valueSet) {
+                    final long date = values.getAsLong(Visits.DATE_VISITED);
+                    final long visitType = values.getAsLong(Visits.VISIT_TYPE);
+                    final String guid = values.getAsString(Visits.HISTORY_GUID);
+                    final Integer isLocal = values.getAsInteger(Visits.IS_LOCAL);
+
+                    // Bind parameters use a 1-based index.
+                    compiledInsertStatement.clearBindings();
+                    compiledInsertStatement.bindLong(1, date);
+                    compiledInsertStatement.bindLong(2, visitType);
+                    compiledInsertStatement.bindString(3, guid);
+                    compiledInsertStatement.bindLong(4, isLocal);
+
+                    try {
+                        if (compiledInsertStatement.executeInsert() != -1) {
+                            inserted++;
+                        }
+
+                    // NB:
+                    // Constraint exception will be thrown if we try to insert a visit violating
+                    // unique(guid, date) constraint. We don't expect to do that, but our incoming
+                    // data might not be clean - either due to duplicate entries in the sync data,
+                    // or, less likely, due to record reconciliation bugs at the RepositorySession
+                    // level.
+                    } catch (SQLiteConstraintException e) {
+                        Log.w(LOGTAG, "Unexpected constraint exception while inserting a visit", e);
+                    }
+                }
+                if (inserted != valueSet.length) {
+                    Log.w(LOGTAG, "Failed to insert some of the visits. " +
+                            "Expected: " + valueSet.length + ", actual: " + inserted);
+                }
+                totalInserted += inserted;
+            }
+            markWriteSuccessful(db);
+        } finally {
+            endWrite(db);
+        }
+
+        return totalInserted;
+    }
+
     @Override
     public ContentProviderResult[] applyBatch (ArrayList<ContentProviderOperation> operations)
         throws OperationApplicationException {
         final int numOperations = operations.size();
         final ContentProviderResult[] results = new ContentProviderResult[numOperations];
 
         if (numOperations < 1) {
             debug("applyBatch: no operations; returning immediately.");
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryDataAccessor.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryDataAccessor.java
@@ -12,16 +12,17 @@ import org.mozilla.gecko.background.comm
 import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.sync.repositories.NullCursorException;
 import org.mozilla.gecko.sync.repositories.domain.HistoryRecord;
 import org.mozilla.gecko.sync.repositories.domain.Record;
 
 import android.content.ContentValues;
 import android.content.Context;
 import android.net.Uri;
+import android.os.Bundle;
 
 public class AndroidBrowserHistoryDataAccessor extends
     AndroidBrowserRepositoryDataAccessor {
 
   public AndroidBrowserHistoryDataAccessor(Context context) {
     super(context);
   }
 
@@ -40,17 +41,17 @@ public class AndroidBrowserHistoryDataAc
     if (rec.visits != null) {
       JSONArray visits = rec.visits;
       long mostRecent = getLastVisited(visits);
 
       // Fennec stores history timestamps in milliseconds, and visit timestamps in microseconds.
       // The rest of Sync works in microseconds. This is the conversion point for records coming form Sync.
       cv.put(BrowserContract.History.DATE_LAST_VISITED, mostRecent / 1000);
       cv.put(BrowserContract.History.REMOTE_DATE_LAST_VISITED, mostRecent / 1000);
-      cv.put(BrowserContract.History.VISITS, Long.toString(visits.size()));
+      cv.put(BrowserContract.History.VISITS, visits.size());
     }
     return cv;
   }
 
   @Override
   protected String[] getAllColumns() {
     return BrowserContractHelpers.HistoryColumns;
   }
@@ -104,73 +105,48 @@ public class AndroidBrowserHistoryDataAc
    * then inserts all the visit information (also using <code>ContentProvider.bulkInsert</code>).
    *
    * @param records
    *          the records to insert.
    * @return
    *          the number of records actually inserted.
    * @throws NullCursorException
    */
-  public int bulkInsert(ArrayList<HistoryRecord> records) throws NullCursorException {
-    if (records.isEmpty()) {
-      Logger.debug(LOG_TAG, "No records to insert, returning.");
-    }
-
-    int size = records.size();
-    ContentValues[] cvs = new ContentValues[size];
-    int index = 0;
-    for (Record record : records) {
+  public boolean bulkInsert(ArrayList<HistoryRecord> records) throws NullCursorException {
+    final Bundle[] historyBundles = new Bundle[records.size()];
+    int i = 0;
+    for (HistoryRecord record : records) {
       if (record.guid == null) {
-        throw new IllegalArgumentException("Record with null GUID passed in to bulkInsert.");
+        throw new IllegalArgumentException("Record with null GUID passed into bulkInsert.");
       }
-      cvs[index] = getContentValues(record);
-      index += 1;
-    }
-
-    // First update the history records.
-    int inserted = context.getContentResolver().bulkInsert(getUri(), cvs);
-    if (inserted == size) {
-      Logger.debug(LOG_TAG, "Inserted " + inserted + " records, as expected.");
-    } else {
-      Logger.debug(LOG_TAG, "Inserted " +
-                   inserted + " records but expected " +
-                   size     + " records; continuing to update visits.");
+      final Bundle historyBundle = new Bundle();
+      historyBundle.putParcelable(BrowserContract.METHOD_PARAM_OBJECT, getContentValues(record));
+      historyBundle.putSerializable(
+              BrowserContract.History.VISITS,
+              VisitsHelper.getVisitsContentValues(record.guid, record.visits)
+      );
+      historyBundles[i] = historyBundle;
+      i++;
     }
 
-    final ContentValues remoteVisitAggregateValues = new ContentValues();
-    final Uri historyIncrementRemoteAggregateUri = getUri().buildUpon()
-            .appendQueryParameter(BrowserContract.PARAM_INCREMENT_REMOTE_AGGREGATES, "true")
-            .build();
-    for (Record record : records) {
-      HistoryRecord rec = (HistoryRecord) record;
-      if (rec.visits != null && rec.visits.size() != 0) {
-        int remoteVisitsInserted = context.getContentResolver().bulkInsert(
-                BrowserContract.Visits.CONTENT_URI,
-                VisitsHelper.getVisitsContentValues(rec.guid, rec.visits)
-        );
+    final Bundle data = new Bundle();
+    data.putSerializable(BrowserContract.METHOD_PARAM_DATA, historyBundles);
 
-        // If we just inserted any visits, update remote visit aggregate values.
-        // While inserting visits, we might not insert all of rec.visits - if we already have a local
-        // visit record with matching (guid,date), we will skip that visit.
-        // Remote visits aggregate value will be incremented by number of visits inserted.
-        // Note that we don't need to set REMOTE_DATE_LAST_VISITED, because it already gets set above.
-        if (remoteVisitsInserted > 0) {
-          // Note that REMOTE_VISITS must be set before calling cr.update(...) with a URI
-          // that has PARAM_INCREMENT_REMOTE_AGGREGATES=true.
-          remoteVisitAggregateValues.put(BrowserContract.History.REMOTE_VISITS, remoteVisitsInserted);
-          context.getContentResolver().update(
-                  historyIncrementRemoteAggregateUri,
-                  remoteVisitAggregateValues,
-                  BrowserContract.History.GUID + " = ?", new String[] {rec.guid}
-          );
-        }
-      }
+    // Let our ContentProvider handle insertion of everything.
+    final Bundle result = context.getContentResolver().call(
+            getUri(),
+            BrowserContract.METHOD_INSERT_HISTORY_WITH_VISITS_FROM_SYNC,
+            getUri().toString(),
+            data
+    );
+    if (result == null) {
+      throw new IllegalStateException("Unexpected null result while bulk inserting history");
     }
-
-    return inserted;
+    final Exception thrownException = (Exception) result.getSerializable(BrowserContract.METHOD_RESULT);
+    return thrownException == null;
   }
 
   /**
    * Helper method used to find largest <code>VisitsHelper.SYNC_DATE_KEY</code> value in a provided JSONArray.
    *
    * @param visits Array of objects which will be searched.
    * @return largest value of <code>VisitsHelper.SYNC_DATE_KEY</code>.
      */
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryRepositorySession.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/android/AndroidBrowserHistoryRepositorySession.java
@@ -23,17 +23,17 @@ import android.database.Cursor;
 import android.os.RemoteException;
 
 public class AndroidBrowserHistoryRepositorySession extends AndroidBrowserRepositorySession {
   public static final String LOG_TAG = "ABHistoryRepoSess";
 
   /**
    * The number of records to queue for insertion before writing to databases.
    */
-  public static final int INSERT_RECORD_THRESHOLD = 50;
+  public static final int INSERT_RECORD_THRESHOLD = 5000;
   public static final int RECENT_VISITS_LIMIT = 20;
 
   public AndroidBrowserHistoryRepositorySession(Repository repository, Context context) {
     super(repository);
     dbHelper = new AndroidBrowserHistoryDataAccessor(context);
   }
 
   @Override
@@ -157,21 +157,18 @@ public class AndroidBrowserHistoryReposi
     if (recordsBuffer.size() < 1) {
       Logger.debug(LOG_TAG, "No records to flush, returning.");
       return;
     }
 
     final ArrayList<HistoryRecord> outgoing = recordsBuffer;
     recordsBuffer = new ArrayList<HistoryRecord>();
     Logger.debug(LOG_TAG, "Flushing " + outgoing.size() + " records to database.");
-    // TODO: move bulkInsert to AndroidBrowserDataAccessor?
-    int inserted = ((AndroidBrowserHistoryDataAccessor) dbHelper).bulkInsert(outgoing);
-    if (inserted != outgoing.size()) {
-      // Something failed; most pessimistic action is to declare that all insertions failed.
-      // TODO: perform the bulkInsert in a transaction and rollback unless all insertions succeed?
+    boolean transactionSuccess = ((AndroidBrowserHistoryDataAccessor) dbHelper).bulkInsert(outgoing);
+    if (!transactionSuccess) {
       for (HistoryRecord failed : outgoing) {
         storeDelegate.onRecordStoreFailed(new RuntimeException("Failed to insert history item with guid " + failed.guid + "."), failed.guid);
       }
       return;
     }
 
     // All good, everybody succeeded.
     for (HistoryRecord succeeded : outgoing) {
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/db/DelegatingTestContentProvider.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/db/DelegatingTestContentProvider.java
@@ -6,16 +6,18 @@ package org.mozilla.gecko.background.db;
 
 import android.content.ContentProvider;
 import android.content.ContentProviderOperation;
 import android.content.ContentProviderResult;
 import android.content.ContentValues;
 import android.content.OperationApplicationException;
 import android.database.Cursor;
 import android.net.Uri;
+import android.os.Bundle;
+import android.support.annotation.Nullable;
 
 import org.mozilla.gecko.db.BrowserContract;
 
 import java.util.ArrayList;
 
 /**
  * Wrap a ContentProvider, appending &test=1 to all queries.
  */
@@ -75,12 +77,18 @@ public class DelegatingTestContentProvid
         return mTargetProvider.applyBatch(operations);
     }
 
     @Override
     public int bulkInsert(Uri uri, ContentValues[] values) {
         return mTargetProvider.bulkInsert(appendTestParam(uri), values);
     }
 
+    @Nullable
+    @Override
+    public Bundle call(String method, String arg, Bundle extras) {
+        return mTargetProvider.call(method, arg, extras);
+    }
+
     public ContentProvider getTargetProvider() {
         return mTargetProvider;
     }
 }
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryTest.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryTest.java
@@ -3,16 +3,17 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.db;
 
 import android.content.ContentProviderClient;
 import android.content.ContentValues;
 import android.database.Cursor;
 import android.net.Uri;
+import android.os.Bundle;
 import android.os.RemoteException;
 
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mozilla.gecko.background.testhelpers.TestRunner;
 import org.robolectric.shadows.ShadowContentResolver;
@@ -254,16 +255,108 @@ public class BrowserProviderHistoryTest 
             assertTrue(true);
 
             // NB: same values as above, to ensure throwing update didn't actually change anything.
             assertHistoryAggregates(BrowserContract.History.URL + " = ?", new String[] {url},
                     2, 19, lastVisited3, 8, lastVisited3);
         }
     }
 
+    @Test
+    public void testBulkHistoryInsert() throws Exception {
+        // Test basic error conditions.
+        String historyTestUriArg = historyTestUri.toString();
+        Bundle result = historyClient.call(BrowserContract.METHOD_INSERT_HISTORY_WITH_VISITS_FROM_SYNC, historyTestUriArg, new Bundle());
+        assertNotNull(result);
+        assertNotNull(result.getSerializable(BrowserContract.METHOD_RESULT));
+
+        final Bundle data = new Bundle();
+
+        Bundle[] recordBundles = new Bundle[0];
+        data.putSerializable(BrowserContract.METHOD_PARAM_DATA, recordBundles);
+        result = historyClient.call(BrowserContract.METHOD_INSERT_HISTORY_WITH_VISITS_FROM_SYNC, historyTestUriArg, data);
+        assertNotNull(result);
+        assertNull(result.getSerializable(BrowserContract.METHOD_RESULT));
+        assertRowCount(historyClient, historyTestUri, 0);
+
+        // Test insert three history records with 10 visits each.
+        recordBundles = new Bundle[3];
+        for (int i = 0; i < 3; i++) {
+            final Bundle bundle = new Bundle();
+            bundle.putParcelable(BrowserContract.METHOD_PARAM_OBJECT, buildHistoryCV("guid" + i, "Test", "https://www.mozilla.org/" + i, 10L, 10L, 10));
+            bundle.putSerializable(BrowserContract.History.VISITS, buildHistoryVisitsCVs(10, "guid" + i, 1L, 3, false));
+            recordBundles[i] = bundle;
+        }
+        data.putSerializable(BrowserContract.METHOD_PARAM_DATA, recordBundles);
+
+        result = historyClient.call(BrowserContract.METHOD_INSERT_HISTORY_WITH_VISITS_FROM_SYNC, historyTestUriArg, data);
+        assertNotNull(result);
+        assertNull(result.getSerializable(BrowserContract.METHOD_RESULT));
+        assertRowCount(historyClient, historyTestUri, 3);
+        assertRowCount(visitsClient, visitsTestUri, 30);
+
+        // Test insert mixed data.
+        recordBundles = new Bundle[3];
+        final Bundle bundle = new Bundle();
+        bundle.putParcelable(BrowserContract.METHOD_PARAM_OBJECT, buildHistoryCV("guid4", null, "https://www.mozilla.org/1", null, null, null));
+        bundle.putSerializable(BrowserContract.History.VISITS, new ContentValues[0]);
+        recordBundles[0] = bundle;
+        final Bundle bundle2 = new Bundle();
+        bundle2.putParcelable(BrowserContract.METHOD_PARAM_OBJECT, buildHistoryCV("guid5", "Test", "https://www.mozilla.org/2", null, null, null));
+        bundle2.putSerializable(BrowserContract.History.VISITS, new ContentValues[0]);
+        recordBundles[1] = bundle2;
+        final Bundle bundle3 = new Bundle();
+        bundle3.putParcelable(BrowserContract.METHOD_PARAM_OBJECT, buildHistoryCV("guid6", "Test", "https://www.mozilla.org/3", 5L, 5L, 5));
+        bundle3.putSerializable(BrowserContract.History.VISITS, buildHistoryVisitsCVs(5, "guid6", 1L, 2, false));
+        recordBundles[2] = bundle3;
+        data.putSerializable(BrowserContract.METHOD_PARAM_DATA, recordBundles);
+
+        result = historyClient.call(BrowserContract.METHOD_INSERT_HISTORY_WITH_VISITS_FROM_SYNC, historyTestUriArg, data);
+        assertNotNull(result);
+        assertNull(result.getSerializable(BrowserContract.METHOD_RESULT));
+        assertRowCount(historyClient, historyTestUri, 6);
+        assertRowCount(visitsClient, visitsTestUri, 35);
+
+        assertHistoryAggregates(BrowserContract.History.URL + " = ?", new String[] {"https://www.mozilla.org/3"},
+                5, 0, 0, 5, 5);
+    }
+
+    private ContentValues[] buildHistoryVisitsCVs(int numberOfVisits, String guid, long baseDate, int visitType, boolean isLocal) {
+        final ContentValues[] visits = new ContentValues[numberOfVisits];
+        for (int i = 0; i < numberOfVisits; i++) {
+            final ContentValues visit = new ContentValues();
+            visit.put(BrowserContract.Visits.HISTORY_GUID, guid);
+            visit.put(BrowserContract.Visits.DATE_VISITED, baseDate + i);
+            visit.put(BrowserContract.Visits.VISIT_TYPE, visitType);
+            visit.put(BrowserContract.Visits.IS_LOCAL, isLocal ? BrowserContract.Visits.VISIT_IS_LOCAL : BrowserContract.Visits.VISIT_IS_REMOTE);
+            visits[i] = visit;
+        }
+        return visits;
+    }
+
+    private ContentValues buildHistoryCV(String guid, String title, String url, Long lastVisited, Long remoteLastVisited, Integer visits) {
+        ContentValues cv = new ContentValues();
+        cv.put(BrowserContract.History.GUID, guid);
+        if (title != null) {
+            cv.put(BrowserContract.History.TITLE, title);
+        }
+        cv.put(BrowserContract.History.URL, url);
+        if (lastVisited != null) {
+            cv.put(BrowserContract.History.DATE_LAST_VISITED, lastVisited);
+        }
+        if (remoteLastVisited != null) {
+            cv.put(BrowserContract.History.REMOTE_DATE_LAST_VISITED, remoteLastVisited);
+        }
+        if (visits != null) {
+            cv.put(BrowserContract.History.VISITS, visits);
+            cv.put(BrowserContract.History.REMOTE_VISITS, visits);
+        }
+        return cv;
+    }
+
     private void assertHistoryAggregates(String selection, String[] selectionArg, int visits, int localVisits, long localLastVisited, int remoteVisits, long remoteLastVisited) throws Exception {
         final Cursor c = historyClient.query(historyTestUri, new String[] {
                 BrowserContract.History.VISITS,
                 BrowserContract.History.LOCAL_VISITS,
                 BrowserContract.History.REMOTE_VISITS,
                 BrowserContract.History.LOCAL_DATE_LAST_VISITED,
                 BrowserContract.History.REMOTE_DATE_LAST_VISITED
         }, selection, selectionArg, null);
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryVisitsTestBase.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/db/BrowserProviderHistoryVisitsTestBase.java
@@ -17,18 +17,17 @@ import org.robolectric.shadows.ShadowCon
 import java.util.UUID;
 
 public class BrowserProviderHistoryVisitsTestBase {
     /* package-private */ ShadowContentResolver contentResolver;
     /* package-private */ ContentProviderClient historyClient;
     /* package-private */ ContentProviderClient visitsClient;
     /* package-private */ Uri historyTestUri;
     /* package-private */ Uri visitsTestUri;
-
-    private BrowserProvider provider;
+    /* package-private */ BrowserProvider provider;
 
     @Before
     public void setUp() throws Exception {
         provider = new BrowserProvider();
         provider.onCreate();
         ShadowContentResolver.registerProvider(BrowserContract.AUTHORITY, new DelegatingTestContentProvider(provider));
 
         contentResolver = new ShadowContentResolver();
@@ -46,32 +45,39 @@ public class BrowserProviderHistoryVisit
         provider.shutdown();
     }
 
     /* package-private */  Uri testUri(Uri baseUri) {
         return baseUri.buildUpon().appendQueryParameter(BrowserContract.PARAM_IS_TEST, "1").build();
     }
 
     /* package-private */  Uri insertHistoryItem(String url, String guid) throws RemoteException {
-        return insertHistoryItem(url, guid, System.currentTimeMillis(), null, null);
+        return insertHistoryItem(url, guid, System.currentTimeMillis(), null, null, null);
     }
 
     /* package-private */  Uri insertHistoryItem(String url, String guid, Long lastVisited, Integer visitCount) throws RemoteException {
-        return insertHistoryItem(url, guid, lastVisited, visitCount, null);
+        return insertHistoryItem(url, guid, lastVisited, visitCount, null, null);
     }
 
     /* package-private */  Uri insertHistoryItem(String url, String guid, Long lastVisited, Integer visitCount, String title) throws RemoteException {
+        return insertHistoryItem(url, guid, lastVisited, visitCount, null, title);
+    }
+
+    /* package-private */  Uri insertHistoryItem(String url, String guid, Long lastVisited, Integer visitCount, Integer remoteVisits, String title) throws RemoteException {
         ContentValues historyItem = new ContentValues();
         historyItem.put(BrowserContract.History.URL, url);
         if (guid != null) {
             historyItem.put(BrowserContract.History.GUID, guid);
         }
         if (visitCount != null) {
             historyItem.put(BrowserContract.History.VISITS, visitCount);
         }
+        if (remoteVisits != null) {
+            historyItem.put(BrowserContract.History.REMOTE_VISITS, remoteVisits);
+        }
         historyItem.put(BrowserContract.History.DATE_LAST_VISITED, lastVisited);
         if (title != null) {
             historyItem.put(BrowserContract.History.TITLE, title);
         }
 
         return historyClient.insert(historyTestUri, historyItem);
     }
 }