Sources/SPTPersistentCache.m (855 lines of code) (raw):
/*
Copyright (c) 2015-2021 Spotify AB.
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
*/
#import "SPTPersistentCache+Private.h"
#import <SPTPersistentCache/SPTPersistentCacheImplementation.h>
#import <SPTPersistentCache/SPTPersistentCacheResponse.h>
#import <SPTPersistentCache/SPTPersistentCacheOptions.h>
#import <SPTPersistentCache/SPTPersistentCacheHeader.h>
#import "SPTPersistentCacheRecord+Private.h"
#import "SPTPersistentCacheResponse+Private.h"
#import "SPTPersistentCacheGarbageCollector.h"
#import "NSError+SPTPersistentCacheDomainErrors.h"
#import "SPTPersistentCacheFileManager.h"
#import "SPTPersistentCacheTypeUtilities.h"
#import "SPTPersistentCacheDebugUtilities.h"
#import "SPTPersistentCachePosixWrapper.h"
#include <sys/stat.h>
#import <mach/mach_time.h>
#include "crc32iso3309.h"
// Enable for more precise logging
//#define DEBUG_OUTPUT_ENABLED
typedef SPTPersistentCacheResponse* (^SPTPersistentCacheFileProcessingBlockType)(int filedes);
typedef void (^SPTPersistentCacheRecordHeaderGetCallbackType)(SPTPersistentCacheRecordHeader *header);
NSString *const SPTPersistentCacheErrorDomain = @"persistent.cache.error";
static NSString * const SPTDataCacheFileNameKey = @"SPTDataCacheFileNameKey";
static NSString * const SPTDataCacheFileAttributesKey = @"SPTDataCacheFileAttributesKey";
static const uint64_t SPTPersistentCacheTTLUpperBoundInSec = 86400 * 31 * 2;
void SPTPersistentCacheSafeDispatch(_Nullable dispatch_queue_t queue, _Nonnull dispatch_block_t block)
{
const dispatch_queue_t dispatchQueue = queue ?: dispatch_get_main_queue();
if (dispatchQueue == dispatch_get_main_queue() && [NSThread isMainThread]) {
block();
} else {
dispatch_async(dispatchQueue, block);
}
}
@interface SPTPersistentCacheFileInfo : NSObject
@property (nonatomic, strong, readonly) NSString *fileName;
@property (nonatomic, strong, readonly) NSDate *mdate;
@property (nonatomic, assign, readonly) off_t fileSize;
- (instancetype)initWithFileName:(NSString *)fileName mdate:(NSDate *)mdate fileSize:(off_t)fileSize;
@end
// Class extension exists in SPTPersistentCache+Private.h
#pragma mark - SPTPersistentCache
@implementation SPTPersistentCache
- (instancetype)init
{
return [self initWithOptions:[SPTPersistentCacheOptions new]];
}
- (instancetype)initWithOptions:(SPTPersistentCacheOptions *)options
{
self = [super init];
if (self) {
_workQueue = [[NSOperationQueue alloc] init];
_workQueue.name = options.identifierForQueue;
_workQueue.maxConcurrentOperationCount = options.maxConcurrentOperations;
NSAssert(_workQueue, @"The work queue couldn’t be created using the given options: %@", options);
_options = [options copy];
_fileManager = [NSFileManager defaultManager];
_debugOutput = [self.options.debugOutput copy];
_dataCacheFileManager = [[SPTPersistentCacheFileManager alloc] initWithOptions:_options];
_posixWrapper = [SPTPersistentCachePosixWrapper new];
_garbageCollector = [[SPTPersistentCacheGarbageCollector alloc] initWithCache:self
options:_options
queue:_workQueue];
if (![_dataCacheFileManager createCacheDirectory]) {
return nil;
}
}
return self;
}
- (BOOL)loadDataForKey:(NSString *)key
withCallback:(SPTPersistentCacheResponseCallback _Nullable)callback
onQueue:(dispatch_queue_t _Nullable)queue
{
if (callback == nil || queue == nil) {
return NO;
}
callback = [callback copy];
[self logTimingForKey:key method:SPTPersistentCacheDebugMethodTypeRead type:SPTPersistentCacheDebugTimingTypeQueued];
[self doWork:^{
[self logTimingForKey:key method:SPTPersistentCacheDebugMethodTypeRead type:SPTPersistentCacheDebugTimingTypeStarting];
[self loadDataForKeySync:key withCallback:callback onQueue:queue];
[self logTimingForKey:key method:SPTPersistentCacheDebugMethodTypeRead type:SPTPersistentCacheDebugTimingTypeFinished];
} priority:self.options.readPriority qos:self.options.readQualityOfService];
return YES;
}
- (BOOL)loadDataForKeysWithPrefix:(NSString *)prefix
chooseKeyCallback:(SPTPersistentCacheChooseKeyCallback _Nullable)chooseKeyCallback
withCallback:(SPTPersistentCacheResponseCallback _Nullable)callback
onQueue:(dispatch_queue_t _Nullable)queue
{
if (callback == nil || queue == nil || chooseKeyCallback == nil) {
return NO;
}
[self logTimingForKey:prefix method:SPTPersistentCacheDebugMethodTypeRead type:SPTPersistentCacheDebugTimingTypeQueued];
[self doWork:^{
[self logTimingForKey:prefix method:SPTPersistentCacheDebugMethodTypeRead type:SPTPersistentCacheDebugTimingTypeStarting];
NSString *path = [self.dataCacheFileManager subDirectoryPathForKey:prefix];
NSMutableArray * __block keys = [NSMutableArray array];
// WARNING: Do not use enumeratorAtURL never ever. Its unsafe bcuz gets locked forever
NSError *error = nil;
NSArray *content = [self.fileManager contentsOfDirectoryAtPath:path error:&error];
if (content == nil) {
// If no directory is exist its fine, say not found to user
if (error.code == NSFileReadNoSuchFileError || error.code == NSFileNoSuchFileError) {
[self dispatchEmptyResponseWithResult:SPTPersistentCacheResponseCodeNotFound
callback:callback
onQueue:queue];
} else {
[self debugOutput:@"PersistentDataCache: Unable to get dir contents: %@, error: %@", path, [error localizedDescription]];
[self dispatchError:error
result:SPTPersistentCacheResponseCodeOperationError
callback:callback
onQueue:queue];
}
return;
}
[content enumerateObjectsUsingBlock:^(NSString *key, NSUInteger idx, BOOL *stop) {
NSString *file = key;
if ([file hasPrefix:prefix]) {
[keys addObject:file];
}
}];
NSMutableArray * __block keysToConsider = [NSMutableArray array];
// Validate keys for expiration before giving it back to caller. Its important since giving expired keys
// is wrong since caller can miss data that are no expired by picking expired key.
for (NSString *key in keys) {
NSString *filePath = [self.dataCacheFileManager pathForKey:key];
// WARNING: We may skip return result here bcuz in that case we will skip the key as invalid
[self alterHeaderForFileAtPath:filePath withBlock:^(SPTPersistentCacheRecordHeader *header) {
// Satisfy Req.#1.2
if ([self isDataCanBeReturnedWithHeader:header]) {
[keysToConsider addObject:key];
}
} writeBack:NO complain:YES];
}
// If not keys left after validation we are done with not found callback
if (keysToConsider.count == 0) {
[self dispatchEmptyResponseWithResult:SPTPersistentCacheResponseCodeNotFound
callback:callback
onQueue:queue];
return;
}
NSString *keyToOpen = chooseKeyCallback(keysToConsider);
// If user told us 'nil' he didnt found abything interesting in keys so we are done wiht not found
if (keyToOpen == nil) {
[self dispatchEmptyResponseWithResult:SPTPersistentCacheResponseCodeNotFound
callback:callback
onQueue:queue];
return;
}
[self loadDataForKeySync:keyToOpen withCallback:callback onQueue:queue];
[self logTimingForKey:prefix method:SPTPersistentCacheDebugMethodTypeRead type:SPTPersistentCacheDebugTimingTypeFinished];
} priority:self.options.readPriority qos:self.options.readQualityOfService];
return YES;
}
- (BOOL)storeData:(NSData *)data
forKey:(NSString *)key
locked:(BOOL)locked
withCallback:(SPTPersistentCacheResponseCallback _Nullable)callback
onQueue:(dispatch_queue_t _Nullable)queue
{
return [self storeData:data forKey:key ttl:0 locked:locked withCallback:callback onQueue:queue];
}
- (BOOL)storeData:(NSData *)data
forKey:(NSString *)key
ttl:(NSUInteger)ttl
locked:(BOOL)locked
withCallback:(SPTPersistentCacheResponseCallback _Nullable)callback
onQueue:(dispatch_queue_t _Nullable)queue
{
if (data == nil || key == nil || (callback != nil && queue == nil)) {
return NO;
}
callback = [callback copy];
[self logTimingForKey:key method:SPTPersistentCacheDebugMethodTypeStore type:SPTPersistentCacheDebugTimingTypeQueued];
[self doWork:^{
[self logTimingForKey:key method:SPTPersistentCacheDebugMethodTypeStore type:SPTPersistentCacheDebugTimingTypeStarting];
[self storeDataSync:data forKey:key ttl:ttl locked:locked withCallback:callback onQueue:queue];
[self logTimingForKey:key method:SPTPersistentCacheDebugMethodTypeStore type:SPTPersistentCacheDebugTimingTypeFinished];
} priority:self.options.writePriority qos:self.options.writeQualityOfService];
return YES;
}
// TODO: return NOT_PERMITTED on try to touch TLL>0
- (void)touchDataForKey:(NSString *)key
callback:(SPTPersistentCacheResponseCallback _Nullable)callback
onQueue:(dispatch_queue_t _Nullable)queue
{
if (callback != nil) {
NSAssert(queue, @"You must specify the queue");
}
[self logTimingForKey:key method:SPTPersistentCacheDebugMethodTypeStore type:SPTPersistentCacheDebugTimingTypeQueued];
[self doWork:^{
[self logTimingForKey:key method:SPTPersistentCacheDebugMethodTypeStore type:SPTPersistentCacheDebugTimingTypeStarting];
NSString *filePath = [self.dataCacheFileManager pathForKey:key];
BOOL __block expired = NO;
SPTPersistentCacheResponse *response = [self alterHeaderForFileAtPath:filePath
withBlock:^(SPTPersistentCacheRecordHeader *header) {
// Satisfy Req.#1.2 and Req.#1.3
if (![self isDataCanBeReturnedWithHeader:header]) {
expired = YES;
return;
}
// Touch files that have default expiration policy
if (header->ttl == 0) {
header->updateTimeSec = spt_uint64rint(self.currentDateTimeInterval);
}
}
writeBack:YES
complain:NO];
// Satisfy Req.#1.2
if (expired) {
response = [[SPTPersistentCacheResponse alloc] initWithResult:SPTPersistentCacheResponseCodeNotFound
error:nil
record:nil];
}
if (callback) {
SPTPersistentCacheSafeDispatch(queue, ^{
callback(response);
});
}
[self logTimingForKey:key method:SPTPersistentCacheDebugMethodTypeStore type:SPTPersistentCacheDebugTimingTypeFinished];
} priority:self.options.writePriority qos:self.options.writeQualityOfService];
}
- (void)removeDataForKeysSync:(NSArray<NSString *> *)keys
{
for (NSString *key in keys) {
[self.dataCacheFileManager removeDataForKey:key];
}
}
- (void)removeDataForKeys:(NSArray<NSString *> *)keys
callback:(SPTPersistentCacheResponseCallback _Nullable)callback
onQueue:(dispatch_queue_t _Nullable)queue
{
[self logTimingForKey:[keys description] method:SPTPersistentCacheDebugMethodTypeRemove type:SPTPersistentCacheDebugTimingTypeQueued];
[self doWork:^{
[self logTimingForKey:[keys description] method:SPTPersistentCacheDebugMethodTypeRemove type:SPTPersistentCacheDebugTimingTypeStarting];
[self removeDataForKeysSync:keys];
if (callback) {
SPTPersistentCacheResponse *response = [[SPTPersistentCacheResponse alloc] initWithResult:SPTPersistentCacheResponseCodeOperationSucceeded
error:nil
record:nil];
SPTPersistentCacheSafeDispatch(queue, ^{
callback(response);
});
}
[self logTimingForKey:[keys description] method:SPTPersistentCacheDebugMethodTypeRemove type:SPTPersistentCacheDebugTimingTypeFinished];
} priority:self.options.deletePriority qos:self.options.deleteQualityOfService];
}
- (BOOL)lockDataForKeys:(NSArray<NSString *> *)keys
callback:(SPTPersistentCacheResponseCallback _Nullable)callback
onQueue:(dispatch_queue_t _Nullable)queue
{
if ((callback != nil && queue == nil) || keys.count == 0) {
return NO;
}
[self logTimingForKey:[keys description] method:SPTPersistentCacheDebugMethodTypeLock type:SPTPersistentCacheDebugTimingTypeQueued];
[self doWork:^{
[self logTimingForKey:[keys description] method:SPTPersistentCacheDebugMethodTypeLock type:SPTPersistentCacheDebugTimingTypeStarting];
for (NSString *key in keys) {
NSString *filePath = [self.dataCacheFileManager pathForKey:key];
BOOL __block expired = NO;
SPTPersistentCacheResponse *response = [self alterHeaderForFileAtPath:filePath
withBlock:^(SPTPersistentCacheRecordHeader *header) {
// Satisfy Req.#1.2
if ([self isDataExpiredWithHeader:header]) {
expired = YES;
return;
}
++header->refCount;
// Do not update access time since file is locked
}
writeBack:YES
complain:YES];
// Satisfy Req.#1.2
if (expired) {
response = [[SPTPersistentCacheResponse alloc] initWithResult:SPTPersistentCacheResponseCodeNotFound
error:nil
record:nil];
}
if (callback) {
SPTPersistentCacheSafeDispatch(queue, ^{
callback(response);
});
}
} // for
[self logTimingForKey:[keys description] method:SPTPersistentCacheDebugMethodTypeLock type:SPTPersistentCacheDebugTimingTypeFinished];
} priority:self.options.writePriority qos:self.options.writeQualityOfService];
return YES;
}
- (BOOL)unlockDataForKeys:(NSArray<NSString *> *)keys
callback:(SPTPersistentCacheResponseCallback _Nullable)callback
onQueue:(dispatch_queue_t _Nullable)queue
{
if ((callback != nil && queue == nil) || keys.count == 0) {
return NO;
}
[self logTimingForKey:[keys description] method:SPTPersistentCacheDebugMethodTypeUnlock type:SPTPersistentCacheDebugTimingTypeQueued];
[self doWork:^{
[self logTimingForKey:[keys description] method:SPTPersistentCacheDebugMethodTypeUnlock type:SPTPersistentCacheDebugTimingTypeStarting];
for (NSString *key in keys) {
NSString *filePath = [self.dataCacheFileManager pathForKey:key];
SPTPersistentCacheResponse *response = [self alterHeaderForFileAtPath:filePath
withBlock:^(SPTPersistentCacheRecordHeader *header){
if (header->refCount > 0) {
--header->refCount;
} else {
[self debugOutput:@"PersistentDataCache: Error trying to decrement refCount below 0 for file at path:%@", filePath];
}
}
writeBack:YES
complain:YES];
if (callback) {
SPTPersistentCacheSafeDispatch(queue, ^{
callback(response);
});
}
} // for
[self logTimingForKey:[keys description] method:SPTPersistentCacheDebugMethodTypeUnlock type:SPTPersistentCacheDebugTimingTypeFinished];
} priority:self.options.deletePriority qos:self.options.deleteQualityOfService];
return YES;
}
- (void)scheduleGarbageCollector
{
[self.garbageCollector schedule];
}
- (void)unscheduleGarbageCollector
{
[self.garbageCollector unschedule];
}
- (void)pruneWithCallback:(SPTPersistentCacheResponseCallback _Nullable)callback
onQueue:(dispatch_queue_t _Nullable)queue
{
[self logTimingForKey:@"prune" method:SPTPersistentCacheDebugMethodTypeRemove type:SPTPersistentCacheDebugTimingTypeQueued];
[self doWork:^{
[self logTimingForKey:@"prune" method:SPTPersistentCacheDebugMethodTypeRemove type:SPTPersistentCacheDebugTimingTypeStarting];
[self.dataCacheFileManager removeAllData];
if (callback) {
SPTPersistentCacheResponse *response = [[SPTPersistentCacheResponse alloc] initWithResult:SPTPersistentCacheResponseCodeOperationSucceeded
error:nil
record:nil];
SPTPersistentCacheSafeDispatch(queue, ^{
callback(response);
});
}
[self logTimingForKey:@"prune" method:SPTPersistentCacheDebugMethodTypeRemove type:SPTPersistentCacheDebugTimingTypeFinished];
} priority:self.options.deletePriority qos:self.options.deleteQualityOfService];
}
- (void)wipeLockedFilesWithCallback:(SPTPersistentCacheResponseCallback _Nullable)callback
onQueue:(dispatch_queue_t _Nullable)queue
{
[self logTimingForKey:@"wipeLocked" method:SPTPersistentCacheDebugMethodTypeRemove type:SPTPersistentCacheDebugTimingTypeQueued];
[self doWork:^{
[self logTimingForKey:@"wipeLocked" method:SPTPersistentCacheDebugMethodTypeRemove type:SPTPersistentCacheDebugTimingTypeStarting];
[self collectGarbageForceExpire:NO forceLocked:YES];
if (callback) {
SPTPersistentCacheResponse *response = [[SPTPersistentCacheResponse alloc] initWithResult:SPTPersistentCacheResponseCodeOperationSucceeded
error:nil
record:nil];
SPTPersistentCacheSafeDispatch(queue, ^{
callback(response);
});
}
[self logTimingForKey:@"wipeLocked" method:SPTPersistentCacheDebugMethodTypeRemove type:SPTPersistentCacheDebugTimingTypeFinished];
} priority:self.options.deletePriority qos:self.options.deleteQualityOfService];
}
- (void)wipeNonLockedFilesWithCallback:(SPTPersistentCacheResponseCallback _Nullable)callback
onQueue:(dispatch_queue_t _Nullable)queue
{
[self logTimingForKey:@"wipeNonLocked" method:SPTPersistentCacheDebugMethodTypeRemove type:SPTPersistentCacheDebugTimingTypeQueued];
[self doWork:^{
[self logTimingForKey:@"wipeNonLocked" method:SPTPersistentCacheDebugMethodTypeRemove type:SPTPersistentCacheDebugTimingTypeStarting];
[self collectGarbageForceExpire:YES forceLocked:NO];
if (callback) {
SPTPersistentCacheResponse *response = [[SPTPersistentCacheResponse alloc] initWithResult:SPTPersistentCacheResponseCodeOperationSucceeded
error:nil
record:nil];
SPTPersistentCacheSafeDispatch(queue, ^{
callback(response);
});
}
[self logTimingForKey:@"wipeNonLocked" method:SPTPersistentCacheDebugMethodTypeRemove type:SPTPersistentCacheDebugTimingTypeFinished];
} priority:self.options.deletePriority qos:self.options.deleteQualityOfService];
}
- (NSUInteger)totalUsedSizeInBytes
{
return self.dataCacheFileManager.totalUsedSizeInBytes;
}
- (NSUInteger)lockedItemsSizeInBytes
{
NSUInteger size = 0;
NSURL *urlPath = [NSURL fileURLWithPath:self.options.cachePath];
NSDirectoryEnumerator *dirEnumerator = [self.fileManager enumeratorAtURL:urlPath
includingPropertiesForKeys:@[NSURLIsDirectoryKey]
options:NSDirectoryEnumerationSkipsHiddenFiles
errorHandler:nil];
// Enumerate the dirEnumerator results, each value is stored in allURLs
NSURL *theURL = nil;
while ((theURL = [dirEnumerator nextObject])) {
// Retrieve the file name. From cached during the enumeration.
NSNumber *isDirectory;
NSError *error = nil;
if ([theURL getResourceValue:&isDirectory forKey:NSURLIsDirectoryKey error:&error]) {
if ([isDirectory boolValue] == NO) {
NSString *key = theURL.lastPathComponent;
// That satisfies Req.#1.3
NSString *filePath = [self.dataCacheFileManager pathForKey:key];
BOOL __block locked = NO;
// WARNING: We may skip return result here bcuz in that case we will not count file as locked
[self alterHeaderForFileAtPath:filePath withBlock:^(SPTPersistentCacheRecordHeader *header) {
locked = header->refCount > 0;
} writeBack:NO complain:YES];
if (locked) {
size += [self.dataCacheFileManager getFileSizeAtPath:filePath];
}
}
} else {
[self debugOutput:@"Unable to fetch isDir#3 attribute:%@ error: %@", theURL, error];
}
}
return size;
}
- (void)dealloc
{
[_garbageCollector unschedule];
}
/**
Load method used internally to load data. Called on work queue.
*/
- (void)loadDataForKeySync:(NSString *)key
withCallback:(SPTPersistentCacheResponseCallback)callback
onQueue:(dispatch_queue_t)queue
{
NSString *filePath = [self.dataCacheFileManager pathForKey:key];
// File not exist -> inform user
if (![self.fileManager fileExistsAtPath:filePath]) {
[self dispatchEmptyResponseWithResult:SPTPersistentCacheResponseCodeNotFound callback:callback onQueue:queue];
return;
} else {
// File exist
NSError *error = nil;
NSMutableData *rawData = [NSMutableData dataWithContentsOfFile:filePath
options:NSDataReadingMappedIfSafe
error:&error];
if (rawData == nil) {
// File read with error -> inform user
[self dispatchError:error
result:SPTPersistentCacheResponseCodeOperationError
callback:callback
onQueue:queue];
} else {
SPTPersistentCacheRecordHeader *header = SPTPersistentCacheGetHeaderFromData(rawData.mutableBytes, rawData.length);
// If not enough data to cast to header, its not the file we can process
if (header == NULL) {
NSError *headerError = [NSError spt_persistentDataCacheErrorWithCode:SPTPersistentCacheLoadingErrorNotEnoughDataToGetHeader];
[self dispatchError:headerError
result:SPTPersistentCacheResponseCodeOperationError
callback:callback
onQueue:queue];
return;
}
SPTPersistentCacheRecordHeader localHeader;
memcpy(&localHeader, header, sizeof(localHeader));
// Check header is valid
NSError *headerError = SPTPersistentCacheCheckValidHeader(&localHeader);
if (headerError != nil) {
[self dispatchError:headerError
result:SPTPersistentCacheResponseCodeOperationError
callback:callback
onQueue:queue];
return;
}
const NSUInteger refCount = localHeader.refCount;
// We return locked files even if they expired, GC doesnt collect them too so they valuable to user
// Satisfy Req.#1.2
if (![self isDataCanBeReturnedWithHeader:&localHeader]) {
#ifdef DEBUG_OUTPUT_ENABLED
[self debugOutput:@"PersistentDataCache: Record with key: %@ expired, t:%llu, TTL:%llu", key, localHeader.updateTimeSec, localHeader.ttl];
#endif
[self dispatchEmptyResponseWithResult:SPTPersistentCacheResponseCodeNotFound
callback:callback
onQueue:queue];
return;
}
// Check that payload is correct size
if (localHeader.payloadSizeBytes != [rawData length] - SPTPersistentCacheRecordHeaderSize) {
[self debugOutput:@"PersistentDataCache: Error: Wrong payload size for key:%@ , will return error", key];
[self dispatchError:[NSError spt_persistentDataCacheErrorWithCode:SPTPersistentCacheLoadingErrorWrongPayloadSize]
result:SPTPersistentCacheResponseCodeOperationError
callback:callback onQueue:queue];
return;
}
NSRange payloadRange = NSMakeRange(SPTPersistentCacheRecordHeaderSize, (NSUInteger)localHeader.payloadSizeBytes);
NSData *payload = [rawData subdataWithRange:payloadRange];
const NSUInteger ttl = (NSUInteger)localHeader.ttl;
SPTPersistentCacheRecord *record = [[SPTPersistentCacheRecord alloc] initWithData:payload
key:key
refCount:refCount
ttl:ttl];
SPTPersistentCacheResponse *response = [[SPTPersistentCacheResponse alloc] initWithResult:SPTPersistentCacheResponseCodeOperationSucceeded
error:nil
record:record];
// If data ttl == 0 we update access time
if (ttl == 0) {
localHeader.updateTimeSec = spt_uint64rint(self.currentDateTimeInterval);
localHeader.crc = SPTPersistentCacheCalculateHeaderCRC(&localHeader);
memcpy(header, &localHeader, sizeof(localHeader));
// Write back with updated access attributes
NSError *werror = nil;
if (![rawData writeToFile:filePath options:NSDataWritingAtomic error:&werror]) {
[self debugOutput:@"PersistentDataCache: Error writing back record:%@, error:%@", filePath.lastPathComponent, werror];
} else {
#ifdef DEBUG_OUTPUT_ENABLED
[self debugOutput:@"PersistentDataCache: Writing back record:%@ OK", filePath.lastPathComponent];
#endif
}
}
// Callback only after we finished everyhing to avoid situation when user gets notified and we are still writting
SPTPersistentCacheSafeDispatch(queue, ^{
callback(response);
});
} // if rawData
} // file exist
}
/**
Store method used internaly. Called on work queue.
*/
- (NSError *)storeDataSync:(NSData *)data
forKey:(NSString *)key
ttl:(NSUInteger)ttl
locked:(BOOL)isLocked
withCallback:(SPTPersistentCacheResponseCallback)callback
onQueue:(dispatch_queue_t)queue
{
NSString *filePath = [self.dataCacheFileManager pathForKey:key];
NSString *subDir = [self.dataCacheFileManager subDirectoryPathForKey:key];
[self.fileManager createDirectoryAtPath:subDir withIntermediateDirectories:YES attributes:nil error:nil];
const NSUInteger payloadLength = [data length];
const NSUInteger rawDataLength = SPTPersistentCacheRecordHeaderSize + payloadLength;
NSMutableData *rawData = [NSMutableData dataWithCapacity:rawDataLength];
SPTPersistentCacheRecordHeader header = SPTPersistentCacheRecordHeaderMake(ttl,
payloadLength,
spt_uint64rint(self.currentDateTimeInterval),
isLocked);
[rawData appendBytes:&header length:SPTPersistentCacheRecordHeaderSize];
[rawData appendData:data];
NSError *error = nil;
if (![rawData writeToFile:filePath options:NSDataWritingAtomic error:&error]) {
[self debugOutput:@"PersistentDataCache: Error writting to file:%@ , for key:%@. Removing it...", filePath, key];
[self removeDataForKeysSync:@[key]];
[self dispatchError:error result:SPTPersistentCacheResponseCodeOperationError callback:callback onQueue:queue];
} else {
if (callback != nil) {
SPTPersistentCacheResponse *response = [[SPTPersistentCacheResponse alloc] initWithResult:SPTPersistentCacheResponseCodeOperationSucceeded
error:nil
record:nil];
SPTPersistentCacheSafeDispatch(queue, ^{
callback(response);
});
}
}
return error;
}
/**
Method to work safely with opened file referenced by file descriptor.
Method handles file closing properly in case of errors.
Descriptor is passed to a jobBlock for further usage.
*/
- (SPTPersistentCacheResponse *)guardOpenFileWithPath:(NSString *)filePath
jobBlock:(SPTPersistentCacheFileProcessingBlockType)jobBlock
complain:(BOOL)needComplains
writeBack:(BOOL)writeBack
{
if (![self.fileManager fileExistsAtPath:filePath]) {
if (needComplains) {
[self debugOutput:@"PersistentDataCache: Record not exist at path:%@", filePath];
}
return [[SPTPersistentCacheResponse alloc] initWithResult:SPTPersistentCacheResponseCodeNotFound error:nil record:nil];
} else {
const int SPTPersistentCacheInvalidResult = -1;
const int flags = (writeBack ? O_RDWR : O_RDONLY);
int fd = open([filePath UTF8String], flags);
if (fd == SPTPersistentCacheInvalidResult) {
const int errorNumber = errno;
NSString *errorDescription = @(strerror(errorNumber));
[self debugOutput:@"PersistentDataCache: Error opening file:%@ , error:%@", filePath, errorDescription];
NSError *error = [NSError errorWithDomain:NSPOSIXErrorDomain
code:errorNumber
userInfo:@{ NSLocalizedDescriptionKey: errorDescription }];
return [[SPTPersistentCacheResponse alloc] initWithResult:SPTPersistentCacheResponseCodeOperationError
error:error
record:nil];
}
SPTPersistentCacheResponse *response = jobBlock(fd);
fd = [self.posixWrapper close:fd];
if (fd == SPTPersistentCacheInvalidResult) {
const int errorNumber = errno;
NSString *errorDescription = @(strerror(errorNumber));
[self debugOutput:@"PersistentDataCache: Error closing file:%@ , error:%@", filePath, errorDescription];
NSError *error = [NSError errorWithDomain:NSPOSIXErrorDomain
code:errorNumber
userInfo:@{ NSLocalizedDescriptionKey: errorDescription }];
return [[SPTPersistentCacheResponse alloc] initWithResult:SPTPersistentCacheResponseCodeOperationError
error:error
record:nil];
}
return response;
}
}
/**
Method used to read/write file header.
*/
- (SPTPersistentCacheResponse *)alterHeaderForFileAtPath:(NSString *)filePath
withBlock:(SPTPersistentCacheRecordHeaderGetCallbackType)modifyBlock
writeBack:(BOOL)needWriteBack
complain:(BOOL)needComplains
{
return [self guardOpenFileWithPath:filePath jobBlock:^SPTPersistentCacheResponse*(int filedes) {
SPTPersistentCacheRecordHeader header;
ssize_t readBytes = [self.posixWrapper read:filedes
buffer:&header
bufferSize:SPTPersistentCacheRecordHeaderSize];
if (readBytes != (ssize_t)SPTPersistentCacheRecordHeaderSize) {
NSError *error = [NSError spt_persistentDataCacheErrorWithCode:SPTPersistentCacheLoadingErrorNotEnoughDataToGetHeader];
if (readBytes == -1) {
const int errorNumber = errno;
NSString *errorDescription = @(strerror(errorNumber));
error = [NSError errorWithDomain:NSPOSIXErrorDomain
code:errorNumber
userInfo:@{ NSLocalizedDescriptionKey: errorDescription }];
}
[self debugOutput:@"PersistentDataCache: Error not enough data to read the header of file path:%@ , error:%@",
filePath, [error localizedDescription]];
return [[SPTPersistentCacheResponse alloc] initWithResult:SPTPersistentCacheResponseCodeOperationError
error:error
record:nil];
}
NSError *nsError = SPTPersistentCacheCheckValidHeader(&header);
if (nsError != nil) {
[self debugOutput:@"PersistentDataCache: Error checking header at file path:%@ , error:%@", filePath, nsError];
return [[SPTPersistentCacheResponse alloc] initWithResult:SPTPersistentCacheResponseCodeOperationError
error:nsError
record:nil];
}
modifyBlock(&header);
if (needWriteBack) {
uint32_t oldCRC = header.crc;
header.crc = SPTPersistentCacheCalculateHeaderCRC(&header);
// If nothing has changed we do nothing then
if (oldCRC == header.crc) {
return [[SPTPersistentCacheResponse alloc] initWithResult:SPTPersistentCacheResponseCodeOperationSucceeded
error:nil
record:nil];
}
// Set file pointer to the beginning of the file
off_t seekOffset = [self.posixWrapper lseek:filedes seekType:SEEK_SET seekAmount:0];
if (seekOffset != 0) {
const int errorNumber = errno;
NSString *errorDescription = @(strerror(errorNumber));
[self debugOutput:@"PersistentDataCache: Error seeking to begin of file path:%@ , error:%@", filePath, errorDescription];
NSError *error = [NSError errorWithDomain:NSPOSIXErrorDomain
code:errorNumber
userInfo:@{ NSLocalizedDescriptionKey: errorDescription }];
return [[SPTPersistentCacheResponse alloc] initWithResult:SPTPersistentCacheResponseCodeOperationError
error:error
record:nil];
} else {
ssize_t writtenBytes = [self.posixWrapper write:filedes
buffer:&header
bufferSize:SPTPersistentCacheRecordHeaderSize];
if (writtenBytes != (ssize_t)SPTPersistentCacheRecordHeaderSize) {
const int errorNumber = errno;
NSString *errorDescription = @(strerror(errorNumber));
[self debugOutput:@"PersistentDataCache: Error writting header at file path:%@ , error:%@", filePath, errorDescription];
NSError *error = [NSError errorWithDomain:NSPOSIXErrorDomain
code:errorNumber
userInfo:@{ NSLocalizedDescriptionKey: errorDescription }];
return [[SPTPersistentCacheResponse alloc] initWithResult:SPTPersistentCacheResponseCodeOperationError
error:error
record:nil];
} else {
int result = [self.posixWrapper fsync:filedes];
if (result == -1) {
const int errorNumber = errno;
NSString *errorDescription = @(strerror(errorNumber));
[self debugOutput:@"PersistentDataCache: Error flushing file:%@ , error:%@", filePath, errorDescription];
NSError *error = [NSError errorWithDomain:NSPOSIXErrorDomain
code:errorNumber
userInfo:@{ NSLocalizedDescriptionKey: errorDescription }];
return [[SPTPersistentCacheResponse alloc] initWithResult:SPTPersistentCacheResponseCodeOperationError
error:error
record:nil];
}
}
}
}
return [[SPTPersistentCacheResponse alloc] initWithResult:SPTPersistentCacheResponseCodeOperationSucceeded
error:nil
record:nil];
} complain:needComplains writeBack:needWriteBack];
}
/**
Only this method check data expiration. Past check is also supported.
*/
- (BOOL)isDataExpiredWithHeader:(SPTPersistentCacheRecordHeader *)header
{
assert(header != nil);
uint64_t ttl = header->ttl;
uint64_t current = spt_uint64rint(self.currentDateTimeInterval);
int64_t threshold = (int64_t)((ttl > 0) ? ttl : self.options.defaultExpirationPeriod);
if (ttl > SPTPersistentCacheTTLUpperBoundInSec) {
[self debugOutput:@"PersistentDataCache: WARNING: TTL seems too big: %llu > %llu sec", ttl, SPTPersistentCacheTTLUpperBoundInSec];
}
return (int64_t)(current - header->updateTimeSec) > threshold;
}
/**
Methos checks whether data can be given to caller with accordance to API.
*/
- (BOOL)isDataCanBeReturnedWithHeader:(SPTPersistentCacheRecordHeader *)header
{
return !([self isDataExpiredWithHeader:header] && header->refCount == 0);
}
- (void)runRegularGC
{
[self collectGarbageForceExpire:NO forceLocked:NO];
}
- (void)collectGarbageForceExpire:(BOOL)forceExpire forceLocked:(BOOL)forceLocked
{
[self debugOutput:@"PersistentDataCache: Run GC with forceExpire:%d forceLock:%d", forceExpire, forceLocked];
NSURL *urlPath = [NSURL fileURLWithPath:self.options.cachePath];
NSDirectoryEnumerator *dirEnumerator = [self.fileManager enumeratorAtURL:urlPath
includingPropertiesForKeys:@[NSURLIsDirectoryKey]
options:NSDirectoryEnumerationSkipsHiddenFiles
errorHandler:nil];
// Enumerate the dirEnumerator results, each value is stored in allURLs
NSURL *theURL = nil;
while ((theURL = [dirEnumerator nextObject])) {
// Retrieve the file name. From cached during the enumeration.
NSNumber *isDirectory;
if ([theURL getResourceValue:&isDirectory forKey:NSURLIsDirectoryKey error:NULL]) {
if ([isDirectory boolValue] == NO) {
NSString *key = theURL.lastPathComponent;
// That satisfies Req.#1.3
NSString *filePath = [self.dataCacheFileManager pathForKey:key];
BOOL __block needRemove = NO;
int __block reason = 0;
// WARNING: We may skip return result here bcuz in that case we won't remove file we do not know what is it
[self alterHeaderForFileAtPath:filePath withBlock:^(SPTPersistentCacheRecordHeader *header) {
if (forceExpire && forceLocked) {
// delete all
needRemove = YES;
reason = 1;
} else if (forceExpire && !forceLocked) {
// delete those: header->refCount == 0
needRemove = header->refCount == 0;
reason = 2;
} else if (!forceExpire && forceLocked) {
// delete those: header->refCount > 0
needRemove = header->refCount > 0;
reason = 3;
} else {
// delete those: [self isDataExpiredWithHeader:header] && header->refCount == 0
needRemove = ![self isDataCanBeReturnedWithHeader:header];
reason = 4;
}
} writeBack:NO complain:YES];
if (needRemove) {
[self debugOutput:@"PersistentDataCache: gc removing record: %@, reason:%d", filePath.lastPathComponent, reason];
[self.dataCacheFileManager removeDataForKey:key];
}
} // is dir
} else {
[self debugOutput:@"Unable to fetch isDir#4 attribute:%@", theURL];
}
} // for
}
- (void)dispatchEmptyResponseWithResult:(SPTPersistentCacheResponseCode)result
callback:(SPTPersistentCacheResponseCallback _Nullable)callback
onQueue:(dispatch_queue_t _Nullable)queue
{
if (callback == nil) {
return;
}
SPTPersistentCacheSafeDispatch(queue, ^{
SPTPersistentCacheResponse *response = [[SPTPersistentCacheResponse alloc] initWithResult:result
error:nil
record:nil];
callback(response);
});
}
- (void)dispatchError:(NSError *)error
result:(SPTPersistentCacheResponseCode)result
callback:(SPTPersistentCacheResponseCallback _Nullable)callback
onQueue:(dispatch_queue_t _Nullable)queue
{
if (callback == nil) {
return;
}
SPTPersistentCacheSafeDispatch(queue, ^{
SPTPersistentCacheResponse *response = [[SPTPersistentCacheResponse alloc] initWithResult:result
error:error
record:nil];
callback(response);
});
}
- (void)debugOutput:(NSString *)format, ... NS_FORMAT_FUNCTION(1,2)
{
SPTPersistentCacheDebugCallback const debugOutput = self.debugOutput;
if (debugOutput && format.length > 0) {
va_list list;
va_start(list, format);
NSString * const message = [[NSString alloc] initWithFormat:format arguments:list];
va_end(list);
debugOutput(message);
}
}
- (BOOL)pruneBySize
{
if (self.options.sizeConstraintBytes == 0) {
return NO;
}
// Find all the image names and attributes and sort oldest last
NSMutableArray<SPTPersistentCacheFileInfo *> *files = [self storedFileNamesAndAttributes];
// Find the free space on the disk
SPTPersistentCacheDiskSize currentCacheSize = (SPTPersistentCacheDiskSize)[self lockedItemsSizeInBytes];
for (SPTPersistentCacheFileInfo *file in files) {
currentCacheSize += file.fileSize;
}
SPTPersistentCacheDiskSize optimalCacheSize = [self.dataCacheFileManager optimizedDiskSizeForCacheSize:currentCacheSize];
// Remove oldest data until we reach acceptable cache size
while (currentCacheSize > optimalCacheSize && files.count) {
SPTPersistentCacheFileInfo *file = files.lastObject;
[files removeLastObject];
NSString *fileName = file.fileName;
NSError *localError = nil;
if (fileName.length > 0 && ![self.fileManager removeItemAtPath:fileName error:&localError]) {
[self debugOutput:@"PersistentDataCache: %@ ERROR %@", @(__PRETTY_FUNCTION__), [localError localizedDescription]];
continue;
} else {
[self debugOutput:@"PersistentDataCache: evicting by size key:%@", fileName.lastPathComponent];
}
currentCacheSize -= file.fileSize;
}
return YES;
}
- (NSMutableArray<SPTPersistentCacheFileInfo *> *)storedFileNamesAndAttributes
{
NSURL *urlPath = [NSURL fileURLWithPath:self.options.cachePath];
// Enumerate the directory (specified elsewhere in your code)
// Ignore hidden files
// The errorHandler: parameter is set to nil. Typically you'd want to present a panel
NSDirectoryEnumerator *dirEnumerator = [self.fileManager enumeratorAtURL:urlPath
includingPropertiesForKeys:@[NSURLIsDirectoryKey]
options:NSDirectoryEnumerationSkipsHiddenFiles
errorHandler:nil];
// An array to store the all the enumerated file names in
NSMutableArray<SPTPersistentCacheFileInfo *> *files = [NSMutableArray array];
// Enumerate the dirEnumerator results, each value is stored in allURLs
NSURL *theURL = nil;
while ((theURL = [dirEnumerator nextObject])) {
// Retrieve the file name. From cached during the enumeration.
NSNumber *isDirectory;
if ([theURL getResourceValue:&isDirectory forKey:NSURLIsDirectoryKey error:NULL]) {
if ([isDirectory boolValue] == NO) {
const char *filePath = theURL.fileSystemRepresentation;
NSString *filePathString = [NSString stringWithUTF8String:filePath];
// We skip locked files always
BOOL __block locked = NO;
// WARNING: We may skip return result here bcuz in that case we will remove unknown file as unlocked trash
[self alterHeaderForFileAtPath:filePathString
withBlock:^(SPTPersistentCacheRecordHeader *header) {
locked = (header->refCount > 0);
} writeBack:NO
complain:YES];
if (locked) {
continue;
}
/* We use this since this is most reliable method to get file info and URL stuff fails sometimes
which is described in apple doc and its our case here */
struct stat fileStat;
int ret = [self.posixWrapper stat:filePath statStruct:&fileStat];
if (ret == -1) {
[self debugOutput:@"Cannot find the stats of file: %@", theURL.absoluteString];
continue;
}
/*
Use modification time even for files with TTL
Files with TTL have updateTime set once on creation.
*/
NSDate *mdate = [NSDate dateWithTimeIntervalSince1970:(fileStat.st_mtimespec.tv_sec + fileStat.st_mtimespec.tv_nsec*1e9)];
SPTPersistentCacheFileInfo *info = [[SPTPersistentCacheFileInfo alloc] initWithFileName:filePathString
mdate:mdate
fileSize:fileStat.st_size];
[files addObject:info];
}
} else {
[self debugOutput:@"Unable to fetch isDir#5 attribute:%@", theURL];
}
}
// Oldest goes last
NSComparisonResult(^SPTSortFilesByModificationDate)(id, id) = ^NSComparisonResult(SPTPersistentCacheFileInfo *file1, SPTPersistentCacheFileInfo *file2) {
return [file2.mdate compare:file1.mdate];
};
[files sortUsingComparator:SPTSortFilesByModificationDate];
return files;
}
- (NSTimeInterval)currentDateTimeInterval
{
return [[NSDate date] timeIntervalSince1970];
}
- (void)doWork:(void (^)(void))block priority:(NSOperationQueuePriority)priority qos:(NSQualityOfService)qos
{
NSBlockOperation *operation = [NSBlockOperation blockOperationWithBlock:block];
operation.qualityOfService = qos;
operation.queuePriority = priority;
[self.workQueue addOperation:operation];
}
- (void)logTimingForKey:(NSString *)key method:(SPTPersistentCacheDebugMethodType)method type:(SPTPersistentCacheDebugTimingType)type
{
if (self.options.timingCallback) {
dispatch_async(dispatch_get_main_queue(), ^{
self.options.timingCallback(key, method, type, mach_absolute_time());
});
}
}
@end
@implementation SPTPersistentCacheFileInfo
- (instancetype)initWithFileName:(NSString *)fileName mdate:(NSDate *)mdate fileSize:(off_t)fileSize
{
self = [super init];
if (self) {
_fileName = fileName;
_mdate = mdate;
_fileSize = fileSize;
}
return self;
}
@end