6.3 会话存储后端

RAF 提供三种会话存储实现,覆盖从开发测试到生产部署的多种场景。所有实现均遵循 `ISessionStore` trait。

ISessionStore trait

/// 会话持久化存储接口。
///
/// 抽象会话数据的存储后端,支持跨请求和跨重启的会话恢复。
#[async_trait]
pub trait ISessionStore: Send + Sync {
    /// 将会话保存到存储中。如果已存在相同 ID 的会话,则会被覆盖。
    async fn save_session(&self, session: &dyn ISession) -> Result<()>;

    /// 根据 ID 获取会话。如果指定 ID 的会话不存在,返回 None。
    async fn get_session(&self, session_id: &str) -> Result<Option<Arc<dyn ISession>>>;

    /// 根据 ID 删除会话。如果会话不存在,不会引发错误。
    async fn delete_session(&self, session_id: &str) -> Result<()>;

    /// 清理过期的会话。返回已移除的会话数量。
    /// 实现应检查 ISession::last_active_at() 与配置的 TTL 选项。
    async fn cleanup_expired(&self) -> Result<usize>;
}

实现一:InMemorySessionStore

基于 `HashMap` 的内存存储。进程退出时会话数据丢失。

/// 基于 HashMap 的内存会话存储。
///
/// 进程退出时会话丢失。适用于开发、测试和短期应用。
///
/// ## TTL 清理
///
/// 使用 with_ttl() 构造时,cleanup_expired() 将驱逐超过
/// max_idle_secs 或 max_lifetime_secs 的会话。
pub struct InMemorySessionStore {
    sessions: RwLock<HashMap<String, Arc<dyn ISession>>>,
    ttl: Option<SessionTTLOptions>,
}

使用示例

use rust_agent_core::{AgentSession, InMemorySessionStore, ISessionStore, SessionTTLOptions};

// 无 TTL 的内存存储
let store = InMemorySessionStore::new();

// 创建并保存会话
let session = AgentSession::new();
store.save_session(&session).await?;

// 查找会话
let found = store.get_session(session.session_id()).await?;
assert!(found.is_some());

// 带 TTL 的内存存储(30 分钟空闲超时)
let ttl = SessionTTLOptions {
    max_idle_secs: Some(1800),
    max_lifetime_secs: None,
    cleanup_interval_secs: 3600,
};
let store_with_ttl = InMemorySessionStore::new().with_ttl(ttl);

// 定期清理过期会话
let removed = store_with_ttl.cleanup_expired().await?;
println!("Removed {} expired sessions", removed);

TTL 清理逻辑

async fn cleanup_expired(&self) -> Result<usize> {
    let ttl = match &self.ttl {
        Some(t) => t,
        None => return Ok(0), // 无 TTL 配置则不清理
    };

    let now = chrono::Utc::now();
    let mut sessions = self.sessions.write().await;
    let mut to_remove = Vec::new();

    for (id, session) in sessions.iter() {
        let last_active = session.last_active_at();
        let created = session.created_at();

        // 空闲超时检查
        if let Some(max_idle) = ttl.max_idle_secs {
            let idle_duration = now - last_active;
            if idle_duration.num_seconds() > max_idle as i64 {
                to_remove.push(id.clone());
                continue;
            }
        }

        // 存活时间超时检查
        if let Some(max_lifetime) = ttl.max_lifetime_secs {
            let lifetime_duration = now - created;
            if lifetime_duration.num_seconds() > max_lifetime as i64 {
                to_remove.push(id.clone());
            }
        }
    }

    let removed = to_remove.len();
    for id in to_remove {
        sessions.remove(&id);
    }

    if removed > 0 {
        tracing::info!(removed, "Expired session eviction completed");
    }
    Ok(removed)
}

**适用场景**:开发环境、单元测试、短生命周期的 Agent 实例。

---

实现二:FileSystemSessionStore

将每个会话保存为独立 JSON 文件。重启后会话数据持久保留。

/// 文件系统会话存储。
///
/// 每个会话作为 JSON 文件存储在配置目录中。文件名为 {session_id}.json。
///
/// 适用于需要重启持久化但无需数据库的单实例生产部署。
pub struct FileSystemSessionStore {
    base_dir: PathBuf,
    ttl: Option<SessionTTLOptions>,
}

使用示例

use rust_agent_core::{AgentSession, FileSystemSessionStore, ISessionStore, SessionTTLOptions};

let store = FileSystemSessionStore::new("./data/sessions");

// 保存会话 → ./data/sessions/{uuid}.json
let session = AgentSession::new();
store.save_session(&session).await?;

// 恢复会话
let restored = store.get_session(session.session_id()).await?;

// 删除会话
store.delete_session(session.session_id()).await?;

文件格式

会话序列化为 `SessionSnapshot` JSON:

{
  "session_id": "a1b2c3d4-...",
  "metadata": {
    "created_at": "2026-06-18T10:00:00Z",
    "updated_at": "2026-06-18T10:30:00Z",
    "message_count": 42,
    "last_request_hash": null
  },
  "messages": [...],
  "provider_states": { "states": {} },
  "last_active_at": "2026-06-18T10:30:00Z"
}

cleanup_expired:mtime 优化

`FileSystemSessionStore` 的 `cleanup_expired()` 使用三阶段算法:

flowchart TD
    START["cleanup_expired()"] --> SCAN["扫描 base_dir 下的 .json 文件"]
    SCAN --> MTIME["阶段 1: mtime 预过滤"]

    MTIME -->|"now - mtime < max_idle_secs"| SKIP["跳过(文件最近有活动)"]

    MTIME -->|"mtime 超时 或 无 idle 配置"| PARSE["阶段 2: JSON 时间戳验证"]

    PARSE -->|"解析失败/损坏"| DEL["加入删除队列"]
    PARSE -->|"idle 超时"| DEL
    PARSE -->|"lifetime 超时"| DEL
    PARSE -->|"未过期"| KEEP["保留"]

    DEL --> BATCH["阶段 3: 批量删除"]

**mtime 优化原理**:

- `agent.run()` 调用前后通常执行 `save_session()`,更新文件 `mtime` - 如果 `now - mtime < max_idle_secs`,说明会话在空闲窗口内肯定有活动 - 可以直接跳过 JSON 反序列化(O(1) 文件元数据 vs O(n) JSON 解析)

**注意**:mtime 仅用于 idle 检查,不能用于 lifetime 检查——新创建的会话也可能超出 lifetime。

**已知限制**:

- 多实例共享文件系统时有 TOCTOU 竞态风险(无文件锁) - 超大目录(10w+ 文件)存在性能瓶颈 - 生产环境多实例部署建议使用数据库后端

---

实现三:IsolationScopedSessionStore

多租户会话隔离的装饰器,在前缀会话 ID 前添加隔离键。

/// 多租户会话隔离的隔离键提供器。
#[async_trait]
pub trait IIsolationKeyProvider: Send + Sync {
    async fn get_isolation_key(&self) -> Result<String>;
}

/// 隔离作用域的会话存储装饰器。
///
/// 包装内部 ISessionStore,将会话 ID 前添加隔离键,
/// 确保不同租户无法访问彼此的会话。
pub struct IsolationScopedSessionStore {
    inner: Arc<dyn ISessionStore>,
    key_provider: Arc<dyn IIsolationKeyProvider>,
}

impl IsolationScopedSessionStore {
    async fn scoped_id(&self, session_id: &str) -> Result<String> {
        let key = self.key_provider.get_isolation_key().await?;
        Ok(format!("{}::{}", key, session_id))
    }
}

使用示例

use rust_agent_core::{
    InMemorySessionStore, ISessionStore, AgentSession,
    IsolationScopedSessionStore, FixedIsolationKeyProvider,
};
use std::sync::Arc;

// 基础存储
let inner = Arc::new(InMemorySessionStore::new());

// 固定隔离键(适用于单租户或简单场景)
let key_provider = Arc::new(FixedIsolationKeyProvider::new("tenant-123"));

// 包装为隔离存储
let store = IsolationScopedSessionStore::new(inner, key_provider);

// 操作时,会话 ID "abc" 变为 "tenant-123::abc"
let session = AgentSession::with_id("abc");
store.save_session(&session).await?;

// 查找时自动添加前缀
let found = store.get_session("abc").await?;   // 查找 "tenant-123::abc"
let gone = store.get_session("abc").await?;     // 不同的隔离键会得到 None

自定义隔离键提供器

对于从 JWT token、请求头等动态获取租户 ID 的场景:

struct RequestScopedKeyProvider {
    // 例如从请求上下文中获取
}

#[async_trait]
impl IIsolationKeyProvider for RequestScopedKeyProvider {
    async fn get_isolation_key(&self) -> Result<String> {
        // 从当前请求上下文获取租户 ID
        let tenant_id = current_tenant_id().await?;
        Ok(tenant_id)
    }
}

三种存储对比

graph LR
    subgraph "内存"
        IM_STORE["InMemorySessionStore"]
        IM_PRO["HashMap + RwLock"]
        IM_PERSIST["❌ 不持久"]
        IM_ISOL["❌ 无隔离"]
    end

    subgraph "文件系统"
        FS_STORE["FileSystemSessionStore"]
        FS_PRO["JSON 文件"]
        FS_PERSIST["✅ 持久化"]
        FS_ISOL["❌ 无隔离"]
    end

    subgraph "隔离包装"
        ISO_STORE["IsolationScopedSessionStore"]
        ISO_PRO["装饰器 + 任意内部存储"]
        ISO_PERSIST["取决于内部存储"]
        ISO_ISOL["✅ 租户隔离"]
    end

| 特性 | InMemory | FileSystem | IsolationScoped | |------|----------|------------|-----------------| | 持久化 | ❌ 进程退出丢失 | ✅ JSON 文件 | 取决于内部 | | TTL 清理 | ✅ idle + lifetime | ✅ idle(mtime 优化)+ lifetime | 委托内部 | | 多租户隔离 | ❌ | ❌ | ✅ 前缀隔离 | | 性能 | ⚡ 最快 | 🐢 磁盘 I/O | 取决于内部 | | 适用场景 | 开发/测试/短生命周期 | 单实例生产/重启持久化 | 多租户 SaaS |

关键要点

1. **`InMemorySessionStore` 是开发首选**——简单、零配置、性能最佳 2. **`FileSystemSessionStore` 的 mtime 优化**——通过文件元数据跳过昂贵的 JSON 解析 3. **`IsolationScopedSessionStore` 是装饰器**——可包裹任何 `ISessionStore` 实现 4. **TTL 是可选的**——所有存储默认不清理过期会话,通过 `with_ttl()` 启用 5. **`cleanup_expired()` 幂等**——可以安全地重复调用