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 { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
import { isElementLink } from "../packages/excalidraw/element/elementLink";
import type { ElementsChange } from "../packages/excalidraw/change";
import Slider from "rc-slider";
import "rc-slider/assets/index.css";

@ -9,7 +9,7 @@ import { Network } from "../sync/utils";
// CFDO II: add senderId, possibly roomId as well
export class DurableDeltasRepository implements DeltasRepository {
// 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;
constructor(private storage: DurableObjectStorage) {

@ -2,8 +2,6 @@ import { DurableObject } from "cloudflare:workers";
import { DurableDeltasRepository } from "./repository";
import { ExcalidrawSyncServer } from "../sync/server";
import type { ExcalidrawElement } from "../element/types";
/**
* Durable Object impl. of Excalidraw room.
*/
@ -11,26 +9,10 @@ export class DurableRoom extends DurableObject {
private roomId: string | null = null;
private sync: ExcalidrawSyncServer;
private snapshot!: {
appState: Record<string, any>;
elements: Map<string, ExcalidrawElement>;
version: number;
};
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
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;
});

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

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

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

@ -474,14 +474,13 @@ export class StoreDelta {
public static applyLatestChanges(
delta: StoreDelta,
elements: SceneElementsMap,
modifierOptions: "deleted" | "inserted",
): StoreDelta {
const inversedDelta = this.inverse(delta);
return this.create(
inversedDelta.elements.applyLatestChanges(elements),
inversedDelta.appState,
delta.elements.applyLatestChanges(elements, modifierOptions),
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_CHANGE = DTO<StoreChange>;
export type RESTORE_PAYLOAD = {};
export type RELAY_PAYLOAD = CLIENT_CHANGE;
export type PUSH_PAYLOAD = CLIENT_DELTA;
export type PULL_PAYLOAD = { lastAcknowledgedVersion: number };
@ -15,6 +16,7 @@ export type CHUNK_INFO = {
};
export type CLIENT_MESSAGE = (
| { type: "restore"; payload: RESTORE_PAYLOAD }
| { type: "relay"; payload: RELAY_PAYLOAD }
| { type: "pull"; payload: PULL_PAYLOAD }
| { type: "push"; payload: PUSH_PAYLOAD }
@ -48,7 +50,8 @@ export type SERVER_MESSAGE =
| {
type: "rejected";
payload: { deltas: Array<CLIENT_DELTA>; message: string };
};
}
| { type: "restored"; payload: { elements: Array<ExcalidrawElement> } };
export interface DeltasRepository {
save(delta: CLIENT_DELTA): SERVER_DELTA | null;

@ -3,7 +3,6 @@ import { Network, Utils } from "./utils";
import type {
DeltasRepository,
CLIENT_MESSAGE,
PULL_PAYLOAD,
PUSH_PAYLOAD,
SERVER_MESSAGE,
@ -11,7 +10,10 @@ import type {
CHUNK_INFO,
RELAY_PAYLOAD,
CLIENT_MESSAGE_BINARY,
CLIENT_MESSAGE,
ExcalidrawElement,
} from "./protocol";
import { StoreDelta } from "../store";
/**
* Core excalidraw sync logic.
@ -24,7 +26,22 @@ export class ExcalidrawSyncServer {
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)
public onConnect(client: WebSocket) {
@ -48,11 +65,12 @@ export class ExcalidrawSyncServer {
return;
}
const { type, payload, chunkInfo } = rawMessage;
// if there is chunkInfo, there are more than 1 chunks => process them first
if (chunkInfo) {
return this.processChunks(client, { type, payload, chunkInfo });
if (rawMessage.chunkInfo) {
return this.processChunks(client, {
...rawMessage,
chunkInfo: rawMessage.chunkInfo,
});
}
return this.processMessage(client, rawMessage);
@ -132,6 +150,8 @@ export class ExcalidrawSyncServer {
}
switch (type) {
case "restore":
return this.restore(client);
case "relay":
return this.relay(client, parsedPayload as RELAY_PAYLOAD);
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) {
// CFDO I: we should likely apply these to the snapshot
return this.broadcast(
@ -191,11 +220,39 @@ export class ExcalidrawSyncServer {
}
private push(client: WebSocket, delta: PUSH_PAYLOAD) {
// CFDO I: apply latest changes to delt & apply the deltas to the snapshot
const [acknowledged, savingError] = Utils.try(() =>
this.repository.save(delta),
const [storeDelta, applyingError] = Utils.try(() => {
// update the "deleted" delta according to the latest changes (in case of concurrent changes)
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) {
// CFDO: everything should be automatically rolled-back in the db -> double-check
return this.send(client, {
@ -204,7 +261,7 @@ export class ExcalidrawSyncServer {
message: savingError
? savingError.message
: "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"
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"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==

Loading…
Cancel
Save