Skip to main content

asupersync/net/atp/transport_common/
multisource.rs

1//! Explicit multi-source ATP fetch planning.
2//!
3//! Multi-source transfer starts from a receiver-owned list of candidate peers
4//! that can serve the same object. This module keeps that protocol decision
5//! transport-agnostic: validate the explicit source list, select a deterministic
6//! subset, assign complementary source/repair emphasis to reduce waste, and
7//! produce the stop fanout once any union of symbols decodes.
8
9use std::collections::{BTreeMap, BTreeSet};
10
11/// Stable object identity used to prove all selected peers serve the same data.
12#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
13pub struct MultiSourceObjectRef {
14    /// Transfer or object identifier shared by the peers.
15    pub object_id: String,
16    /// Expected Merkle root for the complete logical object.
17    pub merkle_root_hex: String,
18}
19
20impl MultiSourceObjectRef {
21    /// Create an object reference.
22    #[must_use]
23    pub fn new(object_id: impl Into<String>, merkle_root_hex: impl Into<String>) -> Self {
24        Self {
25            object_id: object_id.into(),
26            merkle_root_hex: merkle_root_hex.into(),
27        }
28    }
29
30    fn validate(&self) -> Result<(), MultiSourcePlanError> {
31        if self.object_id.trim().is_empty() {
32            return Err(MultiSourcePlanError::EmptyObjectId);
33        }
34        if self.merkle_root_hex.trim().is_empty() {
35            return Err(MultiSourcePlanError::EmptyMerkleRoot);
36        }
37        Ok(())
38    }
39}
40
41/// Per-peer authentication posture for a multi-source candidate.
42#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
43pub enum MultiSourceAuth {
44    /// The peer must authenticate symbols with the named per-transfer key scope.
45    SymbolAuth { key_id: String },
46    /// Lab-only escape hatch. Production plans reject this unless explicitly allowed.
47    UnauthenticatedLab,
48}
49
50impl MultiSourceAuth {
51    /// Return a stable lower-case identifier for logs and plan artifacts.
52    #[must_use]
53    pub fn mode_id(&self) -> &'static str {
54        match self {
55            Self::SymbolAuth { .. } => "symbol_auth",
56            Self::UnauthenticatedLab => "unauthenticated_lab",
57        }
58    }
59
60    fn is_symbol_auth(&self) -> bool {
61        matches!(self, Self::SymbolAuth { .. })
62    }
63
64    fn validate(&self) -> Result<(), MultiSourcePlanError> {
65        match self {
66            Self::SymbolAuth { key_id } if key_id.trim().is_empty() => {
67                Err(MultiSourcePlanError::EmptyAuthKeyId)
68            }
69            _ => Ok(()),
70        }
71    }
72}
73
74/// One explicit peer that can serve the object.
75#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
76pub struct MultiSourcePeer {
77    /// Stable peer label or identity digest.
78    pub peer_id: String,
79    /// Transport endpoint, for example `host:port`.
80    pub endpoint: String,
81    /// Lower values are selected first.
82    pub priority: u32,
83    /// Authentication posture required for this source.
84    pub auth: MultiSourceAuth,
85}
86
87impl MultiSourcePeer {
88    /// Create a candidate source peer.
89    #[must_use]
90    pub fn new(
91        peer_id: impl Into<String>,
92        endpoint: impl Into<String>,
93        priority: u32,
94        auth: MultiSourceAuth,
95    ) -> Self {
96        Self {
97            peer_id: peer_id.into(),
98            endpoint: endpoint.into(),
99            priority,
100            auth,
101        }
102    }
103
104    fn validate(&self) -> Result<(), MultiSourcePlanError> {
105        if self.peer_id.trim().is_empty() {
106            return Err(MultiSourcePlanError::EmptyPeerId);
107        }
108        if self.endpoint.trim().is_empty() {
109            return Err(MultiSourcePlanError::EmptyEndpoint {
110                peer_id: self.peer_id.clone(),
111            });
112        }
113        self.auth.validate()
114    }
115}
116
117/// How a selected source should bias its first spray.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
119pub enum MultiSourceSymbolBias {
120    /// Prefer systematic/source symbols first.
121    SourceFirst,
122    /// Prefer repair symbols earlier to complement a source-first peer.
123    RepairFirst,
124    /// Balanced fallback for additional sources.
125    Balanced,
126}
127
128impl MultiSourceSymbolBias {
129    /// Stable lower-case identifier for logs and plan artifacts.
130    #[must_use]
131    pub const fn bias_id(self) -> &'static str {
132        match self {
133            Self::SourceFirst => "source_first",
134            Self::RepairFirst => "repair_first",
135            Self::Balanced => "balanced",
136        }
137    }
138}
139
140/// Config for deterministic explicit-source selection.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
142pub struct MultiSourceSelectionConfig {
143    /// Minimum eligible sources needed before the plan is usable.
144    pub min_sources: usize,
145    /// Maximum selected sources to ask for the object.
146    pub max_sources: usize,
147    /// Whether `UnauthenticatedLab` peers are admitted.
148    pub allow_unauthenticated_lab: bool,
149}
150
151impl MultiSourceSelectionConfig {
152    /// Production default: at least two authenticated sources, select up to four.
153    #[must_use]
154    pub const fn production_default() -> Self {
155        Self {
156            min_sources: 2,
157            max_sources: 4,
158            allow_unauthenticated_lab: false,
159        }
160    }
161
162    fn validate(self) -> Result<(), MultiSourcePlanError> {
163        if self.min_sources == 0 {
164            return Err(MultiSourcePlanError::ZeroMinSources);
165        }
166        if self.max_sources < self.min_sources {
167            return Err(MultiSourcePlanError::MaxSourcesBelowMin {
168                min_sources: self.min_sources,
169                max_sources: self.max_sources,
170            });
171        }
172        Ok(())
173    }
174}
175
176impl Default for MultiSourceSelectionConfig {
177    fn default() -> Self {
178        Self::production_default()
179    }
180}
181
182/// One selected peer with its deterministic role in the multi-source fetch.
183#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
184pub struct MultiSourceSourcePlan {
185    /// Selected source peer.
186    pub peer: MultiSourcePeer,
187    /// Complementary source/repair bias for this peer.
188    pub symbol_bias: MultiSourceSymbolBias,
189    /// Stable zero-based order after deterministic selection.
190    pub selection_order: u32,
191}
192
193/// Stop command emitted for every selected source once the receiver decodes.
194#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
195pub struct MultiSourceStopCommand {
196    /// Peer to stop.
197    pub peer_id: String,
198    /// Endpoint to send the stop/proof signal to.
199    pub endpoint: String,
200    /// Object the stop applies to.
201    pub object_id: String,
202    /// Stable reason identifier.
203    pub reason: MultiSourceStopReason,
204}
205
206/// Why the receiver asks selected sources to stop.
207#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
208pub enum MultiSourceStopReason {
209    /// Decode, SHA, and Merkle verification completed.
210    DecodedAndVerified,
211    /// Receiver cancelled the fetch before completion.
212    Cancelled,
213    /// Receiver failed closed.
214    FailedClosed,
215}
216
217impl MultiSourceStopReason {
218    /// Stable lower-case identifier for logs and plan artifacts.
219    #[must_use]
220    pub const fn reason_id(self) -> &'static str {
221        match self {
222            Self::DecodedAndVerified => "decoded_and_verified",
223            Self::Cancelled => "cancelled",
224            Self::FailedClosed => "failed_closed",
225        }
226    }
227}
228
229/// Complete receiver-side plan for fetching one object from several sources.
230#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
231pub struct MultiSourceFetchPlan {
232    /// Object all selected sources must serve.
233    pub object: MultiSourceObjectRef,
234    /// Deterministically selected sources.
235    pub selected_sources: Vec<MultiSourceSourcePlan>,
236}
237
238impl MultiSourceFetchPlan {
239    /// Number of selected sources.
240    #[must_use]
241    pub fn source_count(&self) -> usize {
242        self.selected_sources.len()
243    }
244
245    /// Build one stop command per selected source in selection order.
246    #[must_use]
247    pub fn stop_commands(&self, reason: MultiSourceStopReason) -> Vec<MultiSourceStopCommand> {
248        self.selected_sources
249            .iter()
250            .map(|source| MultiSourceStopCommand {
251                peer_id: source.peer.peer_id.clone(),
252                endpoint: source.peer.endpoint.clone(),
253                object_id: self.object.object_id.clone(),
254                reason,
255            })
256            .collect()
257    }
258}
259
260/// Errors from [`plan_multi_source_fetch`].
261#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
262pub enum MultiSourcePlanError {
263    /// Object id is empty.
264    #[error("multi-source object id must be non-empty")]
265    EmptyObjectId,
266    /// Merkle root is empty.
267    #[error("multi-source merkle root must be non-empty")]
268    EmptyMerkleRoot,
269    /// Peer id is empty.
270    #[error("multi-source peer id must be non-empty")]
271    EmptyPeerId,
272    /// Endpoint is empty.
273    #[error("multi-source endpoint for peer {peer_id} must be non-empty")]
274    EmptyEndpoint {
275        /// Peer with the empty endpoint.
276        peer_id: String,
277    },
278    /// Symbol-auth key id is empty.
279    #[error("multi-source symbol-auth key id must be non-empty")]
280    EmptyAuthKeyId,
281    /// Duplicate peer id in the explicit source list.
282    #[error("multi-source duplicate peer id: {peer_id}")]
283    DuplicatePeerId {
284        /// Duplicate peer id.
285        peer_id: String,
286    },
287    /// Duplicate endpoint in the explicit source list.
288    #[error("multi-source duplicate endpoint: {endpoint}")]
289    DuplicateEndpoint {
290        /// Duplicate endpoint.
291        endpoint: String,
292    },
293    /// No selected source can be unauthenticated unless lab mode is explicit.
294    #[error("multi-source peer {peer_id} is unauthenticated but lab mode is not allowed")]
295    UnauthenticatedPeerRejected {
296        /// Rejected peer id.
297        peer_id: String,
298    },
299    /// Minimum source count must be positive.
300    #[error("multi-source min_sources must be greater than zero")]
301    ZeroMinSources,
302    /// max_sources must be at least min_sources.
303    #[error("multi-source max_sources {max_sources} is below min_sources {min_sources}")]
304    MaxSourcesBelowMin {
305        /// Required minimum.
306        min_sources: usize,
307        /// Configured maximum.
308        max_sources: usize,
309    },
310    /// Not enough eligible sources after validation/auth checks.
311    #[error("multi-source needs at least {required} eligible sources, got {available}")]
312    NotEnoughEligibleSources {
313        /// Required eligible source count.
314        required: usize,
315        /// Available eligible source count.
316        available: usize,
317    },
318    /// More selected sources than can be represented in stable plan artifacts.
319    #[error("multi-source selected source count exceeds u32::MAX")]
320    TooManySelectedSources,
321}
322
323/// Plan an explicit multi-source fetch for one object.
324///
325/// Selection is deterministic: candidates are first validated and deduplicated,
326/// then sorted by `(priority, peer_id, endpoint)`, capped by `max_sources`, and
327/// assigned complementary symbol bias by selection order. This is intentionally
328/// policy-only; transports map each selected peer into a connection and feed
329/// authenticated symbols into the multipath aggregator.
330pub fn plan_multi_source_fetch(
331    object: MultiSourceObjectRef,
332    peers: impl IntoIterator<Item = MultiSourcePeer>,
333    config: MultiSourceSelectionConfig,
334) -> Result<MultiSourceFetchPlan, MultiSourcePlanError> {
335    object.validate()?;
336    config.validate()?;
337
338    let mut peer_ids = BTreeSet::new();
339    let mut endpoints = BTreeSet::new();
340    let mut by_key = BTreeMap::new();
341
342    for peer in peers {
343        peer.validate()?;
344        if !peer_ids.insert(peer.peer_id.clone()) {
345            return Err(MultiSourcePlanError::DuplicatePeerId {
346                peer_id: peer.peer_id,
347            });
348        }
349        if !endpoints.insert(peer.endpoint.clone()) {
350            return Err(MultiSourcePlanError::DuplicateEndpoint {
351                endpoint: peer.endpoint,
352            });
353        }
354        if !config.allow_unauthenticated_lab && !peer.auth.is_symbol_auth() {
355            return Err(MultiSourcePlanError::UnauthenticatedPeerRejected {
356                peer_id: peer.peer_id,
357            });
358        }
359        by_key.insert(
360            (peer.priority, peer.peer_id.clone(), peer.endpoint.clone()),
361            peer,
362        );
363    }
364
365    if by_key.len() < config.min_sources {
366        return Err(MultiSourcePlanError::NotEnoughEligibleSources {
367            required: config.min_sources,
368            available: by_key.len(),
369        });
370    }
371
372    let mut selected_sources = Vec::new();
373    for (idx, peer) in by_key.into_values().take(config.max_sources).enumerate() {
374        let selection_order =
375            u32::try_from(idx).map_err(|_| MultiSourcePlanError::TooManySelectedSources)?;
376        selected_sources.push(MultiSourceSourcePlan {
377            peer,
378            symbol_bias: symbol_bias_for_order(idx),
379            selection_order,
380        });
381    }
382
383    Ok(MultiSourceFetchPlan {
384        object,
385        selected_sources,
386    })
387}
388
389fn symbol_bias_for_order(order: usize) -> MultiSourceSymbolBias {
390    match order {
391        0 => MultiSourceSymbolBias::SourceFirst,
392        1 => MultiSourceSymbolBias::RepairFirst,
393        _ => MultiSourceSymbolBias::Balanced,
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    fn object_ref() -> MultiSourceObjectRef {
402        MultiSourceObjectRef::new("object-01", "abc123")
403    }
404
405    fn auth(key_id: &str) -> MultiSourceAuth {
406        MultiSourceAuth::SymbolAuth {
407            key_id: key_id.to_string(),
408        }
409    }
410
411    fn peer(id: &str, endpoint: &str, priority: u32) -> MultiSourcePeer {
412        MultiSourcePeer::new(id, endpoint, priority, auth("key-a"))
413    }
414
415    #[test]
416    fn production_default_requires_two_authenticated_sources() {
417        assert_eq!(
418            MultiSourceSelectionConfig::production_default(),
419            MultiSourceSelectionConfig {
420                min_sources: 2,
421                max_sources: 4,
422                allow_unauthenticated_lab: false,
423            }
424        );
425    }
426
427    #[test]
428    fn selection_is_priority_then_peer_then_endpoint_deterministic() {
429        let plan = plan_multi_source_fetch(
430            object_ref(),
431            [
432                peer("peer-c", "10.0.0.3:8472", 10),
433                peer("peer-b", "10.0.0.2:8472", 5),
434                peer("peer-a", "10.0.0.1:8472", 5),
435            ],
436            MultiSourceSelectionConfig {
437                min_sources: 2,
438                max_sources: 2,
439                allow_unauthenticated_lab: false,
440            },
441        )
442        .unwrap();
443
444        assert_eq!(plan.source_count(), 2);
445        assert_eq!(plan.selected_sources[0].peer.peer_id, "peer-a");
446        assert_eq!(plan.selected_sources[1].peer.peer_id, "peer-b");
447        assert_eq!(
448            plan.selected_sources
449                .iter()
450                .map(|source| source.selection_order)
451                .collect::<Vec<_>>(),
452            vec![0, 1]
453        );
454    }
455
456    #[test]
457    fn selected_sources_get_complementary_symbol_biases() {
458        let plan = plan_multi_source_fetch(
459            object_ref(),
460            [
461                peer("peer-a", "10.0.0.1:8472", 1),
462                peer("peer-b", "10.0.0.2:8472", 2),
463                peer("peer-c", "10.0.0.3:8472", 3),
464            ],
465            MultiSourceSelectionConfig {
466                min_sources: 2,
467                max_sources: 3,
468                allow_unauthenticated_lab: false,
469            },
470        )
471        .unwrap();
472
473        assert_eq!(
474            plan.selected_sources
475                .iter()
476                .map(|source| source.symbol_bias)
477                .collect::<Vec<_>>(),
478            vec![
479                MultiSourceSymbolBias::SourceFirst,
480                MultiSourceSymbolBias::RepairFirst,
481                MultiSourceSymbolBias::Balanced,
482            ]
483        );
484        assert_eq!(
485            plan.selected_sources[0].symbol_bias.bias_id(),
486            "source_first"
487        );
488    }
489
490    #[test]
491    fn stop_commands_cover_every_selected_source_in_selection_order() {
492        let plan = plan_multi_source_fetch(
493            object_ref(),
494            [
495                peer("peer-b", "10.0.0.2:8472", 1),
496                peer("peer-a", "10.0.0.1:8472", 0),
497            ],
498            MultiSourceSelectionConfig::production_default(),
499        )
500        .unwrap();
501
502        let stops = plan.stop_commands(MultiSourceStopReason::DecodedAndVerified);
503        assert_eq!(stops.len(), 2);
504        assert_eq!(stops[0].peer_id, "peer-a");
505        assert_eq!(stops[1].peer_id, "peer-b");
506        assert!(stops.iter().all(|stop| {
507            stop.object_id == "object-01" && stop.reason.reason_id() == "decoded_and_verified"
508        }));
509    }
510
511    #[test]
512    fn unauthenticated_sources_fail_closed_outside_lab_mode() {
513        let err = plan_multi_source_fetch(
514            object_ref(),
515            [
516                peer("peer-a", "10.0.0.1:8472", 0),
517                MultiSourcePeer::new(
518                    "peer-b",
519                    "10.0.0.2:8472",
520                    1,
521                    MultiSourceAuth::UnauthenticatedLab,
522                ),
523            ],
524            MultiSourceSelectionConfig::production_default(),
525        )
526        .unwrap_err();
527
528        assert!(matches!(
529            err,
530            MultiSourcePlanError::UnauthenticatedPeerRejected { peer_id }
531                if peer_id == "peer-b"
532        ));
533    }
534
535    #[test]
536    fn lab_mode_must_still_meet_minimum_source_count() {
537        let plan = plan_multi_source_fetch(
538            object_ref(),
539            [
540                MultiSourcePeer::new(
541                    "peer-a",
542                    "10.0.0.1:8472",
543                    0,
544                    MultiSourceAuth::UnauthenticatedLab,
545                ),
546                MultiSourcePeer::new(
547                    "peer-b",
548                    "10.0.0.2:8472",
549                    1,
550                    MultiSourceAuth::UnauthenticatedLab,
551                ),
552            ],
553            MultiSourceSelectionConfig {
554                min_sources: 2,
555                max_sources: 2,
556                allow_unauthenticated_lab: true,
557            },
558        )
559        .unwrap();
560
561        assert_eq!(plan.source_count(), 2);
562        assert_eq!(
563            plan.selected_sources[0].peer.auth.mode_id(),
564            "unauthenticated_lab"
565        );
566    }
567
568    #[test]
569    fn duplicate_peer_ids_and_endpoints_fail_closed() {
570        assert!(matches!(
571            plan_multi_source_fetch(
572                object_ref(),
573                [
574                    peer("peer-a", "10.0.0.1:8472", 0),
575                    peer("peer-a", "10.0.0.2:8472", 1),
576                ],
577                MultiSourceSelectionConfig::production_default(),
578            ),
579            Err(MultiSourcePlanError::DuplicatePeerId { .. })
580        ));
581
582        assert!(matches!(
583            plan_multi_source_fetch(
584                object_ref(),
585                [
586                    peer("peer-a", "10.0.0.1:8472", 0),
587                    peer("peer-b", "10.0.0.1:8472", 1),
588                ],
589                MultiSourceSelectionConfig::production_default(),
590            ),
591            Err(MultiSourcePlanError::DuplicateEndpoint { .. })
592        ));
593    }
594
595    #[test]
596    fn invalid_config_and_object_identity_fail_closed() {
597        assert!(matches!(
598            plan_multi_source_fetch(
599                MultiSourceObjectRef::new("", "abc"),
600                [peer("peer-a", "10.0.0.1:8472", 0)],
601                MultiSourceSelectionConfig::production_default(),
602            ),
603            Err(MultiSourcePlanError::EmptyObjectId)
604        ));
605        assert!(matches!(
606            plan_multi_source_fetch(
607                object_ref(),
608                [
609                    peer("peer-a", "10.0.0.1:8472", 0),
610                    peer("peer-b", "10.0.0.2:8472", 1),
611                ],
612                MultiSourceSelectionConfig {
613                    min_sources: 3,
614                    max_sources: 2,
615                    allow_unauthenticated_lab: false,
616                },
617            ),
618            Err(MultiSourcePlanError::MaxSourcesBelowMin { .. })
619        ));
620    }
621}