mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-22 03:14:06 -05:00
292 lines
11 KiB
Python
292 lines
11 KiB
Python
import time
|
|
from collections.abc import Sequence
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy import and_
|
|
from sqlalchemy import delete
|
|
from sqlalchemy import func
|
|
from sqlalchemy import or_
|
|
from sqlalchemy import select
|
|
from sqlalchemy.dialects.postgresql import insert
|
|
from sqlalchemy.orm import Session
|
|
|
|
from danswer.configs.constants import DEFAULT_BOOST
|
|
from danswer.db.feedback import delete_document_feedback_for_documents
|
|
from danswer.db.models import ConnectorCredentialPair
|
|
from danswer.db.models import Credential
|
|
from danswer.db.models import Document as DbDocument
|
|
from danswer.db.models import DocumentByConnectorCredentialPair
|
|
from danswer.db.utils import model_to_dict
|
|
from danswer.document_index.interfaces import DocumentMetadata
|
|
from danswer.server.models import ConnectorCredentialPairIdentifier
|
|
from danswer.utils.logger import setup_logger
|
|
|
|
logger = setup_logger()
|
|
|
|
|
|
def get_documents_for_connector_credential_pair(
|
|
db_session: Session, connector_id: int, credential_id: int, limit: int | None = None
|
|
) -> Sequence[DbDocument]:
|
|
initial_doc_ids_stmt = select(DocumentByConnectorCredentialPair.id).where(
|
|
and_(
|
|
DocumentByConnectorCredentialPair.connector_id == connector_id,
|
|
DocumentByConnectorCredentialPair.credential_id == credential_id,
|
|
)
|
|
)
|
|
stmt = select(DbDocument).where(DbDocument.id.in_(initial_doc_ids_stmt)).distinct()
|
|
if limit:
|
|
stmt = stmt.limit(limit)
|
|
return db_session.scalars(stmt).all()
|
|
|
|
|
|
def get_document_connector_cnts(
|
|
db_session: Session,
|
|
document_ids: list[str],
|
|
) -> Sequence[tuple[str, int]]:
|
|
stmt = (
|
|
select(
|
|
DocumentByConnectorCredentialPair.id,
|
|
func.count(),
|
|
)
|
|
.where(DocumentByConnectorCredentialPair.id.in_(document_ids))
|
|
.group_by(DocumentByConnectorCredentialPair.id)
|
|
)
|
|
return db_session.execute(stmt).all() # type: ignore
|
|
|
|
|
|
def get_document_cnts_for_cc_pairs(
|
|
db_session: Session, cc_pair_identifiers: list[ConnectorCredentialPairIdentifier]
|
|
) -> Sequence[tuple[int, int, int]]:
|
|
stmt = (
|
|
select(
|
|
DocumentByConnectorCredentialPair.connector_id,
|
|
DocumentByConnectorCredentialPair.credential_id,
|
|
func.count(),
|
|
)
|
|
.where(
|
|
or_(
|
|
*[
|
|
and_(
|
|
DocumentByConnectorCredentialPair.connector_id
|
|
== cc_pair_identifier.connector_id,
|
|
DocumentByConnectorCredentialPair.credential_id
|
|
== cc_pair_identifier.credential_id,
|
|
)
|
|
for cc_pair_identifier in cc_pair_identifiers
|
|
]
|
|
)
|
|
)
|
|
.group_by(
|
|
DocumentByConnectorCredentialPair.connector_id,
|
|
DocumentByConnectorCredentialPair.credential_id,
|
|
)
|
|
)
|
|
|
|
return db_session.execute(stmt).all() # type: ignore
|
|
|
|
|
|
def get_acccess_info_for_documents(
|
|
db_session: Session,
|
|
document_ids: list[str],
|
|
cc_pair_to_delete: ConnectorCredentialPairIdentifier | None = None,
|
|
) -> Sequence[tuple[str, list[UUID | None], bool]]:
|
|
"""Gets back all relevant access info for the given documents. This includes
|
|
the user_ids for cc pairs that the document is associated with + whether any
|
|
of the associated cc pairs are intending to make the document globally public.
|
|
|
|
If `cc_pair_to_delete` is specified, gets the above access info as if that
|
|
pair had been deleted. This is needed since we want to delete from the Vespa
|
|
before deleting from Postgres to ensure that the state of Postgres never "loses"
|
|
documents that still exist in Vespa.
|
|
"""
|
|
stmt = select(
|
|
DocumentByConnectorCredentialPair.id,
|
|
func.array_agg(Credential.user_id).label("user_ids"),
|
|
func.bool_or(ConnectorCredentialPair.is_public).label("public_doc"),
|
|
).where(DocumentByConnectorCredentialPair.id.in_(document_ids))
|
|
|
|
# pretend that the specified cc pair doesn't exist
|
|
if cc_pair_to_delete:
|
|
stmt = stmt.where(
|
|
and_(
|
|
DocumentByConnectorCredentialPair.connector_id
|
|
!= cc_pair_to_delete.connector_id,
|
|
DocumentByConnectorCredentialPair.credential_id
|
|
!= cc_pair_to_delete.credential_id,
|
|
)
|
|
)
|
|
|
|
stmt = (
|
|
stmt.join(
|
|
Credential,
|
|
DocumentByConnectorCredentialPair.credential_id == Credential.id,
|
|
)
|
|
.join(
|
|
ConnectorCredentialPair,
|
|
and_(
|
|
DocumentByConnectorCredentialPair.connector_id
|
|
== ConnectorCredentialPair.connector_id,
|
|
DocumentByConnectorCredentialPair.credential_id
|
|
== ConnectorCredentialPair.credential_id,
|
|
),
|
|
)
|
|
.group_by(DocumentByConnectorCredentialPair.id)
|
|
)
|
|
return db_session.execute(stmt).all() # type: ignore
|
|
|
|
|
|
def upsert_documents(
|
|
db_session: Session, document_metadata_batch: list[DocumentMetadata]
|
|
) -> None:
|
|
"""NOTE: this function is Postgres specific. Not all DBs support the ON CONFLICT clause."""
|
|
seen_documents: dict[str, DocumentMetadata] = {}
|
|
for document_metadata in document_metadata_batch:
|
|
doc_id = document_metadata.document_id
|
|
if doc_id not in seen_documents:
|
|
seen_documents[doc_id] = document_metadata
|
|
|
|
if not seen_documents:
|
|
logger.info("No documents to upsert. Skipping.")
|
|
return
|
|
|
|
insert_stmt = insert(DbDocument).values(
|
|
[
|
|
model_to_dict(
|
|
DbDocument(
|
|
id=doc.document_id,
|
|
boost=DEFAULT_BOOST,
|
|
hidden=False,
|
|
semantic_id=doc.semantic_identifier,
|
|
link=doc.first_link,
|
|
doc_updated_at=doc.doc_updated_at,
|
|
primary_owners=doc.primary_owners,
|
|
secondary_owners=doc.secondary_owners,
|
|
)
|
|
)
|
|
for doc in seen_documents.values()
|
|
]
|
|
)
|
|
# for now, there are no columns to update. If more metadata is added, then this
|
|
# needs to change to an `on_conflict_do_update`
|
|
on_conflict_stmt = insert_stmt.on_conflict_do_nothing()
|
|
db_session.execute(on_conflict_stmt)
|
|
db_session.commit()
|
|
|
|
|
|
def upsert_document_by_connector_credential_pair(
|
|
db_session: Session, document_metadata_batch: list[DocumentMetadata]
|
|
) -> None:
|
|
"""NOTE: this function is Postgres specific. Not all DBs support the ON CONFLICT clause."""
|
|
if not document_metadata_batch:
|
|
logger.info("`document_metadata_batch` is empty. Skipping.")
|
|
return
|
|
|
|
insert_stmt = insert(DocumentByConnectorCredentialPair).values(
|
|
[
|
|
model_to_dict(
|
|
DocumentByConnectorCredentialPair(
|
|
id=document_metadata.document_id,
|
|
connector_id=document_metadata.connector_id,
|
|
credential_id=document_metadata.credential_id,
|
|
)
|
|
)
|
|
for document_metadata in document_metadata_batch
|
|
]
|
|
)
|
|
# for now, there are no columns to update. If more metadata is added, then this
|
|
# needs to change to an `on_conflict_do_update`
|
|
on_conflict_stmt = insert_stmt.on_conflict_do_nothing()
|
|
db_session.execute(on_conflict_stmt)
|
|
db_session.commit()
|
|
|
|
|
|
def upsert_documents_complete(
|
|
db_session: Session,
|
|
document_metadata_batch: list[DocumentMetadata],
|
|
) -> None:
|
|
upsert_documents(db_session, document_metadata_batch)
|
|
upsert_document_by_connector_credential_pair(db_session, document_metadata_batch)
|
|
logger.info(
|
|
f"Upserted {len(document_metadata_batch)} document store entries into DB"
|
|
)
|
|
|
|
|
|
def delete_document_by_connector_credential_pair(
|
|
db_session: Session,
|
|
document_ids: list[str],
|
|
connector_credential_pair_identifier: ConnectorCredentialPairIdentifier
|
|
| None = None,
|
|
) -> None:
|
|
stmt = delete(DocumentByConnectorCredentialPair).where(
|
|
DocumentByConnectorCredentialPair.id.in_(document_ids)
|
|
)
|
|
if connector_credential_pair_identifier:
|
|
stmt = stmt.where(
|
|
and_(
|
|
DocumentByConnectorCredentialPair.connector_id
|
|
== connector_credential_pair_identifier.connector_id,
|
|
DocumentByConnectorCredentialPair.credential_id
|
|
== connector_credential_pair_identifier.credential_id,
|
|
)
|
|
)
|
|
db_session.execute(stmt)
|
|
|
|
|
|
def delete_documents(db_session: Session, document_ids: list[str]) -> None:
|
|
db_session.execute(delete(DbDocument).where(DbDocument.id.in_(document_ids)))
|
|
|
|
|
|
def delete_documents_complete(db_session: Session, document_ids: list[str]) -> None:
|
|
logger.info(f"Deleting {len(document_ids)} documents from the DB")
|
|
delete_document_by_connector_credential_pair(db_session, document_ids)
|
|
delete_document_feedback_for_documents(
|
|
document_ids=document_ids, db_session=db_session
|
|
)
|
|
delete_documents(db_session, document_ids)
|
|
db_session.commit()
|
|
|
|
|
|
def acquire_document_locks(db_session: Session, document_ids: list[str]) -> bool:
|
|
"""Acquire locks for the specified documents. Ideally this shouldn't be
|
|
called with large list of document_ids (an exception could be made if the
|
|
length of holding the lock is very short).
|
|
|
|
Will simply raise an exception if any of the documents are already locked.
|
|
This prevents deadlocks (assuming that the caller passes in all required
|
|
document IDs in a single call).
|
|
"""
|
|
stmt = (
|
|
select(DbDocument)
|
|
.where(DbDocument.id.in_(document_ids))
|
|
.with_for_update(nowait=True)
|
|
)
|
|
# will raise exception if any of the documents are already locked
|
|
db_session.execute(stmt)
|
|
return True
|
|
|
|
|
|
_NUM_LOCK_ATTEMPTS = 10
|
|
_LOCK_RETRY_DELAY = 30
|
|
|
|
|
|
def prepare_to_modify_documents(db_session: Session, document_ids: list[str]) -> None:
|
|
"""Try and acquire locks for the documents to prevent other jobs from
|
|
modifying them at the same time (e.g. avoid race conditions). This should be
|
|
called ahead of any modification to Vespa. Locks should be released by the
|
|
caller as soon as updates are complete by finishing the transaction."""
|
|
lock_acquired = False
|
|
for _ in range(_NUM_LOCK_ATTEMPTS):
|
|
try:
|
|
lock_acquired = acquire_document_locks(
|
|
db_session=db_session, document_ids=document_ids
|
|
)
|
|
except Exception as e:
|
|
logger.info(f"Failed to acquire locks for documents, retrying. Error: {e}")
|
|
time.sleep(_LOCK_RETRY_DELAY)
|
|
|
|
if not lock_acquired:
|
|
raise RuntimeError(
|
|
f"Failed to acquire locks after {_NUM_LOCK_ATTEMPTS} attempts "
|
|
f"for documents: {document_ids}"
|
|
)
|