Skip to content
Open
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 @@ -13,18 +13,24 @@ internal class ArgumentsExecutionConfigurationGatherer : IExecutionConfiguration
/// <inheritdoc/>
public async ValueTask GatherAsync(IExecutionConfigurationGathererContext context, IResource resource, ILogger resourceLogger, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken = default)
{
if (resource.TryGetAnnotationsOfType<CommandLineArgsCallbackAnnotation>(out var callbacks))
if (resource.TryGetAnnotationsOfType<CommandLineArgsCallbackAnnotation>(out var argumentAnnotations))
{
var callbackContext = new CommandLineArgsCallbackContext(context.Arguments, resource, cancellationToken)
IList<object> args = [.. context.Arguments];
var callbackContext = new CommandLineArgsCallbackContext(args, resource, cancellationToken)
{
Logger = resourceLogger,
ExecutionContext = executionContext
};

foreach (var callback in callbacks)
foreach (var ann in argumentAnnotations)
{
await callback.Callback(callbackContext).ConfigureAwait(false);
// Each annotation operates on a shared context.
args = await ann.AsCallbackAnnotation().EvaluateOnceAsync(callbackContext).ConfigureAwait(false);
}

// Take the final result and apply to the gatherer context.
context.Arguments.Clear();
context.Arguments.AddRange(args);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;

namespace Aspire.Hosting.ApplicationModel;

using IArgCallbackAnnotation = ICallbackResourceAnnotation<CommandLineArgsCallbackContext, IList<object>>;

/// <summary>
/// Represents an annotation that provides a callback to be executed with a list of command-line arguments when an executable resource is started.
/// </summary>
public class CommandLineArgsCallbackAnnotation : IResourceAnnotation
public class CommandLineArgsCallbackAnnotation : IResourceAnnotation, IArgCallbackAnnotation
{
private Task<IList<object>>? _callbackTask;
private readonly object _lock = new();

/// <summary>
/// Initializes a new instance of the <see cref="CommandLineArgsCallbackAnnotation"/> class with the specified callback action.
/// </summary>
Expand Down Expand Up @@ -41,6 +47,35 @@ public CommandLineArgsCallbackAnnotation(Action<IList<object>> callback)
/// Gets the callback action to be executed when the executable arguments are parsed.
/// </summary>
public Func<CommandLineArgsCallbackContext, Task> Callback { get; }

internal IArgCallbackAnnotation AsCallbackAnnotation() => this;

Task<IList<object>> IArgCallbackAnnotation.EvaluateOnceAsync(CommandLineArgsCallbackContext context)
{
lock(_lock)
{
if (_callbackTask is null)
{
_callbackTask = ExecuteCallbackAsync(context);
}
return _callbackTask;
}
}

void IArgCallbackAnnotation.ForgetCachedResult()
{
lock(_lock)
{
_callbackTask = null;
}
}

private async Task<IList<object>> ExecuteCallbackAsync(CommandLineArgsCallbackContext context)
{
await Callback(context).ConfigureAwait(false);
var result = context.Args.ToImmutableList();
return result;
}
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@

namespace Aspire.Hosting.ApplicationModel;

using IEnvCallbackAnnotation = ICallbackResourceAnnotation<EnvironmentCallbackContext, Dictionary<string, object>>;

/// <summary>
/// Represents an annotation that provides a callback to modify the environment variables of an application.
/// </summary>
[DebuggerDisplay("{DebuggerToString(),nq}")]
public class EnvironmentCallbackAnnotation : IResourceAnnotation
public class EnvironmentCallbackAnnotation : IResourceAnnotation, IEnvCallbackAnnotation
{
private readonly string? _name;
private Task<Dictionary<string, object>>? _callbackTask;
private readonly object _lock = new();

/// <summary>
/// Initializes a new instance of the <see cref="EnvironmentCallbackAnnotation"/> class with the specified name and callback function.
Expand Down Expand Up @@ -77,6 +81,35 @@ public EnvironmentCallbackAnnotation(Func<EnvironmentCallbackContext, Task> call
/// </summary>
public Func<EnvironmentCallbackContext, Task> Callback { get; private set; }

internal IEnvCallbackAnnotation AsCallbackAnnotation() => this;

Task<Dictionary<string, object>> IEnvCallbackAnnotation.EvaluateOnceAsync(EnvironmentCallbackContext context)
{
lock(_lock)
{
if (_callbackTask is null)
{
_callbackTask = ExecuteCallbackAsync(context);
}
return _callbackTask;
}
}

void IEnvCallbackAnnotation.ForgetCachedResult()
{
lock(_lock)
{
_callbackTask = null;
}
}

private async Task<Dictionary<string, object>> ExecuteCallbackAsync(EnvironmentCallbackContext context)
{
await Callback(context).ConfigureAwait(false);
var result = new Dictionary<string, object>(context.EnvironmentVariables);
return result;
}

private string DebuggerToString()
{
var text = $@"Type = {GetType().Name}";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,24 @@ internal class EnvironmentVariablesExecutionConfigurationGatherer : IExecutionCo
/// <inheritdoc/>
public async ValueTask GatherAsync(IExecutionConfigurationGathererContext context, IResource resource, ILogger resourceLogger, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken = default)
{
if (resource.TryGetEnvironmentVariables(out var callbacks))
if (resource.TryGetEnvironmentVariables(out var envVarAnnotations))
{
var callbackContext = new EnvironmentCallbackContext(executionContext, resource, context.EnvironmentVariables, cancellationToken)
var envVars = new Dictionary<string, object>(context.EnvironmentVariables);
var callbackContext = new EnvironmentCallbackContext(executionContext, resource, envVars, cancellationToken: cancellationToken)
{
Logger = resourceLogger,
};

foreach (var callback in callbacks)
foreach (var ann in envVarAnnotations)
{
await callback.Callback(callbackContext).ConfigureAwait(false);
// Each annotation operates on a shared context.
envVars = await ann.AsCallbackAnnotation().EvaluateOnceAsync(callbackContext).ConfigureAwait(false);
}

// Take the final result and apply to the gatherer context.
foreach (var kvp in envVars)
{
context.EnvironmentVariables[kvp.Key] = kvp.Value;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// Represents a resource annotation whose callback should be evaluated at most once,
/// with the result cached for subsequent retrievals.
/// </summary>
/// <typeparam name="TContext">The type of the context passed to the callback.</typeparam>
/// <typeparam name="TResult">The type of the result produced by the callback.</typeparam>
internal interface ICallbackResourceAnnotation<TContext, TResult>
{
/// <summary>
/// Evaluates the callback if it has not been evaluated yet, caching the result.
/// Subsequent calls return the cached result regardless of the context passed.
/// </summary>
/// <param name="context">The context for the callback evaluation. Only used on the first call.</param>
/// <returns>The cached result of the callback evaluation.</returns>
Task<TResult> EvaluateOnceAsync(TContext context);

/// <summary>
/// Clears the cached result so that the next call to <see cref="EvaluateOnceAsync"/> will re-execute the callback.
///</summary>
/// <remarks>
/// Use <see cref="ForgetCachedResult"/> when a resource decorated with this callback annotation is restarted.
/// </remarks>
void ForgetCachedResult();
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,26 @@ namespace Aspire.Hosting.ApplicationModel;
/// <summary>
/// Specifies how resource dependencies are discovered.
/// </summary>
[Flags]
public enum ResourceDependencyDiscoveryMode
{
/// <summary>
/// Discover the full transitive closure of all dependencies.
/// This includes direct dependencies and all dependencies of those dependencies, recursively.
/// </summary>
Recursive,
Recursive = 1,

/// <summary>
/// Discover only direct dependencies.
/// This includes dependencies from annotations (parent, wait, connection string redirect)
/// and from environment variables and command-line arguments, but does not recurse
/// into the dependencies of those dependencies.
/// </summary>
DirectOnly
DirectOnly = 2,

/// <summary>
/// When set, unresolved values from annotation callbacks will be cached and reused
/// on subsequent evaluations of the same annotation, rather than re-evaluating the callback each time.
/// </summary>
CacheAnnotationCallbackResults = 4
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recursive = 0 in a [Flags] enum is error-prone

With Recursive = 0, anyValue.HasFlag(Recursive) always returns true. The code currently avoids this by checking !mode.HasFlag(DirectOnly), but the zero-value flags member is a trap for future callers.

Also, CacheAnnotationCallbackResults mixes an internal implementation detail (callback memoization) into what is a public API about dependency discovery modes. This flag is only consumed internally by DcpExecutor.GetHostDependenciesAsync. Consider making it a separate internal parameter instead.

Copy link
Member

@JamesNK JamesNK Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should CacheAnnotationCallbackResults be public? Will people other than us ever use it?

If not, you could remove it from the enum (and make it no longer flags) and have an extra bool on internal GetDependenciesAsync to control the behavior.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you are right, I was too clever here.

I decided to keep CacheAnnotationCallbackResults public. I think it will be useful for Aspire users. Main reason, if somebody wants to call GetResourceDependenciesAsync() during application startup sequence, it will work and won't break "call the callbacks once" promise as long as this flag is set.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still not a fan of making the enum a flags enum. DirectOnly and Recursive are opposites of each other. What happens when both are set?

Should the extra flag be a new parameter instead of adding to the mode enum and making it a flags enum?

Copy link
Contributor Author

@karolz-ms karolz-ms Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JamesNK You get an ArgumentException if you use DirectOnly and Recursive at the same time; I added that check after your initial review.

There are many examples of [Flags] enumerations in .NET standard library where certain combinations of flags are not allowed. For example, Globalization.NumberStyles, Globalization.DateTimeStyles, RegularExpressions.RegexOptions, Threading.Tasks.TaskContinuationOptions. Not a best practice by any means, but not super uncommon either. They all throw ArgumentException or ArgumentOutOfRangeException if invalid flag comibination is used.

In our case I think the benefits of having a single mode parameter outweight the drawback of having additional invocation parameter. Also, the current implementation allows us to easily accommodate a new primary mode that is not direct and not fully recursive, should a need for such thing arise in future.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be fair, a new major mode can be added equally well with split parameters

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be fair, another major mode can be added equally well with two separate parameters.

Copy link
Member

@JamesNK JamesNK Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see why it's preferable to squash options into enum when we don't have to.

Personally, I think the best option would be to add a new overload that just has an GetResourceDependenciesOptions class argument. The current overload would call it internally. It's a little less efficent because an object is allocated, but this isn't designed to be a high-perf API call.

On the options you can set the mode, and a bool flag CacheAnnotationCallbackResults to indicate whether results are cached or not. Any future arguments for GetResourceDependencies (this seems like an API that would get more) could then be added to the options without adding new overloads or breaking APIs. And the options class can be passed down through all the other methods.

68 changes: 49 additions & 19 deletions src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1256,7 +1256,7 @@ internal static ILogger GetLogger(this IResource resource, IServiceProvider serv
/// </summary>
/// <param name="resource">The resource to compute dependencies for.</param>
/// <param name="executionContext">The execution context for resolving environment variables and arguments.</param>
/// <param name="mode">Specifies whether to discover only direct dependencies or the full transitive closure.</param>
/// <param name="mode">Specifies dependency discovery mode.</param>
/// <param name="cancellationToken">A cancellation token to observe while computing dependencies.</param>
/// <returns>A set of all resources that the specified resource depends on.</returns>
/// <remarks>
Expand Down Expand Up @@ -1292,7 +1292,7 @@ public static Task<IReadOnlySet<IResource>> GetResourceDependenciesAsync(
/// </summary>
/// <param name="resources">The source set of resources to compute dependencies for.</param>
/// <param name="executionContext">The execution context for resolving environment variables and arguments.</param>
/// <param name="mode">Specifies whether to discover only direct dependencies or the full transitive closure.</param>
/// <param name="mode">Specifies dependency discovery mode.</param>
/// <param name="cancellationToken">A cancellation token to observe while computing dependencies.</param>
/// <returns>A set of all resources that the specified resource depends on.</returns>
/// <remarks>
Expand Down Expand Up @@ -1320,20 +1320,26 @@ internal static async Task<IReadOnlySet<IResource>> GetDependenciesAsync(
ResourceDependencyDiscoveryMode mode = ResourceDependencyDiscoveryMode.Recursive,
CancellationToken cancellationToken = default)
{
var modeOK = mode.HasFlag(ResourceDependencyDiscoveryMode.Recursive) ^ mode.HasFlag(ResourceDependencyDiscoveryMode.DirectOnly);
if (!modeOK)
{
throw new ArgumentException($"Exactly one of {nameof(ResourceDependencyDiscoveryMode.Recursive)} or {nameof(ResourceDependencyDiscoveryMode.DirectOnly)} must be set in the mode parameter.", nameof(mode));
}

var dependencies = new HashSet<IResource>();
var newDependencies = new HashSet<IResource>();
var toProcess = new Queue<IResource>();

foreach (var resource in resources)
{
newDependencies.Clear();
await GatherDirectDependenciesAsync(resource, dependencies, newDependencies, executionContext, cancellationToken).ConfigureAwait(false);
await GatherDirectDependenciesAsync(resource, dependencies, newDependencies, executionContext, mode, cancellationToken).ConfigureAwait(false);

if (mode == ResourceDependencyDiscoveryMode.Recursive)
if (mode.HasFlag(ResourceDependencyDiscoveryMode.Recursive))
{
// Compute transitive closure by recursively processing dependencies

foreach(var nd in newDependencies)
foreach (var nd in newDependencies)
{
toProcess.Enqueue(nd);
}
Expand All @@ -1343,7 +1349,7 @@ internal static async Task<IReadOnlySet<IResource>> GetDependenciesAsync(
var dep = toProcess.Dequeue();
newDependencies.Clear();

await GatherDirectDependenciesAsync(dep, dependencies, newDependencies, executionContext, cancellationToken).ConfigureAwait(false);
await GatherDirectDependenciesAsync(dep, dependencies, newDependencies, executionContext, mode, cancellationToken).ConfigureAwait(false);

foreach (var newDep in newDependencies)
{
Expand Down Expand Up @@ -1372,12 +1378,14 @@ internal static async Task<IReadOnlySet<IResource>> GetDependenciesAsync(
/// <param name="dependencies">The set of dependencies (where dependency resources will be placed).</param>
/// <param name="newDependencies">The set of newly discovered dependencies in this invocation (not present in <paramref name="dependencies"/> at the moment of invocation).</param>
/// <param name="executionContext">The execution context for resolving environment variables and arguments.</param>
/// <param name="mode">Specifies dependency discovery mode.</param>
/// <param name="cancellationToken">A cancellation token to observe while gathering dependencies.</param>
private static async Task GatherDirectDependenciesAsync(
IResource resource,
HashSet<IResource> dependencies,
HashSet<IResource> newDependencies,
DistributedApplicationExecutionContext executionContext,
ResourceDependencyDiscoveryMode mode,
CancellationToken cancellationToken)
{
var visited = new HashSet<object>();
Expand All @@ -1386,7 +1394,7 @@ private static async Task GatherDirectDependenciesAsync(
CollectAnnotationDependencies(resource, dependencies, newDependencies);

// Collect raw (unresolved) environment variable and argument values
var rawValues = await GatherRawEnvironmentAndArgumentValuesAsync(resource, executionContext, cancellationToken).ConfigureAwait(false);
var rawValues = await GatherRawEnvironmentAndArgumentValuesAsync(resource, executionContext, mode, cancellationToken).ConfigureAwait(false);

foreach (var value in rawValues)
{
Expand All @@ -1400,39 +1408,61 @@ private static async Task GatherDirectDependenciesAsync(
private static async Task<List<object>> GatherRawEnvironmentAndArgumentValuesAsync(
IResource resource,
DistributedApplicationExecutionContext executionContext,
ResourceDependencyDiscoveryMode mode,
CancellationToken cancellationToken)
{
var rawValues = new List<object>();

// Gather environment variable values
if (resource.TryGetEnvironmentVariables(out var environmentCallbacks))
if (resource.TryGetEnvironmentVariables(out var envAnnotations))
{
var envVars = new Dictionary<string, object>();
var context = new EnvironmentCallbackContext(executionContext, resource, envVars, cancellationToken);

foreach (var callback in environmentCallbacks)
var context = new EnvironmentCallbackContext(executionContext, resource, envVars, cancellationToken: cancellationToken);
if (mode.HasFlag(ResourceDependencyDiscoveryMode.CacheAnnotationCallbackResults))
{
await callback.Callback(context).ConfigureAwait(false);
foreach (var ann in envAnnotations)
{
var resultingVars = await ann.AsCallbackAnnotation().EvaluateOnceAsync(context).ConfigureAwait(false);
rawValues.AddRange(resultingVars.Values);
}

}
else
{
foreach (var ann in envAnnotations)
{
await ann.Callback(context).ConfigureAwait(false);
}
rawValues.AddRange(envVars.Values);
}

rawValues.AddRange(envVars.Values);
}

// Gather command-line argument values
if (resource.TryGetAnnotationsOfType<CommandLineArgsCallbackAnnotation>(out var argsCallbacks))
if (resource.TryGetAnnotationsOfType<CommandLineArgsCallbackAnnotation>(out var argAnnotations))
{
var args = new List<object>();
var context = new CommandLineArgsCallbackContext(args, resource, cancellationToken)
{
ExecutionContext = executionContext
};

foreach (var callback in argsCallbacks)
if (mode.HasFlag(ResourceDependencyDiscoveryMode.CacheAnnotationCallbackResults))
{
await callback.Callback(context).ConfigureAwait(false);
foreach (var ann in argAnnotations)
{
var resultingArgs = await ann.AsCallbackAnnotation().EvaluateOnceAsync(context).ConfigureAwait(false);
rawValues.AddRange(resultingArgs);
}
}
else
{
foreach (var ann in argAnnotations)
{
await ann.Callback(context).ConfigureAwait(false);
}
rawValues.AddRange(args);
}

rawValues.AddRange(args);
}

return rawValues;
Expand Down
Loading
Loading