dory-processor-sdk 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. dory/__init__.py +101 -0
  2. dory/auth/__init__.py +10 -0
  3. dory/auth/oauth2.py +153 -0
  4. dory/auto_instrument.py +142 -0
  5. dory/cli/__init__.py +5 -0
  6. dory/cli/main.py +137 -0
  7. dory/cli/templates.py +123 -0
  8. dory/config/__init__.py +23 -0
  9. dory/config/defaults.py +24 -0
  10. dory/config/loader.py +430 -0
  11. dory/config/presets.py +73 -0
  12. dory/config/schema.py +84 -0
  13. dory/core/__init__.py +27 -0
  14. dory/core/app.py +434 -0
  15. dory/core/context.py +209 -0
  16. dory/core/lifecycle.py +214 -0
  17. dory/core/meta.py +121 -0
  18. dory/core/modes.py +479 -0
  19. dory/core/processor.py +564 -0
  20. dory/core/signals.py +122 -0
  21. dory/decorators.py +142 -0
  22. dory/edge/__init__.py +88 -0
  23. dory/edge/adaptive.py +644 -0
  24. dory/edge/detector.py +546 -0
  25. dory/edge/fencing.py +488 -0
  26. dory/edge/heartbeat.py +598 -0
  27. dory/edge/role.py +419 -0
  28. dory/errors/__init__.py +139 -0
  29. dory/errors/classification.py +362 -0
  30. dory/errors/codes.py +498 -0
  31. dory/geo/__init__.py +40 -0
  32. dory/geo/geolocalizer.py +1034 -0
  33. dory/health/__init__.py +12 -0
  34. dory/health/probes.py +210 -0
  35. dory/health/server.py +635 -0
  36. dory/k8s/__init__.py +80 -0
  37. dory/k8s/annotation_watcher.py +184 -0
  38. dory/k8s/client.py +251 -0
  39. dory/k8s/labels.py +505 -0
  40. dory/k8s/pod_metadata.py +182 -0
  41. dory/logging/__init__.py +9 -0
  42. dory/logging/logger.py +148 -0
  43. dory/metrics/__init__.py +7 -0
  44. dory/metrics/collector.py +301 -0
  45. dory/middleware/__init__.py +46 -0
  46. dory/middleware/connection_tracker.py +608 -0
  47. dory/middleware/request_id.py +325 -0
  48. dory/middleware/request_tracker.py +511 -0
  49. dory/migration/__init__.py +33 -0
  50. dory/migration/configmap.py +232 -0
  51. dory/migration/s3_store.py +594 -0
  52. dory/migration/serialization.py +135 -0
  53. dory/migration/state_manager.py +286 -0
  54. dory/migration/transfer.py +382 -0
  55. dory/monitoring/__init__.py +29 -0
  56. dory/monitoring/opentelemetry.py +489 -0
  57. dory/output/__init__.py +31 -0
  58. dory/output/envelope.py +137 -0
  59. dory/output/formatter.py +113 -0
  60. dory/output/rabbitmq.py +632 -0
  61. dory/output/routing.py +318 -0
  62. dory/output/validator.py +199 -0
  63. dory/py.typed +2 -0
  64. dory/recovery/__init__.py +60 -0
  65. dory/recovery/golden_image.py +487 -0
  66. dory/recovery/golden_snapshot.py +713 -0
  67. dory/recovery/golden_validator.py +518 -0
  68. dory/recovery/partial_recovery.py +482 -0
  69. dory/recovery/recovery_decision.py +242 -0
  70. dory/recovery/restart_detector.py +142 -0
  71. dory/recovery/state_validator.py +183 -0
  72. dory/resilience/__init__.py +45 -0
  73. dory/resilience/circuit_breaker.py +457 -0
  74. dory/resilience/retry.py +389 -0
  75. dory/simple.py +342 -0
  76. dory/types.py +68 -0
  77. dory/utils/__init__.py +31 -0
  78. dory/utils/errors.py +59 -0
  79. dory/utils/retry.py +115 -0
  80. dory/utils/timeout.py +80 -0
  81. dory_processor_sdk-0.0.1.dist-info/METADATA +424 -0
  82. dory_processor_sdk-0.0.1.dist-info/RECORD +86 -0
  83. dory_processor_sdk-0.0.1.dist-info/WHEEL +5 -0
  84. dory_processor_sdk-0.0.1.dist-info/entry_points.txt +2 -0
  85. dory_processor_sdk-0.0.1.dist-info/licenses/LICENSE +201 -0
  86. dory_processor_sdk-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,632 @@
1
+ """
2
+ RabbitMQ publisher with circuit breaker, retry, and in-memory buffering.
3
+
4
+ Provides a resilient async publisher for sending processor output to RabbitMQ.
5
+ Requires the ``aio-pika`` optional dependency: ``pip install dory-processor-sdk[production]``
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import time
11
+ from collections import deque
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Awaitable, Callable, Optional
14
+ from urllib.parse import urlparse
15
+
16
+ import aio_pika
17
+ import yarl
18
+
19
+ from dory.output.formatter import JSONFormatter, OutputFormatter
20
+ from dory.resilience import CircuitBreaker, CircuitOpenError, RetryExhaustedError
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class PublishError(Exception):
26
+ """Raised when a publish operation fails."""
27
+
28
+
29
+ def _mask_url(url: str) -> str:
30
+ """Mask credentials in an AMQP URL for safe logging.
31
+
32
+ Returns only the host (and port if present), stripping user/pass/vhost.
33
+ """
34
+ try:
35
+ parsed = urlparse(url)
36
+ host = parsed.hostname or "unknown"
37
+ if parsed.port:
38
+ return f"{host}:{parsed.port}"
39
+ return host
40
+ except Exception:
41
+ return "<masked>"
42
+
43
+
44
+ @dataclass
45
+ class PublisherConfig:
46
+ """Configuration for RabbitMQ publisher.
47
+
48
+ Attributes:
49
+ url: AMQP connection URL.
50
+ exchange: Default exchange name.
51
+ exchange_type: Exchange type (topic, direct, fanout, headers).
52
+ durable: Whether the exchange is durable.
53
+ connection_timeout: Connection timeout in seconds.
54
+ heartbeat: AMQP heartbeat interval in seconds.
55
+ buffer_enabled: Enable in-memory message buffering on failure.
56
+ buffer_max_size: Maximum number of buffered messages.
57
+ buffer_max_bytes: Maximum total size of buffered messages in bytes.
58
+ retry_max_attempts: Max retry attempts per publish.
59
+ retry_initial_delay: Initial retry delay in seconds.
60
+ retry_max_delay: Maximum retry delay in seconds.
61
+ circuit_breaker_failure_threshold: Failures before circuit opens.
62
+ circuit_breaker_timeout: Seconds before circuit half-opens.
63
+ """
64
+
65
+ url: str = "amqp://guest:guest@localhost:5672/"
66
+ exchange: str = "dory.output"
67
+ exchange_type: str = "topic"
68
+ durable: bool = True
69
+ connection_timeout: float = 10.0
70
+ heartbeat: int = 60
71
+ buffer_enabled: bool = True
72
+ buffer_max_size: int = 10000
73
+ buffer_max_bytes: int = 100 * 1024 * 1024 # 100MB
74
+ retry_max_attempts: int = 3
75
+ retry_initial_delay: float = 1.0
76
+ retry_max_delay: float = 30.0
77
+ circuit_breaker_failure_threshold: int = 5
78
+ circuit_breaker_timeout: float = 60.0
79
+
80
+
81
+ @dataclass
82
+ class BufferedMessage:
83
+ """A message buffered for later delivery.
84
+
85
+ Attributes:
86
+ exchange: Target exchange name.
87
+ routing_key: Message routing key.
88
+ body: Serialized message body.
89
+ headers: Optional message headers.
90
+ timestamp: Time the message was originally published.
91
+ """
92
+
93
+ exchange: str
94
+ routing_key: str
95
+ body: bytes
96
+ headers: dict[str, Any] = field(default_factory=dict)
97
+ timestamp: float = field(default_factory=time.time)
98
+
99
+ def size_bytes(self) -> int:
100
+ """Return approximate size of this message in bytes."""
101
+ header_size = sum(
102
+ len(str(k)) + len(str(v)) for k, v in self.headers.items()
103
+ ) if self.headers else 0
104
+ return len(self.body) + len(self.routing_key) + len(self.exchange) + header_size
105
+
106
+
107
+ class RabbitMQPublisher:
108
+ """Async RabbitMQ publisher with circuit breaker, retry, and buffering.
109
+
110
+ Publishes formatted messages to RabbitMQ with full resilience:
111
+ - Circuit breaker wraps retry to prevent cascading failures
112
+ - Retry with exponential backoff for transient errors
113
+ - In-memory buffer for messages that cannot be delivered
114
+ - Automatic buffer flush on reconnection
115
+ - MessageEnvelope wrapping for unified output
116
+
117
+ Args:
118
+ config: Publisher configuration.
119
+ formatter: Inner serializer (defaults to JSONFormatter). Wrapped in
120
+ an EnvelopeFormatter automatically.
121
+ metrics: Optional MetricsCollector for recording publish metrics.
122
+ """
123
+
124
+ def __init__(
125
+ self,
126
+ config: PublisherConfig | None = None,
127
+ formatter: OutputFormatter | None = None,
128
+ metrics: Any | None = None,
129
+ url_provider: Callable[[], Any] | None = None,
130
+ **kwargs: Any,
131
+ ):
132
+ self._config = config or PublisherConfig()
133
+ self._url_provider = url_provider
134
+ self._formatter = formatter or JSONFormatter()
135
+ self._metrics = metrics
136
+
137
+ # Envelope wrapping (always on)
138
+ from dory.output.envelope import EnvelopeFormatter, ENVELOPE_SCHEMA_VERSION
139
+
140
+ self._envelope_formatter = EnvelopeFormatter(
141
+ formatter=self._formatter,
142
+ schema_version=ENVELOPE_SCHEMA_VERSION,
143
+ )
144
+
145
+ # Connection state
146
+ self._connection: Optional[aio_pika.abc.AbstractRobustConnection] = None
147
+ self._channel: Optional[aio_pika.abc.AbstractChannel] = None
148
+ self._exchange: Optional[aio_pika.abc.AbstractExchange] = None
149
+ self._connected = False
150
+ self._closing = False
151
+
152
+ # Buffer
153
+ self._buffer: deque[BufferedMessage] = deque()
154
+ self._buffer_bytes: int = 0
155
+ self._buffer_lock = asyncio.Lock()
156
+
157
+ # Circuit breaker (wraps retry internally)
158
+ self._circuit_breaker = CircuitBreaker(
159
+ name="rabbitmq_publisher",
160
+ failure_threshold=self._config.circuit_breaker_failure_threshold,
161
+ timeout_seconds=self._config.circuit_breaker_timeout,
162
+ )
163
+
164
+ # Metrics counters
165
+ self._publish_total = 0
166
+ self._publish_success = 0
167
+ self._publish_failed = 0
168
+ self._publish_buffered = 0
169
+ self._buffer_dropped = 0
170
+
171
+ masked = _mask_url(self._config.url)
172
+ logger.info(f"RabbitMQ publisher created for {masked}")
173
+
174
+ async def connect(self) -> None:
175
+ """Establish connection to RabbitMQ.
176
+
177
+ Creates a robust connection, opens a channel, and declares the exchange.
178
+ If a ``url_provider`` was given, it is called to obtain the current URL
179
+ before connecting (e.g., to refresh an OAuth2 token). A close callback
180
+ is also registered so that ``aio_pika``'s automatic reconnection uses a
181
+ freshly-fetched URL (with a new token) instead of the stale original.
182
+ Automatically flushes any buffered messages after reconnection.
183
+ """
184
+ if self._url_provider is not None:
185
+ self._config.url = await self._url_provider()
186
+
187
+ masked = _mask_url(self._config.url)
188
+ logger.info(f"Connecting to RabbitMQ at {masked}")
189
+
190
+ self._connection = await aio_pika.connect_robust(
191
+ self._config.url,
192
+ timeout=self._config.connection_timeout,
193
+ heartbeat=self._config.heartbeat,
194
+ )
195
+
196
+ # When url_provider is set (e.g. OAuth2), register a close callback
197
+ # that refreshes the connection URL before aio-pika's automatic
198
+ # reconnection factory retries. Execution order on disconnect:
199
+ # 1. close_callbacks fire (async, awaited)
200
+ # 2. __connection_close_event wakes the reconnection loop
201
+ # 3. reconnection loop calls Connection.connect() using self.url
202
+ # So updating connection.url here ensures a fresh token is used.
203
+ if self._url_provider is not None:
204
+ self._connection.close_callbacks.add(self._refresh_url_on_close)
205
+
206
+ self._channel = await self._connection.channel()
207
+ try:
208
+ self._exchange = await self._channel.declare_exchange(
209
+ self._config.exchange,
210
+ type=aio_pika.ExchangeType(self._config.exchange_type),
211
+ durable=self._config.durable,
212
+ )
213
+ except Exception as e:
214
+ # If declare fails (e.g. OAuth2 user lacks configure permission),
215
+ # fall back to using the exchange without declaring it.
216
+ logger.info(
217
+ f"Exchange declare skipped (using existing): {e}"
218
+ )
219
+ self._exchange = await self._channel.get_exchange(
220
+ self._config.exchange, ensure=False,
221
+ )
222
+ self._connected = True
223
+
224
+ self._record_metric("connection_status", gauge=1.0)
225
+ logger.info(f"Connected to RabbitMQ at {masked}")
226
+
227
+ # Flush buffered messages
228
+ if self._buffer:
229
+ logger.info(f"Flushing {len(self._buffer)} buffered messages")
230
+ await self.flush_buffer()
231
+
232
+ async def _refresh_url_on_close(
233
+ self,
234
+ _sender: Any,
235
+ _exc: BaseException | None = None,
236
+ ) -> None:
237
+ """Close callback that refreshes the AMQP URL for reconnection.
238
+
239
+ Called by ``aio_pika`` when the connection drops, *before* the
240
+ reconnection factory loop retries. Fetches a new token via the
241
+ ``url_provider`` and updates ``connection.url`` so the next
242
+ reconnection attempt uses valid credentials.
243
+ """
244
+ if self._url_provider is None or self._closing:
245
+ return
246
+ try:
247
+ fresh_url = await self._url_provider()
248
+ self._config.url = fresh_url
249
+ if self._connection is not None:
250
+ self._connection.url = yarl.URL(fresh_url)
251
+ logger.info(
252
+ f"Refreshed AMQP URL for reconnection to "
253
+ f"{_mask_url(fresh_url)}"
254
+ )
255
+ except Exception as e:
256
+ logger.warning(f"Failed to refresh AMQP URL on disconnect: {e}")
257
+
258
+ async def publish(
259
+ self,
260
+ routing_key: str,
261
+ data: Any,
262
+ exchange: str | None = None,
263
+ headers: dict[str, Any] | None = None,
264
+ raw: bool = False,
265
+ **kwargs: Any,
266
+ ) -> None:
267
+ """Publish data to RabbitMQ.
268
+
269
+ When ``raw=False`` (default), data is wrapped in a MessageEnvelope
270
+ before publishing. When ``raw=True``, data is serialized as-is
271
+ (caller is responsible for providing a properly formatted payload,
272
+ e.g. an envelope dict).
273
+
274
+ Args:
275
+ routing_key: Message routing key.
276
+ data: Data to publish.
277
+ exchange: Optional exchange override.
278
+ headers: Optional message headers.
279
+ raw: If True, skip envelope wrapping and serialize data directly.
280
+
281
+ Raises:
282
+ PublishError: If publish fails and buffering is disabled.
283
+ """
284
+ if self._closing:
285
+ logger.warning("Publisher is closing, cannot accept new messages")
286
+ return
287
+
288
+ self._publish_total += 1
289
+ self._record_metric("publish_total", counter=1)
290
+
291
+ target_exchange = exchange or self._config.exchange
292
+
293
+ # Format data (envelope wrapping unless raw=True)
294
+ try:
295
+ if raw:
296
+ body = self._formatter.format(data)
297
+ else:
298
+ body = self._envelope_formatter.format(data)
299
+ except Exception as e:
300
+ self._publish_failed += 1
301
+ self._record_metric("publish_failed_total", counter=1)
302
+ raise PublishError(f"Failed to format message: {e}") from e
303
+
304
+ # Try publishing through circuit breaker (which wraps retry)
305
+ try:
306
+ await self._circuit_breaker.call(
307
+ self._publish_with_retry,
308
+ target_exchange,
309
+ routing_key,
310
+ body,
311
+ headers,
312
+ )
313
+ self._publish_success += 1
314
+ self._record_metric("publish_success_total", counter=1)
315
+ except CircuitOpenError:
316
+ logger.warning(
317
+ f"Circuit breaker open, buffering message for {routing_key}"
318
+ )
319
+ await self._buffer_message(
320
+ target_exchange, routing_key, body, headers or {}
321
+ )
322
+ except (PublishError, RetryExhaustedError) as e:
323
+ logger.warning(f"Publish failed after retries: {e}")
324
+ self._publish_failed += 1
325
+ self._record_metric("publish_failed_total", counter=1)
326
+ if self._config.buffer_enabled:
327
+ await self._buffer_message(
328
+ target_exchange, routing_key, body, headers or {}
329
+ )
330
+ else:
331
+ raise PublishError(f"Publish failed: {e}") from e
332
+ except Exception as e:
333
+ logger.warning(f"Publish failed: {e}")
334
+ self._publish_failed += 1
335
+ self._record_metric("publish_failed_total", counter=1)
336
+ if self._config.buffer_enabled:
337
+ await self._buffer_message(
338
+ target_exchange, routing_key, body, headers or {}
339
+ )
340
+ else:
341
+ raise PublishError(f"Publish failed: {e}") from e
342
+
343
+ @staticmethod
344
+ def _is_token_expiry_error(error: Exception) -> bool:
345
+ """Check if an error indicates an expired OAuth2/JWT token."""
346
+ msg = str(error).lower()
347
+ return "access_refused" in msg and (
348
+ "token has expired" in msg
349
+ or "jwt" in msg and "expired" in msg
350
+ )
351
+
352
+ async def _reconnect_with_fresh_token(self) -> None:
353
+ """Close the current connection and reconnect with a fresh token.
354
+
355
+ Called when a publish fails due to an expired JWT while the AMQP
356
+ connection is still alive. Fetches a new token via ``url_provider``,
357
+ then re-establishes the channel and exchange.
358
+ """
359
+ if self._url_provider is None:
360
+ return
361
+
362
+ logger.info("Token expired — refreshing token and reconnecting")
363
+
364
+ try:
365
+ # Fetch fresh URL (triggers OAuth2TokenProvider.get_token())
366
+ fresh_url = await self._url_provider()
367
+ self._config.url = fresh_url
368
+ except Exception as e:
369
+ logger.warning(f"Failed to refresh token: {e}")
370
+ raise
371
+
372
+ # Close existing connection (suppress errors on stale state)
373
+ if self._connection is not None:
374
+ try:
375
+ await self._connection.close()
376
+ except Exception:
377
+ pass
378
+ self._connection = None
379
+ self._channel = None
380
+ self._exchange = None
381
+ self._connected = False
382
+
383
+ # Reconnect with the fresh token
384
+ await self.connect()
385
+ logger.info("Reconnected with fresh token")
386
+
387
+ async def _publish_with_retry(
388
+ self,
389
+ exchange_name: str,
390
+ routing_key: str,
391
+ body: bytes,
392
+ headers: dict[str, Any] | None,
393
+ ) -> None:
394
+ """Publish with retry logic. Called inside circuit breaker.
395
+
396
+ If a publish fails due to an expired JWT token and a ``url_provider``
397
+ is configured, the connection is torn down and re-established with a
398
+ fresh token before retrying.
399
+ """
400
+ last_error: Exception | None = None
401
+ delay = self._config.retry_initial_delay
402
+ token_refreshed = False
403
+
404
+ for attempt in range(self._config.retry_max_attempts):
405
+ try:
406
+ await self._publish_internal(
407
+ exchange_name, routing_key, body, headers
408
+ )
409
+ return
410
+ except Exception as e:
411
+ last_error = e
412
+
413
+ # On token expiry, reconnect with a fresh token once
414
+ if (
415
+ not token_refreshed
416
+ and self._url_provider is not None
417
+ and self._is_token_expiry_error(e)
418
+ ):
419
+ try:
420
+ await self._reconnect_with_fresh_token()
421
+ token_refreshed = True
422
+ # Retry immediately after reconnect (no backoff)
423
+ continue
424
+ except Exception as refresh_err:
425
+ logger.warning(f"Token refresh failed: {refresh_err}")
426
+
427
+ if attempt < self._config.retry_max_attempts - 1:
428
+ logger.warning(
429
+ f"Publish attempt {attempt + 1}/{self._config.retry_max_attempts} "
430
+ f"failed: {e}. Retrying in {delay:.1f}s"
431
+ )
432
+ await asyncio.sleep(delay)
433
+ delay = min(delay * 2, self._config.retry_max_delay)
434
+
435
+ raise PublishError(
436
+ f"Publish failed after {self._config.retry_max_attempts} attempts: {last_error}"
437
+ )
438
+
439
+ async def _publish_internal(
440
+ self,
441
+ exchange_name: str,
442
+ routing_key: str,
443
+ body: bytes,
444
+ headers: dict[str, Any] | None,
445
+ ) -> None:
446
+ """Perform the actual AMQP publish."""
447
+ if not self._connected or self._exchange is None:
448
+ raise PublishError("Not connected to RabbitMQ")
449
+
450
+ # Use declared exchange if name matches, otherwise get exchange by name
451
+ if exchange_name == self._config.exchange and self._exchange is not None:
452
+ exchange = self._exchange
453
+ else:
454
+ if self._channel is None:
455
+ raise PublishError("Channel not available")
456
+ exchange = await self._channel.declare_exchange(
457
+ exchange_name,
458
+ type=aio_pika.ExchangeType(self._config.exchange_type),
459
+ durable=self._config.durable,
460
+ )
461
+
462
+ message = aio_pika.Message(
463
+ body=body,
464
+ content_type=self._formatter.content_type,
465
+ headers=headers or {},
466
+ timestamp=time.time(),
467
+ )
468
+
469
+ logger.info(
470
+ "AMQP publish exchange=%s routing_key=%s size=%d bytes",
471
+ exchange_name,
472
+ routing_key,
473
+ len(body),
474
+ )
475
+ await exchange.publish(message, routing_key=routing_key)
476
+
477
+ async def _buffer_message(
478
+ self,
479
+ exchange: str,
480
+ routing_key: str,
481
+ body: bytes,
482
+ headers: dict[str, Any],
483
+ ) -> None:
484
+ """Buffer a message for later delivery."""
485
+ if not self._config.buffer_enabled:
486
+ return
487
+
488
+ msg = BufferedMessage(
489
+ exchange=exchange,
490
+ routing_key=routing_key,
491
+ body=body,
492
+ headers=headers,
493
+ )
494
+ msg_size = msg.size_bytes()
495
+
496
+ async with self._buffer_lock:
497
+ # Evict oldest messages if buffer is full (by count)
498
+ while (
499
+ len(self._buffer) >= self._config.buffer_max_size
500
+ and self._buffer
501
+ ):
502
+ evicted = self._buffer.popleft()
503
+ self._buffer_bytes -= evicted.size_bytes()
504
+ self._buffer_dropped += 1
505
+ logger.warning(
506
+ f"Buffer full (max {self._config.buffer_max_size} messages), "
507
+ f"dropping oldest message for {evicted.routing_key}"
508
+ )
509
+
510
+ # Evict oldest messages if buffer is full (by bytes)
511
+ while (
512
+ self._config.buffer_max_bytes > 0
513
+ and self._buffer_bytes + msg_size > self._config.buffer_max_bytes
514
+ and self._buffer
515
+ ):
516
+ evicted = self._buffer.popleft()
517
+ self._buffer_bytes -= evicted.size_bytes()
518
+ self._buffer_dropped += 1
519
+ logger.warning(
520
+ f"Buffer full (max {self._config.buffer_max_bytes} bytes), "
521
+ f"dropping oldest message for {evicted.routing_key}"
522
+ )
523
+
524
+ self._buffer.append(msg)
525
+ self._buffer_bytes += msg_size
526
+
527
+ self._publish_buffered += 1
528
+ self._record_metric("publish_buffered_total", counter=1)
529
+ self._record_metric("buffer_size_messages", gauge=float(len(self._buffer)))
530
+ self._record_metric("buffer_size_bytes", gauge=float(self._buffer_bytes))
531
+
532
+ async def flush_buffer(self) -> None:
533
+ """Flush all buffered messages to RabbitMQ.
534
+
535
+ Messages that fail to publish remain in the buffer.
536
+ """
537
+ if not self._buffer:
538
+ return
539
+
540
+ async with self._buffer_lock:
541
+ remaining: deque[BufferedMessage] = deque()
542
+ remaining_bytes = 0
543
+
544
+ while self._buffer:
545
+ msg = self._buffer.popleft()
546
+ self._buffer_bytes -= msg.size_bytes()
547
+
548
+ try:
549
+ await self._publish_internal(
550
+ msg.exchange, msg.routing_key, msg.body, msg.headers
551
+ )
552
+ except Exception as e:
553
+ logger.warning(
554
+ f"Failed to flush buffered message for {msg.routing_key}: {e}"
555
+ )
556
+ remaining.append(msg)
557
+ remaining_bytes += msg.size_bytes()
558
+
559
+ # Put failed messages back
560
+ self._buffer = remaining
561
+ self._buffer_bytes = remaining_bytes
562
+
563
+ self._record_metric("buffer_size_messages", gauge=float(len(self._buffer)))
564
+ self._record_metric("buffer_size_bytes", gauge=float(self._buffer_bytes))
565
+
566
+ async def close(self) -> None:
567
+ """Close the publisher, flushing the buffer first."""
568
+ if self._closing:
569
+ return
570
+ self._closing = True
571
+
572
+ logger.info("Closing RabbitMQ publisher")
573
+
574
+ # Flush buffer before closing
575
+ if self._buffer and self._connected:
576
+ try:
577
+ await self.flush_buffer()
578
+ except Exception as e:
579
+ logger.warning(f"Error flushing buffer during close: {e}")
580
+
581
+ # Close channel and connection
582
+ if self._channel is not None:
583
+ try:
584
+ await self._channel.close()
585
+ except Exception:
586
+ pass
587
+ self._channel = None
588
+
589
+ if self._connection is not None:
590
+ try:
591
+ await self._connection.close()
592
+ except Exception:
593
+ pass
594
+ self._connection = None
595
+
596
+ self._connected = False
597
+ self._exchange = None
598
+ self._record_metric("connection_status", gauge=0.0)
599
+ logger.info("RabbitMQ publisher closed")
600
+
601
+ def is_connected(self) -> bool:
602
+ """Check if the publisher is currently connected."""
603
+ return self._connected
604
+
605
+ def get_buffer_size(self) -> dict[str, int]:
606
+ """Get current buffer size.
607
+
608
+ Returns:
609
+ Dictionary with 'messages' count and 'bytes' total size.
610
+ """
611
+ return {
612
+ "messages": len(self._buffer),
613
+ "bytes": self._buffer_bytes,
614
+ }
615
+
616
+ def _record_metric(
617
+ self,
618
+ name: str,
619
+ counter: float = 0,
620
+ gauge: float | None = None,
621
+ ) -> None:
622
+ """Record a metric if a metrics collector is available."""
623
+ if self._metrics is None:
624
+ return
625
+ try:
626
+ full_name = f"publisher_{name}"
627
+ if gauge is not None:
628
+ self._metrics.set_gauge(full_name, gauge)
629
+ elif counter > 0:
630
+ self._metrics.inc_counter(full_name, counter)
631
+ except Exception:
632
+ pass # Metrics should never break publishing