Batch and deduplicate action resolution across composite depths#4296
Batch and deduplicate action resolution across composite depths#4296stefanpenner wants to merge 6 commits intoactions:mainfrom
Conversation
9408232 to
397fea0
Compare
Action graph (8 unique actions, depth 3): workflow → leaf-echo, composite-a, composite-b, composite-c composite-a → leaf-echo, leaf-sleep, composite-d composite-b → leaf-echo, leaf-sleep, composite-e composite-c → leaf-echo, composite-d, composite-e composite-d → leaf-echo, leaf-sleep, composite-f composite-e → leaf-echo, leaf-sleep, composite-f composite-f → leaf-echo, leaf-sleep Without batching: ~15-20 resolve API calls (one per composite per depth) With actions/runner#4296: ~3-4 calls (one batch per depth level) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Action graph (50 unique actions, 5 depth levels): workflow → 10x L1 composites + 5 leaves (15 top-level) L1-NN → 1 leaf + 3 L2 composites (heavy cross-referencing) L2-NN → 1 leaf + 3 L3 composites L3-NN → 2 leaves + 2 L4 composites L4-NN → 3 leaves Without batching/dedup: ~301 resolve API calls With actions/runner#4296: ~4 calls (75x reduction) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR optimizes action resolution in composite action trees by batching and deduplicating API calls across recursion depths. Instead of resolving sub-actions one composite at a time (N API calls), it collects all sub-actions at each depth level and resolves them in a single batch call, with a cross-depth cache to avoid re-resolving the same action. It also defers pre/post step registration until after recursion completes.
Changes:
- Introduce a stack-local
resolvedDownloadInfoscache to deduplicate action resolution across composite depths, and a newResolveNewActionsAsynchelper that skips already-cached actions - Collect composite sub-actions into a
nextLevellist and batch-resolve them before recursing per parent group - Defer pre/post step registration to after recursion so that
HasPre/HasPostreflect the full subtree
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/Runner.Worker/ActionManager.cs |
Core optimization: adds resolution cache, batch pre-resolution of next-level actions, deferred pre/post registration, and new ResolveNewActionsAsync helper |
src/Test/L0/Worker/ActionManagerL0.cs |
5 new L0 tests covering batching, cross-depth dedup, multi-top-level, nested containers, and parallel downloads |
You can also share your feedback on Copilot code review. Take the survey.
There was a problem hiding this comment.
Pull request overview
This PR optimizes action resolution in the GitHub Actions runner by introducing batching and cross-depth deduplication for composite action resolution. Instead of making individual API calls per composite action at each recursion depth, sub-actions from all composites at the same depth are collected and resolved in a single batch API call. A stack-local cache ensures the same owner/repo@ref is only resolved once even if it appears at multiple depths in the composite tree. Pre/post step registration is deferred until after recursion to ensure HasPre/HasPost reflect the full subtree.
Changes:
- Added a case-insensitive
resolvedDownloadInfosdictionary that caches resolved action download info across recursion depths, with aResolveNewActionsAsynchelper that skips already-cached actions - Refactored
PrepareActionsRecursiveAsyncto collect composite sub-actions into anextLevellist, batch-resolve them in one API call, then recurse per-parent group — moving pre/post step registration after recursion - Added 5 new L0 tests covering batching, cross-depth dedup, multiple top-level actions, nested containers, and parallel downloads
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/Runner.Worker/ActionManager.cs | Core optimization: batch resolution cache, ResolveNewActionsAsync helper, deferred pre/post registration |
| src/Test/L0/Worker/ActionManagerL0.cs | 5 new tests validating batching, deduplication, multi-action, nested container, and parallel download scenarios |
You can also share your feedback on Copilot code review. Take the survey.
6e27f5a to
0aa89ce
Compare
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 actions#3731 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
0aa89ce to
5bcd72f
Compare
src/Runner.Worker/ActionManager.cs
Outdated
| try | ||
| { | ||
| result = await PrepareActionsRecursiveAsync(executionContext, state, actions, depth, rootStepId); | ||
| result = await PrepareActionsRecursiveAsync(executionContext, state, actions, resolvedDownloadInfos, depth, rootStepId); |
There was a problem hiding this comment.
Can you add a feature flag? If messy, prefer duplicating the methods with suffix _Legacy vs _New
Runner feature flags are sent from the server in the job message as variables. In the runner code, you read them via context.Global.Variables.GetBoolean(...).
Step 1: Define the flag constant in Constants.Runner.Features:
public static readonly string BatchActionResolution = "actions_batch_action_resolution";Step 2: Check the flag at the top of your new codepath, falling back to the old behavior when it's off:
var batchActionResolution = executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.BatchActionResolution) ?? false
|| StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION"));
if (batchActionResolution)
{
// new batched/deduped resolution
}
else
{
// original resolution logic
}Step 3: Env var fallback for local testing. The || Environment.GetEnvironmentVariable(...) pattern lets you test locally with a self-hosted runner by setting an env var, without needing the server to send the flag. This is an existing pattern — see ExecutionContext.cs L1422-1426 for an example:
var allowServiceContainerCommand = (context.Global.Variables.GetBoolean(Constants.Runner.Features.ServiceContainerCommand) ?? false)
|| StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_SERVICE_CONTAINER_COMMAND"));
if ((context.Global.Variables.GetBoolean(Constants.Runner.Features.CompareWorkflowParser) ?? false)
|| StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_COMPARE_WORKFLOW_PARSER")))|
@ericsciple any interest in #4297 or is that too invasive? |
Gate the batched/deduplicated action resolution behind a feature flag (actions_batch_action_resolution) with env var fallback (ACTIONS_BATCH_ACTION_RESOLUTION) for local testing. When disabled, falls back to the original per-composite resolution behavior via PrepareActionsRecursiveLegacyAsync. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR optimizes GitHub Actions runner action resolution for composite actions by batching resolve requests and caching resolved download info across composite recursion depths to reduce API calls and avoid 429s.
Changes:
- Add a feature flag (
actions_batch_action_resolution) and an opt-in env var (ACTIONS_BATCH_ACTION_RESOLUTION) to enable batched, cached action resolution. - Implement a new recursive preparation path that batch-resolves next-level sub-actions and defers pre/post registration until after recursion.
- Add new L0 tests covering batching and cross-depth dedup behaviors.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| src/Runner.Worker/ActionManager.cs | Introduces the batched/cached resolution path behind a feature flag + env var and adds a cache-aware resolve helper. |
| src/Runner.Common/Constants.cs | Adds the BatchActionResolution feature flag constant. |
| src/Test/L0/Worker/ActionManagerL0.cs | Adds multiple L0 tests for the new batching/dedup behavior when the env var is enabled. |
| public async void PrepareActions_PreDownloadsNextLevelActions() | ||
| { | ||
| // Verifies that after pre-resolving next-level sub-actions, | ||
| // they are also pre-downloaded in parallel BEFORE recursion. | ||
| // This means the recursive call should find watermarks already | ||
| // on disk and skip redundant downloads. | ||
| // |
There was a problem hiding this comment.
This test’s name/comments claim next-level actions are “pre-downloaded … BEFORE recursion”, but the assertion only checks that depth-1 watermarks exist by the time the 3rd resolve occurs. That condition would also hold if depth-1 downloads happen inside the depth-1 recursive call (i.e., it doesn’t actually verify pre-download-before-recursion behavior).
Consider tightening the test to assert the timing more directly (e.g., detect whether downloads occur during recursion vs before it), or rename the test/comments to reflect what’s actually being validated.
| var batchActionResolution = (executionContext.Global.Variables.GetBoolean(Constants.Runner.Features.BatchActionResolution) ?? false) | ||
| || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_BATCH_ACTION_RESOLUTION")); | ||
| // Stack-local cache: same action (owner/repo@ref) is resolved only once, | ||
| // even if it appears at multiple depths in a composite tree. | ||
| var resolvedDownloadInfos = batchActionResolution | ||
| ? new Dictionary<string, WebApi.ActionDownloadInfo>(StringComparer.OrdinalIgnoreCase) | ||
| : null; |
There was a problem hiding this comment.
resolvedDownloadInfos uses StringComparer.OrdinalIgnoreCase, but the lookup key is ${owner/repo}@${ref}. Git refs are case-sensitive, so this comparer can incorrectly treat repo@main and repo@Main (or tags differing only by case) as the same action and reuse the wrong ActionDownloadInfo/archive.
Consider changing the cache key so only the owner/repo portion is case-insensitive (to address #3731), while keeping ref case-sensitive (e.g., a structured key or normalized owner/repo + original ref).
| // Download each action. | ||
| foreach (var action in repositoryActions) | ||
| { | ||
| var lookupKey = GetDownloadInfoLookupKey(action); | ||
| if (string.IsNullOrEmpty(lookupKey)) | ||
| { | ||
| continue; | ||
| } | ||
| if (!resolvedDownloadInfos.TryGetValue(lookupKey, out var downloadInfo)) | ||
| { | ||
| throw new Exception($"Missing download info for {lookupKey}"); | ||
| } | ||
| await DownloadRepositoryActionAsync(executionContext, downloadInfo); | ||
| } | ||
|
|
||
| // Parse action.yml and collect composite sub-actions for batched | ||
| // resolution below. Pre/post step registration is deferred until | ||
| // after recursion so that HasPre/HasPost reflect the full subtree. | ||
| var nextLevel = new List<(Pipelines.ActionStep action, Guid parentId)>(); | ||
|
|
||
| foreach (var action in repositoryActions) | ||
| { | ||
| var setupInfo = PrepareRepositoryActionAsync(executionContext, action); | ||
| if (setupInfo != null && setupInfo.Container != null) |
There was a problem hiding this comment.
With the new cross-depth cache, the same ActionDownloadInfo can be reused for later Pipelines.ActionSteps whose RepositoryPathReference.Name differs only by case. DownloadRepositoryActionAsync downloads to a directory based on downloadInfo.NameWithOwner, but PrepareRepositoryActionAsync (and later LoadAction) locate action.yml using repositoryReference.Name. On case-sensitive filesystems this can make the manifest lookup fail if the cached downloadInfo.NameWithOwner casing doesn’t match the step’s repositoryReference.Name.
To make case-dedup safe, ensure a single canonical directory name is used consistently for both download and manifest lookup (e.g., normalize repositoryReference.Name after resolution to the resolved canonical name, or pass the resolved name into the path computations).
Summary
Internal workflow with ~30 composites: ~20 resolve API calls → 3-4. Also reduces 429s (#4232).
Fixes #3731
Other possibilities
Smoke test results
Tested with a self-hosted runner built from this branch vs stock (
main), using a 50-action composite tree across 6 repos with 5 depth levels.Action graph:
Results (run)
main)owner/repo@refnever re-resolved across depthsEarlier run (single-repo, same-ref tree)
Run: 311 → 1 resolve calls (all 50 actions shared the same
owner/repo@reflookup key, so the cache eliminated every call after the first).Test plan
🤖 Generated with Claude Code