Repository: PrefectHQ/prefect

Issue:
Reconsider an optimization for triggers when there are no upstream states
Right now, triggers are skipped if there are no upstream states. This was originally done because it seemed like a simple optimization -- if there are no upstream states, there's no need to compare them to a trigger function. However, `manual_only` should fire regardless, so we should remove this optimization. The one catch is to make sure that all triggers properly handle the (very likely) scenario of receiving an empty set of upstream states.


Discussion:

Resolving patch:
diff --git a/src/prefect/engine/task_runner.py b/src/prefect/engine/task_runner.py
--- a/src/prefect/engine/task_runner.py
+++ b/src/prefect/engine/task_runner.py
@@ -361,8 +361,7 @@ def check_task_trigger(
         self, state: State, upstream_states: Dict[Edge, State]
     ) -> State:
         """
-        Checks if the task's trigger function passes. If the upstream_states is empty,
-        then the trigger is not called.
+        Checks if the task's trigger function passes.
 
         Args:
             - state (State): the current state of this task
@@ -383,9 +382,7 @@ def check_task_trigger(
                 all_states.add(upstream_state)
 
         try:
-            if not upstream_states:
-                return state
-            elif not self.task.trigger(all_states):
+            if not self.task.trigger(all_states):
                 raise signals.TRIGGERFAIL(message="Trigger failed")
 
         except signals.PrefectStateSignal as exc:
diff --git a/src/prefect/triggers.py b/src/prefect/triggers.py
--- a/src/prefect/triggers.py
+++ b/src/prefect/triggers.py
@@ -80,7 +80,7 @@ def any_successful(upstream_states: Set["state.State"]) -> bool:
         - upstream_states (set[State]): the set of all upstream states
     """
 
-    if not any(s.is_successful() for s in upstream_states):
+    if upstream_states and not any(s.is_successful() for s in upstream_states):
         raise signals.TRIGGERFAIL(
             'Trigger was "any_successful" but none of the upstream tasks succeeded.'
         )
@@ -96,7 +96,7 @@ def any_failed(upstream_states: Set["state.State"]) -> bool:
         - upstream_states (set[State]): the set of all upstream states
     """
 
-    if not any(s.is_failed() for s in upstream_states):
+    if upstream_states and not any(s.is_failed() for s in upstream_states):
         raise signals.TRIGGERFAIL(
             'Trigger was "any_failed" but none of the upstream tasks failed.'
         )
@@ -131,6 +131,9 @@ def _some_failed(upstream_states: Set["state.State"]) -> bool:
         Returns:
             - bool: whether the trigger thresolds were met
         """
+        if not upstream_states:
+            return True
+
         # scale conversions
         num_failed = len([s for s in upstream_states if s.is_failed()])
         num_states = len(upstream_states)
@@ -180,6 +183,9 @@ def _some_successful(upstream_states: Set["state.State"]) -> bool:
         Returns:
             - bool: whether the trigger thresolds were met
         """
+        if not upstream_states:
+            return True
+
         # scale conversions
         num_success = len([s for s in upstream_states if s.is_successful()])
         num_states = len(upstream_states)


Test patch:
diff --git a/tests/engine/test_task_runner.py b/tests/engine/test_task_runner.py
--- a/tests/engine/test_task_runner.py
+++ b/tests/engine/test_task_runner.py
@@ -585,8 +585,11 @@ def test_manual_only(self):
     def test_manual_only_empty(self):
         task = Task(trigger=prefect.triggers.manual_only)
         state = Pending()
-        new_state = TaskRunner(task).check_task_trigger(state=state, upstream_states={})
-        assert new_state is state
+        with pytest.raises(ENDRUN) as exc:
+            new_state = TaskRunner(task).check_task_trigger(
+                state=state, upstream_states={}
+            )
+        assert isinstance(exc.value.state, Paused)
 
     def test_manual_passes_when_context_is_resume(self):
         task = Task(trigger=prefect.triggers.manual_only)
diff --git a/tests/test_triggers.py b/tests/test_triggers.py
--- a/tests/test_triggers.py
+++ b/tests/test_triggers.py
@@ -108,6 +108,16 @@ def test_manual_only_with_resume_state():
         triggers.manual_only({Success(), Resume()})
 
 
+def test_manual_only_with_empty_set():
+    with pytest.raises(signals.PAUSE):
+        triggers.manual_only(set())
+
+
+def test_manual_only_with_empty_set_and_resume_in_context():
+    with context(resume=True):
+        assert triggers.manual_only(set()) is True
+
+
 def test_all_finished_with_all_success():
     assert triggers.all_finished(generate_states(success=3))
 
@@ -301,3 +311,25 @@ def test_some_successful_is_pickleable():
 
     with pytest.raises(signals.TRIGGERFAIL):
         new_trigger(generate_states(success=1, pending=18))
+
+
+@pytest.mark.parametrize(
+    "trigger",
+    [
+        triggers.all_failed,
+        triggers.all_finished,
+        triggers.all_successful,
+        triggers.always_run,
+        triggers.any_failed,
+        triggers.any_successful,
+    ],
+)
+def test_standard_triggers_return_true_for_empty_upstream(trigger):
+    assert trigger(set()) is True
+
+
+@pytest.mark.parametrize("func", [triggers.some_failed, triggers.some_successful])
+def test_stateful_triggers_return_true_for_empty_upstream(func):
+    assert func()(set()) is True
+    assert func(at_least=2, at_most=4)(set()) is True
+    assert func(at_least=0.1, at_most=0.5)(set()) is True