effect 修复解析:Cache.invalidateWhen 等待旧 lookup 期间误删替换条目的竞态问题
发布时间:2026/9/14 5:04:48来源:尧图网络
effect 修复解析Cache.invalidateWhen 等待旧 lookup 期间误删替换条目的竞态问题【免费下载链接】effectBuild production-ready applications in TypeScript项目地址: https://gitcode.com/GitHub_Trending/ef/effect本篇技术文章基于 effect 仓库中一条 patch 级变更说明changeset深入解析Cache.invalidateWhen与ScopedCache.invalidateWhen在等待先前 lookup 完成期间可能错误删除替换条目的竞态缺陷、源码中的修复方式以及配套的回归测试验证方法。读完之后你将理解 effect 4.x 缓存模块如何处理条件失效与并发替换之间的顺序保证并能在自己的代码中正确编写和验证这类并发边界行为。一、变更说明这条 changeset 修的是什么本次讨论的核心是位于 cache-invalidate-when-replacement.md 的变更说明文件。其全文内容如下--- effect: patch --- Fix Cache.invalidateWhen and ScopedCache.invalidateWhen deleting a replacement entry while waiting for an earlier lookup.这条说明信息量虽小但技术指向非常明确可以拆解为三个要素受影响的 APICache.invalidateWhenpackages/effect/src/Cache.ts与ScopedCache.invalidateWhenpackages/effect/src/ScopedCache.ts两者都是 effect 4.0.0 引入的条件失效操作源码注释中标记since 4.0.0缺陷语义waiting for an earlier lookup——当一个get触发的 lookup 尚未完成、缓存条目还停留在等待中状态时若此时发生替换replacement例如通过Cache.set写入新值invalidateWhen在等待旧 lookup 结束后执行删除会把后来写入的替换条目一并删掉修复级别changeset 前置元数据中标记为effect: patch即针对effect包的补丁级修复且该文件位于.changeset/pre/目录从源码结构看属于 pre 发布周期中待合并的变更记录。下文先回顾这两个 API 的常规用法与返回值语义再还原竞态场景最后逐行分析源码修复与回归测试。二、invalidateWhen 的用法与返回值语义invalidateWhen的作用是在缓存值满足谓词时删除对应键的条目签名支持双参数管道式与三参数直接式两种调用方式// 直接式cache, key, 谓词 const invalidated yield* Cache.invalidateWhen(cache, hello, (value) value 5) // 管道式 const invalidated yield* hello.pipe( Cache.invalidateWhen(cache, (value) value 5) )官方 JSDoc 示例packages/effect/src/Cache.ts完整演示了它的边界行为返回值boolean的语义值得逐条记住const program Effect.gen(function*() { const cache yield* Cache.make({ capacity: 10, lookup: (key: string) Effect.succeed(key.length) }) yield* Cache.get(cache, hello) // value 5 yield* Cache.get(cache, hi) // value 2 // 谓词命中删除条目返回 true const invalidated1 yield* Cache.invalidateWhen(cache, hello, (value) value 5) const hasHello yield* Cache.has(cache, hello) // false // 谓词不命中不删除返回 false const invalidated2 yield* Cache.invalidateWhen(cache, hi, (value) value 5) const hasHi yield* Cache.has(cache, hi) // true // 键不存在返回 false const invalidated3 yield* Cache.invalidateWhen(cache, nonexistent, () true) return [invalidated1, hasHello, invalidated2, hasHi, invalidated3] }) // [true, false, false, true, false]除上述三种情况外还有一个容易被忽略的边界缓存的条目处于lookup 失败状态时同样返回false——即使谓词写成() true也不会去失效一个失败值因为谓词只能作用于成功的缓存值。对应源码中invalidateWhen在await条目结果后用effect.catchCause(() effect.succeed(false))兜底失败退出直接折算为falsepackages/effect/src/Cache.ts。ScopedCache.invalidateWhen语义一致但多一层资源语义命中并删除时会关闭该条目关联的 Scope 并释放其资源这一点在其 JSDoc 的 Gotchas 中明确写明packages/effect/src/ScopedCache.tsIf the key is absent, this is a no-op. … A matching invalidation closes the entry scope and releases its resources.三、竞态场景还原旧 lookup、条件失效、替换写入三方交织要理解这个 bug先看一个最小时间线。Cache.get在 key 未命中时会先向内部 map 写入一个等待中的条目其结果由 lookup 产出也就是说lookup 执行期间该 key 已经占据了 map 中的位置。设一个 key 的 lookup 是慢操作时间线如下t1Cache.get(cache, key)触发lookup 被挂起在某个未完成的 Deferred 上map 中存入条目 E1等待旧 lookupt2另一个 fiber 发起Cache.invalidateWhen(cache, key, f)内部同样拿到条目 E1 并开始await旧 lookup 的结果t3第三方通过Cache.set(cache, key, 99)写入替换值map 中的条目被换成 E2值为 99t4旧 lookup 完成invalidateWhen的await返回谓词判断通过后执行删除。缺陷就发生在t4如果删除操作是无条件按 key 从 map 移除那么被删掉的将是E2 这个更新的替换条目——业务上刚刚写入的 99 凭空消失下一次get会再次触发 lookup。这正是 changeset 描述的 deleting a replacement entry while waiting for an earlier lookup。四、修复实现删除前做条目同一性校验修复的核心思路是延迟绑定late-binding式的乐观删除不在await前锁定要删谁而是在真正执行删除的瞬间重新核对 map 中当前条目是否仍然是自己当初等待的那个条目。Cache.invalidateWhen的修复后实现packages/effect/src/Cache.tsexport const invalidateWhen: { Key, A(key: Key, f: PredicateA): E, R(self: CacheKey, A, E, R) Effect.Effectboolean Key, A, E, R(self: CacheKey, A, E, R, key: Key, f: PredicateA): Effect.Effectboolean } dual( 3, Key, A, E, R(self: CacheKey, A, E, R, key: Key, f: PredicateA): Effect.Effectboolean core.withFiber((fiber) { const oentry getImpl(self, key, fiber, false) if (oentry undefined) { return effect.succeed(false) } return oentry.await().pipe( effect.map((value) { if (f(value)) { const current MutableHashMap.get(self.map, key) if (Option.isNone(current) || current.value ! oentry) { return false } MutableHashMap.remove(self.map, key) return true } return false }), effect.catchCause(() effect.succeed(false)) ) }) )关键修复点在await完成、谓词命中之后的这几行重新从self.map取出该 key 的当前条目current若current为None条目已不存在或current.value ! oentry当前条目已不是自己当初等待的那个条目对象即已被替换则放弃删除并返回false只有对象同一性校验通过时才执行MutableHashMap.remove(self.map, key)并返回true。这个current.value ! oentry的同一性比较是整个修复的精髓由于 map 中存的是条目对象而非裸值任何替换无论是set写入新值还是刷新重建条目都会产生一个不同的对象旧流程因此能精确识别我等待的已经过时了从而把删除权让给真正持有最新条目的调用方。ScopedCache.invalidateWhen采用同一模式packages/effect/src/ScopedCache.ts并额外处理两个 scoped 语义return restore(Deferred.await(entry.deferred)).pipe( effect.flatMap((value) { if (self.state._tag Closed) { return effect.succeed(false) } else if (f(value)) { const current MutableHashMap.get(self.state.map, key) if (Option.isNone(current) || current.value ! entry) { return effect.succeed(false) } MutableHashMap.remove(self.state.map, key) return effect.as(Scope.close(entry.scope, effect.exitVoid), true) } return effect.succeed(false) }), effect.catch_(() effect.succeed(false)) )从源码结构看有三点值得注意同样的同一性校验current.value ! entry时返回false不删除替换条目额外的Closed状态检查缓存已关闭时直接返回false与invalidate缓存关闭则被中断 的语义保持一致删除动作本身被effect.uninterruptibleMask包裹外层保证校验—删除—关闭条目 Scope 这段临界区不会被中途打断避免校验通过后、释放资源前被中断造成的悬挂资源。顺带一提与之相对的无条件Cache.invalidatepackages/effect/src/Cache.ts是同步直接remove根本不等待 lookup因此不存在本文讨论的等待期间被替换窗口竞态只存在于需要先await条目结果才能做谓词判断的invalidateWhen路径上。五、回归测试如何用 effect 原语确定性复现这个竞态针对该修复仓库在 packages/effect/test/Cache.test.ts 中新增了一个确定性竞态回归测试 preserves a newer set value after an old lookup completes它同时覆盖 lookup 成功与失败两种退出it.effect.each([Exit.succeed(1), Exit.fail(error)])( preserves a newer set value after an old lookup completes (%#), (exit) Effect.gen(function*() { const gate yield* Deferred.makenumber, string() const values: Arraynumber [] const cache yield* Cache.makestring, number, string({ capacity: 1, lookup: () Deferred.await(gate) // lookup 被 gate 挂起 }) // t1: 触发 getlookup 卡在 gate 上 const lookup yield* Cache.get(cache, key).pipe( Effect.exit, Effect.forkChild({ startImmediately: true }) ) // t2: 并行发起条件失效 const invalidator yield* Cache.invalidateWhen(cache, key, (value) { values.push(value) return value 1 }).pipe( Effect.forkChild({ startImmediately: true }) ) // t3: 替换写入新值 yield* Cache.set(cache, key, 99) // t4: 放行旧 lookup yield* Deferred.done(gate, exit) const original yield* Fiber.join(lookup) const invalidated yield* Fiber.join(invalidator) assert.deepStrictEqual(original, exit) assert.deepStrictEqual(values, Exit.isSuccess(exit) ? [1] : []) assert.isFalse(invalidated) // 修复的核心断言替换值 99 必须仍然存在 assert.deepStrictEqual(yield* Cache.getSuccess(cache, key), Option.some(99)) }) )测试的设计要点值得学习用Deferred作为gate把 lookup精确地卡在挂起状态从而把并发时序从概率性变成确定性无需依赖 sleep 或时序猜测Cache.set(cache, key, 99)模拟替换写入制造旧 lookup 与新值并存的窗口断言invalidated false条件失效没有生效于替换条目且最终Cache.getSuccess仍返回Option.some(99)——这正是修复前的失败点用Exit.succeed(1)/Exit.fail(error)参数化同时覆盖 lookup 成功谓词会被执行values收到[1]与失败谓词不执行values为空两条路径。同一组回归场景在ScopedCache侧的测试文件 packages/effect/test/ScopedCache.test.ts 中也有对应的invalidateWhen覆盖可在本地运行该包测试验证。六、同类问题的旁证与适用边界这个等待期间被替换的竞态并非invalidateWhen独有。effect 4.x 的缓存实现里refresh路径也存在同族问题强制刷新会先向 map 写入新的等待条目若此时发生替换写入旧流程的中断/退出处理同样可能误删新值。仓库中对应的回归测试是 packages/effect/test/Cache.test.ts 中的 repro: interrupting a missing-key refresh does not remove a newer set value其防护手法与本文一致——在最终写回或删除前核对当前条目与本次操作持有的条目是否同一。阅读这些测试可以帮你建立一套判断effect 缓存 API 在并发窗口内的写入是否安全的方法论。最后明确适用边界本文结论基于当前仓库中effect包的源码与测试适用于 effect 4.xinvalidateWhen标注since 4.0.0changeset 中effect: patch表明这是一个补丁级修复invalidateWhen返回false有四种合法成因键不存在、条目失败、谓词不命中、条目已被替换本次修复新增的语义——阅读返回值时不要只按前三者推断ScopedCache.invalidateWhen的删除附带 Scope 关闭语义在修复同一性校验之前不会触发资源释放替换条目的生命周期不受影响。综合来看这条 patch 修复虽然只有一行变更说明但其背后展示的是 effect 处理异步等待 并发替换这类竞态的通用范式不在等待前做不可撤销决策在动作执行瞬间用对象同一性重新校验世界状态。掌握这一模式对于审查、扩展或测试 effect 中一切先 await 再修改共享结构的 API 都有直接参考价值。【免费下载链接】effectBuild production-ready applications in TypeScript项目地址: https://gitcode.com/GitHub_Trending/ef/effect创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
网站建设高端定制企业官网