Skip to content

Commit 9657fbc

Browse files
authored
pageserver: add and stabilize import chaos test (neondatabase#11982)
## Problem Test coverage of timeline imports is lacking. ## Summary of changes This PR adds a chaos import test. It runs an import while injecting various chaos events in the environment. All the commits that follow the test fix various issues that were surfaced by it. Closes neondatabase#10191
1 parent dd50155 commit 9657fbc

File tree

7 files changed

+413
-43
lines changed

7 files changed

+413
-43
lines changed

pageserver/src/http/routes.rs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,18 @@ impl From<crate::tenant::secondary::SecondaryTenantError> for ApiError {
370370
}
371371
}
372372

373+
impl From<crate::tenant::FinalizeTimelineImportError> for ApiError {
374+
fn from(err: crate::tenant::FinalizeTimelineImportError) -> ApiError {
375+
use crate::tenant::FinalizeTimelineImportError::*;
376+
match err {
377+
ImportTaskStillRunning => {
378+
ApiError::ResourceUnavailable("Import task still running".into())
379+
}
380+
ShuttingDown => ApiError::ShuttingDown,
381+
}
382+
}
383+
}
384+
373385
// Helper function to construct a TimelineInfo struct for a timeline
374386
async fn build_timeline_info(
375387
timeline: &Arc<Timeline>,
@@ -3533,10 +3545,7 @@ async fn activate_post_import_handler(
35333545

35343546
tenant.wait_to_become_active(ACTIVE_TENANT_TIMEOUT).await?;
35353547

3536-
tenant
3537-
.finalize_importing_timeline(timeline_id)
3538-
.await
3539-
.map_err(ApiError::InternalServerError)?;
3548+
tenant.finalize_importing_timeline(timeline_id).await?;
35403549

35413550
match tenant.get_timeline(timeline_id, false) {
35423551
Ok(_timeline) => {

pageserver/src/tenant.rs

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -864,6 +864,14 @@ impl Debug for SetStoppingError {
864864
}
865865
}
866866

867+
#[derive(thiserror::Error, Debug)]
868+
pub(crate) enum FinalizeTimelineImportError {
869+
#[error("Import task not done yet")]
870+
ImportTaskStillRunning,
871+
#[error("Shutting down")]
872+
ShuttingDown,
873+
}
874+
867875
/// Arguments to [`TenantShard::create_timeline`].
868876
///
869877
/// Not usable as an idempotency key for timeline creation because if [`CreateTimelineParamsBranch::ancestor_start_lsn`]
@@ -1150,10 +1158,20 @@ impl TenantShard {
11501158
ctx,
11511159
)?;
11521160
let disk_consistent_lsn = timeline.get_disk_consistent_lsn();
1153-
anyhow::ensure!(
1154-
disk_consistent_lsn.is_valid(),
1155-
"Timeline {tenant_id}/{timeline_id} has invalid disk_consistent_lsn"
1156-
);
1161+
1162+
if !disk_consistent_lsn.is_valid() {
1163+
// As opposed to normal timelines which get initialised with a disk consitent LSN
1164+
// via initdb, imported timelines start from 0. If the import task stops before
1165+
// it advances disk consitent LSN, allow it to resume.
1166+
let in_progress_import = import_pgdata
1167+
.as_ref()
1168+
.map(|import| !import.is_done())
1169+
.unwrap_or(false);
1170+
if !in_progress_import {
1171+
anyhow::bail!("Timeline {tenant_id}/{timeline_id} has invalid disk_consistent_lsn");
1172+
}
1173+
}
1174+
11571175
assert_eq!(
11581176
disk_consistent_lsn,
11591177
metadata.disk_consistent_lsn(),
@@ -1247,20 +1265,25 @@ impl TenantShard {
12471265
}
12481266
}
12491267

1250-
// Sanity check: a timeline should have some content.
1251-
anyhow::ensure!(
1252-
ancestor.is_some()
1253-
|| timeline
1254-
.layers
1255-
.read()
1256-
.await
1257-
.layer_map()
1258-
.expect("currently loading, layer manager cannot be shutdown already")
1259-
.iter_historic_layers()
1260-
.next()
1261-
.is_some(),
1262-
"Timeline has no ancestor and no layer files"
1263-
);
1268+
if disk_consistent_lsn.is_valid() {
1269+
// Sanity check: a timeline should have some content.
1270+
// Exception: importing timelines might not yet have any
1271+
anyhow::ensure!(
1272+
ancestor.is_some()
1273+
|| timeline
1274+
.layers
1275+
.read()
1276+
.await
1277+
.layer_map()
1278+
.expect(
1279+
"currently loading, layer manager cannot be shutdown already"
1280+
)
1281+
.iter_historic_layers()
1282+
.next()
1283+
.is_some(),
1284+
"Timeline has no ancestor and no layer files"
1285+
);
1286+
}
12641287

12651288
Ok(TimelineInitAndSyncResult::ReadyToActivate)
12661289
}
@@ -2860,13 +2883,13 @@ impl TenantShard {
28602883
pub(crate) async fn finalize_importing_timeline(
28612884
&self,
28622885
timeline_id: TimelineId,
2863-
) -> anyhow::Result<()> {
2886+
) -> Result<(), FinalizeTimelineImportError> {
28642887
let timeline = {
28652888
let locked = self.timelines_importing.lock().unwrap();
28662889
match locked.get(&timeline_id) {
28672890
Some(importing_timeline) => {
28682891
if !importing_timeline.import_task_handle.is_finished() {
2869-
return Err(anyhow::anyhow!("Import task not done yet"));
2892+
return Err(FinalizeTimelineImportError::ImportTaskStillRunning);
28702893
}
28712894

28722895
importing_timeline.timeline.clone()
@@ -2879,8 +2902,13 @@ impl TenantShard {
28792902

28802903
timeline
28812904
.remote_client
2882-
.schedule_index_upload_for_import_pgdata_finalize()?;
2883-
timeline.remote_client.wait_completion().await?;
2905+
.schedule_index_upload_for_import_pgdata_finalize()
2906+
.map_err(|_err| FinalizeTimelineImportError::ShuttingDown)?;
2907+
timeline
2908+
.remote_client
2909+
.wait_completion()
2910+
.await
2911+
.map_err(|_err| FinalizeTimelineImportError::ShuttingDown)?;
28842912

28852913
self.timelines_importing
28862914
.lock()
@@ -3484,8 +3512,9 @@ impl TenantShard {
34843512
let mut timelines_importing = self.timelines_importing.lock().unwrap();
34853513
timelines_importing
34863514
.drain()
3487-
.for_each(|(_timeline_id, importing_timeline)| {
3488-
importing_timeline.shutdown();
3515+
.for_each(|(timeline_id, importing_timeline)| {
3516+
let span = tracing::info_span!("importing_timeline_shutdown", %timeline_id);
3517+
js.spawn(async move { importing_timeline.shutdown().instrument(span).await });
34893518
});
34903519
}
34913520
// test_long_timeline_create_then_tenant_delete is leaning on this message

pageserver/src/tenant/timeline/import_pgdata.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,11 @@ pub(crate) struct ImportingTimeline {
2525
}
2626

2727
impl ImportingTimeline {
28-
pub(crate) fn shutdown(self) {
28+
pub(crate) async fn shutdown(self) {
2929
self.import_task_handle.abort();
30+
let _ = self.import_task_handle.await;
31+
32+
self.timeline.remote_client.shutdown().await;
3033
}
3134
}
3235

@@ -93,6 +96,11 @@ pub async fn doit(
9396
);
9497
}
9598

99+
timeline
100+
.remote_client
101+
.schedule_index_upload_for_file_changes()?;
102+
timeline.remote_client.wait_completion().await?;
103+
96104
// Communicate that shard is done.
97105
// Ensure at-least-once delivery of the upcall to storage controller
98106
// before we mark the task as done and never come here again.

pageserver/src/tenant/timeline/import_pgdata/flow.rs

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -113,14 +113,14 @@ async fn run_v1(
113113
let plan_hash = hasher.finish();
114114

115115
if let Some(progress) = &import_progress {
116-
if plan_hash != progress.import_plan_hash {
117-
anyhow::bail!("Import plan does not match storcon metadata");
118-
}
119-
120116
// Handle collisions on jobs of unequal length
121117
if progress.jobs != plan.jobs.len() {
122118
anyhow::bail!("Import plan job length does not match storcon metadata")
123119
}
120+
121+
if plan_hash != progress.import_plan_hash {
122+
anyhow::bail!("Import plan does not match storcon metadata");
123+
}
124124
}
125125

126126
pausable_failpoint!("import-timeline-pre-execute-pausable");
@@ -218,6 +218,19 @@ impl Planner {
218218
checkpoint_buf,
219219
)));
220220

221+
// Sort the tasks by the key ranges they handle.
222+
// The plan being generated here needs to be stable across invocations
223+
// of this method.
224+
self.tasks.sort_by_key(|task| match task {
225+
AnyImportTask::SingleKey(key) => (key.key, key.key.next()),
226+
AnyImportTask::RelBlocks(rel_blocks) => {
227+
(rel_blocks.key_range.start, rel_blocks.key_range.end)
228+
}
229+
AnyImportTask::SlruBlocks(slru_blocks) => {
230+
(slru_blocks.key_range.start, slru_blocks.key_range.end)
231+
}
232+
});
233+
221234
// Assigns parts of key space to later parallel jobs
222235
let mut last_end_key = Key::MIN;
223236
let mut current_chunk = Vec::new();
@@ -426,6 +439,8 @@ impl Plan {
426439
}));
427440
},
428441
maybe_complete_job_idx = work.next() => {
442+
pausable_failpoint!("import-task-complete-pausable");
443+
429444
match maybe_complete_job_idx {
430445
Some(Ok((job_idx, res))) => {
431446
assert!(last_completed_job_idx.checked_add(1).unwrap() == job_idx);
@@ -440,6 +455,9 @@ impl Plan {
440455
import_plan_hash,
441456
};
442457

458+
timeline.remote_client.schedule_index_upload_for_file_changes()?;
459+
timeline.remote_client.wait_completion().await?;
460+
443461
storcon_client.put_timeline_import_status(
444462
timeline.tenant_shard_id,
445463
timeline.timeline_id,
@@ -640,7 +658,11 @@ impl Hash for ImportSingleKeyTask {
640658
let ImportSingleKeyTask { key, buf } = self;
641659

642660
key.hash(state);
643-
buf.hash(state);
661+
// The key value might not have a stable binary representation.
662+
// For instance, the db directory uses an unstable hash-map.
663+
// To work around this we are a bit lax here and only hash the
664+
// size of the buffer which must be consistent.
665+
buf.len().hash(state);
644666
}
645667
}
646668

@@ -915,7 +937,7 @@ impl ChunkProcessingJob {
915937
let guard = timeline.layers.read().await;
916938
let existing_layer = guard.try_get_from_key(&desc.key());
917939
if let Some(layer) = existing_layer {
918-
if layer.metadata().generation != timeline.generation {
940+
if layer.metadata().generation == timeline.generation {
919941
return Err(anyhow::anyhow!(
920942
"Import attempted to rewrite layer file in the same generation: {}",
921943
layer.local_path()

storage_controller/src/service.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3922,6 +3922,11 @@ impl Service {
39223922
})
39233923
}
39243924

3925+
#[instrument(skip_all, fields(
3926+
tenant_id=%req.tenant_shard_id.tenant_id,
3927+
shard_id=%req.tenant_shard_id.shard_slug(),
3928+
timeline_id=%req.timeline_id,
3929+
))]
39253930
pub(crate) async fn handle_timeline_shard_import_progress(
39263931
self: &Arc<Self>,
39273932
req: TimelineImportStatusRequest,
@@ -3971,6 +3976,11 @@ impl Service {
39713976
})
39723977
}
39733978

3979+
#[instrument(skip_all, fields(
3980+
tenant_id=%req.tenant_shard_id.tenant_id,
3981+
shard_id=%req.tenant_shard_id.shard_slug(),
3982+
timeline_id=%req.timeline_id,
3983+
))]
39743984
pub(crate) async fn handle_timeline_shard_import_progress_upcall(
39753985
self: &Arc<Self>,
39763986
req: PutTimelineImportStatusRequest,

test_runner/fixtures/neon_fixtures.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,29 @@ def to_config_key_value(self) -> tuple[str, dict[str, Any]]:
404404
return ("tracing", value)
405405

406406

407+
@dataclass
408+
class PageserverImportConfig:
409+
import_job_concurrency: int
410+
import_job_soft_size_limit: int
411+
import_job_checkpoint_threshold: int
412+
413+
@staticmethod
414+
def default() -> PageserverImportConfig:
415+
return PageserverImportConfig(
416+
import_job_concurrency=4,
417+
import_job_soft_size_limit=512 * 1024,
418+
import_job_checkpoint_threshold=4,
419+
)
420+
421+
def to_config_key_value(self) -> tuple[str, dict[str, Any]]:
422+
value = {
423+
"import_job_concurrency": self.import_job_concurrency,
424+
"import_job_soft_size_limit": self.import_job_soft_size_limit,
425+
"import_job_checkpoint_threshold": self.import_job_checkpoint_threshold,
426+
}
427+
return ("timeline_import_config", value)
428+
429+
407430
class NeonEnvBuilder:
408431
"""
409432
Builder object to create a Neon runtime environment
@@ -454,6 +477,7 @@ def __init__(
454477
pageserver_wal_receiver_protocol: PageserverWalReceiverProtocol | None = None,
455478
pageserver_get_vectored_concurrent_io: str | None = None,
456479
pageserver_tracing_config: PageserverTracingConfig | None = None,
480+
pageserver_import_config: PageserverImportConfig | None = None,
457481
):
458482
self.repo_dir = repo_dir
459483
self.rust_log_override = rust_log_override
@@ -511,6 +535,7 @@ def __init__(
511535
)
512536

513537
self.pageserver_tracing_config = pageserver_tracing_config
538+
self.pageserver_import_config = pageserver_import_config
514539

515540
self.pageserver_default_tenant_config_compaction_algorithm: dict[str, Any] | None = (
516541
pageserver_default_tenant_config_compaction_algorithm
@@ -1179,6 +1204,10 @@ def __init__(self, config: NeonEnvBuilder):
11791204
self.pageserver_wal_receiver_protocol = config.pageserver_wal_receiver_protocol
11801205
self.pageserver_get_vectored_concurrent_io = config.pageserver_get_vectored_concurrent_io
11811206
self.pageserver_tracing_config = config.pageserver_tracing_config
1207+
if config.pageserver_import_config is None:
1208+
self.pageserver_import_config = PageserverImportConfig.default()
1209+
else:
1210+
self.pageserver_import_config = config.pageserver_import_config
11821211

11831212
# Create the neon_local's `NeonLocalInitConf`
11841213
cfg: dict[str, Any] = {
@@ -1258,12 +1287,6 @@ def __init__(self, config: NeonEnvBuilder):
12581287
"no_sync": True,
12591288
# Look for gaps in WAL received from safekeepeers
12601289
"validate_wal_contiguity": True,
1261-
# TODO(vlad): make these configurable through the builder
1262-
"timeline_import_config": {
1263-
"import_job_concurrency": 4,
1264-
"import_job_soft_size_limit": 512 * 1024,
1265-
"import_job_checkpoint_threshold": 4,
1266-
},
12671290
}
12681291

12691292
# Batching (https://github.com/neondatabase/neon/issues/9377):
@@ -1325,6 +1348,12 @@ def __init__(self, config: NeonEnvBuilder):
13251348

13261349
ps_cfg[key] = value
13271350

1351+
if self.pageserver_import_config is not None:
1352+
key, value = self.pageserver_import_config.to_config_key_value()
1353+
1354+
if key not in ps_cfg:
1355+
ps_cfg[key] = value
1356+
13281357
# Create a corresponding NeonPageserver object
13291358
ps = NeonPageserver(
13301359
self, ps_id, port=pageserver_port, az_id=ps_cfg["availability_zone"]

0 commit comments

Comments
 (0)