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
Show file tree
Hide file tree
Changes from all 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
9 changes: 6 additions & 3 deletions frontend/src/scenes/notebooks/Notebook/Notebook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import posthog from 'posthog-js'
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
import { SCRATCHPAD_NOTEBOOK } from './notebooksListLogic'
import { FloatingSlashCommands, SlashCommandsExtension } from './SlashCommands'
import { NotebookConflictWarning } from './NotebookConflictWarning'

export type NotebookProps = {
shortId: string
Expand All @@ -37,7 +38,7 @@ const PLACEHOLDER_TITLES = ['Release notes', 'Product roadmap', 'Meeting notes',

export function Notebook({ shortId, editable = false }: NotebookProps): JSX.Element {
const logic = notebookLogic({ shortId })
const { notebook, content, notebookLoading, isEmpty } = useValues(logic)
const { notebook, content, notebookLoading, isEmpty, conflictWarningVisible } = useValues(logic)
const { setEditorRef, onEditorUpdate, duplicateNotebook, loadNotebook } = useActions(logic)
const { isExpanded } = useValues(notebookSettingsLogic)

Expand Down Expand Up @@ -169,15 +170,17 @@ export function Notebook({ shortId, editable = false }: NotebookProps): JSX.Elem
<BindLogic logic={notebookLogic} props={{ shortId }}>
<div className={clsx('Notebook', !isExpanded && 'Notebook--compact')}>
<FloatingSlashCommands />
{!notebook && notebookLoading ? (
{conflictWarningVisible ? (
<NotebookConflictWarning />
) : !notebook && notebookLoading ? (
<div className="space-y-4 px-8 py-4">
<LemonSkeleton className="w-1/2 h-8" />
<LemonSkeleton className="w-1/3 h-4" />
<LemonSkeleton className="h-4" />
<LemonSkeleton className="h-4" />
</div>
) : !notebook ? (
<NotFound object={'notebook'} />
<NotFound object="notebook" />
) : isEmpty && !editable ? (
<div className="NotebookEditor">
<h1>
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/scenes/notebooks/Notebook/NotebookConflictWarning.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useActions } from 'kea'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { notebookLogic } from './notebookLogic'

export function NotebookConflictWarning(): JSX.Element {
const { loadNotebook } = useActions(notebookLogic)

return (
<div className="flex flex-col items-center text-muted-alt m-10">
<h2 className="text-muted-alt">This Notebook has been edited elsewhere</h2>

<p>
It looks like someone else has been editing this content too. You will need to reload the notebook to
see the latest version.
</p>

<LemonButton type="primary" onClick={loadNotebook}>
Reload to see the latest content
</LemonButton>
</div>
)
}
49 changes: 33 additions & 16 deletions frontend/src/scenes/notebooks/Notebook/notebookLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const notebookLogic = kea<notebookLogicType>([
loadNotebook: true,
saveNotebook: (notebook: Pick<NotebookType, 'content' | 'title'>) => ({ notebook }),
exportJSON: true,
showConflictWarning: true,
}),
reducers({
localContent: [
Expand All @@ -50,21 +51,27 @@ export const notebookLogic = kea<notebookLogicType>([
setEditorRef: (_, { editor }) => editor,
},
],

ready: [
false,
{
setReady: () => true,
},
],
conflictWarningVisible: [
false,
{
showConflictWarning: () => true,
loadNotebookSuccess: () => false,
},
],
}),
loaders(({ values, props, actions }) => ({
notebook: [
undefined as NotebookType | undefined,
null as NotebookType | null,
{
loadNotebook: async () => {
// NOTE: This is all hacky and temporary until we have a backend
let response: NotebookType | undefined
let response: NotebookType | null

if (props.shortId === SCRATCHPAD_NOTEBOOK.short_id) {
response = {
Expand All @@ -73,7 +80,8 @@ export const notebookLogic = kea<notebookLogicType>([
version: 0,
}
} else if (props.shortId.startsWith('template-')) {
response = values.notebookTemplates.find((template) => template.short_id === props.shortId)
response =
values.notebookTemplates.find((template) => template.short_id === props.shortId) || null
} else {
response = await api.notebooks.get(props.shortId)
}
Expand All @@ -95,28 +103,37 @@ export const notebookLogic = kea<notebookLogicType>([
return values.notebook
}

const response = await api.notebooks.update(values.notebook.short_id, {
version: values.notebook.version,
content: notebook.content,
title: notebook.title,
})
try {
const response = await api.notebooks.update(values.notebook.short_id, {
version: values.notebook.version,
content: notebook.content,
title: notebook.title,
})

// If the object is identical then no edits were made, so we can safely clear the local changes
if (notebook.content === values.localContent) {
actions.clearLocalContent()
}
// If the object is identical then no edits were made, so we can safely clear the local changes
if (notebook.content === values.localContent) {
actions.clearLocalContent()
}

return response
return response
} catch (error: any) {
if (error.code === 'conflict') {
actions.showConflictWarning()
return null
} else {
throw error
}
}
},
},
],

newNotebook: [
undefined as NotebookType | undefined,
null as NotebookType | null,
{
duplicateNotebook: async () => {
if (!values.notebook) {
return
return null
}

// We use the local content if set otherwise the notebook content. That way it supports templates, scratchpad etc.
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/scenes/notebooks/NotebookScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ export const scene: SceneExport = {
export function NotebookScene(): JSX.Element {
const { notebookId, mode } = useValues(notebookSceneLogic)
const { setNotebookMode } = useActions(notebookSceneLogic)
const { notebook, notebookLoading } = useValues(notebookLogic({ shortId: notebookId }))
const { notebook, notebookLoading, conflictWarningVisible } = useValues(notebookLogic({ shortId: notebookId }))
const { exportJSON } = useActions(notebookLogic({ shortId: notebookId }))
const { selectNotebook, setNotebookSideBarShown } = useActions(notebookSidebarLogic)
const { selectedNotebook, notebookSideBarShown } = useValues(notebookSidebarLogic)

if (!notebook && !notebookLoading) {
if (!notebook && !notebookLoading && !conflictWarningVisible) {
return <NotFound object="notebook" />
}

Expand Down
26 changes: 15 additions & 11 deletions posthog/api/notebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import structlog
from django.db.models import QuerySet
from django.db import transaction
from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import request, serializers, viewsets
Expand All @@ -11,6 +12,7 @@
from posthog.api.forbid_destroy_model import ForbidDestroyModel
from posthog.api.routing import StructuredViewSetMixin
from posthog.api.shared import UserBasicSerializer
from posthog.exceptions import Conflict
from posthog.models import User
from posthog.models.activity_logging.activity_log import Change, Detail, changes_between, log_activity, load_activity
from posthog.models.activity_logging.activity_page import activity_page_response
Expand Down Expand Up @@ -97,20 +99,22 @@ def update(self, instance: Notebook, validated_data: Dict, **kwargs) -> Notebook
except Notebook.DoesNotExist:
before_update = None

if validated_data.keys():
instance.last_modified_at = now()
instance.last_modified_by = self.context["request"].user
with transaction.atomic():
# select_for_update locks 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


# TODO: This is not atomic meaning we could still end up with race conditions
if validated_data.get("content"):
if validated_data.get("version") != instance.version:
raise serializers.ValidationError(
"Notebook was modified by someone else. Please refresh and try again."
)
if validated_data.keys():
locked_instance.last_modified_at = now()
locked_instance.last_modified_by = self.context["request"].user

validated_data["version"] = instance.version + 1
if validated_data.get("content"):
if validated_data.get("version") != locked_instance.version:
raise Conflict("Someone else edited the Notebook")

validated_data["version"] = locked_instance.version + 1

updated_notebook = super().update(locked_instance, validated_data)

updated_notebook = super().update(instance, validated_data)
changes = changes_between("Notebook", previous=before_update, current=updated_notebook)

log_notebook_activity(
Expand Down
Loading