Skip to content

Stage 14 · Deterministic lifecycle expiration

Goal

Separate pure expiration decisions from an explicit, injected-clock mutation tick.

Hands-on task

Starting from stage-13, Implement ExpirationRule, evaluate_expiration(...), timestamps, and MiniS3.lifecycle_tick(...). Keep all behavior inside the listed source-like boundaries; do not copy the patch first.

Deliverable files / 交付文件

  • src/minis3/__init__.py
  • src/minis3/lifecycle.py
  • src/minis3/store.py
  • tests/test_lifecycle.py

Self-check

  1. Where is this stage's visibility or state transition owned?

    Answer

    Lifecycle is deterministic when evaluation is pure and time enters only through the service boundary.

  2. 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/14-lifecycle-tick/tests.txt)

The real S3 lesson

Lifecycle is deterministic when evaluation is pure and time enters only through the service boundary.

Textbook

Chapter 8

Compare this stage on GitHub

After finishing, use git checkout stage-14 to compare your result.

Try first, then peek: stage.patch
diff --git a/src/minis3/__init__.py b/src/minis3/__init__.py
index 3f6e582..36bc1f3 100644
--- a/src/minis3/__init__.py
+++ b/src/minis3/__init__.py
@@ -1,10 +1,34 @@
 """Public API for the MiniS3 teaching implementation."""
-from .errors import BucketAlreadyExists, BucketNotEmpty, InvalidContinuationToken, MiniS3Error, NoSuchBucket, NoSuchKey, NoSuchVersion
+
+from .errors import (
+    BucketAlreadyExists,
+    BucketNotEmpty,
+    EntityTooSmall,
+    InvalidContinuationToken,
+    InvalidPart,
+    InvalidPartOrder,
+    MiniS3Error,
+    NoSuchBucket,
+    NoSuchKey,
+    NoSuchUpload,
+    NoSuchVersion,
+    NotModified,
+    PreconditionFailed,
+)
 from .bucket import SequenceCounter, VersioningState
+from .listing import (
+    ListedObject,
+    ListedVersion,
+    ListObjectsResult,
+    ListObjectVersionsResult,
+)
 from .model import DeleteMarker, ObjectRecord, Version, content_etag
+from .lifecycle import (
+    ExpirationRule,
+    LifecycleAction,
+    LifecycleActionKind,
+    evaluate_expiration,
+)
+from .multipart import MIN_PART_SIZE, MultipartPart, MultipartUpload
 from .store import MiniS3
 from .storage import InjectedCrash
-from .listing import ListedObject, ListedVersion, ListObjectsResult, ListObjectVersionsResult
-from .errors import EntityTooSmall, InvalidPart, InvalidPartOrder, NoSuchUpload
-from .multipart import MIN_PART_SIZE, MultipartPart, MultipartUpload
-from .errors import NotModified, PreconditionFailed
diff --git a/src/minis3/lifecycle.py b/src/minis3/lifecycle.py
new file mode 100644
index 0000000..ca0c3f0
--- /dev/null
+++ b/src/minis3/lifecycle.py
@@ -0,0 +1,103 @@
+"""Pure lifecycle expiration decisions for an explicit manual tick.
+
+Evaluation reads an immutable-style snapshot and returns actions; it never
+mutates records or reads a clock. The service injects ``now`` and applies the
+actions only when the caller explicitly requests a tick.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import StrEnum
+
+from .model import ObjectRecord, Version
+
+
+@dataclass(frozen=True, slots=True)
+class ExpirationRule:
+    """Age thresholds for matching current and noncurrent data versions."""
+
+    rule_id: str
+    prefix: str = ""
+    expire_current_after: float | None = None
+    expire_noncurrent_after: float | None = None
+
+    def __post_init__(self) -> None:
+        thresholds = (self.expire_current_after, self.expire_noncurrent_after)
+        if all(value is None for value in thresholds):
+            raise ValueError("an expiration rule needs at least one threshold")
+        if any(value is not None and value < 0 for value in thresholds):
+            raise ValueError("expiration ages must be non-negative")
+
+
+class LifecycleActionKind(StrEnum):
+    """The two deliberately small M2 lifecycle transitions."""
+
+    EXPIRE_CURRENT = "expire_current"
+    EXPIRE_NONCURRENT = "expire_noncurrent"
+
+
+@dataclass(frozen=True, slots=True)
+class LifecycleAction:
+    """One deterministic mutation selected by a named rule."""
+
+    rule_id: str
+    key: str
+    version_id: str
+    kind: LifecycleActionKind
+
+
+def _old_enough(created_at: float, threshold: float | None, now: float) -> bool:
+    return threshold is not None and now - created_at >= threshold
+
+
+def evaluate_expiration(
+    records: dict[str, ObjectRecord],
+    rules: list[ExpirationRule] | tuple[ExpirationRule, ...],
+    *,
+    now: float,
+) -> tuple[LifecycleAction, ...]:
+    """Return de-duplicated actions without changing the supplied records."""
+
+    actions: list[LifecycleAction] = []
+    selected: set[tuple[str, str, LifecycleActionKind]] = set()
+    for key in sorted(records):
+        record = records[key]
+        if not record.versions:
+            continue
+        for rule in rules:
+            if not key.startswith(rule.prefix):
+                continue
+            current = record.versions[0]
+            identity = (key, current.version_id, LifecycleActionKind.EXPIRE_CURRENT)
+            if (
+                isinstance(current, Version)
+                and identity not in selected
+                and _old_enough(
+                    current.created_at, rule.expire_current_after, now
+                )
+            ):
+                selected.add(identity)
+                actions.append(
+                    LifecycleAction(rule.rule_id, key, current.version_id, identity[2])
+                )
+            for version in record.versions[1:]:
+                identity = (
+                    key,
+                    version.version_id,
+                    LifecycleActionKind.EXPIRE_NONCURRENT,
+                )
+                if (
+                    isinstance(version, Version)
+                    and identity not in selected
+                    and _old_enough(
+                        version.created_at, rule.expire_noncurrent_after, now
+                    )
+                ):
+                    selected.add(identity)
+                    actions.append(
+                        LifecycleAction(
+                            rule.rule_id, key, version.version_id, identity[2]
+                        )
+                    )
+    return tuple(actions)
diff --git a/src/minis3/store.py b/src/minis3/store.py
index e47e1ac..c2bdc5b 100644
--- a/src/minis3/store.py
+++ b/src/minis3/store.py
@@ -1,4 +1,9 @@
-"""Public service facade joining buckets, object state, and list projections."""
+"""Public service facade joining buckets, object state, and list projections.
+
+This initial service boundary deliberately resembles an SDK rather than an HTTP
+server. A future thin protocol adapter can translate these domain values and
+errors without taking ownership of storage semantics.
+"""

 from __future__ import annotations

@@ -8,10 +13,27 @@ from pathlib import Path
 from threading import RLock
 from time import time

-from .conditional import require_if_match, require_if_none_match
 from .bucket import Bucket, SequenceCounter, VersioningState
-from .errors import BucketAlreadyExists, BucketNotEmpty, NoSuchBucket, NoSuchKey, NoSuchVersion
-from .listing import ListObjectsResult, ListObjectVersionsResult, list_object_versions, list_objects
+from .conditional import require_if_match, require_if_none_match
+from .errors import (
+    BucketAlreadyExists,
+    BucketNotEmpty,
+    NoSuchBucket,
+    NoSuchKey,
+    NoSuchVersion,
+)
+from .lifecycle import (
+    ExpirationRule,
+    LifecycleAction,
+    LifecycleActionKind,
+    evaluate_expiration,
+)
+from .listing import (
+    ListObjectsResult,
+    ListObjectVersionsResult,
+    list_object_versions,
+    list_objects,
+)
 from .model import ObjectVersion, Version
 from .multipart import (
     MAX_PART_NUMBER,
@@ -50,7 +72,6 @@ class MiniS3:
             ensure(maximum_sequence + 1)
         self._lock = RLock()

-
     def create_bucket(self, name: str) -> None:
         with self._lock:
             if name in self._buckets:
@@ -59,7 +80,6 @@ class MiniS3:
             self._storage.create_bucket(bucket)
             self._buckets[name] = bucket

-
     def delete_bucket(self, name: str) -> None:
         with self._lock:
             bucket = self._bucket(name)
@@ -68,7 +88,6 @@ class MiniS3:
             self._storage.delete_bucket(name)
             del self._buckets[name]

-
     def set_bucket_versioning(
         self, name: str, state: VersioningState | str
     ) -> None:
@@ -78,7 +97,6 @@ class MiniS3:
             self._storage.persist_bucket(candidate)
             self._buckets[name] = candidate

-
     def put_object(
         self,
         bucket: str,
@@ -97,7 +115,6 @@ class MiniS3:
             self._buckets[bucket] = candidate
             return result

-
     def get_object(
         self,
         bucket: str,
@@ -113,7 +130,6 @@ class MiniS3:
             require_if_none_match(result.etag, if_none_match)
             return result

-
     def head_object(
         self, bucket: str, key: str, *, version_id: str | None = None
     ) -> Version:
@@ -121,7 +137,6 @@ class MiniS3:

         return self.get_object(bucket, key, version_id=version_id)

-
     def delete_object(
         self,
         bucket: str,
@@ -142,7 +157,6 @@ class MiniS3:
             self._buckets[bucket] = candidate
             return result

-
     def list_objects(
         self,
         bucket: str,
@@ -161,14 +175,12 @@ class MiniS3:
                 continuation_token=continuation_token,
             )

-
     def list_object_versions(
         self, bucket: str, *, prefix: str = ""
     ) -> ListObjectVersionsResult:
         with self._lock:
             return list_object_versions(self._bucket(bucket).records, prefix=prefix)

-
     def create_multipart_upload(
         self, bucket: str, key: str
     ) -> MultipartUpload:
@@ -187,7 +199,6 @@ class MiniS3:
             self._storage.create_multipart_upload(upload)
             return upload

-
     def upload_part(
         self,
         bucket: str,
@@ -206,7 +217,6 @@ class MiniS3:
             self._storage.write_multipart_part(bucket, key, upload_id, part)
             return part.receipt

-
     def complete_multipart_upload(
         self,
         bucket: str,
@@ -239,7 +249,6 @@ class MiniS3:
             self._storage.remove_multipart_upload(bucket, key, upload_id)
             return result

-
     def abort_multipart_upload(
         self, bucket: str, key: str, upload_id: str
     ) -> None:
@@ -249,6 +258,37 @@ class MiniS3:
             self._bucket(bucket)
             self._storage.remove_multipart_upload(bucket, key, upload_id)

+    def lifecycle_tick(
+        self,
+        bucket: str,
+        rules: list[ExpirationRule] | tuple[ExpirationRule, ...],
+    ) -> tuple[LifecycleAction, ...]:
+        """Evaluate at the injected time and atomically apply selected actions."""
+
+        with self._lock:
+            candidate = deepcopy(self._bucket(bucket))
+            now = self._clock()
+            actions = evaluate_expiration(
+                candidate.records, rules, now=now
+            )
+            for action in actions:
+                if action.kind is LifecycleActionKind.EXPIRE_CURRENT:
+                    current = candidate.records[action.key].versions[0]
+                    if current.version_id == action.version_id:
+                        candidate.delete(
+                            action.key, self._counter, now=now
+                        )
+                else:
+                    candidate.delete(
+                        action.key,
+                        self._counter,
+                        action.version_id,
+                        now=now,
+                    )
+            if actions:
+                self._storage.persist_bucket(candidate)
+                self._buckets[bucket] = candidate
+            return actions

     @staticmethod
     def _current_etag(bucket: Bucket, key: str) -> str | None:
@@ -257,7 +297,6 @@ class MiniS3:
         except NoSuchKey:
             return None

-
     @staticmethod
     def _addressed_etag(
         bucket: Bucket, key: str, version_id: str | None
@@ -267,10 +306,8 @@ class MiniS3:
         except (NoSuchKey, NoSuchVersion):
             return None

-
     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_lifecycle.py b/tests/test_lifecycle.py
new file mode 100644
index 0000000..c413345
--- /dev/null
+++ b/tests/test_lifecycle.py
@@ -0,0 +1,110 @@
+"""Lifecycle rules are pure decisions applied only by an explicit clocked tick."""
+
+from pathlib import Path
+
+import pytest
+
+from minis3 import (
+    ExpirationRule,
+    LifecycleActionKind,
+    MiniS3,
+    NoSuchKey,
+    NoSuchVersion,
+    VersioningState,
+    evaluate_expiration,
+)
+
+
+class ManualClock:
+    def __init__(self, now: float = 0.0) -> None:
+        self.now = now
+
+    def __call__(self) -> float:
+        return self.now
+
+
+def test_rule_evaluation_is_pure_prefix_filtered_and_boundary_inclusive(
+    tmp_path: Path,
+) -> None:
+    clock = ManualClock()
+    store = MiniS3(tmp_path, clock=clock)
+    store.create_bucket("b")
+    store.set_bucket_versioning("b", VersioningState.ENABLED)
+    store.put_object("b", "logs/old", b"old")
+    store.put_object("b", "keep/old", b"old")
+    snapshot = store._buckets["b"].records
+    rule = ExpirationRule("logs", prefix="logs/", expire_current_after=10)
+
+    assert evaluate_expiration(snapshot, [rule], now=9.999) == ()
+    actions = evaluate_expiration(snapshot, [rule], now=10)
+
+    assert [(action.key, action.kind) for action in actions] == [
+        ("logs/old", LifecycleActionKind.EXPIRE_CURRENT)
+    ]
+    assert store.get_object("b", "logs/old").body == b"old"
+
+
+def test_tick_expires_current_to_marker_and_noncurrent_physically(
+    tmp_path: Path,
+) -> None:
+    clock = ManualClock()
+    store = MiniS3(tmp_path, clock=clock)
+    store.create_bucket("b")
+    store.set_bucket_versioning("b", "enabled")
+    old = store.put_object("b", "k", b"old")
+    clock.now = 5
+    current = store.put_object("b", "k", b"current")
+    rule = ExpirationRule(
+        "expire",
+        expire_current_after=10,
+        expire_noncurrent_after=12,
+    )
+
+    clock.now = 12
+    first_actions = store.lifecycle_tick("b", [rule])
+    assert [action.kind for action in first_actions] == [
+        LifecycleActionKind.EXPIRE_NONCURRENT
+    ]
+    with pytest.raises(NoSuchVersion):
+        store.get_object("b", "k", version_id=old.version_id)
+    assert store.get_object("b", "k") == current
+
+    clock.now = 15
+    second_actions = store.lifecycle_tick("b", [rule])
+    assert [action.kind for action in second_actions] == [
+        LifecycleActionKind.EXPIRE_CURRENT
+    ]
+    with pytest.raises(NoSuchKey):
+        store.get_object("b", "k")
+    history = store.list_object_versions("b").versions
+    assert history[0].is_delete_marker is True
+    assert history[1].version_id == current.version_id
+
+
+def test_tick_uses_injected_time_and_persists_timestamps_across_restart(
+    tmp_path: Path,
+) -> None:
+    clock = ManualClock(100)
+    store = MiniS3(tmp_path, clock=clock)
+    store.create_bucket("b")
+    store.set_bucket_versioning("b", "enabled")
+    version = store.put_object("b", "k", b"value")
+    assert version.created_at == 100
+
+    reopened_clock = ManualClock(109)
+    reopened = MiniS3(tmp_path, clock=reopened_clock)
+    rule = ExpirationRule("ten-seconds", expire_current_after=10)
+    assert reopened.lifecycle_tick("b", [rule]) == ()
+
+    reopened_clock.now = 110
+    assert reopened.lifecycle_tick("b", [rule])[0].kind is (
+        LifecycleActionKind.EXPIRE_CURRENT
+    )
+
+
+def test_expiration_rule_rejects_empty_or_negative_policy() -> None:
+    with pytest.raises(ValueError):
+        ExpirationRule("empty")
+    with pytest.raises(ValueError):
+        ExpirationRule("negative", expire_current_after=-1)
+