save progress
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
// <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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user