Skip to content

Commit 397fea0

Browse files
Stefan Pennerclaude
andcommitted
Batch and deduplicate action resolution across composite depths
Thread a cache through PrepareActionsRecursiveAsync so the same action is resolved at most once regardless of depth. Collect sub-actions from all sibling composites and resolve them in one API call instead of one per composite. ~30-composite internal workflow went from ~20 resolve calls to 3-4. Fixes #3731 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c7f6c49 commit 397fea0

File tree

2 files changed

+523
-10
lines changed

2 files changed

+523
-10
lines changed

src/Runner.Worker/ActionManager.cs

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ public sealed class ActionManager : RunnerService, IActionManager
6666
private readonly Dictionary<Guid, Stack<Pipelines.ActionStep>> _cachedEmbeddedPostSteps = new();
6767
public Dictionary<Guid, Stack<Pipelines.ActionStep>> CachedEmbeddedPostSteps => _cachedEmbeddedPostSteps;
6868

69+
6970
public async Task<PrepareResult> PrepareActionsAsync(IExecutionContext executionContext, IEnumerable<Pipelines.JobStep> steps, Guid rootStepId = default(Guid))
7071
{
7172
// Assert inputs
@@ -79,6 +80,9 @@ public sealed class ActionManager : RunnerService, IActionManager
7980
PreStepTracker = new Dictionary<Guid, IActionRunner>()
8081
};
8182
var containerSetupSteps = new List<JobExtensionRunner>();
83+
// Stack-local cache: same action (owner/repo@ref) is resolved only once,
84+
// even if it appears at multiple depths in a composite tree.
85+
var resolvedDownloadInfos = new Dictionary<string, WebApi.ActionDownloadInfo>(StringComparer.Ordinal);
8286
var depth = 0;
8387
// We are running at the start of a job
8488
if (rootStepId == default(Guid))
@@ -105,7 +109,7 @@ public sealed class ActionManager : RunnerService, IActionManager
105109
PrepareActionsState result = new PrepareActionsState();
106110
try
107111
{
108-
result = await PrepareActionsRecursiveAsync(executionContext, state, actions, depth, rootStepId);
112+
result = await PrepareActionsRecursiveAsync(executionContext, state, actions, resolvedDownloadInfos, depth, rootStepId);
109113
}
110114
catch (FailedToResolveActionDownloadInfoException ex)
111115
{
@@ -161,13 +165,14 @@ public sealed class ActionManager : RunnerService, IActionManager
161165
return new PrepareResult(containerSetupSteps, result.PreStepTracker);
162166
}
163167

164-
private async Task<PrepareActionsState> PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Int32 depth = 0, Guid parentStepId = default(Guid))
168+
private async Task<PrepareActionsState> PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Dictionary<string, WebApi.ActionDownloadInfo> resolvedDownloadInfos, Int32 depth = 0, Guid parentStepId = default(Guid))
165169
{
166170
ArgUtil.NotNull(executionContext, nameof(executionContext));
167171
if (depth > Constants.CompositeActionsMaxDepth)
168172
{
169173
throw new Exception($"Composite action depth exceeded max depth {Constants.CompositeActionsMaxDepth}");
170174
}
175+
171176
var repositoryActions = new List<Pipelines.ActionStep>();
172177

173178
foreach (var action in actions)
@@ -195,27 +200,29 @@ public sealed class ActionManager : RunnerService, IActionManager
195200

196201
if (repositoryActions.Count > 0)
197202
{
198-
// Get the download info
199-
var downloadInfos = await GetDownloadInfoAsync(executionContext, repositoryActions);
203+
// Resolve download info, skipping any actions already cached.
204+
await ResolveNewActionsAsync(executionContext, repositoryActions, resolvedDownloadInfos);
200205

201-
// Download each action
206+
// Download each action.
202207
foreach (var action in repositoryActions)
203208
{
204209
var lookupKey = GetDownloadInfoLookupKey(action);
205210
if (string.IsNullOrEmpty(lookupKey))
206211
{
207212
continue;
208213
}
209-
210-
if (!downloadInfos.TryGetValue(lookupKey, out var downloadInfo))
214+
if (!resolvedDownloadInfos.TryGetValue(lookupKey, out var downloadInfo))
211215
{
212216
throw new Exception($"Missing download info for {lookupKey}");
213217
}
214-
215218
await DownloadRepositoryActionAsync(executionContext, downloadInfo);
216219
}
217220

218-
// More preparation based on content in the repository (action.yml)
221+
// Parse action.yml and collect composite sub-actions for batched
222+
// resolution below. Pre/post step registration is deferred until
223+
// after recursion so that HasPre/HasPost reflect the full subtree.
224+
var nextLevel = new List<(Pipelines.ActionStep action, Guid parentId)>();
225+
219226
foreach (var action in repositoryActions)
220227
{
221228
var setupInfo = PrepareRepositoryActionAsync(executionContext, action);
@@ -247,8 +254,35 @@ public sealed class ActionManager : RunnerService, IActionManager
247254
}
248255
else if (setupInfo != null && setupInfo.Steps != null && setupInfo.Steps.Count > 0)
249256
{
250-
state = await PrepareActionsRecursiveAsync(executionContext, state, setupInfo.Steps, depth + 1, action.Id);
257+
foreach (var step in setupInfo.Steps)
258+
{
259+
nextLevel.Add((step, action.Id));
260+
}
261+
}
262+
}
263+
264+
// Resolve all next-level sub-actions in one batch API call,
265+
// then recurse per parent (which hits the cache, not the API).
266+
if (nextLevel.Count > 0)
267+
{
268+
var nextLevelRepoActions = nextLevel
269+
.Where(x => x.action.Reference.Type == Pipelines.ActionSourceType.Repository)
270+
.Select(x => x.action)
271+
.ToList();
272+
await ResolveNewActionsAsync(executionContext, nextLevelRepoActions, resolvedDownloadInfos);
273+
274+
foreach (var group in nextLevel.GroupBy(x => x.parentId))
275+
{
276+
var groupActions = group.Select(x => x.action).ToList();
277+
state = await PrepareActionsRecursiveAsync(executionContext, state, groupActions, resolvedDownloadInfos, depth + 1, group.Key);
251278
}
279+
}
280+
281+
// Register pre/post steps after recursion so that HasPre/HasPost
282+
// are correct (they depend on _cachedEmbeddedPreSteps/PostSteps
283+
// being populated by the recursive calls above).
284+
foreach (var action in repositoryActions)
285+
{
252286
var repoAction = action.Reference as Pipelines.RepositoryPathReference;
253287
if (repoAction.RepositoryType != Pipelines.PipelineConstants.SelfAlias)
254288
{
@@ -754,6 +788,33 @@ private async Task BuildActionContainerAsync(IExecutionContext executionContext,
754788
return actionDownloadInfos.Actions;
755789
}
756790

791+
/// <summary>
792+
/// Only resolves actions not already in resolvedDownloadInfos.
793+
/// Results are cached for reuse at deeper recursion levels.
794+
/// </summary>
795+
private async Task ResolveNewActionsAsync(IExecutionContext executionContext, List<Pipelines.ActionStep> actions, Dictionary<string, WebApi.ActionDownloadInfo> resolvedDownloadInfos)
796+
{
797+
var actionsToResolve = new List<Pipelines.ActionStep>();
798+
foreach (var action in actions)
799+
{
800+
var lookupKey = GetDownloadInfoLookupKey(action);
801+
if (!string.IsNullOrEmpty(lookupKey) && !resolvedDownloadInfos.ContainsKey(lookupKey))
802+
{
803+
actionsToResolve.Add(action);
804+
}
805+
}
806+
807+
if (actionsToResolve.Count > 0)
808+
{
809+
var downloadInfos = await GetDownloadInfoAsync(executionContext, actionsToResolve);
810+
foreach (var kvp in downloadInfos)
811+
{
812+
resolvedDownloadInfos[kvp.Key] = kvp.Value;
813+
}
814+
}
815+
}
816+
817+
757818
private async Task DownloadRepositoryActionAsync(IExecutionContext executionContext, WebApi.ActionDownloadInfo downloadInfo)
758819
{
759820
Trace.Entering();

0 commit comments

Comments
 (0)