From f22f3f18fcb16ad70e3fc192d4945e8f226eb445 Mon Sep 17 00:00:00 2001 From: Ivan Savytskyi Date: Wed, 9 Aug 2017 18:05:18 -0400 Subject: [PATCH] Introduce record version history for optimistic store Rolling back optimistic updates is smart now, it keeps the history of record updates. If mutation finished it's execution it rolls back only its version of the record and keeps the rest versions for still running mutations. Closes #583 --- .../apollo/OptimisticCacheTestCase.java | 93 ++++++++++++++++++ .../normalized/OptimisticNormalizedCache.java | 94 ++++++++++++++----- .../apollo/cache/normalized/Record.java | 4 + 3 files changed, 167 insertions(+), 24 deletions(-) diff --git a/apollo-integration/src/test/java/com/apollographql/apollo/OptimisticCacheTestCase.java b/apollo-integration/src/test/java/com/apollographql/apollo/OptimisticCacheTestCase.java index 5cf9132ce4e..c03083c1d7b 100644 --- a/apollo-integration/src/test/java/com/apollographql/apollo/OptimisticCacheTestCase.java +++ b/apollo-integration/src/test/java/com/apollographql/apollo/OptimisticCacheTestCase.java @@ -304,4 +304,97 @@ private MockResponse mockResponse(String fileName) throws IOException { assertThat(watcherData.get(2).reviews().get(2).stars()).isEqualTo(5); assertThat(watcherData.get(2).reviews().get(2).commentary()).isEqualTo("Amazing"); } + + @Test public void two_optimistic_reverse_rollback_order() throws Exception { + server.enqueue(mockResponse("HeroAndFriendsNameWithIdsResponse.json")); + HeroAndFriendsNamesWithIDsQuery query1 = new HeroAndFriendsNamesWithIDsQuery(Episode.JEDI); + apolloClient.query(query1).execute(); + + server.enqueue(mockResponse("HeroNameWithIdResponse.json")); + HeroNameWithIdQuery query2 = new HeroNameWithIdQuery(); + apolloClient.query(query2).execute(); + + UUID mutationId1 = UUID.randomUUID(); + HeroAndFriendsNamesWithIDsQuery.Data data1 = new HeroAndFriendsNamesWithIDsQuery.Data( + new HeroAndFriendsNamesWithIDsQuery.Hero( + "Droid", + "2001", + "R222-D222", + Arrays.asList( + new HeroAndFriendsNamesWithIDsQuery.Friend( + "Human", + "1000", + "Robocop" + ), + new HeroAndFriendsNamesWithIDsQuery.Friend( + "Human", + "1003", + "Batman" + ) + ) + ) + ); + apolloClient.apolloStore().writeOptimisticUpdatesAndPublish(query1, data1, mutationId1).execute(); + + UUID mutationId2 = UUID.randomUUID(); + HeroNameWithIdQuery.Data data2 = new HeroNameWithIdQuery.Data(new HeroNameWithIdQuery.Hero( + "Human", + "1000", + "Spiderman" + )); + apolloClient.apolloStore().writeOptimisticUpdatesAndPublish(query2, data2, mutationId2).execute(); + + // check if query1 see optimistic updates + data1 = apolloClient.query(query1).responseFetcher(ApolloResponseFetchers.CACHE_ONLY).execute().data(); + assertThat(data1.hero().id()).isEqualTo("2001"); + assertThat(data1.hero().name()).isEqualTo("R222-D222"); + assertThat(data1.hero().friends()).hasSize(2); + assertThat(data1.hero().friends().get(0).id()).isEqualTo("1000"); + assertThat(data1.hero().friends().get(0).name()).isEqualTo("Spiderman"); + assertThat(data1.hero().friends().get(1).id()).isEqualTo("1003"); + assertThat(data1.hero().friends().get(1).name()).isEqualTo("Batman"); + + // check if query2 see the latest optimistic updates + data2 = apolloClient.query(query2).responseFetcher(ApolloResponseFetchers.CACHE_ONLY).execute().data(); + assertThat(data2.hero().id()).isEqualTo("1000"); + assertThat(data2.hero().name()).isEqualTo("Spiderman"); + + // rollback query2 optimistic updates + apolloClient.apolloStore().rollbackOptimisticUpdates(mutationId2).execute(); + + // check if query1 see the latest optimistic updates + data1 = apolloClient.query(query1).responseFetcher(ApolloResponseFetchers.CACHE_ONLY).execute().data(); + assertThat(data1.hero().id()).isEqualTo("2001"); + assertThat(data1.hero().name()).isEqualTo("R222-D222"); + assertThat(data1.hero().friends()).hasSize(2); + assertThat(data1.hero().friends().get(0).id()).isEqualTo("1000"); + assertThat(data1.hero().friends().get(0).name()).isEqualTo("Robocop"); + assertThat(data1.hero().friends().get(1).id()).isEqualTo("1003"); + assertThat(data1.hero().friends().get(1).name()).isEqualTo("Batman"); + + // check if query2 see the latest optimistic updates + data2 = apolloClient.query(query2).responseFetcher(ApolloResponseFetchers.CACHE_ONLY).execute().data(); + assertThat(data2.hero().id()).isEqualTo("1000"); + assertThat(data2.hero().name()).isEqualTo("Robocop"); + + // rollback query1 optimistic updates + apolloClient.apolloStore().rollbackOptimisticUpdates(mutationId1).execute(); + + // check if query1 see the latest non-optimistic updates + data1 = apolloClient.query(query1).responseFetcher(ApolloResponseFetchers.CACHE_ONLY).execute().data(); + assertThat(data1.hero().id()).isEqualTo("2001"); + assertThat(data1.hero().name()).isEqualTo("R2-D2"); + assertThat(data1.hero().friends()).hasSize(3); + assertThat(data1.hero().friends().get(0).id()).isEqualTo("1000"); + assertThat(data1.hero().friends().get(0).name()).isEqualTo("SuperMan"); + assertThat(data1.hero().friends().get(1).id()).isEqualTo("1002"); + assertThat(data1.hero().friends().get(1).name()).isEqualTo("Han Solo"); + assertThat(data1.hero().friends().get(2).id()).isEqualTo("1003"); + assertThat(data1.hero().friends().get(2).name()).isEqualTo("Leia Organa"); + + // check if query2 see the latest non-optimistic updates + data2 = apolloClient.query(query2).responseFetcher(ApolloResponseFetchers.CACHE_ONLY).execute().data(); + assertThat(data2.hero().id()).isEqualTo("1000"); + assertThat(data2.hero().name()).isEqualTo("SuperMan"); + } } diff --git a/apollo-runtime/src/main/java/com/apollographql/apollo/cache/normalized/OptimisticNormalizedCache.java b/apollo-runtime/src/main/java/com/apollographql/apollo/cache/normalized/OptimisticNormalizedCache.java index 66abcb0e963..606b788a667 100644 --- a/apollo-runtime/src/main/java/com/apollographql/apollo/cache/normalized/OptimisticNormalizedCache.java +++ b/apollo-runtime/src/main/java/com/apollographql/apollo/cache/normalized/OptimisticNormalizedCache.java @@ -7,12 +7,11 @@ import com.nytimes.android.external.cache.Cache; import com.nytimes.android.external.cache.CacheBuilder; -import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashSet; -import java.util.List; +import java.util.LinkedList; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -23,7 +22,7 @@ import static com.apollographql.apollo.api.internal.Utils.checkNotNull; public final class OptimisticNormalizedCache extends NormalizedCache { - private final Cache lruCache = CacheBuilder.newBuilder().build(); + private final Cache lruCache = CacheBuilder.newBuilder().build(); @Nullable @Override public Record loadRecord(@Nonnull final String key, @Nonnull final CacheHeaders cacheHeaders) { checkNotNull(key, "key == null"); @@ -36,15 +35,15 @@ public final class OptimisticNormalizedCache extends NormalizedCache { return Optional.fromNullable(cache.loadRecord(key, cacheHeaders)); } }); - final Record optimisticRecord = lruCache.getIfPresent(key); - if (optimisticRecord != null) { - return nonOptimisticRecord.transform(new Function() { + final RecordJournal journal = lruCache.getIfPresent(key); + if (journal != null) { + return nonOptimisticRecord.map(new Function() { @Nonnull @Override public Record apply(@Nonnull Record record) { - Record result = record.toBuilder().build(); - result.mergeWith(optimisticRecord); + Record result = record.clone(); + result.mergeWith(journal.snapshot); return result; } - }).or(optimisticRecord); + }).or(journal.snapshot.clone()); } else { return nonOptimisticRecord.orNull(); } @@ -102,31 +101,78 @@ public final class OptimisticNormalizedCache extends NormalizedCache { @Nonnull public Set mergeOptimisticUpdate(@Nonnull final Record record) { checkNotNull(record, "record == null"); - final Record oldRecord = lruCache.getIfPresent(record.key()); - if (oldRecord == null) { - lruCache.put(record.key(), record); + final RecordJournal journal = lruCache.getIfPresent(record.key()); + if (journal == null) { + lruCache.put(record.key(), new RecordJournal(record)); return Collections.singleton(record.key()); } else { - Set changedKeys = oldRecord.mergeWith(record); - //re-insert to trigger new weight calculation - lruCache.put(record.key(), oldRecord); - return changedKeys; + return journal.commit(record); } } @Nonnull public Set removeOptimisticUpdates(@Nonnull final UUID mutationId) { checkNotNull(mutationId, "mutationId == null"); - Map cachedRecords = lruCache.asMap(); - List invalidateKeys = new ArrayList<>(); - for (Map.Entry cachedRecordEntry : cachedRecords.entrySet()) { - if (mutationId.equals(cachedRecordEntry.getValue().mutationId()) - || cachedRecordEntry.getValue().mutationId() == null) { - invalidateKeys.add(cachedRecordEntry.getKey()); + Set changedCacheKeys = new HashSet<>(); + Set removedKeys = new HashSet<>(); + Map recordJournals = lruCache.asMap(); + for (Map.Entry entry : recordJournals.entrySet()) { + String cacheKey = entry.getKey(); + RecordJournal journal = entry.getValue(); + changedCacheKeys.addAll(journal.revert(mutationId)); + if (journal.history.isEmpty()) { + removedKeys.add(cacheKey); } } - lruCache.invalidateAll(invalidateKeys); + lruCache.invalidateAll(removedKeys); + return changedCacheKeys; + } + + private final class RecordJournal { + Record snapshot; + final LinkedList history = new LinkedList<>(); + + RecordJournal(Record mutationRecord) { + this.snapshot = mutationRecord.clone(); + this.history.add(mutationRecord.clone()); + } + + /** + * Commits new version of record to the history and invalidate snapshot version. + */ + Set commit(Record record) { + history.addLast(record.clone()); + return snapshot.mergeWith(record); + } - return new HashSet<>(invalidateKeys); + /** + * Lookups record by mutation id, if it's found removes it from the history and invalidates snapshot record. + * Snapshot record is superposition of all record versions in the history. + */ + Set revert(UUID mutationId) { + int recordIndex = -1; + for (int i = 0; i < history.size(); i++) { + if (mutationId.equals(history.get(i).mutationId())) { + recordIndex = i; + break; + } + } + + if (recordIndex == -1) { + return Collections.emptySet(); + } + + Set changedKeys = new HashSet<>(); + changedKeys.add(history.remove(recordIndex).key()); + for (int i = Math.max(0, recordIndex - 1); i < history.size(); i++) { + Record record = history.get(i); + if (i == Math.max(0, recordIndex - 1)) { + snapshot = record.clone(); + } else { + changedKeys.addAll(snapshot.mergeWith(record)); + } + } + return changedKeys; + } } } diff --git a/apollo-runtime/src/main/java/com/apollographql/apollo/cache/normalized/Record.java b/apollo-runtime/src/main/java/com/apollographql/apollo/cache/normalized/Record.java index 99590fa0716..6e3a5d43637 100644 --- a/apollo-runtime/src/main/java/com/apollographql/apollo/cache/normalized/Record.java +++ b/apollo-runtime/src/main/java/com/apollographql/apollo/cache/normalized/Record.java @@ -91,6 +91,10 @@ public UUID mutationId() { return mutationId; } + public Record clone() { + return toBuilder().build(); + } + /** * @param otherRecord The record to merge into this record. * @return A set of field keys which have changed, or were added. A field key incorporates any GraphQL arguments in