memcached

    A memcached text protocol client library implemented in MoonBit.

    memcached
    cache
    client
    protocol
    text-protocol
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    8 hours ago
    Downloads
    1

    #lexchb/memcached

    用 MoonBit 实现的 memcached 文本协议(text protocol)客户端库。

    它只负责两件事:把命令编码成 memcached 能读懂的字节,把服务器返回的字节解码成结构化数据。 传输通道被抽象成一个 Connection trait,因此整个包不依赖任何 socket,可以在没有 memcached 服务端的环境下完整地编码、解码与测试。

    • 模块名:lexchb/memcached,版本 0.1.0
    • 首选编译目标:wasm-gc
    • 依赖:仅 moonbitlang/core 的 buffer、debug、encoding/utf8、string

    #安装

    模块名为 lexchb/memcached,在模块目录里用包管理器添加即可:

    moon add lexchb/memcached

    本包不引入任何第三方依赖,因此没有 memcached 服务端、也没有网络的环境里同样可以构建、测试与 运行示例。要在本仓库内复现下面的一切,克隆后执行第九节「运行方式」里的命令即可。

    #快速开始

    传输通道由使用方实现 Connection;包内自带的内存实现 ScriptedConnection 让下面的最小样例 不需要 socket 就能跑通(完整的多场景版本见 cmd/main/main.mbt):

    // 内存转录:服务器依次回答 STORED 与一个 VALUE 块。
    let conn = @memcached.ScriptedConnection::new([
    b"STORED\r\n", b"VALUE foo 0 3\r\nbar\r\nEND\r\n",
    ])
    let client = @memcached.Client::new(conn)
    println(client.set("foo", b"bar")) // Status::Stored
    for value in client.get(["foo"]) {
    println(value.key) // foo
    println(value.data) // b"bar"
    }

    接真实网络时只需为 socket 实现同一个 Connection(read 在无数据时阻塞,返回空表示对端 已关闭),上面这段客户端代码不需要任何改动;超时、重连与连接池同样落在实现里。

    #一、目录结构

    路径作用
    README.md仓库首页入口:项目目标、安装、快速开始与验收信息
    README.mbt.md本文件:完整设计文档(数据模型、编解码流程、校验、边界)
    memcached.mbt协议编解码层:请求编码、响应解码、错误类型
    transport.mbt传输层抽象:Connection trait 与内存版 ScriptedConnection
    client.mbt客户端层:Client、请求校验、便捷命令方法
    memcached_test.mbt黑盒测试(只使用公开 API)
    memcached_wbtest.mbt白盒测试:直接验证私有编解码与校验函数
    cmd/main/main.mbt可运行的演示程序(moon run cmd/main)
    moon.mod / moon.pkg模块与包的元数据、依赖声明

    #二、分层架构

    代码分为三层,依赖方向自上而下,每层都可以独立验证:

    ┌─────────────────────────────────────────┐ │ client.mbt Client / 校验 / 便捷方法 │ 面向使用者的 API └───────────────┬─────────────┬───────────┘ │ │ ┌───────────────▼──┐ ┌──────▼──────────────┐ │ memcached.mbt │ │ transport.mbt │ │ Request::to_bytes│ │ trait Connection │ │ Decoder │ │ ScriptedConnection │ │ 协议错误类型 │ │ TransportError │ └──────────────────┘ └─────────────────────┘

    • 编解码层(memcached.mbt)不持有任何连接,纯粹是字节进、字节出,所以可以脱离网络做单元测试。
    • 传输层(transport.mbt)只描述“字节怎么进出”,不关心 memcached 语义。
    • 客户端层(client.mbt)把两者串起来:写入编码后的请求,循环读取直到解出一个完整响应。

    #三、数据模型

    #请求

    Request 是一个封闭枚举,覆盖文本协议里本库支持的全部命令:

    变体生成的一行命令
    Storage(op, key, flags, exptime, value, cas, noreply)<op> <key> <flags> <exptime> <bytes> [<cas>] [noreply]\r\n<data>\r\n
    Get(keys, with_cas)get <key>... 或 gets <key>...
    Gat(keys, exptime, with_cas)gat <exptime> <key>... 或 gats <exptime> <key>...
    Delete(key, noreply)delete <key> [noreply]
    Incr(key, delta, noreply)incr <key> <delta> [noreply]
    Decr(key, delta, noreply)decr <key> <delta> [noreply]
    Touch(key, exptime, noreply)touch <key> <exptime> [noreply]
    Versionversion
    Stats(sub)stats 或 stats [<sub>](<sub> 可以是多个词,如 detail on)
    FlushAll(delay, noreply)flush_all [<delay>] [noreply]
    Verbosity(level, noreply)verbosity <level> [noreply]
    CacheMemlimit(megabytes, noreply)cache_memlimit <megabytes> [noreply]
    SlabsReassign(src, dst, noreply)slabs reassign <src> <dst> [noreply](src 为 -1 时由服务器自选源 slab)
    SlabsAutomove(mode, noreply)slabs automove <mode> [noreply](mode 为 0 关 / 1 开 / 2 激进)
    Quitquit

    StorageOp 有 Set / Add / Replace / Append / Prepend / Cas 六种,只用于 Storage 变体, 其命令行首词由内部的 StorageOp::name 映射(cas 需要额外的 CAS 令牌)。

    #noreply 与「是否有应答」

    noreply 是协议里的一个可选尾词:加上它,服务器执行完命令后不发送终止状态行。 这直接决定了调用方要不要读回响应,因此由 Request::expects_reply 统一裁决:

    命令是否有应答
    Get / Gat / Version / Stats恒为「有」
    Storage / Delete / Incr / Decr / Touch / FlushAll / Verbosity / CacheMemlimit / SlabsReassign / SlabsAutomove取 noreply 的相反数
    Quit恒为「无」(协议规定不回复)

    这条信息被客户端用来把两个方向分开:有应答的走 execute,无应答的走 send, 两者互相拒绝对方的请求;成批的请求走 pipeline,它按这条信息决定要收回几条应答(见第六节)。

    #响应

    类型含义
    Response::Values(Array[RetrievedValue])get/gets/gat/gats 的结果,可能为空(未命中)
    Response::Status(Status)存储类、delete、touch、flush_all、verbosity、cache_memlimit、slabs 的终止状态行
    Response::Counter(UInt64)incr/decr 执行后计数器的值
    Response::Version(String)version 的版本字符串
    Response::Stats(Array[StatEntry])stats 的 STAT 行,直到 END
    StatusStored / NotStored / Exists / NotFound / Deleted / Touched / Ok
    RetrievedValue一个 VALUE 块:key、flags、data(字节)、cas(仅 gets/gats 有值)
    StatEntry一条 STAT <name> <value>:name、value(都是字符串)

    StatEntry 的 value 保持字符串形态,因为 memcached 既报数字也报自由文本 (STAT version 1.6.21);stats items 这类分节把维度嵌进名字里(items:1:number), 而不是新增字段。

    #错误

    错误类型变体触发场景
    ProtocolErrorRemote(RemoteErrorKind, String)服务器回了 ERROR / CLIENT_ERROR / SERVER_ERROR
    ProtocolErrorMalformed(String)收到的字节不符合文本协议,或本地请求不合法
    ProtocolErrorDesynchronised(Int, Int)缓冲区超过上限仍未解出完整响应,流已失去分帧(两个字段依次是上限与已缓冲字节数)
    TransportErrorIo(String)底层通道失败,例如响应读一半连接被关闭

    RemoteErrorKind 把服务器错误分成 Generic / Client / Server 三档,便于调用方区分 “命令不认得”“参数写错了”“服务器内部出错”。Desynchronised 与 Malformed 的区别在于 责任方:前者说明本地缓冲里留着一段永远补不全的残片,后者说明这段字节本身就不是合法响应。 Malformed 的消息里若引用了对端的原始字节,会先转义并截断再拼进去(见第五节第 6 小节); Remote 携带的则是服务器错误行的原文,不转义也不截断——它和 STAT 行的字段一样, 是要交给调用方使用的内容,而不是消息里的引用。

    #四、请求编码流程

    入口:Request::to_bytes(memcached.mbt)。

    1. 新建一个 @buffer.Buffer。
    2. 按 Request 变体分支拼装:
      • Storage:写命令词 → 空格 → key → " <flags> <exptime> <bytes>" → 若有 CAS 令牌再补 " <cas>" → 若有 noreply 再补 " noreply" → \r\n → 原始数据 → \r\n。 其中 <bytes> 由 value.length() 现场计算,不需要调用方手工填写,避免长度与实际数据不一致。
      • Get:按 with_cas 选择 get 或 gets,然后依次追加每个 key。
      • Gat:先写 gat/gats 与 <exptime>,再追加每个 key——注意过期时间在 key 之前。
      • Delete / Incr / Decr / Touch:按模板拼接,noreply 由 write_noreply 统一追加在行尾。
      • Version:常量 version\r\n。
      • Stats:写 stats,有分节名时再补 " <sub>"——段名按调用方给出的样子原样写出, 因此 detail on、cachedump 1 100 这类带参数的段名会变成 stats detail on 这样的命令行。
      • FlushAll:写 flush_all,有延迟时再补 " <delay>",最后是 noreply。
      • Verbosity:写 verbosity <level>,最后是 noreply。
      • CacheMemlimit:写 cache_memlimit <megabytes>,最后是 noreply。
      • SlabsReassign:写 slabs reassign <src> <dst>,最后是 noreply。
      • SlabsAutomove:写 slabs automove <mode>,最后是 noreply。
      • Quit:常量 quit\r\n。
    3. Buffer::to_bytes() 返回最终字节。

    因为 noreply 总是命令行最后一个词,write_noreply 这个小助手保证了它不会插到 <delay>、<exptime> 之类的参数前面。

    编码结果示例:

    set foo 7 60 3\r\nbar\r\n # Storage(Set, "foo", 7, 60, b"bar", None, false) set foo 0 0 3 noreply\r\nbar\r\n # Storage(Set, "foo", 0, 0, b"bar", None, true) cas foo 0 0 3 42\r\nbar\r\n # Storage(Cas, "foo", 0, 0, b"bar", Some(42), false) get foo bar\r\n # Get(["foo","bar"], with_cas=false) gets foo bar\r\n # Get(["foo","bar"], with_cas=true) gat 30 foo bar\r\n # Gat(["foo","bar"], 30, with_cas=false) touch foo 30\r\n # Touch("foo", 30, noreply=false) delete foo\r\n delete foo noreply\r\n incr counter 3\r\n version\r\n stats items\r\n flush_all\r\n # FlushAll(None, noreply=false) flush_all 10\r\n # FlushAll(Some(10), noreply=false) verbosity 2\r\n cache_memlimit 64\r\n # CacheMemlimit(64, noreply=false) slabs reassign -1 2\r\n # SlabsReassign(-1, 2, noreply=false) slabs automove 2 noreply\r\n # SlabsAutomove(2, noreply=true) quit\r\n

    注意:to_bytes 只描述“在线上长什么样”,不做任何合法性校验;校验发生在客户端层 (见第七节),这样编解码层保持成纯粹的函数。

    #五、响应解码流程

    解码是从一个可增量填充的缓冲区里“拉”出完整响应。整体是一条三级流水线:

    Decoder::next └─ decode_response 在一段缓冲里找第一个 \r\n,定位首行 ├─ "VALUE ..." ──► parse_value_header ──► decode_value_blocks(循环) ├─ "STAT ..." ──► parse_stat_line ────► decode_stats(循环) ├─ "VERSION ..." ─► Version(去掉 8 字节前缀后的文本) ├─ "END" ──► Values([]) (get 全部未命中) └─ 其它 ──► decode_status (状态行 / 错误行 / 计数器数字)

    #1. 定位首行

    decode_response 先用 find_crlf 找到首个 \r\n。找不到就说明还只是响应的一部分, 返回 None,等调用方继续喂数据。找到后把首行按 UTF-8 解码成字符串,再按前缀分派。

    首行按 UTF-8 解码而不是 BytesView::to_string,因为 key 允许非 ASCII;后者会得到 Bytes 的 b"..." 展示形式而不是文本。

    #2. 状态行 / 错误行 / 计数器

    decode_status 用一串精确匹配把终止行映射成 Status:

    • STORED、NOT_STORED、EXISTS、NOT_FOUND、DELETED、TOUCHED、OK → 对应的 Status (OK 是 flush_all、verbosity、cache_memlimit 与 slabs 系命令的应答)
    • ERROR → Remote(Generic, 行内容)
    • 以 CLIENT_ERROR 开头 → Remote(Client, ...)
    • 以 SERVER_ERROR 开头 → Remote(Server, ...)
    • 全为 ASCII 数字 → Counter(值)(incr/decr 的返回)
    • 其它 → Malformed("unexpected response line ...")

    #3. VALUE 块

    parse_value_header 把 VALUE <key> <flags> <bytes> [<cas>] 按空格拆成 4 或 5 个字段 (字段数不对直接算 Malformed),并用 parse_decimal_int / parse_decimal 做数字解析: 空串与非数字一律拒绝;parse_decimal 只受 u64 上界约束(cas 令牌用),parse_decimal_int 在其之上再限制到 i32(flags、bytes 两个字段用)。

    <bytes> 另有一道上限,它不是常数,而是接收方解码器的 limit:decode_response 把这个 上限一路传给 parse_value_header。判据不是「块本身是否超过 limit」,而是这个块连同它的分帧 能否装进 limit:一个 VALUE 块必定与它的头行、块尾的 \r\n 和收尾的 END 行同处一个 缓冲区(常量 VALUE_RETRIEVAL_FRAMING 就是这段分帧本身——b"\r\n\r\nEND\r\n",头行剥掉的 CRLF、块尾的 \r\n、收尾的 END\r\n——字节数由编译器数,不靠手算;名字落在「一次检索」而不是 「一个块」上,因为收尾的 END 属于承载各块的那次检索,它量的是「能装下一个块的最小应答」), 这些开销要先从 limit 里扣掉,剩下的才是数据能用的额度。因此头检查与 next() 的 buffered <= limit 是同一根尺子,不会留下「头放过了、缓冲却永远凑不齐」的空档;装不下的头 当场报 Malformed,而不是先缓冲到上限再说。多块响应下这道检查只是必要条件,例外见第五节。

    消息里同时给出声明值、计入分帧后的实际需求与上限(a VALUE block of 100 bytes needs 122 byteswith its header and the END line, more than the 8-byte block limit),因为「太大」有两种成因: 对端失了分帧,或者调用方的 limit 比服务端的 item 上限还小。默认上限 8 MiB 高于 memcached 默认的 1 MiB item 上限,所以默认配置下这条路径只在失步时触发;把 limit 调小之后,它同样会 拦下本来合法的块。

    decode_value_blocks 负责把数据主体和结尾的 END 收齐,关键点有两个:

    • 数据块以字节数定帧,而不是以 \r\n 定帧。 它先检查缓冲区是否已有 header.size 个字节加尾随的 \r\n;不够就返回 None 等更多数据,够了就按声明长度切出 data。因此值里含 \r\n(二进制数据)也不会被截断。若数据后面的两个字节不是 \r\n, 报 Malformed。
    • 循环收块。 一个 VALUE 块处理完后,继续看下一行:是 END 就返回累积的 Values(values),是下一个 VALUE 就换掉当前 header 接着处理,其它内容报 Malformed。 这里刻意不用递归:一次 get 上百个 key 时,递归深度会等于块数。

    返回值统一是 (Response, 消耗字节数) 或 None,其中“消耗字节数”让上层可以精确地从缓冲区 丢弃已消费的部分。

    #4. STAT 块与 VERSION 行

    stats 的应答结构与 get 有一处要注意的区别:它没有自己的首行。get 的应答以 VALUE ... 这行头开始、以 END 结束;而 stats 的应答里每一行都是 STAT, 首行同样是统计数据。所以 decode_response 分派到 decode_stats 时是从头解码的, 而不是像 decode_value_blocks 那样跳过首行。

    parse_stat_line 只按第一个空格切分 STAT <name> <value>,因为值里可能有空格 (STAT libevent 2.1.12-stable)。缺少空格或整行不是 STAT 前缀都报 Malformed。 第一个空格同时也是字段的分界,所以名字不能为空:STAT pid 1 里那个空格正好开启了名字, 这一行没有名字可报,同样算 Malformed。

    VERSION <string> 只需要截掉前缀。前缀 "VERSION " 是 8 个 ASCII 字节,所以它的字符长度 也等于字节长度,可以直接当作字节偏移使用;截取发生在原始字节上,再按 UTF-8 解码成文本。

    #5. 增量解码器

    Decoder 就是把“缓冲 + 解析”包起来的小状态机:

    方法语义
    Decoder::new()空缓冲区,上限为 DEFAULT_BUFFER_LIMIT(8 MiB)
    Decoder::with_limit(limit)空缓冲区,上限为 limit
    Decoder::limit()这个解码器接受的上限字节数:既是缓冲区上限,也是它接受的 VALUE 块上限
    Decoder::feed(view)把新收到的字节追加到缓冲区尾部
    Decoder::buffered()尚未消费的字节数
    Decoder::next()尝试解出一个完整响应;不足一个响应时返回 None;成功时把已消费字节从缓冲区头部移除;Remote 错误行在抛出前也一并消费(见下)
    Decoder::resync()丢弃整个缓冲区,用于流已失去分帧后的重新开始

    这带来三个重要性质,测试里都有覆盖:

    • 一次 read 可能只给半个响应 → next() 返回 None,feed 剩余部分后即可解出(响应跨包重组)。
    • 一次 read 可能给多个响应 → 连续调用 next() 就能逐个取出(流水线批处理就建立在这上面)。
    • Remote 错误行会被消费掉 → 错误行本身就是一个完整响应,所以它从缓冲区头部移除之后才抛出。 否则同一个错误会对之后每一次 next() 重复抛出,服务器确实发回来的那些应答永远读不到。Malformed 不走这条路:这段字节根本读不成一个响应,「下一个响应从哪里开始」正是此时不知道的事,恢复靠 resync()。Desynchronised 同理——缓冲区里的残片还没被读成任何东西。

    #缓冲上限与失步恢复

    next() 返回 None 意味着“还在等剩下的字节”。但一个永远补不全的流会让缓冲区无限增长, 所以缓冲区长度超过 limit 时,next() 不再返回 None,而是抛 Desynchronised(limit, buffered):此时留下的是一段永远解不出响应的残片,继续等下去只会耗尽内存。

    默认上限 DEFAULT_BUFFER_LIMIT = 8 MiB:memcached 默认单条 item 最大 1 MiB,而一次多 key 检索会把每个命中的 VALUE 块拼在一起,8 MiB 给“几条满载 item”留了余量。需要更紧或更松的 约束时用 Decoder::with_limit / Client::with_limit。

    同一个 limit 还有第二个作用:它就是能被接受的 VALUE 块大小上限。块必须整块缓冲才能 解码,所以一个连同分帧都放不进 limit 的头在这个解码器里永远凑不齐数据,属于当场报 Malformed 的情形(见第三节);Desynchronised 则留给「块本身放得下、只是字节一直堆不完」的 流。二者是同一根尺子的两端:调大 limit 为更大的块让位,调小则同时收紧这两道闸。

    头检查把开销算进去了,所以没有「头放过、随后才按 Desynchronised 报出」的空档:能凑齐的 最大块正是 limit 减去头行长度与 VALUE_RETRIEVAL_FRAMING 的字节数,判据与 next() 的 buffered <= limit 对齐。换句话说,一条声明若连自己的分帧都放不进缓冲区,它描述的不是一个 还在路上的块,而是一段已经失去分帧的流,当场说出来比白白缓冲到上限再报错要好。

    一个例外是多块响应:头检查对每个头都用整份 limit,而不是「前面的块用剩多少」。于是它对整段 序列只是必要条件:每个块单独看都放得下、合起来却超过缓冲的响应不会被这里拦下。这也不算漏判, 因为 limit 约束的是等待解码的字节,不是「一个响应可以有多大」——整段一次到齐的响应照常解码, 只有「已经在缓冲里堆着、却还没收完」的响应才会被 next() 按 Desynchronised 报出。两道闸的分工 因此是「这个解码器永远分不出帧的响应」与「还等着收完、但缓冲已经装不下的响应」。

    resync() 是配套的恢复动作:它清空缓冲区,让下一个响应从干净的状态开始。只有确认流的 分帧已经重新开始时才该调用(典型场景是重连之后);在正常流上调用会丢掉尚未消费的字节。

    #6. 错误信息里的对端数据

    解码失败的消息里经常会引用出错的那段原始字节(unexpected response line '...'、data blockof key '...')。这些字节来自对端,直接拼进消息会有两个问题:控制字符会原样出现在日志里 (\r 能把一行消息撕成两行),而超长的一行会把真正的原因挤到看不见的地方。

    所以 Malformed 消息里所有嵌入对端数据的位置都经过 quote:

    • 逐字符调用 Char::escape(quote=false):回车、换行、制表等控制字符被写成「反斜杠 + 字母」 两个字符的形式,不可打印字符写成 \u{7f} 这样,因此消息始终是单行可读的;
    • 最多引用 MAX_QUOTED_CHARS = 64 个字符,超出部分截断并补 ...。

    注意区分两类数据:STAT 行里的 name / value 与 Remote 携带的错误行是 要交给调用方使用的内容,原样保留;只有 Malformed 消息里的引用才做转义与截断。

    #六、客户端执行流程

    Client 持有一个被借用的连接和一个 Decoder:

    Client { conn : &Connection, decoder : Decoder }

    Client::new 用默认上限的 Decoder;Client::with_limit(conn, limit) 换一个缓冲上限不同的 解码器:超过 limit 字节仍未见完整响应时按失步处理,而 VALUE 头声明的块连同分帧都放不进 limit 时当场报 Malformed(见第五节「缓冲上限与失步恢复」)。

    #两个入口:execute 与 send

    写请求的入口有两个,按“这条命令有没有应答”分工,守卫互为反面(一批命令走本节的 pipeline, 它不受这对守卫约束,而是按同一条信息决定收几条应答):

    入口接受的请求行为
    execute(request)expects_reply() 为 true写出请求,读到一整个响应才返回
    send(request)expects_reply() 为 false:quit,或带 noreply 的变更类命令只写出请求,不读任何字节

    两个方向都必须拦:把 noreply 命令交给 execute,它会一直读到对端关闭才报错;把有应答的 命令交给 send,那条应答会留在缓冲里被当成下一条命令的响应。所以 execute 对无应答的 请求抛 Malformed("this command has no reply to read; use 'send' for it"),send 对有应答的 请求抛 Malformed("this command has a reply to read; use 'execute' for it")。

    execute 的步骤固定为:

    1. validate_request(request) —— 先做本地校验,不合法就抛 Malformed,一个字节都不写出去;
    2. guard request.expects_reply() —— 拦下没有应答可读的命令;
    3. conn.write(request.to_bytes()) —— 把编码后的请求整段写出;
    4. 循环尝试解码:
      • decoder.next() 返回 Some(response) → 直接返回;
      • 返回 None → conn.read(4096) 再读一块;
        • 读到 0 字节说明对端已关闭 → 抛 TransportError::Io("server closed the connection mid-response");
        • 否则 decoder.feed(chunk) 后继续循环。

    这个“写一个、读一个”的串行模型让请求与响应的配对关系一目了然,代价是每条命令一个往返; 需要一次跑一批命令时用下面的 pipeline。

    #便捷方法

    execute 之上的薄封装,负责把 Response 转成具体类型,并检查响应种类是否符合预期 (expect_values / expect_status / expect_counter / expect_version / expect_stats, 种类不符即 Malformed):

    方法命令返回值
    store(op, key, value, flags, exptime, cas)任意存储命令(可指定 flags / 过期时间 / CAS)Status
    set / add / replace / append / prepend对应存储命令;flags / exptime 以同名可选参数透传,默认 0Status
    cas(key, value, cas)cas,带上期望令牌;同样接受可选的 flags / exptimeStatus
    get(keys) / gets(keys)get / getsArray[RetrievedValue]
    gat(keys, exptime) / gats(keys, exptime)gat / gats,取回的同时把过期时间重置Array[RetrievedValue]
    delete(key)deleteStatus
    incr(key, delta) / decr(key, delta)incr / decrUInt64(执行后的值)
    touch(key, exptime)touch,只续期不取值Status
    version()versionString
    stats() / stats_of(section)stats / stats <section>Array[StatEntry]
    flush_all(delay)flush_all [<delay>],None 表示立即Status
    verbosity(level)verbosity <level>Status
    cache_memlimit(megabytes)cache_memlimit <megabytes>,调整 item 内存上限Status
    slabs_reassign(src, dst)slabs reassign <src> <dst>,src 为 -1 时由服务器自选Status
    slabs_automove(mode)slabs automove <mode>(0 关 / 1 开 / 2 激进)Status
    quit()quitUnit

    上面这些方法都固定 noreply=false 并走 execute,因此每一个都能拿到服务器的确认。 需要抑制应答时不必绕道编码层:直接构造带 noreply=true 的 Request 交给 send, send 是公开的。

    stats_of(section) 的 section 可以是多个词,stats_of("cachedump 1 100") 会发出 stats cachedump 1 100;词之间的间隔规则由第七节的校验负责。

    quit 的语义单独说明:服务器对 quit 不回复,所以它走 send 而不是 execute。 关闭连接用 postfix catch 兜底:write 失败时先 self.conn.close() 再把错误重新抛出, 写成功时在函数末尾关闭,两种情况都不会泄漏连接。这里是尽力而为的关闭——若连关闭本身 也失败了,那个错误被就地吞掉,因为调用方要的是「写不出去」这个原因,而不是「顺手关闭也失败」。

    与命令无关的连接管理还有一个入口:Client::resync() 直接转发内部的 Decoder::resync (见第五节「缓冲上限与失步恢复」),在 Desynchronised 之后、流的 framing 已知重新开始时 (典型如重连)清空缓冲残片,让同一个 Client 不必重建就能继续。

    #流水线批处理:pipeline

    execute 一条命令一个往返;pipeline(requests) 把整批请求全部写出之后才按序收回应答,省往返的 地方就在这里——服务器可以在前一条应答还在路上时就开始处理后面的请求。返回值与入参等长且按下标对齐:

    槽位含义
    Some(response)这个请求有应答,response 就是它
    None这个请求的应答被协议抑制(带 noreply 的变更类命令),没有字节可读

    判定「某个请求有没有应答」用的正是 Request::expects_reply,所以调用方不必自己数谁有应答,也不会把 槽位整体错开一位。整批都是 noreply 时就等价于「一口气写完、一个字节都不读」,这正是流水线收益最大的 场景;空批次写出 0 字节、读 0 字节、返回空数组。

    固定步骤:

    1. 进函数先把入参复制一份快照,之后校验与写出都只看这份快照——Array 是可变的,若在校验 与写出之间有人动了原数组,就能让一条没被校验过的请求混上线路;
    2. 整批先校验(validate_pipeline,见第七节),可能在一个字节都没写出去时就拒绝;
    3. 依次写出快照里每个请求的全部字节,中途不读;
    4. 写出时顺手记下每个请求是否有应答(一张 Array[Bool] 槽位表),随后照它读回:槽位为 true 才去读一条应答,填进该格;为 false 就留 None。判定只做一次、与配对同源,因此不会出现 「数出 3 条应答、却只有 2 个位置可填」这种漂移——计数和填槽用的是同一份数据。

    只要批次没走完,连接就作废:写出阶段断开时,已经写出去的那几条服务器仍会各回一条;非 Remote 的解码失败(Malformed / Desynchronised)时,顺次读的循环停在中途,线上也还欠着后面每个请求 各一条。这些字节都读不回也扔不掉(resync() 只清解码缓冲),该 Client 只能丢弃重建,见第十节。

    Remote 错误的处理单独说明:错误行本身是一个完整响应,说明分帧没坏、服务器仍会为后面每个请求各发 一条应答。所以剩下的应答会被读完并丢弃,然后才抛出这条错误——把字节留在流里,它们就会被下一次调用 当成自己的应答,正是本库一直在避免的错位。代价是这些应答的内容丢了:需要逐条核对每一份应答时,请拆成 多次 execute。反过来,Malformed / Desynchronised 说明流本身已经不能定帧,继续读只会收集噪声, 于是当场抛出、不做排空:resync() 能清掉缓冲里的残片,却清不掉线上还没读走的那几条应答,所以 这一半收不了场,该 Client 与写出阶段失败一样要丢弃重建(见本节上方的说明与第十节)。

    quit 不允许出现在批次里,不论它排在第几个:服务器读到它就不再多说一个字,排在它后面的请求会被静默 丢弃、没有任何应答能报告这件事;放在末尾又等于绕开调用方把连接关掉。关闭连接是 Client::quit 的职责。

    noreply 混在流水线里的风险与第十节同源,而且更隐蔽:带 noreply 的请求若被服务器回了错误行,这行 游离的错误会被当成后面某条请求的应答,后续槽位整体错开一位。文本协议无法把错误归属到具体命令; 需要严格归属时,把带 noreply 的请求单独走 send,或统一改用 execute。

    #七、请求校验

    校验发生在 client.mbt,目的是拦住文本协议无法表达、或会让服务器解析错位的请求。

    #key 的合法性(validate_key)

    1. 非空,且 UTF-8 编码后长度不超过 MAX_KEY_LENGTH = 250 字节(服务器按字节计数,不是字符数);
    2. 每个字节的值不能 <= 0x20(含空格、制表符、换行等空白与控制字符),也不能是 0x7F。

    原因是服务器用空格切分命令行,key 里出现空格会导致字段错位;gets 解码也依赖 key 不含 控制字符。多字节 UTF-8 的后续字节都 >= 0x80,因此正常的中文等非 ASCII key 可以通过。

    第 2 条里「哪些字节能站进一个词」被抽成一个共享谓词 is_word_byte(code)(即 code > 0x20 && code != 0x7F),key 与 stats 段名都调用它,两处判定不会各自漂移。

    key 来自应用数据,所以每条拒绝都要能把人指回那条 key:消息里带上 key 本身、触犯的规则, 有数字可报时再带上字节数。空 key 报 a key cannot be empty;超长报出实际字节数与上限 (key '...' is 251 bytes, more than the 250 bytes a key may hold);撞上空白或控制字节时报出 那个字节(key 'has\ttab' holds a blank or control byte a command line cannot carry: '\t')。 key 与那个字节都经 quote 处理(见第五节第 6 小节),制表符不会原样进入日志,过长的 key 会被 截断——真实长度仍由旁边的字节数如实报出,两者互相补位。

    #请求级校验(validate_request)

    validate_request 是一条穷尽匹配(match request { ... } 覆盖全部变体,新增变体时编译器会 强制补上分支),客户端层的每个写入口在写字节之前都会调用它:

    请求检查内容
    Storage / Delete / Incr / Decr / Touch校验 key
    Get / Gatkey 列表至少一个(否则生成 get \r\n 毫无意义),并逐个校验 key
    Stats有子命令时,段名是命令行里的一串「词」:段名可以带参数(stats detail on、stats cachedump 1 100),词之间只允许单个空格,且每个词都要满足 key 的规则(非空、≤250 字节、无空白与控制字符)。空段名、首尾空格、连续两个空格都会留下一个空词,一律拒绝
    FlushAlldelay 为 Some(n) 时要求 n >= 0
    Verbositylevel < 0 即拒绝
    CacheMemlimitmegabytes < 0 即拒绝
    SlabsReassignsrc 不得小于 -1(-1 表示由服务器自选源 slab);dst 不得小于 0——只有源可以交给服务器挑,目标必须指明一个 class
    SlabsAutomovemode 只接受 0 / 1 / 2
    Version / Quit无需校验

    段名的长度上限是从 key 借来的:memcached 对 stats 段名没有单独的限制,命令行整体按一串 「词」读取,所以「不长于一个 key」必然被接受。本库据此设了一道保守上限,消息写作 outside the1..250 bytes this client allows 而不是声称服务器要求 250 字节,据实说明这是本客户端自己的闸。 整段为空交给长度判据,与超长段名报同一条 outside the 1..250 bytes;首空格、尾空格、 连续两个空格留下的空词交给扫描,不论空词出现在什么位置,都收敛到同一条 stats section has an empty word,错误种类不会随位置变化。

    校验只回答“能不能表达成一行合法命令”,不替服务器判断语义:例如 touch 一个不存在的 key 是合法的命令行,服务器回 NOT_FOUND,本库照样把它编码发出去。

    #批处理的校验(validate_pipeline)

    pipeline 的整批校验就是逐个套用 validate_request,所以坏请求排在批次哪个位置,都会在写出任何 字节之前拦下——批次不存在「前面几条已经发出去了」这种半途状态。它另有一条禁令:quit 不论出现在哪个 位置都拒绝(理由见第六节「流水线批处理」),这也是唯一一条只对批次成立、对单条命令不成立的规则。

    #八、传输层与测试替身

    Connection 是一个开放 trait,只有三个方法:

    trait Connection { fn write(Self, BytesView) -> Unit raise TransportError fn read(Self, Int) -> Bytes raise TransportError // 空结果 = 对端已关闭 fn close(Self) -> Unit raise TransportError }

    ScriptedConnection 是它的内存实现,用于示例和测试:

    • 构造时给一串 chunks,read 每次只回放一个 chunk,并截断到请求的字节数; 剩余部分留到下一次 read。这让测试可以精确地制造“响应被拆成多段”的场景。
    • write 把客户端写出的所有字节累积进内部 Buffer,通过 written() 取回,因此它同时是一个 spy, 可以逐字节断言线上内容。
    • chunk 用尽后 read 返回空字节,客户端据此判定为连接关闭。
    • close 丢弃剩余的 chunk 和待读数据。

    #九、测试与示例

    两个套件都不需要 memcached 服务端,moon test 共 100 个用例(黑盒 65 + 白盒 35)。

    #黑盒测试(memcached_test.mbt)

    全部通过公开 API 驱动,覆盖:

    • 编码:存储命令行与数据块、CAS 令牌、空值、get/gets 多 key、 gat/gats(<exptime> 位于 key 之前)、touch、delete/incr/decr/quit、 version/stats/stats <section>/flush_all/verbosity 的可选参数、 cache_memlimit 与 slabs reassign/automove 的管理命令行、以及 noreply 一律追加在命令行末尾;
    • 解码:七种状态行(含 OK)、计数器数字、单个/多个 VALUE 块、未命中(END)、 VERSION 行、STAT 块(含无值的 STAT 行被拒、END 前未结束则继续等待、只收到半行 VERSION 时等待)、数据块以字节数定帧(值内含 \r\n)、响应跨 feed 重组、单次 feed 多响应、服务器错误的三种类别、Remote 错误行被消费后后面那条应答仍读得出来(同一个 错误不会对每次 next() 重复抛出)、非法帧(未知行、字段数不对、数据块未以 CRLF 结尾);
    • 客户端:单连接上 set + get 的完整往返与线上字节断言、cas 透传令牌、set/cas 便捷方法 透传 flags/exptime(线上字节逐字节断言)、add/replace/append/prepend 各自驱动对应的 命令词、gets 带回每个块的 CAS 令牌、delete 与 decr 读回自己那条应答、计数器返回值、 大应答(5000 字节的块,超过一次 4096 字节的读取)被连接拆成多次 read 后照样重组、 touch/gat/gats 往返、version 与 stats 的取值、flush_all/verbosity/ cache_memlimit/slabs 系命令读回 OK、 send 写出 noreply 命令且一个字节都不读、有应答的命令不能 send、无应答的命令不能 execute、quit(含写入失败时仍释放连接,以及关闭也失败时就地吞掉、只报出导致放弃连接的那条 发送错误)、响应中途断连报 TransportError::Io、 未知行报 Malformed、key 长度与字符合法性边界(250 通过 / 251 拒绝 / 空串 / 空格 / 制表符 / 换行)、空 key 列表与管理命令的非法参数(负的 megabytes、小于 -1 的 slab source、 负的 slab target、超出 0..=2 的 automove 模式)在写出任何字节之前就被拒绝、声明的字节数远大于 可用数据时只是等待而不是错误分帧、始终无法定帧的应答被 with_limit 当场判为 Malformed 而不是无限缓冲、失步的连接经 Client::resync 清空残片后照常往返;
    • 流水线批处理:三个请求(有应答 / noreply / 有应答)的槽位与请求一一对齐、线上字节逐字节 断言;用一个把 write/read 调用按顺序记下来的测试替身(TracingConnection)证明整批 先全部写完再开始读,以及全 noreply 的批次只写不读;空批次不写不读返回空;应答跨读边界 时在批次内照样重组;Remote 错误把后面的应答读完丢弃后抛出,因此紧接着的命令读到的是自己 的应答而不是留下的那条;Malformed 则当场停住、不做排空;批里混进非法请求或 quit 时一个 字节都没写出去;
    • 缓冲上限与块上限:上限为 0 的解码器仍能解出已经完整的响应(上限只在等待时才起作用), 而同样大小的一段残片会被判为 Desynchronised(0, 6) 并如实报出上限与已缓冲字节数;缓冲区 长度恰好等于上限时继续等待(判据是「超过」而不是「达到」),多一个字节才判失步;而块 连同它的头行与收尾的 END 都放不进上限时当场报 Malformed——连 0 上限的解码器遇到 VALUE k 0 1 也是这个结果,因为头本身就说出了这个块在此处不可能凑齐;Client::with_limit 在上限足够大时不影响正常往返,线上字节与默认解码器一致。

    #白盒测试(memcached_wbtest.mbt)

    黑盒受公开 API 所限,有些私有路径它够不到;白盒测试直接调用这些函数,覆盖:

    • StorageOp::name:六个存储命令的命令词映射;
    • find_crlf:首个分隔符的位置、空输入、只有 \r 的输入;
    • 数字解析:is_decimal 对符号/字母/内嵌空格的拒绝,parse_decimal 的 u64 上界 (18446744073709551615 通过 / ...616 拒绝),parse_decimal_int 的 i32 上界 (2147483647 通过 / 2147483648 拒绝);
    • parse_value_header:四/五字段、非 ASCII key、字段数不对、非数字字段、超界值;头行的字节数由 调用方按它在线上的长度传入(decode_response 传的就是行尾分隔符的偏移),不再重新编码取长度; 以及 <bytes> 的块上限——它由传入的 limit 决定,且判据算的是「块 + 头行 + 块尾 \r\n + END 行」能否装进 limit(分帧部分直接取常量 VALUE_RETRIEVAL_FRAMING 的字节数,不手算): memcached 自身的 1 MiB item 上限(1048576)连分帧也放得进 DEFAULT_BUFFER_LIMIT,属于 「还在路上」的块必须通过,而恰好等于上限的声明会被拒绝(分帧没有容身之处);换成 limit=64 时 VALUE foo 0 41 的 14(头行)+ 41(数据)+ 9(VALUE_RETRIEVAL_FRAMING)= 64 恰好通过, 再多一个字节就被拒绝;
    • 解码细节:decode_status 原样带回 offset、decode_line 把 UTF-8 key 还原成文本、 decode_response 对 64 个 VALUE 块的整段解码与消耗字节数、以及“响应不完整不是错误” (只返回 None 等待更多数据);
    • STAT 行:只在第一个空格处切分(值里可以带空格,如 libevent 2.1.12-stable)、 子命令把维度嵌进名字(items:1:number)、缺值/缺分隔符/非 STAT 行/空名字 (STAT pid 1)均报 Malformed;
    • 错误消息的转义与截断:普通文本原样保留,回车换行等控制字符被转义成单行可读的形式, 0x7F 写成 \u{7f},超过 64 个字符的部分被截断并以 ... 结尾;
    • stats 的定帧:首行就属于数据段(STAT pid 1234\r\nEND\r\n 必须解出 1 条而不是 0 条), 缺少末尾 END 时返回 None 继续等待,中途冒出 STORED 这类非 STAT/END 行才算错帧;
    • VERSION 行:偏移量取前缀的字节长度,否则版本串会剩下一个前导空格;
    • Decoder 的缓冲上限:limit() 默认是 DEFAULT_BUFFER_LIMIT、with_limit 如实回报; 未越界时只等待(并如实报告 buffered()),越界时抛 ProtocolError::Desynchronised(limit, buffered);而头里声明的块连分帧都放不进 limit 时 不等缓冲填满就用 Malformed 报出,消息里同时给出声明字节数、计入分帧后的需求与上限; 多块检索逐头按整份 limit 判定、而不是按前面块用剩的额度,所以这道头检查对整段序列只是 必要条件——每个块单独都放得下、合起来超过 limit 的响应不会被它拦下:整段一次到齐时照常解出 全部块,同样的字节若缺了收尾的 END 堆在缓冲里,才由 next() 按 Desynchronised 报出; resync() 丢弃失步残片后,解码器能立刻从下一个完整响应上重新同步;
    • expect_values / expect_status / expect_counter / expect_version / expect_stats: 响应种类不符即 Malformed;
    • 校验:validate_key 对 0x1F、0x7F 等控制字符与长度边界的判定(含多字节字符按 UTF-8 字节计数的 249 通过 / 252 拒绝),并且逐个断言拒绝时的消息原文——空 key 那条不带 参数,超长那条报出 251 与 250 两个数,撞上制表符、空格、0x7F 时把违规字节转义后附在 key 后面;validate_request 覆盖每条带 key 的命令,以及三条管理命令的参数边界 (cache_memlimit 拒绝负数、slabs reassign 的 src 以 -1 为下限而 dst 以 0 为下限 且两者分别报出是哪一边出错、slabs automove 只收 0..=2); stats 的段名另有一组用例——detail on、cachedump 1 100 这类多词段名通过,而空段名、 首尾空格、连续两个空格、含制表符的段名都拒绝;空段名与超长段名同走长度判据,报出实际字节数 与 1..250;首空格、尾空格、连续两个空格留下的空词收敛到同一条 stats section has an emptyword;非法字节被转义后随段名带回;
    • 流水线用到的私有助手:Decoder 抛 Remote 前先把那一行消费掉(buffered() 只剩紧随的 8 字节,下一次 next() 读到的是它后面的 STORED),Malformed 则原样留在缓冲里 (buffered() 仍是 14 字节,因为没人知道下一个响应从哪里开始,只能交给 resync()); 槽位表由 read_replies 直接驱动——[true, false, true] 只读两次,第二条响应落进第三个槽位、 中间那格是 None,证明「静默请求不占一次读」;remaining_reply_count 从某个槽位起只数仍被 欠着的应答([true, false, true] 依次是 2 / 1 / 1 / 0);validate_pipeline 对空批次放行、 对 quit 无论排在哪个位置都拒绝、并把单条请求的校验(如空 key 的 delete)应用到批次里的 每一个成员;discard_replies 读走指定条数后静默返回(第三条应答仍留给下一次读取),连接已死 时也静默停下,不把 TransportError 漏给调用方。

    #运行方式

    克隆本仓库后,按下面五步复现全部结果:

    moon check # 类型检查(CI 的第一步) moon build # 构建整个模块(含 cmd/main,CI 的第二步) moon test # 跑测试(CI 的第三步) moon coverage analyze # 查看未被测试覆盖的行 moon run cmd/main # 跑演示程序 moon info && moon fmt # 更新接口文件并格式化

    CI(.github/workflows/ci.yml)在每次 push 与 pull request 上依次 执行 moon check、moon build、moon test,对应验收要求里的「检查、构建、测试」三流程。 本包不依赖第三方库,因此这些命令在没有 memcached 服务端、也没有网络的环境里同样能全部通过。

    仓库里另带一个 pre-commit 钩子(.githooks/pre-commit,依次跑 moon check、moon fmt --check、moon test);格式检查只在这里做,不进入 CI。克隆后执行一次 git config core.hooksPath .githooks 即可让它在本机生效。

    cmd/main/main.mbt 用 ScriptedConnection 演示五段内容:请求编码结果(带转义,便于看清 \r\n 分帧,含 gat、noreply 的 del!、stats items、flush_all 10)、单连接客户端的 set/get 往返与线上字节、noreply 与 version/stats/flush_all/cache_memlimit 这些管理命令(send 出去的 delete gone noreply 同样出现在线上字节里)、一个三请求的流水线批次(set / delete gonenoreply / get:先打印整批的线上字节,再逐槽位打印结果,noreply 那一格显示出“没有说话”)、 以及增量解码器“半个响应等待、补齐后解出”。

    #十、边界与已知限制

    • pipeline 不是事务,也不是万能的批入口:它只是把一批命令一次写完再一次读完,省掉往返 延迟,但不改变协议语义——批次里各条命令依旧彼此独立,服务器也不保证它们原子执行;quit 被明确拒绝(服务器在其后退场,后面写出去的请求再也拿不到应答,错误将无从归属)。批内若某条 命令回了 Remote 错误,pipeline 会先把剩余应答读完丢弃再抛出,因此连接仍停在一个干净的分帧 边界上,但那一批里已经成功执行的命令照样生效了,调用方不能据此认定整批未执行。
    • 批次中途失败后 Client 不可复用:两种情形收场相同。其一是写出阶段失败——pipeline 一条条往外写,若写到第 k 条时通道断开,前 k-1 条已经在线上了,服务器会照常为它们各回一条; 其二是非 Remote 的解码失败(Malformed / Desynchronised)——那时读取循环停在中途, 线上仍欠着后面每个请求各一条应答。两种情形下,未读的应答都读不回也扔不掉:resync() 只清解码缓冲,清不掉线上字节。此后再用同一个 Client 发命令,读到的会是这批的残留应答。 只能丢弃该 Client 并重建连接;批次越长,这个窗口越大。这也让「先全校验、再开始写」多了一层 含义:校验拦下的批次,一个字节都还没出去,连接是干净的。
    • noreply 的错误会错位:服务器对 noreply 命令仍然可能返回错误行(例如 CLIENT_ERROR bad data chunk、SERVER_ERROR out of memory)。send 不读这些字节,它们会留在 缓冲区里,被下一条 execute 当成自己的响应,从而报出位置不对的 Malformed 或 Remote。 文本协议的 noreply 只抑制正常应答,并不抑制错误;需要严格的错误归属时请用 execute。 混进 pipeline 时症状更隐蔽:多出来的错误行没有对应槽位,会顶替掉后面某条命令的应答,让整批 结果整体错位一格,pipeline 返回的数组看上去仍然“长度正确”。因此把 noreply 与有应答的 命令混在同一批里时,请只把 noreply 当作“我不关心它成功与否”的加速手段,不要依赖批次结果的 下标与命令严格对应;需要严格归属时,把 noreply 命令和其余命令分成两批(或改用 execute)。
    • 失步要靠调用方恢复:Decoder 只抛出 ProtocolError::Desynchronised(limit, buffered), 不会自己丢弃残片;要先显式 Client::resync()(或直接操作 Decoder::resync(),或另建 Client)才能重新同步,在此之前缓冲区里的字节始终还在。Remote 不属于失步:出错的那一行已经 被消费掉了,连接可以直接继续用;只有 Malformed 会原样留在缓冲里(没人知道下一个响应从哪里 开始),需要 resync() 才能继续。
    • 有字节上限,没有时间上限:DEFAULT_BUFFER_LIMIT(8 MiB)拦的是“缓冲无限增长”, 不是“响应迟迟不来”。Connection::read 阻塞多久完全由实现决定,本库不做超时。同一个上限 也界定了 VALUE 头里 <bytes> 的合法范围:块要连同头行、块尾的 \r\n 与收尾的 END 行 一起装进缓冲区,装不下的声明会因为永远等不到数据而当场判为 Malformed(见第五节)。 因此服务端若配置了比当前上限更大的单条 item 上限,默认配置下本库无法表达——需要放宽带块时 用 Decoder::with_limit / Client::with_limit。
    • 仍未覆盖的协议:二进制协议、meta 协议(mg/ms/md/ma)、SASL 认证、UDP 传输、 压缩值等都不支持;文本协议这边的管理命令覆盖 stats [<section>](段名本身可带参数, 如 stats detail on)、cache_memlimit 与 slabs reassign/automove,lru_crawler、 lru tuner、shutdown 等仍未支持。
    • 校验不做数值范围检查:flags、exptime、cas 的取值本身不校验,exptime 为负会直接 编码成 -1 交给服务器裁决;真正越界的是解码侧——parse_decimal 在 u64 上界之外报 Malformed 而不是静默回绕,parse_decimal_int 对超过 i32 上界的字段同样报错。
    • 一次取回的 VALUE 块全部驻留内存:decode_value_blocks 已由递归改为循环,不再随块数 加深调用栈,但一个响应里的全部块仍会同时构造出来,缓冲区也会持有整段字节直到 next() 消费掉。
    • 计数器命令对非数字值:memcached 会回 CLIENT_ERROR cannot increment or decrementnon-numeric value,在本库中表现为 ProtocolError::Remote(Client, ...)。
    • quit 不读响应:这是协议约定(服务器不回复);若调用方在 quit 之后继续用同一 Client,行为未定义。pipeline 因此在校验阶段就拒绝 quit,避免把“后面写出去的请求永远 等不到应答”这种状态带到运行期。
    • ScriptedConnection 仅用于测试:它在预置的 chunk 回放完之后返回空字节,真实实现(socket 等)需要自行 实现 Connection 并遵守同样的约定——空结果表示对端关闭。

    Connection

    pub(open) trait Connection {
    fn write(Self, BytesView) -> Unit raise TransportError
    fn read(Self, Int) -> Bytes raise TransportError
    fn close(Self) -> Unit raise TransportError
    }

    A duplex byte channel.

    ProtocolError

    pub suberror ProtocolError {
    Remote(RemoteErrorKind, String)
    Malformed(String)
    Desynchronised(Int, Int)
    } derive(
    Debug
    )

    Raised when a response cannot be decoded.

    TransportError

    pub(all) suberror TransportError {
    Io(String)
    } derive(
    Debug
    )

    Failure raised while moving bytes to or from the server.

    The variants stay constructible outside the package: a Connection implementation living in another package has to be able to raise them.

    Client

    pub struct Client {
    conn : &Connection
    decoder : Decoder
    }

    A client bound to a single connection.

    Client::add

    fn Client::add(self : Client, key : String, value : Bytes, flags? : Int, exptime? : Int) -> Status raise

    add: store value under key only if the key does not exist yet.

    flags and exptime default to 0; pass either to override.

    Client::append

    fn Client::append(self : Client, key : String, value : Bytes, flags? : Int, exptime? : Int) -> Status raise

    append: append value to the item stored under key.

    flags and exptime default to 0; pass either to override.

    Client::cache_memlimit

    fn Client::cache_memlimit(self : Client, megabytes : Int) -> Status raise

    cache_memlimit <megabytes>: cap the memory the server uses for items.

    Client::cas

    fn Client::cas(self : Client, key : String, value : Bytes, cas : UInt64, flags? : Int, exptime? : Int) -> Status raise

    cas: store value under key only if its CAS token is still cas.

    flags and exptime default to 0; pass either to override.

    Client::decr

    fn Client::decr(self : Client, key : String, delta : UInt64) -> UInt64 raise

    decr: subtract delta from the counter stored under key.

    Client::delete

    fn Client::delete(self : Client, key : String) -> Status raise

    delete: remove key.

    Client::execute

    fn Client::execute(self : Client, request : Request) -> Response raise

    Send request and return the response it produces.

    The reply is read whole before it is returned, so a response split across several packets is reassembled transparently.

    request has to be one the server answers; a command whose reply is suppressed by noreply has nothing to read, and waiting for it would block until the connection died. Those go through [Client::send], and a batch of them through [Client::pipeline].

    Client::flush_all

    fn Client::flush_all(self : Client, delay : Int?) -> Status raise

    flush_all: invalidate every item after delay seconds.

    None flushes immediately.

    Client::gat

    fn Client::gat(self : Client, keys : Array[String], exptime : Int) -> Array[RetrievedValue] raise

    gat: fetch keys and reset the expiry of every hit to exptime.

    Client::gats

    fn Client::gats(self : Client, keys : Array[String], exptime : Int) -> Array[RetrievedValue] raise

    gats: like [Client::gat], but each block carries its CAS token.

    Client::get

    fn Client::get(self : Client, keys : Array[String]) -> Array[RetrievedValue] raise

    get for one or more keys.

    Client::gets

    fn Client::gets(self : Client, keys : Array[String]) -> Array[RetrievedValue] raise

    gets for one or more keys, including each item's CAS token.

    Client::incr

    fn Client::incr(self : Client, key : String, delta : UInt64) -> UInt64 raise

    incr: add delta to the counter stored under key.

    Client::new

    fn Client::new(conn : &Connection) -> Client

    Wrap an open connection.

    Client::pipeline

    fn Client::pipeline(self : Client, requests : Array[Request]) -> Array[Response?] raise

    Run requests as one pipeline: write every request, then read one reply back for each request the server answers.

    The result holds one element per request, in the order the requests were given: Some(response) for a request that has a reply, and None for one whose reply the protocol suppresses, which is a mutation carrying noreply. Pairing is therefore the index, and the question it asks is [Request::expects_reply], so the caller never counts replies by hand.

    Writing the whole batch before reading any of it is where the round-trip saving comes from: the server works on the requests behind the one whose reply is travelling back. Nothing is validated halfway either - every request is checked before the first byte goes out, so a batch holding a bad request leaves the connection untouched.

    quit is refused wherever it appears in a batch. The server stops answering once it reads one, so a request behind it would be dropped with nothing to report the loss, and closing is [Client::quit]' job anyway.

    A Remote failure is itself a whole response line, so the framing is intact and the server still answers every remaining request; the replies after it are read and dropped before the error is raised, because leaving them in the stream would hand them to the next call as its own answers. Their content is lost along the way, so a batch whose every reply has to be accounted for is better run as one [Client::execute] per request.

    A failure part way through the batch leaves the connection spent, and the batch stops there. Either the write itself failed, in which case the requests that did go out are answered by the server regardless, or a reply could not be framed at all, in which case the loop stopped before reading the rest. Both leave replies on the wire that no call here will take back: a later call on the same [Client] would read them as its own answers, and [Client::resync] only clears the decoder, never the wire. The owner has to drop it and build a new one.

    An empty batch writes nothing, reads nothing and returns nothing: building the list request by request is a normal way to use this, and the empty list has no command to send.

    Client::prepend

    fn Client::prepend(self : Client, key : String, value : Bytes, flags? : Int, exptime? : Int) -> Status raise

    prepend: prepend value to the item stored under key.

    flags and exptime default to 0; pass either to override.

    Client::quit

    fn Client::quit(self : Client) -> Unit raise

    quit: ask the server to hang up, then release the local channel.

    The server sends no reply to quit, so nothing is read back. The channel is closed even when the command cannot be written, so a failed send does not leak it; the send error is re-raised afterwards. A failure of that closing is swallowed, because the send error is the one that explains why the connection is being given up in the first place.

    Client::replace

    fn Client::replace(self : Client, key : String, value : Bytes, flags? : Int, exptime? : Int) -> Status raise

    replace: store value under key only if the key already exists.

    flags and exptime default to 0; pass either to override.

    Client::resync

    fn Client::resync(self : Client) -> Unit

    Drop every byte the decoder still holds.

    This is the client's handle on [Decoder::resync]: once a stream has lost its framing ([ProtocolError::Desynchronised]) and is known to have restarted - typically after a reconnect - the buffered fragment has to go before the next [Client::execute]. Calling it on a healthy stream discards bytes that were never consumed.

    Client::send

    fn Client::send(self : Client, request : Request) -> Unit raise

    Write request without reading an answer back.

    Only accepts the commands that produce no reply: quit, and a mutation that carries noreply. Anything else has to go through [Client::execute], otherwise its reply would stay in the stream and be mistaken for the answer to the next command.

    Client::set

    fn Client::set(self : Client, key : String, value : Bytes, flags? : Int, exptime? : Int) -> Status raise

    set: store value under key, ignoring any existing value.

    flags and exptime default to 0; pass either to override.

    Client::slabs_automove

    fn Client::slabs_automove(self : Client, mode : Int) -> Status raise

    slabs automove <mode>: set the slab automover - 0 off, 1 on, 2 aggressive.

    Client::slabs_reassign

    fn Client::slabs_reassign(self : Client, src : Int, dst : Int) -> Status raise

    slabs reassign <src> <dst>: move a slab page from class src to class dst. A src of -1 lets the server pick the source slab itself.

    Client::stats

    fn Client::stats(self : Client) -> Array[StatEntry] raise

    stats: the general statistics of the server, one entry per STAT line.

    Client::stats_of

    fn Client::stats_of(self : Client, section : String) -> Array[StatEntry] raise

    stats <section>: one section of the server statistics, such as items, slabs or settings.

    A section nests its dimension into the entry name, so stats items yields entries named items:1:number and friends.

    section may be several words separated by single spaces, which is how the server spells the sections that take an argument: stats detail on, stats cachedump <slab> <limit>.

    Client::store

    fn Client::store(self : Client, op : StorageOp, key : String, value : Bytes, flags : Int, exptime : Int, cas : UInt64?) -> Status raise

    Run a storage command with explicit flags, expiry and CAS token.

    Client::touch

    fn Client::touch(self : Client, key : String, exptime : Int) -> Status raise

    touch: reset the expiry of key to exptime without fetching it.

    Client::verbosity

    fn Client::verbosity(self : Client, level : Int) -> Status raise

    verbosity <level>: change how much the server logs.

    Client::version

    fn Client::version(self : Client) -> String raise

    version: the version string the server reports.

    Client::with_limit

    fn Client::with_limit(conn : &Connection, limit : Int) -> Client

    Wrap an open connection with a decoder that accepts limit buffered bytes before it declares the stream out of sync.

    Decoder

    pub struct Decoder {
    buffer : Bytes
    limit : Int
    }

    Incremental decoder for server responses.

    A single read may deliver a partial response, and a single read may deliver several responses at once; feeding bytes and pulling complete responses keeps the two concerns apart.

    The buffered bytes are capped by limit: a stream that never completes a response is a stream that lost its framing, and waiting for more of it only grows the buffer. [Decoder::next] reports that as [ProtocolError::Desynchronised], and [Decoder::resync] clears the buffer.

    Decoder::buffered

    fn Decoder::buffered(self : Decoder) -> Int

    Number of buffered bytes that have not been consumed yet.

    Decoder::feed

    fn Decoder::feed(self : Decoder, data : BytesView) -> Unit

    Append freshly received bytes to the decoder.

    Decoder::limit

    fn Decoder::limit(self : Decoder) -> Int

    Buffer size this decoder accepts, in bytes.

    Decoder::new

    fn Decoder::new() -> Decoder

    A decoder that accepts up to DEFAULT_BUFFER_LIMIT buffered bytes.

    Decoder::next

    fn Decoder::next(self : Decoder) -> Response? raise ProtocolError

    Decode the next complete response.

    Returns None when the buffered bytes are only a prefix of a response, in which case the caller should feed more data. The decoded bytes are consumed from the buffer, so responses can be pulled in a loop.

    A Remote failure is a whole response of its own, and it is consumed before it is raised: the reply it reports has been answered, so the buffer moves past it. Were the line left where it was, the same error would be raised for every response asked for afterwards, and a client could never get back to the replies the server did send.

    Raises [ProtocolError::Desynchronised] instead of returning None when the buffered bytes already exceed the decoder's limit: a response that has not completed by then is not a response, and waiting for the rest of it would only grow the buffer without bound.

    Raises [ProtocolError::Malformed] when a VALUE header declares a block larger than that same limit, because such a block could never be buffered whole; the header is refused before any of the block is kept, so the buffer does not have to fill up before the stream is called lost. A malformed line is not consumed, unlike a Remote one: the bytes could not be read as a response at all, so where the next response starts is exactly what is no longer known - that is what [Decoder::resync] is for.

    Decoder::resync

    fn Decoder::resync(self : Decoder) -> Unit

    Drop every buffered byte.

    Once [Decoder::next] reports [ProtocolError::Desynchronised] the buffer holds a fragment that will never complete, so the bytes in front of the next response have to be discarded before decoding can resume. Only call this on a stream whose framing is known to have restarted, such as after the connection was re-established.

    Decoder::with_limit

    fn Decoder::with_limit(limit : Int) -> Decoder

    A decoder that accepts up to limit buffered bytes.

    RemoteErrorKind

    pub enum RemoteErrorKind {
    Generic
    Client
    Server
    } derive(Eq,
    Debug
    )

    Kind of error reported by the server on a *_ERROR response line.

    Request

    pub(all) enum Request {
    Storage(op~ : StorageOp, key~ : String, flags~ : Int, exptime~ : Int, value~ : Bytes, cas~ : UInt64?, noreply~ : Bool)
    Get(keys~ : Array[String], with_cas~ : Bool)
    Gat(keys~ : Array[String], exptime~ : Int, with_cas~ : Bool)
    Delete(key~ : String, noreply~ : Bool)
    Incr(key~ : String, delta~ : UInt64, noreply~ : Bool)
    Decr(key~ : String, delta~ : UInt64, noreply~ : Bool)
    Touch(key~ : String, exptime~ : Int, noreply~ : Bool)
    Version
    Stats(sub~ : String?)
    FlushAll(delay~ : Int?, noreply~ : Bool)
    Verbosity(level~ : Int, noreply~ : Bool)
    CacheMemlimit(megabytes~ : Int, noreply~ : Bool)
    SlabsReassign(src~ : Int, dst~ : Int, noreply~ : Bool)
    SlabsAutomove(mode~ : Int, noreply~ : Bool)
    Quit
    } derive(Eq,
    Debug
    )

    A single memcached text protocol request.

    Every command the server acknowledges carries a noreply flag: set it to send the command without waiting for the answer. [Request::expects_reply] reports that decision, so the client knows whether there is anything to read back.

    Request::expects_reply

    fn Request::expects_reply(self : Request) -> Bool

    Whether the server answers self.

    quit is never answered, and a mutation that carries noreply suppresses its terminal line, so both leave the connection without a response to read.

    Request::to_bytes

    fn Request::to_bytes(self : Request) -> Bytes

    Encode self into the bytes to write to the server.

    Response

    pub enum Response {
    Values(Array[RetrievedValue])
    Status(Status)
    Counter(UInt64)
    Version(String)
    Stats(Array[StatEntry])
    } derive(Eq,
    Debug
    )

    A decoded server response.

    RetrievedValue

    pub struct RetrievedValue {
    key : String
    flags : Int
    data : Bytes
    cas : UInt64?
    } derive(Eq,
    Debug
    )

    One VALUE block of a retrieval response.

    ScriptedConnection

    pub struct ScriptedConnection {
    chunks : Array[Bytes]
    index : Int
    pending : Bytes
    written :
    Buffer

    }

    An in-memory [Connection] that replays a canned server transcript.

    Everything the client writes is recorded, so the connection doubles as a spy. It stands in for a real socket in the examples and tests, since the targets usable here cannot open one.

    ScriptedConnection::new

    fn ScriptedConnection::new(chunks : Array[Bytes]) -> ScriptedConnection

    Build a connection whose reads replay chunks in order.

    Each element is handed out by a separate read (truncated to the requested size), which makes it easy to force the client through partial responses.

    ScriptedConnection::written

    fn ScriptedConnection::written(self : ScriptedConnection) -> Bytes

    Every byte the client has written so far.

    StatEntry

    pub struct StatEntry {
    name : String
    value : String
    } derive(Eq,
    Debug
    )

    One STAT <name> <value> line of a stats response.

    The value is kept as text: memcached reports both numbers and free-form strings (STAT version 1.6.21), and a section such as stats items nests its dimension into the name (items:1:number).

    Status

    pub(all) enum Status {
    Stored
    NotStored
    Exists
    NotFound
    Deleted
    Touched
    Ok
    } derive(Eq,
    Debug
    )

    Terminal status line of a mutation command.

    StorageOp

    pub(all) enum StorageOp {
    Set
    Add
    Replace
    Append
    Prepend
    Cas
    } derive(Eq,
    Debug
    )

    Storage commands of the text protocol.