Add the pure S3PathPairing planner and S3Path.target (step 3.4 of #1063) - #1081
Conversation
Add the public S3PathPairing (pyathena/filesystem/s3_path_pairing.py), built once by S3FileSystem and exposed as S3FileSystem.pairing and AioS3FileSystem.pairing. Its copy_pairs(), move_pairs() and delete_paths() replace the private _copy_paths(), _move_paths() and _expand_delete_paths(); copy_pairs() and move_pairs() both return (source, destination) pairs. S3Path.target, the object that a write to the path replaces, replaces _move_target(). The pairing results are unchanged. Closes #1063. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
S3PathPairing no longer holds the filesystem, and the filesystems no longer store it, so S3FileSystem is freed by reference counting again. Its static rules take the expanded paths and the results of the lookups that they ask for (skips_directories, looks_up_destination, conflict_candidates) and raise ValueError when a needed lookup is not passed. S3FileSystem and AioS3FileSystem each expand the paths and make the lookups with their own requests and cache, in _copy_pairs, _move_pairs and _delete_paths; aio uses its async _expand_path and _isdir instead of running the sync pairing in a thread. The pairing results are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d run the aio lookups concurrently Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d keep the planning off the event loop Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| from pyathena.filesystem.s3_path import S3Path | ||
|
|
||
|
|
||
| class S3PathPairing: |
There was a problem hiding this comment.
Self-review round 1 (implementation behavior, public contracts, simplicity, regression coverage; plus /code-review high twice and /simplify), base 200762088e7b0bac45054f22e32a16aa0ce95dbb, head 4794aa0b95dbbed432b537e8638096d9edb13228. Initial head b09d4cbb reviewed in full, then redesigned in 422c3ad0, and that design reviewed in full again; repairs in 05f37dbe and 4794aa0b.
Inventory: S3PathPairing (new module), S3Path.target, the sync and aio _copy_pairs/_move_pairs/_delete_paths and their callers (copy/get/mv/rm, _copy/_get/_mv/_rm), the removed helpers, the docs, and the tests.
Design finding (b09d4cb): S3FileSystem stored an S3PathPairing that referenced the filesystem.
- Measured:
S3FileSystem(skip_instance_cache=True)was no longer freed by reference counting, while master freed it at once. - The public class also called the private
_head_object(), and aio ran the sync pairing in a thread.
The maintainer called the design fundamentally wrong. After consulting claude-fable-5-1 and Codex (both recommended a pure planner), the planner became pure and static, and the adapters own the lookups. The decision is recorded on #1063. test_freed_by_reference_counting fails on b09d4cb and passes now.
Equivalence evidence: 26 inputs through the old helpers (origin/master), the new sync orchestration and the new aio orchestration gave identical results, errors included. The sync requests were the same multiset. aio listed globs under the literal prefix in 2 cases (fsspec's async _expand_path()), with the same results.
Result: FINDINGS, repaired (see the following comments).
| """ | ||
| if not S3PathPairing.expands(path1, path2): | ||
| return list(zip(path1, path2, strict=False)) | ||
| if sources is None: |
There was a problem hiding this comment.
Round 1, /code-review on 422c3ad0 (no correctness regressions; contract, efficiency and docs findings):
- Repaired in
05f37dbe:copy_pairs()withoutsourcessilently returned[]; it now raisesValueError, unlike an empty expansion.move_pairs(missing=...)now accepts any form of the path and normalizes it throughS3Path.target.- Each move path is parsed once per call.
- The public cross-module references are qualified. The rendered API page now links them; Sphinx has 205 warnings, the same as master.
- Deferred: the sync/aio orchestration duplication, which follows from the chosen design.
/simplify (4 angles) on 05f37dbe, repaired in 4794aa0b:
copy_pairs()reuseslooks_up_destination()for theexistsrule (one copy of the rule).- The move analysis is one private
_moves()returning(named, sources, candidates), andskippedis a set. - The
_delete_paths()guard comments say why the guard exists. - The test uses
_stubbed_fs(). - The docs name
expands()and say thatcopy()/get()use the pairing only when a source has a version ID. S3Path.target's summary no longer claims to be the replaced object in every bucket.- Skipped: the repeated
isinstance(mypy narrowing); the reference-counting test (it guards this review's regression); docs/docstring overlap (repository style); the second_moves()call per move (inherent to the stateless API; about 0.4 s at 100k pairs, against 100k copy requests);s3_objectnaming throughtarget(out of scope).
| sources = await self._expand_path(path1, recursive=recursive, maxdepth=maxdepth) | ||
| if S3PathPairing.skips_directories(path1, recursive, maxdepth): | ||
| # A path with a trailing slash is a directory without a lookup. | ||
| # The paths are looked up in one thread, mostly from the cache. |
There was a problem hiding this comment.
Round 1, aio efficiency (measured by the /simplify efficiency review):
- After the first
/code-review, the per-sourceasyncio.gather(self._isdir(p))cost about 4.5 s at 100k paths, against 0.02 s for oneto_thread. It is back to one thread with the cached syncisdir, as on master. - The pure planning of a 100k-key
_mv()took about 1 s of CPU on the event loop.copy_pairs/conflict_candidates/move_pairsnow run into_thread, as master ran the whole pairing. - The destination lookup stays
await self._isdir(), and a given localisdirruns in a thread. The conflict HEADs are gathered; they are deduplicated and few.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
||
| `S3PathPairing` holds the rules by which `mv()` and `rm()` pair and expand their | ||
| paths, and by which `copy()` and `get()` pair them when a source has a version ID and | ||
| the destination is one path (fsspec pairs the others). The pairing is fsspec's, except that a path with a version |
There was a problem hiding this comment.
Self-review round 2 (claims, callers, AWS operation, docs, evidence), base 200762088e7b0bac45054f22e32a16aa0ce95dbb, head 594d94d752a20bd44c3e0c7d7fda0aeaa76d48e8. Full claim audit of the PR body, the docstrings of S3PathPairing/S3Path.target/the adapters, docs/filesystem.md and the commit messages.
Claims corrected:
- This docs paragraph said that
copy()/get()use the pairing "when a source has a version ID". The code also requires a single destination path (copy():isinstance(path2, str);get():str/PathLike). It now says so (594d94d7). - The PR body described aio as using
_isdir()for every lookup, but since4794aa0bthe sources are checked in oneto_thread. The body now describes the current lookups and threads, its test counts and tested commits are updated, and the Sphinx claim states the commit it was measured on. S3Path.target(round 1): its summary no longer presents the move convention as an S3 fact; versioning-enabled buckets are mv() of a noncurrent null version onto its key does nothing in a versioning-enabled bucket #1083.
Claims verified:
- The destination is looked up only when it decides the pairing, and HEAD is sent only for the conflict candidates (adapter tests, sync and aio).
- The pairing results are unchanged (26-input equivalence, old/sync/aio).
- The sync requests are unchanged; aio's two narrower glob listings are documented.
- The docs example pairs without
destination_is_dir(both paths end with a slash), checked offline. - The removed private helpers and the never-released
pairingproperty have no callers.
Callers and operators: the public signatures of copy/get/mv/rm are unchanged; no new requests in sync; retries go through the same S3Core.call; aio no longer blocks the event loop on pairing CPU or local isdir.
Limits:
- The equivalence ran on a fake store.
- The live subset (224 passed) ran on
422c3ad0and is being repeated on this head once the active Test run of another PR finishes. - The full AWS suite runs in CI on Ready.
Result: FINDINGS (claims), corrected.
… error, and plan off the event loop Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| writers = Counter( | ||
| dest | ||
| for _, _, versioned, source, dest in named | ||
| if source != dest and (versioned or source not in skipped) |
There was a problem hiding this comment.
Independent review (relayed): Codex CLI gpt-6.1-sol, session 01a10636-6328-77e2-8d4f-3782e5e0971c, codex exec -s read-only on a detached snapshot at 594d94d7, diff 20076208..594d94d7. The prompt gave the intended behavior only. Static review.
Coverage (reviewer): the exact diff and the base implementations; the copy/get/mv/rm pairing, lookups, errors and ordering; sync/aio parity, threading, concurrency and cancellation; reference ownership; docstrings, both docs pages, tests.
Result: FINDINGS, three regressions:
- P1:
missingsuppressed an explicitnullversion that shares its target with a missing directory key. For example,src/d(no object) andsrc/d?versionId=nullboth moved todst/outpassed the checks, so the null version could be overwritten and then deleted. The base raised. Repaired in26b39957: a versioned row always counts as a writer.test_move_pairs_version_of_missing_directory_keyfails on594d94d7and passes now. - P2: the aio
delete_paths()and the list/listcopy_pairs()ran on the event loop. Repaired: both run into_thread. - P2: the gathered conflict HEADs raised whichever error came first in time; the base raised the first candidate's. Repaired:
return_exceptions=True, and the first candidate's error is raised in order.test_move_pairs_raises_the_first_lookup_errorfails on594d94d7and passes now.
After the repairs: lint passed; the offline suite matches master's failure set (736 passed); the 26-input equivalence still holds for sync and aio; the live subset gave 225 passed on 26b39957.
There was a problem hiding this comment.
Independent follow-up of 594d94d7..26b39957 (relayed): Codex gpt-6.1-sol, session 01a10640-1435-7b41-bfb3-b4388f4b33b4, read-only snapshot at 26b39957, full review of the repair. Static review.
Coverage (reviewer): versioned writer accounting, missing-directory exemptions, no-op handling and the sync/aio move callers against the base _move_paths; aio thread dispatch, completion of the conflict HEADs, candidate-order errors and None detection; both regression tests; the docs wording. Result: CLEAN.
origin/master (with #1072 and #1080) was then merged in 41cdf8b4. The conflicts were additive:
docs/filesystem.md: Use multipart upload objects and fix checksum completion and cleanup races #1072's multipart paragraph stays in "Typed S3 operations", followed by "Path pairing"; one line rewrapped.test_s3.py: imports.
Use multipart upload objects and fix checksum completion and cleanup races #1072 changed the multipart primitives and copy scheduling, which the pairing does not touch.
After the merge, against a fresh origin/master 0c19c8d baseline:
- lint and docs lint passed;
- offline: identical failure set (161, all AWS), 815 passed;
- 26-input equivalence unchanged;
- live subset: 235 passed on
41cdf8b4.
|
|
||
| ## Path pairing | ||
|
|
||
| `S3PathPairing` holds the rules by which `mv()` pairs its paths and `rm()` expands |
There was a problem hiding this comment.
Independent review (relayed): Claude claude-fable-5-1 (Agent tool, model fable) on the same snapshot 594d94d7, same prompt. Static review.
Coverage (reviewer): the whole planner traced line by line against the base _copy_paths/_move_paths/_move_target/_expand_delete_paths (including the exists rule, the error order and the deduplicated HEADs), the sync and aio adapters, fsspec's installed async _expand_path/_isdir, S3Path.target, the docs and all changed tests.
Result: CLEAN, with low-severity nits:
- aio used
if not object_where sync usesis None. Repaired in26b39957(is None). - The aio
delete_paths()ran on the loop. Repaired, as Codex's finding 2. - A
NamedTuplefor the_moves()rows: not taken (simplicity). - This sentence said that
rm()pairs its paths. Repaired:mv()pairs,rm()expands.
Both snapshots are unchanged after the reviews.
There was a problem hiding this comment.
Independent follow-up of 594d94d7..26b39957 (relayed): Claude claude-fable-5-1, same snapshot, full review of the repair. Static review.
Coverage (reviewer): move_pairs' writer filter and _moves' candidate rules row by row against the base _move_paths, including one divergence it checked and found to have an identical outcome. It also covered every caller of the planner (sync and aio), the aio gather(return_exceptions=True) order, is None parity, and the to_thread dispatch. It traced both new tests by hand and found them deterministic, with the earlier tests consistent, and checked the docs wording. Result: CLEAN.
S3PathPairing now holds path1, path2, recursive and maxdepth, so the questions (expands, skips_directories, looks_up_destination) are properties and copy_pairs(), conflict_candidates() and move_pairs() are methods of one pairing instead of static functions that took the same paths again. delete_paths() stays a classmethod, as rm() has one path. The adapters build one pairing per operation and pass it to _copy_pairs(). The pairing results are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… thread, and use one isdir in aio Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The aio expansion with fsspec's async glob lists with a prefix, which is not cached, so the directory filter of a non-recursive move sent a HeadObject per matched path. AioS3FileSystem now calls the sync _copy_pairs, _move_pairs and _delete_paths in one thread, which sends the same requests as master and keeps the expansion off the event loop; the aio orchestration and its tests are removed. Also write the exists rule of copy_pairs() in the shape of fsspec's copy(), and call _moves() through self. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class S3PathPairing: |
There was a problem hiding this comment.
Self-review, both rounds, of the value-object change (41cdf8b4..30e48052). The maintainer asked for S3PathPairing to become an object of the paths instead of static functions that took them again (decision recorded on #1063).
Round 1 (/code-review high and /simplify):
- Repaired in
97aea57d:- a string passed as
missingwas split into characters; it now raisesTypeError; - aio sent all conflict HEADs at once, wasting requests after the first failure; it now looks them up in order;
- aio used two different
isdirmechanisms; - the docs/docstrings now say exactly which lookups raise
ValueError(the caller leaves out the directories) and that the dataclass keeps the given lists.
- a string passed as
- Repaired in
30e48052(efficiency review, a request regression): the aio orchestration with fsspec's async_expand_path()lists globs with a prefix, which is not cached. The directory filter of a non-recursive_mv()therefore sent one HeadObject per matched path. My earlier equivalence summary had called this "narrower listings"; case 11 in fact had the extra HEAD. The maintainer chose to run the sync orchestration in one thread from aio, as master did. That also removes the sync/aio duplication that/code-reviewflagged, andtest_s3_async.pyis back to master's, originaltest_rm_maxdepthincluded. /simplifyrepairs in the same commit: theexistsrule in fsspec's shape,self._moves(), and one aio code path.- Skipped:
_moves()twice per move; thedelete_paths/_split_version_pathsoverlap; the pair unzip;missingnormalization (already tested); docs/docstring overlap; the reference-counting test is kept as the guard for this review's regression.
Round 2 (claims): the PR body, the #1063 decisions and the docs were re-audited. The body no longer claims aio-native lookups or narrower aio listings, its test counts and tested commit are current, and "the S3 requests are unchanged" is now true for sync and aio. Evidence on 30e48052: lint; offline suite equal to master's failure set (812 passed); 26-input equivalence with the same request multiset (one fsspec set-order difference); live subset 231 passed.
…D requests as master Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| directories.add(parent) | ||
| parent = parent.rpartition("/")[0] | ||
| counts = Counter(dest for _, _, _, source, dest in named if source != dest) | ||
| candidates = [ |
There was a problem hiding this comment.
Independent review of the value-object change (relayed): Codex CLI gpt-6.1-sol, session 01a1065c-d638-7e80-a0d7-d0540099ca9f, codex exec -s read-only on a detached snapshot at 30e48052, diff 41cdf8b4..30e48052 plus the end state against 0c19c8da. Static review.
Coverage (reviewer): the full incremental diff, compared with 0c19c8da for pairing results, errors, the expansion/isdir/HEAD order, the sync/aio callers, the value-object API, reference ownership, docstrings, both docs and the named tests. test_s3_async.py matches master exactly.
Result: FINDINGS. One P2, present since 41cdf8b4: conflict_candidates() deduplicated, so mv(["b/d", "b/d", "b/d/x"], ["b/out", "b/out", "b/x"]) sent one HEAD for b/d where master sent one per pair. A missing HEAD is not cached, so the actual S3 requests changed.
Repaired in 1a73556c: one candidate per pair.
test_conflict_candidates_one_per_pairpins it.- A direct check of that scenario shows the same HEAD keys on master and on this head:
['d', 'd', 'd', 'd', 'd/x']. - The offline suite matches master's failure set (814 passed), and the 26-input equivalence holds.
There was a problem hiding this comment.
Follow-up of 30e48052..1a73556c (relayed): Codex gpt-6.1-sol, session 01a10664-0026-7742-a536-45568e3eb9ba, read-only snapshot at 1a73556c. It covered duplicate candidates and pair order against 0c19c8da, _move_pairs lookups and exemptions including versioned sources, the comment, and both new tests. Result: CLEAN; the per-pair _head_object calls are restored as in master. Static review.
Live subset on 1a73556c: 231 passed.
| source_is_str = isinstance(path1, str) | ||
| glob = isinstance(path1, str) and self._is_glob(path1) | ||
| # As in fsspec's copy(); destination_is_dir is None only when | ||
| # looks_up_destination is false, where the other terms decide. |
There was a problem hiding this comment.
Independent review of the value-object change (relayed): Claude claude-fable-5-1 on the same snapshot 30e48052, same prompt. Static review.
Disclosure from the reviewer: a command fell through to uv run, which created the gitignored .venv in the snapshot. git status stayed clean, and nothing else ran.
Coverage (reviewer):
copy_pairs'existsagainst master's short-circuit, including whendestination_is_diris supplied;- the lookup order, the
_movescandidates and writers (including theversioned orclause), the raise order,_delete_paths, and the aioto_threaddelegation; S3Path.targetagainst_move_target, the absence of a reference cycle, and all tests;test_s3_async.pyequal to master, no stale names, and the docs.
Result: CLEAN. It noted the same HEAD deduplication as a benign reduction, now repaired above. Nits:
delete_pathsis a classmethod withclsunused. Kept as decided on Move copy, multipart and delete requests into S3Core (step 3 of #1053) #1063.- This comment missed the list-source case. Repaired in
1a73556c. - Frozen with list fields makes
hash()fail. Known, documented. - No unit case for a list source with a string destination. Added in
1a73556c.
There was a problem hiding this comment.
Follow-up of 30e48052..1a73556c (relayed): Claude claude-fable-5-1, same snapshot; the prompt also barred running uv, pip and python. Coverage (reviewer):
- the candidates against master's predicate, with network parity in both directions (cached hits, re-sent misses);
move_pairswith duplicate candidates and versioned pairs;- the comment's accuracy in every
looks_up_destination == Falsecase; - both new tests evaluated by hand against fsspec's
other_paths, and the unchanged tests.
Result: CLEAN. Static review.
WHAT
Sub-step 4 of #1063 (step 3 of #1053), the last one: the path pairing of
copy(),get(),mv()andrm()gets a public owner,S3PathPairing, and thenull-version rule moves ontoS3Path.S3PathPairinginpyathena/filesystem/s3_path_pairing.py: a frozen value of the paths that onecopy(),get()ormv()pairs,S3PathPairing(path1, path2, recursive=False, maxdepth=None). It holds no filesystem.expands,skips_directories,looks_up_destinationandconflict_candidates(pairs).copy_pairs(sources, destination_is_dir) -> list[tuple[str, str]]andmove_pairs(pairs, missing) -> list[tuple[str, str]].delete_paths(path) -> (versioned, unversioned)splits the paths of anrm(), which has one path.ValueError, instead of defaulting to "not a directory" or "no object".S3Path.target: the object that a write to the path replaces. Anullversion names its key; any other path is its own target. It replaces_move_target().S3FileSystembuilds one pairing per operation and makes the lookups with its requests and cache, in private_copy_pairs(pairing, isdir=None),_move_pairs()and_delete_paths().AioS3FileSystemcalls these in oneasyncio.to_thread, as master ran the old helpers._copy_paths(),_move_paths()and_expand_delete_paths()._split_version_paths()stays onS3FileSystemas the expansion rule.docs/filesystem.md, andS3PathPairingin the API reference.The pairing results and the S3 requests are unchanged; see TEST.
The design was revised twice in review:
S3PathPairingon the filesystem that referenced the filesystem. That reference cycle keptS3FileSystemfrom being freed by reference counting (measured), and the public class called the private_head_object(). After a comparison with claude-fable-5-1 and Codex, the planner became pure._expand_path()was tried and dropped. Its glob listing has a prefix and is not cached, so the directory filter of a non-recursive_mv()sent a HeadObject per matched path. aio now runs the sync orchestration in a thread.The decisions are recorded on #1063.
WHY
Closes #1063.
#1063 / #1053: the S3 requests and rules live in the core; the adapters keep fsspec's semantics, the cache and the scheduling. The pairing rules now have one public, I/O-free owner, and one orchestration that both filesystems use.
S3PathPairingstays out ofs3_corebecause it follows fsspec's conventions, while the core does not depend on fsspec.Related: #1083 (filed in this review). A
nullversion moved onto its key in a bucket with versioning enabled is left in place by thetargetrule. This behavior predates this PR;S3Path.targetdocuments it.TEST
Tested commit: 1a73556 (lint, offline suite, equivalence, live run). 41cdf8b merged
origin/master0c19c8d (#1072, #1080).just lintandjust docs lint: passed. A Sphinx build on 05f37db had 205 warnings, the same count asorigin/master, and the API page linked the cross-module references toS3PathPairing.copy_pairsandS3Path.target.origin/masterand through the new sync orchestration, which aio now runs in a thread, with the same served keys.maxdepth, versions with file, directory and trailing-slash destinations, list/list and list/str pairing, globs (including one without matches), a key with?, a local destination with a givenisdir,null-version moves, both move conflicts, a directory without an object in a conflict, and deletes with versions, a bucket and a missing path.pytest --noconftest -n 8 tests/pyathena/filesystemgave 814 passed, 161 failed. The failure set is identical toorigin/master0c19c8d (770 passed, 161 failed); all 161 need AWS.test_s3_path_pairing.py: pure tests of every rule, with no filesystem. They cover theValueErrorfor missingsources,destination_is_dirormissing,missinggiven in any form or as a string (TypeError), and anullversion of a missing directory key still counting as a writer (from the independent review; it fails on 594d94d).test_s3_path.py:S3Path.target.test_s3.py:_copy_pairs/_move_pairsmake: no destination lookup for a trailing slash, and HEAD only for the conflict candidate;test_freed_by_reference_counting, which fails on the first design (b09d4cb) and passes now.test_s3_async.pyis unchanged from master, and its_rm/_mv/_copy/_gettests pass.uv run --env-file .env pytest -n 4 tests/pyathena/filesystem/test_s3.py tests/pyathena/filesystem/test_s3_async.py -k "copy or cp_file or mv or move or rm or get or version or expand": 231 passed on 1a73556 (235 on 9cb261e, which still had the aio orchestration tests).🤖 Generated with Claude Code