Skip to content

Colossus bug fixes for 3.10.0 release - #5016

Merged
mnaamani merged 14 commits into
Joystream:masterfrom
mnaamani:colossus-fix-objectid-cache-bug
Dec 30, 2023
Merged

mnaamani merged 14 commits into
Joystream:masterfrom
mnaamani:colossus-fix-objectid-cache-bug

Conversation

@mnaamani

@mnaamani mnaamani commented Dec 28, 2023 •

Copy link
Copy Markdown
Member
  1. Multiple bug fixes
  2. Refactored and improved pending objects service
  3. Allow re-upload of already "accepted" objects via files api endpoint, in-case they are lost.
  4. Fixed issue in batch extrinsic sender which was not returning correct failed calls.

closes:

@zeeshanakram3 zeeshanakram3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for investigating and fixing the issue. Wouldn't it be much simpler to store data object ID (req.params.id) at the start of the both GET/HEAD handlers, before the try block starts. This way the data object ID in the finally block would still be valid and we won't have to separately do the unpinning at multiple different places. For example, following change fixes the bug

image

Comment on lines +44 to +47
if (!(await getDataObjectIdFromCache(dataObjectId))) {
sendResponseWithError(res, next, new WebApiError('File Not Found', 404), 'files')
return
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wouldn't this be redundant, since now pinDataObjectIdToCache ensures that ID exists before pinning it? If this is added to return a better error message in the response, then we can provide this error in as an argument of asset in pinDataObjectIdToCache function itself. e.g.

  assert(idCache.has(dataObjectId), new WebApiError('File Not Found', 404))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We can also pass the Error as a second optional argument to the pinDataObjectIdToCache function

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Remember assertions don't throw and it is intended to fail the process completely. Meaning the caller needs to make sure they are calling at the right time. So if these new assertions fail it means our code is not correct.

Are you suggesting we should be returning/throwing an error/exception instead of these assertions?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I picked assertions because I was treating the user of the idCache as "our own" code. So its not like a library.
With reference to https://softwareengineering.stackexchange.com/questions/15515/when-to-use-assertions-and-when-to-use-exceptions#15518

Let me know what you think.

@zeeshanakram3 zeeshanakram3 Dec 28, 2023 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Remember assertions don't throw and it is intended to fail the process completely.

Maybe I am misunderstanding, but In javascript, the assert module throws the Error of type AssertionError, and we can actually pass a custom error too, that should be thrown in case assertion fails.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

You are absolutely correct. And given that is true I think I might be introducing more problems.
Let me think this through and come back to it.

@mnaamani

Copy link
Copy Markdown
Member Author

Thanks for investigating and fixing the issue. Wouldn't it be much simpler to store data object ID (req.params.id) at the start of the both GET/HEAD handlers, before the try block starts. This way the data object ID in the finally block would still be valid and we won't have to separately do the unpinning at multiple different places.

Yes that was one of the reasons I moved the data object id instantiation outside of the try block, but then realized that I had to get rid of the finally clause because it can be reached before the stream ends. So we would be unpinning too early, defeating the whole point.

It is easy to miss such cases when mixing async/await code with traditional node event and steam based paradigm in the same function.

Comment thread storage-node/src/services/caching/localDataObjects.ts Outdated
@mnaamani
mnaamani marked this pull request as draft December 29, 2023 10:14
@mnaamani
mnaamani force-pushed the colossus-fix-objectid-cache-bug branch from 987aa0d to 1983731 Compare December 29, 2023 20:36
@mnaamani
mnaamani marked this pull request as ready for review December 29, 2023 21:52
Comment thread storage-node/src/services/webApi/app.ts Outdated
Comment on lines +264 to +266
// Skipping this check to allow re-upload of "lost" objects
if (dataObject.accepted.valueOf()) {
throw new WebApiError(`Data object ${dataObjectId} has already been accepted by storage node`, 400)
// throw new WebApiError(`Data object ${dataObjectId} has already been accepted by storage node`, 400)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please remove this commented code.

Comment on lines +51 to +52
// assert(!idCache.has(dataObjectId))
// existence check to avoid resetting pin count

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Isn't it better to have this assertion?

Comment thread storage-node/src/commands/server.ts Outdated
if (!flags.pendingFolder) {
logger.warn(
'You did not specify a path to the pending directory. ' +
'A pending folder under the uploads folder willl be used. ' +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
'A pending folder under the uploads folder willl be used. ' +
'A pending folder under the uploads folder will be used. ' +

Comment on lines +58 to +61
pendingFolder: flags.string({
description:
'Directory to store pending files which are uploaded upload (absolute path).\nIf not specified a subfolder under the uploads directory will be used.',
}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think making the temp folder configurable does not create any risks, but in the case of the pending folder what would happen if the operator specifies a different pendingFolder in different node runs? Would this lead to considering the objects in the previous pending folder as effectively lost (unless they were re-uploaded)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correct there is a risk of that happening. The operator can after start node with a new path for pending folder, simply copy the files to the new location and they will be picked up. But I think it is a necessary step towards making the uploads folder separate in preparation for external object storage back-end.

Comment thread storage-node/src/services/webApi/app.ts Outdated
Comment on lines +78 to +86
(req: express.Request, res: express.Response<unknown, AppConfig>, next: NextFunction) => {
if (req.path === '/api/v1/files' && (req.method === 'GET' || req.method === 'HEAD')) {
validateDataObjectId(req, res)
.then(next)
.catch((error) => sendResponseWithError(res, next, error, 'upload'))
} else {
next()
}
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This middleware handler would never be invoked since req.path would always be /api/v1/files/{id}, i.e. it would include the data object ID as part of the path.

Also, it's it better to do this validation at the openApi schema level (e.g. example)?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch. Yeah I was surprised actually that the schema was for a string. Maybe it could be of type number rather than regex pattern on a string? Was a string perhaps chosen to support data object ids larger than javascript max number ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Even that regex expression could allow numbers too large for BN to fail.
With this custom function we could also do checks like if the id outside the range of valid dataobject ids?


export function deleteDataObjectIdFromCache(dataObjectId: string): void {
assert(typeof dataObjectId === 'string')
// assert(idCache.has(dataObjectId))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Isn't it better to have this assertion?

sendResponseWithError(res, next, err, 'files')
} finally {
await unpinDataObjectIdFromCache(req.params.id)
// we assume that if stream.pipe throws, then stream.on('end') will never fire

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think the comment should not be here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The reason for the comment at both lines where we are invoking the unpin call is to explain that it is not a mistake (based on the assumption), and we should never actually get the result where unpin is invoked twice.
I suppose I could have a locally scoped function that will only even call unpinDataObjectIdFromCache() once and log a warning if it is every invoked multiple times as a signal that this assumption is false without causing a possible corrupt state or unpin throwing the assertion error.

@mnaamani mnaamani Dec 30, 2023 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

About this whole approach of pinning to prevent the cleanup service deleting a file being served, I may have overlooked that on linux file systems at least, it may not be necessary at all. I should have considered that when reviewing first PR that introduced the clean up service.

I do think we can safely get rid of this.

On top of that, I can't find the comment in the PR but I do remember at one point we were serving content from pending folder if objects was not yet moved to uploads, and I suggested that it was a bad idea as we didn't know what would happen if files was being moved while being served.

I think we should re-introduce ability to serve from pending folder. It will become more important when we move to object storage, if there is a delay in transferring the pending file from local file system to object storage (compared with what we do currently which is just renaming on the save disk volume).

What do you think?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

On top of that, I can't find the comment in the PR but I do remember at one point we were serving content from pending folder if objects was not yet moved to uploads, and I suggested that it was a bad idea as we didn't know what would happen if files was being being while being served.

This is the PR where you suggested and mage the change #4989

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Made unpinning less error prone and guaranteed to only happen once after pinning in getFile handler.
I read that end event on stream may/or may not fire if error event is fired, so there are potentially 3 places where we would need to unpin..

Done in d5ec0ec

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Made unpinning less error prone and guaranteed to only happen once after pinning in getFile handler.

Yeah, this is a good change actually.

Comment on lines +88 to +90
})().catch((err) => {
logger.error(`Accept pending objects service died: ${err}`)
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
})().catch((err) => {
logger.error(`Accept pending objects service died: ${err}`)
})
})()

Since the async function is in try...catch, I don't think any promise rejections will ever be caught in .catch function. WDYT?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

You are correct, I think I was forced to add a catch handler because typescript complained. I'll try to use the previous code style to make this whole function cleaner. The reason I ended up with this was this was the point where I was debugging the issue which ended up being the invalid spread operator on the extrinsic result.

logger.debug(
`Data object ${dataObject.id} in pending directory is no longer assigned to any of the upload buckets: ${this.uploadBuckets}.`
)
await fsPromises.unlink(path.join(this.pendingDataObjectsDir, dataObject.id))

@zeeshanakram3 zeeshanakram3 Dec 30, 2023 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we discussed that doing this is risky, and should probably by handled by pruning service (#4971 (comment))

So better to move such objects to the uploads folder, and pruning service can handle from there?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Ah yes now I remember. That is a good idea actually.

await Promise.allSettled(
pendingDataObjects.map(async (dataObject) => {
const storageBucket = dataObject.storageBag.storageBuckets.find(({ id }) => this.uploadBuckets.includes(id))
// verify the ipfshash of the object matches against runtime!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@zeeshanakram3 what are your thoughts about doing this additional check here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This check is already being done after the file upload completes and before it is moved to the pending dir. So I think doing that would be redundant?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It is of course redundant for objects that just came in through the http api, but not for files that might have been copied by operator directly. I'm thinking of scenario where operator maybe recovering their setup, moving files from other location after chaing pending folder path. Maybe somehow recovering "lost" objects from a backup/archive and placing them into the pending folder for processing..

@zeeshanakram3 zeeshanakram3 Dec 30, 2023 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yeah, in those cases it makes sense to have this validation. So maybe just add a comment, briefly explaining why are we doing this? In which scenarios it would be beneficial? As you described above

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

done in 0cdd273

Comment thread storage-node/src/services/webApi/controllers/filesApi.ts Outdated
Comment thread storage-node/src/services/webApi/app.ts Outdated
Comment on lines +78 to +86
(req: express.Request, res: express.Response<unknown, AppConfig>, next: NextFunction) => {
if (req.path.startsWith('/api/v1/files/') && (req.method === 'GET' || req.method === 'HEAD')) {
validateDataObjectId(req, res)
.then(next)
.catch((error) => sendResponseWithError(res, next, error, 'upload'))
} else {
next()
}
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actually, this still won't work correctly as req.params inside validateDataObjectId is empty object ({}). This is because the express does not know about the structure of the URL yet (i.e. whether 1 in /api/v1/files/1 is parameter or part of the URL itself), and it will only know about the params after the next middleware (OpenApiValidator.middleware) is executed.

So unfortunately I think there is no way then doing this validation at openapi schema level

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Ah thanks for checking this.
So I'll drop it, and update the handlers to not assume the id has been validated fully, and at least update the schema validation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in 0cdd273

@zeeshanakram3 zeeshanakram3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM.

@mnaamani mnaamani changed the title Colossus fix objectid cache bug Colossus bug fixes for 3.10.0 release Dec 30, 2023
@mnaamani
mnaamani merged commit 132bcfb into Joystream:master Dec 30, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants