Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
1814bf1
perf: reduce snapshot read memory retention
zxch3n May 22, 2026
f6ab106
fix: preserve tiny map iteration order
zxch3n May 22, 2026
d3d797a
fix: sort lazy map iteration
zxch3n May 22, 2026
86e2be0
refactor: simplify lazy state variants
zxch3n May 22, 2026
6f0cbaa
perf: cache lazy values on first read
zxch3n May 22, 2026
2d7053d
perf: avoid decoding state for lazy read queries
zxch3n May 22, 2026
c637c2e
fix: preserve lazy snapshot invariants
zxch3n May 23, 2026
3ca6a8f
fix: avoid lazy richtext style append corruption
zxch3n May 23, 2026
a052b01
fix: drop deleted lazy roots from snapshots
zxch3n May 23, 2026
032426a
fix: prevent deleted imported roots from reappearing
zxch3n May 23, 2026
9998e52
fix: hide deleted root counters
zxch3n May 23, 2026
6f3c8ae
fix: align lazy text position conversion
zxch3n May 23, 2026
3a7c98f
test: cover lazy snapshot read consistency
zxch3n May 23, 2026
f4568f8
fix: validate lazy snapshot state on import
zxch3n May 23, 2026
2fbec9c
fix: reject mismatched lazy snapshot container types
zxch3n May 23, 2026
65a95a0
fix: reject orphan lazy snapshot containers
zxch3n May 23, 2026
4e2a949
fix: validate lazy snapshot parent links
zxch3n May 23, 2026
2761b64
fix: validate lazy snapshot value consistency
zxch3n May 23, 2026
82e814f
fix: reject malformed counter snapshot payloads
zxch3n May 23, 2026
5aa2868
fix: tighten lazy snapshot validation
zxch3n May 23, 2026
22f0934
fix: reject missing lazy snapshot child state
zxch3n May 24, 2026
6558440
fix: reject malformed lazy snapshot keys
zxch3n May 24, 2026
39b5e7f
fix: reject non-canonical lazy snapshot keys
zxch3n May 24, 2026
92c7cb0
fix: validate lazy snapshot container depth
zxch3n May 24, 2026
89af4cb
fix: reject non-canonical lazy snapshot headers
zxch3n May 24, 2026
77899f5
fix: reject trailing fast snapshot bytes
zxch3n May 24, 2026
bd09e96
fix: validate fast snapshot metadata length
zxch3n May 24, 2026
8dc8042
fix: reject malformed sstable snapshot state
zxch3n May 24, 2026
4f099b8
fix: validate sstable key ranges
zxch3n May 24, 2026
0ee193c
fix: validate sstable block key order
zxch3n May 24, 2026
23ebd09
fix: validate sstable block checksums
zxch3n May 24, 2026
4f4516a
fix: validate snapshot container ids
zxch3n May 24, 2026
894927f
fix: allow known missing lazy child states
zxch3n May 24, 2026
71719e0
fix: validate lazy shallow state references
zxch3n May 24, 2026
82e16eb
fix: validate snapshot container creation ids
zxch3n May 24, 2026
4e08831
fix: validate lazy snapshot parent payloads
zxch3n May 24, 2026
a6e3b2e
fix: track tree meta containers on create only
zxch3n May 24, 2026
1db202d
fix: validate json update ids
zxch3n May 24, 2026
9cb2b76
chore: satisfy clippy warnings
zxch3n May 24, 2026
ee854e1
fix: harden json schema import
zxch3n May 24, 2026
6c2ee93
fix: reject negative json update counters
zxch3n May 24, 2026
e89e593
fix: reject nested container values
zxch3n May 24, 2026
483b3ef
fix: guard negative value indexes
zxch3n May 24, 2026
da297c6
fix: harden value conversion contracts
zxch3n May 24, 2026
451e6a9
fix: avoid u64 value truncation
zxch3n May 24, 2026
2f3e873
fix: reject invalid awareness payloads
zxch3n May 24, 2026
4f13c95
fix: reject corrupt list map snapshots
zxch3n May 24, 2026
a2e9cc8
fix: reject corrupt fast snapshot metadata
zxch3n May 24, 2026
813ca45
fix: reject overflowing range deletes
zxch3n May 24, 2026
ac9a9be
fix: reject overflowing text delta positions
zxch3n May 24, 2026
62c8ded
fix: reject overflowing apply diff positions
zxch3n May 24, 2026
211050a
fix: keep kv normal blocks importable
zxch3n May 24, 2026
af9a818
fix: reject overflowing json redact counters
zxch3n May 24, 2026
35f7b74
fix: clamp negative json id spans
zxch3n May 24, 2026
15217f8
fix: clamp negative update ranges
zxch3n May 24, 2026
878aaca
fix: clamp negative json version ranges
zxch3n May 24, 2026
4c66d40
fix: clamp changed container id ranges
zxch3n May 24, 2026
91f9d4a
fix: clamp negative version distances
zxch3n May 24, 2026
11b92de
fix: normalize invalid version counters
zxch3n May 24, 2026
7e85518
fix: validate missing lazy child payloads
zxch3n May 24, 2026
d748011
fix: harden lazy snapshot decoding
zxch3n May 24, 2026
3d01bd9
fix: normalize version span bounds
zxch3n May 24, 2026
9ac980e
chore: format undo tests
zxch3n May 24, 2026
fe85813
fix: ignore negative vv frontiers
zxch3n May 24, 2026
2a94fd6
chore: ignore wasm browser output
zxch3n May 24, 2026
eaa9aa5
fix: ignore empty kv keys
zxch3n May 24, 2026
aa798e8
fix: reject non-canonical container ids
zxch3n May 24, 2026
f1d6654
fix: harden snapshot counter decoding
zxch3n May 25, 2026
3f3b1f4
perf: skip lazy import validation
zxch3n May 25, 2026
86bbbf8
chore: add lazy snapshot read changeset
zxch3n May 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/optimize-lazy-snapshot-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"loro-crdt": patch
"loro-crdt-map": patch
---

Reduce memory usage for read-only access to snapshot-imported documents by avoiding unnecessary lazy container state initialization.
6 changes: 2 additions & 4 deletions crates/bench-utils/src/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,8 @@ impl ActionTrait for JsonAction {

fn normalize_value(value: &mut LoroValue) {
match value {
LoroValue::Double(f) => {
if f.is_nan() {
*f = 0.0;
}
LoroValue::Double(f) if f.is_nan() => {
*f = 0.0;
}
LoroValue::List(l) => {
for v in l.make_mut().iter_mut() {
Expand Down
12 changes: 5 additions & 7 deletions crates/delta/src/delta_rope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -392,13 +392,11 @@ impl<V: DeltaValue + PartialEq, Attr: DeltaAttr + PartialEq> PartialEq for Delta
b.next_with(len).unwrap();
}
}
(DeltaItem::Retain { attr, .. }, DeltaItem::Retain { attr: b_attr, .. }) => {
if *attr == *b_attr {
a.next_with(len).unwrap();
b.next_with(len).unwrap();
} else {
return false;
}
(DeltaItem::Retain { attr, .. }, DeltaItem::Retain { attr: b_attr, .. })
if *attr == *b_attr =>
{
a.next_with(len).unwrap();
b.next_with(len).unwrap();
}
_ => return false,
}
Expand Down
2 changes: 1 addition & 1 deletion crates/fuzz/src/container/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ impl Actionable for TreeAction {
}
TreeActionInner::MetaDelete { key } => {
let meta = super::unwrap(tree.get_meta(target))?;
meta.delete(key);
let _ = meta.delete(key);
None
}
TreeActionInner::MetaClear => {
Expand Down
2 changes: 1 addition & 1 deletion crates/fuzz/src/one_doc_fuzzer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,7 @@ impl OneDocFuzzer {
undo.clear();
}
}
Action::ForkAt { site, to } => {
Action::ForkAt { site, to: _ } => {
let frontiers = self.branches[*site as usize].frontiers.clone();
let _forked = self.doc.fork_at(&frontiers);
}
Expand Down
36 changes: 36 additions & 0 deletions crates/fuzz/tests/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,42 @@ fn test_empty() {
test_multi_sites(5, vec![FuzzTarget::All], &mut [])
}

#[test]
fn all_fuzz_lazy_richtext_append_style_anchor() {
test_multi_sites(
5,
vec![FuzzTarget::All],
&mut [
Handle {
site: 0,
target: 0,
container: 0,
action: Generic(GenericAction {
value: I32(0),
bool: false,
key: 0,
pos: 0,
length: 0,
prop: 2962851221704015872,
}),
},
Handle {
site: 0,
target: 0,
container: 0,
action: Generic(GenericAction {
value: I32(0),
bool: false,
key: 59,
pos: 15950377895847788544,
length: 18386260117272886751,
prop: 4251980913,
}),
},
],
)
}

#[test]
fn all_fuzz_text_update_deleted_container() {
test_multi_sites(
Expand Down
158 changes: 142 additions & 16 deletions crates/kv-store/src/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::{
};

use bytes::{Buf, Bytes};
use loro_common::LoroResult;
use loro_common::{LoroError, LoroResult};
use once_cell::sync::OnceCell;

use crate::{
Expand All @@ -17,6 +17,9 @@ use crate::{

use super::sstable::{SIZE_OF_U16, SIZE_OF_U8};

const MAX_NORMAL_BLOCK_DATA_LEN: usize = u16::MAX as usize;
const MAX_NORMAL_BLOCK_ENTRIES: usize = u16::MAX as usize;

#[derive(Debug, Clone)]
pub struct LargeValueBlock {
// without checksum
Expand Down Expand Up @@ -118,23 +121,108 @@ impl NormalBlock {
first_key: Bytes,
compression_type: CompressionType,
) -> LoroResult<NormalBlock> {
if raw_block_and_check.len() < SIZE_OF_U32 {
return Err(LoroError::DecodeError("Invalid bytes".into()));
}

let buf = raw_block_and_check.slice(..raw_block_and_check.len() - SIZE_OF_U32);
let mut data = vec![];
decompress(&mut data, buf, compression_type)?;
if data.len() < SIZE_OF_U16 {
return Err(LoroError::DecodeError("Invalid bytes".into()));
}

let offsets_len = (&data[data.len() - SIZE_OF_U16..]).get_u16_le() as usize;
let data_end = data.len() - SIZE_OF_U16 * (offsets_len + 1);
if offsets_len == 0 {
return Err(LoroError::DecodeError("Invalid bytes".into()));
}

let offsets_bytes_len = SIZE_OF_U16
.checked_mul(offsets_len + 1)
.ok_or_else(|| LoroError::DecodeError("Invalid bytes".into()))?;
if data.len() < offsets_bytes_len {
return Err(LoroError::DecodeError("Invalid bytes".into()));
}

let data_end = data.len() - offsets_bytes_len;
if data_end > u16::MAX as usize {
return Err(LoroError::DecodeError("Invalid bytes".into()));
}

let offsets = &data[data_end..data.len() - SIZE_OF_U16];
let offsets = offsets
let offsets: Vec<u16> = offsets
.chunks(SIZE_OF_U16)
.map(|mut chunk| chunk.get_u16_le())
.collect();
Self::validate_decoded_data(&data[..data_end], &offsets, &first_key)?;
Ok(NormalBlock {
data: Bytes::copy_from_slice(&data[..data_end]),
encoded_data: OnceCell::with_value((raw_block_and_check, compression_type)),
offsets,
first_key,
})
}

fn validate_decoded_data(data: &[u8], offsets: &[u16], first_key: &[u8]) -> LoroResult<()> {
if offsets.first().copied() != Some(0) {
return Err(LoroError::DecodeError("Invalid bytes".into()));
}

let mut prev_key: Option<Vec<u8>> = None;
let mut prev_offset = 0usize;
for (idx, offset) in offsets.iter().map(|x| *x as usize).enumerate() {
let offset_end = offsets
.get(idx + 1)
.map_or(data.len(), |next| *next as usize);
if offset < prev_offset || offset > offset_end || offset_end > data.len() {
return Err(LoroError::DecodeError("Invalid bytes".into()));
}

let key = if idx == 0 {
first_key.to_vec()
} else {
let header_end = offset
.checked_add(SIZE_OF_U8 + SIZE_OF_U16)
.ok_or_else(|| LoroError::DecodeError("Invalid bytes".into()))?;
if header_end > offset_end {
return Err(LoroError::DecodeError("Invalid bytes".into()));
}

let common_prefix_len = data[offset] as usize;
if common_prefix_len > first_key.len() {
return Err(LoroError::DecodeError("Invalid bytes".into()));
}

let key_suffix_len =
u16::from_le_bytes(data[offset + SIZE_OF_U8..header_end].try_into().unwrap())
as usize;
let key_end = header_end
.checked_add(key_suffix_len)
.ok_or_else(|| LoroError::DecodeError("Invalid bytes".into()))?;
if key_end > offset_end {
return Err(LoroError::DecodeError("Invalid bytes".into()));
}

let mut key = Vec::with_capacity(common_prefix_len + key_suffix_len);
key.extend_from_slice(&first_key[..common_prefix_len]);
key.extend_from_slice(&data[header_end..key_end]);
key
};

if key.is_empty()
|| prev_key
.as_ref()
.is_some_and(|prev_key| prev_key.as_slice() >= key.as_slice())
{
return Err(LoroError::DecodeError("Invalid bytes".into()));
}

prev_offset = offset;
prev_key = Some(key);
}

Ok(())
}
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -189,20 +277,31 @@ impl Block {
}
}

pub fn decode(
pub(crate) fn try_decode(
raw_block_and_check: Bytes,
is_large: bool,
key: Bytes,
compression_type: CompressionType,
) -> Self {
// The caller is responsible for validating SSTable integrity before lazy block reads.
) -> LoroResult<Self> {
if key.is_empty() {
return Err(LoroError::DecodeError("Invalid bytes".into()));
}

if is_large {
return LargeValueBlock::decode(raw_block_and_check, key, compression_type)
.map(Block::Large)
.expect("validated SSTable block should decode");
.map(Block::Large);
}
NormalBlock::decode(raw_block_and_check, key, compression_type)
.map(Block::Normal)
NormalBlock::decode(raw_block_and_check, key, compression_type).map(Block::Normal)
}

pub fn decode(
raw_block_and_check: Bytes,
is_large: bool,
key: Bytes,
compression_type: CompressionType,
) -> Self {
// The caller is responsible for validating SSTable integrity before lazy block reads.
Self::try_decode(raw_block_and_check, is_large, key, compression_type)
.expect("validated SSTable block should decode")
}

Expand Down Expand Up @@ -273,9 +372,13 @@ impl BlockBuilder {
/// └─────────────────────────────────────────────────────┘
///
pub fn add(&mut self, key: &[u8], value: &[u8]) -> bool {
if key.is_empty() {
return false;
}

debug_assert!(!key.is_empty(), "key cannot be empty");
if self.first_key.is_empty() {
if value.len() > self.block_size {
if value.len() > self.block_size || value.len() > MAX_NORMAL_BLOCK_DATA_LEN {
self.data.extend_from_slice(value);
self.is_large = true;
self.first_key = Bytes::copy_from_slice(key);
Expand All @@ -288,16 +391,39 @@ impl BlockBuilder {
return true;
}

// whether the block is full
if self.estimated_size() + key.len() + value.len() + SIZE_OF_U8 + SIZE_OF_U16
> self.block_size
{
if self.offsets.len() >= MAX_NORMAL_BLOCK_ENTRIES {
return false;
}

self.offsets.push(self.data.len() as u16);
let (common, suffix) = get_common_prefix_len_and_strip(key, &self.first_key);
let key_len = suffix.len();
let Some(next_data_len) = self
.data
.len()
.checked_add(SIZE_OF_U8 + SIZE_OF_U16)
.and_then(|len| len.checked_add(key_len))
.and_then(|len| len.checked_add(value.len()))
else {
return false;
};
if next_data_len > MAX_NORMAL_BLOCK_DATA_LEN {
return false;
}

// whether the block is full
let Some(estimated_size) = self
.estimated_size()
.checked_add(key_len)
.and_then(|len| len.checked_add(value.len()))
.and_then(|len| len.checked_add(SIZE_OF_U8 + SIZE_OF_U16))
else {
return false;
};
if estimated_size > self.block_size {
return false;
}

self.offsets.push(self.data.len() as u16);
self.data.push(common);
self.data.extend_from_slice(&(key_len as u16).to_le_bytes());
self.data.extend_from_slice(suffix);
Expand Down
Loading
Loading