fix: HEIC vips fallback, DB pool exhaustion, auth session persistence

HEIC thumbnails:
- Switch fallback from ffmpeg to vips for HEIC files that pillow-heif
  rejects. ffmpeg decoded gain map tiles instead of the primary image,
  producing inverted/negative thumbnails. vips uses libheif's item
  references correctly and extracts the full-resolution primary image.

Database pool exhaustion:
- Add idle_in_transaction_session_timeout=60s so Postgres auto-kills
  leaked connections from disconnected thumbnail requests.
- Add pool_timeout=10 so new requests fail fast instead of hanging.
- Bump pool from 5+5 to 10+10 for thumbnail concurrency headroom.
- get_db rolls back on exception before closing.

Auth session persistence:
- Narrow 401 interceptor exclusion to only /auth/refresh and /auth/login
  (was excluding all /auth/* including /auth/me, preventing token refresh
  on boot).
- fetchMe only clears tokens on 401/403, not network errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-13 15:25:55 +02:00
parent 2adaaf18a1
commit ecd8bbe61d
4 changed files with 39 additions and 18 deletions

View File

@@ -26,8 +26,8 @@ class PerformanceSettings(BaseModel):
"""Performance tuning settings"""
max_concurrent_thumbnails: int = 10
cache_ttl: int = 3600
db_pool_size: int = 5
db_pool_max_overflow: int = 5
db_pool_size: int = 10
db_pool_max_overflow: int = 10
db_pool_recycle: int = 3600
class EmbedderSettings(BaseModel):

View File

@@ -76,6 +76,10 @@ else:
max_overflow=settings.performance.db_pool_max_overflow,
pool_recycle=settings.performance.db_pool_recycle,
pool_pre_ping=True,
pool_timeout=10, # fail fast if pool exhausted (default 30)
# Kill connections idle in a transaction for >60s. Prevents leaked
# sessions from thumbnail requests that disconnect mid-flight.
connect_args={"server_settings": {"idle_in_transaction_session_timeout": "60000"}},
)
# Create async session factory
@@ -89,10 +93,17 @@ AsyncSessionLocal = async_sessionmaker(
Base = declarative_base()
async def get_db() -> AsyncSession:
"""Dependency to get database session"""
"""Dependency to get database session.
Rolls back any uncommitted transaction before closing so a client
disconnect doesn't leave idle-in-transaction connections in the pool.
"""
async with AsyncSessionLocal() as session:
try:
yield session
except Exception:
await session.rollback()
raise
finally:
await session.close()

View File

@@ -114,23 +114,27 @@ def process_heic_image(filepath: str) -> Image.Image:
img = img.convert('RGB')
return img
except Exception as e:
logger.warning(f"pillow-heif failed for {filepath}: {e} — trying ffmpeg")
logger.warning(f"pillow-heif failed for {filepath}: {e} — trying vips")
# ffmpeg fallback: decode HEIC to PNG in memory.
import subprocess
from io import BytesIO
# vips fallback: handles tiled Apple HEIC files (bursts, HDR gain
# maps, depth maps) that pillow-heif/libheif rejects due to too many
# auxiliary image references.
import subprocess, tempfile
try:
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
tmp_path = tmp.name
result = subprocess.run(
['ffmpeg', '-i', filepath, '-frames:v', '1',
'-f', 'image2pipe', '-vcodec', 'png', 'pipe:1'],
capture_output=True, timeout=30, stdin=subprocess.DEVNULL,
['vips', 'heifload', filepath, tmp_path],
capture_output=True, timeout=60, stdin=subprocess.DEVNULL,
)
if result.returncode == 0 and result.stdout:
img = Image.open(BytesIO(result.stdout)).convert('RGB')
if result.returncode == 0:
img = Image.open(tmp_path).convert('RGB')
os.unlink(tmp_path)
return img
logger.error(f"ffmpeg HEIC decode failed for {filepath}: {result.stderr.decode()[-200:]}")
logger.error(f"vips HEIC decode failed for {filepath}: {result.stderr.decode()[-200:]}")
os.unlink(tmp_path)
except Exception as e2:
logger.error(f"ffmpeg fallback failed for {filepath}: {e2}")
logger.error(f"vips fallback failed for {filepath}: {e2}")
raise RuntimeError(f"Cannot decode HEIC: {filepath}")
def process_video_thumbnail(filepath: str) -> Image.Image:

View File

@@ -72,9 +72,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
const res = await api.get('/auth/me')
setUser(res.data)
} catch {
clearTokens()
setUser(null)
} catch (err: any) {
// Only clear tokens on auth failure (401/403), not network errors.
if (err?.response?.status === 401 || err?.response?.status === 403) {
clearTokens()
setUser(null)
}
// Network errors: leave tokens in place, user stays on login screen
// but can retry without re-entering credentials.
}
}, [])
@@ -136,7 +141,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (
error.response?.status === 401 &&
!original._retry &&
!original.url?.includes('/auth/')
!original.url?.includes('/auth/refresh') &&
!original.url?.includes('/auth/login')
) {
original._retry = true
const rt = getStoredRefreshToken()