From aae1ba173b857827fa3be5d0700c2806ee64c72c Mon Sep 17 00:00:00 2001 From: "ion.dormenco" Date: Tue, 22 Sep 2026 14:18:37 +0300 Subject: [PATCH] Fix FusionCache DbContext disposal and populate aggregated note observer ids. Use a fresh DI scope in cache factories so eager refresh cannot hit a disposed VoteMonitorContext, migrate locations/citizen forms to FusionCache, and map MonitoringObserverId onto aggregated notes. Co-authored-by: Cursor --- .../GetAggregated/Endpoint.cs | 10 ++- api/src/Feature.Forms/Feature.Forms.csproj | 1 + .../FetchCitizenReportingForms/Endpoint.cs | 47 ++++++------ .../Feature.Locations.csproj | 1 + .../Feature.Locations/FetchAll/Endpoint.cs | 71 ++++++++++--------- .../FetchAll/Endpoint.cs | 15 ++-- .../FetchLevels/Endpoint.cs | 15 ++-- 7 files changed, 96 insertions(+), 64 deletions(-) diff --git a/api/src/Feature.Form.Submissions/GetAggregated/Endpoint.cs b/api/src/Feature.Form.Submissions/GetAggregated/Endpoint.cs index 134f592b4..ac5c880fa 100644 --- a/api/src/Feature.Form.Submissions/GetAggregated/Endpoint.cs +++ b/api/src/Feature.Form.Submissions/GetAggregated/Endpoint.cs @@ -212,7 +212,9 @@ FORM_SUBMISSIONS AS ( 'Text', "Text", 'TimeSubmitted', - "LastUpdatedAt" + "LastUpdatedAt", + 'MonitoringObserverId', + N."MonitoringObserverId" ) ) FROM @@ -433,7 +435,11 @@ @HASATTACHMENTS IS NULL } var notes = submissions - .SelectMany(x => x.Notes.Select(note => note with { SubmissionId = x.SubmissionId })) + .SelectMany(x => x.Notes.Select(note => note with + { + SubmissionId = x.SubmissionId, + MonitoringObserverId = x.MonitoringObserverId + })) .ToList(); var attachments = submissions diff --git a/api/src/Feature.Forms/Feature.Forms.csproj b/api/src/Feature.Forms/Feature.Forms.csproj index 3a939cb2e..b48ea8c65 100644 --- a/api/src/Feature.Forms/Feature.Forms.csproj +++ b/api/src/Feature.Forms/Feature.Forms.csproj @@ -11,6 +11,7 @@ + diff --git a/api/src/Feature.Forms/FetchCitizenReportingForms/Endpoint.cs b/api/src/Feature.Forms/FetchCitizenReportingForms/Endpoint.cs index 229890a3d..e92a1f1d2 100644 --- a/api/src/Feature.Forms/FetchCitizenReportingForms/Endpoint.cs +++ b/api/src/Feature.Forms/FetchCitizenReportingForms/Endpoint.cs @@ -1,13 +1,14 @@ using Feature.Forms.Models; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; using Vote.Monitor.Domain; using Vote.Monitor.Domain.Entities.FormAggregate; using Vote.Monitor.Domain.Entities.FormBase; +using ZiggyCreatures.Caching.Fusion; namespace Feature.Forms.FetchCitizenReportingForms; -public class Endpoint(VoteMonitorContext context, IMemoryCache cache) +public class Endpoint(VoteMonitorContext context, IFusionCache cache, IServiceScopeFactory scopeFactory) : Endpoint>> { public override void Configure() @@ -44,26 +45,32 @@ public override async Task>> Execute var cacheKey = $"election-rounds/{req.ElectionRoundId}/monitoring-ngo/{monitoringNgo.MonitoringNgoForCitizenReportingId}/citizen-reports-forms/{monitoringNgo.FormsVersion}"; - var cachedResponse = await cache.GetOrCreateAsync(cacheKey, async (e) => - { - var forms = await context.Forms - .Where(x => x.Status == FormStatus.Published) - .Where(x => x.ElectionRoundId == req.ElectionRoundId) - .Where(x => x.MonitoringNgoId == monitoringNgo.MonitoringNgoForCitizenReportingId) - .Where(x => x.FormType == FormType.CitizenReporting) - .OrderBy(x => x.Code) - .ToListAsync(cancellationToken: ct); + var cachedResponse = await cache.GetOrSetAsync( + cacheKey, + async factoryCt => + { + // Fresh scope: FusionCache eager refresh may outlive the request DbContext. + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); - e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30); + var forms = await db.Forms + .Where(x => x.Status == FormStatus.Published) + .Where(x => x.ElectionRoundId == req.ElectionRoundId) + .Where(x => x.MonitoringNgoId == monitoringNgo.MonitoringNgoForCitizenReportingId) + .Where(x => x.FormType == FormType.CitizenReporting) + .OrderBy(x => x.Code) + .ToListAsync(cancellationToken: factoryCt); - return new NgoFormsResponseModel - { - ElectionRoundId = monitoringNgo.ElectionRoundId, - Version = monitoringNgo.FormsVersion.ToString(), - Forms = forms.Select(FormFullModel.FromEntity).OrderBy(x => x.DisplayOrder).ToList() - }; - }); + return new NgoFormsResponseModel + { + ElectionRoundId = monitoringNgo.ElectionRoundId, + Version = monitoringNgo.FormsVersion.ToString(), + Forms = forms.Select(FormFullModel.FromEntity).OrderBy(x => x.DisplayOrder).ToList() + }; + }, + options => options.SetDuration(TimeSpan.FromMinutes(30)), + token: ct); - return TypedResults.Ok(cachedResponse!); + return TypedResults.Ok(cachedResponse); } } diff --git a/api/src/Feature.Locations/Feature.Locations.csproj b/api/src/Feature.Locations/Feature.Locations.csproj index ab10b17e8..fc845d4cb 100644 --- a/api/src/Feature.Locations/Feature.Locations.csproj +++ b/api/src/Feature.Locations/Feature.Locations.csproj @@ -15,6 +15,7 @@ + diff --git a/api/src/Feature.Locations/FetchAll/Endpoint.cs b/api/src/Feature.Locations/FetchAll/Endpoint.cs index 8c35838ef..af89109aa 100644 --- a/api/src/Feature.Locations/FetchAll/Endpoint.cs +++ b/api/src/Feature.Locations/FetchAll/Endpoint.cs @@ -1,9 +1,10 @@ -using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; using Vote.Monitor.Core.Extensions; +using ZiggyCreatures.Caching.Fusion; namespace Feature.Locations.FetchAll; -public class Endpoint(VoteMonitorContext context, IMemoryCache cache) +public class Endpoint(VoteMonitorContext context, IFusionCache cache, IServiceScopeFactory scopeFactory) : Endpoint, NotFound>> { public override void Configure() @@ -34,36 +35,42 @@ public override async Task, NotFound>> ExecuteAsync(Request var cacheKey = $"election-rounds/{request.ElectionRoundId}/locations/{electionRound.LocationsVersion}"; - var cachedResponse = await cache.GetOrCreateAsync(cacheKey, async (e) => - { - var locations = await context.Locations - .Where(x => x.ElectionRoundId == request.ElectionRoundId) - .OrderBy(x => x.DisplayOrder) - .Select(x => new LocationModel - { - Id = x.Id, - Level1 = x.Level1, - Level2 = x.Level2, - Level3 = x.Level3, - Level4 = x.Level4, - Level5 = x.Level5, - DisplayOrder = x.DisplayOrder - }) - .ToListAsync(cancellationToken: ct); - - e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30); - - var nodes = GetLocationNodes(locations); - - return new Response + var cachedResponse = await cache.GetOrSetAsync( + cacheKey, + async factoryCt => { - ElectionRoundId = electionRound.Id, - Version = electionRound.LocationsVersion.ToString(), - Nodes = nodes - }; - }); - - return TypedResults.Ok(cachedResponse!); + // Fresh scope: FusionCache eager refresh may outlive the request DbContext. + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var locations = await db.Locations + .Where(x => x.ElectionRoundId == request.ElectionRoundId) + .OrderBy(x => x.DisplayOrder) + .Select(x => new LocationModel + { + Id = x.Id, + Level1 = x.Level1, + Level2 = x.Level2, + Level3 = x.Level3, + Level4 = x.Level4, + Level5 = x.Level5, + DisplayOrder = x.DisplayOrder + }) + .ToListAsync(cancellationToken: factoryCt); + + var nodes = GetLocationNodes(locations); + + return new Response + { + ElectionRoundId = electionRound.Id, + Version = electionRound.LocationsVersion.ToString(), + Nodes = nodes + }; + }, + options => options.SetDuration(TimeSpan.FromMinutes(30)), + token: ct); + + return TypedResults.Ok(cachedResponse); } private static List GetLocationNodes(List locations) @@ -144,4 +151,4 @@ private static List GetLocationNodes(List locations return root.Traverse().Where(x => x.Id != 0).ToList(); } -} \ No newline at end of file +} diff --git a/api/src/Feature.PollingStations/FetchAll/Endpoint.cs b/api/src/Feature.PollingStations/FetchAll/Endpoint.cs index 857865e8d..8583abe89 100644 --- a/api/src/Feature.PollingStations/FetchAll/Endpoint.cs +++ b/api/src/Feature.PollingStations/FetchAll/Endpoint.cs @@ -1,8 +1,9 @@ -using Vote.Monitor.Core.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Vote.Monitor.Core.Extensions; using ZiggyCreatures.Caching.Fusion; namespace Feature.PollingStations.FetchAll; -public class Endpoint(VoteMonitorContext context, IFusionCache cache) : Endpoint, NotFound>> +public class Endpoint(VoteMonitorContext context, IFusionCache cache, IServiceScopeFactory scopeFactory) : Endpoint, NotFound>> { public override void Configure() { @@ -33,9 +34,13 @@ public override async Task, NotFound>> ExecuteAsync(Request var cachedResponse = await cache.GetOrSetAsync( cacheKey, - async _ => + async factoryCt => { - var pollingStations = await context.PollingStations + // Fresh scope: FusionCache eager refresh may outlive the request DbContext. + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var pollingStations = await db.PollingStations .Where(x => x.ElectionRoundId == request.ElectionRoundId) .Select(x => new PollingStationModel { @@ -51,7 +56,7 @@ public override async Task, NotFound>> ExecuteAsync(Request Latitude = x.Latitude, Longitude = x.Longitude }) - .ToListAsync(cancellationToken: ct); + .ToListAsync(cancellationToken: factoryCt); var nodes = GetLocationNodes(pollingStations); diff --git a/api/src/Feature.PollingStations/FetchLevels/Endpoint.cs b/api/src/Feature.PollingStations/FetchLevels/Endpoint.cs index 1f77db4a1..d09969e6e 100644 --- a/api/src/Feature.PollingStations/FetchLevels/Endpoint.cs +++ b/api/src/Feature.PollingStations/FetchLevels/Endpoint.cs @@ -1,8 +1,9 @@ -using Vote.Monitor.Core.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Vote.Monitor.Core.Extensions; using ZiggyCreatures.Caching.Fusion; namespace Feature.PollingStations.FetchLevels; -public class Endpoint(VoteMonitorContext context, IFusionCache cache) : Endpoint, NotFound>> +public class Endpoint(VoteMonitorContext context, IFusionCache cache, IServiceScopeFactory scopeFactory) : Endpoint, NotFound>> { public override void Configure() { @@ -33,9 +34,13 @@ public override async Task, NotFound>> ExecuteAsync(Request var cachedResponse = await cache.GetOrSetAsync( cacheKey, - async _ => + async factoryCt => { - var pollingStations = await context.PollingStations + // Fresh scope: FusionCache eager refresh may outlive the request DbContext. + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var pollingStations = await db.PollingStations .Where(x => x.ElectionRoundId == request.ElectionRoundId) .Select(x => new { @@ -46,7 +51,7 @@ public override async Task, NotFound>> ExecuteAsync(Request x.Level5 }) .Distinct() - .ToListAsync(cancellationToken: ct); + .ToListAsync(cancellationToken: factoryCt); Dictionary nodes = new(); int id = 0;