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""" """Performance tuning settings"""
max_concurrent_thumbnails: int = 10 max_concurrent_thumbnails: int = 10
cache_ttl: int = 3600 cache_ttl: int = 3600
db_pool_size: int = 5 db_pool_size: int = 10
db_pool_max_overflow: int = 5 db_pool_max_overflow: int = 10
db_pool_recycle: int = 3600 db_pool_recycle: int = 3600
class EmbedderSettings(BaseModel): class EmbedderSettings(BaseModel):

View File

@@ -76,6 +76,10 @@ else:
max_overflow=settings.performance.db_pool_max_overflow, max_overflow=settings.performance.db_pool_max_overflow,
pool_recycle=settings.performance.db_pool_recycle, pool_recycle=settings.performance.db_pool_recycle,
pool_pre_ping=True, 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 # Create async session factory
@@ -89,10 +93,17 @@ AsyncSessionLocal = async_sessionmaker(
Base = declarative_base() Base = declarative_base()
async def get_db() -> AsyncSession: 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: async with AsyncSessionLocal() as session:
try: try:
yield session yield session
except Exception:
await session.rollback()
raise
finally: finally:
await session.close() await session.close()

View File

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

View File

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