adp-operator 1.0.7__py3-none-any.whl
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.
- adp_operator/__init__.py +0 -0
- adp_operator/c2c/__init__.py +6 -0
- adp_operator/c2c/__main__.py +5 -0
- adp_operator/c2c/client.py +128 -0
- adp_operator/c2c/commands.py +181 -0
- adp_operator/c2c/config.py +11 -0
- adp_operator/c2c/discovery.py +314 -0
- adp_operator/c2c/event_bus.py +129 -0
- adp_operator/c2c/handlers/__init__.py +2 -0
- adp_operator/c2c/handlers/data.py +11 -0
- adp_operator/c2c/handlers/discovery.py +126 -0
- adp_operator/c2c/handlers/file.py +179 -0
- adp_operator/c2c/handlers/identity.py +25 -0
- adp_operator/c2c/handlers/operator.py +90 -0
- adp_operator/c2c/handlers/scan.py +58 -0
- adp_operator/c2c/log_stream.py +68 -0
- adp_operator/c2c/main.py +46 -0
- adp_operator/c2c/node_context.py +116 -0
- adp_operator/c2c/operator.py +70 -0
- adp_operator/c2c/operator_executor.py +468 -0
- adp_operator/c2c/operator_loader.py +25 -0
- adp_operator/c2c/operator_metadata.py +34 -0
- adp_operator/c2c/operator_service.py +52 -0
- adp_operator/c2c/operator_source.py +57 -0
- adp_operator/c2c/peer_registry.py +388 -0
- adp_operator/c2c/protocol.py +210 -0
- adp_operator/c2c/reverse_connection.py +538 -0
- adp_operator/c2c/routing.py +180 -0
- adp_operator/c2c/run_queue.py +40 -0
- adp_operator/c2c/runtime.py +296 -0
- adp_operator/c2c/service_discovery.py +58 -0
- adp_operator/c2c/session.py +100 -0
- adp_operator/c2c/socket_channel.py +322 -0
- adp_operator/c2c/state.py +58 -0
- adp_operator/c2c/tcp_server.py +411 -0
- adp_operator/c2c/websocket_server.py +240 -0
- adp_operator/cache_utils.py +35 -0
- adp_operator/connect.py +91 -0
- adp_operator/core/__init__.py +12 -0
- adp_operator/core/constants.py +15 -0
- adp_operator/core/json2pdf.py +271 -0
- adp_operator/core/llm_config.py +39 -0
- adp_operator/core/models.py +150 -0
- adp_operator/core/my_scale_db_storeage.py +35 -0
- adp_operator/core/object_storage/__init__.py +0 -0
- adp_operator/core/object_storage/base.py +45 -0
- adp_operator/core/object_storage/base_stream_object.py +3 -0
- adp_operator/core/object_storage/minio_object_storage.py +213 -0
- adp_operator/core/object_storage/tos_object_storage.py +225 -0
- adp_operator/core/operator_init_config.py +121 -0
- adp_operator/core/operator_utils.py +80 -0
- adp_operator/core/run_and_load_node_info.py +115 -0
- adp_operator/dataset/__init__.py +5 -0
- adp_operator/dataset/cache.py +189 -0
- adp_operator/dataset/storage.py +25 -0
- adp_operator/logger.py +113 -0
- adp_operator/management/__init__.py +1 -0
- adp_operator/management/commands/__init__.py +1 -0
- adp_operator/management/commands/common.py +43 -0
- adp_operator/management/commands/login.py +54 -0
- adp_operator/management/commands/logs.py +19 -0
- adp_operator/management/commands/run.py +56 -0
- adp_operator/management/commands/upload.py +109 -0
- adp_operator/management/main.py +78 -0
- adp_operator/myscale_pool.py +163 -0
- adp_operator/myscale_utils.py +90 -0
- adp_operator/storage.py +1184 -0
- adp_operator/utils/__init__.py +0 -0
- adp_operator/utils/dataframe_transport.py +42 -0
- adp_operator/utils/docker_api.py +46 -0
- adp_operator/utils/file_utils.py +57 -0
- adp_operator/utils/myscale_pool.py +187 -0
- adp_operator/utils/myscale_utils.py +389 -0
- adp_operator-1.0.7.dist-info/METADATA +81 -0
- adp_operator-1.0.7.dist-info/RECORD +78 -0
- adp_operator-1.0.7.dist-info/WHEEL +5 -0
- adp_operator-1.0.7.dist-info/entry_points.txt +2 -0
- adp_operator-1.0.7.dist-info/top_level.txt +1 -0
adp_operator/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import threading
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from adp_operator.c2c.operator_executor import OperatorExecutor
|
|
10
|
+
from adp_operator.c2c.runtime import Service
|
|
11
|
+
from adp_operator.c2c.operator_loader import load_operator
|
|
12
|
+
from adp_operator.c2c.operator import C2COperatorBase
|
|
13
|
+
from adp_operator.c2c.operator_source import prepare_operator_source
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class C2CClient(Service):
|
|
17
|
+
"""一个进程承载一个算子类,按 info/run 指令加载信息或执行算子。"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
operator_config: dict,
|
|
22
|
+
node_id: Optional[str] = None,
|
|
23
|
+
network: Optional[str] = None,
|
|
24
|
+
max_concurrent_runs: Optional[int] = None,
|
|
25
|
+
join: Optional[tuple[str, int]] = None,
|
|
26
|
+
):
|
|
27
|
+
self.operator_config = dict(operator_config)
|
|
28
|
+
self.operator_base = "PermanentOperator" if join is not None else "ADPOperator"
|
|
29
|
+
source = Path(operator_config["module_file"]).expanduser().resolve()
|
|
30
|
+
self._source_config = {**operator_config, "module_file": str(source)}
|
|
31
|
+
self._workspaces = {}
|
|
32
|
+
self._workspace_lock = threading.RLock()
|
|
33
|
+
identity = hashlib.sha256(
|
|
34
|
+
f"{node_id or operator_config['name']}:{operator_config['module_name']}".encode()
|
|
35
|
+
).hexdigest()
|
|
36
|
+
self._namespace = Path(tempfile.gettempdir()).resolve() / "adp-operator-clients" / identity
|
|
37
|
+
self._namespace.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
38
|
+
self._temporary_directory = tempfile.TemporaryDirectory(prefix="session-", dir=self._namespace)
|
|
39
|
+
self.working_directory = Path(self._temporary_directory.name)
|
|
40
|
+
self._previous_directory = Path.cwd()
|
|
41
|
+
self._import_root = str(self._previous_directory)
|
|
42
|
+
self._added_import_root = self._import_root not in sys.path
|
|
43
|
+
try:
|
|
44
|
+
target = self.working_directory / source.name
|
|
45
|
+
prepare_operator_source(
|
|
46
|
+
source, target, operator_config["name"], self.operator_base
|
|
47
|
+
)
|
|
48
|
+
self.operator_config["module_file"] = str(target)
|
|
49
|
+
if self._added_import_root:
|
|
50
|
+
sys.path.insert(0, self._import_root)
|
|
51
|
+
os.chdir(self.working_directory)
|
|
52
|
+
self.operator_class = load_operator(self.operator_config)
|
|
53
|
+
if not issubclass(self.operator_class, C2COperatorBase):
|
|
54
|
+
raise TypeError(
|
|
55
|
+
f"{self.operator_class.__name__} 必须继承 ADPOperator 或 PermanentOperator"
|
|
56
|
+
)
|
|
57
|
+
resolved_node_id = node_id or operator_config["name"]
|
|
58
|
+
if max_concurrent_runs is None:
|
|
59
|
+
max_concurrent_runs = max(1, (os.cpu_count() or 1) * 3)
|
|
60
|
+
executor = OperatorExecutor(
|
|
61
|
+
self.operator_class, resolved_node_id, max_concurrent_runs=max_concurrent_runs
|
|
62
|
+
)
|
|
63
|
+
executor.operator_config = self.operator_config
|
|
64
|
+
executor.working_directory = self.working_directory
|
|
65
|
+
executor.get_workspace = self.get_workspace
|
|
66
|
+
executor.close_workspace = self.close_workspace
|
|
67
|
+
super().__init__(resolved_node_id, network, executor, join=join)
|
|
68
|
+
except Exception:
|
|
69
|
+
self._cleanup_workspace()
|
|
70
|
+
raise
|
|
71
|
+
|
|
72
|
+
def _cleanup_workspace(self):
|
|
73
|
+
for session_id in list(self._workspaces):
|
|
74
|
+
self.close_workspace(session_id)
|
|
75
|
+
if Path.cwd() == self.working_directory:
|
|
76
|
+
os.chdir(self._previous_directory)
|
|
77
|
+
if self._added_import_root and self._import_root in sys.path:
|
|
78
|
+
sys.path.remove(self._import_root)
|
|
79
|
+
self._temporary_directory.cleanup()
|
|
80
|
+
|
|
81
|
+
def get_workspace(self, session_id):
|
|
82
|
+
"""文件接收和算子运行使用同一个会话目录。"""
|
|
83
|
+
with self._workspace_lock:
|
|
84
|
+
if session_id not in self._workspaces:
|
|
85
|
+
if not self._workspaces and self.working_directory.exists():
|
|
86
|
+
temporary = self._temporary_directory
|
|
87
|
+
else:
|
|
88
|
+
temporary = tempfile.TemporaryDirectory(prefix="session-", dir=self._namespace)
|
|
89
|
+
directory = Path(temporary.name)
|
|
90
|
+
try:
|
|
91
|
+
source = Path(self._source_config["module_file"])
|
|
92
|
+
target = directory / source.name
|
|
93
|
+
prepare_operator_source(
|
|
94
|
+
source, target, self._source_config["name"], self.operator_base
|
|
95
|
+
)
|
|
96
|
+
config = {**self._source_config, "module_file": str(target)}
|
|
97
|
+
self._workspaces[session_id] = (temporary, config)
|
|
98
|
+
except Exception:
|
|
99
|
+
temporary.cleanup()
|
|
100
|
+
raise
|
|
101
|
+
return self._workspaces[session_id][1]
|
|
102
|
+
|
|
103
|
+
def close_workspace(self, session_id):
|
|
104
|
+
with self._workspace_lock:
|
|
105
|
+
workspace = self._workspaces.get(session_id)
|
|
106
|
+
if workspace is None:
|
|
107
|
+
return
|
|
108
|
+
temporary, _ = workspace
|
|
109
|
+
if Path.cwd() == Path(temporary.name):
|
|
110
|
+
os.chdir(self._previous_directory)
|
|
111
|
+
temporary.cleanup()
|
|
112
|
+
del self._workspaces[session_id]
|
|
113
|
+
|
|
114
|
+
def start(self, blocking: bool = True) -> None:
|
|
115
|
+
try:
|
|
116
|
+
super().start(blocking=blocking)
|
|
117
|
+
except Exception:
|
|
118
|
+
if self._stopped:
|
|
119
|
+
self._cleanup_workspace()
|
|
120
|
+
raise
|
|
121
|
+
|
|
122
|
+
def stop(self) -> None:
|
|
123
|
+
super().stop()
|
|
124
|
+
self._cleanup_workspace()
|
|
125
|
+
|
|
126
|
+
def get_extra_info(self):
|
|
127
|
+
return self.executor.get_extra_info()
|
|
128
|
+
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""与网络入口无关的 C2C 指令实现。"""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import logging
|
|
5
|
+
import uuid
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def output_event(event, prefix=""):
|
|
10
|
+
text = event.get("message", event)
|
|
11
|
+
lines = str(text).splitlines() or [""]
|
|
12
|
+
if event.get("type") == "log":
|
|
13
|
+
logger = logging.getLogger(event.get("logger") or __name__)
|
|
14
|
+
level = getattr(logging, str(event.get("level", "INFO")).upper(), logging.INFO)
|
|
15
|
+
if not isinstance(level, int):
|
|
16
|
+
level = logging.INFO
|
|
17
|
+
logger.disabled = False
|
|
18
|
+
if logger.getEffectiveLevel() > level:
|
|
19
|
+
logger.setLevel(level)
|
|
20
|
+
if not logger.hasHandlers():
|
|
21
|
+
handler = logging.StreamHandler()
|
|
22
|
+
handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s"))
|
|
23
|
+
logger.addHandler(handler)
|
|
24
|
+
logger.propagate = False
|
|
25
|
+
logger.log(level, "\n".join(f"{prefix}{line}" for line in lines))
|
|
26
|
+
return
|
|
27
|
+
print("\n".join(f"{prefix}{line}" for line in lines), flush=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class C2CCommands:
|
|
31
|
+
"""统一实现 C2C 的运行、调用和文件指令。"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, transport, session_id: str | None = None) -> None:
|
|
34
|
+
self.transport = transport
|
|
35
|
+
self.session_id = session_id or uuid.uuid4().hex
|
|
36
|
+
self._operators: set[str] = set()
|
|
37
|
+
self._closed = False
|
|
38
|
+
|
|
39
|
+
def can_reach(self, operator_name: str) -> bool:
|
|
40
|
+
return self.transport.can_reach(operator_name, self.session_id)
|
|
41
|
+
|
|
42
|
+
def call(self, operator_name: str, method: str, *args, **kwargs):
|
|
43
|
+
if not self.can_reach(operator_name):
|
|
44
|
+
return None
|
|
45
|
+
result = None
|
|
46
|
+
result_received = False
|
|
47
|
+
for event in self._request(operator_name, {
|
|
48
|
+
"type": "call",
|
|
49
|
+
"operator_name": operator_name,
|
|
50
|
+
"method": method,
|
|
51
|
+
"args": list(args),
|
|
52
|
+
"kwargs": kwargs,
|
|
53
|
+
}, {"call_end", "call_error"}):
|
|
54
|
+
if event.get("type") == "call_error":
|
|
55
|
+
raise RuntimeError(event.get("error", "C2C 方法调用失败"))
|
|
56
|
+
if event.get("type") == "call_result":
|
|
57
|
+
result = event.get("result")
|
|
58
|
+
result_received = True
|
|
59
|
+
if event.get("type") == "call_end":
|
|
60
|
+
if not result_received:
|
|
61
|
+
raise RuntimeError(f"方法 {operator_name}.{method} 未返回结果")
|
|
62
|
+
return result
|
|
63
|
+
raise RuntimeError(f"方法 {operator_name}.{method} 的事件流异常结束")
|
|
64
|
+
|
|
65
|
+
def run(self, message: dict, on_event=None):
|
|
66
|
+
operator_name = message["operator_name"]
|
|
67
|
+
result = None
|
|
68
|
+
for event in self._request(
|
|
69
|
+
operator_name, message, {"run_end", "run_error"}
|
|
70
|
+
):
|
|
71
|
+
event_type = event.get("type")
|
|
72
|
+
if event_type == "log":
|
|
73
|
+
continue
|
|
74
|
+
if event_type == "run_end" and result is None:
|
|
75
|
+
event = {
|
|
76
|
+
"type": "run_error",
|
|
77
|
+
"status": "error",
|
|
78
|
+
"operator_name": operator_name,
|
|
79
|
+
"pipeline_id": message.get("pipeline_id"),
|
|
80
|
+
"error": f"算子 {operator_name} 运行失败,未返回结果",
|
|
81
|
+
}
|
|
82
|
+
event_type = "run_error"
|
|
83
|
+
if event_type in ("run_result", "run_error"):
|
|
84
|
+
result = event
|
|
85
|
+
if on_event is None:
|
|
86
|
+
output_event(event)
|
|
87
|
+
else:
|
|
88
|
+
on_event(event)
|
|
89
|
+
if event_type == "run_error":
|
|
90
|
+
return result
|
|
91
|
+
if event_type == "run_end":
|
|
92
|
+
return result
|
|
93
|
+
raise RuntimeError(f"算子 {operator_name} 的事件流异常结束")
|
|
94
|
+
|
|
95
|
+
def transfer_file(self, operator_name: str, path, filename: str) -> str:
|
|
96
|
+
offset = 0
|
|
97
|
+
response = None
|
|
98
|
+
with Path(path).open("rb") as source:
|
|
99
|
+
for chunk in iter(lambda: source.read(512 * 1024), b""):
|
|
100
|
+
response = self._single(operator_name, {
|
|
101
|
+
"type": "receive_file",
|
|
102
|
+
"operator_name": operator_name,
|
|
103
|
+
"filename": filename,
|
|
104
|
+
"offset": offset,
|
|
105
|
+
"content": base64.b64encode(chunk).decode("ascii"),
|
|
106
|
+
}, "receive_file_ack")
|
|
107
|
+
offset += len(chunk)
|
|
108
|
+
if response.get("size") != offset:
|
|
109
|
+
raise RuntimeError("文件接收确认不一致")
|
|
110
|
+
if response is None:
|
|
111
|
+
response = self._single(operator_name, {
|
|
112
|
+
"type": "receive_file",
|
|
113
|
+
"operator_name": operator_name,
|
|
114
|
+
"filename": filename,
|
|
115
|
+
"offset": 0,
|
|
116
|
+
"content": "",
|
|
117
|
+
}, "receive_file_ack")
|
|
118
|
+
if response.get("size") != 0:
|
|
119
|
+
raise RuntimeError("空文件接收确认不一致")
|
|
120
|
+
return response["path"]
|
|
121
|
+
|
|
122
|
+
def sync_files(self, operator_name: str, targets: list[str]) -> dict:
|
|
123
|
+
targets = list(dict.fromkeys(targets))
|
|
124
|
+
if not targets:
|
|
125
|
+
return {}
|
|
126
|
+
self._operators.update(targets)
|
|
127
|
+
response = self._single(operator_name, {
|
|
128
|
+
"type": "sync_files",
|
|
129
|
+
"operator_name": operator_name,
|
|
130
|
+
"targets": targets,
|
|
131
|
+
}, "sync_files_ack")
|
|
132
|
+
return response["synchronized"]
|
|
133
|
+
|
|
134
|
+
def close(self) -> None:
|
|
135
|
+
if self._closed:
|
|
136
|
+
return
|
|
137
|
+
error = None
|
|
138
|
+
try:
|
|
139
|
+
for operator_name in self._operators:
|
|
140
|
+
try:
|
|
141
|
+
self._single(operator_name, {
|
|
142
|
+
"type": "close_session",
|
|
143
|
+
"operator_name": operator_name,
|
|
144
|
+
}, "close_session_ack")
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
error = error or exc
|
|
147
|
+
finally:
|
|
148
|
+
self._closed = True
|
|
149
|
+
self.transport.close()
|
|
150
|
+
if error is not None:
|
|
151
|
+
raise error
|
|
152
|
+
|
|
153
|
+
def _single(self, operator_name: str, message: dict, response_type: str) -> dict:
|
|
154
|
+
response = None
|
|
155
|
+
for event in self._request(operator_name, message, {response_type}):
|
|
156
|
+
response = event
|
|
157
|
+
if response is None or response.get("type") != response_type:
|
|
158
|
+
raise RuntimeError(f"{message['type']} 响应无效")
|
|
159
|
+
if response.get("status") == "error":
|
|
160
|
+
raise RuntimeError(response.get("error", "C2C 请求失败"))
|
|
161
|
+
return response
|
|
162
|
+
|
|
163
|
+
def _request(
|
|
164
|
+
self,
|
|
165
|
+
operator_name: str,
|
|
166
|
+
message: dict,
|
|
167
|
+
terminal_types: set[str],
|
|
168
|
+
):
|
|
169
|
+
self._operators.add(operator_name)
|
|
170
|
+
yield from self.transport.request(
|
|
171
|
+
operator_name,
|
|
172
|
+
message,
|
|
173
|
+
self.session_id,
|
|
174
|
+
terminal_types,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def __enter__(self):
|
|
178
|
+
return self
|
|
179
|
+
|
|
180
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
181
|
+
self.close()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
BROADCAST_BASE_PORT = 50000
|
|
2
|
+
BROADCAST_PORT_RANGE = 1000
|
|
3
|
+
SERVICE_BASE_PORT = 60000
|
|
4
|
+
SERVICE_PORT_RANGE = 1000
|
|
5
|
+
WEBSOCKET_BASE_PORT = 61000
|
|
6
|
+
WEBSOCKET_PORT_RANGE = 1000
|
|
7
|
+
BROADCAST_INTERVAL = 3
|
|
8
|
+
PEER_SYNC_INTERVAL = 3
|
|
9
|
+
PEER_TTL = 300
|
|
10
|
+
MAX_REQUEST_SIZE = 1024 * 1024
|
|
11
|
+
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import socket
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from adp_operator.c2c.operator_executor import OperatorExecutor
|
|
10
|
+
|
|
11
|
+
from adp_operator.c2c.config import (
|
|
12
|
+
BROADCAST_BASE_PORT,
|
|
13
|
+
BROADCAST_INTERVAL,
|
|
14
|
+
BROADCAST_PORT_RANGE,
|
|
15
|
+
PEER_SYNC_INTERVAL,
|
|
16
|
+
)
|
|
17
|
+
from adp_operator.c2c.peer_registry import PeerRegistry
|
|
18
|
+
from adp_operator.c2c.protocol import read_json_response, send_json
|
|
19
|
+
from adp_operator.c2c.state import RuntimeState
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def broadcast_announce(
|
|
24
|
+
node_id: str,
|
|
25
|
+
instance_id: str,
|
|
26
|
+
network: Optional[str],
|
|
27
|
+
send_socket: socket.socket,
|
|
28
|
+
stop_event: threading.Event,
|
|
29
|
+
operator_name: Optional[str],
|
|
30
|
+
state: RuntimeState,
|
|
31
|
+
executor: Optional[OperatorExecutor],
|
|
32
|
+
registry: PeerRegistry,
|
|
33
|
+
) -> None:
|
|
34
|
+
if network and network.startswith("127."):
|
|
35
|
+
targets = [
|
|
36
|
+
("127.0.0.1", BROADCAST_BASE_PORT + offset)
|
|
37
|
+
for offset in range(BROADCAST_PORT_RANGE)
|
|
38
|
+
]
|
|
39
|
+
logging.info(
|
|
40
|
+
"[广播] 同机模式 → 127.0.0.1:%s~%s",
|
|
41
|
+
BROADCAST_BASE_PORT,
|
|
42
|
+
BROADCAST_BASE_PORT + BROADCAST_PORT_RANGE - 1,
|
|
43
|
+
)
|
|
44
|
+
else:
|
|
45
|
+
broadcast_address = (
|
|
46
|
+
str(ipaddress.IPv4Network(network, strict=False).broadcast_address)
|
|
47
|
+
if network
|
|
48
|
+
else "<broadcast>"
|
|
49
|
+
)
|
|
50
|
+
targets = [
|
|
51
|
+
(broadcast_address, BROADCAST_BASE_PORT + offset)
|
|
52
|
+
for offset in range(BROADCAST_PORT_RANGE)
|
|
53
|
+
]
|
|
54
|
+
logging.info("[广播] 网络模式 → %s", broadcast_address)
|
|
55
|
+
|
|
56
|
+
node_id, instance_id = state.identity()
|
|
57
|
+
deadline = time.monotonic() + BROADCAST_INTERVAL * 2
|
|
58
|
+
while not stop_event.is_set():
|
|
59
|
+
existing = registry.get(node_id)
|
|
60
|
+
identifier = None
|
|
61
|
+
takeover_via = None
|
|
62
|
+
if existing is not None and existing.get("instance_id") != instance_id:
|
|
63
|
+
existing_instance_id = existing.get("instance_id")
|
|
64
|
+
identifier = existing_instance_id or node_id
|
|
65
|
+
takeover_via = node_id
|
|
66
|
+
else:
|
|
67
|
+
for source_id in registry.active():
|
|
68
|
+
try:
|
|
69
|
+
with registry.open_connection(source_id, timeout=2) as connection:
|
|
70
|
+
connection.settimeout(2)
|
|
71
|
+
send_json(connection, {
|
|
72
|
+
"type": "discover",
|
|
73
|
+
"identifier": source_id,
|
|
74
|
+
"node_id": instance_id,
|
|
75
|
+
})
|
|
76
|
+
response = read_json_response(connection, limit=1024 * 1024)
|
|
77
|
+
if response.get("type") != "discover_ack":
|
|
78
|
+
continue
|
|
79
|
+
advertised = response.get("peers", {}).get(node_id)
|
|
80
|
+
if not isinstance(advertised, dict):
|
|
81
|
+
continue
|
|
82
|
+
existing_instance_id = advertised.get("instance_id")
|
|
83
|
+
if (
|
|
84
|
+
isinstance(existing_instance_id, str)
|
|
85
|
+
and existing_instance_id
|
|
86
|
+
and existing_instance_id != instance_id
|
|
87
|
+
):
|
|
88
|
+
identifier = existing_instance_id
|
|
89
|
+
takeover_via = source_id
|
|
90
|
+
break
|
|
91
|
+
except (ConnectionError, OSError, RuntimeError, TimeoutError, ValueError):
|
|
92
|
+
continue
|
|
93
|
+
if identifier is not None and takeover_via is not None:
|
|
94
|
+
try:
|
|
95
|
+
with registry.open_connection(takeover_via, timeout=2) as connection:
|
|
96
|
+
connection.settimeout(2)
|
|
97
|
+
send_json(connection, {
|
|
98
|
+
"type": "takeover",
|
|
99
|
+
"identifier": identifier,
|
|
100
|
+
"instance_id": instance_id,
|
|
101
|
+
})
|
|
102
|
+
acknowledgement = read_json_response(connection, limit=64 * 1024)
|
|
103
|
+
if (
|
|
104
|
+
acknowledgement.get("type") != "takeover_ack"
|
|
105
|
+
or acknowledgement.get("status") != "ok"
|
|
106
|
+
):
|
|
107
|
+
raise RuntimeError(
|
|
108
|
+
acknowledgement.get("error", "takeover 响应无效")
|
|
109
|
+
)
|
|
110
|
+
logging.info(
|
|
111
|
+
"[接管] 已通过 C2C 接管旧实例: %s (instance_id=%s)",
|
|
112
|
+
node_id,
|
|
113
|
+
identifier,
|
|
114
|
+
)
|
|
115
|
+
except (ConnectionError, OSError, RuntimeError, TimeoutError, ValueError):
|
|
116
|
+
pass
|
|
117
|
+
break
|
|
118
|
+
remaining = deadline - time.monotonic()
|
|
119
|
+
if remaining <= 0:
|
|
120
|
+
break
|
|
121
|
+
stop_event.wait(min(0.1, remaining))
|
|
122
|
+
_do_broadcast(send_socket, targets, operator_name, state, executor)
|
|
123
|
+
next_broadcast = time.monotonic() + BROADCAST_INTERVAL
|
|
124
|
+
while not stop_event.is_set():
|
|
125
|
+
timeout = max(0, next_broadcast - time.monotonic())
|
|
126
|
+
state.identity_changed.wait(min(timeout, 0.5))
|
|
127
|
+
if stop_event.is_set():
|
|
128
|
+
break
|
|
129
|
+
rename = state.take_pending_rename()
|
|
130
|
+
if rename is not None:
|
|
131
|
+
_send_datagram(send_socket, targets, rename)
|
|
132
|
+
logging.info(
|
|
133
|
+
"[改名] 已通知其他节点: %s -> %s (instance_id=%s)",
|
|
134
|
+
rename["old_node_id"], rename["node_id"], rename["instance_id"],
|
|
135
|
+
)
|
|
136
|
+
if rename is not None or time.monotonic() >= next_broadcast:
|
|
137
|
+
_do_broadcast(send_socket, targets, operator_name, state, executor)
|
|
138
|
+
next_broadcast = time.monotonic() + BROADCAST_INTERVAL
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _do_broadcast(
|
|
142
|
+
send_socket: socket.socket,
|
|
143
|
+
targets: list[tuple[str, int]],
|
|
144
|
+
operator_name: Optional[str],
|
|
145
|
+
state: RuntimeState,
|
|
146
|
+
executor: Optional[OperatorExecutor],
|
|
147
|
+
) -> None:
|
|
148
|
+
node_id, instance_id = state.identity()
|
|
149
|
+
message = {
|
|
150
|
+
"type": "announce",
|
|
151
|
+
"node_id": node_id,
|
|
152
|
+
"instance_id": instance_id,
|
|
153
|
+
"operator_name": operator_name,
|
|
154
|
+
"service_port": state.service_port,
|
|
155
|
+
"broadcast_port": state.broadcast_port,
|
|
156
|
+
"websocket_port": state.websocket_port,
|
|
157
|
+
"started_at": state.started_at,
|
|
158
|
+
"timestamp": time.time(),
|
|
159
|
+
}
|
|
160
|
+
if executor is not None:
|
|
161
|
+
message.update(executor.get_runtime_info())
|
|
162
|
+
_send_datagram(send_socket, targets, message)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _send_datagram(send_socket, targets, message) -> None:
|
|
166
|
+
message = json.dumps(message).encode("utf-8")
|
|
167
|
+
for target in targets:
|
|
168
|
+
try:
|
|
169
|
+
send_socket.sendto(message, target)
|
|
170
|
+
except OSError:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def listen_broadcast(
|
|
175
|
+
node_id: str,
|
|
176
|
+
instance_id: str,
|
|
177
|
+
receive_socket: socket.socket,
|
|
178
|
+
registry: PeerRegistry,
|
|
179
|
+
state: RuntimeState,
|
|
180
|
+
stop_event: threading.Event,
|
|
181
|
+
executor: Optional[OperatorExecutor],
|
|
182
|
+
event_bus,
|
|
183
|
+
) -> None:
|
|
184
|
+
logging.info("[监听] 监听端口 %s", state.broadcast_port)
|
|
185
|
+
receive_socket.settimeout(1.0)
|
|
186
|
+
while not stop_event.is_set():
|
|
187
|
+
try:
|
|
188
|
+
data, address = receive_socket.recvfrom(4096)
|
|
189
|
+
message = json.loads(data.decode("utf-8"))
|
|
190
|
+
if message.get("type") not in ("announce", "leave", "renamed"):
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
if message["type"] == "renamed":
|
|
194
|
+
renamed_instance_id = message.get("instance_id")
|
|
195
|
+
old_node_id = message.get("old_node_id")
|
|
196
|
+
renamed_node_id = message.get("node_id")
|
|
197
|
+
if not all(
|
|
198
|
+
isinstance(value, str) and value
|
|
199
|
+
for value in (renamed_instance_id, old_node_id, renamed_node_id)
|
|
200
|
+
):
|
|
201
|
+
continue
|
|
202
|
+
if registry.rename_instance(
|
|
203
|
+
renamed_instance_id, old_node_id, renamed_node_id
|
|
204
|
+
):
|
|
205
|
+
logging.info(
|
|
206
|
+
"[改名] %s -> %s (instance_id=%s)",
|
|
207
|
+
old_node_id, renamed_node_id, renamed_instance_id,
|
|
208
|
+
)
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
node_id, instance_id = state.identity()
|
|
212
|
+
peer_id = message["node_id"]
|
|
213
|
+
peer_instance_id = message.get("instance_id")
|
|
214
|
+
if peer_id == node_id and peer_instance_id == instance_id:
|
|
215
|
+
continue
|
|
216
|
+
peer_ip = address[0]
|
|
217
|
+
if message["type"] == "leave":
|
|
218
|
+
if registry.remove(peer_id, peer_ip, message.get("broadcast_port")):
|
|
219
|
+
logging.info("[离线] 移除节点: %s", peer_id)
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
peer = {
|
|
223
|
+
"ip": peer_ip,
|
|
224
|
+
"port": message.get("service_port"),
|
|
225
|
+
"broadcast_port": message.get("broadcast_port"),
|
|
226
|
+
"websocket_port": message.get("websocket_port"),
|
|
227
|
+
"operator_name": message.get("operator_name"),
|
|
228
|
+
"instance_id": peer_instance_id,
|
|
229
|
+
"started_at": message.get("started_at"),
|
|
230
|
+
"last_seen": time.time(),
|
|
231
|
+
}
|
|
232
|
+
if "last_active_at" in message:
|
|
233
|
+
peer["last_active_at"] = message["last_active_at"]
|
|
234
|
+
if "channels" in message:
|
|
235
|
+
peer["channels"] = message["channels"]
|
|
236
|
+
|
|
237
|
+
previous = registry.update_direct(peer_id, peer)
|
|
238
|
+
if previous is None:
|
|
239
|
+
logging.info("[发现] %s @ %s:%s", peer_id, peer_ip, peer["port"])
|
|
240
|
+
elif (
|
|
241
|
+
previous["ip"], previous["port"], previous["broadcast_port"]
|
|
242
|
+
) != (peer_ip, peer["port"], peer["broadcast_port"]):
|
|
243
|
+
logging.info("[刷新] %s @ %s:%s", peer_id, peer_ip, peer["port"])
|
|
244
|
+
except socket.timeout:
|
|
245
|
+
continue
|
|
246
|
+
except OSError:
|
|
247
|
+
if stop_event.is_set():
|
|
248
|
+
break
|
|
249
|
+
raise
|
|
250
|
+
except json.JSONDecodeError:
|
|
251
|
+
continue
|
|
252
|
+
except Exception as error:
|
|
253
|
+
logging.info("[监听] 错误: %s", error)
|
|
254
|
+
time.sleep(0.1)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def expire_peers(registry: PeerRegistry, stop_event: threading.Event) -> None:
|
|
258
|
+
while not stop_event.wait(5):
|
|
259
|
+
expired = registry.expire()
|
|
260
|
+
if expired:
|
|
261
|
+
logging.info("[清理] 移除过期节点: %s", expired)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def sync_discovered_peers(
|
|
265
|
+
node_id: str,
|
|
266
|
+
instance_id: str,
|
|
267
|
+
registry: PeerRegistry,
|
|
268
|
+
stop_event: threading.Event,
|
|
269
|
+
state: RuntimeState,
|
|
270
|
+
) -> None:
|
|
271
|
+
while not stop_event.wait(PEER_SYNC_INTERVAL):
|
|
272
|
+
node_id, instance_id = state.identity()
|
|
273
|
+
for source_id, source in registry.direct_peers().items():
|
|
274
|
+
try:
|
|
275
|
+
with registry.open_connection(source_id, timeout=5) as connection:
|
|
276
|
+
send_json(connection, {
|
|
277
|
+
"type": "discover", "node_id": node_id,
|
|
278
|
+
"instance_id": instance_id,
|
|
279
|
+
"identifier": source_id,
|
|
280
|
+
})
|
|
281
|
+
response = read_json_response(connection)
|
|
282
|
+
if response.get("type") != "discover_ack":
|
|
283
|
+
registry.discard_if_unchanged(source_id, source)
|
|
284
|
+
continue
|
|
285
|
+
discovered = response.get("peers", {})
|
|
286
|
+
if not isinstance(discovered, dict):
|
|
287
|
+
continue
|
|
288
|
+
merged = registry.merge_discovered(node_id, source_id, discovered)
|
|
289
|
+
if merged:
|
|
290
|
+
logging.info("[同步] 通过 %s 发现节点: %s", source_id, merged)
|
|
291
|
+
except (OSError, ValueError, TypeError, UnicodeDecodeError, json.JSONDecodeError):
|
|
292
|
+
registry.discard_if_unchanged(source_id, source)
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def announce_leave(
|
|
297
|
+
node_id: str,
|
|
298
|
+
send_socket: socket.socket,
|
|
299
|
+
registry: PeerRegistry,
|
|
300
|
+
state: RuntimeState,
|
|
301
|
+
) -> None:
|
|
302
|
+
message = json.dumps(
|
|
303
|
+
{
|
|
304
|
+
"type": "leave",
|
|
305
|
+
"node_id": node_id,
|
|
306
|
+
"broadcast_port": state.broadcast_port,
|
|
307
|
+
}
|
|
308
|
+
).encode("utf-8")
|
|
309
|
+
for target in registry.leave_targets():
|
|
310
|
+
try:
|
|
311
|
+
send_socket.sendto(message, target)
|
|
312
|
+
except OSError:
|
|
313
|
+
pass
|
|
314
|
+
|