Put receptor work-unit operations behind an interface with a fake

Streaming results, reading status, canceling and releasing a work unit
now go through ReceptorWork instead of raw receptorctl commands. The
process phase, error handling, release, same-controller adoption and the
work-unit reaper take a ReceptorWork. Behavior is unchanged.

awx.main.tests.fake_receptor provides FakeReceptorWork, which serves the
ansible-runner worker line protocol from canned events and can break a
stream partway, fail status calls or lose a unit. New functional tests
drive the real reattach, dedup and finalize code against it, including
one that records today's behavior when a results stream breaks: a unit
that succeeded is finalized failed.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Meyers
2026-10-13 05:05:54 -04:00
co-authored by Claude
parent 28b0bc8ae3
commit 76c3a1f61d
8 changed files with 388 additions and 70 deletions
+4 -4
View File
@@ -810,9 +810,9 @@ class BaseTask(object):
if not res: if not res:
# res is None when quota exceeded or other early-return condition. # res is None when quota exceeded or other early-return condition.
# Must release work unit here before returning, or it will leak. # Must release work unit here before returning, or it will leak.
if receptor_job and getattr(receptor_job, 'receptor_ctl', None): if receptor_job and getattr(receptor_job, 'work', None):
try: try:
receptor_job._receptor_release_work(receptor_job.receptor_ctl, 'error') receptor_job._receptor_release_work(receptor_job.work, 'error')
except Exception: except Exception:
logger.exception(f'Failed to release work unit {getattr(receptor_job, "unit_id", "unknown")} on early return') logger.exception(f'Failed to release work unit {getattr(receptor_job, "unit_id", "unknown")} on early return')
return return
@@ -857,9 +857,9 @@ class BaseTask(object):
self.instance = _finalize_job_run(self.model, pk, self.runner_callback, status) self.instance = _finalize_job_run(self.model, pk, self.runner_callback, status)
finally: finally:
# Guarantee work unit release even if finalization throws # Guarantee work unit release even if finalization throws
if receptor_job and getattr(receptor_job, 'receptor_ctl', None): if receptor_job and getattr(receptor_job, 'work', None):
try: try:
receptor_job._receptor_release_work(receptor_job.receptor_ctl, status) receptor_job._receptor_release_work(receptor_job.work, status)
except Exception: except Exception:
logger.exception(f'Failed to release work unit {getattr(receptor_job, "unit_id", "unknown")}') logger.exception(f'Failed to release work unit {getattr(receptor_job, "unit_id", "unknown")}')
+48 -15
View File
@@ -181,6 +181,38 @@ def get_receptor_ctl(config_data=None):
return ReceptorControl(receptor_sockfile) return ReceptorControl(receptor_sockfile)
class ReceptorWork:
"""The receptor work-unit operations AWX uses after a job is submitted.
Streaming results, reading status, canceling and releasing go through this class
instead of raw receptorctl commands, so a test can stand in a fake work unit
(see awx.main.tests.fake_receptor) and drive the real streaming, adoption and
finalize code without a receptor mesh.
"""
def __init__(self, receptor_ctl):
self.receptor_ctl = receptor_ctl
def list(self):
return self.receptor_ctl.simple_command('work list')
def status(self, unit_id):
return self.receptor_ctl.simple_command(f'work status {unit_id}')
def results(self, unit_id, startpos=None):
"""Return (socket, file) streaming the unit's ansible-runner output."""
kwargs = dict(return_socket=True, return_sockfile=True)
if startpos is not None:
kwargs['startpos'] = startpos
return self.receptor_ctl.get_work_results(unit_id, **kwargs)
def cancel(self, unit_id):
return self.receptor_ctl.simple_command(f'work cancel {unit_id}')
def release(self, unit_id):
return self.receptor_ctl.simple_command(f'work release {unit_id}')
def adopt_remote_work(receptor_ctl, node, unit_id, config_data=None): def adopt_remote_work(receptor_ctl, node, unit_id, config_data=None):
"""Adopt a running work unit from a remote node, passing TLS/signwork when configured. """Adopt a running work unit from a remote node, passing TLS/signwork when configured.
@@ -446,7 +478,7 @@ class AWXReceptorJob:
self.receptor_ctl = get_receptor_ctl(self.config_data) self.receptor_ctl = get_receptor_ctl(self.config_data)
return self._run_internal(self.receptor_ctl) return self._run_internal(self.receptor_ctl)
def _receptor_release_work(self, receptor_ctl: ReceptorControl, status: str) -> None: def _receptor_release_work(self, work: ReceptorWork, status: str) -> None:
if self.unit_id is None: if self.unit_id is None:
return return
@@ -457,14 +489,15 @@ class AWXReceptorJob:
return return
try: try:
receptor_ctl.simple_command(f"work release {self.unit_id}") work.release(self.unit_id)
logger.debug(f"Released work unit {self.unit_id}.") logger.debug(f"Released work unit {self.unit_id}.")
except Exception: except Exception:
logger.exception(f"Error releasing work unit {self.unit_id}.") logger.exception(f"Error releasing work unit {self.unit_id}.")
def _run_internal(self, receptor_ctl): def _run_internal(self, receptor_ctl):
self.work = ReceptorWork(receptor_ctl)
self._transmit_phase(receptor_ctl) self._transmit_phase(receptor_ctl)
return self._process_phase(receptor_ctl) return self._process_phase(self.work)
def _transmit_phase(self, receptor_ctl): def _transmit_phase(self, receptor_ctl):
"""Submit work to receptor and wait for the transmit thread to finish. """Submit work to receptor and wait for the transmit thread to finish.
@@ -526,7 +559,7 @@ class AWXReceptorJob:
if self.work_type != 'local' and os.path.exists(artifact_dir): if self.work_type != 'local' and os.path.exists(artifact_dir):
shutil.rmtree(artifact_dir) shutil.rmtree(artifact_dir)
def _process_phase(self, receptor_ctl): def _process_phase(self, work: ReceptorWork):
"""Stream events from the receptor work unit via the ansible-runner process streamer. """Stream events from the receptor work unit via the ansible-runner process streamer.
Extracted from _run_internal() so it can be called standalone for job adoption Extracted from _run_internal() so it can be called standalone for job adoption
@@ -536,7 +569,7 @@ class AWXReceptorJob:
resultsock = None resultsock = None
resultfile = None resultfile = None
try: try:
resultsock, resultfile = receptor_ctl.get_work_results(self.unit_id, return_socket=True, return_sockfile=True) resultsock, resultfile = work.results(self.unit_id)
except Exception: except Exception:
logger.exception(f'Failed to get work results for unit {self.unit_id}') logger.exception(f'Failed to get work results for unit {self.unit_id}')
raise raise
@@ -557,7 +590,7 @@ class AWXReceptorJob:
raise SignalExit() raise SignalExit()
res = processor_future.result() res = processor_future.result()
except SignalExit: except SignalExit:
receptor_ctl.simple_command(f"work cancel {self.unit_id}") work.cancel(self.unit_id)
if resultsock: if resultsock:
try: try:
resultsock.shutdown(socket.SHUT_RDWR) resultsock.shutdown(socket.SHUT_RDWR)
@@ -571,11 +604,11 @@ class AWXReceptorJob:
signal_state.raise_exception = False signal_state.raise_exception = False
if res.status == 'error': if res.status == 'error':
return self._handle_work_error(receptor_ctl, res) return self._handle_work_error(work, res)
return res return res
def _handle_work_error(self, receptor_ctl, res): def _handle_work_error(self, work: ReceptorWork, res):
"""Handle the error path from _process_phase when the work unit status is 'error'.""" """Handle the error path from _process_phase when the work unit status is 'error'."""
# If ansible-runner ran, but an error occured at runtime, the traceback information # If ansible-runner ran, but an error occured at runtime, the traceback information
# is saved via the status_handler passed in to the processor. # is saved via the status_handler passed in to the processor.
@@ -583,7 +616,7 @@ class AWXReceptorJob:
return res return res
try: try:
unit_status = receptor_ctl.simple_command(f'work status {self.unit_id}') unit_status = work.status(self.unit_id)
detail = unit_status.get('Detail') or '' detail = unit_status.get('Detail') or ''
state_name = unit_status.get('StateName', None) state_name = unit_status.get('StateName', None)
stdout_size = unit_status.get('StdoutSize', 0) stdout_size = unit_status.get('StdoutSize', 0)
@@ -607,7 +640,7 @@ class AWXReceptorJob:
# contain useful information about why the job failed. In case stdout is # contain useful information about why the job failed. In case stdout is
# massive, only ask for last 1000 bytes # massive, only ask for last 1000 bytes
startpos = max(stdout_size - 1000, 0) startpos = max(stdout_size - 1000, 0)
_resultsock, resultfile = receptor_ctl.get_work_results(self.unit_id, startpos=startpos, return_socket=True, return_sockfile=True) _resultsock, resultfile = work.results(self.unit_id, startpos=startpos)
lines = resultfile.readlines() lines = resultfile.readlines()
receptor_output = b"".join(lines).decode() receptor_output = b"".join(lines).decode()
_resultsock.shutdown(socket.SHUT_RDWR) _resultsock.shutdown(socket.SHUT_RDWR)
@@ -965,7 +998,7 @@ def _compute_adoption_dedup(job):
return safe_threshold, collision_zone return safe_threshold, collision_zone
def reattach_to_work_unit(job, receptor_ctl): def reattach_to_work_unit(job, work: ReceptorWork):
"""Reconnect to a receptor work unit and stream events in real-time until it completes. """Reconnect to a receptor work unit and stream events in real-time until it completes.
Reconstructs the minimal process-phase context from the DB job record, then calls Reconstructs the minimal process-phase context from the DB job record, then calls
@@ -980,7 +1013,7 @@ def reattach_to_work_unit(job, receptor_ctl):
# Check state for logging — no longer a gate. We stream regardless. # Check state for logging — no longer a gate. We stream regardless.
try: try:
unit_status = receptor_ctl.simple_command(f'work status {unit_id}') unit_status = work.status(unit_id)
except Exception: except Exception:
logger.warning(f'Cannot get receptor status for work unit {unit_id} (job {job.id}), deferring adoption') logger.warning(f'Cannot get receptor status for work unit {unit_id} (job {job.id}), deferring adoption')
return False return False
@@ -1011,7 +1044,7 @@ def reattach_to_work_unit(job, receptor_ctl):
process_phase_failed = False process_phase_failed = False
res = None res = None
try: try:
res = receptor_job._process_phase(receptor_ctl) # blocks until unit completes res = receptor_job._process_phase(work) # blocks until unit completes
except Exception: except Exception:
logger.exception(f'Adoption process phase failed for job {job.id} (unit={unit_id})') logger.exception(f'Adoption process phase failed for job {job.id} (unit={unit_id})')
process_phase_failed = True process_phase_failed = True
@@ -1031,7 +1064,7 @@ def reattach_to_work_unit(job, receptor_ctl):
else: else:
# _process_phase raised — fall back to work status for exit code # _process_phase raised — fall back to work status for exit code
try: try:
unit_status = receptor_ctl.simple_command(f'work status {unit_id}') unit_status = work.status(unit_id)
state_name = unit_status.get('StateName', '') state_name = unit_status.get('StateName', '')
except Exception: except Exception:
logger.debug(f'Could not get final status for {unit_id} after process phase failure') logger.debug(f'Could not get final status for {unit_id} after process phase failure')
@@ -1040,7 +1073,7 @@ def reattach_to_work_unit(job, receptor_ctl):
exit_code = _get_adoption_exit_code(unit_status, state_name) exit_code = _get_adoption_exit_code(unit_status, state_name)
_finalize_adopted_job(job, callback, exit_code, process_phase_failed) _finalize_adopted_job(job, callback, exit_code, process_phase_failed)
finally: finally:
receptor_job._receptor_release_work(receptor_ctl, getattr(res, 'status', 'error')) receptor_job._receptor_release_work(work, getattr(res, 'status', 'error'))
shutil.rmtree(private_data_dir, ignore_errors=True) shutil.rmtree(private_data_dir, ignore_errors=True)
return True return True
+6 -4
View File
@@ -85,6 +85,7 @@ from awx.main.tasks.adoption_decisions import (
from awx.main.tasks.helpers import is_run_threshold_reached from awx.main.tasks.helpers import is_run_threshold_reached
from awx.main.tasks.host_indirect import save_indirect_host_entries from awx.main.tasks.host_indirect import save_indirect_host_entries
from awx.main.tasks.receptor import ( from awx.main.tasks.receptor import (
ReceptorWork,
administrative_workunit_reaper, administrative_workunit_reaper,
get_receptor_ctl, get_receptor_ctl,
reattach_to_work_unit, reattach_to_work_unit,
@@ -1025,7 +1026,7 @@ def adopt_job_async(job_id):
receptor_ctl = get_receptor_ctl() receptor_ctl = get_receptor_ctl()
try: try:
reattach_to_work_unit(job, receptor_ctl) reattach_to_work_unit(job, ReceptorWork(receptor_ctl))
except Exception: except Exception:
logger.exception(f'adopt_job_async: adoption failed for job {job.id} (unit={job.work_unit_id})') logger.exception(f'adopt_job_async: adoption failed for job {job.id} (unit={job.work_unit_id})')
finally: finally:
@@ -1063,8 +1064,9 @@ def awx_receptor_workunit_reaper():
except FileNotFoundError: except FileNotFoundError:
logger.info('Receptorctl sockfile not found for workunit reaper, doing nothing') logger.info('Receptorctl sockfile not found for workunit reaper, doing nothing')
return return
work = ReceptorWork(receptor_ctl)
try: try:
receptor_work_list = receptor_ctl.simple_command("work list") receptor_work_list = work.list()
except ValueError as exc: except ValueError as exc:
logger.info(f'Error getting work list for workunit reaper, error: {str(exc)}') logger.info(f'Error getting work list for workunit reaper, error: {str(exc)}')
return return
@@ -1075,8 +1077,8 @@ def awx_receptor_workunit_reaper():
jobs_with_unreleased_receptor_units = jobs_with_unreleased_receptor_units.exclude(status__in=ERROR_STATES) jobs_with_unreleased_receptor_units = jobs_with_unreleased_receptor_units.exclude(status__in=ERROR_STATES)
for job in jobs_with_unreleased_receptor_units: for job in jobs_with_unreleased_receptor_units:
logger.debug(f"{job.log_format} is not active, reaping receptor work unit {job.work_unit_id}") logger.debug(f"{job.log_format} is not active, reaping receptor work unit {job.work_unit_id}")
receptor_ctl.simple_command(f"work cancel {job.work_unit_id}") work.cancel(job.work_unit_id)
receptor_ctl.simple_command(f"work release {job.work_unit_id}") work.release(job.work_unit_id)
administrative_workunit_reaper(receptor_work_list) administrative_workunit_reaper(receptor_work_list)
+138
View File
@@ -0,0 +1,138 @@
"""In-memory stand-ins for receptor work units, for testing streaming and adoption without a mesh.
FakeReceptorWork implements the ReceptorWork interface. Each unit holds the events its
playbook produced and the state receptor reports. results() returns the same line
protocol the ansible-runner worker writes, so AWX's real process-phase code parses it.
Failures are injected per unit:
- break_after=N: the stream ends after N events with no status or eof, as when the
connection to the execution node drops.
- status_error: status() raises, as when the node is unreachable.
- released units disappear, and releasing or reading an unknown unit raises like receptor.
RecordingDispatcher replaces CallbackQueueDispatcher so tests can see the events that
would have gone to the callback receiver.
"""
import io
import json
import uuid
from awx.main.tasks.receptor import ReceptorWork
TERMINAL_STATE = {'successful': 'Succeeded', 'failed': 'Failed', 'canceled': 'Failed', 'error': 'Failed'}
def make_events(count, failed_tasks=()):
"""Return `count` runner events shaped like a playbook run: start, tasks, then stats."""
events = []
for counter in range(1, count + 1):
if counter == 1:
event = 'playbook_on_start'
elif counter == count:
event = 'playbook_on_stats'
elif counter in failed_tasks:
event = 'runner_on_failed'
else:
event = 'runner_on_ok'
events.append(
{
'uuid': str(uuid.uuid4()),
'counter': counter,
'event': event,
'stdout': f'line {counter}',
'start_line': counter - 1,
'end_line': counter,
'event_data': {},
}
)
return events
class FakeWorkUnit:
def __init__(self, events=(), status='successful', state=None, exit_code=None, detail='', break_after=None, status_error=None):
self.events = list(events)
self.status = status
self.state = state or TERMINAL_STATE.get(status, 'Succeeded')
self.exit_code = exit_code
self.detail = detail
self.break_after = break_after
self.status_error = status_error
def stream(self):
lines = []
events = self.events if self.break_after is None else self.events[: self.break_after]
for event in events:
lines.append(json.dumps(event))
if self.break_after is None:
lines.append(json.dumps({'status': self.status, 'runner_ident': 'fake'}))
lines.append(json.dumps({'eof': True}))
return ('\n'.join(lines) + '\n').encode() if lines else b''
class FakeSocket:
def __init__(self):
self.closed = False
def shutdown(self, how):
self.closed = True
class FakeReceptorWork(ReceptorWork):
def __init__(self, units=None):
super().__init__(receptor_ctl=None)
self.units = dict(units or {})
self.calls = []
def _unit(self, unit_id):
if unit_id not in self.units:
raise RuntimeError(f'ERROR: unknown work unit {unit_id}')
return self.units[unit_id]
def list(self):
self.calls.append(('list',))
return {unit_id: {'StateName': unit.state, 'ExtraData': {}} for unit_id, unit in self.units.items()}
def status(self, unit_id):
self.calls.append(('status', unit_id))
unit = self._unit(unit_id)
if unit.status_error:
raise unit.status_error
status = {'StateName': unit.state, 'Detail': unit.detail, 'StdoutSize': len(unit.stream())}
if unit.exit_code is not None:
status['ExitCode'] = unit.exit_code
return status
def results(self, unit_id, startpos=None):
self.calls.append(('results', unit_id, startpos))
data = self._unit(unit_id).stream()
return FakeSocket(), io.BytesIO(data[startpos or 0 :])
def cancel(self, unit_id):
self.calls.append(('cancel', unit_id))
self._unit(unit_id).state = 'Failed'
def release(self, unit_id):
self.calls.append(('release', unit_id))
self._unit(unit_id)
del self.units[unit_id]
class RecordingDispatcher:
"""Collects callback-receiver messages instead of sending them to Redis."""
dispatched = []
def __init__(self):
pass
def dispatch(self, obj):
RecordingDispatcher.dispatched.append(obj)
@classmethod
def reset(cls):
cls.dispatched = []
@classmethod
def counters(cls):
return [e['counter'] for e in cls.dispatched if 'counter' in e]
@@ -0,0 +1,140 @@
"""Adoption run end to end against a fake receptor work unit.
These tests drive the real reattach, streaming, dedup and finalize code. Only receptor
(FakeReceptorWork) and the callback-receiver queue (RecordingDispatcher) are replaced.
"""
from unittest import mock
import pytest
from awx.main.models import Job, JobEvent
from awx.main.tasks.receptor import reattach_to_work_unit
from awx.main.tasks.system import awx_receptor_workunit_reaper
from awx.main.tests.fake_receptor import FakeReceptorWork, FakeWorkUnit, RecordingDispatcher, make_events
UNIT = 'unit-1'
@pytest.fixture(autouse=True)
def adoption_env(settings, tmp_path):
settings.AWX_ISOLATION_BASE_PATH = str(tmp_path)
RecordingDispatcher.reset()
# connections.close_all() would end the test's transaction; events go to the recorder instead of Redis.
with (
mock.patch('awx.main.tasks.receptor.connections'),
mock.patch('awx.main.tasks.callback.CallbackQueueDispatcher', RecordingDispatcher),
mock.patch('awx.main.tasks.jobs.ScheduleTaskManager'),
mock.patch('awx.main.tasks.jobs.ScheduleWorkflowManager'),
mock.patch('awx.main.models.unified_jobs.UnifiedJob.websocket_emit_status'),
):
yield
@pytest.fixture
def running_job(me_inst):
return Job.objects.create(controller_node=me_inst.hostname, execution_node='receptor-1', status='running', work_unit_id=UNIT)
def save_events(job, counters):
for counter in counters:
JobEvent.objects.create(job=job, counter=counter, event='runner_on_ok', job_created=job.created)
@pytest.mark.django_db
def test_finished_unit_is_replayed_and_finalized(running_job):
work = FakeReceptorWork({UNIT: FakeWorkUnit(make_events(30))})
assert reattach_to_work_unit(running_job, work) is True
running_job.refresh_from_db()
assert running_job.status == 'successful'
assert RecordingDispatcher.counters() == list(range(1, 31))
assert UNIT not in work.units, 'work unit released after finalize'
@pytest.mark.django_db
def test_events_already_saved_are_not_dispatched_again(running_job):
save_events(running_job, range(1, 21))
work = FakeReceptorWork({UNIT: FakeWorkUnit(make_events(30))})
reattach_to_work_unit(running_job, work)
assert RecordingDispatcher.counters() == list(range(21, 31))
@pytest.mark.django_db
def test_events_saved_out_of_order_are_skipped(running_job):
# Parallel callback workers can commit counter 13 before 11 and 12.
save_events(running_job, [*range(1, 11), 13])
work = FakeReceptorWork({UNIT: FakeWorkUnit(make_events(20))})
reattach_to_work_unit(running_job, work)
assert RecordingDispatcher.counters() == [11, 12, *range(14, 21)]
@pytest.mark.django_db
def test_failed_playbook_finalizes_failed(running_job):
work = FakeReceptorWork({UNIT: FakeWorkUnit(make_events(10, failed_tasks=[5]), status='failed', exit_code=2)})
reattach_to_work_unit(running_job, work)
running_job.refresh_from_db()
assert running_job.status == 'failed'
@pytest.mark.django_db
def test_unit_still_running_is_deferred(running_job):
work = FakeReceptorWork({UNIT: FakeWorkUnit(make_events(10), state='Running')})
assert reattach_to_work_unit(running_job, work) is False
running_job.refresh_from_db()
assert running_job.status == 'running'
assert RecordingDispatcher.dispatched == []
assert UNIT in work.units
@pytest.mark.django_db
def test_unit_gone_is_deferred(running_job):
# The unit was released, or the node lost it, before adoption.
work = FakeReceptorWork()
assert reattach_to_work_unit(running_job, work) is False
running_job.refresh_from_db()
assert running_job.status == 'running'
@pytest.mark.django_db
def test_stream_broken_mid_replay_fails_a_successful_unit(running_job):
# The unit succeeded, but the results stream ends after 12 of 30 events.
# Today the job is finalized failed, with only the events read before the break.
work = FakeReceptorWork({UNIT: FakeWorkUnit(make_events(30), break_after=12)})
reattach_to_work_unit(running_job, work)
running_job.refresh_from_db()
assert running_job.status == 'failed'
assert RecordingDispatcher.counters() == list(range(1, 13))
assert 'Unexpected empty line' in running_job.job_explanation
@pytest.mark.django_db
def test_workunit_reaper_releases_units_of_finished_jobs(me_inst, settings):
settings.RECEPTOR_RELEASE_WORK = True
finished = Job.objects.create(controller_node=me_inst.hostname, status='successful', work_unit_id='done')
running = Job.objects.create(controller_node=me_inst.hostname, status='running', work_unit_id='live')
work = FakeReceptorWork({'done': FakeWorkUnit(), 'live': FakeWorkUnit(state='Running')})
with (
mock.patch('awx.main.tasks.system.get_receptor_ctl'),
mock.patch('awx.main.tasks.system.ReceptorWork', return_value=work),
mock.patch('awx.main.tasks.system.administrative_workunit_reaper'),
):
awx_receptor_workunit_reaper()
assert ('cancel', finished.work_unit_id) in work.calls
assert finished.work_unit_id not in work.units
assert running.work_unit_id in work.units
@@ -11,6 +11,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from awx.main.tasks.adoption_decisions import JobAction from awx.main.tasks.adoption_decisions import JobAction
from awx.main.tasks.receptor import ReceptorWork
from awx.main.tasks.system import ( from awx.main.tasks.system import (
CleanupImagesAndFiles, CleanupImagesAndFiles,
execution_node_health_check, execution_node_health_check,
@@ -804,7 +805,7 @@ def test_adoption_finalizes_successful_job(me_inst):
mock_instance._process_phase.return_value = MagicMock(status='successful', rc=0) mock_instance._process_phase.return_value = MagicMock(status='successful', rc=0)
mock_job_cls.return_value = mock_instance mock_job_cls.return_value = mock_instance
reattach_to_work_unit(job, ctl) reattach_to_work_unit(job, ReceptorWork(ctl))
job.refresh_from_db() job.refresh_from_db()
assert job.status == 'successful' assert job.status == 'successful'
@@ -827,7 +828,7 @@ def test_adoption_finalizes_failed_job(me_inst):
mock_instance._process_phase.return_value = MagicMock(status='failed', rc=1) mock_instance._process_phase.return_value = MagicMock(status='failed', rc=1)
mock_job_cls.return_value = mock_instance mock_job_cls.return_value = mock_instance
reattach_to_work_unit(job, ctl) reattach_to_work_unit(job, ReceptorWork(ctl))
job.refresh_from_db() job.refresh_from_db()
assert job.status == 'failed' assert job.status == 'failed'
@@ -1107,7 +1108,7 @@ def test_reattach_receptor_command_fails(me_inst):
ctl = MagicMock() ctl = MagicMock()
ctl.simple_command.side_effect = Exception('connection refused') ctl.simple_command.side_effect = Exception('connection refused')
result = reattach_to_work_unit(job, ctl) result = reattach_to_work_unit(job, ReceptorWork(ctl))
assert result is False assert result is False
job.refresh_from_db() job.refresh_from_db()
@@ -1128,7 +1129,7 @@ def test_reattach_exit_code_from_detail(me_inst):
patch('awx.main.tasks.callback.RunnerCallback'), patch('awx.main.tasks.callback.RunnerCallback'),
): ):
mock_job_cls.return_value = MagicMock() mock_job_cls.return_value = MagicMock()
reattach_to_work_unit(job, ctl) reattach_to_work_unit(job, ReceptorWork(ctl))
job.refresh_from_db() job.refresh_from_db()
assert job.status == 'failed' assert job.status == 'failed'
@@ -1150,7 +1151,7 @@ def test_reattach_exit_code_fallback_succeeded(me_inst):
mock_instance = MagicMock() mock_instance = MagicMock()
mock_instance._process_phase.return_value = MagicMock(status='successful', rc=0) mock_instance._process_phase.return_value = MagicMock(status='successful', rc=0)
mock_job_cls.return_value = mock_instance mock_job_cls.return_value = mock_instance
reattach_to_work_unit(job, ctl) reattach_to_work_unit(job, ReceptorWork(ctl))
job.refresh_from_db() job.refresh_from_db()
assert job.status == 'successful' assert job.status == 'successful'
@@ -1170,7 +1171,7 @@ def test_reattach_exit_code_fallback_failed(me_inst):
patch('awx.main.tasks.callback.RunnerCallback'), patch('awx.main.tasks.callback.RunnerCallback'),
): ):
mock_job_cls.return_value = MagicMock() mock_job_cls.return_value = MagicMock()
reattach_to_work_unit(job, ctl) reattach_to_work_unit(job, ReceptorWork(ctl))
job.refresh_from_db() job.refresh_from_db()
assert job.status == 'failed' assert job.status == 'failed'
@@ -1192,7 +1193,7 @@ def test_reattach_process_phase_raises(me_inst):
mock_instance = MagicMock() mock_instance = MagicMock()
mock_instance._process_phase.side_effect = RuntimeError('boom') mock_instance._process_phase.side_effect = RuntimeError('boom')
mock_job_cls.return_value = mock_instance mock_job_cls.return_value = mock_instance
result = reattach_to_work_unit(job, ctl) result = reattach_to_work_unit(job, ReceptorWork(ctl))
assert result is True assert result is True
job.refresh_from_db() job.refresh_from_db()
@@ -1218,7 +1219,7 @@ def test_reattach_job_already_finalized(me_inst):
mock_instance = MagicMock() mock_instance = MagicMock()
mock_instance._process_phase.side_effect = _finalize_in_db mock_instance._process_phase.side_effect = _finalize_in_db
mock_job_cls.return_value = mock_instance mock_job_cls.return_value = mock_instance
reattach_to_work_unit(job, ctl) reattach_to_work_unit(job, ReceptorWork(ctl))
job.refresh_from_db() job.refresh_from_db()
assert job.status == 'successful' assert job.status == 'successful'
@@ -1236,7 +1237,10 @@ def test_adopt_job_async_calls_reattach(me_inst, settings):
adopt_job_async(job.id) adopt_job_async(job.id)
mock_ctl_factory.assert_called_once() mock_ctl_factory.assert_called_once()
mock_reattach.assert_called_once_with(job, mock_ctl_factory.return_value) mock_reattach.assert_called_once()
called_job, work = mock_reattach.call_args.args
assert called_job == job
assert work.receptor_ctl is mock_ctl_factory.return_value
@pytest.mark.django_db @pytest.mark.django_db
+11 -11
View File
@@ -682,41 +682,41 @@ def test_work_unit_released_on_quota_exceeded():
Scenario: Job hits quota limit → _handle_work_error returns None → Scenario: Job hits quota limit → _handle_work_error returns None →
BaseTask.run() early-returns. Work unit MUST be released. BaseTask.run() early-returns. Work unit MUST be released.
""" """
receptor_ctl = mock.MagicMock() work = mock.MagicMock()
receptor_job = mock.MagicMock() receptor_job = mock.MagicMock()
receptor_job.unit_id = "test-unit-123" receptor_job.unit_id = "test-unit-123"
receptor_job.receptor_ctl = receptor_ctl receptor_job.work = work
# When res is None (quota exceeded case) # When res is None (quota exceeded case)
res = None res = None
# This is the fixed code path from jobs.py lines 810-818 # This is the fixed code path from jobs.py lines 810-818
if not res: if not res:
if receptor_job and getattr(receptor_job, 'receptor_ctl', None): if receptor_job and getattr(receptor_job, 'work', None):
try: try:
receptor_job._receptor_release_work(receptor_job.receptor_ctl, 'error') receptor_job._receptor_release_work(receptor_job.work, 'error')
except Exception: except Exception:
pass pass
# Verify release was called # Verify release was called
receptor_job._receptor_release_work.assert_called_once_with(receptor_ctl, 'error') receptor_job._receptor_release_work.assert_called_once_with(work, 'error')
def test_work_unit_not_released_if_no_receptor_ctl(): def test_work_unit_not_released_if_no_receptor_work():
""" """
Edge case: receptor_ctl not set (transmit phase failed before unit submission). Edge case: work not set (transmit phase failed before unit submission).
Verify graceful handling when receptor_ctl is None. Verify graceful handling when work is None.
""" """
receptor_job = mock.MagicMock(spec=['unit_id', '_receptor_release_work']) receptor_job = mock.MagicMock(spec=['unit_id', '_receptor_release_work'])
receptor_job.receptor_ctl = None # Not set receptor_job.work = None # Not set
res = None res = None
# Code should not crash # Code should not crash
if not res: if not res:
if receptor_job and getattr(receptor_job, 'receptor_ctl', None): if receptor_job and getattr(receptor_job, 'work', None):
receptor_job._receptor_release_work(receptor_job.receptor_ctl, 'error') receptor_job._receptor_release_work(receptor_job.work, 'error')
# else: No release needed, unit was never submitted # else: No release needed, unit was never submitted
# Verify _receptor_release_work was NOT called # Verify _receptor_release_work was NOT called
@@ -20,6 +20,7 @@ from awx.main.tasks.callback import RunnerCallback
from awx.main.tasks.jobs import _finalize_job_run from awx.main.tasks.jobs import _finalize_job_run
from awx.main.tasks.receptor import ( from awx.main.tasks.receptor import (
AWXReceptorJob, AWXReceptorJob,
ReceptorWork,
_AdoptionTask, _AdoptionTask,
_compute_adoption_dedup, _compute_adoption_dedup,
_finalize_adopted_job, _finalize_adopted_job,
@@ -127,7 +128,7 @@ def test_process_phase_success(mock_signal, mock_connections):
rj.processor = Mock(return_value=expected_res) rj.processor = Mock(return_value=expected_res)
ctl = _make_receptor_ctl() ctl = _make_receptor_ctl()
res = rj._process_phase(ctl) res = rj._process_phase(ReceptorWork(ctl))
assert res.status == 'successful' assert res.status == 'successful'
mock_connections.close_all.assert_called_once() mock_connections.close_all.assert_called_once()
@@ -145,7 +146,7 @@ def test_process_phase_signal_exit(mock_signal, mock_connections):
rj.processor = Mock() rj.processor = Mock()
ctl = _make_receptor_ctl() ctl = _make_receptor_ctl()
res = rj._process_phase(ctl) res = rj._process_phase(ReceptorWork(ctl))
assert res.status == 'canceled' assert res.status == 'canceled'
ctl.simple_command.assert_any_call('work cancel unit-1') ctl.simple_command.assert_any_call('work cancel unit-1')
@@ -161,7 +162,7 @@ def test_handle_work_error_result_traceback_present():
ctl = _make_receptor_ctl() ctl = _make_receptor_ctl()
err_res = _Result(status='error', rc=1) err_res = _Result(status='error', rc=1)
result = rj._handle_work_error(ctl, err_res) result = rj._handle_work_error(ReceptorWork(ctl), err_res)
assert result is err_res assert result is err_res
ctl.simple_command.assert_not_called() ctl.simple_command.assert_not_called()
@@ -179,7 +180,7 @@ def test_handle_work_error_status_command_raises():
err_res = _Result(status='error', rc=1) err_res = _Result(status='error', rc=1)
# should not raise; falls through and returns res # should not raise; falls through and returns res
result = rj._handle_work_error(ctl, err_res) result = rj._handle_work_error(ReceptorWork(ctl), err_res)
assert result is err_res assert result is err_res
@@ -193,7 +194,7 @@ def test_handle_work_error_exceeded_quota():
ctl = _make_receptor_ctl(detail='exceeded quota for namespace') ctl = _make_receptor_ctl(detail='exceeded quota for namespace')
err_res = _Result(status='error', rc=1) err_res = _Result(status='error', rc=1)
result = rj._handle_work_error(ctl, err_res) result = rj._handle_work_error(ReceptorWork(ctl), err_res)
assert result is None assert result is None
rj.task.update_model.assert_called_once_with(1, status='pending') rj.task.update_model.assert_called_once_with(1, status='pending')
@@ -209,7 +210,7 @@ def test_handle_work_error_reads_receptor_output_on_failed():
ctl = _make_receptor_ctl(state='Failed', stdout_size=2000) ctl = _make_receptor_ctl(state='Failed', stdout_size=2000)
err_res = _Result(status='error', rc=1) err_res = _Result(status='error', rc=1)
rj._handle_work_error(ctl, err_res) rj._handle_work_error(ReceptorWork(ctl), err_res)
rj.task.runner_callback.delay_update.assert_called_once() rj.task.runner_callback.delay_update.assert_called_once()
call_kwargs = rj.task.runner_callback.delay_update.call_args[1] call_kwargs = rj.task.runner_callback.delay_update.call_args[1]
@@ -226,7 +227,7 @@ def test_handle_work_error_detail_only():
ctl = _make_receptor_ctl(state='Succeeded', detail='some error detail') ctl = _make_receptor_ctl(state='Succeeded', detail='some error detail')
err_res = _Result(status='error', rc=1) err_res = _Result(status='error', rc=1)
rj._handle_work_error(ctl, err_res) rj._handle_work_error(ReceptorWork(ctl), err_res)
rj.task.runner_callback.delay_update.assert_called_once() rj.task.runner_callback.delay_update.assert_called_once()
call_kwargs = rj.task.runner_callback.delay_update.call_args[1] call_kwargs = rj.task.runner_callback.delay_update.call_args[1]
@@ -243,7 +244,7 @@ def test_handle_work_error_no_detail_no_output():
ctl = _make_receptor_ctl(state='Succeeded', detail='') ctl = _make_receptor_ctl(state='Succeeded', detail='')
err_res = _Result(status='error', rc=1) err_res = _Result(status='error', rc=1)
result = rj._handle_work_error(ctl, err_res) result = rj._handle_work_error(ReceptorWork(ctl), err_res)
assert result is err_res assert result is err_res
rj.task.runner_callback.delay_update.assert_not_called() rj.task.runner_callback.delay_update.assert_not_called()
@@ -261,7 +262,7 @@ def test_handle_work_error_get_results_raises():
err_res = _Result(status='error', rc=1) err_res = _Result(status='error', rc=1)
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError):
rj._handle_work_error(ctl, err_res) rj._handle_work_error(ReceptorWork(ctl), err_res)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -296,7 +297,7 @@ def test_reattach_sets_job_created_on_callback(mock_rmtree, mock_pdd, mock_relea
created_callbacks.append(self) created_callbacks.append(self)
with patch.object(RealCallback, '__init__', capturing_cb_init): with patch.object(RealCallback, '__init__', capturing_cb_init):
reattach_to_work_unit(job, ctl) reattach_to_work_unit(job, ReceptorWork(ctl))
assert len(created_callbacks) == 1 assert len(created_callbacks) == 1
assert created_callbacks[0].job_created == str(job.created) assert created_callbacks[0].job_created == str(job.created)
@@ -325,7 +326,7 @@ def test_reattach_releases_work_unit_on_success(mock_rmtree, mock_pdd, mock_rele
ctl.simple_command.return_value = {'StateName': ''} ctl.simple_command.return_value = {'StateName': ''}
mock_process.return_value = Mock(status='successful', rc=0) mock_process.return_value = Mock(status='successful', rc=0)
reattach_to_work_unit(job, ctl) reattach_to_work_unit(job, ReceptorWork(ctl))
mock_release.assert_called_once() mock_release.assert_called_once()
@@ -350,7 +351,7 @@ def test_reattach_releases_work_unit_on_failure(mock_rmtree, mock_pdd, mock_rele
{'StateName': 'Succeeded', 'ExitCode': 0, 'Detail': ''}, # fallback re-check {'StateName': 'Succeeded', 'ExitCode': 0, 'Detail': ''}, # fallback re-check
] ]
result = reattach_to_work_unit(job, ctl) result = reattach_to_work_unit(job, ReceptorWork(ctl))
assert result is True # finalized via exit_code path assert result is True # finalized via exit_code path
mock_release.assert_called_once() mock_release.assert_called_once()
@@ -391,7 +392,7 @@ def test_reattach_sets_parent_workflow_job_id_when_workflow_child(mock_rmtree, m
created_callbacks.append(self) created_callbacks.append(self)
with patch.object(RealCallback, '__init__', capturing_cb_init): with patch.object(RealCallback, '__init__', capturing_cb_init):
reattach_to_work_unit(job, ctl) reattach_to_work_unit(job, ReceptorWork(ctl))
assert len(created_callbacks) == 1 assert len(created_callbacks) == 1
assert created_callbacks[0].parent_workflow_job_id == 999 assert created_callbacks[0].parent_workflow_job_id == 999
@@ -415,7 +416,7 @@ def test_reattach_workflow_job_lookup_exception_swallowed(mock_rmtree, mock_pdd,
ctl = Mock() ctl = Mock()
ctl.simple_command.return_value = {'StateName': 'Succeeded', 'ExitCode': 0, 'Detail': ''} ctl.simple_command.return_value = {'StateName': 'Succeeded', 'ExitCode': 0, 'Detail': ''}
result = reattach_to_work_unit(job, ctl) result = reattach_to_work_unit(job, ReceptorWork(ctl))
assert result is True # exception swallowed, adoption completes assert result is True # exception swallowed, adoption completes
@@ -552,7 +553,7 @@ def test_reattach_non_workflow_job_no_parent_id(mock_rmtree, mock_pdd, mock_rele
created_callbacks.append(self) created_callbacks.append(self)
with patch.object(RealCallback, '__init__', capturing_cb_init): with patch.object(RealCallback, '__init__', capturing_cb_init):
reattach_to_work_unit(job, ctl) reattach_to_work_unit(job, ReceptorWork(ctl))
assert created_callbacks[0].parent_workflow_job_id is None assert created_callbacks[0].parent_workflow_job_id is None
@@ -585,7 +586,7 @@ def test_reattach_running_defers_without_streaming_variant(mock_rmtree, mock_pdd
ctl = Mock() ctl = Mock()
ctl.simple_command.return_value = {'StateName': 'Running'} ctl.simple_command.return_value = {'StateName': 'Running'}
result = reattach_to_work_unit(job, ctl) result = reattach_to_work_unit(job, ReceptorWork(ctl))
mock_process.assert_not_called() mock_process.assert_not_called()
assert result is False assert result is False
@@ -619,7 +620,7 @@ def test_reattach_process_phase_failure_falls_back_to_work_status(mock_rmtree, m
] ]
mock_process.side_effect = RuntimeError('process phase failed') mock_process.side_effect = RuntimeError('process phase failed')
result = reattach_to_work_unit(job, ctl) # must not raise result = reattach_to_work_unit(job, ReceptorWork(ctl)) # must not raise
# Returns True (function completed without re-raising) # Returns True (function completed without re-raising)
assert result is True assert result is True
@@ -645,7 +646,7 @@ def test_reattach_pending_defers_without_streaming():
ctl.simple_command.return_value = {'StateName': 'Pending'} ctl.simple_command.return_value = {'StateName': 'Pending'}
with patch('awx.main.tasks.receptor.AWXReceptorJob._process_phase') as mock_process: with patch('awx.main.tasks.receptor.AWXReceptorJob._process_phase') as mock_process:
result = reattach_to_work_unit(job, ctl) result = reattach_to_work_unit(job, ReceptorWork(ctl))
assert result is False assert result is False
mock_process.assert_not_called() mock_process.assert_not_called()
@@ -673,7 +674,7 @@ def test_reattach_succeeded_goes_through_process_phase(mock_rmtree, mock_pdd, mo
ctl.simple_command.return_value = {'StateName': 'Succeeded', 'ExitCode': 0, 'Detail': ''} ctl.simple_command.return_value = {'StateName': 'Succeeded', 'ExitCode': 0, 'Detail': ''}
with patch('awx.main.tasks.receptor.AWXReceptorJob._process_phase', return_value=Mock(status='successful', rc=0)) as mock_process: with patch('awx.main.tasks.receptor.AWXReceptorJob._process_phase', return_value=Mock(status='successful', rc=0)) as mock_process:
reattach_to_work_unit(job, ctl) reattach_to_work_unit(job, ReceptorWork(ctl))
mock_process.assert_called_once() mock_process.assert_called_once()
mock_finalize_job.assert_called_once() mock_finalize_job.assert_called_once()
@@ -699,7 +700,7 @@ def test_reattach_failed_goes_through_process_phase(mock_rmtree, mock_pdd, mock_
ctl.simple_command.return_value = {'StateName': 'Failed', 'ExitCode': 1, 'Detail': 'exit status 1'} ctl.simple_command.return_value = {'StateName': 'Failed', 'ExitCode': 1, 'Detail': 'exit status 1'}
with patch('awx.main.tasks.receptor.AWXReceptorJob._process_phase', return_value=Mock(status='failed', rc=1)) as mock_process: with patch('awx.main.tasks.receptor.AWXReceptorJob._process_phase', return_value=Mock(status='failed', rc=1)) as mock_process:
reattach_to_work_unit(job, ctl) reattach_to_work_unit(job, ReceptorWork(ctl))
mock_process.assert_called_once() mock_process.assert_called_once()
mock_finalize_job.assert_called_once() mock_finalize_job.assert_called_once()
@@ -724,7 +725,7 @@ def test_reattach_canceled_goes_through_process_phase(mock_rmtree, mock_pdd, moc
ctl.simple_command.return_value = {'StateName': 'Canceled', 'Detail': ''} ctl.simple_command.return_value = {'StateName': 'Canceled', 'Detail': ''}
with patch('awx.main.tasks.receptor.AWXReceptorJob._process_phase', return_value=Mock(status='error', rc=1)) as mock_process: with patch('awx.main.tasks.receptor.AWXReceptorJob._process_phase', return_value=Mock(status='error', rc=1)) as mock_process:
reattach_to_work_unit(job, ctl) reattach_to_work_unit(job, ReceptorWork(ctl))
mock_process.assert_called_once() mock_process.assert_called_once()
mock_finalize_job.assert_called_once() mock_finalize_job.assert_called_once()
@@ -752,7 +753,7 @@ def test_reattach_running_defers_without_streaming(mock_rmtree, mock_pdd, mock_r
ctl.simple_command.return_value = {'StateName': 'Running'} ctl.simple_command.return_value = {'StateName': 'Running'}
with patch('awx.main.tasks.receptor.AWXReceptorJob._process_phase') as mock_process: with patch('awx.main.tasks.receptor.AWXReceptorJob._process_phase') as mock_process:
result = reattach_to_work_unit(job, ctl) result = reattach_to_work_unit(job, ReceptorWork(ctl))
assert result is False assert result is False
mock_process.assert_not_called() mock_process.assert_not_called()
@@ -969,7 +970,7 @@ def test_reattach_returns_false_when_status_command_raises():
ctl = Mock() ctl = Mock()
ctl.simple_command.side_effect = Exception('socket closed') ctl.simple_command.side_effect = Exception('socket closed')
result = reattach_to_work_unit(job, ctl) result = reattach_to_work_unit(job, ReceptorWork(ctl))
assert result is False assert result is False
@@ -1320,7 +1321,7 @@ def test_handle_work_error_no_output_no_detail(mock_logger):
receptor_job.task.runner_callback.extra_update_fields = {} receptor_job.task.runner_callback.extra_update_fields = {}
receptor_job.task.runner_callback.event_ct = 0 # No events receptor_job.task.runner_callback.event_ct = 0 # No events
result = receptor_job._handle_work_error(receptor_ctl, res) result = receptor_job._handle_work_error(ReceptorWork(receptor_ctl), res)
assert result == res assert result == res
mock_logger.warning.assert_called() mock_logger.warning.assert_called()
@@ -1345,7 +1346,7 @@ def test_handle_work_error_state_name_empty_string(mock_logger):
receptor_job.task.runner_callback.extra_update_fields = {} receptor_job.task.runner_callback.extra_update_fields = {}
receptor_job.task.runner_callback.event_ct = 5 receptor_job.task.runner_callback.event_ct = 5
result = receptor_job._handle_work_error(receptor_ctl, res) result = receptor_job._handle_work_error(ReceptorWork(receptor_ctl), res)
assert result == res assert result == res
# Verify exception was logged # Verify exception was logged
@@ -1377,7 +1378,7 @@ def test_handle_work_error_delay_update_with_receptor_output():
receptor_job.task.runner_callback.extra_update_fields = {} receptor_job.task.runner_callback.extra_update_fields = {}
receptor_job.task.runner_callback.event_ct = 0 # No events → fetch output receptor_job.task.runner_callback.event_ct = 0 # No events → fetch output
result = receptor_job._handle_work_error(receptor_ctl, res) result = receptor_job._handle_work_error(ReceptorWork(receptor_ctl), res)
assert result == res assert result == res
# Verify delay_update was called with receptor output # Verify delay_update was called with receptor output
@@ -1406,7 +1407,7 @@ def test_handle_work_error_delay_update_with_detail_fallback():
receptor_job.task.runner_callback.extra_update_fields = {} receptor_job.task.runner_callback.extra_update_fields = {}
receptor_job.task.runner_callback.event_ct = 5 receptor_job.task.runner_callback.event_ct = 5
result = receptor_job._handle_work_error(receptor_ctl, res) result = receptor_job._handle_work_error(ReceptorWork(receptor_ctl), res)
assert result == res assert result == res
# Verify delay_update was called with detail # Verify delay_update was called with detail