Import: Results
|
Related reading
|
1. Overview
Race results are imported from a CSV produced by the timing system (RaceDay Scoring). A single file typically contains rows for multiple EventCategory groups — the importer groups them by the Registration Event column and reconciles each group against the matching Race’s ResultSet. Results are upserted by seq (row order within a group), so re-importing the same file updates existing records in place rather than churning row IDs.
The HTTP surface is asynchronous from release 2.4: the upload returns HTTP 202 immediately and the caller polls a companion GET endpoint for the result DTO.
|
The previous single-race XLSX endpoint ( |
2. API
2.1. Endpoint Reference
| Endpoint | Purpose |
|---|---|
|
Upload a multi-category CSV and start the async import. Returns |
|
Retrieve the |
2.2. Async Delivery Mode
The per-entity endpoint is a thin facade over the generic async framework. Behind the scenes RaceResultRowProcessor runs in whole-file mode, delegating to ResultImportXLS.processBulkCsv(…) — the same bulk importer that produced the response DTO inline in the pre-async implementation. The resulting BulkResultImportResponseDTO is JSON-serialised to ImportJob.resultPayloadJson and echoed back verbatim by the GET endpoint. Response shape is byte-identical to what the prior synchronous PUT returned inline — only the delivery mode changed.
PUT /api/result-sets/import-bulk
?eventId={long} (required)
&participantIdMode={epid|regid|pid|xpid} (default: epid, or the event's preferred identifier)
&sourceSystemId={long} (optional — defaults to the event's source registration system)
&reconciliationScope={SINGLE_CATEGORY|FULL_EVENT} (default: SINGLE_CATEGORY)
&applyNumberChanges={true|false} (default: false)
&pointsCalculator={short-code|FQCN} (default: SA School)
Content-Type: multipart/form-data
Body: file=<csv>
HTTP/1.1 202 Accepted
Location: /api/result-sets/import/{jobId}
{ "identifier": "<jobId>", "status": "PROCESSING", "skipMappingPhases": true, ... }
# operator polls ...
GET /api/result-sets/import/{jobId}
HTTP/1.1 200 OK # once COMPLETED or FAILED
{ ... BulkResultImportResponseDTO ... }
HTTP/1.1 409 Conflict # while still processing
See Import Operations Runbook for the polling procedure, cancellation semantics, and failure recovery steps.
2.3. Request Body
Multipart form with a single file part containing the CSV. A single file may contain multiple concatenated per-category sections — each with its own header row and blank-line separator — and the importer groups rows by the Registration Event column.
Query parameters:
-
eventId(required) — target event; all groups in the file must resolve toEventCategoryrecords within this event -
participantIdMode(defaultepid, or the event’s preferred timing identifier when set) — how to interpretExternal Reference ID. The four modes resolve against different identifiers — see Participant Resolution Modes. Choosing wrongly can empty a category — see Mode-Mismatch Risk (resolved by US #771). -
sourceSystemId(optional) — the external source registration system whose ids the file carries. Required only forxpid; defaults to the event’s storedsourceRegistrationSystem. When supplied and different, it is persisted back onto the event (US #769) — see Source Registration System (US #769). -
reconciliationScope(defaultSINGLE_CATEGORY) — how much of the event the file is authoritative for, governing the delete pass — see Reconciliation Scope & Deletion (US #771). -
applyNumberChanges(defaultfalse) — whentrue,SIMPLEnumber changes (target bib is unassigned stock) are auto-applied;SWAPandCONFLICTare always reported but never auto-applied -
pointsCalculator(defaultSA School) — short code or FQCN; see Points Calculators
2.4. Response: BulkResultImportResponseDTO
{
"eventId": 117,
"fileLines": 615,
"summary": {
"dataRows": 592,
"imported": 591,
"skipped": 1,
"nonDataRows": {
"blankLines": 11,
"repeatedHeaders": 11,
"malformedRows": 0,
"totalNonData": 22
}
},
"categories": [
{"categoryName": "Sub Nipper Boys", "raceId": 4131, "resultSetId": 527,
"rowCount": 107, "importedCount": 107, "skippedCount": 0}
],
"unmatchedCategories": [],
"numberChanges": [...]
}
Invariants:
-
fileLines == 1 (header) + summary.nonDataRows.totalNonData + summary.dataRows -
summary.dataRows == summary.imported + summary.skipped
If the maths doesn’t add up, the parser bailed mid-file. Non-zero summary.skipped is the primary "look here" signal.
Bucket definitions:
| Bucket | What it counts |
|---|---|
|
Data rows successfully written to a ResultSet (created or updated by upsert-by-seq). |
|
Data rows that could not be processed: unresolved |
|
Empty separator lines, expected in concatenated exports. Skipped silently. |
|
Rows whose category column equals the literal header text |
|
Category names that did not resolve to an |
2.5. What to Look At
After polling returns the DTO, these are the patterns to confirm the import landed the way you expected:
-
First import of an event:
imported == dataRows,skipped = 0,unmatchedCategories = [] -
Re-import after a correction: same as first-import, plus
RaceResult.idvalues in the database are unchanged compared to before the re-import (upsert-by-seq preserves identity) -
Normal "number change noise": if the event’s
NumberTypehas noRaceNumberstock loaded, every row triggers aCONFLICTnumber-change report. This is operational data config, not an import error. -
summary.imported = 0: almost always aparticipantIdModemismatch — see Mode-Mismatch Risk (resolved by US #771). Stop and investigate before re-running.
See Import Operations Runbook for the full recovery procedures.
3. Column Definitions
The timing system (RaceDay Scoring) produces a CSV with one header row at the top, followed by rows for each finisher. The same export may concatenate per-category sheets — each sheet retains its own header row and a blank line between sections.
| Column | Required | Notes |
|---|---|---|
|
yes |
Identifier resolved per |
|
yes |
Maps to |
|
- |
Numeric finishing position. Non-numeric values ( |
|
- |
|
|
- |
Integer lap count, stored on |
|
- |
Triggers number-change detection against |
|
- |
Currently informational only (used for logs and the issues list). |
Header normalization strips spaces, slashes, dashes, underscores, and periods, then uppercases — so Num. Laps, NumLaps, NUM_LAPS all collapse to the same key.
4. Participant Resolution Modes
The External Reference ID column is resolved to an EventParticipant by one of four modes. The
default is epid, unless the event has a preferred timing identifier set, in which case that seeds
the default. The vocabulary (Person-XID vs Participant-XID) is defined in
Participant Identity.
| Mode | Resolves against | Use when |
|---|---|---|
|
|
The timing system was loaded with our EP ids (the normal new-system path). |
|
|
The file carries our Person ids. |
|
|
An external system identifies its own entry row, and that key was stored on the EP at import. |
|
The Person-XID via |
An external system feeds the timing system directly and the column holds its person id, not ours and not its entry id. Requires a source registration system (param or event default). |
4.1. Number-trust fallback ladder (US #770)
The Bib column is not a participant identifier of first resort. A re-numbered or mis-keyed
bib must never silently bind a result to the wrong athlete. Resolution therefore follows a ladder:
-
Resolve by the
External Reference IDper the mode above. If it resolves, use it — the bib is only validated for number-change detection, never used to pick the participant. -
Only when the
External Reference IDis absent for the row does the importer fall back to matching byBib→EventParticipant.number, then by identity (ID number) as a last resort.
Rows resolved via the fallback are flagged internally so the reconciliation pass can weigh them appropriately. The rule in one line: trust the XID; treat the number as corroboration, not identity.
4.2. Source Registration System (US #769)
xpid resolution needs to know which external system minted the person ids in the file, so the
Person-XID lookup is scoped correctly. That
source system is resolved as:
-
the
sourceSystemIdquery parameter, when supplied; otherwise -
the event’s stored
sourceRegistrationSystem(set when participants were imported — see Participant Identity).
When sourceSystemId is supplied and differs from the event’s stored value, the import persists
the new value onto the event. The admin-portal upload screen defaults the selector from the event,
hints that the default came from the participant import, and asks the operator to confirm a change
before it sticks. The other three modes (epid/pid/regid) do not need a source system.
5. Processing Flow
5.1. Multi-Pass Architecture
The bulk CSV import processes the file in sequential passes. Each pass reads state collected by the previous pass.
PASS 1 — Results Reconciliation
For each Registration Event group in CSV:
- Resolve EventCategory, Race, ResultSet
- Reconcile ResultSet (upsert-by-seq, delete untouched)
- Detect number changes: collect list of (EP, oldBib, newBib)
- Detect category changes (design-only): collect (EP, oldCat, newCat)
PASS 2 — Number Changes (if applyNumberChanges=true)
- Classify collected changes: SIMPLE / SWAP / CONFLICT
- Apply SIMPLE changes via RaceNumberAssignmentServiceEx
- Report SWAP / CONFLICT as DETECTED
PASS 3 — Category Changes (if applyCategoryChanges=true)
- NOT YET IMPLEMENTED (design captured in the journal)
- Would cascade via StartGroupParticipantServiceEx
Why the passes matter: collecting all changes before applying enables swap detection for numbers (EP_A and EP_B trading bibs 5 and 7). It also means the operator can review number-change reports from Pass 1 before deciding to re-run with applyNumberChanges=true.
See design-journal/2026-04/result-import-improvements.adoc for the full multi-pass design including the deferred Pass 3 (category changes).
5.2. Upsert-by-Seq
seq is the 1-based row index within each Registration Event group. On import:
-
Build a map of existing
RaceResultbyseqwithin the ResultSet. -
For each CSV row in the group: if a matching
seqexists, update in place; otherwise insert. -
After processing all rows for a group: delete any existing
RaceResultwhoseseqwas not touched (participant removed from the export). -
Legacy rows with
seq = NULL(created before this scheme) are deleted on first import after deploy.
This preserves RaceResult.id across re-imports, which keeps any external references stable. Verified in production 2026-04-20 on WPCA Road League events 120 and 121 (515/515 rr_id values preserved across a re-import).
5.3. Reconciliation Scope & Deletion (US #771)
Re-importing a category must remove finishers who are no longer in the file — an athlete moved to
the correct category, or withdrawn, must disappear from the old ResultSet, not linger. The
import is therefore a reconciliation (mirror), not a partial upsert. But the scope of that
mirror — what the delete pass is allowed to clear — is governed by reconciliationScope, because
a single-category file is not authoritative for the whole event:
| Scope | Delete pass |
|---|---|
|
Each |
|
The file is authoritative for the entire event. Every category’s |
In both scopes, a participant whose Registration Event differs from the prior import disappears
from the old ResultSet and reappears in the new one (the move case that motivated the feature).
Deletions are first-class in the response: removed rows are surfaced in the import book-end so the operator sees what was cleared rather than discovering it later.
5.3.1. The catastrophic-delete guard
The reconciliation delete pass is gated so a wholesale resolution failure cannot empty a category
(the 2026-04-20 incident class). The delete pass for a touched ResultSet is skipped when:
-
zero rows resolved for it (nothing was touched → nothing is authoritative → delete nothing), or
-
the per-category resolution failure rate exceeds the configured threshold (
import.result.reconciliation-guard-threshold, default0.5).
A run that trips the guard imports the rows it could resolve but leaves existing results in place, rather than silently deleting them. See Mode-Mismatch Risk (resolved by US #771).
5.4. Number Change Detection
For each row with a Bib value, the imported bib is compared against EventParticipant.number. Mismatches are collected, then classified:
| Type | Meaning |
|---|---|
|
Target bib’s |
|
Two participants each hold the other’s new bib. Detected and reported with |
|
Target bib is held by another person and there is no matching reverse change, OR the bib does not exist as a |
applyNumberChanges=true applies SIMPLE changes via RaceNumberAssignmentServiceEx.addNumberNoLaterThan(…).
6. Points Calculators
The PointsCalculatorFactory resolves the pointsCalculator query-parameter value to a concrete calculator. Both short codes and fully-qualified class names are accepted.
| Short code | Class | Scheme |
|---|---|---|
(default, no param) |
|
SA School Cycling Series — sliding scale down to ~position 67 |
|
|
WPCA Road League — top 12: 50/45/40/36/34/32/30/28/26/24/22/20; position 13+: 2 points (participation); non-finishers: 0 |
Calculators are invoked once per RaceResult after position is parsed. Non-finishers (position = NULL) receive 0 points regardless of the calculator’s position→points function, enforced by a regression test in ResultImportXLSIT.
7. Source Code
| File | Purpose |
|---|---|
|
Bulk CSV importer. |
|
Whole-file bridge — parses |
|
REST endpoints |
|
Response payload, including |
|
Find-or-create ResultSet for a race. |
|
Number assignment with duplicate detection used by |
|
Pluggable points calculation; SA School Cycling and WPCA Road League implementations available. |
Location: admin-service/src/main/java/za/co/idealogic/event/admin/
8. Outstanding Items
Tracked in design-journal/2026-04/result-import-improvements.adoc (see Outstanding Issues table):
-
#2: DNF/DNS/Drop status handling — non-numeric
Placevalues currently becomeposition = NULL. Partial admin-service-only subset shipped (ADO-457); schema changes deferred (ADO-458). -
#3: Category change detection & apply (Pass 3) — designed in the journal, not implemented.
-
#7: Surface unresolved EPs in an
issueslist on the response. ADO US #456. -
#9: This document kept in sync with the latest implementation state.
-
#10: Resolved (US #771). Mode-mismatch catastrophic delete guard shipped — see Mode-Mismatch Risk (resolved by US #771) and Reconciliation Scope & Deletion (US #771).
8.1. Mode-Mismatch Risk (resolved by US #771)
The original failure mode: when every CSV row failed participant resolution, no seq was touched
and the reconciliation delete pass removed every pre-existing row as "absent from the file" — a
silent catastrophic delete indistinguishable in the response from a file with zero matching
participants. Surfaced in production on 2026-04-20 (event 119 wiped by an epid vs regid operator
error).
This is now guarded (see Reconciliation Scope & Deletion (US #771)): the delete pass is skipped for any category where
zero rows resolved or the resolution failure rate exceeds
import.result.reconciliation-guard-threshold (default 0.5). A mode-mismatched file therefore
imports nothing and deletes nothing, instead of emptying the category. Deletions that do occur
are surfaced in the import book-end.
The guard is a safety net, not a licence to skip the pre-flight checklist in Import Operations Guide — a file that resolves to the wrong (but valid) participants still imports.