1use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13#[derive(
15 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
16)]
17#[serde(rename_all = "snake_case")]
18pub enum DeliveryMode {
19 #[default]
22 AtLeastOnce,
23 ExactlyOnce,
27}
28
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ReplayGuarantee {
39 #[default]
43 NonDeterministic,
44 Deterministic,
50}
51
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum SinkGuarantee {
56 #[default]
58 AtLeastOnce,
59 KeyedUpsert,
63 AtomicWatermark,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum EffectivelyOnceMechanism {
72 AtomicWatermark,
76 KeyedUpsert,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case", tag = "guarantee", content = "via")]
90pub enum DeliveryGuarantee {
91 AtLeastOnce,
94 EffectivelyOnce(EffectivelyOnceMechanism),
96}
97
98impl std::fmt::Display for DeliveryGuarantee {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 match self {
101 Self::AtLeastOnce => write!(f, "at-least-once"),
102 Self::EffectivelyOnce(EffectivelyOnceMechanism::AtomicWatermark) => {
103 write!(f, "effectively-once (atomic watermark)")
104 }
105 Self::EffectivelyOnce(EffectivelyOnceMechanism::KeyedUpsert) => {
106 write!(f, "effectively-once (keyed upsert)")
107 }
108 }
109 }
110}
111
112#[derive(Debug, Clone, Copy, Default)]
115pub struct GuaranteeInputs {
116 pub replay: ReplayGuarantee,
118 pub sink_atomic: bool,
121 pub keyed_upsert_configured: bool,
124 pub durable_state: bool,
127 pub dlq: bool,
130}
131
132pub fn derive_delivery_guarantee(i: &GuaranteeInputs) -> DeliveryGuarantee {
140 if i.sink_atomic && i.replay == ReplayGuarantee::Deterministic && i.durable_state && !i.dlq {
141 return DeliveryGuarantee::EffectivelyOnce(EffectivelyOnceMechanism::AtomicWatermark);
142 }
143 if i.keyed_upsert_configured {
144 return DeliveryGuarantee::EffectivelyOnce(EffectivelyOnceMechanism::KeyedUpsert);
145 }
146 DeliveryGuarantee::AtLeastOnce
147}
148
149const EO_MARKER: &str = "__faucet_eo";
151const EO_BOOKMARK: &str = "bookmark";
152const EO_SEQ: &str = "seq";
153
154const TOKEN_WIDTH: usize = 20;
157
158const TOKEN_BOOKMARK_SEP: char = '#';
161
162pub fn format_token(seq: u64) -> String {
164 format!("{seq:0TOKEN_WIDTH$}")
165}
166
167pub fn format_token_with_bookmark(seq: u64, bookmark: Option<&Value>) -> String {
177 match bookmark {
178 Some(bm) => format!("{seq:0TOKEN_WIDTH$}{TOKEN_BOOKMARK_SEP}{bm}"),
179 None => format_token(seq),
180 }
181}
182
183pub fn parse_token(s: &str) -> Option<u64> {
186 let seq = match s.split_once(TOKEN_BOOKMARK_SEP) {
187 Some((prefix, _)) => prefix,
188 None => s,
189 };
190 seq.trim().parse::<u64>().ok()
191}
192
193pub fn parse_token_parts(s: &str) -> Option<(u64, Option<Value>)> {
200 match s.split_once(TOKEN_BOOKMARK_SEP) {
201 Some((prefix, suffix)) => {
202 let seq = prefix.trim().parse::<u64>().ok()?;
203 Some((seq, serde_json::from_str(suffix).ok()))
204 }
205 None => Some((s.trim().parse::<u64>().ok()?, None)),
206 }
207}
208
209pub fn wrap_state(bookmark: Option<&Value>, seq: u64) -> Value {
211 serde_json::json!({
212 EO_MARKER: 1,
213 EO_BOOKMARK: bookmark.cloned().unwrap_or(Value::Null),
214 EO_SEQ: seq,
215 })
216}
217
218pub fn is_eo_envelope(value: &Value) -> bool {
220 value.get(EO_MARKER).and_then(Value::as_u64) == Some(1)
221}
222
223pub fn unwrap_state(value: &Value) -> (Option<Value>, u64) {
231 let peeled = crate::state_version::peel_versioned(value);
232 let value = &peeled;
233 if let Value::Object(map) = value
234 && map.get(EO_MARKER).and_then(Value::as_u64) == Some(1)
235 {
236 let bookmark = match map.get(EO_BOOKMARK) {
237 None | Some(Value::Null) => None,
238 Some(v) => Some(v.clone()),
239 };
240 let seq = map.get(EO_SEQ).and_then(Value::as_u64).unwrap_or(0);
241 return (bookmark, seq);
242 }
243 let bookmark = if value.is_null() {
245 None
246 } else {
247 Some(value.clone())
248 };
249 (bookmark, 0)
250}
251
252pub const COMMIT_TOKEN_TABLE: &str = "_faucet_commit_token";
254pub const COMMIT_TOKEN_SCOPE_COL: &str = "scope";
256pub const COMMIT_TOKEN_TOKEN_COL: &str = "token";
258
259pub const ICEBERG_SCOPE_PROP: &str = "faucet.commit-scope";
261pub const ICEBERG_TOKEN_PROP: &str = "faucet.commit-token";
262
263pub const OVERWRITE_STAGING_SUFFIX: &str = "__faucet_ovw";
272pub const OVERWRITE_STAGING_OLD_SUFFIX: &str = "__faucet_ovw_old";
279
280pub const OVERWRITE_RUN_KEY: &str = "_overwrite_run";
286
287pub const OVERWRITE_RUN_TOKEN_LEN: usize = 14;
290
291pub const ABANDONED_OVERWRITE_STAGING: std::time::Duration =
295 std::time::Duration::from_secs(24 * 60 * 60);
296
297pub fn new_overwrite_run_token() -> String {
300 let secs = std::time::SystemTime::now()
301 .duration_since(std::time::UNIX_EPOCH)
302 .map(|d| d.as_secs())
303 .unwrap_or(0);
304 overwrite_run_token_at(secs, &uuid::Uuid::new_v4().simple().to_string()[..6])
305}
306
307fn overwrite_run_token_at(secs: u64, random: &str) -> String {
308 format!("{:08x}{random}", secs & 0xffff_ffff)
309}
310
311pub fn is_overwrite_run_token(token: &str) -> bool {
313 token.len() == OVERWRITE_RUN_TOKEN_LEN
314 && token
315 .bytes()
316 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
317}
318
319pub fn overwrite_run_started(token: &str) -> Option<u64> {
321 is_overwrite_run_token(token)
322 .then(|| u64::from_str_radix(&token[..8], 16).ok())
323 .flatten()
324}
325
326pub fn check_overwrite_run_token(token: &str) -> Result<(), crate::FaucetError> {
329 if is_overwrite_run_token(token) {
330 Ok(())
331 } else {
332 Err(crate::FaucetError::Config(format!(
333 "`{OVERWRITE_RUN_KEY}` must be a {OVERWRITE_RUN_TOKEN_LEN}-character lowercase hex \
334 overwrite run token, got {token:?}"
335 )))
336 }
337}
338
339pub fn overwrite_staging_prefix(table: &str) -> String {
342 format!("{table}{OVERWRITE_STAGING_SUFFIX}_")
343}
344
345pub fn like_literal(text: &str, escape: char) -> String {
348 let mut out = String::with_capacity(text.len() + 4);
349 for c in text.chars() {
350 if c == '%' || c == '_' || c == escape {
351 out.push(escape);
352 }
353 out.push(c);
354 }
355 out
356}
357
358pub fn overwrite_staging_suffix(run: Option<&str>) -> String {
362 match run {
363 Some(token) => format!("{OVERWRITE_STAGING_SUFFIX}_{token}"),
364 None => OVERWRITE_STAGING_SUFFIX.to_string(),
365 }
366}
367
368pub fn overwrite_staging_token(table: &str, name: &str) -> Option<String> {
372 let prefix = overwrite_staging_prefix(table);
373 let token = (name.len() == prefix.len() + OVERWRITE_RUN_TOKEN_LEN
374 && name.is_char_boundary(prefix.len())
375 && name[..prefix.len()].eq_ignore_ascii_case(&prefix))
376 .then(|| name[prefix.len()..].to_ascii_lowercase())?;
377 is_overwrite_run_token(&token).then_some(token)
378}
379
380pub fn abandoned_overwrite_staging(
387 table: &str,
388 name: &str,
389 own: Option<&str>,
390 now_secs: u64,
391) -> bool {
392 let Some(token) = overwrite_staging_token(table, name) else {
393 return false;
394 };
395 overwrite_run_abandoned(&token, own, now_secs)
396}
397
398pub fn overwrite_run_abandoned(token: &str, own: Option<&str>, now_secs: u64) -> bool {
403 if own.is_some_and(|o| o == token) {
404 return false;
405 }
406 let Some(started) = overwrite_run_started(token) else {
407 return false;
408 };
409 let now = now_secs & 0xffff_ffff;
412 let age = if started <= now {
413 now - started
414 } else {
415 now + (1 << 32) - started
416 };
417 age > ABANDONED_OVERWRITE_STAGING.as_secs()
418}
419
420const SCOPE_DIGEST_LEN: usize = 4 + 64;
423
424pub fn scope_key(scope: &str, max: usize) -> String {
439 if scope.len() <= max {
440 return scope.to_owned();
441 }
442 use sha2::Digest;
443 let digest = sha2::Sha256::digest(scope.as_bytes());
444 let h: String = digest.iter().map(|b| format!("{b:02x}")).collect();
445 let room = max.saturating_sub(SCOPE_DIGEST_LEN);
449 let mut head = 0usize;
450 for (i, _) in scope.char_indices() {
451 if i > room {
452 break;
453 }
454 head = i;
455 }
456 let shortened = format!("{}__h:{h}", &scope[..head]);
457 tracing::debug!(
458 scope_len = scope.len(),
459 max,
460 "exactly-once scope exceeds the sink's key-column width; shortening with a digest"
461 );
462 shortened
463}
464
465pub fn legacy_scope_key(scope: &str, max: usize) -> Option<String> {
469 const LEGACY_DIGEST_LEN: usize = 4 + 16;
470 if scope.len() <= max {
471 return None;
472 }
473 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
474 for b in scope.as_bytes() {
475 h ^= u64::from(*b);
476 h = h.wrapping_mul(0x1000_0000_01b3);
477 }
478 let room = max.saturating_sub(LEGACY_DIGEST_LEN);
479 let mut head = 0usize;
480 for (i, _) in scope.char_indices() {
481 if i > room {
482 break;
483 }
484 head = i;
485 }
486 Some(format!("{}__h:{h:016x}", &scope[..head]))
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492 use serde_json::json;
493
494 #[test]
495 fn token_round_trips_and_orders_lexicographically() {
496 assert_eq!(format_token(42).len(), TOKEN_WIDTH);
497 assert_eq!(parse_token(&format_token(42)), Some(42));
498 assert_eq!(parse_token(&format_token(0)), Some(0));
499 assert_eq!(parse_token(&format_token(u64::MAX)), Some(u64::MAX));
500 assert!(format_token(9) < format_token(10));
501 assert!(format_token(2) < format_token(1000));
502 }
503
504 #[test]
505 fn parse_token_rejects_garbage() {
506 assert_eq!(parse_token("abc"), None);
507 assert_eq!(parse_token(""), None);
508 }
509
510 #[test]
511 fn wrap_then_unwrap_preserves_bookmark_and_seq() {
512 let bm = json!({"lsn": "0/16B2D58"});
513 let wrapped = wrap_state(Some(&bm), 7);
514 let (got_bm, got_seq) = unwrap_state(&wrapped);
515 assert_eq!(got_bm, Some(bm));
516 assert_eq!(got_seq, 7);
517 }
518
519 #[test]
520 fn wrap_none_bookmark_unwraps_to_none() {
521 let wrapped = wrap_state(None, 3);
522 let (got_bm, got_seq) = unwrap_state(&wrapped);
523 assert_eq!(got_bm, None);
524 assert_eq!(got_seq, 3);
525 }
526
527 #[test]
528 fn legacy_bare_bookmark_unwraps_with_seq_zero() {
529 let (bm, seq) = unwrap_state(&json!("2024-12-01"));
530 assert_eq!(bm, Some(json!("2024-12-01")));
531 assert_eq!(seq, 0);
532 let (bm2, seq2) = unwrap_state(&json!({"updated_at": "2024-12-01"}));
533 assert_eq!(bm2, Some(json!({"updated_at": "2024-12-01"})));
534 assert_eq!(seq2, 0);
535 }
536
537 #[test]
538 fn object_with_non_sentinel_marker_is_treated_as_bare_bookmark() {
539 let v = json!({"__faucet_eo": null, "offset": 500});
542 let (bm, seq) = unwrap_state(&v);
543 assert_eq!(bm, Some(v));
544 assert_eq!(seq, 0);
545 }
546
547 #[test]
548 fn null_value_unwraps_to_none_seq_zero() {
549 let (bm, seq) = unwrap_state(&json!(null));
550 assert_eq!(bm, None);
551 assert_eq!(seq, 0);
552 }
553
554 #[test]
555 fn token_with_bookmark_round_trips() {
556 let bm = json!({"partition_offsets": [{"topic": "t", "partition": 0, "offset": 42}]});
557 let token = format_token_with_bookmark(7, Some(&bm));
558 assert!(token.starts_with(&format_token(7)));
559 assert_eq!(parse_token(&token), Some(7));
560 let (seq, parsed_bm) = parse_token_parts(&token).unwrap();
561 assert_eq!(seq, 7);
562 assert_eq!(parsed_bm, Some(bm));
563 }
564
565 #[test]
566 fn token_with_no_bookmark_is_bare_and_back_compatible() {
567 assert_eq!(format_token_with_bookmark(3, None), format_token(3));
568 let (seq, bm) = parse_token_parts(&format_token(3)).unwrap();
569 assert_eq!((seq, bm), (3, None));
570 }
571
572 #[test]
573 fn token_with_bookmark_orders_lexicographically_on_prefix() {
574 let a = format_token_with_bookmark(9, Some(&json!({"o": 1})));
578 let b = format_token_with_bookmark(10, Some(&json!({"o": 2})));
579 assert!(a < b);
580 }
581
582 #[test]
583 fn parse_token_parts_tolerates_garbage() {
584 assert_eq!(parse_token_parts("abc"), None);
585 assert_eq!(parse_token_parts(""), None);
586 let (seq, bm) = parse_token_parts("00000000000000000005#{not json").unwrap();
588 assert_eq!((seq, bm), (5, None));
589 assert_eq!(parse_token("00000000000000000005#{not json"), Some(5));
591 }
592
593 #[test]
594 fn derive_guarantee_prefers_atomic_then_keyed_then_at_least_once() {
595 use ReplayGuarantee::*;
596 let base = GuaranteeInputs {
597 replay: Deterministic,
598 sink_atomic: true,
599 keyed_upsert_configured: false,
600 durable_state: true,
601 dlq: false,
602 };
603 assert_eq!(
604 derive_delivery_guarantee(&base),
605 DeliveryGuarantee::EffectivelyOnce(EffectivelyOnceMechanism::AtomicWatermark)
606 );
607 let non_det = GuaranteeInputs {
609 replay: NonDeterministic,
610 ..base
611 };
612 assert_eq!(
613 derive_delivery_guarantee(&non_det),
614 DeliveryGuarantee::AtLeastOnce
615 );
616 let keyed = GuaranteeInputs {
618 keyed_upsert_configured: true,
619 ..non_det
620 };
621 assert_eq!(
622 derive_delivery_guarantee(&keyed),
623 DeliveryGuarantee::EffectivelyOnce(EffectivelyOnceMechanism::KeyedUpsert)
624 );
625 let dlq = GuaranteeInputs {
627 dlq: true,
628 keyed_upsert_configured: true,
629 ..base
630 };
631 assert_eq!(
632 derive_delivery_guarantee(&dlq),
633 DeliveryGuarantee::EffectivelyOnce(EffectivelyOnceMechanism::KeyedUpsert)
634 );
635 let mem_state = GuaranteeInputs {
636 durable_state: false,
637 ..base
638 };
639 assert_eq!(
640 derive_delivery_guarantee(&mem_state),
641 DeliveryGuarantee::AtLeastOnce
642 );
643 }
644
645 #[test]
646 fn guarantee_display_is_human_readable() {
647 assert_eq!(DeliveryGuarantee::AtLeastOnce.to_string(), "at-least-once");
648 assert_eq!(
649 DeliveryGuarantee::EffectivelyOnce(EffectivelyOnceMechanism::AtomicWatermark)
650 .to_string(),
651 "effectively-once (atomic watermark)"
652 );
653 assert_eq!(
654 DeliveryGuarantee::EffectivelyOnce(EffectivelyOnceMechanism::KeyedUpsert).to_string(),
655 "effectively-once (keyed upsert)"
656 );
657 }
658
659 #[test]
660 fn capability_enums_default_to_weakest() {
661 assert_eq!(
662 ReplayGuarantee::default(),
663 ReplayGuarantee::NonDeterministic
664 );
665 assert_eq!(SinkGuarantee::default(), SinkGuarantee::AtLeastOnce);
666 }
667
668 #[test]
669 fn delivery_mode_serde_is_snake_case_and_defaults_at_least_once() {
670 assert_eq!(DeliveryMode::default(), DeliveryMode::AtLeastOnce);
671 assert_eq!(
672 serde_json::to_string(&DeliveryMode::ExactlyOnce).unwrap(),
673 "\"exactly_once\""
674 );
675 let m: DeliveryMode = serde_json::from_str("\"at_least_once\"").unwrap();
676 assert_eq!(m, DeliveryMode::AtLeastOnce);
677 }
678
679 #[test]
680 fn overwrite_staging_suffixes_are_pinned() {
681 assert_eq!(OVERWRITE_STAGING_SUFFIX, "__faucet_ovw");
685 assert_eq!(OVERWRITE_STAGING_OLD_SUFFIX, "__faucet_ovw_old");
686 assert_ne!(OVERWRITE_STAGING_SUFFIX, OVERWRITE_STAGING_OLD_SUFFIX);
687 assert!(OVERWRITE_STAGING_OLD_SUFFIX.starts_with(OVERWRITE_STAGING_SUFFIX));
690 }
691}
692
693#[cfg(test)]
694mod overwrite_run_tests {
695 use super::*;
696
697 #[test]
698 fn tokens_are_short_hex_and_record_their_start() {
699 let t = new_overwrite_run_token();
700 assert!(is_overwrite_run_token(&t), "{t}");
701 assert_ne!(t, new_overwrite_run_token());
702 let t = overwrite_run_token_at(0x6700_0000, "abcdef");
703 assert_eq!(t, "67000000abcdef");
704 assert_eq!(overwrite_run_started(&t), Some(0x6700_0000));
705 for bad in [
706 "",
707 "67000000abcdeg",
708 "67000000ABCDEF",
709 "67000000abcde",
710 "old",
711 ] {
712 assert!(!is_overwrite_run_token(bad), "{bad}");
713 }
714 assert_eq!(overwrite_staging_suffix(None), "__faucet_ovw");
715 assert!(check_overwrite_run_token(&t).is_ok());
716 assert!(check_overwrite_run_token("../x").is_err());
717 assert_eq!(overwrite_staging_prefix("t"), "t__faucet_ovw_");
718 assert_eq!(like_literal("a_b%c\\", '\\'), "a\\_b\\%c\\\\");
719 assert_eq!(
720 overwrite_staging_suffix(Some(&t)),
721 "__faucet_ovw_67000000abcdef"
722 );
723 }
724
725 #[test]
726 fn only_another_runs_old_staging_is_abandoned() {
727 let day = ABANDONED_OVERWRITE_STAGING.as_secs();
728 let start = 0x6700_0000u64;
729 let old = format!("t__faucet_ovw_{}", overwrite_run_token_at(start, "aaaaaa"));
730 assert!(abandoned_overwrite_staging(
731 "t",
732 &old,
733 None,
734 start + day + 1
735 ));
736 assert!(abandoned_overwrite_staging(
737 "T",
738 &old.to_uppercase(),
739 None,
740 start + day + 1
741 ));
742 assert!(!abandoned_overwrite_staging("t", &old, None, start + day));
744 assert!(!abandoned_overwrite_staging(
746 "t",
747 &old,
748 Some("67000000aaaaaa"),
749 start + 2 * day
750 ));
751 assert_eq!(
752 overwrite_staging_token("t", "T__FAUCET_OVW_67000000AAAAAA").as_deref(),
753 Some("67000000aaaaaa")
754 );
755 assert_eq!(overwrite_staging_token("t", "t__faucet_ovw"), None);
756 assert!(overwrite_run_abandoned(
757 "67000000aaaaaa",
758 None,
759 start + 2 * day
760 ));
761 assert!(!overwrite_run_abandoned(
762 "67000000aaaaaa",
763 Some("67000000aaaaaa"),
764 start + 2 * day
765 ));
766 assert!(!overwrite_run_abandoned("nope", None, start + 2 * day));
767 for name in [
769 "t__faucet_ovw",
770 "t__faucet_ovw_old",
771 "tt__faucet_ovw_67000000aaaaaa",
772 "t__faucet_ovw_67000000aaaaaa_x",
773 "t__faucet_ovw_zzzzzzzzaaaaaa",
774 ] {
775 assert!(
776 !abandoned_overwrite_staging("t", name, None, start + 2 * day),
777 "{name}"
778 );
779 }
780 let late = format!(
782 "t__faucet_ovw_{}",
783 overwrite_run_token_at(0xffff_ff00, "bbbbbb")
784 );
785 assert!(abandoned_overwrite_staging(
786 "t",
787 &late,
788 None,
789 (1 << 32) + day
790 ));
791 }
792}
793
794#[cfg(test)]
795mod scope_key_tests {
796 use super::*;
797
798 #[test]
803 fn scope_key_passes_short_scopes_through_and_shortens_long_ones() {
804 assert_eq!(scope_key("pipe::row", 255), "pipe::row");
807 let exactly = "x".repeat(255);
808 assert_eq!(scope_key(&exactly, 255), exactly);
809
810 let long = format!("pipe::row::{}", "k".repeat(400));
812 let key = scope_key(&long, 255);
813 assert!(key.len() <= 255, "len {}", key.len());
814 assert_ne!(key, long);
815 assert!(key.starts_with("pipe::row::"), "{key}");
817 assert!(key.contains("__h:"), "{key}");
818 }
819
820 #[test]
821 fn scope_key_is_deterministic_and_distinguishes_scopes() {
822 let a = format!("pipe::row::{}", "a".repeat(400));
823 let b = format!("pipe::row::{}", "b".repeat(400));
824 assert_eq!(scope_key(&a, 255), scope_key(&a, 255));
826 assert_ne!(scope_key(&a, 255), scope_key(&b, 255));
829 assert_eq!(&a[..255], &format!("pipe::row::{}", "a".repeat(400))[..255]);
830 }
831
832 #[test]
833 fn scope_key_digest_is_sha256() {
834 let long = format!("pipe::row::{}", "k".repeat(400));
835 let key = scope_key(&long, 255);
836 let (_, digest) = key.rsplit_once("__h:").unwrap();
837 assert_eq!(digest.len(), 64);
838 assert_eq!(
839 &digest[..16],
840 "4c5c3b297497a0a0",
841 "pinned: a changed digest strands every shortened watermark"
842 );
843 }
844
845 #[test]
846 fn legacy_scope_key_reproduces_the_fnv_form() {
847 assert_eq!(legacy_scope_key("short", 64), None);
848 let long = "p::row::".to_string() + &"x".repeat(100);
849 let legacy = legacy_scope_key(&long, 64).unwrap();
850 assert!(legacy.len() <= 64, "{legacy}");
851 assert!(legacy.starts_with("p::row::"), "{legacy}");
852 let digest = legacy.rsplit_once("__h:").unwrap().1;
853 assert_eq!(digest.len(), 16);
854 assert_ne!(legacy, scope_key(&long, 64));
855 assert_eq!(
856 legacy_scope_key("é".repeat(40).as_str(), 30).unwrap().len(),
857 4 + 16 + 10
858 );
859 }
860
861 #[test]
862 fn scope_key_truncates_on_a_char_boundary() {
863 let long = format!("pipé::{}", "é".repeat(400));
865 let key = scope_key(&long, 255);
866 assert!(key.len() <= 255);
867 assert!(key.contains("__h:"), "{key}");
868 }
869}