This is an automated email from the ASF dual-hosted git repository.

morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-website.git


The following commit(s) were added to refs/heads/master by this push:
     new 119862e0da3 [fix] Truncate large query profiles after MergedProfile 
(#4034)
119862e0da3 is described below

commit 119862e0da34fc3c97f9d58dd469a29ed8b1d640
Author: Mairui Li <[email protected]>
AuthorDate: Wed Aug 5 10:46:53 2026 +0800

    [fix] Truncate large query profiles after MergedProfile (#4034)
    
    ## Summary
    
    - accept local Query Profile files up to 100 MiB
    - reduce files over 10 MiB before upload by retaining Summary and
    MergedProfile
    - remove per-instance DetailProfile/Execution Profile sections
    - keep the final upload within the existing 10 MiB backend limit
    - prevent stale asynchronous file preparation from replacing a newer
    selection
    - add focused tests for truncation boundaries, UTF-8 byte size, and file
    limits
    
    ## Why
    
    Large Apache Doris Query Profiles can exceed the backend's 10 MiB upload
    limit because they contain extensive per-instance DetailProfile data.
    
    Summary and MergedProfile retain the aggregated execution metrics needed
    for the initial diagnosis. Removing the following per-instance sections
    allows large Profiles to use the existing analysis API without
    increasing the backend request limit.
    
    Truncation is only performed when a MergedProfile header is followed by
    a recognized per-instance profile header. Files that cannot be reduced
    safely to 10 MiB are rejected.
    
    ## Validation
    
    - `node --test src/components/profile-analysis/*.test.js`
      - 51 tests passed
    - targeted TypeScript compilation passed
    - verified with a 41,070,035-byte real Profile
      - output: 123,362 bytes
      - MergedProfile retained
      - DetailProfile removed
---
 .../profile-analysis/ProfileUploader.tsx           |  42 +++++--
 .../profile-analysis/profile-analysis.api.ts       |   3 +-
 .../profile-analysis.components.test.js            |  23 +++-
 .../profile-analysis/profile-analysis.file.test.js | 132 +++++++++++++++++++++
 .../profile-analysis/profile-analysis.file.ts      |  44 +++++++
 5 files changed, 230 insertions(+), 14 deletions(-)

diff --git a/src/components/profile-analysis/ProfileUploader.tsx 
b/src/components/profile-analysis/ProfileUploader.tsx
index eef4cf89e87..9b3bb5fefb4 100644
--- a/src/components/profile-analysis/ProfileUploader.tsx
+++ b/src/components/profile-analysis/ProfileUploader.tsx
@@ -1,9 +1,8 @@
 import HCaptcha from '@hcaptcha/react-hcaptcha';
 import React, { ChangeEvent, DragEvent, JSX, useCallback, useRef, useState } 
from 'react';
+import { MAX_RAW_BYTES, prepareProfileFile } from './profile-analysis.file';
 import type { ResponseLanguage } from './profile-analysis.types';
 
-export const MAX_PROFILE_FILE_SIZE_BYTES = 10 * 1024 * 1024;
-
 interface ProfileUploaderProps {
     file: File | null;
     language: ResponseLanguage;
@@ -18,8 +17,8 @@ export function validateProfileFile(file: File): string | 
null {
     if (!file.name.toLowerCase().endsWith('.txt')) {
         return 'Select a .txt file. Other file types are not supported.';
     }
-    if (file.size > MAX_PROFILE_FILE_SIZE_BYTES) {
-        return 'The selected file is larger than 10 MiB.';
+    if (file.size > MAX_RAW_BYTES) {
+        return 'The selected file is larger than 100 MiB. Please provide a 
merged Profile.';
     }
     return null;
 }
@@ -48,6 +47,7 @@ export function ProfileUploader({
     const [hcaptchaToken, setHCaptchaToken] = useState<string | null>(null);
     const [hcaptchaError, setHCaptchaError] = useState<string | null>(null);
     const hcaptchaRef = useRef<HCaptcha>(null);
+    const filePreparationIdRef = useRef(0);
 
     const resetCaptcha = useCallback(() => {
         hcaptchaRef.current?.resetCaptcha();
@@ -55,7 +55,8 @@ export function ProfileUploader({
         setHCaptchaError(null);
     }, []);
 
-    const acceptFile = (nextFile: File | null) => {
+    const acceptFile = async (nextFile: File | null) => {
+        const preparationId = ++filePreparationIdRef.current;
         if (!nextFile) {
             setValidationError(null);
             onFileChange(null);
@@ -63,12 +64,31 @@ export function ProfileUploader({
         }
 
         const nextError = validateProfileFile(nextFile);
-        setValidationError(nextError);
-        onFileChange(nextError ? null : nextFile);
+        if (nextError) {
+            setValidationError(nextError);
+            onFileChange(null);
+            return;
+        }
+
+        setValidationError(null);
+        onFileChange(null);
+        try {
+            const preparedFile = await prepareProfileFile(nextFile);
+            if (filePreparationIdRef.current !== preparationId) return;
+            onFileChange(preparedFile);
+        } catch (reason) {
+            if (filePreparationIdRef.current !== preparationId) return;
+            setValidationError(
+                reason instanceof Error
+                    ? reason.message
+                    : 'The Profile could not be prepared for upload.',
+            );
+            onFileChange(null);
+        }
     };
 
     const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
-        acceptFile(event.currentTarget.files?.item(0) ?? null);
+        void acceptFile(event.currentTarget.files?.item(0) ?? null);
         event.currentTarget.value = '';
     };
 
@@ -82,14 +102,15 @@ export function ProfileUploader({
             onFileChange(null);
             return;
         }
-        acceptFile(event.dataTransfer.files.item(0));
+        void acceptFile(event.dataTransfer.files.item(0));
     };
 
     return (
         <section className="profile-analysis__uploader" 
aria-labelledby="profile-analysis-upload-title">
             <h2 id="profile-analysis-upload-title">Upload a Query Profile</h2>
             <p id="profile-analysis-file-help" 
className="profile-analysis__help">
-                Choose one UTF-8 .txt file up to 10 MiB after reviewing and 
accepting the notice below.
+                Choose one UTF-8 .txt file up to 100 MiB after reviewing and 
accepting the notice below. Files
+                over 10 MiB are reduced to their aggregated Profile sections 
before upload.
             </p>
 
             <fieldset className="profile-analysis__language" 
disabled={disabled}>
@@ -254,6 +275,7 @@ export function ProfileUploader({
                             const accepted = event.currentTarget.checked;
                             setConsentAccepted(accepted);
                             if (!accepted) {
+                                filePreparationIdRef.current += 1;
                                 resetCaptcha();
                                 onFileChange(null);
                             }
diff --git a/src/components/profile-analysis/profile-analysis.api.ts 
b/src/components/profile-analysis/profile-analysis.api.ts
index bf6eb7b3913..cbc3619f591 100644
--- a/src/components/profile-analysis/profile-analysis.api.ts
+++ b/src/components/profile-analysis/profile-analysis.api.ts
@@ -8,6 +8,7 @@ import type {
     ResponseLanguage,
 } from './profile-analysis.types';
 import { isUuid } from './profile-analysis.storage';
+import { prepareProfileFile } from './profile-analysis.file';
 
 const ANALYSIS_JOBS_PATH = '/api/profile/analysis-jobs';
 const ANALYSIS_JOB_REQUESTS_PATH = '/api/profile/analysis-job-requests';
@@ -165,7 +166,7 @@ export async function createAnalysisJob(
     }
 
     const formData = new FormData();
-    formData.append('file', file);
+    formData.append('file', await prepareProfileFile(file));
     formData.append('language', language);
     formData.append('consent', 'true');
     formData.append('privacyNoticeVersion', PRIVACY_NOTICE_VERSION);
diff --git 
a/src/components/profile-analysis/profile-analysis.components.test.js 
b/src/components/profile-analysis/profile-analysis.components.test.js
index 9e38a6d9438..02b43f5cf34 100644
--- a/src/components/profile-analysis/profile-analysis.components.test.js
+++ b/src/components/profile-analysis/profile-analysis.components.test.js
@@ -27,11 +27,11 @@ require.extensions['.tsx'] = compileTypeScript;
 const { AnalysisResult } = require('./AnalysisResult.tsx');
 const { AnalysisStatus } = require('./AnalysisStatus.tsx');
 const {
-    MAX_PROFILE_FILE_SIZE_BYTES,
     ProfileUploader,
     formatProfileFileSize,
     validateProfileFile,
 } = require('./ProfileUploader.tsx');
+const { MAX_RAW_BYTES } = require('./profile-analysis.file.ts');
 
 require.extensions['.ts'] = previousTypeScriptLoader;
 require.extensions['.tsx'] = previousTsxLoader;
@@ -42,9 +42,9 @@ test('accepts case-insensitive txt files and rejects other 
types or oversized fi
 
     const oversizedFile = {
         name: 'query.txt',
-        size: MAX_PROFILE_FILE_SIZE_BYTES + 1,
+        size: MAX_RAW_BYTES + 1,
     };
-    assert.match(validateProfileFile(oversizedFile), /10 MiB/);
+    assert.match(validateProfileFile(oversizedFile), /100 MiB/);
 });
 
 test('formats file sizes for display', () => {
@@ -53,6 +53,23 @@ test('formats file sizes for display', () => {
     assert.equal(formatProfileFileSize(2 * 1024 * 1024), '2.0 MiB');
 });
 
+test('explains the raw file limit and large-profile reduction in English', () 
=> {
+    const markup = renderToStaticMarkup(
+        React.createElement(ProfileUploader, {
+            file: null,
+            language: 'en',
+            disabled: false,
+            hcaptchaSiteKey,
+            onFileChange() {},
+            onLanguageChange() {},
+            onAnalyze() {},
+        }),
+    );
+
+    assert.match(markup, /UTF-8 \.txt file up to 100 MiB/);
+    assert.match(markup, /Files over 10 MiB are reduced to their aggregated 
Profile sections/);
+});
+
 test('disables Analyze until a file exists and while analysis is running', () 
=> {
     const withoutFile = renderToStaticMarkup(
         React.createElement(ProfileUploader, {
diff --git a/src/components/profile-analysis/profile-analysis.file.test.js 
b/src/components/profile-analysis/profile-analysis.file.test.js
new file mode 100644
index 00000000000..0fb24fc0217
--- /dev/null
+++ b/src/components/profile-analysis/profile-analysis.file.test.js
@@ -0,0 +1,132 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const test = require('node:test');
+const { File } = require('node:buffer');
+const typescript = require('typescript');
+
+const fileModulePath = path.join(__dirname, 'profile-analysis.file.ts');
+const compiledFileModule = 
typescript.transpileModule(fs.readFileSync(fileModulePath, 'utf8'), {
+    compilerOptions: {
+        module: typescript.ModuleKind.CommonJS,
+        target: typescript.ScriptTarget.ES2020,
+    },
+}).outputText;
+const fileModule = { exports: {} };
+new Function('module', 'exports', 'require', '__filename', '__dirname', 
compiledFileModule)(
+    fileModule,
+    fileModule.exports,
+    require,
+    fileModulePath,
+    path.dirname(fileModulePath),
+);
+
+const {
+    byteLen,
+    ensureTxt,
+    MAX_RAW_BYTES,
+    MAX_UPLOAD_BYTES,
+    prepareProfileFile,
+    truncateProfile,
+} = fileModule.exports;
+
+test('counts profile text in UTF-8 bytes instead of JavaScript characters', () 
=> {
+    assert.equal('é中'.length, 2);
+    assert.equal(byteLen('é中'), 5);
+});
+
+test('ensures prepared profile names have a txt extension', () => {
+    assert.equal(ensureTxt('query-profile'), 'query-profile.txt');
+    assert.equal(ensureTxt('query-profile.TXT'), 'query-profile.TXT');
+});
+
+test('truncates at the first per-instance header after MergedProfile', () => {
+    const source = [
+        'Summary:',
+        'Execution Profile summary text that is not a top-level detail header',
+        'Execution Summary:',
+        'MergedProfile:',
+        '  Fragment 0:',
+        '    Pipeline 0:',
+        'DetailProfile(query-id-1):',
+        '  Instance detail that must not be uploaded',
+        'Execution Profile query-id-2',
+        '  More instance detail',
+    ].join('\n');
+
+    const truncated = truncateProfile(source);
+
+    assert.ok(truncated.startsWith('# [truncated: per-instance execution 
profiles removed]\n'));
+    assert.match(truncated, /Summary:/);
+    assert.match(truncated, /Execution Summary:/);
+    assert.match(truncated, /MergedProfile:/);
+    assert.match(truncated, /Fragment 0:/);
+    assert.match(truncated, /Pipeline 0:/);
+    assert.doesNotMatch(truncated, /^DetailProfile\b/m);
+    assert.doesNotMatch(truncated, /Instance detail/);
+});
+
+test('does not claim truncation without a MergedProfile followed by a detail 
boundary', () => {
+    const mergedOnly = 'Summary:\nExecution Summary:\nMergedProfile:\n  
Fragment 0:\n    Pipeline 0:';
+    const detailOnly = 'Summary:\nDetailProfile(query-id-1):\n  Instance 
detail';
+    assert.equal(truncateProfile(mergedOnly), mergedOnly);
+    assert.equal(truncateProfile(detailOnly), detailOnly);
+});
+
+test('returns an upload-sized profile file without reading or repackaging it', 
async () => {
+    const file = new File(['Summary:\nMergedProfile:'], 'query-profile.txt', { 
type: 'text/plain' });
+    assert.equal(await prepareProfileFile(file), file);
+});
+
+test('rejects a raw profile over 100 MiB without reading it', async () => {
+    let textCalls = 0;
+    const file = {
+        name: 'huge-profile.txt',
+        size: MAX_RAW_BYTES + 1,
+        async text() {
+            textCalls += 1;
+            return 'must not be read';
+        },
+    };
+
+    await assert.rejects(prepareProfileFile(file), /larger than 100 
MiB.*merged Profile/);
+    assert.equal(textCalls, 0);
+});
+
+test('removes per-instance detail from a large profile and returns a plain txt 
file', async () => {
+    const aggregate = 'Summary:\nExecution Summary:\nMergedProfile:\n  
Fragment 0:\n    Pipeline 0:';
+    const source = 
`${aggregate}\nDetailProfile(query-id-1):\n${'x'.repeat(MAX_UPLOAD_BYTES)}`;
+    const file = {
+        name: 'query-profile',
+        size: MAX_UPLOAD_BYTES + 1,
+        async text() {
+            return source;
+        },
+    };
+
+    const prepared = await prepareProfileFile(file);
+
+    assert.equal(prepared.type, 'text/plain');
+    assert.equal(prepared.name, 'query-profile.txt');
+    assert.ok(prepared.size <= MAX_UPLOAD_BYTES);
+    const preparedText = await prepared.text();
+    assert.match(preparedText, /^# \[truncated: per-instance execution 
profiles removed\]/);
+    assert.match(preparedText, /MergedProfile:/);
+    assert.doesNotMatch(preparedText, /^DetailProfile\b/m);
+});
+
+test('rejects a merged-only profile that remains over the upload limit', async 
() => {
+    const source = `Summary:\nMergedProfile:\n${'x'.repeat(MAX_UPLOAD_BYTES)}`;
+    const file = {
+        name: 'merged-profile.txt',
+        size: MAX_UPLOAD_BYTES + 1,
+        async text() {
+            return source;
+        },
+    };
+
+    await assert.rejects(
+        prepareProfileFile(file),
+        /still larger than 10 MiB.*merged Profile/,
+    );
+});
diff --git a/src/components/profile-analysis/profile-analysis.file.ts 
b/src/components/profile-analysis/profile-analysis.file.ts
new file mode 100644
index 00000000000..bbad9bd1d5d
--- /dev/null
+++ b/src/components/profile-analysis/profile-analysis.file.ts
@@ -0,0 +1,44 @@
+export const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
+export const MAX_RAW_BYTES = 100 * 1024 * 1024;
+
+const TRUNCATION_MARKER = '# [truncated: per-instance execution profiles 
removed]';
+const MERGED_PROFILE_HEADER = /^MergedProfile:\s*$/m;
+const DETAIL_PROFILE_HEADER = /^(?:DetailProfile(?:\([^\r\n]*\))?|Execution 
Profile\b[^\r\n]*):?\s*$/gm;
+
+export function byteLen(value: string): number {
+    return new Blob([value]).size;
+}
+
+export function ensureTxt(name: string): string {
+    return name.toLowerCase().endsWith('.txt') ? name : `${name}.txt`;
+}
+
+export function truncateProfile(text: string): string {
+    const mergedProfile = MERGED_PROFILE_HEADER.exec(text);
+    if (!mergedProfile) return text;
+
+    DETAIL_PROFILE_HEADER.lastIndex = mergedProfile.index + 
mergedProfile[0].length;
+    const detailProfile = DETAIL_PROFILE_HEADER.exec(text);
+    DETAIL_PROFILE_HEADER.lastIndex = 0;
+    if (!detailProfile) return text;
+
+    const retainedText = text.slice(0, detailProfile.index).trimEnd();
+    return `${TRUNCATION_MARKER}\n${retainedText}\n`;
+}
+
+export async function prepareProfileFile(file: File): Promise<File> {
+    if (file.size <= MAX_UPLOAD_BYTES) {
+        return file;
+    }
+    if (file.size > MAX_RAW_BYTES) {
+        throw new Error('The selected file is larger than 100 MiB. Please 
provide a merged Profile.');
+    }
+
+    const output = truncateProfile(await file.text());
+    if (byteLen(output) > MAX_UPLOAD_BYTES) {
+        throw new Error(
+            'The Profile is still larger than 10 MiB after removing 
per-instance execution profiles. Please provide a merged Profile.',
+        );
+    }
+    return new File([output], ensureTxt(file.name), { type: 'text/plain' });
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to