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;
}
}