wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Eventing.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model stub for EventingDbContext.
|
||||
/// This is a placeholder that delegates to runtime model building.
|
||||
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
|
||||
/// </summary>
|
||||
[DbContext(typeof(Context.EventingDbContext))]
|
||||
public partial class EventingDbContextModel : RuntimeModel
|
||||
{
|
||||
private static EventingDbContextModel _instance;
|
||||
|
||||
public static IModel Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new EventingDbContextModel();
|
||||
_instance.Initialize();
|
||||
_instance.Customize();
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Eventing.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model builder stub for EventingDbContext.
|
||||
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
|
||||
/// </summary>
|
||||
public partial class EventingDbContextModel
|
||||
{
|
||||
partial void Initialize()
|
||||
{
|
||||
// Stub: when a real compiled model is generated, entity types will be registered here.
|
||||
// The runtime factory will fall back to reflection-based model building for all schemas
|
||||
// until this stub is replaced with a full compiled model.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Eventing.EfCore.Models;
|
||||
|
||||
namespace StellaOps.Eventing.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for the Eventing module.
|
||||
/// Maps to the timeline PostgreSQL schema: events and outbox tables.
|
||||
/// </summary>
|
||||
public partial class EventingDbContext : DbContext
|
||||
{
|
||||
private readonly string _schemaName;
|
||||
|
||||
public EventingDbContext(DbContextOptions<EventingDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "timeline"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
public virtual DbSet<TimelineEventEntity> Events { get; set; }
|
||||
public virtual DbSet<OutboxEntry> OutboxEntries { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
var schemaName = _schemaName;
|
||||
|
||||
// -- events ---------------------------------------------------------------
|
||||
modelBuilder.Entity<TimelineEventEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.EventId).HasName("events_pkey");
|
||||
entity.ToTable("events", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.CorrelationId, e.THlc }, "idx_events_corr_hlc");
|
||||
entity.HasIndex(e => new { e.Service, e.THlc }, "idx_events_svc_hlc");
|
||||
entity.HasIndex(e => e.Kind, "idx_events_kind");
|
||||
entity.HasIndex(e => e.CreatedAt, "idx_events_created_at");
|
||||
|
||||
entity.Property(e => e.EventId).HasColumnName("event_id");
|
||||
entity.Property(e => e.THlc).HasColumnName("t_hlc");
|
||||
entity.Property(e => e.TsWall).HasColumnName("ts_wall");
|
||||
entity.Property(e => e.Service).HasColumnName("service");
|
||||
entity.Property(e => e.TraceParent).HasColumnName("trace_parent");
|
||||
entity.Property(e => e.CorrelationId).HasColumnName("correlation_id");
|
||||
entity.Property(e => e.Kind).HasColumnName("kind");
|
||||
entity.Property(e => e.Payload)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("payload");
|
||||
entity.Property(e => e.PayloadDigest).HasColumnName("payload_digest");
|
||||
entity.Property(e => e.EngineName).HasColumnName("engine_name");
|
||||
entity.Property(e => e.EngineVersion).HasColumnName("engine_version");
|
||||
entity.Property(e => e.EngineDigest).HasColumnName("engine_digest");
|
||||
entity.Property(e => e.DsseSig).HasColumnName("dsse_sig");
|
||||
entity.Property(e => e.SchemaVersion)
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("schema_version");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("created_at");
|
||||
});
|
||||
|
||||
// -- outbox ---------------------------------------------------------------
|
||||
modelBuilder.Entity<OutboxEntry>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("outbox_pkey");
|
||||
entity.ToTable("outbox", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.Status, e.NextRetryAt }, "idx_outbox_status_retry")
|
||||
.HasFilter("(status IN ('PENDING', 'FAILED'))");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.ValueGeneratedOnAdd()
|
||||
.UseIdentityByDefaultColumn()
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.EventId).HasColumnName("event_id");
|
||||
entity.Property(e => e.Status)
|
||||
.HasDefaultValueSql("'PENDING'")
|
||||
.HasColumnName("status");
|
||||
entity.Property(e => e.RetryCount)
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("retry_count");
|
||||
entity.Property(e => e.NextRetryAt).HasColumnName("next_retry_at");
|
||||
entity.Property(e => e.ErrorMessage).HasColumnName("error_message");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("updated_at");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.Eventing.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time factory for <see cref="EventingDbContext"/>.
|
||||
/// Used by <c>dotnet ef</c> CLI tooling for scaffold and optimize commands.
|
||||
/// </summary>
|
||||
public sealed class EventingDesignTimeDbContextFactory : IDesignTimeDbContextFactory<EventingDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=timeline,public";
|
||||
|
||||
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_EVENTING_EF_CONNECTION";
|
||||
|
||||
public EventingDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<EventingDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new EventingDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
namespace StellaOps.Eventing.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for timeline.outbox table.
|
||||
/// </summary>
|
||||
public partial class OutboxEntry
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string EventId { get; set; } = null!;
|
||||
public string Status { get; set; } = null!;
|
||||
public int RetryCount { get; set; }
|
||||
public DateTimeOffset? NextRetryAt { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
namespace StellaOps.Eventing.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for timeline.events table.
|
||||
/// </summary>
|
||||
public partial class TimelineEventEntity
|
||||
{
|
||||
public string EventId { get; set; } = null!;
|
||||
public string THlc { get; set; } = null!;
|
||||
public DateTimeOffset TsWall { get; set; }
|
||||
public string Service { get; set; } = null!;
|
||||
public string? TraceParent { get; set; }
|
||||
public string CorrelationId { get; set; } = null!;
|
||||
public string Kind { get; set; } = null!;
|
||||
public string Payload { get; set; } = null!;
|
||||
public byte[] PayloadDigest { get; set; } = null!;
|
||||
public string EngineName { get; set; } = null!;
|
||||
public string EngineVersion { get; set; } = null!;
|
||||
public string EngineDigest { get; set; } = null!;
|
||||
public string? DsseSig { get; set; }
|
||||
public int SchemaVersion { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using System.Data;
|
||||
using StellaOps.Eventing.Postgres;
|
||||
|
||||
namespace StellaOps.Eventing.Outbox;
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class TimelineOutboxProcessor : BackgroundService
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly IOptions<EventingOptions> _options;
|
||||
private readonly ILogger<TimelineOutboxProcessor> _logger;
|
||||
private readonly EventingDataSource? _eventingDataSource;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimelineOutboxProcessor"/> class.
|
||||
@@ -24,11 +25,13 @@ public sealed class TimelineOutboxProcessor : BackgroundService
|
||||
public TimelineOutboxProcessor(
|
||||
NpgsqlDataSource dataSource,
|
||||
IOptions<EventingOptions> options,
|
||||
ILogger<TimelineOutboxProcessor> logger)
|
||||
ILogger<TimelineOutboxProcessor> logger,
|
||||
EventingDataSource? eventingDataSource = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_eventingDataSource = eventingDataSource;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -74,36 +77,28 @@ public sealed class TimelineOutboxProcessor : BackgroundService
|
||||
private async Task<int> ProcessBatchAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted, cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
// Select and lock pending entries
|
||||
const string selectSql = """
|
||||
SELECT id, event_id, retry_count
|
||||
FROM timeline.outbox
|
||||
WHERE status = 'PENDING'
|
||||
OR (status = 'FAILED' AND next_retry_at <= NOW())
|
||||
ORDER BY id
|
||||
LIMIT @batch_size
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""";
|
||||
|
||||
await using var selectCmd = new NpgsqlCommand(selectSql, connection, transaction);
|
||||
selectCmd.Parameters.AddWithValue("@batch_size", _options.Value.OutboxBatchSize);
|
||||
|
||||
var entries = new List<(long Id, string EventId, int RetryCount)>();
|
||||
|
||||
await using (var reader = await selectCmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
entries.Add((
|
||||
reader.GetInt64(0),
|
||||
reader.GetString(1),
|
||||
reader.GetInt32(2)));
|
||||
}
|
||||
}
|
||||
// Use raw SQL for the SELECT ... FOR UPDATE SKIP LOCKED pattern
|
||||
// which is not directly expressible in LINQ.
|
||||
var batchSize = _options.Value.OutboxBatchSize;
|
||||
var entries = await dbContext.OutboxEntries
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
SELECT id, event_id, status, retry_count, next_retry_at, error_message, created_at, updated_at
|
||||
FROM timeline.outbox
|
||||
WHERE status = 'PENDING'
|
||||
OR (status = 'FAILED' AND next_retry_at <= NOW())
|
||||
ORDER BY id
|
||||
LIMIT {0}
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""",
|
||||
batchSize)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
@@ -124,22 +119,20 @@ public sealed class TimelineOutboxProcessor : BackgroundService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process outbox entry {Id}", entry.Id);
|
||||
await MarkAsFailedAsync(connection, transaction, entry.Id, entry.RetryCount, ex.Message, cancellationToken).ConfigureAwait(false);
|
||||
MarkAsFailed(entry, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark completed entries
|
||||
if (completedIds.Count > 0)
|
||||
{
|
||||
const string completeSql = """
|
||||
UPDATE timeline.outbox
|
||||
SET status = 'COMPLETED', updated_at = NOW()
|
||||
WHERE id = ANY(@ids)
|
||||
""";
|
||||
foreach (var entry in entries.Where(e => completedIds.Contains(e.Id)))
|
||||
{
|
||||
entry.Status = "COMPLETED";
|
||||
entry.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await using var completeCmd = new NpgsqlCommand(completeSql, connection, transaction);
|
||||
completeCmd.Parameters.AddWithValue("@ids", completedIds.ToArray());
|
||||
await completeCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -152,37 +145,23 @@ public sealed class TimelineOutboxProcessor : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task MarkAsFailedAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction transaction,
|
||||
long id,
|
||||
int retryCount,
|
||||
string errorMessage,
|
||||
CancellationToken cancellationToken)
|
||||
private static void MarkAsFailed(EfCore.Models.OutboxEntry entry, string errorMessage)
|
||||
{
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 5 retries
|
||||
var nextRetryDelay = TimeSpan.FromSeconds(Math.Pow(2, retryCount));
|
||||
var nextRetryDelay = TimeSpan.FromSeconds(Math.Pow(2, entry.RetryCount));
|
||||
var maxRetries = 5;
|
||||
|
||||
var newStatus = retryCount >= maxRetries ? "FAILED" : "PENDING";
|
||||
entry.Status = entry.RetryCount >= maxRetries ? "FAILED" : "PENDING";
|
||||
entry.RetryCount += 1;
|
||||
entry.NextRetryAt = DateTimeOffset.UtcNow.Add(nextRetryDelay);
|
||||
entry.ErrorMessage = errorMessage;
|
||||
entry.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
const string sql = """
|
||||
UPDATE timeline.outbox
|
||||
SET status = @status,
|
||||
retry_count = @retry_count,
|
||||
next_retry_at = @next_retry_at,
|
||||
error_message = @error_message,
|
||||
updated_at = NOW()
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, connection, transaction);
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
cmd.Parameters.AddWithValue("@status", newStatus);
|
||||
cmd.Parameters.AddWithValue("@retry_count", retryCount + 1);
|
||||
cmd.Parameters.AddWithValue("@next_retry_at", DateTimeOffset.UtcNow.Add(nextRetryDelay));
|
||||
cmd.Parameters.AddWithValue("@error_message", errorMessage);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
private EfCore.Context.EventingDbContext CreateDbContext(NpgsqlConnection connection)
|
||||
{
|
||||
var commandTimeout = _eventingDataSource?.CommandTimeoutSeconds ?? 30;
|
||||
var schemaName = _eventingDataSource?.SchemaName ?? EventingDataSource.DefaultSchemaName;
|
||||
return EventingDbContextFactory.Create(connection, commandTimeout, schemaName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.Eventing.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL data source for the Eventing module.
|
||||
/// Manages connections for timeline event storage and outbox processing.
|
||||
/// </summary>
|
||||
public sealed class EventingDataSource : DataSourceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Default schema name for Eventing tables.
|
||||
/// </summary>
|
||||
public const string DefaultSchemaName = "timeline";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Eventing data source.
|
||||
/// </summary>
|
||||
public EventingDataSource(IOptions<PostgresOptions> options, ILogger<EventingDataSource> logger)
|
||||
: base(CreateOptions(options.Value), logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ModuleName => "Eventing";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
|
||||
{
|
||||
base.ConfigureDataSourceBuilder(builder);
|
||||
// No custom enum mappings required for the Eventing module.
|
||||
}
|
||||
|
||||
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
|
||||
{
|
||||
baseOptions.SchemaName = DefaultSchemaName;
|
||||
}
|
||||
return baseOptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.Eventing.EfCore.CompiledModels;
|
||||
using StellaOps.Eventing.EfCore.Context;
|
||||
|
||||
namespace StellaOps.Eventing.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime factory for creating <see cref="EventingDbContext"/> instances.
|
||||
/// Uses the static compiled model when schema matches the default; falls back to
|
||||
/// reflection-based model building for non-default schemas (integration tests).
|
||||
/// </summary>
|
||||
internal static class EventingDbContextFactory
|
||||
{
|
||||
public static EventingDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? EventingDataSource.DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<EventingDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, EventingDataSource.DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
// Use the static compiled model when schema mapping matches the default model.
|
||||
optionsBuilder.UseModel(EventingDbContextModel.Instance);
|
||||
}
|
||||
|
||||
return new EventingDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -14,14 +14,27 @@
|
||||
<ProjectReference Include="..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
|
||||
<Compile Remove="EfCore\CompiledModels\EventingDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Eventing.EfCore.Models;
|
||||
using StellaOps.Eventing.Models;
|
||||
using StellaOps.Eventing.Postgres;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Eventing.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="ITimelineEventStore"/>.
|
||||
/// PostgreSQL implementation of <see cref="ITimelineEventStore"/> backed by EF Core.
|
||||
/// </summary>
|
||||
public sealed class PostgresTimelineEventStore : ITimelineEventStore
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<PostgresTimelineEventStore> _logger;
|
||||
private readonly EventingDataSource? _eventingDataSource;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgresTimelineEventStore"/> class.
|
||||
/// Uses the raw NpgsqlDataSource (legacy DI path) or EventingDataSource (EF Core DI path).
|
||||
/// </summary>
|
||||
public PostgresTimelineEventStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresTimelineEventStore> logger)
|
||||
ILogger<PostgresTimelineEventStore> logger,
|
||||
EventingDataSource? eventingDataSource = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_eventingDataSource = eventingDataSource;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -34,27 +38,17 @@ public sealed class PostgresTimelineEventStore : ITimelineEventStore
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timelineEvent);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO timeline.events (
|
||||
event_id, t_hlc, ts_wall, service, trace_parent,
|
||||
correlation_id, kind, payload, payload_digest,
|
||||
engine_name, engine_version, engine_digest, dsse_sig, schema_version
|
||||
) VALUES (
|
||||
@event_id, @t_hlc, @ts_wall, @service, @trace_parent,
|
||||
@correlation_id, @kind, @payload::jsonb, @payload_digest,
|
||||
@engine_name, @engine_version, @engine_digest, @dsse_sig, @schema_version
|
||||
)
|
||||
ON CONFLICT (event_id) DO NOTHING
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
|
||||
AddEventParameters(command, timelineEvent);
|
||||
var entity = MapToEntity(timelineEvent);
|
||||
dbContext.Events.Add(entity);
|
||||
|
||||
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (rowsAffected == 0)
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
_logger.LogDebug("Event {EventId} already exists (idempotent insert)", timelineEvent.EventId);
|
||||
}
|
||||
@@ -72,28 +66,25 @@ public sealed class PostgresTimelineEventStore : ITimelineEventStore
|
||||
}
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted, cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO timeline.events (
|
||||
event_id, t_hlc, ts_wall, service, trace_parent,
|
||||
correlation_id, kind, payload, payload_digest,
|
||||
engine_name, engine_version, engine_digest, dsse_sig, schema_version
|
||||
) VALUES (
|
||||
@event_id, @t_hlc, @ts_wall, @service, @trace_parent,
|
||||
@correlation_id, @kind, @payload::jsonb, @payload_digest,
|
||||
@engine_name, @engine_version, @engine_digest, @dsse_sig, @schema_version
|
||||
)
|
||||
ON CONFLICT (event_id) DO NOTHING
|
||||
""";
|
||||
|
||||
foreach (var timelineEvent in eventList)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(sql, connection, transaction);
|
||||
AddEventParameters(command, timelineEvent);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
var entity = MapToEntity(timelineEvent);
|
||||
dbContext.Events.Add(entity);
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
// Idempotent: event already exists, detach and continue
|
||||
dbContext.ChangeTracker.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -116,24 +107,19 @@ public sealed class PostgresTimelineEventStore : ITimelineEventStore
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
|
||||
|
||||
const string sql = """
|
||||
SELECT event_id, t_hlc, ts_wall, service, trace_parent,
|
||||
correlation_id, kind, payload, payload_digest,
|
||||
engine_name, engine_version, engine_digest, dsse_sig, schema_version
|
||||
FROM timeline.events
|
||||
WHERE correlation_id = @correlation_id
|
||||
ORDER BY t_hlc ASC
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
|
||||
command.Parameters.AddWithValue("@correlation_id", correlationId);
|
||||
command.Parameters.AddWithValue("@limit", limit);
|
||||
command.Parameters.AddWithValue("@offset", offset);
|
||||
var entities = await dbContext.Events
|
||||
.AsNoTracking()
|
||||
.Where(e => e.CorrelationId == correlationId)
|
||||
.OrderBy(e => e.THlc)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await ExecuteQueryAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
return entities.Select(MapToDomain).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -145,25 +131,22 @@ public sealed class PostgresTimelineEventStore : ITimelineEventStore
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
|
||||
|
||||
const string sql = """
|
||||
SELECT event_id, t_hlc, ts_wall, service, trace_parent,
|
||||
correlation_id, kind, payload, payload_digest,
|
||||
engine_name, engine_version, engine_digest, dsse_sig, schema_version
|
||||
FROM timeline.events
|
||||
WHERE correlation_id = @correlation_id
|
||||
AND t_hlc >= @from_hlc
|
||||
AND t_hlc <= @to_hlc
|
||||
ORDER BY t_hlc ASC
|
||||
""";
|
||||
var fromStr = fromHlc.ToSortableString();
|
||||
var toStr = toHlc.ToSortableString();
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
|
||||
command.Parameters.AddWithValue("@correlation_id", correlationId);
|
||||
command.Parameters.AddWithValue("@from_hlc", fromHlc.ToSortableString());
|
||||
command.Parameters.AddWithValue("@to_hlc", toHlc.ToSortableString());
|
||||
var entities = await dbContext.Events
|
||||
.AsNoTracking()
|
||||
.Where(e => e.CorrelationId == correlationId
|
||||
&& string.Compare(e.THlc, fromStr) >= 0
|
||||
&& string.Compare(e.THlc, toStr) <= 0)
|
||||
.OrderBy(e => e.THlc)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await ExecuteQueryAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
return entities.Select(MapToDomain).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -175,38 +158,26 @@ public sealed class PostgresTimelineEventStore : ITimelineEventStore
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(service);
|
||||
|
||||
var sql = fromHlc.HasValue
|
||||
? """
|
||||
SELECT event_id, t_hlc, ts_wall, service, trace_parent,
|
||||
correlation_id, kind, payload, payload_digest,
|
||||
engine_name, engine_version, engine_digest, dsse_sig, schema_version
|
||||
FROM timeline.events
|
||||
WHERE service = @service AND t_hlc >= @from_hlc
|
||||
ORDER BY t_hlc ASC
|
||||
LIMIT @limit
|
||||
"""
|
||||
: """
|
||||
SELECT event_id, t_hlc, ts_wall, service, trace_parent,
|
||||
correlation_id, kind, payload, payload_digest,
|
||||
engine_name, engine_version, engine_digest, dsse_sig, schema_version
|
||||
FROM timeline.events
|
||||
WHERE service = @service
|
||||
ORDER BY t_hlc ASC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
|
||||
command.Parameters.AddWithValue("@service", service);
|
||||
command.Parameters.AddWithValue("@limit", limit);
|
||||
IQueryable<TimelineEventEntity> query = dbContext.Events
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Service == service);
|
||||
|
||||
if (fromHlc.HasValue)
|
||||
{
|
||||
command.Parameters.AddWithValue("@from_hlc", fromHlc.Value.ToSortableString());
|
||||
var fromStr = fromHlc.Value.ToSortableString();
|
||||
query = query.Where(e => string.Compare(e.THlc, fromStr) >= 0);
|
||||
}
|
||||
|
||||
return await ExecuteQueryAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
var entities = await query
|
||||
.OrderBy(e => e.THlc)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(MapToDomain).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -214,21 +185,15 @@ public sealed class PostgresTimelineEventStore : ITimelineEventStore
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(eventId);
|
||||
|
||||
const string sql = """
|
||||
SELECT event_id, t_hlc, ts_wall, service, trace_parent,
|
||||
correlation_id, kind, payload, payload_digest,
|
||||
engine_name, engine_version, engine_digest, dsse_sig, schema_version
|
||||
FROM timeline.events
|
||||
WHERE event_id = @event_id
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
|
||||
command.Parameters.AddWithValue("@event_id", eventId);
|
||||
var entity = await dbContext.Events
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.EventId == eventId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var events = await ExecuteQueryAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
return events.Count > 0 ? events[0] : null;
|
||||
return entity is null ? null : MapToDomain(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -236,78 +201,75 @@ public sealed class PostgresTimelineEventStore : ITimelineEventStore
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
|
||||
|
||||
const string sql = """
|
||||
SELECT COUNT(*) FROM timeline.events WHERE correlation_id = @correlation_id
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
|
||||
command.Parameters.AddWithValue("@correlation_id", correlationId);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToInt64(result, CultureInfo.InvariantCulture);
|
||||
return await dbContext.Events
|
||||
.AsNoTracking()
|
||||
.Where(e => e.CorrelationId == correlationId)
|
||||
.LongCountAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void AddEventParameters(NpgsqlCommand command, TimelineEvent e)
|
||||
private EfCore.Context.EventingDbContext CreateDbContext(NpgsqlConnection connection)
|
||||
{
|
||||
command.Parameters.AddWithValue("@event_id", e.EventId);
|
||||
command.Parameters.AddWithValue("@t_hlc", e.THlc.ToSortableString());
|
||||
command.Parameters.AddWithValue("@ts_wall", e.TsWall);
|
||||
command.Parameters.AddWithValue("@service", e.Service);
|
||||
command.Parameters.AddWithValue("@trace_parent", (object?)e.TraceParent ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@correlation_id", e.CorrelationId);
|
||||
command.Parameters.AddWithValue("@kind", e.Kind);
|
||||
command.Parameters.AddWithValue("@payload", e.Payload);
|
||||
command.Parameters.AddWithValue("@payload_digest", e.PayloadDigest);
|
||||
command.Parameters.AddWithValue("@engine_name", e.EngineVersion.EngineName);
|
||||
command.Parameters.AddWithValue("@engine_version", e.EngineVersion.Version);
|
||||
command.Parameters.AddWithValue("@engine_digest", e.EngineVersion.SourceDigest);
|
||||
command.Parameters.AddWithValue("@dsse_sig", (object?)e.DsseSig ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@schema_version", e.SchemaVersion);
|
||||
var commandTimeout = _eventingDataSource?.CommandTimeoutSeconds ?? 30;
|
||||
var schemaName = _eventingDataSource?.SchemaName ?? EventingDataSource.DefaultSchemaName;
|
||||
return EventingDbContextFactory.Create(connection, commandTimeout, schemaName);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<TimelineEvent>> ExecuteQueryAsync(
|
||||
NpgsqlCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
private static TimelineEventEntity MapToEntity(TimelineEvent e)
|
||||
{
|
||||
var events = new List<TimelineEvent>();
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
return new TimelineEventEntity
|
||||
{
|
||||
events.Add(MapFromReader(reader));
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private static TimelineEvent MapFromReader(NpgsqlDataReader reader)
|
||||
{
|
||||
var hlcString = reader.GetString(reader.GetOrdinal("t_hlc"));
|
||||
|
||||
return new TimelineEvent
|
||||
{
|
||||
EventId = reader.GetString(reader.GetOrdinal("event_id")),
|
||||
THlc = HlcTimestamp.Parse(hlcString),
|
||||
TsWall = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("ts_wall")),
|
||||
Service = reader.GetString(reader.GetOrdinal("service")),
|
||||
TraceParent = reader.IsDBNull(reader.GetOrdinal("trace_parent"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("trace_parent")),
|
||||
CorrelationId = reader.GetString(reader.GetOrdinal("correlation_id")),
|
||||
Kind = reader.GetString(reader.GetOrdinal("kind")),
|
||||
Payload = reader.GetString(reader.GetOrdinal("payload")),
|
||||
PayloadDigest = (byte[])reader.GetValue(reader.GetOrdinal("payload_digest")),
|
||||
EngineVersion = new EngineVersionRef(
|
||||
reader.GetString(reader.GetOrdinal("engine_name")),
|
||||
reader.GetString(reader.GetOrdinal("engine_version")),
|
||||
reader.GetString(reader.GetOrdinal("engine_digest"))),
|
||||
DsseSig = reader.IsDBNull(reader.GetOrdinal("dsse_sig"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("dsse_sig")),
|
||||
SchemaVersion = reader.GetInt32(reader.GetOrdinal("schema_version"))
|
||||
EventId = e.EventId,
|
||||
THlc = e.THlc.ToSortableString(),
|
||||
TsWall = e.TsWall,
|
||||
Service = e.Service,
|
||||
TraceParent = e.TraceParent,
|
||||
CorrelationId = e.CorrelationId,
|
||||
Kind = e.Kind,
|
||||
Payload = e.Payload,
|
||||
PayloadDigest = e.PayloadDigest,
|
||||
EngineName = e.EngineVersion.EngineName,
|
||||
EngineVersion = e.EngineVersion.Version,
|
||||
EngineDigest = e.EngineVersion.SourceDigest,
|
||||
DsseSig = e.DsseSig,
|
||||
SchemaVersion = e.SchemaVersion
|
||||
};
|
||||
}
|
||||
|
||||
private static TimelineEvent MapToDomain(TimelineEventEntity entity)
|
||||
{
|
||||
return new TimelineEvent
|
||||
{
|
||||
EventId = entity.EventId,
|
||||
THlc = HlcTimestamp.Parse(entity.THlc),
|
||||
TsWall = entity.TsWall,
|
||||
Service = entity.Service,
|
||||
TraceParent = entity.TraceParent,
|
||||
CorrelationId = entity.CorrelationId,
|
||||
Kind = entity.Kind,
|
||||
Payload = entity.Payload,
|
||||
PayloadDigest = entity.PayloadDigest,
|
||||
EngineVersion = new EngineVersionRef(
|
||||
entity.EngineName,
|
||||
entity.EngineVersion,
|
||||
entity.EngineDigest),
|
||||
DsseSig = entity.DsseSig,
|
||||
SchemaVersion = entity.SchemaVersion
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsUniqueViolation(DbUpdateException exception)
|
||||
{
|
||||
Exception? current = exception;
|
||||
while (current is not null)
|
||||
{
|
||||
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
|
||||
return true;
|
||||
current = current.InnerException;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Eventing Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260222_079_Eventing_dal_to_efcore.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
@@ -9,3 +9,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0077-T | DONE | Revalidated 2026-01-08; open findings tracked in audit report. |
|
||||
| AUDIT-0077-A | TODO | Revalidated 2026-01-08 (open findings). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| EVENT-EF-01 | DONE | AGENTS verified, migration plugin registered in Platform.Database. |
|
||||
| EVENT-EF-02 | DONE | EF Core models and DbContext scaffolded under EfCore/Context and EfCore/Models. |
|
||||
| EVENT-EF-03 | DONE | PostgresTimelineEventStore and TimelineOutboxProcessor converted to EF Core. |
|
||||
| EVENT-EF-04 | DONE | Compiled model stubs, design-time factory, and runtime factory added. |
|
||||
| EVENT-EF-05 | DONE | Sequential build/test pass (28/28 tests). Sprint and docs updated. |
|
||||
|
||||
Reference in New Issue
Block a user