cache: flush pack writer before periodic chunk-index write (#9900)#9903
Conversation
…p#9900) Otherwise buffered (F_PENDING) chunks are persisted with the placeholder pack_id, and clearing F_NEW prevents their real location from being written.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #9903 +/- ##
==========================================
- Coverage 85.24% 85.13% -0.12%
==========================================
Files 93 93
Lines 15898 15899 +1
Branches 2429 2428 -1
==========================================
- Hits 13552 13535 -17
- Misses 1638 1654 +16
- Partials 708 710 +2 ☔ View full report in Codecov by Harness. |
ThomasWaldmann
left a comment
There was a problem hiding this comment.
can we do it without in-band signalling?
ThomasWaldmann
left a comment
There was a problem hiding this comment.
review by claude fable 5:
Correctness of the fix
The core fix is sound. I verified the load-bearing details against master:
- Shared index object:
ChunksMixin.chunksis literallyrepository.chunks(the cache adopts the repository's index), soRepository.flush()→PackWriter.flush()→update_pack_info()updates exactly the index that_maybe_write_chunks_index()then serializes. No stale-copy problem. - The flush sits inside the
if self._chunks is not None:guard and is a no-op on an empty buffer, so the common path stays cheap.Repository.flush()also does_lock_refresh(), a small bonus on the timer path. - Ordering is right:
add_chunk()runs the timer check before putting the current chunk, so the flush covers everything buffered so far. - Flushing is the right approach vs. the alternative (skipping still-pending entries during serialization): that would also require teaching
clear_new()to spare pending entries, otherwise theirF_NEWwould still be lost — more complexity for no gain. - Performance: worst case is one small, non-full pack per 600 s window; negligible, and correctness requires it.
Empirical check: I applied the diff locally — all 53 cache tests pass; with only the flush() line removed, the new regression test fails (it trips the guard in write_chunkindex_to_repo). So the test really covers the bug.
The in-band signalling point (supporting the requested changes)
To answer the open question from the review discussion: the flag is still available at that point. ChunkIndex.__getitem__ runs hide_system_flags, but that only masks M_SYSTEM (0xff000000, i.e. F_NEW); F_PENDING = 2**2 is in the user-flag range and is only cleared by update_pack_info() after a real flush. Flags are stripped (F_NONE) solely on the serialized copy in the batch. So the assert can simply be:
assert not (entry.flags & ChunkIndex.F_PENDING), f"chunk {bin_to_hex(key)} has no pack location yet"— exact, no collision probability with a legitimate pack ID, no magic value.
Comparing the two suggested alternatives:
- Per-entry flag check: one bitmask test on an entry the loop already fetched — essentially free, and it guards every caller, including calls where the passed index is not
repository.chunks(compact'ssave_chunk_index,repack_chunkindex). - Pack-writer emptiness peek: a single O(1) check (like the existing
assert not self._pack_writer._piecesinRepository.close), but it only sees the repository's own writer, so it wouldn't cover the explicit-chunks cases. The flag assert in the central function seems strictly better for the same cost.
For the test, the in-band-free version is also stronger: after the periodic write, compare against the real location instead of the placeholder —
assert index[H(7)].pack_id == repository.chunks[H(7)].pack_idwhich checks the persisted entry is correct, not merely "not the placeholder". With both changes, the UNKNOWN_BYTES32 imports in cache.py and cache_test.py become unnecessary — a nice sign the in-band constant has fully left the picture.
Minor nits (non-blocking)
Cache.close()already callsself.repository.flush()right before_maybe_write_chunks_index(..., force=True); with the flush now inside_maybe_write_chunks_index, the close-path call is redundant (harmless) — could be removed to keep one canonical place.- The test constructs
AdHocWithFilesCache(manifest)by hand although the class already has acachefixture doing exactly that, and thekeyparameter is unused (manifestalready depends onkey).
|
user-contributed reproducer by @mirko: https://paste.nanl.de/?1212557912c850cc#HB5zEhLysz5dd3UhTAVCptM5PyQTHc9GnvCmevwSHyLK claude: """ What it proves. It puts one chunk (buffered, F_PENDING), does the mid-backup incremental index write the way _maybe_write_chunks_index does (no flush first), flushes, closes, then reopens as a fresh process. On current master it fails because the rebuilt index entry for the chunk is: ChunkIndexEntry(flags=0, size=0, |
The paste link is eventually going to expire, so I'll copy&paste the reproducer into the ticket directly: |
|
review by claude fable 5: Re-review at 1b0fd66 — all points from the previous review round are properly addressed. What changed
Empirical verification (repeated against the new head)
Remaining observationsNothing blocking. One pre-existing quirk noticed in passing, not introduced by this PR: Verdict: looks ready to merge. |
…serializing chunk-index entries Also drop the now-redundant Cache.close flush, since _maybe_write_chunks_index flushes on the force path.
1b0fd66 to
7da670c
Compare
Closes #9900.
The periodic chunk-index write runs on a 600 second timer, independently of the pack writer. When it fires, any chunks added since the last pack flush are still buffered in the pack writer. Their pack location isn't known yet, so in the chunk index they carry the
UNKNOWN_BYTES32placeholder pack_id (b"\xff" * 32).The periodic write serialized those buffered chunks with the placeholder pack_id, then
clear_new()stripped theirF_NEWflag. With the flag gone, the corrected pack location that arrives at the next flush was never written incrementally. A fresh process that rebuilt the index from the stored fragments would then find a live chunk pointing at pack0xff..ff, which shows up asObjectNotFound.The fix flushes the pack writer before the periodic write, so every buffered chunk already has its real pack location by the time it gets serialized. The flush is a no-op on an empty buffer, so it's cheap on the common path.
Cache.closeandRepository.closealready flush or assert an empty buffer before writing, so this just brings the timer path in line with them.A follow-up commit adds an assertion in
write_chunkindex_to_repo, which is the one function every index fragment write goes through. It refuses to serialize an entry that still hasF_PENDINGset, i.e. one whose pack location is unresolved. Checking the flag keeps the sentinel pack_id value out of the guard, and it covers every caller, including the compact paths that pass an index other thanrepository.chunks. If some future caller reaches this code with a buffered chunk, it now fails loudly during development instead of quietly writing a broken location. The assert compiles out underpython -O.There's a regression test that drives the same write the timer triggers while a chunk is still buffered, rebuilds the index from the stored fragments, and checks the chunk's persisted pack_id is real rather than the placeholder.