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