|
1 | 1 | # Copyright Contributors to the Packit project. |
2 | 2 | # SPDX-License-Identifier: MIT |
3 | 3 |
|
| 4 | +import threading |
| 5 | + |
4 | 6 | import pytest |
5 | 7 | from celery.canvas import Signature |
6 | 8 | from copr.v3 import BuildChrootProxy, BuildProxy, Client |
|
21 | 23 | BuildStatus, |
22 | 24 | CoprBuildGroupModel, |
23 | 25 | CoprBuildTargetModel, |
| 26 | + PipelineModel, |
24 | 27 | ProjectEventModel, |
| 28 | + ProjectEventModelType, |
| 29 | + PullRequestModel, |
25 | 30 | SRPMBuildModel, |
| 31 | + TFTTestRunGroupModel, |
| 32 | + TFTTestRunTargetModel, |
| 33 | + sa_session_transaction, |
26 | 34 | ) |
| 35 | +from packit_service.worker.handlers import TestingFarmHandler |
27 | 36 | from packit_service.worker.helpers.build import babysit |
28 | 37 | from packit_service.worker.helpers.build.babysit import check_copr_build |
29 | 38 | from packit_service.worker.monitoring import Pushgateway |
@@ -260,3 +269,213 @@ def celery_run_async_stub(signatures, handlers) -> None: |
260 | 269 | build = CoprBuildTargetModel.get_by_id(packit_build_752.id) |
261 | 270 | assert build is not None |
262 | 271 | assert build.status == BuildStatus.success |
| 272 | + |
| 273 | + |
| 274 | +def test_testing_farm_race_condition_concurrent_identifiers( |
| 275 | + clean_before_and_after, |
| 276 | +): |
| 277 | + """ |
| 278 | + Test that two concurrent testing_farm tasks with different identifiers |
| 279 | + (test_c and test_d) for the same copr build create separate pipelines |
| 280 | + without race conditions. |
| 281 | +
|
| 282 | + This test simulates the race condition where two handlers both try to |
| 283 | + create a test_run_group for the same pipeline at the same time. |
| 284 | + """ |
| 285 | + # Setup: Create a PR and project event |
| 286 | + pr = PullRequestModel.get_or_create( |
| 287 | + pr_id=1, |
| 288 | + namespace="test-namespace", |
| 289 | + repo_name="test-repo", |
| 290 | + project_url="https://github.com/test-namespace/test-repo", |
| 291 | + ) |
| 292 | + project_event = ProjectEventModel.get_or_create( |
| 293 | + type=ProjectEventModelType.pull_request, |
| 294 | + event_id=pr.id, |
| 295 | + commit_sha="4f4403b44107aae0b820f2a940623d3fa54dfcb6", |
| 296 | + ) |
| 297 | + # Store the ID to avoid thread-safety issues with SQLAlchemy sessions |
| 298 | + project_event_id = project_event.id |
| 299 | + |
| 300 | + # Create SRPM build and pipeline |
| 301 | + _, run_model = SRPMBuildModel.create_with_new_run( |
| 302 | + project_event_model=project_event, |
| 303 | + ) |
| 304 | + copr_build_group = CoprBuildGroupModel.create(run_model) |
| 305 | + |
| 306 | + # Create copr build with specific build_id |
| 307 | + # The commit_sha is already set in the project_event we created above |
| 308 | + _ = CoprBuildTargetModel.create( |
| 309 | + build_id="10034382", |
| 310 | + project_name="test-project", |
| 311 | + owner="test-owner", |
| 312 | + web_url="https://copr.fedorainfracloud.org/coprs/build/10034382/", |
| 313 | + target="fedora-rawhide-x86_64", |
| 314 | + status=BuildStatus.success, |
| 315 | + copr_build_group=copr_build_group, |
| 316 | + ) |
| 317 | + |
| 318 | + # Results storage for threads |
| 319 | + results = {} |
| 320 | + errors = {} |
| 321 | + |
| 322 | + # Use a barrier to synchronize both threads to start at exactly the same time |
| 323 | + # This ensures they execute concurrently and increases the chance of a race condition |
| 324 | + barrier = threading.Barrier(2, timeout=30) |
| 325 | + |
| 326 | + def create_test_run_group(identifier: str): |
| 327 | + """Helper function to create test run group in a thread""" |
| 328 | + try: |
| 329 | + # Query the copr build fresh in this thread |
| 330 | + fresh_copr_build = CoprBuildTargetModel.get_by_build_id( |
| 331 | + build_id="10034382", |
| 332 | + target="fedora-rawhide-x86_64", |
| 333 | + ) |
| 334 | + assert fresh_copr_build is not None, "Copr build should exist" |
| 335 | + |
| 336 | + handler_instance = TestingFarmHandler( |
| 337 | + package_config=flexmock(), |
| 338 | + job_config=JobConfig( |
| 339 | + type=JobType.tests, |
| 340 | + trigger=JobConfigTriggerType.pull_request, |
| 341 | + packages={ |
| 342 | + "package": CommonPackageConfig(identifier=identifier), |
| 343 | + }, |
| 344 | + ), |
| 345 | + event={}, |
| 346 | + celery_task=flexmock(request=flexmock(retries=0)), |
| 347 | + ) |
| 348 | + # Query project_event fresh in this thread to avoid thread-safety issues |
| 349 | + with sa_session_transaction() as session: |
| 350 | + fresh_project_event = ( |
| 351 | + session.query(ProjectEventModel).filter_by(id=project_event_id).first() |
| 352 | + ) |
| 353 | + assert fresh_project_event is not None, "Project event should exist" |
| 354 | + |
| 355 | + # Set up the handler's required attributes |
| 356 | + handler_instance._db_project_event = fresh_project_event |
| 357 | + handler_instance.data = flexmock( |
| 358 | + db_project_event=fresh_project_event, |
| 359 | + commit_sha="4f4403b44107aae0b820f2a940623d3fa54dfcb6", |
| 360 | + ) |
| 361 | + handler_instance._project = flexmock( |
| 362 | + get_web_url=lambda: "https://github.com/test-namespace/test-repo" |
| 363 | + ) |
| 364 | + |
| 365 | + # Mock the testing_farm_job_helper |
| 366 | + handler_instance._testing_farm_job_helper = flexmock( |
| 367 | + skip_build=False, |
| 368 | + tft_client=flexmock(default_ranch="public"), |
| 369 | + tests_targets={"fedora-rawhide-x86_64"}, |
| 370 | + test_target2build_target=lambda target: target, |
| 371 | + get_latest_copr_build=lambda target, commit_sha: fresh_copr_build, |
| 372 | + run_testing_farm=lambda test_run, build: {"success": True, "details": {}}, |
| 373 | + build_required=lambda: False, # Build not required, proceed with tests |
| 374 | + job_owner="test-owner", |
| 375 | + job_project="test-project", |
| 376 | + ) |
| 377 | + |
| 378 | + # Wait for both threads to be ready before calling run() |
| 379 | + barrier.wait() |
| 380 | + |
| 381 | + # Call run() which will exercise the full logic including _get_or_create_group |
| 382 | + result = handler_instance.run() |
| 383 | + assert result["success"], ( |
| 384 | + f"Handler run() failed for {identifier}: {result.get('details', {})}" |
| 385 | + ) |
| 386 | + |
| 387 | + # Query the database to get the created group |
| 388 | + with sa_session_transaction() as session: |
| 389 | + test_run = ( |
| 390 | + session.query(TFTTestRunTargetModel) |
| 391 | + .join(TFTTestRunGroupModel) |
| 392 | + .join(PipelineModel) |
| 393 | + .filter(PipelineModel.project_event_id == project_event_id) |
| 394 | + .filter(TFTTestRunTargetModel.identifier == identifier) |
| 395 | + .order_by(TFTTestRunTargetModel.id.desc()) |
| 396 | + .first() |
| 397 | + ) |
| 398 | + assert test_run is not None, f"Test run should have been created for {identifier}" |
| 399 | + group_id = test_run.group_of_targets.id |
| 400 | + results[identifier] = (group_id, [test_run]) |
| 401 | + except Exception as e: |
| 402 | + errors[identifier] = e |
| 403 | + raise |
| 404 | + |
| 405 | + # Create two threads that will execute concurrently |
| 406 | + thread_c = threading.Thread(target=create_test_run_group, args=("test_c",)) |
| 407 | + thread_d = threading.Thread(target=create_test_run_group, args=("test_d",)) |
| 408 | + |
| 409 | + # Start both threads at the same time |
| 410 | + thread_c.start() |
| 411 | + thread_d.start() |
| 412 | + |
| 413 | + # Wait for both threads to complete |
| 414 | + thread_c.join() |
| 415 | + thread_d.join() |
| 416 | + |
| 417 | + # Verify no errors occurred |
| 418 | + assert not errors, f"Errors occurred: {errors}" |
| 419 | + |
| 420 | + # Verify both test_run_groups were created |
| 421 | + assert "test_c" in results |
| 422 | + assert "test_d" in results |
| 423 | + |
| 424 | + group_c_id, _ = results["test_c"] |
| 425 | + group_d_id, _ = results["test_d"] |
| 426 | + |
| 427 | + # Verify both groups have different IDs |
| 428 | + assert group_c_id != group_d_id, "Each identifier should have its own group" |
| 429 | + |
| 430 | + # Verify both groups and test run targets exist |
| 431 | + with sa_session_transaction() as session: |
| 432 | + # Verify that both test_run_groups were created in the same second |
| 433 | + # This ensures the race condition test is actually testing concurrent creation |
| 434 | + group_c = session.query(TFTTestRunGroupModel).filter_by(id=group_c_id).first() |
| 435 | + group_d = session.query(TFTTestRunGroupModel).filter_by(id=group_d_id).first() |
| 436 | + |
| 437 | + assert group_c is not None, "test_c group should exist" |
| 438 | + assert group_d is not None, "test_d group should exist" |
| 439 | + assert group_c.submitted_time is not None, "group_c should have a submitted_time" |
| 440 | + assert group_d.submitted_time is not None, "group_d should have a submitted_time" |
| 441 | + |
| 442 | + # Verify that there exists a tft_test_run_targets row for every identifier |
| 443 | + test_run_target_c = ( |
| 444 | + session.query(TFTTestRunTargetModel) |
| 445 | + .filter_by(identifier="test_c") |
| 446 | + .filter_by(tft_test_run_group_id=group_c_id) |
| 447 | + .first() |
| 448 | + ) |
| 449 | + test_run_target_d = ( |
| 450 | + session.query(TFTTestRunTargetModel) |
| 451 | + .filter_by(identifier="test_d") |
| 452 | + .filter_by(tft_test_run_group_id=group_d_id) |
| 453 | + .first() |
| 454 | + ) |
| 455 | + |
| 456 | + assert test_run_target_c is not None, "test_c should have a test_run_target row" |
| 457 | + assert test_run_target_d is not None, "test_d should have a test_run_target row" |
| 458 | + |
| 459 | + # Verify that both test_run_targets are linked to their respective groups |
| 460 | + assert test_run_target_c.tft_test_run_group_id == group_c_id |
| 461 | + assert test_run_target_d.tft_test_run_group_id == group_d_id |
| 462 | + |
| 463 | + # Verify both groups have pipelines linked to them (check last) |
| 464 | + pipeline_c = session.query(PipelineModel).filter_by(test_run_group_id=group_c_id).first() |
| 465 | + pipeline_d = session.query(PipelineModel).filter_by(test_run_group_id=group_d_id).first() |
| 466 | + |
| 467 | + assert pipeline_c is not None, "test_c group should have a pipeline" |
| 468 | + assert pipeline_d is not None, "test_d group should have a pipeline" |
| 469 | + assert pipeline_c.id != pipeline_d.id, "Each group should have its own pipeline" |
| 470 | + |
| 471 | + # Verify the pipelines are linked to the same project_event |
| 472 | + assert pipeline_c.project_event_id == project_event_id |
| 473 | + assert pipeline_d.project_event_id == project_event_id |
| 474 | + |
| 475 | + # Truncate to seconds and compare |
| 476 | + group_c_second = group_c.submitted_time.replace(microsecond=0) |
| 477 | + group_d_second = group_d.submitted_time.replace(microsecond=0) |
| 478 | + assert group_c_second == group_d_second, ( |
| 479 | + f"Test run groups should be created in the same second. " |
| 480 | + f"group_c: {group_c.submitted_time}, group_d: {group_d.submitted_time}" |
| 481 | + ) |
0 commit comments