- Implemented ReachabilityCenterComponent for displaying asset reachability status with summary and filtering options. - Added ReachabilityWhyDrawerComponent to show detailed reachability evidence and call paths. - Created unit tests for both components to ensure functionality and correctness. - Updated accessibility test results for the new components.
216 lines
8.4 KiB
C#
216 lines
8.4 KiB
C#
using System;
|
|
using System.Text.Json;
|
|
using System.Collections.Generic;
|
|
using StellaOps.Concelier.Bson;
|
|
using StellaOps.Concelier.Storage.Postgres.Models;
|
|
using StellaOps.Concelier.Storage.Postgres.Repositories;
|
|
using Contracts = StellaOps.Concelier.Storage.Contracts;
|
|
using MongoContracts = StellaOps.Concelier.Storage;
|
|
|
|
namespace StellaOps.Concelier.Storage.Postgres;
|
|
|
|
/// <summary>
|
|
/// Adapter that satisfies the legacy source state contract using PostgreSQL storage and provides a Postgres-native cursor contract.
|
|
/// </summary>
|
|
public sealed class PostgresSourceStateAdapter : MongoContracts.ISourceStateRepository, Contracts.ISourceStateStore
|
|
{
|
|
private readonly ISourceRepository _sourceRepository;
|
|
private readonly Repositories.ISourceStateRepository _stateRepository;
|
|
private readonly TimeProvider _timeProvider;
|
|
|
|
public PostgresSourceStateAdapter(
|
|
ISourceRepository sourceRepository,
|
|
Repositories.ISourceStateRepository stateRepository,
|
|
TimeProvider? timeProvider = null)
|
|
{
|
|
_sourceRepository = sourceRepository ?? throw new ArgumentNullException(nameof(sourceRepository));
|
|
_stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
}
|
|
|
|
public async Task<MongoContracts.SourceStateRecord?> TryGetAsync(string sourceName, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(sourceName);
|
|
|
|
var source = await _sourceRepository.GetByKeyAsync(sourceName, cancellationToken).ConfigureAwait(false);
|
|
if (source is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var state = await _stateRepository.GetBySourceIdAsync(source.Id, cancellationToken).ConfigureAwait(false);
|
|
if (state is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var cursor = string.IsNullOrWhiteSpace(state.Cursor) ? null : BsonDocument.Parse(state.Cursor);
|
|
var backoffUntil = TryParseBackoffUntil(state.Metadata);
|
|
return new MongoContracts.SourceStateRecord(
|
|
sourceName,
|
|
Enabled: true,
|
|
Paused: false,
|
|
Cursor: cursor,
|
|
LastSuccess: state.LastSuccessAt,
|
|
LastFailure: state.LastError is null ? null : state.LastSyncAt,
|
|
FailCount: state.ErrorCount,
|
|
BackoffUntil: backoffUntil,
|
|
UpdatedAt: state.UpdatedAt,
|
|
LastFailureReason: state.LastError);
|
|
}
|
|
|
|
public async Task UpdateCursorAsync(string sourceName, BsonDocument cursor, DateTimeOffset completedAt, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(sourceName);
|
|
ArgumentNullException.ThrowIfNull(cursor);
|
|
|
|
var source = await EnsureSourceAsync(sourceName, cancellationToken).ConfigureAwait(false);
|
|
var existing = await _stateRepository.GetBySourceIdAsync(source.Id, cancellationToken).ConfigureAwait(false);
|
|
|
|
var entity = new SourceStateEntity
|
|
{
|
|
Id = existing?.Id ?? Guid.NewGuid(),
|
|
SourceId = source.Id,
|
|
Cursor = cursor.ToJson(),
|
|
LastSyncAt = completedAt,
|
|
LastSuccessAt = completedAt,
|
|
LastError = null,
|
|
SyncCount = (existing?.SyncCount ?? 0) + 1,
|
|
ErrorCount = existing?.ErrorCount ?? 0,
|
|
Metadata = existing?.Metadata ?? "{}",
|
|
UpdatedAt = completedAt
|
|
};
|
|
|
|
_ = await _stateRepository.UpsertAsync(entity, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task MarkFailureAsync(string sourceName, DateTimeOffset now, TimeSpan backoff, string reason, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(sourceName);
|
|
|
|
var source = await EnsureSourceAsync(sourceName, cancellationToken).ConfigureAwait(false);
|
|
var existing = await _stateRepository.GetBySourceIdAsync(source.Id, cancellationToken).ConfigureAwait(false);
|
|
var backoffUntil = SafeAdd(now, backoff);
|
|
|
|
var metadata = new Dictionary<string, object?>(StringComparer.Ordinal)
|
|
{
|
|
["backoffUntil"] = backoffUntil.ToString("O"),
|
|
["reason"] = reason
|
|
};
|
|
|
|
var entity = new SourceStateEntity
|
|
{
|
|
Id = existing?.Id ?? Guid.NewGuid(),
|
|
SourceId = source.Id,
|
|
Cursor = existing?.Cursor,
|
|
LastSyncAt = now,
|
|
LastSuccessAt = existing?.LastSuccessAt,
|
|
LastError = reason,
|
|
SyncCount = existing?.SyncCount ?? 0,
|
|
ErrorCount = (existing?.ErrorCount ?? 0) + 1,
|
|
Metadata = JsonSerializer.Serialize(metadata, new JsonSerializerOptions(JsonSerializerDefaults.Web)),
|
|
UpdatedAt = now
|
|
};
|
|
|
|
_ = await _stateRepository.UpsertAsync(entity, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task UpsertAsync(MongoContracts.SourceStateRecord record, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(record);
|
|
var source = await EnsureSourceAsync(record.SourceName, cancellationToken).ConfigureAwait(false);
|
|
var entity = new SourceStateEntity
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
SourceId = source.Id,
|
|
Cursor = record.Cursor?.ToJson(),
|
|
LastSyncAt = record.UpdatedAt,
|
|
LastSuccessAt = record.LastSuccess,
|
|
LastError = record.LastFailureReason,
|
|
SyncCount = record.FailCount,
|
|
ErrorCount = record.FailCount,
|
|
Metadata = "{}",
|
|
UpdatedAt = record.UpdatedAt
|
|
};
|
|
|
|
_ = await _stateRepository.UpsertAsync(entity, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
async Task<Contracts.SourceCursorState?> Contracts.ISourceStateStore.TryGetAsync(string sourceName, CancellationToken cancellationToken)
|
|
=> (await TryGetAsync(sourceName, cancellationToken).ConfigureAwait(false))?.ToStorageCursorState();
|
|
|
|
Task Contracts.ISourceStateStore.UpdateCursorAsync(string sourceName, JsonDocument cursor, DateTimeOffset completedAt, CancellationToken cancellationToken)
|
|
=> UpdateCursorAsync(sourceName, cursor.ToBsonDocument(), completedAt, cancellationToken);
|
|
|
|
Task Contracts.ISourceStateStore.MarkFailureAsync(string sourceName, DateTimeOffset now, TimeSpan backoff, string reason, CancellationToken cancellationToken)
|
|
=> MarkFailureAsync(sourceName, now, backoff, reason, cancellationToken);
|
|
|
|
Task Contracts.ISourceStateStore.UpsertAsync(Contracts.SourceCursorState record, CancellationToken cancellationToken)
|
|
=> UpsertAsync(record.ToMongoSourceStateRecord(), cancellationToken);
|
|
|
|
private async Task<SourceEntity> EnsureSourceAsync(string sourceName, CancellationToken cancellationToken)
|
|
{
|
|
var existing = await _sourceRepository.GetByKeyAsync(sourceName, cancellationToken).ConfigureAwait(false);
|
|
if (existing is not null)
|
|
{
|
|
return existing;
|
|
}
|
|
|
|
var now = _timeProvider.GetUtcNow();
|
|
return await _sourceRepository.UpsertAsync(new SourceEntity
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Key = sourceName,
|
|
Name = sourceName,
|
|
SourceType = sourceName,
|
|
Url = null,
|
|
Priority = 0,
|
|
Enabled = true,
|
|
Config = "{}",
|
|
Metadata = "{}",
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
}, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private static DateTimeOffset SafeAdd(DateTimeOffset value, TimeSpan delta)
|
|
{
|
|
try
|
|
{
|
|
return value.Add(delta);
|
|
}
|
|
catch (ArgumentOutOfRangeException)
|
|
{
|
|
return delta < TimeSpan.Zero ? DateTimeOffset.MinValue : DateTimeOffset.MaxValue;
|
|
}
|
|
}
|
|
|
|
private static DateTimeOffset? TryParseBackoffUntil(string? metadata)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(metadata))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(metadata);
|
|
if (!document.RootElement.TryGetProperty("backoffUntil", out var backoffProperty))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (backoffProperty.ValueKind == JsonValueKind.String
|
|
&& DateTimeOffset.TryParse(backoffProperty.GetString(), out var parsed))
|
|
{
|
|
return parsed;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|