mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
a67728c163
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
1448 lines
60 KiB
Python
1448 lines
60 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import collections.abc
|
|
import contextlib
|
|
import contextvars
|
|
import dataclasses
|
|
import enum
|
|
import functools
|
|
import types
|
|
import typing
|
|
|
|
import sqlalchemy
|
|
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
|
|
import sqlalchemy.orm as sqlalchemy_orm
|
|
from pgvector.sqlalchemy import Vector
|
|
from sqlalchemy.dialects.postgresql.dml import OnConflictDoNothing as PostgreSQLOnConflictDoNothing
|
|
from sqlalchemy.dialects.postgresql.dml import OnConflictDoUpdate as PostgreSQLOnConflictDoUpdate
|
|
from sqlalchemy.dialects.sqlite.dml import OnConflictDoNothing as SQLiteOnConflictDoNothing
|
|
from sqlalchemy.dialects.sqlite.dml import OnConflictDoUpdate as SQLiteOnConflictDoUpdate
|
|
|
|
|
|
TENANT_SETTING = 'langbot.workspace_uuid'
|
|
ACCOUNT_SETTING = 'langbot.account_uuid'
|
|
API_KEY_HASH_SETTING = 'langbot.api_key_hash'
|
|
INVITATION_HASH_SETTING = 'langbot.invitation_hash'
|
|
INSTANCE_SETTING = 'langbot.instance_uuid'
|
|
IDENTITY_DIGEST_SETTING = 'langbot.identity_digest'
|
|
DIRECTORY_INSTANCE_SETTING = 'langbot.directory_instance_uuid'
|
|
|
|
TENANT_POLICY_NAME = 'langbot_workspace_isolation'
|
|
LOCAL_DIRECTORY_WRITE_POLICY_NAME = 'langbot_workspace_local_directory_write'
|
|
ACCOUNT_DISCOVERY_POLICY_NAME = 'langbot_account_discovery'
|
|
API_KEY_DISCOVERY_POLICY_NAME = 'langbot_api_key_discovery'
|
|
INVITATION_DISCOVERY_POLICY_NAME = 'langbot_invitation_discovery'
|
|
INSTANCE_DISCOVERY_POLICY_NAME = 'langbot_instance_discovery'
|
|
DIRECTORY_PROJECTION_POLICY_NAME = 'langbot_directory_projection'
|
|
|
|
# Keep this contract explicit. A new tenant-owned table must be added to both
|
|
# this runtime list and the corresponding Alembic migration before release.
|
|
TENANT_TABLE_COLUMNS: dict[str, str] = {
|
|
'workspaces': 'uuid',
|
|
'workspace_memberships': 'workspace_uuid',
|
|
'workspace_invitations': 'workspace_uuid',
|
|
'workspace_execution_states': 'workspace_uuid',
|
|
'support_admin_temporary_sessions': 'workspace_uuid',
|
|
'workspace_metadata': 'workspace_uuid',
|
|
'api_keys': 'workspace_uuid',
|
|
'bots': 'workspace_uuid',
|
|
'bot_admins': 'workspace_uuid',
|
|
'binary_storages': 'workspace_uuid',
|
|
'mcp_servers': 'workspace_uuid',
|
|
'model_providers': 'workspace_uuid',
|
|
'llm_models': 'workspace_uuid',
|
|
'embedding_models': 'workspace_uuid',
|
|
'rerank_models': 'workspace_uuid',
|
|
'legacy_pipelines': 'workspace_uuid',
|
|
'pipeline_run_records': 'workspace_uuid',
|
|
'plugin_settings': 'workspace_uuid',
|
|
'knowledge_bases': 'workspace_uuid',
|
|
'knowledge_base_files': 'workspace_uuid',
|
|
'knowledge_base_chunks': 'workspace_uuid',
|
|
'webhooks': 'workspace_uuid',
|
|
'monitoring_messages': 'workspace_uuid',
|
|
'monitoring_llm_calls': 'workspace_uuid',
|
|
'monitoring_tool_calls': 'workspace_uuid',
|
|
'monitoring_sessions': 'workspace_uuid',
|
|
'monitoring_errors': 'workspace_uuid',
|
|
'monitoring_embedding_calls': 'workspace_uuid',
|
|
'monitoring_feedback': 'workspace_uuid',
|
|
# Created by 0013 rather than ORM metadata; it is still part of the same
|
|
# business-database RLS contract and permits no discovery policies.
|
|
'langbot_vectors': 'workspace_uuid',
|
|
}
|
|
|
|
DIRECTORY_PROJECTION_TABLE_COLUMNS: dict[str, str] = {
|
|
'directory_projection_states': 'instance_uuid',
|
|
'directory_projection_inbox': 'instance_uuid',
|
|
}
|
|
|
|
DIRECTORY_PROJECTED_TENANT_TABLES = frozenset(
|
|
{
|
|
'workspaces',
|
|
'workspace_memberships',
|
|
'workspace_execution_states',
|
|
}
|
|
)
|
|
|
|
|
|
class PersistenceScopeKind(enum.StrEnum):
|
|
WORKSPACE = 'workspace'
|
|
ACCOUNT_DISCOVERY = 'account_discovery'
|
|
API_KEY_DISCOVERY = 'api_key_discovery'
|
|
INVITATION_DISCOVERY = 'invitation_discovery'
|
|
INSTANCE_DISCOVERY = 'instance_discovery'
|
|
IDENTITY_DISCOVERY = 'identity_discovery'
|
|
DIRECTORY_PROJECTION = 'directory_projection'
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True, slots=True)
|
|
class PersistenceScope:
|
|
"""A trusted, transaction-local database visibility scope."""
|
|
|
|
kind: PersistenceScopeKind
|
|
settings: tuple[tuple[str, str], ...]
|
|
|
|
@classmethod
|
|
def workspace(cls, workspace_uuid: str) -> PersistenceScope:
|
|
return cls._one(PersistenceScopeKind.WORKSPACE, TENANT_SETTING, workspace_uuid)
|
|
|
|
@classmethod
|
|
def account(cls, account_uuid: str) -> PersistenceScope:
|
|
return cls._one(PersistenceScopeKind.ACCOUNT_DISCOVERY, ACCOUNT_SETTING, account_uuid)
|
|
|
|
@classmethod
|
|
def api_key(cls, key_hash: str) -> PersistenceScope:
|
|
return cls._one(PersistenceScopeKind.API_KEY_DISCOVERY, API_KEY_HASH_SETTING, key_hash)
|
|
|
|
@classmethod
|
|
def invitation(cls, token_hash: str) -> PersistenceScope:
|
|
return cls._one(PersistenceScopeKind.INVITATION_DISCOVERY, INVITATION_HASH_SETTING, token_hash)
|
|
|
|
@classmethod
|
|
def instance(cls, instance_uuid: str) -> PersistenceScope:
|
|
return cls._one(PersistenceScopeKind.INSTANCE_DISCOVERY, INSTANCE_SETTING, instance_uuid)
|
|
|
|
@classmethod
|
|
def identity(cls, identity_digest: str) -> PersistenceScope:
|
|
return cls._one(PersistenceScopeKind.IDENTITY_DISCOVERY, IDENTITY_DIGEST_SETTING, identity_digest)
|
|
|
|
@classmethod
|
|
def directory(cls, instance_uuid: str) -> PersistenceScope:
|
|
return cls._one(
|
|
PersistenceScopeKind.DIRECTORY_PROJECTION,
|
|
DIRECTORY_INSTANCE_SETTING,
|
|
instance_uuid,
|
|
)
|
|
|
|
@classmethod
|
|
def _one(cls, kind: PersistenceScopeKind, setting: str, value: str) -> PersistenceScope:
|
|
return cls(kind, ((setting, cls._normalize(value, kind.value)),))
|
|
|
|
@staticmethod
|
|
def _normalize(value: str, label: str) -> str:
|
|
if not isinstance(value, str):
|
|
raise TypeError(f'{label} must be a string')
|
|
normalized = value.strip()
|
|
if not normalized:
|
|
raise ValueError(f'{label} must not be empty')
|
|
if len(normalized) > 512:
|
|
raise ValueError(f'{label} exceeds the database scope limit')
|
|
return normalized
|
|
|
|
|
|
class TenantScopeRequiredError(RuntimeError):
|
|
"""Raised when Cloud business data is accessed without a trusted scope."""
|
|
|
|
|
|
class CrossScopeTransactionError(RuntimeError):
|
|
"""Raised when code tries to change scope inside an active transaction."""
|
|
|
|
|
|
class TransactionRollbackOnlyError(RuntimeError):
|
|
"""Raised when a caught failure made the outer transaction unsafe to commit."""
|
|
|
|
|
|
class ScopedSessionTransactionError(RuntimeError):
|
|
"""Raised when callers try to escape a UoW-owned transaction boundary."""
|
|
|
|
|
|
@dataclasses.dataclass(slots=True)
|
|
class ActiveScopedTransaction:
|
|
scope: PersistenceScope
|
|
session: sqlalchemy_asyncio.AsyncSession
|
|
engine: sqlalchemy_asyncio.AsyncEngine
|
|
owner_task: asyncio.Task[typing.Any] | None
|
|
depth: int = 1
|
|
rollback_only: bool = False
|
|
rollback_only_cause: BaseException | None = None
|
|
after_commit_waiters: list[asyncio.Future[None]] = dataclasses.field(default_factory=list)
|
|
|
|
def mark_rollback_only(self, cause: BaseException | None = None) -> None:
|
|
"""Remember that this transaction must never release commit-gated work."""
|
|
|
|
self.rollback_only = True
|
|
if self.rollback_only_cause is None:
|
|
self.rollback_only_cause = cause
|
|
|
|
|
|
_UOW_SESSION_CONTROL_CAPABILITY = object()
|
|
|
|
|
|
@dataclasses.dataclass(slots=True)
|
|
class _ScopedSessionGuardState:
|
|
owner_task: asyncio.Task[typing.Any] | None
|
|
transaction_state: ActiveScopedTransaction | None = None
|
|
active: bool = True
|
|
session_events_blocked: bool = False
|
|
|
|
|
|
_SYNC_PROXY_CAPABILITY: contextvars.ContextVar[_ScopedSessionGuardState | None] = contextvars.ContextVar(
|
|
'langbot_sync_session_proxy_capability',
|
|
default=None,
|
|
)
|
|
|
|
_ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
|
|
'coalesce': sqlalchemy.sql.functions.coalesce,
|
|
'count': sqlalchemy.sql.functions.count,
|
|
'now': sqlalchemy.sql.functions.now,
|
|
'sum': sqlalchemy.sql.functions.sum,
|
|
}
|
|
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif'})
|
|
_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
|
|
_ALLOWED_SCOPED_STATEMENT_TYPES = (
|
|
sqlalchemy.sql.dml.UpdateBase,
|
|
sqlalchemy.sql.selectable.SelectBase,
|
|
)
|
|
_ALLOWED_SCOPED_POST_VALUES_TYPES = (
|
|
PostgreSQLOnConflictDoNothing,
|
|
PostgreSQLOnConflictDoUpdate,
|
|
SQLiteOnConflictDoNothing,
|
|
SQLiteOnConflictDoUpdate,
|
|
)
|
|
|
|
|
|
def _collect_embedded_clause_elements(value: typing.Any) -> list[sqlalchemy.sql.elements.ClauseElement]:
|
|
"""Find executable expressions in SQLAlchemy containers omitted by visitors."""
|
|
|
|
if isinstance(value, sqlalchemy.sql.elements.ClauseElement):
|
|
return [value]
|
|
if isinstance(value, collections.abc.Mapping):
|
|
elements: list[sqlalchemy.sql.elements.ClauseElement] = []
|
|
for key, item in value.items():
|
|
elements.extend(_collect_embedded_clause_elements(key))
|
|
elements.extend(_collect_embedded_clause_elements(item))
|
|
return elements
|
|
if isinstance(value, (list, tuple)):
|
|
elements = []
|
|
for item in value:
|
|
elements.extend(_collect_embedded_clause_elements(item))
|
|
return elements
|
|
clause_factory = getattr(value, '__clause_element__', None)
|
|
if callable(clause_factory):
|
|
clause_element = clause_factory()
|
|
if isinstance(clause_element, sqlalchemy.sql.elements.ClauseElement):
|
|
return [clause_element]
|
|
return []
|
|
|
|
|
|
def _validate_opaque_identifier(
|
|
value: typing.Any,
|
|
*,
|
|
label: str,
|
|
) -> list[sqlalchemy.sql.elements.ClauseElement]:
|
|
"""Validate identifiers stored outside SQLAlchemy's normal AST traversal."""
|
|
|
|
if isinstance(value, sqlalchemy.sql.elements.ClauseElement):
|
|
return [value]
|
|
if not isinstance(value, str):
|
|
raise ScopedSessionTransactionError(f'TenantUnitOfWork does not allow an unknown {label}')
|
|
if isinstance(value, sqlalchemy.sql.elements.quoted_name) and value.quote is False:
|
|
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow forced-unquoted SQL identifiers')
|
|
if not value or value[0].isdigit() or any(not (character.isalnum() or character == '_') for character in value):
|
|
raise ScopedSessionTransactionError(f'TenantUnitOfWork does not allow an unsafe {label}')
|
|
return []
|
|
|
|
|
|
def _validate_scoped_sql_type(
|
|
sql_type: typing.Any,
|
|
*,
|
|
seen: set[int] | None = None,
|
|
) -> None:
|
|
"""Reject caller-defined SQL compilers hidden behind otherwise safe nodes."""
|
|
|
|
if not isinstance(sql_type, sqlalchemy.types.TypeEngine):
|
|
return
|
|
if seen is None:
|
|
seen = set()
|
|
identity = id(sql_type)
|
|
if identity in seen:
|
|
return
|
|
seen.add(identity)
|
|
|
|
if type(sql_type) is Vector:
|
|
return
|
|
if not type(sql_type).__module__.startswith('sqlalchemy.'):
|
|
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow custom SQL types in public statements')
|
|
|
|
for identifier_attribute in ('name', 'schema', 'collation'):
|
|
identifier = getattr(sql_type, identifier_attribute, None)
|
|
if isinstance(identifier, sqlalchemy.sql.elements.quoted_name) and identifier.quote is False:
|
|
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow forced-unquoted SQL type identifiers')
|
|
|
|
nested_types: list[typing.Any] = [
|
|
getattr(sql_type, 'item_type', None),
|
|
getattr(sql_type, 'impl', None),
|
|
]
|
|
nested_types.extend(getattr(sql_type, 'types', ()) or ())
|
|
for nested_type in nested_types:
|
|
if nested_type is not sql_type:
|
|
_validate_scoped_sql_type(nested_type, seen=seen)
|
|
|
|
|
|
def _collect_multi_value_elements(
|
|
multi_values: typing.Any,
|
|
) -> list[sqlalchemy.sql.elements.ClauseElement]:
|
|
"""Validate batch INSERT rows which SQLAlchemy omits from AST visitors."""
|
|
|
|
children: list[sqlalchemy.sql.elements.ClauseElement] = []
|
|
for value_group in multi_values or ():
|
|
if not isinstance(value_group, (list, tuple)):
|
|
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow an unknown batch INSERT shape')
|
|
for row in value_group:
|
|
if isinstance(row, collections.abc.Mapping):
|
|
for key, value in row.items():
|
|
children.extend(_validate_opaque_identifier(key, label='batch INSERT column'))
|
|
children.extend(_collect_embedded_clause_elements(value))
|
|
elif isinstance(row, (list, tuple)):
|
|
children.extend(_collect_embedded_clause_elements(row))
|
|
else:
|
|
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow an unknown batch INSERT row')
|
|
return children
|
|
|
|
|
|
def _iter_scoped_statement_elements(
|
|
statement: sqlalchemy.sql.elements.ClauseElement,
|
|
) -> typing.Iterator[sqlalchemy.sql.elements.ClauseElement]:
|
|
"""Walk the SQL AST, including dialect clauses SQLAlchemy omits from visitors.
|
|
|
|
PostgreSQL and SQLite ``ON CONFLICT`` clauses currently expose no children
|
|
through ``get_children()``. Their conflict targets and update expressions
|
|
still form executable SQL, so this walker adds those containers explicitly
|
|
and rejects any future, unknown post-values clause by default.
|
|
"""
|
|
|
|
pending: list[sqlalchemy.sql.elements.ClauseElement] = [statement]
|
|
seen: set[int] = set()
|
|
while pending:
|
|
element = pending.pop()
|
|
identity = id(element)
|
|
if identity in seen:
|
|
continue
|
|
seen.add(identity)
|
|
yield element
|
|
|
|
children = [
|
|
child for child in element.get_children() if isinstance(child, sqlalchemy.sql.elements.ClauseElement)
|
|
]
|
|
post_values_clause = getattr(element, '_post_values_clause', None)
|
|
if post_values_clause is not None:
|
|
if not isinstance(post_values_clause, _ALLOWED_SCOPED_POST_VALUES_TYPES):
|
|
raise ScopedSessionTransactionError(
|
|
'TenantUnitOfWork does not allow an unknown dialect post-values SQL clause'
|
|
)
|
|
children.append(post_values_clause)
|
|
|
|
children.extend(_collect_multi_value_elements(getattr(element, '_multi_values', ())))
|
|
|
|
if isinstance(element, _ALLOWED_SCOPED_POST_VALUES_TYPES):
|
|
if getattr(element, 'constraint_target', None) is not None:
|
|
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow named ON CONFLICT constraints')
|
|
for target in getattr(element, 'inferred_target_elements', ()) or ():
|
|
children.extend(_validate_opaque_identifier(target, label='ON CONFLICT target'))
|
|
children.extend(_collect_embedded_clause_elements(getattr(element, 'inferred_target_whereclause', None)))
|
|
children.extend(_collect_embedded_clause_elements(getattr(element, 'update_whereclause', None)))
|
|
for update_pair in getattr(element, 'update_values_to_set', ()):
|
|
if not isinstance(update_pair, (list, tuple)) or len(update_pair) != 2:
|
|
raise ScopedSessionTransactionError(
|
|
'TenantUnitOfWork does not allow an unknown ON CONFLICT update shape'
|
|
)
|
|
key, value = update_pair
|
|
children.extend(_validate_opaque_identifier(key, label='ON CONFLICT update column'))
|
|
children.extend(_collect_embedded_clause_elements(value))
|
|
|
|
pending.extend(children)
|
|
|
|
|
|
def _validate_scoped_statement_call(args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]) -> None:
|
|
"""Admit only structured SQLAlchemy statements with a small safe vocabulary.
|
|
|
|
A textual denylist cannot be complete on PostgreSQL: ordinary built-in
|
|
functions can execute SQL supplied in string arguments, while statement
|
|
prefixes, suffixes, hints, and custom operators can inject syntax without
|
|
appearing as a top-level ``TextClause``. Tenant code therefore uses
|
|
SQLAlchemy's structured query/DML AST exclusively. Raw fragments and
|
|
unknown functions/operators fail closed before compilation.
|
|
"""
|
|
|
|
statement = args[0] if args else kwargs.get('statement')
|
|
if statement is None:
|
|
return
|
|
if kwargs.get('execution_options'):
|
|
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow public SQL execution options')
|
|
if kwargs.get('bind_arguments'):
|
|
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow foreign database bind arguments')
|
|
if not isinstance(statement, _ALLOWED_SCOPED_STATEMENT_TYPES):
|
|
raise ScopedSessionTransactionError(
|
|
'TenantUnitOfWork public SQL must use a structured SQLAlchemy query, values, or DML statement'
|
|
)
|
|
|
|
for element in _iter_scoped_statement_elements(statement):
|
|
if not type(element).__module__.startswith('sqlalchemy.'):
|
|
raise ScopedSessionTransactionError(
|
|
'TenantUnitOfWork does not allow custom SQL AST nodes in public statements'
|
|
)
|
|
|
|
_validate_scoped_sql_type(getattr(element, 'type', None))
|
|
|
|
if isinstance(element, sqlalchemy.sql.selectable.Values):
|
|
raise ScopedSessionTransactionError(
|
|
'TenantUnitOfWork does not allow SQL VALUES constructs in public statements'
|
|
)
|
|
|
|
if isinstance(element, sqlalchemy.sql.dml.Insert) and getattr(element, 'select', None) is not None:
|
|
raise ScopedSessionTransactionError(
|
|
'TenantUnitOfWork does not allow INSERT FROM SELECT in public statements'
|
|
)
|
|
|
|
for identifier_attribute in ('name', 'schema', 'collation'):
|
|
identifier = getattr(element, identifier_attribute, None)
|
|
if isinstance(identifier, sqlalchemy.sql.elements.quoted_name) and identifier.quote is False:
|
|
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow forced-unquoted SQL identifiers')
|
|
|
|
if any(
|
|
getattr(element, attribute_name, None)
|
|
for attribute_name in (
|
|
'_prefixes',
|
|
'_suffixes',
|
|
'_hints',
|
|
'_statement_hints',
|
|
'_execution_options',
|
|
'_with_options',
|
|
)
|
|
):
|
|
raise ScopedSessionTransactionError(
|
|
'TenantUnitOfWork does not allow textual statement modifiers or execution options'
|
|
)
|
|
|
|
if (
|
|
isinstance(element, sqlalchemy.sql.elements.TextClause)
|
|
or getattr(
|
|
element,
|
|
'__visit_name__',
|
|
None,
|
|
)
|
|
== 'textual_label_reference'
|
|
):
|
|
raise ScopedSessionTransactionError(
|
|
'TenantUnitOfWork does not allow raw or textual SQL fragments in public statements'
|
|
)
|
|
|
|
if isinstance(element, sqlalchemy.sql.elements.ColumnClause) and element.is_literal and element.name != '*':
|
|
raise ScopedSessionTransactionError(
|
|
'TenantUnitOfWork does not allow literal SQL columns in public statements'
|
|
)
|
|
|
|
if isinstance(element, sqlalchemy.sql.elements.Extract):
|
|
raise ScopedSessionTransactionError(
|
|
'TenantUnitOfWork does not allow SQL EXTRACT fields in public statements'
|
|
)
|
|
|
|
if isinstance(element, sqlalchemy.sql.elements.BindParameter) and element.literal_execute:
|
|
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow literal-execute SQL parameters')
|
|
|
|
if isinstance(element, sqlalchemy.sql.elements.Cast) and type(element.type) is not Vector:
|
|
raise ScopedSessionTransactionError(
|
|
'TenantUnitOfWork only allows the trusted pgvector cast used by tenant vector search'
|
|
)
|
|
|
|
if isinstance(element, sqlalchemy.sql.functions.FunctionElement):
|
|
function_name = str(getattr(element, 'name', '')).casefold()
|
|
package_names = tuple(getattr(element, 'packagenames', ()))
|
|
generic_function_allowed = (
|
|
type(element) is sqlalchemy.sql.functions.Function
|
|
and function_name in _ALLOWED_SCOPED_GENERIC_FUNCTIONS
|
|
)
|
|
builtin_function_allowed = _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES.get(function_name) is type(element)
|
|
if package_names or not (generic_function_allowed or builtin_function_allowed):
|
|
raise ScopedSessionTransactionError(
|
|
f'TenantUnitOfWork does not allow SQL function {function_name or "<unknown>"!r}'
|
|
)
|
|
|
|
for operator_attribute in ('operator', 'modifier'):
|
|
operator = getattr(element, operator_attribute, None)
|
|
if not isinstance(operator, sqlalchemy.sql.operators.custom_op):
|
|
continue
|
|
operator_name = str(operator.opstring)
|
|
if not (
|
|
isinstance(element, sqlalchemy.sql.elements.BinaryExpression)
|
|
and operator_attribute == 'operator'
|
|
and operator_name in _ALLOWED_SCOPED_CUSTOM_OPERATORS
|
|
):
|
|
raise ScopedSessionTransactionError(
|
|
f'TenantUnitOfWork does not allow custom SQL operator {operator_name!r}'
|
|
)
|
|
|
|
|
|
class _NoopSessionEventCollection:
|
|
"""Empty SQLAlchemy SessionEvents collection used after fail-closed detection."""
|
|
|
|
def __bool__(self) -> bool:
|
|
return False
|
|
|
|
def __iter__(self) -> typing.Iterator[typing.Any]:
|
|
return iter(())
|
|
|
|
def __call__(self, *args: typing.Any, **kwargs: typing.Any) -> None:
|
|
del args, kwargs
|
|
|
|
|
|
class _NoopSessionEventsDispatch:
|
|
"""Prevent a rejected SessionEvents listener from firing during rollback cleanup."""
|
|
|
|
_event_names: tuple[str, ...] = ()
|
|
|
|
def __getattr__(self, name: str) -> _NoopSessionEventCollection:
|
|
del name
|
|
return _NOOP_SESSION_EVENT_COLLECTION
|
|
|
|
|
|
_NOOP_SESSION_EVENT_COLLECTION = _NoopSessionEventCollection()
|
|
_NOOP_SESSION_EVENTS_DISPATCH = _NoopSessionEventsDispatch()
|
|
|
|
|
|
class TenantScopedSyncSession(sqlalchemy_orm.Session):
|
|
"""Synchronous proxy usable only from its owning AsyncSession operation."""
|
|
|
|
def __init__(
|
|
self,
|
|
bind: sqlalchemy.engine.Engine | sqlalchemy.engine.Connection | None = None,
|
|
*,
|
|
binds: dict[typing.Any, typing.Any] | None = None,
|
|
langbot_guard_state: _ScopedSessionGuardState,
|
|
**kwargs: typing.Any,
|
|
) -> None:
|
|
if binds:
|
|
raise ScopedSessionTransactionError('Tenant-scoped Sessions cannot use multiple database binds')
|
|
object.__setattr__(self, '_langbot_guard_state', langbot_guard_state)
|
|
object.__setattr__(self, '_langbot_expected_bind', bind)
|
|
object.__setattr__(self, '_langbot_initializing', True)
|
|
try:
|
|
super().__init__(bind=bind, binds=None, **kwargs)
|
|
finally:
|
|
object.__setattr__(self, '_langbot_initializing', False)
|
|
|
|
def __getattribute__(self, name: str) -> typing.Any:
|
|
if name.startswith('_'):
|
|
return super().__getattribute__(name)
|
|
self._require_proxy_capability()
|
|
guard = object.__getattribute__(self, '_langbot_guard_state')
|
|
if name == 'dispatch' and guard.session_events_blocked:
|
|
return _NOOP_SESSION_EVENTS_DISPATCH
|
|
attribute = super().__getattribute__(name)
|
|
if name == 'dispatch' and not object.__getattribute__(self, '_langbot_initializing'):
|
|
event_name = next(
|
|
(candidate for candidate in attribute._event_names if bool(getattr(attribute, candidate))),
|
|
None,
|
|
)
|
|
if event_name is not None:
|
|
guard.session_events_blocked = True
|
|
self._reject_proxy_escape(f'ORM Session event listener {event_name}')
|
|
if isinstance(attribute, types.MethodType):
|
|
return self._guard_method_call(attribute)
|
|
return attribute
|
|
|
|
def __setattr__(self, name: str, value: typing.Any) -> None:
|
|
if name.startswith('_') or object.__getattribute__(self, '_langbot_initializing'):
|
|
super().__setattr__(name, value)
|
|
return
|
|
self._require_proxy_capability()
|
|
super().__setattr__(name, value)
|
|
|
|
def __delattr__(self, name: str) -> None:
|
|
if name.startswith('_') or object.__getattribute__(self, '_langbot_initializing'):
|
|
super().__delattr__(name)
|
|
return
|
|
self._require_proxy_capability()
|
|
super().__delattr__(name)
|
|
|
|
def get_bind(
|
|
self,
|
|
mapper: typing.Any = None,
|
|
*,
|
|
clause: typing.Any = None,
|
|
bind: typing.Any = None,
|
|
**kwargs: typing.Any,
|
|
) -> sqlalchemy.engine.Engine | sqlalchemy.engine.Connection:
|
|
self._require_proxy_capability()
|
|
expected_bind = object.__getattribute__(self, '_langbot_expected_bind')
|
|
if bind is not None and bind is not expected_bind:
|
|
self._reject_proxy_escape('foreign database bind')
|
|
resolved = super().get_bind(mapper, clause=clause, bind=bind, **kwargs)
|
|
if resolved is not expected_bind:
|
|
self._reject_proxy_escape('foreign database bind')
|
|
return resolved
|
|
|
|
def flush(self, objects: typing.Sequence[typing.Any] | None = None) -> None:
|
|
"""Reject caller-supplied SQL expressions hidden in ORM state."""
|
|
|
|
self._require_proxy_capability()
|
|
for instance in self.new.union(self.dirty):
|
|
state = sqlalchemy.inspect(instance)
|
|
for column_property in state.mapper.column_attrs:
|
|
for value in state.attrs[column_property.key].history.added:
|
|
if isinstance(value, sqlalchemy.sql.elements.ClauseElement):
|
|
self._reject_proxy_escape('ORM SQL expression attribute value')
|
|
clause_factory = getattr(value, '__clause_element__', None)
|
|
if callable(clause_factory) and isinstance(
|
|
clause_factory(),
|
|
sqlalchemy.sql.elements.ClauseElement,
|
|
):
|
|
self._reject_proxy_escape('ORM SQL expression attribute value')
|
|
super().flush(objects)
|
|
|
|
def _guard_method_call(self, method: types.MethodType) -> typing.Callable[..., typing.Any]:
|
|
@functools.wraps(method)
|
|
def guarded(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
|
|
self._require_proxy_capability()
|
|
return method(*args, **kwargs)
|
|
|
|
return guarded
|
|
|
|
def _require_proxy_capability(self) -> None:
|
|
if object.__getattribute__(self, '_langbot_initializing'):
|
|
return
|
|
guard = object.__getattribute__(self, '_langbot_guard_state')
|
|
if not guard.active:
|
|
raise ScopedSessionTransactionError('TenantUnitOfWork scoped Session is no longer active')
|
|
try:
|
|
current_task = asyncio.current_task()
|
|
except RuntimeError:
|
|
current_task = None
|
|
if guard.owner_task is not current_task:
|
|
raise CrossScopeTransactionError(
|
|
'Scoped database sessions cannot be inherited by child tasks; open an explicit task scope'
|
|
)
|
|
if _SYNC_PROXY_CAPABILITY.get() is not guard:
|
|
self._reject_proxy_escape('synchronous Session access')
|
|
|
|
def _reject_proxy_escape(self, operation: str) -> typing.NoReturn:
|
|
guard = object.__getattribute__(self, '_langbot_guard_state')
|
|
if guard.transaction_state is not None:
|
|
error = ScopedSessionTransactionError(
|
|
f'TenantUnitOfWork owns AsyncSession lifecycle; direct {operation} is not allowed'
|
|
)
|
|
guard.transaction_state.mark_rollback_only(error)
|
|
raise error
|
|
raise ScopedSessionTransactionError(
|
|
f'TenantUnitOfWork owns AsyncSession lifecycle; direct {operation} is not allowed'
|
|
)
|
|
|
|
|
|
class TenantScopedAsyncSession(sqlalchemy_asyncio.AsyncSession):
|
|
"""An AsyncSession whose task and transaction lifecycle are UoW-owned.
|
|
|
|
Normal ORM operations remain available to the task which opened the UoW.
|
|
Transaction-control and connection-escape APIs are deliberately withheld:
|
|
committing, rolling back, or closing the raw Session would otherwise let a
|
|
nested caller defeat the outer unit of work. A blanket public-attribute
|
|
owner check also covers a Session captured in the parent task and later
|
|
passed directly to a child task.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
bind: sqlalchemy_asyncio.AsyncEngine,
|
|
*,
|
|
owner_task: asyncio.Task[typing.Any] | None,
|
|
**kwargs: typing.Any,
|
|
) -> None:
|
|
guard = _ScopedSessionGuardState(owner_task=owner_task)
|
|
object.__setattr__(self, '_langbot_guard_state', guard)
|
|
object.__setattr__(self, '_langbot_bind', None)
|
|
object.__setattr__(self, '_langbot_internal_access_depth', 0)
|
|
object.__setattr__(self, '_langbot_sync_capability_tokens', [])
|
|
object.__setattr__(self, '_langbot_initializing', True)
|
|
capability_token = _SYNC_PROXY_CAPABILITY.set(guard)
|
|
try:
|
|
super().__init__(
|
|
bind,
|
|
sync_session_class=TenantScopedSyncSession,
|
|
langbot_guard_state=guard,
|
|
**kwargs,
|
|
)
|
|
finally:
|
|
_SYNC_PROXY_CAPABILITY.reset(capability_token)
|
|
object.__setattr__(self, '_langbot_initializing', False)
|
|
|
|
def __getattribute__(self, name: str) -> typing.Any:
|
|
# ContextVars are copied into asyncio child tasks, and callers can also
|
|
# copy the Session object itself. Guard the whole supported public
|
|
# surface rather than trying to enumerate every current/future ORM I/O
|
|
# method. Private attributes remain an internal implementation detail.
|
|
if name.startswith('_'):
|
|
return super().__getattribute__(name)
|
|
|
|
self._require_owner_task()
|
|
if (
|
|
name in {'sync_session', 'binds', 'object_session'}
|
|
and object.__getattribute__(self, '_langbot_internal_access_depth') == 0
|
|
):
|
|
self._reject_transaction_escape(f'{name} access')
|
|
self._enter_internal_access()
|
|
try:
|
|
attribute = super().__getattribute__(name)
|
|
finally:
|
|
self._exit_internal_access()
|
|
if isinstance(attribute, types.MethodType):
|
|
return self._guard_method_call(attribute)
|
|
return attribute
|
|
|
|
def __setattr__(self, name: str, value: typing.Any) -> None:
|
|
if name.startswith('_'):
|
|
super().__setattr__(name, value)
|
|
return
|
|
self._require_owner_task()
|
|
if not object.__getattribute__(self, '_langbot_initializing') and name in {'bind', 'binds', 'sync_session'}:
|
|
self._reject_transaction_escape(f'{name} replacement')
|
|
super().__setattr__(name, value)
|
|
|
|
def __delattr__(self, name: str) -> None:
|
|
if name.startswith('_'):
|
|
super().__delattr__(name)
|
|
return
|
|
self._require_owner_task()
|
|
if name in {'bind', 'binds', 'sync_session'}:
|
|
self._reject_transaction_escape(f'{name} deletion')
|
|
super().__delattr__(name)
|
|
|
|
@property
|
|
def bind(self) -> typing.NoReturn:
|
|
self._reject_transaction_escape('bind access')
|
|
|
|
@bind.setter
|
|
def bind(self, value: sqlalchemy_asyncio.AsyncEngine) -> None:
|
|
existing = object.__getattribute__(self, '_langbot_bind')
|
|
if existing is not None:
|
|
self._reject_transaction_escape('bind replacement')
|
|
object.__setattr__(self, '_langbot_bind', value)
|
|
|
|
@property
|
|
def no_autoflush(self) -> typing.ContextManager[None]:
|
|
"""Preserve SQLAlchemy's helper without exposing the synchronous Session."""
|
|
|
|
@contextlib.contextmanager
|
|
def boundary() -> typing.Iterator[None]:
|
|
self._require_owner_task()
|
|
sync_session = object.__getattribute__(self, '_proxied')
|
|
previous = object.__getattribute__(sync_session, 'autoflush')
|
|
object.__setattr__(sync_session, 'autoflush', False)
|
|
try:
|
|
yield
|
|
finally:
|
|
self._require_owner_task()
|
|
object.__setattr__(sync_session, 'autoflush', previous)
|
|
|
|
return boundary()
|
|
|
|
def _bind_transaction_state(self, capability: object, state: ActiveScopedTransaction) -> None:
|
|
self._require_control_capability(capability, 'transaction-state binding')
|
|
self._require_owner_task()
|
|
object.__getattribute__(self, '_langbot_guard_state').transaction_state = state
|
|
|
|
def begin(self) -> typing.NoReturn:
|
|
self._reject_transaction_escape('begin')
|
|
|
|
def begin_nested(self) -> typing.NoReturn:
|
|
self._reject_transaction_escape('begin_nested')
|
|
|
|
async def commit(self) -> typing.NoReturn:
|
|
self._reject_transaction_escape('commit')
|
|
|
|
async def rollback(self) -> typing.NoReturn:
|
|
self._reject_transaction_escape('rollback')
|
|
|
|
async def close(self) -> typing.NoReturn:
|
|
self._reject_transaction_escape('close')
|
|
|
|
async def aclose(self) -> typing.NoReturn:
|
|
self._reject_transaction_escape('aclose')
|
|
|
|
async def reset(self) -> typing.NoReturn:
|
|
self._reject_transaction_escape('reset')
|
|
|
|
async def invalidate(self) -> typing.NoReturn:
|
|
self._reject_transaction_escape('invalidate')
|
|
|
|
async def close_all(self) -> typing.NoReturn:
|
|
self._reject_transaction_escape('close_all')
|
|
|
|
async def connection(self, *args: typing.Any, **kwargs: typing.Any) -> typing.NoReturn:
|
|
del args, kwargs
|
|
self._reject_transaction_escape('connection')
|
|
|
|
def get_bind(self, *args: typing.Any, **kwargs: typing.Any) -> typing.NoReturn:
|
|
del args, kwargs
|
|
self._reject_transaction_escape('get_bind')
|
|
|
|
def get_transaction(self) -> typing.NoReturn:
|
|
self._reject_transaction_escape('get_transaction')
|
|
|
|
def get_nested_transaction(self) -> typing.NoReturn:
|
|
self._reject_transaction_escape('get_nested_transaction')
|
|
|
|
async def run_sync(self, *args: typing.Any, **kwargs: typing.Any) -> typing.NoReturn:
|
|
del args, kwargs
|
|
self._reject_transaction_escape('run_sync')
|
|
|
|
async def __aenter__(self) -> typing.NoReturn:
|
|
self._reject_transaction_escape('context-manager entry')
|
|
|
|
async def __aexit__(self, *args: typing.Any, **kwargs: typing.Any) -> typing.NoReturn:
|
|
del args, kwargs
|
|
self._reject_transaction_escape('context-manager exit')
|
|
|
|
async def _start_owned_transaction(self, capability: object) -> sqlalchemy_asyncio.AsyncSessionTransaction:
|
|
"""Start the one root transaction; callable only by TenantUnitOfWork."""
|
|
|
|
self._require_control_capability(capability, 'owned transaction start')
|
|
self._require_owner_task()
|
|
self._enter_internal_access()
|
|
try:
|
|
transaction = super().begin()
|
|
await transaction
|
|
return transaction
|
|
finally:
|
|
self._exit_internal_access()
|
|
|
|
async def _close_owned_session(self, capability: object) -> None:
|
|
"""Close the Session after its root transaction has been finalized."""
|
|
|
|
self._require_control_capability(capability, 'owned Session close')
|
|
self._require_owner_task()
|
|
self._enter_internal_access()
|
|
try:
|
|
await super().close()
|
|
finally:
|
|
try:
|
|
self._exit_internal_access()
|
|
finally:
|
|
guard = object.__getattribute__(self, '_langbot_guard_state')
|
|
guard.active = False
|
|
guard.transaction_state = None
|
|
guard.owner_task = None
|
|
|
|
async def _commit_owned_transaction(
|
|
self,
|
|
capability: object,
|
|
transaction: sqlalchemy_asyncio.AsyncSessionTransaction,
|
|
) -> None:
|
|
self._require_control_capability(capability, 'owned transaction commit')
|
|
self._require_owner_task()
|
|
self._enter_internal_access()
|
|
try:
|
|
await transaction.commit()
|
|
finally:
|
|
self._exit_internal_access()
|
|
|
|
async def _rollback_owned_transaction(
|
|
self,
|
|
capability: object,
|
|
transaction: sqlalchemy_asyncio.AsyncSessionTransaction,
|
|
) -> None:
|
|
self._require_control_capability(capability, 'owned transaction rollback')
|
|
self._require_owner_task()
|
|
self._enter_internal_access()
|
|
try:
|
|
await transaction.rollback()
|
|
finally:
|
|
self._exit_internal_access()
|
|
|
|
async def _apply_owned_scope_setting(
|
|
self,
|
|
capability: object,
|
|
setting_name: str,
|
|
setting_value: str,
|
|
) -> None:
|
|
self._require_control_capability(capability, 'tenant scope configuration')
|
|
if setting_name not in {
|
|
TENANT_SETTING,
|
|
ACCOUNT_SETTING,
|
|
API_KEY_HASH_SETTING,
|
|
INVITATION_HASH_SETTING,
|
|
INSTANCE_SETTING,
|
|
IDENTITY_DIGEST_SETTING,
|
|
DIRECTORY_INSTANCE_SETTING,
|
|
}:
|
|
self._reject_transaction_escape('unknown tenant scope configuration')
|
|
self._require_owner_task()
|
|
self._enter_internal_access()
|
|
try:
|
|
await super().execute(
|
|
sqlalchemy.text('SELECT set_config(:setting_name, :setting_value, true)'),
|
|
{'setting_name': setting_name, 'setting_value': setting_value},
|
|
)
|
|
finally:
|
|
self._exit_internal_access()
|
|
|
|
async def execute_on_transaction_connection(
|
|
self,
|
|
*args: typing.Any,
|
|
**kwargs: typing.Any,
|
|
) -> sqlalchemy.engine.cursor.CursorResult[typing.Any]:
|
|
"""Execute Core SQL without exposing the transaction-bound Connection."""
|
|
|
|
self._require_owner_task()
|
|
self._validate_statement_or_reject(args, kwargs)
|
|
self._enter_internal_access()
|
|
try:
|
|
await super().flush()
|
|
connection = await super().connection()
|
|
return await connection.execute(*args, **kwargs)
|
|
finally:
|
|
self._exit_internal_access()
|
|
|
|
async def execute(self, *args: typing.Any, **kwargs: typing.Any) -> sqlalchemy.engine.Result[typing.Any]:
|
|
self._validate_statement_or_reject(args, kwargs)
|
|
return await super().execute(*args, **kwargs)
|
|
|
|
async def get(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Any:
|
|
self._reject_nonempty_orm_options(
|
|
'get',
|
|
kwargs,
|
|
{'options', 'with_for_update', 'identity_token', 'execution_options', 'bind_arguments'},
|
|
)
|
|
return await super().get(*args, **kwargs)
|
|
|
|
async def get_one(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Any:
|
|
self._reject_nonempty_orm_options(
|
|
'get_one',
|
|
kwargs,
|
|
{'options', 'with_for_update', 'identity_token', 'execution_options', 'bind_arguments'},
|
|
)
|
|
return await super().get_one(*args, **kwargs)
|
|
|
|
async def refresh(self, *args: typing.Any, **kwargs: typing.Any) -> None:
|
|
self._reject_nonempty_orm_options('refresh', kwargs, {'with_for_update'})
|
|
await super().refresh(*args, **kwargs)
|
|
|
|
async def merge(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Any:
|
|
self._reject_nonempty_orm_options('merge', kwargs, {'options'})
|
|
return await super().merge(*args, **kwargs)
|
|
|
|
async def scalar(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Any:
|
|
self._validate_statement_or_reject(args, kwargs)
|
|
return await super().scalar(*args, **kwargs)
|
|
|
|
async def scalars(self, *args: typing.Any, **kwargs: typing.Any) -> sqlalchemy.engine.ScalarResult[typing.Any]:
|
|
self._validate_statement_or_reject(args, kwargs)
|
|
return await super().scalars(*args, **kwargs)
|
|
|
|
async def stream(self, *args: typing.Any, **kwargs: typing.Any) -> sqlalchemy_asyncio.AsyncResult[typing.Any]:
|
|
del args, kwargs
|
|
self._reject_transaction_escape('stream; live database results cannot outlive the task-owned UoW operation')
|
|
|
|
async def stream_scalars(
|
|
self,
|
|
*args: typing.Any,
|
|
**kwargs: typing.Any,
|
|
) -> sqlalchemy_asyncio.AsyncScalarResult[typing.Any]:
|
|
del args, kwargs
|
|
self._reject_transaction_escape(
|
|
'stream_scalars; live database results cannot outlive the task-owned UoW operation'
|
|
)
|
|
|
|
def _validate_statement_or_reject(
|
|
self,
|
|
args: tuple[typing.Any, ...],
|
|
kwargs: dict[str, typing.Any],
|
|
) -> None:
|
|
try:
|
|
_validate_scoped_statement_call(args, kwargs)
|
|
except ScopedSessionTransactionError as exc:
|
|
guard = object.__getattribute__(self, '_langbot_guard_state')
|
|
if guard.transaction_state is not None:
|
|
guard.transaction_state.mark_rollback_only(exc)
|
|
raise
|
|
|
|
def _reject_nonempty_orm_options(
|
|
self,
|
|
operation: str,
|
|
kwargs: dict[str, typing.Any],
|
|
option_names: set[str],
|
|
) -> None:
|
|
for option_name in option_names:
|
|
value = kwargs.get(option_name)
|
|
if option_name == 'with_for_update':
|
|
if value is None or value is False:
|
|
continue
|
|
self._reject_transaction_escape(f'{operation} option {option_name}')
|
|
if option_name == 'identity_token':
|
|
if value is None:
|
|
continue
|
|
self._reject_transaction_escape(f'{operation} option {option_name}')
|
|
if value is None or value is False:
|
|
continue
|
|
if isinstance(value, collections.abc.Sized) and len(value) == 0:
|
|
continue
|
|
self._reject_transaction_escape(f'{operation} option {option_name}')
|
|
|
|
def _guard_method_call(self, method: types.MethodType) -> typing.Callable[..., typing.Any]:
|
|
if asyncio.iscoroutinefunction(method):
|
|
|
|
@functools.wraps(method)
|
|
async def guarded_async(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
|
|
self._require_owner_task()
|
|
self._enter_internal_access()
|
|
try:
|
|
return await method(*args, **kwargs)
|
|
finally:
|
|
self._exit_internal_access()
|
|
|
|
return guarded_async
|
|
|
|
@functools.wraps(method)
|
|
def guarded_sync(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
|
|
self._require_owner_task()
|
|
self._enter_internal_access()
|
|
try:
|
|
return method(*args, **kwargs)
|
|
finally:
|
|
self._exit_internal_access()
|
|
|
|
return guarded_sync
|
|
|
|
def _enter_internal_access(self) -> None:
|
|
depth = object.__getattribute__(self, '_langbot_internal_access_depth')
|
|
guard = object.__getattribute__(self, '_langbot_guard_state')
|
|
tokens = object.__getattribute__(self, '_langbot_sync_capability_tokens')
|
|
tokens.append(_SYNC_PROXY_CAPABILITY.set(guard))
|
|
object.__setattr__(self, '_langbot_internal_access_depth', depth + 1)
|
|
|
|
def _exit_internal_access(self) -> None:
|
|
depth = object.__getattribute__(self, '_langbot_internal_access_depth')
|
|
if depth <= 0:
|
|
raise RuntimeError('Scoped Session internal access stack underflow')
|
|
tokens = object.__getattribute__(self, '_langbot_sync_capability_tokens')
|
|
_SYNC_PROXY_CAPABILITY.reset(tokens.pop())
|
|
object.__setattr__(self, '_langbot_internal_access_depth', depth - 1)
|
|
|
|
def _require_control_capability(self, capability: object, operation: str) -> None:
|
|
if capability is not _UOW_SESSION_CONTROL_CAPABILITY:
|
|
self._reject_transaction_escape(operation)
|
|
|
|
def _require_owner_task(self) -> None:
|
|
guard = object.__getattribute__(self, '_langbot_guard_state')
|
|
if not guard.active:
|
|
raise ScopedSessionTransactionError('TenantUnitOfWork scoped Session is no longer active')
|
|
owner_task = guard.owner_task
|
|
try:
|
|
current_task = asyncio.current_task()
|
|
except RuntimeError:
|
|
current_task = None
|
|
if owner_task is not current_task:
|
|
raise CrossScopeTransactionError(
|
|
'Scoped database sessions cannot be inherited by child tasks; open an explicit task scope'
|
|
)
|
|
|
|
def _reject_transaction_escape(self, operation: str) -> typing.NoReturn:
|
|
self._require_owner_task()
|
|
error = ScopedSessionTransactionError(
|
|
f'TenantUnitOfWork owns AsyncSession transaction lifecycle; direct {operation} is not allowed'
|
|
)
|
|
state = object.__getattribute__(self, '_langbot_guard_state').transaction_state
|
|
if state is not None:
|
|
state.mark_rollback_only(error)
|
|
raise error
|
|
|
|
|
|
ActiveTransactionVar = contextvars.ContextVar[ActiveScopedTransaction | None]
|
|
|
|
# ``AsyncSession`` delegates database I/O to a greenlet-backed synchronous
|
|
# Engine, but Python context variables are preserved across that boundary. A
|
|
# per-engine ``handle_error`` listener therefore covers DBAPI failures from all
|
|
# direct Session APIs (execute/scalar/get/flush) as well as callers which obtain
|
|
# the transaction-bound Connection. The owner-task and engine checks prevent
|
|
# copied child-task contexts or unrelated database engines from poisoning this
|
|
# transaction.
|
|
_DATABASE_OPERATION_TRANSACTION: contextvars.ContextVar[ActiveScopedTransaction | None] = contextvars.ContextVar(
|
|
'langbot_database_operation_transaction',
|
|
default=None,
|
|
)
|
|
|
|
|
|
def _mark_current_transaction_rollback_only(exception_context: typing.Any) -> None:
|
|
state = _DATABASE_OPERATION_TRANSACTION.get()
|
|
if state is None:
|
|
return
|
|
try:
|
|
current_task = asyncio.current_task()
|
|
except RuntimeError: # pragma: no cover - async engines always run in an event loop
|
|
return
|
|
if state.owner_task is not current_task or exception_context.engine is not state.engine.sync_engine:
|
|
return
|
|
cause = exception_context.sqlalchemy_exception or exception_context.original_exception
|
|
state.mark_rollback_only(cause)
|
|
|
|
|
|
def _install_rollback_only_error_listener(engine: sqlalchemy_asyncio.AsyncEngine) -> None:
|
|
sync_engine = engine.sync_engine
|
|
if not sqlalchemy.event.contains(sync_engine, 'handle_error', _mark_current_transaction_rollback_only):
|
|
sqlalchemy.event.listen(sync_engine, 'handle_error', _mark_current_transaction_rollback_only)
|
|
|
|
|
|
@dataclasses.dataclass(slots=True)
|
|
class ActivePersistenceScope:
|
|
"""A trusted visibility scope without an open database transaction."""
|
|
|
|
scope: PersistenceScope
|
|
owner_task: asyncio.Task[typing.Any] | None
|
|
depth: int = 1
|
|
|
|
|
|
ActivePersistenceScopeVar = contextvars.ContextVar[ActivePersistenceScope | None]
|
|
|
|
|
|
class PersistenceScopeBoundary:
|
|
"""Carry a trusted persistence scope across long-running async work.
|
|
|
|
Unlike :class:`TenantUnitOfWork`, this boundary never opens a database
|
|
session. ``PersistenceManager.execute_async`` materializes the scope as a
|
|
short transaction for each database call. This makes the boundary suitable
|
|
for request and pipeline lifetimes which can spend substantial time waiting
|
|
on model providers, tools, or streaming clients.
|
|
|
|
Context variables are copied into child tasks, so implicit use from a child
|
|
task is rejected by the manager. A child may establish its own explicit
|
|
boundary because the scope value still comes from trusted application code.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
scope: PersistenceScope,
|
|
*,
|
|
active_scope: ActivePersistenceScopeVar,
|
|
active_transaction: ActiveTransactionVar,
|
|
) -> None:
|
|
self.scope = scope
|
|
self._active_scope = active_scope
|
|
self._active_transaction = active_transaction
|
|
self._active_state: ActivePersistenceScope | None = None
|
|
self._context_token: contextvars.Token[ActivePersistenceScope | None] | None = None
|
|
self._used = False
|
|
self._owns_scope = False
|
|
|
|
async def __aenter__(self) -> PersistenceScopeBoundary:
|
|
if self._used:
|
|
raise RuntimeError('PersistenceScopeBoundary instances cannot be reused')
|
|
self._used = True
|
|
|
|
transaction = self._active_transaction.get()
|
|
if transaction is not None:
|
|
if transaction.owner_task is not asyncio.current_task():
|
|
raise CrossScopeTransactionError(
|
|
'Scoped database transactions cannot be inherited by child tasks; open an explicit task scope'
|
|
)
|
|
if transaction.scope != self.scope:
|
|
raise CrossScopeTransactionError(
|
|
f'Cannot enter {self.scope.kind.value} scope while {transaction.scope.kind.value} scope is active'
|
|
)
|
|
|
|
active = self._active_scope.get()
|
|
if active is not None and active.owner_task is asyncio.current_task():
|
|
if active.scope != self.scope:
|
|
raise CrossScopeTransactionError(
|
|
f'Cannot enter {self.scope.kind.value} scope while {active.scope.kind.value} scope is active'
|
|
)
|
|
active.depth += 1
|
|
self._active_state = active
|
|
return self
|
|
|
|
state = ActivePersistenceScope(
|
|
scope=self.scope,
|
|
owner_task=asyncio.current_task(),
|
|
)
|
|
self._active_state = state
|
|
self._context_token = self._active_scope.set(state)
|
|
self._owns_scope = True
|
|
return self
|
|
|
|
async def __aexit__(
|
|
self,
|
|
exc_type: type[BaseException] | None,
|
|
exc_value: BaseException | None,
|
|
traceback: types.TracebackType | None,
|
|
) -> bool:
|
|
del exc_type, exc_value, traceback
|
|
state = self._active_state
|
|
if state is None:
|
|
raise RuntimeError('PersistenceScopeBoundary has no active state')
|
|
if state.owner_task is not asyncio.current_task():
|
|
raise CrossScopeTransactionError('Scoped persistence boundaries cannot be exited by a different task')
|
|
|
|
if not self._owns_scope:
|
|
state.depth -= 1
|
|
self._active_state = None
|
|
return False
|
|
|
|
if self._context_token is None:
|
|
raise RuntimeError('PersistenceScopeBoundary has no context token')
|
|
self._active_scope.reset(self._context_token)
|
|
state.depth = 0
|
|
self._active_state = None
|
|
return False
|
|
|
|
|
|
class TenantUnitOfWork:
|
|
"""Bind one trusted visibility scope to one database transaction.
|
|
|
|
PostgreSQL receives transaction-local settings consumed by RLS policies.
|
|
SQLite keeps the same transaction boundary so OSS code exercises the same
|
|
request/task ownership and rollback semantics. A manager-owned ContextVar
|
|
lets all legacy ``execute_async`` helpers reuse this exact session.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
engine: sqlalchemy_asyncio.AsyncEngine,
|
|
workspace_uuid: str | None = None,
|
|
*,
|
|
scope: PersistenceScope | None = None,
|
|
active_transaction: ActiveTransactionVar | None = None,
|
|
active_scope: ActivePersistenceScopeVar | None = None,
|
|
on_pool_timeout: typing.Callable[[], None] | None = None,
|
|
) -> None:
|
|
if (workspace_uuid is None) == (scope is None):
|
|
raise ValueError('TenantUnitOfWork requires exactly one Workspace or persistence scope')
|
|
|
|
self._engine = engine
|
|
self.scope = scope or PersistenceScope.workspace(typing.cast(str, workspace_uuid))
|
|
self.workspace_uuid = self.scope.settings[0][1] if self.scope.kind == PersistenceScopeKind.WORKSPACE else None
|
|
self._active_transaction = active_transaction
|
|
self._active_scope = active_scope
|
|
self._on_pool_timeout = on_pool_timeout
|
|
self._session: sqlalchemy_asyncio.AsyncSession | None = None
|
|
self._transaction: sqlalchemy_asyncio.AsyncSessionTransaction | None = None
|
|
self._active_state: ActiveScopedTransaction | None = None
|
|
self._context_token: contextvars.Token[ActiveScopedTransaction | None] | None = None
|
|
self._database_operation_token: contextvars.Token[ActiveScopedTransaction | None] | None = None
|
|
self._used = False
|
|
self._owns_transaction = False
|
|
_install_rollback_only_error_listener(engine)
|
|
|
|
@property
|
|
def session(self) -> sqlalchemy_asyncio.AsyncSession:
|
|
if self._session is None:
|
|
raise RuntimeError('TenantUnitOfWork is not active')
|
|
if self._active_state is not None:
|
|
self._require_owner_task(self._active_state)
|
|
return self._session
|
|
|
|
async def __aenter__(self) -> TenantUnitOfWork:
|
|
if self._used:
|
|
raise RuntimeError('TenantUnitOfWork instances cannot be reused')
|
|
self._used = True
|
|
|
|
active_scope = self._active_scope.get() if self._active_scope is not None else None
|
|
if active_scope is not None and active_scope.owner_task is asyncio.current_task():
|
|
if active_scope.scope != self.scope:
|
|
# A request/runtime Workspace boundary is transaction-free and
|
|
# may temporarily enter a narrower trusted discovery UoW (for
|
|
# example, resolving an account record by its verified UUID).
|
|
# It must still never switch directly to another Workspace.
|
|
workspace_to_discovery = (
|
|
active_scope.scope.kind == PersistenceScopeKind.WORKSPACE
|
|
and self.scope.kind
|
|
in {
|
|
PersistenceScopeKind.ACCOUNT_DISCOVERY,
|
|
PersistenceScopeKind.API_KEY_DISCOVERY,
|
|
PersistenceScopeKind.INVITATION_DISCOVERY,
|
|
PersistenceScopeKind.INSTANCE_DISCOVERY,
|
|
PersistenceScopeKind.IDENTITY_DISCOVERY,
|
|
}
|
|
)
|
|
if not workspace_to_discovery:
|
|
raise CrossScopeTransactionError(
|
|
f'Cannot enter {self.scope.kind.value} scope '
|
|
f'while {active_scope.scope.kind.value} scope is active'
|
|
)
|
|
|
|
active = self._active_transaction.get() if self._active_transaction is not None else None
|
|
if active is not None:
|
|
if active.owner_task is asyncio.current_task():
|
|
if active.scope != self.scope:
|
|
raise CrossScopeTransactionError(
|
|
f'Cannot enter {self.scope.kind.value} scope while {active.scope.kind.value} scope is active'
|
|
)
|
|
active.depth += 1
|
|
self._active_state = active
|
|
self._session = active.session
|
|
return self
|
|
# ContextVars are copied into child tasks. Merely calling a helper
|
|
# there must fail (PersistenceManager enforces that), but an
|
|
# explicit UoW is allowed to replace the inherited pointer with an
|
|
# independent transaction owned by the child task.
|
|
|
|
owner_task = asyncio.current_task()
|
|
session = TenantScopedAsyncSession(
|
|
self._engine,
|
|
owner_task=owner_task,
|
|
expire_on_commit=False,
|
|
close_resets_only=False,
|
|
)
|
|
self._session = session
|
|
transaction: sqlalchemy_asyncio.AsyncSessionTransaction | None = None
|
|
try:
|
|
transaction = await session._start_owned_transaction(_UOW_SESSION_CONTROL_CAPABILITY)
|
|
self._transaction = transaction
|
|
if self._engine.dialect.name == 'postgresql':
|
|
for setting_name, setting_value in self.scope.settings:
|
|
await session._apply_owned_scope_setting(
|
|
_UOW_SESSION_CONTROL_CAPABILITY,
|
|
setting_name,
|
|
setting_value,
|
|
)
|
|
state = ActiveScopedTransaction(
|
|
scope=self.scope,
|
|
session=session,
|
|
engine=self._engine,
|
|
owner_task=owner_task,
|
|
)
|
|
session._bind_transaction_state(_UOW_SESSION_CONTROL_CAPABILITY, state)
|
|
self._active_state = state
|
|
if self._active_transaction is not None:
|
|
self._context_token = self._active_transaction.set(state)
|
|
self._database_operation_token = _DATABASE_OPERATION_TRANSACTION.set(state)
|
|
self._owns_transaction = True
|
|
except BaseException as exc:
|
|
if isinstance(exc, sqlalchemy.exc.TimeoutError) and self._on_pool_timeout is not None:
|
|
self._on_pool_timeout()
|
|
if self._database_operation_token is not None:
|
|
_DATABASE_OPERATION_TRANSACTION.reset(self._database_operation_token)
|
|
self._database_operation_token = None
|
|
if self._active_transaction is not None and self._context_token is not None:
|
|
self._active_transaction.reset(self._context_token)
|
|
self._context_token = None
|
|
if transaction is not None and transaction.sync_transaction is not None:
|
|
await session._rollback_owned_transaction(_UOW_SESSION_CONTROL_CAPABILITY, transaction)
|
|
await session._close_owned_session(_UOW_SESSION_CONTROL_CAPABILITY)
|
|
self._session = None
|
|
self._transaction = None
|
|
raise
|
|
return self
|
|
|
|
async def __aexit__(
|
|
self,
|
|
exc_type: type[BaseException] | None,
|
|
exc_value: BaseException | None,
|
|
traceback: types.TracebackType | None,
|
|
) -> bool:
|
|
state = self._active_state
|
|
if state is None:
|
|
raise RuntimeError('TenantUnitOfWork has no active transaction state')
|
|
self._require_owner_task(state)
|
|
|
|
if not self._owns_transaction:
|
|
if exc_type is not None:
|
|
state.mark_rollback_only(exc_value)
|
|
state.depth -= 1
|
|
self._session = None
|
|
self._active_state = None
|
|
return False
|
|
|
|
session = self.session
|
|
transaction = self._transaction
|
|
if transaction is None:
|
|
raise RuntimeError('TenantUnitOfWork has no active root transaction')
|
|
if exc_type is not None:
|
|
state.mark_rollback_only(exc_value)
|
|
rollback_only = state.rollback_only
|
|
committed = False
|
|
try:
|
|
if exc_type is None and not rollback_only:
|
|
await typing.cast(TenantScopedAsyncSession, session)._commit_owned_transaction(
|
|
_UOW_SESSION_CONTROL_CAPABILITY,
|
|
transaction,
|
|
)
|
|
committed = True
|
|
else:
|
|
await typing.cast(TenantScopedAsyncSession, session)._rollback_owned_transaction(
|
|
_UOW_SESSION_CONTROL_CAPABILITY,
|
|
transaction,
|
|
)
|
|
finally:
|
|
try:
|
|
if self._active_transaction is not None and self._context_token is not None:
|
|
self._active_transaction.reset(self._context_token)
|
|
await typing.cast(TenantScopedAsyncSession, session)._close_owned_session(
|
|
_UOW_SESSION_CONTROL_CAPABILITY
|
|
)
|
|
finally:
|
|
if self._database_operation_token is not None:
|
|
_DATABASE_OPERATION_TRANSACTION.reset(self._database_operation_token)
|
|
self._database_operation_token = None
|
|
self._session = None
|
|
self._transaction = None
|
|
self._active_state = None
|
|
state.depth = 0
|
|
self._complete_after_commit_waiters(state, committed=committed)
|
|
|
|
if exc_type is None and rollback_only:
|
|
raise TransactionRollbackOnlyError(
|
|
'A scoped database operation failed or rolled back; '
|
|
'the transaction was rolled back and after-commit work was cancelled'
|
|
) from state.rollback_only_cause
|
|
return False
|
|
|
|
async def execute(self, *args: typing.Any, **kwargs: typing.Any) -> sqlalchemy.engine.Result[typing.Any]:
|
|
try:
|
|
return await self.session.execute(*args, **kwargs)
|
|
except BaseException as exc:
|
|
self._mark_rollback_only(exc)
|
|
raise
|
|
|
|
async def flush(self) -> None:
|
|
try:
|
|
await self.session.flush()
|
|
except BaseException as exc:
|
|
self._mark_rollback_only(exc)
|
|
raise
|
|
|
|
def _mark_rollback_only(self, cause: BaseException) -> None:
|
|
state = self._active_state
|
|
if state is not None:
|
|
state.mark_rollback_only(cause)
|
|
|
|
@staticmethod
|
|
def _require_owner_task(state: ActiveScopedTransaction) -> None:
|
|
if state.owner_task is not asyncio.current_task():
|
|
raise CrossScopeTransactionError(
|
|
'Scoped database transactions cannot be inherited by child tasks; open an explicit task scope'
|
|
)
|
|
|
|
@staticmethod
|
|
def _complete_after_commit_waiters(
|
|
state: ActiveScopedTransaction,
|
|
*,
|
|
committed: bool,
|
|
) -> None:
|
|
for waiter in state.after_commit_waiters:
|
|
if waiter.done():
|
|
continue
|
|
if committed:
|
|
waiter.set_result(None)
|
|
else:
|
|
waiter.cancel()
|
|
state.after_commit_waiters.clear()
|