Skip to content

fix: resolve race condition in compound trigger evaluation#7

Open
tomerqodo wants to merge 2 commits into
coderabbit_full_base_fix_resolve_race_condition_in_compound_trigger_evaluation_pr7from
coderabbit_full_head_fix_resolve_race_condition_in_compound_trigger_evaluation_pr7
Open

fix: resolve race condition in compound trigger evaluation#7
tomerqodo wants to merge 2 commits into
coderabbit_full_base_fix_resolve_race_condition_in_compound_trigger_evaluation_pr7from
coderabbit_full_head_fix_resolve_race_condition_in_compound_trigger_evaluation_pr7

Conversation

@tomerqodo

@tomerqodo tomerqodo commented Jan 30, 2026

Copy link
Copy Markdown

Benchmark PR from agentic-review-benchmarks#7

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Fixed a race condition that could cause composite triggers to fire multiple times when child events are processed concurrently, ensuring triggers fire exactly once as expected.
  • Tests

    • Added comprehensive tests for concurrent trigger execution scenarios to improve reliability.

desertaxle and others added 2 commits January 25, 2026 12:10
Fixes two race conditions in compound trigger evaluation:

1. **Never-firing race** (transactional): When two child triggers fire
   concurrently in separate transactions, each only sees its own
   uncommitted insert due to READ COMMITTED isolation. Neither sees
   enough firings to trigger the parent.

   Fix: Use PostgreSQL advisory locks to serialize concurrent evaluations
   for the same compound trigger.

2. **Double-firing race** (autocommit): When both transactions see all
   firings, both delete and both fire the parent.

   Fix: Use DELETE ... RETURNING to make clearing a claim operation.
   Only the worker that successfully deletes the expected firings
   proceeds; others bail out.

Based on the fix in PrefectHQ/nebula#10716.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jan 30, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This pull request adds advisory locking for composite triggers to prevent race conditions when multiple workers concurrently evaluate and fire the same trigger. The implementation acquires a database lock before processing child firings and detects when another worker has already claimed the firings.

Changes

Cohort / File(s) Summary
Advisory Locking for Composite Triggers
src/prefect/server/events/models/composite_trigger_child_firing.py
Added acquire_composite_trigger_lock() function to acquire PostgreSQL advisory locks (no-op for other dialects); modified clear_child_firings() to return deleted IDs as set[UUID] using .returning(); updated upsert_child_firing() signature to accept firing parameter.
Lock Integration in Trigger Evaluation
src/prefect/server/events/triggers.py
Imported acquire_composite_trigger_lock() and integrated it into evaluate_composite_trigger() to serialize concurrent evaluations; added race-condition detection that compares deleted IDs against original firing IDs and skips parent trigger firing if IDs don't fully match; refactored logger initialization.
Concurrency Test Coverage
tests/events/server/triggers/test_composite_triggers.py
Added TestCompoundTriggerConcurrency class with fixture compound_automation_concurrent() and two test methods (test_compound_trigger_does_not_double_fire_when_children_race() and test_concurrent_child_firings_still_triggers_parent()) to validate single-fire behavior under concurrent child trigger scenarios.

Sequence Diagram(s)

sequenceDiagram
    participant Worker_1
    participant Worker_2
    participant Database
    participant Trigger_Eval

    rect rgba(200, 150, 100, 0.5)
    Note over Worker_1,Trigger_Eval: Composite Trigger Evaluation with Locking
    end

    Worker_1 ->> Database: acquire_composite_trigger_lock()
    activate Database
    Database ->> Worker_1: Lock acquired
    deactivate Database

    Worker_2 ->> Database: acquire_composite_trigger_lock()
    activate Database
    Note over Database: Blocked (Worker_1 holds lock)
    deactivate Database

    Worker_1 ->> Trigger_Eval: Check child firings

    Worker_1 ->> Database: clear_child_firings()
    activate Database
    Database ->> Worker_1: Returns deleted_ids
    deactivate Database

    Worker_1 ->> Trigger_Eval: Compare deleted_ids vs firing_ids

    alt All IDs deleted
        Worker_1 ->> Trigger_Eval: Fire parent trigger
    else Race condition detected
        Worker_1 ->> Trigger_Eval: Log debug, exit early
    end

    Worker_1 ->> Database: Release lock

    Worker_2 ->> Database: Lock now acquired
    activate Database
    Database ->> Worker_2: Lock acquired
    deactivate Database

    Worker_2 ->> Trigger_Eval: Check child firings (already claimed)
    Worker_2 ->> Trigger_Eval: Exit early (no firings available)
    Worker_2 ->> Database: Release lock
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 A race condition hopped through our code,
Two workers on the same trigger road!
With locks from the database, wise and true,
Now only one parent fires—
Concurrency problems, we bid adieu! 🔐✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Description check ⚠️ Warning The pull request description is largely incomplete. It only contains a brief reference to a benchmark PR without providing substantive information about the changes, objectives, or testing. Expand the description to include: a summary of the race conditions being fixed, the solution approach (advisory locks and atomic deletion), testing coverage, and reference any related issues using 'closes ' format.
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: resolve race condition in compound trigger evaluation' accurately summarizes the main change in the changeset, which addresses race conditions in compound trigger evaluation through advisory locks and atomic deletion operations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch coderabbit_full_head_fix_resolve_race_condition_in_compound_trigger_evaluation_pr7

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@tomerqodo

Copy link
Copy Markdown
Author

@coderabbitai review

1 similar comment
@tomerqodo

Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jan 30, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@tomerqodo

Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jan 30, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@bar-qodo

bar-qodo commented Feb 2, 2026

Copy link
Copy Markdown

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Feb 2, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@okotek

okotek commented Feb 2, 2026

Copy link
Copy Markdown

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Feb 2, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/prefect/server/events/models/composite_trigger_child_firing.py`:
- Around line 139-157: The docstring and returned column are mismatched: the
delete query currently
.returning(db.CompositeTriggerChildFiring.child_trigger_id) but the docstring
(and expected semantics) say it should return the deleted child_firing_ids;
update the .returning(...) call to return
db.CompositeTriggerChildFiring.child_firing_id instead (and keep the final
return as set(result.scalars().all())), or if returning child_trigger_id was
intentional, change the docstring to state that the function returns
child_trigger_id values; locate the delete block referencing
db.CompositeTriggerChildFiring, trigger, and firing_ids to make this change.
- Around line 42-44: The advisory lock currently computes lock_key using
Python's non-deterministic hash(str(trigger.id)), which varies between
processes; change this to use the deterministic UUID integer form instead:
derive a stable 64-bit key from trigger.id by using its integer representation
(trigger.id.int) reduced into the 63-bit range (e.g., modulo 2**63 or bitmask)
and pass that as lock_key to the existing await session.execute(sa.text("SELECT
pg_advisory_xact_lock(:key)"), {"key": lock_key}) call so all workers calculate
the same advisory lock value for the same trigger.id.
🧹 Nitpick comments (2)
src/prefect/server/events/models/composite_trigger_child_firing.py (1)

30-32: Consider logging when lock acquisition is skipped due to missing bind.

If session.get_bind() returns None, the function silently returns without acquiring the lock. This could mask configuration issues where the race condition protection is expected but not active. A debug log would aid troubleshooting.

📝 Suggested debug log
     bind = session.get_bind()
     if bind is None:
+        import logging
+        logging.getLogger(__name__).debug(
+            "Skipping advisory lock acquisition: session has no bind"
+        )
         return
src/prefect/server/events/triggers.py (1)

71-73: Consider using get_logger from prefect.logging for consistency.

The module imports get_logger from prefect.logging (Line 27) but initializes the logger using standard logging.getLogger. Prefect's get_logger may apply additional configuration. Consider using the already-imported get_logger for consistency with the rest of the codebase.

♻️ Suggested change
-import logging
-
-logger = logging.getLogger(__name__)
+logger = get_logger(__name__)

Comment on lines +42 to +44
lock_key = hash(str(trigger.id)) % (2**63)
await session.execute(
sa.text("SELECT pg_advisory_xact_lock(:key)"), {"key": lock_key}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Python's hash() is not deterministic across processes, breaking the advisory lock.

Python randomizes the hash seed by default (PYTHONHASHSEED), so different worker processes will compute different lock keys for the same trigger.id. This defeats the purpose of the advisory lock since concurrent workers won't contend for the same lock.

Use the UUID's deterministic integer representation instead:

🐛 Proposed fix using UUID's int representation
-        lock_key = hash(str(trigger.id)) % (2**63)
+        # UUID.int gives a deterministic 128-bit integer; truncate to signed bigint range
+        lock_key = trigger.id.int % (2**63)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
lock_key = hash(str(trigger.id)) % (2**63)
await session.execute(
sa.text("SELECT pg_advisory_xact_lock(:key)"), {"key": lock_key}
# UUID.int gives a deterministic 128-bit integer; truncate to signed bigint range
lock_key = trigger.id.int % (2**63)
await session.execute(
sa.text("SELECT pg_advisory_xact_lock(:key)"), {"key": lock_key}
🤖 Prompt for AI Agents
In `@src/prefect/server/events/models/composite_trigger_child_firing.py` around
lines 42 - 44, The advisory lock currently computes lock_key using Python's
non-deterministic hash(str(trigger.id)), which varies between processes; change
this to use the deterministic UUID integer form instead: derive a stable 64-bit
key from trigger.id by using its integer representation (trigger.id.int) reduced
into the 63-bit range (e.g., modulo 2**63 or bitmask) and pass that as lock_key
to the existing await session.execute(sa.text("SELECT
pg_advisory_xact_lock(:key)"), {"key": lock_key}) call so all workers calculate
the same advisory lock value for the same trigger.id.

Comment on lines +139 to +157
) -> set[UUID]:
"""
Delete the specified child firings and return the IDs that were actually deleted.

Returns the set of child_firing_ids that were successfully deleted. Callers can
compare this to the expected firing_ids to detect races and avoid double-firing
composite triggers.
"""
result = await session.execute(
sa.delete(db.CompositeTriggerChildFiring)
.filter(
db.CompositeTriggerChildFiring.automation_id == trigger.automation.id,
db.CompositeTriggerChildFiring.parent_trigger_id == trigger.id,
db.CompositeTriggerChildFiring.child_firing_id.in_(firing_ids),
)
.returning(db.CompositeTriggerChildFiring.child_trigger_id)
)

return set(result.scalars().all())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Return column mismatch: docstring says child_firing_ids but code returns child_trigger_id.

The docstring states the function "Returns the set of child_firing_ids that were successfully deleted", but the .returning() clause fetches child_trigger_id instead. While the count comparison in the caller may coincidentally work, this is semantically incorrect and could cause subtle bugs if the comparison logic changes.

🐛 Proposed fix to return the correct column
         .returning(db.CompositeTriggerChildFiring.child_trigger_id)
+        .returning(db.CompositeTriggerChildFiring.child_firing_id)
-        .returning(db.CompositeTriggerChildFiring.child_trigger_id)

Or if child_trigger_id is intentional, update the docstring:

     """
     Delete the specified child firings and return the IDs that were actually deleted.

-    Returns the set of child_firing_ids that were successfully deleted. Callers can
-    compare this to the expected firing_ids to detect races and avoid double-firing
+    Returns the set of child_trigger_ids that were successfully deleted. Callers can
+    compare this count to the expected firing count to detect races and avoid double-firing
     composite triggers.
     """
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
) -> set[UUID]:
"""
Delete the specified child firings and return the IDs that were actually deleted.
Returns the set of child_firing_ids that were successfully deleted. Callers can
compare this to the expected firing_ids to detect races and avoid double-firing
composite triggers.
"""
result = await session.execute(
sa.delete(db.CompositeTriggerChildFiring)
.filter(
db.CompositeTriggerChildFiring.automation_id == trigger.automation.id,
db.CompositeTriggerChildFiring.parent_trigger_id == trigger.id,
db.CompositeTriggerChildFiring.child_firing_id.in_(firing_ids),
)
.returning(db.CompositeTriggerChildFiring.child_trigger_id)
)
return set(result.scalars().all())
) -> set[UUID]:
"""
Delete the specified child firings and return the IDs that were actually deleted.
Returns the set of child_firing_ids that were successfully deleted. Callers can
compare this to the expected firing_ids to detect races and avoid double-firing
composite triggers.
"""
result = await session.execute(
sa.delete(db.CompositeTriggerChildFiring)
.filter(
db.CompositeTriggerChildFiring.automation_id == trigger.automation.id,
db.CompositeTriggerChildFiring.parent_trigger_id == trigger.id,
db.CompositeTriggerChildFiring.child_firing_id.in_(firing_ids),
)
.returning(db.CompositeTriggerChildFiring.child_firing_id)
)
return set(result.scalars().all())
🤖 Prompt for AI Agents
In `@src/prefect/server/events/models/composite_trigger_child_firing.py` around
lines 139 - 157, The docstring and returned column are mismatched: the delete
query currently .returning(db.CompositeTriggerChildFiring.child_trigger_id) but
the docstring (and expected semantics) say it should return the deleted
child_firing_ids; update the .returning(...) call to return
db.CompositeTriggerChildFiring.child_firing_id instead (and keep the final
return as set(result.scalars().all())), or if returning child_trigger_id was
intentional, change the docstring to state that the function returns
child_trigger_id values; locate the delete block referencing
db.CompositeTriggerChildFiring, trigger, and firing_ids to make this change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants