Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions src/prefect/server/events/models/composite_trigger_child_firing.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,46 @@

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
from prefect.types._datetime import DateTime, now

if TYPE_CHECKING:
from prefect.server.database.orm_models import ORMCompositeTriggerChildFiring


async def acquire_composite_trigger_lock(
session: AsyncSession,
trigger: CompositeTrigger,
) -> None:
"""
Acquire a transaction-scoped advisory lock for the given composite trigger.

This serializes concurrent child trigger evaluations for the same compound
trigger, preventing a race condition where multiple transactions each see
only their own child firing and neither fires the parent.

The lock is automatically released when the transaction commits or rolls back.
"""
bind = session.get_bind()
if bind is None:
return

# Get the engine from either an Engine or Connection
engine: sa.Engine = bind if isinstance(bind, sa.Engine) else bind.engine # type: ignore[union-attr]
dialect = get_dialect(engine)

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}
)
Comment on lines +38 to +45

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

🧩 Analysis chain

🏁 Script executed:

cat -n src/prefect/server/events/models/composite_trigger_child_firing.py | head -80

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 3630


🏁 Script executed:

# Search for trigger.id type and imports in the file
rg -A 5 -B 5 "trigger\.id" src/prefect/server/events/models/composite_trigger_child_firing.py

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 2265


🏁 Script executed:

# Check UUID imports and usage in this file
rg "UUID|uuid" src/prefect/server/events/models/composite_trigger_child_firing.py

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 292


🏁 Script executed:

# Check the trigger model definition to see what type trigger.id is
fd composite_trigger -t f

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 361


🌐 Web query:

Python hash randomization PYTHONHASHSEED how does it affect hash(str()) consistency across processes

💡 Result:

hash(str_obj) in CPython is stable only within a single interpreter process unless you control the hash seed.

What hash randomization does

  • By default (PYTHONHASHSEED unset or set to random), CPython chooses a different per-process secret seed at startup, so hash("same text") will generally differ across separate runs / processes. (docs.python.org)
  • The randomization applies to str and bytes (and, at the implementation level, also covers the same hashing machinery used for memoryview). (docs.python.org)
  • The motivation is security (mitigating hash-collision DoS); CPython uses a keyed string/bytes hash (SipHash variants on most modern builds). (peps.python.org)

How PYTHONHASHSEED affects cross-process consistency

From the Python docs: (docs.python.org)

  • Unset / random → different hash seed each process → hash("x") not consistent across processes.
  • Set to an integer N (0…4294967295) → fixed seed → hash("x") becomes repeatable across processes that use the same seed.
  • Set to 0 → “disable hash randomization” (i.e., no per-process random seed), giving repeatable results across runs. (docs.python.org)

Example (Unix-like shells):

PYTHONHASHSEED=123 python -c 'print(hash("abc"))'
PYTHONHASHSEED=123 python -c 'print(hash("abc"))'  # same output
PYTHONHASHSEED=random python -c 'print(hash("abc"))'
PYTHONHASHSEED=random python -c 'print(hash("abc"))'  # likely different

Important caveats

  • Even with a fixed PYTHONHASHSEED, don’t treat hash() as a stable persistent identifier: results can differ across Python versions/build options/word size/platforms (the hash algorithm is an internal detail exposed via sys.hash_info.algorithm). (docs.python.org)
  • PYTHONHASHSEED must be set before the interpreter starts; changing os.environ["PYTHONHASHSEED"] inside a running process won’t retroactively change hash() behavior for that process. (stackoverflow.com)

Citations:


🏁 Script executed:

# Find CompositeTrigger definition
rg -A 20 "class CompositeTrigger" src/prefect/server/events/schemas/

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 1950


🏁 Script executed:

# Verify UUID.int attribute exists and works
python3 << 'EOF'
from uuid import UUID
test_uuid = UUID('550e8400-e29b-41d4-a716-446655440000')
print(f"UUID: {test_uuid}")
print(f"UUID.int: {test_uuid.int}")
print(f"UUID.int % (2**63): {test_uuid.int % (2**63)}")
print(f"Type of UUID.int: {type(test_uuid.int)}")
EOF

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 250


🏁 Script executed:

# Find the Trigger base class to confirm trigger.id type
rg -B 5 -A 30 "^class Trigger" src/prefect/server/events/schemas/automations.py | head -60

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 1909


Use a deterministic advisory-lock key across processes.

hash(str(trigger.id)) is randomized per process by default (controlled by PYTHONHASHSEED), so different workers compute different keys and skip mutual exclusion. Use the UUID's stable integer representation instead, which is identical across all processes and aligns with the comment's intent.

🔒 Proposed fix
-        lock_key = hash(str(trigger.id)) % (2**63)
+        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
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}
)
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 = 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 38 - 45, The current advisory lock key uses Python's randomized
hash(str(trigger.id)) which is not stable across processes; replace that with
the UUID's deterministic integer representation (use trigger.id.int) truncated
to fit a signed bigint (e.g. modulo 2**63 or bitmask) when computing lock_key in
composite_trigger_child_firing.py so all workers derive the same key; keep the
existing pg_advisory_xact_lock call using the new lock_key.

# SQLite doesn't support advisory locks, but SQLite also serializes writes
# at the database level, so the race condition is less likely to occur


@db_injector
async def upsert_child_firing(
db: PrefectDBInterface,
Expand Down Expand Up @@ -102,11 +136,22 @@ async def clear_child_firings(
session: AsyncSession,
trigger: CompositeTrigger,
firing_ids: Sequence[UUID],
) -> None:
await session.execute(
sa.delete(db.CompositeTriggerChildFiring).filter(
) -> 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)
)
Comment on lines +147 to 155

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, read the specific file and lines mentioned
echo "=== Reading the file at lines 147-155 ==="
sed -n '140,160p' src/prefect/server/events/models/composite_trigger_child_firing.py

echo -e "\n=== Full file to understand context ==="
wc -l src/prefect/server/events/models/composite_trigger_child_firing.py

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 996


🏁 Script executed:

#!/bin/bash
# Look at the ORM model definition
echo "=== Finding CompositeTriggerChildFiring model ==="
rg -n "class CompositeTriggerChildFiring" -A 20 src/

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 1771


🏁 Script executed:

#!/bin/bash
# Search for the function containing lines 147-155 to understand docstring and intent
echo "=== Finding the function context ==="
sed -n '130,175p' src/prefect/server/events/models/composite_trigger_child_firing.py

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 1065


🏁 Script executed:

#!/bin/bash
# Search for callers of this function to see what they expect
echo "=== Finding callers of the function ==="
rg -n "\.child_firing_id\|\.child_trigger_id" src/prefect/server/events/ -A 2 -B 2

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 125


Return deleted child_firing_id values, not child_trigger_id.

The docstring explicitly states the function returns child_firing_ids that were successfully deleted, and callers use this to detect races. Returning child_trigger_id violates the documented contract and breaks the intended comparison logic with the input firing_ids.

🧩 Proposed fix
-        .returning(db.CompositeTriggerChildFiring.child_trigger_id)
+        .returning(db.CompositeTriggerChildFiring.child_firing_id)
📝 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
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)
)
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)
)
🤖 Prompt for AI Agents
In `@src/prefect/server/events/models/composite_trigger_child_firing.py` around
lines 147 - 155, The delete query in the function in
composite_trigger_child_firing.py is returning
db.CompositeTriggerChildFiring.child_trigger_id but the function is documented
to return child_firing_ids; change the .returning(...) column to
db.CompositeTriggerChildFiring.child_firing_id so the result contains the
deleted child_firing_id values (and ensure any subsequent use of the query
result still extracts those child_firing_id values).


return set(result.scalars().all())
34 changes: 29 additions & 5 deletions src/prefect/server/events/triggers.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
read_automation,
)
from prefect.server.events.models.composite_trigger_child_firing import (
acquire_composite_trigger_lock,
clear_child_firings,
clear_old_child_firings,
get_child_firings,
Expand Down Expand Up @@ -65,12 +66,11 @@
from prefect.settings.context import get_current_settings

if TYPE_CHECKING:
import logging

from prefect.server.database.orm_models import ORMAutomationBucket

import logging

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

AutomationID: TypeAlias = UUID
TriggerID: TypeAlias = UUID
Expand Down Expand Up @@ -346,6 +346,11 @@ async def evaluate_composite_trigger(session: AsyncSession, firing: Firing) -> N
)
return

# Acquire an advisory lock to serialize concurrent evaluations for this
# compound trigger. This prevents a race condition where multiple child
# triggers fire concurrently and neither transaction sees both firings.
await acquire_composite_trigger_lock(session, trigger)

# If we're only looking within a certain time horizon, remove any older firings that
# should no longer be considered as satisfying this trigger
if trigger.within is not None:
Expand Down Expand Up @@ -382,8 +387,27 @@ async def evaluate_composite_trigger(session: AsyncSession, firing: Firing) -> N
},
)

# clear by firing id
await clear_child_firings(session, trigger, firing_ids=list(firing_ids))
# Clear by firing id, and only proceed if we won the race to claim them.
# This prevents double-firing when multiple workers evaluate concurrently.
deleted_ids = await clear_child_firings(
session, trigger, firing_ids=list(firing_ids)
)

if len(deleted_ids) != len(firing_ids):
logger.debug(
"Composite trigger %s skipped fire; expected to delete %s firings, "
"actually deleted %s (another worker likely claimed them)",
trigger.id,
len(firing_ids),
len(deleted_ids),
extra={
"automation": automation.id,
"trigger": trigger.id,
"expected_firing_ids": sorted(str(f) for f in firing_ids),
"deleted_firing_ids": sorted(str(f) for f in deleted_ids),
},
)
return

await fire(
session,
Expand Down
121 changes: 121 additions & 0 deletions tests/events/server/triggers/test_composite_triggers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import datetime
from datetime import timedelta
from typing import List
Expand Down Expand Up @@ -1624,3 +1625,123 @@ async def test_sequence_trigger_identical_event_triggers_only_one_fired_does_not
await triggers.reactive_evaluation(ingredients_buy)

act.assert_not_called()


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:
Comment on lines +1634 to +1639

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

🧩 Analysis chain

🏁 Script executed:

# First, locate the test file and check if it exists
fd "test_composite_triggers.py" --type f

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 141


🏁 Script executed:

# Get line count to understand file size
wc -l tests/events/server/triggers/test_composite_triggers.py

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 146


🏁 Script executed:

# Read the specific lines mentioned in the review (1634-1639)
sed -n '1634,1650p' tests/events/server/triggers/test_composite_triggers.py

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 771


🌐 Web query:

Ruff ARG002 pytest fixture unused arguments pattern

💡 Result:

If Ruff is flagging a pytest-injected fixture parameter as unused (often in class-based tests/fixtures, showing up as ARG002 for methods), you have three practical options:

  1. If you can rename the parameter, rename it to match Ruff’s “dummy” pattern (leading underscore). Ruff treats underscore-prefixed args as intentionally unused via lint.dummy-variable-rgx. [1]
class TestX:
    def test_something(self, _unused_fixture): ...

(Only works if pytest will still resolve the fixture name as written.)

  1. If the fixture is only needed for side effects, prefer usefixtures instead of an unused parameter (Ruff even has a dedicated pytest rule for this pattern: PT019). [2]
import pytest

`@pytest.mark.usefixtures`("db")
def test_something():
    ...
  1. If you must keep the parameter name for pytest injection (common case), suppress ARG002 either locally with noqa [3] or via per-file ignores: [4]
# local
def test_something(self, db):  # noqa: ARG002
    ...

# pyproject.toml / ruff.toml
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["ARG001", "ARG002"]

Notes

  • ARG002 is specifically “unused method argument” (instance methods). [1]
  • per-file-ignores is the standard Ruff mechanism to ignore rules by path/pattern. [4]

Sources: Ruff ARG002 rule docs [1], Ruff PT019 rule docs [2], Ruff suppression (noqa) docs [3], Ruff per-file-ignores setting docs [4].


🏁 Script executed:

# Read more lines to see complete method body and verify fixtures aren't used
sed -n '1634,1680p' tests/events/server/triggers/test_composite_triggers.py

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 1766


🌐 Web query:

pytest fixture parameter naming underscore prefix ARG002 pytest inject fixture by name

💡 Result:

In pytest, fixture injection is name-based: pytest looks at a test function’s parameter names and injects fixtures with the same names. Changing a parameter from db to _db means pytest will now look for a fixture literally named _db (and db will no longer be injected). [1]

If you’re adding a leading underscore only to silence a linter warning like Ruff ARG002 (“unused argument”), note that Ruff treats leading-underscore names as intentionally unused. [2] But that conflicts with pytest’s “inject-by-name” behavior unless your fixture is also named with the underscore.

Practical options:

  1. If you don’t need the fixture value (setup/side-effects only): use @pytest.mark.usefixtures instead of a function argument (this is the idiomatic pytest solution, and Ruff even has a dedicated rule recommending it). [3]

    `@pytest.mark.usefixtures`("db")
    def test_something():
        ...
  2. If you really want the argument to be _db, then the fixture must also be named _db (either define it with that name, or alias it with @pytest.fixture(name="...")). Pytest supports explicitly naming fixtures via the name= parameter. [1]

  3. Alternatively, keep the real fixture name (db) and silence/configure the linter (e.g., per-file ignores for tests, or inline # noqa: ARG002), so you don’t have to rename the parameter and break injection. [2]

Sources: pytest fixture injection & name= parameter [1]; Ruff ARG002 underscore guidance [2]; Ruff PT019 recommending usefixtures when the fixture argument is unused [3].


🏁 Script executed:

# Search for `@pytest.mark.usefixtures` usage in the test file
rg "@pytest.mark.usefixtures" tests/events/server/triggers/test_composite_triggers.py -A 2

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 68


🏁 Script executed:

# Also search for fixtures decorator definitions to understand fixture naming conventions
rg "@pytest.fixture" tests/events/server/triggers/test_composite_triggers.py -A 1 | head -30

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 821


🏁 Script executed:

# Check if cleared_buckets or cleared_automations are defined/used elsewhere in the test file
rg "cleared_buckets|cleared_automations" tests/events/server/triggers/test_composite_triggers.py

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 985


🏁 Script executed:

# Also check the broader context - search for similar fixture cleanup patterns in the file
rg "@pytest.fixture.*cleared" tests/events/server/triggers/test_composite_triggers.py -B 1 -A 3

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 68


🏁 Script executed:

# Search for fixture definitions in conftest.py files
fd "conftest.py" --type f | xargs grep -l "cleared_buckets\|cleared_automations" 2>/dev/null

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 158


🏁 Script executed:

# Search the entire test directory for fixture definitions
rg "def cleared_buckets|def cleared_automations" tests/ -A 3

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 648


🏁 Script executed:

# Check if there's a conftest in the test directory or parent
find tests/events/server/triggers -name "conftest.py" -exec cat {} \;

Repository: qodo-benchmark/prefect-combined-coderabbit

Length of output: 2112


Use @pytest.mark.usefixtures for side-effect fixtures instead of renaming parameters.

cleared_buckets and cleared_automations trigger side effects (clearing database/resetting triggers) but aren't used within the method body. Ruff flags this with ARG002. However, renaming the parameters with an underscore prefix will break the test because pytest resolves fixtures by exact name matching—it would look for fixtures named _cleared_buckets and _cleared_automations, which don't exist.

The correct approach for side-effect-only fixtures is to use the @pytest.mark.usefixtures decorator:

`@pytest.mark.usefixtures`("cleared_buckets", "cleared_automations")
async def compound_automation_concurrent(
    self,
    automations_session: AsyncSession,
) -> Automation:

This is the idiomatic pytest pattern and aligns with Ruff's PT019 recommendation.

🧰 Tools
🪛 Ruff (0.14.14)

1637-1637: Unused method argument: cleared_buckets

(ARG002)


1638-1638: Unused method argument: cleared_automations

(ARG002)

🤖 Prompt for AI Agents
In `@tests/events/server/triggers/test_composite_triggers.py` around lines 1634 -
1639, The test function compound_automation_concurrent currently takes
side-effect-only fixtures cleared_buckets and cleared_automations as parameters
which triggers ARG002; instead remove those unused parameters and apply
pytest.mark.usefixtures("cleared_buckets", "cleared_automations") to the
compound_automation_concurrent definition so pytest still runs the fixtures'
side effects while keeping the function signature as async def
compound_automation_concurrent(self, automations_session: AsyncSession) ->
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
Loading