diff --git a/build/Version.props b/build/Version.props index 74c77f27a7..6e45564094 100644 --- a/build/Version.props +++ b/build/Version.props @@ -5,10 +5,10 @@ 6.8.0 5.1.0 - 10.6.0 + 10.7.0 7.0.0 - 13.6.0 - 15.6.0 + 13.7.0 + 16.0.0 7.2.0 5.9.0 1.4.1 diff --git a/src/Tgstation.Server.Api/Models/Response/AdministrationResponse.cs b/src/Tgstation.Server.Api/Models/Response/AdministrationResponse.cs index 55d7f646e5..2db5d354a7 100644 --- a/src/Tgstation.Server.Api/Models/Response/AdministrationResponse.cs +++ b/src/Tgstation.Server.Api/Models/Response/AdministrationResponse.cs @@ -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 is not equal to 4 the update cannot be applied due to API changes. /// public Version? LatestVersion { get; set; } + + /// + /// This response is cached. This field indicates the when it was generated. + /// + public DateTimeOffset? GeneratedAt { get; set; } } } diff --git a/src/Tgstation.Server.Client/AdministrationClient.cs b/src/Tgstation.Server.Client/AdministrationClient.cs index 2d6841795c..68c7fecd34 100644 --- a/src/Tgstation.Server.Client/AdministrationClient.cs +++ b/src/Tgstation.Server.Client/AdministrationClient.cs @@ -24,7 +24,7 @@ namespace Tgstation.Server.Client } /// - public ValueTask Read(CancellationToken cancellationToken) => ApiClient.Read(Routes.Administration, cancellationToken); + public ValueTask Read(bool forceFresh, CancellationToken cancellationToken) => ApiClient.Read($"{Routes.Administration}?fresh={forceFresh}", cancellationToken); /// public async ValueTask Update( diff --git a/src/Tgstation.Server.Client/IAdministrationClient.cs b/src/Tgstation.Server.Client/IAdministrationClient.cs index 38d0898345..6d5d88f951 100644 --- a/src/Tgstation.Server.Client/IAdministrationClient.cs +++ b/src/Tgstation.Server.Client/IAdministrationClient.cs @@ -17,9 +17,10 @@ namespace Tgstation.Server.Client /// /// Get the represented by the . /// + /// If the response will be forcefully regenerated. /// The for the operation. /// A resulting in the represented by the . - ValueTask Read(CancellationToken cancellationToken); + ValueTask Read(bool forceFresh = false, CancellationToken cancellationToken = default); /// /// Updates the setttings. diff --git a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs index 2ccc4f4c8b..d4c1335df7 100644 --- a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs +++ b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs @@ -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 /// const string OctokitException = "Bad GitHub API response, check configuration!"; + /// + /// The key for . + /// + static readonly object ReadCacheKey = new(); + /// /// The for the . /// @@ -77,6 +83,11 @@ namespace Tgstation.Server.Host.Controllers /// readonly IFileTransferTicketProvider fileTransferService; + /// + /// The for the . + /// + readonly IMemoryCache cacheService; + /// /// The for the . /// @@ -94,6 +105,7 @@ namespace Tgstation.Server.Host.Controllers /// The value of . /// The value of . /// The value of . + /// The value of . /// The for the . /// The containing value of . /// The for the . @@ -107,6 +119,7 @@ namespace Tgstation.Server.Host.Controllers IIOManager ioManager, IPlatformIdentifier platformIdentifier, IFileTransferTicketProvider fileTransferService, + IMemoryCache cacheService, ILogger logger, IOptions 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)); } /// /// Get server information. /// + /// If , the cache should be bypassed. /// The for the operation. /// A resulting in the for the operation. /// Retrieved data successfully. @@ -140,39 +155,56 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(typeof(AdministrationResponse), 200)] [ProducesResponseType(typeof(ErrorMessageResponse), 424)] [ProducesResponseType(typeof(ErrorMessageResponse), 429)] - public async ValueTask Read(CancellationToken cancellationToken) + public async ValueTask Read([FromQuery] bool? fresh, CancellationToken cancellationToken) { try { - Version? greatestVersion = null; - Uri? repoUrl = null; - try + async Task 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 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)rawCacheObject!; + + return await task; } catch (RateLimitExceededException e) { diff --git a/tests/Tgstation.Server.Tests/Live/AdministrationTest.cs b/tests/Tgstation.Server.Tests/Live/AdministrationTest.cs index adfb009d69..afeabef6f5 100644 --- a/tests/Tgstation.Server.Tests/Live/AdministrationTest.cs +++ b/tests/Tgstation.Server.Tests/Live/AdministrationTest.cs @@ -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); } } } diff --git a/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs b/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs index b3149556e5..70b7e9c030 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs @@ -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) { diff --git a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs index 3e20609e60..30e6b9e78e 100644 --- a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs +++ b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs @@ -216,7 +216,7 @@ namespace Tgstation.Server.Tests.Live }; var badClient = clientFactory.CreateFromToken(serverClient.Url, newToken); - await ApiAssert.ThrowsException(() => badClient.Administration.Read(cancellationToken)); + await ApiAssert.ThrowsException(() => badClient.Administration.Read(false, cancellationToken)); await ApiAssert.ThrowsException(() => badClient.ServerInformation(cancellationToken)); } diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index bfc4538c68..b34b14f066 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -670,7 +670,7 @@ namespace Tgstation.Server.Tests.Live "asdfasdfasdfasdf"); await using var node1BadClient = clientFactory.CreateFromToken(node1.RootUrl, controllerUserClient.Token); - await ApiAssert.ThrowsException(() => node1BadClient.Administration.Read(cancellationToken)); + await ApiAssert.ThrowsException(() => node1BadClient.Administration.Read(false, cancellationToken)); // check instance info is not shared var controllerInstance = await controllerClient.Instances.CreateOrAttach(