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.这条说明信息量虽小,但技术指向非常明确,可以拆解为三个要素:
- 受影响的 API:
Cache.invalidateWhen(packages/effect/src/Cache.ts)与ScopedCache.invalidateWhen(packages/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))兜底,失败退出直接折算为false(packages/effect/src/Cache.ts)。
ScopedCache.invalidateWhen语义一致,但多一层资源语义:命中并删除时会关闭该条目关联的 Scope 并释放其资源,这一点在其 JSDoc 的 "Gotchas" 中明确写明(packages/effect/src/ScopedCache.ts):
If 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 是慢操作,时间线如下:
t1:Cache.get(cache, "key")触发,lookup 被挂起在某个未完成的 Deferred 上,map 中存入条目 E1(等待旧 lookup);t2:另一个 fiber 发起Cache.invalidateWhen(cache, "key", f),内部同样拿到条目 E1 并开始await旧 lookup 的结果;t3:第三方通过Cache.set(cache, "key", 99)写入替换值,map 中的条目被换成 E2(值为 99);t4:旧 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.ts):
export const invalidateWhen: { <Key, A>(key: Key, f: Predicate<A>): <E, R>(self: Cache<Key, A, E, R>) => Effect.Effect<boolean> <Key, A, E, R>(self: Cache<Key, A, E, R>, key: Key, f: Predicate<A>): Effect.Effect<boolean> } = dual( 3, <Key, A, E, R>(self: Cache<Key, A, E, R>, key: Key, f: Predicate<A>): Effect.Effect<boolean> => 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.invalidate(packages/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.make<number, string>() const values: Array<number> = [] const cache = yield* Cache.make<string, number, string>({ capacity: 1, lookup: () => Deferred.await(gate) // lookup 被 gate 挂起 }) // t1: 触发 get,lookup 卡在 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.x(invalidateWhen标注@since 4.0.0);changeset 中"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),仅供参考