hyperdir 0.7.0

A container for inodes implemented as a Hyperfile
Documentation
# Second-level inline(blob 档)—— hyperdir 设计

本文件记录"第二级 inline"的设计决策。它是 hyperdir 内自足的特性,**不需要 hyperfile
任何改动**。配套的 hyperfs 侧策略见 `../hyperfs-second-level-inline-requirement.md`。

## 1. 动机与三档模型

文件按大小/写形态落在三档,**只升不降(one-way promotion)**:

```
inode-inline (≤80B)   →   blob (80B < size ≤ 阈值)   →   segmented hyperfile (> 阈值 / 随机写 / 硬链接)
  已实现(FLAG_INLINE)         本文件新增                    已存在(FILE/<uuid> 完整 hyperfile)
```

- **inode-inline**:`fs_create_inline` / `fs_update_inline` 已把 ≤80B 的文件整体塞进 dirent 里那份
  `InodeRaw` 的 inline tail(hyperfile 的 `FLAG_INLINE`),不建 `FILE/<uuid>`。
- **blob**(本文件):80B < size ≤ 每目录阈值(全局默认 128 KiB)。不建完整 hyperfile,只写一个
  **裸字节对象**;dirent 携带权威 inode + 版本指针。
- **segmented**:超过阈值 / 判定为随机写 / 被硬链接时,spill 成同 uuid 上的完整 hyperfile。

缩小不回退(truncate 变小仍保持当前档),以降低复杂度。

### 为什么不"把数据块写进目录 hyperfile 的 segment 数据区"

目录就是一个 hyperfile,其 B-tree 把 `crc64(name) -> DirFileEntryRaw` 建索引。btree-ondisk 是
**定长 `V: Copy`、单棵 B-tree**:字节既进不了 B-tree value(撑大 `V` 会拖垮所有 entry 的 fanout),
目录 hyperfile 也没有第二条数据平面挂裸字节。要"字节进目录文件体"必须改 hyperfile(混合
索引+数据的文件),超出 hyperdir 范围。**结论:hyperdir-only 下字节必在一个独立对象里;能省的只是
把它从"完整 hyperfile"降级成"裸 blob"。**

### 为什么 blob 放在 `FILE/<uuid>/` 而不是 `DIR/<dir-uuid>/_inline/`

选 `FILE/<uuid>/` 白赚三样:
- **rename 零数据搬运**:key 与父目录无关,跨目录 rename 只挪 entry,blob 不动。
- **`_lock` / `_xattr/<name>` sidecar 原地可用**(它们本就挂在 `<FILE|DIR>/<uuid>/` 下)。
- spill 时同 uuid 就地长出 hyperfile。

`DIR/.../_inline` 相对之毫无收益,还多赔一次 rename 时的 S3 copy,故弃用。

## 2. 判据(标志放进 hyperdir 自己的 entry)

**决策:不使用 hyperfile 的 `i_flags`**。在 `DirFileEntryRaw` 增一个 **hyperdir 自有字段
`blob_ulid: [u8; 16]`**,它同时是"是不是 blob"的判据**和**当前版本指针:

| 档位 | 判据 | 字节位置 |
|---|---|---|
| inode-inline | `inode.is_inline()`(`FLAG_INLINE`) | dirent 里 `InodeRaw` 的 inline tail |
| **blob** | `blob_ulid != 0` | `FILE/<uuid>/<blob_ulid>.blob` |
| segmented | 否则 | `FILE/<uuid>/` 完整 hyperfile |

blob 的那份 `InodeRaw` 是普通非-inline inode(`i_size` = 字节长度,不设 `FLAG_INLINE`)。blob 存活期间
**没有任何 hyperfile 在管这个 uuid**,该 `InodeRaw` 由 hyperdir 独占;hyperfile 只在 spill 后才接管。
→ **hyperfile 改动 = 0**。

代价:`DirFileEntryRaw` +16B(~432 → ~448),B-tree fanout 影响可忽略;属 **on-disk 格式变更**
(PoC 可接受)。`DirFileEntryRaw::{from, from_slice, as_*_slice}` 与 `DirFileEntry::from_raw` 需带上
新字段。

## 3. blob 用 ULID 版本化

blob 接入 hyperdir 既有的 **ULID-事件模型**(与 scatter 同构):

- **每次写 = 一个新版本对象** `FILE/<uuid>/<ulid>.blob`,`If-None-Match: *`(write-once,不覆盖旧版本)。
- **`blob_ulid` 随 Create/Update scatter 的 body 携带**(实现选择,低 blast radius):blob 的 scatter
  沿用现有 `Create`/`Update` op,body = `InodeRaw ++ 16 字节小端 ULID`;`entry_from_scatter_body`
  (file.rs)在 `resolve`/`read_dir`/`compaction` 三处统一把尾部 ULID 提升进 `DirFileEntryRaw.blob_ulid`。
  普通(非 blob)scatter 的 body 恰为 `InodeRaw`,多出的 16 字节因此无歧义。**未新增 `DirScatterInodeOp`
  变体、未改 `emit_scatter_event` 或 live/dead 分类**。blob 对象自己的 ULID 与 scatter 事件排序用的
  ULID 相互独立(各取所需)。
- **提交顺序**:先 `PUT <ulid>.blob`(数据先落),再 emit scatter(命名空间提交点)。崩在中间只留一个
  孤儿 blob,由 GC 回收。
- **并发**:两个并发写 → 两个 ULID / 两个 blob / 两条 scatter;compaction 按 ULID 取最新
  (`filter_last_view` 现成),entry 指向最新版本,旧版本作为历史保留。**不需要 OCC / If-Match**。
- **读**:`entry.blob_ulid` 直接给出当前版本 → 一次 `GET FILE/<uuid>/<blob_ulid>.blob`,**无需 LIST**。
  `read_dir` 只看到 entry(不含字节)→ **readdir 保持廉价**,字节只在 open 时取。

### 布局 / 类型改动(实际落地)

- `DirFileEntryRaw` += `blob_ulid: [u8; 16]`(在 `uuid` 之后);`is_blob()`/`blob_ulid()`/`set_blob_ulid()`。
- `DirFileEntry` += `blob_ulid: Option<Ulid>`(`from_raw` 从 `raw.blob_ulid()` 填)。
- `HyperDirLayout::{blob_key, blob_uri, blob_prefix}` = `FILE/<uuid>/<ulid>.blob`。
- `classify_key` 识别 `FILE/<uuid>/<ulid>.blob` → 新 `KeyKind::InlineBlob(Uuid)`,供 keeper / GC 路由。

## 4. spill(blob → hyperfile),崩溃安全 + 幂等

触发点:写/truncate 越过阈值、被 `fs_link`、或 hyperfs 判定为随机写(见需求文档)。复用现有
`fs_create_file_at`(同 uuid 建 hyperfile)。步骤:

1. `GET` 当前 `<blob_ulid>.blob` 拿字节。
2. `fs_create_file_at(.., uuid, ..)` 在同 uuid 上建 hyperfile,写入字节,`fs_flush`。**这次 flush 发出的
   scatter(body = 真实 segmented `InodeRaw`,`blob_ulid` 归 0)就是 spill 的提交点。**
3. 删除全部 `FILE/<uuid>/*.blob` 版本对象。

**提交点 = 那条把 entry 翻回 `blob_ulid=0` 的 scatter**(单对象条件写为承诺点,后续清理靠幂等前向恢复,
与 two-phase delete 同套路)。崩溃窗口:

| 崩溃时机 | entry 判据 | 权威数据 | 垃圾(GC 按判据收) |
|---|---|---|---|
| flush-scatter 提交前 | 仍 `blob_ulid != 0` | `<blob_ulid>.blob` | 半成品 `inode`/`segment`(重跑 spill 覆盖) |
| flush-scatter 提交后、删 blob 前 | `blob_ulid == 0` | hyperfile | 残留 `*.blob`(GC 删) |

**不需要 `_TXN` intent**:谁被 entry 判据认,谁就是权威;另一件是可回收垃圾;重跑 spill 幂等(同 uuid、
同字节、条件写)。

## 5. 阈值配置(每目录 + 全局默认)

- 全局默认:常量 `DEFAULT_INLINE_MAX = 128 * 1024`(或 `HyperDir` opts 字段)。
- 每目录阈值:存 xattr sidecar `DIR/<uuid>/_xattr/<b64url("hyperdir.inline_max")>`(xattr 现状 = 每属性
  一个对象,随 prefix 回收)。**开目录句柄时读一次并缓存**,缺省回落全局值。hyperdir 暴露
  `dir_inline_max(dir) -> usize` 供 hyperfs 查。

## 6. 删除 / retention / undelete / snapshot

ULID 自带毫秒时间戳,旧版本"是否到期"= `ULID_time + retention`,**无需 GET 对象**:

- **unlink**:phase-1 `PreDelete` 不变。blob 无 nlink 可减(恒 1;需 >1 早已 spill)。
- **phase-2 / tombstone**:仍写 tombstone(存完整 `InodeRaw`);**不立即删 blob 版本**,留到 retention。
- **版本 GC**(`fs_gc` 内或独立 pass):保留 `entry.blob_ulid` 指的当前版本;回收**比它旧且过 retention**
  的 `*.blob`。文件被删后,tombstone 到期时连同全部版本清除(期内可 **undelete**:重指 entry 即可)。
- **snapshot**:pin 某个 ULID 版本使其不被版本 GC(与未来 snapshot 能力对接)。

## 7. hard link / rename

- **hard link**:blob 没有独立可变 inode 做共享 nlink 权威 → `fs_link` 命中 blob **先 spill 成 hyperfile**
  再 link(稳定 uuid)。与 inode-inline 同规则。
- **cross-dir rename**:blob 在 `FILE/<uuid>` → 只挪 entry,**零数据搬运**,`fs_rename_across` 基本不改。

## 8. GC / orphan(新增对账规则,均 uuid 局部)

- 无 entry 引用的 `FILE/<uuid>/*.blob` → 回收(靠 `KeyKind::InlineBlob`)。
- **spill 残留**:按引用 entry 的判据清——`blob_ulid==0` 却有 `*.blob` → 删 blob;`blob_ulid!=0` 却有
  `inode`/`segment` → 删那些。
- 净收益:blob 无"无名 FILE hyperfile 孤儿"那类复杂度;对账只需 `LIST FILE/<uuid>/`。

## 9. hyperdir 新增 API 面(供 hyperfs 消费;keeper 不受影响)

- `layout.blob_key(uuid, ulid)` / `blob_uri` / `blob_prefix`。
- `fs_create_blob(client, layout, bucket, parent, name, inode_raw: &InodeRaw, bytes) -> (Uuid, Ulid)`:
  分配 uuid + ulid;`PUT <ulid>.blob`;emit `Create` scatter(body = `InodeRaw ++ 16B ULID`,`InodeRaw`
  须 `i_size == bytes.len()` 且非 `FLAG_INLINE`)。
- `fs_write_blob(client, layout, bucket, parent, name, uuid, inode_raw: &InodeRaw, bytes) -> Ulid`:
  新 ulid;`PUT <ulid>.blob`;emit `Update` scatter。返回新版本 ulid。
- `fs_read_blob(client, layout, bucket, uuid, blob_ulid) -> Vec<u8>`(或 hyperfs 用 `blob_key` 直接 GET)。
- `fs_spill_blob(client, layout, bucket, parent, name, uuid, blob_ulid, flags, mode) -> HyperFileHandle`:见 §4。
- `dir_inline_max(dir) -> usize` / `set_dir_inline_max(dir, max)`(xattr;常量 `DEFAULT_INLINE_MAX` /
  `INLINE_MAX_XATTR`)。
- `fs_gc_blob_versions(uuid, keep, retention)`(旧版本回收)/ `delete_all_blob_versions(uuid)`(spill 后清)。
- `classify_key` → `KeyKind::InlineBlob`;`fs_gc_orphans_from` 已 blob-aware(§6、§8)。

## 10. 与现有模型的一致性 / 影响小结

- 写并发、读、恢复全部沿用 scatter/ULID/compaction/lease 既有机制,无新并发原语。
- entry 里 inline/blob 的 `InodeRaw` 对这两档是**权威而非 advisory**(无独立可变 inode 竞争),消除了
  design §6 的 cached-vs-authoritative 分裂(仅 segmented 仍适用 §6)。
- **hyperfile 改动 = 0**。
- on-disk 变更:`DirFileEntryRaw` +`blob_ulid`;新 op / 新 KeyKind / 新 blob 对象命名。记进 design.md
  §13(格式约束)与 §8/§12(GC/限制)。

## 11. 落地任务(hyperdir 侧)

已实现(lib 编译 + `clippy -D warnings` 通过 + 单测通过):
1. ✅ `DirFileEntryRaw.blob_ulid` 字段 + 序列化/`from_raw`/`DirFileEntry` 分档。
2. ✅ blob scatter body 携带 ULID(`Create`/`Update` 复用,`entry_from_scatter_body` 统一提升;未新增 op)。
3. ✅ `layout.blob_key`/`blob_prefix` + `classify_key` 的 `InlineBlob`(含单测)。
4. ✅ `fs_create_blob` / `fs_write_blob` / `fs_read_blob` / `fs_spill_blob`。
5. ✅ 每目录阈值(`INLINE_MAX_XATTR` + `dir_inline_max`/`set_dir_inline_max`) + `DEFAULT_INLINE_MAX`。
6. ✅ 版本 GC(`fs_gc_blob_versions`)+ blob-aware orphan 回收(`fs_gc_orphans_from` 在无 inode 时以最新
   blob ULID 时间戳作 grace 门;`reclaim_child_prefix` 经 hyperfile `unlink()` 整前缀删除,天然清 `*.blob`
   与 spill 残留)。
7. ✅ 单测:blob scatter body ↔ entry roundtrip(`file::blob_tests`)。

待做:
- (无阻塞项)

已闭环:
- **e2e**(`tests/e2e_blob.rs`,`#[ignore]`,真实 S3 已跑通):create→resolve(一次 GET)→compact 保 `blob_ulid`
  →写新版本→版本 GC(留 current 删旧)→spill 成 segmented(清 `*.blob`、留 inode)→**stray blob 回收**
  →删除后 orphan 扫描回收。
- **spill-stray 缺口**:被引用的 segmented 文件下残留 `*.blob` 由 `fs_gc_orphans` 的 reconcile
  (`fs_reconcile_blobs`)清除;live blob 的旧版本由 `fs_gc_blob_versions`(retention 门)清除。
- **附带修复**:blob-only 前缀(无 inode)的孤儿回收——`reclaim_child_prefix` 经 hyperfile `unlink()` 在
  无 inode 时报 `NotFound` 而被当作"已回收"短路、不删任何对象;改为无 inode 分支走 `fs_reconcile_blobs`
  直接删 `*.blob`(e2e 已验证)。
- `docs/design.md` §8/§13 已增补(blob 回收、entry ~432→~448B)。