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

Respect Cache-Control headers #462

Merged
merged 12 commits into from
Jun 13, 2018
Merged

Conversation

wiseoldduck
Copy link
Contributor

The PINCache upgrade bundled into this has also been submitted as a standalone PR

Correct selector

[NSDate stopMockingDate];

Update .podspec
Copy link
Contributor

@appleguy appleguy left a comment

Choose a reason for hiding this comment

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

This is a really great improvement. Thanks for working on it, @wiseoldduck ! It definitely adds value to PINRemoteImage (in my opinion), as most sophisticated backends do set these headers.

@garrettmoon take a look when you have time — since we'll be merging this internally by Wednesday EOD, it would be ideal to have the final tweaks done. We can re-sync again later if it takes longer to review, though. Thanks!

@@ -116,6 +116,7 @@ @interface PINRemoteImageManager () <PINURLSessionManagerDelegate>
@property (nonatomic, copy) id<PINRequestRetryStrategy> (^retryStrategyCreationBlock)(void);
@property (nonatomic, copy) PINRemoteImageManagerRequestConfigurationHandler requestConfigurationHandler;
@property (nonatomic, strong) NSMutableDictionary <NSString *, NSString *> *httpHeaderFields;
@property(nonatomic, assign) BOOL isTtlCache;
Copy link
Contributor

Choose a reason for hiding this comment

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

To match the codebase, space before ( -- which is not done in some other codebases.

I would suggest a different name, maybe respectCacheControlHeaders, trackImageExpiry, enforceCacheAgeLimits - since this class is not by itself a cache, it's more like a class managing a cache than "isTtlCache".

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Spacing added.
Since the underlying cache is not necessarily PINCache, this var is storing (caching) whether the cache implements the optional - (void)setObjectOnDisk:(id)object forKey:(NSString *)key withAgeLimit:(NSTimeInterval)ageLimit method, that is whether it is a Ttl cache or not. So I think the name is accurate, (although maybe could be cacheIsTtlCache)...

@@ -784,13 +786,62 @@ - (void)downloadImageWithURL:(NSURL *)url
NSError *remoteImageError = error;
PINImage *image = nil;
id alternativeRepresentation = nil;
__block NSNumber *maxAge = nil;
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 to box this as an NSNumber? If not, NSInteger should result shorter / faster code, as long as cases like NSNotFound or other sentinel values are handled.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, we need to distinguish between "set to 0" and "not set" (nil)

if (remoteImageError == nil) {
//stores the object in the caches
[self materializeAndCacheObject:data cacheInDisk:data additionalCost:0 url:url key:key options:options outImage:&image outAltRep:&alternativeRepresentation];
if (_isTtlCache && !(options & PINRemoteImageManagerDownloadOptionsIgnoreCacheControlHeaders)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Believe it or not, this bitwise-AND should not be inverted / handled as a BOOL as it can fail on specifically 32-bit ARM! Our team had some nasty issues with this last Fall.

If other parts of the file have this bug as well, we should introduce a macro similar to ASInterfaceStateIncludes() or ASCellLayoutModeIncludes() to resolve.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

lolwut Citation Needed

No I believe you of course but would love to learn more! The details would inform me how to rewrite this, is putting the x & y result into a BOOL first sufficient?

if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *) response;

[[[httpResponse allHeaderFields][@"Cache-Control"] componentsSeparatedByString:@","] enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Since these are complex lines of code, and we access this twice, local variable?

NSArray *allHeaderFields = ...


if ([trimmed isEqualToString:@"no-store"] || [trimmed isEqualToString:@"must-revalidate"] || [trimmed isEqualToString:@"no-cache"]) {
maxAge = @(0);
*stop = YES;
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd suggest we just use for( ... in ...), as it is both faster - NSFastEnumeration and avoids the internal @autorelease pool that is otherwise created around every block invocation - and it also is a bit nicer looking (less block boilerplate with the arguments, easier to write "break";, etc)

This could be, for (NSString *field in [allHeaderFields[@"Cache-Control"] componentsSeparatedByString:@",") { }

if (!maxAge && (expires = [httpResponse allHeaderFields][@"Expires"])) {
// https://developer.apple.com/library/archive/qa/qa1480/_index.html

static NSDateFormatter *sRFC7231PreferredDateFormatter;
Copy link
Contributor

Choose a reason for hiding this comment

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

Assign nil. I'm not sure we should rely on static variables being zero-initialized.

Also, we should move this out to an accessor method. Ideally static variables are declared outside methods (even if just above the method). In this case a relatively complex method would be shortened if we moved the cached formatter to an accessor like +preferredCacheControlDateFormatter.

}
}
}
//stores the object in the caches
Copy link
Contributor

Choose a reason for hiding this comment

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

Space & caps after //

@@ -1356,7 +1419,15 @@ - (BOOL)materializeAndCacheObject:(id)object
}

if (diskData) {
[self.cache setObjectOnDisk:diskData forKey:key];
// maxAge of 0 is not stored at all
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you clarify this comment? Does this mean "maxAge of 0 means that images should not be stored at all" ?

Is there a value for maxAge that means infinite?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done (yes that's what it meant).
There is no header that can be sent to indicate "infinite", which is why you will see so many HTTP responses with values like "one year in the future". However not setting a value at all, which in our case is represented by maxAge == nil, does effectively mean that. (In our case it will use the cache-global maxAge)

/**
* Do not honor HTTP Cache-Control headers
* Currently PINRemoteImage will by default respect 'no-store', 'no-cache', 'max-age', 'Expires', and 'must-revalidate'. Set this flag to ignore those headers.
* TODO: Currently PINRemoteImage will re-download images that only must be re-validated. In the future this could be improved with revalidation behavior that stores ETag or Last-Modified values and only
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor / optional: "By default, PINRemoteImage will respect...".

Could also drop "Currently" in the next line, as presumably that will be updated if it becomes supported in the future. The next line also seems like it could use a line break to stay within a ~120 (or a bit more) column width — which isn't a hard rule for these frameworks, but up to ~150 works pretty well for Obj-C.

Great comment overall, thanks for including the IETF and Mozilla links!

@@ -943,6 +944,51 @@ - (void)testQOS
XCTAssert(dispatch_semaphore_wait(semaphore, [self timeout]) == 0, @"Semaphore timed out.");
}

- (void)testMaxAge
Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for adding tests! Maybe call this testCacheControlMaxAgeExpiry, or just testCacheControl? Since the maxAge comes from cache control, the cache-control seems like the root nature / focus of the test.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

"testCacheControlSupport"?

Reword `PINRemoteImageManagerDownloadOptionsIgnoreCacheControlHeaders` comment
create static accessor for `RFC7231PreferredDateFormatter`
Replace `enumerateObjectsUsingBlock` with `for (NSString *component in`...
Rename test
Added a comment
@pinterest pinterest deleted a comment Jun 11, 2018
@pinterest pinterest deleted a comment Jun 11, 2018
@pinterest pinterest deleted a comment Jun 11, 2018
@pinterest pinterest deleted a comment Jun 11, 2018
@pinterest pinterest deleted a comment Jun 11, 2018
{
[self.diskCache setObject:object forKey:key withAgeLimit:ageLimit];
}

-(void)setObjectOnDisk:(id)object forKey:(NSString *)key
{
[self.diskCache setObject:object forKey:key];
Copy link
Collaborator

Choose a reason for hiding this comment

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

Super minor: maybe have this call [self setObjectOnDisk:object forKey:key withAgeLimit:0];?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

OK

if ([key hasPrefix:PINRemoteImageCacheKeyResumePrefix]) {
return [NSKeyedUnarchiver unarchiveObjectWithData:data];
}
return data;
}];
} keyEncoder:nil keyDecoder:nil ttlCache:YES];
Copy link
Collaborator

Choose a reason for hiding this comment

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

Dang, so setting this to YES will cause a significant performance hit (PINDiskCache can't asynchronously startup), even if you don't end up using the feature. I'm wondering if we should actually have a flag on init which allows you to disable (or perhaps it should actually be off by default) support of TTL? We could possibly log warnings if we encounter a cache control header and this is NO.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nooooooooooooooooooooooooo

@@ -116,6 +116,7 @@ @interface PINRemoteImageManager () <PINURLSessionManagerDelegate>
@property (nonatomic, copy) id<PINRequestRetryStrategy> (^retryStrategyCreationBlock)(void);
@property (nonatomic, copy) PINRemoteImageManagerRequestConfigurationHandler requestConfigurationHandler;
@property (nonatomic, strong) NSMutableDictionary <NSString *, NSString *> *httpHeaderFields;
@property (nonatomic, assign) BOOL isTtlCache;
Copy link
Collaborator

Choose a reason for hiding this comment

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

This should probably be readonly since it's accessed outside the lock and only set on init?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes

if (remoteImageError == nil) {
//stores the object in the caches
[self materializeAndCacheObject:data cacheInDisk:data additionalCost:0 url:url key:key options:options outImage:&image outAltRep:&alternativeRepresentation];
BOOL ignoreHeaders = (options & PINRemoteImageManagerDownloadOptionsIgnoreCacheControlHeaders) != 0;
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: name this ignoreCacheHeaders

Copy link
Collaborator

Choose a reason for hiding this comment

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

Would it be more readable to make a category on the response which returns maxAge? RFC7231PreferredDateFormatter could live there too?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

🤔

// and while I cannot find any explicit instruction of how to behave
// with a malformed "max-age" header, it seems like a reasonable approach.
maxAge = @([split[1] integerValue]);
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Since invalid means do not cache, should there be an else here setting maxAge to 0?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

hmm well the else would be if [split count] == 0, in which case it was not set at all (and nil is correct), or [split count] > 2, in which case you are correct but it's a pretty weird case with a header like Cache-Control: maxage=maxage=444

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 added a condition for the pretty weird case

// maxAge of 0 means that images should not be stored at all.
// There is no HTTP header that can be sent to indicate "infinite". However not setting a value at all, which in
// our case is represented by maxAge == nil, effectively means that.
if (!(maxAge && [maxAge integerValue] == 0)) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this more readable (honest question)? (I think it's still correct)

if (maxAge && [maxAge integerValue] != 0 && _isTtlCache) {
    [self.cache setObjectOnDisk:diskData forKey:key withAgeLimit:[maxAge integerValue]];
} else if (!maxAge) {
    // unset (nil) maxAge, or a cache that is not _isTtlCache behave as before (will use cache global ageLimit)
    [self.cache setObjectOnDisk:diskData forKey:key];
}

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 dunno? Yes it looks still correct, and the first case is more readable, but it seems harder to reason through the other two possibilities...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No I think it is also incorrect in that if maxAge is set but _isTtlCache is false, the proposal fails to call the old method (does not cache at all)

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 this may suggest we should have a simple local variable with a more descriptive name, like:

BOOL neverStoreImage = (maxAge && [maxAge integerValue] == 0);
if (neverStoreImage == NO) {

}

There's probably a better variable name or another way to do it that is more readable still, like you could check:

BOOL storeImageIndefinitely = !maxAge;
if (storeImageIndefinitely || [maxAge integerValue] > 0) {

}

This second one might be the most readable?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Excellent suggestion! See the newest edition 👍

@@ -943,6 +944,51 @@ - (void)testQOS
XCTAssert(dispatch_semaphore_wait(semaphore, [self timeout]) == 0, @"Semaphore timed out.");
}

- (void)testCacheControlSupport
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think these would be (slightly) better tests if they actually checked the results of a second download instead of just seeing if the objects are still in the cache. Or perhaps both? PINRemoteImage will tell you how it got an object in result.resultType.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

OK.

Well it would be second & third downloads, to confirm that it got it from the cache and then did not.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This change will make more obvious another limitation - The PINCache TTL implementation only applies to the disk cache. If you kept an app alive long enough, without memory warnings, you could still get results from the memory cache that the header would have suggested you shouldn't.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

isTtlCache is now readonly property
setObjectOnDisk:forKey: uses setObjectOnDisk:forKey:withAgeLimit:
weird case of multiple maxage=maxage=maxages covered
…rent startup impl of ttlCache)

Provide + (nonnull id<PINRemoteImageCaching>)defaultImageTtlCache; to get a TTL cache if you want it
Other code review updates: add local vars for readability in -[PINRemoteImageManager materializeAndCacheObject...
@garrettmoon garrettmoon merged commit 65f564b into pinterest:master Jun 13, 2018
@garrettmoon
Copy link
Collaborator

Thanks so much for your contribution @wiseoldduck !

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.

None yet

3 participants