Repository navigation
[Bug] 会话搜索必然失败:稳定性观测在存在活跃写入时不可能达成 #7946
Replies: 1 comment
|
English summary — Same failure confirmed on a different platform and a newer release: DSH Desktop 感谢原帖,根因分析(缺陷 1 / 缺陷 2)与实测耗时已经把问题讲透了。以下只补充原帖未覆盖的部分,不重复结论。 1. 复现面:换平台、换版本,仍然复现
两点结论:
2. 一次失败尝试的磁盘开销(补充证据)搜索无结果、放弃后,索引目录状态:
两个文件的时间戳都停在放弃的那一刻,WAL 未 checkpoint;重启应用后 WAL 被并入主库,主库保持在 187.2 MB 不再回缩。 这与"ROLLBACK 不提交内容"是自洽的:一次失败的 reconcile 会把约 375 MB 写进 WAL 再回滚,页留在 freelist 里,SQLite 不会缩小文件。也就是说每次失败尝试都要付一次数百 MB 的磁盘写放大,而这个自我强化的死锁意味着会反复失败。 (同一次卡顿期间,Host 进程 RSS 观测到约 1.5 GB。这是观测值,我没有做归因,仅作参考。) 3. 操作层面:运行期撤销配置无法止损我先加上覆盖行复现,随后把该行整段删除(同一 profile 的 诚实标注不确定性:我无法区分当时是"同一次 reconcile 仍在跑"还是"重载后新实例又触发了一次"。但两种情况指向同一个可用性问题——用户无法自助止损,只能重启应用。 这与 #8277 / #7356 的结论一致:reconcile 期间跑在 JS 主线程上的同步工作不可中断。 4. 交叉引用:这可能是同一族问题
加上本贴,三条都指向同一个模式: 这一点对修复优先级有影响:按原帖实测,单独修好缺陷 1 之后,索引已建好且持续写入时搜索仍需 73–126 秒。那时它不再抛错,但会变成一个持续 1–2 分钟的主线程阻塞——用户体验上仍是不可用。所以建议在修缺陷 1 / 2 的同时,一并考虑把 reconcile 移出主线程(异步或 worker),否则修完只是从"报错"变成"长时间假死"。 再次感谢原帖的复现脚本与修复验证,这篇补充只是把复现面和运行期行为补齐。 |
Uh oh!
There was an error while loading. Please reload this page.
概要
侧边栏搜索会话时永远转圈,约 50–130 秒后返回:
这不是偶发失败,而是确定性失败:只要有任何会话正在被写入(即应用正在使用中),搜索就必然失败。
索引因此永远无法建立,形成自举死锁。
涉及两个独立缺陷,共同作用:
session-query-sqlitesession-persistence-jsonl环境
0.1.7-rc.2(bundled runtimehostProtocolVersion 4),macOS 27.0 (26A428) arm64@deepseek-ai/dsh-session-query-sqlite0.1.7-rc.2@deepseek-ai/dsh-session-persistence-jsonl0.1.7-rc.2@deepseek-ai/dsh-session0.1.7-rc.2cordis.patch.yml):已排除的因素:索引库结构正常(
application_id1146308689、schema v8 可正常打开);同链路上的session/list正常返回(status=200 elapsed=163ms bytes=154956,147 ms 量级)——它不经过_observeStable。所以不是权限、文件损坏或 DB 打不开。缺陷 1:稳定性闸门要求 revision 不变(硬失败)
packages/session-query/session-query-sqlite/src/index.ts而
revision是 stat 派生的(session-persistence-jsonl/src/index.ts:184-192):即要求两次
list()之间,全语料没有任何文件的 size/mtime/ctime 变化。DSH 在对话进行中会持续 append 会话日志(实测 100 秒内 20 次写入,间隔 2–5 秒)。
一旦任何会话被写入,
samePersistenceSnapshots返回false→continue→ 两次尝试耗尽 → 抛错。自举死锁
实测索引库状态印证(
~/.dsh/storages/session-search.db,persisted_sessions149 行 /persisted_docs222,135 行 /global_generation5):4 个无行、1 个 revision 已过期、0 个最新。不是部分成功,是提交从未落地。
缺陷 2:全语料哈希折进每个 legacy 会话的 revision
packages/session/session-persistence-jsonl/src/index.ts结果:每个 legacy 会话的 revision 都包含了整个语料的哈希。
我直接测量了影响范围(在语料副本上向单个 v4 会话追加 64 字节,然后重新
list()):一次 64 字节写入 → 142/164 个 revision 翻转。翻转的 legacy 会话,其 revision 中自身 stat 部分(
dev:ino:size:mtimeNs:ctimeNs)逐字符相同,只有尾部拼接的语料哈希变了:也就是说:这些会话自身一个字节都没变,仅因为语料里别的会话被写入,它们的 revision 就失效了。
两个后果:
indexed.get(id)?.revision === entry.revision(L514);legacy 会话的 revision 每次写入都变,于是每次 reconcile 都要重新冷读全部 141 个 legacy 会话(约 231 MiB)。关于修复缺陷 1 的实测
单独修掉缺陷 1 后:搜索从"抛错"变为"成功返回 20 条结果"(红→绿),但耗时仍不可接受:
即:只有语料完全静止时才快。这符合缺陷 2 的预期(写入 → 141 个 legacy 会话全部失效 → 全量冷读)。
建议修复
缺陷 1(已实测验证,红 → 绿)
稳定性闸门的职责是"观测期间会话集合与身份没有变化",而不是"文件字节没有变化"。
revision的本职是判断"某个会话自上次索引后是否变化"(L514 的跳过条件),那个用法保留。把它额外当作索引提交的稳定性闸门是越权了:日志是 append-only,追加不改变身份。function samePersistenceSnapshots(before, after): boolean { if (before.size !== after.size) return false for (const [id, first] of before) { const second = after.get(id) - if ( - second === undefined - || first.revision !== second.revision - || !sameHeader(first.header, second.header) - ) return false + if (second === undefined || !sameHeader(first.header, second.header)) return false } return true }保留的部分:
before.size !== after.size(新增/删除会话仍被发现)、sameHeader(逐个比对id/createdAt/cwd/parentSession/isSeeded/delegationDepth/agentPreset)、以及 L532 的_persistenceBinding守卫与 L555 的sameSessionIds守卫。放宽后如果冷读期间文件长大了,提交的是那一刻的快照、携带的 revision 偏旧 → 下次 reconcile 自然会重读。这正是增量机制原本的设计,可以自愈。
缺陷 2(需维护者决策)
historicalCorpusRevision有明确的设计意图(注释:"Historical logical events depend on the corpus, including members with unreadable headers."),所以不应简单删掉。但当前实现有两个问题:全语料stat逐文件哈希、且结果被折进每一个 legacy 会话的 revision。可选方向:historicalCorpusRevision结果(例如按list()调用或短 TTL memoize),避免同一轮 reconcile 内重复计算;我没有实测这几个方向,供维护者判断。
复现
方式 A:直接调用引擎(不依赖 Desktop App)
在语料副本上(不要指向真实会话目录,因为需要制造写入):
实测输出(语料 164 个会话、索引为陈旧快照):
对照:同一个未打补丁的模块(
lib/index.jssha256 两次运行均为dbecf833…)、同一份语料、同一个索引快照,唯一变量是搜索期间有没有写入。SESSION_QUERY_PERSISTENCE_FAILEDCHURN_MS调至不可达,实测 0 次)这条对照是缺陷 1 的直接证据:代码本身能成功,写成失败的唯一原因就是"有并发写入"。
(顺带说明:即使成功也要 88 秒——这正是缺陷 2 造成的全量冷读开销。)
方式 B:通过运行中的 App
对 host RPC 发
session/search(POST/api/session/search,slash 形式方法名):一条最小的确定性单测
不依赖真实语料,直接针对判等语义:
把它改成
=== true(对同一会话集合而言),就是缺陷 1 的回归测试。另外建议加一条针对缺陷 2 的不变量测试:向任一 v4 会话追加 1 字节,不应导致任何 legacy 会话的 revision 变化(当前 141 个全部变化)。
补充观察(非本 bug,供参考)
~/.dsh/profiles/node_modules/@deepseek-ai/*有 240 个指向/opt/homebrew/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/...的符号链接(含dsh-session-query-sqlite),那份是0.1.5-rc.3(dsh-session报SESSION_FORMAT_VERSION = 3),而 App 实际加载的是随包发布的0.1.7-rc.2(v4)。同一个插件文件在两棵树里字节相同,差异在兄弟依赖解析到的dsh-session版本。这不影响本 bug 的复现,但排查时容易误判。0.1.7-rc.2里_observeStable每次尝试都对全部 live 会话重跑observeLive(L548-553,无缓存);这是搜索耗时中的另一项成本,与上述两个缺陷独立。All reactions