1use std::sync::Arc;
30
31use arrow_array::cast::AsArray;
32use arrow_array::types::{Float32Type, UInt32Type, UInt64Type};
33use arrow_array::{
34 Array, Float32Array, LargeBinaryArray, ListArray, RecordBatch, UInt32Array, UInt64Array,
35};
36use arrow_schema::{DataType, Field, Schema};
37use lance_core::cache::{CacheCodecImpl, CacheEntryReader, CacheEntryWriter};
38use lance_core::{Error, Result};
39
40use crate::cache_pb::{
41 CompressedPostingHeader, PlainPostingHeader, PositionStorage as PbPositionStorage,
42 PositionStreamCodec as PbPositionStreamCodec, PositionsHeader, PostingListGroupHeader,
43 PostingTailCodec as PbPostingTailCodec,
44};
45
46use super::impact::ImpactSkipData;
47use super::index::{
48 CompressedPositionStorage, CompressedPostingList, PlainPostingList, PositionStreamCodec,
49 Positions, PostingList, PostingListGroup, PostingListGroupStorage, PostingTailCodec,
50 SharedPositionStream,
51};
52use super::tokenizer::{LEGACY_BLOCK_SIZE, validate_block_size};
53
54const POSTING_VARIANT_PLAIN: u8 = 0;
59const POSTING_VARIANT_COMPRESSED: u8 = 1;
60const GROUP_VARIANT_MATERIALIZED: u8 = 0;
61const GROUP_VARIANT_PACKED: u8 = 1;
62
63fn posting_tail_codec_to_proto(c: PostingTailCodec) -> PbPostingTailCodec {
71 match c {
72 PostingTailCodec::Fixed32 => PbPostingTailCodec::Fixed32,
73 PostingTailCodec::VarintDelta => PbPostingTailCodec::VarintDelta,
74 }
75}
76
77fn proto_to_posting_tail_codec(c: PbPostingTailCodec) -> PostingTailCodec {
78 match c {
79 PbPostingTailCodec::Fixed32 => PostingTailCodec::Fixed32,
80 PbPostingTailCodec::VarintDelta => PostingTailCodec::VarintDelta,
81 }
82}
83
84fn posting_tail_codec_to_tag(c: PostingTailCodec) -> u8 {
85 match c {
86 PostingTailCodec::Fixed32 => 0,
87 PostingTailCodec::VarintDelta => 1,
88 }
89}
90
91fn posting_tail_codec_from_tag(tag: u8) -> Result<PostingTailCodec> {
92 match tag {
93 0 => Ok(PostingTailCodec::Fixed32),
94 1 => Ok(PostingTailCodec::VarintDelta),
95 other => Err(Error::io(format!(
96 "unknown packed posting tail codec: {other}"
97 ))),
98 }
99}
100
101fn position_stream_codec_to_proto(c: PositionStreamCodec) -> PbPositionStreamCodec {
102 match c {
103 PositionStreamCodec::VarintDocDelta => PbPositionStreamCodec::VarintDocDelta,
104 PositionStreamCodec::PackedDelta => PbPositionStreamCodec::PackedDelta,
105 }
106}
107
108fn proto_to_position_stream_codec(c: PbPositionStreamCodec) -> PositionStreamCodec {
109 match c {
110 PbPositionStreamCodec::VarintDocDelta => PositionStreamCodec::VarintDocDelta,
111 PbPositionStreamCodec::PackedDelta => PositionStreamCodec::PackedDelta,
112 }
113}
114
115const POSITION_LIST_COLUMN: &str = "position_list";
120const BLOCK_OFFSETS_COLUMN: &str = "block_offsets";
121const ROW_IDS_COLUMN: &str = "row_ids";
122const FREQUENCIES_COLUMN: &str = "frequencies";
123const BLOCKS_COLUMN: &str = "blocks";
124const IMPACTS_COLUMN: &str = "impacts";
125
126fn legacy_positions_batch(list: &ListArray) -> Result<RecordBatch> {
127 let schema = Arc::new(Schema::new(vec![Field::new(
128 POSITION_LIST_COLUMN,
129 list.data_type().clone(),
130 list.is_nullable(),
131 )]));
132 Ok(RecordBatch::try_new(schema, vec![Arc::new(list.clone())])?)
133}
134
135fn read_legacy_positions(r: &mut CacheEntryReader<'_>) -> Result<ListArray> {
136 let batch = r.read_ipc()?;
137 Ok(batch
138 .column_by_name(POSITION_LIST_COLUMN)
139 .ok_or_else(|| Error::io("legacy position column is missing".to_string()))?
140 .as_any()
141 .downcast_ref::<ListArray>()
142 .ok_or_else(|| Error::io("legacy position column is not a ListArray".to_string()))?
143 .clone())
144}
145
146fn write_position_sections(
149 w: &mut CacheEntryWriter<'_>,
150 storage: &CompressedPositionStorage,
151) -> Result<()> {
152 match storage {
153 CompressedPositionStorage::LegacyPerDoc(list) => {
154 w.write_ipc(&legacy_positions_batch(list)?)?;
155 }
156 CompressedPositionStorage::SharedStream(stream) => {
157 let offsets = UInt32Array::from(stream.block_offsets().to_vec());
158 let schema = Arc::new(Schema::new(vec![Field::new(
159 BLOCK_OFFSETS_COLUMN,
160 DataType::UInt32,
161 false,
162 )]));
163 let batch = RecordBatch::try_new(schema, vec![Arc::new(offsets)])?;
164 w.write_ipc(&batch)?;
165 w.write_raw(stream.bytes())?;
166 }
167 }
168 Ok(())
169}
170
171fn read_position_sections(
175 r: &mut CacheEntryReader<'_>,
176 storage: PbPositionStorage,
177 stream_codec: PositionStreamCodec,
178) -> Result<Option<CompressedPositionStorage>> {
179 match storage {
180 PbPositionStorage::None => Ok(None),
181 PbPositionStorage::Legacy => Ok(Some(CompressedPositionStorage::LegacyPerDoc(
182 read_legacy_positions(r)?,
183 ))),
184 PbPositionStorage::Shared => {
185 let batch = r.read_ipc()?;
186 let block_offsets = batch
187 .column_by_name(BLOCK_OFFSETS_COLUMN)
188 .ok_or_else(|| Error::io("block_offsets column is missing".to_string()))?
189 .as_primitive_opt::<UInt32Type>()
190 .ok_or_else(|| Error::io("block_offsets column is not UInt32".to_string()))?
191 .values()
192 .to_vec();
193 let bytes = r.read_raw()?;
197 Ok(Some(CompressedPositionStorage::SharedStream(
198 SharedPositionStream::new(stream_codec, block_offsets, bytes),
199 )))
200 }
201 }
202}
203
204impl CacheCodecImpl for PostingList {
209 const TYPE_ID: &'static str = "lance.fts.PostingList";
210 const CURRENT_VERSION: u32 = 3;
215
216 fn serialize(&self, w: &mut CacheEntryWriter<'_>) -> Result<()> {
217 match self {
218 Self::Plain(plain) => {
219 w.write_u8(POSTING_VARIANT_PLAIN)?;
220 serialize_plain(w, plain)
221 }
222 Self::Compressed(compressed) => {
223 w.write_u8(POSTING_VARIANT_COMPRESSED)?;
224 serialize_compressed(w, compressed)
225 }
226 }
227 }
228
229 fn deserialize(r: &mut CacheEntryReader<'_>) -> Result<Self> {
230 match r.version() {
231 1 | 2 | Self::CURRENT_VERSION => deserialize_posting_list_body(r),
232 other => Err(Error::io(format!(
233 "unsupported PostingList cache version: {other}"
234 ))),
235 }
236 }
237}
238
239fn deserialize_posting_list_body(r: &mut CacheEntryReader<'_>) -> Result<PostingList> {
240 let variant = r.read_u8()?;
241 match variant {
242 POSTING_VARIANT_PLAIN => Ok(PostingList::Plain(deserialize_plain(r)?)),
243 POSTING_VARIANT_COMPRESSED => Ok(PostingList::Compressed(deserialize_compressed(r)?)),
244 other => Err(Error::io(format!("unknown PostingList variant: {other}"))),
245 }
246}
247
248fn serialize_plain(w: &mut CacheEntryWriter<'_>, plain: &PlainPostingList) -> Result<()> {
249 let position_storage = if plain.positions.is_some() {
251 PbPositionStorage::Legacy
252 } else {
253 PbPositionStorage::None
254 };
255 let header = PlainPostingHeader {
256 max_score: plain.max_score,
257 position_storage: position_storage as i32,
258 };
259 w.write_header(&header)?;
260
261 let row_ids = UInt64Array::new(plain.row_ids.clone(), None);
262 let frequencies = Float32Array::new(plain.frequencies.clone(), None);
263 let schema = Arc::new(Schema::new(vec![
264 Field::new(ROW_IDS_COLUMN, DataType::UInt64, false),
265 Field::new(FREQUENCIES_COLUMN, DataType::Float32, false),
266 ]));
267 let batch = RecordBatch::try_new(schema, vec![Arc::new(row_ids), Arc::new(frequencies)])?;
268 w.write_ipc(&batch)?;
269
270 if let Some(list) = &plain.positions {
271 w.write_ipc(&legacy_positions_batch(list)?)?;
272 }
273 Ok(())
274}
275
276fn deserialize_plain(r: &mut CacheEntryReader<'_>) -> Result<PlainPostingList> {
277 let header: PlainPostingHeader = r.read_header()?;
278
279 let batch = r.read_ipc()?;
280 let row_ids = batch
281 .column_by_name(ROW_IDS_COLUMN)
282 .ok_or_else(|| Error::io("row_ids column is missing".to_string()))?
283 .as_primitive_opt::<UInt64Type>()
284 .ok_or_else(|| Error::io("row_ids column is not UInt64".to_string()))?
285 .values()
286 .clone();
287 let frequencies = batch
288 .column_by_name(FREQUENCIES_COLUMN)
289 .ok_or_else(|| Error::io("frequencies column is missing".to_string()))?
290 .as_primitive_opt::<Float32Type>()
291 .ok_or_else(|| Error::io("frequencies column is not Float32".to_string()))?
292 .values()
293 .clone();
294
295 let positions = match header.position_storage() {
296 PbPositionStorage::None => None,
297 PbPositionStorage::Legacy => Some(read_legacy_positions(r)?),
298 PbPositionStorage::Shared => {
299 return Err(Error::io(
300 "Plain posting list cannot have a shared position stream".to_string(),
301 ));
302 }
303 };
304
305 Ok(PlainPostingList::new(
306 row_ids,
307 frequencies,
308 header.max_score,
309 positions,
310 ))
311}
312
313fn serialize_compressed(
318 w: &mut CacheEntryWriter<'_>,
319 posting: &CompressedPostingList,
320) -> Result<()> {
321 let (position_storage, position_stream_codec) = match &posting.positions {
322 None => (PbPositionStorage::None, PbPositionStreamCodec::default()),
323 Some(CompressedPositionStorage::LegacyPerDoc(_)) => {
324 (PbPositionStorage::Legacy, PbPositionStreamCodec::default())
325 }
326 Some(CompressedPositionStorage::SharedStream(stream)) => (
327 PbPositionStorage::Shared,
328 position_stream_codec_to_proto(stream.codec()),
329 ),
330 };
331
332 let header = CompressedPostingHeader {
333 max_score: posting.max_score,
334 length: posting.length,
335 posting_tail_codec: posting_tail_codec_to_proto(posting.posting_tail_codec) as i32,
336 position_storage: position_storage as i32,
337 position_stream_codec: position_stream_codec as i32,
338 block_size: posting.block_size as u32,
339 has_impacts: posting.impacts.is_some(),
340 };
341 w.write_header(&header)?;
342
343 let schema = Arc::new(Schema::new(vec![Field::new(
344 BLOCKS_COLUMN,
345 DataType::LargeBinary,
346 false,
347 )]));
348 let batch = RecordBatch::try_new(schema, vec![Arc::new(posting.blocks.clone())])?;
349 w.write_ipc(&batch)?;
350
351 if let Some(storage) = &posting.positions {
352 write_position_sections(w, storage)?;
353 }
354 if let Some(impacts) = &posting.impacts {
355 let schema = Arc::new(Schema::new(vec![Field::new(
356 IMPACTS_COLUMN,
357 DataType::LargeBinary,
358 false,
359 )]));
360 let batch = RecordBatch::try_new(schema, vec![Arc::new(impacts.entries().clone())])?;
361 w.write_ipc(&batch)?;
362 }
363 Ok(())
364}
365
366fn deserialize_compressed(r: &mut CacheEntryReader<'_>) -> Result<CompressedPostingList> {
367 let header: CompressedPostingHeader = r.read_header()?;
368 let posting_tail_codec = proto_to_posting_tail_codec(header.posting_tail_codec());
369
370 let batch = r.read_ipc()?;
371 let blocks = batch
372 .column_by_name(BLOCKS_COLUMN)
373 .ok_or_else(|| Error::io("blocks column is missing".to_string()))?
374 .as_any()
375 .downcast_ref::<LargeBinaryArray>()
376 .ok_or_else(|| Error::io("blocks column is not a LargeBinaryArray".to_string()))?
377 .clone();
378
379 let stream_codec = proto_to_position_stream_codec(header.position_stream_codec());
380 let positions = read_position_sections(r, header.position_storage(), stream_codec)?;
381 let block_size = if header.block_size == 0 {
382 LEGACY_BLOCK_SIZE
383 } else {
384 validate_block_size(header.block_size as usize)?
385 };
386 let impacts = if r.version() >= 3 && header.has_impacts {
387 let batch = r.read_ipc()?;
388 let entries = batch
389 .column_by_name(IMPACTS_COLUMN)
390 .ok_or_else(|| Error::io("impacts column is missing".to_string()))?
391 .as_any()
392 .downcast_ref::<LargeBinaryArray>()
393 .ok_or_else(|| Error::io("impacts column is not a LargeBinaryArray".to_string()))?
394 .clone();
395 Some(ImpactSkipData::new(entries, blocks.len())?)
396 } else {
397 None
398 };
399
400 Ok(CompressedPostingList::new(
401 blocks,
402 header.max_score,
403 header.length,
404 posting_tail_codec,
405 block_size,
406 positions,
407 impacts,
408 ))
409}
410
411impl CacheCodecImpl for PostingListGroup {
423 const TYPE_ID: &'static str = "lance.fts.PostingListGroup";
424 const CURRENT_VERSION: u32 = 4;
425
426 fn serialize(&self, w: &mut CacheEntryWriter<'_>) -> Result<()> {
427 let count = u32::try_from(self.len())
428 .map_err(|_| Error::io("posting list group too large to serialize".to_string()))?;
429 match &self.storage {
430 PostingListGroupStorage::Materialized(posting_lists) => {
431 w.write_u8(GROUP_VARIANT_MATERIALIZED)?;
432 w.write_header(&PostingListGroupHeader { count })?;
433 for posting in posting_lists {
434 posting.serialize(w)?;
435 }
436 }
437 PostingListGroupStorage::Packed(group) => {
438 w.write_u8(GROUP_VARIANT_PACKED)?;
439 w.write_header(&PostingListGroupHeader { count })?;
440 w.write_u8(posting_tail_codec_to_tag(group.posting_tail_codec))?;
441 w.write_ipc(&group.batch)?;
442 }
443 }
444 Ok(())
445 }
446
447 fn deserialize(r: &mut CacheEntryReader<'_>) -> Result<Self> {
448 match r.version() {
449 1 => return deserialize_materialized_group(r),
450 2 | 3 | Self::CURRENT_VERSION => {}
451 other => {
452 return Err(Error::io(format!(
453 "unsupported PostingListGroup cache version: {other}"
454 )));
455 }
456 }
457
458 let variant = r.read_u8()?;
459 match variant {
460 GROUP_VARIANT_MATERIALIZED => deserialize_materialized_group(r),
461 GROUP_VARIANT_PACKED => {
462 let header: PostingListGroupHeader = r.read_header()?;
463 let posting_tail_codec = posting_tail_codec_from_tag(r.read_u8()?)?;
464 let batch = r.read_ipc()?;
465 if batch.num_rows() != header.count as usize {
466 return Err(Error::io(format!(
467 "packed posting group row count {} does not match header count {}",
468 batch.num_rows(),
469 header.count
470 )));
471 }
472 Self::new_packed(batch, posting_tail_codec)
473 }
474 other => Err(Error::io(format!(
475 "unknown PostingListGroup variant: {other}"
476 ))),
477 }
478 }
479}
480
481fn deserialize_materialized_group(r: &mut CacheEntryReader<'_>) -> Result<PostingListGroup> {
482 let header: PostingListGroupHeader = r.read_header()?;
483 let mut posting_lists = Vec::with_capacity(header.count as usize);
484 for _ in 0..header.count {
485 posting_lists.push(deserialize_posting_list_body(r)?);
486 }
487 Ok(PostingListGroup::new(posting_lists))
488}
489
490impl CacheCodecImpl for Positions {
495 const TYPE_ID: &'static str = "lance.fts.Positions";
496 const CURRENT_VERSION: u32 = 1;
497
498 fn serialize(&self, w: &mut CacheEntryWriter<'_>) -> Result<()> {
499 let (position_storage, position_stream_codec) = match &self.0 {
500 CompressedPositionStorage::LegacyPerDoc(_) => {
501 (PbPositionStorage::Legacy, PbPositionStreamCodec::default())
502 }
503 CompressedPositionStorage::SharedStream(stream) => (
504 PbPositionStorage::Shared,
505 position_stream_codec_to_proto(stream.codec()),
506 ),
507 };
508 let header = PositionsHeader {
509 position_storage: position_storage as i32,
510 position_stream_codec: position_stream_codec as i32,
511 };
512 w.write_header(&header)?;
513 write_position_sections(w, &self.0)
514 }
515
516 fn deserialize(r: &mut CacheEntryReader<'_>) -> Result<Self> {
517 let header: PositionsHeader = r.read_header()?;
518 let stream_codec = proto_to_position_stream_codec(header.position_stream_codec());
519 read_position_sections(r, header.position_storage(), stream_codec)?
520 .map(Self)
521 .ok_or_else(|| {
522 Error::io("Positions cache entry cannot encode the None variant".to_string())
523 })
524 }
525}
526
527#[cfg(test)]
532mod tests {
533 use std::collections::HashMap;
534 use std::sync::Arc;
535
536 use arrow::buffer::ScalarBuffer;
537 use arrow_array::builder::{Int32Builder, LargeBinaryBuilder, ListBuilder};
538 use arrow_array::{Array, LargeBinaryArray, RecordBatch};
539 use arrow_schema::{Field, Schema};
540 use bytes::Bytes;
541 use lance_core::Result;
542 use lance_core::cache::{CacheCodecImpl, CacheEntryReader, CacheEntryWriter};
543
544 use crate::cache_pb::{CompressedPostingHeader, PostingTailCodec as PbPostingTailCodec};
545
546 use super::super::impact::{ImpactSkipData, ImpactSkipDataBuilder};
547 use super::super::index::{
548 CompressedPositionStorage, CompressedPostingList, IMPACT_COL, POSTING_BLOCK_SIZE_KEY,
549 POSTING_COL, PlainPostingList, PositionStreamCodec, Positions, PostingList,
550 PostingListGroup, PostingTailCodec, SharedPositionStream,
551 };
552 use super::super::tokenizer::LEGACY_BLOCK_SIZE;
553
554 fn legacy_positions(rows: &[&[i32]]) -> arrow_array::ListArray {
555 let mut builder = ListBuilder::new(Int32Builder::new());
556 for row in rows {
557 for v in *row {
558 builder.values().append_value(*v);
559 }
560 builder.append(true);
561 }
562 builder.finish()
563 }
564
565 fn packed_batch(postings: &[Vec<Vec<u8>>], block_size: Option<usize>) -> RecordBatch {
566 let mut builder = ListBuilder::new(LargeBinaryBuilder::new());
567 for posting in postings {
568 for block in posting {
569 builder.values().append_value(block);
570 }
571 builder.append(true);
572 }
573 let postings = builder.finish();
574 let fields = vec![Field::new(POSTING_COL, postings.data_type().clone(), false)];
575 let schema = Arc::new(match block_size {
576 Some(block_size) => Schema::new_with_metadata(
577 fields,
578 HashMap::from([(POSTING_BLOCK_SIZE_KEY.to_owned(), block_size.to_string())]),
579 ),
580 None => Schema::new(fields),
581 });
582 RecordBatch::try_new(schema, vec![Arc::new(postings)]).unwrap()
583 }
584
585 fn packed_group(
586 postings: &[Vec<Vec<u8>>],
587 posting_tail_codec: PostingTailCodec,
588 block_size: Option<usize>,
589 ) -> PostingListGroup {
590 PostingListGroup::new_packed(packed_batch(postings, block_size), posting_tail_codec)
591 .unwrap()
592 }
593
594 fn packed_group_with_impacts(
595 postings: &[Vec<Vec<u8>>],
596 impacts: &[ImpactSkipData],
597 posting_tail_codec: PostingTailCodec,
598 block_size: usize,
599 ) -> PostingListGroup {
600 assert_eq!(postings.len(), impacts.len());
601 let posting_batch = packed_batch(postings, Some(block_size));
602 let mut impacts_builder = ListBuilder::new(LargeBinaryBuilder::new());
603 for impacts in impacts {
604 for entry_idx in 0..impacts.entries().len() {
605 impacts_builder
606 .values()
607 .append_value(impacts.entries().value(entry_idx));
608 }
609 impacts_builder.append(true);
610 }
611 let impacts = impacts_builder.finish();
612 let fields = vec![
613 Field::new(
614 POSTING_COL,
615 posting_batch.column(0).data_type().clone(),
616 false,
617 ),
618 Field::new(IMPACT_COL, impacts.data_type().clone(), false),
619 ];
620 let schema = Arc::new(Schema::new_with_metadata(
621 fields,
622 posting_batch.schema_ref().metadata().clone(),
623 ));
624 let batch = RecordBatch::try_new(
625 schema,
626 vec![posting_batch.column(0).clone(), Arc::new(impacts)],
627 )
628 .unwrap();
629 PostingListGroup::new_packed(batch, posting_tail_codec).unwrap()
630 }
631
632 fn assert_plain_eq(a: &PlainPostingList, b: &PlainPostingList) {
633 assert_eq!(a.row_ids.as_ref(), b.row_ids.as_ref());
634 assert_eq!(a.frequencies.as_ref(), b.frequencies.as_ref());
635 assert_eq!(a.max_score, b.max_score);
636 match (&a.positions, &b.positions) {
637 (None, None) => {}
638 (Some(x), Some(y)) => assert_eq!(x, y),
639 _ => panic!("positions mismatch"),
640 }
641 }
642
643 fn assert_position_storage_eq(a: &CompressedPositionStorage, b: &CompressedPositionStorage) {
644 match (a, b) {
645 (
646 CompressedPositionStorage::LegacyPerDoc(x),
647 CompressedPositionStorage::LegacyPerDoc(y),
648 ) => assert_eq!(x, y),
649 (
650 CompressedPositionStorage::SharedStream(x),
651 CompressedPositionStorage::SharedStream(y),
652 ) => {
653 assert_eq!(x.codec(), y.codec());
654 assert_eq!(x.block_offsets(), y.block_offsets());
655 assert_eq!(x.bytes(), y.bytes());
656 }
657 _ => panic!("position storage variant mismatch"),
658 }
659 }
660
661 fn impact_skip_data(level0_len: usize, block_size: usize) -> ImpactSkipData {
662 let mut builder = ImpactSkipDataBuilder::with_capacity(level0_len, block_size);
663 for block_idx in 0..level0_len {
664 let doc_base = block_idx as u32 * 10;
665 builder
666 .append_block(&[
667 (doc_base + 1, block_idx as u32 + 1, 10),
668 (doc_base + 9, block_idx as u32 + 2, 8),
669 ])
670 .unwrap();
671 }
672 builder.finish().unwrap()
673 }
674
675 fn body_bytes<T: CacheCodecImpl>(entry: &T) -> Bytes {
677 let mut buf = Vec::new();
678 let mut w = CacheEntryWriter::new(&mut buf);
679 entry.serialize(&mut w).unwrap();
680 Bytes::from(buf)
681 }
682
683 fn from_body<T: CacheCodecImpl>(data: &Bytes) -> Result<T> {
685 let mut r = CacheEntryReader::new(data, 0, T::CURRENT_VERSION);
686 T::deserialize(&mut r)
687 }
688
689 fn from_body_version<T: CacheCodecImpl>(data: &Bytes, version: u32) -> Result<T> {
690 let mut r = CacheEntryReader::new(data, 0, version);
691 T::deserialize(&mut r)
692 }
693
694 fn compressed_body_with_ipc_sections(
695 blocks: &RecordBatch,
696 impacts: Option<&RecordBatch>,
697 ) -> Bytes {
698 let mut buf = Vec::new();
699 let mut w = CacheEntryWriter::new(&mut buf);
700 w.write_u8(super::POSTING_VARIANT_COMPRESSED).unwrap();
701 w.write_header(&CompressedPostingHeader {
702 max_score: 1.0,
703 length: 1,
704 posting_tail_codec: PbPostingTailCodec::VarintDelta as i32,
705 block_size: 256,
706 has_impacts: impacts.is_some(),
707 ..Default::default()
708 })
709 .unwrap();
710 w.write_ipc(blocks).unwrap();
711 if let Some(impacts) = impacts {
712 w.write_ipc(impacts).unwrap();
713 }
714 Bytes::from(buf)
715 }
716
717 fn roundtrip_posting_list(entry: &PostingList) -> PostingList {
718 from_body::<PostingList>(&body_bytes(entry)).unwrap()
719 }
720
721 fn roundtrip_positions(entry: &Positions) -> Positions {
722 from_body::<Positions>(&body_bytes(entry)).unwrap()
723 }
724
725 fn assert_slice_points_into_bytes(slice: &[u8], bytes: &Bytes) {
726 let slice_start = slice.as_ptr() as usize;
727 let slice_end = slice_start + slice.len();
728 let bytes_start = bytes.as_ptr() as usize;
729 let bytes_end = bytes_start + bytes.len();
730 assert!(
731 slice_start >= bytes_start && slice_end <= bytes_end,
732 "slice [{slice_start:#x}, {slice_end:#x}) should point into bytes \
733 [{bytes_start:#x}, {bytes_end:#x})",
734 );
735 }
736
737 #[test]
738 fn plain_posting_list_no_positions_roundtrip() {
739 let plain = PlainPostingList::new(
740 ScalarBuffer::from(vec![10u64, 20, 30]),
741 ScalarBuffer::from(vec![0.5f32, 1.0, 1.5]),
742 Some(2.0),
743 None,
744 );
745 let entry = PostingList::Plain(plain.clone());
746 match roundtrip_posting_list(&entry) {
747 PostingList::Plain(restored) => assert_plain_eq(&plain, &restored),
748 PostingList::Compressed(_) => panic!("expected Plain variant"),
749 }
750 }
751
752 #[test]
753 fn plain_posting_list_with_positions_roundtrip() {
754 let plain = PlainPostingList::new(
755 ScalarBuffer::from(vec![1u64, 2]),
756 ScalarBuffer::from(vec![1.0f32, 1.0]),
757 None,
758 Some(legacy_positions(&[&[3, 7], &[1, 4, 9]])),
759 );
760 let entry = PostingList::Plain(plain.clone());
761 match roundtrip_posting_list(&entry) {
762 PostingList::Plain(restored) => assert_plain_eq(&plain, &restored),
763 PostingList::Compressed(_) => panic!("expected Plain variant"),
764 }
765 }
766
767 #[test]
768 fn compressed_posting_list_no_positions_roundtrip() {
769 let blocks = LargeBinaryArray::from_opt_vec(vec![
771 Some(&[1u8, 2, 3, 4, 5][..]),
772 Some(&[6, 7, 8, 9, 10][..]),
773 ]);
774 let posting = CompressedPostingList::new(
775 blocks,
776 3.5,
777 42,
778 PostingTailCodec::VarintDelta,
779 256,
780 None,
781 None,
782 );
783 let entry = PostingList::Compressed(posting.clone());
784 match roundtrip_posting_list(&entry) {
785 PostingList::Compressed(restored) => {
786 assert_eq!(restored.max_score, posting.max_score);
787 assert_eq!(restored.length, posting.length);
788 assert_eq!(restored.posting_tail_codec, posting.posting_tail_codec);
789 assert_eq!(restored.block_size, posting.block_size);
790 assert_eq!(restored.blocks, posting.blocks);
791 assert!(restored.positions.is_none());
792 }
793 PostingList::Plain(_) => panic!("expected Compressed variant"),
794 }
795 }
796
797 #[test]
798 fn compressed_posting_list_impacts_roundtrip() {
799 let blocks = LargeBinaryArray::from_opt_vec(vec![
800 Some(&[1u8, 2, 3, 4, 5][..]),
801 Some(&[6, 7, 8, 9, 10][..]),
802 ]);
803 let impacts = impact_skip_data(blocks.len(), 256);
804 let posting = CompressedPostingList::new(
805 blocks,
806 3.5,
807 42,
808 PostingTailCodec::VarintDelta,
809 256,
810 None,
811 Some(impacts.clone()),
812 );
813 let entry = PostingList::Compressed(posting);
814 match roundtrip_posting_list(&entry) {
815 PostingList::Compressed(restored) => {
816 let restored = restored.impacts.expect("impacts should roundtrip");
817 assert_eq!(restored.level0_len(), impacts.level0_len());
818 assert_eq!(restored.level1_len(), impacts.level1_len());
819 assert_eq!(restored.entries(), impacts.entries());
820 }
821 PostingList::Plain(_) => panic!("expected Compressed variant"),
822 }
823 }
824
825 #[test]
826 fn compressed_posting_list_missing_ipc_columns_returns_error() {
827 let empty = RecordBatch::new_empty(Arc::new(Schema::empty()));
828 assert!(
829 from_body::<PostingList>(&compressed_body_with_ipc_sections(&empty, None)).is_err()
830 );
831
832 let blocks = LargeBinaryArray::from_opt_vec(vec![Some(&[1_u8, 2, 3][..])]);
833 let schema = Arc::new(Schema::new(vec![Field::new(
834 super::BLOCKS_COLUMN,
835 blocks.data_type().clone(),
836 false,
837 )]));
838 let blocks = RecordBatch::try_new(schema, vec![Arc::new(blocks)]).unwrap();
839 assert!(
840 from_body::<PostingList>(&compressed_body_with_ipc_sections(&blocks, Some(&empty)))
841 .is_err()
842 );
843 }
844
845 #[test]
846 fn compressed_posting_list_v1_cache_without_impacts_decodes() {
847 let posting = CompressedPostingList::new(
848 LargeBinaryArray::from_opt_vec(vec![Some(&[1u8, 2, 3][..])]),
849 1.25,
850 5,
851 PostingTailCodec::Fixed32,
852 crate::scalar::inverted::LEGACY_BLOCK_SIZE,
853 None,
854 None,
855 );
856 let data = body_bytes(&PostingList::Compressed(posting));
857 let restored = from_body_version::<PostingList>(&data, 1).unwrap();
858 let PostingList::Compressed(restored) = restored else {
859 panic!("expected Compressed variant");
860 };
861 assert!(restored.impacts.is_none());
862 }
863
864 #[test]
865 fn compressed_posting_list_legacy_positions_roundtrip() {
866 let blocks = LargeBinaryArray::from_opt_vec(vec![Some(&[1u8, 2, 3][..])]);
867 let posting = CompressedPostingList::new(
868 blocks,
869 1.25,
870 5,
871 PostingTailCodec::Fixed32,
872 crate::scalar::inverted::LEGACY_BLOCK_SIZE,
873 Some(CompressedPositionStorage::LegacyPerDoc(legacy_positions(
874 &[&[0, 4, 8]],
875 ))),
876 None,
877 );
878 let entry = PostingList::Compressed(posting.clone());
879 match roundtrip_posting_list(&entry) {
880 PostingList::Compressed(restored) => {
881 assert_eq!(restored.posting_tail_codec, posting.posting_tail_codec);
882 assert_position_storage_eq(
883 restored.positions.as_ref().unwrap(),
884 posting.positions.as_ref().unwrap(),
885 );
886 }
887 PostingList::Plain(_) => panic!("expected Compressed variant"),
888 }
889 }
890
891 #[test]
892 fn compressed_posting_list_shared_stream_roundtrip() {
893 for codec in [
894 PositionStreamCodec::VarintDocDelta,
895 PositionStreamCodec::PackedDelta,
896 ] {
897 let blocks = LargeBinaryArray::from_opt_vec(vec![Some(&[9u8; 16][..])]);
898 let stream = SharedPositionStream::new(
899 codec,
900 vec![0u32, 4, 11],
901 Bytes::from((0u8..32).collect::<Vec<_>>()),
902 );
903 let posting = CompressedPostingList::new(
904 blocks,
905 7.0,
906 3,
907 PostingTailCodec::VarintDelta,
908 256,
909 Some(CompressedPositionStorage::SharedStream(stream)),
910 None,
911 );
912 let entry = PostingList::Compressed(posting.clone());
913 match roundtrip_posting_list(&entry) {
914 PostingList::Compressed(restored) => {
915 assert_position_storage_eq(
916 restored.positions.as_ref().unwrap(),
917 posting.positions.as_ref().unwrap(),
918 );
919 }
920 PostingList::Plain(_) => panic!("expected Compressed variant"),
921 }
922 }
923 }
924
925 #[test]
926 fn shared_stream_deserialize_borrows_from_input_bytes() {
927 let blocks = LargeBinaryArray::from_opt_vec(vec![Some(&[9u8; 16][..])]);
928 let expected_stream = SharedPositionStream::new(
929 PositionStreamCodec::PackedDelta,
930 vec![0u32, 4, 11],
931 Bytes::from((0u8..32).collect::<Vec<_>>()),
932 );
933 let posting = CompressedPostingList::new(
934 blocks,
935 7.0,
936 3,
937 PostingTailCodec::VarintDelta,
938 256,
939 Some(CompressedPositionStorage::SharedStream(
940 expected_stream.clone(),
941 )),
942 None,
943 );
944 let serialized = body_bytes(&PostingList::Compressed(posting));
945
946 let restored = from_body::<PostingList>(&serialized).unwrap();
947 let PostingList::Compressed(restored) = restored else {
948 panic!("expected Compressed variant");
949 };
950 let Some(CompressedPositionStorage::SharedStream(stream)) = restored.positions else {
951 panic!("expected shared-stream positions");
952 };
953
954 assert_eq!(stream.codec(), expected_stream.codec());
955 assert_eq!(stream.block_offsets(), expected_stream.block_offsets());
956 assert_eq!(stream.bytes(), expected_stream.bytes());
957 assert_slice_points_into_bytes(stream.bytes(), &serialized);
958 }
959
960 #[test]
961 fn posting_list_group_roundtrip() {
962 let plain = PostingList::Plain(PlainPostingList::new(
964 ScalarBuffer::from(vec![1u64, 2, 3]),
965 ScalarBuffer::from(vec![1.0f32, 2.0, 3.0]),
966 Some(4.0),
967 None,
968 ));
969 let compressed = PostingList::Compressed(CompressedPostingList::new(
970 LargeBinaryArray::from_opt_vec(vec![Some(&[1u8, 2, 3][..])]),
971 2.5,
972 7,
973 PostingTailCodec::VarintDelta,
974 256,
975 None,
976 None,
977 ));
978
979 for members in [
980 Vec::new(),
981 vec![plain.clone()],
982 vec![plain.clone(), compressed, plain],
983 ] {
984 let group = PostingListGroup::new(members.clone());
985 let restored = from_body::<PostingListGroup>(&body_bytes(&group)).unwrap();
986 assert!(!restored.is_packed());
987 assert_eq!(restored.len(), members.len());
988 for (index, a) in members.iter().enumerate() {
989 let b = restored.posting_list(index, None, None).unwrap().unwrap();
990 match (a, &b) {
991 (PostingList::Plain(x), PostingList::Plain(y)) => assert_plain_eq(x, y),
992 (PostingList::Compressed(x), PostingList::Compressed(y)) => {
993 assert_eq!(x.blocks, y.blocks);
994 assert_eq!(x.length, y.length);
995 assert_eq!(x.max_score, y.max_score);
996 }
997 _ => panic!("variant mismatch in group roundtrip"),
998 }
999 }
1000 }
1001 }
1002
1003 #[test]
1004 fn packed_posting_list_group_roundtrip_and_v1_fallback() {
1005 let group = packed_group(
1006 &[vec![vec![1, 2, 3], vec![4, 5]], vec![vec![7; 16 * 1024]]],
1007 PostingTailCodec::VarintDelta,
1008 Some(256),
1009 );
1010 let restored = from_body::<PostingListGroup>(&body_bytes(&group)).unwrap();
1011 assert!(restored.is_packed());
1012 assert_eq!(restored.len(), 2);
1013 for slot in 0..2 {
1014 let max_score = [1.5, 3.25][slot];
1015 let length = [3, 4096][slot];
1016 let expected = group
1017 .posting_list(slot, Some(max_score), Some(length))
1018 .unwrap()
1019 .unwrap();
1020 let actual = restored
1021 .posting_list(slot, Some(max_score), Some(length))
1022 .unwrap()
1023 .unwrap();
1024 let (PostingList::Compressed(expected), PostingList::Compressed(actual)) =
1025 (expected, actual)
1026 else {
1027 panic!("expected compressed packed posting views");
1028 };
1029 assert_eq!(actual.blocks, expected.blocks);
1030 assert_eq!(actual.max_score, expected.max_score);
1031 assert_eq!(actual.length, expected.length);
1032 assert_eq!(actual.posting_tail_codec, expected.posting_tail_codec);
1033 assert_eq!(actual.block_size, 256);
1034 }
1035
1036 let legacy_packed =
1037 packed_group(&[vec![vec![9, 8, 7]]], PostingTailCodec::VarintDelta, None);
1038 let restored = from_body::<PostingListGroup>(&body_bytes(&legacy_packed)).unwrap();
1039 let PostingList::Compressed(posting) = restored
1040 .posting_list(0, Some(2.0), Some(3))
1041 .unwrap()
1042 .unwrap()
1043 else {
1044 panic!("expected compressed legacy packed posting");
1045 };
1046 assert_eq!(posting.block_size, LEGACY_BLOCK_SIZE);
1047
1048 let legacy_member = PostingList::Compressed(CompressedPostingList::new(
1049 LargeBinaryArray::from_opt_vec(vec![Some(&[9u8, 8, 7][..])]),
1050 2.0,
1051 3,
1052 PostingTailCodec::VarintDelta,
1053 LEGACY_BLOCK_SIZE,
1054 None,
1055 None,
1056 ));
1057 let mut legacy_body = Vec::new();
1058 let mut writer = CacheEntryWriter::new(&mut legacy_body);
1059 writer
1060 .write_header(&crate::cache_pb::PostingListGroupHeader { count: 1 })
1061 .unwrap();
1062 legacy_member.serialize(&mut writer).unwrap();
1063 let legacy_body = Bytes::from(legacy_body);
1064 let mut reader = CacheEntryReader::new(&legacy_body, 0, 1);
1065 let restored = PostingListGroup::deserialize(&mut reader).unwrap();
1066 assert!(!restored.is_packed());
1067 assert_eq!(restored.len(), 1);
1068 }
1069
1070 #[test]
1071 fn posting_list_group_impacted_compressed_members_roundtrip() {
1072 let first = CompressedPostingList::new(
1073 LargeBinaryArray::from_opt_vec(vec![Some(&[1u8, 2, 3][..]), Some(&[4u8, 5, 6][..])]),
1074 3.0,
1075 256,
1076 PostingTailCodec::VarintDelta,
1077 LEGACY_BLOCK_SIZE,
1078 None,
1079 Some(impact_skip_data(2, LEGACY_BLOCK_SIZE)),
1080 );
1081 let second = CompressedPostingList::new(
1082 LargeBinaryArray::from_opt_vec(vec![Some(&[7u8, 8, 9][..])]),
1083 5.0,
1084 128,
1085 PostingTailCodec::Fixed32,
1086 256,
1087 Some(CompressedPositionStorage::SharedStream(
1088 SharedPositionStream::new(
1089 PositionStreamCodec::PackedDelta,
1090 vec![0u32, 12],
1091 Bytes::from(vec![0xABu8; 32]),
1092 ),
1093 )),
1094 Some(impact_skip_data(1, 256)),
1095 );
1096 let members = vec![
1097 PostingList::Compressed(first.clone()),
1098 PostingList::Compressed(second.clone()),
1099 ];
1100 let group = PostingListGroup::new(members);
1101 let restored = from_body::<PostingListGroup>(&body_bytes(&group)).unwrap();
1102 assert!(!restored.is_packed());
1103 assert_eq!(restored.len(), 2);
1104
1105 let expected = [&first, &second];
1106 for (slot, expected) in expected.iter().enumerate() {
1107 let restored = restored.posting_list(slot, None, None).unwrap().unwrap();
1108 let PostingList::Compressed(restored) = restored else {
1109 panic!("expected compressed member");
1110 };
1111 assert_eq!(restored.blocks, expected.blocks);
1112 assert_eq!(restored.length, expected.length);
1113 assert_eq!(restored.max_score, expected.max_score);
1114 assert_eq!(restored.posting_tail_codec, expected.posting_tail_codec);
1115 assert_eq!(restored.block_size, expected.block_size);
1116 assert_eq!(
1117 restored.impacts.as_ref().unwrap().entries(),
1118 expected.impacts.as_ref().unwrap().entries()
1119 );
1120 match (&expected.positions, &restored.positions) {
1121 (Some(expected), Some(restored)) => {
1122 assert_position_storage_eq(expected, restored);
1123 }
1124 (None, None) => {}
1125 _ => panic!("position storage mismatch"),
1126 }
1127 }
1128 }
1129
1130 #[test]
1131 fn packed_posting_list_group_impacts_roundtrip() {
1132 let postings = vec![vec![vec![1, 2, 3], vec![4, 5, 6]], vec![vec![7, 8, 9]]];
1133 let expected_impacts = vec![impact_skip_data(2, 256), impact_skip_data(1, 256)];
1134 let group = packed_group_with_impacts(
1135 &postings,
1136 &expected_impacts,
1137 PostingTailCodec::VarintDelta,
1138 256,
1139 );
1140
1141 let restored = from_body::<PostingListGroup>(&body_bytes(&group)).unwrap();
1142 assert!(restored.is_packed());
1143 assert_eq!(restored.len(), expected_impacts.len());
1144 for (slot, expected) in expected_impacts.iter().enumerate() {
1145 let posting = restored
1146 .posting_list(slot, Some(3.0), Some(256))
1147 .unwrap()
1148 .unwrap();
1149 let PostingList::Compressed(posting) = posting else {
1150 panic!("expected compressed packed posting");
1151 };
1152 let actual = posting.impacts.as_ref().expect("impacts should roundtrip");
1153 assert_eq!(actual.entries(), expected.entries());
1154 assert_eq!(actual.level0_len(), expected.level0_len());
1155 assert_eq!(
1156 actual.level1_doc_up_to(0),
1157 expected.level1_doc_up_to(0),
1158 "impact entries should remain decodable with the packed block size",
1159 );
1160 assert!(actual.level1_doc_up_to(0).is_some());
1161 }
1162 }
1163
1164 #[test]
1165 fn positions_legacy_roundtrip() {
1166 let positions = Positions(CompressedPositionStorage::LegacyPerDoc(legacy_positions(
1167 &[&[1, 2, 3], &[], &[10]],
1168 )));
1169 let restored = roundtrip_positions(&positions);
1170 assert_position_storage_eq(&positions.0, &restored.0);
1171 }
1172
1173 #[test]
1174 fn positions_shared_stream_roundtrip() {
1175 let stream = SharedPositionStream::new(
1176 PositionStreamCodec::PackedDelta,
1177 vec![0u32, 8],
1178 Bytes::from(vec![0xAAu8; 24]),
1179 );
1180 let positions = Positions(CompressedPositionStorage::SharedStream(stream));
1181 let restored = roundtrip_positions(&positions);
1182 assert_position_storage_eq(&positions.0, &restored.0);
1183 }
1184
1185 #[test]
1186 fn truncated_data_errors() {
1187 let plain = PlainPostingList::new(
1188 ScalarBuffer::from(vec![1u64]),
1189 ScalarBuffer::from(vec![1.0f32]),
1190 None,
1191 None,
1192 );
1193 let entry = PostingList::Plain(plain);
1194 let mut buf = body_bytes(&entry).to_vec();
1195 buf.truncate(buf.len() / 2);
1196 assert!(from_body::<PostingList>(&Bytes::from(buf)).is_err());
1197 }
1198
1199 mod stable_format {
1202 use std::sync::Arc;
1203
1204 use arrow_array::Array;
1205 use arrow_schema::DataType;
1206 use lance_core::cache::{
1207 CacheCodec, CacheCodecImpl, CacheDecode, CacheEntryReader, CacheEntryWriter,
1208 CacheMissReason,
1209 };
1210 use lance_core::{Error, Result};
1211 use prost::Message;
1212
1213 use super::super::{
1214 BLOCKS_COLUMN, GROUP_VARIANT_PACKED, POSTING_VARIANT_COMPRESSED,
1215 posting_tail_codec_to_tag,
1216 };
1217 use super::*;
1218 use crate::cache_pb::{
1219 CompressedPostingHeader, PostingListGroupHeader, PostingTailCodec as PbPostingTailCodec,
1220 };
1221
1222 type ArcAny = Arc<dyn std::any::Any + Send + Sync>;
1223
1224 struct PostingListV2Codec(PostingList);
1225
1226 impl CacheCodecImpl for PostingListV2Codec {
1227 const TYPE_ID: &'static str = <PostingList as CacheCodecImpl>::TYPE_ID;
1228 const CURRENT_VERSION: u32 = 2;
1229
1230 fn serialize(&self, w: &mut CacheEntryWriter<'_>) -> Result<()> {
1231 self.0.serialize(w)
1232 }
1233
1234 fn deserialize(r: &mut CacheEntryReader<'_>) -> Result<Self> {
1235 PostingList::deserialize(r).map(Self)
1236 }
1237 }
1238
1239 struct PostingListGroupV3Codec(PostingListGroup);
1240
1241 impl CacheCodecImpl for PostingListGroupV3Codec {
1242 const TYPE_ID: &'static str = <PostingListGroup as CacheCodecImpl>::TYPE_ID;
1243 const CURRENT_VERSION: u32 = 3;
1244
1245 fn serialize(&self, w: &mut CacheEntryWriter<'_>) -> Result<()> {
1246 self.0.serialize(w)
1247 }
1248
1249 fn deserialize(r: &mut CacheEntryReader<'_>) -> Result<Self> {
1250 PostingListGroup::deserialize(r).map(Self)
1251 }
1252 }
1253
1254 struct LegacyCompressedPostingV1 {
1255 blocks: LargeBinaryArray,
1256 }
1257
1258 impl CacheCodecImpl for LegacyCompressedPostingV1 {
1259 const TYPE_ID: &'static str = <PostingList as CacheCodecImpl>::TYPE_ID;
1260 const CURRENT_VERSION: u32 = 1;
1261
1262 fn serialize(&self, w: &mut CacheEntryWriter<'_>) -> Result<()> {
1263 w.write_u8(POSTING_VARIANT_COMPRESSED)?;
1264 w.write_header(&CompressedPostingHeader {
1265 max_score: 2.0,
1266 length: 3,
1267 posting_tail_codec: PbPostingTailCodec::VarintDelta as i32,
1268 ..Default::default()
1269 })?;
1270 let schema = Arc::new(Schema::new(vec![Field::new(
1271 BLOCKS_COLUMN,
1272 DataType::LargeBinary,
1273 false,
1274 )]));
1275 let batch = RecordBatch::try_new(schema, vec![Arc::new(self.blocks.clone())])?;
1276 w.write_ipc(&batch)
1277 }
1278
1279 fn deserialize(_r: &mut CacheEntryReader<'_>) -> Result<Self> {
1280 Err(Error::io(
1281 "LegacyCompressedPostingV1 is a writer-only test codec".to_string(),
1282 ))
1283 }
1284 }
1285
1286 struct LegacyPackedGroupV2 {
1287 batch: RecordBatch,
1288 posting_tail_codec: PostingTailCodec,
1289 }
1290
1291 impl CacheCodecImpl for LegacyPackedGroupV2 {
1292 const TYPE_ID: &'static str = <PostingListGroup as CacheCodecImpl>::TYPE_ID;
1293 const CURRENT_VERSION: u32 = 2;
1294
1295 fn serialize(&self, w: &mut CacheEntryWriter<'_>) -> Result<()> {
1296 w.write_u8(GROUP_VARIANT_PACKED)?;
1297 let count = u32::try_from(self.batch.num_rows())
1298 .map_err(|_| Error::io("legacy packed group is too large".to_string()))?;
1299 w.write_header(&PostingListGroupHeader { count })?;
1300 w.write_u8(posting_tail_codec_to_tag(self.posting_tail_codec))?;
1301 w.write_ipc(&self.batch)
1302 }
1303
1304 fn deserialize(_r: &mut CacheEntryReader<'_>) -> Result<Self> {
1305 Err(Error::io(
1306 "LegacyPackedGroupV2 is a writer-only test codec".to_string(),
1307 ))
1308 }
1309 }
1310
1311 fn codec() -> CacheCodec {
1312 CacheCodec::from_impl::<PostingList>()
1313 }
1314
1315 fn serialize_typed_entry<T: CacheCodecImpl + 'static>(entry: T) -> Vec<u8> {
1316 let any: ArcAny = Arc::new(entry);
1317 let mut buf = Vec::new();
1318 CacheCodec::from_impl::<T>()
1319 .serialize(&any, &mut buf)
1320 .unwrap();
1321 buf
1322 }
1323
1324 fn serialize_entry(entry: PostingList) -> Vec<u8> {
1326 serialize_typed_entry(entry)
1327 }
1328
1329 fn aligned_bytes(payload: &[u8]) -> Bytes {
1332 const ALIGN: usize = 64;
1333 let mut v = vec![0u8; payload.len() + ALIGN];
1334 let pad = (ALIGN - (v.as_ptr() as usize % ALIGN)) % ALIGN;
1335 v[pad..pad + payload.len()].copy_from_slice(payload);
1336 Bytes::from(v).slice(pad..pad + payload.len())
1337 }
1338
1339 fn compressed_with_shared_positions() -> PostingList {
1340 let blocks =
1341 LargeBinaryArray::from_opt_vec(vec![Some(&[9u8; 48][..]), Some(&[1u8; 48])]);
1342 let stream = SharedPositionStream::new(
1343 PositionStreamCodec::PackedDelta,
1344 vec![0u32, 4, 11],
1345 Bytes::from((0u8..64).collect::<Vec<_>>()),
1346 );
1347 PostingList::Compressed(CompressedPostingList::new(
1348 blocks,
1349 7.0,
1350 3,
1351 PostingTailCodec::VarintDelta,
1352 256,
1353 Some(CompressedPositionStorage::SharedStream(stream)),
1354 None,
1355 ))
1356 }
1357
1358 #[test]
1363 fn compressed_sections_are_zero_copy_through_envelope() {
1364 let serialized = aligned_bytes(&serialize_entry(compressed_with_shared_positions()));
1365 let restored = codec().deserialize(&serialized).hit().unwrap();
1366 let restored = restored.downcast::<PostingList>().unwrap();
1367 let PostingList::Compressed(restored) = restored.as_ref() else {
1368 panic!("expected Compressed");
1369 };
1370
1371 let base = serialized.as_ptr() as usize;
1372 let end = base + serialized.len();
1373 let points_in = |ptr: usize| ptr >= base && ptr < end;
1374
1375 for buf in restored.blocks.to_data().buffers() {
1377 assert!(
1378 points_in(buf.as_ptr() as usize),
1379 "blocks buffer was realigned out of the input — misaligned IPC section",
1380 );
1381 }
1382 let Some(CompressedPositionStorage::SharedStream(stream)) = &restored.positions else {
1384 panic!("expected shared stream");
1385 };
1386 assert!(points_in(stream.bytes().as_ptr() as usize));
1387 }
1388
1389 #[test]
1392 fn packed_group_sections_are_zero_copy_through_envelope() {
1393 let postings = vec![
1394 vec![vec![9; 48], vec![9; 48]],
1395 vec![vec![1; 48], vec![1; 48]],
1396 ];
1397 let impacts = vec![impact_skip_data(2, 256), impact_skip_data(2, 256)];
1398 let group =
1399 packed_group_with_impacts(&postings, &impacts, PostingTailCodec::VarintDelta, 256);
1400
1401 let group_codec = CacheCodec::from_impl::<PostingListGroup>();
1402 let any: ArcAny = Arc::new(group);
1403 let mut buf = Vec::new();
1404 group_codec.serialize(&any, &mut buf).unwrap();
1405 let serialized = aligned_bytes(&buf);
1406
1407 let restored = group_codec.deserialize(&serialized).hit().unwrap();
1408 let restored = restored.downcast::<PostingListGroup>().unwrap();
1409
1410 let base = serialized.as_ptr() as usize;
1411 let end = base + serialized.len();
1412 let points_in = |ptr: usize| ptr >= base && ptr < end;
1413
1414 assert!(restored.is_packed());
1415 assert_eq!(restored.len(), 2);
1416 for slot in 0..restored.len() {
1417 let member = restored
1418 .posting_list(slot, Some(7.0), Some(3))
1419 .unwrap()
1420 .unwrap();
1421 let PostingList::Compressed(member) = member else {
1422 panic!("expected Compressed member");
1423 };
1424 for buf in member.blocks.to_data().buffers() {
1425 assert!(
1426 points_in(buf.as_ptr() as usize),
1427 "group member blocks buffer was realigned out of the input — \
1428 misaligned IPC section",
1429 );
1430 }
1431 let impacts = member
1432 .impacts
1433 .as_ref()
1434 .expect("packed impacts should decode");
1435 for buf in impacts.entries().to_data().buffers() {
1436 assert!(
1437 points_in(buf.as_ptr() as usize),
1438 "group member impact buffer was realigned out of the input",
1439 );
1440 }
1441 }
1442 }
1443
1444 #[test]
1447 fn plain_sections_are_zero_copy_through_envelope() {
1448 let plain = PostingList::Plain(PlainPostingList::new(
1449 ScalarBuffer::from((0u64..64).collect::<Vec<_>>()),
1450 ScalarBuffer::from(vec![1.0f32; 64]),
1451 Some(2.0),
1452 None,
1453 ));
1454 let serialized = aligned_bytes(&serialize_entry(plain));
1455 let restored = codec().deserialize(&serialized).hit().unwrap();
1456 let restored = restored.downcast::<PostingList>().unwrap();
1457 let PostingList::Plain(restored) = restored.as_ref() else {
1458 panic!("expected Plain");
1459 };
1460
1461 let base = serialized.as_ptr() as usize;
1462 let end = base + serialized.len();
1463 let ptr = restored.row_ids.as_ptr() as usize;
1465 assert!(
1466 ptr >= base && ptr < end,
1467 "row_ids buffer was realigned out of the input — misaligned IPC section",
1468 );
1469 }
1470
1471 #[test]
1474 fn header_proto_ignores_unknown_fields() {
1475 let header = CompressedPostingHeader {
1476 max_score: 1.5,
1477 length: 9,
1478 posting_tail_codec: PbPostingTailCodec::VarintDelta as i32,
1479 ..Default::default()
1480 };
1481 let mut bytes = header.encode_to_vec();
1482 bytes.push(15 << 3);
1484 bytes.push(7);
1485 let decoded = CompressedPostingHeader::decode(bytes.as_slice()).unwrap();
1486 assert_eq!(decoded.length, 9);
1487 assert_eq!(decoded.max_score, 1.5);
1488 }
1489
1490 #[test]
1492 fn foreign_type_id_is_miss() {
1493 let group = PostingListGroup::new(vec![]);
1496 let any: ArcAny = Arc::new(group);
1497 let mut buf = Vec::new();
1498 CacheCodec::from_impl::<PostingListGroup>()
1499 .serialize(&any, &mut buf)
1500 .unwrap();
1501 assert!(codec().deserialize(&Bytes::from(buf)).hit().is_none());
1502 }
1503
1504 #[test]
1506 fn future_type_version_is_miss() {
1507 let mut buf = serialize_entry(compressed_with_shared_positions());
1508 let type_id_len = u16::from_le_bytes([buf[5], buf[6]]) as usize;
1511 let version_off = 4 + 1 + 2 + type_id_len;
1512 buf[version_off..version_off + 4].copy_from_slice(&u32::MAX.to_le_bytes());
1513 assert!(codec().deserialize(&Bytes::from(buf)).hit().is_none());
1514 }
1515
1516 #[test]
1517 fn old_codecs_reject_new_impact_envelopes_as_version_too_new() {
1518 let posting = Bytes::from(serialize_entry(compressed_with_shared_positions()));
1519 match CacheCodec::from_impl::<PostingListV2Codec>().deserialize(&posting) {
1520 CacheDecode::Miss(reason) => {
1521 assert_eq!(reason, CacheMissReason::VersionTooNew)
1522 }
1523 CacheDecode::Hit(_) => panic!("v2 PostingList codec accepted a v3 envelope"),
1524 }
1525
1526 let group = packed_group(
1527 &[vec![vec![1, 2, 3]], vec![vec![4, 5, 6]]],
1528 PostingTailCodec::VarintDelta,
1529 Some(256),
1530 );
1531 let group = Bytes::from(serialize_typed_entry(group));
1532 match CacheCodec::from_impl::<PostingListGroupV3Codec>().deserialize(&group) {
1533 CacheDecode::Miss(reason) => {
1534 assert_eq!(reason, CacheMissReason::VersionTooNew)
1535 }
1536 CacheDecode::Hit(_) => {
1537 panic!("v3 PostingListGroup codec accepted a v4 envelope")
1538 }
1539 }
1540 }
1541
1542 #[test]
1543 fn current_codecs_read_previous_main_versions() {
1544 let previous_posting = PostingListV2Codec(compressed_with_shared_positions());
1545 let previous_posting = Bytes::from(serialize_typed_entry(previous_posting));
1546 let restored = codec().deserialize(&previous_posting).hit().unwrap();
1547 let restored = restored.downcast::<PostingList>().unwrap();
1548 let PostingList::Compressed(restored) = restored.as_ref() else {
1549 panic!("expected compressed posting");
1550 };
1551 assert_eq!(restored.block_size, 256);
1552 assert!(restored.impacts.is_none());
1553
1554 let previous_group = PostingListGroupV3Codec(packed_group(
1555 &[vec![vec![1, 2, 3]], vec![vec![4, 5, 6]]],
1556 PostingTailCodec::VarintDelta,
1557 Some(256),
1558 ));
1559 let previous_group = Bytes::from(serialize_typed_entry(previous_group));
1560 let restored = CacheCodec::from_impl::<PostingListGroup>()
1561 .deserialize(&previous_group)
1562 .hit()
1563 .unwrap()
1564 .downcast::<PostingListGroup>()
1565 .unwrap();
1566 assert!(restored.is_packed());
1567 assert_eq!(restored.len(), 2);
1568 let PostingList::Compressed(restored) = restored
1569 .posting_list(0, Some(2.0), Some(3))
1570 .unwrap()
1571 .unwrap()
1572 else {
1573 panic!("expected compressed packed posting");
1574 };
1575 assert_eq!(restored.block_size, 256);
1576 assert!(restored.impacts.is_none());
1577 }
1578
1579 #[test]
1580 fn current_codecs_read_legacy_payloads_without_block_size() {
1581 let legacy_posting = LegacyCompressedPostingV1 {
1582 blocks: LargeBinaryArray::from_opt_vec(vec![Some(&[9u8, 8, 7][..])]),
1583 };
1584 let legacy_posting = Bytes::from(serialize_typed_entry(legacy_posting));
1585 let restored = codec().deserialize(&legacy_posting).hit().unwrap();
1586 let restored = restored.downcast::<PostingList>().unwrap();
1587 let PostingList::Compressed(restored) = restored.as_ref() else {
1588 panic!("expected a compressed legacy posting");
1589 };
1590 assert_eq!(restored.block_size, LEGACY_BLOCK_SIZE);
1591
1592 let legacy_batch = packed_batch(&[vec![vec![1, 2, 3]], vec![vec![4, 5, 6]]], None);
1593 assert!(
1594 !legacy_batch
1595 .schema_ref()
1596 .metadata()
1597 .contains_key(POSTING_BLOCK_SIZE_KEY)
1598 );
1599 let legacy_group = LegacyPackedGroupV2 {
1600 batch: legacy_batch,
1601 posting_tail_codec: PostingTailCodec::VarintDelta,
1602 };
1603 let legacy_group = Bytes::from(serialize_typed_entry(legacy_group));
1604 let restored = CacheCodec::from_impl::<PostingListGroup>()
1605 .deserialize(&legacy_group)
1606 .hit()
1607 .unwrap()
1608 .downcast::<PostingListGroup>()
1609 .unwrap();
1610 let PostingList::Compressed(restored) = restored
1611 .posting_list(0, Some(2.0), Some(3))
1612 .unwrap()
1613 .unwrap()
1614 else {
1615 panic!("expected a compressed legacy packed posting");
1616 };
1617 assert_eq!(restored.block_size, LEGACY_BLOCK_SIZE);
1618 }
1619
1620 #[test]
1622 fn pre_stabilization_blob_is_miss() {
1623 let mut blob = (30u64).to_le_bytes().to_vec();
1625 blob.extend_from_slice(&[0u8; 30]);
1626 assert!(codec().deserialize(&Bytes::from(blob)).hit().is_none());
1627 }
1628
1629 #[test]
1633 fn unknown_posting_variant_is_miss() {
1634 use lance_core::cache::{CacheDecode, CacheMissReason};
1635
1636 let mut buf = serialize_entry(compressed_with_shared_positions());
1637 let type_id_len = u16::from_le_bytes([buf[5], buf[6]]) as usize;
1640 let variant_off = 4 + 1 + 2 + type_id_len + 4;
1641 buf[variant_off] = 2; match codec().deserialize(&Bytes::from(buf)) {
1643 CacheDecode::Miss(reason) => assert_eq!(reason, CacheMissReason::BodyError),
1644 CacheDecode::Hit(_) => panic!("expected a BodyError miss, got a hit"),
1645 }
1646 }
1647 }
1648}