mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 08:25:21 +08:00
feat: add verified manual pipeline migration for plugin runners
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
"""Create the manual migration journal only; never convert pipeline JSON."""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0027_pipeline_migration'
|
||||
down_revision = '0026_merge_master_beta'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
table = 'pipeline_migration_snapshots'
|
||||
if not sa.inspect(bind).has_table(table):
|
||||
op.create_table(
|
||||
table,
|
||||
sa.Column('uuid', sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
'workspace_uuid', sa.String(36), sa.ForeignKey('workspaces.uuid', ondelete='CASCADE'), nullable=False
|
||||
),
|
||||
sa.Column('pipeline_uuid', sa.String(255), nullable=False),
|
||||
sa.Column('source_fingerprint', sa.String(64), nullable=False),
|
||||
sa.Column('target_fingerprint', sa.String(64), nullable=False),
|
||||
sa.Column('planner_version', sa.String(64), nullable=False),
|
||||
sa.Column('source_snapshot', sa.JSON(), nullable=False),
|
||||
sa.Column('state', sa.String(32), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(
|
||||
['workspace_uuid', 'pipeline_uuid'],
|
||||
['legacy_pipelines.workspace_uuid', 'legacy_pipelines.uuid'],
|
||||
name='fk_pipeline_migration_workspace_pipeline',
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
'workspace_uuid', 'pipeline_uuid', 'source_fingerprint', name='uq_pipeline_migration_source'
|
||||
),
|
||||
sa.CheckConstraint("state IN ('activation_pending', 'active')", name='ck_pipeline_migration_state'),
|
||||
)
|
||||
op.create_index('ix_pipeline_migration_workspace_pipeline', table, ['workspace_uuid', 'pipeline_uuid'])
|
||||
if bind.dialect.name == 'postgresql':
|
||||
op.execute(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY')
|
||||
op.execute(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY')
|
||||
op.execute(f'DROP POLICY IF EXISTS langbot_workspace_isolation ON {table}')
|
||||
expression = "workspace_uuid = NULLIF(current_setting('langbot.workspace_uuid', true), '')"
|
||||
op.execute(
|
||||
f'CREATE POLICY langbot_workspace_isolation ON {table} FOR ALL TO PUBLIC '
|
||||
f'USING ({expression}) WITH CHECK ({expression})'
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('pipeline_migration_snapshots')
|
||||
@@ -71,6 +71,7 @@ _ALEMBIC_TENANT_TABLES = {
|
||||
'rerank_models',
|
||||
'legacy_pipelines',
|
||||
'pipeline_run_records',
|
||||
'pipeline_migration_snapshots',
|
||||
'plugin_settings',
|
||||
'knowledge_bases',
|
||||
'knowledge_base_files',
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Transaction-held, tenant/pipeline-scoped admission for form producers.
|
||||
|
||||
Every pending producer must hold this row until its INSERT commits. Migration
|
||||
holds the same row for both CAS and activation. No table/process/advisory lock.
|
||||
The no-op UPDATE also acquires SQLite's write reservation before any reads.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from ..entity.persistence.pipeline import LegacyPipeline
|
||||
|
||||
|
||||
async def lock_pipeline_admission(session, workspace_uuid: str, pipeline_uuid: str):
|
||||
if not workspace_uuid or not pipeline_uuid:
|
||||
raise ValueError('Pipeline admission requires exact ownership')
|
||||
t = LegacyPipeline.__table__
|
||||
result = await session.execute(
|
||||
sa.update(t)
|
||||
.where(t.c.workspace_uuid == workspace_uuid, t.c.uuid == pipeline_uuid)
|
||||
.values(updated_at=t.c.updated_at)
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
raise ValueError('Pipeline admission authority unavailable')
|
||||
return (
|
||||
await session.execute(
|
||||
sa.select(t.c.config).where(t.c.workspace_uuid == workspace_uuid, t.c.uuid == pipeline_uuid)
|
||||
)
|
||||
).scalar_one()
|
||||
@@ -58,6 +58,7 @@ TENANT_TABLE_COLUMNS: dict[str, str] = {
|
||||
'rerank_models': 'workspace_uuid',
|
||||
'legacy_pipelines': 'workspace_uuid',
|
||||
'pipeline_run_records': 'workspace_uuid',
|
||||
'pipeline_migration_snapshots': 'workspace_uuid',
|
||||
'plugin_settings': 'workspace_uuid',
|
||||
'knowledge_bases': 'workspace_uuid',
|
||||
'knowledge_base_files': 'workspace_uuid',
|
||||
@@ -467,9 +468,21 @@ def _validate_scoped_statement_call(args: tuple[typing.Any, ...], kwargs: dict[s
|
||||
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow literal-execute SQL parameters')
|
||||
|
||||
if isinstance(element, sqlalchemy.sql.elements.Cast) and type(element.type) not in {Vector, HALFVEC}:
|
||||
raise ScopedSessionTransactionError(
|
||||
'TenantUnitOfWork only allows the trusted pgvector cast used by tenant vector search'
|
||||
# Manual Pipeline migration compares DB-native JSON text, because
|
||||
# PostgreSQL JSON has no equality operator. Permit only these fixed
|
||||
# mapped columns and a built-in Text target, never arbitrary casts.
|
||||
source = element.clause
|
||||
pipeline_json_cas = (
|
||||
type(element.type) is sqlalchemy.Text
|
||||
and isinstance(source, sqlalchemy.Column)
|
||||
and type(source.type) is sqlalchemy.JSON
|
||||
and getattr(getattr(source, 'table', None), 'name', None) == 'legacy_pipelines'
|
||||
and source.name in {'config', 'stages', 'extensions_preferences'}
|
||||
)
|
||||
if not pipeline_json_cas:
|
||||
raise ScopedSessionTransactionError(
|
||||
'TenantUnitOfWork only allows trusted pgvector and Pipeline JSON CAS casts'
|
||||
)
|
||||
|
||||
if isinstance(element, sqlalchemy.sql.functions.FunctionElement):
|
||||
function_name = str(getattr(element, 'name', '')).casefold()
|
||||
|
||||
Reference in New Issue
Block a user