Update critical code
This commit is contained in:
@@ -64,6 +64,10 @@ class StorageConfig(BaseModel):
|
|||||||
default="NORMAL",
|
default="NORMAL",
|
||||||
description="Режим синхронизации: NORMAL, FULL, OFF"
|
description="Режим синхронизации: NORMAL, FULL, OFF"
|
||||||
)
|
)
|
||||||
|
retention_days: int = Field(
|
||||||
|
default=7,
|
||||||
|
description="Дней хранения обработанных записей (для автоочистки)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PostgreSQLConfig(BaseModel):
|
class PostgreSQLConfig(BaseModel):
|
||||||
|
|||||||
@@ -91,14 +91,14 @@ class StorageHandler(BaseHandler):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
"""Корректное завершение работы обработчика."""
|
"""Корректное завершение работы обработчика.
|
||||||
if self.storage:
|
|
||||||
try:
|
Примечание: НЕ закрываем Storage singleton здесь, так как он может
|
||||||
self.storage.close()
|
использоваться другими компонентами (например, для синхронизации с PostgreSQL).
|
||||||
self.logger.info("Storage handler closed")
|
Storage закрывается отдельно при полном завершении приложения.
|
||||||
except Exception as e:
|
"""
|
||||||
self.logger.error(f"Error closing storage: {e}", exc_info=True)
|
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
|
self.logger.info("Storage handler shutdown complete")
|
||||||
|
|
||||||
def get_stats(self) -> Dict[str, Any]:
|
def get_stats(self) -> Dict[str, Any]:
|
||||||
"""Получение статистики обработчика."""
|
"""Получение статистики обработчика."""
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
с поддержкой пакетной отправки, connection pooling, retry с backoff.
|
с поддержкой пакетной отправки, connection pooling, retry с backoff.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import queue
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
from queue import Queue, Empty
|
from queue import Queue, Empty
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any, Tuple
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from config import config
|
from config import config
|
||||||
@@ -55,34 +57,89 @@ class PostgreSQLClient:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Инициализация клиента PostgreSQL."""
|
"""Инициализация клиента PostgreSQL."""
|
||||||
# Проверяем, что инициализация выполняется только один раз
|
# Защита от race condition при инициализации singleton
|
||||||
if hasattr(self, '_initialized'):
|
with self._lock:
|
||||||
return
|
# Проверяем, что инициализация выполняется только один раз
|
||||||
|
if hasattr(self, '_initialized') and self._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
self.config = config.postgresql
|
self.config = config.postgresql
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
# Инициализируем атрибуты по умолчанию
|
# Инициализируем атрибуты по умолчанию
|
||||||
self.connection_pool: Optional[pool.ThreadedConnectionPool] = None
|
self.connection_pool: Optional[pool.ThreadedConnectionPool] = None
|
||||||
self.message_queue: Queue[Dict[str, Any]] = Queue()
|
self.message_queue: Queue[Dict[str, Any]] = Queue(maxsize=config.general.buffer_size)
|
||||||
self.running = False
|
self.running = False
|
||||||
self.forwarder_thread: Optional[threading.Thread] = None
|
self.forwarder_thread: Optional[threading.Thread] = None
|
||||||
self.connection_status = ConnectionStatus.DISCONNECTED
|
self.connection_status = ConnectionStatus.DISCONNECTED
|
||||||
|
|
||||||
# Статистика
|
# Статистика с блокировкой для потокобезопасности
|
||||||
self.sent_count = 0
|
self._stats_lock = threading.Lock()
|
||||||
self.failed_count = 0
|
self._sent_count = 0
|
||||||
self.retry_count = 0
|
self._failed_count = 0
|
||||||
self.reconnect_count = 0
|
self._retry_count = 0
|
||||||
self._initialized = False
|
self._reconnect_count = 0
|
||||||
|
self._synced_count = 0 # Количество синхронизированных из SQLite
|
||||||
|
|
||||||
if not POSTGRESQL_AVAILABLE:
|
# Флаг для запуска синхронизации после восстановления соединения
|
||||||
self.logger.error("PostgreSQL client library not available")
|
self._needs_sync = True
|
||||||
return
|
self._last_sync_time = 0.0
|
||||||
|
self._sync_interval = 30.0 # Интервал синхронизации в секундах
|
||||||
|
|
||||||
# Инициализируем клиент
|
if not POSTGRESQL_AVAILABLE:
|
||||||
self._init_client()
|
self.logger.error("PostgreSQL client library not available")
|
||||||
self._initialized = True
|
self._initialized = True # Отмечаем как инициализированный, чтобы не повторять
|
||||||
|
return
|
||||||
|
|
||||||
|
# Инициализируем клиент
|
||||||
|
self._init_client()
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
# Потокобезопасные свойства для статистики
|
||||||
|
@property
|
||||||
|
def sent_count(self) -> int:
|
||||||
|
with self._stats_lock:
|
||||||
|
return self._sent_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def failed_count(self) -> int:
|
||||||
|
with self._stats_lock:
|
||||||
|
return self._failed_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def retry_count(self) -> int:
|
||||||
|
with self._stats_lock:
|
||||||
|
return self._retry_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reconnect_count(self) -> int:
|
||||||
|
with self._stats_lock:
|
||||||
|
return self._reconnect_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def synced_count(self) -> int:
|
||||||
|
with self._stats_lock:
|
||||||
|
return self._synced_count
|
||||||
|
|
||||||
|
def _increment_sent(self, count: int = 1) -> None:
|
||||||
|
with self._stats_lock:
|
||||||
|
self._sent_count += count
|
||||||
|
|
||||||
|
def _increment_failed(self, count: int = 1) -> None:
|
||||||
|
with self._stats_lock:
|
||||||
|
self._failed_count += count
|
||||||
|
|
||||||
|
def _increment_retry(self) -> None:
|
||||||
|
with self._stats_lock:
|
||||||
|
self._retry_count += 1
|
||||||
|
|
||||||
|
def _increment_reconnect(self) -> None:
|
||||||
|
with self._stats_lock:
|
||||||
|
self._reconnect_count += 1
|
||||||
|
|
||||||
|
def _increment_synced(self, count: int = 1) -> None:
|
||||||
|
with self._stats_lock:
|
||||||
|
self._synced_count += count
|
||||||
|
|
||||||
def _init_client(self) -> None:
|
def _init_client(self) -> None:
|
||||||
"""Инициализация пула соединений PostgreSQL."""
|
"""Инициализация пула соединений PostgreSQL."""
|
||||||
@@ -194,9 +251,9 @@ class PostgreSQLClient:
|
|||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
"is_extended": can_id > 0x7FF
|
"is_extended": can_id > 0x7FF
|
||||||
})
|
})
|
||||||
except:
|
except queue.Full:
|
||||||
# Очередь переполнена - пропускаем сообщение
|
# Очередь переполнена - пропускаем сообщение
|
||||||
self.failed_count += 1
|
self._increment_failed()
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -204,7 +261,7 @@ class PostgreSQLClient:
|
|||||||
f"Failed to queue message for PostgreSQL: {e}",
|
f"Failed to queue message for PostgreSQL: {e}",
|
||||||
exc_info=True
|
exc_info=True
|
||||||
)
|
)
|
||||||
self.failed_count += 1
|
self._increment_failed()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def write_messages_batch(self, messages: List[Dict[str, Any]], block: bool = False) -> int:
|
def write_messages_batch(self, messages: List[Dict[str, Any]], block: bool = False) -> int:
|
||||||
@@ -227,7 +284,7 @@ class PostgreSQLClient:
|
|||||||
if self.connection_status != ConnectionStatus.CONNECTED:
|
if self.connection_status != ConnectionStatus.CONNECTED:
|
||||||
if not self._health_check():
|
if not self._health_check():
|
||||||
# Соединение недоступно - пропускаем батч без ошибки
|
# Соединение недоступно - пропускаем батч без ошибки
|
||||||
self.failed_count += len(messages)
|
self._increment_failed(len(messages))
|
||||||
return 0
|
return 0
|
||||||
else:
|
else:
|
||||||
self.connection_status = ConnectionStatus.CONNECTED
|
self.connection_status = ConnectionStatus.CONNECTED
|
||||||
@@ -236,7 +293,7 @@ class PostgreSQLClient:
|
|||||||
queue_usage = self.message_queue.qsize() / self.message_queue.maxsize if self.message_queue.maxsize > 0 else 0
|
queue_usage = self.message_queue.qsize() / self.message_queue.maxsize if self.message_queue.maxsize > 0 else 0
|
||||||
if queue_usage > 0.9 and not block:
|
if queue_usage > 0.9 and not block:
|
||||||
# Очередь почти переполнена - пропускаем батч
|
# Очередь почти переполнена - пропускаем батч
|
||||||
self.failed_count += len(messages)
|
self._increment_failed(len(messages))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Добавляем сообщения в очередь для асинхронной отправки
|
# Добавляем сообщения в очередь для асинхронной отправки
|
||||||
@@ -248,7 +305,7 @@ class PostgreSQLClient:
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
self.message_queue.put_nowait(msg)
|
self.message_queue.put_nowait(msg)
|
||||||
except:
|
except queue.Full:
|
||||||
# Очередь переполнена - пропускаем оставшиеся сообщения
|
# Очередь переполнена - пропускаем оставшиеся сообщения
|
||||||
break
|
break
|
||||||
added_count += 1
|
added_count += 1
|
||||||
@@ -257,7 +314,7 @@ class PostgreSQLClient:
|
|||||||
break
|
break
|
||||||
|
|
||||||
if added_count < len(messages):
|
if added_count < len(messages):
|
||||||
self.failed_count += (len(messages) - added_count)
|
self._increment_failed(len(messages) - added_count)
|
||||||
|
|
||||||
return added_count
|
return added_count
|
||||||
|
|
||||||
@@ -280,7 +337,7 @@ class PostgreSQLClient:
|
|||||||
if self.connection_status != ConnectionStatus.CONNECTED:
|
if self.connection_status != ConnectionStatus.CONNECTED:
|
||||||
if not self._health_check():
|
if not self._health_check():
|
||||||
self.logger.warning("PostgreSQL connection not available, skipping batch")
|
self.logger.warning("PostgreSQL connection not available, skipping batch")
|
||||||
self.failed_count += len(messages)
|
self._increment_failed(len(messages))
|
||||||
return 0
|
return 0
|
||||||
else:
|
else:
|
||||||
self.connection_status = ConnectionStatus.CONNECTED
|
self.connection_status = ConnectionStatus.CONNECTED
|
||||||
@@ -290,7 +347,7 @@ class PostgreSQLClient:
|
|||||||
# Получаем соединение из пула
|
# Получаем соединение из пула
|
||||||
conn = self.connection_pool.getconn()
|
conn = self.connection_pool.getconn()
|
||||||
if not conn:
|
if not conn:
|
||||||
self.failed_count += len(messages)
|
self._increment_failed(len(messages))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -303,8 +360,8 @@ class PostgreSQLClient:
|
|||||||
|
|
||||||
values = []
|
values = []
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
from datetime import datetime
|
# Используем UTC для согласованности времени
|
||||||
ts = datetime.fromtimestamp(msg["timestamp"])
|
ts = datetime.fromtimestamp(msg["timestamp"], tz=timezone.utc)
|
||||||
values.append((
|
values.append((
|
||||||
ts,
|
ts,
|
||||||
msg["interface"],
|
msg["interface"],
|
||||||
@@ -322,7 +379,7 @@ class PostgreSQLClient:
|
|||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
||||||
sent = len(messages)
|
sent = len(messages)
|
||||||
self.sent_count += sent
|
self._increment_sent(sent)
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
f"Sent {sent} messages to PostgreSQL",
|
f"Sent {sent} messages to PostgreSQL",
|
||||||
extra={"batch_size": sent}
|
extra={"batch_size": sent}
|
||||||
@@ -332,17 +389,62 @@ class PostgreSQLClient:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
if conn:
|
if conn:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
self.failed_count += len(messages)
|
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
f"Failed to send messages batch to PostgreSQL: {e}",
|
f"Failed to send messages batch to PostgreSQL: {e}",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
extra={"batch_size": len(messages)}
|
extra={"batch_size": len(messages)}
|
||||||
)
|
)
|
||||||
return 0
|
# Не увеличиваем failed_count здесь - это делает _send_messages_batch_with_retry
|
||||||
|
raise # Пробрасываем исключение для retry механизма
|
||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
self.connection_pool.putconn(conn)
|
self.connection_pool.putconn(conn)
|
||||||
|
|
||||||
|
def _send_messages_batch_with_retry(self, messages: List[Dict[str, Any]]) -> int:
|
||||||
|
"""
|
||||||
|
Отправка батча сообщений с retry и exponential backoff.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: Список словарей с данными сообщений
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Количество успешно отправленных сообщений
|
||||||
|
"""
|
||||||
|
if not messages:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
max_retries = self.config.max_retries
|
||||||
|
base_backoff = self.config.retry_backoff
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
return self._send_messages_batch(messages)
|
||||||
|
except Exception as e:
|
||||||
|
self._increment_retry()
|
||||||
|
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
# Exponential backoff: 1s, 2s, 4s...
|
||||||
|
delay = base_backoff * (2 ** attempt)
|
||||||
|
self.logger.warning(
|
||||||
|
f"PostgreSQL send failed (attempt {attempt + 1}/{max_retries}), "
|
||||||
|
f"retrying in {delay}s: {e}"
|
||||||
|
)
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
# Проверяем соединение перед повторной попыткой
|
||||||
|
if not self._health_check():
|
||||||
|
self.logger.warning("PostgreSQL connection lost, attempting reconnect")
|
||||||
|
self._reconnect()
|
||||||
|
else:
|
||||||
|
# Все попытки исчерпаны
|
||||||
|
self.logger.error(
|
||||||
|
f"All {max_retries} retries failed for batch of {len(messages)} messages"
|
||||||
|
)
|
||||||
|
self._increment_failed(len(messages))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
def _health_check(self) -> bool:
|
def _health_check(self) -> bool:
|
||||||
"""Проверка здоровья соединения с PostgreSQL."""
|
"""Проверка здоровья соединения с PostgreSQL."""
|
||||||
if not self.connection_pool:
|
if not self.connection_pool:
|
||||||
@@ -363,13 +465,80 @@ class PostgreSQLClient:
|
|||||||
self.logger.debug(f"PostgreSQL health check failed: {e}")
|
self.logger.debug(f"PostgreSQL health check failed: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _sync_from_sqlite(self) -> int:
|
||||||
|
"""
|
||||||
|
Синхронизация необработанных записей из SQLite в PostgreSQL.
|
||||||
|
|
||||||
|
Читает записи с processed=0 из SQLite и отправляет их в PostgreSQL.
|
||||||
|
После успешной отправки помечает записи как обработанные.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Количество синхронизированных сообщений
|
||||||
|
"""
|
||||||
|
if self.connection_status != ConnectionStatus.CONNECTED:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Импортируем storage здесь, чтобы избежать циклических импортов
|
||||||
|
from storage import get_storage
|
||||||
|
storage = get_storage()
|
||||||
|
|
||||||
|
# Получаем необработанные сообщения из SQLite
|
||||||
|
unprocessed = storage.get_unprocessed_messages(limit=1000)
|
||||||
|
if not unprocessed:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Syncing {len(unprocessed)} unprocessed messages from SQLite to PostgreSQL"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Конвертируем записи SQLite в формат для PostgreSQL
|
||||||
|
# Формат из SQLite: (id, timestamp, interface, can_id, can_id_hex, is_extended, dlc, data, data_hex)
|
||||||
|
messages = []
|
||||||
|
sqlite_ids = []
|
||||||
|
for row in unprocessed:
|
||||||
|
sqlite_id, ts, interface, can_id, can_id_hex, is_extended, dlc, data, data_hex = row
|
||||||
|
sqlite_ids.append(sqlite_id)
|
||||||
|
messages.append({
|
||||||
|
"interface": interface,
|
||||||
|
"can_id": can_id,
|
||||||
|
"can_id_hex": can_id_hex or hex(can_id),
|
||||||
|
"dlc": dlc,
|
||||||
|
"data": data,
|
||||||
|
"data_hex": data_hex or (data.hex().upper() if isinstance(data, bytes) else ""),
|
||||||
|
"timestamp": ts,
|
||||||
|
"is_extended": bool(is_extended) if is_extended is not None else (can_id > 0x7FF)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Отправляем в PostgreSQL напрямую (не через очередь)
|
||||||
|
sent_count = self._send_messages_batch(messages)
|
||||||
|
|
||||||
|
if sent_count > 0:
|
||||||
|
# Помечаем успешно отправленные как обработанные
|
||||||
|
# Помечаем все, так как _send_messages_batch либо отправляет всё, либо ничего
|
||||||
|
marked = storage.mark_as_processed(sqlite_ids)
|
||||||
|
self._increment_synced(marked)
|
||||||
|
self.logger.info(
|
||||||
|
f"Synced {sent_count} messages from SQLite, marked {marked} as processed"
|
||||||
|
)
|
||||||
|
return sent_count
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(
|
||||||
|
f"Error syncing from SQLite: {e}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
def _reconnect(self) -> None:
|
def _reconnect(self) -> None:
|
||||||
"""Переподключение к PostgreSQL."""
|
"""Переподключение к PostgreSQL."""
|
||||||
if self.connection_status == ConnectionStatus.CONNECTING:
|
if self.connection_status == ConnectionStatus.CONNECTING:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.connection_status = ConnectionStatus.CONNECTING
|
self.connection_status = ConnectionStatus.CONNECTING
|
||||||
self.reconnect_count += 1
|
self._increment_reconnect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Закрываем старый пул
|
# Закрываем старый пул
|
||||||
@@ -392,9 +561,30 @@ class PostgreSQLClient:
|
|||||||
|
|
||||||
batch = []
|
batch = []
|
||||||
last_flush_time = time.time()
|
last_flush_time = time.time()
|
||||||
|
was_connected = self.connection_status == ConnectionStatus.CONNECTED
|
||||||
|
|
||||||
while self.running or not self.message_queue.empty():
|
while self.running or not self.message_queue.empty():
|
||||||
try:
|
try:
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Проверяем восстановление соединения и запускаем синхронизацию
|
||||||
|
is_connected = self.connection_status == ConnectionStatus.CONNECTED
|
||||||
|
if is_connected:
|
||||||
|
# Синхронизация при восстановлении соединения или по интервалу
|
||||||
|
should_sync = (
|
||||||
|
(not was_connected and is_connected) or # Соединение восстановлено
|
||||||
|
(self._needs_sync) or # Первая синхронизация
|
||||||
|
(current_time - self._last_sync_time >= self._sync_interval) # По интервалу
|
||||||
|
)
|
||||||
|
if should_sync:
|
||||||
|
synced = self._sync_from_sqlite()
|
||||||
|
self._last_sync_time = current_time
|
||||||
|
self._needs_sync = False
|
||||||
|
if synced > 0:
|
||||||
|
self.logger.debug(f"Synced {synced} messages from SQLite")
|
||||||
|
|
||||||
|
was_connected = is_connected
|
||||||
|
|
||||||
# Собираем сообщения в батч
|
# Собираем сообщения в батч
|
||||||
try:
|
try:
|
||||||
message = self.message_queue.get(timeout=0.1)
|
message = self.message_queue.get(timeout=0.1)
|
||||||
@@ -403,7 +593,6 @@ class PostgreSQLClient:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Отправляем батч если он заполнен или прошло достаточно времени
|
# Отправляем батч если он заполнен или прошло достаточно времени
|
||||||
current_time = time.time()
|
|
||||||
should_flush = (
|
should_flush = (
|
||||||
len(batch) >= self.config.batch_size or
|
len(batch) >= self.config.batch_size or
|
||||||
(batch and (current_time - last_flush_time) >= self.config.flush_interval)
|
(batch and (current_time - last_flush_time) >= self.config.flush_interval)
|
||||||
@@ -411,8 +600,8 @@ class PostgreSQLClient:
|
|||||||
|
|
||||||
if should_flush:
|
if should_flush:
|
||||||
if batch:
|
if batch:
|
||||||
# Отправляем батч напрямую в PostgreSQL
|
# Отправляем батч с retry механизмом
|
||||||
self._send_messages_batch(batch)
|
self._send_messages_batch_with_retry(batch)
|
||||||
batch = []
|
batch = []
|
||||||
last_flush_time = current_time
|
last_flush_time = current_time
|
||||||
|
|
||||||
@@ -423,15 +612,19 @@ class PostgreSQLClient:
|
|||||||
)
|
)
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
|
|
||||||
# Отправляем оставшиеся сообщения
|
# Отправляем оставшиеся сообщения с retry
|
||||||
if batch:
|
if batch:
|
||||||
self._send_messages_batch(batch)
|
self._send_messages_batch_with_retry(batch)
|
||||||
|
|
||||||
|
# Финальная синхронизация перед остановкой
|
||||||
|
self._sync_from_sqlite()
|
||||||
|
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"PostgreSQL forwarder loop stopped",
|
"PostgreSQL forwarder loop stopped",
|
||||||
extra={
|
extra={
|
||||||
"sent_count": self.sent_count,
|
"sent_count": self.sent_count,
|
||||||
"failed_count": self.failed_count
|
"failed_count": self.failed_count,
|
||||||
|
"synced_count": self.synced_count
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -505,6 +698,7 @@ class PostgreSQLClient:
|
|||||||
"failed_count": self.failed_count,
|
"failed_count": self.failed_count,
|
||||||
"retry_count": self.retry_count,
|
"retry_count": self.retry_count,
|
||||||
"reconnect_count": self.reconnect_count,
|
"reconnect_count": self.reconnect_count,
|
||||||
|
"synced_count": self.synced_count,
|
||||||
"queue_size": self.message_queue.qsize(),
|
"queue_size": self.message_queue.qsize(),
|
||||||
"host": self.config.host if self.config.enabled else None,
|
"host": self.config.host if self.config.enabled else None,
|
||||||
"database": self.config.database if self.config.enabled else None
|
"database": self.config.database if self.config.enabled else None
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
Использует очередь для асинхронной обработки, чтобы не блокировать чтение CAN сообщений.
|
Использует очередь для асинхронной обработки, чтобы не блокировать чтение CAN сообщений.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import queue
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from queue import Queue, Empty
|
from queue import Queue, Empty
|
||||||
@@ -46,10 +47,35 @@ class MessageProcessor:
|
|||||||
self.running = False
|
self.running = False
|
||||||
self.processing_thread: Optional[threading.Thread] = None
|
self.processing_thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
# Статистика
|
# Статистика с блокировкой для потокобезопасности
|
||||||
self.processed_count = 0
|
self._stats_lock = threading.Lock()
|
||||||
self.dropped_count = 0
|
self._processed_count = 0
|
||||||
self.queue_full_warnings = 0
|
self._dropped_count = 0
|
||||||
|
self._queue_full_warnings = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def processed_count(self) -> int:
|
||||||
|
with self._stats_lock:
|
||||||
|
return self._processed_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dropped_count(self) -> int:
|
||||||
|
with self._stats_lock:
|
||||||
|
return self._dropped_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def queue_full_warnings(self) -> int:
|
||||||
|
with self._stats_lock:
|
||||||
|
return self._queue_full_warnings
|
||||||
|
|
||||||
|
def _increment_processed(self, count: int = 1) -> None:
|
||||||
|
with self._stats_lock:
|
||||||
|
self._processed_count += count
|
||||||
|
|
||||||
|
def _increment_dropped(self, count: int = 1) -> None:
|
||||||
|
with self._stats_lock:
|
||||||
|
self._dropped_count += count
|
||||||
|
self._queue_full_warnings += count
|
||||||
|
|
||||||
# Инициализируем обработчики
|
# Инициализируем обработчики
|
||||||
if handlers is None:
|
if handlers is None:
|
||||||
@@ -130,10 +156,9 @@ class MessageProcessor:
|
|||||||
# Неблокирующий режим - быстрое добавление
|
# Неблокирующий режим - быстрое добавление
|
||||||
self.message_queue.put_nowait(frame)
|
self.message_queue.put_nowait(frame)
|
||||||
return True
|
return True
|
||||||
except:
|
except queue.Full:
|
||||||
# Очередь переполнена - пропускаем сообщение
|
# Очередь переполнена - пропускаем сообщение
|
||||||
self.dropped_count += 1
|
self._increment_dropped()
|
||||||
self.queue_full_warnings += 1
|
|
||||||
|
|
||||||
# Логируем предупреждение периодически (не каждое сообщение)
|
# Логируем предупреждение периодически (не каждое сообщение)
|
||||||
if self.queue_full_warnings % 1000 == 0:
|
if self.queue_full_warnings % 1000 == 0:
|
||||||
@@ -148,6 +173,11 @@ class MessageProcessor:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
# Неожиданная ошибка
|
||||||
|
self.logger.debug(f"Unexpected error in enqueue: {e}")
|
||||||
|
self._increment_dropped()
|
||||||
|
return False
|
||||||
|
|
||||||
def get_queue_usage(self) -> float:
|
def get_queue_usage(self) -> float:
|
||||||
"""
|
"""
|
||||||
@@ -287,8 +317,8 @@ class MessageProcessor:
|
|||||||
extra={"batch_size": len(batch)}
|
extra={"batch_size": len(batch)}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Обновляем счетчик обработанных сообщений
|
# Обновляем счетчик обработанных сообщений (атомарно)
|
||||||
self.processed_count += len(batch)
|
self._increment_processed(len(batch))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
|
|||||||
@@ -49,6 +49,20 @@ class CANBusHandler:
|
|||||||
self.error_count = 0
|
self.error_count = 0
|
||||||
self.last_message_time: Optional[float] = None
|
self.last_message_time: Optional[float] = None
|
||||||
|
|
||||||
|
# Кэшируем ссылки для быстрого доступа (избегаем рефлексии в hot path)
|
||||||
|
self._processor = None
|
||||||
|
self._has_backpressure = False
|
||||||
|
self._enqueue_method = None
|
||||||
|
self._get_queue_usage_method = None
|
||||||
|
|
||||||
|
if hasattr(message_callback, '__self__'):
|
||||||
|
processor = getattr(message_callback, '__self__', None)
|
||||||
|
if processor and hasattr(processor, 'get_queue_usage') and hasattr(processor, 'enqueue'):
|
||||||
|
self._processor = processor
|
||||||
|
self._has_backpressure = True
|
||||||
|
self._enqueue_method = processor.enqueue
|
||||||
|
self._get_queue_usage_method = processor.get_queue_usage
|
||||||
|
|
||||||
# Применяем фильтры, если они есть
|
# Применяем фильтры, если они есть
|
||||||
if self.filters:
|
if self.filters:
|
||||||
self._apply_filters()
|
self._apply_filters()
|
||||||
@@ -104,48 +118,37 @@ class CANBusHandler:
|
|||||||
# Вызываем callback для обработки сообщения
|
# Вызываем callback для обработки сообщения
|
||||||
# Используем backpressure: если очередь заполнена, замедляем чтение
|
# Используем backpressure: если очередь заполнена, замедляем чтение
|
||||||
try:
|
try:
|
||||||
# Проверяем использование очереди (если callback - это enqueue)
|
# Используем закэшированные ссылки для избежания рефлексии в hot path
|
||||||
if hasattr(self.message_callback, '__self__'):
|
if self._has_backpressure:
|
||||||
processor = getattr(self.message_callback, '__self__', None)
|
queue_usage = self._get_queue_usage_method()
|
||||||
if processor and hasattr(processor, 'get_queue_usage'):
|
|
||||||
queue_usage = processor.get_queue_usage()
|
|
||||||
|
|
||||||
# Если очередь заполнена более чем на 80%, используем блокирующий режим
|
# Если очередь заполнена более чем на 80%, используем блокирующий режим
|
||||||
if queue_usage > 0.8:
|
if queue_usage > 0.8:
|
||||||
# Блокируем добавление с небольшим таймаутом для backpressure
|
# Блокируем добавление с небольшим таймаутом для backpressure
|
||||||
if hasattr(processor, 'enqueue'):
|
success = self._enqueue_method(frame, block=True, timeout=0.01)
|
||||||
success = processor.enqueue(frame, block=True, timeout=0.01)
|
if not success:
|
||||||
if not success:
|
consecutive_drops += 1
|
||||||
consecutive_drops += 1
|
# Увеличиваем задержку при последовательных потерях
|
||||||
# Увеличиваем задержку при последовательных потерях
|
backpressure_delay = min(
|
||||||
backpressure_delay = min(
|
max_backpressure_delay,
|
||||||
max_backpressure_delay,
|
0.001 * consecutive_drops
|
||||||
0.001 * consecutive_drops
|
)
|
||||||
)
|
continue
|
||||||
continue
|
|
||||||
else:
|
|
||||||
self.message_callback(frame)
|
|
||||||
else:
|
|
||||||
# Очередь не заполнена - быстрое добавление
|
|
||||||
if hasattr(processor, 'enqueue'):
|
|
||||||
success = processor.enqueue(frame, block=False)
|
|
||||||
if not success:
|
|
||||||
consecutive_drops += 1
|
|
||||||
backpressure_delay = min(
|
|
||||||
max_backpressure_delay,
|
|
||||||
0.001 * consecutive_drops
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
self.message_callback(frame)
|
|
||||||
|
|
||||||
# Сбрасываем счетчик при успешной отправке
|
|
||||||
if queue_usage < 0.5:
|
|
||||||
consecutive_drops = 0
|
|
||||||
backpressure_delay = 0.0
|
|
||||||
else:
|
else:
|
||||||
# Обычный callback без backpressure
|
# Очередь не заполнена - быстрое добавление
|
||||||
self.message_callback(frame)
|
success = self._enqueue_method(frame, block=False)
|
||||||
|
if not success:
|
||||||
|
consecutive_drops += 1
|
||||||
|
backpressure_delay = min(
|
||||||
|
max_backpressure_delay,
|
||||||
|
0.001 * consecutive_drops
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Сбрасываем счетчик при успешной отправке
|
||||||
|
if queue_usage < 0.5:
|
||||||
|
consecutive_drops = 0
|
||||||
|
backpressure_delay = 0.0
|
||||||
else:
|
else:
|
||||||
# Обычный callback без backpressure
|
# Обычный callback без backpressure
|
||||||
self.message_callback(frame)
|
self.message_callback(frame)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class Storage:
|
|||||||
|
|
||||||
_instance: Optional['Storage'] = None
|
_instance: Optional['Storage'] = None
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
|
_write_lock = threading.Lock() # Мьютекс для потокобезопасной записи в SQLite
|
||||||
|
|
||||||
def __new__(cls):
|
def __new__(cls):
|
||||||
"""Singleton паттерн для единого экземпляра хранилища."""
|
"""Singleton паттерн для единого экземпляра хранилища."""
|
||||||
@@ -33,17 +34,19 @@ class Storage:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Инициализация хранилища."""
|
"""Инициализация хранилища."""
|
||||||
# Проверяем, что инициализация выполняется только один раз
|
# Защита от race condition при инициализации singleton
|
||||||
if hasattr(self, '_initialized'):
|
with self._lock:
|
||||||
return
|
# Проверяем, что инициализация выполняется только один раз
|
||||||
|
if hasattr(self, '_initialized') and self._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
self.config = config.storage
|
self.config = config.storage
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self.connection: Optional[sqlite3.Connection] = None
|
self.connection: Optional[sqlite3.Connection] = None
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
|
|
||||||
# Инициализируем базу данных
|
# Инициализируем базу данных
|
||||||
self._init_database()
|
self._init_database()
|
||||||
|
|
||||||
def _init_database(self) -> None:
|
def _init_database(self) -> None:
|
||||||
"""Инициализация базы данных SQLite."""
|
"""Инициализация базы данных SQLite."""
|
||||||
@@ -100,6 +103,22 @@ class Storage:
|
|||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def _migrate_add_column(self, cursor, table: str, column: str, column_def: str) -> None:
|
||||||
|
"""Добавление колонки в таблицу, если она не существует.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cursor: Курсор базы данных
|
||||||
|
table: Имя таблицы
|
||||||
|
column: Имя колонки
|
||||||
|
column_def: Определение колонки (тип и ограничения)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cursor.execute(f"SELECT {column} FROM {table} LIMIT 1")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
# Колонка не существует, добавляем
|
||||||
|
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {column_def}")
|
||||||
|
self.logger.info(f"Added column {column} to table {table}")
|
||||||
|
|
||||||
def _create_tables(self) -> None:
|
def _create_tables(self) -> None:
|
||||||
"""Создание таблиц в базе данных."""
|
"""Создание таблиц в базе данных."""
|
||||||
if not self.connection:
|
if not self.connection:
|
||||||
@@ -107,20 +126,28 @@ class Storage:
|
|||||||
|
|
||||||
cursor = self.connection.cursor()
|
cursor = self.connection.cursor()
|
||||||
|
|
||||||
# Таблица для CAN сообщений
|
# Таблица для CAN сообщений (согласована с PostgreSQL схемой)
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS can_messages (
|
CREATE TABLE IF NOT EXISTS can_messages (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
timestamp REAL NOT NULL,
|
timestamp REAL NOT NULL,
|
||||||
interface TEXT NOT NULL,
|
interface TEXT NOT NULL,
|
||||||
can_id INTEGER NOT NULL,
|
can_id INTEGER NOT NULL,
|
||||||
|
can_id_hex TEXT NOT NULL DEFAULT '',
|
||||||
|
is_extended INTEGER NOT NULL DEFAULT 0,
|
||||||
dlc INTEGER NOT NULL,
|
dlc INTEGER NOT NULL,
|
||||||
data BLOB NOT NULL,
|
data BLOB NOT NULL,
|
||||||
|
data_hex TEXT NOT NULL DEFAULT '',
|
||||||
processed INTEGER DEFAULT 0,
|
processed INTEGER DEFAULT 0,
|
||||||
created_at REAL DEFAULT (julianday('now'))
|
created_at REAL DEFAULT (julianday('now'))
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Добавляем новые колонки для существующих таблиц (миграция)
|
||||||
|
self._migrate_add_column(cursor, "can_messages", "can_id_hex", "TEXT NOT NULL DEFAULT ''")
|
||||||
|
self._migrate_add_column(cursor, "can_messages", "is_extended", "INTEGER NOT NULL DEFAULT 0")
|
||||||
|
self._migrate_add_column(cursor, "can_messages", "data_hex", "TEXT NOT NULL DEFAULT ''")
|
||||||
|
|
||||||
# Индексы для быстрого поиска
|
# Индексы для быстрого поиска
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE INDEX IF NOT EXISTS idx_timestamp
|
CREATE INDEX IF NOT EXISTS idx_timestamp
|
||||||
@@ -186,13 +213,19 @@ class Storage:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with self._get_cursor() as cursor:
|
# Вычисляем дополнительные поля для совместимости с PostgreSQL
|
||||||
cursor.execute("""
|
can_id_hex = hex(can_id)
|
||||||
INSERT INTO can_messages (timestamp, interface, can_id, dlc, data)
|
is_extended = 1 if can_id > 0x7FF else 0
|
||||||
VALUES (?, ?, ?, ?, ?)
|
data_hex = data.hex().upper() if isinstance(data, bytes) else ""
|
||||||
""", (timestamp, interface, can_id, dlc, data))
|
|
||||||
|
|
||||||
return cursor.lastrowid
|
with self._write_lock:
|
||||||
|
with self._get_cursor() as cursor:
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO can_messages (timestamp, interface, can_id, can_id_hex, is_extended, dlc, data, data_hex)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (timestamp, interface, can_id, can_id_hex, is_extended, dlc, data, data_hex))
|
||||||
|
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
@@ -210,7 +243,7 @@ class Storage:
|
|||||||
Пакетное сохранение CAN сообщений.
|
Пакетное сохранение CAN сообщений.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages: Список кортежей (interface, can_id, dlc, data, timestamp)
|
messages: Список кортежей (timestamp, interface, can_id, dlc, data)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Количество успешно сохраненных сообщений
|
Количество успешно сохраненных сообщений
|
||||||
@@ -223,18 +256,30 @@ class Storage:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with self._get_cursor() as cursor:
|
# Преобразуем сообщения в расширенный формат с дополнительными полями
|
||||||
cursor.executemany("""
|
extended_messages = []
|
||||||
INSERT INTO can_messages (timestamp, interface, can_id, dlc, data)
|
for msg in messages:
|
||||||
VALUES (?, ?, ?, ?, ?)
|
timestamp, interface, can_id, dlc, data = msg
|
||||||
""", messages)
|
can_id_hex = hex(can_id)
|
||||||
|
is_extended = 1 if can_id > 0x7FF else 0
|
||||||
|
data_hex = data.hex().upper() if isinstance(data, bytes) else ""
|
||||||
|
extended_messages.append((
|
||||||
|
timestamp, interface, can_id, can_id_hex, is_extended, dlc, data, data_hex
|
||||||
|
))
|
||||||
|
|
||||||
saved_count = cursor.rowcount
|
with self._write_lock:
|
||||||
self.logger.debug(
|
with self._get_cursor() as cursor:
|
||||||
f"Saved {saved_count} messages in batch",
|
cursor.executemany("""
|
||||||
extra={"batch_size": len(messages)}
|
INSERT INTO can_messages (timestamp, interface, can_id, can_id_hex, is_extended, dlc, data, data_hex)
|
||||||
)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
return saved_count
|
""", extended_messages)
|
||||||
|
|
||||||
|
saved_count = cursor.rowcount
|
||||||
|
self.logger.debug(
|
||||||
|
f"Saved {saved_count} messages in batch",
|
||||||
|
extra={"batch_size": len(messages)}
|
||||||
|
)
|
||||||
|
return saved_count
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
@@ -252,7 +297,7 @@ class Storage:
|
|||||||
limit: Максимальное количество сообщений
|
limit: Максимальное количество сообщений
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Список кортежей (id, timestamp, interface, can_id, dlc, data)
|
Список кортежей (id, timestamp, interface, can_id, can_id_hex, is_extended, dlc, data, data_hex)
|
||||||
"""
|
"""
|
||||||
if not self.connection:
|
if not self.connection:
|
||||||
self.logger.error("Database connection not initialized")
|
self.logger.error("Database connection not initialized")
|
||||||
@@ -261,7 +306,7 @@ class Storage:
|
|||||||
try:
|
try:
|
||||||
with self._get_cursor() as cursor:
|
with self._get_cursor() as cursor:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT id, timestamp, interface, can_id, dlc, data
|
SELECT id, timestamp, interface, can_id, can_id_hex, is_extended, dlc, data, data_hex
|
||||||
FROM can_messages
|
FROM can_messages
|
||||||
WHERE processed = 0
|
WHERE processed = 0
|
||||||
ORDER BY timestamp ASC
|
ORDER BY timestamp ASC
|
||||||
@@ -295,15 +340,16 @@ class Storage:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with self._get_cursor() as cursor:
|
with self._write_lock:
|
||||||
placeholders = ','.join('?' * len(message_ids))
|
with self._get_cursor() as cursor:
|
||||||
cursor.execute(f"""
|
placeholders = ','.join('?' * len(message_ids))
|
||||||
UPDATE can_messages
|
cursor.execute(f"""
|
||||||
SET processed = 1
|
UPDATE can_messages
|
||||||
WHERE id IN ({placeholders})
|
SET processed = 1
|
||||||
""", message_ids)
|
WHERE id IN ({placeholders})
|
||||||
|
""", message_ids)
|
||||||
|
|
||||||
return cursor.rowcount
|
return cursor.rowcount
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
@@ -359,6 +405,49 @@ class Storage:
|
|||||||
"error": str(e)
|
"error": str(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def cleanup_old_messages(self, days: Optional[int] = None) -> int:
|
||||||
|
"""
|
||||||
|
Удаление обработанных записей старше указанного количества дней.
|
||||||
|
|
||||||
|
Удаляет только записи с processed=1 для сохранения необработанных данных.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: Количество дней хранения. Если None, берется из config.storage.retention_days
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Количество удаленных записей
|
||||||
|
"""
|
||||||
|
if not self.connection:
|
||||||
|
self.logger.error("Database connection not initialized")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if days is None:
|
||||||
|
days = self.config.retention_days
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._write_lock:
|
||||||
|
with self._get_cursor() as cursor:
|
||||||
|
# julianday('now') - days дает дату N дней назад
|
||||||
|
cursor.execute("""
|
||||||
|
DELETE FROM can_messages
|
||||||
|
WHERE processed = 1
|
||||||
|
AND created_at < julianday('now') - ?
|
||||||
|
""", (days,))
|
||||||
|
|
||||||
|
deleted = cursor.rowcount
|
||||||
|
if deleted > 0:
|
||||||
|
self.logger.info(
|
||||||
|
f"Cleaned up {deleted} processed messages older than {days} days"
|
||||||
|
)
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(
|
||||||
|
f"Failed to cleanup old messages: {e}",
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
"""Закрытие соединения с базой данных."""
|
"""Закрытие соединения с базой данных."""
|
||||||
if self.connection:
|
if self.connection:
|
||||||
|
|||||||
Reference in New Issue
Block a user