-
Notifications
You must be signed in to change notification settings - Fork 162
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
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: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -115,8 +115,19 @@ def __init__(self, source_dir, artifacts_dir, scratch_dir, manifest_path, runtim | |
| # folder | ||
| if self.dependencies_dir and self.combine_dependencies: | ||
| # when copying downloaded dependencies back to artifacts folder, don't exclude anything | ||
| # symlinking python dependencies is disabled for now since it is breaking sam local commands | ||
| if False and is_experimental_build_improvements_enabled(self.experimental_flags): | ||
| # | ||
| # Symlinking is only safe for layers. A layer's artifacts are packed into a tarball | ||
| # (which dereferences symlinks) before they reach the local invoke container, whereas a | ||
| # function's artifacts are bind-mounted at /var/task, where a symlink pointing outside | ||
| # the mount dangles unless the caller passes `sam local invoke --mount-symlinks`. That | ||
| # option does not exist on `sam local start-api` / `start-lambda`, so linking function | ||
| # dependencies would break those commands -- which is why this was disabled wholesale in | ||
| # https://github.com/aws/aws-lambda-builders/pull/391. Keep copying for functions. | ||
| # | ||
| # The links are absolute, so they only resolve on the machine that built them. SAM CLI's | ||
| # container build (`sam build --use-container`) never sends a dependencies_dir over | ||
| # JSON-RPC, so it cannot reach this branch; a caller that does must share the path. | ||
| if self.is_building_layer and is_experimental_build_improvements_enabled(self.experimental_flags): | ||
|
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] os.symlink(Path(source).absolute(), Path(destination).absolute())Until now that was harmless for every The case worth confirming before merge is If
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. Checked, and the container build can't reach this branch. Agreed that a future reader should see this. The comment next to the gate now says the links are absolute, that 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. [GENERAL] The safety argument for enabling this on layers covers one consumer of the layer artifacts directory but not the other. The comment reasons about the local invoke image (tarball, dereferenced) versus the function bind mount at The reason it matters is that symlink handling differs by entry type. A symlinked top-level module such as Please confirm against the packaging code path (not just |
||
| self._actions.append(LinkSourceAction(self.dependencies_dir, artifacts_dir)) | ||
| else: | ||
| self._actions.append(CopySourceAction(self.dependencies_dir, artifacts_dir)) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,6 @@ | ||
| import os | ||
| import platform | ||
| import tempfile | ||
| from pathlib import Path | ||
|
|
||
| from unittest import TestCase | ||
|
|
@@ -29,6 +31,9 @@ def test_must_create_symlink_with_absolute_path(self, patched_copy_tree, patched | |
| @patch("aws_lambda_builders.utils.copytree") | ||
| def test_must_copy_if_symlink_fails(self, patched_copy_tree, pathced_os, patched_path): | ||
| pathced_os.symlink.side_effect = OSError("Unable to create symlink") | ||
| # Without this the mocked Path makes the already-a-symlink branch truthy and the function | ||
| # returns before it ever calls os.symlink. | ||
| patched_path.return_value.exists.return_value = False | ||
|
|
||
| source_path = "source/path" | ||
| destination_path = "destination/path" | ||
|
|
@@ -40,14 +45,137 @@ def test_must_copy_if_symlink_fails(self, patched_copy_tree, pathced_os, patched | |
| @patch("aws_lambda_builders.utils.Path") | ||
| @patch("aws_lambda_builders.utils.os") | ||
| @patch("aws_lambda_builders.utils.copytree") | ||
| def test_must_copy_if_symlink_fails(self, patched_copy_tree, pathced_os, patched_path): | ||
| def test_must_not_copy_when_symlink_succeeds(self, patched_copy_tree, pathced_os, patched_path): | ||
|
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. [GENERAL] Because if Path(destination).exists() and Path(destination).is_symlink():
LOG.debug("Symlink between %s and %s already exists, skipping generating symlink", source, destination)
returnBoth assertions ( Either make the test assert the branch it actually reaches (and rename it accordingly, e.g. test_must_skip_when_destination_is_already_a_symlink, asserting def test_must_not_copy_when_symlink_succeeds(self, patched_copy_tree, pathced_os, patched_path):
patched_path.return_value.exists.return_value = False
utils.create_symlink_or_copy("source/path", "destination/path")
pathced_os.symlink.assert_called_once()
patched_copy_tree.assert_not_called()
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, the test never reached |
||
| # As above: without this the already-a-symlink early return is taken and os.symlink is never reached. | ||
| patched_path.return_value.exists.return_value = False | ||
|
|
||
| source_path = "source/path" | ||
| destination_path = "destination/path" | ||
| utils.create_symlink_or_copy(source_path, destination_path) | ||
|
|
||
| pathced_os.symlink.assert_not_called() | ||
| pathced_os.symlink.assert_called_once() | ||
| patched_copy_tree.assert_not_called() | ||
|
|
||
| def test_falls_back_to_copying_a_top_level_file(self): | ||
| # A dependencies directory holds files as well as packages, and copytree cannot copy a file. | ||
| with tempfile.TemporaryDirectory() as tmp: | ||
| source = Path(tmp, "six.py") | ||
| source.write_text("body") | ||
| destination = Path(tmp, "artifacts", "six.py") | ||
|
|
||
| with patch("aws_lambda_builders.utils.os.symlink", side_effect=OSError("privilege not held")): | ||
| utils.create_symlink_or_copy(str(source), str(destination)) | ||
|
|
||
| self.assertTrue(destination.is_file()) | ||
| self.assertEqual(destination.read_text(), "body") | ||
|
|
||
| def test_falls_back_to_copying_a_package_directory(self): | ||
| with tempfile.TemporaryDirectory() as tmp: | ||
| source = Path(tmp, "somepkg") | ||
| source.mkdir() | ||
| (source / "__init__.py").write_text("body") | ||
| destination = Path(tmp, "artifacts", "somepkg") | ||
|
|
||
| with patch("aws_lambda_builders.utils.os.symlink", side_effect=OSError("privilege not held")): | ||
| utils.create_symlink_or_copy(str(source), str(destination)) | ||
|
|
||
| self.assertTrue(destination.is_dir()) | ||
| self.assertEqual((destination / "__init__.py").read_text(), "body") | ||
|
|
||
| def test_fallback_skips_a_source_that_does_not_exist(self): | ||
| # maintain_symlinks passes raw os.readlink() output, which is often relative to the link | ||
| # rather than the CWD (npm's node_modules/.bin entries), so the fallback must skip it. | ||
| with tempfile.TemporaryDirectory() as tmp: | ||
| destination = Path(tmp, "artifacts", "tsc") | ||
|
|
||
| with patch("aws_lambda_builders.utils.os.symlink", side_effect=OSError("privilege not held")): | ||
| utils.create_symlink_or_copy("../typescript/bin/tsc", str(destination)) | ||
|
|
||
| self.assertFalse(destination.exists()) | ||
|
|
||
| def test_fallback_does_not_copy_through_a_dangling_destination_link(self): | ||
| # A dangling link at the destination is not exists(), so the already-a-symlink guard misses | ||
| # it and os.symlink raises FileExistsError. The fallback must not then copy through it. | ||
| with tempfile.TemporaryDirectory() as tmp: | ||
| source = Path(tmp, "six.py") | ||
| source.write_text("body") | ||
| outside = Path(tmp, "outside.txt") | ||
| destination = Path(tmp, "artifacts", "six.py") | ||
| destination.parent.mkdir() | ||
| destination.symlink_to(str(outside)) | ||
|
|
||
| utils.create_symlink_or_copy(str(source), str(destination)) | ||
|
|
||
| self.assertFalse(outside.exists(), "copy followed a dangling link outside the destination tree") | ||
| self.assertFalse(destination.is_symlink()) | ||
| self.assertEqual(destination.read_text(), "body") | ||
|
|
||
|
|
||
| class Test_copytree(TestCase): | ||
| def test_does_not_write_through_a_symlinked_destination(self): | ||
| """A linking build leaves symlinks into the shared dependencies directory. Copying the | ||
| source tree over a colliding name must stay inside the destination tree rather than | ||
| following the link and mutating a cache that later builds reuse.""" | ||
| with tempfile.TemporaryDirectory() as tmp: | ||
| deps = Path(tmp, "deps", "requests") | ||
| deps.mkdir(parents=True) | ||
| (deps / "__init__.py").write_text("dependency") | ||
|
|
||
| artifacts = Path(tmp, "artifacts") | ||
| artifacts.mkdir() | ||
| os.symlink(str(deps), str(artifacts / "requests")) | ||
|
|
||
| source = Path(tmp, "source", "requests") | ||
| source.mkdir(parents=True) | ||
| (source / "my_helper.py").write_text("user code") | ||
|
|
||
| utils.copytree(str(Path(tmp, "source")), str(artifacts)) | ||
|
|
||
| self.assertFalse((deps / "my_helper.py").exists(), "source leaked into the dependencies directory") | ||
| self.assertFalse((artifacts / "requests").is_symlink()) | ||
| self.assertEqual((artifacts / "requests" / "my_helper.py").read_text(), "user code") | ||
| self.assertEqual((artifacts / "requests" / "__init__.py").read_text(), "dependency") | ||
|
|
||
| def test_does_not_write_through_a_symlinked_file_destination(self): | ||
| with tempfile.TemporaryDirectory() as tmp: | ||
| deps = Path(tmp, "deps") | ||
| deps.mkdir() | ||
| (deps / "six.py").write_text("dependency") | ||
|
|
||
| artifacts = Path(tmp, "artifacts") | ||
| artifacts.mkdir() | ||
| os.symlink(str(deps / "six.py"), str(artifacts / "six.py")) | ||
|
|
||
| source = Path(tmp, "source") | ||
| source.mkdir() | ||
| (source / "six.py").write_text("user code") | ||
|
|
||
| utils.copytree(str(source), str(artifacts)) | ||
|
|
||
| self.assertEqual( | ||
| (deps / "six.py").read_text(), "dependency", "source leaked into the dependencies directory" | ||
| ) | ||
| self.assertFalse((artifacts / "six.py").is_symlink()) | ||
| self.assertEqual((artifacts / "six.py").read_text(), "user code") | ||
|
|
||
| def test_does_not_create_a_file_through_a_dangling_symlink(self): | ||
| with tempfile.TemporaryDirectory() as tmp: | ||
| outside = Path(tmp, "outside", "six.py") | ||
| outside.parent.mkdir() | ||
|
|
||
| artifacts = Path(tmp, "artifacts") | ||
| artifacts.mkdir() | ||
| os.symlink(str(outside), str(artifacts / "six.py")) | ||
|
|
||
| source = Path(tmp, "source") | ||
| source.mkdir() | ||
| (source / "six.py").write_text("user code") | ||
|
|
||
| utils.copytree(str(source), str(artifacts)) | ||
|
|
||
| self.assertFalse(outside.exists(), "copy followed a dangling link outside the destination tree") | ||
| self.assertEqual((artifacts / "six.py").read_text(), "user code") | ||
|
|
||
|
|
||
| class TestDecode(TestCase): | ||
| def test_does_not_crash_non_utf8_encoding(self): | ||
|
|
||
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.