From b8f4ad674b27f2dc1671ff9f5d19712842c0e2df Mon Sep 17 00:00:00 2001 From: Enrico Ros Date: Mon, 2 Dec 2024 17:40:26 -0800 Subject: [PATCH] Vector Clocks implementation --- src/modules/sync/store-sync.ts | 68 ++++++------ src/modules/sync/vectorclock.ts | 148 ++++++++++++++++++++++++++ src/modules/sync/vectorclock.types.ts | 38 +++++++ 3 files changed, 216 insertions(+), 38 deletions(-) create mode 100644 src/modules/sync/vectorclock.ts create mode 100644 src/modules/sync/vectorclock.types.ts diff --git a/src/modules/sync/store-sync.ts b/src/modules/sync/store-sync.ts index 267f9bc2f..fb583af69 100644 --- a/src/modules/sync/store-sync.ts +++ b/src/modules/sync/store-sync.ts @@ -4,50 +4,30 @@ import { persist } from 'zustand/middleware'; import { agiId } from '~/common/util/idUtils'; import { isBrowser } from '~/common/util/pwaUtils'; +import type { VectorClockNodeId } from './vectorclock.types'; -type VectorClientDeviceId = string; -interface VectorClient { +interface SyncStore { - // The critical ID used in vector clocks - vectorId: VectorClientDeviceId; + vectorClockNode: null | { - // Basic device fingerprint stored once at creation - createdAt: number; - userAgent: string; + // unique id for this device, used to track changes, only statistically unique within the user space + nodeId: VectorClockNodeId; + + // basic device fingerprint stored once at creation + createdAt: number; + userAgent: string; + + }; } -interface StoreSync { - - client: VectorClient | null; - - getVectorDeviceId: () => string; - -} - - -const useSyncStore = create()(persist( +const useSyncStore = create()(persist( (_set, _get) => ({ - client: null, - - getVectorDeviceId: () => { - const exClient = _get().client; - if (exClient) return exClient.vectorId; - - // this will be created once per browser - const client = { - vectorId: agiId('vector-device-id10'), - createdAt: Date.now(), - userAgent: isBrowser ? window.navigator?.userAgent || '' : '', - }; - - _set({ client }); - - return client.vectorId; - }, + // initial state + vectorClockNode: null, }), { @@ -56,7 +36,19 @@ const useSyncStore = create()(persist( }, )); -// Quick access for vector clocks -export const getVectorDeviceId = () => { - return useSyncStore.getState().getVectorDeviceId(); -}; + +export function getVectorClockNodeId() { + const exClient = useSyncStore.getState().vectorClockNode; + if (exClient) return exClient.nodeId; + + // this will be created once per browser + const vectorClockNode = { + nodeId: agiId('vector-device-id10'), + createdAt: Date.now(), + userAgent: isBrowser ? window.navigator?.userAgent || '' : '', + }; + + useSyncStore.setState({ vectorClockNode }); + + return vectorClockNode.nodeId; +} diff --git a/src/modules/sync/vectorclock.ts b/src/modules/sync/vectorclock.ts new file mode 100644 index 000000000..2e9e0edb8 --- /dev/null +++ b/src/modules/sync/vectorclock.ts @@ -0,0 +1,148 @@ +import type { VectorClock, VectorClockMergeResult, VectorClockNodeId, VectorClockState } from './vectorclock.types'; + +export const VectorClockOrder = { + BEFORE: -1, + CONCURRENT: 0, + AFTER: 1, +} as const; +export type VectorClockOrderType = typeof VectorClockOrder[keyof typeof VectorClockOrder]; + + +/** + * Creates a new vector clock for a node + */ +export function vectorClockCreate(nodeId: VectorClockNodeId, state: VectorClockState = {}): VectorClock { + return { + nodeId, + state: { ...state, [nodeId]: state[nodeId] ?? 0 }, + }; +} + +/** + * Creates a deep copy of a vector clock + */ +export function vectorClockClone(clock: VectorClock): VectorClock { + return { + nodeId: clock.nodeId, + state: { ...clock.state }, + }; +} + +/** + * Increments the vector clock for its node + */ +export function vectorClockIncrementInPlace(clock: VectorClock): void { + clock.state[clock.nodeId] = (clock.state[clock.nodeId] ?? 0) + 1; +} + +/** + * Merges source clock into target clock + */ +export function vectorClockMergeInPlace(target: VectorClock, source: VectorClock): void { + const allNodes = new Set([...Object.keys(target.state), ...Object.keys(source.state)]); + for (const nodeId of allNodes) + target.state[nodeId] = Math.max(target.state[nodeId] ?? 0, source.state[nodeId] ?? 0); +} + +/** + * Compares two vector clocks + * @returns -1 if a < b, 0 if concurrent, 1 if a > b + */ +export function vectorClockCompare(a: VectorClock, b: VectorClock): VectorClockOrderType { + let isGreater = false; + let isLess = false; + + const allNodes = new Set([...Object.keys(a.state), ...Object.keys(b.state)]); + for (const nodeId of allNodes) { + const aTime = a.state[nodeId] ?? 0; + const bTime = b.state[nodeId] ?? 0; + if (aTime > bTime) isGreater = true; + if (aTime < bTime) isLess = true; + } + + if (isGreater && !isLess) return VectorClockOrder.AFTER; + if (isLess && !isGreater) return VectorClockOrder.BEFORE; + return VectorClockOrder.CONCURRENT; +} + +/** + * Validates if an object is a valid vector clock + */ +export function vectorClockIsValid(obj: unknown): obj is VectorClock { + if (!obj || typeof obj !== 'object') return false; + + const clock = obj as VectorClock; + // noinspection SuspiciousTypeOfGuard + return ( + typeof clock.nodeId === 'string' && !!clock.nodeId && + clock.state !== null && typeof clock.state === 'object' && + Object.values(clock.state).every(v => typeof v === 'number') + ); +} + +// /** +// * Gets timestamp for a specific node +// */ +// export function vectorClockGetTime(clock: VectorClock, nodeId: VectorClockNodeId): VectorClockTimestamp { +// return clock.state[nodeId] ?? 0; +// } + +// /** +// * Creates an empty vector clock state +// */ +// export function vectorClockCreateEmptyState(): VectorClockState { +// return {}; +// } + +// /** +// * Detects if two clocks are concurrent (potential conflict) +// */ +// export function vectorClockHasConcurrentUpdates(a: VectorClock, b: VectorClock): boolean { +// return vectorClockCompare(a, b) === VectorClockOrder.CONCURRENT; +// } + +/** + * Checks if one clock dominates another (happens-before relationship) + */ +export function vectorClockIsDominating(dominant: VectorClock, other: VectorClock): boolean { + return vectorClockCompare(dominant, other) === VectorClockOrder.AFTER; +} + +/** + * Attempts to merge data with vector clocks, detecting conflicts + */ +export function vectorClockAttemptMerge(local: { data: T; clock: VectorClock }, remote: { data: T; clock: VectorClock }): VectorClockMergeResult { + const comparison = vectorClockCompare(local.clock, remote.clock); + switch (comparison) { + case VectorClockOrder.CONCURRENT: + return { + success: false, + conflicts: [{ + local: local.data, + remote: remote.data, + localClock: local.clock, + remoteClock: remote.clock, + }], + }; + case VectorClockOrder.BEFORE: + return { + success: true, + result: remote.data, + }; + default: + return { + success: true, + result: local.data, + }; + } +} + +/** + * Creates a merged clock after conflict resolution + */ +export function vectorClockCreateMerged(localClock: VectorClock, remoteClock: VectorClock): VectorClock { + const merged = vectorClockClone(localClock); + vectorClockMergeInPlace(merged, remoteClock); + vectorClockIncrementInPlace(merged); + return merged; +} diff --git a/src/modules/sync/vectorclock.types.ts b/src/modules/sync/vectorclock.types.ts new file mode 100644 index 000000000..769547291 --- /dev/null +++ b/src/modules/sync/vectorclock.types.ts @@ -0,0 +1,38 @@ +/** + * Vector Clock implementation for distributed systems + * Supports async workflows and conflict resolution + */ + +export type VectorClockNodeId = string; +type VectorClockTimestamp = number; + +/** + * What's in the database + */ +export type VectorClockState = { + [nodeId: string]: VectorClockTimestamp; +}; + +/** + * What's in memory in this node (device) + */ +export interface VectorClock { + nodeId: VectorClockNodeId; + state: VectorClockState; +} + + +// Auxiliary types for comparisons, merges + +export interface VectorClockConflict { + local: T; + remote: T; + localClock: VectorClock; + remoteClock: VectorClock; +} + +export type VectorClockMergeResult = { + success: boolean; + conflicts?: VectorClockConflict[]; + result?: T; +};