Skip to content
Merged
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
1 change: 1 addition & 0 deletions airflow/models/taskinstance.py
Original file line number Diff line number Diff line change
Expand Up @@ -1806,6 +1806,7 @@ def _handle_reschedule(
actual_start_date,
self.end_date,
reschedule_exception.reschedule_date,
self.map_index,
)
)

Expand Down
1 change: 1 addition & 0 deletions airflow/models/taskreschedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ def query_for_task_instance(task_instance, descending=False, session=None, try_n
TR.dag_id == task_instance.dag_id,
TR.task_id == task_instance.task_id,
TR.run_id == task_instance.run_id,
TR.map_index == task_instance.map_index,
TR.try_number == try_number,
)
if descending:
Expand Down
3 changes: 1 addition & 2 deletions airflow/serialization/serialized_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -642,8 +642,7 @@ def task_type(self, task_type: str):

@classmethod
def serialize_mapped_operator(cls, op: MappedOperator) -> Dict[str, Any]:
serialized_op = cls._serialize_node(op, include_deps=op.deps is MappedOperator.deps_for(BaseOperator))

serialized_op = cls._serialize_node(op, include_deps=op.deps != MappedOperator.deps_for(BaseOperator))
# Handle expand_input and op_kwargs_expand_input.
expansion_kwargs = op._get_specified_expand_input()
serialized_op[op._expand_input_attr] = {
Expand Down
11 changes: 10 additions & 1 deletion airflow/ti_deps/deps/ready_to_reschedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ def _get_dep_statuses(self, ti, session, dep_context):
considered as passed. This dependency fails if the latest reschedule
request's reschedule date is still in future.
"""
if not getattr(ti.task, "reschedule", False):
is_mapped = ti.task.is_mapped
if not is_mapped and not getattr(ti.task, "reschedule", False):
Comment thread
ephraimbuddy marked this conversation as resolved.
Outdated
# Mapped sensors don't have the reschedule property (it can only
# be calculated after unmapping), so we don't check them here.
# They are handled below by checking TaskReschedule instead.
yield self._passing_status(reason="Task is not in reschedule mode.")
return

Expand All @@ -62,6 +66,11 @@ def _get_dep_statuses(self, ti, session, dep_context):
.first()
)
if not task_reschedule:
# Because mapped sensors don't have the reschedule property, here's the last resort
# and we need a slightly different passing reason
if is_mapped:
yield self._passing_status(reason="The task is mapped and not in reschedule mode")
return
Comment thread
ephraimbuddy marked this conversation as resolved.
Outdated
yield self._passing_status(reason="There is no reschedule request for this task instance.")
return

Expand Down
217 changes: 217 additions & 0 deletions tests/models/test_taskinstance.py
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,174 @@ def run_ti_and_assert(
done, fail = True, False
run_ti_and_assert(date4, date3, date4, 60, State.SUCCESS, 3, 0)

def test_mapped_reschedule_handling(self, dag_maker):
"""
Test that mapped task reschedules are handled properly
"""
# Return values of the python sensor callable, modified during tests
done = False
fail = False

def func():
if fail:
raise AirflowException()
return done

with dag_maker(dag_id='test_reschedule_handling') as dag:

task = PythonSensor.partial(
task_id='test_reschedule_handling_sensor',
mode='reschedule',
python_callable=func,
retries=1,
retry_delay=datetime.timedelta(seconds=0),
).expand(poke_interval=[0])

ti = dag_maker.create_dagrun(execution_date=timezone.utcnow()).task_instances[0]

ti.task = task
assert ti._try_number == 0
assert ti.try_number == 1

def run_ti_and_assert(
run_date,
expected_start_date,
expected_end_date,
expected_duration,
expected_state,
expected_try_number,
expected_task_reschedule_count,
):
ti.refresh_from_task(task)
with freeze_time(run_date):
try:
ti.run()
except AirflowException:
if not fail:
raise
ti.refresh_from_db()
assert ti.state == expected_state
assert ti._try_number == expected_try_number
assert ti.try_number == expected_try_number + 1
assert ti.start_date == expected_start_date
assert ti.end_date == expected_end_date
assert ti.duration == expected_duration
trs = TaskReschedule.find_for_task_instance(ti)
assert len(trs) == expected_task_reschedule_count

date1 = timezone.utcnow()
date2 = date1 + datetime.timedelta(minutes=1)
date3 = date2 + datetime.timedelta(minutes=1)
date4 = date3 + datetime.timedelta(minutes=1)

# Run with multiple reschedules.
# During reschedule the try number remains the same, but each reschedule is recorded.
# The start date is expected to remain the initial date, hence the duration increases.
# When finished the try number is incremented and there is no reschedule expected
# for this try.

done, fail = False, False
run_ti_and_assert(date1, date1, date1, 0, State.UP_FOR_RESCHEDULE, 0, 1)

done, fail = False, False
run_ti_and_assert(date2, date1, date2, 60, State.UP_FOR_RESCHEDULE, 0, 2)

done, fail = False, False
run_ti_and_assert(date3, date1, date3, 120, State.UP_FOR_RESCHEDULE, 0, 3)

done, fail = True, False
run_ti_and_assert(date4, date1, date4, 180, State.SUCCESS, 1, 0)

# Clear the task instance.
dag.clear()
ti.refresh_from_db()
assert ti.state == State.NONE
assert ti._try_number == 1

# Run again after clearing with reschedules and a retry.
# The retry increments the try number, and for that try no reschedule is expected.
# After the retry the start date is reset, hence the duration is also reset.

done, fail = False, False
run_ti_and_assert(date1, date1, date1, 0, State.UP_FOR_RESCHEDULE, 1, 1)

done, fail = False, True
run_ti_and_assert(date2, date1, date2, 60, State.UP_FOR_RETRY, 2, 0)

done, fail = False, False
run_ti_and_assert(date3, date3, date3, 0, State.UP_FOR_RESCHEDULE, 2, 1)

done, fail = True, False
run_ti_and_assert(date4, date3, date4, 60, State.SUCCESS, 3, 0)

@pytest.mark.usefixtures('test_pool')
def test_mapped_task_reschedule_handling_clear_reschedules(self, dag_maker):
"""
Test that mapped task reschedules clearing are handled properly
"""
# Return values of the python sensor callable, modified during tests
done = False
fail = False

def func():
if fail:
raise AirflowException()
return done

with dag_maker(dag_id='test_reschedule_handling') as dag:
task = PythonSensor.partial(
task_id='test_reschedule_handling_sensor',
mode='reschedule',
python_callable=func,
retries=1,
retry_delay=datetime.timedelta(seconds=0),
pool='test_pool',
).expand(poke_interval=[0])
ti = dag_maker.create_dagrun(execution_date=timezone.utcnow()).task_instances[0]
ti.task = task
assert ti._try_number == 0
assert ti.try_number == 1

def run_ti_and_assert(
run_date,
expected_start_date,
expected_end_date,
expected_duration,
expected_state,
expected_try_number,
expected_task_reschedule_count,
):
ti.refresh_from_task(task)
with freeze_time(run_date):
try:
ti.run()
except AirflowException:
if not fail:
raise
ti.refresh_from_db()
assert ti.state == expected_state
assert ti._try_number == expected_try_number
assert ti.try_number == expected_try_number + 1
assert ti.start_date == expected_start_date
assert ti.end_date == expected_end_date
assert ti.duration == expected_duration
trs = TaskReschedule.find_for_task_instance(ti)
assert len(trs) == expected_task_reschedule_count

date1 = timezone.utcnow()

done, fail = False, False
run_ti_and_assert(date1, date1, date1, 0, State.UP_FOR_RESCHEDULE, 0, 1)

# Clear the task instance.
dag.clear()
ti.refresh_from_db()
assert ti.state == State.NONE
assert ti._try_number == 0
# Check that reschedules for ti have also been cleared.
trs = TaskReschedule.find_for_task_instance(ti)
assert not trs

@pytest.mark.usefixtures('test_pool')
def test_reschedule_handling_clear_reschedules(self, dag_maker):
"""
Expand Down Expand Up @@ -2541,6 +2709,55 @@ def timeout():
assert ti.state == State.FAILED


@pytest.mark.parametrize("mode", ["poke", "reschedule"])
@pytest.mark.parametrize("retries", [0, 1])
def test_mapped_sensor_timeout(mode, retries, dag_maker):
"""
Test that AirflowSensorTimeout does not cause mapped sensor to retry.
"""

def timeout():
raise AirflowSensorTimeout

mock_on_failure = mock.MagicMock()
with dag_maker(dag_id=f'test_sensor_timeout_{mode}_{retries}'):
PythonSensor.partial(
task_id='test_raise_sensor_timeout',
python_callable=timeout,
on_failure_callback=mock_on_failure,
retries=retries,
).expand(mode=[mode])
ti = dag_maker.create_dagrun(execution_date=timezone.utcnow()).task_instances[0]

with pytest.raises(AirflowSensorTimeout):
ti.run()

assert mock_on_failure.called
assert ti.state == State.FAILED


@pytest.mark.parametrize("mode", ["poke", "reschedule"])
@pytest.mark.parametrize("retries", [0, 1])
def test_mapped_sensor_works(mode, retries, dag_maker):
"""
Test that mapped sensors reaches success state.
"""

def timeout(ti):
return 1

with dag_maker(dag_id=f'test_sensor_timeout_{mode}_{retries}'):
PythonSensor.partial(
task_id='test_raise_sensor_timeout',
python_callable=timeout,
retries=retries,
).expand(mode=[mode])
ti = dag_maker.create_dagrun().task_instances[0]

ti.run()
assert ti.state == State.SUCCESS


class TestTaskInstanceRecordTaskMapXComPush:
"""Test TI.xcom_push() correctly records return values for task-mapping."""

Expand Down
28 changes: 28 additions & 0 deletions tests/serialization/test_dag_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
from airflow.models.xcom import XCOM_RETURN_KEY, XCom
from airflow.operators.bash import BashOperator
from airflow.security import permissions
from airflow.sensors.bash import BashSensor
from airflow.serialization.json_schema import load_dag_schema_dict
from airflow.serialization.serialized_objects import (
DagDependency,
Expand Down Expand Up @@ -1270,6 +1271,7 @@ def test_deps_sorted(self):
task1 >> task2

serialize_op = SerializedBaseOperator.serialize_operator(dag.task_dict["task1"])

deps = serialize_op["deps"]
assert deps == [
'airflow.ti_deps.deps.not_in_retry_period_dep.NotInRetryPeriodDep',
Expand Down Expand Up @@ -1611,6 +1613,21 @@ def poke(self, context: Context):
assert serialized_op.reschedule == (mode == "reschedule")
assert op.deps == serialized_op.deps

@pytest.mark.parametrize("mode", ["poke", "reschedule"])
def test_serialize_mapped_sensor_has_reschedule_dep(self, mode):
from airflow.sensors.base import BaseSensorOperator

class DummySensor(BaseSensorOperator):
def poke(self, context: Context):
return False

op = DummySensor.partial(task_id='dummy', mode=mode).expand(poke_interval=[23])

blob = SerializedBaseOperator.serialize_mapped_operator(op)
assert "deps" in blob

assert 'airflow.ti_deps.deps.ready_to_reschedule.ReadyToRescheduleDep' in blob['deps']

@pytest.mark.parametrize(
"passed_success_callback, expected_value",
[
Expand Down Expand Up @@ -1980,6 +1997,17 @@ def test_operator_expand_deserialized_unmap():
assert deserialize(serialize(mapped)).unmap(None) == deserialize(serialize(normal))


def test_sensor_expand_deserialized_unmap():
"""Unmap a deserialized mapped sensor should be similar to deserializing a non-mapped sensor"""
normal = BashSensor(task_id='a', bash_command=[1, 2], mode='reschedule')
mapped = BashSensor.partial(task_id='a', mode='reschedule').expand(bash_command=[1, 2])

serialize = SerializedBaseOperator._serialize

deserialize = SerializedBaseOperator.deserialize_operator
assert deserialize(serialize(mapped)).unmap(None) == deserialize(serialize(normal))


def test_task_resources_serde():
"""
Test task resources serialization/deserialization.
Expand Down
Loading