mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-02 10:41: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 public process states.
|
||||||
- [x] Add `CompanionProcess` runtime state.
|
- [x] Add `CompanionProcess` runtime state.
|
||||||
- [x] Add status description helpers.
|
- [x] Add status description helpers.
|
||||||
- [ ] Add `CompanionManager` skeleton.
|
- [x] Add `CompanionManager` skeleton.
|
||||||
- [ ] Spawn one companion process from the manager.
|
- [x] Spawn one companion process from the manager.
|
||||||
- [ ] Apply `cwd` and `env` before target.
|
- [ ] Apply `cwd` and `env` before target.
|
||||||
- [ ] Redirect `stdout` and `stderr`.
|
- [ ] Redirect `stdout` and `stderr`.
|
||||||
- [ ] Reap exited companion processes.
|
- [ ] 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