first commit

This commit is contained in:
strepsiades
2026-03-09 16:31:42 +08:00
commit 4a9c444b10
486 changed files with 52479 additions and 0 deletions
+252
View File
@@ -0,0 +1,252 @@
# DataSource Layer Implementation
## Overview
This document describes the new datasource layer for `sync_system_new`, which is completely decoupled from the sync strategy and state machine logic.
## Architecture
The datasource layer follows a pure execution model:
1. **Load**: Reads data from backend and creates `SyncNode` instances
2. **Save**: Executes actions (CREATE/UPDATE/DELETE) based on `node.action`
3. **Update Status**: Sets `node.status` to SUCCESS or FAILED after execution
### Key Principles
- **No Strategy Logic**: Datasource doesn't make decisions about what to sync
- **State-Driven**: Only reads `node.action` and `node.status` to determine behavior
- **No ID Resolution**: IDs are already resolved by the strategy layer
- **Backend Agnostic**: Abstract base class supports multiple backends
## Components
### 1. Base Protocol (`base.py`)
#### `DataSourceProtocol`
Protocol defining the interface for all datasource implementations:
```python
async def load_nodes(
node_type: str,
collection: DataCollection,
**filters: Any
) -> None:
"""Load data from backend and create SyncNodes"""
async def save_nodes(
node_type: str,
collection: DataCollection
) -> None:
"""Execute pending actions for nodes"""
```
#### `BaseDataSource`
Abstract base class providing common functionality:
- `load_nodes`: Creates SyncNodes from raw data
- `save_nodes`: Executes actions and updates status
- `_handle_create/update/delete`: Action-specific handlers
Subclasses implement:
- `_load_raw_data`: Load from backend
- `_create_item`: Execute CREATE
- `_update_item`: Execute UPDATE
- `_delete_item`: Execute DELETE
- `_generate_id`: Generate new IDs
### 2. JSONL Implementation (`jsonl_datasource.py`)
#### `JsonlDataSource`
JSONL-based datasource for local testing:
**Features:**
- Loads `.jsonl` files into memory
- Generates UUIDs for new items
- Supports filtering during load
- Supports read-only mode
- Persists changes back to files
**File Structure:**
```
data/
├── contract_1.jsonl
├── project_1.jsonl
└── ...
```
**Usage:**
```python
datasource = JsonlDataSource(Path("data/"))
# Load nodes
await datasource.load_nodes("contract", collection, project_id="P1")
# Execute actions
await datasource.save_nodes("contract", collection)
# Persist to disk
await datasource.save_all()
```
### 3. Domain Handlers
Domain-specific handlers provide business logic (optional):
**Example: `domain/contract/jsonl_handler.py`**
```python
class ContractJsonlHandler:
@staticmethod
def validate_create(data: Any) -> None:
"""Validate before create"""
@staticmethod
def transform_for_create(data: Any) -> Dict[str, Any]:
"""Transform data before create"""
```
## Node Lifecycle
### Loading Phase
1. Datasource reads raw data from backend
2. Creates `SyncNode` for each item:
- `node_id`: Unique session ID (e.g., "contract_C1")
- `data_id`: Backend ID (e.g., "C1")
- `origin_data`: Raw data from backend
- `data`: Copy of origin_data (may be modified by strategy)
- `action`: NONE (initial)
- `status`: PENDING (initial)
### Execution Phase
For each node with `status == PENDING` and `action != NONE`:
1. **CREATE**:
- Generate new ID via `_generate_id`
- Call `_create_item` with new ID and data
- Set `node.data_id` to new ID
- Set `node.status` to SUCCESS
2. **UPDATE**:
- Call `_update_item` with existing `data_id` and data
- Set `node.status` to SUCCESS
3. **DELETE**:
- Call `_delete_item` with `data_id`
- Set `node.status` to SUCCESS
4. **Error Handling**:
- On exception: Set `node.status` to FAILED
- Set `node.error` to exception message
## Integration with Sync System
The datasource is used by the pipeline layer:
```python
# 1. Load data
local_ds = JsonlDataSource(Path("local_data/"))
remote_ds = JsonlDataSource(Path("remote_data/"))
local_collection = DataCollection("local")
remote_collection = DataCollection("remote")
await local_ds.load_nodes("contract", local_collection)
await remote_ds.load_nodes("contract", remote_collection)
# 2. Strategy layer determines actions
strategy = ContractSyncStrategy(...)
await strategy.bind(...) # Sets node.action
# 3. Execute actions
await remote_ds.save_nodes("contract", remote_collection)
await remote_ds.save_all()
```
## Testing
Comprehensive test suite in `tests/test_datasource.py`:
- Basic loading and node creation
- Filtering during load
- CREATE/UPDATE/DELETE actions
- Error handling and status updates
- Read-only mode
- Multiple node types
Run tests:
```bash
python -m pytest sync_system_new/tests/test_datasource.py -v
```
## Future Extensions
### API DataSource
For production use, implement `ApiDataSource`:
```python
class ApiDataSource(BaseDataSource):
async def _load_raw_data(self, node_type: str, **filters):
# Call REST API
async def _create_item(self, node_type: str, item_id: str, data):
# POST request
async def _generate_id(self, node_type: str, data):
# Return None - server generates ID
```
### Database DataSource
For SQL databases:
```python
class DatabaseDataSource(BaseDataSource):
async def _load_raw_data(self, node_type: str, **filters):
# SELECT query
async def _create_item(self, node_type: str, item_id: str, data):
# INSERT query
```
## Comparison with Legacy System
### Legacy System (`sync_system/datasource/`)
- Tightly coupled with RepositoryProtocol
- Mixed concerns (data access + business logic)
- Schema validation in datasource layer
- Different interface for each entity type
### New System (`sync_system_new/datasource/`)
- Decoupled from strategy and state machine
- Pure data access (business logic in domain handlers)
- Generic interface for all entity types
- Direct integration with SyncNode and DataCollection
## Key Differences
| Aspect | Legacy | New |
|--------|--------|-----|
| Coupling | Tight with sync policies | Decoupled from sync logic |
| Interface | Repository per entity | Generic load/save |
| ID Handling | Mixed responsibilities | IDs already resolved |
| State Management | Mixed with data access | SyncNode-based |
| Testing | Complex setup | Simple, focused tests |
## Conclusion
The new datasource layer provides:
1. **Clear Separation**: Data access is isolated from sync logic
2. **Flexibility**: Easy to add new backend types
3. **Testability**: Simple, focused tests
4. **Type Safety**: Full integration with Pydantic models
5. **Maintainability**: Single responsibility per component
+41
View File
@@ -0,0 +1,41 @@
"""
DataSource Layer for sync_state_machine
DataSource 只负责底层 I/O,不关心节点类型。
Handler 负责类型转换和业务逻辑。
"""
from .datasource import BaseDataSource
from .handler import (
NodeHandler,
BaseNodeHandler,
NodeHandlerRegistry,
ValidationResult
)
from .task_result import TaskResult, TaskStatus
# API DataSource
from .api import ApiClient, ApiDataSource, BaseApiHandler
# JSONL DataSource
from .jsonl import JsonlDataSource, BaseJsonlHandler
__all__ = [
# 基础类
"BaseDataSource",
"NodeHandler",
"BaseNodeHandler",
"NodeHandlerRegistry",
"TaskResult",
"TaskStatus",
"ValidationResult",
# API DataSource
"ApiClient",
"ApiDataSource",
"BaseApiHandler",
# JSONL DataSource
"JsonlDataSource",
"BaseJsonlHandler",
]
@@ -0,0 +1,15 @@
"""
API DataSource 模块
提供基于 HTTP API 的数据源实现。
"""
from .client import ApiClient
from .datasource import ApiDataSource
from .handler import BaseApiHandler
__all__ = [
"ApiClient",
"ApiDataSource",
"BaseApiHandler",
]
+627
View File
@@ -0,0 +1,627 @@
"""
ApiClient - HTTP 通信层
职责:
- 封装所有 HTTP 通信逻辑
- 管理 httpx.AsyncClient,维护连接池和 session
- 生成请求签名(timestamp, nonce, sign
- 统一请求入口,自动注入 uid
- 批量 Push Log 查询
"""
import hashlib
import hmac
import time
import uuid
import json as json_lib
import logging
import asyncio
from typing import Dict, List, Any, Optional
from collections import defaultdict, deque
from pathlib import Path
from datetime import datetime
import httpx
from schemas.common.push_system import PushIdsSchema
def _build_default_push_steps() -> List[Dict[str, str]]:
"""构造最小审批步骤字段,满足第三方 v2 push schema。"""
return []
class ApiClient:
"""API HTTP 客户端"""
_TRACE_TEXT_MAX_LEN = 1000
def __init__(
self,
base_url: str,
uid: str,
secret: str,
debug: bool = False,
log_file: Optional[Path] = None,
rate_limit_max_requests: int = 30,
rate_limit_window_seconds: float = 10.0,
):
"""
初始化 API 客户端
Args:
base_url: API 基础 URL(如 https://api.example.com
uid: 用户标识
secret: 签名密钥
debug: 是否启用调试模式(打印请求详情)
log_file: 日志文件路径(可选)
rate_limit_max_requests: 限流窗口内最大请求数(<=0 表示禁用)
rate_limit_window_seconds: 限流窗口秒数(<=0 表示禁用)
"""
self._base_url = base_url.rstrip('/')
self._uid = uid
self._secret = secret
self._client: Optional[httpx.AsyncClient] = None
self._debug = debug
self._log_file = log_file
self._trace_seq = 0
self._trace_by_push_id: Dict[str, deque[Dict[str, Any]]] = defaultdict(deque)
self._trace_by_biz_id: Dict[str, deque[Dict[str, Any]]] = defaultdict(deque)
self._trace_by_op: Dict[str, deque[Dict[str, Any]]] = defaultdict(deque)
self._load_trace_by_item: Dict[tuple[str, str], deque[Dict[str, Any]]] = defaultdict(deque)
self._logger = logging.getLogger(__name__)
self._rate_limit_max_requests = int(rate_limit_max_requests)
self._rate_limit_window_seconds = float(rate_limit_window_seconds)
self._rate_limit_lock = asyncio.Lock()
self._rate_limit_timestamps: deque[float] = deque()
def _is_rate_limit_enabled(self) -> bool:
return self._rate_limit_max_requests > 0 and self._rate_limit_window_seconds > 0
async def _acquire_rate_limit_slot(self) -> None:
if not self._is_rate_limit_enabled():
return
while True:
wait_seconds = 0.0
async with self._rate_limit_lock:
now = time.monotonic()
window_start = now - self._rate_limit_window_seconds
while self._rate_limit_timestamps and self._rate_limit_timestamps[0] <= window_start:
self._rate_limit_timestamps.popleft()
if len(self._rate_limit_timestamps) < self._rate_limit_max_requests:
self._rate_limit_timestamps.append(now)
return
oldest = self._rate_limit_timestamps[0]
wait_seconds = max(0.001, self._rate_limit_window_seconds - (now - oldest))
await asyncio.sleep(wait_seconds)
async def initialize(self):
"""初始化 HTTP 客户端"""
if self._client is None:
# trust_env=False 阻止从环境变量读取代理设置(避免 localhost 走代理导致超时)
self._client = httpx.AsyncClient(timeout=300.0, trust_env=False)
async def close(self):
"""关闭 HTTP 客户端"""
if self._client:
await self._client.aclose()
self._client = None
def _log(self, message: str, level: str = "DEBUG"):
"""
统一日志输出
Args:
message: 日志消息
level: 日志级别(INFO/ERROR
"""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_line = f"[{timestamp}] [{level}] {message}"
level_num = logging.getLevelNamesMapping().get(level.upper(), logging.DEBUG)
self._logger.log(level_num, log_line)
# 文件输出
if self._log_file:
self._log_file.parent.mkdir(parents=True, exist_ok=True)
with open(self._log_file, 'a', encoding='utf-8') as f:
f.write(log_line + '\n')
def _get_client(self) -> httpx.AsyncClient:
"""获取 HTTP 客户端实例"""
if self._client is None:
raise RuntimeError("ApiClient not initialized. Call 'await client.initialize()' first.")
return self._client
def _derive_op_kind(self, method: str, endpoint: str) -> str:
endpoint_norm = endpoint.lower().strip()
if endpoint_norm == "/push/result":
return "poll"
method_upper = method.upper()
if method_upper == "GET":
if endpoint_norm.endswith("/list"):
return "load"
return "get"
if endpoint_norm.endswith("/create"):
return "create"
if endpoint_norm.endswith("/delete"):
return "delete"
if endpoint_norm.endswith("/update") or endpoint_norm.endswith("/plan"):
return "update"
if method_upper == "POST":
return "create"
if method_upper == "PUT":
return "update"
if method_upper == "DELETE":
return "delete"
return method.lower()
def _derive_node_type_from_list_endpoint(self, endpoint: str) -> Optional[str]:
endpoint_norm = endpoint.strip("/")
if not endpoint_norm.endswith("/list"):
return None
prefix = endpoint_norm[:-5]
if not prefix:
return None
return prefix.replace("/", "_")
def _record_trace(
self,
*,
method: str,
endpoint: str,
url: str,
request_params: Optional[Dict[str, Any]],
request_payload: Optional[Dict[str, Any]],
response: Optional[Dict[str, Any]],
elapsed_ms: int,
) -> None:
self._trace_seq += 1
op_kind = self._derive_op_kind(method, endpoint)
payload = request_payload or {}
response_view = self._build_trace_response_view(op_kind=op_kind, response=response)
params_text = self._to_trace_text(request_params or {}, max_len=self._TRACE_TEXT_MAX_LEN)
payload_text = self._to_trace_text(payload, max_len=self._TRACE_TEXT_MAX_LEN)
response_text = self._to_trace_text(response_view, max_len=self._TRACE_TEXT_MAX_LEN)
trace = {
"trace_id": self._trace_seq,
"op": op_kind,
"method": method.upper(),
"endpoint": endpoint,
"url": url,
"params": request_params or {},
"payload": payload,
"response": response_view,
"params_text": params_text,
"payload_text": payload_text,
"response_text": response_text,
"elapsed_ms": elapsed_ms,
"ts": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
push_id = payload.get("push_id")
biz_id = payload.get("biz_id")
if isinstance(push_id, str) and push_id:
self._trace_by_push_id[push_id].append(trace)
if isinstance(biz_id, str) and biz_id:
self._trace_by_biz_id[biz_id].append(trace)
self._trace_by_op[op_kind].append(trace)
if op_kind == "load":
node_type = self._derive_node_type_from_list_endpoint(endpoint)
data = (response or {}).get("data") if isinstance(response, dict) else None
if isinstance(data, list):
items = data
elif isinstance(data, dict):
items = data.get("list", [])
else:
items = []
if node_type and isinstance(items, list):
for item in items:
if not isinstance(item, dict):
continue
item_id = item.get("id") or item.get("_id")
if not item_id:
continue
self._load_trace_by_item[(node_type, str(item_id))].append(trace)
def _to_trace_text(self, value: Any, *, max_len: int) -> str:
try:
text = json_lib.dumps(value, ensure_ascii=False, separators=(",", ":"))
except Exception:
text = str(value)
if len(text) > max_len:
return f"{text[:max_len]}...(已截断)"
return text
def _build_trace_response_view(self, *, op_kind: str, response: Optional[Dict[str, Any]]) -> Any:
return response or {}
def consume_traces_by_push_id(self, push_id: str) -> List[Dict[str, Any]]:
if not push_id:
return []
bucket = self._trace_by_push_id.get(push_id)
if not bucket:
return []
traces = list(bucket)
bucket.clear()
return traces
def consume_latest_trace_by_biz_id(self, biz_id: str, op_kind: Optional[str] = None) -> Optional[Dict[str, Any]]:
if not biz_id:
return None
bucket = self._trace_by_biz_id.get(biz_id)
if not bucket:
return None
if op_kind is None:
return bucket.pop() if bucket else None
for index in range(len(bucket) - 1, -1, -1):
trace = bucket[index]
if trace.get("op") == op_kind:
del bucket[index]
return trace
return None
def peek_latest_trace_by_biz_id(self, biz_id: str, op_kind: Optional[str] = None) -> Optional[Dict[str, Any]]:
if not biz_id:
return None
bucket = self._trace_by_biz_id.get(biz_id)
if not bucket:
return None
if op_kind is None:
return bucket[-1] if bucket else None
for index in range(len(bucket) - 1, -1, -1):
trace = bucket[index]
if trace.get("op") == op_kind:
return trace
return None
def consume_latest_trace_by_op(self, op_kind: str) -> Optional[Dict[str, Any]]:
bucket = self._trace_by_op.get(op_kind)
if not bucket:
return None
return bucket.pop() if bucket else None
def peek_latest_trace_by_op(self, op_kind: str) -> Optional[Dict[str, Any]]:
bucket = self._trace_by_op.get(op_kind)
if not bucket:
return None
return bucket[-1] if bucket else None
def consume_load_traces(self, node_type: str, data_id: str) -> List[Dict[str, Any]]:
key = (node_type, data_id)
bucket = self._load_trace_by_item.get(key)
if not bucket:
return []
traces = list(bucket)
bucket.clear()
return traces
def _generate_signature(self, params: Dict[str, Any]) -> Dict[str, str]:
"""
生成签名参数(兼容旧版签名算法)
Args:
params: 请求参数
Returns:
包含 timestamp, nonce, sign 的字典
"""
timestamp = int(time.time() * 1000) # 毫秒时间戳
nonce = uuid.uuid4().hex[:16] # 16位随机字符串
# 构建签名参数
sign_params = params.copy()
sign_params['timestamp'] = timestamp
sign_params['nonce'] = nonce
# 按key排序
sorted_items = sorted(sign_params.items())
# 构建签名字符串
items = []
for k, v in sorted_items:
if v is None:
v_str = "null"
elif isinstance(v, bool):
v_str = "true" if v else "false"
elif isinstance(v, str):
v_str = v
elif isinstance(v, (int, float)):
v_str = str(v)
elif isinstance(v, (list, dict)):
v_str = json_lib.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
else:
v_str = str(v)
items.append(f"{k}={v_str}")
sign_str = "&".join(items)
# 使用 HMAC-SHA256 生成签名
sign = hmac.new(self._secret.encode("utf-8"), sign_str.encode("utf-8"), hashlib.sha256).hexdigest()
return {
'timestamp': str(timestamp),
'nonce': nonce,
'sign': sign
}
async def request(
self,
method: str,
endpoint: str,
params: Optional[Dict[str, Any]] = None,
data: Optional[Dict[str, Any]] = None,
json_data: Optional[Dict[str, Any]] = None,
json: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
统一请求入口,自动签名
Args:
method: HTTP 方法(GET/POST/PUT/DELETE
endpoint: API 端点(如 /api/v2/project
params: URL 查询参数
data: 请求体数据(form data
json_data: JSON 请求体数据
json: JSON 请求体数据(兼容参数)
Returns:
响应 JSON 数据
Raises:
httpx.HTTPStatusError: HTTP 错误
"""
# 兼容json参数
if json is not None and json_data is None:
json_data = json
client = self._get_client()
url = f"{self._base_url}{endpoint}"
await self._acquire_rate_limit_slot()
# 记录请求信息
self._log(f"\n{'='*60}", level="DEBUG")
self._log(f"REQUEST: {method} {endpoint}", level="DEBUG")
self._log(f"Full URL: {url}", level="DEBUG")
if params:
self._log(f"Params: {json_lib.dumps(params, ensure_ascii=False, indent=2)}", level="DEBUG")
if data:
self._log(f"Data: {json_lib.dumps(data, ensure_ascii=False, indent=2)}", level="DEBUG")
if json_data:
self._log(f"JSON: {json_lib.dumps(json_data, ensure_ascii=False, indent=2)}", level="DEBUG")
# 准备签名参数
if method.upper() == 'GET':
# GET 请求:uid 放在 params 中
if params is None:
params = {}
params['uid'] = self._uid
# 转换所有参数值为字符串(除了 list/dict)
params = {k: str(v) if not isinstance(v, (list, dict)) else v for k, v in params.items()}
sign_params = params.copy()
# 生成签名并附加到 params
signature = self._generate_signature(sign_params)
params.update(signature)
else:
# POST/PUT/DELETEuid 和签名放在 body 中(符合 v2 schema
if data is None:
data = {}
if 'steps' not in data:
data['steps'] = _build_default_push_steps()
data['uid'] = self._uid
sign_params = data.copy()
if params:
sign_params.update(params)
signature = self._generate_signature(sign_params)
data.update(signature)
# 发送请求并增强异常处理
start_time = time.time()
try:
response = await client.request(
method=method,
url=url,
params=params,
json=data if method.upper() in ['POST', 'PUT'] else None
)
# 记录响应时间
elapsed = time.time() - start_time
self._log(f"Response Time: {elapsed:.3f}s", level="DEBUG")
# 检查 HTTP 状态
response.raise_for_status()
# 记录响应
result = response.json()
self._log(f"Status Code: {response.status_code}", level="DEBUG")
self._log(f"Response: {json_lib.dumps(result, ensure_ascii=False, indent=2)}", level="DEBUG")
self._log(f"{'='*60}\n", level="DEBUG")
self._record_trace(
method=method,
endpoint=endpoint,
url=url,
request_params=params,
request_payload=data if method.upper() in ['POST', 'PUT', 'DELETE'] else None,
response=result,
elapsed_ms=int(elapsed * 1000),
)
return result
except Exception as e:
import traceback
# 记录错误
elapsed = time.time() - start_time
error_msg = "\n========== [ApiClient HTTP Error] ==========\n"
error_msg += f"Request: {method} {url}\n"
error_msg += f"Params: {params}\n"
if method.upper() in ['POST', 'PUT']:
error_msg += f"JSON: {data}\n"
error_msg += f"Exception: {type(e).__name__}: {e}\n"
error_msg += f"Elapsed: {elapsed:.3f}s\n"
response_obj = e.response if isinstance(e, httpx.HTTPStatusError) else None
if response_obj is not None:
error_msg += f"Status Code: {response_obj.status_code}\n"
error_msg += f"Response Headers: {response_obj.headers}\n"
try:
error_msg += f"Response Text: {response_obj.text}\n"
except Exception:
pass
error_msg += traceback.format_exc()
error_msg += "============================================\n"
# 记录到日志文件
self._log(error_msg, level="ERROR")
raise
async def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
GET 请求
Args:
endpoint: API 端点
params: URL 查询参数
Returns:
响应 JSON 数据
"""
return await self.request('GET', endpoint, params=params)
async def post(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""
POST 请求,data 中已包含 push_id/biz_id(由 Handler 生成)
Args:
endpoint: API 端点
data: 请求体数据(包含业务数据和 push_id/biz_id
Returns:
响应 JSON 数据
"""
return await self.request('POST', endpoint, data=data)
async def put(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""
PUT 请求,data 中已包含 push_id/biz_id
Args:
endpoint: API 端点
data: 请求体数据
Returns:
响应 JSON 数据
"""
return await self.request('PUT', endpoint, data=data)
async def delete(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""
DELETE 请求,data 中已包含 push_id/biz_id
Args:
endpoint: API 端点
data: 请求体数据
Returns:
响应 JSON 数据
"""
return await self.request('DELETE', endpoint, data=data)
async def get_push_logs(self, push_ids: List[str]) -> Dict[str, Dict[str, Any]]:
"""
批量查询 Push Log
注意:单个查询也传单元素列表
当前实现:用 for 循环模拟,后续等后端实现批量接口
Args:
push_ids: Push ID 列表
Returns:
{push_id: push_log_data} 的映射
"""
result: Dict[str, Dict[str, Any]] = {}
if not push_ids:
return result
try:
payload = PushIdsSchema(push_ids=','.join(push_ids)).model_dump(exclude_none=True)
response = await self.request(
method='POST',
endpoint='/push/result',
data=payload
)
except httpx.HTTPStatusError as e:
return {push_id: {'error': str(e)} for push_id in push_ids}
data_list: List[Dict[str, Any]] = []
if isinstance(response, dict):
raw_data = response.get('data')
if isinstance(raw_data, list):
data_list = [item for item in raw_data if isinstance(item, dict)]
elif isinstance(raw_data, dict):
data_list = [raw_data]
elif 'push_id' in response and 'status' in response:
data_list = [response]
for item in data_list:
push_id = item.get('push_id')
if not push_id:
continue
poll_trace = {
"trace_id": f"poll-{push_id}-{int(time.time() * 1000)}",
"op": "poll",
"method": "POST",
"endpoint": "/push/result",
"url": f"{self._base_url}/push/result",
"params": {},
"payload": {"push_ids": push_id},
"response": item,
"elapsed_ms": 0,
"ts": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
self._trace_by_push_id[push_id].append(poll_trace)
status = (item.get('status') or '').lower()
error_msg = item.get('error') or ''
result_data = item.get('result') or {}
if status == 'success':
# result_data 的结构是 {"code": 200, "message": "OK", "data": {"biz_id": "..."}}
# 需要提取 result_data["data"] 作为最终的 data
inner_data = result_data.get('data', {}) if isinstance(result_data, dict) else {}
result[push_id] = {
'code': 200,
'status': 'success',
'data': inner_data
}
elif status in {'failed', 'not_find'}:
result[push_id] = {
'code': 500,
'status': 'failed',
'message': error_msg or status or 'Unknown error',
'error': error_msg or status or 'Unknown error'
}
else:
result[push_id] = {
'status': status or 'processing'
}
for push_id in push_ids:
if push_id not in result:
result[push_id] = {'status': 'processing'}
return result
@@ -0,0 +1,264 @@
"""
ApiDataSource - API 数据源协调层
职责:
- 持有 ApiClient 实例,提供给 Handler 使用
- 实现 DataSource 接口(load_all, sync_all
- 协调多个 Handler 的执行顺序
- 统一异常处理和日志记录
"""
import json
from typing import Optional
from pathlib import Path
from ..datasource import BaseDataSource
from ..handler import NodeHandler, ensure_handler_compat
from ..task_result import TaskResult
from .client import ApiClient
class ApiDataSource(BaseDataSource):
"""基于 API 的数据源实现"""
_TRACE_MAX_PARAMS_LEN = 1000
_TRACE_MAX_RESPONSE_LEN = 1000
def __init__(
self,
base_url: str,
uid: str,
secret: str,
poll_mode: str = "async",
debug: bool = False,
log_file: Optional[Path] = None,
poll_max_retries: int = 1,
poll_interval: float = 0.5,
rate_limit_max_requests: int = 30,
rate_limit_window_seconds: float = 10.0,
):
"""
初始化 API 数据源
Args:
base_url: API 基础 URL
uid: 用户标识
secret: 签名密钥
debug: 是否启用调试模式
log_file: 日志文件路径(可选)
"""
super().__init__(poll_max_retries=poll_max_retries, poll_interval=poll_interval)
self.client = ApiClient(
base_url,
uid,
secret,
debug=debug,
log_file=log_file,
rate_limit_max_requests=rate_limit_max_requests,
rate_limit_window_seconds=rate_limit_window_seconds,
)
self.poll_mode = poll_mode
async def initialize(self):
"""初始化数据源(初始化 HTTP 客户端)"""
await self.client.initialize()
async def close(self):
"""关闭数据源(关闭 HTTP 客户端)"""
await self.client.close()
def register_handler(self, handler: NodeHandler) -> None:
"""
注册业务 Handler
Args:
handler: API Handler 实例
"""
compatible_handler = ensure_handler_compat(handler)
compatible_handler.set_api_client(self.client)
compatible_handler.set_poll_mode(self.poll_mode)
# 调用基类注册方法
super().register_handler(compatible_handler)
# 如果 collection 已设置,确保新 handler 拿到引用
if self._collection is not None:
compatible_handler.set_collection(self._collection) # type: ignore[arg-type]
@staticmethod
def _fmt_trace_value(value, max_len: int = 600) -> str:
try:
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
except Exception:
text = str(value)
if len(text) > max_len:
return text[:max_len] + "...<truncated>"
return text
def _append_trace_log(self, node, trace: dict, *, stage: str) -> None:
method = str(trace.get("method", "")).upper()
endpoint = str(trace.get("endpoint", ""))
url = str(trace.get("url", ""))
params = trace.get("params_text")
if not isinstance(params, str):
params = self._fmt_trace_value(trace.get("params", {}), max_len=self._TRACE_MAX_PARAMS_LEN)
payload = trace.get("payload_text")
if not isinstance(payload, str):
payload = self._fmt_trace_value(trace.get("payload", {}), max_len=self._TRACE_MAX_PARAMS_LEN)
response = trace.get("response_text")
if not isinstance(response, str):
response = self._fmt_trace_value(trace.get("response", {}), max_len=self._TRACE_MAX_RESPONSE_LEN)
elapsed_ms = trace.get("elapsed_ms", 0)
node.append_log(f"API链路[{stage}] {method} {endpoint}")
node.append_log(f" url={url}")
if params not in {"{}", "null"}:
node.append_log(f" params={params}")
if payload not in {"{}", "null"}:
node.append_log(f" payload={payload}")
node.append_log(f" response={response}")
node.append_log(f" elapsed_ms={elapsed_ms}")
def _consume_and_append_push_traces(self, node, push_id: str, *, stage: str) -> int:
if not push_id:
return 0
traces = self.client.consume_traces_by_push_id(push_id)
for trace in traces:
self._append_trace_log(node, trace, stage=stage)
return len(traces)
@staticmethod
def _extract_push_id_from_trace(trace: dict | None) -> Optional[str]:
if trace is None:
return None
payload = trace.get("payload")
if not isinstance(payload, dict):
return None
push_id = payload.get("push_id")
return push_id if isinstance(push_id, str) and push_id else None
def _resolve_push_id_for_success(self, node, result: TaskResult) -> Optional[str]:
if result.task_id:
return result.task_id
if result.push_id:
return result.push_id
action_name = node.action.value.lower()
biz_id = node.data_id or ""
if biz_id:
by_biz_and_op = self.client.peek_latest_trace_by_biz_id(biz_id, op_kind=action_name)
if by_biz_and_op is not None:
push_id = self._extract_push_id_from_trace(by_biz_and_op)
if push_id:
self.client.consume_latest_trace_by_biz_id(biz_id, op_kind=action_name)
return push_id
by_biz_any = self.client.peek_latest_trace_by_biz_id(biz_id)
if by_biz_any is not None:
push_id = self._extract_push_id_from_trace(by_biz_any)
if push_id:
self.client.consume_latest_trace_by_biz_id(biz_id)
return push_id
return None
def _append_missing_push_id_debug_trace(self, node) -> None:
action_name = node.action.value.lower()
biz_id = node.data_id or ""
if biz_id:
trace = self.client.peek_latest_trace_by_biz_id(biz_id, op_kind=action_name)
if trace is None:
trace = self.client.peek_latest_trace_by_biz_id(biz_id)
if trace is not None:
self._append_trace_log(node, trace, stage="submit-missing-push-id")
return
node.append_log(
f"API链路[submit-missing-push-id] 未找到匹配 biz_id 的请求记录,action={node.action.value}, data_id={node.data_id or '<empty>'}"
)
return
node.append_log(
f"API链路[submit-missing-push-id] 未找到可用请求记录(已禁用按操作类型兜底),action={node.action.value}, data_id={node.data_id or '<empty>'}"
)
return None
def _on_node_loaded(self, handler: NodeHandler, node) -> None:
data_id = node.data_id or ""
if not data_id:
return
traces = self.client.consume_load_traces(node.node_type, data_id)
for trace in traces:
self._append_trace_log(node, trace, stage="load")
async def _intercept_success_result(self, handler: NodeHandler, node, result: TaskResult, async_tasks: dict) -> bool:
if node.action.value not in {"CREATE", "UPDATE", "DELETE"}:
return False
push_id = self._resolve_push_id_for_success(node, result)
if not push_id:
self._append_missing_push_id_debug_trace(node)
raise RuntimeError(
f"[{node.node_type}] unified polling requires push_id for {node.action.value}, "
f"but none resolved for node={node.node_id}, data_id={node.data_id}"
)
synthetic = TaskResult.in_progress(node_id=node.node_id, task_id=push_id)
appended = self._consume_and_append_push_traces(node, push_id, stage="submit")
if appended == 0:
node.append_log(f"API链路[submit] 未捕获请求记录,push_id={push_id}")
await self._handle_in_progress_result(node, synthetic, async_tasks)
return True
def _on_in_progress_result(self, handler: NodeHandler, node, result: TaskResult) -> None:
if result.task_id:
appended = self._consume_and_append_push_traces(node, result.task_id, stage="submit")
if appended == 0:
node.append_log(f"API链路[submit] 未捕获请求记录,push_id={result.task_id}")
def _on_poll_result(self, handler: NodeHandler, node, task_id: str, result: TaskResult) -> None:
appended = self._consume_and_append_push_traces(node, task_id, stage="poll")
if appended == 0:
node.append_log(f"API链路[poll] 未捕获请求记录,push_id={task_id}")
def _on_failed_result(self, handler: NodeHandler, node, result: TaskResult) -> None:
if node.action.value not in {"CREATE", "UPDATE", "DELETE"}:
return
if result.push_id:
traces = self.client.consume_traces_by_push_id(result.push_id)
if traces:
for trace in traces:
self._append_trace_log(node, trace, stage="submit-failed")
return
node.append_log(f"API链路[submit-failed] 未捕获请求记录,push_id={result.push_id}")
action_name = node.action.value.lower()
biz_id = node.data_id or ""
trace = None
if biz_id:
trace = self.client.consume_latest_trace_by_biz_id(biz_id, op_kind=action_name)
if trace is None:
trace = self.client.consume_latest_trace_by_biz_id(biz_id)
if trace is not None:
self._append_trace_log(node, trace, stage="submit-failed")
return
if biz_id:
node.append_log(
f"API链路[submit-failed] 未找到匹配 biz_id 的请求记录,action={node.action.value}, data_id={biz_id}"
)
return
node.append_log(
f"API链路[submit-failed] 未找到可用请求记录(已禁用按操作类型兜底),action={node.action.value}, data_id=<empty>"
)
# load_all 和 sync_all 继承自 BaseDataSource
# BaseDataSource 会按顺序调用 Handler 的方法
@@ -0,0 +1,25 @@
"""
API Handler 通用错误常量
"""
# API Client 相关错误
ERROR_API_CLIENT_NOT_INITIALIZED = "api_client not initialized"
ERROR_API_CLIENT_NOT_INJECTED = "api_client not injected"
# Collection 相关错误
ERROR_COLLECTION_NOT_SET = "collection not set"
# 数据验证错误
ERROR_NODE_DATA_NONE = "Node data is None or validation failed"
ERROR_VALIDATION_FAILED = "Validation failed"
# 业务数据错误
ERROR_PROJECT_ID_NOT_FOUND = "project_id not found"
ERROR_CONTRACT_ID_NOT_FOUND = "contract_id not found"
ERROR_ID_NOT_FOUND = "id not found"
# 操作不支持错误
ERROR_OPERATION_NOT_SUPPORTED = "Operation not supported"
ERROR_CREATE_NOT_SUPPORTED = "Create operation not supported"
ERROR_UPDATE_NOT_SUPPORTED = "Update operation not supported"
ERROR_DELETE_NOT_SUPPORTED = "Delete operation not supported"
@@ -0,0 +1,538 @@
"""
BaseApiHandler - API Handler 基类
职责:
- 实现 Handler 接口(load, create, update, delete, poll
- 使用 self.api_client 发送 HTTP 请求
- 生成 push_id 和 biz_id(业务标识)
- 从 collection 获取依赖节点数据
- 管理节点的 context(存储 project_id, contract_id 等)
- 实现 poll 方法,调用 api_client.get_push_logs 批量查询
- 提取 created_id(因为不同业务的 id 字段名和位置可能不同)
- 返回 TaskResultSUCCESS/FAILED/IN_PROGRESS
"""
import uuid
import asyncio
from abc import abstractmethod
from typing import ClassVar, Dict, List, Any, Optional, TYPE_CHECKING, TypeVar
from pydantic import BaseModel
from ...common.type_safety import get_pydantic_model_fields
from ..handler import NodeHandler
from ..task_result import TaskResult
from ..handler import ValidationResult # Import ValidationResult
if TYPE_CHECKING:
from ...common.sync_node import SyncNode
from ...common.collection import DataCollection
from .client import ApiClient
T = TypeVar("T", bound=BaseModel)
class BaseApiHandler(NodeHandler[T]):
"""API Handler 基类"""
# 子类需要设置这些属性
_node_type: str
_node_class: type['SyncNode']
_schema: Any
def __init__(self):
# 子类应该在调用 super().__init__() 之前或之后设置 _node_type, _node_class, _schema
self.api_client: 'ApiClient' = None # type: ignore # 由 DataSource 注入
self.poll_mode: str = "async" # async | sync
self._collection: 'DataCollection' = None # type: ignore # 由 DataSource 注入
# 子类在 __init__ 中设置此列表以声明 create/update 中涉及的 Pydantic schema 类;
# post-check 将只比较这些 schema 覆盖的字段,过滤后端只读的派生字段。
self.update_schemas: List[type] = []
def get_update_fields(self, *, exclude: frozenset = frozenset({'id'})) -> List[str]:
"""从 update_schemas 中提取所有可写字段名,排除标识键(默认排除 id)。"""
fields: set = set()
for schema in self.update_schemas:
for field_name in get_pydantic_model_fields(schema):
if field_name not in exclude:
fields.add(field_name)
return sorted(fields)
def set_api_client(self, client: 'ApiClient') -> None:
self.api_client = client
def set_collection(self, collection: 'DataCollection') -> None:
"""设置 Collection(由 DataSource 调用)"""
self._collection = collection
@property
def node_type(self) -> str:
"""返回节点类型"""
return self._node_type
@property
def node_class(self) -> type['SyncNode']:
"""返回节点类"""
return self._node_class
@property
def schema(self) -> Any:
"""返回 schema(子类可重写)"""
return self._schema
def _generate_push_id(self) -> str:
"""
生成 push_id(推送唯一标识)
Returns:
UUID 格式的 push_id
"""
return str(uuid.uuid4())
def _generate_biz_id(self, node: 'SyncNode') -> str:
"""
生成 biz_id(业务标识)
子类可重写此方法实现自定义业务 ID 生成逻辑。
默认使用 node_id 作为 biz_id。
Args:
node: 同步节点
Returns:
业务标识符
"""
return node.node_id
def _create_basic_node(self, data: Dict[str, Any], depend_ids: Optional[List[str]] = None) -> "SyncNode":
"""创建或复用基础节点(默认使用 data['id'] 或 data['_id']"""
data_id = str(data.get("id") or data.get("_id") or "")
if self._collection is None:
raise RuntimeError("Collection not set. Call set_collection() before creating nodes.")
if data_id:
existing_node = self._collection.get_by_data_id(self.node_type, data_id)
if existing_node is not None:
existing_node.depend_ids = list(depend_ids or [])
existing_node.set_data(data.copy())
existing_node.set_origin_data(data.copy())
return existing_node
node_id = self._collection.generate_node_id()
node = self.node_class(
node_id=node_id,
data_id=data_id,
data=data.copy(),
depend_ids=depend_ids or [],
origin_data=data.copy(),
)
return node
def _create_node(self, data: Dict[str, Any]) -> "SyncNode":
"""默认节点创建(子类可覆盖并补充 context)"""
return self._create_basic_node(data)
def extract_id(self, data: Dict[str, Any]) -> str:
"""从原始数据提取 ID(默认优先 id,其次 _id)"""
return str(data.get("id") or data.get("_id") or "")
def extract_created_id(self, push_log: Dict[str, Any]) -> Optional[str]:
"""
从 push_log 提取 created_id
子类可重写此方法以适配不同的响应结构。
默认从 push_log['data']['biz_id'] 提取。
Args:
push_log: Push Log 响应数据
Returns:
创建的对象 ID,未找到返回 None
"""
try:
data = push_log.get('data', {})
if isinstance(data, dict):
return data.get('biz_id')
return None
except (KeyError, AttributeError):
return None
def set_poll_mode(self, mode: str) -> None:
"""设置轮询模式(sync/async"""
if mode not in {"sync", "async"}:
raise ValueError(f"Unsupported poll mode: {mode}")
self.poll_mode = mode
def _extract_created_id_from_response(self, response: Dict[str, Any]) -> Optional[str]:
"""从同步创建响应提取创建的 ID(默认 data.biz_id"""
data = response.get("data") if isinstance(response, dict) else None
if isinstance(data, dict):
return data.get("biz_id")
return None
def _build_create_task_result(
self,
node_id: str,
response: Optional[Dict[str, Any]] = None,
task_id: Optional[str] = None,
fallback_data_id: Optional[str] = None,
) -> TaskResult:
"""根据轮询模式构建 create 的 TaskResult"""
if self.poll_mode == "sync":
created_id = None
if response is not None:
created_id = self._extract_created_id_from_response(response)
if not created_id:
created_id = fallback_data_id
return TaskResult.success(node_id=node_id, data_id=created_id)
if not task_id:
return TaskResult.failed(node_id=node_id, error="Missing task_id for async create")
return TaskResult.in_progress(node_id=node_id, task_id=task_id)
# ========== 通用辅助方法 ==========
def _validate_schema(self, data: Dict[str, Any], schema: Any) -> Optional[str]:
"""
验证数据是否符合 schema
Args:
data: 待验证数据
schema: Pydantic schema
Returns:
错误信息,无错误返回 None
"""
try:
schema.model_validate(data)
return None
except Exception as e:
return str(e)
def _get_schema_diff(
self,
schema,
data: Dict[str, Any],
origin_data: Dict[str, Any],
node_id: Optional[str] = None
) -> Dict[str, Any] | None:
"""
获取数据在指定 schema 下的差异字段
Args:
schema: Pydantic schema 类
data: 新数据
origin_data: 原始数据
node_id: 节点ID(用于日志)
Returns:
Dict[str, Any] | None: 有差异返回差异字段字典,无差异或字段不全返回 None
"""
from pydantic import ValidationError
try:
new_model = schema.model_validate(data)
origin_model = schema.model_validate(origin_data)
new_dict = new_model.model_dump(exclude_unset=True)
origin_dict = origin_model.model_dump(exclude_unset=True)
if new_dict == origin_dict:
return None
# 返回有差异的字段
diff_fields = {k: v for k, v in new_dict.items() if origin_dict.get(k) != v}
if diff_fields:
# 打印差异详情
node_info = f"[{node_id}]" if node_id else ""
print(f" [DIFF] {self.node_type}{node_info} schema={schema.__name__}")
for k, v in diff_fields.items():
orig_v = origin_dict.get(k)
print(f"{k}: {orig_v!r}{v!r}")
return diff_fields if diff_fields else None
except ValidationError:
# schema 字段不全,视为无差异
return None
def _filter_update_data(
self,
new_data: Dict[str, Any],
original_data: Dict[str, Any],
schema: Optional[Any] = None
) -> Dict[str, Any]:
"""
过滤出需要更新的字段
规则:
1. 提取 schema 定义的所有字段(包括必填)
2. 检查是否有任何字段发生变化
3. 如果有变化,返回所有 schema 字段;否则返回空
Args:
new_data: 新数据
original_data: 原始数据
schema: 更新 schema(可选)
Returns:
包含 schema 所有字段的数据(如果有变化),否则返回空字典
"""
schema_fields = get_pydantic_model_fields(schema)
if not schema_fields:
# 无 schema,按原逻辑处理
update_data = {}
for key, new_value in new_data.items():
if new_value == "" or new_value is None:
continue
original_value = original_data.get(key)
if new_value != original_value:
update_data[key] = new_value
return update_data
# 1. 提取 schema 定义的所有字段
schema_fields = set(schema_fields.keys())
schema_data = {}
has_change = False
for key in schema_fields:
new_value = new_data.get(key)
# 排除空值
if new_value == "" or new_value is None:
continue
schema_data[key] = new_value
# 检查是否变化
original_value = original_data.get(key)
if new_value != original_value:
# 记录具体差异以便排查
# print(f" [DEBUG] Field '{key}' changed: '{original_value}' -> '{new_value}'")
has_change = True
# 2. 只有存在变化时才返回数据
return schema_data if has_change else {}
def _schema_changed_fields(
self,
schema: Any,
new_data: Dict[str, Any],
origin_data: Dict[str, Any],
) -> List[str]:
schema_fields = get_pydantic_model_fields(schema)
if not schema_fields:
return []
changed: List[str] = []
for field_name in schema_fields.keys():
if new_data.get(field_name) != origin_data.get(field_name):
changed.append(field_name)
return changed
def _build_update_schema_status(
self,
*,
schema_name: str,
schema: Any,
new_data: Dict[str, Any],
origin_data: Dict[str, Any],
will_update: bool,
update_error: Optional[str] = None,
) -> str:
new_error = self._validate_schema(new_data, schema)
origin_error = self._validate_schema(origin_data, schema)
changed_fields = self._schema_changed_fields(schema, new_data, origin_data)
if new_error:
compact = new_error.replace("\n", " ").strip()
if len(compact) > 220:
compact = compact[:220] + "..."
return f"{schema_name}: NEW_INVALID({compact})"
if not changed_fields:
return f"{schema_name}: CONSISTENT"
changed_text = ",".join(changed_fields)
if update_error:
compact = update_error.replace("\n", " ").strip()
if len(compact) > 220:
compact = compact[:220] + "..."
return f"{schema_name}: DIFF[{changed_text}] -> UPDATE_FAILED({compact})"
if will_update:
if origin_error:
compact = origin_error.replace("\n", " ").strip()
if len(compact) > 160:
compact = compact[:160] + "..."
return f"{schema_name}: DIFF[{changed_text}] -> UPDATE_TRIGGERED (origin_invalid={compact})"
return f"{schema_name}: DIFF[{changed_text}] -> UPDATE_TRIGGERED"
if origin_error:
compact = origin_error.replace("\n", " ").strip()
if len(compact) > 160:
compact = compact[:160] + "..."
return f"{schema_name}: DIFF[{changed_text}] -> NOT_TRIGGERED (origin_invalid={compact})"
return f"{schema_name}: DIFF[{changed_text}] -> NOT_TRIGGERED"
async def _execute_with_retry(
self,
operation,
max_retries: int = 3,
retry_delay: float = 1.0
) -> Any:
"""
通用重试逻辑
子类可重写此方法实现自定义重试策略。
默认策略:网络错误/5xx 重试,4xx 不重试。
Args:
operation: 异步操作(协程函数)
max_retries: 最大重试次数
retry_delay: 重试延迟(秒)
Returns:
操作执行结果
Raises:
最后一次重试的异常
"""
import httpx
last_error = None
for attempt in range(max_retries):
try:
return await operation()
except httpx.HTTPStatusError as e:
# 4xx 客户端错误不重试
if 400 <= e.response.status_code < 500:
raise
last_error = e
if attempt < max_retries - 1:
await asyncio.sleep(retry_delay * (attempt + 1))
except (httpx.NetworkError, httpx.TimeoutException) as e:
# 网络错误重试
last_error = e
if attempt < max_retries - 1:
await asyncio.sleep(retry_delay * (attempt + 1))
# 重试次数用尽
if last_error:
raise last_error
else:
raise RuntimeError("Retry failed with unknown error")
# ========== 批量接口(子类必须实现) ==========
@abstractmethod
async def create_all(self, nodes: List['SyncNode']) -> List[TaskResult]:
"""
批量创建节点
Args:
nodes: 需要创建的节点列表(DataSource 已筛选为 CREATE + PENDING
Returns:
List[TaskResult],每个结果关联 node_id
"""
pass
@abstractmethod
async def update_all(self, nodes: List['SyncNode']) -> List[TaskResult]:
"""
批量更新节点
Args:
nodes: 需要更新的节点列表(DataSource 已筛选为 UPDATE + PENDING
Returns:
List[TaskResult],每个结果关联 node_id
"""
pass
@abstractmethod
async def delete_all(self, nodes: List['SyncNode']) -> List[TaskResult]:
"""
批量删除节点
Args:
nodes: 需要删除的节点列表(DataSource 已筛选为 DELETE + PENDING
Returns:
List[TaskResult],每个结果关联 node_id
"""
pass
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
"""
批量轮询异步任务状态
Args:
task_ids: 任务 ID 列表(push_id 列表)
Returns:
{task_id: TaskResult} 映射
"""
if not task_ids:
return {}
if not self.api_client:
raise RuntimeError("api_client not injected")
# 批量查询 push_logs
try:
push_logs = await self.api_client.get_push_logs(task_ids)
except Exception as e:
# 查询失败,所有任务标记为失败
return {
task_id: TaskResult.failed(error=f"Poll failed: {str(e)}")
for task_id in task_ids
}
results = {}
for task_id in task_ids:
push_log = push_logs.get(task_id, {})
if 'error' in push_log:
results[task_id] = TaskResult.failed(error=push_log['error'])
elif push_log.get('code') == 200: # 通过code==200判断成功
created_id = self.extract_created_id(push_log)
results[task_id] = TaskResult.success(data_id=created_id)
elif push_log.get('code') and push_log.get('code') != 200: # code存在但不是200表示失败
results[task_id] = TaskResult.failed(error=push_log.get('message', 'Unknown error'))
else:
# 仍在进行中
results[task_id] = TaskResult.in_progress(task_id=task_id)
return results
async def validate(self, data: Dict[str, Any]) -> ValidationResult:
"""
数据质量检查(使用 schema 验证)
Args:
data: 原始数据
Returns:
ValidationResult(是否有效 + 错误列表)
"""
error = self._validate_schema(data, self.schema)
if error:
return ValidationResult(is_valid=False, errors=[error])
return ValidationResult(is_valid=True)
# ========== 抽象方法:子类必须实现 ==========
@abstractmethod
async def load(self) -> List['SyncNode']:
"""
从 API 加载数据并创建节点
Handler 负责:
1. 加载原始数据(可使用 self._collection 查询依赖)
2. 创建 SyncNode(调用 self.create_node
3. 设置 context(如 project_id, contract_id 等)
Returns:
SyncNode 列表
"""
pass
+962
View File
@@ -0,0 +1,962 @@
"""
DataSource V2 - 状态管理 + Handler 编排层
DataSource 职责:
1. 注册和管理 Handler
2. 按依赖顺序加载数据
3. 执行同步(调用 Handler
4. 管理节点状态流转(PENDING → IN_PROGRESS → SUCCESS/FAILED
5. 处理异步任务轮询
6. 统一异常处理
DataSource 不负责:
- ❌ 后端 API 调用(由 Handler 负责)
- ❌ ID 映射(由 Strategy 负责)
- ❌ 业务逻辑(由 Strategy 负责)
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any, List, Dict, Optional, TYPE_CHECKING
from abc import ABC
from pathlib import Path
if TYPE_CHECKING:
from ..common.collection import DataCollection
from .handler import NodeHandler
from ..common.sync_node import SyncNode
from ..common.types import SyncAction
from .task_result import TaskStatus
from ..common.types import SyncStatus
from ..common.type_safety import get_pydantic_model_fields
from .handler import ensure_handler_compat
from .task_result import TaskResult
from ..engine import (
StateMachineConfig,
StateMachineRuntime,
e40_sync_execute,
)
logger = logging.getLogger(__name__)
class BaseDataSource(ABC):
"""
数据源基类 V2(状态管理 + 编排层)
核心流程:
1. 注册 Handler:每个节点类型对应一个 Handler
2. 加载数据:按依赖顺序调用 Handler.load()
3. 执行同步:根据 node.action 调用 Handler 的 create/update/delete
4. 状态管理:根据 TaskResult 更新 node.status
5. 异步轮询:处理 IN_PROGRESS 节点
示例:
# 1. 初始化 DataSource
datasource = MyDataSource()
# 2. 注册 Handler
datasource.register_handler(project_handler)
datasource.register_handler(contract_handler)
# 3. 设置 Collection
datasource.set_collection(collection)
# 4. 加载数据(按顺序)
await datasource.load_all(order=["project", "contract"])
# 5. 执行同步(按顺序)
await datasource.sync_all(order=["project", "contract"])
"""
def __init__(self, poll_max_retries: int = 1, poll_interval: float = 0.5):
self._handlers: Dict[str, "NodeHandler"] = {}
self._collection: Optional["DataCollection"] = None
self._target_project_ids: List[str] = []
# 按节点类型统计信息
# {"company": {"loaded": 10, "synced": 10, "success": 10, "failed": 0}, ...}
self._stats: Dict[str, Dict[str, int]] = {}
# 记录本次 sync 的动作统计(CREATE/UPDATE/DELETE/NONE
self._action_summary: Dict[str, Dict[str, Dict[str, int]]] = {}
# 记录本次 sync 中的节点动作(用于异步完成后的统计)
self._action_by_node_id: Dict[str, "SyncAction"] = {}
self._poll_max_retries = max(1, poll_max_retries)
self._poll_interval = max(0, poll_interval)
self._sm_runtime: Optional[StateMachineRuntime] = None
def _ensure_sm_runtime(self) -> StateMachineRuntime:
if self._sm_runtime is None:
cfg_path = Path(__file__).resolve().parents[1] / "config" / "node_state_machine.yaml"
self._sm_runtime = StateMachineRuntime(StateMachineConfig.load(cfg_path))
return self._sm_runtime
def set_state_machine_runtime(self, runtime: StateMachineRuntime) -> None:
"""注入统一状态机 runtime(通常由 pipeline 负责)。"""
self._sm_runtime = runtime
def _apply_sync_execute_state(
self,
node: "SyncNode",
*,
handler_status: "TaskStatus",
poll_timeout: bool = False,
detail: str = "",
action_override: Optional["SyncAction"] = None,
result_data_id: Optional[str] = None,
) -> bool:
action_for_event = action_override or node.action
decision = e40_sync_execute(
self._ensure_sm_runtime(),
node=node,
handler_result=handler_status.name,
action=action_for_event.value,
poll_timeout=poll_timeout,
result_data_id=result_data_id,
)
if decision is None:
if detail:
node.append_log(
f"执行状态未变更: action={action_for_event.value}, result={handler_status.name}, detail={detail}"
)
return False
if detail:
node.append_log(
f"执行状态写回: action={action_for_event.value}, result={handler_status.name}, detail={detail}"
)
return True
def _compose_node_error(self, node: "SyncNode", *, source: str, detail: str) -> str:
return (
f"{source} | node={node.node_id} | type={node.node_type} | "
f"action={node.action.value} | status={node.status.value} | detail={detail}"
)
def _on_node_loaded(self, handler: "NodeHandler", node: "SyncNode") -> None:
"""Datasource-specific hook when a loaded node is upserted into collection."""
return
async def _intercept_success_result(
self,
handler: "NodeHandler",
node: "SyncNode",
result: "TaskResult",
async_tasks: Dict[str, str],
) -> bool:
"""Datasource-specific hook to intercept SUCCESS result handling.
Return True if handled/consumed by subclass and no further default SUCCESS flow is needed.
"""
return False
def _on_in_progress_result(self, handler: "NodeHandler", node: "SyncNode", result: "TaskResult") -> None:
"""Datasource-specific hook after an IN_PROGRESS task is queued."""
return
def _on_poll_result(self, handler: "NodeHandler", node: "SyncNode", task_id: str, result: "TaskResult") -> None:
"""Datasource-specific hook after polling result is applied to node."""
return
def _on_failed_result(self, handler: "NodeHandler", node: "SyncNode", result: "TaskResult") -> None:
"""Datasource-specific hook after a FAILED result is applied to node."""
return
async def _handle_success_result(self, node: "SyncNode", result: "TaskResult") -> None:
previous_data_id = node.data_id
ok = self._apply_sync_execute_state(
node,
handler_status=result.status,
detail="远程操作成功完成",
result_data_id=result.data_id,
)
if not ok:
raise RuntimeError(f"[{node.node_type}] SUCCESS result cannot map to state machine: node={node.node_id}")
self._record_action_result(node, result.status)
await self._apply_success_result(node, result.data_id, previous_data_id=previous_data_id)
node.error = None
async def _handle_failed_result(self, node: "SyncNode", result: "TaskResult") -> None:
detail = result.error or "Unknown error"
ok = self._apply_sync_execute_state(
node,
handler_status=result.status,
detail=f"远程操作失败: {detail}",
)
if not ok:
raise RuntimeError(f"[{node.node_type}] FAILED result cannot map to state machine: node={node.node_id}")
self._record_action_result(node, result.status)
node.error = self._compose_node_error(node, source="handler_failed", detail=detail)
async def _handle_skipped_result(self, node: "SyncNode", result: "TaskResult") -> None:
from .task_result import TaskStatus as _TaskStatus
original_action = self._action_by_node_id.get(node.node_id)
if original_action is None or original_action.value != "UPDATE":
raise RuntimeError(
f"[{node.node_type}] SKIPPED is only allowed for UPDATE action: node={node.node_id}, action={original_action.value if original_action is not None else None}"
)
ok = self._apply_sync_execute_state(
node,
handler_status=_TaskStatus.SKIPPED,
detail="远程操作被跳过",
action_override=original_action,
)
if not ok:
raise RuntimeError(f"[{node.node_type}] SKIPPED result cannot map to state machine: node={node.node_id}")
self._record_action_result(node, _TaskStatus.SKIPPED)
if result.sync_log:
node.append_log(f"同步跳过: {result.sync_log}")
node.error = None
async def _handle_in_progress_result(
self,
node: "SyncNode",
result: "TaskResult",
async_tasks: Dict[str, str],
) -> None:
if not result.task_id:
node.error = self._compose_node_error(
node,
source="handler_in_progress_invalid",
detail="missing task_id",
)
node.append_log("异步任务返回无 task_id,已忽略")
return
if not result.node_id:
node.error = self._compose_node_error(
node,
source="handler_in_progress_invalid",
detail="missing node_id",
)
node.append_log("异步任务返回无 node_id,已忽略")
return
async_tasks[result.node_id] = result.task_id
ok = self._apply_sync_execute_state(
node,
handler_status=result.status,
detail=f"异步任务进行中: {result.task_id}",
)
if not ok:
node.append_log(f"异步任务进行中(无状态变更): {result.task_id}")
def set_polling_config(self, max_retries: int, interval: float = 0.5) -> None:
"""设置异步轮询参数(默认仅轮询一次)"""
self._poll_max_retries = max(1, max_retries)
self._poll_interval = max(0, interval)
def reset_stats(self) -> None:
"""重置统计信息"""
self._stats = {}
self._action_summary = {}
self._action_by_node_id = {}
def get_stats(self) -> Dict[str, Dict[str, int]]:
"""
获取按节点类型的统计信息
Returns:
dict: {"company": {"loaded": 10, "synced": 10, "success": 10, "failed": 0}, ...}
"""
return self._stats.copy()
def get_action_summary(self) -> Dict[str, Dict[str, Dict[str, int]]]:
"""
获取本次 sync 的动作统计(按节点类型)。
Returns:
dict: {
"company": {
"create": {"total": 10, "success": 8, "failed": 2},
"update": {"total": 2, "success": 2, "failed": 0},
"delete": {"total": 0, "success": 0, "failed": 0},
"none": {"total": 5, "success": 0, "failed": 0},
"total": {"total": 17, "success": 10, "failed": 2},
},
...
}
"""
return {k: {kk: vv.copy() for kk, vv in v.items()} for k, v in self._action_summary.items()}
def _init_node_stats(self, node_type: str) -> None:
"""初始化节点类型的统计信息"""
if node_type not in self._stats:
self._stats[node_type] = {
"loaded": 0,
"synced": 0,
"success": 0,
"failed": 0,
}
if node_type not in self._action_summary:
self._action_summary[node_type] = {
"create": {"total": 0, "success": 0, "failed": 0, "skipped": 0},
"update": {"total": 0, "success": 0, "failed": 0, "skipped": 0},
"delete": {"total": 0, "success": 0, "failed": 0, "skipped": 0},
"none": {"total": 0, "success": 0, "failed": 0, "skipped": 0},
"total": {"total": 0, "success": 0, "failed": 0, "skipped": 0},
}
def _set_action_totals(self, node_type: str, create: int, update: int, delete: int, none: int) -> None:
summary = self._action_summary[node_type]
summary["create"]["total"] = create
summary["update"]["total"] = update
summary["delete"]["total"] = delete
summary["none"]["total"] = none
summary["total"]["total"] = create + update + delete + none
def _record_action_result(self, node: "SyncNode", result_status: "TaskStatus") -> None:
from ..common.types import SyncAction
from .task_result import TaskStatus as _TaskStatus
# 情况 A: 原始动作就是 NONE
# 情况 B: Handler 运行时发现没差异,把 action 降级为了 NONE
action = node.action
# 获取最初锁定的动作(用于修正统计列)
original_action = self._action_by_node_id.pop(node.node_id, SyncAction.NONE)
summary = self._action_summary.get(node.node_type)
if summary is None:
return
# 如果当前 action 是 NONE,说明这次“同步”被取消了
if action == SyncAction.NONE:
# 如果原始动作不是 NONE,说明发生了降级动作,我们需要把 total 从对应列挪到 none 列
if original_action != SyncAction.NONE:
orig_key = original_action.value.lower()
if orig_key in summary and summary[orig_key]["total"] > 0:
summary[orig_key]["total"] -= 1
summary["none"]["total"] += 1
return
# 正常记录成功/失败/跳过
key = action.value.lower()
if result_status == _TaskStatus.SUCCESS:
summary[key]["success"] += 1
summary["total"]["success"] += 1
elif result_status == _TaskStatus.FAILED:
summary[key]["failed"] += 1
summary["total"]["failed"] += 1
elif result_status == _TaskStatus.SKIPPED:
summary[key]["skipped"] += 1
summary["total"]["skipped"] += 1
def set_collection(self, collection: "DataCollection") -> None:
"""
设置 Collection(在 load_all/sync_all 前调用)
Args:
collection: 数据集合
"""
self._collection = collection
collection.set_state_machine_runtime(self._ensure_sm_runtime())
# 同时设置到所有已注册的 handler
for handler in self._handlers.values():
handler.set_collection(collection)
# ========== Handler 管理 ==========
def register_handler(self, handler: "NodeHandler") -> None:
"""
注册 Handler
Args:
handler: 节点处理器实例
示例:
datasource.register_handler(ContractNodeHandler(api_client))
"""
compatible_handler = ensure_handler_compat(handler)
self._handlers[compatible_handler.node_type] = compatible_handler
self._apply_target_project_ids_to_handler(compatible_handler)
def set_target_project_ids(self, target_project_ids: Optional[List[str]]) -> None:
"""设置项目过滤列表,并在注册阶段下发给所有 handler。"""
normalized = [str(pid) for pid in (target_project_ids or []) if str(pid)]
self._target_project_ids = normalized
for handler in self._handlers.values():
self._apply_target_project_ids_to_handler(handler)
def _apply_target_project_ids_to_handler(self, handler: "NodeHandler") -> None:
handler.set_target_project_ids(self._target_project_ids)
def get_handler(self, node_type: str) -> "NodeHandler":
"""获取 Handler"""
if node_type not in self._handlers:
raise KeyError(f"Handler for '{node_type}' not registered")
return self._handlers[node_type]
async def _upsert_loaded_nodes(
self,
handler: "NodeHandler",
node_type: str,
nodes: List["SyncNode"]
) -> None:
"""将加载的节点写入 Collection(按 data_id 优先命中已有节点)"""
if self._collection is None:
raise RuntimeError("Collection not set. Call set_collection() first.")
for node in nodes:
existing_node = None
if node.data_id:
existing_node = self._collection.get_by_data_id(node_type, node.data_id)
load_source = f"{self.__class__.__name__}.load"
if existing_node is not None:
data_payload = node.get_data()
existing_node.depend_ids = list(node.depend_ids or [])
if node.context:
existing_node.context.update(node.context)
existing_node.set_data(data_payload)
existing_node.set_origin_data(data_payload)
if existing_node.context.pop("_loaded_from_persistence", False):
existing_node.append_log(f"加载来源: persistence -> {load_source} 覆盖刷新")
else:
existing_node.append_log(f"加载来源: {load_source} 覆盖刷新")
await self._collection.update(existing_node)
self._on_node_loaded(handler, existing_node)
else:
node.append_log(f"加载来源: {load_source} 新增节点")
await self._collection.add(node)
self._on_node_loaded(handler, node)
# ========== 数据加载 ==========
async def load_all(
self,
order: Optional[List[str]] = None,
data_type: Optional[str] = None
) -> None:
"""
按依赖顺序加载所有数据
注意:调用前需先 set_collection()
Args:
order: 节点类型顺序(如 ["project", "contract", "construction"])
data_type: 仅加载单一类型(与 order 互斥)
工作流程:
1. 按顺序遍历每个节点类型
2. 调用 Handler.load(collection)
3. Handler 可以从 collection 查询依赖节点(如 Contract 查询 Project
4. 使用 Handler.create_node() 创建 SyncNode
5. 添加到 Collection
示例:
datasource.set_collection(collection)
await datasource.load_all(order=["project", "contract"])
"""
if self._collection is None:
raise RuntimeError("Collection not set. Call set_collection() first.")
if data_type is not None:
order_to_load = [data_type]
elif order is not None:
order_to_load = order
else:
order_to_load = list(self._handlers.keys())
for node_type in order_to_load:
handler = self.get_handler(node_type)
# 初始化统计
self._init_node_stats(node_type)
try:
# Handler 加载数据并创建节点
nodes = await handler.load()
print(f"Loaded nodes for {node_type}: {len(nodes)}")
# 将节点写入 Collection:优先按 data_id 命中已有节点并更新
await self._upsert_loaded_nodes(handler, node_type, nodes)
# 统计加载的节点数
self._stats[node_type]["loaded"] += len(nodes)
except Exception as exc:
print(f"❌ [{node_type}] 加载失败: {exc}")
continue
# ========== 同步执行 ==========
async def sync_all(
self,
order: Optional[List[str]] = None,
data_type: Optional[str] = None,
poll_async_tasks: bool = True
) -> Dict[str, Dict[str, str]]:
"""
按依赖顺序执行同步
注意:调用前需先 set_collection()
Args:
order: 节点类型顺序
data_type: 仅同步单一类型(与 order 互斥)
工作流程:
1. 按顺序遍历每个节点类型
2. 筛选需要同步的节点(status=PENDING, action!=NONE
3. 调用 Handler 的 create/update/delete
4. 根据 TaskResult 更新节点状态
5. 处理异步任务轮询
状态流转:
- PENDING + action=CREATE/UPDATE/DELETE → IN_PROGRESS → SUCCESS/FAILED
- PENDING + action=NONE → 保持 PENDING
- DEPENDENCY_ERROR / ABNORMAL / WARNING → SKIPPED
示例:
datasource.set_collection(collection)
await datasource.sync_all(order=["project", "contract"])
"""
if self._collection is None:
raise RuntimeError("Collection not set. Call set_collection() first.")
if data_type is not None:
order_to_sync = [data_type]
elif order is not None:
order_to_sync = order
else:
order_to_sync = list(self._handlers.keys())
async_tasks_by_type: Dict[str, Dict[str, str]] = {}
for node_type in order_to_sync:
handler = self.get_handler(node_type)
# 批量同步该类型的所有节点
async_tasks = await self._sync_nodes(handler, poll_async_tasks=poll_async_tasks)
if async_tasks:
async_tasks_by_type[node_type] = async_tasks
return async_tasks_by_type
async def _sync_nodes(
self,
handler: "NodeHandler",
poll_async_tasks: bool = True
) -> Dict[str, str]:
"""
同步一批节点(批量接口)
Args:
handler: 节点处理器
流程:
1. DataSource 筛选需要处理的节点
2. 调用 handler.create_all(nodes)
3. 调用 handler.update_all(nodes)
4. 调用 handler.delete_all(nodes)
5. 根据 List[TaskResult] 更新节点状态
6. 收集并轮询异步任务
"""
from ..common.types import SyncAction, SyncStatus
from .task_result import TaskStatus as _TaskStatus
if self._collection is None:
raise RuntimeError("Collection not set. Call set_collection() first.")
collection = self._collection
# 初始化统计
self._init_node_stats(handler.node_type)
async_tasks: Dict[str, str] = {} # {node_id: task_id}
# DataSource 负责筛选需要创建的节点(排除已失败的节点)
create_nodes = [
n
for n in collection.filter_by_state_ids(
node_type=handler.node_type,
state_ids=["S06"],
)
if n.action == SyncAction.CREATE
]
# DataSource 负责筛选需要更新的节点(排除已失败的节点)
update_nodes = [
n
for n in collection.filter_by_state_ids(
node_type=handler.node_type,
state_ids=["S07"],
)
if n.action == SyncAction.UPDATE
]
# DataSource 负责筛选需要删除的节点(排除已失败的节点)
delete_nodes = [
n
for n in collection.filter_by_state_ids(
node_type=handler.node_type,
state_ids=["S09"],
)
if n.action == SyncAction.DELETE
]
if delete_nodes:
raise NotImplementedError(
f"[{handler.node_type}] delete path is not implemented yet, "
f"but found {len(delete_nodes)} node(s) in S09/DELETE."
)
# 记录本次 sync 的动作统计(在 action 被重置前)
for node in create_nodes:
self._action_by_node_id[node.node_id] = SyncAction.CREATE
for node in update_nodes:
self._action_by_node_id[node.node_id] = SyncAction.UPDATE
for node in delete_nodes:
self._action_by_node_id[node.node_id] = SyncAction.DELETE
none_count = len(
self._collection.filter(
node_type=handler.node_type,
node_filter={"action": SyncAction.NONE}
)
)
self._set_action_totals(
handler.node_type,
create=len(create_nodes),
update=len(update_nodes),
delete=len(delete_nodes),
none=none_count,
)
# 批量创建(传入节点列表)
if create_nodes:
logger.info(f"[{handler.node_type}] create提交开始: nodes={len(create_nodes)}")
try:
create_results = await handler.create_all(create_nodes)
create_success = 0
create_in_progress = 0
create_failed = 0
create_skipped = 0
for result in create_results:
if result.status == _TaskStatus.SUCCESS:
create_success += 1
elif result.status == _TaskStatus.IN_PROGRESS:
create_in_progress += 1
elif result.status == _TaskStatus.FAILED:
create_failed += 1
elif result.status == _TaskStatus.SKIPPED:
create_skipped += 1
await self._handle_task_result(handler, result, async_tasks)
logger.info(
f"[{handler.node_type}] create提交完成: success={create_success}, "
f"in_progress={create_in_progress}, failed={create_failed}, skipped={create_skipped}"
)
except Exception as e:
print(f"❌ [{handler.node_type}] create_all 异常: {e}")
for node in create_nodes:
ok = self._apply_sync_execute_state(
node,
handler_status=_TaskStatus.FAILED,
detail=str(e),
)
if not ok:
raise RuntimeError(f"[{handler.node_type}] create_all exception cannot map to state machine")
# 批量更新(传入节点列表)
if update_nodes:
try:
update_results = await handler.update_all(update_nodes)
for result in update_results:
await self._handle_task_result(handler, result, async_tasks)
except Exception as e:
print(f"❌ [{handler.node_type}] update_all 异常: {e}")
for node in update_nodes:
ok = self._apply_sync_execute_state(
node,
handler_status=_TaskStatus.FAILED,
detail=str(e),
)
if not ok:
raise RuntimeError(f"[{handler.node_type}] update_all exception cannot map to state machine")
# 批量删除(delete stage 当前未实现,检测到 S09 会在上方直接 fail-fast
# 轮询异步任务
if async_tasks and poll_async_tasks:
logger.info(
f"[{handler.node_type}] create已提交,push_id检查即将开始: pending={len(async_tasks)}, "
f"first_wait={self._poll_interval:.3f}s"
)
await self._poll_async_tasks(handler, async_tasks)
# 统计同步结果
all_nodes = self._collection.filter(node_type=handler.node_type)
synced_count = len([n for n in all_nodes if n.action != SyncAction.NONE])
success_count = len([n for n in all_nodes if n.status == SyncStatus.SUCCESS])
failed_count = len([n for n in all_nodes if n.status == SyncStatus.FAILED])
self._stats[handler.node_type]["synced"] = synced_count
self._stats[handler.node_type]["success"] = success_count
self._stats[handler.node_type]["failed"] = failed_count
return async_tasks
async def poll_async_tasks(self, async_tasks_by_type: Dict[str, Dict[str, str]]) -> None:
"""由 Pipeline 调度的异步任务轮询"""
for node_type, async_tasks in async_tasks_by_type.items():
if not async_tasks:
continue
handler = self.get_handler(node_type)
await self._poll_async_tasks(handler, async_tasks)
async def _handle_task_result(
self,
handler: "NodeHandler",
result: "TaskResult",
async_tasks: Dict[str, str]
) -> None:
"""
处理 Handler 返回的 TaskResult
Args:
collection: Collection 上下文
result: Handler 返回的结果(包含 node_id
async_tasks: 异步任务字典
"""
from .task_result import TaskStatus as _TaskStatus
if not result.node_id:
# 没有 node_id,无法定位节点
return
if self._collection is None:
# Collection 未设置,无法处理结果
return
# 从 self._collection 中查找节点
node = self._collection.get(result.node_id)
if not node:
return
if result.status == _TaskStatus.SUCCESS:
if await self._intercept_success_result(handler, node, result, async_tasks):
return
if result.status == _TaskStatus.SUCCESS:
await self._handle_success_result(node, result)
return
if result.status == _TaskStatus.FAILED:
self._on_failed_result(handler, node, result)
await self._handle_failed_result(node, result)
return
if result.status == _TaskStatus.SKIPPED:
await self._handle_skipped_result(node, result)
return
if result.status == _TaskStatus.IN_PROGRESS:
self._on_in_progress_result(handler, node, result)
await self._handle_in_progress_result(node, result, async_tasks)
return
def _write_back_data_id_to_data(self, node, data_id: str) -> None:
data = node.get_data()
if data is None:
return
schema = node.get_schema()
candidate_keys = ["id", "_id"]
model_fields = get_pydantic_model_fields(schema)
for key in candidate_keys:
if key in data:
data[key] = data_id
node.set_data(data)
return
if key in model_fields:
data[key] = data_id
node.set_data(data)
return
async def _apply_success_result(self, node, data_id: Optional[str], previous_data_id: str) -> None:
from ..common.types import SyncAction
if data_id and node.action == SyncAction.CREATE:
if node.data_id != data_id:
raise RuntimeError(
f"[{node.node_type}] data_id must be updated in event apply path: expected={data_id}, actual={node.data_id}"
)
self._write_back_data_id_to_data(node, data_id)
if self._collection is not None and data_id != previous_data_id:
await self._collection.update(node)
if node.action == SyncAction.UPDATE:
current_data = node.get_data()
if current_data is not None:
node.set_origin_data(current_data)
if node.action == SyncAction.DELETE:
if self._collection is None:
return
await self._collection.delete(node.node_id)
return
# 成功后 action 是否重置由上层策略决定
async def _poll_async_tasks(
self,
handler: "NodeHandler",
async_tasks: Dict[str, str]
) -> None:
"""
轮询异步任务直到全部完成
Args:
handler: 节点处理器
collection: Collection 上下文
async_tasks: {node_id: task_id} 映射
流程:
1. 批量调用 Handler.poll_tasks()
2. 更新节点状态
3. 如果还有未完成任务,等待后重试
"""
from .task_result import TaskStatus as _TaskStatus
max_retries = self._poll_max_retries
retry_count = 0
poll_interval = self._poll_interval # 轮询间隔(秒)
if self._collection is None:
# Collection 未设置,无法轮询任务
return
if async_tasks and poll_interval > 0:
logger.info(
f"[{handler.node_type}] push_id首次检查前等待 {poll_interval:.3f}s, pending={len(async_tasks)}"
)
await asyncio.sleep(poll_interval)
while async_tasks and retry_count < max_retries:
logger.info(
f"[{handler.node_type}] push_id检查 {retry_count + 1}/{max_retries}, pending={len(async_tasks)}"
)
# 批量查询
task_ids = list(async_tasks.values())
results = await handler.poll_tasks(task_ids)
# 处理结果
completed_nodes = []
for node_id, task_id in list(async_tasks.items()):
if task_id not in results:
continue
result = results[task_id]
node = self._collection.get(node_id)
if not node:
completed_nodes.append(node_id)
continue
if result.status == _TaskStatus.SUCCESS:
self._on_poll_result(handler, node, task_id, result)
previous_data_id = node.data_id
ok = self._apply_sync_execute_state(
node,
handler_status=result.status,
detail=f"异步任务成功完成: {task_id}",
result_data_id=result.data_id,
)
if not ok:
raise RuntimeError(f"[{node.node_type}] async SUCCESS cannot map to state machine: node={node.node_id}")
self._record_action_result(node, result.status)
await self._apply_success_result(node, result.data_id, previous_data_id=previous_data_id)
node.error = None
logger.info(
f"[{handler.node_type}] polling成功: node={node.node_id}, push_id={task_id}, data_id={result.data_id or node.data_id}"
)
completed_nodes.append(node_id)
elif result.status == _TaskStatus.FAILED:
self._on_poll_result(handler, node, task_id, result)
ok = self._apply_sync_execute_state(
node,
handler_status=result.status,
detail=f"异步任务失败: {result.error}",
)
if not ok:
raise RuntimeError(f"[{node.node_type}] async FAILED cannot map to state machine: node={node.node_id}")
self._record_action_result(node, result.status)
node.error = result.error
completed_nodes.append(node_id)
# IN_PROGRESS 继续等待
# 移除已完成的任务
for node_id in completed_nodes:
async_tasks.pop(node_id, None)
# 还有未完成任务,等待后重试
if async_tasks:
retry_count += 1
# 超时未完成的任务标记为失败
if async_tasks:
for node_id in async_tasks:
node = self._collection.get(node_id)
if node:
timeout_msg = f"异步任务超时 (等待 {max_retries * poll_interval} 秒后仍未完成)"
ok = self._apply_sync_execute_state(
node,
handler_status=_TaskStatus.FAILED,
poll_timeout=True,
detail=timeout_msg,
)
if not ok:
raise RuntimeError(f"[{node.node_type}] poll timeout cannot map to state machine: node={node.node_id}")
node.error = f"Async task timeout after {max_retries * poll_interval} seconds"
self._record_action_result(node, _TaskStatus.FAILED)
# ========== 统计信息 ==========
def get_sync_summary(self, collection: "DataCollection") -> Dict[str, Any]:
"""
获取同步统计信息
Returns:
{
"total": 总节点数,
"success": 成功数,
"failed": 失败数,
"in_progress": 进行中,
"skipped": 跳过数,
"by_type": {...} # 按类型统计
}
"""
nodes = list(collection._nodes.values())
summary = {
"total": len(nodes),
"success": sum(1 for n in nodes if n.status == SyncStatus.SUCCESS),
"failed": sum(1 for n in nodes if n.status == SyncStatus.FAILED),
"in_progress": sum(1 for n in nodes if n.status == SyncStatus.IN_PROGRESS),
"skipped": sum(1 for n in nodes if n.status == SyncStatus.SKIPPED),
"by_type": {}
}
# 按类型统计
for node_type in self._handlers.keys():
type_nodes = collection.filter(node_type=node_type)
summary["by_type"][node_type] = {
"total": len(type_nodes),
"success": sum(1 for n in type_nodes if n.status == SyncStatus.SUCCESS),
"failed": sum(1 for n in type_nodes if n.status == SyncStatus.FAILED),
}
return summary
async def close(self) -> None:
"""关闭数据源资源(子类可覆盖以实现特定的清理逻辑)"""
pass # 基类默认空实现
+579
View File
@@ -0,0 +1,579 @@
"""
节点处理器 V2 (Node Handler - Pure Execution Layer)
Handler 职责:
1. 执行后端 API 调用(load/create/update/delete
2. 返回执行结果(TaskResult
3. 提供异步任务轮询(poll
4. 提供数据质量检查(validate)
Handler 不负责:
- ❌ 状态管理(由 DataSource 负责)
- ❌ ID 映射(由 Strategy 负责,已保存在 node.data
- ❌ 依赖关系(由 Strategy 负责)
"""
from __future__ import annotations
from typing import (
TypeVar, Generic, Type, Protocol, Dict, List, Any, Optional,
runtime_checkable, TYPE_CHECKING
)
from abc import ABC, abstractmethod
from pydantic import BaseModel
from .task_result import TaskResult
if TYPE_CHECKING:
from ..common.sync_node import SyncNode
from ..common.collection import DataCollection
from .api.client import ApiClient
T = TypeVar("T", bound=BaseModel)
class ValidationResult:
"""数据质量检查结果"""
def __init__(self, is_valid: bool, errors: Optional[List[str]] = None):
self.is_valid = is_valid
self.errors = errors or []
@runtime_checkable
class NodeHandler(Protocol[T]):
"""
节点处理器协议(纯执行层)
Handler 只负责调用后端 API,不管理状态。
"""
@property
def node_type(self) -> str:
"""节点类型(如 'contract'"""
...
@property
def node_class(self) -> Type["SyncNode[T]"]:
"""SyncNode 类型"""
...
@property
def schema(self) -> Type[T]:
"""Schema 类型"""
...
def set_collection(self, collection: "DataCollection") -> None:
"""
设置 Collection 引用
Args:
collection: 数据集合
"""
...
def set_target_project_ids(self, target_project_ids: List[str]) -> None:
"""注入项目过滤列表。默认可忽略。"""
...
def set_api_client(self, client: "ApiClient") -> None:
"""注入 API 客户端。默认可忽略。"""
...
def set_poll_mode(self, mode: str) -> None:
"""设置轮询模式。默认可忽略。"""
...
def get_update_fields(self, *, exclude: frozenset[str] = frozenset({"id"})) -> List[str]:
"""返回 post-check 可比较字段列表。默认空。"""
...
async def load(self) -> List['SyncNode']:
"""
从后端加载全部数据并创建节点
注意:调用前需先 set_collection()
Returns:
SyncNode 列表
示例:
# Project Handler(无依赖)
async def load(self):
response = await self.api.get_projects()
nodes = []
for data in response["data"]:
node = self.create_node(data)
node.context["project_id"] = data["id"]
nodes.append(node)
return nodes
# Contract Handler(依赖 Project
async def load(self):
# 使用 self._collection 查询依赖
projects = self._collection.filter(node_type="project")
project_ids = [p.data_id for p in projects if p.data_id]
nodes = []
for project_id in project_ids:
response = await self.api.get_contracts(project_id=project_id)
for data in response["data"]:
node = self.create_node(data)
node.context["project_id"] = project_id
node.context["contract_id"] = data["id"]
nodes.append(node)
return nodes
"""
...
async def create_all(
self,
nodes: List["SyncNode"]
) -> List[TaskResult]:
"""
批量创建节点
Args:
nodes: 需要创建的节点列表(DataSource 已筛选为 CREATE + PENDING
Returns:
List[TaskResult]:
每个 TaskResult 必须包含 node_id,用于定位节点:
- TaskResult(status=SUCCESS, node_id="xxx", data_id="yyy")
- TaskResult(status=FAILED, node_id="xxx", error="...")
- TaskResult(status=IN_PROGRESS, node_id="xxx", task_id="...")
示例:
# JSONL Handler
async def create_all(self, nodes):
results = []
for node in nodes:
try:
new_id = str(uuid4())
data = node.get_data()
# ... 存储逻辑 ...
results.append(TaskResult.success(
node_id=node.node_id,
data_id=new_id
))
except Exception as e:
results.append(TaskResult.failed(
node_id=node.node_id,
error=str(e)
))
return results
# API Handler - 查询依赖
async def create_all(self, nodes):
results = []
for node in nodes:
# 查询依赖数据(如合同创建时查项目)
project_id = node.context.get("project_id")
projects = self._collection.filter(
node_type="project",
node_filter={"data_id": project_id}
)
if not projects:
results.append(TaskResult.failed(
node_id=node.node_id,
error=f"Project {project_id} not found"
))
continue
# 构造请求数据
data = node.get_data()
if not data:
results.append(TaskResult.failed(
node_id=node.node_id,
error="node data is None"
))
continue
contract_data = {
"project_id": projects[0].data_id,
**data
}
# 调用 API
response = await self.api.create_contract(contract_data)
results.append(TaskResult.success(
node_id=node.node_id,
data_id=response["id"]
))
return results
"""
...
async def update_all(
self,
nodes: List["SyncNode"]
) -> List[TaskResult]:
"""
批量更新节点
Args:
nodes: 需要更新的节点列表(DataSource 已筛选为 UPDATE + PENDING
Returns:
List[TaskResult](同 create_all
"""
...
async def delete_all(
self,
nodes: List["SyncNode"]
) -> List[TaskResult]:
"""
批量删除节点
Args:
nodes: 需要删除的节点列表(DataSource 已筛选为 DELETE + PENDING
Args:
collection: Collection 上下文
Returns:
List[TaskResult](同 create_all
"""
...
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
"""
批量轮询异步任务状态
Args:
task_ids: 任务 ID 列表
Returns:
{task_id: TaskResult} 字典
- SUCCESS: 任务完成,返回 data_id
- FAILED: 任务失败,返回 error
- IN_PROGRESS: 仍在进行中
示例:
async def poll_tasks(self, task_ids):
response = await self.api.batch_query_tasks(task_ids)
results = {}
for task in response["tasks"]:
if task["status"] == "completed":
results[task["id"]] = TaskResult.success(
data_id=task["result"]["contract_id"]
)
elif task["status"] == "failed":
results[task["id"]] = TaskResult.failed(
error=task["error"]
)
else: # running
results[task["id"]] = TaskResult.in_progress(
task_id=task["id"]
)
return results
"""
...
async def validate(self, data: Dict[str, Any]) -> ValidationResult:
"""
数据质量检查
Args:
data: 原始数据
Returns:
ValidationResult(是否有效 + 错误列表)
示例:
async def validate(self, data):
errors = []
if not data.get("code"):
errors.append("合同编码为空")
if not data.get("project_id"):
errors.append("项目 ID 为空")
return ValidationResult(
is_valid=len(errors) == 0,
errors=errors
)
"""
...
def extract_id(self, data: Dict[str, Any]) -> str:
"""
从原始数据提取 ID
Args:
data: 原始数据
Returns:
业务主键
"""
...
def _create_node(self, data: Dict[str, Any]) -> "SyncNode[T]":
"""
从原始数据创建 SyncNode
Args:
data: 原始数据
Returns:
类型化的 SyncNode
"""
...
class BaseNodeHandler(ABC, Generic[T]):
"""
节点处理器基类
子类需实现:
- load()
- create()
- update()
- delete()(可选)
- poll_tasks()(如果支持异步)
- validate()(可选)
- extract_id()
"""
def __init__(
self,
node_type: str,
node_class: Type["SyncNode[T]"],
schema: Type[T]
):
self._node_type = node_type
self._node_class = node_class
self._schema = schema
self._collection: Optional["DataCollection"] = None
def set_collection(self, collection: "DataCollection") -> None:
"""
设置 Collection 引用
Args:
collection: 数据集合
"""
self._collection = collection
def set_target_project_ids(self, target_project_ids: List[str]) -> None:
"""默认忽略项目过滤能力。"""
return
def set_api_client(self, client: "ApiClient") -> None:
"""默认忽略 API 客户端注入。"""
return
def set_poll_mode(self, mode: str) -> None:
"""默认忽略轮询模式。"""
return
def get_update_fields(self, *, exclude: frozenset[str] = frozenset({"id"})) -> List[str]:
"""默认无 post-check 字段白名单。"""
return []
@property
def node_type(self) -> str:
return self._node_type
@property
def node_class(self) -> Type["SyncNode[T]"]:
return self._node_class
@property
def schema(self) -> Type[T]:
return self._schema
# ========== 必须实现 ==========
@abstractmethod
async def load(self) -> List['SyncNode']:
"""
从数据源加载数据并创建节点
Handler 负责:
1. 加载原始数据
2. 创建 SyncNode(调用 create_node
3. 设置 context(如 project_id, contract_id 等)
Returns:
SyncNode 列表
"""
pass
@abstractmethod
async def create_all(
self,
nodes: List["SyncNode"]
) -> List[TaskResult]:
"""批量创建节点"""
pass
@abstractmethod
async def update_all(
self,
nodes: List["SyncNode"]
) -> List[TaskResult]:
"""批量更新节点"""
pass
@abstractmethod
def extract_id(self, data: Dict[str, Any]) -> str:
"""提取 ID"""
pass
# ========== 可选实现 ==========
async def delete_all(
self,
nodes: List["SyncNode"]
) -> List[TaskResult]:
"""批量删除节点(默认不支持)"""
return [
TaskResult.failed(node_id=node.node_id, error="Delete operation not supported")
for node in nodes
]
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
"""轮询异步任务(默认不支持)"""
return {
task_id: TaskResult.failed(error="Async tasks not supported")
for task_id in task_ids
}
async def validate(self, data: Dict[str, Any]) -> ValidationResult:
"""数据质量检查(默认不检查)"""
return ValidationResult(is_valid=True)
def create_node(self, data: Dict[str, Any]) -> "SyncNode[T]":
"""创建或复用 SyncNode(默认实现)"""
data_id = self.extract_id(data)
if self._collection is None:
raise RuntimeError("Collection not set. Call set_collection() before create_node().")
if data_id:
existing_node = self._collection.get_by_data_id(self.node_type, data_id)
if existing_node is not None:
existing_node.set_data(data.copy())
existing_node.set_origin_data(data.copy())
return existing_node
node_id = self._collection.generate_node_id()
return self.node_class(
node_id=node_id,
data_id=data_id,
data=data.copy()
)
def _create_node(self, data: Dict[str, Any]) -> "SyncNode[T]":
"""兼容 NodeHandler 协议的默认实现"""
return self.create_node(data)
class CompatibleNodeHandler(Generic[T]):
"""兼容旧式 Handler,实现扩展后的可选接口并委托给原对象。"""
def __init__(self, handler: NodeHandler[T]):
self._handler = handler
@property
def node_type(self) -> str:
return self._handler.node_type
@property
def node_class(self) -> Type["SyncNode[T]"]:
return self._handler.node_class
@property
def schema(self) -> Type[T]:
return self._handler.schema
def set_collection(self, collection: "DataCollection") -> None:
self._handler.set_collection(collection)
def set_target_project_ids(self, target_project_ids: List[str]) -> None:
method = getattr(self._handler, "set_target_project_ids", None)
if callable(method):
method(target_project_ids)
def set_api_client(self, client: "ApiClient") -> None:
method = getattr(self._handler, "set_api_client", None)
if callable(method):
method(client)
def set_poll_mode(self, mode: str) -> None:
method = getattr(self._handler, "set_poll_mode", None)
if callable(method):
method(mode)
def get_update_fields(self, *, exclude: frozenset[str] = frozenset({"id"})) -> List[str]:
method = getattr(self._handler, "get_update_fields", None)
if callable(method):
return method(exclude=exclude)
return []
async def load(self) -> List["SyncNode"]:
return await self._handler.load()
async def create_all(self, nodes: List["SyncNode"]) -> List[TaskResult]:
return await self._handler.create_all(nodes)
async def update_all(self, nodes: List["SyncNode"]) -> List[TaskResult]:
return await self._handler.update_all(nodes)
async def delete_all(self, nodes: List["SyncNode"]) -> List[TaskResult]:
return await self._handler.delete_all(nodes)
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
return await self._handler.poll_tasks(task_ids)
async def validate(self, data: Dict[str, Any]) -> ValidationResult:
return await self._handler.validate(data)
def extract_id(self, data: Dict[str, Any]) -> str:
return self._handler.extract_id(data)
def _create_node(self, data: Dict[str, Any]) -> "SyncNode[T]":
return self._handler._create_node(data)
def __getattr__(self, item: str) -> Any:
return getattr(self._handler, item)
def ensure_handler_compat(handler: NodeHandler[T]) -> NodeHandler[T]:
if isinstance(handler, BaseNodeHandler) or isinstance(handler, CompatibleNodeHandler):
return handler
return CompatibleNodeHandler(handler)
class NodeHandlerRegistry:
"""节点处理器注册表"""
_handlers: Dict[str, NodeHandler] = {}
@classmethod
def register(cls, node_type: str, handler: NodeHandler) -> None:
"""注册 handler"""
cls._handlers[node_type] = handler
@classmethod
def get(cls, node_type: str) -> NodeHandler:
"""获取 handler"""
if node_type not in cls._handlers:
raise KeyError(f"Handler for '{node_type}' not registered")
return cls._handlers[node_type]
@classmethod
def has(cls, node_type: str) -> bool:
"""检查是否已注册"""
return node_type in cls._handlers
@classmethod
def clear(cls) -> None:
"""清空注册表"""
cls._handlers.clear()
@classmethod
def list_handlers(cls) -> List[str]:
"""列出所有已注册的 node_type"""
return list(cls._handlers.keys())
@@ -0,0 +1,13 @@
"""
JSONL DataSource 模块
提供基于 JSONL 文件的数据源实现,用于测试和本地开发。
"""
from .datasource import JsonlDataSource
from .handler import BaseJsonlHandler
__all__ = [
"JsonlDataSource",
"BaseJsonlHandler",
]
@@ -0,0 +1,67 @@
"""
JsonlDataSource - JSONL 文件数据源
职责:
- 提供 JSONL 文件目录管理
- 实现 DataSource 接口(load_all, sync_all
- 管理内存缓存和文件写入
- 统一异常处理和状态管理
"""
from __future__ import annotations
from pathlib import Path
from typing import Dict, Any, Set
from ..datasource import BaseDataSource
class JsonlDataSource(BaseDataSource):
"""
JSONL 文件数据源
提供文件路径和基础功能,具体的文件查找和读取由各个 Handler 负责。
Args:
dir_path: JSONL 文件目录
read_only: 是否只读模式
示例:
datasource = JsonlDataSource(Path("filtered_datasource/datasource/ecm-2025-12-02"))
datasource.register_handler(ContractJsonlHandler(datasource))
collection = DataCollection()
datasource.set_collection(collection)
await datasource.load_all(order=["project", "contract"])
await datasource.sync_all(order=["project", "contract"])
"""
def __init__(self, dir_path: Path, read_only: bool = False):
super().__init__()
self.dir_path = Path(dir_path)
self.read_only = read_only
# 用于 create/update/delete 的内存缓存
self._data: Dict[str, Dict[str, Dict[str, Any]]] = {} # {node_type: {id: data}}
self._dirty_types: Set[str] = set()
def _on_node_loaded(self, handler, node) -> None:
node.append_log(
f"JSONL链路[load] dir={self.dir_path.as_posix()} node_type={node.node_type} data_id={node.data_id or '<empty>'}"
)
def _on_in_progress_result(self, handler, node, result) -> None:
node.append_log(
f"JSONL链路[submit] action={node.action.value} data_id={node.data_id or '<empty>'}"
)
def _on_poll_result(self, handler, node, task_id: str, result) -> None:
node.append_log(
f"JSONL链路[poll] task_id={task_id} status={result.status.value}"
)
# load_all 和 sync_all 继承自 BaseDataSource
# 保存逻辑在 BaseJsonlHandler 中实现
@@ -0,0 +1,350 @@
"""
BaseJsonlHandler - JSONL Handler 基类
职责:
- 查找和读取 JSONL 文件
- 实现批量 Handler 接口(load, create_all, update_all, delete_all
- 使用 datasource 的内存缓存
- 返回 List[TaskResult](统一状态管理)
- 捕获所有异常并转换为 TaskResult.failed()
"""
from __future__ import annotations
import json
import re
from typing import Dict, List, Any, TYPE_CHECKING, Optional, Set
from uuid import uuid4
from ..handler import BaseNodeHandler
from ..task_result import TaskResult
if TYPE_CHECKING:
from ...common.sync_node import SyncNode
from .datasource import JsonlDataSource
class BaseJsonlHandler(BaseNodeHandler):
"""
JSONL Handler 基类
负责查找和读取对应的 JSONL 文件。
示例:
class ContractJsonlHandler(BaseJsonlHandler):
def __init__(self, datasource: JsonlDataSource):
super().__init__(
datasource=datasource,
node_type="contract",
node_class=ContractSyncNode,
schema=ContractResponse
)
"""
def __init__(
self,
datasource: JsonlDataSource,
node_type: str,
node_class: type,
schema: type
):
super().__init__(node_type, node_class, schema)
self.datasource = datasource
self._target_project_ids: Optional[Set[str]] = None
def set_target_project_ids(self, target_project_ids: List[str]) -> None:
"""注入项目过滤列表,供 load 阶段预过滤使用。"""
if target_project_ids:
self._target_project_ids = {str(pid) for pid in target_project_ids if str(pid)}
else:
self._target_project_ids = None
def _should_skip_item_by_project_filter(self, item: Dict[str, Any], item_id: str) -> bool:
if not self._target_project_ids:
return False
if self.node_type == "project":
return item_id not in self._target_project_ids
project_id = item.get("project_id")
if project_id:
return str(project_id) not in self._target_project_ids
return False
async def load(self) -> List['SyncNode']:
"""
查找并加载对应的 JSONL 文件并创建节点
文件命名规则:{node_type}_N.jsonl (N 为数字)
例如:contract_1.jsonl, contract_2.jsonl
注意:load 操作的异常由 DataSource 的 load_all 捕获处理
"""
nodes = []
if not self.datasource.dir_path.exists():
return nodes
# 查找所有匹配的文件:{node_type}_N.jsonl
pattern = re.compile(rf"^{re.escape(self.node_type)}_\d+\.jsonl$")
for file_path in self.datasource.dir_path.iterdir():
if not pattern.match(file_path.name):
continue
# 读取文件
with open(file_path, "r", encoding="utf-8") as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
item = json.loads(line)
item_id = self.extract_id(item)
if self._should_skip_item_by_project_filter(item, item_id):
continue
# 创建节点
node = self.create_node(item)
nodes.append(node)
# 同时加载到缓存
if item_id:
bucket = self.datasource._data.setdefault(self.node_type, {})
bucket[item_id] = item
except json.JSONDecodeError as e:
print(f"Warning: Invalid JSON in {file_path.name}:{line_num}: {e}")
# 继续处理下一行
return nodes
def extract_id(self, data: Dict[str, Any]) -> str:
"""
提取数据 ID
默认实现:优先使用 'id',其次 '_id'
子类可重写此方法。
Args:
data: 数据字典
Returns:
数据 ID
"""
return str(data.get("id") or data.get("_id") or "")
async def create_all(
self,
nodes: List['SyncNode']
) -> List[TaskResult]:
"""
批量创建节点
Args:
nodes: 需要创建的节点列表(DataSource 已筛选为 CREATE + PENDING
Returns:
List[TaskResult],每个结果关联 node_id
"""
results = []
for node in nodes:
try:
# 生成新 ID(如果没有)
if not node.data_id:
new_id = str(uuid4())
else:
new_id = node.data_id
# 获取数据字典
# 使用 exclude_unset=False 以确保测试中的完整性校验(如 amount 字段)能通过
# 因为 JSONL 存储通常需要完整记录
data_dict = node.get_data(exclude_unset=False)
if data_dict is None:
results.append(TaskResult.failed(
node_id=node.node_id,
error="Cannot get data from node"
))
continue
# 设置 ID 和 type
data_dict["id"] = new_id
data_dict["_id"] = new_id
if "type" not in data_dict:
data_dict["type"] = self.node_type
# 存储到缓存
bucket = self.datasource._data.setdefault(self.node_type, {})
bucket[new_id] = data_dict
self.datasource._dirty_types.add(self.node_type)
results.append(TaskResult.success(
node_id=node.node_id,
data_id=new_id
))
except Exception as e:
results.append(TaskResult.failed(
node_id=node.node_id,
error=f"Create failed: {str(e)}"
))
# 自动保存到文件
await self._save_to_file()
return results
async def update_all(
self,
nodes: List['SyncNode']
) -> List[TaskResult]:
"""
批量更新节点
Args:
nodes: 需要更新的节点列表(DataSource 已筛选为 UPDATE + PENDING
Returns:
List[TaskResult],每个结果关联 node_id
"""
results = []
for node in nodes:
try:
if not node.data_id:
results.append(TaskResult.failed(
node_id=node.node_id,
error="Cannot update node without data_id"
))
continue
# 获取数据字典
# JSONL 更新通常也是完整记录替换,所以使用 exclude_unset=False
data_dict = node.get_data(exclude_unset=False)
if data_dict is None:
results.append(TaskResult.failed(
node_id=node.node_id,
error="Cannot get data from node"
))
continue
# 检查是否存在
bucket = self.datasource._data.get(self.node_type, {})
existing = bucket.get(node.data_id)
if existing is None:
results.append(TaskResult.failed(
node_id=node.node_id,
error=f"Item {node.data_id} not found"
))
continue
# 合并更新
existing.update(data_dict)
existing["id"] = node.data_id
existing["_id"] = node.data_id
if "type" not in existing:
existing["type"] = self.node_type
# 更新缓存
bucket[node.data_id] = existing
self.datasource._dirty_types.add(self.node_type)
results.append(TaskResult.success(
node_id=node.node_id,
data_id=node.data_id
))
except Exception as e:
results.append(TaskResult.failed(
node_id=node.node_id,
error=f"Update failed: {str(e)}"
))
# 自动保存到文件
await self._save_to_file()
return results
async def _save_to_file(self) -> None:
"""
将缓存数据写回 JSONL 文件
在批量操作(create_all/update_all/delete_all)后自动调用。
"""
if self.datasource.read_only:
return
if self.node_type not in self.datasource._dirty_types:
return
items = self.datasource._data.get(self.node_type, {})
if not items:
# 如果缓存为空,可能是删除了所有数据,仍需写入空文件
pass
try:
# 写入到 {node_type}_1.jsonl
file_path = self.datasource.dir_path / f"{self.node_type}_1.jsonl"
file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path, "w", encoding="utf-8") as f:
for item in items.values():
f.write(json.dumps(item, ensure_ascii=False) + "\n")
# 清除该类型的 dirty 标记
self.datasource._dirty_types.discard(self.node_type)
except Exception as e:
print(f"Error saving {self.node_type}: {str(e)}")
# 不抛出异常,继续执行
async def delete_all(
self,
nodes: List['SyncNode']
) -> List[TaskResult]:
"""
批量删除节点
Args:
nodes: 需要删除的节点列表(DataSource 已筛选为 DELETE + PENDING
Returns:
List[TaskResult],每个结果关联 node_id
"""
results = []
for node in nodes:
try:
if not node.data_id:
results.append(TaskResult.failed(
node_id=node.node_id,
error="Cannot delete node without data_id"
))
continue
# 删除缓存
bucket = self.datasource._data.get(self.node_type, {})
if node.data_id in bucket:
del bucket[node.data_id]
self.datasource._dirty_types.add(self.node_type)
results.append(TaskResult.success(
node_id=node.node_id
))
else:
results.append(TaskResult.failed(
node_id=node.node_id,
error=f"Item {node.data_id} not found"
))
except Exception as e:
results.append(TaskResult.failed(
node_id=node.node_id,
error=f"Delete failed: {str(e)}"
))
# 自动保存到文件
await self._save_to_file()
return results
@@ -0,0 +1,122 @@
"""
TaskResult - Handler 执行结果
统一的任务执行结果,用于所有 DataSource 的 Handler。
"""
from enum import Enum
from typing import Dict, Any, Optional
class TaskStatus(Enum):
"""任务执行状态"""
SUCCESS = "success" # 同步完成,立即成功
FAILED = "failed" # 同步失败
IN_PROGRESS = "in_progress" # 异步任务进行中
SKIPPED = "skipped" # 跳过(无实际更新或不符合更新条件)
class TaskResult:
"""
Handler 执行结果
用于封装 create/update/delete 操作的返回值。
适用于所有 DataSourceAPI、JSONL 等)。
支持批量操作时通过 node_id 定位结果。
"""
def __init__(
self,
status: TaskStatus,
node_id: Optional[str] = None,
data_id: Optional[str] = None,
task_id: Optional[str] = None,
push_id: Optional[str] = None,
error: Optional[str] = None,
sync_log: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
):
"""
Args:
status: 任务状态(SUCCESS/FAILED/IN_PROGRESS
node_id: 关联的节点 ID(批量操作时用于定位节点)
data_id: 成功时返回的业务 IDCREATE 操作必需)
task_id: 异步任务 IDstatus=IN_PROGRESS 时必需)
push_id: 推送请求 ID(用于统一 push/poll 跟踪)
error: 失败原因(status=FAILED 时必需)
sync_log: Debug/Info级别日志(SKIPPED时使用)
metadata: 其他元数据
"""
normalized_metadata = dict(metadata or {})
if push_id is None:
raw_push_id = normalized_metadata.get("push_id")
if isinstance(raw_push_id, str) and raw_push_id:
push_id = raw_push_id
elif push_id:
normalized_metadata.setdefault("push_id", push_id)
self.status = status
self.node_id = node_id
self.data_id = data_id
self.task_id = task_id
self.push_id = push_id
self.error = error
self.sync_log = sync_log
self.metadata = normalized_metadata
@classmethod
def success(
cls,
node_id: Optional[str] = None,
data_id: Optional[str] = None,
push_id: Optional[str] = None,
**metadata,
) -> "TaskResult":
"""创建成功结果"""
return cls(TaskStatus.SUCCESS, node_id=node_id, data_id=data_id, push_id=push_id, metadata=metadata)
@classmethod
def failed(
cls,
node_id: Optional[str] = None,
error: str = "",
push_id: Optional[str] = None,
**metadata,
) -> "TaskResult":
"""创建失败结果"""
return cls(TaskStatus.FAILED, node_id=node_id, error=error, push_id=push_id, metadata=metadata)
@classmethod
def skipped(
cls,
node_id: Optional[str] = None,
reason: str = "",
push_id: Optional[str] = None,
**metadata,
) -> "TaskResult":
"""创建跳过结果(无实际更新等)"""
return cls(TaskStatus.SKIPPED, node_id=node_id, sync_log=reason, push_id=push_id, metadata=metadata)
@classmethod
def in_progress(
cls,
node_id: Optional[str] = None,
task_id: str = "",
push_id: Optional[str] = None,
**metadata,
) -> "TaskResult":
"""创建进行中结果"""
return cls(TaskStatus.IN_PROGRESS, node_id=node_id, task_id=task_id, push_id=push_id, metadata=metadata)
def __repr__(self) -> str:
parts = [f"status={self.status.value}"]
if self.node_id:
parts.append(f"node_id={self.node_id}")
if self.data_id:
parts.append(f"data_id={self.data_id}")
if self.task_id:
parts.append(f"task_id={self.task_id}")
if self.push_id:
parts.append(f"push_id={self.push_id}")
if self.error:
parts.append(f"error={self.error!r}")
return f"TaskResult({', '.join(parts)})"