fix(import): lift the stale xlsx/xlsm size limit via incremental reads

This commit is contained in:
zipg
2026-10-12 23:19:13 +08:00
committed by GitHub
parent 0feb065e48
commit e3dfe324e8
3 changed files with 49 additions and 20 deletions
+45 -20
View File
@@ -39,7 +39,8 @@ pub const DEFAULT_PREVIEW_LIMIT: usize = 50;
pub const DEFAULT_BATCH_SIZE: usize = 500;
pub const CREATE_TABLE_INFERENCE_ROWS: usize = 100;
/// 分隔文本、`.sql` 脚本与 JSON 都已改为流式解析,不再受体积上限约束;
/// 该上限只保留给仍然需要整份物化的 Excel(xlsx)。
/// `.xlsx`/`.xlsm` 的预览与导入同样按增量方式读取工作表;
/// 该上限只保留给仍然需要整份物化的旧版 `.xls`,以及被显式标记为 Excel 的其他文件。
pub const MAX_NON_STREAMING_IMPORT_BYTES: u64 = 100 * 1024 * 1024;
pub const MAX_LEGACY_XLS_IMPORT_BYTES: u64 = 50 * 1024 * 1024;
const IMPORT_PROGRESS_INTERVAL: Duration = Duration::from_millis(100);
@@ -3712,6 +3713,16 @@ fn is_legacy_xls_path(path: &str) -> bool {
.is_some_and(|extension| extension.eq_ignore_ascii_case("xls"))
}
/// `.xlsx`/`.xlsm` 由增量解析器处理:预览只读取需要的工作表行,导入按有界批次流式产出,
/// 内存占用不随工作簿体积增长。calamine 的旧版 `.xls` 读取器会一次读入整个工作簿,
/// 因此只有这两个扩展名可以跳过非流式体积上限。
fn excel_path_streams_incrementally(path: &str) -> bool {
Path::new(path)
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("xlsx") || extension.eq_ignore_ascii_case("xlsm"))
}
fn xlsx_style_selection_columns<T, Label>(range: &Range<T>, row_range: ImportRowRange, cell_label: Label) -> Vec<String>
where
T: CellType,
@@ -3762,9 +3773,8 @@ fn parse_xlsx_file_with_options_and_text_columns(
} else {
sheet_names.first().cloned().ok_or_else(|| "Workbook has no sheets".to_string())?
};
let extension = Path::new(path).extension().and_then(|extension| extension.to_str()).unwrap_or_default();
let legacy_xls = is_legacy_xls_path(path);
if extension.eq_ignore_ascii_case("xlsx") || extension.eq_ignore_ascii_case("xlsm") {
if excel_path_streams_incrementally(path) {
let range = workbook.worksheet_range_ref(&sheet_name).map_err(|e| e.to_string())?;
let row_range = effective_import_row_range(options)?;
let style_selection_columns =
@@ -4508,17 +4518,18 @@ pub fn parse_xlsx_file(path: &str, preview_limit: usize) -> Result<ParsedImportF
}
fn ensure_non_streaming_file_size(path: &str, format: TableImportSourceFormat) -> Result<(), String> {
// 分隔文本、SQL 脚本与 JSON 都是流式解析,内存占用不随文件体积增长,没有体积上限。
// 分隔文本、SQL 脚本、JSON、Parquet 与 `.xlsx`/`.xlsm` 都是流式解析,
// 内存占用不随文件体积增长,没有体积上限。
if format.is_delimited()
|| format == TableImportSourceFormat::Sql
|| format == TableImportSourceFormat::Json
|| format == TableImportSourceFormat::Parquet
|| (format == TableImportSourceFormat::Excel && excel_path_streams_incrementally(path))
{
return Ok(());
}
let metadata = std::fs::metadata(path).map_err(|e| e.to_string())?;
let extension = Path::new(path).extension().and_then(|extension| extension.to_str()).unwrap_or_default();
let max_bytes = if format == TableImportSourceFormat::Excel && extension.eq_ignore_ascii_case("xls") {
let max_bytes = if format == TableImportSourceFormat::Excel && is_legacy_xls_path(path) {
MAX_LEGACY_XLS_IMPORT_BYTES
} else {
MAX_NON_STREAMING_IMPORT_BYTES
@@ -4604,10 +4615,7 @@ async fn parse_import_preview_file_with_options(
}
ensure_non_streaming_file_size(path, format)?;
let extension = Path::new(path).extension().and_then(|extension| extension.to_str()).unwrap_or_default();
if format == TableImportSourceFormat::Excel
&& (extension.eq_ignore_ascii_case("xlsx") || extension.eq_ignore_ascii_case("xlsm"))
{
if format == TableImportSourceFormat::Excel && excel_path_streams_incrementally(path) {
let path = path.to_string();
let options = options.clone();
let (parsed, sheets) =
@@ -7703,11 +7711,7 @@ where
return Ok(import_summary(&request.import_id, rows_imported, rows_imported, started_at));
}
let extension =
Path::new(&request.file_path).extension().and_then(|extension| extension.to_str()).unwrap_or_default();
if source_format == TableImportSourceFormat::Excel
&& (extension.eq_ignore_ascii_case("xlsx") || extension.eq_ignore_ascii_case("xlsm"))
{
if source_format == TableImportSourceFormat::Excel && excel_path_streams_incrementally(&request.file_path) {
let total_bytes = tokio::fs::metadata(&request.file_path).await.map(|metadata| metadata.len()).unwrap_or(0);
progress_callback(import_progress_with_details(
&request.import_id,
@@ -10782,9 +10786,9 @@ mod tests {
let _ = std::fs::remove_file(path);
}
/// `.sql` 与 JSON 都已经改成流式解析,不应再受 100 MB 非流式上限约束。
/// `.sql`、JSON 与 `.xlsx`/`.xlsm` 都已经改成流式解析,不应再受 100 MB 非流式上限约束。
#[test]
fn sql_and_json_imports_are_not_size_capped() {
fn streaming_imports_are_not_size_capped() {
let path = std::env::temp_dir().join(format!("dbx-table-import-limit-{}.sql", uuid::Uuid::new_v4()));
let file = File::create(&path).unwrap();
file.set_len(MAX_NON_STREAMING_IMPORT_BYTES + 1).unwrap();
@@ -10796,13 +10800,34 @@ mod tests {
let xlsx_file = File::create(&xlsx_path).unwrap();
xlsx_file.set_len(MAX_NON_STREAMING_IMPORT_BYTES + 1).unwrap();
drop(xlsx_file);
let error =
ensure_non_streaming_file_size(&xlsx_path.to_string_lossy(), TableImportSourceFormat::Excel).unwrap_err();
assert!(error.contains("File too large for excel import"), "{error}");
ensure_non_streaming_file_size(&xlsx_path.to_string_lossy(), TableImportSourceFormat::Excel).unwrap();
ensure_non_streaming_file_size(&xlsx_path.to_string_lossy(), TableImportSourceFormat::Json).unwrap();
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(xlsx_path);
}
/// `.xlsm` 与 `.xlsx` 走同一条流式解析路径,因此同样不受非流式上限约束;
/// 其他被显式标记为 Excel 的路径仍然由 calamine 整份读入,必须保留上限。
#[test]
fn macro_enabled_workbooks_stream_but_materialized_excel_paths_stay_capped() {
let xlsm_path = std::env::temp_dir().join(format!("dbx-table-import-limit-{}.xlsm", uuid::Uuid::new_v4()));
let xlsm_file = File::create(&xlsm_path).unwrap();
xlsm_file.set_len(MAX_NON_STREAMING_IMPORT_BYTES + 1).unwrap();
drop(xlsm_file);
ensure_non_streaming_file_size(&xlsm_path.to_string_lossy(), TableImportSourceFormat::Excel).unwrap();
let mislabeled_path = std::env::temp_dir().join(format!("dbx-table-import-limit-{}.txt", uuid::Uuid::new_v4()));
let mislabeled_file = File::create(&mislabeled_path).unwrap();
mislabeled_file.set_len(MAX_NON_STREAMING_IMPORT_BYTES + 1).unwrap();
drop(mislabeled_file);
let error = ensure_non_streaming_file_size(&mislabeled_path.to_string_lossy(), TableImportSourceFormat::Excel)
.unwrap_err();
assert!(error.contains("File too large for excel import"), "{error}");
let _ = std::fs::remove_file(xlsm_path);
let _ = std::fs::remove_file(mislabeled_path);
}
/// 流式 `.sql` 在读完整份脚本之前,`total_rows` 只是已解析的部分行数。
/// 失败与取消事件必须沿用「总数未知」这个标志,否则界面会把 19000 行导入
/// 显示成 `19000 / 2771` 这种自相矛盾的进度。
+2
View File
@@ -34,6 +34,8 @@ SQL 导入只接受字面量 `INSERT INTO ... VALUES` 行;表达式、`REPLACE
SQL 导入按分块流式读取脚本,不把整个文件读进内存,因此没有文件体积上限,超大文件的内存占用也保持平稳。总行数要等脚本扫描到结尾才能确定,所以导入过程中的进度按已读取字节数显示,总行数在导入完成后给出。脚本是边扫描边写入的,中途某条语句失败时,已经写入的行会保留在目标表里;预览步骤会先完整解析一遍脚本,所以格式有问题的脚本通常会在写入前就被拦下。`TO_DATE`、`TO_TIMESTAMP` 等日期函数的值会按第一个参数的文本导入,目标字段必须能解析该格式。
Excel 导入按增量方式读取 `.xlsx` 和 `.xlsm` 工作表:预览只读取需要的行,导入按有界批次产出,因此大工作簿同样没有文件体积上限。旧版 `.xls` 仍由 Excel 读取器一次载入整份工作簿,上限为 50 MB。
Desktop 直接读取所选本地文件;Docker/Web 会先把浏览器选择的文件上传到服务器临时目录,预览和导入结束后释放临时源文件。
Parquet 导入使用所选 DuckDB 连接的原生 Parquet 读取能力,仍然沿用预览、字段映射、新建表、追加和清空后导入流程;其他数据库类型不会提供 Parquet 导入格式。大型 Parquet 文件按有界批次读取,嵌套值仍受目标字段类型和 DuckDB 类型转换规则约束。
+2
View File
@@ -34,6 +34,8 @@ SQL import accepts only literal `INSERT INTO ... VALUES` rows. Expressions, `REP
SQL import streams the script in chunks instead of loading it into memory, so there is no file-size limit and memory use stays flat on large dumps. The total row count is only known once the script has been scanned to the end, so progress during import is reported by bytes read and the row count appears when the import finishes. Because rows are written while the script is still being scanned, a statement that fails half-way leaves the rows already written in the target table; the preview step parses the entire script first, so a malformed dump is normally rejected before any data is written. Values written with date functions such as `TO_DATE` and `TO_TIMESTAMP` are imported as the text of their first argument, and the target column must be able to parse that format.
Excel import reads `.xlsx` and `.xlsm` workbooks incrementally: the preview stops after the rows it needs and the import produces bounded row batches, so large workbooks have no file-size limit either. Legacy `.xls` workbooks still load the whole file through the Excel reader and are limited to 50 MB.
Desktop reads selected local files directly. Docker/Web uploads browser-selected files to temporary server storage, then releases the prepared source after preview and import finish.
Parquet import uses the selected DuckDB connection's native Parquet reader. The same preview, mapping, new-table, append, and truncate workflow applies, while other database types do not offer Parquet as an import format. Large Parquet sources are read in bounded batches; nested values remain subject to the target column type and DuckDB conversion rules.