mirror of
https://github.com/MemeMeow-Studio/MemeMeow.git
synced 2026-10-18 13:42:36 +08:00
5ecda39843
- Implement tests for the ImageProcessingWorker to validate task deduplication and policy interactions. - Create tests for operation policy to ensure proper grant handling and error responses. - Add tests for reverse image provider response structure to validate empty and malformed results. - Develop comprehensive tests for scope resolution and service validation to ensure correct behavior in various scenarios.
1632 lines
88 KiB
Python
1632 lines
88 KiB
Python
"""PostgreSQL 版元数据、文件生命周期和检索服务适配器。
|
|
|
|
领域 schema 与旧服务共用 ``MemeContext``/``SidecarMetadata`` 类型,但运行时不读写
|
|
sidecar 文件;数据库记录是唯一结构化事实,BlobStore 只保存图片字节。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Sequence
|
|
from uuid import UUID, uuid4
|
|
|
|
import numpy as np
|
|
from openai import OpenAI
|
|
from sqlalchemy import select
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from threading import Event, Lock
|
|
import threading
|
|
|
|
from backend.database import (
|
|
EMBEDDING_DIMENSIONS,
|
|
BlobStore,
|
|
DataEnvironment,
|
|
DatabaseError,
|
|
DatabaseResources,
|
|
ImageProcessingAttempt,
|
|
Meme,
|
|
MemeEmbedding,
|
|
MemeVisualEmbedding,
|
|
ScopeContext,
|
|
SearchGeneration,
|
|
StorageCoordinator,
|
|
StorageOperation,
|
|
Task,
|
|
TaskBatch,
|
|
TaskLaneSlot,
|
|
UnitOfWork,
|
|
utcnow,
|
|
)
|
|
from backend.metadata import (
|
|
CONTEXT_STATUSES,
|
|
MAX_SEMANTIC_DOCUMENT_LENGTH,
|
|
SCHEMA_VERSION,
|
|
EMBEDDING_FIELDS,
|
|
ImageIdentity,
|
|
MemeContext,
|
|
MetadataError,
|
|
Provenance,
|
|
SidecarMetadata,
|
|
semantic_document,
|
|
)
|
|
from backend.operation_policy import GrantAssociationStore, OperationPolicyError, OperationPolicyGateway, Operations
|
|
from backend.paths import SUPPORTED_EXTENSIONS
|
|
from backend.tasks import IMAGE_PROCESSING_TASK_TYPES, TaskRecord, TERMINAL, STABLE_TASK_ERRORS
|
|
from backend.scope import validate_scope_services
|
|
from backend.visual import VisualEmbeddingError, VisualInferenceClient, identity_from_settings
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _iso(value: datetime | str | None) -> str:
|
|
"""将数据库时间转换为旧领域模型接受的 UTC ISO 字符串。"""
|
|
if value is None:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
return value.isoformat() if isinstance(value, datetime) else str(value)
|
|
|
|
|
|
class PostgresMetadataService:
|
|
"""以 scope-bound repository 管理 Meme 元数据和图片指纹。
|
|
|
|
``scope_id`` 的 local 默认值只为开源旧夹具保留;应用运行时由
|
|
``ScopeServiceFactory`` 显式传入 scope。
|
|
"""
|
|
|
|
def __init__(self, resources: DatabaseResources, *, scope_id: str | ScopeContext = "local"):
|
|
self.resources = resources
|
|
self.scope = scope_id if isinstance(scope_id, ScopeContext) else ScopeContext(scope_id)
|
|
self.blob_store = resources.blob_store_for_scope(self.scope.scope_id)
|
|
self.root = self.blob_store.root
|
|
self.root.mkdir(parents=True, exist_ok=True)
|
|
self.storage = StorageCoordinator(resources, scope_id=self.scope)
|
|
|
|
def image_sha256(self, image: Path) -> str:
|
|
"""计算受控图片 SHA-256;不可读文件转换为稳定 MetadataError。"""
|
|
digest = hashlib.sha256()
|
|
try:
|
|
with image.open("rb") as source:
|
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
except OSError as exc:
|
|
raise MetadataError("image_unreadable") from exc
|
|
return digest.hexdigest()
|
|
|
|
def _relative(self, image: Path) -> str:
|
|
"""将图片解析为当前 scope 内的 POSIX storage_key。"""
|
|
try:
|
|
return self.blob_store.relative(image)
|
|
except (ValueError, OSError) as exc:
|
|
raise MetadataError("path_forbidden") from exc
|
|
|
|
def _identity(self, image: Path) -> dict[str, object]:
|
|
"""读取图片路径、扩展名、大小和 SHA 指纹。"""
|
|
try:
|
|
stat = image.stat()
|
|
except OSError as exc:
|
|
raise MetadataError("image_unreadable") from exc
|
|
return {"relative_path": self._relative(image), "extension": image.suffix.lower(), "size_bytes": stat.st_size, "sha256": self.image_sha256(image)}
|
|
|
|
@staticmethod
|
|
def _base_context() -> dict[str, object]:
|
|
"""创建尚未研究图片的最小语境。"""
|
|
return {"title": None, "summary": "", "subjects": [], "visible_text": [], "references": [], "meaning": None, "keywords": [], "search_queries": [], "uncertainties": ["尚未完成图片语境研究"], "source_urls": []}
|
|
|
|
def _to_sidecar(self, record: Meme, image: Path | None = None) -> SidecarMetadata:
|
|
"""将数据库 Meme 映射为稳定领域对象,供 API/Agent 复用。"""
|
|
context = MemeContext.model_validate(record.meme_context or {})
|
|
provenance = Provenance.model_validate(record.provenance or {})
|
|
payload: dict[str, object] = {
|
|
"schema_version": record.metadata_schema_version,
|
|
"image": {"relative_path": record.storage_key, "extension": record.extension, "size_bytes": record.size_bytes, "sha256": record.sha256},
|
|
"context_status": record.context_status,
|
|
"meme_context": context.model_dump(mode="json", exclude_none=False),
|
|
"provenance": provenance.model_dump(mode="json", exclude_none=False),
|
|
}
|
|
payload.update(record.extensions or {})
|
|
if image is not None:
|
|
identity = self._identity(image)
|
|
payload["image"] = identity
|
|
return SidecarMetadata.model_validate(payload)
|
|
|
|
def _record(self, image: Path, *, for_update: bool = False) -> Meme:
|
|
"""按 storage_key 获取当前 scope Meme 并校验图片实际存在。"""
|
|
key = self._relative(image)
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
record = environment.memes.by_storage_key(key, for_update=for_update)
|
|
if record is None:
|
|
raise MetadataError("metadata_missing")
|
|
return record
|
|
|
|
def load(self, image: Path) -> SidecarMetadata:
|
|
"""读取数据库元数据并严格校验当前图片指纹。"""
|
|
key = self._relative(image)
|
|
try:
|
|
identity = self._identity(image)
|
|
except MetadataError:
|
|
raise
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
record = environment.memes.by_storage_key(key)
|
|
if record is None:
|
|
raise MetadataError("metadata_missing")
|
|
if record.extension != identity["extension"] or record.size_bytes != identity["size_bytes"] or record.sha256 != identity["sha256"]:
|
|
record.context_status = "repair_required"
|
|
record.updated_at = utcnow()
|
|
environment.uow.session.flush()
|
|
environment.uow.session.commit()
|
|
raise MetadataError("metadata_image_mismatch")
|
|
try:
|
|
return self._to_sidecar(record)
|
|
except Exception as exc: # noqa: BLE001
|
|
record.context_status = "repair_required"
|
|
raise MetadataError("metadata_invalid") from exc
|
|
|
|
def status(self, image: Path) -> dict[str, object]:
|
|
"""返回图片库安全状态摘要;失效指纹始终显示为 repair_required。"""
|
|
try:
|
|
metadata = self.load(image)
|
|
except MetadataError as exc:
|
|
return {"status": "repair_required", "error": exc.code}
|
|
context = metadata.meme_context
|
|
return {"status": metadata.context_status, "title": context.title, "summary": context.summary, "subjects": context.subjects, "meaning": context.meaning, "keywords": context.keywords}
|
|
|
|
def meme_id_for_image(self, image: Path) -> UUID:
|
|
"""读取当前 scope 图片对应的稳定 meme_id。"""
|
|
key = self._relative(image)
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
record = environment.memes.by_storage_key(key)
|
|
if record is None:
|
|
raise MetadataError("metadata_missing")
|
|
return record.id
|
|
|
|
def image_for_meme(self, meme_id: UUID | str) -> tuple[Meme, Path]:
|
|
"""按当前 scope meme_id 返回数据库记录及经过 BlobStore 校验的图片路径。"""
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
record = environment.memes.get(meme_id)
|
|
if record is None:
|
|
raise MetadataError("metadata_missing")
|
|
try:
|
|
image = self.blob_store.resolve(record.storage_key)
|
|
except DatabaseError as exc:
|
|
raise MetadataError(exc.code) from exc
|
|
identity = self._identity(image)
|
|
if record.sha256 != identity["sha256"] or record.size_bytes != identity["size_bytes"]:
|
|
raise MetadataError("metadata_image_mismatch")
|
|
return record, image
|
|
|
|
def create_pending(self, image: Path, *, status: str = "pending", meme_id: UUID | None = None) -> SidecarMetadata:
|
|
"""为合法图片幂等创建数据库 Meme 和 pending 语境。"""
|
|
if status not in CONTEXT_STATUSES:
|
|
raise MetadataError("invalid_context_status")
|
|
identity = self._identity(image)
|
|
key = str(identity["relative_path"])
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
existing = environment.memes.by_storage_key(key)
|
|
if existing:
|
|
return self._to_sidecar(existing)
|
|
record = environment.memes.create(storage_key=key, extension=str(identity["extension"]), size_bytes=int(identity["size_bytes"]), sha256=str(identity["sha256"]), context=self._base_context(), provenance={"producer": "system", "model": None, "updated_at": datetime.now(timezone.utc).isoformat(), "field_sources": {}, "last_error": None}, status=status, meme_id=meme_id)
|
|
return self._to_sidecar(record)
|
|
|
|
def upload_bytes(self, content: bytes, *, target_key: str) -> tuple[UUID, Path]:
|
|
"""通过 StorageCoordinator 暂存并提交上传,返回稳定 ID 与受控文件路径。"""
|
|
suffix = Path(target_key).suffix.lower()
|
|
try:
|
|
record = self.storage.upload(content, target_key=target_key, extension=suffix, context=self._base_context(), provenance={"producer": "system", "model": None, "updated_at": datetime.now(timezone.utc).isoformat(), "field_sources": {}, "last_error": None})
|
|
except DatabaseError as exc:
|
|
raise MetadataError(exc.code) from exc
|
|
return record.id, self.blob_store.resolve(record.storage_key)
|
|
|
|
def _merge_payload(self, current: SidecarMetadata, context_updates: dict[str, object], *, producer: str, model: str | None, status: str, error: str | None, agent_context: dict[str, object] | None = None) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
|
"""合并语境并应用人工字段保护,同时保留未知扩展字段。"""
|
|
context = current.meme_context.model_dump(mode="json", exclude_none=False)
|
|
sources = dict(current.provenance.field_sources or {})
|
|
for field, value in context_updates.items():
|
|
if field not in MemeContext.model_fields:
|
|
continue
|
|
if sources.get(field) == "human" and producer != "human":
|
|
continue
|
|
context[field] = value
|
|
sources[field] = producer
|
|
provenance = current.provenance.model_dump(mode="json", exclude_none=False)
|
|
provenance.update({"producer": producer, "model": model, "updated_at": datetime.now(timezone.utc).isoformat(), "field_sources": sources, "last_error": error})
|
|
if agent_context is not None and producer == "research":
|
|
# 该摘要由后端根据 claim/SHA 生成,Agent 输出不能自行伪造候选资格。
|
|
provenance["agent_context"] = dict(agent_context)
|
|
known = {"schema_version", "image", "context_status", "meme_context", "provenance"}
|
|
extensions = {key: value for key, value in current.model_dump(mode="json", exclude_none=False).items() if key not in known}
|
|
return context, provenance, extensions
|
|
|
|
def update_context(self, image: Path, context_updates: dict[str, object], *, producer: str, model: str | None = None, status: str = "partial", error: str | None = None, expected_revision: int | None = None, expected_sha256: str | None = None, claim: tuple[str, int, str] | None = None, agent_context: dict[str, object] | None = None) -> SidecarMetadata:
|
|
"""在一个事务中更新数据库语境,并用 revision/SHA 防止过期任务覆盖。"""
|
|
key = self._relative(image)
|
|
identity = self._identity(image)
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
record = environment.memes.by_storage_key(key, for_update=True)
|
|
if record is None:
|
|
record = environment.memes.create(storage_key=key, extension=str(identity["extension"]), size_bytes=int(identity["size_bytes"]), sha256=str(identity["sha256"]), context=self._base_context(), provenance={"producer": "system", "updated_at": datetime.now(timezone.utc).isoformat(), "field_sources": {}}, status="pending")
|
|
current = self._to_sidecar(record)
|
|
context, provenance, extensions = self._merge_payload(current, context_updates, producer=producer, model=model, status=status, error=error, agent_context=agent_context)
|
|
try:
|
|
record = environment.memes.update_context(record.id, context=context, provenance=provenance, status=status if status in CONTEXT_STATUSES else "partial", expected_revision=expected_revision, expected_sha256=expected_sha256, claim=claim)
|
|
except DatabaseError as exc:
|
|
raise MetadataError(exc.code) from exc
|
|
record.extensions = extensions
|
|
return self._to_sidecar(record)
|
|
|
|
def record_error(self, image: Path, *, producer: str, model: str | None, error: str) -> SidecarMetadata:
|
|
"""记录失败诊断但保留最近一次有效语境。"""
|
|
key = self._relative(image)
|
|
identity = self._identity(image)
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
record = environment.memes.by_storage_key(key, for_update=True)
|
|
if record is None:
|
|
raise MetadataError("metadata_missing")
|
|
# 失败诊断不是语境写回:只更新 provenance,保留同一事务开始时锁定的最新上下文和状态。
|
|
current_provenance = dict(record.provenance or {})
|
|
field_sources = dict(current_provenance.get("field_sources") or {})
|
|
current_provenance.update(
|
|
{
|
|
"producer": producer,
|
|
"model": model,
|
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
"field_sources": field_sources,
|
|
"last_error": error,
|
|
}
|
|
)
|
|
record.provenance = current_provenance
|
|
record.revision += 1
|
|
record.updated_at = utcnow()
|
|
environment.uow.session.flush()
|
|
return self._to_sidecar(record)
|
|
|
|
def update_reverse_image_provenance(self, meme_id: str | UUID, audit: dict[str, Any]) -> None:
|
|
"""把后端 usage event 汇总写入 Meme provenance,拒绝 Agent 自报覆盖。"""
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
record = environment.memes.get(meme_id, for_update=True)
|
|
if record is None:
|
|
raise MetadataError("metadata_missing")
|
|
provenance = dict(record.provenance or {})
|
|
provenance["reverse_image"] = dict(audit)
|
|
record.provenance = provenance
|
|
record.updated_at = utcnow()
|
|
environment.uow.session.flush()
|
|
|
|
def rename(self, source: Path, target: Path) -> SidecarMetadata:
|
|
"""按稳定 Meme 身份更新数据库路径;文件移动由 API 的操作协调器负责。"""
|
|
source_key = self._relative(source)
|
|
target_key = self._relative(target)
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
record = environment.memes.by_storage_key(source_key, for_update=True)
|
|
if record is None:
|
|
raise MetadataError("metadata_missing")
|
|
try:
|
|
record = environment.memes.rename(record.id, target_key)
|
|
except DatabaseError as exc:
|
|
raise MetadataError(exc.code) from exc
|
|
return self._to_sidecar(record)
|
|
|
|
def rename_by_id(self, meme_id: UUID | str, target: Path) -> SidecarMetadata:
|
|
"""按稳定 meme_id 记录并完成文件移动,保持资源身份不变。"""
|
|
target_key = self._relative(target)
|
|
try:
|
|
record = self.storage.rename(meme_id, target_key=target_key)
|
|
except DatabaseError as exc:
|
|
raise MetadataError(exc.code) from exc
|
|
return self._to_sidecar(record)
|
|
|
|
def remove(self, image: Path) -> None:
|
|
"""删除数据库 Meme;文件隔离由调用方先行完成并可恢复。"""
|
|
key = self._relative(image)
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
record = environment.memes.by_storage_key(key, for_update=True)
|
|
if record is None:
|
|
raise MetadataError("metadata_missing")
|
|
environment.memes.delete(record.id)
|
|
|
|
def remove_by_id(self, meme_id: UUID | str) -> None:
|
|
"""按稳定 meme_id 隔离文件、删除数据库记录并清理隔离区。"""
|
|
try:
|
|
self.storage.delete(meme_id)
|
|
except DatabaseError as exc:
|
|
raise MetadataError(exc.code) from exc
|
|
|
|
def recover_storage(self, *, limit: int = 100) -> dict[str, int]:
|
|
"""恢复当前 scope 的未完成跨存储操作。"""
|
|
try:
|
|
return self.storage.recover(limit=limit)
|
|
except DatabaseError as exc:
|
|
raise MetadataError(exc.code) from exc
|
|
|
|
def integrity_scan(self) -> dict[str, Any]:
|
|
"""报告孤立文件、缺失对象、指纹冲突和活动操作。"""
|
|
return self.storage.integrity_scan()
|
|
|
|
def embedding_record(self, image: Path) -> dict[str, object]:
|
|
"""返回数据库语境驱动的 embedding 资格和内容指纹。"""
|
|
image_sha = self.image_sha256(image)
|
|
try:
|
|
metadata = self.load(image)
|
|
except MetadataError as exc:
|
|
return {"text": "", "indexable": False, "skip_reason": "metadata_invalid", "status": "repair_required", "metadata_schema_version": None, "metadata_hash": None, "image_sha256": image_sha, "error": exc.code}
|
|
context = metadata.meme_context
|
|
text = semantic_document(context) if metadata.context_status in {"partial", "ready"} else ""
|
|
serialized = json.dumps(metadata.model_dump(mode="json", exclude_none=False), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
reason = None if metadata.context_status in {"partial", "ready"} and text else ("metadata_pending" if metadata.context_status == "pending" else "semantic_text_empty" if metadata.context_status in {"partial", "ready"} else "metadata_unavailable")
|
|
return {"text": text, "indexable": reason is None, "skip_reason": reason, "status": metadata.context_status, "metadata_schema_version": metadata.schema_version, "metadata_hash": hashlib.sha256(serialized.encode("utf-8")).hexdigest(), "image_sha256": image_sha}
|
|
|
|
def repair(self, progress: Any | None = None) -> dict[str, object]:
|
|
"""执行数据库与文件双向完整性扫描,不把孤立文件隐式导入业务列表。"""
|
|
if progress:
|
|
progress(0.25, "正在扫描数据库图片记录与文件指纹")
|
|
report = self.integrity_scan()
|
|
# 完整性扫描直接以数据库 Meme 为权威;孤立图片只报告,不自动导入。
|
|
report.setdefault("orphan_files", [])
|
|
report["processed"] = len(report.get("missing_files", [])) + len(report.get("mismatched", []))
|
|
report["created"] = 0
|
|
report["repair_required"] = len(report.get("missing_files", [])) + len(report.get("mismatched", []))
|
|
if progress:
|
|
progress(1.0, "图片完整性扫描完成")
|
|
return report
|
|
|
|
|
|
class PostgresSearchService:
|
|
"""基于 search_generations/meme_embeddings 的 pgvector 检索服务。
|
|
|
|
直接构造时的 local 默认值仅用于开源兼容夹具;生产请求通过 scope facade 获取。
|
|
"""
|
|
|
|
def __init__(self, settings: Any, resources: DatabaseResources, metadata: PostgresMetadataService, *, scope_id: str | ScopeContext = "local"):
|
|
self.settings, self.resources, self.metadata = settings, resources, metadata
|
|
self.scope = scope_id if isinstance(scope_id, ScopeContext) else ScopeContext(scope_id)
|
|
self.model = settings.embedding_model
|
|
self._generation_lock = __import__("threading").Lock()
|
|
|
|
def _client(self) -> OpenAI:
|
|
"""创建嵌入客户端,缺少配置时返回稳定错误。"""
|
|
if not self.settings.embedding_api_key or not self.settings.embedding_base_url:
|
|
raise RuntimeError("embedding_not_configured")
|
|
return OpenAI(api_key=self.settings.embedding_api_key, base_url=self.settings.embedding_base_url)
|
|
|
|
def _embedding(self, text: str) -> list[float]:
|
|
"""调用模型并拒绝非 1024 维或零向量响应。"""
|
|
response = self._client().embeddings.create(model=self.model, input=text, encoding_format="float")
|
|
vector = np.asarray(response.data[0].embedding, dtype=float)
|
|
if vector.shape != (EMBEDDING_DIMENSIONS,) or not np.linalg.norm(vector):
|
|
raise RuntimeError("embedding_dimensions_mismatch")
|
|
return (vector / np.linalg.norm(vector)).tolist()
|
|
|
|
def has_cache(self) -> bool:
|
|
"""检查当前 scope/model 的唯一检索来源是否已有有效数据。"""
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
if environment.search.source_mode(self.model) == "incremental":
|
|
return environment.search.has_incremental(self.model)
|
|
# active generation 只说明控制面存在一代索引,不能证明其中仍有
|
|
# 与当前 Meme SHA、语境 hash、维度和文件身份一致的可检索条目。
|
|
return environment.search.has_legacy(self.model)
|
|
|
|
def invalidate_cache(self) -> None:
|
|
"""数据库索引不在进程内缓存;此方法保留兼容调用但无副作用。"""
|
|
|
|
def mark_cache_invalidated(self, batch_id: object = None) -> None:
|
|
"""兼容旧批次回调,generation 由独立任务显式刷新。"""
|
|
|
|
def generate_cache(self, progress: Callable[[float | None, str | None], None], claim: tuple[str, int, str] | None = None) -> dict[str, object]:
|
|
"""短事务固化源快照,在事务外调用 embedding,完成后原子激活。"""
|
|
with self._generation_lock:
|
|
candidates: list[tuple[UUID, int, str, str, str]] = []
|
|
skipped = 0
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
# 源集合必须在同一个短 REPEATABLE READ 事务中固化,避免刷新期间
|
|
# 读取到跨事务的 Meme revision/语境组合。
|
|
environment.uow.session.connection(execution_options={"isolation_level": "REPEATABLE READ"})
|
|
environment.search.assert_claim(claim)
|
|
# 同一 scope/model 的 building generation 可能来自上次 Worker 崩溃;
|
|
# 新 claim 先将其隔离,再创建本次不可变 generation。
|
|
environment.search.abandon_building(self.model, claim=claim)
|
|
for meme in environment.memes.list_all():
|
|
try:
|
|
image = self.metadata.blob_store.resolve(meme.storage_key)
|
|
identity = self.metadata._identity(image)
|
|
context = MemeContext.model_validate(meme.meme_context or {})
|
|
except (DatabaseError, MetadataError, ValueError):
|
|
skipped += 1
|
|
continue
|
|
if identity["sha256"] != meme.sha256 or identity["size_bytes"] != meme.size_bytes or meme.context_status not in {"partial", "ready"}:
|
|
skipped += 1
|
|
continue
|
|
text_value = semantic_document(context)
|
|
if not text_value:
|
|
skipped += 1
|
|
continue
|
|
serialized = json.dumps(self.metadata._to_sidecar(meme).model_dump(mode="json", exclude_none=False), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
metadata_hash = hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
|
candidates.append((meme.id, meme.revision, meme.sha256, text_value, metadata_hash))
|
|
if not candidates:
|
|
raise RuntimeError("no_indexable_images")
|
|
candidates.sort(key=lambda item: str(item[0]))
|
|
source = [(str(meme_id), revision, image_sha, metadata_hash, hashlib.sha256(text.encode()).hexdigest()) for meme_id, revision, image_sha, text, metadata_hash in candidates]
|
|
source_hash = hashlib.sha256(json.dumps(source, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest()
|
|
generation = environment.search.create_generation(self.model, source_hash)
|
|
generation_id = generation.id
|
|
for meme_id, revision, image_sha, text_value, metadata_hash in candidates:
|
|
environment.search.assert_claim(claim)
|
|
environment.search.add_snapshot_item(generation_id, meme_id=meme_id, meme_revision=revision, image_sha256=image_sha, semantic_document=text_value[:MAX_SEMANTIC_DOCUMENT_LENGTH], metadata_hash=metadata_hash)
|
|
total = len(candidates)
|
|
try:
|
|
for index, (meme_id, _revision, _image_sha, text_value, _metadata_hash) in enumerate(candidates, start=1):
|
|
vector = self._embedding(text_value)
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
environment.search.set_item_embedding(generation_id, meme_id, vector, claim=claim)
|
|
if progress:
|
|
progress(index / total, f"正在生成 pgvector 索引 {index}/{total}")
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
environment.search.activate(generation, expected_source_hash=source_hash, expected_items=source, claim=claim)
|
|
return {"indexed_count": total, "skipped_count": skipped, "generation_id": str(generation_id), "model": self.model, "dimensions": EMBEDDING_DIMENSIONS}
|
|
except Exception as exc:
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
try:
|
|
environment.search.fail_generation(generation_id, error=str(exc)[:500], claim=claim)
|
|
except DatabaseError:
|
|
# 租约已被新 Worker 接管时,旧 Worker 不得修改 generation 状态。
|
|
pass
|
|
raise
|
|
|
|
def _enhance_query(self, query: str) -> str:
|
|
"""使用可选 LLM 改写查询;失败由 API 回退普通查询。"""
|
|
if not self.settings.llm_enhance_model:
|
|
raise RuntimeError("llm_enhance_not_configured")
|
|
response = self._client().chat.completions.create(model=self.settings.llm_enhance_model, messages=[{"role": "user", "content": f"将下面内容改写为一句适合检索表情包的简短描述,只输出描述:{query}"}], temperature=0.2)
|
|
value = response.choices[0].message.content
|
|
if not value or not value.strip():
|
|
raise RuntimeError("llm_enhance_invalid")
|
|
return value.strip()
|
|
|
|
def search(self, query: str, top_k: int = 5, api_key: str | None = None, use_llm: bool = False) -> list[str]:
|
|
"""按 meme_id 稳定排序查询并返回当前 scope 的稳定资源标识。"""
|
|
if use_llm:
|
|
try:
|
|
query = self._enhance_query(query)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
vector = self._embedding(query)
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
if environment.search.source_mode(self.model) == "incremental":
|
|
ranked = environment.search.query_incremental(self.model, vector, top_k)
|
|
else:
|
|
ranked = environment.search.query(self.model, vector, top_k)
|
|
result: list[str] = []
|
|
for meme_id, _score in ranked:
|
|
try:
|
|
_record, image = self.metadata.image_for_meme(meme_id)
|
|
except MetadataError:
|
|
continue
|
|
value = str(meme_id)
|
|
if value not in result:
|
|
result.append(value)
|
|
if len(result) >= top_k:
|
|
break
|
|
return result
|
|
|
|
|
|
class PostgresTaskWorkerManager:
|
|
"""进程级任务协调器,统一管理线程池、处理器注册和任务恢复扫描。
|
|
|
|
任务的数据库操作仍由 scope-bound ``PostgresTaskService`` 执行;协调器只按任务
|
|
ID 调度工作,并在真正认领后根据持久 ``Task.scope_id`` 创建轻量服务视图。这样
|
|
历史 scope 数量不会复制 Worker、线程池、handler registry 或全局 Agent lane。
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
resources: DatabaseResources,
|
|
*,
|
|
agent_concurrency: int = 1,
|
|
agent_backpressure: int = 32,
|
|
settings_version: str | None = None,
|
|
lease_seconds: int = 120,
|
|
max_attempts: int = 3,
|
|
executor: ThreadPoolExecutor | None = None,
|
|
) -> None:
|
|
self.resources = resources
|
|
self.agent_concurrency = max(1, min(int(agent_concurrency), 8))
|
|
self.agent_backpressure = max(1, min(int(agent_backpressure), 500))
|
|
self.settings_version = settings_version
|
|
self.lease_seconds = lease_seconds
|
|
self.max_attempts = max_attempts
|
|
self._executor = executor or ThreadPoolExecutor(max_workers=max(2, self.agent_concurrency + 1), thread_name_prefix="mememeow-scope-worker")
|
|
self._owns_executor = executor is None
|
|
self._service_resolver: Callable[[str], Any] | None = None
|
|
self._scope_service_resolver: Callable[[str | ScopeContext], Any] | None = None
|
|
self._handlers: dict[str, Callable[[dict[str, Any], Callable[[float | None, str | None], None]], Any]] = {}
|
|
self._lock = Lock()
|
|
self._stopped = Event()
|
|
self._started = False
|
|
self._scheduled: set[str] = set()
|
|
self.owner = f"worker-{os.getpid()}-{id(self)}"
|
|
|
|
@property
|
|
def worker_count(self) -> int:
|
|
"""返回当前进程协调器数量;一个 manager 代表一个 Worker 控制面。"""
|
|
return 0 if self._stopped.is_set() else 1
|
|
|
|
@property
|
|
def executor(self) -> ThreadPoolExecutor:
|
|
"""返回共享线程池,供 scope facade 复用而不自行创建调度资源。"""
|
|
return self._executor
|
|
|
|
def set_service_resolvers(self, task_resolver: Callable[[str], Any], scope_resolver: Callable[[str | ScopeContext], Any]) -> None:
|
|
"""安装按持久任务或显式 scope 创建轻量服务视图的回调。"""
|
|
self._service_resolver = task_resolver
|
|
self._scope_service_resolver = scope_resolver
|
|
|
|
def register(self, task_type: str, handler: Callable[[dict[str, Any], Callable[[float | None, str | None], None]], Any]) -> None:
|
|
"""在进程级 registry 注册一个任务处理器,所有 scope 共用该注册表。"""
|
|
self._handlers[task_type] = handler
|
|
|
|
def handler(self, task_type: str) -> Callable[[dict[str, Any], Callable[[float | None, str | None], None]], Any] | None:
|
|
"""读取当前任务类型的全局处理器。"""
|
|
return self._handlers.get(task_type)
|
|
|
|
def start(self) -> dict[str, list[str]]:
|
|
"""执行一次全局租约恢复、批次恢复和 queued 任务扫描。"""
|
|
with self._lock:
|
|
if self._started:
|
|
return {"started": [], "invalid_tasks": []}
|
|
self._started = True
|
|
try:
|
|
queued = self._recover_expired()
|
|
invalid = self._fail_invalid_scope_tasks()
|
|
queued.extend(self._recover_pending_batches())
|
|
with self.resources.factory() as session:
|
|
queued.extend(
|
|
session.scalars(
|
|
select(Task.id).where(
|
|
Task.status == "queued",
|
|
~Task.task_type.in_(IMAGE_PROCESSING_TASK_TYPES),
|
|
)
|
|
)
|
|
)
|
|
for task_id in dict.fromkeys(queued):
|
|
self.schedule(task_id)
|
|
return {"started": [self.owner], "invalid_tasks": sorted(set(invalid))}
|
|
except Exception:
|
|
with self._lock:
|
|
self._started = False
|
|
raise
|
|
|
|
def _recover_expired(self) -> list[str]:
|
|
"""跨所有 scope 恢复过期 claim,并释放旧 lane 槽位。"""
|
|
now = utcnow()
|
|
queued: list[str] = []
|
|
with self.resources.factory() as session:
|
|
rows = list(
|
|
session.scalars(
|
|
select(Task)
|
|
.where(
|
|
Task.status == "running",
|
|
Task.lease_expires_at < now,
|
|
~Task.task_type.in_(IMAGE_PROCESSING_TASK_TYPES),
|
|
)
|
|
.with_for_update(skip_locked=True)
|
|
.limit(5000)
|
|
)
|
|
)
|
|
for task in rows:
|
|
if task.attempt_count < task.max_attempts:
|
|
task.status = "queued"
|
|
task.available_at = now
|
|
task.message = "租约已过期,等待重新认领"
|
|
task.error = {"error": "lease_expired", "message": "Worker 租约已过期"}
|
|
queued.append(task.id)
|
|
else:
|
|
task.status = "failed"
|
|
task.completed_at = now
|
|
task.message = "任务达到最大尝试次数"
|
|
task.error = {"error": "max_attempts_exceeded", "message": "任务达到最大尝试次数"}
|
|
task.lease_owner = None
|
|
task.lease_expires_at = None
|
|
task.updated_at = now
|
|
self._release_slot(session, task.scope_id, task.id, owner=self.owner, claim_generation=task.claim_generation)
|
|
session.commit()
|
|
return queued
|
|
|
|
@staticmethod
|
|
def _release_slot(session: Any, scope_id: str, task_id: str, *, owner: str | None = None, claim_generation: int | None = None) -> bool:
|
|
"""在全局恢复事务中按完整 claim 释放 lane 槽位。"""
|
|
slot = session.scalar(select(TaskLaneSlot).where(TaskLaneSlot.task_scope_id == scope_id, TaskLaneSlot.task_id == task_id).with_for_update())
|
|
if slot is not None:
|
|
if owner is not None and slot.lease_owner not in {None, owner}:
|
|
logger.info("task_lane_fencing_rejection task=%s scope=%s", task_id, scope_id)
|
|
return False
|
|
if claim_generation is not None and getattr(slot, "claim_generation", None) not in {None, claim_generation}:
|
|
logger.info("task_lane_fencing_rejection task=%s scope=%s", task_id, scope_id)
|
|
return False
|
|
slot.task_scope_id = None
|
|
slot.task_id = None
|
|
slot.lease_owner = None
|
|
if hasattr(slot, "claim_generation"):
|
|
slot.claim_generation = None
|
|
slot.lease_expires_at = None
|
|
return True
|
|
return False
|
|
|
|
def _fail_invalid_scope_tasks(self) -> list[str]:
|
|
"""启动扫描发现非法持久 scope 时稳定失败,绝不猜测为 local。"""
|
|
invalid: list[str] = []
|
|
with self.resources.factory() as session:
|
|
rows = list(
|
|
session.scalars(
|
|
select(Task).where(
|
|
Task.status.in_(("queued", "running")),
|
|
~Task.task_type.in_(IMAGE_PROCESSING_TASK_TYPES),
|
|
)
|
|
)
|
|
)
|
|
for task in rows:
|
|
try:
|
|
ScopeContext(task.scope_id)
|
|
except (TypeError, ValueError):
|
|
invalid.append(task.id)
|
|
task.status = "failed"
|
|
task.completed_at = utcnow()
|
|
task.lease_owner = None
|
|
task.lease_expires_at = None
|
|
task.error = {"error": "task_scope_invalid", "message": "任务缺少有效 scope"}
|
|
task.message = "任务 scope 无效"
|
|
self._release_slot(session, task.scope_id, task.id)
|
|
session.commit()
|
|
return invalid
|
|
|
|
def _recover_pending_batches(self) -> list[str]:
|
|
"""按批次所属 scope 恢复未收束的唯一 cache 任务,不扫描并实例化全部历史 scope。"""
|
|
queued: list[str] = []
|
|
if self._scope_service_resolver is None:
|
|
return queued
|
|
with self.resources.factory() as session:
|
|
batches = list(session.execute(select(TaskBatch.scope_id, TaskBatch.batch_id).where(TaskBatch.sealed.is_(True), TaskBatch.finalizer_state.in_(('pending', 'submitted'))).limit(5000)).all())
|
|
for scope_id, batch_id in batches:
|
|
try:
|
|
services = self._scope_service_resolver(scope_id)
|
|
with self.resources.environment(services.scope) as environment:
|
|
task = environment.tasks.finalize_batch_with_task(
|
|
batch_id,
|
|
task_type="cache_generation",
|
|
payload={},
|
|
dedupe_key="cache_generation",
|
|
settings_version=self.settings_version,
|
|
max_attempts=self.max_attempts,
|
|
)
|
|
if task is not None:
|
|
queued.append(task.id)
|
|
except (DatabaseError, RuntimeError, TypeError, ValueError):
|
|
# 任务 scope 无法装配时由后续任务诊断和宿主日志收束,不回退到 local。
|
|
continue
|
|
return queued
|
|
|
|
def schedule(self, task_id: str) -> None:
|
|
"""将任务加入进程级调度集合,避免不同 scope 重复提交同一 future。"""
|
|
with self._lock:
|
|
if self._stopped.is_set() or task_id in self._scheduled:
|
|
return
|
|
self._scheduled.add(task_id)
|
|
self._executor.submit(self._run, task_id)
|
|
|
|
def _run(self, task_id: str) -> None:
|
|
"""先按持久 scope 认领任务,再创建 scope facade 执行处理器。"""
|
|
service = None
|
|
claim = None
|
|
try:
|
|
claim = self._claim_for_task(task_id)
|
|
if claim is None:
|
|
self._task_finished(task_id, claimed=False)
|
|
return
|
|
if self._scope_service_resolver is None:
|
|
raise RuntimeError("task_scope_unavailable")
|
|
# Worker 认领后仍必须复用统一 factory 校验;宿主自定义 resolver 不能以
|
|
# 返回错误 scope 的 facade 绕过 claim 的业务隔离边界。
|
|
services = validate_scope_services(ScopeContext(claim.scope_id), self._scope_service_resolver(claim.scope_id))
|
|
service = getattr(services, "tasks", None)
|
|
if service is None:
|
|
raise RuntimeError("task_scope_unavailable")
|
|
service._run(task_id, preclaimed=claim)
|
|
except Exception:
|
|
if service is None:
|
|
# 工厂异常发生在 claim 之后时只能用完整 claim fencing 收束;
|
|
# 没有 claim 证据则不按裸 task_id 修改任意任务。
|
|
self._fail_unresolvable(claim)
|
|
self._task_finished(task_id, claimed=claim is not None)
|
|
else:
|
|
# 业务 facade 异常不能遗留 scheduled 标记,否则恢复扫描无法再次唤醒任务。
|
|
self._task_finished(task_id, claimed=claim is not None)
|
|
|
|
def _claim_for_task(self, task_id: str) -> Task | None:
|
|
"""从任务控制面读取 scope 并在创建业务 facade 前完成 claim。"""
|
|
with self.resources.factory() as session:
|
|
scope_id = session.scalar(select(Task.scope_id).where(Task.id == task_id))
|
|
if not isinstance(scope_id, str) or not scope_id:
|
|
return None
|
|
try:
|
|
scope = ScopeContext(scope_id)
|
|
except (TypeError, ValueError) as exc:
|
|
raise RuntimeError("task_scope_invalid") from exc
|
|
with self.resources.environment(scope) as environment:
|
|
queued = environment.tasks.get(task_id)
|
|
if queued is None:
|
|
return None
|
|
if queued.task_type in IMAGE_PROCESSING_TASK_TYPES:
|
|
# 图片任务属于专用 Worker;通用 manager 不得认领或收束它们。
|
|
return None
|
|
return environment.tasks.claim(
|
|
owner=self.owner,
|
|
lease_seconds=self.lease_seconds,
|
|
task_id=task_id,
|
|
lane=queued.lane,
|
|
lane_capacity=self.agent_concurrency if queued.lane == "agent" else None,
|
|
)
|
|
|
|
def _fail_unresolvable(self, claim: Task | None) -> None:
|
|
"""按完整 claim 收束 scope 装配失败,旧 claim 不得终止新 Worker。"""
|
|
if claim is None:
|
|
return
|
|
with self.resources.factory() as session:
|
|
now = utcnow()
|
|
statement = select(Task).where(
|
|
Task.scope_id == claim.scope_id,
|
|
Task.id == claim.id,
|
|
Task.status == "running",
|
|
Task.claim_generation == claim.claim_generation,
|
|
Task.lease_owner == self.owner,
|
|
Task.lease_expires_at > now,
|
|
).with_for_update()
|
|
task = session.scalar(statement)
|
|
if task is None:
|
|
logger.info("task_scope_assembly_fencing_rejection task=%s scope=%s generation=%s", claim.id, claim.scope_id, claim.claim_generation)
|
|
session.commit()
|
|
return
|
|
task.status = "failed"
|
|
task.completed_at = utcnow()
|
|
task.lease_owner = None
|
|
task.lease_expires_at = None
|
|
task.message = "任务 scope 无法装配"
|
|
task.error = {"error": "task_scope_unavailable", "message": "任务 scope 当前不可用"}
|
|
self._release_slot(session, task.scope_id, task.id, owner=self.owner, claim_generation=claim.claim_generation)
|
|
session.commit()
|
|
|
|
def _task_finished(self, task_id: str, *, claimed: bool) -> None:
|
|
"""释放全局调度标记,并在 lane 槽位释放后扫描下一批任务。"""
|
|
with self._lock:
|
|
self._scheduled.discard(task_id)
|
|
stopped = self._stopped.is_set()
|
|
if claimed and not stopped:
|
|
self._schedule_queued()
|
|
|
|
def _schedule_queued(self) -> None:
|
|
"""跨 scope 唤醒 queued 任务,保持全局 lane 背压后的前进性。"""
|
|
if self._stopped.is_set():
|
|
return
|
|
with self.resources.factory() as session:
|
|
task_ids = list(
|
|
session.scalars(
|
|
select(Task.id)
|
|
.where(
|
|
Task.status == "queued",
|
|
~Task.task_type.in_(IMAGE_PROCESSING_TASK_TYPES),
|
|
)
|
|
.limit(500)
|
|
)
|
|
)
|
|
for task_id in task_ids:
|
|
self.schedule(task_id)
|
|
|
|
def shutdown(self) -> None:
|
|
"""停止调度并以同一个 owner 收束本进程持有的所有 scope 任务。"""
|
|
self._stopped.set()
|
|
now = utcnow()
|
|
with self.resources.factory() as session:
|
|
rows = list(session.scalars(select(Task).where(Task.status == "running", Task.lease_owner == self.owner).with_for_update(skip_locked=True)))
|
|
for task in rows:
|
|
task.status = "failed"
|
|
task.completed_at = now
|
|
task.message = "Worker 已停止"
|
|
task.error = {"error": "task_interrupted", "message": "任务执行 Worker 已停止"}
|
|
task.lease_owner = None
|
|
task.lease_expires_at = None
|
|
task.updated_at = now
|
|
self._release_slot(session, task.scope_id, task.id, owner=self.owner, claim_generation=task.claim_generation)
|
|
session.commit()
|
|
if self._owns_executor:
|
|
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
|
|
|
|
class PostgresTaskService:
|
|
"""使用指定 scope 记录、去重、租约和 claim fencing 的任务执行器。
|
|
|
|
直接构造时的 local 默认值仅用于开源兼容夹具;生产 Worker 由 scope factory 装配。
|
|
``scope`` 只用于选择任务表中的候选行;真正执行时仍从刚认领的 Task 行
|
|
恢复并校验 scope,避免普通 payload 或 Worker 的历史默认值成为归属事实。
|
|
"""
|
|
|
|
def __init__(self, resources: DatabaseResources, *, scope_id: str | ScopeContext = "local", agent_concurrency: int = 1, agent_backpressure: int = 32, settings_version: str | None = None, lease_seconds: int = 120, max_attempts: int = 3, executor: ThreadPoolExecutor | None = None, worker_manager: PostgresTaskWorkerManager | None = None, finalize_image_tasks: bool = True, operation_policy: OperationPolicyGateway | None = None, grant_store: GrantAssociationStore | None = None):
|
|
self.resources = resources
|
|
self.scope = scope_id if isinstance(scope_id, ScopeContext) else ScopeContext(scope_id)
|
|
self.agent_concurrency = max(1, min(int(agent_concurrency), 8))
|
|
self.agent_backpressure = max(1, min(int(agent_backpressure), 500))
|
|
self.settings_version = settings_version
|
|
self.lease_seconds = lease_seconds
|
|
self.max_attempts = max_attempts
|
|
# 图片 Worker 执行叶子任务时关闭旧批次 finalizer,避免再次隐式创建
|
|
# cache_generation;普通兼容 facade 仍保留既有显式批次能力。
|
|
self._finalize_image_tasks = bool(finalize_image_tasks)
|
|
self._handlers: dict[str, Callable[[dict[str, Any], Callable[[float | None, str | None], None]], Any]] = {}
|
|
self._batch_finalizer: Callable[[str], Any] | None = None
|
|
self._worker_manager = worker_manager
|
|
# 只有图片专用 facade 注入该二元组时才启用 Agent grant 复核;普通
|
|
# 兼容任务服务仍可执行历史任务,但不会替客户端伪造计量事实。
|
|
self._operation_policy = operation_policy
|
|
self._grant_store = grant_store
|
|
self._executor = worker_manager.executor if worker_manager is not None else executor or ThreadPoolExecutor(max_workers=max(2, self.agent_concurrency + 1), thread_name_prefix="mememeow-pg-task")
|
|
self._owns_executor = worker_manager is None and executor is None
|
|
self._lock = Lock()
|
|
self._stopped = Event()
|
|
self._scheduled: set[str] = set()
|
|
self.owner = worker_manager.owner if worker_manager is not None else f"worker-{os.getpid()}-{id(self)}"
|
|
|
|
def register(self, task_type: str, handler: Callable[[dict[str, Any], Callable[[float | None, str | None], None]], Any]) -> None:
|
|
"""注册由数据库 payload 重建的同步处理器。"""
|
|
if self._worker_manager is not None:
|
|
self._worker_manager.register(task_type, handler)
|
|
else:
|
|
self._handlers[task_type] = handler
|
|
|
|
def set_batch_finalizer(self, callback: Callable[[str], Any] | None) -> None:
|
|
"""注册批次全部终态后的单次收束回调。"""
|
|
self._batch_finalizer = callback
|
|
|
|
def seal_batch(self, batch_id: str) -> None:
|
|
"""封口批次并在同一数据库事务中持久化唯一缓存任务。"""
|
|
created_task_id: str | None = None
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
environment.tasks.seal_batch(batch_id)
|
|
task = environment.tasks.finalize_batch_with_task(
|
|
batch_id,
|
|
task_type="cache_generation",
|
|
payload={},
|
|
dedupe_key="cache_generation",
|
|
settings_version=self.settings_version,
|
|
max_attempts=self.max_attempts,
|
|
)
|
|
if task is not None:
|
|
created_task_id = task.id
|
|
if created_task_id:
|
|
self._schedule(created_task_id)
|
|
|
|
@staticmethod
|
|
def _dedupe(task_type: str, payload: dict[str, Any]) -> str:
|
|
"""为普通任务和图片阶段任务生成包含来源模式的稳定活动去重键。"""
|
|
mode = str(payload.get("submission_mode") or ("pipeline" if payload.get("job_id") else "legacy"))
|
|
stage = str(payload.get("stage") or {
|
|
"visual_embedding_generation": "visual",
|
|
"meme_context_generation": "agent",
|
|
"text_embedding_generation": "text_embedding",
|
|
}.get(task_type) or "legacy")
|
|
if task_type == "visual_embedding_generation":
|
|
return "visual:{mode}:{stage}:{meme}:{sha}:{model}:{preprocess}:{config}".format(
|
|
mode=mode,
|
|
stage=stage,
|
|
meme=payload.get("meme_id"),
|
|
sha=payload.get("image_sha256"),
|
|
model=payload.get("visual_model"),
|
|
preprocess=payload.get("preprocess_version"),
|
|
config=payload.get("processing_config_hash") or "legacy",
|
|
)
|
|
if task_type == "meme_context_generation":
|
|
return "context:{mode}:{stage}:{meme}:{sha}:{config}:{policy}:r{revision}".format(
|
|
mode=mode,
|
|
stage=stage,
|
|
meme=payload.get("meme_id"),
|
|
sha=payload.get("image_sha256"),
|
|
config=payload.get("processing_config_hash") or payload.get("skill_hash") or payload.get("model"),
|
|
policy=payload.get("reverse_image_policy") or "forbid",
|
|
revision=payload.get("job_revision") or "legacy",
|
|
)
|
|
if task_type == "text_embedding_generation":
|
|
return "text:{mode}:{stage}:{meme}:{sha}:{metadata}:{model}:{config}:r{revision}".format(
|
|
mode=mode,
|
|
stage=stage,
|
|
meme=payload.get("meme_id"),
|
|
sha=payload.get("image_sha256"),
|
|
metadata=payload.get("metadata_hash") or "unknown",
|
|
model=payload.get("embedding_model") or payload.get("model"),
|
|
config=payload.get("processing_config_hash") or "legacy",
|
|
revision=payload.get("job_revision") or "legacy",
|
|
)
|
|
if task_type == "cache_generation":
|
|
return "cache_generation"
|
|
return f"{task_type}:{json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(',', ':'))}"
|
|
|
|
def _context_policy_conflict(self, payload: dict[str, Any], dedupe: str) -> None:
|
|
"""拒绝同一图片活动任务的策略不一致提交,避免静默复用错误权限。"""
|
|
if payload.get("reverse_image_policy") not in {"forbid", "auto"}:
|
|
return
|
|
requested_mode = payload.get("submission_mode") if payload.get("submission_mode") in {"pipeline", "standalone"} else None
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
existing = environment.uow.session.scalar(
|
|
select(Task)
|
|
.where(
|
|
Task.scope_id == self.scope.scope_id,
|
|
Task.task_type == "meme_context_generation",
|
|
Task.submission_mode == requested_mode,
|
|
Task.dedupe_key.like(f"context:%:{payload.get('meme_id')}:{payload.get('image_sha256')}:%"),
|
|
Task.status.in_(('queued', 'running')),
|
|
)
|
|
)
|
|
if existing is not None:
|
|
current = str((existing.payload or {}).get("reverse_image_policy") or "forbid")
|
|
current_config = str((existing.payload or {}).get("processing_config_hash") or (existing.payload or {}).get("skill_hash") or (existing.payload or {}).get("model") or "")
|
|
requested_config = str(payload.get("processing_config_hash") or payload.get("skill_hash") or payload.get("model") or "")
|
|
if current != str(payload.get("reverse_image_policy")) or current_config != requested_config:
|
|
raise RuntimeError("generation_policy_conflict")
|
|
|
|
@staticmethod
|
|
def _assert_context_policy(existing: Task, requested: dict[str, Any]) -> None:
|
|
"""在任务 repository 复用活动任务后再次核对策略,覆盖预检与插入之间的竞态。"""
|
|
current = str((existing.payload or {}).get("reverse_image_policy") or "forbid")
|
|
wanted = str(requested.get("reverse_image_policy") or "forbid")
|
|
current_config = str((existing.payload or {}).get("processing_config_hash") or (existing.payload or {}).get("skill_hash") or (existing.payload or {}).get("model") or "")
|
|
wanted_config = str(requested.get("processing_config_hash") or requested.get("skill_hash") or requested.get("model") or "")
|
|
if current != wanted or current_config != wanted_config:
|
|
raise RuntimeError("generation_policy_conflict")
|
|
|
|
def start(self) -> None:
|
|
"""启动数据库任务恢复调度,包括队列和过期租约。"""
|
|
if self._worker_manager is not None:
|
|
self._worker_manager.start()
|
|
return
|
|
owned_types = None if self._finalize_image_tasks else IMAGE_PROCESSING_TASK_TYPES
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
queued = environment.tasks.recover_expired(
|
|
owner=self.owner,
|
|
limit=5000,
|
|
exclude_task_types=IMAGE_PROCESSING_TASK_TYPES if owned_types is None else None,
|
|
include_task_types=owned_types,
|
|
)
|
|
# 普通 facade 需要恢复旧批次 finalizer;图片专用 facade 禁止重新
|
|
# 创建 scope 级 cache_generation。
|
|
pending_batches = environment.tasks.pending_finalizer_batches(limit=5000) if self._finalize_image_tasks else []
|
|
cursor = None
|
|
while True:
|
|
records, cursor = environment.tasks.list(statuses={"queued"}, cursor=cursor, limit=100)
|
|
queued.extend(
|
|
record.id
|
|
for record in records
|
|
if owned_types is None or record.task_type in owned_types
|
|
)
|
|
if cursor is None:
|
|
break
|
|
for batch_id in pending_batches:
|
|
task = environment.tasks.finalize_batch_with_task(
|
|
batch_id,
|
|
task_type="cache_generation",
|
|
payload={},
|
|
dedupe_key="cache_generation",
|
|
settings_version=self.settings_version,
|
|
max_attempts=self.max_attempts,
|
|
)
|
|
if task is not None:
|
|
queued.append(task.id)
|
|
for task_id in dict.fromkeys(queued):
|
|
self._schedule(task_id)
|
|
|
|
def _record_to_dataclass(self, record: Any, *, slot_id: int | None = None) -> TaskRecord:
|
|
"""将 ORM 任务转换为 API/旧领域共用的安全快照。"""
|
|
return TaskRecord(
|
|
task_id=record.id,
|
|
task_type=record.task_type,
|
|
submission_mode=getattr(record, "submission_mode", None),
|
|
image_stage=getattr(record, "image_stage", None),
|
|
processing_job_id=str(getattr(record, "processing_job_id", "")) if getattr(record, "processing_job_id", None) else None,
|
|
payload=dict(record.payload or {}),
|
|
status=record.status,
|
|
progress=record.progress,
|
|
message=record.message,
|
|
created_at=_iso(record.created_at),
|
|
updated_at=_iso(record.updated_at),
|
|
completed_at=_iso(record.completed_at) if record.completed_at else None,
|
|
attempts=record.attempt_count,
|
|
error=record.error,
|
|
result=record.result,
|
|
settings_version=record.settings_version,
|
|
agent_concurrency=self.agent_concurrency if record.lane == "agent" else None,
|
|
slot_id=slot_id,
|
|
scope_id=record.scope_id,
|
|
)
|
|
|
|
def _image_attempt_state(self, claim: Task, payload: dict[str, Any], state: str) -> None:
|
|
"""保存图片叶子当前 claim 的 attempt 状态,供重启恢复辨认未知执行。"""
|
|
if claim.task_type not in IMAGE_PROCESSING_TASK_TYPES:
|
|
return
|
|
mode = payload.get("submission_mode")
|
|
if mode == "pipeline" and not isinstance(payload.get("job_id"), str):
|
|
return
|
|
if mode not in {None, "pipeline", "standalone"}:
|
|
return
|
|
target_sha = payload.get("image_sha256")
|
|
if not isinstance(target_sha, str) or len(target_sha) != 64:
|
|
return
|
|
stable_payload = {key: value for key, value in payload.items() if not key.startswith("_claim_")}
|
|
input_digest = hashlib.sha256(json.dumps(stable_payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
now = utcnow()
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
session = environment.uow.session
|
|
row = session.scalar(
|
|
select(ImageProcessingAttempt).where(
|
|
ImageProcessingAttempt.scope_id == self.scope.scope_id,
|
|
ImageProcessingAttempt.task_id == claim.id,
|
|
ImageProcessingAttempt.attempt == claim.attempt_count,
|
|
).with_for_update()
|
|
)
|
|
if row is None:
|
|
row = ImageProcessingAttempt(
|
|
scope_id=self.scope.scope_id,
|
|
task_id=claim.id,
|
|
attempt=claim.attempt_count,
|
|
attempt_id=uuid4().hex,
|
|
stage=str(payload.get("stage") or claim.task_type),
|
|
state=state,
|
|
request_id=str(payload.get("request_id")) if payload.get("request_id") else None,
|
|
session_id=str(payload.get("session_id")) if payload.get("session_id") else None,
|
|
input_digest=input_digest,
|
|
target_sha256=target_sha,
|
|
claim_generation=claim.claim_generation,
|
|
)
|
|
session.add(row)
|
|
else:
|
|
# 旧 Worker 不能把新 claim 的 attempt 状态覆盖回去。
|
|
if row.claim_generation != claim.claim_generation:
|
|
return
|
|
row.state = state
|
|
row.updated_at = now
|
|
session.commit()
|
|
|
|
def _image_attempt_requires_unknown(self, claim: Task) -> bool:
|
|
"""判断新 claim 前是否存在无法证明已完成的图片外部 attempt。"""
|
|
if claim.task_type not in IMAGE_PROCESSING_TASK_TYPES or claim.attempt_count <= 1:
|
|
return False
|
|
with self.resources.factory() as session:
|
|
previous = session.scalar(
|
|
select(ImageProcessingAttempt)
|
|
.where(
|
|
ImageProcessingAttempt.scope_id == self.scope.scope_id,
|
|
ImageProcessingAttempt.task_id == claim.id,
|
|
ImageProcessingAttempt.attempt < claim.attempt_count,
|
|
)
|
|
.order_by(ImageProcessingAttempt.attempt.desc())
|
|
)
|
|
return previous is not None and previous.state in {"grant_committed", "external_started", "completed"}
|
|
|
|
def _commit_agent_grant(self, claim: Task, payload: dict[str, Any]) -> None:
|
|
"""在 Agent 外部执行前幂等提交服务端 grant。"""
|
|
if claim.task_type != "meme_context_generation" or self._operation_policy is None or self._grant_store is None:
|
|
return
|
|
meme_id = payload.get("meme_id")
|
|
image_sha256 = payload.get("image_sha256")
|
|
config_hash = payload.get("processing_config_hash")
|
|
revision = payload.get("job_revision")
|
|
policy = payload.get("reverse_image_policy") or "forbid"
|
|
if not all(isinstance(value, str) and value for value in (meme_id, image_sha256, config_hash)):
|
|
raise OperationPolicyError("operation_grant_invalid")
|
|
mode = payload.get("submission_mode")
|
|
if mode == "standalone":
|
|
logical_key = payload.get("agent_grant_key")
|
|
if not isinstance(logical_key, str) or not logical_key.startswith("standalone-agent:"):
|
|
raise OperationPolicyError("operation_grant_invalid")
|
|
source = "image-processing-standalone"
|
|
else:
|
|
logical_key = f"agent:{meme_id}:{image_sha256}:{config_hash}:{policy}:r{revision}"
|
|
source = "image-processing"
|
|
request = self._operation_policy.request(self.scope, Operations.ANALYSIS_AGENT, logical_key, resource_id=meme_id, task_id=claim.id, source=source)
|
|
association = self._grant_store.get(request)
|
|
if association is None or association.grant.scope != self.scope or association.grant.operation != Operations.ANALYSIS_AGENT:
|
|
raise OperationPolicyError("operation_grant_invalid")
|
|
if association.state == "committed":
|
|
return
|
|
if association.state != "acquired":
|
|
raise OperationPolicyError("operation_grant_invalid")
|
|
try:
|
|
result = self._operation_policy.commit(association.grant)
|
|
except OperationPolicyError:
|
|
# policy 返回异常时无法证明计量是否已经生效;保留 unknown,后续
|
|
# claim 只能收束,不能通过重试再次触发不确定的计量边界。
|
|
self._grant_store.transition(association.grant, "unknown")
|
|
raise
|
|
if not result.ok or result.state not in {"committed", "already_committed"}:
|
|
self._grant_store.transition(association.grant, "unknown")
|
|
raise OperationPolicyError("operation_policy_unavailable", retry_at=result.retry_at)
|
|
if not self._grant_store.transition(association.grant, "committed"):
|
|
self._grant_store.transition(association.grant, "unknown")
|
|
raise OperationPolicyError("operation_grant_invalid")
|
|
|
|
def submit(self, task_type: str, payload: dict[str, Any] | None = None, *, schedule: bool = True) -> TaskRecord:
|
|
"""以事务插入或复用活动任务,并立即安排本进程执行。
|
|
|
|
图片阶段来源由当前受信控制面 payload 规范化后落入专用列;客户端不能
|
|
通过额外 scope、Job、grant 或 claim 字段改变这些事实。
|
|
"""
|
|
payload = dict(payload or {})
|
|
# scope/user 只能由 resolver 或 Task.scope_id 提供;即使调用方伪造字段,
|
|
# 也不得让它们进入后续 handler 作为授权事实。
|
|
payload.pop("scope_id", None)
|
|
payload.pop("user_id", None)
|
|
lane = "agent" if task_type == "meme_context_generation" else "default"
|
|
image_stage = None
|
|
submission_mode = None
|
|
processing_job_id = None
|
|
if task_type in IMAGE_PROCESSING_TASK_TYPES:
|
|
stage_by_type = {
|
|
"visual_embedding_generation": "visual",
|
|
"meme_context_generation": "agent",
|
|
"text_embedding_generation": "text_embedding",
|
|
}
|
|
expected_stage = stage_by_type[task_type]
|
|
requested_stage = payload.get("stage")
|
|
if requested_stage is not None and requested_stage != expected_stage:
|
|
raise RuntimeError("image_stage_mismatch")
|
|
requested_mode = payload.get("submission_mode")
|
|
if requested_mode not in {"pipeline", "standalone"}:
|
|
# 没有新来源字段的旧记录仍可被读取/执行,查询时会显示为未归类;
|
|
# 新控制面入口始终显式传入 mode。
|
|
requested_mode = None
|
|
submission_mode = requested_mode
|
|
if submission_mode == "pipeline":
|
|
raw_job_id = payload.get("job_id")
|
|
if not isinstance(raw_job_id, str) or not raw_job_id:
|
|
raise RuntimeError("image_processing_job_required")
|
|
processing_job_id = raw_job_id
|
|
elif submission_mode == "standalone":
|
|
if payload.get("job_id") is not None:
|
|
raise RuntimeError("image_task_job_conflict")
|
|
elif payload.get("job_id") is not None:
|
|
# 旧 job 叶子在来源迁移前仍按 pipeline 处理,避免丢失父 Job
|
|
# 关联;该分支只接受服务端已有的 Job UUID。
|
|
submission_mode = "pipeline"
|
|
processing_job_id = payload.get("job_id")
|
|
# 没有阶段、Job 或来源字段的旧 facade 调用属于迁移前任务。保留
|
|
# NULL image_stage 使它可以继续完成既有业务;迁移脚本对能够
|
|
# 可靠识别阶段的历史任务会补写 image_stage,专用 Worker 随后
|
|
# 将那类未归类任务收束为只读诊断。
|
|
explicit_source = requested_stage is not None or requested_mode is not None or processing_job_id is not None
|
|
if explicit_source:
|
|
image_stage = expected_stage
|
|
payload["stage"] = expected_stage
|
|
if submission_mode is not None:
|
|
payload["submission_mode"] = submission_mode
|
|
dedupe = self._dedupe(task_type, payload)
|
|
if task_type == "meme_context_generation":
|
|
self._context_policy_conflict(payload, dedupe)
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
try:
|
|
record = environment.tasks.submit(task_type=task_type, payload=payload, lane=lane, dedupe_key=dedupe, settings_version=self.settings_version, max_attempts=self.max_attempts, lane_backpressure=self.agent_backpressure if lane == "agent" else None, submission_mode=submission_mode, image_stage=image_stage, processing_job_id=processing_job_id)
|
|
except DatabaseError as exc:
|
|
if exc.code == "agent_backpressure":
|
|
existing = environment.uow.session.scalar(select(Task).where(Task.scope_id == self.scope.scope_id, Task.task_type == task_type, Task.dedupe_key == dedupe, Task.status.in_(("queued", "running"))))
|
|
if existing:
|
|
record = existing
|
|
else:
|
|
raise RuntimeError("agent_backpressure") from exc
|
|
else:
|
|
raise
|
|
if task_type == "meme_context_generation":
|
|
self._assert_context_policy(record, payload)
|
|
if task_type in {"meme_context_generation", "visual_embedding_generation"} and isinstance(payload.get("batch_id"), str):
|
|
environment.tasks.add_batch_item(payload["batch_id"], record.id)
|
|
snapshot = self._record_to_dataclass(record)
|
|
if schedule:
|
|
self._schedule(snapshot.task_id)
|
|
return snapshot
|
|
|
|
def retry(self, task_id: str) -> TaskRecord:
|
|
"""重试一个普通失败任务;图片阶段必须通过受限图片入口重试。"""
|
|
record = self.get(task_id)
|
|
if record is None:
|
|
raise RuntimeError("task_not_found")
|
|
if record.task_type in IMAGE_PROCESSING_TASK_TYPES:
|
|
raise RuntimeError("image_stage_retry_forbidden")
|
|
if record.status != "failed":
|
|
raise RuntimeError("task_not_failed")
|
|
payload = {key: value for key, value in record.payload.items() if not key.startswith("_claim_")}
|
|
# 失败任务的同一 dedupe key 已不属于活动集合,submit 会创建新的可轮询尝试。
|
|
return self.submit(record.task_type, payload)
|
|
|
|
def schedule(self, task_id: str) -> None:
|
|
"""显式唤醒一个已提交任务;批量提交时用于关闭入批竞态窗口。"""
|
|
self._schedule(task_id)
|
|
|
|
def _schedule(self, task_id: str) -> None:
|
|
"""避免同一进程重复调度同一个数据库任务。"""
|
|
if self._worker_manager is not None:
|
|
self._worker_manager.schedule(task_id)
|
|
return
|
|
with self._lock:
|
|
if self._stopped.is_set() or task_id in self._scheduled:
|
|
return
|
|
self._scheduled.add(task_id)
|
|
self._executor.submit(self._run, task_id)
|
|
|
|
def _run(self, task_id: str, *, preclaimed: Task | None = None) -> None:
|
|
"""执行已认领任务并以 claim generation fencing 写回终态。"""
|
|
claim = preclaimed
|
|
try:
|
|
if claim is None:
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
queued_record = environment.tasks.get(task_id)
|
|
if queued_record is None:
|
|
return
|
|
lane = queued_record.lane
|
|
claim = environment.tasks.claim(
|
|
owner=self.owner,
|
|
lease_seconds=self.lease_seconds,
|
|
task_id=task_id,
|
|
lane=lane,
|
|
lane_capacity=self.agent_concurrency if lane == "agent" else None,
|
|
# 专用 facade 必须能恢复自己的过期图片叶子;通用 manager
|
|
# 在更早的 ``_claim_for_task`` 边界排除这些类型。
|
|
exclude_task_types=None,
|
|
)
|
|
if claim is None or claim.id != task_id:
|
|
return
|
|
task_payload = dict(claim.payload or {})
|
|
generation = claim.claim_generation
|
|
task_payload["_claim_task_id"] = claim.id
|
|
task_payload["_claim_generation"] = generation
|
|
task_payload["_claim_owner"] = self.owner
|
|
task_payload["_claim_attempt"] = claim.attempt_count
|
|
try:
|
|
claim_scope = ScopeContext(claim.scope_id)
|
|
except (TypeError, ValueError) as exc:
|
|
# 无效持久 scope 不能猜测为 local;以稳定错误进入 fencing 收束。
|
|
environment.tasks.fail_fenced(task_id, generation, self.owner, message="任务 scope 无效", error={"error": "task_scope_invalid", "message": "任务缺少有效 scope"}, retry=False)
|
|
raise RuntimeError("task_scope_invalid") from exc
|
|
if claim_scope.scope_id != self.scope.scope_id:
|
|
environment.tasks.fail_fenced(task_id, generation, self.owner, message="任务 scope 与 Worker 不一致", error={"error": "task_scope_mismatch", "message": "任务 scope 与当前执行环境不一致"}, retry=False)
|
|
raise RuntimeError("task_scope_mismatch")
|
|
task_payload["_claim_scope_id"] = claim_scope.scope_id
|
|
else:
|
|
if claim.id != task_id or claim.scope_id != self.scope.scope_id:
|
|
return
|
|
task_payload = dict(claim.payload or {})
|
|
generation = claim.claim_generation
|
|
task_payload["_claim_task_id"] = claim.id
|
|
task_payload["_claim_generation"] = generation
|
|
task_payload["_claim_owner"] = self.owner
|
|
task_payload["_claim_attempt"] = claim.attempt_count
|
|
try:
|
|
claim_scope = ScopeContext(claim.scope_id)
|
|
except (TypeError, ValueError):
|
|
return
|
|
if claim_scope.scope_id != self.scope.scope_id:
|
|
return
|
|
task_payload["_claim_scope_id"] = claim_scope.scope_id
|
|
handler = self._worker_manager.handler(claim.task_type) if self._worker_manager is not None else self._handlers.get(claim.task_type)
|
|
if handler is None:
|
|
self._fenced_failure(task_id, generation, message="任务处理器不可用", error={"error": "task_handler_missing", "message": "当前服务未注册此任务类型"}, retry=False)
|
|
return
|
|
|
|
if claim.task_type in IMAGE_PROCESSING_TASK_TYPES and claim.submission_mode not in {"pipeline", "standalone"} and claim.image_stage is not None:
|
|
# 无法可靠归类的历史图片 Task 只允许查询诊断,不能在启动恢复时
|
|
# 被旧 Worker 重新执行或通过异常路径产生下游阶段。没有显式
|
|
# 阶段列的迁移前兼容任务仍由原任务 facade 完成。
|
|
self._fenced_failure(
|
|
task_id,
|
|
generation,
|
|
message="历史图片任务未归类,只读展示",
|
|
error={"error": "image_task_unclassified", "message": "历史图片阶段缺少可信提交来源"},
|
|
retry=False,
|
|
)
|
|
return
|
|
|
|
if self._image_attempt_requires_unknown(claim):
|
|
self._image_attempt_state(claim, task_payload, "unknown_execution")
|
|
self._fenced_failure(
|
|
task_id,
|
|
generation,
|
|
message="外部执行结果无法确认",
|
|
error={"error": "unknown_execution", "message": "上一次图片阶段已进入外部执行窗口,无法安全重放"},
|
|
retry=False,
|
|
)
|
|
return
|
|
self._image_attempt_state(claim, task_payload, "prepared")
|
|
|
|
def progress(value: float | None, message: str | None = None) -> None:
|
|
self._fenced_update(task_id, generation, progress=value, message=message)
|
|
|
|
heartbeat_stop = Event()
|
|
def heartbeat() -> None:
|
|
"""定期续租当前 claim,停止后退出后台线程。"""
|
|
while not heartbeat_stop.wait(max(1, self.lease_seconds // 3)):
|
|
with self.resources.environment(self.scope.scope_id) as heartbeat_env:
|
|
if not heartbeat_env.tasks.heartbeat(task_id, generation, self.owner, self.lease_seconds):
|
|
return
|
|
heartbeat_thread = threading.Thread(target=heartbeat, name=f"mememeow-heartbeat-{task_id}", daemon=True)
|
|
heartbeat_thread.start()
|
|
try:
|
|
if claim.task_type in IMAGE_PROCESSING_TASK_TYPES:
|
|
# 图片阶段均可能触发外部模型或持久副作用;Agent 先完成
|
|
# grant commit,再进入外部执行窗口,恢复者才能区分计量边界。
|
|
self._commit_agent_grant(claim, task_payload)
|
|
if claim.task_type == "meme_context_generation":
|
|
self._image_attempt_state(claim, task_payload, "grant_committed")
|
|
# 恢复者无法证明结果时必须收束 unknown_execution。
|
|
self._image_attempt_state(claim, task_payload, "external_started")
|
|
result = handler(task_payload, progress)
|
|
except Exception as exc: # noqa: BLE001
|
|
if isinstance(exc, OperationPolicyError):
|
|
code = exc.code
|
|
diagnostic = code
|
|
else:
|
|
diagnostic = str(exc)[:500]
|
|
code = diagnostic.partition(":")[0] if diagnostic.partition(":")[0] in STABLE_TASK_ERRORS else "task_failed"
|
|
self._image_attempt_state(claim, task_payload, "unknown_execution" if code in {"unknown_execution", "reverse_image_unknown_execution"} else "failed")
|
|
retry = code not in {
|
|
"target_changed",
|
|
"agent_output_schema_invalid",
|
|
"agent_output_invalid_json",
|
|
"agent_result_file_missing",
|
|
"agent_result_file_unreadable",
|
|
"agent_result_file_too_large",
|
|
"agent_result_file_invalid_json",
|
|
"agent_result_file_schema_invalid",
|
|
"agent_image_path_forbidden",
|
|
"agent_input_provider_unavailable",
|
|
"agent_result_path_invalid",
|
|
"task_handler_missing",
|
|
"opencode_not_configured",
|
|
"agent_runtime_unavailable",
|
|
"agent_image_root_mismatch",
|
|
"reverse_image_forbidden",
|
|
"invalid_reverse_image_policy",
|
|
"usage_request_conflict",
|
|
"visual_model_not_configured",
|
|
"visual_model_migration_required",
|
|
"visual_model_identity_invalid",
|
|
"visual_weights_checksum_mismatch",
|
|
"visual_embedding_dimensions_mismatch",
|
|
"visual_embedding_non_finite",
|
|
"visual_embedding_zero_norm",
|
|
"visual_image_decode_failed",
|
|
"visual_model_identity_mismatch",
|
|
"visual_service_invalid_response",
|
|
"visual_embedding_invalid",
|
|
"visual_embedding_sha256_invalid",
|
|
"visual_embedding_sha256_mismatch",
|
|
"embedding_not_configured",
|
|
"embedding_dimensions_mismatch",
|
|
"embedding_non_finite",
|
|
"embedding_zero_norm",
|
|
"query_embedding_not_ready",
|
|
"invalid_task",
|
|
"task_not_running",
|
|
"task_scope_invalid",
|
|
"task_scope_mismatch",
|
|
"unknown_execution",
|
|
"reverse_image_unknown_execution",
|
|
"operation_forbidden",
|
|
"operation_limit_exceeded",
|
|
"operation_policy_unavailable",
|
|
"operation_grant_invalid",
|
|
"blocked",
|
|
}
|
|
audit_result = self._with_reverse_image_audit(task_id, None, write_provenance=False)
|
|
self._fenced_failure(task_id, generation, message="任务执行失败", error={"error": code, "message": diagnostic}, retry=retry, result=audit_result)
|
|
else:
|
|
# 只有当前 claim 仍有效时才写入任务终态和 Meme provenance。
|
|
self._image_attempt_state(claim, task_payload, "completed")
|
|
audit_result = self._with_reverse_image_audit(task_id, result, write_provenance=False)
|
|
self._fenced_success(task_id, generation, audit_result)
|
|
finally:
|
|
heartbeat_stop.set()
|
|
self._maybe_finalize(task_id)
|
|
finally:
|
|
if self._worker_manager is not None:
|
|
self._worker_manager._task_finished(task_id, claimed=claim is not None)
|
|
else:
|
|
with self._lock:
|
|
self._scheduled.discard(task_id)
|
|
if claim is not None and not self._stopped.is_set():
|
|
self._schedule_queued()
|
|
|
|
def _schedule_queued(self) -> None:
|
|
"""在槽位释放后唤醒数据库中的排队任务,避免 lane 满载时忙循环。"""
|
|
if self._worker_manager is not None:
|
|
self._worker_manager._schedule_queued()
|
|
return
|
|
if self._stopped.is_set():
|
|
return
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
records, _ = environment.tasks.list(statuses={"queued"}, limit=100)
|
|
for record in records:
|
|
if self._finalize_image_tasks or record.task_type in IMAGE_PROCESSING_TASK_TYPES:
|
|
self._schedule(record.id)
|
|
|
|
def _fenced_update(self, task_id: str, generation: int, **changes: Any) -> bool:
|
|
"""在一个短事务中验证 owner/generation/租约后更新任务。"""
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
return environment.tasks.update_fenced(task_id, generation, self.owner, **changes)
|
|
|
|
def _fenced_success(self, task_id: str, generation: int, result: Any) -> bool:
|
|
"""以 claim fencing 原子提交成功结果和图片 Agent provenance。"""
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
complete = getattr(environment.tasks, "complete_fenced_with_provenance", None)
|
|
if callable(complete):
|
|
return bool(complete(task_id, generation, self.owner, result=result))
|
|
# 兼容尚未提供原子扩展的宿主 repository;标准 PostgreSQL
|
|
# repository 始终走上面的单事务路径。
|
|
changed = environment.tasks.update_fenced(
|
|
task_id,
|
|
generation,
|
|
self.owner,
|
|
status="succeeded",
|
|
progress=1.0,
|
|
message="任务完成",
|
|
result=result,
|
|
)
|
|
if changed:
|
|
self._write_reverse_image_provenance(task_id, generation)
|
|
return changed
|
|
|
|
def _fenced_failure(self, task_id: str, generation: int, *, message: str, error: dict[str, Any], retry: bool, result: Any | None = None) -> bool:
|
|
"""按最大尝试次数将当前 claim 重新排队或置为失败。"""
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
changed, should_retry = environment.tasks.fail_fenced(task_id, generation, self.owner, error=error, message=message, retry=retry, result=result)
|
|
if changed and should_retry:
|
|
self._schedule(task_id)
|
|
return changed
|
|
|
|
def _with_reverse_image_audit(self, task_id: str, result: Any, *, write_provenance: bool = False) -> dict[str, Any]:
|
|
"""按任务事件生成终态审计摘要,默认不修改 Meme provenance。"""
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
task = environment.tasks.get(task_id)
|
|
policy = str((task.payload or {}).get("reverse_image_policy") or "forbid") if task else "forbid"
|
|
audit = environment.reverse_image_usage.aggregate_task(task_id)
|
|
payload = dict(result) if isinstance(result, dict) else {}
|
|
payload["reverse_image"] = {"policy": policy, **audit}
|
|
if write_provenance:
|
|
meme_id = (task.payload or {}).get("meme_id") if task else None
|
|
if meme_id:
|
|
self._write_reverse_image_provenance(task_id, None)
|
|
return payload
|
|
|
|
def _write_reverse_image_provenance(self, task_id: str, claim_generation: int | None) -> None:
|
|
"""仅在成功 claim 的收束阶段写回 Meme 反向图片审计,避免旧 Worker 覆盖新结果。"""
|
|
try:
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
task = environment.tasks.get(task_id)
|
|
if task is None or task.status != "succeeded":
|
|
return
|
|
if claim_generation is not None and task.claim_generation != claim_generation:
|
|
return
|
|
audit = environment.reverse_image_usage.aggregate_task(task_id)
|
|
meme_id = (task.payload or {}).get("meme_id")
|
|
if not meme_id:
|
|
return
|
|
meme = environment.memes.get(meme_id, for_update=True)
|
|
if meme is None:
|
|
return
|
|
provenance = dict(meme.provenance or {})
|
|
provenance["reverse_image"] = {"policy": str((task.payload or {}).get("reverse_image_policy") or "forbid"), **audit}
|
|
meme.provenance = provenance
|
|
environment.uow.session.flush()
|
|
except Exception:
|
|
# 审计 provenance 是可重建的附属写回,不能让已成功的任务线程崩溃。
|
|
return
|
|
|
|
def get(self, task_id: str) -> TaskRecord | None:
|
|
"""读取当前 scope 任务快照。"""
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
record = environment.tasks.get(task_id)
|
|
if record is None:
|
|
return None
|
|
slot = environment.tasks.slot_for_task(record.id)
|
|
return self._record_to_dataclass(record, slot_id=slot.slot_number if slot else None)
|
|
|
|
def find_active(self, task_type: str, dedupe_key: str) -> TaskRecord | None:
|
|
"""按当前 scope、类型和活动去重键读取叶子 Task。
|
|
|
|
图片 Worker 在取得 Agent grant 前调用此方法,避免把已有活动任务误判为
|
|
新的计量请求;查询结果只是提示,真正提交仍由 TaskRepository 的唯一键兜底。
|
|
"""
|
|
if not isinstance(task_type, str) or not task_type or not isinstance(dedupe_key, str) or not dedupe_key:
|
|
return None
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
record = environment.uow.session.scalar(
|
|
select(Task)
|
|
.where(
|
|
Task.scope_id == self.scope.scope_id,
|
|
Task.task_type == task_type,
|
|
Task.dedupe_key == dedupe_key,
|
|
Task.status.in_(("queued", "running")),
|
|
)
|
|
.order_by(Task.created_at.asc(), Task.id.asc())
|
|
)
|
|
if record is None:
|
|
return None
|
|
slot = environment.tasks.slot_for_task(record.id)
|
|
return self._record_to_dataclass(record, slot_id=slot.slot_number if slot else None)
|
|
|
|
def cancel(self, task_id: str) -> bool:
|
|
"""取消单个任务并仅终止其 Agent session,不停止共享容器。"""
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
record = environment.tasks.get(task_id, for_update=True)
|
|
if record is None:
|
|
return False
|
|
changed = environment.tasks.cancel(task_id, error={"error": "task_cancelled", "message": "任务已取消"}, message="任务已取消")
|
|
if changed:
|
|
handler = self._handlers.get(record.task_type)
|
|
_ = handler # 仅保留任务类型快照,实际 session 清理由运行器按 task_id 完成。
|
|
return changed
|
|
|
|
def list(self, *, statuses: set[str] | None = None, task_types: set[str] | None = None, cursor: str | None = None, limit: int = 50) -> tuple[list[TaskRecord], str | None]:
|
|
"""分页列出当前 scope 任务,返回兼容 TaskRecord 的安全快照。"""
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
records, next_cursor = environment.tasks.list(statuses=statuses, task_types=task_types, cursor=cursor, limit=limit)
|
|
result = []
|
|
for record in records:
|
|
slot = environment.tasks.slot_for_task(record.id)
|
|
result.append(self._record_to_dataclass(record, slot_id=slot.slot_number if slot else None))
|
|
return result, next_cursor
|
|
|
|
def _maybe_finalize(self, task_id: str) -> None:
|
|
"""批次成员全部终态后在数据库中只提交一次 finalizer 标记。"""
|
|
record = self.get(task_id)
|
|
if not record or record.task_type not in {"meme_context_generation", "visual_embedding_generation"}:
|
|
return
|
|
if not self._finalize_image_tasks:
|
|
return
|
|
# 批量接口可能复用上传时已存在的活动去重任务;此时 payload 没有
|
|
# batch_id,必须以数据库关联表为准,避免 finalizer 永久遗漏。
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
values = environment.tasks.batch_ids_for_task(task_id)
|
|
batch_id = record.payload.get("batch_id")
|
|
batch_ids = record.payload.get("batch_ids")
|
|
if isinstance(batch_id, str) and batch_id:
|
|
values.append(batch_id)
|
|
if isinstance(batch_ids, list):
|
|
values.extend(item for item in batch_ids if isinstance(item, str) and item)
|
|
values = list(dict.fromkeys(values))
|
|
if not values:
|
|
return
|
|
for current_batch_id in values:
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
task = environment.tasks.finalize_batch_with_task(
|
|
current_batch_id,
|
|
task_type="cache_generation",
|
|
payload={},
|
|
dedupe_key="cache_generation",
|
|
settings_version=self.settings_version,
|
|
max_attempts=self.max_attempts,
|
|
)
|
|
created_task_id = task.id if task is not None else None
|
|
if created_task_id:
|
|
self._schedule(created_task_id)
|
|
if task is None or not self._batch_finalizer:
|
|
continue
|
|
try:
|
|
self._batch_finalizer(current_batch_id)
|
|
except Exception:
|
|
pass
|
|
|
|
def shutdown(self) -> None:
|
|
"""停止新认领并将本 Worker 仍持有的任务标记为可诊断中断。"""
|
|
if self._worker_manager is not None:
|
|
return
|
|
self._stopped.set()
|
|
with self.resources.environment(self.scope.scope_id) as environment:
|
|
environment.tasks.interrupt_owner(self.owner)
|
|
if self._owns_executor:
|
|
self._executor.shutdown(wait=False, cancel_futures=True)
|