diff --git a/NEW/src/JdeScoping.Api/Services/SearchNotificationService.cs b/NEW/src/JdeScoping.Api/Services/SearchNotificationService.cs
new file mode 100644
index 0000000..4c63e81
--- /dev/null
+++ b/NEW/src/JdeScoping.Api/Services/SearchNotificationService.cs
@@ -0,0 +1,69 @@
+using JdeScoping.Api.Hubs;
+using JdeScoping.Core.Interfaces;
+using JdeScoping.Core.Models.Infrastructure;
+using JdeScoping.Core.Models.Search;
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.Extensions.Logging;
+
+namespace JdeScoping.Api.Services;
+
+///
+/// SignalR-based implementation of search notification service.
+/// Sends real-time updates to connected clients via StatusHub.
+///
+public class SearchNotificationService : ISearchNotificationService
+{
+ private readonly IHubContext _hubContext;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of SearchNotificationService.
+ ///
+ /// SignalR hub context for StatusHub.
+ /// Logger instance.
+ public SearchNotificationService(
+ IHubContext hubContext,
+ ILogger logger)
+ {
+ _hubContext = hubContext;
+ _logger = logger;
+ }
+
+ ///
+ public async Task NotifySearchUpdateAsync(Search search, CancellationToken ct = default)
+ {
+ try
+ {
+ var update = new SearchUpdate(search);
+ await _hubContext.Clients.All.SendAsync("searchUpdate", update, ct);
+ _logger.LogDebug(
+ "Search update notification sent: Id={SearchId}, Status={Status}",
+ search.Id,
+ search.Status);
+ }
+ catch (Exception ex)
+ {
+ // Best-effort notification - log but don't throw
+ _logger.LogWarning(
+ ex,
+ "Failed to send search update notification for SearchId={SearchId}",
+ search.Id);
+ }
+ }
+
+ ///
+ public async Task NotifyStatusAsync(string status, CancellationToken ct = default)
+ {
+ try
+ {
+ var update = new StatusUpdate(status);
+ await _hubContext.Clients.All.SendAsync("statusUpdate", update, ct);
+ _logger.LogDebug("Status notification sent: {Status}", status);
+ }
+ catch (Exception ex)
+ {
+ // Best-effort notification - log but don't throw
+ _logger.LogWarning(ex, "Failed to send status notification: {Status}", status);
+ }
+ }
+}
diff --git a/NEW/src/JdeScoping.DataSync/Services/SearchExecutionService.cs b/NEW/src/JdeScoping.DataSync/Services/SearchExecutionService.cs
new file mode 100644
index 0000000..146bca2
--- /dev/null
+++ b/NEW/src/JdeScoping.DataSync/Services/SearchExecutionService.cs
@@ -0,0 +1,131 @@
+using JdeScoping.Core.Interfaces;
+using JdeScoping.Core.Models.Enums;
+using JdeScoping.Core.Models.Search;
+using JdeScoping.Core.Models.SearchResults;
+using JdeScoping.DataSync.Contracts;
+using JdeScoping.DataSync.Options;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace JdeScoping.DataSync.Services;
+
+///
+/// Service that orchestrates the complete search execution pipeline.
+///
+public class SearchExecutionService : ISearchExecutionService
+{
+ private readonly ISearchRepository _searchRepository;
+ private readonly ISearchProcessor _searchProcessor;
+ private readonly IExcelExportService _excelExportService;
+ private readonly ISearchNotificationService _notificationService;
+ private readonly WorkProcessorOptions _options;
+ private readonly ILogger _logger;
+
+ public SearchExecutionService(
+ ISearchRepository searchRepository,
+ ISearchProcessor searchProcessor,
+ IExcelExportService excelExportService,
+ ISearchNotificationService notificationService,
+ IOptions options,
+ ILogger logger)
+ {
+ _searchRepository = searchRepository ?? throw new ArgumentNullException(nameof(searchRepository));
+ _searchProcessor = searchProcessor ?? throw new ArgumentNullException(nameof(searchProcessor));
+ _excelExportService = excelExportService ?? throw new ArgumentNullException(nameof(excelExportService));
+ _notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
+ _options = options?.Value ?? throw new ArgumentNullException(nameof(options));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ public async Task ExecuteSearchAsync(Search search, CancellationToken ct = default)
+ {
+ using var timeoutCts = new CancellationTokenSource(_options.SearchTimeout);
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
+
+ try
+ {
+ // Mark as Running
+ await _searchRepository.StartSearchAsync(search.Id, linkedCts.Token);
+ search.Status = SearchStatus.Running;
+ search.StartDt = DateTime.UtcNow;
+ await NotifySearchUpdateSafeAsync(search, linkedCts.Token);
+
+ // Execute search query
+ var model = new SearchModel { Id = search.Id };
+ await _searchProcessor.ExecuteSearchToModelAsync(model, linkedCts.Token);
+
+ // Generate Excel
+ var excelBytes = await _excelExportService.GenerateAsync(model, linkedCts.Token);
+
+ // Complete with success
+ await _searchRepository.CompleteSearchAsync(search.Id, true, excelBytes, linkedCts.Token);
+ search.Status = SearchStatus.Ended;
+ search.EndDt = DateTime.UtcNow;
+ await NotifySearchUpdateSafeAsync(search, linkedCts.Token);
+
+ _logger.LogInformation(
+ "Search {SearchId} completed successfully with {ResultCount} results",
+ search.Id, model.Results.Count);
+ }
+ catch (OperationCanceledException)
+ {
+ // Distinguish between host shutdown and timeout using the CancellationTokenSource states
+ // - Host shutdown: original ct is cancelled, but timeout hasn't fired
+ // - Timeout: timeoutCts is cancelled (regardless of ct state)
+ if (ct.IsCancellationRequested && !timeoutCts.IsCancellationRequested)
+ {
+ // Host shutdown - don't mark as error, let startup cleanup handle requeue
+ _logger.LogInformation(
+ "Search {SearchId} interrupted by shutdown, will be requeued on restart",
+ search.Id);
+ }
+ else if (timeoutCts.IsCancellationRequested)
+ {
+ // Timeout - mark as error
+ _logger.LogWarning(
+ "Search {SearchId} timed out after {Timeout}",
+ search.Id, _options.SearchTimeout);
+ await CompleteWithErrorAsync(search);
+ }
+ else
+ {
+ // Unknown cancellation source - treat as error for safety
+ _logger.LogWarning("Search {SearchId} cancelled from unknown source", search.Id);
+ await CompleteWithErrorAsync(search);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Search {SearchId} failed", search.Id);
+ await CompleteWithErrorAsync(search);
+ }
+ }
+
+ private async Task CompleteWithErrorAsync(Search search)
+ {
+ try
+ {
+ await _searchRepository.CompleteSearchAsync(search.Id, false, null, CancellationToken.None);
+ search.Status = SearchStatus.Error;
+ search.EndDt = DateTime.UtcNow;
+ await NotifySearchUpdateSafeAsync(search, CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to mark search {SearchId} as error", search.Id);
+ }
+ }
+
+ private async Task NotifySearchUpdateSafeAsync(Search search, CancellationToken ct)
+ {
+ try
+ {
+ await _notificationService.NotifySearchUpdateAsync(search, ct);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Failed to send search update notification for {SearchId}", search.Id);
+ }
+ }
+}
diff --git a/NEW/src/JdeScoping.DataSync/Services/SearchRepository.cs b/NEW/src/JdeScoping.DataSync/Services/SearchRepository.cs
new file mode 100644
index 0000000..45cbd61
--- /dev/null
+++ b/NEW/src/JdeScoping.DataSync/Services/SearchRepository.cs
@@ -0,0 +1,136 @@
+using Dapper;
+using JdeScoping.Core.Models.Enums;
+using JdeScoping.Core.Models.Search;
+using JdeScoping.DataAccess.Interfaces;
+using JdeScoping.DataSync.Contracts;
+using Microsoft.Extensions.Logging;
+
+namespace JdeScoping.DataSync.Services;
+
+///
+/// Repository for Search table operations.
+///
+public class SearchRepository : ISearchRepository
+{
+ private readonly IDbConnectionFactory _connectionFactory;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The database connection factory.
+ /// The logger instance.
+ public SearchRepository(
+ IDbConnectionFactory connectionFactory,
+ ILogger logger)
+ {
+ _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ public async Task GetNextQueuedSearchAsync(CancellationToken ct = default)
+ {
+ await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
+
+ const string sql = """
+ SELECT TOP 1
+ Id, UserName, Name, Status, SubmitDT as SubmitDt,
+ StartDT as StartDt, EndDT as EndDt, CriteriaJSON as CriteriaJson
+ FROM dbo.Search
+ WHERE Status = @Status
+ ORDER BY SubmitDT ASC
+ """;
+
+ var search = await connection.QueryFirstOrDefaultAsync(
+ sql,
+ new { Status = (int)SearchStatus.Queued },
+ commandTimeout: 30);
+
+ if (search != null)
+ {
+ _logger.LogDebug("Found queued search {SearchId}", search.Id);
+ }
+
+ return search;
+ }
+
+ ///
+ public async Task ResetPartialSearchesAsync(CancellationToken ct = default)
+ {
+ await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
+
+ const string sql = """
+ UPDATE dbo.Search
+ SET Status = @QueuedStatus, StartDT = NULL
+ WHERE Status = @RunningStatus AND EndDT IS NULL
+ """;
+
+ var count = await connection.ExecuteAsync(
+ sql,
+ new
+ {
+ QueuedStatus = (int)SearchStatus.Queued,
+ RunningStatus = (int)SearchStatus.Running
+ },
+ commandTimeout: 30);
+
+ if (count > 0)
+ {
+ _logger.LogWarning("Reset {Count} partial searches to Queued status", count);
+ }
+
+ return count;
+ }
+
+ ///
+ public async Task StartSearchAsync(int searchId, CancellationToken ct = default)
+ {
+ await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
+
+ const string sql = """
+ UPDATE dbo.Search
+ SET Status = @Status, StartDT = @StartDt
+ WHERE Id = @SearchId
+ """;
+
+ await connection.ExecuteAsync(
+ sql,
+ new
+ {
+ SearchId = searchId,
+ Status = (int)SearchStatus.Running,
+ StartDt = DateTime.UtcNow
+ },
+ commandTimeout: 30);
+
+ _logger.LogDebug("Started search {SearchId}", searchId);
+ }
+
+ ///
+ public async Task CompleteSearchAsync(int searchId, bool success, byte[]? results, CancellationToken ct = default)
+ {
+ await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(ct);
+
+ const string sql = """
+ UPDATE dbo.Search
+ SET Status = @Status, EndDT = @EndDt, Results = @Results
+ WHERE Id = @SearchId
+ """;
+
+ var status = success ? SearchStatus.Ended : SearchStatus.Error;
+
+ await connection.ExecuteAsync(
+ sql,
+ new
+ {
+ SearchId = searchId,
+ Status = (int)status,
+ EndDt = DateTime.UtcNow,
+ Results = results
+ },
+ commandTimeout: 30);
+
+ _logger.LogDebug("Completed search {SearchId} with status {Status}", searchId, status);
+ }
+}
diff --git a/NEW/src/JdeScoping.DataSync/WorkProcessor.cs b/NEW/src/JdeScoping.DataSync/WorkProcessor.cs
new file mode 100644
index 0000000..1ec43d8
--- /dev/null
+++ b/NEW/src/JdeScoping.DataSync/WorkProcessor.cs
@@ -0,0 +1,223 @@
+using JdeScoping.Core.Interfaces;
+using JdeScoping.DataSync.Contracts;
+using JdeScoping.DataSync.Options;
+using JdeScoping.DataSync.Telemetry;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace JdeScoping.DataSync;
+
+///
+/// Unified background service that coordinates data synchronization and search processing.
+/// Data freshness takes priority over search processing.
+///
+public class WorkProcessor : BackgroundService
+{
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly WorkProcessorOptions _options;
+ private readonly ILogger _logger;
+ private readonly DataSyncMetrics _metrics;
+
+ private DateTime _lastPurgeCheck = DateTime.MinValue;
+ private static readonly TimeSpan PurgeCheckInterval = TimeSpan.FromHours(24);
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public WorkProcessor(
+ IServiceScopeFactory scopeFactory,
+ IOptions options,
+ ILogger logger,
+ DataSyncMetrics metrics)
+ {
+ _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
+ _options = options?.Value ?? throw new ArgumentNullException(nameof(options));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
+ }
+
+ ///
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ if (!_options.Enabled)
+ {
+ _logger.LogInformation("WorkProcessor is disabled");
+ return;
+ }
+
+ _logger.LogInformation(
+ "WorkProcessor starting with WorkInterval={WorkInterval}",
+ _options.WorkInterval);
+
+ // Startup cleanup
+ await StartupCleanupAsync(stoppingToken);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ string status = "Idle";
+ try
+ {
+ status = await DoWorkAsync(stoppingToken);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ _logger.LogInformation("WorkProcessor stopping gracefully");
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error in work cycle");
+ _metrics.RecordCycleError();
+ }
+ finally
+ {
+ // Always notify status (best-effort)
+ await NotifyStatusSafeAsync(status, stoppingToken);
+ }
+
+ try
+ {
+ await Task.Delay(_options.WorkInterval, stoppingToken);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ break;
+ }
+ }
+
+ await NotifyStatusSafeAsync("Stopped", CancellationToken.None);
+ _logger.LogInformation("WorkProcessor stopped");
+ }
+
+ ///
+ /// Performs startup cleanup by closing interrupted DataUpdate entries
+ /// and resetting partial searches.
+ ///
+ private async Task StartupCleanupAsync(CancellationToken ct)
+ {
+ await using var scope = _scopeFactory.CreateAsyncScope();
+
+ // Close interrupted DataUpdate entries
+ try
+ {
+ var dataUpdateRepo = scope.ServiceProvider.GetRequiredService();
+ var closedCount = await dataUpdateRepo.CloseOpenUpdateEntriesAsync(ct);
+ if (closedCount > 0)
+ {
+ _logger.LogWarning("Closed {Count} interrupted data update entries", closedCount);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to close open data update entries");
+ }
+
+ // Reset partial searches
+ try
+ {
+ var searchRepo = scope.ServiceProvider.GetRequiredService();
+ var resetCount = await searchRepo.ResetPartialSearchesAsync(ct);
+ if (resetCount > 0)
+ {
+ _logger.LogWarning("Reset {Count} partial searches to Queued", resetCount);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to reset partial searches");
+ }
+ }
+
+ ///
+ /// Performs one work cycle: data syncs have priority, then search processing.
+ ///
+ private async Task DoWorkAsync(CancellationToken ct)
+ {
+ await using var scope = _scopeFactory.CreateAsyncScope();
+
+ // Priority 1: Data syncs
+ var scheduleChecker = scope.ServiceProvider.GetRequiredService();
+ var pendingTasks = await scheduleChecker.GetPendingTasksAsync(ct);
+
+ if (pendingTasks.Count > 0)
+ {
+ await NotifyStatusSafeAsync("Updating data cache", ct);
+
+ var orchestrator = scope.ServiceProvider.GetRequiredService();
+ await orchestrator.ExecutePendingSyncsAsync(ct);
+
+ // Periodic purge check
+ await PurgeOldEntriesAsync(scope, ct);
+
+ return "Idle";
+ }
+
+ // Priority 2: Search processing (only when syncs are current)
+ var searchRepository = scope.ServiceProvider.GetRequiredService();
+ var search = await searchRepository.GetNextQueuedSearchAsync(ct);
+
+ if (search != null)
+ {
+ await NotifyStatusSafeAsync($"Processing search #{search.Id}", ct);
+
+ var executionService = scope.ServiceProvider.GetRequiredService();
+ await executionService.ExecuteSearchAsync(search, ct);
+ }
+
+ // Periodic purge check
+ await PurgeOldEntriesAsync(scope, ct);
+
+ return "Idle";
+ }
+
+ ///
+ /// Purges old DataUpdate entries periodically (every 24 hours).
+ ///
+ private async Task PurgeOldEntriesAsync(AsyncServiceScope scope, CancellationToken ct)
+ {
+ if (DateTime.UtcNow - _lastPurgeCheck < PurgeCheckInterval)
+ {
+ return;
+ }
+
+ _lastPurgeCheck = DateTime.UtcNow;
+
+ try
+ {
+ var repository = scope.ServiceProvider.GetRequiredService();
+ var purgedCount = await repository.PurgeOldEntriesAsync(
+ _options.PurgeRetentionDays, ct);
+
+ if (purgedCount > 0)
+ {
+ _logger.LogInformation(
+ "Purged {Count} DataUpdate records older than {Days} days",
+ purgedCount, _options.PurgeRetentionDays);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to purge old data update entries");
+ }
+ }
+
+ ///
+ /// Sends status notification, catching and logging any exceptions.
+ ///
+ private async Task NotifyStatusSafeAsync(string status, CancellationToken ct)
+ {
+ try
+ {
+ await using var scope = _scopeFactory.CreateAsyncScope();
+ var notificationService = scope.ServiceProvider.GetRequiredService();
+ await notificationService.NotifyStatusAsync(status, ct);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Failed to send status notification");
+ // Best-effort - don't throw
+ }
+ }
+}
diff --git a/NEW/tests/JdeScoping.DataSync.Tests/Services/SearchExecutionServiceTests.cs b/NEW/tests/JdeScoping.DataSync.Tests/Services/SearchExecutionServiceTests.cs
new file mode 100644
index 0000000..42477a2
--- /dev/null
+++ b/NEW/tests/JdeScoping.DataSync.Tests/Services/SearchExecutionServiceTests.cs
@@ -0,0 +1,546 @@
+using JdeScoping.Core.Interfaces;
+using JdeScoping.Core.Models.Enums;
+using JdeScoping.Core.Models.Search;
+using JdeScoping.Core.Models.SearchResults;
+using JdeScoping.DataSync.Contracts;
+using JdeScoping.DataSync.Options;
+using JdeScoping.DataSync.Services;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using Shouldly;
+
+using MsOptions = Microsoft.Extensions.Options.Options;
+
+namespace JdeScoping.DataSync.Tests.Services;
+
+///
+/// Unit tests for SearchExecutionService.
+/// Tests cancellation handling, error paths, and success scenarios.
+///
+public class SearchExecutionServiceTests
+{
+ private readonly ISearchRepository _searchRepository;
+ private readonly ISearchProcessor _searchProcessor;
+ private readonly IExcelExportService _excelExportService;
+ private readonly ISearchNotificationService _notificationService;
+ private readonly IOptions _options;
+ private readonly ILogger _logger;
+
+ public SearchExecutionServiceTests()
+ {
+ _searchRepository = Substitute.For();
+ _searchProcessor = Substitute.For();
+ _excelExportService = Substitute.For();
+ _notificationService = Substitute.For();
+ _options = MsOptions.Create(new WorkProcessorOptions { SearchTimeout = TimeSpan.FromSeconds(30) });
+ _logger = Substitute.For>();
+ }
+
+ #region Constructor Tests
+
+ [Fact]
+ public void Constructor_WithNullSearchRepository_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ Should.Throw(() =>
+ new SearchExecutionService(
+ null!,
+ _searchProcessor,
+ _excelExportService,
+ _notificationService,
+ _options,
+ _logger));
+ }
+
+ [Fact]
+ public void Constructor_WithNullSearchProcessor_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ Should.Throw(() =>
+ new SearchExecutionService(
+ _searchRepository,
+ null!,
+ _excelExportService,
+ _notificationService,
+ _options,
+ _logger));
+ }
+
+ [Fact]
+ public void Constructor_WithNullExcelExportService_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ Should.Throw(() =>
+ new SearchExecutionService(
+ _searchRepository,
+ _searchProcessor,
+ null!,
+ _notificationService,
+ _options,
+ _logger));
+ }
+
+ [Fact]
+ public void Constructor_WithNullNotificationService_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ Should.Throw(() =>
+ new SearchExecutionService(
+ _searchRepository,
+ _searchProcessor,
+ _excelExportService,
+ null!,
+ _options,
+ _logger));
+ }
+
+ [Fact]
+ public void Constructor_WithNullOptions_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ Should.Throw(() =>
+ new SearchExecutionService(
+ _searchRepository,
+ _searchProcessor,
+ _excelExportService,
+ _notificationService,
+ null!,
+ _logger));
+ }
+
+ [Fact]
+ public void Constructor_WithNullLogger_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ Should.Throw(() =>
+ new SearchExecutionService(
+ _searchRepository,
+ _searchProcessor,
+ _excelExportService,
+ _notificationService,
+ _options,
+ null!));
+ }
+
+ [Fact]
+ public void Constructor_WithValidDependencies_CreatesInstance()
+ {
+ // Act
+ var sut = CreateService();
+
+ // Assert
+ sut.ShouldNotBeNull();
+ }
+
+ #endregion
+
+ #region ExecuteSearchAsync Success Path
+
+ [Fact]
+ public async Task ExecuteSearchAsync_Success_StartsSearch()
+ {
+ // Arrange
+ var search = new Search { Id = 1, Status = SearchStatus.Queued };
+ var model = new SearchModel { Id = 1, Results = [] };
+ var excelBytes = new byte[] { 1, 2, 3 };
+
+ _searchProcessor.ExecuteSearchToModelAsync(Arg.Any(), Arg.Any())
+ .Returns(model);
+ _excelExportService.GenerateAsync(Arg.Any