diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ea494d..01d6656f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - [fix] Fix up warnings and upgrade to PINOperation 1.1.1: [#213](https://github.com/pinterest/PINCache/pull/213) - [performance] Reduce locking churn in cleanup methods. [#212](https://github.com/pinterest/PINCache/pull/212) - [fix] Don't set file protection unless requested. [#220](https://github.com/pinterest/PINCache/pull/220) +- [new] Add ability to set an object level TTL: [#209](https://github.com/pinterest/PINCache/pull/209) ## 3.0.1 -- Beta 6 - [fix] Add some sane limits to the disk cache: [#201]https://github.com/pinterest/PINCache/pull/201 diff --git a/PINCache.xcodeproj/project.pbxproj b/PINCache.xcodeproj/project.pbxproj index 412e2577..b2fddb24 100644 --- a/PINCache.xcodeproj/project.pbxproj +++ b/PINCache.xcodeproj/project.pbxproj @@ -18,6 +18,11 @@ 6928EED31E4160EE00B5D975 /* PINCaching.h in Headers */ = {isa = PBXBuildFile; fileRef = 6928EED21E4160EE00B5D975 /* PINCaching.h */; settings = {ATTRIBUTES = (Public, ); }; }; 6928EED41E4160FE00B5D975 /* PINCaching.h in Headers */ = {isa = PBXBuildFile; fileRef = 6928EED21E4160EE00B5D975 /* PINCaching.h */; settings = {ATTRIBUTES = (Public, ); }; }; 6928EED51E41610700B5D975 /* PINCaching.h in Headers */ = {isa = PBXBuildFile; fileRef = 6928EED21E4160EE00B5D975 /* PINCaching.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C30EE1F520373D1900D78CB9 /* NSDate+PINCacheTests.m in Sources */ = {isa = PBXBuildFile; fileRef = C30EE1EA203717DE00D78CB9 /* NSDate+PINCacheTests.m */; }; + C30EE1F620373D1A00D78CB9 /* NSDate+PINCacheTests.m in Sources */ = {isa = PBXBuildFile; fileRef = C30EE1EA203717DE00D78CB9 /* NSDate+PINCacheTests.m */; }; + C30EE1F720373D1B00D78CB9 /* NSDate+PINCacheTests.m in Sources */ = {isa = PBXBuildFile; fileRef = C30EE1EA203717DE00D78CB9 /* NSDate+PINCacheTests.m */; }; + C38F01AA20A32E0200F47F0E /* PINDiskCache+PINCacheTests.h in Headers */ = {isa = PBXBuildFile; fileRef = C38F01A820A32E0200F47F0E /* PINDiskCache+PINCacheTests.h */; }; + C38F01AB20A32E0200F47F0E /* PINDiskCache+PINCacheTests.m in Sources */ = {isa = PBXBuildFile; fileRef = C38F01A920A32E0200F47F0E /* PINDiskCache+PINCacheTests.m */; }; CC0105DF1E271A5C00890935 /* PINCache.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CC0105B11E271A1600890935 /* PINCache.framework */; }; CC0105EE1E271A6400890935 /* PINCache.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CC0105C11E271A4000890935 /* PINCache.framework */; }; CC01060E1E271A9500890935 /* PINCache.m in Sources */ = {isa = PBXBuildFile; fileRef = CC0106061E271A9000890935 /* PINCache.m */; }; @@ -172,6 +177,10 @@ 6818C2901E564C1100875DB7 /* PINOperation.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = PINOperation.xcodeproj; path = Carthage/Checkouts/PINOperation/PINOperation.xcodeproj; sourceTree = ""; }; 68A0FBFF1E4D3282000B552D /* PINCacheMacros.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PINCacheMacros.h; sourceTree = ""; }; 6928EED21E4160EE00B5D975 /* PINCaching.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PINCaching.h; sourceTree = ""; }; + C30EE1E9203717DE00D78CB9 /* NSDate+PINCacheTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSDate+PINCacheTests.h"; sourceTree = ""; }; + C30EE1EA203717DE00D78CB9 /* NSDate+PINCacheTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSDate+PINCacheTests.m"; sourceTree = ""; }; + C38F01A820A32E0200F47F0E /* PINDiskCache+PINCacheTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "PINDiskCache+PINCacheTests.h"; sourceTree = ""; }; + C38F01A920A32E0200F47F0E /* PINDiskCache+PINCacheTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "PINDiskCache+PINCacheTests.m"; sourceTree = ""; }; CC0105B11E271A1600890935 /* PINCache.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = PINCache.framework; sourceTree = BUILT_PRODUCTS_DIR; }; CC0105C11E271A4000890935 /* PINCache.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = PINCache.framework; sourceTree = BUILT_PRODUCTS_DIR; }; CC0105CE1E271A4900890935 /* PINCache.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = PINCache.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -313,8 +322,12 @@ children = ( CC0106CB1E28248A00890935 /* Assets */, CC0106C51E281D6900890935 /* Info.plist */, + C30EE1E9203717DE00D78CB9 /* NSDate+PINCacheTests.h */, + C30EE1EA203717DE00D78CB9 /* NSDate+PINCacheTests.m */, CC0106C91E28228300890935 /* PINCacheTests.h */, CC01060D1E271A9000890935 /* PINCacheTests.m */, + C38F01A820A32E0200F47F0E /* PINDiskCache+PINCacheTests.h */, + C38F01A920A32E0200F47F0E /* PINDiskCache+PINCacheTests.m */, ); path = Tests; sourceTree = ""; @@ -339,6 +352,7 @@ 68A0FC001E4D32C4000B552D /* PINCacheMacros.h in Headers */, CC0106181E271AAF00890935 /* PINCacheObjectSubscripting.h in Headers */, CC01061A1E271AAF00890935 /* PINMemoryCache.h in Headers */, + C38F01AA20A32E0200F47F0E /* PINDiskCache+PINCacheTests.h in Headers */, CC0106191E271AAF00890935 /* PINDiskCache.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; @@ -643,6 +657,7 @@ files = ( CC01060E1E271A9500890935 /* PINCache.m in Sources */, CC0106101E271A9500890935 /* PINMemoryCache.m in Sources */, + C38F01AB20A32E0200F47F0E /* PINDiskCache+PINCacheTests.m in Sources */, CC01060F1E271A9500890935 /* PINDiskCache.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -672,6 +687,7 @@ buildActionMask = 2147483647; files = ( CC0106C61E28226900890935 /* PINCacheTests.m in Sources */, + C30EE1F520373D1900D78CB9 /* NSDate+PINCacheTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -680,6 +696,7 @@ buildActionMask = 2147483647; files = ( CC0106C71E28226A00890935 /* PINCacheTests.m in Sources */, + C30EE1F620373D1A00D78CB9 /* NSDate+PINCacheTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -688,6 +705,7 @@ buildActionMask = 2147483647; files = ( CC0106C81E28226A00890935 /* PINCacheTests.m in Sources */, + C30EE1F720373D1B00D78CB9 /* NSDate+PINCacheTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Source/PINCache.h b/Source/PINCache.h index fa743a89..7ddec1c6 100644 --- a/Source/PINCache.h +++ b/Source/PINCache.h @@ -103,6 +103,27 @@ PIN_SUBCLASSING_RESTRICTED serializer:(nullable PINDiskCacheSerializerBlock)serializer deserializer:(nullable PINDiskCacheDeserializerBlock)deserializer; +/** + Multiple instances with the same name are *not* allowed and can *not* safely + access the same data on disk. Also used to create the . + Initializer allows you to override default NSKeyedArchiver/NSKeyedUnarchiver serialization for . + You must provide both serializer and deserializer, or opt-out to default implementation providing nil values. + + @see name + @param name The name of the cache. + @param rootPath The path of the cache on disk. + @param serializer A block used to serialize object before writing to disk. If nil provided, default NSKeyedArchiver serialized will be used. + @param deserializer A block used to deserialize object read from disk. If nil provided, default NSKeyedUnarchiver serialized will be used. + @param keyEncoder A block used to encode key(filename). If nil provided, default url encoder will be used + @param keyDecoder A block used to decode key(filename). If nil provided, default url decoder will be used + @result A new cache with the specified name. + */ +- (instancetype)initWithName:(nonnull NSString *)name + rootPath:(nonnull NSString *)rootPath + serializer:(nullable PINDiskCacheSerializerBlock)serializer + deserializer:(nullable PINDiskCacheDeserializerBlock)deserializer + keyEncoder:(nullable PINDiskCacheKeyEncoderBlock)keyEncoder + keyDecoder:(nullable PINDiskCacheKeyDecoderBlock)keyDecoder; /** Multiple instances with the same name are *not* allowed and can *not* safely @@ -117,6 +138,7 @@ PIN_SUBCLASSING_RESTRICTED @param deserializer A block used to deserialize object read from disk. If nil provided, default NSKeyedUnarchiver serialized will be used. @param keyEncoder A block used to encode key(filename). If nil provided, default url encoder will be used @param keyDecoder A block used to decode key(filename). If nil provided, default url decoder will be used + @param ttlCache Whether or not the cache should behave as a TTL cache. @result A new cache with the specified name. */ - (instancetype)initWithName:(nonnull NSString *)name @@ -124,7 +146,8 @@ PIN_SUBCLASSING_RESTRICTED serializer:(nullable PINDiskCacheSerializerBlock)serializer deserializer:(nullable PINDiskCacheDeserializerBlock)deserializer keyEncoder:(nullable PINDiskCacheKeyEncoderBlock)keyEncoder - keyDecoder:(nullable PINDiskCacheKeyDecoderBlock)keyDecoder NS_DESIGNATED_INITIALIZER; + keyDecoder:(nullable PINDiskCacheKeyDecoderBlock)keyDecoder + ttlCache:(BOOL)ttlCache NS_DESIGNATED_INITIALIZER; @end diff --git a/Source/PINCache.m b/Source/PINCache.m index 556f62a4..afc01e11 100644 --- a/Source/PINCache.m +++ b/Source/PINCache.m @@ -44,6 +44,17 @@ - (instancetype)initWithName:(NSString *)name deserializer:(PINDiskCacheDeserializerBlock)deserializer keyEncoder:(PINDiskCacheKeyEncoderBlock)keyEncoder keyDecoder:(PINDiskCacheKeyDecoderBlock)keyDecoder +{ + return [self initWithName:name rootPath:rootPath serializer:serializer deserializer:deserializer keyEncoder:keyEncoder keyDecoder:keyDecoder ttlCache:NO]; +} + +- (instancetype)initWithName:(NSString *)name + rootPath:(NSString *)rootPath + serializer:(PINDiskCacheSerializerBlock)serializer + deserializer:(PINDiskCacheDeserializerBlock)deserializer + keyEncoder:(PINDiskCacheKeyEncoderBlock)keyEncoder + keyDecoder:(PINDiskCacheKeyDecoderBlock)keyDecoder + ttlCache:(BOOL)ttlCache { if (!name) return nil; @@ -60,7 +71,8 @@ - (instancetype)initWithName:(NSString *)name deserializer:deserializer keyEncoder:keyEncoder keyDecoder:keyDecoder - operationQueue:_operationQueue]; + operationQueue:_operationQueue + ttlCache:ttlCache]; _memoryCache = [[PINMemoryCache alloc] initWithOperationQueue:_operationQueue]; } return self; @@ -134,7 +146,17 @@ - (void)setObjectAsync:(id )object forKey:(NSString *)key completion:( [self setObjectAsync:object forKey:key withCost:0 completion:block]; } +- (void)setObjectAsync:(id )object forKey:(NSString *)key withAgeLimit:(NSTimeInterval)ageLimit completion:(PINCacheObjectBlock)block +{ + [self setObjectAsync:object forKey:key withCost:0 ageLimit:ageLimit completion:block]; +} + - (void)setObjectAsync:(id )object forKey:(NSString *)key withCost:(NSUInteger)cost completion:(PINCacheObjectBlock)block +{ + [self setObjectAsync:object forKey:key withCost:cost ageLimit:0.0 completion:block]; +} + +- (void)setObjectAsync:(nonnull id)object forKey:(nonnull NSString *)key withCost:(NSUInteger)cost ageLimit:(NSTimeInterval)ageLimit completion:(nullable PINCacheObjectBlock)block { if (!key || !object) return; @@ -142,10 +164,10 @@ - (void)setObjectAsync:(id )object forKey:(NSString *)key withCost:(NS PINOperationGroup *group = [PINOperationGroup asyncOperationGroupWithQueue:_operationQueue]; [group addOperation:^{ - [self->_memoryCache setObject:object forKey:key withCost:cost]; + [self->_memoryCache setObject:object forKey:key withCost:cost ageLimit:ageLimit]; }]; [group addOperation:^{ - [self->_diskCache setObject:object forKey:key]; + [self->_diskCache setObject:object forKey:key withAgeLimit:ageLimit]; }]; if (block) { @@ -223,6 +245,26 @@ - (void)trimToDateAsync:(NSDate *)date completion:(PINCacheBlock)block [group start]; } +- (void)removeExpiredObjectsAsync:(PINCacheBlock)block +{ + PINOperationGroup *group = [PINOperationGroup asyncOperationGroupWithQueue:_operationQueue]; + + [group addOperation:^{ + [self->_memoryCache removeExpiredObjects]; + }]; + [group addOperation:^{ + [self->_diskCache removeExpiredObjects]; + }]; + + if (block) { + [group setCompletion:^{ + block(self); + }]; + } + + [group start]; +} + #pragma mark - Public Synchronous Accessors - - (NSUInteger)diskByteCount @@ -269,13 +311,23 @@ - (void)setObject:(id )object forKey:(NSString *)key [self setObject:object forKey:key withCost:0]; } +- (void)setObject:(id )object forKey:(NSString *)key withAgeLimit:(NSTimeInterval)ageLimit +{ + [self setObject:object forKey:key withCost:0 ageLimit:ageLimit]; +} + - (void)setObject:(id )object forKey:(NSString *)key withCost:(NSUInteger)cost +{ + [self setObject:object forKey:key withCost:cost ageLimit:0.0]; +} + +- (void)setObject:(nullable id)object forKey:(nonnull NSString *)key withCost:(NSUInteger)cost ageLimit:(NSTimeInterval)ageLimit { if (!key || !object) return; - [_memoryCache setObject:object forKey:key withCost:cost]; - [_diskCache setObject:object forKey:key]; + [_memoryCache setObject:object forKey:key withCost:cost ageLimit:ageLimit]; + [_diskCache setObject:object forKey:key withAgeLimit:ageLimit]; } - (nullable id)objectForKeyedSubscript:(NSString *)key @@ -310,6 +362,12 @@ - (void)trimToDate:(NSDate *)date [_diskCache trimToDate:date]; } +- (void)removeExpiredObjects +{ + [_memoryCache removeExpiredObjects]; + [_diskCache removeExpiredObjects]; +} + - (void)removeAllObjects { [_memoryCache removeAllObjects]; diff --git a/Source/PINCaching.h b/Source/PINCaching.h index 6b94136f..ebc67063 100644 --- a/Source/PINCaching.h +++ b/Source/PINCaching.h @@ -78,6 +78,19 @@ typedef void (^PINCacheObjectContainmentBlock)(BOOL containsObject); */ - (void)setObjectAsync:(id)object forKey:(NSString *)key completion:(nullable PINCacheObjectBlock)block; +/** + Stores an object in the cache for the specified key and the specified age limit. This method returns immediately + and executes the passed block after the object has been stored, potentially in parallel with other blocks + on the . + + @param object An object to store in the cache. + @param key A key to associate with the object. This string will be copied. + @param ageLimit The age limit (in seconds) to associate with the object. An age limit <= 0 means there is no object-level age limit and the + cache-level TTL will be used for this object. + @param block A block to be executed concurrently after the object has been stored, or nil. + */ +- (void)setObjectAsync:(id)object forKey:(NSString *)key withAgeLimit:(NSTimeInterval)ageLimit completion:(nullable PINCacheObjectBlock)block; + /** Stores an object in the cache for the specified key and the specified memory cost. If the cost causes the total to go over the the cache is trimmed (oldest objects first). This method returns immediately @@ -91,6 +104,21 @@ typedef void (^PINCacheObjectContainmentBlock)(BOOL containsObject); */ - (void)setObjectAsync:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost completion:(nullable PINCacheObjectBlock)block; +/** + Stores an object in the cache for the specified key and the specified memory cost and age limit. If the cost causes the total + to go over the the cache is trimmed (oldest objects first). This method returns immediately + and executes the passed block after the object has been stored, potentially in parallel with other blocks + on the . + + @param object An object to store in the cache. + @param key A key to associate with the object. This string will be copied. + @param cost An amount to add to the . + @param ageLimit The age limit (in seconds) to associate with the object. An age limit <= 0 means there is no object-level age limit and the cache-level TTL will be + used for this object. + @param block A block to be executed concurrently after the object has been stored, or nil. + */ +- (void)setObjectAsync:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost ageLimit:(NSTimeInterval)ageLimit completion:(nullable PINCacheObjectBlock)block; + /** Removes the object for the specified key. This method returns immediately and executes the passed block after the object has been removed, potentially in parallel with other blocks on the . @@ -109,6 +137,15 @@ typedef void (^PINCacheObjectContainmentBlock)(BOOL containsObject); */ - (void)trimToDateAsync:(NSDate *)date completion:(nullable PINCacheBlock)block; +/** + Removes all expired objects from the cache. This includes objects that are considered expired due to the cache-level ageLimit + as well as object-level ageLimits. This method returns immediately and executes the passed block after the objects have been removed, + potentially in parallel with other blocks on the . + + @param block A block to be executed concurrently after the objects have been removed, or nil. + */ +- (void)removeExpiredObjectsAsync:(nullable PINCacheBlock)block; + /** Removes all objects from the cache.This method returns immediately and executes the passed block after the cache has been cleared, potentially in parallel with other blocks on the . @@ -150,6 +187,18 @@ typedef void (^PINCacheObjectContainmentBlock)(BOOL containsObject); */ - (void)setObject:(nullable id)object forKey:(NSString *)key; +/** + Stores an object in the cache for the specified key and age limit. This method blocks the calling thread until the + object has been set. Uses a lock to achieve synchronicity on the disk cache. + + @see setObjectAsync:forKey:completion: + @param object An object to store in the cache. + @param key A key to associate with the object. This string will be copied. + @param ageLimit The age limit (in seconds) to associate with the object. An age limit <= 0 means there is no + object-level age limit and the cache-level TTL will be used for this object. + */ +- (void)setObject:(nullable id)object forKey:(NSString *)key withAgeLimit:(NSTimeInterval)ageLimit; + /** Stores an object in the cache for the specified key and the specified memory cost. If the cost causes the total to go over the the cache is trimmed (oldest objects first). This method blocks the calling thread @@ -161,6 +210,19 @@ typedef void (^PINCacheObjectContainmentBlock)(BOOL containsObject); */ - (void)setObject:(nullable id)object forKey:(NSString *)key withCost:(NSUInteger)cost; +/** + Stores an object in the cache for the specified key and the specified memory cost and age limit. If the cost causes the total + to go over the the cache is trimmed (oldest objects first). This method blocks the calling thread + until the object has been stored. + + @param object An object to store in the cache. + @param key A key to associate with the object. This string will be copied. + @param cost An amount to add to the . + @param ageLimit The age limit (in seconds) to associate with the object. An age limit <= 0 means there is no object-level age + limit and the cache-level TTL will be used for this object. + */ +- (void)setObject:(nullable id)object forKey:(NSString *)key withCost:(NSUInteger)cost ageLimit:(NSTimeInterval)ageLimit; + /** Removes the object for the specified key. This method blocks the calling thread until the object has been removed. @@ -181,6 +243,13 @@ typedef void (^PINCacheObjectContainmentBlock)(BOOL containsObject); */ - (void)trimToDate:(NSDate *)date; +/** + Removes all expired objects from the cache. This includes objects that are considered expired due to the cache-level ageLimit + as well as object-level ageLimits. This method blocks the calling thread until the objects have been removed. + Uses a lock to achieve synchronicity on the disk cache. + */ +- (void)removeExpiredObjects; + /** Removes all objects from the cache. This method blocks the calling thread until the cache has been cleared. Uses a lock to achieve synchronicity on the disk cache. diff --git a/Source/PINDiskCache.h b/Source/PINDiskCache.h index 24e1d1b6..232ec197 100644 --- a/Source/PINDiskCache.h +++ b/Source/PINDiskCache.h @@ -13,8 +13,16 @@ NS_ASSUME_NONNULL_BEGIN @class PINDiskCache; @class PINOperationQueue; +extern NSString * const PINDiskCacheErrorDomain; +extern NSErrorUserInfoKey const PINDiskCacheErrorReadFailureCodeKey; +extern NSErrorUserInfoKey const PINDiskCacheErrorWriteFailureCodeKey; extern NSString * const PINDiskCachePrefix; +typedef NS_ENUM(NSInteger, PINDiskCacheError) { + PINDiskCacheErrorReadFailure = -1000, + PINDiskCacheErrorWriteFailure = -1001, +}; + /** A callback block which provides the cache, key and object as arguments */ @@ -180,9 +188,13 @@ PIN_SUBCLASSING_RESTRICTED - Accessing an object in the cache does not extend that object's lifetime in the cache - When attempting to access an object in the cache that has lived longer than self.ageLimit, the cache will behave as if the object does not exist + + @note If an object-level age limit is set via one of the @c -setObject:forKey:withAgeLimit methods, + that age limit overrides self.ageLimit. The overridden object age limit could be greater or less + than self.agelimit but must be greater than zero. */ -@property (nonatomic, assign, getter=isTTLCache) BOOL ttlCache; +@property (nonatomic, readonly, getter=isTTLCache) BOOL ttlCache; #pragma mark - Event Blocks /// @name Event Blocks @@ -267,9 +279,7 @@ PIN_SUBCLASSING_RESTRICTED */ - (instancetype)initWithName:(nonnull NSString *)name rootPath:(nonnull NSString *)rootPath serializer:(nullable PINDiskCacheSerializerBlock)serializer deserializer:(nullable PINDiskCacheDeserializerBlock)deserializer; -/** - The designated initializer allowing you to override default NSKeyedArchiver/NSKeyedUnarchiver serialization. - +/** @see name @param name The name of the cache. @param rootPath The path of the cache. @@ -280,6 +290,27 @@ PIN_SUBCLASSING_RESTRICTED */ - (instancetype)initWithName:(nonnull NSString *)name rootPath:(nonnull NSString *)rootPath serializer:(nullable PINDiskCacheSerializerBlock)serializer deserializer:(nullable PINDiskCacheDeserializerBlock)deserializer operationQueue:(nonnull PINOperationQueue *)operationQueue __attribute__((deprecated)); +/** + @see name + @param name The name of the cache. + @param prefix The prefix for the cache name. Defaults to com.pinterest.PINDiskCache + @param rootPath The path of the cache. + @param serializer A block used to serialize object. If nil provided, default NSKeyedArchiver serialized will be used. + @param deserializer A block used to deserialize object. If nil provided, default NSKeyedUnarchiver serialized will be used. + @param keyEncoder A block used to encode key(filename). If nil provided, default url encoder will be used + @param keyDecoder A block used to decode key(filename). If nil provided, default url decoder will be used + @param operationQueue A PINOperationQueue to run asynchronous operations + @result A new cache with the specified name. + */ +- (instancetype)initWithName:(nonnull NSString *)name + prefix:(nonnull NSString *)prefix + rootPath:(nonnull NSString *)rootPath + serializer:(nullable PINDiskCacheSerializerBlock)serializer + deserializer:(nullable PINDiskCacheDeserializerBlock)deserializer + keyEncoder:(nullable PINDiskCacheKeyEncoderBlock)keyEncoder + keyDecoder:(nullable PINDiskCacheKeyDecoderBlock)keyDecoder + operationQueue:(nonnull PINOperationQueue *)operationQueue; + /** The designated initializer allowing you to override default NSKeyedArchiver/NSKeyedUnarchiver serialization. @@ -292,6 +323,7 @@ PIN_SUBCLASSING_RESTRICTED @param keyEncoder A block used to encode key(filename). If nil provided, default url encoder will be used @param keyDecoder A block used to decode key(filename). If nil provided, default url decoder will be used @param operationQueue A PINOperationQueue to run asynchronous operations + @param ttlCache Whether or not the cache should behave as a TTL cache. @result A new cache with the specified name. */ - (instancetype)initWithName:(nonnull NSString *)name @@ -301,7 +333,8 @@ PIN_SUBCLASSING_RESTRICTED deserializer:(nullable PINDiskCacheDeserializerBlock)deserializer keyEncoder:(nullable PINDiskCacheKeyEncoderBlock)keyEncoder keyDecoder:(nullable PINDiskCacheKeyDecoderBlock)keyDecoder - operationQueue:(nonnull PINOperationQueue *)operationQueue NS_DESIGNATED_INITIALIZER; + operationQueue:(nonnull PINOperationQueue *)operationQueue + ttlCache:(BOOL)ttlCache NS_DESIGNATED_INITIALIZER; #pragma mark - Asynchronous Methods /// @name Asynchronous Methods @@ -350,6 +383,18 @@ PIN_SUBCLASSING_RESTRICTED */ - (void)setObjectAsync:(id )object forKey:(NSString *)key completion:(nullable PINDiskCacheObjectBlock)block; +/** + Stores an object in the cache for the specified key and age limit. This method returns immediately and executes the + passed block as soon as the object has been stored. + + @param object An object to store in the cache. + @param key A key to associate with the object. This string will be copied. + @param ageLimit The age limit (in seconds) to associate with the object. An age limit <= 0 means there is no object-level age limit and the cache-level TTL + will be used for this object. + @param block A block to be executed serially after the object has been stored, or nil. + */ +- (void)setObjectAsync:(id )object forKey:(NSString *)key withAgeLimit:(NSTimeInterval)ageLimit completion:(nullable PINDiskCacheObjectBlock)block; + /** Stores an object in the cache for the specified key and the specified memory cost. If the cost causes the total to go over the the cache is trimmed (oldest objects first). This method returns immediately @@ -363,6 +408,21 @@ PIN_SUBCLASSING_RESTRICTED */ - (void)setObjectAsync:(id )object forKey:(NSString *)key withCost:(NSUInteger)cost completion:(nullable PINCacheObjectBlock)block; +/** + Stores an object in the cache for the specified key and the specified memory cost and age limit. If the cost causes the total + to go over the the cache is trimmed (oldest objects first). This method returns immediately + and executes the passed block after the object has been stored, potentially in parallel with other blocks + on the . + + @param object An object to store in the cache. + @param key A key to associate with the object. This string will be copied. + @param cost An amount to add to the . + @param ageLimit The age limit (in seconds) to associate with the object. An age limit <= 0 means there is no object-level age limit and the cache-level TTL will be used for + this object. + @param block A block to be executed concurrently after the object has been stored, or nil. + */ +- (void)setObjectAsync:(id )object forKey:(NSString *)key withCost:(NSUInteger)cost ageLimit:(NSTimeInterval)ageLimit completion:(nullable PINCacheObjectBlock)block; + /** Removes the object for the specified key. This method returns immediately and executes the passed block as soon as the object has been removed. @@ -388,6 +448,8 @@ PIN_SUBCLASSING_RESTRICTED @param byteCount The cache will be trimmed equal to or smaller than this size. @param block A block to be executed serially after the cache has been trimmed, or nil. + + @note This will not remove objects that have been added via one of the @c -setObject:forKey:withAgeLimit methods. */ - (void)trimToSizeByDateAsync:(NSUInteger)byteCount completion:(nullable PINCacheBlock)block; @@ -451,6 +513,18 @@ PIN_SUBCLASSING_RESTRICTED */ - (void)setObject:(nullable id )object forKey:(NSString *)key; +/** + Stores an object in the cache for the specified key and age limit. This method blocks the calling thread until + the object has been stored. + + @see setObjectAsync:forKey:completion: + @param object An object to store in the cache. + @param key A key to associate with the object. This string will be copied. + @param ageLimit The age limit (in seconds) to associate with the object. An age limit <= 0 means there is + no object-level age limit and the cache-level TTL will be used for this object. + */ +- (void)setObject:(nullable id )object forKey:(NSString *)key withAgeLimit:(NSTimeInterval)ageLimit; + /** Removes objects from the cache, largest first, until the cache is equal to or smaller than the specified byteCount. This method blocks the calling thread until the cache has been trimmed. @@ -466,6 +540,8 @@ PIN_SUBCLASSING_RESTRICTED @see trimToSizeByDateAsync: @param byteCount The cache will be trimmed equal to or smaller than this size. + + @note This will not remove objects that have been added via one of the @c -setObject:forKey:withAgeLimit methods. */ - (void)trimToSizeByDate:(NSUInteger)byteCount; @@ -510,6 +586,7 @@ typedef void (^PINDiskCacheBlock)(PINDiskCache *cache); - (void)trimToSizeByDate:(NSUInteger)byteCount block:(nullable PINDiskCacheBlock)block __attribute__((deprecated)); - (void)removeAllObjects:(nullable PINDiskCacheBlock)block __attribute__((deprecated)); - (void)enumerateObjectsWithBlock:(PINDiskCacheFileURLBlock)block completionBlock:(nullable PINDiskCacheBlock)completionBlock __attribute__((deprecated)); +- (void)setTtlCache:(BOOL)ttlCache __attribute__((unavailable("ttlCache is no longer a settable property and must now be set via initializer."))); @end NS_ASSUME_NONNULL_END diff --git a/Source/PINDiskCache.m b/Source/PINDiskCache.m index c1165639..c4f48640 100644 --- a/Source/PINDiskCache.m +++ b/Source/PINDiskCache.m @@ -9,6 +9,7 @@ #endif #import +#import #import @@ -18,6 +19,10 @@ #define PINDiskCacheException(exception) if (exception) { NSAssert(NO, [exception reason]); } +const char * PINDiskCacheAgeLimitAttributeName = "com.pinterest.PINDiskCache.ageLimit"; +NSString * const PINDiskCacheErrorDomain = @"com.pinterest.PINDiskCache"; +NSErrorUserInfoKey const PINDiskCacheErrorReadFailureCodeKey = @"PINDiskCacheErrorReadFailureCodeKey"; +NSErrorUserInfoKey const PINDiskCacheErrorWriteFailureCodeKey = @"PINDiskCacheErrorWriteFailureCodeKey"; NSString * const PINDiskCachePrefix = @"com.pinterest.PINDiskCache"; static NSString * const PINDiskCacheSharedName = @"PINDiskCacheShared"; @@ -40,9 +45,22 @@ typedef NS_ENUM(NSUInteger, PINDiskCacheCondition) { return (result == NSOrderedDescending) ? newDate : existingDate; }; +const char * PINDiskCacheFileSystemRepresentation(NSURL *url) +{ +#ifdef __MAC_10_13 // Xcode >= 9 + // -fileSystemRepresentation is available on macOS >= 10.9 + if (@available(macOS 10.9, iOS 7.0, watchOS 2.0, tvOS 9.0, *)) { + return url.fileSystemRepresentation; + } +#endif + return [url.path cStringUsingEncoding:NSUTF8StringEncoding]; +} + @interface PINDiskCacheMetadata : NSObject -@property (nonatomic, strong) NSDate *date; +@property (nonatomic, strong) NSDate *createdDate; +@property (nonatomic, strong) NSDate *lastModifiedDate; @property (nonatomic, strong) NSNumber *size; +@property (nonatomic) NSTimeInterval ageLimit; @end @interface PINDiskCache () { @@ -143,6 +161,26 @@ - (instancetype)initWithName:(NSString *)name keyEncoder:(PINDiskCacheKeyEncoderBlock)keyEncoder keyDecoder:(PINDiskCacheKeyDecoderBlock)keyDecoder operationQueue:(PINOperationQueue *)operationQueue +{ + return [self initWithName:name prefix:prefix + rootPath:rootPath + serializer:serializer + deserializer:deserializer + keyEncoder:keyEncoder + keyDecoder:keyDecoder + operationQueue:operationQueue + ttlCache:NO]; +} + +- (instancetype)initWithName:(NSString *)name + prefix:(NSString *)prefix + rootPath:(NSString *)rootPath + serializer:(PINDiskCacheSerializerBlock)serializer + deserializer:(PINDiskCacheDeserializerBlock)deserializer + keyEncoder:(PINDiskCacheKeyEncoderBlock)keyEncoder + keyDecoder:(PINDiskCacheKeyDecoderBlock)keyDecoder + operationQueue:(PINOperationQueue *)operationQueue + ttlCache:(BOOL)ttlCache { if (!name) return nil; @@ -161,6 +199,7 @@ - (instancetype)initWithName:(NSString *)name _name = [name copy]; _prefix = [prefix copy]; _operationQueue = operationQueue; + _ttlCache = ttlCache; _willAddObjectBlock = nil; _willRemoveObjectBlock = nil; _willRemoveAllObjectsBlock = nil; @@ -453,7 +492,7 @@ - (BOOL)_locked_createCacheDirectory - (void)initializeDiskProperties { NSUInteger byteCount = 0; - NSArray *keys = @[ NSURLContentModificationDateKey, NSURLTotalFileAllocatedSizeKey ]; + NSArray *keys = @[ NSURLCreationDateKey, NSURLContentModificationDateKey, NSURLTotalFileAllocatedSizeKey ]; NSError *error = nil; @@ -479,16 +518,35 @@ - (void)initializeDiskProperties if (_metadata[key] == nil) { _metadata[key] = [[PINDiskCacheMetadata alloc] init]; } + + NSDate *createdDate = [dictionary objectForKey:NSURLCreationDateKey]; + if (createdDate && key) + _metadata[key].createdDate = createdDate; - NSDate *date = [dictionary objectForKey:NSURLContentModificationDateKey]; - if (date && key) - _metadata[key].date = date; + NSDate *lastModifiedDate = [dictionary objectForKey:NSURLContentModificationDateKey]; + if (lastModifiedDate && key) + _metadata[key].lastModifiedDate = lastModifiedDate; NSNumber *fileSize = [dictionary objectForKey:NSURLTotalFileAllocatedSizeKey]; if (fileSize) { _metadata[key].size = fileSize; byteCount += [fileSize unsignedIntegerValue]; } + + if (_ttlCache) { + NSTimeInterval ageLimit; + ssize_t res = getxattr(PINDiskCacheFileSystemRepresentation(fileURL), PINDiskCacheAgeLimitAttributeName, &ageLimit, sizeof(NSTimeInterval), 0, 0); + if(res) { + _metadata[key].ageLimit = ageLimit; + } else if (res == -1) { + // Ignore if the extended attribute was never recorded for this file. + if (errno != ENOATTR) { + NSDictionary *userInfo = @{ PINDiskCacheErrorReadFailureCodeKey : @(errno)}; + error = [NSError errorWithDomain:PINDiskCacheErrorDomain code:PINDiskCacheErrorReadFailure userInfo:userInfo]; + PINDiskCacheError(error); + } + } + } [self unlock]; } @@ -498,6 +556,9 @@ - (void)initializeDiskProperties if (self->_byteLimit > 0 && self->_byteCount > self->_byteLimit) [self trimToSizeByDateAsync:self->_byteLimit completion:nil]; + + if (self->_ttlCache) + [self removeExpiredObjectsAsync:nil]; _diskStateKnown = YES; pthread_cond_broadcast(&_diskStateKnownCondition); @@ -528,13 +589,56 @@ - (BOOL)_locked_setFileModificationDate:(NSDate *)date forURL:(NSURL *)fileURL if (success) { NSString *key = [self keyForEncodedFileURL:fileURL]; if (key) { - _metadata[key].date = date; + _metadata[key].lastModifiedDate = date; } } return success; } +- (void)asynchronouslySetAgeLimit:(NSTimeInterval)ageLimit forURL:(NSURL *)fileURL +{ + [self.operationQueue scheduleOperation:^{ + [self lockForWriting]; + [self _locked_setAgeLimit:ageLimit forURL:fileURL]; + [self unlock]; + } withPriority:PINOperationQueuePriorityLow]; +} + +- (BOOL)_locked_setAgeLimit:(NSTimeInterval)ageLimit forURL:(NSURL *)fileURL +{ + if (!fileURL) { + return NO; + } + + NSError *error = nil; + if (ageLimit <= 0.0) { + if (removexattr(PINDiskCacheFileSystemRepresentation(fileURL), PINDiskCacheAgeLimitAttributeName, 0) != 0) { + // Ignore if the extended attribute was never recorded for this file. + if (errno != ENOATTR) { + NSDictionary *userInfo = @{ PINDiskCacheErrorWriteFailureCodeKey : @(errno)}; + error = [NSError errorWithDomain:PINDiskCacheErrorDomain code:PINDiskCacheErrorWriteFailure userInfo:userInfo]; + PINDiskCacheError(error); + } + } + } else { + if (setxattr(PINDiskCacheFileSystemRepresentation(fileURL), PINDiskCacheAgeLimitAttributeName, &ageLimit, sizeof(NSTimeInterval), 0, 0) != 0) { + NSDictionary *userInfo = @{ PINDiskCacheErrorWriteFailureCodeKey : @(errno)}; + error = [NSError errorWithDomain:PINDiskCacheErrorDomain code:PINDiskCacheErrorWriteFailure userInfo:userInfo]; + PINDiskCacheError(error); + } + } + + if (!error) { + NSString *key = [self keyForEncodedFileURL:fileURL]; + if (key) { + _metadata[key].ageLimit = ageLimit; + } + } + + return !error; +} + - (BOOL)removeFileAndExecuteBlocksForKey:(NSString *)key { NSURL *fileURL = [self encodedFileURLForKey:key]; @@ -612,18 +716,22 @@ - (void)trimDiskToSize:(NSUInteger)trimByteCount - (void)trimDiskToSizeByDate:(NSUInteger)trimByteCount { + if (self.isTTLCache) { + [self removeExpiredObjects]; + } + NSMutableArray *keysToRemove = nil; - + [self lockForWriting]; if (_byteCount > trimByteCount) { keysToRemove = [[NSMutableArray alloc] init]; - NSArray *keysSortedByDate = [_metadata keysSortedByValueUsingComparator:^NSComparisonResult(PINDiskCacheMetadata * _Nonnull obj1, PINDiskCacheMetadata * _Nonnull obj2) { - return [obj1.date compare:obj2.date]; + NSArray *keysSortedByLastModifiedDate = [_metadata keysSortedByValueUsingComparator:^NSComparisonResult(PINDiskCacheMetadata * _Nonnull obj1, PINDiskCacheMetadata * _Nonnull obj2) { + return [obj1.lastModifiedDate compare:obj2.lastModifiedDate]; }]; NSUInteger bytesSaved = 0; - for (NSString *key in keysSortedByDate) { // oldest objects first + for (NSString *key in keysSortedByLastModifiedDate) { // oldest objects first [keysToRemove addObject:key]; NSNumber *byteSize = _metadata[key].size; if (byteSize) { @@ -644,18 +752,18 @@ - (void)trimDiskToSizeByDate:(NSUInteger)trimByteCount - (void)trimDiskToDate:(NSDate *)trimDate { [self lockForWriting]; - NSArray *keysSortedByDate = [_metadata keysSortedByValueUsingComparator:^NSComparisonResult(PINDiskCacheMetadata * _Nonnull obj1, PINDiskCacheMetadata * _Nonnull obj2) { - return [obj1.date compare:obj2.date]; + NSArray *keysSortedByCreatedDate = [_metadata keysSortedByValueUsingComparator:^NSComparisonResult(PINDiskCacheMetadata * _Nonnull obj1, PINDiskCacheMetadata * _Nonnull obj2) { + return [obj1.createdDate compare:obj2.createdDate]; }]; NSMutableArray *keysToRemove = [[NSMutableArray alloc] init]; - for (NSString *key in keysSortedByDate) { // oldest files first - NSDate *accessDate = _metadata[key].date; - if (!accessDate) + for (NSString *key in keysSortedByCreatedDate) { // oldest files first + NSDate *createdDate = _metadata[key].createdDate; + if (!createdDate || _metadata[key].ageLimit > 0.0) continue; - if ([accessDate compare:trimDate] == NSOrderedAscending) { // older than trim date + if ([createdDate compare:trimDate] == NSOrderedAscending) { // older than trim date [keysToRemove addObject:key]; } else { break; @@ -738,10 +846,15 @@ - (void)fileURLForKeyAsync:(NSString *)key completion:(PINDiskCacheFileURLBlock) } - (void)setObjectAsync:(id )object forKey:(NSString *)key completion:(PINDiskCacheObjectBlock)block +{ + [self setObjectAsync:object forKey:key withAgeLimit:0.0 completion:(PINDiskCacheObjectBlock)block]; +} + +- (void)setObjectAsync:(id )object forKey:(NSString *)key withAgeLimit:(NSTimeInterval)ageLimit completion:(nullable PINDiskCacheObjectBlock)block { [self.operationQueue scheduleOperation:^{ NSURL *fileURL = nil; - [self setObject:object forKey:key fileURL:&fileURL]; + [self setObject:object forKey:key withAgeLimit:ageLimit fileURL:&fileURL]; if (block) { block(self, key, object); @@ -754,6 +867,11 @@ - (void)setObjectAsync:(id )object forKey:(NSString *)key withCost:(NS [self setObjectAsync:object forKey:key completion:(PINDiskCacheObjectBlock)block]; } +- (void)setObjectAsync:(id )object forKey:(NSString *)key withCost:(NSUInteger)cost ageLimit:(NSTimeInterval)ageLimit completion:(nullable PINCacheObjectBlock)block +{ + [self setObjectAsync:object forKey:key withAgeLimit:ageLimit completion:(PINDiskCacheObjectBlock)block]; +} + - (void)removeObjectForKeyAsync:(NSString *)key completion:(PINDiskCacheObjectBlock)block { [self.operationQueue scheduleOperation:^{ @@ -829,6 +947,17 @@ - (void)trimToSizeByDateAsync:(NSUInteger)trimByteCount completion:(PINCacheBloc completion:completion]; } +- (void)removeExpiredObjectsAsync:(PINCacheBlock)block +{ + [self.operationQueue scheduleOperation:^{ + [self removeExpiredObjects]; + + if (block) { + block(self); + } + } withPriority:PINOperationQueuePriorityLow]; +} + - (void)removeAllObjectsAsync:(PINCacheBlock)block { [self.operationQueue scheduleOperation:^{ @@ -866,8 +995,13 @@ - (BOOL)containsObjectForKey:(NSString *)key { [self lock]; if (_metadata[key] != nil || _diskStateKnown == NO) { + BOOL objectExpired = NO; + if (self->_ttlCache && _metadata[key].createdDate != nil) { + NSTimeInterval ageLimit = _metadata[key].ageLimit > 0.0 ? _metadata[key].ageLimit : self->_ageLimit; + objectExpired = ageLimit > 0 && fabs([_metadata[key].createdDate timeIntervalSinceDate:[NSDate date]]) > ageLimit; + } [self unlock]; - return ([self fileURLForKey:key updateFileModificationDate:NO] != nil); + return (!objectExpired && [self fileURLForKey:key updateFileModificationDate:NO] != nil); } [self unlock]; return NO; @@ -895,15 +1029,16 @@ - (id)objectForKeyedSubscript:(NSString *)key id object = nil; NSURL *fileURL = [self encodedFileURLForKey:key]; - NSDate *now = [[NSDate alloc] init]; + NSDate *now = [NSDate date]; [self lock]; if (self->_ttlCache) { // We actually need to know the entire disk state if we're a TTL cache. [self unlock]; [self lockAndWaitForKnownState]; } - - if (!self->_ttlCache || self->_ageLimit <= 0 || fabs([_metadata[key].date timeIntervalSinceDate:now]) < self->_ageLimit) { + + NSTimeInterval ageLimit = _metadata[key].ageLimit > 0.0 ? _metadata[key].ageLimit : self->_ageLimit; + if (!self->_ttlCache || ageLimit <= 0 || fabs([_metadata[key].createdDate timeIntervalSinceDate:now]) < ageLimit) { // If the cache should behave like a TTL cache, then only fetch the object if there's a valid ageLimit and the object is still alive NSData *objectData = [[NSData alloc] initWithContentsOfFile:[fileURL path]]; @@ -924,9 +1059,8 @@ - (id)objectForKeyedSubscript:(NSString *)key } [self lock]; } - if (object && !self->_ttlCache) { + if (object) [self asynchronouslySetFileModificationDate:now forURL:fileURL]; - } } [self unlock]; @@ -950,7 +1084,7 @@ - (NSURL *)fileURLForKey:(NSString *)key updateFileModificationDate:(BOOL)update return nil; } - NSDate *now = [[NSDate alloc] init]; + NSDate *now = [NSDate date]; NSURL *fileURL = [self encodedFileURLForKey:key]; [self lockForWriting]; @@ -967,7 +1101,17 @@ - (NSURL *)fileURLForKey:(NSString *)key updateFileModificationDate:(BOOL)update - (void)setObject:(id )object forKey:(NSString *)key { - [self setObject:object forKey:key fileURL:nil]; + [self setObject:object forKey:key withAgeLimit:0.0]; +} + +- (void)setObject:(id )object forKey:(NSString *)key withAgeLimit:(NSTimeInterval)ageLimit +{ + [self setObject:object forKey:key withAgeLimit:ageLimit fileURL:nil]; +} + +- (void)setObject:(id )object forKey:(NSString *)key withCost:(NSUInteger)cost ageLimit:(NSTimeInterval)ageLimit +{ + [self setObject:object forKey:key withAgeLimit:ageLimit]; } - (void)setObject:(id )object forKey:(NSString *)key withCost:(NSUInteger)cost @@ -984,8 +1128,10 @@ - (void)setObject:(id)object forKeyedSubscript:(NSString *)key } } -- (void)setObject:(id )object forKey:(NSString *)key fileURL:(NSURL **)outFileURL +- (void)setObject:(id )object forKey:(NSString *)key withAgeLimit:(NSTimeInterval)ageLimit fileURL:(NSURL **)outFileURL { + NSAssert(ageLimit <= 0.0 || (ageLimit > 0.0 && _ttlCache), @"ttlCache must be set to YES if setting an object-level age limit."); + if (!key || !object) return; @@ -1031,7 +1177,7 @@ - (void)setObject:(id )object forKey:(NSString *)key fileURL:(NSURL ** } NSError *error = nil; - NSDictionary *values = [fileURL resourceValuesForKeys:@[ NSURLContentModificationDateKey, NSURLTotalFileAllocatedSizeKey ] error:&error]; + NSDictionary *values = [fileURL resourceValuesForKeys:@[ NSURLCreationDateKey, NSURLContentModificationDateKey, NSURLTotalFileAllocatedSizeKey ] error:&error]; PINDiskCacheError(error); NSNumber *diskFileSize = [values objectForKey:NSURLTotalFileAllocatedSizeKey]; @@ -1043,11 +1189,15 @@ - (void)setObject:(id )object forKey:(NSString *)key fileURL:(NSURL ** self->_metadata[key].size = diskFileSize; self.byteCount = self->_byteCount + [diskFileSize unsignedIntegerValue]; // atomic } - NSDate *date = [values objectForKey:NSURLContentModificationDateKey]; - if (date) { - self->_metadata[key].date = date; + NSDate *createdDate = [values objectForKey:NSURLCreationDateKey]; + if (createdDate) { + self->_metadata[key].createdDate = createdDate; } - + NSDate *lastModifiedDate = [values objectForKey:NSURLContentModificationDateKey]; + if (lastModifiedDate) { + self->_metadata[key].lastModifiedDate = lastModifiedDate; + } + [self asynchronouslySetAgeLimit:ageLimit forURL:fileURL]; if (self->_byteLimit > 0 && self->_byteCount > self->_byteLimit) [self trimToSizeByDateAsync:self->_byteLimit completion:nil]; } else { @@ -1121,6 +1271,26 @@ - (void)trimToSizeByDate:(NSUInteger)trimByteCount [self trimDiskToSizeByDate:trimByteCount]; } +- (void)removeExpiredObjects +{ + [self lockForWriting]; + NSDate *now = [NSDate date]; + NSMutableArray *expiredObjectKeys = [NSMutableArray array]; + [_metadata enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, PINDiskCacheMetadata * _Nonnull obj, BOOL * _Nonnull stop) { + NSTimeInterval ageLimit = obj.ageLimit > 0.0 ? obj.ageLimit : self->_ageLimit; + NSDate *expirationDate = [obj.createdDate dateByAddingTimeInterval:ageLimit]; + if ([expirationDate compare:now] == NSOrderedAscending) { // Expiration date has passed + [expiredObjectKeys addObject:key]; + } + }]; + [self unlock]; + + for (NSString *key in expiredObjectKeys) { + //unlock, removeFileAndExecuteBlocksForKey handles locking itself + [self removeFileAndExecuteBlocksForKey:key]; + } +} + - (void)removeAllObjects { // We don't need to know the disk state since we're just going to remove everything. @@ -1161,8 +1331,9 @@ - (void)enumerateObjectsWithBlock:(PINDiskCacheFileURLEnumerationBlock)block for (NSString *key in _metadata) { NSURL *fileURL = [self encodedFileURLForKey:key]; // If the cache should behave like a TTL cache, then only fetch the object if there's a valid ageLimit and the object is still alive - NSDate *date = _metadata[key].date; - if (!self->_ttlCache || self->_ageLimit <= 0 || (date && fabs([date timeIntervalSinceDate:now]) < self->_ageLimit)) { + NSDate *createdDate = _metadata[key].createdDate; + NSTimeInterval ageLimit = _metadata[key].ageLimit > 0.0 ? _metadata[key].ageLimit : self->_ageLimit; + if (!self->_ttlCache || ageLimit <= 0 || (createdDate && fabs([createdDate timeIntervalSinceDate:now]) < ageLimit)) { BOOL stop = NO; block(key, fileURL, &stop); if (stop) diff --git a/Source/PINMemoryCache.h b/Source/PINMemoryCache.h index 5eee26a9..eda8170f 100644 --- a/Source/PINMemoryCache.h +++ b/Source/PINMemoryCache.h @@ -61,8 +61,11 @@ PIN_SUBCLASSING_RESTRICTED - When attempting to access an object in the cache that has lived longer than self.ageLimit, the cache will behave as if the object does not exist + @note If an object-level age limit is set via one of the @c -setObject:forKey:withAgeLimit methods, + that age limit overrides self.ageLimit. The overridden object age limit could be greater or + less than self.agelimit but must be greater than zero. */ -@property (nonatomic, assign, getter=isTTLCache) BOOL ttlCache; +@property (nonatomic, readonly, getter=isTTLCache) BOOL ttlCache; /** When `YES` on iOS the cache will remove all objects when the app receives a memory warning. @@ -145,7 +148,9 @@ PIN_SUBCLASSING_RESTRICTED - (instancetype)initWithOperationQueue:(PINOperationQueue *)operationQueue; -- (instancetype)initWithName:(NSString *)name operationQueue:(PINOperationQueue *)operationQueue NS_DESIGNATED_INITIALIZER; +- (instancetype)initWithName:(NSString *)name operationQueue:(PINOperationQueue *)operationQueue; + +- (instancetype)initWithName:(NSString *)name operationQueue:(PINOperationQueue *)operationQueue ttlCache:(BOOL)ttlCache NS_DESIGNATED_INITIALIZER; #pragma mark - Asynchronous Methods /// @name Asynchronous Methods @@ -234,6 +239,7 @@ typedef void (^PINMemoryCacheContainmentBlock)(BOOL containsObject); - (void)trimToCostByDate:(NSUInteger)cost block:(nullable PINMemoryCacheBlock)block __attribute__((deprecated)); - (void)removeAllObjects:(nullable PINMemoryCacheBlock)block __attribute__((deprecated)); - (void)enumerateObjectsWithBlock:(PINMemoryCacheObjectBlock)block completionBlock:(nullable PINMemoryCacheBlock)completionBlock __attribute__((deprecated)); +- (void)setTtlCache:(BOOL)ttlCache __attribute__((unavailable("ttlCache is no longer a settable property and must now be set via initializer."))); @end NS_ASSUME_NONNULL_END diff --git a/Source/PINMemoryCache.m b/Source/PINMemoryCache.m index 21e4a602..8849e34c 100644 --- a/Source/PINMemoryCache.m +++ b/Source/PINMemoryCache.m @@ -19,8 +19,10 @@ @interface PINMemoryCache () @property (strong, nonatomic) PINOperationQueue *operationQueue; @property (assign, nonatomic) pthread_mutex_t mutex; @property (strong, nonatomic) NSMutableDictionary *dictionary; -@property (strong, nonatomic) NSMutableDictionary *dates; +@property (strong, nonatomic) NSMutableDictionary *createdDates; +@property (strong, nonatomic) NSMutableDictionary *accessDates; @property (strong, nonatomic) NSMutableDictionary *costs; +@property (strong, nonatomic) NSMutableDictionary *ageLimits; @end @implementation PINMemoryCache @@ -60,6 +62,11 @@ - (instancetype)initWithOperationQueue:(PINOperationQueue *)operationQueue } - (instancetype)initWithName:(NSString *)name operationQueue:(PINOperationQueue *)operationQueue +{ + return [self initWithName:name operationQueue:operationQueue ttlCache:NO]; +} + +- (instancetype)initWithName:(NSString *)name operationQueue:(PINOperationQueue *)operationQueue ttlCache:(BOOL)ttlCache { if (self = [super init]) { __unused int result = pthread_mutex_init(&_mutex, NULL); @@ -67,10 +74,13 @@ - (instancetype)initWithName:(NSString *)name operationQueue:(PINOperationQueue _name = [name copy]; _operationQueue = operationQueue; + _ttlCache = ttlCache; _dictionary = [[NSMutableDictionary alloc] init]; - _dates = [[NSMutableDictionary alloc] init]; + _createdDates = [[NSMutableDictionary alloc] init]; + _accessDates = [[NSMutableDictionary alloc] init]; _costs = [[NSMutableDictionary alloc] init]; + _ageLimits = [[NSMutableDictionary alloc] init]; _willAddObjectBlock = nil; _willRemoveObjectBlock = nil; @@ -123,6 +133,8 @@ - (void)didReceiveMemoryWarningNotification:(NSNotification *)notification { if (self.removeAllObjectsOnMemoryWarning) [self removeAllObjectsAsync:nil]; + [self removeExpiredObjects]; + [self.operationQueue scheduleOperation:^{ [self lock]; PINCacheBlock didReceiveMemoryWarningBlock = self->_didReceiveMemoryWarningBlock; @@ -165,8 +177,10 @@ - (void)removeObjectAndExecuteBlocksForKey:(NSString *)key _totalCost -= [cost unsignedIntegerValue]; [_dictionary removeObjectForKey:key]; - [_dates removeObjectForKey:key]; + [_createdDates removeObjectForKey:key]; + [_accessDates removeObjectForKey:key]; [_costs removeObjectForKey:key]; + [_ageLimits removeObjectForKey:key]; [self unlock]; if (didRemoveObjectBlock) @@ -176,16 +190,18 @@ - (void)removeObjectAndExecuteBlocksForKey:(NSString *)key - (void)trimMemoryToDate:(NSDate *)trimDate { [self lock]; - NSArray *keysSortedByDate = [_dates keysSortedByValueUsingSelector:@selector(compare:)]; - NSDictionary *dates = [_dates copy]; + NSArray *keysSortedByCreatedDate = [_createdDates keysSortedByValueUsingSelector:@selector(compare:)]; + NSDictionary *createdDates = [_createdDates copy]; + NSDictionary *ageLimits = [_ageLimits copy]; [self unlock]; - for (NSString *key in keysSortedByDate) { // oldest objects first - NSDate *accessDate = dates[key]; - if (!accessDate) + for (NSString *key in keysSortedByCreatedDate) { // oldest objects first + NSDate *createdDate = createdDates[key]; + NSTimeInterval ageLimit = [ageLimits[key] doubleValue]; + if (!createdDate || ageLimit > 0.0) continue; - if ([accessDate compare:trimDate] == NSOrderedAscending) { // older than trim date + if ([createdDate compare:trimDate] == NSOrderedAscending) { // older than trim date [self removeObjectAndExecuteBlocksForKey:key]; } else { break; @@ -193,6 +209,28 @@ - (void)trimMemoryToDate:(NSDate *)trimDate } } +- (void)removeExpiredObjects +{ + [self lock]; + NSDictionary *createdDates = [_createdDates copy]; + NSDictionary *ageLimits = [_ageLimits copy]; + NSTimeInterval globalAgeLimit = self->_ageLimit; + [self unlock]; + + NSDate *now = [NSDate date]; + for (NSString *key in ageLimits) { + NSDate *createdDate = createdDates[key]; + NSTimeInterval ageLimit = [ageLimits[key] doubleValue] ?: globalAgeLimit; + if (!createdDate) + continue; + + NSDate *expirationDate = [createdDate dateByAddingTimeInterval:ageLimit]; + if ([expirationDate compare:now] == NSOrderedAscending) { // Expiration date has passed + [self removeObjectAndExecuteBlocksForKey:key]; + } + } +} + - (void)trimToCostLimit:(NSUInteger)limit { NSUInteger totalCost = 0; @@ -220,17 +258,21 @@ - (void)trimToCostLimit:(NSUInteger)limit - (void)trimToCostLimitByDate:(NSUInteger)limit { + if (self.isTTLCache) { + [self removeExpiredObjects]; + } + NSUInteger totalCost = 0; [self lock]; totalCost = _totalCost; - NSArray *keysSortedByDate = [_dates keysSortedByValueUsingSelector:@selector(compare:)]; + NSArray *keysSortedByAccessDate = [_accessDates keysSortedByValueUsingSelector:@selector(compare:)]; [self unlock]; if (totalCost <= limit) return; - for (NSString *key in keysSortedByDate) { // oldest objects first + for (NSString *key in keysSortedByAccessDate) { // oldest objects first [self removeObjectAndExecuteBlocksForKey:key]; [self lock]; @@ -294,10 +336,20 @@ - (void)setObjectAsync:(id)object forKey:(NSString *)key completion:(PINCacheObj [self setObjectAsync:object forKey:key withCost:0 completion:block]; } +- (void)setObjectAsync:(id)object forKey:(NSString *)key withAgeLimit:(NSTimeInterval)ageLimit completion:(PINCacheObjectBlock)block +{ + [self setObjectAsync:object forKey:key withCost:0 ageLimit:ageLimit completion:block]; +} + - (void)setObjectAsync:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost completion:(PINCacheObjectBlock)block +{ + [self setObjectAsync:object forKey:key withCost:cost ageLimit:0.0 completion:block]; +} + +- (void)setObjectAsync:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost ageLimit:(NSTimeInterval)ageLimit completion:(PINCacheObjectBlock)block { [self.operationQueue scheduleOperation:^{ - [self setObject:object forKey:key withCost:cost]; + [self setObject:object forKey:key withCost:cost ageLimit:ageLimit]; if (block) block(self, key, object); @@ -344,6 +396,16 @@ - (void)trimToCostByDateAsync:(NSUInteger)cost completion:(PINCacheBlock)block } withPriority:PINOperationQueuePriorityHigh]; } +- (void)removeExpiredObjectsAsync:(PINCacheBlock)block +{ + [self.operationQueue scheduleOperation:^{ + [self removeExpiredObjects]; + + if (block) + block(self); + } withPriority:PINOperationQueuePriorityHigh]; +} + - (void)removeAllObjectsAsync:(PINCacheBlock)block { [self.operationQueue scheduleOperation:^{ @@ -382,18 +444,19 @@ - (nullable id)objectForKey:(NSString *)key if (!key) return nil; - NSDate *now = [[NSDate alloc] init]; + NSDate *now = [NSDate date]; [self lock]; id object = nil; // If the cache should behave like a TTL cache, then only fetch the object if there's a valid ageLimit and the object is still alive - if (!self->_ttlCache || self->_ageLimit <= 0 || fabs([[_dates objectForKey:key] timeIntervalSinceDate:now]) < self->_ageLimit) { + NSTimeInterval ageLimit = [_ageLimits[key] doubleValue] ?: self->_ageLimit; + if (!self->_ttlCache || ageLimit <= 0 || fabs([[_createdDates objectForKey:key] timeIntervalSinceDate:now]) < ageLimit) { object = _dictionary[key]; } [self unlock]; if (object) { [self lock]; - _dates[key] = now; + _accessDates[key] = now; [self unlock]; } @@ -410,6 +473,11 @@ - (void)setObject:(id)object forKey:(NSString *)key [self setObject:object forKey:key withCost:0]; } +- (void)setObject:(id)object forKey:(NSString *)key withAgeLimit:(NSTimeInterval)ageLimit +{ + [self setObject:object forKey:key withCost:0 ageLimit:ageLimit]; +} + - (void)setObject:(id)object forKeyedSubscript:(NSString *)key { if (object == nil) { @@ -421,6 +489,13 @@ - (void)setObject:(id)object forKeyedSubscript:(NSString *)key - (void)setObject:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost { + [self setObject:object forKey:key withCost:cost ageLimit:0.0]; +} + +- (void)setObject:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost ageLimit:(NSTimeInterval)ageLimit +{ + NSAssert(ageLimit <= 0.0 || (ageLimit > 0.0 && _ttlCache), @"ttlCache must be set to YES if setting an object-level age limit."); + if (!key || !object) return; @@ -438,10 +513,18 @@ - (void)setObject:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost if (oldCost) _totalCost -= [oldCost unsignedIntegerValue]; + NSDate *now = [NSDate date]; _dictionary[key] = object; - _dates[key] = [[NSDate alloc] init]; + _createdDates[key] = now; + _accessDates[key] = now; _costs[key] = @(cost); - + + if (ageLimit > 0.0) { + _ageLimits[key] = @(ageLimit); + } else { + [_ageLimits removeObjectForKey:key]; + } + _totalCost += cost; [self unlock]; @@ -495,8 +578,10 @@ - (void)removeAllObjects [self lock]; [_dictionary removeAllObjects]; - [_dates removeAllObjects]; + [_createdDates removeAllObjects]; + [_accessDates removeAllObjects]; [_costs removeAllObjects]; + [_ageLimits removeAllObjects]; _totalCost = 0; [self unlock]; @@ -512,12 +597,13 @@ - (void)enumerateObjectsWithBlock:(PINCacheObjectEnumerationBlock)block return; [self lock]; - NSDate *now = [[NSDate alloc] init]; - NSArray *keysSortedByDate = [_dates keysSortedByValueUsingSelector:@selector(compare:)]; + NSDate *now = [NSDate date]; + NSArray *keysSortedByCreatedDate = [_createdDates keysSortedByValueUsingSelector:@selector(compare:)]; - for (NSString *key in keysSortedByDate) { + for (NSString *key in keysSortedByCreatedDate) { // If the cache should behave like a TTL cache, then only fetch the object if there's a valid ageLimit and the object is still alive - if (!self->_ttlCache || self->_ageLimit <= 0 || fabs([[_dates objectForKey:key] timeIntervalSinceDate:now]) < self->_ageLimit) { + NSTimeInterval ageLimit = [_ageLimits[key] doubleValue] ?: self->_ageLimit; + if (!self->_ttlCache || ageLimit <= 0 || fabs([[_createdDates objectForKey:key] timeIntervalSinceDate:now]) < ageLimit) { BOOL stop = NO; block(self, key, _dictionary[key], &stop); if (stop) diff --git a/Tests/NSDate+PINCacheTests.h b/Tests/NSDate+PINCacheTests.h new file mode 100644 index 00000000..e11033d0 --- /dev/null +++ b/Tests/NSDate+PINCacheTests.h @@ -0,0 +1,15 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface NSDate (PINCacheTests) + +/** Swizzles +[NSDate date] to always return the specified date. */ ++ (void)startMockingDateWithDate:(NSDate *)date; + +/** Stops swizzling +[NSDate date] and returns to original implementation */ ++ (void)stopMockingDate; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Tests/NSDate+PINCacheTests.m b/Tests/NSDate+PINCacheTests.m new file mode 100644 index 00000000..1133c2b0 --- /dev/null +++ b/Tests/NSDate+PINCacheTests.m @@ -0,0 +1,50 @@ +#import "NSDate+PINCacheTests.h" + +#import + +static NSDate *PINCacheTestsSwizzedDate = nil; + +@implementation NSDate (PINCacheTests) + ++ (void)startMockingDateWithDate:(NSDate *)date +{ + // If already swizzled, just replace the static date. + BOOL alreadySwizzled = (PINCacheTestsSwizzedDate != nil); + PINCacheTestsSwizzedDate = date; + if (alreadySwizzled) { return; } + + SEL originalSelector = @selector(date); + SEL swizzledSelector = @selector(swizzled_date); + + Method originalMethod = class_getClassMethod(self, originalSelector); + Method swizzledMethod = class_getClassMethod(self, swizzledSelector); + + Class class = object_getClass((id)self); + + if (class_addMethod(class, + originalSelector, + method_getImplementation(swizzledMethod), + method_getTypeEncoding(swizzledMethod))) { + class_replaceMethod(class, + swizzledSelector, + method_getImplementation(originalMethod), + method_getTypeEncoding(originalMethod)); + } else { + method_exchangeImplementations(originalMethod, swizzledMethod); + } +} + ++ (void)stopMockingDate +{ + Method originalMethod = class_getClassMethod(self, @selector(date)); + Method swizzledMethod = class_getClassMethod(self, @selector(swizzled_date)); + method_exchangeImplementations(swizzledMethod, originalMethod); + PINCacheTestsSwizzedDate = nil; +} + ++ (instancetype)swizzled_date +{ + return PINCacheTestsSwizzedDate; +} + +@end diff --git a/Tests/PINCacheTests.m b/Tests/PINCacheTests.m index d22ce2ed..e9a839e4 100644 --- a/Tests/PINCacheTests.m +++ b/Tests/PINCacheTests.m @@ -3,6 +3,8 @@ // Copyright (c) 2015 Pinterest. All rights reserved. #import "PINCacheTests.h" +#import "NSDate+PINCacheTests.h" +#import "PINDiskCache+PINCacheTests.h" #import #import @@ -19,6 +21,7 @@ @interface PINDiskCache() @property (assign, nonatomic) BOOL diskStateKnown; +@property (strong, nonatomic) NSDictionary *metadata; + (dispatch_queue_t)sharedTrashQueue; - (NSString *)encodedString:(NSString *)string; @@ -28,6 +31,7 @@ - (NSString *)encodedString:(NSString *)string; @interface PINMemoryCache () - (void)didReceiveEnterBackgroundNotification:(NSNotification *)notification; +- (void)setTtlCache:(BOOL)ttlCache; @end @@ -354,6 +358,51 @@ - (void)testMemoryCostByDate XCTAssertTrue(self.cache.memoryCache.totalCost == 0, @"cache had an unexpected total cost"); } +- (void)testMemoryCostByDateWithObjectExpiration +{ + [self.cache.memoryCache setTtlCache:YES]; + [self.cache.memoryCache removeAllObjects]; + NSString * const key1 = @"key1"; + NSString * const key2 = @"key2"; + NSString * const key3 = @"key3"; + NSString * const key4 = @"key4"; + const NSUInteger cost = 1; + const NSTimeInterval oldAgeLimit = 60.0; + const NSTimeInterval notSoOldAgeLimit = 30.0; + + // Add the objects to the cache; set the age limit of one of them (key3) so it expires before the others. + [self.cache.memoryCache setObject:[self image] forKey:key1 withCost:cost ageLimit:oldAgeLimit]; + [self.cache.memoryCache setObject:[self image] forKey:key2 withCost:cost ageLimit:oldAgeLimit]; + [self.cache.memoryCache setObject:[self image] forKey:key3 withCost:cost ageLimit:notSoOldAgeLimit]; + [self.cache.memoryCache setObject:[self image] forKey:key4 withCost:cost ageLimit:oldAgeLimit]; + + // Make the order of recently used key3, key1, key4, key2 + [self.cache.memoryCache objectForKey:key2]; + [self.cache.memoryCache objectForKey:key4]; + [self.cache.memoryCache objectForKey:key1]; + [self.cache.memoryCache objectForKey:key3]; + + // Fast forward 45 seconds. This should expire key3. + [NSDate startMockingDateWithDate:[NSDate dateWithTimeIntervalSinceNow:45]]; + + // Trim the cache enough to evict two objects. + [self.cache.memoryCache trimToCostByDate:self.cache.memoryCache.totalCost - cost * 2]; + + // Go back to current time, so we can check if objects exist in cache. If we don't do this, the getters will return nil + // even if the objects are in the cache. + [NSDate stopMockingDate]; + + // The only objects left should be the last two that were accessed (key3 && key1), but since key3 is expired it will be + // removed first leaving key1 and key4 to remain. + NSMutableArray *keys = [NSMutableArray array]; + [self.cache.memoryCache enumerateObjectsWithBlock:^(id cache, NSString * key, id object, BOOL *stop) { + [keys addObject:key]; + }]; + XCTAssertTrue(keys.count == 2); + XCTAssertTrue([keys.firstObject isEqualToString:key1] || [keys.firstObject isEqualToString:key4]); + XCTAssertTrue([keys.lastObject isEqualToString:key1] || [keys.lastObject isEqualToString:key4]); +} + - (void)testDiskByteCount { self.cache[@"image"] = [self image]; @@ -374,6 +423,54 @@ - (void)testDiskByteCountWithExistingKey XCTAssertTrue(self.cache.diskByteCount > initialDiskByteCount, @"disk cache byte count should increase with new key and object added to disk cache"); } +- (void)testDiskSizeByDateWithObjectExpiration +{ + [self.cache.diskCache setTtlCacheSync:YES]; + [self.cache.diskCache removeAllObjects]; + NSString * const key1 = @"key1"; + NSString * const key2 = @"key2"; + NSString * const key3 = @"key3"; + NSString * const key4 = @"key4"; + const NSUInteger cost = 1; + const NSTimeInterval oldAgeLimit = 60.0; + const NSTimeInterval notSoOldAgeLimit = 30.0; + + // Add the objects to the cache; set the age limit of one of them (key3) so it expires before the others. + [self.cache.diskCache setObject:[self image] forKey:key1 withCost:cost ageLimit:oldAgeLimit]; + [self.cache.diskCache setObject:[self image] forKey:key2 withCost:cost ageLimit:oldAgeLimit]; + [self.cache.diskCache setObject:[self image] forKey:key3 withCost:cost ageLimit:notSoOldAgeLimit]; + NSUInteger sizeOfThreeObjects = self.cache.diskCache.byteCount; + [self.cache.diskCache setObject:[self image] forKey:key4 withCost:cost ageLimit:oldAgeLimit]; + + // Make the order of recently used key3, key1, key4, key2 + [self.cache.diskCache objectForKey:key2]; + [self.cache.diskCache objectForKey:key4]; + [self.cache.diskCache objectForKey:key1]; + [self.cache.diskCache objectForKey:key3]; + + // Fast forward 45 seconds. This should expire key3. + [NSDate startMockingDateWithDate:[NSDate dateWithTimeIntervalSinceNow:45]]; + + + // Trim the cache enough to evict two objects. + [self.cache.diskCache trimToSizeByDate:sizeOfThreeObjects - 1]; + + // Go back to current time, so we can check if objects exist in cache. If we don't do this, the getters will return nil + // even if the objects are in the cache. + [NSDate stopMockingDate]; + + // The only objects left should be the last two that were accessed (key3 && key1), but since key3 is expired it will be + // removed first leaving key1 and key4 to remain. + NSMutableArray *keys = [NSMutableArray array]; + [self.cache.diskCache enumerateObjectsWithBlock:^(NSString * _Nonnull key, NSURL * _Nullable fileURL, BOOL * _Nonnull stop) { + [keys addObject:key]; + }]; + + XCTAssertTrue(keys.count == 2); + XCTAssertTrue([keys.firstObject isEqualToString:key1] || [keys.firstObject isEqualToString:key4]); + XCTAssertTrue([keys.lastObject isEqualToString:key1] || [keys.lastObject isEqualToString:key4]); +} + - (void)testOneThousandAndOneWrites { NSUInteger max = 1001; @@ -777,7 +874,7 @@ - (void)_testTTLCacheObjectAccess { // Wait until time 3 so that we know the object should be expired, the 1st cache clearing has happened, and the 2nd cache clearing hasn't happened yet sleep(2); - [self.cache.diskCache setTtlCache:YES]; + [self.cache.diskCache setTtlCacheSync:YES]; [self.cache.memoryCache setTtlCache:YES]; dispatch_group_t group = dispatch_group_create(); @@ -803,7 +900,7 @@ - (void)_testTTLCacheObjectAccess { XCTAssertNil(memObj, @"should not be in memory cache"); XCTAssertNil(diskObj, @"should not be in disk cache"); - [self.cache.diskCache setTtlCache:NO]; + [self.cache.diskCache setTtlCacheSync:NO]; [self.cache.memoryCache setTtlCache:NO]; memObj = nil; @@ -842,7 +939,7 @@ - (void)_testTTLCacheObjectEnumeration { // Wait until time 3 so that we know the object should be expired, the 1st cache clearing has happened, and the 2nd cache clearing hasn't happened yet sleep(2); - [self.cache.diskCache setTtlCache:YES]; + [self.cache.diskCache setTtlCacheSync:YES]; [self.cache.memoryCache setTtlCache:YES]; // Wait for ttlCache to be set @@ -869,7 +966,7 @@ - (void)_testTTLCacheObjectEnumeration { XCTAssertEqual(objCount, expectedObjCount, @"Expected %lu objects in the cache", (unsigned long)expectedObjCount); - [self.cache.diskCache setTtlCache:NO]; + [self.cache.diskCache setTtlCacheSync:NO]; [self.cache.memoryCache setTtlCache:NO]; // Wait for ttlCache to be set @@ -923,7 +1020,7 @@ - (void)_testTTLCacheFileURLForKey { // Wait a moment to ensure that the file modification time can be changed to something different sleep(1); - [self.cache.diskCache setTtlCache:YES]; + [self.cache.diskCache setTtlCacheSync:YES]; // Wait for ttlCache to be set sleep(1); @@ -940,7 +1037,7 @@ - (void)_testTTLCacheFileURLForKey { XCTAssertEqualObjects(initialModificationDate, ttlCacheEnabledModificationDate, @"The modification date shouldn't change when accessing the file URL, when ttlCache is enabled"); - [self.cache.diskCache setTtlCache:NO]; + [self.cache.diskCache setTtlCacheSync:NO]; // Wait for ttlCache to be set sleep(1); @@ -959,6 +1056,202 @@ - (void)_testTTLCacheFileURLForKey { } +- (void)testObjectTTLObjectAccess +{ + [self.cache.memoryCache setTtlCache:YES]; + [self.cache.diskCache setTtlCacheSync:YES]; + [self.cache removeAllObjects]; + NSString *key1 = @"key1"; + NSString *key2 = @"key2"; + + [self.cache setObject:[self image] forKey:key1 withAgeLimit:60.0]; + [self.cache setObject:[self image] forKey:key2 withAgeLimit:120.0]; + + // Neither object should be expired at this point and should exist in both caches + + XCTestExpectation *memObjectForKey1Expectation = [self expectationWithDescription:@"memoryCache objectForKeyAsync - #1"]; + [self.cache.memoryCache objectForKeyAsync:key1 completion:^(PINMemoryCache *cache, NSString *key, id object) { + XCTAssertNotNil(object, @"should still be in memory cache"); + [memObjectForKey1Expectation fulfill]; + }]; + + XCTestExpectation *memObjectForKey2Expectation = [self expectationWithDescription:@"memoryCache objectForKeyAsync - #2"]; + [self.cache.memoryCache objectForKeyAsync:key2 completion:^(PINMemoryCache *cache, NSString *key, id object) { + XCTAssertNotNil(object, @"should still be in memory cache"); + [memObjectForKey2Expectation fulfill]; + }]; + + XCTestExpectation *diskObjectForKey1Expectation = [self expectationWithDescription:@"diskCache objectForKeyAsync - #1"]; + [self.cache.diskCache objectForKeyAsync:key1 completion:^(PINDiskCache *cache, NSString *key, id object) { + XCTAssertNotNil(object, @"should still be in disk cache"); + [diskObjectForKey1Expectation fulfill]; + }]; + + XCTestExpectation *diskObjectForKey2Expectation = [self expectationWithDescription:@"diskCache objectForKeyAsync - #2"]; + [self.cache.diskCache objectForKeyAsync:key2 completion:^(PINDiskCache *cache, NSString *key, id object) { + XCTAssertNotNil(object, @"should still be in disk cache"); + [diskObjectForKey2Expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:0.1 handler:nil]; + + // Fast forward 90 seconds. + [NSDate startMockingDateWithDate:[NSDate dateWithTimeIntervalSinceNow:90]]; + + // The first object has been expired for 30 seconds and should not exist in the cache anymore. + + memObjectForKey1Expectation = [self expectationWithDescription:@"memoryCache objectForKeyAsync - #1"]; + [self.cache.memoryCache objectForKeyAsync:key1 completion:^(PINMemoryCache *cache, NSString *key, id object) { + XCTAssertNil(object, @"should not be in memory cache"); + [memObjectForKey1Expectation fulfill]; + }]; + + memObjectForKey2Expectation = [self expectationWithDescription:@"memoryCache objectForKeyAsync - #2"]; + [self.cache.memoryCache objectForKeyAsync:key2 completion:^(PINMemoryCache *cache, NSString *key, id object) { + XCTAssertNotNil(object, @"should not be in memory cache"); + [memObjectForKey2Expectation fulfill]; + }]; + + diskObjectForKey1Expectation = [self expectationWithDescription:@"diskCache objectForKeyAsync - #1"]; + [self.cache.diskCache objectForKeyAsync:key1 completion:^(PINDiskCache *cache, NSString *key, id object) { + XCTAssertNil(object, @"should not be in disk cache"); + [diskObjectForKey1Expectation fulfill]; + }]; + + diskObjectForKey2Expectation = [self expectationWithDescription:@"diskCache objectForKeyAsync - #2"]; + [self.cache.diskCache objectForKeyAsync:key2 completion:^(PINDiskCache *cache, NSString *key, id object) { + XCTAssertNotNil(object, @"should not be in disk cache"); + [diskObjectForKey2Expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:0.1 handler:nil]; + + [NSDate stopMockingDate]; +} + +- (void)testObjectTTLObjectEnumeration +{ + [self.cache.memoryCache setTtlCache:YES]; + [self.cache.diskCache setTtlCacheSync:YES]; + [self.cache removeAllObjects]; + NSString *key1 = @"key1"; + NSString *key2 = @"key2"; + + [self.cache setObject:[self image] forKey:key1 withAgeLimit:60.0]; + [self.cache setObject:[self image] forKey:key2 withAgeLimit:120.0]; + + // Neither object should be expired at this point and should exist in both caches + + __block NSUInteger objCount = 0; + [self.cache.memoryCache enumerateObjectsWithBlock:^(PINMemoryCache *cache, NSString *key, id _Nullable object, BOOL *stop) { + objCount++; + }]; + XCTAssertEqual(objCount, 2, @"Expected 2 objects, got %tu.", objCount); + + objCount = 0; + [self.cache.diskCache enumerateObjectsWithBlock:^(NSString * _Nonnull key, NSURL * _Nullable fileURL, BOOL *stop) { + objCount++; + }]; + XCTAssertEqual(objCount, 2, @"Expected 2 objects, got %tu.", objCount); + + // Fast forward 90 seconds. + [NSDate startMockingDateWithDate:[NSDate dateWithTimeIntervalSinceNow:90]]; + + // The first object has been expired for 30 seconds and should not exist in the cache anymore. + + objCount = 0; + [self.cache.memoryCache enumerateObjectsWithBlock:^(PINMemoryCache *cache, NSString *key, id _Nullable object, BOOL *stop) { + objCount++; + }]; + XCTAssertEqual(objCount, 1, @"Expected 1 object, got %tu.", objCount); + + objCount = 0; + [self.cache.diskCache enumerateObjectsWithBlock:^(NSString * _Nonnull key, NSURL * _Nullable fileURL, BOOL *stop) { + objCount++; + }]; + XCTAssertEqual(objCount, 1, @"Expected 1 object, got %tu.", objCount); + + [NSDate stopMockingDate]; +} + +- (void)testRemoveExpiredObjects +{ + [self.cache.memoryCache setTtlCache:YES]; + [self.cache.diskCache setTtlCacheSync:YES]; + [self.cache removeAllObjects]; + NSString *key1 = @"key1"; + NSString *key2 = @"key2"; + + [self.cache setObject:[self image] forKey:key1 withAgeLimit:60.0]; + [self.cache setObject:[self image] forKey:key2 withAgeLimit:120.0]; + + // Fast forward 90 seconds. + [NSDate startMockingDateWithDate:[NSDate dateWithTimeIntervalSinceNow:90]]; + + // The first object has been expired for 30 seconds and should be cleared out. + [self.cache removeExpiredObjects]; + + // Go back to current time, so we can check if objects exist in cache. If we don't do this, the getters will return nil + // even if the objects are in the cache. + [NSDate stopMockingDate]; + + XCTestExpectation *memObjectForKey1Expectation = [self expectationWithDescription:@"memoryCache objectForKeyAsync - #1"]; + [self.cache.memoryCache objectForKeyAsync:key1 completion:^(PINMemoryCache *cache, NSString *key, id object) { + XCTAssertNil(object, @"should not be in memory cache"); + [memObjectForKey1Expectation fulfill]; + }]; + + XCTestExpectation *memObjectForKey2Expectation = [self expectationWithDescription:@"memoryCache objectForKeyAsync - #2"]; + [self.cache.memoryCache objectForKeyAsync:key2 completion:^(PINMemoryCache *cache, NSString *key, id object) { + XCTAssertNotNil(object, @"should not be in memory cache"); + [memObjectForKey2Expectation fulfill]; + }]; + + XCTestExpectation *diskObjectForKey1Expectation = [self expectationWithDescription:@"diskCache objectForKeyAsync - #1"]; + [self.cache.diskCache objectForKeyAsync:key1 completion:^(PINDiskCache *cache, NSString *key, id object) { + XCTAssertNil(object, @"should not be in disk cache"); + [diskObjectForKey1Expectation fulfill]; + }]; + + XCTestExpectation *diskObjectForKey2Expectation = [self expectationWithDescription:@"diskCache objectForKeyAsync - #2"]; + [self.cache.diskCache objectForKeyAsync:key2 completion:^(PINDiskCache *cache, NSString *key, id object) { + XCTAssertNotNil(object, @"should not be in disk cache"); + [diskObjectForKey2Expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:0.1 handler:nil]; +} + +- (void)testDiskRehydrationOfObjectAgeLimit +{ + NSString * const cacheName = @"testDiskRehydrationOfObjectAgeLimit"; + NSString * const key = @"key"; + const NSTimeInterval ageLimit = 60.0; + PINDiskCache *testCache = [[PINDiskCache alloc] initWithName:cacheName]; + [testCache setTtlCacheSync:YES]; + NSURL *testCacheURL = testCache.cacheURL; + NSError *error = nil; + + //Make sure the cache URL does not exist. + if ([[NSFileManager defaultManager] fileExistsAtPath:[testCacheURL path]]) { + [[NSFileManager defaultManager] removeItemAtURL:testCacheURL error:&error]; + XCTAssertNil(error); + } + + testCache = [[PINDiskCache alloc] initWithName:cacheName]; + [testCache setTtlCacheSync:YES]; + [testCache setObject:[self image] forKey:key withAgeLimit:ageLimit]; + + // Re-initialize the cache, this should read the age limit for the object from the extended file system attributes. + testCache = [[PINDiskCache alloc] initWithName:cacheName]; + [testCache setTtlCacheSync:YES]; + //This should not return until *after* disk cache directory has been created + [testCache setObject:@"some bogus object" forKey:@"some bogus key"]; + id object = testCache.metadata[key]; + id ageLimitFromDisk = [object valueForKey:@"ageLimit"]; + XCTAssertEqual([ageLimitFromDisk doubleValue], ageLimit); +} + - (void)testAsyncDiskInitialization { NSString * const cacheName = @"testAsyncDiskInitialization"; diff --git a/Tests/PINDiskCache+PINCacheTests.h b/Tests/PINDiskCache+PINCacheTests.h new file mode 100644 index 00000000..3fb5f557 --- /dev/null +++ b/Tests/PINDiskCache+PINCacheTests.h @@ -0,0 +1,11 @@ +#import + +@interface PINDiskCache (PINCacheTests) + +/** + Sets `ttlCache` property synchronously. This is normally set asyncronously, but for testing purposes it is useful block until the + actual value has been set. + */ +- (void)setTtlCacheSync:(BOOL)ttlCache; + +@end diff --git a/Tests/PINDiskCache+PINCacheTests.m b/Tests/PINDiskCache+PINCacheTests.m new file mode 100644 index 00000000..6d54a4ec --- /dev/null +++ b/Tests/PINDiskCache+PINCacheTests.m @@ -0,0 +1,23 @@ +#import "PINDiskCache+PINCacheTests.h" + +@interface PINDiskCache () +- (void)setTtlCache:(BOOL)ttlCache; +@end + +@implementation PINDiskCache (PINCacheTests) + +- (void)setTtlCacheSync:(BOOL)ttlCache +{ + [self setTtlCache:ttlCache]; + + // Attempt to read from the cache. This will be put on the same queue as `setTtlCache`, but at a lower priority. + // When the completion handler runs, we can be sure the property value has been set. + dispatch_group_t group = dispatch_group_create(); + dispatch_group_enter(group); + [self objectForKeyAsync:@"some bogus key" completion:^(PINDiskCache *cache, NSString *key, id object) { + dispatch_group_leave(group); + }]; + dispatch_group_wait(group, DISPATCH_TIME_FOREVER); +} + +@end