hexdag 0.5.0.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hexdag/__init__.py +116 -0
- hexdag/__main__.py +30 -0
- hexdag/adapters/executors/__init__.py +5 -0
- hexdag/adapters/executors/local_executor.py +316 -0
- hexdag/builtin/__init__.py +6 -0
- hexdag/builtin/adapters/__init__.py +51 -0
- hexdag/builtin/adapters/anthropic/__init__.py +5 -0
- hexdag/builtin/adapters/anthropic/anthropic_adapter.py +151 -0
- hexdag/builtin/adapters/database/__init__.py +6 -0
- hexdag/builtin/adapters/database/csv/csv_adapter.py +249 -0
- hexdag/builtin/adapters/database/pgvector/__init__.py +5 -0
- hexdag/builtin/adapters/database/pgvector/pgvector_adapter.py +478 -0
- hexdag/builtin/adapters/database/sqlalchemy/sqlalchemy_adapter.py +252 -0
- hexdag/builtin/adapters/database/sqlite/__init__.py +5 -0
- hexdag/builtin/adapters/database/sqlite/sqlite_adapter.py +410 -0
- hexdag/builtin/adapters/local/README.md +59 -0
- hexdag/builtin/adapters/local/__init__.py +7 -0
- hexdag/builtin/adapters/local/local_observer_manager.py +696 -0
- hexdag/builtin/adapters/memory/__init__.py +47 -0
- hexdag/builtin/adapters/memory/file_memory_adapter.py +297 -0
- hexdag/builtin/adapters/memory/in_memory_memory.py +216 -0
- hexdag/builtin/adapters/memory/schemas.py +57 -0
- hexdag/builtin/adapters/memory/session_memory.py +178 -0
- hexdag/builtin/adapters/memory/sqlite_memory_adapter.py +215 -0
- hexdag/builtin/adapters/memory/state_memory.py +280 -0
- hexdag/builtin/adapters/mock/README.md +89 -0
- hexdag/builtin/adapters/mock/__init__.py +15 -0
- hexdag/builtin/adapters/mock/hexdag.toml +50 -0
- hexdag/builtin/adapters/mock/mock_database.py +225 -0
- hexdag/builtin/adapters/mock/mock_embedding.py +223 -0
- hexdag/builtin/adapters/mock/mock_llm.py +177 -0
- hexdag/builtin/adapters/mock/mock_tool_adapter.py +192 -0
- hexdag/builtin/adapters/mock/mock_tool_router.py +232 -0
- hexdag/builtin/adapters/openai/__init__.py +5 -0
- hexdag/builtin/adapters/openai/openai_adapter.py +634 -0
- hexdag/builtin/adapters/secret/__init__.py +7 -0
- hexdag/builtin/adapters/secret/local_secret_adapter.py +248 -0
- hexdag/builtin/adapters/unified_tool_router.py +280 -0
- hexdag/builtin/macros/__init__.py +17 -0
- hexdag/builtin/macros/conversation_agent.py +390 -0
- hexdag/builtin/macros/llm_macro.py +151 -0
- hexdag/builtin/macros/reasoning_agent.py +423 -0
- hexdag/builtin/macros/tool_macro.py +380 -0
- hexdag/builtin/nodes/__init__.py +38 -0
- hexdag/builtin/nodes/_discovery.py +123 -0
- hexdag/builtin/nodes/agent_node.py +696 -0
- hexdag/builtin/nodes/base_node_factory.py +242 -0
- hexdag/builtin/nodes/composite_node.py +926 -0
- hexdag/builtin/nodes/data_node.py +201 -0
- hexdag/builtin/nodes/expression_node.py +487 -0
- hexdag/builtin/nodes/function_node.py +454 -0
- hexdag/builtin/nodes/llm_node.py +491 -0
- hexdag/builtin/nodes/loop_node.py +920 -0
- hexdag/builtin/nodes/mapped_input.py +518 -0
- hexdag/builtin/nodes/port_call_node.py +269 -0
- hexdag/builtin/nodes/tool_call_node.py +195 -0
- hexdag/builtin/nodes/tool_utils.py +390 -0
- hexdag/builtin/prompts/__init__.py +68 -0
- hexdag/builtin/prompts/base.py +422 -0
- hexdag/builtin/prompts/chat_prompts.py +303 -0
- hexdag/builtin/prompts/error_correction_prompts.py +320 -0
- hexdag/builtin/prompts/tool_prompts.py +160 -0
- hexdag/builtin/tools/builtin_tools.py +84 -0
- hexdag/builtin/tools/database_tools.py +164 -0
- hexdag/cli/__init__.py +17 -0
- hexdag/cli/__main__.py +7 -0
- hexdag/cli/commands/__init__.py +27 -0
- hexdag/cli/commands/build_cmd.py +812 -0
- hexdag/cli/commands/create_cmd.py +208 -0
- hexdag/cli/commands/docs_cmd.py +293 -0
- hexdag/cli/commands/generate_types_cmd.py +252 -0
- hexdag/cli/commands/init_cmd.py +188 -0
- hexdag/cli/commands/pipeline_cmd.py +494 -0
- hexdag/cli/commands/plugin_dev_cmd.py +529 -0
- hexdag/cli/commands/plugins_cmd.py +441 -0
- hexdag/cli/commands/studio_cmd.py +101 -0
- hexdag/cli/commands/validate_cmd.py +221 -0
- hexdag/cli/main.py +84 -0
- hexdag/core/__init__.py +83 -0
- hexdag/core/config/__init__.py +20 -0
- hexdag/core/config/loader.py +479 -0
- hexdag/core/config/models.py +150 -0
- hexdag/core/configurable.py +294 -0
- hexdag/core/context/__init__.py +37 -0
- hexdag/core/context/execution_context.py +378 -0
- hexdag/core/docs/__init__.py +26 -0
- hexdag/core/docs/extractors.py +678 -0
- hexdag/core/docs/generators.py +890 -0
- hexdag/core/docs/models.py +120 -0
- hexdag/core/domain/__init__.py +10 -0
- hexdag/core/domain/dag.py +1225 -0
- hexdag/core/exceptions.py +234 -0
- hexdag/core/expression_parser.py +569 -0
- hexdag/core/logging.py +449 -0
- hexdag/core/models/__init__.py +17 -0
- hexdag/core/models/base.py +138 -0
- hexdag/core/orchestration/__init__.py +46 -0
- hexdag/core/orchestration/body_executor.py +481 -0
- hexdag/core/orchestration/components/__init__.py +97 -0
- hexdag/core/orchestration/components/adapter_lifecycle_manager.py +113 -0
- hexdag/core/orchestration/components/checkpoint_manager.py +134 -0
- hexdag/core/orchestration/components/execution_coordinator.py +360 -0
- hexdag/core/orchestration/components/health_check_manager.py +176 -0
- hexdag/core/orchestration/components/input_mapper.py +143 -0
- hexdag/core/orchestration/components/lifecycle_manager.py +583 -0
- hexdag/core/orchestration/components/node_executor.py +377 -0
- hexdag/core/orchestration/components/secret_manager.py +202 -0
- hexdag/core/orchestration/components/wave_executor.py +158 -0
- hexdag/core/orchestration/constants.py +17 -0
- hexdag/core/orchestration/events/README.md +312 -0
- hexdag/core/orchestration/events/__init__.py +104 -0
- hexdag/core/orchestration/events/batching.py +330 -0
- hexdag/core/orchestration/events/decorators.py +139 -0
- hexdag/core/orchestration/events/events.py +573 -0
- hexdag/core/orchestration/events/observers/__init__.py +30 -0
- hexdag/core/orchestration/events/observers/core_observers.py +690 -0
- hexdag/core/orchestration/events/observers/models.py +111 -0
- hexdag/core/orchestration/events/taxonomy.py +269 -0
- hexdag/core/orchestration/hook_context.py +237 -0
- hexdag/core/orchestration/hooks.py +437 -0
- hexdag/core/orchestration/models.py +418 -0
- hexdag/core/orchestration/orchestrator.py +910 -0
- hexdag/core/orchestration/orchestrator_factory.py +275 -0
- hexdag/core/orchestration/port_wrappers.py +327 -0
- hexdag/core/orchestration/prompt/__init__.py +32 -0
- hexdag/core/orchestration/prompt/template.py +332 -0
- hexdag/core/pipeline_builder/__init__.py +21 -0
- hexdag/core/pipeline_builder/component_instantiator.py +386 -0
- hexdag/core/pipeline_builder/include_tag.py +265 -0
- hexdag/core/pipeline_builder/pipeline_config.py +133 -0
- hexdag/core/pipeline_builder/py_tag.py +223 -0
- hexdag/core/pipeline_builder/tag_discovery.py +268 -0
- hexdag/core/pipeline_builder/yaml_builder.py +1196 -0
- hexdag/core/pipeline_builder/yaml_validator.py +569 -0
- hexdag/core/ports/__init__.py +65 -0
- hexdag/core/ports/api_call.py +133 -0
- hexdag/core/ports/database.py +489 -0
- hexdag/core/ports/embedding.py +215 -0
- hexdag/core/ports/executor.py +237 -0
- hexdag/core/ports/file_storage.py +117 -0
- hexdag/core/ports/healthcheck.py +87 -0
- hexdag/core/ports/llm.py +551 -0
- hexdag/core/ports/memory.py +70 -0
- hexdag/core/ports/observer_manager.py +130 -0
- hexdag/core/ports/secret.py +145 -0
- hexdag/core/ports/tool_router.py +94 -0
- hexdag/core/ports_builder.py +623 -0
- hexdag/core/protocols.py +273 -0
- hexdag/core/resolver.py +304 -0
- hexdag/core/schema/__init__.py +9 -0
- hexdag/core/schema/generator.py +742 -0
- hexdag/core/secrets.py +242 -0
- hexdag/core/types.py +413 -0
- hexdag/core/utils/async_warnings.py +206 -0
- hexdag/core/utils/schema_conversion.py +78 -0
- hexdag/core/utils/sql_validation.py +86 -0
- hexdag/core/validation/secure_json.py +148 -0
- hexdag/core/yaml_macro.py +517 -0
- hexdag/mcp_server.py +3120 -0
- hexdag/studio/__init__.py +10 -0
- hexdag/studio/build_ui.py +92 -0
- hexdag/studio/server/__init__.py +1 -0
- hexdag/studio/server/main.py +100 -0
- hexdag/studio/server/routes/__init__.py +9 -0
- hexdag/studio/server/routes/execute.py +208 -0
- hexdag/studio/server/routes/export.py +558 -0
- hexdag/studio/server/routes/files.py +207 -0
- hexdag/studio/server/routes/plugins.py +419 -0
- hexdag/studio/server/routes/validate.py +220 -0
- hexdag/studio/ui/index.html +13 -0
- hexdag/studio/ui/package-lock.json +2992 -0
- hexdag/studio/ui/package.json +31 -0
- hexdag/studio/ui/postcss.config.js +6 -0
- hexdag/studio/ui/public/hexdag.svg +5 -0
- hexdag/studio/ui/src/App.tsx +251 -0
- hexdag/studio/ui/src/components/Canvas.tsx +408 -0
- hexdag/studio/ui/src/components/ContextMenu.tsx +187 -0
- hexdag/studio/ui/src/components/FileBrowser.tsx +123 -0
- hexdag/studio/ui/src/components/Header.tsx +181 -0
- hexdag/studio/ui/src/components/HexdagNode.tsx +193 -0
- hexdag/studio/ui/src/components/NodeInspector.tsx +512 -0
- hexdag/studio/ui/src/components/NodePalette.tsx +262 -0
- hexdag/studio/ui/src/components/NodePortsSection.tsx +403 -0
- hexdag/studio/ui/src/components/PluginManager.tsx +347 -0
- hexdag/studio/ui/src/components/PortsEditor.tsx +481 -0
- hexdag/studio/ui/src/components/PythonEditor.tsx +195 -0
- hexdag/studio/ui/src/components/ValidationPanel.tsx +105 -0
- hexdag/studio/ui/src/components/YamlEditor.tsx +196 -0
- hexdag/studio/ui/src/components/index.ts +8 -0
- hexdag/studio/ui/src/index.css +92 -0
- hexdag/studio/ui/src/main.tsx +10 -0
- hexdag/studio/ui/src/types/index.ts +123 -0
- hexdag/studio/ui/src/vite-env.d.ts +1 -0
- hexdag/studio/ui/tailwind.config.js +29 -0
- hexdag/studio/ui/tsconfig.json +37 -0
- hexdag/studio/ui/tsconfig.node.json +13 -0
- hexdag/studio/ui/vite.config.ts +35 -0
- hexdag/visualization/__init__.py +69 -0
- hexdag/visualization/dag_visualizer.py +1020 -0
- hexdag-0.5.0.dev1.dist-info/METADATA +369 -0
- hexdag-0.5.0.dev1.dist-info/RECORD +261 -0
- hexdag-0.5.0.dev1.dist-info/WHEEL +4 -0
- hexdag-0.5.0.dev1.dist-info/entry_points.txt +4 -0
- hexdag-0.5.0.dev1.dist-info/licenses/LICENSE +190 -0
- hexdag_plugins/.gitignore +43 -0
- hexdag_plugins/README.md +73 -0
- hexdag_plugins/__init__.py +1 -0
- hexdag_plugins/azure/LICENSE +21 -0
- hexdag_plugins/azure/README.md +414 -0
- hexdag_plugins/azure/__init__.py +21 -0
- hexdag_plugins/azure/azure_blob_adapter.py +450 -0
- hexdag_plugins/azure/azure_cosmos_adapter.py +383 -0
- hexdag_plugins/azure/azure_keyvault_adapter.py +314 -0
- hexdag_plugins/azure/azure_openai_adapter.py +415 -0
- hexdag_plugins/azure/pyproject.toml +107 -0
- hexdag_plugins/azure/tests/__init__.py +1 -0
- hexdag_plugins/azure/tests/test_azure_blob_adapter.py +350 -0
- hexdag_plugins/azure/tests/test_azure_cosmos_adapter.py +323 -0
- hexdag_plugins/azure/tests/test_azure_keyvault_adapter.py +330 -0
- hexdag_plugins/azure/tests/test_azure_openai_adapter.py +329 -0
- hexdag_plugins/hexdag_etl/README.md +168 -0
- hexdag_plugins/hexdag_etl/__init__.py +53 -0
- hexdag_plugins/hexdag_etl/examples/01_simple_pandas_transform.py +270 -0
- hexdag_plugins/hexdag_etl/examples/02_simple_pandas_only.py +149 -0
- hexdag_plugins/hexdag_etl/examples/03_file_io_pipeline.py +109 -0
- hexdag_plugins/hexdag_etl/examples/test_pandas_transform.py +84 -0
- hexdag_plugins/hexdag_etl/hexdag.toml +25 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/__init__.py +48 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/__init__.py +13 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/api_extract.py +230 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/base_node_factory.py +181 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/file_io.py +415 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/outlook.py +492 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/pandas_transform.py +563 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/sql_extract_load.py +112 -0
- hexdag_plugins/hexdag_etl/pyproject.toml +82 -0
- hexdag_plugins/hexdag_etl/test_transform.py +54 -0
- hexdag_plugins/hexdag_etl/tests/test_plugin_integration.py +62 -0
- hexdag_plugins/mysql_adapter/LICENSE +21 -0
- hexdag_plugins/mysql_adapter/README.md +224 -0
- hexdag_plugins/mysql_adapter/__init__.py +6 -0
- hexdag_plugins/mysql_adapter/mysql_adapter.py +408 -0
- hexdag_plugins/mysql_adapter/pyproject.toml +93 -0
- hexdag_plugins/mysql_adapter/tests/test_mysql_adapter.py +259 -0
- hexdag_plugins/storage/README.md +184 -0
- hexdag_plugins/storage/__init__.py +19 -0
- hexdag_plugins/storage/file/__init__.py +5 -0
- hexdag_plugins/storage/file/local.py +325 -0
- hexdag_plugins/storage/ports/__init__.py +5 -0
- hexdag_plugins/storage/ports/vector_store.py +236 -0
- hexdag_plugins/storage/sql/__init__.py +7 -0
- hexdag_plugins/storage/sql/base.py +187 -0
- hexdag_plugins/storage/sql/mysql.py +27 -0
- hexdag_plugins/storage/sql/postgresql.py +27 -0
- hexdag_plugins/storage/tests/__init__.py +1 -0
- hexdag_plugins/storage/tests/test_local_file_storage.py +161 -0
- hexdag_plugins/storage/tests/test_sql_adapters.py +212 -0
- hexdag_plugins/storage/vector/__init__.py +7 -0
- hexdag_plugins/storage/vector/chromadb.py +223 -0
- hexdag_plugins/storage/vector/in_memory.py +285 -0
- hexdag_plugins/storage/vector/pgvector.py +502 -0
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
"""Local Observer Manager Adapter - Standalone implementation of observer pattern.
|
|
2
|
+
|
|
3
|
+
This adapter provides a complete, standalone implementation of the ObserverManagerPort
|
|
4
|
+
interface with all safety features including weak references, event filtering,
|
|
5
|
+
concurrency control, and fault isolation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import inspect
|
|
12
|
+
import logging
|
|
13
|
+
import uuid
|
|
14
|
+
import weakref
|
|
15
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Protocol, cast
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
19
|
+
|
|
20
|
+
from hexdag.core.orchestration.events.batching import (
|
|
21
|
+
BatchingConfig,
|
|
22
|
+
BatchingMetrics,
|
|
23
|
+
EventBatchEnvelope,
|
|
24
|
+
EventBatcher,
|
|
25
|
+
)
|
|
26
|
+
from hexdag.core.orchestration.events.decorators import (
|
|
27
|
+
EVENT_METADATA_ATTR,
|
|
28
|
+
EventDecoratorMetadata,
|
|
29
|
+
EventType,
|
|
30
|
+
EventTypesInput,
|
|
31
|
+
normalize_event_types,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import Awaitable, Sequence
|
|
36
|
+
|
|
37
|
+
from hexdag.core.orchestration.events.events import Event
|
|
38
|
+
from hexdag.core.ports.observer_manager import (
|
|
39
|
+
AsyncObserverFunc,
|
|
40
|
+
Observer,
|
|
41
|
+
ObserverFunc,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
LOGGER = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ErrorHandler(Protocol):
|
|
48
|
+
"""Protocol for handling errors in event system."""
|
|
49
|
+
|
|
50
|
+
def handle_error(self, error: Exception, context: dict[str, Any]) -> None:
|
|
51
|
+
"""Handle an error that occurred during event processing."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class LoggingErrorHandler:
|
|
56
|
+
"""Default error handler that logs errors."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, logger: Any | None = None):
|
|
59
|
+
"""Initialize with optional logger."""
|
|
60
|
+
self.logger: Any = logger if logger is not None else LOGGER
|
|
61
|
+
|
|
62
|
+
def handle_error(self, error: Exception, context: dict[str, Any]) -> None:
|
|
63
|
+
"""Log the error with context."""
|
|
64
|
+
handler_name = context.get("handler_name", "unknown")
|
|
65
|
+
event_type = context.get("event_type", "unknown")
|
|
66
|
+
|
|
67
|
+
if context.get("is_critical", False):
|
|
68
|
+
self.logger.error(
|
|
69
|
+
f"Critical handler {handler_name} failed for {event_type}: {error}", exc_info=True
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
self.logger.warning(f"Handler {handler_name} failed for {event_type}: {error}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class FunctionObserver:
|
|
76
|
+
"""Wrapper to make functions implement the Observer protocol."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, func: ObserverFunc | AsyncObserverFunc, executor: ThreadPoolExecutor):
|
|
79
|
+
self._func = func
|
|
80
|
+
self._executor = executor
|
|
81
|
+
self.__name__ = getattr(func, "__name__", "anonymous_observer")
|
|
82
|
+
|
|
83
|
+
async def handle(self, event: Event) -> None:
|
|
84
|
+
"""Handle the event by calling the wrapped function."""
|
|
85
|
+
if asyncio.iscoroutinefunction(self._func):
|
|
86
|
+
await self._func(event)
|
|
87
|
+
else:
|
|
88
|
+
# Run sync function in thread pool to avoid blocking
|
|
89
|
+
loop = asyncio.get_running_loop()
|
|
90
|
+
await loop.run_in_executor(self._executor, self._func, event)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Default configuration constants
|
|
94
|
+
DEFAULT_MAX_CONCURRENT_OBSERVERS = 10
|
|
95
|
+
DEFAULT_OBSERVER_TIMEOUT = 5.0
|
|
96
|
+
DEFAULT_MAX_SYNC_WORKERS = 4
|
|
97
|
+
DEFAULT_CLEANUP_INTERVAL = 0.1
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ObserverRegistrationConfig(BaseModel):
|
|
101
|
+
"""Validated configuration for observer registration."""
|
|
102
|
+
|
|
103
|
+
model_config = ConfigDict(extra="forbid")
|
|
104
|
+
|
|
105
|
+
observer_id: str | None = None
|
|
106
|
+
event_types: set[EventType] | None = None
|
|
107
|
+
timeout: float | None = Field(None, gt=0)
|
|
108
|
+
max_concurrency: int | None = Field(None, ge=1)
|
|
109
|
+
keep_alive: bool = False
|
|
110
|
+
|
|
111
|
+
@field_validator("event_types", mode="before")
|
|
112
|
+
@classmethod
|
|
113
|
+
def validate_event_types(cls, value: EventTypesInput) -> set[EventType] | None:
|
|
114
|
+
return normalize_event_types(value)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class LocalObserverManager:
|
|
118
|
+
"""Local standalone implementation of observer manager.
|
|
119
|
+
|
|
120
|
+
This implementation provides:
|
|
121
|
+
- Weak reference support to prevent memory leaks
|
|
122
|
+
- Event type filtering for efficiency
|
|
123
|
+
- Concurrent observer execution with limits
|
|
124
|
+
- Fault isolation - observer failures don't crash the pipeline
|
|
125
|
+
- Timeout handling for slow observers
|
|
126
|
+
- Thread pool for sync observers to avoid blocking
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _get_observer_metadata(handler: Any) -> EventDecoratorMetadata | None:
|
|
131
|
+
"""Return observer metadata if present."""
|
|
132
|
+
metadata = getattr(handler, EVENT_METADATA_ATTR, None)
|
|
133
|
+
if isinstance(metadata, EventDecoratorMetadata) and metadata.kind == "observer":
|
|
134
|
+
return metadata
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
max_concurrent_observers: int = DEFAULT_MAX_CONCURRENT_OBSERVERS,
|
|
140
|
+
observer_timeout: float = DEFAULT_OBSERVER_TIMEOUT,
|
|
141
|
+
max_sync_workers: int = DEFAULT_MAX_SYNC_WORKERS,
|
|
142
|
+
error_handler: ErrorHandler | None = None,
|
|
143
|
+
use_weak_refs: bool = True,
|
|
144
|
+
batching_config: BatchingConfig | None = None,
|
|
145
|
+
batching_enabled: bool = True,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Initialize the local observer manager.
|
|
148
|
+
|
|
149
|
+
Args
|
|
150
|
+
----
|
|
151
|
+
max_concurrent_observers: Maximum number of observers to run concurrently
|
|
152
|
+
observer_timeout: Timeout in seconds for each observer
|
|
153
|
+
max_sync_workers: Maximum thread pool workers for sync observers
|
|
154
|
+
error_handler: Optional error handler, defaults to LoggingErrorHandler
|
|
155
|
+
use_weak_refs: If True, use weak references to prevent memory leaks
|
|
156
|
+
batching_config: Optional batching configuration
|
|
157
|
+
batching_enabled: Toggle to bypass batching (useful for tests/debug)
|
|
158
|
+
"""
|
|
159
|
+
self._max_concurrent = max_concurrent_observers
|
|
160
|
+
self._timeout = observer_timeout
|
|
161
|
+
self._error_handler = error_handler or LoggingErrorHandler()
|
|
162
|
+
self._use_weak_refs = use_weak_refs
|
|
163
|
+
self._batching_config = batching_config or BatchingConfig()
|
|
164
|
+
|
|
165
|
+
self._semaphore = asyncio.Semaphore(max_concurrent_observers)
|
|
166
|
+
self._executor = ThreadPoolExecutor(max_workers=max_sync_workers)
|
|
167
|
+
self._executor_shutdown = False
|
|
168
|
+
|
|
169
|
+
self._handlers: dict[str, Any] = {}
|
|
170
|
+
|
|
171
|
+
# Event type filtering and per-observer config
|
|
172
|
+
self._event_filters: dict[str, set[EventType] | None] = {}
|
|
173
|
+
self._observer_timeouts: dict[str, float | None] = {}
|
|
174
|
+
self._observer_semaphores: dict[str, asyncio.Semaphore] = {}
|
|
175
|
+
|
|
176
|
+
if use_weak_refs:
|
|
177
|
+
self._weak_handlers: weakref.WeakValueDictionary = weakref.WeakValueDictionary()
|
|
178
|
+
self._strong_refs: dict[str, Any] = {}
|
|
179
|
+
self._observer_refs: dict[str, weakref.ref] = {}
|
|
180
|
+
|
|
181
|
+
self._batcher: EventBatcher[Event] | None = None
|
|
182
|
+
if batching_enabled:
|
|
183
|
+
self._batcher = EventBatcher(
|
|
184
|
+
self._flush_envelope,
|
|
185
|
+
self._batching_config,
|
|
186
|
+
logger=LOGGER,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def _on_observer_deleted(self, observer_id: str) -> None:
|
|
190
|
+
"""Cleanup callback when observer is garbage collected."""
|
|
191
|
+
self._event_filters.pop(observer_id, None)
|
|
192
|
+
self._observer_timeouts.pop(observer_id, None)
|
|
193
|
+
self._observer_semaphores.pop(observer_id, None)
|
|
194
|
+
|
|
195
|
+
def _store_observer(self, observer_id: str, observer: Any, keep_alive: bool) -> None:
|
|
196
|
+
"""Store observer with appropriate reference type."""
|
|
197
|
+
if self._use_weak_refs:
|
|
198
|
+
try:
|
|
199
|
+
self._weak_handlers[observer_id] = observer
|
|
200
|
+
self._observer_refs[observer_id] = weakref.ref(
|
|
201
|
+
observer, lambda _: self._on_observer_deleted(observer_id)
|
|
202
|
+
)
|
|
203
|
+
if keep_alive:
|
|
204
|
+
self._strong_refs[observer_id] = observer
|
|
205
|
+
except TypeError:
|
|
206
|
+
self._handlers[observer_id] = observer
|
|
207
|
+
self._strong_refs[observer_id] = observer
|
|
208
|
+
else:
|
|
209
|
+
self._handlers[observer_id] = observer
|
|
210
|
+
|
|
211
|
+
def register(
|
|
212
|
+
self,
|
|
213
|
+
handler: Observer | ObserverFunc | AsyncObserverFunc,
|
|
214
|
+
*,
|
|
215
|
+
observer_id: str | None = None,
|
|
216
|
+
event_types: EventTypesInput = None,
|
|
217
|
+
timeout: float | None = None,
|
|
218
|
+
max_concurrency: int | None = None,
|
|
219
|
+
keep_alive: bool = False,
|
|
220
|
+
) -> str:
|
|
221
|
+
"""Register an observer with optional event type filtering."""
|
|
222
|
+
|
|
223
|
+
metadata = self._get_observer_metadata(handler)
|
|
224
|
+
|
|
225
|
+
raw_event_types: EventTypesInput = (
|
|
226
|
+
event_types if event_types is not None else (metadata.event_types if metadata else None)
|
|
227
|
+
)
|
|
228
|
+
normalized_event_types = (
|
|
229
|
+
normalize_event_types(raw_event_types) if raw_event_types is not None else None
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
config = ObserverRegistrationConfig(
|
|
233
|
+
observer_id=(
|
|
234
|
+
observer_id
|
|
235
|
+
if observer_id is not None
|
|
236
|
+
else (metadata.id if metadata and metadata.id else None)
|
|
237
|
+
),
|
|
238
|
+
event_types=normalized_event_types,
|
|
239
|
+
timeout=(timeout if timeout is not None else (metadata.timeout if metadata else None)),
|
|
240
|
+
max_concurrency=(
|
|
241
|
+
max_concurrency
|
|
242
|
+
if max_concurrency is not None
|
|
243
|
+
else (metadata.max_concurrency if metadata else None)
|
|
244
|
+
),
|
|
245
|
+
keep_alive=keep_alive,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
resolved_id = config.observer_id or str(uuid.uuid4())
|
|
249
|
+
|
|
250
|
+
if resolved_id in self._event_filters:
|
|
251
|
+
raise ValueError(f"Observer '{resolved_id}' already registered")
|
|
252
|
+
|
|
253
|
+
if resolved_id in self._handlers:
|
|
254
|
+
raise ValueError(f"Observer '{resolved_id}' already registered")
|
|
255
|
+
|
|
256
|
+
if (
|
|
257
|
+
self._use_weak_refs
|
|
258
|
+
and hasattr(self, "_weak_handlers")
|
|
259
|
+
and resolved_id in self._weak_handlers
|
|
260
|
+
):
|
|
261
|
+
raise ValueError(f"Observer '{resolved_id}' already registered")
|
|
262
|
+
|
|
263
|
+
keep_alive_flag = config.keep_alive
|
|
264
|
+
|
|
265
|
+
if hasattr(handler, "handle"):
|
|
266
|
+
observer = cast("Observer", handler)
|
|
267
|
+
elif callable(handler):
|
|
268
|
+
observer = FunctionObserver(handler, self._executor)
|
|
269
|
+
keep_alive_flag = True
|
|
270
|
+
else:
|
|
271
|
+
raise TypeError(
|
|
272
|
+
f"Observer must be callable or implement Observer protocol, got {type(handler)}"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
self._store_observer(resolved_id, observer, keep_alive_flag)
|
|
276
|
+
|
|
277
|
+
self._event_filters[resolved_id] = config.event_types
|
|
278
|
+
|
|
279
|
+
if config.timeout is not None:
|
|
280
|
+
self._observer_timeouts[resolved_id] = config.timeout
|
|
281
|
+
else:
|
|
282
|
+
self._observer_timeouts.pop(resolved_id, None)
|
|
283
|
+
|
|
284
|
+
if config.max_concurrency is not None:
|
|
285
|
+
self._observer_semaphores[resolved_id] = asyncio.Semaphore(config.max_concurrency)
|
|
286
|
+
else:
|
|
287
|
+
self._observer_semaphores.pop(resolved_id, None)
|
|
288
|
+
|
|
289
|
+
return resolved_id
|
|
290
|
+
|
|
291
|
+
def unregister(self, handler_id: str) -> bool:
|
|
292
|
+
"""Unregister an observer by ID.
|
|
293
|
+
|
|
294
|
+
Args
|
|
295
|
+
----
|
|
296
|
+
handler_id: The ID of the observer to unregister
|
|
297
|
+
|
|
298
|
+
Returns
|
|
299
|
+
-------
|
|
300
|
+
bool: True if observer was found and removed, False otherwise
|
|
301
|
+
"""
|
|
302
|
+
found = False
|
|
303
|
+
|
|
304
|
+
if handler_id in self._handlers:
|
|
305
|
+
del self._handlers[handler_id]
|
|
306
|
+
found = True
|
|
307
|
+
|
|
308
|
+
if self._use_weak_refs:
|
|
309
|
+
if handler_id in self._weak_handlers:
|
|
310
|
+
del self._weak_handlers[handler_id]
|
|
311
|
+
found = True
|
|
312
|
+
|
|
313
|
+
if handler_id in self._strong_refs:
|
|
314
|
+
del self._strong_refs[handler_id]
|
|
315
|
+
found = True
|
|
316
|
+
|
|
317
|
+
if handler_id in self._event_filters:
|
|
318
|
+
del self._event_filters[handler_id]
|
|
319
|
+
|
|
320
|
+
self._observer_timeouts.pop(handler_id, None)
|
|
321
|
+
self._observer_semaphores.pop(handler_id, None)
|
|
322
|
+
|
|
323
|
+
return found
|
|
324
|
+
|
|
325
|
+
async def notify(self, event: Event) -> None:
|
|
326
|
+
"""Notify all interested observers of an event.
|
|
327
|
+
|
|
328
|
+
Only observers registered for this event type will be notified.
|
|
329
|
+
Errors are handled according to the configured error handler
|
|
330
|
+
but don't affect execution.
|
|
331
|
+
|
|
332
|
+
Args
|
|
333
|
+
----
|
|
334
|
+
event: The event to distribute to observers
|
|
335
|
+
"""
|
|
336
|
+
active_observers = self._collect_active_observers()
|
|
337
|
+
if not active_observers:
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
if self._batcher is not None:
|
|
341
|
+
await self._batcher.add(event)
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
observers = self._collect_interested_observers(event, active_observers)
|
|
345
|
+
if not observers:
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
tasks = [
|
|
349
|
+
self._dispatch_events(observer_id, observer, (event,))
|
|
350
|
+
for observer_id, observer in observers.items()
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
await self._run_with_timeout(
|
|
354
|
+
tasks,
|
|
355
|
+
type(event).__name__,
|
|
356
|
+
is_critical=self._is_priority_event(event),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def clear(self) -> None:
|
|
360
|
+
"""Remove all registered observers."""
|
|
361
|
+
self._handlers.clear()
|
|
362
|
+
self._event_filters.clear()
|
|
363
|
+
self._observer_timeouts.clear()
|
|
364
|
+
self._observer_semaphores.clear()
|
|
365
|
+
|
|
366
|
+
if self._use_weak_refs:
|
|
367
|
+
self._weak_handlers.clear()
|
|
368
|
+
self._strong_refs.clear()
|
|
369
|
+
self._observer_refs.clear()
|
|
370
|
+
|
|
371
|
+
async def close(self) -> None:
|
|
372
|
+
"""Close the manager and cleanup resources."""
|
|
373
|
+
if self._batcher is not None:
|
|
374
|
+
await self._batcher.close()
|
|
375
|
+
|
|
376
|
+
self.clear()
|
|
377
|
+
if not self._executor_shutdown:
|
|
378
|
+
self._executor.shutdown(wait=False)
|
|
379
|
+
self._executor_shutdown = True
|
|
380
|
+
|
|
381
|
+
def __len__(self) -> int:
|
|
382
|
+
"""Return number of registered observers.
|
|
383
|
+
|
|
384
|
+
Returns
|
|
385
|
+
-------
|
|
386
|
+
int: Count of active observers (including weak refs that are still alive)
|
|
387
|
+
"""
|
|
388
|
+
count = len(self._handlers)
|
|
389
|
+
|
|
390
|
+
if self._use_weak_refs:
|
|
391
|
+
for handler_id in list(self._weak_handlers.keys()):
|
|
392
|
+
if (
|
|
393
|
+
handler_id not in self._handlers
|
|
394
|
+
and self._weak_handlers.get(handler_id) is not None
|
|
395
|
+
):
|
|
396
|
+
count += 1
|
|
397
|
+
|
|
398
|
+
return count
|
|
399
|
+
|
|
400
|
+
@property
|
|
401
|
+
def batching_metrics(self) -> BatchingMetrics | None:
|
|
402
|
+
"""Expose batching metrics when batching is enabled."""
|
|
403
|
+
|
|
404
|
+
if self._batcher is None:
|
|
405
|
+
return None
|
|
406
|
+
return self._batcher.metrics
|
|
407
|
+
|
|
408
|
+
def __enter__(self) -> LocalObserverManager:
|
|
409
|
+
"""Context manager entry.
|
|
410
|
+
|
|
411
|
+
Returns
|
|
412
|
+
-------
|
|
413
|
+
Self for use in with statements
|
|
414
|
+
"""
|
|
415
|
+
return self
|
|
416
|
+
|
|
417
|
+
def __exit__(self, _exc_type: Any, _exc_val: Any, _exc_tb: Any) -> None:
|
|
418
|
+
"""Context manager exit with cleanup.
|
|
419
|
+
|
|
420
|
+
Args
|
|
421
|
+
----
|
|
422
|
+
_exc_type: Exception type if an exception occurred
|
|
423
|
+
_exc_val: Exception value if an exception occurred
|
|
424
|
+
_exc_tb: Exception traceback if an exception occurred
|
|
425
|
+
"""
|
|
426
|
+
if not self._executor_shutdown:
|
|
427
|
+
self._executor.shutdown(wait=True)
|
|
428
|
+
self._executor_shutdown = True
|
|
429
|
+
|
|
430
|
+
async def __aenter__(self) -> LocalObserverManager:
|
|
431
|
+
"""Async context manager entry.
|
|
432
|
+
|
|
433
|
+
Returns
|
|
434
|
+
-------
|
|
435
|
+
Self for use in async with statements
|
|
436
|
+
"""
|
|
437
|
+
return self
|
|
438
|
+
|
|
439
|
+
async def __aexit__(self, _exc_type: Any, _exc_val: Any, _exc_tb: Any) -> None:
|
|
440
|
+
"""Async context manager exit with cleanup.
|
|
441
|
+
|
|
442
|
+
Args
|
|
443
|
+
----
|
|
444
|
+
_exc_type: Exception type if an exception occurred
|
|
445
|
+
_exc_val: Exception value if an exception occurred
|
|
446
|
+
_exc_tb: Exception traceback if an exception occurred
|
|
447
|
+
"""
|
|
448
|
+
await self.close()
|
|
449
|
+
|
|
450
|
+
# Private helper methods
|
|
451
|
+
|
|
452
|
+
async def _flush_envelope(self, envelope: EventBatchEnvelope) -> None:
|
|
453
|
+
"""Flush a prepared event envelope to interested observers."""
|
|
454
|
+
|
|
455
|
+
active_observers = self._collect_active_observers()
|
|
456
|
+
if not active_observers:
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
tasks = []
|
|
460
|
+
batch_contains_priority = False
|
|
461
|
+
|
|
462
|
+
for observer_id, observer in active_observers.items():
|
|
463
|
+
events = self._filter_events_for_observer(observer_id, envelope.events)
|
|
464
|
+
if not events:
|
|
465
|
+
continue
|
|
466
|
+
|
|
467
|
+
if any(self._is_priority_event(event) for event in events):
|
|
468
|
+
batch_contains_priority = True
|
|
469
|
+
|
|
470
|
+
batch_envelope = None
|
|
471
|
+
if self._supports_batch(observer):
|
|
472
|
+
batch_envelope = self._make_filtered_envelope(envelope, events)
|
|
473
|
+
|
|
474
|
+
tasks.append(self._dispatch_events(observer_id, observer, events, batch_envelope))
|
|
475
|
+
|
|
476
|
+
if not tasks:
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
await self._run_with_timeout(
|
|
480
|
+
tasks,
|
|
481
|
+
f"batch:{envelope.flush_reason.value}",
|
|
482
|
+
is_critical=batch_contains_priority,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
def _collect_active_observers(self) -> dict[str, Observer]:
|
|
486
|
+
"""Collect currently active observers, pruning dead weak references."""
|
|
487
|
+
|
|
488
|
+
observers: dict[str, Observer] = dict(self._handlers)
|
|
489
|
+
|
|
490
|
+
if self._use_weak_refs:
|
|
491
|
+
for observer_id in list(self._weak_handlers.keys()):
|
|
492
|
+
observer = self._weak_handlers.get(observer_id)
|
|
493
|
+
if observer is None:
|
|
494
|
+
self._weak_handlers.pop(observer_id, None)
|
|
495
|
+
self._event_filters.pop(observer_id, None)
|
|
496
|
+
self._strong_refs.pop(observer_id, None)
|
|
497
|
+
continue
|
|
498
|
+
observers.setdefault(observer_id, observer)
|
|
499
|
+
|
|
500
|
+
return observers
|
|
501
|
+
|
|
502
|
+
def _collect_interested_observers(
|
|
503
|
+
self, event: Event, observers: dict[str, Observer]
|
|
504
|
+
) -> dict[str, Observer]:
|
|
505
|
+
"""Filter observers down to those interested in the given event."""
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
observer_id: observer
|
|
509
|
+
for observer_id, observer in observers.items()
|
|
510
|
+
if self._should_notify(observer_id, event)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async def _dispatch_events(
|
|
514
|
+
self,
|
|
515
|
+
observer_id: str,
|
|
516
|
+
observer: Observer,
|
|
517
|
+
events: Sequence[Event],
|
|
518
|
+
envelope: EventBatchEnvelope | None = None,
|
|
519
|
+
) -> None:
|
|
520
|
+
"""Dispatch one or more events to an observer under concurrency control."""
|
|
521
|
+
|
|
522
|
+
per_observer_semaphore = self._observer_semaphores.get(observer_id)
|
|
523
|
+
|
|
524
|
+
async def _execute() -> None:
|
|
525
|
+
if envelope is not None and self._supports_batch(observer):
|
|
526
|
+
await self._safe_invoke_batch(observer_id, observer, envelope, events)
|
|
527
|
+
else:
|
|
528
|
+
for event in events:
|
|
529
|
+
await self._safe_invoke(observer_id, observer, event)
|
|
530
|
+
|
|
531
|
+
if per_observer_semaphore is None:
|
|
532
|
+
async with self._semaphore:
|
|
533
|
+
await _execute()
|
|
534
|
+
return
|
|
535
|
+
|
|
536
|
+
async with self._semaphore, per_observer_semaphore:
|
|
537
|
+
await _execute()
|
|
538
|
+
|
|
539
|
+
def _filter_events_for_observer(
|
|
540
|
+
self, observer_id: str, events: Sequence[Event]
|
|
541
|
+
) -> tuple[Event, ...]:
|
|
542
|
+
"""Return events from the batch that match the observer's filter."""
|
|
543
|
+
|
|
544
|
+
event_filter = self._event_filters.get(observer_id)
|
|
545
|
+
if event_filter is None:
|
|
546
|
+
return tuple(events)
|
|
547
|
+
|
|
548
|
+
return tuple(event for event in events if type(event) in event_filter)
|
|
549
|
+
|
|
550
|
+
def _make_filtered_envelope(
|
|
551
|
+
self, envelope: EventBatchEnvelope, events: Sequence[Event]
|
|
552
|
+
) -> EventBatchEnvelope:
|
|
553
|
+
"""Create envelope tailored to an observer's filtered events."""
|
|
554
|
+
|
|
555
|
+
if len(events) == len(envelope.events):
|
|
556
|
+
return envelope
|
|
557
|
+
|
|
558
|
+
return EventBatchEnvelope(
|
|
559
|
+
batch_id=envelope.batch_id,
|
|
560
|
+
sequence_no=envelope.sequence_no,
|
|
561
|
+
created_at=envelope.created_at,
|
|
562
|
+
events=tuple(events),
|
|
563
|
+
flush_reason=envelope.flush_reason,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
def _supports_batch(self, observer: Observer) -> bool:
|
|
567
|
+
"""Check whether observer exposes a batch handler."""
|
|
568
|
+
|
|
569
|
+
handler = getattr(observer, "handle_batch", None)
|
|
570
|
+
return callable(handler)
|
|
571
|
+
|
|
572
|
+
async def _run_with_timeout(
|
|
573
|
+
self,
|
|
574
|
+
tasks: Sequence[Awaitable[Any]],
|
|
575
|
+
context_label: str,
|
|
576
|
+
*,
|
|
577
|
+
is_critical: bool = False,
|
|
578
|
+
) -> None:
|
|
579
|
+
"""Run observer tasks enforcing a global timeout."""
|
|
580
|
+
|
|
581
|
+
if not tasks:
|
|
582
|
+
return
|
|
583
|
+
|
|
584
|
+
total_timeout = self._timeout + (len(tasks) * DEFAULT_CLEANUP_INTERVAL)
|
|
585
|
+
|
|
586
|
+
try:
|
|
587
|
+
await asyncio.wait_for(
|
|
588
|
+
asyncio.gather(*tasks, return_exceptions=True),
|
|
589
|
+
timeout=total_timeout,
|
|
590
|
+
)
|
|
591
|
+
except TimeoutError:
|
|
592
|
+
self._error_handler.handle_error(
|
|
593
|
+
TimeoutError(f"Observer notification timed out after {total_timeout}s"),
|
|
594
|
+
{
|
|
595
|
+
"event_type": context_label,
|
|
596
|
+
"handler_name": "LocalObserverManager",
|
|
597
|
+
"is_critical": is_critical,
|
|
598
|
+
},
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
def _should_notify(self, observer_id: str, event: Event) -> bool:
|
|
602
|
+
"""Check if observer should be notified of this event type."""
|
|
603
|
+
event_filter = self._event_filters.get(observer_id)
|
|
604
|
+
|
|
605
|
+
if event_filter is None:
|
|
606
|
+
return True
|
|
607
|
+
|
|
608
|
+
# Check if event type matches any allowed type (supports subclassing)
|
|
609
|
+
return isinstance(event, tuple(event_filter))
|
|
610
|
+
|
|
611
|
+
async def _safe_invoke(self, observer_id: str, observer: Observer, event: Event) -> None:
|
|
612
|
+
"""Safely invoke an observer with timeout."""
|
|
613
|
+
timeout_value = self._observer_timeouts.get(observer_id, self._timeout)
|
|
614
|
+
try:
|
|
615
|
+
if timeout_value is None:
|
|
616
|
+
await observer.handle(event)
|
|
617
|
+
else:
|
|
618
|
+
await asyncio.wait_for(observer.handle(event), timeout=timeout_value)
|
|
619
|
+
except TimeoutError as exc:
|
|
620
|
+
name = getattr(observer, "__name__", observer.__class__.__name__)
|
|
621
|
+
self._error_handler.handle_error(
|
|
622
|
+
exc,
|
|
623
|
+
{
|
|
624
|
+
"handler_name": name,
|
|
625
|
+
"event_type": type(event).__name__,
|
|
626
|
+
"is_critical": self._is_priority_event(event),
|
|
627
|
+
},
|
|
628
|
+
)
|
|
629
|
+
except Exception as exc:
|
|
630
|
+
name = getattr(observer, "__name__", observer.__class__.__name__)
|
|
631
|
+
self._error_handler.handle_error(
|
|
632
|
+
exc,
|
|
633
|
+
{
|
|
634
|
+
"handler_name": name,
|
|
635
|
+
"event_type": type(event).__name__,
|
|
636
|
+
"is_critical": self._is_priority_event(event),
|
|
637
|
+
},
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
async def _safe_invoke_batch(
|
|
641
|
+
self,
|
|
642
|
+
observer_id: str,
|
|
643
|
+
observer: Observer,
|
|
644
|
+
envelope: EventBatchEnvelope,
|
|
645
|
+
events: Sequence[Event],
|
|
646
|
+
) -> None:
|
|
647
|
+
"""Safely invoke an observer's batch handler."""
|
|
648
|
+
|
|
649
|
+
handler = getattr(observer, "handle_batch", None)
|
|
650
|
+
if handler is None:
|
|
651
|
+
for event in events:
|
|
652
|
+
await self._safe_invoke(observer_id, observer, event)
|
|
653
|
+
return
|
|
654
|
+
|
|
655
|
+
timeout_value = self._observer_timeouts.get(observer_id, self._timeout)
|
|
656
|
+
|
|
657
|
+
loop = asyncio.get_running_loop()
|
|
658
|
+
|
|
659
|
+
try:
|
|
660
|
+
if inspect.iscoroutinefunction(handler):
|
|
661
|
+
coroutine = handler(envelope)
|
|
662
|
+
if timeout_value is None:
|
|
663
|
+
await coroutine
|
|
664
|
+
else:
|
|
665
|
+
await asyncio.wait_for(coroutine, timeout=timeout_value)
|
|
666
|
+
else:
|
|
667
|
+
task = loop.run_in_executor(self._executor, handler, envelope)
|
|
668
|
+
if timeout_value is None:
|
|
669
|
+
await task
|
|
670
|
+
else:
|
|
671
|
+
await asyncio.wait_for(task, timeout=timeout_value)
|
|
672
|
+
except TimeoutError as e:
|
|
673
|
+
name = getattr(observer, "__name__", observer.__class__.__name__)
|
|
674
|
+
self._error_handler.handle_error(
|
|
675
|
+
e,
|
|
676
|
+
{
|
|
677
|
+
"handler_name": name,
|
|
678
|
+
"event_type": f"batch:{envelope.flush_reason.value}",
|
|
679
|
+
"is_critical": any(self._is_priority_event(event) for event in events),
|
|
680
|
+
},
|
|
681
|
+
)
|
|
682
|
+
except Exception as e:
|
|
683
|
+
name = getattr(observer, "__name__", observer.__class__.__name__)
|
|
684
|
+
self._error_handler.handle_error(
|
|
685
|
+
e,
|
|
686
|
+
{
|
|
687
|
+
"handler_name": name,
|
|
688
|
+
"event_type": f"batch:{envelope.flush_reason.value}",
|
|
689
|
+
"is_critical": any(self._is_priority_event(event) for event in events),
|
|
690
|
+
},
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
def _is_priority_event(self, event: Event) -> bool:
|
|
694
|
+
"""Check if event is considered priority/critical."""
|
|
695
|
+
|
|
696
|
+
return isinstance(event, self._batching_config.priority_event_types)
|