Skip to content

Commit 0aa89ce

Browse files
Stefan Pennerclaude
authored 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 0aa89ce

File tree

2 files changed

+522
-10
lines changed

2 files changed

+522
-10
lines changed

src/Runner.Worker/ActionManager.cs

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ public sealed class ActionManager : RunnerService, IActionManager
7979
PreStepTracker = new Dictionary<Guid, IActionRunner>()
8080
};
8181
var containerSetupSteps = new List<JobExtensionRunner>();
82+
// Stack-local cache: same action (owner/repo@ref) is resolved only once,
83+
// even if it appears at multiple depths in a composite tree.
84+
var resolvedDownloadInfos = new Dictionary<string, WebApi.ActionDownloadInfo>(StringComparer.Ordinal);
8285
var depth = 0;
8386
// We are running at the start of a job
8487
if (rootStepId == default(Guid))
@@ -105,7 +108,7 @@ public sealed class ActionManager : RunnerService, IActionManager
105108
PrepareActionsState result = new PrepareActionsState();
106109
try
107110
{
108-
result = await PrepareActionsRecursiveAsync(executionContext, state, actions, depth, rootStepId);
111+
result = await PrepareActionsRecursiveAsync(executionContext, state, actions, resolvedDownloadInfos, depth, rootStepId);
109112
}
110113
catch (FailedToResolveActionDownloadInfoException ex)
111114
{
@@ -161,13 +164,14 @@ public sealed class ActionManager : RunnerService, IActionManager
161164
return new PrepareResult(containerSetupSteps, result.PreStepTracker);
162165
}
163166

164-
private async Task<PrepareActionsState> PrepareActionsRecursiveAsync(IExecutionContext executionContext, PrepareActionsState state, IEnumerable<Pipelines.ActionStep> actions, Int32 depth = 0, Guid parentStepId = default(Guid))
167+
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))
165168
{
166169
ArgUtil.NotNull(executionContext, nameof(executionContext));
167170
if (depth > Constants.CompositeActionsMaxDepth)
168171
{
169172
throw new Exception($"Composite action depth exceeded max depth {Constants.CompositeActionsMaxDepth}");
170173
}
174+
171175
var repositoryActions = new List<Pipelines.ActionStep>();
172176

173177
foreach (var action in actions)
@@ -195,27 +199,29 @@ public sealed class ActionManager : RunnerService, IActionManager
195199

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

201-
// Download each action
205+
// Download each action.
202206
foreach (var action in repositoryActions)
203207
{
204208
var lookupKey = GetDownloadInfoLookupKey(action);
205209
if (string.IsNullOrEmpty(lookupKey))
206210
{
207211
continue;
208212
}
209-
210-
if (!downloadInfos.TryGetValue(lookupKey, out var downloadInfo))
213+
if (!resolvedDownloadInfos.TryGetValue(lookupKey, out var downloadInfo))
211214
{
212215
throw new Exception($"Missing download info for {lookupKey}");
213216
}
214-
215217
await DownloadRepositoryActionAsync(executionContext, downloadInfo);
216218
}
217219

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

790+
/// <summary>
791+
/// Only resolves actions not already in resolvedDownloadInfos.
792+
/// Results are cached for reuse at deeper recursion levels.
793+
/// </summary>
794+
private async Task ResolveNewActionsAsync(IExecutionContext executionContext, List<Pipelines.ActionStep> actions, Dictionary<string, WebApi.ActionDownloadInfo> resolvedDownloadInfos)
795+
{
796+
var actionsToResolve = new List<Pipelines.ActionStep>();
797+
var pendingKeys = new HashSet<string>(StringComparer.Ordinal);
798+
foreach (var action in actions)
799+
{
800+
var lookupKey = GetDownloadInfoLookupKey(action);
801+
if (!string.IsNullOrEmpty(lookupKey) && !resolvedDownloadInfos.ContainsKey(lookupKey) && pendingKeys.Add(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+
757817
private async Task DownloadRepositoryActionAsync(IExecutionContext executionContext, WebApi.ActionDownloadInfo downloadInfo)
758818
{
759819
Trace.Entering();

0 commit comments

Comments
 (0)