trustgraph/docs/tech-specs/import-export-graceful-shutdown.ru.md

718 lines
30 KiB
Markdown
Raw Normal View History

---
layout: default
title: "Техническая спецификация корректного завершения работы импорта/экспорта"
parent: "Russian (Beta)"
---
# Техническая спецификация корректного завершения работы импорта/экспорта
> **Beta Translation:** This document was translated via Machine Learning and as such may not be 100% accurate. All non-English languages are currently classified as Beta.
## Описание проблемы
В настоящее время шлюз TrustGraph испытывает потерю сообщений во время закрытия WebSocket как при импорте, так и при экспорте. Это происходит из-за гонок, когда сообщения, находящиеся в процессе передачи, отбрасываются до того, как они достигнут своего назначения (очереди Pulsar для импорта, клиенты WebSocket для экспорта).
### Проблемы на стороне импорта
1. Буфер asyncio.Queue издателя не очищается при завершении работы.
2. WebSocket закрывается до того, как будет гарантировано, что сообщения в очереди достигнут Pulsar.
3. Отсутствует механизм подтверждения успешной доставки сообщений.
### Проблемы на стороне экспорта
1. Сообщения подтверждаются в Pulsar до успешной доставки клиентам.
2. Жестко заданные таймауты приводят к потере сообщений, когда очереди заполнены.
3. Отсутствует механизм обратной связи для обработки медленных потребителей.
4. Несколько буферных областей, где данные могут быть потеряны.
## Обзор архитектуры
```
Import Flow:
Client -> Websocket -> TriplesImport -> Publisher -> Pulsar Queue
Export Flow:
Pulsar Queue -> Subscriber -> TriplesExport -> Websocket -> Client
```
## Предлагаемые исправления
### 1. Улучшения для издателя (сторона импорта)
#### A. Плавная очистка очереди
**Файл**: `trustgraph-base/trustgraph/base/publisher.py`
```python
class Publisher:
def __init__(self, client, topic, schema=None, max_size=10,
chunking_enabled=True, drain_timeout=5.0):
self.client = client
self.topic = topic
self.schema = schema
self.q = asyncio.Queue(maxsize=max_size)
self.chunking_enabled = chunking_enabled
self.running = True
self.draining = False # New state for graceful shutdown
self.task = None
self.drain_timeout = drain_timeout
async def stop(self):
"""Initiate graceful shutdown with draining"""
self.running = False
self.draining = True
if self.task:
# Wait for run() to complete draining
await self.task
async def run(self):
"""Enhanced run method with integrated draining logic"""
while self.running or self.draining:
try:
producer = self.client.create_producer(
topic=self.topic,
schema=JsonSchema(self.schema),
chunking_enabled=self.chunking_enabled,
)
drain_end_time = None
while self.running or self.draining:
try:
# Start drain timeout when entering drain mode
if self.draining and drain_end_time is None:
drain_end_time = time.time() + self.drain_timeout
logger.info(f"Publisher entering drain mode, timeout={self.drain_timeout}s")
# Check drain timeout
if self.draining and time.time() > drain_end_time:
if not self.q.empty():
logger.warning(f"Drain timeout reached with {self.q.qsize()} messages remaining")
self.draining = False
break
# Calculate wait timeout based on mode
if self.draining:
# Shorter timeout during draining to exit quickly when empty
timeout = min(0.1, drain_end_time - time.time())
else:
# Normal operation timeout
timeout = 0.25
# Get message from queue
id, item = await asyncio.wait_for(
self.q.get(),
timeout=timeout
)
# Send the message (single place for sending)
if id:
producer.send(item, { "id": id })
else:
producer.send(item)
except asyncio.TimeoutError:
# If draining and queue is empty, we're done
if self.draining and self.q.empty():
logger.info("Publisher queue drained successfully")
self.draining = False
break
continue
except asyncio.QueueEmpty:
# If draining and queue is empty, we're done
if self.draining and self.q.empty():
logger.info("Publisher queue drained successfully")
self.draining = False
break
continue
# Flush producer before closing
if producer:
producer.flush()
producer.close()
except Exception as e:
logger.error(f"Exception in publisher: {e}", exc_info=True)
if not self.running and not self.draining:
return
# If handler drops out, sleep a retry
await asyncio.sleep(1)
async def send(self, id, item):
"""Send still works normally - just adds to queue"""
if self.draining:
# Optionally reject new messages during drain
raise RuntimeError("Publisher is shutting down, not accepting new messages")
await self.q.put((id, item))
```
**Основные преимущества дизайна:**
**Единое место отправки:** Все вызовы `producer.send()` происходят в одном месте внутри метода `run()`.
**Чистая конечная машина состояний:** Три четких состояния - работа, слив, остановка.
**Защита от таймаута:** Не зависнет бесконечно во время слива.
**Улучшенная наблюдаемость:** Четкое логирование хода слива и переходов состояний.
**Опциональное отклонение сообщений:** Может отклонять новые сообщения во время фазы завершения работы.
#### B. Улучшенный порядок завершения работы
**Файл:** `trustgraph-flow/trustgraph/gateway/dispatch/triples_import.py`
```python
class TriplesImport:
async def destroy(self):
"""Enhanced destroy with proper shutdown order"""
# Step 1: Stop accepting new messages
self.running.stop()
# Step 2: Wait for publisher to drain its queue
logger.info("Draining publisher queue...")
await self.publisher.stop()
# Step 3: Close websocket only after queue is drained
if self.ws:
await self.ws.close()
```
### 2. Улучшения для подписчиков (экспортная сторона)
#### A. Интегрированная схема отвода
**Файл**: `trustgraph-base/trustgraph/base/subscriber.py`
```python
class Subscriber:
def __init__(self, client, topic, subscription, consumer_name,
schema=None, max_size=100, metrics=None,
backpressure_strategy="block", drain_timeout=5.0):
# ... existing init ...
self.backpressure_strategy = backpressure_strategy
self.running = True
self.draining = False # New state for graceful shutdown
self.drain_timeout = drain_timeout
self.pending_acks = {} # Track messages awaiting delivery
async def stop(self):
"""Initiate graceful shutdown with draining"""
self.running = False
self.draining = True
if self.task:
# Wait for run() to complete draining
await self.task
async def run(self):
"""Enhanced run method with integrated draining logic"""
while self.running or self.draining:
if self.metrics:
self.metrics.state("stopped")
try:
self.consumer = self.client.subscribe(
topic = self.topic,
subscription_name = self.subscription,
consumer_name = self.consumer_name,
schema = JsonSchema(self.schema),
)
if self.metrics:
self.metrics.state("running")
logger.info("Subscriber running...")
drain_end_time = None
while self.running or self.draining:
# Start drain timeout when entering drain mode
if self.draining and drain_end_time is None:
drain_end_time = time.time() + self.drain_timeout
logger.info(f"Subscriber entering drain mode, timeout={self.drain_timeout}s")
# Stop accepting new messages from Pulsar during drain
self.consumer.pause_message_listener()
# Check drain timeout
if self.draining and time.time() > drain_end_time:
async with self.lock:
total_pending = sum(
q.qsize() for q in
list(self.q.values()) + list(self.full.values())
)
if total_pending > 0:
logger.warning(f"Drain timeout reached with {total_pending} messages in queues")
self.draining = False
break
# Check if we can exit drain mode
if self.draining:
async with self.lock:
all_empty = all(
q.empty() for q in
list(self.q.values()) + list(self.full.values())
)
if all_empty and len(self.pending_acks) == 0:
logger.info("Subscriber queues drained successfully")
self.draining = False
break
# Process messages only if not draining
if not self.draining:
try:
msg = await asyncio.to_thread(
self.consumer.receive,
timeout_millis=250
)
except _pulsar.Timeout:
continue
except Exception as e:
logger.error(f"Exception in subscriber receive: {e}", exc_info=True)
raise e
if self.metrics:
self.metrics.received()
# Process the message
await self._process_message(msg)
else:
# During draining, just wait for queues to empty
await asyncio.sleep(0.1)
except Exception as e:
logger.error(f"Subscriber exception: {e}", exc_info=True)
finally:
# Negative acknowledge any pending messages
for msg in self.pending_acks.values():
self.consumer.negative_acknowledge(msg)
self.pending_acks.clear()
if self.consumer:
self.consumer.unsubscribe()
self.consumer.close()
self.consumer = None
if self.metrics:
self.metrics.state("stopped")
if not self.running and not self.draining:
return
# If handler drops out, sleep a retry
await asyncio.sleep(1)
async def _process_message(self, msg):
"""Process a single message with deferred acknowledgment"""
# Store message for later acknowledgment
msg_id = str(uuid.uuid4())
self.pending_acks[msg_id] = msg
try:
id = msg.properties()["id"]
except:
id = None
value = msg.value()
delivery_success = False
async with self.lock:
# Deliver to specific subscribers
if id in self.q:
delivery_success = await self._deliver_to_queue(
self.q[id], value
)
# Deliver to all subscribers
for q in self.full.values():
if await self._deliver_to_queue(q, value):
delivery_success = True
# Acknowledge only on successful delivery
if delivery_success:
self.consumer.acknowledge(msg)
del self.pending_acks[msg_id]
else:
# Negative acknowledge for retry
self.consumer.negative_acknowledge(msg)
del self.pending_acks[msg_id]
async def _deliver_to_queue(self, queue, value):
"""Deliver message to queue with backpressure handling"""
try:
if self.backpressure_strategy == "block":
# Block until space available (no timeout)
await queue.put(value)
return True
elif self.backpressure_strategy == "drop_oldest":
# Drop oldest message if queue full
if queue.full():
try:
queue.get_nowait()
if self.metrics:
self.metrics.dropped()
except asyncio.QueueEmpty:
pass
await queue.put(value)
return True
elif self.backpressure_strategy == "drop_new":
# Drop new message if queue full
if queue.full():
if self.metrics:
self.metrics.dropped()
return False
await queue.put(value)
return True
except Exception as e:
logger.error(f"Failed to deliver message: {e}")
return False
```
**Основные преимущества дизайна (соответствие шаблону издателя):**
<<<<<<< HEAD
**Единое место обработки сообщений**: Вся обработка сообщений происходит в методе `run()`
**Чистая конечная машина состояний**: Три четких состояния - работа, слив, остановка
**Приостановка во время слива**: Прекращает прием новых сообщений из Pulsar во время слива существующих очередей
**Защита от таймаутов**: Не зависнет бесконечно во время слива
**Правильная очистка**: Отправляет отрицательные подтверждения для любых неотправленных сообщений при завершении работы
=======
**Единое место обработки сообщений**: Вся обработка сообщений происходит в методе `run()`.
**Чистый конечный автомат**: Три четких состояния - работа, слив, остановка.
**Приостановка во время слива**: Прекращает прием новых сообщений из Pulsar во время слива существующих очередей.
**Защита от таймаутов**: Не зависнет бесконечно во время слива.
**Правильная очистка**: Отправляет отрицательные подтверждения для любых неотправленных сообщений при завершении работы.
>>>>>>> 82edf2d (New md files from RunPod)
#### B. Улучшения обработчика экспорта
**Файл**: `trustgraph-flow/trustgraph/gateway/dispatch/triples_export.py`
```python
class TriplesExport:
async def destroy(self):
"""Enhanced destroy with graceful shutdown"""
# Step 1: Signal stop to prevent new messages
self.running.stop()
# Step 2: Wait briefly for in-flight messages
await asyncio.sleep(0.5)
# Step 3: Unsubscribe and stop subscriber (triggers queue drain)
if hasattr(self, 'subs'):
await self.subs.unsubscribe_all(self.id)
await self.subs.stop()
# Step 4: Close websocket last
if self.ws and not self.ws.closed:
await self.ws.close()
async def run(self):
"""Enhanced run with better error handling"""
self.subs = Subscriber(
client = self.pulsar_client,
topic = self.queue,
consumer_name = self.consumer,
subscription = self.subscriber,
schema = Triples,
backpressure_strategy = "block" # Configurable
)
await self.subs.start()
self.id = str(uuid.uuid4())
q = await self.subs.subscribe_all(self.id)
consecutive_errors = 0
max_consecutive_errors = 5
while self.running.get():
try:
resp = await asyncio.wait_for(q.get(), timeout=0.5)
await self.ws.send_json(serialize_triples(resp))
consecutive_errors = 0 # Reset on success
except asyncio.TimeoutError:
continue
except queue.Empty:
continue
except Exception as e:
logger.error(f"Exception sending to websocket: {str(e)}")
consecutive_errors += 1
if consecutive_errors >= max_consecutive_errors:
logger.error("Too many consecutive errors, shutting down")
break
# Brief pause before retry
await asyncio.sleep(0.1)
# Graceful cleanup handled in destroy()
```
### 3. Улучшения на уровне сокетов
**Файл**: `trustgraph-flow/trustgraph/gateway/endpoint/socket.py`
```python
class SocketEndpoint:
async def listener(self, ws, dispatcher, running):
"""Enhanced listener with graceful shutdown"""
async for msg in ws:
if msg.type == WSMsgType.TEXT:
await dispatcher.receive(msg)
continue
elif msg.type == WSMsgType.BINARY:
await dispatcher.receive(msg)
continue
else:
# Graceful shutdown on close
logger.info("Websocket closing, initiating graceful shutdown")
running.stop()
# Allow time for dispatcher cleanup
await asyncio.sleep(1.0)
break
async def handle(self, request):
"""Enhanced handler with better cleanup"""
# ... existing setup code ...
try:
async with asyncio.TaskGroup() as tg:
running = Running()
dispatcher = await self.dispatcher(
ws, running, request.match_info
)
worker_task = tg.create_task(
self.worker(ws, dispatcher, running)
)
lsnr_task = tg.create_task(
self.listener(ws, dispatcher, running)
)
except ExceptionGroup as e:
logger.error("Exception group occurred:", exc_info=True)
# Attempt graceful dispatcher shutdown
try:
await asyncio.wait_for(
dispatcher.destroy(),
timeout=5.0
)
except asyncio.TimeoutError:
logger.warning("Dispatcher shutdown timed out")
except Exception as de:
logger.error(f"Error during dispatcher cleanup: {de}")
except Exception as e:
logger.error(f"Socket exception: {e}", exc_info=True)
finally:
# Ensure dispatcher cleanup
if dispatcher and hasattr(dispatcher, 'destroy'):
try:
await dispatcher.destroy()
except:
pass
# Ensure websocket is closed
if ws and not ws.closed:
await ws.close()
return ws
```
## Варианты конфигурации
Добавить поддержку конфигурации для настройки поведения:
```python
# config.py
class GracefulShutdownConfig:
# Publisher settings
PUBLISHER_DRAIN_TIMEOUT = 5.0 # Seconds to wait for queue drain
PUBLISHER_FLUSH_TIMEOUT = 2.0 # Producer flush timeout
# Subscriber settings
SUBSCRIBER_DRAIN_TIMEOUT = 5.0 # Seconds to wait for queue drain
BACKPRESSURE_STRATEGY = "block" # Options: "block", "drop_oldest", "drop_new"
SUBSCRIBER_MAX_QUEUE_SIZE = 100 # Maximum queue size before backpressure
# Socket settings
SHUTDOWN_GRACE_PERIOD = 1.0 # Seconds to wait for graceful shutdown
MAX_CONSECUTIVE_ERRORS = 5 # Maximum errors before forced shutdown
# Monitoring
LOG_QUEUE_STATS = True # Log queue statistics on shutdown
METRICS_ENABLED = True # Enable metrics collection
```
## Стратегия тестирования
### Юнит-тесты
```python
async def test_publisher_queue_drain():
"""Verify Publisher drains queue on shutdown"""
publisher = Publisher(...)
# Fill queue with messages
for i in range(10):
await publisher.send(f"id-{i}", {"data": i})
# Stop publisher
await publisher.stop()
# Verify all messages were sent
assert publisher.q.empty()
assert mock_producer.send.call_count == 10
async def test_subscriber_deferred_ack():
"""Verify Subscriber only acks on successful delivery"""
subscriber = Subscriber(..., backpressure_strategy="drop_new")
# Fill queue to capacity
queue = await subscriber.subscribe("test")
for i in range(100):
await queue.put({"data": i})
# Try to add message when full
msg = create_mock_message()
await subscriber._process_message(msg)
# Verify negative acknowledgment
assert msg.negative_acknowledge.called
assert not msg.acknowledge.called
```
### Интеграционные тесты
```python
async def test_import_graceful_shutdown():
"""Test import path handles shutdown gracefully"""
# Setup
import_handler = TriplesImport(...)
await import_handler.start()
# Send messages
messages = []
for i in range(100):
msg = {"metadata": {...}, "triples": [...]}
await import_handler.receive(msg)
messages.append(msg)
# Shutdown while messages in flight
await import_handler.destroy()
# Verify all messages reached Pulsar
received = await pulsar_consumer.receive_all()
assert len(received) == 100
async def test_export_no_message_loss():
"""Test export path doesn't lose acknowledged messages"""
# Setup Pulsar with test messages
for i in range(100):
await pulsar_producer.send({"data": i})
# Start export handler
export_handler = TriplesExport(...)
export_task = asyncio.create_task(export_handler.run())
# Receive some messages
received = []
for _ in range(50):
msg = await websocket.receive()
received.append(msg)
# Force shutdown
await export_handler.destroy()
# Continue receiving until websocket closes
while not websocket.closed:
try:
msg = await websocket.receive()
received.append(msg)
except:
break
# Verify no acknowledged messages were lost
assert len(received) >= 50
```
## План внедрения
<<<<<<< HEAD
### Фаза 1: Критические исправления (Неделя 1)
=======
### Этап 1: Критические исправления (Неделя 1)
>>>>>>> 82edf2d (New md files from RunPod)
Исправить время подтверждения подписки (предотвратить потерю сообщений)
Добавить очистку очереди издателя
Развернуть в тестовой среде
<<<<<<< HEAD
### Фаза 2: Плавное завершение работы (Неделя 2)
=======
### Этап 2: Плавное завершение работы (Неделя 2)
>>>>>>> 82edf2d (New md files from RunPod)
Реализовать координацию завершения работы
Добавить стратегии обратной связи
Тестирование производительности
<<<<<<< HEAD
### Фаза 3: Мониторинг и настройка (Неделя 3)
=======
### Этап 3: Мониторинг и настройка (Неделя 3)
>>>>>>> 82edf2d (New md files from RunPod)
Добавить метрики для глубины очередей
Добавить оповещения о потере сообщений
Настроить значения тайм-аутов на основе данных производственной среды
## Мониторинг и оповещения
### Метрики для отслеживания
`publisher.queue.depth` - Текущий размер очереди издателя
`publisher.messages.dropped` - Сообщения, потерянные во время завершения работы
`subscriber.messages.negatively_acknowledged` - Неудачные доставки
`websocket.graceful_shutdowns` - Успешные плавные завершения работы
`websocket.forced_shutdowns` - Принудительные/тайм-аут завершения работы
### Оповещения
Глубина очереди издателя > 80% от максимальной емкости
Любая потеря сообщений во время завершения работы
Частота отрицательных подтверждений от подписчика > 1%
Превышен тайм-аут завершения работы
## Обратная совместимость
<<<<<<< HEAD
Все изменения сохраняют обратную совместимость:
Поведение по умолчанию не изменено без конфигурации
Существующие развертывания продолжают работать
Плавное снижение производительности в случае недоступности новых функций
=======
Все изменения поддерживают обратную совместимость:
Поведение по умолчанию не изменено без конфигурации
Существующие развертывания продолжают работать
Плавное снижение функциональности в случае недоступности новых функций
>>>>>>> 82edf2d (New md files from RunPod)
## Вопросы безопасности
Не введено новых векторов атак
Обратная связь предотвращает атаки, приводящие к исчерпанию памяти
Настраиваемые лимиты предотвращают злоупотребление ресурсами
## Влияние на производительность
Минимальные накладные расходы во время нормальной работы
Завершение работы может занять до 5 секунд больше (настраивается)
Использование памяти ограничено лимитами размера очереди
Влияние на ЦП незначительно (<1% увеличение)