ufaflow 1.0.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ufaflow-1.0.6/LICENSE +21 -0
- ufaflow-1.0.6/PKG-INFO +101 -0
- ufaflow-1.0.6/README.md +89 -0
- ufaflow-1.0.6/pyproject.toml +32 -0
- ufaflow-1.0.6/setup.cfg +4 -0
- ufaflow-1.0.6/setup.py +15 -0
- ufaflow-1.0.6/tests/test_backend_pool.py +19 -0
- ufaflow-1.0.6/tests/test_cli_smoke.py +45 -0
- ufaflow-1.0.6/ufaflow/__init__.py +5 -0
- ufaflow-1.0.6/ufaflow/cli.py +395 -0
- ufaflow-1.0.6/ufaflow/server.py +260 -0
- ufaflow-1.0.6/ufaflow/ufaflow_server.py +232 -0
- ufaflow-1.0.6/ufaflow.egg-info/PKG-INFO +101 -0
- ufaflow-1.0.6/ufaflow.egg-info/SOURCES.txt +15 -0
- ufaflow-1.0.6/ufaflow.egg-info/dependency_links.txt +1 -0
- ufaflow-1.0.6/ufaflow.egg-info/requires.txt +1 -0
- ufaflow-1.0.6/ufaflow.egg-info/top_level.txt +1 -0
ufaflow-1.0.6/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 halif
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ufaflow-1.0.6/PKG-INFO
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ufaflow
|
|
3
|
+
Version: 1.0.6
|
|
4
|
+
Summary: UfaFlow
|
|
5
|
+
Author: halif
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: PyYAML>=6.0
|
|
10
|
+
Dynamic: author
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
<h1 align="center">UfaFlow</h1>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<img src="https://img.shields.io/badge/Version-1.0.6-7c3aed?style=for-the-badge" alt="version" />
|
|
17
|
+
<img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="license" />
|
|
18
|
+
<img src="https://img.shields.io/badge/Python-3.11%2B-3776AB?style=for-the-badge&logo=python&logoColor=white" alt="python" />
|
|
19
|
+
<img src="https://img.shields.io/badge/Release-1.0.6-10b981?style=for-the-badge" alt="release" />
|
|
20
|
+
<img src="https://img.shields.io/badge/FastAPI-✓-009688?style=for-the-badge&logo=fastapi&logoColor=white" alt="fastapi" />
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
<p align="center">
|
|
24
|
+
<a href="https://github.com/halif/ufaflow/actions">
|
|
25
|
+
<img src="https://img.shields.io/github/actions/workflow/status/halif/ufaflow/ci.yml?branch=main&label=CI&style=flat-square" alt="CI status" />
|
|
26
|
+
</a>
|
|
27
|
+
<img src="https://img.shields.io/badge/security-audits-passing-16a34a?style=flat-square" alt="security audits" />
|
|
28
|
+
<img src="https://img.shields.io/badge/status-stable-22c55e?style=flat-square" alt="status" />
|
|
29
|
+
<img src="https://img.shields.io/github/stars/halif/ufaflow?style=flat-square&logo=github&label=stars" alt="stars" />
|
|
30
|
+
<img src="https://img.shields.io/github/issues/halif/ufaflow?style=flat-square&logo=github&label=issues" alt="issues" />
|
|
31
|
+
<img src="https://img.shields.io/github/last-commit/halif/ufaflow?style=flat-square&logo=github&label=last%20commit" alt="last commit" />
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
## Overview
|
|
35
|
+
|
|
36
|
+
UfaFlow is a lightweight asynchronous worker manager and load balancer for Python services. It distributes traffic across worker processes with predictable lifecycle control, simple configuration, and async-first execution.
|
|
37
|
+
|
|
38
|
+
The project is designed for teams that need:
|
|
39
|
+
- a simple worker runtime
|
|
40
|
+
- process-level isolation
|
|
41
|
+
- async-first execution
|
|
42
|
+
- predictable startup and shutdown behavior
|
|
43
|
+
- easy deployment in development and production-like environments
|
|
44
|
+
|
|
45
|
+
## Why UfaFlow
|
|
46
|
+
|
|
47
|
+
Many applications need more than a single process, but do not require the complexity of a full orchestration platform. UfaFlow acts as a lightweight execution layer that helps you:
|
|
48
|
+
- manage multiple workers with a single runtime
|
|
49
|
+
- balance traffic or task distribution across processes
|
|
50
|
+
- keep lifecycle management explicit and controllable
|
|
51
|
+
- reduce operational overhead for small-to-medium service deployments
|
|
52
|
+
|
|
53
|
+
## Key Features
|
|
54
|
+
|
|
55
|
+
- Async-first worker orchestration
|
|
56
|
+
- Configurable worker pool and process management
|
|
57
|
+
- Lightweight request balancing across workers
|
|
58
|
+
- Support for FastAPI and Python service integration
|
|
59
|
+
- YAML-based configuration
|
|
60
|
+
- Clean startup and shutdown lifecycle behavior
|
|
61
|
+
- Simple deployment model for local and cloud environments
|
|
62
|
+
|
|
63
|
+
## Installation
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install ufaflow
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Quick start
|
|
70
|
+
|
|
71
|
+
Create a YAML config file:
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
health_interval: 5
|
|
75
|
+
health_timeout: 2
|
|
76
|
+
frontends:
|
|
77
|
+
- listen_host: "0.0.0.0"
|
|
78
|
+
listen_port: 8080
|
|
79
|
+
algorithm: round_robin
|
|
80
|
+
backends:
|
|
81
|
+
- host: "127.0.0.1"
|
|
82
|
+
port: 8001
|
|
83
|
+
- host: "127.0.0.1"
|
|
84
|
+
port: 8002
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Run the load balancer:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
python -m ufaflow.cli config.yaml
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
To run with multiple worker processes:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
python -m ufaflow.cli config.yaml --workers 4
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT
|
ufaflow-1.0.6/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<h1 align="center">UfaFlow</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="https://img.shields.io/badge/Version-1.0.6-7c3aed?style=for-the-badge" alt="version" />
|
|
5
|
+
<img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="license" />
|
|
6
|
+
<img src="https://img.shields.io/badge/Python-3.11%2B-3776AB?style=for-the-badge&logo=python&logoColor=white" alt="python" />
|
|
7
|
+
<img src="https://img.shields.io/badge/Release-1.0.6-10b981?style=for-the-badge" alt="release" />
|
|
8
|
+
<img src="https://img.shields.io/badge/FastAPI-✓-009688?style=for-the-badge&logo=fastapi&logoColor=white" alt="fastapi" />
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://github.com/halif/ufaflow/actions">
|
|
13
|
+
<img src="https://img.shields.io/github/actions/workflow/status/halif/ufaflow/ci.yml?branch=main&label=CI&style=flat-square" alt="CI status" />
|
|
14
|
+
</a>
|
|
15
|
+
<img src="https://img.shields.io/badge/security-audits-passing-16a34a?style=flat-square" alt="security audits" />
|
|
16
|
+
<img src="https://img.shields.io/badge/status-stable-22c55e?style=flat-square" alt="status" />
|
|
17
|
+
<img src="https://img.shields.io/github/stars/halif/ufaflow?style=flat-square&logo=github&label=stars" alt="stars" />
|
|
18
|
+
<img src="https://img.shields.io/github/issues/halif/ufaflow?style=flat-square&logo=github&label=issues" alt="issues" />
|
|
19
|
+
<img src="https://img.shields.io/github/last-commit/halif/ufaflow?style=flat-square&logo=github&label=last%20commit" alt="last commit" />
|
|
20
|
+
</p>
|
|
21
|
+
|
|
22
|
+
## Overview
|
|
23
|
+
|
|
24
|
+
UfaFlow is a lightweight asynchronous worker manager and load balancer for Python services. It distributes traffic across worker processes with predictable lifecycle control, simple configuration, and async-first execution.
|
|
25
|
+
|
|
26
|
+
The project is designed for teams that need:
|
|
27
|
+
- a simple worker runtime
|
|
28
|
+
- process-level isolation
|
|
29
|
+
- async-first execution
|
|
30
|
+
- predictable startup and shutdown behavior
|
|
31
|
+
- easy deployment in development and production-like environments
|
|
32
|
+
|
|
33
|
+
## Why UfaFlow
|
|
34
|
+
|
|
35
|
+
Many applications need more than a single process, but do not require the complexity of a full orchestration platform. UfaFlow acts as a lightweight execution layer that helps you:
|
|
36
|
+
- manage multiple workers with a single runtime
|
|
37
|
+
- balance traffic or task distribution across processes
|
|
38
|
+
- keep lifecycle management explicit and controllable
|
|
39
|
+
- reduce operational overhead for small-to-medium service deployments
|
|
40
|
+
|
|
41
|
+
## Key Features
|
|
42
|
+
|
|
43
|
+
- Async-first worker orchestration
|
|
44
|
+
- Configurable worker pool and process management
|
|
45
|
+
- Lightweight request balancing across workers
|
|
46
|
+
- Support for FastAPI and Python service integration
|
|
47
|
+
- YAML-based configuration
|
|
48
|
+
- Clean startup and shutdown lifecycle behavior
|
|
49
|
+
- Simple deployment model for local and cloud environments
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install ufaflow
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
Create a YAML config file:
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
health_interval: 5
|
|
63
|
+
health_timeout: 2
|
|
64
|
+
frontends:
|
|
65
|
+
- listen_host: "0.0.0.0"
|
|
66
|
+
listen_port: 8080
|
|
67
|
+
algorithm: round_robin
|
|
68
|
+
backends:
|
|
69
|
+
- host: "127.0.0.1"
|
|
70
|
+
port: 8001
|
|
71
|
+
- host: "127.0.0.1"
|
|
72
|
+
port: 8002
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Run the load balancer:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python -m ufaflow.cli config.yaml
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
To run with multiple worker processes:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
python -m ufaflow.cli config.yaml --workers 4
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ufaflow"
|
|
7
|
+
version = "1.0.6"
|
|
8
|
+
description = "UfaFlow"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"PyYAML>=6.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[tool.setuptools]
|
|
16
|
+
packages = ["ufaflow"]
|
|
17
|
+
|
|
18
|
+
[tool.black]
|
|
19
|
+
line-length = 88
|
|
20
|
+
target-version = ["py311"]
|
|
21
|
+
|
|
22
|
+
[tool.isort]
|
|
23
|
+
profile = "black"
|
|
24
|
+
line_length = 88
|
|
25
|
+
|
|
26
|
+
[tool.ruff]
|
|
27
|
+
line-length = 88
|
|
28
|
+
fix = true
|
|
29
|
+
|
|
30
|
+
[tool.ruff.lint]
|
|
31
|
+
select = ["E", "F", "W", "I", "B", "C90"]
|
|
32
|
+
ignore = ["C901"]
|
ufaflow-1.0.6/setup.cfg
ADDED
ufaflow-1.0.6/setup.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from setuptools import find_packages, setup
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="ufaflow",
|
|
5
|
+
version="0.1.0",
|
|
6
|
+
description="UfaFlow — lightweight L4/L7 load balancer (MVP)",
|
|
7
|
+
packages=find_packages(),
|
|
8
|
+
install_requires=[
|
|
9
|
+
"PyYAML",
|
|
10
|
+
# optional: uvloop improves async performance if installed
|
|
11
|
+
],
|
|
12
|
+
entry_points={"console_scripts": ["ufaflow=ufaflow.cli:main"]},
|
|
13
|
+
author="halif",
|
|
14
|
+
license="MIT",
|
|
15
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from ufaflow.server import Backend, BackendPool
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_round_robin_simple():
|
|
5
|
+
b1 = Backend("127.0.0.1", 8001)
|
|
6
|
+
b2 = Backend("127.0.0.1", 8002)
|
|
7
|
+
pool = BackendPool([b1, b2], algorithm="round_robin")
|
|
8
|
+
seq = [pool.choose().name for _ in range(4)]
|
|
9
|
+
assert seq[0] != seq[1]
|
|
10
|
+
assert len(set(seq)) == 2
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_least_conn():
|
|
14
|
+
b1 = Backend("127.0.0.1", 8001)
|
|
15
|
+
b2 = Backend("127.0.0.1", 8002)
|
|
16
|
+
b2.current_conns = 5
|
|
17
|
+
pool = BackendPool([b1, b2], algorithm="least_conn")
|
|
18
|
+
chosen = pool.choose()
|
|
19
|
+
assert chosen is b1
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from ufaflow import __version__
|
|
6
|
+
from ufaflow.cli import validate_config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_package_version_is_exposed():
|
|
10
|
+
assert __version__
|
|
11
|
+
assert isinstance(__version__, str)
|
|
12
|
+
assert __version__.count(".") >= 1
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_validate_config_accepts_valid_config():
|
|
16
|
+
config = {
|
|
17
|
+
"frontends": [
|
|
18
|
+
{
|
|
19
|
+
"listen_host": "0.0.0.0",
|
|
20
|
+
"listen_port": 8080,
|
|
21
|
+
"backends": [
|
|
22
|
+
{"host": "127.0.0.1", "port": 8001},
|
|
23
|
+
{"host": "127.0.0.1", "port": 8002},
|
|
24
|
+
],
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
validated = validate_config(config)
|
|
30
|
+
|
|
31
|
+
assert validated["frontends"][0]["listen_port"] == 8080
|
|
32
|
+
assert len(validated["frontends"][0]["backends"]) == 2
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_cli_help_runs_successfully():
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
[sys.executable, "-m", "ufaflow.cli", "--help"],
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
check=False,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
assert result.returncode == 0
|
|
44
|
+
assert "usage:" in result.stdout.lower()
|
|
45
|
+
assert "UfaFlow" in result.stdout
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CLI / process manager for UfaFlow
|
|
4
|
+
|
|
5
|
+
Supports:
|
|
6
|
+
- single-process run: python -m ufaflow.cli config.yaml
|
|
7
|
+
- worker-mode: python -m ufaflow.cli config.yaml --workers N
|
|
8
|
+
|
|
9
|
+
Manager will spawn N workers; each worker binds sockets with SO_REUSEPORT
|
|
10
|
+
(if available). SIGHUP on manager -> restart workers (reload config).
|
|
11
|
+
SIGTERM/SIGINT -> graceful shutdown.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import logging
|
|
16
|
+
import multiprocessing as mp
|
|
17
|
+
import os
|
|
18
|
+
import signal
|
|
19
|
+
import socket
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from typing import Any, List
|
|
23
|
+
|
|
24
|
+
import yaml
|
|
25
|
+
|
|
26
|
+
# try to use uvloop in workers
|
|
27
|
+
try:
|
|
28
|
+
import uvloop # type: ignore
|
|
29
|
+
except Exception:
|
|
30
|
+
uvloop = None
|
|
31
|
+
|
|
32
|
+
from . import server as server_mod
|
|
33
|
+
|
|
34
|
+
logging.basicConfig(
|
|
35
|
+
level=logging.INFO,
|
|
36
|
+
format="%(asctime)s %(levelname)s %(message)s",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def validate_backend(
|
|
41
|
+
backend: Any,
|
|
42
|
+
frontend_index: int,
|
|
43
|
+
backend_index: int,
|
|
44
|
+
) -> None:
|
|
45
|
+
if not isinstance(backend, dict):
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"Invalid frontend[{frontend_index}] backend[{backend_index}]: "
|
|
48
|
+
"expected a mapping with host and port."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if "host" not in backend or "port" not in backend:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Invalid frontend[{frontend_index}] backend[{backend_index}]: "
|
|
54
|
+
"must include 'host' and 'port'."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if not isinstance(backend["host"], str) or not backend["host"].strip():
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"Invalid frontend[{frontend_index}] backend[{backend_index}]: "
|
|
60
|
+
"'host' must be a non-empty string."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
port = backend["port"]
|
|
64
|
+
if not isinstance(port, int) or port <= 0 or port > 65535:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"Invalid frontend[{frontend_index}] backend[{backend_index}]: "
|
|
67
|
+
"'port' must be an integer between 1 and 65535."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def validate_frontend(frontend: Any, index: int) -> None:
|
|
72
|
+
if not isinstance(frontend, dict):
|
|
73
|
+
raise ValueError(f"Invalid frontend[{index}]: expected a mapping.")
|
|
74
|
+
|
|
75
|
+
if "listen_port" not in frontend:
|
|
76
|
+
raise ValueError(f"Invalid frontend[{index}]: missing 'listen_port'.")
|
|
77
|
+
|
|
78
|
+
port = frontend["listen_port"]
|
|
79
|
+
if not isinstance(port, int) or port <= 0 or port > 65535:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"Invalid frontend[{index}]: 'listen_port' must be an integer "
|
|
82
|
+
"between 1 and 65535."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
backends = frontend.get("backends", [])
|
|
86
|
+
if not isinstance(backends, list) or not backends:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"Invalid frontend[{index}]: 'backends' must be a non-empty list."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
for backend_index, backend in enumerate(backends):
|
|
92
|
+
validate_backend(backend, index, backend_index)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def validate_config(config: Any) -> dict:
|
|
96
|
+
if not isinstance(config, dict):
|
|
97
|
+
raise ValueError("Configuration must be a mapping/object.")
|
|
98
|
+
|
|
99
|
+
frontends = config.get("frontends")
|
|
100
|
+
if not isinstance(frontends, list) or not frontends:
|
|
101
|
+
raise ValueError("Configuration error: 'frontends' must be a non-empty list.")
|
|
102
|
+
|
|
103
|
+
for index, frontend in enumerate(frontends):
|
|
104
|
+
validate_frontend(frontend, index)
|
|
105
|
+
|
|
106
|
+
return config
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_config(path: str):
|
|
110
|
+
try:
|
|
111
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
112
|
+
config = yaml.safe_load(f)
|
|
113
|
+
except FileNotFoundError as exc:
|
|
114
|
+
raise ValueError(f"Config file not found: {path}") from exc
|
|
115
|
+
except yaml.YAMLError as exc:
|
|
116
|
+
raise ValueError(f"Invalid YAML in config file '{path}': {exc}") from exc
|
|
117
|
+
|
|
118
|
+
return validate_config(config)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def create_listen_sockets(frontends: list) -> List[socket.socket]:
|
|
122
|
+
socks = []
|
|
123
|
+
for fe in frontends:
|
|
124
|
+
host = fe.get("listen_host", "0.0.0.0")
|
|
125
|
+
port = fe["listen_port"]
|
|
126
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
127
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
128
|
+
# try to set SO_REUSEPORT; may raise AttributeError on some platforms
|
|
129
|
+
try:
|
|
130
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
|
131
|
+
logging.info("Using SO_REUSEPORT on %s:%d", host, port)
|
|
132
|
+
except AttributeError:
|
|
133
|
+
logging.warning(
|
|
134
|
+
"SO_REUSEPORT not supported on this platform; multi-process "
|
|
135
|
+
"gain limited"
|
|
136
|
+
)
|
|
137
|
+
except OSError:
|
|
138
|
+
logging.warning(
|
|
139
|
+
"SO_REUSEPORT not available / permission denied; continuing "
|
|
140
|
+
"without it"
|
|
141
|
+
)
|
|
142
|
+
sock.bind((host, port))
|
|
143
|
+
sock.listen(1000)
|
|
144
|
+
sock.setblocking(False)
|
|
145
|
+
socks.append(sock)
|
|
146
|
+
return socks
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def worker_main(config_path: str):
|
|
150
|
+
# Worker process entrypoint
|
|
151
|
+
if uvloop:
|
|
152
|
+
asyncio_policy = uvloop.EventLoopPolicy()
|
|
153
|
+
import asyncio as _asyncio
|
|
154
|
+
|
|
155
|
+
_asyncio.set_event_loop_policy(asyncio_policy)
|
|
156
|
+
|
|
157
|
+
import asyncio
|
|
158
|
+
|
|
159
|
+
config = load_config(config_path)
|
|
160
|
+
frontends = config.get("frontends", [])
|
|
161
|
+
|
|
162
|
+
# In worker mode we create sockets independently (each worker must set
|
|
163
|
+
# SO_REUSEPORT before bind), but manager may also create sockets and
|
|
164
|
+
# pass FDs via environment variable.
|
|
165
|
+
fds_env = os.environ.get("UFAFLOW_LISTEN_FDS")
|
|
166
|
+
if fds_env:
|
|
167
|
+
fds = [int(x) for x in fds_env.split(",") if x.strip()]
|
|
168
|
+
socks = [socket.fromfd(fd, socket.AF_INET, socket.SOCK_STREAM) for fd in fds]
|
|
169
|
+
# set non-blocking
|
|
170
|
+
for s in socks:
|
|
171
|
+
s.setblocking(False)
|
|
172
|
+
else:
|
|
173
|
+
# fallback: each worker creates its own sockets (SO_REUSEPORT set by
|
|
174
|
+
# manager earlier)
|
|
175
|
+
socks = create_listen_sockets(frontends)
|
|
176
|
+
|
|
177
|
+
async def _run():
|
|
178
|
+
try:
|
|
179
|
+
await server_mod.run_worker_with_sockets(config, socks)
|
|
180
|
+
except Exception as ex:
|
|
181
|
+
logging.exception("Worker encountered exception: %s", ex)
|
|
182
|
+
raise
|
|
183
|
+
|
|
184
|
+
loop = asyncio.new_event_loop()
|
|
185
|
+
asyncio.set_event_loop(loop)
|
|
186
|
+
|
|
187
|
+
# graceful shutdown handler
|
|
188
|
+
stop = False
|
|
189
|
+
|
|
190
|
+
def _on_term(signum, frame):
|
|
191
|
+
nonlocal stop
|
|
192
|
+
logging.info("Worker got signal %s, stopping...", signum)
|
|
193
|
+
stop = True
|
|
194
|
+
for task in asyncio.all_tasks(loop):
|
|
195
|
+
task.cancel()
|
|
196
|
+
|
|
197
|
+
signal.signal(signal.SIGINT, _on_term)
|
|
198
|
+
signal.signal(signal.SIGTERM, _on_term)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
loop.run_until_complete(_run())
|
|
202
|
+
except KeyboardInterrupt:
|
|
203
|
+
pass
|
|
204
|
+
finally:
|
|
205
|
+
loop.run_until_complete(loop.shutdown_asyncgens())
|
|
206
|
+
loop.close()
|
|
207
|
+
logging.info("Worker exiting")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def run_manager(config_path: str, workers: int): # noqa: C901
|
|
211
|
+
"""
|
|
212
|
+
Manager: spawn `workers` worker processes. On SIGHUP restart workers
|
|
213
|
+
(reload config).
|
|
214
|
+
"""
|
|
215
|
+
config = load_config(config_path)
|
|
216
|
+
frontends = config.get("frontends", [])
|
|
217
|
+
# Manager will set env UFAFLOW_LISTEN_FDS with fds of created sockets
|
|
218
|
+
# (so workers can inherit).
|
|
219
|
+
socks = create_listen_sockets(frontends)
|
|
220
|
+
fds = [s.fileno() for s in socks]
|
|
221
|
+
fds_str = ",".join(str(fd) for fd in fds)
|
|
222
|
+
|
|
223
|
+
# children: mapping pid -> Process
|
|
224
|
+
children = {}
|
|
225
|
+
|
|
226
|
+
def spawn_workers():
|
|
227
|
+
logging.info("Spawning %d workers...", workers)
|
|
228
|
+
for _ in range(workers):
|
|
229
|
+
env = os.environ.copy()
|
|
230
|
+
env["UFAFLOW_LISTEN_FDS"] = fds_str
|
|
231
|
+
p = mp.Process(
|
|
232
|
+
target=_worker_process_wrapper,
|
|
233
|
+
args=(config_path,),
|
|
234
|
+
env=env,
|
|
235
|
+
)
|
|
236
|
+
p.start()
|
|
237
|
+
children[p.pid] = p
|
|
238
|
+
logging.info("Spawned worker pid=%d", p.pid)
|
|
239
|
+
|
|
240
|
+
def stop_workers():
|
|
241
|
+
logging.info("Stopping workers...")
|
|
242
|
+
for pid, p in list(children.items()):
|
|
243
|
+
logging.info("Terminating worker pid=%d", pid)
|
|
244
|
+
p.terminate()
|
|
245
|
+
# join/ensure termination
|
|
246
|
+
for pid, p in list(children.items()):
|
|
247
|
+
p.join(timeout=5)
|
|
248
|
+
if p.is_alive():
|
|
249
|
+
logging.info("Killing worker pid=%d", pid)
|
|
250
|
+
p.kill()
|
|
251
|
+
children.pop(pid, None)
|
|
252
|
+
|
|
253
|
+
def restart_workers():
|
|
254
|
+
logging.info("Restarting workers (SIGHUP)...")
|
|
255
|
+
stop_workers()
|
|
256
|
+
spawn_workers()
|
|
257
|
+
|
|
258
|
+
# SIGHUP -> restart
|
|
259
|
+
def _on_sighup(signum, frame):
|
|
260
|
+
logging.info("Manager received SIGHUP")
|
|
261
|
+
restart_workers()
|
|
262
|
+
|
|
263
|
+
def _on_term(signum, frame):
|
|
264
|
+
logging.info("Manager received SIGTERM/SIGINT, shutting down")
|
|
265
|
+
stop_workers()
|
|
266
|
+
sys.exit(0)
|
|
267
|
+
|
|
268
|
+
signal.signal(signal.SIGHUP, _on_sighup)
|
|
269
|
+
signal.signal(signal.SIGINT, _on_term)
|
|
270
|
+
signal.signal(signal.SIGTERM, _on_term)
|
|
271
|
+
|
|
272
|
+
spawn_workers()
|
|
273
|
+
# Wait for children; respawn workers if they exit unexpectedly
|
|
274
|
+
try:
|
|
275
|
+
while True:
|
|
276
|
+
time.sleep(1)
|
|
277
|
+
# reap dead children and respawn
|
|
278
|
+
for pid, p in list(children.items()):
|
|
279
|
+
if not p.is_alive():
|
|
280
|
+
logging.warning(
|
|
281
|
+
"Worker pid=%d exited unexpectedly; restarting", pid
|
|
282
|
+
)
|
|
283
|
+
children.pop(pid, None)
|
|
284
|
+
p.join()
|
|
285
|
+
env = os.environ.copy()
|
|
286
|
+
env["UFAFLOW_LISTEN_FDS"] = fds_str
|
|
287
|
+
p2 = mp.Process(
|
|
288
|
+
target=_worker_process_wrapper,
|
|
289
|
+
args=(config_path,),
|
|
290
|
+
env=env,
|
|
291
|
+
)
|
|
292
|
+
p2.start()
|
|
293
|
+
children[p2.pid] = p2
|
|
294
|
+
logging.info("Respawned worker pid=%d", p2.pid)
|
|
295
|
+
except KeyboardInterrupt:
|
|
296
|
+
logging.info("Manager got KeyboardInterrupt, stopping workers")
|
|
297
|
+
stop_workers()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _worker_process_wrapper(config_path: str):
|
|
301
|
+
# Runs in child process created by multiprocessing.Process. Child reads
|
|
302
|
+
# UFAFLOW_LISTEN_FDS from os.environ (safer across platforms).
|
|
303
|
+
worker_main(config_path)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def run_single(config_path: str): # noqa: C901
|
|
307
|
+
# single-process run (no manager)
|
|
308
|
+
if uvloop:
|
|
309
|
+
import asyncio as _asyncio
|
|
310
|
+
|
|
311
|
+
_asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
|
312
|
+
import asyncio
|
|
313
|
+
|
|
314
|
+
config = load_config(config_path)
|
|
315
|
+
|
|
316
|
+
async def _run():
|
|
317
|
+
servers, tasks = await server_mod.start_servers_from_config(config)
|
|
318
|
+
active_servers = []
|
|
319
|
+
# start servers normally (no external sockets)
|
|
320
|
+
for host, port, pool in servers:
|
|
321
|
+
srv = await asyncio.start_server(
|
|
322
|
+
lambda r, w, p=pool: server_mod.handle_client(r, w, p),
|
|
323
|
+
host=host,
|
|
324
|
+
port=port,
|
|
325
|
+
)
|
|
326
|
+
logging.info("Listening on %s:%d", host, port)
|
|
327
|
+
active_servers.append(srv)
|
|
328
|
+
try:
|
|
329
|
+
await asyncio.gather(
|
|
330
|
+
*[s.serve_forever() for s in active_servers],
|
|
331
|
+
*tasks,
|
|
332
|
+
)
|
|
333
|
+
except asyncio.CancelledError:
|
|
334
|
+
logging.info("Shutting down single-process server")
|
|
335
|
+
finally:
|
|
336
|
+
for s in active_servers:
|
|
337
|
+
s.close()
|
|
338
|
+
await s.wait_closed()
|
|
339
|
+
for t in tasks:
|
|
340
|
+
t.cancel()
|
|
341
|
+
try:
|
|
342
|
+
await t
|
|
343
|
+
except Exception:
|
|
344
|
+
pass
|
|
345
|
+
|
|
346
|
+
loop = asyncio.new_event_loop()
|
|
347
|
+
asyncio.set_event_loop(loop)
|
|
348
|
+
|
|
349
|
+
def _on_term(signum, frame):
|
|
350
|
+
logging.info("Single-process received term, cancelling tasks")
|
|
351
|
+
for task in asyncio.all_tasks(loop):
|
|
352
|
+
task.cancel()
|
|
353
|
+
|
|
354
|
+
signal.signal(signal.SIGINT, _on_term)
|
|
355
|
+
signal.signal(signal.SIGTERM, _on_term)
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
loop.run_until_complete(_run())
|
|
359
|
+
except KeyboardInterrupt:
|
|
360
|
+
pass
|
|
361
|
+
finally:
|
|
362
|
+
loop.run_until_complete(loop.shutdown_asyncgens())
|
|
363
|
+
loop.close()
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def main():
|
|
367
|
+
parser = argparse.ArgumentParser(description="UfaFlow load balancer")
|
|
368
|
+
parser.add_argument("config", help="Path to YAML config")
|
|
369
|
+
parser.add_argument(
|
|
370
|
+
"--workers",
|
|
371
|
+
"-w",
|
|
372
|
+
type=int,
|
|
373
|
+
default=1,
|
|
374
|
+
help="Number of worker processes (default 1)",
|
|
375
|
+
)
|
|
376
|
+
args = parser.parse_args()
|
|
377
|
+
|
|
378
|
+
if args.workers < 1:
|
|
379
|
+
parser.error("--workers must be greater than or equal to 1")
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
if args.workers <= 1:
|
|
383
|
+
run_single(args.config)
|
|
384
|
+
else:
|
|
385
|
+
run_manager(args.config, args.workers)
|
|
386
|
+
except ValueError as exc:
|
|
387
|
+
print(f"Configuration error: {exc}", file=sys.stderr)
|
|
388
|
+
raise SystemExit(1) from exc
|
|
389
|
+
except FileNotFoundError as exc:
|
|
390
|
+
print(f"Configuration file not found: {exc.filename}", file=sys.stderr)
|
|
391
|
+
raise SystemExit(1) from exc
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
if __name__ == "__main__":
|
|
395
|
+
main()
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import socket
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
logging.getLogger().setLevel(logging.INFO)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Backend:
|
|
13
|
+
host: str
|
|
14
|
+
port: int
|
|
15
|
+
weight: int = 1
|
|
16
|
+
name: Optional[str] = None
|
|
17
|
+
up: bool = True
|
|
18
|
+
current_conns: int = 0
|
|
19
|
+
last_checked: float = 0.0
|
|
20
|
+
|
|
21
|
+
def __post_init__(self):
|
|
22
|
+
if not self.name:
|
|
23
|
+
self.name = f"{self.host}:{self.port}"
|
|
24
|
+
|
|
25
|
+
def addr(self):
|
|
26
|
+
return (self.host, self.port)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BackendPool:
|
|
30
|
+
def __init__(self, backends: List[Backend], algorithm: str = "round_robin"):
|
|
31
|
+
self.backends = backends
|
|
32
|
+
self.algorithm = algorithm
|
|
33
|
+
self._rr_state = 0
|
|
34
|
+
|
|
35
|
+
def available(self) -> List[Backend]:
|
|
36
|
+
return [b for b in self.backends if b.up]
|
|
37
|
+
|
|
38
|
+
def choose(self, client_ip: Optional[str] = None) -> Optional[Backend]:
|
|
39
|
+
av = self.available()
|
|
40
|
+
if not av:
|
|
41
|
+
return None
|
|
42
|
+
if self.algorithm == "least_conn":
|
|
43
|
+
return min(av, key=lambda b: b.current_conns)
|
|
44
|
+
# simple weighted round-robin
|
|
45
|
+
choices = []
|
|
46
|
+
for b in av:
|
|
47
|
+
choices.extend([b] * max(1, b.weight))
|
|
48
|
+
self._rr_state = (self._rr_state + 1) % len(choices)
|
|
49
|
+
return choices[self._rr_state]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def relay_stream(
|
|
53
|
+
reader: asyncio.StreamReader,
|
|
54
|
+
writer: asyncio.StreamWriter,
|
|
55
|
+
):
|
|
56
|
+
try:
|
|
57
|
+
while True:
|
|
58
|
+
data = await reader.read(64 * 1024)
|
|
59
|
+
if not data:
|
|
60
|
+
break
|
|
61
|
+
writer.write(data)
|
|
62
|
+
await writer.drain()
|
|
63
|
+
except (asyncio.CancelledError, ConnectionResetError, BrokenPipeError):
|
|
64
|
+
pass
|
|
65
|
+
finally:
|
|
66
|
+
try:
|
|
67
|
+
writer.close()
|
|
68
|
+
await writer.wait_closed()
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def handle_client(
|
|
74
|
+
client_reader: asyncio.StreamReader,
|
|
75
|
+
client_writer: asyncio.StreamWriter,
|
|
76
|
+
pool: BackendPool,
|
|
77
|
+
timeout: float = 10.0,
|
|
78
|
+
):
|
|
79
|
+
peer = client_writer.get_extra_info("peername")
|
|
80
|
+
backend = pool.choose(client_ip=peer[0] if peer else None)
|
|
81
|
+
if not backend:
|
|
82
|
+
logging.warning("No available backend for connection from %s", peer)
|
|
83
|
+
try:
|
|
84
|
+
client_writer.close()
|
|
85
|
+
await client_writer.wait_closed()
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
logging.info("Proxying %s -> %s", peer, backend.name)
|
|
91
|
+
backend.current_conns += 1
|
|
92
|
+
try:
|
|
93
|
+
try:
|
|
94
|
+
backend_reader, backend_writer = await asyncio.wait_for(
|
|
95
|
+
asyncio.open_connection(backend.host, backend.port),
|
|
96
|
+
timeout=timeout,
|
|
97
|
+
)
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logging.warning("Failed to connect to backend %s: %s", backend.name, e)
|
|
100
|
+
backend.up = False
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
t1 = asyncio.create_task(relay_stream(client_reader, backend_writer))
|
|
104
|
+
t2 = asyncio.create_task(relay_stream(backend_reader, client_writer))
|
|
105
|
+
await asyncio.wait(
|
|
106
|
+
[t1, t2],
|
|
107
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
108
|
+
)
|
|
109
|
+
for t in (t1, t2):
|
|
110
|
+
if not t.done():
|
|
111
|
+
t.cancel()
|
|
112
|
+
finally:
|
|
113
|
+
backend.current_conns = max(0, backend.current_conns - 1)
|
|
114
|
+
try:
|
|
115
|
+
client_writer.close()
|
|
116
|
+
await client_writer.wait_closed()
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def tcp_health_check_periodic(
|
|
122
|
+
backends: List[Backend],
|
|
123
|
+
interval: int,
|
|
124
|
+
timeout: float,
|
|
125
|
+
):
|
|
126
|
+
while True:
|
|
127
|
+
for b in backends:
|
|
128
|
+
try:
|
|
129
|
+
b.last_checked = time.time()
|
|
130
|
+
fut = asyncio.open_connection(b.host, b.port)
|
|
131
|
+
r, w = await asyncio.wait_for(fut, timeout=timeout)
|
|
132
|
+
try:
|
|
133
|
+
w.close()
|
|
134
|
+
await w.wait_closed()
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
if not b.up:
|
|
138
|
+
logging.info("Backend %s is up", b.name)
|
|
139
|
+
b.up = True
|
|
140
|
+
except Exception:
|
|
141
|
+
if b.up:
|
|
142
|
+
logging.warning("Backend %s is down", b.name)
|
|
143
|
+
b.up = False
|
|
144
|
+
await asyncio.sleep(interval)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def start_servers_from_config(config: dict):
|
|
148
|
+
frontends = config.get("frontends", [])
|
|
149
|
+
pools = []
|
|
150
|
+
tasks = []
|
|
151
|
+
for fe in frontends:
|
|
152
|
+
be_cfgs = fe.get("backends", [])
|
|
153
|
+
backends = [
|
|
154
|
+
Backend(
|
|
155
|
+
b["host"],
|
|
156
|
+
b["port"],
|
|
157
|
+
b.get("weight", 1),
|
|
158
|
+
name=b.get("name"),
|
|
159
|
+
)
|
|
160
|
+
for b in be_cfgs
|
|
161
|
+
]
|
|
162
|
+
pool = BackendPool(
|
|
163
|
+
backends,
|
|
164
|
+
algorithm=fe.get("algorithm", "round_robin"),
|
|
165
|
+
)
|
|
166
|
+
pools.append((fe, pool))
|
|
167
|
+
|
|
168
|
+
all_backends = [b for _, p in pools for b in p.backends]
|
|
169
|
+
# healthcheck task
|
|
170
|
+
tasks.append(
|
|
171
|
+
asyncio.create_task(
|
|
172
|
+
tcp_health_check_periodic(
|
|
173
|
+
all_backends,
|
|
174
|
+
config.get("health_interval", 5),
|
|
175
|
+
config.get("health_timeout", 2),
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
# start frontends
|
|
180
|
+
servers = []
|
|
181
|
+
for fe, pool in pools:
|
|
182
|
+
host = fe.get("listen_host", "0.0.0.0")
|
|
183
|
+
port = fe["listen_port"]
|
|
184
|
+
# We'll create server sockets externally (in worker mode) and pass them
|
|
185
|
+
# into start_server via sock param.
|
|
186
|
+
# For single-process mode, use default start_server (no sock).
|
|
187
|
+
servers.append((host, port, pool))
|
|
188
|
+
return servers, tasks
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def run_worker_with_sockets(config: dict, sockets: List[socket.socket]):
|
|
192
|
+
"""
|
|
193
|
+
sockets: list of listening sockets (must correspond to config frontends order)
|
|
194
|
+
"""
|
|
195
|
+
# set up uvloop already done by CLI
|
|
196
|
+
frontends = config.get("frontends", [])
|
|
197
|
+
pools = []
|
|
198
|
+
for fe in frontends:
|
|
199
|
+
be_cfgs = fe.get("backends", [])
|
|
200
|
+
backends = [
|
|
201
|
+
Backend(
|
|
202
|
+
b["host"],
|
|
203
|
+
b["port"],
|
|
204
|
+
b.get("weight", 1),
|
|
205
|
+
name=b.get("name"),
|
|
206
|
+
)
|
|
207
|
+
for b in be_cfgs
|
|
208
|
+
]
|
|
209
|
+
pool = BackendPool(
|
|
210
|
+
backends,
|
|
211
|
+
algorithm=fe.get("algorithm", "round_robin"),
|
|
212
|
+
)
|
|
213
|
+
pools.append((fe, pool))
|
|
214
|
+
|
|
215
|
+
all_backends = [b for _, p in pools for b in p.backends]
|
|
216
|
+
tasks = []
|
|
217
|
+
tasks.append(
|
|
218
|
+
asyncio.create_task(
|
|
219
|
+
tcp_health_check_periodic(
|
|
220
|
+
all_backends,
|
|
221
|
+
config.get("health_interval", 5),
|
|
222
|
+
config.get("health_timeout", 2),
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
servers = []
|
|
228
|
+
# Create servers using provided sockets (SO_REUSEPORT case)
|
|
229
|
+
for (fe, pool), sock in zip(pools, sockets, strict=False):
|
|
230
|
+
server = await asyncio.start_server(
|
|
231
|
+
lambda r, w, p=pool: handle_client(r, w, p),
|
|
232
|
+
sock=sock,
|
|
233
|
+
)
|
|
234
|
+
addrs = ", ".join(str(s.getsockname()) for s in server.sockets)
|
|
235
|
+
logging.info(
|
|
236
|
+
"Worker listening on %s (frontend %s:%d)",
|
|
237
|
+
addrs,
|
|
238
|
+
fe.get("listen_host", "0.0.0.0"),
|
|
239
|
+
fe["listen_port"],
|
|
240
|
+
)
|
|
241
|
+
servers.append(server)
|
|
242
|
+
|
|
243
|
+
# Wait until cancelled
|
|
244
|
+
try:
|
|
245
|
+
await asyncio.gather(*[s.serve_forever() for s in servers], *tasks)
|
|
246
|
+
except asyncio.CancelledError:
|
|
247
|
+
logging.info("Worker cancelled, shutting down servers")
|
|
248
|
+
finally:
|
|
249
|
+
for s in servers:
|
|
250
|
+
s.close()
|
|
251
|
+
try:
|
|
252
|
+
await s.wait_closed()
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
for t in tasks:
|
|
256
|
+
t.cancel()
|
|
257
|
+
try:
|
|
258
|
+
await t
|
|
259
|
+
except Exception:
|
|
260
|
+
pass
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import socket
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
logging.getLogger().setLevel(logging.INFO)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Backend:
|
|
13
|
+
host: str
|
|
14
|
+
port: int
|
|
15
|
+
weight: int = 1
|
|
16
|
+
name: Optional[str] = None
|
|
17
|
+
up: bool = True
|
|
18
|
+
current_conns: int = 0
|
|
19
|
+
last_checked: float = 0.0
|
|
20
|
+
|
|
21
|
+
def __post_init__(self):
|
|
22
|
+
if not self.name:
|
|
23
|
+
self.name = f"{self.host}:{self.port}"
|
|
24
|
+
|
|
25
|
+
def addr(self):
|
|
26
|
+
return (self.host, self.port)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BackendPool:
|
|
30
|
+
def __init__(self, backends: List[Backend], algorithm: str = "round_robin"):
|
|
31
|
+
self.backends = backends
|
|
32
|
+
self.algorithm = algorithm
|
|
33
|
+
self._rr_state = 0
|
|
34
|
+
|
|
35
|
+
def available(self) -> List[Backend]:
|
|
36
|
+
return [b for b in self.backends if b.up]
|
|
37
|
+
|
|
38
|
+
def choose(self, client_ip: Optional[str] = None) -> Optional[Backend]:
|
|
39
|
+
av = self.available()
|
|
40
|
+
if not av:
|
|
41
|
+
return None
|
|
42
|
+
if self.algorithm == "least_conn":
|
|
43
|
+
return min(av, key=lambda b: b.current_conns)
|
|
44
|
+
# simple weighted round-robin
|
|
45
|
+
choices = []
|
|
46
|
+
for b in av:
|
|
47
|
+
choices.extend([b] * max(1, b.weight))
|
|
48
|
+
self._rr_state = (self._rr_state + 1) % len(choices)
|
|
49
|
+
return choices[self._rr_state]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def relay_stream(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
|
53
|
+
try:
|
|
54
|
+
while True:
|
|
55
|
+
data = await reader.read(64 * 1024)
|
|
56
|
+
if not data:
|
|
57
|
+
break
|
|
58
|
+
writer.write(data)
|
|
59
|
+
await writer.drain()
|
|
60
|
+
except (asyncio.CancelledError, ConnectionResetError, BrokenPipeError):
|
|
61
|
+
pass
|
|
62
|
+
finally:
|
|
63
|
+
try:
|
|
64
|
+
writer.close()
|
|
65
|
+
await writer.wait_closed()
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def handle_client(
|
|
71
|
+
client_reader: asyncio.StreamReader,
|
|
72
|
+
client_writer: asyncio.StreamWriter,
|
|
73
|
+
pool: BackendPool,
|
|
74
|
+
timeout: float = 10.0,
|
|
75
|
+
):
|
|
76
|
+
peer = client_writer.get_extra_info("peername")
|
|
77
|
+
backend = pool.choose(client_ip=peer[0] if peer else None)
|
|
78
|
+
if not backend:
|
|
79
|
+
logging.warning("No available backend for connection from %s", peer)
|
|
80
|
+
try:
|
|
81
|
+
client_writer.close()
|
|
82
|
+
await client_writer.wait_closed()
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
return
|
|
86
|
+
logging.info("Proxying %s -> %s", peer, backend.name)
|
|
87
|
+
backend.current_conns += 1
|
|
88
|
+
try:
|
|
89
|
+
try:
|
|
90
|
+
backend_reader, backend_writer = await asyncio.wait_for(
|
|
91
|
+
asyncio.open_connection(backend.host, backend.port), timeout=timeout
|
|
92
|
+
)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
logging.warning("Failed to connect to backend %s: %s", backend.name, e)
|
|
95
|
+
backend.up = False
|
|
96
|
+
return
|
|
97
|
+
t1 = asyncio.create_task(relay_stream(client_reader, backend_writer))
|
|
98
|
+
t2 = asyncio.create_task(relay_stream(backend_reader, client_writer))
|
|
99
|
+
await asyncio.wait([t1, t2], return_when=asyncio.FIRST_COMPLETED)
|
|
100
|
+
for t in (t1, t2):
|
|
101
|
+
if not t.done():
|
|
102
|
+
t.cancel()
|
|
103
|
+
finally:
|
|
104
|
+
backend.current_conns = max(0, backend.current_conns - 1)
|
|
105
|
+
try:
|
|
106
|
+
client_writer.close()
|
|
107
|
+
await client_writer.wait_closed()
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def tcp_health_check_periodic(
|
|
113
|
+
backends: List[Backend], interval: int, timeout: float
|
|
114
|
+
):
|
|
115
|
+
while True:
|
|
116
|
+
for b in backends:
|
|
117
|
+
try:
|
|
118
|
+
b.last_checked = time.time()
|
|
119
|
+
fut = asyncio.open_connection(b.host, b.port)
|
|
120
|
+
r, w = await asyncio.wait_for(fut, timeout=timeout)
|
|
121
|
+
try:
|
|
122
|
+
w.close()
|
|
123
|
+
await w.wait_closed()
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
if not b.up:
|
|
127
|
+
logging.info("Backend %s is up", b.name)
|
|
128
|
+
b.up = True
|
|
129
|
+
except Exception:
|
|
130
|
+
if b.up:
|
|
131
|
+
logging.warning("Backend %s is down", b.name)
|
|
132
|
+
b.up = False
|
|
133
|
+
await asyncio.sleep(interval)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def start_servers_from_config(config: dict):
|
|
137
|
+
frontends = config.get("frontends", [])
|
|
138
|
+
pools = []
|
|
139
|
+
tasks = []
|
|
140
|
+
for fe in frontends:
|
|
141
|
+
be_cfgs = fe.get("backends", [])
|
|
142
|
+
backends = [
|
|
143
|
+
Backend(b["host"], b["port"], b.get("weight", 1), name=b.get("name"))
|
|
144
|
+
for b in be_cfgs
|
|
145
|
+
]
|
|
146
|
+
pool = BackendPool(backends, algorithm=fe.get("algorithm", "round_robin"))
|
|
147
|
+
pools.append((fe, pool))
|
|
148
|
+
|
|
149
|
+
all_backends = [b for _, p in pools for b in p.backends]
|
|
150
|
+
# healthcheck task
|
|
151
|
+
tasks.append(
|
|
152
|
+
asyncio.create_task(
|
|
153
|
+
tcp_health_check_periodic(
|
|
154
|
+
all_backends,
|
|
155
|
+
config.get("health_interval", 5),
|
|
156
|
+
config.get("health_timeout", 2),
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
# start frontends
|
|
161
|
+
servers = []
|
|
162
|
+
for fe, pool in pools:
|
|
163
|
+
host = fe.get("listen_host", "0.0.0.0")
|
|
164
|
+
port = fe["listen_port"]
|
|
165
|
+
# We'll create server sockets externally (in worker mode) and pass them
|
|
166
|
+
# into start_server via sock param.
|
|
167
|
+
# For single-process mode, use default start_server (no sock).
|
|
168
|
+
servers.append((host, port, pool))
|
|
169
|
+
return servers, tasks
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def run_worker_with_sockets(config: dict, sockets: List[socket.socket]):
|
|
173
|
+
"""
|
|
174
|
+
sockets: list of listening sockets (must correspond to config frontends order)
|
|
175
|
+
"""
|
|
176
|
+
# set up uvloop already done by CLI
|
|
177
|
+
frontends = config.get("frontends", [])
|
|
178
|
+
pools = []
|
|
179
|
+
for fe in frontends:
|
|
180
|
+
be_cfgs = fe.get("backends", [])
|
|
181
|
+
backends = [
|
|
182
|
+
Backend(b["host"], b["port"], b.get("weight", 1), name=b.get("name"))
|
|
183
|
+
for b in be_cfgs
|
|
184
|
+
]
|
|
185
|
+
pool = BackendPool(backends, algorithm=fe.get("algorithm", "round_robin"))
|
|
186
|
+
pools.append((fe, pool))
|
|
187
|
+
|
|
188
|
+
all_backends = [b for _, p in pools for b in p.backends]
|
|
189
|
+
tasks = []
|
|
190
|
+
tasks.append(
|
|
191
|
+
asyncio.create_task(
|
|
192
|
+
tcp_health_check_periodic(
|
|
193
|
+
all_backends,
|
|
194
|
+
config.get("health_interval", 5),
|
|
195
|
+
config.get("health_timeout", 2),
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
servers = []
|
|
201
|
+
# Create servers using provided sockets (SO_REUSEPORT case)
|
|
202
|
+
for (fe, pool), sock in zip(pools, sockets, strict=False):
|
|
203
|
+
server = await asyncio.start_server(
|
|
204
|
+
lambda r, w, p=pool: handle_client(r, w, p), sock=sock
|
|
205
|
+
)
|
|
206
|
+
addrs = ", ".join(str(s.getsockname()) for s in server.sockets)
|
|
207
|
+
logging.info(
|
|
208
|
+
"Worker listening on %s (frontend %s:%d)",
|
|
209
|
+
addrs,
|
|
210
|
+
fe.get("listen_host", "0.0.0.0"),
|
|
211
|
+
fe["listen_port"],
|
|
212
|
+
)
|
|
213
|
+
servers.append(server)
|
|
214
|
+
|
|
215
|
+
# Wait until cancelled
|
|
216
|
+
try:
|
|
217
|
+
await asyncio.gather(*[s.serve_forever() for s in servers], *tasks)
|
|
218
|
+
except asyncio.CancelledError:
|
|
219
|
+
logging.info("Worker cancelled, shutting down servers")
|
|
220
|
+
finally:
|
|
221
|
+
for s in servers:
|
|
222
|
+
s.close()
|
|
223
|
+
try:
|
|
224
|
+
await s.wait_closed()
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
for t in tasks:
|
|
228
|
+
t.cancel()
|
|
229
|
+
try:
|
|
230
|
+
await t
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ufaflow
|
|
3
|
+
Version: 1.0.6
|
|
4
|
+
Summary: UfaFlow
|
|
5
|
+
Author: halif
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: PyYAML>=6.0
|
|
10
|
+
Dynamic: author
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
<h1 align="center">UfaFlow</h1>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<img src="https://img.shields.io/badge/Version-1.0.6-7c3aed?style=for-the-badge" alt="version" />
|
|
17
|
+
<img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="license" />
|
|
18
|
+
<img src="https://img.shields.io/badge/Python-3.11%2B-3776AB?style=for-the-badge&logo=python&logoColor=white" alt="python" />
|
|
19
|
+
<img src="https://img.shields.io/badge/Release-1.0.6-10b981?style=for-the-badge" alt="release" />
|
|
20
|
+
<img src="https://img.shields.io/badge/FastAPI-✓-009688?style=for-the-badge&logo=fastapi&logoColor=white" alt="fastapi" />
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
<p align="center">
|
|
24
|
+
<a href="https://github.com/halif/ufaflow/actions">
|
|
25
|
+
<img src="https://img.shields.io/github/actions/workflow/status/halif/ufaflow/ci.yml?branch=main&label=CI&style=flat-square" alt="CI status" />
|
|
26
|
+
</a>
|
|
27
|
+
<img src="https://img.shields.io/badge/security-audits-passing-16a34a?style=flat-square" alt="security audits" />
|
|
28
|
+
<img src="https://img.shields.io/badge/status-stable-22c55e?style=flat-square" alt="status" />
|
|
29
|
+
<img src="https://img.shields.io/github/stars/halif/ufaflow?style=flat-square&logo=github&label=stars" alt="stars" />
|
|
30
|
+
<img src="https://img.shields.io/github/issues/halif/ufaflow?style=flat-square&logo=github&label=issues" alt="issues" />
|
|
31
|
+
<img src="https://img.shields.io/github/last-commit/halif/ufaflow?style=flat-square&logo=github&label=last%20commit" alt="last commit" />
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
## Overview
|
|
35
|
+
|
|
36
|
+
UfaFlow is a lightweight asynchronous worker manager and load balancer for Python services. It distributes traffic across worker processes with predictable lifecycle control, simple configuration, and async-first execution.
|
|
37
|
+
|
|
38
|
+
The project is designed for teams that need:
|
|
39
|
+
- a simple worker runtime
|
|
40
|
+
- process-level isolation
|
|
41
|
+
- async-first execution
|
|
42
|
+
- predictable startup and shutdown behavior
|
|
43
|
+
- easy deployment in development and production-like environments
|
|
44
|
+
|
|
45
|
+
## Why UfaFlow
|
|
46
|
+
|
|
47
|
+
Many applications need more than a single process, but do not require the complexity of a full orchestration platform. UfaFlow acts as a lightweight execution layer that helps you:
|
|
48
|
+
- manage multiple workers with a single runtime
|
|
49
|
+
- balance traffic or task distribution across processes
|
|
50
|
+
- keep lifecycle management explicit and controllable
|
|
51
|
+
- reduce operational overhead for small-to-medium service deployments
|
|
52
|
+
|
|
53
|
+
## Key Features
|
|
54
|
+
|
|
55
|
+
- Async-first worker orchestration
|
|
56
|
+
- Configurable worker pool and process management
|
|
57
|
+
- Lightweight request balancing across workers
|
|
58
|
+
- Support for FastAPI and Python service integration
|
|
59
|
+
- YAML-based configuration
|
|
60
|
+
- Clean startup and shutdown lifecycle behavior
|
|
61
|
+
- Simple deployment model for local and cloud environments
|
|
62
|
+
|
|
63
|
+
## Installation
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install ufaflow
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Quick start
|
|
70
|
+
|
|
71
|
+
Create a YAML config file:
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
health_interval: 5
|
|
75
|
+
health_timeout: 2
|
|
76
|
+
frontends:
|
|
77
|
+
- listen_host: "0.0.0.0"
|
|
78
|
+
listen_port: 8080
|
|
79
|
+
algorithm: round_robin
|
|
80
|
+
backends:
|
|
81
|
+
- host: "127.0.0.1"
|
|
82
|
+
port: 8001
|
|
83
|
+
- host: "127.0.0.1"
|
|
84
|
+
port: 8002
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Run the load balancer:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
python -m ufaflow.cli config.yaml
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
To run with multiple worker processes:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
python -m ufaflow.cli config.yaml --workers 4
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
setup.py
|
|
5
|
+
tests/test_backend_pool.py
|
|
6
|
+
tests/test_cli_smoke.py
|
|
7
|
+
ufaflow/__init__.py
|
|
8
|
+
ufaflow/cli.py
|
|
9
|
+
ufaflow/server.py
|
|
10
|
+
ufaflow/ufaflow_server.py
|
|
11
|
+
ufaflow.egg-info/PKG-INFO
|
|
12
|
+
ufaflow.egg-info/SOURCES.txt
|
|
13
|
+
ufaflow.egg-info/dependency_links.txt
|
|
14
|
+
ufaflow.egg-info/requires.txt
|
|
15
|
+
ufaflow.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
PyYAML>=6.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ufaflow
|