using System; using System.Collections.Generic; using System.IO; using System.Threading; using Best.HTTP.Shared; using Best.HTTP.Shared.Extensions; using Best.HTTP.Shared.Logger; using Best.HTTP.Shared.PlatformSupport.FileSystem; using Best.HTTP.Shared.PlatformSupport.Threading; using UnityEngine; using static System.Math; using static Best.HTTP.Hosts.Connections.HTTP1.Constants; using static Best.HTTP.Response.HTTPStatusCodes; namespace Best.HTTP.Caching { internal sealed class HTTPCacheAcquireLockException : Exception { public HTTPCacheAcquireLockException(string message) : base(message) { } } /// /// Types of errors that can occur during cache validation. /// public enum ErrorTypeForValidation { /// /// Indicates that no error has occurred during validation. /// None, /// /// Indicates a server error has occurred during validation. /// ServerError, /// /// Indicates a connection error has occurred during validation. /// ConnectionError } /// /// Represents a delegate that can be used to perform actions before caching of an entity begins. /// /// The HTTP method used in the request. /// The URI of the HTTP request. /// The HTTP status code of the response. /// The HTTP response headers. /// An optional logging context for debugging. public delegate void OnBeforeBeginCacheDelegate(HTTPMethods method, Uri uri, int statusCode, Dictionary> headers, LoggingContext context = null); /// /// Represents a delegate that can be used to handle cache size change events. /// public delegate void OnCacheSizeChangedDelegate(); /// /// Manages caching of HTTP responses and associated metadata. /// /// /// The `HTTPCache` class provides a powerful caching mechanism for HTTP responses in Unity applications. /// It allows you to store and retrieve HTTP responses efficiently, reducing network requests and improving /// the performance of your application. By utilizing HTTP caching, you can enhance user experience, reduce /// bandwidth usage, and optimize loading times. /// /// /// Key features: /// /// Optimal User ExperienceUsers experience faster load times and smoother interactions, enhancing user satisfaction. /// Efficient CachingIt enables efficient caching of HTTP responses, reducing the need to fetch data from the network repeatedly. /// Improved PerformanceCaching helps improve the performance of your Unity application by reducing latency and decreasing loading times. /// Bandwidth OptimizationBy storing and reusing cached responses, you can minimize bandwidth usage, making your application more data-friendly. /// Offline AccessCached responses allow your application to function even when the device is offline or has limited connectivity. /// Reduced Server LoadFewer network requests mean less load on your server infrastructure, leading to cost savings and improved server performance. /// Manual Cache ControlYou can also manually control caching by adding, removing, or updating cached responses. /// /// /// [Best.HTTP.Shared.PlatformSupport.IL2CPP.Il2CppEagerStaticClassConstruction] public class HTTPCache : IDisposable, IHeartbeat { /// /// Constants defining folder and file names used in the HTTP cache storage. /// private const string RootFolderName = "LocalCache"; private const string DatabaseFolderName = "Database"; private const string ContentFolderName = "Content"; private const string HeaderFileName = "headers.txt"; private const string ContentFileName = "content.bin"; /// /// This is the reversed domain the plugin uses for file paths when it have to load content from the local cache. /// public const string CacheHostName = "com.Tivadar.Best.HTTP.Local.Cache"; /// /// Event that is triggered when the size of the cache changes. /// public OnCacheSizeChangedDelegate OnCacheSizeChanged; /// /// Gets the options that define the behavior of the HTTP cache. /// public HTTPCacheOptions Options { get; private set; } /// /// Gets the current size of the HTTP cache in bytes. /// public long CacheSize { get => this._cacheSize; } private long _cacheSize; /// /// Called before the plugin calls to decide whether the content will be cached or not. /// public OnBeforeBeginCacheDelegate OnBeforeBeginCache; private int _subscribed; private bool _isSupported; private HTTPCacheDatabase _database; private string _baseDirectory; /// /// Initializes a new instance of the HTTPCache class with the specified cache options. /// /// The HTTP cache options specifying cache size and deletion policy. public HTTPCache(HTTPCacheOptions options) { this.Options = options ?? new HTTPCacheOptions(); try { _baseDirectory = Path.Combine(HTTPManager.GetRootSaveFolder(), RootFolderName); #if UNITY_WEBGL && !UNITY_EDITOR this._isSupported = false; this._database = null; #else var dbBaseDir = Path.Combine(_baseDirectory, DatabaseFolderName); if (!HTTPManager.IOService.DirectoryExists(dbBaseDir)) HTTPManager.IOService.DirectoryCreate(dbBaseDir); _database = new HTTPCacheDatabase(dbBaseDir); var cacheDir = Path.Combine(_baseDirectory, ContentFolderName); if (!HTTPManager.IOService.DirectoryExists(cacheDir)) HTTPManager.IOService.DirectoryCreate(cacheDir); _isSupported = true; #endif } catch (Exception ex) { if (HTTPManager.Logger.IsDiagnostic) HTTPManager.Logger.Exception(nameof(HTTPCache), "ctr", ex); _isSupported = false; _database?.Dispose(); } } /// /// Calculates a unique hash identifier based on the HTTP method and URI. /// /// The HTTP method used in the request. /// The URI of the HTTP request. /// A unique hash identifier for the combination of method and URI. public static Hash128 CalculateHash(HTTPMethods method, Uri uri) { Hash128 hash = new Hash128(); hash.Append((byte)method); hash.Append(uri.ToString()); return hash; } /// /// Generates the directory path based on the given hash where cached content is stored. /// /// A unique hash identifier for the cached content, returned by . /// The directory path for the cached content associated with the given hash. public string GetHashDirectory(Hash128 hash) => Path.Combine(_baseDirectory, ContentFolderName, hash.ToString()); /// /// Generates the file path for the header cache associated with the given hash. /// /// A unique hash identifier for the cached content, returned by . /// The file path for the header cache associated with the given hash. public string GetHeaderPathFromHash(Hash128 hash) => Path.Combine(_baseDirectory, ContentFolderName, hash.ToString(), "headers.cache"); /// /// Generates the file path for the content cache associated with the given hash. /// /// A unique hash identifier for the cached content, returned by . /// The file path for the content cache associated with the given hash. public string GetContentPathFromHash(Hash128 hash) => Path.Combine(_baseDirectory, ContentFolderName, hash.ToString(), "content.cache"); /// /// Checks whether cache files (header and content) associated with the given hash exist. /// /// A unique hash identifier for the cached content. /// true if both header and content cache files exist, otherwise false. public bool AreCacheFilesExists(Hash128 hash) => HTTPManager.IOService.FileExists(GetHeaderPathFromHash(hash)) && HTTPManager.IOService.FileExists(GetContentPathFromHash(hash)); /// /// Sets up validation headers on an HTTP request if a locally cached response exists. /// /// The to which validation headers will be added. public void SetupValidationHeaders(HTTPRequest request) { var hash = CalculateHash(request.MethodType, request.CurrentUri); if (HTTPManager.Logger.IsDiagnostic) HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(SetupValidationHeaders)}({request}, {hash})", request.Context); request.RemoveHeader("If-None-Match"); request.RemoveHeader("If-Modified-Since"); if (!_isSupported) return; if (!hash.isValid) return; // find&load content for the hash var content = _database.FindByHashAndUpdateRequestTime(hash, request.Context); if (content == null) return; if (!AreCacheFilesExists(hash)) { Delete(hash, request.Context); return; } if (!string.IsNullOrEmpty(content.ETag)) request.SetHeader("If-None-Match", content.ETag); if (content.LastModified != DateTime.MinValue) request.SetHeader("If-Modified-Since", content.LastModified.ToString("R")); } /// /// If necessary tries to make enough space in the cache by calling Maintain. /// internal bool IsThereEnoughSpaceAfterMaintain(ulong spaceNeeded, LoggingContext context) { // Run maintenance and see whether we have enough space for the new content. if ((ulong)(CacheSize + (long)spaceNeeded) > Options.MaxCacheSize) Maintain(contentLength: spaceNeeded, deleteLockedEntries: false, context: context); return (ulong)(CacheSize + (long)spaceNeeded) <= Options.MaxCacheSize; } /// /// Initiates the caching process for an HTTP response, creating an if caching is enabled and all predconditions are met. /// /// The method used to fetch the response. /// The URI for the response. /// The HTTP status code of the response. /// The HTTP headers of the response. /// An optional logging context for debugging. /// An instance for writing the response content to the cache, or null if caching is not enabled or not possible. public HTTPCacheContentWriter BeginCache(HTTPMethods method, Uri uri, int statusCode, Dictionary> headers, LoggingContext context) { if (HTTPManager.Logger.IsDiagnostic) HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(BeginCache)}({method}, {uri}, {statusCode}, {headers?.Count})", context); if (!_isSupported) return null; // Check if the response is cacheable based on method, URI, and status code. // The original IsCachable got split into two: // - first check method, uri and status code before calling OnBeforeBeginCache if (!IsCacheble(method, uri, statusCode)) return null; if (headers == null) return null; // Log caching headers for debugging purposes. LogCachingHeaders(headers, context); var onBeforeBeginCache = OnBeforeBeginCache; if (onBeforeBeginCache != null) { try { HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(BeginCache)} - Calling {nameof(OnBeforeBeginCache)}", context); // Invoke the OnBeforeBeginCache callback if provided. onBeforeBeginCache?.Invoke(method, uri, statusCode, headers, context); // Log caching headers after the callback. LogCachingHeaders(headers, context); } catch (Exception ex) { HTTPManager.Logger.Exception(nameof(HTTPCache), nameof(OnBeforeBeginCache), ex, context); } } // Check if there is enough space in the cache for the response content. var contentLengthStr = headers.GetFirstHeaderValue("content-length"); if (ulong.TryParse(contentLengthStr, out var contentLength)) { if (!IsThereEnoughSpaceAfterMaintain(contentLength, context)) { HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(BeginCache)} - Not enough space({contentLength:N0}) in cache({CacheSize:N0}), even after Maintain!", context); return null; } } // Check if the response headers indicate that the response is cacheable. // (second half of the original IsCachable) // - then existence of the required caching headers after OnBeforeBeginCache if (!IsCacheble(headers)) return null; // Check if the calculated hash is valid. var hash = CalculateHash(method, uri); if (!hash.isValid) return null; // Try to get a lock on the cache entity. Prevents other requests from updating or loading from it. if (!_database.TryAcquireWriteLock(hash, headers, context)) { HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(BeginCache)} - Couldn't acquire write lock!", context); return null; } // Add or replace the "Date" header in the response if it is missing or invalid. // https://www.rfc-editor.org/rfc/rfc9110#section-6.6.1-8 // A recipient with a clock that receives a response message without a Date header field // MUST record the time it was received and append a corresponding Date header field // to the message's header section if it is cached or forwarded downstream. var date = headers.GetFirstHeaderValue("date"); if (string.IsNullOrEmpty(date) || !DateTime.TryParse(date, out var _)) { // A recipient with a clock that receives a response with an invalid Date header field value // MAY replace that value with the time that response was received. headers.RemoveHeader("date"); headers.AddHeader("date", DateTime.Now.ToString("R")); } Stream contentStream = null; try { // Create the cache directory if it does not exist. var hashDir = GetHashDirectory(hash); if (!HTTPManager.IOService.DirectoryExists(hashDir)) HTTPManager.IOService.DirectoryCreate(hashDir); // Create and write the header cache file. using (var headStream = HTTPManager.IOService.CreateFileStream(GetHeaderPathFromHash(hash), FileStreamModes.Create)) WriteHeaders(headStream, headers); // Create/open the content cache file. contentStream = HTTPManager.IOService.CreateFileStream(GetContentPathFromHash(hash), FileStreamModes.Create); } catch (Exception ex) { // Handle exceptions that may occur during cache file creation HTTPManager.Logger.Exception(nameof(HTTPCache), nameof(BeginCache), ex, context); contentStream?.Dispose(); contentStream = null; // Delete the cache entry if an exception occurs. Delete(hash, context); } // Return an HTTPCacheContentWriter for writing response content to the cache. return new HTTPCacheContentWriter(this, hash, contentStream, contentLength, context); } /// /// Finalizes the caching process and takes appropriate actions based on the completion status. /// /// The instance representing the caching operation. /// A boolean indicating whether the caching process completed without issues. /// An optional logging context for debugging. public void EndCache(HTTPCacheContentWriter cacheResult, bool completedWithoutIssue, LoggingContext context) { if (HTTPManager.Logger.IsDiagnostic) HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(EndCache)}({cacheResult}, {completedWithoutIssue})", context); if (cacheResult == null || !cacheResult.Hash.isValid || !_isSupported) return; var hash = cacheResult.Hash; cacheResult.Close(); if (completedWithoutIssue) { _database.ReleaseWriteLock(hash, cacheResult.ProcessedLength, cacheResult.Context); IncrementCacheSize(cacheResult.ProcessedLength); } else { Delete(hash, cacheResult.Context); } } /// /// Initiates the process of reading cached content associated with a given hash. Call BeginReadContent to acquire a Stream object that points to the cached resource. /// /// A hash from identifying the resource. /// An optional /// A stream for reading the cached content, or null if the content could not be read (the resource isn't cached or currently downloading). public Stream BeginReadContent(Hash128 hash, LoggingContext context) { if (HTTPManager.Logger.IsDiagnostic) HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(BeginReadContent)}({hash})", context); if (!_isSupported) return null; if (!_database.TryAcquireReadLock(hash, context)) return null; _database.UpdateLastAccessTime(hash, context); var contentPath = GetContentPathFromHash(hash); return HTTPManager.IOService.CreateFileStream(contentPath, FileStreamModes.OpenRead); } /// /// Finalizes the process of reading cached content associated with a given hash. /// /// The unique hash identifier for the cached content. /// An optional logging context for debugging. public void EndReadContent(Hash128 hash, LoggingContext context) { if (HTTPManager.Logger.IsDiagnostic) HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(EndReadContent)}({hash})", context); if (!_isSupported) return; _database.ReleaseReadLock(hash, context); } /// /// Deletes a cached entry identified by the given hash, including its associated header and content files. /// /// The unique hash identifier for the cached entry to be deleted. /// An optional logging context for debugging. public void Delete(Hash128 hash, LoggingContext context) { if (HTTPManager.Logger.IsDiagnostic) HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(Delete)}({hash})", context); if (!_isSupported) return; // Calling this function more than once with the same hash should be fine, the DB is locked and // only one will find the metadata. try { _database.EnterWriteLock(context); try { var headerPath = GetHeaderPathFromHash(hash); if (HTTPManager.IOService.FileExists(headerPath)) HTTPManager.IOService.FileDelete(headerPath); var contentPath = GetContentPathFromHash(hash); if (HTTPManager.IOService.FileExists(contentPath)) HTTPManager.IOService.FileDelete(contentPath); var hashDirectory = GetHashDirectory(hash); if (HTTPManager.IOService.DirectoryExists(hashDirectory)) HTTPManager.IOService.DirectoryDelete(hashDirectory); } catch (Exception ex) { HTTPManager.Logger.Exception(nameof(HTTPCache), $"{nameof(Delete)}({hash})", ex, context); } DecrementCacheSize(_database.Delete(hash, context)); } finally { _database.ExitWriteLock(context); } } /// /// Refreshes the headers of a cached HTTP response with new headers. /// /// A unique hash identifier for the cached response from a call. /// A dictionary of new headers to replace or merge with existing headers. /// Used by the plugin to add an addition logging context for debugging. It can be null. /// true if the headers were successfully refreshed; otherwise, false. public bool RefreshHeaders(Hash128 hash, Dictionary> newHeaders, LoggingContext context) { // To Refresh stored cache related values from the headers described here: // 1.) https://www.rfc-editor.org/rfc/rfc9111.html#name-freshening-stored-responses // 2.) https://www.rfc-editor.org/rfc/rfc9111.html#name-updating-stored-header-fiel if (HTTPManager.Logger.IsDiagnostic) HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(RefreshHeaders)}({hash}, {newHeaders?.Count})", context); if (!_isSupported) return false; // Log the new headers for debugging purposes. LogCachingHeaders(newHeaders, context); // Update the metadata with the new headers. if (_database.Update(hash, newHeaders, context)) { // https://www.rfc-editor.org/rfc/rfc9111.html#name-updating-stored-header-fiel // Load stored header, merge them with the received ones and store them. try { using (var headerStream = HTTPManager.IOService.CreateFileStream(GetHeaderPathFromHash(hash), FileStreamModes.OpenReadWrite)) { // Load existing headers. var oldHeaders = LoadHeaders(headerStream); foreach (var kvp in newHeaders) { if (oldHeaders.TryGetValue(kvp.Key, out var value)) { // Replace existing header values with new values. value.Clear(); value.AddRange(kvp.Value); } else { // Add new headers if they don't already exist. oldHeaders.Add(kvp.Key, value); } } // Seek to the beginning of the header file and write the updated headers. headerStream.Seek(0, SeekOrigin.Begin); headerStream.SetLength(0); WriteHeaders(headerStream, oldHeaders); } // Everything went as expected, return true return true; } catch (Exception ex) { HTTPManager.Logger.Warning(nameof(HTTPCache), $"{nameof(RefreshHeaders)} - Couldn't merge/store headers. Exception: {ex}", context); // Delete the cached response associated with the hash. Delete(hash, context); } } return false; } private bool IsCacheble(Dictionary> headers) { if (!_isSupported) return false; // Responses with byte ranges not supported. var byteRanges = headers.GetHeaderValues("content-range"); if (byteRanges != null) return false; //http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.2 bool hasValidMaxAge = false; var cacheControls = headers.GetHeaderValues("cache-control"); if (cacheControls != null) { // A local function that checks the header value for any indication that prohibits caching. // So, it must return TRUE, if it's NOT cachable. bool CheckHeader(string headerValue) { HeaderParser parser = new HeaderParser(headerValue); if (parser.Values != null && parser.Values.Count > 0) { for (int i = 0; i < parser.Values.Count; ++i) { var value = parser.Values[i]; // https://csswizardry.com/2019/03/cache-control-for-civilians/#no-store if (value.Key == "no-store") return true; if (value.Key == "max-age" && value.HasValue) { double maxAge; if (double.TryParse(value.Value, out maxAge)) { // A negative max-age value is a no cache if (maxAge <= 0) return true; hasValidMaxAge = true; } } } } return false; } if (cacheControls.Exists(CheckHeader)) return false; } // It has an ETag header var etag = headers.GetFirstHeaderValue("etag"); if (!string.IsNullOrEmpty(etag)) return true; // It has an Expires header, and it's in the future var expires = headers.GetFirstHeaderValue("expires").ToDateTime(DateTime.FromBinary(0)); if (expires > DateTime.Now) return true; // It has a Last-Modified header if (headers.GetFirstHeaderValue("last-modified") != null) return true; return hasValidMaxAge; } private bool IsCacheble(HTTPMethods method, Uri uri, int statusCode) { if (!_isSupported) return false; if (!uri.Scheme.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return false; if (method != HTTPMethods.Get) return false; // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/204 if (statusCode != OK && statusCode != NoContent) return false; return true; } /// /// Checks whether the caches resource identified by the hash is can be served from the local store with the given error conditions. /// /// This check reflects the very current state, even if it returns true, a request might just executing to get a write lock on it to refresh the content. /// hash returned by identifying a resource. /// Possible error condition that can occur during validation. Servers can provision that certain stalled resources can be served if revalidation fails. /// Used by the plugin to add an addition logging context for debugging. It can be null. /// true if the cached response can be served without validating it with the origin server; otherwise, false public bool CanServeWithoutValidation(Hash128 hash, ErrorTypeForValidation errorType, LoggingContext context) { if (HTTPManager.Logger.IsDiagnostic) HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(CanServeWithoutValidation)}({hash}, {errorType})", context); if (!_isSupported || !hash.isValid) return false; (CacheMetadataContent content, CacheMetadata metadata) = (null, null); try { // Attempt to find the cached content and metadata for the given hash. (content, metadata) = _database.FindContentAndMetadataLocked(hash); if (content == null) return false; } catch(Exception ex) { HTTPManager.Logger.Exception(nameof(HTTPCache), $"{nameof(_database.FindContentAndMetadataLocked)}", ex, context); Delete(hash, context); return false; } // if (metadata.Lock == LockTypes.Write) return false; // Check if cache files associated with the hash exist. if (!AreCacheFilesExists(hash)) { Delete(hash, context); return false; } if ((content.Flags & CacheFlags.NoCache) != 0) return false; // Calculate the current age of the cached content, described here: // 1.) https://www.rfc-editor.org/rfc/rfc9111.html#name-freshness // 2.) https://www.rfc-editor.org/rfc/rfc9111.html#name-calculating-age if (content.MaxAge > 0) { long current_age = content.Age; // If there are more than one requests accessing the same resource it's possible that the first one sets the RequestTime // but ResponseTime is the same old value while the second request tries to calculate the resrouce's Age. In this case, // we will just use the received Age. if (content.ResponseTime > content.RequestTime) { var apparent_age = Max(0, (int)(content.ResponseTime - content.Date).TotalSeconds); var response_delay = (int)(content.ResponseTime - content.RequestTime).TotalSeconds; var corrected_age_value = content.Age + response_delay; var corrected_initial_age = Max(apparent_age, corrected_age_value); var resident_time = DateTime.Now - content.ResponseTime; current_age = corrected_initial_age + (int)resident_time.TotalSeconds; } var maxAge = content.MaxAge; switch(errorType) { case ErrorTypeForValidation.None: // https://www.rfc-editor.org/rfc/rfc5861.html#section-1 // The stale-while-revalidate HTTP Cache-Control extension allows a // cache to immediately return a stale response while it revalidates it // in the background, thereby hiding latency (both in the network and on // the server) from clients. // If it's stalled but there's a value set for StaleWhileRevalidate and it's fresh with its value if (current_age > maxAge && content.StaleWhileRevalidate > 0 && current_age <= (maxAge + content.StaleWhileRevalidate)) { maxAge += content.StaleWhileRevalidate; // TODO: send revalidate request } break; case ErrorTypeForValidation.ServerError: case ErrorTypeForValidation.ConnectionError: // Handle stale-if-error caching extension: // https://www.rfc-editor.org/rfc/rfc5861.html#section-4 if (content.StaleIfError > 0) maxAge += content.StaleIfError; break; } return current_age <= maxAge; } // Check if the content has not expired based on the 'Expires' header. return content.Expires > DateTime.Now; } /// /// Redirects a request to a cached entity. /// /// The that will be redirected. /// Hash obtained from . public void Redirect(HTTPRequest request, Hash128 hash) { if (HTTPManager.Logger.IsDiagnostic) HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(Redirect)}({request}, {hash})", request.Context); if (!_isSupported || request == null || !hash.isValid) return; // Redirect to the local cache request.RedirectSettings.RedirectUri = new Uri($"file://{CacheHostName}/{hash}"); request.RedirectSettings.IsRedirected = true; } internal void Maintain(ulong contentLength, bool deleteLockedEntries, LoggingContext context) { if (!_isSupported) return; HTTPManager.Logger.Information(nameof(HTTPCache), $"Maintain({contentLength:N0}, {deleteLockedEntries}, {System.Threading.Thread.CurrentThread.ManagedThreadId})", context); if (HTTPUpdateDelegator.Instance.IsMainThread()) ThreadedRunner.RunShortLiving(MaintainImplementation, contentLength, deleteLockedEntries, HTTPManager.CurrentFrameDateTime, context); else MaintainImplementation(contentLength, deleteLockedEntries, HTTPManager.CurrentFrameDateTime, context); } private void ZeroOutCacheSize() { //HTTPManager.Logger.Information(nameof(HTTPCache), $"CacheSize - ZeroOutCacheSize()"); Interlocked.Exchange(ref this._cacheSize, 0); if (Interlocked.CompareExchange(ref this._subscribed, 1, 0) == 0) HTTPManager.Heartbeats.Subscribe(this); } private void IncrementCacheSize(ulong withSize) { //HTTPManager.Logger.Information(nameof(HTTPCache), $"CacheSize - IncrementCacheSize({withSize:N0}) => {Interlocked.Add(ref this._cacheSize, (long)withSize):N0}"); Interlocked.Add(ref this._cacheSize, (long)withSize); if (Interlocked.CompareExchange(ref this._subscribed, 1, 0) == 0) HTTPManager.Heartbeats.Subscribe(this); } private void DecrementCacheSize(ulong withSize) { //HTTPManager.Logger.Information(nameof(HTTPCache), $"CacheSize - DecrementCacheSize({-(long)withSize:N0}) => {Interlocked.Add(ref this._cacheSize, -(long)withSize):N0}"); Interlocked.Add(ref this._cacheSize, -(long)withSize); if (Interlocked.CompareExchange(ref this._subscribed, 1, 0) == 0) HTTPManager.Heartbeats.Subscribe(this); } private void MaintainImplementation(ulong contentLength, bool deleteLockedEntries, DateTime now, LoggingContext context) { List markedForDelete = null; // lock down the whole database _database.EnterWriteLock(null); ZeroOutCacheSize(); try { var deleteOlderDT = Options.DeleteOlder == TimeSpan.MaxValue ? DateTime.MinValue : now - Options.DeleteOlder; // Go through hashes in the DB metadata and compare them to the directory names in the cache folder // delete those that aren't in the DB/file system. for (int i = 0; i < _database.MetadataService.Metadatas.Count; ++i) { var metadata = _database.MetadataService.Metadatas[i]; // When Maintain first called on startup, we can search for locked entries. // An entry can remeain write locked if the process is terminated unexpectedly while a download is in progress. // By deleting it here we can prevent serving incomplete content. bool isIncomplete = deleteLockedEntries && metadata.Lock != LockTypes.Unlocked; if (isIncomplete) HTTPManager.Logger.Warning(nameof(HTTPCache), $"Incomplete cache entry({metadata}) found!", context); bool isAnyFileMissing = !AreCacheFilesExists(metadata.Hash) && metadata.Lock == LockTypes.Unlocked; if (isAnyFileMissing || isIncomplete || metadata.LastAccessTime <= deleteOlderDT) { if (markedForDelete == null) markedForDelete = new List(); markedForDelete.Add(metadata.Hash); metadata.MarkForDelete(); } else { IncrementCacheSize(metadata.ContentLength); } } var sortedMetadatas = new List(_database.MetadataService.Metadatas); sortedMetadatas.Sort((x, y) => x.LastAccessTime.CompareTo(y.LastAccessTime)); var cacheSize = CacheSize; var targetCacheSize = (long)(Options.MaxCacheSize - contentLength); for (int i = 0; i < sortedMetadatas.Count && cacheSize > targetCacheSize; ++i) { var metadata = sortedMetadatas[i]; // already marked for deletion if (metadata.IsDeleted) continue; // is in use if (metadata.Lock != LockTypes.Unlocked) continue; if (markedForDelete == null) markedForDelete = new List(); markedForDelete.Add(metadata.Hash); cacheSize -= (long)metadata.ContentLength; } } finally { _database.ExitWriteLock(null); } if (markedForDelete != null) { HTTPManager.Logger.Information(nameof(HTTPCache), $"Maintain - collected {markedForDelete.Count} entries for deletion!", context); foreach (Hash128 key in markedForDelete) Delete(key, context); markedForDelete.Clear(); } else HTTPManager.Logger.Information(nameof(HTTPCache), "Maintain - collected 0 entries for deletion!", context); } private static void WriteHeaders(Stream headerStream, Dictionary> headers) { if (headerStream == null || headers == null) return; foreach (var kvp in headers) { // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-header-and-trailer- // TODO: expand the no-save list if (kvp.Key.Equals("alt-svc", StringComparison.OrdinalIgnoreCase) || kvp.Key.Equals("content-encoding", StringComparison.OrdinalIgnoreCase) || kvp.Key.Equals("transfer-encoding", StringComparison.OrdinalIgnoreCase) || kvp.Key.Equals("connection", StringComparison.OrdinalIgnoreCase) || kvp.Key.Equals("proxy-authenticate", StringComparison.OrdinalIgnoreCase) || kvp.Key.Equals("content-length", StringComparison.OrdinalIgnoreCase)) continue; if (kvp.Value == null) { headerStream.WriteString(kvp.Key); headerStream.WriteString(":"); headerStream.WriteString(string.Empty); headerStream.WriteLine(); continue; } foreach (var value in kvp.Value) { headerStream.WriteString(kvp.Key); headerStream.WriteString(":"); headerStream.WriteString(value); headerStream.WriteLine(); } } headerStream.WriteLine(); headerStream.Flush(); } private static Dictionary> LoadHeaders(Stream headersStream) { var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); string headerName = HTTPResponse.ReadTo(headersStream, (byte)':', LF); while (headerName != string.Empty) { string value = HTTPResponse.ReadTo(headersStream, LF); result.AddHeader(headerName, value); headerName = HTTPResponse.ReadTo(headersStream, (byte)':', LF); } return result; } public void Dispose() { HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(Dispose)}"); ZeroOutCacheSize(); try { _database?.Dispose(); _database = null; } catch (Exception ex) { HTTPManager.Logger.Exception(nameof(HTTPCache), $"{nameof(Dispose)}", ex); } HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(Dispose)} - Disposed!"); } private static void LogCachingHeaders(Dictionary> headers, LoggingContext context) { if (!HTTPManager.Logger.IsDiagnostic) return; var etag = headers.GetFirstHeaderValue("etag"); var expires = headers.GetFirstHeaderValue("expires"); var lastModified = headers.GetFirstHeaderValue("last-modified"); var age = headers.GetFirstHeaderValue("age"); var date = headers.GetFirstHeaderValue("date"); var cacheControl = headers.GetFirstHeaderValue("cache-control"); if (etag != null) HTTPManager.Logger.Verbose(nameof(HTTPCache), "ETag: " + etag, context); if (expires != null) HTTPManager.Logger.Verbose(nameof(HTTPCache), "Expires: " + expires, context); if (lastModified != null) HTTPManager.Logger.Verbose(nameof(HTTPCache), "Last-Modified: " + lastModified, context); if (age != null) HTTPManager.Logger.Verbose(nameof(HTTPCache), "Age: " + age, context); if (date != null) HTTPManager.Logger.Verbose(nameof(HTTPCache), "Date: " + date, context); if (cacheControl != null) HTTPManager.Logger.Verbose(nameof(HTTPCache), "Cache-Control: " + cacheControl, context); } /// /// Clears the HTTP cache by removing all cached entries and associated metadata. /// public void Clear() { if (!_isSupported) return; //_database.EnterWriteLock(null); try { var copyOfMetadatas = new List(_database.MetadataService.Metadatas); foreach (var metadata in copyOfMetadatas) Delete(metadata.Hash, null); } finally { //_database.ExitWriteLock(null); } } void IHeartbeat.OnHeartbeatUpdate(DateTime now, TimeSpan dif) { try { this.OnCacheSizeChanged?.Invoke(); } catch(Exception ex) { HTTPManager.Logger.Exception(nameof(HTTPCache), "OnCacheSizeChanged", ex, null); } HTTPManager.Heartbeats.Unsubscribe(this); Interlocked.Exchange(ref this._subscribed, 0); } } }