feat(sync): implement vector clock checking

This commit is contained in:
Johannes Millan 2025-06-24 20:10:39 +02:00
parent 2767503799
commit a08ad5ae9d
19 changed files with 2279 additions and 87 deletions

6
.gitignore vendored
View file

@ -54,6 +54,12 @@ perf-metrics-*.json
.DS_Store
Thumbs.db
# iOS build artifacts
ios/App/App/public/
ios/App/App/capacitor.config.json
ios/App/App/config.xml
ios/App/App/Plugins/
# sometimes idea does compile ts files...
src/app/**/*.js
src/app/**/*.js.map

250
docs/sync/vector-clocks.md Normal file
View file

@ -0,0 +1,250 @@
# Vector Clocks in Super Productivity Sync
## Overview
Super Productivity uses vector clocks to provide accurate conflict detection and resolution in its synchronization system. This document explains how vector clocks work, why they're used, and how they integrate with the existing sync infrastructure.
## Table of Contents
1. [What are Vector Clocks?](#what-are-vector-clocks)
2. [Why Vector Clocks?](#why-vector-clocks)
3. [Implementation Details](#implementation-details)
4. [Migration from Lamport Timestamps](#migration-from-lamport-timestamps)
5. [API Reference](#api-reference)
6. [Examples](#examples)
## What are Vector Clocks?
A vector clock is a data structure used in distributed systems to determine the partial ordering of events and detect causality violations. Each client/device maintains its own component in the vector, incrementing it on local updates.
### Structure
```typescript
interface VectorClock {
[clientId: string]: number;
}
// Example:
{
"desktop_1234": 5,
"mobile_5678": 3,
"web_9012": 7
}
```
### Comparison Results
Vector clocks can have four relationships:
1. **EQUAL**: Same values for all components
2. **LESS_THAN**: A happened before B (all components of A ≤ B)
3. **GREATER_THAN**: B happened before A (all components of B ≤ A)
4. **CONCURRENT**: Neither happened before the other (true conflict)
## Why Vector Clocks?
### Problem with Lamport Timestamps
Lamport timestamps provide a total ordering but can't distinguish between:
- Changes made after syncing (sequential)
- Changes made independently (concurrent)
This leads to false conflicts where user intervention is required even though one device is clearly ahead.
### Benefits of Vector Clocks
1. **Accurate Conflict Detection**: Only reports conflicts for truly concurrent changes
2. **Automatic Resolution**: Can auto-merge when one vector dominates another
3. **Device Tracking**: Maintains history of which device made which changes
4. **Reduced User Interruptions**: Fewer false conflicts mean better UX
## Implementation Details
### File Structure
```
src/app/pfapi/api/
├── util/
│ ├── vector-clock.ts # Core vector clock operations
│ ├── backwards-compat.ts # Migration helpers
│ └── get-sync-status-from-meta-files.ts # Sync status detection
├── model-ctrl/
│ └── meta-model-ctrl.ts # Updates vector clocks on changes
└── sync/
└── sync.service.ts # Integrates vector clocks in sync flow
```
### Core Operations
#### 1. Increment on Local Change
```typescript
// When user modifies data
const newVectorClock = incrementVectorClock(currentVectorClock, clientId);
```
#### 2. Merge on Sync
```typescript
// When downloading remote changes
const mergedClock = mergeVectorClocks(localVector, remoteVector);
```
#### 3. Compare for Conflicts
```typescript
const comparison = compareVectorClocks(localVector, remoteVector);
if (comparison === VectorClockComparison.CONCURRENT) {
// True conflict - user must resolve
}
```
### Integration Points
1. **MetaModelCtrl**: Increments vector clock on every local change
2. **SyncService**: Merges vector clocks during download, includes in upload
3. **getSyncStatusFromMetaFiles**: Uses vector clocks for conflict detection
## Migration from Lamport Timestamps
The system maintains backwards compatibility with existing Lamport timestamps:
### Automatic Migration
```typescript
// If no vector clock exists, create from Lamport timestamp
if (!meta.vectorClock && meta.localLamport > 0) {
meta.vectorClock = { [clientId]: meta.localLamport };
}
```
### Dual Support
- New installations use vector clocks immediately
- Existing installations migrate transparently
- Both systems coexist during transition period
### Field Mapping
| Old Field | New Field | Purpose |
| ------------------- | ----------------------- | ------------------- |
| `localLamport` | `vectorClock[clientId]` | Track local changes |
| `lastSyncedLamport` | `lastSyncedVectorClock` | Track sync state |
## API Reference
### Core Functions
#### `initializeVectorClock(clientId: string, initialValue?: number): VectorClock`
Creates a new vector clock for a client.
#### `compareVectorClocks(a: VectorClock, b: VectorClock): VectorClockComparison`
Determines the relationship between two vector clocks.
#### `incrementVectorClock(clock: VectorClock, clientId: string): VectorClock`
Increments the client's component in the vector clock.
#### `mergeVectorClocks(a: VectorClock, b: VectorClock): VectorClock`
Merges two vector clocks by taking the maximum of each component.
#### `hasVectorClockChanges(current: VectorClock, reference: VectorClock): boolean`
Checks if current has any changes compared to reference.
### Helper Functions
#### `vectorClockToString(clock: VectorClock): string`
Returns human-readable representation for debugging.
#### `lamportToVectorClock(lamport: number, clientId: string): VectorClock`
Converts Lamport timestamp to vector clock for migration.
## Examples
### Example 1: Simple Sequential Updates
```typescript
// Device A makes a change
deviceA.vectorClock = { A: 1 };
// Device A syncs to cloud
cloud.vectorClock = { A: 1 };
// Device B downloads
deviceB.vectorClock = { A: 1 };
// Device B makes a change
deviceB.vectorClock = { A: 1, B: 1 };
// When A tries to sync, vector clock shows B is ahead
// Result: A downloads B's changes (no conflict)
```
### Example 2: Concurrent Updates (True Conflict)
```typescript
// Both devices start synced
deviceA.vectorClock = { A: 1, B: 1 };
deviceB.vectorClock = { A: 1, B: 1 };
// Both make changes before syncing
deviceA.vectorClock = { A: 2, B: 1 }; // A incremented
deviceB.vectorClock = { A: 1, B: 2 }; // B incremented
// Comparison shows CONCURRENT - neither dominates
// Result: User must resolve conflict
```
### Example 3: Complex Multi-Device Scenario
```typescript
// Three devices with different states
desktop.vectorClock = { desktop: 5, mobile: 3, web: 2 };
mobile.vectorClock = { desktop: 4, mobile: 3, web: 2 };
web.vectorClock = { desktop: 4, mobile: 3, web: 7 };
// Desktop vs Mobile: Desktop is ahead (5 > 4)
// Desktop vs Web: Concurrent (desktop has 5 vs 4, but web has 7 vs 2)
// Mobile vs Web: Web is ahead (7 > 2, everything else equal)
```
## Debugging
### Enable Verbose Logging
```typescript
// In pfapi/api/util/log.ts, set log level to 2 or higher
pfLog(2, 'Vector clock comparison', {
localVector: vectorClockToString(localVector),
remoteVector: vectorClockToString(remoteVector),
result: comparison,
});
```
### Common Issues
1. **Clock Drift**: Ensure client IDs are stable and unique
2. **Migration Issues**: Check both vector clock and Lamport fields during transition
3. **Overflow Protection**: Clocks reset to 1 when approaching MAX_SAFE_INTEGER
## Best Practices
1. **Always increment** on local changes
2. **Always merge** when receiving remote data
3. **Never modify** vector clocks directly
4. **Use backwards-compat** helpers during migration period
5. **Log vector states** when debugging sync issues
## Future Improvements
1. **Compression**: Prune old client entries after inactivity
2. **Conflict Resolution**: Add automatic resolution strategies
3. **Visualization**: Add UI to show vector clock states
4. **Performance**: Optimize comparison for many clients

View file

@ -38,6 +38,43 @@
</td>
</tr>
</table>
<!-- Vector Clock Debug Information -->
@if (localVectorClock || remoteVectorClock) {
<h3>Vector Clock Debug Information</h3>
<table>
<thead>
<tr>
<th></th>
<th>Vector Clock</th>
</tr>
</thead>
<tr>
<td>{{ T.F.SYNC.D_CONFLICT.REMOTE | translate }}</td>
<td [matTooltip]="getVectorClockString(remoteVectorClock)">
{{ getVectorClockString(remoteVectorClock) }}
</td>
</tr>
<tr>
<td>{{ T.F.SYNC.D_CONFLICT.LOCAL | translate }}</td>
<td [matTooltip]="getVectorClockString(localVectorClock)">
{{ getVectorClockString(localVectorClock) }}
</td>
</tr>
<tr>
<td>Last Synced</td>
<td [matTooltip]="getVectorClockString(lastSyncedVectorClock)">
{{ getVectorClockString(lastSyncedVectorClock) }}
</td>
</tr>
<tr>
<td>Comparison Result</td>
<td>
<strong>{{ getVectorClockComparisonLabel() }}</strong>
</td>
</tr>
</table>
}
</mat-dialog-content>
<mat-dialog-actions align="end">

View file

@ -1,3 +1,14 @@
.isHighlight {
font-weight: 600;
}
h3 {
margin-top: 20px;
margin-bottom: 10px;
font-size: 1.1em;
font-weight: 500;
}
table + table {
margin-top: 20px;
}

View file

@ -8,12 +8,17 @@ import {
} from '@angular/material/dialog';
import { T } from 'src/app/t.const';
import { DialogConflictResolutionResult } from '../sync.model';
import { ConflictData } from '../../../pfapi/api';
import { ConflictData, VectorClock } from '../../../pfapi/api';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { DatePipe } from '@angular/common';
import { MatTooltip } from '@angular/material/tooltip';
import {
vectorClockToString,
compareVectorClocks,
VectorClockComparison,
} from '../../../pfapi/api/util/vector-clock';
@Component({
selector: 'dialog-sync-conflict',
@ -49,6 +54,15 @@ export class DialogSyncConflictComponent {
localLamport: number = this.data.local.localLamport;
lastSyncedLamport: number | null = this.data.local.lastSyncedLamport;
// Vector clock data
remoteVectorClock: VectorClock | undefined = this.data.remote.vectorClock;
localVectorClock: VectorClock | undefined = this.data.local.vectorClock;
lastSyncedVectorClock: VectorClock | null | undefined =
this.data.local.lastSyncedVectorClock;
// Vector clock comparison
vectorClockComparison: VectorClockComparison | null = this.getVectorClockComparison();
remoteLastUpdateAction: string | undefined = this.data.remote.lastUpdateAction;
localLastUpdateAction: string | undefined = this.data.local.lastUpdateAction;
lastSyncedAction: string | undefined = this.data.local.lastSyncedAction;
@ -72,4 +86,32 @@ export class DialogSyncConflictComponent {
if (!lamport) return '-';
return lamport.toString().slice(-4);
}
getVectorClockComparison(): VectorClockComparison | null {
if (!this.localVectorClock || !this.remoteVectorClock) {
return null;
}
return compareVectorClocks(this.localVectorClock, this.remoteVectorClock);
}
getVectorClockString(clock?: VectorClock | null): string {
if (!clock) return '-';
return vectorClockToString(clock);
}
getVectorClockComparisonLabel(): string {
if (!this.vectorClockComparison) return '-';
switch (this.vectorClockComparison) {
case VectorClockComparison.EQUAL:
return 'Equal';
case VectorClockComparison.LESS_THAN:
return 'Local < Remote';
case VectorClockComparison.GREATER_THAN:
return 'Local > Remote';
case VectorClockComparison.CONCURRENT:
return 'Concurrent (True Conflict)';
default:
return '-';
}
}
}

View file

@ -117,6 +117,17 @@ export class SyncWrapperService {
lastSync: r.conflictData?.local.lastSyncedUpdate,
conflictData: r.conflictData,
});
// Enhanced debugging for vector clock issues
console.log('CONFLICT DEBUG - Vector Clock Analysis:', {
localVectorClock: r.conflictData?.local.vectorClock,
remoteVectorClock: r.conflictData?.remote.vectorClock,
localLastSyncedVectorClock: r.conflictData?.local.lastSyncedVectorClock,
localLamport: r.conflictData?.local.localLamport,
remoteLamport: r.conflictData?.remote.localLamport,
conflictReason: r.conflictData?.reason,
additional: r.conflictData?.additional,
});
const res = await this._openConflictDialog$(
r.conflictData as ConflictData,
).toPromise();

View file

@ -15,8 +15,13 @@ describe('MetaModelCtrl', () => {
mockDb = jasmine.createSpyObj('Database', ['save', 'load']);
mockEventEmitter = jasmine.createSpyObj('PFEventEmitter', ['emit']);
// Default behavior for load - return null initially
mockDb.load.and.returnValue(Promise.resolve(null));
// Default behavior for load - return null initially for meta, but return client ID
mockDb.load.and.callFake(<T = unknown>(key: string): Promise<T | void> => {
if (key === MetaModelCtrl.CLIENT_ID) {
return Promise.resolve('test-client-id' as any);
}
return Promise.resolve(null as any);
});
mockDb.save.and.returnValue(Promise.resolve());
metaModelCtrl = new MetaModelCtrl(mockDb, mockEventEmitter, crossModelVersion);
@ -45,7 +50,12 @@ describe('MetaModelCtrl', () => {
lastSyncedLamport: 5,
};
mockDb.load.and.returnValue(Promise.resolve(existingMeta));
mockDb.load.and.callFake(<T = unknown>(key: string): Promise<T | void> => {
if (key === MetaModelCtrl.CLIENT_ID) {
return Promise.resolve('test-client-id' as any);
}
return Promise.resolve(existingMeta as any);
});
const newCtrl = new MetaModelCtrl(mockDb, mockEventEmitter, crossModelVersion);
const meta = await newCtrl.load();
@ -68,7 +78,12 @@ describe('MetaModelCtrl', () => {
localLamport: 5,
lastSyncedLamport: null,
};
mockDb.load.and.returnValue(Promise.resolve(initialMeta));
mockDb.load.and.callFake(<T = unknown>(key: string): Promise<T | void> => {
if (key === MetaModelCtrl.CLIENT_ID) {
return Promise.resolve('test-client-id' as any);
}
return Promise.resolve(initialMeta as any);
});
// Create a new controller and wait for it to load
testCtrl = new MetaModelCtrl(mockDb, mockEventEmitter, crossModelVersion);
@ -78,14 +93,14 @@ describe('MetaModelCtrl', () => {
mockDb.save.calls.reset();
});
it('should increment localLamport when updating a model', () => {
it('should increment localLamport when updating a model', async () => {
const modelCfg: ModelCfg<any> = {
defaultData: {},
isLocalOnly: false,
isMainFileModel: false,
};
testCtrl.updateRevForModel('testModel', modelCfg);
await testCtrl.updateRevForModel('testModel', modelCfg);
expect(mockDb.save).toHaveBeenCalledWith(
MetaModelCtrl.META_MODEL_ID,
@ -96,14 +111,14 @@ describe('MetaModelCtrl', () => {
);
});
it('should set lastUpdateAction when updating a model', () => {
it('should set lastUpdateAction when updating a model', async () => {
const modelCfg: ModelCfg<any> = {
defaultData: {},
isLocalOnly: false,
isMainFileModel: false,
};
testCtrl.updateRevForModel('testModel', modelCfg);
await testCtrl.updateRevForModel('testModel', modelCfg);
expect(mockDb.save).toHaveBeenCalledWith(
MetaModelCtrl.META_MODEL_ID,
@ -114,26 +129,26 @@ describe('MetaModelCtrl', () => {
);
});
it('should not update for local-only models', () => {
it('should not update for local-only models', async () => {
const modelCfg: ModelCfg<any> = {
defaultData: {},
isLocalOnly: true,
isMainFileModel: false,
};
testCtrl.updateRevForModel('testModel', modelCfg);
await testCtrl.updateRevForModel('testModel', modelCfg);
expect(mockDb.save).not.toHaveBeenCalled();
});
it('should update revMap for non-main file models', () => {
it('should update revMap for non-main file models', async () => {
const modelCfg: ModelCfg<any> = {
defaultData: {},
isLocalOnly: false,
isMainFileModel: false,
};
testCtrl.updateRevForModel('testModel', modelCfg);
await testCtrl.updateRevForModel('testModel', modelCfg);
expect(mockDb.save).toHaveBeenCalledWith(
MetaModelCtrl.META_MODEL_ID,
@ -146,27 +161,27 @@ describe('MetaModelCtrl', () => {
);
});
it('should not update revMap for main file models', () => {
it('should not update revMap for main file models', async () => {
const modelCfg: ModelCfg<any> = {
defaultData: {},
isLocalOnly: false,
isMainFileModel: true,
};
metaModelCtrl.updateRevForModel('mainModel', modelCfg);
await testCtrl.updateRevForModel('mainModel', modelCfg);
const savedMeta = mockDb.save.calls.mostRecent().args[1] as LocalMeta;
expect(savedMeta.revMap).toEqual({});
});
it('should emit events after update', () => {
it('should emit events after update', async () => {
const modelCfg: ModelCfg<any> = {
defaultData: {},
isLocalOnly: false,
isMainFileModel: false,
};
testCtrl.updateRevForModel('testModel', modelCfg);
await testCtrl.updateRevForModel('testModel', modelCfg);
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
'metaModelChange',
@ -189,7 +204,12 @@ describe('MetaModelCtrl', () => {
localLamport: undefined as any,
lastSyncedLamport: undefined as any,
};
mockDb.load.and.returnValue(Promise.resolve(oldMeta));
mockDb.load.and.callFake(<T = unknown>(key: string): Promise<T | void> => {
if (key === MetaModelCtrl.CLIENT_ID) {
return Promise.resolve('test-client-id' as any);
}
return Promise.resolve(oldMeta as any);
});
const newCtrl = new MetaModelCtrl(mockDb, mockEventEmitter, crossModelVersion);
await newCtrl.load();
@ -203,7 +223,7 @@ describe('MetaModelCtrl', () => {
isMainFileModel: false,
};
newCtrl.updateRevForModel('testModel', modelCfg);
await newCtrl.updateRevForModel('testModel', modelCfg);
expect(mockDb.save).toHaveBeenCalledWith(
MetaModelCtrl.META_MODEL_ID,

View file

@ -11,6 +11,8 @@ import {
import { validateLocalMeta } from '../util/validate-local-meta';
import { PFEventEmitter } from '../util/events';
import { devError } from '../../../util/dev-error';
import { incrementVectorClock } from '../util/vector-clock';
import { getVectorClock, setVectorClock } from '../util/backwards-compat';
export const DEFAULT_META_MODEL: LocalMeta = {
crossModelVersion: 1,
@ -20,6 +22,8 @@ export const DEFAULT_META_MODEL: LocalMeta = {
lastSyncedUpdate: null,
localLamport: 0,
lastSyncedLamport: null,
vectorClock: {},
lastSyncedVectorClock: null,
};
/**
@ -64,11 +68,11 @@ export class MetaModelCtrl {
* @param isIgnoreDBLock Whether to ignore database locks
* @throws {MetaNotReadyError} When metamodel is not loaded yet
*/
updateRevForModel<MT extends ModelBase>(
async updateRevForModel<MT extends ModelBase>(
modelId: string,
modelCfg: ModelCfg<MT>,
isIgnoreDBLock = false,
): void {
): Promise<void> {
pfLog(2, `${MetaModelCtrl.L}.${this.updateRevForModel.name}()`, modelId, {
modelCfg,
inMemory: this._metaModelInMemory,
@ -80,31 +84,54 @@ export class MetaModelCtrl {
const metaModel = this._getMetaModelOrThrow(modelId, modelCfg);
const timestamp = Date.now();
// Get client ID for vector clock
const clientId = await this.loadClientId();
// Create action string (max 100 chars)
const actionStr = `${modelId} => ${new Date(timestamp).toISOString()}`;
const lastUpdateAction =
actionStr.length > 100 ? actionStr.substring(0, 97) + '...' : actionStr;
this.save(
{
...metaModel,
lastUpdate: timestamp,
lastUpdateAction,
localLamport: this._incrementLamport(metaModel.localLamport || 0),
// Update vector clock - migrate from Lamport if needed
let currentVectorClock = getVectorClock(metaModel, clientId);
if (!currentVectorClock && metaModel.localLamport > 0) {
// First time creating vector clock - migrate from Lamport timestamp
currentVectorClock = { [clientId]: metaModel.localLamport };
pfLog(
2,
`${MetaModelCtrl.L}.${this.updateRevForModel.name}() migrating Lamport to vector clock`,
{
clientId,
localLamport: metaModel.localLamport,
newVectorClock: currentVectorClock,
},
);
}
...(modelCfg.isMainFileModel
? {}
: {
revMap: {
...metaModel.revMap,
[modelId]: timestamp.toString(),
},
}),
// as soon as we save a related model, we are using the local crossModelVersion (while other updates might be from importing remote data)
crossModelVersion: this.crossModelVersion,
},
isIgnoreDBLock,
);
const newVectorClock = incrementVectorClock(currentVectorClock || {}, clientId);
const updatedMeta = {
...metaModel,
lastUpdate: timestamp,
lastUpdateAction,
localLamport: this._incrementLamport(metaModel.localLamport || 0),
...(modelCfg.isMainFileModel
? {}
: {
revMap: {
...metaModel.revMap,
[modelId]: timestamp.toString(),
},
}),
// as soon as we save a related model, we are using the local crossModelVersion (while other updates might be from importing remote data)
crossModelVersion: this.crossModelVersion,
};
// Set vector clock using backwards compatibility helper
setVectorClock(updatedMeta, newVectorClock, clientId);
await this.save(updatedMeta, isIgnoreDBLock);
}
/**

View file

@ -38,7 +38,7 @@ export class ModelCtrl<MT extends ModelBase> {
* @param p
* @returns Promise resolving after save operation
*/
save(
async save(
data: MT,
p?: { isUpdateRevAndLastUpdate: boolean; isIgnoreDBLock?: boolean },
): Promise<unknown> {
@ -70,7 +70,11 @@ export class ModelCtrl<MT extends ModelBase> {
// Update revision if requested
const isIgnoreDBLock = !!p?.isIgnoreDBLock;
if (p?.isUpdateRevAndLastUpdate) {
this._metaModel.updateRevForModel(this.modelId, this.modelCfg, isIgnoreDBLock);
await this._metaModel.updateRevForModel(
this.modelId,
this.modelCfg,
isIgnoreDBLock,
);
}
// Save data to database

View file

@ -98,6 +98,13 @@ export interface MetaFileBase {
localChangeCounter?: number;
lastSyncedChangeCounter?: number | null;
lastSyncedAction?: string;
// Vector clock fields for improved conflict detection
vectorClock?: VectorClock;
lastSyncedVectorClock?: VectorClock | null;
}
export interface VectorClock {
[clientId: string]: number;
}
export interface RemoteMeta extends MetaFileBase {
@ -105,6 +112,15 @@ export interface RemoteMeta extends MetaFileBase {
isFullData?: boolean;
}
export interface UploadMeta
extends Omit<
RemoteMeta,
'lastSyncedLamport' | 'lastSyncedChangeCounter' | 'lastSyncedVectorClock'
> {
lastSyncedLamport: null;
lastSyncedChangeCounter?: null;
}
export interface LocalMeta extends MetaFileBase {
lastSyncedUpdate: number | null;
metaRev: string | null;

View file

@ -0,0 +1,132 @@
# Sync System Overview
This directory contains the synchronization implementation for Super Productivity, enabling data sync across devices through various providers (Dropbox, WebDAV, Local File).
## Key Components
### Core Services
- **`sync.service.ts`** - Main orchestrator for sync operations
- **`meta-sync.service.ts`** - Handles sync metadata file operations
- **`model-sync.service.ts`** - Manages individual model synchronization
- **`conflict-handler.service.ts`** - User interface for conflict resolution
### Sync Providers
Located in `sync-providers/`:
- Dropbox
- WebDAV
- Local File System
### Sync Algorithm
The sync system uses a hybrid approach combining:
1. **Physical Timestamps** (`lastUpdate`) - For ordering events
2. **Change Counters** (`localLamport`) - For detecting local modifications
3. **Vector Clocks** (`vectorClock`) - For accurate causality tracking
4. **Sync State** (`lastSyncedUpdate`, `lastSyncedLamport`) - To track last successful sync
## How Sync Works
### 1. Change Detection
When a user modifies data:
```typescript
// In meta-model-ctrl.ts
lastUpdate = Date.now();
localLamport = localLamport + 1;
vectorClock[clientId] = vectorClock[clientId] + 1;
```
### 2. Sync Status Determination
The system compares local and remote metadata to determine:
- **InSync**: No changes needed
- **UpdateLocal**: Download remote changes
- **UpdateRemote**: Upload local changes
- **Conflict**: Both have changes (requires user resolution)
### 3. Conflict Detection
Uses vector clocks for accurate detection:
```typescript
const comparison = compareVectorClocks(localVector, remoteVector);
if (comparison === VectorClockComparison.CONCURRENT) {
// True conflict - changes were made independently
}
```
### 4. Data Transfer
- **Upload**: Sends changed models and updated metadata
- **Download**: Retrieves and merges remote changes
- **Conflict Resolution**: User chooses which version to keep
## Key Files
### Metadata Structure
```typescript
interface LocalMeta {
lastUpdate: number; // Physical timestamp
lastSyncedUpdate: number; // Last synced timestamp
localLamport: number; // Change counter
lastSyncedLamport: number; // Last synced counter
vectorClock?: VectorClock; // Causality tracking
revMap: RevMap; // Model revision map
crossModelVersion: number; // Schema version
}
```
### Important Considerations
1. **NOT Pure Lamport Clocks**: We don't increment on receive to prevent sync loops
2. **Backwards Compatibility**: Supports migration from older versions
3. **Conflict Minimization**: Vector clocks reduce false conflicts
4. **Atomic Operations**: Meta file serves as transaction coordinator
## Common Sync Scenarios
### Scenario 1: Simple Update
1. Device A makes changes
2. Device A uploads to cloud
3. Device B downloads changes
4. Both devices now in sync
### Scenario 2: Conflict Resolution
1. Device A and B both make changes
2. Device A syncs first
3. Device B detects conflict
4. User chooses which version to keep
5. Chosen version propagates to all devices
### Scenario 3: Multiple Devices
1. Devices A, B, C all synced
2. Device A makes changes while offline
3. Device B makes different changes
4. Device C acts as intermediary
5. Vector clocks ensure proper ordering
## Debugging Sync Issues
1. Enable verbose logging in `pfapi/api/util/log.ts`
2. Check vector clock states in sync status
3. Verify client IDs are stable
4. Ensure metadata files are properly updated
## Future Improvements
- [ ] Automatic conflict resolution strategies
- [ ] Partial sync for large datasets
- [ ] Real-time sync notifications
- [ ] Sync history visualization
For detailed vector clock documentation, see [vector-clocks.md](../../../docs/sync/vector-clocks.md).

View file

@ -157,8 +157,13 @@ describe('SyncService', () => {
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type,prefer-arrow/prefer-arrow-functions
function setupMetaModelCtrl() {
const ctrl = jasmine.createSpyObj<MetaModelCtrl>('MetaModelCtrl', ['load', 'save']);
const ctrl = jasmine.createSpyObj<MetaModelCtrl>('MetaModelCtrl', [
'load',
'save',
'loadClientId',
]);
ctrl.load.and.returnValue(Promise.resolve(createDefaultLocalMeta()));
ctrl.loadClientId.and.returnValue(Promise.resolve('test-client-id'));
return ctrl;
}

View file

@ -6,6 +6,7 @@ import {
ModelCfgToModelCtrl,
RemoteMeta,
RevMap,
UploadMeta,
} from '../pfapi.model';
import { SyncProviderServiceInterface } from './sync-provider.interface';
import { MiniObservable } from '../util/mini-observable';
@ -28,6 +29,12 @@ import { Pfapi } from '../pfapi';
import { modelVersionCheck, ModelVersionCheckResult } from '../util/model-version-check';
import { MetaSyncService } from './meta-sync.service';
import { ModelSyncService } from './model-sync.service';
import { mergeVectorClocks, incrementVectorClock } from '../util/vector-clock';
import {
getVectorClock,
setVectorClock,
setLastSyncedVectorClock,
} from '../util/backwards-compat';
/**
* Sync Service for Super Productivity
@ -176,12 +183,24 @@ export class SyncService<const MD extends ModelCfgs> {
lastSyncedLamport: localMeta.lastSyncedLamport,
localLamport: localMeta.localLamport,
});
await this._metaFileSyncService.saveLocal({
// Get client ID for vector clock operations
const clientId = await this._metaModelCtrl.loadClientId();
const localVector = getVectorClock(localMeta, clientId);
const updatedMeta = {
...localMeta,
lastSyncedUpdate: localMeta.lastUpdate,
lastSyncedLamport: localMeta.localLamport || 0,
metaRev: remoteMetaRev,
});
};
// Update vector clock if available
if (localVector) {
setLastSyncedVectorClock(updatedMeta, localVector, clientId);
}
await this._metaFileSyncService.saveLocal(updatedMeta);
}
return { status };
case SyncStatus.Conflict:
@ -252,14 +271,52 @@ export class SyncService<const MD extends ModelCfgs> {
{ localLamport: local.localLamport, remoteLamport, currentLamport, nextLamport },
);
// Get client ID for vector clock operations
const clientId = await this._metaModelCtrl.loadClientId();
let localVector = getVectorClock(local, clientId) || {};
// For force uploads, try to merge with remote vector clock to preserve all components
if (isForceUpload) {
try {
const { remoteMeta } = await this._metaFileSyncService.download();
const remoteVector = getVectorClock(remoteMeta, clientId);
if (remoteVector) {
localVector = mergeVectorClocks(localVector, remoteVector);
pfLog(
2,
`${SyncService.L}.${this.uploadAll.name}(): Merged remote vector clock for force upload`,
{
localOriginal: getVectorClock(local, clientId),
remote: remoteVector,
merged: localVector,
},
);
}
} catch (e) {
pfLog(
1,
`${SyncService.L}.${this.uploadAll.name}(): Could not get remote vector clock for merge`,
e,
);
// Continue with local vector clock only
}
}
const newVector = incrementVectorClock(localVector, clientId);
const updatedMeta = {
...local,
lastUpdate: Date.now(),
localLamport: nextLamport,
// Important: Don't update lastSyncedLamport yet
// It will be updated after successful upload
};
// Update vector clock
setVectorClock(updatedMeta, newVector, clientId);
await this._metaModelCtrl.save(
{
...local,
lastUpdate: Date.now(),
localLamport: nextLamport,
// Important: Don't update lastSyncedLamport yet
// It will be updated after successful upload
},
updatedMeta,
// NOTE we always ignore db lock while syncing
true,
);
@ -267,6 +324,10 @@ export class SyncService<const MD extends ModelCfgs> {
}
try {
// Get client ID for vector clock operations
const clientId = await this._metaModelCtrl.loadClientId();
const localVector = getVectorClock(local, clientId);
return await this.uploadToRemote(
{
crossModelVersion: local.crossModelVersion,
@ -276,6 +337,7 @@ export class SyncService<const MD extends ModelCfgs> {
mainModelData: {},
localLamport: local.localLamport || 0,
lastSyncedLamport: null,
vectorClock: localVector,
},
{
...local,
@ -316,6 +378,9 @@ export class SyncService<const MD extends ModelCfgs> {
revMap: {},
localLamport: 0,
lastSyncedLamport: null,
// Include vector clock fields to prevent comparison issues
vectorClock: {},
lastSyncedVectorClock: null,
};
return await this.downloadToLocal(
@ -369,7 +434,15 @@ export class SyncService<const MD extends ModelCfgs> {
localRevMap: local.revMap,
});
await this._metaFileSyncService.saveLocal({
// Get client ID for vector clock operations
const clientId = await this._metaModelCtrl.loadClientId();
// Merge vector clocks if available
const localVector = getVectorClock(local, clientId);
const remoteVector = getVectorClock(remote, clientId);
const mergedVector = mergeVectorClocks(localVector, remoteVector);
const updatedMeta = {
// shared
lastUpdate: remote.lastUpdate,
crossModelVersion: remote.crossModelVersion,
@ -381,7 +454,15 @@ export class SyncService<const MD extends ModelCfgs> {
lastSyncedUpdate: remote.lastUpdate,
lastSyncedLamport: Math.max(local.localLamport || 0, remote.localLamport || 0),
metaRev: remoteRev,
});
};
// Update vector clocks if we have them
if (mergedVector) {
setVectorClock(updatedMeta, mergedVector, clientId);
setLastSyncedVectorClock(updatedMeta, mergedVector, clientId);
}
await this._metaFileSyncService.saveLocal(updatedMeta);
return;
}
@ -460,7 +541,15 @@ export class SyncService<const MD extends ModelCfgs> {
await this._modelSyncService.updateLocalMainModelsFromRemoteMetaFile(remote);
}
await this._metaFileSyncService.saveLocal({
// Get client ID for vector clock operations
const clientId = await this._metaModelCtrl.loadClientId();
// Merge vector clocks if available
const localVector = getVectorClock(local, clientId);
const remoteVector = getVectorClock(remote, clientId);
const mergedVector = mergeVectorClocks(localVector, remoteVector);
const updatedMeta = {
metaRev: remoteRev,
lastSyncedUpdate: remote.lastUpdate,
lastUpdate: remote.lastUpdate,
@ -474,7 +563,15 @@ export class SyncService<const MD extends ModelCfgs> {
...realRemoteRevMap,
}),
crossModelVersion: remote.crossModelVersion,
});
};
// Update vector clocks if we have them
if (mergedVector) {
setVectorClock(updatedMeta, mergedVector, clientId);
setLastSyncedVectorClock(updatedMeta, mergedVector, clientId);
}
await this._metaFileSyncService.saveLocal(updatedMeta);
}
/**
@ -509,16 +606,23 @@ export class SyncService<const MD extends ModelCfgs> {
? await this._pfapiMain.getAllSyncModelData()
: await this._modelSyncService.getMainFileModelDataForUpload();
// Get client ID for vector clock operations
const clientId = await this._metaModelCtrl.loadClientId();
const localVector = getVectorClock(local, clientId);
const uploadMeta: UploadMeta = {
revMap: local.revMap,
lastUpdate: local.lastUpdate,
crossModelVersion: local.crossModelVersion,
mainModelData,
localLamport: local.localLamport || 0,
lastSyncedLamport: null,
vectorClock: localVector,
...(syncProvider.isLimitedToSingleFileSync ? { isFullData: true } : {}),
};
const metaRevAfterUpdate = await this._metaFileSyncService.upload(
{
revMap: local.revMap,
lastUpdate: local.lastUpdate,
crossModelVersion: local.crossModelVersion,
mainModelData,
localLamport: local.localLamport || 0,
lastSyncedLamport: null,
...(syncProvider.isLimitedToSingleFileSync ? { isFullData: true } : {}),
},
uploadMeta,
lastRemoteRev,
);
@ -534,13 +638,20 @@ export class SyncService<const MD extends ModelCfgs> {
},
);
await this._metaFileSyncService.saveLocal({
const updatedMeta = {
...local,
lastSyncedUpdate: local.lastUpdate,
lastSyncedLamport: local.localLamport || 0,
lastSyncedAction: `Uploaded single file at ${new Date().toISOString()}`,
metaRev: metaRevAfterUpdate,
});
};
// Update vector clock if available
if (localVector) {
setLastSyncedVectorClock(updatedMeta, localVector, clientId);
}
await this._metaFileSyncService.saveLocal(updatedMeta);
pfLog(
2,
@ -606,7 +717,12 @@ export class SyncService<const MD extends ModelCfgs> {
// Validate and upload the final revMap
const validatedRevMap = validateRevMap(realRevMap);
const metaRevAfterUpload = await this._metaFileSyncService.upload({
// Get client ID for vector clock operations
const clientId = await this._metaModelCtrl.loadClientId();
const localVector = getVectorClock(local, clientId);
const uploadMeta: UploadMeta = {
revMap: validatedRevMap,
lastUpdate: local.lastUpdate,
crossModelVersion: local.crossModelVersion,
@ -614,10 +730,13 @@ export class SyncService<const MD extends ModelCfgs> {
await this._modelSyncService.getMainFileModelDataForUpload(completeData),
localLamport: local.localLamport || 0,
lastSyncedLamport: null,
});
vectorClock: localVector,
};
const metaRevAfterUpload = await this._metaFileSyncService.upload(uploadMeta);
// Update local after successful upload
await this._metaFileSyncService.saveLocal({
const updatedMeta = {
// leave as is basically
lastUpdate: local.lastUpdate,
crossModelVersion: local.crossModelVersion,
@ -629,7 +748,14 @@ export class SyncService<const MD extends ModelCfgs> {
lastSyncedAction: `Uploaded ${toUpdate.length} models at ${new Date().toISOString()}`,
revMap: validatedRevMap,
metaRev: metaRevAfterUpload,
});
};
// Update vector clock if available
if (localVector) {
setLastSyncedVectorClock(updatedMeta, localVector, clientId);
}
await this._metaFileSyncService.saveLocal(updatedMeta);
}
/**

View file

@ -5,6 +5,11 @@ import {
setLocalChangeCounter,
setLastSyncedChangeCounter,
createBackwardsCompatibleMeta,
getVectorClock,
getLastSyncedVectorClock,
setVectorClock,
setLastSyncedVectorClock,
hasVectorClocks,
} from './backwards-compat';
describe('backwards-compat', () => {
@ -182,4 +187,224 @@ describe('backwards-compat', () => {
expect(result.lastSyncedAction).toBe('sync action');
});
});
describe('Vector Clock Functions', () => {
const createBaseMeta = (): LocalMeta => ({
localLamport: 0,
lastUpdate: 0,
lastSyncedUpdate: null,
lastSyncedLamport: null,
revMap: {},
metaRev: null,
crossModelVersion: 1,
});
describe('getVectorClock', () => {
it('should return existing vector clock', () => {
const meta = createBaseMeta();
meta.vectorClock = { client1: 5, client2: 3 };
const result = getVectorClock(meta, 'client1');
expect(result).toEqual({ client1: 5, client2: 3 });
});
it('should migrate from Lamport timestamp', () => {
const meta = createBaseMeta();
meta.localLamport = 7;
const result = getVectorClock(meta, 'client1');
expect(result).toEqual({ client1: 7 });
});
it('should migrate from localChangeCounter', () => {
const meta = createBaseMeta();
meta.localChangeCounter = 9;
const result = getVectorClock(meta, 'client1');
expect(result).toEqual({ client1: 9 });
});
it('should return undefined for zero Lamport', () => {
const meta = createBaseMeta();
meta.localLamport = 0;
const result = getVectorClock(meta, 'client1');
expect(result).toBeUndefined();
});
it('should return undefined when no data available', () => {
const meta = createBaseMeta();
const result = getVectorClock(meta, 'client1');
expect(result).toBeUndefined();
});
it('should not return empty vector clock', () => {
const meta = createBaseMeta();
meta.vectorClock = {};
const result = getVectorClock(meta, 'client1');
expect(result).toBeUndefined();
});
});
describe('getLastSyncedVectorClock', () => {
it('should return existing last synced vector clock', () => {
const meta = createBaseMeta();
meta.lastSyncedVectorClock = { client1: 4, client2: 2 };
const result = getLastSyncedVectorClock(meta, 'client1');
expect(result).toEqual({ client1: 4, client2: 2 });
});
it('should migrate from last synced Lamport', () => {
const meta = createBaseMeta();
meta.lastSyncedLamport = 6;
const result = getLastSyncedVectorClock(meta, 'client1');
expect(result).toEqual({ client1: 6 });
});
it('should migrate from lastSyncedChangeCounter', () => {
const meta = createBaseMeta();
meta.lastSyncedChangeCounter = 8;
const result = getLastSyncedVectorClock(meta, 'client1');
expect(result).toEqual({ client1: 8 });
});
it('should return null for null Lamport', () => {
const meta = createBaseMeta();
meta.lastSyncedLamport = null;
const result = getLastSyncedVectorClock(meta, 'client1');
expect(result).toBe(null);
});
it('should return null for zero Lamport', () => {
const meta = createBaseMeta();
meta.lastSyncedLamport = 0;
const result = getLastSyncedVectorClock(meta, 'client1');
expect(result).toBe(null);
});
});
describe('setVectorClock', () => {
it('should set vector clock and update Lamport', () => {
const meta = createBaseMeta();
const vectorClock = { client1: 10, client2: 5 };
setVectorClock(meta, vectorClock, 'client1');
expect(meta.vectorClock).toEqual(vectorClock);
expect(meta.localLamport).toBe(10);
expect(meta.localChangeCounter).toBe(10);
});
it('should use 0 for missing client component', () => {
const meta = createBaseMeta();
const vectorClock = { client2: 5 };
setVectorClock(meta, vectorClock, 'client1');
expect(meta.vectorClock).toEqual(vectorClock);
expect(meta.localLamport).toBe(0);
expect(meta.localChangeCounter).toBe(0);
});
it('should handle empty vector clock', () => {
const meta = createBaseMeta();
const vectorClock = {};
setVectorClock(meta, vectorClock, 'client1');
expect(meta.vectorClock).toEqual({});
expect(meta.localLamport).toBe(0);
expect(meta.localChangeCounter).toBe(0);
});
});
describe('setLastSyncedVectorClock', () => {
it('should set last synced vector clock and update Lamport', () => {
const meta = createBaseMeta();
const vectorClock = { client1: 8, client2: 4 };
setLastSyncedVectorClock(meta, vectorClock, 'client1');
expect(meta.lastSyncedVectorClock).toEqual(vectorClock);
expect(meta.lastSyncedLamport).toBe(8);
expect(meta.lastSyncedChangeCounter).toBe(8);
});
it('should handle null vector clock', () => {
const meta = createBaseMeta();
meta.lastSyncedLamport = 5;
meta.lastSyncedChangeCounter = 5;
setLastSyncedVectorClock(meta, null, 'client1');
expect(meta.lastSyncedVectorClock).toBe(null);
expect(meta.lastSyncedLamport).toBeNull();
expect(meta.lastSyncedChangeCounter).toBeNull();
});
it('should use 0 for missing client component', () => {
const meta = createBaseMeta();
const vectorClock = { client2: 4 };
setLastSyncedVectorClock(meta, vectorClock, 'client1');
expect(meta.lastSyncedVectorClock).toEqual(vectorClock);
expect(meta.lastSyncedLamport).toBe(0);
expect(meta.lastSyncedChangeCounter).toBe(0);
});
});
describe('hasVectorClocks', () => {
it('should return true when both have non-empty vector clocks', () => {
const local: LocalMeta = {
...createBaseMeta(),
vectorClock: { client1: 5 },
};
const remote: RemoteMeta = {
...createBaseMeta(),
vectorClock: { client2: 3 },
mainModelData: {},
};
expect(hasVectorClocks(local, remote)).toBe(true);
});
it('should return false when local missing vector clock', () => {
const local = createBaseMeta();
const remote: RemoteMeta = {
...createBaseMeta(),
vectorClock: { client2: 3 },
mainModelData: {},
};
expect(hasVectorClocks(local, remote)).toBe(false);
});
it('should return false when remote missing vector clock', () => {
const local: LocalMeta = {
...createBaseMeta(),
vectorClock: { client1: 5 },
};
const remote: RemoteMeta = {
...createBaseMeta(),
mainModelData: {},
};
expect(hasVectorClocks(local, remote)).toBe(false);
});
it('should return false when both have empty vector clocks', () => {
const local: LocalMeta = {
...createBaseMeta(),
vectorClock: {},
};
const remote: RemoteMeta = {
...createBaseMeta(),
vectorClock: {},
mainModelData: {},
};
expect(hasVectorClocks(local, remote)).toBe(false);
});
it('should return false when one has empty vector clock', () => {
const local: LocalMeta = {
...createBaseMeta(),
vectorClock: {},
};
const remote: RemoteMeta = {
...createBaseMeta(),
vectorClock: { client1: 5 },
mainModelData: {},
};
expect(hasVectorClocks(local, remote)).toBe(false);
});
});
});
});

View file

@ -1,9 +1,11 @@
import { LocalMeta, RemoteMeta } from '../pfapi.model';
import { LocalMeta, RemoteMeta, VectorClock } from '../pfapi.model';
import { lamportToVectorClock, isVectorClockEmpty } from './vector-clock';
import { pfLog } from './log';
/**
* Utility functions for backwards compatibility with old field names.
* This allows gradual migration from localLamport/lastSyncedLamport to
* localChangeCounter/lastSyncedChangeCounter.
* localChangeCounter/lastSyncedChangeCounter and to vector clocks.
*/
/**
@ -72,6 +74,18 @@ export const createBackwardsCompatibleMeta = <T extends LocalMeta | RemoteMeta>(
result.localChangeCounter === undefined
) {
result.localChangeCounter = result.localLamport;
} else if (
result.localChangeCounter !== undefined &&
result.localLamport !== undefined &&
result.localChangeCounter !== result.localLamport
) {
// Warn about field mismatch but use the newer field
pfLog(1, 'WARN: Mismatch between localChangeCounter and localLamport fields', {
localChangeCounter: result.localChangeCounter,
localLamport: result.localLamport,
using: 'localChangeCounter',
});
result.localLamport = result.localChangeCounter;
}
if (
@ -84,7 +98,124 @@ export const createBackwardsCompatibleMeta = <T extends LocalMeta | RemoteMeta>(
result.lastSyncedChangeCounter === undefined
) {
result.lastSyncedChangeCounter = result.lastSyncedLamport;
} else if (
result.lastSyncedChangeCounter !== undefined &&
result.lastSyncedLamport !== undefined &&
result.lastSyncedChangeCounter !== result.lastSyncedLamport
) {
// Warn about field mismatch but use the newer field
pfLog(
1,
'WARN: Mismatch between lastSyncedChangeCounter and lastSyncedLamport fields',
{
lastSyncedChangeCounter: result.lastSyncedChangeCounter,
lastSyncedLamport: result.lastSyncedLamport,
using: 'lastSyncedChangeCounter',
},
);
result.lastSyncedLamport = result.lastSyncedChangeCounter;
}
return result;
};
/**
* Get the vector clock, creating it from Lamport timestamp if needed
* @param meta The metadata object
* @param clientId The client ID to use for migration
* @returns The vector clock
*/
export const getVectorClock = (
meta: LocalMeta | RemoteMeta,
clientId: string,
): VectorClock | undefined => {
// Return existing vector clock if available
if (meta.vectorClock && !isVectorClockEmpty(meta.vectorClock)) {
return meta.vectorClock;
}
// Migrate from Lamport timestamp if available
const changeCounter = getLocalChangeCounter(meta);
if (changeCounter > 0) {
return lamportToVectorClock(changeCounter, clientId);
}
return undefined;
};
/**
* Get the last synced vector clock, creating it from Lamport timestamp if needed
* @param meta The metadata object
* @param clientId The client ID to use for migration
* @returns The last synced vector clock
*/
export const getLastSyncedVectorClock = (
meta: LocalMeta | RemoteMeta,
clientId: string,
): VectorClock | null => {
// Return existing vector clock if available
if (meta.lastSyncedVectorClock && !isVectorClockEmpty(meta.lastSyncedVectorClock)) {
return meta.lastSyncedVectorClock;
}
// Migrate from Lamport timestamp if available
const lastSyncedCounter = getLastSyncedChangeCounter(meta);
if (lastSyncedCounter != null && lastSyncedCounter > 0) {
return lamportToVectorClock(lastSyncedCounter, clientId);
}
return null;
};
/**
* Set the vector clock and update Lamport timestamps for compatibility
* @param meta The metadata object
* @param vectorClock The vector clock to set
* @param clientId The client ID for this instance
*/
export const setVectorClock = (
meta: LocalMeta | RemoteMeta,
vectorClock: VectorClock,
clientId: string,
): void => {
meta.vectorClock = vectorClock;
// Update Lamport timestamps for backwards compatibility
// Use this client's component value
const clientValue = vectorClock[clientId] || 0;
setLocalChangeCounter(meta, clientValue);
};
/**
* Set the last synced vector clock and update Lamport timestamps for compatibility
* @param meta The metadata object
* @param vectorClock The vector clock to set (can be null)
* @param clientId The client ID for this instance
*/
export const setLastSyncedVectorClock = (
meta: LocalMeta | RemoteMeta,
vectorClock: VectorClock | null,
clientId: string,
): void => {
meta.lastSyncedVectorClock = vectorClock;
// Update Lamport timestamps for backwards compatibility
if (vectorClock) {
const clientValue = vectorClock[clientId] || 0;
setLastSyncedChangeCounter(meta, clientValue);
} else {
setLastSyncedChangeCounter(meta, null);
}
};
/**
* Check if both metadata objects have vector clocks
* @param local Local metadata
* @param remote Remote metadata
* @returns True if both have non-empty vector clocks
*/
export const hasVectorClocks = (local: LocalMeta, remote: RemoteMeta): boolean => {
return (
!isVectorClockEmpty(local.vectorClock) && !isVectorClockEmpty(remote.vectorClock)
);
};

View file

@ -0,0 +1,212 @@
import { getSyncStatusFromMetaFiles } from './get-sync-status-from-meta-files';
import { ConflictReason, SyncStatus } from '../pfapi.const';
import { LocalMeta, RemoteMeta, VectorClock } from '../pfapi.model';
import { VectorClockComparison } from './vector-clock';
describe('getSyncStatusFromMetaFiles with Vector Clocks', () => {
// Helper to create test data with vector clocks
const createMetaWithVectorClock = (
localLastUpdate: number,
remoteLastUpdate: number,
lastSyncedUpdate: number | null = null,
vectorClockData?: {
localVector?: VectorClock;
remoteVector?: VectorClock;
lastSyncedVector?: VectorClock | null;
},
): { local: LocalMeta; remote: RemoteMeta } => {
const local: LocalMeta = {
lastUpdate: localLastUpdate,
lastSyncedUpdate: lastSyncedUpdate,
crossModelVersion: 1,
metaRev: 'test-rev',
revMap: {},
localLamport: 0,
lastSyncedLamport: null,
vectorClock: vectorClockData?.localVector,
lastSyncedVectorClock: vectorClockData?.lastSyncedVector,
};
const remote: RemoteMeta = {
lastUpdate: remoteLastUpdate,
crossModelVersion: 1,
revMap: {},
mainModelData: {},
localLamport: 0,
lastSyncedLamport: null,
vectorClock: vectorClockData?.remoteVector,
};
return { local, remote };
};
describe('Vector Clock Sync Status Detection', () => {
it('should detect InSync when vector clocks are equal', () => {
const { local, remote } = createMetaWithVectorClock(2000, 1500, 1000, {
localVector: { clientA: 5, clientB: 3 },
remoteVector: { clientA: 5, clientB: 3 },
lastSyncedVector: { clientA: 5, clientB: 3 },
});
const result = getSyncStatusFromMetaFiles(remote, local);
expect(result.status).toBe(SyncStatus.InSync);
});
it('should detect UpdateLocal when remote is ahead', () => {
const { local, remote } = createMetaWithVectorClock(1500, 2000, 1000, {
localVector: { clientA: 3, clientB: 2 },
remoteVector: { clientA: 5, clientB: 3 },
lastSyncedVector: { clientA: 3, clientB: 2 },
});
const result = getSyncStatusFromMetaFiles(remote, local);
expect(result.status).toBe(SyncStatus.UpdateLocal);
});
it('should detect UpdateRemote when local is ahead', () => {
const { local, remote } = createMetaWithVectorClock(2000, 1500, 1000, {
localVector: { clientA: 5, clientB: 3 },
remoteVector: { clientA: 3, clientB: 2 },
lastSyncedVector: { clientA: 3, clientB: 2 },
});
const result = getSyncStatusFromMetaFiles(remote, local);
expect(result.status).toBe(SyncStatus.UpdateRemote);
});
it('should detect Conflict when vector clocks are concurrent', () => {
const { local, remote } = createMetaWithVectorClock(2000, 2000, 1000, {
localVector: { clientA: 5, clientB: 2 },
remoteVector: { clientA: 3, clientB: 4 },
lastSyncedVector: { clientA: 3, clientB: 2 },
});
const result = getSyncStatusFromMetaFiles(remote, local);
expect(result.status).toBe(SyncStatus.Conflict);
expect(result.conflictData).toBeDefined();
expect(result.conflictData?.reason).toBe(ConflictReason.BothNewerLastSync);
expect(result.conflictData?.additional).toEqual({
vectorClockComparison: VectorClockComparison.CONCURRENT,
localVector: '{clientA:5, clientB:2}',
remoteVector: '{clientA:3, clientB:4}',
});
});
it('should handle missing components in vector clocks', () => {
const { local, remote } = createMetaWithVectorClock(2000, 1500, 1000, {
localVector: { clientA: 5, clientB: 3, clientC: 1 },
remoteVector: { clientA: 5, clientB: 3 },
lastSyncedVector: { clientA: 5, clientB: 3 },
});
const result = getSyncStatusFromMetaFiles(remote, local);
expect(result.status).toBe(SyncStatus.UpdateRemote);
});
it('should handle empty lastSyncedVector', () => {
const { local, remote } = createMetaWithVectorClock(2000, 1500, null, {
localVector: { clientA: 5 },
remoteVector: { clientA: 3 },
lastSyncedVector: null,
});
const result = getSyncStatusFromMetaFiles(remote, local);
expect(result.status).toBe(SyncStatus.UpdateRemote);
});
it('should fall back to Lamport when vector clocks are not available', () => {
const { local, remote } = createMetaWithVectorClock(2000, 1500, 1000);
// Add Lamport data
local.localLamport = 5;
local.lastSyncedLamport = 3;
remote.localLamport = 3;
const result = getSyncStatusFromMetaFiles(remote, local);
expect(result.status).toBe(SyncStatus.UpdateRemote);
});
it('should prefer vector clocks over Lamport when both are available', () => {
const { local, remote } = createMetaWithVectorClock(2000, 2000, 1000, {
localVector: { clientA: 5, clientB: 3 },
remoteVector: { clientA: 5, clientB: 3 },
lastSyncedVector: { clientA: 5, clientB: 3 },
});
// Add conflicting Lamport data that would indicate UpdateRemote
local.localLamport = 10;
local.lastSyncedLamport = 5;
remote.localLamport = 5;
const result = getSyncStatusFromMetaFiles(remote, local);
// Should use vector clock result (InSync) not Lamport result
expect(result.status).toBe(SyncStatus.InSync);
});
it('should handle when only one side has vector clocks', () => {
const { local, remote } = createMetaWithVectorClock(2000, 1500, 1000, {
localVector: { clientA: 5 },
// remote has no vector clock
});
// Add Lamport data as fallback
local.localLamport = 5;
local.lastSyncedLamport = 3;
remote.localLamport = 3;
const result = getSyncStatusFromMetaFiles(remote, local);
// Should fall back to Lamport
expect(result.status).toBe(SyncStatus.UpdateRemote);
});
it('should detect diverged changes with partial vector clock overlap', () => {
const { local, remote } = createMetaWithVectorClock(2000, 2000, 1000, {
localVector: { clientA: 5, clientB: 2, clientC: 7 },
remoteVector: { clientA: 3, clientB: 4, clientD: 2 },
lastSyncedVector: { clientA: 3, clientB: 2 },
});
const result = getSyncStatusFromMetaFiles(remote, local);
expect(result.status).toBe(SyncStatus.Conflict);
});
it('should handle vector clock with resolved conflict scenario', () => {
// Scenario: A and B had concurrent changes, then A merged B's changes
const { local, remote } = createMetaWithVectorClock(3000, 2000, 2000, {
localVector: { clientA: 6, clientB: 4 }, // A incremented after merge
remoteVector: { clientA: 5, clientB: 4 }, // B's state before A's merge
lastSyncedVector: { clientA: 5, clientB: 4 },
});
const result = getSyncStatusFromMetaFiles(remote, local);
expect(result.status).toBe(SyncStatus.UpdateRemote);
});
});
describe('Edge Cases', () => {
it('should handle when timestamps match but vector clocks differ', () => {
const { local, remote } = createMetaWithVectorClock(2000, 2000, 1500, {
localVector: { clientA: 5 },
remoteVector: { clientA: 5 },
lastSyncedVector: { clientA: 4 },
});
const result = getSyncStatusFromMetaFiles(remote, local);
// Timestamps match exactly, so should be InSync
expect(result.status).toBe(SyncStatus.InSync);
});
it('should handle empty vector clocks', () => {
const { local, remote } = createMetaWithVectorClock(2000, 1500, 1000, {
localVector: {},
remoteVector: {},
lastSyncedVector: {},
});
// Add Lamport as fallback
local.localLamport = 5;
local.lastSyncedLamport = 3;
remote.localLamport = 3;
const result = getSyncStatusFromMetaFiles(remote, local);
// Empty vector clocks should fall back to Lamport
expect(result.status).toBe(SyncStatus.UpdateRemote);
});
});
});

View file

@ -1,4 +1,4 @@
import { ConflictData, LocalMeta, RemoteMeta } from '../pfapi.model';
import { ConflictData, LocalMeta, RemoteMeta, VectorClock } from '../pfapi.model';
import { ConflictReason, SyncStatus } from '../pfapi.const';
import {
ImpossibleError,
@ -7,7 +7,17 @@ import {
SyncInvalidTimeValuesError,
} from '../errors/errors';
import { pfLog } from './log';
import { getLocalChangeCounter, getLastSyncedChangeCounter } from './backwards-compat';
import {
getLocalChangeCounter,
getLastSyncedChangeCounter,
hasVectorClocks,
} from './backwards-compat';
import {
compareVectorClocks,
VectorClockComparison,
hasVectorClockChanges,
vectorClockToString,
} from './vector-clock';
// TODO unit test the hell out of this
export const getSyncStatusFromMetaFiles = (
@ -33,23 +43,139 @@ export const getSyncStatusFromMetaFiles = (
status: SyncStatus.UpdateRemote,
};
} else {
// Special case: When timestamps match exactly, we're in sync
// This overrides any Lamport timestamp mismatches which only indicate metadata is out of sync
if (local.lastUpdate === remote.lastUpdate) {
pfLog(2, 'Timestamps match exactly - treating as InSync', {
local,
remote,
// Check if we can use vector clocks for more accurate conflict detection
const localHasVectorClock =
local.vectorClock && Object.keys(local.vectorClock).length > 0;
const remoteHasVectorClock =
remote.vectorClock && Object.keys(remote.vectorClock).length > 0;
// Use console.log for critical debugging to ensure visibility
console.log('SYNC DEBUG - Vector clock availability check', {
localHasVectorClock,
remoteHasVectorClock,
localVectorClock: local.vectorClock,
remoteVectorClock: remote.vectorClock,
hasVectorClocksResult: hasVectorClocks(local, remote),
localLastUpdate: local.lastUpdate,
remoteLastUpdate: remote.lastUpdate,
localLastSyncedUpdate: local.lastSyncedUpdate,
});
// Try to use vector clocks first if both sides have them
if (hasVectorClocks(local, remote)) {
// Extract vector clocks directly since we're comparing full clocks
// Don't use backwards compatibility functions here as they require client ID for migration
const localVector = local.vectorClock!;
const remoteVector = remote.vectorClock!;
const lastSyncedVector = local.lastSyncedVectorClock;
console.log('SYNC DEBUG - Using vector clocks for sync status', {
localVector: vectorClockToString(localVector),
remoteVector: vectorClockToString(remoteVector),
lastSyncedVector: vectorClockToString(lastSyncedVector),
localVectorRaw: localVector,
remoteVectorRaw: remoteVector,
lastSyncedVectorRaw: lastSyncedVector,
});
return {
status: SyncStatus.InSync,
};
const vectorResult = _checkForUpdateVectorClock({
localVector,
remoteVector,
lastSyncedVector: lastSyncedVector || null,
});
switch (vectorResult) {
case UpdateCheckResult.InSync:
return {
status: SyncStatus.InSync,
};
case UpdateCheckResult.LocalUpdateRequired:
return {
status: SyncStatus.UpdateLocal,
};
case UpdateCheckResult.RemoteUpdateRequired:
return {
status: SyncStatus.UpdateRemote,
};
case UpdateCheckResult.DataDiverged:
return {
status: SyncStatus.Conflict,
conflictData: {
reason: ConflictReason.BothNewerLastSync,
remote,
local,
additional: {
vectorClockComparison: VectorClockComparison.CONCURRENT,
localVector: vectorClockToString(localVector),
remoteVector: vectorClockToString(remoteVector),
},
},
};
}
}
// Try to use change counters (Lamport timestamps) first if available
// Enhanced fallback: Try to create hybrid comparison using available data
const localChangeCounter = getLocalChangeCounter(local);
const remoteChangeCounter = getLocalChangeCounter(remote);
const lastSyncedChangeCounter = getLastSyncedChangeCounter(local);
// Special case: If one side has vector clock and other has Lamport, we can still compare intelligently
if (
localHasVectorClock &&
!remoteHasVectorClock &&
typeof remoteChangeCounter === 'number'
) {
// Extract the maximum value from local vector clock to compare with remote Lamport
const localMaxClock = Math.max(...Object.values(local.vectorClock!));
const hasLocalChanges = localMaxClock > (lastSyncedChangeCounter || 0);
const hasRemoteChanges = remoteChangeCounter > (lastSyncedChangeCounter || 0);
if (!hasLocalChanges && !hasRemoteChanges) {
return { status: SyncStatus.InSync };
} else if (hasLocalChanges && !hasRemoteChanges) {
return { status: SyncStatus.UpdateRemote };
} else if (!hasLocalChanges && hasRemoteChanges) {
return { status: SyncStatus.UpdateLocal };
} else {
// Both have changes - need to compare magnitudes
if (localMaxClock > remoteChangeCounter) {
return { status: SyncStatus.UpdateRemote };
} else if (remoteChangeCounter > localMaxClock) {
return { status: SyncStatus.UpdateLocal };
} else {
// Equal - fall through to timestamp comparison
}
}
} else if (
!localHasVectorClock &&
remoteHasVectorClock &&
typeof localChangeCounter === 'number'
) {
// Extract the maximum value from remote vector clock to compare with local Lamport
const remoteMaxClock = Math.max(...Object.values(remote.vectorClock!));
const hasLocalChanges = localChangeCounter > (lastSyncedChangeCounter || 0);
const hasRemoteChanges = remoteMaxClock > (lastSyncedChangeCounter || 0);
if (!hasLocalChanges && !hasRemoteChanges) {
return { status: SyncStatus.InSync };
} else if (hasLocalChanges && !hasRemoteChanges) {
return { status: SyncStatus.UpdateRemote };
} else if (!hasLocalChanges && hasRemoteChanges) {
return { status: SyncStatus.UpdateLocal };
} else {
// Both have changes - need to compare magnitudes
if (localChangeCounter > remoteMaxClock) {
return { status: SyncStatus.UpdateRemote };
} else if (remoteMaxClock > localChangeCounter) {
return { status: SyncStatus.UpdateLocal };
} else {
// Equal - fall through to timestamp comparison
}
}
}
// Standard Lamport fallback when both sides lack vector clocks
if (
typeof localChangeCounter === 'number' &&
typeof remoteChangeCounter === 'number' &&
@ -96,7 +222,8 @@ export const getSyncStatusFromMetaFiles = (
}
// TODO remove later once it is likely that all running apps have lamport clocks
// Fall back to timestamp-based checking
// Final fallback to timestamp-based checking
if (typeof local.lastSyncedUpdate === 'number') {
const r = _checkForUpdate({
remote: remote.lastUpdate,
@ -193,6 +320,52 @@ enum UpdateCheckResult {
LastSyncNotUpToDate = 'LastSyncNotUpToDate',
}
const _checkForUpdateVectorClock = (params: {
localVector: VectorClock;
remoteVector: VectorClock;
lastSyncedVector: VectorClock | null;
}): UpdateCheckResult => {
const { localVector, remoteVector, lastSyncedVector } = params;
pfLog(2, 'Vector clock check', {
localVector: vectorClockToString(localVector),
remoteVector: vectorClockToString(remoteVector),
lastSyncedVector: vectorClockToString(lastSyncedVector),
});
// Check if there have been changes since last sync
const hasLocalChanges = hasVectorClockChanges(localVector, lastSyncedVector);
const hasRemoteChanges = hasVectorClockChanges(remoteVector, lastSyncedVector);
if (!hasLocalChanges && !hasRemoteChanges) {
return UpdateCheckResult.InSync;
} else if (hasLocalChanges && !hasRemoteChanges) {
return UpdateCheckResult.RemoteUpdateRequired;
} else if (!hasLocalChanges && hasRemoteChanges) {
return UpdateCheckResult.LocalUpdateRequired;
} else {
// Both have changes - need to check if they're truly concurrent
const comparison = compareVectorClocks(localVector, remoteVector);
pfLog(2, 'Both sides have changes, vector comparison result:', comparison);
// If one vector clock dominates the other, we can still sync
if (comparison === VectorClockComparison.LESS_THAN) {
// Remote is strictly ahead, update local
return UpdateCheckResult.LocalUpdateRequired;
} else if (comparison === VectorClockComparison.GREATER_THAN) {
// Local is strictly ahead, update remote
return UpdateCheckResult.RemoteUpdateRequired;
} else if (comparison === VectorClockComparison.EQUAL) {
// Both have the same vector clock - they're in sync
return UpdateCheckResult.InSync;
} else {
// Vectors are concurrent - true conflict
return UpdateCheckResult.DataDiverged;
}
}
};
const _checkForUpdateLamport = (params: {
remoteLocalLamport: number;
localLamport: number;
@ -219,7 +392,12 @@ const _checkForUpdateLamport = (params: {
} else if (!hasLocalChanges && hasRemoteChanges) {
return UpdateCheckResult.LocalUpdateRequired;
} else {
// Both have changes - conflict
// Both have changes - check if they're the same
if (localLamport === remoteLocalLamport) {
// Both made the same changes - they're in sync
return UpdateCheckResult.InSync;
}
// Different changes - conflict
return UpdateCheckResult.DataDiverged;
}
};

View file

@ -0,0 +1,521 @@
import {
VectorClock,
VectorClockComparison,
initializeVectorClock,
isVectorClockEmpty,
compareVectorClocks,
incrementVectorClock,
mergeVectorClocks,
lamportToVectorClock,
vectorClockToString,
hasVectorClockChanges,
} from './vector-clock';
describe('Vector Clock', () => {
describe('initializeVectorClock', () => {
it('should create a vector clock with initial value 0', () => {
const clock = initializeVectorClock('client1');
expect(clock).toEqual({ client1: 0 });
});
it('should create a vector clock with custom initial value', () => {
const clock = initializeVectorClock('client1', 5);
expect(clock).toEqual({ client1: 5 });
});
});
describe('isVectorClockEmpty', () => {
it('should return true for null', () => {
expect(isVectorClockEmpty(null)).toBe(true);
});
it('should return true for undefined', () => {
expect(isVectorClockEmpty(undefined)).toBe(true);
});
it('should return true for empty object', () => {
expect(isVectorClockEmpty({})).toBe(true);
});
it('should return false for non-empty clock', () => {
expect(isVectorClockEmpty({ client1: 1 })).toBe(false);
});
});
describe('compareVectorClocks', () => {
it('should return EQUAL for two empty clocks', () => {
expect(compareVectorClocks({}, {})).toBe(VectorClockComparison.EQUAL);
expect(compareVectorClocks(null, null)).toBe(VectorClockComparison.EQUAL);
});
it('should return EQUAL for identical clocks', () => {
const clock1 = { client1: 5, client2: 3 };
const clock2 = { client1: 5, client2: 3 };
expect(compareVectorClocks(clock1, clock2)).toBe(VectorClockComparison.EQUAL);
});
it('should return LESS_THAN when first clock is behind', () => {
const clock1 = { client1: 3, client2: 2 };
const clock2 = { client1: 5, client2: 3 };
expect(compareVectorClocks(clock1, clock2)).toBe(VectorClockComparison.LESS_THAN);
});
it('should return GREATER_THAN when first clock is ahead', () => {
const clock1 = { client1: 5, client2: 3 };
const clock2 = { client1: 3, client2: 2 };
expect(compareVectorClocks(clock1, clock2)).toBe(
VectorClockComparison.GREATER_THAN,
);
});
it('should return CONCURRENT for concurrent clocks', () => {
const clock1 = { client1: 5, client2: 2 };
const clock2 = { client1: 3, client2: 4 };
expect(compareVectorClocks(clock1, clock2)).toBe(VectorClockComparison.CONCURRENT);
});
it('should handle missing components as 0', () => {
const clock1 = { client1: 5 };
const clock2 = { client1: 5, client2: 0 };
expect(compareVectorClocks(clock1, clock2)).toBe(VectorClockComparison.EQUAL);
});
it('should handle comparison with empty clock', () => {
const clock1 = { client1: 1 };
expect(compareVectorClocks(clock1, {})).toBe(VectorClockComparison.GREATER_THAN);
expect(compareVectorClocks({}, clock1)).toBe(VectorClockComparison.LESS_THAN);
});
});
describe('incrementVectorClock', () => {
it('should increment existing component', () => {
const clock = { client1: 5, client2: 3 };
const result = incrementVectorClock(clock, 'client1');
expect(result).toEqual({ client1: 6, client2: 3 });
// Should not modify original
expect(clock).toEqual({ client1: 5, client2: 3 });
});
it('should add new component if not exists', () => {
const clock = { client1: 5 };
const result = incrementVectorClock(clock, 'client2');
expect(result).toEqual({ client1: 5, client2: 1 });
});
it('should handle empty clock', () => {
const result = incrementVectorClock({}, 'client1');
expect(result).toEqual({ client1: 1 });
});
it('should handle null clock', () => {
const result = incrementVectorClock(null, 'client1');
expect(result).toEqual({ client1: 1 });
});
it('should handle overflow protection', () => {
const clock = { client1: Number.MAX_SAFE_INTEGER - 500 };
const result = incrementVectorClock(clock, 'client1');
expect(result).toEqual({ client1: 1 });
});
});
describe('mergeVectorClocks', () => {
it('should take maximum of each component', () => {
const clock1 = { client1: 5, client2: 3, client3: 7 };
const clock2 = { client1: 3, client2: 6, client3: 2 };
const result = mergeVectorClocks(clock1, clock2);
expect(result).toEqual({ client1: 5, client2: 6, client3: 7 });
});
it('should include components from both clocks', () => {
const clock1 = { client1: 5, client2: 3 };
const clock2 = { client2: 2, client3: 4 };
const result = mergeVectorClocks(clock1, clock2);
expect(result).toEqual({ client1: 5, client2: 3, client3: 4 });
});
it('should handle empty clocks', () => {
const clock1 = { client1: 5 };
expect(mergeVectorClocks(clock1, {})).toEqual({ client1: 5 });
expect(mergeVectorClocks({}, clock1)).toEqual({ client1: 5 });
expect(mergeVectorClocks({}, {})).toEqual({});
});
it('should handle null clocks', () => {
const clock1 = { client1: 5 };
expect(mergeVectorClocks(clock1, null)).toEqual({ client1: 5 });
expect(mergeVectorClocks(null, clock1)).toEqual({ client1: 5 });
});
});
describe('lamportToVectorClock', () => {
it('should convert positive Lamport timestamp', () => {
const result = lamportToVectorClock(5, 'client1');
expect(result).toEqual({ client1: 5 });
});
it('should return empty clock for null', () => {
const result = lamportToVectorClock(null, 'client1');
expect(result).toEqual({});
});
it('should return empty clock for 0', () => {
const result = lamportToVectorClock(0, 'client1');
expect(result).toEqual({});
});
it('should return empty clock for undefined', () => {
const result = lamportToVectorClock(undefined, 'client1');
expect(result).toEqual({});
});
});
describe('vectorClockToString', () => {
it('should format vector clock as string', () => {
const clock = { client2: 3, client1: 5 };
expect(vectorClockToString(clock)).toBe('{client1:5, client2:3}');
});
it('should handle empty clock', () => {
expect(vectorClockToString({})).toBe('{}');
});
it('should handle null clock', () => {
expect(vectorClockToString(null)).toBe('{}');
});
it('should sort client IDs alphabetically', () => {
const clock = { z: 1, a: 2, m: 3 };
expect(vectorClockToString(clock)).toBe('{a:2, m:3, z:1}');
});
});
describe('hasVectorClockChanges', () => {
it('should detect changes when current is ahead', () => {
const current = { client1: 5, client2: 3 };
const reference = { client1: 3, client2: 3 };
expect(hasVectorClockChanges(current, reference)).toBe(true);
});
it('should detect no changes when equal', () => {
const current = { client1: 5, client2: 3 };
const reference = { client1: 5, client2: 3 };
expect(hasVectorClockChanges(current, reference)).toBe(false);
});
it('should detect no changes when current is behind', () => {
const current = { client1: 3, client2: 3 };
const reference = { client1: 5, client2: 3 };
expect(hasVectorClockChanges(current, reference)).toBe(false);
});
it('should detect changes in new components', () => {
const current = { client1: 5, client2: 1 };
const reference = { client1: 5 };
expect(hasVectorClockChanges(current, reference)).toBe(true);
});
it('should handle empty current clock', () => {
const reference = { client1: 5 };
expect(hasVectorClockChanges({}, reference)).toBe(false);
expect(hasVectorClockChanges(null, reference)).toBe(false);
});
it('should handle empty reference clock', () => {
const current = { client1: 5 };
expect(hasVectorClockChanges(current, {})).toBe(true);
expect(hasVectorClockChanges(current, null)).toBe(true);
});
it('should handle both empty', () => {
expect(hasVectorClockChanges({}, {})).toBe(false);
expect(hasVectorClockChanges(null, null)).toBe(false);
});
});
describe('Integration scenarios', () => {
it('should handle typical sync scenario', () => {
// Initial state - both clients start at 0
const clientA = initializeVectorClock('A');
const clientB = initializeVectorClock('B');
// Client A makes changes
const clockA1 = incrementVectorClock(clientA, 'A');
const clockA2 = incrementVectorClock(clockA1, 'A');
// Client B makes changes
const clockB1 = incrementVectorClock(clientB, 'B');
// Check they are concurrent
expect(compareVectorClocks(clockA2, clockB1)).toBe(
VectorClockComparison.CONCURRENT,
);
// Client B syncs with A's changes
const clockBSynced = mergeVectorClocks(clockB1, clockA2);
expect(clockBSynced).toEqual({ A: 2, B: 1 });
// Client B makes more changes
const clockB2 = incrementVectorClock(clockBSynced, 'B');
expect(clockB2).toEqual({ A: 2, B: 2 });
// Now B is strictly ahead of A
expect(compareVectorClocks(clockB2, clockA2)).toBe(
VectorClockComparison.GREATER_THAN,
);
});
it('should detect true conflicts', () => {
// Start with synced state
const syncedClock: VectorClock = { A: 5, B: 3, C: 2 };
// A and B both make changes independently
const clockA = incrementVectorClock(syncedClock, 'A');
const clockB = incrementVectorClock(syncedClock, 'B');
// They should be concurrent (true conflict)
expect(compareVectorClocks(clockA, clockB)).toBe(VectorClockComparison.CONCURRENT);
// Both have changes since synced state
expect(hasVectorClockChanges(clockA, syncedClock)).toBe(true);
expect(hasVectorClockChanges(clockB, syncedClock)).toBe(true);
});
it('should handle three-way sync scenario', () => {
// Three devices starting fresh
const deviceA: VectorClock = {};
const deviceB: VectorClock = {};
const deviceC: VectorClock = {};
// Device A makes initial changes
const clockA1 = incrementVectorClock(deviceA, 'A');
const clockA2 = incrementVectorClock(clockA1, 'A');
// Device B syncs with A
const clockB1 = mergeVectorClocks(deviceB, clockA2);
const clockB2 = incrementVectorClock(clockB1, 'B');
// Device C syncs with B (gets both A and B changes)
const clockC1 = mergeVectorClocks(deviceC, clockB2);
expect(clockC1).toEqual({ A: 2, B: 1 });
// Device C makes changes
const clockC2 = incrementVectorClock(clockC1, 'C');
// Now C is ahead of both A and B
expect(compareVectorClocks(clockC2, clockA2)).toBe(
VectorClockComparison.GREATER_THAN,
);
expect(compareVectorClocks(clockC2, clockB2)).toBe(
VectorClockComparison.GREATER_THAN,
);
});
it('should handle complex conflict resolution', () => {
// Start with all devices synced
const baseClock: VectorClock = { A: 10, B: 8, C: 5 };
// Each device makes independent changes
const clockA = incrementVectorClock(incrementVectorClock(baseClock, 'A'), 'A');
const clockB = incrementVectorClock(baseClock, 'B');
const clockC = incrementVectorClock(
incrementVectorClock(incrementVectorClock(baseClock, 'C'), 'C'),
'C',
);
// All should be concurrent with each other
expect(compareVectorClocks(clockA, clockB)).toBe(VectorClockComparison.CONCURRENT);
expect(compareVectorClocks(clockB, clockC)).toBe(VectorClockComparison.CONCURRENT);
expect(compareVectorClocks(clockA, clockC)).toBe(VectorClockComparison.CONCURRENT);
// Resolve by merging all clocks
const resolved = mergeVectorClocks(mergeVectorClocks(clockA, clockB), clockC);
expect(resolved).toEqual({ A: 12, B: 9, C: 8 });
// Resolved clock should be greater than all individual clocks
expect(compareVectorClocks(resolved, clockA)).toBe(
VectorClockComparison.GREATER_THAN,
);
expect(compareVectorClocks(resolved, clockB)).toBe(
VectorClockComparison.GREATER_THAN,
);
expect(compareVectorClocks(resolved, clockC)).toBe(
VectorClockComparison.GREATER_THAN,
);
});
it('should handle lost update scenario', () => {
// Device A and B start synced
const syncedState: VectorClock = { A: 5, B: 5 };
// Device A makes changes
const clockA1 = incrementVectorClock(syncedState, 'A');
// Device B makes changes but doesn\'t sync
const clockB1 = incrementVectorClock(syncedState, 'B');
const clockB2 = incrementVectorClock(clockB1, 'B');
// Device A syncs and overwrites B\'s first change
const clockA2 = mergeVectorClocks(clockA1, { A: 5, B: 5 }); // B hasn't synced yet
// Now when B tries to sync, conflict is detected
expect(compareVectorClocks(clockA2, clockB2)).toBe(
VectorClockComparison.CONCURRENT,
);
});
it('should handle clock drift recovery', () => {
// Simulate a device with drifted clock values
const driftedClock: VectorClock = { A: 1000000, B: 999999, C: 1000001 };
const normalClock: VectorClock = { A: 10, B: 8, C: 12 };
// Merge should take maximum values
const merged = mergeVectorClocks(driftedClock, normalClock);
expect(merged).toEqual({ A: 1000000, B: 999999, C: 1000001 });
// Comparison should still work correctly
expect(compareVectorClocks(driftedClock, normalClock)).toBe(
VectorClockComparison.GREATER_THAN,
);
});
});
describe('Edge cases and error handling', () => {
it('should handle very large vector clocks', () => {
const largeClock: VectorClock = {};
// Create a clock with many clients
for (let i = 0; i < 100; i++) {
largeClock[`client${i}`] = i;
}
// Operations should still work
const incremented = incrementVectorClock(largeClock, 'client50');
expect(incremented.client50).toBe(51);
const str = vectorClockToString(largeClock);
expect(str).toContain('client0:0');
expect(str).toContain('client99:99');
});
it('should handle special characters in client IDs', () => {
const clock = initializeVectorClock('client-with-dash');
const clock2 = incrementVectorClock(clock, 'client.with.dots');
const clock3 = incrementVectorClock(clock2, 'client@email.com');
expect(clock3).toEqual({
// eslint-disable-next-line @typescript-eslint/naming-convention
'client-with-dash': 0,
// eslint-disable-next-line @typescript-eslint/naming-convention
'client.with.dots': 1,
// eslint-disable-next-line @typescript-eslint/naming-convention
'client@email.com': 1,
});
});
it('should handle comparison with mixed empty/zero components', () => {
const clock1 = { A: 0, B: 5, C: 0 };
const clock2 = { B: 5 };
expect(compareVectorClocks(clock1, clock2)).toBe(VectorClockComparison.EQUAL);
});
it('should handle increments near overflow gracefully', () => {
const nearMax = Number.MAX_SAFE_INTEGER - 100;
const clock = { A: nearMax };
// First increment should trigger overflow protection
const clock1 = incrementVectorClock(clock, 'A');
expect(clock1.A).toBe(1);
// Subsequent increments should work normally
const clock2 = incrementVectorClock(clock1, 'A');
expect(clock2.A).toBe(2);
});
it('should maintain consistency when merging with self', () => {
const clock: VectorClock = { A: 5, B: 3 };
const merged = mergeVectorClocks(clock, clock);
expect(merged).toEqual(clock);
expect(merged).not.toBe(clock); // Should be a new object
});
it('should handle comparison of single-client clocks', () => {
const clock1 = { A: 5 };
const clock2 = { A: 3 };
expect(compareVectorClocks(clock1, clock2)).toBe(
VectorClockComparison.GREATER_THAN,
);
});
});
describe('Backwards compatibility scenarios', () => {
it('should handle migration from Lamport timestamps', () => {
// Simulate device with only Lamport timestamp
const lamportValue = 42;
const migratedClock = lamportToVectorClock(lamportValue, 'deviceA');
expect(migratedClock).toEqual({ deviceA: 42 });
// Should be able to increment normally after migration
const incremented = incrementVectorClock(migratedClock, 'deviceA');
expect(incremented).toEqual({ deviceA: 43 });
});
it('should handle mixed Lamport/vector clock comparison', () => {
// Device A has vector clock, Device B has Lamport
const vectorClock: VectorClock = { A: 10, B: 5 };
const lamportClock = lamportToVectorClock(8, 'B');
// B's Lamport 8 is higher than its component in A's vector (5)
const merged = mergeVectorClocks(vectorClock, lamportClock);
expect(merged).toEqual({ A: 10, B: 8 });
});
it('should handle empty to non-empty migration', () => {
// Device starts with no clock
let deviceClock: VectorClock | undefined = undefined;
// First update creates clock
deviceClock = incrementVectorClock(deviceClock, 'device1');
expect(deviceClock).toEqual({ device1: 1 });
// Can continue incrementing
deviceClock = incrementVectorClock(deviceClock, 'device1');
expect(deviceClock).toEqual({ device1: 2 });
});
});
describe('Performance considerations', () => {
it('should handle rapid increments efficiently', () => {
let clock: VectorClock = {};
const clientId = 'rapidClient';
// Perform many increments
for (let i = 0; i < 1000; i++) {
clock = incrementVectorClock(clock, clientId);
}
expect(clock[clientId]).toBe(1000);
});
it('should handle large merges efficiently', () => {
const clock1: VectorClock = {};
const clock2: VectorClock = {};
// Create two large clocks with overlapping clients
for (let i = 0; i < 50; i++) {
clock1[`client${i}`] = i * 2;
clock2[`client${i}`] = i * 3;
}
const merged = mergeVectorClocks(clock1, clock2);
// Should have taken max of each
for (let i = 0; i < 50; i++) {
expect(merged[`client${i}`]).toBe(i * 3);
}
});
});
});

View file

@ -0,0 +1,238 @@
import { pfLog } from './log';
/**
* Vector Clock implementation for distributed synchronization
*
* A vector clock is a data structure used to determine the partial ordering of events
* in a distributed system and detect causality violations.
*
* Each process/device maintains its own component in the vector, incrementing it
* on local updates. This allows us to determine if two states are:
* - EQUAL: Same vector values
* - LESS_THAN: A happened before B
* - GREATER_THAN: B happened before A
* - CONCURRENT: Neither happened before the other (true conflict)
*/
/**
* Vector clock data structure
* Maps client IDs to their respective clock values
*/
export interface VectorClock {
[clientId: string]: number;
}
/**
* Result of comparing two vector clocks
*/
export enum VectorClockComparison {
EQUAL = 'EQUAL',
LESS_THAN = 'LESS_THAN',
GREATER_THAN = 'GREATER_THAN',
CONCURRENT = 'CONCURRENT',
}
/**
* Initialize a new vector clock for a client
* @param clientId The client's unique identifier
* @param initialValue Optional initial value (defaults to 0)
* @returns A new vector clock
*/
export const initializeVectorClock = (
clientId: string,
initialValue: number = 0,
): VectorClock => {
return { [clientId]: initialValue };
};
/**
* Check if a vector clock is empty or uninitialized
* @param clock The vector clock to check
* @returns True if the clock is null, undefined, or has no entries
*/
export const isVectorClockEmpty = (clock: VectorClock | null | undefined): boolean => {
return !clock || Object.keys(clock).length === 0;
};
/**
* Compare two vector clocks to determine their relationship
*
* @param a First vector clock
* @param b Second vector clock
* @returns The comparison result
*/
export const compareVectorClocks = (
a: VectorClock | null | undefined,
b: VectorClock | null | undefined,
): VectorClockComparison => {
// Handle null/undefined cases
if (isVectorClockEmpty(a) && isVectorClockEmpty(b)) {
return VectorClockComparison.EQUAL;
}
if (isVectorClockEmpty(a)) {
return VectorClockComparison.LESS_THAN;
}
if (isVectorClockEmpty(b)) {
return VectorClockComparison.GREATER_THAN;
}
// Safe type assertion after null checks
const clockA = a!;
const clockB = b!;
// Get all client IDs from both clocks
const allClientIds = new Set([...Object.keys(clockA), ...Object.keys(clockB)]);
let aHasGreater = false;
let bHasGreater = false;
// Compare each component
for (const clientId of allClientIds) {
const aVal = clockA[clientId] || 0;
const bVal = clockB[clientId] || 0;
if (aVal > bVal) {
aHasGreater = true;
}
if (bVal > aVal) {
bHasGreater = true;
}
}
// Determine relationship
if (!aHasGreater && !bHasGreater) {
return VectorClockComparison.EQUAL;
} else if (!aHasGreater) {
// B has some greater components, A has none -> A < B
return VectorClockComparison.LESS_THAN;
} else if (!bHasGreater) {
// A has some greater components, B has none -> A > B
return VectorClockComparison.GREATER_THAN;
} else {
// Both have some greater components -> concurrent
return VectorClockComparison.CONCURRENT;
}
};
/**
* Increment a client's component in the vector clock
* Creates a new vector clock with the incremented value
*
* @param clock The current vector clock
* @param clientId The client ID to increment
* @returns A new vector clock with the incremented value
*/
export const incrementVectorClock = (
clock: VectorClock | null | undefined,
clientId: string,
): VectorClock => {
const newClock = { ...(clock || {}) };
const currentValue = newClock[clientId] || 0;
// Handle overflow - reset to 1 if approaching max safe integer
if (currentValue >= Number.MAX_SAFE_INTEGER - 1000) {
pfLog(1, 'Vector clock component overflow protection triggered', {
clientId,
currentValue,
});
newClock[clientId] = 1;
} else {
newClock[clientId] = currentValue + 1;
}
return newClock;
};
/**
* Merge two vector clocks by taking the maximum value for each component
* This is used when receiving updates to ensure we have the most recent view
*
* @param a First vector clock
* @param b Second vector clock
* @returns A new merged vector clock
*/
export const mergeVectorClocks = (
a: VectorClock | null | undefined,
b: VectorClock | null | undefined,
): VectorClock => {
if (isVectorClockEmpty(a)) return { ...(b || {}) };
if (isVectorClockEmpty(b)) return { ...(a || {}) };
const merged: VectorClock = {};
const allClientIds = new Set([...Object.keys(a!), ...Object.keys(b!)]);
for (const clientId of allClientIds) {
const aVal = a![clientId] || 0;
const bVal = b![clientId] || 0;
merged[clientId] = Math.max(aVal, bVal);
}
return merged;
};
/**
* Convert a Lamport timestamp to a vector clock
* Used for backwards compatibility during migration
*
* @param lamport The Lamport timestamp value
* @param clientId The client ID to use
* @returns A vector clock with a single component
*/
export const lamportToVectorClock = (
lamport: number | null | undefined,
clientId: string,
): VectorClock => {
if (lamport == null || lamport === 0) {
return {};
}
return { [clientId]: lamport };
};
/**
* Get a human-readable string representation of a vector clock
* Useful for debugging and logging
*
* @param clock The vector clock
* @returns A string representation
*/
export const vectorClockToString = (clock: VectorClock | null | undefined): string => {
if (isVectorClockEmpty(clock)) {
return '{}';
}
const entries = Object.entries(clock!)
.sort(([a], [b]) => a.localeCompare(b))
.map(([id, val]) => `${id}:${val}`);
return `{${entries.join(', ')}}`;
};
/**
* Check if a vector clock has changes compared to a reference clock
* Used to determine if local changes exist
*
* @param current The current vector clock
* @param reference The reference vector clock (e.g., last synced)
* @returns True if current has any components greater than reference
*/
export const hasVectorClockChanges = (
current: VectorClock | null | undefined,
reference: VectorClock | null | undefined,
): boolean => {
if (isVectorClockEmpty(current)) {
return false;
}
if (isVectorClockEmpty(reference)) {
return !isVectorClockEmpty(current);
}
// Check if any component in current is greater than in reference
for (const [clientId, currentVal] of Object.entries(current!)) {
const refVal = reference![clientId] || 0;
if (currentVal > refVal) {
return true;
}
}
return false;
};