Skip to content

perf(replicache): Batch concurrent get/has operations in SQLiteStore#5955

Open
arv wants to merge 1 commit into
rocicorp:mainfrom
arv:main
Open

perf(replicache): Batch concurrent get/has operations in SQLiteStore#5955
arv wants to merge 1 commit into
rocicorp:mainfrom
arv:main

Conversation

@arv
Copy link
Copy Markdown
Contributor

@arv arv commented May 12, 2026

Summary

Optimize SQLiteStoreRead by batching concurrent get() and has() calls into single database queries using microtask scheduling, reducing round-trips when multiple keys are accessed concurrently.

Key Changes

  • Batching in SQLiteStoreRead: get() and has() calls queue into striped callback arrays ([resolve, reject, resolve, reject, ...]) and are flushed together in a queueMicrotask callback. get and has operations are batched independently.

  • Single-key fast path: When exactly one key is pending at flush time, the original single-row SQL (WHERE key = ?) is used directly, avoiding JSON serialization overhead for the common sequential case.

  • New batch statements: Added getMany (SELECT key, value FROM entry WHERE key IN (SELECT value FROM json_each(?))) and hasMany (SELECT key FROM entry WHERE key IN (SELECT value FROM json_each(?))) prepared statements alongside the existing single-key get/has statements.

  • Updated PreparedStatement interface: Replaced firstValue() with all(params): Promise<unknown[][]> to support returning multiple rows for batch queries.

  • Type aliases for casts: GetResolve, HasResolve, Reject aliases make the necessary as-casts on the unknown[] callback array concise.

  • All adapters updated: zero-sqlite, expo-sqlite, and op-sqlite implement the new all() method. The expo-sqlite mock's executeForRawResultAsync gains getAllAsync() support.

  • directory option: SQLiteStoreOptions gains an optional directory field so stores can be created in a specific path; dropStore/dropZeroSQLiteStore accept it too for consistent file resolution.

Tests

  • sqlite-store.test.node.ts: Unit tests verifying batching behaviour — concurrent gets coalesce into one getMany call, sequential awaited gets use the single-key path, concurrent has coalesces into hasMany, mixed get+has use separate SQL calls.
  • sqlite-store.test.ts: Integration test for SQLiteWrite batch commit (deletes + upserts flushed as one statement each).

## Summary

Optimize `SQLiteStoreRead` by batching concurrent `get()` and `has()` calls into single database queries using microtask scheduling, reducing round-trips when multiple keys are accessed concurrently.

## Key Changes

- **Batching in `SQLiteStoreRead`**: `get()` and `has()` calls queue into striped callback arrays (`[resolve, reject, resolve, reject, ...]`) and are flushed together in a `queueMicrotask` callback. `get` and `has` operations are batched independently.

- **Single-key fast path**: When exactly one key is pending at flush time, the original single-row SQL (`WHERE key = ?`) is used directly, avoiding JSON serialization overhead for the common sequential case.

- **New batch statements**: Added `getMany` (`SELECT key, value FROM entry WHERE key IN (SELECT value FROM json_each(?))`) and `hasMany` (`SELECT key FROM entry WHERE key IN (SELECT value FROM json_each(?))`) prepared statements alongside the existing single-key `get`/`has` statements.

- **Updated `PreparedStatement` interface**: Replaced `firstValue()` with `all(params): Promise<unknown[][]>` to support returning multiple rows for batch queries.

- **Type aliases for casts**: `GetResolve`, `HasResolve`, `Reject` aliases make the necessary `as`-casts on the `unknown[]` callback array concise.

- **All adapters updated**: zero-sqlite, expo-sqlite, and op-sqlite implement the new `all()` method. The expo-sqlite mock's `executeForRawResultAsync` gains `getAllAsync()` support.

- **`directory` option**: `SQLiteStoreOptions` gains an optional `directory` field so stores can be created in a specific path; `dropStore`/`dropZeroSQLiteStore` accept it too for consistent file resolution.

## Tests

- `sqlite-store.test.node.ts`: Unit tests verifying batching behaviour — concurrent gets coalesce into one `getMany` call, sequential awaited gets use the single-key path, concurrent has coalesces into `hasMany`, mixed get+has use separate SQL calls.
- `sqlite-store.test.ts`: Integration test for `SQLiteWrite` batch commit (deletes + upserts flushed as one statement each).
@arv arv requested review from 0xcadams and Copilot May 12, 2026 09:29
@vercel
Copy link
Copy Markdown

vercel Bot commented May 12, 2026

@arv is attempting to deploy a commit to the Rocicorp Team on Vercel.

A member of the Team first needs to authorize it.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves SQLiteStoreRead performance by coalescing concurrent get()/has() calls into batched SQL queries, reducing SQLite round-trips across the Replicache SQLite-backed KV implementations. It also updates the prepared-statement abstraction to support multi-row results and adds an optional directory option for file placement.

Changes:

  • Batch concurrent SQLiteStoreRead.get() and .has() calls via queueMicrotask, adding getMany/hasMany prepared statements plus a single-key fast path.
  • Update the PreparedStatement interface from firstValue() to all() and migrate zero-sqlite / expo-sqlite / op-sqlite adapters + mocks.
  • Add SQLiteStoreOptions.directory and thread options through drop helpers/providers; expand test coverage for batching.

Reviewed changes

Copilot reviewed 9 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/replicache/src/kv/sqlite-store.ts Implements batched get/has, adds getMany/hasMany, changes PreparedStatement, and introduces directory filename resolution.
packages/replicache/src/kv/sqlite-store.test.node.ts Adds focused unit tests validating batching and single-key fast path behavior.
packages/replicache/src/kv/sqlite-store.test.ts Updates mocks to the new PreparedStatement.all() API and adds batch statement placeholders.
packages/replicache/src/kv/sqlite-store-test-util.ts Adds integration coverage for concurrent get+has correctness across implementations.
packages/replicache/src/kv/zero-sqlite/store.ts Updates drop/provider wiring for options and implements PreparedStatement.all() for zero-sqlite.
packages/replicache/src/kv/zero-sqlite/store.test.node.ts Adjusts node test configuration to use a temp directory via the new directory option.
packages/replicache/src/kv/op-sqlite/store.ts Implements PreparedStatement.all() and updates drop/provider wiring to accept options.
packages/replicache/src/kv/expo-sqlite/store.ts Implements PreparedStatement.all() and updates drop/provider wiring to accept options.
packages/replicache/src/kv/expo-sqlite/store.test.node.ts Updates expo-sqlite mock to support getAllAsync() for all() calls.
package-lock.json Lockfile churn from dependency/install changes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 524 to 531
export function dropStore(
name: string,
createDelegate: (
filename: string,
opts?: SQLiteStoreOptions,
) => SQLiteDatabase,
opts?: SQLiteStoreOptions,
): Promise<void> {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude Can you fix this?

Comment on lines +339 to +359
#scheduleLookup(): void {
if (!this.#scheduled) {
this.#scheduled = true;
queueMicrotask(() => {
this.#scheduled = false;
const {get, has, getMany, hasMany} = this.#preparedStatements;
const getKeys = this.#pendingGetKeys.splice(0);
const getCallbacks = this.#pendingGetCallbacks.splice(0);
const hasKeys = this.#pendingHasKeys.splice(0);
const hasCallbacks = this.#pendingHasCallbacks.splice(0);
if (getKeys.length === 1) {
void flushSingle(getKeys[0], getCallbacks, get, settleSingleGet);
} else if (getKeys.length > 1) {
void flushBatch(getKeys, getCallbacks, getMany, settleGets);
}
if (hasKeys.length === 1) {
void flushSingle(hasKeys[0], hasCallbacks, has, settleSingleHas);
} else if (hasKeys.length > 1) {
void flushBatch(hasKeys, hasCallbacks, hasMany, settleHas);
}
});
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude Can you fix this?

Comment on lines +131 to +135
function resolveFilename(name: string, opts?: SQLiteStoreOptions): string {
const safe = safeFilename(name);
const dir = opts?.directory;
return dir ? `${dir}/${safe}` : safe;
}
@vercel
Copy link
Copy Markdown

vercel Bot commented May 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
replicache-docs Ready Ready Preview, Comment May 12, 2026 9:38am

Request Review

@arv arv added this pull request to the merge queue May 12, 2026
@github-merge-queue github-merge-queue Bot removed this pull request from the merge queue due to failed status checks May 12, 2026
@arv arv added this pull request to the merge queue May 12, 2026
@arv arv removed this pull request from the merge queue due to a manual request May 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants