Under which category would you file this issue?
Providers
Apache Airflow version
2.10.5
What happened and how to reproduce it?
Issue Description
GCSToGCSOperator ignores replace=False when the source is a single object (no wildcard) and the destination object already exists. The destination is overwritten despite the operator logging that there are no new files to sync.
Steps to reproduce
- Place an object at
gs://source-bucket/path/model.pkl.
- Place an object with the same key at
gs://dest-bucket/path/model.pkl (any different content).
- Run the following operator:
GCSToGCSOperator(
task_id="copy_model",
source_bucket="source-bucket",
source_object="path/model.pkl",
destination_bucket="dest-bucket",
destination_object="path/model.pkl",
replace=False,
source_object_required=True,
)
Observed behavior
The destination is overwritten. Logs:
gcs_to_gcs.py:296 - Replaced destination_object with source_object prefix.
gcs_to_gcs.py:311 - There are no new files to sync. Have a nice day!
gcs_to_gcs.py:535 - Executing copy of gs://source-bucket/path/model.pkl to gs://dest-bucket/path/model.pkl
The operator decides "nothing to sync" and then immediately copies anyway.
Root cause
In _copy_source_without_wildcard:
if not self.replace:
objects = self._ignore_existing_files(...) # filters out files already in destination
result_uris: list[str] = []
if len(objects) == 0 and prefix: # ← entered when destination already has the file
if hook.exists(self.source_bucket, prefix):
uri = self._copy_single_object( # ← copies unconditionally, ignoring `replace`
hook=hook, source_object=prefix, destination_object=self.destination_object
)
When replace=False and the destination already contains the object, _ignore_existing_files correctly empties objects. But the fallback branch if len(objects) == 0 and prefix then proceeds to call _copy_single_object without re-checking self.replace or the destination's existence, overwriting the file.
This branch was originally designed for the case where hook.list() returns nothing for a single-file prefix; it does not distinguish that case from "the destination already has the file, so we filtered it out."
Related history
The single-file fallback in _copy_source_without_wildcard has not been fixed.
What you think should happen instead?
When replace=False, the operator must not overwrite an existing destination object — even on the single-file fallback path. Either:
- Check
self.replace and hook.exists(self.destination_bucket, dest) inside the len(objects) == 0 branch and skip the copy if the destination already exists, or
- Distinguish "objects was empty from the start" vs "objects was emptied by
_ignore_existing_files" and only enter the fallback in the former case.
Operating System
Cloud Composer (Debian-based)
Deployment
Google Cloud Composer
Apache Airflow Provider(s)
google
Versions of Apache Airflow Providers
apache-airflow-providers-google==19.0.0
Official Helm Chart version
Not Applicable
Kubernetes Version
No response
Helm Chart configuration
No response
Docker Image customizations
No response
Anything else?
Reproducible 100% of the time when:
source_object is a single object key (no wildcard)
replace=False
- the destination object already exists
Confirmed the buggy code path still exists on main (see the snippet from _copy_source_without_wildcard quoted above).
Workaround: a custom subclass that checks hook.exists(destination_bucket, dest) before delegating to super().execute().
Are you willing to submit PR?
Code of Conduct
Under which category would you file this issue?
Providers
Apache Airflow version
2.10.5
What happened and how to reproduce it?
Issue Description
GCSToGCSOperatorignoresreplace=Falsewhen the source is a single object (no wildcard) and the destination object already exists. The destination is overwritten despite the operator logging that there are no new files to sync.Steps to reproduce
gs://source-bucket/path/model.pkl.gs://dest-bucket/path/model.pkl(any different content).Observed behavior
The destination is overwritten. Logs:
The operator decides "nothing to sync" and then immediately copies anyway.
Root cause
In
_copy_source_without_wildcard:When
replace=Falseand the destination already contains the object,_ignore_existing_filescorrectly emptiesobjects. But the fallback branchif len(objects) == 0 and prefixthen proceeds to call_copy_single_objectwithout re-checkingself.replaceor the destination's existence, overwriting the file.This branch was originally designed for the case where
hook.list()returns nothing for a single-file prefix; it does not distinguish that case from "the destination already has the file, so we filtered it out."Related history
setvslistindexing bug in the same function; unrelated to this issue.The single-file fallback in
_copy_source_without_wildcardhas not been fixed.What you think should happen instead?
When
replace=False, the operator must not overwrite an existing destination object — even on the single-file fallback path. Either:self.replaceandhook.exists(self.destination_bucket, dest)inside thelen(objects) == 0branch and skip the copy if the destination already exists, or_ignore_existing_files" and only enter the fallback in the former case.Operating System
Cloud Composer (Debian-based)
Deployment
Google Cloud Composer
Apache Airflow Provider(s)
google
Versions of Apache Airflow Providers
apache-airflow-providers-google==19.0.0
Official Helm Chart version
Not Applicable
Kubernetes Version
No response
Helm Chart configuration
No response
Docker Image customizations
No response
Anything else?
Reproducible 100% of the time when:
source_objectis a single object key (no wildcard)replace=FalseConfirmed the buggy code path still exists on
main(see the snippet from_copy_source_without_wildcardquoted above).Workaround: a custom subclass that checks
hook.exists(destination_bucket, dest)before delegating tosuper().execute().Are you willing to submit PR?
Code of Conduct