From 803c069e507717e4011938157af1e575de57eb53 Mon Sep 17 00:00:00 2001 From: Andy Finnell Date: Fri, 10 May 2024 16:39:57 -0400 Subject: [PATCH] Add Least Frequently Used eviction strategy (#328) * Add Least Frequently Used eviction strategy ## Summary Currently PINCache only offers LRU (least recently used) as an eviction strategy. However, there are some special workloads where LFU (least frequently used) could offer better performance. This PR introduces LFU alongside the existing LRU eviction strategy. The default is still LRU. There is also some minor renaming to the `trimToSizeByDateAsync`, `trimToSizeByDate`, `trimToCostByDate`, and `trimToCostByDateAsync` methods, since those now follow the explicit eviction strategy. Old methods remain and work as expected, but are marked deprecated. ## Testing Added some unit tests for both memory and disk caches to verify objects are evicted based on access count when LFU is selected. Ran tests on iOS, tvOS, macOS. * Add deprecated messages to clarify what should be used instead * DRY up a couple of constants * Remove doc for parameter that doesn't exist * Nudge github actions --- Source/PINCache.h | 28 ++++++- Source/PINCache.m | 19 ++++- Source/PINCaching.h | 5 ++ Source/PINDiskCache.h | 51 ++++++++++-- Source/PINDiskCache.m | 167 ++++++++++++++++++++++++++++++++++++---- Source/PINMemoryCache.h | 21 +++-- Source/PINMemoryCache.m | 72 ++++++++++++++--- Tests/PINCacheTests.m | 124 +++++++++++++++++++++++++++-- 8 files changed, 443 insertions(+), 44 deletions(-) diff --git a/Source/PINCache.h b/Source/PINCache.h index 7ddec1c6..9868c3d8 100644 --- a/Source/PINCache.h +++ b/Source/PINCache.h @@ -147,7 +147,33 @@ PIN_SUBCLASSING_RESTRICTED deserializer:(nullable PINDiskCacheDeserializerBlock)deserializer keyEncoder:(nullable PINDiskCacheKeyEncoderBlock)keyEncoder keyDecoder:(nullable PINDiskCacheKeyDecoderBlock)keyDecoder - ttlCache:(BOOL)ttlCache NS_DESIGNATED_INITIALIZER; + ttlCache:(BOOL)ttlCache; + +/** + 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 + @param ttlCache Whether or not the cache should behave as a TTL cache. + @param evictionStrategy How the cache decide to evict objects when over cost. + @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 + ttlCache:(BOOL)ttlCache + evictionStrategy:(PINCacheEvictionStrategy)evictionStrategy NS_DESIGNATED_INITIALIZER; @end diff --git a/Source/PINCache.m b/Source/PINCache.m index c43d21c4..1ad02eea 100644 --- a/Source/PINCache.m +++ b/Source/PINCache.m @@ -48,6 +48,17 @@ - (instancetype)initWithName:(NSString *)name return [self initWithName:name rootPath:rootPath serializer:serializer deserializer:deserializer keyEncoder:keyEncoder keyDecoder:keyDecoder ttlCache:NO]; } +- (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 + ttlCache:(BOOL)ttlCache +{ + return [self initWithName:name rootPath:rootPath serializer:serializer deserializer:deserializer keyEncoder:keyEncoder keyDecoder:keyDecoder ttlCache:ttlCache evictionStrategy:PINCacheEvictionStrategyLeastRecentlyUsed]; +} + - (instancetype)initWithName:(NSString *)name rootPath:(NSString *)rootPath serializer:(PINDiskCacheSerializerBlock)serializer @@ -55,6 +66,7 @@ - (instancetype)initWithName:(NSString *)name keyEncoder:(PINDiskCacheKeyEncoderBlock)keyEncoder keyDecoder:(PINDiskCacheKeyDecoderBlock)keyDecoder ttlCache:(BOOL)ttlCache + evictionStrategy:(PINCacheEvictionStrategy)evictionStrategy { if (!name) return nil; @@ -72,8 +84,11 @@ - (instancetype)initWithName:(NSString *)name keyEncoder:keyEncoder keyDecoder:keyDecoder operationQueue:_operationQueue - ttlCache:ttlCache]; - _memoryCache = [[PINMemoryCache alloc] initWithName:_name operationQueue:_operationQueue ttlCache:ttlCache]; + ttlCache:ttlCache + byteLimit:PINDiskCacheDefaultByteLimit + ageLimit:PINDiskCacheDefaultAgeLimit + evictionStrategy:evictionStrategy]; + _memoryCache = [[PINMemoryCache alloc] initWithName:_name operationQueue:_operationQueue ttlCache:ttlCache evictionStrategy:evictionStrategy]; } return self; } diff --git a/Source/PINCaching.h b/Source/PINCaching.h index 5e44886c..79ecad5e 100644 --- a/Source/PINCaching.h +++ b/Source/PINCaching.h @@ -34,6 +34,11 @@ typedef void (^PINCacheObjectEnumerationBlock)(__kindof id cache, NS */ typedef void (^PINCacheObjectContainmentBlock)(BOOL containsObject); +typedef NS_ENUM(NSInteger, PINCacheEvictionStrategy) { + PINCacheEvictionStrategyLeastRecentlyUsed, + PINCacheEvictionStrategyLeastFrequentlyUsed, +}; + @protocol PINCaching #pragma mark - Core diff --git a/Source/PINDiskCache.h b/Source/PINDiskCache.h index ed986035..075d4c4d 100644 --- a/Source/PINDiskCache.h +++ b/Source/PINDiskCache.h @@ -18,6 +18,9 @@ extern NSErrorUserInfoKey const PINDiskCacheErrorReadFailureCodeKey; extern NSErrorUserInfoKey const PINDiskCacheErrorWriteFailureCodeKey; extern NSString * const PINDiskCachePrefix; +extern NSUInteger PINDiskCacheDefaultByteLimit; +extern NSTimeInterval PINDiskCacheDefaultAgeLimit; + typedef NS_ENUM(NSInteger, PINDiskCacheError) { PINDiskCacheErrorReadFailure = -1000, PINDiskCacheErrorWriteFailure = -1001, @@ -168,6 +171,11 @@ PIN_SUBCLASSING_RESTRICTED */ @property (assign) NSTimeInterval ageLimit; +/** + The eviction strategy when trimming the cache. + */ +@property (atomic, assign) PINCacheEvictionStrategy evictionStrategy; + /** The writing protection option used when writing a file on disk. This value is used every time an object is set. NSDataWritingAtomic and NSDataWritingWithoutOverwriting are ignored if set @@ -336,6 +344,33 @@ PIN_SUBCLASSING_RESTRICTED operationQueue:(nonnull PINOperationQueue *)operationQueue ttlCache:(BOOL)ttlCache; +/** + @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 + @param ttlCache Whether or not the cache should behave as a TTL cache. + @param byteLimit The maximum number of bytes allowed on disk. Defaults to 50MB. + @param ageLimit The maximum number of seconds an object is allowed to exist in the cache. Defaults to 30 days. + @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 + ttlCache:(BOOL)ttlCache + byteLimit:(NSUInteger)byteLimit + ageLimit:(NSTimeInterval)ageLimit; + /** The designated initializer allowing you to override default NSKeyedArchiver/NSKeyedUnarchiver serialization. @@ -351,6 +386,7 @@ PIN_SUBCLASSING_RESTRICTED @param ttlCache Whether or not the cache should behave as a TTL cache. @param byteLimit The maximum number of bytes allowed on disk. Defaults to 50MB. @param ageLimit The maximum number of seconds an object is allowed to exist in the cache. Defaults to 30 days. + @param evictionStrategy How the cache decides to evict objects @result A new cache with the specified name. */ - (instancetype)initWithName:(nonnull NSString *)name @@ -363,7 +399,8 @@ PIN_SUBCLASSING_RESTRICTED operationQueue:(nonnull PINOperationQueue *)operationQueue ttlCache:(BOOL)ttlCache byteLimit:(NSUInteger)byteLimit - ageLimit:(NSTimeInterval)ageLimit NS_DESIGNATED_INITIALIZER; + ageLimit:(NSTimeInterval)ageLimit + evictionStrategy:(PINCacheEvictionStrategy)evictionStrategy NS_DESIGNATED_INITIALIZER; #pragma mark - Asynchronous Methods /// @name Asynchronous Methods @@ -471,7 +508,7 @@ PIN_SUBCLASSING_RESTRICTED - (void)trimToSizeAsync:(NSUInteger)byteCount completion:(nullable PINCacheBlock)block; /** - Removes objects from the cache, ordered by date (least recently used first), until the cache is equal to or smaller + Removes objects from the cache, using the eviction strategy, until the cache is equal to or smaller than the specified byteCount. This method returns immediately and executes the passed block as soon as the cache has been trimmed. @@ -480,7 +517,7 @@ PIN_SUBCLASSING_RESTRICTED @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; +- (void)trimToSizeByEvictionStrategyAsync:(NSUInteger)byteCount completion:(nullable PINCacheBlock)block; /** Loops through all objects in the cache (reads and writes are suspended during the enumeration). Data is not actually @@ -564,15 +601,15 @@ PIN_SUBCLASSING_RESTRICTED - (void)trimToSize:(NSUInteger)byteCount; /** - Removes objects from the cache, ordered by date (least recently used first), until the cache is equal to or + Removes objects from the cache, using the defined evictionStrategy, until the cache is equal to or smaller than the specified byteCount. This method blocks the calling thread until the cache has been trimmed. - @see trimToSizeByDateAsync: + @see trimToSizeByEvictionStrategyAsync: @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; +- (void)trimToSizeByEvictionStrategy:(NSUInteger)byteCount; /** Loops through all objects in the cache (reads and writes are suspended during the enumeration). Data is not actually @@ -616,6 +653,8 @@ typedef void (^PINDiskCacheBlock)(PINDiskCache *cache); - (void)removeAllObjects:(nullable PINDiskCacheBlock)block __attribute__((deprecated)); - (void)enumerateObjectsWithBlock:(PINDiskCacheFileURLBlock)block completionBlock:(nullable PINDiskCacheBlock)completionBlock __attribute__((deprecated)); - (void)setTtlCache:(BOOL)ttlCache DEPRECATED_MSG_ATTRIBUTE("ttlCache is no longer a settable property and must now be set via initializer."); +- (void)trimToSizeByDate:(NSUInteger)byteCount DEPRECATED_MSG_ATTRIBUTE("Use trimToSizeByEvictionStrategy: instead"); +- (void)trimToSizeByDateAsync:(NSUInteger)byteCount completion:(nullable PINCacheBlock)block DEPRECATED_MSG_ATTRIBUTE("Use trimToSizeByEvictionStrategyAsync:completion: instead"); @end NS_ASSUME_NONNULL_END diff --git a/Source/PINDiskCache.m b/Source/PINDiskCache.m index 4e0037e0..fc6b0c81 100644 --- a/Source/PINDiskCache.m +++ b/Source/PINDiskCache.m @@ -20,12 +20,16 @@ #define PINDiskCacheException(exception) if (exception) { NSAssert(NO, [exception reason]); } const char * PINDiskCacheAgeLimitAttributeName = "com.pinterest.PINDiskCache.ageLimit"; +const char * PINDiskCacheAccessCountAttributeName = "com.pinterest.PINDiskCache.accessCount"; 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"; +NSUInteger PINDiskCacheDefaultByteLimit = 50 * 1024 * 1024; // 50 MB by default +NSTimeInterval PINDiskCacheDefaultAgeLimit = 60 * 60 * 24 * 30; // 30 days by default + static NSString * const PINDiskCacheOperationIdentifierTrimToDate = @"PINDiskCacheOperationIdentifierTrimToDate"; static NSString * const PINDiskCacheOperationIdentifierTrimToSize = @"PINDiskCacheOperationIdentifierTrimToSize"; static NSString * const PINDiskCacheOperationIdentifierTrimToSizeByDate = @"PINDiskCacheOperationIdentifierTrimToSizeByDate"; @@ -58,6 +62,8 @@ @interface PINDiskCacheMetadata : NSObject @property (nonatomic, strong) NSNumber *size; // Age limit is used in conjuction with ttl @property (nonatomic) NSTimeInterval ageLimit; +// Access count is how many times this object has been fetched. Used with the LFU +@property (nonatomic) NSInteger accessCount; @end @interface PINDiskCache () { @@ -187,8 +193,35 @@ - (instancetype)initWithName:(NSString *)name keyDecoder:keyDecoder operationQueue:operationQueue ttlCache:ttlCache - byteLimit:50 * 1024 * 1024 // 50 MB by default - ageLimit:60 * 60 * 24 * 30]; // 30 days by default + byteLimit:PINDiskCacheDefaultByteLimit + ageLimit:PINDiskCacheDefaultAgeLimit]; +} + +- (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 + ttlCache:(BOOL)ttlCache + byteLimit:(NSUInteger)byteLimit + ageLimit:(NSTimeInterval)ageLimit +{ + return [self initWithName:name + prefix:prefix + rootPath:rootPath + serializer:serializer + deserializer:deserializer + keyEncoder:keyEncoder + keyDecoder:keyDecoder + operationQueue:operationQueue + ttlCache:ttlCache + byteLimit:byteLimit + ageLimit:ageLimit + evictionStrategy:PINCacheEvictionStrategyLeastRecentlyUsed]; + } - (instancetype)initWithName:(NSString *)name @@ -202,6 +235,7 @@ - (instancetype)initWithName:(NSString *)name ttlCache:(BOOL)ttlCache byteLimit:(NSUInteger)byteLimit ageLimit:(NSTimeInterval)ageLimit + evictionStrategy:(PINCacheEvictionStrategy)evictionStrategy { if (!name) { return nil; @@ -231,6 +265,7 @@ - (instancetype)initWithName:(NSString *)name _byteCount = 0; _byteLimit = byteLimit; _ageLimit = ageLimit; + _evictionStrategy = evictionStrategy; #if TARGET_OS_IPHONE _writingProtectionOptionSet = NO; @@ -548,6 +583,19 @@ - (NSUInteger)_locked_initializeDiskPropertiesForFile:(NSURL *)fileURL fileKey:( } } } + + NSInteger accessCount = 0; + ssize_t accessCountResult = getxattr(PINDiskCacheFileSystemRepresentation(fileURL), PINDiskCacheAccessCountAttributeName, &accessCount, sizeof(NSInteger), 0, 0); + if(accessCountResult > 0) { + _metadata[fileKey].accessCount = accessCount; + } else if (accessCountResult == -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); + } + } return [fileSize unsignedIntegerValue]; } @@ -582,7 +630,7 @@ - (void)initializeDiskProperties _byteCount = byteCount; if (self->_byteLimit > 0 && self->_byteCount > self->_byteLimit) - [self trimToSizeByDateAsync:self->_byteLimit completion:nil]; + [self trimToSizeByEvictionStrategyAsync:self->_byteLimit completion:nil]; if (self->_ttlCache) [self removeExpiredObjectsAsync:nil]; @@ -659,6 +707,49 @@ - (BOOL)_locked_setAgeLimit:(NSTimeInterval)ageLimit forURL:(NSURL *)fileURL return !error; } +- (void)asynchronouslySetAccessCount:(NSInteger)accessCount forURL:(NSURL *)fileURL +{ + [self.operationQueue scheduleOperation:^{ + [self lockForWriting]; + [self _locked_setAcessCount:accessCount forURL:fileURL]; + [self unlock]; + } withPriority:PINOperationQueuePriorityLow]; +} + +- (BOOL)_locked_setAcessCount:(NSInteger)accessCount forURL:(NSURL *)fileURL +{ + if (!fileURL) { + return NO; + } + + NSError *error = nil; + if (accessCount <= 0) { + if (removexattr(PINDiskCacheFileSystemRepresentation(fileURL), PINDiskCacheAccessCountAttributeName, 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), PINDiskCacheAccessCountAttributeName, &accessCount, sizeof(NSInteger), 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].accessCount = accessCount; + } + } + + return !error; +} + - (BOOL)removeFileAndExecuteBlocksForKey:(NSString *)key { NSURL *fileURL = [self encodedFileURLForKey:key]; @@ -738,7 +829,7 @@ - (void)trimDiskToSize:(NSUInteger)trimByteCount } // This is the default trimming method which happens automatically -- (void)trimDiskToSizeByDate:(NSUInteger)trimByteCount +- (void)trimDiskToSizeByEvictionStrategy:(NSUInteger)trimByteCount { if (self.isTTLCache) { [self removeExpiredObjects]; @@ -748,16 +839,34 @@ - (void)trimDiskToSizeByDate:(NSUInteger)trimByteCount [self lockForWriting]; if (_byteCount > trimByteCount) { + PINCacheEvictionStrategy strategy = self->_evictionStrategy; keysToRemove = [[NSMutableArray alloc] init]; // last modified represents last access. - NSArray *keysSortedByLastModifiedDate = [_metadata keysSortedByValueUsingComparator:^NSComparisonResult(PINDiskCacheMetadata * _Nonnull obj1, PINDiskCacheMetadata * _Nonnull obj2) { - return [obj1.lastModifiedDate compare:obj2.lastModifiedDate]; - }]; + NSArray *keysSortedByEvictionStrategy = nil; + switch (strategy) { + case PINCacheEvictionStrategyLeastRecentlyUsed: + keysSortedByEvictionStrategy = [_metadata keysSortedByValueUsingComparator:^NSComparisonResult(PINDiskCacheMetadata * _Nonnull obj1, PINDiskCacheMetadata * _Nonnull obj2) { + return [obj1.lastModifiedDate compare:obj2.lastModifiedDate]; + }]; + break; + + case PINCacheEvictionStrategyLeastFrequentlyUsed: + keysSortedByEvictionStrategy = [_metadata keysSortedByValueUsingComparator:^NSComparisonResult(PINDiskCacheMetadata * _Nonnull obj1, PINDiskCacheMetadata * _Nonnull obj2) { + if (obj1.accessCount < obj2.accessCount) { + return NSOrderedAscending; + } else if (obj1.accessCount > obj2.accessCount) { + return NSOrderedDescending; + } else { + return [obj1.lastModifiedDate compare:obj2.lastModifiedDate]; + } + }]; + break; + } NSUInteger bytesSaved = 0; // objects accessed last first. - for (NSString *key in keysSortedByLastModifiedDate) { + for (NSString *key in keysSortedByEvictionStrategy) { [keysToRemove addObject:key]; NSNumber *byteSize = _metadata[key].size; if (byteSize) { @@ -963,10 +1072,10 @@ - (void)trimToDateAsync:(NSDate *)trimDate completion:(PINCacheBlock)block completion:completion]; } -- (void)trimToSizeByDateAsync:(NSUInteger)trimByteCount completion:(PINCacheBlock)block +- (void)trimToSizeByEvictionStrategyAsync:(NSUInteger)trimByteCount completion:(PINCacheBlock)block { PINOperationBlock operation = ^(id data){ - [self trimToSizeByDate:((NSNumber *)data).unsignedIntegerValue]; + [self trimToSizeByEvictionStrategy:((NSNumber *)data).unsignedIntegerValue]; }; dispatch_block_t completion = nil; @@ -1102,6 +1211,12 @@ - (id)objectForKeyedSubscript:(NSString *)key if (object) { _metadata[key].lastModifiedDate = now; [self asynchronouslySetFileModificationDate:now forURL:fileURL]; + NSInteger accessCount = _metadata[key].accessCount; + if (accessCount < NSIntegerMax) { + accessCount += 1; + _metadata[key].accessCount = accessCount; + [self asynchronouslySetAccessCount:accessCount forURL:fileURL]; + } } } [self unlock]; @@ -1134,6 +1249,13 @@ - (NSURL *)fileURLForKey:(NSString *)key updateFileModificationDate:(BOOL)update if (updateFileModificationDate) { _metadata[key].lastModifiedDate = now; [self asynchronouslySetFileModificationDate:now forURL:fileURL]; + + NSInteger accessCount = _metadata[key].accessCount; + if (accessCount < NSIntegerMax) { + accessCount += 1; + _metadata[key].accessCount = accessCount; + [self asynchronouslySetAccessCount:accessCount forURL:fileURL]; + } } } else { fileURL = nil; @@ -1241,8 +1363,15 @@ - (void)setObject:(id )object forKey:(NSString *)key withAgeLimit:(NST self->_metadata[key].lastModifiedDate = lastModifiedDate; } [self asynchronouslySetAgeLimit:ageLimit forURL:fileURL]; + NSInteger accessCount = self->_metadata[key].accessCount; + if (accessCount < NSIntegerMax) { + accessCount += 1; + self->_metadata[key].accessCount = accessCount; + [self asynchronouslySetAccessCount:accessCount forURL:fileURL]; + } + if (self->_byteLimit > 0 && self->_byteCount > self->_byteLimit) - [self trimToSizeByDateAsync:self->_byteLimit completion:nil]; + [self trimToSizeByEvictionStrategyAsync:self->_byteLimit completion:nil]; } else { fileURL = nil; } @@ -1304,14 +1433,14 @@ - (void)trimToDate:(NSDate *)trimDate [self trimDiskToDate:trimDate]; } -- (void)trimToSizeByDate:(NSUInteger)trimByteCount +- (void)trimToSizeByEvictionStrategy:(NSUInteger)trimByteCount { if (trimByteCount == 0) { [self removeAllObjects]; return; } - [self trimDiskToSizeByDate:trimByteCount]; + [self trimDiskToSizeByEvictionStrategy:trimByteCount]; } - (void)removeExpiredObjects @@ -1527,7 +1656,7 @@ - (void)setByteLimit:(NSUInteger)byteLimit [self unlock]; if (byteLimit > 0) - [self trimDiskToSizeByDate:byteLimit]; + [self trimDiskToSizeByEvictionStrategy:byteLimit]; } withPriority:PINOperationQueuePriorityHigh]; } @@ -1713,6 +1842,16 @@ - (void)setTtlCache:(BOOL)ttlCache } withPriority:PINOperationQueuePriorityHigh]; } +- (void)trimToSizeByDate:(NSUInteger)byteCount +{ + [self trimToSizeByEvictionStrategy:byteCount]; +} + +- (void)trimToSizeByDateAsync:(NSUInteger)byteCount completion:(nullable PINCacheBlock)block +{ + [self trimToSizeByEvictionStrategyAsync:byteCount completion:block]; +} + @end @implementation PINDiskCacheMetadata diff --git a/Source/PINMemoryCache.h b/Source/PINMemoryCache.h index ef413ce4..5bb78e11 100644 --- a/Source/PINMemoryCache.h +++ b/Source/PINMemoryCache.h @@ -25,7 +25,7 @@ NS_ASSUME_NONNULL_BEGIN optional will trigger a GCD timer to periodically to trim the cache to that age. Objects can optionally be set with a "cost", which could be a byte count or any other meaningful integer. - Setting a will automatically keep the cache below that value with . + Setting a will automatically keep the cache below that value with . Values will not persist after application relaunch or returning from the background. See for a memory cache backed by a disk cache. @@ -43,7 +43,7 @@ PIN_SUBCLASSING_RESTRICTED @property (readonly) NSUInteger totalCost; /** - The maximum cost allowed to accumulate before objects begin to be removed with . + The maximum cost allowed to accumulate before objects begin to be removed with . */ @property (assign) NSUInteger costLimit; @@ -67,6 +67,11 @@ PIN_SUBCLASSING_RESTRICTED */ @property (nonatomic, readonly, getter=isTTLCache) BOOL ttlCache; +/** + The eviction strategy when trimming the cache. + */ +@property (atomic, assign) PINCacheEvictionStrategy evictionStrategy; + /** When `YES` on iOS the cache will remove all objects when the app receives a memory warning. Defaults to `YES`. @@ -150,7 +155,9 @@ PIN_SUBCLASSING_RESTRICTED - (instancetype)initWithName:(NSString *)name operationQueue:(PINOperationQueue *)operationQueue; -- (instancetype)initWithName:(NSString *)name operationQueue:(PINOperationQueue *)operationQueue ttlCache:(BOOL)ttlCache NS_DESIGNATED_INITIALIZER; +- (instancetype)initWithName:(NSString *)name operationQueue:(PINOperationQueue *)operationQueue ttlCache:(BOOL)ttlCache; + +- (instancetype)initWithName:(NSString *)name operationQueue:(PINOperationQueue *)operationQueue ttlCache:(BOOL)ttlCache evictionStrategy:(PINCacheEvictionStrategy)evictionStrategy NS_DESIGNATED_INITIALIZER; #pragma mark - Asynchronous Methods /// @name Asynchronous Methods @@ -173,7 +180,7 @@ PIN_SUBCLASSING_RESTRICTED @param cost The total accumulation allowed to remain after the cache has been trimmed. @param block A block to be executed concurrently after the cache has been trimmed, or nil. */ -- (void)trimToCostByDateAsync:(NSUInteger)cost completion:(nullable PINCacheBlock)block; +- (void)trimToCostByEvictionStrategyAsync:(NSUInteger)cost completion:(nullable PINCacheBlock)block; /** Loops through all objects in the cache with reads and writes suspended. Calling serial methods which @@ -200,10 +207,10 @@ PIN_SUBCLASSING_RESTRICTED Removes objects from the cache, ordered by date (least recently used first), until the is below the specified value. This method blocks the calling thread until the cache has been trimmed. - @see trimToCostByDateAsync: + @see trimToCostByEvictionStrategyAsync: @param cost The total accumulation allowed to remain after the cache has been trimmed. */ -- (void)trimToCostByDate:(NSUInteger)cost; +- (void)trimToCostByEvictionStrategy:(NSUInteger)cost; /** Loops through all objects in the cache within a memory lock (reads and writes are suspended during the enumeration). @@ -240,6 +247,8 @@ typedef void (^PINMemoryCacheContainmentBlock)(BOOL containsObject); - (void)removeAllObjects:(nullable PINMemoryCacheBlock)block __attribute__((deprecated)); - (void)enumerateObjectsWithBlock:(PINMemoryCacheObjectBlock)block completionBlock:(nullable PINMemoryCacheBlock)completionBlock __attribute__((deprecated)); - (void)setTtlCache:(BOOL)ttlCache DEPRECATED_MSG_ATTRIBUTE("ttlCache is no longer a settable property and must now be set via initializer."); +- (void)trimToCostByDate:(NSUInteger)cost DEPRECATED_MSG_ATTRIBUTE("Use trimToCostByEvictionStrategy: instead"); +- (void)trimToCostByDateAsync:(NSUInteger)cost completion:(nullable PINCacheBlock)block DEPRECATED_MSG_ATTRIBUTE("Use trimToCostByEvictionStrategyAsync:completion: instead."); @end NS_ASSUME_NONNULL_END diff --git a/Source/PINMemoryCache.m b/Source/PINMemoryCache.m index 2b58e449..48f017df 100644 --- a/Source/PINMemoryCache.m +++ b/Source/PINMemoryCache.m @@ -24,6 +24,7 @@ @interface PINMemoryCache () @property (strong, nonatomic) NSMutableDictionary *accessDates; @property (strong, nonatomic) NSMutableDictionary *costs; @property (strong, nonatomic) NSMutableDictionary *ageLimits; +@property (strong, nonatomic) NSMutableDictionary *accessCounts; @end @implementation PINMemoryCache @@ -68,6 +69,11 @@ - (instancetype)initWithName:(NSString *)name operationQueue:(PINOperationQueue } - (instancetype)initWithName:(NSString *)name operationQueue:(PINOperationQueue *)operationQueue ttlCache:(BOOL)ttlCache +{ + return [self initWithName:name operationQueue:operationQueue ttlCache:ttlCache evictionStrategy:PINCacheEvictionStrategyLeastRecentlyUsed]; +} + +- (instancetype)initWithName:(NSString *)name operationQueue:(PINOperationQueue *)operationQueue ttlCache:(BOOL)ttlCache evictionStrategy:(PINCacheEvictionStrategy)evictionStrategy { if (self = [super init]) { __unused int result = pthread_mutex_init(&_mutex, NULL); @@ -82,6 +88,7 @@ - (instancetype)initWithName:(NSString *)name operationQueue:(PINOperationQueue _accessDates = [[NSMutableDictionary alloc] init]; _costs = [[NSMutableDictionary alloc] init]; _ageLimits = [[NSMutableDictionary alloc] init]; + _accessCounts = [[NSMutableDictionary alloc] init]; _willAddObjectBlock = nil; _willRemoveObjectBlock = nil; @@ -97,6 +104,7 @@ - (instancetype)initWithName:(NSString *)name operationQueue:(PINOperationQueue _ageLimit = 0.0; _costLimit = 0; _totalCost = 0; + _evictionStrategy = evictionStrategy; _removeAllObjectsOnMemoryWarning = YES; _removeAllObjectsOnEnteringBackground = YES; @@ -187,6 +195,7 @@ - (void)removeObjectAndExecuteBlocksForKey:(NSString *)key [_accessDates removeObjectForKey:key]; [_costs removeObjectForKey:key]; [_ageLimits removeObjectForKey:key]; + [_accessCounts removeObjectForKey:key]; [self unlock]; if (didRemoveObjectBlock) @@ -263,7 +272,7 @@ - (void)trimToCostLimit:(NSUInteger)limit } } -- (void)trimToCostLimitByDate:(NSUInteger)limit +- (void)trimToCostLimitByEvictionStrategy:(NSUInteger)limit { if (self.isTTLCache) { [self removeExpiredObjects]; @@ -278,11 +287,34 @@ - (void)trimToCostLimitByDate:(NSUInteger)limit [self lock]; NSDictionary *accessDates = [_accessDates copy]; + NSDictionary *accessCounts = [_accessCounts copy]; + PINCacheEvictionStrategy strategy = _evictionStrategy; [self unlock]; - NSArray *keysSortedByAccessDate = [accessDates keysSortedByValueUsingSelector:@selector(compare:)]; + NSArray *keysSortedByEvictionStrategy = nil; + switch (strategy) { + case PINCacheEvictionStrategyLeastRecentlyUsed: + keysSortedByEvictionStrategy = [accessDates keysSortedByValueUsingSelector:@selector(compare:)]; + break; + + case PINCacheEvictionStrategyLeastFrequentlyUsed: + keysSortedByEvictionStrategy = [[accessCounts allKeys] sortedArrayUsingComparator:^NSComparisonResult(NSString * _Nonnull key1, NSString * _Nonnull key2) { + NSInteger count1 = [accessCounts[key1] integerValue]; + NSInteger count2 = [accessCounts[key2] integerValue]; + if (count1 < count2) { + return NSOrderedAscending; + } else if (count1 > count2) { + return NSOrderedDescending; + } else { + NSDate *date1 = accessDates[key1]; + NSDate *date2 = accessDates[key2]; + return [date1 compare:date2]; + } + }]; + break; + } - for (NSString *key in keysSortedByAccessDate) { // oldest objects first + for (NSString *key in keysSortedByEvictionStrategy) { // oldest objects first [self removeObjectAndExecuteBlocksForKey:key]; [self lock]; @@ -407,10 +439,10 @@ - (void)trimToCostAsync:(NSUInteger)cost completion:(PINCacheBlock)block } withPriority:PINOperationQueuePriorityLow]; } -- (void)trimToCostByDateAsync:(NSUInteger)cost completion:(PINCacheBlock)block +- (void)trimToCostByEvictionStrategyAsync:(NSUInteger)cost completion:(PINCacheBlock)block { [self.operationQueue scheduleOperation:^{ - [self trimToCostByDate:cost]; + [self trimToCostByEvictionStrategy:cost]; if (block) block(self); @@ -478,6 +510,10 @@ - (nullable id)objectForKey:(NSString *)key if (object) { [self lock]; _accessDates[key] = now; + NSInteger accessCount = [_accessCounts[key] integerValue]; + if (accessCount < NSIntegerMax) { + _accessCounts[key] = @(accessCount + 1); + } [self unlock]; } @@ -538,6 +574,11 @@ - (void)setObject:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost ag _dictionary[key] = object; _createdDates[key] = now; _accessDates[key] = now; + NSInteger accessCount = [_accessCounts[key] integerValue]; + if (accessCount < NSIntegerMax) { + _accessCounts[key] = @(accessCount + 1); + } + _costs[key] = @(cost); if (ageLimit > 0.0) { @@ -553,7 +594,7 @@ - (void)setObject:(id)object forKey:(NSString *)key withCost:(NSUInteger)cost ag didAddObjectBlock(self, key, object); if (costLimit > 0) - [self trimToCostByDate:costLimit]; + [self trimToCostByEvictionStrategy:costLimit]; } - (void)removeObjectForKey:(NSString *)key @@ -582,9 +623,9 @@ - (void)trimToCost:(NSUInteger)cost [self trimToCostLimit:cost]; } -- (void)trimToCostByDate:(NSUInteger)cost +- (void)trimToCostByEvictionStrategy:(NSUInteger)cost { - [self trimToCostLimitByDate:cost]; + [self trimToCostLimitByEvictionStrategy:cost]; } - (void)removeAllObjects @@ -601,6 +642,7 @@ - (void)removeAllObjects [_dictionary removeAllObjects]; [_createdDates removeAllObjects]; [_accessDates removeAllObjects]; + [_accessCounts removeAllObjects]; [_costs removeAllObjects]; [_ageLimits removeAllObjects]; @@ -798,7 +840,7 @@ - (void)setCostLimit:(NSUInteger)costLimit [self unlock]; if (costLimit > 0) - [self trimToCostLimitByDate:costLimit]; + [self trimToCostLimitByEvictionStrategy:costLimit]; } - (NSUInteger)totalCost @@ -900,7 +942,7 @@ - (void)trimToCost:(NSUInteger)cost block:(nullable PINMemoryCacheBlock)block - (void)trimToCostByDate:(NSUInteger)cost block:(nullable PINMemoryCacheBlock)block { - [self trimToCostByDateAsync:cost completion:^(id memoryCache) { + [self trimToCostByEvictionStrategyAsync:cost completion:^(id memoryCache) { if (block) { block((PINMemoryCache *)memoryCache); } @@ -937,4 +979,14 @@ - (void)setTtlCache:(BOOL)ttlCache [self unlock]; } +- (void)trimToCostByDate:(NSUInteger)cost +{ + [self trimToCostByEvictionStrategy:cost]; +} + +- (void)trimToCostByDateAsync:(NSUInteger)cost completion:(nullable PINCacheBlock)block +{ + [self trimToCostByEvictionStrategyAsync:cost completion:block]; +} + @end diff --git a/Tests/PINCacheTests.m b/Tests/PINCacheTests.m index 559cbcaa..c154a127 100644 --- a/Tests/PINCacheTests.m +++ b/Tests/PINCacheTests.m @@ -362,7 +362,7 @@ - (void)testMemoryCostOnReplace XCTAssertTrue(self.cache.memoryCache.totalCost == 1, @"cache had an unexpected total cost"); } -- (void)testMemoryCostByDate +- (void)testMemoryCostByLRU { NSString *key1 = @"key1"; NSString *key2 = @"key2"; @@ -370,7 +370,7 @@ - (void)testMemoryCostByDate [self.cache.memoryCache setObject:key1 forKey:key1 withCost:1]; [self.cache.memoryCache setObject:key2 forKey:key2 withCost:2]; - [self.cache.memoryCache trimToCostByDate:1]; + [self.cache.memoryCache trimToCostByEvictionStrategy:1]; id object1 = self.cache.memoryCache[key1]; id object2 = self.cache.memoryCache[key2]; @@ -380,6 +380,48 @@ - (void)testMemoryCostByDate XCTAssertTrue(self.cache.memoryCache.totalCost == 0, @"cache had an unexpected total cost"); } +- (void)testMemoryCostByLFU +{ + NSString *key1 = @"key1"; + NSString *key2 = @"key2"; + + self.cache.memoryCache.evictionStrategy = PINCacheEvictionStrategyLeastFrequentlyUsed; + + [self.cache.memoryCache setObject:key1 forKey:key1 withCost:1]; + [self.cache.memoryCache setObject:key2 forKey:key2 withCost:2]; + + [self.cache.memoryCache trimToCostByEvictionStrategy:1]; + + id object1 = self.cache.memoryCache[key1]; + id object2 = self.cache.memoryCache[key2]; + + XCTAssertNil(object1, @"object was not trimmed despite exceeding cost"); + XCTAssertNil(object2, @"object was not trimmed despite exceeding cost"); + XCTAssertTrue(self.cache.memoryCache.totalCost == 0, @"cache had an unexpected total cost"); +} + +- (void)testMemoryCostByLFUOnlyOneNeeded +{ + NSString *key1 = @"key1"; + NSString *key2 = @"key2"; + + self.cache.memoryCache.evictionStrategy = PINCacheEvictionStrategyLeastFrequentlyUsed; + + // Least recently used will be key1, but least frequently used will be key2 + [self.cache.memoryCache setObject:key1 forKey:key1 withCost:1]; + (void)self.cache.memoryCache[key1]; // 2nd access count + [self.cache.memoryCache setObject:key2 forKey:key2 withCost:2]; + + [self.cache.memoryCache trimToCostByEvictionStrategy:1]; + + id object1 = self.cache.memoryCache[key1]; + id object2 = self.cache.memoryCache[key2]; + + XCTAssertNotNil(object1, @"object was trimmed despite not exceeding cost"); + XCTAssertNil(object2, @"object was not trimmed despite exceeding cost"); + XCTAssertTrue(self.cache.memoryCache.totalCost == 1, @"cache had an unexpected total cost"); +} + - (void)testMemoryCostByDateWithObjectExpiration { [self.cache.memoryCache setTtlCache:YES]; @@ -408,7 +450,7 @@ - (void)testMemoryCostByDateWithObjectExpiration [NSDate startMockingDateWithDate:[NSDate dateWithTimeIntervalSinceNow:45]]; // Trim the cache enough to evict two objects. - [self.cache.memoryCache trimToCostByDate:self.cache.memoryCache.totalCost - cost * 2]; + [self.cache.memoryCache trimToCostByEvictionStrategy: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. @@ -475,7 +517,7 @@ - (void)testDiskSizeByDateWithObjectExpiration // Trim the cache enough to evict two objects. - [self.cache.diskCache trimToSizeByDate:sizeOfThreeObjects - 1]; + [self.cache.diskCache trimToSizeByEvictionStrategy: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. @@ -818,6 +860,78 @@ - (void)testByteLimit }]; } +- (void)testByteLimitWithLFU +{ + self.cache.diskCache.evictionStrategy = PINCacheEvictionStrategyLeastFrequentlyUsed; + [self.cache removeAllObjects]; + NSString *key = @"key"; + self.cache[key] = [self image]; + + // Below is the size it's actually on disk. + [self.cache.diskCache setByteLimit:983040]; + + // ensure the object is returned + XCTAssert([self.cache.diskCache objectForKey:key] != nil, @"object should be stored"); + + [self.cache.diskCache setByteLimit:1]; + + // wait for disk cache to be trimmed + sleep(2); + + XCTAssert([self.cache.diskCache objectForKey:key] == nil, @"object should be cleared"); + + // check to see if it's actually deleted + [self.cache.diskCache synchronouslyLockFileAccessWhileExecutingBlock:^(id _Nonnull cache) { + NSError *error = nil; + NSArray *contents = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:self.cache.diskCache.cacheURL + includingPropertiesForKeys:@[] + options:0 + error:&error]; + XCTAssertNil(error); + XCTAssert(contents.count == 0); + }]; +} + +- (void)testByteLimitWithLFUEvictingOne +{ + self.cache.diskCache.evictionStrategy = PINCacheEvictionStrategyLeastFrequentlyUsed; + [self.cache removeAllObjects]; + NSString *key1 = @"key1"; + NSString *key2 = @"key2"; + self.cache[key1] = [self image]; + + NSInteger imageSizeOnDisk = self.cache.diskCache.byteCount; + self.cache[key2] = [self image]; + + // Below is the size it's actually on disk. + [self.cache.diskCache setByteLimit:2 * imageSizeOnDisk]; + + // ensure the object is returned (also access key1 twice) + XCTAssert([self.cache.diskCache objectForKey:key1] != nil, @"object should be stored"); + XCTAssert([self.cache.diskCache objectForKey:key1] != nil, @"object should be stored"); + XCTAssert([self.cache.diskCache objectForKey:key2] != nil, @"object should be stored"); + + // Force one to be evicted + [self.cache.diskCache setByteLimit:imageSizeOnDisk]; + + // wait for disk cache to be trimmed + sleep(2); + + XCTAssert([self.cache.diskCache objectForKey:key1] != nil, @"object should not be cleared"); + XCTAssert([self.cache.diskCache objectForKey:key2] == nil, @"object should be cleared"); + + // check to see if it's actually deleted + [self.cache.diskCache synchronouslyLockFileAccessWhileExecutingBlock:^(id _Nonnull cache) { + NSError *error = nil; + NSArray *contents = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:self.cache.diskCache.cacheURL + includingPropertiesForKeys:@[] + options:0 + error:&error]; + XCTAssertNil(error); + XCTAssert(contents.count == 1); + }]; +} + - (void)testDiskReadingAfterCacheInit { NSString *cacheName = @"testDiskReadingAfterCacheInit"; @@ -1388,7 +1502,7 @@ - (void)testCustomEncoderDecoder { } - (void)testTTLCacheIsSet { - PINCache *cache = [[PINCache alloc] initWithName:@"test" rootPath:PINDiskCachePrefix serializer:nil deserializer:nil keyEncoder:nil keyDecoder:nil ttlCache:YES]; + PINCache *cache = [[PINCache alloc] initWithName:@"test" rootPath:PINDiskCachePrefix serializer:nil deserializer:nil keyEncoder:nil keyDecoder:nil ttlCache:YES evictionStrategy:PINCacheEvictionStrategyLeastRecentlyUsed]; XCTAssert(cache.diskCache.isTTLCache); XCTAssert(cache.memoryCache.isTTLCache); }