diff --git a/.agents/design/common/mongo-index-sync-strategy.md b/.agents/design/common/mongo-index-sync-strategy.md new file mode 100644 index 000000000000..b3c9e8f93850 --- /dev/null +++ b/.agents/design/common/mongo-index-sync-strategy.md @@ -0,0 +1,244 @@ +# MongoDB 索引同步安全化方案 + +## 背景 + +私有化部署客户可能会通过 `mongosh`、运维脚本或数据库管理平台为 MongoDB 集合添加自定义索引。当前 FastGPT 在模型加载时调用 Mongoose `model.syncIndexes()`,该 API 的语义是让数据库索引与 schema 中声明的索引保持一致:创建缺失索引,同时删除 schema 中不存在的索引。 + +这会导致服务每次重启时都可能删除客户自建索引。现有 `SYNC_INDEX=false` 能临时关闭启动同步,但它把“补建 FastGPT 必需索引”和“删除旧索引/未知索引”一起关闭了,升级时容易漏建新索引或遗留旧唯一索引。 + +## 改造前代码现状 + +- 主应用入口:`packages/service/common/mongo/index.ts` + - `getMongoModel()` / `getMongoLogModel()` 编译 model 后调用 `syncMongoIndex(model)`。 + - `syncMongoIndex()` 在非测试、非构建、`SYNC_INDEX=true` 且存在 `MONGO_URL` 时执行 `await model.syncIndexes({ background: true })`。 +- Marketplace 入口:`projects/marketplace/src/service/mongo/index.ts` + - 同样在 model 编译后调用 `model.syncIndexes({ background: true })`。 + - 当前没有 `await`,`try/catch` 捕不到异步失败。 +- 环境变量: + - `packages/service/env.ts` 和 `projects/marketplace/src/env.ts` 均定义 `SYNC_INDEX: BoolSchema.default(true)`。 +- 部署文档默认仍配置 `SYNC_INDEX: true`。 + +## 已确认决策 + +1. 启动时默认执行 manager 管理的主动安全同步,不再通过环境变量切换索引处理模式。 +2. 移除 `MONGO_INDEX_SYNC_MODE`;已经移除的 `SYNC_INDEX` 也不恢复兼容逻辑。 +3. 主动安全同步固定执行“创建当前 schema 缺失索引 + 删除该 schema 显式声明的废弃索引”,不会调用 Mongoose 具有破坏性语义的 `syncIndexes()`。 +4. 废弃索引不再集中维护 registry,而是与当前索引一起维护在所属 Schema 文件中;collection 从 model 推导,声明本身不再重复填写 collection name。 +5. schema 外且未被显式声明为废弃的索引一律保留,只记录差异和告警,避免删除客户自建索引。 +6. `llm_request_records.requestId_1` 已确认不再需要,但此前已明确不纳入本次清理;本次仅迁移现有中心清单中的 chat 和 sandbox 废弃索引,不顺带扩大删除范围。 + +## 根因判断 + +`syncIndexes()` 不是单纯“创建索引”,而是完整 reconcile: + +1. 计算 schema 与数据库已有索引差异。 +2. 删除 schema 中不存在的索引。 +3. 创建 schema 中缺失的索引。 + +因此客户自建索引被删除是当前 API 选择带来的必然行为,不是异常分支。 + +## 设计目标 + +1. 默认保护客户自建索引:启动时不删除 schema 未声明的索引。 +2. 仍能自动补建 FastGPT 新版本需要的缺失索引。 +3. 保留可复用的差异检查能力,帮助日志或后续管理入口识别 schema 外索引和缺失索引。 +4. 不再保留启动期模式选择或强制全量同步能力,避免配置分支导致不同部署的索引状态长期分叉。 +5. 主应用与 Marketplace 使用一致的索引策略和错误处理。 + +## 非目标 + +- 不尝试自动判断所有未知索引是否由客户创建。历史上未带所有权标记,完全自动判断不可靠。 +- 不删除 Schema 未明确登记为废弃的索引。 +- 不提供环境变量关闭索引创建或废弃索引清理。 + +## 推荐方案 + +### 1. 固定执行主动安全同步 + +移除 `packages/service/env.ts` 和 `projects/marketplace/src/env.ts` 中的 `MONGO_INDEX_SYNC_MODE`。主应用和 Marketplace 在满足现有运行条件时固定执行同一条索引同步链路,不再支持 `off/create/dryRun/sync` 启动模式。 + +同步内部使用 Mongoose 的 `createIndexes()` 创建当前 Schema 索引,不执行 `syncIndexes()` 或 `cleanIndexes()`。创建完成后,再处理当前 Schema 显式声明的废弃索引。 + +建议同时记录差异日志: + +- `toCreate`:即将创建的 FastGPT schema 索引。 +- `toDrop`:数据库存在但 schema 不声明的索引,只告警,不删除。 +- 冲突:同名或同 key 但 options 不一致的索引,交由 `createIndexes()` 抛错并记录日志,提示需要迁移或人工处理。 + +不建议只简单替换成 `model.createIndexes()` 后结束,原因是: + +1. `createIndexes()` 对已有索引 options 不做修正。TTL、unique、partialFilterExpression 变化时,旧索引仍会保留旧行为。 +2. 它缺少差异报告,管理员无法知道哪些旧索引需要人工处理或迁移。 +3. 它不处理旧索引 options 变化或历史旧索引清理,因此需要由 schema-local 废弃声明补足。 + +因此可以内部用 `createIndexes()` 完成安全创建,但对外的抽象应是 `mongoIndexManager`,保留 inspect 和未来管理入口的扩展点。 + +### 2. Schema 内声明废弃索引 + +在 Mongo 公共模块提供 schema-level 注册 helper。Schema 文件继续使用 `schema.index(...)` 声明当前索引,并在相邻位置调用 helper 声明该 Schema 需要删除的历史索引。helper 只保存元数据,不把自定义 options 传给 Mongoose 或 MongoDB。 + +建议 API 形态: + +```ts +registerDeprecatedMongoIndexes(ChatSchema, [ + { + indexName: 'appId_1_chatId_1', + key: { appId: 1, chatId: 1 }, + options: { unique: true } + } +]); +``` + +元数据应挂在 Schema 实例自身,并使用 Mongo 公共模块私有的 symbol key 读写;不使用中心数组,也不使用可能在热加载或重复模块实例间丢失关联的进程级 `WeakMap`。manager 从 `model.schema` 读取这份元数据。 + +废弃索引定义只包含 `indexName`、`key` 和需要参与精确匹配的可选关键 options。 + +定义不包含 `collectionName`、版本或说明字段。manager 从 model 获取 collection,并且只处理 model.schema 上登记的废弃项。这能让当前索引和历史删除声明在同一个业务 Schema 文件中完成 review,也避免中心清单与 Schema 演进脱节。 + +现有中心清单迁移位置: + +- chat 废弃索引迁移到 `packages/service/core/chat/chatSchema.ts`。 +- sandbox instance 废弃索引迁移到 `packages/service/core/ai/sandbox/infrastructure/instance/schema.ts`。 +- 迁移完成后删除 `packages/service/common/mongo/deprecatedIndexes.ts`。 + +### 3. 历史旧索引处理边界 + +主动同步只允许清理当前 Schema 显式登记的 FastGPT 废弃索引,并且删除前必须精确匹配 index name、key 和关键 options。 + +该边界的原因: + +1. 当前核心目标是保护客户自建索引,不能按 schema 外索引一概删除。 +2. 历史旧索引只能通过所属 Schema 的明确声明识别,不能自动猜测。 +3. 必须先成功创建当前索引,再执行该 model 的废弃索引清理;创建失败时不继续删除,避免替代索引缺失。 +4. 多实例重复执行必须保持幂等:索引已不存在时视为跳过,定义不匹配时保留并告警。 + +### 4. 提供可审计的管理入口 + +建议新增 Root 管理员 API 或脚本: + +- `POST /api/admin/mongoIndexes/inspect` + - 默认 dry-run。 + - 输出每个集合的 `toCreate`、`unknownIndexes`、`conflicts`、`fastgptObsoleteIndexes`。 +- `POST /api/admin/mongoIndexes/apply` + - 只应用 manager 已定义的安全同步动作。 + - 不提供旧式全量同步能力。 + +本次先完成固定的启动同步行为,Root 管理员 API 或脚本作为后续增强。 + +### 5. 未来索引命名规范 + +新建 FastGPT schema 索引尽量显式命名,例如 `fg__`,降低未来识别成本。 + +注意:不能一次性给所有旧索引改名,因为 MongoDB 不支持直接重命名索引,改名通常等价于新建再删除,容易触发冲突和重建成本。旧索引应通过迁移清单逐步治理。 + +## 推荐落地架构 + +### 1. 模块划分 + +建议在 `packages/service/common/mongo/` 下新增索引管理模块: + +- `indexManager.ts` + - `MongoIndexManager.syncModelIndexes()` + - `MongoIndexManager.inspectModelIndexes()` + - `MongoIndexManager.cleanupModelDeprecatedIndexes()` + - `MongoIndexManager.formatCleanupReport()` +- schema metadata helper + - 注册和读取某个 Schema 的废弃索引定义。 + - 类型不包含 collection name,避免业务 Schema 重复维护集合信息。 + +`getMongoModel()` / `getMongoLogModel()` 只负责把 model 注册给 manager,不直接调用 Mongoose 的 destructive API。这样后续可以统一做并发控制、日志聚合和管理 API 复用。 + +### 2. 启动流程 + +每个 model 固定执行: + +1. 用 `diffIndexes()` 检查 `toCreate` 和 schema 外索引,仅用于日志与结果报告。 +2. 用 `createIndexes()` 创建当前 Schema 声明的索引。 +3. 读取 model.schema 上登记的废弃索引定义。 +4. 在当前 model 对应 collection 中逐项精确匹配并删除;未知索引不参与删除。 + +废弃清理从连接级全局阶段下沉到 model 任务后,不再需要等待全连接所有 model 的索引任务,也不再需要 `connectMongo({ cleanupDeprecatedIndexes })` 这类入口开关。 + +### 3. 并发与失败处理 + +当前 model 加载时即触发索引同步。后续实现至少要保证: + +1. 同一进程内同一 model 的并发索引任务会复用;任务结束后允许后续调用重新检查,以支持热加载和重连。 +2. 多实例并发启动时,重复创建索引错误可识别并降噪;真正的冲突错误必须记录。 +3. 索引任务失败不应让 model 注册失败,但要有明确日志,必要时在健康检查或管理 API 暴露状态。 +4. Marketplace 已修正异步错误捕获,后续如果引入管理入口,应继续复用同一 manager。 + +### 4. 日志分级 + +- 无索引变化时不输出日志,避免每个 model 启动时重复打印空结果。 +- `info`:仅在实际创建或删除索引时输出一条精简摘要。 +- `warn`:输出 schema 外索引或废弃索引定义不匹配,只保留 collection 和索引名称。 +- `error`:输出索引创建或废弃索引清理失败,只保留定位所需字段和错误信息。 + +### 5. 本次交付边界 + +- 移除 `MONGO_INDEX_SYNC_MODE`,启动时固定执行主动安全同步。 +- 固定同步语义为 `createIndexes()` + 当前 Schema 显式声明的 deprecated cleanup。 +- 将中心废弃索引清单迁移到 chat 和 sandbox instance Schema,随后删除中心 registry。 +- 加日志说明 `toCreate/toDrop`,但 `toDrop` 不删除;索引冲突由 `createIndexes()` 错误日志暴露。 +- 主应用与 Marketplace 行为一致。 +- 补回归测试:客户自建索引不会被删除,只删除当前 Schema 登记且精确匹配的废弃索引。 +- 代码确认后再更新 `.mdx` 文档,并由生成流程统一产出 `.yml` 部署文件。 + +Root 管理员 inspect/apply API 或等价脚本作为后续增强,不阻塞本次修复。 + +## 已知历史旧索引记录 + +### `llm_request_records.requestId_1` + +- 引入提交:`76d6234de V4.14.7 features (#6406)`,提交时间 `2026-02-12 16:37:50 +0800`。 +- 引入方式:`packages/service/core/ai/record/schema.ts` 初次新增 LLM 请求追踪记录时,在 `requestId` path 上声明 `unique: true`。MongoDB 自动生成索引名 `requestId_1`。 +- 变更提交:`f008ea971 feat: teamId in reacord llm`,提交时间 `2026-06-25 12:27:57 +0800`;后续合并提交 `60c62b7af Fix test (#7179)` 包含同样变更。 +- 变更原因:LLM 请求追踪记录新增 `teamId` 隔离,查询从 `{ requestId }` 调整为 `{ requestId, teamId }`,唯一索引从单字段 `requestId` 调整为复合 `{ teamId: 1, requestId: 1 }`。 +- 当前判断:旧 `requestId_1` 对当前 schema 已无必要,且可能继续施加跨团队全局唯一约束。但 `requestId` 由 `getNanoid(12)` 生成,实际碰撞概率很低;请求追踪记录保存失败只记录错误,不阻塞主流程。 +- 处理边界:本次不为它新增 schema-local 废弃声明。后续如需清理,应单独确认后在 `packages/service/core/ai/record/schema.ts` 声明并补回归测试。 + +## 边界与风险 + +1. `createIndexes()` 不会更新已存在索引的 options。TTL 秒数、unique、partialFilterExpression 等发生变化时,需要独立 migration 或人工处理。 +2. 旧唯一索引如果不删除,可能继续影响业务。例如 `llm_request_records` 从 `{ requestId }` 改到 `{ teamId, requestId }` 后,旧 `requestId_1` 若残留,会继续限制跨团队相同 requestId。 +3. 旧式 Mongoose 全量同步不作为启动行为暴露,避免重新引入删除客户索引的风险。 +4. 多实例同时启动会并发创建索引。MongoDB 创建已存在索引通常是幂等的,但冲突错误需要聚合成清晰日志,避免噪声刷屏。 +5. Marketplace 已统一接入 `mongoIndexManager`,异步错误会被捕获并记录。 +6. 主动清理意味着错误的 schema-local 声明会在服务启动时生效,因此定义 review 和精确匹配是必须保留的防线。 + +## 测试策略 + +至少需要覆盖: + +1. 固定同步会创建 Schema 中缺失索引,并保留未登记的 schema 外索引。 +2. Schema 未登记废弃项时不执行任何删除。 +3. Schema 登记的废弃索引只有在 name、key 和关键 options 精确匹配时才删除。 +4. 当前索引创建失败时不删除废弃索引。 +5. 相同名称但定义不同的客户索引保留并告警。 +6. 已不存在的废弃索引、多实例并发重复清理保持幂等。 +7. chat 和 sandbox 的现有中心清单迁移后行为不变。 +8. 主应用与 Marketplace 均固定调用同一 manager,异步错误可以被捕获并记录。 + +仓库已有 `mongodb-memory-server` 测试环境,可以写真实 MongoDB 索引回归测试,而不是只 mock Mongoose 方法。 + +## 后续增强 + +1. 是否继续补 Root 管理员 inspect/apply API,提供启动日志之外的索引诊断入口。 +2. 是否将 `llm_request_records.requestId_1` 纳入对应 Schema 的废弃索引声明。 +3. 对客户自己改动 FastGPT 既有索引 options 的情况,当前只告警;如未来需要自动修正,应以明确迁移项实现。 + +## TODO + +- [x] 确认修订方案:移除模式变量,固定执行主动安全同步,废弃索引下沉到所属 Schema。 +- [x] 新增 schema-level 废弃索引注册与读取能力;定义仅包含 name、key 和用于精确匹配的可选关键 options。 +- [x] 重构 `MongoIndexManager`:移除 mode 分支,按 model 依次执行 inspect、`createIndexes()` 和 schema-local deprecated cleanup。 +- [x] 将 chat 与 sandbox instance 的现有废弃索引定义迁移到对应 Schema 文件,并删除中心 `deprecatedIndexes.ts`。 +- [x] 移除连接级 cleanup 任务、等待逻辑、`connectMongo.cleanupDeprecatedIndexes` 参数及 instrumentation 调用。 +- [x] 从主应用和 Marketplace env 中移除 `MONGO_INDEX_SYNC_MODE`,删除 manager 中的 mode 类型及对应 env 测试。 +- [x] 简化主应用 `getMongoModel()` / `getMongoLogModel()` 与 Marketplace `getMongoModel()` 的 manager 调用,保持异步错误日志完整。 +- [x] 更新 `AGENTS.md` 的 MongoDB Schema 与索引维护约束,要求在所属 Schema 中声明废弃索引,不再引用中心清单。 +- [x] 调整 manager 单元测试和真实 MongoDB 回归测试,覆盖安全创建、schema-local 精确删除、未知索引保留和并发幂等。 +- [x] 运行索引管理相关局部测试与类型检查;实现全部完成后再运行全量测试(全量并发运行出现 9 个无关超时失败,失败文件在单 worker 下全部复跑通过)。 +- [x] 检查并移除 `.mdx`、部署配置和其他文档中的 `MONGO_INDEX_SYNC_MODE` 引用;需要生成的 `.yml` 通过既有生成流程更新。 +- [ ] 后续增强:新增 Root 管理员 inspect/apply API 或等价脚本。 diff --git a/.agents/issue/mongo-index-sync-customer-index-loss.md b/.agents/issue/mongo-index-sync-customer-index-loss.md new file mode 100644 index 000000000000..7e55c82c9ce9 --- /dev/null +++ b/.agents/issue/mongo-index-sync-customer-index-loss.md @@ -0,0 +1,83 @@ +# MongoDB 启动索引同步删除客户自建索引问题分析 + +## 问题描述 + +私有化部署客户可能会直接在 MongoDB 中添加自定义索引,用于客户自己的查询、报表、审计或集成任务。FastGPT 服务重启时会加载 Mongoose model,并执行索引同步。当前同步使用 Mongoose `model.syncIndexes()`,会删除 schema 中未声明的索引,导致客户自建索引被清理。 + +`SYNC_INDEX=false` 可以阻止启动同步,但它同时阻止 FastGPT 新版本缺失索引的创建,升级时可能留下性能问题或唯一约束缺失。因此它只能算临时开关,不是长期索引治理方案。最终决策是不再保留索引同步环境变量,启动时固定执行 manager 管理的主动安全同步。 + +## 证据 + +当前主应用代码: + +- `packages/service/common/mongo/index.ts` + - `getMongoModel()` / `getMongoLogModel()` 创建 model 后调用 `syncMongoIndex(model)`。 + - `syncMongoIndex()` 在 `SYNC_INDEX=true` 时执行 `await model.syncIndexes({ background: true })`。 + +当前 Marketplace 代码: + +- `projects/marketplace/src/service/mongo/index.ts` + - model 创建后也调用 `model.syncIndexes({ background: true })`。 + - 该调用未 `await`,异步错误无法被当前 `try/catch` 捕获。 + +Mongoose `syncIndexes()` 的语义是: + +1. 对比 schema 索引与数据库已有索引。 +2. 删除 schema 中不存在的索引。 +3. 创建 schema 中缺失的索引。 + +所以删除客户自建索引是当前 API 语义导致的确定性风险。 + +## 影响范围 + +- 私有化部署环境:风险最高,因为客户可能对数据库有直接运维权。 +- 多实例部署:多个服务重启时会重复触发索引同步,日志与冲突更难排查。 +- 版本升级:如果关闭 `SYNC_INDEX`,新索引可能不会创建;如果打开 `SYNC_INDEX`,客户自建索引可能被删。 +- 日志库:`getMongoLogModel()` 也会同步索引,日志集合里的客户索引同样受影响。 +- Marketplace:存在同类 destructive sync,并且异步错误处理不完整。 + +## 方案取舍 + +### 不推荐:继续依赖 `SYNC_INDEX` + +优点是改动小。缺点是它只有开/关两种状态,无法表达“只创建 FastGPT 缺失索引,但保留客户索引”。 + +### 不推荐:直接全量改成 `createIndexes()` + +能保留客户索引,但无法发现和治理 FastGPT 历史旧索引。比如唯一索引或 TTL 索引的 options 发生变化时,旧索引可能继续影响业务。 + +当前最终方案仍以 `createIndexes()` 作为安全创建动作,但通过统一的 index manager 包装差异检查、日志和启动入口。创建当前索引后,只清理所属 Schema 显式登记且精确匹配的 FastGPT 废弃索引。 + +### 推荐:固定执行主动安全同步 + +每个 model 固定执行: + +- 检查并记录当前 Schema 与数据库索引的差异。 +- 创建当前 Schema 缺失的索引。 +- 删除当前 Schema 通过 `registerDeprecatedMongoIndexes` 显式登记、定义精确匹配且替代索引已存在的历史索引。 +- 保留客户自建索引及其他未登记索引。 + +启动过程不再暴露模式选项或旧 `syncIndexes()` 全量同步行为,避免不同部署的索引状态因配置分支长期分叉。 + +## 已确认方向 + +1. 默认行为从破坏性全量同步改为 safe create。 +2. 直接移除 `SYNC_INDEX` 和 `MONGO_INDEX_SYNC_MODE`,不做长期兼容映射。 +3. 启动索引处理不提供按 schema 外索引批量删除的能力;只允许删除所属 Schema 登记且精确匹配的 FastGPT 废弃索引。 +4. `llm_request_records.requestId_1` 已确认不再需要,但不在本次启动索引同步中清理。 + +## 已知旧索引样例 + +`llm_request_records.requestId_1` 来源: + +- `76d6234de V4.14.7 features (#6406)` 在 `2026-02-12 16:37:50 +0800` 引入 LLM 请求追踪记录,`requestId` path 声明 `unique: true`,MongoDB 因此创建 `requestId_1`。 +- `f008ea971 feat: teamId in reacord llm` 在 `2026-06-25 12:27:57 +0800` 将 schema 改为 `teamId + requestId` 复合唯一索引。 + +当前判断:旧 `requestId_1` 对现在的团队隔离查询已无必要,且可能继续施加跨团队全局唯一约束。但 `requestId` 由 `getNanoid(12)` 生成,实际碰撞概率很低;请求追踪记录保存失败只记录错误,不阻塞主流程。因此本次不在启动索引同步中清理它。 + +## 已确认边界 + +1. 常规启动固定补建当前索引,并清理所属 Schema 明确声明的废弃索引。 +2. 未登记的 schema 外索引只记录差异,不会删除。 +3. 不保留旧式全量同步入口;Root 管理员 inspect/apply API 或等价脚本作为后续增强,不阻塞本次修复。 +4. 历史旧索引必须在所属 Schema 中登记;客户自建或未知索引不会被主动同步删除。 diff --git a/AGENTS.md b/AGENTS.md index 2352b63d9ff9..b9627d8b1fcb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,12 @@ FastGPT 是一个 AI Agent 构建平台,通过 Flow 提供开箱即用的数据 - 所有代码编写、修改、重构和测试调整都必须遵守 [FastGPT 代码规范](./.agents/code/syntax.md)。开始改动前先查看相关规范;如果规范与当前实现习惯冲突,优先按规范执行,并只在有明确业务或兼容性理由时说明例外。 +### MongoDB Schema 与索引维护 + +- 每当新增、修改、删除或重命名 MongoDB/Mongoose Schema 字段、索引定义、唯一约束、TTL、partialFilterExpression、collation 等索引相关配置时,必须同步检查是否有 FastGPT 旧版本创建的索引不再被当前 Schema 使用。 +- 如果历史索引可能继续影响写入约束、查询计划或存储成本,应在所属 Schema 文件中通过 `defineDeprecatedIndexes` 紧邻当前索引声明登记删除定义,并补充/调整 `packages/service/test/common/mongo/indexManager.test.ts` 的清理行为覆盖。定义只包含旧索引的 name、key,以及需要参与精确匹配的关键 options。 +- 不要登记客户自建索引、无法确认来源的索引,或仅凭当前 Schema 未声明就推断为废弃的索引。主动同步只允许删除 FastGPT 明确创建过、明确废弃且与 Schema 本地声明精确匹配的历史索引。 + ### API 入参校验 - 编写或修改 NextJS API 路由时,如果需要校验接口入参(`req.body`、`req.query`、`req.params`),必须使用 `parseApiInput`,不要直接写 `SomeSchema.parse(req.body)`、`SomeSchema.parse(req.query)` 或 `SomeSchema.parse(req.params)`。 @@ -139,7 +145,7 @@ function agent_loop(用户需求){ 提出问题,让用户提供答案; 调整需求文档; } - + // 2. 开发文档编写 while(开发文档编写未完成){ 编写开发文档; diff --git a/document/content/self-host/upgrading/4-15/4152.en.mdx b/document/content/self-host/upgrading/4-15/4152.en.mdx index b4401c4d0821..9200d4618811 100644 --- a/document/content/self-host/upgrading/4-15/4152.en.mdx +++ b/document/content/self-host/upgrading/4-15/4152.en.mdx @@ -26,6 +26,18 @@ Starting with V4.15.2, `AGENT_ENGINE` uses new enum values. Update the environme The previous values are no longer supported. Using `default` or `pi` will fail environment variable validation and prevent FastGPT from starting. If `AGENT_ENGINE` is not set, FastGPT uses `fastAgent` by default. +### MongoDB Index Synchronization Changes + +Starting with V4.15.2, FastGPT no longer uses the `SYNC_INDEX` or `MONGO_INDEX_SYNC_MODE` environment variables. You can remove both variables before upgrading; no MongoDB index synchronization mode needs to be configured. + +FastGPT now performs a safe synchronization automatically at startup: + +- Creates indexes that are missing from the current FastGPT schemas. +- Removes only historical FastGPT indexes explicitly declared by the corresponding schema and whose name, key, and relevant options match exactly. +- Preserves custom indexes and any other indexes that are not explicitly declared as deprecated. + +This process does not call Mongoose's full `syncIndexes()` operation, so indexes are never removed simply because they are absent from a FastGPT schema. + ### Configure the File Download URL Mode V4.15.2 introduces the `STORAGE_DOWNLOAD_URL_MODE` environment variable, which defaults to `short-proxy`. diff --git a/document/content/self-host/upgrading/4-15/4152.mdx b/document/content/self-host/upgrading/4-15/4152.mdx index c465708b42e7..237c847dce67 100644 --- a/document/content/self-host/upgrading/4-15/4152.mdx +++ b/document/content/self-host/upgrading/4-15/4152.mdx @@ -26,7 +26,19 @@ V4.15.2 起,`AGENT_ENGINE` 使用新的枚举值。升级前,请按下表修 旧值不再兼容。继续使用 `default` 或 `pi` 会导致环境变量校验失败,FastGPT 无法启动。未配置 `AGENT_ENGINE` 时,可正常启动,系统默认使用 `fastAgent`。 -### 3. 修改文件下载模式变量 +### MongoDB 索引同步调整 + +V4.15.2 起,不再使用 `SYNC_INDEX` 和 `MONGO_INDEX_SYNC_MODE` 环境变量,无需配置 MongoDB 索引同步模式。升级前可直接删除这两个变量。 + +FastGPT 启动时会自动执行安全的主动同步: + +- 创建当前 FastGPT Schema 中缺失的索引。 +- 仅删除对应 Schema 明确声明、且 name、key 和关键 options 完全匹配的 FastGPT 历史废弃索引。 +- 保留客户自建索引及其他未声明的索引。 + +该同步不会调用 Mongoose 的全量 `syncIndexes()`,因此不会按“未在 Schema 中声明”这一条件批量删除索引。 + +### 修改文件下载模式变量 V4.15.2 新增 `STORAGE_DOWNLOAD_URL_MODE` 环境变量,默认值为 `short-proxy`。 diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index 11ebdde32e32..4000d4e3fe7b 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -167,8 +167,8 @@ "content/plugin/model-presets.mdx": "2026-06-04T16:10:15+08:00", "content/plugin/system-tool-development.en.mdx": "2026-07-02T11:54:55+08:00", "content/plugin/system-tool-development.mdx": "2026-07-02T11:54:55+08:00", - "content/self-host/config/env.en.mdx": "2026-07-17T19:17:14+08:00", - "content/self-host/config/env.mdx": "2026-07-17T19:17:14+08:00", + "content/self-host/config/env.en.mdx": "2026-07-18T12:51:59+08:00", + "content/self-host/config/env.mdx": "2026-07-18T12:51:59+08:00", "content/self-host/config/model/intro.en.mdx": "2026-06-04T16:10:15+08:00", "content/self-host/config/model/intro.mdx": "2026-06-04T16:10:15+08:00", "content/self-host/config/model/minimax.en.mdx": "2026-06-03T10:40:17+08:00", @@ -320,10 +320,10 @@ "content/self-host/upgrading/4-15/41507.mdx": "2026-06-30T17:31:43+08:00", "content/self-host/upgrading/4-15/4151.en.mdx": "2026-07-07T21:14:28+08:00", "content/self-host/upgrading/4-15/4151.mdx": "2026-07-07T21:14:28+08:00", - "content/self-host/upgrading/4-15/4152.en.mdx": "2026-07-17T19:17:14+08:00", - "content/self-host/upgrading/4-15/4152.mdx": "2026-07-17T19:34:15+08:00", - "content/self-host/upgrading/4-15/4153.en.mdx": "2026-07-18T11:22:45+08:00", - "content/self-host/upgrading/4-15/4153.mdx": "2026-07-18T11:22:45+08:00", + "content/self-host/upgrading/4-15/4152.en.mdx": "2026-07-17T17:41:38+08:00", + "content/self-host/upgrading/4-15/4152.mdx": "2026-07-17T17:41:38+08:00", + "content/self-host/upgrading/4-15/4153.en.mdx": "2026-07-18T12:51:59+08:00", + "content/self-host/upgrading/4-15/4153.mdx": "2026-07-18T12:51:59+08:00", "content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00", @@ -464,6 +464,6 @@ "content/self-host/upgrading/outdated/499.mdx": "2026-05-07T15:06:40+08:00", "content/self-host/upgrading/upgrade-intruction.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/upgrade-intruction.mdx": "2026-04-26T21:08:47+08:00", - "content/toc.en.mdx": "2026-07-18T11:22:45+08:00", - "content/toc.mdx": "2026-07-18T11:22:45+08:00" + "content/toc.en.mdx": "2026-07-18T12:51:59+08:00", + "content/toc.mdx": "2026-07-18T12:51:59+08:00" } \ No newline at end of file diff --git a/packages/service/common/mongo/index.ts b/packages/service/common/mongo/index.ts index 2491df300a40..82c07ab53772 100644 --- a/packages/service/common/mongo/index.ts +++ b/packages/service/common/mongo/index.ts @@ -9,6 +9,7 @@ import type { } from 'mongoose'; import mongoose, { Mongoose } from 'mongoose'; import { serviceEnv } from '../../env'; +import { MongoIndexManager } from './indexManager'; const logger = getLogger(LogCategories.INFRA.MONGO); @@ -136,7 +137,6 @@ export const getMongoModel = (name: string, schema: mongoose.Schema): Model; - // Sync index syncMongoIndex(model); return model; @@ -148,27 +148,44 @@ export const getMongoLogModel = (name: string, schema: mongoose.Schema): Mode const model = connectionLogMongo.model(name, schema) as Model; - // Sync index syncMongoIndex(model); return model; }; -const syncMongoIndex = async (model: Model) => { +const syncMongoIndex = (model: Model) => { if ( process.env.NODE_ENV === 'test' || process.env.NEXT_PHASE === 'phase-production-build' || - !serviceEnv.SYNC_INDEX || !MONGO_URL ) { return; } - try { - await model.syncIndexes({ background: true }); - } catch (error) { - logger.error('Failed to sync MongoDB indexes', { modelName: model.modelName, error }); - } + void MongoIndexManager.syncModelIndexes({ + model, + logger + }).catch((error) => { + logger.error('Failed to ensure MongoDB indexes', { + modelName: model.modelName, + collectionName: model.collection.collectionName, + error + }); + }); }; export const ReadPreference = connectionMongo.mongo.ReadPreference; + +export { MongoIndexManager } from './indexManager'; +export { + getDeprecatedIndexes as getSchemaDeprecatedMongoIndexes, + defineDeprecatedIndexes as defineDeprecatedIndexes +} from './schemaIndexes'; +export type { + MongoIndexCleanupAction, + MongoIndexCleanupReport, + MongoIndexCleanupReportItem, + MongoIndexCleanupSummary, + MongoIndexSyncResult +} from './indexManager'; +export type { DeprecatedMongoIndexDefinition, DeprecatedMongoIndexOptions } from './schemaIndexes'; diff --git a/packages/service/common/mongo/indexManager.ts b/packages/service/common/mongo/indexManager.ts new file mode 100644 index 000000000000..b058ddd985cc --- /dev/null +++ b/packages/service/common/mongo/indexManager.ts @@ -0,0 +1,452 @@ +import { getLogger, LogCategories } from '../logger'; +import type { Model } from 'mongoose'; +import { getDeprecatedIndexes, type DeprecatedMongoIndexDefinition } from './schemaIndexes'; + +const defaultLogger = getLogger(LogCategories.INFRA.MONGO); + +type MongoIndexLogger = { + debug: (message: string, data?: Record) => void; + info: (message: string, data?: Record) => void; + warn: (message: string, data?: Record) => void; + error: (message: string, data?: Record) => void; +}; + +type MongooseDiffIndexesResult = { + toDrop: string[]; + toCreate: unknown[]; +}; + +export type MongoIndexSyncResult = { + modelName: string; + collectionName: string; + toDrop: string[]; + toCreate: unknown[]; + cleanupReport: MongoIndexCleanupReport; +}; + +export type MongoIndexDescription = { + name?: string; + key?: Record; + unique?: boolean; + sparse?: boolean; + expireAfterSeconds?: number; + partialFilterExpression?: unknown; + collation?: unknown; +}; + +export type MongoIndexCleanupAction = 'drop' | 'skip_missing' | 'skip_mismatch' | 'error'; + +export type MongoIndexCleanupReportItem = { + collectionName: string; + indexName: string; + action: MongoIndexCleanupAction; + applied: boolean; + reason: string; + error?: string; +}; + +export type MongoIndexCleanupReport = { + apply: boolean; + items: MongoIndexCleanupReportItem[]; +}; + +export type MongoIndexCleanupSummary = { + total: number; + dropped: number; + droppable: number; + skippedMissing: number; + skippedMismatch: number; + errors: number; +}; + +type SyncModelIndexesParams = { + model: Model; + logger?: MongoIndexLogger; +}; + +const optionKeys = [ + 'unique', + 'sparse', + 'expireAfterSeconds', + 'partialFilterExpression', + 'collation' +] as const; + +/** + * MongoDB 索引管理入口。 + * + * 每个 model 固定执行安全同步:补建当前 Schema 索引,再删除该 Schema 明确登记且 + * 精确匹配的历史索引。Schema 外未知索引只记录不删除,以保护客户自建索引。 + */ +export class MongoIndexManager { + private static modelIndexTasks = new Map, Promise>(); + + private static getCollectionName(model: Model) { + return model.collection.collectionName; + } + + /** + * 只计算当前 Schema 和数据库索引的差异,不创建也不删除任何索引。 + * + * `toDrop` 仅表示 Mongoose 认为 Schema 外存在的索引,不能直接作为删除清单。 + */ + static async inspectModelIndexes( + model: Model + ): Promise> { + const diff = (await model.diffIndexes({ + indexOptionsToCreate: true + })) as MongooseDiffIndexesResult; + + return { + modelName: model.modelName, + collectionName: MongoIndexManager.getCollectionName(model), + toDrop: diff.toDrop, + toCreate: diff.toCreate + }; + } + + /** + * 主动同步单个 Model 的索引。 + * + * 当前索引必须先创建成功,之后才会清理 Schema 本地登记的废弃索引。同一进程内 + * 针对同一个 Model 的并发调用复用进行中的任务,完成后允许重连或热加载再次检查。 + */ + static async syncModelIndexes(params: SyncModelIndexesParams): Promise { + const existingTask = MongoIndexManager.modelIndexTasks.get(params.model); + if (existingTask) { + return existingTask; + } + + const task = MongoIndexManager.syncModelIndexesInner(params); + MongoIndexManager.modelIndexTasks.set(params.model, task); + + try { + return await task; + } finally { + if (MongoIndexManager.modelIndexTasks.get(params.model) === task) { + MongoIndexManager.modelIndexTasks.delete(params.model); + } + } + } + + private static async syncModelIndexesInner({ + model, + logger = defaultLogger + }: SyncModelIndexesParams): Promise { + const inspection = await MongoIndexManager.inspectModelIndexes(model); + + if (inspection.toDrop.length > 0) { + logger.warn('Detected MongoDB indexes not declared by FastGPT schema', { + collectionName: inspection.collectionName, + indexNames: inspection.toDrop + }); + } + + await model.createIndexes({ background: true }); + + const cleanupReport = await MongoIndexManager.cleanupModelDeprecatedIndexes({ + model, + apply: true, + logger + }); + const result: MongoIndexSyncResult = { + ...inspection, + cleanupReport + }; + const cleanupSummary = MongoIndexManager.summarizeCleanupReport(cleanupReport); + + if (inspection.toCreate.length > 0 || cleanupSummary.dropped > 0) { + logger.info('MongoDB indexes synchronized', { + collectionName: inspection.collectionName, + created: inspection.toCreate.length, + dropped: cleanupSummary.dropped + }); + } + + return result; + } + + /** + * 清理当前 Model 所属 Schema 明确登记的废弃索引。 + * + * 只有 name、key 和关键 options 精确匹配时才允许删除;定义不匹配或未知索引均 + * 保留。`apply=false` 仅供诊断入口复用,启动同步固定传 true。 + */ + static async cleanupModelDeprecatedIndexes({ + model, + apply, + logger + }: { + model: Model; + apply: boolean; + logger?: MongoIndexLogger; + }): Promise { + const collectionName = MongoIndexManager.getCollectionName(model); + const definitions = getDeprecatedIndexes(model.schema); + const items: MongoIndexCleanupReportItem[] = []; + + if (definitions.length === 0) { + return { apply, items }; + } + + for (const definition of definitions) { + try { + const currentIndexes = (await model.collection.indexes().catch((error) => { + if (MongoIndexManager.isNamespaceNotFoundError(error)) { + return []; + } + throw error; + })) as MongoIndexDescription[]; + const targetIndex = currentIndexes.find((index) => index.name === definition.indexName); + + if (!targetIndex) { + const item = MongoIndexManager.buildCleanupItem({ + collectionName, + definition, + action: 'skip_missing', + reason: 'Deprecated index does not exist' + }); + items.push(item); + continue; + } + + if (!MongoIndexManager.isDeprecatedIndexMatched({ definition, index: targetIndex })) { + const item = MongoIndexManager.buildCleanupItem({ + collectionName, + definition, + action: 'skip_mismatch', + reason: 'Index definition does not match Schema declaration' + }); + logger?.warn('Deprecated MongoDB index definition mismatched', { + collectionName, + indexName: definition.indexName + }); + items.push(item); + continue; + } + + if (apply) { + try { + await model.collection.dropIndex(definition.indexName); + } catch (error) { + if (MongoIndexManager.isIndexNotFoundError(error)) { + const item = MongoIndexManager.buildCleanupItem({ + collectionName, + definition, + action: 'skip_missing', + reason: 'Deprecated index was already removed' + }); + items.push(item); + continue; + } + throw error; + } + } + + const item = MongoIndexManager.buildCleanupItem({ + collectionName, + definition, + action: 'drop', + applied: apply, + reason: apply ? 'Deprecated index dropped' : 'Deprecated index can be dropped' + }); + items.push(item); + } catch (error) { + const item = MongoIndexManager.buildCleanupItem({ + collectionName, + definition, + action: 'error', + reason: 'Failed to inspect or cleanup deprecated index', + error: MongoIndexManager.getErrorMessage(error) + }); + logger?.error('Failed to cleanup deprecated MongoDB index', { + collectionName, + indexName: definition.indexName, + error: item.error + }); + items.push(item); + } + } + + return { apply, items }; + } + + static summarizeCleanupReport(report: MongoIndexCleanupReport): MongoIndexCleanupSummary { + return report.items.reduce( + (summary, item) => { + summary.total += 1; + + if (item.action === 'drop' && item.applied) { + summary.dropped += 1; + } else if (item.action === 'drop') { + summary.droppable += 1; + } else if (item.action === 'skip_missing') { + summary.skippedMissing += 1; + } else if (item.action === 'skip_mismatch') { + summary.skippedMismatch += 1; + } else if (item.action === 'error') { + summary.errors += 1; + } + + return summary; + }, + { + total: 0, + dropped: 0, + droppable: 0, + skippedMissing: 0, + skippedMismatch: 0, + errors: 0 + } + ); + } + + static formatCleanupReport(report: MongoIndexCleanupReport) { + const lines = [ + `MongoDB deprecated index cleanup ${report.apply ? 'apply' : 'dry-run'} report`, + `Total: ${report.items.length}` + ]; + + for (const item of report.items) { + lines.push( + [ + `- [${item.action}]`, + item.applied ? 'applied' : 'not-applied', + `${item.collectionName}.${item.indexName}`, + `reason=${item.reason}`, + item.error ? `error=${item.error}` : undefined + ] + .filter(Boolean) + .join(' ') + ); + } + + return lines.join('\n'); + } + + private static normalizeForCompare( + value: unknown, + { sortObjectKeys }: { sortObjectKeys: boolean } + ): unknown { + if (Array.isArray(value)) { + return value.map((item) => MongoIndexManager.normalizeForCompare(item, { sortObjectKeys })); + } + + if (value && typeof value === 'object') { + const keys = Object.keys(value); + const orderedKeys = sortObjectKeys ? keys.sort() : keys; + + return orderedKeys.reduce>((result, key) => { + result[key] = MongoIndexManager.normalizeForCompare(Reflect.get(value, key), { + sortObjectKeys + }); + return result; + }, {}); + } + + return value; + } + + private static isSameValue( + left: unknown, + right: unknown, + { sortObjectKeys = true }: { sortObjectKeys?: boolean } = {} + ) { + return ( + JSON.stringify(MongoIndexManager.normalizeForCompare(left, { sortObjectKeys })) === + JSON.stringify(MongoIndexManager.normalizeForCompare(right, { sortObjectKeys })) + ); + } + + private static getExpectedOptions(definition: DeprecatedMongoIndexDefinition) { + return optionKeys.reduce>((result, key) => { + const value = definition.options?.[key]; + if (value !== undefined) { + result[key] = value; + } + return result; + }, {}); + } + + private static getActualOptions(index: MongoIndexDescription) { + return optionKeys.reduce>((result, key) => { + const value = index[key]; + if (value !== undefined) { + result[key] = value; + } + return result; + }, {}); + } + + private static isDeprecatedIndexMatched({ + definition, + index + }: { + definition: DeprecatedMongoIndexDefinition; + index: MongoIndexDescription; + }) { + if (!MongoIndexManager.isSameValue(index.key, definition.key, { sortObjectKeys: false })) { + return false; + } + + return MongoIndexManager.isSameValue( + MongoIndexManager.getActualOptions(index), + MongoIndexManager.getExpectedOptions(definition) + ); + } + + private static buildCleanupItem({ + collectionName, + definition, + action, + applied = false, + reason, + error + }: { + collectionName: string; + definition: DeprecatedMongoIndexDefinition; + action: MongoIndexCleanupAction; + applied?: boolean; + reason: string; + error?: string; + }): MongoIndexCleanupReportItem { + return { + collectionName, + indexName: definition.indexName, + action, + applied, + reason, + error + }; + } + + private static getErrorMessage(error: unknown) { + if (error instanceof Error) { + return error.message; + } + return String(error); + } + + private static isNamespaceNotFoundError(error: unknown) { + if (typeof error !== 'object' || error === null) { + return false; + } + + const codeName = Reflect.get(error, 'codeName'); + const message = Reflect.get(error, 'message'); + return ( + codeName === 'NamespaceNotFound' || + (typeof message === 'string' && message.includes('ns does not exist')) + ); + } + + private static isIndexNotFoundError(error: unknown) { + if (typeof error !== 'object' || error === null) { + return false; + } + + const code = Reflect.get(error, 'code'); + const codeName = Reflect.get(error, 'codeName'); + return code === 27 || codeName === 'IndexNotFound'; + } +} diff --git a/packages/service/common/mongo/schemaIndexes.ts b/packages/service/common/mongo/schemaIndexes.ts new file mode 100644 index 000000000000..a3e6051242cf --- /dev/null +++ b/packages/service/common/mongo/schemaIndexes.ts @@ -0,0 +1,44 @@ +import type { IndexDefinition, IndexOptions, Schema } from 'mongoose'; + +const deprecatedMongoIndexesKey = Symbol.for('fastgpt.mongo.deprecatedIndexes'); + +export type DeprecatedMongoIndexOptions = Pick< + IndexOptions, + 'unique' | 'sparse' | 'expireAfterSeconds' | 'partialFilterExpression' | 'collation' +>; + +export type DeprecatedMongoIndexDefinition = { + indexName: string; + key: IndexDefinition; + options?: DeprecatedMongoIndexOptions; +}; + +/** + * 将 FastGPT 明确废弃的历史索引登记到所属 Schema。 + * + * collection 由 model 推导,因此定义只描述索引本身。重复名称会在启动前直接报错, + * 避免同一个 Schema 对删除目标给出互相冲突的定义。 + */ +export const defineDeprecatedIndexes = ( + schema: Schema, + indexes: DeprecatedMongoIndexDefinition[] +) => { + const registeredIndexes = getDeprecatedIndexes(schema); + const nextIndexes = [...registeredIndexes, ...indexes]; + const duplicateIndexName = nextIndexes.find( + (index, position) => + nextIndexes.findIndex((candidate) => candidate.indexName === index.indexName) !== position + )?.indexName; + + if (duplicateIndexName) { + throw new Error(`Duplicate deprecated MongoDB index declaration: ${duplicateIndexName}`); + } + + Reflect.set(schema, deprecatedMongoIndexesKey, nextIndexes); +}; + +/** 读取某个 Schema 自身登记的废弃索引,不聚合其他集合或全局清单。 */ +export const getDeprecatedIndexes = (schema: Schema): readonly DeprecatedMongoIndexDefinition[] => { + const indexes: unknown = Reflect.get(schema, deprecatedMongoIndexesKey); + return Array.isArray(indexes) ? indexes : []; +}; diff --git a/packages/service/core/ai/sandbox/infrastructure/instance/schema.ts b/packages/service/core/ai/sandbox/infrastructure/instance/schema.ts index 1e7fcbdaeb94..bac3307a43c1 100644 --- a/packages/service/core/ai/sandbox/infrastructure/instance/schema.ts +++ b/packages/service/core/ai/sandbox/infrastructure/instance/schema.ts @@ -3,7 +3,11 @@ * * 只描述本地实例记录结构,不编排 provider、归档或运行态流程。 */ -import { connectionMongo, getMongoModel } from '../../../../../common/mongo'; +import { + connectionMongo, + getMongoModel, + defineDeprecatedIndexes +} from '../../../../../common/mongo'; const { Schema } = connectionMongo; import type { SandboxInstanceSchemaType } from '../../type'; import { SandboxStatusEnum, SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants'; @@ -82,6 +86,41 @@ SandboxInstanceSchema.index({ status: 1, lastActiveAt: 1, 'metadata.archive.stat SandboxInstanceSchema.index({ 'metadata.archive.state': 1, 'metadata.archive.startedAt': 1 }); SandboxInstanceSchema.index({ 'metadata.archive.state': 1, 'metadata.archive.deleteStartedAt': 1 }); +defineDeprecatedIndexes(SandboxInstanceSchema, [ + { + indexName: 'provider_1_appId_1_userId_1_chatId_1', + key: { provider: 1, appId: 1, userId: 1, chatId: 1 }, + options: { + unique: true, + partialFilterExpression: { + appId: { $exists: true }, + userId: { $exists: true }, + chatId: { $exists: true } + } + } + }, + { + indexName: 'appId_1_chatId_1', + key: { appId: 1, chatId: 1 }, + options: { + unique: true, + partialFilterExpression: { + appId: { $exists: true }, + chatId: { $exists: true }, + type: { $exists: true } + } + } + }, + { + indexName: 'metadata.skillId_1', + key: { 'metadata.skillId': 1 } + }, + { + indexName: 'type_1_chatId_1', + key: { type: 1, chatId: 1 } + } +]); + /** * sandbox 实例 Mongo model。 * diff --git a/packages/service/core/chat/chatSchema.ts b/packages/service/core/chat/chatSchema.ts index 6e7dfd2eb146..166187b14d15 100644 --- a/packages/service/core/chat/chatSchema.ts +++ b/packages/service/core/chat/chatSchema.ts @@ -1,4 +1,4 @@ -import { connectionMongo, getMongoModel } from '../../common/mongo'; +import { connectionMongo, getMongoModel, defineDeprecatedIndexes } from '../../common/mongo'; const { Schema } = connectionMongo; import { type ChatSchemaType } from '@fastgpt/global/core/chat/type'; import { @@ -249,4 +249,12 @@ ChatSchema.index( } ); +defineDeprecatedIndexes(ChatSchema, [ + { + indexName: 'appId_1_chatId_1', + key: { appId: 1, chatId: 1 }, + options: { unique: true } + } +]); + export const MongoChat = getMongoModel(chatCollectionName, ChatSchema); diff --git a/packages/service/env.ts b/packages/service/env.ts index 8e9a85230223..a28dd73e42bf 100644 --- a/packages/service/env.ts +++ b/packages/service/env.ts @@ -29,7 +29,6 @@ export const serviceEnv = createEnv({ server: { // ==================== 基础配置 ==================== DB_MAX_LINK: IntSchema.min(1).default(5), - SYNC_INDEX: BoolSchema.default(true), // ==================== 密钥 ==================== ROOT_KEY: z diff --git a/packages/service/test/common/mongo/indexManager.test.ts b/packages/service/test/common/mongo/indexManager.test.ts new file mode 100644 index 000000000000..e18949916d8e --- /dev/null +++ b/packages/service/test/common/mongo/indexManager.test.ts @@ -0,0 +1,346 @@ +import { randomUUID } from 'node:crypto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { connectionMongo, defineDeprecatedIndexes, Schema } from '@fastgpt/service/common/mongo'; +import { MongoIndexManager } from '@fastgpt/service/common/mongo/indexManager'; + +const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() +}; + +const createModel = ({ + schema, + prefix = 'MongoIndexManager' +}: { + schema: InstanceType; + prefix?: string; +}) => { + const suffix = randomUUID().replaceAll('-', ''); + return connectionMongo.model(`${prefix}${suffix}`, schema, `${prefix.toLowerCase()}_${suffix}`); +}; + +const getIndexNames = async (model: ReturnType) => + new Set((await model.collection.indexes()).map((index) => index.name)); + +const legacyDefinition = { + indexName: 'legacy_field_1', + key: { legacyField: 1 } +} as const; + +describe('MongoIndexManager.syncModelIndexes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates current indexes, removes declared legacy indexes, and preserves customer indexes', async () => { + const schema = new Schema( + { + currentField: String, + legacyField: String, + customerField: String + }, + { autoIndex: false } + ); + schema.index({ currentField: 1 }, { name: 'current_field_1' }); + defineDeprecatedIndexes(schema, [legacyDefinition]); + const model = createModel({ schema }); + await model.collection.createIndex({ legacyField: 1 }, { name: 'legacy_field_1' }); + await model.collection.createIndex({ customerField: 1 }, { name: 'customer_custom_1' }); + + const result = await MongoIndexManager.syncModelIndexes({ model, logger }); + + const indexNames = await getIndexNames(model); + expect(indexNames).toContain('current_field_1'); + expect(indexNames).toContain('customer_custom_1'); + expect(indexNames).not.toContain('legacy_field_1'); + expect(result.cleanupReport.items).toEqual([ + expect.objectContaining({ + action: 'drop', + applied: true, + collectionName: model.collection.collectionName, + indexName: 'legacy_field_1' + }) + ]); + expect(logger.warn).toHaveBeenCalledWith( + 'Detected MongoDB indexes not declared by FastGPT schema', + { + collectionName: model.collection.collectionName, + indexNames: expect.arrayContaining(['legacy_field_1', 'customer_custom_1']) + } + ); + expect(logger.info).toHaveBeenCalledWith('MongoDB indexes synchronized', { + collectionName: model.collection.collectionName, + created: 1, + dropped: 1 + }); + }); + + it('does not delete schema-external indexes when the Schema has no deprecated declarations', async () => { + const schema = new Schema( + { currentField: String, customerField: String }, + { autoIndex: false } + ); + schema.index({ currentField: 1 }, { name: 'current_field_1' }); + const model = createModel({ schema }); + await model.collection.createIndex({ currentField: 1 }, { name: 'current_field_1' }); + await model.collection.createIndex({ customerField: 1 }, { name: 'customer_custom_1' }); + + const result = await MongoIndexManager.syncModelIndexes({ model, logger }); + + expect(await getIndexNames(model)).toContain('customer_custom_1'); + expect(result.cleanupReport.items).toEqual([]); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.debug).not.toHaveBeenCalled(); + }); + + it('reuses an in-flight task for concurrent calls on the same Model', async () => { + const schema = new Schema({ currentField: String }, { autoIndex: false }); + schema.index({ currentField: 1 }, { name: 'current_field_1' }); + const model = createModel({ schema }); + const createIndexes = vi.spyOn(model, 'createIndexes'); + + const [firstResult, secondResult] = await Promise.all([ + MongoIndexManager.syncModelIndexes({ model }), + MongoIndexManager.syncModelIndexes({ model }) + ]); + + expect(firstResult).toBe(secondResult); + expect(createIndexes).toHaveBeenCalledTimes(1); + }); + + it('does not clean deprecated indexes when creating current indexes fails', async () => { + const schema = new Schema( + { currentField: String, conflictingField: String, legacyField: String }, + { autoIndex: false } + ); + schema.index({ currentField: 1 }, { name: 'current_field_1' }); + defineDeprecatedIndexes(schema, [legacyDefinition]); + const model = createModel({ schema }); + await model.collection.createIndex({ conflictingField: 1 }, { name: 'current_field_1' }); + await model.collection.createIndex({ legacyField: 1 }, { name: 'legacy_field_1' }); + + await expect(MongoIndexManager.syncModelIndexes({ model })).rejects.toThrow(); + + expect(await getIndexNames(model)).toContain('legacy_field_1'); + }); +}); + +describe('MongoIndexManager.cleanupModelDeprecatedIndexes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('supports dry-run without deleting a matched index', async () => { + const schema = new Schema({ legacyField: String }, { autoIndex: false }); + defineDeprecatedIndexes(schema, [ + { + ...legacyDefinition, + options: { unique: true } + } + ]); + const model = createModel({ schema }); + await model.collection.createIndex( + { legacyField: 1 }, + { name: 'legacy_field_1', unique: true } + ); + + const report = await MongoIndexManager.cleanupModelDeprecatedIndexes({ + model, + apply: false, + logger + }); + + expect(report.items).toEqual([ + expect.objectContaining({ action: 'drop', applied: false, indexName: 'legacy_field_1' }) + ]); + expect(await getIndexNames(model)).toContain('legacy_field_1'); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.debug).not.toHaveBeenCalled(); + }); + + it('preserves same-name indexes when key, key order, or options do not match', async () => { + const schema = new Schema( + { customerField: String, legacyField: String, otherField: String }, + { autoIndex: false } + ); + defineDeprecatedIndexes(schema, [ + { + ...legacyDefinition, + options: { unique: true } + }, + { + indexName: 'legacy_compound_1', + key: { legacyField: 1, otherField: 1 } + } + ]); + const model = createModel({ schema }); + await model.collection.createIndex({ customerField: 1 }, { name: 'legacy_field_1' }); + await model.collection.createIndex( + { otherField: 1, legacyField: 1 }, + { name: 'legacy_compound_1' } + ); + + const report = await MongoIndexManager.cleanupModelDeprecatedIndexes({ + model, + apply: true, + logger + }); + + expect(report.items).toEqual([ + expect.objectContaining({ action: 'skip_mismatch', indexName: 'legacy_field_1' }), + expect.objectContaining({ action: 'skip_mismatch', indexName: 'legacy_compound_1' }) + ]); + expect(await getIndexNames(model)).toEqual( + expect.objectContaining(new Set(['_id_', 'legacy_field_1', 'legacy_compound_1'])) + ); + }); + + it('reports a missing deprecated index and formats its report', async () => { + const schema = new Schema({ legacyField: String }, { autoIndex: false }); + defineDeprecatedIndexes(schema, [legacyDefinition]); + const model = createModel({ schema }); + + const report = await MongoIndexManager.cleanupModelDeprecatedIndexes({ + model, + apply: true + }); + + expect(report.items).toEqual([ + expect.objectContaining({ action: 'skip_missing', indexName: 'legacy_field_1' }) + ]); + expect(MongoIndexManager.summarizeCleanupReport(report)).toEqual({ + total: 1, + dropped: 0, + droppable: 0, + skippedMissing: 1, + skippedMismatch: 0, + errors: 0 + }); + expect(MongoIndexManager.formatCleanupReport(report)).toContain( + `${model.collection.collectionName}.legacy_field_1 reason=Deprecated index does not exist` + ); + }); + + it('treats a concurrent IndexNotFound response as an idempotent skip', async () => { + const schema = new Schema({ legacyField: String }, { autoIndex: false }); + defineDeprecatedIndexes(schema, [legacyDefinition]); + const model = createModel({ schema }); + await model.collection.createIndex({ legacyField: 1 }, { name: 'legacy_field_1' }); + vi.spyOn(model.collection, 'dropIndex').mockRejectedValueOnce({ + codeName: 'IndexNotFound' + }); + + const report = await MongoIndexManager.cleanupModelDeprecatedIndexes({ + model, + apply: true + }); + + expect(report.items).toEqual([ + expect.objectContaining({ + action: 'skip_missing', + reason: 'Deprecated index was already removed' + }) + ]); + }); + + it('recognizes the numeric MongoDB IndexNotFound code', async () => { + const schema = new Schema({ legacyField: String }, { autoIndex: false }); + defineDeprecatedIndexes(schema, [legacyDefinition]); + const model = createModel({ schema }); + await model.collection.createIndex({ legacyField: 1 }, { name: 'legacy_field_1' }); + vi.spyOn(model.collection, 'dropIndex').mockRejectedValueOnce({ code: 27 }); + + const report = await MongoIndexManager.cleanupModelDeprecatedIndexes({ + model, + apply: true + }); + + expect(report.items[0]).toMatchObject({ + action: 'skip_missing', + reason: 'Deprecated index was already removed' + }); + }); + + it('captures unexpected inspection errors in the cleanup report', async () => { + const schema = new Schema({ legacyField: String }, { autoIndex: false }); + defineDeprecatedIndexes(schema, [legacyDefinition]); + const model = createModel({ schema }); + vi.spyOn(model.collection, 'indexes').mockRejectedValueOnce(new Error('inspection failed')); + + const report = await MongoIndexManager.cleanupModelDeprecatedIndexes({ + model, + apply: true, + logger + }); + + expect(report.items).toEqual([ + expect.objectContaining({ + action: 'error', + error: 'inspection failed', + indexName: 'legacy_field_1' + }) + ]); + expect(logger.error).toHaveBeenCalledWith('Failed to cleanup deprecated MongoDB index', { + collectionName: model.collection.collectionName, + indexName: 'legacy_field_1', + error: 'inspection failed' + }); + }); + + it('normalizes non-Error cleanup failures into report messages', async () => { + const schema = new Schema({ legacyField: String }, { autoIndex: false }); + defineDeprecatedIndexes(schema, [legacyDefinition]); + const model = createModel({ schema }); + await model.collection.createIndex({ legacyField: 1 }, { name: 'legacy_field_1' }); + vi.spyOn(model.collection, 'dropIndex').mockRejectedValueOnce('drop failed'); + + const report = await MongoIndexManager.cleanupModelDeprecatedIndexes({ + model, + apply: true + }); + + expect(report.items[0]).toMatchObject({ action: 'error', error: 'drop failed' }); + }); + + it('summarizes every cleanup action and formats error details', () => { + const report = { + apply: true, + items: [ + { + collectionName: 'test_collection', + indexName: 'legacy_drop_1', + action: 'drop' as const, + applied: false, + reason: 'Can drop', + error: 'test error' + }, + { + collectionName: 'test_collection', + indexName: 'legacy_mismatch_1', + action: 'skip_mismatch' as const, + applied: false, + reason: 'Mismatch' + }, + { + collectionName: 'test_collection', + indexName: 'legacy_error_1', + action: 'error' as const, + applied: false, + reason: 'Error' + } + ] + }; + + expect(MongoIndexManager.summarizeCleanupReport(report)).toEqual({ + total: 3, + dropped: 0, + droppable: 1, + skippedMissing: 0, + skippedMismatch: 1, + errors: 1 + }); + expect(MongoIndexManager.formatCleanupReport(report)).toContain('error=test error'); + }); +}); diff --git a/packages/service/test/common/mongo/schemaIndexes.test.ts b/packages/service/test/common/mongo/schemaIndexes.test.ts new file mode 100644 index 000000000000..ec4822a29667 --- /dev/null +++ b/packages/service/test/common/mongo/schemaIndexes.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { Schema } from '@fastgpt/service/common/mongo'; +import { + getDeprecatedIndexes, + defineDeprecatedIndexes +} from '@fastgpt/service/common/mongo/schemaIndexes'; + +describe('Schema deprecated MongoDB indexes', () => { + it('returns an empty list for a Schema without declarations', () => { + expect(getDeprecatedIndexes(new Schema())).toEqual([]); + }); + + it('stores declarations on the Schema and appends later declarations', () => { + const schema = new Schema(); + defineDeprecatedIndexes(schema, [ + { + indexName: 'legacy_a_1', + key: { legacyA: 1 } + } + ]); + defineDeprecatedIndexes(schema, [ + { + indexName: 'legacy_b_1', + key: { legacyB: 1 } + } + ]); + + expect(getDeprecatedIndexes(schema).map((index) => index.indexName)).toEqual([ + 'legacy_a_1', + 'legacy_b_1' + ]); + }); + + it('rejects duplicate index names within one Schema', () => { + const schema = new Schema(); + const definition = { + indexName: 'legacy_1', + key: { legacy: 1 } + } as const; + defineDeprecatedIndexes(schema, [definition]); + + expect(() => defineDeprecatedIndexes(schema, [definition])).toThrow( + 'Duplicate deprecated MongoDB index declaration: legacy_1' + ); + }); +}); diff --git a/packages/service/test/core/ai/sandbox/infrastructure/instance/schema.test.ts b/packages/service/test/core/ai/sandbox/infrastructure/instance/schema.test.ts index 09f527c3b986..7753c933f885 100644 --- a/packages/service/test/core/ai/sandbox/infrastructure/instance/schema.test.ts +++ b/packages/service/test/core/ai/sandbox/infrastructure/instance/schema.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { MongoSandboxInstance } from '@fastgpt/service/core/ai/sandbox/infrastructure/instance/schema'; +import { getSchemaDeprecatedMongoIndexes } from '@fastgpt/service/common/mongo'; describe('MongoSandboxInstance schema indexes', () => { it('declares provider sandbox uniqueness for remote resource records', () => { @@ -21,6 +22,17 @@ describe('MongoSandboxInstance schema indexes', () => { expect(legacyIndex).toBeUndefined(); }); + it('declares replaced sandbox indexes as deprecated on the same Schema', () => { + const deprecatedIndexes = getSchemaDeprecatedMongoIndexes(MongoSandboxInstance.schema); + + expect(deprecatedIndexes.map((index) => index.indexName)).toEqual([ + 'provider_1_appId_1_userId_1_chatId_1', + 'appId_1_chatId_1', + 'metadata.skillId_1', + 'type_1_chatId_1' + ]); + }); + it('declares source lookup index for migrated sandbox instances', () => { const indexes = MongoSandboxInstance.schema.indexes(); const sourceChatIndex = indexes.find( diff --git a/packages/service/test/core/chat/schema.test.ts b/packages/service/test/core/chat/schema.test.ts index 023c7af40bc3..63528b2c45ee 100644 --- a/packages/service/test/core/chat/schema.test.ts +++ b/packages/service/test/core/chat/schema.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { MongoChat } from '@fastgpt/service/core/chat/chatSchema'; import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema'; import { MongoChatItemResponse } from '@fastgpt/service/core/chat/chatItemResponseSchema'; +import { getSchemaDeprecatedMongoIndexes } from '@fastgpt/service/common/mongo'; const hasIndex = ( indexes: ReturnType, @@ -37,6 +38,16 @@ describe('chat schema indexes', () => { expect(sourceAwareIndex?.[1]?.unique).toBe(true); }); + it('declares the replaced chat identity index as deprecated on the same Schema', () => { + expect(getSchemaDeprecatedMongoIndexes(MongoChat.schema)).toEqual([ + expect.objectContaining({ + indexName: 'appId_1_chatId_1', + key: { appId: 1, chatId: 1 }, + options: { unique: true } + }) + ]); + }); + it('keeps legacy chat item read and pagination indexes', () => { const indexes = MongoChatItem.schema.indexes(); diff --git a/projects/app/test/pages/api/admin/dataClean/cleanupDuplicateChats.test.ts b/projects/app/test/pages/api/admin/dataClean/cleanupDuplicateChats.test.ts index de91f1d8156d..1f0876603571 100644 --- a/projects/app/test/pages/api/admin/dataClean/cleanupDuplicateChats.test.ts +++ b/projects/app/test/pages/api/admin/dataClean/cleanupDuplicateChats.test.ts @@ -37,7 +37,7 @@ const ensureLegacyDuplicateWritableCollection = async () => { }; const restoreChatSchemaIndexes = async () => { - await MongoChat.syncIndexes(); + await MongoChat.createIndexes(); }; const createChatHeader = ({ diff --git a/projects/marketplace/src/env.ts b/projects/marketplace/src/env.ts index df8134f4a763..7e5dd7de789f 100644 --- a/projects/marketplace/src/env.ts +++ b/projects/marketplace/src/env.ts @@ -15,7 +15,6 @@ export const marketplaceEnv = createEnv({ COMMUNITY_AUTH_TOKEN: z.string().optional(), MONGODB_URI: z.string().optional().default(''), DB_MAX_LINK: IntSchema.default(20), - SYNC_INDEX: BoolSchema.default(true), // 对象存储。保持与主项目 packages/service/env.ts 同名,并兼容 marketplace 旧 S3_* 变量。 STORAGE_VENDOR: StorageVendorSchema.default('minio'), diff --git a/projects/marketplace/src/service/mongo/index.ts b/projects/marketplace/src/service/mongo/index.ts index 1eba0ec327b3..492670d084b7 100644 --- a/projects/marketplace/src/service/mongo/index.ts +++ b/projects/marketplace/src/service/mongo/index.ts @@ -2,12 +2,12 @@ import type { Model, Schema } from 'mongoose'; import { Mongoose } from 'mongoose'; import { getLogger, LogCategories } from '../logger'; import { marketplaceEnv } from '../../env'; +import { MongoIndexManager } from '@fastgpt/service/common/mongo/indexManager'; export const MONGO_URL = marketplaceEnv.MONGODB_URI; const maxConnecting = Math.max(30, marketplaceEnv.DB_MAX_LINK); const logger = getLogger(LogCategories.INFRA.MONGO); -const syncIndex = marketplaceEnv.SYNC_INDEX; declare global { var mongodb: Mongoose | undefined; @@ -32,12 +32,21 @@ export const getMongoModel = (name: string, schema: T) => { }; const syncMongoIndex = async (model: Model) => { - if (syncIndex && process.env.NODE_ENV !== 'test') { - try { - model.syncIndexes({ background: true }); - } catch (error: any) { - logger.error('Create index error', { error }); - } + if (process.env.NODE_ENV === 'test' || !MONGO_URL) { + return; + } + + try { + await MongoIndexManager.syncModelIndexes({ + model, + logger + }); + } catch (error) { + logger.error('Failed to ensure MongoDB indexes', { + modelName: model.modelName, + collectionName: model.collection.collectionName, + error + }); } }; @@ -81,6 +90,7 @@ export async function connectMongo(db: Mongoose, url: string): Promise serverSelectionTimeoutMS: 10000, // 服务器选择超时: 10秒,防止副本集故障时长时间阻塞 heartbeatFrequencyMS: 5000 // 5s 进行一次健康检查 }); + return db; } catch (error) { logger.error('Mongo connect error', { error }); diff --git a/projects/marketplace/test/env.test.ts b/projects/marketplace/test/env.test.ts index 96feeb4c8981..de24670dc235 100644 --- a/projects/marketplace/test/env.test.ts +++ b/projects/marketplace/test/env.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -const originalSyncIndex = process.env.SYNC_INDEX; const originalCommunityAuthToken = process.env.COMMUNITY_AUTH_TOKEN; const importEnv = async () => { @@ -10,34 +9,9 @@ const importEnv = async () => { describe('marketplace env', () => { afterEach(() => { - vi.stubEnv('SYNC_INDEX', originalSyncIndex); vi.stubEnv('COMMUNITY_AUTH_TOKEN', originalCommunityAuthToken); }); - it('defaults SYNC_INDEX to true when it is not configured', async () => { - vi.stubEnv('SYNC_INDEX', undefined); - - const { marketplaceEnv } = await importEnv(); - - expect(marketplaceEnv.SYNC_INDEX).toBe(true); - }); - - it('defaults SYNC_INDEX to true when it is empty', async () => { - vi.stubEnv('SYNC_INDEX', ''); - - const { marketplaceEnv } = await importEnv(); - - expect(marketplaceEnv.SYNC_INDEX).toBe(true); - }); - - it('parses explicit false-like SYNC_INDEX values', async () => { - vi.stubEnv('SYNC_INDEX', 'false'); - - const { marketplaceEnv } = await importEnv(); - - expect(marketplaceEnv.SYNC_INDEX).toBe(false); - }); - it('parses optional community auth token', async () => { vi.stubEnv('COMMUNITY_AUTH_TOKEN', 'community-token');