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