test_p3_concurrency.py 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. """P3 concurrency test: atomic task claim under parallel executor instances.
  2. Verifies that when N executors race to claim the same pending task, exactly
  3. one wins; the rest get ValueError (already claimed) or a transient
  4. SQLite lock error - never a duplicate successful claim (which would cause
  5. double simulation of the same points).
  6. Run: python scripts/test_p3_concurrency.py (exit 0 = PASS)
  7. Isolated temp SQLite DB; no real Motor-CAD involved.
  8. """
  9. import os
  10. import sys
  11. import tempfile
  12. import threading
  13. from concurrent.futures import ThreadPoolExecutor
  14. _TMP = os.path.join(tempfile.mkdtemp(), "conc.db")
  15. os.environ["AFM_DB_PATH"] = _TMP
  16. os.environ["KIMI_API_KEY"] = ""
  17. sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "web", "backend"))
  18. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
  19. from app.database import init_db # noqa: E402
  20. init_db()
  21. from app.services.task_manager import get_task_manager # noqa: E402
  22. tm = get_task_manager()
  23. def make_pending_task():
  24. t = tm.create_task(
  25. plan_id=None, plan_data={},
  26. parameters=[{"airgap_mm": 1.0, "point_id": 1}],
  27. task_name="race-task", task_type="scan",
  28. )
  29. return t["task_id"]
  30. # ------------------------------------------------------------------ 1. manager-level race
  31. N = 8
  32. tid = make_pending_task()
  33. success = []
  34. failures = []
  35. lock = threading.Lock()
  36. def claim():
  37. try:
  38. tm.dispatch_task(tid)
  39. with lock:
  40. success.append(1)
  41. except Exception as exc: # ValueError or transient lock error
  42. with lock:
  43. failures.append(type(exc).__name__)
  44. with ThreadPoolExecutor(max_workers=N) as pool:
  45. list(pool.map(lambda _: claim(), range(N)))
  46. assert len(success) == 1, "exactly one claim must win, got %d" % len(success)
  47. assert len(failures) == N - 1, "the rest must fail, got %d failures" % len(failures)
  48. print("[1] manager-level race: 1 win / %d lost (%s)" % (len(failures), set(failures)))
  49. # final state must be dispatched
  50. task = tm.get_task(tid)
  51. assert task["status"] == "dispatched", task
  52. print("[2] final status = dispatched OK")
  53. # a sequential second claim must be rejected
  54. try:
  55. tm.dispatch_task(tid)
  56. raise SystemExit("second claim should fail")
  57. except ValueError:
  58. print("[3] sequential second claim rejected OK")
  59. # ------------------------------------------------------------------ 2. HTTP-level race
  60. from app.database import SessionLocal # noqa: E402
  61. from app.models.task import Task # noqa: E402
  62. # create a second pending task directly for the HTTP race
  63. with SessionLocal() as db:
  64. task = Task(
  65. task_id="http-race-1",
  66. task_name="http-race", plan_id=None,
  67. status="pending", priority=5, created_by="test",
  68. )
  69. db.add(task)
  70. db.commit()
  71. http_success = []
  72. def http_claim():
  73. try:
  74. tm.dispatch_task("http-race-1")
  75. with lock:
  76. http_success.append(1)
  77. except Exception:
  78. pass
  79. with ThreadPoolExecutor(max_workers=N) as pool:
  80. list(pool.map(lambda _: http_claim(), range(N)))
  81. assert len(http_success) == 1, "HTTP-level exactly-one claim, got %d" % len(http_success)
  82. print("[4] HTTP-level race: exactly 1 claim won OK")
  83. print("\nALL P3 CONCURRENCY TESTS PASSED")