graphql-platform icon indicating copy to clipboard operation
graphql-platform copied to clipboard

Fusion gateway schema hot reload not working with ConfigureFromFile

Open interad-woergoetter opened this issue 2 years ago • 6 comments

Is there an existing issue for this?

  • [X] I have searched the existing issues

Product

Hot Chocolate

Describe the bug

When I configure my fusion gateway server from a file, changes to the file don't take effect until the server is restarted.

services
    .AddFusionGatewayServer()
    .ConfigureFromFile("gateway.fgp", watchFileForUpdates: true)

The issue occurred in version 13.5.1. After downgrading to 13.2.1, when the configuration looked slightly differently, hot reload worked without needing to restart.

Steps to reproduce

  1. Set up a HotChocolate Fusion gateway server with services.AddFusionGatewayServer().ConfigureFromFile("gateway.fgp", watchFileForUpdates: true).
  2. Create a valid gateway.fgp file.
  3. Start the GraphQL Fusion server.
  4. Confirm in BananaCakePop that the schema was loaded correctly from the gateway.fgp file.
  5. Replace the gateway.fgp file with a different valid configuration.
  6. Reload the schema in BananaCakePop and notice that the schema hasn't changed.
  7. Restart the GraphQL Fusion server.
  8. Confirm that BananaCakePop now shows the new schema.

Relevant log output

No response

Additional Context?

.ConfigureFromCloud() seems to work. So I assume the error might be related to the file watcher.

Version

13.5.1

interad-woergoetter avatar Sep 01 '23 11:09 interad-woergoetter

Just wanted to add that we have the same issue. I can even delete the gateway.fgp file and it keeps working.

However if I restart it after deleting the file it can't load the schema, so I'm sure that the same file is being loaded and the correct directory is being used.

jeroenbongers avatar Jan 03 '24 08:01 jeroenbongers

@michaelstaib any update on this? This problem has been in the package since sep 2023, was wondering when this will be scheduled or fixed.

Right now I don't think there is any out of the box way to make it work, exect for the hochocolate cloud or by restarting the entire gateway every time.

furystormss avatar Mar 07 '25 22:03 furystormss

Right now I don't think there is any out of the box way to make it work, exect for the hochocolate cloud or by restarting the entire gateway every time.

@furystormss for us, we decided that restarting the gateway is a sufficient workaround until someone gets around to fixing the bug. As far as I know, hot reloading works on Mac, but not on Windows. So switching OS could fix it for you xD

If that is not an option for you, you can implement your own file watcher and use the ConfigureFromDocument() method instead of ConfigureFromFile() You can use ChilliCreams implementation of the https://github.com/ChilliCream/graphql-platform/blob/14.3.0/src/HotChocolate/Fusion/src/Core/DependencyInjection/GatewayConfigurationFileObserver.cs as inspiration. Just be aware that the bug causing this issue is also somewhere in that class or its dependencies, I think 😅 Although, now that I think about it, the bug could also be somewhere else in which case, writing your own file watcher and registering it with ConfigureFromDocument wouldn't even help.

I heard HC v15 will come with a lot of changes for Fusion. Maybe this will be fixed too.

interad-woergoetter avatar Mar 10 '25 12:03 interad-woergoetter

As far as I know, hot reloading works on Mac,

Doesn't work on Linux either!

mjaric avatar Mar 24 '25 14:03 mjaric

Same issue here. It looks like at lease the file-watcher works, but the schema does not get refreshed.

g-hausammann avatar May 06 '25 07:05 g-hausammann

This error seems to happen because the file is locked. I wrote a custom implementation, just changing the loading of the document to handle this. This works usually on the third load attempt and is working great for us. :)

Startup.cs

builder.Services
    .AddFusionGatewayServer()
    .RegisterGatewayConfiguration(
        _ => new CustomFileWatcher("./gateway.fgp"))

CustomFileWatcher.cs

using HotChocolate.Fusion;
using HotChocolate.Language;
using HotChocolate.Utilities;

namespace namespace;

public class CustomFileWatcher(string fileName) : IObservable<GatewayConfiguration>
{
    public IDisposable Subscribe(IObserver<GatewayConfiguration> observer)
        => new CustomFileConfigurationSession(observer, fileName);
}

public class CustomFileConfigurationSession : IDisposable
{
    private readonly IObserver<GatewayConfiguration> _observer;
    private readonly string _fileName;
    private readonly FileSystemWatcher _watcher;

    public CustomFileConfigurationSession(IObserver<GatewayConfiguration> observer, string fileName)
    {
        var fullPath = System.IO.Path.GetFullPath(fileName);
        var directory = System.IO.Path.GetDirectoryName(fullPath) ?? throw new ArgumentException($"Could not find {fileName}");

        _observer = observer;
        _fileName = fullPath;

        BeginLoadConfig();

        _watcher = new FileSystemWatcher
        {
            Path = directory,
            Filter = "*.*",

            NotifyFilter =
                NotifyFilters.FileName |
                NotifyFilters.DirectoryName |
                NotifyFilters.Attributes |
                NotifyFilters.CreationTime |
                NotifyFilters.FileName |
                NotifyFilters.LastWrite |
                NotifyFilters.Size
        };

        _watcher.Created += (_, e) =>
        {
            if (fullPath.EqualsOrdinal(e.FullPath))
            {
                BeginLoadConfig();
            }
        };

        _watcher.Changed += (_, e) =>
        {
            if (fullPath.EqualsOrdinal(e.FullPath))
            {
                BeginLoadConfig();
            }
        };

        _watcher.EnableRaisingEvents = true;
    }
    private void BeginLoadConfig()
        => LoadConfig().FireAndForget();

    private async Task LoadConfig()
    {
        int retryCount = 0;
        int maxRetries = 20;
        int delayMs = 100; // Start with a small delay

        while (retryCount < maxRetries)
        {
            try
            {
                // Check if file is locked
                if (IsFileLocked(_fileName))
                {
                    await Task.Delay(delayMs);
                    delayMs *= 2; // Exponential backoff
                    retryCount++;
                    continue;
                }

                var document = await GatewayConfigurationFileUtils.LoadDocumentAsync(_fileName, CancellationToken.None);
                _observer.OnNext(new GatewayConfiguration(document));
                Console.WriteLine("Configuration loaded successfully");
                return;
            }
            catch (IOException ioEx) when (IsFileLockedException(ioEx))
            {
                Console.WriteLine($"File access error (likely locked): {ioEx.Message}. Retry {retryCount + 1}/{maxRetries}...");
                await Task.Delay(delayMs);
                delayMs *= 2; // Exponential backoff
                retryCount++;
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Failed to load configuration: {ex.Message}");
                await Task.Delay(1000); // Delay one second before retrying on general errors
                LoadConfig().FireAndForget();
                return;
            }
        }

        Console.WriteLine($"Failed to load configuration after {maxRetries} attempts due to file locks");
    }
    private static bool IsFileLocked(string filePath)
    {
        try
        {
            using var fileStream = new FileStream(
                filePath,
                FileMode.Open,
                FileAccess.ReadWrite,
                FileShare.None);
            return false; // File is not locked
        }
        catch (IOException)
        {
            return true; // File is locked
        }
    }

    private static bool IsFileLockedException(IOException exception)
    {
        int errorCode = exception.HResult & 0xFFFF;
        return errorCode == 32 || // ERROR_SHARING_VIOLATION
               errorCode == 33;   // ERROR_LOCK_VIOLATION
    }

    public void Dispose()
        => _watcher.Dispose();
}


internal static class GatewayConfigurationFileUtils
{
    public static async ValueTask<DocumentNode> LoadDocumentAsync(
        string fileName,
        CancellationToken cancellationToken)
    {
        try
        {
            // We first try to load the file name as a fusion graph package.
            // This might fail as the file that was provided is a fusion graph document.
            await using var package = FusionGraphPackage.Open(fileName, FileAccess.Read);
            return await package.GetFusionGraphAsync(cancellationToken);
        }
        catch
        {
            // If we fail to load the file as a fusion graph package we will
            // try to load it as a GraphQL schema document.
            var sourceText = await File.ReadAllTextAsync(fileName, cancellationToken);
            return Utf8GraphQLParser.Parse(sourceText);
        }
    }
}

linusnyren avatar May 23 '25 13:05 linusnyren

Same issue with HC 13.9.14 on Windows.

hero3616 avatar Nov 12 '25 00:11 hero3616

I am closing this issue as we have a new implementation of the configuration observer in version 16

michaelstaib avatar Nov 18 '25 14:11 michaelstaib