Skip to main content

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(&current).cloned() {
181                self.assign(&chain, &info);
182                return Walk::Resolved;
183            }
184            if visited.contains(&current) {
185                let info = self.terminal(&current, 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(&current, 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(&current) {
204                immediate_parent(clip).map(|(id, _edge)| id)
205            } else if let Some(parent) = self.archived_parents.get(&current) {
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(&current),
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;