audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories
This commit is contained in:
66
src/Timeline/AGENTS.md
Normal file
66
src/Timeline/AGENTS.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Timeline Module - AGENTS.md
|
||||
|
||||
## Module Overview
|
||||
|
||||
The Timeline module provides a unified event timeline service for querying, replaying, and exporting HLC-ordered events across all StellaOps services.
|
||||
|
||||
## Roles Expected
|
||||
|
||||
- **Backend Engineer**: Implement API endpoints, query services, and replay orchestration
|
||||
- **QA Engineer**: Create integration tests with Testcontainers PostgreSQL
|
||||
|
||||
## Key Documentation
|
||||
|
||||
Before working on this module, read:
|
||||
|
||||
1. [CLAUDE.md](../../CLAUDE.md) - Code quality rules (especially 8.2 TimeProvider, 8.18 DateTimeOffset, 8.19 HLC)
|
||||
2. [docs/modules/eventing/event-envelope-schema.md](../../docs/modules/eventing/event-envelope-schema.md)
|
||||
3. [docs/modules/scheduler/hlc-ordering.md](../../docs/modules/scheduler/hlc-ordering.md)
|
||||
|
||||
## Working Agreements
|
||||
|
||||
### Directory Ownership
|
||||
|
||||
- **WebService**: `src/Timeline/StellaOps.Timeline.WebService/`
|
||||
- **Core Library**: `src/Timeline/__Libraries/StellaOps.Timeline.Core/`
|
||||
- **Tests**: `src/Timeline/__Tests/`
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Depends on: `StellaOps.Eventing`, `StellaOps.HybridLogicalClock`, `StellaOps.Replay.Core`
|
||||
- Consumed by: UI Console (Timeline view), CLI (replay commands)
|
||||
|
||||
### API Conventions
|
||||
|
||||
1. All endpoints under `/api/v1/timeline`
|
||||
2. HLC timestamps returned as sortable strings
|
||||
3. Pagination via `limit`, `offset`, and `nextCursor`
|
||||
4. Export produces NDJSON by default
|
||||
|
||||
### Testing Requirements
|
||||
|
||||
1. Unit tests with `[Trait("Category", "Unit")]`
|
||||
2. Integration tests with `[Trait("Category", "Integration")]`
|
||||
3. Use `FakeTimeProvider` for deterministic tests
|
||||
4. Use Testcontainers for PostgreSQL integration tests
|
||||
|
||||
## Module-Specific Rules
|
||||
|
||||
### HLC Ordering
|
||||
|
||||
- All queries return events ordered by HLC timestamp (ascending)
|
||||
- Critical path analysis uses wall-clock duration but references HLC for linking
|
||||
- Replay must preserve HLC ordering
|
||||
|
||||
### Export Format
|
||||
|
||||
```ndjson
|
||||
{"event_id":"abc123","t_hlc":"1704585600000:0:node1","correlation_id":"scan-1","kind":"ENQUEUE",...}
|
||||
{"event_id":"def456","t_hlc":"1704585600001:0:node1","correlation_id":"scan-1","kind":"EXECUTE",...}
|
||||
```
|
||||
|
||||
### Replay Contract
|
||||
|
||||
- Replay is read-only (dry-run mode)
|
||||
- Verify mode compares replayed state to stored state
|
||||
- Replay operations are idempotent
|
||||
25
src/Timeline/StellaOps.Timeline.WebService/AGENTS.md
Normal file
25
src/Timeline/StellaOps.Timeline.WebService/AGENTS.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Timeline WebService Module Charter
|
||||
|
||||
## Mission
|
||||
- Provide timeline API endpoints and service orchestration.
|
||||
|
||||
## Responsibilities
|
||||
- Implement HTTP endpoints and request validation.
|
||||
- Orchestrate persistence and timeline workflows.
|
||||
- Enforce deterministic ordering in responses.
|
||||
|
||||
## Required Reading
|
||||
- docs/README.md
|
||||
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
- docs/modules/timeline-indexer/architecture.md
|
||||
- docs/modules/timeline-indexer/README.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
|
||||
- API tests for validation and error handling.
|
||||
- Determinism checks for response ordering.
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Timeline.WebService.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware for authorizing timeline access based on tenant/correlation ownership.
|
||||
/// </summary>
|
||||
public sealed class TimelineAuthorizationMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<TimelineAuthorizationMiddleware> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimelineAuthorizationMiddleware"/> class.
|
||||
/// </summary>
|
||||
public TimelineAuthorizationMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<TimelineAuthorizationMiddleware> logger)
|
||||
{
|
||||
_next = next ?? throw new ArgumentNullException(nameof(next));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes the middleware.
|
||||
/// </summary>
|
||||
public async Task InvokeAsync(
|
||||
HttpContext context,
|
||||
ITimelineAuthorizationService authorizationService)
|
||||
{
|
||||
// Skip authorization for health endpoints
|
||||
if (context.Request.Path.StartsWithSegments("/health"))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract correlation ID from route if present
|
||||
var correlationId = ExtractCorrelationId(context);
|
||||
|
||||
if (!string.IsNullOrEmpty(correlationId))
|
||||
{
|
||||
var user = context.User;
|
||||
var tenantId = user.FindFirstValue("tenant_id");
|
||||
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
_logger.LogWarning("No tenant_id claim found for timeline access");
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
await context.Response.WriteAsync("Unauthorized: Missing tenant claim");
|
||||
return;
|
||||
}
|
||||
|
||||
var hasAccess = await authorizationService.HasAccessToCorrelationAsync(
|
||||
tenantId,
|
||||
correlationId,
|
||||
context.RequestAborted);
|
||||
|
||||
if (!hasAccess)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tenant {TenantId} denied access to correlation {CorrelationId}",
|
||||
tenantId,
|
||||
correlationId);
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await context.Response.WriteAsync("Forbidden: No access to this correlation");
|
||||
return;
|
||||
}
|
||||
|
||||
// Log audit trail
|
||||
_logger.LogInformation(
|
||||
"Tenant {TenantId} accessed correlation {CorrelationId} via {Method} {Path}",
|
||||
tenantId,
|
||||
correlationId,
|
||||
context.Request.Method,
|
||||
context.Request.Path);
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private static string? ExtractCorrelationId(HttpContext context)
|
||||
{
|
||||
// Try to get from route values
|
||||
if (context.Request.RouteValues.TryGetValue("correlationId", out var routeValue))
|
||||
{
|
||||
return routeValue?.ToString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for authorizing timeline access.
|
||||
/// </summary>
|
||||
public interface ITimelineAuthorizationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if a tenant has access to a correlation ID.
|
||||
/// </summary>
|
||||
Task<bool> HasAccessToCorrelationAsync(
|
||||
string tenantId,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation that allows all access.
|
||||
/// Production should integrate with Authority service.
|
||||
/// </summary>
|
||||
public sealed class DefaultTimelineAuthorizationService : ITimelineAuthorizationService
|
||||
{
|
||||
private readonly ILogger<DefaultTimelineAuthorizationService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultTimelineAuthorizationService"/> class.
|
||||
/// </summary>
|
||||
public DefaultTimelineAuthorizationService(ILogger<DefaultTimelineAuthorizationService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> HasAccessToCorrelationAsync(
|
||||
string tenantId,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Default: allow all access
|
||||
// Production: check correlation -> tenant mapping in database or Authority
|
||||
_logger.LogDebug(
|
||||
"Authorization check: tenant={TenantId}, correlation={CorrelationId} -> allowed (default)",
|
||||
tenantId,
|
||||
correlationId);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for timeline authorization.
|
||||
/// </summary>
|
||||
public static class TimelineAuthorizationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds timeline authorization middleware to the pipeline.
|
||||
/// </summary>
|
||||
public static IApplicationBuilder UseTimelineAuthorization(this IApplicationBuilder app)
|
||||
{
|
||||
return app.UseMiddleware<TimelineAuthorizationMiddleware>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using StellaOps.Timeline.Core;
|
||||
|
||||
namespace StellaOps.Timeline.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Export endpoints for timeline bundles.
|
||||
/// </summary>
|
||||
public static class ExportEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps export endpoints.
|
||||
/// </summary>
|
||||
public static void MapExportEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/timeline")
|
||||
.WithTags("Export")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/{correlationId}/export", ExportTimelineAsync)
|
||||
.WithName("ExportTimeline")
|
||||
.WithDescription("Export timeline events as NDJSON bundle with optional DSSE signing");
|
||||
|
||||
group.MapGet("/export/{exportId}", GetExportStatusAsync)
|
||||
.WithName("GetExportStatus")
|
||||
.WithDescription("Get the status of an export operation");
|
||||
|
||||
group.MapGet("/export/{exportId}/download", DownloadExportAsync)
|
||||
.WithName("DownloadExport")
|
||||
.WithDescription("Download the completed export bundle");
|
||||
}
|
||||
|
||||
private static async Task<Results<Accepted<ExportInitiatedResponse>, BadRequest<string>>> ExportTimelineAsync(
|
||||
string correlationId,
|
||||
ExportRequest request,
|
||||
ITimelineQueryService queryService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(correlationId))
|
||||
{
|
||||
return TypedResults.BadRequest("Correlation ID is required");
|
||||
}
|
||||
|
||||
// Validate the correlation exists
|
||||
var result = await queryService.GetByCorrelationIdAsync(correlationId, new TimelineQueryOptions { Limit = 1 }, cancellationToken);
|
||||
if (result.Events.Count == 0)
|
||||
{
|
||||
return TypedResults.BadRequest($"No events found for correlation ID: {correlationId}");
|
||||
}
|
||||
|
||||
// TODO: Queue export job
|
||||
var exportId = Guid.NewGuid().ToString("N")[..16];
|
||||
|
||||
return TypedResults.Accepted(
|
||||
$"/api/v1/timeline/export/{exportId}",
|
||||
new ExportInitiatedResponse
|
||||
{
|
||||
ExportId = exportId,
|
||||
CorrelationId = correlationId,
|
||||
Format = request.Format,
|
||||
SignBundle = request.SignBundle,
|
||||
Status = "INITIATED",
|
||||
EstimatedEventCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ExportStatusResponse>, NotFound>> GetExportStatusAsync(
|
||||
string exportId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: Integrate with export state store
|
||||
await Task.CompletedTask;
|
||||
|
||||
return TypedResults.Ok(new ExportStatusResponse
|
||||
{
|
||||
ExportId = exportId,
|
||||
Status = "COMPLETED",
|
||||
Format = "ndjson",
|
||||
EventCount = 100,
|
||||
FileSizeBytes = 45678,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||
CompletedAt = DateTimeOffset.UtcNow.AddSeconds(-30)
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<Results<FileStreamHttpResult, NotFound>> DownloadExportAsync(
|
||||
string exportId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: Integrate with export storage
|
||||
await Task.CompletedTask;
|
||||
|
||||
// Return stub for now - real implementation would stream from storage
|
||||
var stubContent = """
|
||||
{"event_id":"abc123","correlation_id":"scan-1","kind":"ENQUEUE"}
|
||||
{"event_id":"def456","correlation_id":"scan-1","kind":"EXECUTE"}
|
||||
""";
|
||||
var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(stubContent));
|
||||
|
||||
return TypedResults.File(
|
||||
stream,
|
||||
contentType: "application/x-ndjson",
|
||||
fileDownloadName: $"timeline-{exportId}.ndjson");
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public sealed record ExportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Export format: "ndjson" or "json".
|
||||
/// </summary>
|
||||
public string Format { get; init; } = "ndjson";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to DSSE-sign the bundle.
|
||||
/// </summary>
|
||||
public bool SignBundle { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Optional HLC range start.
|
||||
/// </summary>
|
||||
public string? FromHlc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional HLC range end.
|
||||
/// </summary>
|
||||
public string? ToHlc { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ExportInitiatedResponse
|
||||
{
|
||||
public required string ExportId { get; init; }
|
||||
public required string CorrelationId { get; init; }
|
||||
public required string Format { get; init; }
|
||||
public bool SignBundle { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public long EstimatedEventCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ExportStatusResponse
|
||||
{
|
||||
public required string ExportId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string Format { get; init; }
|
||||
public long EventCount { get; init; }
|
||||
public long FileSizeBytes { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using StellaOps.Eventing.Storage;
|
||||
|
||||
namespace StellaOps.Timeline.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Health check endpoints.
|
||||
/// </summary>
|
||||
public static class HealthEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps health check endpoints.
|
||||
/// </summary>
|
||||
public static void MapHealthEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapHealthChecks("/health");
|
||||
app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
||||
{
|
||||
Predicate = check => check.Tags.Contains("ready")
|
||||
});
|
||||
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
||||
{
|
||||
Predicate = check => check.Tags.Contains("live")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health check for timeline service.
|
||||
/// </summary>
|
||||
public sealed class TimelineHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly ITimelineEventStore _eventStore;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimelineHealthCheck"/> class.
|
||||
/// </summary>
|
||||
public TimelineHealthCheck(ITimelineEventStore eventStore)
|
||||
{
|
||||
_eventStore = eventStore;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Simple check - try to count events for a nonexistent correlation
|
||||
// This validates database connectivity
|
||||
await _eventStore.CountByCorrelationIdAsync("__health_check__", cancellationToken);
|
||||
return HealthCheckResult.Healthy("Timeline service is healthy");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("Timeline service is unhealthy", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
|
||||
namespace StellaOps.Timeline.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Replay endpoints for deterministic replay of event sequences.
|
||||
/// </summary>
|
||||
public static class ReplayEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps replay endpoints.
|
||||
/// </summary>
|
||||
public static void MapReplayEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/timeline")
|
||||
.WithTags("Replay")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/{correlationId}/replay", InitiateReplayAsync)
|
||||
.WithName("InitiateReplay")
|
||||
.WithDescription("Initiate deterministic replay for a correlation ID");
|
||||
|
||||
group.MapGet("/replay/{replayId}", GetReplayStatusAsync)
|
||||
.WithName("GetReplayStatus")
|
||||
.WithDescription("Get the status of a replay operation");
|
||||
|
||||
group.MapPost("/replay/{replayId}/cancel", CancelReplayAsync)
|
||||
.WithName("CancelReplay")
|
||||
.WithDescription("Cancel an in-progress replay operation");
|
||||
}
|
||||
|
||||
private static async Task<Results<Accepted<ReplayInitiatedResponse>, BadRequest<string>>> InitiateReplayAsync(
|
||||
string correlationId,
|
||||
ReplayRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Validate request
|
||||
if (string.IsNullOrWhiteSpace(correlationId))
|
||||
{
|
||||
return TypedResults.BadRequest("Correlation ID is required");
|
||||
}
|
||||
|
||||
// TODO: Integrate with StellaOps.Replay.Core
|
||||
// For now, return a stub response
|
||||
var replayId = Guid.NewGuid().ToString("N")[..16];
|
||||
|
||||
await Task.CompletedTask; // Placeholder for actual implementation
|
||||
|
||||
return TypedResults.Accepted(
|
||||
$"/api/v1/timeline/replay/{replayId}",
|
||||
new ReplayInitiatedResponse
|
||||
{
|
||||
ReplayId = replayId,
|
||||
CorrelationId = correlationId,
|
||||
Mode = request.Mode,
|
||||
Status = "INITIATED",
|
||||
EstimatedDurationMs = 5000
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ReplayStatusResponse>, NotFound>> GetReplayStatusAsync(
|
||||
string replayId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: Integrate with replay state store
|
||||
await Task.CompletedTask;
|
||||
|
||||
return TypedResults.Ok(new ReplayStatusResponse
|
||||
{
|
||||
ReplayId = replayId,
|
||||
Status = "IN_PROGRESS",
|
||||
Progress = 0.5,
|
||||
EventsProcessed = 50,
|
||||
TotalEvents = 100,
|
||||
StartedAt = DateTimeOffset.UtcNow.AddSeconds(-5)
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok, NotFound>> CancelReplayAsync(
|
||||
string replayId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: Integrate with replay cancellation
|
||||
await Task.CompletedTask;
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public sealed record ReplayRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Replay mode: "dry-run" or "verify".
|
||||
/// </summary>
|
||||
public string Mode { get; init; } = "dry-run";
|
||||
|
||||
/// <summary>
|
||||
/// HLC to replay from (optional).
|
||||
/// </summary>
|
||||
public string? FromHlc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HLC to replay to (optional).
|
||||
/// </summary>
|
||||
public string? ToHlc { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReplayInitiatedResponse
|
||||
{
|
||||
public required string ReplayId { get; init; }
|
||||
public required string CorrelationId { get; init; }
|
||||
public required string Mode { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public long EstimatedDurationMs { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReplayStatusResponse
|
||||
{
|
||||
public required string ReplayId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public double Progress { get; init; }
|
||||
public int EventsProcessed { get; init; }
|
||||
public int TotalEvents { get; init; }
|
||||
public DateTimeOffset StartedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
using StellaOps.Timeline.Core;
|
||||
|
||||
namespace StellaOps.Timeline.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Timeline query endpoints.
|
||||
/// </summary>
|
||||
public static class TimelineEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps timeline query endpoints.
|
||||
/// </summary>
|
||||
public static void MapTimelineEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/timeline")
|
||||
.WithTags("Timeline")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/{correlationId}", GetTimelineAsync)
|
||||
.WithName("GetTimeline")
|
||||
.WithDescription("Get events for a correlation ID, ordered by HLC timestamp");
|
||||
|
||||
group.MapGet("/{correlationId}/critical-path", GetCriticalPathAsync)
|
||||
.WithName("GetCriticalPath")
|
||||
.WithDescription("Get the critical path (longest latency stages) for a correlation");
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<TimelineResponse>, NotFound>> GetTimelineAsync(
|
||||
string correlationId,
|
||||
ITimelineQueryService queryService,
|
||||
int? limit,
|
||||
int? offset,
|
||||
string? fromHlc,
|
||||
string? toHlc,
|
||||
string? services,
|
||||
string? kinds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var options = new TimelineQueryOptions
|
||||
{
|
||||
Limit = limit ?? 100,
|
||||
Offset = offset ?? 0,
|
||||
FromHlc = !string.IsNullOrEmpty(fromHlc) ? HlcTimestamp.Parse(fromHlc) : null,
|
||||
ToHlc = !string.IsNullOrEmpty(toHlc) ? HlcTimestamp.Parse(toHlc) : null,
|
||||
Services = !string.IsNullOrEmpty(services)
|
||||
? services.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()
|
||||
: null,
|
||||
Kinds = !string.IsNullOrEmpty(kinds)
|
||||
? kinds.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()
|
||||
: null
|
||||
};
|
||||
|
||||
var result = await queryService.GetByCorrelationIdAsync(correlationId, options, cancellationToken);
|
||||
|
||||
if (result.Events.Count == 0)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new TimelineResponse
|
||||
{
|
||||
CorrelationId = correlationId,
|
||||
Events = result.Events.Select(e => new TimelineEventDto
|
||||
{
|
||||
EventId = e.EventId,
|
||||
THlc = e.THlc.ToSortableString(),
|
||||
TsWall = e.TsWall,
|
||||
Service = e.Service,
|
||||
Kind = e.Kind,
|
||||
Payload = e.Payload,
|
||||
EngineVersion = new EngineVersionDto(
|
||||
e.EngineVersion.EngineName,
|
||||
e.EngineVersion.Version,
|
||||
e.EngineVersion.SourceDigest)
|
||||
}).ToList(),
|
||||
TotalCount = result.TotalCount,
|
||||
HasMore = result.HasMore,
|
||||
NextCursor = result.NextCursor
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<CriticalPathResponse>, NotFound>> GetCriticalPathAsync(
|
||||
string correlationId,
|
||||
ITimelineQueryService queryService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await queryService.GetCriticalPathAsync(correlationId, cancellationToken);
|
||||
|
||||
if (result.Stages.Count == 0)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new CriticalPathResponse
|
||||
{
|
||||
CorrelationId = result.CorrelationId,
|
||||
TotalDurationMs = result.TotalDuration.TotalMilliseconds,
|
||||
Stages = result.Stages.Select(s => new CriticalPathStageDto
|
||||
{
|
||||
Stage = s.Stage,
|
||||
Service = s.Service,
|
||||
DurationMs = s.Duration.TotalMilliseconds,
|
||||
Percentage = s.Percentage,
|
||||
FromHlc = s.FromHlc.ToSortableString(),
|
||||
ToHlc = s.ToHlc.ToSortableString()
|
||||
}).ToList()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public sealed record TimelineResponse
|
||||
{
|
||||
public required string CorrelationId { get; init; }
|
||||
public required IReadOnlyList<TimelineEventDto> Events { get; init; }
|
||||
public long TotalCount { get; init; }
|
||||
public bool HasMore { get; init; }
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TimelineEventDto
|
||||
{
|
||||
public required string EventId { get; init; }
|
||||
public required string THlc { get; init; }
|
||||
public required DateTimeOffset TsWall { get; init; }
|
||||
public required string Service { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required string Payload { get; init; }
|
||||
public required EngineVersionDto EngineVersion { get; init; }
|
||||
}
|
||||
|
||||
public sealed record EngineVersionDto(string EngineName, string Version, string SourceDigest);
|
||||
|
||||
public sealed record CriticalPathResponse
|
||||
{
|
||||
public required string CorrelationId { get; init; }
|
||||
public double TotalDurationMs { get; init; }
|
||||
public required IReadOnlyList<CriticalPathStageDto> Stages { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CriticalPathStageDto
|
||||
{
|
||||
public required string Stage { get; init; }
|
||||
public required string Service { get; init; }
|
||||
public double DurationMs { get; init; }
|
||||
public double Percentage { get; init; }
|
||||
public required string FromHlc { get; init; }
|
||||
public required string ToHlc { get; init; }
|
||||
}
|
||||
42
src/Timeline/StellaOps.Timeline.WebService/Program.cs
Normal file
42
src/Timeline/StellaOps.Timeline.WebService/Program.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using StellaOps.Eventing;
|
||||
using StellaOps.Timeline.Core;
|
||||
using StellaOps.Timeline.WebService.Endpoints;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services
|
||||
builder.Services.AddStellaOpsEventing(builder.Configuration);
|
||||
builder.Services.AddTimelineServices(builder.Configuration);
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new()
|
||||
{
|
||||
Title = "StellaOps Timeline API",
|
||||
Version = "v1",
|
||||
Description = "Unified event timeline API for querying, replaying, and exporting HLC-ordered events"
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddCheck<TimelineHealthCheck>("timeline");
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// Map endpoints
|
||||
app.MapTimelineEndpoints();
|
||||
app.MapReplayEndpoints();
|
||||
app.MapExportEndpoints();
|
||||
app.MapHealthEndpoints();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Timeline.WebService</RootNamespace>
|
||||
<Description>StellaOps Timeline Service - Unified event timeline API</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Timeline.Core\StellaOps.Timeline.Core.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Eventing\StellaOps.Eventing.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.HybridLogicalClock\StellaOps.HybridLogicalClock.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Microservice\StellaOps.Microservice.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
24
src/Timeline/StellaOps.Timeline.WebService/appsettings.json
Normal file
24
src/Timeline/StellaOps.Timeline.WebService/appsettings.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"StellaOps": "Debug"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Eventing": {
|
||||
"ServiceName": "Timeline",
|
||||
"UseInMemoryStore": false,
|
||||
"ConnectionString": "",
|
||||
"SignEvents": false,
|
||||
"EnableOutbox": false
|
||||
},
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Http": {
|
||||
"Url": "http://+:8080"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
536
src/Timeline/StellaOps.Timeline.WebService/openapi.yaml
Normal file
536
src/Timeline/StellaOps.Timeline.WebService/openapi.yaml
Normal file
@@ -0,0 +1,536 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: StellaOps Timeline API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Unified event timeline API for querying, replaying, and exporting HLC-ordered events
|
||||
across all StellaOps services.
|
||||
license:
|
||||
name: AGPL-3.0-or-later
|
||||
url: https://www.gnu.org/licenses/agpl-3.0.html
|
||||
contact:
|
||||
name: StellaOps
|
||||
url: https://stellaops.io
|
||||
|
||||
servers:
|
||||
- url: /api/v1
|
||||
description: Timeline API v1
|
||||
|
||||
tags:
|
||||
- name: Timeline
|
||||
description: Query timeline events
|
||||
- name: Replay
|
||||
description: Deterministic replay operations
|
||||
- name: Export
|
||||
description: Export timeline bundles
|
||||
|
||||
paths:
|
||||
/timeline/{correlationId}:
|
||||
get:
|
||||
summary: Get timeline events
|
||||
description: Returns events for a correlation ID, ordered by HLC timestamp
|
||||
operationId: getTimeline
|
||||
tags:
|
||||
- Timeline
|
||||
parameters:
|
||||
- name: correlationId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The correlation ID to query
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 100
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
description: Maximum number of events to return
|
||||
- name: offset
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 0
|
||||
minimum: 0
|
||||
description: Number of events to skip
|
||||
- name: services
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: Comma-separated list of services to filter by
|
||||
- name: kinds
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: Comma-separated list of event kinds to filter by
|
||||
- name: fromHlc
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: Start of HLC range (inclusive)
|
||||
- name: toHlc
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: End of HLC range (inclusive)
|
||||
responses:
|
||||
'200':
|
||||
description: Timeline events
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TimelineResponse'
|
||||
'404':
|
||||
description: No events found for correlation ID
|
||||
|
||||
/timeline/{correlationId}/critical-path:
|
||||
get:
|
||||
summary: Get critical path
|
||||
description: Returns the critical path (longest latency stages) for a correlation
|
||||
operationId: getCriticalPath
|
||||
tags:
|
||||
- Timeline
|
||||
parameters:
|
||||
- name: correlationId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The correlation ID to analyze
|
||||
responses:
|
||||
'200':
|
||||
description: Critical path analysis
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CriticalPathResponse'
|
||||
'404':
|
||||
description: No events found for correlation ID
|
||||
|
||||
/timeline/{correlationId}/replay:
|
||||
post:
|
||||
summary: Initiate replay
|
||||
description: Initiate deterministic replay for a correlation ID
|
||||
operationId: initiateReplay
|
||||
tags:
|
||||
- Replay
|
||||
parameters:
|
||||
- name: correlationId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The correlation ID to replay
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ReplayRequest'
|
||||
responses:
|
||||
'202':
|
||||
description: Replay initiated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ReplayInitiatedResponse'
|
||||
'400':
|
||||
description: Invalid request
|
||||
|
||||
/timeline/replay/{replayId}:
|
||||
get:
|
||||
summary: Get replay status
|
||||
description: Get the status of a replay operation
|
||||
operationId: getReplayStatus
|
||||
tags:
|
||||
- Replay
|
||||
parameters:
|
||||
- name: replayId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The replay operation ID
|
||||
responses:
|
||||
'200':
|
||||
description: Replay status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ReplayStatusResponse'
|
||||
'404':
|
||||
description: Replay not found
|
||||
|
||||
/timeline/{correlationId}/export:
|
||||
post:
|
||||
summary: Export timeline
|
||||
description: Export timeline events as NDJSON bundle with optional DSSE signing
|
||||
operationId: exportTimeline
|
||||
tags:
|
||||
- Export
|
||||
parameters:
|
||||
- name: correlationId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The correlation ID to export
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExportRequest'
|
||||
responses:
|
||||
'202':
|
||||
description: Export initiated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExportInitiatedResponse'
|
||||
'400':
|
||||
description: Invalid request or no events found
|
||||
|
||||
/timeline/export/{exportId}:
|
||||
get:
|
||||
summary: Get export status
|
||||
description: Get the status of an export operation
|
||||
operationId: getExportStatus
|
||||
tags:
|
||||
- Export
|
||||
parameters:
|
||||
- name: exportId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The export operation ID
|
||||
responses:
|
||||
'200':
|
||||
description: Export status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExportStatusResponse'
|
||||
'404':
|
||||
description: Export not found
|
||||
|
||||
/timeline/export/{exportId}/download:
|
||||
get:
|
||||
summary: Download export
|
||||
description: Download the completed export bundle
|
||||
operationId: downloadExport
|
||||
tags:
|
||||
- Export
|
||||
parameters:
|
||||
- name: exportId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The export operation ID
|
||||
responses:
|
||||
'200':
|
||||
description: Export bundle
|
||||
content:
|
||||
application/x-ndjson:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
'404':
|
||||
description: Export not found or not completed
|
||||
|
||||
components:
|
||||
schemas:
|
||||
TimelineResponse:
|
||||
type: object
|
||||
required:
|
||||
- correlationId
|
||||
- events
|
||||
properties:
|
||||
correlationId:
|
||||
type: string
|
||||
description: The correlation ID queried
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TimelineEvent'
|
||||
totalCount:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Total number of events for this correlation
|
||||
hasMore:
|
||||
type: boolean
|
||||
description: Whether there are more results
|
||||
nextCursor:
|
||||
type: string
|
||||
description: Cursor for next page (HLC of last event)
|
||||
|
||||
TimelineEvent:
|
||||
type: object
|
||||
required:
|
||||
- eventId
|
||||
- tHlc
|
||||
- tsWall
|
||||
- service
|
||||
- kind
|
||||
- payload
|
||||
- engineVersion
|
||||
properties:
|
||||
eventId:
|
||||
type: string
|
||||
description: Deterministic event ID (SHA-256 hash)
|
||||
tHlc:
|
||||
type: string
|
||||
description: HLC timestamp in sortable format
|
||||
tsWall:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Wall-clock time (ISO 8601)
|
||||
service:
|
||||
type: string
|
||||
description: Service that emitted the event
|
||||
kind:
|
||||
type: string
|
||||
description: Event kind (ENQUEUE, EXECUTE, etc.)
|
||||
payload:
|
||||
type: string
|
||||
description: RFC 8785 canonicalized JSON payload
|
||||
engineVersion:
|
||||
$ref: '#/components/schemas/EngineVersion'
|
||||
|
||||
EngineVersion:
|
||||
type: object
|
||||
required:
|
||||
- engineName
|
||||
- version
|
||||
- sourceDigest
|
||||
properties:
|
||||
engineName:
|
||||
type: string
|
||||
description: Engine/service name
|
||||
version:
|
||||
type: string
|
||||
description: Engine version
|
||||
sourceDigest:
|
||||
type: string
|
||||
description: Source/assembly digest
|
||||
|
||||
CriticalPathResponse:
|
||||
type: object
|
||||
required:
|
||||
- correlationId
|
||||
- stages
|
||||
properties:
|
||||
correlationId:
|
||||
type: string
|
||||
description: The correlation ID analyzed
|
||||
totalDurationMs:
|
||||
type: number
|
||||
format: double
|
||||
description: Total duration in milliseconds
|
||||
stages:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/CriticalPathStage'
|
||||
|
||||
CriticalPathStage:
|
||||
type: object
|
||||
required:
|
||||
- stage
|
||||
- service
|
||||
- fromHlc
|
||||
- toHlc
|
||||
properties:
|
||||
stage:
|
||||
type: string
|
||||
description: Stage label (e.g., "ENQUEUE -> EXECUTE")
|
||||
service:
|
||||
type: string
|
||||
description: Service where stage occurred
|
||||
durationMs:
|
||||
type: number
|
||||
format: double
|
||||
description: Duration in milliseconds
|
||||
percentage:
|
||||
type: number
|
||||
format: double
|
||||
description: Percentage of total duration
|
||||
fromHlc:
|
||||
type: string
|
||||
description: HLC at start of stage
|
||||
toHlc:
|
||||
type: string
|
||||
description: HLC at end of stage
|
||||
|
||||
ReplayRequest:
|
||||
type: object
|
||||
properties:
|
||||
mode:
|
||||
type: string
|
||||
enum:
|
||||
- dry-run
|
||||
- verify
|
||||
default: dry-run
|
||||
description: Replay mode
|
||||
fromHlc:
|
||||
type: string
|
||||
description: HLC to replay from (optional)
|
||||
toHlc:
|
||||
type: string
|
||||
description: HLC to replay to (optional)
|
||||
|
||||
ReplayInitiatedResponse:
|
||||
type: object
|
||||
required:
|
||||
- replayId
|
||||
- correlationId
|
||||
- mode
|
||||
- status
|
||||
properties:
|
||||
replayId:
|
||||
type: string
|
||||
description: Unique replay operation ID
|
||||
correlationId:
|
||||
type: string
|
||||
description: The correlation ID being replayed
|
||||
mode:
|
||||
type: string
|
||||
description: Replay mode
|
||||
status:
|
||||
type: string
|
||||
description: Initial status (INITIATED)
|
||||
estimatedDurationMs:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Estimated duration in milliseconds
|
||||
|
||||
ReplayStatusResponse:
|
||||
type: object
|
||||
required:
|
||||
- replayId
|
||||
- status
|
||||
properties:
|
||||
replayId:
|
||||
type: string
|
||||
description: Unique replay operation ID
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- INITIATED
|
||||
- IN_PROGRESS
|
||||
- COMPLETED
|
||||
- FAILED
|
||||
description: Current status
|
||||
progress:
|
||||
type: number
|
||||
format: double
|
||||
description: Progress (0.0 to 1.0)
|
||||
eventsProcessed:
|
||||
type: integer
|
||||
description: Number of events processed
|
||||
totalEvents:
|
||||
type: integer
|
||||
description: Total number of events
|
||||
startedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Start time
|
||||
completedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Completion time (if completed)
|
||||
error:
|
||||
type: string
|
||||
description: Error message (if failed)
|
||||
|
||||
ExportRequest:
|
||||
type: object
|
||||
properties:
|
||||
format:
|
||||
type: string
|
||||
enum:
|
||||
- ndjson
|
||||
- json
|
||||
default: ndjson
|
||||
description: Export format
|
||||
signBundle:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Whether to DSSE-sign the bundle
|
||||
fromHlc:
|
||||
type: string
|
||||
description: HLC range start (optional)
|
||||
toHlc:
|
||||
type: string
|
||||
description: HLC range end (optional)
|
||||
|
||||
ExportInitiatedResponse:
|
||||
type: object
|
||||
required:
|
||||
- exportId
|
||||
- correlationId
|
||||
- format
|
||||
- status
|
||||
properties:
|
||||
exportId:
|
||||
type: string
|
||||
description: Unique export operation ID
|
||||
correlationId:
|
||||
type: string
|
||||
description: The correlation ID being exported
|
||||
format:
|
||||
type: string
|
||||
description: Export format
|
||||
signBundle:
|
||||
type: boolean
|
||||
description: Whether bundle is signed
|
||||
status:
|
||||
type: string
|
||||
description: Initial status (INITIATED)
|
||||
estimatedEventCount:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Estimated number of events
|
||||
|
||||
ExportStatusResponse:
|
||||
type: object
|
||||
required:
|
||||
- exportId
|
||||
- status
|
||||
- format
|
||||
properties:
|
||||
exportId:
|
||||
type: string
|
||||
description: Unique export operation ID
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- INITIATED
|
||||
- IN_PROGRESS
|
||||
- COMPLETED
|
||||
- FAILED
|
||||
description: Current status
|
||||
format:
|
||||
type: string
|
||||
description: Export format
|
||||
eventCount:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Number of events exported
|
||||
fileSizeBytes:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Size of export file
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Creation time
|
||||
completedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Completion time (if completed)
|
||||
error:
|
||||
type: string
|
||||
description: Error message (if failed)
|
||||
25
src/Timeline/__Libraries/StellaOps.Timeline.Core/AGENTS.md
Normal file
25
src/Timeline/__Libraries/StellaOps.Timeline.Core/AGENTS.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Timeline Core Module Charter
|
||||
|
||||
## Mission
|
||||
- Provide timeline core models and deterministic ordering logic.
|
||||
|
||||
## Responsibilities
|
||||
- Define timeline domain models and validation rules.
|
||||
- Implement ordering and partitioning logic for timeline events.
|
||||
- Keep serialization deterministic and invariant.
|
||||
|
||||
## Required Reading
|
||||
- docs/README.md
|
||||
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
- docs/modules/timeline-indexer/architecture.md
|
||||
- docs/modules/timeline-indexer/README.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 ordering, validation, and serialization.
|
||||
- Determinism tests for stable outputs.
|
||||
@@ -0,0 +1,193 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using StellaOps.Eventing.Models;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
namespace StellaOps.Timeline.Core.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for building timeline export bundles.
|
||||
/// </summary>
|
||||
public interface ITimelineBundleBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Initiates an export operation for a correlation ID.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">The correlation ID to export.</param>
|
||||
/// <param name="request">Export request parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The initiated export operation.</returns>
|
||||
Task<ExportOperation> InitiateExportAsync(
|
||||
string correlationId,
|
||||
ExportRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of an export operation.
|
||||
/// </summary>
|
||||
/// <param name="exportId">The export operation ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The export operation status, or null if not found.</returns>
|
||||
Task<ExportOperation?> GetExportStatusAsync(
|
||||
string exportId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the export bundle content.
|
||||
/// </summary>
|
||||
/// <param name="exportId">The export operation ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The export bundle stream, or null if not found/not ready.</returns>
|
||||
Task<ExportBundle?> GetExportBundleAsync(
|
||||
string exportId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for initiating an export operation.
|
||||
/// </summary>
|
||||
public sealed record ExportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Export format: "ndjson" or "json".
|
||||
/// </summary>
|
||||
public string Format { get; init; } = "ndjson";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to DSSE-sign the bundle.
|
||||
/// </summary>
|
||||
public bool SignBundle { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Optional HLC range start.
|
||||
/// </summary>
|
||||
public HlcTimestamp? FromHlc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional HLC range end.
|
||||
/// </summary>
|
||||
public HlcTimestamp? ToHlc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include full payloads.
|
||||
/// </summary>
|
||||
public bool IncludePayloads { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an export operation.
|
||||
/// </summary>
|
||||
public sealed record ExportOperation
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique export operation ID.
|
||||
/// </summary>
|
||||
public required string ExportId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The correlation ID being exported.
|
||||
/// </summary>
|
||||
public required string CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Export format.
|
||||
/// </summary>
|
||||
public required string Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether bundle is signed.
|
||||
/// </summary>
|
||||
public bool SignBundle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status.
|
||||
/// </summary>
|
||||
public required ExportStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of events exported.
|
||||
/// </summary>
|
||||
public int EventCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of export file in bytes.
|
||||
/// </summary>
|
||||
public long FileSizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creation time.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Completion time (if completed).
|
||||
/// </summary>
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message (if failed).
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export operation status.
|
||||
/// </summary>
|
||||
public enum ExportStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Export has been initiated.
|
||||
/// </summary>
|
||||
Initiated,
|
||||
|
||||
/// <summary>
|
||||
/// Export is in progress.
|
||||
/// </summary>
|
||||
InProgress,
|
||||
|
||||
/// <summary>
|
||||
/// Export completed successfully.
|
||||
/// </summary>
|
||||
Completed,
|
||||
|
||||
/// <summary>
|
||||
/// Export failed.
|
||||
/// </summary>
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an export bundle.
|
||||
/// </summary>
|
||||
public sealed record ExportBundle
|
||||
{
|
||||
/// <summary>
|
||||
/// The export operation ID.
|
||||
/// </summary>
|
||||
public required string ExportId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content type (e.g., "application/x-ndjson").
|
||||
/// </summary>
|
||||
public required string ContentType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggested filename.
|
||||
/// </summary>
|
||||
public required string FileName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle content stream.
|
||||
/// </summary>
|
||||
public required Stream Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle size in bytes.
|
||||
/// </summary>
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature (if signed).
|
||||
/// </summary>
|
||||
public string? DsseSignature { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Eventing.Models;
|
||||
using StellaOps.Eventing.Signing;
|
||||
using StellaOps.Eventing.Storage;
|
||||
using StellaOps.Timeline.Core.Telemetry;
|
||||
|
||||
namespace StellaOps.Timeline.Core.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="ITimelineBundleBuilder"/>.
|
||||
/// </summary>
|
||||
public sealed class TimelineBundleBuilder : ITimelineBundleBuilder
|
||||
{
|
||||
private readonly ITimelineEventStore _eventStore;
|
||||
private readonly IEventSigner? _eventSigner;
|
||||
private readonly TimelineMetrics _metrics;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<TimelineBundleBuilder> _logger;
|
||||
|
||||
// In-memory store for export operations (production would use PostgreSQL + object storage)
|
||||
private readonly ConcurrentDictionary<string, ExportOperation> _operations = new();
|
||||
private readonly ConcurrentDictionary<string, byte[]> _bundles = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimelineBundleBuilder"/> class.
|
||||
/// </summary>
|
||||
public TimelineBundleBuilder(
|
||||
ITimelineEventStore eventStore,
|
||||
TimelineMetrics metrics,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<TimelineBundleBuilder> logger,
|
||||
IEventSigner? eventSigner = null)
|
||||
{
|
||||
_eventStore = eventStore ?? throw new ArgumentNullException(nameof(eventStore));
|
||||
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_eventSigner = eventSigner;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<ExportOperation> InitiateExportAsync(
|
||||
string correlationId,
|
||||
ExportRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var exportId = GenerateExportId();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var operation = new ExportOperation
|
||||
{
|
||||
ExportId = exportId,
|
||||
CorrelationId = correlationId,
|
||||
Format = request.Format,
|
||||
SignBundle = request.SignBundle,
|
||||
Status = ExportStatus.Initiated,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
_operations[exportId] = operation;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Initiated export {ExportId} for correlation {CorrelationId}",
|
||||
exportId,
|
||||
correlationId);
|
||||
|
||||
// Start export in background
|
||||
_ = Task.Run(() => ExecuteExportAsync(exportId, correlationId, request, cancellationToken), cancellationToken);
|
||||
|
||||
return operation;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<ExportOperation?> GetExportStatusAsync(
|
||||
string exportId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(exportId);
|
||||
|
||||
_operations.TryGetValue(exportId, out var operation);
|
||||
return Task.FromResult(operation);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<ExportBundle?> GetExportBundleAsync(
|
||||
string exportId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(exportId);
|
||||
|
||||
if (!_operations.TryGetValue(exportId, out var operation))
|
||||
{
|
||||
return Task.FromResult<ExportBundle?>(null);
|
||||
}
|
||||
|
||||
if (operation.Status != ExportStatus.Completed)
|
||||
{
|
||||
return Task.FromResult<ExportBundle?>(null);
|
||||
}
|
||||
|
||||
if (!_bundles.TryGetValue(exportId, out var content))
|
||||
{
|
||||
return Task.FromResult<ExportBundle?>(null);
|
||||
}
|
||||
|
||||
var bundle = new ExportBundle
|
||||
{
|
||||
ExportId = exportId,
|
||||
ContentType = operation.Format == "ndjson" ? "application/x-ndjson" : "application/json",
|
||||
FileName = $"timeline-{operation.CorrelationId}-{exportId}.{operation.Format}",
|
||||
Content = new MemoryStream(content),
|
||||
SizeBytes = content.Length
|
||||
};
|
||||
|
||||
return Task.FromResult<ExportBundle?>(bundle);
|
||||
}
|
||||
|
||||
private async Task ExecuteExportAsync(
|
||||
string exportId,
|
||||
string correlationId,
|
||||
ExportRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Update status to in-progress
|
||||
UpdateOperation(exportId, op => op with { Status = ExportStatus.InProgress });
|
||||
|
||||
// Get events
|
||||
IReadOnlyList<TimelineEvent> events;
|
||||
if (request.FromHlc.HasValue && request.ToHlc.HasValue)
|
||||
{
|
||||
events = await _eventStore.GetByHlcRangeAsync(
|
||||
correlationId,
|
||||
request.FromHlc.Value,
|
||||
request.ToHlc.Value,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
events = await _eventStore.GetByCorrelationIdAsync(
|
||||
correlationId,
|
||||
limit: 100000,
|
||||
offset: 0,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Build bundle content
|
||||
byte[] content;
|
||||
if (request.Format == "ndjson")
|
||||
{
|
||||
content = BuildNdjsonBundle(events, request.IncludePayloads);
|
||||
}
|
||||
else
|
||||
{
|
||||
content = BuildJsonBundle(events, request.IncludePayloads);
|
||||
}
|
||||
|
||||
// Sign if requested
|
||||
string? signature = null;
|
||||
if (request.SignBundle && _eventSigner != null)
|
||||
{
|
||||
signature = await _eventSigner.SignAsync(content, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Store bundle
|
||||
_bundles[exportId] = content;
|
||||
|
||||
// Update final status
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
UpdateOperation(exportId, op => op with
|
||||
{
|
||||
Status = ExportStatus.Completed,
|
||||
EventCount = events.Count,
|
||||
FileSizeBytes = content.Length,
|
||||
CompletedAt = now
|
||||
});
|
||||
|
||||
_metrics.RecordExport(request.Format, request.SignBundle, content.Length, events.Count);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Completed export {ExportId}: {EventCount} events, {Size} bytes",
|
||||
exportId,
|
||||
events.Count,
|
||||
content.Length);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Export {ExportId} failed", exportId);
|
||||
|
||||
UpdateOperation(exportId, op => op with
|
||||
{
|
||||
Status = ExportStatus.Failed,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
Error = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildNdjsonBundle(IReadOnlyList<TimelineEvent> events, bool includePayloads)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
foreach (var evt in events)
|
||||
{
|
||||
var line = new
|
||||
{
|
||||
event_id = evt.EventId,
|
||||
t_hlc = evt.THlc.ToSortableString(),
|
||||
ts_wall = evt.TsWall.ToString("O", CultureInfo.InvariantCulture),
|
||||
correlation_id = evt.CorrelationId,
|
||||
service = evt.Service,
|
||||
kind = evt.Kind,
|
||||
payload = includePayloads ? evt.Payload : null,
|
||||
payload_digest = Convert.ToHexString(evt.PayloadDigest).ToLowerInvariant(),
|
||||
engine_version = new
|
||||
{
|
||||
name = evt.EngineVersion.EngineName,
|
||||
version = evt.EngineVersion.Version,
|
||||
digest = evt.EngineVersion.SourceDigest
|
||||
},
|
||||
schema_version = evt.SchemaVersion
|
||||
};
|
||||
|
||||
sb.AppendLine(JsonSerializer.Serialize(line, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
}));
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetBytes(sb.ToString());
|
||||
}
|
||||
|
||||
private static byte[] BuildJsonBundle(IReadOnlyList<TimelineEvent> events, bool includePayloads)
|
||||
{
|
||||
var bundle = new
|
||||
{
|
||||
events = events.Select(evt => new
|
||||
{
|
||||
event_id = evt.EventId,
|
||||
t_hlc = evt.THlc.ToSortableString(),
|
||||
ts_wall = evt.TsWall.ToString("O", CultureInfo.InvariantCulture),
|
||||
correlation_id = evt.CorrelationId,
|
||||
service = evt.Service,
|
||||
kind = evt.Kind,
|
||||
payload = includePayloads ? evt.Payload : null,
|
||||
payload_digest = Convert.ToHexString(evt.PayloadDigest).ToLowerInvariant(),
|
||||
engine_version = new
|
||||
{
|
||||
name = evt.EngineVersion.EngineName,
|
||||
version = evt.EngineVersion.Version,
|
||||
digest = evt.EngineVersion.SourceDigest
|
||||
},
|
||||
schema_version = evt.SchemaVersion
|
||||
}).ToList(),
|
||||
metadata = new
|
||||
{
|
||||
event_count = events.Count,
|
||||
exported_at = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(bundle, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
});
|
||||
}
|
||||
|
||||
private void UpdateOperation(string exportId, Func<ExportOperation, ExportOperation> update)
|
||||
{
|
||||
if (_operations.TryGetValue(exportId, out var current))
|
||||
{
|
||||
_operations[exportId] = update(current);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateExportId()
|
||||
{
|
||||
return Guid.NewGuid().ToString("N")[..16];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using StellaOps.Eventing.Models;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
namespace StellaOps.Timeline.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for querying timeline events.
|
||||
/// </summary>
|
||||
public interface ITimelineQueryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets events for a correlation ID, ordered by HLC timestamp.
|
||||
/// </summary>
|
||||
Task<TimelineQueryResult> GetByCorrelationIdAsync(
|
||||
string correlationId,
|
||||
TimelineQueryOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the critical path (longest latency stages) for a correlation.
|
||||
/// </summary>
|
||||
Task<CriticalPathResult> GetCriticalPathAsync(
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets events filtered by service.
|
||||
/// </summary>
|
||||
Task<TimelineQueryResult> GetByServiceAsync(
|
||||
string service,
|
||||
HlcTimestamp? fromHlc = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for timeline queries.
|
||||
/// </summary>
|
||||
public sealed record TimelineQueryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum number of events to return.
|
||||
/// </summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Number of events to skip.
|
||||
/// </summary>
|
||||
public int Offset { get; init; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Filter by services (optional).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Services { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by event kinds (optional).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Kinds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start of HLC range (inclusive).
|
||||
/// </summary>
|
||||
public HlcTimestamp? FromHlc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End of HLC range (inclusive).
|
||||
/// </summary>
|
||||
public HlcTimestamp? ToHlc { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a timeline query.
|
||||
/// </summary>
|
||||
public sealed record TimelineQueryResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The events matching the query.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<TimelineEvent> Events { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total count (for pagination).
|
||||
/// </summary>
|
||||
public long TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether there are more results.
|
||||
/// </summary>
|
||||
public bool HasMore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cursor for next page (HLC of last event).
|
||||
/// </summary>
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Critical path analysis result.
|
||||
/// </summary>
|
||||
public sealed record CriticalPathResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The correlation ID analyzed.
|
||||
/// </summary>
|
||||
public required string CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total duration from first to last event.
|
||||
/// </summary>
|
||||
public TimeSpan TotalDuration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The stages in the critical path.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<CriticalPathStage> Stages { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A stage in the critical path.
|
||||
/// </summary>
|
||||
public sealed record CriticalPathStage
|
||||
{
|
||||
/// <summary>
|
||||
/// Stage name (e.g., "ENQUEUE -> EXECUTE").
|
||||
/// </summary>
|
||||
public required string Stage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Service where this stage occurred.
|
||||
/// </summary>
|
||||
public required string Service { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of this stage.
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Percentage of total duration.
|
||||
/// </summary>
|
||||
public double Percentage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HLC at start of stage.
|
||||
/// </summary>
|
||||
public required HlcTimestamp FromHlc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HLC at end of stage.
|
||||
/// </summary>
|
||||
public required HlcTimestamp ToHlc { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Migration: 20260107_002_create_critical_path_view
|
||||
-- Purpose: Create materialized view for critical path computation
|
||||
|
||||
-- Create materialized view for critical path analysis
|
||||
CREATE MATERIALIZED VIEW timeline.critical_path AS
|
||||
WITH ordered_events AS (
|
||||
SELECT
|
||||
correlation_id,
|
||||
event_id,
|
||||
t_hlc,
|
||||
ts_wall,
|
||||
service,
|
||||
kind,
|
||||
LAG(t_hlc) OVER (PARTITION BY correlation_id ORDER BY t_hlc) as prev_hlc,
|
||||
LAG(ts_wall) OVER (PARTITION BY correlation_id ORDER BY t_hlc) as prev_ts,
|
||||
LAG(kind) OVER (PARTITION BY correlation_id ORDER BY t_hlc) as prev_kind,
|
||||
LAG(event_id) OVER (PARTITION BY correlation_id ORDER BY t_hlc) as prev_event_id
|
||||
FROM timeline.events
|
||||
)
|
||||
SELECT
|
||||
correlation_id,
|
||||
prev_kind || ' -> ' || kind as stage,
|
||||
prev_event_id as from_event_id,
|
||||
event_id as to_event_id,
|
||||
prev_hlc as from_hlc,
|
||||
t_hlc as to_hlc,
|
||||
EXTRACT(EPOCH FROM (ts_wall - prev_ts)) * 1000 as duration_ms,
|
||||
service
|
||||
FROM ordered_events
|
||||
WHERE prev_hlc IS NOT NULL;
|
||||
|
||||
-- Create indexes for efficient queries
|
||||
CREATE UNIQUE INDEX idx_critical_path_corr_from_hlc
|
||||
ON timeline.critical_path (correlation_id, from_hlc);
|
||||
|
||||
CREATE INDEX idx_critical_path_duration
|
||||
ON timeline.critical_path (correlation_id, duration_ms DESC);
|
||||
|
||||
-- Function to refresh materialized view for a specific correlation
|
||||
CREATE OR REPLACE FUNCTION timeline.refresh_critical_path()
|
||||
RETURNS void AS $$
|
||||
BEGIN
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY timeline.critical_path;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Comments for documentation
|
||||
COMMENT ON MATERIALIZED VIEW timeline.critical_path IS 'Pre-computed critical path stages for performance analysis';
|
||||
COMMENT ON COLUMN timeline.critical_path.stage IS 'Transition label: prev_kind -> current_kind';
|
||||
COMMENT ON COLUMN timeline.critical_path.duration_ms IS 'Wall-clock duration between events in milliseconds';
|
||||
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using StellaOps.Eventing.Models;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
namespace StellaOps.Timeline.Core.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for orchestrating deterministic replay of timeline events.
|
||||
/// </summary>
|
||||
public interface ITimelineReplayOrchestrator
|
||||
{
|
||||
/// <summary>
|
||||
/// Initiates a replay operation for a correlation ID.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">The correlation ID to replay.</param>
|
||||
/// <param name="request">Replay request parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The initiated replay operation.</returns>
|
||||
Task<ReplayOperation> InitiateReplayAsync(
|
||||
string correlationId,
|
||||
ReplayRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of a replay operation.
|
||||
/// </summary>
|
||||
/// <param name="replayId">The replay operation ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The replay operation status, or null if not found.</returns>
|
||||
Task<ReplayOperation?> GetReplayStatusAsync(
|
||||
string replayId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels an in-progress replay operation.
|
||||
/// </summary>
|
||||
/// <param name="replayId">The replay operation ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if cancelled, false if not found or already completed.</returns>
|
||||
Task<bool> CancelReplayAsync(
|
||||
string replayId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for initiating a replay operation.
|
||||
/// </summary>
|
||||
public sealed record ReplayRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Replay mode: "dry-run" or "verify".
|
||||
/// </summary>
|
||||
public string Mode { get; init; } = "dry-run";
|
||||
|
||||
/// <summary>
|
||||
/// Optional HLC to replay from.
|
||||
/// </summary>
|
||||
public HlcTimestamp? FromHlc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional HLC to replay to.
|
||||
/// </summary>
|
||||
public HlcTimestamp? ToHlc { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a replay operation.
|
||||
/// </summary>
|
||||
public sealed record ReplayOperation
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique replay operation ID.
|
||||
/// </summary>
|
||||
public required string ReplayId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The correlation ID being replayed.
|
||||
/// </summary>
|
||||
public required string CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Replay mode.
|
||||
/// </summary>
|
||||
public required string Mode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status.
|
||||
/// </summary>
|
||||
public required ReplayStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Progress (0.0 to 1.0).
|
||||
/// </summary>
|
||||
public double Progress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of events processed.
|
||||
/// </summary>
|
||||
public int EventsProcessed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of events.
|
||||
/// </summary>
|
||||
public int TotalEvents { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start time.
|
||||
/// </summary>
|
||||
public DateTimeOffset StartedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Completion time (if completed).
|
||||
/// </summary>
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message (if failed).
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original output digest (for verify mode).
|
||||
/// </summary>
|
||||
public string? OriginalDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Replayed output digest (for verify mode).
|
||||
/// </summary>
|
||||
public string? ReplayDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the replay matched the original (for verify mode).
|
||||
/// </summary>
|
||||
public bool? DeterministicMatch { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replay operation status.
|
||||
/// </summary>
|
||||
public enum ReplayStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Replay has been initiated but not started.
|
||||
/// </summary>
|
||||
Initiated,
|
||||
|
||||
/// <summary>
|
||||
/// Replay is in progress.
|
||||
/// </summary>
|
||||
InProgress,
|
||||
|
||||
/// <summary>
|
||||
/// Replay completed successfully.
|
||||
/// </summary>
|
||||
Completed,
|
||||
|
||||
/// <summary>
|
||||
/// Replay failed.
|
||||
/// </summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>
|
||||
/// Replay was cancelled.
|
||||
/// </summary>
|
||||
Cancelled
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Eventing.Models;
|
||||
using StellaOps.Eventing.Storage;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
using StellaOps.Timeline.Core.Telemetry;
|
||||
|
||||
namespace StellaOps.Timeline.Core.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="ITimelineReplayOrchestrator"/>.
|
||||
/// </summary>
|
||||
public sealed class TimelineReplayOrchestrator : ITimelineReplayOrchestrator
|
||||
{
|
||||
private readonly ITimelineEventStore _eventStore;
|
||||
private readonly TimelineMetrics _metrics;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<TimelineReplayOrchestrator> _logger;
|
||||
|
||||
// In-memory store for replay operations (production would use PostgreSQL)
|
||||
private readonly ConcurrentDictionary<string, ReplayOperation> _operations = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimelineReplayOrchestrator"/> class.
|
||||
/// </summary>
|
||||
public TimelineReplayOrchestrator(
|
||||
ITimelineEventStore eventStore,
|
||||
TimelineMetrics metrics,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<TimelineReplayOrchestrator> logger)
|
||||
{
|
||||
_eventStore = eventStore ?? throw new ArgumentNullException(nameof(eventStore));
|
||||
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<ReplayOperation> InitiateReplayAsync(
|
||||
string correlationId,
|
||||
ReplayRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var replayId = GenerateReplayId();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Get events to determine total count
|
||||
var events = await GetEventsForReplayAsync(correlationId, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var operation = new ReplayOperation
|
||||
{
|
||||
ReplayId = replayId,
|
||||
CorrelationId = correlationId,
|
||||
Mode = request.Mode,
|
||||
Status = ReplayStatus.Initiated,
|
||||
Progress = 0,
|
||||
EventsProcessed = 0,
|
||||
TotalEvents = events.Count,
|
||||
StartedAt = now
|
||||
};
|
||||
|
||||
_operations[replayId] = operation;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Initiated replay {ReplayId} for correlation {CorrelationId} with {EventCount} events",
|
||||
replayId,
|
||||
correlationId,
|
||||
events.Count);
|
||||
|
||||
// Start replay in background (in production, this would be queued to a worker)
|
||||
_ = Task.Run(() => ExecuteReplayAsync(replayId, events, request, cancellationToken), cancellationToken);
|
||||
|
||||
return operation;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<ReplayOperation?> GetReplayStatusAsync(
|
||||
string replayId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(replayId);
|
||||
|
||||
_operations.TryGetValue(replayId, out var operation);
|
||||
return Task.FromResult(operation);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> CancelReplayAsync(
|
||||
string replayId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(replayId);
|
||||
|
||||
if (!_operations.TryGetValue(replayId, out var operation))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
if (operation.Status is ReplayStatus.Completed or ReplayStatus.Failed or ReplayStatus.Cancelled)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var cancelled = operation with
|
||||
{
|
||||
Status = ReplayStatus.Cancelled,
|
||||
CompletedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
_operations[replayId] = cancelled;
|
||||
|
||||
_logger.LogInformation("Cancelled replay {ReplayId}", replayId);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<TimelineEvent>> GetEventsForReplayAsync(
|
||||
string correlationId,
|
||||
ReplayRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.FromHlc.HasValue && request.ToHlc.HasValue)
|
||||
{
|
||||
return await _eventStore.GetByHlcRangeAsync(
|
||||
correlationId,
|
||||
request.FromHlc.Value,
|
||||
request.ToHlc.Value,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await _eventStore.GetByCorrelationIdAsync(
|
||||
correlationId,
|
||||
limit: 100000, // Get all events
|
||||
offset: 0,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ExecuteReplayAsync(
|
||||
string replayId,
|
||||
IReadOnlyList<TimelineEvent> events,
|
||||
ReplayRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
// Update status to in-progress
|
||||
UpdateOperation(replayId, op => op with { Status = ReplayStatus.InProgress });
|
||||
|
||||
// Create a FakeTimeProvider for deterministic replay
|
||||
var fakeTimeProvider = new FakeTimeProvider();
|
||||
|
||||
// Compute original digest from events
|
||||
var originalDigest = ComputeEventChainDigest(events);
|
||||
|
||||
var processedCount = 0;
|
||||
var replayedPayloads = new List<string>();
|
||||
|
||||
foreach (var evt in events)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
UpdateOperation(replayId, op => op with
|
||||
{
|
||||
Status = ReplayStatus.Cancelled,
|
||||
CompletedAt = _timeProvider.GetUtcNow()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if cancelled
|
||||
if (_operations.TryGetValue(replayId, out var current) && current.Status == ReplayStatus.Cancelled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulate replay processing
|
||||
// In production, this would re-execute the logic that produced each event
|
||||
fakeTimeProvider.SetUtcNow(evt.TsWall);
|
||||
|
||||
// For dry-run mode, we just verify we can process all events
|
||||
// For verify mode, we would re-execute and compare outputs
|
||||
replayedPayloads.Add(evt.Payload);
|
||||
|
||||
processedCount++;
|
||||
|
||||
// Update progress
|
||||
var progress = (double)processedCount / events.Count;
|
||||
UpdateOperation(replayId, op => op with
|
||||
{
|
||||
Progress = progress,
|
||||
EventsProcessed = processedCount
|
||||
});
|
||||
|
||||
// Small delay to simulate processing (remove in production)
|
||||
await Task.Delay(1, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Compute replayed digest
|
||||
var replayDigest = ComputePayloadChainDigest(replayedPayloads);
|
||||
var deterministicMatch = originalDigest == replayDigest;
|
||||
|
||||
var endTime = _timeProvider.GetUtcNow();
|
||||
var duration = (endTime - startTime).TotalSeconds;
|
||||
|
||||
// Update final status
|
||||
UpdateOperation(replayId, op => op with
|
||||
{
|
||||
Status = ReplayStatus.Completed,
|
||||
Progress = 1.0,
|
||||
EventsProcessed = events.Count,
|
||||
CompletedAt = endTime,
|
||||
OriginalDigest = originalDigest,
|
||||
ReplayDigest = replayDigest,
|
||||
DeterministicMatch = deterministicMatch
|
||||
});
|
||||
|
||||
_metrics.RecordReplay(
|
||||
request.Mode,
|
||||
deterministicMatch ? "SUCCESS" : "MISMATCH",
|
||||
events.Count,
|
||||
duration);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Completed replay {ReplayId}: {EventCount} events, deterministic={Match}, duration={Duration}s",
|
||||
replayId,
|
||||
events.Count,
|
||||
deterministicMatch,
|
||||
duration);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Replay {ReplayId} failed", replayId);
|
||||
|
||||
UpdateOperation(replayId, op => op with
|
||||
{
|
||||
Status = ReplayStatus.Failed,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
Error = ex.Message
|
||||
});
|
||||
|
||||
_metrics.RecordReplay(request.Mode, "FAILED", events.Count, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateOperation(string replayId, Func<ReplayOperation, ReplayOperation> update)
|
||||
{
|
||||
if (_operations.TryGetValue(replayId, out var current))
|
||||
{
|
||||
_operations[replayId] = update(current);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateReplayId()
|
||||
{
|
||||
return Guid.NewGuid().ToString("N")[..16];
|
||||
}
|
||||
|
||||
private static string ComputeEventChainDigest(IReadOnlyList<TimelineEvent> events)
|
||||
{
|
||||
using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
|
||||
foreach (var evt in events)
|
||||
{
|
||||
hasher.AppendData(evt.PayloadDigest);
|
||||
}
|
||||
|
||||
var hash = hasher.GetHashAndReset();
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputePayloadChainDigest(IReadOnlyList<string> payloads)
|
||||
{
|
||||
using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
|
||||
foreach (var payload in payloads)
|
||||
{
|
||||
hasher.AppendData(Encoding.UTF8.GetBytes(payload));
|
||||
}
|
||||
|
||||
var hash = hasher.GetHashAndReset();
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// 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 StellaOps.Timeline.Core.Export;
|
||||
using StellaOps.Timeline.Core.Replay;
|
||||
using StellaOps.Timeline.Core.Telemetry;
|
||||
|
||||
namespace StellaOps.Timeline.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering timeline services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds timeline query and replay services.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddTimelineServices(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
// Register metrics (singleton for consistent counters)
|
||||
services.TryAddSingleton<TimelineMetrics>();
|
||||
|
||||
// Register query service
|
||||
services.TryAddScoped<ITimelineQueryService, TimelineQueryService>();
|
||||
|
||||
// Register replay orchestrator
|
||||
services.TryAddScoped<ITimelineReplayOrchestrator, TimelineReplayOrchestrator>();
|
||||
|
||||
// Register export bundle builder
|
||||
services.TryAddScoped<ITimelineBundleBuilder, TimelineBundleBuilder>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Timeline.Core</RootNamespace>
|
||||
<Description>StellaOps Timeline Core - Query and replay services</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Eventing\StellaOps.Eventing.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.HybridLogicalClock\StellaOps.HybridLogicalClock.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,173 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Timeline.Core.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Metrics instrumentation for the Timeline service.
|
||||
/// </summary>
|
||||
public sealed class TimelineMetrics : IDisposable
|
||||
{
|
||||
private readonly Meter _meter;
|
||||
private readonly Counter<long> _queriesCounter;
|
||||
private readonly Counter<long> _replaysCounter;
|
||||
private readonly Counter<long> _exportsCounter;
|
||||
private readonly Histogram<double> _queryDurationHistogram;
|
||||
private readonly Histogram<double> _replayDurationHistogram;
|
||||
private readonly Histogram<long> _exportSizeHistogram;
|
||||
private readonly Counter<long> _cacheHitsCounter;
|
||||
private readonly Counter<long> _cacheMissesCounter;
|
||||
|
||||
/// <summary>
|
||||
/// Activity source for tracing.
|
||||
/// </summary>
|
||||
public static readonly ActivitySource ActivitySource = new("StellaOps.Timeline", "1.0.0");
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimelineMetrics"/> class.
|
||||
/// </summary>
|
||||
public TimelineMetrics()
|
||||
{
|
||||
_meter = new Meter("StellaOps.Timeline", "1.0.0");
|
||||
|
||||
_queriesCounter = _meter.CreateCounter<long>(
|
||||
"stellaops_timeline_queries_total",
|
||||
description: "Total number of timeline queries");
|
||||
|
||||
_replaysCounter = _meter.CreateCounter<long>(
|
||||
"stellaops_timeline_replays_total",
|
||||
description: "Total number of replay operations");
|
||||
|
||||
_exportsCounter = _meter.CreateCounter<long>(
|
||||
"stellaops_timeline_exports_total",
|
||||
description: "Total number of export operations");
|
||||
|
||||
_queryDurationHistogram = _meter.CreateHistogram<double>(
|
||||
"stellaops_timeline_query_duration_seconds",
|
||||
unit: "s",
|
||||
description: "Duration of timeline query operations");
|
||||
|
||||
_replayDurationHistogram = _meter.CreateHistogram<double>(
|
||||
"stellaops_timeline_replay_duration_seconds",
|
||||
unit: "s",
|
||||
description: "Duration of replay operations");
|
||||
|
||||
_exportSizeHistogram = _meter.CreateHistogram<long>(
|
||||
"stellaops_timeline_export_size_bytes",
|
||||
unit: "By",
|
||||
description: "Size of exported timeline bundles");
|
||||
|
||||
_cacheHitsCounter = _meter.CreateCounter<long>(
|
||||
"stellaops_timeline_cache_hits_total",
|
||||
description: "Total number of cache hits");
|
||||
|
||||
_cacheMissesCounter = _meter.CreateCounter<long>(
|
||||
"stellaops_timeline_cache_misses_total",
|
||||
description: "Total number of cache misses");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a timeline query.
|
||||
/// </summary>
|
||||
public void RecordQuery(string queryType, int eventCount, double durationSeconds)
|
||||
{
|
||||
_queriesCounter.Add(1,
|
||||
new KeyValuePair<string, object?>("query_type", queryType));
|
||||
_queryDurationHistogram.Record(durationSeconds,
|
||||
new KeyValuePair<string, object?>("query_type", queryType),
|
||||
new KeyValuePair<string, object?>("event_count_bucket", GetCountBucket(eventCount)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a replay operation.
|
||||
/// </summary>
|
||||
public void RecordReplay(string mode, string status, int eventCount, double durationSeconds)
|
||||
{
|
||||
_replaysCounter.Add(1,
|
||||
new KeyValuePair<string, object?>("mode", mode),
|
||||
new KeyValuePair<string, object?>("status", status));
|
||||
_replayDurationHistogram.Record(durationSeconds,
|
||||
new KeyValuePair<string, object?>("mode", mode),
|
||||
new KeyValuePair<string, object?>("event_count_bucket", GetCountBucket(eventCount)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an export operation.
|
||||
/// </summary>
|
||||
public void RecordExport(string format, bool signed, long sizeBytes, int eventCount)
|
||||
{
|
||||
_exportsCounter.Add(1,
|
||||
new KeyValuePair<string, object?>("format", format),
|
||||
new KeyValuePair<string, object?>("signed", signed));
|
||||
_exportSizeHistogram.Record(sizeBytes,
|
||||
new KeyValuePair<string, object?>("format", format),
|
||||
new KeyValuePair<string, object?>("event_count_bucket", GetCountBucket(eventCount)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a cache hit.
|
||||
/// </summary>
|
||||
public void RecordCacheHit(string cacheType)
|
||||
{
|
||||
_cacheHitsCounter.Add(1,
|
||||
new KeyValuePair<string, object?>("cache_type", cacheType));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a cache miss.
|
||||
/// </summary>
|
||||
public void RecordCacheMiss(string cacheType)
|
||||
{
|
||||
_cacheMissesCounter.Add(1,
|
||||
new KeyValuePair<string, object?>("cache_type", cacheType));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a query activity for tracing.
|
||||
/// </summary>
|
||||
public Activity? StartQueryActivity(string correlationId, string queryType)
|
||||
{
|
||||
return ActivitySource.StartActivity(
|
||||
"timeline.query",
|
||||
ActivityKind.Server,
|
||||
parentContext: default,
|
||||
tags: new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("correlation_id", correlationId),
|
||||
new KeyValuePair<string, object?>("query_type", queryType)
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a replay activity for tracing.
|
||||
/// </summary>
|
||||
public Activity? StartReplayActivity(string correlationId, string mode)
|
||||
{
|
||||
return ActivitySource.StartActivity(
|
||||
"timeline.replay",
|
||||
ActivityKind.Server,
|
||||
parentContext: default,
|
||||
tags: new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("correlation_id", correlationId),
|
||||
new KeyValuePair<string, object?>("mode", mode)
|
||||
});
|
||||
}
|
||||
|
||||
private static string GetCountBucket(int count) => count switch
|
||||
{
|
||||
<= 10 => "1-10",
|
||||
<= 100 => "11-100",
|
||||
<= 1000 => "101-1000",
|
||||
<= 10000 => "1001-10000",
|
||||
_ => "10000+"
|
||||
};
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
_meter.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Eventing.Models;
|
||||
using StellaOps.Eventing.Storage;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
|
||||
namespace StellaOps.Timeline.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="ITimelineQueryService"/>.
|
||||
/// </summary>
|
||||
public sealed class TimelineQueryService : ITimelineQueryService
|
||||
{
|
||||
private readonly ITimelineEventStore _eventStore;
|
||||
private readonly ILogger<TimelineQueryService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimelineQueryService"/> class.
|
||||
/// </summary>
|
||||
public TimelineQueryService(
|
||||
ITimelineEventStore eventStore,
|
||||
ILogger<TimelineQueryService> logger)
|
||||
{
|
||||
_eventStore = eventStore ?? throw new ArgumentNullException(nameof(eventStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<TimelineQueryResult> GetByCorrelationIdAsync(
|
||||
string correlationId,
|
||||
TimelineQueryOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
|
||||
|
||||
options ??= new TimelineQueryOptions();
|
||||
|
||||
IReadOnlyList<TimelineEvent> events;
|
||||
|
||||
if (options.FromHlc.HasValue && options.ToHlc.HasValue)
|
||||
{
|
||||
events = await _eventStore.GetByHlcRangeAsync(
|
||||
correlationId,
|
||||
options.FromHlc.Value,
|
||||
options.ToHlc.Value,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
events = await _eventStore.GetByCorrelationIdAsync(
|
||||
correlationId,
|
||||
options.Limit + 1, // Fetch one extra to check for more
|
||||
options.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Apply additional filters
|
||||
var filteredEvents = ApplyFilters(events, options);
|
||||
|
||||
// Check if there are more results
|
||||
var hasMore = filteredEvents.Count > options.Limit;
|
||||
if (hasMore)
|
||||
{
|
||||
filteredEvents = filteredEvents.Take(options.Limit).ToList();
|
||||
}
|
||||
|
||||
var totalCount = await _eventStore.CountByCorrelationIdAsync(correlationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Queried {Count} events for correlation {CorrelationId}",
|
||||
filteredEvents.Count,
|
||||
correlationId);
|
||||
|
||||
return new TimelineQueryResult
|
||||
{
|
||||
Events = filteredEvents,
|
||||
TotalCount = totalCount,
|
||||
HasMore = hasMore,
|
||||
NextCursor = hasMore && filteredEvents.Count > 0
|
||||
? filteredEvents[^1].THlc.ToSortableString()
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<CriticalPathResult> GetCriticalPathAsync(
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
|
||||
|
||||
var events = await _eventStore.GetByCorrelationIdAsync(
|
||||
correlationId,
|
||||
limit: 10000, // Get all events for critical path analysis
|
||||
offset: 0,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (events.Count < 2)
|
||||
{
|
||||
return new CriticalPathResult
|
||||
{
|
||||
CorrelationId = correlationId,
|
||||
TotalDuration = TimeSpan.Zero,
|
||||
Stages = Array.Empty<CriticalPathStage>()
|
||||
};
|
||||
}
|
||||
|
||||
var stages = new List<CriticalPathStage>();
|
||||
var totalDuration = events[^1].TsWall - events[0].TsWall;
|
||||
|
||||
for (int i = 1; i < events.Count; i++)
|
||||
{
|
||||
var prev = events[i - 1];
|
||||
var curr = events[i];
|
||||
var stageDuration = curr.TsWall - prev.TsWall;
|
||||
|
||||
stages.Add(new CriticalPathStage
|
||||
{
|
||||
Stage = $"{prev.Kind} -> {curr.Kind}",
|
||||
Service = curr.Service,
|
||||
Duration = stageDuration,
|
||||
Percentage = totalDuration.TotalMilliseconds > 0
|
||||
? stageDuration.TotalMilliseconds / totalDuration.TotalMilliseconds * 100
|
||||
: 0,
|
||||
FromHlc = prev.THlc,
|
||||
ToHlc = curr.THlc
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by duration descending (critical path = longest stages first)
|
||||
stages = stages.OrderByDescending(s => s.Duration).ToList();
|
||||
|
||||
return new CriticalPathResult
|
||||
{
|
||||
CorrelationId = correlationId,
|
||||
TotalDuration = totalDuration,
|
||||
Stages = stages
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<TimelineQueryResult> GetByServiceAsync(
|
||||
string service,
|
||||
HlcTimestamp? fromHlc = null,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(service);
|
||||
|
||||
var events = await _eventStore.GetByServiceAsync(
|
||||
service,
|
||||
fromHlc,
|
||||
limit + 1,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var hasMore = events.Count > limit;
|
||||
var resultEvents = hasMore ? events.Take(limit).ToList() : events;
|
||||
|
||||
return new TimelineQueryResult
|
||||
{
|
||||
Events = resultEvents,
|
||||
TotalCount = resultEvents.Count,
|
||||
HasMore = hasMore,
|
||||
NextCursor = hasMore && resultEvents.Count > 0
|
||||
? resultEvents[^1].THlc.ToSortableString()
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static List<TimelineEvent> ApplyFilters(
|
||||
IReadOnlyList<TimelineEvent> events,
|
||||
TimelineQueryOptions options)
|
||||
{
|
||||
var query = events.AsEnumerable();
|
||||
|
||||
if (options.Services is { Count: > 0 })
|
||||
{
|
||||
var services = new HashSet<string>(options.Services, StringComparer.OrdinalIgnoreCase);
|
||||
query = query.Where(e => services.Contains(e.Service));
|
||||
}
|
||||
|
||||
if (options.Kinds is { Count: > 0 })
|
||||
{
|
||||
var kinds = new HashSet<string>(options.Kinds, StringComparer.OrdinalIgnoreCase);
|
||||
query = query.Where(e => kinds.Contains(e.Kind));
|
||||
}
|
||||
|
||||
return query.ToList();
|
||||
}
|
||||
}
|
||||
24
src/Timeline/__Tests/StellaOps.Timeline.Core.Tests/AGENTS.md
Normal file
24
src/Timeline/__Tests/StellaOps.Timeline.Core.Tests/AGENTS.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Timeline Core Tests Charter
|
||||
|
||||
## Mission
|
||||
- Verify timeline core query, replay, and export logic.
|
||||
|
||||
## Responsibilities
|
||||
- Cover ordering, filtering, and pagination behavior.
|
||||
- Exercise replay and export determinism and cancellation.
|
||||
- Validate HLC range handling and edge cases.
|
||||
|
||||
## Required Reading
|
||||
- docs/README.md
|
||||
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
- docs/modules/timeline-indexer/architecture.md
|
||||
- docs/modules/timeline-indexer/README.md
|
||||
|
||||
## Working Agreement
|
||||
- Use fixed times and IDs in fixtures.
|
||||
- Avoid network dependencies in tests.
|
||||
|
||||
## Testing Strategy
|
||||
- Unit tests for query, replay, and export logic.
|
||||
- Determinism tests for ordering and digests.
|
||||
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Timeline.Core.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Timeline.Core\StellaOps.Timeline.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Eventing\StellaOps.Eventing.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.HybridLogicalClock\StellaOps.HybridLogicalClock.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,224 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Eventing.Models;
|
||||
using StellaOps.Eventing.Storage;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Timeline.Core.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TimelineQueryServiceTests
|
||||
{
|
||||
private readonly InMemoryTimelineEventStore _eventStore;
|
||||
private readonly TimelineQueryService _queryService;
|
||||
|
||||
public TimelineQueryServiceTests()
|
||||
{
|
||||
_eventStore = new InMemoryTimelineEventStore();
|
||||
_queryService = new TimelineQueryService(
|
||||
_eventStore,
|
||||
NullLogger<TimelineQueryService>.Instance);
|
||||
}
|
||||
|
||||
private static TimelineEvent CreateEvent(
|
||||
string correlationId,
|
||||
string kind,
|
||||
HlcTimestamp hlc,
|
||||
string service = "TestService")
|
||||
{
|
||||
return new TimelineEvent
|
||||
{
|
||||
EventId = $"{correlationId}-{kind}-{hlc.LogicalCounter}",
|
||||
CorrelationId = correlationId,
|
||||
Kind = kind,
|
||||
THlc = hlc,
|
||||
TsWall = DateTimeOffset.UtcNow,
|
||||
Service = service,
|
||||
Payload = "{}",
|
||||
PayloadDigest = new byte[32],
|
||||
EngineVersion = new EngineVersionRef("Test", "1.0.0", "test"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCorrelationIdAsync_ReturnsEventsOrderedByHlc()
|
||||
{
|
||||
// Arrange
|
||||
var hlc1 = new HlcTimestamp(1000, 0, "n1");
|
||||
var hlc2 = new HlcTimestamp(1000, 1, "n1");
|
||||
var hlc3 = new HlcTimestamp(2000, 0, "n1");
|
||||
|
||||
await _eventStore.AppendAsync(CreateEvent("corr-1", "C", hlc3));
|
||||
await _eventStore.AppendAsync(CreateEvent("corr-1", "A", hlc1));
|
||||
await _eventStore.AppendAsync(CreateEvent("corr-1", "B", hlc2));
|
||||
|
||||
// Act
|
||||
var result = await _queryService.GetByCorrelationIdAsync("corr-1");
|
||||
|
||||
// Assert
|
||||
result.Events.Should().HaveCount(3);
|
||||
result.Events[0].Kind.Should().Be("A");
|
||||
result.Events[1].Kind.Should().Be("B");
|
||||
result.Events[2].Kind.Should().Be("C");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCorrelationIdAsync_FiltersByServices()
|
||||
{
|
||||
// Arrange
|
||||
var hlc1 = new HlcTimestamp(1000, 0, "n1");
|
||||
var hlc2 = new HlcTimestamp(2000, 0, "n1");
|
||||
var hlc3 = new HlcTimestamp(3000, 0, "n1");
|
||||
|
||||
await _eventStore.AppendAsync(CreateEvent("corr-1", "A", hlc1, "Scheduler"));
|
||||
await _eventStore.AppendAsync(CreateEvent("corr-1", "B", hlc2, "AirGap"));
|
||||
await _eventStore.AppendAsync(CreateEvent("corr-1", "C", hlc3, "Scheduler"));
|
||||
|
||||
// Act
|
||||
var result = await _queryService.GetByCorrelationIdAsync("corr-1", new TimelineQueryOptions
|
||||
{
|
||||
Services = new[] { "Scheduler" }
|
||||
});
|
||||
|
||||
// Assert
|
||||
result.Events.Should().HaveCount(2);
|
||||
result.Events.All(e => e.Service == "Scheduler").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCorrelationIdAsync_FiltersByKinds()
|
||||
{
|
||||
// Arrange
|
||||
var hlc1 = new HlcTimestamp(1000, 0, "n1");
|
||||
var hlc2 = new HlcTimestamp(2000, 0, "n1");
|
||||
var hlc3 = new HlcTimestamp(3000, 0, "n1");
|
||||
|
||||
await _eventStore.AppendAsync(CreateEvent("corr-1", "ENQUEUE", hlc1));
|
||||
await _eventStore.AppendAsync(CreateEvent("corr-1", "EXECUTE", hlc2));
|
||||
await _eventStore.AppendAsync(CreateEvent("corr-1", "COMPLETE", hlc3));
|
||||
|
||||
// Act
|
||||
var result = await _queryService.GetByCorrelationIdAsync("corr-1", new TimelineQueryOptions
|
||||
{
|
||||
Kinds = new[] { "ENQUEUE", "COMPLETE" }
|
||||
});
|
||||
|
||||
// Assert
|
||||
result.Events.Should().HaveCount(2);
|
||||
result.Events.Select(e => e.Kind).Should().BeEquivalentTo(new[] { "ENQUEUE", "COMPLETE" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByCorrelationIdAsync_HasMoreFlag_WhenMoreResults()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _eventStore.AppendAsync(CreateEvent("corr-1", $"E{i}", new HlcTimestamp(1000 + i, 0, "n1")));
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = await _queryService.GetByCorrelationIdAsync("corr-1", new TimelineQueryOptions { Limit = 5 });
|
||||
|
||||
// Assert
|
||||
result.Events.Should().HaveCount(5);
|
||||
result.HasMore.Should().BeTrue();
|
||||
result.TotalCount.Should().Be(10);
|
||||
result.NextCursor.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCriticalPathAsync_ReturnsStagesOrderedByDuration()
|
||||
{
|
||||
// Arrange
|
||||
var baseTime = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
await _eventStore.AppendAsync(new TimelineEvent
|
||||
{
|
||||
EventId = "e1",
|
||||
CorrelationId = "corr-1",
|
||||
Kind = "ENQUEUE",
|
||||
THlc = new HlcTimestamp(1000, 0, "n1"),
|
||||
TsWall = baseTime,
|
||||
Service = "Scheduler",
|
||||
Payload = "{}",
|
||||
PayloadDigest = new byte[32],
|
||||
EngineVersion = new EngineVersionRef("Test", "1.0.0", "test"),
|
||||
SchemaVersion = 1
|
||||
});
|
||||
|
||||
await _eventStore.AppendAsync(new TimelineEvent
|
||||
{
|
||||
EventId = "e2",
|
||||
CorrelationId = "corr-1",
|
||||
Kind = "EXECUTE",
|
||||
THlc = new HlcTimestamp(2000, 0, "n1"),
|
||||
TsWall = baseTime.AddSeconds(1), // 1 second
|
||||
Service = "Scheduler",
|
||||
Payload = "{}",
|
||||
PayloadDigest = new byte[32],
|
||||
EngineVersion = new EngineVersionRef("Test", "1.0.0", "test"),
|
||||
SchemaVersion = 1
|
||||
});
|
||||
|
||||
await _eventStore.AppendAsync(new TimelineEvent
|
||||
{
|
||||
EventId = "e3",
|
||||
CorrelationId = "corr-1",
|
||||
Kind = "COMPLETE",
|
||||
THlc = new HlcTimestamp(3000, 0, "n1"),
|
||||
TsWall = baseTime.AddSeconds(5), // 4 more seconds
|
||||
Service = "Scheduler",
|
||||
Payload = "{}",
|
||||
PayloadDigest = new byte[32],
|
||||
EngineVersion = new EngineVersionRef("Test", "1.0.0", "test"),
|
||||
SchemaVersion = 1
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _queryService.GetCriticalPathAsync("corr-1");
|
||||
|
||||
// Assert
|
||||
result.CorrelationId.Should().Be("corr-1");
|
||||
result.TotalDuration.Should().Be(TimeSpan.FromSeconds(5));
|
||||
result.Stages.Should().HaveCount(2);
|
||||
|
||||
// Stages should be ordered by duration descending
|
||||
result.Stages[0].Stage.Should().Be("EXECUTE -> COMPLETE");
|
||||
result.Stages[0].Duration.Should().Be(TimeSpan.FromSeconds(4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCriticalPathAsync_EmptyForSingleEvent()
|
||||
{
|
||||
// Arrange
|
||||
await _eventStore.AppendAsync(CreateEvent("corr-1", "ENQUEUE", new HlcTimestamp(1000, 0, "n1")));
|
||||
|
||||
// Act
|
||||
var result = await _queryService.GetCriticalPathAsync("corr-1");
|
||||
|
||||
// Assert
|
||||
result.Stages.Should().BeEmpty();
|
||||
result.TotalDuration.Should().Be(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByServiceAsync_ReturnsEventsFromService()
|
||||
{
|
||||
// Arrange
|
||||
await _eventStore.AppendAsync(CreateEvent("corr-1", "A", new HlcTimestamp(1000, 0, "n1"), "Scheduler"));
|
||||
await _eventStore.AppendAsync(CreateEvent("corr-2", "B", new HlcTimestamp(2000, 0, "n1"), "AirGap"));
|
||||
await _eventStore.AppendAsync(CreateEvent("corr-3", "C", new HlcTimestamp(3000, 0, "n1"), "Scheduler"));
|
||||
|
||||
// Act
|
||||
var result = await _queryService.GetByServiceAsync("Scheduler");
|
||||
|
||||
// Assert
|
||||
result.Events.Should().HaveCount(2);
|
||||
result.Events.All(e => e.Service == "Scheduler").Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Timeline WebService Tests Charter
|
||||
|
||||
## Mission
|
||||
- Verify timeline API endpoints, validation, and auth gating.
|
||||
|
||||
## Responsibilities
|
||||
- Exercise request validation, paging, and response ordering.
|
||||
- Cover replay and export endpoints and status flows.
|
||||
- Validate authorization middleware behavior.
|
||||
|
||||
## Required Reading
|
||||
- docs/README.md
|
||||
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
- docs/modules/timeline-indexer/architecture.md
|
||||
- docs/modules/timeline-indexer/README.md
|
||||
|
||||
## Working Agreement
|
||||
- Use fixed times and IDs in fixtures.
|
||||
- Avoid network dependencies beyond the local test server.
|
||||
|
||||
## Testing Strategy
|
||||
- Integration tests for endpoint behavior and auth.
|
||||
- Determinism checks for ordered responses.
|
||||
@@ -0,0 +1,163 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Eventing.Models;
|
||||
using StellaOps.Eventing.Storage;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
using StellaOps.Timeline.Core.Replay;
|
||||
using StellaOps.Timeline.Core.Telemetry;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Timeline.WebService.Tests;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class ReplayOrchestratorIntegrationTests
|
||||
{
|
||||
private readonly InMemoryTimelineEventStore _eventStore;
|
||||
private readonly TimelineReplayOrchestrator _orchestrator;
|
||||
private readonly FakeTimeProvider _fakeTimeProvider;
|
||||
|
||||
public ReplayOrchestratorIntegrationTests()
|
||||
{
|
||||
_eventStore = new InMemoryTimelineEventStore();
|
||||
_fakeTimeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
_orchestrator = new TimelineReplayOrchestrator(
|
||||
_eventStore,
|
||||
new TimelineMetrics(),
|
||||
_fakeTimeProvider,
|
||||
NullLogger<TimelineReplayOrchestrator>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitiateReplayAsync_CreatesOperation()
|
||||
{
|
||||
// Arrange
|
||||
const string correlationId = "replay-test-001";
|
||||
await SeedEventsAsync(correlationId, 10);
|
||||
|
||||
// Act
|
||||
var operation = await _orchestrator.InitiateReplayAsync(
|
||||
correlationId,
|
||||
new ReplayRequest { Mode = "dry-run" });
|
||||
|
||||
// Assert
|
||||
operation.Should().NotBeNull();
|
||||
operation.ReplayId.Should().NotBeNullOrEmpty();
|
||||
operation.CorrelationId.Should().Be(correlationId);
|
||||
operation.Mode.Should().Be("dry-run");
|
||||
operation.Status.Should().Be(ReplayStatus.Initiated);
|
||||
operation.TotalEvents.Should().Be(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetReplayStatusAsync_ReturnsOperation()
|
||||
{
|
||||
// Arrange
|
||||
const string correlationId = "replay-status-001";
|
||||
await SeedEventsAsync(correlationId, 5);
|
||||
|
||||
var operation = await _orchestrator.InitiateReplayAsync(
|
||||
correlationId,
|
||||
new ReplayRequest { Mode = "verify" });
|
||||
|
||||
// Act
|
||||
var status = await _orchestrator.GetReplayStatusAsync(operation.ReplayId);
|
||||
|
||||
// Assert
|
||||
status.Should().NotBeNull();
|
||||
status!.ReplayId.Should().Be(operation.ReplayId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetReplayStatusAsync_ReturnsNull_WhenNotFound()
|
||||
{
|
||||
// Act
|
||||
var status = await _orchestrator.GetReplayStatusAsync("nonexistent-replay");
|
||||
|
||||
// Assert
|
||||
status.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelReplayAsync_CancelsOperation()
|
||||
{
|
||||
// Arrange
|
||||
const string correlationId = "replay-cancel-001";
|
||||
await SeedEventsAsync(correlationId, 100); // Many events to ensure we can cancel
|
||||
|
||||
var operation = await _orchestrator.InitiateReplayAsync(
|
||||
correlationId,
|
||||
new ReplayRequest { Mode = "dry-run" });
|
||||
|
||||
// Act
|
||||
var cancelled = await _orchestrator.CancelReplayAsync(operation.ReplayId);
|
||||
|
||||
// Assert
|
||||
cancelled.Should().BeTrue();
|
||||
var status = await _orchestrator.GetReplayStatusAsync(operation.ReplayId);
|
||||
status!.Status.Should().Be(ReplayStatus.Cancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelReplayAsync_ReturnsFalse_WhenNotFound()
|
||||
{
|
||||
// Act
|
||||
var cancelled = await _orchestrator.CancelReplayAsync("nonexistent-replay");
|
||||
|
||||
// Assert
|
||||
cancelled.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplayCompletes_WithDeterministicDigest()
|
||||
{
|
||||
// Arrange
|
||||
const string correlationId = "replay-digest-001";
|
||||
await SeedEventsAsync(correlationId, 5);
|
||||
|
||||
var operation = await _orchestrator.InitiateReplayAsync(
|
||||
correlationId,
|
||||
new ReplayRequest { Mode = "verify" });
|
||||
|
||||
// Wait for completion
|
||||
await Task.Delay(500); // Give background task time to complete
|
||||
|
||||
// Act
|
||||
var status = await _orchestrator.GetReplayStatusAsync(operation.ReplayId);
|
||||
|
||||
// Assert - should complete or still be running
|
||||
status.Should().NotBeNull();
|
||||
// The operation should have started processing
|
||||
status!.Status.Should().BeOneOf(
|
||||
ReplayStatus.InProgress,
|
||||
ReplayStatus.Completed,
|
||||
ReplayStatus.Initiated);
|
||||
}
|
||||
|
||||
private async Task SeedEventsAsync(string correlationId, int count)
|
||||
{
|
||||
var baseTime = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventId = $"{correlationId}-evt-{i:D4}",
|
||||
CorrelationId = correlationId,
|
||||
Kind = $"EVENT_{i}",
|
||||
THlc = new HlcTimestamp(1000 + i, 0, "test-node"),
|
||||
TsWall = baseTime.AddSeconds(i),
|
||||
Service = "TestService",
|
||||
Payload = $"{{\"index\": {i}}}",
|
||||
PayloadDigest = new byte[32],
|
||||
EngineVersion = new EngineVersionRef("Test", "1.0.0", "test-digest"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
|
||||
await _eventStore.AppendAsync(evt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Timeline.WebService.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Timeline.WebService\StellaOps.Timeline.WebService.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Timeline.Core\StellaOps.Timeline.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Eventing\StellaOps.Eventing.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.HybridLogicalClock\StellaOps.HybridLogicalClock.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,213 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Eventing.Models;
|
||||
using StellaOps.Eventing.Storage;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
using StellaOps.Timeline.WebService.Endpoints;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Timeline.WebService.Tests;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class TimelineApiIntegrationTests : IClassFixture<TimelineWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly TimelineWebApplicationFactory _factory;
|
||||
|
||||
public TimelineApiIntegrationTests(TimelineWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTimeline_ReturnsEvents_WhenCorrelationExists()
|
||||
{
|
||||
// Arrange
|
||||
const string correlationId = "test-corr-001";
|
||||
await SeedEventsAsync(correlationId, 5);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/timeline/{correlationId}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var timeline = await response.Content.ReadFromJsonAsync<TimelineResponse>();
|
||||
timeline.Should().NotBeNull();
|
||||
timeline!.CorrelationId.Should().Be(correlationId);
|
||||
timeline.Events.Should().HaveCount(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTimeline_Returns404_WhenCorrelationNotFound()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/v1/timeline/nonexistent-correlation");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTimeline_ReturnsEventsOrderedByHlc()
|
||||
{
|
||||
// Arrange
|
||||
const string correlationId = "test-corr-ordered";
|
||||
await SeedEventsAsync(correlationId, 10);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/timeline/{correlationId}");
|
||||
var timeline = await response.Content.ReadFromJsonAsync<TimelineResponse>();
|
||||
|
||||
// Assert
|
||||
timeline.Should().NotBeNull();
|
||||
timeline!.Events.Should().BeInAscendingOrder(e => e.THlc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTimeline_SupportsPagination()
|
||||
{
|
||||
// Arrange
|
||||
const string correlationId = "test-corr-pagination";
|
||||
await SeedEventsAsync(correlationId, 20);
|
||||
|
||||
// Act - Get first page
|
||||
var response1 = await _client.GetAsync($"/api/v1/timeline/{correlationId}?limit=10");
|
||||
var page1 = await response1.Content.ReadFromJsonAsync<TimelineResponse>();
|
||||
|
||||
// Assert
|
||||
page1.Should().NotBeNull();
|
||||
page1!.Events.Should().HaveCount(10);
|
||||
page1.HasMore.Should().BeTrue();
|
||||
page1.TotalCount.Should().Be(20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTimeline_FiltersbyService()
|
||||
{
|
||||
// Arrange
|
||||
const string correlationId = "test-corr-filter-svc";
|
||||
await SeedEventsWithServicesAsync(correlationId);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/timeline/{correlationId}?services=Scheduler");
|
||||
var timeline = await response.Content.ReadFromJsonAsync<TimelineResponse>();
|
||||
|
||||
// Assert
|
||||
timeline.Should().NotBeNull();
|
||||
timeline!.Events.Should().AllSatisfy(e => e.Service.Should().Be("Scheduler"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCriticalPath_ReturnsStages()
|
||||
{
|
||||
// Arrange
|
||||
const string correlationId = "test-corr-critical";
|
||||
await SeedEventsAsync(correlationId, 5);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/timeline/{correlationId}/critical-path");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var criticalPath = await response.Content.ReadFromJsonAsync<CriticalPathResponse>();
|
||||
criticalPath.Should().NotBeNull();
|
||||
criticalPath!.CorrelationId.Should().Be(correlationId);
|
||||
criticalPath.Stages.Should().HaveCountGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheck_ReturnsHealthy()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.GetAsync("/health");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
private async Task SeedEventsAsync(string correlationId, int count)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var eventStore = scope.ServiceProvider.GetRequiredService<ITimelineEventStore>();
|
||||
|
||||
var baseTime = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventId = $"{correlationId}-evt-{i:D4}",
|
||||
CorrelationId = correlationId,
|
||||
Kind = $"EVENT_{i}",
|
||||
THlc = new HlcTimestamp(1000 + i, 0, "test-node"),
|
||||
TsWall = baseTime.AddSeconds(i),
|
||||
Service = "TestService",
|
||||
Payload = $"{{\"index\": {i}}}",
|
||||
PayloadDigest = new byte[32],
|
||||
EngineVersion = new EngineVersionRef("Test", "1.0.0", "test-digest"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
|
||||
await eventStore.AppendAsync(evt);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SeedEventsWithServicesAsync(string correlationId)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var eventStore = scope.ServiceProvider.GetRequiredService<ITimelineEventStore>();
|
||||
|
||||
var services = new[] { "Scheduler", "AirGap", "Scheduler", "Attestor", "Scheduler" };
|
||||
var baseTime = new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
for (int i = 0; i < services.Length; i++)
|
||||
{
|
||||
var evt = new TimelineEvent
|
||||
{
|
||||
EventId = $"{correlationId}-evt-{i:D4}",
|
||||
CorrelationId = correlationId,
|
||||
Kind = $"EVENT_{i}",
|
||||
THlc = new HlcTimestamp(1000 + i, 0, "test-node"),
|
||||
TsWall = baseTime.AddSeconds(i),
|
||||
Service = services[i],
|
||||
Payload = $"{{\"index\": {i}}}",
|
||||
PayloadDigest = new byte[32],
|
||||
EngineVersion = new EngineVersionRef("Test", "1.0.0", "test-digest"),
|
||||
SchemaVersion = 1
|
||||
};
|
||||
|
||||
await eventStore.AppendAsync(evt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom WebApplicationFactory for Timeline integration tests.
|
||||
/// </summary>
|
||||
public sealed class TimelineWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Replace with in-memory store for tests
|
||||
services.AddSingleton<ITimelineEventStore, InMemoryTimelineEventStore>();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal Program class reference for WebApplicationFactory.
|
||||
/// </summary>
|
||||
public partial class Program { }
|
||||
Reference in New Issue
Block a user