From 22ecb798e2134aa718c9312715b0493f6766d048 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 16:44:57 +0000 Subject: [PATCH] Avoid loading full container snapshot table in dashboard queries GetLatestContainerSnapshotsAsync materialized the entire ContainerSnapshots table with all navigation includes before selecting the latest state in memory, which exhausted memory as snapshot history grew and produced OutOfMemoryException on the dashboard and runtime container pages. The selection now runs in the database: the latest runtime scan run per instance is resolved from a lightweight distinct projection, and instances without a runtime scan fall back to a per-container greatest-record subquery. Only the small final result set is materialized. Add a regression test covering the historical-snapshot fallback collapse. --- .../UI/ApplicationViewService.cs | 72 ++++++++++++------- .../ApplicationViewServiceTests.cs | 62 ++++++++++++++++ 2 files changed, 110 insertions(+), 24 deletions(-) diff --git a/src/DockerUpdateGuard/UI/ApplicationViewService.cs b/src/DockerUpdateGuard/UI/ApplicationViewService.cs index bacb547..43e724c 100644 --- a/src/DockerUpdateGuard/UI/ApplicationViewService.cs +++ b/src/DockerUpdateGuard/UI/ApplicationViewService.cs @@ -1026,37 +1026,61 @@ private async Task> LoadRecommendedImageVersionsA /// Latest snapshots private async Task> GetLatestContainerSnapshotsAsync(CancellationToken cancellationToken) { - var snapshots = await _dbContext.ContainerSnapshots.Include(entity => entity.DockerInstance) - .ThenInclude(entity => entity.PortainerEndpoint) - .Include(entity => entity.ImageVersion) - .ThenInclude(entity => entity.RegistryRepository) - .Include(entity => entity.ScanRun) - .AsNoTracking() - .OrderByDescending(entity => entity.RecordedAtUtc) - .ToListAsync(cancellationToken) - .ConfigureAwait(false); + var runtimeScanReferences = await _dbContext.ContainerSnapshots.Where(entity => entity.ScanRunId != null + && entity.ScanRun!.Type == ScanRunType.RuntimeContainer) + .Select(entity => new + { + entity.DockerInstanceId, + entity.ScanRunId, + entity.ScanRun!.StartedAtUtc, + }) + .Distinct() + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var targetScanRunByInstance = runtimeScanReferences.GroupBy(reference => reference.DockerInstanceId) + .ToDictionary(group => group.Key, + group => group.OrderByDescending(reference => reference.StartedAtUtc) + .First() + .ScanRunId); var currentSnapshots = new List(); - foreach (var instanceGroup in snapshots.GroupBy(entity => entity.DockerInstanceId)) + if (targetScanRunByInstance.Count > 0) { - var latestRuntimeScanSnapshots = instanceGroup.Where(entity => entity.ScanRun?.Type == ScanRunType.RuntimeContainer) - .GroupBy(entity => entity.ScanRunId) - .OrderByDescending(group => group.Max(item => item.ScanRun?.StartedAtUtc ?? item.RecordedAtUtc)) - .Select(group => group.OrderByDescending(item => item.RecordedAtUtc) - .ToList()) - .FirstOrDefault(); - - if (latestRuntimeScanSnapshots is not null) - { - currentSnapshots.AddRange(latestRuntimeScanSnapshots); + var targetScanRunIds = targetScanRunByInstance.Values.ToList(); + var runtimeSnapshots = await _dbContext.ContainerSnapshots.Include(entity => entity.DockerInstance) + .ThenInclude(entity => entity.PortainerEndpoint) + .Include(entity => entity.ImageVersion) + .ThenInclude(entity => entity.RegistryRepository) + .Include(entity => entity.ScanRun) + .Where(entity => targetScanRunIds.Contains(entity.ScanRunId)) + .AsNoTracking() + .OrderByDescending(entity => entity.RecordedAtUtc) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); - continue; - } + currentSnapshots.AddRange(runtimeSnapshots.Where(entity => targetScanRunByInstance.TryGetValue(entity.DockerInstanceId, out var scanRunId) + && scanRunId == entity.ScanRunId)); + } + + var runtimeInstanceIds = targetScanRunByInstance.Keys.ToList(); + var fallbackSnapshots = await _dbContext.ContainerSnapshots.Include(entity => entity.DockerInstance) + .ThenInclude(entity => entity.PortainerEndpoint) + .Include(entity => entity.ImageVersion) + .ThenInclude(entity => entity.RegistryRepository) + .Include(entity => entity.ScanRun) + .Where(entity => runtimeInstanceIds.Contains(entity.DockerInstanceId) == false + && _dbContext.ContainerSnapshots.Any(other => other.DockerInstanceId == entity.DockerInstanceId + && other.ContainerId == entity.ContainerId + && other.RecordedAtUtc > entity.RecordedAtUtc) == false) + .AsNoTracking() + .OrderByDescending(entity => entity.RecordedAtUtc) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); - currentSnapshots.AddRange(instanceGroup.GroupBy(entity => CreateContainerKey(entity.DockerInstanceId, entity.ContainerId)) + currentSnapshots.AddRange(fallbackSnapshots.GroupBy(entity => CreateContainerKey(entity.DockerInstanceId, entity.ContainerId)) .Select(group => group.First())); - } return currentSnapshots; } diff --git a/src/Tests/DockerUpdateGuard.Tests/ApplicationViewServiceTests.cs b/src/Tests/DockerUpdateGuard.Tests/ApplicationViewServiceTests.cs index 923bf29..56649de 100644 --- a/src/Tests/DockerUpdateGuard.Tests/ApplicationViewServiceTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/ApplicationViewServiceTests.cs @@ -2437,5 +2437,67 @@ await dbContext.SaveChangesAsync(CancellationToken.None) } } + /// + /// Verify runtime container projections collapse historical snapshots without a runtime scan to the latest per container + /// + /// Task + [TestMethod] + public async Task ApplicationViewServiceRuntimeContainersCollapseHistoricalSnapshotsToLatestAsync() + { + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var dbContext = new DockerUpdateGuardDbContext(options); + + await using (dbContext.ConfigureAwait(false)) + { + var imageCatalogRepository = new ImageCatalogRepository(dbContext); + var imageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io", + "company/api", + "1.0.0", + "sha256:api", + cancellationToken: CancellationToken.None) + .ConfigureAwait(false); + var dockerInstance = new DockerInstance + { + Name = "Production", + EndpointUri = "https://docker.example.test", + ConnectionKind = DockerConnectionKind.Https, + }; + + var recordedAt = DateTimeOffset.UtcNow.AddHours(-3); + + for (var index = 0; index < 5; index++) + { + dbContext.ContainerSnapshots.Add(new ContainerSnapshot + { + DockerInstance = dockerInstance, + ImageVersionId = imageVersion.Id, + ContainerId = "container-history", + Name = $"api-{index}", + Status = ContainerRuntimeStatus.Running, + IsRunning = true, + RecordedAtUtc = recordedAt.AddMinutes(index * 10), + }); + } + + await dbContext.SaveChangesAsync(CancellationToken.None) + .ConfigureAwait(false); + + var service = new ApplicationViewService(dbContext, + new ImageReferenceParser(), + new SharedBaseImageQueryService(dbContext)); + var runtimeContainers = await service.GetRuntimeContainersAsync(CancellationToken.None) + .ConfigureAwait(false); + + Assert.HasCount(1, + runtimeContainers, + "Historical snapshots without a runtime scan must collapse to a single latest projection per container"); + Assert.AreEqual("api-4", + runtimeContainers.Single().ContainerName, + "The collapsed runtime container projection must expose the most recently recorded snapshot"); + } + } + #endregion // Methods } \ No newline at end of file