Skip to content

Commit f823134

Browse files
takemi-ohamaclaude
andcommitted
fix(snapshot): mtime 判定をアーカイブ実体に限定 + 未来日時ガード + テスト追加
- last_snapshot_time() の mtime 集計対象を full.tar.zst / incr-*.tar.zst に限定 (meta.yml / snapshot.snar 等で誤スキップしないように) - _auto_snapshot() で経過時間が負 (last が未来) の場合はスキップしない - _snapshot_min_interval_minutes / last_snapshot_time の単体テスト追加 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f99f050 commit f823134

4 files changed

Lines changed: 156 additions & 14 deletions

File tree

lib/devbase/commands/container.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -412,16 +412,18 @@ def _auto_snapshot() -> None:
412412
mgr = SnapshotManager(Path(devbase_root))
413413
min_interval = _snapshot_min_interval_minutes()
414414
last = mgr.last_snapshot_time()
415-
if (
416-
min_interval > 0
417-
and last is not None
418-
and datetime.now() - last < timedelta(minutes=min_interval)
419-
):
420-
logger.info(
421-
"[0/6] 直近のスナップショット (%s) から%d分以内のためスキップします",
422-
last.strftime('%Y-%m-%d %H:%M:%S'), min_interval,
423-
)
424-
return
415+
if min_interval > 0 and last is not None:
416+
# 経過時間が負 (last が未来) の場合はスキップしない。システム時計の
417+
# ズレや他環境からのリストアで last が未来になると delta が負になり、
418+
# 常に閾値未満と判定されて無期限にスキップされてしまうため、
419+
# timedelta(0) <= delta の下限ガードを設ける。
420+
delta = datetime.now() - last
421+
if timedelta(0) <= delta < timedelta(minutes=min_interval):
422+
logger.info(
423+
"[0/6] 直近のスナップショット (%s) から%d分以内のためスキップします",
424+
last.strftime('%Y-%m-%d %H:%M:%S'), min_interval,
425+
)
426+
return
425427
if mgr.should_start_new_generation():
426428
logger.info("[0/6] 新しいスナップショット世代を作成中...")
427429
mgr.create()

lib/devbase/snapshot/manager.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,16 @@ def list(self) -> list[dict]:
9898
def last_snapshot_time(self) -> Optional[datetime]:
9999
"""直近のスナップショット取得 (フル/差分) 日時を返す。
100100
101-
各スナップショットディレクトリ内のアーカイブファイルの mtime のうち
102-
最新のものを採用する。差分更新は既存ディレクトリ名を再利用するため
103-
(ディレクトリ名の日付は世代作成時のまま) ファイルの mtime を実測する方が
104-
正確で、メタデータの整合性にも依存しない。
101+
各スナップショットディレクトリ内のアーカイブ実体
102+
(``full.tar.zst`` / ``incr-*.tar.zst``) の mtime のうち最新のものを採用する。
103+
差分更新は既存ディレクトリ名を再利用するため (ディレクトリ名の日付は世代
104+
作成時のまま) ファイルの mtime を実測する方が正確で、メタデータの整合性にも
105+
依存しない。
106+
107+
``meta.yml`` / ``snapshot.snar`` (listed-incremental 状態ファイル) や
108+
``.bak`` 等の付随ファイルは集計対象から除外する。これらはバックアップ本体の
109+
作成に失敗 (コピーや差分作成失敗) しても残りうるため、これらの mtime を採用
110+
すると「成功したバックアップ本体が無いのに up がスキップされる」状態を招く。
105111
106112
スナップショットが存在しない場合は None。
107113
"""
@@ -114,6 +120,12 @@ def last_snapshot_time(self) -> Optional[datetime]:
114120
for f in snap_dir.iterdir():
115121
if not f.is_file():
116122
continue
123+
# アーカイブ実体 (full.tar.zst / incr-NNN.tar.zst) のみを対象とし、
124+
# meta.yml / snapshot.snar / *.bak 等は除外する。
125+
if f.name != 'full.tar.zst' and not (
126+
f.name.startswith('incr-') and f.name.endswith('.tar.zst')
127+
):
128+
continue
117129
mtime = f.stat().st_mtime
118130
if latest is None or mtime > latest:
119131
latest = mtime

tests/snapshot/__init__.py

Whitespace-only changes.
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""自動スナップショットの最小間隔判定まわりの単体テスト。
2+
3+
カバー対象:
4+
- ``_snapshot_min_interval_minutes`` (commands/container.py): 環境変数の
5+
パースと不正値フォールバック。
6+
- ``SnapshotManager.last_snapshot_time`` (snapshot/manager.py): アーカイブ実体
7+
(full.tar.zst / incr-*.tar.zst) の mtime のみを集計し、meta.yml /
8+
snapshot.snar 等は除外することの確認。
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import os
14+
from datetime import datetime
15+
16+
import pytest
17+
18+
from devbase.commands.container import (
19+
_SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT,
20+
_snapshot_min_interval_minutes,
21+
)
22+
from devbase.snapshot.manager import SnapshotManager
23+
24+
25+
# ---------------------------------------------------------------------------
26+
# _snapshot_min_interval_minutes
27+
# ---------------------------------------------------------------------------
28+
29+
_ENV = 'DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES'
30+
31+
32+
def test_min_interval_unset_returns_default(monkeypatch):
33+
monkeypatch.delenv(_ENV, raising=False)
34+
assert _snapshot_min_interval_minutes() == _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT
35+
assert _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT == 60
36+
37+
38+
def test_min_interval_valid_value(monkeypatch):
39+
monkeypatch.setenv(_ENV, '30')
40+
assert _snapshot_min_interval_minutes() == 30
41+
42+
43+
def test_min_interval_zero_disables(monkeypatch):
44+
monkeypatch.setenv(_ENV, '0')
45+
assert _snapshot_min_interval_minutes() == 0
46+
47+
48+
def test_min_interval_negative_falls_back_with_warning(monkeypatch, caplog):
49+
monkeypatch.setenv(_ENV, '-5')
50+
with caplog.at_level('WARNING'):
51+
result = _snapshot_min_interval_minutes()
52+
assert result == _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT
53+
assert any('Invalid' in r.getMessage() for r in caplog.records)
54+
55+
56+
def test_min_interval_non_numeric_falls_back_with_warning(monkeypatch, caplog):
57+
monkeypatch.setenv(_ENV, 'abc')
58+
with caplog.at_level('WARNING'):
59+
result = _snapshot_min_interval_minutes()
60+
assert result == _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT
61+
assert any('Invalid' in r.getMessage() for r in caplog.records)
62+
63+
64+
# ---------------------------------------------------------------------------
65+
# SnapshotManager.last_snapshot_time
66+
# ---------------------------------------------------------------------------
67+
68+
def _touch(path, mtime: float) -> None:
69+
path.write_bytes(b'x')
70+
os.utime(path, (mtime, mtime))
71+
72+
73+
def test_last_snapshot_time_empty_backups(tmp_path):
74+
mgr = SnapshotManager(tmp_path)
75+
# __init__ で backups ディレクトリは作成されるが中身は空。
76+
assert mgr.last_snapshot_time() is None
77+
78+
79+
def test_last_snapshot_time_missing_backups_dir(tmp_path):
80+
mgr = SnapshotManager(tmp_path)
81+
# backups ディレクトリ自体を消した場合も None。
82+
import shutil
83+
shutil.rmtree(mgr.backups_dir)
84+
assert mgr.last_snapshot_time() is None
85+
86+
87+
def test_last_snapshot_time_uses_newest_archive(tmp_path):
88+
mgr = SnapshotManager(tmp_path)
89+
snap_dir = mgr.backups_dir / '20240101-000000'
90+
snap_dir.mkdir()
91+
92+
old = 1_700_000_000.0
93+
new = 1_700_086_400.0 # +1 day
94+
_touch(snap_dir / 'full.tar.zst', old)
95+
_touch(snap_dir / 'incr-001.tar.zst', new)
96+
97+
result = mgr.last_snapshot_time()
98+
assert result == datetime.fromtimestamp(new)
99+
100+
101+
def test_last_snapshot_time_ignores_meta_and_snar(tmp_path):
102+
"""meta.yml / snapshot.snar が新しくても、アーカイブ実体の mtime を返す。"""
103+
mgr = SnapshotManager(tmp_path)
104+
snap_dir = mgr.backups_dir / '20240101-000000'
105+
snap_dir.mkdir()
106+
107+
archive_old = 1_700_000_000.0
108+
noise_new = 1_700_200_000.0 # アーカイブより新しい付随ファイル
109+
110+
_touch(snap_dir / 'full.tar.zst', archive_old)
111+
_touch(snap_dir / 'meta.yml', noise_new)
112+
_touch(snap_dir / 'snapshot.snar', noise_new)
113+
_touch(snap_dir / 'snapshot.snar.bak', noise_new)
114+
115+
result = mgr.last_snapshot_time()
116+
# 付随ファイルは除外され、アーカイブ実体の古い mtime が返るはず。
117+
assert result == datetime.fromtimestamp(archive_old)
118+
119+
120+
def test_last_snapshot_time_only_noise_returns_none(tmp_path):
121+
"""アーカイブ実体が一切無く付随ファイルだけの場合は None。"""
122+
mgr = SnapshotManager(tmp_path)
123+
snap_dir = mgr.backups_dir / '20240101-000000'
124+
snap_dir.mkdir()
125+
_touch(snap_dir / 'meta.yml', 1_700_200_000.0)
126+
_touch(snap_dir / 'snapshot.snar', 1_700_200_000.0)
127+
128+
assert mgr.last_snapshot_time() is None

0 commit comments

Comments
 (0)