Repository: PrefectHQ/prefect

Issue:
Trigger for Mapped Task that resolves to "failed" only if more than x tasks fail
For a particular mapped task, I may not expect 100% of the generated tasks to succeed. Further, I may not care if they don't.

It would be great to have a trigger that allowed me to specify this. For example:

```python
from prefect import task, Flow
from prefect.triggers import some_failed

@task(trigger=some_failed('>', 3))
def a_mapped_task(some_input):
    pass
```


Discussion:
Love this idea. Maybe `some_failed(at_least=0.3, at_most=0.6)` to allow flexibility 
Decimal: percent of tasks
Integer: number of tasks
Love that ☝️ 
and `some_successful()` equivalently.


Resolving patch:
diff --git a/src/prefect/serialization/task.py b/src/prefect/serialization/task.py
--- a/src/prefect/serialization/task.py
+++ b/src/prefect/serialization/task.py
@@ -98,6 +98,8 @@ class Meta:
             prefect.triggers.all_failed,
             prefect.triggers.any_successful,
             prefect.triggers.any_failed,
+            prefect.triggers.some_failed,
+            prefect.triggers.some_successful,
         ],
         # don't reject custom functions, just leave them as strings
         reject_invalid=False,
diff --git a/src/prefect/triggers.py b/src/prefect/triggers.py
--- a/src/prefect/triggers.py
+++ b/src/prefect/triggers.py
@@ -2,7 +2,7 @@
 Triggers are functions that determine if task state should change based on
 the state of preceding tasks.
 """
-from typing import Callable, Set
+from typing import Callable, Set, Union
 
 from prefect import context
 from prefect.engine import signals, state
@@ -103,5 +103,103 @@ def any_failed(upstream_states: Set["state.State"]) -> bool:
     return True
 
 
+def some_failed(
+    at_least: Union[int, float] = None, at_most: Union[int, float] = None
+) -> Callable[[Set["state.State"]], bool]:
+    """
+    Runs if some amount of upstream tasks failed. This amount can be specified as an upper bound (`at_most`) or
+    a lower bound (`at_least`), and can be provided as an absolute number or a percentage of upstream tasks.
+
+    Note that `SKIPPED` tasks are considered successes and `TRIGGER_FAILED` tasks are considered failures.
+
+    Args:
+        - at_least (Union[int, float], optional): the minimum number of upstream failures that must occur for
+            this task to run.  If the provided number is less than 0, it will be interpreted as a percentage, otherwise as an
+            absolute number.
+        - at_most (Union[int, float], optional): the maximum number of upstream failures to allow for
+            this task to run.  If the provided number is less than 0, it will be interpreted as a percentage, otherwise as an
+            absolute number.
+    """
+
+    def _some_failed(upstream_states: Set["state.State"]) -> bool:
+        """
+        The underlying trigger function.
+
+        Args:
+            - upstream_states (set[State]): the set of all upstream states
+
+        Returns:
+            - bool: whether the trigger thresolds were met
+        """
+        # scale conversions
+        num_failed = len([s for s in upstream_states if s.is_failed()])
+        num_states = len(upstream_states)
+        if at_least is not None:
+            min_num = (num_states * at_least) if at_least < 1 else at_least
+        else:
+            min_num = 0
+        if at_most is not None:
+            max_num = (num_states * at_most) if at_most < 1 else at_most
+        else:
+            max_num = num_states
+
+        if not (min_num <= num_failed <= max_num):
+            raise signals.TRIGGERFAIL(
+                'Trigger was "some_failed" but thresholds were not met.'
+            )
+        return True
+
+    return _some_failed
+
+
+def some_successful(
+    at_least: Union[int, float] = None, at_most: Union[int, float] = None
+) -> Callable[[Set["state.State"]], bool]:
+    """
+    Runs if some amount of upstream tasks succeed. This amount can be specified as an upper bound (`at_most`) or
+    a lower bound (`at_least`), and can be provided as an absolute number or a percentage of upstream tasks.
+
+    Note that `SKIPPED` tasks are considered successes and `TRIGGER_FAILED` tasks are considered failures.
+
+    Args:
+        - at_least (Union[int, float], optional): the minimum number of upstream successes that must occur for
+            this task to run.  If the provided number is less than 0, it will be interpreted as a percentage, otherwise as an
+            absolute number.
+        - at_most (Union[int, float], optional): the maximum number of upstream successes to allow for
+            this task to run.  If the provided number is less than 0, it will be interpreted as a percentage, otherwise as an
+            absolute number.
+    """
+
+    def _some_successful(upstream_states: Set["state.State"]) -> bool:
+        """
+        The underlying trigger function.
+
+        Args:
+            - upstream_states (set[State]): the set of all upstream states
+
+        Returns:
+            - bool: whether the trigger thresolds were met
+        """
+        # scale conversions
+        num_success = len([s for s in upstream_states if s.is_successful()])
+        num_states = len(upstream_states)
+        if at_least is not None:
+            min_num = (num_states * at_least) if at_least < 1 else at_least
+        else:
+            min_num = 0
+        if at_most is not None:
+            max_num = (num_states * at_most) if at_most < 1 else at_most
+        else:
+            max_num = num_states
+
+        if not (min_num <= num_success <= max_num):
+            raise signals.TRIGGERFAIL(
+                'Trigger was "some_successful" but thresholds were not met.'
+            )
+        return True
+
+    return _some_successful
+
+
 # aliases
 always_run = all_finished  # type: Callable[[Set["state.State"]], bool]


Test patch:
diff --git a/tests/serialization/test_tasks.py b/tests/serialization/test_tasks.py
--- a/tests/serialization/test_tasks.py
+++ b/tests/serialization/test_tasks.py
@@ -121,6 +121,8 @@ def test_deserialize_parameter_requires_name():
         prefect.triggers.all_failed,
         prefect.triggers.any_successful,
         prefect.triggers.any_failed,
+        prefect.triggers.some_failed,
+        prefect.triggers.some_successful,
     ],
 )
 def test_trigger(trigger):
diff --git a/tests/test_triggers.py b/tests/test_triggers.py
--- a/tests/test_triggers.py
+++ b/tests/test_triggers.py
@@ -1,3 +1,4 @@
+import cloudpickle
 import pytest
 
 from prefect import context, triggers
@@ -160,3 +161,143 @@ def test_any_failed_with_some_failed_and_1_success():
 def test_any_failed_with_all_success():
     with pytest.raises(signals.TRIGGERFAIL):
         triggers.any_failed(generate_states(success=3))
+
+
+@pytest.mark.parametrize(
+    "states",
+    [
+        generate_states(failed=3),
+        generate_states(failed=1, success=2),
+        generate_states(),
+    ],
+)
+def test_some_failed_with_no_args(states):
+    assert triggers.some_failed(states)
+
+
+def test_some_failed_error_msg():
+    trigger = triggers.some_failed(at_least=23)
+    with pytest.raises(signals.TRIGGERFAIL) as exc:
+        trigger(generate_states(success=1))
+
+    assert "some_failed" in str(exc.value)
+
+
+def test_some_failed_with_one_arg():
+    trigger = triggers.some_failed(at_least=4)
+    assert trigger(generate_states(failed=4, skipped=2))
+
+    with pytest.raises(signals.TRIGGERFAIL):
+        trigger(generate_states(failed=3))
+
+    trigger = triggers.some_failed(at_most=4)
+    assert trigger(generate_states(failed=4, skipped=2))
+
+    with pytest.raises(signals.TRIGGERFAIL):
+        trigger(generate_states(failed=5))
+
+
+@pytest.mark.parametrize("at_least,at_most", [(1, 1), (0.2, 1), (0.01, 20)])
+def test_some_failed_with_all_success(at_least, at_most):
+    trigger = triggers.some_failed(at_least=at_least, at_most=at_most)
+    with pytest.raises(signals.TRIGGERFAIL):
+        trigger(generate_states(success=3))
+
+
+def test_some_failed_does_the_math():
+    trigger = triggers.some_failed(at_least=0.1, at_most=2)
+    assert trigger(generate_states(failed=2, pending=18))
+
+    with pytest.raises(signals.TRIGGERFAIL):
+        trigger(generate_states(failed=1, pending=18))
+
+    with pytest.raises(signals.TRIGGERFAIL):
+        trigger(generate_states(failed=3, pending=18))
+
+    trigger = triggers.some_failed(at_least=2, at_most=0.1)
+    assert trigger(generate_states(failed=2, pending=18))
+
+    with pytest.raises(signals.TRIGGERFAIL):
+        trigger(generate_states(failed=1, pending=18))
+
+    with pytest.raises(signals.TRIGGERFAIL):
+        trigger(generate_states(failed=3, pending=18))
+
+
+def test_some_failed_is_pickleable():
+    trigger = triggers.some_failed(at_least=0.1, at_most=2)
+    new_trigger = cloudpickle.loads(cloudpickle.dumps(trigger))
+    assert new_trigger(generate_states(failed=2, pending=18))
+
+    with pytest.raises(signals.TRIGGERFAIL):
+        new_trigger(generate_states(failed=1, pending=18))
+
+
+@pytest.mark.parametrize(
+    "states",
+    [
+        generate_states(failed=3),
+        generate_states(failed=1, success=2),
+        generate_states(),
+    ],
+)
+def test_some_successful_with_no_args(states):
+    assert triggers.some_successful(states)
+
+
+def test_some_successful_error_msg():
+    trigger = triggers.some_successful(at_least=23)
+    with pytest.raises(signals.TRIGGERFAIL) as exc:
+        trigger(generate_states(success=1))
+
+    assert "some_successful" in str(exc.value)
+
+
+def test_some_successful_with_one_arg():
+    trigger = triggers.some_successful(at_least=4)
+    assert trigger(generate_states(success=4, failed=2))
+
+    with pytest.raises(signals.TRIGGERFAIL):
+        trigger(generate_states(success=3))
+
+    trigger = triggers.some_successful(at_most=4)
+    assert trigger(generate_states(failed=4, pending=5))
+
+    with pytest.raises(signals.TRIGGERFAIL):
+        trigger(generate_states(success=5))
+
+
+@pytest.mark.parametrize("at_least,at_most", [(1, 1), (0.2, 1), (0.01, 20)])
+def test_some_successful_with_all_failed(at_least, at_most):
+    trigger = triggers.some_successful(at_least=at_least, at_most=at_most)
+    with pytest.raises(signals.TRIGGERFAIL):
+        trigger(generate_states(failed=3))
+
+
+def test_some_successful_does_the_math():
+    trigger = triggers.some_successful(at_least=0.1, at_most=2)
+    assert trigger(generate_states(success=2, pending=18))
+
+    with pytest.raises(signals.TRIGGERFAIL):
+        trigger(generate_states(success=1, pending=18))
+
+    with pytest.raises(signals.TRIGGERFAIL):
+        trigger(generate_states(success=3, pending=18))
+
+    trigger = triggers.some_successful(at_least=2, at_most=0.1)
+    assert trigger(generate_states(success=2, pending=18))
+
+    with pytest.raises(signals.TRIGGERFAIL):
+        trigger(generate_states(success=1, pending=18))
+
+    with pytest.raises(signals.TRIGGERFAIL):
+        trigger(generate_states(success=3, pending=18))
+
+
+def test_some_successful_is_pickleable():
+    trigger = triggers.some_successful(at_least=0.1, at_most=2)
+    new_trigger = cloudpickle.loads(cloudpickle.dumps(trigger))
+    assert new_trigger(generate_states(success=2, pending=18))
+
+    with pytest.raises(signals.TRIGGERFAIL):
+        new_trigger(generate_states(success=1, pending=18))