Skip to content

Commit 083a50c

Browse files
committed
fix: batch room meeting status queries via prop-drilling
Alternative to the batcher approach (#848): parent fetches all room meeting statuses in a single bulk POST and passes data down as props. No extra dependency (@yornaath/batshit), no implicit batching magic. Backend: POST /v1/rooms/meetings/bulk-status + bulk DB methods. Frontend: useRoomsBulkMeetingStatus hook in RoomList, MeetingStatus receives data as props instead of calling per-room hooks. CI: fix pnpm 8→10 auto-detect, add concurrency group. Tests: Jest+jsdom+testing-library for bulk hook.
1 parent 1ce1c7a commit 083a50c

13 files changed

Lines changed: 1253 additions & 54 deletions

File tree

.github/workflows/test_next_server.yml

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ on:
1313
jobs:
1414
test-next-server:
1515
runs-on: ubuntu-latest
16+
concurrency:
17+
group: test-next-server-${{ github.ref }}
18+
cancel-in-progress: true
1619

1720
defaults:
1821
run:
@@ -21,17 +24,12 @@ jobs:
2124
steps:
2225
- uses: actions/checkout@v4
2326

24-
- name: Setup Node.js
25-
uses: actions/setup-node@v4
26-
with:
27-
node-version: '20'
28-
2927
- name: Install pnpm
3028
uses: pnpm/action-setup@v4
3129
with:
32-
version: 8
30+
package_json_file: './www/package.json'
3331

34-
- name: Setup Node.js cache
32+
- name: Setup Node.js
3533
uses: actions/setup-node@v4
3634
with:
3735
node-version: '20'
@@ -42,4 +40,4 @@ jobs:
4240
run: pnpm install
4341

4442
- name: Run tests
45-
run: pnpm test
43+
run: pnpm test

server/reflector/db/calendar_events.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,26 @@ async def get_upcoming(
104104
results = await get_database().fetch_all(query)
105105
return [CalendarEvent(**result) for result in results]
106106

107+
async def get_upcoming_for_rooms(
108+
self, room_ids: list[str], minutes_ahead: int = 120
109+
) -> list[CalendarEvent]:
110+
now = datetime.now(timezone.utc)
111+
future_time = now + timedelta(minutes=minutes_ahead)
112+
query = (
113+
calendar_events.select()
114+
.where(
115+
sa.and_(
116+
calendar_events.c.room_id.in_(room_ids),
117+
calendar_events.c.is_deleted == False,
118+
calendar_events.c.start_time <= future_time,
119+
calendar_events.c.end_time >= now,
120+
)
121+
)
122+
.order_by(calendar_events.c.start_time.asc())
123+
)
124+
results = await get_database().fetch_all(query)
125+
return [CalendarEvent(**result) for result in results]
126+
107127
async def get_by_id(self, event_id: str) -> CalendarEvent | None:
108128
query = calendar_events.select().where(calendar_events.c.id == event_id)
109129
result = await get_database().fetch_one(query)

server/reflector/db/meetings.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,23 @@ async def get_all_active_for_room(
301301
results = await get_database().fetch_all(query)
302302
return [Meeting(**result) for result in results]
303303

304+
async def get_all_active_for_rooms(
305+
self, room_ids: list[str], current_time: datetime
306+
) -> list[Meeting]:
307+
query = (
308+
meetings.select()
309+
.where(
310+
sa.and_(
311+
meetings.c.room_id.in_(room_ids),
312+
meetings.c.end_date > current_time,
313+
meetings.c.is_active,
314+
)
315+
)
316+
.order_by(meetings.c.end_date.desc())
317+
)
318+
results = await get_database().fetch_all(query)
319+
return [Meeting(**result) for result in results]
320+
304321
async def get_active_by_calendar_event(
305322
self, room: Room, calendar_event_id: str, current_time: datetime
306323
) -> Meeting | None:

server/reflector/db/rooms.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,11 @@ async def get_by_id_for_http(self, meeting_id: str, user_id: str | None) -> Room
245245

246246
return room
247247

248+
async def get_by_names(self, names: list[str]) -> list[Room]:
249+
query = rooms.select().where(rooms.c.name.in_(names))
250+
results = await get_database().fetch_all(query)
251+
return [Room(**r) for r in results]
252+
248253
async def get_ics_enabled(self) -> list[Room]:
249254
query = rooms.select().where(
250255
rooms.c.ics_enabled == True, rooms.c.ics_url != None

server/reflector/views/rooms.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
1+
import asyncio
12
import logging
3+
from collections import defaultdict
24
from datetime import datetime, timedelta, timezone
35
from enum import Enum
46
from typing import Annotated, Any, Literal, Optional
57

68
from fastapi import APIRouter, Depends, HTTPException
79
from fastapi_pagination import Page
810
from fastapi_pagination.ext.databases import apaginate
9-
from pydantic import BaseModel
11+
from pydantic import BaseModel, Field
1012
from redis.exceptions import LockError
1113

1214
import reflector.auth as auth
1315
from reflector.db import get_database
1416
from reflector.db.calendar_events import calendar_events_controller
1517
from reflector.db.meetings import meetings_controller
18+
from reflector.db.rooms import Room as DbRoom
1619
from reflector.db.rooms import rooms_controller
1720
from reflector.redis_cache import RedisAsyncLock
1821
from reflector.schemas.platform import Platform
@@ -195,6 +198,69 @@ async def rooms_list(
195198
return paginated
196199

197200

201+
class BulkStatusRequest(BaseModel):
202+
room_names: list[str] = Field(max_length=100)
203+
204+
205+
class RoomMeetingStatus(BaseModel):
206+
active_meetings: list[Meeting]
207+
upcoming_events: list[CalendarEventResponse]
208+
209+
210+
@router.post("/rooms/meetings/bulk-status", response_model=dict[str, RoomMeetingStatus])
211+
async def rooms_bulk_meeting_status(
212+
request: BulkStatusRequest,
213+
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
214+
):
215+
user_id = user["sub"] if user else None
216+
217+
all_rooms = await rooms_controller.get_by_names(request.room_names)
218+
# Filter to rooms the user can see (owned or shared), matching rooms_list behavior
219+
rooms = [
220+
r
221+
for r in all_rooms
222+
if r.is_shared or (user_id is not None and r.user_id == user_id)
223+
]
224+
room_by_id: dict[str, DbRoom] = {r.id: r for r in rooms}
225+
room_ids = list(room_by_id.keys())
226+
227+
current_time = datetime.now(timezone.utc)
228+
active_meetings, upcoming_events = await asyncio.gather(
229+
meetings_controller.get_all_active_for_rooms(room_ids, current_time),
230+
calendar_events_controller.get_upcoming_for_rooms(room_ids),
231+
)
232+
233+
# Group by room name
234+
active_by_room: dict[str, list[Meeting]] = defaultdict(list)
235+
for m in active_meetings:
236+
room = room_by_id.get(m.room_id)
237+
if not room:
238+
continue
239+
m.platform = room.platform
240+
if user_id != room.user_id and m.platform == "whereby":
241+
m.host_room_url = ""
242+
active_by_room[room.name].append(m)
243+
244+
upcoming_by_room: dict[str, list[CalendarEventResponse]] = defaultdict(list)
245+
for e in upcoming_events:
246+
room = room_by_id.get(e.room_id)
247+
if not room:
248+
continue
249+
if user_id != room.user_id:
250+
e.description = None
251+
e.attendees = None
252+
upcoming_by_room[room.name].append(e)
253+
254+
result: dict[str, RoomMeetingStatus] = {}
255+
for name in request.room_names:
256+
result[name] = RoomMeetingStatus(
257+
active_meetings=active_by_room.get(name, []),
258+
upcoming_events=upcoming_by_room.get(name, []),
259+
)
260+
261+
return result
262+
263+
198264
@router.get("/rooms/{room_id}", response_model=RoomDetails)
199265
async def rooms_get(
200266
room_id: str,

www/app/(app)/rooms/_components/RoomList.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import { useMemo } from "react";
12
import { Box, Heading, Text, VStack } from "@chakra-ui/react";
23
import type { components } from "../../../reflector-api";
4+
import {
5+
useRoomsBulkMeetingStatus,
6+
BulkMeetingStatusMap,
7+
} from "../../../lib/apiHooks";
38

49
type Room = components["schemas"]["Room"];
510
import { RoomTable } from "./RoomTable";
@@ -31,6 +36,10 @@ export function RoomList({
3136
pt,
3237
loading,
3338
}: RoomListProps) {
39+
const roomNames = useMemo(() => rooms.map((r) => r.name), [rooms]);
40+
const bulkStatusQuery = useRoomsBulkMeetingStatus(roomNames);
41+
const meetingStatusMap: BulkMeetingStatusMap = bulkStatusQuery.data ?? {};
42+
3443
return (
3544
<VStack alignItems="start" gap={4} mb={mb} pt={pt}>
3645
<Heading size="md">{title}</Heading>
@@ -43,6 +52,8 @@ export function RoomList({
4352
onEdit={onEdit}
4453
onDelete={onDelete}
4554
loading={loading}
55+
meetingStatusMap={meetingStatusMap}
56+
meetingStatusLoading={bulkStatusQuery.isLoading}
4657
/>
4758
<RoomCards
4859
rooms={rooms}

www/app/(app)/rooms/_components/RoomTable.tsx

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,7 @@ import {
1414
import { LuLink, LuRefreshCw } from "react-icons/lu";
1515
import { FaCalendarAlt } from "react-icons/fa";
1616
import type { components } from "../../../reflector-api";
17-
import {
18-
useRoomActiveMeetings,
19-
useRoomUpcomingMeetings,
20-
useRoomIcsSync,
21-
} from "../../../lib/apiHooks";
17+
import { useRoomIcsSync, BulkMeetingStatusMap } from "../../../lib/apiHooks";
2218

2319
type Room = components["schemas"]["Room"];
2420
type Meeting = components["schemas"]["Meeting"];
@@ -62,6 +58,8 @@ interface RoomTableProps {
6258
onEdit: (roomId: string, roomData: any) => void;
6359
onDelete: (roomId: string) => void;
6460
loading?: boolean;
61+
meetingStatusMap: BulkMeetingStatusMap;
62+
meetingStatusLoading: boolean;
6563
}
6664

6765
const getRoomModeDisplay = (mode: string): string => {
@@ -104,14 +102,16 @@ const getZulipDisplay = (
104102
return "Enabled";
105103
};
106104

107-
function MeetingStatus({ roomName }: { roomName: string }) {
108-
const activeMeetingsQuery = useRoomActiveMeetings(roomName);
109-
const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName);
110-
111-
const activeMeetings = activeMeetingsQuery.data || [];
112-
const upcomingMeetings = upcomingMeetingsQuery.data || [];
113-
114-
if (activeMeetingsQuery.isLoading || upcomingMeetingsQuery.isLoading) {
105+
function MeetingStatus({
106+
activeMeetings,
107+
upcomingMeetings,
108+
isLoading,
109+
}: {
110+
activeMeetings: Meeting[];
111+
upcomingMeetings: CalendarEventResponse[];
112+
isLoading: boolean;
113+
}) {
114+
if (isLoading) {
115115
return <Spinner size="sm" />;
116116
}
117117

@@ -176,6 +176,8 @@ export function RoomTable({
176176
onEdit,
177177
onDelete,
178178
loading,
179+
meetingStatusMap,
180+
meetingStatusLoading,
179181
}: RoomTableProps) {
180182
const [syncingRooms, setSyncingRooms] = useState<Set<NonEmptyString>>(
181183
new Set(),
@@ -252,7 +254,15 @@ export function RoomTable({
252254
<Link href={`/${room.name}`}>{room.name}</Link>
253255
</Table.Cell>
254256
<Table.Cell>
255-
<MeetingStatus roomName={room.name} />
257+
<MeetingStatus
258+
activeMeetings={
259+
meetingStatusMap[room.name]?.active_meetings ?? []
260+
}
261+
upcomingMeetings={
262+
meetingStatusMap[room.name]?.upcoming_events ?? []
263+
}
264+
isLoading={meetingStatusLoading}
265+
/>
256266
</Table.Cell>
257267
<Table.Cell>
258268
{getZulipDisplay(

0 commit comments

Comments
 (0)