-
Notifications
You must be signed in to change notification settings - Fork 116
Fix null-version moves in versioning-enabled S3 buckets #1084
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
Changes from all commits
354dcad
421ee3c
ca44c77
4a3edf6
d4d7fe5
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 |
|---|---|---|
|
|
@@ -261,6 +261,23 @@ for version in versions: | |
| Version-aware operations require the `s3:GetObjectVersion` and | ||
| `s3:ListBucketVersions` permissions. | ||
|
|
||
| Moving a `?versionId=null` source onto its own key copies that version and | ||
| deletes the source version when bucket versioning is enabled. If versioning has | ||
| never been enabled or is suspended, the move leaves the source in place: copying | ||
| onto the key would replace the null version that the subsequent deletion removes. | ||
| The same distinction applies to move conflict checks. | ||
|
|
||
| A move compares a null version with its unversioned key using `GetBucketVersioning`, | ||
| which requires `s3:GetBucketVersioning`. Each relevant bucket is looked up once | ||
| during planning; other moves do not make this request. The result is not cached | ||
| between moves. A failed lookup stops the move before any copy or deletion. | ||
| Directory buckets do not support versioning, so their null versions are treated | ||
| as the key itself without a versioning lookup. | ||
|
|
||
| The opt-in S3 versioning integration tests create dedicated temporary buckets and | ||
| check unversioned, enabled, and suspended states. See [Testing](testing.md) for the | ||
| command and required test permissions. | ||
|
|
||
| ## Bucket lifecycle | ||
|
|
||
| Bucket creation and deletion are infrastructure-level changes and are disabled by | ||
|
|
@@ -387,6 +404,13 @@ paths, and pass the results to `copy_pairs()` and `move_pairs()`. A `sources`, | |
| `destination_is_dir` or `missing` that a rule needs and that is not passed raises | ||
| `ValueError`. | ||
|
|
||
| For moves that compare a null version with its unversioned key, the caller also | ||
| looks up bucket versioning and passes the names of the versioning-enabled buckets | ||
| as `versioning_enabled_buckets` to both `conflict_candidates()` and `move_pairs()`. | ||
| Pass a collection of bucket names, such as a set, rather than a single string. | ||
| The default empty collection treats null versions as their keys, as in unversioned | ||
| or suspended buckets. The model makes no AWS requests. | ||
|
|
||
|
Member
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. Compatibility, operational and claim repair self-review: CLEAN. Covered all six repair files separately from the implementation pass. Existing positional arguments, valid collection contexts, default no-op semantics and pure model/no-I/O behavior remain compatible. Both public methods now clearly raise TypeError for a bucket-name string; OSError accurately includes S3Core-translated permission failures through sync/async planners and wrapper. Public documentation states that callers supply the same enabled-bucket collection to candidate discovery and final checks; default-empty behavior and opt-in permissions are explicit. Tests verify both method entry points. Format/lint/docs checks and 226 offline tests passed; live results started at the earlier head will be identified by that revision, while current CI is still pending. No new AWS calls, permissions or runtime claims are introduced by this repair. Frozen repair: ca44c77..4a3edf6; PR base e41fe33. Patch series compared as e41fe33..ca44c77 versus e41fe33..4a3edf6. |
||
| ```python | ||
| from pyathena.filesystem.s3_path_pairing import S3PathPairing | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1414,9 +1414,17 @@ def mv(self, path1, path2, recursive=False, maxdepth=None, **kwargs) -> None: | |
| ``AbstractFileSystem.mv()`` instead removes ``path1`` by expanding it | ||
| again, which also deletes copies placed where ``path1`` matches them | ||
| and files that ``maxdepth`` kept from being copied. A file whose | ||
| destination is the file itself, or the ``null`` version of a file | ||
| moved to the file, is left in place, and directories, which S3 does | ||
| not store as objects, are not copied. | ||
| destination is the file itself is left in place, and directories, | ||
| which S3 does not store as objects, are not copied. A ``null`` version | ||
| moved onto its key is copied and deleted only when bucket versioning | ||
| is enabled. Without versioning, or with versioning suspended, that | ||
| move is left in place because the copy would replace the version | ||
| that the deletion removes. | ||
|
|
||
| Comparing a ``null`` version with its unversioned key calls | ||
| GetBucketVersioning once per bucket during the move's planning and | ||
| requires ``s3:GetBucketVersioning``. The result is not cached across | ||
| moves. Directory buckets do not support versioning and need no lookup. | ||
|
|
||
| Args: | ||
| path1: Source S3 path, glob pattern, or list of paths. | ||
|
|
@@ -1431,6 +1439,7 @@ def mv(self, path1, path2, recursive=False, maxdepth=None, **kwargs) -> None: | |
| destination is another source, including one left in place, | ||
| which is checked before anything is copied. A directory with | ||
| no object at its key, which is not copied, does not conflict. | ||
| OSError: If bucket versioning cannot be read. | ||
| """ | ||
| if path1 == path2: | ||
| return | ||
|
|
@@ -1462,15 +1471,35 @@ def _move_pairs( | |
|
|
||
| Raises: | ||
| ValueError: If the move has conflicting paths. | ||
| OSError: If bucket versioning cannot be read. | ||
| """ | ||
| pairing = S3PathPairing(path1, path2, recursive=recursive, maxdepth=maxdepth) | ||
| pairs = self._copy_pairs(pairing) | ||
| paths = {p: S3Path.parse(p) for pair in pairs for p in pair} | ||
| unversioned = {p.name for p in paths.values() if not p.version_id} | ||
| buckets = { | ||
| p.bucket | ||
| for p in paths.values() | ||
| if p.version_id == "null" | ||
| and p.name in unversioned | ||
| and not self.core._is_directory_bucket(p.bucket) | ||
|
Member
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. Self-review round one, bounded repair follow-up: CLEAN. Base: 0c19c8d; old head: 570ed72; new head: d680586. Verified the old commit objects and git range-diff: the initial patch is unchanged, with one repair commit added. Covered all five repaired files and traced the shared planner and existing S3Core._is_directory_bucket predicate through both sync and async callers. Directory null versions retain the previous identity, without an unsupported lookup, while general-purpose bucket handling is unchanged. Added regressions assert both same-key no-op and separate-destination moves without lookup, plus async no-op behavior. just format, just lint, docs lint, and the current-tree Sphinx build passed; the repaired move selection passed 54 cases and intentionally skipped the nine opt-in cases. The initial reviewed general-purpose bucket run passed all 60 cases, including the nine real AWS cases, with cleanup completed.
Member
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. Relayed independent bounded static follow-up: CLEAN. Reviewer: Claude Code claude-opus-5-5, verified first-party Max (not Enterprise), effort high; session 77be42ea-ce2d-4566-9889-c92c73f3db39. Old/new base: 0c19c8d; old head: 570ed72; new head: d680586. Supplied the bounded patch-series comparison without commit subjects, the literal repair diff, and a tracked-source snapshot. Only Read/Grep/Glob were enabled; no commands, edits, validation, network, PR context, commit messages, or memory access were authorized. Snapshot hashes and author worktree were unchanged afterward. Coverage: lookup selection; the existing directory-bucket predicate and core property; null/key identity and conflicts; both shared-planner callers; affected docstrings; all three added directory-bucket test cases; filesystem documentation; testing-guide headings and related anchors. The reviewer confirmed the unsupported directory-bucket lookup is removed without changing general-purpose bucket lookups, and the general tox/reporting guidance has its own section. Both prior findings are resolved. No actionable defects found within the repaired scope. Static review only: the reviewer ran no tests or builds. Directory-bucket preservation has mock coverage, without a real S3 Express runtime claim. |
||
| } | ||
| versioned_buckets = { | ||
| bucket | ||
| for bucket in buckets | ||
| if self._call(self._client.get_bucket_versioning, Bucket=bucket).get("Status") | ||
|
Member
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. Self-review round two (claims, compatibility, and AWS operation): CLEAN. Frozen scope: 0c19c8d..570ed72; all six changed files. Audited the PR explanation and changed documentation/comments before tracing the corresponding implementation and tests. Confirmed unchanged public signatures and version_aware defaults, version-query/protocol normalization, and preservation of original copy/delete paths. The planner observes bucket state per move; it does not introduce persistent configuration caching. Each relevant bucket has one logical lookup through the existing S3Core request/retry/error-translation path, so service retries may send additional requests. Permission failures remain visible before copying or deleting. S3 moves remain copy/delete operations, without an atomicity guarantee. Checked AWS's documented Enabled, Suspended, and absent-Status behavior and GetBucketVersioning permission, the first-enable propagation wait, local fixture permissions and cleanup, the ordinary-CI skip gate, and Test workflow path selection (PyAthena suite, with unrelated SQLAlchemy/Spark suites skipped by conditions). The targeted tests cover aliases, multiple keys/buckets, state freshness, conflicts, and lookup/copy failures. No actionable compatibility or operational regression found. Corrected the PR validation record to name the full published SHA, dependency versions, and completed documentation builds, including the 205 current-tree Sphinx warnings; none names the changed Markdown or move docstrings. The active real AWS versioning cases remain pending and are not represented as passed.
laughingman7743 marked this conversation as resolved.
|
||
| == "Enabled" | ||
| } | ||
| missing = { | ||
| source | ||
| for source in pairing.conflict_candidates(pairs) | ||
| for source in pairing.conflict_candidates( | ||
| pairs, versioning_enabled_buckets=versioned_buckets | ||
|
Member
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. Validation complete at published/reviewed head 4a3edf6: Ready-triggered Test run 37208194062 passed against merge candidate eec166a (head plus master 855d4a7). Python 3.14: 2572 passed, 10 skipped, 13 warnings. All applicable current-head checks are green; SQLAlchemy jobs and Spark coverage were excluded by unchanged-path conditions, and nine temporary-bucket tests remain intentionally opt-in. Local opt-in run at ca44c77 passed all 66 cases, including nine real AWS versioning cases, and cleanup completed. The final repair preserves that valid set-context AWS path; current-head offline tests passed 226 cases. Required self-reviews and Opus 5.5 / first-party Max / high follow-up are complete and CLEAN; all verified findings are repaired and resolved. PR state rechecked: Ready, MERGEABLE, CLEAN. CI: https://github.com/pyathena-dev/PyAthena/actions/runs/37208194062 |
||
| ) | ||
| if self._head_object(source) is None | ||
| } | ||
| return pairing.move_pairs(pairs, missing=missing) | ||
| return pairing.move_pairs( | ||
| pairs, missing=missing, versioning_enabled_buckets=versioned_buckets | ||
|
Member
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. Additional upstream integration check: master advanced from e41fe33 to 855d4a7 while validation ran. Inspected that literal upstream diff: it extracts S3File buffered multipart writing and updates its tests/docs; S3Core copy/delete, S3PathPairing, sync/async move planners and versioning lookups are unchanged. No move-contract repair or branch rewrite is needed. The frozen review merge-base remains e41fe33, the published/reviewed head is 4a3edf6, offline checks pass and GitHub reports a clean merge. Ready CI will validate integration with the new base. |
||
| ) | ||
|
|
||
|
Member
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. Rebased implementation self-review (round one): CLEAN. |
||
| def _copy_pairs( | ||
| self, pairing: S3PathPairing, isdir: Callable[[str], bool] | None = None | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,7 +28,9 @@ class S3PathPairing: | |
| pairs them, except that a path with a version ID names that version of an | ||
| object: it is not a glob pattern, and its destination is named after its | ||
| key without the version. A move compares the paths by what they name | ||
| (see :attr:`~pyathena.filesystem.s3_path.S3Path.target`). | ||
| (see :attr:`~pyathena.filesystem.s3_path.S3Path.target`), keeping ``null`` | ||
| versions distinct from their keys in the caller's versioning-enabled | ||
| buckets. | ||
| :meth:`delete_paths` splits the paths of an ``rm()``. | ||
|
|
||
| The pairing is a frozen dataclass of the paths as given, and holds no | ||
|
|
@@ -138,7 +140,12 @@ def copy_pairs( | |
| destinations = other_paths(names, path2, exists=exists, flatten=not source_is_str) | ||
| return list(zip(sources, destinations, strict=True)) | ||
|
|
||
| def conflict_candidates(self, pairs: Sequence[tuple[str, str]]) -> list[str]: | ||
| def conflict_candidates( | ||
| self, | ||
| pairs: Sequence[tuple[str, str]], | ||
| *, | ||
| versioning_enabled_buckets: Collection[str] = (), | ||
| ) -> list[str]: | ||
| """Return the sources of a move whose conflicts depend on an object at their key. | ||
|
|
||
| A source with another source below it may be a directory. If no object | ||
|
|
@@ -149,18 +156,28 @@ def conflict_candidates(self, pairs: Sequence[tuple[str, str]]) -> list[str]: | |
| Args: | ||
| pairs: The sources and destinations of the move, as | ||
| :meth:`copy_pairs` of this pairing returns them. | ||
| versioning_enabled_buckets: Buckets whose versioning is enabled. | ||
| Their ``null`` versions are distinct from their unversioned | ||
| keys. Use the same collection for :meth:`move_pairs`. | ||
|
|
||
| Returns: | ||
| The sources, in ``bucket/key`` form (their | ||
| :attr:`~pyathena.filesystem.s3_path.S3Path.target`), that have | ||
| another source below them and a destination that conflicts, one | ||
| per pair in the order of the pairs; empty if nothing needs to be | ||
| looked up. | ||
|
|
||
| Raises: | ||
| TypeError: If ``versioning_enabled_buckets`` is a string. | ||
| """ | ||
| return self._moves(pairs)[2] | ||
| return self._moves(pairs, versioning_enabled_buckets)[2] | ||
|
|
||
| def move_pairs( | ||
| self, pairs: Sequence[tuple[str, str]], missing: Collection[str] | None = None | ||
| self, | ||
| pairs: Sequence[tuple[str, str]], | ||
| missing: Collection[str] | None = None, | ||
| *, | ||
| versioning_enabled_buckets: Collection[str] = (), | ||
| ) -> list[tuple[str, str]]: | ||
| """Check the pairs of a move and leave out the sources that stay in place. | ||
|
|
||
|
|
@@ -170,26 +187,31 @@ def move_pairs( | |
| missing: The :meth:`conflict_candidates` without an object at their | ||
| key, in any form that names them; needed when there are | ||
| candidates. | ||
| versioning_enabled_buckets: Buckets whose versioning is enabled. | ||
|
Member
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. Rebased compatibility, operational and claim self-review (round two): CLEAN. |
||
| Their ``null`` versions are distinct from their unversioned | ||
| keys. Use the same collection for :meth:`conflict_candidates`. | ||
|
|
||
| Returns: | ||
| The pairs, except those whose destination is the source itself or, | ||
| for a ``null`` version, the key of the source. | ||
| for a ``null`` version in a bucket without enabled versioning, | ||
| the key of the source. | ||
|
|
||
| Raises: | ||
| ValueError: If two sources have the same destination, or a | ||
| destination is another source, including one left in place, | ||
| except for a directory with no object at its key, which is not | ||
| copied. Also if ``missing`` is needed and None. | ||
| TypeError: If ``missing`` is a string instead of a collection. | ||
| Also if ``versioning_enabled_buckets`` is a string. | ||
| """ | ||
| if isinstance(missing, str): | ||
| raise TypeError("missing is a collection of paths, not a path.") | ||
| named, sources, candidates = self._moves(pairs) | ||
| named, sources, candidates = self._moves(pairs, versioning_enabled_buckets) | ||
| if missing is None and candidates: | ||
| raise ValueError("missing is needed to check the pairs.") | ||
| # A directory without an object at its key writes no destination. | ||
| skipped = set(candidates).intersection( | ||
| str(S3Path.parse(path).target) for path in missing or () | ||
| self._target(S3Path.parse(path), versioning_enabled_buckets) for path in missing or () | ||
| ) | ||
| # A path with a version always names an object, so it writes its | ||
| # destination even when the key has no current object. | ||
|
|
@@ -244,21 +266,44 @@ def _is_glob(path: str) -> bool: | |
| """ | ||
| return has_magic(path) and not S3Path.has_version_id(path) | ||
|
|
||
| @staticmethod | ||
| def _target(path: S3Path, versioning_enabled_buckets: Collection[str]) -> str: | ||
| """Return a move target using the caller's bucket versioning state. | ||
|
|
||
| Args: | ||
| path: The path of the source or destination. | ||
| versioning_enabled_buckets: Buckets whose versioning is enabled. | ||
|
|
||
| Returns: | ||
| The normalized path, retaining a ``null`` version only when its | ||
| bucket has versioning enabled. | ||
| """ | ||
| return str(path if path.bucket in versioning_enabled_buckets else path.target) | ||
|
laughingman7743 marked this conversation as resolved.
|
||
|
|
||
| @staticmethod | ||
| def _moves( | ||
| pairs: Sequence[tuple[str, str]], | ||
| versioning_enabled_buckets: Collection[str] = (), | ||
| ) -> tuple[list[tuple[str, str, bool, str, str]], set[str], list[str]]: | ||
| """Compare the paths of a move by what they name. | ||
|
|
||
| Args: | ||
| pairs: The sources and destinations of the move. | ||
| versioning_enabled_buckets: Buckets whose versioning is enabled. | ||
|
|
||
| Returns: | ||
| Each pair with whether its source has a version and the targets | ||
| of its source and destination; the targets of all sources, | ||
| including those left in place; and the conflict candidates (see | ||
| :meth:`conflict_candidates`). | ||
|
|
||
| Raises: | ||
| TypeError: If ``versioning_enabled_buckets`` is a string. | ||
| """ | ||
| if isinstance(versioning_enabled_buckets, str): | ||
| raise TypeError( | ||
|
Member
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. Implementation repair self-review: CLEAN. Covered all six repair files plus shared callers and contracts using the verified old objects and patch-series comparison. The shared _moves entry rejects a string bucket context before target membership in both public methods; tuple/set/default behavior and internal set calls are preserved. The two regressions exercise the misleading logs/logs-archive substring case. The public model docs describe both required inputs and their default; sync/async exception and private helper docstrings match actual propagation. The valid AWS move path is unchanged by this guard; the opt-in run started on the old head remains in progress. Format/lint/docs lint/build passed and 226 self-contained tests passed. Frozen repair: ca44c77..4a3edf6; PR base e41fe33. Patch series compared as e41fe33..ca44c77 versus e41fe33..4a3edf6.
Member
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. Relayed independent repair review: CLEAN (no defects found). Claude Code claude-opus-5-5, verified first-party Max profile, high effort; session 180b3bea-3d55-4627-bdaa-8fe4c0d8090e, completed in 94257 ms. Model metadata confirms claude-opus-5-5 / firstParty; no permission denials. Verified old/new series: e41fe33..ca44c77 versus e41fe33..4a3edf6. All six repair files and affected callers/contracts covered using the subject-free range-diff and literal repair diff. Read/Grep/Glob only; no edits, commands, builds/tests, network/GitHub or memory access. The exported snapshot remained unchanged, and the author worktree is clean. The reviewer confirms the shared _moves guard rejects strings before any membership lookup in both public entry points, including empty pairs; valid collection and default behavior is preserved. Public docs describe the same collection for candidate discovery/final checks and the empty default. Private helper Args/Returns, sync/async OSError propagation, and the unversioned mock comment are accurate. The two tests exercise the substring failure scenario. No further actionable findings. Static review only; runtime/lint/docs results are author validation. On the repaired head, format/lint/docs checks and 226 offline tests passed. At ca44c77, the serial real AWS selection passed all 66 tests, including nine opt-in cases, and temporary-bucket cleanup completed. The repair only rejects invalid string context and updates prose; the internal AWS caller continues to pass a set. S3 Express remains mock-only coverage. Current-head AWS CI will follow Ready. |
||
| "versioning_enabled_buckets is a collection of bucket names, not a bucket name." | ||
| ) | ||
| named = [] | ||
| for p1, p2 in pairs: | ||
| source_path = S3Path.parse(p1) | ||
|
|
@@ -267,8 +312,8 @@ def _moves( | |
| p1, | ||
| p2, | ||
| bool(source_path.version_id), | ||
| str(source_path.target), | ||
| str(S3Path.parse(p2).target), | ||
| S3PathPairing._target(source_path, versioning_enabled_buckets), | ||
| S3PathPairing._target(S3Path.parse(p2), versioning_enabled_buckets), | ||
| ) | ||
| ) | ||
| # The sources left in place count too; a copy onto one overwrites it. | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.