Copy action - #396
Conversation
| - Action result is empty, similar to symlink actions. | ||
|
|
||
| The API looks like `actions.copy(in: File, out: File, path: String|None): None`. | ||
| - `actions.copy(in, out)` is a simple same-type copy operation. |
There was a problem hiding this comment.
Does "same type" refer to the artifact type or to the filesystem type? The two don't necessarily match: artifacts of file or directory type can be materialized as symlinks in the filesystem.
What should actions.copy(in, out) do if in and out are both declare_file (or both declare_directory), but in is materialized in the filesystem as a symlink? I think there are three possible answers:
- Copy the symlink as-is
- Indirect through the symlink and make a copy of its target
- Execution error (I like this one the least because it makes builds with the same analysis properties and input digests result in different execution behavior, depending on the state of the filesystem - a sort of non-determinism, if you will).
There was a problem hiding this comment.
Does "same type" refer to the artifact type or to the filesystem type? The two don't necessarily match: artifacts of file or directory type can be materialized as symlinks in the filesystem.
I was thinking artifact type when I wrote this, symlinks are something I've yet to figure out. It's very easy to break them, which to be fair is the case for the existing copy rules. The challenge here is that with some symlinks Bazel will actually dereference them and track the targeted artifact.
Not sure what behaviour to go with here just yet. I feel like "copy-as-is" would be the least surprising option, keeps it simple.
- Indirect through the symlink and make a copy of its target
Would make sense when in the source tree a file has been symlinked specifically so it's content can appear in multiple locations. Such cases would be ideally served by hardlinks (multiple canonical handles, same file) but that's not something version control systems keep track of.
Symlinks to a directory ramps up the complexity. They can be used to present the same content in multiple locations, but they also have more novel uses like in pnpm.
Additionally symlinks can point to anything, including other symlinks (e.g. symlink-a -> symlink-b -> file, symlink-a -> symlink-b -> symlink-a). Solving for that in the copy implementation doesn't sound like much fun.
A case could also be made for reading the symlink target then creating the new "copy" with an adjusted target path.
Opting to not do anything special for symlinks may be the best option, can always do the special niche operation (e.g. copying the file a symlink points to) in a separate action.
There was a problem hiding this comment.
The behaviour here has been clarified in the updated proposal. Some changes vs. my last comment driven by prototyping discoveries. To recap;
declare_directory->declare_directorydeclare_file->declare_filedeclare_symlink->declare_symlink
If a declare_directory or declare_file input is provided that was fulfilled by ctx.actions.symlink, the symlink is dereferenced to perform the copy. I'm not convinced this is the right decision, it's a tricky spot as symlink fulfilled artifacts are a little special in their own right (materialised symlinks fulfilling directories or files do not have a fixed target path). Whatever the final outcome, the goal is to adhere to the principle of least astonishment.
There was a problem hiding this comment.
Given the behaviour of ctx.actions.symlink(target_file = ...) described in #396 (comment), dereferencing looks to be the most consistent (and maybe the only possible) way to handle this scenario.
sluongng
left a comment
There was a problem hiding this comment.
FWIW, this is something that we have discussed internally with BuildBuddy as these copy actions are used heavily among the rules_js, rules_oci ecosystem.
Specifically for rules_oci, the copy contents could range anywhere between a few bytes of json, to multi-gigabytes of compressed tarball container layer. Which make them quite "expensive" to schedule and execute remotely.
An alternative way of achieving this, which we are discussing internally, is for the RBE scheduler to "detect" the known copy actions and skip the executions completely by creating the ActionResult directly instead. Though, it would be much nicer if it's Bazel doing these "short-circuit" executions instead of our server.
| dst = ctx.actions.declare_directory(ctx.attr.out) | ||
| ctx.actions.copy(ctx.attr.src, dst) |
There was a problem hiding this comment.
In this example, the desired output here would be a Directory which should be uploaded to remote cache CAS so that subsequent downstream actions could consume it.
If the copy is large (i.e. Dir to Dir recursively), having an ActionResult would also save other Bazel clients from having to do the work.
I also think that if File to Dir is supported, then we should consider making it a list of files instead so we can compose the directory contents in one go.
There was a problem hiding this comment.
File to Dir won't be supported, at least not in this proposal. There is too much ambiguity around what the file's path within the created folder will be. For it to be useful, some degree of customisation (i.e. customising subpaths for files placed in the directory) would also be necessary.
Since this comment a new action ctx.actions.map_directory has been implemented, which may be a better fit.
Settles on copy: it reads cleanly ("copy src to out"), names the observable
result, and aligns with the actions.copy direction in bazelbuild/proposals#396
rather than forking terminology. Renames DuplicateAction -> CopyAction and the
actions/copy_action library.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mFt5KqnN5CxwQVvHGC4zR
6cceadf to
050a01f
Compare
|
Work on this proposal had stalled as I needed a prototype to validate the design. AI has made significant strides since late 2024, so I've been able to (now) quickly put one together and give this proposal a much needed update. It is now ready for another review. |
|
I'd be happy to work with @Silic0nS0ldier, i do believe that copying is still not a the way to solve this, i do believe leaving the copying to the execution strategy is the way to go since they are extremely optimized for staging contents on specific paths before execution. Anything else we do in the name of copying will be duplicating bytes on disk and same pollution as we have today with Copy actions that use coreutils etc. |
|
For copy specifically Bazel already maintains one node for the file, if we design the copy api in a way that duplicates the node, file on disk, we are still not saving anything and frankly making a worse copy action that what we have today. We really need the new copy api to be as cheap as it could be, i came to conclusion that it is only possible with deferred staging (leaving the materialization to the execution strategy because they are heavily optimized for staging inputs cheaply) and skyframe node aliases (cheap clone of the File handles that carries the digest from the backing file) This will only be beneficial if its cheap. rulesets such as rules_js, rules_oci, rules_python, rules_py etc need to workaround the fact that the tools that are being run (nodejs, python) want a specific filesystem layout to function, site-packages, node_modules, mtree |
|
I did notice thesayyn/bazel#3 while updating this PR, but not the proposal till just now. Reading over #419 ( Essentially;
Optimisations are possible across all three (lazy materialisation, deferral to a separate output service a-la The primary motivation for That all said, I don't believe the proposals are mutually exclusive. Sometimes we want a symlink, sometimes we want a copy, sometimes all we really need is for an artifact to show up in another location. For the latter, As far as the optimisations in this proposal, I have deliberately kept them as follow up items (not part of the proposal itself) so as to not hinder landing of the core API. The 1 must-have optimisation (to graduate the API from experimental status) IMO is deferral of source directory copying under remote execution. It is a weakness vs. what spawn based copies can do, but in prototyping proved to be difficult to implement. Keeping this unspecified allows the disk-requirement gap to be closed via hardlinking and copy-on-write (which would likely be much easier to backport). |
050a01f to
bf0d0e3
Compare
|
The current behavior of
If a tool cares about the particular symlink representation, the above already effectively force it to use The crucial point of any new mechanism should be that it explicitly doesn't prescribe the materialization strategy for the output as an input to actions, except that it guarantees one that makes the output independent of the input for all practical purposes (in particular, it must not be a symlink to it). Whether hardlinks are allowed is a subtle question since hardlinks change the I don't think that either proposal needs to focus on how Bazel internally represents these files. Having an action registered for them is already more overhead than anything else Bazel could retain about the files, so that's a natural limit for the scale at which any such action could be used. |
This is the key point, Any other eager materialization strategy, eg CoW/hardlink/reflink by the
I agree, its bazels internal and rulesets don't care how its tracked as long as there are no correctness issues. |
That's a point in favour of hardlinks being opt-in, if we care about that side effect. From memory EngFlow's remote execution implementation uses hardlinks to materialise inputs in executors, some other services likely do the same. Biggest concern I can see in Bazel is that a
Taken in the context of #419, that would mean
I don't quite follow, or maybe I've overlooked something in the proposal. What is the concern that impacts the ability to implement further optimisations? |
|
Small update. I've been working on a lazy download proposal (allows rules to declare non-configurable downloads that can't be eagerly triggered by If a artifact selection API were to be introduced, it would make the foo = ctx.actions.pick_file(bar_dir, "foo")
foo_copy = ctx.actions.declare_file("foo_copy")
ctx.actions.copy(foo, foo_copy)It's 3 lines vs. 2, but would avoid having 2 ways to achieve the same outcome. |
If
the proposal goes beyond RE, its also taking the local execution strategies into account and has the ability to (open question) express content materialization via execution_requirements so things such as nodejs can say i need something that does not use symlinks. |
|
Lazy materialisation where possible is included under this proposal (with one caveat).
This is a part of the I don't think that |

Proposal to introduce a builtin copy action.
Goals
no-remoteandno-cachethat can ultimately lead to greater overhead in remote builds (e.g. forcing remote files to be downloaded under--remote_download_minimal).