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; /// /// Adapter that satisfies the legacy source state contract using PostgreSQL storage and provides a Postgres-native cursor contract. /// 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 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(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.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 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; } }