From 9b3dcbc9b9892ba0b8cc7040caefac57b2d67b4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Fri, 10 Jul 2026 15:32:48 +0300 Subject: [PATCH] Fix release pruning and remove QKeeper reference --- ARGUS_PUBLICATION.md | 2 +- Program.cs | 127 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 114 insertions(+), 15 deletions(-) diff --git a/ARGUS_PUBLICATION.md b/ARGUS_PUBLICATION.md index dbd8fce..97ea062 100644 --- a/ARGUS_PUBLICATION.md +++ b/ARGUS_PUBLICATION.md @@ -20,7 +20,7 @@ Do not put SSH passwords, private keys, or local `.env` files into application r Every app must choose stable values: -- `slug`: public Argus id. Use lowercase letters, digits, and hyphens only, for example `keeper-android` or `my-desktop-app`. +- `slug`: public Argus id. Use lowercase letters, digits, and hyphens only, for example `sample-android` or `my-desktop-app`. - `name`: display name. - `summary`: short catalog text. - `description`: full catalog text. diff --git a/Program.cs b/Program.cs index e68cc79..1b9c6af 100644 --- a/Program.cs +++ b/Program.cs @@ -1,3 +1,5 @@ +using System.Data; +using System.Globalization; using System.Net; using Argus.Contracts; using Argus.Data; @@ -331,11 +333,11 @@ static IOrderedEnumerable OrderReleases(IEnumerable rele .ThenByDescending(x => x.PublishedAt) .ThenByDescending(x => x.Id); -static void DeleteStoredPackages(PackageStorageService storageService, IEnumerable releases) +static void DeleteStoredPackages(PackageStorageService storageService, IEnumerable storedRelativePaths) { - foreach (var release in releases) + foreach (var storedRelativePath in storedRelativePaths) { - storageService.TryDelete(release.StoredRelativePath); + storageService.TryDelete(storedRelativePath); } } @@ -351,15 +353,9 @@ static async Task PruneOldReleasesAsync( throw new ArgumentOutOfRangeException(nameof(maxRetainedReleasesPerApp)); } - IQueryable releasesQuery = dbContext.Releases; - if (catalogAppId is { } appId) - { - releasesQuery = releasesQuery.Where(x => x.CatalogAppId == appId); - } - - var releases = await releasesQuery.ToListAsync(cancellationToken); + var releases = await LoadReleasePruneCandidatesAsync(dbContext, catalogAppId, cancellationToken); var releasesToPrune = releases - .GroupBy(x => x.CatalogAppId) + .GroupBy(x => x.CatalogAppId, StringComparer.OrdinalIgnoreCase) .SelectMany(group => group .OrderByDescending(x => x.PublishedAt) .ThenByDescending(x => x.Id) @@ -371,9 +367,105 @@ static async Task PruneOldReleasesAsync( return; } - dbContext.Releases.RemoveRange(releasesToPrune); - await dbContext.SaveChangesAsync(cancellationToken); - DeleteStoredPackages(storageService, releasesToPrune); + await DeleteReleaseRowsAsync(dbContext, releasesToPrune.Select(x => x.RowId), cancellationToken); + DeleteStoredPackages(storageService, releasesToPrune.Select(x => x.StoredRelativePath)); +} + +static async Task> LoadReleasePruneCandidatesAsync( + ArgusDbContext dbContext, + Guid? catalogAppId, + CancellationToken cancellationToken) +{ + var connection = dbContext.Database.GetDbConnection(); + var mustClose = connection.State != ConnectionState.Open; + + if (mustClose) + { + await connection.OpenAsync(cancellationToken); + } + + try + { + await using var command = connection.CreateCommand(); + command.CommandText = + """ + SELECT rowid, "CatalogAppId", "StoredRelativePath", "PublishedAt", "Id" + FROM "Releases" + WHERE $catalogAppId IS NULL OR lower("CatalogAppId") = lower($catalogAppId) + """; + + var catalogAppIdParameter = command.CreateParameter(); + catalogAppIdParameter.ParameterName = "$catalogAppId"; + catalogAppIdParameter.Value = catalogAppId?.ToString("D") ?? (object)DBNull.Value; + command.Parameters.Add(catalogAppIdParameter); + + var candidates = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + candidates.Add(new ReleasePruneCandidate( + reader.GetInt64(0), + reader.GetString(1), + reader.GetString(2), + DateTimeOffset.Parse(reader.GetString(3), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), + Guid.Parse(reader.GetString(4)))); + } + + return candidates; + } + finally + { + if (mustClose) + { + await connection.CloseAsync(); + } + } +} + +static async Task DeleteReleaseRowsAsync( + ArgusDbContext dbContext, + IEnumerable rowIds, + CancellationToken cancellationToken) +{ + var connection = dbContext.Database.GetDbConnection(); + var mustClose = connection.State != ConnectionState.Open; + + if (mustClose) + { + await connection.OpenAsync(cancellationToken); + } + + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + try + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = """DELETE FROM "Releases" WHERE rowid = $rowid"""; + + var rowIdParameter = command.CreateParameter(); + rowIdParameter.ParameterName = "$rowid"; + command.Parameters.Add(rowIdParameter); + + foreach (var rowId in rowIds) + { + rowIdParameter.Value = rowId; + await command.ExecuteNonQueryAsync(cancellationToken); + } + + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + finally + { + if (mustClose) + { + await connection.CloseAsync(); + } + } } static void SetPackageCacheHeaders(HttpContext httpContext, bool immutable) @@ -388,3 +480,10 @@ static EntityTagHeaderValue CreatePackageEntityTag(string sha256) => static string? NormalizeFilter(string? value) => string.IsNullOrWhiteSpace(value) ? null : IdentifierUtility.Normalize(value, string.Empty); + +sealed record ReleasePruneCandidate( + long RowId, + string CatalogAppId, + string StoredRelativePath, + DateTimeOffset PublishedAt, + Guid Id);