Build 060.28: implement Persistent Checkpoint and Startup Recovery
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
@@ -177,28 +178,26 @@ def release_postgres_test_lock(
|
||||
def count_other_test_connections(
|
||||
connection: psycopg.Connection[Any],
|
||||
) -> int:
|
||||
"""Посчитать оставшиеся соединения стенда и пула с тестовой базой."""
|
||||
"""Посчитать клиентские соединения с проверенной тестовой БД."""
|
||||
database_name = _validated_postgres_test_control_database_name(connection)
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database()
|
||||
AND application_name = %s
|
||||
FROM pg_catalog.pg_stat_activity
|
||||
WHERE datname = %s
|
||||
AND backend_type = 'client backend'
|
||||
AND application_name IS DISTINCT FROM %s
|
||||
""",
|
||||
(POSTGRES_TEST_APPLICATION_NAME,),
|
||||
(database_name, POSTGRES_TEST_CONTROL_APPLICATION_NAME),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if (
|
||||
not isinstance(row, tuple)
|
||||
or len(row) != 1
|
||||
or isinstance(row[0], bool)
|
||||
or not isinstance(row[0], int)
|
||||
):
|
||||
raise RuntimeError("PostgreSQL returned an invalid connection count")
|
||||
|
||||
return row[0]
|
||||
return _validated_postgres_count(
|
||||
row,
|
||||
error_message="PostgreSQL returned an invalid connection count",
|
||||
)
|
||||
|
||||
|
||||
def wait_for_postgres_advisory_lock_waiters(
|
||||
@@ -260,6 +259,138 @@ def wait_for_postgres_advisory_lock_waiters(
|
||||
time.sleep(0.01)
|
||||
|
||||
|
||||
def wait_for_postgres_relation_lock_waiters(
|
||||
connection: psycopg.Connection[Any],
|
||||
*,
|
||||
relation_name: str,
|
||||
expected_count: int,
|
||||
timeout_seconds: float = 5.0,
|
||||
) -> None:
|
||||
"""Дождаться точного числа ожидающих блокировку заданной таблицы."""
|
||||
if not isinstance(relation_name, str) or not relation_name.strip():
|
||||
raise ValueError("relation_name must be a non-empty string")
|
||||
|
||||
if (
|
||||
isinstance(expected_count, bool)
|
||||
or not isinstance(expected_count, int)
|
||||
or expected_count <= 0
|
||||
):
|
||||
raise ValueError("expected_count must be a positive integer")
|
||||
|
||||
if (
|
||||
isinstance(timeout_seconds, bool)
|
||||
or not isinstance(timeout_seconds, (int, float))
|
||||
or not math.isfinite(float(timeout_seconds))
|
||||
or timeout_seconds <= 0
|
||||
):
|
||||
raise ValueError("timeout_seconds must be a positive finite number")
|
||||
|
||||
database_name = _validated_postgres_test_control_database_name(connection)
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT pg_catalog.to_regclass(%s)::oid",
|
||||
(relation_name,),
|
||||
)
|
||||
relation_row = cursor.fetchone()
|
||||
|
||||
if (
|
||||
not isinstance(relation_row, tuple)
|
||||
or len(relation_row) != 1
|
||||
or isinstance(relation_row[0], bool)
|
||||
or not isinstance(relation_row[0], int)
|
||||
or relation_row[0] <= 0
|
||||
):
|
||||
raise RuntimeError(
|
||||
"PostgreSQL did not resolve the requested test relation"
|
||||
)
|
||||
|
||||
relation_oid = relation_row[0]
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
|
||||
while True:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM pg_catalog.pg_locks
|
||||
WHERE locktype = 'relation'
|
||||
AND database = (
|
||||
SELECT oid
|
||||
FROM pg_catalog.pg_database
|
||||
WHERE datname = %s
|
||||
)
|
||||
AND relation = %s
|
||||
AND NOT granted
|
||||
""",
|
||||
(database_name, relation_oid),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
observed_count = _validated_postgres_count(
|
||||
row,
|
||||
error_message=(
|
||||
"PostgreSQL returned an invalid relation-lock waiter count"
|
||||
),
|
||||
)
|
||||
|
||||
if observed_count == expected_count:
|
||||
return
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError(
|
||||
"PostgreSQL did not observe all relation-lock callers; "
|
||||
f"expected {expected_count}, observed {observed_count}."
|
||||
)
|
||||
|
||||
time.sleep(0.01)
|
||||
|
||||
|
||||
def _validated_postgres_test_control_database_name(
|
||||
connection: psycopg.Connection[Any],
|
||||
) -> str:
|
||||
"""Повторно подтвердить безопасную БД и управляющее соединение."""
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT current_database(), current_setting('application_name')
|
||||
"""
|
||||
)
|
||||
identity = cursor.fetchone()
|
||||
|
||||
if (
|
||||
not isinstance(identity, tuple)
|
||||
or len(identity) != 2
|
||||
or not isinstance(identity[0], str)
|
||||
or not _SAFE_DATABASE_NAME.fullmatch(identity[0])
|
||||
or identity[1] != POSTGRES_TEST_CONTROL_APPLICATION_NAME
|
||||
):
|
||||
raise RuntimeError(
|
||||
"PostgreSQL connection is not the validated test control "
|
||||
"connection."
|
||||
)
|
||||
|
||||
return identity[0]
|
||||
|
||||
|
||||
def _validated_postgres_count(
|
||||
row: object,
|
||||
*,
|
||||
error_message: str,
|
||||
) -> int:
|
||||
"""Проверить форму и тип результата PostgreSQL COUNT(*)."""
|
||||
if (
|
||||
not isinstance(row, tuple)
|
||||
or len(row) != 1
|
||||
or isinstance(row[0], bool)
|
||||
or not isinstance(row[0], int)
|
||||
or row[0] < 0
|
||||
):
|
||||
raise RuntimeError(error_message)
|
||||
|
||||
return row[0]
|
||||
|
||||
|
||||
def _validate_local_endpoint(parameters: Mapping[str, object]) -> None:
|
||||
service = str(parameters.get("service", "")).strip()
|
||||
host = str(parameters.get("host", "")).strip()
|
||||
|
||||
@@ -30,12 +30,17 @@ RUNTIME_CLEANUP_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
OWNED_TASK_NAMES = frozenset(
|
||||
{
|
||||
"application-shutdown",
|
||||
"market-data-storage-shutdown",
|
||||
"market-data-storage-startup",
|
||||
"telegram-polling",
|
||||
"trade-stream-market-processing",
|
||||
"trade-stream-receive",
|
||||
"trade-stream-runtime",
|
||||
"trade-stream-runtime-recovery",
|
||||
"trade-stream-scheduler",
|
||||
"trade-stream-startup-recovery",
|
||||
"trade-stream-state-hydration",
|
||||
"trade-stream-startup",
|
||||
}
|
||||
)
|
||||
@@ -173,7 +178,10 @@ def active_owned_task_names() -> tuple[str, ...]:
|
||||
for task in asyncio.all_tasks()
|
||||
if task is not current_task
|
||||
and not task.done()
|
||||
and task.get_name() in OWNED_TASK_NAMES
|
||||
and (
|
||||
task.get_name() in OWNED_TASK_NAMES
|
||||
or task.get_name().startswith("persistent-application-")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user