Skip to content

perf(python): symlink layer dependencies instead of copying them - #932

Open
bnusunny wants to merge 4 commits into
aws:developfrom
bnusunny:perf/link-layer-dependencies
Open

bnusunny wants to merge 4 commits into
aws:developfrom
bnusunny:perf/link-layer-dependencies

Conversation

@bnusunny

@bnusunny bnusunny commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Issue #, if available: aws/aws-sam-cli#4828

Description of changes

sam build --cached re-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:119 reads if False and is_experimental_build_improvements_enabled(...). #391 disabled it in 2022 because symlinked dependencies broke sam local.

That reason holds for functions but not for layers, because the two reach the local invoke container by different routes:

  • A function's artifacts are bind-mounted at /var/task, so a symlink pointing outside the mount dangles inside the container. sam local invoke --mount-symlinks opts into mounting the targets, but sam local start-api and start-lambda have no such option — linking function dependencies really would break them.
  • A layer's artifacts are packed into a tarball that becomes the local invoke image, and the tar dereferences symlinks. By the time the container sees the layer they are ordinary files.

So this gates the existing LinkSourceAction on is_building_layer, which BaseWorkflow already carries and which java_gradle / java_maven already branch on. Functions keep copying, and the layer path stays behind SAM_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.

LinkSourceAction also had no unit tests and two latent bugs that this second consumer hits: os.remove() raises IsADirectoryError on a real directory left behind by an earlier copying build, and a dangling symlink is not exists(), so it was left in place for os.symlink to fail on and silently fall back to a full copy. Both are fixed here.

Description of how you validated changes

Unit tests: tests/unit passes (861 tests). Coverage is 94%, meeting the --cov-fail-under 94 gate. ruff check and black --check are clean.

New tests cover the layer/function contrast on otherwise-identical inputs, and LinkSourceAction linking into an empty destination, replacing a real directory, replacing a dangling symlink, and being idempotent. Both changes are mutation-checked: reverting the is_building_layer gate fails 6 tests, and reverting the LinkSourceAction fix fails 2.

End to end with SAM CLI against a template with 10 python3.12 functions and one AWS::Serverless::LayerVersion:

  • sam local invoke with no flags — pandas and cryptography import from /opt/python. Repeated after docker rmi of the built image, to rule out a stale image.
  • sam local start-lambda — returns StatusCode 200, no FunctionError.
  • A real sam deploy — the uploaded layer is CodeSize: 61936488, no zip entry is stored as a symlink, and the deployed function imports pandas from /opt/python in 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.
  • Functions still get real copies (.aws-sam/build/<Fn>/requests/ is a directory, not a link), and plain sam build without --cached has no dependencies_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/build to hand to a later stage needs tar -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/develop checkout: 13 errors in tests/functional/workflows/python_uv (uv not on PATH) and 6 failures in tests/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.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 file

The 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():

Copy link
Copy Markdown

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 — 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.

Copy link
Copy Markdown
Contributor Author

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 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.

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.

Enabling this for Python exposed three latent defects in the shared link path,
all fixed here because a pip target directory reaches them where a node
`node_modules/`-only directory did not:

- `LinkSourceAction` had no unit tests, and `os.remove()` raises
  IsADirectoryError on a real directory left by an earlier copying build, while
  a dangling symlink is not `exists()` so it was left for `os.symlink` to fail
  on.
- `create_symlink_or_copy`'s copy fallback called `copytree` unconditionally,
  which makedirs a folder named `six.py` and then raises NotADirectoryError. A
  dependencies directory always has top-level files, so the advertised fallback
  was a hard failure wherever symlink creation is not permitted.
- `copytree` treated a symlinked destination as existing and recursed into it,
  so a source-tree name colliding with a dependency name was written THROUGH the
  link into the shared dependencies directory, corrupting a cache that later
  `--cached` builds reuse. It now materialises such a destination first, keeping
  the merge inside the destination tree as copying did.
@bnusunny

Copy link
Copy Markdown
Contributor Author

Re aws_lambda_builders/actions.py:151 — the copy fallback breaks on file entries. Confirmed and fixed.

Reproduced it exactly as you described, by forcing os.symlink to raise against a dependencies directory holding one package and one top-level file:

#2 fallback CRASHED: NotADirectoryError: [Errno 20] Not a directory: '.../deps/six.py'

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 nodejs_npm_esbuild only ever had node_modules/ at the top level.

create_symlink_or_copy now picks the copy primitive from the source:

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 six.py copied as a FILE: True with its contents intact, and pkgdir copied as a DIR: True.

You were also right that none of my tests exercised the fallback, since they all assert is_symlink(). Added two that force OSError from os.symlink on a real filesystem — test_falls_back_to_copying_a_top_level_file and test_falls_back_to_copying_a_package_directory — so the file and directory shapes are both pinned.

One thing I found while adding them, worth flagging separately: test_must_copy_if_symlink_fails was defined twice in tests/unit/test_utils.py, so the second definition shadowed the first and the fallback assertion never ran. Renaming the second to test_must_not_copy_when_symlink_succeeds revived the first, which then failed — patching aws_lambda_builders.utils.Path wholesale makes the already-a-symlink branch truthy, so the function returned before reaching os.symlink. It now sets exists.return_value = False and asserts what its name claims.

@bnusunny

Copy link
Copy Markdown
Contributor Author

Re aws_lambda_builders/workflows/python_pip/workflow.py:127 — the source copy writing through a dependency symlink. Confirmed and fixed. This was the most serious of the three, and it reproduced on the first try.

A dependency requests/ linked into the artifacts directory, then a source tree containing its own requests/my_helper.py copied over it:

#3 art/requests is symlink: True
#3 user file leaked into the shared deps cache: True -> .../deps/requests/my_helper.py

Exactly as you described: the file lands outside the artifacts directory, in the cache that later --cached builds reuse.

Fixed in copytree rather than in the workflow, because the hole is in the shared layer — CopySourceAction is not the only caller that can be handed a symlinked destination, and a fix in the Python workflow would leave nodejs_npm_esbuild exposed to the same thing. A new _materialize_symlinked_destination replaces a symlinked destination with a real copy of what it points at before anything is written into it:

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:

#3 leaked into shared cache: False
#3 artifacts still have the dep:  REAL DEP
#3 artifacts have the user file: USER CODE
#3 colliding entry is now a real dir: True

Pinned by Test_copytree::test_does_not_write_through_a_symlinked_destination, which asserts all four of those: nothing in the dependencies directory, the destination is no longer a symlink, and both the dependency and the source file are readable in the artifacts directory.

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: ruff clean, black --check clean, 865 unit tests passing, 961 with functional. Coverage is unchanged against develop measured with identical exclusions (201 missed lines here versus 210 on the base).

@bnusunny
bnusunny force-pushed the perf/link-layer-dependencies branch from 0c0df09 to bbc105f Compare September 28, 2026 15:54

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..bbc105f
Files: 6
Comments: 2

LOG.warning("Skipping copy operation since source %s does not exist", source)
return

_materialize_symlinked_destination(destination)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.

_materialize_symlinked_destination is called once per copytree invocation, and copytree only recurses (and therefore only re-checks the destination) when the source entry is a directory. The leaf branch is unchanged:

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_destination

So for the same layer scenario you reproduced, but with a top-level module instead of a package, CopySourceAction(source_dir, artifacts_dir) still leaks: artifacts/six.py is a symlink into deps/six.py after LinkSourceAction, the user's own six.py hits the else branch, and shutil.copy2 opens the destination for writing, follows the link, and overwrites the file inside the shared deps directory that later builds reuse. A dangling destination symlink is worse — copy2 creates the file at the link target, outside the artifacts tree entirely.

Top-level files are exactly the case this PR added elsewhere (create_symlink_or_copy's six.py fallback, and the six.py fixture in TestLinkSourceAction), so a Python dependencies directory reliably produces symlinked file destinations.

Unlinking is sufficient here — copy2 overwrites the whole file, so there is nothing to preserve:

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 test_does_not_write_through_a_symlinked_destination with a symlinked file entry, since that test currently only exercises the directory path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed, and fixed in 900fe23. Reproduced against bbc105f: after LinkSourceAction, artifacts/six.py is a symlink into deps/six.py, and CopySourceAction with a user six.py left deps/six.py containing the user's code.

The fix is what you suggested: the file branch of copytree unlinks a symlinked new_destination before shutil.copy2. This also covers the dangling-link case, so no file is created at the link target.

Two new tests, test_does_not_write_through_a_symlinked_file_destination and test_does_not_create_a_file_through_a_dangling_symlink. Removing the unlink makes both fail.

Comment thread aws_lambda_builders/utils.py Outdated
# then raise NotADirectoryError on listdir.
if os.path.isdir(source):
copytree(source, destination)
else:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[ERROR_HANDLING] The new else branch drops copytree's warn-and-return on a missing source, turning a skip into a build failure.

copytree starts with:

if not os.path.exists(source):
    LOG.warning("Skipping copy operation since source %s does not exist", source)
    return

Every fallback used to inherit that. Now a source that is neither a dir nor a file reaches shutil.copy2(source, destination) and raises FileNotFoundError, which nothing in the action chain catches.

That case is reachable, and not only through the dependencies directory. copytree's maintain_symlinks branch passes the raw os.readlink() result:

linkto = os.readlink(new_source)
create_symlink_or_copy(linkto, new_destination)

linkto is commonly relative (npm writes node_modules/.bin/tsc -> ../typescript/bin/tsc), so it does not resolve against the process CWD and both isdir and isfile are False. CopyDependenciesAction does the same. nodejs_npm uses maintain_symlinks=is_building_in_source, and the trigger for reaching the fallback at all is a host where os.symlink raises — Windows without the symlink privilege — which is precisely the configuration where those relative link targets now crash the build instead of being skipped with a warning.

Restoring the third branch keeps the previous contract:

if os.path.isdir(source):
    copytree(source, destination)
elif os.path.isfile(source):
    os.makedirs(os.path.dirname(destination), exist_ok=True)
    shutil.copy2(source, destination)
else:
    LOG.warning("Skipping copy operation since source %s does not exist", source)

Not re-raised: the Windows os.remove()-on-directory-symlink concern from the earlier review, which you dismissed with windows-latest CI evidence for all four TestLinkSourceAction cases. The is_building_layer gate itself checks out — BaseWorkflow sets the attribute (workflow.py:266) and __main__.py threads it through from the JSON-RPC request.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed, and fixed in 900fe23. With os.symlink failing, create_symlink_or_copy("../typescript/bin/tsc", dest) raised FileNotFoundError from shutil.copy2 on bbc105f. Before this PR it went through copytree and was skipped with a warning.

The third branch is restored as you proposed: directory → copytree, file → copy2, anything else → the same "Skipping copy operation" warning copytree logs.

Pinned by test_fallback_skips_a_source_that_does_not_exist. It fails if the missing source is sent down the file branch.

…allback sources

copytree's file branch now unlinks a symlinked destination before copy2, so a
source file colliding with a linked dependency (six.py) no longer overwrites
the shared dependencies directory, and a dangling link no longer creates a
file outside the artifacts tree.

create_symlink_or_copy's copy fallback restores the warn-and-skip for a source
that is neither a directory nor a file, e.g. a relative os.readlink() target
under maintain_symlinks.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..900fe23
Files: 6
Comments: 3


Comments on lines outside the diff:

[aws_lambda_builders/actions.py:137] [ERROR_HANDLING] LinkSourceAction.execute() opens with source_files = set(os.listdir(self._source_dir)) (line 135) with no existence guard, while the CopySourceAction it replaces on this path degrades gracefully:

# utils.copytree
if not os.path.exists(source):
   LOG.warning("Skipping copy operation since source %s does not exist", source)
   return

The branch this PR enables is reached whenever dependencies_dir and combine_dependencies, independently of download_dependencies. So a layer build with download_dependencies=False and a dependencies_dir that does not exist on disk (cache directory deleted while build.toml still records a matching manifest hash, or an external JSON-RPC integrator passing a not-yet-created path) changes from "warn and continue" to a hard failure: FileNotFoundError is not an ActionFailedError, so BaseWorkflow.run wraps it in WorkflowUnknownError and the whole build aborts.

Matching the existing contract keeps the two branches interchangeable:

def execute(self):
   if not os.path.isdir(self._source_dir):
       LOG.warning("Skipping link operation since source %s does not exist", self._source_dir)
       return

   source_files = set(os.listdir(self._source_dir))

This also matters for the pre-existing nodejs_npm_esbuild consumer, which calls LinkSourceAction(self.dependencies_dir, self.scratch_dir) on the same "reusing dependencies" assumption.

# 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.
if self.is_building_layer and is_experimental_build_improvements_enabled(self.experimental_flags):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[BUG] create_symlink_or_copy writes an absolute link target:

os.symlink(Path(source).absolute(), Path(destination).absolute())

Until now that was harmless for every LinkSourceAction consumer, because the only one (nodejs_npm_esbuild) links into self.scratch_dir, which is consumed by EsbuildBundleAction in the same process and then discarded. This PR is the first to leave absolute symlinks in a persisted artifacts_dir, so the build output is now only valid while dependencies_dir remains readable at that exact absolute path.

The case worth confirming before merge is sam build --use-container. There the workflow executes inside the container, so dependencies_dir is a container path and the links it writes point at container paths; once the artifacts are transferred back to the host those links have nothing to resolve to, and neither a dereferencing tar nor a zip can recover the contents. The PR description reasons carefully about sam local but does not mention the container build path.

If is_building_layer and dependencies_dir can be combined with a container build, this branch needs to exclude that mode as well. If they cannot, it is worth stating so in the comment next to the gate, since the comment currently enumerates the sam local reasoning only and a future reader has no signal that the container path was considered.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Checked, and the container build can't reach this branch. LambdaBuildContainer in SAM CLI builds its JSON-RPC params without a dependencies_dir key (samcli/local/docker/lambda_build_container.py). In _build_layer, the container call _build_function_on_container(...) isn't passed one either, only _build_function_in_process is. So inside the container self.dependencies_dir is None, and the if self.dependencies_dir and self.combine_dependencies guard skips both link and copy.

Agreed that a future reader should see this. The comment next to the gate now says the links are absolute, that sam build --use-container never sends a dependencies_dir, and that any other caller has to share the path. This is in fadaa49.

Comment thread tests/unit/test_utils.py
@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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[GENERAL] test_must_not_copy_when_symlink_succeeds does not exercise a successful os.symlink — it never reaches the call at all.

Because aws_lambda_builders.utils.Path is replaced by a MagicMock, both Path(destination).exists() and Path(destination).is_symlink() return truthy mocks, so create_symlink_or_copy takes the "already a symlink" early return:

if Path(destination).exists() and Path(destination).is_symlink():
   LOG.debug("Symlink between %s and %s already exists, skipping generating symlink", source, destination)
   return

Both assertions (symlink.assert_not_called(), copytree.assert_not_called()) then pass for a reason unrelated to the test name, and the test would keep passing if the symlink-success path regressed. This is exactly the pitfall the PR already fixes one method above, where patched_path.return_value.exists.return_value = False was added with a comment explaining it.

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 copytree is not called), or force the guard false so a real os.symlink call is verified:

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()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed, the test never reached os.symlink. Replacing os.symlink with a bare return still passes it. Fixed in fadaa49 as you suggested: the test sets patched_path.return_value.exists.return_value = False and asserts symlink.assert_called_once(). With the same mutation, it now fails.

…s test

LinkSourceAction now warns and returns when its source directory does not
exist, matching the CopySourceAction it replaces for layers, instead of
raising FileNotFoundError (which the workflow wraps into a build failure).

test_must_not_copy_when_symlink_succeeds took the already-a-symlink early
return because the mocked Path made exists()/is_symlink() truthy; it now
forces the guard false and asserts os.symlink is called.

Note next to the python_pip layer gate that the links are absolute and that
SAM CLI's container build never passes a dependencies_dir, so it cannot
reach the linking branch.
@bnusunny

Copy link
Copy Markdown
Contributor Author

Confirmed for the finding on aws_lambda_builders/actions.py:137, which is outside the diff. With a dependencies_dir that doesn't exist, CopySourceAction logs "Skipping copy operation" and continues, but LinkSourceAction raised FileNotFoundError from os.listdir.

In fadaa49, LinkSourceAction.execute now starts with the guard you proposed: if not os.path.isdir(self._source_dir), it logs a warning and returns. This also covers the nodejs_npm_esbuild consumer. New test: TestLinkSourceAction.test_skips_a_source_that_does_not_exist. It fails if the guard is disabled.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..fadaa49
Files: 6
Comments: 1

if os.path.isdir(source):
copytree(source, destination)
elif os.path.isfile(source):
os.makedirs(os.path.dirname(destination), exist_ok=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 (Path(destination).exists() and Path(destination).is_symlink()) is False for a dangling link. So control reaches the except OSError handler with the stale link still in place, and both new branches misbehave:

  • os.path.isdir(source) → copytree(source, destination) → os.path.exists(destination) is False for the dangling link → os.makedirs(destination) raises FileExistsError, which nothing catches. A hard build failure.
  • os.path.isfile(source) → shutil.copy2(source, destination) follows the link and creates the file at the link target, outside the destination tree, leaving destination a symlink. This is the same write-through that test_does_not_create_a_file_through_a_dangling_symlink now pins for copytree's leaf branch — the guard added there was not applied here.

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 create_symlink_or_copy(linkto, new_destination). That branch has a related gap: when new_destination is a valid symlink into the dependencies directory, the early return keeps it and the source's own link is silently never created.

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):
           ...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 Path(destination).exists() is False, so the guard is skipped, and os.symlink raises FileExistsError: [Errno 17]. Control does reach the handler with the stale link in place.

The file branch is a real bug. With a dangling destination and a file source, shutil.copy2 followed the link and wrote the dependency to the link target, leaving destination a symlink. I hit it through a real caller with no forced error at all — LinkSinglePathAction against a dangling destination wrote the file outside the artifact directory. Same write-through that the copytree leaf branch was fixed for; you're right that the guard wasn't applied here.

The directory branch does not fail. copytree starts with _materialize_symlinked_destination(destination), which this PR added, and os.path.islink is True for a dangling link, so the link is unlinked before os.makedirs ever runs. I ran it: no exception, destination ends up a real directory with the source's contents, and nothing is written to the link target.

Fixed in d0f5f6a with the handler-level unlink you proposed, since it covers LinkSinglePathAction and the maintain_symlinks branch too rather than just the one caller. New test test_fallback_does_not_copy_through_a_dangling_destination_link drives the real FileExistsError path (no mocked os.symlink) and asserts nothing lands at the link target; it fails if the unlink is disabled.

Two notes on the rest:

  • I kept the LinkSourceAction removal rather than treating it as redundant. It runs on the happy path, so the symlink succeeds; without it every stale entry would degrade to a full copy through this handler. It also clears stale real directories left by an earlier copying build, which the handler's islink check doesn't cover.
  • The last point — a valid symlink at new_destination makes the early return keep it, so the source's own link is never created — reproduces, but that guard is unchanged by this PR (it's the same on develop). I'd rather not change the existing skip-if-already-a-symlink contract inside a perf change for the python layer path; worth its own issue.

A dangling symlink at the destination is not exists(), so the
already-a-symlink guard in create_symlink_or_copy misses it and os.symlink
raises FileExistsError. The copy fallback then ran with the stale link still
in place, and shutil.copy2 followed it, writing the dependency outside the
destination tree and leaving the destination a symlink. Reachable through
LinkSinglePathAction (nodejs_npm, nodejs_npm_esbuild) and through copytree's
maintain_symlinks branch, neither of which removes the destination first.

The handler now unlinks a symlinked destination before copying, which covers
every caller of the helper.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 587257c..d0f5f6a
Files: 6
Comments: 2


Comments on lines outside the diff:

[aws_lambda_builders/actions.py:143] [BUG] execute() iterates over source_files only, so destination entries that no longer exist in the source are never reconciled. Switching layers from copying to linking changes the consequence of that from benign to fatal.

Concretely, for the python_pip layer path the workflow is CleanUpAction(dependencies_dir) → pip install → LinkSourceAction(dependencies_dir, artifacts_dir) → CopySourceAction(source_dir, artifacts_dir). CleanUpAction is only ever applied to dependencies_dir — nothing in the workflow cleans artifacts_dir, and the PR's own test_replaces_a_real_directory_left_by_an_earlier_copying_build is premised on that directory surviving into the next build. So if a package is dropped from requirements.txt:

  • before this PR: artifacts_dir/requests/ is a stale real directory — wrong content, but it still packages and runs.
  • after this PR: artifacts_dir/requests is a symlink into a target that CleanUpAction just deleted and pip did not recreate, and LinkSourceAction never visits the name because it is absent from source_files. It stays dangling.

A dangling symlink is not a directory, so os.walk yields it in the files list and both zipfile.ZipFile.write and tarfile.add(..., dereference=True) raise FileNotFoundError on it. The dereferencing tarball this PR relies on for sam local is exactly one of those consumers, so the failure lands on the path the PR is optimizing.

Reconciling the destination after linking keeps this narrow enough to be safe for the nodejs_npm_esbuild consumer, which links into a scratch_dir that already holds copied source — only links that point into self._source_dir and no longer resolve are removed:

for source_file in source_files:
            ...

        # A dependency dropped from the manifest is absent from source_files, so the loop above
        # never visits its stale link. Left in place it dangles, and packing the artifacts
        # (tar with dereference, or zip) fails on a broken link rather than skipping it.
        for stale in set(os.listdir(self._dest_dir)) - source_files:
            stale_path = Path(self._dest_dir, stale)
            if stale_path.is_symlink() and not stale_path.exists():
                if _is_within_directory(self._source_dir, os.path.realpath(stale_path)):
                    LOG.debug("Removing dangling symlink %s left by an earlier build", stale_path)
                    os.remove(stale_path)

_is_within_directory already exists in aws_lambda_builders/utils.py. Worth a test alongside test_replaces_a_dangling_symlink: link two dependencies, remove one from the source directory, re-run, and assert the destination no longer contains a broken link.

# 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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 /var/task — but artifacts_dir for a layer is also the input that sam package / sam deploy / sam sync zip and upload, and that path is not addressed here or in the PR description.

The reason it matters is that symlink handling differs by entry type. A symlinked top-level module such as six.py is yielded by os.walk in the files list and gets written into the archive with its content followed. A symlinked package directory such as requests is yielded in the dirs list, and os.walk does not descend into directory symlinks unless followlinks=True is passed. If the zipping code does not opt in, the failure mode is silent: the layer uploads with every package directory missing and only loose module files present, and the error surfaces at invoke time as ModuleNotFoundError rather than at build time.

Please confirm against the packaging code path (not just sam local) that a layer built with SAM_CLI_BETA_BUILD_PERFORMANCE deploys with complete dependencies, and record that in the validation notes the same way the local-invoke reasoning is recorded. If it does not hold, the gate needs to be narrower than is_building_layer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant