remdb 0.3.114__py3-none-any.whl → 0.3.172__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 remdb might be problematic. Click here for more details.
- rem/agentic/agents/__init__.py +16 -0
- rem/agentic/agents/agent_manager.py +311 -0
- rem/agentic/agents/sse_simulator.py +2 -0
- rem/agentic/context.py +103 -5
- rem/agentic/context_builder.py +36 -9
- rem/agentic/mcp/tool_wrapper.py +161 -18
- rem/agentic/otel/setup.py +1 -0
- rem/agentic/providers/phoenix.py +371 -108
- rem/agentic/providers/pydantic_ai.py +172 -30
- rem/agentic/schema.py +8 -4
- rem/api/deps.py +3 -5
- rem/api/main.py +26 -4
- rem/api/mcp_router/resources.py +15 -10
- rem/api/mcp_router/server.py +11 -3
- rem/api/mcp_router/tools.py +418 -4
- rem/api/middleware/tracking.py +5 -5
- rem/api/routers/admin.py +218 -1
- rem/api/routers/auth.py +349 -6
- rem/api/routers/chat/completions.py +255 -7
- rem/api/routers/chat/models.py +81 -7
- rem/api/routers/chat/otel_utils.py +33 -0
- rem/api/routers/chat/sse_events.py +17 -1
- rem/api/routers/chat/streaming.py +126 -19
- rem/api/routers/feedback.py +134 -14
- rem/api/routers/messages.py +24 -15
- rem/api/routers/query.py +6 -3
- rem/auth/__init__.py +13 -3
- rem/auth/jwt.py +352 -0
- rem/auth/middleware.py +115 -10
- rem/auth/providers/__init__.py +4 -1
- rem/auth/providers/email.py +215 -0
- rem/cli/commands/README.md +42 -0
- rem/cli/commands/cluster.py +617 -168
- rem/cli/commands/configure.py +4 -7
- rem/cli/commands/db.py +66 -22
- rem/cli/commands/experiments.py +468 -76
- rem/cli/commands/schema.py +6 -5
- rem/cli/commands/session.py +336 -0
- rem/cli/dreaming.py +2 -2
- rem/cli/main.py +2 -0
- rem/config.py +8 -1
- rem/models/core/experiment.py +58 -14
- rem/models/entities/__init__.py +4 -0
- rem/models/entities/ontology.py +1 -1
- rem/models/entities/ontology_config.py +1 -1
- rem/models/entities/subscriber.py +175 -0
- rem/models/entities/user.py +1 -0
- rem/schemas/agents/core/agent-builder.yaml +235 -0
- rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
- rem/schemas/agents/examples/contract-extractor.yaml +1 -1
- rem/schemas/agents/examples/cv-parser.yaml +1 -1
- rem/services/__init__.py +3 -1
- rem/services/content/service.py +4 -3
- rem/services/email/__init__.py +10 -0
- rem/services/email/service.py +513 -0
- rem/services/email/templates.py +360 -0
- rem/services/phoenix/client.py +59 -18
- rem/services/postgres/README.md +38 -0
- rem/services/postgres/diff_service.py +127 -6
- rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
- rem/services/postgres/repository.py +5 -4
- rem/services/postgres/schema_generator.py +205 -4
- rem/services/session/compression.py +120 -50
- rem/services/session/reload.py +14 -7
- rem/services/user_service.py +41 -9
- rem/settings.py +442 -23
- rem/sql/migrations/001_install.sql +156 -0
- rem/sql/migrations/002_install_models.sql +1951 -88
- rem/sql/migrations/004_cache_system.sql +548 -0
- rem/sql/migrations/005_schema_update.sql +145 -0
- rem/utils/README.md +45 -0
- rem/utils/__init__.py +18 -0
- rem/utils/files.py +157 -1
- rem/utils/schema_loader.py +139 -10
- rem/utils/sql_paths.py +146 -0
- rem/utils/vision.py +1 -1
- rem/workers/__init__.py +3 -1
- rem/workers/db_listener.py +579 -0
- rem/workers/unlogged_maintainer.py +463 -0
- {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/METADATA +218 -180
- {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/RECORD +83 -68
- {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/WHEEL +0 -0
- {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PostgreSQL LISTEN/NOTIFY Database Listener Worker.
|
|
3
|
+
|
|
4
|
+
A lightweight, always-running worker that listens for PostgreSQL NOTIFY events
|
|
5
|
+
and dispatches them to configurable handlers (SQS, REST API, or custom jobs).
|
|
6
|
+
|
|
7
|
+
Architecture Overview
|
|
8
|
+
---------------------
|
|
9
|
+
|
|
10
|
+
PostgreSQL's LISTEN/NOTIFY is a pub/sub mechanism built into the database:
|
|
11
|
+
|
|
12
|
+
1. Application code issues: NOTIFY channel_name, 'payload'
|
|
13
|
+
2. All clients LISTENing on that channel receive the notification
|
|
14
|
+
3. Notifications are delivered in transaction commit order
|
|
15
|
+
4. If no listeners, the notification is simply dropped (fire-and-forget)
|
|
16
|
+
|
|
17
|
+
This worker maintains a dedicated connection that LISTENs on configured channels
|
|
18
|
+
and dispatches notifications to external systems.
|
|
19
|
+
|
|
20
|
+
Why LISTEN/NOTIFY?
|
|
21
|
+
------------------
|
|
22
|
+
|
|
23
|
+
From https://brandur.org/notifier:
|
|
24
|
+
|
|
25
|
+
- **Single connection per process**: Efficient use of Postgres connections
|
|
26
|
+
- **Non-blocking**: Notifications are delivered asynchronously
|
|
27
|
+
- **Delivery guarantees**: Delivered in commit order within a transaction
|
|
28
|
+
- **Resilience**: If the worker is down, the source data remains in tables
|
|
29
|
+
for catch-up processing on restart
|
|
30
|
+
|
|
31
|
+
This is ideal for:
|
|
32
|
+
|
|
33
|
+
- Syncing data to external systems (Phoenix, webhooks, etc.)
|
|
34
|
+
- Triggering async jobs without polling
|
|
35
|
+
- Event-driven architectures with PostgreSQL as the source of truth
|
|
36
|
+
|
|
37
|
+
Typical Flow
|
|
38
|
+
------------
|
|
39
|
+
|
|
40
|
+
1. Trigger on table INSERT/UPDATE sends NOTIFY with row ID
|
|
41
|
+
2. This worker receives the notification
|
|
42
|
+
3. Worker dispatches to configured handler:
|
|
43
|
+
- SQS: Publish message for downstream processing
|
|
44
|
+
- REST: Make HTTP call to internal API endpoint
|
|
45
|
+
- Custom: Execute registered Python handler
|
|
46
|
+
|
|
47
|
+
Example: Feedback Sync to Phoenix
|
|
48
|
+
---------------------------------
|
|
49
|
+
|
|
50
|
+
-- In PostgreSQL (trigger on feedbacks table):
|
|
51
|
+
CREATE OR REPLACE FUNCTION notify_feedback_insert()
|
|
52
|
+
RETURNS TRIGGER AS $$
|
|
53
|
+
BEGIN
|
|
54
|
+
PERFORM pg_notify('feedback_sync', json_build_object(
|
|
55
|
+
'id', NEW.id,
|
|
56
|
+
'table', 'feedbacks',
|
|
57
|
+
'action', 'insert'
|
|
58
|
+
)::text);
|
|
59
|
+
RETURN NEW;
|
|
60
|
+
END;
|
|
61
|
+
$$ LANGUAGE plpgsql;
|
|
62
|
+
|
|
63
|
+
CREATE TRIGGER feedback_insert_notify
|
|
64
|
+
AFTER INSERT ON feedbacks
|
|
65
|
+
FOR EACH ROW
|
|
66
|
+
EXECUTE FUNCTION notify_feedback_insert();
|
|
67
|
+
|
|
68
|
+
-- Worker config (environment variables):
|
|
69
|
+
DB_LISTENER__CHANNELS=feedback_sync,entity_update
|
|
70
|
+
DB_LISTENER__HANDLER_TYPE=rest
|
|
71
|
+
DB_LISTENER__REST_ENDPOINT=http://localhost:8000/api/v1/internal/events
|
|
72
|
+
|
|
73
|
+
Deployment
|
|
74
|
+
----------
|
|
75
|
+
|
|
76
|
+
Run as a single-replica Kubernetes Deployment:
|
|
77
|
+
|
|
78
|
+
- Always running (not scaled by KEDA)
|
|
79
|
+
- Single replica to avoid duplicate processing
|
|
80
|
+
- Uses Pod Identity for AWS credentials (if SQS handler)
|
|
81
|
+
- Graceful shutdown on SIGTERM
|
|
82
|
+
|
|
83
|
+
Handler Types
|
|
84
|
+
-------------
|
|
85
|
+
|
|
86
|
+
1. **SQS Handler** (DB_LISTENER__HANDLER_TYPE=sqs):
|
|
87
|
+
- Publishes notification payload to SQS queue
|
|
88
|
+
- Queue consumers process asynchronously
|
|
89
|
+
- Good for: Fan-out, durable processing, existing SQS consumers
|
|
90
|
+
|
|
91
|
+
2. **REST Handler** (DB_LISTENER__HANDLER_TYPE=rest):
|
|
92
|
+
- POSTs notification to configured endpoint
|
|
93
|
+
- Expects 2xx response for success
|
|
94
|
+
- Good for: Simple webhook patterns, internal API calls
|
|
95
|
+
|
|
96
|
+
3. **Custom Handler** (Python code):
|
|
97
|
+
- Register handlers via `register_handler(channel, async_fn)`
|
|
98
|
+
- Handler receives (channel, payload) tuple
|
|
99
|
+
- Good for: Complex logic, multiple actions per event
|
|
100
|
+
|
|
101
|
+
Connection Management
|
|
102
|
+
---------------------
|
|
103
|
+
|
|
104
|
+
The worker uses a dedicated asyncpg connection (not from the pool) because:
|
|
105
|
+
|
|
106
|
+
1. LISTEN requires a persistent connection
|
|
107
|
+
2. Pool connections may be returned/closed unexpectedly
|
|
108
|
+
3. We need to handle reconnection on connection loss
|
|
109
|
+
|
|
110
|
+
The connection is separate from PostgresService's pool to avoid interference.
|
|
111
|
+
|
|
112
|
+
Error Handling
|
|
113
|
+
--------------
|
|
114
|
+
|
|
115
|
+
- Connection loss: Automatic reconnection with exponential backoff
|
|
116
|
+
- Handler failure: Logged but doesn't crash the worker
|
|
117
|
+
- Graceful shutdown: SIGTERM triggers clean disconnect
|
|
118
|
+
|
|
119
|
+
Catch-up Processing
|
|
120
|
+
-------------------
|
|
121
|
+
|
|
122
|
+
NOTIFY is fire-and-forget - if the worker is down, notifications are lost.
|
|
123
|
+
For critical data, implement catch-up on startup:
|
|
124
|
+
|
|
125
|
+
async def catch_up():
|
|
126
|
+
'''Process records missed while worker was down.'''
|
|
127
|
+
records = await db.fetch(
|
|
128
|
+
"SELECT id FROM feedbacks WHERE phoenix_synced = false"
|
|
129
|
+
)
|
|
130
|
+
for record in records:
|
|
131
|
+
await process_feedback(record['id'])
|
|
132
|
+
|
|
133
|
+
References
|
|
134
|
+
----------
|
|
135
|
+
|
|
136
|
+
- PostgreSQL NOTIFY: https://www.postgresql.org/docs/current/sql-notify.html
|
|
137
|
+
- PostgreSQL LISTEN: https://www.postgresql.org/docs/18/sql-listen.html
|
|
138
|
+
- asyncpg notifications: https://magicstack.github.io/asyncpg/current/api/index.html#connection-notifications
|
|
139
|
+
- Brandur's Notifier pattern: https://brandur.org/notifier
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
import asyncio
|
|
143
|
+
import json
|
|
144
|
+
import signal
|
|
145
|
+
import sys
|
|
146
|
+
from typing import Any, Callable, Awaitable
|
|
147
|
+
|
|
148
|
+
import asyncpg
|
|
149
|
+
from loguru import logger
|
|
150
|
+
|
|
151
|
+
from rem.settings import settings
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# Type alias for notification handlers
|
|
155
|
+
NotificationHandler = Callable[[str, str], Awaitable[None]]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class DBListener:
|
|
159
|
+
"""
|
|
160
|
+
PostgreSQL LISTEN/NOTIFY worker.
|
|
161
|
+
|
|
162
|
+
Listens on configured channels and dispatches notifications to handlers.
|
|
163
|
+
Designed to run as a single-replica Kubernetes deployment.
|
|
164
|
+
|
|
165
|
+
Attributes:
|
|
166
|
+
channels: List of PostgreSQL channels to LISTEN on
|
|
167
|
+
handler_type: Dispatch method ('sqs', 'rest', or 'custom')
|
|
168
|
+
running: Flag to control the main loop
|
|
169
|
+
connection: Dedicated asyncpg connection for LISTEN
|
|
170
|
+
|
|
171
|
+
Example:
|
|
172
|
+
>>> listener = DBListener()
|
|
173
|
+
>>> listener.register_handler('feedback_sync', my_handler)
|
|
174
|
+
>>> await listener.run()
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def __init__(self):
|
|
178
|
+
"""
|
|
179
|
+
Initialize the database listener.
|
|
180
|
+
|
|
181
|
+
Reads configuration from settings.db_listener:
|
|
182
|
+
- channels: Comma-separated list of channels to listen on
|
|
183
|
+
- handler_type: 'sqs', 'rest', or 'custom'
|
|
184
|
+
- sqs_queue_url: Queue URL for SQS handler
|
|
185
|
+
- rest_endpoint: URL for REST handler
|
|
186
|
+
- reconnect_delay: Initial delay between reconnection attempts
|
|
187
|
+
- max_reconnect_delay: Maximum delay between reconnection attempts
|
|
188
|
+
"""
|
|
189
|
+
self.channels: list[str] = settings.db_listener.channel_list
|
|
190
|
+
self.handler_type: str = settings.db_listener.handler_type
|
|
191
|
+
self.running: bool = True
|
|
192
|
+
self.connection: asyncpg.Connection | None = None
|
|
193
|
+
|
|
194
|
+
# Custom handlers registered via register_handler()
|
|
195
|
+
self._custom_handlers: dict[str, NotificationHandler] = {}
|
|
196
|
+
|
|
197
|
+
# Reconnection backoff
|
|
198
|
+
self._reconnect_delay = settings.db_listener.reconnect_delay
|
|
199
|
+
self._max_reconnect_delay = settings.db_listener.max_reconnect_delay
|
|
200
|
+
|
|
201
|
+
# Handler clients (lazy-initialized)
|
|
202
|
+
self._sqs_client = None
|
|
203
|
+
self._http_client = None
|
|
204
|
+
|
|
205
|
+
# Register signal handlers for graceful shutdown
|
|
206
|
+
signal.signal(signal.SIGTERM, self._handle_shutdown)
|
|
207
|
+
signal.signal(signal.SIGINT, self._handle_shutdown)
|
|
208
|
+
|
|
209
|
+
def _handle_shutdown(self, signum: int, frame: Any) -> None:
|
|
210
|
+
"""
|
|
211
|
+
Handle shutdown signals (SIGTERM, SIGINT).
|
|
212
|
+
|
|
213
|
+
Sets running=False to trigger graceful shutdown of the main loop.
|
|
214
|
+
The worker will finish processing the current notification before exiting.
|
|
215
|
+
"""
|
|
216
|
+
logger.info(f"Received shutdown signal ({signum}), stopping listener...")
|
|
217
|
+
self.running = False
|
|
218
|
+
|
|
219
|
+
def register_handler(self, channel: str, handler: NotificationHandler) -> None:
|
|
220
|
+
"""
|
|
221
|
+
Register a custom handler for a specific channel.
|
|
222
|
+
|
|
223
|
+
Custom handlers are called when handler_type='custom' and a notification
|
|
224
|
+
arrives on the specified channel.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
channel: PostgreSQL channel name
|
|
228
|
+
handler: Async function taking (channel, payload) arguments
|
|
229
|
+
|
|
230
|
+
Example:
|
|
231
|
+
>>> async def handle_feedback(channel: str, payload: str):
|
|
232
|
+
... data = json.loads(payload)
|
|
233
|
+
... await sync_to_phoenix(data['id'])
|
|
234
|
+
...
|
|
235
|
+
>>> listener.register_handler('feedback_sync', handle_feedback)
|
|
236
|
+
"""
|
|
237
|
+
self._custom_handlers[channel] = handler
|
|
238
|
+
logger.info(f"Registered custom handler for channel: {channel}")
|
|
239
|
+
|
|
240
|
+
async def _connect(self) -> asyncpg.Connection:
|
|
241
|
+
"""
|
|
242
|
+
Establish a dedicated connection for LISTEN.
|
|
243
|
+
|
|
244
|
+
This connection is separate from the PostgresService pool because:
|
|
245
|
+
1. LISTEN requires a persistent connection
|
|
246
|
+
2. Pool connections may be returned/closed
|
|
247
|
+
3. We need full control for reconnection logic
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
asyncpg.Connection: Dedicated connection for notifications
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
asyncpg.PostgresError: If connection fails after retries
|
|
254
|
+
"""
|
|
255
|
+
connection_string = settings.postgres.connection_string
|
|
256
|
+
|
|
257
|
+
# Connection with keepalive for long-running listener
|
|
258
|
+
conn = await asyncpg.connect(
|
|
259
|
+
connection_string,
|
|
260
|
+
# TCP keepalive to detect dead connections
|
|
261
|
+
# These settings help detect network issues faster
|
|
262
|
+
server_settings={
|
|
263
|
+
'application_name': 'rem-db-listener',
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
logger.info("Database connection established for LISTEN")
|
|
268
|
+
return conn
|
|
269
|
+
|
|
270
|
+
async def _setup_listeners(self) -> None:
|
|
271
|
+
"""
|
|
272
|
+
Subscribe to all configured channels.
|
|
273
|
+
|
|
274
|
+
Issues LISTEN command for each channel in self.channels.
|
|
275
|
+
Must be called after connection is established.
|
|
276
|
+
"""
|
|
277
|
+
if not self.connection:
|
|
278
|
+
raise RuntimeError("Connection not established")
|
|
279
|
+
|
|
280
|
+
for channel in self.channels:
|
|
281
|
+
await self.connection.execute(f"LISTEN {channel}")
|
|
282
|
+
logger.info(f"Listening on channel: {channel}")
|
|
283
|
+
|
|
284
|
+
async def _dispatch_notification(self, channel: str, payload: str) -> None:
|
|
285
|
+
"""
|
|
286
|
+
Dispatch a notification to the configured handler.
|
|
287
|
+
|
|
288
|
+
Routes the notification based on handler_type setting:
|
|
289
|
+
- 'sqs': Publish to SQS queue
|
|
290
|
+
- 'rest': POST to REST endpoint
|
|
291
|
+
- 'custom': Call registered Python handler
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
channel: The PostgreSQL channel that received the notification
|
|
295
|
+
payload: The notification payload (typically JSON string)
|
|
296
|
+
"""
|
|
297
|
+
logger.debug(f"Received notification on {channel}: {payload[:100]}...")
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
if self.handler_type == 'sqs':
|
|
301
|
+
await self._dispatch_to_sqs(channel, payload)
|
|
302
|
+
elif self.handler_type == 'rest':
|
|
303
|
+
await self._dispatch_to_rest(channel, payload)
|
|
304
|
+
elif self.handler_type == 'custom':
|
|
305
|
+
await self._dispatch_to_custom(channel, payload)
|
|
306
|
+
else:
|
|
307
|
+
logger.warning(f"Unknown handler type: {self.handler_type}")
|
|
308
|
+
|
|
309
|
+
except Exception as e:
|
|
310
|
+
# Log and continue - don't crash the worker on handler failure
|
|
311
|
+
logger.error(f"Handler failed for {channel}: {e}", exc_info=True)
|
|
312
|
+
|
|
313
|
+
async def _dispatch_to_sqs(self, channel: str, payload: str) -> None:
|
|
314
|
+
"""
|
|
315
|
+
Publish notification to SQS queue.
|
|
316
|
+
|
|
317
|
+
Creates a message with the notification payload and channel metadata.
|
|
318
|
+
Uses boto3 with IRSA credentials in Kubernetes.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
channel: Source channel for message attributes
|
|
322
|
+
payload: Notification payload as message body
|
|
323
|
+
"""
|
|
324
|
+
import boto3
|
|
325
|
+
|
|
326
|
+
if not self._sqs_client:
|
|
327
|
+
self._sqs_client = boto3.client('sqs', region_name=settings.sqs.region)
|
|
328
|
+
|
|
329
|
+
queue_url = settings.db_listener.sqs_queue_url
|
|
330
|
+
if not queue_url:
|
|
331
|
+
logger.error("SQS queue URL not configured (DB_LISTENER__SQS_QUEUE_URL)")
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
# Build message with channel as attribute
|
|
335
|
+
message = {
|
|
336
|
+
'QueueUrl': queue_url,
|
|
337
|
+
'MessageBody': payload,
|
|
338
|
+
'MessageAttributes': {
|
|
339
|
+
'channel': {
|
|
340
|
+
'DataType': 'String',
|
|
341
|
+
'StringValue': channel,
|
|
342
|
+
},
|
|
343
|
+
'source': {
|
|
344
|
+
'DataType': 'String',
|
|
345
|
+
'StringValue': 'db_listener',
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
response = self._sqs_client.send_message(**message)
|
|
351
|
+
logger.debug(f"Published to SQS: {response['MessageId']}")
|
|
352
|
+
|
|
353
|
+
async def _dispatch_to_rest(self, channel: str, payload: str) -> None:
|
|
354
|
+
"""
|
|
355
|
+
POST notification to REST endpoint.
|
|
356
|
+
|
|
357
|
+
Sends the notification as JSON to the configured endpoint.
|
|
358
|
+
Expects a 2xx response for success.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
channel: Included in request body
|
|
362
|
+
payload: Notification payload (will be parsed if JSON)
|
|
363
|
+
"""
|
|
364
|
+
import httpx
|
|
365
|
+
|
|
366
|
+
if not self._http_client:
|
|
367
|
+
self._http_client = httpx.AsyncClient(timeout=30.0)
|
|
368
|
+
|
|
369
|
+
endpoint = settings.db_listener.rest_endpoint
|
|
370
|
+
if not endpoint:
|
|
371
|
+
logger.error("REST endpoint not configured (DB_LISTENER__REST_ENDPOINT)")
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
# Parse payload if it's JSON
|
|
375
|
+
try:
|
|
376
|
+
payload_data = json.loads(payload)
|
|
377
|
+
except json.JSONDecodeError:
|
|
378
|
+
payload_data = payload
|
|
379
|
+
|
|
380
|
+
# Build request body
|
|
381
|
+
body = {
|
|
382
|
+
'channel': channel,
|
|
383
|
+
'payload': payload_data,
|
|
384
|
+
'source': 'db_listener',
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
response = await self._http_client.post(endpoint, json=body)
|
|
388
|
+
response.raise_for_status()
|
|
389
|
+
logger.debug(f"REST dispatch success: {response.status_code}")
|
|
390
|
+
|
|
391
|
+
async def _dispatch_to_custom(self, channel: str, payload: str) -> None:
|
|
392
|
+
"""
|
|
393
|
+
Call registered custom handler for the channel.
|
|
394
|
+
|
|
395
|
+
Looks up the handler registered via register_handler() and calls it.
|
|
396
|
+
If no handler is registered for the channel, logs a warning.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
channel: Used to find the registered handler
|
|
400
|
+
payload: Passed to the handler function
|
|
401
|
+
"""
|
|
402
|
+
handler = self._custom_handlers.get(channel)
|
|
403
|
+
if not handler:
|
|
404
|
+
logger.warning(f"No custom handler registered for channel: {channel}")
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
await handler(channel, payload)
|
|
408
|
+
logger.debug(f"Custom handler completed for {channel}")
|
|
409
|
+
|
|
410
|
+
async def _notification_callback(
|
|
411
|
+
self,
|
|
412
|
+
connection: asyncpg.Connection,
|
|
413
|
+
pid: int,
|
|
414
|
+
channel: str,
|
|
415
|
+
payload: str,
|
|
416
|
+
) -> None:
|
|
417
|
+
"""
|
|
418
|
+
Callback invoked by asyncpg when a notification arrives.
|
|
419
|
+
|
|
420
|
+
This is called by asyncpg's event loop integration whenever a NOTIFY
|
|
421
|
+
is received on any subscribed channel.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
connection: The connection that received the notification
|
|
425
|
+
pid: Process ID of the notifying backend
|
|
426
|
+
channel: The channel name
|
|
427
|
+
payload: The notification payload
|
|
428
|
+
"""
|
|
429
|
+
await self._dispatch_notification(channel, payload)
|
|
430
|
+
|
|
431
|
+
async def run(self) -> None:
|
|
432
|
+
"""
|
|
433
|
+
Main worker loop.
|
|
434
|
+
|
|
435
|
+
Establishes connection, subscribes to channels, and processes
|
|
436
|
+
notifications until shutdown is signaled.
|
|
437
|
+
|
|
438
|
+
Handles:
|
|
439
|
+
- Initial connection with retry
|
|
440
|
+
- Automatic reconnection on connection loss
|
|
441
|
+
- Graceful shutdown on SIGTERM/SIGINT
|
|
442
|
+
|
|
443
|
+
Example:
|
|
444
|
+
>>> listener = DBListener()
|
|
445
|
+
>>> await listener.run() # Runs until SIGTERM
|
|
446
|
+
"""
|
|
447
|
+
if not self.channels:
|
|
448
|
+
logger.error("No channels configured (DB_LISTENER__CHANNELS)")
|
|
449
|
+
sys.exit(1)
|
|
450
|
+
|
|
451
|
+
logger.info(f"Starting DB Listener worker")
|
|
452
|
+
logger.info(f"Channels: {', '.join(self.channels)}")
|
|
453
|
+
logger.info(f"Handler type: {self.handler_type}")
|
|
454
|
+
|
|
455
|
+
reconnect_delay = self._reconnect_delay
|
|
456
|
+
|
|
457
|
+
while self.running:
|
|
458
|
+
try:
|
|
459
|
+
# Establish connection
|
|
460
|
+
self.connection = await self._connect()
|
|
461
|
+
|
|
462
|
+
# Register notification callback
|
|
463
|
+
self.connection.add_listener('*', self._notification_callback)
|
|
464
|
+
|
|
465
|
+
# Subscribe to channels
|
|
466
|
+
await self._setup_listeners()
|
|
467
|
+
|
|
468
|
+
# Reset reconnect delay on successful connection
|
|
469
|
+
reconnect_delay = self._reconnect_delay
|
|
470
|
+
|
|
471
|
+
# Wait for notifications (or shutdown)
|
|
472
|
+
# The callback handles actual notification processing
|
|
473
|
+
while self.running:
|
|
474
|
+
# Check connection health periodically
|
|
475
|
+
# asyncpg handles notifications in the background
|
|
476
|
+
await asyncio.sleep(1.0)
|
|
477
|
+
|
|
478
|
+
# Verify connection is still alive
|
|
479
|
+
if self.connection.is_closed():
|
|
480
|
+
logger.warning("Connection closed, reconnecting...")
|
|
481
|
+
break
|
|
482
|
+
|
|
483
|
+
except asyncpg.PostgresError as e:
|
|
484
|
+
logger.error(f"Database error: {e}")
|
|
485
|
+
except Exception as e:
|
|
486
|
+
logger.error(f"Unexpected error: {e}", exc_info=True)
|
|
487
|
+
finally:
|
|
488
|
+
# Clean up connection
|
|
489
|
+
if self.connection and not self.connection.is_closed():
|
|
490
|
+
await self.connection.close()
|
|
491
|
+
self.connection = None
|
|
492
|
+
|
|
493
|
+
# Reconnect with backoff (unless shutting down)
|
|
494
|
+
if self.running:
|
|
495
|
+
logger.info(f"Reconnecting in {reconnect_delay}s...")
|
|
496
|
+
await asyncio.sleep(reconnect_delay)
|
|
497
|
+
# Exponential backoff with max
|
|
498
|
+
reconnect_delay = min(reconnect_delay * 2, self._max_reconnect_delay)
|
|
499
|
+
|
|
500
|
+
# Cleanup
|
|
501
|
+
if self._http_client:
|
|
502
|
+
await self._http_client.aclose()
|
|
503
|
+
|
|
504
|
+
logger.info("DB Listener stopped")
|
|
505
|
+
|
|
506
|
+
async def catch_up(self, query: str, handler: NotificationHandler) -> int:
|
|
507
|
+
"""
|
|
508
|
+
Process records missed while the worker was down.
|
|
509
|
+
|
|
510
|
+
NOTIFY is fire-and-forget - if the worker wasn't running, notifications
|
|
511
|
+
are lost. For critical data, call this on startup to catch up.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
query: SQL query that returns rows with an 'id' column
|
|
515
|
+
handler: Async function to process each record
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
Number of records processed
|
|
519
|
+
|
|
520
|
+
Example:
|
|
521
|
+
>>> # Catch up on unsynced feedback records
|
|
522
|
+
>>> count = await listener.catch_up(
|
|
523
|
+
... "SELECT id FROM feedbacks WHERE phoenix_synced = false",
|
|
524
|
+
... handle_feedback
|
|
525
|
+
... )
|
|
526
|
+
>>> logger.info(f"Caught up {count} records")
|
|
527
|
+
"""
|
|
528
|
+
from rem.services.postgres import get_postgres_service
|
|
529
|
+
|
|
530
|
+
db = get_postgres_service()
|
|
531
|
+
if not db:
|
|
532
|
+
logger.warning("PostgreSQL not available for catch-up")
|
|
533
|
+
return 0
|
|
534
|
+
|
|
535
|
+
await db.connect()
|
|
536
|
+
try:
|
|
537
|
+
records = await db.fetch(query)
|
|
538
|
+
count = 0
|
|
539
|
+
|
|
540
|
+
for record in records:
|
|
541
|
+
try:
|
|
542
|
+
# Convert record to JSON payload
|
|
543
|
+
payload = json.dumps(dict(record))
|
|
544
|
+
await handler('catch_up', payload)
|
|
545
|
+
count += 1
|
|
546
|
+
except Exception as e:
|
|
547
|
+
logger.error(f"Catch-up failed for record: {e}")
|
|
548
|
+
|
|
549
|
+
logger.info(f"Catch-up completed: {count}/{len(records)} records")
|
|
550
|
+
return count
|
|
551
|
+
|
|
552
|
+
finally:
|
|
553
|
+
await db.disconnect()
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def main() -> None:
|
|
557
|
+
"""
|
|
558
|
+
Entry point for containerized deployment.
|
|
559
|
+
|
|
560
|
+
Runs the DB Listener worker until SIGTERM.
|
|
561
|
+
|
|
562
|
+
Usage:
|
|
563
|
+
python -m rem.workers.db_listener
|
|
564
|
+
"""
|
|
565
|
+
logger.info("REM DB Listener Worker")
|
|
566
|
+
logger.info(f"Environment: {settings.environment}")
|
|
567
|
+
|
|
568
|
+
if not settings.db_listener.enabled:
|
|
569
|
+
logger.warning("DB Listener is disabled (DB_LISTENER__ENABLED=false)")
|
|
570
|
+
sys.exit(0)
|
|
571
|
+
|
|
572
|
+
listener = DBListener()
|
|
573
|
+
|
|
574
|
+
# Run the async main loop
|
|
575
|
+
asyncio.run(listener.run())
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
if __name__ == "__main__":
|
|
579
|
+
main()
|