feat(plugins): consolidate official plugins with independent releases (LANG-180) (#2797)

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
RockChinQ
2026-10-28 17:32:24 +08:00
committed by GitHub
co-authored by multica-agent
parent d099033d79
commit 3a77306c3c
1144 changed files with 142049 additions and 130 deletions
@@ -0,0 +1,136 @@
apiVersion: v1
kind: KnowledgeEngine
metadata:
name: dify-connector
label:
en_US: Dify Knowledge Base
zh_Hans: Dify 知识库
ja_JP: Dify 知識ベース
description:
en_US: Retrieve knowledge from or store files into Dify knowledge bases
zh_Hans: 从 Dify 知识库中检索知识或向其中存入文件
ja_JP: Dify 知識ベースから知識を検索または保存
spec:
creation_schema:
- name: api_base_url
label:
en_US: API Base URL
zh_Hans: API 基础地址
description:
en_US: 'Base URL for Dify API (e.g., https://api.dify.ai/v1 for cloud, or your self-hosted URL)'
zh_Hans: 'Dify API 基础地址(云端版本使用 https://api.dify.ai/v1,自托管版本使用您的服务器地址)'
ja_JP: 'Dify API ベース URL(クラウド版は https://api.dify.ai/v1、セルフホストの場合はサーバーの URL)'
type: string
default: https://api.dify.ai/v1
required: true
- name: dify_apikey
label:
en_US: API Key
zh_Hans: API 密钥
description:
en_US: 'API key from your Dify instance'
zh_Hans: '从您的 Dify 实例获取的 API Key'
ja_JP: 'Dify インスタンスから取得した API Key'
type: password
required: true
- name: dataset_id
label:
en_US: Dataset ID
zh_Hans: 数据集 ID
ja_JP: データセット ID
description:
en_US: 'The UUID of the Dify Dataset to retrieve from'
zh_Hans: '要检索的 Dify 数据集 UUID'
ja_JP: 'Dify ナレッジベース/データセットの ID'
type: string
required: true
retrieval_schema:
- name: search_method
label:
en_US: Search Method
zh_Hans: 检索模式
ja_JP: 検索方法
description:
en_US: 'The search method to use'
zh_Hans: '使用的搜索方法'
ja_JP: '使用する検索方法'
type: select
default: semantic_search
options:
- name: keyword_search
label:
en_US: Keyword Search
zh_Hans: 关键词检索
ja_JP: キーワード検索
- name: semantic_search
label:
en_US: Semantic Search
zh_Hans: 语义检索
ja_JP: セマンティック検索
- name: full_text_search
label:
en_US: Full Text Search
zh_Hans: 全文检索
ja_JP: 全文検索
- name: hybrid_search
label:
en_US: Hybrid Search
zh_Hans: 混合检索
ja_JP: ハイブリッド検索
required: false
- name: top_k
label:
en_US: Top K
zh_Hans: 返回数量
ja_JP: 取得件数
description:
en_US: 'Maximum number of results to return'
zh_Hans: '返回的最大结果数量'
ja_JP: '返す結果の最大数'
type: integer
default: 5
required: false
- name: score_threshold_enabled
label:
en_US: Enable Score Threshold
zh_Hans: 启用分数阈值
ja_JP: スコア閾値を有効化
description:
en_US: 'Filter results below the minimum score'
zh_Hans: '过滤低于最低分数的结果'
ja_JP: '最小スコア以下の結果をフィルタリング'
type: boolean
default: false
required: false
- name: score_threshold
label:
en_US: Score Threshold
zh_Hans: 分数阈值
ja_JP: スコア閾値
description:
en_US: 'Minimum relevance score (0-1)'
zh_Hans: '最小相关性分数 (0-1)'
ja_JP: '最小関連性スコア (0-1)'
type: float
default: 0.5
required: false
show_if:
field: score_threshold_enabled
operator: eq
value: true
- name: reranking_enable
label:
en_US: Enable Reranking
zh_Hans: 启用重排序
ja_JP: リランキングを有効化
description:
en_US: 'Enable reranking using the model configured in your Dify dataset settings (configure it in Dify console first)'
zh_Hans: '启用重排序,将自动使用 Dify 数据集中配置的重排序模型(请先在 Dify 控制台配置)'
ja_JP: 'Dify データセット設定で構成されたリランキングモデルを使用(先に Dify コンソールで設定してください)'
type: boolean
default: false
required: false
execution:
python:
path: engine.py
attr: DifyDatasetsConnector
@@ -0,0 +1,373 @@
from __future__ import annotations
import json
import logging
from urllib.parse import urlsplit, urlunsplit
from components.shared_state import (
AmbiguousMutationError,
ConfigStore,
serialized,
)
import httpx
from langbot_plugin.api.definition.components.knowledge_engine import KnowledgeEngine, KnowledgeEngineCapability
from langbot_plugin.api.entities.builtin.rag import (
IngestionContext,
IngestionResult,
DocumentStatus,
RetrievalContext,
RetrievalResultEntry,
RetrievalResponse,
)
from langbot_plugin.api.entities.builtin.provider.message import ContentElement
logger = logging.getLogger(__name__)
def _normalize_api_base_url(url: str) -> str:
"""Canonical provider target: ``scheme://host[:port]/path``, no trailing slash.
Used to bind a stored document mapping to the upstream instance that created
it, so a later configuration change is detected before any remote call.
"""
parts = urlsplit(url or '')
return urlunsplit((parts.scheme.lower(), parts.netloc.lower(), parts.path.rstrip('/'), '', ''))
class DifyDatasetsConnector(ConfigStore, KnowledgeEngine):
"""Knowledge Engine powered by Dify Datasets.
Supports retrieval, document ingestion (file upload), and deletion
via the Dify Dataset API.
"""
@classmethod
def get_capabilities(cls) -> list[str]:
return [KnowledgeEngineCapability.DOC_INGESTION, KnowledgeEngineCapability.DOC_PARSING]
# ========== Lifecycle Hooks ==========
@serialized
async def on_knowledge_base_create(self, kb_id: str, config: dict) -> None:
"""Persist the knowledge-base config for deletion after worker restart."""
await self._save_config(kb_id, config)
logger.info(f"[DifyDatasetsConnector] Knowledge base created: {kb_id}")
@serialized(allow_fenced=True)
async def on_knowledge_base_delete(self, kb_id: str) -> None:
"""Tombstone the stored config and clear any fence for this knowledge base."""
await self._save_config(kb_id, None)
# Deleting the knowledge base is the operator path out of a fence: the
# pending ambiguity is moot once it no longer exists.
await self._state.clear_fence(self.plugin, kb_id)
logger.info(f"[DifyDatasetsConnector] Knowledge base deleted: {kb_id}")
# ========== Helper Methods ==========
async def _fetch_dataset_reranking_config(
self, api_base_url: str, api_key: str, dataset_id: str
) -> dict:
"""Fetch the reranking model config from the Dify dataset settings."""
url = f"{api_base_url}/datasets/{dataset_id}"
headers = {"Authorization": f"Bearer {api_key}"}
try:
async with self.http_client() as client:
response = await client.get(url, headers=headers, timeout=15.0)
response.raise_for_status()
data = response.json()
retrieval_model = data.get("retrieval_model_dict") or data.get("retrieval_model") or {}
reranking = retrieval_model.get("reranking_model", {})
mode = retrieval_model.get("reranking_mode")
logger.info(
f"[DifyDatasetsConnector] Fetched dataset reranking config: "
f"mode={mode}, provider={reranking.get('reranking_provider_name')}, "
f"model={reranking.get('reranking_model_name')}"
)
return {
"reranking_mode": mode or "reranking_model",
"reranking_model": reranking,
}
except Exception:
logger.exception("[DifyDatasetsConnector] Failed to fetch dataset reranking config")
return {
"reranking_mode": "reranking_model",
"reranking_model": {"reranking_provider_name": "", "reranking_model_name": ""},
}
# ========== Core Methods ==========
async def retrieve(self, context: RetrievalContext) -> RetrievalResponse:
"""Execute retrieval against Dify Dataset API."""
config = context.creation_settings
retrieval = context.retrieval_settings
api_base_url = config.get("api_base_url", "https://api.dify.ai/v1").rstrip("/")
api_key = config.get("dify_apikey")
dataset_id = config.get("dataset_id")
top_k = retrieval.get("top_k", 5)
score_threshold_enabled = retrieval.get("score_threshold_enabled", False)
score_threshold = retrieval.get("score_threshold", 0.5)
search_method = retrieval.get("search_method", "semantic_search")
reranking_enable = retrieval.get("reranking_enable", False)
if not api_key or not dataset_id:
logger.error(
f"[DifyDatasetsConnector] Missing required configuration. "
f"Config keys: {list(config.keys())}"
)
return RetrievalResponse(results=[], total_found=0)
# Fetch reranking model config from Dify dataset when reranking is enabled
reranking_mode = None
reranking_model = {"reranking_provider_name": "", "reranking_model_name": ""}
if reranking_enable:
reranking_config = await self._fetch_dataset_reranking_config(
api_base_url, api_key, dataset_id
)
reranking_mode = reranking_config["reranking_mode"]
reranking_model = reranking_config["reranking_model"]
url = f"{api_base_url}/datasets/{dataset_id}/retrieve"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"query": context.query,
"retrieval_model": {
"search_method": search_method,
"reranking_enable": reranking_enable,
"reranking_mode": reranking_mode,
"reranking_model": reranking_model,
"weights": None,
"top_k": int(top_k),
"score_threshold_enabled": score_threshold_enabled,
"score_threshold": float(score_threshold),
},
}
results: list[RetrievalResultEntry] = []
try:
async with self.http_client() as client:
response = await client.post(url, json=payload, headers=headers, timeout=30.0)
response.raise_for_status()
data = response.json()
logger.info(
f"[DifyDatasetsConnector] Request: search_method={search_method}, top_k={top_k}, "
f"score_threshold_enabled={score_threshold_enabled}, score_threshold={score_threshold}, "
f"reranking_enable={reranking_enable}, reranking_mode={reranking_mode}, "
f"reranking_model={reranking_model}, "
f"Response records count: {len(data.get('records', []))}"
)
for record in data.get("records", []):
segment = record.get("segment", {})
document = segment.get("document", {})
score = record.get("score")
if score is None:
score = 0.0
results.append(
RetrievalResultEntry(
id=segment.get("id", ""),
content=[ContentElement.from_text(segment.get("content", ""))],
metadata={
"document_id": segment.get("document_id", ""),
"document_name": document.get("name", ""),
"segment_id": segment.get("id", ""),
"keywords": segment.get("keywords", []),
"answer": segment.get("answer"),
},
distance=1.0 - float(score),
score=float(score),
)
)
logger.info(f"[DifyDatasetsConnector] Retrieved {len(results)} chunks from Dify.")
except Exception:
logger.exception("[DifyDatasetsConnector] Error during retrieval")
return RetrievalResponse(results=results, total_found=len(results))
@serialized
async def ingest(self, context: IngestionContext) -> IngestionResult:
"""Upload a file to a Dify dataset for indexing."""
doc_id = context.file_object.metadata.document_id
filename = context.file_object.metadata.filename
config = context.creation_settings
api_base_url = config.get("api_base_url", "https://api.dify.ai/v1").rstrip("/")
upstream_target = _normalize_api_base_url(api_base_url)
api_key = config.get("dify_apikey")
dataset_id = config.get("dataset_id")
if not api_key or not dataset_id:
logger.error(
f"[DifyDatasetsConnector] Missing required configuration for ingestion. "
f"Config keys: {list(config.keys())}"
)
return IngestionResult(
document_id=doc_id,
status=DocumentStatus.FAILED,
error_message="Missing required config: dify_apikey or dataset_id.",
)
# Persist config for deletion after worker restart
kb_id = context.get_collection_id()
await self._save_config(kb_id, config)
if await self._load_document(kb_id, doc_id) is not None:
raise RuntimeError('Existing upload intent; reconcile before retry')
# 1. Read file content from Host
try:
file_bytes = await self.plugin.get_knowledge_file_stream(context.file_object.storage_path)
except Exception as e:
logger.error(f"[DifyDatasetsConnector] Failed to read file content: {e}")
return IngestionResult(
document_id=doc_id,
status=DocumentStatus.FAILED,
error_message=f"Could not read file: {e}",
)
# Intent is durable before remote I/O; an ambiguous timeout cannot create
# a second document on retry without explicit upstream reconciliation.
await self._save_document(kb_id, doc_id, {'upstream_id': '', 'dataset_id': dataset_id,
'status': 'pending'})
# 2. Upload to Dify dataset
url = f"{api_base_url}/datasets/{dataset_id}/document/create-by-file"
headers = {
"Authorization": f"Bearer {api_key}",
}
try:
data_payload = json.dumps({
"indexing_technique": "high_quality",
"process_rule": {"mode": "automatic"},
})
async def upload():
async with self.http_client() as client:
response = await client.post(
url,
headers=headers,
files={"file": (filename, file_bytes)},
data={"data": data_payload},
timeout=120.0,
)
response.raise_for_status()
return response.json()
resp_data = await self._state.dispatched(
self.plugin, kb_id, upload, 'Dify upload outcome unknown')
dify_document = resp_data.get("document", {})
dify_doc_id = dify_document.get("id")
if not isinstance(dify_doc_id, str) or not dify_doc_id:
raise AmbiguousMutationError("Dify upload omitted upstream document ID; outcome requires reconciliation")
await self._save_document(kb_id, doc_id, {'upstream_id': dify_doc_id,
'dataset_id': dataset_id, 'status': 'created',
'api_base_url': upstream_target})
logger.info(
f"[DifyDatasetsConnector] File uploaded: {filename} -> "
f"dify_doc_id={dify_doc_id}, status={dify_document.get('indexing_status')}"
)
return IngestionResult(
document_id=dify_doc_id,
status=DocumentStatus.PROCESSING,
)
except httpx.HTTPStatusError as e:
# The provider answered, so this rejection is deterministic: no fence.
error_body = e.response.text
logger.error(
f"[DifyDatasetsConnector] Dify API error during ingestion: "
f"status={e.response.status_code}, body={error_body}"
)
return IngestionResult(
document_id=doc_id,
status=DocumentStatus.FAILED,
error_message=f"Dify API error {e.response.status_code}: {error_body}",
)
except AmbiguousMutationError:
# Already-dispatched upload with an unknown outcome: fence this KB.
raise
except Exception as e:
logger.error(f"[DifyDatasetsConnector] Ingestion failed for {filename}: {e}")
return IngestionResult(
document_id=doc_id,
status=DocumentStatus.FAILED,
error_message=str(e),
)
@serialized
async def delete_document(self, kb_id: str, document_id: str) -> bool:
"""Delete a document from a Dify dataset."""
mapping = await self._load_document(kb_id, document_id)
if not mapping or not mapping['upstream_id'] or mapping['status'] != 'created':
return False
config = await self._load_config(kb_id)
if not config:
logger.warning(
f"[DifyDatasetsConnector] No stored config for kb_id={kb_id}. "
"Cannot delete document without API credentials."
)
return False
api_base_url = config.get("api_base_url", "https://api.dify.ai/v1").rstrip("/")
api_key = config.get("dify_apikey")
dataset_id = config.get("dataset_id")
if not api_key or not dataset_id:
logger.error(
f"[DifyDatasetsConnector] Missing required configuration for deletion. "
f"Config keys: {list(config.keys())}"
)
return False
if mapping['dataset_id'] != dataset_id:
raise RuntimeError('Dataset changed; reconcile before deletion')
# The mapping is bound to the upstream instance that created it: a changed
# base URL (or a legacy mapping without one) must not be sent to a
# different provider. Reject before dispatching any remote request.
if mapping.get('api_base_url') != _normalize_api_base_url(api_base_url):
raise RuntimeError('Upstream target changed; reconcile before deletion')
url = f"{api_base_url}/datasets/{dataset_id}/documents/{mapping['upstream_id']}"
headers = {
"Authorization": f"Bearer {api_key}",
}
async def remove():
async with self.http_client() as client:
response = await client.delete(url, headers=headers, timeout=30.0)
if response.status_code != 204:
# Some Dify versions may return 200 instead of 204.
response.raise_for_status()
return response.status_code
try:
status_code = await self._state.dispatched(
self.plugin, kb_id, remove, 'Dify delete outcome unknown')
await self._save_document(kb_id, mapping['host_document_id'], {**mapping, 'status': 'deleted'})
logger.info(
f"[DifyDatasetsConnector] Document deleted: {document_id} "
f"from dataset {dataset_id} (status={status_code})"
)
return True
except httpx.HTTPStatusError as e:
# The provider answered, so the delete was rejected deterministically.
logger.error(
f"[DifyDatasetsConnector] Failed to delete document {document_id}: {e}"
)
return False
except Exception as e:
logger.error(
f"[DifyDatasetsConnector] Failed to delete document {document_id}: {e}"
)
return False
@@ -0,0 +1,381 @@
"""Installation- and knowledge-base-scoped state using the SDK-bound Host proxy.
Bundled in each independent archive. No filesystem/global tenant registry.
Locks and fences are keyed by ``(installation binding, knowledge-base identity)``
so an ambiguous failure for one knowledge base never blocks the other knowledge
bases that share the same installation; the platform storage key is the same for
a whole installation, hence the knowledge-base id is always folded into our keys.
Fences are persisted in installation-bound Host storage (key includes the
knowledge-base id) with an in-process cache in front, so a restarted worker still
recognises them. Only dispatched mutations whose outcome is unknown fence; a
deterministic validation error raised before any remote call and ordinary
cancellation before a call is dispatched do not.
"""
import asyncio
import hashlib
import inspect
import json
import logging
from functools import wraps
from contextlib import asynccontextmanager
import httpx
from weakref import WeakValueDictionary
logger = logging.getLogger(__name__)
HTTP_TOTAL_TIMEOUT = 150.0
FENCE_KEY_PREFIX = "ke.fence.v1."
# Fallback fence scope when the wrapped method carries no knowledge-base identity.
_INSTALLATION_FENCE_KEY = FENCE_KEY_PREFIX + "installation"
_IDENTITY_ARGUMENTS = ('kb_id', 'collection_id', 'knowledge_base_id')
class AmbiguousMutationError(RuntimeError):
"""A dispatched mutation's outcome is unknown and requires reconciliation.
Raise this from a call site when a remote mutation may have taken effect but
its result (or its durable record) is unknown. Only these failures fence a
knowledge base; deterministic errors raised before any remote call, and
ordinary cancellation before a call is dispatched, do not fence.
"""
class FenceStateUnavailableError(RuntimeError):
"""Persisted fence state could not be read, so the fence cannot be confirmed.
A failed read is not evidence that no fence exists, so the operation fails
closed rather than proceeding as if the knowledge base were clear. The read
is retried on the next call instead of being cached as loaded.
"""
def is_ambiguous_http_failure(exc):
"""Whether a failed provider call had an unknown outcome.
A response status means the provider answered, so the failure is
deterministic. A connection that was never established means nothing was
dispatched. Everything else (timeout, lost connection, unparsable response)
was dispatched with an unknown outcome.
"""
if isinstance(exc, httpx.HTTPStatusError):
return False
if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)):
return False
return True
async def _settle_commit(task):
"""Wait for a dispatched commit and report its outcome.
Repeated caller cancellation never abandons the wait, so a Host commit that
was already dispatched settles before its lock is released. Returns
``(caller_cancelled, failure)``; ``failure`` is the commit's exception, or
``asyncio.CancelledError`` when the commit itself was cancelled.
"""
cancelled = False
while not task.done():
try:
await asyncio.shield(task)
except asyncio.CancelledError:
cancelled = True
except Exception:
# The commit raised; its outcome is read from the state below.
pass
if task.cancelled():
return cancelled, asyncio.CancelledError()
return cancelled, task.exception()
def _kb_identity(method, instance, args, kwargs):
"""Extract the knowledge-base identity from a serialized method's arguments.
Reads a ``kb_id`` / ``collection_id`` / ``knowledge_base_id`` argument first,
then an argument exposing ``get_collection_id()`` or ``knowledge_base_id``
(ingestion passes the whole context). Returns ``None`` when the wrapped
method carries no knowledge-base identity, in which case the caller falls
back to an installation-wide lock and fence.
"""
try:
bound = inspect.signature(method).bind_partial(instance, *args, **kwargs)
except (TypeError, ValueError):
return None
for name in _IDENTITY_ARGUMENTS:
value = bound.arguments.get(name)
if isinstance(value, str) and value:
return value
for value in bound.arguments.values():
getter = getattr(value, 'get_collection_id', None)
if callable(getter):
try:
identity = getter()
except Exception:
continue
if isinstance(identity, str) and identity:
return identity
identity = getattr(value, 'knowledge_base_id', None)
if isinstance(identity, str) and identity:
return identity
return None
def _kb_fence_key(kb_identity):
if kb_identity is None:
return _INSTALLATION_FENCE_KEY
return FENCE_KEY_PREFIX + hashlib.sha256(kb_identity.encode()).hexdigest()
class SerialState:
def __init__(self):
self._locks = WeakValueDictionary()
# binding -> set of fence keys; cache in front of persisted fences.
self._fenced = {}
self._fence_loaded = set()
@staticmethod
def binding(plugin):
try:
binding = plugin.get_installation_binding()
except Exception:
# Revocation has no invocation context; fall back to dedicated scope.
binding = None
return binding if binding is not None else ('dedicated',)
@staticmethod
def _scope(binding, kb_identity):
return (binding, kb_identity)
def _fenced_keys(self, binding):
return self._fenced.setdefault(binding, set())
def is_fenced(self, binding, kb_identity):
fenced = self._fenced_keys(binding)
return (
_kb_fence_key(kb_identity) in fenced
or _INSTALLATION_FENCE_KEY in fenced
)
async def _load_fences(self, plugin, binding):
"""Read persisted fences once per binding.
A failed read cannot be treated as "no fence": it means the fence state
is unknown, so the caller fails closed and retries on the next call
rather than caching the failure as loaded.
"""
if binding in self._fence_loaded:
return
try:
keys = await plugin.get_plugin_storage_keys()
fenced = self._fenced_keys(binding)
for key in keys:
if not key.startswith(FENCE_KEY_PREFIX):
continue
try:
marker = json.loads(await plugin.get_plugin_storage(key))
except Exception:
# Unreadable marker: fail closed on this knowledge base.
marker = {'reason': 'unreadable fence record'}
if marker is not None:
fenced.add(key)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("Could not read persisted fences for %r", binding, exc_info=True)
raise FenceStateUnavailableError(
f"Could not confirm persisted fence state for {binding!r}: {exc!r}"
) from exc
self._fence_loaded.add(binding)
async def run(self, plugin, kb_identity, operation, *, allow_fenced=False):
binding = self.binding(plugin)
key = self._scope(binding, kb_identity)
lock = self._locks.get(key)
if lock is None:
lock = self._locks[key] = asyncio.Lock()
async with lock:
await self._load_fences(plugin, binding)
if not allow_fenced and self.is_fenced(binding, kb_identity):
raise RuntimeError(
f"Knowledge base {kb_identity!r} fenced after an ambiguous mutation; "
"reconcile with the provider before retrying"
)
try:
return await operation()
except AmbiguousMutationError:
await self.fence(plugin, kb_identity, 'ambiguous remote mutation')
raise
async def dispatched(self, plugin, kb_identity, operation, reason):
"""Dispatch a remote mutation under a pre-persisted intent.
The fence is written before the request goes out, so a cancellation after
dispatch (or an ambiguous failure) cannot leave a mutation with no marker
for reconciliation; a deterministic answer clears it again.
"""
await self.fence(plugin, kb_identity, reason)
try:
result = await operation()
except asyncio.CancelledError:
# Dispatched, outcome unknown: keep the fence and let the caller see it.
raise
except Exception as exc:
if not is_ambiguous_http_failure(exc):
await self.clear_fence(plugin, kb_identity)
raise
await self.clear_fence(plugin, kb_identity)
return result
async def fence(self, plugin, kb_identity, reason):
"""Mark one knowledge base as requiring reconciliation.
The in-process marker is set synchronously; persisting it to Host storage
is best effort so that a caller cancellation cannot lose the fence.
"""
binding = self.binding(plugin)
fence_key = _kb_fence_key(kb_identity)
self._fenced_keys(binding).add(fence_key)
payload = json.dumps({'kb_id': kb_identity, 'reason': reason}).encode()
cancelled, failure = await _settle_commit(
asyncio.create_task(plugin.set_plugin_storage(fence_key, payload))
)
if failure is not None:
logger.warning("Could not persist fence for %r: %r", kb_identity, failure)
if cancelled:
raise asyncio.CancelledError
async def clear_fence(self, plugin, kb_identity):
"""Drop the fence for one knowledge base, in process and in storage.
Used when a knowledge base is deleted: its pending ambiguity is moot, so
the operator path out of a fence is deletion. A lingering persisted
marker is inert because the configuration tombstone is already written.
"""
binding = self.binding(plugin)
fence_key = _kb_fence_key(kb_identity)
self._fenced_keys(binding).discard(fence_key)
cancelled, failure = await _settle_commit(
asyncio.create_task(plugin.set_plugin_storage(fence_key, b'null'))
)
if failure is not None:
logger.warning("Could not clear fence for %r: %r", kb_identity, failure)
if cancelled:
raise asyncio.CancelledError
async def write(self, plugin, kb_identity, key, value):
# A caller cancellation must not abandon a dispatched Host commit while
# its lock is released; settle it and fence only a real ambiguity.
task = asyncio.create_task(plugin.set_plugin_storage(key, value))
cancelled, failure = await _settle_commit(task)
if failure is not None:
await self.fence(plugin, kb_identity, f'Host write outcome unknown: {failure!r}')
if cancelled:
raise asyncio.CancelledError
if failure is not None:
raise failure
return task.result()
def release_binding(self, binding):
"""Drop process-local state keyed by one installation binding."""
for key in [k for k in self._locks if k[0] == binding]:
self._locks.pop(key, None)
self._fenced.pop(binding, None)
self._fence_loaded.discard(binding)
def serialized(method=None, *, allow_fenced=False):
def decorate(func):
@wraps(func)
async def call(self, *args, **kwargs):
return await self._state.run(
self.plugin,
_kb_identity(func, self, args, kwargs),
lambda: func(self, *args, **kwargs),
allow_fenced=allow_fenced,
)
return call
return decorate if method is None else decorate(method)
class ConfigStore:
"""No credential cache: every delete resolves durable installation storage.
A JSON null tombstone makes missing KBs explicit without ambiguous delete RPCs.
The one mutation lock also orders ingest/delete/create inside this worker.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._state = SerialState()
self._http_slots = asyncio.Semaphore(4)
async def initialize(self) -> None:
await super().initialize()
# The runtime dispatches installation revocation to the plugin instance,
# which holds no back-reference to its components; hand it this
# component's state so the revocation hook can drop per-binding locks and
# fences. Without the hook (langbot-plugin 0.7.4) this attribute is simply
# never read.
plugin = getattr(self, 'plugin', None)
if plugin is not None:
plugin.knowledge_engine_serial_state = self._state
@asynccontextmanager
async def http_client(self):
# Include queueing and the whole response body in the deadline, not just
# individual socket reads. Clients/credentials never survive a call.
async with asyncio.timeout(HTTP_TOTAL_TIMEOUT):
async with self._http_slots:
async with httpx.AsyncClient() as client:
yield client
@staticmethod
def _key(kb_id):
return "ke.config.v1." + hashlib.sha256(kb_id.encode()).hexdigest()
async def _save_config(self, kb_id, config):
value = json.dumps(config, ensure_ascii=False).encode()
if len(value) > 65536:
raise ValueError("Knowledge-base configuration exceeds 64 KiB")
await self._state.write(self.plugin, kb_id, self._key(kb_id), value)
async def _load_config(self, kb_id):
key = self._key(kb_id)
# Only an authoritative keys response establishes absence; RPC errors propagate.
if key not in await self.plugin.get_plugin_storage_keys():
return None
value = json.loads(await self.plugin.get_plugin_storage(key))
if value is not None and not isinstance(value, dict):
raise ValueError("Invalid persisted knowledge-base configuration")
return value
@staticmethod
def _document_key(kb_id, host_document_id):
return "ke.document.v1." + hashlib.sha256(
json.dumps([kb_id, host_document_id]).encode()
).hexdigest()
async def _load_document(self, kb_id, host_document_id):
key = self._document_key(kb_id, host_document_id)
keys = await self.plugin.get_plugin_storage_keys()
if key in keys:
value = json.loads(await self.plugin.get_plugin_storage(key))
else:
# Current Host passes the durable upstream ID on delete; older Host
# revisions pass its file UUID. Resolve either without trusting an
# arbitrary unrecorded identifier.
matches = []
for candidate in keys:
if candidate.startswith('ke.document.v1.'):
item = json.loads(await self.plugin.get_plugin_storage(candidate))
if item.get('kb_id') == kb_id and item.get('upstream_id') == host_document_id:
matches.append(item)
if len(matches) > 1:
raise ValueError('Ambiguous upstream document ID')
return matches[0] if matches else None
if not isinstance(value, dict) or not isinstance(value.get('upstream_id'), str):
raise ValueError('Invalid upstream document mapping; reconcile installation')
return value
async def _save_document(self, kb_id, host_document_id, value):
value = {**value, 'kb_id': kb_id, 'host_document_id': host_document_id}
await self._state.write(self.plugin, kb_id, self._document_key(kb_id, host_document_id),
json.dumps(value).encode())