Debounce and throttle paced mutations allow concurrent persistence despite the documented single-flight contract
维护者通常 1 天内回复
评估
- 难度
- 4/5
- 预计耗时
- 3-5 天
- 新手友好度
- 35/100
- Issue 类型
- 缺陷
- 描述清晰度
- 描述清楚
- 活跃度
- 停滞
- 技术栈
- typescript
调研方向
Read packages/db/src/paced-mutations.ts and the debounce and throttle strategies in packages/db/src/strategies, then run the supplied reproducer with the package's Vitest runner. Extend packages/db/tests/paced-mutations-oracle.test.ts with held-write histories covering the admission cases and release behavior described in the issue. Done means the documented one-persisting-transaction limit holds and admitted transactions settle with optimistic state retained; two open linked pull requests indicate work is already underway.
由索引模型根据 Issue 内容生成。
描述
- I've validated the bug against the latest version of DB packages
Reproduced against source commit 5688e232eff34eeac555f2586a34672bad25865b (@tanstack/db package version 0.11.3). The affected paced-mutations.ts, debounceStrategy.ts, and throttleStrategy.ts blobs match the current default branch as checked on 2026-10-06. This is source-level validation; the latest published npm packages were not tested.
Describe the bug
Debounce and throttle can start a second persistence call while the first is still pending, contradicting the documented limit of one persisting transaction at a time.
The Paced Mutations guide states:
Debounce/Throttle: Only one pending transaction (collecting mutations) and one persisting transaction (writing to backend) at a time.
For auto-save callers relying on that promise, writes can overlap. This reproduction establishes overlapping requests; it does not simulate server-side reordering or claim a demonstrated stale server value.
To Reproduce
For either debounceStrategy or throttleStrategy:
- Create a paced manager with
{ wait: 10, leading: true, trailing: true }. - Mutate at time 0 and hold the successful
mutationFnpromise unresolved. - Mutate again at time 1.
- Advance to time 21 without resolving the first write.
- Both returned transactions are
persisting.
No rejected persistence, invalid input, same-key interaction, or cleanup race is required.
Save this test as packages/db/tests/paced-persistence-concurrency.test.ts and run it with the package's Vitest runner:
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { createCollection } from '../src/collection'
import { createPacedMutations } from '../src/paced-mutations'
import {
debounceStrategy,
throttleStrategy,
} from '../src/strategies'
import { mockSyncCollectionOptionsNoInitialState } from './utils'
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(1000000)
})
afterEach(() => vi.useRealTimers())
async function ready() {
const collection = createCollection(
mockSyncCollectionOptionsNoInitialState<{ id: number }>({
id: 'review-witness',
getKey: (row) => row.id,
}),
)
const preload = collection.preload()
collection.utils.begin()
collection.utils.commit()
collection.utils.markReady()
await preload
return collection
}
for (const [name, factory] of [
['debounce', debounceStrategy],
['throttle', throttleStrategy],
] as const) {
it(`${name}: public guide permits at most one persisting transaction`, async () => {
const collection = await ready()
const strategy = factory({ wait: 10, leading: true, trailing: true })
const starts: number[] = []
let release!: () => void
const hold = new Promise<void>((resolve) => (release = resolve))
const mutate = createPacedMutations<number, { id: number }>({
strategy,
onMutate: (id) => {
collection.insert({ id })
},
mutationFn: () => {
starts.push(Date.now() - 1000000)
return hold
},
})
const first = mutate(1)
await vi.advanceTimersByTimeAsync(1)
const second = mutate(2)
await vi.advanceTimersByTimeAsync(20)
try {
console.log(
JSON.stringify({
strategy: name,
starts,
states: [first.state, second.state],
}),
)
expect(
[first, second].filter((tx) => tx.state === 'persisting').length,
'docs/guides/mutations.md:1099 one persisting transaction at a time',
).toBeLessThanOrEqual(1)
} finally {
release()
await Promise.all([first.isPersisted.promise, second.isPersisted.promise])
strategy.cleanup()
await collection.cleanup()
}
})
}
Expected behavior
At most one transaction per paced manager is persisting at a time, as promised by the guide. Timer eligibility and actual persistence invocation need distinct treatment while an earlier write remains in flight. The later admitted work must still settle after release.
Observed behavior
| Strategy | Persistence callback starts (ms) | Transaction states at time 21 |
|---|---|---|
| Debounce | [0, 11] |
['persisting', 'persisting'] |
| Throttle | [0, 10] |
['persisting', 'persisting'] |
Both tests reach and fail the concurrency assertion with expected 2 to be less than or equal to 1. Their cleanup releases both writes and awaits successful settlement.
Why the existing oracle misses it
All 42 tests in paced-mutations-oracle.test.ts pass. The ordinary timing driver immediately fulfills writes. Its held debounce/throttle witnesses cover a dropped second call; they do not let an admitted second write reach its timer edge while the first remains in flight. Final successful settlement does not reveal the overlap.
Extend that owner with successful hold/release histories for admitted debounce and throttle calls. Observe concurrent persistence before the next edge, at the edge while the first write is held, and after release. Include leading and non-leading initial admission, receipt settlement, and retained optimistic state. This reproduction covers the two leading-enabled cases; it does not establish closure of the wider class.
A change allowing concurrent persistence would be an explicit product-contract decision with implications for callers. Simply weakening these expectations to match current output would leave the documented promise unresolved.
Environment and validation
- macOS, Node 24.19.0, Vitest 3.2.4,
@tanstack/pacer-lite0.2.1. - Real DB source entry points and fake timers in a Node test host; coverage and typechecking disabled for this bounded component experiment.
- The 42 existing tests passed; the two concurrency witnesses failed at their intended assertions.
- Production and existing oracle files were not modified.
Related background: #35 discusses serialized strategy support. #1060 concerns optimistic value regression; this report isolates simultaneous persistence calls and does not require Electric.
- 主要语言
- TypeScript
- 星标
- 3.9k
- 派生
- 268
- 平均合并
- 1 天 3 小时
- 30 天内合并 PR
- 193
环境准备
- 没有 Dockerfile 或 Docker Compose 文件
- 有 Pull Request 模板
- 阅读贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
TanStack/db 的其他 Issue
-
难度 3/5 1-2 天 新手友好度 72/100
维护者通常 1 天内回复
-
难度 3/5 1-2 天 新手友好度 68/100
维护者通常 1 天内回复
-
难度 4/5 3-5 天 新手友好度 48/100
维护者通常 1 天内回复
-
难度 5/5 一周以上 新手友好度 45/100
维护者通常 1 天内回复
-
难度 5/5 一周以上 新手友好度 45/100
维护者通常 1 天内回复
相似的 Issue
-
难度 2/5 1-3 小时 新手友好度 84/100
维护者通常 1 天内回复
-
area:ui enhancement issue-form:feature platform:cross-platform review: high
难度 2/5 1-3 小时 新手友好度 72/100
1lck/Lithe-IDEA#1092 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 72/100
developmentseed/deck.gl-raster#693 ·
维护者通常 1 天内回复
-
bug
难度 2/5 1-3 小时 新手友好度 86/100
Marker-Inc-Korea/AutoRAG#1801 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 72/100
维护者通常 1 天内回复