diff --git a/backend/FwLite/FwDataMiniLcmBridge/FwDataFactory.cs b/backend/FwLite/FwDataMiniLcmBridge/FwDataFactory.cs index 3691bede09..8dff6914bc 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/FwDataFactory.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/FwDataFactory.cs @@ -20,6 +20,10 @@ public class FwDataFactory( IOptions config) : IDisposable, IHostedService { private bool _shuttingDown = false; + + // Sliding window before an idle LcmCache is evicted and disposed. PreventEviction refreshes inside this window. + private static readonly TimeSpan CacheSlidingExpiration = TimeSpan.FromMinutes(30); + public FwDataFactory(ILogger fwdataLogger, IMemoryCache cache, ILogger logger, @@ -51,7 +55,7 @@ private LcmCache GetProjectServiceCached(FwDataProject project) var projectService = cache.GetOrCreate(key, entry => { - entry.SlidingExpiration = TimeSpan.FromMinutes(30); + entry.SlidingExpiration = CacheSlidingExpiration; entry.RegisterPostEvictionCallback(OnLcmProjectCacheEviction, (logger, _projectCacheKeys)); logger.LogInformation("Loading project {ProjectFileName}", project.FileName); var projectService = projectLoader.LoadCache(project); @@ -131,6 +135,24 @@ public IAsyncDisposable DeferCloseAsync(FwDataProject project) return Defer.Async(() => CloseProjectAsync(project)); } + /// + /// Keeps the project's LcmCache from being evicted until disposed, by periodically resetting its sliding expiration. + /// Use around long-running work that holds one api instance past the expiration window (e.g. a sync paused in a debugger). + /// + public IDisposable PreventEviction(FwDataProject project) + { + var key = CacheKey(project); + // Refresh now in case little of the window remains, then every half-window so a tick can't be missed. + var period = CacheSlidingExpiration / 2; + return new Timer(_ => + { + // Best-effort: the shared cache can be disposed during shutdown while a sync is still finishing, + // and an unhandled throw on this timer thread would take the process down. + try { cache.TryGetValue(key, out _); } + catch (ObjectDisposedException) { } + }, null, TimeSpan.Zero, period); + } + public Task StartAsync(CancellationToken cancellationToken) { return Task.CompletedTask; diff --git a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs index e9d9d352ca..fdf163b716 100644 --- a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs +++ b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using FwDataMiniLcmBridge; using FwDataMiniLcmBridge.Api; using LcmCrdt; using LexCore.Sync; @@ -12,7 +13,8 @@ namespace FwLiteProjectSync; public class CrdtFwdataProjectSyncService(MiniLcmImport miniLcmImport, ILogger logger, MiniLcmApiValidationWrapperFactory validationWrapperFactory, - CrdtProjectsService crdtProjectsService) + CrdtProjectsService crdtProjectsService, + FwDataFactory fwDataFactory) { public record DryRunSyncResult( int CrdtChanges, @@ -53,6 +55,9 @@ private async Task SyncOrImportInternal(IMiniLcmApi crdtApi, IMiniLc throw new InvalidOperationException($"Project id mismatch, CRDT Id: {crdt.ProjectData.FwProjectId}, FWData Id: {fwdata.ProjectId}"); } + // A sync that outlives the LcmCache's sliding expiration (e.g. paused in a debugger) would otherwise have it disposed mid-sync. + using var keepFwdataAlive = fwDataFactory.PreventEviction(fwdata.Project); + // Project snapshot logic/handling is done outside of this class so that Sync vs Import is explicit. // We still choose to explicitly verify a consistent state to avoid accidental misuse. var hasSyncedSuccessfully = ProjectSnapshotService.HasSyncedSuccessfully(fwdata.Project); diff --git a/backend/Testing/FwHeadless/Services/SyncWorkerTestHarness.cs b/backend/Testing/FwHeadless/Services/SyncWorkerTestHarness.cs index 0f4567fdfc..2f6205b511 100644 --- a/backend/Testing/FwHeadless/Services/SyncWorkerTestHarness.cs +++ b/backend/Testing/FwHeadless/Services/SyncWorkerTestHarness.cs @@ -253,6 +253,7 @@ private ServiceProvider BuildServiceProvider( null!, NullLogger.Instance, null!, + null!, null!); syncService