Hacktoberfest 2026:维护者为十月标记出来的 issue,仍然开放、适合新手。 浏览 Hacktoberfest issue

Persistence: a hydration that starts inside a commit-reserving source transaction rejects that transaction's commit

未关闭
#2,226 0 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

维护者通常 1 天内回复

还没有人认领这个 Issue。

评估

难度
4/5
预计耗时
1-2 天
新手友好度
48/100
Issue 类型
缺陷
描述清晰度
描述清楚
活跃度
活跃
技术栈
sqlite, typescript
领域
backend, database

调研方向

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 hydrateSubsetUnsafe after it (for example, queued behind a durable write on the apply mutex);
  • reloadActiveSubsetsUnsafe from a peer tab's tx:committed notice with requiresFullReload;
  • truncateAndReloadUnsafe from a peer collection: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:

  1. 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.
  2. With several tabs open, each tab commits its own refetch and broadcasts it. A peer tab handled that requiresFullReload notice 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, and truncateAndReloadUnsafe throw a private CommitTurnDeferral before 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:committed notice goes back to the front of queuedTxCommitted. 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

  1. 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?
  2. Is the requiresFullReload path 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

环境准备

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

TanStack/db 的其他 Issue

查看 TanStack/db 的全部 Issue

相似的 Issue

更多 TypeScript Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。