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:
2026-07-19 06:49:48 +02:00
co-authored by Claude Opus 4.8
parent bcf11fcecb
commit 245672d3f2
7 changed files with 127 additions and 0 deletions
+32
View File
@@ -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()