Files
volumen/tests/test_concurrency.py
T

105 lines
3.7 KiB
Python
Raw Permalink Normal View History

2026-07-26 18:15:29 +02:00
"""Concurrency tests: store/users racy write scenarios.
These tests hammer the same files from many threads to confirm that
per-target locks, fsync, and unique tempfiles do not corrupt data.
"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from volumen.post import Post
from volumen.store import Store
from volumen.users import Users
class TestStoreRace:
def test_concurrent_saves_keep_all_data(self, tmp_content_dir: Path) -> None:
"""Many threads save distinct posts in parallel."""
store = Store(str(tmp_content_dir), default_lang="en")
def worker(idx: int) -> str:
slug = f"post-{idx}"
post = Post(metadata={"slug": slug, "title": f"Post {idx}"}, body=f"body {idx}")
store.save(post)
return slug
with ThreadPoolExecutor(max_workers=16) as pool:
futures = [pool.submit(worker, i) for i in range(50)]
for f in as_completed(futures):
assert f.result() is not None
posts = store.all()
slugs = {p.slug for p in posts}
for i in range(50):
assert f"post-{i}" in slugs
def test_concurrent_saves_to_same_target(self, tmp_content_dir: Path) -> None:
"""Many threads save to the same target path; no data lost."""
store = Store(str(tmp_content_dir), default_lang="en")
def worker(idx: int) -> None:
post = Post(
metadata={"slug": "shared", "title": f"v{idx}"},
body=f"iteration {idx}",
)
store.save(post)
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(worker, range(20)))
post = store.find("shared")
assert post is not None
# The file content must end with one of our iterations, no torn write.
assert post.body.startswith("iteration ")
class TestUsersRace:
def test_concurrent_adds_keep_all_users(self, users_path: str) -> None:
users = Users(path=users_path)
def worker(idx: int) -> str:
name = f"user-{idx}"
users.add(name, "alice-test-password")
return name
with ThreadPoolExecutor(max_workers=8) as pool:
futures = [pool.submit(worker, i) for i in range(30)]
for f in as_completed(futures):
assert f.result() is not None
assert len(users.all) == 30
class TestSnapshotCache:
def test_snapshot_changes_invalidate_cache(self, tmp_content_dir: Path) -> None:
store = Store(str(tmp_content_dir), default_lang="en")
store.all() # prime cache
(tmp_content_dir / "extra.md").write_text('+++\ntitle = "Extra"\n+++\n\nNew post.\n')
# Even without touching mtime directly, stat() picks up the new file.
posts = store.all()
slugs = {p.slug for p in posts}
assert "extra" in slugs
def test_size_only_change_invalidates(self, tmp_content_dir: Path) -> None:
"""The cache key includes size; in-place rewrites invalidate."""
import os
store = Store(str(tmp_content_dir), default_lang="en")
# Force initial cache.
store.all()
# Overwrite an existing file with the same mtime but different size.
path = tmp_content_dir / "hello.md"
st = os.stat(path)
os.utime(path, (st.st_atime, st.st_mtime))
path.write_text(
'+++\ntitle = "Hello (resized)"\n+++\n\nNew body that is much longer than before.\n' * 5
)
# Touch back to original mtime so only size differs.
os.utime(path, (st.st_atime, st.st_mtime))
post = store.find("hello")
assert post is not None
assert "resized" in (post.title or "")