HTTPCache.cs 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059
  1. using System;
  2. using System.Collections.Generic;
  3. using System.IO;
  4. using System.Threading;
  5. using Best.HTTP.Shared;
  6. using Best.HTTP.Shared.Extensions;
  7. using Best.HTTP.Shared.Logger;
  8. using Best.HTTP.Shared.PlatformSupport.FileSystem;
  9. using Best.HTTP.Shared.PlatformSupport.Threading;
  10. using UnityEngine;
  11. using static System.Math;
  12. using static Best.HTTP.Hosts.Connections.HTTP1.Constants;
  13. using static Best.HTTP.Response.HTTPStatusCodes;
  14. namespace Best.HTTP.Caching
  15. {
  16. internal sealed class HTTPCacheAcquireLockException : Exception
  17. {
  18. public HTTPCacheAcquireLockException(string message) : base(message)
  19. {
  20. }
  21. }
  22. /// <summary>
  23. /// Types of errors that can occur during cache validation.
  24. /// </summary>
  25. public enum ErrorTypeForValidation
  26. {
  27. /// <summary>
  28. /// Indicates that no error has occurred during validation.
  29. /// </summary>
  30. None,
  31. /// <summary>
  32. /// Indicates a server error has occurred during validation.
  33. /// </summary>
  34. ServerError,
  35. /// <summary>
  36. /// Indicates a connection error has occurred during validation.
  37. /// </summary>
  38. ConnectionError
  39. }
  40. /// <summary>
  41. /// Represents a delegate that can be used to perform actions before caching of an entity begins.
  42. /// </summary>
  43. /// <param name="method">The HTTP method used in the request.</param>
  44. /// <param name="uri">The URI of the HTTP request.</param>
  45. /// <param name="statusCode">The HTTP status code of the response.</param>
  46. /// <param name="headers">The HTTP response headers.</param>
  47. /// <param name="context">An optional logging context for debugging.</param>
  48. public delegate void OnBeforeBeginCacheDelegate(HTTPMethods method, Uri uri, int statusCode, Dictionary<string, List<string>> headers, LoggingContext context = null);
  49. /// <summary>
  50. /// Represents a delegate that can be used to handle cache size change events.
  51. /// </summary>
  52. public delegate void OnCacheSizeChangedDelegate();
  53. /// <summary>
  54. /// Manages caching of HTTP responses and associated metadata.
  55. /// </summary>
  56. /// <remarks>
  57. /// <para>The `HTTPCache` class provides a powerful caching mechanism for HTTP responses in Unity applications.
  58. /// It allows you to store and retrieve HTTP responses efficiently, reducing network requests and improving
  59. /// the performance of your application. By utilizing HTTP caching, you can enhance user experience, reduce
  60. /// bandwidth usage, and optimize loading times.
  61. /// </para>
  62. /// <para>
  63. /// Key features:
  64. /// <list type="bullet">
  65. /// <item><term>Optimal User Experience</term><description>Users experience faster load times and smoother interactions, enhancing user satisfaction.</description></item>
  66. /// <item><term>Efficient Caching</term><description>It enables efficient caching of HTTP responses, reducing the need to fetch data from the network repeatedly.</description></item>
  67. /// <item><term>Improved Performance</term><description>Caching helps improve the performance of your Unity application by reducing latency and decreasing loading times.</description></item>
  68. /// <item><term>Bandwidth Optimization</term><description>By storing and reusing cached responses, you can minimize bandwidth usage, making your application more data-friendly.</description></item>
  69. /// <item><term>Offline Access</term><description>Cached responses allow your application to function even when the device is offline or has limited connectivity.</description></item>
  70. /// <item><term>Reduced Server Load</term><description>Fewer network requests mean less load on your server infrastructure, leading to cost savings and improved server performance.</description></item>
  71. /// <item><term>Manual Cache Control</term><description>You can also manually control caching by adding, removing, or updating cached responses.</description></item>
  72. /// </list>
  73. /// </para>
  74. /// </remarks>
  75. [Best.HTTP.Shared.PlatformSupport.IL2CPP.Il2CppEagerStaticClassConstruction]
  76. public class HTTPCache : IDisposable, IHeartbeat
  77. {
  78. /// <summary>
  79. /// Constants defining folder and file names used in the HTTP cache storage.
  80. /// </summary>
  81. private const string RootFolderName = "LocalCache";
  82. private const string DatabaseFolderName = "Database";
  83. private const string ContentFolderName = "Content";
  84. private const string HeaderFileName = "headers.txt";
  85. private const string ContentFileName = "content.bin";
  86. /// <summary>
  87. /// This is the reversed domain the plugin uses for file paths when it have to load content from the local cache.
  88. /// </summary>
  89. public const string CacheHostName = "com.Tivadar.Best.HTTP.Local.Cache";
  90. /// <summary>
  91. /// Event that is triggered when the size of the cache changes.
  92. /// </summary>
  93. public OnCacheSizeChangedDelegate OnCacheSizeChanged;
  94. /// <summary>
  95. /// Gets the options that define the behavior of the HTTP cache.
  96. /// </summary>
  97. public HTTPCacheOptions Options { get; private set; }
  98. /// <summary>
  99. /// Gets the current size of the HTTP cache in bytes.
  100. /// </summary>
  101. public long CacheSize { get => this._cacheSize; }
  102. private long _cacheSize;
  103. /// <summary>
  104. /// Called before the plugin calls <see cref="BeginCache(HTTPMethods, Uri, int, Dictionary{string, List{string}}, LoggingContext)"/> to decide whether the content will be cached or not.
  105. /// </summary>
  106. public OnBeforeBeginCacheDelegate OnBeforeBeginCache;
  107. private int _subscribed;
  108. private bool _isSupported;
  109. private HTTPCacheDatabase _database;
  110. private string _baseDirectory;
  111. /// <summary>
  112. /// Initializes a new instance of the HTTPCache class with the specified cache options.
  113. /// </summary>
  114. /// <param name="options">The HTTP cache options specifying cache size and deletion policy.</param>
  115. public HTTPCache(HTTPCacheOptions options)
  116. {
  117. this.Options = options ?? new HTTPCacheOptions();
  118. try
  119. {
  120. _baseDirectory = Path.Combine(HTTPManager.GetRootSaveFolder(), RootFolderName);
  121. #if UNITY_WEBGL && !UNITY_EDITOR
  122. this._isSupported = false;
  123. this._database = null;
  124. #else
  125. var dbBaseDir = Path.Combine(_baseDirectory, DatabaseFolderName);
  126. if (!HTTPManager.IOService.DirectoryExists(dbBaseDir))
  127. HTTPManager.IOService.DirectoryCreate(dbBaseDir);
  128. _database = new HTTPCacheDatabase(dbBaseDir);
  129. var cacheDir = Path.Combine(_baseDirectory, ContentFolderName);
  130. if (!HTTPManager.IOService.DirectoryExists(cacheDir))
  131. HTTPManager.IOService.DirectoryCreate(cacheDir);
  132. _isSupported = true;
  133. #endif
  134. }
  135. catch (Exception ex)
  136. {
  137. if (HTTPManager.Logger.IsDiagnostic)
  138. HTTPManager.Logger.Exception(nameof(HTTPCache), "ctr", ex);
  139. _isSupported = false;
  140. _database?.Dispose();
  141. }
  142. }
  143. /// <summary>
  144. /// Calculates a unique hash identifier based on the HTTP method and URI.
  145. /// </summary>
  146. /// <param name="method">The HTTP method used in the request.</param>
  147. /// <param name="uri">The URI of the HTTP request.</param>
  148. /// <returns>A unique hash identifier for the combination of method and URI.</returns>
  149. public static Hash128 CalculateHash(HTTPMethods method, Uri uri)
  150. {
  151. Hash128 hash = new Hash128();
  152. hash.Append((byte)method);
  153. hash.Append(uri.ToString());
  154. return hash;
  155. }
  156. /// <summary>
  157. /// Generates the directory path based on the given hash where cached content is stored.
  158. /// </summary>
  159. /// <param name="hash">A unique hash identifier for the cached content, returned by <see cref="HTTPCache.CalculateHash(HTTPMethods, Uri)"/>.</param>
  160. /// <returns>The directory path for the cached content associated with the given hash.</returns>
  161. public string GetHashDirectory(Hash128 hash)
  162. => Path.Combine(_baseDirectory, ContentFolderName, hash.ToString());
  163. /// <summary>
  164. /// Generates the file path for the header cache associated with the given hash.
  165. /// </summary>
  166. /// <param name="hash">A unique hash identifier for the cached content, returned by <see cref="HTTPCache.CalculateHash(HTTPMethods, Uri)"/>.</param>
  167. /// <returns>The file path for the header cache associated with the given hash.</returns>
  168. public string GetHeaderPathFromHash(Hash128 hash)
  169. => Path.Combine(_baseDirectory, ContentFolderName, hash.ToString(), "headers.cache");
  170. /// <summary>
  171. /// Generates the file path for the content cache associated with the given hash.
  172. /// </summary>
  173. /// <param name="hash">A unique hash identifier for the cached content, returned by <see cref="HTTPCache.CalculateHash(HTTPMethods, Uri)"/>.</param>
  174. /// <returns>The file path for the content cache associated with the given hash.</returns>
  175. public string GetContentPathFromHash(Hash128 hash)
  176. => Path.Combine(_baseDirectory, ContentFolderName, hash.ToString(), "content.cache");
  177. /// <summary>
  178. /// Checks whether cache files (header and content) associated with the given hash exist.
  179. /// </summary>
  180. /// <param name="hash">A unique hash identifier for the cached content.</param>
  181. /// <returns><c>true</c> if both header and content cache files exist, otherwise <c>false</c>.</returns>
  182. public bool AreCacheFilesExists(Hash128 hash)
  183. => HTTPManager.IOService.FileExists(GetHeaderPathFromHash(hash)) &&
  184. HTTPManager.IOService.FileExists(GetContentPathFromHash(hash));
  185. /// <summary>
  186. /// Sets up validation headers on an HTTP request if a locally cached response exists.
  187. /// </summary>
  188. /// <param name="request">The <see cref="HTTPRequest"/> to which validation headers will be added.</param>
  189. public void SetupValidationHeaders(HTTPRequest request)
  190. {
  191. var hash = CalculateHash(request.MethodType, request.CurrentUri);
  192. if (HTTPManager.Logger.IsDiagnostic)
  193. HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(SetupValidationHeaders)}({request}, {hash})", request.Context);
  194. request.RemoveHeader("If-None-Match");
  195. request.RemoveHeader("If-Modified-Since");
  196. if (!_isSupported)
  197. return;
  198. if (!hash.isValid)
  199. return;
  200. // find&load content for the hash
  201. var content = _database.FindByHashAndUpdateRequestTime(hash, request.Context);
  202. if (content == null)
  203. return;
  204. if (!AreCacheFilesExists(hash))
  205. {
  206. Delete(hash, request.Context);
  207. return;
  208. }
  209. if (!string.IsNullOrEmpty(content.ETag))
  210. request.SetHeader("If-None-Match", content.ETag);
  211. if (content.LastModified != DateTime.MinValue)
  212. request.SetHeader("If-Modified-Since", content.LastModified.ToString("R"));
  213. }
  214. /// <summary>
  215. /// If necessary tries to make enough space in the cache by calling Maintain.
  216. /// </summary>
  217. internal bool IsThereEnoughSpaceAfterMaintain(ulong spaceNeeded, LoggingContext context)
  218. {
  219. // Run maintenance and see whether we have enough space for the new content.
  220. if ((ulong)(CacheSize + (long)spaceNeeded) > Options.MaxCacheSize)
  221. Maintain(contentLength: spaceNeeded, deleteLockedEntries: false, context: context);
  222. return (ulong)(CacheSize + (long)spaceNeeded) <= Options.MaxCacheSize;
  223. }
  224. /// <summary>
  225. /// Initiates the caching process for an HTTP response, creating an <see cref="HTTPCacheContentWriter"/> if caching is enabled and all predconditions are met.
  226. /// </summary>
  227. /// <param name="method">The <see cref="HTTPRequest"/> method used to fetch the response.</param>
  228. /// <param name="uri">The URI for the response.</param>
  229. /// <param name="statusCode">The HTTP status code of the response.</param>
  230. /// <param name="headers">The HTTP headers of the response.</param>
  231. /// <param name="context">An optional logging context for debugging.</param>
  232. /// <returns>An <see cref="HTTPCacheContentWriter"/> instance for writing the response content to the cache, or null if caching is not enabled or not possible.</returns>
  233. public HTTPCacheContentWriter BeginCache(HTTPMethods method, Uri uri, int statusCode, Dictionary<string, List<string>> headers, LoggingContext context)
  234. {
  235. if (HTTPManager.Logger.IsDiagnostic)
  236. HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(BeginCache)}({method}, {uri}, {statusCode}, {headers?.Count})", context);
  237. if (!_isSupported)
  238. return null;
  239. // Check if the response is cacheable based on method, URI, and status code.
  240. // The original IsCachable got split into two:
  241. // - first check method, uri and status code before calling OnBeforeBeginCache
  242. if (!IsCacheble(method, uri, statusCode))
  243. return null;
  244. if (headers == null)
  245. return null;
  246. // Log caching headers for debugging purposes.
  247. LogCachingHeaders(headers, context);
  248. var onBeforeBeginCache = OnBeforeBeginCache;
  249. if (onBeforeBeginCache != null)
  250. {
  251. try
  252. {
  253. HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(BeginCache)} - Calling {nameof(OnBeforeBeginCache)}", context);
  254. // Invoke the OnBeforeBeginCache callback if provided.
  255. onBeforeBeginCache?.Invoke(method, uri, statusCode, headers, context);
  256. // Log caching headers after the callback.
  257. LogCachingHeaders(headers, context);
  258. }
  259. catch (Exception ex)
  260. {
  261. HTTPManager.Logger.Exception(nameof(HTTPCache), nameof(OnBeforeBeginCache), ex, context);
  262. }
  263. }
  264. // Check if there is enough space in the cache for the response content.
  265. var contentLengthStr = headers.GetFirstHeaderValue("content-length");
  266. if (ulong.TryParse(contentLengthStr, out var contentLength))
  267. {
  268. if (!IsThereEnoughSpaceAfterMaintain(contentLength, context))
  269. {
  270. HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(BeginCache)} - Not enough space({contentLength:N0}) in cache({CacheSize:N0}), even after Maintain!", context);
  271. return null;
  272. }
  273. }
  274. // Check if the response headers indicate that the response is cacheable.
  275. // (second half of the original IsCachable)
  276. // - then existence of the required caching headers after OnBeforeBeginCache
  277. if (!IsCacheble(headers))
  278. return null;
  279. // Check if the calculated hash is valid.
  280. var hash = CalculateHash(method, uri);
  281. if (!hash.isValid)
  282. return null;
  283. // Try to get a lock on the cache entity. Prevents other requests from updating or loading from it.
  284. if (!_database.TryAcquireWriteLock(hash, headers, context))
  285. {
  286. HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(BeginCache)} - Couldn't acquire write lock!", context);
  287. return null;
  288. }
  289. // Add or replace the "Date" header in the response if it is missing or invalid.
  290. // https://www.rfc-editor.org/rfc/rfc9110#section-6.6.1-8
  291. // A recipient with a clock that receives a response message without a Date header field
  292. // MUST record the time it was received and append a corresponding Date header field
  293. // to the message's header section if it is cached or forwarded downstream.
  294. var date = headers.GetFirstHeaderValue("date");
  295. if (string.IsNullOrEmpty(date) || !DateTime.TryParse(date, out var _))
  296. {
  297. // A recipient with a clock that receives a response with an invalid Date header field value
  298. // MAY replace that value with the time that response was received.
  299. headers.RemoveHeader("date");
  300. headers.AddHeader("date", DateTime.Now.ToString("R"));
  301. }
  302. Stream contentStream = null;
  303. try
  304. {
  305. // Create the cache directory if it does not exist.
  306. var hashDir = GetHashDirectory(hash);
  307. if (!HTTPManager.IOService.DirectoryExists(hashDir))
  308. HTTPManager.IOService.DirectoryCreate(hashDir);
  309. // Create and write the header cache file.
  310. using (var headStream = HTTPManager.IOService.CreateFileStream(GetHeaderPathFromHash(hash), FileStreamModes.Create))
  311. WriteHeaders(headStream, headers);
  312. // Create/open the content cache file.
  313. contentStream = HTTPManager.IOService.CreateFileStream(GetContentPathFromHash(hash), FileStreamModes.Create);
  314. }
  315. catch (Exception ex)
  316. {
  317. // Handle exceptions that may occur during cache file creation
  318. HTTPManager.Logger.Exception(nameof(HTTPCache), nameof(BeginCache), ex, context);
  319. contentStream?.Dispose();
  320. contentStream = null;
  321. // Delete the cache entry if an exception occurs.
  322. Delete(hash, context);
  323. }
  324. // Return an HTTPCacheContentWriter for writing response content to the cache.
  325. return new HTTPCacheContentWriter(this, hash, contentStream, contentLength, context);
  326. }
  327. /// <summary>
  328. /// Finalizes the caching process and takes appropriate actions based on the completion status.
  329. /// </summary>
  330. /// <param name="cacheResult">The <see cref="HTTPCacheContentWriter"/> instance representing the caching operation.</param>
  331. /// <param name="completedWithoutIssue">A boolean indicating whether the caching process completed without issues.</param>
  332. /// <param name="context">An optional logging context for debugging.</param>
  333. public void EndCache(HTTPCacheContentWriter cacheResult, bool completedWithoutIssue, LoggingContext context)
  334. {
  335. if (HTTPManager.Logger.IsDiagnostic)
  336. HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(EndCache)}({cacheResult}, {completedWithoutIssue})", context);
  337. if (cacheResult == null || !cacheResult.Hash.isValid || !_isSupported)
  338. return;
  339. var hash = cacheResult.Hash;
  340. cacheResult.Close();
  341. if (completedWithoutIssue)
  342. {
  343. _database.ReleaseWriteLock(hash, cacheResult.ProcessedLength, cacheResult.Context);
  344. IncrementCacheSize(cacheResult.ProcessedLength);
  345. }
  346. else
  347. {
  348. Delete(hash, cacheResult.Context);
  349. }
  350. }
  351. /// <summary>
  352. /// 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.
  353. /// </summary>
  354. /// <param name="hash">A hash from <see cref="HTTPCache.CalculateHash(HTTPMethods, Uri)"/> identifying the resource.</param>
  355. /// <param name="context">An optional <see cref="LoggingContext"/></param>
  356. /// <returns>A stream for reading the cached content, or null if the content could not be read (the resource isn't cached or currently downloading).</returns>
  357. public Stream BeginReadContent(Hash128 hash, LoggingContext context)
  358. {
  359. if (HTTPManager.Logger.IsDiagnostic)
  360. HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(BeginReadContent)}({hash})", context);
  361. if (!_isSupported)
  362. return null;
  363. if (!_database.TryAcquireReadLock(hash, context))
  364. return null;
  365. _database.UpdateLastAccessTime(hash, context);
  366. var contentPath = GetContentPathFromHash(hash);
  367. return HTTPManager.IOService.CreateFileStream(contentPath, FileStreamModes.OpenRead);
  368. }
  369. /// <summary>
  370. /// Finalizes the process of reading cached content associated with a given hash.
  371. /// </summary>
  372. /// <param name="hash">The unique hash identifier for the cached content.</param>
  373. /// <param name="context">An optional logging context for debugging.</param>
  374. public void EndReadContent(Hash128 hash, LoggingContext context)
  375. {
  376. if (HTTPManager.Logger.IsDiagnostic)
  377. HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(EndReadContent)}({hash})", context);
  378. if (!_isSupported)
  379. return;
  380. _database.ReleaseReadLock(hash, context);
  381. }
  382. /// <summary>
  383. /// Deletes a cached entry identified by the given hash, including its associated header and content files.
  384. /// </summary>
  385. /// <param name="hash">The unique hash identifier for the cached entry to be deleted.</param>
  386. /// <param name="context">An optional logging context for debugging.</param>
  387. public void Delete(Hash128 hash, LoggingContext context)
  388. {
  389. if (HTTPManager.Logger.IsDiagnostic)
  390. HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(Delete)}({hash})", context);
  391. if (!_isSupported)
  392. return;
  393. // Calling this function more than once with the same hash should be fine, the DB is locked and
  394. // only one will find the metadata.
  395. try
  396. {
  397. _database.EnterWriteLock(context);
  398. try
  399. {
  400. var headerPath = GetHeaderPathFromHash(hash);
  401. if (HTTPManager.IOService.FileExists(headerPath))
  402. HTTPManager.IOService.FileDelete(headerPath);
  403. var contentPath = GetContentPathFromHash(hash);
  404. if (HTTPManager.IOService.FileExists(contentPath))
  405. HTTPManager.IOService.FileDelete(contentPath);
  406. var hashDirectory = GetHashDirectory(hash);
  407. if (HTTPManager.IOService.DirectoryExists(hashDirectory))
  408. HTTPManager.IOService.DirectoryDelete(hashDirectory);
  409. }
  410. catch (Exception ex)
  411. {
  412. HTTPManager.Logger.Exception(nameof(HTTPCache), $"{nameof(Delete)}({hash})", ex, context);
  413. }
  414. DecrementCacheSize(_database.Delete(hash, context));
  415. }
  416. finally
  417. {
  418. _database.ExitWriteLock(context);
  419. }
  420. }
  421. /// <summary>
  422. /// Refreshes the headers of a cached HTTP response with new headers.
  423. /// </summary>
  424. /// <param name="hash">A unique hash identifier for the cached response from a <see cref="HTTPCache.CalculateHash(HTTPMethods, Uri)"/> call.</param>
  425. /// <param name="newHeaders">A dictionary of new headers to replace or merge with existing headers.</param>
  426. /// <param name="context">Used by the plugin to add an addition logging context for debugging. It can be <c>null</c>.</param>
  427. /// <returns><c>true</c> if the headers were successfully refreshed; otherwise, <c>false</c>.</returns>
  428. public bool RefreshHeaders(Hash128 hash, Dictionary<string, List<string>> newHeaders, LoggingContext context)
  429. {
  430. // To Refresh stored cache related values from the headers described here:
  431. // 1.) https://www.rfc-editor.org/rfc/rfc9111.html#name-freshening-stored-responses
  432. // 2.) https://www.rfc-editor.org/rfc/rfc9111.html#name-updating-stored-header-fiel
  433. if (HTTPManager.Logger.IsDiagnostic)
  434. HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(RefreshHeaders)}({hash}, {newHeaders?.Count})", context);
  435. if (!_isSupported)
  436. return false;
  437. // Log the new headers for debugging purposes.
  438. LogCachingHeaders(newHeaders, context);
  439. // Update the metadata with the new headers.
  440. if (_database.Update(hash, newHeaders, context))
  441. {
  442. // https://www.rfc-editor.org/rfc/rfc9111.html#name-updating-stored-header-fiel
  443. // Load stored header, merge them with the received ones and store them.
  444. try
  445. {
  446. using (var headerStream = HTTPManager.IOService.CreateFileStream(GetHeaderPathFromHash(hash), FileStreamModes.OpenReadWrite))
  447. {
  448. // Load existing headers.
  449. var oldHeaders = LoadHeaders(headerStream);
  450. foreach (var kvp in newHeaders)
  451. {
  452. if (oldHeaders.TryGetValue(kvp.Key, out var value))
  453. {
  454. // Replace existing header values with new values.
  455. value.Clear();
  456. value.AddRange(kvp.Value);
  457. }
  458. else
  459. {
  460. // Add new headers if they don't already exist.
  461. oldHeaders.Add(kvp.Key, value);
  462. }
  463. }
  464. // Seek to the beginning of the header file and write the updated headers.
  465. headerStream.Seek(0, SeekOrigin.Begin);
  466. headerStream.SetLength(0);
  467. WriteHeaders(headerStream, oldHeaders);
  468. }
  469. // Everything went as expected, return true
  470. return true;
  471. }
  472. catch (Exception ex)
  473. {
  474. HTTPManager.Logger.Warning(nameof(HTTPCache), $"{nameof(RefreshHeaders)} - Couldn't merge/store headers. Exception: {ex}", context);
  475. // Delete the cached response associated with the hash.
  476. Delete(hash, context);
  477. }
  478. }
  479. return false;
  480. }
  481. private bool IsCacheble(Dictionary<string, List<string>> headers)
  482. {
  483. if (!_isSupported)
  484. return false;
  485. // Responses with byte ranges not supported.
  486. var byteRanges = headers.GetHeaderValues("content-range");
  487. if (byteRanges != null)
  488. return false;
  489. //http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.2
  490. bool hasValidMaxAge = false;
  491. var cacheControls = headers.GetHeaderValues("cache-control");
  492. if (cacheControls != null)
  493. {
  494. // A local function that checks the header value for any indication that prohibits caching.
  495. // So, it must return TRUE, if it's NOT cachable.
  496. bool CheckHeader(string headerValue)
  497. {
  498. HeaderParser parser = new HeaderParser(headerValue);
  499. if (parser.Values != null && parser.Values.Count > 0)
  500. {
  501. for (int i = 0; i < parser.Values.Count; ++i)
  502. {
  503. var value = parser.Values[i];
  504. // https://csswizardry.com/2019/03/cache-control-for-civilians/#no-store
  505. if (value.Key == "no-store")
  506. return true;
  507. if (value.Key == "max-age" && value.HasValue)
  508. {
  509. double maxAge;
  510. if (double.TryParse(value.Value, out maxAge))
  511. {
  512. // A negative max-age value is a no cache
  513. if (maxAge <= 0)
  514. return true;
  515. hasValidMaxAge = true;
  516. }
  517. }
  518. }
  519. }
  520. return false;
  521. }
  522. if (cacheControls.Exists(CheckHeader))
  523. return false;
  524. }
  525. // It has an ETag header
  526. var etag = headers.GetFirstHeaderValue("etag");
  527. if (!string.IsNullOrEmpty(etag))
  528. return true;
  529. // It has an Expires header, and it's in the future
  530. var expires = headers.GetFirstHeaderValue("expires").ToDateTime(DateTime.FromBinary(0));
  531. if (expires > DateTime.Now)
  532. return true;
  533. // It has a Last-Modified header
  534. if (headers.GetFirstHeaderValue("last-modified") != null)
  535. return true;
  536. return hasValidMaxAge;
  537. }
  538. private bool IsCacheble(HTTPMethods method, Uri uri, int statusCode)
  539. {
  540. if (!_isSupported)
  541. return false;
  542. if (!uri.Scheme.StartsWith("http", StringComparison.OrdinalIgnoreCase))
  543. return false;
  544. if (method != HTTPMethods.Get)
  545. return false;
  546. // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/204
  547. if (statusCode != OK && statusCode != NoContent)
  548. return false;
  549. return true;
  550. }
  551. /// <summary>
  552. /// Checks whether the caches resource identified by the hash is can be served from the local store with the given error conditions.
  553. /// </summary>
  554. /// <remarks>This check reflects the very current state, even if it returns <c>true</c>, a request might just executing to get a write lock on it to refresh the content.</remarks>
  555. /// <param name="hash"><see cref="Hash128"/> hash returned by <see cref="HTTPCache.CalculateHash(HTTPMethods, Uri)"/> identifying a resource.</param>
  556. /// <param name="errorType">Possible error condition that can occur during validation. Servers can provision that certain stalled resources can be served if revalidation fails.</param>
  557. /// <param name="context">Used by the plugin to add an addition logging context for debugging. It can be <c>null</c>.</param>
  558. /// <returns><c>true</c> if the cached response can be served without validating it with the origin server; otherwise, <c>false</c></returns>
  559. public bool CanServeWithoutValidation(Hash128 hash, ErrorTypeForValidation errorType, LoggingContext context)
  560. {
  561. if (HTTPManager.Logger.IsDiagnostic)
  562. HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(CanServeWithoutValidation)}({hash}, {errorType})", context);
  563. if (!_isSupported || !hash.isValid)
  564. return false;
  565. (CacheMetadataContent content, CacheMetadata metadata) = (null, null);
  566. try
  567. {
  568. // Attempt to find the cached content and metadata for the given hash.
  569. (content, metadata) = _database.FindContentAndMetadataLocked(hash);
  570. if (content == null)
  571. return false;
  572. }
  573. catch(Exception ex)
  574. {
  575. HTTPManager.Logger.Exception(nameof(HTTPCache), $"{nameof(_database.FindContentAndMetadataLocked)}", ex, context);
  576. Delete(hash, context);
  577. return false;
  578. }
  579. //
  580. if (metadata.Lock == LockTypes.Write)
  581. return false;
  582. // Check if cache files associated with the hash exist.
  583. if (!AreCacheFilesExists(hash))
  584. {
  585. Delete(hash, context);
  586. return false;
  587. }
  588. if ((content.Flags & CacheFlags.NoCache) != 0)
  589. return false;
  590. // Calculate the current age of the cached content, described here:
  591. // 1.) https://www.rfc-editor.org/rfc/rfc9111.html#name-freshness
  592. // 2.) https://www.rfc-editor.org/rfc/rfc9111.html#name-calculating-age
  593. if (content.MaxAge > 0)
  594. {
  595. long current_age = content.Age;
  596. // If there are more than one requests accessing the same resource it's possible that the first one sets the RequestTime
  597. // but ResponseTime is the same old value while the second request tries to calculate the resrouce's Age. In this case,
  598. // we will just use the received Age.
  599. if (content.ResponseTime > content.RequestTime)
  600. {
  601. var apparent_age = Max(0, (int)(content.ResponseTime - content.Date).TotalSeconds);
  602. var response_delay = (int)(content.ResponseTime - content.RequestTime).TotalSeconds;
  603. var corrected_age_value = content.Age + response_delay;
  604. var corrected_initial_age = Max(apparent_age, corrected_age_value);
  605. var resident_time = DateTime.Now - content.ResponseTime;
  606. current_age = corrected_initial_age + (int)resident_time.TotalSeconds;
  607. }
  608. var maxAge = content.MaxAge;
  609. switch(errorType)
  610. {
  611. case ErrorTypeForValidation.None:
  612. // https://www.rfc-editor.org/rfc/rfc5861.html#section-1
  613. // The stale-while-revalidate HTTP Cache-Control extension allows a
  614. // cache to immediately return a stale response while it revalidates it
  615. // in the background, thereby hiding latency (both in the network and on
  616. // the server) from clients.
  617. // If it's stalled but there's a value set for StaleWhileRevalidate and it's fresh with its value
  618. if (current_age > maxAge && content.StaleWhileRevalidate > 0 && current_age <= (maxAge + content.StaleWhileRevalidate))
  619. {
  620. maxAge += content.StaleWhileRevalidate;
  621. // TODO: send revalidate request
  622. }
  623. break;
  624. case ErrorTypeForValidation.ServerError:
  625. case ErrorTypeForValidation.ConnectionError:
  626. // Handle stale-if-error caching extension:
  627. // https://www.rfc-editor.org/rfc/rfc5861.html#section-4
  628. if (content.StaleIfError > 0)
  629. maxAge += content.StaleIfError;
  630. break;
  631. }
  632. return current_age <= maxAge;
  633. }
  634. // Check if the content has not expired based on the 'Expires' header.
  635. return content.Expires > DateTime.Now;
  636. }
  637. /// <summary>
  638. /// Redirects a request to a cached entity.
  639. /// </summary>
  640. /// <param name="request">The <see cref="HTTPRequest"/> that will be redirected.</param>
  641. /// <param name="hash">Hash obtained from <see cref="HTTPCache.CalculateHash(HTTPMethods, Uri)"/>.</param>
  642. public void Redirect(HTTPRequest request, Hash128 hash)
  643. {
  644. if (HTTPManager.Logger.IsDiagnostic)
  645. HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(Redirect)}({request}, {hash})", request.Context);
  646. if (!_isSupported || request == null || !hash.isValid)
  647. return;
  648. // Redirect to the local cache
  649. request.RedirectSettings.RedirectUri = new Uri($"file://{CacheHostName}/{hash}");
  650. request.RedirectSettings.IsRedirected = true;
  651. }
  652. internal void Maintain(ulong contentLength, bool deleteLockedEntries, LoggingContext context)
  653. {
  654. if (!_isSupported)
  655. return;
  656. HTTPManager.Logger.Information(nameof(HTTPCache), $"Maintain({contentLength:N0}, {deleteLockedEntries}, {System.Threading.Thread.CurrentThread.ManagedThreadId})", context);
  657. if (HTTPUpdateDelegator.Instance.IsMainThread())
  658. ThreadedRunner.RunShortLiving<ulong, bool, DateTime, LoggingContext>(MaintainImplementation, contentLength, deleteLockedEntries, HTTPManager.CurrentFrameDateTime, context);
  659. else
  660. MaintainImplementation(contentLength, deleteLockedEntries, HTTPManager.CurrentFrameDateTime, context);
  661. }
  662. private void ZeroOutCacheSize()
  663. {
  664. //HTTPManager.Logger.Information(nameof(HTTPCache), $"CacheSize - ZeroOutCacheSize()");
  665. Interlocked.Exchange(ref this._cacheSize, 0);
  666. if (Interlocked.CompareExchange(ref this._subscribed, 1, 0) == 0)
  667. HTTPManager.Heartbeats.Subscribe(this);
  668. }
  669. private void IncrementCacheSize(ulong withSize)
  670. {
  671. //HTTPManager.Logger.Information(nameof(HTTPCache), $"CacheSize - IncrementCacheSize({withSize:N0}) => {Interlocked.Add(ref this._cacheSize, (long)withSize):N0}");
  672. Interlocked.Add(ref this._cacheSize, (long)withSize);
  673. if (Interlocked.CompareExchange(ref this._subscribed, 1, 0) == 0)
  674. HTTPManager.Heartbeats.Subscribe(this);
  675. }
  676. private void DecrementCacheSize(ulong withSize)
  677. {
  678. //HTTPManager.Logger.Information(nameof(HTTPCache), $"CacheSize - DecrementCacheSize({-(long)withSize:N0}) => {Interlocked.Add(ref this._cacheSize, -(long)withSize):N0}");
  679. Interlocked.Add(ref this._cacheSize, -(long)withSize);
  680. if (Interlocked.CompareExchange(ref this._subscribed, 1, 0) == 0)
  681. HTTPManager.Heartbeats.Subscribe(this);
  682. }
  683. private void MaintainImplementation(ulong contentLength, bool deleteLockedEntries, DateTime now, LoggingContext context)
  684. {
  685. List<Hash128> markedForDelete = null;
  686. // lock down the whole database
  687. _database.EnterWriteLock(null);
  688. ZeroOutCacheSize();
  689. try
  690. {
  691. var deleteOlderDT = Options.DeleteOlder == TimeSpan.MaxValue ? DateTime.MinValue : now - Options.DeleteOlder;
  692. // Go through hashes in the DB metadata and compare them to the directory names in the cache folder
  693. // delete those that aren't in the DB/file system.
  694. for (int i = 0; i < _database.MetadataService.Metadatas.Count; ++i)
  695. {
  696. var metadata = _database.MetadataService.Metadatas[i];
  697. // When Maintain first called on startup, we can search for locked entries.
  698. // An entry can remeain write locked if the process is terminated unexpectedly while a download is in progress.
  699. // By deleting it here we can prevent serving incomplete content.
  700. bool isIncomplete = deleteLockedEntries && metadata.Lock != LockTypes.Unlocked;
  701. if (isIncomplete)
  702. HTTPManager.Logger.Warning(nameof(HTTPCache), $"Incomplete cache entry({metadata}) found!", context);
  703. bool isAnyFileMissing = !AreCacheFilesExists(metadata.Hash) && metadata.Lock == LockTypes.Unlocked;
  704. if (isAnyFileMissing || isIncomplete || metadata.LastAccessTime <= deleteOlderDT)
  705. {
  706. if (markedForDelete == null)
  707. markedForDelete = new List<Hash128>();
  708. markedForDelete.Add(metadata.Hash);
  709. metadata.MarkForDelete();
  710. }
  711. else
  712. {
  713. IncrementCacheSize(metadata.ContentLength);
  714. }
  715. }
  716. var sortedMetadatas = new List<CacheMetadata>(_database.MetadataService.Metadatas);
  717. sortedMetadatas.Sort((x, y) => x.LastAccessTime.CompareTo(y.LastAccessTime));
  718. var cacheSize = CacheSize;
  719. var targetCacheSize = (long)(Options.MaxCacheSize - contentLength);
  720. for (int i = 0; i < sortedMetadatas.Count && cacheSize > targetCacheSize; ++i)
  721. {
  722. var metadata = sortedMetadatas[i];
  723. // already marked for deletion
  724. if (metadata.IsDeleted)
  725. continue;
  726. // is in use
  727. if (metadata.Lock != LockTypes.Unlocked)
  728. continue;
  729. if (markedForDelete == null)
  730. markedForDelete = new List<Hash128>();
  731. markedForDelete.Add(metadata.Hash);
  732. cacheSize -= (long)metadata.ContentLength;
  733. }
  734. }
  735. finally
  736. {
  737. _database.ExitWriteLock(null);
  738. }
  739. if (markedForDelete != null)
  740. {
  741. HTTPManager.Logger.Information(nameof(HTTPCache), $"Maintain - collected {markedForDelete.Count} entries for deletion!", context);
  742. foreach (Hash128 key in markedForDelete)
  743. Delete(key, context);
  744. markedForDelete.Clear();
  745. }
  746. else
  747. HTTPManager.Logger.Information(nameof(HTTPCache), "Maintain - collected 0 entries for deletion!", context);
  748. }
  749. private static void WriteHeaders(Stream headerStream, Dictionary<string, List<string>> headers)
  750. {
  751. if (headerStream == null || headers == null)
  752. return;
  753. foreach (var kvp in headers)
  754. {
  755. // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-header-and-trailer-
  756. // TODO: expand the no-save list
  757. if (kvp.Key.Equals("alt-svc", StringComparison.OrdinalIgnoreCase) ||
  758. kvp.Key.Equals("content-encoding", StringComparison.OrdinalIgnoreCase) ||
  759. kvp.Key.Equals("transfer-encoding", StringComparison.OrdinalIgnoreCase) ||
  760. kvp.Key.Equals("connection", StringComparison.OrdinalIgnoreCase) ||
  761. kvp.Key.Equals("proxy-authenticate", StringComparison.OrdinalIgnoreCase) ||
  762. kvp.Key.Equals("content-length", StringComparison.OrdinalIgnoreCase))
  763. continue;
  764. if (kvp.Value == null)
  765. {
  766. headerStream.WriteString(kvp.Key);
  767. headerStream.WriteString(":");
  768. headerStream.WriteString(string.Empty);
  769. headerStream.WriteLine();
  770. continue;
  771. }
  772. foreach (var value in kvp.Value)
  773. {
  774. headerStream.WriteString(kvp.Key);
  775. headerStream.WriteString(":");
  776. headerStream.WriteString(value);
  777. headerStream.WriteLine();
  778. }
  779. }
  780. headerStream.WriteLine();
  781. headerStream.Flush();
  782. }
  783. private static Dictionary<string, List<string>> LoadHeaders(Stream headersStream)
  784. {
  785. var result = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
  786. string headerName = HTTPResponse.ReadTo(headersStream, (byte)':', LF);
  787. while (headerName != string.Empty)
  788. {
  789. string value = HTTPResponse.ReadTo(headersStream, LF);
  790. result.AddHeader(headerName, value);
  791. headerName = HTTPResponse.ReadTo(headersStream, (byte)':', LF);
  792. }
  793. return result;
  794. }
  795. public void Dispose()
  796. {
  797. HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(Dispose)}");
  798. ZeroOutCacheSize();
  799. try
  800. {
  801. _database?.Dispose();
  802. _database = null;
  803. }
  804. catch (Exception ex)
  805. {
  806. HTTPManager.Logger.Exception(nameof(HTTPCache), $"{nameof(Dispose)}", ex);
  807. }
  808. HTTPManager.Logger.Information(nameof(HTTPCache), $"{nameof(Dispose)} - Disposed!");
  809. }
  810. private static void LogCachingHeaders(Dictionary<string, List<string>> headers, LoggingContext context)
  811. {
  812. if (!HTTPManager.Logger.IsDiagnostic)
  813. return;
  814. var etag = headers.GetFirstHeaderValue("etag");
  815. var expires = headers.GetFirstHeaderValue("expires");
  816. var lastModified = headers.GetFirstHeaderValue("last-modified");
  817. var age = headers.GetFirstHeaderValue("age");
  818. var date = headers.GetFirstHeaderValue("date");
  819. var cacheControl = headers.GetFirstHeaderValue("cache-control");
  820. if (etag != null)
  821. HTTPManager.Logger.Verbose(nameof(HTTPCache), "ETag: " + etag, context);
  822. if (expires != null)
  823. HTTPManager.Logger.Verbose(nameof(HTTPCache), "Expires: " + expires, context);
  824. if (lastModified != null)
  825. HTTPManager.Logger.Verbose(nameof(HTTPCache), "Last-Modified: " + lastModified, context);
  826. if (age != null)
  827. HTTPManager.Logger.Verbose(nameof(HTTPCache), "Age: " + age, context);
  828. if (date != null)
  829. HTTPManager.Logger.Verbose(nameof(HTTPCache), "Date: " + date, context);
  830. if (cacheControl != null)
  831. HTTPManager.Logger.Verbose(nameof(HTTPCache), "Cache-Control: " + cacheControl, context);
  832. }
  833. /// <summary>
  834. /// Clears the HTTP cache by removing all cached entries and associated metadata.
  835. /// </summary>
  836. public void Clear()
  837. {
  838. if (!_isSupported)
  839. return;
  840. //_database.EnterWriteLock(null);
  841. try
  842. {
  843. var copyOfMetadatas = new List<CacheMetadata>(_database.MetadataService.Metadatas);
  844. foreach (var metadata in copyOfMetadatas)
  845. Delete(metadata.Hash, null);
  846. }
  847. finally
  848. {
  849. //_database.ExitWriteLock(null);
  850. }
  851. }
  852. void IHeartbeat.OnHeartbeatUpdate(DateTime now, TimeSpan dif)
  853. {
  854. try
  855. {
  856. this.OnCacheSizeChanged?.Invoke();
  857. }
  858. catch(Exception ex)
  859. {
  860. HTTPManager.Logger.Exception(nameof(HTTPCache), "OnCacheSizeChanged", ex, null);
  861. }
  862. HTTPManager.Heartbeats.Unsubscribe(this);
  863. Interlocked.Exchange(ref this._subscribed, 0);
  864. }
  865. }
  866. }