Repository navigation
perf(python): symlink layer dependencies instead of copying them #932
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
bbc105f
900fe23
fadaa49
d0f5f6a
1ddb58a
00ac451
0e904a8
8fe67df
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,30 @@ | |
| LOG = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def _materialize_symlinked_destination(destination: str) -> None: | ||
| """ | ||
| Replace a symlinked destination with a real copy of what it points at. | ||
|
|
||
| A linking build leaves symlinks into the shared dependencies directory. Copying into one would | ||
| follow the link and write outside the destination tree, mutating a cache that later builds | ||
| reuse. Materialising it first keeps the merge where the caller asked for it, which is what a | ||
| copying build did. | ||
| """ | ||
| if not os.path.islink(destination): | ||
| return | ||
|
|
||
| LOG.debug("Replacing symlinked destination %s with a real copy before copying into it", destination) | ||
| link_target = os.path.realpath(destination) | ||
| os.unlink(destination) | ||
|
|
||
| if os.path.isdir(link_target): | ||
| copytree(link_target, destination) | ||
| elif os.path.isfile(link_target): | ||
| os.makedirs(os.path.dirname(destination), exist_ok=True) | ||
| shutil.copy2(link_target, destination) | ||
| # A dangling link leaves nothing to preserve; the caller creates the destination itself. | ||
|
|
||
|
|
||
| def copytree( | ||
| source: str, | ||
| destination: str, | ||
|
|
@@ -48,6 +72,8 @@ def copytree( | |
| LOG.warning("Skipping copy operation since source %s does not exist", source) | ||
| return | ||
|
|
||
| _materialize_symlinked_destination(destination) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [BUG] The write-through fix only covers directory entries; file entries still write into the shared dependencies cache.
elif os.path.isdir(new_source):
copytree(new_source, new_destination, ...) # materialize runs on new_destination
else:
shutil.copy2(new_source, new_destination) # follows a symlinked new_destinationSo for the same layer scenario you reproduced, but with a top-level module instead of a package, Top-level files are exactly the case this PR added elsewhere ( Unlinking is sufficient here — else:
if os.path.islink(new_destination):
os.unlink(new_destination)
LOG.debug("Copying source file (%s) to destination (%s)", new_source, new_destination)
shutil.copy2(new_source, new_destination)Worth extending
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed, and fixed in 900fe23. Reproduced against bbc105f: after The fix is what you suggested: the file branch of Two new tests, |
||
|
|
||
| if not os.path.exists(destination): | ||
| LOG.debug("Creating target folders at %s", destination) | ||
| os.makedirs(destination) | ||
|
|
@@ -86,6 +112,10 @@ def copytree( | |
| elif os.path.isdir(new_source): | ||
| copytree(new_source, new_destination, ignore=ignore, include=include, maintain_symlinks=maintain_symlinks) | ||
| else: | ||
| # copy2 opens the destination for writing and would follow a symlink into the shared | ||
| # dependencies directory; it replaces the whole file, so unlinking loses nothing. | ||
| if os.path.islink(new_destination): | ||
| os.unlink(new_destination) | ||
| LOG.debug("Copying source file (%s) to destination (%s)", new_source, new_destination) | ||
| shutil.copy2(new_source, new_destination) | ||
|
|
||
|
|
@@ -210,7 +240,21 @@ def create_symlink_or_copy(source: str, destination: str) -> None: | |
| "consider enabling the necessary settings or privileges on your system to support symbolic links.", | ||
| exc_info=ex if LOG.isEnabledFor(logging.DEBUG) else None, | ||
| ) | ||
| copytree(source, destination) | ||
| if os.path.islink(destination): | ||
| # A leftover link is one reason os.symlink raised: the guard above misses a dangling one, | ||
| # which is not exists(). Copying through it would write outside the destination tree. | ||
| LOG.debug("Removing existing symlink at destination %s before copying", destination) | ||
| os.unlink(destination) | ||
| # A dependencies directory holds top-level files as well as packages (six.py, *.pth), and | ||
| # copytree assumes its source is a directory -- it would makedirs a folder named six.py and | ||
| # then raise NotADirectoryError on listdir. | ||
| if os.path.isdir(source): | ||
| copytree(source, destination) | ||
| elif os.path.isfile(source): | ||
| os.makedirs(os.path.dirname(destination), exist_ok=True) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [BUG] The new copy fallback does not account for destination already existing, and a dangling symlink there is precisely what sends it down this path. os.symlink raises FileExistsError (a subclass of OSError) when the destination path is occupied — including by a broken link, since the guard above it (
It also misreports the cause: the operator sees "Symbolic link creation failed... consider enabling the necessary settings or privileges on your system" when the real problem is a leftover link, not a missing privilege. LinkSourceAction is immune because this PR makes it unlink the destination first, but that fix lives in one caller while the shared helper stays broken for the others — LinkSinglePathAction (used by nodejs_npm and nodejs_npm_esbuild) passes a destination it has not removed, and so does copytree's maintain_symlinks branch via Clearing the destination in the handler fixes every caller at once and makes the LinkSourceAction workaround redundant: except OSError as ex:
LOG.warning(
"Symbolic link creation failed, falling back to copying files instead. ...",
exc_info=ex if LOG.isEnabledFor(logging.DEBUG) else None,
)
if os.path.islink(destination):
# A leftover link at the destination is what made os.symlink raise. Copying through it
# would write outside the destination tree, and makedirs() would fail on it outright.
os.unlink(destination)
if os.path.isdir(source):
copytree(source, destination)
elif os.path.isfile(source):
...
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Partly confirmed. I reproduced all three sub-claims, and one of the two "misbehaves" cases doesn't hold. The premise is right: for a dangling link The file branch is a real bug. With a dangling The directory branch does not fail. Fixed in d0f5f6a with the handler-level unlink you proposed, since it covers Two notes on the rest:
|
||
| shutil.copy2(source, destination) | ||
| else: | ||
| LOG.warning("Skipping copy operation since source %s does not exist", source) | ||
|
|
||
|
|
||
| def _is_within_directory(directory: Union[str, os.PathLike], target: Union[str, os.PathLike]) -> bool: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[BUG]
os.remove()cannot delete a directory symlink on Windows —DeleteFileWfails withERROR_ACCESS_DENIEDfor a reparse point that is a directory, which surfaces asPermissionError. Sinceis_symlink()is now the first branch, every rebuild of an already-linked dependency directory takes it, so the idempotent second build thattest_is_idempotentcovers on POSIX will fail on Windows for package directories (requests/,certifi/, …) whenever symlink creation succeeded on the first build (developer mode / elevated shell). The exception is not anActionFailedError, so it propagates as a hard workflow failure rather than degrading to a copy.The directory symlink must be removed with
os.rmdir()on Windows (it unlinks the link, it does not recurse):Note this also pre-dates the PR (the old
exists()branch calledos.removeon the same input), but this PR is what makes the path reachable for Python layer builds, and it is the branch being rewritten here.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Checked this against Windows rather than reasoning about it, and it does not reproduce: all four
TestLinkSourceActiontests pass onwindows-latest, includingtest_is_idempotent, which is exactly the scenario described here.test_is_idempotentlinkssomepkg(a real directory), then runs the action again. The second run takes theis_symlink()branch and callsos.remove()on a directory symlink._assert_linkedthen assertsdestination.is_symlink()is True for that entry, so a silent fallback to copying would fail the test too — the symlink really was created and really was removed.From
windows-latest / 3.10 / unit-functionalon this head (job 108558335756):Same result on the 3.11, 3.12 and 3.13
unit-functionaljobs, so it is not a single-version quirk.The Win32 distinction you cite is real —
DeleteFile's own docs say "To remove an empty directory, use theRemoveDirectoryfunction" — butos.removeis not a thin wrapper overDeleteFileW, and empirically CPython handles the directory reparse point on all four supported versions. Adding asys.platform == "win32"branch would therefore be an untested path guarding a condition that does not occur, so I would rather not carry it.Happy to reconsider with a failing case on a Windows configuration the CI matrix does not cover.
Your other two comments were both real and are fixed — replies on those separately.