Skip to content

Commit 2f70188

Browse files
fix(tables): route large CSV imports to the background job instead of 413 (#4927)
* fix(tables): route large CSV imports to the background job instead of 413 * fix(tables): drop duplicate error toast on async import failure * fix(tables): guard importId on async cancel and drop mutation objects from deps
1 parent 37f1141 commit 2f70188

2 files changed

Lines changed: 146 additions & 57 deletions

File tree

apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx

Lines changed: 78 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,26 @@ import {
2323
TableRow,
2424
toast,
2525
} from '@/components/emcn'
26+
import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES } from '@/lib/table/constants'
2627
import { buildAutoMapping, parseCsvBuffer } from '@/lib/table/import'
2728
import type { TableDefinition } from '@/lib/table/types'
28-
import { type CsvImportMode, useImportCsvIntoTable } from '@/hooks/queries/tables'
29+
import {
30+
type CsvImportMode,
31+
cancelTableImport,
32+
useImportCsvIntoTable,
33+
useImportCsvIntoTableAsync,
34+
} from '@/hooks/queries/tables'
35+
import { useImportTrayStore } from '@/stores/table/import-tray/store'
2936

3037
const logger = createLogger('ImportCsvDialog')
3138

3239
const MAX_SAMPLE_ROWS = 5
3340
const MAX_EXAMPLES_IN_ERROR = 3
41+
/**
42+
* Bytes read for the preview/mapping. We never parse the whole file client-side — the importer
43+
* streams it server-side and the DB row-count trigger enforces the row limit.
44+
*/
45+
const CSV_PREVIEW_BYTES = 512 * 1024
3446
/**
3547
* Sentinel value for the "Do not import" option in the mapping combobox. The
3648
* whitespace is intentional: valid column names must match `NAME_PATTERN`
@@ -92,7 +104,18 @@ interface ParsedCsv {
92104
file: File
93105
headers: string[]
94106
sampleRows: Record<string, unknown>[]
95-
totalRows: number
107+
}
108+
109+
/** Parses the head of a CSV/TSV for the mapping + sample, dropping any truncated final line. */
110+
async function parseCsvPreview(file: File, delimiter: ',' | '\t') {
111+
const sliced = file.size > CSV_PREVIEW_BYTES
112+
const blob = sliced ? file.slice(0, CSV_PREVIEW_BYTES) : file
113+
let bytes = new Uint8Array(await blob.arrayBuffer())
114+
if (sliced) {
115+
const lastNewline = bytes.lastIndexOf(0x0a)
116+
if (lastNewline > 0) bytes = bytes.subarray(0, lastNewline + 1)
117+
}
118+
return parseCsvBuffer(bytes, delimiter)
96119
}
97120

98121
export function ImportCsvDialog({
@@ -110,6 +133,7 @@ export function ImportCsvDialog({
110133
const [createHeaders, setCreateHeaders] = useState<Set<string>>(new Set())
111134
const [mode, setMode] = useState<CsvImportMode>('append')
112135
const importMutation = useImportCsvIntoTable()
136+
const importAsyncMutation = useImportCsvIntoTableAsync()
113137

114138
function resetState() {
115139
setParsed(null)
@@ -155,15 +179,13 @@ export function ImportCsvDialog({
155179
setParsing(true)
156180
setParseError(null)
157181
try {
158-
const arrayBuffer = await file.arrayBuffer()
159-
const delimiter = ext === 'tsv' ? '\t' : ','
160-
const { headers, rows } = await parseCsvBuffer(new Uint8Array(arrayBuffer), delimiter)
182+
const delimiter: ',' | '\t' = ext === 'tsv' ? '\t' : ','
183+
const { headers, rows } = await parseCsvPreview(file, delimiter)
161184
const autoMapping = buildAutoMapping(headers, table.schema)
162185
setParsed({
163186
file,
164187
headers,
165188
sampleRows: rows.slice(0, MAX_SAMPLE_ROWS),
166-
totalRows: rows.length,
167189
})
168190
setMapping(autoMapping)
169191
} catch (err) {
@@ -256,36 +278,70 @@ export function ImportCsvDialog({
256278
}
257279
}, [mapping, parsed?.headers, table.schema.columns, createHeaders])
258280

259-
const appendCapacityDeficit =
260-
parsed && mode === 'append' && table.rowCount + parsed.totalRows > table.maxRows
261-
? table.rowCount + parsed.totalRows - table.maxRows
262-
: 0
263-
264-
const replaceCapacityDeficit =
265-
parsed && mode === 'replace' && parsed.totalRows > table.maxRows
266-
? parsed.totalRows - table.maxRows
267-
: 0
268-
269281
const canSubmit =
270282
parsed !== null &&
271283
!importMutation.isPending &&
284+
!importAsyncMutation.isPending &&
272285
missingRequired.length === 0 &&
273286
duplicateTargets.length === 0 &&
274-
mappedCount + createCount > 0 &&
275-
appendCapacityDeficit === 0 &&
276-
replaceCapacityDeficit === 0
287+
mappedCount + createCount > 0
277288

278289
async function handleSubmit() {
279290
if (!parsed || !canSubmit) return
280291
setSubmitError(null)
292+
const createColumns = createHeaders.size > 0 ? [...createHeaders] : undefined
293+
294+
// Large files can't be POSTed through the server (request-body cap) — upload them
295+
// straight to storage and import in the background instead. Seed the header tray and
296+
// close the dialog immediately so the indicator is visible during the upload, then run
297+
// the upload + kickoff in the background (don't block the dialog on it).
298+
if (parsed.file.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES) {
299+
useImportTrayStore.getState().startUpload({
300+
uploadId: table.id,
301+
workspaceId,
302+
title: parsed.file.name,
303+
})
304+
onOpenChange(false)
305+
toast.success(`Importing "${parsed.file.name}" into "${table.name}" in the background`)
306+
importAsyncMutation.mutate(
307+
{
308+
workspaceId,
309+
tableId: table.id,
310+
file: parsed.file,
311+
mode,
312+
mapping,
313+
createColumns,
314+
onProgress: (percent) => {
315+
useImportTrayStore.getState().setUploadPercent(table.id, percent)
316+
},
317+
},
318+
{
319+
onSuccess: (data) => {
320+
useImportTrayStore.getState().endUpload(table.id)
321+
// The server row drives the tray once the list refetches. If canceled mid-upload, flag
322+
// the id so it's not shown and cancel the worker server-side.
323+
if (useImportTrayStore.getState().consumeCanceled(table.id) && data?.importId) {
324+
useImportTrayStore.getState().cancel(table.id)
325+
void cancelTableImport(workspaceId, table.id, data.importId).catch(() => {})
326+
}
327+
},
328+
onError: () => {
329+
// The hook's onError surfaces the toast; just clear the tray indicator here.
330+
useImportTrayStore.getState().endUpload(table.id)
331+
},
332+
}
333+
)
334+
return
335+
}
336+
281337
try {
282338
const result = await importMutation.mutateAsync({
283339
workspaceId,
284340
tableId: table.id,
285341
file: parsed.file,
286342
mode,
287343
mapping,
288-
createColumns: createHeaders.size > 0 ? [...createHeaders] : undefined,
344+
createColumns,
289345
})
290346
const data = result.data
291347
if (mode === 'append') {
@@ -307,11 +363,7 @@ export function ImportCsvDialog({
307363
}
308364
}
309365

310-
const hasWarning =
311-
missingRequired.length > 0 ||
312-
duplicateTargets.length > 0 ||
313-
appendCapacityDeficit > 0 ||
314-
replaceCapacityDeficit > 0
366+
const hasWarning = missingRequired.length > 0 || duplicateTargets.length > 0
315367

316368
return (
317369
<ChipModal
@@ -344,7 +396,7 @@ export function ImportCsvDialog({
344396
{parsed.file.name}
345397
</span>
346398
<span className='text-[var(--text-tertiary)] text-xs'>
347-
{parsed.totalRows.toLocaleString()} rows · {parsed.headers.length} columns
399+
{parsed.headers.length} columns
348400
</span>
349401
</div>
350402
<Button variant='ghost' size='sm' onClick={resetState}>
@@ -442,20 +494,6 @@ export function ImportCsvDialog({
442494
Multiple CSV columns target: {duplicateTargets.join(', ')} (pick one)
443495
</p>
444496
)}
445-
{appendCapacityDeficit > 0 && (
446-
<p className='text-[var(--text-error)] text-caption leading-tight'>
447-
Append would exceed the row limit ({table.maxRows.toLocaleString()}) by{' '}
448-
{appendCapacityDeficit.toLocaleString()} row(s). Remove rows or switch to
449-
Replace.
450-
</p>
451-
)}
452-
{replaceCapacityDeficit > 0 && (
453-
<p className='text-[var(--text-error)] text-caption leading-tight'>
454-
CSV has {parsed.totalRows.toLocaleString()} rows, which exceeds the table limit
455-
of {table.maxRows.toLocaleString()} by {replaceCapacityDeficit.toLocaleString()}
456-
.
457-
</p>
458-
)}
459497
</div>
460498
)}
461499

apps/sim/app/workspace/[workspaceId]/tables/tables.tsx

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
5+
import { generateId } from '@sim/utils/id'
56
import { useParams, useRouter } from 'next/navigation'
67
import type { ComboboxOption } from '@/components/emcn'
78
import { ChipCombobox, ChipConfirmModal, toast, Upload } from '@/components/emcn'
89
import { Columns3, Rows3, Table as TableIcon } from '@/components/emcn/icons'
910
import type { TableDefinition } from '@/lib/table'
10-
import { generateUniqueTableName } from '@/lib/table/constants'
11+
import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES, generateUniqueTableName } from '@/lib/table/constants'
1112
import type {
1213
FilterTag,
1314
ResourceColumn,
@@ -24,14 +25,17 @@ import {
2425
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
2526
import {
2627
ImportCsvDialog,
28+
ImportProgressMenu,
2729
TablesListContextMenu,
2830
} from '@/app/workspace/[workspaceId]/tables/components'
2931
import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu'
3032
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
3133
import {
34+
cancelTableImport,
3235
downloadTableExport,
3336
useCreateTable,
3437
useDeleteTable,
38+
useImportCsvAsync,
3539
useRenameTable,
3640
useTablesList,
3741
useUploadCsvToTable,
@@ -40,6 +44,7 @@ import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
4044
import { useDebounce } from '@/hooks/use-debounce'
4145
import { useInlineRename } from '@/hooks/use-inline-rename'
4246
import { usePermissionConfig } from '@/hooks/use-permission-config'
47+
import { useImportTrayStore } from '@/stores/table/import-tray/store'
4348

4449
const logger = createLogger('Tables')
4550

@@ -76,6 +81,7 @@ export function Tables() {
7681
const renameTable = useRenameTable(workspaceId)
7782
const createTable = useCreateTable(workspaceId)
7883
const uploadCsv = useUploadCsvToTable()
84+
const importCsvAsync = useImportCsvAsync()
7985

8086
const tableRename = useInlineRename({
8187
onSave: (tableId, name) => renameTable.mutate({ tableId, name }),
@@ -407,37 +413,80 @@ export function Tables() {
407413
const list = e.target.files
408414
if (!list || list.length === 0 || !workspaceId) return
409415

410-
try {
411-
setUploading(true)
416+
const csvFiles = Array.from(list).filter((f) => {
417+
const ext = f.name.split('.').pop()?.toLowerCase()
418+
return ext === 'csv' || ext === 'tsv'
419+
})
420+
421+
if (csvFiles.length === 0) {
422+
toast.error('No CSV or TSV files selected')
423+
if (csvInputRef.current) csvInputRef.current.value = ''
424+
return
425+
}
412426

413-
const csvFiles = Array.from(list).filter((f) => {
414-
const ext = f.name.split('.').pop()?.toLowerCase()
415-
return ext === 'csv' || ext === 'tsv'
416-
})
427+
// Large files can't be POSTed through the server (request-body cap) — upload them
428+
// straight to storage and import in the background. These are tracked by the import
429+
// tray, never the header upload button, so don't touch uploading/uploadProgress here.
430+
const asyncFiles = csvFiles.filter((f) => f.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES)
431+
const syncFiles = csvFiles.filter((f) => f.size < CSV_ASYNC_IMPORT_THRESHOLD_BYTES)
417432

418-
if (csvFiles.length === 0) {
419-
toast.error('No CSV or TSV files selected')
420-
return
433+
try {
434+
for (const file of asyncFiles) {
435+
// Show the indicator immediately under a temporary id (the real table id doesn't
436+
// exist until kickoff returns), then let the tray track it. Don't redirect — the
437+
// table is still empty/importing, so stay on the list.
438+
const pendingId = `pending_${generateId()}`
439+
useImportTrayStore
440+
.getState()
441+
.startUpload({ uploadId: pendingId, workspaceId, title: file.name })
442+
toast.success(`Importing "${file.name}" in the background`)
443+
try {
444+
const result = await importCsvAsync.mutateAsync({
445+
workspaceId,
446+
file,
447+
onProgress: (percent) => {
448+
useImportTrayStore.getState().setUploadPercent(pendingId, percent)
449+
},
450+
})
451+
useImportTrayStore.getState().endUpload(pendingId)
452+
// The server row drives the tray once the list refetches (mutation invalidates it).
453+
// If canceled mid-upload, flag the real id so it's not shown and cancel server-side.
454+
if (
455+
result?.tableId &&
456+
result.importId &&
457+
useImportTrayStore.getState().consumeCanceled(pendingId)
458+
) {
459+
useImportTrayStore.getState().cancel(result.tableId)
460+
void cancelTableImport(workspaceId, result.tableId, result.importId).catch(() => {})
461+
}
462+
} catch {
463+
// The hook's onError surfaces the toast; just clear the tray indicator here.
464+
useImportTrayStore.getState().endUpload(pendingId)
465+
}
421466
}
422467

423-
setUploadProgress({ completed: 0, total: csvFiles.length })
468+
if (syncFiles.length === 0) return
469+
470+
setUploading(true)
471+
setUploadProgress({ completed: 0, total: syncFiles.length })
424472
const failed: string[] = []
425473

426-
for (let i = 0; i < csvFiles.length; i++) {
474+
for (let i = 0; i < syncFiles.length; i++) {
475+
const file = syncFiles[i]
427476
try {
428-
const result = await uploadCsv.mutateAsync({ workspaceId, file: csvFiles[i] })
477+
const result = await uploadCsv.mutateAsync({ workspaceId, file })
429478

430-
if (csvFiles.length === 1) {
479+
if (syncFiles.length === 1 && asyncFiles.length === 0) {
431480
const tableId = result?.data?.table?.id
432481
if (tableId) {
433482
router.push(`/workspace/${workspaceId}/tables/${tableId}`)
434483
}
435484
}
436485
} catch (err) {
437-
failed.push(csvFiles[i].name)
486+
failed.push(file.name)
438487
logger.error('Error uploading CSV:', err)
439488
} finally {
440-
setUploadProgress({ completed: i + 1, total: csvFiles.length })
489+
setUploadProgress({ completed: i + 1, total: syncFiles.length })
441490
}
442491
}
443492

@@ -459,7 +508,8 @@ export function Tables() {
459508
}
460509
}
461510
},
462-
[workspaceId, router, uploadCsv]
511+
// eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5
512+
[workspaceId, router]
463513
)
464514

465515
const handleListUploadCsv = useCallback(() => {
@@ -508,6 +558,7 @@ export function Tables() {
508558
sort={sortConfig}
509559
filter={filterContent}
510560
filterTags={filterTags}
561+
leadingActions={<ImportProgressMenu workspaceId={workspaceId} />}
511562
headerActions={[
512563
{
513564
label: uploadButtonLabel,

0 commit comments

Comments
 (0)