Fix DB init - composite PK for hypertable + separate transactions
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 7s

This commit is contained in:
2026-06-06 15:01:39 +01:00
parent ecc077f153
commit 264c27469b
2 changed files with 49 additions and 65 deletions
+24 -8
View File
@@ -2,33 +2,44 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from sqlalchemy import text
import asyncio
from app.core.database import engine, AsyncSessionLocal, Base
from app.core.config import settings
from app.api import auth, activities, routes, health, records, upload
@asynccontextmanager
async def lifespan(app: FastAPI):
# Create tables
async def init_db():
"""Create tables then seed admin, with retries for slow DB startup."""
for attempt in range(10):
try:
# Step 1: create all tables (separate connection so it commits cleanly)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
break
except Exception as e:
if attempt == 9:
raise
print(f"DB not ready yet (attempt {attempt + 1}/10): {e}")
await asyncio.sleep(3)
# Try to enable TimescaleDB hypertable for data points
# Step 2: try to enable TimescaleDB hypertable (separate connection,
# failure here is non-fatal - falls back to plain Postgres)
try:
async with engine.begin() as conn:
await conn.execute(text(
"SELECT create_hypertable('activity_data_points', 'timestamp', "
"if_not_exists => TRUE, migrate_data => TRUE)"
))
except Exception:
pass # Already exists or TimescaleDB not available
except Exception as e:
print(f"TimescaleDB hypertable skipped: {e}")
# Seed admin user
async with AsyncSessionLocal() as db:
# Step 3: seed admin user
from sqlalchemy import select
from app.models.user import User
from app.core.security import hash_password
async with AsyncSessionLocal() as db:
result = await db.execute(
select(User).where(User.username == settings.admin_username)
)
@@ -40,7 +51,12 @@ async def lifespan(app: FastAPI):
)
db.add(admin)
await db.commit()
print(f"Admin user '{settings.admin_username}' created")
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
+17 -49
View File
@@ -17,7 +17,7 @@ class User(Base):
id = Column(Integer, primary_key=True)
username = Column(String(64), unique=True, nullable=False, index=True)
email = Column(String(256), unique=True, nullable=True)
hashed_password = Column(String(256), nullable=True) # null = OIDC-only user
hashed_password = Column(String(256), nullable=True)
is_admin = Column(Boolean, default=False)
pocketid_sub = Column(String(256), unique=True, nullable=True)
created_at = Column(DateTime(timezone=True), default=now_utc)
@@ -32,16 +32,12 @@ class Activity(Base):
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
# Core fields
name = Column(String(256), nullable=False)
sport_type = Column(String(64), nullable=False) # running, cycling, swimming, etc.
sport_type = Column(String(64), nullable=False)
start_time = Column(DateTime(timezone=True), nullable=False, index=True)
end_time = Column(DateTime(timezone=True), nullable=True)
# Metrics summary (cached aggregates)
distance_m = Column(Float, nullable=True) # metres
duration_s = Column(Float, nullable=True) # seconds
distance_m = Column(Float, nullable=True)
duration_s = Column(Float, nullable=True)
elevation_gain_m = Column(Float, nullable=True)
elevation_loss_m = Column(Float, nullable=True)
avg_heart_rate = Column(Float, nullable=True)
@@ -55,23 +51,14 @@ class Activity(Base):
calories = Column(Float, nullable=True)
training_stress_score = Column(Float, nullable=True)
vo2max_estimate = Column(Float, nullable=True)
# Route reference
named_route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=True)
# Raw GPS track (encoded polyline for quick map render)
polyline = Column(Text, nullable=True)
bounding_box = Column(JSON, nullable=True) # {min_lat, max_lat, min_lon, max_lon}
# Source file info
bounding_box = Column(JSON, nullable=True)
source_file = Column(String(512), nullable=True)
source_type = Column(String(32), nullable=True) # fit, gpx, strava_json
source_type = Column(String(32), nullable=True)
garmin_activity_id = Column(String(64), nullable=True, unique=True)
strava_activity_id = Column(String(64), nullable=True, unique=True)
# HR zones (% of time in each zone)
hr_zones = Column(JSON, nullable=True) # {z1: pct, z2: pct, ...}
hr_zones = Column(JSON, nullable=True)
created_at = Column(DateTime(timezone=True), default=now_utc)
user = relationship("User", back_populates="activities")
@@ -83,14 +70,13 @@ class Activity(Base):
class ActivityDataPoint(Base):
"""
TimescaleDB hypertable - one row per second of activity data.
After creation, converted to hypertable in migration:
SELECT create_hypertable('activity_data_points', 'timestamp');
Composite primary key (activity_id, timestamp) satisfies TimescaleDB's
requirement that the partition column be part of the primary key.
"""
__tablename__ = "activity_data_points"
id = Column(Integer, primary_key=True)
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, index=True)
timestamp = Column(DateTime(timezone=True), nullable=False)
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, primary_key=True)
timestamp = Column(DateTime(timezone=True), nullable=False, primary_key=True)
latitude = Column(Float, nullable=True)
longitude = Column(Float, nullable=True)
altitude_m = Column(Float, nullable=True)
@@ -99,11 +85,7 @@ class ActivityDataPoint(Base):
speed_ms = Column(Float, nullable=True)
power = Column(Float, nullable=True)
temperature_c = Column(Float, nullable=True)
distance_m = Column(Float, nullable=True) # cumulative distance
__table_args__ = (
Index("ix_adp_activity_time", "activity_id", "timestamp"),
)
distance_m = Column(Float, nullable=True)
activity = relationship("Activity", back_populates="data_points")
@@ -133,7 +115,7 @@ class NamedRoute(Base):
name = Column(String(256), nullable=False)
description = Column(Text, nullable=True)
sport_type = Column(String(64), nullable=True)
reference_polyline = Column(Text, nullable=True) # canonical route polyline
reference_polyline = Column(Text, nullable=True)
bounding_box = Column(JSON, nullable=True)
distance_m = Column(Float, nullable=True)
created_at = Column(DateTime(timezone=True), default=now_utc)
@@ -144,13 +126,12 @@ class NamedRoute(Base):
class RouteSegment(Base):
"""Named sections within a route for targeted comparisons (e.g. 'The big hill')"""
__tablename__ = "route_segments"
id = Column(Integer, primary_key=True)
route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=False, index=True)
name = Column(String(256), nullable=False)
start_distance_m = Column(Float, nullable=False) # distance into route where segment starts
start_distance_m = Column(Float, nullable=False)
end_distance_m = Column(Float, nullable=False)
description = Column(Text, nullable=True)
@@ -164,8 +145,8 @@ class PersonalRecord(Base):
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False)
sport_type = Column(String(64), nullable=False)
distance_m = Column(Float, nullable=False) # e.g. 1000, 1609, 5000, 10000, 42195
distance_label = Column(String(32), nullable=False) # e.g. "1k", "1 mile", "5k"
distance_m = Column(Float, nullable=False)
distance_label = Column(String(32), nullable=False)
duration_s = Column(Float, nullable=False)
achieved_at = Column(DateTime(timezone=True), nullable=False)
is_current_record = Column(Boolean, default=True)
@@ -177,25 +158,18 @@ class PersonalRecord(Base):
class HealthMetric(Base):
"""Daily health summary metrics from Garmin Connect / FIT wellness data"""
__tablename__ = "health_metrics"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
date = Column(DateTime(timezone=True), nullable=False)
# Heart rate
resting_hr = Column(Float, nullable=True)
max_hr_day = Column(Float, nullable=True)
avg_hr_day = Column(Float, nullable=True)
# HRV
hrv_status = Column(String(32), nullable=True) # balanced, unbalanced, etc.
hrv_status = Column(String(32), nullable=True)
hrv_nightly_avg = Column(Float, nullable=True)
hrv_5min_high = Column(Float, nullable=True)
hrv_5min_low = Column(Float, nullable=True)
# Sleep
sleep_duration_s = Column(Float, nullable=True)
sleep_deep_s = Column(Float, nullable=True)
sleep_light_s = Column(Float, nullable=True)
@@ -204,20 +178,14 @@ class HealthMetric(Base):
sleep_score = Column(Float, nullable=True)
sleep_start = Column(DateTime(timezone=True), nullable=True)
sleep_end = Column(DateTime(timezone=True), nullable=True)
# Body composition
weight_kg = Column(Float, nullable=True)
bmi = Column(Float, nullable=True)
body_fat_pct = Column(Float, nullable=True)
muscle_mass_kg = Column(Float, nullable=True)
# Fitness
vo2max = Column(Float, nullable=True)
fitness_age = Column(Integer, nullable=True)
training_load = Column(Float, nullable=True)
recovery_time_h = Column(Float, nullable=True)
# Stress & activity
avg_stress = Column(Float, nullable=True)
steps = Column(Integer, nullable=True)
floors_climbed = Column(Integer, nullable=True)