-
Notifications
You must be signed in to change notification settings - Fork 660
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
Conversation
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:
|
@@ -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"> |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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" | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
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 @@ | |||
{ |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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"); |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
willParseList
not necessary?
There was a problem hiding this comment.
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____ |
There was a problem hiding this comment.
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? |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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<>(); |
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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())
There was a problem hiding this 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 { |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
package protected, final ?
General comments:
|
|
||
public class GraphQLResponseNormalizer implements ResponseReaderShadow { | ||
|
||
private Stack<List<String>> pathStack; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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..
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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? |
Nothing jumped out as an issue from an architectural standpoint. |
so far design looks reasonable, no any major concerns |
169cf27
to
9a2c213
Compare
@@ -64,15 +67,28 @@ | |||
value = readCustomType((Field.CustomTypeField) field); | |||
break; | |||
case CONDITIONAL: | |||
value = readConditional((Field.ConditionalTypeField) field); | |||
value = readConditional((Field.ConditionalTypeField) field, operation.variables()); |
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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.
@sav007 Looks like you moved the http code to |
Sounds reasonable. The other one should be in cache/http maybe? |
|
||
public final class InMemoryCacheStore extends CacheStore { | ||
|
||
private final RecordSet recordSet; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
One question do we need really |
return responseBodyConverter.convert(response.body()); | ||
ResponseNormalizer normalizer = cache.responseNormalizer(); | ||
Response<T> convertedResponse = responseBodyConverter.convert(response.body(), normalizer); | ||
cache.cacheStore().merge(normalizer.records()); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lgtm
lgtm as well. |
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:
BufferedResponseReader
aResponseReaderShadow
to allow normalization while reading network responseGraphQLResponseNormalizer
contains the meat of the normalization construction process, which mostly matches iOS implementationInMemoryNormalizedCacheStore
(but may prove useful as an intermediate store)NormalizedCacheStore
allows user to plug in how cached data is storedNormalizedCache
Closes 258