2029选举:一个无法解释的异常

本文详细描述了作者在开发选举数据网站时遇到的一个无法解释的异常,涉及C#代码中的字符串比较和集合验证问题,并探讨了可能的JIT编译器或字符串比较器bug。

选举2029:一个无法解释的异常

我原以为我已经写过关于我的 Election 2029 网站(https://election2029.uk)的第一篇博客文章,但看来我的博客进度比我想象的要落后。因此,这是这个系列中有点奇怪的第一篇文章,不过没关系。在某种程度上,它与选举网站并不特别相关,除了 a) 这是相对现代的 C#,与我的大多数代码库相比;b) 它将解释所涉及的两个字符串。

我无法强调更多:当我的代码行为不当时,几乎总是我的错。(我以前在博客中写过这个。)

但在星期四,我看到了一个无法解释的异常。这篇文章将提供一些背景信息,展示涉及的代码和异常消息,以及我下一步(相当弱的)行动。这篇文章的目的有三个:

  1. 这是一种谦卑的行为:如果我在诊断一个棘手问题后对自己感到满意时发帖,那么当我被难住时,我也应该同样乐意发帖。
  2. 有可能有人会对发生的事情有一些见解,并添加评论。
  3. 如果我确实弄清楚了发生了什么,那么在有原始问题时写下这篇文章以供参考是很好的。

当我的代码行为不当时,几乎总是我的错。

首先,简单介绍一下网站如何存储数据。

选举数据

我将在未来的文章中详细介绍网站的一般架构,但就本文而言,你只需要知道:

  • 数据存储在 Firestore 中
  • 正常的页面请求根本不访问数据库。相反,所有数据都保存在内存中,并偶尔(且每个实例原子性地)更新。包含所有数据(以相对“原始”的形式)的类型称为 ElectionContext,并且是完全不可变的。
  • 在开发过程中,有一个手动的“重新加载当前数据”页面,在我知道更新了数据库后使用。(我将在另一篇文章中详细解释我的重新加载计划。)
  • 当 ElectionContext 重新加载时,它会执行一些验证——如果无效,重新加载操作将失败,并继续使用先前加载的数据。(当然,这对于新启动的实例没有帮助。)验证的一部分是检查某些集合是否按预期顺序排列。

上下文中的两个集合是数据提供者和预测集。星期四,我添加了一个新的数据提供者(Focaldata)和他们的第一个预测集。

我总是先将数据添加到我的本地测试环境(使用 Firestore 和基于文件的版本)、我的暂存环境,最后是生产环境。当我更新 ElectionContext 时,我在存储之前验证它,然后从头获取两次,两次都验证它,然后检查 a) 第一次获取的上下文等于我存储的上下文,以及第二次获取的上下文等于第一次获取的上下文。

当我的代码行为不当时,几乎总是我的错。

代码和异常

这是用于在 ElectionContext.Validate() 方法中检查集合顺序的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void ValidateOrdering<T>(
    IEnumerable<T> source,
    Func<T, string> selector,
    [CallerArgumentExpression(nameof(source))] string? message = null)
{
    foreach (var (current, next) in source.Zip(source.Skip(1)))
    {
        string currentText = selector(current);
        string nextText = selector(next);
        if (StringComparer.Ordinal.Compare(currentText, nextText) >= 0)
        {
            throw new InvalidOperationException(
                $"Incorrect ordering for {message}: {currentText} should occur before {nextText}");
        }
    }
}

特别是对于预测集:

1
ValidateOrdering(PredictionSets, ps => ps.Id);

PredictionSets 属性是一个 ImmutableList,而 PredictionSet 是一个记录:

1
public sealed record PredictionSet(string Id, /* many other properties */)

所以基本上,代码是检查所有预测集的 ID 是否按顺序排列,当使用序数字符串比较排序时。(看到异常后,我的最初想法是涉及一些奇怪的文化,并且我正在执行文化特定的排序检查——但不是,它是序数的。)

当我推送新数据时,测试和暂存环境都很好。当我在生产环境中点击重新加载页面时,重新加载失败,并且日志中有这个异常:

1
2
3
System.InvalidOperationException:
   Incorrect ordering for PredictionSets: mic-01 should occur before focaldata-01
   at Election2029.Models.ElectionContext.<Validate>g__ValidateOrdering [...]

在我们更详细地查看异常本身之前,日志显示了对重新加载页面的五个请求:

  • 在 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:

1
2
// 这怎么可能为真?
StringComparer.Ordinal.Compare("focaldata-01", "mic-01") >= 0

为了覆盖一些正常的基础,即使它们不能轻易解释异常:

  • 使用 source.Zip(source.Skip(1)) 可能看起来令人担忧,因为集合可能会改变,但 source 是一个 ImmutableList
  • PredictionSet.Id 的类型是 string——所以它是不可变的。
  • PredictionSet 本身是不可变的,因此 Id 不能随时间改变。
  • 最重要的是,currentText 和 nextText 都是循环中的局部变量。

当我的代码行为不当时,几乎总是我的错。但在这种情况下,我真的、真的无法理解它怎么可能。

我剩下两个选项:

  1. 序数字符串比较器有一个错误,使得结果非确定性(不要忘记同样的检查在同一台机器上几分钟后通过了)。
  2. JIT 编译器有一个错误,意味着参数没有正确评估——要么它们以错误的顺序传递,或者也许第二个参数或两个参数由于某种原因评估为 null,但随后在异常的字符串格式化中正确评估。

老实说,这两个似乎都不太可能。第二个似乎更可能一些,因为我已经知道 .NET 9 中有一个 JIT 编译器问题,影响了我的 Google Cloud 客户端库工作中的一些客户。我不太理解链接的问题,无法判断它是否能解释这个异常。

下一步是什么?

我无法在任何环境中重现这个问题。我能想到的唯一方面是改进诊断一点,以排除不可打印字符,即在异常中记录每个字符串的长度:

1
2
throw new InvalidOperationException(
    $"Incorrect ordering for {message}: {currentText} (length {currentText.Length}) should occur before {nextText} (length {nextText.Length})");

这不多,但这是我目前所有的。

我猜我永远不会知道这里发生了什么。我永远不会再看到这个异常,但也永远无法在“之前”状态重现它以知道它已被修复。所有这些都有些令人不满意——但至少有趣。哦,我绝对仍然相信,当我的代码行为不当时,几乎总是我的错。

更新——已解决!

感谢一位更聪明的同事,这个谜团现在已经解决了。虽然她没有足够的上下文来知道问题实际在哪里,但她能够指出我上述推理错误的地方。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计