From 12651deddee26fc0b0f7519ff1ffbc175b0e5b06 Mon Sep 17 00:00:00 2001 From: StarpTech Date: Tue, 12 Aug 2025 00:01:15 +0200 Subject: [PATCH 01/10] fix(s3): allow to delete objects individually --- controlplane/src/core/blobstorage/s3.ts | 136 ++++++++++++++++++++---- controlplane/src/core/build-server.ts | 5 +- 2 files changed, 122 insertions(+), 19 deletions(-) diff --git a/controlplane/src/core/blobstorage/s3.ts b/controlplane/src/core/blobstorage/s3.ts index ea7c7c6983..f706140851 100644 --- a/controlplane/src/core/blobstorage/s3.ts +++ b/controlplane/src/core/blobstorage/s3.ts @@ -9,14 +9,61 @@ import { } from '@aws-sdk/client-s3'; import { BlobNotFoundError, BlobObject, type BlobStorage } from './index.js'; +const maxConcurrency = 10; // Maximum number of concurrent operations (conservative for GCS compatibility) +const batchDelayMs = 200; // Delay between batches in milliseconds (balance between throughput and rate limiting) + +/** + * Configuration options for S3BlobStorage + */ +export interface S3BlobStorageConfig { + /** + * Use individual delete operations instead of bulk delete. + * Set to true for GCS compatibility, false for better S3 performance. + * @default false + */ + useIndividualDeletes?: boolean; +} + /** * Stores objects in S3 given an S3Client and a bucket name */ export class S3BlobStorage implements BlobStorage { + private readonly useIndividualDeletes: boolean; + constructor( private s3Client: S3Client, private bucketName: string, - ) {} + config: S3BlobStorageConfig = {}, + ) { + this.useIndividualDeletes = config.useIndividualDeletes ?? false; + } + + /** + * Sleep for the specified number of milliseconds + */ + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * Execute promises with limited concurrency and delays between batches + */ + private async executeWithConcurrency(tasks: (() => Promise)[], concurrency: number): Promise { + const results: T[] = []; + + for (let i = 0; i < tasks.length; i += concurrency) { + const batch = tasks.slice(i, i + concurrency); + const batchResults = await Promise.all(batch.map((task) => task())); + results.push(...batchResults); + + // Add delay between batches (except for the last batch) + if (i + concurrency < tasks.length) { + await this.sleep(batchDelayMs); + } + } + + return results; + } async putObject>({ key, @@ -88,28 +135,81 @@ export class S3BlobStorage implements BlobStorage { } } - async removeDirectory(data: { key: string; abortSignal?: AbortSignal }): Promise { - const listCommand = new ListObjectsV2Command({ + /** + * Delete objects using bulk DeleteObjectsCommand (efficient for S3) + */ + private async deleteObjectsBulk(objects: { Key?: string }[], abortSignal?: AbortSignal): Promise { + const objectsToDelete = objects.filter((item) => item.Key).map((item) => ({ Key: item.Key! })); + + if (objectsToDelete.length === 0) { + return 0; + } + + const deleteCommand = new DeleteObjectsCommand({ Bucket: this.bucketName, - Prefix: data.key, + Delete: { + Objects: objectsToDelete, + Quiet: false, + }, }); - const entries = await this.s3Client.send(listCommand, { - abortSignal: data.abortSignal, + + const deleted = await this.s3Client.send(deleteCommand, { abortSignal }); + + if (deleted.Errors && deleted.Errors.length > 0) { + throw new Error(`Could not delete files: ${JSON.stringify(deleted.Errors)}`); + } + + return objectsToDelete.length; + } + + /** + * Delete objects individually with limited concurrency (for GCS compatibility) + */ + private async deleteObjectsIndividually(objects: { Key?: string }[], abortSignal?: AbortSignal): Promise { + const deleteTasks = objects.map((item) => async () => { + if (item.Key) { + const deleteCommand = new DeleteObjectCommand({ + Bucket: this.bucketName, + Key: item.Key, + }); + await this.s3Client.send(deleteCommand, { abortSignal }); + return 1; + } + return 0; }); - const objectsToDelete = entries.Contents?.map((item) => ({ Key: item.Key })); - if (objectsToDelete && objectsToDelete.length > 0) { - const deleteCommand = new DeleteObjectsCommand({ + + const deletedCounts = await this.executeWithConcurrency(deleteTasks, maxConcurrency); + return deletedCounts.reduce((sum: number, count: number) => sum + count, 0); + } + + async removeDirectory(data: { key: string; abortSignal?: AbortSignal }): Promise { + let totalDeleted = 0; + let continuationToken: string | undefined; + + do { + const listCommand = new ListObjectsV2Command({ Bucket: this.bucketName, - Delete: { - Objects: objectsToDelete, - Quiet: false, - }, + Prefix: data.key, + ContinuationToken: continuationToken, + }); + + const entries = await this.s3Client.send(listCommand, { + abortSignal: data.abortSignal, }); - const deleted = await this.s3Client.send(deleteCommand); - if (deleted.Errors) { - throw new Error(`could not delete files: ${deleted.Errors}`); + + if (entries.Contents && entries.Contents.length > 0) { + if (this.useIndividualDeletes) { + // Use individual deletes for GCS compatibility + totalDeleted += await this.deleteObjectsIndividually(entries.Contents, data.abortSignal); + } else { + // Use bulk delete for better S3 performance + totalDeleted += await this.deleteObjectsBulk(entries.Contents, data.abortSignal); + } } - } - return objectsToDelete?.length ?? 0; + + continuationToken = entries.IsTruncated ? entries.NextContinuationToken : undefined; + } while (continuationToken); + + return totalDeleted; } } diff --git a/controlplane/src/core/build-server.ts b/controlplane/src/core/build-server.ts index 115dc5eb7c..df7e02afd0 100644 --- a/controlplane/src/core/build-server.ts +++ b/controlplane/src/core/build-server.ts @@ -106,6 +106,7 @@ export interface BuildConfig { username?: string; password?: string; forcePathStyle?: boolean; + useIndividualDeletes?: boolean; }; mailer: { smtpEnabled: boolean; @@ -310,7 +311,9 @@ export default async function build(opts: BuildConfig) { const s3Config = createS3ClientConfig(bucketName, opts.s3Storage); const s3Client = new S3Client(s3Config); - const blobStorage = new S3BlobStorage(s3Client, bucketName); + const blobStorage = new S3BlobStorage(s3Client, bucketName, { + useIndividualDeletes: opts.s3Storage.useIndividualDeletes ?? false, + }); const platformWebhooks = new PlatformWebhookService(opts.webhook?.url, opts.webhook?.key, logger); From 69ef5d89c793e15c65c6a0a7b703853dabe687eb Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Mon, 11 Aug 2025 18:57:07 -0400 Subject: [PATCH 02/10] chore: add the `S3_USE_INDIVIDUAL_DELETES` and pass it to the configuration --- controlplane/.env.example | 1 + controlplane/src/core/build-server.ts | 6 +++++- controlplane/src/core/env.schema.ts | 9 +++++++++ controlplane/src/index.ts | 2 ++ 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/controlplane/.env.example b/controlplane/.env.example index de8787ab8a..c7df2247b2 100644 --- a/controlplane/.env.example +++ b/controlplane/.env.example @@ -37,6 +37,7 @@ S3_ENDPOINT="" S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= S3_FORCE_PATH_STYLE="true" +S3_USE_INDIVIDUAL_DELETES="false" # Optional for Stripe Integration DEFAULT_PLAN="developer@1" diff --git a/controlplane/src/core/build-server.ts b/controlplane/src/core/build-server.ts index df7e02afd0..95d6e79868 100644 --- a/controlplane/src/core/build-server.ts +++ b/controlplane/src/core/build-server.ts @@ -312,7 +312,11 @@ export default async function build(opts: BuildConfig) { const s3Client = new S3Client(s3Config); const blobStorage = new S3BlobStorage(s3Client, bucketName, { - useIndividualDeletes: opts.s3Storage.useIndividualDeletes ?? false, + // When using GCS, we overwrite the configured behavior to always use individual deletes as GCS doesn't + // support bulk object deletion as of August 11th, 2025 + useIndividualDeletes: opts.s3Storage.url.toLowerCase().includes('storage.googleapis.com') + ? true + : opts.s3Storage.useIndividualDeletes ?? false, }); const platformWebhooks = new PlatformWebhookService(opts.webhook?.url, opts.webhook?.key, logger); diff --git a/controlplane/src/core/env.schema.ts b/controlplane/src/core/env.schema.ts index f44ba0f719..363fbdc302 100644 --- a/controlplane/src/core/env.schema.ts +++ b/controlplane/src/core/env.schema.ts @@ -142,6 +142,15 @@ export const envVariables = z .string() .transform((val) => val === 'true') .default('true'), + /** + * Whether to use individual deletes for S3 objects instead of bulking them. + * + * This value is overwritten when using GCS to always be `true` as GCS does not support bulk object deletes. + */ + S3_USE_INDIVIDUAL_DELETES: z + .string() + .transform((val) => val === 'true') + .optional(), /** * Email */ diff --git a/controlplane/src/index.ts b/controlplane/src/index.ts index ef3f008977..1387868cdc 100644 --- a/controlplane/src/index.ts +++ b/controlplane/src/index.ts @@ -46,6 +46,7 @@ const { S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_FORCE_PATH_STYLE, + S3_USE_INDIVIDUAL_DELETES, SMTP_ENABLED, SMTP_HOST, SMTP_PORT, @@ -128,6 +129,7 @@ const options: BuildConfig = { username: S3_ACCESS_KEY_ID, password: S3_SECRET_ACCESS_KEY, forcePathStyle: S3_FORCE_PATH_STYLE, + useIndividualDeletes: S3_USE_INDIVIDUAL_DELETES, }, mailer: { smtpEnabled: SMTP_ENABLED, From c172eb10c654a9c9305571b366ee9c88f2c02808 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Tue, 12 Aug 2025 08:02:00 -0400 Subject: [PATCH 03/10] chore: don't overwrite the configured `useIndividualDeletes` value --- controlplane/.env.example | 1 - controlplane/src/core/build-server.ts | 13 +++++++------ controlplane/src/core/util.ts | 19 +++++++++++++++++++ 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/controlplane/.env.example b/controlplane/.env.example index c7df2247b2..de8787ab8a 100644 --- a/controlplane/.env.example +++ b/controlplane/.env.example @@ -37,7 +37,6 @@ S3_ENDPOINT="" S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= S3_FORCE_PATH_STYLE="true" -S3_USE_INDIVIDUAL_DELETES="false" # Optional for Stripe Integration DEFAULT_PLAN="developer@1" diff --git a/controlplane/src/core/build-server.ts b/controlplane/src/core/build-server.ts index 95d6e79868..fb985900a2 100644 --- a/controlplane/src/core/build-server.ts +++ b/controlplane/src/core/build-server.ts @@ -37,7 +37,7 @@ import { BillingRepository } from './repositories/BillingRepository.js'; import { BillingService } from './services/BillingService.js'; import { UserRepository } from './repositories/UserRepository.js'; import { AIGraphReadmeQueue, createAIGraphReadmeWorker } from './workers/AIGraphReadmeWorker.js'; -import { fastifyLoggerId, createS3ClientConfig, extractS3BucketName } from './util.js'; +import { fastifyLoggerId, createS3ClientConfig, extractS3BucketName, isGoogleCloudStorageUrl } from './util.js'; import { ApiKeyRepository } from './repositories/ApiKeyRepository.js'; import { createDeleteOrganizationWorker, DeleteOrganizationQueue } from './workers/DeleteOrganizationWorker.js'; import { @@ -312,11 +312,12 @@ export default async function build(opts: BuildConfig) { const s3Client = new S3Client(s3Config); const blobStorage = new S3BlobStorage(s3Client, bucketName, { - // When using GCS, we overwrite the configured behavior to always use individual deletes as GCS doesn't - // support bulk object deletion as of August 11th, 2025 - useIndividualDeletes: opts.s3Storage.url.toLowerCase().includes('storage.googleapis.com') - ? true - : opts.s3Storage.useIndividualDeletes ?? false, + // If the configuration option is not set, we try to detect whether the provided endpoint is a + // Google Cloud Storage endpoint, this is because GCS doesn't support the `deleteObjects` request as of + // August 12th, 2025 + useIndividualDeletes: + opts.s3Storage.useIndividualDeletes ?? + (isGoogleCloudStorageUrl(opts.s3Storage.url) || isGoogleCloudStorageUrl(s3Config.endpoint as string)), }); const platformWebhooks = new PlatformWebhookService(opts.webhook?.url, opts.webhook?.key, logger); diff --git a/controlplane/src/core/util.ts b/controlplane/src/core/util.ts index e7c6c7f4f6..95bb962455 100644 --- a/controlplane/src/core/util.ts +++ b/controlplane/src/core/util.ts @@ -398,6 +398,25 @@ export function webhookAxiosRetryCond(err: AxiosError) { return isNetworkError(err) || isRetryableError(err); } +/** + * Determines whether the given string is a Google Cloud Storage address by checking whether the hostname is + * `storage.googleapis.com` or the protocol is `gs:`. + */ +export function isGoogleCloudStorageUrl(s: string): boolean { + if (!s) { + return false; + } + + try { + const url = new URL(s); + return url.hostname === 'storage.googleapis.com' || url.protocol === 'gs:'; + } catch { + // ignore + } + + return false; +} + export function createS3ClientConfig(bucketName: string, opts: S3StorageOptions): S3ClientConfig { const url = new URL(opts.url); const { region, username, password } = opts; From 1564fe8e951fbc2a392197013732f5c6071dfab2 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Tue, 12 Aug 2025 08:37:49 -0400 Subject: [PATCH 04/10] chore: some nitpicks --- controlplane/src/core/env.schema.ts | 2 -- controlplane/src/core/util.ts | 6 +++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/controlplane/src/core/env.schema.ts b/controlplane/src/core/env.schema.ts index 363fbdc302..a70609ab32 100644 --- a/controlplane/src/core/env.schema.ts +++ b/controlplane/src/core/env.schema.ts @@ -144,8 +144,6 @@ export const envVariables = z .default('true'), /** * Whether to use individual deletes for S3 objects instead of bulking them. - * - * This value is overwritten when using GCS to always be `true` as GCS does not support bulk object deletes. */ S3_USE_INDIVIDUAL_DELETES: z .string() diff --git a/controlplane/src/core/util.ts b/controlplane/src/core/util.ts index 95bb962455..84e783c557 100644 --- a/controlplane/src/core/util.ts +++ b/controlplane/src/core/util.ts @@ -409,7 +409,11 @@ export function isGoogleCloudStorageUrl(s: string): boolean { try { const url = new URL(s); - return url.hostname === 'storage.googleapis.com' || url.protocol === 'gs:'; + const hostname = url.hostname.toLowerCase(); + + return ( + url.protocol === 'gs:' || hostname === 'storage.googleapis.com' || hostname.endsWith('.storage.googleapis.com') + ); } catch { // ignore } From 947e839e64d75677dc6a593eaff5986564eb1778 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 13 Aug 2025 08:58:07 -0400 Subject: [PATCH 05/10] chore: add tests for the `isGoogleCloudStorageUrl` helper --- controlplane/test/utils.test.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/controlplane/test/utils.test.ts b/controlplane/test/utils.test.ts index 85cddf31f1..81aeb6d5c5 100644 --- a/controlplane/test/utils.test.ts +++ b/controlplane/test/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { isValidLabelMatchers, mergeUrls, normalizeLabelMatchers } from '../src/core/util.js'; +import { isValidLabelMatchers, mergeUrls, normalizeLabelMatchers, isGoogleCloudStorageUrl } from '../src/core/util.js'; describe('Utils', () => { test('isValidLabelMatchers', () => { @@ -29,4 +29,23 @@ describe('Utils', () => { expect(mergeUrls('http://example.com/auth', '/path')).toBe('http://example.com/auth/path'); expect(mergeUrls('http://example.com/auth/', '/path')).toBe('http://example.com/auth/path'); }); + + describe('isGoogleCloudStorageUrl', () => { + test('that true is returned when a valid Google Cloud Storage URL', () => { + expect(isGoogleCloudStorageUrl('https://storage.googleapis.com/')).toBe(true); + expect(isGoogleCloudStorageUrl('https://STORAGE.GOOGLEAPIS.COM')).toBe(true); + expect(isGoogleCloudStorageUrl('https://storage.googleapis.com/bucket-name')).toBe(true); + expect(isGoogleCloudStorageUrl('https://bucket-name.storage.googleapis.com/')).toBe(true); + }); + + test('that true is returned when an URL with the `gs` protocol', () => { + expect(isGoogleCloudStorageUrl('gs://bucket-name')); + }); + + test('that false is returned when the URL is not a valid Google Cloud Storage URL', () => { + expect(isGoogleCloudStorageUrl('http://minio/cosmo')); + expect(isGoogleCloudStorageUrl('https://bucket-name.s3.amazonaws.com/')); + expect(isGoogleCloudStorageUrl('https://bucket-name.s3.amazonaws.com')); + }); + }); }); From 26fdd78db51ae318a8405a0cd3576830be763041 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 13 Aug 2025 09:35:47 -0400 Subject: [PATCH 06/10] chore: fix tests --- controlplane/test/utils.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/controlplane/test/utils.test.ts b/controlplane/test/utils.test.ts index 81aeb6d5c5..aa6f5a6a19 100644 --- a/controlplane/test/utils.test.ts +++ b/controlplane/test/utils.test.ts @@ -39,13 +39,14 @@ describe('Utils', () => { }); test('that true is returned when an URL with the `gs` protocol', () => { - expect(isGoogleCloudStorageUrl('gs://bucket-name')); + expect(isGoogleCloudStorageUrl('gs://bucket-name')).toBe(true); }); test('that false is returned when the URL is not a valid Google Cloud Storage URL', () => { - expect(isGoogleCloudStorageUrl('http://minio/cosmo')); - expect(isGoogleCloudStorageUrl('https://bucket-name.s3.amazonaws.com/')); - expect(isGoogleCloudStorageUrl('https://bucket-name.s3.amazonaws.com')); + expect(isGoogleCloudStorageUrl('http://minio/cosmo')).toBe(false); + expect(isGoogleCloudStorageUrl('https://bucket-name.s3.amazonaws.com/')).toBe(false); + expect(isGoogleCloudStorageUrl('https://bucket-name.s3.amazonaws.com')).toBe(false); + expect(isGoogleCloudStorageUrl('https://storage.googleapis.com.evil.com')).toBe(false); }); }); }); From dbcf5ddd05a9bb465381d38d733a27554d78c10e Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 13 Aug 2025 15:51:52 -0400 Subject: [PATCH 07/10] chore: force individual deletes on GCS --- controlplane/src/core/build-server.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/controlplane/src/core/build-server.ts b/controlplane/src/core/build-server.ts index fb985900a2..d70727e463 100644 --- a/controlplane/src/core/build-server.ts +++ b/controlplane/src/core/build-server.ts @@ -312,12 +312,11 @@ export default async function build(opts: BuildConfig) { const s3Client = new S3Client(s3Config); const blobStorage = new S3BlobStorage(s3Client, bucketName, { - // If the configuration option is not set, we try to detect whether the provided endpoint is a - // Google Cloud Storage endpoint, this is because GCS doesn't support the `deleteObjects` request as of - // August 12th, 2025 + // GCS does not support DeleteObjects; force individual deletes when detected. useIndividualDeletes: - opts.s3Storage.useIndividualDeletes ?? - (isGoogleCloudStorageUrl(opts.s3Storage.url) || isGoogleCloudStorageUrl(s3Config.endpoint as string)), + (isGoogleCloudStorageUrl(opts.s3Storage.url) || isGoogleCloudStorageUrl(s3Config.endpoint as string)) + ? true + : (opts.s3Storage.useIndividualDeletes ?? false), }); const platformWebhooks = new PlatformWebhookService(opts.webhook?.url, opts.webhook?.key, logger); From e213b542d260ad42fe594150aa365690c1386943 Mon Sep 17 00:00:00 2001 From: Wilson Rivera Date: Wed, 13 Aug 2025 15:52:20 -0400 Subject: [PATCH 08/10] chore: linting --- controlplane/src/core/build-server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/controlplane/src/core/build-server.ts b/controlplane/src/core/build-server.ts index d70727e463..0820536ae5 100644 --- a/controlplane/src/core/build-server.ts +++ b/controlplane/src/core/build-server.ts @@ -314,9 +314,9 @@ export default async function build(opts: BuildConfig) { const blobStorage = new S3BlobStorage(s3Client, bucketName, { // GCS does not support DeleteObjects; force individual deletes when detected. useIndividualDeletes: - (isGoogleCloudStorageUrl(opts.s3Storage.url) || isGoogleCloudStorageUrl(s3Config.endpoint as string)) + isGoogleCloudStorageUrl(opts.s3Storage.url) || isGoogleCloudStorageUrl(s3Config.endpoint as string) ? true - : (opts.s3Storage.useIndividualDeletes ?? false), + : opts.s3Storage.useIndividualDeletes ?? false, }); const platformWebhooks = new PlatformWebhookService(opts.webhook?.url, opts.webhook?.key, logger); From 3a578b9f6da14d8938552888b10ce91ced12735c Mon Sep 17 00:00:00 2001 From: StarpTech Date: Thu, 14 Aug 2025 11:12:31 +0200 Subject: [PATCH 09/10] chore: remove timeout because it is handled by sdk --- controlplane/src/core/blobstorage/s3.ts | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/controlplane/src/core/blobstorage/s3.ts b/controlplane/src/core/blobstorage/s3.ts index f706140851..3804b76596 100644 --- a/controlplane/src/core/blobstorage/s3.ts +++ b/controlplane/src/core/blobstorage/s3.ts @@ -9,8 +9,7 @@ import { } from '@aws-sdk/client-s3'; import { BlobNotFoundError, BlobObject, type BlobStorage } from './index.js'; -const maxConcurrency = 10; // Maximum number of concurrent operations (conservative for GCS compatibility) -const batchDelayMs = 200; // Delay between batches in milliseconds (balance between throughput and rate limiting) +const maxConcurrency = 10; // Maximum number of concurrent operations /** * Configuration options for S3BlobStorage @@ -38,15 +37,9 @@ export class S3BlobStorage implements BlobStorage { this.useIndividualDeletes = config.useIndividualDeletes ?? false; } - /** - * Sleep for the specified number of milliseconds - */ - private sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - /** * Execute promises with limited concurrency and delays between batches + * Retries are handled by AWS SDK internally using exponential backoff. Default 3 retries. */ private async executeWithConcurrency(tasks: (() => Promise)[], concurrency: number): Promise { const results: T[] = []; @@ -55,11 +48,6 @@ export class S3BlobStorage implements BlobStorage { const batch = tasks.slice(i, i + concurrency); const batchResults = await Promise.all(batch.map((task) => task())); results.push(...batchResults); - - // Add delay between batches (except for the last batch) - if (i + concurrency < tasks.length) { - await this.sleep(batchDelayMs); - } } return results; @@ -199,7 +187,7 @@ export class S3BlobStorage implements BlobStorage { if (entries.Contents && entries.Contents.length > 0) { if (this.useIndividualDeletes) { - // Use individual deletes for GCS compatibility + // Use individual deletes for S3 implementation without DeleteObjectsCommand totalDeleted += await this.deleteObjectsIndividually(entries.Contents, data.abortSignal); } else { // Use bulk delete for better S3 performance From 73bd80e0ddf2be2c231747a6c655364574233153 Mon Sep 17 00:00:00 2001 From: StarpTech Date: Thu, 14 Aug 2025 11:45:02 +0200 Subject: [PATCH 10/10] chore: rely on reported delete count --- controlplane/src/core/blobstorage/s3.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controlplane/src/core/blobstorage/s3.ts b/controlplane/src/core/blobstorage/s3.ts index 3804b76596..279259db45 100644 --- a/controlplane/src/core/blobstorage/s3.ts +++ b/controlplane/src/core/blobstorage/s3.ts @@ -147,7 +147,7 @@ export class S3BlobStorage implements BlobStorage { throw new Error(`Could not delete files: ${JSON.stringify(deleted.Errors)}`); } - return objectsToDelete.length; + return deleted.Deleted?.length ?? 0; } /**