@@ -39,6 +39,7 @@ const MAX_SYSTEM_NODES: usize = 100;
3939const CATALOG_MAX_BYTES : usize = 262_144 ;
4040const NODE_TEST_CONCURRENCY : usize = 4 ;
4141const AUTO_POOL_NODE_TARGET : usize = 10 ;
42+ const DOWNLOAD_BATCH_SIZE : usize = 4 ;
4243const HEALTH_CHECK_INTERVALS : [ u32 ; 6 ] = [
4344 0 ,
4445 60 ,
@@ -380,54 +381,49 @@ impl AppCore {
380381 } )
381382 }
382383
383- pub fn prepare_download_excluding (
384+ pub fn prepare_download_batch (
384385 & self ,
385386 original_url : & str ,
386387 excluded_node_ids : & [ String ] ,
387- ) -> Result < DownloadTarget , String > {
388+ ) -> Result < ( Vec < DownloadTarget > , bool ) , String > {
388389 let settings = self . settings ( ) ?;
389- let pairs: Vec < _ > = self
390+ let mut pairs: Vec < _ > = self
390391 . node_pairs ( ) ?
391392 . into_iter ( )
392- . filter ( |( node, _) | !excluded_node_ids. contains ( & node. id ) )
393+ . filter ( |( node, health) | {
394+ !excluded_node_ids. contains ( & node. id )
395+ && node. enabled
396+ && health. in_auto_pool
397+ && is_usable_health ( health)
398+ } )
393399 . collect ( ) ;
394- let current = settings. current_node_id . as_deref ( ) . and_then ( |id| {
395- pairs
396- . iter ( )
397- . find ( |( node, health) | {
398- node. id == id && node. enabled && health. in_auto_pool && is_usable_health ( health)
399- } )
400- . map ( |( node, _) | node)
401- } ) ;
402- let preferred = current. or_else ( || git:: choose_node ( & pairs) ) ;
403- let node = preferred. ok_or_else ( || {
404- if excluded_node_ids. is_empty ( ) {
400+ let mut targets = Vec :: new ( ) ;
401+ while targets. len ( ) < DOWNLOAD_BATCH_SIZE {
402+ let selected = if targets. is_empty ( ) {
403+ settings
404+ . current_node_id
405+ . as_deref ( )
406+ . and_then ( |id| pairs. iter ( ) . find ( |( node, _) | node. id == id) )
407+ . map ( |( node, _) | node)
408+ . or_else ( || git:: choose_node ( & pairs) )
409+ } else {
410+ git:: choose_node ( & pairs)
411+ } ;
412+ let Some ( node) = selected else {
413+ break ;
414+ } ;
415+ targets. push ( downloads:: prepare_target ( original_url, node) ?) ;
416+ let selected_id = node. id . clone ( ) ;
417+ pairs. retain ( |( node, _) | node. id != selected_id) ;
418+ }
419+ if targets. is_empty ( ) {
420+ return Err ( if excluded_node_ids. is_empty ( ) {
405421 "自动线路池中没有可用的下载节点" . to_string ( )
406422 } else {
407423 "自动线路池中没有其他可用的下载线路" . to_string ( )
408- }
409- } ) ?;
410- downloads:: prepare_target ( original_url, node)
411- }
412-
413- pub fn prepare_download_with_node (
414- & self ,
415- original_url : & str ,
416- node_id : & str ,
417- ) -> Result < DownloadTarget , String > {
418- let health = self . health ( ) ?;
419- let nodes = self . nodes ( ) ?;
420- let node = nodes
421- . iter ( )
422- . find ( |node| {
423- node. id == node_id
424- && node. enabled
425- && health
426- . get ( & node. id )
427- . is_some_and ( |summary| summary. in_auto_pool && is_usable_health ( summary) )
428- } )
429- . ok_or_else ( || "下载线路不在自动线路池中" . to_string ( ) ) ?;
430- downloads:: prepare_target ( original_url, node)
424+ } ) ;
425+ }
426+ Ok ( ( targets, git:: choose_node ( & pairs) . is_some ( ) ) )
431427 }
432428
433429 fn node_entries ( & self ) -> Result < Vec < NodeEntry > , String > {
@@ -3049,7 +3045,7 @@ mod tests {
30493045 let directory = tempfile:: tempdir ( ) . unwrap ( ) ;
30503046 let core = AppCore :: new ( directory. path ( ) . to_path_buf ( ) ) . unwrap ( ) ;
30513047 let error = core
3052- . prepare_download_excluding (
3048+ . prepare_download_batch (
30533049 "https://github.com/ollama/ollama/releases/download/v1/OllamaSetup.exe" ,
30543050 & [ ] ,
30553051 )
@@ -3058,7 +3054,7 @@ mod tests {
30583054 }
30593055
30603056 #[ test]
3061- fn prepares_download_with_the_next_available_node_without_changing_settings ( ) {
3057+ fn prepares_download_batch_without_changing_settings ( ) {
30623058 let directory = tempfile:: tempdir ( ) . unwrap ( ) ;
30633059 let core = AppCore :: new ( directory. path ( ) . to_path_buf ( ) ) . unwrap ( ) ;
30643060 core. import_nodes ( "https://one.example\n https://two.example" )
@@ -3089,20 +3085,23 @@ mod tests {
30893085 } ;
30903086 atomic_write_json ( & core. paths . settings , & settings) . unwrap ( ) ;
30913087
3092- let target = core
3093- . prepare_download_excluding (
3088+ let ( targets , has_remaining ) = core
3089+ . prepare_download_batch (
30943090 "https://github.com/DiscoverBox/gitboost/archive/refs/heads/main.zip" ,
3095- std :: slice :: from_ref ( & first . id ) ,
3091+ & [ ] ,
30963092 )
30973093 . unwrap ( ) ;
30983094
3099- assert_eq ! ( target. node_id, second. id) ;
3095+ assert_eq ! ( targets. len( ) , 2 ) ;
3096+ assert_eq ! ( targets[ 0 ] . node_id, first. id) ;
3097+ assert_eq ! ( targets[ 1 ] . node_id, second. id) ;
3098+ assert ! ( !has_remaining) ;
31003099 assert_eq ! (
31013100 core. settings( ) . unwrap( ) . current_node_id,
31023101 Some ( first. id. clone( ) )
31033102 ) ;
31043103 assert_eq ! (
3105- core. prepare_download_excluding (
3104+ core. prepare_download_batch (
31063105 "https://github.com/DiscoverBox/gitboost/archive/refs/heads/main.zip" ,
31073106 & [ first. id. clone( ) , second. id. clone( ) ] ,
31083107 )
@@ -3111,6 +3110,45 @@ mod tests {
31113110 ) ;
31123111 }
31133112
3113+ #[ test]
3114+ fn download_batch_keeps_unattempted_nodes_for_manual_retry ( ) {
3115+ let directory = tempfile:: tempdir ( ) . unwrap ( ) ;
3116+ let core = AppCore :: new ( directory. path ( ) . to_path_buf ( ) ) . unwrap ( ) ;
3117+ let nodes = ( 1 ..=5 )
3118+ . map ( |index| {
3119+ custom_node (
3120+ & format ! ( "node-{index}" ) ,
3121+ & format ! ( "Node {index}" ) ,
3122+ & format ! ( "https://{index}.example/https://github.com/" ) ,
3123+ )
3124+ } )
3125+ . collect :: < Vec < _ > > ( ) ;
3126+ configure_automatic_nodes ( & core, & nodes, & nodes[ 0 ] . id ) ;
3127+
3128+ let ( first_batch, has_remaining) = core
3129+ . prepare_download_batch (
3130+ "https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh" ,
3131+ & [ ] ,
3132+ )
3133+ . unwrap ( ) ;
3134+ assert_eq ! ( first_batch. len( ) , DOWNLOAD_BATCH_SIZE ) ;
3135+ assert ! ( has_remaining) ;
3136+
3137+ let excluded = first_batch
3138+ . iter ( )
3139+ . map ( |target| target. node_id . clone ( ) )
3140+ . collect :: < Vec < _ > > ( ) ;
3141+ let ( second_batch, has_remaining) = core
3142+ . prepare_download_batch (
3143+ "https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh" ,
3144+ & excluded,
3145+ )
3146+ . unwrap ( ) ;
3147+ assert_eq ! ( second_batch. len( ) , 1 ) ;
3148+ assert_eq ! ( second_batch[ 0 ] . node_id, nodes[ 4 ] . id) ;
3149+ assert ! ( !has_remaining) ;
3150+ }
3151+
31143152 #[ test]
31153153 fn usage_audit_records_route_and_removes_credentials ( ) {
31163154 let directory = tempfile:: tempdir ( ) . unwrap ( ) ;
@@ -3555,6 +3593,50 @@ mod tests {
35553593 . unwrap ( ) ;
35563594 }
35573595
3596+ #[ test]
3597+ #[ ignore = "requires live HTTPS and GitHub proxy access" ]
3598+ fn live_download_batch_falls_back_after_a_real_http_failure ( ) {
3599+ let directory = tempfile:: tempdir ( ) . unwrap ( ) ;
3600+ let core = AppCore :: new ( directory. path ( ) . to_path_buf ( ) ) . unwrap ( ) ;
3601+ let unavailable = custom_node (
3602+ "real-http-failure" ,
3603+ "Real HTTP failure" ,
3604+ "https://example.com/https://github.com/" ,
3605+ ) ;
3606+ let fallback = NodeDefinition :: fastgit ( ) ;
3607+ configure_automatic_nodes (
3608+ & core,
3609+ & [ unavailable. clone ( ) , fallback. clone ( ) ] ,
3610+ & unavailable. id ,
3611+ ) ;
3612+
3613+ let ( targets, has_remaining) = core
3614+ . prepare_download_batch (
3615+ "https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh" ,
3616+ & [ ] ,
3617+ )
3618+ . unwrap ( ) ;
3619+ assert_eq ! (
3620+ targets
3621+ . iter( )
3622+ . map( |target| target. node_id. as_str( ) )
3623+ . collect:: <Vec <_>>( ) ,
3624+ vec![ unavailable. id. as_str( ) , fallback. id. as_str( ) ]
3625+ ) ;
3626+ assert ! ( !has_remaining) ;
3627+
3628+ let ( winner, attempted_node_ids) = downloads:: probe_first_available ( targets) . unwrap ( ) ;
3629+ assert_eq ! ( winner. node_id, fallback. id) ;
3630+ assert_eq ! (
3631+ attempted_node_ids,
3632+ vec![ unavailable. id. clone( ) , fallback. id. clone( ) ]
3633+ ) ;
3634+ assert_eq ! (
3635+ core. settings( ) . unwrap( ) . current_node_id,
3636+ Some ( unavailable. id)
3637+ ) ;
3638+ }
3639+
35583640 #[ test]
35593641 #[ ignore = "requires live system-node and GitHub access" ]
35603642 fn core_workflow_applies_verifies_persists_and_restores_git_routing ( ) {
0 commit comments