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

feat: Handle notebook conflicts #16352

Merged
merged 3 commits into from
Jul 5, 2023
Merged

feat: Handle notebook conflicts #16352

merged 3 commits into from
Jul 5, 2023

Conversation

daibhin
Copy link
Contributor

@daibhin daibhin commented Jul 3, 2023

Problem

Towards #15680

Notebooks do not support multiplayer editing so instead we are going to render a conflict error if the content has been updated elsewhere since the last save

Changes

  • Return a custom 409 error
  • Show a warning and allow the notebook to be reloaded from the server

conflict

How did you test this code?

Manually (gif included)

@@ -79,7 +79,7 @@ export function initKea({ routerHistory, routerLocation, beforePlugins }: InitKe
if (
!ERROR_FILTER_WHITELIST.includes(actionKey) &&
(error?.message === 'Failed to fetch' || // Likely CORS headers errors (i.e. request failing without reaching Django)
(error?.status !== undefined && ![200, 201, 204].includes(error.status)))
(error?.status !== undefined && ![200, 201, 204, 409].includes(error.status)))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We seem to show the toast on every request. 409 doesn't seem to be used anywhere else and should be handled gracefully so thought it worth adding as a new special case here

Copy link
Contributor

Choose a reason for hiding this comment

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

Hmmm I don't know that this wouldn't cause issues elsewhere...

A more localized way would be to simply catch the error from the API call in the logic and check for the error status.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That would mean the little toast appears in the bottom right hand corner which seemed uncessary given the error is handled in the UI
Screenshot 2023-07-03 at 15 09 03

The other option would be to add the saveNotebook action to the ERROR_FILTER_WHITELIST array. That has the effect of not showing the toast for any failed response from the server for that action which felt less good e.g. an actual 500 would not show anything in the UI despite having failed to save

/*
Actions for which we don't want to show error alerts,
mostly to avoid user confusion.
*/
const ERROR_FILTER_WHITELIST = [

What we really want is a combination of the saveNotebook and 409 response. It doesn't look like there is any way to inject conditions given it is a global listener 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually...

Maybe I can implement something within the logic. It seems to get passed as an argument to the onFailure callback

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Please ignore... covered in #16352 (comment)

@daibhin daibhin force-pushed the dn-feat/notebook-conflicts branch from c9d0506 to 9055be3 Compare July 3, 2023 13:12
@@ -82,10 +89,7 @@ export const notebookLogic = kea<notebookLogicType>([
throw new Error('Notebook not found')
}

if (!values.notebook) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We only load notebooks when the component mounts so I thought it was ok to remove this check as it was preventing the refreshed content from being populated into the editor

instance.last_modified_by = self.context["request"].user
with transaction.atomic():
# Lock the database row so we ensure version updates are atomic
locked_instance = Notebook.objects.select_for_update().get(pk=instance.pk)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not super familiar with Django but it seemed necessary to me that the instance needed to be loaded with select_for_update to lock the table row as part of the transaction. Is there a cleaner way to override the instance lookup within the serializer so maybe I don't need to load it a second time?

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't know is my answer here :D
A quick google showed me that you can mark an entire view as atomic with a decorator . No idea if that would work here but worth a try? That way the entire call is in a transaction?

Not sure how that plays into what you say about needing to use select_for_update though...

Copy link
Contributor

Choose a reason for hiding this comment

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

Checked it out and the decorator didn't break anything but I also couldn't see an easy way of testing and handling the error....

Copy link
Member

Choose a reason for hiding this comment

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

I like that Notebook.objects.select_for_update() is super explicit though. makes me happy we're not having to rely on some dark magic

@daibhin daibhin requested a review from a team July 3, 2023 13:15
@daibhin daibhin changed the title Handle notebook conflicts [https://github.com/PostHog/posthog/issues/15680] feat: Handle notebook conflicts Jul 3, 2023
@@ -79,7 +79,7 @@ export function initKea({ routerHistory, routerLocation, beforePlugins }: InitKe
if (
!ERROR_FILTER_WHITELIST.includes(actionKey) &&
(error?.message === 'Failed to fetch' || // Likely CORS headers errors (i.e. request failing without reaching Django)
(error?.status !== undefined && ![200, 201, 204].includes(error.status)))
(error?.status !== undefined && ![200, 201, 204, 409].includes(error.status)))
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmmm I don't know that this wouldn't cause issues elsewhere...

A more localized way would be to simply catch the error from the API call in the logic and check for the error status.

Comment on lines 233 to 237
saveNotebookFailure: ({ errorObject }) => {
if (errorObject.code === 'conflict') {
actions.showConflictWarning()
}
},
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should instead do this in the saveNotebook call. Partly because there we can better control the error state, as well as reset the notebook to a null value or something which would stop it from flashing the old content.

Locally this loads really fast but in prod it will definitely be slower so the old version will likely still be visible for a second or so which might be confusing...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ahh gotcha. Please ignore my comment above. This seems like a better approach

instance.last_modified_by = self.context["request"].user
with transaction.atomic():
# Lock the database row so we ensure version updates are atomic
locked_instance = Notebook.objects.select_for_update().get(pk=instance.pk)
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't know is my answer here :D
A quick google showed me that you can mark an entire view as atomic with a decorator . No idea if that would work here but worth a try? That way the entire call is in a transaction?

Not sure how that plays into what you say about needing to use select_for_update though...

@@ -33,6 +33,11 @@ def __init__(self, feature: Optional[str] = None) -> None:
)


class Conflict(APIException):
Copy link
Contributor

Choose a reason for hiding this comment

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

Crazy that there is no built in for this in Django 😅

Copy link
Member

Choose a reason for hiding this comment

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

May be because we're a major version behind 🙈

@posthog-bot
Copy link
Contributor

📸 UI snapshots have been updated

1 snapshot changes in total. 0 added, 1 modified, 0 deleted:

  • chromium: 0 added, 1 modified, 0 deleted (diff for shard 1)
  • webkit: 0 added, 0 modified, 0 deleted

Triggered by this commit.

👉 Review this PR's diff of snapshots.

@daibhin daibhin force-pushed the dn-feat/notebook-conflicts branch from f6674e2 to d55888a Compare July 3, 2023 16:52
@posthog-bot
Copy link
Contributor

📸 UI snapshots have been updated

1 snapshot changes in total. 0 added, 1 modified, 0 deleted:

  • chromium: 0 added, 1 modified, 0 deleted (diff for shard 1)
  • webkit: 0 added, 0 modified, 0 deleted

Triggered by this commit.

👉 Review this PR's diff of snapshots.

Comment on lines +92 to +107
SELECT "posthog_notebook"."id",
"posthog_notebook"."short_id",
"posthog_notebook"."team_id",
"posthog_notebook"."title",
"posthog_notebook"."content",
"posthog_notebook"."deleted",
"posthog_notebook"."version",
"posthog_notebook"."created_at",
"posthog_notebook"."created_by_id",
"posthog_notebook"."last_modified_at",
"posthog_notebook"."last_modified_by_id"
FROM "posthog_notebook"
WHERE "posthog_notebook"."id" = '01891c84-73f6-0000-8ea4-5d223f446585'::uuid
LIMIT 21
FOR
UPDATE /*controller='project_notebooks-detail',route='api/projects/%28%3FP%3Cparent_lookup_team_id%3E%5B%5E/.%5D%2B%29/notebooks/%28%3FP%3Cshort_id%3E%5B%5E/.%5D%2B%29/%3F%24'*/
Copy link
Member

Choose a reason for hiding this comment

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

cool the snapshot captured the fir update, yay

So, if someone breaks it in future, this will highlight it to them

Copy link
Member

@pauldambra pauldambra left a comment

Choose a reason for hiding this comment

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

looks good to me, tried locally and it does what it says on the tin

Would be cool to move past this and on to conflict resolution but that's for another day 🙈

@daibhin daibhin force-pushed the dn-feat/notebook-conflicts branch from ecd8c2d to 149bfdb Compare July 4, 2023 15:22
@daibhin daibhin merged commit fcf09b5 into master Jul 5, 2023
@daibhin daibhin deleted the dn-feat/notebook-conflicts branch July 5, 2023 08:15
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.

4 participants