From 543a77a1cd634b62da5f11e42388290aad754727 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:01:02 +0000 Subject: [PATCH 1/6] Initial plan From 9f0628a702196e3a5e2f57a2452aafe4388f95b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:05:58 +0000 Subject: [PATCH 2/6] Fix static field modification in instance constructor - Removed static keyword from _modules field to prevent shared state issues - Changed from AppDbContext to IDbContextFactory for proper singleton support - Added lazy initialization with thread-safe semaphore lock - Changed service registration from Transient to Singleton - Updated Fake service with same pattern for consistency Co-authored-by: afshinalizadeh <4254006+afshinalizadeh@users.noreply.github.com> --- .../ApplicationBuilderExtensions.cs | 2 +- .../CrystaProgramSyncModuleService.cs | 47 ++++++++++++++----- .../CrystaProgramSyncModuleServiceFake.cs | 6 ++- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/Core/CrystaLearn.Core/Extensions/ApplicationBuilderExtensions.cs b/src/Core/CrystaLearn.Core/Extensions/ApplicationBuilderExtensions.cs index b20079c..6c1a3d2 100644 --- a/src/Core/CrystaLearn.Core/Extensions/ApplicationBuilderExtensions.cs +++ b/src/Core/CrystaLearn.Core/Extensions/ApplicationBuilderExtensions.cs @@ -39,7 +39,7 @@ void AddDbContext(DbContextOptionsBuilder options) services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); + services.AddSingleton(); services.AddTransient(); } diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs index 95e173e..7c7ddfd 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs @@ -12,20 +12,40 @@ namespace CrystaLearn.Core.Services; public partial class CrystaProgramSyncModuleService : ICrystaProgramSyncModuleService { - private static List _modules = new(); - private AppDbContext DbContext { get; set; } = default!; + private List _modules = new(); + private IDbContextFactory DbContextFactory { get; set; } = default!; + private bool _initialized = false; + private readonly SemaphoreSlim _initLock = new SemaphoreSlim(1, 1); - public CrystaProgramSyncModuleService(AppDbContext dbContext) + public CrystaProgramSyncModuleService(IDbContextFactory dbContextFactory) { - this.DbContext = dbContext; - if (_modules.Count == 0) + this.DbContextFactory = dbContextFactory; + } + + private async Task EnsureInitializedAsync(CancellationToken cancellationToken) + { + if (!_initialized) { - _modules = DbContext.Set().Include(f => f.CrystaProgram).ToListAsync().GetAwaiter().GetResult(); + await _initLock.WaitAsync(cancellationToken); + try + { + if (!_initialized) + { + await using var dbContext = await DbContextFactory.CreateDbContextAsync(cancellationToken); + _modules = await dbContext.Set().Include(f => f.CrystaProgram).ToListAsync(cancellationToken); + _initialized = true; + } + } + finally + { + _initLock.Release(); + } } } public async Task> GetSyncModulesAsync(CancellationToken cancellationToken) { + await EnsureInitializedAsync(cancellationToken); return _modules; } @@ -34,32 +54,33 @@ public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module) // Try to persist to database if DbContext is available and configured try { - if (DbContext != null) + if (DbContextFactory != null) { - var set = DbContext.Set(); + await using var dbContext = await DbContextFactory.CreateDbContextAsync(CancellationToken.None); + var set = dbContext.Set(); var existing = await set.FindAsync(new object[] { module.Id }, cancellationToken: CancellationToken.None); if (existing != null) { // Update all scalar properties from incoming module - DbContext.Entry(existing).CurrentValues.SetValues(module); + dbContext.Entry(existing).CurrentValues.SetValues(module); // If SyncInfo is an owned/complex type, ensure its properties are updated as well if (module.SyncInfo != null) { existing.SyncInfo ??= new SyncInfo(); - DbContext.Entry(existing).CurrentValues.SetValues(existing); // ensure entry is tracked - DbContext.Entry(existing).Reference(e => e.SyncInfo).TargetEntry?.CurrentValues.SetValues(module.SyncInfo); + dbContext.Entry(existing).CurrentValues.SetValues(existing); // ensure entry is tracked + dbContext.Entry(existing).Reference(e => e.SyncInfo).TargetEntry?.CurrentValues.SetValues(module.SyncInfo); } - DbContext.Update(existing); + dbContext.Update(existing); } else { await set.AddAsync(module); } - await DbContext.SaveChangesAsync(); + await dbContext.SaveChangesAsync(); // keep in-memory copy in sync as well - replace whole object to reflect all fields var idx = _modules.FindIndex(m => m.Id == module.Id); diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs index 95faa1f..da14869 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs @@ -8,14 +8,15 @@ namespace CrystaLearn.Core.Services; public partial class CrystaProgramSyncModuleServiceFake : ICrystaProgramSyncModuleService { - private static List _modules = new(); + private List _modules = new(); + private bool _initialized = false; private IConfiguration Configuration { get; set; } = default!; public CrystaProgramSyncModuleServiceFake(IConfiguration configuration) { Configuration = configuration; - if (_modules.Count == 0) + if (!_initialized) { var pat = Configuration["AzureDevOps:PersonalAccessToken"]; @@ -42,6 +43,7 @@ public CrystaProgramSyncModuleServiceFake(IConfiguration configuration) } } }; + _initialized = true; } } From ffae89b25e2b223caaa576344aa526b23ca7a8ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:07:53 +0000 Subject: [PATCH 3/6] Add thread-safety and improve code quality - Add Microsoft.EntityFrameworkCore using statement for EF Core extensions - Add thread-safe synchronization for UpdateSyncModuleAsync with SemaphoreSlim - Move initialization to async method with proper thread-safety in Fake service - Add synchronization locks for in-memory list modifications Co-authored-by: afshinalizadeh <4254006+afshinalizadeh@users.noreply.github.com> --- .../CrystaProgramSyncModuleService.cs | 38 ++++++--- .../CrystaProgramSyncModuleServiceFake.cs | 80 ++++++++++++------- 2 files changed, 81 insertions(+), 37 deletions(-) diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs index 7c7ddfd..25d4158 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs @@ -6,6 +6,7 @@ using CrystaLearn.Core.Data; using CrystaLearn.Core.Models.Crysta; using CrystaLearn.Core.Services.Contracts; +using Microsoft.EntityFrameworkCore; namespace CrystaLearn.Core.Services; @@ -16,6 +17,7 @@ public partial class CrystaProgramSyncModuleService : ICrystaProgramSyncModuleSe private IDbContextFactory DbContextFactory { get; set; } = default!; private bool _initialized = false; private readonly SemaphoreSlim _initLock = new SemaphoreSlim(1, 1); + private readonly SemaphoreSlim _updateLock = new SemaphoreSlim(1, 1); public CrystaProgramSyncModuleService(IDbContextFactory dbContextFactory) { @@ -83,14 +85,22 @@ public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module) await dbContext.SaveChangesAsync(); // keep in-memory copy in sync as well - replace whole object to reflect all fields - var idx = _modules.FindIndex(m => m.Id == module.Id); - if (idx >= 0) + await _updateLock.WaitAsync(); + try { - _modules[idx] = module; + var idx = _modules.FindIndex(m => m.Id == module.Id); + if (idx >= 0) + { + _modules[idx] = module; + } + else + { + _modules.Add(module); + } } - else + finally { - _modules.Add(module); + _updateLock.Release(); } return; @@ -102,14 +112,22 @@ public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module) } // Fallback: update in-memory collection (replace whole object) - var existingInMemoryIndex = _modules.FindIndex(m => m.Id == module.Id); - if (existingInMemoryIndex >= 0) + await _updateLock.WaitAsync(); + try { - _modules[existingInMemoryIndex] = module; + var existingInMemoryIndex = _modules.FindIndex(m => m.Id == module.Id); + if (existingInMemoryIndex >= 0) + { + _modules[existingInMemoryIndex] = module; + } + else + { + _modules.Add(module); + } } - else + finally { - _modules.Add(module); + _updateLock.Release(); } } } diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs index da14869..57264b8 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs @@ -10,59 +10,85 @@ public partial class CrystaProgramSyncModuleServiceFake : ICrystaProgramSyncModu { private List _modules = new(); private bool _initialized = false; + private readonly SemaphoreSlim _initLock = new SemaphoreSlim(1, 1); + private readonly SemaphoreSlim _updateLock = new SemaphoreSlim(1, 1); private IConfiguration Configuration { get; set; } = default!; public CrystaProgramSyncModuleServiceFake(IConfiguration configuration) { Configuration = configuration; + } + + private async Task EnsureInitializedAsync() + { if (!_initialized) { - var pat = Configuration["AzureDevOps:PersonalAccessToken"]; - - _modules = new List + await _initLock.WaitAsync(); + try { - new CrystaProgramSyncModule + if (!_initialized) { - Id = Guid.NewGuid(), - CrystaProgramId = CrystaProgramServiceFake.FakeProgramCSI.Id, - CrystaProgram = CrystaProgramServiceFake.FakeProgramCSI, - ModuleType = SyncModuleType.AzureBoard, - SyncConfig = - $$""" + var pat = Configuration["AzureDevOps:PersonalAccessToken"]; + + _modules = new List + { + new CrystaProgramSyncModule + { + Id = Guid.NewGuid(), + CrystaProgramId = CrystaProgramServiceFake.FakeProgramCSI.Id, + CrystaProgram = CrystaProgramServiceFake.FakeProgramCSI, + ModuleType = SyncModuleType.AzureBoard, + SyncConfig = + $$""" + { + "Organization": "cs-internship", + "PersonalAccessToken": "{{pat}}", + "Project": "CS Internship Program" + } + """, + SyncInfo = new SyncInfo { - "Organization": "cs-internship", - "PersonalAccessToken": "{{pat}}", - "Project": "CS Internship Program" + LastSyncDateTime = DateTimeOffset.Now.AddDays(-2), + LastSyncOffset = "0" } - """, - SyncInfo = new SyncInfo - { - LastSyncDateTime = DateTimeOffset.Now.AddDays(-2), - LastSyncOffset = "0" - } + } + }; + _initialized = true; } - }; - _initialized = true; + } + finally + { + _initLock.Release(); + } } } public async Task> GetSyncModulesAsync(CancellationToken cancellationToken) { + await EnsureInitializedAsync(); return _modules; } public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module) { - var existing = _modules.FirstOrDefault(m => m.Id == module.Id); - if (existing != null) + await _updateLock.WaitAsync(); + try { - existing.SyncInfo = module.SyncInfo; - existing.SyncConfig = module.SyncConfig; + var existing = _modules.FirstOrDefault(m => m.Id == module.Id); + if (existing != null) + { + existing.SyncInfo = module.SyncInfo; + existing.SyncConfig = module.SyncConfig; + } + else + { + _modules.Add(module); + } } - else + finally { - _modules.Add(module); + _updateLock.Release(); } } } From f74ef3eee345af3e61d3348c7780b9dc2c9263c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:09:09 +0000 Subject: [PATCH 4/6] Add CancellationToken support to UpdateSyncModuleAsync - Add CancellationToken parameter to interface method with default value - Propagate cancellation token through all database operations - Pass cancellation token to AddAsync and SaveChangesAsync methods Co-authored-by: afshinalizadeh <4254006+afshinalizadeh@users.noreply.github.com> --- .../Contracts/ICrystaProgramSyncModuleService.cs | 2 +- .../Services/CrystaProgramSyncModuleService.cs | 14 +++++++------- .../Services/CrystaProgramSyncModuleServiceFake.cs | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Core/CrystaLearn.Core/Services/Contracts/ICrystaProgramSyncModuleService.cs b/src/Core/CrystaLearn.Core/Services/Contracts/ICrystaProgramSyncModuleService.cs index 56b5d57..1dfb3b4 100644 --- a/src/Core/CrystaLearn.Core/Services/Contracts/ICrystaProgramSyncModuleService.cs +++ b/src/Core/CrystaLearn.Core/Services/Contracts/ICrystaProgramSyncModuleService.cs @@ -7,5 +7,5 @@ public interface ICrystaProgramSyncModuleService Task> GetSyncModulesAsync(CancellationToken cancellationToken); // Save or update a sync module (persist SyncInfo changes) - Task UpdateSyncModuleAsync(CrystaProgramSyncModule module); + Task UpdateSyncModuleAsync(CrystaProgramSyncModule module, CancellationToken cancellationToken = default); } diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs index 25d4158..d15ea5b 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs @@ -51,17 +51,17 @@ public async Task> GetSyncModulesAsync(Cancellatio return _modules; } - public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module) + public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module, CancellationToken cancellationToken = default) { // Try to persist to database if DbContext is available and configured try { if (DbContextFactory != null) { - await using var dbContext = await DbContextFactory.CreateDbContextAsync(CancellationToken.None); + await using var dbContext = await DbContextFactory.CreateDbContextAsync(cancellationToken); var set = dbContext.Set(); - var existing = await set.FindAsync(new object[] { module.Id }, cancellationToken: CancellationToken.None); + var existing = await set.FindAsync(new object[] { module.Id }, cancellationToken: cancellationToken); if (existing != null) { // Update all scalar properties from incoming module @@ -79,13 +79,13 @@ public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module) } else { - await set.AddAsync(module); + await set.AddAsync(module, cancellationToken); } - await dbContext.SaveChangesAsync(); + await dbContext.SaveChangesAsync(cancellationToken); // keep in-memory copy in sync as well - replace whole object to reflect all fields - await _updateLock.WaitAsync(); + await _updateLock.WaitAsync(cancellationToken); try { var idx = _modules.FindIndex(m => m.Id == module.Id); @@ -112,7 +112,7 @@ public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module) } // Fallback: update in-memory collection (replace whole object) - await _updateLock.WaitAsync(); + await _updateLock.WaitAsync(cancellationToken); try { var existingInMemoryIndex = _modules.FindIndex(m => m.Id == module.Id); diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs index 57264b8..5b6863f 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs @@ -70,9 +70,9 @@ public async Task> GetSyncModulesAsync(Cancellatio return _modules; } - public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module) + public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module, CancellationToken cancellationToken = default) { - await _updateLock.WaitAsync(); + await _updateLock.WaitAsync(cancellationToken); try { var existing = _modules.FirstOrDefault(m => m.Id == module.Id); From e7b5014c2d49220e03c4c90d3f527ad986ecf1e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:10:20 +0000 Subject: [PATCH 5/6] Implement IDisposable for proper resource cleanup - Add IDisposable implementation to both service classes - Properly dispose SemaphoreSlim instances to free unmanaged resources - Add _disposed flag to prevent double disposal Co-authored-by: afshinalizadeh <4254006+afshinalizadeh@users.noreply.github.com> --- .../Services/CrystaProgramSyncModuleService.cs | 13 ++++++++++++- .../Services/CrystaProgramSyncModuleServiceFake.cs | 13 ++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs index d15ea5b..c9c354b 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs @@ -10,7 +10,7 @@ namespace CrystaLearn.Core.Services; -public partial class CrystaProgramSyncModuleService : ICrystaProgramSyncModuleService +public partial class CrystaProgramSyncModuleService : ICrystaProgramSyncModuleService, IDisposable { private List _modules = new(); @@ -18,6 +18,7 @@ public partial class CrystaProgramSyncModuleService : ICrystaProgramSyncModuleSe private bool _initialized = false; private readonly SemaphoreSlim _initLock = new SemaphoreSlim(1, 1); private readonly SemaphoreSlim _updateLock = new SemaphoreSlim(1, 1); + private bool _disposed = false; public CrystaProgramSyncModuleService(IDbContextFactory dbContextFactory) { @@ -130,4 +131,14 @@ public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module, Cancella _updateLock.Release(); } } + + public void Dispose() + { + if (!_disposed) + { + _initLock?.Dispose(); + _updateLock?.Dispose(); + _disposed = true; + } + } } diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs index 5b6863f..6d07f8e 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs @@ -6,12 +6,13 @@ namespace CrystaLearn.Core.Services; -public partial class CrystaProgramSyncModuleServiceFake : ICrystaProgramSyncModuleService +public partial class CrystaProgramSyncModuleServiceFake : ICrystaProgramSyncModuleService, IDisposable { private List _modules = new(); private bool _initialized = false; private readonly SemaphoreSlim _initLock = new SemaphoreSlim(1, 1); private readonly SemaphoreSlim _updateLock = new SemaphoreSlim(1, 1); + private bool _disposed = false; private IConfiguration Configuration { get; set; } = default!; @@ -91,4 +92,14 @@ public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module, Cancella _updateLock.Release(); } } + + public void Dispose() + { + if (!_disposed) + { + _initLock?.Dispose(); + _updateLock?.Dispose(); + _disposed = true; + } + } } From 482ee1d7b8572c154df8b47d037782a56bf5a7bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:11:27 +0000 Subject: [PATCH 6/6] Add CancellationToken to EnsureInitializedAsync in Fake service - Pass cancellation token to semaphore wait operations - Enable proper cancellation propagation during initialization Co-authored-by: afshinalizadeh <4254006+afshinalizadeh@users.noreply.github.com> --- .../Services/CrystaProgramSyncModuleServiceFake.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs index 6d07f8e..5817979 100644 --- a/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs +++ b/src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleServiceFake.cs @@ -21,11 +21,11 @@ public CrystaProgramSyncModuleServiceFake(IConfiguration configuration) Configuration = configuration; } - private async Task EnsureInitializedAsync() + private async Task EnsureInitializedAsync(CancellationToken cancellationToken = default) { if (!_initialized) { - await _initLock.WaitAsync(); + await _initLock.WaitAsync(cancellationToken); try { if (!_initialized) @@ -67,7 +67,7 @@ private async Task EnsureInitializedAsync() public async Task> GetSyncModulesAsync(CancellationToken cancellationToken) { - await EnsureInitializedAsync(); + await EnsureInitializedAsync(cancellationToken); return _modules; }