Hacktoberfest 2026: los issues que los mantenedores marcaron para octubre, abiertos y aptos para principiantes. Explorar issues de Hacktoberfest

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

Abierto
#2,226 0 comentarios 0 reacciones 0 asignados Ver en GitHub

Los mantenedores suelen responder en 1 día

Nadie ha tomado este issue todavía.

Evaluación

Dificultad
4/5
Tiempo estimado
1-2 días
Aptitud para principiantes
48/100
Tipo de issue
Error
Claridad
Bien especificado
Estado de actividad
Activo
Stack tecnológico
sqlite, typescript
Área
backend, database

Línea de trabajo

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.

Escrito por el modelo de indexación a partir del texto del issue.

Descripción

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.
Lenguaje dominante
TypeScript
Estrellas
3.9k
Forks
277
Merge medio
18 h 7 min
PR fusionados (30 d)
228

Preparar el entorno

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de TanStack/db

Todos los issues de TanStack/db

Issues similares

Más issues de TypeScript

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.