Skip to content

Common code for caching systems (Draft)#4729

Closed
Zombach wants to merge 12 commits intomicrosoft:mainfrom
Zombach:zombach/generic-code-for-cache
Closed

Common code for caching systems (Draft)#4729
Zombach wants to merge 12 commits intomicrosoft:mainfrom
Zombach:zombach/generic-code-for-cache

Conversation

@Zombach
Copy link
Copy Markdown
Contributor

@Zombach Zombach commented Jun 29, 2024

Common code for caching systems

Redis
Garnet
Valkey

And others

In connection with this task #4519 Add redis commander

Idea based on this post #4519 message

Microsoft Reviewers: Open in CodeFlow

@ghost ghost added the area-codeflow for labeling automated codeflow. intentionally a different color! label Jun 29, 2024
@dotnet-policy-service dotnet-policy-service bot added the community-contribution Indicates that the PR has been added by a community member label Jun 29, 2024
@Zombach
Copy link
Copy Markdown
Contributor Author

Zombach commented Jun 29, 2024

@davidfowl
@mitchdenny
@eerhardt

Hi, could you please rate this?
Any ideas or suggestions? Adjustments?
Is it worth continuing?
After this task, I also want to add Redis commander

@ReubenBond
Copy link
Copy Markdown
Member

Is this common to caching systems or is it common to systems supporting Redis' protocol?

@Zombach
Copy link
Copy Markdown
Contributor Author

Zombach commented Jun 30, 2024

Is this common to caching systems or is it common to systems supporting Redis' protocol?

Currently systems supporting the Redis protocol

@dotnet-bot
Copy link
Copy Markdown
Contributor

‼️ Found issues ‼️

Project Coverage Type Expected Actual
Microsoft.Extensions.ServiceDiscovery Branch 81 79.31 🔻
Microsoft.Extensions.ServiceDiscovery Line 81 79.01 🔻

Full code coverage report: https://dev.azure.com/dnceng-public/public/_build/results?buildId=725441&view=codecoverage-tab

@mitchdenny
Copy link
Copy Markdown
Member

@Zombach I think that this is too big of a change (there are breaking changes here). We'd probably want to spin up an API proposal around this so we could get into the motivations and shape of API changes here before we get too much futher.

Obviously across things like Redis, Garnet, and Valkey there is a common protocol (currently) but I am not 100% sure that means that we should bind them together this way.

@eerhardt
Copy link
Copy Markdown
Member

eerhardt commented Jul 1, 2024

We'd probably want to spin up an API proposal around this so we could get into the motivations and shape of API changes here before we get too much futher.

+1

@danmoseley danmoseley added area-integrations Issues pertaining to Aspire Integrations packages and removed area-codeflow for labeling automated codeflow. intentionally a different color! labels Jul 2, 2024
@Zombach
Copy link
Copy Markdown
Contributor Author

Zombach commented Jul 2, 2024

@Zombach I think that this is too big of a change (there are breaking changes here). We'd probably want to spin up an API proposal around this so we could get into the motivations and shape of API changes here before we get too much futher.

Obviously across things like Redis, Garnet, and Valkey there is a common protocol (currently) but I am not 100% sure that means that we should bind them together this way.

I understand that the change is large and complex, but I made it big on purpose to test whether all the cases would pass the test. Or there will be some problems. But as I was convinced, everything works, and the tests passed.

As for the API, let's first focus on the option without RedisCommander.

I’ll write using Garnet as an example, it’s clear that for the Redis protocol everything is identical

At the moment, we are using three classes.

///This class contains constants
internal static class GarnetContainerImageTags
{
    public const string Registry = "ghcr.io";
    public const string Image = "microsoft/garnet";
    public const string Tag = "1.0";
}

I believe that GarnetContainerImageTags class should be created for each protocol, as it is now.

public class GarnetResource(string name) : ContainerResource(name), IResourceWithConnectionString
{
    internal const string PrimaryEndpointName = "tcp";

    private EndpointReference? _primaryEndpoint;

    /// <summary>
    /// Gets the primary endpoint for the Garnet server.
    /// </summary>
    public EndpointReference PrimaryEndpoint => _primaryEndpoint ??= new(this, PrimaryEndpointName);

    /// <summary>
    /// Gets the connection string expression for the Garnet server.
    /// </summary>
    public ReferenceExpression ConnectionStringExpression => 
        ReferenceExpression.Create(
            $"{PrimaryEndpoint.Property(EndpointProperty.Host)}:{PrimaryEndpoint.Property(EndpointProperty.Port)}");
}

This class should be made abstract and raised to a level, and inherited from it.
In the end you will be left with something like

/// <summary>
/// A resource that represents a Garnet resource independent of the hosting model.
/// </summary>
/// <param name="name">The name of the resource.</param>
public class GarnetResource(string name) : CacheResource(name);

Since RedisResource additionally uses the method.
RedisResource will look like this.

/// <summary>
/// A resource that represents a Redis resource independent of the hosting model.
/// </summary>
/// <param name="name">The name of the resource.</param>
public class RedisResource(string name) : CacheResource(name)
{
    /// <summary>
    /// Gets the connection string for the Redis server.
    /// </summary>
    /// <param name="cancellationToken"> A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
    /// <returns>A connection string for the redis server in the form "host:port".</returns>
    public ValueTask<string?> GetConnectionStringAsync(CancellationToken cancellationToken = default)
    {
        if (this.TryGetLastAnnotation<ConnectionStringRedirectAnnotation>(out var connectionStringAnnotation))
        {
            return connectionStringAnnotation.Resource.GetConnectionStringAsync(cancellationToken);
        }

        var referenceExpression = ReferenceExpression.Create(
            $"{PrimaryEndpoint.Property(EndpointProperty.Host)}:{PrimaryEndpoint.Property(EndpointProperty.Port)}");

        return referenceExpression.GetValueAsync(cancellationToken);
    }
}

Regarding GarnetBuilderExtensions

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Globalization;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Garnet;
using Aspire.Hosting.Utils;

namespace Aspire.Hosting;

/// <summary>
/// Provides extension methods for adding Garnet resources to the application model.
/// </summary>
public static class GarnetBuilderExtensions
{
    private const string GarnetContainerDataDirectory = "/data";

    /// <summary>
    /// Adds a Garnet container to the application model.
    /// </summary>
    /// <example>
    /// Use in application host
    /// <code lang="csharp">
    /// var builder = DistributedApplication.CreateBuilder(args);
    ///
    /// var garnet = builder.AddGarnet("garnet");
    /// var api = builder.AddProject&lt;Projects.Api&gt;("api)
    ///                  .WithReference(garnet);
    ///  
    /// builder.Build().Run(); 
    /// </code>
    /// </example>
    /// <example>
    /// Use in Api with Aspire.StackExchange.Redis
    /// <code lang="csharp">
    /// var builder = WebApplication.CreateBuilder(args);
    /// builder.AddRedisClient("garnet");
    ///
    /// var multiplexer = builder.Services.BuildServiceProvider()
    ///                                   .GetRequiredService&lt;IConnectionMultiplexer&gt;();
    /// 
    /// var db = multiplexer.GetDatabase();
    /// db.HashSet("key", [new HashEntry("hash", "value")]);
    /// var value = db.HashGet("key", "hash");
    /// </code>
    /// </example>
    /// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
    /// <param name="name">The name of the resource. This name will be used as the connection string name when referenced in a dependency.</param>
    /// <param name="port">The host port to bind the underlying container to.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
    public static IResourceBuilder<GarnetResource> AddGarnet(this IDistributedApplicationBuilder builder, string name,
        int? port = null)
    {
        var garnet = new GarnetResource(name);
        return builder.AddResource(garnet)
            .WithEndpoint(port: port, targetPort: 6379, name: GarnetResource.PrimaryEndpointName)
            .WithImage(GarnetContainerImageTags.Image, GarnetContainerImageTags.Tag)
            .WithImageRegistry(GarnetContainerImageTags.Registry);
    }

    /// <summary>
    /// Adds a named volume for the data folder to a Garnet container resource and enables Garnet persistence.
    /// </summary>
    /// <example>
    /// Use <see cref="WithPersistence(IResourceBuilder{GarnetResource}, TimeSpan?, long)"/> to adjust Garnet persistence configuration, e.g.:
    /// <code lang="csharp">
    /// var cache = builder.AddGarnet("cache")
    ///                    .WithDataVolume()
    ///                    .WithPersistence(TimeSpan.FromSeconds(10), 5);
    /// </code>
    /// </example>
    /// <param name="builder">The resource builder.</param>
    /// <param name="name">The name of the volume. Defaults to an auto-generated name based on the application and resource names.</param>
    /// <param name="isReadOnly">
    /// A flag that indicates if this is a read-only volume. Setting this to <c>true</c> will disable Garnet persistence.<br/>
    /// Defaults to <c>false</c>.
    /// </param>
    /// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
    public static IResourceBuilder<GarnetResource> WithDataVolume(this IResourceBuilder<GarnetResource> builder,
        string? name = null, bool isReadOnly = false)
    {
        builder.WithVolume(name ?? VolumeNameGenerator.CreateVolumeName(builder, "data"), GarnetContainerDataDirectory,
            isReadOnly);
        if (!isReadOnly)
        {
            builder.WithPersistence();
        }

        return builder;
    }

    /// <summary>
    /// Adds a bind mount for the data folder to a Garnet container resource and enables Garnet persistence.
    /// </summary>
    /// <example>
    /// Use <see cref="WithPersistence(IResourceBuilder{GarnetResource}, TimeSpan?, long)"/> to adjust Garnet persistence configuration, e.g.:
    /// <code lang="csharp">
    /// var garnet = builder.AddGarnet("garnet")
    ///                    .WithDataBindMount()
    ///                    .WithPersistence(TimeSpan.FromSeconds(10), 5);
    /// </code>
    /// </example>
    /// <param name="builder">The resource builder.</param>
    /// <param name="source">The source directory on the host to mount into the container.</param>
    /// <param name="isReadOnly">
    /// A flag that indicates if this is a read-only mount. Setting this to <c>true</c> will disable Garnet persistence.<br/>
    /// Defaults to <c>false</c>.
    /// </param>
    /// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
    public static IResourceBuilder<GarnetResource> WithDataBindMount(this IResourceBuilder<GarnetResource> builder,
        string source, bool isReadOnly = false)
    {
        builder.WithBindMount(source, GarnetContainerDataDirectory, isReadOnly);
        if (!isReadOnly)
        {
            builder.WithPersistence();
        }

        return builder;
    }

    /// <summary>
    /// Configures a Garnet container resource for persistence.
    /// </summary>
    /// <example>
    /// Use with <see cref="WithDataBindMount(IResourceBuilder{GarnetResource}, string, bool)"/>
    /// or <see cref="WithDataVolume(IResourceBuilder{GarnetResource}, string?, bool)"/> to persist Garnet data across sessions with custom persistence configuration, e.g.:
    /// <code lang="csharp">
    /// var cache = builder.AddGarnet("cache")
    ///                    .WithDataVolume()
    ///                    .WithPersistence(TimeSpan.FromSeconds(10), 5);
    /// </code>
    /// </example>
    /// <param name="builder">The resource builder.</param>
    /// <param name="interval">The interval between snapshot exports. Defaults to 60 seconds.</param>
    /// <param name="keysChangedThreshold">The number of key change operations required to trigger a snapshot at the interval. Defaults to 1.</param>
    /// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
    public static IResourceBuilder<GarnetResource> WithPersistence(this IResourceBuilder<GarnetResource> builder,
        TimeSpan? interval = null, long keysChangedThreshold = 1)
        => builder.WithAnnotation(new CommandLineArgsCallbackAnnotation(context =>
        {
            context.Args.Add("--save");
            context.Args.Add((interval ?? TimeSpan.FromSeconds(60)).TotalSeconds.ToString(CultureInfo.InvariantCulture));
            context.Args.Add(keysChangedThreshold.ToString(CultureInfo.InvariantCulture));
            return Task.CompletedTask;
        }), ResourceAnnotationMutationBehavior.Replace);
}

In this case, we also raise all methods to the main one, but modify them a little.
AddGarnet exceptions which we change to AddCache
We don't really change the internals of the methods themselves.
But we make them universal with the help of generics.

public static IResourceBuilder<T> AddCache<T>(this IDistributedApplicationBuilder builder,
    T cacheResource,
    string registry,
    string image,
    string tag,
    int targetPort,
    int? port = null)
    where T : CacheResource
    => builder.AddResource(cacheResource)
        .WithEndpoint(port: port, targetPort: targetPort, name: CacheResource.PrimaryEndpointName)
        .WithImage(image, tag)
        .WithImageRegistry(registry);

public static IResourceBuilder<T> WithDataVolume<T>(this IResourceBuilder<T> builder,
    string target,
    string? name = null,
    bool isReadOnly = false)
where T : CacheResource
{
    builder.WithVolume(name ?? VolumeNameGenerator.CreateVolumeName(builder, "data"), target,
        isReadOnly);
    if (!isReadOnly)
    {
        builder.WithPersistence();
    }

    return builder;
}

public static IResourceBuilder<T> WithDataBindMount<T>(this IResourceBuilder<T> builder,
    string source,
    string target,
    bool isReadOnly = false)
where T : CacheResource
{
    builder.WithBindMount(source, target, isReadOnly);
    if (!isReadOnly)
    {
        builder.WithPersistence();
    }

    return builder;
}

public static IResourceBuilder<T> WithPersistence<T>(this IResourceBuilder<T> builder,
    TimeSpan? interval = null,
    long keysChangedThreshold = 1)
where T : CacheResource
    => builder.WithAnnotation(new CommandLineArgsCallbackAnnotation(context =>
    {
        context.Args.Add("--save");
        context.Args.Add((interval ?? TimeSpan.FromSeconds(60)).TotalSeconds.ToString(CultureInfo.InvariantCulture));
        context.Args.Add(keysChangedThreshold.ToString(CultureInfo.InvariantCulture));
        return Task.CompletedTask;
    }), ResourceAnnotationMutationBehavior.Replace);

And finally, in each Redis protocol cache. we can use this kind of class.

public static IResourceBuilder<GarnetResource> AddGarnet(this IDistributedApplicationBuilder builder,
    string name,
    int? port = null)
{
    var garnetResource = new GarnetResource(name);
    return builder.AddCache(garnetResource,
        GarnetContainerImageTags.Registry,
        GarnetContainerImageTags.Image,
        GarnetContainerImageTags.Tag,
        6379,
        port);
}

public static IResourceBuilder<GarnetResource> WithDataVolume(this IResourceBuilder<GarnetResource> builder,
    string? name = null,
    bool isReadOnly = false)
    => builder.WithDataVolume(GarnetContainerDataDirectory, name, isReadOnly);

public static IResourceBuilder<GarnetResource> WithDataBindMount(this IResourceBuilder<GarnetResource> builder,
    string source,
    bool isReadOnly = false)
    => builder.WithDataBindMount(source, GarnetContainerDataDirectory, isReadOnly);

public static IResourceBuilder<GarnetResource> WithPersistence(this IResourceBuilder<GarnetResource> builder,
    TimeSpan? interval = null,
    long keysChangedThreshold = 1)
    => CacheBuilderExtensions.WithPersistence(builder, interval, keysChangedThreshold);

I see it all roughly like this.
If I'm wrong, please correct me.

If you need any more examples, please let me know as well.

If you need examples specifically about the API, then please provide at least some example so that I can understand in what form to provide them to you.

Thank you for your time!
I hope this message will help in some way.

@mitchdenny
Copy link
Copy Markdown
Member

@Zombach thanks for the detailed response.

Even though Garnet, Redis, and Valkey (and some other spinoffs from Redis) all implement the same protocol (not 100% but close enough for this conversation) the way that the containers are configured differ. My guess is that container configuration is the thing that is most likely to diverge when it comes to these Redis-like cache APIs.

Given this I'm not sure it makes sense to try and converge the hosting packages in the way that you have here because once bonded together it can become more difficult to unpick down the track. A small amount of duplicated code now provides some options when the (in my opinion) inevitable happens.

@eerhardt
Copy link
Copy Markdown
Member

eerhardt commented Jul 8, 2024

In #4519 (comment), @davidfowl says:

We can share source, we do this today for other things in the apphost (the volume generator), seems like a cheaper way to share this as well.

What he meant is that for common, internal code we can compile the same code into multiple libraries, so we don't need the duplication. For example, https://github.com/dotnet/aspire/blob/main/src/Shared/VolumeNameGenerator.cs is compiled into a few different libraries:

https://github.com/dotnet/aspire/blob/9a7f50c5f5a86d9d995f3cc94dc9b984e6ea4617/src/Aspire.Hosting.Redis/Aspire.Hosting.Redis.csproj#L16

https://github.com/dotnet/aspire/blob/9a7f50c5f5a86d9d995f3cc94dc9b984e6ea4617/src/Aspire.Hosting.RabbitMQ/Aspire.Hosting.RabbitMQ.csproj#L15

Sharing internal code doesn't need any change to public API. We can leave the public API as it is today, but underneath share code across all 3 libraries.

@danmoseley
Copy link
Copy Markdown
Member

Cc @mgravell

@mgravell
Copy link
Copy Markdown

mgravell commented Jul 8, 2024

Coming in a little cold, but to me the key factor would be how much of the relevant code here is infrastructure/hosting, vs how much is runtime/monitoring. The latter: makes a lot of sense to share (and if the ship hasn't already sailed, I might suggest leaning on Resp/RESP nomenclature rather than Redis nomenclature). But @mitchdenny hints that a significant portion is the hosting, which will absolutely be bespoke per provider (although Redis and ValKey obviously have a lot of overlap, Garnet and all of the other red-ish backends will be much more varied).

@Zombach
Copy link
Copy Markdown
Contributor Author

Zombach commented Jul 9, 2024

In #4519 (comment), @davidfowl says:

We can share source, we do this today for other things in the apphost (the volume generator), seems like a cheaper way to share this as well.

What he meant is that for common, internal code we can compile the same code into multiple libraries, so we don't need the duplication. For example, https://github.com/dotnet/aspire/blob/main/src/Shared/VolumeNameGenerator.cs is compiled into a few different libraries:

https://github.com/dotnet/aspire/blob/9a7f50c5f5a86d9d995f3cc94dc9b984e6ea4617/src/Aspire.Hosting.Redis/Aspire.Hosting.Redis.csproj#L16

https://github.com/dotnet/aspire/blob/9a7f50c5f5a86d9d995f3cc94dc9b984e6ea4617/src/Aspire.Hosting.RabbitMQ/Aspire.Hosting.RabbitMQ.csproj#L15

Sharing internal code doesn't need any change to public API. We can leave the public API as it is today, but underneath share code across all 3 libraries.

Now I got it right. Thanks for the explanation.

@Zombach
Copy link
Copy Markdown
Contributor Author

Zombach commented Jul 9, 2024

@Zombach thanks for the detailed response.

Even though Garnet, Redis, and Valkey (and some other spinoffs from Redis) all implement the same protocol (not 100% but close enough for this conversation) the way that the containers are configured differ. My guess is that container configuration is the thing that is most likely to diverge when it comes to these Redis-like cache APIs.

Given this I'm not sure it makes sense to try and converge the hosting packages in the way that you have here because once bonded together it can become more difficult to unpick down the track. A small amount of duplicated code now provides some options when the (in my opinion) inevitable happens.

You are absolutely right and I completely agree with you.
After all, not every attempt has to be successful!

I'll still try to combine the common part, but as said @davidfowl and @eerhardt. If it doesn’t work out, then I’ll just close this PR.

Thanks again everyone!

@Zombach
Copy link
Copy Markdown
Contributor Author

Zombach commented Jul 9, 2024

Coming in a little cold, but to me the key factor would be how much of the relevant code here is infrastructure/hosting, vs how much is runtime/monitoring. The latter: makes a lot of sense to share (and if the ship hasn't already sailed, I might suggest leaning on Resp/RESP nomenclature rather than Redis nomenclature). But @mitchdenny hints that a significant portion is the hosting, which will absolutely be bespoke per provider (although Redis and ValKey obviously have a lot of overlap, Garnet and all of the other red-ish backends will be much more varied).

Yes, thanks for the comment, in general I understand it. But I don’t yet have enough experience to see this in advance and not take unnecessary actions. From my side. I can only say one thing. Thank you @mitchdenny and @eerhardt for bearing with me and sharing your experiences.

@mitchdenny
Copy link
Copy Markdown
Member

No ... thank-you for contributing to the Aspire code base!

@eerhardt
Copy link
Copy Markdown
Member

Thanks for the contribution, @Zombach.

I'm going to close this PR now as it is stale, and I don't think we want to make this change.

@eerhardt eerhardt closed this Jul 22, 2024
@Zombach Zombach deleted the zombach/generic-code-for-cache branch July 22, 2024 17:04
@github-actions github-actions bot locked and limited conversation to collaborators Aug 22, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-integrations Issues pertaining to Aspire Integrations packages community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants