Spaces:
Sleeping
Sleeping
Fix participant-file truncation: serialize + never-shrink saves, blocking final save
Browse files
app.py
CHANGED
|
@@ -471,20 +471,57 @@ def _row_to_trial(row: pd.Series) -> dict:
|
|
| 471 |
}
|
| 472 |
|
| 473 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
def _save_results(participant_file: str, results: list[dict]) -> None:
|
| 475 |
if not HF_TOKEN or not results:
|
| 476 |
return
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
|
| 489 |
|
| 490 |
# ---------------------------------------------------------------------------
|
|
@@ -548,6 +585,9 @@ def start_session(access_code: str):
|
|
| 548 |
|
| 549 |
participant_file = _participant_filename(code)
|
| 550 |
prior = _load_participant_results(participant_file)
|
|
|
|
|
|
|
|
|
|
| 551 |
|
| 552 |
if _is_complete(prior):
|
| 553 |
msg = DONE_ALREADY_HTML_TMPL.format(
|
|
@@ -649,14 +689,21 @@ def _make_choice(state: dict, side: str):
|
|
| 649 |
}
|
| 650 |
)
|
| 651 |
|
| 652 |
-
threading.Thread(
|
| 653 |
-
target=_save_results,
|
| 654 |
-
args=(state["participant_file"], list(state["results"])),
|
| 655 |
-
daemon=True,
|
| 656 |
-
).start()
|
| 657 |
-
|
| 658 |
state["current_idx"] += 1
|
| 659 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 660 |
total = state["total_trials"]
|
| 661 |
return (
|
| 662 |
state,
|
|
|
|
| 471 |
}
|
| 472 |
|
| 473 |
|
| 474 |
+
# Per-participant save coordination. Uploads for a given participant file are
|
| 475 |
+
# serialized through one lock, and we never overwrite a larger file with a
|
| 476 |
+
# smaller (stale) snapshot. This prevents the out-of-order/last-writer-wins race
|
| 477 |
+
# that previously truncated participant files when clicks were saved from
|
| 478 |
+
# unsynchronized background threads.
|
| 479 |
+
_SAVE_REGISTRY_LOCK = threading.Lock()
|
| 480 |
+
_SAVE_ENTRIES: dict[str, dict] = {}
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
def _save_entry(participant_file: str) -> dict:
|
| 484 |
+
with _SAVE_REGISTRY_LOCK:
|
| 485 |
+
entry = _SAVE_ENTRIES.get(participant_file)
|
| 486 |
+
if entry is None:
|
| 487 |
+
entry = {"lock": threading.Lock(), "saved_count": 0}
|
| 488 |
+
_SAVE_ENTRIES[participant_file] = entry
|
| 489 |
+
return entry
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def _reset_save_baseline(participant_file: str, count: int) -> None:
|
| 493 |
+
"""Align the never-shrink guard with what's actually on HF at session start."""
|
| 494 |
+
entry = _save_entry(participant_file)
|
| 495 |
+
with entry["lock"]:
|
| 496 |
+
entry["saved_count"] = count
|
| 497 |
+
|
| 498 |
+
|
| 499 |
def _save_results(participant_file: str, results: list[dict]) -> None:
|
| 500 |
if not HF_TOKEN or not results:
|
| 501 |
return
|
| 502 |
+
snapshot = list(results)
|
| 503 |
+
entry = _save_entry(participant_file)
|
| 504 |
+
# Serialize all uploads for this participant so they can't race each other.
|
| 505 |
+
with entry["lock"]:
|
| 506 |
+
# Never replace a more-complete file with a stale/smaller snapshot.
|
| 507 |
+
if len(snapshot) <= entry["saved_count"]:
|
| 508 |
+
return
|
| 509 |
+
frame = pd.DataFrame(snapshot, columns=RESULTS_COLUMNS)
|
| 510 |
+
buf = io.BytesIO()
|
| 511 |
+
frame.to_csv(buf, index=False)
|
| 512 |
+
buf.seek(0)
|
| 513 |
+
try:
|
| 514 |
+
api.upload_file(
|
| 515 |
+
path_or_fileobj=buf,
|
| 516 |
+
path_in_repo=participant_file,
|
| 517 |
+
repo_id=RESULTS_REPO,
|
| 518 |
+
repo_type="dataset",
|
| 519 |
+
commit_message=f"Update {participant_file} (n={len(snapshot)})",
|
| 520 |
+
)
|
| 521 |
+
except Exception as exc: # noqa: BLE001
|
| 522 |
+
print(f"[save] WARNING: upload failed for {participant_file} ({exc}).")
|
| 523 |
+
return
|
| 524 |
+
entry["saved_count"] = len(snapshot)
|
| 525 |
|
| 526 |
|
| 527 |
# ---------------------------------------------------------------------------
|
|
|
|
| 585 |
|
| 586 |
participant_file = _participant_filename(code)
|
| 587 |
prior = _load_participant_results(participant_file)
|
| 588 |
+
# Baseline the never-shrink save guard to the file that's actually on HF,
|
| 589 |
+
# so a returning participant's saves grow from their real prior progress.
|
| 590 |
+
_reset_save_baseline(participant_file, len(prior))
|
| 591 |
|
| 592 |
if _is_complete(prior):
|
| 593 |
msg = DONE_ALREADY_HTML_TMPL.format(
|
|
|
|
| 689 |
}
|
| 690 |
)
|
| 691 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 692 |
state["current_idx"] += 1
|
| 693 |
+
is_done = state["current_idx"] >= len(state["trials"])
|
| 694 |
+
|
| 695 |
+
if is_done:
|
| 696 |
+
# Final trial: save synchronously so completion is guaranteed persisted
|
| 697 |
+
# (all trials written) before we show the "done" panel.
|
| 698 |
+
_save_results(state["participant_file"], list(state["results"]))
|
| 699 |
+
else:
|
| 700 |
+
threading.Thread(
|
| 701 |
+
target=_save_results,
|
| 702 |
+
args=(state["participant_file"], list(state["results"])),
|
| 703 |
+
daemon=True,
|
| 704 |
+
).start()
|
| 705 |
+
|
| 706 |
+
if is_done:
|
| 707 |
total = state["total_trials"]
|
| 708 |
return (
|
| 709 |
state,
|