乐于分享
好东西不私藏

OpenHands 源码-事件溯源系统 (Event Log + 3 Backends)

OpenHands 源码-事件溯源系统 (Event Log + 3 Backends)

OpenHands 源码解析系列

第 5 讲:事件溯源系统 (Event Log + 3 Backends)

基于 OpenHands 源码 · 2026-08-01

一、事件溯源:OpenHands 的数据基石

在 OpenHands V1 架构中,事件(Event)是会话中所有交互的唯一真相来源。用户的每一条消息、Agent 的每一次工具调用、沙箱的每一个输出片段——全部被序列化为 JSON 事件,持久化到不同的存储后端。

这种事件溯源(Event Sourcing)模式意味着:

🔹 会话状态不是直接存储的,而是由事件序列回放重建

🔹 每个事件是不可变的 JSON 文档,存储在独立文件中

🔹 三种存储后端(文件系统 / AWS S3 / Google Cloud Storage)通过同一抽象接口切换

🔹 事件回调系统(Event Callback)在事件产生时触发下游处理

📦 源码位置

openhands/app_server/event/ — 事件存储服务

openhands/app_server/event_callback/ — 事件回调系统

openhands/app_server/conversation_paths.py — 路径构造工具

openhands/app_server/utils/environment.py — 存储提供商检测

二、事件存储架构总览

事件溯源系统架构

+----------------------------------------------------------+
|                    EventService (ABC)                     |
|  抽象接口: get / search / count / save / batch_get        |
+---------------------------+------------------------------+
                            |
            +---------------+----------------+
            |               |                |
+-----------v----------+ +---v----------+ +--v-------------+
|  EventServiceBase    | | (future:     | | (future:       |
|  (带路径逻辑的基类)   | |  RedisEvent  | |  PostgresEvent |
+-----------+----------+ +-------------+ +---------------+
            |
    +-------+--------+
    |       |        |
+v1  |     v2 |     v3 |
    |       |        |
+---v----+ +v-------v+ +v------------v+
|Filesys | |   AWS   | |  Google Cloud|
|Event   | |  S3     | |    Storage   |
|Service | | Service | |   Service    |
+--------+ +---------+ +--------------+
    |       |        |
    +-------+--------+
            |
    {prefix}/{user_id}/v1_conversations/
    {conversation_id_hex}/{event_id_hex}.json

三、EventService 抽象接口

所有事件存储后端的共同契约,定义了事件操作的完整 API:

📄 event_service.py (第 18-71 行)

class EventService(ABC):
    """Event Service for getting events."""

    @abstractmethod
    async def get_event(self, conversation_id: UUID, event_id: UUID) -> Event | None:
        """Given an id, retrieve an event."""

    @abstractmethod
    async def search_events(
        self,
        conversation_id: UUID,
        kind__eq: EventKind | None = None,       # 按事件类型过滤
        timestamp__gte: datetime | None = None,  # 时间范围过滤
        timestamp__lt: datetime | None = None,
        sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
        page_id: str | None = None,              # 分页游标
        limit: int = 100,
    ) -> EventPage:
        """Search events matching the given filters."""

    @abstractmethod
    async def count_events(...) -> int:
        """Count events matching the given filters."""

    @abstractmethod
    async def save_event(self, conversation_id: UUID, event: Event):
        """Save an event. Internal method intended not be part of the REST api."""

    async def batch_get_events(
        self, conversation_id: UUID, event_ids: list[UUID]
    ) -> list[Event | None]:
        """Batch retrieval — asyncio.gather 并行获取多个事件"""

关键设计:接口纯粹基于 UUID 寻址,不暴露任何存储细节。调用方(路由层、会话服务、导出工具)完全不知道事件存在于本地磁盘、S3 还是 GCS。

同时定义了 EventServiceInjector 依赖注入抽象:

📄 event_service.py (第 73-74 行)

class EventServiceInjector(DiscriminatedUnionMixin, Injector[EventService], ABC):
    pass

四、EventServiceBase — 路径与并发核心

这是最关键的中间层,封装了路径构造并发加载的通用逻辑:

📄 event_service_base.py (第 24-28 行)

def _event_load_concurrency() -> int:
    try:
        return max(1, int(os.getenv('EVENT_SERVICE_LOAD_EVENT_CONCURRENCY', '10')))
    except ValueError:
        return 10

默认并发度为 10,可通过环境变量 EVENT_SERVICE_LOAD_EVENT_CONCURRENCY 调整。这是性能调优的关键参数。

4.1 路径构造逻辑

每个事件的存储路径遵循统一的层级结构:

📄 event_service_base.py (第 66-84 行)

async def get_conversation_path(self, conversation_id: UUID) -> Path:
    """Get a path for a conversation. Ensure user_id is included if possible."""
    path = self.prefix
    if self.user_id:
        path /= self.user_id
    elif self.app_conversation_info_service:
        # 异步懒加载:如果 user_id 不在上下文中,
        # 从会话信息中查找 created_by_user_id
        task = self.app_conversation_info_load_tasks.get(conversation_id)
        if task is None:
            task = asyncio.create_task(
                self.app_conversation_info_service.get_app_conversation_info(
                    conversation_id
                )
            )
            self.app_conversation_info_load_tasks[conversation_id] = task
        conversation_info = await task
        if conversation_info and conversation_info.created_by_user_id:
            path /= conversation_info.created_by_user_id
    path = path / V1_CONVERSATIONS_DIR / conversation_id.hex
    return path

路径结构:

📄 conversation_paths.py (第 12 行)

V1_CONVERSATIONS_DIR = 'v1_conversations'

# 最终路径格式:
# {persistence_dir}/{user_id}/v1_conversations/{conversation_id_hex}/{event_id_hex}.json

注意懒加载设计:app_conversation_info_load_tasks 缓存了异步任务,避免重复查询数据库。

4.2 并发事件加载

📄 event_service_base.py (第 56-64 行)

async def _load_events_from_paths(self, paths: list[Path]) -> list[Event | None]:
    loop = asyncio.get_running_loop()
    semaphore = asyncio.Semaphore(_event_load_concurrency())

    async def load_event(path: Path) -> Event | None:
        async with semaphore:
            return await loop.run_in_executor(None, self._load_event, path)

    return await asyncio.gather(*(load_event(path) for path in paths))

三个设计要点:

🔹 信号量限流:Semaphore 控制最大并发,防止 I/O 风暴

🔹 线程池执行run_in_executor 将阻塞的文件/网络 I/O 移到线程池,不阻塞事件循环

🔹 并行聚合asyncio.gather 等待所有事件加载完成

4.3 搜索与过滤

📄 event_service_base.py (第 94-143 行)

async def search_events(
    self,
    conversation_id: UUID,
    kind__eq: EventKind | None = None,
    timestamp__gte: datetime | None = None,
    timestamp__lt: datetime | None = None,
    sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
    page_id: str | None = None,
    limit: int = 100,
) -> EventPage:
    """Search events matching the given filters."""
    loop = asyncio.get_running_loop()
    prefix = await self.get_conversation_path(conversation_id)
    paths = await loop.run_in_executor(None, self._search_paths, prefix)

    events = await self._load_events_from_paths(paths)
    # 时间过滤:datetime 转为 ISO 字符串与 event.timestamp 比较
    timestamp_gte_str = timestamp__gte.isoformat() if timestamp__gte else None
    timestamp_lt_str = timestamp__lt.isoformat() if timestamp__lt else None

    items = []
    for event in events:
        if not event:
            continue
        if kind__eq and event.kind != kind__eq:
            continue
        if timestamp_gte_str and event.timestamp < timestamp_gte_str:
            continue
        if timestamp_lt_str and event.timestamp >= timestamp_lt_str:
            continue
        items.append(event)

    if sort_order:
        items.sort(
            key=lambda e: e.timestamp,
            reverse=(sort_order == EventSortOrder.TIMESTAMP_DESC),
        )

    # 分页逻辑
    start_offset = 0
    next_page_id = None
    if page_id:
        start_offset = int(page_id)
        items = items[start_offset:]
    if len(items) > limit:
        next_page_id = str(start_offset + limit)
        items = items[:limit]

    return EventPage(items=items, next_page_id=next_page_id)

五、三大存储后端实现

OpenHands 通过三个后端实现同一套抽象接口,每个后端只需覆写三个方法:

方法 FilesystemEventService AwsEventService GoogleCloudEventService
_load_event path.read_text() s3_client.get_object() bucket.blob().open('r')
_store_event path.write_text() s3_client.put_object() blob.open('w')
_search_paths glob.glob() list_objects_v2() bucket.list_blobs()
认证方式 无需认证 IAM Role (无显式凭证) Application Default Credentials

5.1 FilesystemEventService — 本地文件系统

📄 filesystem_event_service.py (第 17-43 行)

@dataclass
class FilesystemEventService(EventServiceBase):
    """Event service based on file system"""

    limit: int = 500

    def _load_event(self, path: Path) -> Event | None:
        try:
            content = path.read_text()
            content = Event.model_validate_json(content)
            return content
        except Exception:
            if path.exists():
                _logger.exception('Error reading event', stack_info=True)
            return None

    def _store_event(self, path: Path, event: Event):
        path.parent.mkdir(parents=True, exist_ok=True)
        content = event.model_dump_json(indent=2)
        path.write_text(content)

    def _search_paths(self, prefix: Path, page_id: str | None = None) -> list[Path]:
        search_path = f'{prefix}/*'
        files = glob.glob(str(search_path))
        paths = [Path(file) for file in files]
        return paths

最简实现:每个事件是一个独立的 JSON 文件。Pydantic 的 model_validate_jsonmodel_dump_json 负责序列化/反序列化。

5.2 AwsEventService — AWS S3

📄 aws_event_service.py (第 27-77 行)

@dataclass
class AwsEventService(EventServiceBase):
    """AWS S3-based implementation of EventService.
    Uses role-based authentication, so no explicit credentials are needed."""

    s3_client: Any
    bucket_name: str

    def _load_event(self, path: Path) -> Event | None:
        try:
            response = self.s3_client.get_object(
                Bucket=self.bucket_name, Key=str(path)
            )
            with response['Body'] as stream:
                json_data = stream.read().decode('utf-8')
            event = Event.model_validate_json(json_data)
            return event
        except botocore.exceptions.ClientError as e:
            if e.response['Error']['Code'] == 'NoSuchKey':
                return None
            _logger.exception(f'Error reading event from {path}', stack_info=True)
            return None

    def _search_paths(self, prefix: Path, page_id: str | None = None) -> list[Path]:
        kwargs = {'Bucket': self.bucket_name, 'Prefix': str(prefix)}
        if page_id:
            kwargs['ContinuationToken'] = page_id
        response = self.s3_client.list_objects_v2(**kwargs)
        contents = response.get('Contents', [])
        paths = [Path(obj['Key']) for obj in contents]
        return paths

关键点:使用 IAM Role 认证,无需显式密钥。S3 Key 路径与文件系统路径完全一致,实现了存储透明切换。

5.3 GoogleCloudEventService — GCS

📄 google_cloud_event_service.py (第 26-71 行)

@lru_cache(maxsize=1)
def _get_shared_storage_client() -> Client:
    """Return a process-wide shared GCS client.
    google.cloud.storage.Client is thread-safe and manages its own urllib3
    connection pool.  Creating one per request leads to pool exhaustion under
    load ("Connection pool is full, discarding connection")."""
    return storage.Client()

@dataclass
class GoogleCloudEventService(EventServiceBase):
    """Google Cloud Storage-based implementation of EventService."""

    bucket: Bucket

    def _load_event(self, path: Path) -> Event | None:
        blob: Blob = self.bucket.blob(str(path))
        try:
            with blob.open('r') as f:
                json_data = f.read()
            event = Event.model_validate_json(json_data)
            return event
        except NotFound:
            return None

    def _search_paths(self, prefix: Path, page_id: str | None = None) -> list[Path]:
        blobs: Iterator[Blob] = self.bucket.list_blobs(
            page_token=page_id, prefix=str(prefix)
        )
        paths = list(Path(blob.name) for blob in blobs)
        return paths

性能优化:注意到 @lru_cache(maxsize=1) 装饰器——GCS Client 是进程级共享的,避免每个请求创建连接池导致连接耗尽。

六、存储提供商自动检测

系统启动时自动检测环境配置,选择对应的后端:

📄 utils/environment.py (第 13-42 行)

class StorageProvider(str, Enum):
    """Storage provider types for event and shared event storage."""
    AWS = 'aws'
    GCP = 'gcp'
    FILESYSTEM = 'filesystem'

def get_storage_provider() -> StorageProvider:
    provider = os.environ.get('SHARED_EVENT_STORAGE_PROVIDER', '').lower()
    if not provider:
        provider = os.environ.get('FILE_STORE', '').lower()

    if provider in ('aws', 's3'):
        return StorageProvider.AWS
    elif provider in ('gcp', 'google_cloud'):
        return StorageProvider.GCP
    else:
        return StorageProvider.FILESYSTEM

配置链:SHARED_EVENT_STORAGE_PROVIDERFILE_STORE → 默认 FILESYSTEM

📄 config.py (第 307-327 行)

if config.event is None:
    provider = get_storage_provider()

    if provider == StorageProvider.AWS:
        bucket_name = os.environ.get('FILE_STORE_PATH')
        if not bucket_name:
            raise ValueError(
                'FILE_STORE_PATH environment variable is required for S3 storage'
            )
        config.event = AwsEventServiceInjector(bucket_name=bucket_name)
    elif provider == StorageProvider.GCP:
        bucket_name = os.environ.get('FILE_STORE_PATH')
        if not bucket_name:
            raise ValueError(
                'FILE_STORE_PATH environment variable is required for Google Cloud storage'
            )
        config.event = GoogleCloudEventServiceInjector(bucket_name=bucket_name)
    else:
        config.event = FilesystemEventServiceInjector()

七、事件回调系统

事件产生后,Event Callback 系统负责触发下游处理。这是一个可插拔的回调框架:

7.1 回调模型

📄 event_callback_models.py (第 40-94 行)

class EventCallbackStatus(Enum):
    ACTIVE = 'ACTIVE'
    DISABLED = 'DISABLED'
    COMPLETED = 'COMPLETED'
    ERROR = 'ERROR'

class EventCallbackProcessor(DiscriminatedUnionMixin, ABC):
    event_kind: ClassVar[EventKind] = 'MessageEvent'

    @abstractmethod
    async def __call__(
        self,
        conversation_id: UUID,
        callback: EventCallback,
        event: Event,
    ) -> EventCallbackResult | None:
        """Process an event."""

class EventCallback(CreateEventCallbackRequest):
    id: OpenHandsUUID = Field(default_factory=uuid4)
    status: EventCallbackStatus = Field(default=EventCallbackStatus.ACTIVE)
    created_at: datetime = Field(default_factory=utc_now)
    updated_at: datetime = Field(default_factory=utc_now)

每个回调绑定一个 会话 ID + 事件类型 + 处理器。当匹配的事件产生时,处理器被调用。

7.2 SQL 回调服务 — 短会话设计

SQL 实现的核心设计哲学是不持有长连接

📄 sql_event_callback_service.py (第 92-108 行)

@dataclass
class SQLEventCallbackService(EventCallbackService):
    """SQL implementation of EventCallbackService.

    The service does **not** hold a long-lived ``AsyncSession``. Each public
    method opens a short-lived session via ``self.async_session_maker`` for the
    duration of its own DB work. This means:

    * The pool connection is only checked out while the method runs an
      actual SQL statement — it is released as soon as the method returns,
      so callers never accidentally pin a connection across slow logic
      (e.g. webhook deliveries, ``asyncio.gather`` of callback processors).
    * Methods are independent: a failure in one (e.g. a flush error) cannot
      leave another with a session in an unknown transactional state.
    * The service itself is no longer an async-context-manager. There is
      no per-service ``__aenter__``/``__aexit__`` lifecycle to get wrong.
    """

    async_session_maker: async_sessionmaker

三个保证:

🔹 连接池只在实际执行 SQL 时占用,方法返回即释放

🔹 方法间独立,一个方法的失败不会影响其他方法的会话状态

🔹 服务本身不再是异步上下文管理器,没有生命周期管理的风险

7.3 回调执行流程

📄 sql_event_callback_service.py (第 248-281 行)

async def execute_callbacks(self, conversation_id: UUID, event: Event) -> None:
    """Run all active callbacks for the event and persist their results."""
    callbacks = await self.get_active_callbacks(conversation_id, event)
    if not callbacks:
        return
    outcomes = await asyncio.gather(
        *[invoke_callback(cb, conversation_id, event) for cb in callbacks],
        return_exceptions=True,
    )
    normalised: list[StoredEventCallbackResult | None] = []
    for callback, outcome in zip(callbacks, outcomes, strict=False):
        if isinstance(outcome, BaseException):
            _logger.exception(
                f'Exception in callback {callback.id}', stack_info=True
            )
            normalised.append(
                StoredEventCallbackResult(
                    status=EventCallbackResultStatus.ERROR,
                    event_callback_id=callback.id,
                    event_id=event.id,
                    conversation_id=conversation_id,
                    detail=str(outcome),
                )
            )
        else:
            normalised.append(outcome)
    await self.persist_callback_results(callbacks, normalised)

三个步骤各自独立的数据库会话:

🔹 get_active_callbacks:查询活跃回调(短会话,立即释放)

🔹 invoke_callback:执行回调处理器(无数据库连接!)

🔹 persist_callback_results:持久化结果(新的短会话)

7.4 实际回调:SetTitleCallbackProcessor

一个真实的回调处理器——自动为会话设置标题:

📄 set_title_callback_processor.py (第 28-80 行)

# Delay between attempts to poll title
_POLL_DELAY_S = 3
# Number of attempts to poll title
_NUM_POLL_ATTEMPTS = 4
# Avoid starting one slow title poll per webhook event.
_CONVERSATIONS_BEING_POLLED: set[UUID] = set()

async def _poll_for_title(
    httpx_client: httpx.AsyncClient,
    url: str,
    session_api_key: str | None,
) -> str | None:
    """Poll the agent server for the conversation title."""
    for _ in range(_NUM_POLL_ATTEMPTS):
        await asyncio.sleep(_POLL_DELAY_S)
        try:
            headers = {'X-Session-API-Key': session_api_key} if session_api_key else {}
            response = await httpx_client.get(url, headers=headers)
            response.raise_for_status()
        except httpx.HTTPError as exc:
            _logger.warning('Title poll failed for conversation %s: %s', url, exc)
        else:
            title = response.json().get('title')
            if title:
                return title
    return None

设计要点:最多重试 4 次,每次间隔 3 秒;使用全局 _CONVERSATIONS_BEING_POLLED 集合防止重复轮询。

八、API 路由层

事件系统的 REST API 入口:

📄 event_router.py (第 18-67 行)

router = APIRouter(
    prefix='/conversation/{conversation_id}/events',
    tags=['Events'],
    dependencies=get_dependencies(),
)
event_service_dependency = depends_event_service()

@router.get('/search')
async def search_events(
    conversation_id: str,
    kind__eq: EventKind | None = None,
    timestamp__gte: datetime | None = None,
    timestamp__lt: datetime | None = None,
    sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
    page_id: str | None = None,
    limit: int = 100,
    event_service: EventService = event_service_dependency,
) -> EventPage:
    """Search / List events."""
    return await event_service.search_events(...)

@router.get('/count')
async def count_events(...) -> int:
    """Count events matching the given filters."""

@router.get('')
async def batch_get_events(
    conversation_id: str,
    id: list[str],
    event_service: EventService = event_service_dependency,
) -> list[Event | None]:
    """Get a batch of events given their ids."""
    if len(id) > 100:
        raise HTTPException(status_code=400, ...)
    event_ids = [UUID(id_) for id_ in id]
    return await event_service.batch_get_events(UUID(conversation_id), event_ids)

九、架构总结

设计维度 实现方式
存储抽象 EventService ABC + 3 个具体后端
路径一致性 所有后端使用相同 Path 格式
并发控制 Semaphore + run_in_executor + asyncio.gather
懒加载优化 app_conversation_info_load_tasks 缓存异步任务
连接池保护 短会话设计,回调执行时不持有 DB 连接
容错机制 return_exceptions=True + 异常转为 ERROR 状态
扩展性 EventCallbackProcessor 抽象 + DiscriminatedUnionMixin

事件溯源系统是 OpenHands 的数据骨干——每一个用户交互、Agent 行为都被不可变地记录下来,支撑了会话回放、轨迹导出、回调处理等核心功能。下一讲我们将深入沙箱生命周期管理。

📚 系列导航

← 第 4 讲:会话管理核心

→ 第 6 讲:沙箱生命周期

关注公众号「AI技术推荐官」获取更多源码解析内容