suno_core/roots.rs
1//! Async lineage-root resolution: walk a whole library back to each clip's
2//! root ancestor, gap-filling ancestors that are missing from the caller's
3//! listing over the network.
4//!
5//! This is the IO surface lifted out of [`crate::lineage`], which stays pure.
6//! A clip's parent edge is classified there; here those pure classifiers
7//! ([`immediate_parent`], [`attribution_edges`]) are threaded up each parent
8//! chain to a root ([`resolve_roots`]), reaching the network only through the
9//! [`Http`] port (via [`SunoClient`]) to fill ancestors absent from the
10//! caller's listing. The dependency is one-way: `roots` depends on `lineage`,
11//! never the reverse.
12
13use std::borrow::Cow;
14use std::collections::{HashMap, HashSet};
15
16use crate::client::SunoClient;
17use crate::clock::Clock;
18use crate::error::Result;
19use crate::http::Http;
20use crate::lineage::{Resolution, ResolveStatus, RootInfo, attribution_edges, immediate_parent};
21use crate::model::Clip;
22
23/// Tunables bounding how hard [`resolve_roots`] works per call.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct ResolveOpts {
26 /// Maximum number of missing ancestor ids to fetch from the network. This
27 /// is the only budget: in-index walking is unbounded (a cycle guard, not a
28 /// hop cap, guarantees termination), so a deep chain resolves in full.
29 pub max_gap_fills: u32,
30 /// Maximum concurrent by-id clip fetches during one gap-fill batch.
31 pub concurrency: u32,
32}
33
34impl Default for ResolveOpts {
35 fn default() -> Self {
36 Self {
37 max_gap_fills: 200,
38 concurrency: 4,
39 }
40 }
41}
42
43/// Resolve the root ancestor of every clip in `clips`.
44///
45/// Walks each clip up its [`immediate_parent`] chain to a root. Chains that
46/// stay within `clips` resolve with no network access. When a parent is absent
47/// from the index it is gap-filled: missing ids are fetched in a batch through
48/// [`SunoClient::get_clips_by_ids`], and any id that cannot be retrieved that
49/// way falls back to [`SunoClient::get_clip_parent`], which yields one ancestor
50/// hop to keep walking (never assumed to be the absolute root).
51///
52/// Gap-filled clips (which may be trashed) are held in an index that is kept
53/// structurally separate from the caller's `clips`; they exist only to resolve
54/// ancestry and must never be treated as download candidates by later phases.
55///
56/// Bounded by [`ResolveOpts`]: at most `max_gap_fills` ancestor ids are fetched
57/// (exhaustion yields [`ResolveStatus::External`] at the last reachable
58/// ancestor). In-index chains are not hop-capped: a chain that stays within the
59/// index (or persisted archive) is walked in full to its true parentless root,
60/// however deep, terminating via a `visited` cycle guard. A cycle yields
61/// [`ResolveStatus::Cycle`] rooted at the cycle's canonical (lexicographically
62/// smallest) member, so cyclic data resolves order-independently. The returned
63/// [`Resolution`] has a root entry for every input clip, plus the gap-filled
64/// ancestor clips it fetched.
65pub async fn resolve_roots(
66 clips: &[Clip],
67 archived_parents: &HashMap<String, String>,
68 client: &SunoClient<impl Clock>,
69 http: &impl Http,
70 opts: ResolveOpts,
71) -> Result<Resolution> {
72 let mut resolver = Resolver::new(clips, opts, archived_parents);
73 resolver.run(client, http).await?;
74 Ok(resolver.into_resolution(clips))
75}
76
77/// The result of walking one chain as far as the current index allows.
78enum Walk {
79 /// The start clip's root is now recorded in the memo.
80 Resolved,
81 /// The walk stalled needing this ancestor id gap-filled.
82 Blocked(String),
83}
84
85/// Working state for one [`resolve_roots`] call.
86///
87/// `index` holds the input clips plus any gap-filled ancestors so the walk can
88/// read their pointers; `gap_filled` records which ids were fetched here so
89/// later phases can tell ancestors apart from download candidates. `bridges`
90/// maps a missing id to the known parent that the parent endpoint returned in
91/// its place, and `external` records ids the API reported as parentless roots.
92struct Resolver<'a> {
93 index: HashMap<String, Cow<'a, Clip>>,
94 /// Persisted `child_id -> parent_id` links from the durable store's primary
95 /// edges. Consulted before any network gap-fill so a walk can hop through an
96 /// ancestor whose clip is absent (e.g. an intermediate remix, or one Suno
97 /// has since purged) using data captured on an earlier run.
98 archived_parents: &'a HashMap<String, String>,
99 gap_filled: HashSet<String>,
100 bridges: HashMap<String, String>,
101 external: HashSet<String>,
102 /// Clip-root ids already attempted as a gap-fill seed, so a root that the
103 /// batch never returns is tried once and then left alone (never re-seeded,
104 /// never bridged, never external).
105 seeded: HashSet<String>,
106 memo: HashMap<String, RootInfo>,
107 targets: Vec<String>,
108 budget: u32,
109 concurrency: u32,
110}
111
112impl<'a> Resolver<'a> {
113 fn new(
114 clips: &'a [Clip],
115 opts: ResolveOpts,
116 archived_parents: &'a HashMap<String, String>,
117 ) -> Self {
118 let index = clips
119 .iter()
120 .map(|clip| (clip.id.clone(), Cow::Borrowed(clip)))
121 .collect();
122 let targets = clips.iter().map(|clip| clip.id.clone()).collect();
123 Self {
124 index,
125 archived_parents,
126 gap_filled: HashSet::new(),
127 bridges: HashMap::new(),
128 external: HashSet::new(),
129 seeded: HashSet::new(),
130 memo: HashMap::new(),
131 targets,
132 budget: opts.max_gap_fills,
133 concurrency: opts.concurrency,
134 }
135 }
136
137 /// Resolve every target, gap-filling missing ancestors until the whole set
138 /// is settled or the budget runs out.
139 async fn run(&mut self, client: &SunoClient<impl Clock>, http: &impl Http) -> Result<()> {
140 let targets = self.targets.clone();
141 loop {
142 let mut frontier: Vec<String> = Vec::new();
143 let mut seen: HashSet<String> = HashSet::new();
144 let mut blocked: Vec<(String, String)> = Vec::new();
145
146 for target in &targets {
147 if self.memo.contains_key(target) {
148 continue;
149 }
150 if let Walk::Blocked(missing) = self.walk(target) {
151 if seen.insert(missing.clone()) {
152 frontier.push(missing.clone());
153 }
154 blocked.push((target.clone(), missing));
155 }
156 }
157
158 if blocked.is_empty() {
159 break;
160 }
161 if self.budget == 0 || !self.gap_fill(client, http, &frontier).await? {
162 self.finalise_external(&blocked);
163 break;
164 }
165 }
166 Ok(())
167 }
168
169 /// Walk `start` up its parent chain within the current index, memoising the
170 /// root for every node reached. Returns [`Walk::Blocked`] with the first
171 /// ancestor id that is missing and needs gap-filling.
172 fn walk(&mut self, start: &str) -> Walk {
173 if self.memo.contains_key(start) {
174 return Walk::Resolved;
175 }
176 let mut chain: Vec<String> = Vec::new();
177 let mut visited: HashSet<String> = HashSet::new();
178 let mut current = start.to_string();
179
180 loop {
181 if let Some(info) = self.memo.get(¤t).cloned() {
182 self.assign(&chain, &info);
183 return Walk::Resolved;
184 }
185 if visited.contains(¤t) {
186 // A cycle. Root it at its canonical (lexicographically smallest)
187 // member so the same cyclic data resolves the same root whatever
188 // order its clips were listed in. The members are the nodes from
189 // `current`'s first occurrence in the chain onward; any non-cycle
190 // lead-in walked before that point is excluded.
191 let cycle_start = chain.iter().position(|id| *id == current).unwrap_or(0);
192 let root = chain[cycle_start..]
193 .iter()
194 .min()
195 .cloned()
196 .unwrap_or_else(|| current.clone());
197 let info = self.terminal(&root, ResolveStatus::Cycle);
198 self.assign(&chain, &info);
199 self.memo.insert(current, info);
200 return Walk::Resolved;
201 }
202
203 // The parent of `current` comes from its live/fetched clip, or from
204 // a persisted archived edge when the clip itself is not in hand. An
205 // id known through neither is unknown locally and must be gap-filled
206 // (this is the guard: an edgeless archived node is fetched, never
207 // assumed a root, so a not-yet-persisted remix still gets its real
208 // parent).
209 let parent_id = if let Some(clip) = self.index.get(¤t) {
210 immediate_parent(clip).map(|(id, _edge)| id)
211 } else if let Some(parent) = self.archived_parents.get(¤t) {
212 Some(parent.clone())
213 } else {
214 return Walk::Blocked(current);
215 };
216
217 let Some(parent_id) = parent_id else {
218 let info = RootInfo {
219 root_id: current.clone(),
220 root_title: self.title_of(¤t),
221 status: ResolveStatus::Resolved,
222 };
223 self.assign(&chain, &info);
224 self.memo.insert(current, info);
225 return Walk::Resolved;
226 };
227
228 visited.insert(current.clone());
229 chain.push(current);
230
231 if self.index.contains_key(&parent_id) || self.archived_parents.contains_key(&parent_id)
232 {
233 current = parent_id;
234 } else if let Some(bridged) = self.bridges.get(&parent_id).cloned() {
235 visited.insert(parent_id);
236 current = bridged;
237 } else if self.external.contains(&parent_id) {
238 let info = self.terminal(&parent_id, ResolveStatus::External);
239 self.assign(&chain, &info);
240 self.memo.insert(parent_id, info);
241 return Walk::Resolved;
242 } else {
243 return Walk::Blocked(parent_id);
244 }
245 }
246 }
247
248 /// Fetch missing `frontier` ancestors, batching by id and falling back to
249 /// the parent endpoint. Same-owner `clip_roots` are additionally seeded as
250 /// best-effort root candidates. Returns whether the index (or
251 /// bridges/externals) grew, so the caller can detect a stalled resolution.
252 async fn gap_fill(
253 &mut self,
254 client: &SunoClient<impl Clock>,
255 http: &impl Http,
256 frontier: &[String],
257 ) -> Result<bool> {
258 // Structural frontier: ancestors a walk is blocked on. They get the full
259 // treatment (batch fetch, then a parent-endpoint fallback that may bridge
260 // one hop or mark the id external).
261 let mut want: Vec<String> = frontier
262 .iter()
263 .filter(|id| !self.known(id))
264 .cloned()
265 .collect();
266 want.sort();
267 want.dedup();
268
269 // Same-owner clip_root seeds: an OPTIONAL extra root candidate. They ride
270 // the batch and its per-id fallback, but never the parent-endpoint path
271 // below, so a seed the fetch omits is simply dropped, never bridged,
272 // externalised, or forced to a root: clip_roots can neither fabricate a
273 // parent link nor arm a delete. Foreign-owned roots are excluded
274 // (fail-closed by handle), and each seed is attempted at most once.
275 let mut seeds: Vec<String> = self
276 .clip_root_seeds()
277 .into_iter()
278 .filter(|id| !self.known(id) && !self.seeded.contains(id) && !want.contains(id))
279 .collect();
280 seeds.sort();
281 seeds.dedup();
282
283 if want.is_empty() && seeds.is_empty() {
284 return Ok(false);
285 }
286
287 // Frontier ids take budget priority so a blocked walk is never starved
288 // by a best-effort seed.
289 let frontier_take = (self.budget as usize).min(want.len());
290 let frontier_batch: Vec<String> = want.into_iter().take(frontier_take).collect();
291 self.budget -= frontier_batch.len() as u32;
292
293 let seed_take = (self.budget as usize).min(seeds.len());
294 let seed_batch: Vec<String> = seeds.into_iter().take(seed_take).collect();
295 self.budget -= seed_batch.len() as u32;
296 for id in &seed_batch {
297 self.seeded.insert(id.clone());
298 }
299
300 // One batch call covers frontier + seeds; the parent-endpoint fallback
301 // below is confined to the structural frontier.
302 let all: Vec<&str> = frontier_batch
303 .iter()
304 .chain(seed_batch.iter())
305 .map(String::as_str)
306 .collect();
307 let fetched = client
308 .get_clips_by_ids(http, &all, self.concurrency as usize)
309 .await?;
310
311 let mut returned: HashSet<String> = HashSet::new();
312 let mut progressed = false;
313 for clip in fetched {
314 returned.insert(clip.id.clone());
315 if self.insert_ancestor(clip) {
316 progressed = true;
317 }
318 }
319
320 for id in &frontier_batch {
321 if returned.contains(id) {
322 continue;
323 }
324 match client.get_clip_parent(http, id).await? {
325 Some(parent) => {
326 let parent_id = parent.id.clone();
327 self.insert_ancestor(parent);
328 self.bridges.insert(id.clone(), parent_id);
329 progressed = true;
330 }
331 None => {
332 self.external.insert(id.clone());
333 progressed = true;
334 }
335 }
336 }
337
338 Ok(progressed)
339 }
340
341 /// Same-owner `clip_root` ids across the current index, as extra root
342 /// candidates for gap-fill. Foreign-owned roots are excluded (fail-closed by
343 /// handle) so a foreign remix source is never folded into the owner's album.
344 fn clip_root_seeds(&self) -> Vec<String> {
345 let mut seeds = Vec::new();
346 for clip in self.index.values() {
347 for edge in attribution_edges(clip) {
348 if edge.same_owner {
349 seeds.push(edge.parent_id);
350 }
351 }
352 }
353 seeds
354 }
355
356 /// Add a gap-filled ancestor to the index, tracking it as an ancestor-only
357 /// clip. Returns whether it was newly added.
358 fn insert_ancestor(&mut self, clip: Clip) -> bool {
359 if clip.id.is_empty() || self.index.contains_key(&clip.id) {
360 return false;
361 }
362 self.gap_filled.insert(clip.id.clone());
363 self.index.insert(clip.id.clone(), Cow::Owned(clip));
364 true
365 }
366
367 /// Whether an id is already resolvable without another fetch.
368 fn known(&self, id: &str) -> bool {
369 self.index.contains_key(id)
370 || self.archived_parents.contains_key(id)
371 || self.bridges.contains_key(id)
372 || self.external.contains(id)
373 }
374
375 /// Mark every still-unresolved blocked target as external at the ancestor it
376 /// stalled on.
377 fn finalise_external(&mut self, blocked: &[(String, String)]) {
378 for (target, missing) in blocked {
379 if self.memo.contains_key(target) {
380 continue;
381 }
382 let info = self.terminal(missing, ResolveStatus::External);
383 self.memo.insert(target.clone(), info);
384 }
385 }
386
387 /// Build a [`RootInfo`] rooted at `id`, titled from the index when present.
388 fn terminal(&self, id: &str, status: ResolveStatus) -> RootInfo {
389 RootInfo {
390 root_id: id.to_string(),
391 root_title: self.title_of(id),
392 status,
393 }
394 }
395
396 /// The title of an indexed clip, or empty when it is not in the index.
397 fn title_of(&self, id: &str) -> String {
398 self.index
399 .get(id)
400 .map_or_else(String::new, |clip| clip.title.clone())
401 }
402
403 /// Record `info` as the root for every node on `chain`.
404 fn assign(&mut self, chain: &[String], info: &RootInfo) {
405 for id in chain {
406 self.memo.insert(id.clone(), info.clone());
407 }
408 }
409
410 /// Project the memo onto the input clips (so every one has a root entry) and
411 /// collect the gap-filled ancestors, sorted by id for a deterministic order.
412 fn into_resolution(mut self, clips: &[Clip]) -> Resolution {
413 let mut roots = HashMap::with_capacity(clips.len());
414 for clip in clips {
415 let info = self
416 .memo
417 .get(&clip.id)
418 .cloned()
419 .unwrap_or_else(|| RootInfo {
420 root_id: clip.id.clone(),
421 root_title: clip.title.clone(),
422 status: ResolveStatus::Unresolved,
423 });
424 roots.insert(clip.id.clone(), info);
425 }
426
427 // Gap-filled ancestors are held as `Cow::Owned`, so move them out of the
428 // index rather than cloning; the input clips are borrowed and never
429 // collected here.
430 let gap_filled_ids = std::mem::take(&mut self.gap_filled);
431 let mut gap_filled: Vec<Clip> = gap_filled_ids
432 .iter()
433 .filter_map(|id| self.index.remove(id))
434 .map(Cow::into_owned)
435 .collect();
436 gap_filled.sort_by(|a, b| a.id.cmp(&b.id));
437
438 let mut bridges: Vec<(String, String)> = self
439 .bridges
440 .iter()
441 .map(|(child, parent)| (child.clone(), parent.clone()))
442 .collect();
443 bridges.sort();
444
445 Resolution {
446 roots,
447 gap_filled,
448 bridges,
449 }
450 }
451}
452
453#[cfg(test)]
454mod tests;