Skip to main content

summa_core/segment/
deletion.rs

1//! Immutable row visibility. Files are owned by the ordinary segment tracker.
2use 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/// A compact metadata reference to one immutable visibility generation.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct DeletionMeta {
14    /// Independently tracked file identity, not the data segment identity.
15    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
50// FNV-1a over the canonical little-endian header and payload. This detects
51// accidental corruption, including changes that preserve the population count.
52fn 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/// Writes a claimed file without a second bitmap-sized serialization buffer.
86#[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/// Copy deletion bits into a concatenated physical row space, including tails
128/// whose source boundary is not aligned to a bitmap word.
129#[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                // A previously appended source may already have tombstones.
171                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/// Bounded key resolution over the fast column's global dictionary ordinals.
224#[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/// Streaming batch decode; shared by native worker-pool and inline WASM commits.
252#[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}