Skip to main content

exoware_sql/
prune.rs

1use exoware_sdk::kv_codec::Utf8;
2use exoware_sdk::prune_policy::{
3    GroupBy, KeysScope, OrderBy, OrderEncoding, PolicyScope, PrunePolicy, RetainPolicy,
4};
5use exoware_sdk::selector::Selector;
6
7use crate::codec::primary_key_prefix;
8
9const VERSION_WIDTH_BYTES: usize = 8;
10const ORDERED_UTF8_REGEX: &str = r"(?:\x01[\x00-\x02]|[^\x00\x01\xFF])*\x00";
11
12fn keep_latest_versions_with_regex(
13    table_prefix: u8,
14    min_entity_bytes: usize,
15    payload_regex: impl Into<Utf8>,
16    count: usize,
17) -> Result<PrunePolicy, String> {
18    let payload_regex = payload_regex.into();
19    if count == 0 {
20        return Err("keep_latest_versions count must be > 0".to_string());
21    }
22    let prefix = primary_key_prefix(table_prefix)?;
23    let required_bytes = min_entity_bytes
24        .checked_add(VERSION_WIDTH_BYTES)
25        .ok_or_else(|| "entity width overflowed when adding version width".to_string())?;
26    if required_bytes > prefix.max_payload_len() {
27        return Err(format!(
28            "entity width {min_entity_bytes} plus version width {VERSION_WIDTH_BYTES} exceeds primary key payload capacity {}",
29            prefix.max_payload_len()
30        ));
31    }
32
33    Ok(PrunePolicy {
34        scope: PolicyScope::Keys(KeysScope {
35            selector: Selector {
36                prefix: prefix.as_bytes().clone(),
37                payload_regex,
38            },
39            group_by: GroupBy {
40                capture_groups: vec![Utf8::from("entity")],
41            },
42            order_by: Some(OrderBy {
43                capture_group: Utf8::from("version"),
44                encoding: OrderEncoding::U64Be,
45            }),
46        }),
47        retain: RetainPolicy::KeepLatest { count },
48    })
49}
50
51/// Build a prune policy that keeps the latest `count` versions for each entity
52/// in a `exoware-sql` versioned primary-key family with a fixed-width entity key.
53///
54/// The policy assumes the key layout created by `KvSchema::table_versioned`:
55/// `[entity bytes][u64_be version]` under the table's primary-key family.
56pub fn keep_latest_versions(
57    table_prefix: u8,
58    entity_key_width: usize,
59    count: usize,
60) -> Result<PrunePolicy, String> {
61    keep_latest_versions_with_regex(
62        table_prefix,
63        entity_key_width,
64        format!(
65            r"(?s-u)^(?P<entity>.{{{entity_key_width}}})(?P<version>.{{{VERSION_WIDTH_BYTES}}})$"
66        ),
67        count,
68    )
69}
70
71/// Build a prune policy that keeps the latest `count` versions for each entity
72/// in a `exoware-sql` versioned primary-key family whose entity column is `Utf8`.
73///
74/// `table_versioned()` encodes ordered UTF-8 keys as an escape-aware byte stream
75/// terminated by `0x00`, so the entity capture must be length-delimited by that
76/// terminator rather than by a caller-provided fixed width.
77pub fn keep_latest_versions_utf8(table_prefix: u8, count: usize) -> Result<PrunePolicy, String> {
78    keep_latest_versions_with_regex(
79        table_prefix,
80        1,
81        format!(r"(?s-u)^(?P<entity>{ORDERED_UTF8_REGEX})(?P<version>.{{{VERSION_WIDTH_BYTES}}})$"),
82        count,
83    )
84}
85
86#[cfg(test)]
87mod tests {
88    use std::collections::HashSet;
89
90    use super::{keep_latest_versions, keep_latest_versions_utf8, ORDERED_UTF8_REGEX};
91    use crate::codec::{
92        decode_variable_text, encode_primary_key, encode_string_variable, family_byte,
93    };
94    use crate::types::{
95        KvTableConfig, TableColumnConfig, TableModel, PRIMARY_FAMILY_DISCRIMINATOR,
96    };
97    use crate::CellValue;
98    use datafusion::arrow::datatypes::DataType;
99    use exoware_sdk::kv_codec::Utf8;
100    use exoware_sdk::prune_policy::{validate_policy, OrderEncoding, PolicyScope, RetainPolicy};
101    use exoware_sdk::selector::compile_payload_regex;
102
103    fn keys_scope(policy: &super::PrunePolicy) -> &super::KeysScope {
104        match &policy.scope {
105            PolicyScope::Keys(s) => s,
106            PolicyScope::Sequence => panic!("expected Keys scope"),
107        }
108    }
109
110    fn entity_version_model(entity_type: DataType) -> TableModel {
111        let config = KvTableConfig::new(
112            3,
113            vec![
114                TableColumnConfig::new("entity", entity_type, false),
115                TableColumnConfig::new("version", DataType::UInt64, false),
116            ],
117            vec!["entity".to_string(), "version".to_string()],
118            vec![],
119        )
120        .expect("config");
121        TableModel::from_config(&config).expect("model")
122    }
123
124    #[test]
125    fn keep_latest_versions_builds_expected_policy_for_fixed_width_entity() {
126        let policy = keep_latest_versions(3, 32, 1).expect("policy");
127        let scope = keys_scope(&policy);
128        assert_eq!(
129            &scope.selector.prefix[..],
130            &[family_byte(3, PRIMARY_FAMILY_DISCRIMINATOR)]
131        );
132        assert_eq!(
133            scope.selector.payload_regex,
134            r"(?s-u)^(?P<entity>.{32})(?P<version>.{8})$"
135        );
136        assert_eq!(scope.group_by.capture_groups, vec![Utf8::from("entity")]);
137        assert_eq!(
138            &*scope.order_by.as_ref().expect("order_by").capture_group,
139            "version"
140        );
141        assert_eq!(
142            scope.order_by.as_ref().expect("order_by").encoding,
143            OrderEncoding::U64Be
144        );
145        assert_eq!(policy.retain, RetainPolicy::KeepLatest { count: 1 });
146        validate_policy(&policy).expect("policy should validate");
147    }
148
149    #[test]
150    fn keep_latest_versions_rejects_zero_count() {
151        let err = keep_latest_versions(3, 32, 0).expect_err("zero count should fail");
152        assert!(err.contains("count must be > 0"));
153    }
154
155    #[test]
156    fn keep_latest_versions_rejects_oversized_entity_width() {
157        let err = keep_latest_versions(3, 1000, 1).expect_err("oversized entity should fail");
158        assert!(err.contains("exceeds primary key payload capacity"));
159    }
160
161    #[test]
162    fn keep_latest_versions_utf8_builds_expected_policy() {
163        let policy = keep_latest_versions_utf8(3, 1).expect("policy");
164        let scope = keys_scope(&policy);
165        assert_eq!(
166            &scope.selector.prefix[..],
167            &[family_byte(3, PRIMARY_FAMILY_DISCRIMINATOR)]
168        );
169        assert_eq!(
170            scope.selector.payload_regex,
171            format!(r"(?s-u)^(?P<entity>{ORDERED_UTF8_REGEX})(?P<version>.{{8}})$")
172        );
173        assert_eq!(scope.group_by.capture_groups, vec![Utf8::from("entity")]);
174        assert_eq!(
175            &*scope.order_by.as_ref().expect("order_by").capture_group,
176            "version"
177        );
178        assert_eq!(
179            scope.order_by.as_ref().expect("order_by").encoding,
180            OrderEncoding::U64Be
181        );
182        assert_eq!(policy.retain, RetainPolicy::KeepLatest { count: 1 });
183        validate_policy(&policy).expect("policy should validate");
184    }
185
186    // The prune regexes re-state the primary-key layout, and enforcement
187    // silently retains any row a regex fails to match, so pin regex and codec
188    // together: every payload the codec can produce must match in full, with
189    // the grouping capture equal to the encoded entity and the order capture
190    // equal to the big-endian version.
191    #[test]
192    fn keep_latest_versions_utf8_matches_variable_length_entity_payloads() {
193        let policy = keep_latest_versions_utf8(3, 1).expect("policy");
194        let scope = keys_scope(&policy);
195        let regex = compile_payload_regex(&scope.selector.payload_regex).expect("regex");
196        let model = entity_version_model(DataType::Utf8);
197        let prefix = &model.primary_key_prefix;
198
199        // Entities exercising every escape arm: embedded terminator bytes,
200        // embedded escape-prefix bytes, and both at the boundaries.
201        let entities = [
202            "",
203            "a",
204            "alpha\x00beta",
205            "\x01",
206            "\x01\x00",
207            "a\x01b",
208            "trail\x00",
209        ];
210        // Versions whose big-endian bytes collide with the entity grammar: the
211        // 0x00 and 0x01 bytes must neither extend nor truncate the entity
212        // capture.
213        let versions = [0u64, 1, 0x0100_0000_0000_0000, 0xFF, u64::MAX];
214
215        let mut entity_captures = HashSet::new();
216        for entity in entities {
217            let mut per_entity = HashSet::new();
218            for version in versions {
219                let key = encode_primary_key(
220                    3,
221                    &[
222                        &CellValue::Utf8(entity.to_string()),
223                        &CellValue::UInt64(version),
224                    ],
225                    &model,
226                )
227                .expect("key");
228                let payload = prefix.strip(&key).expect("payload");
229                let captures = regex.captures(&payload).unwrap_or_else(|| {
230                    panic!("regex must match entity {entity:?} at version {version}")
231                });
232                assert_eq!(
233                    captures.get(0).expect("full match").as_bytes(),
234                    payload.as_ref()
235                );
236                let entity_bytes = captures.name("entity").expect("entity").as_bytes();
237                assert_eq!(
238                    entity_bytes,
239                    encode_string_variable(entity).expect("encode").as_slice()
240                );
241                assert_eq!(decode_variable_text(entity_bytes).as_deref(), Some(entity));
242                assert_eq!(
243                    captures.name("version").expect("version").as_bytes(),
244                    version.to_be_bytes().as_slice()
245                );
246                per_entity.insert(entity_bytes.to_vec());
247            }
248            // Grouping correctness: every version of one entity must land in
249            // the same group, and no two entities may share a group.
250            assert_eq!(per_entity.len(), 1, "one group per entity");
251            assert!(
252                entity_captures.insert(per_entity.into_iter().next().unwrap()),
253                "group for entity {entity:?} must be distinct"
254            );
255        }
256    }
257
258    #[test]
259    fn keep_latest_versions_matches_fixed_width_entity_payloads() {
260        let policy = keep_latest_versions(3, 8, 1).expect("policy");
261        let scope = keys_scope(&policy);
262        let regex = compile_payload_regex(&scope.selector.payload_regex).expect("regex");
263        let model = entity_version_model(DataType::UInt64);
264        let prefix = &model.primary_key_prefix;
265
266        for (entity, version) in [(0u64, 0u64), (42, 7), (42, u64::MAX), (u64::MAX, 1)] {
267            let key = encode_primary_key(
268                3,
269                &[&CellValue::UInt64(entity), &CellValue::UInt64(version)],
270                &model,
271            )
272            .expect("key");
273            let payload = prefix.strip(&key).expect("payload");
274            let captures = regex.captures(&payload).expect("captures");
275            assert_eq!(
276                captures.get(0).expect("full match").as_bytes(),
277                payload.as_ref()
278            );
279            assert_eq!(
280                captures.name("entity").expect("entity").as_bytes(),
281                entity.to_be_bytes().as_slice()
282            );
283            assert_eq!(
284                captures.name("version").expect("version").as_bytes(),
285                version.to_be_bytes().as_slice()
286            );
287            // A truncated row must not match and be silently mis-grouped.
288            assert!(!regex.is_match(&payload[..payload.len() - 1]));
289        }
290    }
291
292    #[test]
293    fn keep_latest_versions_utf8_regex_rejects_malformed_payloads() {
294        let policy = keep_latest_versions_utf8(3, 1).expect("policy");
295        let scope = keys_scope(&policy);
296        let regex = compile_payload_regex(&scope.selector.payload_regex).expect("regex");
297
298        // 0xFF cannot appear in an ordered UTF-8 entity encoding.
299        let foreign = [&[0xFF, 0x00][..], &[0x11; 8][..]].concat();
300        // Escape prefix followed by a byte outside the escape range.
301        let broken_escape = [&[0x01, 0x03, 0x00][..], &[0x11; 8][..]].concat();
302        // Too short to carry a terminator plus a version.
303        let truncated = vec![0x00_u8; 8];
304        for payload in [foreign, broken_escape, truncated] {
305            assert!(
306                !regex.is_match(&payload),
307                "regex must reject invalid payload {payload:02X?}"
308            );
309        }
310    }
311}