Activity ingestion
This page describes how a file or a provider sync becomes stored activities. The code lives in backend/app/modules/activities/:
activity_ingestion/orchestrates the entry points and the shared pipeline.activity_file_import/holds the format parsers (.gpx,.tcx,.fit,.gz).activity/is the activities core. It never imports a parser or a provider.
Two import-linter contracts enforce that split: activities-parsing-boundary and ingestion-pipeline-provider-agnostic.
One pipeline, three entry points
Upload, provider sync (Garmin Connect, Strava) and bulk import differ only in how the file arrives and what happens on failure. They all call the same pipeline.
flowchart LR
U[Upload] --> V
P[Provider sync] --> V
B[Bulk import batch] --> V
V[Validate and decompress] --> PA[Parse]
PA --> E[Enrich]
E --> R{Source rule}
R -- skip --> X[Not imported]
R -- import --> S[Store and dedup]
S --> RET[Retain source file]
On failure the entry points differ: an upload cleans up and re-raises, a provider sync logs and returns nothing, and a bulk import counts the file as failed and keeps its bytes.
What happens to a parsed activity
Parsers are pure functions. They return a canonical ParsedFile and know nothing about the database, privacy settings, gear or providers. The pipeline then applies these steps to each activity in the file:
- Enrich: re-attach the owner's privacy defaults, gear and provider ids (
enrichment_service). - Source rules (bulk imports only): the source's
should_importhook decides whether to keep an activity, for example to skip duplicate listings in a Strava export. Thenapply_metadataapplies the manifest's name, description, type and gear. -
Deduplicate:
store_parsed_activityderives a key, in this order of precedence, and skips the activity if the owner already has it.Source Key Strava activity id strava:<id>Garmin activity id garmin:<id>:<start time>(a multi-activity.fitshares one Garmin id)File content file:<sha256>:<start time>None of these Falls back to start-time matching: the activity is stored but hidden, with a notification A unique index on
(user_id, dedup_key)settles concurrent imports of the same activity. The loser rolls back and returns the winner. 4. Persist components: the activity row and its children (laps, sets, streams, workout steps and so on) are staged through contributors registered incontributor_registry. A parsed component with no registered contributor fails the import instead of being dropped silently. 5. Publishactivity.created: staged in the same transaction as the rows. The pipeline commits once per activity, so a later failure does not undo earlier activities.
sequenceDiagram
participant Pipe as Pipeline
participant Core as Activities core
participant DB as Database
participant Bus as Event system
Pipe->>Core: store_parsed_activity(parsed)
Core->>DB: look up dedup key
alt already stored
Core-->>Pipe: existing activity, nothing staged
else new
Core->>DB: stage activity and components
Core->>Bus: stage activity.created
Pipe->>DB: commit
Bus-->>Bus: deliver after commit
end
Derived work
Nothing in the ingestion path knows what consumes activity.created. Independent subscribers react to it:
- map thumbnail generation (
activity_thumbnail) - reverse geocoding (
activity_geocoding) - notifications (
notifications)
Every subscriber also has a scheduled backfill that repairs missed events. How delivery works, locally and distributed, is covered in Scaling and background jobs.
Adding to the pipeline
- A new file format: register a parser for its extension in
activity_file_import/parser_registry.py. Return the canonicalParsedFile, with no database or provider coupling. - A new component stored with each activity: register an activity-ingestion contributor in
contributor_registry. File-scoped reference data uses a file-ingestion contributor. - A new provider export with its own rules: subclass
BulkImportSourceand overridemetadata_for,should_importandapply_metadata, as the Strava export does. Do not branch on the provider inside the pipeline.