compass(tests): the abandoned-staging pin checks what is inside, not only that it stands - #260
Conversation
…only that it stands `test_an_abandoned_staging_directory_does_not_block_a_publish` asserted `abandoned.is_dir()` after a publish. That stays true when a publish empties another publisher's staging directory and leaves it in place, so the test passed with that corruption in the store. It now also asserts the directory holds exactly the half-built file it was given, with its bytes unchanged. Closes #216 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review of PR #260 (issue #216), head
|
| tree | mutation | store file | both files | failing node id / assertion |
|---|---|---|---|---|
tip f5003b255 |
none (null) | 40 passed | 118 passed | - |
head e55679e99 |
none (null) | 40 passed | 118 passed | - |
| tip | cleared, not removed | 40 passed: blind | 118 passed | - |
| head | cleared, not removed | 1 failed, 39 passed | 1 failed, 117 passed | tests/compass/test_artifact_store.py::test_an_abandoned_staging_directory_does_not_block_a_publish, :578 assert {} == {'prices.dp0o...son': b'half'} |
| tip | pre-fix staging (fc4c47560's three lines in place of :472-474) |
1 failed, 39 passed | 1 failed, 117 passed | same node id, :571 assert abandoned.is_dir() |
| head | pre-fix staging | 1 failed, 39 passed | 1 failed, 117 passed | same node id, :577 assert abandoned.is_dir() |
This matches the dev record row for row, reproduced through a different corruption mechanism.
The pin still bites on its original defect. It does so through the original is_dir() line, which is why keeping that line is right: the pre-fix failure keeps its own message instead of becoming a FileNotFoundError out of iterdir().
The pristine store.py md5 (b1f635d5...) was restored and checked after each tree's battery.
2. Is the new assertion too tight? No. It pins exactly the named property (principles 3 and 8)
The assertion constrains exactly one thing: the contents of another publisher's staging directory, as name-to-bytes. It says nothing about the publisher's own staging, the destination, or the parent directory.
Measured. A store that writes a marker into its own staging and removes it before the rename stays green: 40 passed at head, and 118 passed across both files. The mutation was line-preserving:
:474); (staging / ".marker").write_bytes(b"m"):483(staging / ".marker").unlink(); os.rename(...)
Read, not measured. The other plausible future changes:
- A reaper that removes stale staging. It already fails the pre-existing
is_dir(), so the new line adds no new constraint there. Allowing a reaper would be a deliberate change to the docstring's "a crashed one is not in anybody's way", not collateral damage. - A store that writes a lock or marker into sibling staging. That is touching another publisher's staging, which is precisely the property the test names.
So the only thing the new line refuses that is_dir() did not is the neighbouring defect that #216 names. The staging layout is not over-specified.
3. Pin inventory in the PR body
Measured row: confirmed. Line :482 becomes item.chmod(0o444 if item.name != ENTRY_FILE else 0o644). That gives 40 passed on the store file and 118 passed across both artifact files, at tip and at head. Nothing notices a writable entry.json.
Follow-up ruling: file it (principle 8). It is a real green mutant on behaviour the store implements deliberately: _write chmods every staged item to 0o444, including entry.json. Three tests also presume the property without asserting it. They chmod(0o644) entry.json before hand-editing it:
test_artifact_store.py:490and:509test_artifact_invalidation.py:669
The fix is the same size as this PR: one assertion beside :422's member-mode check. It should be its own issue, not folded in here. It is outside #216's named result, and this PR should stay one-assertion-sized (principles 3 and 5).
notes TypeError claim: true. I reverted both guards with a line-preserving pair:
:205if False::484except OSError as clash:
With both reverted, test_artifact_store.py stays at 40 passed, blind. With test_artifact_invalidation.py added, the result is 2 failed, 116 passed:
tests/compass/test_artifact_invalidation.py::test_notes_that_are_not_text_are_refused_and_leave_nothing_behindtests/compass/test_artifact_invalidation.py::test_a_publish_that_fails_on_the_way_to_the_rename_leaves_nothing_behind
So the brief's "pre-existing and unpinned" was stale. It is fixed and pinned, and the pin lives in the other file.
Rows marked "not measured". These are honestly labelled as read-from-code. None of them is a blocker for this PR.
4. No design-doc references
The added lines (docstring paragraph, half_built, the assertion) contain no design-doc numbers, no principle citations and no gate labels.
5. Gate 1: the tree that will land
The merge tree. git merge-tree --write-tree f5003b255 e55679e99 gives d5df206f64d6a6a0c50df3a033f1d7a0f67747bf. The merge is clean (rc 0). The tip fork/feature/atomcompass_new was re-read at f5003b255 when this review started.
How it was staged. The merged tree was gated once on node 18. It carried:
- its own
scripts/compass; - a
.compass-commitstamp holding the tree hash; - a
.compass-changedstamp fromgit diff --name-only f5003b255 d5df206f6, which istests/compass/test_artifact_store.pyonly.
The run was bounded by timeout -k 10 2400 and unpiped. atom.__file__ was /tmp/xiaobizh-pr260-review/stage/merged/ATOM/atom/__init__.py. The gate printed commit: d5df206f6 (stamp) and gpu: not required.
| tree | passed | skipped | xfailed | failed | GATE_CPU_RC |
|---|---|---|---|---|---|
merged d5df206f6 |
5056 | 149 | 3 | 0 | 0 |
This is the expected 5056 for tip f5003b255. The 12 above the developer's 5044 at e1da5404e come from #212, which is the only change between the two tips (inferred from the diff, not gated separately). This PR adds no node ids. The gate printed pytest: rc=0. Nothing failed, so there was nothing to check against the flaky TestTheRegionIsNotCopiedPerChunk class.
The staging was removed from node 18 afterwards.
Closes #216
What changed
One assertion, in
test_an_abandoned_staging_directory_does_not_block_a_publish(
tests/compass/test_artifact_store.py). After the publish, the test used to check onlyabandoned.is_dir(). It now also checks that the abandoned directory holds exactly thehalf-built file it was given, with its bytes unchanged:
The half-built file is now bound to a local (
half_built) so the assertion can name it,and the docstring gains a four-line paragraph saying why
is_dir()alone is not enough. Theis_dir()assertion stays, so the original defect still fails with its own message.No production code changed.
Named result: re-measured, not cited
Re-measured at the current tip. The brief's numbers came from
065f34f06. Since then thefile gained a test (there are now 40, not 39) and the assertion moved from line 503 to 571
(577 at head).
The corruption is a line-count-preserving mutation of
ArtifactStore._write(
atom/compass/artifacts/store.py, 493 lines before and after). Line 471 empties everysibling staging directory of the destination but leaves each directory in place:
Node 18,
xiaobizh_n18_cpu. Each tree is agit archivecopy staged withdocker exec -i,and
atom.__file__resolves under that tree's root on every run.Command:
pytest -q tests/compass/test_artifact_store.py.e1da5404ee55679e99tests/compass/test_artifact_store.py::test_an_abandoned_staging_directory_does_not_block_a_publish, at:578,assert {} == {'prices.dp0o...son': b'half'}e1da5404ee55679e99e1da5404e:571assert abandoned.is_dir()e55679e99:577assert abandoned.is_dir()The "pre-fix staging code" mutation takes the three lines of
fc4c47560's_write(afixed
.<entry>.publishingname,shutil.rmtree,mkdir) and puts them in place of thethree
mkdtemplines, 472–474. So the pin still bites on the defect it was written for,and it bites through the original assertion, not the new one.
The whole table was also measured once at
fa5180b62, the tip when this was claimed.#164landed during the work, and the branch was rebased onto it. The results wereidentical there (40 / 40 / 1F+39 / 40 / 1F+39 / 1F+39).
Gate 1: CPU tier, as a delta against a measured control
Each tree used its own
scripts/compass/gate_cpu.sh, with.compass-commitand.compass-changedstamps written from the samerev-parseas the archive. Every run wasbounded by
timeout -k 10 1500, was unpiped, and ran one at a time.GATE_CPU_RCe1da5404ee55679e99test. The collected ids of
test_artifact_store.pyare identical on both sides (40).fa5180b62/a03c67ba9(before the rebase) gave 5004 / 149 / 3,GATE_CPU_RC=0, on both sides.gpu: not requiredon both sides.one statement rewritten to use it, one
assertadded, and a four-line docstring paragraph (plus its blank line).ruff checkandblack --checkon the file: rc 0 and rc 0.git merge-tree --write-tree e1da5404e e55679e99→58a48d516ba266a99b5994b1dbc01e3764903b87, clean.Gate 2
No new test function. The brief sizes the fix as roughly one assertion, and it lands
inside the pin it repairs. It is CPU-only and runs in the CPU tier above.
What surprised me
notesdefect named in the brief is already fixed and already pinned. At thistip,
publishrefuses non-textnotesbefore any staging exists, and_write'sexceptalso catchesTypeError/ValueError.if False:at 205, andexcept OSErrorat 484). TheTypeErrorand the leaked.<entry>.<random>stagingdirectory came back, confirmed with a probe.
test_artifact_store.pystayed at 40 passed.test_artifact_invalidation.pyfailed 2:test_notes_that_are_not_text_are_refused_and_leave_nothing_behindandtest_a_publish_that_fails_on_the_way_to_the_rename_leaves_nothing_behind.changed for it.
than carried across.
Pin inventory: what each other pin in this file does not notice
"Measured" means a line-preserving mutation was run against
test_artifact_store.pyandtest_artifact_invalidation.pytogether. Everything else is read from the code, notmeasured.
test_the_six_artifacts_are_the_ones_the_key_table_declaresKEY_FIELDSagainst the table. Also a field whose words happen to appear elsewhere in the same cell, because it checks count plus a substring.dirname. Not measured.test_a_price_list_asked_for_by_path_is_refused_by_namemodel(onlysource_rootand apath=field are tried).test_two_keys_never_share_a_directorymodelorsource_root. Onlywidthvaries.dirnamecarries a hash of the key.test_the_bare_name_is_never_produced,test_four_ranks_do_not_resolve_to_one_namedp/tpabove width 1.ppandpcpare never above 1 anywhere in this file.AXES.test_an_entry_round_trips_through_the_naming_functionnotes, fingerprints and gates round-tripping. The first is checked by the notes-rewrite test, the other two intest_artifact_invalidation.py.test_a_notes_only_rewrite_of_a_handed_off_entry_is_refusedtest_a_member_changed_after_hand_off_is_refusedentry.jsonitself is read-only. Only one member's mode is checked.item.chmod(0o444 if item.name != ENTRY_FILE else 0o644)gives 118 passed across both artifact test files. Nothing notices a writableentry.json.test_a_hand_edited_entry_is_refused_by_nameentry.jsonwell-formed, for example changednotes.readaccepts it and reports a new digest. The two exception-name cases ("KeyError","ValueError") are loose needles.test_a_truncated_entry_is_refused_by_name,test_a_missing_entry_names_the_key_that_missed,test_an_entry_moved_by_hand_is_found_outtest_an_empty_directory_in_the_way_is_not_silently_replaced_refuse_overwritebranch, so it is probably fine.test_an_abandoned_staging_directory_does_not_block_a_publish(this PR)test_the_store_is_a_directory_convention_with_no_index*index*/*manifest*. The top-level set check ({"price_list"}) catches a sibling, but not a file insideprice_list/.test_a_source_root_is_located_without_importing_itchecks only the first candidate that has not been imported yet.test_a_tree_that_is_neither_is_refuseddepends on the box having no stamp in any ancestor. It asserts that, so it fails loudly rather than silently.Proposed follow-up (not filed). Pin
entry.jsonread-only after publish, next to themember-mode check. It is the one measured green mutant. Today a writable
entry.jsonturns a hand edit from "had to chmod first" into a silent write, and
readthen answersunder the edited document.
What was left undone
🤖 Generated with Claude Code