Skip to content

Commit 514d089

Browse files
Use atomic update when adding a testing farm test (#2952)
Use atomic update when adding a testing farm test To prevent race conditions. Both threads see that the pipeline model has not yet been linked to the test. So both link it to the test. The latest one overwrites the previous value. The with_for_update clause isn't enough. SELECT ... FOR UPDATE + Python check: Two separate operations (read, then check in Python, then write) → race condition possible Atomic UPDATE with WHERE clause: One operation that checks and updates atomically → no race condition Reviewed-by: Nikola Forró Reviewed-by: Matej Focko Reviewed-by: Maja Massarini
2 parents 3e587c3 + f395aac commit 514d089

3 files changed

Lines changed: 245 additions & 16 deletions

File tree

packit_service/models.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3447,32 +3447,40 @@ def create(cls, run_model: "PipelineModel", ranch: str) -> "TFTTestRunGroupModel
34473447
# Flush to get test_run_group.id before using it
34483448
session.flush()
34493449

3450-
# lock the run_model in the DB by using SELECT ... FOR UPDATE
3451-
# all other workers accessing this run_model will block here
3452-
# until the context is exited
3453-
locked_run_model = (
3454-
session.query(PipelineModel).filter_by(id=run_model.id).with_for_update().first()
3450+
# Attempt to update the existing run_model if test_run_group_id is NULL
3451+
# This is an atomic operation at the DB level, preventing race conditions
3452+
# Only one thread will successfully update (updated_count=1)
3453+
# The other thread will get updated_count=0 and proceed to clone
3454+
updated_count = (
3455+
session.query(PipelineModel)
3456+
.filter_by(id=run_model.id, test_run_group_id=None)
3457+
.update({"test_run_group_id": test_run_group.id})
34553458
)
34563459

3457-
if not locked_run_model:
3458-
logger.warning(f"PipelineModel with id={run_model.id} not found in DB.")
3459-
return test_run_group
3460+
if updated_count == 0:
3461+
# If 0 rows were updated, it means another thread already set test_run_group_id
3462+
# So, clone the run model
3463+
locked_run_model = (
3464+
session.query(PipelineModel)
3465+
.filter_by(id=run_model.id)
3466+
.with_for_update()
3467+
.first()
3468+
)
3469+
if not locked_run_model:
3470+
logger.warning(f"PipelineModel with id={run_model.id} not found in DB.")
3471+
return test_run_group
34603472

3461-
# Check test_run_group_id directly to avoid stale relationship cache.
3462-
# The relationship might not be loaded or might be stale from a different session.
3463-
if locked_run_model.test_run_group_id:
3464-
# Clone run model
34653473
new_run_model = PipelineModel()
3474+
# Add to session first so relationships can be set properly
3475+
session.add(new_run_model)
34663476
new_run_model.project_event = locked_run_model.project_event
34673477
new_run_model.package_name = locked_run_model.package_name
34683478
new_run_model.srpm_build = locked_run_model.srpm_build
34693479
new_run_model.copr_build_group = locked_run_model.copr_build_group
34703480
new_run_model.koji_build_group = locked_run_model.koji_build_group
34713481
new_run_model.test_run_group_id = test_run_group.id
3472-
session.add(new_run_model)
3473-
else:
3474-
locked_run_model.test_run_group_id = test_run_group.id
3475-
session.add(locked_run_model)
3482+
# else: (updated_count == 1) - the current thread successfully set test_run_group_id
3483+
# No need to do anything further for the existing run_model as it's already updated.
34763484

34773485
return test_run_group
34783486

tests_openshift/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
TFTTestRunTargetModel,
5959
sa_session_transaction,
6060
sync_release_pr_association_table,
61+
tf_copr_association_table,
6162
)
6263

6364

@@ -210,6 +211,7 @@ def clean_db():
210211
session.query(PipelineModel).delete()
211212
session.query(ProjectEventModel).delete()
212213

214+
session.query(tf_copr_association_table).delete()
213215
session.query(TFTTestRunTargetModel).delete()
214216
session.query(TFTTestRunGroupModel).delete()
215217
session.query(CoprBuildTargetModel).delete()

tests_openshift/database/test_tasks.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Copyright Contributors to the Packit project.
22
# SPDX-License-Identifier: MIT
33

4+
import threading
5+
46
import pytest
57
from celery.canvas import Signature
68
from copr.v3 import BuildChrootProxy, BuildProxy, Client
@@ -21,9 +23,16 @@
2123
BuildStatus,
2224
CoprBuildGroupModel,
2325
CoprBuildTargetModel,
26+
PipelineModel,
2427
ProjectEventModel,
28+
ProjectEventModelType,
29+
PullRequestModel,
2530
SRPMBuildModel,
31+
TFTTestRunGroupModel,
32+
TFTTestRunTargetModel,
33+
sa_session_transaction,
2634
)
35+
from packit_service.worker.handlers import TestingFarmHandler
2736
from packit_service.worker.helpers.build import babysit
2837
from packit_service.worker.helpers.build.babysit import check_copr_build
2938
from packit_service.worker.monitoring import Pushgateway
@@ -260,3 +269,213 @@ def celery_run_async_stub(signatures, handlers) -> None:
260269
build = CoprBuildTargetModel.get_by_id(packit_build_752.id)
261270
assert build is not None
262271
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

Comments
 (0)