Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ void AddDbContext(DbContextOptionsBuilder options)
services.AddTransient<ICrystaProgramSyncService, CrystaProgramSyncService>();
services.AddTransient<IAzureBoardSyncService, AzureBoardSyncService>();
services.AddTransient<IGitHubSyncService, GitHubSyncService>();
services.AddTransient<ICrystaProgramSyncModuleService, CrystaProgramSyncModuleService>();
services.AddSingleton<ICrystaProgramSyncModuleService, CrystaProgramSyncModuleService>();
services.AddTransient<ICrystaTaskService, CrystaTaskService>();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ public interface ICrystaProgramSyncModuleService
Task<List<CrystaProgramSyncModule>> GetSyncModulesAsync(CancellationToken cancellationToken);

// Save or update a sync module (persist SyncInfo changes)
Task UpdateSyncModuleAsync(CrystaProgramSyncModule module);
Task UpdateSyncModuleAsync(CrystaProgramSyncModule module, CancellationToken cancellationToken = default);
}
104 changes: 77 additions & 27 deletions src/Core/CrystaLearn.Core/Services/CrystaProgramSyncModuleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,70 +6,102 @@
using CrystaLearn.Core.Data;
using CrystaLearn.Core.Models.Crysta;
using CrystaLearn.Core.Services.Contracts;
using Microsoft.EntityFrameworkCore;

namespace CrystaLearn.Core.Services;

public partial class CrystaProgramSyncModuleService : ICrystaProgramSyncModuleService
public partial class CrystaProgramSyncModuleService : ICrystaProgramSyncModuleService, IDisposable
{

private static List<CrystaProgramSyncModule> _modules = new();
private AppDbContext DbContext { get; set; } = default!;
private List<CrystaProgramSyncModule> _modules = new();
private IDbContextFactory<AppDbContext> DbContextFactory { get; set; } = default!;
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(AppDbContext dbContext)
public CrystaProgramSyncModuleService(IDbContextFactory<AppDbContext> dbContextFactory)
{
this.DbContext = dbContext;
if (_modules.Count == 0)
this.DbContextFactory = dbContextFactory;
}

private async Task EnsureInitializedAsync(CancellationToken cancellationToken)
{
if (!_initialized)
{
_modules = DbContext.Set<CrystaProgramSyncModule>().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<CrystaProgramSyncModule>().Include(f => f.CrystaProgram).ToListAsync(cancellationToken);
_initialized = true;
}
}
finally
{
_initLock.Release();
}
}
}

public async Task<List<CrystaProgramSyncModule>> GetSyncModulesAsync(CancellationToken cancellationToken)
{
await EnsureInitializedAsync(cancellationToken);
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 (DbContext != null)
if (DbContextFactory != null)
{
var set = DbContext.Set<CrystaProgramSyncModule>();
await using var dbContext = await DbContextFactory.CreateDbContextAsync(cancellationToken);
var set = dbContext.Set<CrystaProgramSyncModule>();

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
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 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
var idx = _modules.FindIndex(m => m.Id == module.Id);
if (idx >= 0)
await _updateLock.WaitAsync(cancellationToken);
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;
Expand All @@ -81,14 +113,32 @@ 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(cancellationToken);
try
{
var existingInMemoryIndex = _modules.FindIndex(m => m.Id == module.Id);
if (existingInMemoryIndex >= 0)
{
_modules[existingInMemoryIndex] = module;
}
else
{
_modules.Add(module);
}
}
finally
{
_modules[existingInMemoryIndex] = module;
_updateLock.Release();
}
else
}

public void Dispose()
{
if (!_disposed)
{
_modules.Add(module);
_initLock?.Dispose();
_updateLock?.Dispose();
_disposed = true;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,61 +6,100 @@

namespace CrystaLearn.Core.Services;

public partial class CrystaProgramSyncModuleServiceFake : ICrystaProgramSyncModuleService
public partial class CrystaProgramSyncModuleServiceFake : ICrystaProgramSyncModuleService, IDisposable
{
private static List<CrystaProgramSyncModule> _modules = new();
private List<CrystaProgramSyncModule> _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!;

public CrystaProgramSyncModuleServiceFake(IConfiguration configuration)
{
Configuration = configuration;
if (_modules.Count == 0)
{
var pat = Configuration["AzureDevOps:PersonalAccessToken"];
}

_modules = new List<CrystaProgramSyncModule>
private async Task EnsureInitializedAsync(CancellationToken cancellationToken = default)
{
if (!_initialized)
{
await _initLock.WaitAsync(cancellationToken);
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<CrystaProgramSyncModule>
{
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;
}
};
}
finally
{
_initLock.Release();
}
}
}

public async Task<List<CrystaProgramSyncModule>> GetSyncModulesAsync(CancellationToken cancellationToken)
{
await EnsureInitializedAsync(cancellationToken);
return _modules;
}

public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module)
public async Task UpdateSyncModuleAsync(CrystaProgramSyncModule module, CancellationToken cancellationToken = default)
{
var existing = _modules.FirstOrDefault(m => m.Id == module.Id);
if (existing != null)
await _updateLock.WaitAsync(cancellationToken);
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);
}
}
finally
{
_updateLock.Release();
}
else
}

public void Dispose()
{
if (!_disposed)
{
_modules.Add(module);
_initLock?.Dispose();
_updateLock?.Dispose();
_disposed = true;
}
}
}