Fleet
More Than a Code Editor
Fleet의 내부 구조, 파트 IV – 분산형 트랜잭션
목차
JetBrains의 차세대 IDE인 Fleet 구축에 대해 알아보는 연재 게시물입니다.
이 연재물의 파트 III에서 Fleet 상태의 요소를 표현, 저장 및 변경하는 방법을 중점적으로 살펴보았습니다. 이번 편에서는 분산형 IDE로서 Fleet의 특징을 설명하고 모든 분산형 구성 요소에서 데이터 일관성을 보장하는 방법을 설명합니다.
Fleet은 개발자 간 실시간 원격 공동 작업을 지원하는 플랫폼입니다. 매우 흥미로운 기술 분야죠. 먼저 분산형 환경에서 작업 중 직면할 수 있는 문제를 살펴보겠습니다.
분산형 작업 중 발생 가능한 문제
A와 B라는 두 명의 사용자가 있다고 가정해 보겠습니다. 두 사용자는 다른 시스템상에서 동일한 Fleet 문서를 작업하고 있습니다. 네트워크 채널을 사용하면 지연이 무작위로 발생합니다. 각 사용자는 다른 시점에 다른 사용자의 활동에 대한 정보를 수신합니다. Fleet은 이 문제를 어떻게 처리할까요?
분산형 환경에서 Fleet의 작동 방식을 설명하기 위해 몇 가지 단순한 샘플 시나리오를 활용해 보겠습니다.
- 시나리오 1: 사용자 A가 Fleet 인터페이스를 통해 파일 이름 변경을 변경. 그 결과 사용자 B의 UI가 업데이트된 경우.
- 시나리오 2: 사용자 A가 Fleet 인터페이스를 통해 파일 이름을 변경. 그와 동시에 사용자 B가 문서에 단어를 추가. 그 결과 둘의 UI가 모두 업데이트된 경우.
- 시나리오 3: 사용자 A가 함수 호출로 괄호를 연 후 Fleet 명령어를 호출하여 괄호의 짝을 맞춤. 그와 동시에 사용자 B가 여는 괄호를 제거함. 그 결과 짝을 맞출 괄호가 사라짐. 사용자 A와 B 중 누가 이길까요?
Fleet 상태 관리에 대해 읽었다면 위의 모든 시나리오에서 핵심은 Fleet 상태 변경이라는 것을 알고 계실 겁니다. Fleet은 대응하는 엔티티의 속성 값을 업데이트하고 새로운 상태 스냅샷을 제공합니다. 이에 따라 모든 UI 요소는 새 값으로 업데이트됩니다.
더 자세히 살펴보자면 이 시나리오의 모든 사용자 작업은 명령어 시퀀스로 컴파일됩니다. 명령어는 값 추가 또는 제거와 같이 간단할 수 있으며, 속성 또는 전체 엔티티에서 작동할 수도 있습니다. 이러한 명령어 시퀀스가 네트워크를 통해 다른 Fleet 인스턴스로 이동합니다.
Fleet은 (원자성, 일관성, 고립성 및 지속성 제공을 위해) 트랜잭션의 명령어를 실행하고, 결과적으로 새로운 상태 스냅샷을 제공합니다. 또한 우리는 Fleet이 모든 트랜잭션(데이터 추가 및 제거 세트)에 대한 변경 사항의 참신성을 기록하고, 읽기 추적 및 쿼리 반응성을 사용하여 영향을 받는 UI 요소를 업데이트한다는 것도 알고 있습니다.
이를 시나리오 컨텍스트에 적용해 보겠습니다. 시나리오 1의 경우, Fleet에 파일 이름 변경 명령어가 있을 수 있습니다. 사용자 A가 Files(파일) 뷰를 통해 파일 이름을 변경하면 해당 명령어가 실행되고, 열린 파일 문서의 파일 주소가 변경됩니다(대응하는 속성 값 변경).
- [18 :fileAddress "~/file.kt"] + [18 :fileAddress "~/newFile.kt"]
또한 문서 탭 이름도 변경됩니다(UI 요소 업데이트). 사용자 B에 대해서도 동일한 명령어를 실행하여 사용자 B의 시스템에서 UI 요소를 업데이트해야 합니다.
시나리오 2의 경우, 위 작업 외에도 사용자 B의 Fleet 인스턴스가 명령어를 실행하여 다음과 같이 변경됩니다.
- [19 :text ""] + [19 :text "hello"]
두 사용자의 Fleet 인스턴스 모두에서 최종 내용은 “hello”이며, 파일 이름이 변경될 것으로 예상할 수 있습니다.
시나리오 3의 경우 상황이 조금 다릅니다. 두 사용자 모두 텍스트 “val x = f(“로 시작한다고 가정할 때 사용자 A가 괄호 짝 맞추기 명령어를 실행하면 텍스트가 다음과 같이 변경될 수 있습니다.
- [19 :text "val x = f("]
+ [19 :text "val x = f()"]
사용자 B가 여는 괄호를 제거하면 다음과 같이 표시될 겁니다.
- [19 :text "val x = f("]
+ [19 :text "val x = f"]
이때 새로운 시작 상태에서 괄호 짝 맞추기 명령어를 실행하면 아무런 변화도 없어야 합니다. 사용자 A와 B가 편집 작업을 수행하는 실제 타이밍과 무작위로 발생하는 네트워킹 지연에 따라 최종 결과가 달라질 수 있습니다. 이 상황을 적절히 이해하려면 Fleet의 내부 구조를 이해해야 합니다.
분산형 상태 관련 참고 사항
지금까지는 상태가 하나뿐인 상황을 가정했습니다. 이전 게시물에서 상태 스냅샷 간 전환을 담당하고 최신 스냅샷에 대한 참조를 제공하는 커널이라는 하나의 구성 요소에 대해 설명해 드렸습니다. 실제로 Fleet은 분산형 IDE이므로 여러 개의 상태와 커널이 있습니다. 모든 프런트엔드 구성 요소에 고유한 상태 및 커널이 있는 것이죠. 상태의 일부는 로컬이지만 작업공간에서 관리하는 공유 상태도 있습니다(파트 I의 개요 참조). 작업공간에도 고유한 커널이 있습니다. 작업공간 커널의 주요 목표는 모든 프런트엔드 간 상태를 동기화하는 것입니다.

작업공간은 개발자의 시스템 중 한 곳이나 클라우드에서 실행될 수 있습니다. 이로 인해 상태가 분산되고, 인터넷상에서 상태 동기화가 필요하게 되는 등 다양한 복잡성이 초래됩니다. 다행히 리더(reader) 락, 불변성 및 트랜잭션 메커니즘이 없으므로 모든 문제는 해결 가능합니다.
이전 섹션의 샘플 시나리오에서 사용자 A와 B가 작업 중인 문서도 네트워크상에서 분산되어 있습니다. 해당 문서는 작업공간에 저장되고, 그 사본은 모든 프런트엔드 구성 요소에 저장됩니다.
모든 사용자의 편집 작업(타이핑, 괄호 짝 맞춤, 삭제 등)은 해당 작업을 시작한 사용자의 프런트엔드, 작업공간 및 기타 모든 프런트엔드에서 Fleet 명령어 형태로 실행됩니다. 해당 명령어는 작업공간의 커널을 통해 모든 프런트엔드에 분산됩니다. 이 명령어를 실행하면 모든 종속 UI 요소가 업데이트되어야 합니다.
일부 원격 구성 요소에서 변경 사항을 확인할 때까지 작업을 시작한 사용자의 UI가 대기 상태로 남기를 바라시나요? 당연히 아닐 겁니다. 그렇게 되면 불편한 지연이 발생할 테니까요. Fleet은 분산형 IDE이므로 사용자가 로컬 상태로 작업하는 것처럼 보이게 하려면 먼저 로컬에서 명령어 전체를 실행해야 합니다. 하지만 다른 사용자로 인해 변경 사항이 전역에서 확인되지 않을 가능성이 있습니다. 이 경우 로컬 Fleet의 프런트엔드는 작업을 잠시 멈추고 상태가 작업공간의 전역 프런트엔드 상태와 일치하도록 합니다.
트랜잭션 동기화
다시 샘플 시나리오를 살펴보겠습니다. 시나리오 1에서 사용자 A는 파일 이름을 변경합니다. 이때 Fleet의 상태가 변경되므로 해당 트랜잭션을 실행하고 영향을 받은 UI 요소를 업데이트합니다. 사용자 A의 프런트엔드는 파일 이름 변경 명령어를 작업공간으로 전송합니다. 작업공간에서 해당 명령어를 실행하고, 문제가 없는지 확인한 후 사용자 B의 프런트엔드로 전송합니다. 사용자 B의 프런트엔드에서 이 명령어를 실행하고 사용자 B의 UI에 변경 사항을 반영합니다. 어떤 문제도 발생하지 않습니다.

시나리오 2에서는 파일 이름이 변경되며, 내용이 편집됩니다. 작업공간에 먼저 도달하는 작업에 따라 트랜잭션 순서가 바뀔 수 있으나 최종 결과는 동일합니다. 다음 다이어그램에서 사용자 A의 트랜잭션이 먼저 적용된 것을 확인할 수 있습니다. 사용자 A의 트랜잭션은 전역으로 가장 먼저 실행됩니다.

사용자 B에게는 한동안 트랜잭션 A가 없는 상태가 표시되지만, 작업공간 동기화 이후 트랜잭션 A 다음에 트랜잭션 B가 적절한 순서로 재생되었습니다.
작업공간은 프런트엔드에서 어떤 순서에 따라 명령어를 수신하지만, 시퀀스는 완료됩니다. 모든 프런트엔드는 (잠시 동안은 오래된 UI 요소를 표시할 수 있어도) 결국 최종 순서를 준수해야 합니다. 따라서 지정된 순서에 따라 트랜잭션을 재생해야 할 수 있습니다.
트랜잭션 재생은 무엇일까요? Git 사용자라면 git-rebase라는 유사한 개념을 알고 계실 겁니다. 트랜잭션 재생 시 동일한 명령어가 실행되지만, 새로운 상태로 실행됩니다. 상태 지속성이 있고 작업공간에서 확인되지 않은 트랜잭션 기록의 일부를 알고 있기에 트랜잭션 재생은 실용적이고 효율적입니다.
이제 시나리오 3의 괄호 짝 맞추기 명령어 구현 문제를 자세히 살펴보겠습니다.
- 우리는 코드 줄을 보고 상태를 읽습니다.
- 여는 괄호가 있으면 닫는 괄호를 추가합니다. write 명령어를 전송합니다.
- 여는 괄호가 없으면 코드 줄을 그대로 둡니다. 이 경우 명령어를 전송하지 않습니다.
문제는 코드를 실행하는 상태에 따라 명령어의 결과 시퀀스가 달라질 수 있다는 것입니다.
Fleet에서 다음 다이어그램과 같은 상황이 발생하면 어떻게 될까요?

가장 핵심적인 문제는 해당 상황을 인지하는 것입니다. Fleet에서 읽기 유효성 검사가 사용됩니다. (오래된 상태를 기반으로 한) 트랜잭션의 명령이 유효하지 않다는 점이 확인되면 Fleet은 프런트엔드 코드가 현재 상태에 기반한 명령어를 다시 생성하도록 합니다.
읽기 유효성 검사
솔루션은 트랜잭션 빌드 중 수행되는 모든 읽기 작업에 대해 특별한 Validate 명령어를 추가하는 것입니다. 새 상태에 대한 트랜잭션의 유효성이 검증되면 특별한 처리가 불필요하지만, 유효성이 검증되지 않으면 다시 빌드해야 합니다.
읽기 작업의 유효성은 어떻게 검증할 수 있을까요? 해시를 계산하여 읽은 값을 추적하는 방식은 값이 클 수 있으므로 적합하지 않습니다. 전체 문서 텍스트는 상태의 일부로 저장되기에 더 효율적인 방식이 필요합니다.
엔티티 ID, 속성 및 값으로 구성된 트리플인 datom을 기억하시나요? 사실 datom은 트리플이 아닌 쿼드러플입니다. 그동안 사실대로 말씀드리지 못해 죄송합니다! datom의 네 번째 구성 요소인 tx라는 정수는 트리플 값의 변경 기록을 추적하는 역할을 합니다.
모든 datom은 트랜잭션 내부에서 생성되며, datom을 작성하려면 해당 명령어가 다른 datom을 읽어야 합니다. 새로 생성된 datom은 트랜잭션 ID를 해싱한 결과인 tx 값과 명령어로 읽은 datom의 모든 tx의 정렬된 시퀀스를 수신합니다. 결과적으로 이 tx 번호에는 해당 값의 전체 기록이 기록됩니다.
트랜잭션 유효성 검증 시 첫 번째 로컬 실행을 통해 얻은 tx 번호와 작업공간에서 트랜잭션을 재생할 때 얻은 다른 번호를 간편하게 비교할 수 있습니다. 다른 숫자가 나온다는 것은 트랜잭션 생성 시 사용된 값의 기록이 다르다는 것을 입증합니다. 따라서 트랜잭션을 무효화한 후, 새 상태 스냅샷을 사용하여 처음부터 다시 작성해야 합니다. tx를 데이터베이스 내부의 특정 값에 대한 데이터 흐름 추적으로 간주할 수 있습니다.
시나리오 3을 다시 살펴보자면, 작업공간의 작동 방식은 다음과 같습니다.
- 작업공간에서 사용자 B의 여는 괄호 삭제 트랜잭션을 수신하고 실행한 후 유효성을 검사합니다.
- 다음으로 사용자 A의 괄호 짝 맞추기 명령어를 수신 및 실행하면 해당 명령어가 유효하지 않다는 점이 확인됩니다(:text 속성의 잘못된 tx 값).
- 새로운 상태 스냅샷을 기반으로 괄호 짝 맞춤 명령어를 처음부터 다시 작성하고 실행합니다(사실 여는 괄호가 없으므로 변경된 사항도 없음).
- 명령어의 최종 버전을 프런트엔드에 배포한 후 전역 및 로컬 상태를 동기화합니다.
사용자 A에게 한동안 짝을 맞춘 괄호가 표시될 테지만 동기화 후에는 괄호가 표시되지 않습니다. 즉, 어느 편에 있는지에 따라 승리 또는 실패 여부가 결정되는 것입니다.
요약
Fleet 빌드 방법을 설명하는 네 번째 연재물에서는 분산형 프런트엔드 구성 요소 간 동기화 문제를 살펴보았습니다. 모든 변경 사항은 트랜잭션에서 실행되는 명령어 시퀀스의 형태로 제공되며 작업공간은 프런트엔드에서 이러한 명령어를 수신하고 실행한 후 유효성을 검증하여 다시 배포합니다. 모든 프런트엔드에서 한동안 다른 상태가 표시될 수 있으나, 결국 모든 프런트엔드 상태는 전역 상태와 일치하게 됩니다.
이 연재물을 통해 더 다양한 정보를 다룰 예정이니 나중에 또 확인해 보세요!
게시물 원문 작성자
Discover more
Fleet 后台探秘,第四部分 – 分布式事务
在本系列博文中,我们将以多个部分为您介绍构建 Fleet 这款由 JetBrains 打造的下一代 IDE。
本系列第三部分重点介绍了如何表示、存储和更改 Fleet 状态的元素。 在此部分中,我们将讨论作为分布式 IDE 的 Fleet,以及它如何保证数据在所有分布式组件之间的一致性。
Fleet 是供开发者进行实时远程协作的平台。 这是一个令人兴奋的技术领域。 我们先来分析在分布式环境中可能面临的问题。
分布式操作的问题
假设两个用户,用户 A 和用户 B。他们在 Fleet 中处理同一个文档,但使用不同的机器。 使用网络信道会引入随机延迟,导致每个用户在不同时间收到其他用户的活动信息。 Fleet 应该如何处理?
为了阐明 Fleet 在分布式环境中的运作方式,我们将使用几个经过简化的示例场景:
- 场景 1:用户 A 通过 Fleet 的界面重命名文件。 用户 B 的 UI 得到更新。
- 场景 2:用户 A 通过 Fleet 的界面重命名文件。 同时,用户 B 将一个字词追加到文档。 两个 UI 都得到更新。
- 场景 3:用户 A 在函数调用中打开一个圆括号,然后调用 Fleet 的命令平衡圆括号。 同时,用户 B 移除了左圆括号。 这样就没有圆括号可以平衡了! 最后会得到什么结果?
了解过 Fleet 中的状态管理后,我们知道上述场景都是在更改 Fleet 的状态。 Fleet 更新相应实体的特性值并提供新的状态快照。 然后,所有 UI 元素将使用新值进行更新。
如果我们再进一步,场景中的所有用户动作都被编译成指令(instructions)序列。 指令可能就像添加或移除值一样简单。 它们还可以处理特性或整个实体。 这些指令序列通过网络传输到其他 Fleet 实例。
Fleet 在事务中执行指令(提供原子性、一致性、隔离性和持久性)并提供新的状态快照。 我们还知道,Fleet 会记录每个事务的变化的新颖点(datom 的添加和移除集),并使用读取跟踪和查询反应性更新受影响的 UI 元素。
回到我们的场景,对于场景 1,Fleet 可以有一个重命名文件的指令。 如果用户 A 通过 Files(文件)视图重命名文件,则执行此指令并更改打开的文件文档的文件地址(更改相应特性的值):
- [18 :fileAddress "~/file.kt"] + [18 :fileAddress "~/newFile.kt"]
这还会更改文档选项卡名称(更新 UI 元素)。 同样的指令也应该为用户 B 执行,更新用户 B 机器上的 UI 元素。
在场景 2 中,除了上述动作,用户 B 的 Fleet 实例还会执行一条指令,导致以下更改:
- [19 :text ""] + [19 :text "hello"]
我们预计将得到“hello”的最终内容和两个 Fleet 实例中的重命名文件。
场景 3 就有些不同了。 假设两个用户都以文本 “val x = f(“ 开头。 用户 A 执行圆括号平衡命令可能导致以下更改:
- [19 :text "val x = f("]
+ [19 :text "val x = f()"]
如果用户 B 移除左圆括号,我们会观察到:
- [19 :text "val x = f("]
+ [19 :text "val x = f"]
如果我们现在执行圆括号平衡指令,从新的初始状态开始,应该不会出现任何变化。 根据用户 A 和用户 B 编辑动作的实际时间,加上随机网络延迟,我们会得到不同的最终结果。 只有了解 Fleet 的运作方式,才能理清这里的情况。
关于分布式状态
我们一直在假设只有一个状态。 上一部分提到一个内核(kernel),这个组件负责状态快照之间的转换并提供对最新快照的引用。 事实上,Fleet 为分布式,具有很多状态和内核。 每个前端组件都有自己的状态和内核。 它们的部分状态属于本地,但也有一个由工作区管理的共享状态(请参阅第一部分了解概述)。 工作区也有自己的内核。 工作区内核的主要目标是在所有前端之间同步状态。

工作区可以在开发者的机器上或云中运行。 这使状态成为分布式,并增加了复杂性,例如需要通过互联网同步状态。 好消息是,没有了读取器锁定、不变性和事务机制,所有问题都可以解决。
对于上一部分中的示例场景,用户 A 和 B 处理的文档也分布在网络上。 它存储在工作区中,副本存储在所有前端组件中。
所有用户编辑动作(包括输入、圆括号平衡和删除)都以 Fleet 指令形式在其用户前端以及工作区和其他前端中执行。 相应指令通过工作区的内核分布在所有前端。 执行后,所有依赖 UI 元素都应该得到更新。
您希望发起者的 UI 等待远程组件确认更改吗? 当然不希望,因为这会带来延迟。 Fleet 的分布式特性要求它首先在本地完全执行指令,给人一种用户正在使用本地状态的印象。 由于存在其他用户,更改可能不会得到全局确认。 在这种情况下,本地 Fleet 的前端会退后一步,使状态与工作区的全局状态一致。
同步事务
我们回到示例场景。 在场景 1 中,用户 A 重命名文件。 这改变了 Fleet 的状态,因此我们为它运行事务并更新受影响的 UI 元素。 用户 A 的前端向工作区发送重命名文件指令。 工作区执行指令,确认一切正常,然后将其发送到用户 B 的前端。 用户 B 的前端执行指令并在用户 B 的 UI 中反映更改。 完全没有问题。

在场景 2 中,我们既重命名文件也编辑内容。 事务顺序根据首先到达工作区的动作而异,但最终结果相同。 如下图所示,最后是用户 A 的结果。 用户 A 的事务是第一个全局事务。

在一段时间内,用户 B 观察到没有事务 A 的状态,但是在与工作区同步后,事务 B 在事务 A 之后以正确的顺序重放。
工作区以某种顺序从前端获取指令,并最终确定为这一顺序。 所有前端最后都必须遵守最终顺序(虽然它们可以在短时间内显示过时的 UI 元素)。 因此,它们可能被迫以指定顺序重放事务。
什么是重放事务? 它与 Git 用户常用的 git-rebase 类似。 重放事务时,我们执行相同的指令,但是在新的状态下。 由于状态持久性以及我们知道一些未经工作区确认的事务历史记录,这既实用又高效。
接下来我们转到场景 3 并深入研究圆括号平衡命令的实现:
- 我们查看行 – 读取状态。
- 如果有左圆括号,则添加右圆括号 – 我们发出写入指令。
- 如果没有左圆括号,则保持原样 – 在这种情况下我们不发出任何指令。
问题在于,根据我们执行此代码的状态,生成的指令序列可能会有所不同。
如果 Fleet 处于下图所示的情况,该怎么办?

此时的主要问题是如何理解我们处于这样的情况。 为此,Fleet 将使用读取验证(read validation)。 明确事务中的指令无效后(基于过时的状态),Fleet 会强制前端代码根据当前状态重新生成指令。
读取验证
我们的解决方案是为构建事务期间执行的每个读取操作添加一个特殊的 Validate 指令。 如果针对新状态验证了事务,则不需要特殊处理。 否则,应该将其重建。
我们如何验证读取操作? 通过计算哈希值来跟踪读取值是不可信的,因为值可能很大。 请注意,整个文档文本都存储为状态的一部分。 我们需要提高效率。
还记得包含实体 ID、特性和值的三元组 datom 吗? 它们实际上不是三元组,而是四元组。 没错, 其中还有第四个组件,整数 tx,负责跟踪三元组中值的变化历史记录。
每个 datom 都是在事务中诞生的。 要进行写入,相应指令必须读取其他 datom。 新生的 datom 会收到 tx 值,作为事务 ID 哈希处理的结果,以及指令读取的所有 datom 的 tx 排序序列。 最后,此 tx 数字会记录值的整个历史记录。
要验证事务,我们可以直接比较 tx 数字,也就是第一次本地运行获得的数字,以及在工作区中播放事务时获得的数字。 不同的数字表明用于事务生成的值的不同历史记录,因此我们必须使事务失效,再用新的状态快照从头开始重建。 tx 可被视为对数据库中特定值的数据流跟踪记录。
回到场景 3,工作区的工作方式如下:
- 接收、执行并验证用户 B 对左圆括号的删除。
- 接收并执行用户 A 的圆括号平衡指令,发现它不再有效(:text 特性的 tx 值错误)。
- 根据新的状态快照从头开始重建圆括号平衡指令并执行指令(实际并无更改,因为不存在左圆括号)。
- 将指令的最终版本分布到前端并同步全局和本地状态。
请注意,用户 A 观察到一段时间的已平衡圆括号,但是在同步之后就不再有圆括号了。 问题解决! 或者未解决,这就要看您站在哪一边了。
总结
在关于如何构建 Fleet 的第四部分中,我们讨论了分布式前端组件之间的同步问题。 所有更改都采用在事务中执行的指令序列的形式。 工作区从前端接收这些指令,然后将其执行、验证和分布。 所有前端最终都同意一个全局状态,尽管它们会在一段时间里观察到不同的状态。
本系列还有更多内容,请继续关注!
本博文英文原作者: