定时任务与工作空间清理.md 25 KB

定时任务与工作空间清理

本文引用的文件

  • DmService.java
  • CaseDataSourceReaper.java
  • ChatAttachmentService.java
  • PathConst.java
  • WorkspaceManagerFactory.java
  • WorkspaceScaffolder.java
  • os.js
  • application.yaml
  • chat_attachment.sql
  • knowledge_base.sql

目录

  1. 简介
  2. 项目结构与工作空间总览
  3. 调度策略与执行频率
  4. 工作空间目录结构、生命周期与归属关系
  5. 详细清理逻辑分析
  6. 依赖与失败处理
  7. 运维操作建议
  8. 故障排查指南
  9. 结论

简介

本文聚焦系统启动后用于清理以下资源的定时任务:Agent 工作空间相关数据、临时批次目录、对话附件孤儿、以及索引残留。文档说明这些任务的调度配置、执行频率、工作空间的目录结构和生命周期,并给出重试/告警策略、运维调整建议与常见故障排查方法。

需要特别说明的是:当前代码库中并未实现“下载日志表定期自动删除”的独立定时任务;知识库下载记录 knowledge_doc_download_log 仅作为审计字段存在。因此“下载日志清理”在现有实现中属于未覆盖项,本文会明确标注其现状与排查建议。

项目结构与工作空间总览

系统涉及三类与“工作空间/临时文件/索引”相关的存储位置:

类型 根路径或隔离维度 主要用途 是否由定时任务清理
Agent 工作空间 {cwd}/.agentscope/users/{userId}/workspaces/{workspaceId}/agents/{agentRowId} Agent 会话、技能、记忆、子智能体等运行时数据 无直接定时清理;受 Agent 生命周期和会话管理约束
清洗临时批次 {user.dir}/QingJian/tmp/{batchId} Excel/CSV 流式清洗过程中的中间批次目录 是,按修改时间超过 24 小时兜底清扫
对话附件 {user.dir}/QingJian/chat-attachments/{userId}/{caseId}/{sessionId} + 向量表 输入框上传的文件、sidecar、RAG 向量块 是,按“上传后超期仍未关联消息”判定为孤儿清理
DuckDB 案件数据源 内存注册表 + 每案 DuckDB 实例 案件级数据库连接与缓存 是,按 token 失效+无在途任务回收
知识库下载日志 PG 表 knowledge_doc_download_log 记录谁何时下载了哪个文档 无自动清理任务
graph TB
    A["应用根目录<br/>System.getProperty('user.dir')"] --> B["QingJian<br/>运行目录"]
    B --> C["tmp<br/>清洗临时批次"]
    B --> D["chat-attachments<br/>对话附件"]
    A --> E[".agentscope<br/>Agent 运行时数据"]
    E --> F["users/{userId}<br/>用户级隔离"]
    F --> G["workspaces/{workspaceId}<br/>工作空间隔离"]
    G --> H["agents/{agentRowId}<br/>Agent 隔离"]

图表来源

  • PathConst.java:12-109
  • WorkspaceManagerFactory.java:12-79

章节来源

  • PathConst.java:12-109
  • WorkspaceManagerFactory.java:12-79
  • WorkspaceScaffolder.java:26-134
  • os.js:134-200

调度策略与执行频率

系统使用 Spring @Scheduled 实现多个清理类任务。调度线程池默认大小为 4,以避免单个批量任务拖慢 SSE 心跳等其他调度任务。

任务 触发方式 默认间隔 初始延迟 作用范围 关键配置项
临时批次目录清扫 fixedDelayString 21600000 ms(约 6 小时) 600000 ms(10 分钟) QingJian/tmp 下直接子目录 zsjz.tmp.sweep-interval-ms、zsjz.tmp.sweep-initial-delay-ms
DuckDB 案件数据源回收 fixedDelayString 300000 ms(5 分钟) 60000 ms(1 分钟) 已打开但未归属有效会话的案件 zsjz.duckdb.reaper-interval-ms、zsjz.duckdb.reaper-initial-delay-ms
对话附件孤儿清理 fixedDelayString 3600000 ms(1 小时) 600000 ms(10 分钟) chat_attachment 中未关联消息且超期的附件 zsjz.chat-attachment.purge-interval-ms、zsjz.chat-attachment.purge-initial-delay-ms
知识库下载日志清理 未发现定时任务 不适用 不适用 knowledge_doc_download_log 无

调度线程池配置位于 spring.task.scheduling.pool.size,默认值为 4。该值影响所有 @Scheduled 任务并发能力,若某个清理任务耗时过长,应优先检查该任务本身,而不是盲目扩大线程池。

章节来源

  • application.yaml:25-32
  • DmService.java:1291-1307
  • CaseDataSourceReaper.java:44-125
  • ChatAttachmentService.java:404-432

工作空间目录结构、生命周期与归属关系

Agent 工作空间

Agent 工作空间采用三维隔离:用户 × 工作空间 × Agent。解析结果为:

{cwd}/.agentscope/users/{userId}/workspaces/{workspaceId}/agents/{agentRowId}

其中:

层级 含义 是否可被通用清理脚本删除
.agentscope Agent 运行时根目录 不应直接删除,否则丢失用户技能、记忆与会话
users/{userId} 用户级数据,包括技能仓库 仅可删除对应用户的目录,不能跨用户清理
workspaces/{workspaceId} 业务工作空间,通常等于 caseId 删除前应确认该案件不再被任何会话或 Agent 引用
agents/{agentRowId} 具体 Agent 实例数据,包含会话、事件转录、标准槽位 不应由外部脚本直接删除

Agent 首次创建工作空间时,WorkspaceScaffolder 会写入 AGENTS.md、tools.json、skills/、subagents/、memory/ 等模板内容,并保证“不存在才写入”,避免覆盖已有数据。

flowchart TD
    Start["创建 Agent 工作空间"] --> Check["检查工作空间目录是否存在"]
    Check --> Exists{"已存在?"}
    Exists -->|是| Noop["跳过覆盖,保持已有数据"]
    Exists -->|否| Create["创建 AGENTS.md、tools.json、skills、subagents、memory 等"]
    Create --> Done["完成初始化"]
    Noop --> Done

图表来源

  • WorkspaceScaffolder.java:26-134

章节来源

  • WorkspaceManagerFactory.java:12-79
  • WorkspaceScaffolder.java:26-134

清洗临时目录

清洗临时目录位于 QingJian/tmp,每个数据清洗批次以 batchId 命名一个子目录。正常流程会在清洗回调中删除本批次目录;但中途失败、取消或异常退出的批次可能留下残留。

该目录与 Agent 工作空间不同:它不是按用户/会话隔离的工作空间,而是清洗流水线共享的临时目录,因此清理策略基于“目录最后修改时间超过 24 小时”。

章节来源

  • PathConst.java:85-85
  • DmService.java:1270-1307

对话附件目录

对话附件落盘路径为:

{user.dir}/QingJian/chat-attachments/{userId}/{caseId}/{sessionId}/{雪花}_{safeName}

附件元数据保存在 chat_attachment 表中,支持三路处理:小内容内联、大表格 Python 直读、大文档 RAG 向量化。向量索引按案件分表,并在检索时强制按会话过滤。

章节来源

  • ChatAttachmentService.java:107-107
  • chat_attachment.sql:1-39

Electron 端 workspace 扫描

Electron 主进程提供上传清鉴数据源的能力,它会:

  1. 校验选中目录最后一级必须为 QingJian。
  2. 在 QingJian/workspace 下扫描下一层文件夹。
  3. 读取每个子文件夹中的文件,不递归更深层。
  4. 将结果通过 multipart 流式上传到后端。

这说明桌面客户端对 workspace 的使用方式是“按目录枚举上传”,而不是由 Electron 负责清理。

章节来源

  • os.js:134-200

详细清理逻辑分析

临时批次目录清扫

该任务由 DmService.sweepStaleTmpBatches 实现,核心逻辑如下:

flowchart TD
    Start["定时任务开始"] --> ReadRoot["读取 QingJian/tmp 根目录"]
    ReadRoot --> HasDirs{"是否有子目录?"}
    HasDirs -->|否| End["结束"]
    HasDirs -->|是| Iterate["遍历 tmp 下的直接子目录"]
    Iterate --> CheckTime{"最后修改时间是否早于 24 小时前?"}
    CheckTime -->|否| Next["跳过该目录"]
    CheckTime -->|是| Delete["删除该批次目录"]
    Next --> More{"还有目录?"}
    Delete --> More
    More -->|是| Iterate
    More -->|否| End

图表来源

  • DmService.java:1270-1307

需要注意:

  • 只删除 tmp 根目录下的直接子目录,不会删除 tmp 根目录本身。
  • 判断依据是目录的“最后修改时间”,而不是数据库状态。
  • 如果清洗回调没有正确删除,旧批次最多保留 24 小时。
  • 若目录中存在正在使用的文件,底层删除会失败,但该异常未被显式捕获,可能中断本轮后续目录处理。

章节来源

  • DmService.java:1270-1307

DuckDB 案件数据源回收

CaseDataSourceReaper 是兜底回收器,解决 sa-token Redis 会话自然过期不触发监听器的问题。它的策略非常保守:

  1. 每隔 5 分钟扫描一次已打开案件。
  2. 检查归属 token 是否仍然有效。
  3. 若 token 失效,再检查该案件是否仍有业务模块声明的在途任务。
  4. 只有两者都满足“会话失效 + 无在途任务”才关闭数据源并清理缓存。

    sequenceDiagram
    participant Scheduler as "调度器"
    participant Reaper as "CaseDataSourceReaper"
    participant Registry as "CaseDataSourceRegistry"
    participant Token as "Sa-Token"
    participant Probes as "在途任务探针"
    
    Scheduler->>Reaper: 定时触发 reap()
    Reaper->>Registry: 获取已打开案件列表
    loop 每个案件
        Reaper->>Registry: 查询归属 userId 与 ownerToken
        alt 有 ownerToken
            Reaper->>Token: 校验 token 是否有效
            Token-->>Reaper: 有效/无效
        else 老数据无 ownerToken
            Reaper->>Token: 按用户查剩余 token
        end
        Reaper->>Probes: 检查案件是否在途
        alt 会话仍有效 或 有在途任务
            Reaper->>Reaper: 跳过回收
        else 会话失效 且 无在途任务
            Reaper->>Registry: 关闭案件数据源
            Reaper->>Registry: 清理案件缓存
        end
    end
    

图表来源

  • CaseDataSourceReaper.java:44-125

该任务的关键设计原则是“宁可多留,不可误杀”。当 token 状态检查或探针异常时,它选择跳过回收,而不是释放可能仍被使用的案件数据源。

章节来源

  • CaseDataSourceReaper.java:1-125

对话附件孤儿清理

ChatAttachmentService.purgeOrphans 清理上传后超期仍未关联消息的附件。默认保留时间为 7 天(orphanHours=168),但实际截止时间取“当前时间减去 max(24, orphanHours)”,即至少保留 24 小时。

清理流程:

  1. 查询 link_message_id 为空且创建时间早于截止时间的附件。
  2. 对每个候选附件执行条件删除:再次确认“仍未关联消息且超期”。
  3. 只有 DB 删除成功,才删除向量索引和磁盘文件。
  4. 单条附件清理异常会被捕获并记录日志,不会中断其他附件清理。

    flowchart TD
    Start["孤儿附件清理任务"] --> Query["查询未关联消息且超期的附件"]
    Query --> HasOrphans{"是否有孤儿?"}
    HasOrphans -->|否| End["结束"]
    HasOrphans -->|是| Loop["逐个孤儿附件"]
    Loop --> AtomicDelete["条件删除:仍未关联消息且超期"]
    AtomicDelete --> Deleted{"DB 删除成功?"}
    Deleted -->|否| Next["跳过,可能刚被关联"]
    Deleted -->|是| CleanIndex["删除向量索引"]
    CleanIndex --> CleanFile["删除原件 sidecar 文件"]
    CleanFile --> Next
    Next --> More{"还有孤儿?"}
    More -->|是| Loop
    More -->|否| Log["记录清理数量"]
    Log --> End
    

图表来源

  • ChatAttachmentService.java:397-432

章节来源

  • ChatAttachmentService.java:397-432
  • chat_attachment.sql:1-39

索引残留清理

系统中与“索引”相关的清理主要有两类:

索引类型 清理时机 说明
对话附件 RAG 向量 附件孤儿清理、删除案件附件时 调用 AttachmentDocIndexer.deleteAttachmentVectors 删除对应向量行
AI 清洗模板向量索引 模板变更增量更新、启动时重建、手工重建接口 由 aiclean 模块管理,不属于通用工作空间清理
知识库文档向量 删除知识库、撤销附件索引时 与知识库生命周期绑定

目前未见“周期性扫描磁盘索引目录并清理孤立索引文件”的通用任务。若出现索引残留,通常应先确认对应的数据库记录是否仍存在;若记录已删而索引未删,则属于幂等删除缺失或外部清理未覆盖。

章节来源

  • ChatAttachmentService.java:404-432
  • application.yaml:114-143

依赖与失败处理

清理任务 失败处理策略 是否重试 风险点
临时批次目录清扫 未对单目录删除做 try/catch;删除失败可能中断后续目录 无 某个目录占用导致整轮清扫停止
DuckDB 数据源回收 token 检查失败、探针异常均按“仍在用”保守跳过 下次调度周期重试 回收变慢,但安全性高
对话附件孤儿清理 单条附件清理异常被捕获,继续处理下一条 无立即重试,靠下次调度周期 文件/向量删除失败会留下磁盘残留
知识库下载日志 无自动清理逻辑 不适用 长期积累可能增长日志表

重试机制现状

  • 临时批次目录清扫:没有逐目录重试。如果某个目录因权限或文件占用删除失败,可能抛出异常并终止本轮后续目录处理。
  • DuckDB 数据源回收:依赖调度周期自然重试;每次扫描都会重新评估 token 与会话状态。
  • 对话附件孤儿清理:单条异常被吞掉,只记录日志;下一次调度周期会再次尝试。
  • 知识库下载日志:当前没有清理任务,因此不存在重试语义。

告警与监控

当前清理任务主要通过日志输出信息,例如:

  • 临时批次目录清扫:打印待清扫目录名称。
  • DuckDB 回收:记录归属会话存活状态、跳过原因、最终回收情况。
  • 附件孤儿清理:记录清理失败详情与本轮清理总数。

仓库中未发现专门的清理任务指标上报或告警规则。运维侧建议结合应用日志、磁盘监控与数据库行数变化进行观察。

章节来源

  • DmService.java:1291-1307
  • CaseDataSourceReaper.java:44-125
  • ChatAttachmentService.java:404-432

运维操作建议

调整清理周期

可通过环境变量或配置文件调整以下参数:

目标 配置键 默认值 建议
临时批次目录清扫间隔 zsjz.tmp.sweep-interval-ms 21600000 高频上传场景可适当缩短,但不要小于 1 小时
临时批次目录清扫初始延迟 zsjz.tmp.sweep-initial-delay-ms 600000 一般保持 10 分钟即可
DuckDB 数据源回收间隔 zsjz.duckdb.reaper-interval-ms 300000 高并发长驻会话环境可缩短至 1~2 分钟,但会增加 token 校验开销
DuckDB 数据源回收初始延迟 zsjz.duckdb.reaper-initial-delay-ms 60000 一般保持 1 分钟
附件孤儿清理间隔 zsjz.chat-attachment.purge-interval-ms 3600000 高频上传场景可缩短至 15~30 分钟
附件孤儿清理初始延迟 zsjz.chat-attachment.purge-initial-delay-ms 600000 一般保持 10 分钟
附件孤儿保留小时数 zsjz.chat-attachment.orphan-hours 168 不建议低于 24 小时,避免用户暂存输入框后发送被误删

注意:zsjz.chat-attachment.purge-interval-ms 控制“多久扫一次”,而 orphan-hours 控制“多久算孤儿”。二者要分开理解。

监控磁盘占用

重点监控以下目录:

目录 监控重点
QingJian/tmp 是否存在大量超过 24 小时的批次目录
QingJian/chat-attachments 是否堆积未关联消息的大文件、sidecar 或向量索引
.agentscope 是否随用户/Agent 数量线性增长
PG 中 knowledge_doc_download_log 下载日志是否持续快速膨胀

推荐指标:

  • QingJian/tmp 目录数量与总大小。
  • chat_attachment 表中 link_message_id IS NULL 的记录数。
  • DuckDB 已打开案件数。
  • 各清理任务最近一次执行时间与错误日志条数。

定位无法删除的文件

对于临时批次目录或附件文件无法删除的情况,建议按以下步骤定位:

  1. 查看应用日志中对应任务的错误堆栈。
  2. 确认文件是否仍被 Java 进程、Python 进程、Agent harness、浏览器或其他程序持有。
  3. 在 Linux 上使用 lsof 或 fuser 查找占用文件的进程。
  4. 在 Windows 上使用资源监视器或 Process Explorer 查找句柄。
  5. 若文件确实属于正在运行的清洗任务或 Agent 会话,不要强制删除,应等待任务结束或恢复服务后再清理。

安全清理建议

  • 不要直接删除 .agentscope 根目录。
  • 不要直接删除 QingJian/workspace,因为它是业务 workspace 入口,可能被桌面端或 Agent 使用。
  • 清理 QingJian/tmp 前,先确认没有正在执行的清洗任务。
  • 清理 chat-attachments 前,先确认 chat_attachment 中是否仍有活跃记录。
  • 如需批量清理,建议先在测试环境验证,并保留至少 24 小时观察窗口。

故障排查指南

任务未执行

可能原因:

  1. Spring 调度线程池耗尽。默认大小为 4,若某个任务长时间阻塞,其他任务也可能受影响。
  2. 配置文件中启用了 profile,但对应调度开关被关闭。
  3. 应用启动后尚未到达初始延迟。
  4. 调度注解未生效,需确认 Spring Boot 启用调度配置。

排查步骤:

  • 检查 spring.task.scheduling.pool.size 是否过小。
  • 检查最近日志中是否有调度任务启动或周期性执行记录。
  • 核对环境变量是否覆盖了默认值。
  • 确认应用进程处于运行状态,而非启动失败。

章节来源

  • application.yaml:25-32

权限不足

典型表现:

  • 临时批次目录无法删除。
  • 附件文件或 sidecar 删除失败。
  • 新建 workspace 子目录失败。
  • Electron 上传时提示 workspace 子目录无文件。

排查步骤:

  • 确认应用运行用户对 QingJian、.agentscope、tmp、chat-attachments 具有读写权限。
  • 如果是容器部署,确认挂载卷权限与宿主用户一致。
  • 如果是共享目录,确认网络文件系统未锁定文件。
  • 在 Linux 上检查 SELinux/AppArmor 是否拦截删除。

章节来源

  • PathConst.java:12-109
  • os.js:134-200

路径不存在

典型表现:

  • 临时目录不存在或为空。
  • workspace 下没有子文件夹。
  • chat-attachments 根目录未创建。

排查步骤:

  • 确认应用启动工作目录是仓库根,使 RUN_QINGJIAN_PATH 指向 QingJian。
  • 检查应用启动时是否创建 tmp 目录。
  • 检查 Electron 上传时选择的目录最后一级是否为 QingJian。
  • 检查 chat-attachments 是否在首次上传附件时被创建。

章节来源

  • PathConst.java:12-26
  • os.js:134-200

清理过于激进导致正在使用的文件丢失

当前代码对“误删”有一定防护:

  • DuckDB 数据源回收仅在 token 失效且无在途任务时才关闭。
  • 附件孤儿清理先做原子条件删除,防止 select 与 delete 之间用户发消息导致误删。
  • 附件删除前先删向量,再删文件,单条异常不影响其他附件。

但仍需注意:

  • 临时批次目录清扫没有逐目录 try/catch,删除失败可能中断后续目录处理。
  • 若手动删除 chat-attachments 或 workspace,不会经过业务归属校验。
  • 知识库下载日志没有自动清理,长期积累不会被自动删除。

建议:

  • 不要在生产环境直接删除 workspace 或 .agentscope。
  • 如需清理,优先通过业务接口或已实现的清理任务。
  • 对临时目录和附件目录增加备份快照,以便回滚。
  • 将清理任务纳入日志监控,关注“清理失败”“孤儿数量持续增长”等信号。

章节来源

  • CaseDataSourceReaper.java:44-125
  • ChatAttachmentService.java:397-432
  • DmService.java:1270-1307

结论

当前系统的清理能力集中在三个方向:

  1. 临时批次目录:按 24 小时 TTL 兜底清扫 QingJian/tmp 下的批次子目录,适合缓解中途失败导致的目录膨胀。
  2. DuckDB 案件数据源:按 token 与会话状态保守回收,避免长期占用内存和 DuckDB 配额。
  3. 对话附件孤儿:按“上传后超期未关联消息”清理附件、sidecar 与向量索引,默认 7 天保留,每小时扫描一次。

Agent 工作空间本身没有独立的定时清理任务,其生命周期由 Agent 运行时、用户、工作空间和会话共同决定。知识库下载日志也没有自动清理任务。

运维上最关键的实践是:先理解每条清理规则的边界,再调整间隔和阈值;不要直接删除 workspace、.agentscope 或 QingJian 根目录;把清理任务日志纳入监控,重点关注无法删除、孤儿数量增长和调度线程池饱和等问题。