Conversation
PR aws#391 disabled symlinking wholesale (`if False and ...`) because symlinked dependencies broke `sam local`. That is true for FUNCTIONS only: a function's artifacts are bind-mounted at /var/task, so a symlink pointing outside the mount dangles unless `sam local invoke --mount-symlinks` is passed -- and that option does not exist on `sam local start-api` / `start-lambda`. A LAYER's artifacts are tarred into the local invoke image, and the tar dereferences symlinks. So gate on `is_building_layer`, which BaseWorkflow already carries. Functions keep copying. Measured on a 185 MB / ~6,200-file layer with SAM CLI: warm `sam build --cached` 1.88s -> 0.93s, build dir 184.5 MB -> ~20 KB. See aws/aws-sam-cli#4828. LinkSourceAction had no unit tests and two latent bugs this second consumer hits: os.remove() raises IsADirectoryError on a real directory left by an earlier copying build, and a dangling symlink is not exists(), so it was left in place for os.symlink to fail on.
There was a problem hiding this comment.
Code Review Results
Reviewed: 587257c..0c0df09
Files: 4
Comments: 3
Comments on lines outside the diff:
[aws_lambda_builders/actions.py:151] [BUG] The copy fallback described in the PR description does not actually work for file entries, and a Python dependencies directory always has them. create_symlink_or_copy falls back to copytree(source, destination) when os.symlink raises (the common case being WinError 1314, privilege not held), and copytree assumes source is a directory:
if not os.path.exists(destination):
os.makedirs(destination) # creates a directory named six.py
...
names = os.listdir(source) # NotADirectoryError on a regular fileThe copytree call is inside the except OSError handler, so the resulting NotADirectoryError is not caught and the build fails. Until now the only LinkSourceAction consumer was nodejs_npm_esbuild, whose dependencies dir contains just node_modules/, so the bug stayed dormant. A pip-installed target dir reliably has top-level files (six.py, typing_extensions.py, *.pth), so enabling this for Python layers turns "fall back to copying" into a hard failure on any host where symlink creation is not permitted.
Either handle files in create_symlink_or_copy (shutil.copy2 when Path(source).is_file()) or pick the copy primitive in LinkSourceAction based on source_path.is_dir(). A test that forces os.symlink to raise OSError and asserts a file dependency still lands as a file would lock this down — the new tests all assert is_symlink(), so none of them exercise the fallback.
[aws_lambda_builders/workflows/python_pip/workflow.py:127] [BUG] The final CopySourceAction(source_dir, artifacts_dir) queued below runs after the dependency links exist, and copytree recurses into a destination directory without checking whether it is a symlink:
if not os.path.exists(destination): # True for a symlink to a directory -> skipped
os.makedirs(destination)
...
elif os.path.isdir(new_source):
copytree(new_source, new_destination)So if a top-level name in source_dir collides with a top-level name in dependencies_dir, the source files are written through the symlink into the shared dependencies directory instead of into the artifacts directory. With copying, that merge stayed inside artifacts_dir and was discarded on the next build; with links it mutates the cache that --cached reuses across builds, and CleanUpAction only wipes it when download_dependencies is true, so the pollution survives and gets published into later builds.
The collision requires a matching name, so it is not the common case, but the blast radius is a corrupted shared cache outside the build directory. Worth either replacing a colliding link with a real copy or asserting the artifacts-dir boundary before the source copy runs.
| source_path = Path(self._source_dir, source_file) | ||
| destination_path = Path(self._dest_dir, source_file) | ||
| if destination_path.exists(): | ||
| if destination_path.is_symlink() or destination_path.is_file(): |
There was a problem hiding this comment.
[BUG] os.remove() cannot delete a directory symlink on Windows — DeleteFileW fails with ERROR_ACCESS_DENIED for a reparse point that is a directory, which surfaces as PermissionError. Since is_symlink() is now the first branch, every rebuild of an already-linked dependency directory takes it, so the idempotent second build that test_is_idempotent covers 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 an ActionFailedError, 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):
if destination_path.is_symlink():
if sys.platform == "win32" and destination_path.is_dir():
# os.remove() cannot delete a directory symlink/junction on Windows
os.rmdir(destination_path)
else:
os.remove(destination_path)
elif destination_path.is_file():
os.remove(destination_path)
elif destination_path.is_dir():
shutil.rmtree(destination_path)
else:
os.makedirs(destination_path.parent, exist_ok=True)Note this also pre-dates the PR (the old exists() branch called os.remove on 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.
Checked this against Windows rather than reasoning about it, and it does not reproduce: all four TestLinkSourceAction tests pass on windows-latest, including test_is_idempotent, which is exactly the scenario described here.
test_is_idempotent links somepkg (a real directory), then runs the action again. The second run takes the is_symlink() branch and calls os.remove() on a directory symlink. _assert_linked then asserts destination.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-functional on this head (job 108558335756):
tests/unit/test_actions.py::TestLinkSourceAction::test_is_idempotent PASSED
tests/unit/test_actions.py::TestLinkSourceAction::test_links_into_an_empty_destination PASSED
tests/unit/test_actions.py::TestLinkSourceAction::test_replaces_a_dangling_symlink PASSED
tests/unit/test_actions.py::TestLinkSourceAction::test_replaces_a_real_directory_left_by_an_earlier_copying_build PASSED
Same result on the 3.11, 3.12 and 3.13 unit-functional jobs, 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 the RemoveDirectory function" — but os.remove is not a thin wrapper over DeleteFileW, and empirically CPython handles the directory reparse point on all four supported versions. Adding a sys.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.
|
Re Reproduced it exactly as you described, by forcing So the fallback this PR advertises was a hard failure on any host where symlink creation is not permitted, and you are right that it stayed dormant because
if os.path.isdir(source):
copytree(source, destination)
else:
os.makedirs(os.path.dirname(destination), exist_ok=True)
shutil.copy2(source, destination)After the fix, the same probe gives You were also right that none of my tests exercised the fallback, since they all assert One thing I found while adding them, worth flagging separately: |
|
Re A dependency Exactly as you described: the file lands outside the artifacts directory, in the cache that later Fixed in 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)That keeps the semantics a copying build had — the collision merges inside the artifacts directory and is discarded on the next build — rather than just refusing the copy. After the fix: Pinned by I took the shared-layer route over "assert the artifacts-dir boundary" because the boundary check would turn a name collision into a build failure, and collisions were previously legal and harmless. Full gates on the amended commit: |
Issue #, if available: aws/aws-sam-cli#4828
Description of changes
sam build --cachedre-copies every Python dependency file on every build. For a project with a large dependency layer that is most of the build time — measured on a 185 MB / ~6,200-file layer, a warm no-change rebuild spends 0.96 s of 1.88 s re-copying the layer.This workflow can already symlink dependencies instead of copying them, but the branch is dead code:
workflow.py:119readsif False and is_experimental_build_improvements_enabled(...). #391 disabled it in 2022 because symlinked dependencies brokesam local.That reason holds for functions but not for layers, because the two reach the local invoke container by different routes:
/var/task, so a symlink pointing outside the mount dangles inside the container.sam local invoke --mount-symlinksopts into mounting the targets, butsam local start-apiandstart-lambdahave no such option — linking function dependencies really would break them.So this gates the existing
LinkSourceActiononis_building_layer, whichBaseWorkflowalready carries and whichjava_gradle/java_mavenalready branch on. Functions keep copying, and the layer path stays behindSAM_CLI_BETA_BUILD_PERFORMANCE. With SAM CLI 1.166.2 the warm rebuild goes 1.88 s -> 0.93 s and the layer build directory 184.5 MB -> ~20 KB.LinkSourceActionalso had no unit tests and two latent bugs that this second consumer hits:os.remove()raisesIsADirectoryErroron a real directory left behind by an earlier copying build, and a dangling symlink is notexists(), so it was left in place foros.symlinkto fail on and silently fall back to a full copy. Both are fixed here.Description of how you validated changes
Unit tests:
tests/unitpasses (861 tests). Coverage is 94%, meeting the--cov-fail-under 94gate.ruff checkandblack --checkare clean.New tests cover the layer/function contrast on otherwise-identical inputs, and
LinkSourceActionlinking into an empty destination, replacing a real directory, replacing a dangling symlink, and being idempotent. Both changes are mutation-checked: reverting theis_building_layergate fails 6 tests, and reverting theLinkSourceActionfix fails 2.End to end with SAM CLI against a template with 10
python3.12functions and oneAWS::Serverless::LayerVersion:sam local invokewith no flags —pandasandcryptographyimport from/opt/python. Repeated afterdocker rmiof the built image, to rule out a stale image.sam local start-lambda— returnsStatusCode 200, noFunctionError.sam deploy— the uploaded layer isCodeSize: 61936488, no zip entry is stored as a symlink, and the deployed function importspandasfrom/opt/pythonin the Lambda runtime. The layer zip's inventory (6,158 entries, 61.9 MB) is identical to a copying build's zip of the same tree..aws-sam/build/<Fn>/requests/is a directory, not a link), and plainsam buildwithout--cachedhas nodependencies_dir, so it is unaffected.Note for reviewers: the symlinks point at absolute paths under
.aws-sam/deps/<uuid>, so CI that tars.aws-sam/buildto hand to a later stage needstar -h. That is part of why this stays behind the experimental flag.Two pre-existing failures in this environment, unrelated to the change and reproduced on a clean
origin/developcheckout: 13 errors intests/functional/workflows/python_uv(uvnot on PATH) and 6 failures intests/functional/test_cli.py::TestCliWithHelloWorkflow.Checklist
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.