diff --git a/.github/actions/allure-report-generate/action.yml b/.github/actions/allure-report-generate/action.yml
index 79f054cb06..9a0c79a221 100644
--- a/.github/actions/allure-report-generate/action.yml
+++ b/.github/actions/allure-report-generate/action.yml
@@ -76,8 +76,8 @@ runs:
rm -f ${ALLURE_ZIP}
fi
env:
- ALLURE_VERSION: 2.24.0
- ALLURE_ZIP_SHA256: 60b1d6ce65d9ef24b23cf9c2c19fd736a123487c38e54759f1ed1a7a77353c90
+ ALLURE_VERSION: 2.27.0
+ ALLURE_ZIP_SHA256: b071858fb2fa542c65d8f152c5c40d26267b2dfb74df1f1608a589ecca38e777
# Potentially we could have several running build for the same key (for example, for the main branch), so we use improvised lock for this
- name: Acquire lock
diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml
index 2a1c79e437..1744616888 100644
--- a/.github/workflows/build_and_test.yml
+++ b/.github/workflows/build_and_test.yml
@@ -472,6 +472,7 @@ jobs:
CHECK_ONDISK_DATA_COMPATIBILITY: nonempty
BUILD_TAG: ${{ needs.tag.outputs.build-tag }}
PAGESERVER_VIRTUAL_FILE_IO_ENGINE: std-fs
+ PAGESERVER_GET_VECTORED_IMPL: vectored
# Temporary disable this step until we figure out why it's so flaky
# Ref https://github.com/neondatabase/neon/issues/4540
diff --git a/CODEOWNERS b/CODEOWNERS
index e384dc39f1..5b601f0566 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -1,10 +1,10 @@
/compute_tools/ @neondatabase/control-plane @neondatabase/compute
-/control_plane/ @neondatabase/compute @neondatabase/storage
-/libs/pageserver_api/ @neondatabase/compute @neondatabase/storage
+/control_plane/attachment_service @neondatabase/storage
+/libs/pageserver_api/ @neondatabase/storage
/libs/postgres_ffi/ @neondatabase/compute
/libs/remote_storage/ @neondatabase/storage
/libs/safekeeper_api/ @neondatabase/safekeepers
-/libs/vm_monitor/ @neondatabase/autoscaling @neondatabase/compute
+/libs/vm_monitor/ @neondatabase/autoscaling
/pageserver/ @neondatabase/storage
/pgxn/ @neondatabase/compute
/proxy/ @neondatabase/proxy
diff --git a/Cargo.lock b/Cargo.lock
index f25e3d1574..51c433cd07 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -284,6 +284,7 @@ dependencies = [
"diesel_migrations",
"futures",
"git-version",
+ "humantime",
"hyper",
"metrics",
"once_cell",
@@ -3552,6 +3553,7 @@ dependencies = [
"enum-map",
"hex",
"humantime-serde",
+ "itertools",
"postgres_ffi",
"rand 0.8.5",
"serde",
diff --git a/compute_tools/src/bin/compute_ctl.rs b/compute_tools/src/bin/compute_ctl.rs
index a7e10d0aee..117919786e 100644
--- a/compute_tools/src/bin/compute_ctl.rs
+++ b/compute_tools/src/bin/compute_ctl.rs
@@ -45,7 +45,6 @@ use std::{thread, time::Duration};
use anyhow::{Context, Result};
use chrono::Utc;
use clap::Arg;
-use nix::sys::signal::{kill, Signal};
use signal_hook::consts::{SIGQUIT, SIGTERM};
use signal_hook::{consts::SIGINT, iterator::Signals};
use tracing::{error, info};
@@ -53,7 +52,9 @@ use url::Url;
use compute_api::responses::ComputeStatus;
-use compute_tools::compute::{ComputeNode, ComputeState, ParsedSpec, PG_PID, SYNC_SAFEKEEPERS_PID};
+use compute_tools::compute::{
+ forward_termination_signal, ComputeNode, ComputeState, ParsedSpec, PG_PID,
+};
use compute_tools::configurator::launch_configurator;
use compute_tools::extension_server::get_pg_version;
use compute_tools::http::api::launch_http_server;
@@ -394,6 +395,15 @@ fn main() -> Result<()> {
info!("synced safekeepers at lsn {lsn}");
}
+ let mut state = compute.state.lock().unwrap();
+ if state.status == ComputeStatus::TerminationPending {
+ state.status = ComputeStatus::Terminated;
+ compute.state_changed.notify_all();
+ // we were asked to terminate gracefully, don't exit to avoid restart
+ delay_exit = true
+ }
+ drop(state);
+
if let Err(err) = compute.check_for_core_dumps() {
error!("error while checking for core dumps: {err:?}");
}
@@ -523,16 +533,7 @@ fn cli() -> clap::Command {
/// wait for termination which would be easy then.
fn handle_exit_signal(sig: i32) {
info!("received {sig} termination signal");
- let ss_pid = SYNC_SAFEKEEPERS_PID.load(Ordering::SeqCst);
- if ss_pid != 0 {
- let ss_pid = nix::unistd::Pid::from_raw(ss_pid as i32);
- kill(ss_pid, Signal::SIGTERM).ok();
- }
- let pg_pid = PG_PID.load(Ordering::SeqCst);
- if pg_pid != 0 {
- let pg_pid = nix::unistd::Pid::from_raw(pg_pid as i32);
- kill(pg_pid, Signal::SIGTERM).ok();
- }
+ forward_termination_signal();
exit(1);
}
diff --git a/compute_tools/src/compute.rs b/compute_tools/src/compute.rs
index 1c5363d048..142bb14fe5 100644
--- a/compute_tools/src/compute.rs
+++ b/compute_tools/src/compute.rs
@@ -28,6 +28,8 @@ use compute_api::responses::{ComputeMetrics, ComputeStatus};
use compute_api::spec::{ComputeFeature, ComputeMode, ComputeSpec};
use utils::measured_stream::MeasuredReader;
+use nix::sys::signal::{kill, Signal};
+
use remote_storage::{DownloadError, RemotePath};
use crate::checker::create_availability_check_data;
@@ -1322,3 +1324,17 @@ LIMIT 100",
Ok(remote_ext_metrics)
}
}
+
+pub fn forward_termination_signal() {
+ let ss_pid = SYNC_SAFEKEEPERS_PID.load(Ordering::SeqCst);
+ if ss_pid != 0 {
+ let ss_pid = nix::unistd::Pid::from_raw(ss_pid as i32);
+ kill(ss_pid, Signal::SIGTERM).ok();
+ }
+ let pg_pid = PG_PID.load(Ordering::SeqCst);
+ if pg_pid != 0 {
+ let pg_pid = nix::unistd::Pid::from_raw(pg_pid as i32);
+ // use 'immediate' shutdown (SIGQUIT): https://www.postgresql.org/docs/current/server-shutdown.html
+ kill(pg_pid, Signal::SIGQUIT).ok();
+ }
+}
diff --git a/compute_tools/src/http/api.rs b/compute_tools/src/http/api.rs
index fa2c4cff28..f076951239 100644
--- a/compute_tools/src/http/api.rs
+++ b/compute_tools/src/http/api.rs
@@ -5,6 +5,7 @@ use std::net::SocketAddr;
use std::sync::Arc;
use std::thread;
+use crate::compute::forward_termination_signal;
use crate::compute::{ComputeNode, ComputeState, ParsedSpec};
use compute_api::requests::ConfigurationRequest;
use compute_api::responses::{ComputeStatus, ComputeStatusResponse, GenericAPIError};
@@ -123,6 +124,17 @@ async fn routes(req: Request
, compute: &Arc) -> Response {
+ info!("serving /terminate POST request");
+ match handle_terminate_request(compute).await {
+ Ok(()) => Response::new(Body::empty()),
+ Err((msg, code)) => {
+ error!("error handling /terminate request: {msg}");
+ render_json_error(&msg, code)
+ }
+ }
+ }
+
// download extension files from remote extension storage on demand
(&Method::POST, route) if route.starts_with("/extension_server/") => {
info!("serving {:?} POST request", route);
@@ -297,6 +309,49 @@ fn render_json_error(e: &str, status: StatusCode) -> Response {
.unwrap()
}
+async fn handle_terminate_request(compute: &Arc) -> Result<(), (String, StatusCode)> {
+ {
+ let mut state = compute.state.lock().unwrap();
+ if state.status == ComputeStatus::Terminated {
+ return Ok(());
+ }
+ if state.status != ComputeStatus::Empty && state.status != ComputeStatus::Running {
+ let msg = format!(
+ "invalid compute status for termination request: {:?}",
+ state.status.clone()
+ );
+ return Err((msg, StatusCode::PRECONDITION_FAILED));
+ }
+ state.status = ComputeStatus::TerminationPending;
+ compute.state_changed.notify_all();
+ drop(state);
+ }
+ forward_termination_signal();
+ info!("sent signal and notified waiters");
+
+ // Spawn a blocking thread to wait for compute to become Terminated.
+ // This is needed to do not block the main pool of workers and
+ // be able to serve other requests while some particular request
+ // is waiting for compute to finish configuration.
+ let c = compute.clone();
+ task::spawn_blocking(move || {
+ let mut state = c.state.lock().unwrap();
+ while state.status != ComputeStatus::Terminated {
+ state = c.state_changed.wait(state).unwrap();
+ info!(
+ "waiting for compute to become Terminated, current status: {:?}",
+ state.status
+ );
+ }
+
+ Ok(())
+ })
+ .await
+ .unwrap()?;
+ info!("terminated Postgres");
+ Ok(())
+}
+
// Main Hyper HTTP server function that runs it and blocks waiting on it forever.
#[tokio::main]
async fn serve(port: u16, state: Arc) {
diff --git a/compute_tools/src/http/openapi_spec.yaml b/compute_tools/src/http/openapi_spec.yaml
index cedc6ece8f..d2ec54299f 100644
--- a/compute_tools/src/http/openapi_spec.yaml
+++ b/compute_tools/src/http/openapi_spec.yaml
@@ -168,6 +168,29 @@ paths:
schema:
$ref: "#/components/schemas/GenericError"
+ /terminate:
+ post:
+ tags:
+ - Terminate
+ summary: Terminate Postgres and wait for it to exit
+ description: ""
+ operationId: terminate
+ responses:
+ 200:
+ description: Result
+ 412:
+ description: "wrong state"
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GenericError"
+ 500:
+ description: "Unexpected error"
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GenericError"
+
components:
securitySchemes:
JWT:
diff --git a/control_plane/attachment_service/Cargo.toml b/control_plane/attachment_service/Cargo.toml
index 9e1c6377ee..bfdfd4c77d 100644
--- a/control_plane/attachment_service/Cargo.toml
+++ b/control_plane/attachment_service/Cargo.toml
@@ -18,6 +18,7 @@ clap.workspace = true
futures.workspace = true
git-version.workspace = true
hyper.workspace = true
+humantime.workspace = true
once_cell.workspace = true
pageserver_api.workspace = true
pageserver_client.workspace = true
diff --git a/control_plane/attachment_service/src/http.rs b/control_plane/attachment_service/src/http.rs
index 67ab37dfc1..d85753bedc 100644
--- a/control_plane/attachment_service/src/http.rs
+++ b/control_plane/attachment_service/src/http.rs
@@ -4,7 +4,7 @@ use hyper::{Body, Request, Response};
use hyper::{StatusCode, Uri};
use pageserver_api::models::{
TenantCreateRequest, TenantLocationConfigRequest, TenantShardSplitRequest,
- TimelineCreateRequest,
+ TenantTimeTravelRequest, TimelineCreateRequest,
};
use pageserver_api::shard::TenantShardId;
use pageserver_client::mgmt_api;
@@ -12,7 +12,7 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use utils::auth::SwappableJwtAuth;
use utils::http::endpoint::{auth_middleware, request_span};
-use utils::http::request::parse_request_param;
+use utils::http::request::{must_get_query_param, parse_request_param};
use utils::id::{TenantId, TimelineId};
use utils::{
@@ -180,6 +180,39 @@ async fn handle_tenant_location_config(
)
}
+async fn handle_tenant_time_travel_remote_storage(
+ service: Arc,
+ mut req: Request,
+) -> Result, ApiError> {
+ let tenant_id: TenantId = parse_request_param(&req, "tenant_id")?;
+ let time_travel_req = json_request::(&mut req).await?;
+
+ let timestamp_raw = must_get_query_param(&req, "travel_to")?;
+ let _timestamp = humantime::parse_rfc3339(×tamp_raw).map_err(|_e| {
+ ApiError::BadRequest(anyhow::anyhow!(
+ "Invalid time for travel_to: {timestamp_raw:?}"
+ ))
+ })?;
+
+ let done_if_after_raw = must_get_query_param(&req, "done_if_after")?;
+ let _done_if_after = humantime::parse_rfc3339(&done_if_after_raw).map_err(|_e| {
+ ApiError::BadRequest(anyhow::anyhow!(
+ "Invalid time for done_if_after: {done_if_after_raw:?}"
+ ))
+ })?;
+
+ service
+ .tenant_time_travel_remote_storage(
+ &time_travel_req,
+ tenant_id,
+ timestamp_raw,
+ done_if_after_raw,
+ )
+ .await?;
+
+ json_response(StatusCode::OK, ())
+}
+
async fn handle_tenant_delete(
service: Arc,
req: Request,
@@ -477,6 +510,9 @@ pub fn make_router(
.put("/v1/tenant/:tenant_id/location_config", |r| {
tenant_service_handler(r, handle_tenant_location_config)
})
+ .put("/v1/tenant/:tenant_id/time_travel_remote_storage", |r| {
+ tenant_service_handler(r, handle_tenant_time_travel_remote_storage)
+ })
// Timeline operations
.delete("/v1/tenant/:tenant_id/timeline/:timeline_id", |r| {
tenant_service_handler(r, handle_tenant_timeline_delete)
diff --git a/control_plane/attachment_service/src/scheduler.rs b/control_plane/attachment_service/src/scheduler.rs
index 39d8d0a260..fb3c7f634c 100644
--- a/control_plane/attachment_service/src/scheduler.rs
+++ b/control_plane/attachment_service/src/scheduler.rs
@@ -175,10 +175,7 @@ impl Scheduler {
}
}
- pub(crate) fn schedule_shard(
- &mut self,
- hard_exclude: &[NodeId],
- ) -> Result {
+ pub(crate) fn schedule_shard(&self, hard_exclude: &[NodeId]) -> Result {
if self.nodes.is_empty() {
return Err(ScheduleError::NoPageservers);
}
diff --git a/control_plane/attachment_service/src/service.rs b/control_plane/attachment_service/src/service.rs
index 4082af3fe6..74e1296709 100644
--- a/control_plane/attachment_service/src/service.rs
+++ b/control_plane/attachment_service/src/service.rs
@@ -1,4 +1,5 @@
use std::{
+ borrow::Cow,
cmp::Ordering,
collections::{BTreeMap, HashMap, HashSet},
str::FromStr,
@@ -21,11 +22,11 @@ use pageserver_api::{
ReAttachRequest, ReAttachResponse, ReAttachResponseTenant, ValidateRequest,
ValidateResponse, ValidateResponseTenant,
},
- models,
models::{
- LocationConfig, LocationConfigMode, ShardParameters, TenantConfig, TenantCreateRequest,
- TenantLocationConfigRequest, TenantLocationConfigResponse, TenantShardLocation,
- TenantShardSplitRequest, TenantShardSplitResponse, TimelineCreateRequest, TimelineInfo,
+ self, LocationConfig, LocationConfigListResponse, LocationConfigMode, ShardParameters,
+ TenantConfig, TenantCreateRequest, TenantLocationConfigRequest,
+ TenantLocationConfigResponse, TenantShardLocation, TenantShardSplitRequest,
+ TenantShardSplitResponse, TenantTimeTravelRequest, TimelineCreateRequest, TimelineInfo,
},
shard::{ShardCount, ShardIdentity, ShardNumber, ShardStripeSize, TenantShardId},
};
@@ -167,84 +168,53 @@ impl Service {
/// Called once on startup, this function attempts to contact all pageservers to build an up-to-date
/// view of the world, and determine which pageservers are responsive.
#[instrument(skip_all)]
- async fn startup_reconcile(&self) {
+ async fn startup_reconcile(self: &Arc) {
// For all tenant shards, a vector of observed states on nodes (where None means
// indeterminate, same as in [`ObservedStateLocation`])
let mut observed = HashMap::new();
let mut nodes_online = HashSet::new();
- // TODO: issue these requests concurrently
- {
- let nodes = {
- let locked = self.inner.read().unwrap();
- locked.nodes.clone()
- };
- for node in nodes.values() {
- let http_client = reqwest::ClientBuilder::new()
- .timeout(Duration::from_secs(5))
- .build()
- .expect("Failed to construct HTTP client");
- let client = mgmt_api::Client::from_client(
- http_client,
- node.base_url(),
- self.config.jwt_token.as_deref(),
- );
+ // Startup reconciliation does I/O to other services: whether they
+ // are responsive or not, we should aim to finish within our deadline, because:
+ // - If we don't, a k8s readiness hook watching /ready will kill us.
+ // - While we're waiting for startup reconciliation, we are not fully
+ // available for end user operations like creating/deleting tenants and timelines.
+ //
+ // We set multiple deadlines to break up the time available between the phases of work: this is
+ // arbitrary, but avoids a situation where the first phase could burn our entire timeout period.
+ let start_at = Instant::now();
+ let node_scan_deadline = start_at
+ .checked_add(STARTUP_RECONCILE_TIMEOUT / 2)
+ .expect("Reconcile timeout is a modest constant");
- fn is_fatal(e: &mgmt_api::Error) -> bool {
- use mgmt_api::Error::*;
- match e {
- ReceiveBody(_) | ReceiveErrorBody(_) => false,
- ApiError(StatusCode::SERVICE_UNAVAILABLE, _)
- | ApiError(StatusCode::GATEWAY_TIMEOUT, _)
- | ApiError(StatusCode::REQUEST_TIMEOUT, _) => false,
- ApiError(_, _) => true,
- }
- }
+ let compute_notify_deadline = start_at
+ .checked_add((STARTUP_RECONCILE_TIMEOUT / 4) * 3)
+ .expect("Reconcile timeout is a modest constant");
- let list_response = backoff::retry(
- || client.list_location_config(),
- is_fatal,
- 1,
- 5,
- "Location config listing",
- &self.cancel,
- )
- .await;
- let Some(list_response) = list_response else {
- tracing::info!("Shutdown during startup_reconcile");
- return;
- };
+ // Accumulate a list of any tenant locations that ought to be detached
+ let mut cleanup = Vec::new();
- tracing::info!("Scanning shards on node {}...", node.id);
- match list_response {
- Err(e) => {
- tracing::warn!("Could not contact pageserver {} ({e})", node.id);
- // TODO: be more tolerant, do some retries, in case
- // pageserver is being restarted at the same time as we are
- }
- Ok(listing) => {
- tracing::info!(
- "Received {} shard statuses from pageserver {}, setting it to Active",
- listing.tenant_shards.len(),
- node.id
- );
- nodes_online.insert(node.id);
+ let node_listings = self.scan_node_locations(node_scan_deadline).await;
+ for (node_id, list_response) in node_listings {
+ let tenant_shards = list_response.tenant_shards;
+ tracing::info!(
+ "Received {} shard statuses from pageserver {}, setting it to Active",
+ tenant_shards.len(),
+ node_id
+ );
+ nodes_online.insert(node_id);
- for (tenant_shard_id, conf_opt) in listing.tenant_shards {
- observed.insert(tenant_shard_id, (node.id, conf_opt));
- }
- }
- }
+ for (tenant_shard_id, conf_opt) in tenant_shards {
+ observed.insert(tenant_shard_id, (node_id, conf_opt));
}
}
- let mut cleanup = Vec::new();
-
+ // List of tenants for which we will attempt to notify compute of their location at startup
let mut compute_notifications = Vec::new();
// Populate intent and observed states for all tenants, based on reported state on pageservers
- let (shard_count, nodes) = {
+ let shard_count = {
let mut locked = self.inner.write().unwrap();
let (nodes, tenants, scheduler) = locked.parts_mut();
@@ -288,18 +258,171 @@ impl Service {
}
}
- (tenants.len(), nodes.clone())
+ tenants.len()
};
// TODO: if any tenant's intent now differs from its loaded generation_pageserver, we should clear that
// generation_pageserver in the database.
- // Clean up any tenants that were found on pageservers but are not known to us.
+ // Emit compute hook notifications for all tenants which are already stably attached. Other tenants
+ // will emit compute hook notifications when they reconcile.
+ //
+ // Ordering: we must complete these notification attempts before doing any other reconciliation for the
+ // tenants named here, because otherwise our calls to notify() might race with more recent values
+ // generated by reconciliation.
+ let notify_failures = self
+ .compute_notify_many(compute_notifications, compute_notify_deadline)
+ .await;
+
+ // Compute notify is fallible. If it fails here, do not delay overall startup: set the
+ // flag on these shards that they have a pending notification.
+ // Update tenant state for any that failed to do their initial compute notify, so that they'll retry later.
+ {
+ let mut locked = self.inner.write().unwrap();
+ for tenant_shard_id in notify_failures.into_iter() {
+ if let Some(shard) = locked.tenants.get_mut(&tenant_shard_id) {
+ shard.pending_compute_notification = true;
+ }
+ }
+ }
+
+ // Finally, now that the service is up and running, launch reconcile operations for any tenants
+ // which require it: under normal circumstances this should only include tenants that were in some
+ // transient state before we restarted, or any tenants whose compute hooks failed above.
+ let reconcile_tasks = self.reconcile_all();
+ // We will not wait for these reconciliation tasks to run here: we're now done with startup and
+ // normal operations may proceed.
+
+ // Clean up any tenants that were found on pageservers but are not known to us. Do this in the
+ // background because it does not need to complete in order to proceed with other work.
+ if !cleanup.is_empty() {
+ tracing::info!("Cleaning up {} locations in the background", cleanup.len());
+ tokio::task::spawn({
+ let cleanup_self = self.clone();
+ async move { cleanup_self.cleanup_locations(cleanup).await }
+ });
+ }
+
+ tracing::info!("Startup complete, spawned {reconcile_tasks} reconciliation tasks ({shard_count} shards total)");
+ }
+
+ /// Used during [`Self::startup_reconcile`]: issue GETs to all nodes concurrently, with a deadline.
+ ///
+ /// The result includes only nodes which responded within the deadline
+ async fn scan_node_locations(
+ &self,
+ deadline: Instant,
+ ) -> HashMap {
+ let nodes = {
+ let locked = self.inner.read().unwrap();
+ locked.nodes.clone()
+ };
+
+ let mut node_results = HashMap::new();
+
+ let mut node_list_futs = FuturesUnordered::new();
+
+ for node in nodes.values() {
+ node_list_futs.push({
+ async move {
+ let http_client = reqwest::ClientBuilder::new()
+ .timeout(Duration::from_secs(5))
+ .build()
+ .expect("Failed to construct HTTP client");
+ let client = mgmt_api::Client::from_client(
+ http_client,
+ node.base_url(),
+ self.config.jwt_token.as_deref(),
+ );
+
+ fn is_fatal(e: &mgmt_api::Error) -> bool {
+ use mgmt_api::Error::*;
+ match e {
+ ReceiveBody(_) | ReceiveErrorBody(_) => false,
+ ApiError(StatusCode::SERVICE_UNAVAILABLE, _)
+ | ApiError(StatusCode::GATEWAY_TIMEOUT, _)
+ | ApiError(StatusCode::REQUEST_TIMEOUT, _) => false,
+ ApiError(_, _) => true,
+ }
+ }
+
+ tracing::info!("Scanning shards on node {}...", node.id);
+ let description = format!("List locations on {}", node.id);
+ let response = backoff::retry(
+ || client.list_location_config(),
+ is_fatal,
+ 1,
+ 5,
+ &description,
+ &self.cancel,
+ )
+ .await;
+
+ (node.id, response)
+ }
+ });
+ }
+
+ loop {
+ let (node_id, result) = tokio::select! {
+ next = node_list_futs.next() => {
+ match next {
+ Some(result) => result,
+ None =>{
+ // We got results for all our nodes
+ break;
+ }
+
+ }
+ },
+ _ = tokio::time::sleep(deadline.duration_since(Instant::now())) => {
+ // Give up waiting for anyone who hasn't responded: we will yield the results that we have
+ tracing::info!("Reached deadline while waiting for nodes to respond to location listing requests");
+ break;
+ }
+ };
+
+ let Some(list_response) = result else {
+ tracing::info!("Shutdown during startup_reconcile");
+ break;
+ };
+
+ match list_response {
+ Err(e) => {
+ tracing::warn!("Could not scan node {} ({e})", node_id);
+ }
+ Ok(listing) => {
+ node_results.insert(node_id, listing);
+ }
+ }
+ }
+
+ node_results
+ }
+
+ /// Used during [`Self::startup_reconcile`]: detach a list of unknown-to-us tenants from pageservers.
+ ///
+ /// This is safe to run in the background, because if we don't have this TenantShardId in our map of
+ /// tenants, then it is probably something incompletely deleted before: we will not fight with any
+ /// other task trying to attach it.
+ #[instrument(skip_all)]
+ async fn cleanup_locations(&self, cleanup: Vec<(TenantShardId, NodeId)>) {
+ let nodes = self.inner.read().unwrap().nodes.clone();
+
for (tenant_shard_id, node_id) in cleanup {
// A node reported a tenant_shard_id which is unknown to us: detach it.
- let node = nodes
- .get(&node_id)
- .expect("Always exists: only known nodes are scanned");
+ let Some(node) = nodes.get(&node_id) else {
+ // This is legitimate; we run in the background and [`Self::startup_reconcile`] might have identified
+ // a location to clean up on a node that has since been removed.
+ tracing::info!(
+ "Not cleaning up location {node_id}/{tenant_shard_id}: node not found"
+ );
+ continue;
+ };
+
+ if self.cancel.is_cancelled() {
+ break;
+ }
let client = mgmt_api::Client::new(node.base_url(), self.config.jwt_token.as_deref());
match client
@@ -332,21 +455,24 @@ impl Service {
}
}
}
+ }
- // Emit compute hook notifications for all tenants which are already stably attached. Other tenants
- // will emit compute hook notifications when they reconcile.
- //
- // Ordering: we must complete these notification attempts before doing any other reconciliation for the
- // tenants named here, because otherwise our calls to notify() might race with more recent values
- // generated by reconciliation.
-
- // Compute notify is fallible. If it fails here, do not delay overall startup: set the
- // flag on these shards that they have a pending notification.
+ /// Used during [`Self::startup_reconcile`]: issue many concurrent compute notifications.
+ ///
+ /// Returns a set of any shards for which notifications where not acked within the deadline.
+ async fn compute_notify_many(
+ &self,
+ notifications: Vec<(TenantShardId, NodeId)>,
+ deadline: Instant,
+ ) -> HashSet {
let compute_hook = self.inner.read().unwrap().compute_hook.clone();
+ let attempt_shards = notifications.iter().map(|i| i.0).collect::>();
+ let mut success_shards = HashSet::new();
+
// Construct an async stream of futures to invoke the compute notify function: we do this
// in order to subsequently use .buffered() on the stream to execute with bounded parallelism.
- let stream = futures::stream::iter(compute_notifications.into_iter())
+ let mut stream = futures::stream::iter(notifications.into_iter())
.map(|(tenant_shard_id, node_id)| {
let compute_hook = compute_hook.clone();
let cancel = self.cancel.clone();
@@ -357,33 +483,43 @@ impl Service {
node_id=%node_id,
"Failed to notify compute on startup for shard: {e}"
);
- Some(tenant_shard_id)
- } else {
None
+ } else {
+ Some(tenant_shard_id)
}
}
})
.buffered(compute_hook::API_CONCURRENCY);
- let notify_results = stream.collect::>().await;
- // Update tenant state for any that failed to do their initial compute notify, so that they'll retry later.
- {
- let mut locked = self.inner.write().unwrap();
- for tenant_shard_id in notify_results.into_iter().flatten() {
- if let Some(shard) = locked.tenants.get_mut(&tenant_shard_id) {
- shard.pending_compute_notification = true;
+ loop {
+ tokio::select! {
+ next = stream.next() => {
+ match next {
+ Some(Some(success_shard)) => {
+ // A notification succeeded
+ success_shards.insert(success_shard);
+ },
+ Some(None) => {
+ // A notification that failed
+ },
+ None => {
+ tracing::info!("Successfully sent all compute notifications");
+ break;
+ }
+ }
+ },
+ _ = tokio::time::sleep(deadline.duration_since(Instant::now())) => {
+ // Give up sending any that didn't succeed yet
+ tracing::info!("Reached deadline while sending compute notifications");
+ break;
}
- }
+ };
}
- // Finally, now that the service is up and running, launch reconcile operations for any tenants
- // which require it: under normal circumstances this should only include tenants that were in some
- // transient state before we restarted, or any tenants whose compute hooks failed above.
- let reconcile_tasks = self.reconcile_all();
- // We will not wait for these reconciliation tasks to run here: we're now done with startup and
- // normal operations may proceed.
-
- tracing::info!("Startup complete, spawned {reconcile_tasks} reconciliation tasks ({shard_count} shards total)");
+ attempt_shards
+ .difference(&success_shards)
+ .cloned()
+ .collect()
}
/// Long running background task that periodically wakes up and looks for shards that need
@@ -1194,6 +1330,95 @@ impl Service {
Ok(result)
}
+ pub(crate) async fn tenant_time_travel_remote_storage(
+ &self,
+ time_travel_req: &TenantTimeTravelRequest,
+ tenant_id: TenantId,
+ timestamp: Cow<'_, str>,
+ done_if_after: Cow<'_, str>,
+ ) -> Result<(), ApiError> {
+ let node = {
+ let locked = self.inner.read().unwrap();
+ // Just a sanity check to prevent misuse: the API expects that the tenant is fully
+ // detached everywhere, and nothing writes to S3 storage. Here, we verify that,
+ // but only at the start of the process, so it's really just to prevent operator
+ // mistakes.
+ for (shard_id, shard) in locked.tenants.range(TenantShardId::tenant_range(tenant_id)) {
+ if shard.intent.get_attached().is_some() || !shard.intent.get_secondary().is_empty()
+ {
+ return Err(ApiError::InternalServerError(anyhow::anyhow!(
+ "We want tenant to be attached in shard with tenant_shard_id={shard_id}"
+ )));
+ }
+ let maybe_attached = shard
+ .observed
+ .locations
+ .iter()
+ .filter_map(|(node_id, observed_location)| {
+ observed_location
+ .conf
+ .as_ref()
+ .map(|loc| (node_id, observed_location, loc.mode))
+ })
+ .find(|(_, _, mode)| *mode != LocationConfigMode::Detached);
+ if let Some((node_id, _observed_location, mode)) = maybe_attached {
+ return Err(ApiError::InternalServerError(anyhow::anyhow!("We observed attached={mode:?} tenant in node_id={node_id} shard with tenant_shard_id={shard_id}")));
+ }
+ }
+ let scheduler = &locked.scheduler;
+ // Right now we only perform the operation on a single node without parallelization
+ // TODO fan out the operation to multiple nodes for better performance
+ let node_id = scheduler.schedule_shard(&[])?;
+ let node = locked
+ .nodes
+ .get(&node_id)
+ .expect("Pageservers may not be deleted while lock is active");
+ node.clone()
+ };
+
+ // The shard count is encoded in the remote storage's URL, so we need to handle all historically used shard counts
+ let mut counts = time_travel_req
+ .shard_counts
+ .iter()
+ .copied()
+ .collect::>()
+ .into_iter()
+ .collect::>();
+ counts.sort_unstable();
+
+ for count in counts {
+ let shard_ids = (0..count.count())
+ .map(|i| TenantShardId {
+ tenant_id,
+ shard_number: ShardNumber(i),
+ shard_count: count,
+ })
+ .collect::>();
+ for tenant_shard_id in shard_ids {
+ let client =
+ mgmt_api::Client::new(node.base_url(), self.config.jwt_token.as_deref());
+
+ tracing::info!("Doing time travel recovery for shard {tenant_shard_id}",);
+
+ client
+ .tenant_time_travel_remote_storage(
+ tenant_shard_id,
+ ×tamp,
+ &done_if_after,
+ )
+ .await
+ .map_err(|e| {
+ ApiError::InternalServerError(anyhow::anyhow!(
+ "Error doing time travel recovery for shard {tenant_shard_id} on node {}: {e}",
+ node.id
+ ))
+ })?;
+ }
+ }
+
+ Ok(())
+ }
+
pub(crate) async fn tenant_delete(&self, tenant_id: TenantId) -> Result {
self.ensure_attached_wait(tenant_id).await?;
diff --git a/control_plane/attachment_service/src/tenant_state.rs b/control_plane/attachment_service/src/tenant_state.rs
index 4ec6fdca67..7970207e27 100644
--- a/control_plane/attachment_service/src/tenant_state.rs
+++ b/control_plane/attachment_service/src/tenant_state.rs
@@ -495,6 +495,13 @@ impl TenantState {
}
}
+ for node_id in self.observed.locations.keys() {
+ if self.intent.attached != Some(*node_id) && !self.intent.secondary.contains(node_id) {
+ // We have observed state that isn't part of our intent: need to clean it up.
+ return true;
+ }
+ }
+
// Even if there is no pageserver work to be done, if we have a pending notification to computes,
// wake up a reconciler to send it.
if self.pending_compute_notification {
diff --git a/control_plane/src/bin/neon_local.rs b/control_plane/src/bin/neon_local.rs
index a155e9ebb2..f824003d01 100644
--- a/control_plane/src/bin/neon_local.rs
+++ b/control_plane/src/bin/neon_local.rs
@@ -616,7 +616,7 @@ async fn handle_timeline(timeline_match: &ArgMatches, env: &mut local_env::Local
let tenant_id = get_tenant_id(create_match, env)?;
let new_branch_name = create_match
.get_one::("branch-name")
- .ok_or_else(|| anyhow!("No branch name provided"))?;
+ .ok_or_else(|| anyhow!("No branch name provided"))?; // TODO
let pg_version = create_match
.get_one::("pg-version")
@@ -652,6 +652,10 @@ async fn handle_timeline(timeline_match: &ArgMatches, env: &mut local_env::Local
let name = import_match
.get_one::("node-name")
.ok_or_else(|| anyhow!("No node name provided"))?;
+ let update_catalog = import_match
+ .get_one::("update-catalog")
+ .cloned()
+ .unwrap_or_default();
// Parse base inputs
let base_tarfile = import_match
@@ -694,6 +698,7 @@ async fn handle_timeline(timeline_match: &ArgMatches, env: &mut local_env::Local
None,
pg_version,
ComputeMode::Primary,
+ !update_catalog,
)?;
println!("Done");
}
@@ -831,6 +836,10 @@ async fn handle_endpoint(ep_match: &ArgMatches, env: &local_env::LocalEnv) -> Re
.get_one::("endpoint_id")
.map(String::to_string)
.unwrap_or_else(|| format!("ep-{branch_name}"));
+ let update_catalog = sub_args
+ .get_one::("update-catalog")
+ .cloned()
+ .unwrap_or_default();
let lsn = sub_args
.get_one::("lsn")
@@ -880,6 +889,7 @@ async fn handle_endpoint(ep_match: &ArgMatches, env: &local_env::LocalEnv) -> Re
http_port,
pg_version,
mode,
+ !update_catalog,
)?;
}
"start" => {
@@ -918,6 +928,11 @@ async fn handle_endpoint(ep_match: &ArgMatches, env: &local_env::LocalEnv) -> Re
.get(endpoint_id.as_str())
.ok_or_else(|| anyhow::anyhow!("endpoint {endpoint_id} not found"))?;
+ let create_test_user = sub_args
+ .get_one::("create-test-user")
+ .cloned()
+ .unwrap_or_default();
+
cplane.check_conflicting_endpoints(
endpoint.mode,
endpoint.tenant_id,
@@ -972,6 +987,7 @@ async fn handle_endpoint(ep_match: &ArgMatches, env: &local_env::LocalEnv) -> Re
pageservers,
remote_ext_config,
stripe_size.0 as usize,
+ create_test_user,
)
.await?;
}
@@ -1457,6 +1473,18 @@ fn cli() -> Command {
.required(false)
.default_value("1");
+ let update_catalog = Arg::new("update-catalog")
+ .value_parser(value_parser!(bool))
+ .long("update-catalog")
+ .help("If set, will set up the catalog for neon_superuser")
+ .required(false);
+
+ let create_test_user = Arg::new("create-test-user")
+ .value_parser(value_parser!(bool))
+ .long("create-test-user")
+ .help("If set, will create test user `user` and `neondb` database. Requires `update-catalog = true`")
+ .required(false);
+
Command::new("Neon CLI")
.arg_required_else_help(true)
.version(GIT_VERSION)
@@ -1517,6 +1545,7 @@ fn cli() -> Command {
.arg(Arg::new("end-lsn").long("end-lsn")
.help("Lsn the basebackup ends at"))
.arg(pg_version_arg.clone())
+ .arg(update_catalog.clone())
)
).subcommand(
Command::new("tenant")
@@ -1630,6 +1659,7 @@ fn cli() -> Command {
.required(false))
.arg(pg_version_arg.clone())
.arg(hot_standby_arg.clone())
+ .arg(update_catalog)
)
.subcommand(Command::new("start")
.about("Start postgres.\n If the endpoint doesn't exist yet, it is created.")
@@ -1637,6 +1667,7 @@ fn cli() -> Command {
.arg(endpoint_pageserver_id_arg.clone())
.arg(safekeepers_arg)
.arg(remote_ext_config_args)
+ .arg(create_test_user)
)
.subcommand(Command::new("reconfigure")
.about("Reconfigure the endpoint")
diff --git a/control_plane/src/endpoint.rs b/control_plane/src/endpoint.rs
index f1fe12e05f..bab7a70ce7 100644
--- a/control_plane/src/endpoint.rs
+++ b/control_plane/src/endpoint.rs
@@ -41,11 +41,15 @@ use std::net::SocketAddr;
use std::net::TcpStream;
use std::path::PathBuf;
use std::process::Command;
+use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
+use compute_api::spec::Database;
+use compute_api::spec::PgIdent;
use compute_api::spec::RemoteExtSpec;
+use compute_api::spec::Role;
use nix::sys::signal::kill;
use nix::sys::signal::Signal;
use serde::{Deserialize, Serialize};
@@ -122,6 +126,7 @@ impl ComputeControlPlane {
http_port: Option,
pg_version: u32,
mode: ComputeMode,
+ skip_pg_catalog_updates: bool,
) -> Result> {
let pg_port = pg_port.unwrap_or_else(|| self.get_port());
let http_port = http_port.unwrap_or_else(|| self.get_port() + 1);
@@ -140,7 +145,7 @@ impl ComputeControlPlane {
// before and after start are the same. So, skip catalog updates,
// with this we basically test a case of waking up an idle compute, where
// we also skip catalog updates in the cloud.
- skip_pg_catalog_updates: true,
+ skip_pg_catalog_updates,
features: vec![],
});
@@ -155,7 +160,7 @@ impl ComputeControlPlane {
http_port,
pg_port,
pg_version,
- skip_pg_catalog_updates: true,
+ skip_pg_catalog_updates,
features: vec![],
})?,
)?;
@@ -500,6 +505,7 @@ impl Endpoint {
pageservers: Vec<(Host, u16)>,
remote_ext_config: Option<&String>,
shard_stripe_size: usize,
+ create_test_user: bool,
) -> Result<()> {
if self.status() == EndpointStatus::Running {
anyhow::bail!("The endpoint is already running");
@@ -551,8 +557,26 @@ impl Endpoint {
cluster_id: None, // project ID: not used
name: None, // project name: not used
state: None,
- roles: vec![],
- databases: vec![],
+ roles: if create_test_user {
+ vec![Role {
+ name: PgIdent::from_str("test").unwrap(),
+ encrypted_password: None,
+ options: None,
+ }]
+ } else {
+ Vec::new()
+ },
+ databases: if create_test_user {
+ vec![Database {
+ name: PgIdent::from_str("neondb").unwrap(),
+ owner: PgIdent::from_str("test").unwrap(),
+ options: None,
+ restrict_conn: false,
+ invalid: false,
+ }]
+ } else {
+ Vec::new()
+ },
settings: None,
postgresql_conf: Some(postgresql_conf),
},
@@ -577,11 +601,16 @@ impl Endpoint {
.open(self.endpoint_path().join("compute.log"))?;
// Launch compute_ctl
- println!("Starting postgres node at '{}'", self.connstr());
+ let conn_str = self.connstr("cloud_admin", "postgres");
+ println!("Starting postgres node at '{}'", conn_str);
+ if create_test_user {
+ let conn_str = self.connstr("user", "neondb");
+ println!("Also at '{}'", conn_str);
+ }
let mut cmd = Command::new(self.env.neon_distrib_dir.join("compute_ctl"));
cmd.args(["--http-port", &self.http_address.port().to_string()])
.args(["--pgdata", self.pgdata().to_str().unwrap()])
- .args(["--connstr", &self.connstr()])
+ .args(["--connstr", &conn_str])
.args([
"--spec-path",
self.endpoint_path().join("spec.json").to_str().unwrap(),
@@ -652,7 +681,9 @@ impl Endpoint {
}
ComputeStatus::Empty
| ComputeStatus::ConfigurationPending
- | ComputeStatus::Configuration => {
+ | ComputeStatus::Configuration
+ | ComputeStatus::TerminationPending
+ | ComputeStatus::Terminated => {
bail!("unexpected compute status: {:?}", state.status)
}
}
@@ -783,13 +814,13 @@ impl Endpoint {
Ok(())
}
- pub fn connstr(&self) -> String {
+ pub fn connstr(&self, user: &str, db_name: &str) -> String {
format!(
"postgresql://{}@{}:{}/{}",
- "cloud_admin",
+ user,
self.pg_address.ip(),
self.pg_address.port(),
- "postgres"
+ db_name
)
}
}
diff --git a/libs/compute_api/src/responses.rs b/libs/compute_api/src/responses.rs
index 92bbf79cd4..fd0c90d447 100644
--- a/libs/compute_api/src/responses.rs
+++ b/libs/compute_api/src/responses.rs
@@ -52,6 +52,10 @@ pub enum ComputeStatus {
// compute will exit soon or is waiting for
// control-plane to terminate it.
Failed,
+ // Termination requested
+ TerminationPending,
+ // Terminated Postgres
+ Terminated,
}
fn rfc3339_serialize(x: &Option>, s: S) -> Result
diff --git a/libs/metrics/src/lib.rs b/libs/metrics/src/lib.rs
index 18786106d1..744fc18e61 100644
--- a/libs/metrics/src/lib.rs
+++ b/libs/metrics/src/lib.rs
@@ -201,6 +201,11 @@ impl GenericCounterPairVec