Stage 12 · Multipart crash recovery¶
Goal¶
Prove both sides of a completion crash: keep retryable staging before publish, clean it after publish.
Hands-on task¶
Starting from stage-11, Exercise _recover_uploads(...) with crashes before and after manifest publication. Keep all behavior inside the listed source-like boundaries; do not copy the patch first.
Deliverable files / 交付文件¶
tests/test_storage.py
Self-check¶
-
Where is this stage's visibility or state transition owned?
Answer
Recovery correlates a published object with its upload ID to make cleanup idempotent.
-
Which test would fail first if the new boundary were bypassed?
Answer
Read
tests.txt, identify the narrowest new node, and name the public call it exercises.
Pass command¶
uv run pytest -q $(cat journey/stages/12-multipart-recovery/tests.txt)
The real S3 lesson¶
Recovery correlates a published object with its upload ID to make cleanup idempotent.
Textbook¶
After finishing, use git checkout stage-12 to compare your result.
Try first, then peek: stage.patch
diff --git a/tests/test_storage.py b/tests/test_storage.py
index afc9a8a..96bc973 100644
--- a/tests/test_storage.py
+++ b/tests/test_storage.py
@@ -4,7 +4,13 @@ from pathlib import Path
import pytest
-from minis3 import InjectedCrash, MiniS3, NoSuchKey, SequenceCounter
+from minis3 import (
+ InjectedCrash,
+ MiniS3,
+ NoSuchKey,
+ NoSuchUpload,
+ SequenceCounter,
+)
from minis3.bucket import Bucket
from minis3.storage import atomic, disk
from minis3.storage.disk import DiskStorage
@@ -152,3 +158,56 @@ def test_recovery_removes_spurious_tmp_files(tmp_path: Path) -> None:
MiniS3(tmp_path)
assert not stray.exists()
+
+
+def test_multipart_complete_crash_before_publish_keeps_upload_not_object(
+ tmp_path: Path,
+) -> None:
+ MiniS3(tmp_path).create_bucket("b")
+ staging = MiniS3(tmp_path, minimum_part_size=3)
+ upload = staging.create_multipart_upload("b", "movie")
+ first = staging.upload_part("b", "movie", upload.upload_id, 1, b"abc")
+ last = staging.upload_part("b", "movie", upload.upload_id, 2, b"x")
+ crashing = MiniS3(
+ tmp_path,
+ minimum_part_size=3,
+ crash_injector=CrashOnce("before_manifest_publish"),
+ )
+
+ with pytest.raises(InjectedCrash):
+ crashing.complete_multipart_upload(
+ "b", "movie", upload.upload_id, [first, last]
+ )
+
+ reopened = MiniS3(tmp_path, minimum_part_size=3)
+ with pytest.raises(NoSuchKey):
+ reopened.get_object("b", "movie")
+ completed = reopened.complete_multipart_upload(
+ "b", "movie", upload.upload_id, [first, last]
+ )
+ assert completed.body == b"abcx"
+
+
+def test_multipart_complete_crash_after_publish_recovers_object_and_cleans_upload(
+ tmp_path: Path,
+) -> None:
+ MiniS3(tmp_path).create_bucket("b")
+ staging = MiniS3(tmp_path, minimum_part_size=3)
+ upload = staging.create_multipart_upload("b", "movie")
+ first = staging.upload_part("b", "movie", upload.upload_id, 1, b"abc")
+ last = staging.upload_part("b", "movie", upload.upload_id, 2, b"x")
+ crashing = MiniS3(
+ tmp_path,
+ minimum_part_size=3,
+ crash_injector=CrashOnce("after_manifest_publish"),
+ )
+
+ with pytest.raises(InjectedCrash):
+ crashing.complete_multipart_upload(
+ "b", "movie", upload.upload_id, [first, last]
+ )
+
+ reopened = MiniS3(tmp_path, minimum_part_size=3)
+ assert reopened.get_object("b", "movie").body == b"abcx"
+ with pytest.raises(NoSuchUpload):
+ reopened.abort_multipart_upload("b", "movie", upload.upload_id)