2021년, Nathan, Max, 그리고 저는 Zed Industries를 설립했습니다. 저희의 목표는 두 가지였습니다. 첫째는 매우 빠른 코드 에디터를 만드는 것이었고, 둘째는 팀들이 소프트웨어에 협업하는 방식을 근본적으로 변화시키는 것이었습니다. 현재 수백만 명의 사람들이 Zed를 사용하고 있으며, 저희가 구축한 것에 자부심을 느낍니다.
지난 1년 동안 우리는 두 번째 목표에 집중해 왔습니다.
저는 소프트웨어를 작성하는 과정이란, 머릿속이나 팀원들과 함께 문제를 지속적으로 명확히 하는 과정을 거쳐 코드가 필연적이 될 때까지의 과정이라고 믿습니다. 명확한 사고는 명확한 코드를 낳고; 혼란스러운 사고는 혼란스러운 코드를 낳습니다. 에이전트가 이러한 사실을 바꾸지는 못했습니다. 우리는 단지 훨씬 더 빠르게 코드를 생산하고 있으며, 점점 더 많은 명료화 작업이 에이전트와의 대화 속에서 일어나고 있습니다. 최상의 경우, 해당 코드의 배경 맥락은 주간 에이전트 하니스(agent harness) 안에 갇힙니다. 최악의 경우, 그것은 Slack에 영원히 사라지거나, 에이전트가 작성했고 여러분이 읽지 않은 커밋 메시지에 압축되어 버립니다.
DeltaDB는 저희의 해답입니다. 이것은 에이전트 컨텍스트를 위한 버전 관리 시스템(version control system)입니다. 특정 코드 조각을 가리키고, 그 배경에 깔린 추론 과정을 복구하며, 해당 코드를 작성한 에이전트나 사람에게 왜 그렇게 보이는지 물어볼 수 있습니다.
본 게시물에서는 저희가 인간과 에이전트의 모든 변경 사항을 실시간으로 스트리밍하고, 이를 단일하게 증가하는 인과 그래프(causal graph)로 병합하며, 모든 편집 내용을 해당 출처 대화에 연결해 온 시스템을 어떻게 구축했는지 깊이 파고들 것입니다.
항상 그랬듯이, Zed Industries에서는 빈 레포지토리(repo)부터 시작했습니다.
DeltaDB는 CRDTs(Conflict-free Replicated Data Types) 모음이며, 모두 SQLite에 로컬로 저장되고 방향성 비순환 그래프(directed acyclic graph, DAG) 내에서 연결되어 있습니다. 이 그래프의 모든 노드는 델타(delta)라고 불리며, 표현할 수 있는 가장 작은 변화 단위입니다. 이를 결합하면 에이전트 스레드의 필수 상태—그 대화, 그 파일, 그리고 주변의 모든 것—를 모델링할 수 있게 됩니다.
CRDT(Conflict-free Replicated Data Types)를 사용하면 클라이언트가 자체적으로 진행할 수 있게 되어 네트워크 장애나 분할(partition)에도 탄력성을 유지할 수 있습니다. 궁극적으로 모든 상태는 서버와 동기화되지만, 클라이언트가 지속적으로 연결되어 있을 필요는 없습니다. 변경 사항은 즉시 로컬에서 적용되고 가능한 경우에만 동기화됩니다. 이는 스레드를 비동기적으로 인계하는 것부터 팀원들과 실시간으로 협업하는 것까지 전체 협업 스펙트럼을 구현하기 쉽게 만듭니다.
DAG(Directed Acyclic Graph, 방향성 비순환 그래프)는 시스템의 모든 가능한 중간 상태를 표현할 수 있게 합니다. 이는 몇 가지 좋은 속성을 가집니다. 예를 들어, 스레드와 그 작업 트리(worktree)의 이력을 앞뒤로 쉽게 탐색할 수 있습니다. 또한 매우 간결한 버전으로 시스템 전체 상태를 표현할 수 있게 하는데, 그것이 바로 그래프 끝단에 있는 델타(delta)(또는 프론티어(frontier)라고도 함)입니다. 그 크기는 동시에 열려있는 브랜치 수에 비례하며, 지금까지 존재했던 작성자 수에는 비례하지 않습니다. 저희의 설계는 Gentle과 Kleppmann이 Eg-walker 논문에서 개척한 작업과 매우 유사합니다.
DAG를 사용하는 주된 단점은 델타가 이미 다른 델타의 영향을 포함하는지 감지하기 위해 그래프를 탐색해야 한다는 것입니다. 만약 버전 벡터(version vector)를 사용했다면, 카운터를 비교하여 이를 답변할 수 있습니다. 비록 이것이 부모 링크를 따라가는 것보다 저렴하겠지만, 버전 벡터는 액터(actor)가 소수이고 장기적으로 존재한다고 가정합니다. 그러나 스레드에서는 사람과 에이전트가 끊임없이 오가며, 일단 액터가 일부 변경 사항을 만들면 그 카운터를 줄일 수 없습니다. 따라서 해당 액터가 떠난 후에도 벡터는 계속 증가할 수밖에 없습니다. 아래 애니메이션에서 설명된 것처럼, 점형 버전 벡터(Dotted version vectors)도 유사한 문제를 가지고 있습니다.
조상 질의(Ancestor queries)는 저희에게 좋은 절충안입니다. 왜냐하면 어쨌든 모든 버전을 저장해야 하기 때문입니다. 또한 실질적으로 빠릅니다. 델타의 조상 정보(ancestry)를 페이로드(payload)와 분리하여 저장하기 때문에 그렇습니다. 이 조상 데이터는 매우 작고 인과 순서(causal order)로 저장되므로, 이러한 질의들은 많은 지역성(locality)을 활용할 수 있습니다.
각 델타를 DAG의 노드로 만드는 것은 네트워크 표현을 CRDT가 내부적으로 영향을 표현하는 방식으로부터 분리할 수 있게 합니다. 예를 들어, 텍스트 편집은 다음과 같이 정의합니다:
struct EditTextDelta {
parent: Version,
offset: usize,
...
}
parent와 offset이 동시 편집 상황에서도 버퍼 내의 위치를 고유하게 식별하기에 충분하다는 점에 주목하십시오. 대부분의 경우, 버퍼는 이미 parent 상태에 있으므로 편집은 직접 적용됩니다. 그렇지 않은 경우(예: 그 사이에 동시 편집이 발생한 경우), 클라이언트는 parent 시점의 버전을 *재현화(reifies)*하여 해당 시점의 버퍼를 재구성합니다. 그런 다음 오프셋을 내부 표현(우리 경우에는 프래그먼트 ID, Zed에서 하는 방식과 유사함)으로 변환하고 편집을 적용하는 버전의 버퍼 내에서 해당 프래그먼트 ID를 찾습니다.
이렇게 하면 델타가 전송(on the wire) 시 매우 저렴해지며, 내부 프래그먼트 표현을 변경하고 사용하지 않는 프래그먼트를 페이지 아웃할 수도 있습니다.
우리는 모든 데이터를 SQLite에 저장합니다. 이 데이터는 대략적으로 관계형(relational)과 버전 관리형(versioned)의 두 가지 범주로 나눌 수 있습니다.
관계형 데이터는 매우 간단합니다. 예를 들어, 클라이언트에서는 사용자가 생성하거나 연 모든 스레드를 저장합니다. 이것은 전통적인 테이블에 들어가며 특별한 점이 없습니다.
버전 관리형 데이터가 흥미로운 부분입니다. 전체 시스템은 모든 버전을 빠르게 재현화(reify)할 수 있는 능력에 달려 있습니다. 위에서 보았듯이, 동시 델타를 적용하는 것은 이에 의존하며, 또한 히스토리를 탐색하거나 파일 시스템에서 스레드의 오래된 버전의 작업 트리(worktree)를 마운트할 때 오랜 시간을 기다리고 싶지 않습니다.
이를 빠르게 만들기 위해, 우리는 DAG 내에서 최근에 사용된 노드들의 전체 상태 스냅샷을 캐시하고(우리가 고정하는 몇 가지 중요한 버전도 포함), 원하는 버전부터 시작하여 가장 가까운 캐시된 스냅샷이 있는 조상 노드까지 DAG를 거슬러 올라가면서 그 사이의 모든 델타를 적용합니다.
모든 캐시된 버전에 대한 CRDT(Conflict-free Replicated Data Types)의 전체 사본을 저장하는 것은 비용이 엄청나게 많이 듭니다. 대신, 우리는 전체 상태를 copy-on-write B+ 트리로 표현합니다. 이는 이전 스냅샷의 대부분의 노드를 재사용할 수 있으며, 재생된 델타(deltas)가 건드리는 경로에 대해서만 비용을 지불한다는 것을 의미합니다.
우리의 B+ 트리는 Zed의 SumTree 변형입니다. Zed의 SumTree는 내부 노드가 자식들을 요약하는 append-only B+ 트리입니다. SumTree를 편집하려면, 삽입 지점까지 트리의 접두사(prefix)를 잘라내고, 새 항목을 밀어 넣은 다음, 삭제하려는 항목들은 건너뛰고, 마지막으로 접미사(suffix)를 추가해야 하며, 이 과정에서 절단된 경로의 모든 노드가 재구축됩니다. 반면에 DeltaDB의 B+ 트리는 가변 커서(mutable cursor)를 통해 제자리(in place)에서 편집합니다.
트리에 훨씬 적은 조정만 수행하기 때문에 훨씬 빠릅니다 (아래 애니메이션 참조). 제자리 변형(In-place mutation)은 어떤 노드가 할당되었는지, 변경되었는지, 또는 해제되었는지를 정확하게 추적할 수 있게 합니다. 커밋 시, 새 노드는 삽입물(inserts)이 되고, 변경된 노드는 기존 행에 다시 쓰여지며, 해제된 노드는 참조 횟수(refcount)만 감소합니다. 모든 노드는 일반적인 nodes 테이블에 저장됩니다.
클라이언트는 *타임라인(timelines)*을 사용하여 DAG에서 원하는 만큼의 프런티어(frontiers)를 추적할 수 있습니다. 예를 들어, 서브 에이전트를 시작할 때, 부모 스레드의 프런티어에 위치한 새로운 타임라인을 생성하고 병렬로 변경을 시작합니다. 모든 타임라인은 동일한 DAG를 공유하므로 나중에 언제든지 다시 병합될 수 있습니다. 이 과정 중 어느 것도 Git을 건드리지 않으며, 결정적으로 크로스-에이전트 조정(cross-agent coordination)을 위해 커밋할 필요가 없습니다.
재연결(Reconnections)도 같은 방식으로 처리되므로 델타 전달의 정확성을 이해하기 쉽습니다.
우리는 모든 인간이 점점 더 많은 에이전트를 생성하고 상호 작용하는 세상을 구상합니다. 그런 미래에는 모든 스레드 상태를 단일 서버에 유지하는 것이 어려울 것입니다.
DeltaDB에서는 모든 스레드가 자체 Cloudflare Durable Object에 동기화됩니다. 이 오브젝트는 클라이언트가 처음 연결할 때 활성화되며, 스레드를 생성하는 데 서버 비용이 들지 않습니다. 각 클라이언트는 관심 있는 스레드마다 하나의 WebSocket 연결을 설정합니다.
연결을 설정할 때만 발생하는 인증(authentication) 외에는 본질적으로 공유 없음 아키텍처(share-nothing architecture)입니다. 이는 Cloudflare가 트래픽을 처리할 수 있는 한 스레드 수에 따라 수평적으로 확장(scale horizontally)할 수 있음을 의미합니다.
이는 스레드 전반에 걸쳐 확장할 수 있게 하지만, 여러 에이전트와 사람들도 단일 스레드 내에서 많은 동시 쓰기(simultaneous writes)를 발행할 수 있습니다. 우리는 Durable Object 내에서 이를 효율적이고 비용 효과적으로 만드는 데 많은 노력을 기울였으며, 이 부분은 다음 게시물에서 논의하겠습니다.
Cloudflare는 DeltaDB의 첫 번째 배포 대상입니다. 오늘날까지도 이는 당사의 멀티테넌트(multi-tenant) 배포뿐만 아니라 "자체 Cloudflare를 가져오고 싶은" 고객을 위한 싱글테넌트(single-tenant) 배포에서도 실행됩니다. 하지만 전체 시스템은 오브젝트 스토어, 인증을 위한 내결함성 Postgres 데이터베이스(fault-tolerant Postgres database), 그리고 자체 트랜잭션 저장소와 함께 주소 지정 가능한 단일 스레드 프로세스를 시작할 수 있는 방법을 제공하는 다른 플랫폼으로 이식 가능하도록 설계되었습니다.
실제로는 DeltaDB와 상호 작용할 때 위 사항들 중 어떤 것도 생각할 필요가 없습니다. 전체 시스템은 다음의 원시 요소(primitives)를 가진 더 높은 수준의 클라이언트 API를 통해 노출됩니다:
Register<T>: 마지막 작성자 승리(last-writer-wins) 컨테이너.
Map<K, V>: 추가 승리(add-wins) 맵.
Buffer: 로프(rope) 기반의 협업 가능한 텍스트 조각.
Worktree: Git 위에 계층화된 파일 트리로, 디스크의 어느 곳에나 마운트할 수 있습니다. Worktree는 커밋(commit)에서 시작하며, 각 파일은 커밋된 블롭(blob)에 고정되거나 이후 작성된 델타(delta)를 포함합니다. 커밋을 생성하는 것도 델타를 생성하는데, 이것이 우리의 DAG를 Git의 것과 연결하는 요소입니다.
우리는 위의 원시 요소와 상호 작용하는 데 사용될 수 있는 장기 지속되는(long-lived) deltadb::Client 구조체(struct)를 가지고 있습니다:
client.create(|cx| {
let register = cx.create_register(42);
register.set(43, cx);
...
원시 타입(primitive)을 업데이트하면 자동으로 새로운 델타(deltas)가 발행되어 DAG에 추가되고, 이 데이터는 사용자 대신 디스크와 서버에 동기화됩니다. 또한 #[derive(Replicate)] 매크로를 사용하여 더 복잡한 구조체로 조합할 수도 있으며, 실제로 저희는 에이전트 스레드를 표현하기 위해 그렇게 합니다.
#[derive(Clone, Replicate)]
struct Thread {
#[field(0)]
...
내부를 들여다보면, 이 타입들은 우리가 Snapshot이라고 부르는 더 크고 평평한 CRDT(Conflict-free Replicated Data Type) 내의 포인터에 불과합니다. 이 포인터는 컴파일 타임에는 field 속성을 통해, 또는 런타임에는 이를 생성한 델타의 ID를 가져옴으로써 생성될 수 있습니다.
#[derive(Clone)]
struct Register<T> { id: RegisterId, _type: PhantomData<T> }
#[derive(Clone)]
...
클라이언트 API는 평평한 스냅샷(snapshot)을 직접 노출하지 않지만, 위에서 설명된 포인터 메커니즘을 통해 데이터를 노출합니다.
현재 DeltaDB는 CLI(Command Line Interface)를 통해 또는 타사 에이전트 하네스(agentic harnesses)와 통합되는 플러그인을 사용하여 사용할 수 있습니다. CLI는 이미 자체 프로세스로 실행되므로, SQLite에 직접 상호 작용하는 것은 전혀 문제가 없습니다.
하지만 저희 회사의 역사는 수직 통합(vertical integration)과 문제를 끝에서 끝까지 해결하는 것에 있습니다. 전체 스택을 제어함으로써, 에이전트와 어떻게 상호 작용해야 하는지에 대한 다른 사람의 아이디어를 우회하는 대신 우리가 원하는 UX를 설계할 수 있게 됩니다. 이것이 바로 저희가 자체 개발한 첫 번째 파티(first-party) 에이전트 하네스인 Delta를 구축한 이유입니다. 저는 이것이 에이전트 코딩 분야의 최첨단 기술이라고 믿으며, Zed를 매우 빠르게 만드는 기반 구조 중 많은 부분을 공유하고 있습니다.
네이티브 UI에서는 상황이 매우 다릅니다. 디스플레이의 주사율(refresh rate)에 맞춰 렌더링해야 하므로, 메인 스레드에서 비용이 많이 드는 작업(예: 블로킹 I/O)을 수행하면 저희가 설정한 빠듯한 8ms 프레임 예산을 훨씬 초과하게 됩니다.
좋은 소식은 위에 소개된 API가 실제로 메인 스레드에서 상호 작용하도록 설계되었다는 것입니다. 저희는 Arc를 유지함으로써 이를 달성했습니다.
-allocated version of the Snapshot
초기 로드 중에 생성되는 스냅샷(Snapshot)의 할당된 버전을 사용합니다. 이 시점은 데이터베이스에서 로드하기 위해 비동기적으로 일시 정지할 여유가 있는 순간입니다. 그 이후부터 메인 스레드는 복제된 프리미티브를 조작함에 따라 델타(deltas)를 동기적으로 생성합니다. 이 델타들은 스냅샷의 클론과 함께 백그라운드 스레드로 비동기적으로 전송됩니다. 스냅샷을 클론하는 것이 저렴하다는 점을 기억하세요. 왜냐하면 우리는 Copy-on-Write (COW) 자료 구조만을 사용하기 때문입니다.
백그라운드에서는 기계의 코어 수에 따라 2개에서 4개의 읽기(reader) 스레드와 1개의 전용 쓰기(writer) 스레드를 유지합니다. 포그라운드가 방금 생성한 델타를 게시하면, 쓰기 스레드는 이를 SQLite에 영구적으로 저장하고, 그 노드들이 이전에 설명했던 nodes 테이블에 존재하는 스냅샷의 SQLite 할당 버전에 적용합니다. 해당 트랜잭션이 커밋되자마자, 읽기 스레드가 새로운 델타를 로드하여 쓰기 스레드를 기다리지 않고 서버에 업로드합니다.