pmadinei commited on
Commit
d9f7d07
·
verified ·
1 Parent(s): 0e84875

Fix participant-file truncation: serialize + never-shrink saves, blocking final save

Browse files
Files changed (1) hide show
  1. app.py +65 -18
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
- frame = pd.DataFrame(results, columns=RESULTS_COLUMNS)
478
- buf = io.BytesIO()
479
- frame.to_csv(buf, index=False)
480
- buf.seek(0)
481
- api.upload_file(
482
- path_or_fileobj=buf,
483
- path_in_repo=participant_file,
484
- repo_id=RESULTS_REPO,
485
- repo_type="dataset",
486
- commit_message=f"Update {participant_file} (n={len(results)})",
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
- if state["current_idx"] >= len(state["trials"]):
 
 
 
 
 
 
 
 
 
 
 
 
 
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,