feat: make push delivery multi-worker safe (Closes #551)
This commit is contained in:
1 parent
4c64cc4c3e
commit
59c263fc59
5 files changed
+229
-33
No files matched your search
@@ -1,4 +1,6 @@
|
||||
import json
|
||||
import asyncio
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@@ -8,6 +10,32 @@ from src.push_notifications import PushConfiguration, dispatch_unread_updates
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
|
||||
|
||||
def test_dispatch_lease_is_exclusive_recoverable_and_owner_fenced(tmp_path):
|
||||
path = tmp_path / "push.sqlite3"
|
||||
first = PushSubscriptionStore(path)
|
||||
second = PushSubscriptionStore(path)
|
||||
|
||||
assert first.acquire_dispatch_lease("worker-a", now=100, lease_seconds=30) is True
|
||||
assert second.acquire_dispatch_lease("worker-b", now=100, lease_seconds=30) is False
|
||||
assert second.acquire_dispatch_lease("worker-b", now=131, lease_seconds=30) is True
|
||||
|
||||
assert first.release_dispatch_lease("worker-a") is False
|
||||
assert second.release_dispatch_lease("worker-b") is True
|
||||
|
||||
|
||||
def test_subscription_store_uses_private_filesystem_permissions(tmp_path):
|
||||
state_dir = tmp_path / "push-state"
|
||||
previous_umask = os.umask(0)
|
||||
try:
|
||||
database = state_dir / "push.sqlite3"
|
||||
PushSubscriptionStore(database)
|
||||
finally:
|
||||
os.umask(previous_umask)
|
||||
|
||||
assert state_dir.stat().st_mode & 0o777 == 0o700
|
||||
assert database.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_subscription_store_delivers_each_unread_thread_once_per_device(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
subscription = {
|
||||
@@ -79,6 +107,40 @@ async def test_dispatch_sends_one_privacy_safe_deep_link_per_new_thread(tmp_path
|
||||
assert "Secret title" not in json.dumps(sent)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_concurrent_workers_do_not_dispatch_the_same_update(tmp_path):
|
||||
path = tmp_path / "push.sqlite3"
|
||||
first = PushSubscriptionStore(path)
|
||||
second = PushSubscriptionStore(path)
|
||||
first.upsert("session-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
sending = asyncio.Event()
|
||||
finish = asyncio.Event()
|
||||
sent = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [{"notification_id": 42}]}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(json.loads(payload)["tag"])
|
||||
sending.set()
|
||||
await finish.wait()
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
active = asyncio.create_task(dispatch_unread_updates(first, config, unread, send))
|
||||
await sending.wait()
|
||||
competing = await asyncio.wait_for(
|
||||
dispatch_unread_updates(second, config, unread, send), timeout=0.1
|
||||
)
|
||||
finish.set()
|
||||
|
||||
assert competing == 0
|
||||
assert await active == 1
|
||||
assert sent == ["stackchain-update-42"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispatch_removes_an_expired_push_endpoint(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
@@ -101,6 +163,60 @@ async def test_dispatch_removes_an_expired_push_endpoint(tmp_path):
|
||||
assert store.is_subscribed("session-a") is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_transient_endpoint_failure_does_not_block_healthy_devices(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("session-a", "session-b"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
sent = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [{"notification_id": 8}]}
|
||||
|
||||
async def send(subscription, _payload):
|
||||
if subscription["endpoint"].endswith("session-a"):
|
||||
raise RuntimeError("provider unavailable")
|
||||
sent.append(subscription["endpoint"])
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_unread_updates(store, config, unread, send) == 1
|
||||
assert sent == ["https://push.example/session-b"]
|
||||
assert store.claim_unseen({8})[0].session_id == "session-a"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_timed_out_endpoint_does_not_stall_push_fanout(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("session-a", "session-b"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
sent = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [{"notification_id": 13}]}
|
||||
|
||||
async def send(subscription, _payload):
|
||||
if subscription["endpoint"].endswith("session-a"):
|
||||
await asyncio.Event().wait()
|
||||
sent.append(subscription["endpoint"])
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
delivered = await asyncio.wait_for(
|
||||
dispatch_unread_updates(
|
||||
store, config, unread, send, send_timeout_seconds=0.01
|
||||
),
|
||||
timeout=0.2,
|
||||
)
|
||||
|
||||
assert delivered == 1
|
||||
assert sent == ["https://push.example/session-b"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
|
||||
Reference in new issue
Block a user