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:
@@ -1,6 +1,8 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using JdeScoping.Api.Hubs;
|
using JdeScoping.Api.Hubs;
|
||||||
using JdeScoping.Api.Options;
|
using JdeScoping.Api.Options;
|
||||||
|
using JdeScoping.Api.Services;
|
||||||
|
using JdeScoping.Core.Interfaces;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
@@ -36,6 +38,9 @@ public static class ApiDependencyInjection
|
|||||||
// Configure SignalR
|
// Configure SignalR
|
||||||
services.AddSignalR();
|
services.AddSignalR();
|
||||||
|
|
||||||
|
// Register SignalR notification service
|
||||||
|
services.AddScoped<ISearchNotificationService, SearchNotificationService>();
|
||||||
|
|
||||||
// Configure cookie authentication
|
// Configure cookie authentication
|
||||||
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
.AddCookie(options =>
|
.AddCookie(options =>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ public static class DataAccessDependencyInjection
|
|||||||
|
|
||||||
// Register search processing services (scoped)
|
// Register search processing services (scoped)
|
||||||
services.AddScoped<IWorkOrderTraversalService, WorkOrderTraversalService>();
|
services.AddScoped<IWorkOrderTraversalService, WorkOrderTraversalService>();
|
||||||
services.AddScoped<SearchProcessor>();
|
services.AddScoped<ISearchProcessor, SearchProcessor>();
|
||||||
|
|
||||||
return services;
|
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()
|
.ValidateDataAnnotations()
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
|
|
||||||
|
// WorkProcessor configuration with validation
|
||||||
|
services.AddOptions<WorkProcessorOptions>()
|
||||||
|
.Bind(configuration.GetSection(WorkProcessorOptions.SectionName))
|
||||||
|
.ValidateDataAnnotations()
|
||||||
|
.ValidateOnStart();
|
||||||
|
|
||||||
// Pipeline configuration (new ETL infrastructure)
|
// Pipeline configuration (new ETL infrastructure)
|
||||||
services.AddOptions<PipelineOptions>()
|
services.AddOptions<PipelineOptions>()
|
||||||
.Bind(configuration.GetSection(PipelineOptions.SectionName));
|
.Bind(configuration.GetSection(PipelineOptions.SectionName));
|
||||||
@@ -36,8 +42,8 @@ public static class DataSyncDependencyInjection
|
|||||||
// Pipeline factory (new ETL infrastructure)
|
// Pipeline factory (new ETL infrastructure)
|
||||||
services.AddSingleton<IEtlPipelineFactory, EtlPipelineFactory>();
|
services.AddSingleton<IEtlPipelineFactory, EtlPipelineFactory>();
|
||||||
|
|
||||||
// Register hosted service
|
// Register hosted service (WorkProcessor combines data sync and search processing)
|
||||||
services.AddHostedService<DataSyncService>();
|
services.AddHostedService<WorkProcessor>();
|
||||||
|
|
||||||
// Register core services as scoped (for parallel isolation)
|
// Register core services as scoped (for parallel isolation)
|
||||||
services.AddScoped<ISyncOrchestrator, SyncOrchestrator>();
|
services.AddScoped<ISyncOrchestrator, SyncOrchestrator>();
|
||||||
@@ -45,6 +51,10 @@ public static class DataSyncDependencyInjection
|
|||||||
services.AddScoped<ITableSyncOperation, TableSyncOperation>();
|
services.AddScoped<ITableSyncOperation, TableSyncOperation>();
|
||||||
services.AddScoped<IDataUpdateRepository, DataUpdateRepository>();
|
services.AddScoped<IDataUpdateRepository, DataUpdateRepository>();
|
||||||
|
|
||||||
|
// Register search processing services as scoped
|
||||||
|
services.AddScoped<ISearchRepository, SearchRepository>();
|
||||||
|
services.AddScoped<ISearchExecutionService, SearchExecutionService>();
|
||||||
|
|
||||||
// Register health check
|
// Register health check
|
||||||
services.AddHealthChecks()
|
services.AddHealthChecks()
|
||||||
.AddCheck<DataSyncHealthCheck>("data-sync", tags: ["datasync", "background"]);
|
.AddCheck<DataSyncHealthCheck>("data-sync", tags: ["datasync", "background"]);
|
||||||
|
|||||||
@@ -107,11 +107,10 @@ public class ScheduleChecker : IScheduleChecker
|
|||||||
return CreateTask(config, UpdateTypes.Daily, minimumDt);
|
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))
|
if (config.HourlyConfig.Enabled && NeedsHourlySync(config, lastHourly, lastDaily, lastMass, now))
|
||||||
{
|
{
|
||||||
// Use daily update timestamp for lookback, not hourly
|
var minimumDt = CalculateMinimumDt(lastHourly, config.HourlyConfig.IntervalMinutes);
|
||||||
var minimumDt = CalculateMinimumDt(lastDaily, config.DailyConfig.IntervalMinutes);
|
|
||||||
|
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Hourly sync needed for {Table}: last={LastSync}, interval={Interval}m, minDT={MinDT}",
|
"Hourly sync needed for {Table}: last={LastSync}, interval={Interval}m, minDT={MinDT}",
|
||||||
|
|||||||
@@ -128,6 +128,12 @@
|
|||||||
"UseFileDataSource": false,
|
"UseFileDataSource": false,
|
||||||
"FileDirectory": "DevData"
|
"FileDirectory": "DevData"
|
||||||
},
|
},
|
||||||
|
"WorkProcessor": {
|
||||||
|
"Enabled": true,
|
||||||
|
"WorkInterval": "00:00:05",
|
||||||
|
"SearchTimeout": "00:30:00",
|
||||||
|
"PurgeRetentionDays": 30
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"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]
|
[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",
|
var config = CreateDataSourceConfig("WorkOrder",
|
||||||
massEnabled: true, massInterval: 10080,
|
massEnabled: true, massInterval: 10080,
|
||||||
dailyEnabled: true, dailyInterval: 1440,
|
dailyEnabled: true, dailyInterval: 1440,
|
||||||
@@ -241,8 +241,8 @@ public class ScheduleCheckerTests
|
|||||||
tasks[0].UpdateType.ShouldBe(UpdateTypes.Hourly);
|
tasks[0].UpdateType.ShouldBe(UpdateTypes.Hourly);
|
||||||
tasks[0].MinimumDt.ShouldNotBeNull();
|
tasks[0].MinimumDt.ShouldNotBeNull();
|
||||||
|
|
||||||
// Hourly uses daily's timestamp and daily's interval for lookback calculation
|
// Hourly uses hourly's timestamp and hourly's interval for lookback calculation
|
||||||
var expectedMinimumDt = lastDaily.EndDt.AddMinutes(-3 * 1440);
|
var expectedMinimumDt = lastHourly.EndDt.AddMinutes(-3 * 60);
|
||||||
tasks[0].MinimumDt!.Value.ShouldBe(expectedMinimumDt, TimeSpan.FromSeconds(1));
|
tasks[0].MinimumDt!.Value.ShouldBe(expectedMinimumDt, TimeSpan.FromSeconds(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user