初步实现多project pipeline
This commit is contained in:
@@ -1,28 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, List, TypedDict, Any
|
||||
from typing import Any, Dict, Optional, List
|
||||
|
||||
from ..config import (
|
||||
MultiProjectItemConfig,
|
||||
MultiProjectRunConfig,
|
||||
PipelineRunConfig,
|
||||
StrategyRuntimeConfig,
|
||||
apply_config_overrides,
|
||||
build_config_from_file,
|
||||
)
|
||||
from ..logging import get_logger
|
||||
from .factory import create_pipeline_from_config
|
||||
from ..logging import get_scope_logger
|
||||
from .factory import create_pipeline_from_config, create_scope_runtime_from_config
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
logger = get_scope_logger("multi-project", __name__)
|
||||
|
||||
|
||||
class _SharedStateSnapshot(TypedDict):
|
||||
local_nodes: List[Any]
|
||||
remote_nodes: List[Any]
|
||||
bindings_by_type: Dict[str, Dict[str, Optional[str]]]
|
||||
@dataclass(frozen=True)
|
||||
class ImplicitProjectItem:
|
||||
local_project_id: str
|
||||
remote_project_id: str
|
||||
|
||||
@property
|
||||
def scope(self) -> str:
|
||||
return f"project_id:{self.local_project_id}"
|
||||
|
||||
|
||||
def _resolve_embedded_config_path(raw_path: str, *, project_root: Path, profile_path: Path) -> Path:
|
||||
@@ -65,7 +71,215 @@ def _resolve_project_bootstrap_node_types(
|
||||
return list(dict.fromkeys(shared_node_types))
|
||||
|
||||
|
||||
class MultiProjectPipeline:
|
||||
def _normalize_project_id_items(raw_items: Any) -> list[str]:
|
||||
items = raw_items if isinstance(raw_items, list) else []
|
||||
normalized: list[str] = []
|
||||
for item in items:
|
||||
value = str(item).strip()
|
||||
if value and value not in normalized:
|
||||
normalized.append(value)
|
||||
return normalized
|
||||
|
||||
|
||||
def _get_project_handler_filters(config: PipelineRunConfig) -> tuple[list[str], list[str]]:
|
||||
local_filters = _normalize_project_id_items(
|
||||
config.local_datasource.handler_configs.get("project", {}).get("data_id_filter", [])
|
||||
)
|
||||
remote_filters = _normalize_project_id_items(
|
||||
config.remote_datasource.handler_configs.get("project", {}).get("data_id_filter", [])
|
||||
)
|
||||
return local_filters, remote_filters
|
||||
|
||||
|
||||
def _get_project_strategy_runtime(config: PipelineRunConfig) -> StrategyRuntimeConfig | None:
|
||||
for item in config.strategies:
|
||||
if item.node_type == "project":
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def _get_project_prebind_pairs(config: PipelineRunConfig) -> list[tuple[str, str]]:
|
||||
runtime_cfg = _get_project_strategy_runtime(config)
|
||||
if runtime_cfg is None:
|
||||
return []
|
||||
pairs: list[tuple[str, str]] = []
|
||||
for item in runtime_cfg.config.pre_bind_data_id:
|
||||
local_id = str(item.local_data_id).strip()
|
||||
remote_id = str(item.remote_data_id).strip()
|
||||
if not local_id or not remote_id:
|
||||
continue
|
||||
pairs.append((local_id, remote_id))
|
||||
return pairs
|
||||
|
||||
|
||||
def is_implicit_project_batch_config(config: PipelineRunConfig) -> bool:
|
||||
if "project" not in config.node_types:
|
||||
return False
|
||||
local_filters, remote_filters = _get_project_handler_filters(config)
|
||||
prebind_pairs = _get_project_prebind_pairs(config)
|
||||
return max(len(local_filters), len(remote_filters), len(prebind_pairs)) > 1
|
||||
|
||||
|
||||
def _extract_implicit_project_items(config: PipelineRunConfig) -> list[ImplicitProjectItem]:
|
||||
local_filters, remote_filters = _get_project_handler_filters(config)
|
||||
prebind_pairs = _get_project_prebind_pairs(config)
|
||||
if not prebind_pairs:
|
||||
raise ValueError("implicit multi-project config requires strategies.project.config.pre_bind_data_id")
|
||||
|
||||
pair_local_ids = [local_id for local_id, _ in prebind_pairs]
|
||||
pair_remote_ids = [remote_id for _, remote_id in prebind_pairs]
|
||||
|
||||
effective_local_filters = local_filters or list(dict.fromkeys(pair_local_ids))
|
||||
effective_remote_filters = remote_filters or list(dict.fromkeys(pair_remote_ids))
|
||||
|
||||
if set(effective_local_filters) != set(pair_local_ids):
|
||||
raise ValueError(
|
||||
"implicit multi-project config mismatch: local project data_id_filter must match project pre_bind_data_id local_data_id"
|
||||
)
|
||||
if set(effective_remote_filters) != set(pair_remote_ids):
|
||||
raise ValueError(
|
||||
"implicit multi-project config mismatch: remote project data_id_filter must match project pre_bind_data_id remote_data_id"
|
||||
)
|
||||
|
||||
seen_local_ids: set[str] = set()
|
||||
seen_remote_ids: set[str] = set()
|
||||
items: list[ImplicitProjectItem] = []
|
||||
for local_id, remote_id in prebind_pairs:
|
||||
if local_id in seen_local_ids:
|
||||
raise ValueError(f"duplicate local project id in implicit multi-project config: {local_id}")
|
||||
if remote_id in seen_remote_ids:
|
||||
raise ValueError(f"duplicate remote project id in implicit multi-project config: {remote_id}")
|
||||
seen_local_ids.add(local_id)
|
||||
seen_remote_ids.add(remote_id)
|
||||
items.append(ImplicitProjectItem(local_project_id=local_id, remote_project_id=remote_id))
|
||||
return items
|
||||
|
||||
|
||||
def _split_implicit_project_node_types(node_types: list[str]) -> tuple[list[str], list[str]]:
|
||||
if "project" not in node_types:
|
||||
raise ValueError("implicit multi-project config requires node_types to include 'project'")
|
||||
project_index = node_types.index("project")
|
||||
return node_types[:project_index], node_types[project_index:]
|
||||
|
||||
|
||||
def _build_implicit_project_config(
|
||||
base_config: PipelineRunConfig,
|
||||
*,
|
||||
item: ImplicitProjectItem,
|
||||
project_node_types: list[str],
|
||||
) -> PipelineRunConfig:
|
||||
return apply_config_overrides(
|
||||
base_config,
|
||||
{
|
||||
"node_types": project_node_types,
|
||||
"persist": {"wipe_on_start": False},
|
||||
"local_datasource": {
|
||||
"handler_configs": {
|
||||
"project": {"data_id_filter": [item.local_project_id]},
|
||||
}
|
||||
},
|
||||
"remote_datasource": {
|
||||
"handler_configs": {
|
||||
"project": {"data_id_filter": [item.remote_project_id]},
|
||||
}
|
||||
},
|
||||
"strategies": {
|
||||
"project": {
|
||||
"config": {
|
||||
"pre_bind_data_id": [
|
||||
{
|
||||
"local_data_id": item.local_project_id,
|
||||
"remote_data_id": item.remote_project_id,
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def run_implicit_project_batch_from_config(
|
||||
config: PipelineRunConfig,
|
||||
*,
|
||||
title: str,
|
||||
print_summary: bool = True,
|
||||
max_concurrency: int | None = None,
|
||||
) -> Dict[str, Dict[str, object]]:
|
||||
from .factory import create_pipeline_from_config, create_scope_runtime_from_config
|
||||
|
||||
project_items = _extract_implicit_project_items(config)
|
||||
shared_node_types, project_node_types = _split_implicit_project_node_types(list(config.node_types))
|
||||
results: Dict[str, Dict[str, object]] = {}
|
||||
|
||||
global_runtime = None
|
||||
global_pipeline = None
|
||||
global_config = None
|
||||
|
||||
if print_summary:
|
||||
logger.info("\n%s", "=" * 80)
|
||||
logger.info("🚀 Creating implicit multi-project pipeline from in-memory config")
|
||||
logger.info("📦 Projects=%d", len(project_items))
|
||||
logger.info("=" * 80)
|
||||
|
||||
try:
|
||||
if shared_node_types:
|
||||
global_config = apply_config_overrides(
|
||||
config,
|
||||
{
|
||||
"node_types": shared_node_types,
|
||||
},
|
||||
)
|
||||
global_runtime = await create_scope_runtime_from_config(global_config, scope="global")
|
||||
global_pipeline = await create_pipeline_from_config(
|
||||
global_config,
|
||||
scope="global",
|
||||
pipeline_name=f"{title} [global]",
|
||||
scope_runtime=global_runtime,
|
||||
)
|
||||
results["global"] = await global_pipeline.run()
|
||||
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency or len(project_items)))
|
||||
|
||||
async def _run_project(item: ImplicitProjectItem) -> None:
|
||||
project_config = _build_implicit_project_config(
|
||||
config,
|
||||
item=item,
|
||||
project_node_types=project_node_types,
|
||||
)
|
||||
async with semaphore:
|
||||
project_runtime = await create_scope_runtime_from_config(
|
||||
project_config,
|
||||
scope=item.scope,
|
||||
persistence_service=global_runtime.persistence_service if global_runtime is not None else None,
|
||||
local_fallback_collection=global_runtime.local_collection if global_runtime is not None else None,
|
||||
remote_fallback_collection=global_runtime.remote_collection if global_runtime is not None else None,
|
||||
binding_fallback_manager=global_runtime.binding_manager if global_runtime is not None else None,
|
||||
global_runtime=global_runtime,
|
||||
)
|
||||
try:
|
||||
project_pipeline = await create_pipeline_from_config(
|
||||
project_config,
|
||||
scope=item.scope,
|
||||
pipeline_name=f"{title} [{item.scope}]",
|
||||
bootstrap_binding_node_types=shared_node_types,
|
||||
ephemeral_node_types=shared_node_types,
|
||||
scope_runtime=project_runtime,
|
||||
)
|
||||
results[item.scope] = await project_pipeline.run()
|
||||
finally:
|
||||
await project_runtime.unload()
|
||||
await project_runtime.close()
|
||||
|
||||
await asyncio.gather(*[_run_project(item) for item in project_items])
|
||||
return results
|
||||
finally:
|
||||
if global_runtime is not None:
|
||||
await global_runtime.unload()
|
||||
await global_runtime.close()
|
||||
|
||||
|
||||
class ProjectBatchPipeline:
|
||||
def __init__(self, *, project_root: Path, config_path: Path, config: MultiProjectRunConfig):
|
||||
self.project_root = project_root
|
||||
self.config_path = config_path
|
||||
@@ -87,27 +301,6 @@ class MultiProjectPipeline:
|
||||
cfg = apply_config_overrides(cfg, {"persist": {**shared_persist, "wipe_on_start": False}})
|
||||
return apply_config_overrides(cfg, _project_scope_overrides(item, node_types=list(cfg.node_types)))
|
||||
|
||||
async def _snapshot_shared_state(self, *, pipeline, node_types: List[str]) -> _SharedStateSnapshot:
|
||||
local_nodes = [
|
||||
copy.deepcopy(node)
|
||||
for node_type in node_types
|
||||
for node in pipeline.local_collection.filter(node_type=node_type)
|
||||
]
|
||||
remote_nodes = [
|
||||
copy.deepcopy(node)
|
||||
for node_type in node_types
|
||||
for node in pipeline.remote_collection.filter(node_type=node_type)
|
||||
]
|
||||
bindings_by_type: Dict[str, Dict[str, Optional[str]]] = {}
|
||||
for node_type in node_types:
|
||||
bindings = await pipeline.binding_manager.get_all_bindings(node_type)
|
||||
bindings_by_type[node_type] = {local_id: remote_id for local_id, remote_id in bindings}
|
||||
return {
|
||||
"local_nodes": local_nodes,
|
||||
"remote_nodes": remote_nodes,
|
||||
"bindings_by_type": bindings_by_type,
|
||||
}
|
||||
|
||||
async def run(self) -> Dict[str, Dict[str, object]]:
|
||||
results: Dict[str, Dict[str, object]] = {}
|
||||
|
||||
@@ -119,49 +312,67 @@ class MultiProjectPipeline:
|
||||
shared_node_types=shared_node_types,
|
||||
)
|
||||
logger.info("🚀 Multi-project pipeline start: projects=%d", len(self.config.projects))
|
||||
global_pipeline = await create_pipeline_from_config(
|
||||
global_runtime = await create_scope_runtime_from_config(
|
||||
global_config,
|
||||
scope="global",
|
||||
pipeline_name="multi-project pipeline [global]",
|
||||
)
|
||||
global_pipeline = None
|
||||
try:
|
||||
global_pipeline = await create_pipeline_from_config(
|
||||
global_config,
|
||||
scope="global",
|
||||
pipeline_name="multi-project pipeline [global]",
|
||||
scope_runtime=global_runtime,
|
||||
)
|
||||
results["global"] = await global_pipeline.run()
|
||||
shared_state = await self._snapshot_shared_state(
|
||||
pipeline=global_pipeline,
|
||||
node_types=project_bootstrap_node_types,
|
||||
)
|
||||
finally:
|
||||
await global_pipeline.close()
|
||||
semaphore = asyncio.Semaphore(self.config.max_concurrency)
|
||||
|
||||
for item in self.config.projects:
|
||||
project_config = self._prepare_project_config(
|
||||
shared_persist=shared_persist,
|
||||
item=item,
|
||||
base_path=item.config_path,
|
||||
)
|
||||
scope = item.scope
|
||||
title = f"multi-project pipeline [{scope}]"
|
||||
logger.info(
|
||||
"🚀 Project pipeline start: scope=%s local_project_id=%s remote_project_id=%s",
|
||||
scope,
|
||||
item.local_project_id,
|
||||
item.remote_project_id,
|
||||
)
|
||||
project_pipeline = await create_pipeline_from_config(
|
||||
project_config,
|
||||
scope=scope,
|
||||
pipeline_name=title,
|
||||
bootstrap_binding_node_types=project_bootstrap_node_types,
|
||||
ephemeral_node_types=project_bootstrap_node_types,
|
||||
)
|
||||
try:
|
||||
project_pipeline.preload_shared_state(
|
||||
local_nodes=shared_state["local_nodes"],
|
||||
remote_nodes=shared_state["remote_nodes"],
|
||||
bindings_by_type=shared_state["bindings_by_type"],
|
||||
async def _run_project(item: MultiProjectItemConfig) -> None:
|
||||
project_config = self._prepare_project_config(
|
||||
shared_persist=shared_persist,
|
||||
item=item,
|
||||
base_path=item.config_path,
|
||||
)
|
||||
results[scope] = await project_pipeline.run()
|
||||
finally:
|
||||
await project_pipeline.close()
|
||||
scope = item.scope
|
||||
title = f"multi-project pipeline [{scope}]"
|
||||
logger.info(
|
||||
"🚀 Project pipeline start: scope=%s local_project_id=%s remote_project_id=%s",
|
||||
scope,
|
||||
item.local_project_id,
|
||||
item.remote_project_id,
|
||||
)
|
||||
async with semaphore:
|
||||
project_runtime = await create_scope_runtime_from_config(
|
||||
project_config,
|
||||
scope=scope,
|
||||
persistence_service=global_runtime.persistence_service,
|
||||
local_fallback_collection=global_runtime.local_collection,
|
||||
remote_fallback_collection=global_runtime.remote_collection,
|
||||
binding_fallback_manager=global_runtime.binding_manager,
|
||||
global_runtime=global_runtime,
|
||||
)
|
||||
project_pipeline = None
|
||||
try:
|
||||
project_pipeline = await create_pipeline_from_config(
|
||||
project_config,
|
||||
scope=scope,
|
||||
pipeline_name=title,
|
||||
bootstrap_binding_node_types=project_bootstrap_node_types,
|
||||
ephemeral_node_types=project_bootstrap_node_types,
|
||||
scope_runtime=project_runtime,
|
||||
)
|
||||
results[scope] = await project_pipeline.run()
|
||||
finally:
|
||||
await project_runtime.unload()
|
||||
await project_runtime.close()
|
||||
|
||||
return results
|
||||
await asyncio.gather(*[_run_project(item) for item in self.config.projects])
|
||||
finally:
|
||||
await global_runtime.unload()
|
||||
await global_runtime.close()
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class MultiProjectPipeline(ProjectBatchPipeline):
|
||||
pass
|
||||
Reference in New Issue
Block a user