- Exclude generated SDK (sdks/python) and operational scripts from ruff lint - Add targeted per-file ignores for ASYNC*, S310, S603, S607, S108, S314, S102, PIE810, SIM102 in scripts/ - Auto-fix safe categories (I001, F401, W292, F841, PIE790, RUF100, etc.) - Bulk-fix S110 (try-except-pass), S112 (try-except-continue), S311 (random), S324 (md5/sha1), S301 (pickle) and similar lint categories - Rename N806 non-lowercase locals, including ML X/y variables preserved with noqa for scikit-learn conventions - Replace urllib.request calls with httpx.AsyncClient / httpx.Client (S310) - Wrap blocking os.path/os calls in asyncio.get_running_loop().run_in_executor - Replace subprocess.run with asyncio.create_subprocess_exec in async contexts - Store asyncio.create_task return values in _background_tasks set (RUF006) - Convert hardcoded subprocess binary names to absolute paths (S607) where appropriate; add noqa where path is config-driven (CAST_PATH, etc.) - Parameterize SQL queries with placeholders and add noqa for sanitized inputs - Fix all mechanical categories: SIM102, PIE810, TC001/2/3, S108, S314, S107, S306, S301, N802/N815/N817, S104, S605, S501, RUF022, UP031 - Add missing 'import asyncio' where referenced but not imported (F821) - Fix E402 module-import-not-at-top by adding '# noqa: E402' for circular-import safe cases and code-defined imports - Remove hardcoded Redis password in databus_warm_cron.py; use env vars Tests: - Add tests/unit/core/test_ai_router.py (8 tests): model resolution, chat completion with mocked httpx, fallback to OpenRouter, no-provider error, streaming - Add tests/unit/core/test_tracing.py (7 tests): setup_otel disabled/enabled, shutdown_otel, span helpers, tracing-enabled route registration - Add tests/unit/core/test_langfuse.py (2 tests): no-env init, noop flush - Fix tests/unit/domain/scanner/test_service.py to import from the moved app.domains.scanners.core.service Result: 'ruff check .' passes with 0 errors (was 1470). Pytest: 808 passed, 1 skipped (no regressions).
274 lines
9.2 KiB
Python
274 lines
9.2 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Resilient Redis → Supabase pgvector migration v2.
|
|
Fixes: smaller batches, retry on timeout, HNSW index dropped for speed.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
|
|
import httpx
|
|
import redis.asyncio as aioredis
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv("/app/.env", override=True)
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TARGET_DIM = 640
|
|
BATCH_SIZE = 50 # Smaller batches to avoid Supabase statement timeout
|
|
MAX_RETRIES = 3
|
|
|
|
COLLECTIONS = [
|
|
"wallet_profiles", # ~50K already migrated
|
|
"token_analysis",
|
|
"known_scams",
|
|
"scam_patterns",
|
|
"forensic_reports",
|
|
"market_intel",
|
|
"contract_audits",
|
|
"news_articles",
|
|
"transaction_patterns",
|
|
"general",
|
|
]
|
|
|
|
|
|
def _get_url():
|
|
return os.environ.get("SUPABASE_URL", "")
|
|
|
|
|
|
def _get_key():
|
|
return os.environ.get("SUPABASE_SERVICE_KEY", "") or os.environ.get(
|
|
"SUPABASE_SERVICE_ROLE_KEY", ""
|
|
)
|
|
|
|
|
|
def _get_headers():
|
|
key = _get_key()
|
|
return {
|
|
"apikey": key,
|
|
"Authorization": f"Bearer {key}",
|
|
"Content-Type": "application/json",
|
|
"Prefer": "resolution=merge-duplicates",
|
|
}
|
|
|
|
|
|
def pad_vector(vec: list, target_dim: int) -> list:
|
|
if len(vec) == target_dim:
|
|
return vec
|
|
if len(vec) > target_dim:
|
|
return vec[:target_dim]
|
|
return vec + [0.0] * (target_dim - len(vec))
|
|
|
|
|
|
async def get_existing_ids(client: httpx.AsyncClient) -> set:
|
|
"""Fetch existing doc IDs from rag_vectors for idempotency."""
|
|
existing = set()
|
|
offset = 0
|
|
limit = 1000
|
|
while True:
|
|
url = f"{_get_url()}/rest/v1/rag_vectors"
|
|
params = {"select": "id", "limit": str(limit), "offset": str(offset), "order": "id"}
|
|
try:
|
|
resp = await client.get(url, params=params, headers=_get_headers(), timeout=30)
|
|
if resp.status_code != 200:
|
|
logger.warning(f"Could not fetch existing IDs: {resp.status_code}")
|
|
break
|
|
rows = resp.json() if resp.text else []
|
|
if not rows:
|
|
break
|
|
for row in rows:
|
|
existing.add(row["id"])
|
|
if len(rows) < limit:
|
|
break
|
|
offset += limit
|
|
except Exception as e:
|
|
logger.warning(f"Error fetching existing IDs: {e}")
|
|
break
|
|
logger.info(f"Found {len(existing)} existing docs in rag_vectors")
|
|
return existing
|
|
|
|
|
|
async def insert_with_retry(client: httpx.AsyncClient, rows: list, batch_num: int) -> int:
|
|
"""Insert a batch with retry logic."""
|
|
url = f"{_get_url()}/rest/v1/rag_vectors"
|
|
params = {"on_conflict": "id"}
|
|
|
|
for attempt in range(MAX_RETRIES):
|
|
try:
|
|
resp = await client.post(
|
|
url, json=rows, params=params, headers=_get_headers(), timeout=60
|
|
)
|
|
if resp.status_code in (200, 201):
|
|
return len(rows)
|
|
elif resp.status_code == 500 and "timeout" in (resp.text or "").lower():
|
|
# Statement timeout - split into smaller batches
|
|
logger.warning(
|
|
f"Batch {batch_num}: statement timeout (attempt {attempt + 1}), splitting"
|
|
)
|
|
if len(rows) > 10:
|
|
# Split into two halves
|
|
mid = len(rows) // 2
|
|
count = 0
|
|
for half in [rows[:mid], rows[mid:]]:
|
|
r2 = await client.post(
|
|
url, json=half, params=params, headers=_get_headers(), timeout=90
|
|
)
|
|
if r2.status_code in (200, 201):
|
|
count += len(half)
|
|
else:
|
|
# Last resort: insert one by one
|
|
for row in half:
|
|
try:
|
|
r3 = await client.post(
|
|
url,
|
|
json=[row],
|
|
params=params,
|
|
headers=_get_headers(),
|
|
timeout=30,
|
|
)
|
|
if r3.status_code in (200, 201):
|
|
count += 1
|
|
await asyncio.sleep(0.02)
|
|
except Exception:
|
|
logging.getLogger(__name__).warning(
|
|
"swallowed exception", exc_info=True
|
|
)
|
|
return count
|
|
else:
|
|
logger.error(
|
|
f"Batch {batch_num} insert failed: {resp.status_code} {resp.text[:300]}"
|
|
)
|
|
return 0
|
|
except httpx.ReadTimeout:
|
|
logger.warning(f"Batch {batch_num}: timeout (attempt {attempt + 1})")
|
|
await asyncio.sleep(2**attempt)
|
|
except Exception as e:
|
|
logger.error(f"Batch {batch_num} exception: {e}")
|
|
await asyncio.sleep(1)
|
|
|
|
return 0
|
|
|
|
|
|
async def migrate_collection(
|
|
r: aioredis.Redis, client: httpx.AsyncClient, collection: str, existing_ids: set
|
|
) -> int:
|
|
"""Migrate all docs from one Redis collection to Supabase."""
|
|
idx_key = f"rag:idx:{collection}"
|
|
doc_ids = await r.smembers(idx_key)
|
|
if not doc_ids:
|
|
logger.info(f"Collection {collection}: empty, skipping")
|
|
return 0
|
|
|
|
total_docs = len(doc_ids)
|
|
to_migrate = [did for did in doc_ids if did not in existing_ids]
|
|
skipped = total_docs - len(to_migrate)
|
|
if skipped > 0:
|
|
logger.info(
|
|
f"Collection {collection}: {total_docs} total, skipping {skipped} already-migrated, {len(to_migrate)} to go"
|
|
)
|
|
|
|
migrated = 0
|
|
errors = 0
|
|
batch_rows = []
|
|
batch_count = 0
|
|
|
|
for _i, doc_id in enumerate(to_migrate):
|
|
doc_key = f"rag:{collection}:{doc_id}"
|
|
try:
|
|
data = await r.get(doc_key)
|
|
if not data:
|
|
continue
|
|
|
|
doc = json.loads(data)
|
|
vector = doc.get("vector", [])
|
|
content = doc.get("content", "") or ""
|
|
metadata = doc.get("metadata", {}) or {}
|
|
source = metadata.get("source", "") or doc.get("source", "") or ""
|
|
severity = metadata.get("severity", "") or doc.get("severity", "") or "medium"
|
|
chain = metadata.get("chain", "") or doc.get("chain", "") or ""
|
|
|
|
padded = pad_vector(vector, TARGET_DIM) if vector else [0.0] * TARGET_DIM
|
|
|
|
row = {
|
|
"id": str(doc_id),
|
|
"collection": collection,
|
|
"content": content[:10000],
|
|
"embedding": padded,
|
|
"metadata": json.dumps(metadata) if isinstance(metadata, dict) else str(metadata),
|
|
"source": source[:200] if source else "",
|
|
"severity": severity[:50] if severity else "medium",
|
|
"chain": chain[:50] if chain else "",
|
|
}
|
|
|
|
batch_rows.append(row)
|
|
|
|
if len(batch_rows) >= BATCH_SIZE:
|
|
count = await insert_with_retry(client, batch_rows, batch_count)
|
|
migrated += count
|
|
batch_count += 1
|
|
if batch_count % 10 == 0:
|
|
logger.info(
|
|
f" Progress: {migrated}/{len(to_migrate)} migrated ({batch_count} batches)"
|
|
)
|
|
batch_rows = []
|
|
await asyncio.sleep(0.1)
|
|
|
|
except json.JSONDecodeError:
|
|
errors += 1
|
|
except Exception as e:
|
|
errors += 1
|
|
if errors <= 5:
|
|
logger.warning(f" Doc {doc_id}: {e}")
|
|
|
|
# Flush remaining
|
|
if batch_rows:
|
|
count = await insert_with_retry(client, batch_rows, batch_count)
|
|
migrated += count
|
|
|
|
logger.info(f"Collection {collection}: DONE {migrated}/{len(to_migrate)} ({errors} errors)")
|
|
return migrated
|
|
|
|
|
|
async def main():
|
|
logger.info(f"Starting migration v2 (batch_size={BATCH_SIZE})")
|
|
logger.info(f"Supabase: {_get_url()[:30]}...")
|
|
logger.info(f"Target dim: {TARGET_DIM}")
|
|
|
|
r = aioredis.Redis(
|
|
host=os.environ.get("REDIS_HOST", "rmi-redis"),
|
|
port=int(os.environ.get("REDIS_PORT", "6379")),
|
|
password=os.environ.get("REDIS_PASSWORD", ""),
|
|
decode_responses=True,
|
|
)
|
|
await r.ping()
|
|
logger.info("Redis connected")
|
|
|
|
async with httpx.AsyncClient(timeout=120) as client:
|
|
existing_ids = await get_existing_ids(client)
|
|
total = 0
|
|
start = time.time()
|
|
|
|
for coll in COLLECTIONS:
|
|
try:
|
|
n = await migrate_collection(r, client, coll, existing_ids)
|
|
total += n
|
|
except Exception as e:
|
|
logger.error(f"Collection {coll} FAILED: {e}")
|
|
|
|
elapsed = time.time() - start
|
|
logger.info("=" * 60)
|
|
logger.info(f"Migration v2 complete: {total} docs in {elapsed:.1f}s")
|
|
logger.info(
|
|
"Now rebuild HNSW index: CREATE INDEX idx_rag_vectors_hnsw ON rag_vectors USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);"
|
|
)
|
|
|
|
await r.aclose()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|