From 86fa37b9fd60fbedd6a74c042a69f09fe2a2b414 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 15 Jul 2026 19:59:52 +0000 Subject: [PATCH] Reduce Meet poll load with shared cache, ETag, and slower clients. Heavy per-participant poll queries were saturating the shared host during live rooms. Cache the shared room payload for 2s, return 304 when unchanged, throttle breakout expiry and captions, and slow client polling (6s / 20s when hidden) so concurrent attendees no longer fan out full Laravel work. --- .../Meet/MeetingRoomController.php | 190 ++++++++++++------ resources/js/meet-public.js | 2 +- resources/js/meet-room.js | 73 ++++++- 3 files changed, 193 insertions(+), 72 deletions(-) diff --git a/app/Http/Controllers/Meet/MeetingRoomController.php b/app/Http/Controllers/Meet/MeetingRoomController.php index b069b15..2d81f9f 100644 --- a/app/Http/Controllers/Meet/MeetingRoomController.php +++ b/app/Http/Controllers/Meet/MeetingRoomController.php @@ -23,6 +23,8 @@ use App\Services\Meet\WebinarService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Http\Response; +use Illuminate\Support\Facades\Cache; use Illuminate\View\View; class MeetingRoomController extends Controller @@ -387,7 +389,12 @@ class MeetingRoomController extends Controller ]); } - public function poll(Request $request, Session $session): JsonResponse + /** + * Room state for clients. Shared payload is cached briefly so concurrent + * pollers (every participant every few seconds) do not each re-run the full + * query fan-out. Per-participant media is still computed fresh. + */ + public function poll(Request $request, Session $session): JsonResponse|Response { if (! $session->isLive()) { $townHall = app(TownHallService::class); @@ -419,77 +426,128 @@ class MeetingRoomController extends Controller $participant = $this->currentParticipant($request, $session); - app(BreakoutService::class)->closeExpired($session); + // Breakout expiry is not latency-critical; run at most once per 30s/session. + if (Cache::add("meet:breakout-close:{$session->id}", 1, 30)) { + app(BreakoutService::class)->closeExpired($session); + } $participant->refresh(); - $session->load(['participants' => fn ($q) => $q->whereIn('status', ['joined', 'waiting'])]); + $shared = $this->sharedPollPayload($session); + $media = $this->sessions->mediaSessionState($participant); - $avatars = ParticipantPresenter::avatarMap($session->participants, $session->room->host_user_ref); + $etag = '"'.md5(($shared['_etag'] ?? '').'|'.$participant->id.'|'.json_encode($media)).'"'; - $messages = $this->chat->recentMessages($session, 50); - $reactions = $session->reactions()->where('created_at', '>=', now()->subMinutes(5))->latest()->limit(20)->get(); - $qa = app(\App\Services\Meet\QaService::class)->forSession($session, true); - $polls = $session->polls()->where('status', 'open')->with('votes')->get(); - $breakouts = $session->breakoutRooms()->where('status', 'open')->get(); - $broadcasts = $session->messages()->where('type', 'breakout_broadcast')->where('created_at', '>=', now()->subMinutes(30))->latest()->limit(5)->get(); - $files = $session->sessionFiles() - ->where(function ($q) { - $q->whereNull('expires_at')->orWhere('expires_at', '>', now()); - }) - ->latest() - ->limit(20) - ->get(); + if ($request->headers->get('If-None-Match') === $etag) { + return response('', 304)->withHeaders([ + 'ETag' => $etag, + 'Cache-Control' => 'private, no-cache', + ]); + } - return response()->json(array_merge([ - 'participants' => $session->participants->map( - fn ($p) => ParticipantPresenter::toArray($p, $avatars, $session->room->host_user_ref) - ), - 'waiting_count' => $session->participants->where('status', 'waiting')->count(), - 'messages' => $messages->map(fn ($m) => [ - 'uuid' => $m->uuid, - 'sender_name' => $m->sender_name, - 'body' => $m->body, - 'type' => $m->type, - 'created_at' => $m->created_at->toIso8601String(), - ]), - 'reactions' => $reactions->map(fn ($r) => [ - 'sender_name' => $r->sender_name, - 'emoji' => $r->emoji, - 'created_at' => $r->created_at->toIso8601String(), - ]), - 'qa' => $qa->map(fn ($q) => [ - 'uuid' => $q->uuid, - 'asker_name' => $q->asker_name, - 'question' => $q->question, - 'status' => $q->status, - 'answer' => $q->answer, - 'upvotes' => $q->upvotes, - ]), - 'polls' => $polls->map(fn ($p) => [ - 'uuid' => $p->uuid, - 'question' => $p->question, - 'options' => $p->options, - 'votes' => $p->votes->count(), - ]), - 'breakouts' => $breakouts->map(fn ($b) => [ - 'uuid' => $b->uuid, - 'name' => $b->name, - 'closes_at' => $b->closes_at?->toIso8601String(), - ]), - 'broadcasts' => $broadcasts->map(fn ($m) => [ - 'body' => $m->body, - 'sender_name' => $m->sender_name, - 'created_at' => $m->created_at->toIso8601String(), - ]), - 'files' => $files->map(fn ($f) => [ - 'uuid' => $f->uuid, - 'original_name' => $f->original_name, - 'file_size' => $f->file_size, - ]), - 'media' => $this->sessions->mediaSessionState($participant), - 'breakouts_active' => app(BreakoutService::class)->hasActiveBreakouts($session), - ], app(StageLayoutService::class)->config($session->room))); + unset($shared['_etag']); + + return response() + ->json(array_merge($shared, ['media' => $media])) + ->withHeaders([ + 'ETag' => $etag, + 'Cache-Control' => 'private, no-cache', + ]); + } + + /** + * Session-wide poll fields shared by every participant (2s TTL). + * + * @return array + */ + protected function sharedPollPayload(Session $session): array + { + $cacheKey = "meet:poll:shared:{$session->id}"; + + return Cache::remember($cacheKey, 2, function () use ($session) { + // Ensure room relation is available without re-querying when already loaded. + $session->loadMissing('room'); + $session->load(['participants' => fn ($q) => $q->whereIn('status', ['joined', 'waiting'])]); + + $hostRef = $session->room->host_user_ref; + $avatars = ParticipantPresenter::avatarMap($session->participants, $hostRef); + + $messages = $this->chat->recentMessages($session, 30); + $reactions = $session->reactions() + ->where('created_at', '>=', now()->subMinutes(2)) + ->latest() + ->limit(15) + ->get(); + $qa = app(\App\Services\Meet\QaService::class)->forSession($session, true); + $polls = $session->polls()->where('status', 'open')->with('votes')->get(); + $breakouts = $session->breakoutRooms()->where('status', 'open')->get(); + $broadcasts = $session->messages() + ->where('type', 'breakout_broadcast') + ->where('created_at', '>=', now()->subMinutes(30)) + ->latest() + ->limit(5) + ->get(); + $files = $session->sessionFiles() + ->where(function ($q) { + $q->whereNull('expires_at')->orWhere('expires_at', '>', now()); + }) + ->latest() + ->limit(20) + ->get(); + + $payload = array_merge([ + 'participants' => $session->participants->map( + fn ($p) => ParticipantPresenter::toArray($p, $avatars, $hostRef) + )->values()->all(), + 'waiting_count' => $session->participants->where('status', 'waiting')->count(), + 'messages' => $messages->map(fn ($m) => [ + 'uuid' => $m->uuid, + 'sender_name' => $m->sender_name, + 'body' => $m->body, + 'type' => $m->type, + 'created_at' => $m->created_at->toIso8601String(), + ])->values()->all(), + 'reactions' => $reactions->map(fn ($r) => [ + 'sender_name' => $r->sender_name, + 'emoji' => $r->emoji, + 'created_at' => $r->created_at->toIso8601String(), + ])->values()->all(), + 'qa' => $qa->map(fn ($q) => [ + 'uuid' => $q->uuid, + 'asker_name' => $q->asker_name, + 'question' => $q->question, + 'status' => $q->status, + 'answer' => $q->answer, + 'upvotes' => $q->upvotes, + ])->values()->all(), + 'polls' => $polls->map(fn ($p) => [ + 'uuid' => $p->uuid, + 'question' => $p->question, + 'options' => $p->options, + 'votes' => $p->votes->count(), + ])->values()->all(), + 'breakouts' => $breakouts->map(fn ($b) => [ + 'uuid' => $b->uuid, + 'name' => $b->name, + 'closes_at' => $b->closes_at?->toIso8601String(), + ])->values()->all(), + 'broadcasts' => $broadcasts->map(fn ($m) => [ + 'body' => $m->body, + 'sender_name' => $m->sender_name, + 'created_at' => $m->created_at->toIso8601String(), + ])->values()->all(), + 'files' => $files->map(fn ($f) => [ + 'uuid' => $f->uuid, + 'original_name' => $f->original_name, + 'file_size' => $f->file_size, + ])->values()->all(), + 'breakouts_active' => $breakouts->isNotEmpty(), + ], app(StageLayoutService::class)->config($session->room)); + + $payload['_etag'] = md5(json_encode($payload)); + + return $payload; + }); } public function mediaToken(Request $request, Session $session): JsonResponse diff --git a/resources/js/meet-public.js b/resources/js/meet-public.js index 73e1eb5..6ea85bd 100644 --- a/resources/js/meet-public.js +++ b/resources/js/meet-public.js @@ -6,7 +6,7 @@ Alpine.data('waitingRoom', () => ({ pollTimer: null, init() { - this.pollTimer = setInterval(() => this.poll(), 2500); + this.pollTimer = setInterval(() => this.poll(), 5000); this.poll(); }, diff --git a/resources/js/meet-room.js b/resources/js/meet-room.js index 59acdd7..9b4eb85 100644 --- a/resources/js/meet-room.js +++ b/resources/js/meet-room.js @@ -180,6 +180,11 @@ function meetRoom() { pictureInPictureActive: false, pictureInPictureSupported: false, pollTimer: null, + pollInFlight: false, + pollEtag: null, + pollIntervalMs: 6000, + pollHiddenIntervalMs: 20000, + _lastCaptionSentAt: 0, config: {}, init() { @@ -308,7 +313,7 @@ function meetRoom() { this.connectionStatus = 'Video unavailable — configure LiveKit'; } - this.pollTimer = setInterval(() => this.poll(), 3000); + this.startPolling(); this.applyStageLayout(); this.$nextTick(() => { @@ -319,6 +324,34 @@ function meetRoom() { this.setupPictureInPicture(); }, + currentPollInterval() { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { + return this.pollHiddenIntervalMs; + } + + return this.pollIntervalMs; + }, + + startPolling() { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + + this.poll(); + this.pollTimer = setInterval(() => this.poll(), this.currentPollInterval()); + + if (typeof document !== 'undefined' && !this._pollVisibilityBound) { + this._pollVisibilityBound = true; + document.addEventListener('visibilitychange', () => { + if (this.goingLive) { + return; + } + this.startPolling(); + }); + } + }, + connectionErrorMessage(error) { const name = error?.name || ''; const message = error?.message || ''; @@ -1739,13 +1772,20 @@ function meetRoom() { return; } + this.captionText = normalized; + if (normalized === this._lastCaptionSent) { - this.captionText = normalized; + return; + } + + // Throttle caption posts — speech recognition fires very frequently. + const now = Date.now(); + if (now - (this._lastCaptionSentAt || 0) < 2000) { return; } this._lastCaptionSent = normalized; - this.captionText = normalized; + this._lastCaptionSentAt = now; fetch(this.config.captionUrl, { method: 'POST', @@ -3079,7 +3119,7 @@ function meetRoom() { window.location.replace(data.room_url || `/room/${data.session_uuid}`); } catch { this.goingLive = false; - this.pollTimer = setInterval(() => this.poll(), 3000); + this.startPolling(); } }, @@ -3201,8 +3241,29 @@ function meetRoom() { }, async poll() { + if (this.pollInFlight || !this.config.pollUrl) { + return; + } + + this.pollInFlight = true; + try { - const res = await fetch(this.config.pollUrl, { headers: { Accept: 'application/json' } }); + const headers = { Accept: 'application/json' }; + if (this.pollEtag) { + headers['If-None-Match'] = this.pollEtag; + } + + const res = await fetch(this.config.pollUrl, { headers, credentials: 'same-origin' }); + + const responseEtag = res.headers.get('ETag'); + if (responseEtag) { + this.pollEtag = responseEtag; + } + + if (res.status === 304) { + return; + } + if (!res.ok) { if (res.status === 404 && this.config.endedUrl) { this.redirectToMeetingEnded(this.config.endedUrl); @@ -3253,6 +3314,8 @@ function meetRoom() { this.processReactionsFromPoll(data.reactions || []); } catch (e) { // ignore poll errors + } finally { + this.pollInFlight = false; } },