TRA and native values · dive 01
Bitmaps on strings
Four TRA commands read and write the bits of an ordinary string key, the
high bit of byte 0 first. TRA.SETBIT is the one that writes, and it replicates itself
verbatim; every key type but a string answers WRONGTYPE.
TRA.SETBIT, TRA.GETBIT,
TRA.STRLEN and TRA.BITCOUNT work on the same bytes the
server’s own SETBIT and BITCOUNT work on. They parse their
arguments themselves, with refusals of their own, and they read the key through one function that classifies the
raw key type, so a key type newer than the Rust binding is a WRONGTYPE reply and not a
server abort. Send one call to one key to read the reply, the bytes after and what reaches the replicas; then
count a range in bytes or in bits.
High bit first
A TRA bitmap is an ordinary string. Offset N names bit N counted from the high bit of
byte 0: offset 0 is the 0x80 bit of the first byte, offset 7 its
0x01 bit, offset 8 the high bit of the second byte. A read at or past the end of the
string reads 0. In the string control of a recorded scratch run,
TRA.SETBIT sk 7 1 on a five-byte string replies 0 and GET sk then reads iello: offset 7 is the low bit of byte 0, so that byte was h (0x68) and is now i (0x69).
The offset domain is 0 through 232 − 1. An offset is written in ASCII digits only: no sign,
no space, at most 32 characters, leading zeros allowed, so 0008 is offset 8. Anything
else, or a value past the domain, is ERR TRA invalid-offset. A bit is exactly
0 or 1; anything else is
ERR TRA invalid-argument.
// trafikey — tra::bitmap, the offset and bit parsers and the MSB-first read // the canonical offset domain ends at 2^32 - 1 const MAX_OFFSET: u64 = u32::MAX as u64; /// Offsets: ASCII decimal digits only (leading zeroes allowed), within 0..=2^32-1. fn parse_offset(raw: &[u8]) -> Option<u64> { if raw.is_empty() || raw.len() > 32 { return None; } let mut value: u64 = 0; for &c in raw { if !c.is_ascii_digit() { return None; } value = value.checked_mul(10)?.checked_add(u64::from(c - b'0'))?; } (value <= MAX_OFFSET).then_some(value) } /// Bit values are exactly `0` or `1`. fn parse_bit(raw: &[u8]) -> Option<u8> { match raw { b"0" => Some(0), b"1" => Some(1), _ => None, } } // … /// MSB-first bit read; anything at or past the logical length is zero. fn get_bit(bytes: &[u8], offset: u64) -> u8 { match bytes.get((offset >> 3) as usize) { Some(byte) => (byte >> (7 - (offset & 7))) & 1, None => 0, } }
Offset N is bit 7 − (N mod 8) of byte N ÷ 8, the layout the server’s own bit commands use, over an offset grammar the module checks itself.
The one write
TRA.SETBIT key offset bit runs in a fixed order. The server checks the arity, four
words. The handler parses the offset, then the bit, then reads the key once in read mode: a missing key reads as
no string, so the previous bit is 0 and the string must grow. If the bit already holds the value and the string
is long enough, the handler replies with the previous bit and stops: no write, no replication, no
WATCH touch.
Otherwise it opens the key again for writing. A NULL write handle is ERR TRA limit.
A string shorter than the byte the offset falls in grows to that byte with
StringTruncate, zero-filled, and a growth the host refuses is
ERR TRA limit too; on a missing key the growth creates the string. The bit is then set
or cleared in place through StringDMA; a change in place keeps the key’s TTL. The handler
closes the key, raises a setbit keyspace event in the string class, has the server
replicate the command verbatim and replies with the previous bit.
// trafikey — tra::bitmap::setbit, the one TRA command that writes a key pub(crate) fn setbit(ctx: &Context, args: Vec<ValkeyString>) -> ValkeyResult { // The host enforces arity 4 before the handler runs; the else arm is its arity reply. let [_, name, offset, bit] = args.as_slice() else { return Err(ValkeyError::WrongArity); }; let offset = parse_offset(offset.as_slice()).ok_or(INVALID_OFFSET)?; let bit = parse_bit(bit.as_slice()).ok_or(INVALID_ARGUMENT)?; let index = (offset >> 3) as usize; let (previous, grows) = with_string(ctx, name, |bytes| match bytes { None => (0, true), Some(b) => (get_bit(b, offset), index >= b.len()), })?; // Equal bit without growth is not a logical mutation: no write, no replication, no WATCH touch. if !grows && previous == bit { return Ok(ValkeyValue::Integer(i64::from(previous))); } let key = writable(raw::open_key( ctx.ctx, name.inner, raw::KeyMode::READ | raw::KeyMode::WRITE, ))?; let mut length = 0; // StringTruncate only grows here (zero-filled); in-place mutation keeps the TTL. The preload // gate refuses the load without it; an absent API is the host refusing to grow. raw::string_dma(key, &mut length, raw::KeyMode::READ); if length <= index { // SAFETY: `RedisModule_StringTruncate` is a C global that `RedisModule_InitAPI` wrote // during OnLoad; TRA.SETBIT runs on the main thread after it and nothing writes it again. let grown = unsafe { raw::RedisModule_StringTruncate }.is_some_and(|truncate| { // SAFETY: `VM_StringTruncate` dereferences `key`: it is the non-null // write-mode handle `writable` accepted, not yet closed. (unsafe { truncate(key, index + 1) }) == raw::Status::Ok as i32 }); if !grown { raw::close_key(key); return Err(LIMIT); } } let ptr = raw::string_dma(key, &mut length, raw::KeyMode::WRITE).cast::<u8>(); if ptr.is_null() || length <= index { raw::close_key(key); return Err(LIMIT); } let mask = 1u8 << (7 - (offset & 7)); // SAFETY: `ptr` starts the `length`-byte write-mode StringDMA buffer of the open key and // `index < length` (checked above), so the offset stays inside that one allocation. let target = unsafe { ptr.add(index) }; // SAFETY: `target` is an in-bounds byte of the write-mode DMA buffer, valid until close_key; // no key write runs before close_key, so this is the only access. let byte = unsafe { &mut *target }; if bit == 1 { *byte |= mask; } else { *byte &= !mask; } raw::close_key(key); ctx.notify_keyspace_event(NotifyEvent::STRING, "setbit", name); // … four lines: a clippy lint expectation for the next call, with its reason ctx.replicate_verbatim(); Ok(ValkeyValue::Integer(i64::from(previous))) }
One call on one key · pick a key and a call
checked: arity 4 (the server) → offset 7 → bit 1 → raw key type: string → previous bit 0
reply: (integer) 0
bytes after: 81 00 · 2 bytes
written: yes, in place
replicated: TRA.SETBIT k 7 1, verbatim
keyspace event: setbit, string class
The replicas and the AOF receive the call as the client sent it:
TRA.SETBIT k 0008 1 arrives with its leading zeros. No EMQ
handler does that. The EMQ adapter calls the server itself and lets each write it makes
replicate as that write, and its own file says so.
// trafikey — emq/host.rs, the EMQ adapter's replication rule, for contrast //! So this file calls `RedisModule_Call` itself, with `"vE"` for reads and `"v!E"` for writes, //! and classifies the raw reply type: `!` replicates each write as its effect, and every `!` call //! of one command reaches the AOF and replicas inside one MULTI/EXEC. Never `ReplicateVerbatim`, //! never the `3`, `0`, `X` or `K` flags.
The comment overstates one point: Valkey wraps the writes in one MULTI/EXEC only when a command makes more than one, and a single write travels alone.
A changed bit is one write and one replicated command; an unchanged bit inside the string is a reply and nothing else.
Counting a range
TRA.BITCOUNT takes the shapes of the server’s own BITCOUNT.
The key alone counts the set bits of the whole string. The key with a start and an end counts an inclusive
window, and one more word after the end picks the unit: BIT or
BYTE, in any letter case, with BYTE when it is absent. A start
alone counts bytes from the start to the last byte. Every other shape is
ERR TRA invalid-argument: a word after the unit, an unknown unit. So is an endpoint that
the server’s integer parser refuses. An endpoint is 0, or an optional leading minus
and a first digit from 1 to 9, then digits, inside the signed 64-bit range: no plus sign, no space, no leading
zero and no -0. Where BITCOUNT refuses with
ERR value is not an integer or out of range or ERR syntax error,
TRA.BITCOUNT refuses with its own text. The arguments are parsed before the key is read,
so a malformed call is refused even on a missing key or a hash.
// trafikey — tra::bitmap::bitcount, the argument shapes, then the count pub(crate) fn bitcount(ctx: &Context, args: Vec<ValkeyString>) -> ValkeyResult { let (name, range) = match args.as_slice() { [_, name] => (name, None), [_, name, start, rest @ ..] if rest.len() <= 2 => { let start = parse_i64(start.as_slice()).ok_or(INVALID_ARGUMENT)?; let (end, bit_unit) = match rest { [] => (None, false), [end, unit @ ..] => { let end = parse_i64(end.as_slice()).ok_or(INVALID_ARGUMENT)?; let bit_unit = match unit.first().map(|u| u.as_slice()) { None => false, Some(u) if u.eq_ignore_ascii_case(b"BIT") => true, Some(u) if u.eq_ignore_ascii_case(b"BYTE") => false, Some(_) => return Err(INVALID_ARGUMENT), }; (Some(end), bit_unit) } }; (name, Some((start, end, bit_unit))) } _ => return Err(INVALID_ARGUMENT), }; with_string(ctx, name, |b| { let n = count(b.unwrap_or(&[]), range); ValkeyValue::Integer(n.min(i64::MAX as u64) as i64) }) } /// TRA.BITCOUNT's answer for a parsed range: no range counts the whole value, and a start /// without an end ends at the last byte. fn count(bytes: &[u8], range: Option<(i64, Option<i64>, bool)>) -> u64 { let last_byte = bytes.len() as i64 - 1; range.map_or_else( || popcount(bytes), |(start, end, bit_unit)| count_range(bytes, start, end.unwrap_or(last_byte), bit_unit), ) }
The window follows the server’s BITCOUNT step for
step. Two negative endpoints with the start past the end count nothing. Otherwise a negative endpoint counts back
from the end in the chosen unit, in 128-bit integers, so no endpoint overflows. An endpoint still below zero is
raised to 0, the end is lowered to the last unit, and a start past the end counts nothing. So a window that ends
before the string counts its first unit, as BITCOUNT does: on
80 00, -100 -50 counts byte 0 and replies 1. A
BYTE window counts whole bytes; a BIT window masks the first
and last byte and counts whole bytes between them.
// trafikey — tra::bitmap::count_range, the inclusive window over the string // two negative endpoints with start > end are empty; negatives count back from the end, // both are raised to 0 and the end lowered to the last unit, and start > end is empty fn count_range(bytes: &[u8], start: i64, end: i64, bit_unit: bool) -> u64 { if start < 0 && end < 0 && start > end { return 0; } let domain: i128 = bytes.len() as i128 * if bit_unit { 8 } else { 1 }; let mut s = i128::from(start); let mut e = i128::from(end); if s < 0 { s += domain; } if e < 0 { e += domain; } if s < 0 { s = 0; } if e < 0 { e = 0; } if e >= domain { e = domain - 1; } if s > e { return 0; } // From here 0 <= s <= e < domain, so both windows below lie inside `bytes`. if !bit_unit { return bytes.get(s as usize..=e as usize).map_or(0, popcount); } let (first, last) = ((s >> 3) as usize, (e >> 3) as usize); let head: u8 = 0xFF >> (s & 7); let tail: u8 = 0xFF << (7 - (e & 7)); match bytes.get(first..=last).unwrap_or_default() { [] => 0, [only] => u64::from((only & head & tail).count_ones()), [lo, middle @ .., hi] => { u64::from((lo & head).count_ones()) + popcount(middle) + u64::from((hi & tail).count_ones()) } } }
Count a range · pick a string and the arguments
shape: key start end unit · unit BIT
domain: 2 bytes × 8 = 16 bits
window: start 0, end 0 · bits 0..0
reply: (integer) 1
Arguments first, then the key: a malformed range is refused before the string is read, and a well-formed range on a string always counts, an empty window counting 0.
Strings only
All four data commands read the key through one function, with_string. It opens the
key in read mode, reads the key’s raw type value from the host and classifies it: the empty type is a missing
key, the string type is readable, and every other value answers WRONGTYPE. Lists,
sets, hashes, sorted sets, streams and module types answer it, and so does the path hash that Valkey 9.2 adds as
type 8, a value the Rust binding’s key-type enum does not reach.
// trafikey — tra::bitmap::with_string, the one reader all four commands share /// Reads argv[1] as an ordinary string: Ok(None) when missing, WRONGTYPE for any other type, /// including types this crate does not know. fn with_string<T>( ctx: &Context, name: &ValkeyString, f: impl FnOnce(Option<&[u8]>) -> T, ) -> Result<T, ValkeyError> { // The preload gate refuses the load without KeyType; an absent API is the host refusing. let Some(key_type) = (unsafe { raw::RedisModule_KeyType }) else { return Err(LIMIT); }; // Read mode, as the crate's `open_key`. A missing key is NULL, and KeyType(NULL) is EMPTY. let key = raw::open_key(ctx.ctx, name.inner, raw::KeyMode::READ); // SAFETY: `key` is the handle open_key just returned, not yet closed; NULL reads as EMPTY. let out = match classify(unsafe { key_type(key) }) { Kind::Missing => Ok(f(None)), Kind::String => { let mut length = 0; let ptr = raw::string_dma(key, &mut length, raw::KeyMode::READ).cast::<u8>(); if ptr.is_null() { Err(ValkeyError::WrongType) } else { // SAFETY: StringDMA returned a non-null pointer to `length` bytes of this open // string key; they stay valid until close_key below, and `f` runs before it. Ok(f(Some(unsafe { std::slice::from_raw_parts(ptr, length) }))) } } Kind::Other => Err(ValkeyError::WrongType), }; raw::close_key(key); out }
The classification is the module’s own because the binding’s conversion
unwraps: handed type 8, it panics, and the release profile’s panic = "abort"
turns the panic into a server abort. A scratch run on a Valkey 9.2.0-rc1 server recorded exactly that before the
fix: TRA.STRLEN on a path hash key ended the server with exit 134. After the fix a run on
the same release candidate answers all five data calls on that key, TRA.BITCOUNT with and without a
range included, with WRONGTYPE, the key’s type stays
pathhash and the server answers PING. Both runs are recorded on scratch servers, apart from the stand’s nodes.
A second function, writable, guards a second Valkey 9.2 hazard: a write-mode open
can return NULL while a background iteration holds the entry. with_string opens keys
read-only, so the hazard reaches only the write path. TRA.SETBIT refuses such a handle with
ERR TRA limit before it reaches StringDMA. That case needs a
startup option that is off by default and a live iteration, so a unit test covers it, not a server run.
// trafikey — tra::bitmap, the raw key-type classification and the write-handle guard // what a key holds, read from the raw RedisModule_KeyType value: /// Never through the crate's `KeyType: From<c_int>`: it unwraps, and a type newer than the crate /// (Valkey 9.2 `PATH_HASH` = 8) panics, which `panic = "abort"` turns into a server abort. #[derive(Debug, PartialEq, Eq)] enum Kind { Missing, String, Other, } fn classify(raw_type: c_int) -> Kind { match raw_type { t if t == raw::REDISMODULE_KEYTYPE_EMPTY as c_int => Kind::Missing, t if t == raw::REDISMODULE_KEYTYPE_STRING as c_int => Kind::String, _ => Kind::Other, } } // the second Valkey 9.2 hazard: a write-mode open can return NULL while a background // iteration holds the entry, so a NULL handle never reaches StringDMA or StringTruncate fn writable(key: *mut raw::RedisModuleKey) -> Result<*mut raw::RedisModuleKey, ValkeyError> { if key.is_null() { Err(LIMIT) } else { Ok(key) } }
// trafikey — the test that pins the mapping, Valkey 9.2's type 8 included // every raw key type maps without a panic; only EMPTY and STRING are readable #[test] fn raw_key_types_classify_without_panic() { assert_eq!(classify(0), Kind::Missing); assert_eq!(classify(1), Kind::String); // LIST..STREAM (2..=7), Valkey 9.2 PATH_HASH (8), and values no server defines yet. for t in [2, 3, 4, 5, 6, 7, 8, 9, 99, -1, c_int::MIN, c_int::MAX] { assert_eq!(classify(t), Kind::Other, "{t}"); } assert_eq!(raw::REDISMODULE_KEYTYPE_EMPTY, 0); assert_eq!(raw::REDISMODULE_KEYTYPE_STRING, 1); }
The workspace keeps a lint list for the same trap. Its
clippy.toml carries the Trafikist profile, written over the shared rstyler preset by
cargo rstyler --profile trafikist. Under disallowed-methods,
packs keyed to the binding’s version name the calls to avoid, each with its reason. The key-type pack
names the typed-key calls that reach the conversion and the calls that open such keys. The other packs name
calls that abort on a NUL byte or on a reply the binding cannot convert, and the two replication calls, which
discard the host’s status. A hit is a lint error, not a warning, and a deliberate use carries a reasoned
expect, as the verbatim replication in TRA.SETBIT does.
# trafikey — clippy.toml, the Trafikist profile's key-type pack # rstyler: Profile trafikist disallowed-methods = [ # pack: valkey-module key types, keyed to valkey-module 0.1.14 { path = "valkey_module::key::ValkeyKey::key_type", reason = "aborts on a Valkey 9.2 Path Hash key; read the raw key type" }, { path = "valkey_module::key::ValkeyKey::get_value", reason = "verify_type aborts on a Valkey 9.2 Path Hash key" }, { path = "valkey_module::key::ValkeyKeyWritable::key_type", reason = "aborts on a Valkey 9.2 Path Hash key; read the raw key type" }, { path = "valkey_module::key::ValkeyKeyWritable::get_value", reason = "verify_type aborts on a Valkey 9.2 Path Hash key" }, { path = "valkey_module::key::ValkeyKeyWritable::set_value", reason = "verify_type aborts on a Valkey 9.2 Path Hash key" }, { path = "valkey_module::key::verify_type", reason = "aborts on a Valkey 9.2 Path Hash key" }, # … four more: Context::open_key, open_key_writable and their _with_flags forms # … three more packs: NUL bytes, call replies and replication ]
A string, a missing key or WRONGTYPE: the classification reads the raw type
value, so a key type the binding does not define is a reply, not an abort.
Pattern & implementation
Where Redis Patterns Applied sets and counts flags in a string with the server’s own bit commands, this dive reads four module commands that do the same work on the same bytes.
One bit per entity in a string, set with SETBIT and read with
GETBIT, and a population count over the string or a window of it.
One-bit flags and
BITCOUNT aggregates teach it.
TRA.SETBIT, GETBIT, STRLEN and
BITCOUNT over the same high-bit-first string, with their own offset grammar, the
server’s range rules, a no-op that writes nothing, one write replicated verbatim, and
WRONGTYPE for every other key type.
References
Sources
- Valkey — SETBIT — the server’s bit write: the same layout, offset domain and previous-bit reply.
- Valkey — GETBIT — a read past the end reads 0.
- Valkey — BITCOUNT — the BYTE and BIT windows and their negative endpoints.
- Valkey — STRLEN — the length of a string, 0 for a missing key.
- Valkey — Modules API reference — key types, StringDMA, StringTruncate and verbatim replication.
- Valkey — Keyspace notifications — the event class a
setbitevent belongs to.
Related in this course
- TRA and native values — the module this dive opens.
- One guard, then effects — the EMQ path, replicated as effects.
- Loading the module — the load gate that refuses a host without the APIs these commands call.
- The shared ABI and bundled Lua — the next dive: a bounded count in Lua over the same strings.
- redis-patterns · One-bit flags — the near side of the door.
- redis-patterns · BITCOUNT aggregates — counting windows of a bitmap.