[cloud functions] Move commit fetching to a shared cache

Change-Id: Ie7562ec9686053759db3c1fa334afd9ba3976408
Reviewed-on: https://dart-review.googlesource.com/c/dart_ci/+/137800
Reviewed-by: Alexander Thomas <athom@google.com>
diff --git a/functions/node/builder.dart b/functions/node/builder.dart
index c0c6b46..c7bed59 100644
--- a/functions/node/builder.dart
+++ b/functions/node/builder.dart
@@ -2,51 +2,20 @@
 // for details. All rights reserved. Use of this source code is governed by a
 // BSD-style license that can be found in the LICENSE file.
 
-import 'dart:convert';
-
-import 'package:http/http.dart' as http;
 import 'package:pool/pool.dart';
 
+import 'commits_cache.dart';
 import 'firestore.dart';
 import 'result.dart';
 import 'reverted_changes.dart';
 
-const prefix = ")]}'\n";
-
-Iterable<int> range(int start, int end) sync* {
-  for (int i = start; i < end; ++i) yield i;
-}
-
-const months = const {
-  'Jan': '01',
-  'Feb': '02',
-  'Mar': '03',
-  'Apr': '04',
-  'May': '05',
-  'Jun': '06',
-  'Jul': '07',
-  'Aug': '08',
-  'Sep': '09',
-  'Oct': '10',
-  'Nov': '11',
-  'Dec': '12'
-};
-
-DateTime parseGitilesDateTime(String gitiles) {
-  final parts = gitiles.split(' ');
-  final year = parts[4];
-  final month = months[parts[1]];
-  final day = parts[2].padLeft(2, '0');
-  return DateTime.parse('$year-$month-$day ${parts[3]} ${parts[5]}');
-}
-
 /// A Builder holds information about a CI build, and can
 /// store the changed results from that build, using an injected
 /// Firestore() object.
 /// Tryjob builds are represented by a subclass Tryjob of this class.
 class Build {
   final FirestoreService firestore;
-  final http.BaseClient httpClient;
+  final CommitsCache commitsCache;
   final String commitHash;
   final Map<String, dynamic> firstResult;
   final int countChunks;
@@ -66,8 +35,8 @@
   List<String> approvalMessages = [];
   int countApprovalsCopied = 0;
 
-  Build(this.commitHash, this.firstResult, this.countChunks, this.firestore,
-      this.httpClient)
+  Build(this.commitHash, this.firstResult, this.countChunks, this.commitsCache,
+      this.firestore)
       : builderName = firstResult['builder_name'],
         buildNumber = int.parse(firstResult['build_number']);
 
@@ -106,13 +75,9 @@
   /// Saves the commit indices of the start and end of the blamelist.
   Future<void> storeBuildCommitsInfo() async {
     // Get indices of change.  Range includes startIndex and endIndex.
-    endCommit = await firestore.getCommit(commitHash);
+    endCommit = await commitsCache.getCommit(commitHash);
     if (endCommit == null) {
-      await getMissingCommits();
-      endCommit = await firestore.getCommit(commitHash);
-      if (endCommit == null) {
-        throw 'Result received with unknown commit hash $commitHash';
-      }
+      throw 'Result received with unknown commit hash $commitHash';
     }
     endIndex = endCommit[fIndex];
     // If this is a new builder, use the current commit as a trivial blamelist.
@@ -120,7 +85,7 @@
       startIndex = endIndex;
     } else {
       final startCommit =
-          await firestore.getCommit(firstResult['previous_commit_hash']);
+          await commitsCache.getCommit(firstResult['previous_commit_hash']);
       startIndex = startCommit[fIndex] + 1;
       if (startIndex > endIndex) {
         throw ArgumentError("Results received with empty blamelist\n"
@@ -135,7 +100,7 @@
   Future<void> fetchReviewsAndReverts() => _awaitCommits ??= () async {
         commits = [
           for (var index = startIndex; index < endIndex; ++index)
-            await firestore.getCommitByIndex(index),
+            await commitsCache.getCommitByIndex(index),
           endCommit
         ];
         for (final commit in commits) {
@@ -155,81 +120,6 @@
         }
       }();
 
-  /// This function is idempotent, so every call of it should write the
-  /// same info to new Firestore documents.  It is save to call multiple
-  /// times simultaneously.
-  Future<void> getMissingCommits() async {
-    final lastCommit = await firestore.getLastCommit();
-    // Unit tests api uses 'hash', FirestoreService uses 'id'.
-    // FirestoreService is changed to 'hash' in follow-up CL.
-    final lastHash = lastCommit['id'] ?? lastCommit['hash'];
-    final lastIndex = lastCommit[fIndex];
-
-    final logUrl = 'https://dart.googlesource.com/sdk/+log/';
-    final range = '$lastHash..master';
-    final parameters = ['format=JSON', 'topo-order', 'n=1000'];
-    final url = '$logUrl$range?${parameters.join('&')}';
-    final response = await httpClient.get(url);
-    final protectedJson = response.body;
-    if (!protectedJson.startsWith(prefix))
-      throw Exception('Gerrit response missing prefix $prefix: $protectedJson');
-    final commits = jsonDecode(protectedJson.substring(prefix.length))['log']
-        as List<dynamic>;
-    if (commits.isEmpty) {
-      print('Found no new commits between $lastHash and master');
-      return;
-    }
-    commitsFetched = commits.length;
-    final first = commits.last as Map<String, dynamic>;
-    if (first['parents'].first != lastHash) {
-      throw 'First new commit $first is not'
-          ' a child of last known commit $lastHash when fetching new commits';
-    }
-    if (!commits.any((commit) => commit['commit'] == commitHash)) {
-      throw 'Did not find commit $commitHash when fetching new commits';
-    }
-    var index = lastIndex + 1;
-    for (Map<String, dynamic> commit in commits.reversed) {
-      final review = _review(commit);
-      var reverted = _revert(commit);
-      var relanded = _reland(commit);
-      if (relanded != null) {
-        reverted = null;
-      }
-      if (reverted != null) {
-        final revertedCommit = await firestore.getCommit(reverted);
-        if (revertedCommit != null && revertedCommit[fRevertOf] != null) {
-          reverted = null;
-          relanded = revertedCommit[fRevertOf];
-        }
-      }
-      await firestore.addCommit(commit['commit'], {
-        fAuthor: commit['author']['email'],
-        fCreated: parseGitilesDateTime(commit['committer']['time']),
-        fIndex: index,
-        fTitle: commit['message'].split('\n').first,
-        if (review != null) fReview: review,
-        if (reverted != null) fRevertOf: reverted,
-        if (relanded != null) fRelandOf: relanded,
-      });
-      if (review != null) {
-        await landReview(commit, index);
-      }
-      ++index;
-    }
-  }
-
-  /// This function is idempotent and may be called multiple times
-  /// concurrently.
-  Future<void> landReview(Map<String, dynamic> commit, int index) async {
-    int review = _review(commit);
-    // Optimization to avoid duplicate work: if another instance has linked
-    // the review to its landed commit, do nothing.
-    if (await firestore.reviewIsLanded(review)) return;
-    await firestore.linkReviewToCommit(review, index);
-    await firestore.linkCommentsToCommit(review, index);
-  }
-
   Future<void> storeConfigurationsInfo(Iterable<String> configurations) async {
     for (final configuration in configurations) {
       await firestore.updateConfiguration(configuration, builderName);
@@ -287,28 +177,6 @@
   }
 }
 
-final reviewRegExp = RegExp(
-    '^Reviewed-on: https://dart-review.googlesource.com/c/sdk/\\+/(\\d+)\$',
-    multiLine: true);
-
-int _review(Map<String, dynamic> commit) {
-  final match = reviewRegExp.firstMatch(commit['message']);
-  if (match != null) return int.parse(match.group(1));
-  return null;
-}
-
-final revertRegExp =
-    RegExp('^This reverts commit ([\\da-f]+)\\.\$', multiLine: true);
-
-String _revert(Map<String, dynamic> commit) =>
-    revertRegExp.firstMatch(commit['message'])?.group(1);
-
-final relandRegExp =
-    RegExp('^This is a reland of ([\\da-f]+)\\.?\$', multiLine: true);
-
-String _reland(Map<String, dynamic> commit) =>
-    relandRegExp.firstMatch(commit['message'])?.group(1);
-
 Map<String, dynamic> constructResult(
         Map<String, dynamic> change, int startIndex, int endIndex,
         {bool approved, int landedReviewIndex, bool failure}) =>
@@ -325,3 +193,10 @@
       if (failure) fActive: true,
       if (failure) fActiveConfigurations: <String>[change['configuration']]
     };
+
+String testResult(Map<String, dynamic> change) => [
+      change['name'],
+      change['result'],
+      change['previous_result'] ?? 'new test',
+      change['expected']
+    ].join(' ');
diff --git a/functions/node/commits_cache.dart b/functions/node/commits_cache.dart
new file mode 100644
index 0000000..8477215
--- /dev/null
+++ b/functions/node/commits_cache.dart
@@ -0,0 +1,216 @@
+// Copyright (c) 2020, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:http/http.dart' as http;
+import 'firestore.dart';
+import 'result.dart';
+
+/// Contains data about the commits on the master branch of the sdk repo.
+/// An instance of this class is stored in a top-level variable, and is
+/// shared between cloud function invocations.
+///
+/// The class fetches commits from Firestore if they are present,
+/// and fetches them from gitiles if not, and saves them to Firestore.
+class CommitsCache {
+  FirestoreService firestore;
+  final http.Client httpClient;
+  Map<String, Map<String, dynamic>> byHash = {};
+  Map<int, Map<String, dynamic>> byIndex = {};
+  int startIndex;
+  int endIndex;
+
+  CommitsCache(this.firestore, this.httpClient);
+
+  Future<Map<String, dynamic>> getCommit(String hash) async {
+    return byHash[hash] ??
+        await fetchByHash(hash) ??
+        await getNewCommits() ??
+        await fetchByHash(hash);
+  }
+
+  Future<Map<String, dynamic>> getCommitByIndex(int index) async {
+    return byIndex[index] ??
+        await fetchByIndex(index) ??
+        await getNewCommits() ??
+        await fetchByIndex(index);
+  }
+
+  /// Add a commit to the cache. The cache must be empty, or the commit
+  /// must be immediately before or after the current cached commits.
+  /// Otherwise, do nothing.
+  void cacheCommit(Map<String, dynamic> commit) {
+    final index = commit['index'];
+    if (startIndex == null || startIndex == index + 1) {
+      startIndex = index;
+      if (endIndex == null) {
+        endIndex = index;
+      }
+    } else if (endIndex + 1 == index) {
+      endIndex = index;
+    } else
+      return;
+    byHash[commit['hash']] = commit;
+    byIndex[index] = commit;
+  }
+
+  Future<Map<String, dynamic>> fetchByHash(String hash) async {
+    final commit = await firestore.getCommit(hash);
+    if (commit == null) return null;
+    final index = commit['index'];
+    if (startIndex == null) {
+      cacheCommit(commit);
+    } else if (index < startIndex) {
+      for (int fetchIndex = startIndex - 1; fetchIndex > index; --fetchIndex) {
+        // Other invocations may be fetching simultaneously.
+        if (fetchIndex < startIndex) {
+          final infillCommit = await firestore.getCommitByIndex(fetchIndex);
+          cacheCommit(infillCommit);
+        }
+      }
+      cacheCommit(commit);
+    } else if (index > endIndex) {
+      for (int fetchIndex = endIndex + 1; fetchIndex < index; ++fetchIndex) {
+        // Other invocations may be fetching simultaneously.
+        if (fetchIndex > endIndex) {
+          final infillCommit = await firestore.getCommitByIndex(fetchIndex);
+          cacheCommit(infillCommit);
+        }
+      }
+      cacheCommit(commit);
+    }
+    return commit;
+  }
+
+  Future<Map<String, dynamic>> fetchByIndex(int index) => firestore
+      .getCommitByIndex(index)
+      .then((commit) => fetchByHash(commit['hash']));
+
+  /// This function is idempotent, so every call of it should write the
+  /// same info to new Firestore documents.  It is save to call multiple
+  /// times simultaneously.
+  Future<Null> getNewCommits() async {
+    const prefix = ")]}'\n";
+    final lastCommit = await firestore.getLastCommit();
+    final lastHash = lastCommit['hash'];
+    final lastIndex = lastCommit['index'];
+
+    final logUrl = 'https://dart.googlesource.com/sdk/+log/';
+    final range = '$lastHash..master';
+    final parameters = ['format=JSON', 'topo-order', 'n=1000'];
+    final url = '$logUrl$range?${parameters.join('&')}';
+    final response = await httpClient.get(url);
+    final protectedJson = response.body;
+    if (!protectedJson.startsWith(prefix))
+      throw Exception('Gerrit response missing prefix $prefix: $protectedJson');
+    final commits = jsonDecode(protectedJson.substring(prefix.length))['log']
+        as List<dynamic>;
+    if (commits.isEmpty) {
+      print('Found no new commits between $lastHash and master');
+      return;
+    }
+    final first = commits.last as Map<String, dynamic>;
+    if (first['parents'].first != lastHash) {
+      throw 'First new commit ${first['parents'].first} is not'
+          ' a child of last known commit $lastHash when fetching new commits';
+    }
+    var index = lastIndex + 1;
+    for (Map<String, dynamic> commit in commits.reversed) {
+      final review = _review(commit);
+      var reverted = _revert(commit);
+      var relanded = _reland(commit);
+      if (relanded != null) {
+        reverted = null;
+      }
+      if (reverted != null) {
+        final revertedCommit = await firestore.getCommit(reverted);
+        if (revertedCommit != null && revertedCommit[fRevertOf] != null) {
+          reverted = null;
+          relanded = revertedCommit[fRevertOf];
+        }
+      }
+      await firestore.addCommit(commit['commit'], {
+        fAuthor: commit['author']['email'],
+        fCreated: parseGitilesDateTime(commit['committer']['time']),
+        fIndex: index,
+        fTitle: commit['message'].split('\n').first,
+        if (review != null) fReview: review,
+        if (reverted != null) fRevertOf: reverted,
+        if (relanded != null) fRelandOf: relanded,
+      });
+      if (review != null) {
+        await landReview(commit, index);
+      }
+      ++index;
+    }
+  }
+
+  /// This function is idempotent and may be called multiple times
+  /// concurrently.
+  Future<void> landReview(Map<String, dynamic> commit, int index) async {
+    int review = _review(commit);
+    // Optimization to avoid duplicate work: if another instance has linked
+    // the review to its landed commit, do nothing.
+    if (await firestore.reviewIsLanded(review)) return;
+    await firestore.linkReviewToCommit(review, index);
+    await firestore.linkCommentsToCommit(review, index);
+  }
+}
+
+class TestingCommitsCache extends CommitsCache {
+  TestingCommitsCache(firestore, httpClient) : super(firestore, httpClient);
+
+  Future<Null> getNewCommits() async {
+    if ((await firestore.isStaging())) {
+      return super.getNewCommits();
+    }
+  }
+}
+
+const months = const {
+  'Jan': '01',
+  'Feb': '02',
+  'Mar': '03',
+  'Apr': '04',
+  'May': '05',
+  'Jun': '06',
+  'Jul': '07',
+  'Aug': '08',
+  'Sep': '09',
+  'Oct': '10',
+  'Nov': '11',
+  'Dec': '12'
+};
+
+DateTime parseGitilesDateTime(String gitiles) {
+  final parts = gitiles.split(' ');
+  final year = parts[4];
+  final month = months[parts[1]];
+  final day = parts[2].padLeft(2, '0');
+  return DateTime.parse('$year-$month-$day ${parts[3]} ${parts[5]}');
+}
+
+final reviewRegExp = RegExp(
+    '^Reviewed-on: https://dart-review.googlesource.com/c/sdk/\\+/(\\d+)\$',
+    multiLine: true);
+
+int _review(Map<String, dynamic> commit) {
+  final match = reviewRegExp.firstMatch(commit['message']);
+  if (match != null) return int.parse(match.group(1));
+  return null;
+}
+
+final revertRegExp =
+    RegExp('^This reverts commit ([\\da-f]+)\\.\$', multiLine: true);
+
+String _revert(Map<String, dynamic> commit) =>
+    revertRegExp.firstMatch(commit['message'])?.group(1);
+
+final relandRegExp =
+    RegExp('^This is a reland of ([\\da-f]+)\\.?\$', multiLine: true);
+
+String _reland(Map<String, dynamic> commit) =>
+    relandRegExp.firstMatch(commit['message'])?.group(1);
diff --git a/functions/node/firestore.dart b/functions/node/firestore.dart
index adf47d7..27bce78 100644
--- a/functions/node/firestore.dart
+++ b/functions/node/firestore.dart
@@ -7,6 +7,8 @@
 /// The implementation must be in a separate file, so that testing can
 /// run on Dart native, not just on the nodeJS platform.
 abstract class FirestoreService {
+  Future<bool> isStaging();
+
   Future<bool> hasPatchset(String review, String patchset);
 
   Future<Map<String, dynamic>> getCommit(String hash);
diff --git a/functions/node/firestore_impl.dart b/functions/node/firestore_impl.dart
index 64336f8..541b3ff 100644
--- a/functions/node/firestore_impl.dart
+++ b/functions/node/firestore_impl.dart
@@ -20,21 +20,31 @@
 }
 
 class FirestoreServiceImpl implements FirestoreService {
+  Future<bool> isStaging() =>
+      firestore.collection('staging').get().then((s) => s.isNotEmpty);
+
   Future<bool> hasPatchset(String review, String patchset) => firestore
       .document('reviews/$review/patchsets/$patchset')
       .get()
       .then((s) => s.exists);
 
+  Map<String, dynamic> _commit(DocumentSnapshot document) {
+    if (document.exists) {
+      return document.data.toMap()..['hash'] = document.documentID;
+    }
+    return null;
+  }
+
   Future<Map<String, dynamic>> getCommit(String hash) => firestore
       .document('commits/$hash')
       .get()
-      .then((s) => s.exists ? s.data.toMap() : null);
+      .then((document) => _commit(document));
 
   Future<Map<String, dynamic>> getCommitByIndex(int index) => firestore
       .collection('commits')
       .where('index', isEqualTo: index)
       .get()
-      .then((s) => s.documents.first.data.toMap());
+      .then((s) => _commit(s.documents.first));
 
   Future<Map<String, dynamic>> getLastCommit() async {
     QuerySnapshot lastCommit = await firestore
@@ -42,9 +52,7 @@
         .orderBy('index', descending: true)
         .limit(1)
         .get();
-    final result = lastCommit.documents.first.data.toMap();
-    result['id'] = lastCommit.documents.first.documentID;
-    return result;
+    return _commit(lastCommit.documents.first);
   }
 
   Future<void> addCommit(String id, Map<String, dynamic> data) async {
diff --git a/functions/node/index.dart b/functions/node/index.dart
index 0dc7766..b680190 100644
--- a/functions/node/index.dart
+++ b/functions/node/index.dart
@@ -7,9 +7,12 @@
 import 'package:node_interop/node.dart';
 
 import 'builder.dart';
+import 'commits_cache.dart';
 import 'firestore_impl.dart';
 import 'tryjob.dart';
 
+final commits = CommitsCache(FirestoreServiceImpl(), http.NodeClient());
+
 void main() {
   functions['receiveChanges'] =
       functions.pubsub.topic('results').onPublish(receiveChanges);
@@ -23,15 +26,15 @@
       ? int.parse(message.attributes['num_chunks'])
       : null;
   final String buildbucketID = message.attributes['buildbucket_id'];
+  final String baseRevision = message.attributes['base_revision'];
   try {
     var firestore = FirestoreServiceImpl();
     if (commit.startsWith('refs/changes')) {
-      return await Tryjob(
-              commit, countChunks, buildbucketID, firestore, http.NodeClient())
+      return await Tryjob(commit, countChunks, buildbucketID, baseRevision,
+              commits, firestore, http.NodeClient())
           .process(results);
     } else {
-      return await Build(
-              commit, first, countChunks, firestore, http.NodeClient())
+      return await Build(commit, first, countChunks, commits, firestore)
           .process(results);
     }
   } catch (e, trace) {
diff --git a/functions/node/test/commits_cache_test_nodejs.dart b/functions/node/test/commits_cache_test_nodejs.dart
new file mode 100644
index 0000000..5e09679
--- /dev/null
+++ b/functions/node/test/commits_cache_test_nodejs.dart
@@ -0,0 +1,102 @@
+// Copyright (c) 2020, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:firebase_admin_interop/firebase_admin_interop.dart';
+import 'package:firebase_functions_interop/firebase_functions_interop.dart';
+import 'package:node_http/node_http.dart' as http;
+import 'package:test/test.dart';
+
+import '../firestore_impl.dart' as fs;
+import '../commits_cache.dart';
+
+// These tests read and write data from the Firestore database.
+// If they are run against the production database, they will not
+// write data to the database.
+// Requires the environment variable GOOGLE_APPLICATION_CREDENTIALS
+// to point to a json key to a service account.
+// To run against the staging database, use a service account.
+// with write access to dart_ci_staging datastore.
+// Set the database with 'firebase use --add dart-ci-staging'
+// The test must be compiled with nodejs, and run using the 'node' command.
+
+void main() async {
+  final firestore = fs.FirestoreServiceImpl();
+  // create commits cache
+  final commits = TestingCommitsCache(firestore, http.NodeClient());
+  test('Test fetch first commit', () async {
+    Future<void> fetchAndTestCommit(Map<String, dynamic> commit) async {
+      final fetched = await commits.getCommit(commit['hash']);
+      final copied = Map.from(fetched)..remove('author');
+      expect(copied, commit);
+    }
+    Future<void> fetchAndTestCommitByIndex(Map<String, dynamic> commit) async {
+      final fetched = await commits.getCommitByIndex(commit['index']);
+      final copied = Map.from(fetched)..remove('author');
+      expect(copied, commit);
+    }
+    expect(commits.startIndex, isNull);
+    await fetchAndTestCommit(commit68900);
+    expect(commits.startIndex, 68900);
+    expect(commits.endIndex, 68900);
+    await fetchAndTestCommit(commit68900);
+    expect(commits.startIndex, 68900);
+    expect(commits.endIndex, 68900);
+    await fetchAndTestCommitByIndex(commit68910);
+    expect(commits.startIndex, 68900);
+    expect(commits.endIndex, 68910);
+    await fetchAndTestCommitByIndex(commit68905);
+    expect(commits.startIndex, 68900);
+    expect(commits.endIndex, 68910);
+    await fetchAndTestCommit(commit68905);
+    expect(commits.startIndex, 68900);
+    expect(commits.endIndex, 68910);
+    await fetchAndTestCommitByIndex(commit68890);
+    expect(commits.startIndex, 68890);
+    expect(commits.endIndex, 68910);
+    await fetchAndTestCommitByIndex(commit68889);
+    expect(commits.startIndex, 68889);
+    expect(commits.endIndex, 68910);
+  });
+}
+
+final commit68889 = <String, dynamic>{
+  'review': 136974,
+  'title': '[Cleanup] Removes deprecated --gc_at_instance_allocation.',
+  'index': 68889,
+  'created': Timestamp.fromDateTime(DateTime.parse("2020-02-26 16:00:26.000")),
+  'hash': '9c05fde96b62556944befd18ec834c56d6854fda'
+};
+
+final commit68890 = <String, dynamic>{
+  'review': 136854,
+  'title':
+      'Add analyzer run support to steamroller and minor QOL improvements.',
+  'index': 68890,
+  'created': Timestamp.fromDateTime(DateTime.parse("2020-02-26 17:57:46.000")),
+  'hash': '31053a8c0180b663858aadce1ff6c0eefcf78623'
+};
+
+final commit68900 = <String, dynamic>{
+  'review': 137322,
+  'title': 'Remove unused SdkPatcher.',
+  'index': 68900,
+  'created': Timestamp.fromDateTime(DateTime.parse("2020-02-26 21:20:31.000")),
+  'hash': '118d220bfa7dc0f065b441e4edd584c2b9c0edc8',
+};
+
+final commit68905 = <String, dynamic>{
+  'review': 137286,
+  'title': '[dart2js] switch bot to use hostaserts once again',
+  'index': 68905,
+  'created': Timestamp.fromDateTime(DateTime.parse("2020-02-26 22:41:47.000")),
+  'hash': '5055c98beeacb3996c256e37148b4dc3561735ee'
+};
+
+final commit68910 = <String, dynamic>{
+  'review': 137424,
+  'title': 'corpus index updates',
+  'index': 68910,
+  'created': Timestamp.fromDateTime(DateTime.parse("2020-02-27 00:19:11.000")),
+  'hash': '8fb0e62babb213c98f4051f544fc80527bcecc18',
+};
diff --git a/functions/node/test/fakes.dart b/functions/node/test/fakes.dart
index bf125a1..c283739 100644
--- a/functions/node/test/fakes.dart
+++ b/functions/node/test/fakes.dart
@@ -10,6 +10,7 @@
 import 'package:http/http.dart';
 
 import '../builder.dart';
+import '../commits_cache.dart';
 import '../firestore.dart';
 import '../result.dart';
 import 'test_data.dart';
@@ -17,12 +18,14 @@
 class BuilderTest {
   final client = HttpClientMock();
   final firestore = FirestoreServiceFake();
+  CommitsCache commitsCache;
   Build builder;
   String commitHash;
   Map<String, dynamic> firstChange;
 
   BuilderTest(this.commitHash, this.firstChange) {
-    builder = Build(commitHash, firstChange, null, firestore, client);
+    commitsCache = CommitsCache(firestore, client);
+    builder = Build(commitHash, firstChange, null, commitsCache, firestore);
   }
 
   Future<void> update() async {
@@ -94,7 +97,7 @@
             results[id][fActiveConfigurations] != null &&
             results[id][fActiveConfigurations]
                 .contains(change['configuration']))
-          results[id]..['id'] = id
+          Map.from(results[id])..['id'] = id
     ];
   }
 
diff --git a/functions/node/test/firestore_test_nodejs.dart b/functions/node/test/firestore_test_nodejs.dart
index 52b1415..cd37ea3 100644
--- a/functions/node/test/firestore_test_nodejs.dart
+++ b/functions/node/test/firestore_test_nodejs.dart
@@ -21,7 +21,7 @@
 
 void main() async {
   final firestore = fs.FirestoreServiceImpl();
-  if ((await fs.firestore.collection('staging').get()).isEmpty) {
+  if (!await firestore.isStaging()) {
     console
         .error('Error: firestore_test_nodejs.dart is being run on production');
     throw (TestFailure(
@@ -163,7 +163,8 @@
         ..addAll(
             {'commit_hash': testPreviousPatchsetPath, 'build_number': '307'});
       final buildID0 = 'test buildbucket id 0';
-      await Tryjob(testPreviousPatchsetPath, 1, buildID0, firestore, null)
+      await Tryjob(testPreviousPatchsetPath, 1, buildID0, null, null, firestore,
+              null)
           .process([previousFailingChange]);
       var snapshot = await fs.firestore
           .collection('try_results')
@@ -180,10 +181,10 @@
           {'number': testPreviousPatchset});
       // Send first chunk with a previously approved result and a passing result
       final buildID1 = 'test buildbucket id 1';
-      await Tryjob(testReviewPath, null, buildID1, firestore, null)
+      await Tryjob(testReviewPath, null, buildID1, null, null, firestore, null)
           .process([tryjobPassingChange, tryjobFailingChange]);
       // Send second & final chunk with an unchanged failure.
-      await Tryjob(testReviewPath, 2, buildID1, firestore, null)
+      await Tryjob(testReviewPath, 2, buildID1, null, null, firestore, null)
           .process([tryjobExistingFailure, tryjobFailingChange]);
       // Verify state
       snapshot = await fs.firestore
@@ -215,7 +216,7 @@
       // Send first chunk of second run on the same patchset, with an approved
       // failure and an unapproved failure.
       final buildID2 = 'test buildbucket id 2';
-      await Tryjob(testReviewPath, null, buildID2, firestore, null)
+      await Tryjob(testReviewPath, null, buildID2, null, null, firestore, null)
           .process([tryjob2OtherFailingChange, tryjob2FailingChange]);
       final reference = fs.firestore
           .document('try_builds/$testBuilder:$testReview:$testPatchset');
@@ -227,7 +228,7 @@
       expect(document.data.getInt('num_chunks'), isNull);
       expect(document.data.getInt('processed_chunks'), 1);
       // Send second chunk.
-      await Tryjob(testReviewPath, 3, buildID2, firestore, null)
+      await Tryjob(testReviewPath, 3, buildID2, null, null, firestore, null)
           .process([tryjob2ExistingFailure]);
       document = await reference.get();
       expect(document.data.getBool('success'), isFalse);
@@ -236,7 +237,7 @@
       expect(document.data.getInt('num_chunks'), 3);
       expect(document.data.getInt('processed_chunks'), 2);
       // Send third and final chunk.
-      await Tryjob(testReviewPath, null, buildID2, firestore, null)
+      await Tryjob(testReviewPath, null, buildID2, null, null, firestore, null)
           .process([tryjob2PassingChange]);
       document = await reference.get();
       expect(document.data.getBool('success'), isFalse);
@@ -247,7 +248,7 @@
 
       // Send first chunk of a third run, with only one chunk.
       final buildID3 = 'test buildbucket id 3';
-      await Tryjob(testReviewPath, 1, buildID3, firestore, null)
+      await Tryjob(testReviewPath, 1, buildID3, null, null, firestore, null)
           .process([tryjob3PassingChange]);
       document = await reference.get();
       expect(document.data.getBool('success'), isTrue);
diff --git a/functions/node/test/test.dart b/functions/node/test/test.dart
index d8dbd56..5924b5a 100644
--- a/functions/node/test/test.dart
+++ b/functions/node/test/test.dart
@@ -58,5 +58,13 @@
         Map.from(activeResult)..remove(fActiveConfigurations)..remove(fActive));
     expect(builderTest.builder.countApprovalsCopied, 1);
     expect(builderTest.builder.countChanges, 2);
+    expect(
+        builderTest.firestore.results[await builderTest.firestore.findResult(
+            landedCommitChange, landedCommitIndex, landedCommitIndex)],
+        landedResult);
+    expect(
+        (await builderTest.firestore.findActiveResults(landedCommitChange))
+          ..single.remove('id'),
+        [landedResult]);
   });
 }
diff --git a/functions/node/test/test_data.dart b/functions/node/test/test_data.dart
index 30aeaff..a941d70 100644
--- a/functions/node/test/test_data.dart
+++ b/functions/node/test/test_data.dart
@@ -71,11 +71,15 @@
 /// Changes
 /// These are single lines from a result.json, representing the output
 /// of a single test on a single configuration on a single build.
+const String sampleTestName = "local_function_signatures_strong_test/none";
+const String sampleTestSuite = "dart2js_extra";
+const String sampleTest = "$sampleTestSuite/$sampleTestName";
+
 const Map<String, dynamic> existingCommitChange = {
-  "name": "dart2js_extra/local_function_signatures_strong_test/none",
+  "name": sampleTest,
   "configuration": "dart2js-new-rti-linux-x64-d8",
-  "suite": "dart2js_extra",
-  "test_name": "local_function_signatures_strong_test/none",
+  "suite": sampleTestSuite,
+  "test_name": sampleTestName,
   "time_ms": 2384,
   "result": "Pass",
   "expected": "Pass",
@@ -95,10 +99,10 @@
 };
 
 const Map<String, dynamic> landedCommitChange = {
-  "name": "dart2js_extra/local_function_signatures_strong_test/none",
+  "name": sampleTest,
   "configuration": "dart2js-new-rti-linux-x64-d8",
-  "suite": "dart2js_extra",
-  "test_name": "local_function_signatures_strong_test/none",
+  "suite": sampleTestSuite,
+  "test_name": sampleTestName,
   "time_ms": 2384,
   "result": "RuntimeError",
   "expected": "Pass",
@@ -135,17 +139,17 @@
 // A result on existingCommit that is overridden by the new result in
 // landedCommitChange.
 Map<String, dynamic> activeResult = {
-  "name": landedCommitChange["name"],
+  "name": sampleTest,
   "blamelist_start_index": existingCommitIndex,
   "blamelist_end_index": existingCommitIndex,
   "configurations": [
     landedCommitChange["configuration"],
     'another configuration'
-  ],
+  ]..sort(),
   "active_configurations": [
     landedCommitChange["configuration"],
     'another configuration'
-  ],
+  ]..sort(),
   "active": true,
   "approved": false,
   "result": "RuntimeError",
@@ -153,6 +157,26 @@
   "previous_result": "Pass",
 };
 
+Map<String, dynamic> landedResult = {
+  "name": sampleTest,
+  "blamelist_start_index": existingCommitIndex + 1,
+  "blamelist_end_index": landedCommitIndex,
+  "pinned_index": landedCommitIndex,
+  "configurations": [
+    'another configuration',
+    landedCommitChange["configuration"],
+  ]..sort(),
+  "active_configurations": [
+    landedCommitChange["configuration"],
+    'another configuration'
+  ]..sort(),
+  "active": true,
+  "approved": true,
+  "result": "RuntimeError",
+  "expected": "Pass",
+  "previous_result": "Pass",
+};
+
 /// Try results
 /// These are documents from the try_results table in Firestore.
 const Map<String, dynamic> review44445Result = {
@@ -162,7 +186,7 @@
     "dartk-reload-rollback-linux-debug-x64",
     "dartk-reload-linux-debug-x64"
   ],
-  "name": "dart2js_extra/local_function_signatures_strong_test/none",
+  "name": sampleTest,
   "patchset": 1,
   "result": "RuntimeError",
   "expected": "Pass",
diff --git a/functions/node/tryjob.dart b/functions/node/tryjob.dart
index 3dfd943..d40fc80 100644
--- a/functions/node/tryjob.dart
+++ b/functions/node/tryjob.dart
@@ -5,6 +5,7 @@
 import 'package:http/http.dart' as http show BaseClient;
 import 'package:pool/pool.dart';
 
+import 'commits_cache.dart';
 import 'firestore.dart';
 import 'gerrit_change.dart';
 
@@ -15,7 +16,9 @@
   static final changeRefRegExp = RegExp(r'refs/changes/(\d*)/(\d*)');
   final http.BaseClient httpClient;
   final FirestoreService firestore;
+  final CommitsCache commits;
   String builderName;
+  String baseRevision;
   int buildNumber;
   int review;
   int patchset;
@@ -25,8 +28,8 @@
   int countChanges = 0;
   int countUnapproved = 0;
 
-  Tryjob(String changeRef, this.countChunks, this.buildbucketID, this.firestore,
-      this.httpClient) {
+  Tryjob(String changeRef, this.countChunks, this.buildbucketID,
+      this.baseRevision, this.commits, this.firestore, this.httpClient) {
     final match = changeRefRegExp.matchAsPrefix(changeRef);
     review = int.parse(match[1]);
     patchset = int.parse(match[2]);