fix(api): fix alembic offline mode (#19285)

Alembic's offline mode generates SQL from SQLAlchemy migration operations,
providing developers with a clear view of database schema changes without
requiring an active database connection.

However, some migration versions (specifically bbadea11becb and d7999dfa4aae)
were performing database schema introspection, which fails in offline mode
since it requires an actual database connection.

This commit:
- Adds offline mode support by detecting context.is_offline_mode()
- Skips introspection steps when in offline mode
- Adds warning messages in SQL output to inform users that assumptions were made
- Prompts users to review the generated SQL for accuracy

These changes ensure migrations work consistently in both online and offline modes.

Close #19284.
This commit is contained in:
QuantumGhost 2025-05-06 18:05:19 +08:00 committed by GitHub
parent 8de24bc16e
commit 9565fe9b1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 61 additions and 63 deletions

View File

@ -5,45 +5,61 @@ Revises: 33f5fac87f29
Create Date: 2024-10-10 05:16:14.764268 Create Date: 2024-10-10 05:16:14.764268
""" """
from alembic import op
import models as models
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.dialects import postgresql from alembic import op, context
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = 'bbadea11becb' revision = "bbadea11becb"
down_revision = 'd8e744d88ed6' down_revision = "d8e744d88ed6"
branch_labels = None branch_labels = None
depends_on = None depends_on = None
def upgrade(): def upgrade():
def _has_name_or_size_column() -> bool:
# We cannot access the database in offline mode, so assume
# the "name" and "size" columns do not exist.
if context.is_offline_mode():
# Log a warning message to inform the user that the database schema cannot be inspected
# in offline mode, and the generated SQL may not accurately reflect the actual execution.
op.execute(
"-- Executing in offline mode, assuming the name and size columns do not exist.\n"
"-- The generated SQL may differ from what will actually be executed.\n"
"-- Please review the migration script carefully!"
)
return False
# Use SQLAlchemy inspector to get the columns of the 'tool_files' table
inspector = sa.inspect(conn)
columns = [col["name"] for col in inspector.get_columns("tool_files")]
# If 'name' or 'size' columns already exist, exit the upgrade function
if "name" in columns or "size" in columns:
return True
return False
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
# Get the database connection # Get the database connection
conn = op.get_bind() conn = op.get_bind()
# Use SQLAlchemy inspector to get the columns of the 'tool_files' table if _has_name_or_size_column():
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('tool_files')]
# If 'name' or 'size' columns already exist, exit the upgrade function
if 'name' in columns or 'size' in columns:
return return
with op.batch_alter_table('tool_files', schema=None) as batch_op: with op.batch_alter_table("tool_files", schema=None) as batch_op:
batch_op.add_column(sa.Column('name', sa.String(), nullable=True)) batch_op.add_column(sa.Column("name", sa.String(), nullable=True))
batch_op.add_column(sa.Column('size', sa.Integer(), nullable=True)) batch_op.add_column(sa.Column("size", sa.Integer(), nullable=True))
op.execute("UPDATE tool_files SET name = '' WHERE name IS NULL") op.execute("UPDATE tool_files SET name = '' WHERE name IS NULL")
op.execute("UPDATE tool_files SET size = -1 WHERE size IS NULL") op.execute("UPDATE tool_files SET size = -1 WHERE size IS NULL")
with op.batch_alter_table('tool_files', schema=None) as batch_op: with op.batch_alter_table("tool_files", schema=None) as batch_op:
batch_op.alter_column('name', existing_type=sa.String(), nullable=False) batch_op.alter_column("name", existing_type=sa.String(), nullable=False)
batch_op.alter_column('size', existing_type=sa.Integer(), nullable=False) batch_op.alter_column("size", existing_type=sa.Integer(), nullable=False)
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade(): def downgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tool_files', schema=None) as batch_op: with op.batch_alter_table("tool_files", schema=None) as batch_op:
batch_op.drop_column('size') batch_op.drop_column("size")
batch_op.drop_column('name') batch_op.drop_column("name")
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@ -5,28 +5,38 @@ Revises: e1944c35e15e
Create Date: 2024-12-23 11:54:15.344543 Create Date: 2024-12-23 11:54:15.344543
""" """
from alembic import op
import models as models from alembic import op, context
import sqlalchemy as sa
from sqlalchemy import inspect from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = 'd7999dfa4aae' revision = "d7999dfa4aae"
down_revision = 'e1944c35e15e' down_revision = "e1944c35e15e"
branch_labels = None branch_labels = None
depends_on = None depends_on = None
def upgrade(): def upgrade():
# Check if column exists before attempting to remove it def _has_retry_index_column() -> bool:
conn = op.get_bind() if context.is_offline_mode():
inspector = inspect(conn) # Log a warning message to inform the user that the database schema cannot be inspected
has_column = 'retry_index' in [col['name'] for col in inspector.get_columns('workflow_node_executions')] # in offline mode, and the generated SQL may not accurately reflect the actual execution.
op.execute(
'-- Executing in offline mode: assuming the "retry_index" column does not exist.\n'
"-- The generated SQL may differ from what will actually be executed.\n"
"-- Please review the migration script carefully!"
)
return False
conn = op.get_bind()
inspector = inspect(conn)
return "retry_index" in [col["name"] for col in inspector.get_columns("workflow_node_executions")]
has_column = _has_retry_index_column()
if has_column: if has_column:
with op.batch_alter_table('workflow_node_executions', schema=None) as batch_op: with op.batch_alter_table("workflow_node_executions", schema=None) as batch_op:
batch_op.drop_column('retry_index') batch_op.drop_column("retry_index")
def downgrade(): def downgrade():

View File

@ -1,6 +1,6 @@
import json import json
from datetime import datetime from datetime import datetime
from typing import Any, Optional, cast from typing import Any, cast
import sqlalchemy as sa import sqlalchemy as sa
from deprecated import deprecated from deprecated import deprecated
@ -304,8 +304,11 @@ class DeprecatedPublishedAppTool(Base):
db.UniqueConstraint("app_id", "user_id", name="unique_published_app_tool"), db.UniqueConstraint("app_id", "user_id", name="unique_published_app_tool"),
) )
id = db.Column(StringUUID, server_default=db.text("uuid_generate_v4()"))
# id of the app # id of the app
app_id = db.Column(StringUUID, ForeignKey("apps.id"), nullable=False) app_id = db.Column(StringUUID, ForeignKey("apps.id"), nullable=False)
user_id: Mapped[str] = db.Column(StringUUID, nullable=False)
# who published this tool # who published this tool
description = db.Column(db.Text, nullable=False) description = db.Column(db.Text, nullable=False)
# llm_description of the tool, for LLM # llm_description of the tool, for LLM
@ -325,34 +328,3 @@ class DeprecatedPublishedAppTool(Base):
@property @property
def description_i18n(self) -> I18nObject: def description_i18n(self) -> I18nObject:
return I18nObject(**json.loads(self.description)) return I18nObject(**json.loads(self.description))
id = db.Column(StringUUID, server_default=db.text("uuid_generate_v4()"))
user_id: Mapped[str] = db.Column(StringUUID, nullable=False)
tenant_id: Mapped[str] = db.Column(StringUUID, nullable=False)
conversation_id: Mapped[Optional[str]] = db.Column(StringUUID, nullable=True)
file_key: Mapped[str] = db.Column(db.String(255), nullable=False)
mimetype: Mapped[str] = db.Column(db.String(255), nullable=False)
original_url: Mapped[Optional[str]] = db.Column(db.String(2048), nullable=True)
name: Mapped[str] = mapped_column(default="")
size: Mapped[int] = mapped_column(default=-1)
def __init__(
self,
*,
user_id: str,
tenant_id: str,
conversation_id: Optional[str] = None,
file_key: str,
mimetype: str,
original_url: Optional[str] = None,
name: str,
size: int,
):
self.user_id = user_id
self.tenant_id = tenant_id
self.conversation_id = conversation_id
self.file_key = file_key
self.mimetype = mimetype
self.original_url = original_url
self.name = name
self.size = size