Skip to content

Commit 9b9a6c9

Browse files
committed
MDH: chunked Import from JSON file with progress, cancel, overwrite mode
The old "Insert from file" sent the entire parsed array in one /data/insert_many call. When the server returned a BulkWriteError, the data-storage backend collapsed pymongo's per-doc writeErrors into the bare string "batch op errors occurred" — leaving the user with no clue which documents failed or why. For large files the failure was indistinguishable from a timeout, gateway limit, or partial-success. Replace with a multi-stage wizard (pick → confirm → importing → done): - Chunked insert at the same BATCH_SIZE constant as the dataset download (1000 docs per request), so up and down round-trip with matching shape. - In-file _id deduplication (local, no network) before any insert. - Continue-past-failure semantics: each batch is independent, failed batches are recorded with their record range and surfaced in the summary alongside a hint about the most common cause. - After a failed batch we re-probe just that batch's _ids to count which actually landed, so the "inserted" total doesn't under-report due to the collapsed BulkWriteError detail. - Cancel button aborts cleanly between batches. - Overwrite mode for idempotent re-import: deleteMany on every _id in the file (no-op for ids that don't exist), then chunked insert. Uses the same two-call pattern as bulkOps.js undo, which deliberately avoids the unreliable bulk_write replaceOne wire format. Mode picker only appears when the file has at least one document with an _id; default is Insert and the user must explicitly opt into Overwrite.
1 parent 40499e9 commit 9b9a6c9

5 files changed

Lines changed: 875 additions & 21 deletions

File tree

src/mdh/components/DataOperations.jsx

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useState, useRef } from 'preact/hooks';
33
import { selectedCollection, loading, error } from '../store.js';
44
import { openModal, closeModal } from './Modal.jsx';
55
import JsonEditor from './JsonEditor.jsx';
6+
import InsertFileWizard from './InsertFileWizard.jsx';
67
import * as api from '../api.js';
78

89
function FileInput({ onParsed }) {
@@ -67,29 +68,27 @@ export function openDataOperations(mode, onSuccess, fieldsFn) {
6768
const title = op.charAt(0).toUpperCase() + op.slice(1) + (isFile ? ' from File' : '');
6869

6970
openModal(title, () => {
70-
if (op === 'insert') return <InsertPanel isFile={isFile} onSuccess={onSuccess} fieldsFn={fieldsFn} />;
71+
if (op === 'insert' && isFile) return <InsertFileWizard onSuccess={onSuccess} />;
72+
if (op === 'insert') return <InsertPanel isFile={false} onSuccess={onSuccess} fieldsFn={fieldsFn} />;
7173
if (op === 'update' && isFile) return <UpdatePanel onSuccess={onSuccess} fieldsFn={fieldsFn} />;
7274
if (op === 'replace' && isFile) return <ReplacePanel onSuccess={onSuccess} fieldsFn={fieldsFn} />;
7375
return null;
7476
});
7577
}
7678

77-
function InsertPanel({ isFile, onSuccess, fieldsFn }) {
79+
// Inline (JSON-editor) insert path. The file-import path is handled by
80+
// InsertFileWizard, which adds chunking, pre-validation, conflict resolution,
81+
// progress, and cancellation.
82+
function InsertPanel({ onSuccess, fieldsFn }) {
7883
const editorRef = useRef(null);
7984
const hintRef = useRef(null);
80-
const [fileDocs, setFileDocs] = useState(null);
8185

8286
async function handleSubmit() {
8387
const collection = selectedCollection.value;
8488
let docs;
8589
try {
86-
if (isFile) {
87-
if (!fileDocs) { hintRef.current.textContent = 'No file selected'; return; }
88-
docs = fileDocs;
89-
} else {
90-
if (!editorRef.current?.isValid()) { hintRef.current.textContent = 'Invalid JSON'; return; }
91-
docs = editorRef.current.getParsed();
92-
}
90+
if (!editorRef.current?.isValid()) { hintRef.current.textContent = 'Invalid JSON'; return; }
91+
docs = editorRef.current.getParsed();
9392
} catch (e) { hintRef.current.textContent = e.message; return; }
9493

9594
if (!Array.isArray(docs)) docs = [docs];
@@ -113,17 +112,10 @@ function InsertPanel({ isFile, onSuccess, fieldsFn }) {
113112

114113
return (
115114
<div class="modal-body">
116-
{isFile ? (
117-
<div>
118-
<div class="modal-field-label">Select a JSON file with documents to insert:</div>
119-
<FileInput onParsed={setFileDocs} />
120-
</div>
121-
) : (
122-
<div>
123-
<div class="modal-field-label">Document or array of documents:</div>
124-
<JsonEditor value={'{\n \n}'} minHeight="200px" fields={fieldsFn} editorRef={editorRef} />
125-
</div>
126-
)}
115+
<div>
116+
<div class="modal-field-label">Document or array of documents:</div>
117+
<JsonEditor value={'{\n \n}'} minHeight="200px" fields={fieldsFn} editorRef={editorRef} />
118+
</div>
127119
<div ref={hintRef} class="input-hint"></div>
128120
<div class="modal-actions">
129121
<button class="btn btn-secondary" onClick={closeModal}>Cancel</button>
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
import { h, Fragment } from 'preact';
2+
import { useState, useRef, useEffect } from 'preact/hooks';
3+
import { selectedCollection } from '../store.js';
4+
import { closeModal } from './Modal.jsx';
5+
import {
6+
analyzeDocs,
7+
dedupeById,
8+
runChunkedInsert,
9+
runChunkedOverwrite,
10+
} from '../importFile.js';
11+
12+
// Multi-stage Import from JSON File flow:
13+
//
14+
// pick → confirm → importing → done
15+
//
16+
// We don't probe the collection for conflicting _ids before uploading —
17+
// the user opted out of that. In-file deduplication still runs locally
18+
// (free, no network) so the file itself never sends the same _id twice.
19+
// Overwrite mode delegates conflict resolution to a deleteMany pass over
20+
// every _id in the file (no-op for ids that don't exist server-side).
21+
22+
const STAGE = {
23+
PICK: 'pick',
24+
CONFIRM: 'confirm',
25+
IMPORTING: 'importing',
26+
DONE: 'done',
27+
};
28+
29+
function formatBytes(n) {
30+
if (n == null) return '';
31+
if (n < 1024) return `${n} B`;
32+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
33+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
34+
}
35+
36+
function formatIdSample(ids, max = 3) {
37+
const out = [];
38+
for (let i = 0; i < ids.length && i < max; i++) {
39+
const v = ids[i];
40+
if (v && typeof v === 'object' && '$oid' in v) out.push(String(v.$oid));
41+
else if (typeof v === 'string') out.push(v.length > 12 ? v.slice(0, 12) + '…' : v);
42+
else out.push(String(v));
43+
}
44+
if (ids.length > max) out.push(`+${ids.length - max} more`);
45+
return out.join(', ');
46+
}
47+
48+
export default function InsertFileWizard({ onSuccess }) {
49+
const [stage, setStage] = useState(STAGE.PICK);
50+
const [fileMeta, setFileMeta] = useState(null);
51+
const [docs, setDocs] = useState(null);
52+
const [stats, setStats] = useState(null);
53+
const [mode, setMode] = useState('insert'); // 'insert' | 'overwrite'
54+
const [importProgress, setImportProgress] = useState(null);
55+
const [importResult, setImportResult] = useState(null);
56+
const [errorMsg, setErrorMsg] = useState(null);
57+
58+
const abortRef = useRef(null);
59+
60+
useEffect(() => () => { abortRef.current?.abort(); }, []);
61+
62+
function handleFile(file) {
63+
setErrorMsg(null);
64+
setFileMeta({ name: file.name, size: file.size });
65+
file.text().then((text) => {
66+
let parsed;
67+
try { parsed = JSON.parse(text); }
68+
catch (e) {
69+
setErrorMsg(`Couldn't parse JSON: ${e.message}`);
70+
return;
71+
}
72+
if (!Array.isArray(parsed)) parsed = [parsed];
73+
if (parsed.length === 0) {
74+
setErrorMsg('File contains no documents');
75+
return;
76+
}
77+
setDocs(parsed);
78+
setStats(analyzeDocs(parsed));
79+
setStage(STAGE.CONFIRM);
80+
}).catch((err) => {
81+
setErrorMsg(`Couldn't read file: ${err.message}`);
82+
});
83+
}
84+
85+
async function startImport() {
86+
if (!docs) return;
87+
setErrorMsg(null);
88+
89+
const { kept, dropped: inFileDropped } = dedupeById(docs);
90+
91+
setStage(STAGE.IMPORTING);
92+
const controller = new AbortController();
93+
abortRef.current = controller;
94+
setImportProgress({ phase: 'insert', processed: 0, total: kept.length, inserted: 0, failedBatches: 0 });
95+
96+
try {
97+
let result;
98+
if (mode === 'overwrite' && stats.uniqueIdCount > 0) {
99+
result = await runChunkedOverwrite(selectedCollection.value, kept, {
100+
signal: controller.signal,
101+
onProgress: (p) => setImportProgress({ ...p, total: kept.length }),
102+
});
103+
result.kind = 'overwrite';
104+
} else {
105+
result = await runChunkedInsert(selectedCollection.value, kept, {
106+
signal: controller.signal,
107+
onProgress: setImportProgress,
108+
});
109+
result.kind = 'insert';
110+
}
111+
result.inFileDropped = inFileDropped;
112+
setImportResult(result);
113+
114+
if (result.inserted > 0 || result.deleted > 0) onSuccess?.();
115+
setStage(STAGE.DONE);
116+
} catch (err) {
117+
setErrorMsg(`Import failed: ${err.message}`);
118+
setStage(STAGE.CONFIRM);
119+
} finally {
120+
abortRef.current = null;
121+
}
122+
}
123+
124+
function handleCancel() {
125+
abortRef.current?.abort();
126+
}
127+
128+
return (
129+
<div class="modal-body import-wizard">
130+
{stage === STAGE.PICK && <StagePick onFile={handleFile} errorMsg={errorMsg} onCancel={closeModal} />}
131+
132+
{stage === STAGE.CONFIRM && stats && (
133+
<StageConfirm
134+
fileMeta={fileMeta}
135+
stats={stats}
136+
mode={mode}
137+
setMode={setMode}
138+
errorMsg={errorMsg}
139+
onImport={startImport}
140+
onCancel={closeModal}
141+
/>
142+
)}
143+
144+
{stage === STAGE.IMPORTING && importProgress && (
145+
<StageImporting progress={importProgress} mode={mode} onCancel={handleCancel} />
146+
)}
147+
148+
{stage === STAGE.DONE && importResult && (
149+
<StageDone result={importResult} mode={mode} fileMeta={fileMeta} onClose={closeModal} />
150+
)}
151+
</div>
152+
);
153+
}
154+
155+
// ---- stage components ----
156+
157+
function StagePick({ onFile, errorMsg, onCancel }) {
158+
const inputRef = useRef(null);
159+
function pick(e) {
160+
const f = e.target.files?.[0];
161+
if (f) onFile(f);
162+
}
163+
return (
164+
<Fragment>
165+
<div class="modal-field-label">Select a JSON file with documents to insert:</div>
166+
<input ref={inputRef} type="file" accept=".json,application/json" style="display:none" onChange={pick} />
167+
<div class="file-input-area" onClick={() => inputRef.current?.click()}>
168+
<div class="file-input-label">Click to select a JSON file</div>
169+
<div class="file-input-info" style="margin-top:4px">Array of documents, or a single document</div>
170+
</div>
171+
{errorMsg && <div class="input-hint" style="color:var(--danger)">{errorMsg}</div>}
172+
<div class="modal-actions">
173+
<button class="btn btn-secondary" onClick={onCancel}>Cancel</button>
174+
</div>
175+
</Fragment>
176+
);
177+
}
178+
179+
function StageConfirm({ fileMeta, stats, mode, setMode, errorMsg, onImport, onCancel }) {
180+
const hasInFileDupes = stats.inFileDupeCount > 0;
181+
const hasIds = stats.withId > 0;
182+
const willInsert = stats.uniqueIdCount + stats.withoutId;
183+
184+
return (
185+
<Fragment>
186+
<FileSummary fileMeta={fileMeta} stats={stats} />
187+
188+
{hasInFileDupes && (
189+
<div class="import-conflict-info">
190+
<strong>{stats.inFileDupeCount.toLocaleString()}</strong> duplicate <code>_id</code>{stats.inFileDupeCount === 1 ? '' : 's'} within the file will be collapsed to one occurrence.
191+
{stats.inFileDupeIdSample.length > 0 && <div class="import-id-sample">e.g. {formatIdSample(stats.inFileDupeIdSample)}</div>}
192+
</div>
193+
)}
194+
195+
{hasIds && (
196+
<div>
197+
<div class="modal-field-label">If a document's <code>_id</code> already exists in <code>{selectedCollection.value}</code>:</div>
198+
<div class="import-mode-group">
199+
<label class={`import-mode-option ${mode === 'insert' ? 'selected' : ''}`}>
200+
<input type="radio" name="import-mode" value="insert" checked={mode === 'insert'} onChange={() => setMode('insert')} />
201+
<span>
202+
<span class="import-mode-title">Insert (fail on duplicate)</span>
203+
<span class="import-mode-desc">Send the file as-is. Batches with conflicting <code>_id</code>s will be reported as failures in the summary.</span>
204+
</span>
205+
</label>
206+
<label class={`import-mode-option ${mode === 'overwrite' ? 'selected' : ''}`}>
207+
<input type="radio" name="import-mode" value="overwrite" checked={mode === 'overwrite'} onChange={() => setMode('overwrite')} />
208+
<span>
209+
<span class="import-mode-title">Overwrite</span>
210+
<span class="import-mode-desc">Delete any documents whose <code>_id</code> matches the file (no-op for <code>_id</code>s that don't exist), then insert all {willInsert.toLocaleString()} from the file. Idempotent re-import.</span>
211+
</span>
212+
</label>
213+
</div>
214+
</div>
215+
)}
216+
217+
{errorMsg && <div class="input-hint" style="color:var(--danger)">{errorMsg}</div>}
218+
219+
<div class="modal-actions">
220+
<button class="btn btn-secondary" onClick={onCancel}>Cancel</button>
221+
<button
222+
class={`btn ${mode === 'overwrite' ? 'btn-danger' : 'btn-success'}`}
223+
onClick={onImport}
224+
disabled={willInsert === 0}
225+
>
226+
{mode === 'overwrite'
227+
? `Overwrite ${willInsert.toLocaleString()} document${willInsert === 1 ? '' : 's'}`
228+
: `Insert ${willInsert.toLocaleString()} document${willInsert === 1 ? '' : 's'}`}
229+
</button>
230+
</div>
231+
</Fragment>
232+
);
233+
}
234+
235+
function StageImporting({ progress, mode, onCancel }) {
236+
const { processed = 0, total = 0, inserted = 0, failedBatches = 0, phase } = progress;
237+
const pct = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 0;
238+
const label = mode === 'overwrite' && phase === 'delete'
239+
? 'Deleting any matching documents'
240+
: 'Inserting documents';
241+
return (
242+
<Fragment>
243+
<div class="modal-message">{label}</div>
244+
<div class="import-progress">
245+
<div class="import-progress-track">
246+
<div class="import-progress-fill" style={`width:${pct}%`}></div>
247+
</div>
248+
<div class="import-progress-counts">
249+
<span>{processed.toLocaleString()} / {total.toLocaleString()}</span>
250+
<span>{pct}%</span>
251+
</div>
252+
</div>
253+
<div class="import-progress-meta">
254+
{phase !== 'delete' && <span>{inserted.toLocaleString()} inserted</span>}
255+
{failedBatches > 0 && <span style="color:var(--danger)">{failedBatches} batch{failedBatches === 1 ? '' : 'es'} failed</span>}
256+
</div>
257+
<div class="modal-actions">
258+
<button class="btn btn-secondary" onClick={onCancel}>Cancel</button>
259+
</div>
260+
</Fragment>
261+
);
262+
}
263+
264+
function StageDone({ result, mode, fileMeta, onClose }) {
265+
const { inserted = 0, deleted = 0, failedBatches = [], inFileDropped = 0, cancelled, kind } = result;
266+
const overall = failedBatches.length === 0 && !cancelled;
267+
268+
return (
269+
<Fragment>
270+
<div class={`import-result-header ${overall ? 'success' : 'partial'}`}>
271+
<span class="import-result-icon">{overall ? '✓' : cancelled ? '○' : '⚠'}</span>
272+
<span>
273+
{cancelled ? 'Cancelled' : overall ? 'Import complete' : 'Import partially complete'}
274+
{fileMeta?.name && <span class="import-result-filename"> · {fileMeta.name}</span>}
275+
</span>
276+
</div>
277+
278+
<ul class="import-result-list">
279+
{kind === 'overwrite' && deleted > 0 && <li>Deleted <strong>{deleted.toLocaleString()}</strong> existing record{deleted === 1 ? '' : 's'}</li>}
280+
{inserted > 0 && <li>Inserted <strong>{inserted.toLocaleString()}</strong> document{inserted === 1 ? '' : 's'}</li>}
281+
{inFileDropped > 0 && <li><strong>{inFileDropped.toLocaleString()}</strong> in-file duplicate{inFileDropped === 1 ? '' : 's'} were collapsed</li>}
282+
{failedBatches.length > 0 && (
283+
<li style="color:var(--danger)">
284+
<strong>{failedBatches.length}</strong> batch{failedBatches.length === 1 ? '' : 'es'} failed
285+
<ul class="import-failure-list">
286+
{failedBatches.slice(0, 5).map((b) => (
287+
<li>
288+
Records {b.startIdx.toLocaleString()}{b.endIdx.toLocaleString()} ({b.count.toLocaleString()} docs): <code>{b.message}</code>
289+
</li>
290+
))}
291+
{failedBatches.length > 5 && <li>{'… and '}{failedBatches.length - 5}{' more'}</li>}
292+
</ul>
293+
{failedBatches.some((b) => /batch op errors/i.test(b.message)) && (
294+
<div class="import-result-hint">
295+
<code>batch op errors occurred</code> typically means at least one document in the batch had a duplicate <code>_id</code> or violated a collection validator. Re-run with Overwrite mode to replace existing records.
296+
</div>
297+
)}
298+
</li>
299+
)}
300+
</ul>
301+
302+
<div class="modal-actions">
303+
<button class="btn btn-primary" onClick={onClose}>Close</button>
304+
</div>
305+
</Fragment>
306+
);
307+
}
308+
309+
function FileSummary({ fileMeta, stats }) {
310+
if (!fileMeta || !stats) return null;
311+
const parts = [];
312+
parts.push(`${stats.total.toLocaleString()} document${stats.total === 1 ? '' : 's'}`);
313+
if (fileMeta.size) parts.push(formatBytes(fileMeta.size));
314+
if (stats.withId === stats.total && stats.total > 0) parts.push('all have _id');
315+
else if (stats.withId === 0) parts.push('no explicit _id');
316+
else parts.push(`${stats.withId.toLocaleString()} with _id, ${stats.withoutId.toLocaleString()} without`);
317+
return (
318+
<div class="modal-count-info">
319+
<div style="font-family:var(--font-mono);font-size:11px;color:var(--text-secondary)">{fileMeta.name}</div>
320+
<div>{parts.join(' · ')}</div>
321+
</div>
322+
);
323+
}

0 commit comments

Comments
 (0)