Skip to content

Commit c4cb7d3

Browse files
authored
Support checkpoint GC in leader for mode stateful operators (#1112)
1 parent fab1668 commit c4cb7d3

4 files changed

Lines changed: 423 additions & 72 deletions

File tree

crates/arroyo-state-protocol/src/gc.rs

Lines changed: 106 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,31 @@ use crate::ProtocolPaths;
22
use crate::store::{ProtocolStore, StoreError, read_protobuf};
33
use crate::types::{CheckpointRef, Epoch, Generation, ProtocolError};
44
use 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};
89
use prost::Message;
910
use std::collections::HashSet;
1011
use 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)]
1417
pub(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+
1930
pub async fn cleanup_leader_checkpoints<S>(
2031
store: &S,
2132
paths: &ProtocolPaths,
@@ -25,10 +36,42 @@ pub async fn cleanup_leader_checkpoints<S>(
2536
where
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>
62113
where
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

174210
fn 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

Comments
 (0)