mirror of
https://github.com/neondatabase/neon.git
synced 2026-05-23 08:00:37 +00:00
storage scrubber: GC ancestor shard layers (#8196)
## Problem After a shard split, the pageserver leaves the ancestor shard's content in place. It may be referenced by child shards, but eventually child shards will de-reference most ancestor layers as they write their own data and do GC. We would like to eventually clean up those ancestor layers to reclaim space. ## Summary of changes - Extend the physical GC command with `--mode=full`, which includes cleaning up unreferenced ancestor shard layers - Add test `test_scrubber_physical_gc_ancestors` - Remove colored log output: in testing this is irritating ANSI code spam in logs, and in interactive use doesn't add much. - Refactor storage controller API client code out of storcon_client into a `storage_controller/client` crate - During physical GC of ancestors, call into the storage controller to check that the latest shards seen in S3 reflect the latest state of the tenant, and there is no shard split in progress.
This commit is contained in:
committed by
Christian Schwarz
parent
9b883e4651
commit
affe408433
@@ -34,6 +34,7 @@ camino.workspace = true
|
||||
rustls.workspace = true
|
||||
rustls-native-certs.workspace = true
|
||||
once_cell.workspace = true
|
||||
storage_controller_client.workspace = true
|
||||
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
chrono = { workspace = true, default-features = false, features = ["clock", "serde"] }
|
||||
|
||||
@@ -24,6 +24,7 @@ use camino::{Utf8Path, Utf8PathBuf};
|
||||
use clap::ValueEnum;
|
||||
use pageserver::tenant::TENANTS_SEGMENT_NAME;
|
||||
use pageserver_api::shard::TenantShardId;
|
||||
use remote_storage::RemotePath;
|
||||
use reqwest::Url;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncReadExt;
|
||||
@@ -31,7 +32,7 @@ use tracing::error;
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||
use utils::fs_ext;
|
||||
use utils::id::{TenantId, TimelineId};
|
||||
use utils::id::{TenantId, TenantTimelineId, TimelineId};
|
||||
|
||||
const MAX_RETRIES: usize = 20;
|
||||
const CLOUD_ADMIN_API_TOKEN_ENV_VAR: &str = "CLOUD_ADMIN_API_TOKEN";
|
||||
@@ -54,7 +55,7 @@ pub struct S3Target {
|
||||
/// in the pageserver, as all timeline objects existing in the scope of a particular
|
||||
/// tenant: the scrubber is different in that it handles collections of data referring to many
|
||||
/// TenantShardTimelineIds in on place.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct TenantShardTimelineId {
|
||||
tenant_shard_id: TenantShardId,
|
||||
timeline_id: TimelineId,
|
||||
@@ -67,6 +68,10 @@ impl TenantShardTimelineId {
|
||||
timeline_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_tenant_timeline_id(&self) -> TenantTimelineId {
|
||||
TenantTimelineId::new(self.tenant_shard_id.tenant_id, self.timeline_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for TenantShardTimelineId {
|
||||
@@ -179,6 +184,22 @@ impl RootTarget {
|
||||
.with_sub_segment(&id.timeline_id.to_string())
|
||||
}
|
||||
|
||||
/// Given RemotePath "tenants/foo/timelines/bar/layerxyz", prefix it to a literal
|
||||
/// key in the S3 bucket.
|
||||
pub fn absolute_key(&self, key: &RemotePath) -> String {
|
||||
let root = match self {
|
||||
Self::Pageserver(root) => root,
|
||||
Self::Safekeeper(root) => root,
|
||||
};
|
||||
|
||||
let prefix = &root.prefix_in_bucket;
|
||||
if prefix.ends_with('/') {
|
||||
format!("{prefix}{key}")
|
||||
} else {
|
||||
format!("{prefix}/{key}")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bucket_name(&self) -> &str {
|
||||
match self {
|
||||
Self::Pageserver(root) => &root.bucket_name,
|
||||
@@ -216,6 +237,14 @@ impl BucketConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ControllerClientConfig {
|
||||
/// URL to storage controller. e.g. http://127.0.0.1:1234 when using `neon_local`
|
||||
pub controller_api: Url,
|
||||
|
||||
/// JWT token for authenticating with storage controller. Requires scope 'scrubber' or 'admin'.
|
||||
pub controller_jwt: String,
|
||||
}
|
||||
|
||||
pub struct ConsoleConfig {
|
||||
pub token: String,
|
||||
pub base_url: Url,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use anyhow::bail;
|
||||
use anyhow::{anyhow, bail};
|
||||
use camino::Utf8PathBuf;
|
||||
use pageserver_api::shard::TenantShardId;
|
||||
use storage_scrubber::find_large_objects;
|
||||
use reqwest::Url;
|
||||
use storage_scrubber::garbage::{find_garbage, purge_garbage, PurgeMode};
|
||||
use storage_scrubber::pageserver_physical_gc::GcMode;
|
||||
use storage_scrubber::scan_pageserver_metadata::scan_metadata;
|
||||
use storage_scrubber::tenant_snapshot::SnapshotDownloader;
|
||||
use storage_scrubber::{find_large_objects, ControllerClientConfig};
|
||||
use storage_scrubber::{
|
||||
init_logging, pageserver_physical_gc::pageserver_physical_gc,
|
||||
scan_safekeeper_metadata::scan_safekeeper_metadata, BucketConfig, ConsoleConfig, NodeKind,
|
||||
@@ -24,6 +25,14 @@ struct Cli {
|
||||
|
||||
#[arg(short, long, default_value_t = false)]
|
||||
delete: bool,
|
||||
|
||||
#[arg(long)]
|
||||
/// URL to storage controller. e.g. http://127.0.0.1:1234 when using `neon_local`
|
||||
controller_api: Option<Url>,
|
||||
|
||||
#[arg(long)]
|
||||
/// JWT token for authenticating with storage controller. Requires scope 'scrubber' or 'admin'.
|
||||
controller_jwt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
@@ -204,8 +213,37 @@ async fn main() -> anyhow::Result<()> {
|
||||
min_age,
|
||||
mode,
|
||||
} => {
|
||||
let summary =
|
||||
pageserver_physical_gc(bucket_config, tenant_ids, min_age.into(), mode).await?;
|
||||
let controller_client_conf = cli.controller_api.map(|controller_api| {
|
||||
ControllerClientConfig {
|
||||
controller_api,
|
||||
// Default to no key: this is a convenience when working in a development environment
|
||||
controller_jwt: cli.controller_jwt.unwrap_or("".to_owned()),
|
||||
}
|
||||
});
|
||||
|
||||
match (&controller_client_conf, mode) {
|
||||
(Some(_), _) => {
|
||||
// Any mode may run when controller API is set
|
||||
}
|
||||
(None, GcMode::Full) => {
|
||||
// The part of physical GC where we erase ancestor layers cannot be done safely without
|
||||
// confirming the most recent complete shard split with the controller. Refuse to run, rather
|
||||
// than doing it unsafely.
|
||||
return Err(anyhow!("Full physical GC requires `--controller-api` and `--controller-jwt` to run"));
|
||||
}
|
||||
(None, GcMode::DryRun | GcMode::IndicesOnly) => {
|
||||
// These GcModes do not require the controller to run.
|
||||
}
|
||||
}
|
||||
|
||||
let summary = pageserver_physical_gc(
|
||||
bucket_config,
|
||||
controller_client_conf,
|
||||
tenant_ids,
|
||||
min_age.into(),
|
||||
mode,
|
||||
)
|
||||
.await?;
|
||||
println!("{}", serde_json::to_string(&summary).unwrap());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,22 +1,50 @@
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use crate::checks::{list_timeline_blobs, BlobDataParseResult};
|
||||
use crate::metadata_stream::{stream_tenant_timelines, stream_tenants};
|
||||
use crate::{init_remote, BucketConfig, NodeKind, RootTarget, TenantShardTimelineId};
|
||||
use crate::{
|
||||
init_remote, BucketConfig, ControllerClientConfig, NodeKind, RootTarget, TenantShardTimelineId,
|
||||
};
|
||||
use aws_sdk_s3::Client;
|
||||
use futures_util::{StreamExt, TryStreamExt};
|
||||
use pageserver::tenant::remote_timeline_client::parse_remote_index_path;
|
||||
use pageserver::tenant::remote_timeline_client::index::LayerFileMetadata;
|
||||
use pageserver::tenant::remote_timeline_client::{parse_remote_index_path, remote_layer_path};
|
||||
use pageserver::tenant::storage_layer::LayerName;
|
||||
use pageserver::tenant::IndexPart;
|
||||
use pageserver_api::shard::TenantShardId;
|
||||
use pageserver_api::controller_api::TenantDescribeResponse;
|
||||
use pageserver_api::shard::{ShardIndex, TenantShardId};
|
||||
use remote_storage::RemotePath;
|
||||
use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
use storage_controller_client::control_api;
|
||||
use tracing::{info_span, Instrument};
|
||||
use utils::generation::Generation;
|
||||
use utils::id::{TenantId, TenantTimelineId};
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
pub struct GcSummary {
|
||||
indices_deleted: usize,
|
||||
remote_storage_errors: usize,
|
||||
controller_api_errors: usize,
|
||||
ancestor_layers_deleted: usize,
|
||||
}
|
||||
|
||||
impl GcSummary {
|
||||
fn merge(&mut self, other: Self) {
|
||||
let Self {
|
||||
indices_deleted,
|
||||
remote_storage_errors,
|
||||
ancestor_layers_deleted,
|
||||
controller_api_errors,
|
||||
} = other;
|
||||
|
||||
self.indices_deleted += indices_deleted;
|
||||
self.remote_storage_errors += remote_storage_errors;
|
||||
self.ancestor_layers_deleted += ancestor_layers_deleted;
|
||||
self.controller_api_errors += controller_api_errors;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(clap::ValueEnum, Debug, Clone, Copy)]
|
||||
@@ -26,9 +54,9 @@ pub enum GcMode {
|
||||
|
||||
// Enable only removing old-generation indices
|
||||
IndicesOnly,
|
||||
|
||||
// Enable all forms of GC
|
||||
// TODO: this will be used when shard split ancestor layer deletion is added
|
||||
// All,
|
||||
Full,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for GcMode {
|
||||
@@ -36,10 +64,232 @@ impl std::fmt::Display for GcMode {
|
||||
match self {
|
||||
GcMode::DryRun => write!(f, "dry-run"),
|
||||
GcMode::IndicesOnly => write!(f, "indices-only"),
|
||||
GcMode::Full => write!(f, "full"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod refs {
|
||||
use super::*;
|
||||
// Map of cross-shard layer references, giving a refcount for each layer in each shard that is referenced by some other
|
||||
// shard in the same tenant. This is sparse! The vast majority of timelines will have no cross-shard refs, and those that
|
||||
// do have cross shard refs should eventually drop most of them via compaction.
|
||||
//
|
||||
// In our inner map type, the TTID in the key is shard-agnostic, and the ShardIndex in the value refers to the _ancestor
|
||||
// which is is referenced_.
|
||||
#[derive(Default)]
|
||||
pub(super) struct AncestorRefs(
|
||||
BTreeMap<TenantTimelineId, HashMap<(ShardIndex, LayerName), usize>>,
|
||||
);
|
||||
|
||||
impl AncestorRefs {
|
||||
/// Insert references for layers discovered in a particular shard-timeline that refer to an ancestral shard-timeline.
|
||||
pub(super) fn update(
|
||||
&mut self,
|
||||
ttid: TenantShardTimelineId,
|
||||
layers: Vec<(LayerName, LayerFileMetadata)>,
|
||||
) {
|
||||
let ttid_refs = self.0.entry(ttid.as_tenant_timeline_id()).or_default();
|
||||
for (layer_name, layer_metadata) in layers {
|
||||
// Increment refcount of this layer in the ancestor shard
|
||||
*(ttid_refs
|
||||
.entry((layer_metadata.shard, layer_name))
|
||||
.or_default()) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// For a particular TTID, return the map of all ancestor layers referenced by a descendent to their refcount
|
||||
///
|
||||
/// The `ShardIndex` in the result's key is the index of the _ancestor_, not the descendent.
|
||||
pub(super) fn get_ttid_refcounts(
|
||||
&self,
|
||||
ttid: &TenantTimelineId,
|
||||
) -> Option<&HashMap<(ShardIndex, LayerName), usize>> {
|
||||
self.0.get(ttid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use refs::AncestorRefs;
|
||||
|
||||
// As we see shards for a tenant, acccumulate knowledge needed for cross-shard GC:
|
||||
// - Are there any ancestor shards?
|
||||
// - Are there any refs to ancestor shards' layers?
|
||||
#[derive(Default)]
|
||||
struct TenantRefAccumulator {
|
||||
shards_seen: HashMap<TenantId, Vec<ShardIndex>>,
|
||||
|
||||
// For each shard that has refs to an ancestor's layers, the set of ancestor layers referred to
|
||||
ancestor_ref_shards: AncestorRefs,
|
||||
}
|
||||
|
||||
impl TenantRefAccumulator {
|
||||
fn update(&mut self, ttid: TenantShardTimelineId, index_part: &IndexPart) {
|
||||
let this_shard_idx = ttid.tenant_shard_id.to_index();
|
||||
(*self
|
||||
.shards_seen
|
||||
.entry(ttid.tenant_shard_id.tenant_id)
|
||||
.or_default())
|
||||
.push(this_shard_idx);
|
||||
|
||||
let mut ancestor_refs = Vec::new();
|
||||
for (layer_name, layer_metadata) in &index_part.layer_metadata {
|
||||
if layer_metadata.shard != this_shard_idx {
|
||||
// This is a reference from this shard to a layer in an ancestor shard: we must track this
|
||||
// as a marker to not GC this layer from the parent.
|
||||
ancestor_refs.push((layer_name.clone(), layer_metadata.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if !ancestor_refs.is_empty() {
|
||||
tracing::info!(%ttid, "Found {} ancestor refs", ancestor_refs.len());
|
||||
self.ancestor_ref_shards.update(ttid, ancestor_refs);
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume Self and return a vector of ancestor tenant shards that should be GC'd, and map of referenced ancestor layers to preserve
|
||||
async fn into_gc_ancestors(
|
||||
self,
|
||||
controller_client: &control_api::Client,
|
||||
summary: &mut GcSummary,
|
||||
) -> (Vec<TenantShardId>, AncestorRefs) {
|
||||
let mut ancestors_to_gc = Vec::new();
|
||||
for (tenant_id, mut shard_indices) in self.shards_seen {
|
||||
// Find the highest shard count
|
||||
let latest_count = shard_indices
|
||||
.iter()
|
||||
.map(|i| i.shard_count)
|
||||
.max()
|
||||
.expect("Always at least one shard");
|
||||
|
||||
let (mut latest_shards, ancestor_shards) = {
|
||||
let at =
|
||||
itertools::partition(&mut shard_indices, |i| i.shard_count == latest_count);
|
||||
(shard_indices[0..at].to_owned(), &shard_indices[at..])
|
||||
};
|
||||
// Sort shards, as we will later compare them with a sorted list from the controller
|
||||
latest_shards.sort();
|
||||
|
||||
// Check that we have a complete view of the latest shard count: this should always be the case unless we happened
|
||||
// to scan the S3 bucket halfway through a shard split.
|
||||
if latest_shards.len() != latest_count.count() as usize {
|
||||
// This should be extremely rare, so we warn on it.
|
||||
tracing::warn!(%tenant_id, "Missed some shards at count {:?}", latest_count);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we have any non-latest-count shards
|
||||
if ancestor_shards.is_empty() {
|
||||
tracing::debug!(%tenant_id, "No ancestor shards to clean up");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Based on S3 view, this tenant looks like it might have some ancestor shard work to do. We
|
||||
// must only do this work if the tenant is not currently being split: otherwise, it is not safe
|
||||
// to GC ancestors, because if the split fails then the controller will try to attach ancestor
|
||||
// shards again.
|
||||
match controller_client
|
||||
.dispatch::<(), TenantDescribeResponse>(
|
||||
Method::GET,
|
||||
format!("control/v1/tenant/{tenant_id}"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Err(e) => {
|
||||
// We were not able to learn the latest shard split state from the controller, so we will not
|
||||
// do ancestor GC on this tenant.
|
||||
tracing::warn!(%tenant_id, "Failed to query storage controller, will not do ancestor GC: {e}");
|
||||
summary.controller_api_errors += 1;
|
||||
continue;
|
||||
}
|
||||
Ok(desc) => {
|
||||
// We expect to see that the latest shard count matches the one we saw in S3, and that none
|
||||
// of the shards indicate splitting in progress.
|
||||
|
||||
let controller_indices: Vec<ShardIndex> = desc
|
||||
.shards
|
||||
.iter()
|
||||
.map(|s| s.tenant_shard_id.to_index())
|
||||
.collect();
|
||||
if controller_indices != latest_shards {
|
||||
tracing::info!(%tenant_id, "Latest shards seen in S3 ({latest_shards:?}) don't match controller state ({controller_indices:?})");
|
||||
continue;
|
||||
}
|
||||
|
||||
if desc.shards.iter().any(|s| s.is_splitting) {
|
||||
tracing::info!(%tenant_id, "One or more shards is currently splitting");
|
||||
continue;
|
||||
}
|
||||
|
||||
// This shouldn't be too noisy, because we only log this for tenants that have some ancestral refs.
|
||||
tracing::info!(%tenant_id, "Validated state with controller: {desc:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// GC ancestor shards
|
||||
for ancestor_shard in ancestor_shards.iter().map(|idx| TenantShardId {
|
||||
tenant_id,
|
||||
shard_count: idx.shard_count,
|
||||
shard_number: idx.shard_number,
|
||||
}) {
|
||||
ancestors_to_gc.push(ancestor_shard);
|
||||
}
|
||||
}
|
||||
|
||||
(ancestors_to_gc, self.ancestor_ref_shards)
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_old_enough(
|
||||
s3_client: &Client,
|
||||
bucket_config: &BucketConfig,
|
||||
min_age: &Duration,
|
||||
key: &str,
|
||||
summary: &mut GcSummary,
|
||||
) -> bool {
|
||||
// Validation: we will only GC indices & layers after a time threshold (e.g. one week) so that during an incident
|
||||
// it is easier to read old data for analysis, and easier to roll back shard splits without having to un-delete any objects.
|
||||
let age: Duration = match s3_client
|
||||
.head_object()
|
||||
.bucket(&bucket_config.bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => match response.last_modified {
|
||||
None => {
|
||||
tracing::warn!("Missing last_modified");
|
||||
summary.remote_storage_errors += 1;
|
||||
return false;
|
||||
}
|
||||
Some(last_modified) => match SystemTime::try_from(last_modified).map(|t| t.elapsed()) {
|
||||
Ok(Ok(e)) => e,
|
||||
Err(_) | Ok(Err(_)) => {
|
||||
tracing::warn!("Bad last_modified time: {last_modified:?}");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to HEAD {key}: {e}");
|
||||
summary.remote_storage_errors += 1;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let old_enough = &age > min_age;
|
||||
|
||||
if !old_enough {
|
||||
tracing::info!(
|
||||
"Skipping young object {} < {}",
|
||||
humantime::format_duration(age),
|
||||
humantime::format_duration(*min_age)
|
||||
);
|
||||
}
|
||||
|
||||
old_enough
|
||||
}
|
||||
|
||||
async fn maybe_delete_index(
|
||||
s3_client: &Client,
|
||||
bucket_config: &BucketConfig,
|
||||
@@ -79,45 +329,7 @@ async fn maybe_delete_index(
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation: we will only delete indices after one week, so that during incidents we will have
|
||||
// easy access to recent indices.
|
||||
let age: Duration = match s3_client
|
||||
.head_object()
|
||||
.bucket(&bucket_config.bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => match response.last_modified {
|
||||
None => {
|
||||
tracing::warn!("Missing last_modified");
|
||||
summary.remote_storage_errors += 1;
|
||||
return;
|
||||
}
|
||||
Some(last_modified) => {
|
||||
let last_modified =
|
||||
UNIX_EPOCH + Duration::from_secs_f64(last_modified.as_secs_f64());
|
||||
match last_modified.elapsed() {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
tracing::warn!("Bad last_modified time: {last_modified:?}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to HEAD {key}: {e}");
|
||||
summary.remote_storage_errors += 1;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if &age < min_age {
|
||||
tracing::info!(
|
||||
"Skipping young object {} < {}",
|
||||
age.as_secs_f64(),
|
||||
min_age.as_secs_f64()
|
||||
);
|
||||
if !is_old_enough(s3_client, bucket_config, min_age, key, summary).await {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -145,6 +357,108 @@ async fn maybe_delete_index(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn gc_ancestor(
|
||||
s3_client: &Client,
|
||||
bucket_config: &BucketConfig,
|
||||
root_target: &RootTarget,
|
||||
min_age: &Duration,
|
||||
ancestor: TenantShardId,
|
||||
refs: &AncestorRefs,
|
||||
mode: GcMode,
|
||||
summary: &mut GcSummary,
|
||||
) -> anyhow::Result<()> {
|
||||
// Scan timelines in the ancestor
|
||||
let timelines = stream_tenant_timelines(s3_client, root_target, ancestor).await?;
|
||||
let mut timelines = std::pin::pin!(timelines);
|
||||
|
||||
// Build a list of keys to retain
|
||||
|
||||
while let Some(ttid) = timelines.next().await {
|
||||
let ttid = ttid?;
|
||||
|
||||
let data = list_timeline_blobs(s3_client, ttid, root_target).await?;
|
||||
|
||||
let s3_layers = match data.blob_data {
|
||||
BlobDataParseResult::Parsed {
|
||||
index_part: _,
|
||||
index_part_generation: _,
|
||||
s3_layers,
|
||||
} => s3_layers,
|
||||
BlobDataParseResult::Relic => {
|
||||
// Post-deletion tenant location: don't try and GC it.
|
||||
continue;
|
||||
}
|
||||
BlobDataParseResult::Incorrect(reasons) => {
|
||||
// Our primary purpose isn't to report on bad data, but log this rather than skipping silently
|
||||
tracing::warn!(
|
||||
"Skipping ancestor GC for timeline {ttid}, bad metadata: {reasons:?}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let ttid_refs = refs.get_ttid_refcounts(&ttid.as_tenant_timeline_id());
|
||||
let ancestor_shard_index = ttid.tenant_shard_id.to_index();
|
||||
|
||||
for (layer_name, layer_gen) in s3_layers {
|
||||
let ref_count = ttid_refs
|
||||
.and_then(|m| m.get(&(ancestor_shard_index, layer_name.clone())))
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
|
||||
if ref_count > 0 {
|
||||
tracing::debug!(%ttid, "Ancestor layer {layer_name} has {ref_count} refs");
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::info!(%ttid, "Ancestor layer {layer_name} is not referenced");
|
||||
|
||||
// Build the key for the layer we are considering deleting
|
||||
let key = root_target.absolute_key(&remote_layer_path(
|
||||
&ttid.tenant_shard_id.tenant_id,
|
||||
&ttid.timeline_id,
|
||||
ancestor_shard_index,
|
||||
&layer_name,
|
||||
layer_gen,
|
||||
));
|
||||
|
||||
// We apply a time threshold to GCing objects that are un-referenced: this preserves our ability
|
||||
// to roll back a shard split if we have to, by avoiding deleting ancestor layers right away
|
||||
if !is_old_enough(s3_client, bucket_config, min_age, &key, summary).await {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !matches!(mode, GcMode::Full) {
|
||||
tracing::info!("Dry run: would delete key {key}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// All validations passed: erase the object
|
||||
match s3_client
|
||||
.delete_object()
|
||||
.bucket(&bucket_config.bucket)
|
||||
.key(&key)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
tracing::info!("Successfully deleted unreferenced ancestor layer {key}");
|
||||
summary.ancestor_layers_deleted += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to delete layer {key}: {e}");
|
||||
summary.remote_storage_errors += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: if all the layers are gone, clean up the whole timeline dir (remove index)
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Physical garbage collection: removing unused S3 objects. This is distinct from the garbage collection
|
||||
/// done inside the pageserver, which operates at a higher level (keys, layers). This type of garbage collection
|
||||
/// is about removing:
|
||||
@@ -156,22 +470,26 @@ async fn maybe_delete_index(
|
||||
/// make sure that object listings don't get slowed down by large numbers of garbage objects.
|
||||
pub async fn pageserver_physical_gc(
|
||||
bucket_config: BucketConfig,
|
||||
tenant_ids: Vec<TenantShardId>,
|
||||
controller_client_conf: Option<ControllerClientConfig>,
|
||||
tenant_shard_ids: Vec<TenantShardId>,
|
||||
min_age: Duration,
|
||||
mode: GcMode,
|
||||
) -> anyhow::Result<GcSummary> {
|
||||
let (s3_client, target) = init_remote(bucket_config.clone(), NodeKind::Pageserver).await?;
|
||||
|
||||
let tenants = if tenant_ids.is_empty() {
|
||||
let tenants = if tenant_shard_ids.is_empty() {
|
||||
futures::future::Either::Left(stream_tenants(&s3_client, &target))
|
||||
} else {
|
||||
futures::future::Either::Right(futures::stream::iter(tenant_ids.into_iter().map(Ok)))
|
||||
futures::future::Either::Right(futures::stream::iter(tenant_shard_ids.into_iter().map(Ok)))
|
||||
};
|
||||
|
||||
// How many tenants to process in parallel. We need to be mindful of pageservers
|
||||
// accessing the same per tenant prefixes, so use a lower setting than pageservers.
|
||||
const CONCURRENCY: usize = 32;
|
||||
|
||||
// Accumulate information about each tenant for cross-shard GC step we'll do at the end
|
||||
let accumulator = Arc::new(std::sync::Mutex::new(TenantRefAccumulator::default()));
|
||||
|
||||
// Generate a stream of TenantTimelineId
|
||||
let timelines = tenants.map_ok(|t| stream_tenant_timelines(&s3_client, &target, t));
|
||||
let timelines = timelines.try_buffered(CONCURRENCY);
|
||||
@@ -185,16 +503,17 @@ pub async fn pageserver_physical_gc(
|
||||
target: &RootTarget,
|
||||
mode: GcMode,
|
||||
ttid: TenantShardTimelineId,
|
||||
accumulator: &Arc<std::sync::Mutex<TenantRefAccumulator>>,
|
||||
) -> anyhow::Result<GcSummary> {
|
||||
let mut summary = GcSummary::default();
|
||||
let data = list_timeline_blobs(s3_client, ttid, target).await?;
|
||||
|
||||
let (latest_gen, candidates) = match &data.blob_data {
|
||||
let (index_part, latest_gen, candidates) = match &data.blob_data {
|
||||
BlobDataParseResult::Parsed {
|
||||
index_part: _index_part,
|
||||
index_part,
|
||||
index_part_generation,
|
||||
s3_layers: _s3_layers,
|
||||
} => (*index_part_generation, data.unused_index_keys),
|
||||
} => (index_part, *index_part_generation, data.unused_index_keys),
|
||||
BlobDataParseResult::Relic => {
|
||||
// Post-deletion tenant location: don't try and GC it.
|
||||
return Ok(summary);
|
||||
@@ -206,6 +525,8 @@ pub async fn pageserver_physical_gc(
|
||||
}
|
||||
};
|
||||
|
||||
accumulator.lock().unwrap().update(ttid, index_part);
|
||||
|
||||
for key in candidates {
|
||||
maybe_delete_index(
|
||||
s3_client,
|
||||
@@ -222,17 +543,61 @@ pub async fn pageserver_physical_gc(
|
||||
|
||||
Ok(summary)
|
||||
}
|
||||
let timelines = timelines
|
||||
.map_ok(|ttid| gc_timeline(&s3_client, &bucket_config, &min_age, &target, mode, ttid));
|
||||
let mut timelines = std::pin::pin!(timelines.try_buffered(CONCURRENCY));
|
||||
|
||||
let mut summary = GcSummary::default();
|
||||
|
||||
while let Some(i) = timelines.next().await {
|
||||
let tl_summary = i?;
|
||||
// Drain futures for per-shard GC, populating accumulator as a side effect
|
||||
{
|
||||
let timelines = timelines.map_ok(|ttid| {
|
||||
gc_timeline(
|
||||
&s3_client,
|
||||
&bucket_config,
|
||||
&min_age,
|
||||
&target,
|
||||
mode,
|
||||
ttid,
|
||||
&accumulator,
|
||||
)
|
||||
});
|
||||
let mut timelines = std::pin::pin!(timelines.try_buffered(CONCURRENCY));
|
||||
|
||||
summary.indices_deleted += tl_summary.indices_deleted;
|
||||
summary.remote_storage_errors += tl_summary.remote_storage_errors;
|
||||
while let Some(i) = timelines.next().await {
|
||||
summary.merge(i?);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute cross-shard GC, using the accumulator's full view of all the shards built in the per-shard GC
|
||||
let Some(controller_client) = controller_client_conf.as_ref().map(|c| {
|
||||
let ControllerClientConfig {
|
||||
controller_api,
|
||||
controller_jwt,
|
||||
} = c;
|
||||
control_api::Client::new(controller_api.clone(), Some(controller_jwt.clone()))
|
||||
}) else {
|
||||
tracing::info!("Skipping ancestor layer GC, because no `--controller-api` was specified");
|
||||
return Ok(summary);
|
||||
};
|
||||
|
||||
let (ancestor_shards, ancestor_refs) = Arc::into_inner(accumulator)
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.unwrap()
|
||||
.into_gc_ancestors(&controller_client, &mut summary)
|
||||
.await;
|
||||
|
||||
for ancestor_shard in ancestor_shards {
|
||||
gc_ancestor(
|
||||
&s3_client,
|
||||
&bucket_config,
|
||||
&target,
|
||||
&min_age,
|
||||
ancestor_shard,
|
||||
&ancestor_refs,
|
||||
mode,
|
||||
&mut summary,
|
||||
)
|
||||
.instrument(info_span!("gc_ancestor", %ancestor_shard))
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(summary)
|
||||
|
||||
Reference in New Issue
Block a user