use crate::storage::commitlog::schema::{
cql_fixed_len, format_table_id, is_simple_scalar_type, CommitLogSchema, SchemaSet,
};
use crate::{Error, Result};
const ITER_IS_EMPTY: u8 = 0x01;
const ITER_HAS_PARTITION_DELETION: u8 = 0x04;
const ITER_HAS_STATIC_ROW: u8 = 0x08;
const ITER_HAS_ROW_ESTIMATE: u8 = 0x10;
const ROW_END_OF_PARTITION: u8 = 0x01;
const ROW_IS_MARKER: u8 = 0x02;
const ROW_HAS_TIMESTAMP: u8 = 0x04;
const ROW_HAS_TTL: u8 = 0x08;
const ROW_HAS_DELETION: u8 = 0x10;
const ROW_HAS_ALL_COLUMNS: u8 = 0x20;
const ROW_HAS_COMPLEX_DELETION: u8 = 0x40;
const ROW_EXTENSION_FLAG: u8 = 0x80;
const CELL_IS_DELETED: u8 = 0x01;
const CELL_IS_EXPIRING: u8 = 0x02;
const CELL_HAS_EMPTY_VALUE: u8 = 0x04;
const CELL_USE_ROW_TIMESTAMP: u8 = 0x08;
const CELL_USE_ROW_TTL: u8 = 0x10;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mutation {
pub updates: Vec<PartitionUpdate>,
pub updates_complete: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PartitionUpdate {
pub table_id: [u8; 16],
pub partition_key: Vec<u8>,
pub column_names: Vec<String>,
pub has_partition_deletion: bool,
pub rows: Vec<DecodedRow>,
pub rows_decoded: bool,
}
impl PartitionUpdate {
pub fn table_id_uuid(&self) -> String {
format_table_id(&self.table_id)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecodedRow {
pub clustering: Vec<Vec<u8>>,
pub cells: Vec<DecodedCell>,
pub is_row_deletion: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecodedCell {
pub column: String,
pub value: Option<Vec<u8>>,
pub deleted: bool,
}
pub fn decode_mutation(body: &[u8], schemas: &SchemaSet) -> Result<Mutation> {
let mut c = Cursor::new(body);
let num_updates = c.uvint()?;
if num_updates > body.len() as u64 {
return Err(Error::CorruptCommitLogFrame(format!(
"mutation declares {num_updates} updates, impossible for a {}-byte body",
body.len()
)));
}
let mut updates = Vec::with_capacity(num_updates.min(1024) as usize);
let mut updates_complete = true;
for _ in 0..num_updates {
let (update, consumed_fully) = decode_partition_update(&mut c, schemas)?;
updates.push(update);
if !consumed_fully {
updates_complete = false;
break;
}
}
Ok(Mutation {
updates,
updates_complete,
})
}
fn decode_partition_update(
c: &mut Cursor<'_>,
schemas: &SchemaSet,
) -> Result<(PartitionUpdate, bool)> {
let table_id = c.take_array16()?;
let pk_len = c.uvint()? as usize;
let partition_key = c.take(pk_len)?.to_vec();
let iter_flags = c.u8()?;
let mut update = PartitionUpdate {
table_id,
partition_key,
column_names: Vec::new(),
has_partition_deletion: iter_flags & ITER_HAS_PARTITION_DELETION != 0,
rows: Vec::new(),
rows_decoded: false,
};
if iter_flags & ITER_IS_EMPTY != 0 {
update.rows_decoded = true;
return Ok((update, true));
}
let _min_timestamp = c.uvint()?;
let _min_ldt = c.uvint()?;
let _min_ttl = c.uvint()?;
let has_static = iter_flags & ITER_HAS_STATIC_ROW != 0;
if has_static {
return Ok((update, false));
}
update.column_names = read_column_names(c)?;
if update.has_partition_deletion {
return Ok((update, false));
}
if iter_flags & ITER_HAS_ROW_ESTIMATE != 0 {
let _row_estimate = c.uvint()?;
}
let schema = match schemas.get(&table_id) {
Some(s) => s,
None => return Ok((update, false)), };
match decode_rows(c, schema, &update.column_names) {
Ok(rows) => {
update.rows = rows;
update.rows_decoded = true;
Ok((update, true))
}
Err(_) => {
update.rows.clear();
update.rows_decoded = false;
Ok((update, false))
}
}
}
struct Unsupported;
type RowResult<T> = std::result::Result<T, Unsupported>;
fn decode_rows(
c: &mut Cursor<'_>,
schema: &CommitLogSchema,
column_names: &[String],
) -> RowResult<Vec<DecodedRow>> {
if !schema.clustering.is_empty() {
return Err(Unsupported);
}
if column_names
.iter()
.any(|name| !schema.column_type(name).is_some_and(is_simple_scalar_type))
{
return Err(Unsupported);
}
let mut rows = Vec::new();
loop {
let flags = c.u8().map_err(|_| Unsupported)?;
if flags & ROW_END_OF_PARTITION != 0 {
break;
}
if flags & ROW_IS_MARKER != 0 {
return Err(Unsupported);
}
if flags & ROW_EXTENSION_FLAG != 0 {
let _ext = c.u8().map_err(|_| Unsupported)?;
}
let clustering = Vec::new();
if flags & ROW_HAS_TIMESTAMP != 0 {
let _ts_delta = c.uvint().map_err(|_| Unsupported)?;
}
if flags & ROW_HAS_TTL != 0 {
let _ttl = c.uvint().map_err(|_| Unsupported)?;
let _ldt = c.uvint().map_err(|_| Unsupported)?;
}
let mut is_row_deletion = false;
if flags & ROW_HAS_DELETION != 0 {
let _mfda = c.uvint().map_err(|_| Unsupported)?;
let _ldt = c.uvint().map_err(|_| Unsupported)?;
is_row_deletion = true;
}
if flags & ROW_HAS_COMPLEX_DELETION != 0 {
return Err(Unsupported);
}
if flags & ROW_HAS_ALL_COLUMNS == 0 {
return Err(Unsupported);
}
let mut cells = Vec::with_capacity(column_names.len());
for name in column_names {
let type_name = schema.column_type(name).ok_or(Unsupported)?;
let cell = read_cell(c, name, type_name)?;
cells.push(cell);
}
rows.push(DecodedRow {
clustering,
cells,
is_row_deletion,
});
}
Ok(rows)
}
fn read_cell(c: &mut Cursor<'_>, column: &str, type_name: &str) -> RowResult<DecodedCell> {
let flags = c.u8().map_err(|_| Unsupported)?;
let deleted = flags & CELL_IS_DELETED != 0;
let expiring = flags & CELL_IS_EXPIRING != 0;
let empty = flags & CELL_HAS_EMPTY_VALUE != 0;
if flags & CELL_USE_ROW_TIMESTAMP == 0 {
let _ts_delta = c.uvint().map_err(|_| Unsupported)?;
}
let use_row_ttl = flags & CELL_USE_ROW_TTL != 0;
if (deleted || expiring) && !use_row_ttl {
let _ldt = c.uvint().map_err(|_| Unsupported)?;
}
if expiring && !use_row_ttl {
let _ttl = c.uvint().map_err(|_| Unsupported)?;
}
let value = if deleted || empty {
None
} else {
Some(read_typed_value(c, type_name)?)
};
Ok(DecodedCell {
column: column.to_string(),
value,
deleted,
})
}
fn read_typed_value(c: &mut Cursor<'_>, type_name: &str) -> RowResult<Vec<u8>> {
match cql_fixed_len(type_name) {
Some(n) => c.take(n).map(|s| s.to_vec()).map_err(|_| Unsupported),
None => {
let len = c.uvint().map_err(|_| Unsupported)? as usize;
c.take(len).map(|s| s.to_vec()).map_err(|_| Unsupported)
}
}
}
fn read_column_names(c: &mut Cursor<'_>) -> Result<Vec<String>> {
let count = c.uvint()?;
let remaining = c.remaining() as u64;
if count > remaining {
return Err(Error::CorruptCommitLogFrame(format!(
"column-names block declares {count} names, impossible for {remaining} remaining bytes"
)));
}
let mut names = Vec::with_capacity(count.min(4096) as usize);
for _ in 0..count {
let len = c.uvint()? as usize;
let bytes = c.take(len)?;
names.push(String::from_utf8_lossy(bytes).into_owned());
}
Ok(names)
}
struct Cursor<'a> {
bytes: &'a [u8],
pos: usize,
}
impl<'a> Cursor<'a> {
fn new(bytes: &'a [u8]) -> Self {
Self { bytes, pos: 0 }
}
fn remaining(&self) -> usize {
self.bytes.len().saturating_sub(self.pos)
}
fn u8(&mut self) -> Result<u8> {
let b = *self.bytes.get(self.pos).ok_or_else(short)?;
self.pos += 1;
Ok(b)
}
fn take(&mut self, n: usize) -> Result<&'a [u8]> {
let end = self.pos.checked_add(n).ok_or_else(short)?;
if end > self.bytes.len() {
return Err(short());
}
let out = &self.bytes[self.pos..end];
self.pos = end;
Ok(out)
}
fn take_array16(&mut self) -> Result<[u8; 16]> {
let s = self.take(16)?;
let mut a = [0u8; 16];
a.copy_from_slice(s);
Ok(a)
}
fn uvint(&mut self) -> Result<u64> {
let (value, consumed) =
crate::parser::vint::decode_unsigned(&self.bytes[self.pos..]).map_err(|_| short())?;
self.pos += consumed;
Ok(value)
}
}
fn short() -> Error {
Error::CorruptCommitLogFrame("mutation body ended mid-field".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::commitlog::schema::{parse_table_id, ColumnSpec};
use std::collections::HashMap;
const ALICE_BODY: &str = "01d6de7150844811f1bf5abf4cbc47cb4a040000000110fd36c1327cb48c00000203616765046e616d65012400080000001e0805616c69636501";
fn users_schema() -> CommitLogSchema {
CommitLogSchema {
keyspace: "commitlog_test".into(),
table: "users".into(),
partition_key: vec![ColumnSpec::new("id", "int")],
clustering: vec![],
columns: vec![
ColumnSpec::new("age", "int"),
ColumnSpec::new("name", "text"),
],
}
}
#[test]
fn decodes_structural_fields_without_schema() {
let body = hex(ALICE_BODY);
let m = decode_mutation(&body, &HashMap::new()).expect("decode");
assert_eq!(m.updates.len(), 1);
let u = &m.updates[0];
assert_eq!(u.table_id_uuid(), "d6de7150-8448-11f1-bf5a-bf4cbc47cb4a");
assert_eq!(u.partition_key, vec![0, 0, 0, 1]);
assert_eq!(u.column_names, vec!["age", "name"]);
assert!(!u.rows_decoded);
}
#[test]
fn decodes_cell_values_with_schema() {
let body = hex(ALICE_BODY);
let id = parse_table_id("d6de7150-8448-11f1-bf5a-bf4cbc47cb4a").unwrap();
let mut schemas: SchemaSet = HashMap::new();
schemas.insert(id, users_schema());
let m = decode_mutation(&body, &schemas).expect("decode");
assert!(m.updates_complete);
let u = &m.updates[0];
assert!(u.rows_decoded);
assert_eq!(u.rows.len(), 1);
let row = &u.rows[0];
assert!(row.clustering.is_empty());
let age = row.cells.iter().find(|c| c.column == "age").unwrap();
assert_eq!(age.value, Some(vec![0, 0, 0, 30]));
let name = row.cells.iter().find(|c| c.column == "name").unwrap();
assert_eq!(name.value.as_deref(), Some(b"alice".as_ref()));
}
#[test]
fn clustered_schema_bails_honestly_instead_of_misaligning() {
let body = hex(ALICE_BODY);
let id = parse_table_id("d6de7150-8448-11f1-bf5a-bf4cbc47cb4a").unwrap();
let mut schema = users_schema();
schema.clustering = vec![ColumnSpec::new("ck", "int")];
let mut schemas: SchemaSet = HashMap::new();
schemas.insert(id, schema);
let m = decode_mutation(&body, &schemas).expect("decode");
let u = &m.updates[0];
assert!(
!u.rows_decoded,
"clustered schema must bail to structural-only, never emit misaligned rows"
);
assert!(u.rows.is_empty());
assert_eq!(u.partition_key, vec![0, 0, 0, 1]);
}
#[test]
fn collection_column_schema_bails_honestly_instead_of_misaligning() {
let mut schema = users_schema();
schema.columns.push(ColumnSpec::new("tags", "list<text>"));
let column_names = vec!["age".to_string(), "name".to_string(), "tags".to_string()];
let mut c = Cursor::new(&[]);
let result = decode_rows(&mut c, &schema, &column_names);
assert!(
result.is_err(),
"a collection-typed column in column_names must bail, never emit misaligned rows"
);
}
#[test]
fn counter_column_no_longer_misreads_as_fixed_length() {
use crate::storage::commitlog::schema::is_simple_scalar_type;
assert!(is_simple_scalar_type("counter"));
assert_eq!(cql_fixed_len("counter"), None);
}
#[test]
fn tinyint_cell_stays_aligned_into_following_int_cell() {
let schema = CommitLogSchema {
keyspace: "commitlog_test".into(),
table: "gauges".into(),
partition_key: vec![ColumnSpec::new("id", "int")],
clustering: vec![],
columns: vec![
ColumnSpec::new("flag", "tinyint"),
ColumnSpec::new("count", "int"),
],
};
let column_names = vec!["flag".to_string(), "count".to_string()];
let bytes: Vec<u8> = [
ROW_HAS_ALL_COLUMNS,
CELL_USE_ROW_TIMESTAMP,
0x01, 0x05, CELL_USE_ROW_TIMESTAMP,
0x00,
0x00,
0x00,
0x2A,
ROW_END_OF_PARTITION,
]
.to_vec();
let mut c = Cursor::new(&bytes);
let rows = match decode_rows(&mut c, &schema, &column_names) {
Ok(rows) => rows,
Err(Unsupported) => panic!("row with a variable-length tinyint cell must decode"),
};
assert_eq!(rows.len(), 1);
let cells = &rows[0].cells;
let flag = cells.iter().find(|c| c.column == "flag").unwrap();
assert_eq!(
flag.value.as_deref(),
Some([0x05].as_ref()),
"tinyint value must be the single byte 5, not the vint length"
);
let count = cells.iter().find(|c| c.column == "count").unwrap();
assert_eq!(
count.value.as_deref(),
Some([0x00, 0x00, 0x00, 0x2A].as_ref()),
"int cell must stay aligned after the variable-length tinyint"
);
}
#[test]
fn batch_declaring_more_updates_than_present_reports_incomplete() {
let mut body = hex(ALICE_BODY);
body[0] = 0x02;
let m = decode_mutation(&body, &HashMap::new()).expect("decode");
assert_eq!(m.updates.len(), 1);
assert!(
!m.updates_complete,
"declared 2 updates but only 1 present must report updates_complete == false"
);
}
#[test]
fn static_row_bail_leaves_column_names_empty() {
let mut bytes = vec![0u8; 16]; bytes.push(0x00); bytes.push(ITER_HAS_STATIC_ROW); bytes.extend_from_slice(&[0x00, 0x00, 0x00]); let (update, consumed_fully) =
decode_partition_update(&mut Cursor::new(&bytes), &SchemaSet::new()).expect("decode");
assert!(
!consumed_fully,
"static-row path must not report full consume"
);
assert!(!update.rows_decoded);
assert!(
update.column_names.is_empty(),
"static-row bail must happen before any column-names read"
);
}
#[test]
fn empty_partition_fast_path_surfaces_partition_deletion() {
let mut bytes = vec![0u8; 16]; bytes.push(0x00); bytes.push(ITER_IS_EMPTY | ITER_HAS_PARTITION_DELETION); let (update, consumed_fully) =
decode_partition_update(&mut Cursor::new(&bytes), &SchemaSet::new()).expect("decode");
assert!(
update.has_partition_deletion,
"empty-partition fast path must still surface the partition deletion"
);
assert!(update.rows_decoded, "empty partition is fully consumed");
assert!(consumed_fully);
}
#[test]
fn read_column_names_rejects_hostile_count() {
let bytes = [0xFFu8, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF];
let err = read_column_names(&mut Cursor::new(&bytes)).unwrap_err();
assert!(
matches!(err, Error::CorruptCommitLogFrame(_)),
"hostile column count must be rejected as a corrupt frame, got {err:?}"
);
}
fn hex(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
}