// // Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later. // 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; /// /// Implementation of HLC-ordered scheduler job enqueueing with chain linking. /// public sealed class HlcSchedulerEnqueueService : IHlcSchedulerEnqueueService { /// /// Namespace GUID for deterministic job ID generation (v5 UUID style). /// 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 _logger; /// /// Creates a new HLC scheduler enqueue service. /// public HlcSchedulerEnqueueService( IHybridLogicalClock hlc, ISchedulerLogRepository logRepository, IChainHeadRepository chainHeadRepository, ILogger 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)); } /// public Task EnqueuePlannerAsync( string tenantId, PlannerQueueMessage message, string? partitionKey = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(message); return EnqueueAsync(tenantId, message, message.IdempotencyKey, partitionKey, cancellationToken); } /// public Task EnqueueRunnerSegmentAsync( string tenantId, RunnerSegmentQueueMessage message, string? partitionKey = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(message); return EnqueueAsync(tenantId, message, message.IdempotencyKey, partitionKey, cancellationToken); } /// public async Task EnqueueAsync( 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); } /// /// Computes a deterministic GUID from the idempotency key using SHA-256. /// 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); } }