chore: prepare release v0.4.0
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""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 "")
|
||||
Reference in New Issue
Block a user