Skip to content
Draft
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
170 changes: 170 additions & 0 deletions bench/config/gunicorn.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# imports - standard imports
import getpass
import os
import pprint

# imports - third party imports
import click

# imports - module imports
import bench
from bench.bench import Bench
from bench.config.common_site_config import (
compute_max_requests_jitter,
get_default_max_requests,
get_gunicorn_workers,
)
from bench.utils import get_bench_name, which

# Per-queue drain budget (seconds), mirroring the supervisor template's stopwaitsecs.
QUEUE_STOP_TIMEOUT = {
"default": 1560,
"long": 1560,
"short": 360,
}
SCHEDULER_STOP_TIMEOUT = 60
SOCKETIO_STOP_TIMEOUT = 30


def build_companion_workers(bench_path, config, *, sites_dir, logs_dir):
"""Build the ``companion_workers`` spec list for the gunicorn config.

One companion per worker process (mirrors the supervisor ``numprocs``):
scheduler, then ``background_workers`` instances for each queue, plus the
socketio companion when enabled. Queue grouping follows the supervisor
template, including multi-queue consumption.
"""
from bench.config.supervisor import can_enable_multi_queue_consumption

background_workers = config.get("background_workers") or 1
multi_queue = can_enable_multi_queue_consumption(bench_path)
custom_workers = config.get("workers", {})

def spec(name, target, *, cwd, stop_timeout, env=None):
worker = {
"name": name,
"target": target,
"cwd": cwd,
"stop_timeout": stop_timeout,
"stdout": os.path.join(logs_dir, f"{name}.log"),
"stderr": "stdout",
}
if env:
worker["env"] = env
return worker

# Workers/scheduler run from sites/ (frappe.init_site reads ./apps.txt);
# socketio runs from the bench dir. cwd set per spec below.
workers = [
spec(
"scheduler",
"frappe.gunicorn_companion:run_scheduler",
cwd=sites_dir,
stop_timeout=SCHEDULER_STOP_TIMEOUT,
)
]

# Built-in queues. With multi-queue, short/long also drain lighter queues and
# there is no separate default worker, matching the supervisor template.
if multi_queue:
queue_consumption = {"short": "short,default", "long": "long,default,short"}
else:
queue_consumption = {"default": "default", "short": "short", "long": "long"}

for queue_name, queue_list in queue_consumption.items():
for index in range(background_workers):
workers.append(
spec(
f"worker-{queue_name}-{index + 1}",
"frappe.gunicorn_companion:run_worker",
cwd=sites_dir,
stop_timeout=QUEUE_STOP_TIMEOUT[queue_name],
env={"FRAPPE_COMPANION_QUEUE": queue_list},
)
)

# Custom queues defined under the "workers" key.
for queue_name, details in custom_workers.items():
count = details.get("background_workers") or background_workers
timeout = details.get("timeout", QUEUE_STOP_TIMEOUT["default"])
for index in range(count):
workers.append(
spec(
f"worker-{queue_name}-{index + 1}",
"frappe.gunicorn_companion:run_worker",
cwd=sites_dir,
stop_timeout=timeout,
env={"FRAPPE_COMPANION_QUEUE": queue_name},
)
)

if _socketio_enabled(config):
workers.append(
spec(
"socketio",
"frappe.gunicorn_companion:run_socketio",
cwd=bench_path,
stop_timeout=SOCKETIO_STOP_TIMEOUT,
)
)

return workers


def _socketio_enabled(config):
# python backend needs no node; node backend needs node present.
return config.get("socketio_backend", "node") == "python" or bool(
which("node") or which("nodejs")
)


def generate_gunicorn_config(bench_path, user=None, yes=False):
"""Render ``config/gunicorn.conf.py`` for use_gunicorn_companion mode."""
if not user:
user = getpass.getuser()

Check warning on line 124 in bench/config/gunicorn.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this assignment to local variable 'user'; the value is never used.

See more on https://sonarcloud.io/project/issues?id=frappe_bench&issues=AZ6_q7hVixFPtcnbF0Q0&open=AZ6_q7hVixFPtcnbF0Q0&pullRequest=1718

config = Bench(bench_path).conf
bench_dir = os.path.abspath(bench_path)
sites_dir = os.path.join(bench_dir, "sites")
logs_dir = os.path.join(bench_dir, "logs")

web_worker_count = config.get(
"gunicorn_workers", get_gunicorn_workers()["gunicorn_workers"]
)
max_requests = config.get(
"gunicorn_max_requests", get_default_max_requests(web_worker_count)
)

companion_workers = build_companion_workers(
bench_dir, config, sites_dir=sites_dir, logs_dir=logs_dir
)

template = bench.config.env().get_template("gunicorn.conf.py")
rendered = template.render(
**{
"webserver_port": config.get("webserver_port", 8000),
"gunicorn_workers": web_worker_count,
"http_timeout": config.get("http_timeout", 120),
"gunicorn_max_requests": max_requests,
"gunicorn_max_requests_jitter": compute_max_requests_jitter(max_requests),
"companion_control_socket": os.path.join(
bench_dir, "config", "gunicorn-companion.sock"
),
"companion_workers_code": pprint.pformat(
companion_workers, indent=4, width=100, sort_dicts=False
),
"bench_name": get_bench_name(bench_path),
}
)

conf_path = os.path.join(bench_path, "config", "gunicorn.conf.py")
if not yes and os.path.exists(conf_path):
click.confirm(
"gunicorn.conf.py already exists and this will overwrite it. Do you want to continue?",
abort=True,
)

with open(conf_path, "w") as f:
f.write(rendered)

return conf_path
7 changes: 7 additions & 0 deletions bench/config/supervisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ def generate_supervisor_config(bench_path, user=None, yes=False, skip_redis=Fals
template = bench.config.env().get_template("supervisor.conf")
bench_dir = os.path.abspath(bench_path)

use_gunicorn_companion = bool(config.get("use_gunicorn_companion"))
if use_gunicorn_companion:
from bench.config.gunicorn import generate_gunicorn_config

generate_gunicorn_config(bench_path, user=user, yes=yes)

web_worker_count = config.get(
"gunicorn_workers", get_gunicorn_workers()["gunicorn_workers"]
)
Expand Down Expand Up @@ -63,6 +69,7 @@ def generate_supervisor_config(bench_path, user=None, yes=False, skip_redis=Fals
"workers": config.get("workers", {}),
"multi_queue_consumption": can_enable_multi_queue_consumption(bench_path),
"supervisor_startretries": 10,
"use_gunicorn_companion": use_gunicorn_companion,
}
)

Expand Down
47 changes: 34 additions & 13 deletions bench/config/systemd.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ def generate_systemd_config(
_delete_symlinks(bench_path)
return

use_gunicorn_companion = bool(config.get("use_gunicorn_companion"))
if use_gunicorn_companion:
from bench.config.gunicorn import generate_gunicorn_config

generate_gunicorn_config(bench_path, user=user, yes=yes)

number_of_workers = config.get("background_workers") or 1
background_workers = []
for i in range(number_of_workers):
Expand Down Expand Up @@ -93,6 +99,7 @@ def generate_systemd_config(
"bench_name": get_bench_name(bench_path),
"worker_target_wants": " ".join(background_workers),
"bench_cmd": which("bench"),
"use_gunicorn_companion": use_gunicorn_companion,
}

if not yes:
Expand All @@ -103,7 +110,9 @@ def generate_systemd_config(

setup_systemd_directory(bench_path)
setup_main_config(bench_info, bench_path)
setup_workers_config(bench_info, bench_path)
if not use_gunicorn_companion:
# Companion mode runs workers/scheduler inside gunicorn; no worker units.
setup_workers_config(bench_info, bench_path)
setup_web_config(bench_info, bench_path)
setup_redis_config(bench_info, bench_path)

Expand Down Expand Up @@ -204,35 +213,37 @@ def setup_web_config(bench_info, bench_path):
bench_web_service_template = bench.config.env().get_template(
"systemd/frappe-bench-frappe-web.service"
)
bench_node_socketio_template = bench.config.env().get_template(
"systemd/frappe-bench-node-socketio.service"
)

bench_web_target_config = bench_web_target_template.render(**bench_info)
bench_web_service_config = bench_web_service_template.render(**bench_info)
bench_node_socketio_config = bench_node_socketio_template.render(**bench_info)

bench_web_target_config_path = os.path.join(
bench_path, "config", "systemd", bench_info.get("bench_name") + "-web.target"
)
bench_web_service_config_path = os.path.join(
bench_path, "config", "systemd", bench_info.get("bench_name") + "-frappe-web.service"
)
bench_node_socketio_config_path = os.path.join(
bench_path,
"config",
"systemd",
bench_info.get("bench_name") + "-node-socketio.service",
)

with open(bench_web_target_config_path, "w") as f:
f.write(bench_web_target_config)

with open(bench_web_service_config_path, "w") as f:
f.write(bench_web_service_config)

with open(bench_node_socketio_config_path, "w") as f:
f.write(bench_node_socketio_config)
# Companion mode runs socketio inside gunicorn; no node-socketio unit.
if not bench_info.get("use_gunicorn_companion"):
bench_node_socketio_template = bench.config.env().get_template(
"systemd/frappe-bench-node-socketio.service"
)
bench_node_socketio_config = bench_node_socketio_template.render(**bench_info)
bench_node_socketio_config_path = os.path.join(
bench_path,
"config",
"systemd",
bench_info.get("bench_name") + "-node-socketio.service",
)
with open(bench_node_socketio_config_path, "w") as f:
f.write(bench_node_socketio_config)


def setup_redis_config(bench_info, bench_path):
Expand Down Expand Up @@ -295,6 +306,16 @@ def _delete_symlinks(bench_path):

def get_unit_files(bench_path):
bench_name = get_bench_name(bench_path)
if bool(Bench(bench_path).conf.get("use_gunicorn_companion")):
# Companion mode: only gunicorn web + redis units.
return [
[bench_name, ".target"],
[bench_name + "-web", ".target"],
[bench_name + "-redis", ".target"],
[bench_name + "-frappe-web", ".service"],
[bench_name + "-redis-cache", ".service"],
[bench_name + "-redis-queue", ".service"],
]
unit_files = [
[bench_name, ".target"],
[bench_name + "-workers", ".target"],
Expand Down
44 changes: 44 additions & 0 deletions bench/config/templates/gunicorn.conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Generated by bench (use_gunicorn_companion mode). Do not edit by hand.
#
# Gunicorn serves web and supervises the scheduler, rq workers and socketio as
# companions of this preloaded master, sharing its memory copy-on-write.
import os

# Skip frappe.app's eager mysqlclient import so the python socketio companion
# can run gevent. Must be set before the app is preloaded.
os.environ.setdefault("FRAPPE_GUNICORN_COMPANION", "1")

wsgi_app = "frappe.app:application"
preload_app = True

bind = "127.0.0.1:{{ webserver_port }}"
workers = {{ gunicorn_workers }}

Check warning on line 15 in bench/config/templates/gunicorn.conf.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure this expression is hashable.

See more on https://sonarcloud.io/project/issues?id=frappe_bench&issues=AZ6_q7jXixFPtcnbF0Q1&open=AZ6_q7jXixFPtcnbF0Q1&pullRequest=1718
timeout = {{ http_timeout }}

Check warning on line 16 in bench/config/templates/gunicorn.conf.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure this expression is hashable.

See more on https://sonarcloud.io/project/issues?id=frappe_bench&issues=AZ6_q7jXixFPtcnbF0Q2&open=AZ6_q7jXixFPtcnbF0Q2&pullRequest=1718
graceful_timeout = 30
max_requests = {{ gunicorn_max_requests }}

Check warning on line 18 in bench/config/templates/gunicorn.conf.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure this expression is hashable.

See more on https://sonarcloud.io/project/issues?id=frappe_bench&issues=AZ6_q7jXixFPtcnbF0Q3&open=AZ6_q7jXixFPtcnbF0Q3&pullRequest=1718
max_requests_jitter = {{ gunicorn_max_requests_jitter }}

Check warning on line 19 in bench/config/templates/gunicorn.conf.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure this expression is hashable.

See more on https://sonarcloud.io/project/issues?id=frappe_bench&issues=AZ6_q7jXixFPtcnbF0Q4&open=AZ6_q7jXixFPtcnbF0Q4&pullRequest=1718

# Control socket for `gunicorn-companion -s <path> ...`.
companion_control_socket = "{{ companion_control_socket }}"
companion_control_socket_mode = 0o660
# Shutdown headroom over the slowest companion's stop_timeout.
companion_manager_shutdown_buffer = 15

companion_workers = {{ companion_workers_code }}

Check warning on line 27 in bench/config/templates/gunicorn.conf.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure this expression is hashable.

See more on https://sonarcloud.io/project/issues?id=frappe_bench&issues=AZ6_q7jXixFPtcnbF0Q5&open=AZ6_q7jXixFPtcnbF0Q5&pullRequest=1718


def on_starting(server):

Check warning on line 30 in bench/config/templates/gunicorn.conf.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "server".

See more on https://sonarcloud.io/project/issues?id=frappe_bench&issues=AZ6_q7jXixFPtcnbF0Q6&open=AZ6_q7jXixFPtcnbF0Q6&pullRequest=1718
# Eager-import rq/redis in the master before any fork, for copy-on-write sharing.
import frappe.gunicorn_companion as companion

companion.warmup()


def when_ready(server):

Check warning on line 37 in bench/config/templates/gunicorn.conf.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "server".

See more on https://sonarcloud.io/project/issues?id=frappe_bench&issues=AZ6_q7jXixFPtcnbF0Q7&open=AZ6_q7jXixFPtcnbF0Q7&pullRequest=1718
# App is fully preloaded. Freeze all GC-tracked objects so workers don't
# trigger copy-on-write faults from GC scanning shared pages. Reuse frappe's
# freeze_gc so the _gc_frozen guard is set and the at-fork hook then no-ops
# instead of redoing collect+freeze on the first fork.
from frappe._optimizations import freeze_gc

freeze_gc()
Loading
Loading