mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-28 16:07:05 +00:00
feat(table-catalog): harden maintenance scheduler control (#4164)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
This commit is contained in:
+193
-25
@@ -944,6 +944,11 @@ struct TableMaintenanceWorkerControlReport<'a> {
|
|||||||
now: OffsetDateTime,
|
now: OffsetDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TableMaintenanceWorkerPreflight {
|
||||||
|
Ready(TableMaintenanceEffectiveConfig),
|
||||||
|
Complete(Box<TableMetadataMaintenanceReport>),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
pub(crate) struct TableCatalogExport {
|
pub(crate) struct TableCatalogExport {
|
||||||
pub table_bucket: TableBucketEntry,
|
pub table_bucket: TableBucketEntry,
|
||||||
@@ -4040,36 +4045,129 @@ where
|
|||||||
worker_id: String,
|
worker_id: String,
|
||||||
now: OffsetDateTime,
|
now: OffsetDateTime,
|
||||||
) -> TableCatalogStoreResult<TableMetadataMaintenanceReport> {
|
) -> TableCatalogStoreResult<TableMetadataMaintenanceReport> {
|
||||||
|
let namespace = parse_namespace_for_store(namespace)?;
|
||||||
|
let table = parse_table_for_store(table)?;
|
||||||
|
let table_path = self.paths.table_entry_path(table_bucket, &namespace, &table);
|
||||||
|
let namespace_name = namespace.public_name();
|
||||||
|
let table_name = table.as_str().to_string();
|
||||||
|
|
||||||
|
let effective = {
|
||||||
|
let _guard = self.backend.acquire_write_lock(self.catalog_bucket(), &table_path).await?;
|
||||||
|
match self
|
||||||
|
.table_metadata_maintenance_worker_preflight(table_bucket, &namespace_name, &table_name, &worker_id, now)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
TableMaintenanceWorkerPreflight::Ready(effective) => effective,
|
||||||
|
TableMaintenanceWorkerPreflight::Complete(report) => return Ok(*report),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut report = self
|
||||||
|
.plan_table_metadata_maintenance(
|
||||||
|
table_bucket,
|
||||||
|
&namespace_name,
|
||||||
|
&table_name,
|
||||||
|
effective.config.retain_recent_metadata_files,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (report, effective, delete) = {
|
||||||
|
let _guard = self.backend.acquire_write_lock(self.catalog_bucket(), &table_path).await?;
|
||||||
|
let effective = match self
|
||||||
|
.table_metadata_maintenance_worker_preflight(table_bucket, &namespace_name, &table_name, &worker_id, now)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
TableMaintenanceWorkerPreflight::Ready(effective) => effective,
|
||||||
|
TableMaintenanceWorkerPreflight::Complete(report) => return Ok(*report),
|
||||||
|
};
|
||||||
|
|
||||||
|
if report.job.retain_recent_metadata_files != effective.config.retain_recent_metadata_files {
|
||||||
|
return Err(TableCatalogStoreError::Conflict(
|
||||||
|
"maintenance config changed before worker claim".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Some((entry, _)) = self.read_table_with_etag_unlocked(table_bucket, &namespace, &table).await? else {
|
||||||
|
return Err(TableCatalogStoreError::NotFound(format!(
|
||||||
|
"table {}/{}/{}",
|
||||||
|
table_bucket, namespace_name, table_name
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
if entry.metadata_location != report.current_metadata_location {
|
||||||
|
return Err(TableCatalogStoreError::Conflict(
|
||||||
|
"current metadata location changed before maintenance worker claim".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let started_at = maintenance_timestamp(now);
|
||||||
|
report.job.operation = if effective.config.delete_enabled {
|
||||||
|
TableMetadataMaintenanceOperation::Delete
|
||||||
|
} else {
|
||||||
|
TableMetadataMaintenanceOperation::DryRun
|
||||||
|
};
|
||||||
|
report.job.status = TableMetadataMaintenanceJobStatus::Running;
|
||||||
|
report.job.failure_reason = None;
|
||||||
|
report.job.config_source = effective.source;
|
||||||
|
report.job.worker_id = Some(worker_id);
|
||||||
|
report.job.lease_id = Uuid::new_v4().to_string();
|
||||||
|
report.job.attempt = 1;
|
||||||
|
report.job.max_retry_attempts = effective.config.max_retry_attempts;
|
||||||
|
report.job.next_retry_after = None;
|
||||||
|
report.job.quarantine_enabled = effective.config.quarantine_enabled;
|
||||||
|
report.job.quarantine_retention_seconds = effective.config.quarantine_retention_seconds;
|
||||||
|
report.job.heartbeat_at = Some(started_at.clone());
|
||||||
|
report.job.started_at = Some(started_at);
|
||||||
|
report.job.finished_at = None;
|
||||||
|
refresh_table_maintenance_report_recommended_actions(&mut report);
|
||||||
|
self.put_table_metadata_maintenance_report(&report).await?;
|
||||||
|
|
||||||
|
let delete = effective.config.delete_enabled;
|
||||||
|
(report, effective, delete)
|
||||||
|
};
|
||||||
|
|
||||||
|
self.finish_table_metadata_maintenance_run(table_bucket, &namespace_name, &table_name, delete, &effective, report)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn table_metadata_maintenance_worker_preflight(
|
||||||
|
&self,
|
||||||
|
table_bucket: &str,
|
||||||
|
namespace: &str,
|
||||||
|
table: &str,
|
||||||
|
worker_id: &str,
|
||||||
|
now: OffsetDateTime,
|
||||||
|
) -> TableCatalogStoreResult<TableMaintenanceWorkerPreflight> {
|
||||||
let effective = self
|
let effective = self
|
||||||
.get_effective_table_maintenance_config(table_bucket, namespace, table)
|
.get_effective_table_maintenance_config(table_bucket, namespace, table)
|
||||||
.await?;
|
.await?;
|
||||||
if !effective.config.background_enabled {
|
if !effective.config.background_enabled {
|
||||||
return self
|
let report = self
|
||||||
.put_table_metadata_maintenance_worker_control_report(TableMaintenanceWorkerControlReport {
|
.put_table_metadata_maintenance_worker_control_report(TableMaintenanceWorkerControlReport {
|
||||||
table_bucket,
|
table_bucket,
|
||||||
namespace,
|
namespace,
|
||||||
table,
|
table,
|
||||||
worker_id,
|
worker_id: worker_id.to_string(),
|
||||||
effective: &effective,
|
effective: &effective,
|
||||||
status: TableMetadataMaintenanceJobStatus::Disabled,
|
status: TableMetadataMaintenanceJobStatus::Disabled,
|
||||||
reason: "background maintenance is disabled",
|
reason: "background maintenance is disabled",
|
||||||
now,
|
now,
|
||||||
})
|
})
|
||||||
.await;
|
.await?;
|
||||||
|
return Ok(TableMaintenanceWorkerPreflight::Complete(Box::new(report)));
|
||||||
}
|
}
|
||||||
if effective.config.worker_paused {
|
if effective.config.worker_paused {
|
||||||
return self
|
let report = self
|
||||||
.put_table_metadata_maintenance_worker_control_report(TableMaintenanceWorkerControlReport {
|
.put_table_metadata_maintenance_worker_control_report(TableMaintenanceWorkerControlReport {
|
||||||
table_bucket,
|
table_bucket,
|
||||||
namespace,
|
namespace,
|
||||||
table,
|
table,
|
||||||
worker_id,
|
worker_id: worker_id.to_string(),
|
||||||
effective: &effective,
|
effective: &effective,
|
||||||
status: TableMetadataMaintenanceJobStatus::Paused,
|
status: TableMetadataMaintenanceJobStatus::Paused,
|
||||||
reason: "background maintenance worker is paused",
|
reason: "background maintenance worker is paused",
|
||||||
now,
|
now,
|
||||||
})
|
})
|
||||||
.await;
|
.await?;
|
||||||
|
return Ok(TableMaintenanceWorkerPreflight::Complete(Box::new(report)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(current) = self
|
if let Some(current) = self
|
||||||
@@ -4078,27 +4176,20 @@ where
|
|||||||
{
|
{
|
||||||
if matches!(current.job.status, TableMetadataMaintenanceJobStatus::Running) {
|
if matches!(current.job.status, TableMetadataMaintenanceJobStatus::Running) {
|
||||||
if table_maintenance_job_lease_is_active(¤t.job, effective.config.worker_lease_timeout_seconds, now) {
|
if table_maintenance_job_lease_is_active(¤t.job, effective.config.worker_lease_timeout_seconds, now) {
|
||||||
return Ok(current);
|
return Ok(TableMaintenanceWorkerPreflight::Complete(Box::new(current)));
|
||||||
}
|
}
|
||||||
let mut expired = current;
|
let mut expired = current;
|
||||||
expired.job.status = TableMetadataMaintenanceJobStatus::Failed;
|
expired.job.status = TableMetadataMaintenanceJobStatus::Failed;
|
||||||
expired.job.failure_reason = Some("maintenance worker lease expired".to_string());
|
expired.job.failure_reason = Some("maintenance worker lease expired".to_string());
|
||||||
expired.job.finished_at = Some(maintenance_timestamp(now));
|
expired.job.finished_at = Some(maintenance_timestamp(now));
|
||||||
|
refresh_table_maintenance_report_recommended_actions(&mut expired);
|
||||||
self.put_table_metadata_maintenance_report(&expired).await?;
|
self.put_table_metadata_maintenance_report(&expired).await?;
|
||||||
} else if table_maintenance_job_retry_is_pending(¤t.job, now) {
|
} else if table_maintenance_job_retry_is_pending(¤t.job, now) {
|
||||||
return Ok(current);
|
return Ok(TableMaintenanceWorkerPreflight::Complete(Box::new(current)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.run_table_metadata_maintenance_with_config(
|
Ok(TableMaintenanceWorkerPreflight::Ready(effective))
|
||||||
table_bucket,
|
|
||||||
namespace,
|
|
||||||
table,
|
|
||||||
effective.config.delete_enabled,
|
|
||||||
Some(worker_id),
|
|
||||||
effective,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn heartbeat_table_metadata_maintenance_job(
|
pub(crate) async fn heartbeat_table_metadata_maintenance_job(
|
||||||
@@ -5066,15 +5157,27 @@ where
|
|||||||
refresh_table_maintenance_report_recommended_actions(&mut report);
|
refresh_table_maintenance_report_recommended_actions(&mut report);
|
||||||
self.put_table_metadata_maintenance_report(&report).await?;
|
self.put_table_metadata_maintenance_report(&report).await?;
|
||||||
|
|
||||||
|
self.finish_table_metadata_maintenance_run(table_bucket, namespace, table, delete, &effective, report)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finish_table_metadata_maintenance_run(
|
||||||
|
&self,
|
||||||
|
table_bucket: &str,
|
||||||
|
namespace: &str,
|
||||||
|
table: &str,
|
||||||
|
delete: bool,
|
||||||
|
effective: &TableMaintenanceEffectiveConfig,
|
||||||
|
mut report: TableMetadataMaintenanceReport,
|
||||||
|
) -> TableCatalogStoreResult<TableMetadataMaintenanceReport> {
|
||||||
if delete && !effective.config.delete_enabled {
|
if delete && !effective.config.delete_enabled {
|
||||||
let mut failed = report;
|
report.job.status = TableMetadataMaintenanceJobStatus::Failed;
|
||||||
failed.job.status = TableMetadataMaintenanceJobStatus::Failed;
|
report.job.failure_reason = Some(TABLE_MAINTENANCE_DELETE_DISABLED_REASON.to_string());
|
||||||
failed.job.failure_reason = Some(TABLE_MAINTENANCE_DELETE_DISABLED_REASON.to_string());
|
apply_maintenance_retry_after(&mut report.job, &effective.config, OffsetDateTime::now_utc());
|
||||||
apply_maintenance_retry_after(&mut failed.job, &effective.config, OffsetDateTime::now_utc());
|
report.job.finished_at = Some(maintenance_timestamp(OffsetDateTime::now_utc()));
|
||||||
failed.job.finished_at = Some(maintenance_timestamp(OffsetDateTime::now_utc()));
|
refresh_table_maintenance_report_recommended_actions(&mut report);
|
||||||
refresh_table_maintenance_report_recommended_actions(&mut failed);
|
self.put_table_metadata_maintenance_report(&report).await?;
|
||||||
self.put_table_metadata_maintenance_report(&failed).await?;
|
return Ok(report);
|
||||||
return Ok(failed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if delete {
|
if delete {
|
||||||
@@ -10089,6 +10192,7 @@ mod tests {
|
|||||||
fail_delete_attempts: BTreeMap<(String, String), BTreeSet<usize>>,
|
fail_delete_attempts: BTreeMap<(String, String), BTreeSet<usize>>,
|
||||||
put_attempts: BTreeMap<(String, String), usize>,
|
put_attempts: BTreeMap<(String, String), usize>,
|
||||||
delete_attempts: BTreeMap<(String, String), usize>,
|
delete_attempts: BTreeMap<(String, String), usize>,
|
||||||
|
write_lock_acquisitions: BTreeMap<(String, String), usize>,
|
||||||
read_calls: usize,
|
read_calls: usize,
|
||||||
list_calls: usize,
|
list_calls: usize,
|
||||||
next_etag: u64,
|
next_etag: u64,
|
||||||
@@ -10147,6 +10251,16 @@ mod tests {
|
|||||||
state.list_calls = 0;
|
state.list_calls = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn write_lock_acquisition_count(&self, bucket: &str, object: &str) -> usize {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.write_lock_acquisitions
|
||||||
|
.get(&(bucket.to_string(), object.to_string()))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
async fn fail_next_read(&self, bucket: &str, object: &str) {
|
async fn fail_next_read(&self, bucket: &str, object: &str) {
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock().await;
|
||||||
let key = (bucket.to_string(), object.to_string());
|
let key = (bucket.to_string(), object.to_string());
|
||||||
@@ -10525,6 +10639,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn acquire_write_lock(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<Box<dyn Send>> {
|
async fn acquire_write_lock(&self, bucket: &str, object: &str) -> TableCatalogStoreResult<Box<dyn Send>> {
|
||||||
|
{
|
||||||
|
let mut state = self.state.lock().await;
|
||||||
|
*state
|
||||||
|
.write_lock_acquisitions
|
||||||
|
.entry((bucket.to_string(), object.to_string()))
|
||||||
|
.or_default() += 1;
|
||||||
|
}
|
||||||
let lock = {
|
let lock = {
|
||||||
let mut locks = self.locks.lock().await;
|
let mut locks = self.locks.lock().await;
|
||||||
locks
|
locks
|
||||||
@@ -12029,12 +12150,14 @@ mod tests {
|
|||||||
let table = IdentifierSegment::parse("orders").unwrap();
|
let table = IdentifierSegment::parse("orders").unwrap();
|
||||||
let old = default_table_metadata_file_path(&namespace, &table, "00001.metadata.json");
|
let old = default_table_metadata_file_path(&namespace, &table, "00001.metadata.json");
|
||||||
let current = default_table_metadata_file_path(&namespace, &table, "00002.metadata.json");
|
let current = default_table_metadata_file_path(&namespace, &table, "00002.metadata.json");
|
||||||
|
let table_path = store.paths.table_entry_path(bucket, &namespace, &table);
|
||||||
|
|
||||||
seed_table_for_metadata_maintenance(&store, bucket, &namespace, &table, current.clone()).await;
|
seed_table_for_metadata_maintenance(&store, bucket, &namespace, &table, current.clone()).await;
|
||||||
backend.seed_object(bucket, &old, b"{}".to_vec()).await;
|
backend.seed_object(bucket, &old, b"{}".to_vec()).await;
|
||||||
backend
|
backend
|
||||||
.seed_object(bucket, ¤t, br#"{"metadata-log":[]}"#.to_vec())
|
.seed_object(bucket, ¤t, br#"{"metadata-log":[]}"#.to_vec())
|
||||||
.await;
|
.await;
|
||||||
|
let before_worker_run = backend.write_lock_acquisition_count(RUSTFS_META_BUCKET, &table_path).await;
|
||||||
|
|
||||||
let report = store
|
let report = store
|
||||||
.run_table_metadata_maintenance_worker_once(bucket, "sales", "orders", "worker-a".to_string())
|
.run_table_metadata_maintenance_worker_once(bucket, "sales", "orders", "worker-a".to_string())
|
||||||
@@ -12048,6 +12171,10 @@ mod tests {
|
|||||||
vec![TableMaintenanceRecommendedAction::EnableBackgroundMaintenance]
|
vec![TableMaintenanceRecommendedAction::EnableBackgroundMaintenance]
|
||||||
);
|
);
|
||||||
assert_eq!(report.job.deleted_metadata_file_count, 0);
|
assert_eq!(report.job.deleted_metadata_file_count, 0);
|
||||||
|
assert_eq!(
|
||||||
|
backend.write_lock_acquisition_count(RUSTFS_META_BUCKET, &table_path).await,
|
||||||
|
before_worker_run + 1
|
||||||
|
);
|
||||||
assert!(backend.object_exists(bucket, &old).await.unwrap());
|
assert!(backend.object_exists(bucket, &old).await.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12098,6 +12225,47 @@ mod tests {
|
|||||||
assert!(backend.object_exists(bucket, &old).await.unwrap());
|
assert!(backend.object_exists(bucket, &old).await.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn maintenance_worker_run_claims_table_entry_write_lock() {
|
||||||
|
let backend = TestCatalogObjectBackend::default();
|
||||||
|
let store = ObjectTableCatalogStore::new(backend.clone());
|
||||||
|
let bucket = "analytics";
|
||||||
|
let namespace = Namespace::parse("sales").expect("namespace should parse");
|
||||||
|
let table = IdentifierSegment::parse("orders").expect("table should parse");
|
||||||
|
let current = default_table_metadata_file_path(&namespace, &table, "00002.metadata.json");
|
||||||
|
let table_path = store.paths.table_entry_path(bucket, &namespace, &table);
|
||||||
|
|
||||||
|
seed_table_for_metadata_maintenance(&store, bucket, &namespace, &table, current.clone()).await;
|
||||||
|
backend
|
||||||
|
.seed_object(bucket, ¤t, br#"{"metadata-log":[]}"#.to_vec())
|
||||||
|
.await;
|
||||||
|
store
|
||||||
|
.put_table_maintenance_config(
|
||||||
|
bucket,
|
||||||
|
"sales",
|
||||||
|
"orders",
|
||||||
|
TableMaintenanceConfig {
|
||||||
|
version: TABLE_MAINTENANCE_CONFIG_VERSION,
|
||||||
|
background_enabled: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("background maintenance config should persist");
|
||||||
|
let before_worker_run = backend.write_lock_acquisition_count(RUSTFS_META_BUCKET, &table_path).await;
|
||||||
|
|
||||||
|
let report = store
|
||||||
|
.run_table_metadata_maintenance_worker_once("analytics", "sales", "orders", "worker-a".to_string())
|
||||||
|
.await
|
||||||
|
.expect("worker run should complete");
|
||||||
|
|
||||||
|
assert_eq!(report.job.worker_id.as_deref(), Some("worker-a"));
|
||||||
|
assert_eq!(
|
||||||
|
backend.write_lock_acquisition_count(RUSTFS_META_BUCKET, &table_path).await,
|
||||||
|
before_worker_run + 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn maintenance_scheduler_report_marks_disabled_default() {
|
async fn maintenance_scheduler_report_marks_disabled_default() {
|
||||||
let backend = TestCatalogObjectBackend::default();
|
let backend = TestCatalogObjectBackend::default();
|
||||||
|
|||||||
Reference in New Issue
Block a user