Remote Service 协议
本文规定 Graft remote service protocol version 1。它是 Graft client 与 remote service 之间的互操作契约,不限定编程语言、部署平台、数据库或对象存储。
文中的 必须(MUST)、不得(MUST NOT)、应该(SHOULD)、 **不应该(SHOULD NOT)和可以(MAY)**按 BCP 14解释。
Repository URL
Section titled “Repository URL”规范的 Git 风格 URL 是:
https://host/<namespace>/<repository>client 也必须接受显式 transport 形式:
graft+https://host/<namespace>/<repository>graft+https 选择本协议,wire 上仍使用 HTTPS。client 可以为本地开发或可信
网络接受 graft+http;生产服务应该使用 HTTPS。
repository path 是协议 base URL,对 client 不透明。服务若有不同的租户模型,
可以使用多于两段的路径;面向用户的 URL 应保持熟悉的
<namespace>/<repository> 形式,不得暴露内部 /api/... 路由或协议版本。
可选的 token_env query 只属于 client 本地配置:
https://host/acme/archive?token_env=GRAFT_ARCHIVE_TOKENclient 发请求前必须移除 token_env,不得把 token 本身写进 URL。version 1
repository URL 不得包含 userinfo、fragment 或其他 query parameter。
每个请求必须包含:
Graft-Protocol: 1每个响应(包括错误响应)必须回显同一 header。无法提供请求版本的服务应该返回
426 Upgrade Required,并通过 Graft-Protocol 声明所支持的版本。版本属于
协商元数据,不属于 repository URL。
服务可以要求授权。version 1 client 支持 bearer token:
Authorization: Bearer <token>默认环境变量是 GRAFT_REMOTE_TOKEN,token_env 可选择另一个变量。凭证缺失或
无效时应该返回 401 和合适的 WWW-Authenticate header。token 签发、租户和
ACL 存储不属于本协议。
Repository Descriptor
Section titled “Repository Descriptor”GET {base} 返回用于诊断和 capability discovery 的描述:
{ "protocol": "graft-remote", "version": 1, "capabilities": [ "range", "list", "put-if-absent", "upload-bundle", "receive-pack", "receive-bundle", "multipart-object", "cas", "cad" ], "limits": { "max_request_bytes": 67108864, "multipart_part_bytes": 16777216 }}响应可以增加字段,client 必须忽略未知字段。
Object Key
Section titled “Object Key”操作使用 repository-relative 的 opaque key。version 1 已知 key 包括:
HEADrefs/heads/<branch>objects/<fanout>/<object-id>objects/pack/<pack-id>.packobjects/pack/<pack-id>.idxstore/files/<fanout>/<object-id>logs/<log-id>/commits/<lsn>segments/<segment-id>HEAD 和 refs/** 是事务元数据;objects/**、store/**、logs/** 和
segments/** 下的内容创建后不可变。locks/** 是保留 namespace,不得作为
repository 数据暴露。
每个 key segment 独立进行 UTF-8 percent encoding。/ 只作为分隔符,不得作为
segment 数据编码。空 segment、.、..、反斜杠、NUL、控制字符和非法 percent
encoding 必须被拒绝。服务可以公布 key/body 限制,并在超限时返回 413/414。
下表 path 都相对于 {base}:
| Request | 用途 | 成功状态 |
|---|---|---|
HEAD /raw/<key> | 检查存在性和大小。 | 200 OK |
GET /raw/<key> | 读取原始字节。 | 200 OK |
PUT /raw/<key> | 替换事务元数据。 | 204 No Content |
DELETE /raw/<key> | 删除事务元数据。 | 204 No Content |
PUT /raw-if-not-exists/<key> | 仅当对象不存在时创建。 | 204 No Content |
POST /upload-bundle/<ref-key> | 流式返回 ref 快照及其不可变存储。 | 200 OK |
POST /receive-pack/<ref-key> | 发布一个 object pack 并原子更新 ref。 | 204 No Content |
POST /receive-bundle/<ref-key> | 发布不可变对象、pack 并原子更新 ref。 | 204 No Content |
POST /multipart-start/<key> | 开始或恢复不可变对象的分片上传。 | 200 OK |
PUT /multipart-part/<key> | 上传一个编号分片。 | 204 No Content |
POST /multipart-complete/<key> | 将全部分片组装到原不可变 key。 | 204 No Content |
DELETE /multipart-abort/<key> | 终止未完成的分片上传。 | 204 No Content |
POST /cas/<key> | 原子 compare-and-swap。 | 204 No Content |
POST /cad/<key> | 原子 compare-and-delete。 | 204 No Content |
GET /list?prefix=<prefix> | 递归列举 key。 | 200 OK |
PUT 和 POST /cas 的 body 是新的原始字节,应该使用
application/octet-stream。mutation 成功响应没有 body。
成功的 HEAD 响应必须通过 Content-Length 返回对应完整 GET 的字节数。
version 1 服务仍必须支持 HEAD /raw/<key>。为了兼容明确返回 405 Method Not Allowed 或 501 Not Implemented 的旧 gateway,client 可以用
GET /raw/<key> 和 Range: bytes=0-0 重试存在性探测。200 或 206 表示对象
存在,404 表示不存在;只有同时包含 Content-Range: bytes */0 时,416 才
表示一个已存在的空对象。当 gateway 忽略 Range 时,client 必须释放成功响应且
不得继续读取完整对象;鉴权失败、其他状态码和 transport error 不得触发该 fallback。
fallback 响应本身仍必须包含有效的 Graft-Protocol header;初始 405 或 501
可能由 protocol service 前的 gateway 生成,因此可以不包含该 header。
服务必须为不可变对象支持 raw-if-not-exists,并应该拒绝不可变对象的无条件覆盖
或删除。Graft 的发布顺序是:先发布不可变对象,pack 成功后发布 index,最后 CAS
更新 ref。ref 更新失败可以留下不可达对象,但不会暴露不完整 commit。
对已不存在 key 的 DELETE /raw 应该仍成功,以保持幂等。
Upload Bundle
Section titled “Upload Bundle”upload-bundle 是 version 1 的可选 capability,用于 Git 风格的 clone 传输。client
选定 branch 后只发送一个已认证请求:
POST /upload-bundle/refs/heads/mainContent-Length: 0服务先读取请求的 ref,列举 repository 的不可变 key,再读取一次 ref。如果 ref
已经变化则返回 409,client 可以重试。快照稳定时返回
Content-Type: application/vnd.graft.upload-bundle 和
x-graft-bundle-manifest-bytes header。响应开头是该 header 指定长度的 UTF-8 JSON:
{ "version": 1, "reference": { "path": "refs/heads/main", "value_hex": "<ref 字节的小写十六进制>" }, "objects": 3}之后必须紧跟 objects 个二进制 frame。每个 frame 依次包含 4 字节无符号 path
长度、8 字节无符号对象长度、UTF-8 path 和对象 body;整数使用网络字节序。path
必须严格有序、唯一,并且属于不可变 repository key。最后一个 frame 后不得有多余
字节。服务直接转发每个 backend 对象流,无需把整个 bundle 缓存在内存中。
version 1 会打包全部不可变 key,因为协议服务把对象内容视为 opaque。client 校验
所有 frame,建立临时本地 remote,再从该本地快照解析选定 commit graph 并完成
checkout。因此 clone 的数据主体只有一个 bulk response;后续协议版本可以增加
reachability negotiation。upload-bundle 返回 404 或 405 时,client 必须回退
到 version 1 的 raw/list 操作。
Receive Pack
Section titled “Receive Pack”receive-pack 是 version 1 的可选 capability,把 pack 上传、index 上传和最终 ref
CAS 合并为一个已认证请求:
POST /receive-pack/refs/heads/mainContent-Length: <pack-bytes + index-bytes>x-graft-pack-id: <64 个小写十六进制字符>x-graft-pack-bytes: <十进制字节数>x-graft-index-bytes: <十进制字节数>x-graft-ref-replacement-hex: <ref 新值的小写十六进制>x-graft-expected-present: truex-graft-expected-hex: <ref 预期值的小写十六进制>body 必须依次包含精确的 pack 字节和 index 字节;Content-Length 必须等于两个
声明长度之和。服务必须以 stream 创建 objects/pack/<pack-id>.pack,再创建
objects/pack/<pack-id>.idx,最后才执行 ref CAS。已存在的不可变 pack/index
按幂等成功处理;body 非法或截断时不得更新 ref。
expected-value 规则与 /cas 相同。不匹配时返回 409,且可以留下不可达的不可变
对象。服务通过 descriptor 中的 receive-pack capability 声明支持。面对不支持该
可选操作并返回 404/405 的服务,client 必须回退到独立的
raw-if-not-exists 加 /cas 流程。
Receive Bundle
Section titled “Receive Bundle”receive-bundle 是 version 1 的可选 capability,在 receive-pack 之外还可携带
pack 引用的不可变对象,例如 SQLite segment、SQLite storage commit 和外部文件
payload。它使用全部 receive-pack header,并增加:
x-graft-bundle-manifest-bytes: <manifest 的十进制字节数>body 依次包含 UTF-8 JSON manifest、manifest 顺序声明的每个对象、pack 和 index。 manifest 结构如下:
{ "version": 1, "objects": [ { "path": "segments/example", "bytes": 4096, "allow_existing": true }, { "path": "logs/example/commits/0000000000000001", "bytes": 128, "allow_existing": false } ]}每个 path 必须是不可变 repository key,且最多出现一次。Content-Length 必须等于
manifest、全部对象、pack 和 index 的长度之和。服务必须按 manifest 顺序创建对象,
再创建 pack 和 index,最后执行 ref CAS。body 非法、截断或含多余字节时不得更新 ref。
allow_existing: true 把已有对象视为幂等成功;false 则返回 412,让 client
通过独立的 version 1 路径读取并校验冲突后再重试发布。receive-bundle 返回 404
或 405 时,client 必须回退到逐个不可变对象写入,再调用 receive-pack 或 /cas。
不可变对象分片上传
Section titled “不可变对象分片上传”multipart-object 是 version 1 的可选 capability,用于超过 HTTP 网关 request body
限制的不可变对象。它只改变传输方式:完成后的对象仍位于原 key,并保持相同的逻辑
content ID。
公布该 capability 时,descriptor 必须包含 limits.multipart_part_bytes;服务也应该
公布 limits.max_request_bytes,让 client 在请求不可能到达应用时直接跳过聚合的
receive-bundle 或 receive-pack。开始或恢复上传:
POST /multipart-start/segments/<segment-id>Content-Length: 0x-graft-object-bytes: <总字节数>响应返回持久 upload session 与已经完成的 part:
{ "upload_id": "opaque-upload-id", "total_bytes": 111249535, "part_bytes": 16777216, "uploaded_parts": [{ "part_number": 1, "bytes": 16777216 }]}part 从 1 开始编号。除最后一个 part 外,其余 part 必须等于 descriptor 公布的大小; 最后一个 part 携带余数:
PUT /multipart-part/segments/<segment-id>Content-Length: <part 字节数>x-graft-upload-id: <opaque-upload-id>x-graft-part-number: <正十进制编号>同一 session 重传相同编号会替换该 part,因此响应丢失后可以安全重试。对相同 key 和
长度再次调用 multipart-start,必须返回同一 session 和已完成 part 列表。全部 part
存在后,使用空 body 和 x-graft-upload-id 调用 POST /multipart-complete/<key>,
把完整对象暴露在原不可变 key;DELETE /multipart-abort/<key> 释放未完成 session。
目标已存在时,start/complete 返回 412,client 沿用 raw-if-not-exists 的 collision
策略。multipart complete 不发布 ref;所有不可变对象完成后,client 仍需执行
receive-pack 或 CAS。
Compare 操作
Section titled “Compare 操作”POST /cas 和 POST /cad 用两个 header 携带 expected value:
x-graft-expected-present: truex-graft-expected-hex: 6162630apresent: false表示预期 key 不存在,此时 hex header 必须存在且为空;present: true表示当前字节必须精确等于小写 hex;空 hex 表示“存在的零长度 对象”,不同于“不存在”;/cas用 request body 替换匹配值;/cad删除匹配值且没有 request body。
比较和 mutation 对 repository 的所有 client 必须表现为一个原子操作。HEAD 和
refs/** 必须支持 CAS/CAD;服务可以拒绝对不可变 key 使用它们。
Range Read
Section titled “Range Read”GET /raw/<key> 必须支持一个标准 byte range:
Range: bytes=1024-2047合法的部分响应返回 206、Content-Range、Content-Length 和
Accept-Ranges: bytes。非法、多段或不可满足的 range 返回 416;object 存在时,
该响应必须包含 Content-Range: bytes */<size>。大 request 和 response 应该使用
stream,不应整体缓冲。
prefix 作为单个 query component 编码,因此 refs/heads/ 会发送为
refs%2Fheads%2F。每页响应为:
{ "paths": ["refs/heads/feature/search", "refs/heads/main"], "next_cursor": "opaque-service-value"}version 1 list 是递归的。为限制单次请求的工作量和内存,服务可以分页。响应包含
next_cursor 时,client 必须把它原样作为一个编码后的 query component 请求下一页:
GET /list?prefix=refs%2Fheads%2F&cursor=opaque-service-valuecursor 对 client 不透明;client 不得解析或自行构造。只有返回最后一页时,服务才能
省略 next_cursor。可选的正整数 limit 是 page size hint,服务可以使用更小的已
公布上限,或对超出范围的值返回 400。一次返回完整结果的服务仍兼容本规范;不含
next_cursor 的响应也兼容早期 client。
完整遍历必须恰好一次返回所有匹配 key,使用解码后的 repository-relative path。 每页及所有 page 拼接后的结果都必须按 bytewise lexical order 升序排列。cursor 必须向前推进,若会过期,服务应该公布有效期。
| 状态 | 含义 |
|---|---|
200 | 完整 read、descriptor、HEAD 或 list。 |
204 | mutation 成功。 |
206 | byte-range read 成功。 |
400 | path、query、header 或 request 非法。 |
401 / 403 | 缺少凭证或无权限。 |
404 | repository 或 object 不存在。 |
405 | 该 key 不允许此操作。 |
409 | CAS/CAD 的 expected bytes 不匹配。 |
412 | raw-if-not-exists 遇到已有 object。 |
413 / 414 | 超过服务 body/key 限制。 |
416 | range 非法或不可满足。 |
423 | 使用锁的实现可以表示临时 lock contention。 |
426 | 不支持请求的 Graft-Protocol 版本。 |
429 | 服务限流;client 可以退避重试。 |
500 / 503 | 服务端故障或服务配置不可用。 |
409 与 412 有意区分:client 把 409 解释为并发 ref 变化,把 412 解释为
create-only collision。
错误应该使用 application/problem+json。client 必须依赖状态码,不得依赖某个
特定 error body schema。
一致性与持久性
Section titled “一致性与持久性”兼容服务必须提供:
- mutation 成功后的 durable read-after-write;
raw-if-not-exists的 create-only atomicity;- 每个事务 key 上 linearizable 的 CAS/CAD;
- repository isolation;
- byte-preserving read/write;
- 在没有并发 mutation 时,一次完整 list 遍历能看到第一页请求前已完成且匹配 prefix 的所有 write。
服务可以把事务元数据和不可变字节放在不同系统中。这些要求描述可观察行为,不规定 实现方式。
早期文档使用:
graft+https://host/api/graft/v1/repos/acme/archive服务可以保留该 path 作为兼容 alias,但新配置应该使用
https://host/acme/archive(或显式 graft+https 形式)。client 必须把配置的
repository base 当作 opaque value,不得自行插入旧 /api/graft/v1/repos prefix。