ExpertКейс18 min

Кейс: Collaborative Editor

CRDT, OT, real-time синхронизация, conflict resolution и проектирование коллаборативного редактора

Задача

Спроектировать систему реального времени для одновременного редактирования документов несколькими пользователями (как Google Docs). Ключевой challenge -- разрешение конфликтов при одновременных изменениях.

Требования

Требование Значение
Concurrent users До 50 на один документ
Latency < 100ms для локальных операций
Sync delay < 500ms до отображения чужих правок
Consistency Eventual consistency, convergence
Offline Поддержка оффлайн-редактирования с синхронизацией
History Полная история изменений с undo/redo

Подходы к синхронизации

OT (Operational Transformation)

OT -- алгоритм, который трансформирует операции относительно друг друга, чтобы результат применения был одинаковым независимо от порядка.

User A (position 2): Insert "X"    → "abXcde"
User B (position 4): Insert "Y"    → "abcdYe"

Без OT (naive): конфликт — позиции сдвигаются!

С OT:
1. Server получает Op(A) = Insert("X", pos=2)
2. Server получает Op(B) = Insert("Y", pos=4)
3. Transform: Op(B) после Op(A) → Insert("Y", pos=5)  // +1 потому что X сдвинул
4. Result: "abXcdYe" — одинаковый у обоих!

CRDT (Conflict-free Replicated Data Type)

CRDT -- структуры данных, которые гарантируют конвергенцию без центрального сервера. Каждая реплика может применять операции независимо.

User A: вставляет "X" после символа с ID "a2"
User B: вставляет "Y" после символа с ID "a4"

CRDT: каждый символ имеет уникальный ID
Результат: операции коммутативны, порядок не важен
Все реплики сходятся к одному состоянию

OT vs CRDT

OT CRDT
Центральный сервер Нужен Не обязателен
Сложность алгоритма Высокая (transform pairs) Средняя (но больше памяти)
Offline Ограниченно Полная поддержка
Память Эффективна Больше (tombstones, IDs)
Используется Google Docs Figma, Notion, Yjs
Масштабирование Через сервер P2P возможен

Архитектура системы

[Editor Client A]                    [Editor Client B]
      │                                    │
      ├── Local operations                 ├── Local operations
      ├── Apply locally (instant)          ├── Apply locally (instant)
      │                                    │
      └──── WebSocket ────┐    ┌──── WebSocket ────┘
                          ▼    ▼
                   ┌──────────────┐
                   │  Sync Server │
                   │              │
                   │  - Transform │
                   │  - Broadcast │
                   │  - Persist   │
                   └──────┬───────┘
                          │
                   ┌──────┴───────┐
                   │   Database   │
                   │  (operations │
                   │   + document │
                   │   snapshots) │
                   └──────────────┘

WebSocket сервер для синхронизации

<?php

declare(strict_types=1);

namespace App\Collaboration;

/**
 * Document synchronization service.
 * Receives operations from clients and broadcasts to others.
 */
final class DocumentSyncService
{
    /** @var array<string, array<string>> Active connections per document */
    private array $documentSessions = [];

    /** @var array<string, int> Server version per document */
    private array $documentVersions = [];

    public function __construct(
        private readonly OperationStore $operationStore,
        private readonly DocumentStore $documentStore,
    ) {}

    /**
     * Process an operation from a client.
     *
     * @param string $documentId
     * @param string $clientId
     * @param Operation $operation
     * @return SyncResult Operations to broadcast to other clients
     */
    public function processOperation(
        string $documentId,
        string $clientId,
        Operation $operation,
    ): SyncResult {
        $currentVersion = $this->documentVersions[$documentId] ?? 0;

        // If client is behind, transform operation against missed ops
        if ($operation->baseVersion < $currentVersion) {
            $missedOps = $this->operationStore->getOperationsSince(
                $documentId,
                $operation->baseVersion,
            );

            // Transform the incoming operation against each missed operation
            foreach ($missedOps as $missedOp) {
                if ($missedOp->clientId === $clientId) {
                    continue; // Skip own operations
                }

                $operation = $this->transform($operation, $missedOp);
            }
        }

        // Assign server version
        $newVersion = $currentVersion + 1;
        $this->documentVersions[$documentId] = $newVersion;

        // Persist operation
        $serverOp = new ServerOperation(
            documentId: $documentId,
            clientId: $clientId,
            operation: $operation,
            serverVersion: $newVersion,
            timestamp: new \DateTimeImmutable(),
        );

        $this->operationStore->save($serverOp);

        // Return acknowledgement for sender + broadcast for others
        return new SyncResult(
            acknowledgement: new Acknowledgement(
                clientId: $clientId,
                serverVersion: $newVersion,
            ),
            broadcast: new BroadcastOperation(
                operation: $operation,
                serverVersion: $newVersion,
                originClientId: $clientId,
            ),
            recipients: $this->getOtherClients($documentId, $clientId),
        );
    }

    /**
     * Transform operation A against operation B (OT core).
     * This is simplified — real OT has many edge cases.
     */
    private function transform(Operation $incoming, ServerOperation $existing): Operation
    {
        // Both are inserts
        if ($incoming->type === OperationType::Insert && $existing->operation->type === OperationType::Insert) {
            if ($existing->operation->position <= $incoming->position) {
                // Existing insert shifts incoming position right
                return new Operation(
                    type: $incoming->type,
                    position: $incoming->position + strlen($existing->operation->content),
                    content: $incoming->content,
                    baseVersion: $incoming->baseVersion,
                );
            }
        }

        // Existing delete before incoming insert
        if ($existing->operation->type === OperationType::Delete && $incoming->type === OperationType::Insert) {
            if ($existing->operation->position < $incoming->position) {
                return new Operation(
                    type: $incoming->type,
                    position: $incoming->position - $existing->operation->length,
                    content: $incoming->content,
                    baseVersion: $incoming->baseVersion,
                );
            }
        }

        return $incoming; // No transformation needed
    }

    /**
     * Create a document snapshot for new clients joining.
     */
    public function getDocumentSnapshot(string $documentId): DocumentSnapshot
    {
        $document = $this->documentStore->get($documentId);
        $version = $this->documentVersions[$documentId] ?? 0;

        return new DocumentSnapshot(
            documentId: $documentId,
            content: $document->getContent(),
            version: $version,
            cursors: $this->getActiveCursors($documentId),
        );
    }

    private function getOtherClients(string $documentId, string $excludeClientId): array
    {
        $clients = $this->documentSessions[$documentId] ?? [];

        return array_filter($clients, static fn(string $id) => $id !== $excludeClientId);
    }

    private function getActiveCursors(string $documentId): array
    {
        return []; // Cursor positions of active users
    }
}
## Типы операций
<?php

declare(strict_types=1);

namespace App\Collaboration;

enum OperationType: string
{
    case Insert = 'insert';
    case Delete = 'delete';
    case Retain = 'retain'; // Skip N characters
    case Format = 'format'; // Bold, italic, etc.
}

final readonly class Operation
{
    public function __construct(
        public OperationType $type,
        public int $position,
        public ?string $content = null,   // For insert
        public ?int $length = null,       // For delete/retain
        public ?array $attributes = null, // For format
        public int $baseVersion = 0,
    ) {}
}

final readonly class ServerOperation
{
    public function __construct(
        public string $documentId,
        public string $clientId,
        public Operation $operation,
        public int $serverVersion,
        public \DateTimeImmutable $timestamp,
    ) {}
}

final readonly class Acknowledgement
{
    public function __construct(
        public string $clientId,
        public int $serverVersion,
    ) {}
}

final readonly class BroadcastOperation
{
    public function __construct(
        public Operation $operation,
        public int $serverVersion,
        public string $originClientId,
    ) {}
}

final readonly class SyncResult
{
    public function __construct(
        public Acknowledgement $acknowledgement,
        public BroadcastOperation $broadcast,
        /** @var array<string> */
        public array $recipients,
    ) {}
}

final readonly class DocumentSnapshot
{
    public function __construct(
        public string $documentId,
        public string $content,
        public int $version,
        public array $cursors,
    ) {}
}
## Presence (курсоры пользователей)
<?php

declare(strict_types=1);

namespace App\Collaboration;

final class PresenceService
{
    /** @var array<string, array<string, CursorPosition>> */
    private array $cursors = [];

    /**
     * Update cursor position for a user.
     */
    public function updateCursor(
        string $documentId,
        string $userId,
        CursorPosition $position,
    ): void {
        $this->cursors[$documentId][$userId] = $position;
    }

    /**
     * Get all active cursors for a document.
     *
     * @return array<string, CursorPosition>
     */
    public function getCursors(string $documentId): array
    {
        return $this->cursors[$documentId] ?? [];
    }

    /**
     * Remove cursor when user disconnects.
     */
    public function removeCursor(string $documentId, string $userId): void
    {
        unset($this->cursors[$documentId][$userId]);
    }
}

final readonly class CursorPosition
{
    public function __construct(
        public int $offset,
        public ?int $selectionEnd = null,
        public string $userName = '',
        public string $color = '#FF0000',
    ) {}
}
## Конфликтные сценарии
Сценарий Решение
Два пользователя пишут в одно место OT трансформирует позиции
Один удаляет текст, другой редактирует его OT: delete wins, edit отбрасывается
Оффлайн правки + онлайн правки При reconnect: трансформировать все оффлайн-операции
Конфликт форматирования Last-write-wins для атрибутов
Одновременное удаление Idempotent delete (повторное удаление = no-op)

Хранение и History

Snapshot + Operations model

Snapshot (version 0): "Hello"
Op v1: Insert("World", pos=5) → "HelloWorld"
Op v2: Insert(" ", pos=5)     → "Hello World"
Op v3: Delete(pos=0, len=5)   → " World"

Recovery: Apply snapshot + all ops since snapshot
Optimization: Periodic snapshot creation (every 100 ops)
<?php

declare(strict_types=1);

namespace App\Collaboration;

final readonly class DocumentHistory
{
    public function __construct(
        private OperationStore $operations,
        private DocumentStore $documents,
    ) {}

    /**
     * Rebuild document state at a specific version.
     */
    public function getAtVersion(string $documentId, int $version): string
    {
        // Find nearest snapshot before requested version
        $snapshot = $this->documents->getSnapshotBefore($documentId, $version);

        // Apply operations from snapshot to target version
        $ops = $this->operations->getOperationsRange(
            $documentId,
            fromVersion: $snapshot->version,
            toVersion: $version,
        );

        $content = $snapshot->content;

        foreach ($ops as $op) {
            $content = $this->applyOperation($content, $op->operation);
        }

        return $content;
    }

    private function applyOperation(string $content, Operation $op): string
    {
        return match ($op->type) {
            OperationType::Insert => substr($content, 0, $op->position)
                . $op->content
                . substr($content, $op->position),
            OperationType::Delete => substr($content, 0, $op->position)
                . substr($content, $op->position + $op->length),
            default => $content,
        };
    }
}
## Масштабирование
Аспект Решение
Много документов Шардирование по document_id
Много пользователей на документ Один сервер на документ (affinity)
Персистентность Периодические snapshots + append-only operations
Failover Rebuild из operations log
WebSocket масштабирование Redis pub/sub для cross-server broadcast

Итоги

Концепция Суть
OT Трансформация операций для консистентности
CRDT Структуры данных с автоматической конвергенцией
Cursor-based sync Каждая операция привязана к позиции
Presence Отображение курсоров других пользователей
Snapshot + Ops Эффективное хранение истории
Conflict resolution OT transform или CRDT merge

Главный вывод: Коллаборативное редактирование -- одна из сложнейших задач в distributed systems. Начинайте с OT (проще для понимания, нужен сервер), переходите к CRDT если нужен offline и P2P.