mirror of
https://git.zavage.net/Zavage-Software/wabot.git
synced 2026-07-21 13:06:08 -06:00
fix: harden SessionStore — per-record timestamp handling, atomic writes, robust quarantine
This commit is contained in:
parent
8a81f16d7d
commit
1909eb621e
@ -371,6 +371,62 @@ class TestSessionStore:
|
|||||||
|
|
||||||
assert default_store_path().name == "sessions.json"
|
assert default_store_path().name == "sessions.json"
|
||||||
assert "wabot" in str(default_store_path())
|
assert "wabot" in str(default_store_path())
|
||||||
|
|
||||||
|
def test_naive_created_at_is_treated_as_utc_not_crash(self, tmp_path):
|
||||||
|
# legacy/hand-written records may carry naive timestamps
|
||||||
|
import json
|
||||||
|
|
||||||
|
path = tmp_path / "sessions.json"
|
||||||
|
record = make_record()
|
||||||
|
data = {"scraper1": {**record.__dict__, "created_at": "2999-01-01T00:00:00"}}
|
||||||
|
path.write_text(json.dumps(data))
|
||||||
|
store = SessionStore(path=path)
|
||||||
|
assert store.get("scraper1") is not None # fresh: kept, assumed UTC
|
||||||
|
|
||||||
|
def test_garbage_created_at_evicts_only_that_record(self, tmp_path):
|
||||||
|
import json
|
||||||
|
|
||||||
|
path = tmp_path / "sessions.json"
|
||||||
|
good, bad = make_record(name="good"), make_record(name="bad")
|
||||||
|
data = {
|
||||||
|
"good": good.__dict__,
|
||||||
|
"bad": {**bad.__dict__, "created_at": "not-a-date"},
|
||||||
|
}
|
||||||
|
path.write_text(json.dumps(data))
|
||||||
|
store = SessionStore(path=path)
|
||||||
|
assert store.names() == ["good"] # no crash, bad evicted, file rewritten
|
||||||
|
assert "not-a-date" not in path.read_text()
|
||||||
|
|
||||||
|
def test_null_created_at_evicts_only_that_record(self, tmp_path):
|
||||||
|
import json
|
||||||
|
|
||||||
|
path = tmp_path / "sessions.json"
|
||||||
|
data = {"broken": {**make_record(name="broken").__dict__, "created_at": None}}
|
||||||
|
path.write_text(json.dumps(data))
|
||||||
|
assert SessionStore(path=path).names() == []
|
||||||
|
|
||||||
|
def test_unknown_record_fields_are_ignored_not_fatal(self, tmp_path):
|
||||||
|
# a newer wabot may add fields; older versions must not quarantine the store
|
||||||
|
import json
|
||||||
|
|
||||||
|
path = tmp_path / "sessions.json"
|
||||||
|
data = {"scraper1": {**make_record().__dict__, "future_field": 42}}
|
||||||
|
path.write_text(json.dumps(data))
|
||||||
|
assert SessionStore(path=path).get("scraper1") is not None
|
||||||
|
|
||||||
|
def test_repeat_corruption_with_existing_bad_file_does_not_crash(self, tmp_path):
|
||||||
|
path = tmp_path / "sessions.json"
|
||||||
|
(tmp_path / "sessions.json.bad").write_text("old corruption")
|
||||||
|
path.write_text("{ corrupt again")
|
||||||
|
store = SessionStore(path=path)
|
||||||
|
assert store.load() == {}
|
||||||
|
store.save(make_record())
|
||||||
|
assert store.get("scraper1") is not None
|
||||||
|
|
||||||
|
def test_write_is_atomic_no_tmp_leftover(self, tmp_path):
|
||||||
|
store = SessionStore(path=tmp_path / "sessions.json")
|
||||||
|
store.save(make_record())
|
||||||
|
assert not (tmp_path / "sessions.json.tmp").exists()
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
@ -395,6 +451,7 @@ from __future__ import annotations
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -417,6 +474,20 @@ class SessionRecord:
|
|||||||
service_port: int | None = None
|
service_port: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_RECORD_FIELDS = {field.name for field in dataclasses.fields(SessionRecord)}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_created_at(value) -> datetime | None:
|
||||||
|
"""Parse a record timestamp; None means unusable (treat as stale)."""
|
||||||
|
try:
|
||||||
|
created = datetime.fromisoformat(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
if created.tzinfo is None:
|
||||||
|
created = created.replace(tzinfo=timezone.utc)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
def default_store_path() -> Path:
|
def default_store_path() -> Path:
|
||||||
return Path(platformdirs.user_data_dir("wabot")) / "sessions.json"
|
return Path(platformdirs.user_data_dir("wabot")) / "sessions.json"
|
||||||
|
|
||||||
@ -433,16 +504,22 @@ class SessionStore:
|
|||||||
if not self.path.exists():
|
if not self.path.exists():
|
||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
raw = json.loads(self.path.read_text())
|
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
records = {name: SessionRecord(**data) for name, data in raw.items()}
|
records = {
|
||||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
name: SessionRecord(**{k: v for k, v in data.items() if k in _RECORD_FIELDS})
|
||||||
bad = self.path.with_name(self.path.name + ".bad")
|
for name, data in raw.items()
|
||||||
self.path.rename(bad)
|
}
|
||||||
LOGGER.warning("corrupt session store moved to %s; starting fresh", bad)
|
except (json.JSONDecodeError, TypeError, AttributeError, OSError):
|
||||||
|
self._quarantine()
|
||||||
return {}
|
return {}
|
||||||
fresh = {}
|
fresh = {}
|
||||||
for name, record in records.items():
|
for name, record in records.items():
|
||||||
created = datetime.fromisoformat(record.created_at)
|
created = _parse_created_at(record.created_at)
|
||||||
|
if created is None:
|
||||||
|
LOGGER.warning(
|
||||||
|
"evicting session %r with unusable created_at %r", name, record.created_at
|
||||||
|
)
|
||||||
|
continue
|
||||||
if datetime.now(timezone.utc) - created >= self.max_age:
|
if datetime.now(timezone.utc) - created >= self.max_age:
|
||||||
LOGGER.info("evicting stale session %r (created %s)", name, record.created_at)
|
LOGGER.info("evicting stale session %r (created %s)", name, record.created_at)
|
||||||
continue
|
continue
|
||||||
@ -451,6 +528,14 @@ class SessionStore:
|
|||||||
self._write(fresh)
|
self._write(fresh)
|
||||||
return fresh
|
return fresh
|
||||||
|
|
||||||
|
def _quarantine(self) -> None:
|
||||||
|
bad = self.path.with_name(self.path.name + ".bad")
|
||||||
|
try:
|
||||||
|
self.path.replace(bad) # replace, not rename: works when .bad exists
|
||||||
|
LOGGER.warning("corrupt session store moved to %s; starting fresh", bad)
|
||||||
|
except OSError as ex:
|
||||||
|
LOGGER.warning("could not quarantine corrupt store %s: %s", self.path, ex)
|
||||||
|
|
||||||
def get(self, name: str) -> SessionRecord | None:
|
def get(self, name: str) -> SessionRecord | None:
|
||||||
return self.load().get(name)
|
return self.load().get(name)
|
||||||
|
|
||||||
@ -468,9 +553,14 @@ class SessionStore:
|
|||||||
return sorted(self.load())
|
return sorted(self.load())
|
||||||
|
|
||||||
def _write(self, records: dict[str, SessionRecord]) -> None:
|
def _write(self, records: dict[str, SessionRecord]) -> None:
|
||||||
|
# NOTE: save() is read-modify-write with no cross-process lock; at this
|
||||||
|
# library's scale (a handful of sessions on human timescales) a lost
|
||||||
|
# update is acceptable. A torn/partial file is NOT — hence atomic replace.
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
payload = {name: dataclasses.asdict(rec) for name, rec in records.items()}
|
payload = {name: dataclasses.asdict(rec) for name, rec in records.items()}
|
||||||
self.path.write_text(json.dumps(payload, indent=2))
|
tmp = self.path.with_name(self.path.name + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||||
|
os.replace(tmp, self.path)
|
||||||
```
|
```
|
||||||
|
|
||||||
Note the corrupt-file test writes `sessions.json` + `.bad` → `sessions.json.bad`, which is why `_write` uses `with_name(self.path.name + ".bad")` (not `with_suffix`, which would replace `.json`).
|
Note the corrupt-file test writes `sessions.json` + `.bad` → `sessions.json.bad`, which is why `_write` uses `with_name(self.path.name + ".bad")` (not `with_suffix`, which would replace `.json`).
|
||||||
@ -478,7 +568,7 @@ Note the corrupt-file test writes `sessions.json` + `.bad` → `sessions.json.ba
|
|||||||
- [ ] **Step 4: Run tests to verify they pass**
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
Run: `uv run pytest tests/unit/test_sessions.py -v`
|
Run: `uv run pytest tests/unit/test_sessions.py -v`
|
||||||
Expected: 8 passed
|
Expected: 14 passed
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
@ -1052,7 +1142,7 @@ Run: `uv run pytest tests/unit/test_reattach.py -v`
|
|||||||
Expected: 5 passed
|
Expected: 5 passed
|
||||||
|
|
||||||
Run: `uv run pytest`
|
Run: `uv run pytest`
|
||||||
Expected: all unit tests so far pass (pacing 6, sessions 8, hosts 17, reattach 5 = 36)
|
Expected: all unit tests so far pass (pacing 7, sessions 14, hosts 17, reattach 5 = 43)
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
@ -2577,7 +2667,7 @@ Run: `uv run pytest tests/unit/test_api.py -v`
|
|||||||
Expected: 11 passed
|
Expected: 11 passed
|
||||||
|
|
||||||
Run: `uv run pytest`
|
Run: `uv run pytest`
|
||||||
Expected: full unit suite passes (97 tests: pacing 6, sessions 8, hosts 17, reattach 5, fields 11, page 23, screenshot 3, browser 13, api 11)
|
Expected: full unit suite passes (104 tests: pacing 7, sessions 14, hosts 17, reattach 5, fields 11, page 23, screenshot 3, browser 13, api 11)
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from __future__ import annotations
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -32,6 +33,20 @@ class SessionRecord:
|
|||||||
service_port: int | None = None
|
service_port: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_RECORD_FIELDS = {field.name for field in dataclasses.fields(SessionRecord)}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_created_at(value) -> datetime | None:
|
||||||
|
"""Parse a record timestamp; None means unusable (treat as stale)."""
|
||||||
|
try:
|
||||||
|
created = datetime.fromisoformat(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
if created.tzinfo is None:
|
||||||
|
created = created.replace(tzinfo=timezone.utc)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
def default_store_path() -> Path:
|
def default_store_path() -> Path:
|
||||||
return Path(platformdirs.user_data_dir("wabot")) / "sessions.json"
|
return Path(platformdirs.user_data_dir("wabot")) / "sessions.json"
|
||||||
|
|
||||||
@ -48,16 +63,22 @@ class SessionStore:
|
|||||||
if not self.path.exists():
|
if not self.path.exists():
|
||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
raw = json.loads(self.path.read_text())
|
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
records = {name: SessionRecord(**data) for name, data in raw.items()}
|
records = {
|
||||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
name: SessionRecord(**{k: v for k, v in data.items() if k in _RECORD_FIELDS})
|
||||||
bad = self.path.with_name(self.path.name + ".bad")
|
for name, data in raw.items()
|
||||||
self.path.rename(bad)
|
}
|
||||||
LOGGER.warning("corrupt session store moved to %s; starting fresh", bad)
|
except (json.JSONDecodeError, TypeError, AttributeError, OSError):
|
||||||
|
self._quarantine()
|
||||||
return {}
|
return {}
|
||||||
fresh = {}
|
fresh = {}
|
||||||
for name, record in records.items():
|
for name, record in records.items():
|
||||||
created = datetime.fromisoformat(record.created_at)
|
created = _parse_created_at(record.created_at)
|
||||||
|
if created is None:
|
||||||
|
LOGGER.warning(
|
||||||
|
"evicting session %r with unusable created_at %r", name, record.created_at
|
||||||
|
)
|
||||||
|
continue
|
||||||
if datetime.now(timezone.utc) - created >= self.max_age:
|
if datetime.now(timezone.utc) - created >= self.max_age:
|
||||||
LOGGER.info("evicting stale session %r (created %s)", name, record.created_at)
|
LOGGER.info("evicting stale session %r (created %s)", name, record.created_at)
|
||||||
continue
|
continue
|
||||||
@ -66,6 +87,14 @@ class SessionStore:
|
|||||||
self._write(fresh)
|
self._write(fresh)
|
||||||
return fresh
|
return fresh
|
||||||
|
|
||||||
|
def _quarantine(self) -> None:
|
||||||
|
bad = self.path.with_name(self.path.name + ".bad")
|
||||||
|
try:
|
||||||
|
self.path.replace(bad) # replace, not rename: works when .bad exists
|
||||||
|
LOGGER.warning("corrupt session store moved to %s; starting fresh", bad)
|
||||||
|
except OSError as ex:
|
||||||
|
LOGGER.warning("could not quarantine corrupt store %s: %s", self.path, ex)
|
||||||
|
|
||||||
def get(self, name: str) -> SessionRecord | None:
|
def get(self, name: str) -> SessionRecord | None:
|
||||||
return self.load().get(name)
|
return self.load().get(name)
|
||||||
|
|
||||||
@ -83,6 +112,11 @@ class SessionStore:
|
|||||||
return sorted(self.load())
|
return sorted(self.load())
|
||||||
|
|
||||||
def _write(self, records: dict[str, SessionRecord]) -> None:
|
def _write(self, records: dict[str, SessionRecord]) -> None:
|
||||||
|
# NOTE: save() is read-modify-write with no cross-process lock; at this
|
||||||
|
# library's scale (a handful of sessions on human timescales) a lost
|
||||||
|
# update is acceptable. A torn/partial file is NOT — hence atomic replace.
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
payload = {name: dataclasses.asdict(rec) for name, rec in records.items()}
|
payload = {name: dataclasses.asdict(rec) for name, rec in records.items()}
|
||||||
self.path.write_text(json.dumps(payload, indent=2))
|
tmp = self.path.with_name(self.path.name + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||||
|
os.replace(tmp, self.path)
|
||||||
|
|||||||
@ -67,3 +67,59 @@ class TestSessionStore:
|
|||||||
|
|
||||||
assert default_store_path().name == "sessions.json"
|
assert default_store_path().name == "sessions.json"
|
||||||
assert "wabot" in str(default_store_path())
|
assert "wabot" in str(default_store_path())
|
||||||
|
|
||||||
|
def test_naive_created_at_is_treated_as_utc_not_crash(self, tmp_path):
|
||||||
|
# legacy/hand-written records may carry naive timestamps
|
||||||
|
import json
|
||||||
|
|
||||||
|
path = tmp_path / "sessions.json"
|
||||||
|
record = make_record()
|
||||||
|
data = {"scraper1": {**record.__dict__, "created_at": "2999-01-01T00:00:00"}}
|
||||||
|
path.write_text(json.dumps(data))
|
||||||
|
store = SessionStore(path=path)
|
||||||
|
assert store.get("scraper1") is not None # fresh: kept, assumed UTC
|
||||||
|
|
||||||
|
def test_garbage_created_at_evicts_only_that_record(self, tmp_path):
|
||||||
|
import json
|
||||||
|
|
||||||
|
path = tmp_path / "sessions.json"
|
||||||
|
good, bad = make_record(name="good"), make_record(name="bad")
|
||||||
|
data = {
|
||||||
|
"good": good.__dict__,
|
||||||
|
"bad": {**bad.__dict__, "created_at": "not-a-date"},
|
||||||
|
}
|
||||||
|
path.write_text(json.dumps(data))
|
||||||
|
store = SessionStore(path=path)
|
||||||
|
assert store.names() == ["good"] # no crash, bad evicted, file rewritten
|
||||||
|
assert "not-a-date" not in path.read_text()
|
||||||
|
|
||||||
|
def test_null_created_at_evicts_only_that_record(self, tmp_path):
|
||||||
|
import json
|
||||||
|
|
||||||
|
path = tmp_path / "sessions.json"
|
||||||
|
data = {"broken": {**make_record(name="broken").__dict__, "created_at": None}}
|
||||||
|
path.write_text(json.dumps(data))
|
||||||
|
assert SessionStore(path=path).names() == []
|
||||||
|
|
||||||
|
def test_unknown_record_fields_are_ignored_not_fatal(self, tmp_path):
|
||||||
|
# a newer wabot may add fields; older versions must not quarantine the store
|
||||||
|
import json
|
||||||
|
|
||||||
|
path = tmp_path / "sessions.json"
|
||||||
|
data = {"scraper1": {**make_record().__dict__, "future_field": 42}}
|
||||||
|
path.write_text(json.dumps(data))
|
||||||
|
assert SessionStore(path=path).get("scraper1") is not None
|
||||||
|
|
||||||
|
def test_repeat_corruption_with_existing_bad_file_does_not_crash(self, tmp_path):
|
||||||
|
path = tmp_path / "sessions.json"
|
||||||
|
(tmp_path / "sessions.json.bad").write_text("old corruption")
|
||||||
|
path.write_text("{ corrupt again")
|
||||||
|
store = SessionStore(path=path)
|
||||||
|
assert store.load() == {}
|
||||||
|
store.save(make_record())
|
||||||
|
assert store.get("scraper1") is not None
|
||||||
|
|
||||||
|
def test_write_is_atomic_no_tmp_leftover(self, tmp_path):
|
||||||
|
store = SessionStore(path=tmp_path / "sessions.json")
|
||||||
|
store.save(make_record())
|
||||||
|
assert not (tmp_path / "sessions.json.tmp").exists()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user