这篇文档用于说明 Locus 在 durable queue 重构之后的完整文件生命周期。
它覆盖以下内容:
- 文件如何进入系统并写入存储
- 文件如何被直接读取或被调度处理
- 文件处理成功、失败、超时后的流转方式
- 文件何时进入删除阶段,以及删除如何完成
queue.log、SQLite、内存缓存、物理文件各自承担什么职责- SQLite 损坏、投影丢失、进程异常中断时系统如何恢复
当前 Locus 可以理解为由四层状态共同组成:
-
物理文件层
- 文件真实内容保存在各个挂载的 storage volume 上。
- 这是文件字节内容的最终载体。
-
durable queue 日志层
- 每个租户有自己的
queue.log。 - 它以追加方式记录队列状态迁移事件。
- 这是队列状态机的持久事实来源。
- 每个租户有自己的
-
SQLite 投影层
metadata.db:保存文件当前投影状态quotas.db:保存租户和逻辑目录的配额投影状态- 它们是本地可查询、可恢复的投影,不再是唯一真相来源。
-
进程内内存层
- 热路径读写、调度、配额判断优先依赖内存缓存和索引。
- SQLite 主要承担持久化投影和重启恢复的职责。
flowchart LR
Client["调用方 / Worker"] --> Pool["StoragePool / IStoragePool"]
Pool --> Tenant["租户校验"]
Pool --> Projection["投影查询"]
Projection --> Cache["进程内 active-data cache"]
Cache -. "首次访问或重启后预热" .-> Sqlite["每租户 SQLite 投影<br/>metadata.db / quotas.db"]
Pool --> Volume["Storage Volume<br/>物理文件内容"]
Pool --> Journal["每租户 queue.log"]
Journal --> Projector["QueueEventProjectionService"]
Projector --> Sqlite
Projector --> Cache
Storage Volume保存文件真实字节内容,是文件内容的最终载体。queue.log保存队列状态迁移事件,是队列状态机的持久事实来源。- SQLite 保存可查询、可重建的本地投影,不负责承载文件二进制内容。
- 进程内内存缓存承担热路径查询与调度加速,当前进程内优先命中它。
当前 queue.log 中会记录这些事件:
AcceptedProcessingStartedProcessingFailedProcessingTimedOutProcessingCompletedDeleteRequestedDeleteSucceeded
这些事件会被 QueueEventProjectionService 持续回放,并投影到 metadata / quota / snapshot。
flowchart TD
A["文件进入系统"] --> B{"入口"}
B -->|"API / SDK"| C["StoragePool.WriteFileAsync"]
B -->|"FileWatcher 导入"| FW["FileWatcher"] --> C
C --> D["校验租户 + 预占租户配额 + 预占目录配额"]
D --> E["选择 volume + 生成 fileKey + 写入物理文件"]
E --> F{"是否启用 queue journal"}
F -->|"是"| G["写入 Accepted 到 queue.log"]
F -->|"否"| H["直接应用 accepted 投影"]
G --> I["写入 metadata 投影"]
H --> I
I --> J["文件状态进入 Pending"]
J --> K["FileScheduler 租约获取待处理文件"]
K --> L["写入 ProcessingStarted"]
L --> M{"处理结果"}
M -->|"失败"| N["写入 ProcessingFailed"]
M -->|"超时"| O["Cleanup 写入 ProcessingTimedOut"]
M -->|"成功"| P["写入 ProcessingCompleted + DeleteRequested"]
P --> Q["后台 cleanup 删除物理文件"]
Q --> R["写入 DeleteSucceeded"]
R --> S["Projector 回放并移除投影 metadata"]
T["按 fileKey 直接读取"] --> U["查询 metadata 投影"]
U --> V["从 volume 读取物理文件"]
文件进入系统主要有两种方式:
- 调用方直接通过
StoragePool.WriteFileAsync(...)写入 FileWatcher扫描到目录中的文件后自动导入,最终也会调用StoragePool.WriteFileAsync(...)
-
校验租户
- 系统先确认租户存在且处于启用状态。
- 禁用租户不会进入后续写入流程。
-
预占配额
- 先预占租户级配额
- 再规范化逻辑目录路径
- 然后预占目录级配额
-
选择存储卷
- 从健康的 volume 中选择一个可写卷
- 生成
fileKey - 如果传入了原始文件名,则保留文件扩展名
-
写入物理文件
- 文件内容首先真正写入 volume 上的物理路径
- 这一步先于 metadata 持久化
-
构造初始 metadata
- 新文件会生成一条
Pending状态的 metadata - 里面包含:
TenantIdFileKeyVolumeIdPhysicalPathDirectoryPathFileSizeCreatedAtRetryCountOriginalFileNameFileExtension
- 新文件会生成一条
-
写入 durable accepted 语义
- 如果启用了 queue journal:
- 会先往
queue.log里追加Accepted
- 会先往
- 如果没有启用 journal 且显式允许 legacy 模式:
- 会直接应用 accepted 配额投影
- 如果启用了 queue journal:
-
写入投影
- metadata 会进入投影存储
- 当前实现是“先更新内存,再异步写 SQLite”
- 物理文件一定先于 metadata 投影写入
- durable journal 模式下,系统会尽量保证“物理文件写入成功”和“Accepted 事件写入成功”同时成立
- 如果物理文件写入成功了,但
Accepted没能完成,系统会尽量删除刚写入的物理文件并回滚预占配额 - 如果 SQLite 暂时不可用,物理文件仍然是安全的,后续可以通过 orphan recovery 补回 metadata
这里说的“读取”,指的是按 fileKey 直接获取文件内容,不是队列式调度消费。
sequenceDiagram
participant Caller as 调用方
participant StoragePool
participant ProjectionStore
participant Cache as 内存 metadata cache
participant SQLite as SQLite 投影
participant Volume as Storage Volume
Caller->>StoragePool: ReadFileAsync(tenant, fileKey)
StoragePool->>ProjectionStore: GetProjectedFileAsync(tenantId, fileKey)
ProjectionStore->>Cache: 查询 metadata
alt 当前租户缓存已预热
Cache-->>ProjectionStore: FileMetadata
else 首次访问租户或进程重启后
Cache->>SQLite: 加载 active projection rows
SQLite-->>Cache: 返回 active metadata
Cache-->>ProjectionStore: FileMetadata
end
ProjectionStore-->>StoragePool: FileMetadata
StoragePool->>Volume: ReadAsync(physicalPath)
Volume-->>StoragePool: Stream
StoragePool-->>Caller: Stream
- 直接读取文件内容时,不会从 SQLite 读取文件字节。
- 读取动作先根据
(tenantId, fileKey)查询 metadata 投影,再按PhysicalPath去 volume 打开物理文件。 - 在当前进程内,metadata 查询优先命中内存缓存。
- SQLite 的职责是投影持久化与重启恢复,而不是文件内容存储。
- 校验租户状态
- 根据
(tenantId, fileKey)查询当前 metadata 投影 - 校验 metadata 所属租户
- 根据
VolumeId找到已挂载的 volume - 使用
volume.ReadAsync(...)打开物理文件流
- 直接读取不会往
queue.log追加事件 - 它依赖 metadata 投影来定位文件的物理路径
- 它本质上是“投影查定位,物理文件读内容”
这条链路用于“消费者线程从池子里取文件进行处理”,不是直接返回文件内容,而是先拿到一个处理租约。
FileScheduler从投影中原子获取一个Pending文件- 该文件被标记为
Processing - 系统追加
ProcessingStarted到queue.log - 返回
FileLocation
FileLocation 中包含:
TenantIdFileKeyVolumeIdPhysicalPathDirectoryPathProcessingStartTimeLease
调用方随后根据这个位置自行读取物理文件内容。
- 调用方处理完文件
- 调用
MarkAsCompletedAsync(lease) - 系统写入:
ProcessingCompletedDeleteRequested
- 文件进入“已完成,等待删除”的阶段
- 真正删除物理文件不在工作线程里进行,而交给后台 cleanup
- 调用
MarkAsFailedAsync(lease, error) - 系统写入
ProcessingFailed RetryCount增加- 如果未超过最大重试次数:
- 状态重新回到
Pending - 设置
AvailableForProcessingAt - 等待延迟后重试
- 状态重新回到
- 如果达到最大重试次数:
- 状态进入
PermanentlyFailed
- 状态进入
- 后台 cleanup 发现某个文件长时间停留在
Processing - 系统写入
ProcessingTimedOut - 文件被重置回
Pending - 这不是普通失败,不会伪造成一次
ProcessingFailed
当前实现中,文件“处理完成”并不等于“立刻从磁盘删除”。
删除是一个独立的、可恢复的阶段。
- 工作线程处理成功
- 写入
ProcessingCompleted - 写入
DeleteRequested - 后台 cleanup 在后续周期中选中这批可删除文件
- 实际删除物理文件
- 删除成功后写入
DeleteSucceeded - projector 回放
DeleteSucceeded - 回放逻辑完成:
- 扣减目录配额
- 扣减租户配额
- 移除 metadata 投影
这种两阶段删除设计有几个好处:
- 不把物理删除放在处理热路径中
- 删除过程具备 durable event 记录
- 删除失败或中断时,可以继续恢复和补偿
对于已经进入 PermanentlyFailed 的文件,后台 cleanup 也会在保留期之后进行最终清理。
在 journal 模式下,这条链路也会走 durable delete 流程。
- cleanup 选出达到保留时间的
PermanentlyFailed文件 - 删除物理文件
- 写入:
DeleteRequestedDeleteSucceeded
- 通过投影侧移除 metadata,并更新 quota
也就是说,永久失败文件的最终删除,不再是一个完全绕开队列语义的特殊路径。
QueueEventProjectionService 是 durable queue 架构里非常关键的一层。
- 从
queue.log读取每个租户的事件批次 - 逐条回放到 metadata 投影
- 根据 accepted / delete 事件更新 quota 投影
- 保存 projector cursor
- 自动保存 projection snapshot
- 在 projector 追平日志尾部且达到阈值时执行 journal compaction
queue.log 并不是只在写入时记一笔,而是会持续驱动整个队列状态机的恢复和投影构建。
在当前默认配置下,compaction 处于开启状态,因此日志不会无限增长;当某个租户的
projector 已经追到日志尾部,且已处理字节数达到 MinBytesBeforeCompaction 阈值后,
前面已经完成投影的日志段会被裁剪。
SQLite 现在依然非常重要,但它的角色已经从“唯一真相数据库”转成了“核心投影数据库”。
metadata.db 负责保存文件当前投影状态,例如:
PendingProcessingCompletedDeleteRequestedDeleteSucceededPermanentlyFailed
它用于:
- direct read 时定位
PhysicalPath - scheduler 取下一个待处理文件
- 状态迁移更新
- cleanup 查询
- 启动恢复和重建
quotas.db 保存:
- 逻辑目录级配额当前计数
- 目录级限制
- 租户级配额当前计数
- 全局 quota 配置
SQLite 的作用依然很大,但它不再是系统中唯一不可替代的真相来源。
可以这样理解:
- 文件内容真相:物理文件
- 队列状态真相:
queue.log - 当前查询和调度视图:SQLite 投影
因为 Locus 当前设计把“内容”和“状态迁移”分开保存了:
- 内容存在 volume 上
- 状态迁移存在
queue.log - 当前可查询形态存在 SQLite 投影
所以只要物理文件和 durable queue 还在,投影层就不是不可恢复的。
所谓 orphan file,指的是:
- 物理文件真实存在
- 但是 metadata 投影丢失了
这种情况常见于:
- 物理文件写完后,进程在 metadata 异步落盘前崩溃
- SQLite 暂时不可写
- metadata write-behind 队列尚未 flush 就退出
OrphanFileRecoveryService 会周期性执行恢复。
它内部调用 StorageCleanupService.RecoverAllOrphanedFilesAsync(...),流程如下:
- 按租户、按 volume 扫描物理文件
- 检查该物理路径是否已经在 metadata 投影中存在
- 如果不存在,则根据路径重建 metadata
- 重新应用 accepted 配额投影
- 把文件重新以
Pending状态放回队列
- orphan file 不会被直接删除
- 它会被恢复成一个可重新处理的正常文件
和 orphan file 相反,还有另一种不一致:
- metadata 还在
- 物理文件已经没了
这种 stale metadata 会由后台 cleanup 清理掉。
BackgroundCleanupService调用 scheduler 的 orphaned metadata cleanup- 系统确认物理文件确实不存在
- 移除 metadata 投影
- 扣减对应的 tenant / directory quota
这条路径用于补齐“物理文件已经消失,但投影还没清掉”的异常状态。
数据库损坏恢复由两个服务协同完成:
DatabaseHealthCheckServiceDatabaseRecoveryService
应用启动时,DatabaseHealthCheckService 会:
- 等待其他服务初始化完成
- 检查所有租户的
metadata.db和quotas.db - 识别损坏数据库
- 在配置允许时自动触发恢复
当某个租户的 metadata.db 损坏时,恢复顺序如下:
- 获取该租户的独占 rebuild 锁
- 备份损坏数据库文件
- 优先尝试从 snapshot +
queue.log回放恢复 metadata - 如果 queue-based recovery 不足以恢复:
- 回退到扫描物理文件
- 重新构造 metadata
- 状态统一按
Pending恢复
如果 durable queue 是完整的,那么 metadata 投影可以不依赖旧 SQLite,直接从日志重建。
当某个租户的 quotas.db 损坏时,恢复顺序如下:
- 获取该租户的独占 rebuild 锁
- 备份损坏数据库文件
- 优先从当前 metadata 投影聚合出目录和租户计数
- 如果 metadata 为空且 queue 可用:
- 先尝试通过 queue 恢复 metadata
- 如果 metadata 仍然不可用:
- 回退到扫描物理目录统计文件数量
- 如需修复 quota 漂移,可在维护窗口显式执行 quota reconciliation
flowchart TD
A["应用启动"] --> B["DatabaseHealthCheckService"]
B --> C{"数据库是否健康"}
C -->|"是"| D["正常启动"]
C -->|"否"| E["DatabaseRecoveryService"]
E --> F["重建 metadata.db"]
F --> G["优先使用 snapshot + queue.log 回放"]
G --> H{"恢复结果是否足够"}
H -->|"是"| I["metadata 投影恢复完成"]
H -->|"否"| J["回退为物理文件扫描恢复"]
I --> K["重建 quotas.db"]
J --> K
K --> L["优先从 metadata 投影聚合 quota"]
L --> M{"metadata 是否不足"}
M -->|"是"| N["回退为物理目录扫描统计"]
M -->|"否"| O["写回 quota 投影"]
N --> O
O --> P["按需执行手动 quota reconciliation"]
这和“SQLite 损坏”不是一回事。
这种场景通常是:
- 物理文件已经写成功
- 但 metadata 还没完全 flush 到 SQLite
- 进程突然退出
这时数据库文件本身不一定坏,但投影可能不完整。
系统通过两条补偿链路处理:
-
orphan file recovery
- 修复“磁盘上有文件,但 metadata 没了”
-
orphaned metadata cleanup
- 修复“metadata 还在,但磁盘文件没了”
也就是说,Locus 当前不只考虑“数据库损坏怎么恢复”,还考虑“投影不一致怎么自动收敛”。
- 回放
queue.log - 更新 metadata / quota 投影
- 维护 cursor
- 保存 snapshot
- 可选压缩 journal
- 清理已完成文件
- 回收处理超时文件
- 清理永久失败文件
- 清理失效 metadata
- 删除损坏数据库遗留备份文件
- 优化 SQLite 文件空间
- 扫描没有 metadata 的物理文件
- 重建 metadata
- 把文件放回
Pending
- 启动时检查 SQLite 是否损坏
- 必要时自动触发重建
理解当前 Locus 架构时,最推荐用下面这套心智模型:
- 物理文件层负责保存真实内容
queue.log负责保存队列状态迁移事实- SQLite 负责保存当前可查询、可调度、可恢复的投影
- 内存层负责让热路径足够快
换句话说:
- 内容真相在 volume
- 队列真相在
queue.log - 当前运行视图在 projection
- direct read 依赖 metadata 投影来定位物理文件
- 处理成功后并不会立刻删除文件,而是进入独立的 durable delete 阶段
- 进程崩溃可能导致内存投影丢失,但 orphan recovery 和 queue replay 会帮助系统收敛
- SQLite 仍然非常重要,只是它的定位已经从“唯一真相”转成了“核心投影层”