7月12日,Sourcegraph基于LSIF的精确代码智能将在一年前首次提交。
Sourcegraph的精确代码智能功能是由用户上传的LSIF索引驱动的,这些LSIF索引是在他们自己的构建和持续集成系统中创建的。浏览已编制索引的代码时,所有悬停工具提示、定义和参考结果都是精确的,而不是启发式的(基于搜索结果,这是无配置的默认设置)。
这篇帖子反映了随着精确代码英特尔服务通过附加功能、不断变化的环境要求、强化、性能改进、重构和一次不同语言的重大重写而成熟的高级技术变化。这些更改跨越了超过527个提交的+324K/-277K代码行。
严格来说,我可能不能称这篇文章为软件考古工作(因为它只有一年的历史,它得到了积极的使用,而不是遗留的代码库,主要作者仍然在这里谈论它,而且它也不是完全没有文档记录的)。称它为历史文物可能更准确,但恐龙比书籍更酷,所以欢迎来到挖掘现场。
Chris Wendt将该服务的初稿写成一个简单的TypeScript express服务器,由前端服务的HTTP API代理。这使公共服务的数量保持在较低水平,并使请求身份验证流保持在单个代码路径上。Express服务器将接受原始LSIF输入,并将其原封不动地存储在磁盘上。通过概念验证LSIF特定扩展对lsif服务器的查询将读取该存储库的原始LSIF数据,将其解析到内存中,并遍历图表以构造适当的响应。
选择打字稿在当时是很自然的:(当时称为)codenav团队由后来的网络和代码情报团队组成。TypeScript是团队的一项主要核心能力,另外一个好处是能够在未发布的Visual Studio代码扩展中重用服务器包中的代码,以便使用LSIF协议的功能使用者(此时只有0.4.0)启动服务。
这段代码,以及它的编写方式,绝对是MVP的黄金标准。它做了它需要做的事情,以征求用户的反馈,并在可能的功能宇宙中找到自己的位置。正如所有此类最低实现应该注意的那样,它缺乏对可伸缩性、性能和健壮性的关注(因为您不会在立即废弃的特性上花费精力)。
在这个公关开通十天后,我将作为Codenav团队的一员开始我在Sourcegraph的旅程。大约在这个时候,Sourcegraph决定取消语言服务器工作的优先顺序,以支持构建基础设施,以实现LSIF索引支持的精确代码智能。LSIF公告的博客文章概述了我们选择转移重点的一些原因。这使我们能够在扩展其功能集以最终包含语言服务器当前提供的内容的同时,将改进服务的可伸缩性、性能和健壮性方面的工作进行预算。
要解决的第一个问题是选择如何在服务中表示LSIF数据。MVP只是将原始LSIF输入保存在磁盘上,按需将其解析到内存中。并不是每个查询都会从头开始解析LSIF输入-有一个LRU缓存允许多个请求命中相同的LSIF索引,而无需重新解析。但是,LSIF索引可能非常大。首先,将这些索引读取到内存中的时间是不可忽略的,在内存中存储即使是几个大索引也可能导致OOM崩溃。
我们需要找到一种方法来仅查询我们回答查询所需的索引部分,并且我们需要找到一种高效地进行查询的方法。我们决定在上传时将原始LSIF数据转换为内部协议无关的格式,让我们完全控制存储格式。通过控制存储格式,可以针对建议的访问模式高效地设计存储格式:
为了证明或反驳一种存储格式相对于另一种存储格式的性能,我们构建了两个版本的后端(Dgraph后端实现可以在这里找到)。基准测试显示,SQLite和Dgraph后端的上传性能都与输入大小成正比:SQLite的系数在2.2倍到2.8之间,Dgraph的系数在25倍之间。增加这种性能差异的因素可能是多方面的,包括缺乏图形数据库的经验、没有DGRAPH的操作经验,以及对图形模式的错误选择。
我们选择使用SQLite运行,因为它具有更高的初始性能、熟悉性和易于操作的特性。SQLite仍然是Sourcegraph中代码智能捆绑包的磁盘格式。
在此更改之后,原始LSIF上载被立即处理到存储在磁盘上的SQLite文件中,查询只需要访问包含目标源范围的文档。这里适用标准的SQL设计和技巧。
在上一次更改之后,不再在查询路径中解析原始LSIF数据,而是在上载时解析原始LSIF数据。但是,上载仍然是一个HTTP请求,它受制于OSI第5层及更低层的属性、规则和突发奇想。转换过程本身很繁重,而且在优化方面没有得到太多关注-处理大型文件所需的时间会比某些客户端、服务器和/或代理超时时间更长,并且最终会断开对中等大小有效负载的上传请求。
上传一个大文件仍然很容易导致整个过程崩溃,在转换过程中它仍然必须将该文件读取到内存中。因为转换发生在HTTP处理程序中,所以也很容易通过上传多个小文件使lsif-server崩溃,这些小文件的总和超过了分配给lsif-server的内存资源。
我们选择的解决方案是将处理工作从Express服务器提取到另一个服务中,该服务将工作从共享队列中提取出来。上传操作变得很快,因为它只需要传输有效负载就可以命中磁盘,而不需要完全转换。一次处理一个上载可确保一系列上载不会使系统崩溃,而处理大型上载可以通过增加分配给lsif-worker进程的内存来补救。今天,lsif-worker被称为精确编码的intel-worker。
作为共享队列的选择,我们选择了node-resque,这是Rails生态系统中流行的resque库的Node.js端口。这个库使用Redis存储作业数据,它已经是我们堆栈的一个组件。另一种选择包括使用postgres(它是我们堆栈的一个组件,但由一小部分服务保护),使用某种本地IPC(这将阻止我们将来独立地扩展lsif-server和lsif-worker),以及使用AMQP服务器(它还不是我们堆栈中的现有组件)。
到目前为止,我们将转换后的LSIF索引数据一对一地存储在SQLite数据库中:每个上传的LSIF索引都成为磁盘上的单个数据库。为了支持跨存储库查询(跳到远程定义,查找跨存储库的引用),我们有一个额外的xrepo.db数据库,它允许我们通过它们提供的版本化包和它们所依赖的版本化包来查找索引。
到目前为止,我们有一个lsif-server实例和一个lsif-worker实例,它们都部署在相同的Docker容器中,以便两个进程可以共享相同的持久磁盘空间。为了拆分这些服务并进行水平扩展,我们需要将xrepo.db拥有的数据从SQLite中迁移出来,这不允许来自多个进程的并发写入。将这些数据移到我们现有的Postgres实例中很容易:开始和结束状态都定义了几个简单的表,并且不需要任何花哨的技巧。
额外写入的未知规模令我们担忧。是否会导致操作问题或影响应用程序无关部分的性能?为安全起见,我们保持表空间不相交(带前缀的表名,没有指向现有表的外键),并计划支持将此数据库迁移到第二个Postgres实例中。这需要我们使用db_link执行一些令人讨厌的诡计,以便运行迁移,我们发现这非常痛苦,并最终恢复。一些粗略的计算表明,负荷不会压倒Postgres(这无论如何都是很难做到的-Postgres是相当厉害的)。谢天谢地,这些计算结果是正确的。
此更改将作业队列的接口更新为使用BULL而不是节点重新排队。这不是一个重大的架构改变,但是更新库确实消除了我们遇到的一些操作问题,比如员工滞留和失业。切换到新的库还释放了一些额外的功能,比如按时间表运行作业,并能够列出处于特定状态的作业(借助Redis的EVAL命令的魔力,可以在作业有效负载中搜索与查询匹配的文本)。
作业队列的演变仍在继续。此更改会将入队/出队作业的接口更新为目标Postgres,而不是Redis。这大大降低了复杂性。lsif-server和lsif-worker都不再依赖Redis,Redis在应用程序的所有其他部分都被用作临时的、可中继的缓存。所有进入Bull拥有的数据的自定义Lua脚本都可以简化为几个SQL语句。
减少交易期间发生突变的服务数量始终是一件好事。此更改还允许我们为作业队列重用现有的Postgres事务。不再可能在成功转换时提交Postgres事务,但无法将作业标记为已在队列中解决,因为修改队列现在可以作为事务的一部分完成。
此时,lsif-server只能由前端中未记录的代理访问。此代理既用于接受上传有效负载,也用于查询特定源范围的定义、引用和悬停文本。此API的唯一使用者是Sourcegraph创作的语言扩展(sourcegraph/go、source graph/typecript)。
这些数据比简单地支持跳转到定义要有用得多,并且被浪费在未记录的API后面。更改使相同的数据通过GraphQL API在扩展外部可见。这不仅为Sourcegraph的其他功能(如活动和当前正在进行的代码洞察)提供了查询,也为Sourcegraph的外部扩展作者和任何想要运行即席查询的用户打开了查询空间。随着我们通过LSIF索引收集的数据持续增长(我们最近增加了对诊断的支持),可能的使用范围也在不断扩大。我们很高兴能发现这些数据的用途在前方。
此时,lsif-server和lsif-worker捆绑在同一个容器中。我们决定预先派生容器中运行的工作进程的数量,从而增加一次可以处理的上载数量。
这是一种基本的水平扩展方式,因为资源仍然限于物理节点,并且工作进程之间不存在隔离:如果一个工作进程正在处理特定的大型上载,可能会导致其他进程耗尽内存。所有工作进程将立即崩溃,lsif-server进程也将随之崩溃。
在这一点上,这是一个合理的权衡,并为我们带来了一些快速的胜利,同时增加了处理吞吐量。这一特别的改变还帮助我们克服了Sourceraph.com部署中遇到的一些问题,当时我们的Sourceraph.com部署正受到线头阻塞的困扰。
一直以来,我们的计划是在必要时水平扩展lsif-server和lsif-worker实例(工作器可能需要首先扩展,因为他们执行的CPU和内存密集型工作最多)。不幸的是,我误解了Kubernetes持久卷的功能:我假设磁盘可以作为RWX或读写多个节点挂载到多个节点上。事实证明,这种访问模式只有几个卷插件(Azure、CephFS、Glusterfs和NFS等等)支持。我们用于Sourceraph.com部署的GCEPersistentDisk插件不支持此访问模式。
我们前进的道路是增加另一个层次的间接性,老实说,这是解决大多数问题的办法。此更改引入了一个新的lsif-dump-manager,今天称为精确代码英特尔捆绑管理器,该服务拥有持久磁盘。
这从lsif-server和lsif-worker中解放了读取或写入磁盘的任何责任,使它们能够自由地水平和无状态地扩展。扩展转储管理器本身是一个不同的问题,但由于我们的Gitserver的分片特性,我们已经有了一些经验。
在这一点上,代码导航团队已经被分成网络和代码英特尔已经有一段时间了。在代码英特尔团队的成员发生了一些变化后,我们获得了很多围棋人才,但也失去了一些打字人才。我们不再拥有相同的核心能力,从组织和架构的角度来看,重写现有的打字脚本后端代码以便将其与更大的Sourcegraph代码库统一起来是有意义的。
我们决定慢慢地将每个服务重写为Go。我们的高级计划是首先将lsif-server完成的CPU受限工作提取到一个高性能的GO进程中,该进程可以被现有的工作者代码调用(这类似于重构的扼杀图模式)。堆栈的这一部分似乎从重写中获益最多,并允许我们使用现有知识来积极优化围棋,而不必学习Node.js环境的低级优化技术。最终,越来越多的工人将落入围棋代码的事件范围,最终整个工人将被取代。
事实证明,在用不同的语言修复所有错误之后,用您熟悉的一种语言编写相同的系统是相当容易的,而我只是在一次通过中重写了所有这三个服务。由此产生的代码并不是特别习惯用法,因为它实际上是一个快速的、行为等价的目标语言翻译。自重写以来,为了将代码塑造成更适合新环境的东西,一直在进行不断的、小规模的重构。
这释放了大量的性能改进机会,其结果将在Sourcegraph 3.17发布公告中详细说明。技术细节在一篇关于精确代码英特尔性能改进的博客文章中进行了描述。
在GO中重写服务之后,lsif-server进程不再保持本地状态,并且对它的查询不再跨越语言边界。几乎没有理由将这项服务分开。前端的HTTP API和GraphQL解析器使用的客户端能够直接执行与旧服务器的HTTP处理程序相同的功能,并且可以删除新未使用的HTTP服务器/路由代码。
这一变化在上面提到的关于精确代码英特尔性能改进的博客文章中有更详细的讨论。
旅程不会在这里停下来。我们计划继续增加功能,并对服务进行调整,以支持大规模。我们关注的问题包括(但不限于):
创建大规模公共索引(以支持从私有代码跳转到OSS)