Skip to content

Commit 43abc45

Browse files
thiyaguk09Dhriti07
andauthored
feat(storage): add deleteSourceObjects option to combine/compose method (#8444)
* feat(storage): add deleteSourceObjects option to bucket.combine and introduce ComposeCleanupError for failed source deletions * feat(storage): support userProject in bucket.combine cleanup and update delete options interface * fix(storage): update bucket combine method to respect file generation and ignore missing sources during deletion * fix: handle source generation correctly and refactor compose cleanup to use synchronous callbacks --------- Co-authored-by: Dhriti07 <56169283+Dhriti07@users.noreply.github.com>
1 parent 512ba79 commit 43abc45

4 files changed

Lines changed: 225 additions & 15 deletions

File tree

handwritten/storage/src/bucket.ts

Lines changed: 81 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import {CRC32CValidatorGenerator} from './crc32c.js';
6767
import {URL} from 'url';
6868
import {
6969
BaseMetadata,
70+
DeleteOptions,
7071
SetMetadataOptions,
7172
} from './nodejs-common/service-object.js';
7273

@@ -190,6 +191,7 @@ export interface CombineOptions extends PreconditionOptions {
190191
[key: string]: ContextValue;
191192
} | null;
192193
};
194+
deleteSourceObjects?: boolean;
193195
}
194196

195197
export interface CombineCallback {
@@ -198,6 +200,24 @@ export interface CombineCallback {
198200

199201
export type CombineResponse = [File, unknown];
200202

203+
export class ComposeCleanupError extends Error {
204+
errors: Error[];
205+
newFile: File;
206+
apiResponse: unknown;
207+
constructor(
208+
message: string,
209+
errors: Error[],
210+
newFile: File,
211+
apiResponse: unknown
212+
) {
213+
super(message);
214+
this.name = 'ComposeCleanupError';
215+
this.errors = errors;
216+
this.newFile = newFile;
217+
this.apiResponse = apiResponse;
218+
}
219+
}
220+
201221
export interface CreateChannelConfig extends WatchAllOptions {
202222
address: string;
203223
}
@@ -1579,7 +1599,9 @@ class Bucket extends ServiceObject<Bucket, BucketMetadata> {
15791599
* metadata's `kms_key_name` value, if any.
15801600
* @property {string} [userProject] The ID of the project which will be
15811601
* billed for the request.
1582-
*/
1602+
* @property {boolean} [deleteSourceObjects] If true, the source objects
1603+
* will be permanently deleted after a successful compose operation.
1604+
*/
15831605
/**
15841606
* @callback CombineCallback
15851607
* @param {?Error} err Request error, if any.
@@ -1612,7 +1634,8 @@ class Bucket extends ServiceObject<Bucket, BucketMetadata> {
16121634
* metadata's `kms_key_name` value, if any.
16131635
* @param {string} [options.userProject] The ID of the project which will be
16141636
* billed for the request.
1615-
1637+
* @param {boolean} [options.deleteSourceObjects] If true, the source objects
1638+
* will be permanently deleted after a successful compose operation.
16161639
* @param {CombineCallback} [callback] Callback function.
16171640
* @returns {Promise<CombineResponse>}
16181641
*
@@ -1709,8 +1732,17 @@ class Bucket extends ServiceObject<Bucket, BucketMetadata> {
17091732
maxRetries = 0;
17101733
}
17111734

1712-
if (options.ifGenerationMatch === undefined) {
1713-
Object.assign(options, destinationFile.instancePreconditionOpts, options);
1735+
const deleteSourceObjects = options.deleteSourceObjects;
1736+
1737+
const requestQueryObject = Object.assign({}, options);
1738+
delete requestQueryObject.deleteSourceObjects;
1739+
1740+
if (requestQueryObject.ifGenerationMatch === undefined) {
1741+
Object.assign(
1742+
requestQueryObject,
1743+
destinationFile.instancePreconditionOpts,
1744+
requestQueryObject
1745+
);
17141746
}
17151747

17161748
// Make the request from the destination File object.
@@ -1723,23 +1755,23 @@ class Bucket extends ServiceObject<Bucket, BucketMetadata> {
17231755
destination: {
17241756
contentType: destinationFile.metadata.contentType,
17251757
contentEncoding: destinationFile.metadata.contentEncoding,
1726-
contexts: options.contexts || destinationFile.metadata.contexts,
1758+
contexts:
1759+
requestQueryObject.contexts || destinationFile.metadata.contexts,
17271760
},
17281761
sourceObjects: (sources as File[]).map(source => {
17291762
const sourceObject = {
17301763
name: source.name,
17311764
} as SourceObject;
17321765

1733-
if (source.metadata && source.metadata.generation) {
1734-
sourceObject.generation = parseInt(
1735-
source.metadata.generation.toString(),
1736-
);
1766+
const generation = source.generation ?? source.metadata?.generation;
1767+
if (generation !== undefined) {
1768+
sourceObject.generation = parseInt(generation.toString());
17371769
}
17381770

17391771
return sourceObject;
17401772
}),
17411773
},
1742-
qs: options,
1774+
qs: requestQueryObject,
17431775
},
17441776
(err, resp) => {
17451777
this.storage.retryOptions.autoRetry = this.instanceRetryValue;
@@ -1748,8 +1780,45 @@ class Bucket extends ServiceObject<Bucket, BucketMetadata> {
17481780
return;
17491781
}
17501782

1751-
callback!(null, destinationFile, resp);
1752-
},
1783+
if (deleteSourceObjects) {
1784+
const deletePromises = (sources as File[]).map(source => {
1785+
const deleteOptions: DeleteOptions = {
1786+
ignoreNotFound: true,
1787+
userProject: options.userProject,
1788+
};
1789+
1790+
const generation = source.generation ?? source.metadata?.generation;
1791+
if (generation !== undefined) {
1792+
deleteOptions.ifGenerationMatch = generation;
1793+
}
1794+
1795+
return source
1796+
.delete(deleteOptions)
1797+
.catch(deleteErr => deleteErr as Error);
1798+
});
1799+
1800+
Promise.all(deletePromises).then(results => {
1801+
const errors = results.filter(
1802+
(res): res is Error => res instanceof Error
1803+
);
1804+
1805+
if (errors.length > 0) {
1806+
const cleanupErr = new ComposeCleanupError(
1807+
`Compose operation succeeded, but cleaning up source objects failed. Failed to delete ${errors.length} source object(s).`,
1808+
errors,
1809+
destinationFile,
1810+
resp
1811+
);
1812+
callback!(cleanupErr, destinationFile, resp);
1813+
return;
1814+
}
1815+
1816+
callback!(null, destinationFile, resp);
1817+
});
1818+
} else {
1819+
callback!(null, destinationFile, resp);
1820+
}
1821+
}
17531822
);
17541823
}
17551824

handwritten/storage/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export {
111111
CombineCallback,
112112
CombineOptions,
113113
CombineResponse,
114+
ComposeCleanupError,
114115
CreateChannelCallback,
115116
CreateChannelConfig,
116117
CreateChannelOptions,

handwritten/storage/src/nodejs-common/service-object.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export interface CreateCallback<T> {
111111

112112
export type DeleteOptions = {
113113
ignoreNotFound?: boolean;
114+
userProject?: string;
114115
ifGenerationMatch?: number | string;
115116
ifGenerationNotMatch?: number | string;
116117
ifMetagenerationMatch?: number | string;

handwritten/storage/test/bucket.ts

Lines changed: 142 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ describe('Bucket', () => {
191191
let Bucket: any;
192192
// eslint-disable-next-line @typescript-eslint/no-explicit-any
193193
let bucket: any;
194+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
195+
let ComposeCleanupError: any;
194196

195197
const STORAGE = {
196198
createBucket: util.noop,
@@ -211,7 +213,7 @@ describe('Bucket', () => {
211213
const BUCKET_NAME = 'test-bucket';
212214

213215
before(() => {
214-
Bucket = proxyquire('../src/bucket.js', {
216+
const bucketModule = proxyquire('../src/bucket.js', {
215217
fs: fakeFs,
216218
'p-limit': fakePLimit,
217219
'@google-cloud/promisify': fakePromisify,
@@ -225,7 +227,9 @@ describe('Bucket', () => {
225227
'./iam.js': {Iam: FakeIam},
226228
'./notification.js': {Notification: FakeNotification},
227229
'./signer.js': fakeSigner,
228-
}).Bucket;
230+
});
231+
Bucket = bucketModule.Bucket;
232+
ComposeCleanupError = bucketModule.ComposeCleanupError;
229233
});
230234

231235
beforeEach(() => {
@@ -806,7 +810,7 @@ describe('Bucket', () => {
806810
const destination = bucket.file('destination.txt');
807811

808812
destination.request = (reqOpts: DecorateRequestOptions) => {
809-
assert.strictEqual(reqOpts.qs, options);
813+
assert.deepStrictEqual(reqOpts.qs, options);
810814
done();
811815
};
812816

@@ -916,6 +920,141 @@ describe('Bucket', () => {
916920

917921
bucket.combine(sources, destination, done);
918922
});
923+
924+
it('should delete source objects if deleteSourceObjects is true', done => {
925+
const sources = [bucket.file('1.foo'), bucket.file('2.foo')];
926+
const destination = bucket.file('destination.foo');
927+
928+
// Set generation on the first file and leave second file without generation
929+
sources[0].generation = 12345;
930+
931+
let deletedCount = 0;
932+
sources[0].delete = async (opts?: any) => {
933+
assert.strictEqual(opts?.userProject, 'user-project-id');
934+
assert.strictEqual(opts?.ignoreNotFound, true);
935+
assert.strictEqual(opts?.ifGenerationMatch, 12345);
936+
deletedCount++;
937+
return [{}];
938+
};
939+
sources[1].delete = async (opts?: any) => {
940+
assert.strictEqual(opts?.userProject, 'user-project-id');
941+
assert.strictEqual(opts?.ignoreNotFound, true);
942+
assert.strictEqual(opts?.ifGenerationMatch, undefined);
943+
deletedCount++;
944+
return [{}];
945+
};
946+
947+
destination.request = (reqOpts: DecorateRequestOptions, callback: Function) => {
948+
assert.strictEqual(reqOpts.qs.deleteSourceObjects, undefined);
949+
assert.strictEqual(reqOpts.json.deleteSourceObjects, undefined);
950+
assert.strictEqual(reqOpts.json.sourceObjects[0].generation, 12345);
951+
callback(null, {});
952+
};
953+
954+
bucket.combine(
955+
sources,
956+
destination,
957+
{deleteSourceObjects: true, userProject: 'user-project-id'},
958+
(err: any) => {
959+
assert.ifError(err);
960+
assert.strictEqual(deletedCount, 2);
961+
done();
962+
}
963+
);
964+
});
965+
966+
it('should not delete source objects if deleteSourceObjects is false/omitted', done => {
967+
const sources = [bucket.file('1.foo'), bucket.file('2.foo')];
968+
const destination = bucket.file('destination.foo');
969+
970+
let deletedCount = 0;
971+
sources.forEach(source => {
972+
source.delete = async () => {
973+
deletedCount++;
974+
return [{}];
975+
};
976+
});
977+
978+
destination.request = (reqOpts: DecorateRequestOptions, callback: Function) => {
979+
assert.strictEqual(reqOpts.json.deleteSourceObjects, undefined);
980+
callback(null, {});
981+
};
982+
983+
bucket.combine(sources, destination, (err: any) => {
984+
assert.ifError(err);
985+
assert.strictEqual(deletedCount, 0);
986+
done();
987+
});
988+
});
989+
990+
it('should not delete source objects if compose operation fails', done => {
991+
const sources = [bucket.file('1.foo'), bucket.file('2.foo')];
992+
const destination = bucket.file('destination.foo');
993+
const composeError = new Error('Compose failed.');
994+
995+
let deletedCount = 0;
996+
sources.forEach(source => {
997+
source.delete = async () => {
998+
deletedCount++;
999+
return [{}];
1000+
};
1001+
});
1002+
1003+
destination.request = (reqOpts: DecorateRequestOptions, callback: Function) => {
1004+
assert.strictEqual(reqOpts.json.deleteSourceObjects, undefined);
1005+
callback(composeError);
1006+
};
1007+
1008+
bucket.combine(sources, destination, {deleteSourceObjects: true}, (err: any) => {
1009+
assert.strictEqual(err, composeError);
1010+
assert.strictEqual(deletedCount, 0);
1011+
done();
1012+
});
1013+
});
1014+
1015+
it('should return ComposeCleanupError if deleting source objects fails', done => {
1016+
const sources = [bucket.file('1.foo'), bucket.file('2.foo')];
1017+
const destination = bucket.file('destination.foo');
1018+
const deleteError = new Error('Delete failed.');
1019+
1020+
sources[0].delete = async (opts?: any) => {
1021+
assert.strictEqual(opts?.userProject, 'user-project-id');
1022+
assert.strictEqual(opts?.ignoreNotFound, true);
1023+
throw deleteError;
1024+
};
1025+
sources[1].delete = async (opts?: any) => {
1026+
assert.strictEqual(opts?.userProject, 'user-project-id');
1027+
assert.strictEqual(opts?.ignoreNotFound, true);
1028+
return [{}];
1029+
};
1030+
1031+
destination.request = (reqOpts: DecorateRequestOptions, callback: Function) => {
1032+
assert.strictEqual(reqOpts.json.deleteSourceObjects, undefined);
1033+
callback(null, {success: true});
1034+
};
1035+
1036+
bucket.combine(
1037+
sources,
1038+
destination,
1039+
{deleteSourceObjects: true, userProject: 'user-project-id'},
1040+
(err: any, newFile: any, apiResponse: any) => {
1041+
try {
1042+
assert.ok(err instanceof ComposeCleanupError);
1043+
assert.strictEqual(err.name, 'ComposeCleanupError');
1044+
assert.deepStrictEqual((err as any).errors, [deleteError]);
1045+
assert.strictEqual((err as any).newFile, destination);
1046+
assert.deepStrictEqual((err as any).apiResponse, {success: true});
1047+
1048+
// Also check callback arguments
1049+
assert.strictEqual(newFile, destination);
1050+
assert.deepStrictEqual(apiResponse, {success: true});
1051+
done();
1052+
} catch (assertErr) {
1053+
done(assertErr);
1054+
}
1055+
}
1056+
);
1057+
});
9191058
});
9201059

9211060
describe('createChannel', () => {

0 commit comments

Comments
 (0)