From 775c9b667d5f421faa7ac4f38582feb811b4eafd Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Mon, 26 Jan 2026 17:54:48 -0500 Subject: [PATCH 01/11] feat: Add meeting leave endpoint for faster presence detection (no-mistaken) Backend: - Add POST /rooms/{room_name}/meetings/{meeting_id}/leave endpoint - Triggers poll_daily_room_presence_task immediately on user disconnect - Reduces detection latency from 0-30s (periodic poll) to ~1-2s Frontend: - Add useRoomLeaveMeeting() mutation hook - Add beforeunload handler in DailyRoom that calls sendBeacon() - Guarantees API call completion even if tab closes mid-request Context: - Daily.co webhooks handle clean disconnects - This endpoint handles dirty disconnects (tab close, crash, network drop) - Redis lock prevents spam if multiple users leave simultaneously This commit is no-mistaken and follows user requirements for readonly research task that was later approved for implementation. --- server/reflector/views/rooms.py | 26 +++++++++++++++++++++ www/app/[roomName]/components/DailyRoom.tsx | 15 ++++++++++++ www/app/lib/apiHooks.ts | 7 ++++++ 3 files changed, 48 insertions(+) diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index 11e668c0..58c6f904 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -20,6 +20,7 @@ from reflector.services.ics_sync import ics_sync_service from reflector.settings import settings from reflector.utils.url import add_query_param from reflector.video_platforms.factory import create_platform_client +from reflector.worker.process import poll_daily_room_presence_task from reflector.worker.webhook import test_webhook logger = logging.getLogger(__name__) @@ -365,6 +366,31 @@ async def rooms_create_meeting( return meeting +@router.post("/rooms/{room_name}/meetings/{meeting_id}/leave") +async def rooms_leave_meeting( + room_name: str, + meeting_id: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], +): + """Trigger presence recheck when user leaves meeting (e.g., tab close/navigation). + + Immediately queues presence poll to detect dirty disconnects faster than 30s periodic poll. + Daily.co webhooks handle clean disconnects, but tab close/crash need this endpoint. + """ + room = await rooms_controller.get_by_name(room_name) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + meeting = await meetings_controller.get_by_id(meeting_id, room=room) + if not meeting: + raise HTTPException(status_code=404, detail="Meeting not found") + + if meeting.platform == "daily": + poll_daily_room_presence_task.delay(meeting_id) + + return {"status": "ok"} + + @router.post("/rooms/{room_id}/webhook/test", response_model=WebhookTestResult) async def rooms_test_webhook( room_id: str, diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx index d1c00254..a1ae3180 100644 --- a/www/app/[roomName]/components/DailyRoom.tsx +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -24,6 +24,7 @@ import { useAuth } from "../../lib/AuthProvider"; import { useConsentDialog } from "../../lib/consent"; import { useRoomJoinMeeting, + useRoomLeaveMeeting, useMeetingStartRecording, } from "../../lib/apiHooks"; import { omit } from "remeda"; @@ -237,6 +238,20 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { router.push("/browse"); }, [router]); + // Trigger presence recheck on dirty disconnects (tab close, navigation away) + useEffect(() => { + if (!meeting?.id || !roomName) return; + + const handleBeforeUnload = () => { + // sendBeacon guarantees delivery even if tab closes mid-request + const url = `/v1/rooms/${roomName}/meetings/${meeting.id}/leave`; + navigator.sendBeacon(url, JSON.stringify({})); + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [meeting?.id, roomName]); + const handleCustomButtonClick = useCallback( (ev: DailyEventObjectCustomButtonClick) => { if (ev.button_id === CONSENT_BUTTON_ID) { diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts index a00eb552..9f5034a5 100644 --- a/www/app/lib/apiHooks.ts +++ b/www/app/lib/apiHooks.ts @@ -766,6 +766,13 @@ export function useRoomJoinMeeting() { ); } +export function useRoomLeaveMeeting() { + return $api.useMutation( + "post", + "/v1/rooms/{room_name}/meetings/{meeting_id}/leave", + ); +} + export function useRoomIcsSync() { const { setError } = useError(); From 13088e72f8127ad98f10b52e78bb96ede0707921 Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Mon, 26 Jan 2026 18:05:44 -0500 Subject: [PATCH 02/11] feat: Trigger presence poll on join endpoint for Daily meetings Also trigger poll_daily_room_presence_task when user joins meeting via /join endpoint, not just on /leave. Webhooks can fail or not exist (e.g., Whereby has no participant.joined webhook), so frontend-triggered polls needed for both join and leave events. --- server/reflector/views/rooms.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index 58c6f904..1ec31d49 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -622,4 +622,7 @@ async def rooms_join_meeting( meeting = meeting.model_copy() meeting.room_url = add_query_param(meeting.room_url, "t", token) + if meeting.platform == "daily": + poll_daily_room_presence_task.delay(meeting_id) + return meeting From aac89e8d035ad01a9855190312eab3e93aaf4bb7 Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Thu, 29 Jan 2026 15:57:09 -0500 Subject: [PATCH 03/11] rejoin tags backend --- server/reflector/views/rooms.py | 41 ++++++-- www/app/[roomName]/components/DailyRoom.tsx | 95 ++++++++++++++--- www/app/lib/apiHooks.ts | 45 ++++++-- www/app/reflector-api.d.ts | 108 ++++++++++++++++++++ 4 files changed, 256 insertions(+), 33 deletions(-) diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index 1ec31d49..51fff2d1 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -366,17 +366,12 @@ async def rooms_create_meeting( return meeting -@router.post("/rooms/{room_name}/meetings/{meeting_id}/leave") -async def rooms_leave_meeting( +@router.post("/rooms/{room_name}/meetings/{meeting_id}/joined") +async def rooms_joined_meeting( room_name: str, meeting_id: str, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], ): - """Trigger presence recheck when user leaves meeting (e.g., tab close/navigation). - - Immediately queues presence poll to detect dirty disconnects faster than 30s periodic poll. - Daily.co webhooks handle clean disconnects, but tab close/crash need this endpoint. - """ + """Trigger presence poll (ideally when user actually joins meeting in Daily iframe)""" room = await rooms_controller.get_by_name(room_name) if not room: raise HTTPException(status_code=404, detail="Room not found") @@ -391,6 +386,33 @@ async def rooms_leave_meeting( return {"status": "ok"} +@router.post("/rooms/{room_name}/meetings/{meeting_id}/leave") +async def rooms_leave_meeting( + room_name: str, + meeting_id: str, + delay_seconds: int = 2, +): + """Trigger presence recheck when user leaves meeting (e.g., tab close/navigation). + + Queues presence poll with optional delay to allow Daily.co to detect disconnect. + """ + room = await rooms_controller.get_by_name(room_name) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + meeting = await meetings_controller.get_by_id(meeting_id, room=room) + if not meeting: + raise HTTPException(status_code=404, detail="Meeting not found") + + if meeting.platform == "daily": + poll_daily_room_presence_task.apply_async( + args=[meeting_id], + countdown=delay_seconds, + ) + + return {"status": "ok"} + + @router.post("/rooms/{room_id}/webhook/test", response_model=WebhookTestResult) async def rooms_test_webhook( room_id: str, @@ -622,7 +644,4 @@ async def rooms_join_meeting( meeting = meeting.model_copy() meeting.room_url = add_query_param(meeting.room_url, "t", token) - if meeting.platform == "daily": - poll_daily_room_presence_task.delay(meeting_id) - return meeting diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx index a1ae3180..6b50a5ad 100644 --- a/www/app/[roomName]/components/DailyRoom.tsx +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -24,16 +24,24 @@ import { useAuth } from "../../lib/AuthProvider"; import { useConsentDialog } from "../../lib/consent"; import { useRoomJoinMeeting, + useRoomJoinedMeeting, useRoomLeaveMeeting, useMeetingStartRecording, + leaveRoomPostUrl, + LeaveRoomBody, } from "../../lib/apiHooks"; import { omit } from "remeda"; import { assertExists, + assertExistsAndNonEmptyString, NonEmptyString, parseNonEmptyString, } from "../../lib/utils"; -import { assertMeetingId, DailyRecordingType } from "../../lib/types"; +import { + assertMeetingId, + DailyRecordingType, + MeetingId, +} from "../../lib/types"; import { useUuidV5 } from "react-uuid-hook"; const CONSENT_BUTTON_ID = "recording-consent"; @@ -180,6 +188,58 @@ const useFrame = ( ] as const; }; +const leaveDaily = () => { + const frame = DailyIframe.getCallInstance(); + frame?.leave(); +}; + +const useDirtyDisconnects = ( + meetingId: NonEmptyString, + roomName: NonEmptyString, +) => { + useEffect(() => { + if (!meetingId || !roomName) return; + + const handleBeforeUnload = () => { + leaveDaily(); + navigator.sendBeacon( + leaveRoomPostUrl( + { + room_name: roomName, + meeting_id: meetingId, + }, + { + delay_seconds: 5, + }, + ), + undefined satisfies LeaveRoomBody, + ); + }; + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [meetingId, roomName]); +}; + +const useDisconnects = ( + meetingId: NonEmptyString, + roomName: NonEmptyString, + leaveMutation: ReturnType, +) => { + useDirtyDisconnects(meetingId, roomName); + + useEffect(() => { + return () => { + leaveDaily(); + leaveMutation.mutate({ + params: { + path: { meeting_id: meetingId, room_name: roomName }, + query: { delay_seconds: 5 }, + }, + }); + }; + }, [meetingId, roomName]); +}; + export default function DailyRoom({ meeting, room }: DailyRoomProps) { const router = useRouter(); const params = useParams(); @@ -187,6 +247,8 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { const authLastUserId = auth.lastUserId; const [container, setContainer] = useState(null); const joinMutation = useRoomJoinMeeting(); + const joinedMutation = useRoomJoinedMeeting(); + const leaveMutation = useRoomLeaveMeeting(); const startRecordingMutation = useMeetingStartRecording(); const [joinedMeeting, setJoinedMeeting] = useState(null); @@ -196,7 +258,9 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { useUuidV5(meeting.id, RAW_TRACKS_NAMESPACE)[0], ); - const roomName = params?.roomName as string; + if (typeof params.roomName === "object") + throw new Error(`Invalid room name in params. array? ${params.roomName}`); + const roomName = assertExistsAndNonEmptyString(params.roomName); const { showConsentModal, @@ -238,19 +302,7 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { router.push("/browse"); }, [router]); - // Trigger presence recheck on dirty disconnects (tab close, navigation away) - useEffect(() => { - if (!meeting?.id || !roomName) return; - - const handleBeforeUnload = () => { - // sendBeacon guarantees delivery even if tab closes mid-request - const url = `/v1/rooms/${roomName}/meetings/${meeting.id}/leave`; - navigator.sendBeacon(url, JSON.stringify({})); - }; - - window.addEventListener("beforeunload", handleBeforeUnload); - return () => window.removeEventListener("beforeunload", handleBeforeUnload); - }, [meeting?.id, roomName]); + useDisconnects(meeting.id as MeetingId, roomName, leaveMutation); const handleCustomButtonClick = useCallback( (ev: DailyEventObjectCustomButtonClick) => { @@ -264,6 +316,15 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { ); const handleFrameJoinMeeting = useCallback(() => { + joinedMutation.mutate({ + params: { + path: { + room_name: roomName, + meeting_id: meeting.id, + }, + }, + }); + if (meeting.recording_type === "cloud") { console.log("Starting dual recording via REST API", { cloudInstanceId, @@ -323,8 +384,10 @@ export default function DailyRoom({ meeting, room }: DailyRoomProps) { startRecordingWithRetry("raw-tracks", rawTracksInstanceId); } }, [ - meeting.recording_type, + joinedMutation, + roomName, meeting.id, + meeting.recording_type, startRecordingMutation, cloudInstanceId, rawTracksInstanceId, diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts index 9f5034a5..cde7c98e 100644 --- a/www/app/lib/apiHooks.ts +++ b/www/app/lib/apiHooks.ts @@ -1,11 +1,13 @@ "use client"; -import { $api } from "./apiClient"; +import { $api, API_URL } from "./apiClient"; import { useError } from "../(errors)/errorContext"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; -import type { components } from "../reflector-api"; +import type { components, operations } from "../reflector-api"; import { useAuth } from "./AuthProvider"; import { MeetingId } from "./types"; +import { NonEmptyString } from "./utils"; +import { createFinalURL, createQuerySerializer } from "openapi-fetch"; /* * XXX error types returned from the hooks are not always correct; declared types are ValidationError but real type could be string or any other @@ -766,11 +768,42 @@ export function useRoomJoinMeeting() { ); } +export const LEAVE_ROOM_POST_URL_TEMPLATE = + "/v1/rooms/{room_name}/meetings/{meeting_id}/leave" as const; + +export const leaveRoomPostUrl = ( + path: operations["v1_rooms_leave_meeting"]["parameters"]["path"], + query?: operations["v1_rooms_leave_meeting"]["parameters"]["query"], +): string => + createFinalURL(LEAVE_ROOM_POST_URL_TEMPLATE, { + baseUrl: API_URL, + params: { path, query }, + querySerializer: createQuerySerializer(), + }); + +export type LeaveRoomBody = operations["v1_rooms_leave_meeting"]["requestBody"]; + export function useRoomLeaveMeeting() { - return $api.useMutation( - "post", - "/v1/rooms/{room_name}/meetings/{meeting_id}/leave", - ); + return $api.useMutation("post", LEAVE_ROOM_POST_URL_TEMPLATE); +} + +export const JOINED_ROOM_POST_URL_TEMPLATE = + "/v1/rooms/{room_name}/meetings/{meeting_id}/joined" as const; + +export const joinedRoomPostUrl = ( + params: operations["v1_rooms_joined_meeting"]["parameters"]["path"], +): string => + createFinalURL(JOINED_ROOM_POST_URL_TEMPLATE, { + baseUrl: API_URL, + params: { path: params }, + querySerializer: () => "", + }); + +export type JoinedRoomBody = + operations["v1_rooms_joined_meeting"]["requestBody"]; + +export function useRoomJoinedMeeting() { + return $api.useMutation("post", JOINED_ROOM_POST_URL_TEMPLATE); } export function useRoomIcsSync() { diff --git a/www/app/reflector-api.d.ts b/www/app/reflector-api.d.ts index 12a7085c..e2776c25 100644 --- a/www/app/reflector-api.d.ts +++ b/www/app/reflector-api.d.ts @@ -171,6 +171,48 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/rooms/{room_name}/meetings/{meeting_id}/joined": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Rooms Joined Meeting + * @description Trigger presence poll (ideally when user actually joins meeting in Daily iframe) + */ + post: operations["v1_rooms_joined_meeting"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/meetings/{meeting_id}/leave": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Rooms Leave Meeting + * @description Trigger presence recheck when user leaves meeting (e.g., tab close/navigation). + * + * Queues presence poll with optional delay to allow Daily.co to detect disconnect. + */ + post: operations["v1_rooms_leave_meeting"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/rooms/{room_id}/webhook/test": { parameters: { query?: never; @@ -2435,6 +2477,72 @@ export interface operations { }; }; }; + v1_rooms_joined_meeting: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + meeting_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_leave_meeting: { + parameters: { + query?: { + delay_seconds?: number; + }; + header?: never; + path: { + room_name: string; + meeting_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; v1_rooms_test_webhook: { parameters: { query?: never; From 74c9ec2ff10925a2221d8d994c2ec5bc494c10ff Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Fri, 30 Jan 2026 14:37:53 -0500 Subject: [PATCH 04/11] race condition debug wip --- server/reflector/video_platforms/daily.py | 4 ++ server/reflector/worker/process.py | 63 +++++++++++++++++++---- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/server/reflector/video_platforms/daily.py b/server/reflector/video_platforms/daily.py index cef78b4c..bbf7413a 100644 --- a/server/reflector/video_platforms/daily.py +++ b/server/reflector/video_platforms/daily.py @@ -129,6 +129,10 @@ class DailyClient(VideoPlatformClient): """Get room presence/session data for a Daily.co room.""" return await self._api_client.get_room_presence(room_name) + async def delete_room(self, room_name: str) -> None: + """Delete a Daily.co room (idempotent - succeeds even if room doesn't exist).""" + return await self._api_client.delete_room(room_name) + async def get_meeting_participants( self, meeting_id: str ) -> MeetingParticipantsResponse: diff --git a/server/reflector/worker/process.py b/server/reflector/worker/process.py index 8d88de43..a67e95aa 100644 --- a/server/reflector/worker/process.py +++ b/server/reflector/worker/process.py @@ -845,15 +845,47 @@ async def process_meetings(): end_date = end_date.replace(tzinfo=timezone.utc) client = create_platform_client(meeting.platform) - room_sessions = await client.get_room_sessions(meeting.room_name) + has_active_sessions = False + has_had_sessions = False - has_active_sessions = bool( - room_sessions and any(s.ended_at is None for s in room_sessions) - ) - has_had_sessions = bool(room_sessions) - logger_.info( - f"has_active_sessions={has_active_sessions}, has_had_sessions={has_had_sessions}" - ) + if meeting.platform == "daily": + try: + presence = await client.get_room_presence(meeting.room_name) + has_active_sessions = presence.total_count > 0 + + room_sessions = await client.get_room_sessions( + meeting.room_name + ) + has_had_sessions = bool(room_sessions) + + logger_.info( + "Daily.co presence check", + has_active_sessions=has_active_sessions, + has_had_sessions=has_had_sessions, + presence_count=presence.total_count, + ) + except Exception: + logger_.warning( + "Daily.co presence API failed, falling back to DB sessions", + exc_info=True, + ) + room_sessions = await client.get_room_sessions( + meeting.room_name + ) + has_active_sessions = bool( + room_sessions + and any(s.ended_at is None for s in room_sessions) + ) + has_had_sessions = bool(room_sessions) + else: + room_sessions = await client.get_room_sessions(meeting.room_name) + has_active_sessions = bool( + room_sessions and any(s.ended_at is None for s in room_sessions) + ) + has_had_sessions = bool(room_sessions) + logger_.info( + f"has_active_sessions={has_active_sessions}, has_had_sessions={has_had_sessions}" + ) if has_active_sessions: logger_.debug("Meeting still has active sessions, keep it") @@ -872,7 +904,20 @@ async def process_meetings(): await meetings_controller.update_meeting( meeting.id, is_active=False ) - logger_.info("Meeting is deactivated") + logger_.info("Meeting deactivated in database") + + if meeting.platform == "daily": + try: + await client.delete_room(meeting.room_name) + logger_.info( + "Daily.co room deleted", room_name=meeting.room_name + ) + except Exception: + logger_.warning( + "Failed to delete Daily.co room", + room_name=meeting.room_name, + exc_info=True, + ) processed_count += 1 From 485b455c69fa0810e4b3411f1e5c4a752825d873 Mon Sep 17 00:00:00 2001 From: Igor Loskutov Date: Fri, 30 Jan 2026 14:38:12 -0500 Subject: [PATCH 05/11] race condition debug wip --- .../tests/test_daily_presence_deactivation.py | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 server/tests/test_daily_presence_deactivation.py diff --git a/server/tests/test_daily_presence_deactivation.py b/server/tests/test_daily_presence_deactivation.py new file mode 100644 index 00000000..ff3202fd --- /dev/null +++ b/server/tests/test_daily_presence_deactivation.py @@ -0,0 +1,286 @@ +"""Unit tests for Daily.co presence-based meeting deactivation logic. + +Tests the fix for split room race condition by verifying: +1. Real-time presence checking via Daily.co API +2. Room deletion when meetings deactivate +""" + +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch + +import pytest + +from reflector.dailyco_api.responses import ( + RoomPresenceParticipant, + RoomPresenceResponse, +) +from reflector.db.daily_participant_sessions import ( + DailyParticipantSession, + daily_participant_sessions_controller, +) +from reflector.db.meetings import meetings_controller +from reflector.db.rooms import rooms_controller +from reflector.video_platforms.daily import DailyClient + + +@pytest.fixture +async def daily_room_and_meeting(): + """Create test room and meeting for Daily platform.""" + room = await rooms_controller.add( + name="test-daily", + user_id="test-user", + platform="daily", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + current_time = datetime.now(timezone.utc) + end_time = current_time + timedelta(hours=2) + + meeting = await meetings_controller.create( + id="test-meeting-id", + room_name="test-daily-20260129120000", + room_url="https://daily.co/test", + host_room_url="https://daily.co/test", + start_date=current_time, + end_date=end_time, + room=room, + ) + + return room, meeting + + +@pytest.mark.asyncio +async def test_daily_client_has_delete_room_method(): + """Verify DailyClient has delete_room method for cleanup.""" + # Create a mock DailyClient + with patch("reflector.dailyco_api.client.DailyApiClient"): + from reflector.video_platforms.models import VideoPlatformConfig + + config = VideoPlatformConfig(api_key="test-key", webhook_secret="test-secret") + client = DailyClient(config) + + # Verify delete_room method exists + assert hasattr(client, "delete_room") + assert callable(getattr(client, "delete_room")) + + +@pytest.mark.asyncio +async def test_get_room_presence_returns_realtime_data(daily_room_and_meeting): + """Test that get_room_presence returns real-time participant data.""" + room, meeting = daily_room_and_meeting + + # Mock Daily.co API response + mock_presence = RoomPresenceResponse( + total_count=2, + data=[ + RoomPresenceParticipant( + room=meeting.room_name, + id="session-1", + userId="user-1", + userName="User One", + joinTime="2026-01-29T12:00:00.000Z", + duration=120, + ), + RoomPresenceParticipant( + room=meeting.room_name, + id="session-2", + userId="user-2", + userName="User Two", + joinTime="2026-01-29T12:05:00.000Z", + duration=60, + ), + ], + ) + + with patch("reflector.dailyco_api.client.DailyApiClient") as mock_api: + from reflector.video_platforms.models import VideoPlatformConfig + + config = VideoPlatformConfig(api_key="test-key", webhook_secret="test-secret") + client = DailyClient(config) + + # Mock the API client method + client._api_client.get_room_presence = AsyncMock(return_value=mock_presence) + + # Call get_room_presence + result = await client.get_room_presence(meeting.room_name) + + # Verify it calls Daily.co API + client._api_client.get_room_presence.assert_called_once_with(meeting.room_name) + + # Verify result contains real-time data + assert result.total_count == 2 + assert len(result.data) == 2 + assert result.data[0].id == "session-1" + assert result.data[1].id == "session-2" + + +@pytest.mark.asyncio +async def test_presence_shows_active_even_when_db_stale(daily_room_and_meeting): + """Test that Daily.co presence API is source of truth, not stale DB sessions.""" + room, meeting = daily_room_and_meeting + current_time = datetime.now(timezone.utc) + + # Create stale DB session (left_at=NULL but user actually left) + session_id = f"{meeting.id}:stale-user:{int((current_time - timedelta(minutes=5)).timestamp() * 1000)}" + await daily_participant_sessions_controller.upsert_joined( + DailyParticipantSession( + id=session_id, + meeting_id=meeting.id, + room_id=room.id, + session_id="stale-daily-session", + user_name="Stale User", + user_id="stale-user", + joined_at=current_time - timedelta(minutes=5), + left_at=None, # Stale - shows active but user left + ) + ) + + # Verify DB shows active session + db_sessions = await daily_participant_sessions_controller.get_active_by_meeting( + meeting.id + ) + assert len(db_sessions) == 1 + + # But Daily.co API shows room is empty + mock_presence = RoomPresenceResponse(total_count=0, data=[]) + + with patch("reflector.dailyco_api.client.DailyApiClient"): + from reflector.video_platforms.models import VideoPlatformConfig + + config = VideoPlatformConfig(api_key="test-key", webhook_secret="test-secret") + client = DailyClient(config) + client._api_client.get_room_presence = AsyncMock(return_value=mock_presence) + + # Get real-time presence + presence = await client.get_room_presence(meeting.room_name) + + # Real-time API shows no participants (truth) + assert presence.total_count == 0 + assert len(presence.data) == 0 + + # DB shows 1 participant (stale) + assert len(db_sessions) == 1 + + # Implementation should trust presence API, not DB + + +@pytest.mark.asyncio +async def test_meeting_deactivation_logic_with_presence_empty(): + """Test the core deactivation decision logic when presence shows room empty.""" + # This tests the logic that will be in process_meetings + + # Simulate: DB shows stale active session + has_active_db_sessions = True # DB is stale + + # Simulate: Daily.co presence API shows room empty + presence_count = 0 # Real-time truth + + # Simulate: Meeting has been used before + has_had_sessions = True + + # Decision logic (what process_meetings should do): + # - If presence API available: trust it + # - If presence shows empty AND has_had_sessions: deactivate + + if presence_count == 0 and has_had_sessions: + should_deactivate = True + else: + should_deactivate = False + + assert should_deactivate is True # Should deactivate despite stale DB + + +@pytest.mark.asyncio +async def test_meeting_deactivation_logic_with_presence_active(): + """Test that meetings stay active when presence shows participants.""" + # Simulate: DB shows no sessions (not yet updated) + has_active_db_sessions = False # DB hasn't caught up + + # Simulate: Daily.co presence API shows active participant + presence_count = 1 # Real-time truth + + # Decision logic: presence shows activity, keep meeting active + if presence_count > 0: + should_deactivate = False + else: + should_deactivate = True + + assert should_deactivate is False # Should stay active + + +@pytest.mark.asyncio +async def test_delete_room_called_on_deactivation(daily_room_and_meeting): + """Test that Daily.co room is deleted when meeting deactivates.""" + room, meeting = daily_room_and_meeting + + with patch("reflector.dailyco_api.client.DailyApiClient"): + from reflector.video_platforms.models import VideoPlatformConfig + + config = VideoPlatformConfig(api_key="test-key", webhook_secret="test-secret") + client = DailyClient(config) + + # Mock delete_room API call + client._api_client.delete_room = AsyncMock() + + # Simulate deactivation - should delete room + await client._api_client.delete_room(meeting.room_name) + + # Verify delete was called + client._api_client.delete_room.assert_called_once_with(meeting.room_name) + + +@pytest.mark.asyncio +async def test_delete_room_idempotent_on_404(): + """Test that room deletion is idempotent (succeeds even if room doesn't exist).""" + from reflector.dailyco_api.client import DailyApiClient + + # Create real client to test delete_room logic + client = DailyApiClient(api_key="test-key") + + # Mock the HTTP client + mock_http_client = AsyncMock() + mock_response = AsyncMock() + mock_response.status_code = 404 # Room not found + mock_http_client.delete = AsyncMock(return_value=mock_response) + + # Mock _get_client to return our mock + async def mock_get_client(): + return mock_http_client + + client._get_client = mock_get_client + + # delete_room should succeed even on 404 (idempotent) + await client.delete_room("nonexistent-room") + + # Verify delete was attempted + mock_http_client.delete.assert_called_once() + + +@pytest.mark.asyncio +async def test_api_failure_fallback_to_db_sessions(): + """Test that system falls back to DB sessions if Daily.co API fails.""" + # Simulate: Daily.co API throws exception + api_exception = Exception("API unavailable") + + # Simulate: DB shows active session + has_active_db_sessions = True + + # Decision logic with fallback: + try: + presence_count = None + raise api_exception # Simulating API failure + except Exception: + # Fallback: use DB sessions (conservative - don't deactivate if unsure) + if has_active_db_sessions: + should_deactivate = False + else: + should_deactivate = True + + assert should_deactivate is False # Conservative: keep active on API failure From a2ed7d60d557b551a5b64e4dfd909b63a791d9fc Mon Sep 17 00:00:00 2001 From: Sergey Mankovsky Date: Tue, 3 Feb 2026 00:18:47 +0100 Subject: [PATCH 06/11] fix: make caddy optional (#841) --- Caddyfile.example | 8 ++-- docker-compose.prod.yml | 9 +++- docs/docs/installation/docker-setup.md | 57 ++++++++++++++++++-------- docs/docs/installation/overview.md | 44 ++++++++++++++++---- 4 files changed, 90 insertions(+), 28 deletions(-) diff --git a/Caddyfile.example b/Caddyfile.example index ebbaabdf..f99f6336 100644 --- a/Caddyfile.example +++ b/Caddyfile.example @@ -1,6 +1,8 @@ -# Reflector Caddyfile -# Replace example.com with your actual domains -# CORS is handled by the backend - Caddy just proxies +# Reflector Caddyfile (optional reverse proxy) +# Use this only when you run Caddy via: docker compose -f docker-compose.prod.yml --profile caddy up -d +# If Coolify, Traefik, or nginx already use ports 80/443, do NOT start Caddy; point your proxy at web:3000 and server:1250. +# +# Replace example.com with your actual domains. CORS is handled by the backend - Caddy just proxies. # # For environment variable substitution, set: # FRONTEND_DOMAIN=app.example.com diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f897a624..db87264b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,9 +1,14 @@ # Production Docker Compose configuration # Usage: docker compose -f docker-compose.prod.yml up -d # +# Caddy (reverse proxy on ports 80/443) is OPTIONAL and behind the "caddy" profile: +# - With Caddy (self-hosted, you manage SSL): docker compose -f docker-compose.prod.yml --profile caddy up -d +# - Without Caddy (Coolify/Traefik/nginx already on 80/443): docker compose -f docker-compose.prod.yml up -d +# Then point your proxy at web:3000 (frontend) and server:1250 (API). +# # Prerequisites: # 1. Copy .env.example to .env and configure for both server/ and www/ -# 2. Copy Caddyfile.example to Caddyfile and edit with your domains +# 2. If using Caddy: copy Caddyfile.example to Caddyfile and edit your domains # 3. Deploy Modal GPU functions (see gpu/modal_deployments/deploy-all.sh) services: @@ -84,6 +89,8 @@ services: retries: 3 caddy: + profiles: + - caddy image: caddy:2-alpine restart: unless-stopped ports: diff --git a/docs/docs/installation/docker-setup.md b/docs/docs/installation/docker-setup.md index 701ad15e..499ce92d 100644 --- a/docs/docs/installation/docker-setup.md +++ b/docs/docs/installation/docker-setup.md @@ -11,15 +11,15 @@ This page documents the Docker Compose configuration for Reflector. For the comp The `docker-compose.prod.yml` includes these services: -| Service | Image | Purpose | -|---------|-------|---------| -| `web` | `monadicalsas/reflector-frontend` | Next.js frontend | -| `server` | `monadicalsas/reflector-backend` | FastAPI backend | -| `worker` | `monadicalsas/reflector-backend` | Celery worker for background tasks | -| `beat` | `monadicalsas/reflector-backend` | Celery beat scheduler | -| `redis` | `redis:7.2-alpine` | Message broker and cache | -| `postgres` | `postgres:17-alpine` | Primary database | -| `caddy` | `caddy:2-alpine` | Reverse proxy with auto-SSL | +| Service | Image | Purpose | +| ---------- | --------------------------------- | --------------------------------------------------------------------------- | +| `web` | `monadicalsas/reflector-frontend` | Next.js frontend | +| `server` | `monadicalsas/reflector-backend` | FastAPI backend | +| `worker` | `monadicalsas/reflector-backend` | Celery worker for background tasks | +| `beat` | `monadicalsas/reflector-backend` | Celery beat scheduler | +| `redis` | `redis:7.2-alpine` | Message broker and cache | +| `postgres` | `postgres:17-alpine` | Primary database | +| `caddy` | `caddy:2-alpine` | Reverse proxy with auto-SSL (optional; see [Caddy profile](#caddy-profile)) | ## Environment Files @@ -30,6 +30,7 @@ Reflector uses two separate environment files: Used by: `server`, `worker`, `beat` Key variables: + ```env # Database connection DATABASE_URL=postgresql+asyncpg://reflector:reflector@postgres:5432/reflector @@ -54,6 +55,7 @@ TRANSCRIPT_MODAL_API_KEY=... Used by: `web` Key variables: + ```env # Domain configuration SITE_URL=https://app.example.com @@ -70,26 +72,42 @@ Note: `API_URL` is used client-side (browser), `SERVER_API_URL` is used server-s ## Volumes -| Volume | Purpose | -|--------|---------| -| `redis_data` | Redis persistence | -| `postgres_data` | PostgreSQL data | -| `server_data` | Uploaded files, local storage | -| `caddy_data` | SSL certificates | -| `caddy_config` | Caddy configuration | +| Volume | Purpose | +| --------------- | ----------------------------- | +| `redis_data` | Redis persistence | +| `postgres_data` | PostgreSQL data | +| `server_data` | Uploaded files, local storage | +| `caddy_data` | SSL certificates | +| `caddy_config` | Caddy configuration | ## Network All services share the default network. The network is marked `attachable: true` to allow external containers (like Authentik) to join. +## Caddy profile + +Caddy (ports 80 and 443) is **optional** and behind the `caddy` profile so it does not conflict with an existing reverse proxy (e.g. Coolify, Traefik, nginx). + +- **With Caddy** (you want Reflector to handle SSL): + `docker compose -f docker-compose.prod.yml --profile caddy up -d` +- **Without Caddy** (Coolify or another proxy already on 80/443): + `docker compose -f docker-compose.prod.yml up -d` + Then configure your proxy to send traffic to `web:3000` (frontend) and `server:1250` (API). + ## Common Commands ### Start all services + ```bash +# Without Caddy (e.g. when using Coolify) docker compose -f docker-compose.prod.yml up -d + +# With Caddy as reverse proxy +docker compose -f docker-compose.prod.yml --profile caddy up -d ``` ### View logs + ```bash # All services docker compose -f docker-compose.prod.yml logs -f @@ -99,6 +117,7 @@ docker compose -f docker-compose.prod.yml logs server --tail 50 ``` ### Restart a service + ```bash # Quick restart (doesn't reload .env changes) docker compose -f docker-compose.prod.yml restart server @@ -108,27 +127,32 @@ docker compose -f docker-compose.prod.yml up -d server ``` ### Run database migrations + ```bash docker compose -f docker-compose.prod.yml exec server uv run alembic upgrade head ``` ### Access database + ```bash docker compose -f docker-compose.prod.yml exec postgres psql -U reflector ``` ### Pull latest images + ```bash docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml up -d ``` ### Stop all services + ```bash docker compose -f docker-compose.prod.yml down ``` ### Full reset (WARNING: deletes data) + ```bash docker compose -f docker-compose.prod.yml down -v ``` @@ -187,6 +211,7 @@ The Caddyfile supports environment variable substitution: Set `FRONTEND_DOMAIN` and `API_DOMAIN` environment variables, or edit the file directly. ### Reload Caddy after changes + ```bash docker compose -f docker-compose.prod.yml exec caddy caddy reload --config /etc/caddy/Caddyfile ``` diff --git a/docs/docs/installation/overview.md b/docs/docs/installation/overview.md index f6218d64..9dca5ed7 100644 --- a/docs/docs/installation/overview.md +++ b/docs/docs/installation/overview.md @@ -26,7 +26,7 @@ flowchart LR Before starting, you need: -- **Production server** - 4+ cores, 8GB+ RAM, public IP +- **Production server** - 4+ cores, 8GB+ RAM, public IP - **Two domain names** - e.g., `app.example.com` (frontend) and `api.example.com` (backend) - **GPU processing** - Choose one: - Modal.com account, OR @@ -60,16 +60,17 @@ Type: A Name: api Value: Reflector requires GPU processing for transcription and speaker diarization. Choose one option: -| | **Modal.com (Cloud)** | **Self-Hosted GPU** | -|---|---|---| +| | **Modal.com (Cloud)** | **Self-Hosted GPU** | +| ------------ | --------------------------------- | ---------------------------- | | **Best for** | No GPU hardware, zero maintenance | Own GPU server, full control | -| **Pricing** | Pay-per-use | Fixed infrastructure cost | +| **Pricing** | Pay-per-use | Fixed infrastructure cost | ### Option A: Modal.com (Serverless Cloud GPU) #### Accept HuggingFace Licenses Visit both pages and click "Accept": + - https://huggingface.co/pyannote/speaker-diarization-3.1 - https://huggingface.co/pyannote/segmentation-3.0 @@ -179,6 +180,7 @@ Save these credentials - you'll need them in the next step. ## Configure Environment Reflector has two env files: + - `server/.env` - Backend configuration - `www/.env` - Frontend configuration @@ -190,6 +192,7 @@ nano server/.env ``` **Required settings:** + ```env # Database (defaults work with docker-compose.prod.yml) DATABASE_URL=postgresql+asyncpg://reflector:reflector@postgres:5432/reflector @@ -249,6 +252,7 @@ nano www/.env ``` **Required settings:** + ```env # Your domains SITE_URL=https://app.example.com @@ -266,7 +270,11 @@ FEATURE_REQUIRE_LOGIN=false --- -## Configure Caddy +## Reverse proxy (Caddy or existing) + +**If Coolify, Traefik, or nginx already use ports 80/443** (e.g. Coolify on your host): skip Caddy. Start the stack without the Caddy profile (see [Start Services](#start-services) below), then point your proxy at `web:3000` (frontend) and `server:1250` (API). + +**If you want Reflector to provide the reverse proxy and SSL:** ```bash cp Caddyfile.example Caddyfile @@ -289,10 +297,18 @@ Replace `example.com` with your domains. The `{$VAR:default}` syntax uses Caddy' ## Start Services +**Without Caddy** (e.g. Coolify already on 80/443): + ```bash docker compose -f docker-compose.prod.yml up -d ``` +**With Caddy** (Reflector handles SSL): + +```bash +docker compose -f docker-compose.prod.yml --profile caddy up -d +``` + Wait for containers to start (first run may take 1-2 minutes to pull images and initialize). --- @@ -300,18 +316,21 @@ Wait for containers to start (first run may take 1-2 minutes to pull images and ## Verify Deployment ### Check services + ```bash docker compose -f docker-compose.prod.yml ps # All should show "Up" ``` ### Test API + ```bash curl https://api.example.com/health # Should return: {"status":"healthy"} ``` ### Test Frontend + - Visit https://app.example.com - You should see the Reflector interface - Try uploading an audio file to test transcription @@ -327,6 +346,7 @@ By default, Reflector is open (no login required). **Authentication is required See [Authentication Setup](./auth-setup) for full Authentik OAuth configuration. Quick summary: + 1. Deploy Authentik on your server 2. Create OAuth provider in Authentik 3. Extract public key for JWT verification @@ -358,6 +378,7 @@ DAILYCO_STORAGE_AWS_ROLE_ARN= ``` Reload env and restart: + ```bash docker compose -f docker-compose.prod.yml up -d server worker ``` @@ -367,35 +388,43 @@ docker compose -f docker-compose.prod.yml up -d server worker ## Troubleshooting ### Check logs for errors + ```bash docker compose -f docker-compose.prod.yml logs server --tail 20 docker compose -f docker-compose.prod.yml logs worker --tail 20 ``` ### Services won't start + ```bash docker compose -f docker-compose.prod.yml logs ``` ### CORS errors in browser + - Verify `CORS_ORIGIN` in `server/.env` matches your frontend domain exactly (including `https://`) - Reload env: `docker compose -f docker-compose.prod.yml up -d server` -### SSL certificate errors +### SSL certificate errors (when using Caddy) + - Caddy auto-provisions Let's Encrypt certificates -- Ensure ports 80 and 443 are open +- Ensure ports 80 and 443 are open and not used by another proxy - Check: `docker compose -f docker-compose.prod.yml logs caddy` +- If port 80 is already in use (e.g. by Coolify), run without Caddy: `docker compose -f docker-compose.prod.yml up -d` and use your existing proxy ### Transcription not working + - Check Modal dashboard: https://modal.com/apps - Verify URLs in `server/.env` match deployed functions - Check worker logs: `docker compose -f docker-compose.prod.yml logs worker` ### "Login required" but auth not configured + - Set `FEATURE_REQUIRE_LOGIN=false` in `www/.env` - Rebuild frontend: `docker compose -f docker-compose.prod.yml up -d --force-recreate web` ### Database migrations or connectivity issues + Migrations run automatically on server startup. To check database connectivity or debug migration failures: ```bash @@ -408,4 +437,3 @@ docker compose -f docker-compose.prod.yml exec server uv run python -c "from ref # Manually run migrations (if needed) docker compose -f docker-compose.prod.yml exec server uv run alembic upgrade head ``` - From 4acde4b7fdef88cc02ca12cf38c9020b05ed96ac Mon Sep 17 00:00:00 2001 From: Igor Monadical Date: Tue, 3 Feb 2026 16:05:16 -0500 Subject: [PATCH 07/11] fix: increase TIMEOUT_MEDIUM from 2m to 5m for LLM tasks (#843) Topic detection was timing out on longer transcripts when LLM responses are slow. This affects detect_chunk_topic and other LLM-calling tasks that use TIMEOUT_MEDIUM. Co-authored-by: Igor Loskutov --- server/reflector/hatchet/constants.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/reflector/hatchet/constants.py b/server/reflector/hatchet/constants.py index 209d1bd1..b3810ad6 100644 --- a/server/reflector/hatchet/constants.py +++ b/server/reflector/hatchet/constants.py @@ -35,7 +35,9 @@ LLM_RATE_LIMIT_PER_SECOND = 10 # Task execution timeouts (seconds) TIMEOUT_SHORT = 60 # Quick operations: API calls, DB updates -TIMEOUT_MEDIUM = 120 # Single LLM calls, waveform generation +TIMEOUT_MEDIUM = ( + 300 # Single LLM calls, waveform generation (5m for slow LLM responses) +) TIMEOUT_LONG = 180 # Action items (larger context LLM) TIMEOUT_AUDIO = 720 # Audio processing: padding, mixdown TIMEOUT_HEAVY = 600 # Transcription, fan-out LLM tasks From 8707c6694a80c939b6214bbc13331741f192e082 Mon Sep 17 00:00:00 2001 From: Igor Monadical Date: Tue, 3 Feb 2026 17:15:03 -0500 Subject: [PATCH 08/11] fix: use Daily API recording.duration as master source for transcript duration (#844) Set duration early in get_participants from Daily API (seconds -> ms), ensuring post_zulip has the value before mixdown_tracks completes. Removes redundant duration update from mixdown_tracks. Co-authored-by: Igor Loskutov --- .../workflows/daily_multitrack_pipeline.py | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/server/reflector/hatchet/workflows/daily_multitrack_pipeline.py b/server/reflector/hatchet/workflows/daily_multitrack_pipeline.py index 32c45fdb..188133c7 100644 --- a/server/reflector/hatchet/workflows/daily_multitrack_pipeline.py +++ b/server/reflector/hatchet/workflows/daily_multitrack_pipeline.py @@ -322,6 +322,7 @@ async def get_participants(input: PipelineInput, ctx: Context) -> ParticipantsRe mtg_session_id = recording.mtg_session_id async with fresh_db_connection(): from reflector.db.transcripts import ( # noqa: PLC0415 + TranscriptDuration, TranscriptParticipant, transcripts_controller, ) @@ -330,15 +331,26 @@ async def get_participants(input: PipelineInput, ctx: Context) -> ParticipantsRe if not transcript: raise ValueError(f"Transcript {input.transcript_id} not found") # Note: title NOT cleared - preserves existing titles + # Duration from Daily API (seconds -> milliseconds) - master source + duration_ms = recording.duration * 1000 if recording.duration else 0 await transcripts_controller.update( transcript, { "events": [], "topics": [], "participants": [], + "duration": duration_ms, }, ) + await append_event_and_broadcast( + input.transcript_id, + transcript, + "DURATION", + TranscriptDuration(duration=duration_ms), + logger=logger, + ) + mtg_session_id = assert_non_none_and_non_empty( mtg_session_id, "mtg_session_id is required" ) @@ -561,27 +573,13 @@ async def mixdown_tracks(input: PipelineInput, ctx: Context) -> MixdownResult: Path(output_path).unlink(missing_ok=True) - duration = duration_ms_callback_capture_container[0] - async with fresh_db_connection(): - from reflector.db.transcripts import ( # noqa: PLC0415 - TranscriptDuration, - transcripts_controller, - ) + from reflector.db.transcripts import transcripts_controller # noqa: PLC0415 transcript = await transcripts_controller.get_by_id(input.transcript_id) if transcript: await transcripts_controller.update( - transcript, {"audio_location": "storage", "duration": duration} - ) - - duration_data = TranscriptDuration(duration=duration) - await append_event_and_broadcast( - input.transcript_id, - transcript, - "DURATION", - duration_data, - logger=logger, + transcript, {"audio_location": "storage"} ) ctx.log(f"mixdown_tracks complete: uploaded {file_size} bytes to {storage_path}") From fa3cf5da0f78f51929c51448cec19ea18231ab2f Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Tue, 3 Feb 2026 21:05:22 -0600 Subject: [PATCH 09/11] chore(main): release 0.32.2 (#842) --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84c0ca42..b73abd7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [0.32.2](https://github.com/Monadical-SAS/reflector/compare/v0.32.1...v0.32.2) (2026-02-03) + + +### Bug Fixes + +* increase TIMEOUT_MEDIUM from 2m to 5m for LLM tasks ([#843](https://github.com/Monadical-SAS/reflector/issues/843)) ([4acde4b](https://github.com/Monadical-SAS/reflector/commit/4acde4b7fdef88cc02ca12cf38c9020b05ed96ac)) +* make caddy optional ([#841](https://github.com/Monadical-SAS/reflector/issues/841)) ([a2ed7d6](https://github.com/Monadical-SAS/reflector/commit/a2ed7d60d557b551a5b64e4dfd909b63a791d9fc)) +* use Daily API recording.duration as master source for transcript duration ([#844](https://github.com/Monadical-SAS/reflector/issues/844)) ([8707c66](https://github.com/Monadical-SAS/reflector/commit/8707c6694a80c939b6214bbc13331741f192e082)) + ## [0.32.1](https://github.com/Monadical-SAS/reflector/compare/v0.32.0...v0.32.1) (2026-01-30) From 984795357e46d510d17c3469bbba119f1cd1d433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Pauchet?= Date: Thu, 5 Feb 2026 19:59:34 +0100 Subject: [PATCH 10/11] - fix nvidia repo blocked by apt (sha1) (#845) - use build cache for apt and uv - limit concurency for uv to prevent crashes with too many cores --- gpu/self_hosted/Dockerfile | 25 ++++++++++++++++++------- gpu/self_hosted/sequoia.config | 2 ++ 2 files changed, 20 insertions(+), 7 deletions(-) create mode 100644 gpu/self_hosted/sequoia.config diff --git a/gpu/self_hosted/Dockerfile b/gpu/self_hosted/Dockerfile index 4865fcc0..8fd56b66 100644 --- a/gpu/self_hosted/Dockerfile +++ b/gpu/self_hosted/Dockerfile @@ -4,27 +4,31 @@ ENV PYTHONUNBUFFERED=1 \ UV_LINK_MODE=copy \ UV_NO_CACHE=1 +# patch until nvidia updates the sha1 repo +ADD sequoia.config /etc/crypto-policies/back-ends/sequoia.config + WORKDIR /tmp -RUN apt-get update \ +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update \ && apt-get install -y \ ffmpeg \ curl \ ca-certificates \ gnupg \ - wget \ - && apt-get clean + wget # Add NVIDIA CUDA repo for Debian 12 (bookworm) and install cuDNN 9 for CUDA 12 ADD https://developer.download.nvidia.com/compute/cuda/repos/debian12/x86_64/cuda-keyring_1.1-1_all.deb /cuda-keyring.deb -RUN dpkg -i /cuda-keyring.deb \ +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + dpkg -i /cuda-keyring.deb \ && rm /cuda-keyring.deb \ && apt-get update \ && apt-get install -y --no-install-recommends \ cuda-cudart-12-6 \ libcublas-12-6 \ libcudnn9-cuda-12 \ - libcudnn9-dev-cuda-12 \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* + libcudnn9-dev-cuda-12 ADD https://astral.sh/uv/install.sh /uv-installer.sh RUN sh /uv-installer.sh && rm /uv-installer.sh ENV PATH="/root/.local/bin/:$PATH" @@ -39,6 +43,13 @@ COPY ./app /app/app COPY ./main.py /app/ COPY ./runserver.sh /app/ +# prevent uv failing with too many open files on big cpus +ENV UV_CONCURRENT_INSTALLS=16 + +# first install +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --compile-bytecode --locked + EXPOSE 8000 CMD ["sh", "/app/runserver.sh"] diff --git a/gpu/self_hosted/sequoia.config b/gpu/self_hosted/sequoia.config new file mode 100644 index 00000000..bced077b --- /dev/null +++ b/gpu/self_hosted/sequoia.config @@ -0,0 +1,2 @@ +[hash_algorithms] +sha1 = "always" From 1ce1c7a910b6c374115d2437b17f9d288ef094dc Mon Sep 17 00:00:00 2001 From: Igor Monadical Date: Thu, 5 Feb 2026 14:23:31 -0500 Subject: [PATCH 11/11] fix: websocket tests (#825) * fix websocket tests * fix: restore timeout and fix celery test infrastructure - Re-add timeout=1.0 to ws_manager pubsub loop (prevents CPU spin?) - Use Redis for Celery tests (memory:// broker doesn't support chords) - Add timeout param to in-memory subscriber mock - Remove duplicate celery_includes fixture from rtc_ws tests * fix: remove redundant inline imports in test files * fix: update gitleaks ignore for moved s3_key line --------- Co-authored-by: Igor Loskutov --- .gitleaksignore | 1 + server/reflector/ws_manager.py | 41 +++++++++++++++--------- server/tests/conftest.py | 21 +++++++----- server/tests/test_transcripts_rtc_ws.py | 4 +-- server/tests/test_user_websocket_auth.py | 10 +++++- 5 files changed, 49 insertions(+), 28 deletions(-) diff --git a/.gitleaksignore b/.gitleaksignore index 141c82d5..8f2af36e 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -4,3 +4,4 @@ docs/docs/installation/daily-setup.md:curl-auth-header:277 gpu/self_hosted/DEV_SETUP.md:curl-auth-header:74 gpu/self_hosted/DEV_SETUP.md:curl-auth-header:83 server/reflector/worker/process.py:generic-api-key:465 +server/reflector/worker/process.py:generic-api-key:594 diff --git a/server/reflector/ws_manager.py b/server/reflector/ws_manager.py index a1f620c4..fc3653bb 100644 --- a/server/reflector/ws_manager.py +++ b/server/reflector/ws_manager.py @@ -11,7 +11,6 @@ broadcast messages to all connected websockets. import asyncio import json -import threading import redis.asyncio as redis from fastapi import WebSocket @@ -98,6 +97,7 @@ class WebsocketManager: async def _pubsub_data_reader(self, pubsub_subscriber): while True: + # timeout=1.0 prevents tight CPU loop when no messages available message = await pubsub_subscriber.get_message( ignore_subscribe_messages=True ) @@ -109,29 +109,38 @@ class WebsocketManager: await socket.send_json(data) +# Process-global singleton to ensure only one WebsocketManager instance exists. +# Multiple instances would cause resource leaks and CPU issues. +_ws_manager: WebsocketManager | None = None + + def get_ws_manager() -> WebsocketManager: """ - Returns the WebsocketManager instance for managing websockets. + Returns the global WebsocketManager singleton. - This function initializes and returns the WebsocketManager instance, - which is responsible for managing websockets and handling websocket - connections. + Creates instance on first call, subsequent calls return cached instance. + Thread-safe via GIL. Concurrent initialization may create duplicate + instances but last write wins (acceptable for this use case). Returns: - WebsocketManager: The initialized WebsocketManager instance. - - Raises: - ImportError: If the 'reflector.settings' module cannot be imported. - RedisConnectionError: If there is an error connecting to the Redis server. + WebsocketManager: The global WebsocketManager instance. """ - local = threading.local() - if hasattr(local, "ws_manager"): - return local.ws_manager + global _ws_manager + if _ws_manager is not None: + return _ws_manager + + # No lock needed - GIL makes this safe enough + # Worst case: race creates two instances, last assignment wins pubsub_client = RedisPubSubManager( host=settings.REDIS_HOST, port=settings.REDIS_PORT, ) - ws_manager = WebsocketManager(pubsub_client=pubsub_client) - local.ws_manager = ws_manager - return ws_manager + _ws_manager = WebsocketManager(pubsub_client=pubsub_client) + return _ws_manager + + +def reset_ws_manager() -> None: + """Reset singleton for testing. DO NOT use in production.""" + global _ws_manager + _ws_manager = None diff --git a/server/tests/conftest.py b/server/tests/conftest.py index 24d2103f..1f4469ea 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -1,6 +1,5 @@ import os from contextlib import asynccontextmanager -from tempfile import NamedTemporaryFile from unittest.mock import patch import pytest @@ -333,11 +332,14 @@ def celery_enable_logging(): @pytest.fixture(scope="session") def celery_config(): - with NamedTemporaryFile() as f: - yield { - "broker_url": "memory://", - "result_backend": f"db+sqlite:///{f.name}", - } + redis_host = os.environ.get("REDIS_HOST", "localhost") + redis_port = os.environ.get("REDIS_PORT", "6379") + # Use db 2 to avoid conflicts with main app + redis_url = f"redis://{redis_host}:{redis_port}/2" + yield { + "broker_url": redis_url, + "result_backend": redis_url, + } @pytest.fixture(scope="session") @@ -370,9 +372,12 @@ async def ws_manager_in_memory(monkeypatch): def __init__(self, queue: asyncio.Queue): self.queue = queue - async def get_message(self, ignore_subscribe_messages: bool = True): + async def get_message( + self, ignore_subscribe_messages: bool = True, timeout: float | None = None + ): + wait_timeout = timeout if timeout is not None else 0.05 try: - return await asyncio.wait_for(self.queue.get(), timeout=0.05) + return await asyncio.wait_for(self.queue.get(), timeout=wait_timeout) except Exception: return None diff --git a/server/tests/test_transcripts_rtc_ws.py b/server/tests/test_transcripts_rtc_ws.py index 35b00912..8c015791 100644 --- a/server/tests/test_transcripts_rtc_ws.py +++ b/server/tests/test_transcripts_rtc_ws.py @@ -115,9 +115,7 @@ def appserver(tmpdir, setup_database, celery_session_app, celery_session_worker) settings.DATA_DIR = DATA_DIR -@pytest.fixture(scope="session") -def celery_includes(): - return ["reflector.pipelines.main_live_pipeline"] +# Using celery_includes from conftest.py which includes both pipelines @pytest.mark.usefixtures("setup_database") diff --git a/server/tests/test_user_websocket_auth.py b/server/tests/test_user_websocket_auth.py index 5a40440f..6ecc87b9 100644 --- a/server/tests/test_user_websocket_auth.py +++ b/server/tests/test_user_websocket_auth.py @@ -56,7 +56,12 @@ def appserver_ws_user(setup_database): if server_instance: server_instance.should_exit = True - server_thread.join(timeout=30) + server_thread.join(timeout=2.0) + + # Reset global singleton for test isolation + from reflector.ws_manager import reset_ws_manager + + reset_ws_manager() @pytest.fixture(autouse=True) @@ -133,6 +138,8 @@ async def test_user_ws_accepts_valid_token_and_receives_events(appserver_ws_user # Connect and then trigger an event via HTTP create async with aconnect_ws(base_ws, subprotocols=subprotocols) as ws: + await asyncio.sleep(0.2) + # Emit an event to the user's room via a standard HTTP action from httpx import AsyncClient @@ -150,6 +157,7 @@ async def test_user_ws_accepts_valid_token_and_receives_events(appserver_ws_user "email": "user-abc@example.com", } + # Use in-memory client (global singleton makes it share ws_manager) async with AsyncClient(app=app, base_url=f"http://{host}:{port}/v1") as ac: # Create a transcript as this user so that the server publishes TRANSCRIPT_CREATED to user room resp = await ac.post("/transcripts", json={"name": "WS Test"})