fix(core): roll back partial component startup

This commit is contained in:
jinli.yl 2026-08-28 14:57:39 +08:00
parent 68125c2840
commit 49739980db
3 changed files with 82 additions and 16 deletions

View file

@ -345,16 +345,7 @@ class Application(BaseComponent):
)
if was_started:
await old_component.dump()
try:
await replacement.start()
except BaseException:
try:
await replacement.close()
except BaseException as cleanup_error:
self.logger.exception(
f"Failed to clean up replacement {component_type}:{name}: {cleanup_error}",
)
raise
await replacement.start()
# Commit the new generation synchronously so observers cannot see
# a context with only some dependency references updated.

View file

@ -223,15 +223,41 @@ class BaseComponent(ComponentMixin, ABC):
# ----- Lifecycle control --------------------------------------------
async def start(self) -> None:
"""Start the component once: resolve deps → start owned → run _start."""
"""Start once, rolling back partial resources when any startup stage fails."""
async with self._lock:
if self._is_started:
return
await self._resolve_bindings()
for owned in self._owned:
await owned.start()
await self._start()
self._is_started = True
started_owned: list[BaseComponent] = []
start_hook_entered = False
try:
await self._resolve_bindings()
for owned in self._owned:
await owned.start()
started_owned.append(owned)
start_hook_entered = True
await self._start()
self._is_started = True
except BaseException:
await self._rollback_start(start_hook_entered, started_owned)
raise
async def _rollback_start(
self,
start_hook_entered: bool,
started_owned: list["BaseComponent"],
) -> None:
"""Best-effort cleanup that preserves the original startup error."""
if start_hook_entered:
try:
await self._close()
except BaseException as exc:
self.logger.exception(f"Failed to roll back partially started component {self.name}: {exc}")
for owned in reversed(started_owned):
try:
await owned.close()
except BaseException as exc:
self.logger.exception(f"Failed to close owned component {owned.name} during startup rollback: {exc}")
self._is_started = False
async def close(self) -> None:
"""Close the component once: run _close → close owned in reverse order."""

View file

@ -3,6 +3,7 @@
# pylint: disable=protected-access,missing-function-docstring,missing-class-docstring,attribute-defined-outside-init
import asyncio
import contextlib
import os
import tempfile
@ -205,6 +206,54 @@ def test_close_closes_owned_when_parent_close_fails():
asyncio.run(run())
def test_start_failure_rolls_back_partial_resources():
class PartialStartComponent(BaseComponent):
component_type = ComponentEnum.FILE_CHUNKER
def __init__(self):
super().__init__()
self.resource_open = False
self.task = None
self.rollback_count = 0
async def _start(self):
self.resource_open = True
self.task = asyncio.create_task(asyncio.Event().wait())
raise RuntimeError("partial startup failed")
async def _close(self):
self.rollback_count += 1
self.resource_open = False
if self.task is not None:
self.task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self.task
self.task = None
async def run():
comp = PartialStartComponent()
owned = StubComponent(name="owned")
comp.dep = BaseComponent.bind(
"owned",
StubComponent,
default_factory=lambda: owned,
)
with pytest.raises(RuntimeError, match="partial startup failed"):
await comp.start()
assert comp.is_started is False
assert comp.resource_open is False
assert comp.task is None
assert comp.rollback_count == 1
assert owned.is_started is False
assert owned.close_count == 1
await comp.close()
assert comp.rollback_count == 1
asyncio.run(run())
# -- standalone resolution ----------------------------------------------------