mirror of
https://github.com/neondatabase/neon.git
synced 2026-10-28 14:36:51 +00:00
Add safekeeper scrubber test (#8785)
The test is very rudimentary, it only checks that before and after tenant deletion, we can run `scan_metadata` for the safekeeper node kind. Also, we don't actually expect any uploaded data, for that we don't have enough WAL (needs to create at least one S3-uploaded file, the scrubber doesn't recognize partial files yet). The `scan_metadata` scrubber subcommand is extended to support either specifying a database connection string, which was previously the only way, and required a database to be present, or specifying the timeline information manually via json. This is ideal for testing scenarios because in those, the number of timelines is usually limited, but it is involved to spin up a database just to write the timeline information.
This commit is contained in:
@@ -4625,12 +4625,20 @@ class Safekeeper(LogUtils):
|
||||
wait_until(20, 0.5, paused)
|
||||
|
||||
|
||||
# TODO: Replace with `StrEnum` when we upgrade to python 3.11
|
||||
class NodeKind(str, Enum):
|
||||
PAGESERVER = "pageserver"
|
||||
SAFEKEEPER = "safekeeper"
|
||||
|
||||
|
||||
class StorageScrubber:
|
||||
def __init__(self, env: NeonEnv, log_dir: Path):
|
||||
self.env = env
|
||||
self.log_dir = log_dir
|
||||
|
||||
def scrubber_cli(self, args: list[str], timeout) -> str:
|
||||
def scrubber_cli(
|
||||
self, args: list[str], timeout, extra_env: Optional[Dict[str, str]] = None
|
||||
) -> str:
|
||||
assert isinstance(self.env.pageserver_remote_storage, S3Storage)
|
||||
s3_storage = self.env.pageserver_remote_storage
|
||||
|
||||
@@ -4645,6 +4653,9 @@ class StorageScrubber:
|
||||
if s3_storage.endpoint is not None:
|
||||
env.update({"AWS_ENDPOINT_URL": s3_storage.endpoint})
|
||||
|
||||
if extra_env is not None:
|
||||
env.update(extra_env)
|
||||
|
||||
base_args = [
|
||||
str(self.env.neon_binpath / "storage_scrubber"),
|
||||
f"--controller-api={self.env.storage_controller.api_root()}",
|
||||
@@ -4672,18 +4683,43 @@ class StorageScrubber:
|
||||
assert stdout is not None
|
||||
return stdout
|
||||
|
||||
def scan_metadata(self, post_to_storage_controller: bool = False) -> Tuple[bool, Any]:
|
||||
def scan_metadata_safekeeper(
|
||||
self,
|
||||
timeline_lsns: List[Dict[str, Any]],
|
||||
cloud_admin_api_url: str,
|
||||
cloud_admin_api_token: str,
|
||||
) -> Tuple[bool, Any]:
|
||||
extra_env = {
|
||||
"CLOUD_ADMIN_API_URL": cloud_admin_api_url,
|
||||
"CLOUD_ADMIN_API_TOKEN": cloud_admin_api_token,
|
||||
}
|
||||
return self.scan_metadata(
|
||||
node_kind=NodeKind.SAFEKEEPER, timeline_lsns=timeline_lsns, extra_env=extra_env
|
||||
)
|
||||
|
||||
def scan_metadata(
|
||||
self,
|
||||
post_to_storage_controller: bool = False,
|
||||
node_kind: NodeKind = NodeKind.PAGESERVER,
|
||||
timeline_lsns: Optional[List[Dict[str, Any]]] = None,
|
||||
extra_env: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[bool, Any]:
|
||||
"""
|
||||
Returns the health status and the metadata summary.
|
||||
"""
|
||||
args = ["scan-metadata", "--node-kind", "pageserver", "--json"]
|
||||
args = ["scan-metadata", "--node-kind", node_kind.value, "--json"]
|
||||
if post_to_storage_controller:
|
||||
args.append("--post")
|
||||
stdout = self.scrubber_cli(args, timeout=30)
|
||||
if timeline_lsns is not None:
|
||||
args.append("--timeline-lsns")
|
||||
args.append(json.dumps(timeline_lsns))
|
||||
stdout = self.scrubber_cli(args, timeout=30, extra_env=extra_env)
|
||||
|
||||
try:
|
||||
summary = json.loads(stdout)
|
||||
healthy = not summary["with_errors"] and not summary["with_warnings"]
|
||||
# summary does not contain "with_warnings" if node_kind is the safekeeper
|
||||
no_warnings = "with_warnings" not in summary or not summary["with_warnings"]
|
||||
healthy = not summary["with_errors"] and no_warnings
|
||||
return healthy, summary
|
||||
except:
|
||||
log.error("Failed to decode JSON output from `scan-metadata`. Dumping stdout:")
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import json
|
||||
from threading import Thread
|
||||
|
||||
import pytest
|
||||
from fixtures.common_types import Lsn, TenantId, TimelineId
|
||||
from fixtures.log_helper import log
|
||||
from fixtures.neon_fixtures import (
|
||||
NeonEnvBuilder,
|
||||
PgBin,
|
||||
@@ -17,6 +19,8 @@ from fixtures.pageserver.utils import (
|
||||
from fixtures.remote_storage import RemoteStorageKind, s3_storage
|
||||
from fixtures.utils import run_pg_bench_small, wait_until
|
||||
from requests.exceptions import ReadTimeout
|
||||
from werkzeug.wrappers.request import Request
|
||||
from werkzeug.wrappers.response import Response
|
||||
|
||||
|
||||
def error_tolerant_delete(ps_http, tenant_id):
|
||||
@@ -322,7 +326,7 @@ def test_tenant_delete_races_timeline_creation(neon_env_builder: NeonEnvBuilder)
|
||||
env.pageserver.stop()
|
||||
|
||||
|
||||
def test_tenant_delete_scrubber(pg_bin: PgBin, neon_env_builder: NeonEnvBuilder):
|
||||
def test_tenant_delete_scrubber(pg_bin: PgBin, make_httpserver, neon_env_builder: NeonEnvBuilder):
|
||||
"""
|
||||
Validate that creating and then deleting the tenant both survives the scrubber,
|
||||
and that one can run the scrubber without problems.
|
||||
@@ -347,6 +351,45 @@ def test_tenant_delete_scrubber(pg_bin: PgBin, neon_env_builder: NeonEnvBuilder)
|
||||
healthy, _ = env.storage_scrubber.scan_metadata()
|
||||
assert healthy
|
||||
|
||||
timeline_lsns = {
|
||||
"tenant_id": f"{tenant_id}",
|
||||
"timeline_id": f"{timeline_id}",
|
||||
"timeline_start_lsn": f"{last_flush_lsn}",
|
||||
"backup_lsn": f"{last_flush_lsn}",
|
||||
}
|
||||
|
||||
cloud_admin_url = f"http://{make_httpserver.host}:{make_httpserver.port}/"
|
||||
cloud_admin_token = ""
|
||||
|
||||
def get_branches(request: Request):
|
||||
# Compare definition with `BranchData` struct
|
||||
dummy_data = {
|
||||
"id": "test-branch-id",
|
||||
"created_at": "", # TODO
|
||||
"updated_at": "", # TODO
|
||||
"name": "testbranchname",
|
||||
"project_id": "test-project-id",
|
||||
"timeline_id": f"{timeline_id}",
|
||||
"default": False,
|
||||
"deleted": False,
|
||||
"logical_size": 42000,
|
||||
"physical_size": 42000,
|
||||
"written_size": 42000,
|
||||
}
|
||||
# This test does all its own compute configuration (by passing explicit pageserver ID to Workload functions),
|
||||
# so we send controller notifications to /dev/null to prevent it fighting the test for control of the compute.
|
||||
log.info(f"got get_branches request: {request.json}")
|
||||
return Response(json.dumps(dummy_data), content_type="application/json", status=200)
|
||||
|
||||
make_httpserver.expect_request("/branches", method="GET").respond_with_handler(get_branches)
|
||||
|
||||
healthy, _ = env.storage_scrubber.scan_metadata_safekeeper(
|
||||
timeline_lsns=[timeline_lsns],
|
||||
cloud_admin_api_url=cloud_admin_url,
|
||||
cloud_admin_api_token=cloud_admin_token,
|
||||
)
|
||||
assert healthy
|
||||
|
||||
env.start()
|
||||
ps_http = env.pageserver.http_client()
|
||||
ps_http.tenant_delete(tenant_id)
|
||||
@@ -354,3 +397,10 @@ def test_tenant_delete_scrubber(pg_bin: PgBin, neon_env_builder: NeonEnvBuilder)
|
||||
|
||||
healthy, _ = env.storage_scrubber.scan_metadata()
|
||||
assert healthy
|
||||
|
||||
healthy, _ = env.storage_scrubber.scan_metadata_safekeeper(
|
||||
timeline_lsns=[timeline_lsns],
|
||||
cloud_admin_api_url=cloud_admin_url,
|
||||
cloud_admin_api_token=cloud_admin_token,
|
||||
)
|
||||
assert healthy
|
||||
|
||||
Reference in New Issue
Block a user