using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Concurrent;
using Junction.Core.Plugins;
using Junction.Core.Polling;
using Junction.Domain;
using Junction.Domain.Models;
using Junction.Domain.Persistence;
using Junction.Domain.Protocols;
using Microsoft.Extensions.Logging;
namespace Junction.Core.Monitoring
{
///
/// Default . See the interface for the orchestration contract
/// and partial-start policy.
///
/// PollingEngine-per-machine: each machine gets its own built
/// from an injected factory. The factory keeps the
/// monitor decoupled from engine construction (and from
/// wiring), which makes the monitor trivially unit-testable with a stub engine. DI registers
/// the default factory as () => new PollingEngine(loggerFactory.CreateLogger<PollingEngine>()).
///
///
/// Thread-safety: snapshots arrive concurrently from multiple poll loops. The latest-snapshot
/// cache is a (lock-free reads for
/// the dashboard), and the event is raised through a captured
/// local delegate. Start/Stop mutate the running-loop set under a private lock.
///
///
public sealed class MachineMonitor : IMachineMonitor
{
private const string Source = "MachineMonitor";
private readonly IMachineRepository _repository;
private readonly IPluginLoader _pluginLoader;
private readonly Func _engineFactory;
private readonly ILogger _logger;
private readonly ConcurrentDictionary _snapshots =
new ConcurrentDictionary();
private readonly object _lifecycleLock = new object();
private readonly List _running = new List();
// Built once by StartAsync and kept so dynamic add/update can resolve drivers at runtime.
// Guarded by _lifecycleLock. Null until StartAsync completes = "monitor not started".
private Dictionary? _factories;
public MachineMonitor(
IMachineRepository repository,
IPluginLoader pluginLoader,
Func engineFactory,
ILogger logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_pluginLoader = pluginLoader ?? throw new ArgumentNullException(nameof(pluginLoader));
_engineFactory = engineFactory ?? throw new ArgumentNullException(nameof(engineFactory));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
///
public event EventHandler? SnapshotUpdated;
///
public IReadOnlyDictionary LatestSnapshots => _snapshots;
///
public IReadOnlyCollection AvailableProtocols
{
get
{
lock (_lifecycleLock)
{
if (_factories is null)
{
return Array.Empty();
}
// Independent snapshot of the keys so callers can't observe later mutations.
return new List(_factories.Keys);
}
}
}
///
public async Task StartAsync(string pluginsDirectory, CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
{
return Result.Cancelled();
}
// 1. Load plugins. A failure here (missing/invalid directory) is a hard infra failure.
Result> loadResult = _pluginLoader.LoadFrom(pluginsDirectory);
if (!loadResult.IsSuccess)
{
_logger.LogError("Plugin load failed; monitor cannot start: {Errors}", DescribeErrors(loadResult.Errors));
return Result.Fail(loadResult.Errors);
}
var factories = BuildFactoryMap(loadResult.Value);
// 2. Load configured machines. A repository failure is a hard infra failure.
Result> machinesResult;
try
{
machinesResult = await _repository.GetAllAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return Result.Cancelled();
}
if (machinesResult.WasCancelled)
{
return Result.Cancelled();
}
if (!machinesResult.IsSuccess)
{
_logger.LogError("Machine load failed; monitor cannot start: {Errors}", DescribeErrors(machinesResult.Errors));
return Result.Fail(machinesResult.Errors);
}
// 3. Start one poll loop per machine that resolves a driver. Skips are non-fatal.
int started = 0;
int skipped = 0;
lock (_lifecycleLock)
{
// Retain the factory map so AddOrUpdateMachineAsync can build drivers later.
_factories = factories;
foreach (Machine machine in machinesResult.Value)
{
if (!factories.TryGetValue(machine.ProtocolId, out IProtocolDriverFactory factory))
{
skipped++;
_logger.LogWarning(
"No plugin loaded for protocol '{ProtocolId}'; skipping machine {MachineId} ({MachineName}).",
machine.ProtocolId, machine.Id, machine.Name);
continue;
}
Result driverResult = factory.Create(machine);
if (!driverResult.IsSuccess)
{
skipped++;
_logger.LogWarning(
"Driver creation failed for machine {MachineId} ({MachineName}) via protocol '{ProtocolId}'; skipping: {Errors}",
machine.Id, machine.Name, machine.ProtocolId, DescribeErrors(driverResult.Errors));
continue;
}
IProtocolDriver driver = driverResult.Value;
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
IPollingEngine engine = _engineFactory();
CancellationToken loopToken = cts.Token;
Task loop = engine.RunAsync(
machine,
driver,
snapshot => OnSnapshot(snapshot, loopToken),
loopToken);
_running.Add(new RunningLoop(machine.Id, cts, loop));
started++;
}
}
_logger.LogInformation(
"MachineMonitor started: {Started} machine(s) polling, {Skipped} skipped.",
started, skipped);
// Partial start is a success.
return Result.Ok();
}
///
public async Task StopAsync()
{
List loops;
lock (_lifecycleLock)
{
loops = new List(_running);
_running.Clear();
}
if (loops.Count == 0)
{
return;
}
foreach (RunningLoop loop in loops)
{
try
{
loop.Cts.Cancel();
}
catch (ObjectDisposedException)
{
// Already disposed; ignore.
}
}
var tasks = new Task[loops.Count];
for (int i = 0; i < loops.Count; i++)
{
tasks[i] = loops[i].Loop;
}
try
{
// Poll loops never fault (they swallow driver/cancellation errors), but guard anyway.
await Task.WhenAll(tasks).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "A poll loop faulted during shutdown; continuing cleanup.");
}
foreach (RunningLoop loop in loops)
{
loop.Cts.Dispose();
}
_logger.LogInformation("MachineMonitor stopped: {Count} poll loop(s) shut down.", loops.Count);
}
///
public async Task AddOrUpdateMachineAsync(Machine machine, CancellationToken cancellationToken)
{
if (machine is null)
{
return Result.Fail(OperationError.Of(Source, "machine is null."));
}
if (cancellationToken.IsCancellationRequested)
{
return Result.Cancelled();
}
// Phase 1: detach any existing loop for this id (under lock), enforcing "not started".
RunningLoop? existing;
lock (_lifecycleLock)
{
if (_factories is null)
{
return Result.Fail(OperationError.Of(Source, "Monitor not started; call StartAsync first."));
}
existing = FindAndRemoveLocked(machine.Id);
}
// Phase 2: await the old loop's clean shutdown OUTSIDE the lock (no await under lock).
if (existing != null)
{
await StopLoopAsync(existing).ConfigureAwait(false);
_snapshots.TryRemove(machine.Id, out _);
}
// Phase 3: start a fresh loop (under lock).
lock (_lifecycleLock)
{
if (_factories is null)
{
return Result.Fail(OperationError.Of(Source, "Monitor not started; call StartAsync first."));
}
var cts = new CancellationTokenSource();
Result started = TryStartLoopLocked(machine, cts);
if (!started.IsSuccess)
{
cts.Dispose();
_logger.LogWarning(
"AddOrUpdate failed to start machine {MachineId} ({MachineName}): {Errors}",
machine.Id, machine.Name, DescribeErrors(started.Errors));
return started;
}
_logger.LogInformation(
"Machine {MachineId} ({MachineName}) started/restarted via AddOrUpdate.",
machine.Id, machine.Name);
return Result.Ok();
}
}
///
public async Task RemoveMachineAsync(Guid machineId, CancellationToken cancellationToken)
{
RunningLoop? existing;
lock (_lifecycleLock)
{
existing = FindAndRemoveLocked(machineId);
}
if (existing != null)
{
await StopLoopAsync(existing).ConfigureAwait(false);
}
_snapshots.TryRemove(machineId, out _);
_logger.LogInformation("Machine {MachineId} removed from monitor (was running: {Running}).", machineId, existing != null);
// Idempotent: Ok even when nothing was running for the id.
return Result.Ok();
}
///
/// Removes and returns the tracked loop for , or null.
/// Caller must hold .
///
private RunningLoop? FindAndRemoveLocked(Guid machineId)
{
for (int i = 0; i < _running.Count; i++)
{
if (_running[i].MachineId == machineId)
{
RunningLoop loop = _running[i];
_running.RemoveAt(i);
return loop;
}
}
return null;
}
/// Cancels, awaits and disposes a single loop. Never throws.
private async Task StopLoopAsync(RunningLoop loop)
{
try
{
loop.Cts.Cancel();
}
catch (ObjectDisposedException)
{
// Already disposed; ignore.
}
try
{
await loop.Loop.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Poll loop faulted while stopping machine {MachineId}; continuing.", loop.MachineId);
}
loop.Cts.Dispose();
}
///
/// Resolves a driver for via the retained factory map and starts
/// a poll loop tracked in . Caller must hold
/// and have verified is non-null. Returns Fail (unknown protocol /
/// driver-create failure) without adding a loop.
///
private Result TryStartLoopLocked(Machine machine, CancellationTokenSource cts)
{
Dictionary factories = _factories!;
if (!factories.TryGetValue(machine.ProtocolId, out IProtocolDriverFactory factory))
{
return Result.Fail(OperationError.Of(
Source,
$"No plugin loaded for protocol '{machine.ProtocolId}'."));
}
Result driverResult = factory.Create(machine);
if (!driverResult.IsSuccess)
{
return Result.Fail(driverResult.Errors);
}
IProtocolDriver driver = driverResult.Value;
IPollingEngine engine = _engineFactory();
CancellationToken loopToken = cts.Token;
Task loop = engine.RunAsync(
machine,
driver,
snapshot => OnSnapshot(snapshot, loopToken),
loopToken);
_running.Add(new RunningLoop(machine.Id, cts, loop));
return Result.Ok();
}
///
/// Snapshot callback invoked from a poll loop: updates the latest cache, raises the
/// event, then fires the persistence write off (fire-and-forget with error logging) so
/// a slow/failed save never stalls or breaks the poll loop.
///
private void OnSnapshot(MachineSnapshot snapshot, CancellationToken cancellationToken)
{
_snapshots[snapshot.MachineId] = snapshot;
EventHandler? handler = SnapshotUpdated;
if (handler != null)
{
try
{
handler(this, snapshot);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "SnapshotUpdated subscriber threw for machine {MachineId}; continuing.", snapshot.MachineId);
}
}
_ = PersistAsync(snapshot, cancellationToken);
}
private async Task PersistAsync(MachineSnapshot snapshot, CancellationToken cancellationToken)
{
try
{
Result result = await _repository.SaveSnapshotAsync(snapshot, cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess && !result.WasCancelled)
{
_logger.LogWarning(
"Failed to persist snapshot for machine {MachineId}: {Errors}",
snapshot.MachineId, DescribeErrors(result.Errors));
}
}
catch (OperationCanceledException)
{
// Shutdown in progress; nothing to do.
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Persisting snapshot threw for machine {MachineId}; continuing.", snapshot.MachineId);
}
}
///
/// Maps ProtocolId (case-insensitive) to factory. On duplicate protocol ids the first
/// loaded plugin wins; the collision is logged.
///
private Dictionary BuildFactoryMap(IReadOnlyList plugins)
{
var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (LoadedPlugin plugin in plugins)
{
string protocolId = plugin.Factory.ProtocolId ?? "";
if (map.ContainsKey(protocolId))
{
_logger.LogWarning("Duplicate plugin for protocol '{ProtocolId}'; keeping the first loaded.", protocolId);
continue;
}
map[protocolId] = plugin.Factory;
}
return map;
}
private static string DescribeErrors(IReadOnlyList errors)
{
if (errors is null || errors.Count == 0)
{
return "(no detail)";
}
if (errors.Count == 1)
{
return errors[0].ToString();
}
var parts = new string[errors.Count];
for (int i = 0; i < errors.Count; i++)
{
parts[i] = errors[i].ToString();
}
return string.Join("; ", parts);
}
/// A running per-machine poll loop with its cancellation source.
private sealed class RunningLoop
{
public Guid MachineId { get; }
public CancellationTokenSource Cts { get; }
public Task Loop { get; }
public RunningLoop(Guid machineId, CancellationTokenSource cts, Task loop)
{
MachineId = machineId;
Cts = cts;
Loop = loop;
}
}
}
}