Import: Results

Related reading

  • Result Import Requirements — the "what" (functional requirements, acceptance criteria)

  • Import Operations Guide — the "run it" (operator runbook, query-param choices, recovery)

  • This document — the "how" (API surface, data dictionary, processing flow, open items)

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 (PUT /api/result-sets/import backed by ResultImportXLS.process) was deleted in the 2026-04 async cutover. Soft-deprecated on develop, it never gained the operational improvements (number-change detection, DNF classification, reconciliation summary, issues list) and had no production consumers beyond ad-hoc Postman. Callers that still need single-race imports can use the bulk CSV endpoint with a one-race file. The ResultImportXLS.process(…​) and readStream(…​) service methods remain on the class as @Deprecated and are not exposed over HTTP.

2. API

2.1. Endpoint Reference

Endpoint Purpose

PUT /api/result-sets/import-bulk

Upload a multi-category CSV and start the async import. Returns HTTP 202 Accepted with an ImportJobDTO — the job is queued on the generic async framework.

GET /api/result-sets/import/{jobId}

Retrieve the BulkResultImportResponseDTO once the job reaches COMPLETED or FAILED. Returns HTTP 409 while still processing; HTTP 404 for unknown ids or wrong importType.

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 to EventCategory records within this event

  • participantIdMode (default epid, or the event’s preferred timing identifier when set) — how to interpret External 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 for xpid; defaults to the event’s stored sourceRegistrationSystem. When supplied and different, it is persisted back onto the event (US #769) — see Source Registration System (US #769).

  • reconciliationScope (default SINGLE_CATEGORY) — how much of the event the file is authoritative for, governing the delete pass — see Reconciliation Scope & Deletion (US #771).

  • applyNumberChanges (default false) — when true, SIMPLE number changes (target bib is unassigned stock) are auto-applied; SWAP and CONFLICT are always reported but never auto-applied

  • pointsCalculator (default SA 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

summary.imported

Data rows successfully written to a ResultSet (created or updated by upsert-by-seq).

summary.skipped

Data rows that could not be processed: unresolved External Reference ID, mapping failures, save errors. Detail is surfaced in the issues list (in progress).

summary.nonDataRows.blankLines

Empty separator lines, expected in concatenated exports. Skipped silently.

summary.nonDataRows.repeatedHeaders

Rows whose category column equals the literal header text "Registration Event". The timing system emits one per concatenated section. Skipped silently.

unmatchedCategories

Category names that did not resolve to an EventCategory for the event. Repeated header rows are not surfaced here — they go to nonDataRows.repeatedHeaders.

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.id values in the database are unchanged compared to before the re-import (upsert-by-seq preserves identity)

  • Normal "number change noise": if the event’s NumberType has no RaceNumber stock loaded, every row triggers a CONFLICT number-change report. This is operational data config, not an import error.

  • summary.imported = 0: almost always a participantIdMode mismatch — 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

External Reference ID

yes

Identifier resolved per participantIdMode — our EP id (epid), our Person id (pid), the source system’s entry key / Participant-XID (regid), or the source system’s person key / Person-XID (xpid). See Participant Resolution Modes.

Registration Event

yes

Maps to EventCategory.name by exact case-sensitive match.

Place

-

Numeric finishing position. Non-numeric values (Drop, DNF, DNS, DQ) currently land as position = NULL — DNF status handling is in progress (see Outstanding Items).

Total Time

-

HH:mm:ss.SS — stored as duration_milli_second.

Num. Laps

-

Integer lap count, stored on RaceResult.laps.

Bib

-

Triggers number-change detection against EventParticipant.number. Aliases: Bib, BibNumber, RaceNumber, Number.

First Name, Last Name, School

-

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

epid

EventParticipant.id (ours)

The timing system was loaded with our EP ids (the normal new-system path).

pid

Person/User.id (ours) → that person’s EP in the event

The file carries our Person ids.

regid

EventParticipant.registrationId — the Participant-XID

An external system identifies its own entry row, and that key was stored on the EP at import.

xpid

The Person-XID via PersonExternalReference → person → that event’s EP, scoped by sourceSystemId

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:

  1. Resolve by the External Reference ID per the mode above. If it resolves, use it — the bib is only validated for number-change detection, never used to pick the participant.

  2. Only when the External Reference ID is absent for the row does the importer fall back to matching by BibEventParticipant.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:

  1. the sourceSystemId query parameter, when supplied; otherwise

  2. 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:

  1. Build a map of existing RaceResult by seq within the ResultSet.

  2. For each CSV row in the group: if a matching seq exists, update in place; otherwise insert.

  3. After processing all rows for a group: delete any existing RaceResult whose seq was not touched (participant removed from the export).

  4. 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

SINGLE_CATEGORY (default)

Each ResultSet that the file touched (had at least one successfully-resolved row for) is reconciled against the file: untouched seq rows in those sets are deleted. ResultSets the file never mentions are left untouched. This is the safe default for loading one category’s results.

FULL_EVENT

The file is authoritative for the entire event. Every category’s ResultSet is reconciled; a finisher absent from the file is removed wherever they were. Use only when uploading a complete event export.

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, default 0.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

SIMPLE

Target bib’s RaceNumber.person is NULL — straightforward reassignment. Applied automatically when applyNumberChanges=true.

SWAP

Two participants each hold the other’s new bib. Detected and reported with swapWithParticipantId, but not auto-applied.

CONFLICT

Target bib is held by another person and there is no matching reverse change, OR the bib does not exist as a RaceNumber for the event’s NumberType. Reported only.

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)

SASchoolCyclingSeriesPointsCalculator

SA School Cycling Series — sliding scale down to ~position 67

wpca-road-league

WpcaRoadLeaguePointsCalculator

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

ResultImportXLS.java

Bulk CSV importer. processBulkCsv() is the canonical path; the older readStream() / process() XLSX helpers remain @Deprecated and are no longer reachable from HTTP.

RaceResultRowProcessor.java

Whole-file bridge — parses ImportJob.configJson (participantIdMode, pointsCalculator, applyNumberChanges), resolves the PointsCalculator via PointsCalculatorFactory, delegates to ResultImportXLS.processBulkCsv, writes counters back onto the ImportJob. See Async Import Architecture — Whole-File Bridge.

ResultSetResourceEx.java

REST endpoints PUT /import-bulk (async upload) and GET /import/{jobId} (result retrieval). Thin facade over ImportJobService.createWholeFileImportJob.

BulkResultImportResponseDTO.java

Response payload, including Summary, NonDataRows, CategoryResult, NumberChangeDTO.

ResultSetQueryService.createOrReplace(…​)

Find-or-create ResultSet for a race.

RaceNumberAssignmentServiceEx.addNumberNoLaterThan(…​)

Number assignment with duplicate detection used by applyNumberChanges.

PointsCalculator + factory

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 Place values currently become position = 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 issues list 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.