synapse-memory-engine 1.3.0__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.
- synapse_memory/__init__.py +31 -0
- synapse_memory/api/fastapi_server.py +1334 -0
- synapse_memory/cli/synapse_cli.py +112 -0
- synapse_memory/client.py +127 -0
- synapse_memory/core/__init__.py +50 -0
- synapse_memory/core/auth.py +1273 -0
- synapse_memory/core/decay_engine.py +123 -0
- synapse_memory/core/deduplicator.py +98 -0
- synapse_memory/core/embedder.py +119 -0
- synapse_memory/core/embedding_provider.py +241 -0
- synapse_memory/core/encryption.py +226 -0
- synapse_memory/core/hybrid_search.py +199 -0
- synapse_memory/core/knapsack_packer.py +103 -0
- synapse_memory/core/memory_manager.py +233 -0
- synapse_memory/core/migrations.py +241 -0
- synapse_memory/core/observability.py +48 -0
- synapse_memory/core/pgvector_db.py +1276 -0
- synapse_memory/core/pruner.py +68 -0
- synapse_memory/core/rate_limiter.py +180 -0
- synapse_memory/core/sqlite_db.py +564 -0
- synapse_memory/examples/langchain_integration.py +12 -0
- synapse_memory/examples/llamaindex_integration.py +12 -0
- synapse_memory/integrations/__init__.py +9 -0
- synapse_memory/integrations/langchain.py +209 -0
- synapse_memory/integrations/llamaindex.py +295 -0
- synapse_memory/main.py +112 -0
- synapse_memory/py.typed +0 -0
- synapse_memory_engine-1.3.0.dist-info/METADATA +75 -0
- synapse_memory_engine-1.3.0.dist-info/RECORD +32 -0
- synapse_memory_engine-1.3.0.dist-info/WHEEL +5 -0
- synapse_memory_engine-1.3.0.dist-info/entry_points.txt +4 -0
- synapse_memory_engine-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import logging
|
|
4
|
+
import asyncio
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Dict, Any, Tuple, Optional, Union
|
|
7
|
+
from synapse_memory.core.sqlite_db import SQLiteMemoryStore
|
|
8
|
+
from synapse_memory.core.pgvector_db import PGVectorMemoryStore
|
|
9
|
+
from synapse_memory.core.embedder import SynapseEmbedder
|
|
10
|
+
from synapse_memory.core.deduplicator import Deduplicator, Action
|
|
11
|
+
from synapse_memory.core.pruner import PruningEngine, CapacityPruningPolicy
|
|
12
|
+
from synapse_memory.core.decay_engine import DecayEngine
|
|
13
|
+
from synapse_memory.core.observability import log_event, instrument_operation
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("SynapseMemoryManager")
|
|
16
|
+
|
|
17
|
+
# Explicit, operator-visible capacity ceiling for the in-memory store.
|
|
18
|
+
# Override with SYNAPSE_MAX_RECORD_LIMIT (must be a positive integer).
|
|
19
|
+
# The historical default was 50 records; change this only with capacity planning.
|
|
20
|
+
try:
|
|
21
|
+
DEFAULT_MAX_RECORD_LIMIT = int(os.environ.get("SYNAPSE_MAX_RECORD_LIMIT", "50"))
|
|
22
|
+
if DEFAULT_MAX_RECORD_LIMIT <= 0:
|
|
23
|
+
raise ValueError
|
|
24
|
+
except (TypeError, ValueError):
|
|
25
|
+
DEFAULT_MAX_RECORD_LIMIT = 50
|
|
26
|
+
logger.warning(
|
|
27
|
+
"Invalid SYNAPSE_MAX_RECORD_LIMIT; using default max_record_limit=50"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def estimate_token_cost(content: str) -> int:
|
|
32
|
+
"""Production token estimator: prefers tiktoken when installed, else safe heuristic.
|
|
33
|
+
Heuristic calibrated to ~4 chars/token (OpenAI/Gemini average for English).
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
import tiktoken # type: ignore
|
|
37
|
+
|
|
38
|
+
enc = tiktoken.get_encoding("cl100k_base")
|
|
39
|
+
return max(15, len(enc.encode(content)) + 8)
|
|
40
|
+
except Exception:
|
|
41
|
+
# Fallback: chars/4 + overhead. More accurate than words*1.35 for code/CJK.
|
|
42
|
+
return max(15, int(len(content) / 4) + 10)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MemoryManager:
|
|
46
|
+
"""
|
|
47
|
+
Manages high-level memory operations: Semantic Deduplication,
|
|
48
|
+
Multi-tenant Ingestion, Auto-Eviction, and Cognitive Memory Pruning.
|
|
49
|
+
Compatible with both SQLiteMemoryStore and PGVectorMemoryStore backends.
|
|
50
|
+
|
|
51
|
+
Capacity: ``max_record_limit`` bounds how many memories a tenant may hold.
|
|
52
|
+
After every ingest, :meth:`auto_prune_store` evicts the lowest-relevance
|
|
53
|
+
memories until the count is within the limit. The default comes from the
|
|
54
|
+
``SYNAPSE_MAX_RECORD_LIMIT`` environment variable and falls back to 50
|
|
55
|
+
records (the historical default). Expose this setting explicitly -- never
|
|
56
|
+
rely on the silent default in production, or older memories are pruned
|
|
57
|
+
without the operator realizing a cap is active.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
store: Union[SQLiteMemoryStore, PGVectorMemoryStore, Any],
|
|
63
|
+
embedder: SynapseEmbedder,
|
|
64
|
+
category_thresholds: Optional[Dict[str, float]] = None,
|
|
65
|
+
max_record_limit: int = DEFAULT_MAX_RECORD_LIMIT
|
|
66
|
+
):
|
|
67
|
+
self.store = store
|
|
68
|
+
self.embedder = embedder
|
|
69
|
+
self.deduplicator = Deduplicator(category_thresholds or {})
|
|
70
|
+
self.max_record_limit = int(max_record_limit)
|
|
71
|
+
self.decay_engine = DecayEngine({})
|
|
72
|
+
self.pruner = PruningEngine([CapacityPruningPolicy(max_record_limit, self.decay_engine)])
|
|
73
|
+
|
|
74
|
+
@instrument_operation("ingest_memory")
|
|
75
|
+
def ingest_with_deduplication(
|
|
76
|
+
self,
|
|
77
|
+
content: str,
|
|
78
|
+
category: str = "interaction",
|
|
79
|
+
confidence: float = 0.95,
|
|
80
|
+
tenant_id: str = "default"
|
|
81
|
+
) -> Tuple[str, str, int]:
|
|
82
|
+
"""
|
|
83
|
+
Ingests a new memory node.
|
|
84
|
+
Runs semantic similarity comparison against existing indexed nodes within the same tenant.
|
|
85
|
+
|
|
86
|
+
When a near-duplicate is found (Action.MERGE), the incoming content is preserved:
|
|
87
|
+
a new memory row is created and linked to the old one via a supersession chain
|
|
88
|
+
(``superseded_by``). The old row is marked superseded but retained for provenance.
|
|
89
|
+
Confidence is merged conservatively: the higher of the two confidences is kept,
|
|
90
|
+
and a small reinforcement is applied only when the new content is at least as
|
|
91
|
+
confident as the existing one. This ensures updated/corrected facts are never
|
|
92
|
+
silently discarded.
|
|
93
|
+
|
|
94
|
+
When Action.REJECT is returned (direct contradiction), the incoming memory is
|
|
95
|
+
still recorded as a separate row so no information is lost; the caller can
|
|
96
|
+
decide how to resolve the conflict.
|
|
97
|
+
"""
|
|
98
|
+
# 1. Generate dense vector representation
|
|
99
|
+
new_vector = self.embedder.embed_query(content)
|
|
100
|
+
existing_memories = self.store.get_memories_by_category(category, tenant_id=tenant_id)
|
|
101
|
+
|
|
102
|
+
# 2. Check for duplicate nodes
|
|
103
|
+
new_node_base = {
|
|
104
|
+
"content": content,
|
|
105
|
+
"category": category,
|
|
106
|
+
"tenant_id": tenant_id,
|
|
107
|
+
"embedding": new_vector,
|
|
108
|
+
"confidence": confidence,
|
|
109
|
+
}
|
|
110
|
+
result = self.deduplicator.check_duplicate(new_node_base, existing_memories)
|
|
111
|
+
|
|
112
|
+
# 3. Handle Deduplication Result
|
|
113
|
+
if result.action == Action.MERGE and result.target_id:
|
|
114
|
+
match_id = result.target_id
|
|
115
|
+
match_mem = next((m for m in existing_memories if m["id"] == match_id), None)
|
|
116
|
+
old_confidence = float(match_mem.get("confidence", 0.5)) if match_mem else 0.5
|
|
117
|
+
|
|
118
|
+
merged_confidence = max(old_confidence, confidence)
|
|
119
|
+
if confidence >= old_confidence:
|
|
120
|
+
merged_confidence = min(1.0, merged_confidence + 0.02)
|
|
121
|
+
|
|
122
|
+
new_memory_id = f"mem_{uuid.uuid4().hex[:16]}"
|
|
123
|
+
token_cost = estimate_token_cost(content)
|
|
124
|
+
new_node = {
|
|
125
|
+
"id": new_memory_id,
|
|
126
|
+
"tenant_id": tenant_id,
|
|
127
|
+
"content": content,
|
|
128
|
+
"category": category,
|
|
129
|
+
"confidence": merged_confidence,
|
|
130
|
+
"created_at": time.time(),
|
|
131
|
+
"access_count": 0,
|
|
132
|
+
"token_cost": token_cost,
|
|
133
|
+
"embedding": new_vector,
|
|
134
|
+
"embedding_dimension": len(new_vector),
|
|
135
|
+
"model_version": getattr(self.embedder, "model_name", "unknown"),
|
|
136
|
+
"supersedes": match_id,
|
|
137
|
+
"superseded_by": None,
|
|
138
|
+
}
|
|
139
|
+
self.store.insert_memory(new_node)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
self.store.mark_superseded(match_id, new_memory_id, tenant_id=tenant_id)
|
|
143
|
+
except Exception:
|
|
144
|
+
log_event("memory_deduplication", "MERGE_MARK_FAILED", {"target_id": match_id, "tenant_id": tenant_id})
|
|
145
|
+
|
|
146
|
+
# The supersession row above is a real row: it counts toward
|
|
147
|
+
# max_record_limit like any other write, so enforce the capacity
|
|
148
|
+
# ceiling on this path too. Without this, repeated merges of the same
|
|
149
|
+
# fact grow the table without bound (the superseded lineage rows are
|
|
150
|
+
# never evicted) even though CREATE/REJECT do prune.
|
|
151
|
+
self.auto_prune_store(tenant_id=tenant_id)
|
|
152
|
+
|
|
153
|
+
log_event("memory_deduplication", "MERGED_PRESERVED", {
|
|
154
|
+
"old_id": match_id, "new_id": new_memory_id, "tenant_id": tenant_id,
|
|
155
|
+
"old_confidence": old_confidence, "new_confidence": merged_confidence,
|
|
156
|
+
})
|
|
157
|
+
return new_memory_id, "MERGED", token_cost
|
|
158
|
+
|
|
159
|
+
elif result.action == Action.REJECT:
|
|
160
|
+
log_event("memory_deduplication", "REJECTED_RECORDED", {"tenant_id": tenant_id, "reason": result.reason})
|
|
161
|
+
new_memory_id = f"mem_{uuid.uuid4().hex[:16]}"
|
|
162
|
+
token_cost = estimate_token_cost(content)
|
|
163
|
+
new_node = {
|
|
164
|
+
"id": new_memory_id,
|
|
165
|
+
"tenant_id": tenant_id,
|
|
166
|
+
"content": content,
|
|
167
|
+
"category": category,
|
|
168
|
+
"confidence": confidence,
|
|
169
|
+
"created_at": time.time(),
|
|
170
|
+
"access_count": 0,
|
|
171
|
+
"token_cost": token_cost,
|
|
172
|
+
"embedding": new_vector,
|
|
173
|
+
"embedding_dimension": len(new_vector),
|
|
174
|
+
"model_version": getattr(self.embedder, "model_name", "unknown"),
|
|
175
|
+
"supersedes": None,
|
|
176
|
+
"superseded_by": None,
|
|
177
|
+
}
|
|
178
|
+
self.store.insert_memory(new_node)
|
|
179
|
+
self.auto_prune_store(tenant_id=tenant_id)
|
|
180
|
+
return new_memory_id, "REJECTED", token_cost
|
|
181
|
+
|
|
182
|
+
# 4. Otherwise, save as a clean memory
|
|
183
|
+
log_event("memory_deduplication", "CREATED", {"tenant_id": tenant_id})
|
|
184
|
+
memory_id = f"mem_{uuid.uuid4().hex[:16]}"
|
|
185
|
+
token_cost = estimate_token_cost(content)
|
|
186
|
+
new_node = {
|
|
187
|
+
"id": memory_id,
|
|
188
|
+
"tenant_id": tenant_id,
|
|
189
|
+
"content": content,
|
|
190
|
+
"category": category,
|
|
191
|
+
"confidence": confidence,
|
|
192
|
+
"created_at": time.time(),
|
|
193
|
+
"access_count": 0,
|
|
194
|
+
"token_cost": token_cost,
|
|
195
|
+
"embedding": new_vector,
|
|
196
|
+
# Embedding provenance: stamps which dimension + model wrote this row,
|
|
197
|
+
# so schema/drift checks can detect stale index entries after an
|
|
198
|
+
# embedding-model upgrade (and trigger a re-embed pass).
|
|
199
|
+
"embedding_dimension": len(new_vector),
|
|
200
|
+
"model_version": getattr(self.embedder, "model_name", "unknown"),
|
|
201
|
+
"supersedes": None,
|
|
202
|
+
"superseded_by": None,
|
|
203
|
+
}
|
|
204
|
+
self.store.insert_memory(new_node)
|
|
205
|
+
|
|
206
|
+
# 5. Execute Auto-Pruning if limit is breached
|
|
207
|
+
self.auto_prune_store(tenant_id=tenant_id)
|
|
208
|
+
|
|
209
|
+
return memory_id, "CREATED", token_cost
|
|
210
|
+
|
|
211
|
+
async def ingest_async(self, *args, **kwargs) -> Tuple[str, str, int]:
|
|
212
|
+
return await asyncio.to_thread(self.ingest_with_deduplication, *args, **kwargs)
|
|
213
|
+
|
|
214
|
+
def auto_prune_store(self, tenant_id: Optional[str] = None) -> int:
|
|
215
|
+
"""
|
|
216
|
+
Evicts memories based on the configured PruningEngine within a tenant.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
The number of successfully evicted memory nodes.
|
|
220
|
+
"""
|
|
221
|
+
memories = self.store.get_all_memories(tenant_id=tenant_id, include_superseded=True)
|
|
222
|
+
targets = self.pruner.run_pruning(memories)
|
|
223
|
+
|
|
224
|
+
evicted = 0
|
|
225
|
+
for target_id in targets:
|
|
226
|
+
self.store.delete_memory(target_id, tenant_id=tenant_id)
|
|
227
|
+
evicted += 1
|
|
228
|
+
log_event("memory_pruning", "EVICTED", {"target_id": target_id, "tenant_id": tenant_id})
|
|
229
|
+
|
|
230
|
+
return evicted
|
|
231
|
+
|
|
232
|
+
async def prune_async(self, tenant_id: Optional[str] = None) -> int:
|
|
233
|
+
return await asyncio.to_thread(self.auto_prune_store, tenant_id=tenant_id)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lightweight, dependency-free forward-only schema migrations for SynapseMemory.
|
|
3
|
+
|
|
4
|
+
Why not Alembic: the engine must stay importable and runnable with zero external
|
|
5
|
+
services (SQLite) and a minimal dependency footprint. This runner is versioned,
|
|
6
|
+
idempotent, and records applied versions in a `schema_migrations` ledger table so
|
|
7
|
+
a team can audit exactly which schema state a database is in. It supports both
|
|
8
|
+
SQLite (local/dev) and PostgreSQL (team/prod) via a tiny dialect shim.
|
|
9
|
+
|
|
10
|
+
Adding a migration:
|
|
11
|
+
1. Append a Migration(version=N, name=..., sqlite=..., postgres=...) to MIGRATIONS.
|
|
12
|
+
2. Bump CURRENT_SCHEMA_VERSION to N.
|
|
13
|
+
Never edit an already-released migration — add a new one.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Callable, List, Optional
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("SynapseMigrations")
|
|
22
|
+
|
|
23
|
+
# Ledger + metadata columns tracked on the memories table so embedding-model
|
|
24
|
+
# upgrades (e.g. 128-d hash -> 384-d MiniLM) are explicit, not silent.
|
|
25
|
+
CURRENT_SCHEMA_VERSION = 5
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Migration:
|
|
30
|
+
version: int
|
|
31
|
+
name: str
|
|
32
|
+
# Callables receive a cursor + a `p` placeholder ('?' for sqlite, '%s' for pg)
|
|
33
|
+
sqlite: Optional[Callable[[Any], None]] = None
|
|
34
|
+
postgres: Optional[Callable[[Any], None]] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _column_exists(cur: Any, table: str, column: str) -> bool:
|
|
38
|
+
"""Dialect-aware column existence probe (PostgreSQL then SQLite)."""
|
|
39
|
+
try:
|
|
40
|
+
cur.execute(
|
|
41
|
+
"SELECT 1 FROM information_schema.columns WHERE table_name=%s AND column_name=%s",
|
|
42
|
+
(table, column),
|
|
43
|
+
)
|
|
44
|
+
if cur.fetchone():
|
|
45
|
+
return True
|
|
46
|
+
return False
|
|
47
|
+
except Exception:
|
|
48
|
+
# SQLite has no information_schema; fall through to PRAGMA.
|
|
49
|
+
pass
|
|
50
|
+
try:
|
|
51
|
+
cur.execute(f"PRAGMA table_info({table});")
|
|
52
|
+
for row in cur.fetchall():
|
|
53
|
+
if isinstance(row, dict):
|
|
54
|
+
if row.get("name") == column:
|
|
55
|
+
return True
|
|
56
|
+
elif len(row) > 1 and row[1] == column:
|
|
57
|
+
return True
|
|
58
|
+
return False
|
|
59
|
+
except Exception:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _add_column_if_missing(cur: Any, table: str, column: str, coltype: str) -> None:
|
|
64
|
+
"""Idempotent ADD COLUMN that works on both SQLite and PostgreSQL.
|
|
65
|
+
|
|
66
|
+
Only a real duplicate-column condition counts as success. Any other failure
|
|
67
|
+
— missing privileges, invalid DDL, lock timeout, schema drift — is re-raised
|
|
68
|
+
so ``fail_closed=True`` aborts startup instead of recording a migration
|
|
69
|
+
version for a column that was never created (F-12).
|
|
70
|
+
"""
|
|
71
|
+
if _column_exists(cur, table, column):
|
|
72
|
+
return
|
|
73
|
+
try:
|
|
74
|
+
cur.execute(f"ALTER TABLE {table} ADD COLUMN {column} {coltype};")
|
|
75
|
+
except Exception:
|
|
76
|
+
# Re-probe: a concurrent replica may have added it, in which case this
|
|
77
|
+
# is genuinely done. Otherwise the failure is real and must propagate.
|
|
78
|
+
if _column_exists(cur, table, column):
|
|
79
|
+
return
|
|
80
|
+
raise
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _m1_embedding_metadata(cur: Any) -> None:
|
|
84
|
+
"""v1: Track embedding dimension + model version per memory row."""
|
|
85
|
+
_add_column_if_missing(cur, "memories", "embedding_dimension", "INTEGER NOT NULL DEFAULT 0")
|
|
86
|
+
_add_column_if_missing(cur, "memories", "model_version", "VARCHAR(64) NOT NULL DEFAULT 'unknown'")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _m2_migration_ledger(cur: Any) -> None:
|
|
90
|
+
"""v2: Audit ledger of applied schema versions (created here, not via ADD COLUMN)."""
|
|
91
|
+
cur.execute(
|
|
92
|
+
"""
|
|
93
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
94
|
+
version INTEGER PRIMARY KEY,
|
|
95
|
+
name VARCHAR(128) NOT NULL,
|
|
96
|
+
applied_at DOUBLE PRECISION NOT NULL
|
|
97
|
+
);
|
|
98
|
+
"""
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _m3_backfill_dimension(cur: Any) -> None:
|
|
103
|
+
"""v3: Backfill embedding_dimension for rows written before tracking existed."""
|
|
104
|
+
# Rows with dimension 0 keep 0 (unknown); a later re-embed pass fills them.
|
|
105
|
+
cur.execute("UPDATE memories SET embedding_dimension = 0 WHERE embedding_dimension IS NULL;")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _m4_content_plain_search_projection(cur: Any) -> None:
|
|
109
|
+
"""v4: Combined-protection search projection for field-encrypted content.
|
|
110
|
+
|
|
111
|
+
Adds `content_plain` (searchable projection feeding the tsvector index).
|
|
112
|
+
Existing rows backfill `content_plain` from `content` ONLY when that
|
|
113
|
+
column still holds plaintext — i.e. rows written before P0-2 field
|
|
114
|
+
encryption. Rows already holding Fernet ciphertext are left untouched
|
|
115
|
+
(re-encrypting ciphertext would destroy recoverability); operators must
|
|
116
|
+
run the documented key-rotation/re-encryption sweep for those.
|
|
117
|
+
Detection heuristic: Fernet tokens are urlsafe-base64 starting with
|
|
118
|
+
'gAAAAA'; anything else is treated as legacy plaintext.
|
|
119
|
+
"""
|
|
120
|
+
_add_column_if_missing(cur, "memories", "content_plain", "TEXT NOT NULL DEFAULT ''")
|
|
121
|
+
cur.execute(
|
|
122
|
+
"UPDATE memories SET content_plain = content "
|
|
123
|
+
"WHERE (content_plain IS NULL OR content_plain = '') "
|
|
124
|
+
"AND content NOT LIKE 'gAAAAA%';"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _m5_supersession_chain(cur: Any) -> None:
|
|
129
|
+
"""v5: Deduplication supersession lineage (MERGE keeps both rows).
|
|
130
|
+
|
|
131
|
+
`supersedes` points from the successor (new) row to the row it replaced;
|
|
132
|
+
`superseded_by` points from the replaced (old) row to its successor.
|
|
133
|
+
"""
|
|
134
|
+
_add_column_if_missing(cur, "memories", "supersedes", "VARCHAR(128)")
|
|
135
|
+
_add_column_if_missing(cur, "memories", "superseded_by", "VARCHAR(128)")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
MIGRATIONS: List[Migration] = [
|
|
139
|
+
Migration(1, "embedding_metadata", _m1_embedding_metadata, _m1_embedding_metadata),
|
|
140
|
+
Migration(2, "migration_ledger", _m2_migration_ledger, _m2_migration_ledger),
|
|
141
|
+
Migration(3, "backfill_dimension", _m3_backfill_dimension, _m3_backfill_dimension),
|
|
142
|
+
Migration(4, "content_plain_search_projection", _m4_content_plain_search_projection, _m4_content_plain_search_projection),
|
|
143
|
+
Migration(5, "supersession_chain", _m5_supersession_chain, _m5_supersession_chain),
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _applied_versions(cur: Any) -> set:
|
|
148
|
+
try:
|
|
149
|
+
cur.execute("SELECT version FROM schema_migrations;")
|
|
150
|
+
rows = cur.fetchall()
|
|
151
|
+
return {int(r[0] if isinstance(r, (tuple, list)) else r["version"]) for r in rows}
|
|
152
|
+
except Exception:
|
|
153
|
+
# Ledger table doesn't exist yet -> nothing applied.
|
|
154
|
+
return set()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def run_migrations(
|
|
158
|
+
get_connection: Callable[[], Any],
|
|
159
|
+
is_postgres: bool,
|
|
160
|
+
fail_closed: bool = False,
|
|
161
|
+
close_on_exit: bool = True,
|
|
162
|
+
owns_connection: bool = True,
|
|
163
|
+
) -> int:
|
|
164
|
+
"""Apply all pending forward migrations. Returns count applied. Idempotent.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
get_connection: Callable returning a fresh connection for this run.
|
|
168
|
+
is_postgres: Dialect to apply (SQLite vs PostgreSQL).
|
|
169
|
+
fail_closed: Raise on migration failure instead of skipping.
|
|
170
|
+
close_on_exit: If True (default), the connection is released when the run
|
|
171
|
+
finishes. Set False only when the caller keeps the connection alive
|
|
172
|
+
and reuses it afterwards, e.g. SQLiteMemoryStore caching its
|
|
173
|
+
schema-init ':memory:' connection.
|
|
174
|
+
owns_connection: If True (default), treat the returned connection as
|
|
175
|
+
freshly-owned (non-pooled) and close it in the finally block. Set
|
|
176
|
+
False when the connection belongs to an external pool that must be
|
|
177
|
+
returned, not closed. Existing callers can rely on the default True
|
|
178
|
+
(fresh connection). PGVectorMemoryStore passes a pooled connection
|
|
179
|
+
and sets this False.
|
|
180
|
+
|
|
181
|
+
The connection is closed only when ``owns_connection`` AND
|
|
182
|
+
``close_on_exit`` are both True. Otherwise it is handed back through the
|
|
183
|
+
factory owner's ``_return_connection`` hook (when present) or closed as
|
|
184
|
+
a last-resort fallback.
|
|
185
|
+
"""
|
|
186
|
+
applied_total = 0
|
|
187
|
+
conn = get_connection()
|
|
188
|
+
should_close = owns_connection and close_on_exit
|
|
189
|
+
try:
|
|
190
|
+
cur = conn.cursor()
|
|
191
|
+
# The ledger (v2) must exist before we can read it; bootstrap directly.
|
|
192
|
+
_m2_migration_ledger(cur)
|
|
193
|
+
conn.commit()
|
|
194
|
+
|
|
195
|
+
done = _applied_versions(cur)
|
|
196
|
+
for migration in MIGRATIONS:
|
|
197
|
+
if migration.version in done:
|
|
198
|
+
continue
|
|
199
|
+
fn = migration.postgres if is_postgres else migration.sqlite
|
|
200
|
+
if fn is None:
|
|
201
|
+
continue
|
|
202
|
+
try:
|
|
203
|
+
fn(cur)
|
|
204
|
+
cur.execute(
|
|
205
|
+
"INSERT INTO schema_migrations (version, name, applied_at) VALUES (%s, %s, %s);"
|
|
206
|
+
if is_postgres
|
|
207
|
+
else "INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?);",
|
|
208
|
+
(migration.version, migration.name, time.time()),
|
|
209
|
+
)
|
|
210
|
+
conn.commit()
|
|
211
|
+
applied_total += 1
|
|
212
|
+
logger.info("Applied migration v%d: %s", migration.version, migration.name)
|
|
213
|
+
except Exception as e:
|
|
214
|
+
conn.rollback()
|
|
215
|
+
if fail_closed:
|
|
216
|
+
raise RuntimeError(f"Migration v{migration.version} failed: {e}") from e
|
|
217
|
+
logger.warning("Migration v%d (%s) skipped: %s", migration.version, migration.name, e)
|
|
218
|
+
finally:
|
|
219
|
+
try:
|
|
220
|
+
if should_close:
|
|
221
|
+
conn.close()
|
|
222
|
+
else:
|
|
223
|
+
# Return pooled connections to the pool instead of closing.
|
|
224
|
+
try:
|
|
225
|
+
conn.rollback()
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
try:
|
|
229
|
+
owner = getattr(get_connection, "__self__", None)
|
|
230
|
+
if owner is not None:
|
|
231
|
+
owner._return_connection(conn)
|
|
232
|
+
else:
|
|
233
|
+
conn.close()
|
|
234
|
+
except Exception:
|
|
235
|
+
try:
|
|
236
|
+
conn.close()
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
return applied_total
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
from typing import Dict, Any
|
|
5
|
+
from functools import wraps
|
|
6
|
+
|
|
7
|
+
# Structured Logger Setup
|
|
8
|
+
logger = logging.getLogger("SynapseObservability")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def log_event(event_name: str, status: str, details: Dict[str, Any], level: int = logging.INFO):
|
|
12
|
+
"""Logs structured JSON events for production analysis."""
|
|
13
|
+
log_record = {
|
|
14
|
+
"event": event_name,
|
|
15
|
+
"status": status,
|
|
16
|
+
"timestamp": time.time(),
|
|
17
|
+
"details": details
|
|
18
|
+
}
|
|
19
|
+
logger.log(level, json.dumps(log_record))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def instrument_operation(operation_name: str):
|
|
23
|
+
"""Decorator to instrument method execution times and failures."""
|
|
24
|
+
def decorator(func):
|
|
25
|
+
@wraps(func)
|
|
26
|
+
def wrapper(*args, **kwargs):
|
|
27
|
+
start = time.perf_counter()
|
|
28
|
+
try:
|
|
29
|
+
result = func(*args, **kwargs)
|
|
30
|
+
log_event(operation_name, "SUCCESS", {"duration_ms": (time.perf_counter() - start) * 1000})
|
|
31
|
+
return result
|
|
32
|
+
except Exception as e:
|
|
33
|
+
log_event(operation_name, "FAILURE", {"error": str(e), "duration_ms": (time.perf_counter() - start) * 1000}, level=logging.ERROR)
|
|
34
|
+
raise e
|
|
35
|
+
return wrapper
|
|
36
|
+
return decorator
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MetricsCollector:
|
|
40
|
+
"""Simple in-memory metrics collector."""
|
|
41
|
+
def __init__(self):
|
|
42
|
+
self.metrics: Dict[str, float] = {}
|
|
43
|
+
|
|
44
|
+
def increment(self, metric_name: str, value: float = 1.0):
|
|
45
|
+
self.metrics[metric_name] = self.metrics.get(metric_name, 0.0) + value
|
|
46
|
+
|
|
47
|
+
def get_metrics(self) -> Dict[str, float]:
|
|
48
|
+
return self.metrics
|