diff --git a/handwritten/storage/internal-tooling/README.md b/handwritten/storage/internal-tooling/README.md index 9a40bb4c97a1..97b3a596ba99 100644 --- a/handwritten/storage/internal-tooling/README.md +++ b/handwritten/storage/internal-tooling/README.md @@ -40,4 +40,35 @@ For each invocation of the benchmark, write a new object of random size between | ElapsedTimeUs | the elapsed time in microseconds the operation took | | Status | completion state of the operation [OK, FAIL] | | AppBufferSize | N/A | -| CpuTimeUs | N/A | \ No newline at end of file +| CpuTimeUs | N/A | + +--- + +## Comparative Latency & Memory Benchmarking (`benchmark.ts`) + +This benchmark compares the current codebase build against a specified baseline NPM version of `@google-cloud/storage` (e.g. comparing Gaxios migration vs baseline `7.19.0`). It measures latency and throughput metrics for standard upload, stream upload, metadata lookup, standard download, stream download, bucket file listing, file existence checks (Exists), updating metadata (Set Metadata), copying files (Copy File), and deleting files (Delete File) scenarios, while tracking heap memory footprint changes. + +### Run Example: + +1. **Compile the codebase:** + ```bash + cd handwritten/storage + npm run compile + ``` + +2. **Execute the benchmark comparison:** + *(Note: `--experimental-specifier-resolution=node` is recommended for ESM-compiled specifiers in node).* + ```bash + node --experimental-specifier-resolution=node build/esm/internal-tooling/benchmark.js --projectid --bucket --iterations 100 --baseline 7.19.0 --fileSize 10485760 --resumable + ``` + +### CLI Parameters: + +| Parameter | Description | Requirement | Default | +| --------- | ----------- | :---: | :---: | +| `--projectid` | Google Cloud Project ID | **Required** | - | +| `--bucket` | Cloud Storage Bucket Name to upload/download files | **Required** | - | +| `--iterations` | Number of iterations for each workload scenario | Optional | `100` | +| `--baseline` | Stable baseline NPM version of `@google-cloud/storage` to compare against | Optional | - | +| `--fileSize` | File size in bytes for benchmark uploads/downloads | Optional | `1024` (1KB) | +| `--resumable` | Force resumable upload for the upload scenarios | Optional | - (default behavior) | \ No newline at end of file diff --git a/handwritten/storage/internal-tooling/benchmark.ts b/handwritten/storage/internal-tooling/benchmark.ts new file mode 100644 index 000000000000..eb2ce16995ae --- /dev/null +++ b/handwritten/storage/internal-tooling/benchmark.ts @@ -0,0 +1,863 @@ +/*! + * Copyright 2026 Google LLC. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Storage, File, Bucket} from '../src/index.js'; +import {performance} from 'perf_hooks'; +import * as path from 'path'; +import * as fs from 'fs'; +import {execSync} from 'child_process'; +import * as os from 'os'; +import yargs from 'yargs'; +import {randomBytes} from 'crypto'; + +interface Args { + projectId: string; + bucket: string; + iterations: number; + baseline?: string; + fileSize: number; + resumable?: boolean; +} + +const argv = yargs(process.argv.slice(2)) + .option('projectId', { + type: 'string', + alias: 'projectid', + demandOption: true, + description: 'Google Cloud Project ID' + }) + .option('bucket', { + type: 'string', + demandOption: true, + description: 'Cloud Storage Bucket Name' + }) + .option('iterations', { + type: 'number', + default: 100, + description: 'Number of iterations for each test' + }) + .option('baseline', { + type: 'string', + description: 'Baseline version of @google-cloud/storage to compare against (e.g., 7.19.0)' + }) + .option('fileSize', { + type: 'number', + default: 1024, + description: 'File size in bytes for benchmark uploads' + }) + .option('resumable', { + type: 'boolean', + description: 'Force resumable upload for the upload scenario' + }) + .parseSync() as unknown as Args; + +let tempDirToDelete: string | undefined; + +async function loadBaseline(version: string) { + // Strict SemVer regular expression to prevent command injection + const semverRegex = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/; + if (!semverRegex.test(version)) { + throw new Error(`Invalid baseline version format: "${version}". Must be a valid semver string (e.g. 7.19.0).`); + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'storage-benchmark-')); + tempDirToDelete = tempDir; // Track for cleanup + + console.log(`Installing baseline version ${version} in ${tempDir}...`); + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({name: 'bench-temp'})); + execSync(`npm install @google-cloud/storage@${version} --silent`, {cwd: tempDir}); + const baselinePath = path.join(tempDir, 'node_modules', '@google-cloud/storage'); + + const pkgJson = JSON.parse(fs.readFileSync(path.join(baselinePath, 'package.json'), 'utf8')); + const main = pkgJson.main || './build/src/index.js'; + const entry = path.join(baselinePath, main); + + console.log(`Loading baseline from ${entry}`); + const pkg = await import(entry); + return pkg.Storage || pkg.default?.Storage || pkg.default; +} + +const logMemory = (prefix: string) => { + const mem = process.memoryUsage(); + console.log(`${prefix} - Heap Used: ${(mem.heapUsed / 1024 / 1024).toFixed(2)} MB / Heap Total: ${(mem.heapTotal / 1024 / 1024).toFixed(2)} MB`); +}; + +async function cleanupResources(resources: Array<{delete(): Promise}>, concurrency = 32) { + for (let i = 0; i < resources.length; i += concurrency) { + const chunk = resources.slice(i, i + concurrency); + await Promise.all(chunk.map(r => r.delete().catch(() => {}))); + } +} + +async function runUploadScenario( + bucket: Bucket, + content: Buffer, + name: string, + uploadedFiles: File[] +): Promise { + console.log(`Starting Scenario: Upload (${argv.fileSize} bytes)...`); + const uploadTimes: number[] = []; + const options = argv.resumable !== undefined ? {resumable: argv.resumable} : {}; + + for (let i = 0; i < argv.iterations; i++) { + if (i % 10 === 0) logMemory(` Upload iteration ${i}`); + const iterFilename = `bench-${name}-${Date.now()}-${i}.bin`; + const iterFile = bucket.file(iterFilename); + const start = performance.now(); + await iterFile.save(content, options); + uploadTimes.push(performance.now() - start); + uploadedFiles.push(iterFile); + } + return uploadTimes; +} + +async function runStreamUploadScenario( + bucket: Bucket, + content: Buffer, + name: string, + uploadedFiles: File[] +): Promise { + console.log(`Starting Scenario: Stream Upload (${argv.fileSize} bytes)...`); + const uploadTimes: number[] = []; + const options = argv.resumable !== undefined ? {resumable: argv.resumable} : {}; + + for (let i = 0; i < argv.iterations; i++) { + if (i % 10 === 0) logMemory(` Stream Upload iteration ${i}`); + const iterFilename = `bench-stream-${name}-${Date.now()}-${i}.bin`; + const iterFile = bucket.file(iterFilename); + const start = performance.now(); + await new Promise((resolve, reject) => { + const writeStream = iterFile.createWriteStream(options); + writeStream.on('finish', () => resolve()); + writeStream.on('error', err => reject(err)); + writeStream.end(content); + }); + uploadTimes.push(performance.now() - start); + uploadedFiles.push(iterFile); + } + return uploadTimes; +} + +async function runLocalFileUploadScenario( + bucket: Bucket, + content: Buffer, + name: string, + uploadedFiles: File[] +): Promise<{ resumableTimes: number[]; multipartTimes: number[] }> { + console.log(`Starting Scenario: Local bucket.upload() (${argv.fileSize} bytes)...`); + const resumableTimes: number[] = []; + const multipartTimes: number[] = []; + + // Create a temporary local file for bucket.upload() + const localFilePath = path.join(os.tmpdir(), `bench-local-${name}-${Date.now()}.bin`); + fs.writeFileSync(localFilePath, content); + + try { + for (let i = 0; i < argv.iterations; i++) { + if (i % 10 === 0) logMemory(` Local Upload iteration ${i}`); + + // Resumable upload + const resName = `bench-upload-res-${name}-${Date.now()}-${i}.bin`; + let start = performance.now(); + const [resFile] = await bucket.upload(localFilePath, { destination: resName, resumable: true }); + resumableTimes.push(performance.now() - start); + uploadedFiles.push(resFile); + + // Multipart upload + const multiName = `bench-upload-multi-${name}-${Date.now()}-${i}.bin`; + start = performance.now(); + const [multiFile] = await bucket.upload(localFilePath, { destination: multiName, resumable: false }); + multipartTimes.push(performance.now() - start); + uploadedFiles.push(multiFile); + } + } finally { + if (fs.existsSync(localFilePath)) { + fs.unlinkSync(localFilePath); + } + } + + return { resumableTimes, multipartTimes }; +} + +async function runMetadataScenario( + mainFile: File +): Promise { + console.log('Starting Scenario: Get Metadata...'); + const metadataTimes: number[] = []; + for (let i = 0; i < argv.iterations; i++) { + if (i % 10 === 0) logMemory(` Metadata iteration ${i}`); + const start = performance.now(); + await mainFile.getMetadata(); + metadataTimes.push(performance.now() - start); + } + return metadataTimes; +} + +async function runDownloadScenario( + mainFile: File +): Promise { + console.log(`Starting Scenario: Download (${argv.fileSize} bytes)...`); + const downloadTimes: number[] = []; + for (let i = 0; i < argv.iterations; i++) { + if (i % 10 === 0) logMemory(` Download iteration ${i}`); + const start = performance.now(); + await mainFile.download(); + downloadTimes.push(performance.now() - start); + } + return downloadTimes; +} + +async function runStreamDownloadScenario( + mainFile: File +): Promise { + console.log(`Starting Scenario: Stream Download (${argv.fileSize} bytes)...`); + const downloadTimes: number[] = []; + for (let i = 0; i < argv.iterations; i++) { + if (i % 10 === 0) logMemory(` Stream Download iteration ${i}`); + const start = performance.now(); + await new Promise((resolve, reject) => { + const readStream = mainFile.createReadStream(); + readStream.on('data', () => {}); + readStream.on('end', () => resolve()); + readStream.on('error', err => reject(err)); + }); + downloadTimes.push(performance.now() - start); + } + return downloadTimes; +} + +async function runFileGetSaveAndResumableCreateScenario( + bucket: Bucket, + mainFile: File, + content: Buffer +): Promise<{ getTimes: number[]; createResumableTimes: number[]; saveMultipartTimes: number[] }> { + console.log('Starting Scenario: File .get(), save(multipart), and createResumableUpload()...'); + const getTimes: number[] = []; + const createResumableTimes: number[] = []; + const saveMultipartTimes: number[] = []; + + const tempFiles: File[] = []; + + try { + for (let i = 0; i < argv.iterations; i++) { + if (i % 10 === 0) logMemory(` Missing Methods iteration ${i}`); + + // 1. file.get() + let start = performance.now(); + await mainFile.get(); + getTimes.push(performance.now() - start); + + // 2. Explicit multipart save + const multiFile = bucket.file(`bench-save-multi-${Date.now()}-${i}.bin`); + tempFiles.push(multiFile); + start = performance.now(); + await multiFile.save(content, { resumable: false }); + saveMultipartTimes.push(performance.now() - start); + + // 3. createResumableUpload explicitly + const resFile = bucket.file(`bench-createres-${Date.now()}-${i}.bin`); + tempFiles.push(resFile); + start = performance.now(); + await resFile.createResumableUpload(); + createResumableTimes.push(performance.now() - start); + } + } finally { + await cleanupResources(tempFiles); + } + + return { getTimes, createResumableTimes, saveMultipartTimes }; +} + +async function runListFilesScenario( + bucket: Bucket, + prefix: string +): Promise { + console.log('Starting Scenario: List Files (getFiles & getFilesStream)...'); + const listTimes: number[] = []; + for (let i = 0; i < argv.iterations; i++) { + if (i % 10 === 0) logMemory(` List Files iteration ${i}`); + const start = performance.now(); + await bucket.getFiles({prefix, maxResults: 100}); + + // getFilesStream + await new Promise((resolve, reject) => { + const stream = bucket.getFilesStream({prefix, maxResults: 100}); + stream.on('data', () => {}); + stream.on('end', () => resolve()); + stream.on('error', err => reject(err)); + }); + + listTimes.push(performance.now() - start); + } + return listTimes; +} + +async function runExistsScenario( + mainFile: File +): Promise { + console.log('Starting Scenario: Exists...'); + const existsTimes: number[] = []; + for (let i = 0; i < argv.iterations; i++) { + if (i % 10 === 0) logMemory(` Exists iteration ${i}`); + const start = performance.now(); + await mainFile.exists(); + existsTimes.push(performance.now() - start); + } + return existsTimes; +} + +async function runSetMetadataScenario( + mainFile: File +): Promise { + console.log('Starting Scenario: Set Metadata...'); + const setMetadataTimes: number[] = []; + for (let i = 0; i < argv.iterations; i++) { + if (i % 10 === 0) logMemory(` Set Metadata iteration ${i}`); + const start = performance.now(); + await mainFile.setMetadata({ + metadata: { + benchmarkedAt: new Date().toISOString(), + iteration: i.toString(), + }, + }); + setMetadataTimes.push(performance.now() - start); + } + return setMetadataTimes; +} + +async function runDeleteScenario( + bucket: Bucket, + name: string, + content: Buffer +): Promise { + console.log('Starting Scenario: Delete...'); + const deleteTimes: number[] = []; + const filesToDelete: File[] = []; + for (let i = 0; i < argv.iterations; i++) { + const filename = `bench-delete-target-${name}-${Date.now()}-${i}.bin`; + const file = bucket.file(filename); + await file.save(content); + filesToDelete.push(file); + } + + for (let i = 0; i < argv.iterations; i++) { + if (i % 10 === 0) logMemory(` Delete iteration ${i}`); + const file = filesToDelete[i]; + const start = performance.now(); + await file.delete(); + deleteTimes.push(performance.now() - start); + } + return deleteTimes; +} + +async function runBucketLifecycleScenario( + storage: Storage, + name: string +): Promise { + console.log('Starting Scenario: Bucket Lifecycle (Create, Get, Exists, Delete)...'); + const safeName = name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + const times: number[] = []; + for (let i = 0; i < argv.iterations; i++) { + if (i % 10 === 0) logMemory(` Bucket Lifecycle iteration ${i}`); + const bucketName = `bench-lifecycle-${safeName}-${Date.now()}-${i}`; + const bucket = storage.bucket(bucketName); + + const start = performance.now(); + await storage.createBucket(bucketName); // createBucket / storage.buckets.insert + await bucket.get(); // bucketGet + await bucket.exists(); // bucketExists + await bucket.getMetadata(); // bucketGetMetadata + await bucket.delete(); // deleteBucket + times.push(performance.now() - start); + } + return times; +} + +async function runBucketPatchScenario( + storage: Storage, + name: string +): Promise { + const safeName = name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + const bucketName = `bench-patch-${safeName}-${Date.now()}`; + const bucket = storage.bucket(bucketName); + const times: number[] = []; + await bucket.create(); + + try { + for (let i = 0; i < argv.iterations; i++) { + if (i % 10 === 0) logMemory(` Bucket Patch iteration ${i}`); + const start = performance.now(); + await bucket.setMetadata({ + metadata: { + customLabel: i.toString(), + }, + }); // bucketSetMetadata / storage.buckets.patch + await bucket.setLabels({ testlabel: 'val' }); // setLabels + await bucket.getLabels(); // getLabels + await bucket.deleteLabels('testlabel'); // deleteLabels + await bucket.addLifecycleRule({ + action: { type: 'Delete' }, + condition: { age: 365 }, + }); // addLifecycleRule + await bucket.enableRequesterPays(); // enableRequesterPays + await bucket.disableRequesterPays(); // disableRequesterPays + await bucket.enableLogging({ + bucket: bucketName, + prefix: 'log', + }); // enableLogging + await bucket.setCorsConfiguration([{ + maxAgeSeconds: 3600, + method: ['GET'], + origin: ['*'], + }]); // setCorsConfiguration + await bucket.setRetentionPeriod(1000); // setRetentionPeriod + await bucket.removeRetentionPeriod(); // removeRetentionPeriod + await bucket.setStorageClass('nearline'); // bucketSetStorageClass + await bucket.makePublic(); // bucketMakePublic + await bucket.makePrivate(); // bucketMakePrivate + times.push(performance.now() - start); + } + } finally { + await bucket.delete().catch(() => {}); + } + return times; +} + +async function runBucketLockScenario( + storage: Storage, + name: string +): Promise { + const safeName = name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + const bucketName = `bench-lock-${safeName}-${Date.now()}`; + const bucket = storage.bucket(bucketName); + const times: number[] = []; + await bucket.create(); + + try { + for (let i = 0; i < argv.iterations; i++) { + if (i % 10 === 0) logMemory(` Bucket Lock iteration ${i}`); + // Setting retention period so we can lock it + await bucket.setRetentionPeriod(1000); + const [metadata] = await bucket.getMetadata(); + const metageneration = metadata.metageneration; + + const start = performance.now(); + await bucket.lock(metageneration!); // lockRententionPolicy + times.push(performance.now() - start); + } + } finally { + await bucket.delete().catch(() => {}); + } + return times; +} + +async function runStorageListAndAccountScenario( + storage: Storage +): Promise { + console.log('Starting Scenario: Storage List and Service Account...'); + const times: number[] = []; + for (let i = 0; i < argv.iterations; i++) { + if (i % 10 === 0) logMemory(` Storage List/Account iteration ${i}`); + const start = performance.now(); + await storage.getBuckets({ maxResults: 1 }); // getBuckets + + // getBucketsStream + await new Promise((resolve, reject) => { + const stream = storage.getBucketsStream(); + stream.on('data', () => {}); + stream.on('end', () => resolve()); + stream.on('error', err => reject(err)); + }); + + await storage.getServiceAccount(); // getServiceAccount + times.push(performance.now() - start); + } + return times; +} + +async function runFilePatchAndAclScenario( + bucket: Bucket, + file: File +): Promise { + console.log('Starting Scenario: File Patch, Get, and ACL (makePublic/makePrivate)...'); + const times: number[] = []; + for (let i = 0; i < argv.iterations; i++) { + if (i % 10 === 0) logMemory(` File Patch/ACL iteration ${i}`); + const start = performance.now(); + try { + await file.makePublic(); // fileMakePublic + await file.isPublic(); // isPublic + await file.makePrivate(); // fileMakePrivate + await file.getExpirationDate(); // getExpirationDate + times.push(performance.now() - start); + } catch (err: any) { + if (i === 0) { + console.warn(' [Skip] Bucket likely has Uniform Bucket-Level Access enabled. Skipping ACL benchmark.'); + } + break; // Exit the loop entirely for this scenario + } + } + return times; +} + +async function runFileCopyMoveComposeScenario( + bucket: Bucket, + mainFile: File, + name: string +): Promise { + console.log('Starting Scenario: File Copy, Move, Rename, Rotate Key, Storage Class and Compose...'); + const times: number[] = []; + const tempFiles: File[] = []; + + try { + for (let i = 0; i < argv.iterations; i++) { + if (i % 10 === 0) logMemory(` File Copy/Move/Compose iteration ${i}`); + const destFilename = `bench-copy-dest-${name}-${Date.now()}-${i}.bin`; + const movedFilename = `bench-moved-${name}-${Date.now()}-${i}.bin`; + const composedFilename = `bench-composed-${name}-${Date.now()}-${i}.bin`; + + const destFile = bucket.file(destFilename); + const movedFile = bucket.file(movedFilename); + const composedFile = bucket.file(composedFilename); + + const start = performance.now(); + await mainFile.copy(destFile); // copy / objects.rewrite + await destFile.setStorageClass('nearline'); // setStorageClass / objects.rewrite + + // Rotate Key isolated scenario + try { + const encFilename = `bench-enc-${name}-${Date.now()}-${i}.bin`; + const key1 = randomBytes(32); + const encFile = bucket.file(encFilename, { encryptionKey: key1 }); + const content = Buffer.alloc(1024, 'a'); + await encFile.save(content); + const key2 = randomBytes(32); + await encFile.rotateEncryptionKey({ encryptionKey: key2 }); // rotateEncryptionKey / objects.rewrite + await encFile.delete(); + } catch (encErr) { + console.warn(' [Warning] Rotate encryption key failed. Skipping rotation sub-step.'); + } + + await destFile.move(movedFile); // move / rename / objects.rewrite + await bucket.combine([mainFile, movedFile], composedFile); // combine / objects.compose + + times.push(performance.now() - start); + tempFiles.push(movedFile, composedFile); + } + } finally { + await cleanupResources(tempFiles); + } + return times; +} + +async function runNotificationScenario( + bucket: Bucket, + name: string +): Promise { + console.log('Starting Scenario: Notifications...'); + const times: number[] = []; + const dummyTopic = `projects/${argv.projectId}/topics/bench-topic-${Date.now()}`; + const createdNotifications: any[] = []; + + try { + for (let i = 0; i < argv.iterations; i++) { + if (i % 10 === 0) logMemory(` Notification iteration ${i}`); + const start = performance.now(); + try { + const [notification] = await bucket.createNotification(dummyTopic); // createNotification + createdNotifications.push(notification); + + await notification.getMetadata(); // notificationGetMetadata + await notification.get(); // notificationGet / notifications.get + await notification.exists(); // notificationExists + await bucket.getNotifications(); // getNotifications + await notification.delete(); // notificationDelete + } catch (err) { + console.warn(' [Warning] Notification scenario step failed (Pub/Sub configuration or permission issue). Skipping metrics for this step.'); + } + times.push(performance.now() - start); + } + } finally { + await cleanupResources(createdNotifications); + } + return times; +} + +async function runHmacKeyScenario( + storage: Storage +): Promise { + console.log('Starting Scenario: HMAC Key Management...'); + const times: number[] = []; + const keysToDelete: any[] = []; + + try { + const [serviceAccount] = await storage.getServiceAccount(); + const email = serviceAccount.email_address; + if (!email) { + throw new Error('Service account email is required'); + } + + for (let i = 0; i < argv.iterations; i++) { + if (i % 10 === 0) logMemory(` HMAC Key iteration ${i}`); + const start = performance.now(); + try { + const [hmacKey] = (await storage.createHmacKey(email)) as any; // createHMACKey + keysToDelete.push(hmacKey); + + await hmacKey.getMetadata(); // getMetadataHMAC / getHMAC + await hmacKey.get(); // getHMAC / hmacKey.get + + // getHMACKeyStream + await new Promise((resolve, reject) => { + const stream = storage.getHmacKeysStream(); + stream.on('data', () => {}); + stream.on('end', () => resolve()); + stream.on('error', err => reject(err)); + }); + + // To delete, we must first set state to INACTIVE + await hmacKey.setMetadata({ state: 'INACTIVE' }); // hmacKey.update + await hmacKey.delete(); // deleteHMAC + } catch (err) { + console.warn(' [Warning] HMAC key scenario step failed (missing permissions or project state). Skipping metrics.'); + } + times.push(performance.now() - start); + } + } catch (err) { + console.warn(' [Warning] HMAC Scenario initialization failed (could not fetch service account). Skipping.'); + } finally { + // Cleanup keys in case + for (const key of keysToDelete) { + try { + await key.setMetadata({ state: 'INACTIVE' }).catch(() => {}); + await key.delete().catch(() => {}); + } catch {} + } + } + return times; +} + +async function runBucketIamScenario( + bucket: Bucket +): Promise { + console.log('Starting Scenario: Bucket IAM (getIamPolicy, setIamPolicy, testIamPermissions)...'); + const times: number[] = []; + for (let i = 0; i < argv.iterations; i++) { + if (i % 10 === 0) logMemory(` Bucket IAM iteration ${i}`); + const start = performance.now(); + try { + const [policy] = await bucket.iam.getPolicy(); // iamGetPolicy + await bucket.iam.setPolicy(policy); // iamSetPolicy + await bucket.iam.testPermissions(['storage.buckets.get']); // iamTestPermissions + } catch (err) { + console.warn(' [Warning] IAM scenario failed (permission issue). Skipping.'); + } + times.push(performance.now() - start); + } + return times; +} + +async function runBenchmark(StorageClass: typeof Storage, name: string, bucketName: string) { + // Pass custom project ID to the storage client + const storage = new StorageClass({ projectId: argv.projectId }); + const bucket = storage.bucket(bucketName); + const content = Buffer.alloc(argv.fileSize, 'a'); + const uploadedFiles: File[] = []; + const safeName = name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + + console.log(`\n=== Running benchmark for ${name} ===`); + + try { + const uploadTimes = await runUploadScenario(bucket, content, safeName, uploadedFiles); + reportResults(`Upload (${argv.fileSize} bytes)`, uploadTimes, true); + logMemory('After Upload'); + + const streamUploadedFiles: File[] = []; + const streamUploadTimes = await runStreamUploadScenario(bucket, content, safeName, streamUploadedFiles); + reportResults(`Stream Upload (${argv.fileSize} bytes)`, streamUploadTimes, true); + logMemory('After Stream Upload'); + uploadedFiles.push(...streamUploadedFiles); + + const localUploadResults = await runLocalFileUploadScenario(bucket, content, safeName, uploadedFiles); + reportResults('Local bucket.upload() Resumable', localUploadResults.resumableTimes, true); + reportResults('Local bucket.upload() Multipart', localUploadResults.multipartTimes, true); + logMemory('After Local Uploads'); + + const mainFile = uploadedFiles[0]; + + const metadataTimes = await runMetadataScenario(mainFile); + reportResults('Get Metadata', metadataTimes); + logMemory('After Metadata'); + + const fileGetSaveCreateResults = await runFileGetSaveAndResumableCreateScenario(bucket, mainFile, content); + reportResults('File .get()', fileGetSaveCreateResults.getTimes); + reportResults('File .save({ resumable: false })', fileGetSaveCreateResults.saveMultipartTimes, true); + reportResults('File .createResumableUpload()', fileGetSaveCreateResults.createResumableTimes); + logMemory('After File Get, Save, and Resumable Create'); + + const downloadTimes = await runDownloadScenario(mainFile); + reportResults(`Download (${argv.fileSize} bytes)`, downloadTimes, true); + logMemory('After Download'); + + const streamDownloadTimes = await runStreamDownloadScenario(mainFile); + reportResults(`Stream Download (${argv.fileSize} bytes)`, streamDownloadTimes, true); + logMemory('After Stream Download'); + + const listTimes = await runListFilesScenario(bucket, `bench-${safeName}`); + reportResults('List Files', listTimes); + logMemory('After List Files'); + + const existsTimes = await runExistsScenario(mainFile); + reportResults('Exists', existsTimes); + logMemory('After Exists'); + + const setMetadataTimes = await runSetMetadataScenario(mainFile); + reportResults('Set Metadata', setMetadataTimes); + logMemory('After Set Metadata'); + + const deleteTimes = await runDeleteScenario(bucket, safeName, content); + reportResults('Delete File', deleteTimes); + logMemory('After Delete File'); + + try { + const bucketLifecycleTimes = await runBucketLifecycleScenario(storage, safeName); + reportResults('Bucket Lifecycle', bucketLifecycleTimes); + } catch (err) { + console.warn(' [Warning] Bucket Lifecycle scenario failed (likely missing storage.buckets.create permissions). Skipping.'); + } + logMemory('After Bucket Lifecycle'); + + try { + const bucketPatchTimes = await runBucketPatchScenario(storage, safeName); + reportResults('Bucket Patch / Settings', bucketPatchTimes); + } catch (err) { + console.warn(' [Warning] Bucket Patch scenario failed (likely missing storage.buckets.create permissions). Skipping.'); + } + logMemory('After Bucket Patch'); + + try { + const bucketLockTimes = await runBucketLockScenario(storage, safeName); + reportResults('Bucket Lock Retention Policy', bucketLockTimes); + } catch (err) { + console.warn(' [Warning] Bucket Lock scenario failed (likely missing storage.buckets.create permissions). Skipping.'); + } + logMemory('After Bucket Lock'); + + try { + const storageListTimes = await runStorageListAndAccountScenario(storage); + reportResults('Storage List & Service Account', storageListTimes); + } catch (err) { + console.warn(' [Warning] Storage List & Service Account scenario failed (likely missing storage.buckets.list permissions). Skipping.', err); + } + logMemory('After Storage List/Account'); + + try { + const filePatchAclTimes = await runFilePatchAndAclScenario(bucket, mainFile); + if (filePatchAclTimes.length > 0) { + reportResults('File Patch, Get, and ACL', filePatchAclTimes); + } + } catch (err) { + console.warn(' [Warning] File Patch, Get, and ACL scenario failed. Skipping.'); + } + logMemory('After File Patch/ACL'); + + try { + const fileCopyMoveComposeTimes = await runFileCopyMoveComposeScenario(bucket, mainFile, safeName); + reportResults('File Copy, Move, Compose & Storage Class', fileCopyMoveComposeTimes); + } catch (err) { + console.warn(' [Warning] File Copy, Move, Compose & Storage Class scenario failed. Skipping.', err); + } + logMemory('After File Copy/Move/Compose'); + + const notificationTimes = await runNotificationScenario(bucket, safeName); + reportResults('Notifications', notificationTimes); + logMemory('After Notifications'); + + const hmacTimes = await runHmacKeyScenario(storage); + reportResults('HMAC Key Management', hmacTimes); + logMemory('After HMAC Key Management'); + + try { + const iamTimes = await runBucketIamScenario(bucket); + reportResults('Bucket IAM', iamTimes); + } catch (err) { + console.warn(' [Warning] Bucket IAM scenario failed. Skipping.'); + } + logMemory('After Bucket IAM'); + + } finally { + // Guaranteed cloud files deletion + console.log('Cleaning up cloud files...'); + await cleanupResources(uploadedFiles); + logMemory('After Cleanup'); + } +} + +function reportResults(operation: string, times: number[], includeThroughput = false) { + const min = Math.min(...times); + const max = Math.max(...times); + const avg = times.reduce((a, b) => a + b, 0) / times.length; + + console.log(`\n${operation}:`); + console.log(` Iterations: ${times.length}`); + console.log(` Average Latency: ${avg.toFixed(2)} ms`); + console.log(` Min Latency: ${min.toFixed(2)} ms`); + console.log(` Max Latency: ${max.toFixed(2)} ms`); + if (includeThroughput) { + const throughput = (argv.fileSize / 1024) * (1000 / avg); // KB/s + console.log(` Approx. Throughput: ${throughput.toFixed(2)} KB/s`); + } +} + +async function main() { + try { + // Validate iterations parameter to handle edge cases + if (argv.iterations < 1) { + throw new Error('Iterations parameter must be greater than or equal to 1'); + } + + // Validate fileSize parameter + if (argv.fileSize < 0) { + throw new Error('fileSize parameter must be greater than or equal to 0'); + } + + // Run for local version + await runBenchmark(Storage, 'Current (Gaxios)', argv.bucket); + + // Run for baseline if specified + if (argv.baseline) { + const BaselineStorage = await loadBaseline(argv.baseline); + await runBenchmark(BaselineStorage, `Baseline (${argv.baseline})`, argv.bucket); + } + } catch (error) { + console.error('Error running benchmark:', error); + // Exit with non-zero code on failures for CI integration + process.exitCode = 1; + } finally { + // Guaranteed local directory cleanup + if (tempDirToDelete) { + console.log(`Cleaning up local temporary directory: ${tempDirToDelete}`); + try { + fs.rmSync(tempDirToDelete, { recursive: true, force: true }); + } catch (cleanupErr) { + console.error('Failed to clean up local temporary directory:', cleanupErr); + } + } + } +} + +main(); +