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