jonnifyechomq

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

the key k before the call
the call
key k · before: 80 00 (2 bytes) 0 1 1 0 2 0 3 0 4 0 5 0 6 0 7 1 8 0 9 0 10 0 11 0 12 0 13 0 14 0 15 0 16 · 17 · 18 · 19 · 20 · 21 · 22 · 23 · 0x81 0x00 — reply 0 · written · replicated verbatim
TRA.SETBIT k 7 1 on the string 80 00
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

the string k
TRA.BITCOUNT k …
string k · 80 00 (2 bytes) 0 1 1 0 2 0 3 0 4 0 5 0 6 0 7 0 8 0 9 0 10 0 11 0 12 0 13 0 14 0 15 0 0x80 0x00 bits 0..0: 1 set
TRA.BITCOUNT k 0 0 BIT on the string 80 00
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.

the pattern (Redis Patterns Applied)

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.

the implementation (trafikey)

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

Related in this course