random operations test (#10986)

## Problem
We need to test the stability of Neon.

## Summary of changes
The test runs random operations on a Neon project. It performs via the
Public API calls the following operations: `create a branch`, `delete a
branch`, `add a read-only endpoint`, `delete a read-only endpoint`,
`restore a branch to a random position in the past`. All the branches
and endpoints are loaded with `pgbench`.

---------

Co-authored-by: Peter Bendel <peterbendel@neon.tech>
Co-authored-by: Alexander Bayandin <alexander@neon.tech>
This commit is contained in:
a-masterov
2025-04-17 19:59:35 +00:00
committed by GitHub
co-authored by Peter Bendel Alexander Bayandin
parent 748539b222
commit 6c2e5c044c
5 changed files with 795 additions and 6 deletions
+142 -5
View File
@@ -22,19 +22,62 @@ def connection_parameters_to_env(params: dict[str, str]) -> dict[str, str]:
}
# Some API calls not yet implemented.
# You may want to copy not-yet-implemented methods from the PR https://github.com/neondatabase/neon/pull/11305
class NeonAPI:
def __init__(self, neon_api_key: str, neon_api_base_url: str):
self.__neon_api_key = neon_api_key
self.__neon_api_base_url = neon_api_base_url.strip("/")
self.retry_if_possible = False
self.attempts = 10
self.sleep_before_retry = 1
self.retries524 = 0
self.retries4xx = 0
def __request(self, method: str | bytes, endpoint: str, **kwargs: Any) -> requests.Response:
if "headers" not in kwargs:
kwargs["headers"] = {}
kwargs["headers"] = kwargs.get("headers", {})
kwargs["headers"]["Authorization"] = f"Bearer {self.__neon_api_key}"
resp = requests.request(method, f"{self.__neon_api_base_url}{endpoint}", **kwargs)
log.debug("%s %s returned a %d: %s", method, endpoint, resp.status_code, resp.text)
resp.raise_for_status()
for attempt in range(self.attempts):
retry = False
resp = requests.request(method, f"{self.__neon_api_base_url}{endpoint}", **kwargs)
if resp.status_code >= 400:
log.error(
"%s %s returned a %d: %s",
method,
endpoint,
resp.status_code,
resp.text if resp.status_code != 524 else "CloudFlare error page",
)
else:
log.debug("%s %s returned a %d: %s", method, endpoint, resp.status_code, resp.text)
if not self.retry_if_possible:
resp.raise_for_status()
break
elif resp.status_code >= 400:
if resp.status_code == 422:
if resp.json()["message"] == "branch not ready yet":
retry = True
self.retries4xx += 1
elif resp.status_code == 423 and resp.json()["message"] in {
"endpoint is in some transitive state, could not suspend",
"project already has running conflicting operations, scheduling of new ones is prohibited",
}:
retry = True
self.retries4xx += 1
elif resp.status_code == 524:
log.info("The request was timed out, trying to get operations")
retry = True
self.retries524 += 1
if retry:
log.info("Retrying, attempt %s/%s", attempt + 1, self.attempts)
time.sleep(self.sleep_before_retry)
continue
else:
resp.raise_for_status()
break
else:
raise RuntimeError("Max retry count is reached")
return resp
@@ -101,6 +144,96 @@ class NeonAPI:
return cast("dict[str, Any]", resp.json())
def create_branch(
self,
project_id: str,
branch_name: str | None = None,
parent_id: str | None = None,
parent_lsn: str | None = None,
parent_timestamp: str | None = None,
protected: bool | None = None,
archived: bool | None = None,
init_source: str | None = None,
add_endpoint=True,
) -> dict[str, Any]:
data: dict[str, Any] = {}
if add_endpoint:
data["endpoints"] = [{"type": "read_write"}]
data["branch"] = {}
if parent_id:
data["branch"]["parent_id"] = parent_id
if branch_name:
data["branch"]["name"] = branch_name
if parent_lsn is not None:
data["branch"]["parent_lsn"] = parent_lsn
if parent_timestamp is not None:
data["branch"]["parent_timestamp"] = parent_timestamp
if protected is not None:
data["branch"]["protected"] = protected
if init_source is not None:
data["branch"]["init_source"] = init_source
if archived is not None:
data["branch"]["archived"] = archived
if not data["branch"]:
data.pop("branch")
resp = self.__request(
"POST",
f"/projects/{project_id}/branches",
headers={
"Accept": "application/json",
"Content-Type": "application/json",
},
json=data,
)
return cast("dict[str, Any]", resp.json())
def get_branch_details(self, project_id: str, branch_id: str) -> dict[str, Any]:
resp = self.__request(
"GET",
f"/projects/{project_id}/branches/{branch_id}",
headers={
"Accept": "application/json",
},
)
return cast("dict[str, Any]", resp.json())
def delete_branch(self, project_id: str, branch_id: str) -> dict[str, Any]:
resp = self.__request(
"DELETE",
f"/projects/{project_id}/branches/{branch_id}",
headers={
"Accept": "application/json",
},
)
return cast("dict[str, Any]", resp.json())
def restore_branch(
self,
project_id: str,
branch_id: str,
source_branch_id: str,
source_lsn: str | None,
source_timestamp: str | None,
preserve_under_name: str | None,
):
data = {"source_branch_id": source_branch_id}
if source_lsn:
data["source_lsn"] = source_lsn
if source_timestamp:
data["source_timestamp"] = source_timestamp
if preserve_under_name:
data["preserve_under_name"] = preserve_under_name
log.info("Data: %s", data)
resp = self.__request(
"POST",
f"/projects/{project_id}/branches/{branch_id}/restore",
headers={
"Accept": "application/json",
},
json=data,
)
return cast("dict[str, Any]", resp.json())
def start_endpoint(
self,
project_id: str,
@@ -176,6 +309,10 @@ class NeonAPI:
return cast("dict[str, Any]", resp.json())
def delete_endpoint(self, project_id: str, endpoint_id: str) -> dict[str, Any]:
resp = self.__request("DELETE", f"/projects/{project_id}/endpoints/{endpoint_id}")
return cast("dict[str,Any]", resp.json())
def get_connection_uri(
self,
project_id: str,
+4 -1
View File
@@ -3185,6 +3185,7 @@ class PgBin:
command: list[str],
env: Env | None = None,
cwd: str | Path | None = None,
stderr_pipe: Any | None = None,
) -> subprocess.Popen[Any]:
"""
Run one of the postgres binaries, not waiting for it to finish
@@ -3202,7 +3203,9 @@ class PgBin:
log.info(f"Running command '{' '.join(command)}'")
env = self._build_env(env)
self._log_env(env)
return subprocess.Popen(command, env=env, cwd=cwd, stdout=subprocess.PIPE, text=True)
return subprocess.Popen(
command, env=env, cwd=cwd, stdout=subprocess.PIPE, stderr=stderr_pipe, text=True
)
def run(
self,