Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion GenHub/GenHub.Core/Constants/CasDefaults.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,10 @@ public static class CasDefaults
/// Default garbage collection grace period in days.
/// </summary>
public const int GcGracePeriodDays = 7;
}

/// <summary>
/// Explains why destructive CAS garbage collection is currently unavailable.
/// </summary>
public const string GarbageCollectionDisabledMessage =
"CAS garbage collection is disabled until complete reachability tracking is proven safe. No CAS blobs were deleted.";
}
6 changes: 3 additions & 3 deletions GenHub/GenHub.Core/Interfaces/Storage/ICasLifecycleManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,13 @@ Task<OperationResult<BulkUntrackResult>> UntrackManifestsAsync(
CancellationToken cancellationToken = default);

/// <summary>
/// Runs garbage collection immediately.
/// Should only be called AFTER all untrack operations are complete.
/// Requests garbage collection.
/// Destructive collection is currently disabled until reachability tracking is proven complete.
/// </summary>
/// <param name="force">Whether to force collection regardless of grace period.</param>
/// <param name="lockTimeout">Optional timeout to wait for the GC lock. Defaults to 5 seconds if not specified.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result with GC statistics.</returns>
/// <returns>A disabled result with zero deletion statistics.</returns>
Task<OperationResult<GarbageCollectionStats>> RunGarbageCollectionAsync(
bool force = false,
TimeSpan? lockTimeout = null,
Expand Down
5 changes: 3 additions & 2 deletions GenHub/GenHub.Core/Interfaces/Storage/ICasService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,12 @@ public interface ICasService
Task<OperationResult<Stream>> OpenContentStreamAsync(string hash, CancellationToken cancellationToken = default);

/// <summary>
/// Runs garbage collection to remove unreferenced content.
/// Requests garbage collection of unreferenced content.
/// Destructive collection is currently disabled until reachability tracking is proven complete.
/// </summary>
/// <param name="force">If true, ignores the grace period and deletes all unreferenced objects immediately.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The result of the garbage collection operation.</returns>
/// <returns>A clear disabled result; no CAS blobs are deleted.</returns>
Task<CasGarbageCollectionResult> RunGarbageCollectionAsync(bool force = false, CancellationToken cancellationToken = default);

/// <summary>
Expand Down
6 changes: 6 additions & 0 deletions GenHub/GenHub.Core/Models/Content/ContentRemovalResult.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;

namespace GenHub.Core.Models.Content;

Expand Down Expand Up @@ -36,4 +37,9 @@ public record ContentRemovalResult
/// Gets the duration of the operation.
/// </summary>
public TimeSpan Duration { get; init; }

/// <summary>
/// Gets non-fatal warnings reported during removal.
/// </summary>
public IReadOnlyList<string> Warnings { get; init; } = [];
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
using GenHub.Core.Constants;

namespace GenHub.Core.Models.Results.CAS;

/// <summary>
/// Result of a CAS garbage collection operation.
/// </summary>
public class CasGarbageCollectionResult : ResultBase
{
/// <summary>
/// Creates the fail-closed result returned while destructive garbage collection is disabled.
/// </summary>
/// <returns>A disabled result that reports zero deletion.</returns>
public static CasGarbageCollectionResult CreateDisabled()
{
return new CasGarbageCollectionResult(
false,
CasDefaults.GarbageCollectionDisabledMessage)
{
Disabled = true,
};
}

/// <summary>
/// Initializes a new instance of the <see cref="CasGarbageCollectionResult"/> class.
/// </summary>
Expand Down Expand Up @@ -39,6 +55,11 @@ public CasGarbageCollectionResult(bool success, string? error = null, TimeSpan e
/// <summary>Gets or sets the number of objects that were referenced and kept.</summary>
public int ObjectsReferenced { get; set; }

/// <summary>
/// Gets a value indicating whether destructive garbage collection is disabled.
/// </summary>
public bool Disabled { get; init; }

/// <summary>Gets the percentage of storage freed.</summary>
public double PercentageFreed
{
Expand All @@ -63,4 +84,4 @@ public double PercentageFreed
return (double)ObjectsDeleted / ObjectsScanned * 100;
}
}
}
}
20 changes: 20 additions & 0 deletions GenHub/GenHub.Core/Models/Storage/GarbageCollectionStats.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ public record GarbageCollectionStats
/// </summary>
public bool InProgress { get; init; }

/// <summary>
/// Gets a value indicating whether collection was blocked because destructive GC is disabled.
/// </summary>
public bool Disabled { get; init; }

/// <summary>
/// Gets a static instance representing a skipped GC operation.
/// </summary>
Expand Down Expand Up @@ -69,4 +74,19 @@ public record GarbageCollectionStats
Skipped = true,
InProgress = true,
};

/// <summary>
/// Gets a static instance representing fail-closed disabled garbage collection.
/// </summary>
public static GarbageCollectionStats DisabledResult { get; } = new()
{
ObjectsScanned = 0,
ObjectsReferenced = 0,
ObjectsDeleted = 0,
BytesFreed = 0,
Duration = TimeSpan.Zero,
Skipped = true,
InProgress = false,
Disabled = true,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using GenHub.Core.Models.GameProfile;
using GenHub.Core.Models.Manifest;
using GenHub.Core.Models.Results;
using GenHub.Core.Models.Results.CAS;
using GenHub.Core.Models.Storage;
using GenHub.Core.Models.Workspace;
using GenHub.Features.AppUpdate.Interfaces;
Expand Down Expand Up @@ -338,7 +339,7 @@ public void Constructor_HandlesUserSettingsServiceException()
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous test operation.</returns>
[Fact]
public async Task DeleteCasStorageCommand_CallsService()
public async Task DeleteCasStorageCommand_ReportsGarbageCollectionIsDisabled()
{
// Arrange
// Setup stats to return valid data so update method works
Expand All @@ -350,6 +351,9 @@ public async Task DeleteCasStorageCommand_CallsService()
.ReturnsAsync(OperationResult<IEnumerable<WorkspaceInfo>>.CreateSuccess([]));
_mockProfileManager.Setup(x => x.GetAllProfilesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(ProfileOperationResult<IReadOnlyList<GameProfile>>.CreateSuccess([]));
_mockCasService
.Setup(x => x.RunGarbageCollectionAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(CasGarbageCollectionResult.CreateDisabled());

var viewModel = new SettingsViewModel(
_mockConfigService.Object,
Expand All @@ -370,6 +374,20 @@ public async Task DeleteCasStorageCommand_CallsService()

// Assert
_mockCasService.Verify(x => x.RunGarbageCollectionAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Once);
_mockNotificationService.Verify(
service => service.ShowInfo(
"CAS Cleanup Disabled",
CasDefaults.GarbageCollectionDisabledMessage,
(int)TimeIntervals.NotificationHideDelay.TotalMilliseconds,
It.IsAny<bool>()),
Times.Once);
_mockNotificationService.Verify(
service => service.ShowSuccess(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<int?>(),
It.IsAny<bool>()),
Times.Never);
}

/// <summary>
Expand Down Expand Up @@ -400,4 +418,4 @@ public async Task UninstallGenHubCommand_CallsService()
// Assert
_mockUpdateManager.Verify(x => x.Uninstall(), Times.Once);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
using GenHub.Core.Constants;
using GenHub.Core.Interfaces.Content;
using GenHub.Core.Interfaces.Manifest;
using GenHub.Core.Interfaces.Storage;
using GenHub.Core.Models.Content;
using GenHub.Core.Models.Results;
using GenHub.Core.Models.Storage;
using GenHub.Features.Content.Services.Reconciliation;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;

namespace GenHub.Tests.Core.Features.Reconciliation;

/// <summary>
/// Verifies that reconciliation reports disabled garbage collection without failing
/// otherwise-successful manifest operations.
/// </summary>
public class ContentReconciliationOrchestratorGarbageCollectionTests
{
/// <summary>
/// Verifies that replacement results expose the disabled-GC warning.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous test.</returns>
[Fact]
public async Task ExecuteContentReplacementAsync_WhenGcDisabled_ReturnsWarning()
{
var reconciliationService = new Mock<IContentReconciliationService>();
reconciliationService
.Setup(service => service.OrchestrateBulkUpdateAsync(
It.IsAny<IReadOnlyDictionary<string, string>>(),
false,
It.IsAny<CancellationToken>()))
.ReturnsAsync(OperationResult<ReconciliationResult>.CreateSuccess(
ReconciliationResult.Empty));

var auditEntries = new List<ReconciliationAuditEntry>();
var orchestrator = CreateOrchestrator(
reconciliationService,
CreateDisabledLifecycleManager(),
auditEntries);
var request = new ContentReplacementRequest
{
ManifestMapping = new Dictionary<string, string>
{
["1.0.publisher.mod.old"] = "2.0.publisher.mod.new",
},
RemoveOldManifests = false,
};

var result = await orchestrator.ExecuteContentReplacementAsync(request);

Assert.True(result.Success);
Assert.NotNull(result.Data);
Assert.Contains(CasDefaults.GarbageCollectionDisabledMessage, result.Data.Warnings);
Assert.Equal(0, result.Data.CasObjectsCollected);
Assert.Equal(0, result.Data.BytesFreed);
var auditEntry = Assert.Single(auditEntries);
Assert.NotNull(auditEntry.Metadata);
Assert.Contains(CasDefaults.GarbageCollectionDisabledMessage, auditEntry.Metadata["warnings"]);
}

/// <summary>
/// Verifies that removal results expose the disabled-GC warning.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous test.</returns>
[Fact]
public async Task ExecuteContentRemovalAsync_WhenGcDisabled_ReturnsWarning()
{
var reconciliationService = new Mock<IContentReconciliationService>();
var lifecycleManager = CreateDisabledLifecycleManager();
lifecycleManager
.Setup(manager => manager.UntrackManifestsAsync(
It.IsAny<IEnumerable<string>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(OperationResult<BulkUntrackResult>.CreateSuccess(
new BulkUntrackResult(0, 0, [])));

var auditEntries = new List<ReconciliationAuditEntry>();
var orchestrator = CreateOrchestrator(reconciliationService, lifecycleManager, auditEntries);

var result = await orchestrator.ExecuteContentRemovalAsync([]);

Assert.True(result.Success);
Assert.NotNull(result.Data);
Assert.Contains(CasDefaults.GarbageCollectionDisabledMessage, result.Data.Warnings);
Assert.Equal(0, result.Data.CasObjectsCollected);
Assert.Equal(0, result.Data.BytesFreed);
var auditEntry = Assert.Single(auditEntries);
Assert.NotNull(auditEntry.Metadata);
Assert.Contains(CasDefaults.GarbageCollectionDisabledMessage, auditEntry.Metadata["warnings"]);
}

private static Mock<ICasLifecycleManager> CreateDisabledLifecycleManager()
{
var lifecycleManager = new Mock<ICasLifecycleManager>();
lifecycleManager
.Setup(manager => manager.RunGarbageCollectionAsync(
It.IsAny<bool>(),
It.IsAny<TimeSpan?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(OperationResult<GarbageCollectionStats>.CreateFailure(
CasDefaults.GarbageCollectionDisabledMessage,
GarbageCollectionStats.DisabledResult,
TimeSpan.Zero));
return lifecycleManager;
}

private static ContentReconciliationOrchestrator CreateOrchestrator(
Mock<IContentReconciliationService> reconciliationService,
Mock<ICasLifecycleManager> lifecycleManager,
List<ReconciliationAuditEntry>? auditEntries = null)
{
var auditLog = new Mock<IReconciliationAuditLog>();
auditLog
.Setup(log => log.LogOperationAsync(
It.IsAny<ReconciliationAuditEntry>(),
It.IsAny<CancellationToken>()))
.Callback<ReconciliationAuditEntry, CancellationToken>((entry, _) => auditEntries?.Add(entry))
.Returns(Task.CompletedTask);

return new ContentReconciliationOrchestrator(
reconciliationService.Object,
Mock.Of<IContentManifestPool>(),
lifecycleManager.Object,
auditLog.Object,
NullLogger<ContentReconciliationOrchestrator>.Instance);
}
}
Loading
Loading