Skip to content

Commit 8f57f4c

Browse files
lodyai[bot]zxch3n
andauthored
perf: reduce snapshot read memory retention (#985)
* perf: reduce snapshot read memory retention * fix: preserve tiny map iteration order * fix: sort lazy map iteration * refactor: simplify lazy state variants * perf: cache lazy values on first read * perf: avoid decoding state for lazy read queries * fix: preserve lazy snapshot invariants * fix: avoid lazy richtext style append corruption * fix: drop deleted lazy roots from snapshots * fix: prevent deleted imported roots from reappearing * fix: hide deleted root counters * fix: align lazy text position conversion * test: cover lazy snapshot read consistency * fix: validate lazy snapshot state on import * fix: reject mismatched lazy snapshot container types * fix: reject orphan lazy snapshot containers * fix: validate lazy snapshot parent links * fix: validate lazy snapshot value consistency * fix: reject malformed counter snapshot payloads * fix: tighten lazy snapshot validation * fix: reject missing lazy snapshot child state * fix: reject malformed lazy snapshot keys * fix: reject non-canonical lazy snapshot keys * fix: validate lazy snapshot container depth * fix: reject non-canonical lazy snapshot headers * fix: reject trailing fast snapshot bytes * fix: validate fast snapshot metadata length * fix: reject malformed sstable snapshot state * fix: validate sstable key ranges * fix: validate sstable block key order * fix: validate sstable block checksums * fix: validate snapshot container ids * fix: allow known missing lazy child states * fix: validate lazy shallow state references * fix: validate snapshot container creation ids * fix: validate lazy snapshot parent payloads * fix: track tree meta containers on create only * fix: validate json update ids * chore: satisfy clippy warnings * fix: harden json schema import * fix: reject negative json update counters * fix: reject nested container values * fix: guard negative value indexes * fix: harden value conversion contracts * fix: avoid u64 value truncation * fix: reject invalid awareness payloads * fix: reject corrupt list map snapshots * fix: reject corrupt fast snapshot metadata * fix: reject overflowing range deletes * fix: reject overflowing text delta positions * fix: reject overflowing apply diff positions * fix: keep kv normal blocks importable * fix: reject overflowing json redact counters * fix: clamp negative json id spans * fix: clamp negative update ranges * fix: clamp negative json version ranges * fix: clamp changed container id ranges * fix: clamp negative version distances * fix: normalize invalid version counters * fix: validate missing lazy child payloads * fix: harden lazy snapshot decoding * fix: normalize version span bounds * chore: format undo tests * fix: ignore negative vv frontiers * chore: ignore wasm browser output * fix: ignore empty kv keys * fix: reject non-canonical container ids * fix: harden snapshot counter decoding * perf: skip lazy import validation * chore: add lazy snapshot read changeset --------- Co-authored-by: Zixuan Chen <zx@loro.dev>
1 parent eb9c18a commit 8f57f4c

54 files changed

Lines changed: 5240 additions & 1025 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"loro-crdt": patch
3+
"loro-crdt-map": patch
4+
---
5+
6+
Reduce memory usage for read-only access to snapshot-imported documents by avoiding unnecessary lazy container state initialization.

crates/bench-utils/src/json.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,8 @@ impl ActionTrait for JsonAction {
5353

5454
fn normalize_value(value: &mut LoroValue) {
5555
match value {
56-
LoroValue::Double(f) => {
57-
if f.is_nan() {
58-
*f = 0.0;
59-
}
56+
LoroValue::Double(f) if f.is_nan() => {
57+
*f = 0.0;
6058
}
6159
LoroValue::List(l) => {
6260
for v in l.make_mut().iter_mut() {

crates/delta/src/delta_rope.rs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -392,13 +392,11 @@ impl<V: DeltaValue + PartialEq, Attr: DeltaAttr + PartialEq> PartialEq for Delta
392392
b.next_with(len).unwrap();
393393
}
394394
}
395-
(DeltaItem::Retain { attr, .. }, DeltaItem::Retain { attr: b_attr, .. }) => {
396-
if *attr == *b_attr {
397-
a.next_with(len).unwrap();
398-
b.next_with(len).unwrap();
399-
} else {
400-
return false;
401-
}
395+
(DeltaItem::Retain { attr, .. }, DeltaItem::Retain { attr: b_attr, .. })
396+
if *attr == *b_attr =>
397+
{
398+
a.next_with(len).unwrap();
399+
b.next_with(len).unwrap();
402400
}
403401
_ => return false,
404402
}

crates/fuzz/src/container/tree.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ impl Actionable for TreeAction {
362362
}
363363
TreeActionInner::MetaDelete { key } => {
364364
let meta = super::unwrap(tree.get_meta(target))?;
365-
meta.delete(key);
365+
let _ = meta.delete(key);
366366
None
367367
}
368368
TreeActionInner::MetaClear => {

crates/fuzz/src/one_doc_fuzzer.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -646,7 +646,7 @@ impl OneDocFuzzer {
646646
undo.clear();
647647
}
648648
}
649-
Action::ForkAt { site, to } => {
649+
Action::ForkAt { site, to: _ } => {
650650
let frontiers = self.branches[*site as usize].frontiers.clone();
651651
let _forked = self.doc.fork_at(&frontiers);
652652
}

crates/fuzz/tests/test.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,42 @@ fn test_empty() {
3131
test_multi_sites(5, vec![FuzzTarget::All], &mut [])
3232
}
3333

34+
#[test]
35+
fn all_fuzz_lazy_richtext_append_style_anchor() {
36+
test_multi_sites(
37+
5,
38+
vec![FuzzTarget::All],
39+
&mut [
40+
Handle {
41+
site: 0,
42+
target: 0,
43+
container: 0,
44+
action: Generic(GenericAction {
45+
value: I32(0),
46+
bool: false,
47+
key: 0,
48+
pos: 0,
49+
length: 0,
50+
prop: 2962851221704015872,
51+
}),
52+
},
53+
Handle {
54+
site: 0,
55+
target: 0,
56+
container: 0,
57+
action: Generic(GenericAction {
58+
value: I32(0),
59+
bool: false,
60+
key: 59,
61+
pos: 15950377895847788544,
62+
length: 18386260117272886751,
63+
prop: 4251980913,
64+
}),
65+
},
66+
],
67+
)
68+
}
69+
3470
#[test]
3571
fn all_fuzz_text_update_deleted_container() {
3672
test_multi_sites(

crates/kv-store/src/block.rs

Lines changed: 142 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use std::{
66
};
77

88
use bytes::{Buf, Bytes};
9-
use loro_common::LoroResult;
9+
use loro_common::{LoroError, LoroResult};
1010
use once_cell::sync::OnceCell;
1111

1212
use crate::{
@@ -17,6 +17,9 @@ use crate::{
1717

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

20+
const MAX_NORMAL_BLOCK_DATA_LEN: usize = u16::MAX as usize;
21+
const MAX_NORMAL_BLOCK_ENTRIES: usize = u16::MAX as usize;
22+
2023
#[derive(Debug, Clone)]
2124
pub struct LargeValueBlock {
2225
// without checksum
@@ -118,23 +121,108 @@ impl NormalBlock {
118121
first_key: Bytes,
119122
compression_type: CompressionType,
120123
) -> LoroResult<NormalBlock> {
124+
if raw_block_and_check.len() < SIZE_OF_U32 {
125+
return Err(LoroError::DecodeError("Invalid bytes".into()));
126+
}
127+
121128
let buf = raw_block_and_check.slice(..raw_block_and_check.len() - SIZE_OF_U32);
122129
let mut data = vec![];
123130
decompress(&mut data, buf, compression_type)?;
131+
if data.len() < SIZE_OF_U16 {
132+
return Err(LoroError::DecodeError("Invalid bytes".into()));
133+
}
134+
124135
let offsets_len = (&data[data.len() - SIZE_OF_U16..]).get_u16_le() as usize;
125-
let data_end = data.len() - SIZE_OF_U16 * (offsets_len + 1);
136+
if offsets_len == 0 {
137+
return Err(LoroError::DecodeError("Invalid bytes".into()));
138+
}
139+
140+
let offsets_bytes_len = SIZE_OF_U16
141+
.checked_mul(offsets_len + 1)
142+
.ok_or_else(|| LoroError::DecodeError("Invalid bytes".into()))?;
143+
if data.len() < offsets_bytes_len {
144+
return Err(LoroError::DecodeError("Invalid bytes".into()));
145+
}
146+
147+
let data_end = data.len() - offsets_bytes_len;
148+
if data_end > u16::MAX as usize {
149+
return Err(LoroError::DecodeError("Invalid bytes".into()));
150+
}
151+
126152
let offsets = &data[data_end..data.len() - SIZE_OF_U16];
127-
let offsets = offsets
153+
let offsets: Vec<u16> = offsets
128154
.chunks(SIZE_OF_U16)
129155
.map(|mut chunk| chunk.get_u16_le())
130156
.collect();
157+
Self::validate_decoded_data(&data[..data_end], &offsets, &first_key)?;
131158
Ok(NormalBlock {
132159
data: Bytes::copy_from_slice(&data[..data_end]),
133160
encoded_data: OnceCell::with_value((raw_block_and_check, compression_type)),
134161
offsets,
135162
first_key,
136163
})
137164
}
165+
166+
fn validate_decoded_data(data: &[u8], offsets: &[u16], first_key: &[u8]) -> LoroResult<()> {
167+
if offsets.first().copied() != Some(0) {
168+
return Err(LoroError::DecodeError("Invalid bytes".into()));
169+
}
170+
171+
let mut prev_key: Option<Vec<u8>> = None;
172+
let mut prev_offset = 0usize;
173+
for (idx, offset) in offsets.iter().map(|x| *x as usize).enumerate() {
174+
let offset_end = offsets
175+
.get(idx + 1)
176+
.map_or(data.len(), |next| *next as usize);
177+
if offset < prev_offset || offset > offset_end || offset_end > data.len() {
178+
return Err(LoroError::DecodeError("Invalid bytes".into()));
179+
}
180+
181+
let key = if idx == 0 {
182+
first_key.to_vec()
183+
} else {
184+
let header_end = offset
185+
.checked_add(SIZE_OF_U8 + SIZE_OF_U16)
186+
.ok_or_else(|| LoroError::DecodeError("Invalid bytes".into()))?;
187+
if header_end > offset_end {
188+
return Err(LoroError::DecodeError("Invalid bytes".into()));
189+
}
190+
191+
let common_prefix_len = data[offset] as usize;
192+
if common_prefix_len > first_key.len() {
193+
return Err(LoroError::DecodeError("Invalid bytes".into()));
194+
}
195+
196+
let key_suffix_len =
197+
u16::from_le_bytes(data[offset + SIZE_OF_U8..header_end].try_into().unwrap())
198+
as usize;
199+
let key_end = header_end
200+
.checked_add(key_suffix_len)
201+
.ok_or_else(|| LoroError::DecodeError("Invalid bytes".into()))?;
202+
if key_end > offset_end {
203+
return Err(LoroError::DecodeError("Invalid bytes".into()));
204+
}
205+
206+
let mut key = Vec::with_capacity(common_prefix_len + key_suffix_len);
207+
key.extend_from_slice(&first_key[..common_prefix_len]);
208+
key.extend_from_slice(&data[header_end..key_end]);
209+
key
210+
};
211+
212+
if key.is_empty()
213+
|| prev_key
214+
.as_ref()
215+
.is_some_and(|prev_key| prev_key.as_slice() >= key.as_slice())
216+
{
217+
return Err(LoroError::DecodeError("Invalid bytes".into()));
218+
}
219+
220+
prev_offset = offset;
221+
prev_key = Some(key);
222+
}
223+
224+
Ok(())
225+
}
138226
}
139227

140228
#[derive(Debug, Clone)]
@@ -189,20 +277,31 @@ impl Block {
189277
}
190278
}
191279

192-
pub fn decode(
280+
pub(crate) fn try_decode(
193281
raw_block_and_check: Bytes,
194282
is_large: bool,
195283
key: Bytes,
196284
compression_type: CompressionType,
197-
) -> Self {
198-
// The caller is responsible for validating SSTable integrity before lazy block reads.
285+
) -> LoroResult<Self> {
286+
if key.is_empty() {
287+
return Err(LoroError::DecodeError("Invalid bytes".into()));
288+
}
289+
199290
if is_large {
200291
return LargeValueBlock::decode(raw_block_and_check, key, compression_type)
201-
.map(Block::Large)
202-
.expect("validated SSTable block should decode");
292+
.map(Block::Large);
203293
}
204-
NormalBlock::decode(raw_block_and_check, key, compression_type)
205-
.map(Block::Normal)
294+
NormalBlock::decode(raw_block_and_check, key, compression_type).map(Block::Normal)
295+
}
296+
297+
pub fn decode(
298+
raw_block_and_check: Bytes,
299+
is_large: bool,
300+
key: Bytes,
301+
compression_type: CompressionType,
302+
) -> Self {
303+
// The caller is responsible for validating SSTable integrity before lazy block reads.
304+
Self::try_decode(raw_block_and_check, is_large, key, compression_type)
206305
.expect("validated SSTable block should decode")
207306
}
208307

@@ -273,9 +372,13 @@ impl BlockBuilder {
273372
/// └─────────────────────────────────────────────────────┘
274373
///
275374
pub fn add(&mut self, key: &[u8], value: &[u8]) -> bool {
375+
if key.is_empty() {
376+
return false;
377+
}
378+
276379
debug_assert!(!key.is_empty(), "key cannot be empty");
277380
if self.first_key.is_empty() {
278-
if value.len() > self.block_size {
381+
if value.len() > self.block_size || value.len() > MAX_NORMAL_BLOCK_DATA_LEN {
279382
self.data.extend_from_slice(value);
280383
self.is_large = true;
281384
self.first_key = Bytes::copy_from_slice(key);
@@ -288,16 +391,39 @@ impl BlockBuilder {
288391
return true;
289392
}
290393

291-
// whether the block is full
292-
if self.estimated_size() + key.len() + value.len() + SIZE_OF_U8 + SIZE_OF_U16
293-
> self.block_size
294-
{
394+
if self.offsets.len() >= MAX_NORMAL_BLOCK_ENTRIES {
295395
return false;
296396
}
297397

298-
self.offsets.push(self.data.len() as u16);
299398
let (common, suffix) = get_common_prefix_len_and_strip(key, &self.first_key);
300399
let key_len = suffix.len();
400+
let Some(next_data_len) = self
401+
.data
402+
.len()
403+
.checked_add(SIZE_OF_U8 + SIZE_OF_U16)
404+
.and_then(|len| len.checked_add(key_len))
405+
.and_then(|len| len.checked_add(value.len()))
406+
else {
407+
return false;
408+
};
409+
if next_data_len > MAX_NORMAL_BLOCK_DATA_LEN {
410+
return false;
411+
}
412+
413+
// whether the block is full
414+
let Some(estimated_size) = self
415+
.estimated_size()
416+
.checked_add(key_len)
417+
.and_then(|len| len.checked_add(value.len()))
418+
.and_then(|len| len.checked_add(SIZE_OF_U8 + SIZE_OF_U16))
419+
else {
420+
return false;
421+
};
422+
if estimated_size > self.block_size {
423+
return false;
424+
}
425+
426+
self.offsets.push(self.data.len() as u16);
301427
self.data.push(common);
302428
self.data.extend_from_slice(&(key_len as u16).to_le_bytes());
303429
self.data.extend_from_slice(suffix);

0 commit comments

Comments
 (0)