Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public class FileSystemFusionConfigurationProvider : IFusionConfigurationProvide
private ulong _schemaDocumentHash;
private ulong _settingsHash;
private ulong _packageHash;
private string? _planningFingerprint;
private bool _disposed;

public FileSystemFusionConfigurationProvider(string fileName)
Expand Down Expand Up @@ -137,6 +138,7 @@ private async Task SchemaUpdateProcessorAsync(CancellationToken ct)
DocumentNode schema;
ulong settingsHash;
ulong schemaHash;
string? planningFingerprint = null;

if (_isPackage)
{
Expand Down Expand Up @@ -170,6 +172,7 @@ private async Task SchemaUpdateProcessorAsync(CancellationToken ct)
buffer.Write(settingsSpan);
settingsHash = XxHash64.HashToUInt64(settingsSpan);
settings = new JsonDocumentOwner(JsonDocument.Parse(buffer.WrittenMemory), buffer);
planningFingerprint = config.PlanningFingerprint;
}
else
{
Expand All @@ -178,15 +181,21 @@ private async Task SchemaUpdateProcessorAsync(CancellationToken ct)
settingsHash = defaultSettingsHash;
}

if (_schemaDocumentHash == schemaHash && _settingsHash == settingsHash)
if (_schemaDocumentHash == schemaHash
&& _settingsHash == settingsHash
&& _planningFingerprint == planningFingerprint)
{
settings.Dispose();
continue;
}

_settingsHash = settingsHash;
_schemaDocumentHash = schemaHash;
NotifyObservers(new FusionConfiguration(schema, settings));
_planningFingerprint = planningFingerprint;
NotifyObservers(new FusionConfiguration(schema, settings)
{
PlanningFingerprint = planningFingerprint
Comment thread
tobias-tengler marked this conversation as resolved.
Resolved
});
}
catch
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,7 @@ public sealed record FusionConfiguration(
JsonDocumentOwner Settings)
: IDisposable
{
public string? PlanningFingerprint { get; init; }

public void Dispose() => Settings.Dispose();
}
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ private FusionRequestExecutor CreateRequestExecutor(
var parserOptions = CreateParserOptions(setup);
var features = CreateSchemaFeatures(
setup,
configuration,
options,
requestOptions,
parserOptions);
Expand Down Expand Up @@ -304,6 +305,7 @@ private static ParserOptions CreateParserOptions(FusionGatewaySetup setup)

private FeatureCollection CreateSchemaFeatures(
FusionGatewaySetup setup,
FusionConfiguration configuration,
FusionOptions options,
FusionRequestOptions requestOptions,
ParserOptions parserOptions)
Expand All @@ -317,7 +319,10 @@ private FeatureCollection CreateSchemaFeatures(
features.Set(parserOptions);
features.Set(CreateTypeResolverInterceptors(options));
features.Set(new SchemaCancellationFeature());
features.Set(new OperationPlannerFeature(OperationPlanner.Version));
features.Set(new OperationPlannerFeature(OperationPlanner.Version)
{
ConfigurationFingerprint = configuration.PlanningFingerprint
Comment thread
tobias-tengler marked this conversation as resolved.
Resolved
});

foreach (var configure in setup.SchemaFeaturesModifiers)
{
Expand Down Expand Up @@ -621,6 +626,7 @@ private sealed class RequestExecutorRegistration : IAsyncDisposable
private FusionConfiguration _currentConfiguration;
private ulong _documentHash;
private ulong _settingsHash;
private string? _planningFingerprint;
private bool _disposed;

public RequestExecutorRegistration(
Expand All @@ -635,6 +641,7 @@ public RequestExecutorRegistration(
_currentConfiguration = configuration;
_documentHash = XxHash64.HashToUInt64(Encoding.UTF8.GetBytes(configuration.Schema.ToString()));
_settingsHash = XxHash64.HashToUInt64(GetRawUtf8Value(configuration.Settings.Document.RootElement));
_planningFingerprint = configuration.PlanningFingerprint;

_documentProviderSubscription = documentProvider.Subscribe(
onNext: OnDocumentChanged,
Expand Down Expand Up @@ -666,13 +673,16 @@ private async Task WaitForUpdatesAsync()
var documentHash = XxHash64.HashToUInt64(Encoding.UTF8.GetBytes(configuration.Schema.ToString()));
var settingsHash = XxHash64.HashToUInt64(GetRawUtf8Value(configuration.Settings.Document.RootElement));

if (documentHash == _documentHash && settingsHash == _settingsHash)
if (documentHash == _documentHash
&& settingsHash == _settingsHash
&& configuration.PlanningFingerprint == _planningFingerprint)
{
continue;
}

_documentHash = documentHash;
_settingsHash = settingsHash;
_planningFingerprint = configuration.PlanningFingerprint;

var previousExecutor = Executor;
var previousConfiguration = _currentConfiguration;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ public OperationPlannerFeature(Version version)
}

public Version Version { get; }

public string? ConfigurationFingerprint { get; init; }
}
5 changes: 5 additions & 0 deletions src/HotChocolate/Fusion/src/Fusion.Packaging/FileNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ internal static class FileNames
{
private const string GatewaySchemaFormat = "gateway/{0}/gateway.graphqls";
private const string GatewaySettingsFormat = "gateway/{0}/gateway-settings.json";
private const string GatewayPlanningFingerprintFormat = "gateway/{0}/planning-fingerprint";
private const string SourceSchemaFormat = "source-schemas/{0}/schema.graphqls";
private const string SourceSchemaExtensionsFormat = "source-schemas/{0}/schema-extensions.graphqls";
private const string SourceSchemaSettingsFormat = "source-schemas/{0}/schema-settings.json";
Expand All @@ -20,6 +21,9 @@ public static string GetGatewaySchemaPath(Version version)
public static string GetGatewaySettingsPath(Version version)
=> string.Format(GatewaySettingsFormat, version);

public static string GetGatewayPlanningFingerprintPath(Version version)
=> string.Format(GatewayPlanningFingerprintFormat, version);

public static string GetSourceSchemaPath(string schemaName)
=> string.Format(SourceSchemaFormat, schemaName);

Expand All @@ -40,6 +44,7 @@ public static FileKind GetFileKind(string fileName)

case "schema-settings.json":
case "gateway-settings.json":
case "planning-fingerprint":
case "composition-settings.json":
return FileKind.Settings;

Expand Down
37 changes: 36 additions & 1 deletion src/HotChocolate/Fusion/src/Fusion.Packaging/FusionArchive.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ namespace HotChocolate.Fusion.Packaging;
/// </summary>
public sealed class FusionArchive : IDisposable
{
private const string PlanningFingerprintPrefix = "v1:";

private readonly Stream _stream;
private readonly bool _leaveOpen;
private readonly ArchiveSession _session;
Expand Down Expand Up @@ -381,6 +383,8 @@ public async Task SetGatewayConfigurationAsync(
"You need to first declare the gateway schema version in the archive metadata.");
}

var planningFingerprint = FormatPlanningFingerprint(SHA256.HashData(schema.Span));

await using (var stream = _session.OpenWrite(FileNames.GetGatewaySchemaPath(version)))
{
await stream.WriteAsync(schema, cancellationToken);
Expand All @@ -392,6 +396,11 @@ public async Task SetGatewayConfigurationAsync(
settings.WriteTo(jsonWriter);
await jsonWriter.FlushAsync(cancellationToken);
}

await using (var stream = _session.OpenWrite(FileNames.GetGatewayPlanningFingerprintPath(version)))
{
await stream.WriteAsync(Encoding.UTF8.GetBytes(planningFingerprint), cancellationToken);
}
}

/// <summary>
Expand Down Expand Up @@ -424,6 +433,8 @@ public async Task SetGatewayConfigurationAsync(
return null;
}

var planningFingerprint = await TryReadPlanningFingerprintAsync(version, cancellationToken);

JsonDocument settings;
await using (var stream = await _session.OpenReadAsync(
FileNames.GetGatewaySettingsPath(version),
Expand All @@ -433,7 +444,7 @@ public async Task SetGatewayConfigurationAsync(
settings = await JsonDocument.ParseAsync(stream, default, cancellationToken);
}

return new GatewayConfiguration(OpenReadSchemaAsync, settings, version);
return new GatewayConfiguration(OpenReadSchemaAsync, settings, version, planningFingerprint);

Task<Stream> OpenReadSchemaAsync(CancellationToken ct)
=> _session.OpenReadAsync(FileNames.GetGatewaySchemaPath(version), FileKind.Schema, ct);
Expand Down Expand Up @@ -896,6 +907,30 @@ private void TryReturnBuffer(ArrayBufferWriter<byte> buffer)
}
}

private async Task<string?> TryReadPlanningFingerprintAsync(
Version version,
CancellationToken cancellationToken)
{
var path = FileNames.GetGatewayPlanningFingerprintPath(version);
if (!_session.Exists(path))
{
return null;
}

await using var stream = await _session.OpenReadAsync(path, FileKind.Settings, cancellationToken);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false);
var fingerprint = await reader.ReadToEndAsync(cancellationToken);

return fingerprint.Length > 0 ? fingerprint : null;
}

private static string FormatPlanningFingerprint(ReadOnlySpan<byte> hash)
#if NET9_0_OR_GREATER
=> PlanningFingerprintPrefix + Convert.ToHexStringLower(hash);
#else
=> PlanningFingerprintPrefix + Convert.ToHexString(hash).ToLowerInvariant();
#endif

private async Task<SignatureManifest> GenerateManifestAsync(CancellationToken cancellationToken)
{
var files = ImmutableDictionary.CreateBuilder<string, string>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,29 @@ public sealed class GatewayConfiguration : IDisposable
internal GatewayConfiguration(
Func<CancellationToken, Task<Stream>> openReadSchema,
JsonDocument settings,
Version version)
Version version,
string? planningFingerprint)
{
ArgumentNullException.ThrowIfNull(openReadSchema);
ArgumentNullException.ThrowIfNull(settings);

_openReadSchema = openReadSchema;
Settings = settings;
Version = version;
PlanningFingerprint = planningFingerprint;
}

/// <summary>
/// Gets the version of the gateway configuration.
/// </summary>
public Version Version { get; }

/// <summary>
/// Gets an opaque fingerprint of the configuration inputs relevant to operation planning,
/// or null if the archive does not contain one.
/// </summary>
public string? PlanningFingerprint { get; }

/// <summary>
/// Opens the Hot Chocolate Fusion execution schema for reading.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
Expand Down Expand Up @@ -202,6 +203,57 @@ public async Task SetGatewaySchema_WithByteContent_StoresCorrectly()
result.Dispose();
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task SetGatewayConfigurationAsync_Should_PersistPlainTextPlanningFingerprint_When_CommittingArchive(bool useBytes)
{
// arrange
await using var stream = CreateStream();
const string schema = "type Query { field: String }";
const string fingerprint = "v1:ad43460c77c6b31ede9d5bcd3fced3fbfea7f91d2cb6443c65757a56f30b6c72";
var version = new Version(2, 0, 0);
using var settings = CreateSettingsJson();

// act
using (var archive = FusionArchive.Create(stream, leaveOpen: true))
{
await archive.SetArchiveMetadataAsync(CreateTestMetadata(), TestContext.Current.CancellationToken);
if (useBytes)
{
await archive.SetGatewayConfigurationAsync(
Encoding.UTF8.GetBytes(schema), settings, version, TestContext.Current.CancellationToken);
}
else
{
await archive.SetGatewayConfigurationAsync(schema, settings, version, TestContext.Current.CancellationToken);
}

await archive.CommitAsync(TestContext.Current.CancellationToken);
}

// assert
stream.Position = 0;
#if NET10_0_OR_GREATER
await using (var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true))
#else
using (var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true))
#endif
{
var entry = Assert.IsType<ZipArchiveEntry>(zip.GetEntry("gateway/2.0.0/planning-fingerprint"));
await using var content = entry.Open();
await using var bytes = new MemoryStream();
await content.CopyToAsync(bytes, TestContext.Current.CancellationToken);
Assert.Equal(Encoding.UTF8.GetBytes(fingerprint), bytes.ToArray());
}

stream.Position = 0;
using var reader = FusionArchive.Open(stream, leaveOpen: true);
using var configuration = Assert.IsType<GatewayConfiguration>(
await reader.TryGetGatewayConfigurationAsync(version, TestContext.Current.CancellationToken));
Assert.Equal(fingerprint, configuration.PlanningFingerprint);
}

[Fact]
public async Task SetGatewaySchema_WithoutMetadata_ThrowsInvalidOperationException()
{
Expand Down
Loading