Documentation
¶
Overview ¶
媒体类型 → 落盘扩展名的**单源**推导。
## 为什么内核要管这件事
Ext 由调用方逐条给,若不给推导,N 个宿主就会写 N 套映射,彼此不一致。 而落盘键是内容寻址的(见 StorePath):扩展名一变,同一份内容的键就变, 占位文本逐字节稳定性与去重**同时失效**(见 placeholder.go 文件注释)。 换句话说,这不是风格问题,是键一致性问题,故必须单源。
## 为什么不能只靠 mime.ExtensionsByType
两层不可移植性,每层都足以让同一份内容在不同机器上落进不同的键:
- 表内容依平台而变。Go 会加载宿主机的 mime.types(macOS 的 /etc/apache2/mime.types、多数发行版的 /etc/mime.types),同一类型在 A 机器注册 .jpg/.jpe/.jpeg、在 B 机器只有 .jpeg;
- 候选顺序依实现而变。平台表对同一类型可能返回多个扩展名,未显式定序时 「取哪个」在不同实现/版本间会漂。
故口径固定为三段:**剥参数 → 查显式覆盖表 → 平台表兜底**,兜底结果排序取首个, 使同一进程内反复调用恒等。常见类型由覆盖表兜住,第 1、2 条都碰不到它们。
## 未知类型为什么给中性值而不是报错
扩展名在本包只用于拼逻辑键,取值域是宿主的落盘实现,不参与内容语义判定。 让每个宿主自己判一次「这算不算错误」,只会得到 N 种失败方式;而报错会连带 丢掉「阈值已过、本可省上下文」的外置收益,中性取值则至少键稳定、行为可预期。
Package offload 内联大对象外置:把躺在消息历史里的二进制本体换成占位文本。
## 为什么需要这个包
大对象(base64 图片/音频/归档)随消息进历史后,会在**每一轮**被原样重发。 代价与对象大小成正比、与轮数相乘,而模型在该对象上真正需要的信息量通常只有 一次——第一轮看完之后,剩下的 N-1 份纯属浪费。本包把本体外置为内容寻址的落盘对象, 历史里只留一段占位文本,模型需要时按路径取回。
与 spill 是**姊妹机制而非重复**:spill 管「输出超预算被截断」(文本尾部溢出), offload 管「二进制本体躺在消息里」(输入侧上下文计量)。二者判定时机、 触发条件、替换语义都不同,故不合并。
## 关键取舍一:阈值——小对象外置必亏
占位文本本身有体量(缺省形态 191 字节,见 placeholder.go)。对一个小对象, 「写占位 + 多一次取回往返」的总成本**高于**直接内联,外置反而是负优化。 故低于 MinBytes 的对象一律原样保留;阈值取 4 KiB 远高于占位体量, 保证跨过阈值后收益一定为正。
## 关键取舍二:两阶段——首次绝不替换(正确性核心)
首次见到某对象时:落盘 + 标记,**但内容原地保留**。只有后续轮才换成占位文本。
理由只有一个,但足够致命:若首次就替换,模型这一轮**从未真正看到过该对象**, 后续轮的占位文本又把它指向一个「模型没看过的东西」——等于凭空丢失输入。 两阶段让「至少完整出现过一次」成为硬保证,压缩只发生在安全的那一侧。
连带一条:首次落盘失败时**不得标记**。否则下一轮会放出一个指向从未写入的路径的 占位文本(悬空引用),比不外置更糟。
连带二条:**标记按轮提交,不按条目提交**。「首次」的判据只能是「进入本轮时 已可见的标记」。若在轮内循环里就写 State,同一份内容出现两次时,第二份会被 自己刚写的标记判成「后续轮」而当场替换——那一轮模型从未完整看到过其中任何一份, 恰恰是上面那条保证要禁止的动作。参考实现逐块独立处理(标记挂在块自身上), 同一消息里两份相同图片都保持内联,本包与之对齐。
## 关键取舍三:状态归调用方
「已见过哪些摘要」必须跨轮存活,但**不属于本包**:它随会话存储、随宿主重启策略 变化。本包除调用方传入的 *State 与 Store 外无任何状态,纯函数式, 故可被任意编排层并发复用而不串扰。
## 落盘面是接缝不是实现
内核不碰存储布局(与 checkpoint.Store、contract 同一纪律):Store 由宿主实现, 本包只推导一个**逻辑键** StorePath 并交给它落盘。宿主须保证在 State 存活期内 该键可取回——本包不负责保留期与清理(spill 侧有 Prune,offload 侧的回收由宿主决定)。
占位文本契约:外置对象在消息历史里留下的那一小段替身文本。
## 为什么要有格式与解析两件套
占位文本是**跨轮复用的字符串**:内容寻址保证同一份内容逐轮得到逐字节相同的 占位文本,历史前缀因此能命中提示缓存——这正是外置收益的一半(另一半是不重发本体)。 一旦它可被机械解析,宿主才能在不重新计算摘要的前提下回答「这条历史上外置过什么」 (对账、回收、审计)。故本文件与 spill 的 TruncHint/ParseHint 同纪律:格式单源, 改格式即改契约。
## 字段取舍
每个字段都有存在理由,缺一个就会有一类问题变成不可排查:
- kind/id:模型据此判断「这是什么东西、同一 id 是否同一份字节」;
- path:取回入口。**必须与真实落盘键逐字一致**,否则模型按图索骥必然落空;
- bytes:模型据此估算取回成本,决定要不要取;
- mime:决定取回后如何解释;缺失时模型只能盲解。
解析面要求各字段不含空格(path 已由 StorePath 净化保证),这是格式契约的一部分。
Index ¶
Constants ¶
const ( // DefaultRetrieveHint 缺省取回指引。 // // 指引不写死具体工具名:取回入口由宿主决定(本地文件、对象存储、专用工具……), // 内核写死某个名字只会在宿主换实现后变成误导——指向一个不存在的工具 // 比不给指引更糟。宿主有自定义时经 Request.RetrieveHint 覆盖。 DefaultRetrieveHint = "原文不在本条消息内;需要时按 path 取回(id 相同即字节相同)。" )
const FallbackExt = ".bin"
FallbackExt 未知(或无法解析的)媒体类型的缺省扩展名。
取 .bin 而非留空:留空会被 sanitizeExt 判为非法扩展名,条目级失败后内容内联, 白白越过阈值却不省任何上下文(Item.Reason 只够留痕,不够自愈)。
const MinBytes = 4096
MinBytes 外置阈值(缺省 4 KiB)。低于此尺寸的对象原样内联——占位文本的体量 加上一次取回往返的成本高于直接内联,小对象外置是负优化。 Request.MinBytes 可逐调用覆盖(≤0 时取本缺省)。
Variables ¶
var ( // ErrNoStore 未装配落盘面(Request.Store 为 nil),无从外置。 ErrNoStore = errors.New("未装配外置落盘面,无法外置") // ErrBadExt 扩展名非法。扩展名会拼进逻辑键,必须先净化—— // 未经净化的扩展名可以让同一份内容落进不同键,使去重与占位复用同时失效。 ErrBadExt = errors.New("外置扩展名非法(只允许字母数字与短横线/下划线)") // ErrBadHash 摘要长度不足,无法推导分片目录。 ErrBadHash = errors.New("内容摘要非法(至少 2 位十六进制)") )
派生哨兵。均为**条目级**失败:触发时该条目保持原内容内联,不影响本轮其余条目。
Functions ¶
func ExtForMimeType ¶
ExtForMimeType 由媒体类型推导落盘扩展名,返回带前导点的形态(如 ".png"), 可直接填入 Blob.Ext;StorePath 两种形态都接受。
三段口径:剥参数(`image/jpeg; charset=x` → `image/jpeg`)→ 查覆盖表 → 查平台表(排序取首个)→ 未知返回 FallbackExt。参数与大小写都要剥:覆盖表以 裸类型为键,而带参数的写法在真实消息里极常见(不剥则整条覆盖形同虚设)。
刻意不返回 error:推导失败不是本包的失败面,本包没有「类型不对」这种错误类型, 只有一个必须恒定的中性取值(见 FallbackExt)。
func Render ¶
Render 生成占位文本。
缺省形态 191 字节(由 TestPlaceholderBodySmallerThanThreshold 钉住上限)——这是 MinBytes 取 4 KiB 的前提:小到占位体量的若干分之一, 跨过阈值后替换一定是净赚(见 offload.go 包注释「关键取舍一」)。 同一 (Ref, hint) 必须产出**逐字节相同**的文本,故渲染内不含时间戳、序号、 随机数等任何随轮变化的成分。
func StorePath ¶
StorePath 由内容摘要与扩展名推导逻辑落盘键:blobs/<前两位>/<摘要><扩展名>。
纯函数,且**同一 (hash, ext) 恒得同一键**——这是去重的全部机制:键一致, 占位文本才可能逐字节一致,历史前缀才能命中提示缓存。按摘要前两位分片只是 避免单目录堆积,宿主可忽略目录层级自行映射。
返回的键以 path 分隔(与宿主无关的逻辑键,不是本机文件路径)。
func TruncatedHash ¶
TruncatedHash 取占位文本里暴露的摘要前缀。
为什么只暴露前缀:全量摘要进了消息就等于给内容做了一次可跨库比对的指纹, 没必要让每一轮都带它在上下文里飘;前缀够用于人眼定位与占位文本复用。
Types ¶
type Blob ¶
type Blob struct {
// Kind 对象类型(如 image/attachment)。参与占位文本,同一对象跨轮须给同一值,
// 否则占位文本不可复用、缓存不命中。
Kind string
// MimeType 媒体类型;空则占位文本省略该字段。
MimeType string
// Ext 落盘扩展名(调用方给,内核不猜——猜错会写出一个后端解不开的键)。
// 不给时用 ExtForMimeType(b.MimeType) 推导:那是本包的单源实现,宿主各写
// 一套映射会让同一份内容在不同宿主上落进不同的键(见 mimeext.go)。
Ext string
// Data 原始字节。
Data []byte
}
Blob 一条待处置的内联对象。
type Item ¶
type Item struct {
// Index 在 Request.Blobs 中的下标,调用方据此回填消息。
Index int
// Kind / Hash / Path 该对象的类型、全量摘要、逻辑落盘键。
Kind string
Hash string
Path string
// Bytes 原始字节数。
Bytes int
// Offloaded 该对象已有落盘副本(本次写的或此前写的)。
Offloaded bool
// Replaced 是否已用占位文本替换内容。
//
// **仅在后续轮为真**:首次见到时恒为 false,内容必须原地保留
// (见包注释「关键取舍二」)。把它做成可判定的字段而不是隐含约定,
// 是为了让「首轮替换」这类错误在测试里直接暴露。
Replaced bool
// Placeholder 占位文本;仅在 Replaced 为真时非空。
Placeholder string
// Reason 未处置/保持内联的原因(空=按预期处置);仅供留痕,不可据以重试。
Reason string
}
Item 单条对象的处置结果。
type Ref ¶
type Ref struct {
// Kind 对象类型。
Kind string
// ID 摘要前缀(见 TruncatedHash)。
ID string
// Path 逻辑落盘键,须与实际落盘键逐字一致。
Path string
// Bytes 原始字节数。
Bytes int
// MimeType 媒体类型;空则不渲染该字段。
MimeType string
}
Ref 占位文本的可解析部分(取回指引不在契约内,由宿主自定故不解析)。
type Report ¶
type Report struct {
Items []Item
// Stored 本次实际落盘的条数(去重的直接证据:同内容第二轮不再写)。
Stored int
// Replaced 本轮替换为占位文本的条数。
Replaced int
// Inline 保持原内容内联的条数(含低于阈值与条目级失败)。
Inline int
}
Report 一轮的外置报告(条目数与 Blobs 对齐)。
type Request ¶
type Request struct {
// Blobs 本轮内联的对象(按调用方顺序处理,互不影响)。
Blobs []Blob
// State 跨轮状态;nil 时每轮都按首次处理(内容始终完整,压缩不生效)。
State *State
// Store 落盘面;nil 时全部条目保持内联并给出 ErrNoStore 留痕。
Store Store
// MinBytes 覆盖缺省阈值;≤0 取 MinBytes。
MinBytes int
// RetrieveHint 占位文本里的取回指引(宿主自述其取回方式);空用缺省指引。
RetrieveHint string
}
Request 一轮的外置请求。
type State ¶
type State struct {
// contains filtered or unexported fields
}
State 已外置对象的摘要集合(**调用方持有**,须跨轮存活以兑现第二轮起的压缩)。
零值与 nil 均可用:nil 视作「什么都没见过」,后果是每轮都按首次处理—— 内容永远完整可见、只是省不下上下文。保守方向,永不丢输入。
并发安全:编排可能从多个环节并发推进,共享同一个 State 是常态而非意外。 未经保护的 map 在并发读写下是**不可 recover 的致命错误**,故这里加锁, 与 checkpoint.SeqGate / budget.Meter / tooldisclose.Catalog 同一口径。