Skip to content

Commit 7f63344

Browse files
author
chenyu.jiang
committed
fix large size validation
Signed-off-by: chenyu.jiang <chenyu.jiang@bytedance.com>
1 parent c10bd72 commit 7f63344

3 files changed

Lines changed: 247 additions & 49 deletions

File tree

apps/console/web/src/components/CreateJob.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ import {
3636
ValidationResult,
3737
MutationDiff,
3838
ParseResult,
39-
parseJsonl,
39+
parseJsonlFile,
4040
validateBatchFileName,
41+
formatBatchFileValidationError,
4142
applyBatchOverrides,
4243
serializeJsonl,
4344
hasAnyOverride,
@@ -71,6 +72,8 @@ const STEP_INDEX: Record<Step, number> = { model: 1, template: 2, dataset: 3, se
7172
const MIN_CLIENT_MAX_CONCURRENCY = 1;
7273
const MAX_CLIENT_MAX_CONCURRENCY = 1024;
7374
const MIN_ADAPTIVE_MAX_FACTOR = 1;
75+
const BYTES_PER_MEBIBYTE = 1024 * 1024;
76+
const FULL_PARSE_CACHE_MAX_BYTES = 20 * BYTES_PER_MEBIBYTE;
7477

7578
export function CreateJob({ onBack }: CreateJobProps) {
7679
const [currentStep, setCurrentStep] = useState<Step>('model');
@@ -278,8 +281,9 @@ export function CreateJob({ onBack }: CreateJobProps) {
278281
// Validate immediately
279282
setValidating(true);
280283
try {
281-
const parsed = parseJsonl(await file.text());
282-
setSelectedFileParse(parsed);
284+
const shouldCacheFullParse = file.size <= FULL_PARSE_CACHE_MAX_BYTES;
285+
const parsed = await parseJsonlFile(file, { retainFullRecords: shouldCacheFullParse });
286+
setSelectedFileParse(shouldCacheFullParse ? parsed : null);
283287
const result = validateBatchLines(parsed, {
284288
expectedModel: selectedServingName,
285289
supportedEndpoints: selectedTemplate?.spec?.supportedEndpoints,
@@ -289,7 +293,7 @@ export function CreateJob({ onBack }: CreateJobProps) {
289293
setValidation({
290294
valid: false,
291295
totalLines: 0,
292-
errors: [`Failed to read file: ${err instanceof Error ? err.message : 'unknown error'}`],
296+
errors: [formatBatchFileValidationError(err)],
293297
warnings: [],
294298
detectedModel: null,
295299
endpoints: [],
@@ -489,7 +493,7 @@ export function CreateJob({ onBack }: CreateJobProps) {
489493

490494
let fileToUpload: File = selectedFile;
491495
if (hasAnyOverride(overrides)) {
492-
const parsed = selectedFileParse ?? parseJsonl(await selectedFile.text());
496+
const parsed = selectedFileParse ?? await parseJsonlFile(selectedFile, { retainFullRecords: true });
493497
const { records } = applyBatchOverrides(parsed.records, overrides);
494498
fileToUpload = new File(
495499
[serializeJsonl(records)],

apps/console/web/src/utils/batchValidation.test.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import { describe, expect, it } from 'vitest';
2-
import { parseJsonl, validateBatchFile, validateBatchLines } from './batchValidation';
2+
import {
3+
getFileValidationTimeoutError,
4+
parseJsonl,
5+
parseJsonlFile,
6+
validateBatchFile,
7+
validateBatchLines,
8+
} from './batchValidation';
39

4-
function jsonlLine(url: string, model = 'qwen-serving'): string {
10+
function jsonlLine(url: string, model = 'qwen-serving', customId = 'req-1'): string {
511
return JSON.stringify({
6-
custom_id: 'req-1',
12+
custom_id: customId,
713
method: 'POST',
814
url,
915
body: {
@@ -40,4 +46,35 @@ describe('batch validation', () => {
4046
expect(result.valid).toBe(false);
4147
expect(result.errors).toContain('File must use the .jsonl extension.');
4248
});
49+
50+
it('validates a file through chunked parsing', async () => {
51+
const file = new File([
52+
[
53+
jsonlLine('/v1/chat/completions', 'qwen-serving', 'req-1'),
54+
jsonlLine('/v1/chat/completions', 'qwen-serving', 'req-2'),
55+
].join('\n'),
56+
], 'requests.jsonl', {
57+
type: 'application/jsonl',
58+
});
59+
60+
const parsed = await parseJsonlFile(file, {
61+
retainFullRecords: true,
62+
readChunkSizeBytes: 7,
63+
});
64+
const result = validateBatchLines(parsed, {
65+
expectedModel: 'qwen-serving',
66+
supportedEndpoints: ['/v1/chat/completions'],
67+
});
68+
69+
expect(result.valid).toBe(true);
70+
expect(result.totalLines).toBe(2);
71+
expect(result.errors).toEqual([]);
72+
});
73+
74+
it('uses an explicit client-side timeout message', () => {
75+
expect(getFileValidationTimeoutError(60_000)).toBe(
76+
'Client-side validation timed out after 60s. ' +
77+
'The file is too large to validate in the browser; split it into smaller batches or select an already uploaded dataset.',
78+
);
79+
});
4380
});

apps/console/web/src/utils/batchValidation.ts

Lines changed: 198 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ export interface ParseResult {
3535
totalLines: number;
3636
}
3737

38+
export interface ParseJsonlFileOptions {
39+
retainFullRecords?: boolean;
40+
timeoutMs?: number;
41+
readChunkSizeBytes?: number;
42+
}
43+
3844
export interface BatchOverrides {
3945
maxTokens?: number;
4046
temperature?: number;
@@ -54,6 +60,13 @@ const MAX_ERRORS = 20;
5460
const MAX_DIFF_SAMPLES = 5;
5561
// OpenAI Batch API hard limit; MDS enforces the same cap (python/aibrix/.../batch.py).
5662
const MAX_REQUESTS_PER_BATCH = 50000;
63+
const BYTES_PER_MEBIBYTE = 1024 * 1024;
64+
const FILE_READ_CHUNK_SIZE_BYTES = BYTES_PER_MEBIBYTE;
65+
const MIN_FILE_READ_CHUNK_SIZE_BYTES = 1;
66+
const FILE_VALIDATION_TIMEOUT_MS = 60_000;
67+
const MILLISECONDS_PER_SECOND = 1000;
68+
const VALIDATION_YIELD_INTERVAL_LINES = 500;
69+
const VALIDATION_YIELD_DELAY_MS = 0;
5770
export const JSONL_EXTENSION_ERROR = 'File must use the .jsonl extension.';
5871

5972
const OVERRIDE_FIELD_MAP: Record<keyof BatchOverrides, string> = {
@@ -66,48 +79,181 @@ const OVERRIDE_FIELD_MAP: Record<keyof BatchOverrides, string> = {
6679
// Endpoints where completion-style sampling params apply.
6780
const COMPLETION_ENDPOINTS = new Set(['/v1/chat/completions', '/v1/completions']);
6881

82+
class BatchFileValidationTimeoutError extends Error {
83+
constructor(timeoutMs: number) {
84+
super(getFileValidationTimeoutError(timeoutMs));
85+
this.name = 'BatchFileValidationTimeoutError';
86+
}
87+
}
88+
6989
export function validateBatchFileName(fileName: string): string | null {
7090
return fileName.trim().toLowerCase().endsWith('.jsonl') ? null : JSONL_EXTENSION_ERROR;
7191
}
7292

73-
export function parseJsonl(text: string): ParseResult {
74-
const rawLines = text.trimEnd().split('\n');
75-
const records: ParsedLine[] = [];
76-
const parseErrors: ParseResult['parseErrors'] = [];
77-
const warnings: ParseResult['warnings'] = [];
93+
export function getFileValidationTimeoutError(timeoutMs = FILE_VALIDATION_TIMEOUT_MS): string {
94+
const timeoutSeconds = Math.ceil(timeoutMs / MILLISECONDS_PER_SECOND);
95+
return `Client-side validation timed out after ${timeoutSeconds}s. ` +
96+
'The file is too large to validate in the browser; split it into smaller batches or select an already uploaded dataset.';
97+
}
98+
99+
export function formatBatchFileValidationError(err: unknown): string {
100+
if (err instanceof BatchFileValidationTimeoutError) {
101+
return err.message;
102+
}
103+
if (err instanceof Error && err.message) {
104+
return `Failed to read file: ${err.message}`;
105+
}
106+
return 'Failed to read file: unknown error';
107+
}
108+
109+
interface ParseState extends ParseResult {
110+
pendingEmptyLineNumbers: number[];
111+
}
112+
113+
function createParseState(): ParseState {
114+
return {
115+
records: [],
116+
parseErrors: [],
117+
warnings: [],
118+
totalLines: 0,
119+
pendingEmptyLineNumbers: [],
120+
};
121+
}
122+
123+
function flushPendingEmptyLineWarnings(state: ParseState) {
124+
for (const lineNumber of state.pendingEmptyLineNumbers) {
125+
state.warnings.push({ lineNumber, message: 'empty line (will be skipped)' });
126+
}
127+
state.pendingEmptyLineNumbers = [];
128+
}
129+
130+
function summarizeRecord(record: BatchLineRecord): BatchLineRecord {
131+
const rawRecord = record as Record<string, unknown>;
132+
const rawBody = rawRecord.body;
133+
return {
134+
custom_id: rawRecord.custom_id as string | undefined,
135+
method: rawRecord.method as string | undefined,
136+
url: rawRecord.url as string | undefined,
137+
body: rawBody && typeof rawBody === 'object' && !Array.isArray(rawBody)
138+
? { model: (rawBody as { model?: string }).model }
139+
: rawBody as BatchLineRecord['body'],
140+
};
141+
}
142+
143+
function processJsonlLine(
144+
state: ParseState,
145+
rawLine: string,
146+
lineNumber: number,
147+
retainFullRecords: boolean,
148+
) {
149+
const line = rawLine.trim();
150+
if (line === '') {
151+
state.pendingEmptyLineNumbers.push(lineNumber);
152+
return;
153+
}
154+
155+
flushPendingEmptyLineWarnings(state);
156+
state.totalLines++;
157+
158+
let parsed: unknown;
159+
try {
160+
parsed = JSON.parse(line);
161+
} catch {
162+
state.parseErrors.push({ lineNumber, message: 'invalid JSON' });
163+
return;
164+
}
165+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
166+
state.parseErrors.push({ lineNumber, message: 'must be a JSON object' });
167+
return;
168+
}
169+
170+
const record = parsed as BatchLineRecord;
171+
state.records.push({
172+
lineNumber,
173+
record: retainFullRecords ? record : summarizeRecord(record),
174+
});
175+
}
78176

79-
if (rawLines.length === 0 || (rawLines.length === 1 && rawLines[0].trim() === '')) {
80-
return { records, parseErrors: [{ lineNumber: 1, message: 'File is empty' }], warnings, totalLines: 0 };
177+
function finishParseState(state: ParseState): ParseResult {
178+
if (state.totalLines === 0 && state.parseErrors.length === 0) {
179+
state.parseErrors.push({ lineNumber: 1, message: 'File is empty' });
81180
}
82181

83-
let nonEmpty = 0;
182+
return {
183+
records: state.records,
184+
parseErrors: state.parseErrors,
185+
warnings: state.warnings,
186+
totalLines: state.totalLines,
187+
};
188+
}
189+
190+
function assertValidationNotTimedOut(startedAtMs: number, timeoutMs: number) {
191+
if (Date.now() - startedAtMs > timeoutMs) {
192+
throw new BatchFileValidationTimeoutError(timeoutMs);
193+
}
194+
}
195+
196+
function yieldToMainThread(): Promise<void> {
197+
return new Promise((resolve) => {
198+
setTimeout(resolve, VALIDATION_YIELD_DELAY_MS);
199+
});
200+
}
201+
202+
export function parseJsonl(text: string): ParseResult {
203+
const rawLines = text.trimEnd().split('\n');
204+
const state = createParseState();
84205
for (let i = 0; i < rawLines.length; i++) {
85-
const lineNumber = i + 1;
86-
const line = rawLines[i].trim();
87-
if (line === '') {
88-
if (i < rawLines.length - 1) {
89-
warnings.push({ lineNumber, message: 'empty line (will be skipped)' });
206+
processJsonlLine(state, rawLines[i] ?? '', i + 1, true);
207+
}
208+
209+
return finishParseState(state);
210+
}
211+
212+
export async function parseJsonlFile(
213+
file: File,
214+
options: ParseJsonlFileOptions = {},
215+
): Promise<ParseResult> {
216+
const state = createParseState();
217+
const decoder = new TextDecoder();
218+
const retainFullRecords = options.retainFullRecords ?? false;
219+
const timeoutMs = options.timeoutMs ?? FILE_VALIDATION_TIMEOUT_MS;
220+
const readChunkSizeBytes = Math.max(
221+
options.readChunkSizeBytes ?? FILE_READ_CHUNK_SIZE_BYTES,
222+
MIN_FILE_READ_CHUNK_SIZE_BYTES,
223+
);
224+
const startedAtMs = Date.now();
225+
let bufferedText = '';
226+
let lineNumber = 1;
227+
let linesSinceYield = 0;
228+
229+
for (let offset = 0; offset < file.size; offset += readChunkSizeBytes) {
230+
assertValidationNotTimedOut(startedAtMs, timeoutMs);
231+
const chunkEnd = Math.min(offset + readChunkSizeBytes, file.size);
232+
const chunk = new Uint8Array(await file.slice(offset, chunkEnd).arrayBuffer());
233+
bufferedText += decoder.decode(chunk, { stream: chunkEnd < file.size });
234+
const lines = bufferedText.split('\n');
235+
bufferedText = lines.pop() ?? '';
236+
237+
for (const rawLine of lines) {
238+
processJsonlLine(state, rawLine, lineNumber, retainFullRecords);
239+
lineNumber++;
240+
linesSinceYield++;
241+
242+
if (linesSinceYield >= VALIDATION_YIELD_INTERVAL_LINES) {
243+
linesSinceYield = 0;
244+
await yieldToMainThread();
245+
assertValidationNotTimedOut(startedAtMs, timeoutMs);
90246
}
91-
continue;
92-
}
93-
nonEmpty++;
94-
95-
let parsed: unknown;
96-
try {
97-
parsed = JSON.parse(line);
98-
} catch {
99-
parseErrors.push({ lineNumber, message: 'invalid JSON' });
100-
continue;
101-
}
102-
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
103-
parseErrors.push({ lineNumber, message: 'must be a JSON object' });
104-
continue;
105247
}
248+
}
106249

107-
records.push({ lineNumber, record: parsed as BatchLineRecord });
250+
bufferedText += decoder.decode();
251+
if (bufferedText !== '') {
252+
processJsonlLine(state, bufferedText, lineNumber, retainFullRecords);
108253
}
254+
assertValidationNotTimedOut(startedAtMs, timeoutMs);
109255

110-
return { records, parseErrors, warnings, totalLines: nonEmpty };
256+
return finishParseState(state);
111257
}
112258

113259
export function validateBatchLines(parsed: ParseResult, ctx: ValidationContext): ValidationResult {
@@ -211,23 +357,34 @@ export function validateBatchLines(parsed: ParseResult, ctx: ValidationContext):
211357
};
212358
}
213359

360+
function validationFailure(errors: string[]): ValidationResult {
361+
return {
362+
valid: false,
363+
totalLines: 0,
364+
errors,
365+
warnings: [],
366+
detectedModel: null,
367+
endpoints: [],
368+
};
369+
}
370+
214371
// Convenience wrapper: read file, parse, validate.
215-
export async function validateBatchFile(file: File, ctx: ValidationContext): Promise<ValidationResult> {
372+
export async function validateBatchFile(
373+
file: File,
374+
ctx: ValidationContext,
375+
options: ParseJsonlFileOptions = {},
376+
): Promise<ValidationResult> {
216377
const fileNameError = validateBatchFileName(file.name);
217378
if (fileNameError) {
218-
return {
219-
valid: false,
220-
totalLines: 0,
221-
errors: [fileNameError],
222-
warnings: [],
223-
detectedModel: null,
224-
endpoints: [],
225-
};
379+
return validationFailure([fileNameError]);
226380
}
227381

228-
const text = await file.text();
229-
const parsed = parseJsonl(text);
230-
return validateBatchLines(parsed, ctx);
382+
try {
383+
const parsed = await parseJsonlFile(file, options);
384+
return validateBatchLines(parsed, ctx);
385+
} catch (err) {
386+
return validationFailure([formatBatchFileValidationError(err)]);
387+
}
231388
}
232389

233390
export function hasAnyOverride(overrides: BatchOverrides): boolean {

0 commit comments

Comments
 (0)