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
12 changes: 6 additions & 6 deletions pulpcore/content/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,18 +58,18 @@ async def _heartbeat():
msg = "Content App '{name}' heartbeat written, sleeping for '{interarrival}' seconds".format(
name=name, interarrival=heartbeat_interval
)
fail_msg = ("Content App '{name}' failed to write a heartbeat to the database.").format(
name=name
)
fail_msg = f"Content App '{name}' failed to register or write a heartbeat to the database."
versions = {app.label: app.version for app in pulp_plugin_configs()}

try:
app_status = await AppStatus.objects.acreate(
name=name, app_type="content", versions=versions
)
except IntegrityError:
log.error(f"A content app with name {name} already exists in the database.")
exit(Arbiter.WORKER_BOOT_ERROR)
os._exit(Arbiter.WORKER_BOOT_ERROR)
except (InterfaceError, DatabaseError) as e:
log.error(f"Content App '{name}' failed to register with the database. Exception: {str(e)}")
os._exit(Arbiter.WORKER_BOOT_ERROR)
try:
while True:
await asyncio.sleep(heartbeat_interval)
Expand All @@ -83,7 +83,7 @@ async def _heartbeat():
log.debug(msg)
except (InterfaceError, DatabaseError) as e:
log.error(f"{fail_msg} Exception: {str(e)}")
exit(Arbiter.WORKER_BOOT_ERROR)
os._exit(Arbiter.WORKER_BOOT_ERROR)
finally:
if app_status:
await app_status.adelete()
Expand Down
40 changes: 29 additions & 11 deletions pulpcore/tests/unit/content/test_heartbeat.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from unittest.mock import AsyncMock, Mock, call
from unittest.mock import AsyncMock, Mock

import pytest
from django.db.utils import InterfaceError, OperationalError
from django.db.utils import IntegrityError, InterfaceError, OperationalError
from gunicorn.arbiter import Arbiter

from pulpcore.app.models.status import AppStatus, AppStatusManager
from pulpcore.content import _heartbeat
Expand All @@ -14,12 +15,7 @@ class MockException(Exception):

@pytest.mark.parametrize("error_class", [InterfaceError, OperationalError])
@pytest.mark.asyncio
async def test_db_connection_interface_error(monkeypatch, settings, error_class):
"""
Test that if an InterfaceError or OperationalError is raised,
Handler._reset_db_connection() is called
"""

async def test_exits_on_heartbeat_database_failure(monkeypatch, settings, error_class):
mock_app_status = AsyncMock()
mock_app_status.asave_heartbeat.side_effect = [error_class(), error_class()]
mock_acreate = AsyncMock()
Expand All @@ -28,10 +24,32 @@ async def test_db_connection_interface_error(monkeypatch, settings, error_class)
monkeypatch.setattr(AppStatus, "objects", AppStatusManager())
mock_reset_db = Mock()
monkeypatch.setattr(Handler, "_reset_db_connection", mock_reset_db)
mock_exit = Mock(side_effect=SystemExit(Arbiter.WORKER_BOOT_ERROR))

@rooftopcellist rooftopcellist Oct 14, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sharing this ai-review note:

Could we add a subprocess regression test that starts the heartbeat as a detached task and leaves os._exit unmocked? Raising SystemExit here and directly awaiting _heartbeat() verifies the call, but doesn't exercise the process-termination behavior this fix depends on. In particular, the heartbeat's finally performs another DB operation, whose failure can replace a pending SystemExit; real _exit skips that cleanup. The test should inject the heartbeat DB failures and failing cleanup, then assert that the child exits with code 3 within a bounded timeout. The reported Gunicorn outage/recovery scenario would also be valuable integration coverage for master termination and successful re-registration.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed in another thread, this is not the best place for a test like that to live. Resolving this thread.

monkeypatch.setattr("pulpcore.content.os._exit", mock_exit)
settings.CONTENT_APP_TTL = 1
monkeypatch.setattr("pulpcore.content.asyncio.sleep", AsyncMock(return_value=None))

with pytest.raises(SystemExit) as exc:
await _heartbeat()

assert exc.value.code == Arbiter.WORKER_BOOT_ERROR
assert mock_app_status.asave_heartbeat.await_count == 2
mock_reset_db.assert_called_once_with()
mock_exit.assert_called_once_with(Arbiter.WORKER_BOOT_ERROR)


@pytest.mark.asyncio
@pytest.mark.parametrize("error_class", [IntegrityError, OperationalError])
async def test_exits_on_initial_registration_database_failure(monkeypatch, settings, error_class):
mock_acreate = AsyncMock(side_effect=error_class())
monkeypatch.setattr(AppStatusManager, "acreate", mock_acreate)
monkeypatch.setattr(AppStatus, "objects", AppStatusManager())
mock_exit = Mock(side_effect=SystemExit(Arbiter.WORKER_BOOT_ERROR))
monkeypatch.setattr("pulpcore.content.os._exit", mock_exit)

with pytest.raises(SystemExit):
with pytest.raises(SystemExit) as exc:
await _heartbeat()

mock_app_status.asave_heartbeat.assert_called()
mock_reset_db.assert_has_calls([call()])
assert exc.value.code == Arbiter.WORKER_BOOT_ERROR
mock_exit.assert_called_once_with(Arbiter.WORKER_BOOT_ERROR)
assert mock_acreate.await_count == 1
Loading