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.

Files changed (83) hide show
  1. rem/agentic/agents/__init__.py +16 -0
  2. rem/agentic/agents/agent_manager.py +311 -0
  3. rem/agentic/agents/sse_simulator.py +2 -0
  4. rem/agentic/context.py +103 -5
  5. rem/agentic/context_builder.py +36 -9
  6. rem/agentic/mcp/tool_wrapper.py +161 -18
  7. rem/agentic/otel/setup.py +1 -0
  8. rem/agentic/providers/phoenix.py +371 -108
  9. rem/agentic/providers/pydantic_ai.py +172 -30
  10. rem/agentic/schema.py +8 -4
  11. rem/api/deps.py +3 -5
  12. rem/api/main.py +26 -4
  13. rem/api/mcp_router/resources.py +15 -10
  14. rem/api/mcp_router/server.py +11 -3
  15. rem/api/mcp_router/tools.py +418 -4
  16. rem/api/middleware/tracking.py +5 -5
  17. rem/api/routers/admin.py +218 -1
  18. rem/api/routers/auth.py +349 -6
  19. rem/api/routers/chat/completions.py +255 -7
  20. rem/api/routers/chat/models.py +81 -7
  21. rem/api/routers/chat/otel_utils.py +33 -0
  22. rem/api/routers/chat/sse_events.py +17 -1
  23. rem/api/routers/chat/streaming.py +126 -19
  24. rem/api/routers/feedback.py +134 -14
  25. rem/api/routers/messages.py +24 -15
  26. rem/api/routers/query.py +6 -3
  27. rem/auth/__init__.py +13 -3
  28. rem/auth/jwt.py +352 -0
  29. rem/auth/middleware.py +115 -10
  30. rem/auth/providers/__init__.py +4 -1
  31. rem/auth/providers/email.py +215 -0
  32. rem/cli/commands/README.md +42 -0
  33. rem/cli/commands/cluster.py +617 -168
  34. rem/cli/commands/configure.py +4 -7
  35. rem/cli/commands/db.py +66 -22
  36. rem/cli/commands/experiments.py +468 -76
  37. rem/cli/commands/schema.py +6 -5
  38. rem/cli/commands/session.py +336 -0
  39. rem/cli/dreaming.py +2 -2
  40. rem/cli/main.py +2 -0
  41. rem/config.py +8 -1
  42. rem/models/core/experiment.py +58 -14
  43. rem/models/entities/__init__.py +4 -0
  44. rem/models/entities/ontology.py +1 -1
  45. rem/models/entities/ontology_config.py +1 -1
  46. rem/models/entities/subscriber.py +175 -0
  47. rem/models/entities/user.py +1 -0
  48. rem/schemas/agents/core/agent-builder.yaml +235 -0
  49. rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
  50. rem/schemas/agents/examples/contract-extractor.yaml +1 -1
  51. rem/schemas/agents/examples/cv-parser.yaml +1 -1
  52. rem/services/__init__.py +3 -1
  53. rem/services/content/service.py +4 -3
  54. rem/services/email/__init__.py +10 -0
  55. rem/services/email/service.py +513 -0
  56. rem/services/email/templates.py +360 -0
  57. rem/services/phoenix/client.py +59 -18
  58. rem/services/postgres/README.md +38 -0
  59. rem/services/postgres/diff_service.py +127 -6
  60. rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
  61. rem/services/postgres/repository.py +5 -4
  62. rem/services/postgres/schema_generator.py +205 -4
  63. rem/services/session/compression.py +120 -50
  64. rem/services/session/reload.py +14 -7
  65. rem/services/user_service.py +41 -9
  66. rem/settings.py +442 -23
  67. rem/sql/migrations/001_install.sql +156 -0
  68. rem/sql/migrations/002_install_models.sql +1951 -88
  69. rem/sql/migrations/004_cache_system.sql +548 -0
  70. rem/sql/migrations/005_schema_update.sql +145 -0
  71. rem/utils/README.md +45 -0
  72. rem/utils/__init__.py +18 -0
  73. rem/utils/files.py +157 -1
  74. rem/utils/schema_loader.py +139 -10
  75. rem/utils/sql_paths.py +146 -0
  76. rem/utils/vision.py +1 -1
  77. rem/workers/__init__.py +3 -1
  78. rem/workers/db_listener.py +579 -0
  79. rem/workers/unlogged_maintainer.py +463 -0
  80. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/METADATA +218 -180
  81. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/RECORD +83 -68
  82. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/WHEEL +0 -0
  83. {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()