添加服务器重启后的 API 视频结果恢复

This commit is contained in:
magicwang1111
2026-10-28 11:35:41 +08:00
parent 53873117b2
commit 9b41552baf
12 changed files with 815 additions and 2 deletions
+25
View File
@@ -189,6 +189,31 @@ variable. Extension scripts and helper scripts revalidate their browser cache on
reload. Only the refresh extension has a versioned URL, preserving other plugins'
module identities and relative imports. A reverse proxy must honor these cache headers.
When the connection drops, the page displays “服务器连接已断开,正在等待恢复”
once and waits without refreshing. Reconnecting clears the notice and checks the
frontend revision again. Load the updated script while the server is available
before testing a shutdown; an already open page keeps its previously loaded code.
## Interrupted API task recovery
With the distributed queue enabled, successful asynchronous submissions made through
the audited `requests`/`httpx` clients create encrypted recovery checkpoints. After a
job becomes `worker_lost`, a single recovery worker queries the original task ID,
saves returned videos under the owner's output directory, and adds them to that
owner's history. It never resubmits generation or runs downstream workflow nodes;
the history remains failed with an explicit recovery message.
Supported protocols are Seedance Ark, Wan OPC/Vapeur, and Kling official/Vapeur video
endpoints using bearer authentication. Other protocols, synchronous calls, and old
submissions without checkpoints are not automatically recovered. Short-lived bearer
tokens must still be valid; expired credentials or missing tasks require manual lookup.
Network/download failures retry with backoff, stopping after 20 consecutive failures
or seven days since submission. Keep the scheduler database, history database,
internal signing secret, and shared output directory across restarts. The secret is
needed to decrypt pending query credentials; terminal checkpoints discard credentials.
Multiple workers must share these paths and support the recovery file lock.
## API Access
Authenticated API calls can include either:
+29
View File
@@ -6,11 +6,14 @@ import folder_paths
from aiohttp import web
from server import PromptServer, is_loopback
from comfy_execution.utils import get_executing_context
from .utils import *
from .utils.api_audit import install_api_audit
from .utils.frontend import create_jobs_middleware, install_origin_middleware
from .utils.frontend_reload import install_frontend_reload
from .utils.api_recovery import ApiRecoveryManager
from .utils.history_store import HistoryStore
instance = PromptServer.instance
app = instance.app
@@ -85,6 +88,32 @@ jwt_auth = JWTAuth(
)
access_control.sync_comfy_users()
api_recovery = None
if scheduler:
api_recovery = ApiRecoveryManager(
scheduler, HistoryStore(HISTORY_FILE), internal_signer.secret,
folder_paths.get_output_directory(),
register_assets=access_control.register_recovered_assets,
notify=instance.queue_updated,
)
def observe_api_submission(prompt_id, request, response):
context = get_executing_context()
node_id = context.node_id if context and context.prompt_id == prompt_id else ""
api_recovery.observe(prompt_id, node_id, request, response)
install_api_audit(scheduler.record_api_call, observe_api_submission)
async def start_api_recovery(app):
api_recovery.start()
app.on_startup.append(start_api_recovery)
async def stop_api_recovery(app):
api_recovery.stop()
app.on_cleanup.append(stop_api_recovery)
if scheduler:
event_relay = EventRelay(instance, scheduler, internal_signer, INSTANCE_PORT)
event_relay.install()
@@ -0,0 +1,29 @@
# API result recovery implementation plan
**Goal:** Recover already submitted asynchronous media results after a worker is lost, without resubmitting generation or resuming downstream nodes.
**Design:** Capture successful, recognized submission responses at the existing HTTP audit boundary. Store task identity and encrypted query credentials in a private scheduler table. A cross-process file lock serializes recovery. Only worker_lost jobs are eligible. Download to deterministic owner-scoped paths using temporary files and atomic rename; merge results idempotently into persistent history. Keep the interrupted workflow failed and label recovered outputs explicitly.
**Approaches:** Re-running a workflow risks duplicate charges. Replaying audit logs cannot authenticate because secrets are redacted. Selected approach is submission checkpoints plus explicit read-only provider query adapters. The user approved this recovery scope in the preceding conversation and requested implementation.
**Tech:** Python, SQLite, existing filelock and safe media downloader; cryptography Fernet for credentials, derived from the existing internal secret with domain separation.
**Scope:** Ark contents/generations/tasks, Wan OPC and Vapeur, Kling official video endpoints and Vapeur submit/describe. Signed Tencent/LAS, synchronous responses, and old submissions without checkpoints are outside automatic recovery. No changes outside Account Manager; no remote restart/deployment.
## Tasks
- [x] Add explicit provider recognition/query/result parsers, with fixture tests.
- [x] Add durable recovery store/worker and audit observer with encrypted credentials, tests for restart, duplicate processing, retry, isolation, failed provider, and local completion.
- [x] Wire startup and persistent history/assets, preserving failed workflow status and existing outputs.
- [x] Run focused and complete suites, review the patch, document supported protocols and operational limits.
Validation: 129 Python tests, 6 frontend unit tests, and the browser refresh suite
passed against the installed ComfyUI frontend, including WebSocket disconnect/reconnect.
Provider responses/downloads were simulated; no live paid generation, deployment,
or production restart was performed.
## Review focus
- Never submit a second generation request; only known GET paths or Vapeur describe POST.
- Credentials never enter ordinary audit logs, history, manifests, or error messages.
- Multiple workers and crashes must not duplicate history entries or expose another user's outputs.
- Provider timeout/authentication failure must not become a completed workflow.
- Live jobs and cancelled jobs must not be recovered; unknown protocols must not be guessed.
+1
View File
@@ -4,3 +4,4 @@ PyJWT
bcrypt
bleach
filelock
cryptography>=41
+18
View File
@@ -20,6 +20,24 @@ class ApiAuditTests(unittest.TestCase):
def tearDown(self):
api_audit.clear_current_job()
api_audit._recorder = None
api_audit._observer = None
def test_checkpoint_observer_keeps_audit_redacted_and_recovery_is_not_misattributed(self):
records, observed = [], []
api_audit._recorder = lambda **item: records.append(item)
api_audit._observer = lambda *args: observed.append(args)
api_audit.set_current_job("job-a")
request = type("Request", (), {"method": "POST", "url": "https://api.example/tasks",
"headers": {"Authorization": "Bearer secret"}, "body": b'{}'})()
response = type("Response", (), {"status_code": 200, "headers": {}, "content": b'{"id":"task-a"}'})()
api_audit._record("requests", request, response)
self.assertEqual("job-a", observed[0][0])
self.assertEqual("Bearer secret", observed[0][1].headers["Authorization"])
self.assertNotIn("Bearer secret", str(records))
with api_audit.suspend_api_audit():
api_audit._record("requests", request, response)
self.assertEqual(1, len(records))
self.assertEqual(1, len(observed))
def test_records_original_request_and_response_bodies(self):
records = []
+245
View File
@@ -0,0 +1,245 @@
import importlib
import json
import sys
import tempfile
import types
import unittest
from contextlib import closing
from pathlib import Path
from unittest.mock import patch
import httpx
package = types.ModuleType("recovery_test_utils")
package.__path__ = [str(Path(__file__).parents[1] / "utils")]
sys.modules[package.__name__] = package
recovery = importlib.import_module("recovery_test_utils.api_recovery")
SchedulerStore = importlib.import_module("recovery_test_utils.scheduler_store").SchedulerStore
HistoryStore = importlib.import_module("recovery_test_utils.history_store").HistoryStore
class ApiRecoveryTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.scheduler = SchedulerStore(str(self.root / "scheduler.db"))
self.history = HistoryStore(str(self.root / "history.db"))
self.scheduler.enqueue((0, "job-1", {}, {}, [], {}), "user-a", 8180)
self.set_job_status("running")
self.manager = self.make_manager()
def tearDown(self):
self.temp.cleanup()
def make_manager(self):
return recovery.ApiRecoveryManager(self.scheduler, self.history, b"test-secret", str(self.root / "output"))
def set_job_status(self, status):
with closing(self.scheduler._connect()) as db:
db.execute("UPDATE scheduler_jobs SET status=? WHERE prompt_id='job-1'", (status,))
def capture(self):
request = types.SimpleNamespace(
method="POST", url="https://api.example/api/v3/contents/generations/tasks",
headers={"Authorization": "Bearer private-key"},
)
response = types.SimpleNamespace(status_code=200, content=b'{"id":"task-1"}')
self.manager.observe("job-1", "7", request, response)
def rows(self):
with closing(self.scheduler._connect()) as db:
return [dict(row) for row in db.execute("SELECT * FROM scheduler_api_recovery")]
def download(self, url, path, max_bytes):
Path(path).write_bytes(b"video-data")
return "video/mp4", 10
def test_restart_recovers_only_original_task_and_is_idempotent(self):
self.capture()
self.assertNotIn("private-key", str(self.rows()))
self.set_job_status("worker_lost")
restarted = self.make_manager()
with patch.object(restarted, "_query", return_value={"status": "succeeded", "content": {"video_url": "https://cdn.example/a.mp4"}}) as query, patch.object(recovery, "download_remote", side_effect=self.download) as download:
self.assertTrue(restarted.run_once())
self.assertFalse(restarted.run_once())
self.assertEqual(1, query.call_count)
self.assertEqual(1, download.call_count)
self.assertEqual("recovered", self.rows()[0]["state"])
self.assertIsNone(self.rows()[0]["credentials"])
visible = self.history.query(owner_id="user-a")["job-1"]
self.assertEqual("error", visible["status"]["status_str"])
self.assertFalse(visible["status"]["completed"])
self.assertEqual({}, self.history.query(owner_id="user-b"))
videos = [ref for output in visible["outputs"].values() for ref in output.get("video", [])]
self.assertEqual(1, len(videos))
self.assertTrue((self.root / "output" / videos[0]["subfolder"] / videos[0]["filename"]).is_file())
def test_running_completed_cancelled_tasks_never_requery(self):
for status in ("running", "completed", "cancelled", "failed"):
with self.subTest(status=status):
self.set_job_status(status)
self.capture()
with patch.object(self.manager, "_query") as query:
self.manager.run_once()
query.assert_not_called()
def test_pending_and_network_failure_retry_without_duplicate_submit(self):
self.capture()
self.set_job_status("worker_lost")
with patch.object(self.manager, "_query", return_value={"status": "running"}):
self.manager.run_once()
self.assertEqual("pending", self.rows()[0]["state"])
with closing(self.scheduler._connect()) as db:
db.execute("UPDATE scheduler_api_recovery SET next_check=0")
with patch.object(self.manager, "_query", side_effect=TimeoutError("private-key must not be logged")):
self.manager.run_once()
self.assertEqual("pending", self.rows()[0]["state"])
self.assertNotIn("private-key", str(self.rows()))
def test_provider_failure_is_visible_and_stops_polling(self):
self.capture()
self.set_job_status("worker_lost")
with patch.object(self.manager, "_query", return_value={"status": "failed"}):
self.manager.run_once()
self.assertEqual("failed", self.rows()[0]["state"])
item = self.history.query(owner_id="user-a")["job-1"]
self.assertIn("第三方任务失败", str(item))
def test_cross_worker_lock_prevents_duplicate_queries(self):
self.capture()
self.set_job_status("worker_lost")
sibling = self.make_manager()
with self.manager.lock:
with patch.object(sibling, "_query") as query:
self.assertFalse(sibling.run_once())
query.assert_not_called()
def test_duplicate_capture_does_not_reset_recovery(self):
self.capture()
self.capture()
self.assertEqual(1, len(self.rows()))
def test_audit_boundary_checkpoints_submission_without_logging_credentials(self):
audit = importlib.import_module("recovery_test_utils.api_audit")
records = []
with patch.object(audit, "_recorder", lambda **record: records.append(record)), \
patch.object(audit, "_observer", lambda prompt, request, response: self.manager.observe(prompt, "7", request, response)), \
patch.object(audit, "current_job", return_value="job-1"):
# Use the same HTTP response type consumed by the real audit boundary.
with httpx.Client(transport=httpx.MockTransport(
lambda request: httpx.Response(200, json={"id": "task-1"})
)) as client:
response = client.post("https://api.example/api/v3/contents/generations/tasks",
headers={"Authorization": "Bearer private-key"})
audit._record("httpx", response.request, response)
self.assertEqual("task-1", self.rows()[0]["task_id"])
self.assertNotIn("private-key", str(records))
def test_recovered_video_is_previewable_in_installed_jobs_api(self):
sys.path.insert(0, str(Path(__file__).parents[3]))
from comfy_execution.jobs import normalize_history_item
self.capture()
self.set_job_status("worker_lost")
with patch.object(self.manager, "_query", return_value={"status": "succeeded", "content": {"video_url": "https://cdn.example/a.mp4"}}), patch.object(recovery, "download_remote", side_effect=self.download):
self.manager.run_once()
history = self.history.query(owner_id="user-a", for_jobs=True)["job-1"]
job = normalize_history_item("job-1", history, include_outputs=True)
self.assertEqual("failed", job["status"])
self.assertEqual(1, job["previewable_outputs_count"])
self.assertEqual("000.mp4", job["preview_output"]["filename"])
def test_crash_after_history_commit_reuses_file_and_history(self):
self.capture()
self.set_job_status("worker_lost")
body = {"status": "succeeded", "content": {"video_url": "https://cdn.example/a.mp4"}}
self.manager.notify = lambda: (_ for _ in ()).throw(RuntimeError("Simulated exit"))
with patch.object(self.manager, "_query", return_value=body), patch.object(recovery, "download_remote", side_effect=self.download) as download:
with self.assertRaises(RuntimeError):
self.manager.run_once()
self.manager.notify = None
self.manager.run_once()
self.assertEqual(1, download.call_count)
item = self.history.query(owner_id="user-a")["job-1"]
self.assertEqual(1, len(item["api_recovery"]))
self.assertEqual(1, len(item["outputs"]))
def test_history_preserves_previous_outputs(self):
self.history.save("job-1", {"user_id": "user-a", "outputs": {"3": {"text": ["original"]}},
"status": {"status_str": "error", "completed": False}}, 100)
self.capture()
self.set_job_status("worker_lost")
with patch.object(self.manager, "_query", return_value={"status": "running"}):
self.manager.run_once()
item = self.history.query(owner_id="user-a")["job-1"]
self.assertEqual(["original"], item["outputs"]["3"]["text"])
def test_query_uses_original_id_and_never_follows_auth_redirect(self):
self.capture()
row = self.rows()[0]
response = unittest.mock.MagicMock()
response.__enter__.return_value = response
response.status_code = 200
response.json.return_value = {"status": "running"}
with patch.object(recovery, "_validate_public_http_url"), patch.object(recovery.requests, "request", return_value=response) as request:
self.manager._query(row)
args, kwargs = request.call_args
self.assertEqual(("GET", "https://api.example/api/v3/contents/generations/tasks/task-1"), args)
self.assertFalse(kwargs["allow_redirects"])
self.assertEqual("Bearer private-key", kwargs["headers"]["Authorization"])
def test_auth_failure_stops_and_retains_task_identity_without_credentials(self):
self.capture()
self.set_job_status("worker_lost")
with patch.object(self.manager, "_query", side_effect=recovery.RecoveryQueryError("恢复查询凭证已失效")):
self.manager.run_once()
row = self.rows()[0]
self.assertEqual("failed", row["state"])
self.assertEqual("task-1", row["task_id"])
self.assertIsNone(row["credentials"])
def test_partial_download_is_removed_and_retried(self):
self.capture()
self.set_job_status("worker_lost")
body = {"status": "succeeded", "content": {"video_url": "https://cdn.example/a.mp4"}}
def interrupted(url, destination, maximum):
Path(destination).write_bytes(b"incomplete")
raise TimeoutError()
with patch.object(self.manager, "_query", return_value=body), patch.object(recovery, "download_remote", side_effect=interrupted):
self.manager.run_once()
self.assertEqual([], list((self.root / "output").rglob("*.mp4")))
self.assertEqual([], list((self.root / "output").rglob("*.part")))
self.assertEqual("pending", self.rows()[0]["state"])
def test_captured_body_without_auth_or_with_invalid_json_is_ignored(self):
request = types.SimpleNamespace(method="POST", url="https://api.example/api/v3/contents/generations/tasks", headers={})
for content in (b'{"id":"x"}', b'not json'):
self.manager.observe("job-1", "7", request, types.SimpleNamespace(status_code=200, content=content))
self.assertEqual([], self.rows())
def test_original_worker_finishing_during_query_cancels_recovery(self):
self.capture()
self.set_job_status("worker_lost")
def complete(row):
self.set_job_status("completed")
return {"status": "succeeded", "content": {"video_url": "https://cdn.example/a.mp4"}}
with patch.object(self.manager, "_query", side_effect=complete), patch.object(recovery, "download_remote") as download:
self.manager.run_once()
download.assert_not_called()
self.assertEqual("ignored", self.rows()[0]["state"])
self.assertEqual({}, self.history.query())
def test_successful_history_is_not_overwritten_before_scheduler_completion(self):
self.capture()
self.set_job_status("worker_lost")
self.history.save("job-1", {"user_id": "user-a", "outputs": {"3": {"text": ["success"]}},
"status": {"status_str": "success", "completed": True}}, 100)
with patch.object(self.manager, "_query", return_value={"status": "running"}):
self.manager.run_once()
item = self.history.query()["job-1"]
self.assertNotIn("api_recovery", item)
self.assertEqual("ignored", self.rows()[0]["state"])
if __name__ == "__main__":
unittest.main()
+55
View File
@@ -0,0 +1,55 @@
import importlib.util
import unittest
from pathlib import Path
spec = importlib.util.spec_from_file_location(
"recovery_providers", Path(__file__).parents[1] / "utils/recovery_providers.py"
)
providers = importlib.util.module_from_spec(spec)
spec.loader.exec_module(providers)
class RecoveryProviderTests(unittest.TestCase):
def test_submissions_map_to_query_only_endpoints(self):
cases = [
("/api/v3/contents/generations/tasks", {"id": "task-1"}, "ark", "GET", "/api/v3/contents/generations/tasks/task-1", None),
("/v1/videos/generations", {"output": {"task_id": "task-1"}}, "wan", "GET", "/v1/tasks/task-1", None),
("/qwen/api/v1/services/aigc/video-generation/generation", {"output": {"task_id": "task-1"}}, "wan", "GET", "/qwen/api/v1/tasks/video/task-1", None),
("/v1/videos/image2video", {"code": 0, "data": {"task_id": "task-1"}}, "kling", "GET", "/v1/videos/image2video/task-1", None),
("/v1/kling/text-to-video/submit", {"Response": {"JobId": "task-1"}}, "vapeur_kling", "POST", "/v1/kling/text-to-video/describe", {"JobId": "task-1"}),
("/v1/videos/generations", {"data": {"task_id": "task-1"}}, "wan", "GET", "/v1/tasks/task-1", None),
("/v1/videos/generations", {"data": {"id": "task-1"}}, "wan", "GET", "/v1/tasks/task-1", None),
]
for path, response, provider, method, query, body in cases:
with self.subTest(path=path):
result = providers.submission("https://api.example" + path, response)
self.assertEqual((provider, "task-1", method, "https://api.example" + query, body), result)
def test_unknown_invalid_and_failed_submissions_are_not_replayed(self):
for path, response in [
("/unknown/tasks", {"id": "task-1"}),
("/api/v3/contents/generations/tasks", {"id": "../delete"}),
("/v1/videos/image2video", {"code": 12, "data": {"task_id": "task-1"}}),
]:
self.assertIsNone(providers.submission("https://api.example" + path, response))
def test_results_extract_only_generated_media(self):
cases = [
("ark", {"status": "succeeded", "content": {"video_url": "https://cdn.example/a.mp4"}}),
("wan", {"output": {"task_status": "SUCCEEDED", "video_url": "https://cdn.example/a.mp4"}}),
("kling", {"code": 0, "data": {"task_status": "succeed", "task_result": {"videos": [{"url": "https://cdn.example/a.mp4"}]}}}),
("vapeur_kling", {"Response": {"Status": "DONE", "ResultVideoUrl": "https://cdn.example/a.mp4"}}),
]
for provider, body in cases:
body["input"] = {"video_url": "https://cdn.example/input.mp4"}
self.assertEqual(("succeeded", ["https://cdn.example/a.mp4"]), providers.result(provider, body))
def test_pending_failed_and_missing_output_are_distinct(self):
self.assertEqual(("pending", []), providers.result("ark", {"status": "running"}))
self.assertEqual(("failed", []), providers.result("ark", {"status": "expired"}))
with self.assertRaises(ValueError):
providers.result("ark", {"status": "succeeded"})
if __name__ == "__main__":
unittest.main()
+29
View File
@@ -44,6 +44,7 @@ class AccessControl:
self.instance_port = int(instance_port or 0)
self.worker_resource_class = str(worker_resource_class or "default")
self.cloud_archive = cloud_archive
self.asset_access = None
self._worker_heartbeat = None
self._current_user = contextvars.ContextVar("user_id", default=None)
@@ -620,6 +621,34 @@ class AccessControl:
except Exception:
logger.exception("Failed to register persistent history asset %s", path)
def register_recovered_assets(self, assets, prompt_id: str, user_id: str) -> None:
if self.asset_access:
from app.assets.database.queries.records import get_record_by_path_or_none
from app.assets.services.ingest import register_executed_output
from app.database.db import create_session
with self.asset_access.scope(user_id, write=True):
for reference, path in assets:
with create_session() as session:
existing = get_record_by_path_or_none(session, path)
asset_id = existing.id if existing else None
if not asset_id:
registered = register_executed_output(path, job_id=prompt_id)
if not registered:
raise RuntimeError("Recovered asset registration failed")
asset_id = registered.id
reference["id"] = asset_id
else:
self._register_history_assets(assets, prompt_id, user_id)
if any(not reference.get("id") for reference, _ in assets):
raise RuntimeError("Recovered asset registration failed")
for reference, path in assets:
if reference.get("id"):
self.scheduler.upsert_asset(
reference["id"], user_id, prompt_id, self.instance_port,
dict(reference, file_path=path),
)
def _save_history_item(self, prompt_id: str) -> None:
if not self.__history_store:
return
+24 -2
View File
@@ -1,8 +1,10 @@
import contextvars
import hashlib
import json
import logging
import threading
import time
from contextlib import contextmanager
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
@@ -31,6 +33,17 @@ _fallback_prompt_id = None
_fallback_lock = threading.Lock()
_recorder = None
_installed = False
_observer = None
_suspended = contextvars.ContextVar("account_manager_audit_suspended", default=False)
@contextmanager
def suspend_api_audit():
token = _suspended.set(True)
try:
yield
finally:
_suspended.reset(token)
MEDIA_CONTENT_TYPES = (
"image/",
@@ -211,9 +224,17 @@ def _audit_body(value, headers=None) -> bytes | None:
def _record(client, request, response=None, error: BaseException = None, *, stream=False) -> None:
if _suspended.get():
return
prompt_id = current_job()
if not prompt_id or _recorder is None:
return
if _observer and response is not None and not stream:
try:
_observer(prompt_id, request, response)
except Exception:
# Audit and execution must continue even if a checkpoint cannot be saved.
logging.getLogger("ComfyUI-Account-Manager").error("API recovery checkpoint could not be saved")
try:
response_body = None
# Reading .content consumes the raw stream used by COS and other downloaders.
@@ -257,9 +278,10 @@ def _fallback_request(method, url, kwargs):
return request
def install_api_audit(recorder) -> None:
global _installed, _recorder
def install_api_audit(recorder, observer=None) -> None:
global _installed, _recorder, _observer
_recorder = recorder
_observer = observer
if _installed:
return
+234
View File
@@ -0,0 +1,234 @@
"""Recover submitted API results, never re-execute a workflow or generation POST."""
import base64
import hashlib
import json
import logging
import os
import re
import threading
import time
from contextlib import closing
from pathlib import Path
from urllib.parse import urlsplit
import requests
from cryptography.fernet import Fernet
from filelock import FileLock, Timeout
from .api_audit import suspend_api_audit
from .cloud_archive import download_remote, _validate_public_http_url
from .recovery_providers import submission, result
logger = logging.getLogger("ComfyUI-Account-Manager")
class RecoveryQueryError(Exception):
pass
class RecoverySuperseded(Exception):
pass
class ApiRecoveryManager:
def __init__(self, scheduler, history, secret: bytes, output_dir: str,
register_assets=None, notify=None):
self.scheduler = scheduler
self.history = history
self.output_dir = Path(output_dir).resolve()
self.register_assets = register_assets
self.notify = notify
key = hashlib.sha256(b"AccountManager.APIRecovery.v1\0" + secret).digest()
self.cipher = Fernet(base64.urlsafe_b64encode(key))
self.lock = FileLock(scheduler.database + ".recovery.lock", timeout=0)
self._stop = threading.Event()
self._thread = None
with closing(scheduler._connect()) as db:
db.executescript("""
CREATE TABLE IF NOT EXISTS scheduler_api_recovery (
id TEXT PRIMARY KEY, prompt_id TEXT NOT NULL, node_id TEXT NOT NULL,
provider TEXT NOT NULL, task_id TEXT NOT NULL,
query_method TEXT NOT NULL, query_url TEXT NOT NULL, query_body TEXT,
credentials BLOB, state TEXT NOT NULL DEFAULT 'pending',
created_at REAL NOT NULL, next_check REAL NOT NULL DEFAULT 0,
failures INTEGER NOT NULL DEFAULT 0, message TEXT
);
CREATE INDEX IF NOT EXISTS scheduler_api_recovery_due
ON scheduler_api_recovery(state, next_check);
""")
def observe(self, prompt_id, node_id, request, response):
if str(request.method).upper() != "POST" or not 200 <= response.status_code < 300:
return
try:
body = json.loads(response.content)
except (ValueError, UnicodeDecodeError):
return
parsed = submission(str(request.url), body)
if parsed is None:
return
provider, task_id, method, url, query_body = parsed
headers = {str(k).lower(): str(v) for k, v in request.headers.items()}
authorization = headers.get("authorization", "")
if not authorization.lower().startswith("bearer "):
return # Signed requests require a provider-specific signer, not replay.
key = hashlib.sha256(f"{prompt_id}\0{url}\0{task_id}".encode()).hexdigest()
credentials = self.cipher.encrypt(authorization.encode())
with closing(self.scheduler._connect()) as db:
db.execute("PRAGMA synchronous=FULL")
# Owner identity comes exclusively from the queue, never the response.
db.execute("""
INSERT OR IGNORE INTO scheduler_api_recovery
(id,prompt_id,node_id,provider,task_id,query_method,query_url,query_body,credentials,created_at)
SELECT ?,prompt_id,?,?,?,?,?,?,?,? FROM scheduler_jobs
WHERE prompt_id=? AND status='running'
""", (key, str(node_id or ""), provider, task_id, method, url,
json.dumps(query_body), credentials, time.time(), prompt_id))
def start(self):
self._thread = threading.Thread(target=self._run, name="api-result-recovery", daemon=True)
self._thread.start()
def stop(self):
self._stop.set()
if self._thread:
self._thread.join(timeout=5)
def _run(self):
while not self._stop.wait(5):
try:
self.run_once()
except Exception:
# Do not log exception text: HTTP errors can contain signed URLs.
logger.error("API result recovery iteration failed; retrying")
def _query(self, row):
_validate_public_http_url(row["query_url"])
authorization = self.cipher.decrypt(row["credentials"]).decode()
with requests.request(
row["query_method"], row["query_url"],
headers={"Authorization": authorization},
json=json.loads(row["query_body"]), timeout=(10, 30), allow_redirects=False,
) as response:
if response.status_code in (401, 403):
raise RecoveryQueryError("恢复查询凭证已失效或无权限,请人工查询原任务 ID。")
if response.status_code in (404, 410):
raise RecoveryQueryError("第三方任务不存在或已过期,请检查原任务 ID。")
response.raise_for_status()
if not 200 <= response.status_code < 300:
raise ValueError("Unexpected query redirect")
return response.json()
def _download(self, row, urls):
owner = row["owner_id"]
if not re.fullmatch(r"[A-Za-z0-9_-]+", owner):
raise ValueError("Invalid owner path")
relative = Path(owner) / "recovered" / row["id"]
directory = self.output_dir / relative
directory.mkdir(parents=True, exist_ok=True)
references = []
assets = []
for index, url in enumerate(urls):
suffix = Path(urlsplit(url).path).suffix.lower()
if suffix not in (".mp4", ".webm", ".mov"):
suffix = ".mp4"
destination = directory / f"{index:03d}{suffix}"
if not destination.is_file():
temporary = destination.with_suffix(suffix + ".part")
try:
content_type, size = download_remote(url, str(temporary), 20 * 1024**3)
if not size or (content_type and not content_type.startswith("video/") and content_type != "application/octet-stream"):
raise ValueError("Recovered result is not a video")
os.replace(temporary, destination)
finally:
temporary.unlink(missing_ok=True)
reference = {"filename": destination.name, "subfolder": relative.as_posix(),
"type": "output", "format": "video/" + ("webm" if suffix == ".webm" else "mp4")}
references.append(reference)
assets.append((reference, str(destination)))
self._require_lost(row)
if self.register_assets:
self.register_assets(assets, row["prompt_id"], owner)
return references
def _publish(self, row, state, message, references=()):
with closing(self.scheduler._connect()) as db:
db.execute("BEGIN IMMEDIATE")
try:
job = db.execute("SELECT status FROM scheduler_jobs WHERE prompt_id=?", (row["prompt_id"],)).fetchone()
published = bool(job and job["status"] == "worker_lost") and self.history.merge_api_recovery(row, state, message, list(references))
db.execute("COMMIT")
except Exception:
db.execute("ROLLBACK")
raise
if not published:
return False
if self.notify:
self.notify()
return True
def _require_lost(self, row):
with closing(self.scheduler._connect()) as db:
job = db.execute("SELECT status FROM scheduler_jobs WHERE prompt_id=?", (row["prompt_id"],)).fetchone()
if not job or job["status"] != "worker_lost":
raise RecoverySuperseded()
def run_once(self):
try:
with self.lock, suspend_api_audit():
return self._recover_one()
except Timeout:
return False
def _recover_one(self):
now = time.time()
with closing(self.scheduler._connect()) as db:
db.execute("""
UPDATE scheduler_api_recovery SET state='ignored', credentials=NULL
WHERE state='pending' AND prompt_id IN
(SELECT prompt_id FROM scheduler_jobs WHERE status IN ('completed','cancelled','failed'))
""")
row = db.execute("""
SELECT recovery.*, job.owner_id, job.payload, job.submitted_at
FROM scheduler_api_recovery recovery JOIN scheduler_jobs job USING(prompt_id)
WHERE recovery.state='pending' AND recovery.next_check<=? AND job.status='worker_lost'
ORDER BY recovery.next_check, recovery.created_at LIMIT 1
""", (now,)).fetchone()
if row is None:
return False
row = dict(row)
state, message, references = "pending", "服务器已恢复,正在查询原 API 任务。", []
failures = row["failures"]
try:
if now - row["created_at"] > 7 * 86400:
raise RecoveryQueryError("自动恢复已超过 7 天,请人工查询原任务 ID。")
outcome, urls = result(row["provider"], self._query(row))
self._require_lost(row)
if outcome == "succeeded":
references = self._download(row, urls)
state, message = "recovered", "API 生成结果已恢复;原工作流已中断,后续节点未执行。"
elif outcome == "failed":
state, message = "failed", "第三方任务失败、取消或过期;未重新提交生成请求。"
else:
failures = 0
message = "第三方仍在生成,正在等待原任务完成。"
except RecoverySuperseded:
state, message = "ignored", "原任务状态已更新,停止恢复。"
except RecoveryQueryError as error:
state, message = "failed", str(error)
except Exception:
failures += 1
message = "查询或下载暂时失败,将自动重试;未重新提交生成请求。"
if failures >= 20:
state, message = "failed", "连续 20 次恢复失败,请人工查询原任务 ID;未重新提交生成请求。"
# Persist history before acknowledging; a crash repeats an idempotent merge.
if state != "ignored" and not self._publish(row, state, message, references):
state = "ignored"
with closing(self.scheduler._connect()) as db:
db.execute("""
UPDATE scheduler_api_recovery SET state=?, message=?, next_check=?, failures=?,
credentials=CASE WHEN ?='pending' THEN credentials ELSE NULL END WHERE id=?
""", (state, message, now + min(1800, 30 * 2 ** min(failures, 6)), failures, state, row["id"]))
return True
+37
View File
@@ -1,6 +1,7 @@
import json
import os
import sqlite3
import time
from contextlib import closing
@@ -158,6 +159,42 @@ class HistoryStore:
with connection:
connection.execute("DELETE FROM history WHERE prompt_id = ?", (prompt_id,))
def merge_api_recovery(self, task: dict, state: str, message: str, videos: list) -> bool:
"""Add recovered output without claiming that downstream nodes completed."""
with closing(self._connect()) as connection:
with connection:
connection.execute("BEGIN IMMEDIATE")
existing = connection.execute(
"SELECT owner_id,data FROM history WHERE prompt_id=?", (task["prompt_id"],)
).fetchone()
if existing and existing[0] != task["owner_id"]:
raise ValueError("Recovery history owner mismatch")
item = json.loads(existing[1]) if existing else {
"prompt": json.loads(task["payload"])[:5], "outputs": {},
"user_id": task["owner_id"],
"status": {"status_str": "error", "completed": False, "messages": [
["execution_start", {"timestamp": int(task["submitted_at"] * 1000)}],
["execution_error", {"node_id": task["node_id"], "node_type": "APIRecovery",
"exception_type": "WorkerInterrupted", "exception_message": "服务器中断,工作流未完整执行。",
"timestamp": int(time.time() * 1000)}],
]},
}
if (item.get("status") or {}).get("status_str") == "success":
return False
item.setdefault("api_recovery", {})[task["id"]] = {
"provider": task["provider"], "task_id": task["task_id"],
"node_id": task["node_id"], "state": state, "message": message,
}
output = {"text": [message]}
if videos:
output["video"] = videos
item.setdefault("outputs", {})["api_recovery_" + task["id"]] = output
connection.execute("""
INSERT INTO history(prompt_id,owner_id,data) VALUES(?,?,?)
ON CONFLICT(prompt_id) DO UPDATE SET data=excluded.data
""", (task["prompt_id"], task["owner_id"], json.dumps(item, ensure_ascii=False)))
return True
def delete_owner(self, owner_id: str) -> None:
with closing(self._connect()) as connection:
with connection:
+89
View File
@@ -0,0 +1,89 @@
"""Explicit query-only adapters for the async video protocols used by our nodes."""
import re
from urllib.parse import urlsplit, urlunsplit
KLING_VIDEO_KINDS = {
"text2video", "image2video", "multi-image2video", "omni-video",
"video-extend", "motion-control", "multi-elements", "lip-sync",
}
VAPEUR_VIDEO_KINDS = {
"text-to-video", "image-to-video", "multi-image-to-video",
"video-motion-control", "video-extend", "video-edit",
}
def submission(url: str, body: dict):
parts = urlsplit(url)
if parts.scheme != "https" or parts.username or parts.password or parts.query or parts.fragment:
return None
if not isinstance(body, dict) or body.get("error") or body.get("code") not in (None, 0, "0"):
return None
path = parts.path.rstrip("/")
method, query_body = "GET", None
if path.endswith("/contents/generations/tasks"):
provider, task_id = "ark", body.get("id")
query_path = path + "/{}"
elif path.endswith("/v1/videos/generations"):
provider = "wan"
envelope = body.get("data") or body
output = envelope.get("output") or envelope
task_id = envelope.get("task_id") or envelope.get("id") or output.get("task_id") or output.get("id")
query_path = path.removesuffix("/videos/generations") + "/tasks/{}"
elif path.endswith("/qwen/api/v1/services/aigc/video-generation/generation"):
provider, task_id = "wan", (body.get("output") or {}).get("task_id")
query_path = path.removesuffix("/services/aigc/video-generation/generation") + "/tasks/video/{}"
elif path.startswith("/v1/videos/") and path.removeprefix("/v1/videos/") in KLING_VIDEO_KINDS:
provider, task_id = "kling", (body.get("data") or {}).get("task_id")
query_path = path + "/{}"
elif path.startswith("/v1/kling/") and path.endswith("/submit") and path.split("/")[-2] in VAPEUR_VIDEO_KINDS:
provider, task_id = "vapeur_kling", (body.get("Response") or body).get("JobId")
if (body.get("Response") or body).get("Error"):
return None
method, query_path = "POST", path.removesuffix("/submit") + "/describe"
query_body = {"JobId": task_id}
else:
return None
if not isinstance(task_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,256}", task_id):
return None
query_url = urlunsplit((parts.scheme, parts.netloc, query_path.format(task_id), "", ""))
return provider, task_id, method, query_url, query_body
def result(provider: str, body: dict) -> tuple[str, list[str]]:
if not isinstance(body, dict):
raise ValueError("Invalid provider response")
if body.get("error") or body.get("code") not in (None, 0, "0"):
raise ValueError("Provider query rejected")
if provider == "ark":
status = body.get("status")
urls = [(body.get("content") or {}).get("video_url")]
elif provider == "wan":
envelope = body.get("data") or body
output = envelope.get("output") or envelope
status = envelope.get("task_status") or envelope.get("status") or output.get("task_status") or output.get("status")
urls = [output.get("video_url") or output.get("url")]
elif provider == "kling":
data = body.get("data") or {}
status = data.get("task_status")
urls = [item.get("url") for item in (data.get("task_result") or {}).get("videos", [])]
elif provider == "vapeur_kling":
data = body.get("Response") or body
if data.get("Error"):
raise ValueError("Provider query rejected")
status = "failed" if data.get("ErrorCode") else data.get("Status") or data.get("TaskStatus")
urls = [data.get("ResultVideoUrl")]
else:
raise ValueError("Unsupported recovery provider")
status = str(status or "").lower()
if status in {"fail", "failed", "error", "aborted", "cancelled", "canceled", "expired"}:
return "failed", []
if status in {"succeeded", "succeed", "success", "completed", "done", "finish"}:
urls = list(dict.fromkeys(url for url in urls if isinstance(url, str) and url.startswith("https://")))
if not urls:
raise ValueError("Completed task returned no downloadable media")
return "succeeded", urls
if status in {"submitted", "queued", "pending", "running", "processing", "in_progress", "created", "waiting"}:
return "pending", []
raise ValueError("Unknown provider task status")