Reduce Meet poll load with shared cache, ETag, and slower clients.
Deploy Ladill Meet / deploy (push) Successful in 2m44s
Deploy Ladill Meet / deploy (push) Successful in 2m44s
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.
This commit is contained in:
@@ -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<string, mixed>
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -6,7 +6,7 @@ Alpine.data('waitingRoom', () => ({
|
||||
pollTimer: null,
|
||||
|
||||
init() {
|
||||
this.pollTimer = setInterval(() => this.poll(), 2500);
|
||||
this.pollTimer = setInterval(() => this.poll(), 5000);
|
||||
this.poll();
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user