Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: Migrate S3 Client from AWS SDK v2 to v3 #220

Closed
wants to merge 19 commits into from
Closed
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
logs
*.log
npm-debug.log*
.env

# Runtime data
pids
Expand Down
193 changes: 119 additions & 74 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
//
// Stores Parse files in AWS S3.

const AWS = require('aws-sdk');
const { S3Client, CreateBucketCommand, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const deasync = require('deasync');

mtrezza marked this conversation as resolved.
Show resolved Hide resolved
const optionsFromArguments = require('./lib/optionsFromArguments');

const awsCredentialsDeprecationNotice = function awsCredentialsDeprecationNotice() {
Expand Down Expand Up @@ -36,6 +39,15 @@ function buildDirectAccessUrl(baseUrl, baseUrlFileKey, presignedUrl, config, fil
return directAccessUrl;
}

function responseToBuffer(response) {
return new Promise((resolve, reject) => {
const chunks = [];
response.Body.on('data', (chunk) => chunks.push(chunk));
response.Body.on('end', () => resolve(Buffer.concat(chunks)));
response.Body.on('error', reject);
});
}

class S3Adapter {
// Creates an S3 session.
// Providing AWS access, secret keys and bucket are mandatory
Expand Down Expand Up @@ -65,6 +77,21 @@ class S3Adapter {
globalCacheControl: this._globalCacheControl,
};

// const s3Options = {
// region: this._region,
// // Add other configuration options if needed
// };

if (options.accessKey && options.secretKey) {
awsCredentialsDeprecationNotice();
s3Options.credentials = {
accessKeyId: options.accessKey,
secretAccessKey: options.secretKey,
};
} else if (options.credentials)
s3Options.credentials = options.credentials;


if (options.accessKey && options.secretKey) {
awsCredentialsDeprecationNotice();
s3Options.accessKeyId = options.accessKey;
Expand All @@ -73,29 +100,28 @@ class S3Adapter {

Object.assign(s3Options, options.s3overrides);

this._s3Client = new AWS.S3(s3Options);
this._s3Client = new S3Client(s3Options);
this._hasBucket = false;
}

createBucket() {
let promise;
if (this._hasBucket) {
promise = Promise.resolve();
} else {
promise = new Promise((resolve) => {
this._s3Client.createBucket(() => {
this._hasBucket = true;
resolve();
});
});
async createBucket() {
if (this._hasBucket) return;

try {
await this._s3Client.send(new CreateBucketCommand({ Bucket: this._bucket }));
this._hasBucket = true;
} catch (error) {
if (error.name === 'BucketAlreadyOwnedByYou')
this._hasBucket = true;
else throw error;
}
return promise;
}

// For a given config object, filename, and data, store a file in S3
// Returns a promise containing the S3 object creation response
createFile(filename, data, contentType, options = {}) {
async createFile(filename, data, contentType, options = {}) {
const params = {
Bucket: this._bucket,
Key: this._bucketPrefix + filename,
Body: data,
};
Expand Down Expand Up @@ -128,46 +154,65 @@ class S3Adapter {
const serializedTags = serialize(options.tags);
params.Tagging = serializedTags;
}
return this.createBucket().then(() => new Promise((resolve, reject) => {
this._s3Client.upload(params, (err, response) => {
if (err !== null) {
return reject(err);
}
return resolve(response);
});
}));
await this.createBucket();
const command = new PutObjectCommand(params);
const response = await this._s3Client.send(command);
return response;
}

deleteFile(filename) {
return this.createBucket().then(() => new Promise((resolve, reject) => {
const params = {
Key: this._bucketPrefix + filename,
};
this._s3Client.deleteObject(params, (err, data) => {
if (err !== null) {
return reject(err);
}
return resolve(data);
});
}));
async deleteFile(filename) {
const params = {
Bucket: this._bucket,
Key: this._bucketPrefix + filename,
};
await this.createBucket()
const command = new DeleteObjectCommand(params);
const response = await this._s3Client.send(command)
return response;
}

// Search for and return a file if found by filename
// Returns a promise that succeeds with the buffer result from S3
getFileData(filename) {
const params = { Key: this._bucketPrefix + filename };
return this.createBucket().then(() => new Promise((resolve, reject) => {
this._s3Client.getObject(params, (err, data) => {
if (err !== null) {
return reject(err);
}
// Something happened here...
if (data && !data.Body) {
return reject(data);
}
return resolve(data.Body);
async getFileData(filename) {
const params = {
Bucket: this._bucket,
Key: this._bucketPrefix + filename,
};
await this.createBucket()
const command = new GetObjectCommand(params);
const response = await this._s3Client.send(command);
if (response && !response.Body) throw new Error(response);

const buffer = await responseToBuffer(response);
return buffer;
}

// Exposed only for testing purposes
getSignedUrlSync(client, command, options) {
let isDone = false;
let signedUrl = '';
let error = null;

getSignedUrl(client, command, options)
.then((url) => {
signedUrl = url;
isDone = true;
})
.catch((err) => {
error = err;
isDone = true;
});
}));

// Block the event loop until the promise resolves
while (!isDone) {
deasync.sleep(100); // Sleep for 100 milliseconds
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

100ms seems quite long, what is this value based on?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 100ms value was an estimate to prevent CPU overload from a tight loop, as there's no specific AWS documentation on the exact timing needed for this function. Reducing it to 10ms might be possible. Additionally, I suggest opening an issue on parse-server to make the getFileLocation function async, eliminating the need for the deasync library altogether.

Copy link
Contributor Author

@vahidalizad vahidalizad Aug 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reduced it to 10ms

Copy link
Member

@mtrezza mtrezza Aug 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we estimate the response time for an intra-region call from EC2 to S3 (i.e. Parse Server also running on AWS) to be ~10-100ms, then a 100ms sleep would on avg. double the response time. Maybe setting to 50ms could be a good compromise.

We can only guess a value here, because the best value depends on the actual response times of the specific infrastructure scenario. Using deasync is really more of a hack and we don't know how this responds under heavy concurrent load given that deasync.sleep blocks the event loop. Maybe the correct approach here would be to modify Parse Server first to support sync and async calling of getFileLocation, and then modify the S3 adapter. Would be open to submit a PR to Parse Server? We could add another bounty for that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the feedback! I don't currently have a Parse Server test setup, but I can certainly set one up. It might take some time, but I'm definitely up for the task. I'm happy to work on this without needing the bounty.

Could you please create an issue on the Parse Server repository for this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! 🙏 I'll get started on it. 🚀

}

if (error) {
throw error;
}

return signedUrl;
}

// Generates and returns the location of a file stored in S3 for the given request and filename
Expand All @@ -184,12 +229,11 @@ class S3Adapter {
let presignedUrl = '';
if (this._presignedUrl) {
const params = { Bucket: this._bucket, Key: fileKey };
if (this._presignedUrlExpires) {
params.Expires = this._presignedUrlExpires;
}
// Always use the "getObject" operation, and we recommend that you protect the URL
// appropriately: https://docs.aws.amazon.com/AmazonS3/latest/dev/ShareObjectPreSignedURL.html
presignedUrl = this._s3Client.getSignedUrl('getObject', params);
const options = this._presignedUrlExpires ? { expiresIn: this._presignedUrlExpires } : {};

const command = new GetObjectCommand(params);
presignedUrl = this.getSignedUrlSync(this._s3Client, command, options);

if (!this._baseUrl) {
return presignedUrl;
}
Expand All @@ -203,30 +247,31 @@ class S3Adapter {
return buildDirectAccessUrl(this._baseUrl, baseUrlFileKey, presignedUrl, config, filename);
}

handleFileStream(filename, req, res) {
async handleFileStream(filename, req, res) {
const params = {
Bucket: this._bucket,
Key: this._bucketPrefix + filename,
Range: req.get('Range'),
};
return this.createBucket().then(() => new Promise((resolve, reject) => {
this._s3Client.getObject(params, (error, data) => {
if (error !== null) {
return reject(error);
}
if (data && !data.Body) {
return reject(data);
}
res.writeHead(206, {
'Accept-Ranges': data.AcceptRanges,
'Content-Length': data.ContentLength,
'Content-Range': data.ContentRange,
'Content-Type': data.ContentType,
});
res.write(data.Body);
res.end();
return resolve(data.Body);
});
}));

await this.createBucket();
const command = new GetObjectCommand(params);
const data = await this._s3Client.send(command);
if (data && !data.Body) throw new Error(data);

res.writeHead(206, {
'Accept-Ranges': data.AcceptRanges,
'Content-Length': data.ContentLength,
'Content-Range': data.ContentRange,
'Content-Type': data.ContentType,
});
data.Body.on('data', (chunk) => res.write(chunk));
data.Body.on('end', () => res.end());
data.Body.on('error', (e) => {
res.status(404);
res.send(e.message);
});
return responseToBuffer(data);
}
}

Expand Down
1 change: 1 addition & 0 deletions lib/optionsFromArguments.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const optionsFromArguments = function optionsFromArguments(args) {

if (otherOptions) {
options.bucketPrefix = otherOptions.bucketPrefix;
options.credentials = otherOptions.credentials;
options.directAccess = otherOptions.directAccess;
options.fileAcl = otherOptions.fileAcl;
options.baseUrl = otherOptions.baseUrl;
Expand Down
Loading