Spaces:
Running
Running
File size: 8,964 Bytes
24480a0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 | """
backend/memory/oracle_sync.py β Supabase β Oracle DB dual-write synchronizer.
Two public classes:
MemorySynchronizer β Wraps a primary (Supabase) and a secondary (Oracle)
MemoryBackend. Writes always go to both; Oracle failures are non-fatal
(logged but not re-raised) because Oracle is a secondary replica, not
the source of truth.
OracleState β Singleton lifecycle manager hooked from main.py _on_startup().
Reads ORACLE_DB_* env vars, initialises OracleMemoryAdapter if they are
set, and exposes oracle_adapter and memory_synchronizer as class-level
attributes for use by other API modules.
Design decisions:
- IDs are generated once by MemorySynchronizer (uuid4) and passed to BOTH
backends so keys stay consistent (BUG-1 fix).
- Secondary write is wrapped in try/except so a primary success is never
rolled back due to an Oracle failure (BUG-4 fix).
- sync_update / sync_delete return a dict {'primary': bool, 'secondary': bool|None}
so callers can distinguish which backend failed (BUG-8 fix).
- OracleState checks oracle_adapter.is_connected, not the object's truthiness,
to avoid initialising the synchronizer against an unconnected adapter (BUG-3 fix).
"""
from __future__ import annotations
import logging
import os
import uuid
from typing import Any, Dict, List, Optional
from .memory_backend import MemoryBackend
from .oracle_adapter import OracleMemoryAdapter
_logger = logging.getLogger("memory.oracle_sync")
# ββ Synchronizer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class MemorySynchronizer:
"""
Dual-write coordinator: primary backend is authoritative, secondary is
best-effort.
Args:
primary: Authoritative backend (Supabase). Errors propagate.
secondary: Replica backend (Oracle). Errors are logged, never raised.
"""
def __init__(self, primary: MemoryBackend, secondary: OracleMemoryAdapter) -> None:
self.primary = primary
self.secondary = secondary
# ββ Writes βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async def sync_add_memory(
self, user_id: str, memory_data: Dict[str, Any]
) -> str:
"""Insert into primary then Oracle. Returns the shared memory_id."""
shared_id = str(uuid.uuid4()) # single ID for both backends
await self.primary.add_memory(user_id, memory_data, memory_id=shared_id)
if self.secondary.is_connected:
try:
await self.secondary.add_memory(
user_id, memory_data, memory_id=shared_id
)
except Exception as exc:
_logger.warning(
"OracleSync.add_memory: Oracle write failed (non-fatal) β %s", exc
)
return shared_id
async def sync_update_memory(
self, user_id: str, memory_id: str, new_data: Dict[str, Any]
) -> Dict[str, Optional[bool]]:
"""
Update both backends.
Returns:
{'primary': bool, 'secondary': bool | None}
None means Oracle is not connected or the call was skipped.
"""
primary_ok = await self.primary.update_memory(user_id, memory_id, new_data)
secondary_ok: Optional[bool] = None
if self.secondary.is_connected:
try:
secondary_ok = await self.secondary.update_memory(
user_id, memory_id, new_data
)
except Exception as exc:
_logger.warning(
"OracleSync.update_memory: Oracle update failed β %s", exc
)
secondary_ok = False
return {"primary": primary_ok, "secondary": secondary_ok}
async def sync_delete_memory(
self, user_id: str, memory_id: str
) -> Dict[str, Optional[bool]]:
"""
Delete from both backends.
Returns:
{'primary': bool, 'secondary': bool | None}
"""
primary_ok = await self.primary.delete_memory(user_id, memory_id)
secondary_ok: Optional[bool] = None
if self.secondary.is_connected:
try:
secondary_ok = await self.secondary.delete_memory(user_id, memory_id)
except Exception as exc:
_logger.warning(
"OracleSync.delete_memory: Oracle delete failed β %s", exc
)
secondary_ok = False
return {"primary": primary_ok, "secondary": secondary_ok}
# ββ Reads ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async def get_memory_from_primary(
self, user_id: str, memory_id: str
) -> Optional[Dict[str, Any]]:
return await self.primary.get_memory(user_id, memory_id)
async def get_memory_from_secondary(
self, user_id: str, memory_id: str
) -> Optional[Dict[str, Any]]:
if not self.secondary.is_connected:
return None
return await self.secondary.get_memory(user_id, memory_id)
async def list_memories_from_primary(
self, user_id: str, limit: int = 100, offset: int = 0
) -> List[Dict[str, Any]]:
return await self.primary.list_memories(user_id, limit, offset)
async def list_memories_from_secondary(
self, user_id: str, limit: int = 100, offset: int = 0
) -> List[Dict[str, Any]]:
if not self.secondary.is_connected:
return []
return await self.secondary.list_memories(user_id, limit, offset)
# ββ Lifecycle singleton βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class OracleState:
"""
Singleton lifecycle manager for the Oracle memory backend.
Hooked from backend/main.py::_on_startup() β call await OracleState.initialize().
The synchronizer is wired lazily; if ORACLE_DB_* vars are absent the class
silently stays disabled so the rest of the API is unaffected.
Usage after startup:
from memory.oracle_sync import OracleState
adapter = OracleState.oracle_adapter # None if not configured
sync = OracleState.memory_synchronizer # None if not configured
"""
oracle_adapter: Optional[OracleMemoryAdapter] = None
memory_synchronizer: Optional[MemorySynchronizer] = None
_initialized: bool = False
@classmethod
async def initialize(cls) -> None:
if cls._initialized:
return
user = os.getenv("ORACLE_DB_USER")
password = os.getenv("ORACLE_DB_PASSWORD")
dsn = os.getenv("ORACLE_DB_DSN")
if not all([user, password, dsn]):
_logger.info(
"OracleState: ORACLE_DB_* env vars not set β Oracle backend disabled."
)
cls._initialized = True
return
adapter = OracleMemoryAdapter()
try:
await adapter.connect({"user": user, "password": password, "dsn": dsn})
except Exception as exc:
_logger.warning(
"OracleState: connect failed (%s) β Oracle backend disabled.", exc
)
cls._initialized = True
return
cls.oracle_adapter = adapter
# Wire up MemorySynchronizer only when a primary backend is available.
# SupabaseMemoryBackend (backend/memory/supabase_backend.py) wraps the
# existing state._sb client. If that module doesn't exist yet the
# synchronizer stays None; oracle_adapter is still available for direct use.
try:
from memory.supabase_backend import SupabaseMemoryBackend # type: ignore[import]
cls.memory_synchronizer = MemorySynchronizer(
primary=SupabaseMemoryBackend(), secondary=cls.oracle_adapter
)
_logger.info(
"OracleState: MemorySynchronizer ready (SupabaseβOracle dual-write active)."
)
except ImportError:
_logger.info(
"OracleState: supabase_backend not found β "
"oracle_adapter available for direct use; synchronizer disabled."
)
cls._initialized = True
@classmethod
async def shutdown(cls) -> None:
"""Call from _on_shutdown() to close the Oracle pool gracefully."""
if cls.oracle_adapter and cls.oracle_adapter.is_connected:
await cls.oracle_adapter.disconnect()
cls._initialized = False
|