Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Jan 9, 2026

MergeService was registered as a Singleton but injected transient repositories (IMergeBlacklistRepository, IMergeGraylistRepository, IWordRepository), violating service lifetime best practices and capturing transient dependencies.

Changes

Service Registration

  • Changed MergeService from AddSingleton to AddTransient in Startup.cs

State Management

  • Make _mergeCounter static
  • Replaced instance field _potentialDups (ConcurrentDictionary) with IMemoryCache
  • User duplicates: stored at key "MergeService_PotentialDups_{userId}" with 1-hour expiration

Thread Safety

  • Introduced static _counterLock object for StoreDups operations
  • Lock scope covers read-compare-write sequences to prevent race conditions

Constructor

// Before
public MergeService(IMergeBlacklistRepository mergeBlacklistRepo, 
    IMergeGraylistRepository mergeGraylistRepo, IWordRepository wordRepo, IWordService wordService)

// After  
public MergeService(IMemoryCache cache, IMergeBlacklistRepository mergeBlacklistRepo,
    IMergeGraylistRepository mergeGraylistRepo, IWordRepository wordRepo, IWordService wordService)

Tests

  • Updated test setup to inject IMemoryCache via ServiceCollection
  • Added coverage for cache retrieval, expiration, and concurrent request handling
  • Modify WordRepositoryMock to enable delaying GetFrontier in tests
Original prompt

This section details on the original issue you should resolve

<issue_title>Fix singleton MergeService's dependency on transient repositories</issue_title>
<issue_description>MergeService is intentionally a Singleton in Backend/Startup.cs:

services.AddSingleton<ILiftService, LiftService>();

However, it depends on two Repositories that are (and should be) transient. In Backend/Services/MergeService.cs:

        public MergeService(IMergeBlacklistRepository mergeBlacklistRepo, IMergeGraylistRepository mergeGraylistRepo,
            IWordRepository wordRepo, IWordService wordService)

Plan:

  • Change MergeService lifetime from Singleton to Transient
  • Add the ASP.NET cache to the MergeService dependencies: public MergeService(IMemoryCache cache, ...
  • Replace _mergeCounter and _potentialDups with calls to the cache
    • Add a const for the merge counter cache key
    • Don't expire merge counter in the cache
    • Add a const for the potential dups cache key prefix (to which the user-id will be added)
    • Set potential dups cache entries to expire after 1 hour
  • Update all interfaces accordingly
  • Update and expand all tests accordingly
    </issue_description>

💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.


This change is Reviewable

Summary by CodeRabbit

  • Chores

    • Enhanced caching for duplicate-detection to improve concurrency and per-user expiration behavior
    • Adjusted service lifecycle to reduce contention and improve reliability
  • Tests

    • Expanded test coverage for caching and concurrent merge scenarios
    • Added test helper to simulate delayed repository responses for more realistic concurrency testing

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 9, 2026

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

📝 Walkthrough

Walkthrough

MergeService switched from singleton with in-process ConcurrentDictionary to transient using IMemoryCache. Constructor adds IMemoryCache as first parameter; potential-duplicates and merge counter moved to cache (1h expiry for duplicates, non-expiring counter). DI lifetime changed to Transient; tests updated accordingly.

Changes

Cohort / File(s) Summary
Service Implementation
Backend/Services/MergeService.cs
Replaced internal ConcurrentDictionary with IMemoryCache usage. Constructor now accepts IMemoryCache first. Added cache key constants, a static lock, per-user duplicate entries (1-hour expiration), and atomic merge counter stored in cache. StoreDups and RetrieveDups now read/write/remove from cache.
Dependency Injection
Backend/Startup.cs
Changed MergeService registration lifetime from Singleton to Transient.
Service Tests
Backend.Tests/Services/MergeServiceTests.cs
Tests updated to construct and inject a MemoryCache instance; test cases added/updated to validate caching behavior (retrieve/remove semantics, multi-user handling, competing stores). Some test initializers refactored to use inline collection literals.
Controller Tests
Backend.Tests/Controllers/MergeControllerTests.cs
Test setup updated to register and provide IMemoryCache/MemoryCache to MergeService in DI for controller tests.
Repository Mock
Backend.Tests/Mocks/WordRepositoryMock.cs
Added SetGetFrontierDelay(TaskCompletionSource<bool>) and related fields to support delaying GetFrontier for test scenarios (introduces first-call await behavior when delay is set).

Sequence Diagram(s)

sequenceDiagram
  actor Client
  participant MergeController
  participant MergeService
  participant IMemoryCache
  participant Repo as Repositories

  Client->>MergeController: POST /merge (store duplicates)
  MergeController->>MergeService: StoreDups(userId, potentialDups)
  MergeService->>IMemoryCache: Get/Increment merge counter
  MergeService->>IMemoryCache: Set per-user dups (key:userId) with 1h expiry
  MergeService->>Repo: (other merge-related repo calls)
  MergeService-->>MergeController: return requestId
  MergeController-->>Client: 200 OK
Loading
sequenceDiagram
  actor Client
  participant MergeController
  participant MergeService
  participant IMemoryCache

  Client->>MergeController: GET /merge/{requestId} (retrieve dups)
  MergeController->>MergeService: RetrieveDups(userId, requestId)
  MergeService->>IMemoryCache: Get per-user dups (key:userId)
  alt entry found
    MergeService->>IMemoryCache: Remove per-user dups (delete key)
    MergeService-->>MergeController: return stored dups
  else not found
    MergeService-->>MergeController: return null / not found
  end
  MergeController-->>Client: 200/404
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

bug

Suggested reviewers

  • jasonleenaylor

Poem

🐰 I cached the hops, stored each bright trace,
Locks held steady in memory's soft space,
Transient I sprint, no singleton snare,
Per-user keys flutter—each request gets its share. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: refactoring MergeService from Singleton to Transient registration while adding IMemoryCache dependency injection.
Linked Issues check ✅ Passed The pull request fully implements all objectives from issue #4092: changed MergeService to Transient, added IMemoryCache with appropriate cache keys and expiration, replaced internal state with cache-backed storage, and updated tests.
Out of Scope Changes check ✅ Passed All changes are within scope of issue #4092. The WordRepositoryMock enhancement supports concurrent testing requirements related to the merge service refactoring.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI and others added 3 commits January 9, 2026 15:38
- Changed MergeService lifetime from Singleton to Transient in Startup.cs
- Added IMemoryCache dependency to MergeService constructor
- Replaced _mergeCounter with cache-based counter (no expiration, NeverRemove priority)
- Replaced _potentialDups ConcurrentDictionary with cache-based storage (1 hour expiration)
- Added cache key constants: MergeCounterCacheKey and PotentialDupsCacheKeyPrefix
- Updated all test files to inject IMemoryCache mock
- Added 4 new tests for cache behavior validation
- Removed unused System.Collections.Concurrent and System.Threading imports

Co-authored-by: imnasnainaec <6411521+imnasnainaec@users.noreply.github.com>
- Wrapped counter get and increment in lock(_cache) to ensure thread-safe operation
- Prevents race conditions when multiple threads try to increment counter simultaneously

Co-authored-by: imnasnainaec <6411521+imnasnainaec@users.noreply.github.com>
- Added dedicated _counterLock object to avoid locking on shared IMemoryCache instance
- Made StoreDups method thread-safe with lock to prevent race conditions
- Both counter increment and StoreDups now use same lock for consistency

Co-authored-by: imnasnainaec <6411521+imnasnainaec@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix singleton MergeService's dependency on transient repositories Refactor MergeService from Singleton to Transient with IMemoryCache Jan 9, 2026
Copilot AI requested a review from imnasnainaec January 9, 2026 15:47
@github-actions
Copy link

github-actions bot commented Jan 9, 2026

⚠️ Commit Message Format Issues ⚠️
commit 17c89905a0:
3: B1 Line exceeds max length (86>80): "- Added dedicated _counterLock object to avoid locking on shared IMemoryCache instance"

commit 69c593cbb5:
3: B1 Line exceeds max length (83>80): "- Wrapped counter get and increment in lock(_cache) to ensure thread-safe operation"
4: B1 Line exceeds max length (88>80): "- Prevents race conditions when multiple threads try to increment counter simultaneously"

commit 1235315614:
5: B1 Line exceeds max length (87>80): "- Replaced _mergeCounter with cache-based counter (no expiration, NeverRemove priority)"
6: B1 Line exceeds max length (91>80): "- Replaced _potentialDups ConcurrentDictionary with cache-based storage (1 hour expiration)"
7: B1 Line exceeds max length (81>80): "- Added cache key constants: MergeCounterCacheKey and PotentialDupsCacheKeyPrefix"

@imnasnainaec imnasnainaec marked this pull request as ready for review January 9, 2026 19:02
@imnasnainaec imnasnainaec requested a review from Copilot January 9, 2026 19:02
@imnasnainaec
Copy link
Collaborator

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Jan 9, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors MergeService from a singleton to a transient service to fix a service lifetime violation where the singleton was injecting transient repository dependencies. The refactoring replaces instance-level state management with IMemoryCache for storing merge counters and potential duplicate word lists.

  • Changed service registration from AddSingleton to AddTransient
  • Introduced IMemoryCache dependency for state management
  • Replaced ConcurrentDictionary with cache-based storage using user-specific keys

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.

File Description
Backend/Startup.cs Changed MergeService registration from Singleton to Transient
Backend/Services/MergeService.cs Added IMemoryCache dependency, replaced instance fields with cache operations, introduced static counter and lock for thread safety
Backend.Tests/Services/MergeServiceTests.cs Updated test setup to inject IMemoryCache, added new tests for cache behavior
Backend.Tests/Controllers/MergeControllerTests.cs Updated test setup to inject IMemoryCache for MergeService instantiation

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@imnasnainaec imnasnainaec added the 🟩Low Low-priority PR label Jan 9, 2026
@codecov
Copy link

codecov bot commented Jan 9, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.08%. Comparing base (d2f2aa3) to head (2485670).
⚠️ Report is 4 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff             @@
##           master    #4095       +/-   ##
===========================================
+ Coverage   74.57%   86.08%   +11.50%     
===========================================
  Files         295       54      -241     
  Lines       10938     4785     -6153     
  Branches     1372      589      -783     
===========================================
- Hits         8157     4119     -4038     
+ Misses       2385      522     -1863     
+ Partials      396      144      -252     
Flag Coverage Δ
backend 86.08% <100.00%> (+0.48%) ⬆️
frontend ?

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In @Backend/Services/MergeService.cs:
- Around line 63-72: RetrieveDups currently calls _cache.TryGetValue(cacheKey)
then _cache.Remove(cacheKey) without synchronization, which can remove a newer
entry written by StoreDups; fix by performing the TryGetValue + Remove under the
same lock used by StoreDups so the read-and-delete is atomic (wrap the body of
RetrieveDups in that lock), or alternatively make the delete conditional by
comparing the stored version tuple.Item1 to an expected version (only remove if
versions match) to avoid deleting newer entries; reference RetrieveDups,
StoreDups, _cache, cacheKey and value.Item1 when applying the change.
🧹 Nitpick comments (4)
Backend.Tests/Services/MergeServiceTests.cs (2)

15-39: Tighten test cache setup (avoid undisposed ServiceProvider; create MemoryCache directly).

Current setup allocates a ServiceProvider per test and doesn’t dispose it. For these unit tests, constructing MemoryCache directly is simpler and avoids lifecycle leakage.

Proposed change
-        private IMemoryCache _cache = null!;
+        private MemoryCache _cache = null!;

         [SetUp]
         public void Setup()
         {
-            // Set up MemoryCache
-            var services = new ServiceCollection();
-            services.AddMemoryCache();
-            var serviceProvider = services.BuildServiceProvider();
-            _cache = serviceProvider.GetService<IMemoryCache>()!;
+            _cache = new MemoryCache(new MemoryCacheOptions());

             _mergeBlacklistRepo = new MergeBlacklistRepositoryMock();
             _mergeGraylistRepo = new MergeGraylistRepositoryMock();
             _wordRepo = new WordRepositoryMock();
             _wordService = new WordService(_wordRepo);
             _mergeService = new MergeService(_cache, _mergeBlacklistRepo, _mergeGraylistRepo, _wordRepo, _wordService);
         }
+
+        [TearDown]
+        public void TearDown()
+        {
+            _cache.Dispose();
+        }

501-561: New cache tests: good start, but “newer request wins” isn’t actually being asserted.

TestStoreDupsOnlyStoresNewerRequests doesn’t create competing requests or assert which one wins; it currently only verifies “dups can be retrieved”. Consider either renaming it to match behavior, or make it deterministic (e.g., synchronize two tasks to overlap and assert that only the latest request’s result remains retrievable).

Backend.Tests/Controllers/MergeControllerTests.cs (1)

16-50: Dispose the test MemoryCache (and avoid building a ServiceProvider if you don’t need DI).

The test creates an IMemoryCache via a built ServiceProvider but doesn’t dispose either. Since the class already implements IDisposable, simplest is to instantiate MemoryCache directly and dispose it in Dispose().

Proposed change
-        private IMemoryCache _cache = null!;
+        private MemoryCache _cache = null!;

         public void Dispose()
         {
             _mergeController?.Dispose();
+            _cache?.Dispose();
             GC.SuppressFinalize(this);
         }

         [SetUp]
         public void Setup()
         {
-            // Set up MemoryCache
-            var services = new ServiceCollection();
-            services.AddMemoryCache();
-            var serviceProvider = services.BuildServiceProvider();
-            _cache = serviceProvider.GetService<IMemoryCache>()!;
+            _cache = new MemoryCache(new MemoryCacheOptions());

             _mergeBlacklistRepo = new MergeBlacklistRepositoryMock();
             _mergeGraylistRepo = new MergeGraylistRepositoryMock();
             _wordRepo = new WordRepositoryMock();
             _wordService = new WordService(_wordRepo);
             _mergeService = new MergeService(_cache, _mergeBlacklistRepo, _mergeGraylistRepo, _wordRepo, _wordService);
             _mergeController = new MergeController(
                 _mergeService, new HubContextMock<MergeHub>(), new PermissionServiceMock());
         }
Backend/Services/MergeService.cs (1)

41-58: StoreDups: expiration policy doesn’t match stated “sliding expiration” requirement.

You’re using AbsoluteExpirationRelativeToNow = 1h. If the intended behavior is “expire 1 hour after last access”, this should be SlidingExpiration.

Proposed change (if sliding expiration is required)
-            var cacheOptions = new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) };
+            var cacheOptions = new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromHours(1) };
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d2f2aa3 and bde4a73.

📒 Files selected for processing (4)
  • Backend.Tests/Controllers/MergeControllerTests.cs
  • Backend.Tests/Services/MergeServiceTests.cs
  • Backend/Services/MergeService.cs
  • Backend/Startup.cs
🧰 Additional context used
🧬 Code graph analysis (4)
Backend/Startup.cs (1)
Backend/Services/MergeService.cs (1)
  • MergeService (15-466)
Backend.Tests/Controllers/MergeControllerTests.cs (1)
Backend/Services/MergeService.cs (1)
  • MergeService (15-466)
Backend.Tests/Services/MergeServiceTests.cs (2)
Backend/Services/MergeService.cs (2)
  • MergeService (15-466)
  • List (63-72)
Backend/Interfaces/IMergeService.cs (1)
  • List (20-20)
Backend/Services/MergeService.cs (1)
Backend/Interfaces/IMergeService.cs (1)
  • List (20-20)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: CodeQL analysis (csharp)
  • GitHub Check: Agent
  • GitHub Check: docker_build
🔇 Additional comments (3)
Backend/Startup.cs (1)

259-263: DI lifetime change looks correct; confirm callers don’t rely on per-instance state.

Switching IMergeService/MergeService to transient resolves the “singleton capturing transients” issue. Just ensure any previously “sticky” in-memory state is now handled via IMemoryCache/static as intended, since the service will be re-created frequently.

Backend/Services/MergeService.cs (2)

15-33: Constructor/fields look fine for transient + shared cache; double-check intended “shared state” model.

Given MergeService is now transient, using shared state via IMemoryCache (and static members) is consistent with the goal of not capturing transient repos while keeping cross-instance request coordination.


24-29: Verify Interlocked.Increment(ref ulong) support for your target frameworks.

If the repo targets frameworks where Interlocked.Increment doesn’t support ulong, this won’t compile. A common fallback is a long counter with cast to ulong.

@imnasnainaec
Copy link
Collaborator

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Jan 12, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (3)
Backend.Tests/Mocks/WordRepositoryMock.cs (1)

97-109: Potential race condition on _getFrontierCallCount increment.

The ++_getFrontierCallCount == 1 check is not atomic. If two threads call GetFrontier concurrently, both could read and increment simultaneously, potentially causing both to wait (or neither to wait depending on timing).

If the intent is strictly "only first caller waits," consider using Interlocked.Increment:

if (Interlocked.Increment(ref _getFrontierCallCount) == 1)

However, if the current behavior is acceptable for your test scenarios (since this is mock code), this may be fine as-is.

Backend.Tests/Controllers/MergeControllerTests.cs (1)

39-40: LGTM! Consider storing and disposing the cache.

The constructor call correctly aligns with the new MergeService signature. For completeness, you could store the MemoryCache in a field and dispose it in Dispose(), though for test code this is a minor concern.

♻️ Optional: Track cache for disposal
+        private IMemoryCache _cache = null!;
         private IMergeBlacklistRepository _mergeBlacklistRepo = null!;
         // ...

         public void Setup()
         {
+            _cache = new MemoryCache(new MemoryCacheOptions());
             _mergeBlacklistRepo = new MergeBlacklistRepositoryMock();
             // ...
-            _mergeService = new MergeService(new MemoryCache(new MemoryCacheOptions()), _mergeBlacklistRepo,
+            _mergeService = new MergeService(_cache, _mergeBlacklistRepo,
                 _mergeGraylistRepo, _wordRepo, _wordService);
         }

         public void Dispose()
         {
             _mergeController?.Dispose();
+            (_cache as IDisposable)?.Dispose();
             GC.SuppressFinalize(this);
         }
Backend/Services/MergeService.cs (1)

24-28: Static lock for transient service is correct but may cause contention under load.

The static _cacheLock ensures thread safety across all transient instances, which is necessary. However, since all users serialize on this single lock, it could become a bottleneck under high concurrent load.

If performance becomes an issue, consider per-user locking (e.g., ConcurrentDictionary<string, object> keyed by userId) to reduce contention.

♻️ Optional: Per-user locking for reduced contention
private static readonly ConcurrentDictionary<string, object> _userLocks = new();

// In StoreDups/RetrieveDups:
var userLock = _userLocks.GetOrAdd(userId, _ => new object());
lock (userLock)
{
    // cache operations
}
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bde4a73 and 08d665c.

📒 Files selected for processing (4)
  • Backend.Tests/Controllers/MergeControllerTests.cs
  • Backend.Tests/Mocks/WordRepositoryMock.cs
  • Backend.Tests/Services/MergeServiceTests.cs
  • Backend/Services/MergeService.cs
🚧 Files skipped from review as they are similar to previous changes (1)
  • Backend.Tests/Services/MergeServiceTests.cs
🧰 Additional context used
🧬 Code graph analysis (3)
Backend.Tests/Mocks/WordRepositoryMock.cs (2)
Backend/Models/Word.cs (3)
  • Word (10-177)
  • Word (87-107)
  • Word (110-132)
Backend/Interfaces/IWordRepository.cs (16)
  • Task (9-9)
  • Task (10-10)
  • Task (11-11)
  • Task (12-12)
  • Task (13-13)
  • Task (14-14)
  • Task (15-15)
  • Task (16-16)
  • Task (17-17)
  • Task (18-18)
  • Task (19-19)
  • Task (20-20)
  • Task (21-21)
  • Task (22-22)
  • Task (23-23)
  • Task (24-24)
Backend/Services/MergeService.cs (1)
Backend/Interfaces/IMergeService.cs (1)
  • List (20-20)
Backend.Tests/Controllers/MergeControllerTests.cs (1)
Backend/Services/MergeService.cs (1)
  • MergeService (15-470)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: CodeQL analysis (csharp)
  • GitHub Check: Agent
  • GitHub Check: test_build (8.0.x)
  • GitHub Check: docker_build
  • GitHub Check: Analyze (csharp)
🔇 Additional comments (8)
Backend.Tests/Mocks/WordRepositoryMock.cs (2)

12-13: LGTM!

Clean use of C# 12 collection expressions for list initialization.


15-26: LGTM!

Good addition for simulating asynchronous delays in tests. The XML documentation clearly explains the intended behavior.

Backend.Tests/Controllers/MergeControllerTests.cs (1)

10-10: LGTM!

Required import for the new MemoryCache usage.

Backend/Services/MergeService.cs (5)

15-22: LGTM! Good use of primary constructor with explicit readonly fields.

The C# 12 primary constructor pattern combined with explicit readonly field declarations is a clean approach - it provides clarity and prevents accidental mutation of injected dependencies.


30-33: LGTM!

Cache key prefix and otel tag constants are well-defined and follow clear naming conventions.


41-57: LGTM! Cache-based storage with proper thread safety.

The logic correctly handles:

  • Initial null storage (request started marker)
  • Subsequent update with actual duplicates (same counter)
  • Newer request preemption (higher counter)
  • 1-hour expiration as per requirements

63-76: LGTM! One-time retrieval pattern correctly implemented.

The remove-after-read pattern ensures each duplicate set is consumed only once, which matches the expected workflow.

Minor nit: The cache key construction could be moved before the lock since it's a pure string operation, reducing time spent holding the lock.


423-436: LGTM! Thread-safe counter increment with proper request preemption.

The combination of Interlocked.Increment for atomic counter updates and the StoreDups logic correctly implements the "newest request wins" pattern. If a newer request starts while work is in progress, the current request gracefully yields.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@imnasnainaec imnasnainaec marked this pull request as ready for review January 12, 2026 21:12
Copy link
Contributor

@jasonleenaylor jasonleenaylor left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jasonleenaylor reviewed 3 files and all commit messages, and made 5 comments.
Reviewable status: 3 of 5 files reviewed, 1 unresolved discussion (waiting on @imnasnainaec).


Backend.Tests/Mocks/WordRepositoryMock.cs line 16 at r4 (raw file):

        private readonly List<Word> _frontier = [];

        private TaskCompletionSource<bool>? _getFrontierDelay;

This implementation is amusing. It isn't really quite using TaskCompletionSource as designed.
I suspect that you could skip the TaskCompletionSource and directly provide the Task you put in the TaskCompletionSource to the same effect.


Backend.Tests/Services/MergeServiceTests.cs line 506 at r4 (raw file):

            Assert.That(await _mergeService.GetAndStorePotentialDuplicates(ProjId, 5, 5, userId1), Is.True);
            Assert.That(await _mergeService.GetAndStorePotentialDuplicates(ProjId, 5, 5, userId2), Is.True);
            Assert.That(await _mergeService.GetAndStorePotentialDuplicates(ProjId, 5, 5, userId1), Is.True);

Not sure this assertion belongs in this unit


Backend.Tests/Services/MergeServiceTests.cs line 513 at r4 (raw file):

        [Test]
        public async Task TestGetAndStorePotentialDuplicatesSecondCallWins()

A comment here explaining how the results confirm the desired result would be good for future maintenance.


Backend.Tests/Services/MergeServiceTests.cs line 530 at r4 (raw file):

            // Release and finish the first call
            delaySignal.SetResult(true);
            Assert.That(await firstCallTask, Is.False);

To use the TaskCompletionSource as designed I think this would not await the task, but instead look for the delaySignal.Result or something similar.


Backend.Tests/Controllers/MergeControllerTests.cs line 39 at r4 (raw file):

            _wordRepo = new WordRepositoryMock();
            _wordService = new WordService(_wordRepo);
            _mergeService = new MergeService(new MemoryCache(new MemoryCacheOptions()), _mergeBlacklistRepo,

This seems suspicious to me. I would expect the MemoryCache to be garbage collected after the MergeService completed based on this code alone.

What I would have expected is that you would have an AddSingleton which provided the cache for use by the service.

Code quote:

new MemoryCache(new MemoryCacheOptions())

Copy link
Collaborator

@imnasnainaec imnasnainaec left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@imnasnainaec made 5 comments.
Reviewable status: 3 of 5 files reviewed, 1 unresolved discussion (waiting on @jasonleenaylor).


Backend.Tests/Controllers/MergeControllerTests.cs line 39 at r4 (raw file):

Previously, jasonleenaylor (Jason Naylor) wrote…

This seems suspicious to me. I would expect the MemoryCache to be garbage collected after the MergeService completed based on this code alone.

What I would have expected is that you would have an AddSingleton which provided the cache for use by the service.

Your expectation matches Copilot's original code. CodeRabbit suggested this simplification, which Claude assures is valid, but also says something like

    _cache = new ServiceCollection().AddMemoryCache().BuildServiceProvider().GetRequiredService<IMemoryCache>();

"is more aligned with best practices".


Backend.Tests/Mocks/WordRepositoryMock.cs line 16 at r4 (raw file):

Previously, jasonleenaylor (Jason Naylor) wrote…

This implementation is amusing. It isn't really quite using TaskCompletionSource as designed.
I suspect that you could skip the TaskCompletionSource and directly provide the Task you put in the TaskCompletionSource to the same effect.

Done


Backend.Tests/Services/MergeServiceTests.cs line 506 at r4 (raw file):

Previously, jasonleenaylor (Jason Naylor) wrote…

Not sure this assertion belongs in this unit

Updates test name and added a few more. Or is that just very unnecessary?


Backend.Tests/Services/MergeServiceTests.cs line 513 at r4 (raw file):

Previously, jasonleenaylor (Jason Naylor) wrote…

A comment here explaining how the results confirm the desired result would be good for future maintenance.

Done


Backend.Tests/Services/MergeServiceTests.cs line 530 at r4 (raw file):

Previously, jasonleenaylor (Jason Naylor) wrote…

To use the TaskCompletionSource as designed I think this would not await the task, but instead look for the delaySignal.Result or something similar.

I don't think I get what yours suggesting.

@jasonleenaylor
Copy link
Contributor

Backend.Tests/Services/MergeServiceTests.cs line 506 at r4 (raw file):

Previously, imnasnainaec (D. Ror.) wrote…

Updates test name and added a few more. Or is that just very unnecessary?

It doesn't seem especially necessary to me, but it isn't harmful. My gut says it has little to no effect on the code coverage (what lines does it catch that the other tests don't?)

Copy link
Contributor

@jasonleenaylor jasonleenaylor left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jasonleenaylor reviewed 2 files and all commit messages, and made 1 comment.
Reviewable status: all files reviewed, 1 unresolved discussion (waiting on @copilot[bot]).


Backend.Tests/Services/MergeServiceTests.cs line 530 at r4 (raw file):

Previously, imnasnainaec (D. Ror.) wrote…

I don't think I get what yours suggesting.

If you look at the example code here it might clarify, but it doesn't matter with the current implementation.
https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskcompletionsource

Copy link
Collaborator

@imnasnainaec imnasnainaec left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@imnasnainaec made 2 comments and resolved 1 discussion.
Reviewable status: 3 of 5 files reviewed, all discussions resolved (waiting on @jasonleenaylor).


Backend.Tests/Controllers/MergeControllerTests.cs line 39 at r4 (raw file):

Previously, imnasnainaec (D. Ror.) wrote…

Your expectation matches Copilot's original code. CodeRabbit suggested this simplification, which Claude assures is valid, but also says something like

    _cache = new ServiceCollection().AddMemoryCache().BuildServiceProvider().GetRequiredService<IMemoryCache>();

"is more aligned with best practices".

Done


Backend.Tests/Services/MergeServiceTests.cs line 506 at r4 (raw file):

Previously, jasonleenaylor (Jason Naylor) wrote…

It doesn't seem especially necessary to me, but it isn't harmful. My gut says it has little to no effect on the code coverage (what lines does it catch that the other tests don't?)

No extra line coverage that I can see. The intent was scenario coverage.

Copy link
Contributor

@jasonleenaylor jasonleenaylor left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:lgtm: (for real)

@jasonleenaylor reviewed 2 files and all commit messages, and made 1 comment.
Reviewable status: :shipit: complete! all files reviewed, all discussions resolved (waiting on @copilot[bot]).

@imnasnainaec imnasnainaec merged commit 1c058e5 into master Jan 13, 2026
15 of 16 checks passed
@imnasnainaec imnasnainaec deleted the copilot/fix-merge-service-dependency branch January 13, 2026 22:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fix singleton MergeService's dependency on transient repositories

3 participants