use std::{
collections::HashMap,
sync::{Arc, LazyLock, Mutex},
time::{Duration, Instant},
};
use async_trait::async_trait;
use cloudillo_core::scheduler::{Task, TaskId};
use cloudillo_types::meta_adapter::{SearchObject, SearchPart};
use serde::{Deserialize, Serialize};
use crate::{
extract::{TextSink, extract_fields, resolve_str},
prelude::*,
rules::{DOC_ID, IndexRules, PartRule},
};
pub const DEBOUNCE_SECS: i64 = 30;
pub const OBJ_FILE: char = 'F';
pub const OBJ_DOC: char = 'D';
const MAX_CONTRIBUTIONS: usize = 100_000;
pub const STORE_RTDB: &str = "RTDB";
pub const STORE_CRDT: &str = "CRDT";
const THROTTLE_SECS: u64 = 5;
const _: () = assert!(THROTTLE_SECS < DEBOUNCE_SECS as u64);
const THROTTLE_MAP_CAP: usize = 4096;
type ThrottleKey = (u32, Box<str>);
type ThrottleMap = HashMap<ThrottleKey, Instant>;
static LAST_SCHEDULED: LazyLock<Mutex<ThrottleMap>> = LazyLock::new(|| Mutex::new(HashMap::new()));
fn should_schedule(now: Instant, tn_id: TnId, file_id: &str) -> bool {
let mut map = match LAST_SCHEDULED.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
should_schedule_in(&mut map, now, tn_id, file_id)
}
fn should_schedule_in(map: &mut ThrottleMap, now: Instant, tn_id: TnId, file_id: &str) -> bool {
let key: ThrottleKey = (tn_id.0, Box::from(file_id));
if let Some(last) = map.get(&key)
&& now.duration_since(*last) < Duration::from_secs(THROTTLE_SECS)
{
return false;
}
if map.len() >= THROTTLE_MAP_CAP {
let window = Duration::from_secs(DEBOUNCE_SECS as u64);
map.retain(|_, last| now.duration_since(*last) < window);
}
map.insert(key, now);
true
}
pub fn schedule(app: &App, tn_id: TnId, file_id: &str) {
if !should_schedule(Instant::now(), tn_id, file_id) {
return;
}
let app = app.clone();
let file_id: Box<str> = file_id.into();
tokio::spawn(async move {
let key = format!("search.index:{}:{}", tn_id.0, file_id);
let task = IndexDocumentTask { tn_id, file_id: file_id.clone() };
if let Err(e) = app.scheduler.task(Arc::new(task)).key(key).after(DEBOUNCE_SECS).await {
warn!(tn_id = %tn_id, file_id = %file_id, error = %e,
"Failed to schedule search index task");
}
});
}
pub async fn index_document(app: &App, tn_id: TnId, file_id: &str) -> ClResult<()> {
let Some(file) = app.meta_adapter.read_file(tn_id, file_id).await? else {
return forget(app, tn_id, file_id).await;
};
if !crate::objects::is_indexable(&file) {
return forget(app, tn_id, file_id).await;
}
let content_type = file.content_type.as_deref();
let store_tp = file.file_tp.as_deref();
let rules = match (content_type, store_tp) {
(Some(ct), Some(STORE_RTDB | STORE_CRDT)) => read_rules(app, tn_id, ct).await,
_ => None,
};
let Some(rules) = rules else {
return app.meta_adapter.delete_search_object(tn_id, OBJ_DOC, file_id).await;
};
let parts = {
let _permit = crate::MATERIALIZE_PERMIT
.acquire()
.await
.map_err(|e| Error::Internal(format!("search index permit closed: {e}")))?;
let mut docs = if store_tp == Some(STORE_CRDT) {
crate::crdt::export_all(app, tn_id, file_id).await?
} else {
app.rtdb_adapter.export_all(tn_id, file_id).await?
};
let owned_id: Box<str> = file_id.into();
app.worker
.run_slow(move || {
crate::prune::prune_docs(&rules, &mut docs, tn_id, &owned_id);
build_parts(&rules, &docs, tn_id, &owned_id)
})
.await
.map_err(|e| Error::Internal(format!("Worker pool failed extracting doc: {e}")))?
};
let fts_cl = !crate::store_text(app, tn_id).await;
app.meta_adapter
.replace_search_object(
tn_id,
&SearchObject {
obj_tp: OBJ_DOC,
obj_id: file_id,
content_type,
owner_tag: file.owner_tag.as_deref(),
visibility: file.visibility,
root_id: Some(file.root_id.as_deref().unwrap_or(file_id)),
created_at: Some(file.created_at),
fts_cl,
},
&parts.iter().map(BuiltPart::as_search_part).collect::<Vec<_>>(),
)
.await
}
async fn forget(app: &App, tn_id: TnId, file_id: &str) -> ClResult<()> {
app.meta_adapter.delete_search_object(tn_id, OBJ_DOC, file_id).await
}
async fn read_rules(app: &App, tn_id: TnId, content_type: &str) -> Option<IndexRules> {
let fmt = cloudillo_core::doc_format::resolve(app, tn_id, content_type)
.await
.inspect_err(|e| warn!(content_type, error = %e, "Cannot read doc format"))
.ok()??;
let search = fmt.search.as_ref()?;
IndexRules::parse(search)
.inspect_err(|e| warn!(content_type, error = %e, "Invalid search manifest"))
.ok()
}
#[derive(Debug)]
struct BuiltPart {
part_id: String,
part_kind: String,
parent_part: Option<String>,
anchor_id: Option<String>,
title: Option<String>,
tags: Option<String>,
body: String,
body_chars: usize,
}
impl BuiltPart {
fn as_search_part(&self) -> SearchPart<'_> {
SearchPart {
part_id: &self.part_id,
part_kind: Some(&self.part_kind),
parent_part: self.parent_part.as_deref(),
anchor_id: self.anchor_id.as_deref(),
title: self.title.as_deref(),
body: (!self.body.is_empty()).then_some(self.body.as_str()),
tags: self.tags.as_deref(),
}
}
}
struct Contribution {
owner: String,
sort_key: Vec<String>,
anchor: Option<String>,
text: String,
}
fn build_parts(
rules: &IndexRules,
docs: &[(Box<str>, serde_json::Value)],
tn_id: TnId,
file_id: &str,
) -> Vec<BuiltPart> {
let mut parts: Vec<BuiltPart> = Vec::new();
let mut index: HashMap<(&str, String), usize> = HashMap::new();
let mut total_used: usize = 0;
let mut truncated = false;
for (path, doc) in docs {
let Some((kind, doc_id)) = split_path(path) else { continue };
let Some(rule) = rules.owner_rule(kind) else { continue };
if parts.len() >= rules.limits.max_parts {
truncated = true;
break;
}
let mut left = rules.limits.max_total_chars.saturating_sub(total_used);
if left == 0 {
truncated = true;
break;
}
let mut title = TextSink::new(rules.limits.max_body_chars.min(1024).min(left));
extract_fields(doc, &rule.title, &mut title);
left -= title.len_chars();
let mut tags = TextSink::new(1024.min(left));
extract_fields(doc, &rule.tags, &mut tags);
left -= tags.len_chars();
let mut body = TextSink::new(rules.limits.max_body_chars.min(left));
extract_fields(doc, &rule.body, &mut body);
truncated |= title.truncated() || tags.truncated() || body.truncated();
index.insert((kind, doc_id.to_owned()), parts.len());
let body = body.into_string();
let body_chars = body.chars().count();
total_used += title.len_chars() + tags.len_chars() + body_chars;
parts.push(BuiltPart {
part_id: format!("{kind}/{doc_id}"),
part_kind: kind.to_owned(),
parent_part: rule
.parent
.as_deref()
.and_then(|f| resolve_str(doc, f))
.map(|p| format!("{kind}/{p}")),
anchor_id: anchor_of(rule, doc, doc_id),
title: (!title.is_empty()).then(|| title.into_string()),
tags: (!tags.is_empty()).then(|| tags.into_string()),
body,
body_chars,
});
}
let mut pending: HashMap<&str, Vec<Contribution>> = HashMap::new();
let mut pending_chars: usize = 0;
let mut pending_count: usize = 0;
let budget_left = rules.limits.max_total_chars.saturating_sub(total_used);
'collect: for (path, doc) in docs {
let Some((kind, doc_id)) = split_path(path) else { continue };
for rule in rules.parts.iter().filter(|p| p.kind == kind) {
let Some(attach) = &rule.attach_to else { continue };
let Some(owner) = resolve_str(doc, &attach.field) else { continue };
let key = (attach.kind.as_str(), owner);
if !index.contains_key(&key) {
continue;
}
if pending_chars >= budget_left || pending_count >= MAX_CONTRIBUTIONS {
truncated = true;
break 'collect;
}
let mut text = TextSink::new(rules.limits.max_body_chars);
extract_fields(doc, &rule.body, &mut text);
if text.is_empty() {
continue;
}
pending_chars = pending_chars.saturating_add(text.len_chars());
pending_count += 1;
let (owner_kind, owner) = key;
pending.entry(owner_kind).or_default().push(Contribution {
owner,
sort_key: rule.order.iter().map(|f| sort_key_of(doc, f, doc_id)).collect(),
anchor: anchor_of(rule, doc, doc_id),
text: text.into_string(),
});
}
}
let mut owner_kinds: Vec<&str> = pending.keys().copied().collect();
owner_kinds.sort_unstable();
for owner_kind in owner_kinds {
let Some(mut contributions) = pending.remove(owner_kind) else { continue };
contributions.sort_by(|a, b| a.sort_key.cmp(&b.sort_key));
for c in contributions {
let Some(&i) = index.get(&(owner_kind, c.owner)) else { continue };
let Some(part) = parts.get_mut(i) else { continue };
let wanted = c.text.chars().count();
let sep = usize::from(!part.body.is_empty());
let room = rules
.limits
.max_body_chars
.saturating_sub(part.body_chars)
.min(rules.limits.max_total_chars.saturating_sub(total_used))
.saturating_sub(sep);
if room == 0 {
truncated = true;
continue;
}
if sep == 1 {
part.body.push(' ');
part.body_chars += 1;
total_used += 1;
}
part.body.extend(c.text.chars().take(room));
part.body_chars += wanted.min(room);
total_used += wanted.min(room);
truncated |= wanted > room;
if part.anchor_id.is_none() {
part.anchor_id = c.anchor;
}
}
}
parts.retain(|p| !p.body.is_empty() || p.title.is_some() || p.tags.is_some());
if truncated {
warn!(tn_id = %tn_id, file_id, max_parts = rules.limits.max_parts,
max_body_chars = rules.limits.max_body_chars,
max_total_chars = rules.limits.max_total_chars,
total_used,
"Search index truncated: document exceeds manifest limits");
}
parts
}
fn anchor_of(rule: &PartRule, doc: &serde_json::Value, doc_id: &str) -> Option<String> {
match rule.anchor.as_deref()? {
DOC_ID => Some(doc_id.to_owned()),
field => resolve_str(doc, field),
}
}
fn sort_key_of(doc: &serde_json::Value, field: &str, doc_id: &str) -> String {
if field == DOC_ID {
return doc_id.to_owned();
}
let Some(raw) = resolve_str(doc, field) else { return String::new() };
raw.parse::<f64>().map_or(raw, |n| {
if n.is_nan() {
return "f".repeat(16);
}
let bits = n.to_bits();
let key = if n.is_sign_negative() { !bits } else { bits ^ (1 << 63) };
format!("{key:016x}")
})
}
pub(crate) fn split_path(path: &str) -> Option<(&str, &str)> {
let (doc_id, collection) = {
let mut it = path.rsplitn(2, '/');
(it.next()?, it.next()?)
};
(!collection.is_empty() && !doc_id.is_empty()).then_some((collection, doc_id))
}
#[derive(Debug, Serialize, Deserialize)]
pub struct IndexDocumentTask {
pub tn_id: TnId,
pub file_id: Box<str>,
}
#[async_trait]
impl Task<App> for IndexDocumentTask {
fn kind() -> &'static str {
"search.index"
}
fn kind_of(&self) -> &'static str {
Self::kind()
}
fn build(_id: TaskId, ctx: &str) -> ClResult<Arc<dyn Task<App>>> {
Ok(Arc::new(serde_json::from_str::<Self>(ctx)?))
}
fn serialize(&self) -> String {
let mut obj = serde_json::Map::with_capacity(2);
obj.insert("tn_id".into(), self.tn_id.0.into());
obj.insert("file_id".into(), self.file_id.as_ref().into());
serde_json::Value::Object(obj).to_string()
}
async fn run(&self, app: &App) -> ClResult<()> {
index_document(app, self.tn_id, &self.file_id).await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn notillo_rules() -> IndexRules {
IndexRules::parse(&serde_json::json!({
"v": 1,
"parts": [
{ "kind": "p", "title": ["ti"], "tags": ["tg"], "parent": "pp" },
{ "kind": "b", "attachTo": { "kind": "p", "field": "p" },
"anchor": "docId", "order": ["o"],
"body": [{ "field": "c", "extract": "text", "excludeKeys": ["l"] }] }
]
}))
.expect("rules")
}
fn docs() -> Vec<(Box<str>, serde_json::Value)> {
vec![
("p/page1".into(), serde_json::json!({ "ti": "Bevezetés", "tg": ["munka"] })),
("p/page2".into(), serde_json::json!({ "ti": "Részletek", "pp": "page1" })),
("b/blockB".into(), serde_json::json!({ "p": "page1", "o": 10, "c": ["második"] })),
("b/blockA".into(), serde_json::json!({ "p": "page1", "o": 2, "c": ["első"] })),
("b/blockC".into(), serde_json::json!({ "p": "page2", "o": 1, "c": ["külön"] })),
("b/orphan".into(), serde_json::json!({ "p": "gone", "o": 1, "c": ["árva"] })),
]
}
fn build() -> Vec<BuiltPart> {
build_parts(¬illo_rules(), &docs(), TnId(1), "f1~doc")
}
#[test]
fn emits_one_row_per_page_with_block_text_folded_in() {
let parts = build();
assert_eq!(parts.len(), 2, "one row per page, none per block");
let page1 = parts.iter().find(|p| p.part_id == "p/page1").expect("page1");
assert_eq!(page1.title.as_deref(), Some("Bevezetés"));
assert_eq!(page1.tags.as_deref(), Some("munka"));
assert_eq!(page1.part_kind, "p");
assert_eq!(page1.body, "első második");
let page2 = parts.iter().find(|p| p.part_id == "p/page2").expect("page2");
assert_eq!(page2.parent_part.as_deref(), Some("p/page1"));
assert_eq!(page2.body, "külön");
}
#[test]
fn anchor_points_at_the_first_contributing_block() {
let parts = build();
let page1 = parts.iter().find(|p| p.part_id == "p/page1").expect("page1");
assert_eq!(page1.anchor_id.as_deref(), Some("blockA"), "anchor must follow reading order");
}
#[test]
fn orphan_contributions_are_dropped() {
let parts = build();
assert!(
parts.iter().all(|p| !p.body.contains("árva")),
"a block naming a missing page must not leak into another page"
);
}
#[test]
fn numeric_order_sorts_numerically_not_lexicographically() {
let docs = vec![
("p/page1".into(), serde_json::json!({ "ti": "T" })),
("b/b1".into(), serde_json::json!({ "p": "page1", "o": 9, "c": ["nine"] })),
("b/b2".into(), serde_json::json!({ "p": "page1", "o": 10, "c": ["ten"] })),
];
let parts = build_parts(¬illo_rules(), &docs, TnId(1), "f1~doc");
assert_eq!(parts[0].body, "nine ten");
}
#[test]
fn negative_and_fractional_order_values_sort_correctly() {
let docs = vec![
("p/page1".into(), serde_json::json!({ "ti": "T" })),
("b/b1".into(), serde_json::json!({ "p": "page1", "o": 1.5, "c": ["mid"] })),
("b/b2".into(), serde_json::json!({ "p": "page1", "o": -3, "c": ["first"] })),
("b/b3".into(), serde_json::json!({ "p": "page1", "o": 2, "c": ["last"] })),
("b/b4".into(), serde_json::json!({ "p": "page1", "o": 0.0, "c": ["zero"] })),
("b/b5".into(), serde_json::json!({ "p": "page1", "o": -0.0, "c": ["negzero"] })),
];
let parts = build_parts(¬illo_rules(), &docs, TnId(1), "f1~doc");
assert_eq!(parts[0].body, "first negzero zero mid last");
}
#[test]
fn bisected_order_values_keep_their_order() {
let docs = vec![
("p/page1".into(), serde_json::json!({ "ti": "T" })),
("b/b1".into(), serde_json::json!({ "p": "page1", "o": 1.00012, "c": ["second"] })),
("b/b2".into(), serde_json::json!({ "p": "page1", "o": 1.00006, "c": ["first"] })),
];
let parts = build_parts(¬illo_rules(), &docs, TnId(1), "f1~doc");
assert_eq!(parts[0].body, "first second");
}
#[test]
fn sort_keys_of_near_identical_order_values_stay_distinct() {
let key = |n: f64| sort_key_of(&serde_json::json!({ "o": n }), "o", "f1~doc");
assert_ne!(key(1.00006), key(1.00012));
assert!(key(1.00006) < key(1.00012));
assert!(key(-3.0) < key(0.0));
assert!(key(0.0) < key(1.5));
let nan = sort_key_of(&serde_json::json!({ "o": "NaN" }), "o", "f1~doc");
assert!(nan > key(f64::MAX));
}
#[test]
fn body_is_capped_at_the_manifest_limit() {
let rules = IndexRules::parse(&serde_json::json!({
"parts": [
{ "kind": "p", "title": ["ti"] },
{ "kind": "b", "attachTo": { "kind": "p", "field": "p" }, "body": ["c"] }
],
"limits": { "maxBodyChars": 10 }
}))
.expect("rules");
let docs = vec![
("p/page1".into(), serde_json::json!({ "ti": "T" })),
("b/b1".into(), serde_json::json!({ "p": "page1", "c": "0123456789abcdef" })),
];
let parts = build_parts(&rules, &docs, TnId(1), "f1~doc");
assert!(parts[0].body.chars().count() <= 10, "got {:?}", parts[0].body);
}
#[test]
fn max_total_chars_bounds_the_emitting_pass_too() {
let rules = IndexRules::parse(&serde_json::json!({
"parts": [{ "kind": "p", "title": ["ti"], "body": ["c"] }],
"limits": { "maxParts": 100, "maxBodyChars": 20, "maxTotalChars": 30 }
}))
.expect("rules");
let docs: Vec<(Box<str>, serde_json::Value)> = (0..10)
.map(|i| {
(
format!("p/page{i}").into(),
serde_json::json!({ "ti": format!("T{i}"), "c": "0123456789" }),
)
})
.collect();
let parts = build_parts(&rules, &docs, TnId(1), "f1~doc");
let emitted: usize = parts
.iter()
.map(|p| {
p.title.as_deref().unwrap_or_default().chars().count()
+ p.tags.as_deref().unwrap_or_default().chars().count()
+ p.body.chars().count()
})
.sum();
assert!(emitted <= 30, "emitted {emitted} chars past a 30-char total budget");
assert!(parts.len() < 10, "the pass must stop before every page, not after it");
}
#[test]
fn attached_contributions_stop_being_collected_once_the_budget_is_spent() {
let rules = IndexRules::parse(&serde_json::json!({
"parts": [
{ "kind": "p", "title": ["ti"] },
{ "kind": "b", "attachTo": { "kind": "p", "field": "p" },
"order": ["o"], "body": ["c"] }
],
"limits": { "maxParts": 100, "maxBodyChars": 50, "maxTotalChars": 40 }
}))
.expect("rules");
let mut docs: Vec<(Box<str>, serde_json::Value)> =
vec![("p/page1".into(), serde_json::json!({ "ti": "T" }))];
for i in (1..=200).rev() {
docs.push((
format!("b/b{i:03}").into(),
serde_json::json!({ "p": "page1", "o": i, "c": format!("blokk{i:03}") }),
));
}
let parts = build_parts(&rules, &docs, TnId(1), "f1~doc");
assert_eq!(parts.len(), 1);
let emitted = parts[0].title.as_deref().unwrap_or_default().chars().count()
+ parts[0].body.chars().count();
assert!(emitted <= 40, "emitted {emitted} chars past a 40-char total budget");
assert!(
parts[0].body.contains("blokk196"),
"expected the first blocks off the export, got {:?}",
parts[0].body
);
assert!(
!parts[0].body.contains("blokk001"),
"the whole export was collected before anything was charged: {:?}",
parts[0].body
);
let again = build_parts(&rules, &docs, TnId(1), "f1~doc");
assert_eq!(parts[0].body, again[0].body);
}
#[test]
fn pruning_before_build_parts_keeps_style_flags_out_of_an_assembled_body() {
let rules = IndexRules::parse(&serde_json::json!({
"v": 1,
"parts": [
{ "kind": "p", "title": ["ti"], "tags": ["tg"], "parent": "pp" },
{ "kind": "b", "attachTo": { "kind": "p", "field": "p" },
"anchor": "docId", "order": ["o"],
"prune": ["$..c[0:][1:]", "$..cells[0:][0:][1:]"],
"body": [{ "path": "c", "extract": "text", "keys": ["c", "cells", "wt"] }] }
]
}))
.expect("rules");
let mut docs: Vec<(Box<str>, serde_json::Value)> = vec![
("p/page1".into(), serde_json::json!({ "ti": "Bevezetés" })),
(
"b/b1".into(),
serde_json::json!({ "p": "page1", "o": 1,
"c": ["Sima ", ["félkövér", "b"], ["dőlt", "iu"]] }),
),
(
"b/b2".into(),
serde_json::json!({ "p": "page1", "o": 2,
"c": [["piros", "", { "tc": "#f00" }], " és ", ["busás", "bus"]] }),
),
];
crate::prune::prune_docs(&rules, &mut docs, TnId(1), "f1~doc");
let parts = build_parts(&rules, &docs, TnId(1), "f1~doc");
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].body, "Sima félkövér dőlt piros és busás");
for token in parts[0].body.split_whitespace() {
assert!(
!matches!(token, "b" | "i" | "u" | "s" | "c" | "bi" | "iu" | "bus"),
"style flag {token:?} survived into {:?}",
parts[0].body
);
}
}
#[test]
fn a_manifest_without_prune_indexes_exactly_as_before() {
let rules = notillo_rules();
let untouched = build_parts(&rules, &docs(), TnId(1), "f1~doc");
let mut docs = docs();
crate::prune::prune_docs(&rules, &mut docs, TnId(1), "f1~doc");
let after = build_parts(&rules, &docs, TnId(1), "f1~doc");
let fields = |ps: &[BuiltPart]| {
ps.iter()
.map(|p| (p.part_id.clone(), p.title.clone(), p.tags.clone(), p.body.clone()))
.collect::<Vec<_>>()
};
assert_eq!(fields(&after), fields(&untouched));
}
#[test]
fn max_parts_stops_the_emitting_pass() {
let rules = IndexRules::parse(&serde_json::json!({
"parts": [{ "kind": "p", "title": ["ti"] }],
"limits": { "maxParts": 2 }
}))
.expect("rules");
let docs: Vec<(Box<str>, serde_json::Value)> = (0..10)
.map(|i| {
(format!("p/page{i}").into(), serde_json::json!({ "ti": format!("Page {i}") }))
})
.collect();
assert_eq!(build_parts(&rules, &docs, TnId(1), "f1~doc").len(), 2);
}
#[test]
fn unknown_collections_and_malformed_paths_are_ignored() {
let docs = vec![
("p/page1".into(), serde_json::json!({ "ti": "T" })),
("z/other".into(), serde_json::json!({ "ti": "Not indexed" })),
("noslash".into(), serde_json::json!({ "ti": "Not indexed" })),
];
let parts = build_parts(¬illo_rules(), &docs, TnId(1), "f1~doc");
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].part_id, "p/page1");
}
#[test]
fn two_emitting_kinds_sharing_a_doc_id_get_distinct_part_ids() {
let rules = IndexRules::parse(&serde_json::json!({
"v": 1,
"parts": [{ "kind": "s", "title": ["ti"] }, { "kind": "n", "title": ["ti"] }]
}))
.expect("rules");
let docs = vec![
("s/0".into(), serde_json::json!({ "ti": "Diák" })),
("n/0".into(), serde_json::json!({ "ti": "Jegyzet" })),
];
let parts = build_parts(&rules, &docs, TnId(1), "f1~doc");
assert_eq!(parts.len(), 2);
let mut ids: Vec<&str> = parts.iter().map(|p| p.part_id.as_str()).collect();
ids.sort_unstable();
assert_eq!(ids, vec!["n/0", "s/0"]);
}
#[test]
fn textless_rows_are_dropped() {
let docs = vec![("p/empty".into(), serde_json::json!({ "x": 1 }))];
assert!(build_parts(¬illo_rules(), &docs, TnId(1), "f1~doc").is_empty());
}
#[test]
fn split_path_handles_nested_collections() {
assert_eq!(split_path("p/page1"), Some(("p", "page1")));
assert_eq!(split_path("a/b/doc"), Some(("a/b", "doc")));
assert_eq!(split_path("noslash"), None);
assert_eq!(split_path("/doc"), None);
assert_eq!(split_path("coll/"), None);
}
#[test]
fn the_scheduler_throttle_suppresses_a_burst_but_not_the_next_window() {
let map = &mut ThrottleMap::new();
let tn_id = TnId(9_001);
let t0 = Instant::now();
assert!(
should_schedule_in(map, t0, tn_id, "f1~burst"),
"the first commit must reach the scheduler"
);
assert!(
!should_schedule_in(map, t0, tn_id, "f1~burst"),
"an immediate re-commit must be suppressed"
);
let inside = t0 + Duration::from_secs(THROTTLE_SECS - 1);
assert!(
!should_schedule_in(map, inside, tn_id, "f1~burst"),
"still inside the throttle window"
);
assert!(
should_schedule_in(map, t0 + Duration::from_secs(THROTTLE_SECS), tn_id, "f1~burst"),
"a commit a full window later must reach the scheduler again"
);
assert!(should_schedule_in(map, t0, tn_id, "f1~other"));
}
#[test]
fn the_throttle_map_is_pruned_past_its_cap() {
let map = &mut ThrottleMap::new();
let tn_id = TnId(9_002);
let t0 = Instant::now();
for i in 0..THROTTLE_MAP_CAP {
should_schedule_in(map, t0, tn_id, &format!("f1~{i}"));
}
let later = t0 + Duration::from_secs(DEBOUNCE_SECS as u64 + 1);
should_schedule_in(map, later, tn_id, "f1~last");
assert_eq!(map.len(), 1, "the prune must drop entries older than the debounce window");
}
}