//
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
//
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Sync.Models;
using StellaOps.AirGap.Sync.Stores;
using StellaOps.Canonical.Json;
using StellaOps.Determinism;
using StellaOps.HybridLogicalClock;
namespace StellaOps.AirGap.Sync.Services;
///
/// Interface for offline HLC management.
///
public interface IOfflineHlcManager
{
///
/// Enqueues a job locally while offline, maintaining the local chain.
///
/// The payload type.
/// The job payload.
/// The idempotency key for deterministic job ID.
/// Optional partition key.
/// Cancellation token.
/// The enqueue result.
Task EnqueueOfflineAsync(
T payload,
string idempotencyKey,
string? partitionKey = null,
CancellationToken cancellationToken = default) where T : notnull;
///
/// Gets the current node's job log for export.
///
/// Cancellation token.
/// The node job log, or null if empty.
Task GetNodeJobLogAsync(CancellationToken cancellationToken = default);
///
/// Gets the node ID.
///
string NodeId { get; }
}
///
/// Manages HLC operations for offline/air-gap scenarios.
///
public sealed class OfflineHlcManager : IOfflineHlcManager
{
private readonly IHybridLogicalClock _hlc;
private readonly IOfflineJobLogStore _jobLogStore;
private readonly IGuidProvider _guidProvider;
private readonly ILogger _logger;
///
/// Initializes a new instance of the class.
///
public OfflineHlcManager(
IHybridLogicalClock hlc,
IOfflineJobLogStore jobLogStore,
IGuidProvider guidProvider,
ILogger logger)
{
_hlc = hlc ?? throw new ArgumentNullException(nameof(hlc));
_jobLogStore = jobLogStore ?? throw new ArgumentNullException(nameof(jobLogStore));
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
///
public string NodeId => _hlc.NodeId;
///
public async Task EnqueueOfflineAsync(
T payload,
string idempotencyKey,
string? partitionKey = null,
CancellationToken cancellationToken = default) where T : notnull
{
ArgumentNullException.ThrowIfNull(payload);
ArgumentException.ThrowIfNullOrWhiteSpace(idempotencyKey);
// 1. Generate HLC timestamp
var tHlc = _hlc.Tick();
// 2. Compute deterministic job ID from idempotency key
var jobId = ComputeDeterministicJobId(idempotencyKey);
// 3. Serialize and hash payload
var payloadJson = CanonJson.Serialize(payload);
var payloadHash = SHA256.HashData(Encoding.UTF8.GetBytes(payloadJson));
// 4. Get previous chain link
var prevLink = await _jobLogStore.GetLastLinkAsync(NodeId, cancellationToken)
.ConfigureAwait(false);
// 5. Compute chain link
var link = ComputeLink(prevLink, jobId, tHlc, payloadHash);
// 6. Create and store entry
var entry = new OfflineJobLogEntry
{
NodeId = NodeId,
THlc = tHlc,
JobId = jobId,
PartitionKey = partitionKey,
Payload = payloadJson,
PayloadHash = payloadHash,
PrevLink = prevLink,
Link = link,
EnqueuedAt = DateTimeOffset.UtcNow
};
await _jobLogStore.AppendAsync(entry, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Enqueued offline job {JobId} with HLC {THlc} on node {NodeId}",
jobId, tHlc, NodeId);
return new OfflineEnqueueResult
{
THlc = tHlc,
JobId = jobId,
Link = link,
NodeId = NodeId
};
}
///
public Task GetNodeJobLogAsync(CancellationToken cancellationToken = default)
=> _jobLogStore.GetNodeJobLogAsync(NodeId, cancellationToken);
///
/// Computes deterministic job ID from idempotency key.
///
private Guid ComputeDeterministicJobId(string idempotencyKey)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(idempotencyKey));
// Use first 16 bytes of SHA-256 as deterministic GUID
return new Guid(hash.AsSpan(0, 16));
}
///
/// Computes chain link: Hash(prev_link || job_id || t_hlc || payload_hash).
///
internal static byte[] ComputeLink(
byte[]? prevLink,
Guid jobId,
HlcTimestamp tHlc,
byte[] payloadHash)
{
using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
// Previous link (or 32 zero bytes for first entry)
hasher.AppendData(prevLink ?? new byte[32]);
// Job ID as bytes
hasher.AppendData(jobId.ToByteArray());
// HLC timestamp as UTF-8 bytes
hasher.AppendData(Encoding.UTF8.GetBytes(tHlc.ToSortableString()));
// Payload hash
hasher.AppendData(payloadHash);
return hasher.GetHashAndReset();
}
}