mirror of
https://github.com/neondatabase/neon.git
synced 2026-05-23 16:10:37 +00:00
## Problem - #5050 Closes: https://github.com/neondatabase/neon/issues/5136 ## Summary of changes - A new configuration property `control_plane_api` controls other functionality in this PR: if it is unset (default) then everything still works as it does today. - If `control_plane_api` is set, then on startup we call out to control plane `/re-attach` endpoint to discover our attachments and their generations. If an attachment is missing from the response we implicitly detach the tenant. - Calls to pageserver `/attach` API may include a `generation` parameter. If `control_plane_api` is set, then this parameter is mandatory. - RemoteTimelineClient's loading of index_part.json is generation-aware, and will try to load the index_part with the most recent generation <= its own generation. - The `neon_local` testing environment now includes a new binary `attachment_service` which implements the endpoints that the pageserver requires to operate. This is on by default if running `cargo neon` by hand. In `test_runner/` tests, it is off by default: existing tests continue to run with in the legacy generation-less mode. Caveats: - The re-attachment during startup assumes that we are only re-attaching tenants that have previously been attached, and not totally new tenants -- this relies on the control plane's attachment logic to keep retrying so that we should eventually see the attach API call. That's important because the `/re-attach` API doesn't tell us which timelines we should attach -- we still use local disk state for that. Ref: https://github.com/neondatabase/neon/issues/5173 - Testing: generations are only enabled for one integration test right now (test_pageserver_restart), as a smoke test that all the machinery basically works. Writing fuller tests that stress tenant migration will come later, and involve extending our test fixtures to deal with multiple pageservers. - I'm not in love with "attachment_service" as a name for the neon_local component, but it's not very important because we can easily rename these test bits whenever we want. - Limited observability when in re-attach on startup: when I add generation validation for deletions in a later PR, I want to wrap up the control plane API calls in some small client class that will expose metrics for things like errors calling the control plane API, which will act as a strong red signal that something is not right. Co-authored-by: Christian Schwarz <christian@neon.tech> Co-authored-by: Joonas Koivunen <joonas@neon.tech>
120 lines
4.0 KiB
Rust
120 lines
4.0 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use hyper::StatusCode;
|
|
use pageserver_api::control_api::{ReAttachRequest, ReAttachResponse};
|
|
use tokio_util::sync::CancellationToken;
|
|
use url::Url;
|
|
use utils::{
|
|
backoff,
|
|
generation::Generation,
|
|
id::{NodeId, TenantId},
|
|
};
|
|
|
|
use crate::config::PageServerConf;
|
|
|
|
// Backoffs when control plane requests do not succeed: compromise between reducing load
|
|
// on control plane, and retrying frequently when we are blocked on a control plane
|
|
// response to make progress.
|
|
const BACKOFF_INCREMENT: f64 = 0.1;
|
|
const BACKOFF_MAX: f64 = 10.0;
|
|
|
|
/// The Pageserver's client for using the control plane API: this is a small subset
|
|
/// of the overall control plane API, for dealing with generations (see docs/rfcs/025-generation-numbers.md)
|
|
pub(crate) struct ControlPlaneClient {
|
|
http_client: reqwest::Client,
|
|
base_url: Url,
|
|
node_id: NodeId,
|
|
cancel: CancellationToken,
|
|
}
|
|
|
|
impl ControlPlaneClient {
|
|
/// A None return value indicates that the input `conf` object does not have control
|
|
/// plane API enabled.
|
|
pub(crate) fn new(conf: &'static PageServerConf, cancel: &CancellationToken) -> Option<Self> {
|
|
let mut url = match conf.control_plane_api.as_ref() {
|
|
Some(u) => u.clone(),
|
|
None => return None,
|
|
};
|
|
|
|
if let Ok(mut segs) = url.path_segments_mut() {
|
|
// This ensures that `url` ends with a slash if it doesn't already.
|
|
// That way, we can subsequently use join() to safely attach extra path elements.
|
|
segs.pop_if_empty().push("");
|
|
}
|
|
|
|
let client = reqwest::ClientBuilder::new()
|
|
.build()
|
|
.expect("Failed to construct http client");
|
|
|
|
Some(Self {
|
|
http_client: client,
|
|
base_url: url,
|
|
node_id: conf.id,
|
|
cancel: cancel.clone(),
|
|
})
|
|
}
|
|
|
|
async fn try_re_attach(
|
|
&self,
|
|
url: Url,
|
|
request: &ReAttachRequest,
|
|
) -> anyhow::Result<ReAttachResponse> {
|
|
match self.http_client.post(url).json(request).send().await {
|
|
Err(e) => Err(anyhow::Error::from(e)),
|
|
Ok(r) => {
|
|
if r.status() == StatusCode::OK {
|
|
r.json::<ReAttachResponse>()
|
|
.await
|
|
.map_err(anyhow::Error::from)
|
|
} else {
|
|
Err(anyhow::anyhow!("Unexpected status {}", r.status()))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Block until we get a successful response
|
|
pub(crate) async fn re_attach(&self) -> anyhow::Result<HashMap<TenantId, Generation>> {
|
|
let re_attach_path = self
|
|
.base_url
|
|
.join("re-attach")
|
|
.expect("Failed to build re-attach path");
|
|
let request = ReAttachRequest {
|
|
node_id: self.node_id,
|
|
};
|
|
|
|
let mut attempt = 0;
|
|
loop {
|
|
let result = self.try_re_attach(re_attach_path.clone(), &request).await;
|
|
match result {
|
|
Ok(res) => {
|
|
tracing::info!(
|
|
"Received re-attach response with {} tenants",
|
|
res.tenants.len()
|
|
);
|
|
|
|
return Ok(res
|
|
.tenants
|
|
.into_iter()
|
|
.map(|t| (t.id, Generation::new(t.generation)))
|
|
.collect::<HashMap<_, _>>());
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Error re-attaching tenants, retrying: {e:#}");
|
|
backoff::exponential_backoff(
|
|
attempt,
|
|
BACKOFF_INCREMENT,
|
|
BACKOFF_MAX,
|
|
&self.cancel,
|
|
)
|
|
.await;
|
|
if self.cancel.is_cancelled() {
|
|
return Err(anyhow::anyhow!("Shutting down"));
|
|
}
|
|
attempt += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|