feat: implement WorkProcessor and search execution services

- SearchRepository: Search table operations with Dapper
- SearchExecutionService: Search pipeline with proper cancellation handling
- WorkProcessor: Unified BackgroundService for syncs and searches
- SearchNotificationService: SignalR notifications in Api layer

All 45 new tests pass. Proper shutdown vs timeout distinction
prevents marking searches as error on host shutdown.
This commit is contained in:
Joseph Doherty
2026-01-07 06:18:35 -05:00
parent ca4cf9d3ec
commit 91b516e197
7 changed files with 1906 additions and 0 deletions
@@ -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;
/// <summary>
/// SignalR-based implementation of search notification service.
/// Sends real-time updates to connected clients via StatusHub.
/// </summary>
public class SearchNotificationService : ISearchNotificationService
{
private readonly IHubContext<StatusHub> _hubContext;
private readonly ILogger<SearchNotificationService> _logger;
/// <summary>
/// Initializes a new instance of SearchNotificationService.
/// </summary>
/// <param name="hubContext">SignalR hub context for StatusHub.</param>
/// <param name="logger">Logger instance.</param>
public SearchNotificationService(
IHubContext<StatusHub> hubContext,
ILogger<SearchNotificationService> logger)
{
_hubContext = hubContext;
_logger = logger;
}
/// <inheritdoc />
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);
}
}
/// <inheritdoc />
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);
}
}
}