Skip to content

fix(content): exit on database failures - #8216

Merged
ggainey merged 1 commit into
pulp:mainfrom
Funi1234:fix/content-heartbeat-db-recovery
Oct 15, 2026
Merged

ggainey merged 1 commit into
pulp:mainfrom
Funi1234:fix/content-heartbeat-db-recovery

Conversation

@Funi1234

@Funi1234 Funi1234 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Content workers can lose database connectivity during PostgreSQL outage. content endpoint can continue returning HTTP 200 while Pulp removes worker's AppStatus heartbeat entry. operator sees online_content_apps: [] Hub readiness never completes.

Root Cause

heartbeat runs in detached asyncio task. previous failure path called:

exit(Arbiter.WORKER_BOOT_ERROR)

In deployed aiohttp/Gunicorn worker, did not terminate worker process. HTTP server continued serving /pulp/content/, so Pod remained Ready Kubernetes did not restart it.

Changes

  • Use os._exit(Arbiter.WORKER_BOOT_ERROR) when initial content-app registration fails, including duplicate registration and other database failures.
  • Use os._exit(Arbiter.WORKER_BOOT_ERROR) when a heartbeat write fails after the existing connection reset and second write attempt.
  • Remove retry loop CONTENT_APP_MAX_DB_RETRIES setting.
  • Add focused tests for registration and heartbeat database failures.
  • Add a subprocess regression test that runs the detached heartbeat task with real os._exit, injects cleanup failure, and verifies the worker exits with the Gunicorn boot-error code.

worker now fails closed. Kubernetes restarts container. If PostgreSQL still unavailable, replacement process exits again; once PostgreSQL recovers, next replacement registers heartbeat normally. No registration retry loop required.

Validation

change tested against original unpatched Galaxy image using pre-PR #302 operator:

  • PostgreSQL unavailable 45 seconds.
  • Content workers terminated HaltServer('Worker failed boot.', 3).
  • Each content Pod restarted three times while PostgreSQL unavailable.
  • After PostgreSQL recovered, next replacement workers registered successfully.
  • All four online_content_apps entries returned.

Focused heartbeat tests cover both registration error types, heartbeat write failures, and process-boundary termination. Ruff, Black, Python compilation, and diff checks also pass.

PR #302 remains possible operator-side mitigation already-running images contain Pulpcore defect, but change fixes failure content-worker level.

@Funi1234 Funi1234 changed the title fix(content): recover heartbeats after DB outages fix(content): exit on database failures Oct 14, 2026
Comment thread pulpcore/content/__init__.py Outdated
)
except IntegrityError:
log.error(f"A content app with name {name} already exists in the database.")
exit(Arbiter.WORKER_BOOT_ERROR)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should this fatal registration path also use os._exit(Arbiter.WORKER_BOOT_ERROR)? IntegrityError is handled here before the new DatabaseError handler, so it retains the exception-based exit. This path is outside the heartbeat loop's finally, so I wouldn't assume it has the same cleanup failure, but it would be useful to either use the same termination mechanism for both registration failures or cover this branch with a real-worker termination test.

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.

@jerabekjiri
jerabekjiri requested review from dralley, gerrod3, ggainey and mdellweg and removed request for rooftopcellist October 14, 2026 16:55
assert mock_acreate.await_count == 1


def test_detached_heartbeat_exits_process_on_database_failure():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hmm. This looks pretty strange to me. What does it test? Would gunicorn ever decide to restructure it's process organization, this test would not catch it. Otoh it's really hard to wrap ones head around what actually happens here. Does it actually reflect the way the heartbeat code is excercised in the app?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The content app starts the heartbeat as a detached asyncio task. The test starts that same context in a child process, makes both heartbeat writes fail, and checks that the process exits with code 3. Cleanup is also made to fail, to make sure it cannot hide the exit.

It was added because the old test mocked os._exit and called _heartbeat() directly. That only proved we called os._exit, not that the worker actually died.

It does not test Gunicorn restarting the worker or registering it again. That would need a separate integration test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The test as a string was going to cause confusion, I've reformatted to to be a normal function so that it is more easily readable.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

That is actually my concern. This test does not test that gunicorn terminates (and btw not restarts itself). So the only gap this test bridges is between "calling _exit" and some process actually terminating. If we stop trusting that level of python facilities, we have surpassed more than one level of insanity.
Put another way the thing this tests excersises beyond "_exit being called" is that the fake setup in the very test, that by no means of the test suite is guaranteed to reflect the actual app code in the future, also terminates. So the only answer to what needs to happen for this test to fail without the other one failing is: "Python is broken".
And therefore I would rather just scrap this test. (Yes, I know it has been suggested to add such a thing, but I believe this repository is not the right layer of the onion.)

@Funi1234
Funi1234 requested a review from mdellweg October 15, 2026 13:29
Terminate content workers when database registration or heartbeat writes fail, so a detached heartbeat task cannot leave the worker serving without an AppStatus heartbeat.
@Funi1234
Funi1234 force-pushed the fix/content-heartbeat-db-recovery branch from cfde751 to 973ffc9 Compare October 15, 2026 15:14
@ggainey
ggainey merged commit ad59666 into pulp:main Oct 15, 2026
14 checks passed
@patchback

patchback Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Backport to 3.122: 💚 backport PR created

✅ Backport PR branch: patchback/backports/3.122/ad5966607e0f826c090885d938729bf7cfac94fd/pr-8216

Backported as #8227

🤖 @patchback
I'm built with octomachinery and
my source is open — https://github.com/sanitizers/patchback-github-app.

@patchback

patchback Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Backport to 3.118: 💚 backport PR created

✅ Backport PR branch: patchback/backports/3.118/ad5966607e0f826c090885d938729bf7cfac94fd/pr-8216

Backported as #8228

🤖 @patchback
I'm built with octomachinery and
my source is open — https://github.com/sanitizers/patchback-github-app.

@patchback

patchback Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Backport to 3.105: 💚 backport PR created

✅ Backport PR branch: patchback/backports/3.105/ad5966607e0f826c090885d938729bf7cfac94fd/pr-8216

Backported as #8229

🤖 @patchback
I'm built with octomachinery and
my source is open — https://github.com/sanitizers/patchback-github-app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants