Skip to content

Add pin_symlink_on_refresh option to LocalDagBundle for atomically-swapped (git-sync) checkouts - #70003

Closed
kjh0623 wants to merge 4 commits into
apache:mainfrom
kjh0623:feat/git-sync-dag-bundle
Closed

kjh0623 wants to merge 4 commits into
apache:mainfrom
kjh0623:feat/git-sync-dag-bundle

Conversation

@kjh0623

@kjh0623 kjh0623 commented Jul 17, 2026 •

Copy link
Copy Markdown

Note

Reworked per review feedback from @ephraimbuddy: the original version added a new GitSyncDagBundle class in the git provider. As pointed out, LocalDagBundle already covers the git-sync pattern the same way the Airflow 2 dags folder did, so the only genuinely new behaviour — symlink pinning — is now an opt-in flag on LocalDagBundle instead.

What this adds

An opt-in pin_symlink_on_refresh kwarg (default False, no behaviour change for existing users) on LocalDagBundle, for deployments where the bundle path is a symlink that an external synchronization process swaps atomically — typically the Kubernetes git-sync sidecar:

LocalDagBundle(path="/git/repo", pin_symlink_on_refresh=True)

git-sync publishes each synced commit as a separate worktree directory and atomically swaps a symlink to point at the current one. Without pinning, every file access follows the symlink, so a sync happening mid-parse can mix files from two different commits within one parsing round. With the flag enabled, the symlink is resolved on each refresh() and the resolved target is pinned until the next refresh.

Scope note (also in the docs): each component — dag processor, worker, triggerer — resolves and pins independently when it initializes the bundle, so this provides per-process consistency (one parse round, one task run), not global consistency. The docs recommend git-sync's --stale-worktree-timeout so a previously pinned worktree remains readable after a swap.

Production context

We ran this pattern over a ~3,600-DAG repository (git-sync pulls, Airflow only reads the synced worktree) and hit the mid-parse mixing issue in practice; resolving the symlink once per refresh closes exactly that window. It is also the pattern the official helm chart deploys when dags.gitSync is enabled: the default bundle is a LocalDagBundle over the dags folder, with git-sync swapping the checkout underneath it.

Included

  • pin_symlink_on_refresh on LocalDagBundle + initialize() refreshing so the pin is set before first use
  • Unit tests: default (no pinning) follows the swapped symlink, pinning holds the old target until the next refresh then repoints, plain directories are a no-op
  • Documentation section in the dag-bundles page with a config example and operational notes
  • Newsfragment 70003.feature.rst

Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: Claude Code following the guidelines


  • Read the Pull Request Guidelines for more information. Note: commit author/co-author name and email in commits become permanently public when merged.
  • For fundamental code changes, an Airflow Improvement Proposal (AIP) is needed.
  • When adding dependency, check compliance with the ASF 3rd Party License Policy.
  • For significant user-facing changes create newsfragment: {pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.

GitDagBundle fetches the repository itself: every refresh runs git
fetch and resolves the bundle's Airflow Connection inside the dag
processor. For large repositories parsed frequently this puts git
traffic, latency and credentials in the parsing path, and pairing
Airflow with the kubernetes git-sync sidecar currently requires a
custom bundle class.

Add GitSyncDagBundle: a read-only bundle for checkouts maintained by
an external synchronization process such as git-sync. No git command
or Connection resolution ever happens in Airflow. The bundle resolves
the symlink git-sync swaps atomically on each refresh and pins the
resolved worktree until the next refresh, so a sync happening
mid-parse cannot mix files from two different commits in one parsing
round.

We have been running this pattern in production over a ~3,600-DAG
repository (git-sync pulls, the dag processor only reads) after the
built-in fetch contended with parsing load; this upstreams it.

@ephraimbuddy ephraimbuddy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There's no regression from airflow 2 to 3 in LocalDagBundle. It works same as airflow 2 dags folder. This is also documented.

If the symlink pinning is the issue here, why not an opt-in symlink pinning in LocalDagBundle instead of a new bundle construction?

LocalDagBundle(path="/git/repo", pin_symlink_on_refresh=True) # default False

But note that the worker also initializes the bundle at task start which will give the current symlink at the start

…stead of a new bundle class

Per review feedback from ephraimbuddy: LocalDagBundle already covers the
git-sync pattern the same way the Airflow 2 dags folder did, so a new
bundle class is unnecessary. Move the only genuinely new behaviour --
resolving and pinning the atomically-swapped symlink on each refresh --
into LocalDagBundle behind an opt-in pin_symlink_on_refresh flag
(default False, no behaviour change for existing users).

Revert the GitSyncDagBundle class, its provider.yaml registration and
git-provider docs; add tests and documentation for the new flag in core
instead, including the note that each component (dag processor, worker,
triggerer) resolves and pins independently at bundle initialization.
@kjh0623
kjh0623 requested a review from jedcunningham as a code owner July 17, 2026 12:12
@kjh0623 kjh0623 changed the title feat(providers/git): add GitSyncDagBundle for externally synchronized checkouts (git-sync) Add pin_symlink_on_refresh option to LocalDagBundle for atomically-swapped (git-sync) checkouts Jul 17, 2026
@kjh0623

kjh0623 commented Jul 17, 2026

Copy link
Copy Markdown
Author

Thanks for the review @ephraimbuddy — you're right, LocalDagBundle already covers the git-sync pattern the same way the Airflow 2 dags folder did, so a new bundle class was unnecessary.

I reworked the PR as you suggested: the GitSyncDagBundle class is removed, and the symlink pinning is now an opt-in pin_symlink_on_refresh flag on LocalDagBundle (default False, so no behavior change for existing users).

On your note about workers: correct — each component (dag processor, worker, triggerer) resolves the symlink when it initializes the bundle, so pinning gives per-process consistency (one parse round or one task run), not global consistency. I documented that scope, plus the git-sync --stale-worktree-timeout recommendation, in the dag-bundles docs.

PTAL when you have a chance.

@kjh0623
kjh0623 requested a review from ephraimbuddy July 17, 2026 12:18
@jedcunningham

Copy link
Copy Markdown
Member

Maybe I don't follow, but why wouldn't you want to jump to the newer commit right when you can? I don't see a point in finishing the round on the old version, just to reparse it all on the new version?

@jedcunningham jedcunningham left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Also, why do you want to use git-sync instead of GitDagBundle? You are loosing the code versioning benefits it brings.

@kjh0623

kjh0623 commented Jul 17, 2026 •

Copy link
Copy Markdown
Author

@jedcunningham Good question — pinning doesn't delay picking up new commits: the very next refresh() repoints to the new target, so the update cadence is unchanged. What it prevents is a swap landing mid-round, where one parse round (or one task run) reads files from two different commits — e.g. a DAG file from the new commit importing a shared helper still resolved from the old one. For parsing that means transient import errors that mark DAGs broken and vanish on the next round; for a task run it's a real, non-self-healing failure. We hit both in production with a ~3,600-DAG repo, where long parse rounds make the window wide.

Another way to look at it: GitDagBundle also only moves its working tree forward at refresh() — nobody expects it to jump to a newer commit halfway through a parse. This flag just gives the git-sync/symlink deployment style the same refresh-boundary semantics; today it's the odd one out, with code changing underneath a round at arbitrary times.

For context: we ran this exact pattern (a central git-sync deployment + shared RWX PVC + LocalDagBundle) over that repo — stock GitDagBundle would have added ~15s of per-pod clone cold-start to every single task, and shallow/sparse cloning shaves that down but every pod still pays a network fetch on startup. Note this is also what the official helm chart deploys: the default dagBundleConfigList is a LocalDagBundle over the dags folder, and dags.gitSync syncs that folder underneath it — so every chart user with gitSync enabled is on this pattern today, with reads tearing across commits at arbitrary times.

@kjh0623

kjh0623 commented Jul 20, 2026

Copy link
Copy Markdown
Author

@jedcunningham On losing the versioning benefits — you're right that GitDagBundle brings DagVersioning (each commit becomes a DagVersion, and task runs are pinned to the version they were queued with), and that's a real benefit. For deployments that want commit-pinned execution it's the right choice, and nothing here argues against it.

The thing is, git-sync + LocalDagBundle is a separate, already-shipped pattern — it's what the Helm chart deploys by default when dags.gitSync is enabled (the default bundle is a LocalDagBundle over the dags folder, with git-sync swapping the checkout underneath it). Deployments on it have already traded per-commit versioning for centralized sync and no per-pod clone cold-start on large repos. This PR doesn't push anyone toward that trade or away from GitDagBundle; it just fixes a correctness bug — mid-round cross-commit reads — that today silently affects everyone already on that pattern, including default-Helm gitSync users.

So "use GitDagBundle to keep versioning" is fair advice, but it doesn't help the deployments that are on git-sync right now and currently hit silent cross-commit tearing — telling them not to use git-sync isn't a fix for the bug they're hitting. The two are orthogonal: this opt-in flag (default False) makes the git-sync path correct; it doesn't make it preferable.

kjh0623 and others added 2 commits July 21, 2026 21:09
sphinx spellcheck flagged the two prose occurrences of "worktree" in the
dag-bundles docs; wrap them in double backticks like the other git-sync terms
on the same lines rather than adding the word to the shared wordlist.

Signed-off-by: kjh0623 <[email protected]>
@github-actions

Copy link
Copy Markdown
Contributor

This pull request has been automatically marked as stale because it has not had recent activity. It will be closed in 5 days if no further activity occurs. Thank you for your contributions.

@github-actions github-actions Bot added the stale Stale PRs per the .github/workflows/stale.yml policy file label Sep 10, 2026
@github-actions github-actions Bot closed this Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:providers kind:documentation provider:git stale Stale PRs per the .github/workflows/stale.yml policy file

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants