mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-01 10:11:30 +08:00
feat(companion): Add CompanionManager skeleton and single-companion spawn
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
78d67197b6
commit
5639d467f3
@ -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.
|
||||
|
||||
65
gunicorn/companion/manager.py
Normal file
65
gunicorn/companion/manager.py
Normal 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)
|
||||
48
tests/test_companion_manager.py
Normal file
48
tests/test_companion_manager.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user