Load each FwData project once under concurrent requests - #2698
imnasnainaec wants to merge 7 commits into
Conversation
FwDataFactory.GetProjectServiceCached used IMemoryCache.GetOrCreate with a factory that ran the slow LoadCache. GetOrCreate isn't atomic, so concurrent requests for the same project (e.g. the Platform.Bible extension fetching writing systems for every project, then retrying after a client timeout) each loaded it. When a later load finished, its Set replaced the earlier entry, and the eviction callback disposed the earlier LcmCache (EvictionReason.Replaced). The logs showed "loaded" immediately followed by "Evicting"/"disposed", and the load queue grew with every retry. Cache a Lazy<LcmCache> (ExecutionAndPublication) created under a lock, so concurrent callers join the one in-flight load. A failed load is removed from the cache so the next call retries. Eviction, Dispose and CloseProjectAsync only dispose a Lazy whose value was created. The eviction log now includes the reason. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. 📝 WalkthroughWalkthroughFwDataFactory now coordinates project loads by cache key and tracks loaded caches through eviction, explicit close, and shutdown. New tests cover load concurrency, retries, disposal coordination, reloads, and lock-file cleanup. ChangesProject cache lifecycle
Priority: ⬇️ Low Estimated code review effort: 3 (Moderate) | ~20 minutes Change: Bug fix Merge Risk: 🟡 Moderate · up to After cache expiration, reopening a project may fail while the previous cache is still being disposed. Ensure disposal finishes before replacement loading prior to merge. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. A rabbit watched the project load, Comment |
|
The latest updates on your projects. Learn more about Argos notifications ↗︎
|
- CloseProjectAsync takes the entry out of the cache and waits for a load still in flight, then disposes it, so file locks are released before it returns. - Evicting an entry whose load hasn't finished disposes the cache once the load completes. - Track cache entries instead of keys, so a failed load's late eviction callback can't untrack its replacement and hide it from shutdown. Each entry owns a run-once disposal shared by eviction, close and shutdown. The concurrency test no longer depends on a fixed delay. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Loads a real on-disk project through a gated loader, closes it while the load is blocked, and checks LCM's lock file is gone afterwards. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Replace the Lazy-based cache entry with a lock per project held for the whole load, so the cache only ever holds a finished LcmCache. A failed load caches nothing, an entry can't be evicted mid-load, and a request made during a close waits for it to finish disposing. Open caches are tracked by instance, and whoever untracks one disposes it, so eviction, close and shutdown dispose each cache exactly once. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An expired entry's eviction callback runs on the thread pool, so a reload could start LoadCache before the old LcmCache was disposed, and disposing it afterwards deleted the new copy's lock file. Close could also return while that callback was still disposing. Reload and close now dispose any tracked copies of the project under its lock first, and the eviction callback takes the same lock. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When several requests asked for the same FwData project at once, each one ran
LoadCache, becauseIMemoryCache.GetOrCreateisn't atomic. The later load's cacheSetreplaced the earlier entry, and the eviction callback (reasonReplaced) disposed that earlier copy. The Platform.Bible lexicon picker fetches writing systems for every local project, so each retry after a timeout queued another full round of loads, and the queue never drained.FwDataFactorynow holds a lock for each project around the whole load, and the memory cache only ever holds a finishedLcmCache.CloseProjectAsynctakes the same lock. It waits for any load still running, then removes and disposes the cache, so the project's lock file is gone before it returns (FwLinkerandFwIntegrationRoutesdepend on this before they hand out a FieldWorks link). A request made during a close waits for the close to finish, then loads a new copy.Test plan
dotnet test FwDataMiniLcmBridge.Tests --filter "FullyQualifiedName~FwDataFactory": 9 tests, all passing across repeated runs.ConcurrentRequestsForTheSameProjectShareOneLoad: fails against develop (2 loads instead of 1).CloseWaitsForAnInFlightLoadAndDisposesIt: fails against develop.FwDataFactoryOnDiskTests.CloseDuringALoadReleasesTheLockFile: loads a real on-disk project, closes it during the load, and checks that LCM's.fwdata.lockfile is gone afterwards. (The XML backend doesn't hold the.fwdataopen; that lock file is its lock.) Fails against develop.CloseRightAfterRemovalDisposesTheOldCacheandReloadAfterRemovalDisposesTheOldCacheBeforeLoading: remove the cache key (queuing the eviction callback, as expiry does) and then close or reload; the old copy must be disposed before close returns or the new load starts. Both fail with the stale-copy disposal removed.FailedLoadIsRetriedOnTheNextRequest,RemovingTheCacheKeyDuringALoadDoesNotLoseTheLoad,ShutdownDisposesAProjectRetriedAfterAFailedLoad,RequestDuringACloseGetsANewCacheAfterIt: guard the behaviour above.FwLiteWeb and FwHeadless build.
Known limitations
RequestDuringACloseGetsANewCacheAfterItcan't force its request to arrive while the close is still disposing, so the waiting path isn't exercised every run.For the team: per-project locks across scopes
FwHeadless registers
FwDataFactoryas scoped (AddFwDataBridge(ServiceLifetime.Scoped)), butIMemoryCacheis a singleton. Each scope therefore gets its own_keyLocks, which never grows much there, but loads of the same project from two scopes at once aren't coordinated; that's no worse than develop. If FwHeadless can open one project from two scopes at once, the locks should be shared, e.g. through a singleton.Considered and rejected
Lazy<LcmCache>created under a short lock. It also shares one load, but a load in progress then sits in the cache, so a failed load, eviction during a load, and closing during a load each needed extra handling. The per-project lock avoids all three.🤖 Generated with Claude Code