mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
fix(sync): return existingClock in conflict rejections to fix infinite loop
When encryption is enabled, clients could get stuck in infinite conflict resolution loops because they couldn't create vector clocks that dominate the server's state. The server already computed the existingClock during conflict detection but didn't return it to the client. This fix: - Adds existingClock to UploadResult type (server) - Returns conflict.existingClock in CONFLICT_CONCURRENT/CONFLICT_STALE rejections - Passes existingClock through client upload service to rejected ops handler - Merges entity clocks into extraClocks for stale operation resolution Includes E2E tests for encryption + conflict resolution scenarios.
This commit is contained in:
parent
ed8b9a5f88
commit
100b8af5f2
10 changed files with 787 additions and 45 deletions
|
|
@ -120,7 +120,9 @@ export class SuperSyncPage extends BasePage {
|
|||
|
||||
// Select "SuperSync" from provider dropdown
|
||||
await this.providerSelect.click();
|
||||
// Wait for dropdown options to appear (Angular Material animation)
|
||||
const superSyncOption = this.page.locator('mat-option:has-text("SuperSync")');
|
||||
await superSyncOption.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await superSyncOption.click();
|
||||
await this.page.waitForTimeout(500); // Wait for dropdown to close and form to update
|
||||
|
||||
|
|
@ -221,6 +223,8 @@ export class SuperSyncPage extends BasePage {
|
|||
if (encryptionSettingsChanged) {
|
||||
// Trigger a manual sync to ensure we get latest data after encryption change
|
||||
await this.page.waitForTimeout(500); // Wait for UI to settle
|
||||
// Ensure any lingering overlays from encryption dialogs are closed
|
||||
await this.ensureOverlaysClosed();
|
||||
await this.syncBtn.click();
|
||||
|
||||
// Wait for sync to start
|
||||
|
|
@ -276,7 +280,9 @@ export class SuperSyncPage extends BasePage {
|
|||
// CRITICAL: Select "SuperSync" from provider dropdown to load current configuration
|
||||
// Without this, the form shows default/empty values instead of the actual current state
|
||||
await this.providerSelect.click();
|
||||
// Wait for dropdown options to appear (Angular Material animation)
|
||||
const superSyncOption = this.page.locator('mat-option:has-text("SuperSync")');
|
||||
await superSyncOption.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await superSyncOption.click();
|
||||
|
||||
// IMPORTANT: Wait for the provider change listener to complete loading the config
|
||||
|
|
@ -304,45 +310,67 @@ export class SuperSyncPage extends BasePage {
|
|||
|
||||
// Check if already enabled
|
||||
const isChecked = await this.encryptionCheckbox.isChecked();
|
||||
if (!isChecked) {
|
||||
// Step 1: Enable checkbox first (this makes the password field visible)
|
||||
// Since there's no password yet, no dialog will appear
|
||||
await checkboxLabel.click();
|
||||
await this.page.waitForTimeout(300);
|
||||
if (isChecked) {
|
||||
// Already enabled - just close the dialog
|
||||
const configDialog = this.page.locator('mat-dialog-container').first();
|
||||
const cancelBtn = configDialog.locator('button').filter({ hasText: /cancel/i });
|
||||
await cancelBtn.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
return;
|
||||
}
|
||||
|
||||
// IMPORTANT: The "Enable Encryption?" dialog is triggered by the checkbox valueChanges
|
||||
// subscription ONLY when there's already a password set. So we must:
|
||||
// 1. First click checkbox to show password field (no dialog since no password)
|
||||
// 2. Fill the password
|
||||
// 3. Uncheck the checkbox (might trigger disable dialog - cancel it)
|
||||
// 4. Re-check the checkbox (this triggers the enable dialog with password set)
|
||||
|
||||
// Step 1: Enable checkbox first (this makes the password field visible)
|
||||
await checkboxLabel.click();
|
||||
await this.page.waitForTimeout(300);
|
||||
|
||||
// Step 2: Wait for password field to appear and fill it
|
||||
await this.encryptionPasswordInput.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await this.encryptionPasswordInput.fill(password);
|
||||
await this.page.waitForTimeout(300); // Wait for Angular model to update
|
||||
|
||||
// Step 3: Save the form to trigger encryption enabling
|
||||
// The form save with encryption enabled + password will configure encryption
|
||||
const configDialog = this.page.locator('mat-dialog-container').first();
|
||||
const saveBtn = configDialog.locator('button').filter({ hasText: /save/i });
|
||||
await saveBtn.click();
|
||||
// Step 3: Uncheck the checkbox
|
||||
// NOTE: If the model has encryptKey, this might trigger the "Disable Encryption?" dialog
|
||||
await checkboxLabel.click();
|
||||
await this.page.waitForTimeout(300);
|
||||
|
||||
// Check if disable dialog appeared and cancel it
|
||||
const disableDialog = this.page
|
||||
.locator('mat-dialog-container')
|
||||
.filter({ hasText: 'Disable Encryption?' });
|
||||
const disableDialogVisible = await disableDialog.isVisible().catch(() => false);
|
||||
if (disableDialogVisible) {
|
||||
const cancelBtn = disableDialog.locator('button').filter({ hasText: /cancel/i });
|
||||
await cancelBtn.click();
|
||||
await disableDialog.waitFor({ state: 'hidden', timeout: 5000 });
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// Step 4: Re-check the checkbox - NOW the dialog will appear since password is set
|
||||
await checkboxLabel.click();
|
||||
await this.page.waitForTimeout(300);
|
||||
|
||||
// Wait for the "Enable Encryption?" confirmation dialog to appear
|
||||
// This dialog should appear when saving with encryption enabled and a password set
|
||||
const enableDialog = this.page
|
||||
.locator('mat-dialog-container')
|
||||
.filter({ hasText: 'Enable Encryption?' });
|
||||
const dialogAppeared = await enableDialog
|
||||
.waitFor({ state: 'visible', timeout: 5000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
await enableDialog.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
if (dialogAppeared) {
|
||||
// Click the confirm button (mat-flat-button with "Enable Encryption" text)
|
||||
const confirmBtn = enableDialog
|
||||
.locator('button[mat-flat-button]')
|
||||
.filter({ hasText: /enable/i });
|
||||
await confirmBtn.click();
|
||||
// Click the confirm button (mat-flat-button with "Enable Encryption" text)
|
||||
const confirmBtn = enableDialog
|
||||
.locator('button[mat-flat-button]')
|
||||
.filter({ hasText: /enable/i });
|
||||
await confirmBtn.click();
|
||||
|
||||
// Wait for the enable encryption dialog to close and any loading to complete
|
||||
await enableDialog.waitFor({ state: 'hidden', timeout: 60000 });
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
// Wait for the enable encryption dialog to close and any loading to complete
|
||||
await enableDialog.waitFor({ state: 'hidden', timeout: 60000 });
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
// Wait for all dialogs to close
|
||||
await expect(this.page.locator('mat-dialog-container')).toHaveCount(0, {
|
||||
|
|
@ -381,7 +409,9 @@ export class SuperSyncPage extends BasePage {
|
|||
// CRITICAL: Select "SuperSync" from provider dropdown to load current configuration
|
||||
// Without this, the form shows default/empty values instead of the actual current state
|
||||
await this.providerSelect.click();
|
||||
// Wait for dropdown options to appear (Angular Material animation)
|
||||
const superSyncOption = this.page.locator('mat-option:has-text("SuperSync")');
|
||||
await superSyncOption.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await superSyncOption.click();
|
||||
|
||||
// IMPORTANT: Wait for the provider change listener to complete loading the config
|
||||
|
|
|
|||
521
e2e/tests/sync/supersync-encryption-conflict.spec.ts
Normal file
521
e2e/tests/sync/supersync-encryption-conflict.spec.ts
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
import { test, expect } from '../../fixtures/supersync.fixture';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
|
||||
/**
|
||||
* SuperSync Encryption + Conflict Resolution E2E Tests
|
||||
*
|
||||
* These tests verify that conflict resolution works correctly when encryption is enabled.
|
||||
*
|
||||
* BACKGROUND:
|
||||
* There was a bug where encryption + concurrent modifications caused an infinite conflict loop:
|
||||
* 1. Client uploads ops → server rejects with CONFLICT_CONCURRENT
|
||||
* 2. Client downloads → 0 new ops (already has them, can't read encrypted payloads)
|
||||
* 3. Client creates LWW update with merged clock
|
||||
* 4. Server STILL rejects (client didn't know server's existing entity clock)
|
||||
* 5. Loop repeats infinitely with "conflict was fixed" messages
|
||||
*
|
||||
* FIX:
|
||||
* Server now returns `existingClock` in conflict rejection responses, allowing
|
||||
* the client to create LWW ops that properly dominate the server's state.
|
||||
*
|
||||
* These tests verify the fix works end-to-end with real encryption.
|
||||
*
|
||||
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-encryption-conflict.spec.ts
|
||||
*/
|
||||
|
||||
test.describe('@supersync @encryption Encryption + Conflict Resolution', () => {
|
||||
/**
|
||||
* Regression test: Concurrent modifications with encryption enabled resolve correctly
|
||||
*
|
||||
* This is the core test for the existingClock fix. It verifies that when two clients
|
||||
* make concurrent modifications with encryption enabled, conflicts resolve correctly
|
||||
* without an infinite loop.
|
||||
*
|
||||
* Scenario:
|
||||
* 1. Client A and B both set up with encryption (same password)
|
||||
* 2. Client A creates a task, syncs
|
||||
* 3. Client B syncs (downloads encrypted task)
|
||||
* 4. Both clients make concurrent changes to the same task
|
||||
* 5. Client A syncs first (uploads change)
|
||||
* 6. Client B syncs (triggers conflict, should resolve via existingClock)
|
||||
* 7. Final sync round to verify convergence
|
||||
* 8. Verify both clients have consistent state (no infinite loop)
|
||||
*/
|
||||
test('Concurrent modifications with encryption resolve without infinite loop (regression)', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
serverHealthy,
|
||||
}) => {
|
||||
void serverHealthy; // Ensure fixture is evaluated for server health check
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const encryptionPassword = `conflict-test-${testRunId}`;
|
||||
const syncConfig = {
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: encryptionPassword,
|
||||
};
|
||||
|
||||
// ============ PHASE 1: Setup Both Clients with Encryption ============
|
||||
console.log('[EncryptConflict] Phase 1: Setting up clients with encryption');
|
||||
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Initial sync to establish vector clocks
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[EncryptConflict] Both clients synced initially with encryption');
|
||||
|
||||
// ============ PHASE 2: Client A Creates Task ============
|
||||
console.log('[EncryptConflict] Phase 2: Client A creating task');
|
||||
|
||||
const taskName = `EncryptedConflictTask-${testRunId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
await waitForTask(clientA.page, taskName);
|
||||
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log(`[EncryptConflict] Client A created and synced: ${taskName}`);
|
||||
|
||||
// ============ PHASE 3: Client B Downloads Task ============
|
||||
console.log('[EncryptConflict] Phase 3: Client B downloading task');
|
||||
|
||||
await clientB.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, taskName);
|
||||
console.log('[EncryptConflict] Client B received the encrypted task');
|
||||
|
||||
// ============ PHASE 4: Both Clients Make Concurrent Changes ============
|
||||
console.log('[EncryptConflict] Phase 4: Making concurrent changes');
|
||||
|
||||
// Client A marks task as done
|
||||
const taskLocatorA = clientA.page
|
||||
.locator(`task:not(.ng-animating):has-text("${taskName}")`)
|
||||
.first();
|
||||
await taskLocatorA.hover();
|
||||
await taskLocatorA.locator('.task-done-btn').click();
|
||||
await expect(taskLocatorA).toHaveClass(/isDone/);
|
||||
console.log('[EncryptConflict] Client A marked task done');
|
||||
|
||||
// Wait for timestamp gap (ensures B's change has later timestamp)
|
||||
await clientB.page.waitForTimeout(500);
|
||||
|
||||
// Client B also marks task as done (concurrent change, later timestamp)
|
||||
const taskLocatorB = clientB.page
|
||||
.locator(`task:not(.ng-animating):has-text("${taskName}")`)
|
||||
.first();
|
||||
await taskLocatorB.hover();
|
||||
await taskLocatorB.locator('.task-done-btn').click();
|
||||
await expect(taskLocatorB).toHaveClass(/isDone/);
|
||||
console.log('[EncryptConflict] Client B marked task done (later timestamp)');
|
||||
|
||||
// ============ PHASE 5: Client A Syncs First ============
|
||||
console.log('[EncryptConflict] Phase 5: Client A syncing first');
|
||||
|
||||
await clientA.sync.syncAndWait();
|
||||
console.log('[EncryptConflict] Client A synced');
|
||||
|
||||
// ============ PHASE 6: Client B Syncs (Triggers Conflict Resolution) ============
|
||||
console.log('[EncryptConflict] Phase 6: Client B syncing (conflict resolution)');
|
||||
|
||||
// This is where the bug would manifest - without the fix, B would enter
|
||||
// an infinite loop of "conflict was fixed" messages.
|
||||
// With the fix, B receives existingClock and creates a dominating LWW op.
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[EncryptConflict] Client B synced');
|
||||
|
||||
// ============ PHASE 7: Final Sync Round for Convergence ============
|
||||
console.log('[EncryptConflict] Phase 7: Final sync round');
|
||||
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Wait a moment for any lingering operations
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
|
||||
// ============ PHASE 8: Verify Consistent State ============
|
||||
console.log('[EncryptConflict] Phase 8: Verifying consistent state');
|
||||
|
||||
// Both clients should have the task marked as done
|
||||
await expect(taskLocatorA).toHaveClass(/isDone/);
|
||||
await expect(taskLocatorB).toHaveClass(/isDone/);
|
||||
|
||||
// Verify no sync errors
|
||||
const hasErrorA = await clientA.sync.hasSyncError();
|
||||
const hasErrorB = await clientB.sync.hasSyncError();
|
||||
expect(hasErrorA).toBe(false);
|
||||
expect(hasErrorB).toBe(false);
|
||||
|
||||
// Verify task count is the same on both clients
|
||||
const countA = await clientA.page.locator(`task:has-text("${taskName}")`).count();
|
||||
const countB = await clientB.page.locator(`task:has-text("${taskName}")`).count();
|
||||
expect(countA).toBe(1);
|
||||
expect(countB).toBe(1);
|
||||
|
||||
console.log(
|
||||
'[EncryptConflict] ✓ Concurrent encrypted modifications resolved correctly',
|
||||
);
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Multiple tasks with concurrent edits and encryption converge
|
||||
*
|
||||
* A scenario with multiple tasks being edited concurrently to verify the
|
||||
* existingClock mechanism handles conflicts across different entities.
|
||||
*/
|
||||
test('Multiple tasks with concurrent edits and encryption converge', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
serverHealthy,
|
||||
}) => {
|
||||
void serverHealthy;
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const encryptionPassword = `multi-task-${testRunId}`;
|
||||
const syncConfig = {
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: encryptionPassword,
|
||||
};
|
||||
|
||||
// Setup clients with encryption
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
console.log('[MultiTask] Both clients set up with encryption');
|
||||
|
||||
// Create 3 tasks on client A
|
||||
const task1 = `Task1-${testRunId}`;
|
||||
const task2 = `Task2-${testRunId}`;
|
||||
const task3 = `Task3-${testRunId}`;
|
||||
|
||||
await clientA.workView.addTask(task1);
|
||||
await clientA.page.waitForTimeout(100);
|
||||
await clientA.workView.addTask(task2);
|
||||
await clientA.page.waitForTimeout(100);
|
||||
await clientA.workView.addTask(task3);
|
||||
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
await clientB.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, task1);
|
||||
await waitForTask(clientB.page, task2);
|
||||
await waitForTask(clientB.page, task3);
|
||||
console.log('[MultiTask] All 3 tasks synced');
|
||||
|
||||
// Client A marks task1 as done
|
||||
const task1LocatorA = clientA.page
|
||||
.locator(`task:not(.ng-animating):has-text("${task1}")`)
|
||||
.first();
|
||||
await task1LocatorA.hover();
|
||||
await task1LocatorA.locator('.task-done-btn').click();
|
||||
console.log('[MultiTask] Client A marked task1 done');
|
||||
|
||||
// Wait for timestamp gap
|
||||
await clientB.page.waitForTimeout(500);
|
||||
|
||||
// Client B marks task1 AND task2 as done (concurrent, later timestamp)
|
||||
const task1LocatorB = clientB.page
|
||||
.locator(`task:not(.ng-animating):has-text("${task1}")`)
|
||||
.first();
|
||||
await task1LocatorB.hover();
|
||||
await task1LocatorB.locator('.task-done-btn').click();
|
||||
|
||||
const task2LocatorB = clientB.page
|
||||
.locator(`task:not(.ng-animating):has-text("${task2}")`)
|
||||
.first();
|
||||
await task2LocatorB.hover();
|
||||
await task2LocatorB.locator('.task-done-btn').click();
|
||||
console.log('[MultiTask] Client B marked task1 and task2 done (later timestamp)');
|
||||
|
||||
// Sync sequence
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Wait for convergence
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
|
||||
// Verify no errors
|
||||
const hasErrorA = await clientA.sync.hasSyncError();
|
||||
const hasErrorB = await clientB.sync.hasSyncError();
|
||||
expect(hasErrorA).toBe(false);
|
||||
expect(hasErrorB).toBe(false);
|
||||
|
||||
// All 3 tasks should exist on both clients
|
||||
const countA = await clientA.page.locator('task').count();
|
||||
const countB = await clientB.page.locator('task').count();
|
||||
expect(countA).toBeGreaterThanOrEqual(3);
|
||||
expect(countB).toBeGreaterThanOrEqual(3);
|
||||
|
||||
console.log('[MultiTask] ✓ Multiple tasks with concurrent edits resolved');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Title edit conflict with encryption resolves correctly
|
||||
*
|
||||
* Tests that more complex entity changes (title edits) also resolve
|
||||
* correctly with encryption enabled.
|
||||
*/
|
||||
test('Title edit conflict with encryption resolves correctly', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
serverHealthy,
|
||||
}) => {
|
||||
void serverHealthy;
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const encryptionPassword = `title-conflict-${testRunId}`;
|
||||
const syncConfig = {
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: encryptionPassword,
|
||||
};
|
||||
|
||||
// Setup clients with encryption
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Create initial task
|
||||
const originalTitle = `TitleConflict-${testRunId}`;
|
||||
const titleA = `A-Edited-${testRunId}`;
|
||||
const titleB = `B-Edited-${testRunId}`;
|
||||
|
||||
await clientA.workView.addTask(originalTitle);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
await clientB.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, originalTitle);
|
||||
console.log('[TitleConflict] Initial task synced');
|
||||
|
||||
// Client A edits title
|
||||
const taskLocatorA = clientA.page
|
||||
.locator(`task:not(.ng-animating):has-text("${originalTitle}")`)
|
||||
.first();
|
||||
await taskLocatorA.dblclick();
|
||||
const editInputA = clientA.page.locator(
|
||||
'input.mat-mdc-input-element:focus, textarea:focus',
|
||||
);
|
||||
await editInputA.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await editInputA.fill(titleA);
|
||||
await clientA.page.keyboard.press('Enter');
|
||||
await clientA.page.waitForTimeout(500);
|
||||
console.log('[TitleConflict] Client A edited title');
|
||||
|
||||
// Wait for timestamp gap
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
|
||||
// Client B edits title (concurrent, later timestamp)
|
||||
const taskLocatorB = clientB.page
|
||||
.locator(`task:not(.ng-animating):has-text("${originalTitle}")`)
|
||||
.first();
|
||||
await taskLocatorB.dblclick();
|
||||
const editInputB = clientB.page.locator(
|
||||
'input.mat-mdc-input-element:focus, textarea:focus',
|
||||
);
|
||||
await editInputB.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await editInputB.fill(titleB);
|
||||
await clientB.page.keyboard.press('Enter');
|
||||
await clientB.page.waitForTimeout(500);
|
||||
console.log('[TitleConflict] Client B edited title (later timestamp)');
|
||||
|
||||
// Sync sequence
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Wait for convergence
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
await clientB.page.waitForTimeout(1000);
|
||||
|
||||
// Verify no errors
|
||||
const hasErrorA = await clientA.sync.hasSyncError();
|
||||
const hasErrorB = await clientB.sync.hasSyncError();
|
||||
expect(hasErrorA).toBe(false);
|
||||
expect(hasErrorB).toBe(false);
|
||||
|
||||
// B's title should win (later timestamp) - both clients should have titleB
|
||||
const taskWithBTitleOnA = clientA.page.locator(
|
||||
`task:not(.ng-animating):has-text("${titleB}")`,
|
||||
);
|
||||
const taskWithBTitleOnB = clientB.page.locator(
|
||||
`task:not(.ng-animating):has-text("${titleB}")`,
|
||||
);
|
||||
|
||||
await expect(taskWithBTitleOnA.first()).toBeVisible({ timeout: 10000 });
|
||||
await expect(taskWithBTitleOnB.first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
console.log(
|
||||
'[TitleConflict] ✓ Title conflict resolved correctly - B won (later ts)',
|
||||
);
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test: Three clients with encryption converge correctly
|
||||
*
|
||||
* Tests that the existingClock mechanism works when more than two clients
|
||||
* are involved in conflicts.
|
||||
*/
|
||||
test('Three clients with encryption converge correctly', async ({
|
||||
browser,
|
||||
baseURL,
|
||||
testRunId,
|
||||
serverHealthy,
|
||||
}) => {
|
||||
void serverHealthy;
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
let clientC: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const encryptionPassword = `three-way-${testRunId}`;
|
||||
const syncConfig = {
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: encryptionPassword,
|
||||
};
|
||||
|
||||
// Setup all three clients with encryption
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync(syncConfig);
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync(syncConfig);
|
||||
|
||||
clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId);
|
||||
await clientC.sync.setupSuperSync(syncConfig);
|
||||
|
||||
// Initial sync
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientC.sync.syncAndWait();
|
||||
console.log('[ThreeWay] All three clients set up with encryption');
|
||||
|
||||
// Create task on A
|
||||
const taskName = `ThreeWayTask-${testRunId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// B and C sync to get task
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientC.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, taskName);
|
||||
await waitForTask(clientC.page, taskName);
|
||||
console.log('[ThreeWay] All clients have the task');
|
||||
|
||||
// All three clients make concurrent changes
|
||||
const taskLocatorA = clientA.page
|
||||
.locator(`task:not(.ng-animating):has-text("${taskName}")`)
|
||||
.first();
|
||||
await taskLocatorA.hover();
|
||||
await taskLocatorA.locator('.task-done-btn').click();
|
||||
|
||||
const taskLocatorB = clientB.page
|
||||
.locator(`task:not(.ng-animating):has-text("${taskName}")`)
|
||||
.first();
|
||||
await taskLocatorB.hover();
|
||||
await taskLocatorB.locator('.task-done-btn').click();
|
||||
|
||||
const taskLocatorC = clientC.page
|
||||
.locator(`task:not(.ng-animating):has-text("${taskName}")`)
|
||||
.first();
|
||||
await taskLocatorC.hover();
|
||||
await taskLocatorC.locator('.task-done-btn').click();
|
||||
|
||||
console.log('[ThreeWay] All three clients made concurrent changes');
|
||||
|
||||
// Sequential sync with conflict resolution
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientC.sync.syncAndWait();
|
||||
|
||||
// Final sync round to ensure convergence
|
||||
await clientA.sync.syncAndWait();
|
||||
await clientB.sync.syncAndWait();
|
||||
await clientC.sync.syncAndWait();
|
||||
|
||||
// Wait for state to settle
|
||||
await clientA.page.waitForTimeout(1000);
|
||||
|
||||
// Verify no errors on any client
|
||||
const hasErrorA = await clientA.sync.hasSyncError();
|
||||
const hasErrorB = await clientB.sync.hasSyncError();
|
||||
const hasErrorC = await clientC.sync.hasSyncError();
|
||||
expect(hasErrorA).toBe(false);
|
||||
expect(hasErrorB).toBe(false);
|
||||
expect(hasErrorC).toBe(false);
|
||||
|
||||
// All clients should have consistent state
|
||||
await expect(taskLocatorA).toHaveClass(/isDone/);
|
||||
await expect(taskLocatorB).toHaveClass(/isDone/);
|
||||
await expect(taskLocatorC).toHaveClass(/isDone/);
|
||||
|
||||
// Task counts should match
|
||||
const countA = await clientA.page.locator(`task:has-text("${taskName}")`).count();
|
||||
const countB = await clientB.page.locator(`task:has-text("${taskName}")`).count();
|
||||
const countC = await clientC.page.locator(`task:has-text("${taskName}")`).count();
|
||||
expect(countA).toBe(1);
|
||||
expect(countB).toBe(1);
|
||||
expect(countC).toBe(1);
|
||||
|
||||
console.log('[ThreeWay] ✓ Three clients with encryption converged correctly');
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
if (clientC) await closeClient(clientC);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -378,6 +378,7 @@ export class SyncService {
|
|||
accepted: false,
|
||||
error: conflict.reason,
|
||||
errorCode,
|
||||
existingClock: conflict.existingClock,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -414,6 +415,7 @@ export class SyncService {
|
|||
accepted: false,
|
||||
error: finalConflict.reason,
|
||||
errorCode,
|
||||
existingClock: finalConflict.existingClock,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -155,6 +155,11 @@ export interface UploadResult {
|
|||
serverSeq?: number;
|
||||
error?: string;
|
||||
errorCode?: SyncErrorCode;
|
||||
/**
|
||||
* The existing entity's vector clock when rejecting due to conflict.
|
||||
* Allows clients to create LWW updates that dominate the server's state.
|
||||
*/
|
||||
existingClock?: VectorClock;
|
||||
}
|
||||
|
||||
export interface UploadOpsResponse {
|
||||
|
|
|
|||
|
|
@ -282,6 +282,60 @@ describe('Conflict Detection', () => {
|
|||
expect(result2[0].error).toContain('Concurrent modification');
|
||||
});
|
||||
|
||||
it('should return existingClock in rejection response for concurrent conflicts', async () => {
|
||||
const service = getSyncService();
|
||||
const entityId = 'task-1';
|
||||
|
||||
// First op from client A with clock {a: 1}
|
||||
const op1 = createOp({
|
||||
entityId,
|
||||
clientId: clientA,
|
||||
vectorClock: { [clientA]: 1 },
|
||||
opType: 'CRT',
|
||||
});
|
||||
await service.uploadOps(userId, clientA, [op1]);
|
||||
|
||||
// Second op from client B with clock {b: 1} - CONCURRENT with {a: 1}
|
||||
const op2 = createOp({
|
||||
entityId,
|
||||
clientId: clientB,
|
||||
vectorClock: { [clientB]: 1 },
|
||||
});
|
||||
const result = await service.uploadOps(userId, clientB, [op2]);
|
||||
|
||||
// Should return the existing clock so client can create LWW update
|
||||
expect(result[0].accepted).toBe(false);
|
||||
expect(result[0].existingClock).toBeDefined();
|
||||
expect(result[0].existingClock).toEqual({ [clientA]: 1 });
|
||||
});
|
||||
|
||||
it('should return existingClock in rejection response for stale conflicts', async () => {
|
||||
const service = getSyncService();
|
||||
const entityId = 'task-1';
|
||||
|
||||
// First op with clock {a: 2}
|
||||
const op1 = createOp({
|
||||
entityId,
|
||||
clientId: clientA,
|
||||
vectorClock: { [clientA]: 2 },
|
||||
opType: 'CRT',
|
||||
});
|
||||
await service.uploadOps(userId, clientA, [op1]);
|
||||
|
||||
// Second op with clock {a: 1} - LESS_THAN {a: 2} (stale)
|
||||
const op2 = createOp({
|
||||
entityId,
|
||||
clientId: clientB,
|
||||
vectorClock: { [clientA]: 1 },
|
||||
});
|
||||
const result = await service.uploadOps(userId, clientB, [op2]);
|
||||
|
||||
// Should return the existing clock so client can create LWW update
|
||||
expect(result[0].accepted).toBe(false);
|
||||
expect(result[0].existingClock).toBeDefined();
|
||||
expect(result[0].existingClock).toEqual({ [clientA]: 2 });
|
||||
});
|
||||
|
||||
it('should accept operation when clocks are EQUAL from same client (retry)', async () => {
|
||||
const service = getSyncService();
|
||||
const entityId = 'task-1';
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ export interface RejectedOpInfo {
|
|||
opId: string;
|
||||
error?: string;
|
||||
errorCode?: string;
|
||||
/**
|
||||
* The existing entity's vector clock when rejecting due to conflict.
|
||||
* Allows clients to create LWW updates that dominate the server's state.
|
||||
*/
|
||||
existingClock?: VectorClock;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -165,6 +165,11 @@ export interface OpUploadResult {
|
|||
error?: string;
|
||||
/** Structured error code for programmatic handling */
|
||||
errorCode?: string;
|
||||
/**
|
||||
* The existing entity's vector clock when rejecting due to conflict.
|
||||
* Allows clients to create LWW updates that dominate the server's state.
|
||||
*/
|
||||
existingClock?: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -318,7 +318,12 @@ export class OperationLogUploadService {
|
|||
const rejected = response.results.filter((r) => !r.accepted);
|
||||
if (rejected.length > 0) {
|
||||
for (const r of rejected) {
|
||||
rejectedOps.push({ opId: r.opId, error: r.error, errorCode: r.errorCode });
|
||||
rejectedOps.push({
|
||||
opId: r.opId,
|
||||
error: r.error,
|
||||
errorCode: r.errorCode,
|
||||
existingClock: r.existingClock,
|
||||
});
|
||||
}
|
||||
rejectedCount += rejected.length;
|
||||
|
||||
|
|
|
|||
|
|
@ -374,6 +374,85 @@ describe('RejectedOpsHandlerService', () => {
|
|||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it('should pass existingClock from rejection to stale resolver (FIX: encryption conflict loop)', async () => {
|
||||
// REGRESSION TEST: Bug where encrypted SuperSync gets stuck in infinite conflict loop.
|
||||
// Root cause: Client cannot create LWW update that dominates server state because
|
||||
// it doesn't receive the server's existing entity clock in rejection responses.
|
||||
//
|
||||
// Fix: Server returns existingClock in rejection, client passes it to stale resolver.
|
||||
const op = createOp({ id: 'op-1' });
|
||||
const existingClock = { otherClient: 5 };
|
||||
opLogStoreSpy.getOpById.and.returnValue(Promise.resolve(mockEntry(op)));
|
||||
downloadCallback.and.callFake(async (options) => {
|
||||
if (options?.forceFromSeq0) {
|
||||
return {
|
||||
newOpsCount: 0,
|
||||
allOpClocks: [],
|
||||
snapshotVectorClock: { snapshot: 1 },
|
||||
} as DownloadResultForRejection;
|
||||
}
|
||||
return { newOpsCount: 0 } as DownloadResultForRejection;
|
||||
});
|
||||
staleOperationResolverSpy.resolveStaleLocalOps.and.resolveTo(1);
|
||||
|
||||
await service.handleRejectedOps(
|
||||
[
|
||||
{
|
||||
opId: 'op-1',
|
||||
error: 'concurrent',
|
||||
errorCode: 'CONFLICT_CONCURRENT',
|
||||
existingClock,
|
||||
},
|
||||
],
|
||||
downloadCallback,
|
||||
);
|
||||
|
||||
// Should include existingClock in the extraClocks passed to resolver
|
||||
expect(staleOperationResolverSpy.resolveStaleLocalOps).toHaveBeenCalledWith(
|
||||
jasmine.arrayContaining([jasmine.objectContaining({ opId: 'op-1' })]),
|
||||
[existingClock], // existingClock should be in extraClocks
|
||||
{ snapshot: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should merge existingClock with allOpClocks from force download', async () => {
|
||||
// Test that existingClock is merged with other clocks from force download
|
||||
const op = createOp({ id: 'op-1' });
|
||||
const existingClock = { conflictClient: 3 };
|
||||
const remoteClocks = [{ otherClient: 2 }];
|
||||
opLogStoreSpy.getOpById.and.returnValue(Promise.resolve(mockEntry(op)));
|
||||
downloadCallback.and.callFake(async (options) => {
|
||||
if (options?.forceFromSeq0) {
|
||||
return {
|
||||
newOpsCount: 0,
|
||||
allOpClocks: remoteClocks,
|
||||
snapshotVectorClock: { snapshot: 1 },
|
||||
} as DownloadResultForRejection;
|
||||
}
|
||||
return { newOpsCount: 0 } as DownloadResultForRejection;
|
||||
});
|
||||
staleOperationResolverSpy.resolveStaleLocalOps.and.resolveTo(1);
|
||||
|
||||
await service.handleRejectedOps(
|
||||
[
|
||||
{
|
||||
opId: 'op-1',
|
||||
error: 'concurrent',
|
||||
errorCode: 'CONFLICT_CONCURRENT',
|
||||
existingClock,
|
||||
},
|
||||
],
|
||||
downloadCallback,
|
||||
);
|
||||
|
||||
// Should merge existingClock with allOpClocks
|
||||
expect(staleOperationResolverSpy.resolveStaleLocalOps).toHaveBeenCalledWith(
|
||||
jasmine.arrayContaining([jasmine.objectContaining({ opId: 'op-1' })]),
|
||||
[...remoteClocks, existingClock], // Both clocks should be passed
|
||||
{ snapshot: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should mark ops as rejected when force download returns no clocks', async () => {
|
||||
const op = createOp({ id: 'op-1' });
|
||||
opLogStoreSpy.getOpById.and.returnValue(Promise.resolve(mockEntry(op)));
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { OperationLogStoreService } from '../persistence/operation-log-store.service';
|
||||
import { Operation } from '../core/operation.types';
|
||||
import { Operation, VectorClock } from '../core/operation.types';
|
||||
import { OpLog } from '../../core/log';
|
||||
import { SnackService } from '../../core/snack/snack.service';
|
||||
import { T } from '../../t.const';
|
||||
import { MAX_REJECTED_OPS_BEFORE_WARNING } from '../core/operation-log.const';
|
||||
import { StaleOperationResolverService } from './stale-operation-resolver.service';
|
||||
import { DownloadCallback } from '../core/types/sync-results.types';
|
||||
import { DownloadCallback, RejectedOpInfo } from '../core/types/sync-results.types';
|
||||
import { handleStorageQuotaError } from './sync-error-utils';
|
||||
|
||||
// Re-export for consumers that import from this service
|
||||
export type {
|
||||
DownloadResultForRejection,
|
||||
DownloadCallback,
|
||||
RejectedOpInfo,
|
||||
} from '../core/types/sync-results.types';
|
||||
|
||||
/**
|
||||
|
|
@ -57,7 +58,7 @@ export class RejectedOpsHandlerService {
|
|||
* @returns Number of merged ops created (caller should trigger follow-up upload if > 0)
|
||||
*/
|
||||
async handleRejectedOps(
|
||||
rejectedOps: Array<{ opId: string; error?: string; errorCode?: string }>,
|
||||
rejectedOps: RejectedOpInfo[],
|
||||
downloadCallback?: DownloadCallback,
|
||||
): Promise<number> {
|
||||
if (rejectedOps.length === 0) {
|
||||
|
|
@ -67,8 +68,12 @@ export class RejectedOpsHandlerService {
|
|||
let mergedOpsCreated = 0;
|
||||
|
||||
// Separate concurrent modification rejections from permanent failures
|
||||
// For concurrent mods, we collect the full operation for later processing
|
||||
const concurrentModificationOps: Array<{ opId: string; op: Operation }> = [];
|
||||
// For concurrent mods, we collect the full operation and existingClock for later processing
|
||||
const concurrentModificationOps: Array<{
|
||||
opId: string;
|
||||
op: Operation;
|
||||
existingClock?: VectorClock;
|
||||
}> = [];
|
||||
const permanentlyRejectedOps: string[] = [];
|
||||
|
||||
for (const rejected of rejectedOps) {
|
||||
|
|
@ -130,6 +135,7 @@ export class RejectedOpsHandlerService {
|
|||
concurrentModificationOps.push({
|
||||
opId: rejected.opId,
|
||||
op: entry.op,
|
||||
existingClock: rejected.existingClock,
|
||||
});
|
||||
OpLog.warn(
|
||||
`RejectedOpsHandlerService: Concurrent modification for ${entry.op.entityType}:${entry.op.entityId}, ` +
|
||||
|
|
@ -175,7 +181,11 @@ export class RejectedOpsHandlerService {
|
|||
* Resolves concurrent modification rejections by downloading and merging.
|
||||
*/
|
||||
private async _resolveConcurrentModifications(
|
||||
concurrentModificationOps: Array<{ opId: string; op: Operation }>,
|
||||
concurrentModificationOps: Array<{
|
||||
opId: string;
|
||||
op: Operation;
|
||||
existingClock?: VectorClock;
|
||||
}>,
|
||||
downloadCallback: DownloadCallback,
|
||||
): Promise<number> {
|
||||
let mergedOpsCreated = 0;
|
||||
|
|
@ -189,20 +199,33 @@ export class RejectedOpsHandlerService {
|
|||
// Try to download new remote ops - if there are any, conflict detection will handle them
|
||||
const downloadResult = await downloadCallback();
|
||||
|
||||
// Helper to check which ops are still pending
|
||||
// Helper to check which ops are still pending, preserving existingClock from rejection
|
||||
const getStillPendingOps = async (): Promise<
|
||||
Array<{ opId: string; op: Operation }>
|
||||
Array<{ opId: string; op: Operation; existingClock?: VectorClock }>
|
||||
> => {
|
||||
const pending: Array<{ opId: string; op: Operation }> = [];
|
||||
for (const { opId, op } of concurrentModificationOps) {
|
||||
const pending: Array<{
|
||||
opId: string;
|
||||
op: Operation;
|
||||
existingClock?: VectorClock;
|
||||
}> = [];
|
||||
for (const { opId, op, existingClock } of concurrentModificationOps) {
|
||||
const entry = await this.opLogStore.getOpById(opId);
|
||||
if (entry && !entry.syncedAt && !entry.rejectedAt) {
|
||||
pending.push({ opId, op });
|
||||
pending.push({ opId, op, existingClock });
|
||||
}
|
||||
}
|
||||
return pending;
|
||||
};
|
||||
|
||||
// Helper to extract entity clocks from still-pending ops for merging
|
||||
const extractEntityClocks = (
|
||||
ops: Array<{ existingClock?: VectorClock }>,
|
||||
): VectorClock[] => {
|
||||
return ops
|
||||
.map((item) => item.existingClock)
|
||||
.filter((clock): clock is VectorClock => clock !== undefined);
|
||||
};
|
||||
|
||||
// If download got new ops, conflict detection already happened in _processRemoteOps
|
||||
// If download got nothing (newOpsCount === 0), we need to resolve locally
|
||||
if (downloadResult.newOpsCount === 0) {
|
||||
|
|
@ -220,26 +243,34 @@ export class RejectedOpsHandlerService {
|
|||
const forceDownloadResult = await downloadCallback({ forceFromSeq0: true });
|
||||
|
||||
// Use the clocks from force download to resolve stale ops
|
||||
// Also merge in entity clocks from server rejection responses
|
||||
const entityClocks = extractEntityClocks(stillPendingOps);
|
||||
if (
|
||||
forceDownloadResult.allOpClocks &&
|
||||
forceDownloadResult.allOpClocks.length > 0
|
||||
) {
|
||||
const allExtraClocks = [...forceDownloadResult.allOpClocks, ...entityClocks];
|
||||
OpLog.normal(
|
||||
`RejectedOpsHandlerService: Got ${forceDownloadResult.allOpClocks.length} clocks from force download`,
|
||||
`RejectedOpsHandlerService: Got ${forceDownloadResult.allOpClocks.length} clocks from force download` +
|
||||
(entityClocks.length > 0
|
||||
? ` + ${entityClocks.length} entity clocks from rejection`
|
||||
: ''),
|
||||
);
|
||||
mergedOpsCreated += await this.staleOperationResolver.resolveStaleLocalOps(
|
||||
stillPendingOps,
|
||||
forceDownloadResult.allOpClocks,
|
||||
allExtraClocks,
|
||||
forceDownloadResult.snapshotVectorClock,
|
||||
);
|
||||
} else if (forceDownloadResult.snapshotVectorClock) {
|
||||
// Force download returned no individual clocks but we have snapshot clock
|
||||
} else if (forceDownloadResult.snapshotVectorClock || entityClocks.length > 0) {
|
||||
// Force download returned no individual clocks but we have snapshot clock or entity clocks
|
||||
OpLog.normal(
|
||||
`RejectedOpsHandlerService: Using snapshotVectorClock from force download`,
|
||||
`RejectedOpsHandlerService: Using ${forceDownloadResult.snapshotVectorClock ? 'snapshotVectorClock' : ''}` +
|
||||
`${forceDownloadResult.snapshotVectorClock && entityClocks.length > 0 ? ' + ' : ''}` +
|
||||
`${entityClocks.length > 0 ? `${entityClocks.length} entity clocks from rejection` : ''}`,
|
||||
);
|
||||
mergedOpsCreated += await this.staleOperationResolver.resolveStaleLocalOps(
|
||||
stillPendingOps,
|
||||
undefined,
|
||||
entityClocks.length > 0 ? entityClocks : undefined,
|
||||
forceDownloadResult.snapshotVectorClock,
|
||||
);
|
||||
} else {
|
||||
|
|
@ -266,13 +297,18 @@ export class RejectedOpsHandlerService {
|
|||
if (stillPendingOps.length > 0) {
|
||||
// Ops still pending after download - conflict detection didn't resolve them
|
||||
// This can happen if downloaded ops were for different entities
|
||||
// Merge entity clocks from rejection responses into extraClocks
|
||||
const entityClocks = extractEntityClocks(stillPendingOps);
|
||||
OpLog.warn(
|
||||
`RejectedOpsHandlerService: Download got ${downloadResult.newOpsCount} ops but ${stillPendingOps.length} ` +
|
||||
`concurrent ops still pending. Resolving locally with merged clocks...`,
|
||||
`concurrent ops still pending. Resolving locally with merged clocks...` +
|
||||
(entityClocks.length > 0
|
||||
? ` (including ${entityClocks.length} entity clocks from rejection)`
|
||||
: ''),
|
||||
);
|
||||
mergedOpsCreated += await this.staleOperationResolver.resolveStaleLocalOps(
|
||||
stillPendingOps,
|
||||
undefined,
|
||||
entityClocks.length > 0 ? entityClocks : undefined,
|
||||
downloadResult.snapshotVectorClock,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue