(分享)org-supertag 的开发故事:纯文本与数据库同步的搏斗

在发布 org-supertag 新版之前,先听我分享一个故事。

这是一个搏斗的故事。

起源是我开发的 org-supertag,项目的初心是希望将 Tana 的笔记体验迁移到 Emacs,在我开发了一个极简原型之后就面临一个问题——

如果 org-supertag 想像 Tana 那样快速展示一个标签所关联的节点,不能使用纯文本检索的方法。这种方法直接使用检索所有文件,通过正则提取相关的数据,速度慢不说,还会令 Emacs 卡顿。简而言之,这种体验,谁都不能忍。

后来我借鉴自己另外一个项目 org-zettel-ref-mode (以下简称 orz) 的经验,它直接使用哈希表作为数据库,成功地令两个文件的数据通过数据库进行关联,理所当然的,我将这个经验迁移到 org-supertag 上。

这就是搏斗的开始。

org-supertag 的目标不是将两个文件之间的数据进行关联,而是要在数据库里同步在 org-mode 里操作过的数据(CRUD)。很显然,我的第一个想法是,只要用户执行了对应的命令,就会将更改过的数据保存到数据库。

这方法看上去是没啥问题的,然而 Emacs 是一个文本编辑器,org-mode 又已经有很多强大的命令。只要用户不使用 org-supertag 提供的命令,数据就不能保存到数据库中。这是一种困扰:用户必须执行某个命令,才能让数据记录到数据库。这实际上是强迫用户必须使用命令,才能够保证自己的数据记录的安全的。而这通常无法办到,因为人是依照直觉行动的生物,尤其是,当一个人有想法的时候,你不能勉强他,必须使用命令才能开始记录。而是应该让他马上直接记录下来,而不用去担心其它问题。

让我来简化问题:如果用户只是输入了一个标题(尚未赋予 ID),我如何保证这个标题,在用户不执行任何 org-supertag命令的情况下,可以及时地记录到数据库中呢?

这是 org-supertag-sync 这个组件的起源,也是第二次搏斗的开始。它的设计思想是,通过定期扫描用户打开过,修改过的文件,然后通过 org-mode 内置的语法解析器,扫描每一个标题,将其记录成一个数据结构,然后覆盖到数据库对应的记录中。这样子,就不必担心用户新建了标题,而没有同步到数据库的情况。

此时,如何知道一个文件是修改过的,显得尤为重要。我当时想的办法是,为文件生成一个哈希值,通过对比文件的哈希值,就能够知道一个文件是否已经修改过。以此决定,是否扫描一整份文档。

这个方案看上去还行对不对?只要是用户写了标题,就会通过扫描自动同步到数据库,数据的完备性方面是不用考虑了。直到有一天,我发现问题,数据库中出现了重复的记录、通过寻找节点的命令却无法定位该节点的位置……这意味着之前的方法有着很重大的缺陷。是的,这个方法还不行。因为,它只扫描并将一个文件里的节点全部同步到数据库,但无法覆盖以下情形:

  • 节点从一个文件移动到另外一个文件;
  • 节点已删除;
  • 节点重命名;
  • ….

换言之,除了将节点的信息同步到数据库之外,同步的颗粒度,不能光是关注文件的变化,还要关心节点的变化。于是, org-supertag-sync 再一次迎来重构。这一次,我决心解决这个问题。这就是我与数据一致性的第三次搏斗。

这一次策略变得更加细致一些,依然还是从具体文件的变化入手(因为 Emacs 是一个文本编辑器),然后为每一个节点建立一个哈希值,记录到数据库中。具体是这样的:

  • 获取修改过的文件(基于文件时间戳)
  • 扫描这些文件中的节点:
  • 提取节点 ID
  • 计算节点哈希值
  • 与数据库中的哈希值比较

只对发生变化的节点进行处理:

  • 删除:在修改的文件中,找不到该节点,则从数据库移除
  • 移动:在修改的文件中,找到该节点,但文件路径记录有所更换,则在数据库中更新节点的位置信息,保持哈希值
  • 更新:节点的内容发生了变化,则重新同步内容和,更新哈希值
  • 创建:赋予 ID,计算哈希值并存储

终于,我找到办法,解决纯文本与数据库的数据一致性的问题。你说这有什么大不了的?

我在 X 上谈到了:

其实 Logseq 要走纯数据库这一步,是可以预见的。

将纯文本内容同步到另外一个数据库之中,然后再校验双方的一致性。这非常麻烦而且困难。

Logseq 重构 DB 版,从去年开始,到现在尚未正式发布。已经超过了 6 个月的时间。要知道,就连 Shopify 的创始人都在用 Logseq 呢。

BTW:

有耐心看到文末的你,会疑问:这篇东西和 AI 辅助开发有啥关系?

有的,有的。我没有任何开发数据库的经验,所以,所有这些数据库实现的方式,都是 AI 建议的。——三次搏斗的起源,就是来自 AI 不靠谱的建议。

所以在第三次,我靠自己的想法(当然还有之前各种跳坑的经验),终于终于迈过数据一致性的坎。

教训就是,真的不要盲目听从 AI 的建议!

2 个赞

标题有一点点🤏误导,我以为是和 AI 开发相关的经验分享,实际上是 org-supertag 的一个问题解决思路。

2 个赞

其实有的,因为我没有开发数据库的经验,所以所有这些实现方式都是 AI 建议的。

想起来之前看见过的一句话,大意就是「所有执着于完善标签/属性系统的笔记软件最终都会变成手写数据库」

4 个赞

定期扫描是指每隔一段时间扫描一次嘛,这样是如何保证短时间内的数据一致性问题的呢?

会进行增量对比,以文件的修改时间与节点的哈希值作为锚点。每一次扫描,都跟上一次扫描的结果进行对比。第一次扫描会花一点时间,全面扫描监视池里的所有文件。我自己不会设置非常频繁地扫描。

整体流程,之前文章里的排版体现得不好,我重新整理了一下:

  • 获取修改过的文件(基于文件时间戳)
  • 扫描这些文件中的节点:
  • 提取节点 ID
  • 计算节点哈希值
  • 与数据库中的哈希值比较

只对发生变化的节点进行处理:

  • 删除:在修改的文件中,找不到该节点,则从数据库移除
  • 移动:在修改的文件中,找到该节点,但文件路径记录有所更换,则在数据库中更新节点的位置信息,保持哈希值
  • 更新:节点的内容发生了变化,则重新同步内容和,更新哈希值
  • 创建:赋予 ID,计算哈希值并存储

我理解全面的扫描,应该是为了保证对未使用supertag命令时的的修改进行同步,那么如果手动修改了内容之后,又立马需要进行 supertag的操作,此时还没触发扫描,是否就出现了数据不一致的情况呢?

相比之下,在保存buffer的时候,根据diff的内容进行实时的增量同步会不会更好一些

不会啊,在后端的代码里,已经有了旧值,新值的对比,过程之中新值会缓存在内存,然后与保存在数据库里的新值进行对比。

你不理解的部分,大概你不太了解 org-element 这个 API,以及目前 org-mode 的后端实际上是使用 AST 的方式解析整一份文件。

仅仅是基于保存的策略进行对比是不够的。这样子你无法得知节点移动和修改之后的情况,而且这个策略如果只考虑一个文件的保存,那么就更加不够了。

无论是全面扫描还是保存时再启动修改,假设都需要定时器,那么这两个方式没有太大的区别…只要精度到节点级,那么每个节点上哈希(或者是时间戳),也是必须的。

换一个产品去思考,假设是 Notion,它如何将服务器与用户本地的页面的数据进行同步?它怎么知道用户编辑了某个笔记块?那么服务器和本地端,都对同一个笔记块分别记录状态(不管是时间戳,还是哈希值),然后对状态值对比,来判断数据是否发生了修改。

但是如果此时,还没有触发扫描,但是文件已经手动修改了,那么数据库里面的旧值就是错误的呢(正确的旧值应该是当前展示的,未修改之前的值)。

org-element API 可以将 org-mode 文件解析为 AST 这个我是知道的。它可以很方便的比对修改的差异,这和我上面说的这种情况好像关系不大。

只是基于保存当然是不够的,我的意思是可以综合使用这些策略,不同的策略应对不同的场景。保存这个策略可以解决上面说的那个场景,它个小概率事件,但是从严谨性考虑还是需要的。

定时器本质上是一个异步的策略,异步的问题就是会出现一些极限的情况。此时,需要配合一个同步的策略来处理这些 rare case。

这实际上是一个 “最终一致性” 是否可以处理所有场景问题,因为我不了解 org-supertag具体的逻辑。如果最终一致性就够用了,当我没说,哈哈。

哦,我理解你的意思,你的意思说,扫描间隔期间,如果 Emacs 退出或者崩溃等情形,导致状态不同步。

不会的,只要节点发生了改变(可根据标题、TODO STATE、PROPERTY、File-Path,甚至 CONTENT 等方式),在扫描的过程中就会产生新的哈希值,此时与数据库中保存的旧值对比,就可以知道是不同的,只要不同就会触发数据同步的操作。

而且如果发生意外退出,没能保存的,用什么方式也保存不了…(主要是我不知道在这种极限情况,可以怎么处理)

开放交流,我没觉得你挑刺。趁此机会,我又复习一次 org-supertag 的数据同步策略。

我说的不是 emacs 崩溃的情况。

嗯嗯,你这种说的就是最终一致性,如果这样就可以解决所有的问题,那就够用了。至于过程中的修改(比如我需要严格比对 当前修改后的内容 和 当前真正的旧值(与数据库中旧值不一样)),如果不需要关心,我的这个场景就不会出现。

没有,我也没觉得你觉得我挑刺哈哈。我只是在考虑一种理论的情况,实际上脱离了插件本身,毋怪。

是,过程中发生很多修改都没啥关系。但重要的是,能够标记出中间发生了修改。

至于中间发生了很多修改,然后是否每一次修改都「实时同步」到数据库。虽然可以做到,但开销挺大的。我在 org-zettel-ref-mode 里就是通过哈希表同步两个文件的内容,但代价是 org-zettel-ref-mode 的 highlight-refresh 函数需要对所有 org 文件进行不间断地扫描。

如果实时同步只存在与 2 个文件,我觉得这种开销和侵略性还可以忍受。如果是多份文件就有点无法想象…(但我也没尝试过,只是任性地这么认为)

其实可以做到开销不大。主要看如何理解这个“实时”,如果是使用 post-command-hook 这种实时,那确实不现实;如果是我们选择一些合适的时机来“实时”同步,就可以在一定程序上解决个问题。因为保存是所有的文件最终都要经历的过程(且我们确定一次修改结束必定要将buffer的内容落地到文件才算数),所以我会说使用 保存buffer这个 hook。

我不知道实际操作中这个多份文件的量有多少,按道理只考虑当前修改的部分,应该不会很多。据我的经验,数据库的操作是很高效的。

哦哦,如你所说,是可以增加一个 save-buffer 的 hook 来增加一次校验的步骤。