@@ -2,20 +2,31 @@ use crate::ProtocolPaths;
22use crate :: store:: { ProtocolStore , StoreError , read_protobuf} ;
33use crate :: types:: { CheckpointRef , Epoch , Generation , ProtocolError } ;
44use arroyo_rpc:: grpc:: rpc:: {
5- CheckpointManifest , GlobalKeyedTableTaskCheckpointMetadata , TableCheckpointMetadata , TableEnum ,
5+ CheckpointManifest , ExpiringKeyedTimeTableCheckpointMetadata ,
6+ GlobalKeyedTableTaskCheckpointMetadata , TableCheckpointMetadata , TableEnum ,
67} ;
7- use futures:: future :: join_all ;
8+ use futures:: { TryStreamExt , stream } ;
89use prost:: Message ;
910use std:: collections:: HashSet ;
1011use std:: path:: Path ;
11- use tracing:: { debug, warn} ;
12+ use tracing:: debug;
13+
14+ const MAX_CONCURRENT_DELETES : usize = 32 ;
1215
1316#[ derive( Debug , Clone , Copy , PartialEq , Eq , Hash , PartialOrd , Ord ) ]
1417pub ( crate ) struct CheckpointOwner {
1518 pub generation : Generation ,
1619 pub epoch : Epoch ,
1720}
1821
22+ /// A fully classified GC operation. Classification must complete before any deletion begins.
23+ struct CleanupPlan {
24+ /// Checkpoints below the retention boundary, ordered newest to oldest by traversal order.
25+ old_checkpoints : Vec < CheckpointOwner > ,
26+ /// Deduplicated files referenced only by old checkpoints.
27+ data_files : Vec < CheckpointRef > ,
28+ }
29+
1930pub async fn cleanup_leader_checkpoints < S > (
2031 store : & S ,
2132 paths : & ProtocolPaths ,
@@ -25,10 +36,42 @@ pub async fn cleanup_leader_checkpoints<S>(
2536where
2637 S : ProtocolStore + ?Sized ,
2738{
28- let history =
29- collect_history_and_clean_checkpoint_files ( store, paths, head, new_min_epoch) . await ?;
39+ let cleanup = classify_checkpoint_history ( store, head, new_min_epoch) . await ?;
40+
41+ let data_directories: HashSet < _ > = cleanup
42+ . data_files
43+ . iter ( )
44+ . filter_map ( |f| Path :: new ( f. as_str ( ) ) . parent ( ) . and_then ( |p| p. to_str ( ) ) )
45+ . map ( |f| f. to_string ( ) )
46+ . collect ( ) ;
47+ let checkpoint_directories: HashSet < _ > = data_directories
48+ . iter ( )
49+ . filter_map ( |directory| Path :: new ( directory) . parent ( ) . and_then ( |p| p. to_str ( ) ) )
50+ . map ( |directory| directory. to_string ( ) )
51+ . collect ( ) ;
52+
53+ let mut objects = cleanup. data_files ;
54+ for checkpoint in & cleanup. old_checkpoints {
55+ objects. push ( paths. committed_marker ( checkpoint. generation , checkpoint. epoch ) ) ;
56+ objects. push ( paths. epoch_record ( checkpoint. epoch ) ) ;
57+ }
3058
31- for c in history. iter ( ) . rev ( ) {
59+ delete_objects ( store, objects) . await ?;
60+
61+ for directory in data_directories {
62+ store. delete_directory ( & directory) . await ;
63+ }
64+
65+ // A carried-forward file may live under a checkpoint whose manifest was deleted by an earlier
66+ // GC pass. Retry its checkpoint directory now that the file and its operator directory are
67+ // gone. Local storage removes only empty directories; object stores treat this as a no-op.
68+ for directory in checkpoint_directories {
69+ store. delete_directory ( & directory) . await ;
70+ }
71+
72+ // Traversal records checkpoints newest-to-oldest. Delete manifests in reverse so a failed
73+ // cleanup cannot create a gap that makes still-reachable older checkpoints undiscoverable.
74+ for c in cleanup. old_checkpoints . iter ( ) . rev ( ) {
3275 debug ! (
3376 generation = c. generation. 0 ,
3477 epoch = c. epoch. 0 ,
@@ -45,25 +88,35 @@ where
4588 Ok ( ( ) )
4689}
4790
48- /// Traverses backwards through the checkpoint history, doing two things:
49- /// 1. Finding and cleaning data files for GC'able checkpoints
50- /// 2. Collecting a record of ownership for each checkpoint
91+ async fn delete_objects < S > ( store : & S , objects : Vec < CheckpointRef > ) -> Result < ( ) , StoreError >
92+ where
93+ S : ProtocolStore + ?Sized ,
94+ {
95+ stream:: iter ( objects. into_iter ( ) . map ( Ok :: < _ , StoreError > ) )
96+ . try_for_each_concurrent ( MAX_CONCURRENT_DELETES , |object| async move {
97+ store. delete_object ( & object) . await
98+ } )
99+ . await
100+ }
101+
102+ /// Traverses the reachable checkpoint history and classifies all files before deleting anything.
51103///
52- /// We do it in this order because we must delete metadata files by going forward, to prevent
53- /// gaps from forming in case of failure which would orphan older files. However, we do not
54- /// want to re-read or retain the metadata in order to do file deletion as that could exhaust
55- /// memory, so we opportunistically delete data files .
56- async fn collect_history_and_clean_checkpoint_files < S > (
104+ /// Data files referenced by retained checkpoints are protected even if an older checkpoint also
105+ /// references them. This intentionally buffers deduplicated candidate and protected file refs for
106+ /// the reachable chain, but not full manifests. That memory cost is required to safely handle
107+ /// cumulative table metadata such as expiring keyed-time tables .
108+ async fn classify_checkpoint_history < S > (
57109 store : & S ,
58- paths : & ProtocolPaths ,
59110 current : CheckpointRef ,
60111 new_min_epoch : Epoch ,
61- ) -> Result < Vec < CheckpointOwner > , StoreError >
112+ ) -> Result < CleanupPlan , StoreError >
62113where
63114 S : ProtocolStore + ?Sized ,
64115{
65116 let mut head = true ;
66- let mut history = vec ! [ ] ;
117+ let mut old_checkpoints = vec ! [ ] ;
118+ let mut candidate_files = HashSet :: new ( ) ;
119+ let mut protected_files = HashSet :: new ( ) ;
67120 let mut seen = HashSet :: new ( ) ;
68121 let mut next = Some ( current) ;
69122
@@ -80,13 +133,21 @@ where
80133 break ;
81134 } ;
82135
83- head = false ;
84-
85136 let owner = CheckpointOwner {
86137 generation : Generation ( manifest. generation ) ,
87138 epoch : Epoch ( manifest. epoch ) ,
88139 } ;
89140
141+ if head && owner. epoch < new_min_epoch {
142+ return Err ( ProtocolError :: CheckpointGcMinEpochBeyondHead {
143+ head_epoch : owner. epoch ,
144+ new_min_epoch,
145+ }
146+ . into ( ) ) ;
147+ }
148+
149+ head = false ;
150+
90151 if !seen. insert ( owner) {
91152 return Err ( ProtocolError :: CheckpointCycle {
92153 generation : owner. generation ,
@@ -95,15 +156,12 @@ where
95156 . into ( ) ) ;
96157 }
97158
159+ let files = checkpoint_data_files ( & checkpoint_ref, & manifest) ?;
98160 if manifest. epoch < * new_min_epoch {
99- history. push ( owner) ;
100- if let Err ( e) = clean_checkpoint ( store, paths, & checkpoint_ref, & manifest) . await {
101- warn ! (
102- checkpoint = checkpoint_ref. as_str( ) ,
103- error =? e,
104- "failed to clean checkpoint"
105- ) ;
106- }
161+ old_checkpoints. push ( owner) ;
162+ candidate_files. extend ( files) ;
163+ } else {
164+ protected_files. extend ( files) ;
107165 }
108166
109167 next = manifest
@@ -112,20 +170,19 @@ where
112170 . transpose ( ) ?;
113171 }
114172
115- Ok ( history)
173+ candidate_files. retain ( |file| !protected_files. contains ( file) ) ;
174+
175+ Ok ( CleanupPlan {
176+ old_checkpoints,
177+ data_files : candidate_files. into_iter ( ) . collect ( ) ,
178+ } )
116179}
117180
118- /// cleans a checkpoint but leaves the metadata file in place
119- async fn clean_checkpoint < S > (
120- store : & S ,
121- paths : & ProtocolPaths ,
181+ fn checkpoint_data_files (
122182 manifest_path : & CheckpointRef ,
123183 checkpoint : & CheckpointManifest ,
124- ) -> Result < ( ) , StoreError >
125- where
126- S : ProtocolStore + ?Sized ,
127- {
128- let mut to_delete = vec ! [ ] ;
184+ ) -> Result < Vec < CheckpointRef > , StoreError > {
185+ let mut files = vec ! [ ] ;
129186 for operator in & checkpoint. operators {
130187 for ( table_name, metadata) in & operator. table_checkpoint_metadata {
131188 let op_metadata =
@@ -142,33 +199,12 @@ where
142199 table_name,
143200 manifest_path,
144201 metadata,
145- & mut to_delete ,
202+ & mut files ,
146203 ) ?;
147204 }
148205 }
149206
150- let directories: HashSet < _ > = to_delete
151- . iter ( )
152- . filter_map ( |f| Path :: new ( f. as_str ( ) ) . parent ( ) . and_then ( |p| p. to_str ( ) ) )
153- . map ( |f| f. to_string ( ) )
154- . collect ( ) ;
155-
156- to_delete
157- . push ( paths. committed_marker ( Generation ( checkpoint. generation ) , Epoch ( checkpoint. epoch ) ) ) ;
158-
159- to_delete. push ( paths. epoch_record ( Epoch ( checkpoint. epoch ) ) ) ;
160-
161- for r in join_all ( to_delete. iter ( ) . map ( |f| store. delete_object ( f) ) ) . await {
162- r?;
163- }
164-
165- for d in directories {
166- // this will loop over duplicate directory when there are multiples tables per operator,
167- // but it's a no-op if the directory is already deleted
168- store. delete_directory ( & d) . await ;
169- }
170-
171- Ok ( ( ) )
207+ Ok ( files)
172208}
173209
174210fn table_checkpoint_data_files (
@@ -200,14 +236,16 @@ fn table_checkpoint_data_files(
200236 }
201237 }
202238 TableEnum :: ExpiringKeyedTimeTable => {
203- return Err ( StoreError :: InvalidProtobuf {
204- path : metadata_path. clone ( ) ,
205- msg : format ! (
206- "table metadata for operator '{}' table '{}' has table type \
207- ExpiringKeyedTimeTable, which is not yet supported in leader mode",
208- operator_id, table_name
209- ) ,
210- } ) ;
239+ let metadata =
240+ ExpiringKeyedTimeTableCheckpointMetadata :: decode ( metadata. data . as_slice ( ) )
241+ . map_err ( |e| StoreError :: DecodeProtobuf {
242+ path : metadata_path. clone ( ) ,
243+ source : e,
244+ } ) ?;
245+
246+ for file in metadata. files {
247+ files. push ( CheckpointRef :: new ( file. file ) ?) ;
248+ }
211249 }
212250 }
213251
0 commit comments