2029选举:一个无法解释的异常
我原以为我已经写过关于我的Election 2029网站(https://election2029.uk)的第一篇博客文章,但看来我的博客进度比我想象的要落后。因此,这是这个系列中有点奇怪的第一篇文章,但没关系。在某种程度上,它与选举网站并没有特别相关,除了:a)这是相对现代的C#,与我的大多数代码库相比;b)它将解释相关的两个字符串。
我无法强调 enough:当我的代码行为不当时,几乎总是我的错。(我之前已经写过关于这个的博客。)
但在星期四,我看到了一个无法解释的异常。这篇文章将提供一些背景信息,展示涉及的代码和异常消息,以及我的下一步(相当弱的)步骤。这篇文章的目的有三个:
- 这是一种谦卑的行为:如果我在诊断一个棘手问题后对自己感到满意时发帖,那么当我被难住时,我也应该同样乐意发帖。
- 有可能有人会对发生的事情有一些见解,并添加评论。
- 如果我 ever 弄清楚发生了什么,那么有这篇文章在原始问题发生时写下来以供参考是很好的。
当我的代码行为不当时,几乎总是我的错。
首先,关于网站如何存储数据的一些背景信息。
选举数据
我将在未来的文章中详细介绍网站的一般架构,但就本文而言,您只需要知道:
- 数据存储在 Firestore 中
- 正常的页面请求根本不访问数据库。相反,所有数据都保存在内存中,并偶尔(并且每个实例原子性地)更新。包含所有数据(以相对“原始”的形式)的类型称为 ElectionContext,并且是完全不可变的。
- 在开发过程中,有一个手动的“重新加载当前数据”页面,在我知道更新了数据库后使用。(我将在另一篇文章中详细解释我的重新加载计划。)
- 当 ElectionContext 重新加载时,它会执行一些验证——如果无效,重新加载操作失败,并继续使用先前加载的数据。(当然,如果启动新实例,这没有帮助。)验证的一部分是检查某些集合是否按预期顺序排列。
上下文中的两个集合是数据提供者和预测集。星期四,我添加了一个新的数据提供者(Focaldata)和他们的第一个预测集。
我总是首先将数据添加到我的本地测试环境(使用 Firestore 和基于文件的版本)、我的暂存环境,最后是生产环境。当我更新 ElectionContext 时,我在存储之前验证它,然后从头获取它两次,两次都验证它,然后检查:a)第一次获取的上下文等于我存储的上下文,b)第二次获取的上下文等于第一次获取的上下文。
当我的代码行为不当时,几乎总是我的错。
代码和异常
这是在 ElectionContext.Validate() 方法中用于检查集合顺序的代码:
|
|
特别是对于预测集:
|
|
PredictionSets 属性是一个 ImmutableList
|
|
所以基本上,代码是检查所有预测集的 ID 是否按顺序排列,当使用序数字符串比较排序时。(看到异常后,我的最初想法是涉及一些奇怪的文化,并且我正在执行文化特定的排序检查——但不是,它是序数的。)
当我推送新数据时,测试和暂存环境都很好。当我在生产环境中点击重新加载页面时,重新加载失败,并且日志中有此异常:
|
|
在我们更详细地查看异常本身之前,日志显示了对重新加载页面的五个请求:
- 在 20:15:38,新数据尚未存储:重新加载页面发现没有新数据,并成功
- 在 20:15:45、20:15:49 和 20:15:57,重新加载操作失败(每次都有上述异常)
- 在 20:26:22,重新加载操作成功
有趣的是,重新加载操作的存储组件会查找新数据,如果没有则返回其缓存版本;执行验证的是上一级的组件。(这个缓存版本不是“服务”上下文——它只是在存储组件中。因此在此期间,网站继续提供仅有一个预测集的先前数据。)在这种情况下,日志显示:
- 20:15:38:没有新数据,因此返回缓存——然后验证成功
- 20:15:45:加载了新数据,然后验证失败
- 20:15:49:没有新数据,因此返回缓存上下文,然后验证失败
- 20:15:57:没有新数据,因此返回缓存上下文,然后验证失败
- 20:26:22:没有新数据,因此返回缓存上下文——然后验证成功
换句话说,三次验证失败的相同数据后来验证成功。日志条目非常明确地表示没有加载任何内容——因此似乎不是存储组件中的问题,即数据首先加载不正确,然后后来加载正确。我说“似乎不是”是因为当我的代码行为不当时,几乎总是我的错。(而且表象可能具有欺骗性。基本上,在我有办法重现问题之前,我对任何推论都持怀疑态度。)
让我们再次查看代码和异常。
我们“知道”/怀疑什么?
在异常中,mic-01 和 focaldata-01 是两个预测集的 ID。在生产环境中,这是仅有的两个预测集,并且这些是正确的 ID。异常消息暗示在异常发生时,currentText 的值是 “focaldata-01”,而 nextText 的值是 “mic-01”。这些值是有意义的,因为我原本期望 “focaldata-01” 排在 “mic-01” 之前。
换句话说,看起来数据是正确的,但检查是错误的。换句话说,用实际( apparent)值替换参数后,看起来这个表达式评估为 true:
|
|
为了覆盖一些正常的基础,即使它们不容易解释异常:
- 使用 source.Zip(source.Skip(1)) 可能看起来令人担忧,因为集合可能会变化,但 source 是一个 ImmutableList
。 - PredictionSet.Id 的类型是 string——所以它是不可变的。
- PredictionSet 本身是不可变的,所以 Id 不能随时间变化。
- 最重要的是,currentText 和 nextText 都是循环中的局部变量。
当我的代码行为不当时,几乎总是我的错。但在这种情况下,我真的、真的无法理解它怎么可能。
我剩下两个选项:
- 序数字符串比较器有一个错误,使得结果非确定性(不要忘记同样的检查在几分钟后在同一个机器上通过了)。
- JIT 编译器有一个错误,意味着参数没有正确评估——要么它们以错误的顺序传递,或者也许第二个参数或两个参数由于某种原因评估为 null,但随后在异常字符串格式化时正确评估。
老实说,这两个似乎都不太可能。第二个似乎更可能一些,因为我已经知道 .NET 9 中的一个 JIT 编译器问题,它影响了我从 Google Cloud 客户端库工作的一些客户。不过,我不够理解链接的问题来判断它是否能解释异常。
下一步是什么?
我无法在任何环境中重现这个问题。我能想到的唯一方面是改进诊断一点,以排除不可打印字符,是在异常中记录每个字符串的长度:
|
|
这不多,但这是我目前所有的。
我的猜测是,我永远不会知道这里发生了什么。我永远不会再看到这个异常,但也永远无法在“之前”状态重现它以知道它已经被修复。所有这些都有些令人不满意——但至少有趣。哦,我绝对仍然相信,当我的代码行为不当时,几乎总是我的错。
更新——已解决!
感谢一位更聪明的同事,这个谜团现在已经解决了。虽然她没有足够的上下文来知道问题实际在哪里,但她能够指出我上面推理错误的地方。