Skip to content

Commit d79af9c

Browse files
Sbussisoclaude
andcommitted
Performance: batch queries, shared contexts, cache eviction, token sharing
Backend: - Add Setting.get_many() for batch key lookups (1 query instead of 4) - Use get_many() in settings endpoints (4x fewer DB queries) - Add TTL + max-size eviction to HLS playlist/access-log caches (prevents unbounded memory growth with many cameras) - Fix require_active_billing to reuse request's DB session via Depends(get_db) instead of opening a separate SessionLocal() Frontend: - Add PlanInfoProvider context: single 60s poll shared by all pages (Layout, Dashboard, Settings, Admin, MCP) — eliminates 3-4 duplicate getPlanInfo() calls per navigation - Add SharedTokenProvider: one 30s token refresh interval shared by all HlsPlayer instances — eliminates N intervals for N cameras - Replace JSON.stringify camera comparison with shallow field check (status, name, last_seen) to avoid blocking main thread on polls Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 69d5ebf commit d79af9c

13 files changed

Lines changed: 236 additions & 183 deletions

File tree

backend/app/api/cameras.py

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -209,16 +209,18 @@ async def get_all_settings(
209209
user: AuthUser = Depends(require_view), db: Session = Depends(get_db)
210210
):
211211
"""Get all settings for the user's organization."""
212+
vals = Setting.get_many(db, user.org_id, {
213+
"scheduled_recording": "false",
214+
"scheduled_start": "06:00",
215+
"scheduled_end": "17:00",
216+
"continuous_24_7": "false",
217+
})
212218
return {
213219
"recording": {
214-
"scheduled_recording": Setting.get(
215-
db, user.org_id, "scheduled_recording", "false"
216-
)
217-
== "true",
218-
"scheduled_start": Setting.get(db, user.org_id, "scheduled_start", "06:00"),
219-
"scheduled_end": Setting.get(db, user.org_id, "scheduled_end", "17:00"),
220-
"continuous_24_7": Setting.get(db, user.org_id, "continuous_24_7", "false")
221-
== "true",
220+
"scheduled_recording": vals["scheduled_recording"] == "true",
221+
"scheduled_start": vals["scheduled_start"],
222+
"scheduled_end": vals["scheduled_end"],
223+
"continuous_24_7": vals["continuous_24_7"] == "true",
222224
},
223225
}
224226

@@ -228,15 +230,17 @@ async def get_recording_settings(
228230
user: AuthUser = Depends(require_view), db: Session = Depends(get_db)
229231
):
230232
"""Get recording settings."""
233+
vals = Setting.get_many(db, user.org_id, {
234+
"scheduled_recording": "false",
235+
"scheduled_start": "06:00",
236+
"scheduled_end": "17:00",
237+
"continuous_24_7": "false",
238+
})
231239
return {
232-
"scheduled_recording": Setting.get(
233-
db, user.org_id, "scheduled_recording", "false"
234-
)
235-
== "true",
236-
"scheduled_start": Setting.get(db, user.org_id, "scheduled_start", "06:00"),
237-
"scheduled_end": Setting.get(db, user.org_id, "scheduled_end", "17:00"),
238-
"continuous_24_7": Setting.get(db, user.org_id, "continuous_24_7", "false")
239-
== "true",
240+
"scheduled_recording": vals["scheduled_recording"] == "true",
241+
"scheduled_start": vals["scheduled_start"],
242+
"scheduled_end": vals["scheduled_end"],
243+
"continuous_24_7": vals["continuous_24_7"] == "true",
240244
}
241245

242246

backend/app/api/hls.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
# playlist periodically even if the CloudNode hasn't pushed an update,
3838
# so that segment URLs never expire while still in the playlist.
3939
_PLAYLIST_CACHE_MAX_AGE = 300.0 # 5 minutes — well within 15-min URL expiry
40+
_CACHE_MAX_CAMERAS = 500 # Evict oldest entries above this limit
4041

4142
# Track playlist update count per camera to trigger periodic Tigris cleanup.
4243
# Old segments pile up on Tigris since batch uploads have no confirm step.
@@ -50,6 +51,27 @@
5051
# {(user_id, camera_id): monotonic_timestamp_of_last_log}
5152
_ACCESS_LOG_INTERVAL = 300.0 # 5 minutes
5253
_last_access_logged: dict[tuple[str, str], float] = {}
54+
_ACCESS_LOG_MAX_ENTRIES = 10000 # Evict stale entries above this limit
55+
56+
57+
def _evict_caches():
58+
"""Evict stale entries from module-level caches to prevent unbounded growth."""
59+
now = time.monotonic()
60+
61+
# Evict expired playlist caches
62+
if len(_playlist_cache) > _CACHE_MAX_CAMERAS:
63+
# Sort by timestamp, keep newest
64+
sorted_entries = sorted(_playlist_cache.items(), key=lambda x: x[1][1])
65+
for camera_id, _ in sorted_entries[:len(sorted_entries) - _CACHE_MAX_CAMERAS]:
66+
del _playlist_cache[camera_id]
67+
_playlist_update_count.pop(camera_id, None)
68+
69+
# Evict stale access log entries (older than 2x the log interval)
70+
if len(_last_access_logged) > _ACCESS_LOG_MAX_ENTRIES:
71+
cutoff = now - (_ACCESS_LOG_INTERVAL * 2)
72+
stale_keys = [k for k, ts in _last_access_logged.items() if ts < cutoff]
73+
for k in stale_keys:
74+
del _last_access_logged[k]
5375

5476

5577
def _maybe_log_access(
@@ -308,11 +330,11 @@ async def update_hls_playlist(
308330
)
309331
_playlist_cache[camera_id] = (rewritten, time.monotonic())
310332

311-
# Periodically clean up old segments on Tigris. Batch uploads
312-
# don't have a confirm step, so segments pile up indefinitely.
333+
# Periodically clean up old segments on Tigris and evict stale caches.
313334
count = _playlist_update_count.get(camera_id, 0) + 1
314335
_playlist_update_count[camera_id] = count
315336
if count % settings.CLEANUP_INTERVAL == 0:
337+
_evict_caches()
316338
asyncio.ensure_future(
317339
_cleanup_old_segments(node.org_id, camera_id)
318340
)

backend/app/core/auth.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import httpx
22
from fastapi import Depends, HTTPException, Request, status
3+
from sqlalchemy.orm import Session
34
from app.core.config import settings
5+
from app.core.database import get_db
46
from clerk_backend_api.security import AuthenticateRequestOptions
57
from app.core.clerk import clerk
68

@@ -197,20 +199,19 @@ async def require_admin(user: AuthUser = Depends(get_current_user)) -> AuthUser:
197199
return user
198200

199201

200-
async def require_active_billing(user: AuthUser = Depends(require_admin)) -> AuthUser:
202+
async def require_active_billing(
203+
user: AuthUser = Depends(require_admin),
204+
db: Session = Depends(get_db),
205+
) -> AuthUser:
201206
"""Admin + payment must not be past due. Use for write operations
202207
(create node, create key, etc.) so past-due orgs can still read
203-
their data but can't provision new resources."""
204-
from app.core.database import get_db, SessionLocal
208+
their data but can't provision new resources.
209+
Reuses the request's existing DB session instead of opening a new one."""
205210
from app.models.models import Setting
206211

207-
db = SessionLocal()
208-
try:
209-
if Setting.get(db, user.org_id, "payment_past_due", "false") == "true":
210-
raise HTTPException(
211-
status_code=status.HTTP_402_PAYMENT_REQUIRED,
212-
detail="Your payment is past due. Please update your billing information before making changes.",
213-
)
214-
finally:
215-
db.close()
212+
if Setting.get(db, user.org_id, "payment_past_due", "false") == "true":
213+
raise HTTPException(
214+
status_code=status.HTTP_402_PAYMENT_REQUIRED,
215+
detail="Your payment is past due. Please update your billing information before making changes.",
216+
)
216217
return user

backend/app/models/models.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,20 @@ def get(db, org_id: str, key: str, default: str = None) -> str:
9494
setting = db.query(Setting).filter_by(org_id=org_id, key=key).first()
9595
return setting.value if setting else default
9696

97+
@staticmethod
98+
def get_many(db, org_id: str, keys_defaults: dict) -> dict:
99+
"""Fetch multiple settings in a single query.
100+
keys_defaults: {key: default_value, ...}
101+
Returns: {key: value, ...}
102+
"""
103+
rows = (
104+
db.query(Setting)
105+
.filter(Setting.org_id == org_id, Setting.key.in_(keys_defaults.keys()))
106+
.all()
107+
)
108+
found = {row.key: row.value for row in rows}
109+
return {k: found.get(k, default) for k, default in keys_defaults.items()}
110+
97111
@staticmethod
98112
def set(db, org_id: str, key: str, value: str):
99113
setting = db.query(Setting).filter_by(org_id=org_id, key=key).first()

frontend/src/components/HlsPlayer.jsx

Lines changed: 14 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect, useRef, useState } from "react"
22
import Hls from "hls.js"
3-
import { useAuth } from "@clerk/clerk-react"
3+
import { useSharedToken } from "../hooks/useSharedToken.jsx"
44

55
// Set to true to connect directly to CloudNode on localhost:8080
66
// Set to false to use backend proxy with authentication
@@ -9,7 +9,7 @@ const LOCAL_TEST_MODE = import.meta.env.VITE_LOCAL_HLS === "true"
99
function HlsPlayer({ cameraId, cameraName }) {
1010
const videoRef = useRef(null)
1111
const hlsRef = useRef(null)
12-
const { getToken } = useAuth()
12+
const { getCurrentToken, refreshNow } = useSharedToken()
1313
const [loading, setLoading] = useState(true)
1414
const [error, setError] = useState(null)
1515
const [isLive, setIsLive] = useState(false)
@@ -29,14 +29,10 @@ function HlsPlayer({ cameraId, cameraName }) {
2929
? `http://localhost:8080/hls/${cameraId}/stream.m3u8`
3030
: `${API_URL}/api/cameras/${cameraId}/stream.m3u8`
3131

32-
// Pre-fetch the auth token BEFORE creating hls.js.
33-
// xhrSetup is called synchronously — an async callback
34-
// would return a Promise that hls.js never awaits, so
35-
// the request would fire without the Authorization header.
36-
let authToken = null
37-
if (!LOCAL_TEST_MODE) {
38-
authToken = await getToken()
39-
}
32+
// Get the current shared auth token. xhrSetup reads the
33+
// latest token on each request via getCurrentToken(), so
34+
// no per-player refresh interval is needed.
35+
let authToken = LOCAL_TEST_MODE ? null : getCurrentToken()
4036

4137
if (Hls.isSupported()) {
4238
const hls = new Hls({
@@ -45,8 +41,10 @@ function HlsPlayer({ cameraId, cameraName }) {
4541
// Presigned Tigris URLs already carry auth in the query
4642
// string — sending an Authorization header to Tigris
4743
// triggers a CORS preflight that Tigris rejects.
48-
if (authToken && url.startsWith(ownOrigin)) {
49-
xhr.setRequestHeader("Authorization", `Bearer ${authToken}`)
44+
// Read the latest shared token on each request.
45+
const token = LOCAL_TEST_MODE ? null : getCurrentToken()
46+
if (token && url.startsWith(ownOrigin)) {
47+
xhr.setRequestHeader("Authorization", `Bearer ${token}`)
5048
}
5149
},
5250

@@ -83,29 +81,6 @@ function HlsPlayer({ cameraId, cameraName }) {
8381

8482
hlsRef.current = hls
8583

86-
// Refresh the auth token every 30 seconds so it never
87-
// reaches Clerk's 60s expiry. If a refresh fails, the
88-
// next attempt is only 30s away (not 50s).
89-
const tokenRefreshInterval = setInterval(async () => {
90-
try {
91-
authToken = await getToken()
92-
} catch (e) {
93-
console.warn("[HlsPlayer] Token refresh failed:", e)
94-
}
95-
}, 30000)
96-
97-
// Store interval ID for cleanup
98-
hls._tokenRefreshInterval = tokenRefreshInterval
99-
100-
// Helper: refresh token immediately (called on auth failures)
101-
const refreshTokenNow = async () => {
102-
try {
103-
authToken = await getToken()
104-
} catch (e) {
105-
console.warn("[HlsPlayer] Urgent token refresh failed:", e)
106-
}
107-
}
108-
10984
hls.loadSource(playlistUrl)
11085
hls.attachMedia(video)
11186

@@ -163,11 +138,10 @@ function HlsPlayer({ cameraId, cameraName }) {
163138
switch (data.type) {
164139
case Hls.ErrorTypes.NETWORK_ERROR:
165140
// Network errors are often caused by an expired
166-
// auth token (401). Refresh the token immediately
167-
// before retrying, so the next request uses a
168-
// fresh token instead of the stale one.
141+
// auth token (401). Refresh the shared token
142+
// immediately before retrying.
169143
console.warn("[HlsPlayer] Network error, refreshing token and retrying:", data.details)
170-
refreshTokenNow().then(() => {
144+
refreshNow().then(() => {
171145
hls.startLoad()
172146
})
173147
break
@@ -196,17 +170,14 @@ function HlsPlayer({ cameraId, cameraName }) {
196170

197171
return () => {
198172
if (hlsRef.current) {
199-
if (hlsRef.current._tokenRefreshInterval) {
200-
clearInterval(hlsRef.current._tokenRefreshInterval)
201-
}
202173
if (hlsRef.current._stallCheck) {
203174
clearInterval(hlsRef.current._stallCheck)
204175
}
205176
hlsRef.current.destroy()
206177
hlsRef.current = null
207178
}
208179
}
209-
}, [cameraId, getToken])
180+
}, [cameraId, getCurrentToken])
210181

211182
if (error) {
212183
return (

frontend/src/components/Layout.jsx

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,19 @@
11
import { Outlet, Link, useLocation } from "react-router-dom"
2-
import { SignedIn, SignedOut, UserButton, OrganizationSwitcher, useOrganization, useAuth } from "@clerk/clerk-react"
3-
import { useState, useEffect } from "react"
4-
import { getPlanInfo } from "../services/api"
2+
import { SignedIn, SignedOut, UserButton, OrganizationSwitcher, useOrganization } from "@clerk/clerk-react"
3+
import { usePlanInfo } from "../hooks/usePlanInfo.jsx"
54
import ToastContainer from "./ToastContainer.jsx"
65

76
function Layout() {
87
const { organization, isLoaded: orgLoaded, membership } = useOrganization()
9-
const { getToken } = useAuth()
8+
const { planInfo } = usePlanInfo()
109
const location = useLocation()
11-
const [planFeatures, setPlanFeatures] = useState([])
12-
13-
const [planName, setPlanName] = useState(null)
1410

1511
const isAdmin = orgLoaded && membership?.role === "org:admin"
12+
const planFeatures = planInfo?.features || []
13+
const planName = planInfo?.plan || null
1614
const hasAdminFeature = planFeatures.includes("admin")
1715
const isPro = planName === "pro" || planName === "business"
1816

19-
useEffect(() => {
20-
if (organization && isAdmin) {
21-
loadPlanFeatures()
22-
}
23-
}, [organization])
24-
25-
const loadPlanFeatures = async () => {
26-
try {
27-
const token = await getToken()
28-
const data = await getPlanInfo(() => Promise.resolve(token))
29-
setPlanFeatures(data.features || [])
30-
setPlanName(data.plan || null)
31-
} catch (err) {
32-
// Silently fail — nav still works, just hides admin link
33-
}
34-
}
35-
3617
const isActive = (path) => location.pathname === path ? "nav-link active" : "nav-link"
3718

3819
return (

frontend/src/hooks/usePlanInfo.jsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { createContext, useContext, useState, useEffect, useCallback, useRef } from "react"
2+
import { useAuth, useOrganization } from "@clerk/clerk-react"
3+
import { getPlanInfo } from "../services/api"
4+
5+
const PlanInfoContext = createContext(null)
6+
7+
const REFRESH_INTERVAL = 60000 // 60 seconds
8+
9+
export function PlanInfoProvider({ children }) {
10+
const { getToken } = useAuth()
11+
const { organization } = useOrganization()
12+
const [planInfo, setPlanInfo] = useState(null)
13+
const [loading, setLoading] = useState(false)
14+
const lastOrgRef = useRef(null)
15+
16+
const loadPlanInfo = useCallback(async () => {
17+
if (!organization) return
18+
try {
19+
setLoading(true)
20+
const token = await getToken()
21+
const data = await getPlanInfo(() => Promise.resolve(token))
22+
setPlanInfo(data)
23+
} catch (err) {
24+
console.error("[PlanInfo] Failed to load:", err)
25+
} finally {
26+
setLoading(false)
27+
}
28+
}, [organization, getToken])
29+
30+
// Load on mount and when org changes
31+
useEffect(() => {
32+
if (!organization) {
33+
setPlanInfo(null)
34+
return
35+
}
36+
// Reset if org changed
37+
if (lastOrgRef.current !== organization.id) {
38+
lastOrgRef.current = organization.id
39+
setPlanInfo(null)
40+
}
41+
loadPlanInfo()
42+
const interval = setInterval(loadPlanInfo, REFRESH_INTERVAL)
43+
return () => clearInterval(interval)
44+
}, [organization, loadPlanInfo])
45+
46+
return (
47+
<PlanInfoContext.Provider value={{ planInfo, loading, refreshPlanInfo: loadPlanInfo }}>
48+
{children}
49+
</PlanInfoContext.Provider>
50+
)
51+
}
52+
53+
export function usePlanInfo() {
54+
const context = useContext(PlanInfoContext)
55+
if (!context) {
56+
throw new Error("usePlanInfo must be used within PlanInfoProvider")
57+
}
58+
return context
59+
}

0 commit comments

Comments
 (0)