django-cfg 1.5.14__py3-none-any.whl → 1.5.29__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/business/accounts/serializers/profile.py +42 -0
- django_cfg/apps/business/support/serializers.py +3 -2
- django_cfg/apps/integrations/centrifugo/__init__.py +2 -0
- django_cfg/apps/integrations/centrifugo/apps.py +2 -1
- django_cfg/apps/integrations/centrifugo/codegen/generators/typescript_thin/templates/rpc-client.ts.j2 +151 -12
- django_cfg/apps/integrations/centrifugo/management/commands/generate_centrifugo_clients.py +2 -2
- django_cfg/apps/integrations/centrifugo/services/__init__.py +6 -0
- django_cfg/apps/integrations/centrifugo/services/client/__init__.py +6 -1
- django_cfg/apps/integrations/centrifugo/services/client/client.py +1 -1
- django_cfg/apps/integrations/centrifugo/services/client/direct_client.py +282 -0
- django_cfg/apps/integrations/centrifugo/services/logging.py +47 -0
- django_cfg/apps/integrations/centrifugo/services/publisher.py +371 -0
- django_cfg/apps/integrations/centrifugo/services/token_generator.py +122 -0
- django_cfg/apps/integrations/centrifugo/urls.py +8 -0
- django_cfg/apps/integrations/centrifugo/views/__init__.py +2 -0
- django_cfg/apps/integrations/centrifugo/views/admin_api.py +29 -32
- django_cfg/apps/integrations/centrifugo/views/testing_api.py +31 -116
- django_cfg/apps/integrations/centrifugo/views/token_api.py +101 -0
- django_cfg/apps/integrations/centrifugo/views/wrapper.py +259 -0
- django_cfg/apps/integrations/grpc/auth/api_key_auth.py +11 -10
- django_cfg/apps/integrations/grpc/management/commands/compile_proto.py +105 -0
- django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +56 -1
- django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +315 -26
- django_cfg/apps/integrations/grpc/management/proto/__init__.py +3 -0
- django_cfg/apps/integrations/grpc/management/proto/compiler.py +194 -0
- 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/__init__.py +29 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/bridge.py +469 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/config.py +167 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/demo.py +626 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/test_publish.py +229 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/transformers.py +89 -0
- 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} +67 -54
- django_cfg/apps/integrations/grpc/services/{service_registry.py → discovery/registry.py} +215 -5
- django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/__init__.py +3 -1
- django_cfg/apps/integrations/grpc/services/interceptors/centrifugo.py +541 -0
- 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/SERVER_LOGGING.md +164 -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 +261 -13
- django_cfg/apps/integrations/grpc/views/charts.py +1 -1
- django_cfg/apps/integrations/grpc/views/config.py +1 -1
- django_cfg/apps/system/dashboard/serializers/config.py +95 -9
- django_cfg/apps/system/dashboard/serializers/statistics.py +9 -4
- django_cfg/apps/system/frontend/views.py +87 -6
- django_cfg/core/base/config_model.py +11 -0
- django_cfg/core/builders/middleware_builder.py +5 -0
- django_cfg/core/builders/security_builder.py +1 -0
- django_cfg/core/generation/integration_generators/api.py +2 -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/generator.py +26 -0
- django_cfg/modules/django_client/core/generator/typescript/hooks_generator.py +7 -1
- django_cfg/modules/django_client/core/generator/typescript/models_generator.py +5 -0
- django_cfg/modules/django_client/core/generator/typescript/schemas_generator.py +11 -0
- django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/fetchers.ts.jinja +1 -0
- django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +29 -1
- django_cfg/modules/django_client/core/generator/typescript/templates/hooks/hooks.ts.jinja +4 -0
- django_cfg/modules/django_client/core/groups/manager.py +25 -18
- django_cfg/modules/django_client/core/ir/schema.py +15 -1
- django_cfg/modules/django_client/core/parser/base.py +12 -0
- django_cfg/modules/django_client/management/commands/generate_client.py +9 -5
- django_cfg/modules/django_logging/django_logger.py +58 -19
- django_cfg/pyproject.toml +3 -3
- django_cfg/static/frontend/admin.zip +0 -0
- django_cfg/templates/admin/index.html +0 -39
- django_cfg/utils/pool_monitor.py +320 -0
- django_cfg/utils/smart_defaults.py +233 -7
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/METADATA +75 -5
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/RECORD +118 -74
- /django_cfg/apps/integrations/grpc/services/{grpc_client.py → client/client.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.14.dist-info → django_cfg-1.5.29.dist-info}/WHEEL +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Universal bidirectional streaming service for gRPC.
|
|
3
|
+
|
|
4
|
+
This module provides a generic, type-safe implementation of bidirectional gRPC streaming.
|
|
5
|
+
It extracts the common pattern used across signals and trading_bots services.
|
|
6
|
+
|
|
7
|
+
**Key Features**:
|
|
8
|
+
- Generic over TMessage (input) and TCommand (output) types
|
|
9
|
+
- Type-safe callbacks via Protocol types
|
|
10
|
+
- Pydantic v2 configuration with validation
|
|
11
|
+
- Automatic ping/keepalive handling
|
|
12
|
+
- Proper concurrent input/output processing
|
|
13
|
+
- Critical `await asyncio.sleep(0)` for event loop yielding
|
|
14
|
+
- Connection lifecycle management
|
|
15
|
+
|
|
16
|
+
**Usage Example**:
|
|
17
|
+
```python
|
|
18
|
+
from .types import MessageProcessor, ClientIdExtractor, PingMessageCreator
|
|
19
|
+
from .config import BidirectionalStreamingConfig, ConfigPresets
|
|
20
|
+
|
|
21
|
+
# Define your callbacks
|
|
22
|
+
async def process_messages(
|
|
23
|
+
client_id: str,
|
|
24
|
+
message: SignalCommand,
|
|
25
|
+
output_queue: asyncio.Queue[SignalMessage]
|
|
26
|
+
) -> None:
|
|
27
|
+
# Your business logic
|
|
28
|
+
response = await handle_signal(message)
|
|
29
|
+
await output_queue.put(response)
|
|
30
|
+
|
|
31
|
+
def extract_client_id(message: SignalCommand) -> str:
|
|
32
|
+
return message.client_id
|
|
33
|
+
|
|
34
|
+
def create_ping() -> SignalMessage:
|
|
35
|
+
return SignalMessage(is_ping=True)
|
|
36
|
+
|
|
37
|
+
# Create service instance
|
|
38
|
+
service = BidirectionalStreamingService(
|
|
39
|
+
config=ConfigPresets.PRODUCTION,
|
|
40
|
+
message_processor=process_messages,
|
|
41
|
+
client_id_extractor=extract_client_id,
|
|
42
|
+
ping_message_creator=create_ping,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Use in gRPC servicer
|
|
46
|
+
async def BidirectionalStream(self, request_iterator, context):
|
|
47
|
+
async for response in service.handle_stream(request_iterator, context):
|
|
48
|
+
yield response
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Created: 2025-11-07
|
|
52
|
+
Status: %%PRODUCTION%%
|
|
53
|
+
Phase: Phase 1 - Universal Components
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from typing import Generic, Optional, AsyncIterator, Dict
|
|
57
|
+
import asyncio
|
|
58
|
+
import logging
|
|
59
|
+
import time
|
|
60
|
+
|
|
61
|
+
import grpc
|
|
62
|
+
|
|
63
|
+
from .types import (
|
|
64
|
+
TMessage,
|
|
65
|
+
TCommand,
|
|
66
|
+
MessageProcessor,
|
|
67
|
+
ClientIdExtractor,
|
|
68
|
+
PingMessageCreator,
|
|
69
|
+
ConnectionCallback,
|
|
70
|
+
ErrorHandler,
|
|
71
|
+
)
|
|
72
|
+
from .config import BidirectionalStreamingConfig, StreamingMode, PingStrategy
|
|
73
|
+
|
|
74
|
+
# Import setup_streaming_logger for auto-created logger
|
|
75
|
+
from django_cfg.apps.integrations.grpc.utils.streaming_logger import setup_streaming_logger
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Module-level logger (fallback only)
|
|
79
|
+
logger = logging.getLogger(__name__)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ============================================================================
|
|
83
|
+
# Main Service Class
|
|
84
|
+
# ============================================================================
|
|
85
|
+
|
|
86
|
+
class BidirectionalStreamingService(Generic[TMessage, TCommand]):
|
|
87
|
+
"""
|
|
88
|
+
Universal bidirectional streaming service with type-safe callbacks.
|
|
89
|
+
|
|
90
|
+
This service handles the complex concurrent streaming pattern used in
|
|
91
|
+
signals and trading_bots services, making it reusable across projects.
|
|
92
|
+
|
|
93
|
+
**Type Parameters**:
|
|
94
|
+
TMessage: Type of incoming messages from client
|
|
95
|
+
TCommand: Type of outgoing commands to client
|
|
96
|
+
|
|
97
|
+
**Architecture**:
|
|
98
|
+
```
|
|
99
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
100
|
+
│ BidirectionalStreamingService │
|
|
101
|
+
│ │
|
|
102
|
+
│ ┌─────────────────┐ ┌──────────────────┐ │
|
|
103
|
+
│ │ Input Task │───────>│ output_queue │ │
|
|
104
|
+
│ │ (processes │ │ (asyncio.Queue) │ │
|
|
105
|
+
│ │ messages) │ └──────────────────┘ │
|
|
106
|
+
│ └─────────────────┘ │ │
|
|
107
|
+
│ │ │ │
|
|
108
|
+
│ │ await asyncio.sleep(0) │ │
|
|
109
|
+
│ │ (CRITICAL!) │ │
|
|
110
|
+
│ │ ▼ │
|
|
111
|
+
│ │ ┌──────────────────┐ │
|
|
112
|
+
│ │ │ Output Loop │ │
|
|
113
|
+
│ │ │ (yields to │ │
|
|
114
|
+
│ └──────────────────│ client) │ │
|
|
115
|
+
│ └──────────────────┘ │
|
|
116
|
+
└─────────────────────────────────────────────────────────────┘
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**Concurrency Model**:
|
|
120
|
+
- Input task runs concurrently, processing incoming messages
|
|
121
|
+
- Output loop yields commands from queue back to client
|
|
122
|
+
- `await asyncio.sleep(0)` ensures output loop can yield promptly
|
|
123
|
+
- Ping messages sent on timeout to keep connection alive
|
|
124
|
+
|
|
125
|
+
**Parameters**:
|
|
126
|
+
config: Pydantic configuration model
|
|
127
|
+
message_processor: Callback to process each incoming message
|
|
128
|
+
client_id_extractor: Callback to extract client ID from message
|
|
129
|
+
ping_message_creator: Callback to create ping messages
|
|
130
|
+
on_connect: Optional callback when client connects
|
|
131
|
+
on_disconnect: Optional callback when client disconnects
|
|
132
|
+
on_error: Optional callback on errors
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
config: BidirectionalStreamingConfig,
|
|
138
|
+
message_processor: MessageProcessor[TMessage, TCommand],
|
|
139
|
+
client_id_extractor: ClientIdExtractor[TMessage],
|
|
140
|
+
ping_message_creator: PingMessageCreator[TCommand],
|
|
141
|
+
on_connect: Optional[ConnectionCallback] = None,
|
|
142
|
+
on_disconnect: Optional[ConnectionCallback] = None,
|
|
143
|
+
on_error: Optional[ErrorHandler] = None,
|
|
144
|
+
logger: Optional[logging.Logger] = None,
|
|
145
|
+
):
|
|
146
|
+
"""
|
|
147
|
+
Initialize bidirectional streaming service.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
config: Pydantic configuration (frozen, validated)
|
|
151
|
+
message_processor: Process incoming messages
|
|
152
|
+
client_id_extractor: Extract client ID from messages
|
|
153
|
+
ping_message_creator: Create ping messages
|
|
154
|
+
on_connect: Optional connection callback
|
|
155
|
+
on_disconnect: Optional disconnection callback
|
|
156
|
+
on_error: Optional error callback
|
|
157
|
+
logger: Optional logger instance (auto-created if None)
|
|
158
|
+
"""
|
|
159
|
+
self.config = config
|
|
160
|
+
self.message_processor = message_processor
|
|
161
|
+
self.client_id_extractor = client_id_extractor
|
|
162
|
+
self.ping_message_creator = ping_message_creator
|
|
163
|
+
self.on_connect = on_connect
|
|
164
|
+
self.on_disconnect = on_disconnect
|
|
165
|
+
self.on_error = on_error
|
|
166
|
+
|
|
167
|
+
# Auto-create logger if not provided
|
|
168
|
+
if logger is None:
|
|
169
|
+
logger_name = self.config.logger_name or "grpc_streaming"
|
|
170
|
+
self.logger = setup_streaming_logger(
|
|
171
|
+
name=logger_name,
|
|
172
|
+
level=logging.DEBUG,
|
|
173
|
+
console_level=logging.INFO
|
|
174
|
+
)
|
|
175
|
+
else:
|
|
176
|
+
self.logger = logger
|
|
177
|
+
|
|
178
|
+
# Active connections tracking
|
|
179
|
+
self._active_connections: Dict[str, asyncio.Queue[TCommand]] = {}
|
|
180
|
+
|
|
181
|
+
if self.config.enable_logging:
|
|
182
|
+
self.logger.info(
|
|
183
|
+
f"BidirectionalStreamingService initialized: "
|
|
184
|
+
f"mode={self.config.streaming_mode.value}, "
|
|
185
|
+
f"ping={self.config.ping_strategy.value}, "
|
|
186
|
+
f"interval={self.config.ping_interval}s"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# ------------------------------------------------------------------------
|
|
190
|
+
# Main Stream Handler
|
|
191
|
+
# ------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
async def handle_stream(
|
|
194
|
+
self,
|
|
195
|
+
request_iterator: AsyncIterator[TMessage],
|
|
196
|
+
context: grpc.aio.ServicerContext,
|
|
197
|
+
) -> AsyncIterator[TCommand]:
|
|
198
|
+
"""
|
|
199
|
+
Handle bidirectional gRPC stream.
|
|
200
|
+
|
|
201
|
+
This is the main entry point called by gRPC servicer methods.
|
|
202
|
+
|
|
203
|
+
**Flow**:
|
|
204
|
+
1. Create output queue for this connection
|
|
205
|
+
2. Start input task to process messages concurrently
|
|
206
|
+
3. Yield commands from output queue (with ping on timeout)
|
|
207
|
+
4. Handle cancellation and cleanup
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
request_iterator: Incoming message stream from client
|
|
211
|
+
context: gRPC service context
|
|
212
|
+
|
|
213
|
+
Yields:
|
|
214
|
+
Commands to send back to client
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
asyncio.CancelledError: On client disconnect
|
|
218
|
+
grpc.RpcError: On gRPC errors
|
|
219
|
+
"""
|
|
220
|
+
client_id: Optional[str] = None
|
|
221
|
+
output_queue: Optional[asyncio.Queue[TCommand]] = None
|
|
222
|
+
input_task: Optional[asyncio.Task] = None
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
# Create output queue for this connection
|
|
226
|
+
output_queue = asyncio.Queue(maxsize=self.config.max_queue_size)
|
|
227
|
+
|
|
228
|
+
# Start background task to process incoming messages
|
|
229
|
+
# This runs concurrently with output streaming below
|
|
230
|
+
input_task = asyncio.create_task(
|
|
231
|
+
self._process_input_stream(
|
|
232
|
+
request_iterator,
|
|
233
|
+
output_queue,
|
|
234
|
+
context,
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Main output loop: yield commands from queue
|
|
239
|
+
async for command in self._output_loop(output_queue, context):
|
|
240
|
+
yield command
|
|
241
|
+
|
|
242
|
+
# Output loop finished, wait for input task
|
|
243
|
+
if self.config.enable_logging:
|
|
244
|
+
self.logger.info("Output loop finished, waiting for input task...")
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
await input_task
|
|
248
|
+
if self.config.enable_logging:
|
|
249
|
+
self.logger.info("Input task completed successfully")
|
|
250
|
+
except Exception as e:
|
|
251
|
+
if self.config.enable_logging:
|
|
252
|
+
self.logger.error(f"Input task error: {e}", exc_info=True)
|
|
253
|
+
if self.on_error and client_id:
|
|
254
|
+
await self.on_error(client_id, e)
|
|
255
|
+
|
|
256
|
+
except asyncio.CancelledError:
|
|
257
|
+
if self.config.enable_logging:
|
|
258
|
+
self.logger.info(f"Client {client_id} stream cancelled")
|
|
259
|
+
|
|
260
|
+
# Cancel input task if still running
|
|
261
|
+
if input_task and not input_task.done():
|
|
262
|
+
input_task.cancel()
|
|
263
|
+
try:
|
|
264
|
+
await input_task
|
|
265
|
+
except asyncio.CancelledError:
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
raise
|
|
269
|
+
|
|
270
|
+
except Exception as e:
|
|
271
|
+
if self.config.enable_logging:
|
|
272
|
+
self.logger.error(f"Client {client_id} stream error: {e}", exc_info=True)
|
|
273
|
+
|
|
274
|
+
if self.on_error and client_id:
|
|
275
|
+
await self.on_error(client_id, e)
|
|
276
|
+
|
|
277
|
+
await context.abort(grpc.StatusCode.INTERNAL, f"Server error: {e}")
|
|
278
|
+
|
|
279
|
+
finally:
|
|
280
|
+
# Cleanup connection
|
|
281
|
+
if client_id and client_id in self._active_connections:
|
|
282
|
+
del self._active_connections[client_id]
|
|
283
|
+
if self.config.enable_logging:
|
|
284
|
+
self.logger.info(f"Client {client_id} disconnected")
|
|
285
|
+
|
|
286
|
+
if self.on_disconnect:
|
|
287
|
+
await self.on_disconnect(client_id)
|
|
288
|
+
|
|
289
|
+
# ------------------------------------------------------------------------
|
|
290
|
+
# Output Loop
|
|
291
|
+
# ------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
async def _output_loop(
|
|
294
|
+
self,
|
|
295
|
+
output_queue: asyncio.Queue[TCommand],
|
|
296
|
+
context: grpc.aio.ServicerContext,
|
|
297
|
+
) -> AsyncIterator[TCommand]:
|
|
298
|
+
"""
|
|
299
|
+
Main output loop that yields commands to client.
|
|
300
|
+
|
|
301
|
+
**Logic**:
|
|
302
|
+
- Wait for commands in queue with timeout
|
|
303
|
+
- If timeout and ping enabled -> send ping
|
|
304
|
+
- Yield commands to client
|
|
305
|
+
- Stop when context cancelled or sentinel received
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
output_queue: Queue containing commands to send
|
|
309
|
+
context: gRPC service context
|
|
310
|
+
|
|
311
|
+
Yields:
|
|
312
|
+
Commands to send to client
|
|
313
|
+
"""
|
|
314
|
+
ping_sequence = 0
|
|
315
|
+
last_message_time = time.time()
|
|
316
|
+
consecutive_errors = 0
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
while not context.cancelled():
|
|
320
|
+
try:
|
|
321
|
+
# Determine timeout based on ping strategy
|
|
322
|
+
timeout = self._get_output_timeout(last_message_time)
|
|
323
|
+
|
|
324
|
+
# Wait for command with timeout
|
|
325
|
+
command = await asyncio.wait_for(
|
|
326
|
+
output_queue.get(),
|
|
327
|
+
timeout=timeout,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# Check for shutdown sentinel (None)
|
|
331
|
+
if command is None:
|
|
332
|
+
if self.config.enable_logging:
|
|
333
|
+
self.logger.info("Received shutdown sentinel")
|
|
334
|
+
break
|
|
335
|
+
|
|
336
|
+
# Yield command to client
|
|
337
|
+
yield command
|
|
338
|
+
last_message_time = time.time()
|
|
339
|
+
consecutive_errors = 0 # Reset error counter
|
|
340
|
+
|
|
341
|
+
if self.config.enable_logging:
|
|
342
|
+
self.logger.debug("Sent command to client")
|
|
343
|
+
|
|
344
|
+
except asyncio.TimeoutError:
|
|
345
|
+
# Timeout - send ping if enabled
|
|
346
|
+
if self.config.is_ping_enabled():
|
|
347
|
+
ping_sequence += 1
|
|
348
|
+
ping_command = self.ping_message_creator()
|
|
349
|
+
yield ping_command
|
|
350
|
+
last_message_time = time.time()
|
|
351
|
+
|
|
352
|
+
if self.config.enable_logging:
|
|
353
|
+
self.logger.debug(f"Sent PING #{ping_sequence}")
|
|
354
|
+
|
|
355
|
+
except Exception as e:
|
|
356
|
+
consecutive_errors += 1
|
|
357
|
+
if self.config.enable_logging:
|
|
358
|
+
self.logger.error(f"Output loop error: {e}", exc_info=True)
|
|
359
|
+
|
|
360
|
+
# Check if max consecutive errors exceeded
|
|
361
|
+
if (
|
|
362
|
+
self.config.max_consecutive_errors > 0
|
|
363
|
+
and consecutive_errors >= self.config.max_consecutive_errors
|
|
364
|
+
):
|
|
365
|
+
if self.config.enable_logging:
|
|
366
|
+
self.logger.error(
|
|
367
|
+
f"Max consecutive errors ({self.config.max_consecutive_errors}) exceeded"
|
|
368
|
+
)
|
|
369
|
+
break
|
|
370
|
+
|
|
371
|
+
except asyncio.CancelledError:
|
|
372
|
+
if self.config.enable_logging:
|
|
373
|
+
self.logger.info("Output loop cancelled")
|
|
374
|
+
raise
|
|
375
|
+
|
|
376
|
+
def _get_output_timeout(self, last_message_time: float) -> Optional[float]:
|
|
377
|
+
"""
|
|
378
|
+
Calculate output queue timeout based on ping strategy.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
last_message_time: Timestamp of last sent message
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
Timeout in seconds, or None for no timeout
|
|
385
|
+
"""
|
|
386
|
+
if self.config.ping_strategy == PingStrategy.DISABLED:
|
|
387
|
+
# No timeout when ping disabled (wait indefinitely)
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
elif self.config.ping_strategy == PingStrategy.INTERVAL:
|
|
391
|
+
# Fixed interval timeout
|
|
392
|
+
return self.config.ping_interval
|
|
393
|
+
|
|
394
|
+
elif self.config.ping_strategy == PingStrategy.ON_IDLE:
|
|
395
|
+
# Timeout based on time since last message
|
|
396
|
+
elapsed = time.time() - last_message_time
|
|
397
|
+
remaining = self.config.ping_interval - elapsed
|
|
398
|
+
return max(remaining, 0.1) # At least 0.1s
|
|
399
|
+
|
|
400
|
+
return self.config.ping_interval # Fallback
|
|
401
|
+
|
|
402
|
+
# ------------------------------------------------------------------------
|
|
403
|
+
# Input Processing
|
|
404
|
+
# ------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
async def _process_input_stream(
|
|
407
|
+
self,
|
|
408
|
+
request_iterator: AsyncIterator[TMessage],
|
|
409
|
+
output_queue: asyncio.Queue[TCommand],
|
|
410
|
+
context: grpc.aio.ServicerContext,
|
|
411
|
+
) -> None:
|
|
412
|
+
"""
|
|
413
|
+
Process incoming messages from client.
|
|
414
|
+
|
|
415
|
+
**Flow**:
|
|
416
|
+
1. Iterate over incoming messages
|
|
417
|
+
2. Extract client ID from first message
|
|
418
|
+
3. Call on_connect callback
|
|
419
|
+
4. Process each message via message_processor
|
|
420
|
+
5. **CRITICAL**: `await asyncio.sleep(0)` to yield event loop
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
request_iterator: Stream of incoming messages
|
|
424
|
+
output_queue: Queue for outgoing commands
|
|
425
|
+
context: gRPC service context
|
|
426
|
+
|
|
427
|
+
Raises:
|
|
428
|
+
Exception: Any processing errors
|
|
429
|
+
"""
|
|
430
|
+
client_id: Optional[str] = None
|
|
431
|
+
is_first_message = True
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
# Choose iteration mode based on config
|
|
435
|
+
if self.config.streaming_mode == StreamingMode.ASYNC_FOR:
|
|
436
|
+
await self._process_async_for(
|
|
437
|
+
request_iterator,
|
|
438
|
+
output_queue,
|
|
439
|
+
context,
|
|
440
|
+
)
|
|
441
|
+
else: # StreamingMode.ANEXT
|
|
442
|
+
await self._process_anext(
|
|
443
|
+
request_iterator,
|
|
444
|
+
output_queue,
|
|
445
|
+
context,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
except asyncio.CancelledError:
|
|
449
|
+
if self.config.enable_logging:
|
|
450
|
+
self.logger.info(f"Input stream cancelled for client {client_id}")
|
|
451
|
+
raise
|
|
452
|
+
|
|
453
|
+
except Exception as e:
|
|
454
|
+
if self.config.enable_logging:
|
|
455
|
+
self.logger.error(f"Input stream error for client {client_id}: {e}", exc_info=True)
|
|
456
|
+
raise
|
|
457
|
+
|
|
458
|
+
async def _process_async_for(
|
|
459
|
+
self,
|
|
460
|
+
request_iterator: AsyncIterator[TMessage],
|
|
461
|
+
output_queue: asyncio.Queue[TCommand],
|
|
462
|
+
context: grpc.aio.ServicerContext,
|
|
463
|
+
) -> None:
|
|
464
|
+
"""Process input stream using async for iteration."""
|
|
465
|
+
client_id: Optional[str] = None
|
|
466
|
+
is_first_message = True
|
|
467
|
+
|
|
468
|
+
async for message in request_iterator:
|
|
469
|
+
# Extract client ID from first message
|
|
470
|
+
if is_first_message:
|
|
471
|
+
client_id = self.client_id_extractor(message)
|
|
472
|
+
self._active_connections[client_id] = output_queue
|
|
473
|
+
is_first_message = False
|
|
474
|
+
|
|
475
|
+
if self.config.enable_logging:
|
|
476
|
+
self.logger.info(f"Client {client_id} connected")
|
|
477
|
+
|
|
478
|
+
if self.on_connect:
|
|
479
|
+
await self.on_connect(client_id)
|
|
480
|
+
|
|
481
|
+
# Process message
|
|
482
|
+
await self.message_processor(client_id, message, output_queue)
|
|
483
|
+
|
|
484
|
+
# ⚠️ CRITICAL: Yield to event loop!
|
|
485
|
+
# Without this, the next message read blocks output loop from yielding.
|
|
486
|
+
# This is the key pattern that makes bidirectional streaming work correctly.
|
|
487
|
+
if self.config.should_yield_event_loop():
|
|
488
|
+
await asyncio.sleep(0)
|
|
489
|
+
|
|
490
|
+
async def _process_anext(
|
|
491
|
+
self,
|
|
492
|
+
request_iterator: AsyncIterator[TMessage],
|
|
493
|
+
output_queue: asyncio.Queue[TCommand],
|
|
494
|
+
context: grpc.aio.ServicerContext,
|
|
495
|
+
) -> None:
|
|
496
|
+
"""Process input stream using anext() calls."""
|
|
497
|
+
client_id: Optional[str] = None
|
|
498
|
+
is_first_message = True
|
|
499
|
+
|
|
500
|
+
while not context.cancelled():
|
|
501
|
+
try:
|
|
502
|
+
# Get next message with optional timeout
|
|
503
|
+
if self.config.connection_timeout:
|
|
504
|
+
message = await asyncio.wait_for(
|
|
505
|
+
anext(request_iterator),
|
|
506
|
+
timeout=self.config.connection_timeout,
|
|
507
|
+
)
|
|
508
|
+
else:
|
|
509
|
+
message = await anext(request_iterator)
|
|
510
|
+
|
|
511
|
+
# Extract client ID from first message
|
|
512
|
+
if is_first_message:
|
|
513
|
+
client_id = self.client_id_extractor(message)
|
|
514
|
+
self._active_connections[client_id] = output_queue
|
|
515
|
+
is_first_message = False
|
|
516
|
+
|
|
517
|
+
if self.config.enable_logging:
|
|
518
|
+
self.logger.info(f"Client {client_id} connected")
|
|
519
|
+
|
|
520
|
+
if self.on_connect:
|
|
521
|
+
await self.on_connect(client_id)
|
|
522
|
+
|
|
523
|
+
# Process message
|
|
524
|
+
await self.message_processor(client_id, message, output_queue)
|
|
525
|
+
|
|
526
|
+
# ⚠️ CRITICAL: Yield to event loop!
|
|
527
|
+
if self.config.should_yield_event_loop():
|
|
528
|
+
await asyncio.sleep(0)
|
|
529
|
+
|
|
530
|
+
except StopAsyncIteration:
|
|
531
|
+
# Stream ended normally
|
|
532
|
+
if self.config.enable_logging:
|
|
533
|
+
self.logger.info(f"Client {client_id} stream ended")
|
|
534
|
+
break
|
|
535
|
+
|
|
536
|
+
except asyncio.TimeoutError:
|
|
537
|
+
if self.config.enable_logging:
|
|
538
|
+
self.logger.warning(f"Client {client_id} connection timeout")
|
|
539
|
+
break
|
|
540
|
+
|
|
541
|
+
# ------------------------------------------------------------------------
|
|
542
|
+
# Connection Management
|
|
543
|
+
# ------------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
def get_active_connections(self) -> Dict[str, asyncio.Queue[TCommand]]:
|
|
546
|
+
"""
|
|
547
|
+
Get all active connections.
|
|
548
|
+
|
|
549
|
+
Returns:
|
|
550
|
+
Dict mapping client_id to output_queue
|
|
551
|
+
"""
|
|
552
|
+
return self._active_connections.copy()
|
|
553
|
+
|
|
554
|
+
def is_client_connected(self, client_id: str) -> bool:
|
|
555
|
+
"""
|
|
556
|
+
Check if client is currently connected.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
client_id: Client identifier
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
True if client has active connection
|
|
563
|
+
"""
|
|
564
|
+
return client_id in self._active_connections
|
|
565
|
+
|
|
566
|
+
async def send_to_client(
|
|
567
|
+
self,
|
|
568
|
+
client_id: str,
|
|
569
|
+
command: TCommand,
|
|
570
|
+
timeout: Optional[float] = None,
|
|
571
|
+
) -> bool:
|
|
572
|
+
"""
|
|
573
|
+
Send command to specific client.
|
|
574
|
+
|
|
575
|
+
Args:
|
|
576
|
+
client_id: Target client identifier
|
|
577
|
+
command: Command to send
|
|
578
|
+
timeout: Optional timeout for enqueue (uses config.queue_timeout if None)
|
|
579
|
+
|
|
580
|
+
Returns:
|
|
581
|
+
True if sent successfully, False if client not connected or timeout
|
|
582
|
+
|
|
583
|
+
Raises:
|
|
584
|
+
asyncio.TimeoutError: If enqueue times out and no default handler
|
|
585
|
+
"""
|
|
586
|
+
if client_id not in self._active_connections:
|
|
587
|
+
if self.config.enable_logging:
|
|
588
|
+
self.logger.warning(f"Client {client_id} not connected")
|
|
589
|
+
return False
|
|
590
|
+
|
|
591
|
+
queue = self._active_connections[client_id]
|
|
592
|
+
timeout = timeout or self.config.queue_timeout
|
|
593
|
+
|
|
594
|
+
try:
|
|
595
|
+
await asyncio.wait_for(queue.put(command), timeout=timeout)
|
|
596
|
+
return True
|
|
597
|
+
except asyncio.TimeoutError:
|
|
598
|
+
if self.config.enable_logging:
|
|
599
|
+
self.logger.warning(f"Timeout sending to client {client_id}")
|
|
600
|
+
return False
|
|
601
|
+
except Exception as e:
|
|
602
|
+
if self.config.enable_logging:
|
|
603
|
+
self.logger.error(f"Error sending to client {client_id}: {e}")
|
|
604
|
+
return False
|
|
605
|
+
|
|
606
|
+
async def broadcast_to_all(
|
|
607
|
+
self,
|
|
608
|
+
command: TCommand,
|
|
609
|
+
exclude: Optional[list[str]] = None,
|
|
610
|
+
) -> int:
|
|
611
|
+
"""
|
|
612
|
+
Broadcast command to all connected clients.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
command: Command to broadcast
|
|
616
|
+
exclude: Optional list of client IDs to exclude
|
|
617
|
+
|
|
618
|
+
Returns:
|
|
619
|
+
Number of clients successfully sent to
|
|
620
|
+
"""
|
|
621
|
+
exclude = exclude or []
|
|
622
|
+
sent_count = 0
|
|
623
|
+
|
|
624
|
+
for client_id in list(self._active_connections.keys()):
|
|
625
|
+
if client_id not in exclude:
|
|
626
|
+
if await self.send_to_client(client_id, command):
|
|
627
|
+
sent_count += 1
|
|
628
|
+
|
|
629
|
+
return sent_count
|
|
630
|
+
|
|
631
|
+
async def disconnect_client(self, client_id: str) -> None:
|
|
632
|
+
"""
|
|
633
|
+
Gracefully disconnect a client.
|
|
634
|
+
|
|
635
|
+
Sends shutdown sentinel (None) to trigger clean disconnection.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
client_id: Client to disconnect
|
|
639
|
+
"""
|
|
640
|
+
if client_id in self._active_connections:
|
|
641
|
+
queue = self._active_connections[client_id]
|
|
642
|
+
await queue.put(None) # Sentinel for shutdown
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
# ============================================================================
|
|
646
|
+
# Exports
|
|
647
|
+
# ============================================================================
|
|
648
|
+
|
|
649
|
+
__all__ = [
|
|
650
|
+
'BidirectionalStreamingService',
|
|
651
|
+
]
|