Skip to content

fix: resolve race condition in compound trigger evaluation#174

Open
tomerqodo wants to merge 2 commits into
qodo_claude_vs_qodo_base_fix_resolve_race_condition_in_compound_trigger_evaluation_pr7from
qodo_claude_vs_qodo_head_fix_resolve_race_condition_in_compound_trigger_evaluation_pr7
Open

fix: resolve race condition in compound trigger evaluation#174
tomerqodo wants to merge 2 commits into
qodo_claude_vs_qodo_base_fix_resolve_race_condition_in_compound_trigger_evaluation_pr7from
qodo_claude_vs_qodo_head_fix_resolve_race_condition_in_compound_trigger_evaluation_pr7

Conversation

@tomerqodo
Copy link
Copy Markdown

Benchmark PR from agentic-review-benchmarks#7

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>
@qodo-code-review
Copy link
Copy Markdown

Review Summary by Qodo

Fix race conditions in compound trigger evaluation

🐞 Bug fix

Grey Divider

Walkthroughs

Description
• Resolve race condition preventing compound triggers from firing
  - Serialize concurrent child trigger evaluations with advisory locks
  - Prevent double-firing by making deletion a claim operation
• Add comprehensive concurrency tests for compound trigger evaluation
• Improve logging for race condition detection and debugging
Diagram
flowchart LR
  A["Child Trigger Events"] -->|"concurrent processing"| B["Acquire Advisory Lock"]
  B -->|"serialized evaluation"| C["Check Child Firings"]
  C -->|"all conditions met"| D["DELETE...RETURNING"]
  D -->|"claim operation"| E{"Deleted == Expected?"}
  E -->|"yes"| F["Fire Parent Trigger"]
  E -->|"no"| G["Skip - Another Worker Won"]
Loading

Grey Divider

File Changes

1. src/prefect/server/events/models/composite_trigger_child_firing.py 🐞 Bug fix +48/-3

Add advisory locks and DELETE...RETURNING for race prevention

• Add acquire_composite_trigger_lock() function using PostgreSQL advisory locks to serialize
 concurrent evaluations
• Modify clear_child_firings() to return deleted IDs using DELETE...RETURNING clause
• Enable race detection by comparing expected vs actually deleted firing IDs

src/prefect/server/events/models/composite_trigger_child_firing.py


2. src/prefect/server/events/triggers.py 🐞 Bug fix +29/-5

Integrate advisory locks and race detection in trigger evaluation

• Import and call acquire_composite_trigger_lock() in evaluate_composite_trigger()
• Add race condition detection logic comparing deleted vs expected firing counts
• Log debug messages when another worker claims the firings
• Fix logger initialization to use standard logging.getLogger() instead of TYPE_CHECKING import

src/prefect/server/events/triggers.py


3. tests/events/server/triggers/test_composite_triggers.py 🧪 Tests +121/-0

Add regression tests for compound trigger concurrency

• Add TestCompoundTriggerConcurrency test class with concurrent evaluation tests
• Add compound_automation_concurrent fixture for compound trigger testing
• Add test_compound_trigger_does_not_double_fire_when_children_race() to verify single firing
• Add test_concurrent_child_firings_still_triggers_parent() to verify legitimate firings still
 work
• Import asyncio for concurrent test execution

tests/events/server/triggers/test_composite_triggers.py


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented Mar 10, 2026

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (4) 📎 Requirement gaps (0)

Grey Divider


Action required

1. composite_trigger_child_firing missing future 📘 Rule violation ✓ Correctness
Description
This src/ file contains type annotations but does not include from __future__ import annotations
as the first import. This can break forward references and violates the repository typing/import
standard.
Code

src/prefect/server/events/models/composite_trigger_child_firing.py[10]

+from prefect.server.utilities.database import get_dialect
Evidence
Compliance ID 1 requires from __future__ import annotations as the first import in any typed
src/ Python file. The file starts with other imports and includes new type annotations (e.g.,
engine: sa.Engine) without the future import.

AGENTS.md
src/prefect/server/events/models/composite_trigger_child_firing.py[1-11]
src/prefect/server/events/models/composite_trigger_child_firing.py[35-36]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`src/prefect/server/events/models/composite_trigger_child_firing.py` contains type annotations but is missing `from __future__ import annotations` as the first import.

## Issue Context
Compliance requires this import for typed `src/` files to support forward references and improve type-checking behavior.

## Fix Focus Areas
- src/prefect/server/events/models/composite_trigger_child_firing.py[1-11]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. triggers.py missing future 📘 Rule violation ✓ Correctness
Description
This src/ file contains type annotations but does not include from __future__ import annotations
as the first import. This violates the required typing/import convention for typed Python modules.
Code

src/prefect/server/events/triggers.py[71]

+import logging
Evidence
Compliance ID 1 requires from __future__ import annotations as the first import statement in typed
src/ Python files. triggers.py has a module docstring then regular imports and typed annotations
(e.g., AutomationID: TypeAlias = UUID) without the future import.

AGENTS.md
src/prefect/server/events/triggers.py[1-24]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`src/prefect/server/events/triggers.py` includes type annotations but lacks `from __future__ import annotations` as the first import.

## Issue Context
The module begins with a docstring; the `__future__` import should come right after that docstring and before other imports.

## Fix Focus Areas
- src/prefect/server/events/triggers.py[1-24]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. logger uses logging.getLogger 📘 Rule violation ✧ Quality
Description
The logger is now instantiated via logging.getLogger(__name__) and lacks the standardized
type-annotated get_logger(...) pattern. This can lead to inconsistent logging configuration and
violates the required logger initialization standard.
Code

src/prefect/server/events/triggers.py[R71-73]

+import logging

-logger: "logging.Logger" = get_logger(__name__)
+logger = logging.getLogger(__name__)
Evidence
Compliance ID 3 requires logger instances to use `logger: "logging.Logger" =
get_logger("module_name"). The changed code initializes logger with logging.getLogger(__name__)`
and provides no type annotation.

AGENTS.md
src/prefect/server/events/triggers.py[71-73]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`logger` is initialized with `logging.getLogger(__name__)` and is missing the standardized `get_logger` initialization pattern and type annotation.

## Issue Context
The required pattern is `logger: &quot;logging.Logger&quot; = get_logger(&quot;module_name&quot;)` to ensure consistent configuration.

## Fix Focus Areas
- src/prefect/server/events/triggers.py[71-73]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
4. Unstable lock key 🐞 Bug ✓ Correctness
Description
acquire_composite_trigger_lock uses hash(str(trigger.id)) to derive the Postgres advisory lock
key, which is not stable across processes, so concurrent workers may take different locks and the
composite-trigger race can still occur. This contradicts the function’s own comment/docstring that
it serializes evaluations for the same trigger.
Code

src/prefect/server/events/models/composite_trigger_child_firing.py[R38-45]

+    if dialect.name == "postgresql":
+        # Use the trigger's UUID as the lock key
+        # pg_advisory_xact_lock takes a bigint, so we use the UUID's int representation
+        # truncated to fit (collision is extremely unlikely and benign)
+        lock_key = hash(str(trigger.id)) % (2**63)
+        await session.execute(
+            sa.text("SELECT pg_advisory_xact_lock(:key)"), {"key": lock_key}
+        )
Evidence
The lock is explicitly intended to serialize concurrent evaluations for the same composite trigger,
but the key derivation uses Python’s hash() instead of a stable UUID->int mapping (even though the
comment says it does). If different workers compute different keys for the same UUID, they will not
block each other.

src/prefect/server/events/models/composite_trigger_child_firing.py[17-45]
src/prefect/server/events/triggers.py[349-353]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`acquire_composite_trigger_lock` uses Python `hash()` to compute the Postgres advisory lock key. Python hash values are not stable across processes, so different workers can compute different lock keys for the same trigger UUID and fail to serialize evaluations.

### Issue Context
The function’s docstring/comment state that it serializes concurrent child trigger evaluations for the same composite trigger.

### Fix Focus Areas
- src/prefect/server/events/models/composite_trigger_child_firing.py[17-45]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Wrong IDs returned 🐞 Bug ✓ Correctness
Description
clear_child_firings claims to return deleted child_firing_ids but actually RETURNINGs
child_trigger_id, while evaluate_composite_trigger treats the returned IDs as firing IDs
(including in logs). This breaks the function contract and produces misleading race-diagnosis
output.
Code

src/prefect/server/events/models/composite_trigger_child_firing.py[R140-155]

+    """
+    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)
    )
Evidence
The function documentation and caller both operate in terms of firing IDs (Firing.id /
child_firing_id), but the DELETE statement returns child_trigger_id instead. The ORM model
contains both columns, confirming this is not just a naming alias.

src/prefect/server/events/models/composite_trigger_child_firing.py[133-155]
src/prefect/server/database/orm_models.py[1351-1371]
src/prefect/server/events/triggers.py[365-410]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`clear_child_firings` is documented/used as returning deleted firing IDs, but it returns trigger IDs instead, violating its contract and corrupting caller logging/diagnostics.

### Issue Context
`evaluate_composite_trigger` builds `firing_ids` from `Firing.id` and expects `clear_child_firings` to return those IDs.

### Fix Focus Areas
- src/prefect/server/events/models/composite_trigger_child_firing.py[133-157]
- src/prefect/server/events/triggers.py[365-410]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. SQLite RETURNING breakage 🐞 Bug ⛯ Reliability
Description
clear_child_firings uses DELETE … RETURNING without checking the DB dialect, but Prefect
explicitly supports SQLite and elsewhere treats .returning() as Postgres-only. On SQLite
configurations where RETURNING is unavailable/unsupported, composite trigger evaluation will raise
SQL errors at runtime.
Code

src/prefect/server/events/models/composite_trigger_child_firing.py[R147-155]

+    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)
    )
Evidence
The codebase states Prefect supports SQLite, and shows patterns where .returning() is used only in
explicitly Postgres-only write paths. clear_child_firings does not follow that pattern and will
attempt .returning() for all dialects.

src/prefect/server/utilities/database.py[1-6]
src/prefect/server/database/configurations.py[411-435]
src/prefect/server/events/storage/database.py[252-270]
src/prefect/server/events/models/composite_trigger_child_firing.py[147-155]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`clear_child_firings` uses `.returning()` unconditionally even though the codebase supports SQLite and already treats RETURNING as a Postgres-only feature in other write paths.

### Issue Context
Composite trigger evaluation runs on the main automation DB session, which can be configured for SQLite or Postgres.

### Fix Focus Areas
- src/prefect/server/events/models/composite_trigger_child_firing.py[133-157]
- src/prefect/server/events/storage/database.py[252-286]
- src/prefect/server/database/configurations.py[411-435]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

7. New tests not mirrored 📘 Rule violation ⛯ Reliability
Description
The newly added tests for src/prefect/server/events/triggers.py are placed under
tests/events/server/triggers/ which does not mirror the source directory structure required by the
checklist. This reduces discoverability and violates the test layout convention.
Code

tests/events/server/triggers/test_composite_triggers.py[R1630-1747]

+class TestCompoundTriggerConcurrency:
+    """Tests for concurrent child trigger evaluation race condition fix."""
+
+    @pytest.fixture
+    async def compound_automation_concurrent(
+        self,
+        automations_session: AsyncSession,
+        cleared_buckets: None,
+        cleared_automations: None,
+    ) -> Automation:
+        """Compound trigger requiring all child triggers to fire."""
+        compound_automation = Automation(
+            name="Compound Automation Concurrency Test",
+            trigger=CompoundTrigger(
+                require="all",
+                within=timedelta(minutes=5),
+                triggers=[
+                    EventTrigger(
+                        expect={"event.A"},
+                        match={"prefect.resource.id": "*"},
+                        posture=Posture.Reactive,
+                        threshold=1,
+                    ),
+                    EventTrigger(
+                        expect={"event.B"},
+                        match={"prefect.resource.id": "*"},
+                        posture=Posture.Reactive,
+                        threshold=1,
+                    ),
+                ],
+            ),
+            actions=[actions.DoNothing()],
+        )
+
+        persisted = await automations.create_automation(
+            session=automations_session, automation=compound_automation
+        )
+        compound_automation.created = persisted.created
+        compound_automation.updated = persisted.updated
+        triggers.load_automation(persisted)
+        await automations_session.commit()
+
+        return compound_automation
+
+    async def test_compound_trigger_does_not_double_fire_when_children_race(
+        self,
+        act: mock.AsyncMock,
+        compound_automation_concurrent: Automation,
+        start_of_test: DateTime,
+    ):
+        """
+        Regression test for compound trigger double-firing when child firings race.
+
+        Verifies that when two child trigger events are processed concurrently,
+        the compound trigger fires exactly once. The DELETE ... RETURNING fix
+        ensures only one worker proceeds to fire the parent trigger.
+        """
+        event_a = ReceivedEvent(
+            occurred=start_of_test + timedelta(microseconds=1),
+            event="event.A",
+            resource={"prefect.resource.id": "test.resource"},
+            id=uuid4(),
+        )
+        event_b = ReceivedEvent(
+            occurred=start_of_test + timedelta(microseconds=2),
+            event="event.B",
+            resource={"prefect.resource.id": "test.resource"},
+            id=uuid4(),
+        )
+
+        # Process both events concurrently
+        await asyncio.gather(
+            triggers.reactive_evaluation(event_a),
+            triggers.reactive_evaluation(event_b),
+        )
+
+        # The compound trigger should fire exactly once
+        act.assert_called_once()
+
+        firing: Firing = act.call_args.args[0]
+        assert isinstance(firing.trigger, CompoundTrigger)
+        assert firing.trigger.id == compound_automation_concurrent.trigger.id
+
+    async def test_concurrent_child_firings_still_triggers_parent(
+        self,
+        act: mock.AsyncMock,
+        compound_automation_concurrent: Automation,
+        start_of_test: DateTime,
+    ):
+        """
+        Verify that when two child trigger events arrive nearly simultaneously,
+        the compound trigger still fires. This tests that the race condition fix
+        doesn't prevent legitimate firings.
+        """
+        event_a = ReceivedEvent(
+            occurred=start_of_test + timedelta(microseconds=1),
+            event="event.A",
+            resource={"prefect.resource.id": "test.resource"},
+            id=uuid4(),
+        )
+        event_b = ReceivedEvent(
+            occurred=start_of_test + timedelta(microseconds=2),
+            event="event.B",
+            resource={"prefect.resource.id": "test.resource"},
+            id=uuid4(),
+        )
+
+        # Process both events concurrently to simulate the race condition
+        await asyncio.gather(
+            triggers.reactive_evaluation(event_a),
+            triggers.reactive_evaluation(event_b),
+        )
+
+        # The compound trigger should fire exactly once
+        act.assert_called_once()
+
+        firing: Firing = act.call_args.args[0]
+        assert firing.trigger.id == compound_automation_concurrent.trigger.id
Evidence
Compliance ID 6 requires tests to mirror the src/ directory hierarchy (e.g.,
src/prefect/server/events/triggers.py should map under tests/server/events/ with test_*.py).
The PR adds new tests in a path that does not reflect that structure.

AGENTS.md
tests/events/server/triggers/test_composite_triggers.py[1630-1707]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New tests for `src/prefect/server/events/triggers.py` were added in a test file path that does not mirror the `src/` directory structure.

## Issue Context
The checklist requires test organization to mirror the source hierarchy to keep tests discoverable.

## Fix Focus Areas
- tests/events/server/triggers/test_composite_triggers.py[1630-1747]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo


from prefect.server.database import PrefectDBInterface, db_injector
from prefect.server.events.schemas.automations import CompositeTrigger, Firing
from prefect.server.utilities.database import get_dialect
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. composite_trigger_child_firing missing future 📘 Rule violation ✓ Correctness

This src/ file contains type annotations but does not include from __future__ import annotations
as the first import. This can break forward references and violates the repository typing/import
standard.
Agent Prompt
## Issue description
`src/prefect/server/events/models/composite_trigger_child_firing.py` contains type annotations but is missing `from __future__ import annotations` as the first import.

## Issue Context
Compliance requires this import for typed `src/` files to support forward references and improve type-checking behavior.

## Fix Focus Areas
- src/prefect/server/events/models/composite_trigger_child_firing.py[1-11]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


from prefect.server.database.orm_models import ORMAutomationBucket

import logging
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. triggers.py missing future 📘 Rule violation ✓ Correctness

This src/ file contains type annotations but does not include from __future__ import annotations
as the first import. This violates the required typing/import convention for typed Python modules.
Agent Prompt
## Issue description
`src/prefect/server/events/triggers.py` includes type annotations but lacks `from __future__ import annotations` as the first import.

## Issue Context
The module begins with a docstring; the `__future__` import should come right after that docstring and before other imports.

## Fix Focus Areas
- src/prefect/server/events/triggers.py[1-24]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +71 to +73
import logging

logger: "logging.Logger" = get_logger(__name__)
logger = logging.getLogger(__name__)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

3. logger uses logging.getlogger 📘 Rule violation ✧ Quality

The logger is now instantiated via logging.getLogger(__name__) and lacks the standardized
type-annotated get_logger(...) pattern. This can lead to inconsistent logging configuration and
violates the required logger initialization standard.
Agent Prompt
## Issue description
`logger` is initialized with `logging.getLogger(__name__)` and is missing the standardized `get_logger` initialization pattern and type annotation.

## Issue Context
The required pattern is `logger: "logging.Logger" = get_logger("module_name")` to ensure consistent configuration.

## Fix Focus Areas
- src/prefect/server/events/triggers.py[71-73]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +1630 to +1747
class TestCompoundTriggerConcurrency:
"""Tests for concurrent child trigger evaluation race condition fix."""

@pytest.fixture
async def compound_automation_concurrent(
self,
automations_session: AsyncSession,
cleared_buckets: None,
cleared_automations: None,
) -> Automation:
"""Compound trigger requiring all child triggers to fire."""
compound_automation = Automation(
name="Compound Automation Concurrency Test",
trigger=CompoundTrigger(
require="all",
within=timedelta(minutes=5),
triggers=[
EventTrigger(
expect={"event.A"},
match={"prefect.resource.id": "*"},
posture=Posture.Reactive,
threshold=1,
),
EventTrigger(
expect={"event.B"},
match={"prefect.resource.id": "*"},
posture=Posture.Reactive,
threshold=1,
),
],
),
actions=[actions.DoNothing()],
)

persisted = await automations.create_automation(
session=automations_session, automation=compound_automation
)
compound_automation.created = persisted.created
compound_automation.updated = persisted.updated
triggers.load_automation(persisted)
await automations_session.commit()

return compound_automation

async def test_compound_trigger_does_not_double_fire_when_children_race(
self,
act: mock.AsyncMock,
compound_automation_concurrent: Automation,
start_of_test: DateTime,
):
"""
Regression test for compound trigger double-firing when child firings race.

Verifies that when two child trigger events are processed concurrently,
the compound trigger fires exactly once. The DELETE ... RETURNING fix
ensures only one worker proceeds to fire the parent trigger.
"""
event_a = ReceivedEvent(
occurred=start_of_test + timedelta(microseconds=1),
event="event.A",
resource={"prefect.resource.id": "test.resource"},
id=uuid4(),
)
event_b = ReceivedEvent(
occurred=start_of_test + timedelta(microseconds=2),
event="event.B",
resource={"prefect.resource.id": "test.resource"},
id=uuid4(),
)

# Process both events concurrently
await asyncio.gather(
triggers.reactive_evaluation(event_a),
triggers.reactive_evaluation(event_b),
)

# The compound trigger should fire exactly once
act.assert_called_once()

firing: Firing = act.call_args.args[0]
assert isinstance(firing.trigger, CompoundTrigger)
assert firing.trigger.id == compound_automation_concurrent.trigger.id

async def test_concurrent_child_firings_still_triggers_parent(
self,
act: mock.AsyncMock,
compound_automation_concurrent: Automation,
start_of_test: DateTime,
):
"""
Verify that when two child trigger events arrive nearly simultaneously,
the compound trigger still fires. This tests that the race condition fix
doesn't prevent legitimate firings.
"""
event_a = ReceivedEvent(
occurred=start_of_test + timedelta(microseconds=1),
event="event.A",
resource={"prefect.resource.id": "test.resource"},
id=uuid4(),
)
event_b = ReceivedEvent(
occurred=start_of_test + timedelta(microseconds=2),
event="event.B",
resource={"prefect.resource.id": "test.resource"},
id=uuid4(),
)

# Process both events concurrently to simulate the race condition
await asyncio.gather(
triggers.reactive_evaluation(event_a),
triggers.reactive_evaluation(event_b),
)

# The compound trigger should fire exactly once
act.assert_called_once()

firing: Firing = act.call_args.args[0]
assert firing.trigger.id == compound_automation_concurrent.trigger.id
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

4. New tests not mirrored 📘 Rule violation ⛯ Reliability

The newly added tests for src/prefect/server/events/triggers.py are placed under
tests/events/server/triggers/ which does not mirror the source directory structure required by the
checklist. This reduces discoverability and violates the test layout convention.
Agent Prompt
## Issue description
New tests for `src/prefect/server/events/triggers.py` were added in a test file path that does not mirror the `src/` directory structure.

## Issue Context
The checklist requires test organization to mirror the source hierarchy to keep tests discoverable.

## Fix Focus Areas
- tests/events/server/triggers/test_composite_triggers.py[1630-1747]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +38 to +45
if dialect.name == "postgresql":
# Use the trigger's UUID as the lock key
# pg_advisory_xact_lock takes a bigint, so we use the UUID's int representation
# truncated to fit (collision is extremely unlikely and benign)
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.

Action required

5. Unstable lock key 🐞 Bug ✓ Correctness

acquire_composite_trigger_lock uses hash(str(trigger.id)) to derive the Postgres advisory lock
key, which is not stable across processes, so concurrent workers may take different locks and the
composite-trigger race can still occur. This contradicts the function’s own comment/docstring that
it serializes evaluations for the same trigger.
Agent Prompt
### Issue description
`acquire_composite_trigger_lock` uses Python `hash()` to compute the Postgres advisory lock key. Python hash values are not stable across processes, so different workers can compute different lock keys for the same trigger UUID and fail to serialize evaluations.

### Issue Context
The function’s docstring/comment state that it serializes concurrent child trigger evaluations for the same composite trigger.

### Fix Focus Areas
- src/prefect/server/events/models/composite_trigger_child_firing.py[17-45]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +140 to 155
"""
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)
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

6. Wrong ids returned 🐞 Bug ✓ Correctness

clear_child_firings claims to return deleted child_firing_ids but actually RETURNINGs
child_trigger_id, while evaluate_composite_trigger treats the returned IDs as firing IDs
(including in logs). This breaks the function contract and produces misleading race-diagnosis
output.
Agent Prompt
### Issue description
`clear_child_firings` is documented/used as returning deleted firing IDs, but it returns trigger IDs instead, violating its contract and corrupting caller logging/diagnostics.

### Issue Context
`evaluate_composite_trigger` builds `firing_ids` from `Firing.id` and expects `clear_child_firings` to return those IDs.

### Fix Focus Areas
- src/prefect/server/events/models/composite_trigger_child_firing.py[133-157]
- src/prefect/server/events/triggers.py[365-410]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +147 to 155
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)
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

7. Sqlite returning breakage 🐞 Bug ⛯ Reliability

clear_child_firings uses DELETE … RETURNING without checking the DB dialect, but Prefect
explicitly supports SQLite and elsewhere treats .returning() as Postgres-only. On SQLite
configurations where RETURNING is unavailable/unsupported, composite trigger evaluation will raise
SQL errors at runtime.
Agent Prompt
### Issue description
`clear_child_firings` uses `.returning()` unconditionally even though the codebase supports SQLite and already treats RETURNING as a Postgres-only feature in other write paths.

### Issue Context
Composite trigger evaluation runs on the main automation DB session, which can be configured for SQLite or Postgres.

### Fix Focus Areas
- src/prefect/server/events/models/composite_trigger_child_firing.py[133-157]
- src/prefect/server/events/storage/database.py[252-286]
- src/prefect/server/database/configurations.py[411-435]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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.

2 participants