Stage 04 · 对象服务门面¶
目标¶
用加锁服务连接 Bucket 与存储,支持版本化 PUT、GET、HEAD 与 DELETE。
动手任务¶
从stage-03开始,实现 MiniS3.__init__、Bucket 操作、对象操作与 _bucket(name)。 行为必须留在下列源码同构边界中;不要先复制补丁。
交付文件¶
src/minis3/__init__.pysrc/minis3/store.pytests/test_storage.pytests/test_versioning.py
自查¶
-
本阶段的可见性或状态迁移由谁负责?
答案
强一致性来自单一变更锁,以及先发布再替换候选状态。
-
如果绕过新边界,哪个测试会最先失败?
答案
阅读
tests.txt,找出最窄的新节点,并说出它覆盖的公开调用。
通关命令¶
uv run pytest -q $(cat journey/stages/04-object-service/tests.txt)
对应真实 S3 的一课¶
强一致性来自单一变更锁,以及先发布再替换候选状态。
教材¶
完成后可运行 git checkout stage-04 对照你的结果。
先做后看:stage.patch
diff --git a/src/minis3/__init__.py b/src/minis3/__init__.py
index 8a3d1c7..11378e1 100644
--- a/src/minis3/__init__.py
+++ b/src/minis3/__init__.py
@@ -1,3 +1,6 @@
"""Public API for the MiniS3 teaching implementation."""
from .errors import BucketAlreadyExists, BucketNotEmpty, InvalidContinuationToken, MiniS3Error, NoSuchBucket, NoSuchKey, NoSuchVersion
+from .bucket import SequenceCounter, VersioningState
from .model import DeleteMarker, ObjectRecord, Version, content_etag
+from .store import MiniS3
+from .storage import InjectedCrash
diff --git a/src/minis3/store.py b/src/minis3/store.py
new file mode 100644
index 0000000..5d418b8
--- /dev/null
+++ b/src/minis3/store.py
@@ -0,0 +1,104 @@
+"""Public service facade joining buckets, object state, and list projections."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from copy import deepcopy
+from pathlib import Path
+from threading import RLock
+
+from .bucket import Bucket, SequenceCounter, VersioningState
+from .errors import BucketAlreadyExists, BucketNotEmpty, NoSuchBucket
+from .model import ObjectVersion, Version
+from .storage import DiskStorage
+
+
+class MiniS3:
+ """A deterministic collection of strongly consistent buckets."""
+
+ def __init__(
+ self,
+ root: str | Path,
+ *,
+ counter: Callable[[], int] | None = None,
+ crash_injector: Callable[[str], None] | None = None,
+ ) -> None:
+ self.root = Path(root)
+ self._counter = counter or SequenceCounter()
+ self._storage = DiskStorage(root, crash_injector=crash_injector)
+ self._buckets, maximum_sequence = self._storage.load_buckets()
+ ensure = getattr(self._counter, "ensure_at_least", None)
+ if ensure is not None:
+ ensure(maximum_sequence + 1)
+ self._lock = RLock()
+
+
+ def create_bucket(self, name: str) -> None:
+ with self._lock:
+ if name in self._buckets:
+ raise BucketAlreadyExists(name)
+ bucket = Bucket(name)
+ self._storage.create_bucket(bucket)
+ self._buckets[name] = bucket
+
+
+ def delete_bucket(self, name: str) -> None:
+ with self._lock:
+ bucket = self._bucket(name)
+ if bucket.records:
+ raise BucketNotEmpty(name)
+ self._storage.delete_bucket(name)
+ del self._buckets[name]
+
+
+ def set_bucket_versioning(
+ self, name: str, state: VersioningState | str
+ ) -> None:
+ with self._lock:
+ candidate = deepcopy(self._bucket(name))
+ candidate.set_versioning(state)
+ self._storage.persist_bucket(candidate)
+ self._buckets[name] = candidate
+
+
+ def put_object(self, bucket: str, key: str, body: bytes) -> Version:
+ with self._lock:
+ candidate = deepcopy(self._bucket(bucket))
+ result = candidate.put(key, body, self._counter)
+ self._storage.persist_bucket(candidate)
+ self._buckets[bucket] = candidate
+ return result
+
+
+ def get_object(
+ self, bucket: str, key: str, *, version_id: str | None = None
+ ) -> Version:
+ with self._lock:
+ return self._bucket(bucket).get(key, version_id)
+
+
+ def head_object(
+ self, bucket: str, key: str, *, version_id: str | None = None
+ ) -> Version:
+ """Return object metadata; M1 reuses the immutable Version value."""
+
+ return self.get_object(bucket, key, version_id=version_id)
+
+
+ def delete_object(
+ self, bucket: str, key: str, *, version_id: str | None = None
+ ) -> ObjectVersion | None:
+ with self._lock:
+ candidate = deepcopy(self._bucket(bucket))
+ result = candidate.delete(key, self._counter, version_id)
+ self._storage.persist_bucket(candidate)
+ self._buckets[bucket] = candidate
+ return result
+
+
+ def _bucket(self, name: str) -> Bucket:
+ try:
+ return self._buckets[name]
+ except KeyError as exc:
+ raise NoSuchBucket(name) from exc
+
diff --git a/tests/test_storage.py b/tests/test_storage.py
new file mode 100644
index 0000000..c96a7a6
--- /dev/null
+++ b/tests/test_storage.py
@@ -0,0 +1,32 @@
+"""Disk tests pin the manifest publication crash boundary."""
+
+from pathlib import Path
+import pytest
+from minis3 import InjectedCrash, MiniS3, NoSuchKey, SequenceCounter
+from minis3.bucket import Bucket
+from minis3.storage import atomic, disk
+from minis3.storage.disk import DiskStorage
+
+
+class CrashOnce:
+ def __init__(self, target: str) -> None:
+ self.target = target
+ self.used = False
+
+ def __call__(self, point: str) -> None:
+ if point == self.target and not self.used:
+ self.used = True
+ raise InjectedCrash(point)
+
+
+def test_restart_restores_versions_bodies_and_counter(tmp_path: Path) -> None:
+ store = MiniS3(tmp_path)
+ store.create_bucket("b")
+ store.set_bucket_versioning("b", "enabled")
+ first = store.put_object("b", "k", b"one")
+
+ reopened = MiniS3(tmp_path)
+ second = reopened.put_object("b", "k", b"two")
+
+ assert reopened.get_object("b", "k", version_id=first.version_id).body == b"one"
+ assert second.version_id != first.version_id
diff --git a/tests/test_versioning.py b/tests/test_versioning.py
new file mode 100644
index 0000000..389e45d
--- /dev/null
+++ b/tests/test_versioning.py
@@ -0,0 +1,109 @@
+"""Versioning is the central state-machine contract of M1."""
+
+from pathlib import Path
+import pytest
+from minis3 import BucketNotEmpty, MiniS3, NoSuchKey, NoSuchVersion, SequenceCounter, VersioningState
+from minis3.bucket import Bucket
+
+
+@pytest.mark.parametrize(
+ ("initial", "requested", "allowed"),
+ [
+ (VersioningState.UNVERSIONED, VersioningState.UNVERSIONED, True),
+ (VersioningState.UNVERSIONED, VersioningState.ENABLED, True),
+ (VersioningState.UNVERSIONED, VersioningState.SUSPENDED, False),
+ (VersioningState.ENABLED, VersioningState.UNVERSIONED, False),
+ (VersioningState.ENABLED, VersioningState.ENABLED, True),
+ (VersioningState.ENABLED, VersioningState.SUSPENDED, True),
+ (VersioningState.SUSPENDED, VersioningState.UNVERSIONED, False),
+ (VersioningState.SUSPENDED, VersioningState.ENABLED, True),
+ (VersioningState.SUSPENDED, VersioningState.SUSPENDED, True),
+ ],
+)
+def test_versioning_state_machine_exhaustive(
+ initial: VersioningState,
+ requested: VersioningState,
+ allowed: bool,
+) -> None:
+ bucket = Bucket("b", versioning=initial)
+
+ if allowed:
+ bucket.set_versioning(requested)
+ assert bucket.versioning is requested
+ else:
+ with pytest.raises(ValueError):
+ bucket.set_versioning(requested)
+ assert bucket.versioning is initial
+
+
+def test_unversioned_delete_defensively_preserves_named_history() -> None:
+ bucket = Bucket("b")
+ bucket.set_versioning(VersioningState.ENABLED)
+ historical = bucket.put("k", b"named", SequenceCounter())
+ bucket.versioning = VersioningState.UNVERSIONED
+
+ bucket.delete("k", SequenceCounter(10))
+
+ assert bucket.get("k", historical.version_id) == historical
+
+
+def test_enabled_puts_stack_and_delete_marker_hides_history(tmp_path: Path) -> None:
+ store = MiniS3(tmp_path, counter=SequenceCounter())
+ store.create_bucket("photos")
+ null = store.put_object("photos", "cat.jpg", b"before")
+ store.set_bucket_versioning("photos", VersioningState.ENABLED)
+ first = store.put_object("photos", "cat.jpg", b"one")
+ second = store.put_object("photos", "cat.jpg", b"two")
+ marker = store.delete_object("photos", "cat.jpg")
+
+ assert [null.version_id, first.version_id, second.version_id] == [
+ "null",
+ "v00000002",
+ "v00000003",
+ ]
+ assert marker is not None and marker.version_id == "v00000004"
+ with pytest.raises(NoSuchKey):
+ store.get_object("photos", "cat.jpg")
+ assert store.get_object(
+ "photos", "cat.jpg", version_id=second.version_id
+ ).body == b"two"
+ assert store.head_object(
+ "photos", "cat.jpg", version_id=first.version_id
+ ).etag == first.etag
+
+
+def test_specific_delete_removes_only_addressed_version(tmp_path: Path) -> None:
+ store = MiniS3(tmp_path, counter=SequenceCounter())
+ store.create_bucket("b")
+ store.set_bucket_versioning("b", "enabled")
+ store.put_object("b", "k", b"old")
+ new = store.put_object("b", "k", b"new")
+
+ removed = store.delete_object("b", "k", version_id=new.version_id)
+
+ assert removed == new
+ assert store.get_object("b", "k").body == b"old"
+ with pytest.raises(NoSuchVersion):
+ store.get_object("b", "k", version_id=new.version_id)
+
+
+def test_latest_marker_is_404_even_when_older_data_exists(tmp_path: Path) -> None:
+ store = MiniS3(tmp_path)
+ store.create_bucket("b")
+ store.set_bucket_versioning("b", "enabled")
+ old = store.put_object("b", "k", b"still here")
+ marker = store.delete_object("b", "k")
+
+ with pytest.raises(NoSuchKey):
+ store.head_object("b", "k")
+ assert store.get_object("b", "k", version_id=old.version_id).body == b"still here"
+ assert store.delete_object("b", "k", version_id=marker.version_id) == marker
+ assert store.get_object("b", "k").body == b"still here"
+
+
+def test_nonempty_bucket_cannot_be_deleted(tmp_path: Path) -> None:
+ store = MiniS3(tmp_path)
+ store.create_bucket("b")
+ store.put_object("b", "k", b"value")
+ with pytest.raises(BucketNotEmpty):
+ store.delete_bucket("b")