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.
Files changed (78) hide show
  1. adp_operator/__init__.py +0 -0
  2. adp_operator/c2c/__init__.py +6 -0
  3. adp_operator/c2c/__main__.py +5 -0
  4. adp_operator/c2c/client.py +128 -0
  5. adp_operator/c2c/commands.py +181 -0
  6. adp_operator/c2c/config.py +11 -0
  7. adp_operator/c2c/discovery.py +314 -0
  8. adp_operator/c2c/event_bus.py +129 -0
  9. adp_operator/c2c/handlers/__init__.py +2 -0
  10. adp_operator/c2c/handlers/data.py +11 -0
  11. adp_operator/c2c/handlers/discovery.py +126 -0
  12. adp_operator/c2c/handlers/file.py +179 -0
  13. adp_operator/c2c/handlers/identity.py +25 -0
  14. adp_operator/c2c/handlers/operator.py +90 -0
  15. adp_operator/c2c/handlers/scan.py +58 -0
  16. adp_operator/c2c/log_stream.py +68 -0
  17. adp_operator/c2c/main.py +46 -0
  18. adp_operator/c2c/node_context.py +116 -0
  19. adp_operator/c2c/operator.py +70 -0
  20. adp_operator/c2c/operator_executor.py +468 -0
  21. adp_operator/c2c/operator_loader.py +25 -0
  22. adp_operator/c2c/operator_metadata.py +34 -0
  23. adp_operator/c2c/operator_service.py +52 -0
  24. adp_operator/c2c/operator_source.py +57 -0
  25. adp_operator/c2c/peer_registry.py +388 -0
  26. adp_operator/c2c/protocol.py +210 -0
  27. adp_operator/c2c/reverse_connection.py +538 -0
  28. adp_operator/c2c/routing.py +180 -0
  29. adp_operator/c2c/run_queue.py +40 -0
  30. adp_operator/c2c/runtime.py +296 -0
  31. adp_operator/c2c/service_discovery.py +58 -0
  32. adp_operator/c2c/session.py +100 -0
  33. adp_operator/c2c/socket_channel.py +322 -0
  34. adp_operator/c2c/state.py +58 -0
  35. adp_operator/c2c/tcp_server.py +411 -0
  36. adp_operator/c2c/websocket_server.py +240 -0
  37. adp_operator/cache_utils.py +35 -0
  38. adp_operator/connect.py +91 -0
  39. adp_operator/core/__init__.py +12 -0
  40. adp_operator/core/constants.py +15 -0
  41. adp_operator/core/json2pdf.py +271 -0
  42. adp_operator/core/llm_config.py +39 -0
  43. adp_operator/core/models.py +150 -0
  44. adp_operator/core/my_scale_db_storeage.py +35 -0
  45. adp_operator/core/object_storage/__init__.py +0 -0
  46. adp_operator/core/object_storage/base.py +45 -0
  47. adp_operator/core/object_storage/base_stream_object.py +3 -0
  48. adp_operator/core/object_storage/minio_object_storage.py +213 -0
  49. adp_operator/core/object_storage/tos_object_storage.py +225 -0
  50. adp_operator/core/operator_init_config.py +121 -0
  51. adp_operator/core/operator_utils.py +80 -0
  52. adp_operator/core/run_and_load_node_info.py +115 -0
  53. adp_operator/dataset/__init__.py +5 -0
  54. adp_operator/dataset/cache.py +189 -0
  55. adp_operator/dataset/storage.py +25 -0
  56. adp_operator/logger.py +113 -0
  57. adp_operator/management/__init__.py +1 -0
  58. adp_operator/management/commands/__init__.py +1 -0
  59. adp_operator/management/commands/common.py +43 -0
  60. adp_operator/management/commands/login.py +54 -0
  61. adp_operator/management/commands/logs.py +19 -0
  62. adp_operator/management/commands/run.py +56 -0
  63. adp_operator/management/commands/upload.py +109 -0
  64. adp_operator/management/main.py +78 -0
  65. adp_operator/myscale_pool.py +163 -0
  66. adp_operator/myscale_utils.py +90 -0
  67. adp_operator/storage.py +1184 -0
  68. adp_operator/utils/__init__.py +0 -0
  69. adp_operator/utils/dataframe_transport.py +42 -0
  70. adp_operator/utils/docker_api.py +46 -0
  71. adp_operator/utils/file_utils.py +57 -0
  72. adp_operator/utils/myscale_pool.py +187 -0
  73. adp_operator/utils/myscale_utils.py +389 -0
  74. adp_operator-1.0.7.dist-info/METADATA +81 -0
  75. adp_operator-1.0.7.dist-info/RECORD +78 -0
  76. adp_operator-1.0.7.dist-info/WHEEL +5 -0
  77. adp_operator-1.0.7.dist-info/entry_points.txt +2 -0
  78. adp_operator-1.0.7.dist-info/top_level.txt +1 -0
File without changes
@@ -0,0 +1,6 @@
1
+ from adp_operator.c2c.client import C2CClient
2
+ from adp_operator.c2c.main import main
3
+
4
+
5
+ __all__ = ["C2CClient", "main"]
6
+
@@ -0,0 +1,5 @@
1
+ from adp_operator.c2c.main import main
2
+
3
+
4
+ main()
5
+
@@ -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
+