Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ void IArgCallbackAnnotation.ForgetCachedResult()
}
}

bool IArgCallbackAnnotation.TryGetCachedResult(out Task<IList<object>>? result)
{
lock(_lock)
{
result = _callbackTask;
return result is not null;
}
}

private async Task<IList<object>> ExecuteCallbackAsync(CommandLineArgsCallbackContext context)
{
await Callback(context).ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,15 @@ void IEnvCallbackAnnotation.ForgetCachedResult()
}
}

bool IEnvCallbackAnnotation.TryGetCachedResult(out Task<Dictionary<string, object>>? result)
{
lock(_lock)
{
result = _callbackTask;
return result is not null;
}
}

private async Task<Dictionary<string, object>> ExecuteCallbackAsync(EnvironmentCallbackContext context)
{
await Callback(context).ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,25 @@ internal interface ICallbackResourceAnnotation<TContext, TResult>
/// <returns>The cached result of the callback evaluation.</returns>
Task<TResult> EvaluateOnceAsync(TContext context);

/// <summary>
/// Peeks at the already-cached callback result without ever executing the callback.
/// </summary>
/// <param name="result">
/// When this method returns <see langword="true"/>, the task previously produced by
/// <see cref="EvaluateOnceAsync"/>; otherwise <see langword="null"/>.
/// </param>
/// <returns>
/// <see langword="true"/> when a cached result exists; otherwise <see langword="false"/>.
/// </returns>
/// <remarks>
/// This is a read-only peek: unlike <see cref="EvaluateOnceAsync"/> it never invokes the callback and never
/// populates the cache. It exists so that read-only consumers (such as <c>aspire describe</c> observing live
/// resource snapshots) can inspect values that DCP has already resolved without racing DCP's own
/// cache lifecycle. Invoking the callback from such a consumer would run it with the consumer's cancellation
/// token and could cache a canceled or faulted task that DCP would later reuse on the resource's execution path.
/// </remarks>
bool TryGetCachedResult(out Task<TResult>? result);

/// <summary>
/// Clears the cached result so that the next call to <see cref="EvaluateOnceAsync"/> will re-execute the callback.
///</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@ void IArgCallbackAnnotation.ForgetCachedResult()
}
}

bool IArgCallbackAnnotation.TryGetCachedResult(out Task<IList<object>>? result)
{
lock (_lock)
{
result = _callbackTask;
return result is not null;
}
}

private async Task<IList<object>> ExecuteCallbackAsync(CommandLineArgsCallbackContext context)
{
await Callback(context).ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,18 @@ public sealed class ResourceDependencyDiscoveryOptions
/// on subsequent evaluations of the same annotation, rather than re-evaluating the callback each time.
/// </summary>
public bool CacheAnnotationCallbackResults { get; init; }

/// <summary>
/// When true, discovery reads only callback results that have already been cached by a prior
/// <see cref="ICallbackResourceAnnotation{TContext, TResult}.EvaluateOnceAsync"/> call and never invokes a
/// callback itself. Annotations without a completed cached result are skipped.
/// </summary>
/// <remarks>
/// This is for read-only consumers (such as <c>aspire describe</c> inspecting live resource snapshots) that
/// must not run resource callbacks. Invoking a callback from such a consumer would execute it with the
/// consumer's cancellation token and could poison DCP's cache with a canceled or faulted task that DCP later
/// reuses on the resource's execution path. When this is set, <see cref="CacheAnnotationCallbackResults"/> has
/// no effect because nothing is executed or newly cached.
/// </remarks>
internal bool PeekCachedCallbackResultsOnly { get; init; }
}
87 changes: 65 additions & 22 deletions src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ internal static async ValueTask<List<object>> GatherArgumentValuesWithoutCaching
executionContext,
logger,
cacheAnnotationCallbackResult: false,
peekCachedResultOnly: false,
cancellationToken).ConfigureAwait(false);
args.InsertRange(0, launchToolArgs);

Expand All @@ -299,6 +300,7 @@ private static async ValueTask<IList<object>> GatherLaunchToolArgumentValuesAsyn
DistributedApplicationExecutionContext executionContext,
ILogger logger,
bool cacheAnnotationCallbackResult,
bool peekCachedResultOnly,
CancellationToken cancellationToken)
{
// Launch tool arguments run against an isolated list and do not apply to containers, matching
Expand All @@ -309,6 +311,15 @@ private static async ValueTask<IList<object>> GatherLaunchToolArgumentValuesAsyn
return [];
}

if (peekCachedResultOnly)
{
// Read-only discovery: never invoke the callback. Only surface a result DCP has already resolved
// and cached; skip anything still in flight, faulted, or canceled.
return annotation.AsCallbackAnnotation().TryGetCachedResult(out var cachedTask) && cachedTask!.IsCompletedSuccessfully
? cachedTask.Result
: [];
}

var context = new CommandLineArgsCallbackContext([], resource, cancellationToken)
{
Logger = logger,
Expand Down Expand Up @@ -1630,52 +1641,83 @@ private static async Task<List<object>> GatherRawEnvironmentAndArgumentValuesAsy
// Gather environment variable values
if (resource.TryGetEnvironmentVariables(out var envAnnotations))
{
var envVars = new Dictionary<string, object>();
var context = new EnvironmentCallbackContext(executionContext, resource, envVars, cancellationToken: cancellationToken);

if (options.CacheAnnotationCallbackResults)
if (options.PeekCachedCallbackResultsOnly)
{
// Read-only discovery: never invoke a callback. Only harvest values DCP has already resolved
// and cached via EvaluateOnceAsync. Skip in-flight/faulted/canceled tasks so we never block
// describe on an unresolved value nor observe a poisoned task.
foreach (var ann in envAnnotations)
{
var resultingVars = await ann.AsCallbackAnnotation().EvaluateOnceAsync(context).ConfigureAwait(false);
rawValues.AddRange(resultingVars.Values);
if (ann.AsCallbackAnnotation().TryGetCachedResult(out var cachedTask) &&
cachedTask!.IsCompletedSuccessfully)
{
rawValues.AddRange(cachedTask.Result.Values);
}
}

}
else
{
foreach (var ann in envAnnotations)
var envVars = new Dictionary<string, object>();
var context = new EnvironmentCallbackContext(executionContext, resource, envVars, cancellationToken: cancellationToken);

if (options.CacheAnnotationCallbackResults)
{
await ann.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 argAnnotations))
{
var args = new List<object>();
var context = new CommandLineArgsCallbackContext(args, resource, cancellationToken)
{
ExecutionContext = executionContext
};

if (options.CacheAnnotationCallbackResults)
if (options.PeekCachedCallbackResultsOnly)
{
foreach (var ann in argAnnotations)
{
var resultingArgs = await ann.AsCallbackAnnotation().EvaluateOnceAsync(context).ConfigureAwait(false);
rawValues.AddRange(resultingArgs);
if (ann.AsCallbackAnnotation().TryGetCachedResult(out var cachedTask) &&
cachedTask!.IsCompletedSuccessfully)
{
rawValues.AddRange(cachedTask.Result);
}
}
}
else
{
foreach (var ann in argAnnotations)
var args = new List<object>();
var context = new CommandLineArgsCallbackContext(args, resource, cancellationToken)
{
ExecutionContext = executionContext
};

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

Expand All @@ -1684,6 +1726,7 @@ private static async Task<List<object>> GatherRawEnvironmentAndArgumentValuesAsy
executionContext,
NullLogger.Instance,
options.CacheAnnotationCallbackResults,
options.PeekCachedCallbackResultsOnly,
cancellationToken).ConfigureAwait(false);
rawValues.AddRange(launchToolArgs);

Expand Down
Loading
Loading