feat(companion): Add CompanionManager skeleton and single-companion spawn

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tanmoy Sarkar 2026-06-09 16:57:18 +05:30
parent 78d67197b6
commit 5639d467f3
3 changed files with 115 additions and 2 deletions

View File

@ -670,8 +670,8 @@ No per-companion logic in Arbiter.
- [x] Add public process states.
- [x] Add `CompanionProcess` runtime state.
- [x] Add status description helpers.
- [ ] Add `CompanionManager` skeleton.
- [ ] Spawn one companion process from the manager.
- [x] Add `CompanionManager` skeleton.
- [x] Spawn one companion process from the manager.
- [ ] Apply `cwd` and `env` before target.
- [ ] Redirect `stdout` and `stderr`.
- [ ] Reap exited companion processes.

View File

@ -0,0 +1,65 @@
#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
import importlib
import os
import time
from gunicorn.companion.process import CompanionProcess, State
class CompanionManager:
"""Forks and supervises companion processes.
Created by the arbiter after preload. Holds one ``CompanionProcess`` per
configured companion and owns the fork lifecycle. This skeleton wires
construction and single-companion spawn; reaping, backoff, the control
socket, and the run loop arrive in later tasks.
"""
def __init__(self, configs, log):
self.log = log
self.pid = os.getpid()
self.processes = {c.name: CompanionProcess(c) for c in configs}
def spawn_process(self, proc):
"""Fork one companion.
Parent records the pid and moves the companion to STARTING. Child
resolves and runs the target, exiting the worker on any failure so a
crashed companion never leaks back into the manager's control flow.
"""
pid = os.fork()
if pid != 0:
proc.pid = pid
proc.state = State.STARTING
proc.started_at = time.time()
proc.manual_stop = False
self.log.info("companion %s started (pid %s)", proc.name, pid)
return pid
try:
target = self._resolve_target(proc.config.target)
target()
except SystemExit:
raise
except BaseException:
self.log.exception("companion %s crashed", proc.name)
os._exit(1)
os._exit(0)
@staticmethod
def _resolve_target(target):
"""Return the zero-arg callable for a companion target.
Accepts an already-callable target or a ``"module:attr"`` import
string, e.g. ``"frappe_companions:start_rq_default"``.
"""
if callable(target):
return target
module, sep, attr = target.partition(":")
if not sep:
raise ValueError("companion target %r must be 'module:callable'" % target)
return getattr(importlib.import_module(module), attr)

View File

@ -0,0 +1,48 @@
#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
from unittest import mock
import pytest
from gunicorn.companion.manager import CompanionManager
from gunicorn.companion.process import CompanionConfig, State
def make_manager(*names):
configs = [CompanionConfig(name=n, target=lambda: None) for n in names]
return CompanionManager(configs, log=mock.Mock())
def test_manager_builds_one_process_per_config():
mgr = make_manager("rq", "scheduler")
assert set(mgr.processes) == {"rq", "scheduler"}
assert mgr.processes["rq"].state == State.STOPPED
def test_resolve_target_accepts_callable():
fn = lambda: None
assert CompanionManager._resolve_target(fn) is fn
def test_resolve_target_import_string():
# os.getpid is a real "module:attr" target.
assert CompanionManager._resolve_target("os:getpid") is __import__("os").getpid
def test_resolve_target_rejects_bad_string():
with pytest.raises(ValueError):
CompanionManager._resolve_target("no_colon")
def test_spawn_parent_records_pid_and_starting():
mgr = make_manager("rq")
proc = mgr.processes["rq"]
with mock.patch("os.fork", return_value=4321):
pid = mgr.spawn_process(proc)
assert pid == 4321
assert proc.pid == 4321
assert proc.state == State.STARTING
assert proc.started_at is not None
assert proc.manual_stop is False