Initial commit: Add project files
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
namespace KArtSell.BuildingBlocks.Auditing;
|
||||
|
||||
/// <summary>
|
||||
/// Append-only audit envelope. Corrections are new events referencing the original event.
|
||||
/// </summary>
|
||||
public sealed record AuditEvent(
|
||||
Guid AuditEventId,
|
||||
string EntityType,
|
||||
string EntityId,
|
||||
string EventType,
|
||||
string ActorId,
|
||||
string CorrelationId,
|
||||
string PayloadJson,
|
||||
string PayloadHash,
|
||||
Guid? CorrectsAuditEventId,
|
||||
DateTimeOffset OccurredAt);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace KArtSell.BuildingBlocks.Capabilities;
|
||||
|
||||
public sealed class CapabilityOptions
|
||||
{
|
||||
public const string SectionName = "Capabilities";
|
||||
|
||||
public bool AutomaticOrder { get; init; }
|
||||
public bool KisOrderAdapter { get; init; }
|
||||
public bool ClientPublication { get; init; }
|
||||
public bool ShadowEvaluation { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Data.Common;
|
||||
|
||||
namespace KArtSell.BuildingBlocks.Data;
|
||||
|
||||
public interface IDbConnectionFactory
|
||||
{
|
||||
ValueTask<DbConnection> OpenAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Data.Common;
|
||||
using Npgsql;
|
||||
|
||||
namespace KArtSell.BuildingBlocks.Data;
|
||||
|
||||
public sealed class NpgsqlConnectionFactory(NpgsqlDataSource dataSource) : IDbConnectionFactory
|
||||
{
|
||||
public async ValueTask<DbConnection> OpenAsync(CancellationToken cancellationToken = default)
|
||||
=> await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace KArtSell.BuildingBlocks.DataIntegrity;
|
||||
|
||||
public enum DataQualityStatus
|
||||
{
|
||||
Pass,
|
||||
Warn,
|
||||
Quarantined
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Point-in-time metadata shared by canonical data records.
|
||||
/// PublishedAt controls the earliest market session in which the record may be used.
|
||||
/// </summary>
|
||||
public sealed record PitRecordMetadata(
|
||||
string SourceId,
|
||||
DateTimeOffset SourceEventTime,
|
||||
DateTimeOffset PublishedAt,
|
||||
DateTimeOffset IngestedAt,
|
||||
DateTimeOffset EffectiveFrom,
|
||||
DateTimeOffset? EffectiveTo,
|
||||
int RevisionNo,
|
||||
string ContentHash,
|
||||
string DatasetId,
|
||||
DataQualityStatus QualityStatus)
|
||||
{
|
||||
public void EnsureValid()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(SourceId)) throw new ArgumentException("SourceId is required.");
|
||||
if (string.IsNullOrWhiteSpace(ContentHash)) throw new ArgumentException("ContentHash is required.");
|
||||
if (string.IsNullOrWhiteSpace(DatasetId)) throw new ArgumentException("DatasetId is required.");
|
||||
if (RevisionNo < 0) throw new ArgumentOutOfRangeException(nameof(RevisionNo));
|
||||
if (IngestedAt < PublishedAt) throw new ArgumentException("IngestedAt cannot precede PublishedAt.");
|
||||
if (EffectiveTo is not null && EffectiveTo < EffectiveFrom)
|
||||
throw new ArgumentException("EffectiveTo cannot precede EffectiveFrom.");
|
||||
}
|
||||
|
||||
public bool IsUsableAt(DateTimeOffset decisionCutoff)
|
||||
=> QualityStatus is DataQualityStatus.Pass && PublishedAt <= decisionCutoff;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using KArtSell.BuildingBlocks.Versioning;
|
||||
namespace KArtSell.BuildingBlocks.Execution;
|
||||
public sealed record ExecutionEnvelope(
|
||||
DateTimeOffset AsOf,
|
||||
DateTimeOffset PublishedAtCutoff,
|
||||
string ScopeKey,
|
||||
string IdempotencyKey,
|
||||
string CorrelationId,
|
||||
VersionSet VersionSet,
|
||||
string ContentHash)
|
||||
{
|
||||
public void EnsureValid()
|
||||
{
|
||||
if (PublishedAtCutoff > AsOf) throw new InvalidOperationException("PublishedAtCutoff cannot be after AsOf.");
|
||||
if (string.IsNullOrWhiteSpace(ScopeKey) || string.IsNullOrWhiteSpace(IdempotencyKey) || string.IsNullOrWhiteSpace(CorrelationId) || string.IsNullOrWhiteSpace(ContentHash))
|
||||
throw new InvalidOperationException("Execution envelope identifiers are required.");
|
||||
VersionSet.EnsureValid();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace KArtSell.BuildingBlocks.Hashing;
|
||||
|
||||
public static class ContentHasher
|
||||
{
|
||||
public static string Sha256(string value)
|
||||
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace KArtSell.BuildingBlocks.ReadModels;
|
||||
|
||||
public sealed record ProjectionCheckpoint(
|
||||
string ProjectionName,
|
||||
int ProjectionVersion,
|
||||
string SourceWatermark,
|
||||
Guid? LastEventId,
|
||||
string ContentHash,
|
||||
DateTimeOffset BuiltAt)
|
||||
{
|
||||
public bool IsStale(DateTimeOffset now, TimeSpan maximumAge) => now - BuiltAt > maximumAge;
|
||||
}
|
||||
|
||||
public sealed record ProjectionRebuildRequest(
|
||||
Guid RebuildId,
|
||||
string ProjectionName,
|
||||
int TargetVersion,
|
||||
string ThroughWatermark,
|
||||
bool DryRun,
|
||||
string RequestedBy,
|
||||
DateTimeOffset RequestedAt);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace KArtSell.BuildingBlocks.ReadModels;
|
||||
public enum ProjectionHealth { Current, Stale, Rebuilding, Failed, Quarantined }
|
||||
public sealed record ProjectionContract(string Name, int Version, string SourceWatermark, DateTimeOffset BuiltAt, TimeSpan StaleAfter, string Owner)
|
||||
{
|
||||
public ProjectionHealth Evaluate(DateTimeOffset now, bool rebuilding = false, bool failed = false, bool quarantined = false)
|
||||
{
|
||||
if (quarantined) return ProjectionHealth.Quarantined;
|
||||
if (failed) return ProjectionHealth.Failed;
|
||||
if (rebuilding) return ProjectionHealth.Rebuilding;
|
||||
return now - BuiltAt > StaleAfter ? ProjectionHealth.Stale : ProjectionHealth.Current;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Data.Common;
|
||||
using Dapper;
|
||||
|
||||
namespace KArtSell.BuildingBlocks.Reliability;
|
||||
|
||||
public sealed class DapperInboxStore : IInboxStore
|
||||
{
|
||||
private const string Sql = """
|
||||
insert into building_blocks.inbox_message
|
||||
(consumer, message_id, received_at, payload_hash)
|
||||
values (@Consumer, @MessageId, @ReceivedAt, @PayloadHash)
|
||||
on conflict (consumer, message_id) do nothing;
|
||||
""";
|
||||
|
||||
public async Task<bool> TryBeginAsync(
|
||||
DbConnection connection,
|
||||
DbTransaction transaction,
|
||||
string consumer,
|
||||
Guid messageId,
|
||||
string payloadHash,
|
||||
DateTimeOffset receivedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var affected = await connection.ExecuteAsync(new CommandDefinition(
|
||||
Sql,
|
||||
new { Consumer = consumer, MessageId = messageId, ReceivedAt = receivedAt, PayloadHash = payloadHash },
|
||||
transaction,
|
||||
cancellationToken: cancellationToken));
|
||||
return affected == 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using Dapper;
|
||||
using KArtSell.BuildingBlocks.Data;
|
||||
|
||||
namespace KArtSell.BuildingBlocks.Reliability;
|
||||
|
||||
public sealed class DapperJobRunRepository(IDbConnectionFactory connectionFactory) : IJobRunRepository
|
||||
{
|
||||
private const string InsertSql = """
|
||||
insert into building_blocks.job_run
|
||||
(job_run_id, job_type, job_version, scope_key, idempotency_key, watermark,
|
||||
app_version, model_version, config_version, data_version, contract_version,
|
||||
status, input_hash, started_at, heartbeat_at, trace_id)
|
||||
values
|
||||
(@JobRunId, @JobType, @JobVersion, @ScopeKey, @IdempotencyKey, @Watermark,
|
||||
@CodeSha, @ModelVersion, @ConfigVersion, @DatasetId, @ContractVersion,
|
||||
'Running', @InputHash, @RequestedAt, @RequestedAt, @TraceId)
|
||||
on conflict (idempotency_key) do nothing;
|
||||
""";
|
||||
|
||||
private const string HeartbeatSql = """
|
||||
update building_blocks.job_run
|
||||
set heartbeat_at = @At
|
||||
where job_run_id = @JobRunId and status = 'Running';
|
||||
""";
|
||||
|
||||
private const string CompleteSql = """
|
||||
update building_blocks.job_run
|
||||
set status = @Status,
|
||||
output_hash = @OutputHash,
|
||||
watermark = coalesce(@Watermark, watermark),
|
||||
error_code = @ErrorCode,
|
||||
finished_at = @FinishedAt,
|
||||
heartbeat_at = @FinishedAt
|
||||
where job_run_id = @JobRunId and status = 'Running';
|
||||
""";
|
||||
|
||||
public async Task<bool> TryStartAsync(JobRunStart start, CancellationToken cancellationToken)
|
||||
{
|
||||
start.VersionSet.EnsureValid();
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
var affected = await connection.ExecuteAsync(new CommandDefinition(
|
||||
InsertSql,
|
||||
new
|
||||
{
|
||||
start.JobRunId,
|
||||
start.JobType,
|
||||
start.JobVersion,
|
||||
start.ScopeKey,
|
||||
start.IdempotencyKey,
|
||||
start.Watermark,
|
||||
start.VersionSet.CodeSha,
|
||||
start.VersionSet.ModelVersion,
|
||||
start.VersionSet.ConfigVersion,
|
||||
start.VersionSet.DatasetId,
|
||||
start.VersionSet.ContractVersion,
|
||||
start.InputHash,
|
||||
start.TraceId,
|
||||
start.RequestedAt
|
||||
},
|
||||
cancellationToken: cancellationToken));
|
||||
return affected == 1;
|
||||
}
|
||||
|
||||
public async Task HeartbeatAsync(Guid jobRunId, DateTimeOffset at, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
await connection.ExecuteAsync(new CommandDefinition(
|
||||
HeartbeatSql,
|
||||
new { JobRunId = jobRunId, At = at },
|
||||
cancellationToken: cancellationToken));
|
||||
}
|
||||
|
||||
public async Task CompleteAsync(JobRunCompletion completion, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
var affected = await connection.ExecuteAsync(new CommandDefinition(
|
||||
CompleteSql,
|
||||
new
|
||||
{
|
||||
completion.JobRunId,
|
||||
Status = completion.Status.ToString(),
|
||||
completion.OutputHash,
|
||||
completion.Watermark,
|
||||
completion.ErrorCode,
|
||||
completion.FinishedAt
|
||||
},
|
||||
cancellationToken: cancellationToken));
|
||||
if (affected != 1)
|
||||
{
|
||||
throw new InvalidOperationException("Job run is absent or no longer Running.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Data.Common;
|
||||
using Dapper;
|
||||
|
||||
namespace KArtSell.BuildingBlocks.Reliability;
|
||||
|
||||
public sealed class DapperOutboxWriter : IOutboxWriter
|
||||
{
|
||||
private const string Sql = """
|
||||
insert into building_blocks.outbox_message
|
||||
(message_id, event_type, schema_version, payload_json, correlation_id, occurred_at, payload_hash)
|
||||
values (@MessageId, @EventType, @SchemaVersion, cast(@PayloadJson as jsonb), @CorrelationId, @OccurredAt, @PayloadHash)
|
||||
on conflict (message_id) do nothing;
|
||||
""";
|
||||
|
||||
public Task AddAsync(DbConnection connection, DbTransaction transaction, OutboxMessage message, CancellationToken cancellationToken)
|
||||
=> connection.ExecuteAsync(new CommandDefinition(Sql, message, transaction, cancellationToken: cancellationToken));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Data.Common;
|
||||
|
||||
namespace KArtSell.BuildingBlocks.Reliability;
|
||||
|
||||
public interface IInboxStore
|
||||
{
|
||||
Task<bool> TryBeginAsync(
|
||||
DbConnection connection,
|
||||
DbTransaction transaction,
|
||||
string consumer,
|
||||
Guid messageId,
|
||||
string payloadHash,
|
||||
DateTimeOffset receivedAt,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace KArtSell.BuildingBlocks.Reliability;
|
||||
|
||||
public interface IJobRunRepository
|
||||
{
|
||||
Task<bool> TryStartAsync(JobRunStart start, CancellationToken cancellationToken);
|
||||
Task HeartbeatAsync(Guid jobRunId, DateTimeOffset at, CancellationToken cancellationToken);
|
||||
Task CompleteAsync(JobRunCompletion completion, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Data.Common;
|
||||
|
||||
namespace KArtSell.BuildingBlocks.Reliability;
|
||||
|
||||
public interface IOutboxWriter
|
||||
{
|
||||
Task AddAsync(DbConnection connection, DbTransaction transaction, OutboxMessage message, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using KArtSell.BuildingBlocks.Versioning;
|
||||
|
||||
namespace KArtSell.BuildingBlocks.Reliability;
|
||||
|
||||
public enum JobRunStatus
|
||||
{
|
||||
Requested,
|
||||
Running,
|
||||
Succeeded,
|
||||
BusinessHold,
|
||||
Quarantined,
|
||||
Failed,
|
||||
DeadLettered
|
||||
}
|
||||
|
||||
public sealed record JobRunStart(
|
||||
Guid JobRunId,
|
||||
string JobType,
|
||||
int JobVersion,
|
||||
string ScopeKey,
|
||||
string IdempotencyKey,
|
||||
string? Watermark,
|
||||
VersionSet VersionSet,
|
||||
string? InputHash,
|
||||
string TraceId,
|
||||
DateTimeOffset RequestedAt);
|
||||
|
||||
public sealed record JobRunCompletion(
|
||||
Guid JobRunId,
|
||||
JobRunStatus Status,
|
||||
string? OutputHash,
|
||||
string? Watermark,
|
||||
string? ErrorCode,
|
||||
DateTimeOffset FinishedAt);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace KArtSell.BuildingBlocks.Reliability;
|
||||
|
||||
public sealed record OutboxMessage(
|
||||
Guid MessageId,
|
||||
string EventType,
|
||||
int SchemaVersion,
|
||||
string PayloadJson,
|
||||
string CorrelationId,
|
||||
DateTimeOffset OccurredAt,
|
||||
string PayloadHash);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace KArtSell.BuildingBlocks.Time;
|
||||
|
||||
public interface IClock
|
||||
{
|
||||
DateTimeOffset UtcNow { get; }
|
||||
}
|
||||
|
||||
public sealed class SystemClock : IClock
|
||||
{
|
||||
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace KArtSell.BuildingBlocks.Versioning;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable identity of the data, model, configuration and code used for a decision.
|
||||
/// Values are opaque, approved identifiers or SHA-256 digests; they are never inferred from the client.
|
||||
/// </summary>
|
||||
public sealed record VersionSet(
|
||||
string DatasetId,
|
||||
string DataHash,
|
||||
string ModelVersion,
|
||||
string ConfigVersion,
|
||||
string CodeSha,
|
||||
string ContractVersion)
|
||||
{
|
||||
public void EnsureValid()
|
||||
{
|
||||
EnsureNotBlank(DatasetId, nameof(DatasetId));
|
||||
EnsureNotBlank(DataHash, nameof(DataHash));
|
||||
EnsureNotBlank(ModelVersion, nameof(ModelVersion));
|
||||
EnsureNotBlank(ConfigVersion, nameof(ConfigVersion));
|
||||
EnsureNotBlank(CodeSha, nameof(CodeSha));
|
||||
EnsureNotBlank(ContractVersion, nameof(ContractVersion));
|
||||
}
|
||||
|
||||
private static void EnsureNotBlank(string value, string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException($"{name} must be an approved, non-blank identifier.", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user