Persistence: a hydration that starts inside a commit-reserving source transaction rejects that transaction's commit
维护者通常 1 天内回复
还没有人认领这个 Issue。
评估
- 难度
- 4/5
- 预计耗时
- 1-2 天
- 新手友好度
- 48/100
- Issue 类型
- 缺陷
- 描述清晰度
- 描述清楚
- 活跃度
- 活跃
- 技术栈
- sqlite, typescript
调研方向
The issue identifies persisted.ts as the affected code and provides a Vitest repro for peer full-reload notices and reloads queued in the shared scheduler. Start by tracing how hydration admission and hydrateSubsetUnsafe interact with reserveCommitTurn, then run the two repro cases against the persistence packages. Done when both cases commit without the hydration-cycle error and the collection does not enter error.
由索引模型根据 Issue 内容生成。
描述
Summary
A source transaction that reserves its commit turn (#2030) is protected only from subset loads admitted while it is open. The persisted loadSubset wrapper checks reservesCommitTurn once, at admission. Hydrations that start any other way do not check it:
- a subset load admitted before the transaction reserved its turn, which reaches
hydrateSubsetUnsafeafter it (for example, queued behind a durable write on the apply mutex); reloadActiveSubsetsUnsafefrom a peer tab'stx:committednotice withrequiresFullReload;truncateAndReloadUnsafefrom a peercollection:reset;- any of these held by the shared logical scheduler behind another collection's write, after their mutex turn began.
Each one advances the hydration sequence that the open transaction was built against. Persistence then rejects the commit with a persisted sync transaction cannot cross a hydration cycle, and the collection enters error.
Reproduced on @tanstack/db 0.15.2 and @tanstack/db-sqlite-persistence-core 0.4.12. main has the same persisted.ts as 0.4.12.
How we hit it in production
We run on-demand Electric collections with browser OPFS persistence. Users often keep several tabs open on the same screen. A deploy resets Electric storage, so every tab gets a 409 must-refetch at about the same moment. Electric's refetch transaction reserves its commit turn and stays open across message batches. Two paths then broke the refetch commit:
- A subset load (a user opening the next record) was admitted while a durable write held the apply mutex. It hydrated inside the refetch transaction.
- With several tabs open, each tab commits its own refetch and broadcasts it. A peer tab handled that
requiresFullReloadnotice by reloading its active subsets while its own refetch transaction was still open.
We saw this on 0.4.8 (where it surfaced through Sentry). The code path is unchanged on 0.4.12.
Minimal repro (no Electric)
Two vitest cases using only @tanstack/db, core persistence, and node:sqlite (Node 22+). Both fail on 0.4.12:
× commits the reserved transaction when a peer full-reload notice arrives while it is open
× commits the reserved transaction when a reload waited behind another collection in the shared scheduler
InvalidPersistedCollectionConfigError: a persisted sync transaction cannot cross a hydration cycle
The harness drives a persisted collection's source by hand: begin(), write(), then metadata.persistence.reserveCommitTurn(), as Electric does. A SingleProcessCoordinator subclass delivers another tab's tx:committed broadcast.
upstream-repro.test.ts
// @vitest-environment node
// Repro against @tanstack/db 0.15.2 + @tanstack/db-sqlite-persistence-core 0.4.12. Run: npx vitest run
import { createCollection, type SyncConfig } from '@tanstack/db'
import {
eagerStorageTarget,
type ProtocolEnvelope,
persistedCollectionOptions,
SingleProcessCoordinator,
SQLITE_DRIVER_SHARED_LOGICAL_SCHEDULING_KEY,
SQLiteCorePersistenceAdapter,
type SQLiteDriver,
type TxCommitted,
} from '@tanstack/db-sqlite-persistence-core'
import { afterEach, describe, expect, it } from 'vitest'
import { DatabaseSync, type SQLInputValue } from 'node:sqlite'
interface ShapeRow extends Record<string, unknown> {
readonly id: string
readonly value: string
}
/**
* A real in-memory SQLite driver whose next write transaction can be held to keep the persistence queue busy.
* `sharedScheduling` marks it as one physical database, as the browser driver does, so every adapter on it
* shares one write/hydrate scheduler.
*/
const openHoldableDatabase = ({ sharedScheduling = false }: { sharedScheduling?: boolean } = {}) => {
const database = new DatabaseSync(':memory:')
let hold: { reached: () => void; released: Promise<void> } | undefined
let transactionQueue: Promise<unknown> = Promise.resolve()
const driver: SQLiteDriver = {
...(sharedScheduling ? { [SQLITE_DRIVER_SHARED_LOGICAL_SCHEDULING_KEY]: {} } : {}),
exec: async sql => {
database.exec(sql)
},
query: async <T>(sql: string, params: readonly unknown[] = []) =>
// SQLiteDriver's generic contract erases SQL parameter and result types.
database.prepare(sql).all(...(params as SQLInputValue[])) as T[],
run: async (sql, params = []) => {
database.prepare(sql).run(...(params as SQLInputValue[]))
},
transaction: work => {
const transaction = transactionQueue.then(async () => {
const current = hold
hold = undefined
if (current) {
current.reached()
await current.released
}
database.exec('BEGIN IMMEDIATE')
try {
const result = await work(driver)
database.exec('COMMIT')
return result
} catch (error) {
database.exec('ROLLBACK')
throw error
}
})
transactionQueue = transaction.catch(() => undefined)
return transaction
},
}
const holdNextWrite = () => {
let release!: () => void
let reached!: () => void
const wasReached = new Promise<void>(resolve => {
reached = resolve
})
hold = { reached, released: new Promise<void>(resolve => (release = resolve)) }
return { release, wasReached }
}
return { database, driver, holdNextWrite }
}
const waitFor = async (assertion: () => void, timeoutMs: number = 2_000): Promise<void> => {
const deadline = Date.now() + timeoutMs
while (true) {
try {
assertion()
return
} catch (error) {
if (Date.now() >= deadline) throw error
await new Promise(resolve => setTimeout(resolve, 5))
}
}
}
/** A coordinator for one tab that lets the test deliver another tab's broadcast, as BroadcastChannel does. */
class PeerTabCoordinator extends SingleProcessCoordinator {
private readonly listeners = new Map<string, (message: ProtocolEnvelope<unknown>) => void>()
override subscribe(collectionId?: string, onMessage?: (message: ProtocolEnvelope<unknown>) => void): () => void {
if (collectionId === undefined || onMessage === undefined) return () => undefined
this.listeners.set(collectionId, onMessage)
return () => this.listeners.delete(collectionId)
}
deliverFromPeer(collectionId: string, payload: TxCommitted): void {
const listener = this.listeners.get(collectionId)
if (!listener) throw new Error(`No runtime subscribed to ${collectionId}.`)
listener({ collectionId, dbName: 'repro', payload, senderId: 'peer-tab', ts: Date.now(), v: 1 })
}
}
const COLLECTION_ID = 'items'
type SourceSyncParams = Parameters<SyncConfig<ShapeRow, string>['sync']>[0]
/**
* A persisted collection whose source the test drives by hand, with another tab committing to the same SQLite
* database. `reserve()` opens a source transaction that reserves its commit turn, as Electric's refetch does.
*/
const openPeerTabHarness = async ({ sharedScheduling = false }: { sharedScheduling?: boolean } = {}) => {
const { database, driver, holdNextWrite } = openHoldableDatabase({ sharedScheduling })
// The concrete class exposes the stream-position and hydration-scope methods the factory's interface marks optional.
const adapter = new SQLiteCorePersistenceAdapter({ driver })
const coordinator = new PeerTabCoordinator()
let source: SourceSyncParams | undefined
const collection = createCollection(
persistedCollectionOptions<ShapeRow, string>({
id: COLLECTION_ID,
persistence: { adapter, coordinator },
schemaVersion: 1,
getKey: row => row.id,
sync: {
sync: params => {
source = params
params.begin()
params.write({ type: 'insert', value: { id: 'row-1', value: 'initial' } })
params.commit()
params.markReady()
return {}
},
},
})
)
const requireSource = (): SourceSyncParams => {
if (!source) throw new Error('The collection has not started its source.')
return source
}
const start = async () => {
await collection.preload()
await waitFor(() => expect(collection.get('row-1')).toMatchObject({ value: 'initial' }))
}
/** Commits a row change from the peer tab into the shared database and returns its broadcast notice. */
const commitFromPeer = async (value: string): Promise<TxCommitted> => {
const position = await adapter.getStreamPosition(eagerStorageTarget(COLLECTION_ID))
const tx = {
mutations: [{ key: 'row-1', type: 'update' as const, value: { id: 'row-1', value } }],
rowVersion: position.latestRowVersion + 1,
seq: position.latestSeq + 1,
term: position.latestTerm,
txId: value,
}
await adapter.applyCommittedTx(eagerStorageTarget(COLLECTION_ID), tx)
return {
latestRowVersion: tx.rowVersion,
requiresFullReload: true,
seq: tx.seq,
term: tx.term,
txId: tx.txId,
type: 'tx:committed',
}
}
const reserve = (value?: string) => {
const params = requireSource()
params.begin()
if (value) params.write({ type: 'update', value: { id: 'row-1', value } })
params.metadata?.persistence?.reserveCommitTurn()
}
const commit = () => requireSource().commit()
/** The peer commits and announces while this tab's reserved transaction is open, so the notice is deferred. */
const deferPeerCommit = async (value: string) => {
const notice = await commitFromPeer(value)
reserve()
coordinator.deliverFromPeer(COLLECTION_ID, notice)
await new Promise(resolve => setTimeout(resolve, 30))
expect(collection.get('row-1')).not.toMatchObject({ value })
}
await start()
return { adapter, coordinator, collection, commit, commitFromPeer, database, deferPeerCommit, holdNextWrite, reserve, start }
}
describe('a hydration that starts inside a commit-reserving source transaction', () => {
const cleanups: Array<() => Promise<void> | void> = []
afterEach(async () => {
for (const cleanup of cleanups.splice(0).reverse()) await cleanup()
})
const open = async (options?: { sharedScheduling?: boolean }) => {
const harness = await openPeerTabHarness(options)
cleanups.push(() => harness.database.close())
cleanups.push(() => harness.collection.cleanup())
return harness
}
// Two tabs on one database. The other tab commits and broadcasts a full-reload notice while this tab's source
// holds an open transaction that reserved its commit turn (as Electric does across a 409 must-refetch).
it('commits the reserved transaction when a peer full-reload notice arrives while it is open', async () => {
const harness = await open()
const notice = await harness.commitFromPeer('peer edit')
harness.reserve('refetched')
harness.coordinator.deliverFromPeer(COLLECTION_ID, notice)
await new Promise(resolve => setTimeout(resolve, 30))
await harness.commit()
await waitFor(() => expect(harness.collection.get('row-1')).toMatchObject({ value: 'refetched' }))
expect(harness.collection.status).not.toBe('error')
})
// The shared scheduler can hold a hydration behind another collection's write after its mutex turn began. The
// reservation must be checked when the hydration starts, not when its turn was admitted.
it('commits the reserved transaction when a reload waited behind another collection in the shared scheduler', async () => {
const harness = await open({ sharedScheduling: true })
expect(harness.adapter.isHydrationScopeScheduled()).toBe(true)
await harness.adapter.loadSubset(eagerStorageTarget('other_collection'), {})
const notice = await harness.commitFromPeer('peer edit')
const otherWrite = harness.holdNextWrite()
const blocker = harness.adapter.applyCommittedTx(eagerStorageTarget('other_collection'), { mutations: [], rowVersion: 1, seq: 1, term: 1, txId: 'blocker' })
await otherWrite.wasReached
let reloadAdmitted!: () => void
const admission = new Promise<void>(resolve => {
reloadAdmitted = resolve
})
const runInHydrationScope = harness.adapter.runInHydrationScope.bind(harness.adapter)
harness.adapter.runInHydrationScope = task => {
reloadAdmitted()
return runInHydrationScope(task)
}
harness.coordinator.deliverFromPeer(COLLECTION_ID, notice)
await admission
harness.reserve('refetched')
otherWrite.release()
await blocker
await new Promise(resolve => setTimeout(resolve, 30))
await harness.commit()
await waitFor(() => expect(harness.collection.get('row-1')).toMatchObject({ value: 'refetched' }))
expect(harness.collection.status).not.toBe('error')
})
})
What we did locally
We carry a patch on 0.4.8. It moves the check from admission to the place where each hydration starts:
hydrateSubsetUnsafe,reloadActiveSubsetsUnsafe, andtruncateAndReloadUnsafethrow a privateCommitTurnDeferralbefore they change any state if a reserved transaction is open. The check runs inside the hydration's own scope because the shared scheduler can hold a hydration long after its mutex turn began.- Subset loads,
forceReloadSubset, and peer resets run through a helper that waits for publication admission outside the apply mutex and then retries the turn. Waiting inside the mutex would deadlock against the commit's own durable write. - A deferred
tx:committednotice goes back to the front ofqueuedTxCommitted. Later notices queue behind it until the queue drains, so notices still apply in arrival order. A drain belongs to the collection lifecycle that scheduled it, so a cleanup during a deferral cannot strand the restarted collection's notices.
The patch is about 130 lines in persisted.ts. I'm happy to share it or open a PR. I'd expect you to prefer something that fits the oracle structure, though, so a design call from you first would help.
Questions
- Is "check at hydration start, retry outside the mutex" the shape you'd want, or would you rather keep reserved transactions from spanning a hydration in some other way?
- Is the
requiresFullReloadpath the intended response to a peer's refetch commit while this tab has its own refetch open? Deferring it until the local commit lands, then reloading from durable state, worked for us.
- 主要语言
- TypeScript
- 星标
- 3.9k
- 派生
- 277
- 平均合并
- 18 小时 7 分钟
- 30 天内合并 PR
- 228
环境准备
- 没有 Dockerfile 或 Docker Compose 文件
- 有 Pull Request 模板
- 阅读贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
TanStack/db 的其他 Issue
-
难度 2/5 1-3 小时 新手友好度 78/100
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 82/100
维护者通常 1 天内回复
-
难度 4/5 1-2 天 新手友好度 48/100
维护者通常 1 天内回复
-
难度 4/5 1-2 天 新手友好度 48/100
维护者通常 1 天内回复
-
难度 4/5 3-5 天 新手友好度 48/100
维护者通常 1 天内回复
相似的 Issue
-
area: image-engine bug priority: low
难度 2/5 1-3 小时 新手友好度 88/100
snapotter-hq/SnapOtter#3885 ·
维护者通常 1 天内回复
-
Mend: dependency security vulnerability
难度 2/5 1-3 小时 新手友好度 68/100
维护者通常 3 天内回复
-
pending triage
难度 2/5 1-3 小时 新手友好度 76/100
维护者通常 1 天内回复
-
bug P3 UI/UX
难度 2/5 1-3 小时 新手友好度 68/100
维护者通常 1 天内回复
-
clawsweeper:fix-shape-clear clawsweeper:queueable-fix clawsweeper:source-repro impact:ux-friction issue-rating: 🦞 diamond lobster no-stale P3
难度 2/5 1-3 小时 新手友好度 73/100
openclaw/openclaw#182077 · 1 条评论 · 1 个 reaction ·
维护者通常 1 天内回复