summa_core/segment/
deletion.rs1use std::path::PathBuf;
3use std::sync::Arc;
4
5use serde::{Deserialize, Serialize};
6
7use crate::directories::Directory;
8use crate::query::DocBitset;
9use crate::{Error, Result};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct DeletionMeta {
14 pub id: String,
16 pub num_deleted: u32,
17}
18
19impl DeletionMeta {
20 pub(crate) fn path(&self) -> Result<PathBuf> {
21 let id = super::SegmentId::from_hex(&self.id)
22 .ok_or_else(|| Error::Corruption("invalid deletion file identity".into()))?;
23 Ok(super::SegmentFiles::new(id.0).deletions)
24 }
25
26 pub(crate) async fn load<D: Directory>(
27 &self,
28 directory: &D,
29 num_docs: u32,
30 ) -> Result<Arc<DocBitset>> {
31 if self.num_deleted == 0 || self.num_deleted > num_docs {
32 return Err(Error::Corruption("invalid deletion count".into()));
33 }
34 let handle = directory.open_read(&self.path()?).await.map_err(|error| {
35 if error.kind() == std::io::ErrorKind::NotFound {
36 Error::Corruption(format!("referenced deletion file {} is missing", self.id))
37 } else {
38 Error::Io(error)
39 }
40 })?;
41 let expected = 24 + (num_docs as u64).div_ceil(64) * 8;
42 if handle.len() != expected {
43 return Err(Error::Corruption("invalid deletion file length".into()));
44 }
45 let bytes = handle.read_bytes().await?;
46 decode(bytes.as_slice(), num_docs, self.num_deleted).map(Arc::new)
47 }
48}
49
50fn checksum(bytes: impl IntoIterator<Item = u8>) -> u64 {
53 bytes.into_iter().fold(0xcbf29ce484222325, |hash, byte| {
54 (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3)
55 })
56}
57
58fn decode(bytes: &[u8], num_docs: u32, num_deleted: u32) -> Result<DocBitset> {
59 let invalid = || Error::Corruption("invalid row deletion bitset".into());
60 let expected = 24 + (num_docs as usize).div_ceil(64) * 8;
61 if num_deleted > num_docs || bytes.len() != expected || &bytes[..8] != b"HDEL\x01\0\0\0" {
62 return Err(invalid());
63 }
64 let read_u32 = |at| u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap());
65 if read_u32(8) != num_docs || read_u32(12) != num_deleted {
66 return Err(invalid());
67 }
68 let end = bytes.len() - 8;
69 if checksum(bytes[..end].iter().copied())
70 != u64::from_le_bytes(bytes[end..].try_into().unwrap())
71 {
72 return Err(invalid());
73 }
74 let bits = bytes[16..end]
75 .chunks_exact(8)
76 .map(|word| u64::from_le_bytes(word.try_into().unwrap()))
77 .collect();
78 let alive = DocBitset { bits };
79 if alive.count() != num_docs - num_deleted || alive.next_set_bit(num_docs).is_some() {
80 return Err(invalid());
81 }
82 Ok(alive)
83}
84
85#[cfg(any(feature = "native", feature = "wasm"))]
87pub(crate) async fn write<D: crate::directories::DirectoryWriter>(
88 directory: &D,
89 id: super::SegmentId,
90 num_docs: u32,
91 alive: &DocBitset,
92) -> Result<DeletionMeta> {
93 use std::io::Write;
94 let num_deleted = num_docs
95 .checked_sub(alive.count())
96 .ok_or_else(|| Error::Corruption("deletion population exceeds row count".into()))?;
97 if num_deleted == 0
98 || alive.bits.len() != (num_docs as usize).div_ceil(64)
99 || alive.next_set_bit(num_docs).is_some()
100 {
101 return Err(Error::Corruption("invalid deletion output".into()));
102 }
103 let metadata = DeletionMeta {
104 id: id.to_hex(),
105 num_deleted,
106 };
107 let mut header = Vec::with_capacity(16);
108 header.extend_from_slice(b"HDEL\x01\0\0\0");
109 header.extend_from_slice(&num_docs.to_le_bytes());
110 header.extend_from_slice(&num_deleted.to_le_bytes());
111 let digest = checksum(
112 header
113 .iter()
114 .copied()
115 .chain(alive.bits.iter().flat_map(|w| w.to_le_bytes())),
116 );
117 let mut writer = directory.streaming_writer_cold(&metadata.path()?).await?;
118 writer.write_all(&header)?;
119 for word in &alive.bits {
120 writer.write_all(&word.to_le_bytes())?;
121 }
122 writer.write_all(&digest.to_le_bytes())?;
123 writer.finish()?;
124 Ok(metadata)
125}
126
127#[cfg(feature = "native")]
130pub(crate) fn append_dead_rows(
131 target: &mut DocBitset,
132 source: &DocBitset,
133 num_docs: u32,
134 offset: u32,
135) -> Result<()> {
136 if (offset as u64 + num_docs as u64).div_ceil(64) > target.bits.len() as u64 {
137 return Err(Error::Corruption(
138 "deletion remap exceeds output row space".into(),
139 ));
140 }
141 for (word_index, word) in source.bits.iter().enumerate() {
142 let valid = (num_docs as usize - word_index * 64).min(64);
143 let padding = if valid == 64 {
144 u64::MAX
145 } else {
146 (1u64 << valid) - 1
147 };
148 let dead = !word & padding;
149 let start = offset as usize + word_index * 64;
150 let out = start / 64;
151 let shift = start % 64;
152 target.bits[out] &= !(dead << shift);
153 if shift != 0 && out + 1 < target.bits.len() {
154 target.bits[out + 1] &= !(dead >> (64 - shift));
155 }
156 }
157 Ok(())
158}
159
160#[cfg(all(test, feature = "native"))]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn merging_deletion_masks_preserves_every_unaligned_boundary_and_neighbor() {
166 for offset in 0..128 {
167 for len in 0..131 {
168 let total = offset + len + 65;
169 let mut target = DocBitset::all(total);
170 if offset > 0 {
172 target.clear(offset - 1);
173 }
174 let mut source = DocBitset::all(len);
175 for doc in 0..len {
176 if (doc + len) % 3 != 0 {
177 source.clear(doc);
178 }
179 }
180 append_dead_rows(&mut target, &source, len, offset).unwrap();
181 for doc in 0..total {
182 let expected = if doc >= offset && doc < offset + len {
183 source.contains(doc - offset)
184 } else {
185 offset == 0 || doc != offset - 1
186 };
187 assert_eq!(
188 target.contains(doc),
189 expected,
190 "offset={offset} len={len} row={doc}"
191 );
192 }
193 assert_eq!(target.next_set_bit(total), None);
194 }
195 }
196 }
197
198 #[tokio::test]
199 async fn deletion_files_reject_corruption_and_keep_tail_rows() {
200 let dir = crate::directories::RamDirectory::new();
201 let mut alive = DocBitset::all(65);
202 alive.clear(0);
203 let meta = write(&dir, super::super::SegmentId::new(), 65, &alive)
204 .await
205 .unwrap();
206 let loaded = meta.load(&dir, 65).await.unwrap();
207 assert!(!loaded.contains(0));
208 assert!(loaded.contains(64));
209 let bytes = dir
210 .open_read(&meta.path().unwrap())
211 .await
212 .unwrap()
213 .read_bytes()
214 .await
215 .unwrap();
216 let mut corrupt = bytes.as_slice().to_vec();
217 corrupt[16] ^= 3;
218 assert!(decode(&corrupt, 65, 1).is_err());
219 assert!(decode(bytes.as_slice(), 64, 1).is_err());
220 }
221}
222
223#[cfg(any(feature = "native", feature = "wasm"))]
225pub(crate) fn target_ordinals<'a>(
226 column: &crate::structures::fast_field::FastFieldReader,
227 num_docs: u32,
228 keys: impl Iterator<Item = &'a str>,
229 mut check_cancelled: impl FnMut() -> Result<()>,
230) -> Result<rustc_hash::FxHashSet<u64>> {
231 if column.num_docs != num_docs
232 || column.multi
233 || column.column_type != crate::structures::fast_field::FastFieldColumnType::TextOrdinal
234 {
235 return Err(Error::Corruption(
236 "primary-key column must contain one text value per physical row".into(),
237 ));
238 }
239 let mut ordinals = rustc_hash::FxHashSet::default();
240 for (i, key) in keys.enumerate() {
241 if i.is_multiple_of(4096) {
242 check_cancelled()?;
243 }
244 if let Some(ordinal) = column.text_ordinal(key) {
245 ordinals.insert(ordinal);
246 }
247 }
248 Ok(ordinals)
249}
250
251#[cfg(any(feature = "native", feature = "wasm"))]
253pub(crate) fn clear_target_rows(
254 column: &crate::structures::fast_field::FastFieldReader,
255 ordinals: &rustc_hash::FxHashSet<u64>,
256 alive: &mut DocBitset,
257 mut check_cancelled: impl FnMut() -> Result<()>,
258) -> Result<bool> {
259 let mut changed = false;
260 column.try_scan_single_values(|doc, ordinal| {
261 if doc.is_multiple_of(4096) {
262 check_cancelled()?;
263 }
264 if alive.contains(doc) && ordinals.contains(&ordinal) {
265 alive.clear(doc);
266 changed = true;
267 }
268 Ok::<_, Error>(())
269 })?;
270 Ok(changed)
271}