535 lines
20 KiB
Python
535 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import ssl
|
|
import uuid
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
|
|
|
|
|
|
def postgres_ssl_context() -> ssl.SSLContext:
|
|
ca_file = os.environ.get("PG_CA_FILE")
|
|
if not ca_file:
|
|
raise RuntimeError("PG_CA_FILE is required")
|
|
context = ssl.create_default_context(cafile=ca_file)
|
|
context.check_hostname = True
|
|
context.verify_mode = ssl.CERT_REQUIRED
|
|
return context
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LeasedTask:
|
|
id: uuid.UUID
|
|
task_type: str
|
|
user_id: uuid.UUID
|
|
lease_token: uuid.UUID
|
|
attempt_count: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LeasedWebhook:
|
|
id: uuid.UUID
|
|
receiver_type: str
|
|
event_type: str
|
|
external_id: str
|
|
lease_token: uuid.UUID
|
|
attempt_count: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Profile:
|
|
user_id: uuid.UUID
|
|
phone: str
|
|
identity_status: str
|
|
profile_status: str
|
|
|
|
|
|
class Repository:
|
|
def __init__(self, database_url: str, pool_size: int = 5) -> None:
|
|
self.engine: AsyncEngine = create_async_engine(
|
|
database_url,
|
|
pool_size=pool_size,
|
|
pool_pre_ping=True,
|
|
connect_args={"ssl": postgres_ssl_context()},
|
|
)
|
|
|
|
async def close(self) -> None:
|
|
await self.engine.dispose()
|
|
|
|
@asynccontextmanager
|
|
async def transaction(self) -> AsyncIterator[AsyncConnection]:
|
|
async with self.engine.begin() as connection:
|
|
yield connection
|
|
|
|
async def ping(self) -> bool:
|
|
try:
|
|
async with self.engine.connect() as connection:
|
|
await connection.execute(text("SELECT 1"))
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
async def claim_tasks(
|
|
self, worker_id: str, limit: int, lease_seconds: int
|
|
) -> list[LeasedTask]:
|
|
sql = text(
|
|
"""
|
|
WITH candidates AS (
|
|
SELECT id
|
|
FROM han_app.sync_queue
|
|
WHERE status IN ('pending','retry_wait')
|
|
AND next_attempt_at <= now()
|
|
AND (locked_until IS NULL OR locked_until < now())
|
|
ORDER BY next_attempt_at, created_at
|
|
FOR UPDATE SKIP LOCKED
|
|
LIMIT :limit
|
|
)
|
|
UPDATE han_app.sync_queue q
|
|
SET status='leased', locked_by=:worker_id,
|
|
locked_until=now() + make_interval(secs => :lease_seconds),
|
|
lease_token=gen_random_uuid(), updated_at=now()
|
|
FROM candidates c
|
|
WHERE q.id=c.id
|
|
RETURNING q.id, q.task_type, q.entity_id, q.lease_token, q.attempt_count
|
|
"""
|
|
)
|
|
async with self.engine.begin() as connection:
|
|
rows = (
|
|
await connection.execute(
|
|
sql,
|
|
{"worker_id": worker_id, "limit": limit, "lease_seconds": lease_seconds},
|
|
)
|
|
).mappings()
|
|
return [
|
|
LeasedTask(
|
|
row["id"],
|
|
row["task_type"],
|
|
row["entity_id"],
|
|
row["lease_token"],
|
|
row["attempt_count"],
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
async def load_profile(self, user_id: uuid.UUID) -> Profile | None:
|
|
sql = text(
|
|
"""
|
|
SELECT i.id user_id, i.phone_number phone, i.record_status identity_status,
|
|
p.record_status profile_status
|
|
FROM han_app.user_identities i
|
|
JOIN han_app.client_profiles p ON p.user_id=i.id
|
|
WHERE i.id=:user_id
|
|
"""
|
|
)
|
|
async with self.engine.connect() as connection:
|
|
row = (await connection.execute(sql, {"user_id": user_id})).mappings().first()
|
|
return Profile(**row) if row else None
|
|
|
|
async def active_mapping(self, user_id: uuid.UUID) -> str | None:
|
|
sql = text(
|
|
"""
|
|
SELECT external_id FROM bitrix_sync.entity_external_mapping
|
|
WHERE external_system='bitrix24' AND entity_type='contact'
|
|
AND entity_id=:user_id AND status='active'
|
|
"""
|
|
)
|
|
async with self.engine.connect() as connection:
|
|
return (await connection.execute(sql, {"user_id": user_id})).scalar_one_or_none()
|
|
|
|
async def create_workflow(self, task: LeasedTask) -> uuid.UUID:
|
|
workflow_id = uuid.uuid4()
|
|
sql = text(
|
|
"""
|
|
INSERT INTO bitrix_sync.workflow_instances
|
|
(id, workflow_type, user_id, state, current_step, source_task_id,
|
|
deadline_at, created_at, updated_at)
|
|
VALUES (:id, :workflow_type, :user_id, 'created', 'load_profile', :task_id,
|
|
now() + interval '24 hours', now(), now())
|
|
ON CONFLICT (source_task_id) DO UPDATE SET updated_at=now()
|
|
RETURNING id
|
|
"""
|
|
)
|
|
async with self.engine.begin() as connection:
|
|
return (
|
|
await connection.execute(
|
|
sql,
|
|
{
|
|
"id": workflow_id,
|
|
"workflow_type": task.task_type,
|
|
"user_id": task.user_id,
|
|
"task_id": task.id,
|
|
},
|
|
)
|
|
).scalar_one()
|
|
|
|
async def complete_task(self, task: LeasedTask, workflow_id: uuid.UUID) -> bool:
|
|
async with self.engine.begin() as connection:
|
|
result = await connection.execute(
|
|
text(
|
|
"""
|
|
UPDATE han_app.sync_queue
|
|
SET status='processed', completed_at=now(), locked_by=NULL,
|
|
locked_until=NULL, lease_token=NULL, updated_at=now()
|
|
WHERE id=:id AND status='leased' AND lease_token=:lease_token
|
|
"""
|
|
),
|
|
{"id": task.id, "lease_token": task.lease_token},
|
|
)
|
|
if result.rowcount != 1:
|
|
return False
|
|
await connection.execute(
|
|
text(
|
|
"""
|
|
UPDATE bitrix_sync.workflow_instances
|
|
SET state='succeeded', current_step='done', outcome='processed',
|
|
completed_at=now(), updated_at=now()
|
|
WHERE id=:workflow_id AND state NOT IN ('succeeded','failed','cancelled')
|
|
"""
|
|
),
|
|
{"workflow_id": workflow_id},
|
|
)
|
|
return True
|
|
|
|
async def retry_task(
|
|
self, task: LeasedTask, safe_code: str, delay_seconds: float
|
|
) -> bool:
|
|
sql = text(
|
|
"""
|
|
UPDATE han_app.sync_queue
|
|
SET status='retry_wait', attempt_count=attempt_count+1,
|
|
next_attempt_at=now() + make_interval(secs => :delay),
|
|
last_error_code=:code, last_error_at=now(),
|
|
locked_by=NULL, locked_until=NULL, lease_token=NULL, updated_at=now()
|
|
WHERE id=:id AND status='leased' AND lease_token=:lease_token
|
|
"""
|
|
)
|
|
async with self.engine.begin() as connection:
|
|
result = await connection.execute(
|
|
sql,
|
|
{
|
|
"id": task.id,
|
|
"lease_token": task.lease_token,
|
|
"code": safe_code[:64],
|
|
"delay": delay_seconds,
|
|
},
|
|
)
|
|
return result.rowcount == 1
|
|
|
|
async def insert_webhook(
|
|
self,
|
|
receiver_type: str,
|
|
event_type: str,
|
|
entity_id: str,
|
|
source_ip: str,
|
|
) -> uuid.UUID:
|
|
inbox_id = uuid.uuid4()
|
|
async with self.engine.begin() as connection:
|
|
existing = await connection.execute(
|
|
text(
|
|
"""
|
|
SELECT id FROM bitrix_sync.webhook_inbox
|
|
WHERE receiver_type=:receiver AND external_entity_id=:entity_id
|
|
AND status IN ('received','processing','retry_wait')
|
|
FOR UPDATE
|
|
"""
|
|
),
|
|
{"receiver": receiver_type, "entity_id": entity_id},
|
|
)
|
|
if row := existing.first():
|
|
await connection.execute(
|
|
text(
|
|
"""
|
|
UPDATE bitrix_sync.webhook_inbox
|
|
SET coalesced_count=coalesced_count+1, last_received_at=now()
|
|
WHERE id=:id
|
|
"""
|
|
),
|
|
{"id": row[0]},
|
|
)
|
|
return row[0]
|
|
await connection.execute(
|
|
text(
|
|
"""
|
|
INSERT INTO bitrix_sync.webhook_inbox
|
|
(id, receiver_type, event_type, external_entity_id, source_ip,
|
|
status, coalesced_count, received_at, last_received_at)
|
|
VALUES (:id,:receiver,:event,:entity_id,CAST(:source_ip AS inet),
|
|
'received',1,now(),now())
|
|
"""
|
|
),
|
|
{
|
|
"id": inbox_id,
|
|
"receiver": receiver_type,
|
|
"event": event_type,
|
|
"entity_id": entity_id,
|
|
"source_ip": source_ip,
|
|
},
|
|
)
|
|
return inbox_id
|
|
|
|
async def claim_webhooks(
|
|
self, worker_id: str, limit: int, lease_seconds: int
|
|
) -> list[LeasedWebhook]:
|
|
sql = text(
|
|
"""
|
|
WITH candidates AS (
|
|
SELECT id FROM bitrix_sync.webhook_inbox
|
|
WHERE status IN ('received','retry_wait') AND next_attempt_at<=now()
|
|
AND (locked_until IS NULL OR locked_until<now())
|
|
ORDER BY next_attempt_at,received_at
|
|
FOR UPDATE SKIP LOCKED LIMIT :limit
|
|
)
|
|
UPDATE bitrix_sync.webhook_inbox w
|
|
SET status='processing',locked_by=:worker_id,
|
|
locked_until=now()+make_interval(secs=>:lease_seconds),
|
|
lease_token=gen_random_uuid()
|
|
FROM candidates c WHERE w.id=c.id
|
|
RETURNING w.id,w.receiver_type,w.event_type,w.external_entity_id,
|
|
w.lease_token,w.attempt_count
|
|
"""
|
|
)
|
|
async with self.engine.begin() as connection:
|
|
rows = (
|
|
await connection.execute(
|
|
sql,
|
|
{"worker_id": worker_id, "limit": limit, "lease_seconds": lease_seconds},
|
|
)
|
|
).mappings()
|
|
return [
|
|
LeasedWebhook(
|
|
row["id"],
|
|
row["receiver_type"],
|
|
row["event_type"],
|
|
row["external_entity_id"],
|
|
row["lease_token"],
|
|
row["attempt_count"],
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
async def mapped_user_for_external(self, external_id: str) -> uuid.UUID | None:
|
|
async with self.engine.connect() as connection:
|
|
return (
|
|
await connection.execute(
|
|
text(
|
|
"""
|
|
SELECT entity_id FROM bitrix_sync.entity_external_mapping
|
|
WHERE external_system='bitrix24' AND external_entity_type='contact'
|
|
AND external_id=:external_id AND status='active'
|
|
"""
|
|
),
|
|
{"external_id": external_id},
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
async def apply_crm_profile(
|
|
self,
|
|
user_id: uuid.UUID,
|
|
external_id: str,
|
|
*,
|
|
full_name: str | None,
|
|
citizenship: str | None,
|
|
email: str | None,
|
|
source_updated_at: datetime | None,
|
|
source: str,
|
|
) -> None:
|
|
async with self.engine.begin() as connection:
|
|
await connection.execute(text("SET LOCAL han.sync_suppress='true'"))
|
|
await connection.execute(
|
|
text(
|
|
"""
|
|
UPDATE han_app.client_profiles
|
|
SET full_name=:full_name,citizenship=:citizenship,email=:email,updated_at=now()
|
|
WHERE user_id=:user_id AND record_status='A'
|
|
"""
|
|
),
|
|
{
|
|
"user_id": user_id,
|
|
"full_name": full_name,
|
|
"citizenship": citizenship,
|
|
"email": email,
|
|
},
|
|
)
|
|
await connection.execute(
|
|
text(
|
|
"""
|
|
INSERT INTO bitrix_sync.contact_snapshots
|
|
(id,mapping_id,user_id,external_id,full_name_hash,email_hash,
|
|
citizenship_hash,source_updated_at,last_applied_source,
|
|
last_webhook_received_at,created_at,updated_at)
|
|
SELECT gen_random_uuid(),m.id,:user_id,:external_id,
|
|
encode(digest(coalesce(:full_name,''),'sha256'),'hex'),
|
|
encode(digest(coalesce(:email,''),'sha256'),'hex'),
|
|
encode(digest(coalesce(:citizenship,''),'sha256'),'hex'),
|
|
:source_updated_at,:source,
|
|
CASE WHEN :source='webhook' THEN now() END,now(),now()
|
|
FROM bitrix_sync.entity_external_mapping m
|
|
WHERE m.entity_id=:user_id AND m.external_id=:external_id AND m.status='active'
|
|
ON CONFLICT (mapping_id) DO UPDATE
|
|
SET full_name_hash=excluded.full_name_hash,email_hash=excluded.email_hash,
|
|
citizenship_hash=excluded.citizenship_hash,
|
|
source_updated_at=excluded.source_updated_at,
|
|
last_applied_source=excluded.last_applied_source,
|
|
last_webhook_received_at=coalesce(
|
|
excluded.last_webhook_received_at,
|
|
bitrix_sync.contact_snapshots.last_webhook_received_at),
|
|
updated_at=now()
|
|
"""
|
|
),
|
|
{
|
|
"user_id": user_id,
|
|
"external_id": external_id,
|
|
"full_name": full_name,
|
|
"citizenship": citizenship,
|
|
"email": email,
|
|
"source_updated_at": source_updated_at,
|
|
"source": source,
|
|
},
|
|
)
|
|
|
|
async def complete_webhook(self, item: LeasedWebhook) -> bool:
|
|
async with self.engine.begin() as connection:
|
|
result = await connection.execute(
|
|
text(
|
|
"""
|
|
UPDATE bitrix_sync.webhook_inbox
|
|
SET status='processed',processed_at=now(),locked_by=NULL,
|
|
locked_until=NULL,lease_token=NULL
|
|
WHERE id=:id AND status='processing' AND lease_token=:lease_token
|
|
"""
|
|
),
|
|
{"id": item.id, "lease_token": item.lease_token},
|
|
)
|
|
return result.rowcount == 1
|
|
|
|
async def retry_webhook(
|
|
self, item: LeasedWebhook, safe_code: str, delay_seconds: float
|
|
) -> bool:
|
|
async with self.engine.begin() as connection:
|
|
result = await connection.execute(
|
|
text(
|
|
"""
|
|
UPDATE bitrix_sync.webhook_inbox
|
|
SET status='retry_wait',attempt_count=attempt_count+1,
|
|
next_attempt_at=now()+make_interval(secs=>:delay),
|
|
safe_error_code=:code,locked_by=NULL,locked_until=NULL,lease_token=NULL
|
|
WHERE id=:id AND status='processing' AND lease_token=:lease_token
|
|
"""
|
|
),
|
|
{
|
|
"id": item.id,
|
|
"lease_token": item.lease_token,
|
|
"code": safe_code[:64],
|
|
"delay": delay_seconds,
|
|
},
|
|
)
|
|
return result.rowcount == 1
|
|
|
|
async def pending_rebind_ids(self, limit: int) -> list[uuid.UUID]:
|
|
async with self.engine.connect() as connection:
|
|
rows = await connection.execute(
|
|
text(
|
|
"""
|
|
SELECT id FROM bitrix_sync.rebind_requests
|
|
WHERE status IN ('pending','retry_wait')
|
|
ORDER BY requested_at LIMIT :limit
|
|
"""
|
|
),
|
|
{"limit": limit},
|
|
)
|
|
return list(rows.scalars())
|
|
|
|
async def reserve_limiter_token(self, refill_per_second: float, burst: int) -> float:
|
|
async with self.engine.begin() as connection:
|
|
row = (
|
|
await connection.execute(
|
|
text(
|
|
"""
|
|
SELECT tokens,capacity,refill_per_second,
|
|
extract(epoch FROM now()-updated_at) elapsed,
|
|
greatest(0,extract(epoch FROM blocked_until-now())) blocked
|
|
FROM bitrix_sync.limiter_coordination
|
|
WHERE limiter_key='bitrix24:portal'
|
|
FOR UPDATE
|
|
"""
|
|
)
|
|
)
|
|
).mappings().first()
|
|
if row is None:
|
|
await connection.execute(
|
|
text(
|
|
"""
|
|
INSERT INTO bitrix_sync.limiter_coordination
|
|
(limiter_key,tokens,capacity,refill_per_second,updated_at,fencing_token)
|
|
VALUES ('bitrix24:portal',:tokens,:capacity,:refill,now(),1)
|
|
"""
|
|
),
|
|
{"tokens": max(0, burst - 1), "capacity": burst, "refill": refill_per_second},
|
|
)
|
|
return 0
|
|
blocked = float(row["blocked"] or 0)
|
|
tokens = min(
|
|
float(burst),
|
|
float(row["tokens"]) + float(row["elapsed"] or 0) * refill_per_second,
|
|
)
|
|
if blocked > 0:
|
|
delay = blocked
|
|
elif tokens >= 1:
|
|
tokens -= 1
|
|
delay = 0
|
|
else:
|
|
delay = (1 - tokens) / refill_per_second
|
|
await connection.execute(
|
|
text(
|
|
"""
|
|
UPDATE bitrix_sync.limiter_coordination
|
|
SET tokens=:tokens,capacity=:capacity,refill_per_second=:refill,
|
|
updated_at=now(),fencing_token=fencing_token+1
|
|
WHERE limiter_key='bitrix24:portal'
|
|
"""
|
|
),
|
|
{
|
|
"tokens": tokens,
|
|
"capacity": burst,
|
|
"refill": refill_per_second,
|
|
},
|
|
)
|
|
return delay
|
|
|
|
async def status(self) -> dict[str, Any]:
|
|
queries = {
|
|
"queue": "SELECT status, count(*) count FROM han_app.sync_queue GROUP BY status",
|
|
"workflows": (
|
|
"SELECT state AS status, count(*) count "
|
|
"FROM bitrix_sync.workflow_instances GROUP BY state"
|
|
),
|
|
"commands": (
|
|
"SELECT status, count(*) count "
|
|
"FROM bitrix_sync.crm_commands GROUP BY status"
|
|
),
|
|
"webhook_lag_seconds": (
|
|
"SELECT coalesce(extract(epoch from now()-min(received_at)),0) "
|
|
"FROM bitrix_sync.webhook_inbox WHERE status IN ('received','retry_wait')"
|
|
),
|
|
"settings_version": (
|
|
"SELECT version FROM bitrix_sync.settings_versions "
|
|
"WHERE active=true AND validation_status='valid' ORDER BY activated_at DESC LIMIT 1"
|
|
),
|
|
}
|
|
output: dict[str, Any] = {}
|
|
async with self.engine.connect() as connection:
|
|
for key, sql in queries.items():
|
|
result = await connection.execute(text(sql))
|
|
if key in {"queue", "workflows", "commands"}:
|
|
output[key] = {row.status: row.count for row in result}
|
|
else:
|
|
output[key] = result.scalar_one_or_none()
|
|
output["generated_at"] = datetime.now(UTC).isoformat()
|
|
return output
|