Bug 1291821 - Remove total sync limits, refactor batching downloader r=rnewman draft
authorGrisha Kruglov <gkruglov@mozilla.com>
Thu, 20 Oct 2016 13:34:06 -0700
changeset 489489 1c795598a9c4d9c7538c740a89a8867dc32bdca1
parent 489488 0f48a86e5d6861ffc217bd8c69c154e37c6fd871
child 489490 74b0d4dc58c0cd2c1113253eee28ba783e114803
push id46825
push usergkruglov@mozilla.com
push dateFri, 24 Feb 2017 21:13:39 +0000
reviewersrnewman
bugs1291821
milestone54.0a1
Bug 1291821 - Remove total sync limits, refactor batching downloader r=rnewman MozReview-Commit-ID: 2SYxemfTv9V
mobile/android/base/android-services.mozbuild
mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ConstrainedServer11Repository.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Server11Repository.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Server11RepositorySession.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloader.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderDelegate.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AndroidBrowserBookmarksServerSyncStage.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AndroidBrowserHistoryServerSyncStage.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FormHistoryServerSyncStage.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SafeConstrainedServer11Repository.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestServer11Repository.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestServer11RepositorySession.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderDelegateTest.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderTest.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/test/TestSafeConstrainedServer11Repository.java
--- a/mobile/android/base/android-services.mozbuild
+++ b/mobile/android/base/android-services.mozbuild
@@ -1047,17 +1047,16 @@ sync_java_files = [TOPSRCDIR + '/mobile/
     'sync/stage/FennecTabsServerSyncStage.java',
     'sync/stage/FetchInfoCollectionsStage.java',
     'sync/stage/FetchInfoConfigurationStage.java',
     'sync/stage/FetchMetaGlobalStage.java',
     'sync/stage/FormHistoryServerSyncStage.java',
     'sync/stage/GlobalSyncStage.java',
     'sync/stage/NoSuchStageException.java',
     'sync/stage/PasswordsServerSyncStage.java',
-    'sync/stage/SafeConstrainedServer11Repository.java',
     'sync/stage/ServerSyncStage.java',
     'sync/stage/SyncClientsEngineStage.java',
     'sync/stage/UploadMetaGlobalStage.java',
     'sync/Sync11Configuration.java',
     'sync/SyncConfiguration.java',
     'sync/SyncConfigurationException.java',
     'sync/SyncConstants.java',
     'sync/SyncException.java',
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ConstrainedServer11Repository.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/ConstrainedServer11Repository.java
@@ -6,46 +6,55 @@ package org.mozilla.gecko.sync.repositor
 
 import java.net.URISyntaxException;
 
 import org.mozilla.gecko.sync.InfoCollections;
 import org.mozilla.gecko.sync.InfoConfiguration;
 import org.mozilla.gecko.sync.net.AuthHeaderProvider;
 
 /**
- * A kind of Server11Repository that supports explicit setting of total fetch limit, per-batch fetch limit, and a sort order.
+ * A kind of Server11Repository that supports explicit setting of per-batch fetch limit,
+ * batching mode (single batch vs multi-batch), and a sort order.
  *
  * @author rnewman
  *
  */
 public class ConstrainedServer11Repository extends Server11Repository {
-
-  private final String sort;
+  private final String sortOrder;
   private final long batchLimit;
-  private final long totalLimit;
+  private final boolean allowMultipleBatches;
 
-  public ConstrainedServer11Repository(String collection, String storageURL,
-                                       AuthHeaderProvider authHeaderProvider,
-                                       InfoCollections infoCollections,
-                                       InfoConfiguration infoConfiguration,
-                                       long batchLimit, long totalLimit, String sort)
-          throws URISyntaxException {
-    super(collection, storageURL, authHeaderProvider, infoCollections, infoConfiguration);
+  public ConstrainedServer11Repository(
+          String collection,
+          String storageURL,
+          AuthHeaderProvider authHeaderProvider,
+          InfoCollections infoCollections,
+          InfoConfiguration infoConfiguration,
+          long batchLimit,
+          String sort,
+          boolean allowMultipleBatches) throws URISyntaxException {
+    super(
+            collection,
+            storageURL,
+            authHeaderProvider,
+            infoCollections,
+            infoConfiguration
+    );
     this.batchLimit = batchLimit;
-    this.totalLimit = totalLimit;
-    this.sort  = sort;
+    this.sortOrder  = sort;
+    this.allowMultipleBatches = allowMultipleBatches;
   }
 
   @Override
-  public String getDefaultSort() {
-    return sort;
+  public String getSortOrder() {
+    return sortOrder;
   }
 
   @Override
-  public long getDefaultBatchLimit() {
+  public Long getBatchLimit() {
     return batchLimit;
   }
 
   @Override
-  public long getDefaultTotalLimit() {
-    return totalLimit;
+  public boolean getAllowMultipleBatches() {
+    return allowMultipleBatches;
   }
 }
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Server11Repository.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Server11Repository.java
@@ -1,52 +1,60 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.sync.repositories;
 
 import java.net.URI;
 import java.net.URISyntaxException;
-import java.util.ArrayList;
 
 import org.mozilla.gecko.sync.InfoCollections;
 import org.mozilla.gecko.sync.InfoConfiguration;
-import org.mozilla.gecko.sync.Utils;
 import org.mozilla.gecko.sync.net.AuthHeaderProvider;
 import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
 
 import android.content.Context;
 import android.support.annotation.NonNull;
 import android.support.annotation.Nullable;
 
 /**
- * A Server11Repository implements fetching and storing against the Sync 1.1 API.
+ * A Server11Repository implements fetching and storing against the Sync 1.5 API.
  * It doesn't do crypto: that's the job of the middleware.
  *
  * @author rnewman
  */
 public class Server11Repository extends Repository {
-  protected String collection;
-  protected URI collectionURI;
-  protected final AuthHeaderProvider authHeaderProvider;
+  public final AuthHeaderProvider authHeaderProvider;
+
+  /* package-local */ final long syncDeadline;
+  /* package-local */ final URI collectionURI;
+
+  protected final String collection;
   protected final InfoCollections infoCollections;
 
   private final InfoConfiguration infoConfiguration;
+  private final static String DEFAULT_SORT_ORDER = "oldest";
+  private final static long DEFAULT_BATCH_LIMIT = 100;
 
   /**
-   * Construct a new repository that fetches and stores against the Sync 1.1. API.
+   * Construct a new repository that fetches and stores against the Sync 1.5 API.
    *
    * @param collection name.
    * @param storageURL full URL to storage endpoint.
    * @param authHeaderProvider to use in requests; may be null.
    * @param infoCollections instance; must not be null.
    * @throws URISyntaxException
    */
-  public Server11Repository(@NonNull String collection, @NonNull String storageURL, AuthHeaderProvider authHeaderProvider, @NonNull InfoCollections infoCollections, @NonNull InfoConfiguration infoConfiguration) throws URISyntaxException {
+  public Server11Repository(
+          @NonNull String collection,
+          @NonNull String storageURL,
+          AuthHeaderProvider authHeaderProvider,
+          @NonNull InfoCollections infoCollections,
+          @NonNull InfoConfiguration infoConfiguration) throws URISyntaxException {
     if (collection == null) {
       throw new IllegalArgumentException("collection must not be null");
     }
     if (storageURL == null) {
       throw new IllegalArgumentException("storageURL must not be null");
     }
     if (infoCollections == null) {
       throw new IllegalArgumentException("infoCollections must not be null");
@@ -63,82 +71,33 @@ public class Server11Repository extends 
                             Context context) {
     delegate.onSessionCreated(new Server11RepositorySession(this));
   }
 
   public URI collectionURI() {
     return this.collectionURI;
   }
 
-  public URI collectionURI(boolean full, long newer, long limit, String sort, String ids, String offset) throws URISyntaxException {
-    ArrayList<String> params = new ArrayList<String>();
-    if (full) {
-      params.add("full=1");
-    }
-    if (newer >= 0) {
-      // Translate local millisecond timestamps into server decimal seconds.
-      String newerString = Utils.millisecondsToDecimalSecondsString(newer);
-      params.add("newer=" + newerString);
-    }
-    if (limit > 0) {
-      params.add("limit=" + limit);
-    }
-    if (sort != null) {
-      params.add("sort=" + sort);       // We trust these values.
-    }
-    if (ids != null) {
-      params.add("ids=" + ids);         // We trust these values.
-    }
-    if (offset != null) {
-      // Offset comes straight out of HTTP headers and it is the responsibility of the caller to URI-escape it.
-      params.add("offset=" + offset);
-    }
-    if (params.size() == 0) {
-      return this.collectionURI;
-    }
-
-    StringBuilder out = new StringBuilder();
-    char indicator = '?';
-    for (String param : params) {
-      out.append(indicator);
-      indicator = '&';
-      out.append(param);
-    }
-    String uri = this.collectionURI + out.toString();
-    return new URI(uri);
-  }
-
-  public URI wboURI(String id) throws URISyntaxException {
-    return new URI(this.collectionURI + "/" + id);
-  }
-
-  // Override these.
-  @SuppressWarnings("static-method")
-  public long getDefaultBatchLimit() {
-    return -1;
-  }
-
-  @SuppressWarnings("static-method")
-  public String getDefaultSort() {
-    return null;
-  }
-
-  public long getDefaultTotalLimit() {
-    return -1;
-  }
-
-  public AuthHeaderProvider getAuthHeaderProvider() {
-    return authHeaderProvider;
-  }
-
-  public boolean updateNeeded(long lastSyncTimestamp) {
+  /* package-local */ boolean updateNeeded(long lastSyncTimestamp) {
     return infoCollections.updateNeeded(collection, lastSyncTimestamp);
   }
 
   @Nullable
-  public Long getCollectionLastModified() {
+  /* package-local */ Long getCollectionLastModified() {
     return infoCollections.getTimestamp(collection);
   }
 
   public InfoConfiguration getInfoConfiguration() {
     return infoConfiguration;
   }
+
+  public String getSortOrder() {
+    return DEFAULT_SORT_ORDER;
+  }
+
+  public Long getBatchLimit() {
+    return DEFAULT_BATCH_LIMIT;
+  }
+
+  public boolean getAllowMultipleBatches() {
+    return true;
+  }
 }
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Server11RepositorySession.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/Server11RepositorySession.java
@@ -11,30 +11,26 @@ import org.mozilla.gecko.sync.repositori
 import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionGuidsSinceDelegate;
 import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate;
 import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate;
 import org.mozilla.gecko.sync.repositories.domain.Record;
 import org.mozilla.gecko.sync.repositories.downloaders.BatchingDownloader;
 import org.mozilla.gecko.sync.repositories.uploaders.BatchingUploader;
 
 public class Server11RepositorySession extends RepositorySession {
-  public static final String LOG_TAG = "Server11Session";
+  public static final String LOG_TAG = "Server11RepositorySession";
 
-  Server11Repository serverRepository;
+  protected final Server11Repository serverRepository;
   private BatchingUploader uploader;
   private final BatchingDownloader downloader;
 
   public Server11RepositorySession(Repository repository) {
     super(repository);
-    serverRepository = (Server11Repository) repository;
-    this.downloader = new BatchingDownloader(serverRepository, this);
-  }
-
-  public Server11Repository getServerRepository() {
-    return serverRepository;
+    this.serverRepository = (Server11Repository) repository;
+    this.downloader = initializeDownloader(this);
   }
 
   @Override
   public void setStoreDelegate(RepositorySessionStoreDelegate storeDelegate) {
     super.setStoreDelegate(storeDelegate);
 
     // Now that we have the delegate, we can initialize our uploader.
     this.uploader = new BatchingUploader(
@@ -46,19 +42,24 @@ public class Server11RepositorySession e
   @Override
   public void guidsSince(long timestamp,
                          RepositorySessionGuidsSinceDelegate delegate) {
     // TODO Auto-generated method stub
 
   }
 
   @Override
-  public void fetchSince(long timestamp,
+  public void fetchSince(long sinceTimestamp,
                          RepositorySessionFetchRecordsDelegate delegate) {
-    this.downloader.fetchSince(timestamp, delegate);
+    this.downloader.fetchSince(
+            delegate,
+            sinceTimestamp,
+            serverRepository.getBatchLimit(),
+            serverRepository.getSortOrder()
+    );
   }
 
   @Override
   public void fetchAll(RepositorySessionFetchRecordsDelegate delegate) {
     this.fetchSince(-1, delegate);
   }
 
   @Override
@@ -101,9 +102,17 @@ public class Server11RepositorySession e
 
     uploader.noMoreRecordsToUpload();
   }
 
   @Override
   public boolean dataAvailable() {
     return serverRepository.updateNeeded(getLastSyncTimestamp());
   }
+
+  protected static BatchingDownloader initializeDownloader(final Server11RepositorySession serverRepositorySession) {
+    return new BatchingDownloader(
+            serverRepositorySession.serverRepository.authHeaderProvider,
+            Uri.parse(serverRepositorySession.serverRepository.collectionURI().toString()),
+            serverRepositorySession.serverRepository.getAllowMultipleBatches(),
+            serverRepositorySession);
+  }
 }
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloader.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloader.java
@@ -1,74 +1,86 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.sync.repositories.downloaders;
 
+import android.net.Uri;
+import android.os.SystemClock;
 import android.support.annotation.Nullable;
 import android.support.annotation.VisibleForTesting;
 
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.sync.CryptoRecord;
 import org.mozilla.gecko.sync.DelayedWorkTracker;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.net.AuthHeaderProvider;
 import org.mozilla.gecko.sync.net.SyncResponse;
 import org.mozilla.gecko.sync.net.SyncStorageCollectionRequest;
 import org.mozilla.gecko.sync.net.SyncStorageResponse;
-import org.mozilla.gecko.sync.repositories.Server11Repository;
-import org.mozilla.gecko.sync.repositories.Server11RepositorySession;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
 import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate;
 
 import java.io.UnsupportedEncodingException;
 import java.net.URI;
 import java.net.URISyntaxException;
-import java.net.URLEncoder;
 import java.util.Collections;
+import java.util.ConcurrentModificationException;
 import java.util.HashSet;
 import java.util.Set;
+import java.util.concurrent.TimeUnit;
 
 /**
- * Batching Downloader, which implements batching protocol as supported by Sync 1.5.
+ * Batching Downloader implements batching protocol as supported by Sync 1.5.
  *
  * Downloader's batching behaviour is configured via two parameters, obtained from the repository:
  * - Per-batch limit, which specified how many records may be fetched in an individual GET request.
- * - Total limit, which controls number of batch GET requests we will make.
- *
+ * - allowMultipleBatches, which determines if downloader is allowed to perform more than one fetch.
  *
  * Batching is implemented via specifying a 'limit' GET parameter, and looking for an 'offset' token
  * in the response. If offset token is present, this indicates that there are more records than what
- * we've received so far, and we perform an additional fetch. Batching stops when either we hit a total
- * limit, or offset token is no longer present (indicating that we're done).
+ * we've received so far, and we perform an additional fetch, if we're allowed to do so by our
+ * configuration. Batching stops when offset token is no longer present (indicating that we're done).
  *
- * For unlimited repositories (such as passwords), both of these value will be -1. Downloader will not
- * specify a limit parameter in this case, and the response will contain every record available and no
- * offset token, thus fully completing in one go.
+ * If we are not allowed to perform multiple batches, we consider batching to be succesfully complete
+ * after fist fetch request succeeds. Similarly, a trivial case of collection having less records than
+ * the batch limit will also successfully complete in one fetch.
  *
- * In between batches, we maintain a Last-Modified timestamp, based off the value return in the header
+ * In between batches, we maintain a Last-Modified timestamp, based off the value returned in the header
  * of the first response. Every response will have a Last-Modified header, indicating when the collection
  * was modified last. We pass along this header in our subsequent requests in a X-If-Unmodified-Since
  * header. Server will ensure that our collection did not change while we are batching, if it did it will
- * fail our fetch with a 412 (Consequent Modification) error. Additionally, we perform the same checks
- * locally.
+ * fail our fetch with a 412 error. Additionally, we perform the same checks locally.
  */
 public class BatchingDownloader {
     public static final String LOG_TAG = "BatchingDownloader";
+    private static final String DEFAULT_SORT_ORDER = "index";
 
-    protected final Server11Repository repository;
-    private final Server11RepositorySession repositorySession;
+    private final RepositorySession repositorySession;
     private final DelayedWorkTracker workTracker = new DelayedWorkTracker();
+    private final Uri baseCollectionUri;
+    private final boolean allowMultipleBatches;
+
+    /* package-local */ final AuthHeaderProvider authHeaderProvider;
+
     // Used to track outstanding requests, so that we can abort them as needed.
     @VisibleForTesting
     protected final Set<SyncStorageCollectionRequest> pending = Collections.synchronizedSet(new HashSet<SyncStorageCollectionRequest>());
     /* @GuardedBy("this") */ private String lastModified;
-    /* @GuardedBy("this") */ private long numRecords = 0;
 
-    public BatchingDownloader(final Server11Repository repository, final Server11RepositorySession repositorySession) {
-        this.repository = repository;
+    public BatchingDownloader(
+            AuthHeaderProvider authHeaderProvider,
+            Uri baseCollectionUri,
+            boolean allowMultipleBatches,
+            RepositorySession repositorySession) {
         this.repositorySession = repositorySession;
+        this.authHeaderProvider = authHeaderProvider;
+        this.baseCollectionUri = baseCollectionUri;
+        this.allowMultipleBatches = allowMultipleBatches;
     }
 
     @VisibleForTesting
     protected static String flattenIDs(String[] guids) {
         // Consider using Utils.toDelimitedString if and when the signature changes
         // to Collection<String> guids.
         if (guids.length == 0) {
             return "";
@@ -89,165 +101,141 @@ public class BatchingDownloader {
     protected void fetchWithParameters(long newer,
                                     long batchLimit,
                                     boolean full,
                                     String sort,
                                     String ids,
                                     SyncStorageCollectionRequest request,
                                     RepositorySessionFetchRecordsDelegate fetchRecordsDelegate)
             throws URISyntaxException, UnsupportedEncodingException {
-        if (batchLimit > repository.getDefaultTotalLimit()) {
-            throw new IllegalArgumentException("Batch limit should not be greater than total limit");
-        }
-
         request.delegate = new BatchingDownloaderDelegate(this, fetchRecordsDelegate, request,
                 newer, batchLimit, full, sort, ids);
         this.pending.add(request);
         request.get();
     }
 
     @VisibleForTesting
-    @Nullable
-    protected String encodeParam(String param) throws UnsupportedEncodingException {
-        if (param != null) {
-            return URLEncoder.encode(param, "UTF-8");
-        }
-        return null;
-    }
-
-    @VisibleForTesting
     protected SyncStorageCollectionRequest makeSyncStorageCollectionRequest(long newer,
                                                   long batchLimit,
                                                   boolean full,
                                                   String sort,
                                                   String ids,
                                                   String offset)
             throws URISyntaxException, UnsupportedEncodingException {
-        URI collectionURI = repository.collectionURI(full, newer, batchLimit, sort, ids, encodeParam(offset));
+        final URI collectionURI = buildCollectionURI(baseCollectionUri, full, newer, batchLimit, sort, ids, offset);
         Logger.debug(LOG_TAG, collectionURI.toString());
 
         return new SyncStorageCollectionRequest(collectionURI);
     }
 
-    public void fetchSince(long timestamp, RepositorySessionFetchRecordsDelegate fetchRecordsDelegate) {
-        this.fetchSince(timestamp, null, fetchRecordsDelegate);
+    public void fetchSince(RepositorySessionFetchRecordsDelegate fetchRecordsDelegate, long timestamp, long batchLimit, String sortOrder) {
+        this.fetchSince(fetchRecordsDelegate, timestamp, batchLimit, sortOrder, null);
     }
 
-    private void fetchSince(long timestamp, String offset,
-                           RepositorySessionFetchRecordsDelegate fetchRecordsDelegate) {
-        long batchLimit = repository.getDefaultBatchLimit();
-        String sort = repository.getDefaultSort();
-
+    @VisibleForTesting
+    public void fetchSince(RepositorySessionFetchRecordsDelegate fetchRecordsDelegate, long timestamp, long batchLimit, String sortOrder, String offset) {
         try {
             SyncStorageCollectionRequest request = makeSyncStorageCollectionRequest(timestamp,
-                    batchLimit, true, sort, null, offset);
-            this.fetchWithParameters(timestamp, batchLimit, true, sort, null, request, fetchRecordsDelegate);
+                    batchLimit, true, sortOrder, null, offset);
+            this.fetchWithParameters(timestamp, batchLimit, true, sortOrder, null, request, fetchRecordsDelegate);
         } catch (URISyntaxException | UnsupportedEncodingException e) {
             fetchRecordsDelegate.onFetchFailed(e);
         }
     }
 
     public void fetch(String[] guids, RepositorySessionFetchRecordsDelegate fetchRecordsDelegate) {
         String ids = flattenIDs(guids);
-        String index = "index";
 
         try {
             SyncStorageCollectionRequest request = makeSyncStorageCollectionRequest(
-                    -1, -1, true, index, ids, null);
-            this.fetchWithParameters(-1, -1, true, index, ids, request, fetchRecordsDelegate);
+                    -1, -1, true, DEFAULT_SORT_ORDER, ids, null);
+            this.fetchWithParameters(-1, -1, true, DEFAULT_SORT_ORDER, ids, request, fetchRecordsDelegate);
         } catch (URISyntaxException | UnsupportedEncodingException e) {
             fetchRecordsDelegate.onFetchFailed(e);
         }
     }
 
-    public Server11Repository getServerRepository() {
-        return this.repository;
-    }
-
     public void onFetchCompleted(SyncStorageResponse response,
                                  final RepositorySessionFetchRecordsDelegate fetchRecordsDelegate,
                                  final SyncStorageCollectionRequest request, long newer,
                                  long limit, boolean full, String sort, String ids) {
         removeRequestFromPending(request);
 
-        // When we process our first request, we get back a X-Last-Modified header indicating when collection was modified last.
-        // We pass it to the server with every subsequent request (if we need to make more) as the X-If-Unmodified-Since header,
-        // and server is supposed to ensure that this pre-condition is met, and fail our request with a 412 error code otherwise.
-        // So, if all of this happens, these checks should never fail.
-        // However, we also track this header in client side, and can defensively validate against it here as well.
+        // When we process our first request, we get back a X-Last-Modified header indicating when
+        // collection was modified last. We pass it to the server with every subsequent request
+        // (if we need to make more) as the X-If-Unmodified-Since header, and server is supposed to
+        // ensure that this pre-condition is met, and fail our request with a 412 error code otherwise.
+        // So, if all of this happens, these checks should never fail. However, we also track this
+        // header on the client side, and can defensively validate against it here as well.
+
+        // This value won't be null, since we check for this in the delegate.
         final String currentLastModifiedTimestamp = response.lastModified();
         Logger.debug(LOG_TAG, "Last modified timestamp " + currentLastModifiedTimestamp);
 
-        // Sanity check. We also did a null check in delegate before passing it into here.
-        if (currentLastModifiedTimestamp == null) {
-            this.abort(fetchRecordsDelegate, "Last modified timestamp is missing");
-            return;
-        }
-
         final boolean lastModifiedChanged;
         synchronized (this) {
             if (this.lastModified == null) {
                 // First time seeing last modified timestamp.
                 this.lastModified = currentLastModifiedTimestamp;
             }
             lastModifiedChanged = !this.lastModified.equals(currentLastModifiedTimestamp);
         }
 
+        // We expected server to fail our request with 412 in case of concurrent modifications, so
+        // this is unexpected. However, let's treat this case just as if we received a 412.
         if (lastModifiedChanged) {
-            this.abort(fetchRecordsDelegate, "Last modified timestamp has changed unexpectedly");
+            this.abort(
+                    fetchRecordsDelegate,
+                    new ConcurrentModificationException("Last-modified timestamp has changed unexpectedly")
+            );
             return;
         }
 
-        final boolean hasNotReachedLimit;
-        synchronized (this) {
-            this.numRecords += response.weaveRecords();
-            hasNotReachedLimit = this.numRecords < repository.getDefaultTotalLimit();
+        // If we can (or must) stop batching at this point, let the delegate know that we're all done!
+        final String offset = response.weaveOffset();
+        if (offset == null || !allowMultipleBatches) {
+            final long normalizedTimestamp = response.normalizedTimestampForHeader(SyncResponse.X_LAST_MODIFIED);
+            Logger.debug(LOG_TAG, "Fetch completed. Timestamp is " + normalizedTimestamp);
+
+            this.workTracker.delayWorkItem(new Runnable() {
+                @Override
+                public void run() {
+                    Logger.debug(LOG_TAG, "Delayed onFetchCompleted running.");
+                    fetchRecordsDelegate.onFetchCompleted(normalizedTimestamp);
+                }
+            });
+            return;
         }
 
-        final String offset = response.weaveOffset();
-        final SyncStorageCollectionRequest newRequest;
+        // We need to make another batching request!
+        // Let the delegate know that a batch fetch just completed before we proceed.
+        // This operation needs to run after every call to onFetchedRecord for this batch has been
+        // processed, hence the delayWorkItem call.
+        this.workTracker.delayWorkItem(new Runnable() {
+            @Override
+            public void run() {
+                Logger.debug(LOG_TAG, "Running onBatchCompleted.");
+                fetchRecordsDelegate.onBatchCompleted();
+            }
+        });
+
+        // Create and execute new batch request.
         try {
-            newRequest = makeSyncStorageCollectionRequest(newer,
+            final SyncStorageCollectionRequest newRequest = makeSyncStorageCollectionRequest(newer,
                     limit, full, sort, ids, offset);
+            this.fetchWithParameters(newer, limit, full, sort, ids, newRequest, fetchRecordsDelegate);
         } catch (final URISyntaxException | UnsupportedEncodingException e) {
             this.workTracker.delayWorkItem(new Runnable() {
                 @Override
                 public void run() {
                     Logger.debug(LOG_TAG, "Delayed onFetchCompleted running.");
                     fetchRecordsDelegate.onFetchFailed(e);
                 }
             });
-            return;
         }
-
-        if (offset != null && hasNotReachedLimit) {
-            try {
-                this.fetchWithParameters(newer, limit, full, sort, ids, newRequest, fetchRecordsDelegate);
-            } catch (final URISyntaxException | UnsupportedEncodingException e) {
-                this.workTracker.delayWorkItem(new Runnable() {
-                    @Override
-                    public void run() {
-                        Logger.debug(LOG_TAG, "Delayed onFetchCompleted running.");
-                        fetchRecordsDelegate.onFetchFailed(e, null);
-                    }
-                });
-            }
-            return;
-        }
-
-        final long normalizedTimestamp = response.normalizedTimestampForHeader(SyncResponse.X_LAST_MODIFIED);
-        Logger.debug(LOG_TAG, "Fetch completed. Timestamp is " + normalizedTimestamp);
-
-        this.workTracker.delayWorkItem(new Runnable() {
-            @Override
-            public void run() {
-                Logger.debug(LOG_TAG, "Delayed onFetchCompleted running.");
-                fetchRecordsDelegate.onFetchCompleted(normalizedTimestamp);
-            }
-        });
     }
 
     public void onFetchFailed(final Exception ex,
                               final RepositorySessionFetchRecordsDelegate fetchRecordsDelegate,
                               final SyncStorageCollectionRequest request) {
         removeRequestFromPending(request);
         this.workTracker.delayWorkItem(new Runnable() {
             @Override
@@ -289,22 +277,49 @@ public class BatchingDownloader {
         }
     }
 
     @Nullable
     protected synchronized String getLastModified() {
         return this.lastModified;
     }
 
-    private void abort(final RepositorySessionFetchRecordsDelegate delegate, final String msg) {
-        Logger.error(LOG_TAG, msg);
+    private void abort(final RepositorySessionFetchRecordsDelegate delegate, final Exception exception) {
+        Logger.error(LOG_TAG, exception.getMessage());
         this.abortRequests();
         this.workTracker.delayWorkItem(new Runnable() {
             @Override
             public void run() {
                 Logger.debug(LOG_TAG, "Delayed onFetchCompleted running.");
-                delegate.onFetchFailed(
-                        new IllegalStateException(msg),
-                        null);
+                delegate.onFetchFailed(exception);
             }
         });
     }
+    @VisibleForTesting
+    public static URI buildCollectionURI(Uri baseCollectionUri, boolean full, long newer, long limit, String sort, String ids, String offset) throws URISyntaxException {
+        Uri.Builder uriBuilder = baseCollectionUri.buildUpon();
+
+        if (full) {
+            uriBuilder.appendQueryParameter("full", "1");
+        }
+
+        if (newer >= 0) {
+            // Translate local millisecond timestamps into server decimal seconds.
+            String newerString = Utils.millisecondsToDecimalSecondsString(newer);
+            uriBuilder.appendQueryParameter("newer", newerString);
+        }
+        if (limit > 0) {
+            uriBuilder.appendQueryParameter("limit", Long.toString(limit));
+        }
+        if (sort != null) {
+            uriBuilder.appendQueryParameter("sort", sort); // We trust these values.
+        }
+        if (ids != null) {
+            uriBuilder.appendQueryParameter("ids", ids); // We trust these values.
+        }
+        if (offset != null) {
+            // Offset comes straight out of HTTP headers and it is the responsibility of the caller to URI-escape it.
+            uriBuilder.appendQueryParameter("offset", offset);
+        }
+
+        return new URI(uriBuilder.build().toString());
+    }
 }
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderDelegate.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderDelegate.java
@@ -9,31 +9,34 @@ import org.mozilla.gecko.sync.CryptoReco
 import org.mozilla.gecko.sync.HTTPFailureException;
 import org.mozilla.gecko.sync.crypto.KeyBundle;
 import org.mozilla.gecko.sync.net.AuthHeaderProvider;
 import org.mozilla.gecko.sync.net.SyncStorageCollectionRequest;
 import org.mozilla.gecko.sync.net.SyncStorageResponse;
 import org.mozilla.gecko.sync.net.WBOCollectionRequestDelegate;
 import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate;
 
+
+import java.util.ConcurrentModificationException;
+
 /**
  * Delegate that gets passed into fetch methods to handle server response from fetch.
  */
 public class BatchingDownloaderDelegate extends WBOCollectionRequestDelegate {
     public static final String LOG_TAG = "BatchingDownloaderDelegate";
 
-    private BatchingDownloader downloader;
-    private RepositorySessionFetchRecordsDelegate fetchRecordsDelegate;
-    public SyncStorageCollectionRequest request;
+    private final BatchingDownloader downloader;
+    private final RepositorySessionFetchRecordsDelegate fetchRecordsDelegate;
+    public final SyncStorageCollectionRequest request;
     // Used to pass back to BatchDownloader to start another fetch with these parameters if needed.
-    private long newer;
-    private long batchLimit;
-    private boolean full;
-    private String sort;
-    private String ids;
+    private final long newer;
+    private final long batchLimit;
+    private final boolean full;
+    private final String sort;
+    private final String ids;
 
     public BatchingDownloaderDelegate(final BatchingDownloader downloader,
                                       final RepositorySessionFetchRecordsDelegate fetchRecordsDelegate,
                                       final SyncStorageCollectionRequest request, long newer,
                                       long batchLimit, boolean full, String sort, String ids) {
         this.downloader = downloader;
         this.fetchRecordsDelegate = fetchRecordsDelegate;
         this.request = request;
@@ -41,17 +44,17 @@ public class BatchingDownloaderDelegate 
         this.batchLimit = batchLimit;
         this.full = full;
         this.sort = sort;
         this.ids = ids;
     }
 
     @Override
     public AuthHeaderProvider getAuthHeaderProvider() {
-        return this.downloader.getServerRepository().getAuthHeaderProvider();
+        return this.downloader.authHeaderProvider;
     }
 
     @Override
     public String ifUnmodifiedSince() {
         return this.downloader.getLastModified();
     }
 
     @Override
@@ -65,17 +68,28 @@ public class BatchingDownloaderDelegate 
         this.downloader.onFetchFailed(
                 new IllegalStateException("Missing last modified header from response"),
                 this.fetchRecordsDelegate,
                 this.request);
     }
 
     @Override
     public void handleRequestFailure(SyncStorageResponse response) {
-        this.handleRequestError(new HTTPFailureException(response));
+        Logger.warn(LOG_TAG, "Got a non-success response.");
+        // Handle concurrent modification errors separately. We will need to signal upwards that
+        // this happened, in case stage buffer will want to clean up.
+        if (response.getStatusCode() == 412) {
+            this.downloader.onFetchFailed(
+                    new ConcurrentModificationException(),
+                    this.fetchRecordsDelegate,
+                    this.request
+            );
+        } else {
+            this.handleRequestError(new HTTPFailureException(response));
+        }
     }
 
     @Override
     public void handleRequestError(final Exception ex) {
         Logger.warn(LOG_TAG, "Got request error.", ex);
         this.downloader.onFetchFailed(ex, this.fetchRecordsDelegate, this.request);
     }
 
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AndroidBrowserBookmarksServerSyncStage.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AndroidBrowserBookmarksServerSyncStage.java
@@ -1,35 +1,31 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.sync.stage;
 
 import java.net.URISyntaxException;
 
-import org.mozilla.gecko.sync.JSONRecordFetcher;
 import org.mozilla.gecko.sync.MetaGlobalException;
-import org.mozilla.gecko.sync.net.AuthHeaderProvider;
+import org.mozilla.gecko.sync.repositories.ConstrainedServer11Repository;
 import org.mozilla.gecko.sync.repositories.RecordFactory;
 import org.mozilla.gecko.sync.repositories.Repository;
 import org.mozilla.gecko.sync.repositories.android.AndroidBrowserBookmarksRepository;
 import org.mozilla.gecko.sync.repositories.domain.BookmarkRecordFactory;
 import org.mozilla.gecko.sync.repositories.domain.VersionConstants;
 
 public class AndroidBrowserBookmarksServerSyncStage extends ServerSyncStage {
   protected static final String LOG_TAG = "BookmarksStage";
 
   // Eventually this kind of sync stage will be data-driven,
   // and all this hard-coding can go away.
-  private static final String BOOKMARKS_SORT          = "index";
-  // Sanity limit. Batch and total limit are the same for now, and will be adjusted
-  // once buffer and high water mark are in place. See Bug 730142.
+  private static final String BOOKMARKS_SORT = "oldest";
   private static final long BOOKMARKS_BATCH_LIMIT = 5000;
-  private static final long BOOKMARKS_TOTAL_LIMIT = 5000;
 
   @Override
   protected String getCollection() {
     return "bookmarks";
   }
 
   @Override
   protected String getEngineName() {
@@ -38,31 +34,25 @@ public class AndroidBrowserBookmarksServ
 
   @Override
   public Integer getStorageVersion() {
     return VersionConstants.BOOKMARKS_ENGINE_VERSION;
   }
 
   @Override
   protected Repository getRemoteRepository() throws URISyntaxException {
-    // If this is a first sync, we need to check server counts to make sure that we aren't
-    // going to screw up. SafeConstrainedServer11Repository does this. See Bug 814331.
-    AuthHeaderProvider authHeaderProvider = session.getAuthHeaderProvider();
-    final JSONRecordFetcher countsFetcher = new JSONRecordFetcher(session.config.infoCollectionCountsURL(), authHeaderProvider);
-    String collection = getCollection();
-    return new SafeConstrainedServer11Repository(
-        collection,
-        session.config.storageURL(),
-        session.getAuthHeaderProvider(),
-        session.config.infoCollections,
-        session.config.infoConfiguration,
-        BOOKMARKS_BATCH_LIMIT,
-        BOOKMARKS_TOTAL_LIMIT,
-        BOOKMARKS_SORT,
-        countsFetcher);
+    return new ConstrainedServer11Repository(
+            getCollection(),
+            session.config.storageURL(),
+            session.getAuthHeaderProvider(),
+            session.config.infoCollections,
+            session.config.infoConfiguration,
+            BOOKMARKS_BATCH_LIMIT,
+            BOOKMARKS_SORT,
+            true /* allow multiple batches */);
   }
 
   @Override
   protected Repository getLocalRepository() {
     return new AndroidBrowserBookmarksRepository();
   }
 
   @Override
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AndroidBrowserHistoryServerSyncStage.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AndroidBrowserHistoryServerSyncStage.java
@@ -14,21 +14,18 @@ import org.mozilla.gecko.sync.repositori
 import org.mozilla.gecko.sync.repositories.domain.HistoryRecordFactory;
 import org.mozilla.gecko.sync.repositories.domain.VersionConstants;
 
 public class AndroidBrowserHistoryServerSyncStage extends ServerSyncStage {
   protected static final String LOG_TAG = "HistoryStage";
 
   // Eventually this kind of sync stage will be data-driven,
   // and all this hard-coding can go away.
-  private static final String HISTORY_SORT          = "index";
-  // Sanity limit. Batch and total limit are the same for now, and will be adjusted
-  // once buffer and high water mark are in place. See Bug 730142.
-  private static final long HISTORY_BATCH_LIMIT = 250;
-  private static final long HISTORY_TOTAL_LIMIT = 250;
+  private static final String HISTORY_SORT = "oldest";
+  private static final long HISTORY_BATCH_LIMIT = 500;
 
   @Override
   protected String getCollection() {
     return "history";
   }
 
   @Override
   protected String getEngineName() {
@@ -42,26 +39,25 @@ public class AndroidBrowserHistoryServer
 
   @Override
   protected Repository getLocalRepository() {
     return new AndroidBrowserHistoryRepository();
   }
 
   @Override
   protected Repository getRemoteRepository() throws URISyntaxException {
-    String collection = getCollection();
     return new ConstrainedServer11Repository(
-                                             collection,
-                                             session.config.storageURL(),
-                                             session.getAuthHeaderProvider(),
-                                             session.config.infoCollections,
-                                             session.config.infoConfiguration,
-                                             HISTORY_BATCH_LIMIT,
-                                             HISTORY_TOTAL_LIMIT,
-                                             HISTORY_SORT);
+            getCollection(),
+            session.config.storageURL(),
+            session.getAuthHeaderProvider(),
+            session.config.infoCollections,
+            session.config.infoConfiguration,
+            HISTORY_BATCH_LIMIT,
+            HISTORY_SORT,
+            true /* allow multiple batches */);
   }
 
   @Override
   protected RecordFactory getRecordFactory() {
     return new HistoryRecordFactory();
   }
 
   @Override
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FormHistoryServerSyncStage.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/FormHistoryServerSyncStage.java
@@ -14,21 +14,18 @@ import org.mozilla.gecko.sync.repositori
 import org.mozilla.gecko.sync.repositories.domain.FormHistoryRecord;
 import org.mozilla.gecko.sync.repositories.domain.Record;
 import org.mozilla.gecko.sync.repositories.domain.VersionConstants;
 
 public class FormHistoryServerSyncStage extends ServerSyncStage {
 
   // Eventually this kind of sync stage will be data-driven,
   // and all this hard-coding can go away.
-  private static final String FORM_HISTORY_SORT          = "index";
-  // Sanity limit. Batch and total limit are the same for now, and will be adjusted
-  // once buffer and high water mark are in place. See Bug 730142.
+  private static final String FORM_HISTORY_SORT = "oldest";
   private static final long FORM_HISTORY_BATCH_LIMIT = 5000;
-  private static final long FORM_HISTORY_TOTAL_LIMIT = 5000;
 
   @Override
   protected String getCollection() {
     return "forms";
   }
 
   @Override
   protected String getEngineName() {
@@ -39,24 +36,25 @@ public class FormHistoryServerSyncStage 
   public Integer getStorageVersion() {
     return VersionConstants.FORMS_ENGINE_VERSION;
   }
 
   @Override
   protected Repository getRemoteRepository() throws URISyntaxException {
     String collection = getCollection();
     return new ConstrainedServer11Repository(
-        collection,
-        session.config.storageURL(),
-        session.getAuthHeaderProvider(),
-        session.config.infoCollections,
-        session.config.infoConfiguration,
-        FORM_HISTORY_BATCH_LIMIT,
-        FORM_HISTORY_TOTAL_LIMIT,
-        FORM_HISTORY_SORT);
+            collection,
+            session.getSyncDeadline(),
+            session.config.storageURL(),
+            session.getAuthHeaderProvider(),
+            session.config.infoCollections,
+            session.config.infoConfiguration,
+            FORM_HISTORY_BATCH_LIMIT,
+            FORM_HISTORY_SORT,
+            true /* allow multiple batches */);
   }
 
   @Override
   protected Repository getLocalRepository() {
     return new FormHistoryRepositorySession.FormHistoryRepository();
   }
 
   public class FormHistoryRecordFactory extends RecordFactory {
deleted file mode 100644
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SafeConstrainedServer11Repository.java
+++ /dev/null
@@ -1,110 +0,0 @@
-/* This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
-
-package org.mozilla.gecko.sync.stage;
-
-import java.net.URISyntaxException;
-
-import org.mozilla.gecko.background.common.log.Logger;
-import org.mozilla.gecko.sync.InfoCollections;
-import org.mozilla.gecko.sync.InfoConfiguration;
-import org.mozilla.gecko.sync.InfoCounts;
-import org.mozilla.gecko.sync.JSONRecordFetcher;
-import org.mozilla.gecko.sync.net.AuthHeaderProvider;
-import org.mozilla.gecko.sync.repositories.ConstrainedServer11Repository;
-import org.mozilla.gecko.sync.repositories.Repository;
-import org.mozilla.gecko.sync.repositories.Server11RepositorySession;
-import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
-
-import android.content.Context;
-
-/**
- * This is a constrained repository -- one which fetches a limited number
- * of records -- that additionally refuses to sync if the limit will
- * be exceeded on a first sync by the number of records on the server.
- *
- * You must pass an {@link InfoCounts} instance, which will be interrogated
- * in the event of a first sync.
- *
- * "First sync" means that our sync timestamp is not greater than zero.
- */
-public class SafeConstrainedServer11Repository extends ConstrainedServer11Repository {
-
-  // This can be lazily evaluated if we need it.
-  private final JSONRecordFetcher countFetcher;
-
-  public SafeConstrainedServer11Repository(String collection,
-                                           String storageURL,
-                                           AuthHeaderProvider authHeaderProvider,
-                                           InfoCollections infoCollections,
-                                           InfoConfiguration infoConfiguration,
-                                           long batchLimit,
-                                           long totalLimit,
-                                           String sort,
-                                           JSONRecordFetcher countFetcher)
-    throws URISyntaxException {
-    super(collection, storageURL, authHeaderProvider, infoCollections, infoConfiguration,
-            batchLimit, totalLimit, sort);
-    if (countFetcher == null) {
-      throw new IllegalArgumentException("countFetcher must not be null");
-    }
-    this.countFetcher = countFetcher;
-  }
-
-  @Override
-  public void createSession(RepositorySessionCreationDelegate delegate,
-                            Context context) {
-    delegate.onSessionCreated(new CountCheckingServer11RepositorySession(this, this.getDefaultBatchLimit()));
-  }
-
-  public class CountCheckingServer11RepositorySession extends Server11RepositorySession {
-    private static final String LOG_TAG = "CountCheckingServer11RepositorySession";
-
-    /**
-     * The session will report no data available if this is a first sync
-     * and the server has more data available than this limit.
-     */
-    private final long fetchLimit;
-
-    public CountCheckingServer11RepositorySession(Repository repository, long fetchLimit) {
-      super(repository);
-      this.fetchLimit = fetchLimit;
-    }
-
-    @Override
-    public boolean shouldSkip() {
-      // If this is a first sync, verify that we aren't going to blow through our limit.
-      final long lastSyncTimestamp = getLastSyncTimestamp();
-      if (lastSyncTimestamp > 0) {
-        Logger.info(LOG_TAG, "Collection " + collection + " has already had a first sync: " +
-            "timestamp is " + lastSyncTimestamp  + "; " +
-            "ignoring any updated counts and syncing as usual.");
-      } else {
-        Logger.info(LOG_TAG, "Collection " + collection + " is starting a first sync; checking counts.");
-
-        final InfoCounts counts;
-        try {
-          // This'll probably be the same object, but best to obey the API.
-          counts = new InfoCounts(countFetcher.fetchBlocking());
-        } catch (Exception e) {
-          Logger.warn(LOG_TAG, "Skipping " + collection + " until we can fetch counts.", e);
-          return true;
-        }
-
-        Integer c = counts.getCount(collection);
-        if (c == null) {
-          Logger.info(LOG_TAG, "Fetched counts does not include collection " + collection + "; syncing as usual.");
-          return false;
-        }
-
-        Logger.info(LOG_TAG, "First sync for " + collection + ": " + c + " items.");
-        if (c > fetchLimit) {
-          Logger.warn(LOG_TAG, "Too many items to sync safely. Skipping.");
-          return true;
-        }
-      }
-      return super.shouldSkip();
-    }
-  }
-}
--- a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestServer11Repository.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestServer11Repository.java
@@ -22,27 +22,16 @@ public class TestServer11Repository {
 
   protected final InfoCollections infoCollections = new InfoCollections();
   protected final InfoConfiguration infoConfiguration = new InfoConfiguration();
 
   public static void assertQueryEquals(String expected, URI u) {
     Assert.assertEquals(expected, u.getRawQuery());
   }
 
-  @SuppressWarnings("static-method")
-  @Test
-  public void testCollectionURIFull() throws URISyntaxException {
-    Server11Repository r = new Server11Repository(COLLECTION, COLLECTION_URL, null, infoCollections, infoConfiguration);
-    assertQueryEquals("full=1&newer=5000.000",              r.collectionURI(true,  5000000L, -1,    null, null, null));
-    assertQueryEquals("newer=1230.000",                     r.collectionURI(false, 1230000L, -1,    null, null, null));
-    assertQueryEquals("newer=5000.000&limit=10",            r.collectionURI(false, 5000000L, 10,    null, null, null));
-    assertQueryEquals("full=1&newer=5000.000&sort=index",   r.collectionURI(true,  5000000L,  0, "index", null, null));
-    assertQueryEquals("full=1&ids=123,abc",                 r.collectionURI(true,       -1L, -1,    null, "123,abc", null));
-  }
-
   @Test
   public void testCollectionURI() throws URISyntaxException {
     Server11Repository noTrailingSlash = new Server11Repository(COLLECTION, COLLECTION_URL, null, infoCollections, infoConfiguration);
     Server11Repository trailingSlash = new Server11Repository(COLLECTION, COLLECTION_URL + "/", null, infoCollections, infoConfiguration);
     Assert.assertEquals("http://foo.com/1.1/n6ec3u5bee3tixzp2asys7bs6fve4jfw/storage/bookmarks", noTrailingSlash.collectionURI().toASCIIString());
     Assert.assertEquals("http://foo.com/1.1/n6ec3u5bee3tixzp2asys7bs6fve4jfw/storage/bookmarks", trailingSlash.collectionURI().toASCIIString());
   }
 }
--- a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestServer11RepositorySession.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/TestServer11RepositorySession.java
@@ -170,67 +170,9 @@ public class TestServer11RepositorySessi
         return;
       }
     };
 
     Exception e = doSynchronize(server);
     assertNotNull(e);
     assertEquals(StoreFailedException.class, e.getClass());
   }
-
-  @Test
-  public void testConstraints() throws Exception {
-    MockServer server = new MockServer() {
-      @Override
-      public void handle(Request request, Response response) {
-        if (request.getMethod().equals("GET")) {
-          if (request.getPath().getPath().endsWith("/info/collection_counts")) {
-            this.handle(request, response, 200, "{\"bookmarks\": 5001}");
-          }
-        }
-        this.handle(request, response, 400, "NOOOO");
-      }
-    };
-    final JSONRecordFetcher countsFetcher = new JSONRecordFetcher(LOCAL_COUNTS_URL, getAuthHeaderProvider());
-    String collection = "bookmarks";
-    final SafeConstrainedServer11Repository remote = new SafeConstrainedServer11Repository(collection,
-        getCollectionURL(collection),
-        getAuthHeaderProvider(),
-        infoCollections,
-        infoConfiguration,
-        5000, 5000, "sortindex", countsFetcher);
-
-    data.startHTTPServer(server);
-    final AtomicBoolean out = new AtomicBoolean(false);
-
-    // Verify that shouldSkip returns true due to a fetch of too large counts,
-    // rather than due to a timeout failure waiting to fetch counts.
-    try {
-      WaitHelper.getTestWaiter().performWait(
-          SHORT_TIMEOUT,
-          new Runnable() {
-            @Override
-            public void run() {
-              remote.createSession(new RepositorySessionCreationDelegate() {
-                @Override
-                public void onSessionCreated(RepositorySession session) {
-                  out.set(session.shouldSkip());
-                  WaitHelper.getTestWaiter().performNotify();
-                }
-
-                @Override
-                public void onSessionCreateFailed(Exception ex) {
-                  WaitHelper.getTestWaiter().performNotify(ex);
-                }
-
-                @Override
-                public RepositorySessionCreationDelegate deferredCreationDelegate() {
-                  return this;
-                }
-              }, null);
-            }
-          });
-      assertTrue(out.get());
-    } finally {
-      data.stopHTTPServer();
-    }
-  }
 }
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderDelegateTest.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderDelegateTest.java
@@ -1,55 +1,65 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.sync.repositories.downloaders;
 
+import android.net.Uri;
+import android.os.SystemClock;
+
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mozilla.gecko.background.testhelpers.TestRunner;
 import org.mozilla.gecko.sync.CryptoRecord;
 import org.mozilla.gecko.sync.HTTPFailureException;
 import org.mozilla.gecko.sync.InfoCollections;
 import org.mozilla.gecko.sync.InfoConfiguration;
 import org.mozilla.gecko.sync.net.SyncResponse;
 import org.mozilla.gecko.sync.net.SyncStorageCollectionRequest;
 import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.mozilla.gecko.sync.repositories.RepositorySession;
+import org.mozilla.gecko.sync.repositories.Server11RepositorySession;
 import org.mozilla.gecko.sync.repositories.Server11Repository;
-import org.mozilla.gecko.sync.repositories.Server11RepositorySession;
 import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate;
 import org.mozilla.gecko.sync.repositories.domain.Record;
 
 import java.net.URI;
 import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
 
 import ch.boye.httpclientandroidlib.ProtocolVersion;
 import ch.boye.httpclientandroidlib.client.ClientProtocolException;
 import ch.boye.httpclientandroidlib.message.BasicHttpResponse;
 import ch.boye.httpclientandroidlib.message.BasicStatusLine;
 
 import static org.junit.Assert.*;
 
 @RunWith(TestRunner.class)
 public class BatchingDownloaderDelegateTest {
-    private Server11Repository server11Repository;
     private Server11RepositorySession repositorySession;
     private MockDownloader mockDownloader;
     private String DEFAULT_COLLECTION_URL = "http://dummy.url/";
 
     class MockDownloader extends BatchingDownloader {
         public boolean isSuccess = false;
         public boolean isFetched = false;
         public boolean isFailure = false;
         public Exception ex;
 
-        public MockDownloader(Server11Repository repository, Server11RepositorySession repositorySession) {
-            super(repository, repositorySession);
+        public MockDownloader(RepositorySession repositorySession) {
+            super(
+                    null,
+                    Uri.EMPTY,
+                    SystemClock.elapsedRealtime() + TimeUnit.MINUTES.toMillis(30),
+                    true,
+                    repositorySession
+            );
         }
 
         @Override
         public void onFetchCompleted(SyncStorageResponse response,
                                      final RepositorySessionFetchRecordsDelegate fetchRecordsDelegate,
                                      final SyncStorageCollectionRequest request,
                                      long l, long newerTimestamp, boolean full, String sort, String ids) {
             this.isSuccess = true;
@@ -67,17 +77,17 @@ public class BatchingDownloaderDelegateT
         public void onFetchedRecord(CryptoRecord record,
                                     RepositorySessionFetchRecordsDelegate fetchRecordsDelegate) {
             this.isFetched = true;
         }
     }
 
     class SimpleSessionFetchRecordsDelegate implements RepositorySessionFetchRecordsDelegate {
         @Override
-        public void onFetchFailed(Exception ex, Record record) {
+        public void onFetchFailed(Exception ex) {
 
         }
 
         @Override
         public void onFetchedRecord(Record record) {
 
         }
 
@@ -94,29 +104,36 @@ public class BatchingDownloaderDelegateT
         @Override
         public RepositorySessionFetchRecordsDelegate deferredFetchDelegate(ExecutorService executor) {
             return null;
         }
     }
 
     @Before
     public void setUp() throws Exception {
-        server11Repository = new Server11Repository(
+        repositorySession = new Server11RepositorySession(new Server11Repository(
                 "dummyCollection",
+                SystemClock.elapsedRealtime() + TimeUnit.MINUTES.toMillis(30),
                 DEFAULT_COLLECTION_URL,
                 null,
                 new InfoCollections(),
-                new InfoConfiguration());
-        repositorySession = new Server11RepositorySession(server11Repository);
-        mockDownloader = new MockDownloader(server11Repository, repositorySession);
+                new InfoConfiguration())
+        );
+        mockDownloader = new MockDownloader(repositorySession);
     }
 
     @Test
     public void testIfUnmodifiedSince() throws Exception {
-        BatchingDownloader downloader = new BatchingDownloader(server11Repository, repositorySession);
+        BatchingDownloader downloader = new BatchingDownloader(
+                null,
+                Uri.EMPTY,
+                SystemClock.elapsedRealtime() + TimeUnit.MINUTES.toMillis(30),
+                true,
+                repositorySession
+        );
         RepositorySessionFetchRecordsDelegate delegate = new SimpleSessionFetchRecordsDelegate();
         BatchingDownloaderDelegate downloaderDelegate = new BatchingDownloaderDelegate(downloader, delegate,
                 new SyncStorageCollectionRequest(new URI(DEFAULT_COLLECTION_URL)), 0, 0, true, null, null);
         String lastModified = "12345678";
         SyncStorageResponse response = makeSyncStorageResponse(200, lastModified);
         downloaderDelegate.handleRequestSuccess(response);
         assertEquals(lastModified, downloaderDelegate.ifUnmodifiedSince());
     }
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderTest.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/downloaders/BatchingDownloaderTest.java
@@ -464,34 +464,16 @@ public class BatchingDownloaderTest {
         assertNotEquals(lmHeader, mockDownloader.getLastModified());
         assertTrue(mockDownloader.abort);
         assertFalse(sessionFetchRecordsDelegate.isSuccess);
         assertFalse(sessionFetchRecordsDelegate.isFetched);
         assertTrue(sessionFetchRecordsDelegate.isFailure);
     }
 
     @Test
-    public void testFailureMissingLMMultiBatch() throws Exception {
-        assertNull(mockDownloader.getLastModified());
-
-        String offsetHeader = "100";
-        SyncStorageResponse response = makeSyncStorageResponse(200, null, offsetHeader, null);
-        SyncStorageCollectionRequest request = new SyncStorageCollectionRequest(new URI("http://dummy.url"));
-        mockDownloader.onFetchCompleted(response, sessionFetchRecordsDelegate, request, DEFAULT_NEWER,
-                1, true, DEFAULT_SORT, DEFAULT_IDS);
-
-        // Last modified header somehow missing from response.
-        assertNull(null, mockDownloader.getLastModified());
-        assertTrue(mockDownloader.abort);
-        assertFalse(sessionFetchRecordsDelegate.isSuccess);
-        assertFalse(sessionFetchRecordsDelegate.isFetched);
-        assertTrue(sessionFetchRecordsDelegate.isFailure);
-    }
-
-    @Test
     public void testFailureException() throws Exception {
         Exception ex = new IllegalStateException();
         SyncStorageCollectionRequest request = new SyncStorageCollectionRequest(new URI("http://dummy.url"));
         mockDownloader.onFetchFailed(ex, sessionFetchRecordsDelegate, request);
 
         assertFalse(sessionFetchRecordsDelegate.isSuccess);
         assertFalse(sessionFetchRecordsDelegate.isFetched);
         assertTrue(sessionFetchRecordsDelegate.isFailure);
deleted file mode 100644
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/test/TestSafeConstrainedServer11Repository.java
+++ /dev/null
@@ -1,144 +0,0 @@
-/* Any copyright is dedicated to the Public Domain.
-   http://creativecommons.org/publicdomain/zero/1.0/ */
-
-package org.mozilla.gecko.sync.repositories.test;
-
-import org.junit.Assert;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mozilla.android.sync.test.helpers.HTTPServerTestHelper;
-import org.mozilla.android.sync.test.helpers.MockServer;
-import org.mozilla.gecko.background.testhelpers.TestRunner;
-import org.mozilla.gecko.background.testhelpers.WaitHelper;
-import org.mozilla.gecko.sync.InfoCollections;
-import org.mozilla.gecko.sync.InfoConfiguration;
-import org.mozilla.gecko.sync.JSONRecordFetcher;
-import org.mozilla.gecko.sync.net.AuthHeaderProvider;
-import org.mozilla.gecko.sync.repositories.RepositorySession;
-import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
-import org.mozilla.gecko.sync.stage.SafeConstrainedServer11Repository;
-import org.simpleframework.http.Request;
-import org.simpleframework.http.Response;
-
-import java.net.URISyntaxException;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicInteger;
-
-@RunWith(TestRunner.class)
-public class TestSafeConstrainedServer11Repository {
-  private static final int     TEST_PORT      = HTTPServerTestHelper.getTestPort();
-  private static final String  TEST_SERVER    = "http://localhost:" + TEST_PORT + "/";
-  private static final String  TEST_USERNAME  = "c6o7dvmr2c4ud2fyv6woz2u4zi22bcyd";
-  private static final String  TEST_BASE_PATH = "/1.1/" + TEST_USERNAME + "/";
-
-  public AuthHeaderProvider getAuthHeaderProvider() {
-    return null;
-  }
-
-  protected final InfoCollections infoCollections = new InfoCollections();
-  protected final InfoConfiguration infoConfiguration = new InfoConfiguration();
-
-  private class CountsMockServer extends MockServer {
-    public final AtomicInteger count = new AtomicInteger(0);
-    public final AtomicBoolean error = new AtomicBoolean(false);
-
-    @Override
-    public void handle(Request request, Response response) {
-      final String path = request.getPath().getPath();
-      if (error.get()) {
-        super.handle(request, response, 503, "Unavailable.");
-        return;
-      }
-
-      if (!path.startsWith(TEST_BASE_PATH)) {
-        super.handle(request, response, 404, "Not Found");
-        return;
-      }
-
-      if (path.endsWith("info/collections")) {
-        super.handle(request, response, 200, "{\"rotary\": 123456789}");
-        return;
-      }
-
-      if (path.endsWith("info/collection_counts")) {
-        super.handle(request, response, 200, "{\"rotary\": " + count.get() + "}");
-        return;
-      }
-
-      super.handle(request, response);
-    }
-  }
-
-  /**
-   * Ensure that a {@link SafeConstrainedServer11Repository} will advise
-   * skipping if the reported collection counts are higher than its limit.
-   */
-  @Test
-  public void testShouldSkip() throws URISyntaxException {
-    final HTTPServerTestHelper data = new HTTPServerTestHelper();
-    final CountsMockServer server = new CountsMockServer();
-    data.startHTTPServer(server);
-
-    try {
-      String countsURL = TEST_SERVER + TEST_BASE_PATH + "info/collection_counts";
-      JSONRecordFetcher countFetcher = new JSONRecordFetcher(countsURL, getAuthHeaderProvider());
-      String sort = "sortindex";
-      String collection = "rotary";
-
-      final int TEST_LIMIT = 1000;
-      final SafeConstrainedServer11Repository repo = new SafeConstrainedServer11Repository(
-          collection, getCollectionURL(collection), null, infoCollections, infoConfiguration,
-          TEST_LIMIT, TEST_LIMIT, sort, countFetcher);
-
-      final AtomicBoolean shouldSkipLots = new AtomicBoolean(false);
-      final AtomicBoolean shouldSkipFew = new AtomicBoolean(true);
-      final AtomicBoolean shouldSkip503 = new AtomicBoolean (false);
-
-      WaitHelper.getTestWaiter().performWait(2000, new Runnable() {
-        @Override
-        public void run() {
-          repo.createSession(new RepositorySessionCreationDelegate() {
-            @Override
-            public void onSessionCreated(RepositorySession session) {
-              // Try with too many items.
-              server.count.set(TEST_LIMIT + 1);
-              shouldSkipLots.set(session.shouldSkip());
-
-              // … and few enough that we should sync.
-              server.count.set(TEST_LIMIT - 1);
-              shouldSkipFew.set(session.shouldSkip());
-
-              // Now try with an error response. We advise skipping if we can't
-              // fetch counts, because we'll try again later.
-              server.error.set(true);
-              shouldSkip503.set(session.shouldSkip());
-
-              WaitHelper.getTestWaiter().performNotify();
-            }
-
-            @Override
-            public void onSessionCreateFailed(Exception ex) {
-              WaitHelper.getTestWaiter().performNotify(ex);
-            }
-
-            @Override
-            public RepositorySessionCreationDelegate deferredCreationDelegate() {
-              return this;
-            }
-          }, null);
-        }
-      });
-
-      Assert.assertTrue(shouldSkipLots.get());
-      Assert.assertFalse(shouldSkipFew.get());
-      Assert.assertTrue(shouldSkip503.get());
-
-    } finally {
-      data.stopHTTPServer();
-    }
-  }
-
-  protected String getCollectionURL(String collection) {
-    return TEST_BASE_PATH + "/storage/" + collection;
-  }
-}