Update critical code

This commit is contained in:
2026-01-25 18:29:49 +03:00
parent 8ba620aa6c
commit 536698c9cc
6 changed files with 510 additions and 190 deletions

View File

@@ -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):

View File

@@ -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]:
"""Получение статистики обработчика.""" """Получение статистики обработчика."""

View File

@@ -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,8 +57,10 @@ class PostgreSQLClient:
def __init__(self): def __init__(self):
"""Инициализация клиента PostgreSQL.""" """Инициализация клиента PostgreSQL."""
# Защита от race condition при инициализации singleton
with self._lock:
# Проверяем, что инициализация выполняется только один раз # Проверяем, что инициализация выполняется только один раз
if hasattr(self, '_initialized'): if hasattr(self, '_initialized') and self._initialized:
return return
self.config = config.postgresql self.config = config.postgresql
@@ -64,26 +68,79 @@ class PostgreSQLClient:
# Инициализируем атрибуты по умолчанию # Инициализируем атрибуты по умолчанию
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
# Флаг для запуска синхронизации после восстановления соединения
self._needs_sync = True
self._last_sync_time = 0.0
self._sync_interval = 30.0 # Интервал синхронизации в секундах
if not POSTGRESQL_AVAILABLE: if not POSTGRESQL_AVAILABLE:
self.logger.error("PostgreSQL client library not available") self.logger.error("PostgreSQL client library not available")
self._initialized = True # Отмечаем как инициализированный, чтобы не повторять
return return
# Инициализируем клиент # Инициализируем клиент
self._init_client() self._init_client()
self._initialized = True 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."""
if not POSTGRESQL_AVAILABLE: if not POSTGRESQL_AVAILABLE:
@@ -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

View File

@@ -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(

View File

@@ -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,17 +118,14 @@ 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
# Увеличиваем задержку при последовательных потерях # Увеличиваем задержку при последовательных потерях
@@ -123,12 +134,9 @@ class CANBusHandler:
0.001 * consecutive_drops 0.001 * consecutive_drops
) )
continue continue
else:
self.message_callback(frame)
else: else:
# Очередь не заполнена - быстрое добавление # Очередь не заполнена - быстрое добавление
if hasattr(processor, 'enqueue'): success = self._enqueue_method(frame, block=False)
success = processor.enqueue(frame, block=False)
if not success: if not success:
consecutive_drops += 1 consecutive_drops += 1
backpressure_delay = min( backpressure_delay = min(
@@ -136,8 +144,6 @@ class CANBusHandler:
0.001 * consecutive_drops 0.001 * consecutive_drops
) )
continue continue
else:
self.message_callback(frame)
# Сбрасываем счетчик при успешной отправке # Сбрасываем счетчик при успешной отправке
if queue_usage < 0.5: if queue_usage < 0.5:
@@ -146,9 +152,6 @@ class CANBusHandler:
else: else:
# Обычный callback без backpressure # Обычный callback без backpressure
self.message_callback(frame) self.message_callback(frame)
else:
# Обычный callback без backpressure
self.message_callback(frame)
except Exception as e: except Exception as e:
self.logger.error( self.logger.error(
f"Error in message callback for {self.interface}: {e}", f"Error in message callback for {self.interface}: {e}",

View File

@@ -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,8 +34,10 @@ class Storage:
def __init__(self): def __init__(self):
"""Инициализация хранилища.""" """Инициализация хранилища."""
# Защита от race condition при инициализации singleton
with self._lock:
# Проверяем, что инициализация выполняется только один раз # Проверяем, что инициализация выполняется только один раз
if hasattr(self, '_initialized'): if hasattr(self, '_initialized') and self._initialized:
return return
self.config = config.storage self.config = config.storage
@@ -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,11 +213,17 @@ class Storage:
return None return None
try: try:
# Вычисляем дополнительные поля для совместимости с PostgreSQL
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 ""
with self._write_lock:
with self._get_cursor() as cursor: with self._get_cursor() as cursor:
cursor.execute(""" cursor.execute("""
INSERT INTO can_messages (timestamp, interface, can_id, dlc, data) INSERT INTO can_messages (timestamp, interface, can_id, can_id_hex, is_extended, dlc, data, data_hex)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (timestamp, interface, can_id, dlc, data)) """, (timestamp, interface, can_id, can_id_hex, is_extended, dlc, data, data_hex))
return cursor.lastrowid return cursor.lastrowid
@@ -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,11 +256,23 @@ class Storage:
return 0 return 0
try: try:
# Преобразуем сообщения в расширенный формат с дополнительными полями
extended_messages = []
for msg in messages:
timestamp, interface, can_id, dlc, data = msg
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
))
with self._write_lock:
with self._get_cursor() as cursor: with self._get_cursor() as cursor:
cursor.executemany(""" cursor.executemany("""
INSERT INTO can_messages (timestamp, interface, can_id, dlc, data) INSERT INTO can_messages (timestamp, interface, can_id, can_id_hex, is_extended, dlc, data, data_hex)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", messages) """, extended_messages)
saved_count = cursor.rowcount saved_count = cursor.rowcount
self.logger.debug( self.logger.debug(
@@ -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,6 +340,7 @@ class Storage:
return 0 return 0
try: try:
with self._write_lock:
with self._get_cursor() as cursor: with self._get_cursor() as cursor:
placeholders = ','.join('?' * len(message_ids)) placeholders = ','.join('?' * len(message_ids))
cursor.execute(f""" cursor.execute(f"""
@@ -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: