Persist best efforts during import and add resumable backfill

This commit is contained in:
Peter Stockings
2026-10-02 14:05:37 +10:00
parent a82135bc89
commit 9145bf8e47
9 changed files with 317 additions and 16 deletions
+3
View File
@@ -67,6 +67,9 @@ def create_app(config=None):
from .sync import register_cli
register_cli(app)
from .records import register_cli as register_records_cli
register_records_cli(app)
@login_manager.user_loader
def load_user(user_id):
+5 -2
View File
@@ -54,8 +54,11 @@ class Activity(db.Model):
metrics = db.Column(db.JSON, nullable=False, default=dict, server_default="{}")
visibility = db.Column(db.String(12), nullable=False, default="private")
share_token = db.Column(db.String(64), unique=True)
samples = db.Column(db.JSON, nullable=False, default=list)
laps = db.Column(db.JSON, nullable=False, default=list)
best_efforts = db.Column(db.JSON, nullable=False, default=list, server_default="[]")
efforts_version = db.Column(db.Integer, nullable=False, default=0, server_default="0")
has_route = db.Column(db.Boolean, nullable=False, default=False, server_default=db.false())
samples = db.deferred(db.Column(db.JSON, nullable=False, default=list))
laps = db.deferred(db.Column(db.JSON, nullable=False, default=list))
created_at = db.Column(db.DateTime(timezone=True), default=lambda: datetime.now(UTC))
__table_args__ = (db.UniqueConstraint("owner_id", "source_key"),)
+61 -2
View File
@@ -2,8 +2,11 @@
from bisect import bisect_left, bisect_right
import click
from .analytics import numeric
EFFORTS_VERSION = 1
DISTANCES = (
("1 km", 1000),
("2 km", 2000),
@@ -75,15 +78,26 @@ def best_efforts(activity):
return [best[label] for label, _ in DISTANCES if label in best]
def persist_efforts(activity):
"""Store derived data in the same transaction as the imported run."""
activity.best_efforts = best_efforts(activity)
activity.efforts_version = EFFORTS_VERSION
activity.has_route = any(sample.get("point") for sample in activity.samples)
def record_book(activities):
"""Recompute in activity chronology so backfills and removals stay correct.
"""Compare persisted efforts in chronology; never read samples here.
First efforts establish a record. Strict improvements earn historical PR
badges, which remain on the run after a later activity beats that record.
"""
records, runs = {}, {}
for activity in sorted(activities, key=lambda row: (row.started_at, row.id)):
efforts = best_efforts(activity)
efforts = (
[dict(effort) for effort in activity.best_efforts]
if activity.efforts_version == EFFORTS_VERSION and activity.sport == "running"
else []
)
for effort in efforts:
previous = records.get(effort["label"])
effort["pr"] = previous is None or effort["seconds"] < previous["seconds"] - 1e-6
@@ -91,3 +105,48 @@ def record_book(activities):
records[effort["label"]] = dict(effort, activity=activity)
runs[activity.id] = efforts
return {"records": [records[label] for label, _ in DISTANCES if label in records], "runs": runs}
def register_cli(app):
@app.cli.command("backfill-efforts")
@click.option("--batch-size", default=25, type=click.IntRange(1, 1000), show_default=True)
@click.option("--owner-id", type=click.IntRange(1), help="Limit to one athlete.")
@click.option("--force", is_flag=True, help="Recalculate even current-version efforts.")
def backfill(batch_size, owner_id, force):
"""Backfill persisted best efforts in bounded, resumable transactions."""
from sqlalchemy.orm import undefer
from .extensions import db
from .models import Activity
last_id, count = 0, 0
# A fixed upper bound prevents concurrent imports extending this run forever.
maximum = db.session.scalar(db.select(db.func.max(Activity.id))) or 0
while last_id < maximum:
query = db.select(Activity).where(Activity.id > last_id, Activity.id <= maximum)
if owner_id:
query = query.where(Activity.owner_id == owner_id)
if not force:
query = query.where(Activity.efforts_version != EFFORTS_VERSION)
rows = db.session.scalars(
query.options(undefer(Activity.samples))
.order_by(Activity.id)
.limit(batch_size)
.with_for_update()
).all()
if not rows:
break
try:
for row in rows:
persist_efforts(row)
last_id = rows[-1].id
db.session.commit()
except Exception as error:
db.session.rollback()
raise click.ClickException(
"Backfill failed; completed batches are saved. Rerun to resume."
) from error
count += len(rows)
db.session.expunge_all()
click.echo(f"Backfilled {count} activities (through ID {last_id}).")
click.echo(f"Complete: {count} activities backfilled; effort version {EFFORTS_VERSION}.")
+1 -1
View File
@@ -79,7 +79,7 @@ def latest_mapped_run(rows):
(
row
for row in rows
if row.sport == "running" and any(sample.get("point") for sample in row.samples)
if row.sport == "running" and row.has_route
),
None,
)
+3
View File
@@ -8,6 +8,7 @@ from garmin_sync.service import SyncError, complete, decode_fit
from .extensions import db
from .models import Activity
from .records import persist_efforts
def number(value, default=0):
@@ -110,6 +111,7 @@ def activity_from_fit(owner_id, source_key, documents, metadata=None, refresh=Fa
}
if existing:
existing.samples, existing.laps, existing.metrics = samples, laps, metrics
persist_efforts(existing)
return existing
row = Activity(
owner_id=owner_id,
@@ -125,6 +127,7 @@ def activity_from_fit(owner_id, source_key, documents, metadata=None, refresh=Fa
samples=samples,
laps=laps,
)
persist_efforts(row)
db.session.add(row)
return row