2024-08-15 09:17:36 +08:00
#
# Copyright 2024 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
2025-06-18 16:45:42 +08:00
import hashlib
2024-08-15 09:17:36 +08:00
import inspect
2025-03-24 13:18:47 +08:00
import logging
import operator
2024-08-15 09:17:36 +08:00
import os
import sys
2025-03-25 15:09:56 +08:00
import time
2025-06-18 16:45:42 +08:00
import typing
2025-11-03 19:59:18 +08:00
from datetime import datetime , timezone
2024-09-12 15:12:39 +08:00
from enum import Enum
2024-08-15 09:17:36 +08:00
from functools import wraps
2025-03-24 13:18:47 +08:00
2025-11-18 17:05:16 +08:00
from quart_auth import AuthUser
2025-03-24 13:18:47 +08:00
from itsdangerous . url_safe import URLSafeTimedSerializer as Serializer
2026-08-18 09:53:11 +08:00
from psycopg2 import sql as psycopg2_sql
2026-02-27 12:55:51 +01:00
from peewee import (
fn ,
InterfaceError ,
OperationalError ,
ProgrammingError ,
BigIntegerField ,
BooleanField ,
CharField ,
CompositeKey ,
DateTimeField ,
Field ,
FloatField ,
IntegerField ,
Metadata ,
Model ,
2026-08-18 09:53:11 +08:00
ModelInsert ,
2026-02-27 12:55:51 +01:00
TextField ,
)
2024-09-12 15:12:39 +08:00
from playhouse . migrate import MySQLMigrator , PostgresqlMigrator , migrate
from playhouse . pool import PooledMySQLDatabase , PooledPostgresqlDatabase
2024-11-19 14:51:33 +08:00
2025-11-06 09:36:38 +08:00
from api import utils
2025-11-05 08:01:39 +08:00
from api . db import SerializedType
2026-08-18 09:53:11 +08:00
from api . db . gaussdb_error_utils import (
is_duplicate_column_error ,
is_duplicate_object_error ,
is_psycopg_connection_error ,
is_undefined_object_error ,
sqlstate_from_exception ,
)
2025-10-29 12:19:57 +08:00
from api . utils . json_encode import json_dumps , json_loads
2025-09-25 18:04:49 +08:00
from api . utils . configs import deserialize_b64 , serialize_b64
2024-08-15 09:17:36 +08:00
2025-10-28 19:09:14 +08:00
from common . time_utils import current_timestamp , timestamp_to_date , date_string_to_timestamp
2025-11-02 12:24:08 +08:00
from common . decorator import singleton
Fix: Remove hardcoded page limits causing parsing failures on large PDFs (>300 pages) (#14382)
### What problem does this PR solve?
Fixes #14196
## Problem
When using DeepDOC to parse large PDFs (over 1000 pages), the parser
silently truncated processing at 300 pages due to a hardcoded default
`page_to=299` in `RAGFlowPdfParser.__images__()`. This caused:
- **Errors** on pages beyond the limit
- **Poor image quality** as the parser attempted to compensate with
missing page data
- **Inconsistent chunk splitting** between full PDF imports and partial
imports
Additionally, the codebase scattered magic numbers (`299`, `600`,
`10000`, `100000`, `100000000`, `10000000000`, `10**9`) across 22 files
as sentinel values for "parse all pages", making future maintenance
error-prone.
## Root Cause
```python
# deepdoc/parser/pdf_parser.py (before)
def __images__(self, fnm, zoomin=3, page_from=0, page_to=299, callback=None):
# Only the first 300 pages were rendered; everything beyond was silently dropped
```
While most callers in `rag/app/*.py` correctly passed `to_page=100000`,
the base class `RAGFlowPdfParser.__call__()` and `parse_into_bboxes()`
invoked `__images__` **without** forwarding `page_from`/`page_to`,
falling back to the restrictive default of 299.
## Solution
### 1. Define constants in `common/constants.py`
```python
MAXIMUM_PAGE_NUMBER = 100000 # Used by the parsing layer
MAXIMUM_TASK_PAGE_NUMBER = MAXIMUM_PAGE_NUMBER * 1000 # Used by the task/DB layer
```
### 2. Replace all hardcoded sentinel values
| Layer | Files Changed | Old Values | New Value |
|---|---|---|---|
| **Deepdoc parsers** | `pdf_parser.py`, `mineru_parser.py`,
`docling_parser.py`, `opendataloader_parser.py`, `paddleocr_parser.py`,
`docx_parser.py` | `299`, `600`, `10**9`, `100000000` |
`MAXIMUM_PAGE_NUMBER` |
| **Chunk parsers** | `naive.py`, `book.py`, `qa.py`, `one.py`,
`manual.py`, `paper.py`, `presentation.py`, `laws.py`, `resume.py`,
`email.py`, `table.py` | `100000`, `10000`, `10000000000` |
`MAXIMUM_PAGE_NUMBER` |
| **Task/DB layer** | `db_models.py`, `task_service.py`,
`document_service.py`, `file_service.py` | `100000000` |
`MAXIMUM_TASK_PAGE_NUMBER` |
### 3. Fix `parse_into_bboxes()` missing parameters
Added `from_page`/`to_page` parameters to `parse_into_bboxes()` so that
the `rag/flow/parser/parser.py` DeepDOC path no longer falls back to the
restrictive default.
## Files Changed (22)
- `common/constants.py`
- `deepdoc/parser/pdf_parser.py`
- `deepdoc/parser/mineru_parser.py`
- `deepdoc/parser/docling_parser.py`
- `deepdoc/parser/opendataloader_parser.py`
- `deepdoc/parser/paddleocr_parser.py`
- `deepdoc/parser/docx_parser.py`
- `rag/app/naive.py`
- `rag/app/book.py`
- `rag/app/qa.py`
- `rag/app/one.py`
- `rag/app/manual.py`
- `rag/app/paper.py`
- `rag/app/presentation.py`
- `rag/app/laws.py`
- `rag/app/resume.py`
- `rag/app/email.py`
- `rag/app/table.py`
- `api/db/db_models.py`
- `api/db/services/task_service.py`
- `api/db/services/document_service.py`
- `api/db/services/file_service.py`
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [x] Refactoring
---------
Signed-off-by: noob <yixiao121314@outlook.com>
2026-04-27 06:57:20 +00:00
from common . constants import ParserType , MAXIMUM_TASK_PAGE_NUMBER
2025-11-06 09:36:38 +08:00
from common import settings
2024-08-15 09:17:36 +08:00
CONTINUOUS_FIELD_TYPE = { IntegerField , FloatField , DateTimeField }
2025-03-24 13:18:47 +08:00
AUTO_DATE_TIMESTAMP_FIELD_PREFIX = { " create " , " start " , " end " , " update " , " read_access " , " write_access " }
2026-08-18 09:53:11 +08:00
GAUSSDB_COMPATIBLE_DATABASE_TYPES = { " GAUSSDB " }
def is_gaussdb_compatible_database ( ) - > bool :
return settings . DATABASE_TYPE . upper ( ) in GAUSSDB_COMPATIBLE_DATABASE_TYPES
2024-08-15 09:17:36 +08:00
2024-09-12 15:12:39 +08:00
class TextFieldType ( Enum ) :
2025-03-24 13:18:47 +08:00
MYSQL = " LONGTEXT "
2026-01-31 15:45:20 +08:00
OCEANBASE = " LONGTEXT "
POSTGRES = " TEXT "
2026-08-18 09:53:11 +08:00
# GaussDB does not support MySQL LONGTEXT semantics. Map long text to TEXT
# for JSONField and LongTextField while retaining a distinct enum entry so
# DB_TYPE=gaussdb is not treated as DB_TYPE=postgres.
GAUSSDB = " TEXT "
2024-09-12 15:12:39 +08:00
2024-08-15 09:17:36 +08:00
class LongTextField ( TextField ) :
2024-11-15 17:30:56 +08:00
field_type = TextFieldType [ settings . DATABASE_TYPE . upper ( ) ] . value
2024-08-15 09:17:36 +08:00
2026-08-18 09:53:11 +08:00
class EmptyStringFieldMixin :
""" Preserve application-level empty-string semantics on GaussDB.
A / ORA - compatible GaussDB treats empty strings in SQL literals and bound
parameters as NULL . Some RAGFlow fields use an empty string to mean " not
configured " and can remain NOT NULL with a default of " " on MySQL and
PostgreSQL . On GaussDB , those values would violate a NOT NULL constraint .
Apply this field only to explicitly selected columns . Do not perform a
global NULL - to - empty - string conversion in DB . execute_sql or the cursor
layer , because NULL remains meaningful for other fields .
"""
__hash__ = Field . __hash__
def __init__ ( self , * args , * * kwargs ) :
if is_gaussdb_compatible_database ( ) :
# These fields represent application-level empty strings as NULL
# in GaussDB. Their columns must therefore be nullable to avoid a
# NOT NULL violation. Other databases retain their original
# constraints.
kwargs [ " null " ] = True
super ( ) . __init__ ( * args , * * kwargs )
def db_value ( self , value ) :
if is_gaussdb_compatible_database ( ) and value == " " :
# Normalize the application-level empty string explicitly so the
# storage semantics remain stable even if a test or user database
# stops converting empty strings to NULL automatically.
return None
return super ( ) . db_value ( value )
def python_value ( self , value ) :
if is_gaussdb_compatible_database ( ) and value is None :
# Preserve the existing application contract without scattering
# `is None` branches throughout the business code.
return " "
return super ( ) . python_value ( value )
def __eq__ ( self , rhs ) :
if is_gaussdb_compatible_database ( ) and isinstance ( rhs , str ) and rhs == " " :
# On A/ORA-compatible GaussDB, `field = ''` becomes `field = NULL`
# and never matches. Express the application-level empty value as
# `IS NULL OR LENGTH(field) = 0` to match both normalized NULLs and
# any historical empty strings.
return self . is_null ( True ) | ( fn . LENGTH ( self ) == 0 )
return super ( ) . __eq__ ( rhs )
def __ne__ ( self , rhs ) :
if is_gaussdb_compatible_database ( ) and isinstance ( rhs , str ) and rhs == " " :
# Symmetrically, a value unequal to the application-level empty
# string must contain data. LENGTH(field) > 0 avoids comparing with
# NULL and excludes both NULL and historical empty strings.
return fn . LENGTH ( self ) > 0
return super ( ) . __ne__ ( rhs )
class EmptyStringCharField ( EmptyStringFieldMixin , CharField ) :
pass
class EmptyStringTextField ( EmptyStringFieldMixin , TextField ) :
pass
class EmptyStringLongTextField ( EmptyStringFieldMixin , LongTextField ) :
pass
2024-08-15 09:17:36 +08:00
class JSONField ( LongTextField ) :
default_value = { }
def __init__ ( self , object_hook = None , object_pairs_hook = None , * * kwargs ) :
self . _object_hook = object_hook
self . _object_pairs_hook = object_pairs_hook
super ( ) . __init__ ( * * kwargs )
def db_value ( self , value ) :
if value is None :
value = self . default_value
2025-09-25 18:04:49 +08:00
return json_dumps ( value )
2024-08-15 09:17:36 +08:00
def python_value ( self , value ) :
if not value :
return self . default_value
2025-09-25 18:04:49 +08:00
return json_loads ( value , object_hook = self . _object_hook , object_pairs_hook = self . _object_pairs_hook )
2024-08-15 09:17:36 +08:00
class ListField ( JSONField ) :
default_value = [ ]
class SerializedField ( LongTextField ) :
2025-03-24 13:18:47 +08:00
def __init__ ( self , serialized_type = SerializedType . PICKLE , object_hook = None , object_pairs_hook = None , * * kwargs ) :
2024-08-15 09:17:36 +08:00
self . _serialized_type = serialized_type
self . _object_hook = object_hook
self . _object_pairs_hook = object_pairs_hook
super ( ) . __init__ ( * * kwargs )
def db_value ( self , value ) :
if self . _serialized_type == SerializedType . PICKLE :
2025-09-25 18:04:49 +08:00
return serialize_b64 ( value , to_str = True )
2024-08-15 09:17:36 +08:00
elif self . _serialized_type == SerializedType . JSON :
if value is None :
return None
2025-09-25 18:04:49 +08:00
return json_dumps ( value , with_type = True )
2024-08-15 09:17:36 +08:00
else :
2025-03-24 13:18:47 +08:00
raise ValueError ( f " the serialized type { self . _serialized_type } is not supported " )
2024-08-15 09:17:36 +08:00
def python_value ( self , value ) :
if self . _serialized_type == SerializedType . PICKLE :
2025-09-25 18:04:49 +08:00
return deserialize_b64 ( value )
2024-08-15 09:17:36 +08:00
elif self . _serialized_type == SerializedType . JSON :
if value is None :
return { }
2025-09-25 18:04:49 +08:00
return json_loads ( value , object_hook = self . _object_hook , object_pairs_hook = self . _object_pairs_hook )
2024-08-15 09:17:36 +08:00
else :
2025-03-24 13:18:47 +08:00
raise ValueError ( f " the serialized type { self . _serialized_type } is not supported " )
2024-08-15 09:17:36 +08:00
2024-11-19 14:51:33 +08:00
def is_continuous_field ( cls : typing . Type ) - > bool :
2024-08-15 09:17:36 +08:00
if cls in CONTINUOUS_FIELD_TYPE :
return True
for p in cls . __bases__ :
if p in CONTINUOUS_FIELD_TYPE :
return True
2024-12-08 14:21:12 +08:00
elif p is not Field and p is not object :
2024-08-15 09:17:36 +08:00
if is_continuous_field ( p ) :
return True
else :
return False
def auto_date_timestamp_field ( ) :
return { f " { f } _time " for f in AUTO_DATE_TIMESTAMP_FIELD_PREFIX }
def auto_date_timestamp_db_field ( ) :
return { f " f_ { f } _time " for f in AUTO_DATE_TIMESTAMP_FIELD_PREFIX }
def remove_field_name_prefix ( field_name ) :
2025-03-24 13:18:47 +08:00
return field_name [ 2 : ] if field_name . startswith ( " f_ " ) else field_name
2024-08-15 09:17:36 +08:00
class BaseModel ( Model ) :
create_time = BigIntegerField ( null = True , index = True )
create_date = DateTimeField ( null = True , index = True )
update_time = BigIntegerField ( null = True , index = True )
update_date = DateTimeField ( null = True , index = True )
def to_json ( self ) :
# This function is obsolete
return self . to_dict ( )
def to_dict ( self ) :
2025-03-24 13:18:47 +08:00
return self . __dict__ [ " __data__ " ]
2024-08-15 09:17:36 +08:00
2024-11-19 14:51:33 +08:00
def to_human_model_dict ( self , only_primary_with : list = None ) :
2025-03-24 13:18:47 +08:00
model_dict = self . __dict__ [ " __data__ " ]
2024-08-15 09:17:36 +08:00
if not only_primary_with :
2025-03-24 13:18:47 +08:00
return { remove_field_name_prefix ( k ) : v for k , v in model_dict . items ( ) }
2024-08-15 09:17:36 +08:00
human_model_dict = { }
for k in self . _meta . primary_key . field_names :
human_model_dict [ remove_field_name_prefix ( k ) ] = model_dict [ k ]
for k in only_primary_with :
2025-03-24 13:18:47 +08:00
human_model_dict [ k ] = model_dict [ f " f_ { k } " ]
2024-08-15 09:17:36 +08:00
return human_model_dict
@property
def meta ( self ) - > Metadata :
return self . _meta
@classmethod
def get_primary_keys_name ( cls ) :
2025-03-24 13:18:47 +08:00
return cls . _meta . primary_key . field_names if isinstance ( cls . _meta . primary_key , CompositeKey ) else [ cls . _meta . primary_key . name ]
2024-08-15 09:17:36 +08:00
@classmethod
def getter_by ( cls , attr ) :
return operator . attrgetter ( attr ) ( cls )
@classmethod
def query ( cls , reverse = None , order_by = None , * * kwargs ) :
filters = [ ]
for f_n , f_v in kwargs . items ( ) :
2025-03-24 13:18:47 +08:00
attr_name = " %s " % f_n
2024-08-15 09:17:36 +08:00
if not hasattr ( cls , attr_name ) or f_v is None :
continue
if type ( f_v ) in { list , set } :
f_v = list ( f_v )
if is_continuous_field ( type ( getattr ( cls , attr_name ) ) ) :
if len ( f_v ) == 2 :
for i , v in enumerate ( f_v ) :
2025-03-24 13:18:47 +08:00
if isinstance ( v , str ) and f_n in auto_date_timestamp_field ( ) :
2024-08-15 09:17:36 +08:00
# time type: %Y-%m-%d %H:%M:%S
2025-10-28 19:09:14 +08:00
f_v [ i ] = date_string_to_timestamp ( v )
2024-08-15 09:17:36 +08:00
lt_value = f_v [ 0 ]
gt_value = f_v [ 1 ]
if lt_value is not None and gt_value is not None :
2025-03-24 13:18:47 +08:00
filters . append ( cls . getter_by ( attr_name ) . between ( lt_value , gt_value ) )
2024-08-15 09:17:36 +08:00
elif lt_value is not None :
2025-03-24 13:18:47 +08:00
filters . append ( operator . attrgetter ( attr_name ) ( cls ) > = lt_value )
2024-08-15 09:17:36 +08:00
elif gt_value is not None :
2025-03-24 13:18:47 +08:00
filters . append ( operator . attrgetter ( attr_name ) ( cls ) < = gt_value )
2024-08-15 09:17:36 +08:00
else :
filters . append ( operator . attrgetter ( attr_name ) ( cls ) << f_v )
else :
filters . append ( operator . attrgetter ( attr_name ) ( cls ) == f_v )
if filters :
query_records = cls . select ( ) . where ( * filters )
if reverse is not None :
if not order_by or not hasattr ( cls , f " { order_by } " ) :
order_by = " create_time "
if reverse is True :
2025-03-24 13:18:47 +08:00
query_records = query_records . order_by ( cls . getter_by ( f " { order_by } " ) . desc ( ) )
2024-08-15 09:17:36 +08:00
elif reverse is False :
2025-03-24 13:18:47 +08:00
query_records = query_records . order_by ( cls . getter_by ( f " { order_by } " ) . asc ( ) )
2024-08-15 09:17:36 +08:00
return [ query_record for query_record in query_records ]
else :
return [ ]
@classmethod
def insert ( cls , __data = None , * * insert ) :
if isinstance ( __data , dict ) and __data :
2025-10-28 19:09:14 +08:00
__data [ cls . _meta . combined [ " create_time " ] ] = current_timestamp ( )
2024-08-15 09:17:36 +08:00
if insert :
2025-10-28 19:09:14 +08:00
insert [ " create_time " ] = current_timestamp ( )
2024-08-15 09:17:36 +08:00
return super ( ) . insert ( __data , * * insert )
# update and insert will call this method
@classmethod
def _normalize_data ( cls , data , kwargs ) :
normalized = super ( ) . _normalize_data ( data , kwargs )
if not normalized :
return { }
2025-10-28 19:09:14 +08:00
normalized [ cls . _meta . combined [ " update_time " ] ] = current_timestamp ( )
2024-08-15 09:17:36 +08:00
for f_n in AUTO_DATE_TIMESTAMP_FIELD_PREFIX :
2025-03-24 13:18:47 +08:00
if { f " { f_n } _time " , f " { f_n } _date " } . issubset ( cls . _meta . combined . keys ( ) ) and cls . _meta . combined [ f " { f_n } _time " ] in normalized and normalized [ cls . _meta . combined [ f " { f_n } _time " ] ] is not None :
2025-10-28 19:09:14 +08:00
normalized [ cls . _meta . combined [ f " { f_n } _date " ] ] = timestamp_to_date ( normalized [ cls . _meta . combined [ f " { f_n } _time " ] ] )
2024-08-15 09:17:36 +08:00
return normalized
class JsonSerializedField ( SerializedField ) :
2025-03-24 13:18:47 +08:00
def __init__ ( self , object_hook = utils . from_dict_hook , object_pairs_hook = None , * * kwargs ) :
super ( JsonSerializedField , self ) . __init__ ( serialized_type = SerializedType . JSON , object_hook = object_hook , object_pairs_hook = object_pairs_hook , * * kwargs )
2024-08-15 09:17:36 +08:00
2024-11-19 14:51:33 +08:00
2025-07-15 19:05:48 +08:00
class RetryingPooledMySQLDatabase ( PooledMySQLDatabase ) :
def __init__ ( self , * args , * * kwargs ) :
2025-09-03 14:55:24 +08:00
self . max_retries = kwargs . pop ( " max_retries " , 5 )
self . retry_delay = kwargs . pop ( " retry_delay " , 1 )
2025-07-15 19:05:48 +08:00
super ( ) . __init__ ( * args , * * kwargs )
def execute_sql ( self , sql , params = None , commit = True ) :
for attempt in range ( self . max_retries + 1 ) :
try :
return super ( ) . execute_sql ( sql , params , commit )
2025-09-25 17:03:43 +08:00
except ( OperationalError , InterfaceError ) as e :
error_codes = [ 2013 , 2006 ]
2026-07-02 23:22:07 +08:00
error_messages = [ " " , " Lost connection " ]
should_retry = ( hasattr ( e , " args " ) and e . args and e . args [ 0 ] in error_codes ) or ( str ( e ) in error_messages ) or ( hasattr ( e , " __class__ " ) and e . __class__ . __name__ == " InterfaceError " )
2025-09-25 17:03:43 +08:00
if should_retry and attempt < self . max_retries :
2026-07-02 23:22:07 +08:00
logging . warning ( f " Database connection issue (attempt { attempt + 1 } / { self . max_retries } ): { e } " )
2025-07-15 19:05:48 +08:00
self . _handle_connection_loss ( )
2026-07-02 23:22:07 +08:00
time . sleep ( self . retry_delay * ( 2 * * attempt ) )
2025-07-15 19:05:48 +08:00
else :
logging . error ( f " DB execution failure: { e } " )
raise
return None
def _handle_connection_loss ( self ) :
2025-09-25 17:03:43 +08:00
# self.close_all()
# self.connect()
try :
self . close ( )
except Exception :
pass
try :
self . connect ( )
except Exception as e :
logging . error ( f " Failed to reconnect: { e } " )
time . sleep ( 0.1 )
2026-01-18 17:48:10 -08:00
try :
self . connect ( )
except Exception as e2 :
logging . error ( f " Failed to reconnect on second attempt: { e2 } " )
raise
2025-07-15 19:05:48 +08:00
def begin ( self ) :
for attempt in range ( self . max_retries + 1 ) :
try :
return super ( ) . begin ( )
2025-09-25 17:03:43 +08:00
except ( OperationalError , InterfaceError ) as e :
error_codes = [ 2013 , 2006 ]
2026-07-02 23:22:07 +08:00
error_messages = [ " " , " Lost connection " ]
2025-09-25 17:03:43 +08:00
2026-07-02 23:22:07 +08:00
should_retry = ( hasattr ( e , " args " ) and e . args and e . args [ 0 ] in error_codes ) or ( str ( e ) in error_messages ) or ( hasattr ( e , " __class__ " ) and e . __class__ . __name__ == " InterfaceError " )
2025-09-25 17:03:43 +08:00
if should_retry and attempt < self . max_retries :
2026-07-02 23:22:07 +08:00
logging . warning ( f " Lost connection during transaction (attempt { attempt + 1 } / { self . max_retries } ) " )
2025-07-15 19:05:48 +08:00
self . _handle_connection_loss ( )
2026-07-02 23:22:07 +08:00
time . sleep ( self . retry_delay * ( 2 * * attempt ) )
2025-07-15 19:05:48 +08:00
else :
raise
2025-11-16 19:29:20 +08:00
return None
2025-07-15 19:05:48 +08:00
2026-08-18 09:53:11 +08:00
class GaussDBPsycopgRetryMixin :
""" Connection retry behavior used only by the GaussDB adapter. """
database_display_name = " GaussDB "
def __init__ ( self , * args , * * kwargs ) :
self . max_retries = kwargs . pop ( " max_retries " , 5 )
self . retry_delay = kwargs . pop ( " retry_delay " , 1 )
super ( ) . __init__ ( * args , * * kwargs )
def _prepare_sql_for_execution ( self , sql ) :
return sql
def execute_sql ( self , sql , params = None , commit = True ) :
for attempt in range ( self . max_retries + 1 ) :
try :
prepared_sql = self . _prepare_sql_for_execution ( sql )
return super ( ) . execute_sql ( prepared_sql , params , commit )
except ( OperationalError , InterfaceError ) as e :
# A reconnect cannot restore an active transaction. Let the
# atomic block fail as a unit instead of retrying one statement
# on a different connection.
if self . in_transaction ( ) :
logging . error ( f " { self . database_display_name } execution failure: { e } " )
raise
# Peewee may hide the driver SQLSTATE in `__context__`. Use the
# shared classifier to recognize 08xxx/57P0x connection states
# first and fall back to observed GaussDB disconnect text.
should_retry = is_psycopg_connection_error ( e )
if should_retry and attempt < self . max_retries :
logging . warning ( f " { self . database_display_name } connection issue (attempt { attempt + 1 } / { self . max_retries } ): { e } " )
self . _handle_connection_loss ( )
time . sleep ( self . retry_delay * ( 2 * * attempt ) )
else :
logging . error ( f " { self . database_display_name } execution failure: { e } " )
raise
return None
def _handle_connection_loss ( self ) :
try :
self . close ( )
except Exception :
pass
try :
self . connect ( )
except Exception as e :
logging . error ( f " Failed to reconnect to { self . database_display_name } : { e } " )
time . sleep ( 0.1 )
try :
self . connect ( )
except Exception as e2 :
logging . error ( f " Failed to reconnect to { self . database_display_name } on second attempt: { e2 } " )
raise
def begin ( self ) :
for attempt in range ( self . max_retries + 1 ) :
try :
return super ( ) . begin ( )
except ( OperationalError , InterfaceError ) as e :
# Apply the same connection-error classification during
# transaction startup and statement execution.
should_retry = is_psycopg_connection_error ( e )
if should_retry and attempt < self . max_retries :
logging . warning ( f " { self . database_display_name } connection lost during transaction (attempt { attempt + 1 } / { self . max_retries } ) " )
self . _handle_connection_loss ( )
time . sleep ( self . retry_delay * ( 2 * * attempt ) )
else :
raise
return None
2025-10-16 09:08:41 +02:00
class RetryingPooledPostgresqlDatabase ( PooledPostgresqlDatabase ) :
def __init__ ( self , * args , * * kwargs ) :
self . max_retries = kwargs . pop ( " max_retries " , 5 )
self . retry_delay = kwargs . pop ( " retry_delay " , 1 )
super ( ) . __init__ ( * args , * * kwargs )
def execute_sql ( self , sql , params = None , commit = True ) :
for attempt in range ( self . max_retries + 1 ) :
try :
return super ( ) . execute_sql ( sql , params , commit )
except ( OperationalError , InterfaceError ) as e :
# PostgreSQL specific error codes
# 57P01: admin_shutdown
# 57P02: crash_shutdown
# 57P03: cannot_connect_now
# 08006: connection_failure
# 08003: connection_does_not_exist
# 08000: connection_exception
2026-07-02 23:22:07 +08:00
error_messages = [ " connection " , " server closed " , " connection refused " , " no connection to the server " , " terminating connection " ]
2025-10-28 19:09:14 +08:00
2025-10-16 09:08:41 +02:00
should_retry = any ( msg in str ( e ) . lower ( ) for msg in error_messages )
if should_retry and attempt < self . max_retries :
2026-07-02 23:22:07 +08:00
logging . warning ( f " PostgreSQL connection issue (attempt { attempt + 1 } / { self . max_retries } ): { e } " )
2025-10-16 09:08:41 +02:00
self . _handle_connection_loss ( )
2026-07-02 23:22:07 +08:00
time . sleep ( self . retry_delay * ( 2 * * attempt ) )
2025-10-16 09:08:41 +02:00
else :
logging . error ( f " PostgreSQL execution failure: { e } " )
raise
return None
def _handle_connection_loss ( self ) :
try :
self . close ( )
except Exception :
pass
try :
self . connect ( )
except Exception as e :
logging . error ( f " Failed to reconnect to PostgreSQL: { e } " )
time . sleep ( 0.1 )
2026-01-18 17:48:10 -08:00
try :
self . connect ( )
except Exception as e2 :
logging . error ( f " Failed to reconnect to PostgreSQL on second attempt: { e2 } " )
raise
2025-10-16 09:08:41 +02:00
def begin ( self ) :
for attempt in range ( self . max_retries + 1 ) :
try :
return super ( ) . begin ( )
except ( OperationalError , InterfaceError ) as e :
2026-07-02 23:22:07 +08:00
error_messages = [ " connection " , " server closed " , " connection refused " , " no connection to the server " , " terminating connection " ]
2025-10-28 19:09:14 +08:00
2025-10-16 09:08:41 +02:00
should_retry = any ( msg in str ( e ) . lower ( ) for msg in error_messages )
if should_retry and attempt < self . max_retries :
2026-07-02 23:22:07 +08:00
logging . warning ( f " PostgreSQL connection lost during transaction (attempt { attempt + 1 } / { self . max_retries } ) " )
2025-10-16 09:08:41 +02:00
self . _handle_connection_loss ( )
2026-07-02 23:22:07 +08:00
time . sleep ( self . retry_delay * ( 2 * * attempt ) )
2025-10-16 09:08:41 +02:00
else :
raise
2025-11-04 14:15:31 +08:00
return None
2025-10-16 09:08:41 +02:00
2026-08-18 09:53:11 +08:00
class RetryingPooledGaussDBDatabase ( GaussDBPsycopgRetryMixin , PooledPostgresqlDatabase ) :
""" Connection pool for the GaussDB business metadata database.
GaussDB exposes a psycopg / libpq - compatible protocol , so Peewee can use
PooledPostgresqlDatabase as its transport . The adapter remains distinct so
DB_TYPE = gaussdb dialect differences , compatibility checks , logging , and
diagnostics stay in this class or GaussDBMigrator instead of aliasing
GaussDB to PostgreSQL .
"""
database_display_name = " GaussDB "
# These tables have both an id primary key and a business unique key that
# does not contain id. Distributed HASH tables cannot enforce both sets of
# constraints globally, so preserve the existing schema with replication.
distributed_replication_tables = frozenset (
{
" compilation_template " ,
" compilation_template_group " ,
" file_commit_item " ,
" tenant_llm " ,
" tenant_model_provider " ,
" user " ,
}
)
def __init__ ( self , * args , * * kwargs ) :
self . _is_distributed = None
# Force UTF-8 so metadata containing non-ASCII text, JSON, or template
# data does not depend on the connection's default encoding.
kwargs . setdefault ( " options " , " -c client_encoding=UTF8 " )
super ( ) . __init__ ( * args , * * kwargs )
def _initialize_connection ( self , conn ) :
""" Initialize a checked-out connection and detect its deployment type. """
super ( ) . _initialize_connection ( conn )
with conn . cursor ( ) as cursor :
cursor . execute ( " SELECT current_schema() " )
row = cursor . fetchone ( )
schema = row [ 0 ] if row else None
if not schema :
raise OperationalError ( " GaussDB metadata search_path has no existing schema " )
# SET is repeated after connection startup because a distributed CN
# may not propagate the libpq search_path option to every DN.
cursor . execute ( psycopg2_sql . SQL ( " SET search_path TO {} " ) . format ( psycopg2_sql . Identifier ( schema ) ) )
try :
cursor . execute ( " SELECT EXISTS (SELECT 1 FROM pg_catalog.pgxc_node WHERE node_type = ' D ' ) " )
topology_row = cursor . fetchone ( )
is_distributed = bool ( topology_row and topology_row [ 0 ] )
except Exception as exc :
# Centralized editions may not expose pgxc_node at all, or may
# reject querying it as an unsupported feature.
if sqlstate_from_exception ( exc ) not in { " 0A000 " , " 42P01 " } :
raise
rollback = getattr ( conn , " rollback " , None )
if callable ( rollback ) :
rollback ( )
# A failed catalog probe can abort the startup transaction and
# roll back the preceding SET. Restore the configured schema so
# the centralized connection remains immediately usable.
cursor . execute ( psycopg2_sql . SQL ( " SET search_path TO {} " ) . format ( psycopg2_sql . Identifier ( schema ) ) )
is_distributed = False
if self . _is_distributed is not None and self . _is_distributed != is_distributed :
raise RuntimeError ( " GaussDB deployment topology changed across pooled connections " )
if self . _is_distributed is None :
logging . info (
" Detected %s GaussDB metadata deployment " ,
" distributed " if is_distributed else " centralized " ,
)
self . _is_distributed = is_distributed
@property
def is_distributed ( self ) :
if self . _is_distributed is None :
raise RuntimeError ( " GaussDB deployment topology is unavailable before connection initialization " )
return self . _is_distributed
def _ensure_topology ( self ) :
if self . _is_distributed is None :
# This goes through GaussDBPsycopgRetryMixin so a transient first-connect
# failure gets the same retry treatment as any other SQL execution.
self . execute_sql ( " SELECT 1 " )
def prepare_update_by_id ( self , model , pid , data ) :
""" Remove only redundant primary-key assignments on distributed GaussDB. """
self . _ensure_topology ( )
if not self . is_distributed :
return data
primary_key = model . _meta . primary_key
if isinstance ( primary_key , CompositeKey ) or primary_key . name not in data :
return data
field_name = primary_key . name
locator_value = primary_key . db_value ( pid )
update_value = primary_key . db_value ( data [ field_name ] )
if locator_value != update_value :
return data
prepared = data . copy ( )
del prepared [ field_name ]
return prepared
def replace_update_by_id ( self , model , pid , data ) :
""" Replace a row when distributed GaussDB must change its primary key.
A distributed table commonly uses its primary key as the distribution
key , which GaussDB does not allow an UPDATE statement to modify . Keep
the generic UPDATE path for centralized GaussDB and for updates that do
not really change the key . A real key change is performed atomically as
delete plus insert while preserving every existing column .
Return ` ` None ` ` when the caller should execute its normal UPDATE , or
the affected - row count when this method handled the replacement .
"""
self . _ensure_topology ( )
if not self . is_distributed :
return None
primary_key = model . _meta . primary_key
if isinstance ( primary_key , CompositeKey ) or primary_key . name not in data :
return None
field_name = primary_key . name
locator_value = primary_key . db_value ( pid )
update_value = primary_key . db_value ( data [ field_name ] )
if locator_value == update_value :
return None
with self . atomic ( ) :
record = model . get_or_none ( primary_key == pid )
if record is None :
return 0
replacement = record . __data__ . copy ( )
replacement . update ( data )
deleted = model . delete ( ) . where ( primary_key == pid ) . execute ( )
if deleted != 1 :
return deleted
# Bypass BaseModel.insert(), which refreshes create_time. Replacing
# a distribution key must retain the original row's creation data.
ModelInsert ( model , replacement ) . execute ( )
return deleted
def _prepare_distributed_ddl ( self , sql ) :
if not self . is_distributed or not isinstance ( sql , str ) :
return sql
statement = sql . rstrip ( )
trailing_whitespace = sql [ len ( statement ) : ]
terminator = " ; " if statement . endswith ( " ; " ) else " "
if terminator :
statement = statement [ : - 1 ] . rstrip ( )
for table_name in self . distributed_replication_tables :
quoted_name = f ' " { table_name } " '
if statement . startswith ( f " CREATE TABLE IF NOT EXISTS { quoted_name } " ) or statement . startswith ( f " CREATE TABLE { quoted_name } " ) :
return f " { statement } DISTRIBUTE BY REPLICATION { terminator } { trailing_whitespace } "
return sql
def _prepare_sql_for_execution ( self , sql ) :
# Topology initialization intentionally runs inside the retry loop in
# GaussDBPsycopgRetryMixin. This also ensures the first CREATE TABLE receives
# its distributed table clause before Peewee executes it.
if self . _is_distributed is None :
self . connection ( )
return self . _prepare_distributed_ddl ( sql )
def get_tables ( self , schema = None ) :
if schema is not None :
return super ( ) . get_tables ( schema )
cursor = self . execute_sql ( " SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = current_schema() ORDER BY tablename " )
return [ table for ( table , ) in cursor . fetchall ( ) ]
2026-01-31 15:45:20 +08:00
class RetryingPooledOceanBaseDatabase ( PooledMySQLDatabase ) :
""" Pooled OceanBase database with retry mechanism.
OceanBase is compatible with MySQL protocol , so we inherit from PooledMySQLDatabase .
This class provides connection pooling and automatic retry for connection issues .
"""
2026-07-02 23:22:07 +08:00
2026-01-31 15:45:20 +08:00
def __init__ ( self , * args , * * kwargs ) :
self . max_retries = kwargs . pop ( " max_retries " , 5 )
self . retry_delay = kwargs . pop ( " retry_delay " , 1 )
super ( ) . __init__ ( * args , * * kwargs )
def execute_sql ( self , sql , params = None , commit = True ) :
for attempt in range ( self . max_retries + 1 ) :
try :
return super ( ) . execute_sql ( sql , params , commit )
except ( OperationalError , InterfaceError ) as e :
# OceanBase/MySQL specific error codes
# 2013: Lost connection to MySQL server during query
# 2006: MySQL server has gone away
error_codes = [ 2013 , 2006 ]
2026-07-02 23:22:07 +08:00
error_messages = [ " " , " Lost connection " , " gone away " ]
2026-01-31 15:45:20 +08:00
should_retry = (
2026-07-02 23:22:07 +08:00
( hasattr ( e , " args " ) and e . args and e . args [ 0 ] in error_codes )
or any ( msg in str ( e ) . lower ( ) for msg in error_messages )
or ( hasattr ( e , " __class__ " ) and e . __class__ . __name__ == " InterfaceError " )
2026-01-31 15:45:20 +08:00
)
if should_retry and attempt < self . max_retries :
2026-07-02 23:22:07 +08:00
logging . warning ( f " OceanBase connection issue (attempt { attempt + 1 } / { self . max_retries } ): { e } " )
2026-01-31 15:45:20 +08:00
self . _handle_connection_loss ( )
2026-07-02 23:22:07 +08:00
time . sleep ( self . retry_delay * ( 2 * * attempt ) )
2026-01-31 15:45:20 +08:00
else :
logging . error ( f " OceanBase execution failure: { e } " )
raise
return None
def _handle_connection_loss ( self ) :
try :
self . close ( )
except Exception :
pass
try :
self . connect ( )
except Exception as e :
logging . error ( f " Failed to reconnect to OceanBase: { e } " )
time . sleep ( 0.1 )
try :
self . connect ( )
except Exception as e2 :
logging . error ( f " Failed to reconnect to OceanBase on second attempt: { e2 } " )
raise
def begin ( self ) :
for attempt in range ( self . max_retries + 1 ) :
try :
return super ( ) . begin ( )
except ( OperationalError , InterfaceError ) as e :
error_codes = [ 2013 , 2006 ]
2026-07-02 23:22:07 +08:00
error_messages = [ " " , " Lost connection " ]
2026-01-31 15:45:20 +08:00
2026-07-02 23:22:07 +08:00
should_retry = ( hasattr ( e , " args " ) and e . args and e . args [ 0 ] in error_codes ) or ( str ( e ) in error_messages ) or ( hasattr ( e , " __class__ " ) and e . __class__ . __name__ == " InterfaceError " )
2026-01-31 15:45:20 +08:00
if should_retry and attempt < self . max_retries :
2026-07-02 23:22:07 +08:00
logging . warning ( f " Lost connection during transaction (attempt { attempt + 1 } / { self . max_retries } ) " )
2026-01-31 15:45:20 +08:00
self . _handle_connection_loss ( )
2026-07-02 23:22:07 +08:00
time . sleep ( self . retry_delay * ( 2 * * attempt ) )
2026-01-31 15:45:20 +08:00
else :
raise
return None
2024-09-12 15:12:39 +08:00
class PooledDatabase ( Enum ) :
2025-07-15 19:05:48 +08:00
MYSQL = RetryingPooledMySQLDatabase
2026-01-31 15:45:20 +08:00
OCEANBASE = RetryingPooledOceanBaseDatabase
2025-10-16 09:08:41 +02:00
POSTGRES = RetryingPooledPostgresqlDatabase
2026-08-18 09:53:11 +08:00
# Keep GaussDB distinct instead of rewriting settings.DATABASE_TYPE to
# postgres so logs, health checks, and Admin report the real database type.
GAUSSDB = RetryingPooledGaussDBDatabase
class GaussDBMigrator ( PostgresqlMigrator ) :
# Peewee has no built-in GaussDB migrator. Its standard ADD COLUMN, ALTER
# COLUMN, and ADD INDEX statements are compatible with those generated by
# PostgresqlMigrator. Keep a separate class so GaussDB-specific DDL can be
# added without changing DB_TYPE=postgres.
pass
2024-09-12 15:12:39 +08:00
class DatabaseMigrator ( Enum ) :
MYSQL = MySQLMigrator
2026-01-31 15:45:20 +08:00
OCEANBASE = MySQLMigrator
2024-09-12 15:12:39 +08:00
POSTGRES = PostgresqlMigrator
2026-08-18 09:53:11 +08:00
# Route DB_TYPE=gaussdb through its own migrator entry even though the
# current DDL implementation derives from PostgresqlMigrator.
GAUSSDB = GaussDBMigrator
2024-09-12 15:12:39 +08:00
2024-08-15 09:17:36 +08:00
@singleton
class BaseDataBase :
def __init__ ( self ) :
2024-11-15 17:30:56 +08:00
database_config = settings . DATABASE . copy ( )
2024-08-15 09:17:36 +08:00
db_name = database_config . pop ( " name " )
2025-10-28 19:09:14 +08:00
2025-09-25 17:03:43 +08:00
pool_config = {
2026-07-02 23:22:07 +08:00
" max_retries " : 5 ,
" retry_delay " : 1 ,
2025-09-25 17:03:43 +08:00
}
2026-08-18 09:53:11 +08:00
# Retry settings are runtime behavior shared by the database adapters,
# not database configuration fields. GaussDBPsycopgRetryMixin uses
# SQLSTATE to decide whether an operation is retryable.
2025-09-25 17:03:43 +08:00
database_config . update ( pool_config )
2026-07-02 23:22:07 +08:00
self . database_connection = PooledDatabase [ settings . DATABASE_TYPE . upper ( ) ] . value ( db_name , * * database_config )
2025-09-25 17:03:43 +08:00
# self.database_connection = PooledDatabase[settings.DATABASE_TYPE.upper()].value(db_name, **database_config)
2025-03-24 13:18:47 +08:00
logging . info ( " init database on cluster mode successfully " )
2024-09-12 15:12:39 +08:00
2024-11-19 14:51:33 +08:00
2025-03-25 15:09:56 +08:00
def with_retry ( max_retries = 3 , retry_delay = 1.0 ) :
""" Decorator: Add retry mechanism to database operations
2025-06-18 16:45:42 +08:00
2025-03-25 15:09:56 +08:00
Args :
max_retries ( int ) : maximum number of retries
retry_delay ( float ) : initial retry delay ( seconds ) , will increase exponentially
2025-06-18 16:45:42 +08:00
2025-03-25 15:09:56 +08:00
Returns :
decorated function
"""
2025-06-18 16:45:42 +08:00
2025-03-25 15:09:56 +08:00
def decorator ( func ) :
@wraps ( func )
def wrapper ( * args , * * kwargs ) :
last_exception = None
for retry in range ( max_retries ) :
try :
return func ( * args , * * kwargs )
except Exception as e :
last_exception = e
# get self and method name for logging
self_obj = args [ 0 ] if args else None
func_name = func . __name__
2025-06-18 16:45:42 +08:00
lock_name = getattr ( self_obj , " lock_name " , " unknown " ) if self_obj else " unknown "
2025-03-25 15:09:56 +08:00
if retry < max_retries - 1 :
2025-06-18 16:45:42 +08:00
current_delay = retry_delay * ( 2 * * retry )
logging . warning ( f " { func_name } { lock_name } failed: { str ( e ) } , retrying ( { retry + 1 } / { max_retries } ) " )
2025-03-25 15:09:56 +08:00
time . sleep ( current_delay )
else :
logging . error ( f " { func_name } { lock_name } failed after all attempts: { str ( e ) } " )
2025-06-18 16:45:42 +08:00
2025-03-25 15:09:56 +08:00
if last_exception :
raise last_exception
return False
2025-06-18 16:45:42 +08:00
2025-03-25 15:09:56 +08:00
return wrapper
2025-06-18 16:45:42 +08:00
2025-03-25 15:09:56 +08:00
return decorator
2024-09-12 15:12:39 +08:00
class PostgresDatabaseLock :
def __init__ ( self , lock_name , timeout = 10 , db = None ) :
self . lock_name = lock_name
2025-06-18 16:45:42 +08:00
self . lock_id = int ( hashlib . md5 ( lock_name . encode ( ) ) . hexdigest ( ) , 16 ) % ( 2 * * 31 - 1 )
2024-09-12 15:12:39 +08:00
self . timeout = int ( timeout )
self . db = db if db else DB
2025-03-25 15:09:56 +08:00
@with_retry ( max_retries = 3 , retry_delay = 1.0 )
2024-09-12 15:12:39 +08:00
def lock ( self ) :
2025-03-25 15:09:56 +08:00
cursor = self . db . execute_sql ( " SELECT pg_try_advisory_lock( %s ) " , ( self . lock_id , ) )
2024-09-12 15:12:39 +08:00
ret = cursor . fetchone ( )
if ret [ 0 ] == 0 :
2025-03-24 13:18:47 +08:00
raise Exception ( f " acquire postgres lock { self . lock_name } timeout " )
2024-09-12 15:12:39 +08:00
elif ret [ 0 ] == 1 :
return True
else :
2025-03-24 13:18:47 +08:00
raise Exception ( f " failed to acquire lock { self . lock_name } " )
2024-09-12 15:12:39 +08:00
2025-03-25 15:09:56 +08:00
@with_retry ( max_retries = 3 , retry_delay = 1.0 )
2024-09-12 15:12:39 +08:00
def unlock ( self ) :
2025-03-25 15:09:56 +08:00
cursor = self . db . execute_sql ( " SELECT pg_advisory_unlock( %s ) " , ( self . lock_id , ) )
2024-09-12 15:12:39 +08:00
ret = cursor . fetchone ( )
if ret [ 0 ] == 0 :
2025-03-24 13:18:47 +08:00
raise Exception ( f " postgres lock { self . lock_name } was not established by this thread " )
2024-09-12 15:12:39 +08:00
elif ret [ 0 ] == 1 :
return True
else :
2025-03-24 13:18:47 +08:00
raise Exception ( f " postgres lock { self . lock_name } does not exist " )
2024-08-15 09:17:36 +08:00
2024-09-12 15:12:39 +08:00
def __enter__ ( self ) :
2025-03-25 15:09:56 +08:00
if isinstance ( self . db , PooledPostgresqlDatabase ) :
2024-09-12 15:12:39 +08:00
self . lock ( )
return self
def __exit__ ( self , exc_type , exc_val , exc_tb ) :
2025-03-25 15:09:56 +08:00
if isinstance ( self . db , PooledPostgresqlDatabase ) :
2024-09-12 15:12:39 +08:00
self . unlock ( )
2024-08-15 09:17:36 +08:00
2024-09-12 15:12:39 +08:00
def __call__ ( self , func ) :
@wraps ( func )
def magic ( * args , * * kwargs ) :
with self :
return func ( * args , * * kwargs )
return magic
2024-11-19 14:51:33 +08:00
2026-08-18 09:53:11 +08:00
class GaussDBDatabaseLock ( PostgresDatabaseLock ) :
# GaussDB supports pg_try_advisory_lock/pg_advisory_unlock, so its current
# lock SQL matches PostgreSQL. Keep a distinct class so future lock APIs,
# timeout behavior, or error handling remain isolated.
database_label = " gaussdb "
poll_interval = 0.1
def __init__ ( self , lock_name , timeout = 10 , db = None ) :
super ( ) . __init__ ( lock_name , timeout = timeout , db = db )
self . timeout = max ( float ( timeout ) , 0.0 )
def lock ( self ) :
deadline = time . monotonic ( ) + self . timeout
while True :
cursor = self . db . execute_sql ( " SELECT pg_try_advisory_lock( %s ) " , ( self . lock_id , ) )
row = cursor . fetchone ( )
value = row [ 0 ] if row else None
if value in ( 1 , True ) :
return True
if value not in ( 0 , False ) :
raise Exception ( f " failed to acquire lock { self . lock_name } " )
remaining = deadline - time . monotonic ( )
if remaining < = 0 :
raise Exception ( f " acquire { self . database_label } lock { self . lock_name } timeout " )
time . sleep ( min ( self . poll_interval , remaining ) )
@with_retry ( max_retries = 3 , retry_delay = 1.0 )
def unlock ( self ) :
cursor = self . db . execute_sql ( " SELECT pg_advisory_unlock( %s ) " , ( self . lock_id , ) )
ret = cursor . fetchone ( )
if ret [ 0 ] == 0 :
raise Exception ( f " gaussdb lock { self . lock_name } was not established by this thread " )
if ret [ 0 ] == 1 :
return True
raise Exception ( f " gaussdb lock { self . lock_name } does not exist " )
2024-09-12 15:12:39 +08:00
class MysqlDatabaseLock :
2024-08-15 09:17:36 +08:00
def __init__ ( self , lock_name , timeout = 10 , db = None ) :
self . lock_name = lock_name
self . timeout = int ( timeout )
self . db = db if db else DB
2025-03-25 15:09:56 +08:00
@with_retry ( max_retries = 3 , retry_delay = 1.0 )
2024-08-15 09:17:36 +08:00
def lock ( self ) :
# SQL parameters only support %s format placeholders
2025-03-24 13:18:47 +08:00
cursor = self . db . execute_sql ( " SELECT GET_LOCK( %s , %s ) " , ( self . lock_name , self . timeout ) )
2024-08-15 09:17:36 +08:00
ret = cursor . fetchone ( )
if ret [ 0 ] == 0 :
2025-03-24 13:18:47 +08:00
raise Exception ( f " acquire mysql lock { self . lock_name } timeout " )
2024-08-15 09:17:36 +08:00
elif ret [ 0 ] == 1 :
return True
else :
2025-03-24 13:18:47 +08:00
raise Exception ( f " failed to acquire lock { self . lock_name } " )
2024-08-15 09:17:36 +08:00
2025-03-25 15:09:56 +08:00
@with_retry ( max_retries = 3 , retry_delay = 1.0 )
2024-08-15 09:17:36 +08:00
def unlock ( self ) :
2025-03-24 13:18:47 +08:00
cursor = self . db . execute_sql ( " SELECT RELEASE_LOCK( %s ) " , ( self . lock_name , ) )
2024-08-15 09:17:36 +08:00
ret = cursor . fetchone ( )
if ret [ 0 ] == 0 :
2025-03-24 13:18:47 +08:00
raise Exception ( f " mysql lock { self . lock_name } was not established by this thread " )
2024-08-15 09:17:36 +08:00
elif ret [ 0 ] == 1 :
return True
else :
2025-03-24 13:18:47 +08:00
raise Exception ( f " mysql lock { self . lock_name } does not exist " )
2024-08-15 09:17:36 +08:00
def __enter__ ( self ) :
if isinstance ( self . db , PooledMySQLDatabase ) :
self . lock ( )
return self
def __exit__ ( self , exc_type , exc_val , exc_tb ) :
if isinstance ( self . db , PooledMySQLDatabase ) :
self . unlock ( )
def __call__ ( self , func ) :
@wraps ( func )
def magic ( * args , * * kwargs ) :
with self :
return func ( * args , * * kwargs )
return magic
2024-09-12 15:12:39 +08:00
class DatabaseLock ( Enum ) :
MYSQL = MysqlDatabaseLock
2026-01-31 15:45:20 +08:00
OCEANBASE = MysqlDatabaseLock
2024-09-12 15:12:39 +08:00
POSTGRES = PostgresDatabaseLock
2026-08-18 09:53:11 +08:00
# Initialization and migrations require cross-process exclusion. GaussDB
# uses advisory locks instead of MySQL GET_LOCK/RELEASE_LOCK while retaining
# a distinct enum entry from PostgreSQL.
GAUSSDB = GaussDBDatabaseLock
2024-09-12 15:12:39 +08:00
2024-08-15 09:17:36 +08:00
DB = BaseDataBase ( ) . database_connection
2024-11-15 17:30:56 +08:00
DB . lock = DatabaseLock [ settings . DATABASE_TYPE . upper ( ) ] . value
2024-08-15 09:17:36 +08:00
def close_connection ( ) :
try :
if DB :
2026-08-18 09:53:11 +08:00
if settings . DATABASE_TYPE . upper ( ) in GAUSSDB_COMPATIBLE_DATABASE_TYPES :
if not DB . is_closed ( ) :
# Return this worker thread's connection to the GaussDB pool.
DB . close ( )
else :
DB . close_stale ( age = 30 )
2024-08-15 09:17:36 +08:00
except Exception as e :
2024-11-14 17:13:48 +08:00
logging . exception ( e )
2024-08-15 09:17:36 +08:00
class DataBaseModel ( BaseModel ) :
class Meta :
database = DB
@DB.connection_context ( )
2025-07-30 19:41:09 +08:00
@DB.lock ( " init_database_tables " , 60 )
2024-08-15 09:17:36 +08:00
def init_database_tables ( alter_fields = [ ] ) :
members = inspect . getmembers ( sys . modules [ __name__ ] , inspect . isclass )
table_objs = [ ]
create_failed_list = [ ]
for name , obj in members :
if obj != DataBaseModel and issubclass ( obj , DataBaseModel ) :
table_objs . append ( obj )
Fix table migration on non-exist-yet indexed columns. (#6666)
### What problem does this PR solve?
Fix #6334
Hello, I encountered the same problem in #6334. In the
`api/db/db_models.py`, it calls `obj.create_table()` unconditionally in
`init_database_tables`, before the `migrate_db()`. Specially for the
`permission` field of `user_canvas` table, it has `index=True`, which
causes `peewee` to issue a SQL trying to create the index when the field
does not exist (the `user_canvas` table already exists), so
`psycopg2.errors.UndefinedColumn: column "permission" does not exist`
occurred.
I've added a judgement in the code, to only call `create_table()` when
the table does not exist, delegate the migration process to
`migrate_db()`.
Then another problem occurs: the `migrate_db()` actually does nothing
because it failed on the first migration! The `playhouse` blindly issue
DDLs without things like `IF NOT EXISTS`, so it fails... even if the
exception is `pass`, the transaction is still rolled back. So I removed
the transaction in `migrate_db()` to make it work.
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [ ] New Feature (non-breaking change which adds functionality)
- [ ] Documentation Update
- [ ] Refactoring
- [ ] Performance Improvement
- [ ] Other (please describe):
2025-03-31 11:27:20 +08:00
if not obj . table_exists ( ) :
logging . debug ( f " start create table { obj . __name__ } " )
try :
2025-07-30 19:41:09 +08:00
obj . create_table ( safe = True )
Fix table migration on non-exist-yet indexed columns. (#6666)
### What problem does this PR solve?
Fix #6334
Hello, I encountered the same problem in #6334. In the
`api/db/db_models.py`, it calls `obj.create_table()` unconditionally in
`init_database_tables`, before the `migrate_db()`. Specially for the
`permission` field of `user_canvas` table, it has `index=True`, which
causes `peewee` to issue a SQL trying to create the index when the field
does not exist (the `user_canvas` table already exists), so
`psycopg2.errors.UndefinedColumn: column "permission" does not exist`
occurred.
I've added a judgement in the code, to only call `create_table()` when
the table does not exist, delegate the migration process to
`migrate_db()`.
Then another problem occurs: the `migrate_db()` actually does nothing
because it failed on the first migration! The `playhouse` blindly issue
DDLs without things like `IF NOT EXISTS`, so it fails... even if the
exception is `pass`, the transaction is still rolled back. So I removed
the transaction in `migrate_db()` to make it work.
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [ ] New Feature (non-breaking change which adds functionality)
- [ ] Documentation Update
- [ ] Refactoring
- [ ] Performance Improvement
- [ ] Other (please describe):
2025-03-31 11:27:20 +08:00
logging . debug ( f " create table success: { obj . __name__ } " )
except Exception as e :
logging . exception ( e )
create_failed_list . append ( obj . __name__ )
else :
logging . debug ( f " table { obj . __name__ } already exists, skip creation. " )
2024-08-15 09:17:36 +08:00
if create_failed_list :
2024-11-14 17:13:48 +08:00
logging . error ( f " create tables failed: { create_failed_list } " )
2024-08-15 09:17:36 +08:00
raise Exception ( f " create tables failed: { create_failed_list } " )
migrate_db ( )
def fill_db_model_object ( model_object , human_model_dict ) :
for k , v in human_model_dict . items ( ) :
2025-03-24 13:18:47 +08:00
attr_name = " %s " % k
2024-08-15 09:17:36 +08:00
if hasattr ( model_object . __class__ , attr_name ) :
setattr ( model_object , attr_name , v )
return model_object
2025-11-18 17:05:16 +08:00
class User ( DataBaseModel , AuthUser ) :
2026-05-22 00:14:26 -07:00
SENSITIVE_FIELDS = { " password " , " access_token " , " email " }
2024-08-15 09:17:36 +08:00
id = CharField ( max_length = 32 , primary_key = True )
access_token = CharField ( max_length = 255 , null = True , index = True )
2026-08-18 09:53:11 +08:00
# A/ORA-compatible GaussDB stores the application-level empty string as
# NULL. Admin retains nickname="" while the field restores that value when
# reading from storage.
nickname = EmptyStringCharField ( max_length = 100 , null = False , help_text = " nicky name " , index = True )
2024-08-15 09:17:36 +08:00
password = CharField ( max_length = 255 , null = True , help_text = " password " , index = True )
2026-02-27 12:55:51 +01:00
email = CharField ( max_length = 255 , null = False , help_text = " email " , unique = True )
2024-08-15 09:17:36 +08:00
avatar = TextField ( null = True , help_text = " avatar base64 string " )
2025-03-24 13:18:47 +08:00
language = CharField ( max_length = 32 , null = True , help_text = " English|Chinese " , default = " Chinese " if " zh_CN " in os . getenv ( " LANG " , " " ) else " English " , index = True )
color_schema = CharField ( max_length = 32 , null = True , help_text = " Bright|Dark " , default = " Bright " , index = True )
timezone = CharField ( max_length = 64 , null = True , help_text = " Timezone " , default = " UTC+8 \t Asia/Shanghai " , index = True )
2024-08-15 09:17:36 +08:00
last_login_time = DateTimeField ( null = True , index = True )
is_authenticated = CharField ( max_length = 1 , null = False , default = " 1 " , index = True )
is_active = CharField ( max_length = 1 , null = False , default = " 1 " , index = True )
is_anonymous = CharField ( max_length = 1 , null = False , default = " 0 " , index = True )
login_channel = CharField ( null = True , help_text = " from which user login " , index = True )
2025-03-24 13:18:47 +08:00
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
2024-08-15 09:17:36 +08:00
is_superuser = BooleanField ( null = True , help_text = " is root " , default = False , index = True )
def __str__ ( self ) :
return self . email
def get_id ( self ) :
2026-05-07 10:10:02 +08:00
jwt = Serializer ( secret_key = settings . get_secret_key ( ) )
2024-08-15 09:17:36 +08:00
return jwt . dumps ( str ( self . access_token ) )
2026-05-22 00:14:26 -07:00
def to_safe_dict ( self , * , for_self : bool = False ) :
""" Return a dict with sensitive fields stripped for API responses.
Email is treated as sensitive in generic serialization . Pass for_self = True
when returning the authenticated user ' s own record (login, profile, etc.).
"""
result = { k : v for k , v in self . to_dict ( ) . items ( ) if k not in self . SENSITIVE_FIELDS }
if for_self :
result [ " email " ] = self . email
logging . debug ( " User %s serialized safely, filtered fields: %s " , self . id , self . SENSITIVE_FIELDS )
return result
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " user "
class Tenant ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
name = CharField ( max_length = 100 , null = True , help_text = " Tenant name " , index = True )
public_key = CharField ( max_length = 255 , null = True , index = True )
2026-08-18 09:53:11 +08:00
# These default model IDs historically use "" for an unconfigured value.
# EmptyStringCharField maps GaussDB storage NULL back to that application
# value.
llm_id = EmptyStringCharField ( max_length = 128 , null = False , help_text = " default llm ID " , index = True )
2026-07-08 09:47:29 +08:00
tenant_llm_id = CharField ( max_length = 32 , null = True , help_text = " id in tenant_model " , index = True )
2026-08-18 09:53:11 +08:00
embd_id = EmptyStringCharField ( max_length = 128 , null = False , help_text = " default embedding model ID " , index = True )
2026-07-08 09:47:29 +08:00
tenant_embd_id = CharField ( max_length = 32 , null = True , help_text = " id in tenant_model " , index = True )
2026-08-18 09:53:11 +08:00
asr_id = EmptyStringCharField ( max_length = 128 , null = False , help_text = " default ASR model ID " , index = True )
2026-07-08 09:47:29 +08:00
tenant_asr_id = CharField ( max_length = 32 , null = True , help_text = " id in tenant_model " , index = True )
2026-08-18 09:53:11 +08:00
img2txt_id = EmptyStringCharField ( max_length = 128 , null = False , help_text = " default image to text model ID " , index = True )
2026-07-08 09:47:29 +08:00
tenant_img2txt_id = CharField ( max_length = 32 , null = True , help_text = " id in tenant_model " , index = True )
2026-08-18 09:53:11 +08:00
rerank_id = EmptyStringCharField ( max_length = 128 , null = False , help_text = " default rerank model ID " , index = True )
2026-07-08 09:47:29 +08:00
tenant_rerank_id = CharField ( max_length = 32 , null = True , help_text = " id in tenant_model " , index = True )
2025-03-24 13:18:47 +08:00
tts_id = CharField ( max_length = 256 , null = True , help_text = " default tts model ID " , index = True )
2026-07-08 09:47:29 +08:00
tenant_tts_id = CharField ( max_length = 32 , null = True , help_text = " id in tenant_model " , index = True )
2026-05-29 17:39:41 +08:00
ocr_id = CharField ( max_length = 256 , null = True , help_text = " default OCR model ID " , index = True )
2026-07-08 09:47:29 +08:00
tenant_ocr_id = CharField ( max_length = 32 , null = True , help_text = " id in tenant_model " , index = True )
2025-03-24 13:18:47 +08:00
parser_ids = CharField ( max_length = 256 , null = False , help_text = " document processors " , index = True )
2024-08-15 09:17:36 +08:00
credit = IntegerField ( default = 512 , index = True )
2025-03-24 13:18:47 +08:00
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " tenant "
class UserTenant ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
user_id = CharField ( max_length = 32 , null = False , index = True )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
role = CharField ( max_length = 32 , null = False , help_text = " UserTenantRole " , index = True )
invited_by = CharField ( max_length = 32 , null = False , index = True )
2025-03-24 13:18:47 +08:00
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " user_tenant "
class InvitationCode ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
code = CharField ( max_length = 32 , null = False , index = True )
visit_time = DateTimeField ( null = True , index = True )
user_id = CharField ( max_length = 32 , null = True , index = True )
tenant_id = CharField ( max_length = 32 , null = True , index = True )
2025-03-24 13:18:47 +08:00
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " invitation_code "
class LLMFactories ( DataBaseModel ) :
2025-03-24 13:18:47 +08:00
name = CharField ( max_length = 128 , null = False , help_text = " LLM factory name " , primary_key = True )
2024-08-15 09:17:36 +08:00
logo = TextField ( null = True , help_text = " llm logo base64 " )
2025-03-24 13:18:47 +08:00
tags = CharField ( max_length = 255 , null = False , help_text = " LLM, Text Embedding, Image2Text, ASR " , index = True )
2025-11-10 13:28:07 +08:00
rank = IntegerField ( default = 0 , index = False )
2025-03-24 13:18:47 +08:00
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
2024-08-15 09:17:36 +08:00
def __str__ ( self ) :
return self . name
class Meta :
db_table = " llm_factories "
class LLM ( DataBaseModel ) :
# LLMs dictionary
2025-03-24 13:18:47 +08:00
llm_name = CharField ( max_length = 128 , null = False , help_text = " LLM name " , index = True )
model_type = CharField ( max_length = 128 , null = False , help_text = " LLM, Text Embedding, Image2Text, ASR " , index = True )
2024-08-15 09:17:36 +08:00
fid = CharField ( max_length = 128 , null = False , help_text = " LLM factory id " , index = True )
max_tokens = IntegerField ( default = 0 )
2025-03-24 13:18:47 +08:00
tags = CharField ( max_length = 255 , null = False , help_text = " LLM, Text Embedding, Image2Text, Chat, 32k... " , index = True )
2025-06-18 16:45:42 +08:00
is_tools = BooleanField ( null = False , help_text = " support tools " , default = False )
2025-03-24 13:18:47 +08:00
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
2024-08-15 09:17:36 +08:00
def __str__ ( self ) :
return self . llm_name
class Meta :
2025-03-24 13:18:47 +08:00
primary_key = CompositeKey ( " fid " , " llm_name " )
2024-08-15 09:17:36 +08:00
db_table = " llm "
class TenantLLM ( DataBaseModel ) :
tenant_id = CharField ( max_length = 32 , null = False , index = True )
2025-03-24 13:18:47 +08:00
llm_factory = CharField ( max_length = 128 , null = False , help_text = " LLM factory name " , index = True )
model_type = CharField ( max_length = 128 , null = True , help_text = " LLM, Text Embedding, Image2Text, ASR " , index = True )
llm_name = CharField ( max_length = 128 , null = True , help_text = " LLM name " , default = " " , index = True )
2025-10-10 13:18:24 +02:00
api_key = TextField ( null = True , help_text = " API KEY " )
2024-08-15 09:17:36 +08:00
api_base = CharField ( max_length = 255 , null = True , help_text = " API Base " )
2026-03-05 17:27:17 +08:00
max_tokens = IntegerField ( default = 8192 , help_text = " Max context token num " , index = True )
used_tokens = IntegerField ( default = 0 , help_text = " Used token num " , index = True )
2025-11-03 19:59:18 +08:00
status = CharField ( max_length = 1 , null = False , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
2024-08-15 09:17:36 +08:00
def __str__ ( self ) :
return self . llm_name
class Meta :
db_table = " tenant_llm "
2026-07-02 23:22:07 +08:00
indexes = ( ( ( " tenant_id " , " llm_factory " , " llm_name " ) , True ) , )
2025-03-24 13:18:47 +08:00
class TenantLangfuse ( DataBaseModel ) :
tenant_id = CharField ( max_length = 32 , null = False , primary_key = True )
2026-07-15 23:05:06 +08:00
secret_key = CharField ( max_length = 2048 , null = False , help_text = " SECRET KEY " )
public_key = CharField ( max_length = 2048 , null = False , help_text = " PUBLIC KEY " )
host = CharField ( max_length = 128 , null = False , help_text = " HOST " )
2025-03-24 13:18:47 +08:00
def __str__ ( self ) :
return " Langfuse host " + self . host
class Meta :
db_table = " tenant_langfuse "
2024-08-15 09:17:36 +08:00
class Knowledgebase ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
avatar = TextField ( null = True , help_text = " avatar base64 string " )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
2025-03-24 13:18:47 +08:00
name = CharField ( max_length = 128 , null = False , help_text = " KB name " , index = True )
language = CharField ( max_length = 32 , null = True , default = " Chinese " if " zh_CN " in os . getenv ( " LANG " , " " ) else " English " , help_text = " English|Chinese " , index = True )
2024-08-15 09:17:36 +08:00
description = TextField ( null = True , help_text = " KB description " )
2026-08-18 09:53:11 +08:00
# A dataset may inherit an application-level empty string. GaussDB stores it
# as NULL and restores it to "" when read.
embd_id = EmptyStringCharField ( max_length = 128 , null = False , help_text = " default embedding model ID " , index = True )
2026-07-08 09:47:29 +08:00
tenant_embd_id = CharField ( max_length = 32 , null = True , help_text = " id in tenant_model " , index = True )
2025-03-24 13:18:47 +08:00
permission = CharField ( max_length = 16 , null = False , help_text = " me|team " , default = " me " , index = True )
2024-08-15 09:17:36 +08:00
created_by = CharField ( max_length = 32 , null = False , index = True )
doc_num = IntegerField ( default = 0 , index = True )
token_num = IntegerField ( default = 0 , index = True )
chunk_num = IntegerField ( default = 0 , index = True )
similarity_threshold = FloatField ( default = 0.2 , index = True )
vector_similarity_weight = FloatField ( default = 0.3 , index = True )
2025-03-24 13:18:47 +08:00
parser_id = CharField ( max_length = 32 , null = False , help_text = " default parser ID " , default = ParserType . NAIVE . value , index = True )
2025-10-09 12:36:19 +08:00
pipeline_id = CharField ( max_length = 32 , null = True , help_text = " Pipeline ID " , index = True )
2025-11-27 10:21:44 +08:00
parser_config = JSONField ( null = False , default = { " pages " : [ [ 1 , 1000000 ] ] , " table_context_size " : 0 , " image_context_size " : 0 } )
2024-12-03 14:30:35 +08:00
pagerank = IntegerField ( default = 0 , index = False )
2025-10-09 12:36:19 +08:00
graphrag_task_id = CharField ( max_length = 32 , null = True , help_text = " Graph RAG task ID " , index = True )
graphrag_task_finish_at = DateTimeField ( null = True )
raptor_task_id = CharField ( max_length = 32 , null = True , help_text = " RAPTOR task ID " , index = True )
raptor_task_finish_at = DateTimeField ( null = True )
mindmap_task_id = CharField ( max_length = 32 , null = True , help_text = " Mindmap task ID " , index = True )
mindmap_task_finish_at = DateTimeField ( null = True )
2026-07-30 11:06:16 +08:00
wiki_task_id = CharField ( max_length = 32 , null = True , help_text = " Artifact compilation task ID " , index = True )
wiki_task_finish_at = DateTimeField ( null = True )
2026-07-02 23:22:07 +08:00
skill_task_id = CharField ( max_length = 32 , null = True , help_text = " Skill generation task ID " , index = True )
skill_task_finish_at = DateTimeField ( null = True )
2026-07-23 18:39:16 +08:00
# KB-wide structure-graph merge tasks, one traceable task id per merged kind
# (rebuild_dataset_structure_graph_json). ``structure_task_id`` is the
# merge-all variant that rebuilds every dataset-merge kind at once.
structure_graph_task_id = CharField ( max_length = 32 , null = True , help_text = " Structure graph merge task ID " , index = True )
structure_graph_task_finish_at = DateTimeField ( null = True )
structure_mindmap_task_id = CharField ( max_length = 32 , null = True , help_text = " Structure mindmap merge task ID " , index = True )
structure_mindmap_task_finish_at = DateTimeField ( null = True )
timeline_task_id = CharField ( max_length = 32 , null = True , help_text = " Timeline merge task ID " , index = True )
timeline_task_finish_at = DateTimeField ( null = True )
session_graph_task_id = CharField ( max_length = 32 , null = True , help_text = " Session graph merge task ID " , index = True )
session_graph_task_finish_at = DateTimeField ( null = True )
session_essence_task_id = CharField ( max_length = 32 , null = True , help_text = " Session essence merge task ID " , index = True )
session_essence_task_finish_at = DateTimeField ( null = True )
structure_task_id = CharField ( max_length = 32 , null = True , help_text = " Structure merge-all task ID " , index = True )
structure_task_finish_at = DateTimeField ( null = True )
2025-10-09 12:36:19 +08:00
2025-03-24 13:18:47 +08:00
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
2024-08-15 09:17:36 +08:00
def __str__ ( self ) :
return self . name
class Meta :
db_table = " knowledgebase "
class Document ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
thumbnail = TextField ( null = True , help_text = " thumbnail base64 string " )
kb_id = CharField ( max_length = 256 , null = False , index = True )
2025-03-24 13:18:47 +08:00
parser_id = CharField ( max_length = 32 , null = False , help_text = " default parser ID " , index = True )
2025-11-16 19:29:20 +08:00
pipeline_id = CharField ( max_length = 32 , null = True , help_text = " pipeline ID " , index = True )
2025-11-27 10:21:44 +08:00
parser_config = JSONField ( null = False , default = { " pages " : [ [ 1 , 1000000 ] ] , " table_context_size " : 0 , " image_context_size " : 0 } )
2025-03-24 13:18:47 +08:00
source_type = CharField ( max_length = 128 , null = False , default = " local " , help_text = " where dose this document come from " , index = True )
type = CharField ( max_length = 32 , null = False , help_text = " file extension " , index = True )
created_by = CharField ( max_length = 32 , null = False , help_text = " who created it " , index = True )
name = CharField ( max_length = 255 , null = True , help_text = " file name " , index = True )
location = CharField ( max_length = 255 , null = True , help_text = " where dose it store " , index = True )
fix: change file size column from IntegerField to BigIntegerField to support files > 2GB (#14148)
### What problem does this PR solve?
Fixes #6034
Changes the `size` field in both `Document` and `File` models from
`IntegerField` (32-bit, max ~2GB) to `BigIntegerField` (64-bit, max
~9.2EB), and adds corresponding database migrations.
## Problem
When uploading a file larger than 2GB, the `size` value overflows a
32-bit signed integer (max 2,147,483,647). This causes:
- The stored `size` wraps around to an incorrect value (e.g., a 3GB file
shows as 2,097,152 KB in File Management).
- Subsequent file operations (e.g., download) fail because the corrupted
size leads to invalid storage lookups.
## Changes
- `Document.size`: `IntegerField` → `BigIntegerField`
- `File.size`: `IntegerField` → `BigIntegerField`
- Added `alter_db_column_type` migrations in `migrate_db()` for both
`document.size` and `file.size` columns to ensure existing deployments
are upgraded automatically.
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
Signed-off-by: noob <yixiao121314@outlook.com>
2026-04-16 15:43:29 +08:00
size = BigIntegerField ( default = 0 , index = True )
2024-08-15 09:17:36 +08:00
token_num = IntegerField ( default = 0 , index = True )
chunk_num = IntegerField ( default = 0 , index = True )
progress = FloatField ( default = 0 , index = True )
2025-03-24 13:18:47 +08:00
progress_msg = TextField ( null = True , help_text = " process message " , default = " " )
2024-08-15 09:17:36 +08:00
process_begin_at = DateTimeField ( null = True , index = True )
2025-07-07 14:11:47 +08:00
process_duration = FloatField ( default = 0 )
2025-07-09 09:33:11 +08:00
suffix = CharField ( max_length = 32 , null = False , help_text = " The real file extension suffix " , index = True )
2024-08-15 09:17:36 +08:00
fix: re-chunk documents when data source content is updated (#12918)
Closes: #12889
### What problem does this PR solve?
When syncing external data sources (e.g., Jira, Confluence, Google
Drive), updated documents were not being re-chunked. The raw content was
correctly updated in blob storage, but the vector database retained
stale chunks, causing search results to return outdated information.
**Root cause:** The task digest used for chunk reuse optimization was
calculated only from parser configuration fields (`parser_id`,
`parser_config`, `kb_id`, etc.), without any content-dependent fields.
When a document's content changed but the parser configuration remained
the same, the system incorrectly reused old chunks instead of
regenerating new ones.
**Example scenario:**
1. User syncs a Jira issue: "Meeting scheduled for Monday"
2. User updates the Jira issue to: "Meeting rescheduled to Friday"
3. User triggers sync again
4. Raw content panel shows updated text ✓
5. Chunk panel still shows old text "Monday" ✗
**Solution:**
1. Include `update_time` and `size` in the chunking config, so the task
digest changes when document content is updated
2. Track updated documents separately in `upload_document()` and return
them for processing
3. Process updated documents through the re-parsing pipeline to
regenerate chunks
[1.webm](https://github.com/user-attachments/assets/d21d4dcd-e189-4d39-8700-053bae0ca5a0)
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
2026-03-06 06:48:47 +02:00
content_hash = CharField ( max_length = 32 , null = True , help_text = " xxhash128 of document content for change detection " , default = " " , index = True )
2025-03-24 13:18:47 +08:00
run = CharField ( max_length = 1 , null = True , help_text = " start to run processing or cancel.(1: run it; 2: cancel) " , default = " 0 " , index = True )
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " document "
class File ( DataBaseModel ) :
2025-03-24 13:18:47 +08:00
id = CharField ( max_length = 32 , primary_key = True )
parent_id = CharField ( max_length = 32 , null = False , help_text = " parent folder id " , index = True )
tenant_id = CharField ( max_length = 32 , null = False , help_text = " tenant id " , index = True )
created_by = CharField ( max_length = 32 , null = False , help_text = " who created it " , index = True )
name = CharField ( max_length = 255 , null = False , help_text = " file name or folder name " , index = True )
location = CharField ( max_length = 255 , null = True , help_text = " where dose it store " , index = True )
fix: change file size column from IntegerField to BigIntegerField to support files > 2GB (#14148)
### What problem does this PR solve?
Fixes #6034
Changes the `size` field in both `Document` and `File` models from
`IntegerField` (32-bit, max ~2GB) to `BigIntegerField` (64-bit, max
~9.2EB), and adds corresponding database migrations.
## Problem
When uploading a file larger than 2GB, the `size` value overflows a
32-bit signed integer (max 2,147,483,647). This causes:
- The stored `size` wraps around to an incorrect value (e.g., a 3GB file
shows as 2,097,152 KB in File Management).
- Subsequent file operations (e.g., download) fail because the corrupted
size leads to invalid storage lookups.
## Changes
- `Document.size`: `IntegerField` → `BigIntegerField`
- `File.size`: `IntegerField` → `BigIntegerField`
- Added `alter_db_column_type` migrations in `migrate_db()` for both
`document.size` and `file.size` columns to ensure existing deployments
are upgraded automatically.
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
Signed-off-by: noob <yixiao121314@outlook.com>
2026-04-16 15:43:29 +08:00
size = BigIntegerField ( default = 0 , index = True )
2024-08-15 09:17:36 +08:00
type = CharField ( max_length = 32 , null = False , help_text = " file extension " , index = True )
2026-08-18 09:53:11 +08:00
# File.source_type historically defaults to "". Only GaussDB makes the
# storage column nullable; MySQL and PostgreSQL retain NOT NULL.
source_type = EmptyStringCharField ( max_length = 128 , null = False , default = " " , help_text = " where dose this document come from " , index = True )
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " file "
class File2Document ( DataBaseModel ) :
2025-03-24 13:18:47 +08:00
id = CharField ( max_length = 32 , primary_key = True )
file_id = CharField ( max_length = 32 , null = True , help_text = " file id " , index = True )
document_id = CharField ( max_length = 32 , null = True , help_text = " document id " , index = True )
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " file2document "
2026-06-15 11:19:56 +08:00
class FileCommit ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
folder_id = CharField ( max_length = 32 , null = False , help_text = " workspace folder id " , index = True )
parent_id = CharField ( max_length = 32 , null = True , help_text = " parent commit id " , index = True )
message = CharField ( max_length = 512 , default = " " , help_text = " commit message " )
author_id = CharField ( max_length = 32 , null = False , help_text = " user who created the commit " , index = True )
file_count = IntegerField ( default = 0 , help_text = " number of files in this commit " )
tree_state = LongTextField ( null = True , help_text = " JSON snapshot of the full folder tree at this commit " )
2026-07-02 23:22:07 +08:00
# ---- Artifact-commit extension ----
# Populated only for commits recorded via
# ``FileCommitService.record_page_edit`` (i.e. artifact-page saves).
# For workspace file commits both fields stay null and the ``message``
# column carries the commit body.
title = CharField ( max_length = 255 , null = True , help_text = " commit title (artifact-page edits) " )
comments = TextField ( null = True , help_text = " commit body/description (artifact-page edits) " )
2026-06-15 11:19:56 +08:00
class Meta :
db_table = " file_commit "
class FileCommitItem ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
commit_id = CharField ( max_length = 32 , null = False , help_text = " commit id " , index = True )
file_id = CharField ( max_length = 32 , null = False , help_text = " file id " , index = True )
operation = CharField ( max_length = 16 , null = False , help_text = " add / modify / delete / rename " , index = True )
old_hash = CharField ( max_length = 64 , null = True , help_text = " old content hash " , index = True )
new_hash = CharField ( max_length = 64 , null = True , help_text = " new content hash " , index = True )
old_location = CharField ( max_length = 255 , null = True , help_text = " old storage location " )
new_location = CharField ( max_length = 255 , null = True , help_text = " new storage location " )
old_name = CharField ( max_length = 255 , null = True , help_text = " old file name (for rename) " )
new_name = CharField ( max_length = 255 , null = True , help_text = " new file name (for rename) " )
2026-07-02 23:22:07 +08:00
# ---- Artifact-commit extension ----
# Populated only for artifact-page saves recorded via
# ``FileCommitService.record_page_edit``.
diff = LongTextField ( null = True , help_text = " pre-computed unified diff (artifact-page edits) " )
content_after_storage = CharField ( max_length = 16 , null = True , help_text = " ' minio ' | ' es ' — where the post-save blob lives " , index = True )
content_after_location = CharField ( max_length = 512 , null = True , help_text = " storage key/id for the post-save blob " )
slug_kwd = CharField ( max_length = 512 , null = True , help_text = " artifact page slug (<page_type>/<name>) " , index = True )
page_type_kwd = CharField ( max_length = 32 , null = True , help_text = " artifact page type " , index = True )
2026-06-15 11:19:56 +08:00
class Meta :
db_table = " file_commit_item "
indexes = (
( ( " commit_id " , " file_id " ) , True ) , # unique composite index
)
2026-07-02 23:22:07 +08:00
# ``ArtifactCommit`` retired — artifact page history is now stored under
# ``FileCommit`` + ``FileCommitItem`` via ``FileCommitService.record_page_edit``
# (see the artifact-commit extension columns on those models above).
2026-07-30 11:06:16 +08:00
# Pre-existing ``wiki_commit`` rows are intentionally left in place;
2026-07-02 23:22:07 +08:00
# no code path reads them.
2024-08-15 09:17:36 +08:00
class Task ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
doc_id = CharField ( max_length = 32 , null = False , index = True )
from_page = IntegerField ( default = 0 )
Fix: Remove hardcoded page limits causing parsing failures on large PDFs (>300 pages) (#14382)
### What problem does this PR solve?
Fixes #14196
## Problem
When using DeepDOC to parse large PDFs (over 1000 pages), the parser
silently truncated processing at 300 pages due to a hardcoded default
`page_to=299` in `RAGFlowPdfParser.__images__()`. This caused:
- **Errors** on pages beyond the limit
- **Poor image quality** as the parser attempted to compensate with
missing page data
- **Inconsistent chunk splitting** between full PDF imports and partial
imports
Additionally, the codebase scattered magic numbers (`299`, `600`,
`10000`, `100000`, `100000000`, `10000000000`, `10**9`) across 22 files
as sentinel values for "parse all pages", making future maintenance
error-prone.
## Root Cause
```python
# deepdoc/parser/pdf_parser.py (before)
def __images__(self, fnm, zoomin=3, page_from=0, page_to=299, callback=None):
# Only the first 300 pages were rendered; everything beyond was silently dropped
```
While most callers in `rag/app/*.py` correctly passed `to_page=100000`,
the base class `RAGFlowPdfParser.__call__()` and `parse_into_bboxes()`
invoked `__images__` **without** forwarding `page_from`/`page_to`,
falling back to the restrictive default of 299.
## Solution
### 1. Define constants in `common/constants.py`
```python
MAXIMUM_PAGE_NUMBER = 100000 # Used by the parsing layer
MAXIMUM_TASK_PAGE_NUMBER = MAXIMUM_PAGE_NUMBER * 1000 # Used by the task/DB layer
```
### 2. Replace all hardcoded sentinel values
| Layer | Files Changed | Old Values | New Value |
|---|---|---|---|
| **Deepdoc parsers** | `pdf_parser.py`, `mineru_parser.py`,
`docling_parser.py`, `opendataloader_parser.py`, `paddleocr_parser.py`,
`docx_parser.py` | `299`, `600`, `10**9`, `100000000` |
`MAXIMUM_PAGE_NUMBER` |
| **Chunk parsers** | `naive.py`, `book.py`, `qa.py`, `one.py`,
`manual.py`, `paper.py`, `presentation.py`, `laws.py`, `resume.py`,
`email.py`, `table.py` | `100000`, `10000`, `10000000000` |
`MAXIMUM_PAGE_NUMBER` |
| **Task/DB layer** | `db_models.py`, `task_service.py`,
`document_service.py`, `file_service.py` | `100000000` |
`MAXIMUM_TASK_PAGE_NUMBER` |
### 3. Fix `parse_into_bboxes()` missing parameters
Added `from_page`/`to_page` parameters to `parse_into_bboxes()` so that
the `rag/flow/parser/parser.py` DeepDOC path no longer falls back to the
restrictive default.
## Files Changed (22)
- `common/constants.py`
- `deepdoc/parser/pdf_parser.py`
- `deepdoc/parser/mineru_parser.py`
- `deepdoc/parser/docling_parser.py`
- `deepdoc/parser/opendataloader_parser.py`
- `deepdoc/parser/paddleocr_parser.py`
- `deepdoc/parser/docx_parser.py`
- `rag/app/naive.py`
- `rag/app/book.py`
- `rag/app/qa.py`
- `rag/app/one.py`
- `rag/app/manual.py`
- `rag/app/paper.py`
- `rag/app/presentation.py`
- `rag/app/laws.py`
- `rag/app/resume.py`
- `rag/app/email.py`
- `rag/app/table.py`
- `api/db/db_models.py`
- `api/db/services/task_service.py`
- `api/db/services/document_service.py`
- `api/db/services/file_service.py`
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [x] Refactoring
---------
Signed-off-by: noob <yixiao121314@outlook.com>
2026-04-27 06:57:20 +00:00
to_page = IntegerField ( default = MAXIMUM_TASK_PAGE_NUMBER )
2026-08-18 09:53:11 +08:00
# Standard document parsing tasks historically use task_type="" to mean
# that they are not dataflow, graph, memory, or another specialized task.
# A/ORA-compatible GaussDB stores the bound empty string as NULL, so a NOT
# NULL column would make /documents/parse fail while inserting the task.
# Only DB_TYPE=gaussdb stores NULL, and the ORM restores "" when reading.
task_type = EmptyStringCharField ( max_length = 32 , null = False , default = " " )
2025-03-14 23:43:46 +08:00
priority = IntegerField ( default = 0 )
2024-08-15 09:17:36 +08:00
begin_at = DateTimeField ( null = True , index = True )
2025-07-07 14:11:47 +08:00
process_duration = FloatField ( default = 0 )
2024-08-15 09:17:36 +08:00
progress = FloatField ( default = 0 , index = True )
2026-08-18 09:53:11 +08:00
# progress_msg and chunk_ids are nullable, but callers commonly concatenate,
# strip, or split them as strings. The field maps GaussDB NULL to "" without
# affecting MySQL or PostgreSQL.
progress_msg = EmptyStringTextField ( null = True , help_text = " process message " , default = " " )
2024-08-29 13:31:41 +08:00
retry_count = IntegerField ( default = 0 )
2024-12-12 16:38:03 +08:00
digest = TextField ( null = True , help_text = " task digest " , default = " " )
2026-08-18 09:53:11 +08:00
chunk_ids = EmptyStringLongTextField ( null = True , help_text = " chunk ids " , default = " " )
2024-08-15 09:17:36 +08:00
class Dialog ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
2025-03-24 13:18:47 +08:00
name = CharField ( max_length = 255 , null = True , help_text = " dialog application name " , index = True )
2024-08-15 09:17:36 +08:00
description = TextField ( null = True , help_text = " Dialog description " )
icon = TextField ( null = True , help_text = " icon base64 string " )
2025-03-24 13:18:47 +08:00
language = CharField ( max_length = 32 , null = True , default = " Chinese " if " zh_CN " in os . getenv ( " LANG " , " " ) else " English " , help_text = " English|Chinese " , index = True )
2026-08-18 09:53:11 +08:00
# Map application-level empty chat/reranker model IDs to storage NULL in the
# field instead of handling None throughout the business code.
llm_id = EmptyStringCharField ( max_length = 128 , null = False , help_text = " default llm ID " )
2026-07-08 09:47:29 +08:00
tenant_llm_id = CharField ( max_length = 32 , null = True , help_text = " id in tenant_model " , index = True )
2024-08-15 09:17:36 +08:00
2025-03-24 13:18:47 +08:00
llm_setting = JSONField ( null = False , default = { " temperature " : 0.1 , " top_p " : 0.3 , " frequency_penalty " : 0.7 , " presence_penalty " : 0.4 , " max_tokens " : 512 } )
prompt_type = CharField ( max_length = 16 , null = False , default = " simple " , help_text = " simple|advanced " , index = True )
prompt_config = JSONField (
2024-08-15 09:17:36 +08:00
null = False ,
2025-08-14 12:13:11 +08:00
default = { " system " : " " , " prologue " : " Hi! I ' m your assistant. What can I do for you? " , " parameters " : [ ] , " empty_response " : " Sorry! No relevant content was found in the knowledge base! " } ,
2025-03-24 13:18:47 +08:00
)
2025-08-12 14:12:56 +08:00
meta_data_filter = JSONField ( null = True , default = { } )
2024-08-15 09:17:36 +08:00
similarity_threshold = FloatField ( default = 0.2 )
vector_similarity_weight = FloatField ( default = 0.3 )
top_n = IntegerField ( default = 6 )
top_k = IntegerField ( default = 1024 )
2025-03-24 13:18:47 +08:00
do_refer = CharField ( max_length = 1 , null = False , default = " 1 " , help_text = " it needs to insert reference index into answer or not " )
2024-11-19 14:51:33 +08:00
2026-08-18 09:53:11 +08:00
rerank_id = EmptyStringCharField ( max_length = 128 , null = False , help_text = " default rerank model ID " )
2026-07-08 09:47:29 +08:00
tenant_rerank_id = CharField ( max_length = 32 , null = True , help_text = " id in tenant_model " , index = True )
2024-08-15 09:17:36 +08:00
kb_ids = JSONField ( null = False , default = [ ] )
2025-03-24 13:18:47 +08:00
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " dialog "
class Conversation ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
dialog_id = CharField ( max_length = 32 , null = False , index = True )
2025-11-16 19:29:20 +08:00
name = CharField ( max_length = 255 , null = True , help_text = " conversation name " , index = True )
2024-08-15 09:17:36 +08:00
message = JSONField ( null = True )
reference = JSONField ( null = True , default = [ ] )
2024-12-24 15:59:11 +08:00
user_id = CharField ( max_length = 255 , null = True , help_text = " user_id " , index = True )
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " conversation "
class APIToken ( DataBaseModel ) :
tenant_id = CharField ( max_length = 32 , null = False , index = True )
token = CharField ( max_length = 255 , null = False , index = True )
2025-03-07 13:26:08 +08:00
dialog_id = CharField ( max_length = 32 , null = True , index = True )
2024-08-15 09:17:36 +08:00
source = CharField ( max_length = 16 , null = True , help_text = " none|agent|dialog " , index = True )
2024-12-09 12:38:04 +08:00
beta = CharField ( max_length = 255 , null = True , index = True )
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " api_token "
2025-03-24 13:18:47 +08:00
primary_key = CompositeKey ( " tenant_id " , " token " )
2024-08-15 09:17:36 +08:00
class API4Conversation ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
2026-02-05 19:19:09 +08:00
name = CharField ( max_length = 255 , null = True , help_text = " conversation name " , index = False )
2024-08-15 09:17:36 +08:00
dialog_id = CharField ( max_length = 32 , null = False , index = True )
2026-08-18 09:53:11 +08:00
user_id = EmptyStringCharField ( max_length = 255 , null = False , help_text = " user_id " , index = True )
2026-02-05 19:19:09 +08:00
exp_user_id = CharField ( max_length = 255 , null = True , help_text = " exp_user_id " , index = True )
2024-08-15 09:17:36 +08:00
message = JSONField ( null = True )
reference = JSONField ( null = True , default = [ ] )
tokens = IntegerField ( default = 0 )
source = CharField ( max_length = 16 , null = True , help_text = " none|agent|dialog " , index = True )
2024-12-02 19:05:18 +08:00
dsl = JSONField ( null = True , default = { } )
2024-08-15 09:17:36 +08:00
duration = FloatField ( default = 0 , index = True )
round = IntegerField ( default = 0 , index = True )
thumb_up = IntegerField ( default = 0 , index = True )
2025-07-30 19:41:09 +08:00
errors = TextField ( null = True , help_text = " errors " )
2026-03-17 18:51:26 +08:00
version_title = CharField ( max_length = 255 , null = True , help_text = " canvas version title when session created " , index = False )
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " api_4_conversation "
class UserCanvas ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
avatar = TextField ( null = True , help_text = " avatar base64 string " )
user_id = CharField ( max_length = 255 , null = False , help_text = " user_id " , index = True )
title = CharField ( max_length = 255 , null = True , help_text = " Canvas title " )
2025-03-24 13:18:47 +08:00
permission = CharField ( max_length = 16 , null = False , help_text = " me|team " , default = " me " , index = True )
2026-03-05 17:26:39 +08:00
release = BooleanField ( null = False , help_text = " is released " , default = False , index = True )
2024-08-15 09:17:36 +08:00
description = TextField ( null = True , help_text = " Canvas description " )
canvas_type = CharField ( max_length = 32 , null = True , help_text = " Canvas type " , index = True )
2025-09-03 14:55:24 +08:00
canvas_category = CharField ( max_length = 32 , null = False , default = " agent_canvas " , help_text = " Canvas category: agent_canvas|dataflow_canvas " , index = True )
2026-08-18 09:53:11 +08:00
tags = EmptyStringCharField (
max_length = 512 ,
null = False ,
default = " " ,
help_text = " Comma-separated tags for organizing agents " ,
index = True ,
)
2024-08-15 09:17:36 +08:00
dsl = JSONField ( null = True , default = { } )
2025-03-24 13:18:47 +08:00
2024-08-15 09:17:36 +08:00
class Meta :
db_table = " user_canvas "
class CanvasTemplate ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
avatar = TextField ( null = True , help_text = " avatar base64 string " )
2025-08-28 09:34:47 +08:00
title = JSONField ( null = True , default = dict , help_text = " Canvas title " )
description = JSONField ( null = True , default = dict , help_text = " Canvas description " )
2024-08-15 09:17:36 +08:00
canvas_type = CharField ( max_length = 32 , null = True , help_text = " Canvas type " , index = True )
2026-04-13 09:26:30 -03:00
canvas_types = ListField ( null = True , default = list , help_text = " Canvas types " )
2025-09-03 14:55:24 +08:00
canvas_category = CharField ( max_length = 32 , null = False , default = " agent_canvas " , help_text = " Canvas category: agent_canvas|dataflow_canvas " , index = True )
2024-08-15 09:17:36 +08:00
dsl = JSONField ( null = True , default = { } )
class Meta :
db_table = " canvas_template "
2025-03-24 13:18:47 +08:00
2025-03-19 14:22:53 +07:00
class UserCanvasVersion ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
user_canvas_id = CharField ( max_length = 255 , null = False , help_text = " user_canvas_id " , index = True )
title = CharField ( max_length = 255 , null = True , help_text = " Canvas title " )
description = TextField ( null = True , help_text = " Canvas description " )
2026-03-10 14:25:27 +08:00
release = BooleanField ( null = False , help_text = " is released " , default = False , index = True )
2025-03-19 14:22:53 +07:00
dsl = JSONField ( null = True , default = { } )
class Meta :
db_table = " user_canvas_version "
2024-08-15 09:17:36 +08:00
2025-03-24 13:18:47 +08:00
2025-06-23 17:45:35 +08:00
class MCPServer ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
name = CharField ( max_length = 255 , null = False , help_text = " MCP Server name " )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
url = CharField ( max_length = 2048 , null = False , help_text = " MCP Server URL " )
server_type = CharField ( max_length = 32 , null = False , help_text = " MCP Server type " )
description = TextField ( null = True , help_text = " MCP Server description " )
2025-06-25 09:26:04 +08:00
variables = JSONField ( null = True , default = dict , help_text = " MCP Server variables " )
headers = JSONField ( null = True , default = dict , help_text = " MCP Server additional request headers " )
2025-06-23 17:45:35 +08:00
class Meta :
db_table = " mcp_server "
2025-06-25 09:26:04 +08:00
2026-07-02 23:22:07 +08:00
class CompilationTemplate ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
tenant_id = CharField ( max_length = 32 , null = True , index = True )
group_id = CharField ( max_length = 32 , null = True , index = True )
name = CharField ( max_length = 128 , null = False , index = True )
description = TextField ( null = True , default = " " )
kind = CharField ( max_length = 64 , null = False , index = True )
config = JSONField ( null = False , default = { } )
is_builtin = BooleanField ( null = False , default = False , index = True )
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
class Meta :
db_table = " compilation_template "
2026-07-16 19:37:18 +08:00
indexes = ( ( ( " tenant_id " , " group_id " , " name " , " is_builtin " , " status " ) , True ) , )
2026-07-02 23:22:07 +08:00
class CompilationTemplateGroup ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
name = CharField ( max_length = 128 , null = False , index = True )
description = TextField ( null = True , default = " " )
scope = CharField ( max_length = 16 , null = False , index = True , help_text = " file | dataset " )
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
class Meta :
db_table = " compilation_template_group "
2025-06-18 16:45:42 +08:00
class Search ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
avatar = TextField ( null = True , help_text = " avatar base64 string " )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
name = CharField ( max_length = 128 , null = False , help_text = " Search name " , index = True )
description = TextField ( null = True , help_text = " KB description " )
created_by = CharField ( max_length = 32 , null = False , index = True )
search_config = JSONField (
null = False ,
default = {
" kb_ids " : [ ] ,
" doc_ids " : [ ] ,
2025-08-19 09:33:33 +08:00
" similarity_threshold " : 0.2 ,
2025-06-18 16:45:42 +08:00
" vector_similarity_weight " : 0.3 ,
" use_kg " : False ,
# rerank settings
" rerank_id " : " " ,
" top_k " : 1024 ,
# chat settings
" summary " : False ,
2026-08-04 10:02:14 +08:00
" chat_id " : " " , # id of chat model in tenant_model table
2025-06-18 16:45:42 +08:00
" llm_setting " : {
2026-08-04 10:02:14 +08:00
" temperature " : 0.1 ,
" top_p " : 0.3 ,
" frequency_penalty " : 0.7 ,
" presence_penalty " : 0.4 ,
" temperature_enabled " : True ,
" top_p_enabled " : True ,
" frequency_penalty_enabled " : True ,
" presence_penalty_enabled " : True ,
2025-06-18 16:45:42 +08:00
} ,
" chat_settingcross_languages " : [ ] ,
" highlight " : False ,
" keyword " : False ,
" web_search " : False ,
" related_search " : False ,
" query_mindmap " : False ,
} ,
)
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
def __str__ ( self ) :
return self . name
class Meta :
db_table = " search "
2025-10-09 12:36:19 +08:00
class PipelineOperationLog ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
document_id = CharField ( max_length = 32 , index = True )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
kb_id = CharField ( max_length = 32 , null = False , index = True )
pipeline_id = CharField ( max_length = 32 , null = True , help_text = " Pipeline ID " , index = True )
pipeline_title = CharField ( max_length = 32 , null = True , help_text = " Pipeline title " , index = True )
parser_id = CharField ( max_length = 32 , null = False , help_text = " Parser ID " , index = True )
document_name = CharField ( max_length = 255 , null = False , help_text = " File name " )
document_suffix = CharField ( max_length = 255 , null = False , help_text = " File suffix " )
document_type = CharField ( max_length = 255 , null = False , help_text = " Document type " )
source_from = CharField ( max_length = 255 , null = False , help_text = " Source " )
progress = FloatField ( default = 0 , index = True )
progress_msg = TextField ( null = True , help_text = " process message " , default = " " )
process_begin_at = DateTimeField ( null = True , index = True )
process_duration = FloatField ( default = 0 )
dsl = JSONField ( null = True , default = dict )
task_type = CharField ( max_length = 32 , null = False , default = " " )
operation_status = CharField ( max_length = 32 , null = False , help_text = " Operation status " )
avatar = TextField ( null = True , help_text = " avatar base64 string " )
status = CharField ( max_length = 1 , null = True , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True )
class Meta :
db_table = " pipeline_operation_log "
2025-11-03 19:59:18 +08:00
class Connector ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
name = CharField ( max_length = 128 , null = False , help_text = " Search name " , index = False )
source = CharField ( max_length = 128 , null = False , help_text = " Data source " , index = True )
input_type = CharField ( max_length = 128 , null = False , help_text = " poll/event/.. " , index = True )
config = JSONField ( null = False , default = { } )
refresh_freq = IntegerField ( default = 0 , index = False )
prune_freq = IntegerField ( default = 0 , index = False )
timeout_secs = IntegerField ( default = 3600 , index = False )
indexing_start = DateTimeField ( null = True , index = True )
status = CharField ( max_length = 16 , null = True , help_text = " schedule " , default = " schedule " , index = True )
def __str__ ( self ) :
return self . name
class Meta :
db_table = " connector "
class Connector2Kb ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
connector_id = CharField ( max_length = 32 , null = False , index = True )
kb_id = CharField ( max_length = 32 , null = False , index = True )
2025-11-07 11:43:59 +08:00
auto_parse = CharField ( max_length = 1 , null = False , default = " 1 " , index = False )
2025-11-03 19:59:18 +08:00
class Meta :
db_table = " connector2kb "
Feat: chat channels — connect assistants to external messaging bots (#15850)
### What problem does this PR solve?
#15844
Adds a **Chat channels** capability so a RAGFlow assistant (Dialog) can
be exposed as a bot on external messaging platforms (Feishu/Lark,
Discord, Telegram, Slack, WeCom, LINE, etc.). An admin configures a bot
in the UI, connects it to an assistant, and inbound messages are
answered from that assistant's knowledge base — replies are delivered
back on the channel.
**Feishu/Lark is implemented and tested end-to-end.** Discord, Telegram,
LINE, and WeCom are scaffolded against the same interface; the remaining
listed channels are tracked as follow-ups.
### Design
**Backend**
- New `chat_channel` table (`tenant_id`, `name`, `channel`, `config`
JSON holding `{credential: {...}}`, `dialog_id`, `status`) +
`ChatChannelService` and RESTful CRUD under `/api/v1/chat_channels`.
- Channel framework under `api/channels/`: a `core` registry +
per-channel packages that self-register a builder and implement a common
`Channel` interface (`start`/`stop`/`send` + inbound normalization) over
`IncomingMessage`/`OutgoingMessage`.
- Embedded **reconcile loop** in `ragflow_server`
(`api/channels/bootstrap.py`): loads enabled bots, and
starts/stops/restarts them as rows change (no server restart needed).
Inbound messages run the connected dialog via the non-streaming
completion path, keeping per-end-user conversation history.
- Missing optional channel SDKs degrade gracefully (channel skipped with
a warning; others unaffected). Channel-level errors are logged, not
crashed.
- Feishu's WebSocket client runs in a dedicated thread with its own
event loop to avoid cross-loop/contextvars conflicts with the channel
runtime.
**Frontend**
- **Settings → Chat channels** panel: available-channels grid +
configured-bots list with add/edit/delete and a **Connect assistant**
popup that binds a bot to a dialog.
- Brand icons via simple-icons / reused shared data-source assets, with
colored fallbacks for brands not available.
- Route, sidebar entry, i18n (en/zh), and a top-nav segment-boundary fix
so the settings page no longer highlights the Chat tab.
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
### Notes
- DB: new `chat_channel` table is auto-created; `chat_channel.dialog_id`
is also covered by a `migrate_db` `alter_db_add_column` for existing
installs.
- Channel SDKs (`lark-oapi`, `discord.py`, `python-telegram-bot`,
`line-bot-sdk`, `wechatpy`, `aiohttp`) added to dependencies.
- Screenshots / per-channel credential docs to follow.
<img width="1338" height="1290" alt="Image"
src="https://github.com/user-attachments/assets/042cb2f9-0dad-4e6a-bcf7-43ced4bbd704"
/>
<img width="1344" height="738" alt="Image"
src="https://github.com/user-attachments/assets/373cd08e-ec40-4c67-9c51-4d948b1ba617"
/>
<img width="672" height="887" alt="Image"
src="https://github.com/user-attachments/assets/5a34953f-a9a3-4c1e-869e-5eff0dc64c84"
/>
---------
2026-06-12 18:21:30 +08:00
class ChatChannel ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
name = CharField ( max_length = 128 , null = False , help_text = " Bot name " , index = False )
channel = CharField ( max_length = 128 , null = False , help_text = " Chat channel type " , index = True )
config = JSONField ( null = False , default = { } , help_text = " Channel credential & settings " )
2026-06-16 19:02:20 +08:00
chat_id = CharField ( max_length = 32 , null = True , default = None , help_text = " connected chat id " , index = True )
2026-06-16 12:02:12 +08:00
status = IntegerField ( default = 1 , index = True )
Feat: chat channels — connect assistants to external messaging bots (#15850)
### What problem does this PR solve?
#15844
Adds a **Chat channels** capability so a RAGFlow assistant (Dialog) can
be exposed as a bot on external messaging platforms (Feishu/Lark,
Discord, Telegram, Slack, WeCom, LINE, etc.). An admin configures a bot
in the UI, connects it to an assistant, and inbound messages are
answered from that assistant's knowledge base — replies are delivered
back on the channel.
**Feishu/Lark is implemented and tested end-to-end.** Discord, Telegram,
LINE, and WeCom are scaffolded against the same interface; the remaining
listed channels are tracked as follow-ups.
### Design
**Backend**
- New `chat_channel` table (`tenant_id`, `name`, `channel`, `config`
JSON holding `{credential: {...}}`, `dialog_id`, `status`) +
`ChatChannelService` and RESTful CRUD under `/api/v1/chat_channels`.
- Channel framework under `api/channels/`: a `core` registry +
per-channel packages that self-register a builder and implement a common
`Channel` interface (`start`/`stop`/`send` + inbound normalization) over
`IncomingMessage`/`OutgoingMessage`.
- Embedded **reconcile loop** in `ragflow_server`
(`api/channels/bootstrap.py`): loads enabled bots, and
starts/stops/restarts them as rows change (no server restart needed).
Inbound messages run the connected dialog via the non-streaming
completion path, keeping per-end-user conversation history.
- Missing optional channel SDKs degrade gracefully (channel skipped with
a warning; others unaffected). Channel-level errors are logged, not
crashed.
- Feishu's WebSocket client runs in a dedicated thread with its own
event loop to avoid cross-loop/contextvars conflicts with the channel
runtime.
**Frontend**
- **Settings → Chat channels** panel: available-channels grid +
configured-bots list with add/edit/delete and a **Connect assistant**
popup that binds a bot to a dialog.
- Brand icons via simple-icons / reused shared data-source assets, with
colored fallbacks for brands not available.
- Route, sidebar entry, i18n (en/zh), and a top-nav segment-boundary fix
so the settings page no longer highlights the Chat tab.
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
### Notes
- DB: new `chat_channel` table is auto-created; `chat_channel.dialog_id`
is also covered by a `migrate_db` `alter_db_add_column` for existing
installs.
- Channel SDKs (`lark-oapi`, `discord.py`, `python-telegram-bot`,
`line-bot-sdk`, `wechatpy`, `aiohttp`) added to dependencies.
- Screenshots / per-channel credential docs to follow.
<img width="1338" height="1290" alt="Image"
src="https://github.com/user-attachments/assets/042cb2f9-0dad-4e6a-bcf7-43ced4bbd704"
/>
<img width="1344" height="738" alt="Image"
src="https://github.com/user-attachments/assets/373cd08e-ec40-4c67-9c51-4d948b1ba617"
/>
<img width="672" height="887" alt="Image"
src="https://github.com/user-attachments/assets/5a34953f-a9a3-4c1e-869e-5eff0dc64c84"
/>
---------
2026-06-12 18:21:30 +08:00
def __str__ ( self ) :
return self . name
class Meta :
db_table = " chat_channel "
2025-11-03 19:59:18 +08:00
class DateTimeTzField ( CharField ) :
2026-07-02 23:22:07 +08:00
field_type = " VARCHAR "
2025-11-03 19:59:18 +08:00
2026-07-02 23:22:07 +08:00
def db_value ( self , value : datetime | None ) - > str | None :
2025-11-03 19:59:18 +08:00
if value is not None :
if value . tzinfo is not None :
return value . isoformat ( )
else :
return value . replace ( tzinfo = timezone . utc ) . isoformat ( )
return value
2026-08-14 00:56:05 -07:00
def python_value ( self , value : str | datetime | None ) - > datetime | None :
if value is None :
return None
if isinstance ( value , datetime ) :
# The column is declared VARCHAR, but deployments upgraded from
# older schemas may hold it as native DATETIME, in which case the
# driver returns datetime objects instead of ISO strings.
return value if value . tzinfo is not None else value . replace ( tzinfo = timezone . utc )
try :
2025-11-03 19:59:18 +08:00
dt = datetime . fromisoformat ( value )
2026-08-14 00:56:05 -07:00
except ValueError :
# Zero dates (0000-00-00) and other unparseable values must not
# raise here: peewee counts the row before converting it, so one
# bad value wedges every later iteration over the table with
# IndexError: list index out of range.
logging . warning ( " DateTimeTzField: unparseable value %r , falling back to None " , value )
return None
return dt if dt . tzinfo is not None else dt . replace ( tzinfo = timezone . utc )
2025-11-03 19:59:18 +08:00
class SyncLogs ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
connector_id = CharField ( max_length = 32 , index = True )
2026-05-19 10:07:11 +08:00
task_type = CharField ( max_length = 32 , null = False , default = " sync " , index = True )
2025-11-03 19:59:18 +08:00
status = CharField ( max_length = 128 , null = False , help_text = " Processing status " , index = True )
from_beginning = CharField ( max_length = 1 , null = True , help_text = " " , default = " 0 " , index = False )
new_docs_indexed = IntegerField ( default = 0 , index = False )
total_docs_indexed = IntegerField ( default = 0 , index = False )
docs_removed_from_index = IntegerField ( default = 0 , index = False )
2026-08-18 09:53:11 +08:00
# Connector scheduling logs use error_msg="" to mean that no error is
# present. A/ORA-compatible GaussDB stores it as NULL, so keeping this column
# NOT NULL would break PUT /datasets/<id> when it schedules sync_logs. The
# field permits storage NULL and restores the application-level empty string.
error_msg = EmptyStringTextField ( null = False , help_text = " process message " , default = " " )
2025-11-03 19:59:18 +08:00
error_count = IntegerField ( default = 0 , index = False )
2026-08-18 09:53:11 +08:00
# full_exception_trace is nullable, but callers append stack traces as text.
# Hide GaussDB storage NULL behind the same application-level empty string.
full_exception_trace = EmptyStringTextField ( null = True , help_text = " process message " , default = " " )
2025-11-03 19:59:18 +08:00
time_started = DateTimeField ( null = True , index = True )
poll_range_start = DateTimeTzField ( max_length = 255 , null = True , index = True )
poll_range_end = DateTimeTzField ( max_length = 255 , null = True , index = True )
kb_id = CharField ( max_length = 32 , null = False , index = True )
class Meta :
db_table = " sync_logs "
2025-12-10 13:34:08 +08:00
class Memory ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
name = CharField ( max_length = 128 , null = False , index = False , help_text = " Memory name " )
avatar = TextField ( null = True , help_text = " avatar base64 string " )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
memory_type = IntegerField ( null = False , default = 1 , index = True , help_text = " Bit flags (LSB->MSB): 1=raw, 2=semantic, 4=episodic, 8=procedural. E.g., 5 enables raw + episodic. " )
2026-07-02 23:22:07 +08:00
storage_type = CharField ( max_length = 32 , default = " table " , null = False , index = True , help_text = " table|graph " )
2026-08-18 09:53:11 +08:00
# Keep empty model-ID handling in the field so storage NULL semantics do not
# leak into Memory Store callers.
embd_id = EmptyStringCharField ( max_length = 128 , null = False , index = False , help_text = " embedding model ID " )
2026-07-08 09:47:29 +08:00
tenant_embd_id = CharField ( max_length = 32 , null = True , help_text = " id in tenant_model " , index = True )
2026-08-18 09:53:11 +08:00
llm_id = EmptyStringCharField ( max_length = 128 , null = False , index = False , help_text = " chat model ID " )
2026-07-08 09:47:29 +08:00
tenant_llm_id = CharField ( max_length = 32 , null = True , help_text = " id in tenant_model " , index = True )
2025-12-10 13:34:08 +08:00
permissions = CharField ( max_length = 16 , null = False , index = True , help_text = " me|team " , default = " me " )
description = TextField ( null = True , help_text = " description " )
memory_size = IntegerField ( default = 5242880 , null = False , index = False )
2025-12-26 11:18:08 +08:00
forgetting_policy = CharField ( max_length = 32 , null = False , default = " FIFO " , index = False , help_text = " LRU|FIFO " )
2025-12-10 13:34:08 +08:00
temperature = FloatField ( default = 0.5 , index = False )
system_prompt = TextField ( null = True , help_text = " system prompt " , index = False )
user_prompt = TextField ( null = True , help_text = " user prompt " , index = False )
class Meta :
db_table = " memory "
2026-07-02 23:22:07 +08:00
2026-01-04 14:21:39 +08:00
class SystemSettings ( DataBaseModel ) :
name = CharField ( max_length = 128 , primary_key = True )
2026-01-04 20:26:12 +08:00
source = CharField ( max_length = 32 , null = False , index = False )
2026-01-04 14:21:39 +08:00
data_type = CharField ( max_length = 32 , null = False , index = False )
2026-08-18 09:53:11 +08:00
# system_settings.json contains empty configuration values. Store them as
# NULL on GaussDB and restore "" here instead of globally converting SQL
# results and affecting fields where NULL is meaningful.
value = EmptyStringTextField ( null = False , help_text = " Configuration value (JSON, string, etc.) " )
2026-07-02 23:22:07 +08:00
2026-01-04 14:21:39 +08:00
class Meta :
db_table = " system_settings "
2025-12-10 13:34:08 +08:00
2026-07-02 23:22:07 +08:00
2026-05-29 17:39:41 +08:00
class TenantModelProvider ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
provider_name = CharField ( max_length = 128 , null = False , index = False , help_text = " LLM provider name " )
tenant_id = CharField ( max_length = 32 , null = False , index = True )
class Meta :
db_table = " tenant_model_provider "
2026-07-02 23:22:07 +08:00
indexes = ( ( ( " tenant_id " , " provider_name " ) , True ) , )
2026-05-29 17:39:41 +08:00
class TenantModelInstance ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
instance_name = CharField ( max_length = 128 , null = False , index = False , help_text = " Model instance name " )
provider_id = CharField ( max_length = 32 , null = False , index = False )
api_key = CharField ( max_length = 512 , null = False , index = False , help_text = " API key " )
status = CharField ( max_length = 32 , default = " active " , index = False )
extra = CharField ( max_length = 512 , default = " {} " , index = False )
class Meta :
db_table = " tenant_model_instance "
class TenantModel ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
model_name = CharField ( max_length = 128 , null = True , index = False , help_text = " Model name " )
provider_id = CharField ( max_length = 32 , null = False , index = False )
instance_id = CharField ( max_length = 32 , null = False , index = True )
2026-07-13 16:42:55 +08:00
model_type = IntegerField ( null = False , default = 1 , index = True , help_text = " Bit flags (LSB->MSB): 1=chat, 2=embedding, 4=asr, 8=vision, 16=rerank, 32=tts, 64=ocr " )
2026-05-29 17:39:41 +08:00
status = CharField ( max_length = 32 , default = " active " , index = False )
extra = CharField ( max_length = 1024 , default = " {} " , index = False )
class Meta :
db_table = " tenant_model "
class TenantModelGroup ( DataBaseModel ) :
id = CharField ( max_length = 32 , primary_key = True )
group_type = CharField ( max_length = 32 , null = False , index = False , help_text = " Group type " )
model_name = CharField ( max_length = 128 , null = True , index = False , help_text = " Model name " )
strategy = CharField ( max_length = 32 , default = " weighted " , index = False , help_text = " Routing strategy " )
class Meta :
db_table = " tenant_model_group "
2026-07-02 23:22:07 +08:00
2026-05-29 17:39:41 +08:00
class TenantModelGroupMapping ( DataBaseModel ) :
group_id = CharField ( max_length = 32 , null = False , index = True , help_text = " Group ID " )
provider_id = CharField ( max_length = 32 , null = False , index = False )
instance_id = CharField ( max_length = 32 , null = False , index = False )
model_id = CharField ( max_length = 32 , null = False , index = True )
weight = IntegerField ( default = 100 , index = False , help_text = " Routing weight " )
status = CharField ( max_length = 32 , default = " active " , index = False )
class Meta :
db_table = " tenant_model_group_mapping "
primary_key = CompositeKey ( " group_id " , " provider_id " , " instance_id " , " model_id " )
2026-08-18 09:53:11 +08:00
GAUSSDB_EMPTY_STRING_COMPATIBLE_COLUMNS = (
( " user " , ( " nickname " , ) ) ,
( " tenant " , ( " llm_id " , " embd_id " , " asr_id " , " img2txt_id " , " rerank_id " ) ) ,
( " knowledgebase " , ( " embd_id " , ) ) ,
( " dialog " , ( " llm_id " , " rerank_id " ) ) ,
( " memory " , ( " embd_id " , " llm_id " ) ) ,
( " file " , ( " source_type " , ) ) ,
( " system_settings " , ( " value " , ) ) ,
( " task " , ( " task_type " , ) ) ,
( " sync_logs " , ( " error_msg " , " full_exception_trace " ) ) ,
( " api_4_conversation " , ( " user_id " , ) ) ,
( " user_canvas " , ( " tags " , ) ) ,
)
def _quote_identifier_for_gaussdb ( identifier : str ) - > str :
return ' " ' + identifier . replace ( ' " ' , ' " " ' ) + ' " '
def relax_gaussdb_empty_string_compatible_columns ( ) :
if not is_gaussdb_compatible_database ( ) :
return
# These fields retain their historical application-level empty-string
# semantics, but A/ORA-compatible GaussDB stores "" as NULL. Existing
# databases must therefore make only these listed columns nullable. MySQL,
# PostgreSQL, and unlisted fields remain unchanged.
for table_name , column_names in GAUSSDB_EMPTY_STRING_COMPATIBLE_COLUMNS :
quoted_table = _quote_identifier_for_gaussdb ( table_name )
for column_name in column_names :
quoted_column = _quote_identifier_for_gaussdb ( column_name )
try :
DB . execute_sql ( f " ALTER TABLE { quoted_table } ALTER COLUMN { quoted_column } DROP NOT NULL " )
except Exception as ex :
# Match the migration framework's behavior for failed column
# additions or type changes: log at critical and continue
# startup. This avoids aborting on a missing historical column
# while identifying the constraint that was not relaxed.
logging . critical (
" Failed to relax GaussDB empty-string compatible column %s . %s : %s " ,
table_name ,
column_name ,
ex ,
)
2026-01-08 16:44:53 +08:00
def alter_db_add_column ( migrator , table_name , column_name , column_type ) :
2026-08-18 09:53:11 +08:00
if is_gaussdb_compatible_database ( ) :
try :
migrate ( migrator . add_column ( table_name , column_name , column_type ) )
except ( OperationalError , ProgrammingError ) as ex :
if not is_duplicate_column_error ( ex ) :
logging . critical ( f " Failed to add { settings . DATABASE_TYPE . upper ( ) } . { table_name } column { column_name } , operation error: { ex } " )
except Exception as ex :
logging . critical ( f " Failed to add { settings . DATABASE_TYPE . upper ( ) } . { table_name } column { column_name } , error: { ex } " )
return
Fix table migration on non-exist-yet indexed columns. (#6666)
### What problem does this PR solve?
Fix #6334
Hello, I encountered the same problem in #6334. In the
`api/db/db_models.py`, it calls `obj.create_table()` unconditionally in
`init_database_tables`, before the `migrate_db()`. Specially for the
`permission` field of `user_canvas` table, it has `index=True`, which
causes `peewee` to issue a SQL trying to create the index when the field
does not exist (the `user_canvas` table already exists), so
`psycopg2.errors.UndefinedColumn: column "permission" does not exist`
occurred.
I've added a judgement in the code, to only call `create_table()` when
the table does not exist, delegate the migration process to
`migrate_db()`.
Then another problem occurs: the `migrate_db()` actually does nothing
because it failed on the first migration! The `playhouse` blindly issue
DDLs without things like `IF NOT EXISTS`, so it fails... even if the
exception is `pass`, the transaction is still rolled back. So I removed
the transaction in `migrate_db()` to make it work.
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [ ] New Feature (non-breaking change which adds functionality)
- [ ] Documentation Update
- [ ] Refactoring
- [ ] Performance Improvement
- [ ] Other (please describe):
2025-03-31 11:27:20 +08:00
try :
2026-01-08 16:44:53 +08:00
migrate ( migrator . add_column ( table_name , column_name , column_type ) )
except OperationalError as ex :
error_codes = [ 1060 ]
2026-07-02 23:22:07 +08:00
error_messages = [ " Duplicate column name " ]
2026-01-08 16:44:53 +08:00
2026-07-02 23:22:07 +08:00
should_skip_error = ( hasattr ( ex , " args " ) and ex . args and ex . args [ 0 ] in error_codes ) or ( str ( ex ) in error_messages )
Fix table migration on non-exist-yet indexed columns. (#6666)
### What problem does this PR solve?
Fix #6334
Hello, I encountered the same problem in #6334. In the
`api/db/db_models.py`, it calls `obj.create_table()` unconditionally in
`init_database_tables`, before the `migrate_db()`. Specially for the
`permission` field of `user_canvas` table, it has `index=True`, which
causes `peewee` to issue a SQL trying to create the index when the field
does not exist (the `user_canvas` table already exists), so
`psycopg2.errors.UndefinedColumn: column "permission" does not exist`
occurred.
I've added a judgement in the code, to only call `create_table()` when
the table does not exist, delegate the migration process to
`migrate_db()`.
Then another problem occurs: the `migrate_db()` actually does nothing
because it failed on the first migration! The `playhouse` blindly issue
DDLs without things like `IF NOT EXISTS`, so it fails... even if the
exception is `pass`, the transaction is still rolled back. So I removed
the transaction in `migrate_db()` to make it work.
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [ ] New Feature (non-breaking change which adds functionality)
- [ ] Documentation Update
- [ ] Refactoring
- [ ] Performance Improvement
- [ ] Other (please describe):
2025-03-31 11:27:20 +08:00
2026-01-08 16:44:53 +08:00
if not should_skip_error :
logging . critical ( f " Failed to add { settings . DATABASE_TYPE . upper ( ) } . { table_name } column { column_name } , operation error: { ex } " )
2025-12-10 13:34:08 +08:00
2026-01-08 16:44:53 +08:00
except Exception as ex :
logging . critical ( f " Failed to add { settings . DATABASE_TYPE . upper ( ) } . { table_name } column { column_name } , error: { ex } " )
Feat: RAG evaluation (#11674)
### What problem does this PR solve?
Feature: This PR implements a comprehensive RAG evaluation framework to
address issue #11656.
**Problem**: Developers using RAGFlow lack systematic ways to measure
RAG accuracy and quality. They cannot objectively answer:
1. Are RAG results truly accurate?
2. How should configurations be adjusted to improve quality?
3. How to maintain and improve RAG performance over time?
**Solution**: This PR adds a complete evaluation system with:
- **Dataset & test case management** - Create ground truth datasets with
questions and expected answers
- **Automated evaluation** - Run RAG pipeline on test cases and compute
metrics
- **Comprehensive metrics** - Precision, recall, F1 score, MRR, hit rate
for retrieval quality
- **Smart recommendations** - Analyze results and suggest specific
configuration improvements (e.g., "increase top_k", "enable reranking")
- **20+ REST API endpoints** - Full CRUD operations for datasets, test
cases, and evaluation runs
**Impact**: Enables developers to objectively measure RAG quality,
identify issues, and systematically improve their RAG systems through
data-driven configuration tuning.
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
2025-12-03 04:00:58 -05:00
pass
2026-01-08 16:44:53 +08:00
2026-07-02 23:22:07 +08:00
2026-01-08 16:44:53 +08:00
def alter_db_column_type ( migrator , table_name , column_name , new_column_type ) :
Feat: RAG evaluation (#11674)
### What problem does this PR solve?
Feature: This PR implements a comprehensive RAG evaluation framework to
address issue #11656.
**Problem**: Developers using RAGFlow lack systematic ways to measure
RAG accuracy and quality. They cannot objectively answer:
1. Are RAG results truly accurate?
2. How should configurations be adjusted to improve quality?
3. How to maintain and improve RAG performance over time?
**Solution**: This PR adds a complete evaluation system with:
- **Dataset & test case management** - Create ground truth datasets with
questions and expected answers
- **Automated evaluation** - Run RAG pipeline on test cases and compute
metrics
- **Comprehensive metrics** - Precision, recall, F1 score, MRR, hit rate
for retrieval quality
- **Smart recommendations** - Analyze results and suggest specific
configuration improvements (e.g., "increase top_k", "enable reranking")
- **20+ REST API endpoints** - Full CRUD operations for datasets, test
cases, and evaluation runs
**Impact**: Enables developers to objectively measure RAG quality,
identify issues, and systematically improve their RAG systems through
data-driven configuration tuning.
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
2025-12-03 04:00:58 -05:00
try :
2026-01-08 16:44:53 +08:00
migrate ( migrator . alter_column_type ( table_name , column_name , new_column_type ) )
except Exception as ex :
logging . critical ( f " Failed to alter { settings . DATABASE_TYPE . upper ( ) } . { table_name } column { column_name } type, error: { ex } " )
Feat: RAG evaluation (#11674)
### What problem does this PR solve?
Feature: This PR implements a comprehensive RAG evaluation framework to
address issue #11656.
**Problem**: Developers using RAGFlow lack systematic ways to measure
RAG accuracy and quality. They cannot objectively answer:
1. Are RAG results truly accurate?
2. How should configurations be adjusted to improve quality?
3. How to maintain and improve RAG performance over time?
**Solution**: This PR adds a complete evaluation system with:
- **Dataset & test case management** - Create ground truth datasets with
questions and expected answers
- **Automated evaluation** - Run RAG pipeline on test cases and compute
metrics
- **Comprehensive metrics** - Precision, recall, F1 score, MRR, hit rate
for retrieval quality
- **Smart recommendations** - Analyze results and suggest specific
configuration improvements (e.g., "increase top_k", "enable reranking")
- **20+ REST API endpoints** - Full CRUD operations for datasets, test
cases, and evaluation runs
**Impact**: Enables developers to objectively measure RAG quality,
identify issues, and systematically improve their RAG systems through
data-driven configuration tuning.
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
2025-12-03 04:00:58 -05:00
pass
2026-01-08 16:44:53 +08:00
2026-07-02 23:22:07 +08:00
2026-01-08 16:44:53 +08:00
def alter_db_rename_column ( migrator , table_name , old_column_name , new_column_name ) :
Feat: RAG evaluation (#11674)
### What problem does this PR solve?
Feature: This PR implements a comprehensive RAG evaluation framework to
address issue #11656.
**Problem**: Developers using RAGFlow lack systematic ways to measure
RAG accuracy and quality. They cannot objectively answer:
1. Are RAG results truly accurate?
2. How should configurations be adjusted to improve quality?
3. How to maintain and improve RAG performance over time?
**Solution**: This PR adds a complete evaluation system with:
- **Dataset & test case management** - Create ground truth datasets with
questions and expected answers
- **Automated evaluation** - Run RAG pipeline on test cases and compute
metrics
- **Comprehensive metrics** - Precision, recall, F1 score, MRR, hit rate
for retrieval quality
- **Smart recommendations** - Analyze results and suggest specific
configuration improvements (e.g., "increase top_k", "enable reranking")
- **20+ REST API endpoints** - Full CRUD operations for datasets, test
cases, and evaluation runs
**Impact**: Enables developers to objectively measure RAG quality,
identify issues, and systematically improve their RAG systems through
data-driven configuration tuning.
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
2025-12-03 04:00:58 -05:00
try :
2026-01-08 16:44:53 +08:00
migrate ( migrator . rename_column ( table_name , old_column_name , new_column_name ) )
Feat: RAG evaluation (#11674)
### What problem does this PR solve?
Feature: This PR implements a comprehensive RAG evaluation framework to
address issue #11656.
**Problem**: Developers using RAGFlow lack systematic ways to measure
RAG accuracy and quality. They cannot objectively answer:
1. Are RAG results truly accurate?
2. How should configurations be adjusted to improve quality?
3. How to maintain and improve RAG performance over time?
**Solution**: This PR adds a complete evaluation system with:
- **Dataset & test case management** - Create ground truth datasets with
questions and expected answers
- **Automated evaluation** - Run RAG pipeline on test cases and compute
metrics
- **Comprehensive metrics** - Precision, recall, F1 score, MRR, hit rate
for retrieval quality
- **Smart recommendations** - Analyze results and suggest specific
configuration improvements (e.g., "increase top_k", "enable reranking")
- **20+ REST API endpoints** - Full CRUD operations for datasets, test
cases, and evaluation runs
**Impact**: Enables developers to objectively measure RAG quality,
identify issues, and systematically improve their RAG systems through
data-driven configuration tuning.
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
2025-12-03 04:00:58 -05:00
except Exception :
2026-01-08 16:44:53 +08:00
# rename fail will lead to a weired error.
# logging.critical(f"Failed to rename {settings.DATABASE_TYPE.upper()}.{table_name} column {old_column_name} to {new_column_name}, error: {ex}")
Feat: RAG evaluation (#11674)
### What problem does this PR solve?
Feature: This PR implements a comprehensive RAG evaluation framework to
address issue #11656.
**Problem**: Developers using RAGFlow lack systematic ways to measure
RAG accuracy and quality. They cannot objectively answer:
1. Are RAG results truly accurate?
2. How should configurations be adjusted to improve quality?
3. How to maintain and improve RAG performance over time?
**Solution**: This PR adds a complete evaluation system with:
- **Dataset & test case management** - Create ground truth datasets with
questions and expected answers
- **Automated evaluation** - Run RAG pipeline on test cases and compute
metrics
- **Comprehensive metrics** - Precision, recall, F1 score, MRR, hit rate
for retrieval quality
- **Smart recommendations** - Analyze results and suggest specific
configuration improvements (e.g., "increase top_k", "enable reranking")
- **20+ REST API endpoints** - Full CRUD operations for datasets, test
cases, and evaluation runs
**Impact**: Enables developers to objectively measure RAG quality,
identify issues, and systematically improve their RAG systems through
data-driven configuration tuning.
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
2025-12-03 04:00:58 -05:00
pass
2025-12-10 13:34:08 +08:00
2026-07-02 23:22:07 +08:00
2026-07-16 23:00:44 +08:00
def alter_db_drop_index ( migrator , table_name , index_name ) :
try :
migrate ( migrator . drop_index ( table_name , index_name ) )
except Exception :
# rename fail will lead to a weired error.
# logging.critical(f"Failed to rename {settings.DATABASE_TYPE.upper()}.{table_name} column {old_column_name} to {new_column_name}, error: {ex}")
pass
2026-07-23 18:39:16 +08:00
2026-07-13 17:34:28 +08:00
def ensure_model_indexes ( migrator ) :
""" Create indexes declared by the Peewee models when they are missing. """
members = inspect . getmembers ( sys . modules [ __name__ ] , inspect . isclass )
for name , model in members :
if model == DataBaseModel or not issubclass ( model , DataBaseModel ) :
continue
table_name = model . _meta . table_name
expected = { }
for field in model . _meta . fields . values ( ) :
if field . primary_key :
continue
if field . index or field . unique :
expected [ ( field . name , ) ] = bool ( field . unique )
for columns , unique in model . _meta . indexes :
expected [ tuple ( columns ) ] = bool ( unique )
if not expected :
continue
try :
existing = { tuple ( index . columns ) : bool ( index . unique ) for index in DB . get_indexes ( table_name ) }
except Exception as ex :
logging . error ( f " Failed to inspect indexes on { table_name } : { ex } " )
continue
for columns , unique in expected . items ( ) :
if columns in existing and ( not unique or existing [ columns ] ) :
continue
try :
migrate ( migrator . add_index ( table_name , columns , unique = unique ) )
logging . info ( f " Created { ' unique ' if unique else ' ' } index on { table_name } ( { ' , ' . join ( columns ) } ) " )
except Exception as ex :
logging . error ( f " Failed to create { ' unique ' if unique else ' ' } index on { table_name } ( { ' , ' . join ( columns ) } ): { ex } " )
2026-08-18 09:53:11 +08:00
def _gaussdb_user_email_unique_index_exists ( ) - > bool :
cursor = DB . execute_sql (
"""
SELECT COUNT ( * )
FROM pg_indexes
WHERE schemaname = current_schema ( )
AND tablename = ' user '
AND lower ( indexdef ) LIKE % s
AND (
lower ( indexdef ) LIKE % s
OR lower ( indexdef ) LIKE % s
)
""" ,
( " create unique index % " , " %(email)% " , ' % ( " email " ) % ' ) ,
)
result = cursor . fetchone ( )
return bool ( result and result [ 0 ] > 0 )
2026-02-27 12:55:51 +01:00
def migrate_add_unique_email ( migrator ) :
""" Deduplicates user emails and add UNIQUE constraint to email column (idempotent) """
2026-03-24 20:24:24 +08:00
# step 0: check existing index state on user.email and prepare for unique constraint
2026-03-05 17:27:17 +08:00
try :
2026-08-18 09:53:11 +08:00
if is_gaussdb_compatible_database ( ) :
# GaussDB cannot use MySQL information_schema.statistics or
# backtick syntax. Query pg_indexes through a distinct GaussDB path
# so future catalog changes remain isolated in
# _gaussdb_user_email_unique_index_exists.
if _gaussdb_user_email_unique_index_exists ( ) :
logging . info ( " UNIQUE index on user.email already exists, skipping migration " )
return
elif settings . DATABASE_TYPE . upper ( ) == " POSTGRES " :
2026-03-13 11:53:01 +08:00
cursor = DB . execute_sql ( """
SELECT COUNT ( * )
FROM pg_indexes
WHERE tablename = ' user '
AND indexname = ' user_email '
""" )
2026-03-24 20:24:24 +08:00
result = cursor . fetchone ( )
if result and result [ 0 ] > 0 :
logging . info ( " UNIQUE index on user.email already exists, skipping migration " )
return
2026-03-13 11:53:01 +08:00
else :
2026-03-24 20:24:24 +08:00
# Fetch the first index on email: tells us both the name and whether it's unique.
# non_unique=0 means unique, non_unique=1 means non-unique.
2026-03-13 11:53:01 +08:00
cursor = DB . execute_sql ( """
2026-03-24 20:24:24 +08:00
SELECT index_name , non_unique
2026-03-13 11:53:01 +08:00
FROM information_schema . statistics
WHERE table_schema = DATABASE ( )
AND table_name = ' user '
2026-03-24 20:24:24 +08:00
AND column_name = ' email '
LIMIT 1
2026-03-13 11:53:01 +08:00
""" )
2026-03-24 20:24:24 +08:00
row = cursor . fetchone ( )
if row :
index_name , non_unique = row
if non_unique == 0 :
logging . info ( " UNIQUE index on user.email already exists, skipping migration " )
return
# Non-unique index exists (e.g. from old peewee index=True); drop it so
# the upcoming ADD UNIQUE INDEX does not hit MySQL error 1061 "Duplicate key name".
DB . execute_sql ( f " ALTER TABLE `user` DROP INDEX ` { index_name } ` " )
logging . info ( f " Dropped non-unique index ' { index_name } ' on user.email before adding unique index " )
2026-03-05 17:27:17 +08:00
except Exception as ex :
2026-03-24 20:24:24 +08:00
logging . warning ( f " Failed to check/prepare email index on user table: { ex } , continuing with migration " )
2026-03-05 17:27:17 +08:00
2026-02-27 12:55:51 +01:00
# step 1: rename duplicate rows so the UNIQUE constraint can be applied
try :
duplicates = User . select ( User . email ) . group_by ( User . email ) . having ( fn . COUNT ( User . id ) > 1 ) . tuples ( )
for ( dup_email , ) in duplicates :
# Keep the superuser row, or the oldest row if there is no superuser
2026-07-02 23:22:07 +08:00
rows = list ( User . select ( User . id ) . where ( User . email == dup_email ) . order_by ( User . is_superuser . desc ( ) , User . create_time . asc ( ) ) . tuples ( ) )
2026-02-27 12:55:51 +01:00
for ( uid , ) in rows [ 1 : ] :
new_email = f " { dup_email } _DUPLICATE_ { uid [ : 8 ] } "
User . update ( email = new_email ) . where ( User . id == uid ) . execute ( )
logging . warning ( " Renamed duplicate user %s email to %s during migration " , uid , new_email )
except Exception as ex :
logging . critical ( " Failed to deduplicate user.email before adding UNIQUE constraint: %s " , ex )
return
# step 2: add UNIQUE index via migrator
try :
migrate ( migrator . add_index ( " user " , ( " email " , ) , unique = True ) )
except ( OperationalError , ProgrammingError ) as ex :
msg = str ( ex )
2026-08-18 09:53:11 +08:00
if is_gaussdb_compatible_database ( ) :
already_exists = is_duplicate_object_error ( ex )
else :
# Preserve the upstream MySQL/PostgreSQL handling.
already_exists = " 1061 " in msg or " Duplicate key name " in msg or " already exists " in msg . lower ( )
if already_exists :
2026-02-27 12:55:51 +01:00
pass
else :
logging . critical ( " Failed to add UNIQUE constraint on user.email: %s " , ex )
except Exception as ex :
logging . critical ( " Failed to add UNIQUE constraint on user.email: %s " , ex )
2026-03-05 17:27:17 +08:00
def update_tenant_llm_to_id_primary_key ( ) :
""" Add ID and set to primary key step by step. """
2026-08-18 09:53:11 +08:00
if is_gaussdb_compatible_database ( ) :
# Use the GaussDB implementation for sequences, catalogs, and constraint
# DDL instead of MySQL AUTO_INCREMENT/INFORMATION_SCHEMA or a branch
# labeled as PostgreSQL.
_update_tenant_llm_to_id_primary_key_gaussdb ( )
elif settings . DATABASE_TYPE . upper ( ) == " POSTGRES " :
2026-03-13 11:53:01 +08:00
_update_tenant_llm_to_id_primary_key_postgres ( )
else :
_update_tenant_llm_to_id_primary_key_mysql ( )
def _update_tenant_llm_to_id_primary_key_mysql ( ) :
""" MySQL implementation: Add ID column and set as AUTO_INCREMENT primary key. """
2026-03-05 17:27:17 +08:00
try :
with DB . atomic ( ) :
2026-03-13 11:53:01 +08:00
# 0. Check if 'id' column already exists
2026-03-05 17:27:17 +08:00
cursor = DB . execute_sql ( """
2026-03-13 11:53:01 +08:00
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA . COLUMNS
WHERE TABLE_SCHEMA = DATABASE ( )
AND TABLE_NAME = ' tenant_llm '
2026-03-05 17:27:17 +08:00
AND COLUMN_NAME = ' id '
""" )
if cursor . rowcount > 0 :
return
# 1. Add nullable column
DB . execute_sql ( " ALTER TABLE tenant_llm ADD COLUMN temp_id INT NULL " )
2026-03-13 11:53:01 +08:00
# 2. Set ID using MySQL user variables
2026-03-05 17:27:17 +08:00
DB . execute_sql ( " SET @row = 0; " )
DB . execute_sql ( " UPDATE tenant_llm SET temp_id = (@row := @row + 1) ORDER BY tenant_id, llm_factory, llm_name; " )
# 3. Drop old primary key
DB . execute_sql ( " ALTER TABLE tenant_llm DROP PRIMARY KEY " )
2026-03-13 11:53:01 +08:00
# 4. Update ID column to primary key with AUTO_INCREMENT
2026-03-05 17:27:17 +08:00
DB . execute_sql ( """
2026-03-13 11:53:01 +08:00
ALTER TABLE tenant_llm
2026-03-05 17:27:17 +08:00
MODIFY COLUMN temp_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY
""" )
# 5. Add unique key
DB . execute_sql ( """
2026-03-13 11:53:01 +08:00
ALTER TABLE tenant_llm
2026-03-05 17:27:17 +08:00
ADD CONSTRAINT uk_tenant_llm UNIQUE ( tenant_id , llm_factory , llm_name )
""" )
# 6. rename
DB . execute_sql ( " ALTER TABLE tenant_llm RENAME COLUMN temp_id TO id " )
logging . info ( " Successfully updated tenant_llm to id primary key. " )
except Exception as e :
logging . error ( str ( e ) )
cursor = DB . execute_sql ( """
2026-03-13 11:53:01 +08:00
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA . COLUMNS
WHERE TABLE_SCHEMA = DATABASE ( )
AND TABLE_NAME = ' tenant_llm '
2026-03-05 17:27:17 +08:00
AND COLUMN_NAME = ' temp_id '
""" )
if cursor . rowcount > 0 :
2026-03-13 11:53:01 +08:00
DB . execute_sql ( " ALTER TABLE tenant_llm DROP COLUMN temp_id " )
def _update_tenant_llm_to_id_primary_key_postgres ( ) :
""" PostgreSQL implementation: Add SERIAL primary key column to tenant_llm. """
try :
with DB . atomic ( ) :
# 0. Check if 'id' column already exists
cursor = DB . execute_sql ( """
SELECT column_name
FROM information_schema . columns
WHERE table_catalog = current_database ( )
AND table_name = ' tenant_llm '
AND column_name = ' id '
""" )
if cursor . rowcount > 0 :
return
# 1. Add nullable integer column
DB . execute_sql ( " ALTER TABLE tenant_llm ADD COLUMN temp_id INTEGER NULL " )
# 2. Assign sequential row numbers ordered consistently
DB . execute_sql ( """
UPDATE tenant_llm
SET temp_id = subq . rn
FROM (
SELECT ctid ,
ROW_NUMBER ( ) OVER ( ORDER BY tenant_id , llm_factory , llm_name ) AS rn
FROM tenant_llm
) AS subq
WHERE tenant_llm . ctid = subq . ctid
""" )
# 3. Drop old composite primary key constraint
cursor = DB . execute_sql ( """
SELECT constraint_name
FROM information_schema . table_constraints
WHERE table_catalog = current_database ( )
AND table_name = ' tenant_llm '
AND constraint_type = ' PRIMARY KEY '
""" )
row = cursor . fetchone ( )
if row :
DB . execute_sql ( f ' ALTER TABLE tenant_llm DROP CONSTRAINT " { row [ 0 ] } " ' )
# 4. Make temp_id NOT NULL and create a sequence for it
DB . execute_sql ( " ALTER TABLE tenant_llm ALTER COLUMN temp_id SET NOT NULL " )
DB . execute_sql ( " CREATE SEQUENCE IF NOT EXISTS tenant_llm_id_seq " )
DB . execute_sql ( """
SELECT setval ( ' tenant_llm_id_seq ' , COALESCE ( ( SELECT MAX ( temp_id ) FROM tenant_llm ) , 0 ) )
""" )
DB . execute_sql ( " ALTER TABLE tenant_llm ALTER COLUMN temp_id SET DEFAULT nextval( ' tenant_llm_id_seq ' ) " )
DB . execute_sql ( " ALTER SEQUENCE tenant_llm_id_seq OWNED BY tenant_llm.temp_id " )
DB . execute_sql ( " ALTER TABLE tenant_llm ADD PRIMARY KEY (temp_id) " )
# 5. Add unique constraint
DB . execute_sql ( """
ALTER TABLE tenant_llm
ADD CONSTRAINT uk_tenant_llm UNIQUE ( tenant_id , llm_factory , llm_name )
""" )
# 6. Rename temp_id to id
DB . execute_sql ( " ALTER TABLE tenant_llm RENAME COLUMN temp_id TO id " )
logging . info ( " Successfully updated tenant_llm to id primary key (PostgreSQL). " )
except Exception as e :
logging . error ( str ( e ) )
cursor = DB . execute_sql ( """
SELECT column_name
FROM information_schema . columns
WHERE table_catalog = current_database ( )
AND table_name = ' tenant_llm '
AND column_name = ' temp_id '
""" )
if cursor . rowcount > 0 :
2026-03-05 17:27:17 +08:00
DB . execute_sql ( " ALTER TABLE tenant_llm DROP COLUMN temp_id " )
2026-08-18 09:53:11 +08:00
def _update_tenant_llm_to_id_primary_key_gaussdb ( ) :
""" GaussDB-compatible implementation: add sequence-backed primary key. """
# GaussDB can perform this migration with pg_attribute, pg_constraint, and
# a sequence. Keep the entry point distinct so catalog, ctid, or sequence
# differences can be handled without changing the PostgreSQL path.
_update_tenant_llm_to_id_primary_key_gaussdb_catalog ( )
def _update_tenant_llm_to_id_primary_key_gaussdb_catalog ( ) :
""" GaussDB pg_catalog implementation for the tenant_llm migration. """
try :
with DB . atomic ( ) :
# 0. Check if 'id' column already exists
# information_schema rowcount is unreliable on GaussDB. Query
# pg_attribute and use fetchone() to prevent a duplicate migration.
cursor = DB . execute_sql ( """
SELECT a . attname
FROM pg_attribute a
JOIN pg_class c ON c . oid = a . attrelid
JOIN pg_namespace n ON n . oid = c . relnamespace
WHERE n . nspname = current_schema ( )
AND c . relname = ' tenant_llm '
AND a . attname = ' id '
AND NOT a . attisdropped
""" )
if cursor . fetchone ( ) :
return
# 1. Add nullable integer column
DB . execute_sql ( " ALTER TABLE tenant_llm ADD COLUMN temp_id INTEGER NULL " )
# 2. Assign sequential row numbers ordered consistently
DB . execute_sql ( """
UPDATE tenant_llm
SET temp_id = subq . rn
FROM (
SELECT ctid ,
ROW_NUMBER ( ) OVER ( ORDER BY tenant_id , llm_factory , llm_name ) AS rn
FROM tenant_llm
) AS subq
WHERE tenant_llm . ctid = subq . ctid
""" )
# 3. Drop old composite primary key constraint
# Older tenant_llm tables use (tenant_id, llm_factory, llm_name) as
# a composite primary key. The new schema needs an id primary key
# and a composite unique constraint. Read the actual constraint name
# from pg_constraint instead of assuming a fixed name.
cursor = DB . execute_sql ( """
SELECT con . conname
FROM pg_constraint con
JOIN pg_class c ON c . oid = con . conrelid
JOIN pg_namespace n ON n . oid = c . relnamespace
WHERE n . nspname = current_schema ( )
AND c . relname = ' tenant_llm '
AND con . contype = ' p '
""" )
row = cursor . fetchone ( )
if row :
DB . execute_sql ( f ' ALTER TABLE tenant_llm DROP CONSTRAINT " { row [ 0 ] } " ' )
# 4. Make temp_id NOT NULL and create a sequence for it
# GaussDB and PostgreSQL do not use MySQL AUTO_INCREMENT here. A
# sequence with nextval() provides the generated key, and OWNED BY
# keeps its lifecycle tied to the column after temp_id becomes id.
DB . execute_sql ( " ALTER TABLE tenant_llm ALTER COLUMN temp_id SET NOT NULL " )
DB . execute_sql ( " CREATE SEQUENCE IF NOT EXISTS tenant_llm_id_seq " )
DB . execute_sql ( """
SELECT setval ( ' tenant_llm_id_seq ' , COALESCE ( ( SELECT MAX ( temp_id ) FROM tenant_llm ) , 0 ) )
""" )
DB . execute_sql ( " ALTER TABLE tenant_llm ALTER COLUMN temp_id SET DEFAULT nextval( ' tenant_llm_id_seq ' ) " )
DB . execute_sql ( " ALTER SEQUENCE tenant_llm_id_seq OWNED BY tenant_llm.temp_id " )
DB . execute_sql ( " ALTER TABLE tenant_llm ADD PRIMARY KEY (temp_id) " )
# 5. Add unique constraint
DB . execute_sql ( """
ALTER TABLE tenant_llm
ADD CONSTRAINT uk_tenant_llm UNIQUE ( tenant_id , llm_factory , llm_name )
""" )
# 6. Rename temp_id to id
DB . execute_sql ( " ALTER TABLE tenant_llm RENAME COLUMN temp_id TO id " )
logging . info ( " Successfully updated tenant_llm to id primary key (GaussDB). " )
except Exception as e :
logging . error ( str ( e ) )
cursor = DB . execute_sql ( """
SELECT a . attname
FROM pg_attribute a
JOIN pg_class c ON c . oid = a . attrelid
JOIN pg_namespace n ON n . oid = c . relnamespace
WHERE n . nspname = current_schema ( )
AND c . relname = ' tenant_llm '
AND a . attname = ' temp_id '
AND NOT a . attisdropped
""" )
if cursor . fetchone ( ) :
DB . execute_sql ( " ALTER TABLE tenant_llm DROP COLUMN temp_id " )
2026-01-08 16:44:53 +08:00
def migrate_db ( ) :
logging . disable ( logging . ERROR )
migrator = DatabaseMigrator [ settings . DATABASE_TYPE . upper ( ) ] . value ( DB )
2026-08-18 09:53:11 +08:00
alter_db_add_column ( migrator , " file " , " source_type " , EmptyStringCharField ( max_length = 128 , null = False , default = " " , help_text = " where dose this document come from " , index = True ) )
alter_db_add_column ( migrator , " tenant " , " rerank_id " , EmptyStringCharField ( max_length = 128 , null = False , default = " BAAI/bge-reranker-v2-m3 " , help_text = " default rerank model ID " ) )
alter_db_add_column ( migrator , " dialog " , " rerank_id " , EmptyStringCharField ( max_length = 128 , null = False , default = " " , help_text = " default rerank model ID " ) )
2026-01-08 16:44:53 +08:00
alter_db_column_type ( migrator , " dialog " , " top_k " , IntegerField ( default = 1024 ) )
alter_db_add_column ( migrator , " tenant_llm " , " api_key " , CharField ( max_length = 2048 , null = True , help_text = " API KEY " , index = True ) )
alter_db_add_column ( migrator , " api_token " , " source " , CharField ( max_length = 16 , null = True , help_text = " none|agent|dialog " , index = True ) )
alter_db_add_column ( migrator , " tenant " , " tts_id " , CharField ( max_length = 256 , null = True , help_text = " default tts model ID " , index = True ) )
alter_db_add_column ( migrator , " api_4_conversation " , " source " , CharField ( max_length = 16 , null = True , help_text = " none|agent|dialog " , index = True ) )
alter_db_add_column ( migrator , " task " , " retry_count " , IntegerField ( default = 0 ) )
alter_db_column_type ( migrator , " api_token " , " dialog_id " , CharField ( max_length = 32 , null = True , index = True ) )
alter_db_add_column ( migrator , " tenant_llm " , " max_tokens " , IntegerField ( default = 8192 , index = True ) )
alter_db_add_column ( migrator , " api_4_conversation " , " dsl " , JSONField ( null = True , default = { } ) )
alter_db_add_column ( migrator , " knowledgebase " , " pagerank " , IntegerField ( default = 0 , index = False ) )
alter_db_add_column ( migrator , " api_token " , " beta " , CharField ( max_length = 255 , null = True , index = True ) )
alter_db_add_column ( migrator , " task " , " digest " , TextField ( null = True , help_text = " task digest " , default = " " ) )
2026-08-18 09:53:11 +08:00
alter_db_add_column ( migrator , " task " , " chunk_ids " , EmptyStringLongTextField ( null = True , help_text = " chunk ids " , default = " " ) )
2026-01-08 16:44:53 +08:00
alter_db_add_column ( migrator , " conversation " , " user_id " , CharField ( max_length = 255 , null = True , help_text = " user_id " , index = True ) )
2026-08-18 09:53:11 +08:00
alter_db_add_column ( migrator , " task " , " task_type " , EmptyStringCharField ( max_length = 32 , null = False , default = " " ) )
2026-01-08 16:44:53 +08:00
alter_db_add_column ( migrator , " task " , " priority " , IntegerField ( default = 0 ) )
alter_db_add_column ( migrator , " user_canvas " , " permission " , CharField ( max_length = 16 , null = False , help_text = " me|team " , default = " me " , index = True ) )
2026-03-05 17:26:39 +08:00
alter_db_add_column ( migrator , " user_canvas " , " release " , BooleanField ( null = False , help_text = " is released " , default = False , index = True ) )
2026-01-08 16:44:53 +08:00
alter_db_add_column ( migrator , " llm " , " is_tools " , BooleanField ( null = False , help_text = " support tools " , default = False ) )
alter_db_add_column ( migrator , " mcp_server " , " variables " , JSONField ( null = True , help_text = " MCP Server variables " , default = dict ) )
alter_db_rename_column ( migrator , " task " , " process_duation " , " process_duration " )
alter_db_rename_column ( migrator , " document " , " process_duation " , " process_duration " )
alter_db_add_column ( migrator , " document " , " suffix " , CharField ( max_length = 32 , null = False , default = " " , help_text = " The real file extension suffix " , index = True ) )
alter_db_add_column ( migrator , " api_4_conversation " , " errors " , TextField ( null = True , help_text = " errors " ) )
alter_db_add_column ( migrator , " dialog " , " meta_data_filter " , JSONField ( null = True , default = { } ) )
alter_db_column_type ( migrator , " canvas_template " , " title " , JSONField ( null = True , default = dict , help_text = " Canvas title " ) )
alter_db_column_type ( migrator , " canvas_template " , " description " , JSONField ( null = True , default = dict , help_text = " Canvas description " ) )
alter_db_add_column ( migrator , " user_canvas " , " canvas_category " , CharField ( max_length = 32 , null = False , default = " agent_canvas " , help_text = " agent_canvas|dataflow_canvas " , index = True ) )
alter_db_add_column ( migrator , " canvas_template " , " canvas_category " , CharField ( max_length = 32 , null = False , default = " agent_canvas " , help_text = " agent_canvas|dataflow_canvas " , index = True ) )
2026-04-13 09:26:30 -03:00
alter_db_add_column ( migrator , " canvas_template " , " canvas_types " , ListField ( null = True , default = list , help_text = " Canvas types " ) )
2026-01-08 16:44:53 +08:00
alter_db_add_column ( migrator , " knowledgebase " , " pipeline_id " , CharField ( max_length = 32 , null = True , help_text = " Pipeline ID " , index = True ) )
Feat: chat channels — connect assistants to external messaging bots (#15850)
### What problem does this PR solve?
#15844
Adds a **Chat channels** capability so a RAGFlow assistant (Dialog) can
be exposed as a bot on external messaging platforms (Feishu/Lark,
Discord, Telegram, Slack, WeCom, LINE, etc.). An admin configures a bot
in the UI, connects it to an assistant, and inbound messages are
answered from that assistant's knowledge base — replies are delivered
back on the channel.
**Feishu/Lark is implemented and tested end-to-end.** Discord, Telegram,
LINE, and WeCom are scaffolded against the same interface; the remaining
listed channels are tracked as follow-ups.
### Design
**Backend**
- New `chat_channel` table (`tenant_id`, `name`, `channel`, `config`
JSON holding `{credential: {...}}`, `dialog_id`, `status`) +
`ChatChannelService` and RESTful CRUD under `/api/v1/chat_channels`.
- Channel framework under `api/channels/`: a `core` registry +
per-channel packages that self-register a builder and implement a common
`Channel` interface (`start`/`stop`/`send` + inbound normalization) over
`IncomingMessage`/`OutgoingMessage`.
- Embedded **reconcile loop** in `ragflow_server`
(`api/channels/bootstrap.py`): loads enabled bots, and
starts/stops/restarts them as rows change (no server restart needed).
Inbound messages run the connected dialog via the non-streaming
completion path, keeping per-end-user conversation history.
- Missing optional channel SDKs degrade gracefully (channel skipped with
a warning; others unaffected). Channel-level errors are logged, not
crashed.
- Feishu's WebSocket client runs in a dedicated thread with its own
event loop to avoid cross-loop/contextvars conflicts with the channel
runtime.
**Frontend**
- **Settings → Chat channels** panel: available-channels grid +
configured-bots list with add/edit/delete and a **Connect assistant**
popup that binds a bot to a dialog.
- Brand icons via simple-icons / reused shared data-source assets, with
colored fallbacks for brands not available.
- Route, sidebar entry, i18n (en/zh), and a top-nav segment-boundary fix
so the settings page no longer highlights the Chat tab.
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
### Notes
- DB: new `chat_channel` table is auto-created; `chat_channel.dialog_id`
is also covered by a `migrate_db` `alter_db_add_column` for existing
installs.
- Channel SDKs (`lark-oapi`, `discord.py`, `python-telegram-bot`,
`line-bot-sdk`, `wechatpy`, `aiohttp`) added to dependencies.
- Screenshots / per-channel credential docs to follow.
<img width="1338" height="1290" alt="Image"
src="https://github.com/user-attachments/assets/042cb2f9-0dad-4e6a-bcf7-43ced4bbd704"
/>
<img width="1344" height="738" alt="Image"
src="https://github.com/user-attachments/assets/373cd08e-ec40-4c67-9c51-4d948b1ba617"
/>
<img width="672" height="887" alt="Image"
src="https://github.com/user-attachments/assets/5a34953f-a9a3-4c1e-869e-5eff0dc64c84"
/>
---------
2026-06-12 18:21:30 +08:00
alter_db_add_column ( migrator , " chat_channel " , " dialog_id " , CharField ( max_length = 32 , null = True , help_text = " connected dialog id " , index = True ) )
2026-01-08 16:44:53 +08:00
alter_db_add_column ( migrator , " document " , " pipeline_id " , CharField ( max_length = 32 , null = True , help_text = " Pipeline ID " , index = True ) )
alter_db_add_column ( migrator , " knowledgebase " , " graphrag_task_id " , CharField ( max_length = 32 , null = True , help_text = " Gragh RAG task ID " , index = True ) )
alter_db_add_column ( migrator , " knowledgebase " , " raptor_task_id " , CharField ( max_length = 32 , null = True , help_text = " RAPTOR task ID " , index = True ) )
alter_db_add_column ( migrator , " knowledgebase " , " graphrag_task_finish_at " , DateTimeField ( null = True ) )
2026-07-16 11:44:00 +08:00
alter_db_add_column ( migrator , " knowledgebase " , " raptor_task_finish_at " , DateTimeField ( null = True ) )
2026-01-08 16:44:53 +08:00
alter_db_add_column ( migrator , " knowledgebase " , " mindmap_task_id " , CharField ( max_length = 32 , null = True , help_text = " Mindmap task ID " , index = True ) )
2026-07-16 11:44:00 +08:00
alter_db_add_column ( migrator , " knowledgebase " , " mindmap_task_finish_at " , DateTimeField ( null = True ) )
2026-07-30 11:06:16 +08:00
alter_db_rename_column ( migrator , " knowledgebase " , " artifact_task_id " , " wiki_task_id " )
alter_db_rename_column ( migrator , " knowledgebase " , " artifact_task_finish_at " , " wiki_task_finish_at " )
alter_db_add_column ( migrator , " knowledgebase " , " wiki_task_id " , CharField ( max_length = 32 , null = True , help_text = " Artifact compilation task ID " , index = True ) )
alter_db_add_column ( migrator , " knowledgebase " , " wiki_task_finish_at " , DateTimeField ( null = True ) )
2026-07-02 23:22:07 +08:00
alter_db_add_column ( migrator , " knowledgebase " , " skill_task_id " , CharField ( max_length = 32 , null = True , help_text = " Skill generation task ID " , index = True ) )
alter_db_add_column ( migrator , " knowledgebase " , " skill_task_finish_at " , DateTimeField ( null = True ) )
2026-07-23 18:39:16 +08:00
for _structure_type in ( " structure_graph " , " structure_mindmap " , " timeline " , " session_graph " , " session_essence " , " structure " ) :
alter_db_add_column ( migrator , " knowledgebase " , f " { _structure_type } _task_id " , CharField ( max_length = 32 , null = True , help_text = f " { _structure_type } merge task ID " , index = True ) )
alter_db_add_column ( migrator , " knowledgebase " , f " { _structure_type } _task_finish_at " , DateTimeField ( null = True ) )
2026-01-08 16:44:53 +08:00
alter_db_column_type ( migrator , " tenant_llm " , " api_key " , TextField ( null = True , help_text = " API KEY " ) )
alter_db_add_column ( migrator , " tenant_llm " , " status " , CharField ( max_length = 1 , null = False , help_text = " is it validate(0: wasted, 1: validate) " , default = " 1 " , index = True ) )
alter_db_add_column ( migrator , " connector2kb " , " auto_parse " , CharField ( max_length = 1 , null = False , default = " 1 " , index = False ) )
alter_db_add_column ( migrator , " llm_factories " , " rank " , IntegerField ( default = 0 , index = False ) )
2026-02-05 19:19:09 +08:00
alter_db_add_column ( migrator , " api_4_conversation " , " name " , CharField ( max_length = 255 , null = True , help_text = " conversation name " , index = False ) )
alter_db_add_column ( migrator , " api_4_conversation " , " exp_user_id " , CharField ( max_length = 255 , null = True , help_text = " exp_user_id " , index = True ) )
2026-05-19 10:07:11 +08:00
alter_db_add_column ( migrator , " sync_logs " , " task_type " , CharField ( max_length = 32 , null = False , default = " sync " , index = True ) )
2026-08-18 09:53:11 +08:00
# Migrate system_settings.value from CharField to TextField for longer sandbox configs.
# Use the dedicated field so the GaussDB column remains nullable after the
# type change and the ORM restores NULL to the application-level "".
alter_db_column_type ( migrator , " system_settings " , " value " , EmptyStringTextField ( null = False , help_text = " Configuration value (JSON, string, etc.) " ) )
fix: re-chunk documents when data source content is updated (#12918)
Closes: #12889
### What problem does this PR solve?
When syncing external data sources (e.g., Jira, Confluence, Google
Drive), updated documents were not being re-chunked. The raw content was
correctly updated in blob storage, but the vector database retained
stale chunks, causing search results to return outdated information.
**Root cause:** The task digest used for chunk reuse optimization was
calculated only from parser configuration fields (`parser_id`,
`parser_config`, `kb_id`, etc.), without any content-dependent fields.
When a document's content changed but the parser configuration remained
the same, the system incorrectly reused old chunks instead of
regenerating new ones.
**Example scenario:**
1. User syncs a Jira issue: "Meeting scheduled for Monday"
2. User updates the Jira issue to: "Meeting rescheduled to Friday"
3. User triggers sync again
4. Raw content panel shows updated text ✓
5. Chunk panel still shows old text "Monday" ✗
**Solution:**
1. Include `update_time` and `size` in the chunking config, so the task
digest changes when document content is updated
2. Track updated documents separately in `upload_document()` and return
them for processing
3. Process updated documents through the re-parsing pipeline to
regenerate chunks
[1.webm](https://github.com/user-attachments/assets/d21d4dcd-e189-4d39-8700-053bae0ca5a0)
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
2026-03-06 06:48:47 +02:00
alter_db_add_column ( migrator , " document " , " content_hash " , CharField ( max_length = 32 , null = True , help_text = " xxhash128 of document content for change detection " , default = " " , index = True ) )
2026-03-10 14:25:27 +08:00
alter_db_add_column ( migrator , " user_canvas_version " , " release " , BooleanField ( null = False , help_text = " is released " , default = False , index = True ) )
2026-08-18 09:53:11 +08:00
alter_db_add_column (
migrator ,
" user_canvas " ,
" tags " ,
EmptyStringCharField (
max_length = 512 ,
null = False ,
default = " " ,
help_text = " Comma-separated tags for organizing agents " ,
index = True ,
) ,
)
2026-03-17 18:51:26 +08:00
alter_db_add_column ( migrator , " api_4_conversation " , " version_title " , CharField ( max_length = 255 , null = True , help_text = " canvas version title when session created " , index = False ) )
fix: change file size column from IntegerField to BigIntegerField to support files > 2GB (#14148)
### What problem does this PR solve?
Fixes #6034
Changes the `size` field in both `Document` and `File` models from
`IntegerField` (32-bit, max ~2GB) to `BigIntegerField` (64-bit, max
~9.2EB), and adds corresponding database migrations.
## Problem
When uploading a file larger than 2GB, the `size` value overflows a
32-bit signed integer (max 2,147,483,647). This causes:
- The stored `size` wraps around to an incorrect value (e.g., a 3GB file
shows as 2,097,152 KB in File Management).
- Subsequent file operations (e.g., download) fail because the corrupted
size leads to invalid storage lookups.
## Changes
- `Document.size`: `IntegerField` → `BigIntegerField`
- `File.size`: `IntegerField` → `BigIntegerField`
- Added `alter_db_column_type` migrations in `migrate_db()` for both
`document.size` and `file.size` columns to ensure existing deployments
are upgraded automatically.
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
Signed-off-by: noob <yixiao121314@outlook.com>
2026-04-16 15:43:29 +08:00
alter_db_column_type ( migrator , " document " , " size " , BigIntegerField ( default = 0 , index = True ) )
alter_db_column_type ( migrator , " file " , " size " , BigIntegerField ( default = 0 , index = True ) )
2026-05-29 17:39:41 +08:00
alter_db_add_column ( migrator , " tenant " , " ocr_id " , CharField ( max_length = 128 , null = True , help_text = " default ocr model ID " , index = True ) )
2026-06-16 12:02:12 +08:00
alter_db_column_type ( migrator , " chat_channel " , " status " , IntegerField ( default = 1 , index = True ) )
2026-06-16 19:02:20 +08:00
alter_db_rename_column ( migrator , " chat_channel " , " dialog_id " , " chat_id " )
2026-07-02 23:22:07 +08:00
# ---- FileCommit / FileCommitItem: artifact-page commit extension ----
alter_db_add_column ( migrator , " file_commit " , " title " , CharField ( max_length = 255 , null = True ) )
alter_db_add_column ( migrator , " file_commit " , " comments " , TextField ( null = True ) )
alter_db_add_column ( migrator , " file_commit_item " , " diff " , LongTextField ( null = True ) )
alter_db_add_column ( migrator , " file_commit_item " , " content_after_storage " , CharField ( max_length = 16 , null = True , index = True ) )
alter_db_add_column ( migrator , " file_commit_item " , " content_after_location " , CharField ( max_length = 512 , null = True ) )
alter_db_add_column ( migrator , " file_commit_item " , " slug_kwd " , CharField ( max_length = 512 , null = True , index = True ) )
alter_db_add_column ( migrator , " file_commit_item " , " page_type_kwd " , CharField ( max_length = 32 , null = True , index = True ) )
2026-07-16 23:00:44 +08:00
alter_db_drop_index ( migrator , " tenant_langfuse " , " idx_tenant_langfuse_secret_key " )
alter_db_drop_index ( migrator , " tenant_langfuse " , " idx_tenant_langfuse_public_key " )
alter_db_drop_index ( migrator , " tenant_langfuse " , " idx_tenant_langfuse_host " )
2026-08-18 09:53:11 +08:00
# Run after all alter_db_* calls so newly added compatible columns, such as
# user_canvas.tags, exist before their GaussDB NOT NULL constraints relax.
relax_gaussdb_empty_string_compatible_columns ( )
2026-07-16 23:00:44 +08:00
fix(db): drop Peewee-auto-named unique index on tenant_model_instance (#15699) (#15879)
## Summary
Fixes #15699.
User upgrades to v0.25.6 against an existing MySQL database, tries to
add an Ollama provider instance, and gets:
```
MySQL IntegrityError: Duplicate entry 'dbaafbfe608a11f1a5516d6066988224'
for key 'tenant_model_instance.tenantmodelinstance_api_key_provider_id'
```
The route at
[api/apps/restful_apis/provider_api.py:354](api/apps/restful_apis/provider_api.py#L354)
catches it and returns `get_error_data_result(message="Internal server
error")` — which by RAGFlow's convention is HTTP 200 with an error
`code` on the body — hence the reporter's "200 status code but the
database errored" complaint.
### Root cause
The provider-instance refactor in [PR
#15460](https://github.com/infiniflow/ragflow/pull/15460) dropped the
unique-compound-index tuple from `TenantModelInstance`:
```python
# Removed in #15460
class Meta:
db_table = "tenant_model_instance"
indexes = (
(("api_key", "provider_id"), True), # unique
)
```
and added a one-shot drop in `migrate_db()` for existing databases. But
the drop targets the wrong index name:
```python
# Before this PR — wrong name
for table_name, index_name in [
("tenant_model_instance", "idx_api_key_provider_id"), # ← doesn't exist
("tenant_model", "idx_provider_model_instance"),
]:
```
Peewee's auto-derived index name is `<lowercase
classname>_<col1>_<col2>` →
**`tenantmodelinstance_api_key_provider_id`**, which matches the user's
error verbatim. The drop raises `OperationalError: 1091 (HY000): Can't
DROP …`, the surrounding `except` clause at
[db_models.py:1736](api/db/db_models.py#L1736) swallows it as
expected-on-fresh-installs, and the legacy unique index lives on
indefinitely.
### Why Ollama hits it specifically
Ollama doesn't require an API key. The form posts `api_key: ""`. The
app-layer dedupe at
[provider_api_service.py:288-292](api/apps/services/provider_api_service.py#L288-L292):
```python
api_key_str = ""
if api_key: # ← skipped for ""
...
same_key_instance = TenantModelInstanceService.get_by_provider_id_and_api_key(...)
if same_key_instance:
return False, f"Already exist instance: ... with api_key {api_key}"
```
falls through for empty keys. Control reaches
`TenantModelInstanceService.create_instance(..., api_key="")` which
inserts a row whose `(api_key, provider_id) = ("", <provider_uuid>)`
collides with any prior Ollama row that already shipped that same pair →
the still-present unique index throws.
(`dbaafbfe608a11f1a5516d6066988224` in the user's error is the
duplicated `provider_id` UUID, paired with the empty `api_key`.)
### Fix
Add the Peewee auto-name alongside the existing `idx_*` entry so the
migration finally drops the obsolete index on next restart:
```python
legacy_indexes = [
("tenant_model_instance", "idx_api_key_provider_id"),
("tenant_model_instance", "tenantmodelinstance_api_key_provider_id"), # ← added
("tenant_model", "idx_provider_model_instance"),
]
```
The surrounding `try/except (OperationalError, ProgrammingError)`
matches `1091` / `can't DROP` / `does not exist` and treats them as
success, so every state is idempotent (see Test plan).
### Idempotency matrix
| Database state | First entry (`idx_api_key_provider_id`) | New entry
(`tenantmodelinstance_api_key_provider_id`) |
| --- | --- | --- |
| Fresh install (≥ #15460) — neither index exists | `1091` → swallowed |
`1091` → swallowed |
| Upgraded from before dc4b82523 (the user's case) — auto-name present |
`1091` → swallowed | **drops the index** |
| Upgraded after a manual rename to `idx_*` | drops the index | `1091` →
swallowed |
| Re-run of `migrate_db()` after either of the above | `1091` →
swallowed | `1091` → swallowed |
No rollback hazard: nothing depends on this unique constraint anymore
(`create_instance` dedupes by `instance_name` via `duplicate_name`, see
[tenant_model_instance_service.py:27](api/db/services/tenant_model_instance_service.py#L27)).
### What this PR does NOT change
- **`provider_api_service.create_provider_instance`** — its `if
api_key:` gate is correct *for the post-migration world*: multiple
Ollama instances with empty keys under one provider are legitimate, so
we shouldn't tighten the app-layer check.
- **`TenantModelInstance` Peewee model** — the `indexes` tuple was
already removed in #15460. New databases never get the constraint in the
first place.
- **The `except → get_error_data_result` → HTTP 200 pattern at
`provider_api.py:354`** — that's a project-wide convention; changing one
route to HTTP 500 would be inconsistent and out of scope.
## Test plan
- [ ] **Reproducer (pre-fix):** on a database originally created before
#15460, configure an Ollama provider with an empty `api_key`, then try
to create a *second* instance under the same provider — confirm the
`Duplicate entry … 'tenantmodelinstance_api_key_provider_id'` error in
the server log.
- [ ] **Verify the index is present pre-restart:** `SHOW INDEX FROM
tenant_model_instance WHERE Key_name =
'tenantmodelinstance_api_key_provider_id';` — non-empty result.
- [ ] **Restart with the fix applied:** server starts cleanly,
`migrate_db()` runs, no `Failed to drop index` in critical logs.
- [ ] **Verify the index is gone post-restart:** same `SHOW INDEX` query
— empty result.
- [ ] **Re-run the reproducer:** two Ollama instances under the same
provider, both `api_key=""`, both succeed.
- [ ] **Restart a second time** — no new errors; the matching `1091`
swallow keeps the migration idempotent.
- [ ] **Fresh install smoke test:** drop the DB volume, start clean — no
`1091` noise (the new index never existed), no functional regression.
## Files changed
- [api/db/db_models.py](api/db/db_models.py) — extend the legacy-index
drop list with `tenantmodelinstance_api_key_provider_id`; refactor the
inline list to a named `legacy_indexes` local with a comment pointing at
#15460 and #15699.
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [ ] New Feature (non-breaking change which adds functionality)
- [ ] Documentation Update
- [ ] Refactoring
- [ ] Performance Improvement
- [ ] Other (please describe):
Co-authored-by: Wang Qi <wangq8@outlook.com>
2026-06-11 00:47:12 -07:00
# Drop both the explicit "idx_*" name from later migrations AND the
# Peewee-auto-derived "<table-as-classname>_<col1>_<col2>" name from the
# original TenantModelInstance definition (commit dc4b82523). Databases
# created before #15460 dropped the model's `indexes = ((...,), True)`
# tuple still carry the auto-named compound unique index, which makes a
# second instance with an empty api_key (e.g. Ollama) fail with
# "Duplicate entry ... for key 'tenantmodelinstance_api_key_provider_id'"
# — see #15699.
legacy_indexes = [
( " tenant_model_instance " , " idx_api_key_provider_id " ) ,
( " tenant_model_instance " , " tenantmodelinstance_api_key_provider_id " ) ,
( " tenant_model " , " idx_provider_model_instance " ) ,
2026-07-29 14:54:20 +08:00
( " compilation_template " , " compilationtemplate_tenant_id_name_is_builtin_status " ) ,
( " compilation_template " , " compilation_template_tenant_id_name_is_builtin_status " ) ,
( " compilation_template " , " idx_compilation_template_tenant_id_name_is_builtin_status " ) ,
2026-08-06 13:24:09 +08:00
( " compilation_template_group " , " compilationtemplategroup_tenant_id_name_status " ) ,
( " compilation_template_group " , " compilation_template_group_tenant_id_name_status " ) ,
( " compilation_template_group " , " idx_compilation_template_group_tenant_id_name_status " ) ,
fix(db): drop Peewee-auto-named unique index on tenant_model_instance (#15699) (#15879)
## Summary
Fixes #15699.
User upgrades to v0.25.6 against an existing MySQL database, tries to
add an Ollama provider instance, and gets:
```
MySQL IntegrityError: Duplicate entry 'dbaafbfe608a11f1a5516d6066988224'
for key 'tenant_model_instance.tenantmodelinstance_api_key_provider_id'
```
The route at
[api/apps/restful_apis/provider_api.py:354](api/apps/restful_apis/provider_api.py#L354)
catches it and returns `get_error_data_result(message="Internal server
error")` — which by RAGFlow's convention is HTTP 200 with an error
`code` on the body — hence the reporter's "200 status code but the
database errored" complaint.
### Root cause
The provider-instance refactor in [PR
#15460](https://github.com/infiniflow/ragflow/pull/15460) dropped the
unique-compound-index tuple from `TenantModelInstance`:
```python
# Removed in #15460
class Meta:
db_table = "tenant_model_instance"
indexes = (
(("api_key", "provider_id"), True), # unique
)
```
and added a one-shot drop in `migrate_db()` for existing databases. But
the drop targets the wrong index name:
```python
# Before this PR — wrong name
for table_name, index_name in [
("tenant_model_instance", "idx_api_key_provider_id"), # ← doesn't exist
("tenant_model", "idx_provider_model_instance"),
]:
```
Peewee's auto-derived index name is `<lowercase
classname>_<col1>_<col2>` →
**`tenantmodelinstance_api_key_provider_id`**, which matches the user's
error verbatim. The drop raises `OperationalError: 1091 (HY000): Can't
DROP …`, the surrounding `except` clause at
[db_models.py:1736](api/db/db_models.py#L1736) swallows it as
expected-on-fresh-installs, and the legacy unique index lives on
indefinitely.
### Why Ollama hits it specifically
Ollama doesn't require an API key. The form posts `api_key: ""`. The
app-layer dedupe at
[provider_api_service.py:288-292](api/apps/services/provider_api_service.py#L288-L292):
```python
api_key_str = ""
if api_key: # ← skipped for ""
...
same_key_instance = TenantModelInstanceService.get_by_provider_id_and_api_key(...)
if same_key_instance:
return False, f"Already exist instance: ... with api_key {api_key}"
```
falls through for empty keys. Control reaches
`TenantModelInstanceService.create_instance(..., api_key="")` which
inserts a row whose `(api_key, provider_id) = ("", <provider_uuid>)`
collides with any prior Ollama row that already shipped that same pair →
the still-present unique index throws.
(`dbaafbfe608a11f1a5516d6066988224` in the user's error is the
duplicated `provider_id` UUID, paired with the empty `api_key`.)
### Fix
Add the Peewee auto-name alongside the existing `idx_*` entry so the
migration finally drops the obsolete index on next restart:
```python
legacy_indexes = [
("tenant_model_instance", "idx_api_key_provider_id"),
("tenant_model_instance", "tenantmodelinstance_api_key_provider_id"), # ← added
("tenant_model", "idx_provider_model_instance"),
]
```
The surrounding `try/except (OperationalError, ProgrammingError)`
matches `1091` / `can't DROP` / `does not exist` and treats them as
success, so every state is idempotent (see Test plan).
### Idempotency matrix
| Database state | First entry (`idx_api_key_provider_id`) | New entry
(`tenantmodelinstance_api_key_provider_id`) |
| --- | --- | --- |
| Fresh install (≥ #15460) — neither index exists | `1091` → swallowed |
`1091` → swallowed |
| Upgraded from before dc4b82523 (the user's case) — auto-name present |
`1091` → swallowed | **drops the index** |
| Upgraded after a manual rename to `idx_*` | drops the index | `1091` →
swallowed |
| Re-run of `migrate_db()` after either of the above | `1091` →
swallowed | `1091` → swallowed |
No rollback hazard: nothing depends on this unique constraint anymore
(`create_instance` dedupes by `instance_name` via `duplicate_name`, see
[tenant_model_instance_service.py:27](api/db/services/tenant_model_instance_service.py#L27)).
### What this PR does NOT change
- **`provider_api_service.create_provider_instance`** — its `if
api_key:` gate is correct *for the post-migration world*: multiple
Ollama instances with empty keys under one provider are legitimate, so
we shouldn't tighten the app-layer check.
- **`TenantModelInstance` Peewee model** — the `indexes` tuple was
already removed in #15460. New databases never get the constraint in the
first place.
- **The `except → get_error_data_result` → HTTP 200 pattern at
`provider_api.py:354`** — that's a project-wide convention; changing one
route to HTTP 500 would be inconsistent and out of scope.
## Test plan
- [ ] **Reproducer (pre-fix):** on a database originally created before
#15460, configure an Ollama provider with an empty `api_key`, then try
to create a *second* instance under the same provider — confirm the
`Duplicate entry … 'tenantmodelinstance_api_key_provider_id'` error in
the server log.
- [ ] **Verify the index is present pre-restart:** `SHOW INDEX FROM
tenant_model_instance WHERE Key_name =
'tenantmodelinstance_api_key_provider_id';` — non-empty result.
- [ ] **Restart with the fix applied:** server starts cleanly,
`migrate_db()` runs, no `Failed to drop index` in critical logs.
- [ ] **Verify the index is gone post-restart:** same `SHOW INDEX` query
— empty result.
- [ ] **Re-run the reproducer:** two Ollama instances under the same
provider, both `api_key=""`, both succeed.
- [ ] **Restart a second time** — no new errors; the matching `1091`
swallow keeps the migration idempotent.
- [ ] **Fresh install smoke test:** drop the DB volume, start clean — no
`1091` noise (the new index never existed), no functional regression.
## Files changed
- [api/db/db_models.py](api/db/db_models.py) — extend the legacy-index
drop list with `tenantmodelinstance_api_key_provider_id`; refactor the
inline list to a named `legacy_indexes` local with a comment pointing at
#15460 and #15699.
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [ ] New Feature (non-breaking change which adds functionality)
- [ ] Documentation Update
- [ ] Refactoring
- [ ] Performance Improvement
- [ ] Other (please describe):
Co-authored-by: Wang Qi <wangq8@outlook.com>
2026-06-11 00:47:12 -07:00
]
for table_name , index_name in legacy_indexes :
2026-06-02 13:24:53 +08:00
try :
migrate ( migrator . drop_index ( table_name , index_name ) )
except ( OperationalError , ProgrammingError ) as ex :
msg = str ( ex )
2026-08-18 09:53:11 +08:00
if is_gaussdb_compatible_database ( ) :
can_skip = is_undefined_object_error ( ex ) or is_duplicate_object_error ( ex )
else :
can_skip = " 1091 " in msg or " can ' t DROP " in msg . lower ( ) or " does not exist " in msg . lower ( ) or " already exists " in msg . lower ( )
if can_skip :
2026-06-02 13:24:53 +08:00
pass
else :
logging . critical ( f " Failed to drop index { index_name } on { table_name } : { ex } " )
except Exception as ex :
logging . critical ( f " Failed to drop index { index_name } on { table_name } : { ex } " )
2025-08-15 17:44:58 +08:00
logging . disable ( logging . NOTSET )
2026-02-27 12:55:51 +01:00
# this is after re-enabling logging to allow logging changed user emails
migrate_add_unique_email ( migrator )
2026-07-13 16:42:55 +08:00
migrate_model_type_names ( )
2026-07-13 17:34:28 +08:00
ensure_model_indexes ( migrator )
2026-07-13 16:42:55 +08:00
def migrate_model_type_names ( ) :
""" Rename legacy model_type string values to the canonical asr/vision names.
Previously the code used speech2text / image2text . LLMType now emits asr /
vision , and the backend compares model_type strings directly . This idempotent
data migration updates persisted rows in llm and tenant_llm before the new
enum values are used at runtime .
"""
RENAME_MAP = {
" speech2text " : " asr " ,
" image2text " : " vision " ,
}
tables = [ " llm " , " tenant_llm " ]
for table in tables :
if not DB . table_exists ( table ) :
continue
for old_name , new_name in RENAME_MAP . items ( ) :
try :
cursor = DB . execute_sql (
" UPDATE {} SET model_type = %s WHERE model_type = %s " . format ( table ) ,
( new_name , old_name ) ,
)
if cursor . rowcount :
logging . info (
" Migrated %s rows in %s .model_type from %s to %s " ,
cursor . rowcount ,
table ,
old_name ,
new_name ,
)
except Exception as ex :
logging . warning (
" Failed to migrate model_type values in %s (from %s to %s ): %s " ,
table ,
old_name ,
new_name ,
ex ,
)