audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories
This commit is contained in:
24
src/__Libraries/StellaOps.Eventing/AGENTS.md
Normal file
24
src/__Libraries/StellaOps.Eventing/AGENTS.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Eventing Library Charter
|
||||
|
||||
## Mission
|
||||
- Provide event envelope models and deterministic serialization helpers.
|
||||
|
||||
## Responsibilities
|
||||
- Define event envelope schemas and validation helpers.
|
||||
- Provide serialization/parsing utilities with invariant culture.
|
||||
- Maintain envelope versioning and compatibility rules.
|
||||
|
||||
## Required Reading
|
||||
- docs/README.md
|
||||
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
- docs/modules/eventing/event-envelope-schema.md
|
||||
|
||||
## Working Agreement
|
||||
- Deterministic ordering and invariant formatting.
|
||||
- Use TimeProvider and IGuidGenerator where timestamps or IDs are created.
|
||||
- Propagate CancellationToken for async operations.
|
||||
|
||||
## Testing Strategy
|
||||
- Unit tests for envelope validation and serialization round-trips.
|
||||
- Determinism tests for stable ordering and hashes.
|
||||
64
src/__Libraries/StellaOps.Eventing/EventingOptions.cs
Normal file
64
src/__Libraries/StellaOps.Eventing/EventingOptions.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using StellaOps.Eventing.Models;
|
||||
|
||||
namespace StellaOps.Eventing;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the eventing library.
|
||||
/// </summary>
|
||||
public sealed class EventingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Eventing";
|
||||
|
||||
/// <summary>
|
||||
/// Name of the service emitting events.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string ServiceName { get; set; } = "Unknown";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to sign events with DSSE.
|
||||
/// </summary>
|
||||
public bool SignEvents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID for event signing (if SignEvents is true).
|
||||
/// </summary>
|
||||
public string? SigningKeyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Engine version reference (auto-detected if null).
|
||||
/// </summary>
|
||||
public EngineVersionRef? EngineVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Connection string for PostgreSQL event store.
|
||||
/// </summary>
|
||||
public string? ConnectionString { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use in-memory store (for testing).
|
||||
/// </summary>
|
||||
public bool UseInMemoryStore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Enable outbox pattern for reliable event delivery.
|
||||
/// </summary>
|
||||
public bool EnableOutbox { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Outbox batch size for processing.
|
||||
/// </summary>
|
||||
[Range(1, 10000)]
|
||||
public int OutboxBatchSize { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Outbox processing interval.
|
||||
/// </summary>
|
||||
public TimeSpan OutboxInterval { get; set; } = TimeSpan.FromSeconds(5);
|
||||
}
|
||||
36
src/__Libraries/StellaOps.Eventing/ITimelineEventEmitter.cs
Normal file
36
src/__Libraries/StellaOps.Eventing/ITimelineEventEmitter.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using StellaOps.Eventing.Models;
|
||||
|
||||
namespace StellaOps.Eventing;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for emitting timeline events.
|
||||
/// </summary>
|
||||
public interface ITimelineEventEmitter
|
||||
{
|
||||
/// <summary>
|
||||
/// Emits a single event to the timeline.
|
||||
/// </summary>
|
||||
/// <typeparam name="TPayload">The payload type.</typeparam>
|
||||
/// <param name="correlationId">Correlation ID linking related events.</param>
|
||||
/// <param name="kind">Event kind (ENQUEUE, EXECUTE, etc.).</param>
|
||||
/// <param name="payload">Event payload object.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The emitted timeline event.</returns>
|
||||
Task<TimelineEvent> EmitAsync<TPayload>(
|
||||
string correlationId,
|
||||
string kind,
|
||||
TPayload payload,
|
||||
CancellationToken cancellationToken = default) where TPayload : notnull;
|
||||
|
||||
/// <summary>
|
||||
/// Emits multiple events atomically as a batch.
|
||||
/// </summary>
|
||||
/// <param name="events">Collection of pending events.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The emitted timeline events.</returns>
|
||||
Task<IReadOnlyList<TimelineEvent>> EmitBatchAsync(
|
||||
IEnumerable<PendingEvent> events,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
namespace StellaOps.Eventing.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Generates deterministic event IDs from event content.
|
||||
/// </summary>
|
||||
internal static class EventIdGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a deterministic event ID using SHA-256 hash of inputs.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">The correlation ID.</param>
|
||||
/// <param name="tHlc">The HLC timestamp.</param>
|
||||
/// <param name="service">The service name.</param>
|
||||
/// <param name="kind">The event kind.</param>
|
||||
/// <returns>First 32 hex characters of SHA-256 hash (128 bits).</returns>
|
||||
public static string Generate(
|
||||
string correlationId,
|
||||
HlcTimestamp tHlc,
|
||||
string service,
|
||||
string kind)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(service);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(kind);
|
||||
|
||||
using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
|
||||
// Append all inputs in deterministic order
|
||||
hasher.AppendData(Encoding.UTF8.GetBytes(correlationId));
|
||||
hasher.AppendData(Encoding.UTF8.GetBytes(tHlc.ToSortableString()));
|
||||
hasher.AppendData(Encoding.UTF8.GetBytes(service));
|
||||
hasher.AppendData(Encoding.UTF8.GetBytes(kind));
|
||||
|
||||
var hash = hasher.GetHashAndReset();
|
||||
|
||||
// Return first 16 bytes (128 bits) as lowercase hex (32 chars)
|
||||
return Convert.ToHexString(hash.AsSpan(0, 16)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes SHA-256 digest of the payload.
|
||||
/// </summary>
|
||||
/// <param name="payload">The canonicalized JSON payload.</param>
|
||||
/// <returns>SHA-256 digest bytes.</returns>
|
||||
public static byte[] ComputePayloadDigest(string payload)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
return SHA256.HashData(Encoding.UTF8.GetBytes(payload));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
-- Migration: 20260107_001_create_timeline_events
|
||||
-- Purpose: Create timeline schema and events table for unified event timeline
|
||||
|
||||
-- Create schema
|
||||
CREATE SCHEMA IF NOT EXISTS timeline;
|
||||
|
||||
-- Create events table
|
||||
CREATE TABLE timeline.events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
t_hlc TEXT NOT NULL, -- HLC timestamp (sortable string format)
|
||||
ts_wall TIMESTAMPTZ NOT NULL, -- Wall-clock time (informational)
|
||||
service TEXT NOT NULL, -- Service name
|
||||
trace_parent TEXT, -- W3C Trace Context traceparent
|
||||
correlation_id TEXT NOT NULL, -- Correlation ID linking events
|
||||
kind TEXT NOT NULL, -- Event kind (ENQUEUE, EXECUTE, etc.)
|
||||
payload JSONB NOT NULL, -- RFC 8785 canonicalized JSON payload
|
||||
payload_digest BYTEA NOT NULL, -- SHA-256 digest of payload
|
||||
engine_name TEXT NOT NULL, -- Engine/service name
|
||||
engine_version TEXT NOT NULL, -- Engine version
|
||||
engine_digest TEXT NOT NULL, -- Source/assembly digest
|
||||
dsse_sig TEXT, -- Optional DSSE signature
|
||||
schema_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create indexes for common query patterns
|
||||
CREATE INDEX idx_events_corr_hlc ON timeline.events (correlation_id, t_hlc);
|
||||
CREATE INDEX idx_events_svc_hlc ON timeline.events (service, t_hlc);
|
||||
CREATE INDEX idx_events_kind ON timeline.events (kind);
|
||||
CREATE INDEX idx_events_created_at ON timeline.events (created_at);
|
||||
|
||||
-- GIN index for payload queries
|
||||
CREATE INDEX idx_events_payload ON timeline.events USING GIN (payload);
|
||||
|
||||
-- Create outbox table for transactional outbox pattern
|
||||
CREATE TABLE timeline.outbox (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
event_id TEXT NOT NULL REFERENCES timeline.events(event_id),
|
||||
status TEXT NOT NULL DEFAULT 'PENDING', -- PENDING, PROCESSING, COMPLETED, FAILED
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_retry_at TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_outbox_status_retry ON timeline.outbox (status, next_retry_at)
|
||||
WHERE status IN ('PENDING', 'FAILED');
|
||||
|
||||
-- Comments for documentation
|
||||
COMMENT ON TABLE timeline.events IS 'Unified timeline events from all StellaOps services';
|
||||
COMMENT ON COLUMN timeline.events.event_id IS 'Deterministic event ID: SHA-256(correlation_id || t_hlc || service || kind)[0:32]';
|
||||
COMMENT ON COLUMN timeline.events.t_hlc IS 'HLC timestamp in sortable string format: {physical}:{logical}:{nodeId}';
|
||||
COMMENT ON COLUMN timeline.events.ts_wall IS 'Wall-clock time for human reference (not used for ordering)';
|
||||
COMMENT ON COLUMN timeline.events.correlation_id IS 'Links related events (e.g., scanId, jobId, artifactDigest)';
|
||||
COMMENT ON COLUMN timeline.events.kind IS 'Event type: ENQUEUE, EXECUTE, ATTEST, VERIFY, GATE_PASS, etc.';
|
||||
COMMENT ON COLUMN timeline.events.payload IS 'RFC 8785 canonicalized JSON payload for deterministic hashing';
|
||||
175
src/__Libraries/StellaOps.Eventing/Models/TimelineEvent.cs
Normal file
175
src/__Libraries/StellaOps.Eventing/Models/TimelineEvent.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Reflection;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
namespace StellaOps.Eventing.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical event envelope for unified timeline.
|
||||
/// </summary>
|
||||
public sealed record TimelineEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Deterministic event ID: SHA-256(correlation_id || t_hlc || service || kind)[0:32] as hex.
|
||||
/// </summary>
|
||||
[JsonPropertyName("event_id")]
|
||||
public required string EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HLC timestamp from StellaOps.HybridLogicalClock.
|
||||
/// </summary>
|
||||
[JsonPropertyName("t_hlc")]
|
||||
public required HlcTimestamp THlc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Wall-clock time (informational only).
|
||||
/// </summary>
|
||||
[JsonPropertyName("ts_wall")]
|
||||
public required DateTimeOffset TsWall { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Service name (e.g., "Scheduler", "AirGap", "Attestor").
|
||||
/// </summary>
|
||||
[JsonPropertyName("service")]
|
||||
public required string Service { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// W3C Trace Context traceparent.
|
||||
/// </summary>
|
||||
[JsonPropertyName("trace_parent")]
|
||||
public string? TraceParent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID linking related events.
|
||||
/// </summary>
|
||||
[JsonPropertyName("correlation_id")]
|
||||
public required string CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event kind (ENQUEUE, EXECUTE, EMIT, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public required string Kind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// RFC 8785 canonicalized JSON payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("payload")]
|
||||
public required string Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 digest of Payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("payload_digest")]
|
||||
public required byte[] PayloadDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Engine version for reproducibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("engine_version")]
|
||||
public required EngineVersionRef EngineVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional DSSE signature.
|
||||
/// </summary>
|
||||
[JsonPropertyName("dsse_sig")]
|
||||
public string? DsseSig { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Schema version (current: 1).
|
||||
/// </summary>
|
||||
[JsonPropertyName("schema_version")]
|
||||
public int SchemaVersion { get; init; } = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Engine version reference for reproducibility tracking.
|
||||
/// </summary>
|
||||
/// <param name="EngineName">The name of the engine/service.</param>
|
||||
/// <param name="Version">The version string.</param>
|
||||
/// <param name="SourceDigest">SHA-256 digest of the source or assembly.</param>
|
||||
public sealed record EngineVersionRef(
|
||||
[property: JsonPropertyName("engine_name")] string EngineName,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("source_digest")] string SourceDigest)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an EngineVersionRef from the specified assembly metadata.
|
||||
/// </summary>
|
||||
public static EngineVersionRef FromAssembly(Assembly assembly)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(assembly);
|
||||
|
||||
var name = assembly.GetName();
|
||||
var version = name.Version?.ToString() ?? "0.0.0";
|
||||
|
||||
// Try to get source digest from assembly metadata
|
||||
var sourceDigest = assembly
|
||||
.GetCustomAttributes<AssemblyMetadataAttribute>()
|
||||
.FirstOrDefault(a => a.Key == "SourceDigest")?.Value
|
||||
?? "unknown";
|
||||
|
||||
return new EngineVersionRef(name.Name ?? "Unknown", version, sourceDigest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an EngineVersionRef from the entry assembly.
|
||||
/// </summary>
|
||||
public static EngineVersionRef FromEntryAssembly()
|
||||
{
|
||||
var assembly = Assembly.GetEntryAssembly()
|
||||
?? throw new InvalidOperationException("No entry assembly found");
|
||||
return FromAssembly(assembly);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pending event for batch emission.
|
||||
/// </summary>
|
||||
/// <param name="CorrelationId">Correlation ID linking related events.</param>
|
||||
/// <param name="Kind">Event kind (ENQUEUE, EXECUTE, etc.).</param>
|
||||
/// <param name="Payload">Event payload object.</param>
|
||||
public sealed record PendingEvent(
|
||||
string CorrelationId,
|
||||
string Kind,
|
||||
object Payload);
|
||||
|
||||
/// <summary>
|
||||
/// Standard event kinds used across StellaOps services.
|
||||
/// </summary>
|
||||
public static class EventKinds
|
||||
{
|
||||
// Scheduler events
|
||||
public const string Enqueue = "ENQUEUE";
|
||||
public const string Dequeue = "DEQUEUE";
|
||||
public const string Execute = "EXECUTE";
|
||||
public const string Complete = "COMPLETE";
|
||||
public const string Fail = "FAIL";
|
||||
|
||||
// AirGap events
|
||||
public const string Import = "IMPORT";
|
||||
public const string Export = "EXPORT";
|
||||
public const string Merge = "MERGE";
|
||||
public const string Conflict = "CONFLICT";
|
||||
|
||||
// Attestor events
|
||||
public const string Attest = "ATTEST";
|
||||
public const string Verify = "VERIFY";
|
||||
|
||||
// Policy events
|
||||
public const string Evaluate = "EVALUATE";
|
||||
public const string GatePass = "GATE_PASS";
|
||||
public const string GateFail = "GATE_FAIL";
|
||||
|
||||
// VexLens events
|
||||
public const string Consensus = "CONSENSUS";
|
||||
public const string Override = "OVERRIDE";
|
||||
|
||||
// Generic events
|
||||
public const string Emit = "EMIT";
|
||||
public const string Ack = "ACK";
|
||||
public const string Error = "ERR";
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using System.Data;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Eventing.Outbox;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that processes the transactional outbox for reliable event delivery.
|
||||
/// </summary>
|
||||
public sealed class TimelineOutboxProcessor : BackgroundService
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly IOptions<EventingOptions> _options;
|
||||
private readonly ILogger<TimelineOutboxProcessor> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimelineOutboxProcessor"/> class.
|
||||
/// </summary>
|
||||
public TimelineOutboxProcessor(
|
||||
NpgsqlDataSource dataSource,
|
||||
IOptions<EventingOptions> options,
|
||||
ILogger<TimelineOutboxProcessor> logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!_options.Value.EnableOutbox)
|
||||
{
|
||||
_logger.LogInformation("Outbox processing disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting outbox processor with batch size {BatchSize} and interval {Interval}",
|
||||
_options.Value.OutboxBatchSize,
|
||||
_options.Value.OutboxInterval);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var processedCount = await ProcessBatchAsync(stoppingToken).ConfigureAwait(false);
|
||||
|
||||
if (processedCount > 0)
|
||||
{
|
||||
_logger.LogDebug("Processed {Count} outbox entries", processedCount);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing outbox batch");
|
||||
}
|
||||
|
||||
await Task.Delay(_options.Value.OutboxInterval, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Outbox processor stopped");
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Process each entry (in real implementation, this would forward to downstream consumers)
|
||||
var completedIds = new List<long>();
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
try
|
||||
{
|
||||
// TODO: Forward event to downstream consumers
|
||||
// For now, just mark as completed
|
||||
completedIds.Add(entry.Id);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark completed entries
|
||||
if (completedIds.Count > 0)
|
||||
{
|
||||
const string completeSql = """
|
||||
UPDATE timeline.outbox
|
||||
SET status = 'COMPLETED', updated_at = NOW()
|
||||
WHERE id = ANY(@ids)
|
||||
""";
|
||||
|
||||
await using var completeCmd = new NpgsqlCommand(completeSql, connection, transaction);
|
||||
completeCmd.Parameters.AddWithValue("@ids", completedIds.ToArray());
|
||||
await completeCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return completedIds.Count;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task MarkAsFailedAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction transaction,
|
||||
long id,
|
||||
int retryCount,
|
||||
string errorMessage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 5 retries
|
||||
var nextRetryDelay = TimeSpan.FromSeconds(Math.Pow(2, retryCount));
|
||||
var maxRetries = 5;
|
||||
|
||||
var newStatus = retryCount >= maxRetries ? "FAILED" : "PENDING";
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Npgsql;
|
||||
using StellaOps.Eventing.Signing;
|
||||
using StellaOps.Eventing.Storage;
|
||||
using StellaOps.Eventing.Telemetry;
|
||||
|
||||
namespace StellaOps.Eventing;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering eventing services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds StellaOps eventing services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The configuration.</param>
|
||||
/// <returns>The service collection.</returns>
|
||||
public static IServiceCollection AddStellaOpsEventing(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
// Register options with validation
|
||||
services.AddOptions<EventingOptions>()
|
||||
.Bind(configuration.GetSection(EventingOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// Register core services
|
||||
services.TryAddSingleton<ITimelineEventEmitter, TimelineEventEmitter>();
|
||||
|
||||
// Register event store based on configuration
|
||||
var options = configuration.GetSection(EventingOptions.SectionName).Get<EventingOptions>();
|
||||
if (options?.UseInMemoryStore == true)
|
||||
{
|
||||
services.TryAddSingleton<ITimelineEventStore, InMemoryTimelineEventStore>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.TryAddSingleton<ITimelineEventStore, PostgresTimelineEventStore>();
|
||||
}
|
||||
|
||||
// Register telemetry
|
||||
services.TryAddSingleton<EventingTelemetry>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds StellaOps eventing with PostgreSQL store.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="serviceName">The service name.</param>
|
||||
/// <param name="connectionString">The PostgreSQL connection string.</param>
|
||||
/// <returns>The service collection.</returns>
|
||||
public static IServiceCollection AddStellaOpsEventing(
|
||||
this IServiceCollection services,
|
||||
string serviceName,
|
||||
string connectionString)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(serviceName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||
|
||||
services.Configure<EventingOptions>(options =>
|
||||
{
|
||||
options.ServiceName = serviceName;
|
||||
options.ConnectionString = connectionString;
|
||||
});
|
||||
|
||||
services.AddOptions<EventingOptions>()
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// Register NpgsqlDataSource
|
||||
services.TryAddSingleton(_ => NpgsqlDataSource.Create(connectionString));
|
||||
|
||||
services.TryAddSingleton<ITimelineEventStore, PostgresTimelineEventStore>();
|
||||
services.TryAddSingleton<ITimelineEventEmitter, TimelineEventEmitter>();
|
||||
services.TryAddSingleton<EventingTelemetry>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds StellaOps eventing with in-memory store (for testing).
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="serviceName">The service name.</param>
|
||||
/// <returns>The service collection.</returns>
|
||||
public static IServiceCollection AddStellaOpsEventingInMemory(
|
||||
this IServiceCollection services,
|
||||
string serviceName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(serviceName);
|
||||
|
||||
services.Configure<EventingOptions>(options =>
|
||||
{
|
||||
options.ServiceName = serviceName;
|
||||
options.UseInMemoryStore = true;
|
||||
});
|
||||
|
||||
services.AddOptions<EventingOptions>()
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<ITimelineEventStore, InMemoryTimelineEventStore>();
|
||||
services.TryAddSingleton<ITimelineEventEmitter, TimelineEventEmitter>();
|
||||
services.TryAddSingleton<EventingTelemetry>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an event signer for DSSE signing.
|
||||
/// </summary>
|
||||
/// <typeparam name="TSigner">The signer implementation type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection.</returns>
|
||||
public static IServiceCollection AddEventSigner<TSigner>(this IServiceCollection services)
|
||||
where TSigner : class, IEventSigner
|
||||
{
|
||||
services.TryAddSingleton<IEventSigner, TSigner>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
30
src/__Libraries/StellaOps.Eventing/Signing/IEventSigner.cs
Normal file
30
src/__Libraries/StellaOps.Eventing/Signing/IEventSigner.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using StellaOps.Eventing.Models;
|
||||
|
||||
namespace StellaOps.Eventing.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for signing timeline events with DSSE.
|
||||
/// </summary>
|
||||
public interface IEventSigner
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs a timeline event and returns the DSSE signature.
|
||||
/// </summary>
|
||||
/// <param name="timelineEvent">The event to sign.</param>
|
||||
/// <returns>The DSSE signature string.</returns>
|
||||
string Sign(TimelineEvent timelineEvent);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a timeline event signature.
|
||||
/// </summary>
|
||||
/// <param name="timelineEvent">The event with DSSE signature.</param>
|
||||
/// <returns>True if signature is valid.</returns>
|
||||
bool Verify(TimelineEvent timelineEvent);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the key ID used for signing.
|
||||
/// </summary>
|
||||
string KeyId { get; }
|
||||
}
|
||||
28
src/__Libraries/StellaOps.Eventing/StellaOps.Eventing.csproj
Normal file
28
src/__Libraries/StellaOps.Eventing/StellaOps.Eventing.csproj
Normal file
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Eventing</RootNamespace>
|
||||
<Description>StellaOps Event Envelope SDK for unified timeline events</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.HybridLogicalClock\StellaOps.HybridLogicalClock.csproj" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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="System.Diagnostics.DiagnosticSource" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using StellaOps.Eventing.Models;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
namespace StellaOps.Eventing.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for timeline event persistence.
|
||||
/// </summary>
|
||||
public interface ITimelineEventStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Appends a single event to the store.
|
||||
/// </summary>
|
||||
/// <param name="timelineEvent">The event to append.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the operation.</returns>
|
||||
Task AppendAsync(TimelineEvent timelineEvent, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Appends multiple events atomically.
|
||||
/// </summary>
|
||||
/// <param name="events">The events to append.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the operation.</returns>
|
||||
Task AppendBatchAsync(IEnumerable<TimelineEvent> events, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets events by correlation ID, ordered by HLC timestamp.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">The correlation ID.</param>
|
||||
/// <param name="limit">Maximum number of events to return.</param>
|
||||
/// <param name="offset">Number of events to skip.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Events ordered by HLC timestamp.</returns>
|
||||
Task<IReadOnlyList<TimelineEvent>> GetByCorrelationIdAsync(
|
||||
string correlationId,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets events within an HLC range.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">The correlation ID.</param>
|
||||
/// <param name="fromHlc">Start of HLC range (inclusive).</param>
|
||||
/// <param name="toHlc">End of HLC range (inclusive).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Events within the range, ordered by HLC timestamp.</returns>
|
||||
Task<IReadOnlyList<TimelineEvent>> GetByHlcRangeAsync(
|
||||
string correlationId,
|
||||
HlcTimestamp fromHlc,
|
||||
HlcTimestamp toHlc,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets events by service.
|
||||
/// </summary>
|
||||
/// <param name="service">The service name.</param>
|
||||
/// <param name="fromHlc">Optional start of HLC range.</param>
|
||||
/// <param name="limit">Maximum number of events to return.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Events from the service, ordered by HLC timestamp.</returns>
|
||||
Task<IReadOnlyList<TimelineEvent>> GetByServiceAsync(
|
||||
string service,
|
||||
HlcTimestamp? fromHlc = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single event by ID.
|
||||
/// </summary>
|
||||
/// <param name="eventId">The event ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The event, or null if not found.</returns>
|
||||
Task<TimelineEvent?> GetByIdAsync(string eventId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Counts events for a correlation ID.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">The correlation ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of events.</returns>
|
||||
Task<long> CountByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Eventing.Models;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
namespace StellaOps.Eventing.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="ITimelineEventStore"/> for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryTimelineEventStore : ITimelineEventStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, TimelineEvent> _events = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task AppendAsync(TimelineEvent timelineEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timelineEvent);
|
||||
|
||||
_events.TryAdd(timelineEvent.EventId, timelineEvent);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task AppendBatchAsync(IEnumerable<TimelineEvent> events, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
|
||||
foreach (var e in events)
|
||||
{
|
||||
_events.TryAdd(e.EventId, e);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<TimelineEvent>> GetByCorrelationIdAsync(
|
||||
string correlationId,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _events.Values
|
||||
.Where(e => e.CorrelationId == correlationId)
|
||||
.OrderBy(e => e.THlc.ToSortableString())
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<TimelineEvent>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<TimelineEvent>> GetByHlcRangeAsync(
|
||||
string correlationId,
|
||||
HlcTimestamp fromHlc,
|
||||
HlcTimestamp toHlc,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var fromStr = fromHlc.ToSortableString();
|
||||
var toStr = toHlc.ToSortableString();
|
||||
|
||||
var result = _events.Values
|
||||
.Where(e => e.CorrelationId == correlationId)
|
||||
.Where(e =>
|
||||
{
|
||||
var hlcStr = e.THlc.ToSortableString();
|
||||
return string.Compare(hlcStr, fromStr, StringComparison.Ordinal) >= 0 &&
|
||||
string.Compare(hlcStr, toStr, StringComparison.Ordinal) <= 0;
|
||||
})
|
||||
.OrderBy(e => e.THlc.ToSortableString())
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<TimelineEvent>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<TimelineEvent>> GetByServiceAsync(
|
||||
string service,
|
||||
HlcTimestamp? fromHlc = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _events.Values.Where(e => e.Service == service);
|
||||
|
||||
if (fromHlc.HasValue)
|
||||
{
|
||||
var fromStr = fromHlc.Value.ToSortableString();
|
||||
query = query.Where(e =>
|
||||
string.Compare(e.THlc.ToSortableString(), fromStr, StringComparison.Ordinal) >= 0);
|
||||
}
|
||||
|
||||
var result = query
|
||||
.OrderBy(e => e.THlc.ToSortableString())
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<TimelineEvent>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<TimelineEvent?> GetByIdAsync(string eventId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_events.TryGetValue(eventId, out var e);
|
||||
return Task.FromResult(e);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<long> CountByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var count = _events.Values.Count(e => e.CorrelationId == correlationId);
|
||||
return Task.FromResult((long)count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all events (for testing).
|
||||
/// </summary>
|
||||
public void Clear() => _events.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all events (for testing).
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<TimelineEvent> GetAll() => _events.Values.ToList();
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Eventing.Models;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
namespace StellaOps.Eventing.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="ITimelineEventStore"/>.
|
||||
/// </summary>
|
||||
public sealed class PostgresTimelineEventStore : ITimelineEventStore
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<PostgresTimelineEventStore> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgresTimelineEventStore"/> class.
|
||||
/// </summary>
|
||||
public PostgresTimelineEventStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresTimelineEventStore> logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task AppendAsync(TimelineEvent timelineEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
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);
|
||||
|
||||
AddEventParameters(command, timelineEvent);
|
||||
|
||||
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (rowsAffected == 0)
|
||||
{
|
||||
_logger.LogDebug("Event {EventId} already exists (idempotent insert)", timelineEvent.EventId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task AppendBatchAsync(IEnumerable<TimelineEvent> events, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
|
||||
var eventList = events.ToList();
|
||||
if (eventList.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted, 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);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Appended batch of {Count} events", eventList.Count);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<TimelineEvent>> GetByCorrelationIdAsync(
|
||||
string correlationId,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
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);
|
||||
|
||||
command.Parameters.AddWithValue("@correlation_id", correlationId);
|
||||
command.Parameters.AddWithValue("@limit", limit);
|
||||
command.Parameters.AddWithValue("@offset", offset);
|
||||
|
||||
return await ExecuteQueryAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<TimelineEvent>> GetByHlcRangeAsync(
|
||||
string correlationId,
|
||||
HlcTimestamp fromHlc,
|
||||
HlcTimestamp toHlc,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
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
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
|
||||
command.Parameters.AddWithValue("@correlation_id", correlationId);
|
||||
command.Parameters.AddWithValue("@from_hlc", fromHlc.ToSortableString());
|
||||
command.Parameters.AddWithValue("@to_hlc", toHlc.ToSortableString());
|
||||
|
||||
return await ExecuteQueryAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<TimelineEvent>> GetByServiceAsync(
|
||||
string service,
|
||||
HlcTimestamp? fromHlc = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
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);
|
||||
|
||||
command.Parameters.AddWithValue("@service", service);
|
||||
command.Parameters.AddWithValue("@limit", limit);
|
||||
|
||||
if (fromHlc.HasValue)
|
||||
{
|
||||
command.Parameters.AddWithValue("@from_hlc", fromHlc.Value.ToSortableString());
|
||||
}
|
||||
|
||||
return await ExecuteQueryAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<TimelineEvent?> GetByIdAsync(string eventId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
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);
|
||||
|
||||
command.Parameters.AddWithValue("@event_id", eventId);
|
||||
|
||||
var events = await ExecuteQueryAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
return events.Count > 0 ? events[0] : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<long> CountByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
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);
|
||||
|
||||
command.Parameters.AddWithValue("@correlation_id", correlationId);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToInt64(result, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static void AddEventParameters(NpgsqlCommand command, TimelineEvent e)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<TimelineEvent>> ExecuteQueryAsync(
|
||||
NpgsqlCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var events = new List<TimelineEvent>();
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
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"))
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Eventing.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry instrumentation for the eventing library.
|
||||
/// </summary>
|
||||
public sealed class EventingTelemetry : IDisposable
|
||||
{
|
||||
private readonly Meter _meter;
|
||||
private readonly Counter<long> _eventsEmittedCounter;
|
||||
private readonly Counter<long> _eventsPersistFailedCounter;
|
||||
private readonly Histogram<double> _emitDurationHistogram;
|
||||
private readonly Counter<long> _batchesEmittedCounter;
|
||||
|
||||
/// <summary>
|
||||
/// Activity source for tracing.
|
||||
/// </summary>
|
||||
public static readonly ActivitySource ActivitySource = new("StellaOps.Eventing", "1.0.0");
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EventingTelemetry"/> class.
|
||||
/// </summary>
|
||||
public EventingTelemetry()
|
||||
{
|
||||
_meter = new Meter("StellaOps.Eventing", "1.0.0");
|
||||
|
||||
_eventsEmittedCounter = _meter.CreateCounter<long>(
|
||||
"stellaops_eventing_events_emitted_total",
|
||||
description: "Total number of timeline events emitted");
|
||||
|
||||
_eventsPersistFailedCounter = _meter.CreateCounter<long>(
|
||||
"stellaops_eventing_events_persist_failed_total",
|
||||
description: "Total number of events that failed to persist");
|
||||
|
||||
_emitDurationHistogram = _meter.CreateHistogram<double>(
|
||||
"stellaops_eventing_emit_duration_seconds",
|
||||
unit: "s",
|
||||
description: "Duration of event emission operations");
|
||||
|
||||
_batchesEmittedCounter = _meter.CreateCounter<long>(
|
||||
"stellaops_eventing_batches_emitted_total",
|
||||
description: "Total number of event batches emitted");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a single event emission.
|
||||
/// </summary>
|
||||
public void RecordEventEmitted(string service, string kind)
|
||||
{
|
||||
_eventsEmittedCounter.Add(1,
|
||||
new KeyValuePair<string, object?>("service", service),
|
||||
new KeyValuePair<string, object?>("kind", kind));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a batch event emission.
|
||||
/// </summary>
|
||||
public void RecordBatchEmitted(string service, int count)
|
||||
{
|
||||
_batchesEmittedCounter.Add(1,
|
||||
new KeyValuePair<string, object?>("service", service));
|
||||
_eventsEmittedCounter.Add(count,
|
||||
new KeyValuePair<string, object?>("service", service),
|
||||
new KeyValuePair<string, object?>("kind", "batch"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a persist failure.
|
||||
/// </summary>
|
||||
public void RecordPersistFailed(string service, string reason)
|
||||
{
|
||||
_eventsPersistFailedCounter.Add(1,
|
||||
new KeyValuePair<string, object?>("service", service),
|
||||
new KeyValuePair<string, object?>("reason", reason));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records emit duration.
|
||||
/// </summary>
|
||||
public void RecordEmitDuration(double durationSeconds, string service)
|
||||
{
|
||||
_emitDurationHistogram.Record(durationSeconds,
|
||||
new KeyValuePair<string, object?>("service", service));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an emit activity for tracing.
|
||||
/// </summary>
|
||||
public Activity? StartEmitActivity(string correlationId, string kind)
|
||||
{
|
||||
return ActivitySource.StartActivity(
|
||||
"eventing.emit",
|
||||
ActivityKind.Producer,
|
||||
parentContext: default,
|
||||
tags: new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("correlation_id", correlationId),
|
||||
new KeyValuePair<string, object?>("event_kind", kind)
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
_meter.Dispose();
|
||||
}
|
||||
}
|
||||
153
src/__Libraries/StellaOps.Eventing/TimelineEventEmitter.cs
Normal file
153
src/__Libraries/StellaOps.Eventing/TimelineEventEmitter.cs
Normal file
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Eventing.Internal;
|
||||
using StellaOps.Eventing.Models;
|
||||
using StellaOps.Eventing.Signing;
|
||||
using StellaOps.Eventing.Storage;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
namespace StellaOps.Eventing;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="ITimelineEventEmitter"/> for emitting timeline events.
|
||||
/// </summary>
|
||||
public sealed class TimelineEventEmitter : ITimelineEventEmitter
|
||||
{
|
||||
private readonly IHybridLogicalClock _hlc;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ITimelineEventStore _eventStore;
|
||||
private readonly IEventSigner? _eventSigner;
|
||||
private readonly IOptions<EventingOptions> _options;
|
||||
private readonly ILogger<TimelineEventEmitter> _logger;
|
||||
private readonly EngineVersionRef _engineVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimelineEventEmitter"/> class.
|
||||
/// </summary>
|
||||
public TimelineEventEmitter(
|
||||
IHybridLogicalClock hlc,
|
||||
TimeProvider timeProvider,
|
||||
ITimelineEventStore eventStore,
|
||||
IOptions<EventingOptions> options,
|
||||
ILogger<TimelineEventEmitter> logger,
|
||||
IEventSigner? eventSigner = null)
|
||||
{
|
||||
_hlc = hlc ?? throw new ArgumentNullException(nameof(hlc));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_eventStore = eventStore ?? throw new ArgumentNullException(nameof(eventStore));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_eventSigner = eventSigner;
|
||||
|
||||
_engineVersion = options.Value.EngineVersion ?? EngineVersionRef.FromEntryAssembly();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<TimelineEvent> EmitAsync<TPayload>(
|
||||
string correlationId,
|
||||
string kind,
|
||||
TPayload payload,
|
||||
CancellationToken cancellationToken = default) where TPayload : notnull
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(kind);
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
|
||||
var timelineEvent = CreateEvent(correlationId, kind, payload);
|
||||
|
||||
await _eventStore.AppendAsync(timelineEvent, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Emitted timeline event {EventId} for {CorrelationId} [{Kind}]",
|
||||
timelineEvent.EventId,
|
||||
correlationId,
|
||||
kind);
|
||||
|
||||
return timelineEvent;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<TimelineEvent>> EmitBatchAsync(
|
||||
IEnumerable<PendingEvent> events,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
|
||||
var timelineEvents = events
|
||||
.Select(e => CreateEvent(e.CorrelationId, e.Kind, e.Payload))
|
||||
.ToList();
|
||||
|
||||
if (timelineEvents.Count == 0)
|
||||
{
|
||||
return Array.Empty<TimelineEvent>();
|
||||
}
|
||||
|
||||
await _eventStore.AppendBatchAsync(timelineEvents, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Emitted batch of {Count} timeline events", timelineEvents.Count);
|
||||
|
||||
return timelineEvents;
|
||||
}
|
||||
|
||||
private TimelineEvent CreateEvent<TPayload>(
|
||||
string correlationId,
|
||||
string kind,
|
||||
TPayload payload) where TPayload : notnull
|
||||
{
|
||||
var tHlc = _hlc.Tick();
|
||||
var tsWall = _timeProvider.GetUtcNow();
|
||||
var service = _options.Value.ServiceName;
|
||||
|
||||
// Canonicalize payload using RFC 8785
|
||||
var canonicalPayload = CanonicalizePayload(payload);
|
||||
var payloadDigest = EventIdGenerator.ComputePayloadDigest(canonicalPayload);
|
||||
|
||||
// Generate deterministic event ID
|
||||
var eventId = EventIdGenerator.Generate(correlationId, tHlc, service, kind);
|
||||
|
||||
// Capture trace context if available
|
||||
var traceParent = Activity.Current?.Id;
|
||||
|
||||
var timelineEvent = new TimelineEvent
|
||||
{
|
||||
EventId = eventId,
|
||||
THlc = tHlc,
|
||||
TsWall = tsWall,
|
||||
Service = service,
|
||||
TraceParent = traceParent,
|
||||
CorrelationId = correlationId,
|
||||
Kind = kind,
|
||||
Payload = canonicalPayload,
|
||||
PayloadDigest = payloadDigest,
|
||||
EngineVersion = _engineVersion,
|
||||
SchemaVersion = 1
|
||||
};
|
||||
|
||||
// Sign if signer is available and signing is enabled
|
||||
if (_eventSigner is not null && _options.Value.SignEvents)
|
||||
{
|
||||
var signature = _eventSigner.Sign(timelineEvent);
|
||||
return timelineEvent with { DsseSig = signature };
|
||||
}
|
||||
|
||||
return timelineEvent;
|
||||
}
|
||||
|
||||
private static string CanonicalizePayload<TPayload>(TPayload payload)
|
||||
{
|
||||
// Use RFC 8785 canonicalization
|
||||
// For now, use standard JSON serialization with sorted keys
|
||||
// In production, this should use StellaOps.Canonical.Json
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(payload, options);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user