# 数据保留与归档策略 Orbit 将保留策略作为命名空间级数据库配置保存。控制台保持单管理员模型,登录后打开“保留策略”即可查看和修改窗口;每次修改都要求上一版本号、幂等键和变更原因,前后值写入 `orbit_audit_events`。多个 Orbit 节点读取同一个 MySQL 版本,因此不会因为节点环境变量不同而产生不同清理边界。 首次安装和数据库策略尚未保存时使用部署默认值:幂等记录 90 天、任务历史 365 天、流程历史 365 天、事件日志 365 天、已确认交付 90 天、已处理 Inbox 90 天、管理审计 2555 天。默认 `enabled=false`,保存策略也不会立即删除数据。 环境变量只负责首次展示/未保存策略的默认值: ```text ORBIT_RETENTION_IDEMPOTENCY_DAYS=90 ORBIT_RETENTION_TASK_DAYS=365 ORBIT_RETENTION_WORKFLOW_DAYS=365 ORBIT_RETENTION_EVENT_DAYS=365 ORBIT_RETENTION_DELIVERY_DAYS=90 ORBIT_RETENTION_INBOX_DAYS=90 ORBIT_RETENTION_AUDIT_DAYS=2555 ``` 窗口范围为 1–3650 天,幂等记录不得短于 90 天;所有节点应使用同一组默认值。数据库中已有策略后,环境变量不覆盖它。管理端的“允许导出只读归档”控制导出权限,导出仍须核对固定策略版本。 归档分两步:先用“预览”或 `GET /console/retention/preview` 查看各类候选,再按固定类别调用 `POST /console/retention/export` 分批生成只读 JSON 快照。每个快照包含冻结的策略版本、数据库时间、截止时间、根记录及关联历史,并返回 SHA-256;源表仍然保留,导出成功只记录摘要和数量审计,不代表可以删除。后续删除执行器必须在独立发布和备份恢复验收后接入,不能把导出接口当成删除接口。 候选筛选由服务端固定目录实现,浏览器不能提交表名或 SQL。终态任务必须没有未知效果、租约和流程所有权;流程必须终态且没有未结任务或未处理 Inbox;事件必须没有未确认交付或未处理 Inbox;交付只允许已确认记录;Inbox 只允许已处理且流程已终态记录;幂等记录需同时超过过期时间和窗口。审计单独使用审计窗口,普通清理不处理审计记录。未确认交付、未处理 Inbox、未知效果、进行中的任务/流程和有效幂等身份始终受保护。 控制台接口示例: ```sh # 已登录控制台会话;浏览器通常直接使用页面,不需要手工传令牌 curl -b orbit-console.cookie http://127.0.0.1:8080/console/retention curl -b orbit-console.cookie -X PUT http://127.0.0.1:8080/console/retention \ -H 'Content-Type: application/json' -H 'Idempotency-Key: retention-2026-q3' \ --data '{"expected_revision":"0","enabled":false,"idempotency_days":90,"task_history_days":365,"workflow_history_days":365,"event_log_days":365,"delivery_history_days":90,"inbox_history_days":90,"audit_log_days":2555,"reason":"初始生产保留评审"}' ``` ## 管理页操作 1. 登录后打开 `/console/#/retention`,调整窗口、填写单行原因后保存;原因最多 1024 UTF-8 字节。幂等记录最少 90 天。 2. 点击“预览候选”:每类最多展示 100 条,`100+` 表示还有更多,并非全库总数。修改未保存时不能预览或导出,避免使用错误版本。 3. 需要导出时开启“允许导出只读归档”并保存,选择历史类型,点击“导出首批”。每批最多 20 条根记录,关联历史一并导出。浏览器需要 HTTPS 或 localhost 安全上下文来执行 SHA-256 校验。 4. 页面显示本批、累计条数和完整摘要;有后续记录时显示“导出下一批”,到末批后停止。可“重新下载本批”,切换历史类型或保存新策略会重置进度。“从首批开始”重新扫描,不会清理已下载文件。 下载文件保存 `format_version`、`sha256`、原始字符串 `content` 三个字段。页面关闭或刷新后内存中的分页进度会丢失;文件中的 `content.next_cursor` 可用于接口继续导出。服务端提交后网络中断可能多记录一次导出审计,但不改变任何源业务记录;页面未成功校验时不推进游标。修改策略的网络重试在当前页面复用原幂等键。 ## 导出接口与分页 这些接口仅允许已登录控制台会话;业务 Bearer 凭据不能代替管理员会话。 | 接口 | 请求/结果 | | --- | --- | | `GET /console/retention` | `data` 为当前策略,`revision` 是字符串;未保存版本为 `"0"` | | `PUT /console/retention` | 必填全部窗口、`enabled`、字符串 `expected_revision`、单行 `reason`,请求头必填 `Idempotency-Key`;幂等返回同一版本 | | `GET /console/retention/preview` | `data.policy` 为所用策略,`sample_limit=100`;各类别包含 `kind/cutoff/count/more` | | `POST /console/retention/export` | `kind`、字符串 `expected_revision` 必填;`limit` 默认 20、最大 100;`cursor` 默认空 | 响应采用标准 `{"data": ...}` 信封。导出请求示例: ```sh curl -b orbit-console.cookie http://127.0.0.1:8080/console/retention/export \ -H 'Content-Type: application/json' \ --data '{"kind":"tasks","expected_revision":"1","limit":20}' \ --output export-response.json ``` `data.content` 是原始 JSON 文本,解析后包括: | 字段 | 含义 | | --- | --- | | `policy` / `kind` | 本批实际策略与根记录类别 | | `snapshot_at` / `cutoff` | 数据库读取时间与严格早于的候选截止时间,UTC | | `source_retained` | 始终为 `true`,源数据保留 | | `root_count` | 本批根记录数量,不含关联历史计数 | | `tables` | 固定目录下的表与行;二进制为十六进制,整数和 JSON 正文按文本保留精度 | | `next_cursor` | 非空表示可以继续;下一次请求原样传入相同 `kind` 和 `expected_revision` | 游标绑定命名空间、历史类型、策略版本与截止时间;不接受客户端表名或 SQL。策略变化后必须重新核对并从首批开始。每批使用独立数据库一致性快照,多个批次不是同一个全库时间点;期间从受保护状态转为候选的记录可能需要重新扫描。归档不是灾难恢复备份,不能直接把这些 JSON 导入生产库。 每个导出事务最多 10 秒、10000 条关联记录、8 MiB 正文;超限整体失败,不返回截断快照。减少根记录 `limit` 后可重新尝试;单个根记录本身过大时需要另行处理,不能忽略其历史。每次成功导出记录类别、策略版本、数量和摘要审计。 | 情况 | 处理 | | --- | --- | | 未登录/会话已失效 | 401,重新登录 | | 归档关闭或策略版本不匹配 | 409,读取策略并重新核对 | | 不支持的类别、游标不匹配、数量越界、快照超限 | 400,修正参数;超限时减小批量 | | 修改策略结果不明 | 使用完全相同的内容和 `Idempotency-Key` 重试 | 离线核验及生成下一批请求(兼容控制台下载信封和上述 curl 响应): ```python import hashlib import json from pathlib import Path # 控制台下载文件也可替换这里的文件路径。 response = json.loads(Path("export-response.json").read_text(encoding="utf-8")) snapshot = response.get("data", response) assert snapshot["format_version"] == 1 content = snapshot["content"].encode("utf-8") assert hashlib.sha256(content).hexdigest() == snapshot["sha256"], "快照校验失败" page = json.loads(content) assert page["source_retained"] is True print("本批根记录:", page["root_count"]) if page["next_cursor"]: Path("next-request.json").write_text(json.dumps({ "kind": page["kind"], "expected_revision": page["policy"]["revision"], "limit": 20, "cursor": page["next_cursor"], }), encoding="utf-8") # 下一批:curl ... --data-binary @next-request.json --output export-response-2.json ``` 摘要针对 `content` 的原始 UTF-8 字节,不对解析后重新格式化的 JSON 计算。SHA-256 用于发现内容损坏;摘要与正文一起被篡改时无法证明真实性,需要把审计摘要或外部签名另行保存在可信介质中。 ## 自动分批与持久归档 需要在页面之外连续处理并断点续跑时,使用 [`orbit-admin retention archive`](retention-jobs.md)。目录、批量、总字节及执行时限可配置,已发布文件就是恢复检查点,支持离线核验;源业务数据仍保留。周期触发和清理执行器尚未接入。 ## 只读盘点 `orbit-admin retention inventory` 用于在制定保留窗口、归档和备份恢复方案前盘点历史数据。它只执行带截止时间的统计查询,不删除、搬迁、改写数据,也不会自动判断某条记录可以安全归档。 ```sh ORBIT_MYSQL_DSN='user:password@tcp(127.0.0.1:3306)/orbit' \ go run ./cmd/orbit-admin retention inventory \ --before 2026-09-18T00:00:00Z ``` 示例输出: ```json {"cutoff":"2026-09-18T00:00:00Z","expired_idempotency":0,"terminal_tasks":0,"uncertain_tasks":0,"active_tasks":0,"terminal_workflows":0,"active_workflows":0,"acknowledged_deliveries":0,"unconfirmed_deliveries":0,"processed_inbox":0,"unprocessed_inbox":0,"events":0,"audit_events":0} ``` 所有时间按 UTC 解析,严格早于 `--before` 的记录才计入。截止时间不能晚于数据库当前时间,避免把未来记录误认为历史。`terminal_tasks` 只统计确定终态且 `effect_uncertain=false` 的任务;`uncertain_tasks`、`active_tasks`、`active_workflows`、`unconfirmed_deliveries` 和 `unprocessed_inbox` 是需要先处理或保留的阻塞项。 结果是盘点快照,不是归档授权。真正的清理或归档还必须完成保留周期评审、未确认副作用核对、事件投递和流程推进核对、备份恢复演练、权限隔离及审计;当前命令没有 `apply` 或删除路径。