sqlseed-web 0.2.4__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.
- sqlseed_web/AGENTS.md +106 -0
- sqlseed_web/__init__.py +24 -0
- sqlseed_web/__main__.py +8 -0
- sqlseed_web/_application.py +205 -0
- sqlseed_web/ai_settings.py +290 -0
- sqlseed_web/api.py +1102 -0
- sqlseed_web/app.py +26 -0
- sqlseed_web/managed_worker.py +184 -0
- sqlseed_web/operation_errors.py +42 -0
- sqlseed_web/plugin_environment.py +231 -0
- sqlseed_web/plugin_management.py +322 -0
- sqlseed_web/plugin_process.py +131 -0
- sqlseed_web/runtime_lifecycle.py +130 -0
- sqlseed_web/runtime_session.py +97 -0
- sqlseed_web/settings_environment.py +368 -0
- sqlseed_web/sqlite_target.py +86 -0
- sqlseed_web/state.py +348 -0
- sqlseed_web/static/AGENTS.md +180 -0
- sqlseed_web/static/ai.css +57 -0
- sqlseed_web/static/configs.css +50 -0
- sqlseed_web/static/date-picker.css +230 -0
- sqlseed_web/static/disclosure.css +149 -0
- sqlseed_web/static/graph-clarity.css +103 -0
- sqlseed_web/static/index.html +29 -0
- sqlseed_web/static/js/api.js +228 -0
- sqlseed_web/static/js/app.js +97 -0
- sqlseed_web/static/js/dropdown.js +432 -0
- sqlseed_web/static/js/filepicker.js +203 -0
- sqlseed_web/static/js/genform.js +1066 -0
- sqlseed_web/static/js/labels.js +195 -0
- sqlseed_web/static/js/pages/browse.js +209 -0
- sqlseed_web/static/js/pages/configs.js +424 -0
- sqlseed_web/static/js/pages/connect.js +332 -0
- sqlseed_web/static/js/pages/heal.js +395 -0
- sqlseed_web/static/js/pages/meta.js +110 -0
- sqlseed_web/static/js/pages/runs.js +293 -0
- sqlseed_web/static/js/pages/settings.js +942 -0
- sqlseed_web/static/js/pages/wizard.js +751 -0
- sqlseed_web/static/js/pages/workbench.js +3123 -0
- sqlseed_web/static/js/tree.js +126 -0
- sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
- sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
- sqlseed_web/static/js/workbench/ai-stream.js +116 -0
- sqlseed_web/static/js/workbench/ai.js +888 -0
- sqlseed_web/static/js/workbench/connection.js +508 -0
- sqlseed_web/static/js/workbench/date-picker.js +445 -0
- sqlseed_web/static/js/workbench/dependency-view.js +119 -0
- sqlseed_web/static/js/workbench/editor.js +1236 -0
- sqlseed_web/static/js/workbench/focus.js +11 -0
- sqlseed_web/static/js/workbench/graph-layout.js +332 -0
- sqlseed_web/static/js/workbench/graph.js +970 -0
- sqlseed_web/static/js/workbench/guidance.js +29 -0
- sqlseed_web/static/js/workbench/model.js +124 -0
- sqlseed_web/static/js/workbench/plugin-management.js +512 -0
- sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
- sqlseed_web/static/js/workbench/preview.js +572 -0
- sqlseed_web/static/js/workbench/provider-guide.js +33 -0
- sqlseed_web/static/js/workbench/recovery.js +28 -0
- sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
- sqlseed_web/static/js/workbench/session.js +174 -0
- sqlseed_web/static/js/workbench/table-data.js +186 -0
- sqlseed_web/static/js/workbench/ui.js +262 -0
- sqlseed_web/static/navigation.css +92 -0
- sqlseed_web/static/preview.css +29 -0
- sqlseed_web/static/runs.css +53 -0
- sqlseed_web/static/scrollbars.css +42 -0
- sqlseed_web/static/settings.css +108 -0
- sqlseed_web/static/style.css +3382 -0
- sqlseed_web/static/table-data.css +27 -0
- sqlseed_web/static/workbench.css +509 -0
- sqlseed_web/supervised_plugins.py +173 -0
- sqlseed_web/supervisor.py +238 -0
- sqlseed_web/workbench.py +381 -0
- sqlseed_web/workbench_ai.py +887 -0
- sqlseed_web/workbench_ai_relations.py +285 -0
- sqlseed_web/workbench_ai_stream.py +172 -0
- sqlseed_web/workbench_data.py +163 -0
- sqlseed_web/workbench_execution.py +199 -0
- sqlseed_web/workbench_runtime.py +1218 -0
- sqlseed_web/workbench_schema.py +277 -0
- sqlseed_web/workbench_store.py +458 -0
- sqlseed_web/worker_control.py +192 -0
- sqlseed_web-0.2.4.dist-info/METADATA +105 -0
- sqlseed_web-0.2.4.dist-info/RECORD +87 -0
- sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
- sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
- sqlseed_web-0.2.4.dist-info/licenses/LICENSE +679 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Package operations with automatic, process-isolated business recovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
from fastapi import HTTPException
|
|
10
|
+
|
|
11
|
+
from sqlseed_web import plugin_environment
|
|
12
|
+
from sqlseed_web.plugin_environment import InstalledPackage
|
|
13
|
+
from sqlseed_web.plugin_management import ExecuteRequest, PlanRequest, PluginManager, _reject
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LifecycleController(Protocol):
|
|
17
|
+
def pause(self) -> None: ...
|
|
18
|
+
def resume(self) -> None: ...
|
|
19
|
+
def enter_maintenance(self) -> None: ...
|
|
20
|
+
def restore_business(self) -> dict[str, Any]: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SupervisedPluginManager(PluginManager):
|
|
24
|
+
"""Keep task ownership in the parent while serving workers are replaced."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, controller: LifecycleController) -> None:
|
|
27
|
+
super().__init__(enabled=True)
|
|
28
|
+
self.controller = controller
|
|
29
|
+
self.instance_id = secrets.token_hex(16)
|
|
30
|
+
self.service_generation = 1
|
|
31
|
+
self.phase = "ready"
|
|
32
|
+
self.session_restore: dict[str, Any] = {}
|
|
33
|
+
self._package_status = "failed"
|
|
34
|
+
self._package_message = "组件操作未完成。"
|
|
35
|
+
|
|
36
|
+
def _reason(self) -> str | None:
|
|
37
|
+
if self.phase == "recovery_failed":
|
|
38
|
+
return "业务服务未恢复,请在页面重试恢复服务。"
|
|
39
|
+
if self.phase != "ready":
|
|
40
|
+
return "正在处理组件操作,业务服务将自动恢复。"
|
|
41
|
+
return super()._reason()
|
|
42
|
+
|
|
43
|
+
def status(self) -> dict[str, Any]:
|
|
44
|
+
with self._lock:
|
|
45
|
+
result = super().status()
|
|
46
|
+
result.update(
|
|
47
|
+
automatic_lifecycle=True,
|
|
48
|
+
instance_id=self.instance_id,
|
|
49
|
+
phase=self.phase,
|
|
50
|
+
service_generation=self.service_generation,
|
|
51
|
+
service_ready=self.phase == "ready",
|
|
52
|
+
session_restore=dict(self.session_restore),
|
|
53
|
+
restart_required=False,
|
|
54
|
+
maintenance_command=None,
|
|
55
|
+
)
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
def plan(self, body: PlanRequest) -> dict[str, Any]:
|
|
59
|
+
with self._lock:
|
|
60
|
+
result = super().plan(body)
|
|
61
|
+
warnings = [
|
|
62
|
+
"安装固定当前环境与已有组件版本;没有兼容版本或 wheel 时会失败。"
|
|
63
|
+
if body.action == "install"
|
|
64
|
+
else "仅卸载选中的组件,不自动卸载其依赖。重新安装需要软件源提供兼容版本;开发版或本地安装的组件可能无法恢复。",
|
|
65
|
+
"操作期间会短暂暂停工作台;完成后自动恢复服务与可恢复的连接,无需手动重启。",
|
|
66
|
+
]
|
|
67
|
+
result["warnings"] = warnings
|
|
68
|
+
if self._plan is not None:
|
|
69
|
+
self._plan["warnings"] = warnings
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
def execute(self, body: ExecuteRequest) -> dict[str, Any]:
|
|
73
|
+
with self._lock:
|
|
74
|
+
if reason := self._reason():
|
|
75
|
+
raise _reject(reason)
|
|
76
|
+
self.controller.pause()
|
|
77
|
+
try:
|
|
78
|
+
result = super().execute(body)
|
|
79
|
+
except Exception:
|
|
80
|
+
self.controller.resume()
|
|
81
|
+
raise
|
|
82
|
+
if result["status"] == "failed":
|
|
83
|
+
self.controller.resume()
|
|
84
|
+
return result
|
|
85
|
+
self._stage("preparing", "正在暂存连接并准备组件操作。")
|
|
86
|
+
return self.task_snapshot()
|
|
87
|
+
|
|
88
|
+
def task_snapshot(self, task_id: str | None = None) -> dict[str, Any]:
|
|
89
|
+
with self._lock:
|
|
90
|
+
task = super().task_snapshot(task_id)
|
|
91
|
+
task.update(stage=self.phase, service_ready=self.phase == "ready", restart_required=False)
|
|
92
|
+
if self.phase not in {"ready", "recovery_failed"}:
|
|
93
|
+
task["status"] = "running"
|
|
94
|
+
return task
|
|
95
|
+
|
|
96
|
+
def _stage(self, stage: str, message: str) -> None:
|
|
97
|
+
with self._lock:
|
|
98
|
+
self.phase = stage
|
|
99
|
+
if self._task is not None:
|
|
100
|
+
self._task.update(
|
|
101
|
+
stage=stage, status="running", message=message, service_ready=False, restart_required=False
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def _run(self, operation_plan: dict[str, Any], before: dict[str, InstalledPackage]) -> None:
|
|
105
|
+
self._package_status = "failed"
|
|
106
|
+
self._package_message = "组件操作未完成,业务服务已恢复;请检查服务日志后重试。"
|
|
107
|
+
try:
|
|
108
|
+
self.controller.enter_maintenance()
|
|
109
|
+
self._stage("installing", "正在安装组件。" if operation_plan["action"] == "install" else "正在卸载组件。")
|
|
110
|
+
if plugin_environment.installed_packages(self.environment.prefix) != before:
|
|
111
|
+
raise RuntimeError("environment changed before installation")
|
|
112
|
+
super()._run(operation_plan, before)
|
|
113
|
+
with self._lock:
|
|
114
|
+
if self._task is not None:
|
|
115
|
+
self._package_status = self._task["status"]
|
|
116
|
+
self._package_message = (
|
|
117
|
+
"组件操作完成,业务服务已自动恢复。"
|
|
118
|
+
if self._package_status == "succeeded"
|
|
119
|
+
else "组件操作失败,业务服务已恢复;请查看输出后重试。"
|
|
120
|
+
)
|
|
121
|
+
except (HTTPException, OSError, RuntimeError, ValueError):
|
|
122
|
+
self._package_status = "failed"
|
|
123
|
+
self._package_message = "组件操作未完成,业务服务已恢复;环境未通过操作前检查。"
|
|
124
|
+
self._output("组件操作未完成,正在自动恢复业务服务。")
|
|
125
|
+
finally:
|
|
126
|
+
self._restore()
|
|
127
|
+
|
|
128
|
+
def _recovery_failed(self) -> None:
|
|
129
|
+
with self._lock:
|
|
130
|
+
self.phase = "recovery_failed"
|
|
131
|
+
self.restart_required = False
|
|
132
|
+
if self._task is not None:
|
|
133
|
+
self._task.update(
|
|
134
|
+
status="failed",
|
|
135
|
+
message="业务服务未恢复,请点击重试恢复;不会重复安装或卸载。",
|
|
136
|
+
service_ready=False,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def _restore(self) -> None:
|
|
140
|
+
self._stage("restoring", "正在恢复业务服务与连接。")
|
|
141
|
+
restored = None
|
|
142
|
+
try:
|
|
143
|
+
restored = self.controller.restore_business()
|
|
144
|
+
except (HTTPException, OSError, RuntimeError, ValueError):
|
|
145
|
+
return
|
|
146
|
+
finally:
|
|
147
|
+
if restored is None:
|
|
148
|
+
self._recovery_failed()
|
|
149
|
+
with self._lock:
|
|
150
|
+
self.phase = "ready"
|
|
151
|
+
self.restart_required = False
|
|
152
|
+
self.service_generation += int(restored.get("service_restarted", True))
|
|
153
|
+
self.session_restore = restored
|
|
154
|
+
if self._task is not None:
|
|
155
|
+
self._task.update(
|
|
156
|
+
status=self._package_status,
|
|
157
|
+
message=self._package_message,
|
|
158
|
+
restart_required=False,
|
|
159
|
+
service_ready=True,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def recover(self) -> dict[str, Any]:
|
|
163
|
+
with self._lock:
|
|
164
|
+
if self.phase != "recovery_failed":
|
|
165
|
+
raise HTTPException(409, detail={"code": "recovery_not_needed", "message": "当前服务无需恢复。"})
|
|
166
|
+
self._stage("restoring", "正在重试恢复业务服务。")
|
|
167
|
+
self._worker = threading.Thread(target=self._restore, daemon=False, name="sqlseed-service-recover")
|
|
168
|
+
try:
|
|
169
|
+
self._worker.start()
|
|
170
|
+
except RuntimeError as exc:
|
|
171
|
+
self.phase = "recovery_failed"
|
|
172
|
+
raise _reject("暂时无法恢复服务,请稍后重试。") from exc
|
|
173
|
+
return self.status()
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Stable listener and private supervisor for replaceable Web worker processes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import multiprocessing
|
|
6
|
+
import signal
|
|
7
|
+
import socket
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from multiprocessing.context import SpawnProcess
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fastapi import HTTPException
|
|
14
|
+
|
|
15
|
+
from sqlseed_web.managed_worker import run_worker
|
|
16
|
+
from sqlseed_web.plugin_management import ExecuteRequest, PlanRequest
|
|
17
|
+
from sqlseed_web.supervised_plugins import SupervisedPluginManager
|
|
18
|
+
from sqlseed_web.worker_control import ControlChannel, ControlError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Supervisor:
|
|
22
|
+
"""One supervised app owns its environment, children, and listening socket."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, *, host: str = "127.0.0.1", port: int = 8630) -> None:
|
|
25
|
+
self.host, self.port = host, port
|
|
26
|
+
self.manager = SupervisedPluginManager(self)
|
|
27
|
+
self.listener: socket.socket | None = None
|
|
28
|
+
self.process: SpawnProcess | None = None
|
|
29
|
+
self.channel: ControlChannel | None = None
|
|
30
|
+
self.mode: str | None = None
|
|
31
|
+
self._session: dict[str, Any] = {}
|
|
32
|
+
self._ready = threading.Event()
|
|
33
|
+
self._restoration: dict[str, Any] = {}
|
|
34
|
+
self._lifecycle_lock = threading.RLock()
|
|
35
|
+
self._shutdown_requested = False
|
|
36
|
+
self._session_lost = False
|
|
37
|
+
|
|
38
|
+
def start(self) -> None:
|
|
39
|
+
if self.manager.environment.reason is None:
|
|
40
|
+
self.manager.start()
|
|
41
|
+
if self.manager.environment.reason is None and self.manager._environment_lock is None:
|
|
42
|
+
raise RuntimeError("此 Python 环境正被另一个 Web 服务使用,无法启动受管服务。")
|
|
43
|
+
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
44
|
+
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
45
|
+
try:
|
|
46
|
+
listener.bind((self.host, self.port))
|
|
47
|
+
listener.listen(2048)
|
|
48
|
+
self.port = listener.getsockname()[1]
|
|
49
|
+
self.listener = listener
|
|
50
|
+
self.manager.phase = "preparing"
|
|
51
|
+
self._spawn("business")
|
|
52
|
+
self.manager.phase = "ready"
|
|
53
|
+
except Exception:
|
|
54
|
+
self.stop()
|
|
55
|
+
listener.close()
|
|
56
|
+
raise
|
|
57
|
+
|
|
58
|
+
def _request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
59
|
+
if self.channel is None:
|
|
60
|
+
raise RuntimeError("业务进程暂不可用。")
|
|
61
|
+
try:
|
|
62
|
+
return self.channel.call(method, params or {})
|
|
63
|
+
except ControlError as exc:
|
|
64
|
+
raise HTTPException(exc.status_code, detail=exc.detail) from exc
|
|
65
|
+
|
|
66
|
+
def _handle(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
67
|
+
if method == "worker_ready":
|
|
68
|
+
self._restoration = dict(params.get("restoration", {}))
|
|
69
|
+
self._ready.set()
|
|
70
|
+
return {}
|
|
71
|
+
if method == "management":
|
|
72
|
+
return self.manager.status()
|
|
73
|
+
if method == "plan":
|
|
74
|
+
return self.manager.plan(PlanRequest.model_validate(params))
|
|
75
|
+
if method == "execute":
|
|
76
|
+
return self.manager.execute(ExecuteRequest.model_validate(params))
|
|
77
|
+
if method == "task":
|
|
78
|
+
return self.manager.task_snapshot(params.get("task_id"))
|
|
79
|
+
if method == "recover":
|
|
80
|
+
return self.manager.recover()
|
|
81
|
+
raise ValueError("unsupported supervisor method")
|
|
82
|
+
|
|
83
|
+
def _spawn(self, mode: str) -> dict[str, Any]:
|
|
84
|
+
if self.listener is None:
|
|
85
|
+
raise RuntimeError("服务监听端口不可用。")
|
|
86
|
+
self._ready.clear()
|
|
87
|
+
self._restoration = {}
|
|
88
|
+
context = multiprocessing.get_context("spawn")
|
|
89
|
+
parent, child = context.Pipe()
|
|
90
|
+
channel = ControlChannel(parent)
|
|
91
|
+
lock_descriptor = None
|
|
92
|
+
if self.manager._environment_lock is not None:
|
|
93
|
+
from multiprocessing.reduction import DupFd
|
|
94
|
+
|
|
95
|
+
lock_descriptor = DupFd(self.manager._environment_lock.fileno())
|
|
96
|
+
process = context.Process(
|
|
97
|
+
target=run_worker,
|
|
98
|
+
args=(
|
|
99
|
+
self.listener,
|
|
100
|
+
child,
|
|
101
|
+
mode,
|
|
102
|
+
self._session if mode == "business" else {},
|
|
103
|
+
self.manager.token,
|
|
104
|
+
lock_descriptor,
|
|
105
|
+
),
|
|
106
|
+
name=f"sqlseed-{mode}-worker",
|
|
107
|
+
daemon=False,
|
|
108
|
+
)
|
|
109
|
+
self.channel, self.process, self.mode = channel, process, mode
|
|
110
|
+
self._shutdown_requested = False
|
|
111
|
+
process.start()
|
|
112
|
+
child.close()
|
|
113
|
+
channel.start(self._handle)
|
|
114
|
+
deadline = time.monotonic() + 20
|
|
115
|
+
while not self._ready.wait(0.05):
|
|
116
|
+
if not process.is_alive() or time.monotonic() >= deadline:
|
|
117
|
+
# No public API was admitted before readiness. A failed boot
|
|
118
|
+
# can be stopped without interrupting a generation transaction.
|
|
119
|
+
if process.is_alive():
|
|
120
|
+
process.kill()
|
|
121
|
+
process.join(timeout=3)
|
|
122
|
+
channel.close()
|
|
123
|
+
self.channel, self.process, self.mode = None, None, None
|
|
124
|
+
raise RuntimeError("业务服务启动未完成,请在页面重试恢复。")
|
|
125
|
+
if mode == "business":
|
|
126
|
+
self._request("resume")
|
|
127
|
+
return dict(self._restoration)
|
|
128
|
+
|
|
129
|
+
def _stop_worker(self) -> None:
|
|
130
|
+
if (process := self.process) is None:
|
|
131
|
+
return
|
|
132
|
+
if process.is_alive():
|
|
133
|
+
if not self._shutdown_requested:
|
|
134
|
+
try:
|
|
135
|
+
self._request("shutdown")
|
|
136
|
+
except RuntimeError:
|
|
137
|
+
# EOF makes the owned worker close admission and drain;
|
|
138
|
+
# losing control never permits proceeding to installation.
|
|
139
|
+
if self.channel is not None:
|
|
140
|
+
self.channel.close()
|
|
141
|
+
self._shutdown_requested = True
|
|
142
|
+
process.join(timeout=20)
|
|
143
|
+
if process.is_alive():
|
|
144
|
+
raise RuntimeError("业务进程尚未自然退出,未执行环境变更。")
|
|
145
|
+
if self.channel is not None:
|
|
146
|
+
self.channel.close()
|
|
147
|
+
self.channel, self.process, self.mode = None, None, None
|
|
148
|
+
|
|
149
|
+
def pause(self) -> None:
|
|
150
|
+
if self.mode != "business":
|
|
151
|
+
raise HTTPException(409, detail={"code": "service_not_ready", "message": "业务服务尚未就绪。"})
|
|
152
|
+
self._session = self._request("prepare")
|
|
153
|
+
self._session_lost = False
|
|
154
|
+
|
|
155
|
+
def resume(self) -> None:
|
|
156
|
+
if self.mode == "business":
|
|
157
|
+
self._request("resume")
|
|
158
|
+
self._session = {}
|
|
159
|
+
|
|
160
|
+
def enter_maintenance(self) -> None:
|
|
161
|
+
with self._lifecycle_lock:
|
|
162
|
+
self._stop_worker()
|
|
163
|
+
self._spawn("maintenance")
|
|
164
|
+
|
|
165
|
+
def restore_business(self) -> dict[str, Any]:
|
|
166
|
+
with self._lifecycle_lock:
|
|
167
|
+
if (
|
|
168
|
+
self.mode == "business"
|
|
169
|
+
and self.process is not None
|
|
170
|
+
and self.process.is_alive()
|
|
171
|
+
and not self._shutdown_requested
|
|
172
|
+
):
|
|
173
|
+
self.resume()
|
|
174
|
+
return {"restored_connections": 0, "failed_connections": [], "service_restarted": False}
|
|
175
|
+
self._stop_worker()
|
|
176
|
+
try:
|
|
177
|
+
restored = self._spawn("business")
|
|
178
|
+
except Exception:
|
|
179
|
+
self._spawn("maintenance")
|
|
180
|
+
raise
|
|
181
|
+
self._session = {}
|
|
182
|
+
return {**restored, **self._lost_session_summary(), "service_restarted": True}
|
|
183
|
+
|
|
184
|
+
def _lost_session_summary(self) -> dict[str, Any]:
|
|
185
|
+
if not self._session_lost:
|
|
186
|
+
return {}
|
|
187
|
+
return {"session_lost": True, "message": "业务进程意外退出,原有连接与会话密钥需要重新配置。"}
|
|
188
|
+
|
|
189
|
+
def ensure_worker(self) -> None:
|
|
190
|
+
"""Keep a recovery page available after an unexpected worker exit."""
|
|
191
|
+
with self._lifecycle_lock:
|
|
192
|
+
with self.manager._lock:
|
|
193
|
+
if self.manager.phase != "ready" or self.process is None or self.process.is_alive():
|
|
194
|
+
return
|
|
195
|
+
self.manager.phase = "recovery_failed"
|
|
196
|
+
self._session_lost = True
|
|
197
|
+
self._session = {}
|
|
198
|
+
self.manager.session_restore = self._lost_session_summary()
|
|
199
|
+
self.manager._task = None
|
|
200
|
+
self._stop_worker()
|
|
201
|
+
self._spawn("maintenance")
|
|
202
|
+
|
|
203
|
+
def stop(self) -> None:
|
|
204
|
+
if self.manager._worker is not None:
|
|
205
|
+
self.manager._worker.join()
|
|
206
|
+
with self._lifecycle_lock:
|
|
207
|
+
while self.process is not None:
|
|
208
|
+
try:
|
|
209
|
+
self._stop_worker()
|
|
210
|
+
except HTTPException as exc:
|
|
211
|
+
if exc.status_code != 409:
|
|
212
|
+
raise
|
|
213
|
+
# Explicit shutdown waits for existing work; it never
|
|
214
|
+
# kills running SQL or an SDK thread that has not exited.
|
|
215
|
+
time.sleep(0.1)
|
|
216
|
+
self.manager.stop()
|
|
217
|
+
if self.listener is not None:
|
|
218
|
+
self.listener.close()
|
|
219
|
+
self.listener = None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def run_supervised() -> None:
|
|
223
|
+
"""Default console launch: plugin changes and recovery remain in the page."""
|
|
224
|
+
stopped = threading.Event()
|
|
225
|
+
|
|
226
|
+
def stop(signum: int, frame: Any) -> None:
|
|
227
|
+
stopped.set()
|
|
228
|
+
|
|
229
|
+
previous = {sig: signal.signal(sig, stop) for sig in (signal.SIGINT, signal.SIGTERM)}
|
|
230
|
+
supervisor = Supervisor()
|
|
231
|
+
try:
|
|
232
|
+
supervisor.start()
|
|
233
|
+
while not stopped.wait(0.25):
|
|
234
|
+
supervisor.ensure_worker()
|
|
235
|
+
finally:
|
|
236
|
+
supervisor.stop()
|
|
237
|
+
for sig, handler in previous.items():
|
|
238
|
+
signal.signal(sig, handler)
|