Fix: feedback POSTs failed with HTTP 500 during a poll cycle
Found in production, not in tests. SQLite's default busy timeout is ZERO, so when a poll held the write lock while storing ~1,500 embeddings, a concurrent feedback POST failed instantly with "database is locked" rather than waiting. Polls take minutes and run every fifteen, so this was a wide failure window on the single endpoint that must never drop a write -- feedback is the only way the taste model learns anything, and the previous system's entire failure was collecting 32 labels. Connections now set busy_timeout=15s. Regression test included, and its first draft was wrong in an instructive way: the competing thread held the write lock continuously, which no finite timeout can survive. It now writes in bursts with gaps, like a real poll cycle, which is the contention actually being tested. Also cleans up the static-site plumbing left over from the Rust service: the caddy /srv/news bind mount, the built SPA under data/news/web, and the news-update systemd units -- which turned out to be DANGLING symlinks into docs/projects/news/deploy/, the path the old repo used before being renamed to news_triage, and had been failing every ten minutes unnoticed ever since.
This commit is contained in:
1 parent
c9cbc3c7d2
commit
b0e509ea92
3 files changed
+61
-2
No files matched your search
@@ -160,3 +160,47 @@ def test_digest_page_handles_a_day_with_nothing(client):
|
||||
def test_bad_date_is_rejected(client):
|
||||
c, _ = client
|
||||
assert c.get("/digest/not-a-date").status_code == 400
|
||||
|
||||
|
||||
def test_feedback_survives_a_concurrent_writer(tmp_path, monkeypatch):
|
||||
"""Regression: a feedback button pressed during a poll returned HTTP 500.
|
||||
|
||||
SQLite's default busy timeout is zero, so the second writer failed instantly
|
||||
with "database is locked". Polls take minutes and run every 15, so this hit
|
||||
the one endpoint that must never drop a write.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
monkeypatch.setenv("NEWS_DB", str(tmp_path / "c.db"))
|
||||
monkeypatch.setenv("NEWS_FEEDBACK_SECRET", SECRET)
|
||||
store.connect(tmp_path / "c.db")
|
||||
|
||||
stop = threading.Event()
|
||||
|
||||
def hammer():
|
||||
# Bursts with gaps, like a real poll cycle: it writes a batch, releases,
|
||||
# writes the next. A thread that NEVER releases would defeat any finite
|
||||
# timeout, which is a different failure and not the one being tested.
|
||||
con = store.connect(tmp_path / "c.db")
|
||||
while not stop.is_set():
|
||||
con.execute("BEGIN IMMEDIATE")
|
||||
con.execute(
|
||||
"INSERT INTO digest_prose (day,briefing,model,created_at) VALUES (?,?,?,?)",
|
||||
(str(time.time()), "x", "m", "t"))
|
||||
time.sleep(0.4) # hold the write lock
|
||||
con.execute("COMMIT")
|
||||
time.sleep(0.1) # ... then let others through
|
||||
|
||||
t = threading.Thread(target=hammer, daemon=True)
|
||||
t.start()
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
from news import web
|
||||
client = TestClient(web.app)
|
||||
token = sign(SECRET, "c9", "interested")
|
||||
r = client.post(f"/api/feedback/c9/interested/{token}")
|
||||
assert r.status_code == 200, f"feedback must not fail under write contention: {r.text}"
|
||||
finally:
|
||||
stop.set()
|
||||
t.join(timeout=5)
|
||||
Reference in new issue
Block a user