Files
git.stella-ops.org/src/Scheduler/__Libraries/StellaOps.Scheduler.Queue/Hlc/HlcSchedulerEnqueueService.cs
StellaOps Bot 37e11918e0 save progress
2026-01-06 09:42:20 +02:00

167 lines
6.3 KiB
C#

// <copyright file="HlcSchedulerEnqueueService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.Canonical.Json;
using StellaOps.HybridLogicalClock;
using StellaOps.Scheduler.Persistence;
using StellaOps.Scheduler.Persistence.Postgres.Models;
using StellaOps.Scheduler.Persistence.Postgres.Repositories;
namespace StellaOps.Scheduler.Queue.Hlc;
/// <summary>
/// Implementation of HLC-ordered scheduler job enqueueing with chain linking.
/// </summary>
public sealed class HlcSchedulerEnqueueService : IHlcSchedulerEnqueueService
{
/// <summary>
/// Namespace GUID for deterministic job ID generation (v5 UUID style).
/// </summary>
private static readonly Guid JobIdNamespace = new("b8a7c6d5-e4f3-42a1-9b0c-1d2e3f4a5b6c");
private readonly IHybridLogicalClock _hlc;
private readonly ISchedulerLogRepository _logRepository;
private readonly IChainHeadRepository _chainHeadRepository;
private readonly ILogger<HlcSchedulerEnqueueService> _logger;
/// <summary>
/// Creates a new HLC scheduler enqueue service.
/// </summary>
public HlcSchedulerEnqueueService(
IHybridLogicalClock hlc,
ISchedulerLogRepository logRepository,
IChainHeadRepository chainHeadRepository,
ILogger<HlcSchedulerEnqueueService> logger)
{
_hlc = hlc ?? throw new ArgumentNullException(nameof(hlc));
_logRepository = logRepository ?? throw new ArgumentNullException(nameof(logRepository));
_chainHeadRepository = chainHeadRepository ?? throw new ArgumentNullException(nameof(chainHeadRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public Task<SchedulerHlcEnqueueResult> EnqueuePlannerAsync(
string tenantId,
PlannerQueueMessage message,
string? partitionKey = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
return EnqueueAsync(tenantId, message, message.IdempotencyKey, partitionKey, cancellationToken);
}
/// <inheritdoc />
public Task<SchedulerHlcEnqueueResult> EnqueueRunnerSegmentAsync(
string tenantId,
RunnerSegmentQueueMessage message,
string? partitionKey = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
return EnqueueAsync(tenantId, message, message.IdempotencyKey, partitionKey, cancellationToken);
}
/// <inheritdoc />
public async Task<SchedulerHlcEnqueueResult> EnqueueAsync<T>(
string tenantId,
T payload,
string idempotencyKey,
string? partitionKey = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(payload);
ArgumentException.ThrowIfNullOrWhiteSpace(idempotencyKey);
var effectivePartitionKey = partitionKey ?? string.Empty;
// 1. Generate deterministic job ID from idempotency key
var jobId = ComputeDeterministicJobId(idempotencyKey);
// 2. Check for existing entry (idempotency)
if (await _logRepository.ExistsAsync(tenantId, jobId, cancellationToken).ConfigureAwait(false))
{
var existing = await _logRepository.GetByJobIdAsync(jobId, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
_logger.LogDebug(
"Job already enqueued, returning existing entry. TenantId={TenantId}, JobId={JobId}",
tenantId,
jobId);
return new SchedulerHlcEnqueueResult(
HlcTimestamp.Parse(existing.THlc),
existing.JobId,
existing.Link,
Deduplicated: true);
}
}
// 3. Generate HLC timestamp
var tHlc = _hlc.Tick();
// 4. Compute payload hash
var payloadHash = SchedulerChainLinking.ComputePayloadHash(payload);
// 5. Get previous chain link
var prevLink = await _chainHeadRepository.GetLastLinkAsync(tenantId, effectivePartitionKey, cancellationToken)
.ConfigureAwait(false);
// 6. Compute new chain link
var link = SchedulerChainLinking.ComputeLink(prevLink, jobId, tHlc, payloadHash);
// 7. Insert log entry (atomic with chain head update)
var entry = new SchedulerLogEntry
{
TenantId = tenantId,
THlc = tHlc.ToSortableString(),
PartitionKey = effectivePartitionKey,
JobId = jobId,
PayloadHash = payloadHash,
PrevLink = prevLink,
Link = link
};
await _logRepository.InsertWithChainUpdateAsync(entry, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Job enqueued with HLC ordering. TenantId={TenantId}, JobId={JobId}, THlc={THlc}, Link={Link}",
tenantId,
jobId,
tHlc.ToSortableString(),
SchedulerChainLinking.ToHex(link));
return new SchedulerHlcEnqueueResult(tHlc, jobId, link, Deduplicated: false);
}
/// <summary>
/// Computes a deterministic GUID from the idempotency key using SHA-256.
/// </summary>
private static Guid ComputeDeterministicJobId(string idempotencyKey)
{
// Use namespace + key pattern similar to UUID v5
var namespaceBytes = JobIdNamespace.ToByteArray();
var keyBytes = Encoding.UTF8.GetBytes(idempotencyKey);
var combined = new byte[namespaceBytes.Length + keyBytes.Length];
Buffer.BlockCopy(namespaceBytes, 0, combined, 0, namespaceBytes.Length);
Buffer.BlockCopy(keyBytes, 0, combined, namespaceBytes.Length, keyBytes.Length);
var hash = SHA256.HashData(combined);
// Take first 16 bytes for GUID
var guidBytes = new byte[16];
Buffer.BlockCopy(hash, 0, guidBytes, 0, 16);
// Set version (4) and variant bits for RFC 4122 compliance
guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x40); // Version 4
guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80); // Variant 1
return new Guid(guidBytes);
}
}