diff --git a/libs/pageserver_api/src/models.rs b/libs/pageserver_api/src/models.rs index 42daa1d996..ca9faad6f5 100644 --- a/libs/pageserver_api/src/models.rs +++ b/libs/pageserver_api/src/models.rs @@ -1364,6 +1364,12 @@ pub enum TimelineArchivalState { Unarchived, } +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone)] +pub enum TimelineVisibilityState { + Visible, + Invisible, +} + #[derive(Serialize, Deserialize, PartialEq, Eq, Clone)] pub struct TimelineArchivalConfigRequest { pub state: TimelineArchivalState, @@ -1496,6 +1502,9 @@ pub struct TimelineInfo { /// The status of the rel_size migration. pub rel_size_migration: Option, + + /// Whether the timeline is invisible in synthetic size calculations. + pub is_invisible: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/pageserver/src/http/routes.rs b/pageserver/src/http/routes.rs index 01ae96f89f..5a13fb1387 100644 --- a/pageserver/src/http/routes.rs +++ b/pageserver/src/http/routes.rs @@ -37,8 +37,8 @@ use pageserver_api::models::{ TenantShardSplitResponse, TenantSorting, TenantState, TenantWaitLsnRequest, TimelineArchivalConfigRequest, TimelineCreateRequest, TimelineCreateRequestMode, TimelineCreateRequestModeImportPgdata, TimelineGcRequest, TimelineInfo, - TimelinePatchIndexPartRequest, TimelinesInfoAndOffloaded, TopTenantShardItem, - TopTenantShardsRequest, TopTenantShardsResponse, + TimelinePatchIndexPartRequest, TimelineVisibilityState, TimelinesInfoAndOffloaded, + TopTenantShardItem, TopTenantShardsRequest, TopTenantShardsResponse, }; use pageserver_api::shard::{ShardCount, TenantShardId}; use remote_storage::{DownloadError, GenericRemoteStorage, TimeTravelError}; @@ -439,6 +439,7 @@ async fn build_timeline_info_common( let remote_consistent_lsn_visible = timeline .get_remote_consistent_lsn_visible() .unwrap_or(Lsn(0)); + let is_invisible = timeline.remote_client.is_invisible().unwrap_or(false); let walreceiver_status = timeline.walreceiver_status(); @@ -482,6 +483,7 @@ async fn build_timeline_info_common( state, is_archived: Some(is_archived), rel_size_migration: Some(timeline.get_rel_size_v2_status()), + is_invisible: Some(is_invisible), walreceiver_status, }; @@ -2333,6 +2335,28 @@ async fn timeline_compact_handler( .await } +async fn timeline_mark_invisible_handler( + request: Request, + _cancel: CancellationToken, +) -> Result, ApiError> { + let tenant_shard_id: TenantShardId = parse_request_param(&request, "tenant_shard_id")?; + let timeline_id: TimelineId = parse_request_param(&request, "timeline_id")?; + check_permission(&request, Some(tenant_shard_id.tenant_id))?; + + let state = get_state(&request); + + async { + let tenant = state + .tenant_manager + .get_attached_tenant_shard(tenant_shard_id)?; + let timeline = tenant.get_timeline(timeline_id, true)?; + timeline.remote_client.schedule_index_upload_for_timeline_invisible_state(TimelineVisibilityState::Invisible).map_err(ApiError::InternalServerError)?; + json_response(StatusCode::OK, ()) + } + .instrument(info_span!("manual_timeline_mark_invisible", tenant_id = %tenant_shard_id.tenant_id, shard_id = %tenant_shard_id.shard_slug(), %timeline_id)) + .await +} + // Run offload immediately on given timeline. async fn timeline_offload_handler( request: Request, @@ -3750,6 +3774,10 @@ pub fn make_router( "/v1/tenant/:tenant_shard_id/timeline/:timeline_id/offload", |r| testing_api_handler("attempt timeline offload", r, timeline_offload_handler), ) + .put( + "/v1/tenant/:tenant_shard_id/timeline/:timeline_id/mark_invisible", + |r| testing_api_handler("mark timeline invisible", r, timeline_mark_invisible_handler), + ) .put( "/v1/tenant/:tenant_shard_id/timeline/:timeline_id/checkpoint", |r| testing_api_handler("run timeline checkpoint", r, timeline_checkpoint_handler), diff --git a/pageserver/src/tenant/remote_timeline_client.rs b/pageserver/src/tenant/remote_timeline_client.rs index 891760b499..32c0571b97 100644 --- a/pageserver/src/tenant/remote_timeline_client.rs +++ b/pageserver/src/tenant/remote_timeline_client.rs @@ -194,7 +194,7 @@ pub(crate) use download::{ }; use index::GcCompactionState; pub(crate) use index::LayerFileMetadata; -use pageserver_api::models::{RelSizeMigration, TimelineArchivalState}; +use pageserver_api::models::{RelSizeMigration, TimelineArchivalState, TimelineVisibilityState}; use pageserver_api::shard::{ShardIndex, TenantShardId}; use regex::Regex; use remote_storage::{ @@ -573,6 +573,16 @@ impl RemoteTimelineClient { .ok() } + /// Returns true if the timeline is invisible in synthetic size calculations. + pub(crate) fn is_invisible(&self) -> Option { + self.upload_queue + .lock() + .unwrap() + .initialized_mut() + .map(|q| q.clean.0.marked_invisible_at.is_some()) + .ok() + } + /// Returns `Ok(Some(timestamp))` if the timeline has been archived, `Ok(None)` if the timeline hasn't been archived. /// /// Return Err(_) if the remote index_part hasn't been downloaded yet, or the timeline hasn't been stopped yet. @@ -845,6 +855,37 @@ impl RemoteTimelineClient { Ok(need_wait) } + pub(crate) fn schedule_index_upload_for_timeline_invisible_state( + self: &Arc, + state: TimelineVisibilityState, + ) -> anyhow::Result<()> { + let mut guard = self.upload_queue.lock().unwrap(); + let upload_queue = guard.initialized_mut()?; + + fn need_change( + marked_invisible_at: &Option, + state: TimelineVisibilityState, + ) -> Option { + match (marked_invisible_at, state) { + (Some(_), TimelineVisibilityState::Invisible) => Some(false), + (None, TimelineVisibilityState::Invisible) => Some(true), + (Some(_), TimelineVisibilityState::Visible) => Some(false), + (None, TimelineVisibilityState::Visible) => Some(true), + } + } + + let need_upload_scheduled = need_change(&upload_queue.dirty.marked_invisible_at, state); + + if let Some(marked_invisible_at_set) = need_upload_scheduled { + let intended_marked_invisible_at = + marked_invisible_at_set.then(|| Utc::now().naive_utc()); + upload_queue.dirty.marked_invisible_at = intended_marked_invisible_at; + self.schedule_index_upload(upload_queue); + } + + Ok(()) + } + /// Shuts the timeline client down, but only if the timeline is archived. /// /// This function and [`Self::schedule_index_upload_for_timeline_archival_state`] use the diff --git a/pageserver/src/tenant/remote_timeline_client/index.rs b/pageserver/src/tenant/remote_timeline_client/index.rs index 16c38be907..5635cf3268 100644 --- a/pageserver/src/tenant/remote_timeline_client/index.rs +++ b/pageserver/src/tenant/remote_timeline_client/index.rs @@ -110,6 +110,10 @@ pub struct IndexPart { /// just the specific use case here; it needs a new name. #[serde(skip_serializing_if = "Option::is_none", default)] pub(crate) gc_compaction: Option, + + /// The timestamp when the timeline was marked invisible in synthetic size calculations. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub(crate) marked_invisible_at: Option, } #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] @@ -137,10 +141,11 @@ impl IndexPart { /// - 11: +rel_size_migration /// - 12: +l2_lsn /// - 13: +gc_compaction - const LATEST_VERSION: usize = 13; + /// - 14: +marked_invisible_at + const LATEST_VERSION: usize = 14; // Versions we may see when reading from a bucket. - pub const KNOWN_VERSIONS: &'static [usize] = &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]; + pub const KNOWN_VERSIONS: &'static [usize] = &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]; pub const FILE_NAME: &'static str = "index_part.json"; @@ -159,6 +164,7 @@ impl IndexPart { rel_size_migration: None, l2_lsn: None, gc_compaction: None, + marked_invisible_at: None, } } @@ -468,6 +474,7 @@ mod tests { rel_size_migration: None, l2_lsn: None, gc_compaction: None, + marked_invisible_at: None, }; let part = IndexPart::from_json_bytes(example.as_bytes()).unwrap(); @@ -516,6 +523,7 @@ mod tests { rel_size_migration: None, l2_lsn: None, gc_compaction: None, + marked_invisible_at: None, }; let part = IndexPart::from_json_bytes(example.as_bytes()).unwrap(); @@ -565,6 +573,7 @@ mod tests { rel_size_migration: None, l2_lsn: None, gc_compaction: None, + marked_invisible_at: None, }; let part = IndexPart::from_json_bytes(example.as_bytes()).unwrap(); @@ -617,6 +626,7 @@ mod tests { rel_size_migration: None, l2_lsn: None, gc_compaction: None, + marked_invisible_at: None, }; let empty_layers_parsed = IndexPart::from_json_bytes(empty_layers_json.as_bytes()).unwrap(); @@ -664,6 +674,7 @@ mod tests { rel_size_migration: None, l2_lsn: None, gc_compaction: None, + marked_invisible_at: None, }; let part = IndexPart::from_json_bytes(example.as_bytes()).unwrap(); @@ -714,6 +725,7 @@ mod tests { rel_size_migration: None, l2_lsn: None, gc_compaction: None, + marked_invisible_at: None, }; let part = IndexPart::from_json_bytes(example.as_bytes()).unwrap(); @@ -769,6 +781,7 @@ mod tests { rel_size_migration: None, l2_lsn: None, gc_compaction: None, + marked_invisible_at: None, }; let part = IndexPart::from_json_bytes(example.as_bytes()).unwrap(); @@ -829,6 +842,7 @@ mod tests { rel_size_migration: None, l2_lsn: None, gc_compaction: None, + marked_invisible_at: None, }; let part = IndexPart::from_json_bytes(example.as_bytes()).unwrap(); @@ -890,6 +904,7 @@ mod tests { rel_size_migration: None, l2_lsn: None, gc_compaction: None, + marked_invisible_at: None, }; let part = IndexPart::from_json_bytes(example.as_bytes()).unwrap(); @@ -956,6 +971,7 @@ mod tests { rel_size_migration: None, l2_lsn: None, gc_compaction: None, + marked_invisible_at: None, }; let part = IndexPart::from_json_bytes(example.as_bytes()).unwrap(); @@ -1035,6 +1051,7 @@ mod tests { rel_size_migration: None, l2_lsn: None, gc_compaction: None, + marked_invisible_at: None, }; let part = IndexPart::from_json_bytes(example.as_bytes()).unwrap(); @@ -1115,6 +1132,7 @@ mod tests { rel_size_migration: Some(RelSizeMigration::Legacy), l2_lsn: None, gc_compaction: None, + marked_invisible_at: None, }; let part = IndexPart::from_json_bytes(example.as_bytes()).unwrap(); @@ -1124,7 +1142,7 @@ mod tests { #[test] fn v12_v13_l2_gc_ompaction_is_parsed() { let example = r#"{ - "version": 12, + "version": 13, "layer_metadata":{ "000000000000000000000000000000000000-FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF__0000000001696070-00000000016960E9": { "file_size": 25600000 }, "000000000000000000000000000000000000-FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF__00000000016B59D8-00000000016B5A51": { "file_size": 9007199254741001 } @@ -1160,7 +1178,7 @@ mod tests { }"#; let expected = IndexPart { - version: 12, + version: 13, layer_metadata: HashMap::from([ ("000000000000000000000000000000000000-FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF__0000000001696070-00000000016960E9".parse().unwrap(), LayerFileMetadata { file_size: 25600000, @@ -1201,6 +1219,95 @@ mod tests { gc_compaction: Some(GcCompactionState { last_completed_lsn: "0/16960E8".parse::().unwrap(), }), + marked_invisible_at: None, + }; + + let part = IndexPart::from_json_bytes(example.as_bytes()).unwrap(); + assert_eq!(part, expected); + } + + #[test] + fn v14_marked_invisible_at_is_parsed() { + let example = r#"{ + "version": 14, + "layer_metadata":{ + "000000000000000000000000000000000000-FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF__0000000001696070-00000000016960E9": { "file_size": 25600000 }, + "000000000000000000000000000000000000-FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF__00000000016B59D8-00000000016B5A51": { "file_size": 9007199254741001 } + }, + "disk_consistent_lsn":"0/16960E8", + "metadata": { + "disk_consistent_lsn": "0/16960E8", + "prev_record_lsn": "0/1696070", + "ancestor_timeline": "e45a7f37d3ee2ff17dc14bf4f4e3f52e", + "ancestor_lsn": "0/0", + "latest_gc_cutoff_lsn": "0/1696070", + "initdb_lsn": "0/1696070", + "pg_version": 14 + }, + "gc_blocking": { + "started_at": "2024-07-19T09:00:00.123", + "reasons": ["DetachAncestor"] + }, + "import_pgdata": { + "V1": { + "Done": { + "idempotency_key": "specified-by-client-218a5213-5044-4562-a28d-d024c5f057f5", + "started_at": "2024-11-13T09:23:42.123", + "finished_at": "2024-11-13T09:42:23.123" + } + } + }, + "rel_size_migration": "legacy", + "l2_lsn": "0/16960E8", + "gc_compaction": { + "last_completed_lsn": "0/16960E8" + }, + "marked_invisible_at": "2023-07-31T09:00:00.123" + }"#; + + let expected = IndexPart { + version: 14, + layer_metadata: HashMap::from([ + ("000000000000000000000000000000000000-FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF__0000000001696070-00000000016960E9".parse().unwrap(), LayerFileMetadata { + file_size: 25600000, + generation: Generation::none(), + shard: ShardIndex::unsharded() + }), + ("000000000000000000000000000000000000-FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF__00000000016B59D8-00000000016B5A51".parse().unwrap(), LayerFileMetadata { + file_size: 9007199254741001, + generation: Generation::none(), + shard: ShardIndex::unsharded() + }) + ]), + disk_consistent_lsn: "0/16960E8".parse::().unwrap(), + metadata: TimelineMetadata::new( + Lsn::from_str("0/16960E8").unwrap(), + Some(Lsn::from_str("0/1696070").unwrap()), + Some(TimelineId::from_str("e45a7f37d3ee2ff17dc14bf4f4e3f52e").unwrap()), + Lsn::INVALID, + Lsn::from_str("0/1696070").unwrap(), + Lsn::from_str("0/1696070").unwrap(), + 14, + ).with_recalculated_checksum().unwrap(), + deleted_at: None, + lineage: Default::default(), + gc_blocking: Some(GcBlocking { + started_at: parse_naive_datetime("2024-07-19T09:00:00.123000000"), + reasons: enumset::EnumSet::from_iter([GcBlockingReason::DetachAncestor]), + }), + last_aux_file_policy: Default::default(), + archived_at: None, + import_pgdata: Some(import_pgdata::index_part_format::Root::V1(import_pgdata::index_part_format::V1::Done(import_pgdata::index_part_format::Done{ + started_at: parse_naive_datetime("2024-11-13T09:23:42.123000000"), + finished_at: parse_naive_datetime("2024-11-13T09:42:23.123000000"), + idempotency_key: import_pgdata::index_part_format::IdempotencyKey::new("specified-by-client-218a5213-5044-4562-a28d-d024c5f057f5".to_string()), + }))), + rel_size_migration: Some(RelSizeMigration::Legacy), + l2_lsn: Some("0/16960E8".parse::().unwrap()), + gc_compaction: Some(GcCompactionState { + last_completed_lsn: "0/16960E8".parse::().unwrap(), + }), + marked_invisible_at: Some(parse_naive_datetime("2023-07-31T09:00:00.123000000")), }; let part = IndexPart::from_json_bytes(example.as_bytes()).unwrap();