feat: complete WorkProcessor integration and bug fixes

- Fix hourly lookback bug: use hourly timestamp/interval (not daily)
- Update DI registrations across DataSync, DataAccess, Api layers
- Add WorkProcessor config to appsettings.json
- Remove deprecated DataSyncService (replaced by WorkProcessor)

All 340 DataSync tests pass. Legacy bug from OLD solution now fixed.
This commit is contained in:
Joseph Doherty
2026-01-07 06:26:45 -05:00
parent 91b516e197
commit 5ee920a399
8 changed files with 30 additions and 841 deletions
@@ -1,6 +1,8 @@
using System.Text.Json.Serialization;
using JdeScoping.Api.Hubs;
using JdeScoping.Api.Options;
using JdeScoping.Api.Services;
using JdeScoping.Core.Interfaces;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@@ -36,6 +38,9 @@ public static class ApiDependencyInjection
// Configure SignalR
services.AddSignalR();
// Register SignalR notification service
services.AddScoped<ISearchNotificationService, SearchNotificationService>();
// Configure cookie authentication
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
@@ -49,7 +49,7 @@ public static class DataAccessDependencyInjection
// Register search processing services (scoped)
services.AddScoped<IWorkOrderTraversalService, WorkOrderTraversalService>();
services.AddScoped<SearchProcessor>();
services.AddScoped<ISearchProcessor, SearchProcessor>();
return services;
}
@@ -1,160 +0,0 @@
using JdeScoping.DataSync.Options;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Telemetry;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace JdeScoping.DataSync;
/// <summary>
/// Background service that orchestrates data synchronization from JDE/CMS to SQL Server cache.
/// </summary>
public class DataSyncService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOptions<DataSyncOptions> _options;
private readonly ILogger<DataSyncService> _logger;
private readonly DataSyncMetrics _metrics;
private DateTime _lastPurgeCheck = DateTime.MinValue;
private readonly TimeSpan _purgeCheckInterval = TimeSpan.FromHours(24);
/// <summary>
/// Initializes a new instance of the <see cref="DataSyncService"/> class.
/// </summary>
public DataSyncService(
IServiceScopeFactory scopeFactory,
IOptions<DataSyncOptions> options,
ILogger<DataSyncService> logger,
DataSyncMetrics metrics)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
}
/// <inheritdoc/>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_options.Value.Enabled)
{
_logger.LogInformation("DataSyncService is disabled via configuration");
return;
}
_logger.LogInformation(
"DataSyncService starting with CheckInterval={CheckInterval}, MaxDegreeOfParallelism={MaxDegreeOfParallelism}",
_options.Value.CheckInterval,
_options.Value.MaxDegreeOfParallelism);
// Startup: close any interrupted syncs from prior runs
await CloseOpenUpdateEntriesAsync(stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Create scope for this sync cycle
await using var scope = _scopeFactory.CreateAsyncScope();
var orchestrator = scope.ServiceProvider.GetRequiredService<ISyncOrchestrator>();
// Check schedules and execute pending syncs
await orchestrator.ExecutePendingSyncsAsync(stoppingToken);
// Periodic purge of old DataUpdate records
await PurgeUpdateEntriesAsync(scope, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Graceful shutdown
_logger.LogInformation("DataSyncService stopping gracefully");
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in sync cycle");
_metrics.RecordCycleError();
}
// Wait before next check
try
{
await Task.Delay(_options.Value.CheckInterval, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
_logger.LogInformation("DataSyncService stopped");
}
/// <summary>
/// Closes any open update entries from interrupted prior runs.
/// </summary>
private async Task CloseOpenUpdateEntriesAsync(CancellationToken cancellationToken)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<IDataUpdateRepository>();
var closedCount = await repository.CloseOpenUpdateEntriesAsync(cancellationToken);
if (closedCount > 0)
{
_logger.LogWarning(
"Closed {Count} interrupted update entries from prior runs",
closedCount);
}
else
{
_logger.LogDebug("No interrupted update entries found");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to close open update entries at startup");
// Continue starting - this is not fatal
}
}
/// <summary>
/// Purges old DataUpdate records periodically.
/// </summary>
private async Task PurgeUpdateEntriesAsync(AsyncServiceScope scope, CancellationToken cancellationToken)
{
if (DateTime.UtcNow - _lastPurgeCheck < _purgeCheckInterval)
{
return;
}
_lastPurgeCheck = DateTime.UtcNow;
try
{
var repository = scope.ServiceProvider.GetRequiredService<IDataUpdateRepository>();
var purgedCount = await repository.PurgeOldEntriesAsync(
_options.Value.PurgeRetentionDays,
cancellationToken);
if (purgedCount > 0)
{
_logger.LogInformation(
"Purged {Count} DataUpdate records older than {Days} days",
purgedCount,
_options.Value.PurgeRetentionDays);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to purge old update entries");
// Continue - this is not fatal
}
}
}
@@ -29,6 +29,12 @@ public static class DataSyncDependencyInjection
.ValidateDataAnnotations()
.ValidateOnStart();
// WorkProcessor configuration with validation
services.AddOptions<WorkProcessorOptions>()
.Bind(configuration.GetSection(WorkProcessorOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
// Pipeline configuration (new ETL infrastructure)
services.AddOptions<PipelineOptions>()
.Bind(configuration.GetSection(PipelineOptions.SectionName));
@@ -36,8 +42,8 @@ public static class DataSyncDependencyInjection
// Pipeline factory (new ETL infrastructure)
services.AddSingleton<IEtlPipelineFactory, EtlPipelineFactory>();
// Register hosted service
services.AddHostedService<DataSyncService>();
// Register hosted service (WorkProcessor combines data sync and search processing)
services.AddHostedService<WorkProcessor>();
// Register core services as scoped (for parallel isolation)
services.AddScoped<ISyncOrchestrator, SyncOrchestrator>();
@@ -45,6 +51,10 @@ public static class DataSyncDependencyInjection
services.AddScoped<ITableSyncOperation, TableSyncOperation>();
services.AddScoped<IDataUpdateRepository, DataUpdateRepository>();
// Register search processing services as scoped
services.AddScoped<ISearchRepository, SearchRepository>();
services.AddScoped<ISearchExecutionService, SearchExecutionService>();
// Register health check
services.AddHealthChecks()
.AddCheck<DataSyncHealthCheck>("data-sync", tags: ["datasync", "background"]);
@@ -107,11 +107,10 @@ public class ScheduleChecker : IScheduleChecker
return CreateTask(config, UpdateTypes.Daily, minimumDt);
}
// Check Hourly (uses Daily's last timestamp for MinimumDT calculation, per legacy behavior)
// Check Hourly
if (config.HourlyConfig.Enabled && NeedsHourlySync(config, lastHourly, lastDaily, lastMass, now))
{
// Use daily update timestamp for lookback, not hourly
var minimumDt = CalculateMinimumDt(lastDaily, config.DailyConfig.IntervalMinutes);
var minimumDt = CalculateMinimumDt(lastHourly, config.HourlyConfig.IntervalMinutes);
_logger.LogDebug(
"Hourly sync needed for {Table}: last={LastSync}, interval={Interval}m, minDT={MinDT}",
+6
View File
@@ -128,6 +128,12 @@
"UseFileDataSource": false,
"FileDirectory": "DevData"
},
"WorkProcessor": {
"Enabled": true,
"WorkInterval": "00:00:05",
"SearchTimeout": "00:30:00",
"PurgeRetentionDays": 30
},
"Logging": {
"LogLevel": {
"Default": "Information",
@@ -1,671 +0,0 @@
using System.Diagnostics.Metrics;
using JdeScoping.DataSync.Options;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Telemetry;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using Shouldly;
namespace JdeScoping.DataSync.Tests;
/// <summary>
/// Integration tests for DataSyncService.
/// These tests verify the service lifecycle and orchestration behavior.
/// </summary>
public class DataSyncServiceTests
{
#region Service Startup and Shutdown
[Fact]
public async Task ExecuteAsync_WhenDisabled_ExitsImmediately()
{
// Arrange
var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = false
});
var services = new ServiceCollection();
services.AddSingleton(Substitute.For<IDataUpdateRepository>());
services.AddSingleton(Substitute.For<ISyncOrchestrator>());
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
var task = service.StartAsync(cts.Token);
await Task.Delay(100); // Give it time to start
// Assert: Service should complete quickly since it's disabled
await service.StopAsync(CancellationToken.None);
task.IsCompleted.ShouldBeTrue();
}
[Fact]
public async Task ExecuteAsync_WhenEnabled_StartsAndCanBeStopped()
{
// Arrange
var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(100)
});
var repository = Substitute.For<IDataUpdateRepository>();
repository.CloseOpenUpdateEntriesAsync(Arg.Any<CancellationToken>())
.Returns(0);
var orchestratorCallCount = 0;
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
orchestratorCallCount++;
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(350); // Let it run a few cycles
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert: Should have called orchestrator at least once
orchestratorCallCount.ShouldBeGreaterThan(0);
}
[Fact]
public async Task ExecuteAsync_GracefulShutdown_CompletesCleanly()
{
// Arrange
var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromSeconds(10) // Long interval
});
var repository = Substitute.For<IDataUpdateRepository>();
var orchestrator = Substitute.For<ISyncOrchestrator>();
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
// Request cancellation after brief delay
await Task.Delay(50);
cts.Cancel();
// Should not throw and should complete
await service.StopAsync(CancellationToken.None);
// Assert: No exceptions thrown during shutdown
}
#endregion
#region CloseOpenUpdateEntries at Startup
[Fact]
public async Task ExecuteAsync_AtStartup_CallsCloseOpenUpdateEntries()
{
// Arrange
var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
});
var closeEntriesCallCount = 0;
var repository = Substitute.For<IDataUpdateRepository>();
repository.CloseOpenUpdateEntriesAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
closeEntriesCallCount++;
return Task.FromResult(0);
});
var orchestrator = Substitute.For<ISyncOrchestrator>();
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert
closeEntriesCallCount.ShouldBe(1);
}
[Fact]
public async Task ExecuteAsync_WhenCloseOpenEntriesFindsEntries_LogsAndContinues()
{
// Arrange
var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
});
var repository = Substitute.For<IDataUpdateRepository>();
repository.CloseOpenUpdateEntriesAsync(Arg.Any<CancellationToken>())
.Returns(5); // Found 5 interrupted entries
var orchestratorCallCount = 0;
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
orchestratorCallCount++;
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(150);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert: Should have continued to orchestrator after close
orchestratorCallCount.ShouldBeGreaterThan(0);
}
[Fact]
public async Task ExecuteAsync_WhenCloseOpenEntriesThrows_ContinuesStarting()
{
// Arrange
var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
});
var repository = Substitute.For<IDataUpdateRepository>();
repository.CloseOpenUpdateEntriesAsync(Arg.Any<CancellationToken>())
.Returns<int>(x => throw new Exception("Database error"));
var orchestratorCallCount = 0;
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
orchestratorCallCount++;
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act - Should not throw even if CloseOpenUpdateEntries fails
await service.StartAsync(cts.Token);
await Task.Delay(150);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert: Should have continued and called orchestrator
orchestratorCallCount.ShouldBeGreaterThan(0);
}
#endregion
#region Parallel Sync Execution
[Fact]
public async Task ExecuteAsync_CallsOrchestratorForParallelExecution()
{
// Arrange
var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50),
MaxDegreeOfParallelism = 4
});
var repository = Substitute.For<IDataUpdateRepository>();
var orchestratorCallCount = 0;
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
orchestratorCallCount++;
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(200); // Let multiple cycles run
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert: Orchestrator should be called to handle parallel execution
orchestratorCallCount.ShouldBeGreaterThan(0);
}
[Fact]
public async Task ExecuteAsync_WhenOrchestratorThrows_ContinuesNextCycle()
{
// Arrange
var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
});
var repository = Substitute.For<IDataUpdateRepository>();
var callCount = 0;
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
callCount++;
if (callCount == 1)
{
throw new Exception("Sync error");
}
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(250); // Let multiple cycles run
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert: Should have been called multiple times despite first failure
callCount.ShouldBeGreaterThan(1);
}
#endregion
#region Cancellation Handling
[Fact]
public async Task ExecuteAsync_WhenCancelled_StopsGracefully()
{
// Arrange
var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromSeconds(10)
});
var repository = Substitute.For<IDataUpdateRepository>();
var orchestrator = Substitute.For<ISyncOrchestrator>();
// Make orchestrator take some time but respect cancellation
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(async x =>
{
try
{
await Task.Delay(5000, x.Arg<CancellationToken>());
}
catch (OperationCanceledException)
{
// Expected - swallow and return
}
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(100);
// Cancel while orchestrator is running
cts.Cancel();
// Should complete without hanging
var stopTask = service.StopAsync(CancellationToken.None);
var completed = await Task.WhenAny(stopTask, Task.Delay(2000));
// Assert: Should complete, not hang
completed.ShouldBe(stopTask);
}
[Fact]
public async Task ExecuteAsync_PassesCancellationTokenToOrchestrator()
{
// Arrange
var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
});
var repository = Substitute.For<IDataUpdateRepository>();
var orchestrator = Substitute.For<ISyncOrchestrator>();
var tokenWasProvided = false;
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
var token = x.Arg<CancellationToken>();
tokenWasProvided = token != default;
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert: Token should have been passed
tokenWasProvided.ShouldBeTrue();
}
[Fact]
public async Task ExecuteAsync_WhenCancelledDuringDelay_ExitsCleanly()
{
// Arrange
var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMinutes(5) // Long delay
});
var repository = Substitute.For<IDataUpdateRepository>();
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
// Service should be in delay after first cycle
await Task.Delay(100);
// Cancel during delay
cts.Cancel();
// Should exit quickly
var stopTask = service.StopAsync(CancellationToken.None);
var completed = await Task.WhenAny(stopTask, Task.Delay(1000));
// Assert
completed.ShouldBe(stopTask);
}
#endregion
#region Service Scope Isolation
[Fact]
public async Task ExecuteAsync_UsesNewScopePerCycle()
{
// Arrange
var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
});
var repository = Substitute.For<IDataUpdateRepository>();
var orchestrator = Substitute.For<ISyncOrchestrator>();
var scopeCount = 0;
var services = new ServiceCollection();
services.AddScoped<IDataUpdateRepository>(sp =>
{
Interlocked.Increment(ref scopeCount);
return repository;
});
services.AddScoped<ISyncOrchestrator>(sp => orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(200); // Multiple cycles
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert: Multiple scopes should have been created
scopeCount.ShouldBeGreaterThan(1);
}
#endregion
#region Error Handling and Metrics
[Fact]
public async Task ExecuteAsync_WhenSyncFails_ContinuesRunning()
{
// Arrange
var options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
});
var repository = Substitute.For<IDataUpdateRepository>();
var callCount = 0;
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
callCount++;
throw new Exception("Sync failed");
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(200);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert: Should have continued calling orchestrator despite failures
callCount.ShouldBeGreaterThan(1);
}
#endregion
#region Helper Methods
private static DataSyncMetrics CreateMetrics()
{
// Use real MeterFactory since mocking Meter is complex
var services = new ServiceCollection();
services.AddMetrics();
var provider = services.BuildServiceProvider();
var meterFactory = provider.GetRequiredService<IMeterFactory>();
return new DataSyncMetrics(meterFactory);
}
#endregion
}
@@ -211,9 +211,9 @@ public class ScheduleCheckerTests
}
[Fact]
public async Task GetPendingTasksAsync_HourlySync_UsesDailyTimestampForMinimumDT()
public async Task GetPendingTasksAsync_HourlySync_UsesHourlyTimestampForMinimumDT()
{
// Arrange: Per legacy behavior, hourly uses DAILY's timestamp for MinimumDT calculation
// Arrange: Hourly uses its own timestamp and interval for MinimumDT calculation
var config = CreateDataSourceConfig("WorkOrder",
massEnabled: true, massInterval: 10080,
dailyEnabled: true, dailyInterval: 1440,
@@ -241,8 +241,8 @@ public class ScheduleCheckerTests
tasks[0].UpdateType.ShouldBe(UpdateTypes.Hourly);
tasks[0].MinimumDt.ShouldNotBeNull();
// Hourly uses daily's timestamp and daily's interval for lookback calculation
var expectedMinimumDt = lastDaily.EndDt.AddMinutes(-3 * 1440);
// Hourly uses hourly's timestamp and hourly's interval for lookback calculation
var expectedMinimumDt = lastHourly.EndDt.AddMinutes(-3 * 60);
tasks[0].MinimumDt!.Value.ShouldBe(expectedMinimumDt, TimeSpan.FromSeconds(1));
}