sprints enhancements
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Provcache;
|
||||
|
||||
/// <summary>
|
||||
/// File-based lazy evidence chunk fetcher for sneakernet mode.
|
||||
/// Fetches chunks from a local directory (e.g., USB drive, NFS mount).
|
||||
/// </summary>
|
||||
public sealed class FileChunkFetcher : ILazyEvidenceFetcher
|
||||
{
|
||||
private readonly string _basePath;
|
||||
private readonly ILogger<FileChunkFetcher> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string FetcherType => "file";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a file chunk fetcher with the specified base directory.
|
||||
/// </summary>
|
||||
/// <param name="basePath">The base directory containing evidence files.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public FileChunkFetcher(string basePath, ILogger<FileChunkFetcher> logger)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(basePath);
|
||||
|
||||
_basePath = Path.GetFullPath(basePath);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
_logger.LogDebug("FileChunkFetcher initialized with base path: {BasePath}", _basePath);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FetchedChunk?> FetchChunkAsync(
|
||||
string proofRoot,
|
||||
int chunkIndex,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(proofRoot);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(chunkIndex);
|
||||
|
||||
var chunkPath = GetChunkPath(proofRoot, chunkIndex);
|
||||
_logger.LogDebug("Looking for chunk at {Path}", chunkPath);
|
||||
|
||||
if (!File.Exists(chunkPath))
|
||||
{
|
||||
_logger.LogDebug("Chunk file not found: {Path}", chunkPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(chunkPath);
|
||||
var chunk = await JsonSerializer.DeserializeAsync<FetchedChunk>(stream, _jsonOptions, cancellationToken);
|
||||
_logger.LogDebug("Successfully loaded chunk {Index}, {Bytes} bytes", chunkIndex, chunk?.Data.Length ?? 0);
|
||||
return chunk;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error reading chunk file {Path}", chunkPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<FetchedChunk> FetchChunksAsync(
|
||||
string proofRoot,
|
||||
IEnumerable<int> chunkIndices,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(proofRoot);
|
||||
ArgumentNullException.ThrowIfNull(chunkIndices);
|
||||
|
||||
var indices = chunkIndices.ToList();
|
||||
_logger.LogInformation("Fetching {Count} chunks from file system for proof root {ProofRoot}", indices.Count, proofRoot);
|
||||
|
||||
foreach (var index in indices)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var chunk = await FetchChunkAsync(proofRoot, index, cancellationToken);
|
||||
if (chunk is not null)
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<FetchedChunk> FetchRemainingChunksAsync(
|
||||
string proofRoot,
|
||||
ChunkManifest manifest,
|
||||
IReadOnlySet<int> existingIndices,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(proofRoot);
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
ArgumentNullException.ThrowIfNull(existingIndices);
|
||||
|
||||
var missingIndices = Enumerable.Range(0, manifest.TotalChunks)
|
||||
.Where(i => !existingIndices.Contains(i))
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Fetching {MissingCount} remaining chunks from files (have {ExistingCount}/{TotalCount})",
|
||||
missingIndices.Count, existingIndices.Count, manifest.TotalChunks);
|
||||
|
||||
await foreach (var chunk in FetchChunksAsync(proofRoot, missingIndices, cancellationToken))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var isAvailable = Directory.Exists(_basePath);
|
||||
_logger.LogDebug("File fetcher availability check: {IsAvailable}", isAvailable);
|
||||
return Task.FromResult(isAvailable);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ChunkManifest?> FetchManifestAsync(
|
||||
string proofRoot,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(proofRoot);
|
||||
|
||||
var manifestPath = GetManifestPath(proofRoot);
|
||||
_logger.LogDebug("Looking for manifest at {Path}", manifestPath);
|
||||
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
_logger.LogDebug("Manifest file not found: {Path}", manifestPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(manifestPath);
|
||||
return await JsonSerializer.DeserializeAsync<ChunkManifest>(stream, _jsonOptions, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error reading manifest file {Path}", manifestPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the file path for a chunk.
|
||||
/// </summary>
|
||||
private string GetChunkPath(string proofRoot, int chunkIndex)
|
||||
{
|
||||
// Sanitize proof root for use in file paths
|
||||
var safeProofRoot = SanitizeForPath(proofRoot);
|
||||
return Path.Combine(_basePath, safeProofRoot, $"chunk_{chunkIndex:D4}.json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the file path for a manifest.
|
||||
/// </summary>
|
||||
private string GetManifestPath(string proofRoot)
|
||||
{
|
||||
var safeProofRoot = SanitizeForPath(proofRoot);
|
||||
return Path.Combine(_basePath, safeProofRoot, "manifest.json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes a proof root for use in file paths.
|
||||
/// </summary>
|
||||
private static string SanitizeForPath(string input)
|
||||
{
|
||||
// Use hash prefix to ensure consistent directory naming
|
||||
var hash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input))).ToLowerInvariant();
|
||||
|
||||
// Return first 16 chars of hash for reasonable directory names
|
||||
return hash[..16];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports chunks to files for sneakernet transfer.
|
||||
/// </summary>
|
||||
/// <param name="proofRoot">The proof root.</param>
|
||||
/// <param name="manifest">The chunk manifest.</param>
|
||||
/// <param name="chunks">The chunks to export.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task ExportToFilesAsync(
|
||||
string proofRoot,
|
||||
ChunkManifest manifest,
|
||||
IEnumerable<FetchedChunk> chunks,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(proofRoot);
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
ArgumentNullException.ThrowIfNull(chunks);
|
||||
|
||||
var safeProofRoot = SanitizeForPath(proofRoot);
|
||||
var proofDir = Path.Combine(_basePath, safeProofRoot);
|
||||
|
||||
Directory.CreateDirectory(proofDir);
|
||||
_logger.LogInformation("Exporting to {Directory}", proofDir);
|
||||
|
||||
// Write manifest
|
||||
var manifestPath = GetManifestPath(proofRoot);
|
||||
await using (var manifestStream = File.Create(manifestPath))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(manifestStream, manifest, _jsonOptions, cancellationToken);
|
||||
}
|
||||
_logger.LogDebug("Wrote manifest to {Path}", manifestPath);
|
||||
|
||||
// Write chunks
|
||||
var count = 0;
|
||||
foreach (var chunk in chunks)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var chunkPath = GetChunkPath(proofRoot, chunk.Index);
|
||||
await using var chunkStream = File.Create(chunkPath);
|
||||
await JsonSerializer.SerializeAsync(chunkStream, chunk, _jsonOptions, cancellationToken);
|
||||
count++;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Exported {Count} chunks to {Directory}", count, proofDir);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports EvidenceChunks to files (converts to FetchedChunk format).
|
||||
/// </summary>
|
||||
/// <param name="proofRoot">The proof root.</param>
|
||||
/// <param name="manifest">The chunk manifest.</param>
|
||||
/// <param name="chunks">The evidence chunks to export.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public Task ExportEvidenceChunksToFilesAsync(
|
||||
string proofRoot,
|
||||
ChunkManifest manifest,
|
||||
IEnumerable<EvidenceChunk> chunks,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var fetchedChunks = chunks.Select(c => new FetchedChunk
|
||||
{
|
||||
Index = c.ChunkIndex,
|
||||
Data = c.Blob,
|
||||
Hash = c.ChunkHash
|
||||
});
|
||||
|
||||
return ExportToFilesAsync(proofRoot, manifest, fetchedChunks, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Provcache;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-based lazy evidence chunk fetcher for connected mode.
|
||||
/// Fetches chunks from a remote Stella API endpoint.
|
||||
/// </summary>
|
||||
public sealed class HttpChunkFetcher : ILazyEvidenceFetcher, IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsClient;
|
||||
private readonly ILogger<HttpChunkFetcher> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string FetcherType => "http";
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HTTP chunk fetcher with the specified base URL.
|
||||
/// </summary>
|
||||
/// <param name="baseUrl">The base URL of the Stella API.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public HttpChunkFetcher(Uri baseUrl, ILogger<HttpChunkFetcher> logger)
|
||||
: this(CreateClient(baseUrl), ownsClient: true, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HTTP chunk fetcher with an existing HTTP client.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">The HTTP client to use.</param>
|
||||
/// <param name="ownsClient">Whether this fetcher owns the client lifecycle.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public HttpChunkFetcher(HttpClient httpClient, bool ownsClient, ILogger<HttpChunkFetcher> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_ownsClient = ownsClient;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
}
|
||||
|
||||
private static HttpClient CreateClient(Uri baseUrl)
|
||||
{
|
||||
var client = new HttpClient { BaseAddress = baseUrl };
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FetchedChunk?> FetchChunkAsync(
|
||||
string proofRoot,
|
||||
int chunkIndex,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(proofRoot);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(chunkIndex);
|
||||
|
||||
var url = $"api/v1/evidence/{Uri.EscapeDataString(proofRoot)}/chunks/{chunkIndex}";
|
||||
_logger.LogDebug("Fetching chunk {Index} from {Url}", chunkIndex, url);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("Chunk {Index} not found at remote", chunkIndex);
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var chunk = await response.Content.ReadFromJsonAsync<FetchedChunk>(_jsonOptions, cancellationToken);
|
||||
_logger.LogDebug("Successfully fetched chunk {Index}, {Bytes} bytes", chunkIndex, chunk?.Data.Length ?? 0);
|
||||
return chunk;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "HTTP error fetching chunk {Index}", chunkIndex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<FetchedChunk> FetchChunksAsync(
|
||||
string proofRoot,
|
||||
IEnumerable<int> chunkIndices,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(proofRoot);
|
||||
ArgumentNullException.ThrowIfNull(chunkIndices);
|
||||
|
||||
var indices = chunkIndices.ToList();
|
||||
_logger.LogInformation("Fetching {Count} chunks for proof root {ProofRoot}", indices.Count, proofRoot);
|
||||
|
||||
foreach (var index in indices)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var chunk = await FetchChunkAsync(proofRoot, index, cancellationToken);
|
||||
if (chunk is not null)
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<FetchedChunk> FetchRemainingChunksAsync(
|
||||
string proofRoot,
|
||||
ChunkManifest manifest,
|
||||
IReadOnlySet<int> existingIndices,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(proofRoot);
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
ArgumentNullException.ThrowIfNull(existingIndices);
|
||||
|
||||
var missingIndices = Enumerable.Range(0, manifest.TotalChunks)
|
||||
.Where(i => !existingIndices.Contains(i))
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Fetching {MissingCount} remaining chunks (have {ExistingCount}/{TotalCount})",
|
||||
missingIndices.Count, existingIndices.Count, manifest.TotalChunks);
|
||||
|
||||
await foreach (var chunk in FetchChunksAsync(proofRoot, missingIndices, cancellationToken))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync("api/v1/health", cancellationToken);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Health check failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ChunkManifest?> FetchManifestAsync(
|
||||
string proofRoot,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(proofRoot);
|
||||
|
||||
var url = $"api/v1/evidence/{Uri.EscapeDataString(proofRoot)}/manifest";
|
||||
_logger.LogDebug("Fetching manifest from {Url}", url);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("Manifest not found for proof root {ProofRoot}", proofRoot);
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<ChunkManifest>(_jsonOptions, cancellationToken);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "HTTP error fetching manifest for {ProofRoot}", proofRoot);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsClient)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
namespace StellaOps.Provcache;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for lazy evidence chunk fetching from various sources.
|
||||
/// Enables on-demand evidence retrieval for air-gapped auditors.
|
||||
/// </summary>
|
||||
public interface ILazyEvidenceFetcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the fetcher type (e.g., "http", "file").
|
||||
/// </summary>
|
||||
string FetcherType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a single chunk by index.
|
||||
/// </summary>
|
||||
/// <param name="proofRoot">The proof root identifying the evidence.</param>
|
||||
/// <param name="chunkIndex">The chunk index to fetch.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The fetched chunk or null if not found.</returns>
|
||||
Task<FetchedChunk?> FetchChunkAsync(
|
||||
string proofRoot,
|
||||
int chunkIndex,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches multiple chunks by index.
|
||||
/// </summary>
|
||||
/// <param name="proofRoot">The proof root identifying the evidence.</param>
|
||||
/// <param name="chunkIndices">The chunk indices to fetch.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Async enumerable of fetched chunks.</returns>
|
||||
IAsyncEnumerable<FetchedChunk> FetchChunksAsync(
|
||||
string proofRoot,
|
||||
IEnumerable<int> chunkIndices,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all remaining chunks for a proof root.
|
||||
/// </summary>
|
||||
/// <param name="proofRoot">The proof root identifying the evidence.</param>
|
||||
/// <param name="manifest">The chunk manifest for reference.</param>
|
||||
/// <param name="existingIndices">Indices of chunks already present locally.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Async enumerable of fetched chunks.</returns>
|
||||
IAsyncEnumerable<FetchedChunk> FetchRemainingChunksAsync(
|
||||
string proofRoot,
|
||||
ChunkManifest manifest,
|
||||
IReadOnlySet<int> existingIndices,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the source is available for fetching.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the source is available.</returns>
|
||||
Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the manifest from the source.
|
||||
/// </summary>
|
||||
/// <param name="proofRoot">The proof root to get manifest for.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The chunk manifest or null if not available.</returns>
|
||||
Task<ChunkManifest?> FetchManifestAsync(
|
||||
string proofRoot,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simplified chunk representation for lazy fetch interface.
|
||||
/// Contains only the index and data for transport.
|
||||
/// </summary>
|
||||
public sealed record FetchedChunk
|
||||
{
|
||||
/// <summary>
|
||||
/// Zero-based chunk index.
|
||||
/// </summary>
|
||||
public required int Index { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The chunk data.
|
||||
/// </summary>
|
||||
public required byte[] Data { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 hash of the data for verification.
|
||||
/// </summary>
|
||||
public required string Hash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a lazy fetch operation.
|
||||
/// </summary>
|
||||
public sealed record LazyFetchResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the fetch was successful.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of chunks fetched.
|
||||
/// </summary>
|
||||
public required int ChunksFetched { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total bytes fetched.
|
||||
/// </summary>
|
||||
public required long BytesFetched { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of chunks that failed verification.
|
||||
/// </summary>
|
||||
public required int ChunksFailedVerification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Indices of failed chunks.
|
||||
/// </summary>
|
||||
public IReadOnlyList<int> FailedIndices { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Any errors encountered.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Errors { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Time taken for the fetch operation.
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Provcache;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates lazy evidence fetching with verification.
|
||||
/// Coordinates between fetchers and the local evidence store.
|
||||
/// </summary>
|
||||
public sealed class LazyFetchOrchestrator
|
||||
{
|
||||
private readonly IEvidenceChunkRepository _repository;
|
||||
private readonly ILogger<LazyFetchOrchestrator> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a lazy fetch orchestrator.
|
||||
/// </summary>
|
||||
/// <param name="repository">The chunk repository for local storage.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="timeProvider">Optional time provider.</param>
|
||||
public LazyFetchOrchestrator(
|
||||
IEvidenceChunkRepository repository,
|
||||
ILogger<LazyFetchOrchestrator> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches remaining chunks for a proof root and stores them locally.
|
||||
/// </summary>
|
||||
/// <param name="proofRoot">The proof root.</param>
|
||||
/// <param name="fetcher">The fetcher to use.</param>
|
||||
/// <param name="options">Fetch options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The fetch result.</returns>
|
||||
public async Task<LazyFetchResult> FetchAndStoreAsync(
|
||||
string proofRoot,
|
||||
ILazyEvidenceFetcher fetcher,
|
||||
LazyFetchOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(proofRoot);
|
||||
ArgumentNullException.ThrowIfNull(fetcher);
|
||||
|
||||
options ??= new LazyFetchOptions();
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var errors = new List<string>();
|
||||
var failedIndices = new List<int>();
|
||||
var chunksFetched = 0;
|
||||
long bytesFetched = 0;
|
||||
var chunksFailedVerification = 0;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting lazy fetch for {ProofRoot} using {FetcherType} fetcher",
|
||||
proofRoot, fetcher.FetcherType);
|
||||
|
||||
try
|
||||
{
|
||||
// Check fetcher availability
|
||||
if (!await fetcher.IsAvailableAsync(cancellationToken))
|
||||
{
|
||||
_logger.LogWarning("Fetcher {FetcherType} is not available", fetcher.FetcherType);
|
||||
return new LazyFetchResult
|
||||
{
|
||||
Success = false,
|
||||
ChunksFetched = 0,
|
||||
BytesFetched = 0,
|
||||
ChunksFailedVerification = 0,
|
||||
Errors = [$"Fetcher {fetcher.FetcherType} is not available"],
|
||||
Duration = stopwatch.Elapsed
|
||||
};
|
||||
}
|
||||
|
||||
// Get local manifest
|
||||
var localManifest = await _repository.GetManifestAsync(proofRoot, cancellationToken);
|
||||
|
||||
if (localManifest is null)
|
||||
{
|
||||
// Try to fetch manifest from remote
|
||||
localManifest = await fetcher.FetchManifestAsync(proofRoot, cancellationToken);
|
||||
if (localManifest is null)
|
||||
{
|
||||
_logger.LogWarning("No manifest found for {ProofRoot}", proofRoot);
|
||||
return new LazyFetchResult
|
||||
{
|
||||
Success = false,
|
||||
ChunksFetched = 0,
|
||||
BytesFetched = 0,
|
||||
ChunksFailedVerification = 0,
|
||||
Errors = [$"No manifest found for proof root {proofRoot}"],
|
||||
Duration = stopwatch.Elapsed
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Get existing chunks
|
||||
var existingChunks = (await _repository.GetChunksAsync(proofRoot, cancellationToken))
|
||||
.Select(c => c.ChunkIndex)
|
||||
.ToHashSet();
|
||||
|
||||
var totalChunks = localManifest.TotalChunks;
|
||||
var missingCount = totalChunks - existingChunks.Count;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Have {Existing}/{Total} chunks, need to fetch {Missing}",
|
||||
existingChunks.Count, totalChunks, missingCount);
|
||||
|
||||
if (missingCount == 0)
|
||||
{
|
||||
_logger.LogInformation("All chunks already present, nothing to fetch");
|
||||
return new LazyFetchResult
|
||||
{
|
||||
Success = true,
|
||||
ChunksFetched = 0,
|
||||
BytesFetched = 0,
|
||||
ChunksFailedVerification = 0,
|
||||
Duration = stopwatch.Elapsed
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch remaining chunks
|
||||
var chunksToStore = new List<EvidenceChunk>();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
await foreach (var fetchedChunk in fetcher.FetchRemainingChunksAsync(
|
||||
proofRoot, localManifest, existingChunks, cancellationToken))
|
||||
{
|
||||
// Verify chunk if enabled
|
||||
if (options.VerifyOnFetch)
|
||||
{
|
||||
var isValid = VerifyChunk(fetchedChunk, localManifest);
|
||||
if (!isValid)
|
||||
{
|
||||
chunksFailedVerification++;
|
||||
failedIndices.Add(fetchedChunk.Index);
|
||||
errors.Add($"Chunk {fetchedChunk.Index} failed verification");
|
||||
|
||||
if (options.FailOnVerificationError)
|
||||
{
|
||||
_logger.LogError("Chunk {Index} failed verification, aborting", fetchedChunk.Index);
|
||||
break;
|
||||
}
|
||||
|
||||
_logger.LogWarning("Chunk {Index} failed verification, skipping", fetchedChunk.Index);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert FetchedChunk to EvidenceChunk for storage
|
||||
var evidenceChunk = new EvidenceChunk
|
||||
{
|
||||
ChunkId = Guid.NewGuid(),
|
||||
ProofRoot = proofRoot,
|
||||
ChunkIndex = fetchedChunk.Index,
|
||||
ChunkHash = fetchedChunk.Hash,
|
||||
Blob = fetchedChunk.Data,
|
||||
BlobSize = fetchedChunk.Data.Length,
|
||||
ContentType = "application/octet-stream",
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
chunksToStore.Add(evidenceChunk);
|
||||
bytesFetched += fetchedChunk.Data.Length;
|
||||
chunksFetched++;
|
||||
|
||||
// Batch store to reduce database round-trips
|
||||
if (chunksToStore.Count >= options.BatchSize)
|
||||
{
|
||||
await _repository.StoreChunksAsync(proofRoot, chunksToStore, cancellationToken);
|
||||
_logger.LogDebug("Stored batch of {Count} chunks", chunksToStore.Count);
|
||||
chunksToStore.Clear();
|
||||
}
|
||||
|
||||
// Check max chunks limit
|
||||
if (options.MaxChunksToFetch > 0 && chunksFetched >= options.MaxChunksToFetch)
|
||||
{
|
||||
_logger.LogInformation("Reached max chunks limit ({Max})", options.MaxChunksToFetch);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Store any remaining chunks
|
||||
if (chunksToStore.Count > 0)
|
||||
{
|
||||
await _repository.StoreChunksAsync(proofRoot, chunksToStore, cancellationToken);
|
||||
_logger.LogDebug("Stored final batch of {Count} chunks", chunksToStore.Count);
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
var success = chunksFailedVerification == 0 || !options.FailOnVerificationError;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Lazy fetch complete: {Fetched} chunks, {Bytes} bytes, {Failed} verification failures in {Duration}",
|
||||
chunksFetched, bytesFetched, chunksFailedVerification, stopwatch.Elapsed);
|
||||
|
||||
return new LazyFetchResult
|
||||
{
|
||||
Success = success,
|
||||
ChunksFetched = chunksFetched,
|
||||
BytesFetched = bytesFetched,
|
||||
ChunksFailedVerification = chunksFailedVerification,
|
||||
FailedIndices = failedIndices,
|
||||
Errors = errors,
|
||||
Duration = stopwatch.Elapsed
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during lazy fetch for {ProofRoot}", proofRoot);
|
||||
errors.Add(ex.Message);
|
||||
|
||||
return new LazyFetchResult
|
||||
{
|
||||
Success = false,
|
||||
ChunksFetched = chunksFetched,
|
||||
BytesFetched = bytesFetched,
|
||||
ChunksFailedVerification = chunksFailedVerification,
|
||||
FailedIndices = failedIndices,
|
||||
Errors = errors,
|
||||
Duration = stopwatch.Elapsed
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a chunk against the manifest.
|
||||
/// </summary>
|
||||
private bool VerifyChunk(FetchedChunk chunk, ChunkManifest manifest)
|
||||
{
|
||||
// Check index bounds
|
||||
if (chunk.Index < 0 || chunk.Index >= manifest.TotalChunks)
|
||||
{
|
||||
_logger.LogWarning("Chunk index {Index} out of bounds (max {Max})", chunk.Index, manifest.TotalChunks - 1);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify hash against manifest metadata
|
||||
if (manifest.Chunks is not null && chunk.Index < manifest.Chunks.Count)
|
||||
{
|
||||
var expectedHash = manifest.Chunks[chunk.Index].Hash;
|
||||
var actualHash = Convert.ToHexString(SHA256.HashData(chunk.Data)).ToLowerInvariant();
|
||||
|
||||
if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Chunk {Index} hash mismatch: expected {Expected}, got {Actual}",
|
||||
chunk.Index, expectedHash, actualHash);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Also verify the chunk's own hash claim
|
||||
var claimedHash = Convert.ToHexString(SHA256.HashData(chunk.Data)).ToLowerInvariant();
|
||||
if (!string.Equals(claimedHash, chunk.Hash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Chunk {Index} self-hash mismatch: claimed {Claimed}, actual {Actual}",
|
||||
chunk.Index, chunk.Hash, claimedHash);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for lazy fetch operations.
|
||||
/// </summary>
|
||||
public sealed class LazyFetchOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to verify chunks on fetch.
|
||||
/// </summary>
|
||||
public bool VerifyOnFetch { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail the entire operation on verification error.
|
||||
/// </summary>
|
||||
public bool FailOnVerificationError { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Batch size for storing chunks.
|
||||
/// </summary>
|
||||
public int BatchSize { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of chunks to fetch (0 = unlimited).
|
||||
/// </summary>
|
||||
public int MaxChunksToFetch { get; init; } = 0;
|
||||
}
|
||||
Reference in New Issue
Block a user