forks/sorcery
git clone https://git.gae.moe/forks/sorcery
2688dec
typst_support
1use std:: collections::{ HashMap , HashSet }; 2use std:: fs; 3use std:: path::{ Path , PathBuf }; 4use std:: sync:: Arc ; 5use std:: sync:: atomic::{ AtomicBool , Ordering }; 6use std:: time:: Instant ; 7 8use anyhow::{ Context , Result }; 9use axum:: body:: Bytes ; 10use tokio:: sync::{ Mutex , watch}; 11 12use crate :: catalog::{ self , Repository }; 13use crate :: generate::{ self , Config as GenerateConfig , STATE_FILE }; 14use crate :: render::{ catalog_index, user_index}; 15 16use super ::{ AppState , Inner }; 17 18# [ derive ( Default )] 19pub ( super ) struct Snapshot { 20pub ( super ) catalog_html : Bytes , 21pub ( super ) user_html : HashMap < String , Bytes >, 22pub ( super ) repos : HashMap < String , RepoEntry >, 23} 24 25# [ derive ( Clone )] 26pub ( super ) struct RepoEntry { 27pub ( super ) repository : Repository , 28build_gate : Arc < Mutex <()>>, 29} 30 31# [ derive ( Clone , Copy , Default )] 32struct BuildQueueState { 33running : bool , 34priority_waiting : usize , 35} 36 37/// Serializes site builds: each already fans out over every core with 38/// rayon, so concurrent builds only thrash. Human-triggered builds claim 39/// the slot ahead of background rebuilds, and cancel the background build 40/// currently holding it; the scanner re-queues whatever it abandons. 41pub ( super ) struct BuildQueue { 42state : watch:: Sender < BuildQueueState >, 43background : std:: sync:: Mutex < Option < Arc < AtomicBool >>>, 44} 45 46pub ( super ) struct BuildPermit < ' a > { 47queue : &' a BuildQueue , 48} 49 50impl BuildQueue { 51pub ( super ) fn new () ->Self { 52Self { 53state : watch:: channel ( BuildQueueState :: default ()). 0 , 54background : std:: sync:: Mutex :: new ( None ), 55} 56} 57 58fn register_background ( & self ) ->Arc < AtomicBool > { 59let flag =Arc :: new ( AtomicBool :: new ( false )); 60* self . background . lock (). unwrap_or_else ( |e| e. into_inner ()) =Some ( flag. clone ()); 61 flag 62} 63 64fn clear_background ( & self , flag : & Arc < AtomicBool >) { 65let mut background =self . background . lock (). unwrap_or_else ( |e| e. into_inner ()); 66if background. as_ref (). is_some_and ( |active|Arc :: ptr_eq ( active, flag)) { 67* background =None ; 68} 69} 70 71async fn acquire ( & self , priority : bool ) ->BuildPermit < ' _ > { 72if priority{ 73self . state 74. send_modify ( |state| state. priority_waiting +=1 ); 75let background =self . background . lock (). unwrap_or_else ( |e| e. into_inner ()); 76if let Some ( flag) = background. as_ref () { 77 flag. store ( true , Ordering :: Relaxed ); 78} 79} 80let mut seen =self . state . subscribe (); 81loop { 82let mut claimed =false ; 83self . state . send_modify ( |state|{ 84if !state. running &&( priority || state. priority_waiting ==0 ) { 85 state. running =true ; 86if priority{ 87 state. priority_waiting -=1 ; 88} 89 claimed =true ; 90} 91}); 92if claimed{ 93return BuildPermit { queue : self }; 94} 95 seen. borrow_and_update (); 96let _ = seen. changed (). await ; 97} 98} 99} 100 101impl Drop for BuildPermit < ' _ > { 102fn drop ( & mut self ) { 103self . queue 104. state 105. send_modify ( |state| state. running =false ); 106} 107} 108 109pub ( super ) async fn scan_periodically ( state : AppState ) { 110let mut tick = tokio:: time:: interval ( state. config . check_interval ); 111 tick. set_missed_tick_behavior ( tokio:: time:: MissedTickBehavior :: Delay ); 112 tick. tick (). await ; // the first tick is immediate; run() already scanned 113loop { 114 tokio:: select!{ 115 _ = tick. tick () =>{}, 116 _ = state. rescan_requested . notified () =>{}, 117} 118if let Err ( error) = state. rescan (). await { 119eprintln! ( "sorceryd: rescan: {error}" ); 120} 121} 122} 123 124impl Inner { 125/// Discover repositories and publish a fresh immutable catalog snapshot. 126pub ( super ) async fn rescan ( & self ) ->Result <()> { 127let _gate =self . rescan_gate . lock (). await ; 128let config =self . config . clone (); 129let ( repositories, freshness) = tokio:: task:: spawn_blocking ( move || ->Result < _ > { 130let repositories = catalog:: discover ( & config. repositories ) ?; 131let freshness = repositories 132. iter () 133. map ( |repository|is_current ( & generate_config ( & config, repository))) 134. collect ::< Vec < _ >>(); 135Ok (( repositories, freshness)) 136}) 137. await ??; 138 139let old =self . snapshot . load_full (); 140let mut repos =HashMap :: with_capacity ( repositories. len ()); 141let mut stale =Vec :: new (); 142for ( repository, current) in repositories. iter (). zip ( freshness) { 143let current =match current{ 144Ok ( current) => current, 145Err ( error) =>{ 146eprintln! ( 147"sorceryd: skipping {}/{}: {error}" , 148 repository. user , repository. name 149); 150continue ; 151} 152}; 153let key =format! ( "{}/{}" , repository. user , repository. name ); 154let build_gate = old 155. repos 156. get ( & key) 157. map ( |entry| entry. build_gate . clone ()) 158. unwrap_or_else ( ||Arc :: new ( Mutex :: new (()))); 159let entry =RepoEntry { 160repository : repository. clone (), 161 build_gate, 162}; 163if !current{ 164 stale. push ( entry. clone ()); 165} 166 repos. insert ( key, entry); 167} 168 169let instance_name =& self . config . instance_name ; 170let user_html = repositories 171. chunk_by ( |a, b| a. user == b. user ) 172. map ( |repos|{ 173let user =& repos[ 0 ]. user ; 174( user. clone (), user_index ( instance_name, user, repos). into ()) 175}) 176. collect (); 177self . snapshot . store ( Arc :: new ( Snapshot { 178catalog_html : catalog_index ( instance_name, & repositories). into (), 179 user_html, 180 repos, 181})); 182self . background . send_replace ( stale); 183Ok (()) 184} 185 186pub ( super ) async fn build ( & self , entry : & RepoEntry , priority : bool ) ->Result < bool > { 187let background =( !priority). then ( ||self . build_queue . register_background ()); 188let permit =self . build_queue . acquire ( priority). await ; 189let _gate = entry. build_gate . lock (). await ; 190let _cache =self . cache_lock . read (). await ; 191let config =generate_config ( & self . config , & entry. repository ); 192let highlights =self . highlights . clone (); 193let cancel = background. clone (); 194let result = 195 tokio:: task:: spawn_blocking ( move ||ensure_current ( & config, highlights, cancel)). await ; 196if let Some ( flag) =& background{ 197self . build_queue . clear_background ( flag); 198} 199drop ( permit); 200 result? 201} 202} 203 204pub ( super ) async fn background_worker ( state : AppState , mut stale : watch:: Receiver < Vec < RepoEntry >>) { 205while stale. changed (). await . is_ok () { 206let entries = stale. borrow_and_update (). clone (); 207for entryin entries{ 208let started =Instant :: now (); 209let name =format! ( "{}/{}" , entry. repository . user , entry. repository . name ); 210match state. build ( & entry, false ). await { 211Ok ( true ) =>eprintln! ( 212"sorceryd: rebuilt {name} in {:.2}s" , 213 started. elapsed (). as_secs_f64 (), 214), 215Ok ( false ) =>{} 216Err ( error) =>eprintln! ( "sorceryd: background rebuild of {name}: {error}" ), 217} 218} 219match garbage_collect ( & state). await { 220Ok ( None |Some ( 0 )) =>{} 221Ok ( Some ( count)) =>{ 222eprintln! ( "sorceryd: removed {count} stale cache directories" ) 223} 224Err ( error) =>eprintln! ( "sorceryd: cache garbage collection: {error}" ), 225} 226} 227} 228 229async fn garbage_collect ( state : & Inner ) ->Result < Option < usize >> { 230let Ok ( _cache) = state. cache_lock . try_write () else { 231return Ok ( None ); 232}; 233let cache = state. config . cache . clone (); 234let live = state 235. snapshot 236. load () 237. repos 238. values () 239. map ( |entry| state. config . repo_cache ( & entry. repository )) 240. collect ::< HashSet < _ >>(); 241 tokio:: task:: spawn_blocking ( move ||garbage_collect_cache ( & cache, & live)) 242. await ? 243. map ( Some ) 244} 245 246fn garbage_collect_cache ( cache : & Path , live : & HashSet < PathBuf >) ->Result < usize > { 247let mut removed =0 ; 248for userin fs:: read_dir ( cache) ?{ 249let user = user?; 250if !user. file_type () ?. is_dir () { 251continue ; 252} 253for repoin fs:: read_dir ( user. path ()) ?{ 254let repo = repo?; 255if !repo. file_type () ?. is_dir () { 256continue ; 257} 258let path = repo. path (); 259if path. join ( STATE_FILE ). is_file () && !live. contains ( & path) { 260 fs:: remove_dir_all ( path) ?; 261 removed +=1 ; 262} 263} 264} 265Ok ( removed) 266} 267 268fn generate_config ( config : & super :: Config , repository : & Repository ) ->GenerateConfig { 269GenerateConfig { 270repo : repository. path . clone (), 271out : config. repo_cache ( repository), 272instance_name : config. instance_name . clone (), 273name : Some ( format! ( "{}/{}" , repository. user , repository. name )), 274clone_url : config. site_url ( repository), 275} 276} 277 278fn is_current ( config : & GenerateConfig ) ->Result < bool > { 279let expected = generate:: expected_state ( config) ?; 280Ok ( fs:: read_to_string ( config. out . join ( STATE_FILE )) 281. ok () 282. as_deref () 283 ==Some ( & expected)) 284} 285 286/// Regenerate the cache directory if its recorded state differs from the 287/// repository's, building into a staging sibling and swapping atomically. 288fn ensure_current ( 289config : & GenerateConfig , 290highlights : Arc < crate :: highlight:: Cache >, 291cancel : Option < Arc < AtomicBool >>, 292) ->Result < bool > { 293if is_current ( config) ?{ 294return Ok ( false ); 295} 296 297let parent = config 298. out 299. parent () 300. context ( "cache repository has no parent" ) ?; 301let name = config 302. out 303. file_name () 304. context ( "cache repository has no name" ) ?; 305 fs:: create_dir_all ( parent) ?; 306let staging = parent. join ( format! ( 307".{}.new-{}" , 308 name. to_string_lossy (), 309 std:: process:: id () 310)); 311if staging. exists () { 312 fs:: remove_dir_all ( & staging) ?; 313} 314let mut staging_config = config. clone (); 315 staging_config. out = staging. clone (); 316if let Err ( error) = generate:: full_cancellable ( 317& staging_config, 318Some ( & config. out ), 319 highlights, 320 cancel. clone (), 321) { 322// A human-triggered build wanted the machine; the scanner will 323// re-queue this repository for a later background attempt. 324if cancel 325. as_ref () 326. is_some_and ( |flag| flag. load ( Ordering :: Relaxed )) 327{ 328return Ok ( false ); 329} 330return Err ( error); 331} 332// keep info/refs + objects/info/packs current for static git-dir access 333let status = std:: process:: Command :: new ( "git" ) 334. arg ( "-C" ) 335. arg ( & config. repo ) 336. arg ( "update-server-info" ) 337. status () ?; 338if !status. success () { 339eprintln! ( 340"sorceryd: update-server-info failed for {}" , 341 config. repo . display () 342); 343} 344 generate:: swap_dir ( & staging, & config. out ) ?; 345Ok ( true ) 346} 347 348# [ cfg ( test )] 349mod tests{ 350use std:: collections:: HashSet ; 351use std:: fs; 352use std:: sync:: Arc ; 353use std:: sync:: atomic:: Ordering ; 354 355use anyhow:: Result ; 356 357use crate :: generate:: STATE_FILE ; 358use crate :: testutil:: TempDir ; 359 360use super ::{ BuildQueue , garbage_collect_cache}; 361 362# [ tokio :: test ] 363async fn priority_builds_claim_the_slot_ahead_of_background_builds () { 364let queue =Arc :: new ( BuildQueue :: new ()); 365let first = queue. acquire ( false ). await ; 366let ( tx, mut rx) = tokio:: sync:: mpsc:: unbounded_channel (); 367for ( priority, label) in [( false , "background" ), ( true , "priority" )] { 368let queue = queue. clone (); 369let tx = tx. clone (); 370 tokio:: spawn ( async move { 371let _permit = queue. acquire ( priority). await ; 372 tx. send ( label). unwrap (); 373}); 374 tokio:: time:: sleep ( std:: time:: Duration :: from_millis ( 20 )). await ; 375} 376drop ( first); 377assert_eq! ( rx. recv (). await , Some ( "priority" )); 378assert_eq! ( rx. recv (). await , Some ( "background" )); 379} 380 381# [ tokio :: test ] 382async fn priority_builds_cancel_the_active_background_build () { 383let queue =BuildQueue :: new (); 384let flag = queue. register_background (); 385let _permit = queue. acquire ( true ). await ; 386assert! ( flag. load ( Ordering :: Relaxed )); 387 queue. clear_background ( & flag); 388} 389 390# [ test ] 391fn garbage_collects_only_inactive_site_caches () ->Result <()> { 392let root =TempDir :: new ( "cache-gc" ); 393let live = root. join ( "alice/live" ); 394let stale = root. join ( "alice/stale" ); 395let staging = root. join ( "alice/.live.new-1" ); 396let asset = root. join ( "fonts/inter" ); 397for pathin [ & live, & stale, & staging] { 398 fs:: create_dir_all ( path) ?; 399 fs:: write ( path. join ( STATE_FILE ), "state" ) ?; 400} 401 fs:: create_dir_all ( & asset) ?; 402 403assert_eq! ( 404garbage_collect_cache ( & root, & HashSet :: from ([ live. clone ()])) ?, 4052 406); 407assert! ( live. is_dir ()); 408assert! ( !stale. exists ()); 409assert! ( !staging. exists ()); 410assert! ( asset. is_dir ()); 411Ok (()) 412} 413}