Add 30 minute cache to GitHub releases requests

Closes #1831
This commit is contained in:
Jordan Dominion
2024-08-10 17:38:27 -04:00
parent a7789a04dd
commit bd1deb0a9b
9 changed files with 87 additions and 35 deletions
+3 -3
View File
@@ -5,10 +5,10 @@
<PropertyGroup>
<TgsCoreVersion>6.8.0</TgsCoreVersion>
<TgsConfigVersion>5.1.0</TgsConfigVersion>
<TgsApiVersion>10.6.0</TgsApiVersion>
<TgsApiVersion>10.7.0</TgsApiVersion>
<TgsCommonLibraryVersion>7.0.0</TgsCommonLibraryVersion>
<TgsApiLibraryVersion>13.6.0</TgsApiLibraryVersion>
<TgsClientVersion>15.6.0</TgsClientVersion>
<TgsApiLibraryVersion>13.7.0</TgsApiLibraryVersion>
<TgsClientVersion>16.0.0</TgsClientVersion>
<TgsDmapiVersion>7.2.0</TgsDmapiVersion>
<TgsInteropVersion>5.9.0</TgsInteropVersion>
<TgsHostWatchdogVersion>1.4.1</TgsHostWatchdogVersion>
@@ -16,5 +16,10 @@ namespace Tgstation.Server.Api.Models.Response
/// The latest available version of the Tgstation.Server.Host assembly from the upstream repository. If <see cref="Version.Major"/> is not equal to 4 the update cannot be applied due to API changes.
/// </summary>
public Version? LatestVersion { get; set; }
/// <summary>
/// This response is cached. This field indicates the <see cref="DateTimeOffset"/> when it was generated.
/// </summary>
public DateTimeOffset? GeneratedAt { get; set; }
}
}
@@ -24,7 +24,7 @@ namespace Tgstation.Server.Client
}
/// <inheritdoc />
public ValueTask<AdministrationResponse> Read(CancellationToken cancellationToken) => ApiClient.Read<AdministrationResponse>(Routes.Administration, cancellationToken);
public ValueTask<AdministrationResponse> Read(bool forceFresh, CancellationToken cancellationToken) => ApiClient.Read<AdministrationResponse>($"{Routes.Administration}?fresh={forceFresh}", cancellationToken);
/// <inheritdoc />
public async ValueTask<ServerUpdateResponse> Update(
@@ -17,9 +17,10 @@ namespace Tgstation.Server.Client
/// <summary>
/// Get the <see cref="AdministrationResponse"/> represented by the <see cref="IAdministrationClient"/>.
/// </summary>
/// <param name="forceFresh">If <see langword="true"/> the response will be forcefully regenerated.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="AdministrationResponse"/> represented by the <see cref="IAdministrationClient"/>.</returns>
ValueTask<AdministrationResponse> Read(CancellationToken cancellationToken);
ValueTask<AdministrationResponse> Read(bool forceFresh = false, CancellationToken cancellationToken = default);
/// <summary>
/// Updates the <see cref="AdministrationResponse"/> setttings.
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using System.Web;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -42,6 +43,11 @@ namespace Tgstation.Server.Host.Controllers
/// </summary>
const string OctokitException = "Bad GitHub API response, check configuration!";
/// <summary>
/// The <see cref="IMemoryCache"/> key for <see cref="Read(bool?, CancellationToken)"/>.
/// </summary>
static readonly object ReadCacheKey = new();
/// <summary>
/// The <see cref="IGitHubServiceFactory"/> for the <see cref="AdministrationController"/>.
/// </summary>
@@ -77,6 +83,11 @@ namespace Tgstation.Server.Host.Controllers
/// </summary>
readonly IFileTransferTicketProvider fileTransferService;
/// <summary>
/// The <see cref="IMemoryCache"/> for the <see cref="AdministrationController"/>.
/// </summary>
readonly IMemoryCache cacheService;
/// <summary>
/// The <see cref="FileLoggingConfiguration"/> for the <see cref="AdministrationController"/>.
/// </summary>
@@ -94,6 +105,7 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="ioManager">The value of <see cref="ioManager"/>.</param>
/// <param name="platformIdentifier">The value of <see cref="platformIdentifier"/>.</param>
/// <param name="fileTransferService">The value of <see cref="fileTransferService"/>.</param>
/// <param name="cacheService">The value of <see cref="cacheService"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/>.</param>
/// <param name="fileLoggingConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing value of <see cref="fileLoggingConfiguration"/>.</param>
/// <param name="apiHeadersProvider">The <see cref="IApiHeadersProvider"/> for the <see cref="ApiController"/>.</param>
@@ -107,6 +119,7 @@ namespace Tgstation.Server.Host.Controllers
IIOManager ioManager,
IPlatformIdentifier platformIdentifier,
IFileTransferTicketProvider fileTransferService,
IMemoryCache cacheService,
ILogger<AdministrationController> logger,
IOptions<FileLoggingConfiguration> fileLoggingConfigurationOptions,
IApiHeadersProvider apiHeadersProvider)
@@ -124,12 +137,14 @@ namespace Tgstation.Server.Host.Controllers
this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier));
this.fileTransferService = fileTransferService ?? throw new ArgumentNullException(nameof(fileTransferService));
this.cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService));
fileLoggingConfiguration = fileLoggingConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(fileLoggingConfigurationOptions));
}
/// <summary>
/// Get <see cref="AdministrationResponse"/> server information.
/// </summary>
/// <param name="fresh">If <see langword="true"/>, the cache should be bypassed.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
/// <response code="200">Retrieved <see cref="AdministrationResponse"/> data successfully.</response>
@@ -140,39 +155,56 @@ namespace Tgstation.Server.Host.Controllers
[ProducesResponseType(typeof(AdministrationResponse), 200)]
[ProducesResponseType(typeof(ErrorMessageResponse), 424)]
[ProducesResponseType(typeof(ErrorMessageResponse), 429)]
public async ValueTask<IActionResult> Read(CancellationToken cancellationToken)
public async ValueTask<IActionResult> Read([FromQuery] bool? fresh, CancellationToken cancellationToken)
{
try
{
Version? greatestVersion = null;
Uri? repoUrl = null;
try
async Task<JsonResult> CacheFactory()
{
var gitHubService = gitHubServiceFactory.CreateService();
var repositoryUrlTask = gitHubService.GetUpdatesRepositoryUrl(cancellationToken);
var releases = await gitHubService.GetTgsReleases(cancellationToken);
foreach (var kvp in releases)
Version? greatestVersion = null;
Uri? repoUrl = null;
try
{
var version = kvp.Key;
var release = kvp.Value;
if (version.Major > 3 // Forward/backward compatible but not before TGS4
&& (greatestVersion == null || version > greatestVersion))
greatestVersion = version;
var gitHubService = gitHubServiceFactory.CreateService();
var repositoryUrlTask = gitHubService.GetUpdatesRepositoryUrl(cancellationToken);
var releases = await gitHubService.GetTgsReleases(cancellationToken);
foreach (var kvp in releases)
{
var version = kvp.Key;
var release = kvp.Value;
if (version.Major > 3 // Forward/backward compatible but not before TGS4
&& (greatestVersion == null || version > greatestVersion))
greatestVersion = version;
}
repoUrl = await repositoryUrlTask;
}
catch (NotFoundException e)
{
Logger.LogWarning(e, "Not found exception while retrieving upstream repository info!");
}
repoUrl = await repositoryUrlTask;
}
catch (NotFoundException e)
{
Logger.LogWarning(e, "Not found exception while retrieving upstream repository info!");
return Json(new AdministrationResponse
{
LatestVersion = greatestVersion,
TrackedRepositoryUrl = repoUrl,
GeneratedAt = DateTimeOffset.UtcNow,
});
}
return Json(new AdministrationResponse
var ttl = TimeSpan.FromMinutes(30);
Task<JsonResult> task;
if (fresh == true || !cacheService.TryGetValue(ReadCacheKey, out var rawCacheObject))
{
LatestVersion = greatestVersion,
TrackedRepositoryUrl = repoUrl,
});
using var entry = cacheService.CreateEntry(ReadCacheKey);
entry.AbsoluteExpirationRelativeToNow = ttl;
entry.Value = task = CacheFactory();
}
else
task = (Task<JsonResult>)rawCacheObject!;
return await task;
}
catch (RateLimitExceededException e)
{
@@ -1,10 +1,10 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Client;
@@ -54,10 +54,24 @@ namespace Tgstation.Server.Tests.Live
async Task TestRead(CancellationToken cancellationToken)
{
var model = await client.Read(cancellationToken);
var model = await client.Read(false, cancellationToken);
//we've released a few 5.x versions now, check the release checker is at least somewhat functional
Assert.IsTrue(4 < model.LatestVersion.Major);
Assert.IsNotNull(model.TrackedRepositoryUrl);
Assert.IsTrue(model.GeneratedAt.HasValue);
Assert.IsTrue(model.GeneratedAt.Value <= DateTimeOffset.UtcNow);
// test the cache
var newerModel = await client.Read(false, cancellationToken);
Assert.AreEqual(model.GeneratedAt, newerModel.GeneratedAt);
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
var newestModel = await client.Read(true, cancellationToken);
Assert.AreNotEqual(model.GeneratedAt, newestModel.GeneratedAt);
Assert.IsNotNull(newestModel.GeneratedAt);
Assert.IsTrue(model.GeneratedAt < newestModel.GeneratedAt);
}
}
}
@@ -262,7 +262,7 @@ namespace Tgstation.Server.Tests.Live.Instance
Assert.AreEqual(HubConnectionState.Disconnected, permlessConn.State);
// force token refreshs
await Task.WhenAll(permedUser.Administration.Read(cancellationToken).AsTask(), permlessUser.Instances.List(null, cancellationToken).AsTask());
await Task.WhenAll(permedUser.Administration.Read(false, cancellationToken).AsTask(), permlessUser.Instances.List(null, cancellationToken).AsTask());
if (!permlessPsId.HasValue)
{
@@ -216,7 +216,7 @@ namespace Tgstation.Server.Tests.Live
};
var badClient = clientFactory.CreateFromToken(serverClient.Url, newToken);
await ApiAssert.ThrowsException<UnauthorizedException, AdministrationResponse>(() => badClient.Administration.Read(cancellationToken));
await ApiAssert.ThrowsException<UnauthorizedException, AdministrationResponse>(() => badClient.Administration.Read(false, cancellationToken));
await ApiAssert.ThrowsException<UnauthorizedException, ServerInformationResponse>(() => badClient.ServerInformation(cancellationToken));
}
@@ -670,7 +670,7 @@ namespace Tgstation.Server.Tests.Live
"asdfasdfasdfasdf");
await using var node1BadClient = clientFactory.CreateFromToken(node1.RootUrl, controllerUserClient.Token);
await ApiAssert.ThrowsException<UnauthorizedException, AdministrationResponse>(() => node1BadClient.Administration.Read(cancellationToken));
await ApiAssert.ThrowsException<UnauthorizedException, AdministrationResponse>(() => node1BadClient.Administration.Read(false, cancellationToken));
// check instance info is not shared
var controllerInstance = await controllerClient.Instances.CreateOrAttach(