跳转到内容

Remote Service 协议

本文规定 Graft remote service protocol version 1。它是 Graft client 与 remote service 之间的互操作契约,不限定编程语言、部署平台、数据库或对象存储。

文中的 必须(MUST)不得(MUST NOT)应该(SHOULD)、 **不应该(SHOULD NOT)可以(MAY)**按 BCP 14解释。

规范的 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_TOKEN

client 发请求前必须移除 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_TOKENtoken_env 可选择另一个变量。凭证缺失或 无效时应该返回 401 和合适的 WWW-Authenticate header。token 签发、租户和 ACL 存储不属于本协议。

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 必须忽略未知字段。

操作使用 repository-relative 的 opaque key。version 1 已知 key 包括:

HEAD
refs/heads/<branch>
objects/<fanout>/<object-id>
objects/pack/<pack-id>.pack
objects/pack/<pack-id>.idx
store/files/<fanout>/<object-id>
logs/<log-id>/commits/<lsn>
segments/<segment-id>

HEADrefs/** 是事务元数据;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

PUTPOST /cas 的 body 是新的原始字节,应该使用 application/octet-stream。mutation 成功响应没有 body。 成功的 HEAD 响应必须通过 Content-Length 返回对应完整 GET 的字节数。

version 1 服务仍必须支持 HEAD /raw/<key>。为了兼容明确返回 405 Method Not Allowed501 Not Implemented 的旧 gateway,client 可以用 GET /raw/<key>Range: bytes=0-0 重试存在性探测。200206 表示对象 存在,404 表示不存在;只有同时包含 Content-Range: bytes */0 时,416 才 表示一个已存在的空对象。当 gateway 忽略 Range 时,client 必须释放成功响应且 不得继续读取完整对象;鉴权失败、其他状态码和 transport error 不得触发该 fallback。 fallback 响应本身仍必须包含有效的 Graft-Protocol header;初始 405501 可能由 protocol service 前的 gateway 生成,因此可以不包含该 header。

服务必须为不可变对象支持 raw-if-not-exists,并应该拒绝不可变对象的无条件覆盖 或删除。Graft 的发布顺序是:先发布不可变对象,pack 成功后发布 index,最后 CAS 更新 ref。ref 更新失败可以留下不可达对象,但不会暴露不完整 commit。

对已不存在 key 的 DELETE /raw 应该仍成功,以保持幂等。

upload-bundle 是 version 1 的可选 capability,用于 Git 风格的 clone 传输。client 选定 branch 后只发送一个已认证请求:

POST /upload-bundle/refs/heads/main
Content-Length: 0

服务先读取请求的 ref,列举 repository 的不可变 key,再读取一次 ref。如果 ref 已经变化则返回 409,client 可以重试。快照稳定时返回 Content-Type: application/vnd.graft.upload-bundlex-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 返回 404405 时,client 必须回退 到 version 1 的 raw/list 操作。

receive-pack 是 version 1 的可选 capability,把 pack 上传、index 上传和最终 ref CAS 合并为一个已认证请求:

POST /receive-pack/refs/heads/main
Content-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: true
x-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 是 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 返回 404405 时,client 必须回退到逐个不可变对象写入,再调用 receive-pack/cas

multipart-object 是 version 1 的可选 capability,用于超过 HTTP 网关 request body 限制的不可变对象。它只改变传输方式:完成后的对象仍位于原 key,并保持相同的逻辑 content ID。

公布该 capability 时,descriptor 必须包含 limits.multipart_part_bytes;服务也应该 公布 limits.max_request_bytes,让 client 在请求不可能到达应用时直接跳过聚合的 receive-bundlereceive-pack。开始或恢复上传:

POST /multipart-start/segments/<segment-id>
Content-Length: 0
x-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。

POST /casPOST /cad 用两个 header 携带 expected value:

x-graft-expected-present: true
x-graft-expected-hex: 6162630a
  • present: false 表示预期 key 不存在,此时 hex header 必须存在且为空;
  • present: true 表示当前字节必须精确等于小写 hex;空 hex 表示“存在的零长度 对象”,不同于“不存在”;
  • /cas 用 request body 替换匹配值;
  • /cad 删除匹配值且没有 request body。

比较和 mutation 对 repository 的所有 client 必须表现为一个原子操作。HEADrefs/** 必须支持 CAS/CAD;服务可以拒绝对不可变 key 使用它们。

GET /raw/<key> 必须支持一个标准 byte range:

Range: bytes=1024-2047

合法的部分响应返回 206Content-RangeContent-LengthAccept-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-value

cursor 对 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。
204mutation 成功。
206byte-range read 成功。
400path、query、header 或 request 非法。
401 / 403缺少凭证或无权限。
404repository 或 object 不存在。
405该 key 不允许此操作。
409CAS/CAD 的 expected bytes 不匹配。
412raw-if-not-exists 遇到已有 object。
413 / 414超过服务 body/key 限制。
416range 非法或不可满足。
423使用锁的实现可以表示临时 lock contention。
426不支持请求的 Graft-Protocol 版本。
429服务限流;client 可以退避重试。
500 / 503服务端故障或服务配置不可用。

409412 有意区分:client 把 409 解释为并发 ref 变化,把 412 解释为 create-only collision。

错误应该使用 application/problem+json。client 必须依赖状态码,不得依赖某个 特定 error body schema。

兼容服务必须提供:

  • 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。