sync-v2 step 4: event-driven account lifecycle (§3)
- Coordinator subscribes to account.state_changed over Redis pub/sub: going live marks the account claimable at once; disabled/deleted/error tears the owning runner down immediately (≤30s) instead of waiting for the lease TTL. - delete_account now publishes account.state_changed:deleted after dropping the account from sync:live, so teardown is event-driven, not just fencing. - AccountRunner gains a verify loop (defense in depth): it periodically confirms the account is still live and flags itself dead if not; the coordinator reaps dead runners and releases their lease. Zombie loops are structurally impossible. - New core event user.active (SPEC §8.3), published on login and on every WS connect; consumed for tier promotion in step 5. - Acceptance: added event-driven teardown test (isolated from fencing/verify). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.events import get_event_bus
|
||||
from app.identity.passwords import hash_password
|
||||
from app.models import Account, User
|
||||
from app.redis_client import get_redis
|
||||
@@ -222,5 +223,36 @@ async def test_acceptance_3_create_delete(connections, make_accounts) -> None:
|
||||
await c.stop()
|
||||
|
||||
|
||||
async def test_event_driven_teardown(connections, make_accounts) -> None: # type: ignore[no-untyped-def]
|
||||
"""§3: an account.state_changed event tears the runner down at once.
|
||||
|
||||
Leases are kept long (no fencing) and the verify loop slow, so the only path
|
||||
that can stop the runner in time is the cross-process event subscriber.
|
||||
"""
|
||||
(aid,) = await make_accounts(1)
|
||||
user_id = uuid.uuid4()
|
||||
await _seed({aid})
|
||||
c = _fast(SyncCoordinator("w1"))
|
||||
c.lease_ttl = 60 # lease stays valid: fencing cannot explain the teardown
|
||||
c.lease_renew = 30
|
||||
await c.start()
|
||||
try:
|
||||
await _until(lambda: aid in c.owned)
|
||||
assert connections[aid] == 1
|
||||
|
||||
# As delete_account does: drop it from sync:live (so it isn't re-claimed),
|
||||
# then emit the event. The lease is left intact, so only the event — not
|
||||
# fencing — can explain the teardown.
|
||||
await get_redis().srem(leases.LIVE_SET, str(aid))
|
||||
await get_event_bus().publish(
|
||||
"account.state_changed",
|
||||
{"user_id": str(user_id), "account_id": str(aid), "state": "deleted"},
|
||||
)
|
||||
await _until(lambda: aid not in c.owned, timeout_s=3.0)
|
||||
await _until(lambda: connections[aid] == 0, timeout_s=3.0)
|
||||
finally:
|
||||
await c.stop()
|
||||
|
||||
|
||||
def test_worker_id_is_unique() -> None:
|
||||
assert coordinator_mod._new_worker_id() != coordinator_mod._new_worker_id()
|
||||
|
||||
Reference in New Issue
Block a user