Bug 1509066 - Sync on RemoteSettings.get() when local db is empty r=glasserc
authorMathieu Leplatre <mathieu@mozilla.com>
Thu, 29 Nov 2018 17:35:20 +0000
changeset 508183 dfe85e4f62c30675e0da05456e2a0b2eb473957c
parent 508182 e9eedbf1bd4b6ed0bdd07118ae9447570270af79
child 508184 cd370c35d88d82c719e7b3d70ae1da003c8cb7f6
push id1905
push userffxbld-merge
push dateMon, 21 Jan 2019 12:33:13 +0000
treeherdermozilla-release@c2fca1944d8c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersglasserc
bugs1509066
milestone65.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 1509066 - Sync on RemoteSettings.get() when local db is empty r=glasserc Differential Revision: https://phabricator.services.mozilla.com/D13099
services/common/docs/RemoteSettings.rst
services/common/tests/unit/test_blocklist_certificates.js
services/common/tests/unit/test_blocklist_clients.js
services/common/tests/unit/test_blocklist_pinning.js
services/common/tests/unit/test_blocklist_signatures.js
services/settings/RemoteSettingsClient.jsm
services/settings/Utils.jsm
services/settings/moz.build
services/settings/remote-settings.js
services/settings/test/unit/test_remote_settings.js
services/settings/test/unit/test_remote_settings_poll.js
--- a/services/common/docs/RemoteSettings.rst
+++ b/services/common/docs/RemoteSettings.rst
@@ -27,16 +27,21 @@ The ``get()`` method returns the list of
     */
 
     for(const entry of data) {
       // Do something with entry...
       // await InternalAPI.load(entry.id, entry.label, entry.weight);
     });
 
 .. note::
+    The data updates are managed internally, and ``.get()`` only returns the local data.
+    The data is pulled from the server only if this collection has no local data yet and no JSON dump
+    could be found (see :ref:`services/initial-data` below).
+
+.. note::
     The ``id`` and ``last_modified`` (timestamp) attributes are assigned by the server.
 
 Options
 -------
 
 The list can optionally be filtered or ordered:
 
 .. code-block:: js
@@ -84,28 +89,33 @@ When an entry has a file attached to it,
     data.filter(d => d.attachment)
         .forEach(async ({ attachment: { url, filename, size } }) => {
           if (size < OS.freeDiskSpace) {
             // Planned feature, see Bug 1501214
             await downloadLocally(url, filename);
           }
         });
 
+.. _services/initial-data:
+
 Initial data
 ------------
 
-For newly created user profiles, the list of entries returned by the ``.get()`` method will be empty until the first synchronization happens.
+It is possible to package a dump of the server records that will be loaded into the local database when no synchronization has happened yet.
 
-It is possible to package a dump of the server records that will be loaded into the local database when no synchronization has happened yet. It will thus serve as the default dataset and also reduce the amount of data to be downloaded on the first synchronization.
+The JSON dump will serve as the default dataset for ``.get()``, instead of doing a round-trip to pull the latest data. It will also reduce the amount of data to be downloaded on the first synchronization.
 
 #. Place the JSON dump of the server records in the ``services/settings/dumps/main/`` folder
 #. Add the filename to the ``FINAL_TARGET_FILES`` list in ``services/settings/dumps/main/moz.build``
 
 Now, when ``RemoteSettings("some-key").get()`` is called from an empty profile, the ``some-key.json`` file is going to be loaded before the results are returned.
 
+.. note::
+
+    JSON dumps are not shipped on Android to minimize the installer size.
 
 Targets and A/B testing
 =======================
 
 In order to deliver settings to subsets of the population, you can set targets on entries (platform, language, channel, version range, preferences values, samples, etc.) when editing records on the server.
 
 From the client API standpoint, this is completely transparent: the ``.get()`` method — as well as the event data — will always filter the entries on which the target matches.
 
@@ -165,24 +175,25 @@ Trigger a synchronization manually
 ----------------------------------
 
 The synchronization of every known remote settings clients can be triggered manually with ``pollChanges()``:
 
 .. code-block:: js
 
     await RemoteSettings.pollChanges()
 
-The synchronization of a single client can be forced with ``maybeSync()``:
+The synchronization of a single client can be forced with the ``.sync()`` method:
 
 .. code-block:: js
 
-    const fakeTimestamp = Infinity;
-    const fakeServerTime = Date.now();
+    await RemoteSettings("a-key").sync();
 
-    await RemoteSettings("a-key").maybeSync(fakeTimestamp, fakeServerTime)
+.. important::
+
+    The above methods are only relevant during development or debugging and should never be called in production code.
 
 
 Manipulate local data
 ---------------------
 
 A handle on the local collection can be obtained with ``openCollection()``.
 
 .. code-block:: js
--- a/services/common/tests/unit/test_blocklist_certificates.js
+++ b/services/common/tests/unit/test_blocklist_certificates.js
@@ -46,79 +46,70 @@ add_task(async function test_something()
     } catch (e) {
       info(e);
     }
   }
   server.registerPathHandler(configPath, handleResponse);
   server.registerPathHandler(recordsPath, handleResponse);
 
   // Test an empty db populates
-  await OneCRLBlocklistClient.maybeSync(2000, Date.now());
+  await OneCRLBlocklistClient.maybeSync(2000);
 
   // Open the collection, verify it's been populated:
   const list = await OneCRLBlocklistClient.get();
   // We know there will be initial values from the JSON dump.
   // (at least as many as in the dump shipped when this test was written).
   Assert.ok(list.length >= 363);
 
   // No sync will be intented if maybeSync() is up-to-date.
   Services.prefs.clearUserPref("services.settings.server");
   Services.prefs.setIntPref("services.blocklist.onecrl.checked", 0);
   // Use any last_modified older than highest shipped in JSON dump.
-  await OneCRLBlocklistClient.maybeSync(123456, Date.now());
-  // Last check value was updated.
-  Assert.notEqual(0, Services.prefs.getIntPref("services.blocklist.onecrl.checked"));
+  await OneCRLBlocklistClient.maybeSync(123456);
 
   // Restore server pref.
   Services.prefs.setCharPref("services.settings.server", dummyServerURL);
 
   // clear the collection, save a non-zero lastModified so we don't do
   // import of initial data when we sync again.
   const collection = await OneCRLBlocklistClient.openCollection();
   await collection.clear();
   // a lastModified value of 1000 means we get a remote collection with a
   // single record
   await collection.db.saveLastModified(1000);
 
-  await OneCRLBlocklistClient.maybeSync(2000, Date.now());
+  await OneCRLBlocklistClient.maybeSync(2000);
 
   // Open the collection, verify it's been updated:
   // Our test data now has two records; both should be in the local collection
   const before = await OneCRLBlocklistClient.get();
   Assert.equal(before.length, 1);
 
   // Test the db is updated when we call again with a later lastModified value
-  await OneCRLBlocklistClient.maybeSync(4000, Date.now());
+  await OneCRLBlocklistClient.maybeSync(4000);
 
   // Open the collection, verify it's been updated:
   // Our test data now has two records; both should be in the local collection
   const after = await OneCRLBlocklistClient.get();
   Assert.equal(after.length, 3);
 
   // Try to maybeSync with the current lastModified value - no connection
   // should be attempted.
   // Clear the kinto base pref so any connections will cause a test failure
   Services.prefs.clearUserPref("services.settings.server");
-  await OneCRLBlocklistClient.maybeSync(4000, Date.now());
+  await OneCRLBlocklistClient.maybeSync(4000);
 
   // Try again with a lastModified value at some point in the past
-  await OneCRLBlocklistClient.maybeSync(3000, Date.now());
-
-  // Check the OneCRL check time pref is modified, even if the collection
-  // hasn't changed
-  Services.prefs.setIntPref("services.blocklist.onecrl.checked", 0);
-  await OneCRLBlocklistClient.maybeSync(3000, Date.now());
-  let newValue = Services.prefs.getIntPref("services.blocklist.onecrl.checked");
-  Assert.notEqual(newValue, 0);
+  await OneCRLBlocklistClient.maybeSync(3000);
 
   // Check that a sync completes even when there's bad data in the
   // collection. This will throw on fail, so just calling maybeSync is an
   // acceptible test.
   Services.prefs.setCharPref("services.settings.server", dummyServerURL);
-  await OneCRLBlocklistClient.maybeSync(5000, Date.now());
+  await OneCRLBlocklistClient.maybeSync(5000);
 });
 
 function run_test() {
   // Ensure that signature verification is disabled to prevent interference
   // with basic certificate sync tests
   Services.prefs.setBoolPref("services.settings.verify_signature", false);
 
   // Set up an HTTP Server
--- a/services/common/tests/unit/test_blocklist_clients.js
+++ b/services/common/tests/unit/test_blocklist_clients.js
@@ -97,17 +97,17 @@ add_task(async function test_initial_dum
 
   for (let {client} of gBlocklistClients) {
     if (IS_ANDROID && client.collectionName != BlocklistClients.AddonBlocklistClient.collectionName) {
       // On Android we don't ship the dumps of plugins and gfx.
       continue;
     }
 
     // Test an empty db populates, but don't reach server (specified timestamp <= dump).
-    await client.maybeSync(1, Date.now());
+    await client.maybeSync(1);
 
     // Verify the loaded data has status to synced:
     const collection = await client.openCollection();
     const { data: list } = await collection.list();
     equal(list[0]._status, "synced");
 
     // Verify that the internal timestamp was updated.
     const timestamp = await collection.db.getLastModified();
@@ -129,55 +129,40 @@ add_task(async function test_initial_dum
 
     // Calling .get() will load the dump.
     const afterLoaded = await client.get();
     ok(afterLoaded.length > 0, `Loaded dump of ${client.collectionName} has ${afterLoaded.length} records`);
   }
 });
 add_task(clear_state);
 
-add_task(async function test_current_server_time_is_saved_in_pref() {
-  for (let {client} of gBlocklistClients) {
-    // The lastCheckTimePref was customized:
-    ok(/services\.blocklist\.(\w+)\.checked/.test(client.lastCheckTimePref), client.lastCheckTimePref);
-
-    const serverTime = Date.now();
-    await client.maybeSync(3000, serverTime);
-    const after = Services.prefs.getIntPref(client.lastCheckTimePref);
-    equal(after, Math.round(serverTime / 1000));
-  }
-});
-add_task(clear_state);
-
 add_task(async function test_sync_event_data_is_filtered_for_target() {
   // Here we will synchronize 4 times, the first two to initialize the local DB and
   // the last two about event filtered data.
   const timestamp1 = 3000;
   const timestamp2 = 3001;
   const timestamp3 = 4001;
   const timestamp4 = 5001;
-  // Fake a date value obtained from server (used to store a pref, useless here).
-  const fakeServerTime = Date.now();
 
   for (let {client} of gBlocklistClients) {
     // Initialize the collection with some data (local is empty, thus no ?_since)
-    await client.maybeSync(timestamp1, fakeServerTime - 30, {loadDump: false});
+    await client.maybeSync(timestamp1, {loadDump: false});
     // This will pick the data with ?_since=3000.
-    await client.maybeSync(timestamp2, fakeServerTime - 20);
+    await client.maybeSync(timestamp2);
 
     // In ?_since=4000 entries, no target matches. The sync event is not called.
     let called = false;
     client.on("sync", e => called = true);
-    await client.maybeSync(timestamp3, fakeServerTime - 10);
+    await client.maybeSync(timestamp3);
     equal(called, false, `shouldn't have sync event for ${client.collectionName}`);
 
     // In ?_since=5000 entries, only one entry matches.
     let syncEventData;
     client.on("sync", e => syncEventData = e.data);
-    await client.maybeSync(timestamp4, fakeServerTime);
+    await client.maybeSync(timestamp4);
     const { current, created, updated, deleted } = syncEventData;
     equal(created.length + updated.length + deleted.length, 1, `event filtered data for ${client.collectionName}`);
 
     // Since we had entries whose target does not match, the internal storage list
     // and the event current data should differ.
     const collection = await client.openCollection();
     const { data: internalData } = await collection.list();
     ok(internalData.length > current.length, `event current data for ${client.collectionName}`);
--- a/services/common/tests/unit/test_blocklist_pinning.js
+++ b/services/common/tests/unit/test_blocklist_pinning.js
@@ -75,29 +75,29 @@ add_task(async function test_something()
   ok(!sss.isSecureURI(sss.HEADER_HPKP,
                       Services.io.newURI("https://three.example.com"), 0));
   ok(!sss.isSecureURI(sss.HEADER_HSTS,
                       Services.io.newURI("https://four.example.com"), 0));
   ok(!sss.isSecureURI(sss.HEADER_HSTS,
                       Services.io.newURI("https://five.example.com"), 0));
 
   // Test an empty db populates
-  await PinningPreloadClient.maybeSync(2000, Date.now());
+  await PinningPreloadClient.maybeSync(2000);
 
   // Open the collection, verify it's been populated:
   // Our test data has a single record; it should be in the local collection
   const before = await PinningPreloadClient.get();
   Assert.equal(before.length, 1);
 
   // check that a pin exists for one.example.com
   ok(sss.isSecureURI(sss.HEADER_HPKP,
                      Services.io.newURI("https://one.example.com"), 0));
 
   // Test the db is updated when we call again with a later lastModified value
-  await PinningPreloadClient.maybeSync(4000, Date.now());
+  await PinningPreloadClient.maybeSync(4000);
 
   // Open the collection, verify it's been updated:
   // Our data now has four new records; all should be in the local collection
   const after = await PinningPreloadClient.get();
   Assert.equal(after.length, 5);
 
   // check that a pin exists for two.example.com and three.example.com
   ok(sss.isSecureURI(sss.HEADER_HPKP,
@@ -109,42 +109,35 @@ add_task(async function test_something()
   // collection but the version should not match
   ok(!sss.isSecureURI(sss.HEADER_HPKP,
                       Services.io.newURI("https://four.example.com"), 0));
 
   // Try to maybeSync with the current lastModified value - no connection
   // should be attempted.
   // Clear the kinto base pref so any connections will cause a test failure
   Services.prefs.clearUserPref("services.settings.server");
-  await PinningPreloadClient.maybeSync(4000, Date.now());
+  await PinningPreloadClient.maybeSync(4000);
 
   // Try again with a lastModified value at some point in the past
-  await PinningPreloadClient.maybeSync(3000, Date.now());
-
-  // Check the pinning check time pref is modified, even if the collection
-  // hasn't changed
-  Services.prefs.setIntPref("services.blocklist.onecrl.checked", 0);
-  await PinningPreloadClient.maybeSync(3000, Date.now());
-  let newValue = Services.prefs.getIntPref("services.blocklist.pinning.checked");
-  Assert.notEqual(newValue, 0);
+  await PinningPreloadClient.maybeSync(3000);
 
   // Check that the HSTS preload added to the collection works...
   ok(sss.isSecureURI(sss.HEADER_HSTS,
                      Services.io.newURI("https://five.example.com"), 0));
   // ...and that includeSubdomains is honored
   ok(!sss.isSecureURI(sss.HEADER_HSTS,
                       Services.io.newURI("https://subdomain.five.example.com"),
                       0));
 
   // Check that a sync completes even when there's bad data in the
   // collection. This will throw on fail, so just calling maybeSync is an
   // acceptible test (the data below with last_modified of 300 is nonsense).
   Services.prefs.setCharPref("services.settings.server",
                              `http://localhost:${server.identity.primaryPort}/v1`);
-  await PinningPreloadClient.maybeSync(5000, Date.now());
+  await PinningPreloadClient.maybeSync(5000);
 
   // The STS entry for five.example.com now has includeSubdomains set;
   // ensure that the new includeSubdomains value is honored.
   ok(sss.isSecureURI(sss.HEADER_HSTS,
                      Services.io.newURI("https://subdomain.five.example.com"),
                      0));
 });
 
--- a/services/common/tests/unit/test_blocklist_signatures.js
+++ b/services/common/tests/unit/test_blocklist_signatures.js
@@ -147,19 +147,16 @@ add_task(async function test_check_signa
   const collectionSignature = "p384ecdsa=f4pA2tYM5jQgWY6YUmhUwQiBLj6QO5sHLD_5MqLePz95qv-7cNCuQoZnPQwxoptDtW8hcWH3kLb0quR7SB-r82gkpR9POVofsnWJRA-ETb0BcIz6VvI3pDT49ZLlNg3p";
 
   ok(verifier.verifyContentSignature(collectionData, collectionSignature, getCertChain(), name));
 
   // set up prefs so the kinto updater talks to the test server
   Services.prefs.setCharPref(PREF_SETTINGS_SERVER,
     `http://localhost:${server.identity.primaryPort}/v1`);
 
-  // Set up some data we need for our test
-  let startTime = Date.now();
-
   // These are records we'll use in the test collections
   const RECORD1 = {
     details: {
       bug: "https://bugzilla.mozilla.org/show_bug.cgi?id=1155145",
       created: "2016-01-18T14:43:37Z",
       name: "GlobalSign certs",
       who: ".",
       why: ".",
@@ -293,17 +290,17 @@ add_task(async function test_check_signa
   // .. and use this map to register handlers for each path
   registerHandlers(emptyCollectionResponses);
 
   let startHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
 
   // With all of this set up, we attempt a sync. This will resolve if all is
   // well and throw if something goes wrong.
   // We don't want to load initial json dumps in this test suite.
-  await OneCRLBlocklistClient.maybeSync(1000, startTime, {loadDump: false});
+  await OneCRLBlocklistClient.maybeSync(1000, { loadDump: false });
 
   let endHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
 
   // ensure that a success histogram is tracked when a succesful sync occurs.
   let expectedIncrements = {[UptakeTelemetry.STATUS.SUCCESS]: 1};
   checkUptakeTelemetry(startHistogram, endHistogram, expectedIncrements);
 
 
@@ -331,17 +328,17 @@ add_task(async function test_check_signa
 
   const twoItemsResponses = {
     "GET:/v1/buckets/blocklists/collections/certificates/records?_expected=3000&_sort=-last_modified&_since=1000":
       [RESPONSE_TWO_ADDED],
     "GET:/v1/buckets/blocklists/collections/certificates?_expected=3000":
       [RESPONSE_META_TWO_ITEMS_SIG],
   };
   registerHandlers(twoItemsResponses);
-  await OneCRLBlocklistClient.maybeSync(3000, startTime);
+  await OneCRLBlocklistClient.maybeSync(3000);
 
 
   // Check the collection with one addition and one removal has a valid
   // signature
 
   // Remove RECORD1, add RECORD3
   const RESPONSE_ONE_ADDED_ONE_REMOVED = {
     comment: "RESPONSE_ONE_ADDED_ONE_REMOVED ",
@@ -363,17 +360,17 @@ add_task(async function test_check_signa
 
   const oneAddedOneRemovedResponses = {
     "GET:/v1/buckets/blocklists/collections/certificates/records?_expected=4000&_sort=-last_modified&_since=3000":
       [RESPONSE_ONE_ADDED_ONE_REMOVED],
     "GET:/v1/buckets/blocklists/collections/certificates?_expected=4000":
       [RESPONSE_META_THREE_ITEMS_SIG],
   };
   registerHandlers(oneAddedOneRemovedResponses);
-  await OneCRLBlocklistClient.maybeSync(4000, startTime);
+  await OneCRLBlocklistClient.maybeSync(4000);
 
   // Check the signature is still valid with no operation (no changes)
 
   // Leave the collection unchanged
   const RESPONSE_EMPTY_NO_UPDATE = {
     comment: "RESPONSE_EMPTY_NO_UPDATE ",
     sampleHeaders: [
       "Content-Type: application/json; charset=UTF-8",
@@ -385,17 +382,17 @@ add_task(async function test_check_signa
 
   const noOpResponses = {
     "GET:/v1/buckets/blocklists/collections/certificates/records?_expected=4100&_sort=-last_modified&_since=4000":
       [RESPONSE_EMPTY_NO_UPDATE],
     "GET:/v1/buckets/blocklists/collections/certificates?_expected=4100":
       [RESPONSE_META_THREE_ITEMS_SIG],
   };
   registerHandlers(noOpResponses);
-  await OneCRLBlocklistClient.maybeSync(4100, startTime);
+  await OneCRLBlocklistClient.maybeSync(4100);
 
 
   // Check the collection is reset when the signature is invalid
 
   // Prepare a (deliberately) bad signature to check the collection state is
   // reset if something is inconsistent
   const RESPONSE_COMPLETE_INITIAL = {
     comment: "RESPONSE_COMPLETE_INITIAL ",
@@ -446,17 +443,17 @@ add_task(async function test_check_signa
 
   registerHandlers(badSigGoodSigResponses);
 
   startHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
 
   let syncEventSent = false;
   OneCRLBlocklistClient.on("sync", ({ data }) => { syncEventSent = true; });
 
-  await OneCRLBlocklistClient.maybeSync(5000, startTime);
+  await OneCRLBlocklistClient.maybeSync(5000);
 
   endHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
 
   // since we only fixed the signature, and no data was changed, the sync event
   // was not sent.
   equal(syncEventSent, false);
 
   // ensure that the failure count is incremented for a succesful sync with an
@@ -487,17 +484,17 @@ add_task(async function test_check_signa
   // ensure our collection hasn't been replaced with an older, empty one
   await checkRecordCount(OneCRLBlocklistClient, 2);
 
   registerHandlers(badSigGoodOldResponses);
 
   syncEventSent = false;
   OneCRLBlocklistClient.on("sync", ({ data }) => { syncEventSent = true; });
 
-  await OneCRLBlocklistClient.maybeSync(5000, startTime);
+  await OneCRLBlocklistClient.maybeSync(5000);
 
   // Local data was unchanged, since it was never than the one returned by the server,
   // thus the sync event is not sent.
   equal(syncEventSent, false);
 
   const badLocalContentGoodSigResponses = {
     // In this test, we deliberately serve a bad signature initially. The
     // subsequent signature returned is a valid one for the three item
@@ -523,17 +520,17 @@ add_task(async function test_check_signa
   await kintoCol.clear();
   await kintoCol.create({ ...RECORD2, last_modified: 1234567890, serialNumber: "abc" }, { synced: true, useRecordId: true });
   const localId = "0602b1b2-12ab-4d3a-b6fb-593244e7b035";
   await kintoCol.create({ id: localId }, { synced: true, useRecordId: true });
 
   let syncData;
   OneCRLBlocklistClient.on("sync", ({ data }) => { syncData = data; });
 
-  await OneCRLBlocklistClient.maybeSync(5000, startTime, { loadDump: false });
+  await OneCRLBlocklistClient.maybeSync(5000, { loadDump: false });
 
   // Local data was unchanged, since it was never than the one returned by the server.
   equal(syncData.current.length, 2);
   equal(syncData.created.length, 1);
   equal(syncData.created[0].id, RECORD3.id);
   equal(syncData.updated.length, 1);
   equal(syncData.updated[0].old.serialNumber, "abc");
   equal(syncData.updated[0].new.serialNumber, RECORD2.serialNumber);
@@ -554,17 +551,17 @@ add_task(async function test_check_signa
     // checked against the valid signature - so the sync should succeed.
     "GET:/v1/buckets/blocklists/collections/certificates/records?_expected=6000&_sort=id":
       [RESPONSE_COMPLETE_INITIAL_SORTED_BY_ID],
   };
 
   startHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
   registerHandlers(allBadSigResponses);
   try {
-    await OneCRLBlocklistClient.maybeSync(6000, startTime);
+    await OneCRLBlocklistClient.maybeSync(6000);
     do_throw("Sync should fail (the signature is intentionally bad)");
   } catch (e) {
     await checkRecordCount(OneCRLBlocklistClient, 2);
   }
 
   // Ensure that the failure is reflected in the accumulated telemetry:
   endHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
   expectedIncrements = {[UptakeTelemetry.STATUS.SIGNATURE_RETRY_ERROR]: 1};
@@ -576,17 +573,17 @@ add_task(async function test_check_signa
     // As if the collection was not signed.
     "GET:/v1/buckets/blocklists/collections/certificates?_expected=6000":
       [RESPONSE_META_NO_SIG],
   };
 
   startHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
   registerHandlers(missingSigResponses);
   try {
-    await OneCRLBlocklistClient.maybeSync(6000, startTime);
+    await OneCRLBlocklistClient.maybeSync(6000);
     do_throw("Sync should fail (the signature is missing)");
   } catch (e) {
     await checkRecordCount(OneCRLBlocklistClient, 2);
   }
 
   // Ensure that the failure is reflected in the accumulated telemetry:
   endHistogram = getUptakeTelemetrySnapshot(TELEMETRY_HISTOGRAM_KEY);
   expectedIncrements = {
--- a/services/settings/RemoteSettingsClient.jsm
+++ b/services/settings/RemoteSettingsClient.jsm
@@ -16,27 +16,31 @@ ChromeUtils.defineModuleGetter(this, "Ki
 ChromeUtils.defineModuleGetter(this, "KintoHttpClient",
                                "resource://services-common/kinto-http-client.js");
 ChromeUtils.defineModuleGetter(this, "UptakeTelemetry",
                                "resource://services-common/uptake-telemetry.js");
 ChromeUtils.defineModuleGetter(this, "ClientEnvironmentBase",
                                "resource://gre/modules/components-utils/ClientEnvironment.jsm");
 ChromeUtils.defineModuleGetter(this, "RemoteSettingsWorker",
                                "resource://services-settings/RemoteSettingsWorker.jsm");
+ChromeUtils.defineModuleGetter(this, "Utils",
+                               "resource://services-settings/Utils.jsm");
 
 XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
 
 // IndexedDB name.
 const DB_NAME = "remote-settings";
 
 const INVALID_SIGNATURE = "Invalid content signature";
 const MISSING_SIGNATURE = "Missing signature";
 
 XPCOMUtils.defineLazyPreferenceGetter(this, "gServerURL",
                                       "services.settings.server");
+XPCOMUtils.defineLazyPreferenceGetter(this, "gChangesPath",
+                                      "services.settings.changes.path");
 XPCOMUtils.defineLazyPreferenceGetter(this, "gVerifySignature",
                                       "services.settings.verify_signature", true);
 
 /**
  * cacheProxy returns an object Proxy that will memoize properties of the target.
  * @param {Object} target the object to wrap.
  * @returns {Proxy}
  */
@@ -120,17 +124,17 @@ class EventEmitter {
   /**
    * Event emitter: will execute the registered listeners in the order and
    * sequentially.
    *
    * @param {string} event    the event name
    * @param {Object} payload  the event payload to call the listeners with
    */
   async emit(event, payload) {
-    const callbacks = this._listeners.get("sync");
+    const callbacks = this._listeners.get(event);
     let lastError;
     for (const cb of callbacks) {
       try {
         await cb(payload);
       } catch (e) {
         lastError = e;
       }
     }
@@ -159,17 +163,17 @@ class EventEmitter {
     }
   }
 }
 
 
 class RemoteSettingsClient extends EventEmitter {
 
   constructor(collectionName, { bucketNamePref, signerName, filterFunc, localFields = [], lastCheckTimePref }) {
-    super(["sync"]);
+    super(["sync"]); // emitted events
 
     this.collectionName = collectionName;
     this.signerName = signerName;
     this.filterFunc = filterFunc;
     this.localFields = localFields;
     this._lastCheckTimePref = lastCheckTimePref;
 
     // The bucket preference value can be changed (eg. `main` to `main-preview`) in order
@@ -207,56 +211,77 @@ class RemoteSettingsClient extends Event
    * Lists settings.
    *
    * @param  {Object} options         The options object.
    * @param  {Object} options.filters Filter the results (default: `{}`).
    * @param  {Object} options.order   The order to apply (eg. `-last_modified`).
    * @return {Promise}
    */
   async get(options = {}) {
-    const {
-      filters = {},
-      order = "", // not sorted by default.
-    } = options;
-
-    const c = await this.openCollection();
+    const { filters = {}, order = "" } = options; // not sorted by default.
 
-    const timestamp = await c.db.getLastModified();
-    if (timestamp == null) {
-      // The local database for this collection was never synchronized.
-      // Before returning an empty list, we attempt to load a packaged JSON dump.
+    if (!(await Utils.hasLocalData(this))) {
       try {
-        // Load JSON dump if there is one.
-        await RemoteSettingsWorker.importJSONDump(this.bucketName, this.collectionName);
+        // .get() was called before we had the chance to synchronize the local database.
+        // We'll try to avoid returning an empty list.
+        if (await Utils.hasLocalDump(this.bucketName, this.collectionName)) {
+          // Since there is a JSON dump, load it as default data.
+          await RemoteSettingsWorker.importJSONDump(this.bucketName, this.collectionName);
+        } else {
+          // There is no JSON dump, force a synchronization from the server.
+          await this.sync({ loadDump: false });
+        }
       } catch (e) {
         // Report but return an empty list since there will be no data anyway.
         Cu.reportError(e);
         return [];
       }
     }
 
     // Read from the local DB.
-    const { data } = await c.list({ filters, order });
+    const kintoCol = await this.openCollection();
+    const { data } = await kintoCol.list({ filters, order });
     // Filter the records based on `this.filterFunc` results.
     return this._filterEntries(data);
   }
 
   /**
-   * Synchronize from Kinto server, if necessary.
+   * Synchronize the local database with the remote server.
+   *
+   * @param {Object} options See #maybeSync() options.
+   */
+  async sync(options) {
+    // We want to know which timestamp we are expected to obtain in order to leverage
+    // cache busting. We don't provide ETag because we don't want a 304.
+    const { changes } = await Utils.fetchLatestChanges(gServerURL + gChangesPath, {
+      filters: {
+        collection: this.collectionName,
+        bucket: this.bucketName,
+      },
+    });
+    if (changes.length === 0) {
+      throw new Error(`Unknown collection "${this.identifier}"`);
+    }
+    // According to API, there will be one only (fail if not).
+    const [{ last_modified: expectedTimestamp }] = changes;
+
+    return this.maybeSync(expectedTimestamp, options);
+  }
+
+  /**
+   * Synchronize the local database with the remote server, **only if necessary**.
    *
    * @param {int}    expectedTimestamp the lastModified date (on the server) for the remote collection.
    *                                   This will be compared to the local timestamp, and will be used for
    *                                   cache busting if local data is out of date.
-   * @param {int}   serverTimeMillis   the current date return by the server.
-   *                                   This is only used to track the last check or synchronization.
    * @param {Object} options           additional advanced options.
    * @param {bool}   options.loadDump  load initial dump from disk on first sync (default: true)
    * @return {Promise}                 which rejects on sync or process failure.
    */
-  async maybeSync(expectedTimestamp, serverTimeMillis, options = { loadDump: true }) {
+  async maybeSync(expectedTimestamp, options = { loadDump: true }) {
     const { loadDump } = options;
 
     let reportStatus = null;
     try {
       const collection = await this.openCollection();
       // Synchronize remote data into a local Sqlite DB.
       let collectionLastModified = await collection.db.getLastModified();
 
@@ -272,17 +297,16 @@ class RemoteSettingsClient extends Event
           // Report but go-on.
           Cu.reportError(e);
         }
       }
 
       // If the data is up to date, there's no need to sync. We still need
       // to record the fact that a check happened.
       if (expectedTimestamp <= collectionLastModified) {
-        this._updateLastCheck(serverTimeMillis);
         reportStatus = UptakeTelemetry.STATUS.UP_TO_DATE;
         return;
       }
 
       // If there is a `signerName` and collection signing is enforced, add a
       // hook for incoming changes that validates the signature.
       if (this.signerName && gVerifySignature) {
         collection.hooks["incoming-changes"] = [async (payload, collection) => {
@@ -393,19 +417,16 @@ class RemoteSettingsClient extends Event
         try {
           await this.emit("sync", payload);
         } catch (e) {
           reportStatus = UptakeTelemetry.STATUS.APPLY_ERROR;
           throw e;
         }
       }
 
-      // Track last update.
-      this._updateLastCheck(serverTimeMillis);
-
     } catch (e) {
       // No specific error was tracked, mark it as unknown.
       if (reportStatus === null) {
         reportStatus = UptakeTelemetry.STATUS.UNKNOWN_ERROR;
       }
       throw e;
     } finally {
       // No error was reported, this is a success!
@@ -413,21 +434,27 @@ class RemoteSettingsClient extends Event
         reportStatus = UptakeTelemetry.STATUS.SUCCESS;
       }
       // Report success/error status to Telemetry.
       UptakeTelemetry.report(this.identifier, reportStatus);
     }
   }
 
   /**
+   * Fetch the signature info from the collection metadata and verifies that the
+   * local set of records has the same.
    *
-   * @param {Array<Object>} remoteRecords
-   * @param {int} timestamp
-   * @param {Collection} collection
+   * @param {Array<Object>} remoteRecords   The list of changes to apply to the local database.
+   * @param {int} timestamp                 The timestamp associated with the list of remote records.
+   * @param {Collection} collection         Kinto.js Collection instance.
    * @param {Object} options
+   * @param {int} options.expectedTimestamp Cache busting of collection metadata
+   * @param {Boolean} options.ignoreLocal   When the signature verification is retried, since we refetch
+   *                                        the whole collection, we don't take into account the local
+   *                                        data (default: `false`)
    * @returns {Promise}
    */
   async _validateCollectionSignature(remoteRecords, timestamp, kintoCollection, options = {}) {
     const { expectedTimestamp, ignoreLocal = false } = options;
     // this is a content-signature field from an autograph response.
     const { name: collection, bucket } = kintoCollection;
     const { signature, certChain } = await fetchCollectionSignature(bucket, collection, expectedTimestamp);
 
@@ -447,31 +474,22 @@ class RemoteSettingsClient extends Event
                                          "p384ecdsa=" + signature,
                                          certChain,
                                          this.signerName)) {
       throw new Error(INVALID_SIGNATURE + ` (${bucket}/${collection})`);
     }
   }
 
   /**
-   * Save last time server was checked in users prefs.
-   *
-   * @param {int} serverTimeMillis   the current date return by server.
-   */
-  _updateLastCheck(serverTimeMillis) {
-    const checkedServerTimeInSeconds = Math.round(serverTimeMillis / 1000);
-    Services.prefs.setIntPref(this.lastCheckTimePref, checkedServerTimeInSeconds);
-  }
-
-  /**
+   * Filter entries for which calls to `this.filterFunc` returns null.
    *
    * @param {Array<Objet>} data
+   * @returns {Array<Object>}
    */
   async _filterEntries(data) {
-    // Filter entries for which calls to `this.filterFunc` returns null.
     if (!this.filterFunc) {
       return data;
     }
     const environment = cacheProxy(ClientEnvironment);
     const dataPromises = data.map(e => this.filterFunc(e, environment));
     const results = await Promise.all(dataPromises);
     return results.filter(Boolean);
   }
new file mode 100644
--- /dev/null
+++ b/services/settings/Utils.jsm
@@ -0,0 +1,123 @@
+/* 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/. */
+
+ var EXPORTED_SYMBOLS = [
+  "Utils",
+];
+
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
+
+var Utils = {
+
+  /**
+   * Check if local data exist for the specified client.
+   *
+   * @param {RemoteSettingsClient} client
+   * @return {bool} Whether it exists or not.
+   */
+  async hasLocalData(client) {
+    const kintoCol = await client.openCollection();
+    const timestamp = await kintoCol.db.getLastModified();
+    return timestamp !== null;
+  },
+
+  /**
+   * Check if we ship a JSON dump for the specified bucket and collection.
+   *
+   * @param {String} bucket
+   * @param {String} collection
+   * @return {bool} Whether it is present or not.
+   */
+  async hasLocalDump(bucket, collection) {
+    try {
+      await fetch(`resource://app/defaults/settings/${bucket}/${collection}.json`);
+      return true;
+    } catch (e) {
+      return false;
+    }
+  },
+
+  /**
+   * Fetch the list of remote collections and their timestamp.
+   * @param {String} url               The poll URL (eg. `http://${server}{pollingEndpoint}`)
+   * @param {int}    expectedTimestamp The timestamp that the server is supposed to return.
+   *                                   We obtained it from the Megaphone notification payload,
+   *                                   and we use it only for cache busting (Bug 1497159).
+   * @param {String} lastEtag          (optional) The Etag of the latest poll to be matched
+   *                                   by the server (eg. `"123456789"`).
+   * @param {Object} filters
+   */
+  async fetchLatestChanges(url, options = {}) {
+    const { expectedTimestamp, lastEtag = "", filters = {} } = options;
+    //
+    // Fetch the list of changes objects from the server that looks like:
+    // {"data":[
+    //   {
+    //     "host":"kinto-ota.dev.mozaws.net",
+    //     "last_modified":1450717104423,
+    //     "bucket":"blocklists",
+    //     "collection":"certificates"
+    //    }]}
+
+    // Use ETag to obtain a `304 Not modified` when no change occurred,
+    // and `?_since` parameter to only keep entries that weren't processed yet.
+    const headers = {};
+    const params = { ...filters };
+    if (lastEtag != "") {
+      headers["If-None-Match"] = lastEtag;
+      params._since = lastEtag;
+    }
+    if (expectedTimestamp) {
+      params._expected = expectedTimestamp;
+    }
+    if (params) {
+      url += "?" + Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
+    }
+    const response = await fetch(url, { headers });
+
+    let changes = [];
+    // If no changes since last time, go on with empty list of changes.
+    if (response.status != 304) {
+      let payload;
+      try {
+        payload = await response.json();
+      } catch (e) {
+        payload = e.message;
+      }
+
+      if (!payload.hasOwnProperty("data")) {
+        // If the server is failing, the JSON response might not contain the
+        // expected data. For example, real server errors (Bug 1259145)
+        // or dummy local server for tests (Bug 1481348)
+        const is404FromCustomServer = response.status == 404 && Services.prefs.prefHasUserValue("services.settings.server");
+        if (!is404FromCustomServer) {
+          throw new Error(`Server error ${response.status} ${response.statusText}: ${JSON.stringify(payload)}`);
+        }
+      } else {
+        changes = payload.data;
+      }
+    }
+    // The server should always return ETag. But we've had situations where the CDN
+    // was interfering.
+    const currentEtag = response.headers.has("ETag") ? response.headers.get("ETag") : undefined;
+    let serverTimeMillis = Date.parse(response.headers.get("Date"));
+    // Since the response is served via a CDN, the Date header value could have been cached.
+    const ageSeconds = response.headers.has("Age") ? parseInt(response.headers.get("Age"), 10) : 0;
+    serverTimeMillis += ageSeconds * 1000;
+
+    // Check if the server asked the clients to back off.
+    let backoffSeconds;
+    if (response.headers.has("Backoff")) {
+      const value = parseInt(response.headers.get("Backoff"), 10);
+      if (!isNaN(value)) {
+        backoffSeconds = value;
+      }
+    }
+
+    return { changes, currentEtag, serverTimeMillis, backoffSeconds };
+  },
+};
--- a/services/settings/moz.build
+++ b/services/settings/moz.build
@@ -14,11 +14,12 @@ EXTRA_COMPONENTS += [
     'servicesSettings.manifest',
 ]
 
 EXTRA_JS_MODULES['services-settings'] += [
     'remote-settings.js',
     'RemoteSettingsClient.jsm',
     'RemoteSettingsWorker.js',
     'RemoteSettingsWorker.jsm',
+    'Utils.jsm',
 ]
 
 XPCSHELL_TESTS_MANIFESTS += ['test/unit/xpcshell.ini']
--- a/services/settings/remote-settings.js
+++ b/services/settings/remote-settings.js
@@ -16,16 +16,18 @@ ChromeUtils.import("resource://gre/modul
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 
 ChromeUtils.defineModuleGetter(this, "UptakeTelemetry",
                                "resource://services-common/uptake-telemetry.js");
 ChromeUtils.defineModuleGetter(this, "pushBroadcastService",
                                "resource://gre/modules/PushBroadcastService.jsm");
 ChromeUtils.defineModuleGetter(this, "RemoteSettingsClient",
                                "resource://services-settings/RemoteSettingsClient.jsm");
+ChromeUtils.defineModuleGetter(this, "Utils",
+                               "resource://services-settings/Utils.jsm");
 ChromeUtils.defineModuleGetter(this, "FilterExpressions",
                                "resource://gre/modules/components-utils/FilterExpressions.jsm");
 
 XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
 
 const PREF_SETTINGS_DEFAULT_BUCKET     = "services.settings.default_bucket";
 const PREF_SETTINGS_BRANCH             = "services.settings.";
 const PREF_SETTINGS_SERVER             = "server";
@@ -68,122 +70,16 @@ async function jexlFilterFunc(entry, env
     };
     result = await FilterExpressions.eval(filter_expression, context);
   } catch (e) {
     Cu.reportError(e);
   }
   return result ? entry : null;
 }
 
-/**
- * Fetch the list of remote collections and their timestamp.
- * @param {String} url               The poll URL (eg. `http://${server}{pollingEndpoint}`)
- * @param {String} lastEtag          (optional) The Etag of the latest poll to be matched
- *                                    by the server (eg. `"123456789"`).
- * @param {int}    expectedTimestamp The timestamp that the server is supposed to return.
- *                                   We obtained it from the Megaphone notification payload,
- *                                   and we use it only for cache busting (Bug 1497159).
- */
-async function fetchLatestChanges(url, lastEtag, expectedTimestamp) {
-  //
-  // Fetch the list of changes objects from the server that looks like:
-  // {"data":[
-  //   {
-  //     "host":"kinto-ota.dev.mozaws.net",
-  //     "last_modified":1450717104423,
-  //     "bucket":"blocklists",
-  //     "collection":"certificates"
-  //    }]}
-
-  // Use ETag to obtain a `304 Not modified` when no change occurred,
-  // and `?_since` parameter to only keep entries that weren't processed yet.
-  const headers = {};
-  const params = {};
-  if (lastEtag) {
-    headers["If-None-Match"] = lastEtag;
-    params._since = lastEtag;
-  }
-  if (expectedTimestamp) {
-    params._expected = expectedTimestamp;
-  }
-  if (params) {
-    url += "?" + Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
-  }
-  const response = await fetch(url, {headers});
-
-  let changes = [];
-  // If no changes since last time, go on with empty list of changes.
-  if (response.status != 304) {
-    let payload;
-    try {
-      payload = await response.json();
-    } catch (e) {
-      payload = e.message;
-    }
-
-    if (!payload.hasOwnProperty("data")) {
-      // If the server is failing, the JSON response might not contain the
-      // expected data. For example, real server errors (Bug 1259145)
-      // or dummy local server for tests (Bug 1481348)
-      const is404FromCustomServer = response.status == 404 && gPrefs.prefHasUserValue(PREF_SETTINGS_SERVER);
-      if (!is404FromCustomServer) {
-        throw new Error(`Server error ${response.status} ${response.statusText}: ${JSON.stringify(payload)}`);
-      }
-    } else {
-      changes = payload.data;
-    }
-  }
-  // The server should always return ETag. But we've had situations where the CDN
-  // was interfering.
-  const currentEtag = response.headers.has("ETag") ? response.headers.get("ETag") : undefined;
-  let serverTimeMillis = Date.parse(response.headers.get("Date"));
-  // Since the response is served via a CDN, the Date header value could have been cached.
-  const ageSeconds = response.headers.has("Age") ? parseInt(response.headers.get("Age"), 10) : 0;
-  serverTimeMillis += ageSeconds * 1000;
-
-  // Check if the server asked the clients to back off.
-  let backoffSeconds;
-  if (response.headers.has("Backoff")) {
-    const value = parseInt(response.headers.get("Backoff"), 10);
-    if (!isNaN(value)) {
-      backoffSeconds = value;
-    }
-  }
-
-  return { changes, currentEtag, serverTimeMillis, backoffSeconds };
-}
-
-/**
- * Check if local data exist for the specified client.
- *
- * @param {RemoteSettingsClient} client
- * @return {bool} Whether it exists or not.
- */
-async function hasLocalData(client) {
-  const kintoCol = await client.openCollection();
-  const timestamp = await kintoCol.db.getLastModified();
-  return timestamp !== null;
-}
-
-/**
- * Check if we ship a JSON dump for the specified bucket and collection.
- *
- * @param {String} bucket
- * @param {String} collection
- * @return {bool} Whether it is present or not.
- */
-async function hasLocalDump(bucket, collection) {
-  try {
-    await fetch(`resource://app/defaults/settings/${bucket}/${collection}.json`);
-    return true;
-  } catch (e) {
-    return false;
-  }
-}
-
 
 function remoteSettingsFunction() {
   const _clients = new Map();
 
   // If not explicitly specified, use the default signer.
   const defaultSigner = gPrefs.getCharPref(PREF_SETTINGS_DEFAULT_SIGNER);
   const defaultOptions = {
     bucketNamePref: PREF_SETTINGS_DEFAULT_BUCKET,
@@ -231,18 +127,18 @@ function remoteSettingsFunction() {
     }
     // There was no client registered for this collection, but it's the main bucket,
     // therefore we can instantiate a client with the default options.
     // So if we have a local database or if we ship a JSON dump, then it means that
     // this client is known but it was not registered yet (eg. calling module not "imported" yet).
     if (bucketName == Services.prefs.getCharPref(PREF_SETTINGS_DEFAULT_BUCKET)) {
       const c = new RemoteSettingsClient(collectionName, defaultOptions);
       const [dbExists, localDump] = await Promise.all([
-        hasLocalData(c),
-        hasLocalDump(bucketName, collectionName),
+        Utils.hasLocalData(c),
+        Utils.hasLocalDump(bucketName, collectionName),
       ]);
       if (dbExists || localDump) {
         return c;
       }
     }
     // Else, we cannot return a client insttance because we are not able to synchronize data in specific buckets.
     // Mainly because we cannot guess which `signerName` has to be used for example.
     // And we don't want to synchronize data for collections in the main bucket that are
@@ -267,24 +163,21 @@ function remoteSettingsFunction() {
         UptakeTelemetry.report(TELEMETRY_HISTOGRAM_KEY,
                                UptakeTelemetry.STATUS.BACKOFF);
         throw new Error(`Server is asking clients to back off; retry in ${Math.ceil(remainingMilliseconds / 1000)}s.`);
       } else {
         gPrefs.clearUserPref(PREF_SETTINGS_SERVER_BACKOFF);
       }
     }
 
-    let lastEtag;
-    if (gPrefs.prefHasUserValue(PREF_SETTINGS_LAST_ETAG)) {
-      lastEtag = gPrefs.getCharPref(PREF_SETTINGS_LAST_ETAG);
-    }
+    const lastEtag = gPrefs.getCharPref(PREF_SETTINGS_LAST_ETAG, "");
 
     let pollResult;
     try {
-      pollResult = await fetchLatestChanges(remoteSettings.pollingEndpoint, lastEtag, expectedTimestamp);
+      pollResult = await Utils.fetchLatestChanges(remoteSettings.pollingEndpoint, { expectedTimestamp, lastEtag });
     } catch (e) {
       // Report polling error to Uptake Telemetry.
       let report;
       if (/Server/.test(e.message)) {
         report = UptakeTelemetry.STATUS.SERVER_ERROR;
       } else if (/NetworkError/.test(e.message)) {
         report = UptakeTelemetry.STATUS.NETWORK_ERROR;
       } else {
@@ -308,34 +201,38 @@ function remoteSettingsFunction() {
       gPrefs.setCharPref(PREF_SETTINGS_SERVER_BACKOFF, backoffReleaseTime);
     }
 
     // Record new update time and the difference between local and server time.
     // Negative clockDifference means local time is behind server time
     // by the absolute of that value in seconds (positive means it's ahead)
     const clockDifference = Math.floor((Date.now() - serverTimeMillis) / 1000);
     gPrefs.setIntPref(PREF_SETTINGS_CLOCK_SKEW_SECONDS, clockDifference);
-    gPrefs.setIntPref(PREF_SETTINGS_LAST_UPDATE, serverTimeMillis / 1000);
+    const checkedServerTimeInSeconds = Math.round(serverTimeMillis / 1000);
+    gPrefs.setIntPref(PREF_SETTINGS_LAST_UPDATE, checkedServerTimeInSeconds);
+
 
     const loadDump = gPrefs.getBoolPref(PREF_SETTINGS_LOAD_DUMP, true);
 
     // Iterate through the collections version info and initiate a synchronization
     // on the related remote settings client.
     let firstError;
     for (const change of changes) {
       const { bucket, collection, last_modified } = change;
 
       const client = await _client(bucket, collection);
       if (!client) {
         continue;
       }
       // Start synchronization! It will be a no-op if the specified `lastModified` equals
       // the one in the local database.
       try {
-        await client.maybeSync(last_modified, serverTimeMillis, {loadDump});
+        await client.maybeSync(last_modified, { loadDump });
+        // Save last time this client was successfully synced.
+        Services.prefs.setIntPref(client.lastCheckTimePref, checkedServerTimeInSeconds);
       } catch (e) {
         if (!firstError) {
           firstError = e;
           firstError.details = change;
         }
       }
     }
     if (firstError) {
@@ -351,17 +248,17 @@ function remoteSettingsFunction() {
     Services.obs.notifyObservers(null, "remote-settings-changes-polled");
   };
 
   /**
    * Returns an object with polling status information and the list of
    * known remote settings collections.
    */
   remoteSettings.inspect = async () => {
-    const { changes, currentEtag: serverTimestamp } = await fetchLatestChanges(remoteSettings.pollingEndpoint);
+    const { changes, currentEtag: serverTimestamp } = await Utils.fetchLatestChanges(remoteSettings.pollingEndpoint);
 
     const collections = await Promise.all(changes.map(async (change) => {
       const { bucket, collection, last_modified: serverTimestamp } = change;
       const client = await _client(bucket, collection);
       if (!client) {
         return null;
       }
       const kintoCol = await client.openCollection();
--- a/services/settings/test/unit/test_remote_settings.js
+++ b/services/settings/test/unit/test_remote_settings.js
@@ -1,30 +1,38 @@
 /* import-globals-from ../../../common/tests/unit/head_helpers.js */
 
 const { Constructor: CC } = Components;
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://testing-common/httpd.js");
+ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
+
+const IS_ANDROID = AppConstants.platform == "android";
 
 const { RemoteSettings } = ChromeUtils.import("resource://services-settings/remote-settings.js", {});
 const { UptakeTelemetry } = ChromeUtils.import("resource://services-common/uptake-telemetry.js", {});
 
 const BinaryInputStream = CC("@mozilla.org/binaryinputstream;1",
   "nsIBinaryInputStream", "setInputStream");
 
 let server;
 let client;
+let clientWithDump;
 
 async function clear_state() {
   // Clear local DB.
   const collection = await client.openCollection();
   await collection.clear();
   // Reset event listeners.
   client._listeners.set("sync", []);
+
+  const collectionWithDump = await clientWithDump.openCollection();
+  await collectionWithDump.clear();
+
   Services.prefs.clearUserPref("services.settings.default_bucket");
 }
 
 
 function run_test() {
   // Set up an HTTP Server
   server = new HttpServer();
   server.start(-1);
@@ -32,16 +40,17 @@ function run_test() {
   // Point the blocklist clients to use this local HTTP server.
   Services.prefs.setCharPref("services.settings.server",
                              `http://localhost:${server.identity.primaryPort}/v1`);
   // Ensure that signature verification is disabled to prevent interference
   // with basic certificate sync tests
   Services.prefs.setBoolPref("services.settings.verify_signature", false);
 
   client = RemoteSettings("password-fields");
+  clientWithDump = RemoteSettings("language-dictionaries");
 
   // Setup server fake responses.
   function handleResponse(request, response) {
     try {
       const sample = getSampleResponse(request, server.identity.primaryPort);
       if (!sample) {
         do_throw(`unexpected ${request.method} request for ${request.path}?${request.queryString}`);
       }
@@ -72,240 +81,250 @@ function run_test() {
 
   registerCleanupFunction(function() {
     server.stop(() => { });
   });
 }
 
 add_task(async function test_records_obtained_from_server_are_stored_in_db() {
   // Test an empty db populates
-  await client.maybeSync(2000, Date.now());
+  await client.maybeSync(2000);
 
   // Open the collection, verify it's been populated:
   // Our test data has a single record; it should be in the local collection
   const list = await client.get();
   equal(list.length, 1);
 });
 add_task(clear_state);
 
 add_task(async function test_records_can_have_local_fields() {
   const c = RemoteSettings("password-fields", { localFields: ["accepted"] });
-  await c.maybeSync(2000, Date.now());
+  await c.maybeSync(2000);
 
   const col = await c.openCollection();
   await col.update({ id: "9d500963-d80e-3a91-6e74-66f3811b99cc", accepted: true });
 
-  await c.maybeSync(2000, Date.now()); // Does not fail.
-});
-add_task(clear_state);
-
-add_task(async function test_current_server_time_is_saved_in_pref() {
-  const serverTime = Date.now();
-  await client.maybeSync(2000, serverTime);
-  equal(client.lastCheckTimePref, "services.settings.main.password-fields.last_check");
-  const after = Services.prefs.getIntPref(client.lastCheckTimePref);
-  equal(after, Math.round(serverTime / 1000));
+  await c.maybeSync(2000); // Does not fail.
 });
 add_task(clear_state);
 
 add_task(async function test_records_changes_are_overwritten_by_server_changes() {
   // Create some local conflicting data, and make sure it syncs without error.
   const collection = await client.openCollection();
   await collection.create({
     "website": "",
     "id": "9d500963-d80e-3a91-6e74-66f3811b99cc",
   }, { useRecordId: true });
 
-  await client.maybeSync(2000, Date.now());
+  await client.maybeSync(2000);
 
   const data = await client.get();
   equal(data[0].website, "https://some-website.com");
 });
 add_task(clear_state);
 
-add_task(async function test_default_records_come_from_a_local_dump_when_database_is_empty() {
-  // When collection is unknown, no dump is loaded, and there is no error.
+add_task(async function test_get_loads_default_records_from_a_local_dump_when_database_is_empty() {
+  if (IS_ANDROID) {
+    // Skip test: we don't ship remote settings dumps on Android (see package-manifest).
+    return;
+  }
+
+  // When collection has a dump in services/settings/dumps/{bucket}/{collection}.json
+  const data = await clientWithDump.get();
+  notEqual(data.length, 0);
+  // No synchronization happened (responses are not mocked).
+});
+add_task(clear_state);
+
+add_task(async function test_get_triggers_synchronization_when_database_is_empty() {
+  // The "password-fields" collection has no local dump, and no local data.
+  // Therefore a synchronization will happen.
+  const data = await client.get();
+
+  // Data comes from mocked HTTP response (see below).
+  equal(data.length, 1);
+  equal(data[0].selector, "#webpage[field-pwd]");
+});
+add_task(clear_state);
+
+add_task(async function test_get_ignores_synchronization_errors() {
+  // The monitor endpoint won't contain any information about this collection.
   let data = await RemoteSettings("some-unknown-key").get();
   equal(data.length, 0);
-
-  // When collection has a dump in services/settings/dumps/{bucket}/{collection}.json
-  data = await RemoteSettings("certificates", { bucketNamePref: "services.blocklist.bucket" }).get();
-  notEqual(data.length, 0);
+  // The sync endpoints are not mocked, this fails internally.
+  data = await RemoteSettings("no-mocked-responses").get();
+  equal(data.length, 0);
 });
 add_task(clear_state);
 
 add_task(async function test_sync_event_provides_information_about_records() {
-  const serverTime = Date.now();
-
   let eventData;
   client.on("sync", ({ data }) => eventData = data);
 
-  await client.maybeSync(2000, serverTime - 1000);
+  await client.maybeSync(2000);
   equal(eventData.current.length, 1);
 
-  await client.maybeSync(3001, serverTime);
+  await client.maybeSync(3001);
   equal(eventData.current.length, 2);
   equal(eventData.created.length, 1);
   equal(eventData.created[0].website, "https://www.other.org/signin");
   equal(eventData.updated.length, 1);
   equal(eventData.updated[0].old.website, "https://some-website.com");
   equal(eventData.updated[0].new.website, "https://some-website.com/login");
   equal(eventData.deleted.length, 0);
 
-  await client.maybeSync(4001, serverTime);
+  await client.maybeSync(4001);
   equal(eventData.current.length, 1);
   equal(eventData.created.length, 0);
   equal(eventData.updated.length, 0);
   equal(eventData.deleted.length, 1);
   equal(eventData.deleted[0].website, "https://www.other.org/signin");
 });
 add_task(clear_state);
 
 add_task(async function test_inspect_method() {
-  const serverTime = Date.now();
-
-  // Synchronize the `password-fields` collection.
-  await client.maybeSync(2000, serverTime);
+  // Synchronize the `password-fields` collection in order to have
+  // some local data when .inspect() is called.
+  await client.maybeSync(2000);
 
   const inspected = await RemoteSettings.inspect();
 
-  const { mainBucket, serverURL, defaultSigner, collections } = inspected;
+  // Assertion for global attributes.
+  const { mainBucket, serverURL, defaultSigner, collections, serverTimestamp } = inspected;
   const rsSigner = "remote-settings.content-signature.mozilla.org";
   equal(mainBucket, "main");
   equal(serverURL, `http://localhost:${server.identity.primaryPort}/v1`);
   equal(defaultSigner, rsSigner);
+  equal(serverTimestamp, '"5000"');
 
-  equal(inspected.serverTimestamp, '"4000"');
-  equal(collections.length, 1);
-  // password-fields was synchronized and has local data.
-  equal(collections[0].collection, "password-fields");
-  equal(collections[0].serverTimestamp, 3000);
-  equal(collections[0].localTimestamp, 3000);
+  // A collection is listed in .inspect() if it has local data or if there
+  // is a JSON dump for it.
+  // "password-fields" has no dump but was synchronized above and thus has local data.
+  let col = collections.pop();
+  equal(col.collection, "password-fields");
+  equal(col.serverTimestamp, 3000);
+  equal(col.localTimestamp, 3000);
+
+  if (!IS_ANDROID) {
+    // "language-dictionaries" has a local dump (not on Android)
+    col = collections.pop();
+    equal(col.collection, "language-dictionaries");
+    equal(col.serverTimestamp, 4000);
+    ok(!col.localTimestamp); // not synchronized.
+  }
 });
 add_task(clear_state);
 
 add_task(async function test_listeners_are_not_deduplicated() {
-  const serverTime = Date.now();
-
   let count = 0;
   const plus1 = () => { count += 1; };
 
   client.on("sync", plus1);
   client.on("sync", plus1);
   client.on("sync", plus1);
 
-  await client.maybeSync(2000, serverTime);
+  await client.maybeSync(2000);
 
   equal(count, 3);
 });
 add_task(clear_state);
 
 add_task(async function test_listeners_can_be_removed() {
-  const serverTime = Date.now();
-
   let count = 0;
   const onSync = () => { count += 1; };
 
   client.on("sync", onSync);
   client.off("sync", onSync);
 
-  await client.maybeSync(2000, serverTime);
+  await client.maybeSync(2000);
 
   equal(count, 0);
 });
 add_task(clear_state);
 
 add_task(async function test_all_listeners_are_executed_if_one_fails() {
-  const serverTime = Date.now();
-
   let count = 0;
   client.on("sync", () => { count += 1; });
   client.on("sync", () => { throw new Error("boom"); });
   client.on("sync", () => { count += 2; });
 
   let error;
   try {
-    await client.maybeSync(2000, serverTime);
+    await client.maybeSync(2000);
   } catch (e) {
     error = e;
   }
 
   equal(count, 3);
   equal(error.message, "boom");
 });
 add_task(clear_state);
 
 add_task(async function test_telemetry_reports_up_to_date() {
-  await client.maybeSync(2000, Date.now() - 1000);
-  const serverTime = Date.now();
+  await client.maybeSync(2000);
   const startHistogram = getUptakeTelemetrySnapshot(client.identifier);
 
-  await client.maybeSync(3000, serverTime);
+  await client.maybeSync(3000);
 
   // No Telemetry was sent.
   const endHistogram = getUptakeTelemetrySnapshot(client.identifier);
   const expectedIncrements = {[UptakeTelemetry.STATUS.UP_TO_DATE]: 1};
   checkUptakeTelemetry(startHistogram, endHistogram, expectedIncrements);
 });
 add_task(clear_state);
 
 add_task(async function test_telemetry_if_sync_succeeds() {
   // We test each client because Telemetry requires preleminary declarations.
-  const serverTime = Date.now();
   const startHistogram = getUptakeTelemetrySnapshot(client.identifier);
 
-  await client.maybeSync(2000, serverTime);
+  await client.maybeSync(2000);
 
   const endHistogram = getUptakeTelemetrySnapshot(client.identifier);
   const expectedIncrements = {[UptakeTelemetry.STATUS.SUCCESS]: 1};
   checkUptakeTelemetry(startHistogram, endHistogram, expectedIncrements);
 });
 add_task(clear_state);
 
 add_task(async function test_telemetry_reports_if_application_fails() {
-  const serverTime = Date.now();
   const startHistogram = getUptakeTelemetrySnapshot(client.identifier);
   client.on("sync", () => { throw new Error("boom"); });
 
   try {
-    await client.maybeSync(2000, serverTime);
+    await client.maybeSync(2000);
   } catch (e) {}
 
   const endHistogram = getUptakeTelemetrySnapshot(client.identifier);
   const expectedIncrements = {[UptakeTelemetry.STATUS.APPLY_ERROR]: 1};
   checkUptakeTelemetry(startHistogram, endHistogram, expectedIncrements);
 });
 add_task(clear_state);
 
 add_task(async function test_telemetry_reports_if_sync_fails() {
-  const serverTime = Date.now();
-
   const collection = await client.openCollection();
   await collection.db.saveLastModified(9999);
 
   const startHistogram = getUptakeTelemetrySnapshot(client.identifier);
 
   try {
-    await client.maybeSync(10000, serverTime);
+    await client.maybeSync(10000);
   } catch (e) {}
 
   const endHistogram = getUptakeTelemetrySnapshot(client.identifier);
   const expectedIncrements = {[UptakeTelemetry.STATUS.SYNC_ERROR]: 1};
   checkUptakeTelemetry(startHistogram, endHistogram, expectedIncrements);
 });
 add_task(clear_state);
 
 add_task(async function test_telemetry_reports_unknown_errors() {
-  const serverTime = Date.now();
   const backup = client.openCollection;
   client.openCollection = () => { throw new Error("Internal"); };
   const startHistogram = getUptakeTelemetrySnapshot(client.identifier);
 
   try {
-    await client.maybeSync(2000, serverTime);
+    await client.maybeSync(2000);
   } catch (e) {}
 
   client.openCollection = backup;
   const endHistogram = getUptakeTelemetrySnapshot(client.identifier);
   const expectedIncrements = {[UptakeTelemetry.STATUS.UNKNOWN_ERROR]: 1};
   checkUptakeTelemetry(startHistogram, endHistogram, expectedIncrements);
 });
 add_task(clear_state);
@@ -315,25 +334,33 @@ add_task(async function test_bucketname_
 
   Services.prefs.setCharPref("services.settings.default_bucket", "main-preview");
 
   equal(client.bucketName, "main-preview");
 });
 add_task(clear_state);
 
 add_task(async function test_inspect_changes_the_list_when_bucket_pref_is_changed() {
+  if (IS_ANDROID) {
+     // Skip test: we don't ship remote settings dumps on Android (see package-manifest).
+    return;
+  }
   // Register a client only listed in -preview...
   RemoteSettings("crash-rate");
 
   const { collections: before } = await RemoteSettings.inspect();
-  deepEqual(before.map(c => c.collection).sort(), ["password-fields"]);
 
+  // These two collections are listed in the main bucket in monitor/changes (one with dump, one registered).
+  deepEqual(before.map(c => c.collection).sort(), ["language-dictionaries", "password-fields"]);
+
+  // Switch to main-preview bucket.
   Services.prefs.setCharPref("services.settings.default_bucket", "main-preview");
+  const { collections: after, mainBucket } = await RemoteSettings.inspect();
 
-  const { collections: after, mainBucket } = await RemoteSettings.inspect();
+  // These two collections are listed in the main bucket in monitor/changes (both are registered).
   deepEqual(after.map(c => c.collection).sort(), ["crash-rate", "password-fields"]);
   equal(mainBucket, "main-preview");
 });
 add_task(clear_state);
 
 // get a response for a given request from sample data
 function getSampleResponse(req, port) {
   const responses = {
@@ -369,24 +396,29 @@ function getSampleResponse(req, port) {
     },
     "GET:/v1/buckets/monitor/collections/changes/records": {
       "sampleHeaders": [
         "Access-Control-Allow-Origin: *",
         "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
         "Content-Type: application/json; charset=UTF-8",
         "Server: waitress",
         `Date: ${new Date().toUTCString()}`,
-        "Etag: \"4000\"",
+        "Etag: \"5000\"",
       ],
       "status": { status: 200, statusText: "OK" },
       "responseBody": {
         "data": [{
           "id": "4676f0c7-9757-4796-a0e8-b40a5a37a9c9",
           "bucket": "main",
-          "collection": "unknown",
+          "collection": "unknown-locally",
+          "last_modified": 5000,
+        }, {
+          "id": "4676f0c7-9757-4796-a0e8-b40a5a37a9c9",
+          "bucket": "main",
+          "collection": "language-dictionaries",
           "last_modified": 4000,
         }, {
           "id": "0af8da0b-3e03-48fb-8d0d-2d8e4cb7514d",
           "bucket": "main",
           "collection": "password-fields",
           "last_modified": 3000,
         }, {
           "id": "4acda969-3bd3-4074-a678-ff311eeb076e",
@@ -467,14 +499,70 @@ function getSampleResponse(req, port) {
       ],
       "status": {status: 503, statusText: "Service Unavailable"},
       "responseBody": {
         code: 503,
         errno: 999,
         error: "Service Unavailable",
       },
     },
+    "GET:/v1/buckets/monitor/collections/changes/records?collection=password-fields&bucket=main": {
+      "sampleHeaders": [
+        "Access-Control-Allow-Origin: *",
+        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+        "Content-Type: application/json; charset=UTF-8",
+        "Server: waitress",
+        `Date: ${new Date().toUTCString()}`,
+        "Etag: \"1338\"",
+      ],
+      "status": { status: 200, statusText: "OK" },
+      "responseBody": {
+        "data": [{
+          "id": "fe5758d0-c67a-42d0-bb4f-8f2d75106b65",
+          "bucket": "main",
+          "collection": "password-fields",
+          "last_modified": 1337,
+        }],
+      },
+    },
+    "GET:/v1/buckets/main/collections/password-fields/records?_expected=1337&_sort=-last_modified": {
+      "sampleHeaders": [
+        "Access-Control-Allow-Origin: *",
+        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+        "Content-Type: application/json; charset=UTF-8",
+        "Server: waitress",
+        "Etag: \"3000\"",
+      ],
+      "status": { status: 200, statusText: "OK" },
+      "responseBody": {
+        "data": [{
+          "id": "312cc78d-9c1f-4291-a4fa-a1be56f6cc69",
+          "last_modified": 3000,
+          "website": "https://some-website.com",
+          "selector": "#webpage[field-pwd]",
+        }],
+      },
+    },
+    "GET:/v1/buckets/monitor/collections/changes/records?collection=no-mocked-responses&bucket=main": {
+      "sampleHeaders": [
+        "Access-Control-Allow-Origin: *",
+        "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+        "Content-Type: application/json; charset=UTF-8",
+        "Server: waitress",
+        `Date: ${new Date().toUTCString()}`,
+        "Etag: \"713705\"",
+      ],
+      "status": { status: 200, statusText: "OK" },
+      "responseBody": {
+        "data": [{
+          "id": "07a98d1b-7c62-4344-ab18-76856b3facd8",
+          "bucket": "main",
+          "collection": "no-mocked-responses",
+          "last_modified": 713705,
+        }],
+      },
+    },
   };
   return responses[`${req.method}:${req.path}?${req.queryString}`] ||
          responses[`${req.method}:${req.path}`] ||
          responses[req.method];
 
 }
--- a/services/settings/test/unit/test_remote_settings_poll.js
+++ b/services/settings/test/unit/test_remote_settings_poll.js
@@ -11,16 +11,17 @@ const { Kinto } = ChromeUtils.import("re
 const IS_ANDROID = AppConstants.platform == "android";
 
 const PREF_SETTINGS_SERVER = "services.settings.server";
 const PREF_SETTINGS_SERVER_BACKOFF = "services.settings.server.backoff";
 const PREF_LAST_UPDATE = "services.settings.last_update_seconds";
 const PREF_LAST_ETAG = "services.settings.last_etag";
 const PREF_CLOCK_SKEW_SECONDS = "services.settings.clock_skew_seconds";
 
+const DB_NAME = "remote-settings";
 // Telemetry report result.
 const TELEMETRY_HISTOGRAM_KEY = "settings-changes-monitoring";
 const CHANGES_PATH = "/v1/buckets/monitor/collections/changes/records";
 
 var server;
 
 async function clear_state() {
   // set up prefs so the kinto updater talks to the test server
@@ -223,16 +224,44 @@ add_task(async function test_expected_ti
 
   await RemoteSettings.pollChanges({ expectedTimestamp: '"42"'});
 
   Assert.ok(maybeSyncCalled, "maybeSync was called");
 });
 add_task(clear_state);
 
 
+add_task(async function test_client_last_check_is_saved() {
+  server.registerPathHandler(CHANGES_PATH, (request, response) => {
+      response.write(JSON.stringify({
+      data: [{
+        id: "695c2407-de79-4408-91c7-70720dd59d78",
+        last_modified: 1100,
+        host: "localhost",
+        bucket: "main",
+        collection: "models-recipes",
+      }],
+    }));
+    response.setHeader("ETag", '"42"');
+    response.setHeader("Date", (new Date()).toUTCString());
+    response.setStatusLine(null, 200, "OK");
+  });
+
+  const c = RemoteSettings("models-recipes");
+  c.maybeSync = () => {};
+
+  equal(c.lastCheckTimePref, "services.settings.main.models-recipes.last_check");
+  Services.prefs.setIntPref(c.lastCheckTimePref, 0);
+
+  await RemoteSettings.pollChanges({ expectedTimestamp: '"42"' });
+
+  notEqual(Services.prefs.getIntPref(c.lastCheckTimePref), 0);
+});
+add_task(clear_state);
+
 add_task(async function test_success_with_partial_list() {
   function partialList(request, response) {
     const entries = [{
       id: "028261ad-16d4-40c2-a96a-66f72914d125",
       last_modified: 43,
       host: "localhost",
       bucket: "main",
       collection: "cid-1",
@@ -498,19 +527,18 @@ add_task(async function test_syncs_clien
     host: "localhost",
     bucket: "main",
     collection: "recipes",
   }]));
 
   // This simulates what remote-settings would do when initializing a local database.
   // We don't want to instantiate a client using the RemoteSettings() API
   // since we want to test «unknown» clients that have a local database.
-  const dbName = "remote-settings";
-  await (new Kinto.adapters.IDB("blocklists/addons", { dbName })).saveLastModified(42);
-  await (new Kinto.adapters.IDB("main/recipes", { dbName })).saveLastModified(43);
+  await (new Kinto.adapters.IDB("blocklists/addons", { dbName: DB_NAME })).saveLastModified(42);
+  await (new Kinto.adapters.IDB("main/recipes", { dbName: DB_NAME })).saveLastModified(43);
 
   let error;
   try {
     await RemoteSettings.pollChanges();
   } catch (e) {
     error = e;
   }