django-cfg 1.5.20__py3-none-any.whl → 1.5.31__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.
Potentially problematic release.
This version of django-cfg might be problematic. Click here for more details.
- django_cfg/__init__.py +1 -1
- django_cfg/apps/integrations/centrifugo/__init__.py +2 -0
- django_cfg/apps/integrations/centrifugo/services/client/client.py +1 -1
- django_cfg/apps/integrations/centrifugo/services/logging.py +90 -14
- django_cfg/apps/integrations/centrifugo/views/admin_api.py +29 -32
- django_cfg/apps/integrations/centrifugo/views/testing_api.py +47 -43
- django_cfg/apps/integrations/centrifugo/views/wrapper.py +41 -29
- django_cfg/apps/integrations/grpc/auth/api_key_auth.py +11 -10
- django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +1 -1
- django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +22 -36
- django_cfg/apps/integrations/grpc/managers/grpc_request_log.py +84 -0
- django_cfg/apps/integrations/grpc/managers/grpc_server_status.py +126 -3
- django_cfg/apps/integrations/grpc/models/grpc_api_key.py +7 -1
- django_cfg/apps/integrations/grpc/models/grpc_server_status.py +22 -3
- django_cfg/apps/integrations/grpc/services/__init__.py +102 -17
- django_cfg/apps/integrations/grpc/services/centrifugo/bridge.py +469 -0
- django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/demo.py +1 -1
- django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/test_publish.py +4 -4
- django_cfg/apps/integrations/grpc/services/client/__init__.py +26 -0
- django_cfg/apps/integrations/grpc/services/commands/IMPLEMENTATION.md +456 -0
- django_cfg/apps/integrations/grpc/services/commands/README.md +252 -0
- django_cfg/apps/integrations/grpc/services/commands/__init__.py +93 -0
- django_cfg/apps/integrations/grpc/services/commands/base.py +243 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/__init__.py +22 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/base_client.py +228 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/client.py +272 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/config.py +177 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/start.py +125 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/stop.py +101 -0
- django_cfg/apps/integrations/grpc/services/commands/registry.py +170 -0
- django_cfg/apps/integrations/grpc/services/discovery/__init__.py +39 -0
- django_cfg/apps/integrations/grpc/services/{discovery.py → discovery/discovery.py} +62 -55
- django_cfg/apps/integrations/grpc/services/{service_registry.py → discovery/registry.py} +216 -5
- django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/metrics.py +3 -3
- django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/request_logger.py +10 -13
- django_cfg/apps/integrations/grpc/services/management/__init__.py +37 -0
- django_cfg/apps/integrations/grpc/services/monitoring/__init__.py +38 -0
- django_cfg/apps/integrations/grpc/services/{monitoring_service.py → monitoring/monitoring.py} +2 -2
- django_cfg/apps/integrations/grpc/services/{testing_service.py → monitoring/testing.py} +5 -5
- django_cfg/apps/integrations/grpc/services/rendering/__init__.py +27 -0
- django_cfg/apps/integrations/grpc/services/{chart_generator.py → rendering/charts.py} +1 -1
- django_cfg/apps/integrations/grpc/services/routing/__init__.py +59 -0
- django_cfg/apps/integrations/grpc/services/routing/config.py +76 -0
- django_cfg/apps/integrations/grpc/services/routing/router.py +430 -0
- django_cfg/apps/integrations/grpc/services/streaming/__init__.py +117 -0
- django_cfg/apps/integrations/grpc/services/streaming/config.py +451 -0
- django_cfg/apps/integrations/grpc/services/streaming/service.py +651 -0
- django_cfg/apps/integrations/grpc/services/streaming/types.py +367 -0
- django_cfg/apps/integrations/grpc/utils/__init__.py +58 -1
- django_cfg/apps/integrations/grpc/utils/converters.py +565 -0
- django_cfg/apps/integrations/grpc/utils/handlers.py +242 -0
- django_cfg/apps/integrations/grpc/utils/proto_gen.py +1 -1
- django_cfg/apps/integrations/grpc/utils/streaming_logger.py +55 -8
- django_cfg/apps/integrations/grpc/views/charts.py +1 -1
- django_cfg/apps/integrations/grpc/views/config.py +1 -1
- django_cfg/core/base/config_model.py +11 -0
- django_cfg/core/builders/middleware_builder.py +5 -0
- django_cfg/management/commands/pool_status.py +153 -0
- django_cfg/middleware/pool_cleanup.py +261 -0
- django_cfg/models/api/grpc/config.py +2 -2
- django_cfg/models/infrastructure/database/config.py +16 -0
- django_cfg/models/infrastructure/database/converters.py +2 -0
- django_cfg/modules/django_admin/utils/html/composition.py +57 -13
- django_cfg/modules/django_admin/utils/html_builder.py +1 -0
- django_cfg/modules/django_client/core/generator/typescript/files_generator.py +12 -0
- django_cfg/modules/django_client/core/generator/typescript/generator.py +8 -0
- django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +22 -0
- django_cfg/modules/django_client/core/generator/typescript/templates/main_index.ts.jinja +4 -0
- django_cfg/modules/django_client/core/generator/typescript/templates/utils/validation-events.ts.jinja +133 -0
- django_cfg/modules/django_client/core/groups/manager.py +25 -18
- django_cfg/modules/django_client/management/commands/generate_client.py +9 -5
- django_cfg/modules/django_client/urls.py +38 -5
- django_cfg/modules/django_logging/django_logger.py +58 -19
- django_cfg/modules/django_twilio/email_otp.py +3 -1
- django_cfg/modules/django_twilio/sms.py +3 -1
- django_cfg/modules/django_twilio/unified.py +6 -2
- django_cfg/modules/django_twilio/whatsapp.py +3 -1
- django_cfg/pyproject.toml +3 -3
- django_cfg/static/frontend/admin.zip +0 -0
- django_cfg/templates/admin/index.html +17 -57
- django_cfg/utils/pool_monitor.py +320 -0
- django_cfg/utils/smart_defaults.py +233 -7
- {django_cfg-1.5.20.dist-info → django_cfg-1.5.31.dist-info}/METADATA +75 -5
- {django_cfg-1.5.20.dist-info → django_cfg-1.5.31.dist-info}/RECORD +97 -68
- django_cfg/apps/integrations/grpc/centrifugo/bridge.py +0 -277
- /django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/__init__.py +0 -0
- /django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/config.py +0 -0
- /django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/transformers.py +0 -0
- /django_cfg/apps/integrations/grpc/services/{grpc_client.py → client/client.py} +0 -0
- /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/__init__.py +0 -0
- /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/centrifugo.py +0 -0
- /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/errors.py +0 -0
- /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/logging.py +0 -0
- /django_cfg/apps/integrations/grpc/services/{config_helper.py → management/config_helper.py} +0 -0
- /django_cfg/apps/integrations/grpc/services/{proto_files_manager.py → management/proto_manager.py} +0 -0
- {django_cfg-1.5.20.dist-info → django_cfg-1.5.31.dist-info}/WHEEL +0 -0
- {django_cfg-1.5.20.dist-info → django_cfg-1.5.31.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.5.20.dist-info → django_cfg-1.5.31.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centrifugo Bridge Mixin for gRPC Services.
|
|
3
|
+
|
|
4
|
+
Universal mixin that enables automatic publishing of gRPC stream events
|
|
5
|
+
to Centrifugo WebSocket channels using Pydantic configuration.
|
|
6
|
+
|
|
7
|
+
Enhanced with:
|
|
8
|
+
- Circuit Breaker pattern for resilience (aiobreaker - async-native)
|
|
9
|
+
- Automatic retry with exponential backoff for critical messages (tenacity)
|
|
10
|
+
- Dead Letter Queue (DLQ) for failed critical messages
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
from collections import deque
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime, timedelta, timezone as tz
|
|
19
|
+
from typing import Dict, Optional, Any, TYPE_CHECKING, Deque
|
|
20
|
+
|
|
21
|
+
from aiobreaker import CircuitBreaker
|
|
22
|
+
from tenacity import (
|
|
23
|
+
retry,
|
|
24
|
+
stop_after_attempt,
|
|
25
|
+
wait_exponential,
|
|
26
|
+
retry_if_exception_type,
|
|
27
|
+
RetryError,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from .config import CentrifugoChannels, ChannelConfig
|
|
31
|
+
from .transformers import transform_protobuf_to_dict
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from django_cfg.apps.integrations.centrifugo import CentrifugoClient
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class FailedMessage:
|
|
41
|
+
"""Failed Centrifugo message for retry queue."""
|
|
42
|
+
channel: str
|
|
43
|
+
data: Dict[str, Any]
|
|
44
|
+
retry_count: int = 0
|
|
45
|
+
timestamp: float = field(default_factory=time.time)
|
|
46
|
+
field_name: str = ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CentrifugoBridgeMixin:
|
|
50
|
+
"""
|
|
51
|
+
Universal mixin for publishing gRPC stream events to Centrifugo.
|
|
52
|
+
|
|
53
|
+
Uses Pydantic models for type-safe, validated configuration.
|
|
54
|
+
|
|
55
|
+
Features:
|
|
56
|
+
- Type-safe Pydantic configuration
|
|
57
|
+
- Automatic event publishing to WebSocket channels
|
|
58
|
+
- Built-in protobuf → JSON transformation
|
|
59
|
+
- Graceful degradation if Centrifugo unavailable
|
|
60
|
+
- Custom transform functions support
|
|
61
|
+
- Template-based channel naming
|
|
62
|
+
- Per-channel rate limiting
|
|
63
|
+
- Critical event bypassing
|
|
64
|
+
|
|
65
|
+
Production-Ready Resilience (NEW):
|
|
66
|
+
- Circuit Breaker pattern (fails after 5 errors, recovers after 60s)
|
|
67
|
+
- Automatic retry with exponential backoff for critical messages (3 attempts)
|
|
68
|
+
- Dead Letter Queue (DLQ) for failed critical messages (max 1000 messages)
|
|
69
|
+
- Background retry loop (every 10 seconds)
|
|
70
|
+
|
|
71
|
+
Usage:
|
|
72
|
+
```python
|
|
73
|
+
from django_cfg.apps.integrations.grpc.mixins import (
|
|
74
|
+
CentrifugoBridgeMixin,
|
|
75
|
+
CentrifugoChannels,
|
|
76
|
+
ChannelConfig,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
class BotChannels(CentrifugoChannels):
|
|
80
|
+
heartbeat: ChannelConfig = ChannelConfig(
|
|
81
|
+
template='bot#{bot_id}#heartbeat',
|
|
82
|
+
rate_limit=0.1
|
|
83
|
+
)
|
|
84
|
+
status: ChannelConfig = ChannelConfig(
|
|
85
|
+
template='bot#{bot_id}#status',
|
|
86
|
+
critical=True
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
class BotStreamingService(
|
|
90
|
+
bot_streaming_service_pb2_grpc.BotStreamingServiceServicer,
|
|
91
|
+
CentrifugoBridgeMixin
|
|
92
|
+
):
|
|
93
|
+
centrifugo_channels = BotChannels()
|
|
94
|
+
|
|
95
|
+
async def ConnectBot(self, request_iterator, context):
|
|
96
|
+
async for message in request_iterator:
|
|
97
|
+
# Your business logic
|
|
98
|
+
await self._handle_message(bot_id, message)
|
|
99
|
+
|
|
100
|
+
# Auto-publish to Centrifugo (1 line!)
|
|
101
|
+
await self._notify_centrifugo(message, bot_id=bot_id)
|
|
102
|
+
```
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
# Class-level Pydantic config (optional, can be set in __init__)
|
|
106
|
+
centrifugo_channels: Optional[CentrifugoChannels] = None
|
|
107
|
+
|
|
108
|
+
def __init__(self):
|
|
109
|
+
"""Initialize Centrifugo bridge from Pydantic configuration."""
|
|
110
|
+
super().__init__()
|
|
111
|
+
|
|
112
|
+
# Instance attributes
|
|
113
|
+
self._centrifugo_enabled: bool = False
|
|
114
|
+
self._centrifugo_graceful: bool = True
|
|
115
|
+
self._centrifugo_client: Optional['CentrifugoClient'] = None
|
|
116
|
+
self._centrifugo_mappings: Dict[str, Dict[str, Any]] = {}
|
|
117
|
+
self._centrifugo_last_publish: Dict[str, float] = {}
|
|
118
|
+
|
|
119
|
+
# Circuit Breaker for Centrifugo resilience
|
|
120
|
+
self._circuit_breaker = CircuitBreaker(
|
|
121
|
+
fail_max=5, # Open after 5 consecutive failures
|
|
122
|
+
timeout_duration=timedelta(seconds=60), # Stay open for 60 seconds
|
|
123
|
+
name='centrifugo_bridge'
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Dead Letter Queue for failed critical messages (bounded to prevent memory issues)
|
|
127
|
+
self._failed_messages: Deque[FailedMessage] = deque(maxlen=1000)
|
|
128
|
+
self._retry_task: Optional[asyncio.Task] = None
|
|
129
|
+
self._shutdown_event = asyncio.Event()
|
|
130
|
+
|
|
131
|
+
# Auto-setup if config exists
|
|
132
|
+
if self.centrifugo_channels:
|
|
133
|
+
logger.info(f"Setting up Centrifugo bridge with {len(self.centrifugo_channels.get_channel_mappings())} channels")
|
|
134
|
+
self._setup_from_pydantic_config(self.centrifugo_channels)
|
|
135
|
+
|
|
136
|
+
# Don't start background retry task here - will be started lazily on first publish
|
|
137
|
+
# (avoids event loop issues during initialization)
|
|
138
|
+
else:
|
|
139
|
+
logger.debug("No centrifugo_channels configured, skipping Centrifugo bridge setup")
|
|
140
|
+
|
|
141
|
+
def _setup_from_pydantic_config(self, config: CentrifugoChannels):
|
|
142
|
+
"""
|
|
143
|
+
Setup Centrifugo bridge from Pydantic configuration.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
config: CentrifugoChannels instance with channel mappings
|
|
147
|
+
"""
|
|
148
|
+
self._centrifugo_enabled = config.enabled
|
|
149
|
+
self._centrifugo_graceful = config.graceful_degradation
|
|
150
|
+
|
|
151
|
+
# Extract channel mappings
|
|
152
|
+
for field_name, channel_config in config.get_channel_mappings().items():
|
|
153
|
+
if channel_config.enabled:
|
|
154
|
+
self._centrifugo_mappings[field_name] = {
|
|
155
|
+
'template': channel_config.template,
|
|
156
|
+
'rate_limit': channel_config.rate_limit or config.default_rate_limit,
|
|
157
|
+
'critical': channel_config.critical,
|
|
158
|
+
'transform': channel_config.transform,
|
|
159
|
+
'metadata': channel_config.metadata,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# Initialize client if enabled
|
|
163
|
+
if self._centrifugo_enabled and self._centrifugo_mappings:
|
|
164
|
+
self._initialize_centrifugo_client()
|
|
165
|
+
else:
|
|
166
|
+
logger.debug(f"Skipping Centrifugo client init: enabled={self._centrifugo_enabled}, mappings={len(self._centrifugo_mappings)}")
|
|
167
|
+
|
|
168
|
+
def _initialize_centrifugo_client(self):
|
|
169
|
+
"""Lazy initialize Centrifugo client."""
|
|
170
|
+
try:
|
|
171
|
+
# Use DirectCentrifugoClient for gRPC bridge (bypasses wrapper)
|
|
172
|
+
# Gets settings from django-cfg config automatically via get_centrifugo_config()
|
|
173
|
+
from django_cfg.apps.integrations.centrifugo import DirectCentrifugoClient
|
|
174
|
+
|
|
175
|
+
self._centrifugo_client = DirectCentrifugoClient()
|
|
176
|
+
logger.info(
|
|
177
|
+
f"Centrifugo bridge enabled with {len(self._centrifugo_mappings)} channels"
|
|
178
|
+
)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
logger.warning(f"Centrifugo client not available: {e}")
|
|
181
|
+
if not self._centrifugo_graceful:
|
|
182
|
+
raise
|
|
183
|
+
self._centrifugo_enabled = False
|
|
184
|
+
|
|
185
|
+
def _on_circuit_open(self, breaker, *args, **kwargs):
|
|
186
|
+
"""Called when circuit breaker opens (too many failures)."""
|
|
187
|
+
logger.error(
|
|
188
|
+
f"🔴 Centrifugo circuit breaker OPEN: {breaker.fail_counter} failures. "
|
|
189
|
+
f"Blocking publishes for {breaker._reset_timeout}s"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def _on_circuit_close(self, breaker, *args, **kwargs):
|
|
193
|
+
"""Called when circuit breaker closes (recovered)."""
|
|
194
|
+
logger.info(
|
|
195
|
+
f"🟢 Centrifugo circuit breaker CLOSED: Service recovered"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def _ensure_retry_task_started(self):
|
|
199
|
+
"""Lazily start background retry task if not already running."""
|
|
200
|
+
if self._retry_task is None and self._centrifugo_enabled:
|
|
201
|
+
try:
|
|
202
|
+
self._retry_task = asyncio.create_task(self._retry_failed_messages_loop())
|
|
203
|
+
logger.debug("Started background retry task for failed messages")
|
|
204
|
+
except RuntimeError:
|
|
205
|
+
# No event loop available yet, will try again later
|
|
206
|
+
logger.debug("Event loop not available yet, retry task will start on next attempt")
|
|
207
|
+
|
|
208
|
+
async def _retry_failed_messages_loop(self):
|
|
209
|
+
"""Background task to retry failed critical messages."""
|
|
210
|
+
try:
|
|
211
|
+
while not self._shutdown_event.is_set():
|
|
212
|
+
await asyncio.sleep(10) # Retry every 10 seconds
|
|
213
|
+
|
|
214
|
+
if not self._failed_messages:
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
queue_size = len(self._failed_messages)
|
|
218
|
+
logger.info(f"Retrying {queue_size} failed Centrifugo messages...")
|
|
219
|
+
|
|
220
|
+
# Process all failed messages
|
|
221
|
+
retry_queue = list(self._failed_messages)
|
|
222
|
+
self._failed_messages.clear()
|
|
223
|
+
|
|
224
|
+
for msg in retry_queue:
|
|
225
|
+
if msg.retry_count >= 3:
|
|
226
|
+
# Max retries exceeded - drop message
|
|
227
|
+
logger.error(
|
|
228
|
+
f"Dropping message after 3 retries: {msg.field_name} → {msg.channel}"
|
|
229
|
+
)
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
# Retry with exponential backoff decorator
|
|
234
|
+
await self._publish_with_retry(msg.channel, msg.data)
|
|
235
|
+
logger.info(f"✅ Retry succeeded: {msg.field_name} → {msg.channel}")
|
|
236
|
+
|
|
237
|
+
except (RetryError, Exception) as e:
|
|
238
|
+
# Retry failed, re-queue with incremented counter
|
|
239
|
+
msg.retry_count += 1
|
|
240
|
+
self._failed_messages.append(msg)
|
|
241
|
+
logger.warning(
|
|
242
|
+
f"Retry {msg.retry_count}/3 failed for {msg.field_name}: {e}"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
except asyncio.CancelledError:
|
|
246
|
+
logger.info("Retry loop cancelled, shutting down...")
|
|
247
|
+
except Exception as e:
|
|
248
|
+
logger.error(f"Error in retry loop: {e}", exc_info=True)
|
|
249
|
+
|
|
250
|
+
@retry(
|
|
251
|
+
retry=retry_if_exception_type((ConnectionError, TimeoutError)),
|
|
252
|
+
stop=stop_after_attempt(3),
|
|
253
|
+
wait=wait_exponential(multiplier=2, min=4, max=30),
|
|
254
|
+
reraise=True,
|
|
255
|
+
)
|
|
256
|
+
async def _publish_with_retry(self, channel: str, data: Dict[str, Any]):
|
|
257
|
+
"""Publish to Centrifugo with automatic retry (exponential backoff)."""
|
|
258
|
+
await self._centrifugo_client.publish(channel=channel, data=data)
|
|
259
|
+
|
|
260
|
+
async def shutdown(self):
|
|
261
|
+
"""Gracefully shutdown the bridge (cancel retry task)."""
|
|
262
|
+
if self._retry_task:
|
|
263
|
+
self._shutdown_event.set()
|
|
264
|
+
self._retry_task.cancel()
|
|
265
|
+
try:
|
|
266
|
+
await self._retry_task
|
|
267
|
+
except asyncio.CancelledError:
|
|
268
|
+
pass
|
|
269
|
+
logger.info("Centrifugo bridge shutdown complete")
|
|
270
|
+
|
|
271
|
+
async def _notify_centrifugo(
|
|
272
|
+
self,
|
|
273
|
+
message: Any, # Protobuf message
|
|
274
|
+
**context: Any # Template variables for channel rendering
|
|
275
|
+
) -> bool:
|
|
276
|
+
"""
|
|
277
|
+
Publish protobuf message to Centrifugo based on configured mappings.
|
|
278
|
+
|
|
279
|
+
Automatically detects which field is set in the message and publishes
|
|
280
|
+
to the corresponding channel.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
message: Protobuf message (e.g., BotMessage with heartbeat/status/etc.)
|
|
284
|
+
**context: Template variables for channel name rendering
|
|
285
|
+
Example: bot_id='123', user_id='456'
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
True if published successfully, False otherwise
|
|
289
|
+
|
|
290
|
+
Example:
|
|
291
|
+
```python
|
|
292
|
+
# message = BotMessage with heartbeat field set
|
|
293
|
+
await self._notify_centrifugo(message, bot_id='bot-123')
|
|
294
|
+
# → Publishes to channel: bot#bot-123#heartbeat
|
|
295
|
+
```
|
|
296
|
+
"""
|
|
297
|
+
if not self._centrifugo_enabled or not self._centrifugo_client:
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
# Lazily start retry task on first publish
|
|
301
|
+
self._ensure_retry_task_started()
|
|
302
|
+
|
|
303
|
+
# Check each mapped field
|
|
304
|
+
for field_name, mapping in self._centrifugo_mappings.items():
|
|
305
|
+
if message.HasField(field_name):
|
|
306
|
+
return await self._publish_field(
|
|
307
|
+
field_name,
|
|
308
|
+
message,
|
|
309
|
+
mapping,
|
|
310
|
+
context
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
return False
|
|
314
|
+
|
|
315
|
+
async def _publish_field(
|
|
316
|
+
self,
|
|
317
|
+
field_name: str,
|
|
318
|
+
message: Any,
|
|
319
|
+
mapping: Dict[str, Any],
|
|
320
|
+
context: dict
|
|
321
|
+
) -> bool:
|
|
322
|
+
"""
|
|
323
|
+
Publish specific message field to Centrifugo with circuit breaker protection.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
field_name: Name of the protobuf field
|
|
327
|
+
message: Full protobuf message
|
|
328
|
+
mapping: Channel mapping configuration
|
|
329
|
+
context: Template variables
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
True if published successfully
|
|
333
|
+
"""
|
|
334
|
+
try:
|
|
335
|
+
# Render channel from template
|
|
336
|
+
channel = mapping['template'].format(**context)
|
|
337
|
+
|
|
338
|
+
# Rate limiting check (unless critical)
|
|
339
|
+
if not mapping['critical'] and mapping['rate_limit']:
|
|
340
|
+
now = time.time()
|
|
341
|
+
last = self._centrifugo_last_publish.get(channel, 0)
|
|
342
|
+
if now - last < mapping['rate_limit']:
|
|
343
|
+
logger.debug(f"Rate limit: skipping {field_name} for {channel}")
|
|
344
|
+
return False
|
|
345
|
+
self._centrifugo_last_publish[channel] = now
|
|
346
|
+
|
|
347
|
+
# Get field value
|
|
348
|
+
field_value = getattr(message, field_name)
|
|
349
|
+
|
|
350
|
+
# Transform to dict
|
|
351
|
+
data = self._transform_field(field_name, field_value, mapping, context)
|
|
352
|
+
|
|
353
|
+
# Publish to Centrifugo with circuit breaker protection
|
|
354
|
+
try:
|
|
355
|
+
if mapping['critical']:
|
|
356
|
+
# Critical messages: circuit breaker + retry with exponential backoff
|
|
357
|
+
await self._circuit_breaker.call(
|
|
358
|
+
self._publish_with_retry,
|
|
359
|
+
channel,
|
|
360
|
+
data
|
|
361
|
+
)
|
|
362
|
+
else:
|
|
363
|
+
# Non-critical: only circuit breaker, no retry
|
|
364
|
+
await self._circuit_breaker.call(
|
|
365
|
+
self._centrifugo_client.publish,
|
|
366
|
+
channel=channel,
|
|
367
|
+
data=data
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
logger.debug(f"Published {field_name} to {channel}")
|
|
371
|
+
return True
|
|
372
|
+
|
|
373
|
+
except Exception as circuit_error:
|
|
374
|
+
# Circuit breaker is open or publish failed
|
|
375
|
+
error_name = type(circuit_error).__name__
|
|
376
|
+
if 'CircuitBreakerError' in error_name or 'CircuitBreakerOpen' in error_name:
|
|
377
|
+
logger.warning(
|
|
378
|
+
f"Circuit breaker open, cannot publish {field_name} to {channel}"
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
if mapping['critical']:
|
|
382
|
+
# Queue critical message for background retry
|
|
383
|
+
self._failed_messages.append(FailedMessage(
|
|
384
|
+
channel=channel,
|
|
385
|
+
data=data,
|
|
386
|
+
retry_count=0,
|
|
387
|
+
field_name=field_name
|
|
388
|
+
))
|
|
389
|
+
logger.info(f"Queued critical message for retry: {field_name}")
|
|
390
|
+
|
|
391
|
+
return False
|
|
392
|
+
else:
|
|
393
|
+
# Re-raise if not circuit breaker error
|
|
394
|
+
raise
|
|
395
|
+
|
|
396
|
+
except KeyError as e:
|
|
397
|
+
logger.error(
|
|
398
|
+
f"Missing template variable in channel: {e}. "
|
|
399
|
+
f"Template: {mapping['template']}, Context: {context}"
|
|
400
|
+
)
|
|
401
|
+
return False
|
|
402
|
+
|
|
403
|
+
except (RetryError, Exception) as e:
|
|
404
|
+
# Publish failed even after retries
|
|
405
|
+
logger.error(
|
|
406
|
+
f"Failed to publish {field_name} to Centrifugo: {e}",
|
|
407
|
+
exc_info=True if not isinstance(e, RetryError) else False
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# Queue critical messages for background retry
|
|
411
|
+
if mapping['critical']:
|
|
412
|
+
self._failed_messages.append(FailedMessage(
|
|
413
|
+
channel=channel,
|
|
414
|
+
data=data,
|
|
415
|
+
retry_count=0,
|
|
416
|
+
field_name=field_name
|
|
417
|
+
))
|
|
418
|
+
logger.warning(f"Queued failed critical message: {field_name}")
|
|
419
|
+
|
|
420
|
+
if not self._centrifugo_graceful:
|
|
421
|
+
raise
|
|
422
|
+
|
|
423
|
+
return False
|
|
424
|
+
|
|
425
|
+
def _transform_field(
|
|
426
|
+
self,
|
|
427
|
+
field_name: str,
|
|
428
|
+
field_value: Any,
|
|
429
|
+
mapping: Dict[str, Any],
|
|
430
|
+
context: dict
|
|
431
|
+
) -> dict:
|
|
432
|
+
"""
|
|
433
|
+
Transform protobuf field to JSON-serializable dict.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
field_name: Field name
|
|
437
|
+
field_value: Protobuf message field value
|
|
438
|
+
mapping: Channel mapping with optional transform function
|
|
439
|
+
context: Template context variables
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
JSON-serializable dictionary
|
|
443
|
+
"""
|
|
444
|
+
# Use custom transform if provided
|
|
445
|
+
if mapping['transform']:
|
|
446
|
+
data = mapping['transform'](field_name, field_value)
|
|
447
|
+
else:
|
|
448
|
+
# Default protobuf → dict transform
|
|
449
|
+
data = transform_protobuf_to_dict(field_value)
|
|
450
|
+
|
|
451
|
+
# Add metadata
|
|
452
|
+
data['type'] = field_name
|
|
453
|
+
data['timestamp'] = datetime.now(tz.utc).isoformat()
|
|
454
|
+
|
|
455
|
+
# Merge channel metadata
|
|
456
|
+
if mapping['metadata']:
|
|
457
|
+
for key, value in mapping['metadata'].items():
|
|
458
|
+
if key not in data:
|
|
459
|
+
data[key] = value
|
|
460
|
+
|
|
461
|
+
# Add context variables (bot_id, user_id, etc.)
|
|
462
|
+
for key, value in context.items():
|
|
463
|
+
if key not in data:
|
|
464
|
+
data[key] = value
|
|
465
|
+
|
|
466
|
+
return data
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
__all__ = ["CentrifugoBridgeMixin"]
|
|
@@ -31,7 +31,7 @@ from rich.table import Table
|
|
|
31
31
|
|
|
32
32
|
from .bridge import CentrifugoBridgeMixin
|
|
33
33
|
from .config import ChannelConfig, CentrifugoChannels
|
|
34
|
-
from
|
|
34
|
+
from ...utils.streaming_logger import setup_streaming_logger
|
|
35
35
|
|
|
36
36
|
# Setup logger with Rich support
|
|
37
37
|
logger = setup_streaming_logger(
|
|
@@ -5,12 +5,12 @@ Run this to test that events are being published to Centrifugo channels.
|
|
|
5
5
|
|
|
6
6
|
Usage:
|
|
7
7
|
# From Django shell:
|
|
8
|
-
>>> from django_cfg.apps.integrations.grpc.centrifugo.test_publish import run_test
|
|
8
|
+
>>> from django_cfg.apps.integrations.grpc.services.centrifugo.test_publish import run_test
|
|
9
9
|
>>> await run_test()
|
|
10
10
|
|
|
11
11
|
# Or from async context:
|
|
12
12
|
>>> import asyncio
|
|
13
|
-
>>> from django_cfg.apps.integrations.grpc.centrifugo.test_publish import run_test
|
|
13
|
+
>>> from django_cfg.apps.integrations.grpc.services.centrifugo.test_publish import run_test
|
|
14
14
|
>>> asyncio.run(run_test())
|
|
15
15
|
"""
|
|
16
16
|
|
|
@@ -72,7 +72,7 @@ async def run_test(verbose: bool = True):
|
|
|
72
72
|
logger.info("\n📍 Step 2: Testing Interceptor-style publishing (RPC metadata)")
|
|
73
73
|
logger.info("-" * 70)
|
|
74
74
|
try:
|
|
75
|
-
from django_cfg.apps.integrations.grpc.centrifugo.demo import send_demo_event
|
|
75
|
+
from django_cfg.apps.integrations.grpc.services.centrifugo.demo import send_demo_event
|
|
76
76
|
|
|
77
77
|
# Send 3 test events
|
|
78
78
|
for i in range(1, 4):
|
|
@@ -100,7 +100,7 @@ async def run_test(verbose: bool = True):
|
|
|
100
100
|
logger.info("\n📍 Step 3: Testing Mixin-style publishing (message data)")
|
|
101
101
|
logger.info("-" * 70)
|
|
102
102
|
try:
|
|
103
|
-
from django_cfg.apps.integrations.grpc.centrifugo.demo import test_demo_service
|
|
103
|
+
from django_cfg.apps.integrations.grpc.services.centrifugo.demo import test_demo_service
|
|
104
104
|
|
|
105
105
|
stats = await test_demo_service(service_id='test-integration', count=3)
|
|
106
106
|
results['mixin_test'] = stats
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
gRPC client utilities for django-cfg.
|
|
3
|
+
|
|
4
|
+
This package provides tools for creating and managing gRPC client connections.
|
|
5
|
+
|
|
6
|
+
**Components**:
|
|
7
|
+
- client: GrpcClient for service-to-service communication
|
|
8
|
+
|
|
9
|
+
**Usage Example**:
|
|
10
|
+
```python
|
|
11
|
+
from django_cfg.apps.integrations.grpc.services.client import GrpcClient
|
|
12
|
+
|
|
13
|
+
client = GrpcClient(host="localhost", port=50051)
|
|
14
|
+
# Use client for gRPC calls
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Created: 2025-11-07
|
|
18
|
+
Status: %%PRODUCTION%%
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# Export when client module is refactored
|
|
22
|
+
# from .client import GrpcClient
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
# 'GrpcClient', # Uncomment when ready
|
|
26
|
+
]
|