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
+32 -4
View File
@@ -1,6 +1,6 @@
# Running personal records
The dashboard and owner's run page now calculate estimated best efforts for
Activity import calculates and persists estimated best efforts for
1 km, 2 km, 5 km, 10 km, half marathon (21,097.5 m), and marathon (42,195 m).
These are fastest stretches anywhere within a run, rather than lap times or
times projected from average pace. All existing running activities participate.
@@ -22,8 +22,36 @@ These are estimates from watch data, not verified race records or an exact clone
of Strava's proprietary calculation. GPS/distance spikes or sparse recording can
affect results. There is currently no manual effort exclusion or correction UI.
The initial implementation computes on page requests without a database migration.
For large histories, the next step is to persist per-run efforts at import time
with an algorithm version, backfill existing runs, and query those small results.
Imports (FIT upload, Garmin sync and local cache) persist per-run efforts in the
same transaction as the run. Refreshing recorded data recalculates the efforts.
`efforts_version` identifies the calculation version; version 0 means pending.
A current version with an empty list means calculated with no eligible distances.
Page requests only compare stored efforts; there is no sample-based fallback.
Raw samples and laps are deferred in list queries. Route availability is also
persisted, so the dashboard can choose its map without loading historical samples.
Chronological PR comparisons still happen on page requests, using at most six
stored efforts per run, preserving correct badges after backfills or removals.
After `flask --app app db upgrade`, run:
```sh
flask --app app backfill-efforts --batch-size 25
```
The command commits each bounded batch and skips current-version rows on reruns.
It includes non-running and incomplete activities, storing an empty result when
appropriate. `--owner-id ID` limits the operation to one athlete; `--force`
recalculates current results. It uses row locks on PostgreSQL to coordinate with
concurrent refreshes, and fixes an upper activity ID at the start so new imports
do not extend the backfill indefinitely. New imports calculate their own results.
No FIT files need to be downloaded again. On Dokku:
```sh
dokku run stride flask --app app backfill-efforts --batch-size 25
```
Until backfill finishes, pending activities do not contribute to records or
route selection. Keep backfills separate from schema migrations so a large
history does not delay every deployment.
Further additions could include second/third-best medals, annual records, manual
exclusion of bad recordings, and a selected-effort highlight on the route map.
@@ -0,0 +1,29 @@
"""Persist versioned running best efforts and route availability."""
import sqlalchemy as sa
from alembic import op
revision = "e57a0f824c61"
down_revision = "d81b97ae5401"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("activity") as batch_op:
batch_op.add_column(
sa.Column("best_efforts", sa.JSON(), nullable=False, server_default="[]")
)
batch_op.add_column(
sa.Column("efforts_version", sa.Integer(), nullable=False, server_default="0")
)
batch_op.add_column(
sa.Column("has_route", sa.Boolean(), nullable=False, server_default=sa.false())
)
def downgrade():
with op.batch_alter_table("activity") as batch_op:
batch_op.drop_column("has_route")
batch_op.drop_column("efforts_version")
batch_op.drop_column("best_efforts")
+179 -5
View File
@@ -6,6 +6,7 @@ import pytest
from app import create_app
from app.extensions import db
from app.models import Activity, Friendship, User
from app.records import EFFORTS_VERSION, persist_efforts
from app.services import activity_data, import_cache
@@ -34,6 +35,7 @@ def app(tmp_path):
owner_id=1,
source_key="test:1",
title="A quiet morning run",
has_route=True,
sport="running",
started_at=datetime(2026, 9, 30, tzinfo=UTC),
distance_m=5000,
@@ -90,6 +92,7 @@ def test_personal_records_render_and_remain_owner_only(app):
with app.app_context():
row = db.session.get(Activity, 1)
row.samples = [{"distance": 0, "time": 0}, {"distance": 5000, "time": 1500}]
persist_efforts(row)
row.share_token = "record-test"
db.session.commit()
client = app.test_client()
@@ -106,25 +109,196 @@ def test_personal_records_render_and_remain_owner_only(app):
assert b"5 km PR" not in client.get("/dashboard").data
def test_import_and_refresh_persist_efforts_in_the_run_transaction(app):
from app.services import activity_from_fit
doc = {
"session": [{"sport": "running", "start_time": "2026-09-30T10:00:00Z"}],
"record": [
{"timestamp": "2026-09-30T10:00:00Z", "distance": 0},
{"timestamp": "2026-09-30T10:05:00Z", "distance": 1000},
],
}
with app.app_context():
row = activity_from_fit(1, "test:new", [doc])
db.session.commit()
row_id = row.id
db.session.remove()
row = db.session.get(Activity, row_id)
assert row.efforts_version == EFFORTS_VERSION
assert row.best_efforts[0]["seconds"] == 300
assert activity_from_fit(1, "test:new", [doc]) is None
doc["record"][1]["timestamp"] = "2026-09-30T10:04:00Z"
activity_from_fit(1, "test:new", [doc], refresh=True)
db.session.commit()
assert row.best_efforts[0]["seconds"] == 240
doc["record"] = []
activity_from_fit(1, "test:new", [doc], refresh=True)
db.session.commit()
assert row.best_efforts == []
assert row.efforts_version == EFFORTS_VERSION
def test_backfill_is_resumable_and_dashboard_uses_only_stored_efforts(app, monkeypatch):
from sqlalchemy import event
from app import records
with app.app_context():
row = db.session.get(Activity, 1)
row.samples = [
{"distance": 0, "time": 0, "point": [-33, 151]},
{"distance": 5000, "time": 1500},
]
db.session.add(
Activity(
owner_id=1,
source_key="old:2",
title="Old ride",
sport="cycling",
started_at=row.started_at,
samples=[],
)
)
db.session.commit()
runner = app.test_cli_runner()
result = runner.invoke(args=["backfill-efforts", "--batch-size", "1"])
assert result.exit_code == 0, result.output
assert "2 activities backfilled" in result.output
result = runner.invoke(args=["backfill-efforts"])
assert result.exit_code == 0
assert "0 activities backfilled" in result.output
with app.app_context():
assert db.session.get(Activity, 2).best_efforts == []
assert db.session.get(Activity, 2).efforts_version == EFFORTS_VERSION
statements = []
def capture(_conn, _cursor, statement, _params, _context, _many):
statements.append(statement)
event.listen(db.engine, "before_cursor_execute", capture)
engine = db.engine
def fail_calculation(_row):
raise AssertionError("Page requests must never calculate from raw samples")
monkeypatch.setattr(records, "best_efforts", fail_calculation)
try:
client = app.test_client()
login(client)
assert b"5 km PR" in client.get("/dashboard").data
assert all("activity.samples" not in s and "activity.laps" not in s for s in statements)
assert b"Best efforts on this run" in client.get("/activities/1").data
finally:
event.remove(engine, "before_cursor_execute", capture)
def test_backfill_keeps_completed_batches_after_failure(app, monkeypatch):
from app import records
with app.app_context():
db.session.add(
Activity(
owner_id=2,
source_key="old:2",
title="Second athlete",
sport="running",
started_at=datetime(2026, 1, 1, tzinfo=UTC),
samples=[],
)
)
db.session.commit()
original = records.persist_efforts
def fail_second(row):
if row.id == 2:
raise ValueError("Interrupted batch")
original(row)
monkeypatch.setattr(records, "persist_efforts", fail_second)
runner = app.test_cli_runner()
result = runner.invoke(args=["backfill-efforts", "--batch-size", "1"])
assert result.exit_code != 0
with app.app_context():
assert db.session.get(Activity, 1).efforts_version == EFFORTS_VERSION
assert db.session.get(Activity, 2).efforts_version == 0
monkeypatch.setattr(records, "persist_efforts", original)
result = runner.invoke(args=["backfill-efforts", "--owner-id", "1"])
assert result.exit_code == 0
assert "0 activities backfilled" in result.output
result = runner.invoke(args=["backfill-efforts", "--batch-size", "1"])
assert result.exit_code == 0
assert "1 activities backfilled" in result.output
result = runner.invoke(args=["backfill-efforts", "--force", "--owner-id", "1"])
assert result.exit_code == 0
assert "1 activities backfilled" in result.output
def test_efforts_migration_upgrades_existing_schema(tmp_path):
from sqlalchemy import text
application = create_app(
{
"TESTING": True,
"SECRET_KEY": "test",
"SQLALCHEMY_DATABASE_URI": f"sqlite:///{tmp_path / 'migration.db'}",
"RATELIMIT_ENABLED": False,
}
)
runner = application.test_cli_runner()
result = runner.invoke(args=["db", "upgrade", "d81b97ae5401"])
assert result.exit_code == 0, result.output
with application.app_context():
db.session.execute(
text(
"INSERT INTO user (id, username, email, display_name, password_hash, timezone) "
"VALUES (1, 'test', 'test@example.test', 'Test', 'test', 'UTC')"
)
)
db.session.execute(
text(
"INSERT INTO activity (owner_id, source_key, title, sport, started_at, "
"distance_m, duration_s, elevation_m, visibility, samples, laps) "
"VALUES (1, 'old', 'Old run', 'running', '2026-01-01', "
"1000, 300, 0, 'private', '[]', '[]')"
)
)
db.session.commit()
result = runner.invoke(args=["db", "upgrade"])
assert result.exit_code == 0, result.output
with application.app_context():
row = db.session.get(Activity, 1)
assert row.best_efforts == []
assert row.efforts_version == 0
assert row.has_route is False
assert row.title == "Old run"
result = runner.invoke(args=["backfill-efforts"])
assert result.exit_code == 0, result.output
with application.app_context():
assert db.session.get(Activity, 1).efforts_version == EFFORTS_VERSION
def test_dashboard_replays_latest_accessible_gps_run(app):
client = app.test_client()
login(client)
page = client.get("/dashboard").data
assert b'id="dashboard-route-map" data-url="/activities/1/data"' in page
assert b'id="toggle-route-replay"' not in page
assert b'vendor/leaflet.js' in page
assert b"vendor/leaflet.js" in page
assert b'class="dashboard-map"' not in page
client.post("/logout")
login(client, "other")
page = client.get("/dashboard").data
assert b'No GPS running route available yet.' in page
assert b'/activities/1/data' not in page
assert b"No GPS running route available yet." in page
assert b"/activities/1/data" not in page
with app.app_context():
db.session.get(Activity, 1).samples = []
row = db.session.get(Activity, 1)
row.samples = []
persist_efforts(row)
db.session.commit()
client.post("/logout")
login(client)
assert b'No GPS running route available yet.' in client.get("/dashboard").data
assert b"No GPS running route available yet." in client.get("/dashboard").data
def test_friend_access_requires_acceptance_and_revokes_immediately(app):
+4 -2
View File
@@ -3,16 +3,18 @@ from types import SimpleNamespace
import pytest
from app.records import best_efforts, record_book
from app.records import best_efforts, persist_efforts, record_book
def run(points, id=1, sport="running"):
return SimpleNamespace(
activity = SimpleNamespace(
id=id,
sport=sport,
started_at=datetime(2026, 1, 1, tzinfo=UTC) + timedelta(days=id),
samples=[{"distance": d, "time": t} for d, t in points],
)
persist_efforts(activity)
return activity
def test_fastest_window_can_start_between_samples():