# Issue #17: 非同期ノード基本サポート

**Phase:** 1c
**優先度:** 低
**依存関係:** #03, #05
**見積もり:** 1.5日

---

## 概要

非同期関数（async def）を@nodeデコレータでサポートする。
仕様書6.4節で定義された非同期パイプラインの基本機能を実装する。

**注意:** Phase 1では基本サポートのみ。高度な非同期機能（並列実行、タイムアウト等）は将来のPhaseで実装。

---

## TDD実装手順

### Step 1: Red（テストを書く）

```python
# tests/unit/core/test_async_node.py
"""Tests for async node support."""
import pytest
import asyncio
from unittest.mock import patch, AsyncMock


class TestAsyncNodeBasic:
    """Test basic async node functionality."""

    @pytest.mark.asyncio
    async def test_async_node_execution(self):
        """Should execute async node."""
        from railway.core.decorators import node

        @node
        async def async_fetch() -> str:
            await asyncio.sleep(0.01)
            return "data"

        result = await async_fetch()
        assert result == "data"

    @pytest.mark.asyncio
    async def test_async_node_with_args(self):
        """Should pass arguments to async node."""
        from railway.core.decorators import node

        @node
        async def async_process(x: int, y: int) -> int:
            await asyncio.sleep(0.01)
            return x + y

        result = await async_process(3, 4)
        assert result == 7

    @pytest.mark.asyncio
    async def test_async_node_logging(self):
        """Should log async node execution."""
        from railway.core.decorators import node

        @node
        async def logged_async() -> str:
            return "logged"

        with patch("railway.core.decorators.logger") as mock_logger:
            await logged_async()

            # Should have start and complete logs
            info_calls = [str(c) for c in mock_logger.info.call_args_list]
            assert any("Starting" in str(c) for c in info_calls)
            assert any("Completed" in str(c) for c in info_calls)


class TestAsyncNodeMetadata:
    """Test async node metadata."""

    def test_async_node_has_metadata(self):
        """Should have _is_async=True metadata."""
        from railway.core.decorators import node

        @node
        async def async_func() -> str:
            return "async"

        assert hasattr(async_func, '_is_railway_node')
        assert async_func._is_railway_node is True
        assert hasattr(async_func, '_is_async')
        assert async_func._is_async is True

    def test_sync_node_has_async_false(self):
        """Sync node should have _is_async=False."""
        from railway.core.decorators import node

        @node
        def sync_func() -> str:
            return "sync"

        assert async_func._is_async is False


class TestAsyncNodeErrors:
    """Test async node error handling."""

    @pytest.mark.asyncio
    async def test_async_node_propagates_error(self):
        """Should propagate errors from async node."""
        from railway.core.decorators import node

        @node
        async def failing_async() -> str:
            await asyncio.sleep(0.01)
            raise ValueError("Async error")

        with pytest.raises(ValueError, match="Async error"):
            await failing_async()

    @pytest.mark.asyncio
    async def test_async_node_logs_error(self):
        """Should log errors from async node."""
        from railway.core.decorators import node

        @node
        async def error_async() -> str:
            raise RuntimeError("Runtime error")

        with patch("railway.core.decorators.logger") as mock_logger:
            with pytest.raises(RuntimeError):
                await error_async()

            error_calls = [str(c) for c in mock_logger.error.call_args_list]
            assert any("RuntimeError" in str(c) for c in error_calls)


class TestAsyncPipeline:
    """Test async pipeline support."""

    @pytest.mark.asyncio
    async def test_async_pipeline_basic(self):
        """Should execute async pipeline."""
        from railway.core.pipeline import async_pipeline
        from railway.core.decorators import node

        @node
        async def step1(x: int) -> int:
            await asyncio.sleep(0.01)
            return x + 1

        @node
        async def step2(x: int) -> int:
            await asyncio.sleep(0.01)
            return x * 2

        result = await async_pipeline(1, step1, step2)
        assert result == 4  # (1 + 1) * 2

    @pytest.mark.asyncio
    async def test_async_pipeline_with_sync_nodes(self):
        """Should handle mixed sync/async nodes."""
        from railway.core.pipeline import async_pipeline
        from railway.core.decorators import node

        @node
        def sync_step(x: int) -> int:
            return x + 1

        @node
        async def async_step(x: int) -> int:
            await asyncio.sleep(0.01)
            return x * 2

        result = await async_pipeline(1, sync_step, async_step)
        assert result == 4  # (1 + 1) * 2


class TestSyncPipelineRejectsAsync:
    """Test that sync pipeline rejects async nodes."""

    def test_sync_pipeline_rejects_async_node(self):
        """Should raise error when async node in sync pipeline."""
        from railway.core.pipeline import pipeline
        from railway.core.decorators import node

        @node
        async def async_node(x: int) -> int:
            return x + 1

        with pytest.raises(TypeError) as exc_info:
            pipeline(1, async_node)

        assert "async" in str(exc_info.value).lower()


class TestAsyncNodeWithRetry:
    """Test async node with retry functionality."""

    @pytest.mark.asyncio
    async def test_async_node_retry(self):
        """Should retry async node on failure."""
        from railway.core.decorators import node, Retry

        call_count = 0

        @node(retry=Retry(max_attempts=3, min_wait=0.01, max_wait=0.02))
        async def flaky_async() -> str:
            nonlocal call_count
            call_count += 1
            if call_count < 3:
                raise ValueError("Temporary failure")
            return "success"

        with patch("railway.core.decorators.logger"):
            result = await flaky_async()

        assert result == "success"
        assert call_count == 3
```

```bash
# 実行して失敗を確認
pytest tests/unit/core/test_async_node.py -v
# Expected: FAILED
```

### Step 2: Green（最小限の実装）

```python
# railway/core/decorators.py を更新（async対応追加）

import asyncio
import inspect
from functools import wraps
from typing import Callable, TypeVar, ParamSpec, Any
from loguru import logger
from tenacity import (
    retry as tenacity_retry,
    stop_after_attempt,
    wait_exponential,
    RetryError,
    AsyncRetrying,
)

P = ParamSpec('P')
T = TypeVar('T')


class Retry:
    """Retry configuration for nodes."""

    def __init__(
        self,
        max_attempts: int = 3,
        min_wait: float = 2.0,
        max_wait: float = 10.0,
        exponential_base: int = 2,
    ):
        self.max_attempts = max_attempts
        self.min_wait = min_wait
        self.max_wait = max_wait
        self.exponential_base = exponential_base


def node(
    func: Callable[P, T] | None = None,
    *,
    retry: bool | Retry = False,
    log_input: bool = False,
    log_output: bool = False,
    name: str | None = None,
) -> Callable[P, T] | Callable[[Callable[P, T]], Callable[P, T]]:
    """
    Node decorator with async support.
    """

    def decorator(f: Callable[P, T]) -> Callable[P, T]:
        node_name = name or f.__name__
        is_async = asyncio.iscoroutinefunction(f)

        if is_async:
            return _create_async_wrapper(f, node_name, retry, log_input, log_output)
        else:
            return _create_sync_wrapper(f, node_name, retry, log_input, log_output)

    if func is None:
        return decorator
    return decorator(func)


def _create_sync_wrapper(
    f: Callable[P, T],
    node_name: str,
    retry_config: bool | Retry,
    log_input: bool,
    log_output: bool,
) -> Callable[P, T]:
    """Create wrapper for synchronous function."""

    @wraps(f)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
        if log_input:
            logger.debug(f"[{node_name}] Input: args={args}, kwargs={kwargs}")

        logger.info(f"[{node_name}] Starting...")

        # Setup retry if needed
        retryable_func = _setup_sync_retry(f, node_name, retry_config)

        try:
            result = retryable_func(*args, **kwargs)

            if log_output:
                logger.debug(f"[{node_name}] Output: {result}")

            logger.info(f"[{node_name}] ✓ Completed")
            return result

        except RetryError as e:
            original_exception = e.last_attempt.exception()
            logger.error(
                f"[{node_name}] ✗ Failed after retries: "
                f"{type(original_exception).__name__}: {original_exception}"
            )
            raise original_exception

        except Exception as e:
            logger.error(f"[{node_name}] ✗ Failed: {type(e).__name__}: {e}")
            raise

    wrapper._is_railway_node = True
    wrapper._node_name = node_name
    wrapper._original_func = f
    wrapper._is_async = False

    return wrapper


def _create_async_wrapper(
    f: Callable[P, T],
    node_name: str,
    retry_config: bool | Retry,
    log_input: bool,
    log_output: bool,
) -> Callable[P, T]:
    """Create wrapper for asynchronous function."""

    @wraps(f)
    async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
        if log_input:
            logger.debug(f"[{node_name}] Input: args={args}, kwargs={kwargs}")

        logger.info(f"[{node_name}] Starting...")

        try:
            # Execute with or without retry
            if retry_config:
                result = await _execute_async_with_retry(
                    f, args, kwargs, node_name, retry_config
                )
            else:
                result = await f(*args, **kwargs)

            if log_output:
                logger.debug(f"[{node_name}] Output: {result}")

            logger.info(f"[{node_name}] ✓ Completed")
            return result

        except Exception as e:
            logger.error(f"[{node_name}] ✗ Failed: {type(e).__name__}: {e}")
            raise

    wrapper._is_railway_node = True
    wrapper._node_name = node_name
    wrapper._original_func = f
    wrapper._is_async = True

    return wrapper


async def _execute_async_with_retry(
    f: Callable,
    args: tuple,
    kwargs: dict,
    node_name: str,
    retry_config: bool | Retry,
) -> Any:
    """Execute async function with retry."""
    if isinstance(retry_config, Retry):
        config = retry_config
    else:
        from railway.core.config import get_retry_config
        config = get_retry_config(node_name)

    async for attempt in AsyncRetrying(
        stop=stop_after_attempt(config.max_attempts),
        wait=wait_exponential(
            multiplier=config.exponential_base if hasattr(config, 'exponential_base') else config.multiplier,
            min=config.min_wait,
            max=config.max_wait,
        ),
        reraise=True,
    ):
        with attempt:
            if attempt.retry_state.attempt_number > 1:
                logger.warning(
                    f"[{node_name}] リトライ中... "
                    f"(試行 {attempt.retry_state.attempt_number}/{config.max_attempts})"
                )
            return await f(*args, **kwargs)


def _setup_sync_retry(
    f: Callable,
    node_name: str,
    retry_config: bool | Retry,
) -> Callable:
    """Setup retry for synchronous function."""
    if not retry_config:
        return f

    if isinstance(retry_config, Retry):
        config = retry_config
    else:
        from railway.core.config import get_retry_config
        config = get_retry_config(node_name)

    max_attempts = config.max_attempts

    def before_retry(retry_state):
        attempt = retry_state.attempt_number
        logger.warning(f"[{node_name}] リトライ中... (試行 {attempt}/{max_attempts})")

    return tenacity_retry(
        stop=stop_after_attempt(max_attempts),
        wait=wait_exponential(
            multiplier=config.exponential_base if hasattr(config, 'exponential_base') else config.multiplier,
            min=config.min_wait,
            max=config.max_wait,
        ),
        reraise=True,
        before=before_retry,
    )(f)
```

```python
# railway/core/pipeline.py を更新（async_pipeline追加）

import asyncio
from typing import Any, Callable, TypeVar
from loguru import logger

T = TypeVar('T')


def pipeline(
    initial_value: Any,
    *steps: Callable[[Any], Any],
    strict: bool = False,
) -> Any:
    """
    Execute a synchronous pipeline of functions.

    Raises TypeError if any step is an async function.
    """
    if not steps:
        return initial_value

    # Check for async nodes
    for step in steps:
        if getattr(step, '_is_async', False) or asyncio.iscoroutinefunction(step):
            raise TypeError(
                f"Cannot use async node '{getattr(step, '_node_name', step.__name__)}' "
                f"in synchronous pipeline. Use async_pipeline() instead."
            )

    logger.info(f"Pipeline starting with {len(steps)} steps")
    result = initial_value

    for i, step in enumerate(steps, 1):
        step_name = getattr(step, '_node_name', step.__name__)
        logger.info(f"Pipeline step {i}/{len(steps)}: {step_name}")

        try:
            result = step(result)
        except Exception as e:
            remaining = len(steps) - i
            if remaining > 0:
                logger.info(f"Skipping {remaining} remaining steps due to error")
            raise

    logger.info("Pipeline completed successfully")
    return result


async def async_pipeline(
    initial_value: Any,
    *steps: Callable[[Any], Any],
    strict: bool = False,
) -> Any:
    """
    Execute an asynchronous pipeline of functions.

    Supports both sync and async nodes.
    """
    if not steps:
        return initial_value

    logger.info(f"Async pipeline starting with {len(steps)} steps")
    result = initial_value

    for i, step in enumerate(steps, 1):
        step_name = getattr(step, '_node_name', step.__name__)
        is_async = getattr(step, '_is_async', False) or asyncio.iscoroutinefunction(step)

        logger.info(f"Async pipeline step {i}/{len(steps)}: {step_name}")

        try:
            if is_async:
                result = await step(result)
            else:
                result = step(result)
        except Exception as e:
            remaining = len(steps) - i
            if remaining > 0:
                logger.info(f"Skipping {remaining} remaining steps due to error")
            raise

    logger.info("Async pipeline completed successfully")
    return result
```

```bash
# 実行して成功を確認
pytest tests/unit/core/test_async_node.py -v
# Expected: PASSED
```

---

## 完了条件

- [ ] async関数に@nodeデコレータが適用できる
- [ ] 非同期ノードが正しく実行される
- [ ] 非同期ノードのログが出力される
- [ ] `_is_async=True` メタデータが設定される
- [ ] エラーが適切に伝播される
- [ ] `async_pipeline()` で非同期パイプラインが実行できる
- [ ] `async_pipeline()` で同期/非同期ノードが混在できる
- [ ] 同期 `pipeline()` が非同期ノードを拒否する
- [ ] 非同期ノードでリトライが機能する
- [ ] テストカバレッジ90%以上

---

## 注意事項

Phase 1では以下の機能は対象外:
- 並列実行（`parallel_pipeline`）
- タイムアウト処理
- キャンセル処理
- ストリーミング

これらは将来のPhaseで実装予定。

---

## 次のIssue

- #18: カスタムエラー型階層
