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
51pub 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
71pub 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 #[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 let entities = [
202 "",
203 "a",
204 "alpha\x00beta",
205 "\x01",
206 "\x01\x00",
207 "a\x01b",
208 "trail\x00",
209 ];
210 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 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 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 let foreign = [&[0xFF, 0x00][..], &[0x11; 8][..]].concat();
300 let broken_escape = [&[0x01, 0x03, 0x00][..], &[0x11; 8][..]].concat();
302 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}