Move storing of snapshots to R2 (#3693)

Instead of storing them in supabse we will store them in r2.

I have already created `room-snapshots` and `room-snapshots-preview`
buckets on cloudflare.

We could also migrate all the data from supabase, but it seems we
haven't done so for the rooms, so I also didn't look into doing it for
snapshots.

One slight drawback of moving to R2 is that it's harder to query data by
parent slug. So answering questions like which room is the parent to the
most snapshots is a bit harder to answer. Instead of just a simple query
we'd need to do some custom logic to go through the bucket. Not sure if
have ever needed this info though.

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [ ] `sdk` — Changes the tldraw SDK
- [x] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [ ] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [x] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### Test Plan

Existing snapshots:

1. Load an existing snapshot. It should still load correctly. The best
way to do that is probably to generate a few of them in advance.

New snapshots:
1. Create a new room.
2. Create a few snapshot links.
3. They should work.

- [ ] Unit Tests
- [ ] End to end tests

### Release Notes

- Move storing of snapshots to cloudflare R2.
This commit is contained in:
Mitja Bezenšek 2024-05-08 11:06:02 +02:00 committed by GitHub
parent a2f4d35579
commit ddebf3fc5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 110 additions and 31 deletions

View file

@ -338,7 +338,7 @@ export class TLDrawDurableObject extends TLServer {
this._roomState = undefined
}
// Load the room's drawing data from supabase
// Load the room's drawing data. First we check the R2 bucket, then we fallback to supabase (legacy).
override async loadFromDatabase(persistenceKey: string): Promise<DBLoadResult> {
try {
const key = getR2KeyForRoom(persistenceKey)

View file

@ -1,3 +1,9 @@
export function getR2KeyForRoom(persistenceKey: string) {
return `public_rooms/${persistenceKey}`
}
export function getR2KeyForSnapshot(parentSlug: string | undefined | null, snapshotSlug: string) {
// We might not have a parent slug. This happens when creating a snapshot from a local room.
const persistenceKey = parentSlug ? `${parentSlug}/${snapshotSlug}` : snapshotSlug
return getR2KeyForRoom(persistenceKey)
}

View file

@ -1,11 +1,16 @@
import { CreateSnapshotRequestBody } from '@tldraw/dotcom-shared'
import { RoomSnapshot } from '@tldraw/tlsync'
import { IRequest } from 'itty-router'
import { nanoid } from 'nanoid'
import { getR2KeyForSnapshot } from '../r2'
import { Environment } from '../types'
import { createSupabaseClient, noSupabaseSorry } from '../utils/createSupabaseClient'
import { getSnapshotsTable } from '../utils/getSnapshotsTable'
import { validateSnapshot } from '../utils/validateSnapshot'
export type R2Snapshot = {
parent_slug: CreateSnapshotRequestBody['parent_slug']
drawing: RoomSnapshot
}
export async function createRoomSnapshot(request: IRequest, env: Environment): Promise<Response> {
const data = (await request.json()) as CreateSnapshotRequestBody
@ -18,7 +23,6 @@ export async function createRoomSnapshot(request: IRequest, env: Environment): P
const persistedRoomSnapshot = {
parent_slug: data.parent_slug,
slug: roomId,
drawing: {
schema: data.schema,
clock: 0,
@ -28,13 +32,16 @@ export async function createRoomSnapshot(request: IRequest, env: Environment): P
})),
tombstones: {},
},
} satisfies R2Snapshot
const parentSlug = data.parent_slug
if (parentSlug) {
await env.SNAPSHOT_SLUG_TO_PARENT_SLUG.put(roomId, parentSlug)
}
const supabase = createSupabaseClient(env)
if (!supabase) return noSupabaseSorry()
const supabaseTable = getSnapshotsTable(env)
await supabase.from(supabaseTable).insert(persistedRoomSnapshot)
await env.ROOM_SNAPSHOTS.put(
getR2KeyForSnapshot(parentSlug, roomId),
JSON.stringify(persistedRoomSnapshot)
)
return new Response(JSON.stringify({ error: false, roomId }))
}

View file

@ -1,15 +1,45 @@
import { RoomSnapshot } from '@tldraw/tlsync'
import { IRequest } from 'itty-router'
import { getR2KeyForSnapshot } from '../r2'
import { Environment } from '../types'
import { createSupabaseClient, noSupabaseSorry } from '../utils/createSupabaseClient'
import { fourOhFour } from '../utils/fourOhFour'
import { getSnapshotsTable } from '../utils/getSnapshotsTable'
import { R2Snapshot } from './createRoomSnapshot'
function generateReponse(roomId: string, data: RoomSnapshot) {
return new Response(
JSON.stringify({
roomId,
records: data.documents.map((d) => d.state),
schema: data.schema,
error: false,
}),
{
headers: { 'content-type': 'application/json' },
}
)
}
// Returns a snapshot of the room at a given point in time
export async function getRoomSnapshot(request: IRequest, env: Environment): Promise<Response> {
const roomId = request.params.roomId
if (!roomId) return fourOhFour()
// Get the parent slug if it exists
const parentSlug = await env.SNAPSHOT_SLUG_TO_PARENT_SLUG.get(roomId)
// Get the room snapshot from R2
const snapshot = await env.ROOM_SNAPSHOTS.get(getR2KeyForSnapshot(parentSlug, roomId))
if (snapshot) {
const data = ((await snapshot.json()) as R2Snapshot)?.drawing as RoomSnapshot
if (data) {
return generateReponse(roomId, data)
}
}
// If we can't find the snapshot in R2 then fallback to Supabase
// Create a supabase client
const supabase = createSupabaseClient(env)
if (!supabase) return noSupabaseSorry()
@ -26,12 +56,5 @@ export async function getRoomSnapshot(request: IRequest, env: Environment): Prom
if (!data) return fourOhFour()
// Send back the snapshot!
return new Response(
JSON.stringify({
roomId,
records: data.documents.map((d) => d.state),
schema: data.schema,
error: false,
})
)
return generateReponse(roomId, data)
}

View file

@ -17,6 +17,9 @@ export interface Environment {
ROOMS: R2Bucket
ROOMS_HISTORY_EPHEMERAL: R2Bucket
ROOM_SNAPSHOTS: R2Bucket
SNAPSHOT_SLUG_TO_PARENT_SLUG: KVNamespace
SLUG_TO_READONLY_SLUG: KVNamespace
READONLY_SLUG_TO_SLUG: KVNamespace

View file

@ -115,6 +115,44 @@ bucket_name = "rooms-history-ephemeral-preview"
binding = "ROOMS_HISTORY_EPHEMERAL"
bucket_name = "rooms-history-ephemeral"
#################### Room snapshots bucket ####################
# in dev, we write to the preview bucket and need a `preview_bucket_name`
[[env.dev.r2_buckets]]
binding = "ROOM_SNAPSHOTS"
bucket_name = "room-snapshots-preview"
preview_bucket_name = "room-snapshots-preview"
# in preview and staging we write to the preview bucket
[[env.preview.r2_buckets]]
binding = "ROOM_SNAPSHOTS"
bucket_name = "room-snapshots-preview"
[[env.staging.r2_buckets]]
binding = "ROOM_SNAPSHOTS"
bucket_name = "room-snapshots-preview"
# in production, we write to the main bucket
[[env.production.r2_buckets]]
binding = "ROOM_SNAPSHOTS"
bucket_name = "room-snapshots"
#################### Room snapshots parent slug KV store ####################
[[env.dev.kv_namespaces]]
binding = "SNAPSHOT_SLUG_TO_PARENT_SLUG"
id = "5eaa50a2b87145e582661ea3344804b8"
[[env.preview.kv_namespaces]]
binding = "SNAPSHOT_SLUG_TO_PARENT_SLUG"
id = "5eaa50a2b87145e582661ea3344804b8"
[[env.staging.kv_namespaces]]
binding = "SNAPSHOT_SLUG_TO_PARENT_SLUG"
id = "5eaa50a2b87145e582661ea3344804b8"
[[env.production.kv_namespaces]]
binding = "SNAPSHOT_SLUG_TO_PARENT_SLUG"
id = "c6ce1f45447e4a44a00edb2a2077bc5c"
#################### Key value storage ####################
[[env.dev.kv_namespaces]]
binding = "SLUG_TO_READONLY_SLUG"

View file

@ -7,20 +7,21 @@ import { defineLoader } from '../utils/defineLoader'
const { loader, useData } = defineLoader(async (args) => {
const roomId = args.params.roomId
const result = await fetch(`/api/snapshot/${roomId}`)
return result.ok
? ((await result.json()) as {
if (!result.ok) throw new Error('Room not found')
const data = await result.json()
if (!data || data.error) throw new Error('Room not found')
return data as {
roomId: string
schema: SerializedSchema
records: TLRecord[]
})
: null
}
})
export { loader }
export function Component() {
const result = useData()
if (!result) throw Error('Room not found')
const { roomId, records, schema } = result
return (
<IFrameProtector slug={roomId} context={ROOM_CONTEXT.PUBLIC_SNAPSHOT}>

View file

@ -7,7 +7,7 @@ import {
Snapshot,
} from '@tldraw/dotcom-shared'
import { useMemo } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useNavigate, useParams } from 'react-router-dom'
import {
AssetRecordType,
Editor,
@ -96,7 +96,8 @@ export async function getNewRoomResponse(snapshot: Snapshot) {
export function useSharing(): TLUiOverrides {
const navigate = useNavigate()
const id = useSearchParams()[0].get('id') ?? undefined
const params = useParams()
const roomId = params.roomId
const uploadFileToAsset = useMultiplayerAssets(ASSET_UPLOADER_URL)
const handleUiEvent = useHandleUiEvents()
const runningInIFrame = isInIframe()
@ -173,7 +174,7 @@ export function useSharing(): TLUiOverrides {
addToast,
msg,
uploadFileToAsset,
id
roomId
)
if (navigator?.clipboard?.write) {
await navigator.clipboard.write([
@ -196,7 +197,7 @@ export function useSharing(): TLUiOverrides {
return actions
},
}),
[handleUiEvent, navigate, uploadFileToAsset, id, runningInIFrame]
[handleUiEvent, navigate, uploadFileToAsset, roomId, runningInIFrame]
)
}

View file

@ -13,7 +13,7 @@ export type CreateRoomRequestBody = {
export type CreateSnapshotRequestBody = {
schema: SerializedSchema
snapshot: SerializedStore<TLRecord>
parent_slug?: string | string[] | undefined
parent_slug?: string | undefined
}
export type CreateSnapshotResponseBody =