d5279b124f
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
79 lines
3.0 KiB
Python
79 lines
3.0 KiB
Python
"""Server-side materialization for time and state driven business reminders."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
from sqlalchemy import select, text
|
|
|
|
from app.core.config import settings
|
|
from app.db.session import SessionLocal
|
|
from app.models.study_member import StudyMember
|
|
from app.models.user import User, UserStatus
|
|
from app.services.project_reminder_service import sync_project_reminders
|
|
|
|
logger = logging.getLogger("ctms.notifications")
|
|
NOTIFICATION_SYNC_LOCK_KEY = 0x43544D53 # "CTMS"
|
|
|
|
|
|
async def sync_all_project_reminders_once() -> int:
|
|
"""Synchronize every active project member once, with a cross-worker advisory lock."""
|
|
async with SessionLocal() as lock_session:
|
|
acquired = bool(await lock_session.scalar(
|
|
text("SELECT pg_try_advisory_lock(:lock_key)"),
|
|
{"lock_key": NOTIFICATION_SYNC_LOCK_KEY},
|
|
))
|
|
if not acquired:
|
|
return 0
|
|
try:
|
|
memberships = (await lock_session.execute(
|
|
select(StudyMember.study_id, StudyMember.user_id)
|
|
.join(User, User.id == StudyMember.user_id)
|
|
.where(
|
|
StudyMember.is_active.is_(True),
|
|
User.status == UserStatus.ACTIVE,
|
|
)
|
|
)).all()
|
|
await lock_session.commit()
|
|
synced = 0
|
|
for study_id, user_id in memberships:
|
|
async with SessionLocal() as session:
|
|
try:
|
|
user = await session.get(User, user_id)
|
|
if user is None or not user.is_active:
|
|
continue
|
|
await sync_project_reminders(session, study_id, user)
|
|
synced += 1
|
|
except Exception:
|
|
await session.rollback()
|
|
logger.exception(
|
|
"Failed to synchronize reminders for study=%s user=%s",
|
|
study_id,
|
|
user_id,
|
|
)
|
|
return synced
|
|
finally:
|
|
await lock_session.execute(
|
|
text("SELECT pg_advisory_unlock(:lock_key)"),
|
|
{"lock_key": NOTIFICATION_SYNC_LOCK_KEY},
|
|
)
|
|
|
|
|
|
async def run_notification_sync_job(stop_event: asyncio.Event) -> None:
|
|
logger.info("Notification synchronization task started")
|
|
while not stop_event.is_set():
|
|
try:
|
|
synced = await sync_all_project_reminders_once()
|
|
logger.debug("Synchronized reminders for %s project memberships", synced)
|
|
except Exception:
|
|
logger.exception("Notification synchronization task failed")
|
|
try:
|
|
await asyncio.wait_for(
|
|
stop_event.wait(),
|
|
timeout=settings.NOTIFICATION_SYNC_INTERVAL_SECONDS,
|
|
)
|
|
except asyncio.TimeoutError:
|
|
continue
|
|
logger.info("Notification synchronization task stopped")
|