Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Normalized Cache structure and Response normalization #260

Merged
merged 11 commits into from
Mar 3, 2017

Conversation

BenSchwab
Copy link
Contributor

@BenSchwab BenSchwab commented Mar 1, 2017

This PR will focus on the general structure of the normalized cache, and response normalization (writing to the cache). Reading from the cache is not done.

Some change summaries:

  • Give BufferedResponseReader a ResponseReaderShadow to allow normalization while reading network response
  • GraphQLResponseNormalizer contains the meat of the normalization construction process, which mostly matches iOS implementation
  • Temporarily use unbounded in memory cache InMemoryNormalizedCacheStore (but may prove useful as an intermediate store)
  • NormalizedCacheStore allows user to plug in how cached data is stored
  • Set up rough structure of NormalizedCache

Closes 258

@BenSchwab
Copy link
Contributor Author

@sav007 @digitalbuddha

Still working on writing tests, cleaning things up etc. But let me know any early thoughts/concerns.

This should roughly set the structure of the normalized cache (similar pattern to network cache), and I will be able to break out a lot of smaller tasks including:

  • Preparing ApolloRecord for easy disk/serialized cache support
  • Adding reading from NormalizedCache via interceptor
  • Cache expiration, fetch strategy
  • Query watching

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.ben_schwab.topshelf.apollointegration">

<application android:allowBackup="true" android:label="@string/app_name" android:supportsRtl="true">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my understanding of android:allowBackup flag is that if any manifest that is being merged has that flag set then the client app will have it no matter what their main manifest says. Our internal policy is to have libraries define android:allowBackup="false" so that client apps can still overwrite it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops, yeah I have been burnt by this before

"name": "R2-D2"
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing newline eof, checkstyle might have trouble.

@@ -0,0 +1,7 @@
{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems identical to EpisodeHeroNameResponse.json confirming that both are needed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is identical, just thought for organizational purposes 1 graphql query = 1 graphql response of the same name.


private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US);

ApolloClient apolloClient;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason not to make fields private?

.serverUrl(server.url("/"))
.okHttpClient(okHttpClient)
.normalizedCache(cacheStore, new CacheKeyResolver() {
@Nullable @Override public String cacheKeyForJsonObject(Map<String, Object> jsonObject) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: method should be named resolve unless there are other variants of how the resolver can work

Response<HeroName.Data> body = call.execute();
assertThat(body.isSuccessful()).isTrue();

ApolloRecord record = cacheStore.read("QUERY_ROOT");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets move the next few strings into private constants

}


// private static String readNormalizerJsonFile(String name) throws IOException {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove?


void didParseObject(Map<String, Object> objectMap);

void didParseList(List array);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

willParseList not necessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iOS only uses willParseList to allocate more space on their "stack" data structure. I'm not aware of any similar pattern in Java (besides setting the size when a collection is first initialized)

import java.util.HashMap;
import java.util.Map;

//Todo: Enhance this class to better support generalized serialization ISSUE____
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reminder to create issue :-)


public final String key;

//Todo: provide serialized wrapper?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same, imo every TODO needs issue for tracking.

//Todo: Enhance this class to better support generalized serialization ISSUE____
public class ApolloRecord {

public final String key;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does key need to be public? any reason not to have key() and keep the field private?

import javax.annotation.Nullable;

public interface CacheKeyResolver {
@Nullable String cacheKeyForJsonObject(Map<String, Object> jsonObject);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nonnull on param?


public class InMemoryNormalizedCacheStore extends NormalizedCacheStore {

private final Map<String, ApolloRecord> store = new HashMap<>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick and sorta opinion to use LinkedHashMap https://publicobject.com/2016/02/08/linkedhashmap-is-always-better-than-hashmap/ also do we need to worry about threading here?

}
}

public Collection<ApolloRecord> getAllRecords() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unless there is a setAllRecords I don't think the get prefix is necessary (similar to how its store.values() not store.getValues())

Copy link
Contributor

@digitalbuddha digitalbuddha left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nothing major, just did some human linting. Looks good overall! great job!!

import java.util.List;
import java.util.Map;

public interface ResponseReaderShadow {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we hide this to package protected?

import java.util.Map;

//Todo: Enhance this class to better support generalized serialization ISSUE____
public class ApolloRecord {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

package protected, final ?

@sav007
Copy link
Contributor

sav007 commented Mar 1, 2017

General comments:

  • should we drop prefix normalized and just use cache
  • by default make all classes as closed as possible (make final, package protected etc)


public class GraphQLResponseNormalizer implements ResponseReaderShadow {

private Stack<List<String>> pathStack;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stack is based on Vector that is synchronized. Do we really need synchronization? Question about performance, should we replace with light weight impl of stack structure?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't need synchronization here. Any suggestions to a lighter stack impl?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking most of custom structure that behaves as stack, but we can use ArrayDeque

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried ArrayDeque, but it does not support nulls. So rolled our own "SimpleStack"

this.cacheKeyResolver = cacheKeyResolver;
}

public NormalizedCacheStore getCacheStore() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: We are avoiding of get so cacheStore()

return cacheKeyResolver;
}

public GraphQLResponseNormalizer createResponseNormalizer() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: responseNormalizer()

private List<String> path;
private ApolloRecord currentRecord;

private InMemoryNormalizedCacheStore recordSet;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't it be generic NormalizedCacheStore ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't sure if I wanted to add allRecords to the generic interface, which is necessary for the NormalizedCacheStore. This is really more of a per-request store that will be thrown out once parsing is done (and the results added to the aggregate store the user supplies)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced with the concept of a RecordSet which is more inline with the iOS implementation


path = new ArrayList<>();
currentRecord = new ApolloRecord(rootKeyForOperation(operation));
recordSet = new InMemoryNormalizedCacheStore();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intentionally, use InMemoryNormalizedCacheStore shouldn't it be configured in constructor what impl of NormalizedCacheStore will be used?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking that using a intermediate store might be valuable as if there is a parsing error in the middle of normalization, we probably don't want to have some of the records from the response stored, and others not.

Also, some store implementations may be more efficient with a batched call to mergeOrInsert(Collection) as opposed to serial calls to mergeOrInsert(Record)

Copy link
Contributor

@sav007 sav007 Mar 1, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if there is a parsing error

for that we def need to support transactions, so that we can roll back if error happen

with a batched call

well this is totally up to store impl, it can accumulate all merges internally and save it in one short, but for us it should be transparent

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like a more scalable solution is to use temporary names within the store. So you start writing to a file/record called temp, if everything succeeds you rename to the actual record filename/key. This way there's no transactions and no duplication of memory allocation.

@@ -17,15 +19,20 @@
private final Operation operation;
private final ResponseFieldMapper responseFieldMapper;
private final Map<ScalarType, CustomTypeAdapter> customTypeAdapters;
private final NormalizedCache normalizedCache;
private final GraphQLResponseNormalizer resultNormalizer;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: responseNormalizer ?


import javax.annotation.Nullable;

public class GraphQLResponseNormalizer implements ResponseReaderShadow {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GraphQLResponseNormalizer -> ResponseNormalizer ?

@@ -53,6 +60,7 @@
}
}
jsonReader.endObject();
this.normalizedCache.getCacheStore().mergeOrInsert(resultNormalizer.getRecords());
Copy link
Contributor

@sav007 sav007 Mar 1, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we expect any Exception here? What if this operation failed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open to discussion on what the right behavior is. I was thinking on an Exception we don't normalize anything.


import java.util.Collection;

public abstract class NormalizedCacheStore {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are all methods safe? I mean do we expect any exceptions while we reading/merging etc?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious to your architecture opinion on this. I tend to lean towards requiring implementations to catch their own IO related errors.
Generally, an exception on read can be replaced with null.

I wouldn't be sure what to do with exception on a merge though..

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that broken cache, should only fail in one case when cache only policy is set.
Other than that if cache is broken for some reason, we should always hit network.
Of course we need to log that as error for user to debug, plus user should understand if response is taken from cache or from network, to proper handle this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It almost feels like we want to return an absent optional here. Or something similar. So when reading it can return an empty container or something.

@stubailo
Copy link
Contributor

stubailo commented Mar 1, 2017

Hey - it looks like a lot of these comments are focused on things like variable naming and code style - are there any comments about the overall design or architecture?

@digitalbuddha
Copy link
Contributor

Nothing jumped out as an issue from an architectural standpoint.

@sav007
Copy link
Contributor

sav007 commented Mar 1, 2017

so far design looks reasonable, no any major concerns

@BenSchwab BenSchwab force-pushed the bschwab/normalized-cache-v1 branch from 169cf27 to 9a2c213 Compare March 2, 2017 20:46
@@ -64,15 +67,28 @@
value = readCustomType((Field.CustomTypeField) field);
break;
case CONDITIONAL:
value = readConditional((Field.ConditionalTypeField) field);
value = readConditional((Field.ConditionalTypeField) field, operation.variables());
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parsing for conditional is slightly strange

When we call readConditional, the field is actually __typename and not the conditional field. However, we will need to store the _typename for reading from the cache. Thus, read conditional actually stores two values at once, and can't follow as clean as a pattern as every other type of field.

@@ -53,6 +59,7 @@
}
}
jsonReader.endObject();
this.cache.cacheStore().merge(responseNormalizer.records());
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resurfacing some comments from earlier:

I still feel like the right thing is to either store all, or none of the records from a response, and not burden cachestore implementations with the concept of transactions, or rollbacks.

I also don't love that the ResponseBodyConverter is saving the records at all. I feel like it's job should be to create the records as the response is parsed, and then return them with the Data.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is to either store all, or none of the records from a response

Completely agree. And that what I actually meant by transaction, if we failed to parse then don't store at all. But at the same time we need to make sure that responseNormalizer won't crash parsing network response

I also don't love that the ResponseBodyConverter is saving the records at all

can we pass the instance of normalizer in constructor and move logic of merging to RealApolloCall?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. The normalizer should be a single use object, so changed it to have RealApolloCall send it ResponseBodyConverter and then save the records.

Also, good point about responseNormalizer crashing. In theory, if it's built right, and we get a well-structured graphql response, it won't throw an error. On a malformed response, it may throw something. Not sure who would error first -- the generated response reading, or the normalizer in that case.

@BenSchwab
Copy link
Contributor Author

@sav007 Looks like you moved the http code to cache/http. should this stuff go in cache/normalized

@digitalbuddha
Copy link
Contributor

Sounds reasonable. The other one should be in cache/http maybe?

@BenSchwab BenSchwab changed the title [WIP] Add Normalized Cache structure and Response normalization Add Normalized Cache structure and Response normalization Mar 2, 2017

public final class InMemoryCacheStore extends CacheStore {

private final RecordSet recordSet;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to have RecordSet or we can merge it to this class?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel that RecordSet better describes it's purpose (a in memory data structure to store records).

I don't think we will keep InMemoryCacheStore around for very long. I think we will want to ship with some sort CacheStore implementation, but I think it will either be disk based or at least a LRU in-memory cache.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that I think about it, let's not include this at all in the runtime. The only thing using it is the tests, so I will move it there. We can cross the bridge of what default to ship with later.

@sav007
Copy link
Contributor

sav007 commented Mar 3, 2017

One question do we need really RecordSet or we can work with CacheStore only?

return responseBodyConverter.convert(response.body());
ResponseNormalizer normalizer = cache.responseNormalizer();
Response<T> convertedResponse = responseBodyConverter.convert(response.body(), normalizer);
cache.cacheStore().merge(normalizer.records());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we wrap this with own try/catch?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the question is if someone has a faulty cacheStore, is it better to fail the entire response, or catch the error?

My issue is if we catch the error, how to we communicate that the cacheStore is faulty?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we can log but I guess you are right, lets leave it and fail for now

Copy link
Contributor

@sav007 sav007 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@digitalbuddha
Copy link
Contributor

lgtm as well.

@BenSchwab BenSchwab merged commit ad3f3bb into master Mar 3, 2017
@BenSchwab BenSchwab deleted the bschwab/normalized-cache-v1 branch March 3, 2017 18:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants