Server snapshot WIP

mrazator/delta-based-sync
Marcel Mraz 2 days ago
parent 49925038fd
commit 7b72406824
No known key found for this signature in database
GPG Key ID: 4EBD6E62DC830CD2

@ -140,7 +140,6 @@ import DebugCanvas, {
import { AIComponents } from "./components/AI"; import { AIComponents } from "./components/AI";
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport"; import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
import { isElementLink } from "../packages/excalidraw/element/elementLink"; import { isElementLink } from "../packages/excalidraw/element/elementLink";
import type { ElementsChange } from "../packages/excalidraw/change";
import Slider from "rc-slider"; import Slider from "rc-slider";
import "rc-slider/assets/index.css"; import "rc-slider/assets/index.css";

@ -9,7 +9,7 @@ import { Network } from "../sync/utils";
// CFDO II: add senderId, possibly roomId as well // CFDO II: add senderId, possibly roomId as well
export class DurableDeltasRepository implements DeltasRepository { export class DurableDeltasRepository implements DeltasRepository {
// there is a 2MB row limit, hence working with max payload size of 1.5 MB // there is a 2MB row limit, hence working with max payload size of 1.5 MB
// and leaving a buffer for other row metadata // and leaving a ~500kB buffer for other row metadata
private static readonly MAX_PAYLOAD_SIZE = 1_500_000; private static readonly MAX_PAYLOAD_SIZE = 1_500_000;
constructor(private storage: DurableObjectStorage) { constructor(private storage: DurableObjectStorage) {

@ -2,8 +2,6 @@ import { DurableObject } from "cloudflare:workers";
import { DurableDeltasRepository } from "./repository"; import { DurableDeltasRepository } from "./repository";
import { ExcalidrawSyncServer } from "../sync/server"; import { ExcalidrawSyncServer } from "../sync/server";
import type { ExcalidrawElement } from "../element/types";
/** /**
* Durable Object impl. of Excalidraw room. * Durable Object impl. of Excalidraw room.
*/ */
@ -11,26 +9,10 @@ export class DurableRoom extends DurableObject {
private roomId: string | null = null; private roomId: string | null = null;
private sync: ExcalidrawSyncServer; private sync: ExcalidrawSyncServer;
private snapshot!: {
appState: Record<string, any>;
elements: Map<string, ExcalidrawElement>;
version: number;
};
constructor(ctx: DurableObjectState, env: Env) { constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env); super(ctx, env);
this.ctx.blockConcurrencyWhile(async () => { this.ctx.blockConcurrencyWhile(async () => {
// CFDO I: snapshot should likely be a transient store
// CFDO II: loaded the latest state from the db
this.snapshot = {
// CFDO: start persisting acknowledged version (not a scene version!)
// CFDO: we don't persist appState, should we?
appState: {},
elements: new Map(),
version: 0,
};
this.roomId = (await this.ctx.storage.get("roomId")) || null; this.roomId = (await this.ctx.storage.get("roomId")) || null;
}); });

@ -5367,7 +5367,7 @@ class App extends React.Component<AppProps, AppState> {
: -1; : -1;
if (midPoint && midPoint > -1) { if (midPoint && midPoint > -1) {
this.store.shouldCaptureIncrement(); this.store.scheduleCapture();
LinearElementEditor.deleteFixedSegment(selectedElements[0], midPoint); LinearElementEditor.deleteFixedSegment(selectedElements[0], midPoint);
const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords( const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(

@ -32,7 +32,7 @@ import type {
} from "./element/types"; } from "./element/types";
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
import { getNonDeletedGroupIds } from "./groups"; import { getNonDeletedGroupIds } from "./groups";
import { getObservedAppState } from "./store"; import { getObservedAppState, StoreSnapshot } from "./store";
import type { import type {
AppState, AppState,
ObservedAppState, ObservedAppState,
@ -1036,7 +1036,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated * @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
* @returns new instance with modified delta/s * @returns new instance with modified delta/s
*/ */
public applyLatestChanges(elements: SceneElementsMap): ElementsDelta { public applyLatestChanges(
elements: SceneElementsMap,
modifierOptions: "deleted" | "inserted",
): ElementsDelta {
const modifier = const modifier =
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => { (element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
const latestPartial: { [key: string]: unknown } = {}; const latestPartial: { [key: string]: unknown } = {};
@ -1069,7 +1072,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
delta.deleted, delta.deleted,
delta.inserted, delta.inserted,
modifier(existingElement), modifier(existingElement),
"inserted", modifierOptions,
); );
modifiedDeltas[id] = modifiedDelta; modifiedDeltas[id] = modifiedDelta;
@ -1092,7 +1095,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
public applyTo( public applyTo(
elements: SceneElementsMap, elements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>, elementsSnapshot: Map<
string,
OrderedExcalidrawElement
> = StoreSnapshot.empty().elements,
): [SceneElementsMap, boolean] { ): [SceneElementsMap, boolean] {
let nextElements = toBrandedType<SceneElementsMap>(new Map(elements)); let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
let changedElements: Map<string, OrderedExcalidrawElement>; let changedElements: Map<string, OrderedExcalidrawElement>;
@ -1106,7 +1112,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
try { try {
const applyDeltas = ElementsDelta.createApplier( const applyDeltas = ElementsDelta.createApplier(
nextElements, nextElements,
snapshot, elementsSnapshot,
flags, flags,
); );

@ -159,7 +159,13 @@ export class History {
entry: HistoryEntry, entry: HistoryEntry,
prevElements: SceneElementsMap, prevElements: SceneElementsMap,
) { ) {
const updatedEntry = HistoryEntry.applyLatestChanges(entry, prevElements); const inversedEntry = HistoryEntry.inverse(entry);
const updatedEntry = HistoryEntry.applyLatestChanges(
inversedEntry,
prevElements,
"inserted",
);
return stack.push(updatedEntry); return stack.push(updatedEntry);
} }
} }

@ -474,14 +474,13 @@ export class StoreDelta {
public static applyLatestChanges( public static applyLatestChanges(
delta: StoreDelta, delta: StoreDelta,
elements: SceneElementsMap, elements: SceneElementsMap,
modifierOptions: "deleted" | "inserted",
): StoreDelta { ): StoreDelta {
const inversedDelta = this.inverse(delta);
return this.create( return this.create(
inversedDelta.elements.applyLatestChanges(elements), delta.elements.applyLatestChanges(elements, modifierOptions),
inversedDelta.appState, delta.appState,
{ {
id: inversedDelta.id, id: delta.id,
}, },
); );
} }

@ -4,6 +4,7 @@ import type { DTO } from "../utility-types";
export type CLIENT_DELTA = DTO<StoreDelta>; export type CLIENT_DELTA = DTO<StoreDelta>;
export type CLIENT_CHANGE = DTO<StoreChange>; export type CLIENT_CHANGE = DTO<StoreChange>;
export type RESTORE_PAYLOAD = {};
export type RELAY_PAYLOAD = CLIENT_CHANGE; export type RELAY_PAYLOAD = CLIENT_CHANGE;
export type PUSH_PAYLOAD = CLIENT_DELTA; export type PUSH_PAYLOAD = CLIENT_DELTA;
export type PULL_PAYLOAD = { lastAcknowledgedVersion: number }; export type PULL_PAYLOAD = { lastAcknowledgedVersion: number };
@ -15,6 +16,7 @@ export type CHUNK_INFO = {
}; };
export type CLIENT_MESSAGE = ( export type CLIENT_MESSAGE = (
| { type: "restore"; payload: RESTORE_PAYLOAD }
| { type: "relay"; payload: RELAY_PAYLOAD } | { type: "relay"; payload: RELAY_PAYLOAD }
| { type: "pull"; payload: PULL_PAYLOAD } | { type: "pull"; payload: PULL_PAYLOAD }
| { type: "push"; payload: PUSH_PAYLOAD } | { type: "push"; payload: PUSH_PAYLOAD }
@ -48,7 +50,8 @@ export type SERVER_MESSAGE =
| { | {
type: "rejected"; type: "rejected";
payload: { deltas: Array<CLIENT_DELTA>; message: string }; payload: { deltas: Array<CLIENT_DELTA>; message: string };
}; }
| { type: "restored"; payload: { elements: Array<ExcalidrawElement> } };
export interface DeltasRepository { export interface DeltasRepository {
save(delta: CLIENT_DELTA): SERVER_DELTA | null; save(delta: CLIENT_DELTA): SERVER_DELTA | null;

@ -3,7 +3,6 @@ import { Network, Utils } from "./utils";
import type { import type {
DeltasRepository, DeltasRepository,
CLIENT_MESSAGE,
PULL_PAYLOAD, PULL_PAYLOAD,
PUSH_PAYLOAD, PUSH_PAYLOAD,
SERVER_MESSAGE, SERVER_MESSAGE,
@ -11,7 +10,10 @@ import type {
CHUNK_INFO, CHUNK_INFO,
RELAY_PAYLOAD, RELAY_PAYLOAD,
CLIENT_MESSAGE_BINARY, CLIENT_MESSAGE_BINARY,
CLIENT_MESSAGE,
ExcalidrawElement,
} from "./protocol"; } from "./protocol";
import { StoreDelta } from "../store";
/** /**
* Core excalidraw sync logic. * Core excalidraw sync logic.
@ -24,7 +26,22 @@ export class ExcalidrawSyncServer {
Map<CHUNK_INFO["position"], CLIENT_MESSAGE_BINARY["payload"]> Map<CHUNK_INFO["position"], CLIENT_MESSAGE_BINARY["payload"]>
>(); >();
constructor(private readonly repository: DeltasRepository) {} // CFDO II: load from the db
private elements = new Map<string, ExcalidrawElement>();
constructor(private readonly repository: DeltasRepository) {
// CFDO II: load from the db
const deltas = this.repository.getAllSinceVersion(0);
for (const delta of deltas) {
const storeDelta = StoreDelta.load(delta.payload);
// CFDO II: fix types (everywhere)
const [nextElements] = storeDelta.elements.applyTo(this.elements as any);
this.elements = nextElements;
}
}
// CFDO: optimize, should send a message about collaborators (no collaborators => no need to send ephemerals) // CFDO: optimize, should send a message about collaborators (no collaborators => no need to send ephemerals)
public onConnect(client: WebSocket) { public onConnect(client: WebSocket) {
@ -48,11 +65,12 @@ export class ExcalidrawSyncServer {
return; return;
} }
const { type, payload, chunkInfo } = rawMessage;
// if there is chunkInfo, there are more than 1 chunks => process them first // if there is chunkInfo, there are more than 1 chunks => process them first
if (chunkInfo) { if (rawMessage.chunkInfo) {
return this.processChunks(client, { type, payload, chunkInfo }); return this.processChunks(client, {
...rawMessage,
chunkInfo: rawMessage.chunkInfo,
});
} }
return this.processMessage(client, rawMessage); return this.processMessage(client, rawMessage);
@ -132,6 +150,8 @@ export class ExcalidrawSyncServer {
} }
switch (type) { switch (type) {
case "restore":
return this.restore(client);
case "relay": case "relay":
return this.relay(client, parsedPayload as RELAY_PAYLOAD); return this.relay(client, parsedPayload as RELAY_PAYLOAD);
case "pull": case "pull":
@ -147,6 +167,15 @@ export class ExcalidrawSyncServer {
} }
} }
private restore(client: WebSocket) {
return this.send(client, {
type: "restored",
payload: {
elements: Array.from(this.elements.values()),
},
});
}
private relay(client: WebSocket, payload: RELAY_PAYLOAD) { private relay(client: WebSocket, payload: RELAY_PAYLOAD) {
// CFDO I: we should likely apply these to the snapshot // CFDO I: we should likely apply these to the snapshot
return this.broadcast( return this.broadcast(
@ -191,11 +220,39 @@ export class ExcalidrawSyncServer {
} }
private push(client: WebSocket, delta: PUSH_PAYLOAD) { private push(client: WebSocket, delta: PUSH_PAYLOAD) {
// CFDO I: apply latest changes to delt & apply the deltas to the snapshot const [storeDelta, applyingError] = Utils.try(() => {
const [acknowledged, savingError] = Utils.try(() => // update the "deleted" delta according to the latest changes (in case of concurrent changes)
this.repository.save(delta), const storeDelta = StoreDelta.applyLatestChanges(
StoreDelta.load(delta),
this.elements as any,
"deleted",
); );
// apply the delta to the elements snapshot
const [nextElements] = storeDelta.elements.applyTo(this.elements as any);
this.elements = nextElements;
return storeDelta;
});
if (applyingError) {
// CFDO: everything should be automatically rolled-back in the db -> double-check
return this.send(client, {
type: "rejected",
payload: {
message: applyingError
? applyingError.message
: "Couldn't apply the delta.",
deltas: [delta],
},
});
}
const [acknowledged, savingError] = Utils.try(() => {
return this.repository.save(storeDelta);
});
if (savingError || !acknowledged) { if (savingError || !acknowledged) {
// CFDO: everything should be automatically rolled-back in the db -> double-check // CFDO: everything should be automatically rolled-back in the db -> double-check
return this.send(client, { return this.send(client, {
@ -204,7 +261,7 @@ export class ExcalidrawSyncServer {
message: savingError message: savingError
? savingError.message ? savingError.message
: "Coudn't persist the delta.", : "Coudn't persist the delta.",
deltas: [delta], deltas: [storeDelta],
}, },
}); });
} }

@ -7986,7 +7986,7 @@ lodash.camelcase@^4.3.0:
resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==
lodash.debounce@^4.0.8: lodash.debounce@4.0.8, lodash.debounce@^4.0.8:
version "4.0.8" version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==

Loading…
Cancel
Save