Skip to content

Conversation

@tantaman
Copy link
Contributor

@tantaman tantaman commented Dec 4, 2025

We cache prepared statements today but it turns out just constructing the SQL is also expensive.

This now caches SQL strings by constraint_col_names,start_col_names,basis,dir. So less work than building the full SQL.

The big difficulty with this change is figuring out how to bind values into the correct slots. Before we re-recreated the full SQL each time and the library would return values in the correct order for binding.

Example:

const {text, values} = sql`SELECT * FROM foo WHERE a = ? AND b = ? AND c > ?`

But now we have the SQL string pre-cached we need to figure out what slots constraint and start values go into ourselves.

I updated our SQL formatter to support named parameters which makes this easier.

  1. Filters are static / never changing. Filter bind slots are named: f_{n}
  2. Constraints can change with each fetch. They get named: c_{columnName}
  3. Start basis can change with each fetch. Start slots are named: s_{columnName}

Then we can create a binding object:

const bindings = {
  ...filterValues,
  ...constraintValues,
  ...startValues
};

to pass:

stmt.iterate(bindings);

Query: (over ~250,000 issues)

builder.issue
    .related('comments', c => c.limit(1))
    .related('creator')
    .related('assignee'),

before: 12.62 s/iter

after: 10.32 s/iter


before: planned: track.exists(playlists) 127.35 ms
after: planned: track.exists(playlists) 105.75 ms

Waiting on: rocicorp/zero-sqlite3#19

@vercel
Copy link

vercel bot commented Dec 4, 2025

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

Project Deployment Preview Comments Updated (UTC)
replicache-docs Ready Ready Preview Comment Dec 9, 2025 0:33am
zbugs Ready Ready Preview Comment Dec 9, 2025 0:33am

@github-actions
Copy link

github-actions bot commented Dec 4, 2025

🐰 Bencher Report

Branchmlaw/query-cache
TestbedLinux
Click to view all benchmark results
BenchmarkFile SizeBenchmark Result
kilobytes (KB)
(Result Δ%)
Upper Boundary
kilobytes (KB)
(Limit %)
zero-package.tgz📈 view plot
🚷 view threshold
1,771.80 KB
(+0.12%)Baseline: 1,769.61 KB
1,805.00 KB
(98.16%)
zero.js📈 view plot
🚷 view threshold
239.65 KB
(0.00%)Baseline: 239.65 KB
244.44 KB
(98.04%)
zero.js.br📈 view plot
🚷 view threshold
65.95 KB
(0.00%)Baseline: 65.95 KB
67.26 KB
(98.04%)
🐰 View full continuous benchmarking report in Bencher

@github-actions
Copy link

github-actions bot commented Dec 4, 2025

🐰 Bencher Report

Branchmlaw/query-cache
Testbedself-hosted

🚨 3 Alerts

BenchmarkMeasure
Units
ViewBenchmark Result
(Result Δ%)
Lower Boundary
(Limit %)
planned: track.exists(album) where title="Big Ones"Throughput
operations / second (ops/s) x 1e3
📈 plot
🚷 threshold
🚨 alert (🔔)
6.64 ops/s x 1e3
(-17.27%)Baseline: 8.02 ops/s x 1e3
7.15 ops/s x 1e3
(107.77%)

zpg: all playlistsThroughput
operations / second (ops/s)
📈 plot
🚷 threshold
🚨 alert (🔔)
4.06 ops/s
(-29.31%)Baseline: 5.74 ops/s
5.18 ops/s
(127.61%)

zql: edit for limited query, outside the boundThroughput
operations / second (ops/s) x 1e3
📈 plot
🚷 threshold
🚨 alert (🔔)
207.27 ops/s x 1e3
(-16.99%)Baseline: 249.69 ops/s x 1e3
207.77 ops/s x 1e3
(100.24%)

Click to view all benchmark results
BenchmarkThroughputBenchmark Result
operations / second (ops/s)
(Result Δ%)
Lower Boundary
operations / second (ops/s)
(Limit %)
1 exists: track.exists(album)📈 view plot
🚷 view threshold
14,941.46 ops/s
(+0.19%)Baseline: 14,913.06 ops/s
13,992.36 ops/s
(93.65%)
10 exists (AND)📈 view plot
🚷 view threshold
218,384.71 ops/s
10 exists (OR)📈 view plot
🚷 view threshold
4,169.36 ops/s
12 exists (AND)📈 view plot
🚷 view threshold
194,059.45 ops/s
12 exists (OR)📈 view plot
🚷 view threshold
3,605.39 ops/s
12 level nesting📈 view plot
🚷 view threshold
3,211.44 ops/s
2 exists (AND): track.exists(album).exists(genre)📈 view plot
🚷 view threshold
5,622.90 ops/s
(+0.03%)Baseline: 5,621.08 ops/s
5,277.16 ops/s
(93.85%)
3 exists (AND)📈 view plot
🚷 view threshold
2,244.22 ops/s
(+3.22%)Baseline: 2,174.16 ops/s
2,024.38 ops/s
(90.20%)
3 exists (OR)📈 view plot
🚷 view threshold
1,133.79 ops/s
(+2.13%)Baseline: 1,110.09 ops/s
1,037.20 ops/s
(91.48%)
5 exists (AND)📈 view plot
🚷 view threshold
356.09 ops/s
(+3.70%)Baseline: 343.39 ops/s
322.29 ops/s
(90.51%)
5 exists (OR)📈 view plot
🚷 view threshold
184.47 ops/s
(+1.15%)Baseline: 182.37 ops/s
170.24 ops/s
(92.29%)
Nested 2 levels: track > album > artist📈 view plot
🚷 view threshold
5,006.64 ops/s
(+2.93%)Baseline: 4,864.15 ops/s
4,556.86 ops/s
(91.02%)
Nested 4 levels: playlist > tracks > album > artist📈 view plot
🚷 view threshold
797.71 ops/s
(-0.39%)Baseline: 800.81 ops/s
751.59 ops/s
(94.22%)
Nested with filters: track > album > artist (filtered)📈 view plot
🚷 view threshold
4,011.79 ops/s
(-1.15%)Baseline: 4,058.37 ops/s
3,803.58 ops/s
(94.81%)
planned: album.related(tracks orderBy name, artist)📈 view plot
🚷 view threshold
1,003.58 ops/s
planned: album.related(tracks where milliseconds>300000, artist)📈 view plot
🚷 view threshold
999.72 ops/s
planned: artist.related(albums.related(tracks))📈 view plot
🚷 view threshold
1,632.82 ops/s
planned: artist.related(albums.related(tracks.related(genre, mediaType)))📈 view plot
🚷 view threshold
1,553.41 ops/s
planned: artist.whereExists(albums.whereExists(tracks.whereExists(genre=Rock)))📈 view plot
🚷 view threshold
11.32 ops/s
planned: customer.whereExists(deep invoice->line->track->genre=Rock).related(supportRep)📈 view plot
🚷 view threshold
1,907.06 ops/s
planned: invoice deep: customer->supportRep->reportsTo, lines->track->album->artist📈 view plot
🚷 view threshold
620.32 ops/s
planned: invoice.related(customer, lines.related(track))📈 view plot
🚷 view threshold
622.13 ops/s
planned: invoice.related(lines where quantity>1, customer)📈 view plot
🚷 view threshold
592.32 ops/s
planned: invoice.whereExists(customer where country=USA).related(lines)📈 view plot
🚷 view threshold
941.58 ops/s
planned: playlist.exists(tracks)📈 view plot
🚷 view threshold
830.96 ops/s
(+9.73%)Baseline: 757.27 ops/s
309.76 ops/s
(37.28%)
planned: playlist.related(tracks.related(album.related(artist), genre))📈 view plot
🚷 view threshold
14,635.11 ops/s
planned: playlist.whereExists(tracks.whereExists(genre=Rock)).related(tracks)📈 view plot
🚷 view threshold
107.63 ops/s
planned: track.exists(album) OR exists(genre)📈 view plot
🚷 view threshold
174.75 ops/s
(-1.23%)Baseline: 176.92 ops/s
162.25 ops/s
(92.85%)
planned: track.exists(album) where title="Big Ones"📈 view plot
🚷 view threshold
🚨 view alert (🔔)
6,638.52 ops/s
(-17.27%)Baseline: 8,024.75 ops/s
7,154.59 ops/s
(107.77%)

planned: track.exists(album).exists(genre)📈 view plot
🚷 view threshold
45.49 ops/s
(+43.42%)Baseline: 31.72 ops/s
4.52 ops/s
(9.94%)
planned: track.exists(album).exists(genre) with filters📈 view plot
🚷 view threshold
4,538.66 ops/s
(-14.45%)Baseline: 5,305.54 ops/s
3,531.46 ops/s
(77.81%)
planned: track.exists(playlists)📈 view plot
🚷 view threshold
5.47 ops/s
(+4.92%)Baseline: 5.21 ops/s
2.46 ops/s
(44.90%)
planned: track.related(album.related(artist), genre, mediaType, playlists)📈 view plot
🚷 view threshold
63.92 ops/s
planned: track.where(OR album=BigOnes, genre=Rock).related(album, genre)📈 view plot
🚷 view threshold
147.19 ops/s
planned: track.whereExists(invoiceLines).related(album, genre)📈 view plot
🚷 view threshold
3.25 ops/s
unplanned: album.related(tracks orderBy name, artist)📈 view plot
🚷 view threshold
1,012.38 ops/s
unplanned: album.related(tracks where milliseconds>300000, artist)📈 view plot
🚷 view threshold
1,003.99 ops/s
unplanned: artist.related(albums.related(tracks))📈 view plot
🚷 view threshold
1,641.43 ops/s
unplanned: artist.related(albums.related(tracks.related(genre, mediaType)))📈 view plot
🚷 view threshold
1,515.90 ops/s
unplanned: artist.whereExists(albums.whereExists(tracks.whereExists(genre=Rock)))📈 view plot
🚷 view threshold
42.52 ops/s
unplanned: customer.whereExists(deep invoice->line->track->genre=Rock).related(supportRep)📈 view plot
🚷 view threshold
1,869.61 ops/s
unplanned: invoice deep: customer->supportRep->reportsTo, lines->track->album->artist📈 view plot
🚷 view threshold
621.01 ops/s
unplanned: invoice.related(customer, lines.related(track))📈 view plot
🚷 view threshold
631.42 ops/s
unplanned: invoice.related(lines where quantity>1, customer)📈 view plot
🚷 view threshold
589.40 ops/s
unplanned: invoice.whereExists(customer where country=USA).related(lines)📈 view plot
🚷 view threshold
419.53 ops/s
unplanned: playlist.exists(tracks)📈 view plot
🚷 view threshold
792.95 ops/s
(+7.60%)Baseline: 736.96 ops/s
300.48 ops/s
(37.89%)
unplanned: playlist.related(tracks.related(album.related(artist), genre))📈 view plot
🚷 view threshold
14,082.60 ops/s
unplanned: playlist.whereExists(tracks.whereExists(genre=Rock)).related(tracks)📈 view plot
🚷 view threshold
107.53 ops/s
unplanned: track.exists(album) OR exists(genre)📈 view plot
🚷 view threshold
53.84 ops/s
(+51.23%)Baseline: 35.60 ops/s
1.62 ops/s
(3.01%)
unplanned: track.exists(album) where title="Big Ones"📈 view plot
🚷 view threshold
68.23 ops/s
(+44.63%)Baseline: 47.17 ops/s
14.16 ops/s
(20.75%)
unplanned: track.exists(album).exists(genre)📈 view plot
🚷 view threshold
45.74 ops/s
(+45.14%)Baseline: 31.51 ops/s
4.26 ops/s
(9.32%)
unplanned: track.exists(album).exists(genre) with filters📈 view plot
🚷 view threshold
65.36 ops/s
(+41.42%)Baseline: 46.22 ops/s
15.18 ops/s
(23.23%)
unplanned: track.exists(playlists)📈 view plot
🚷 view threshold
5.50 ops/s
(+5.37%)Baseline: 5.22 ops/s
2.51 ops/s
(45.74%)
unplanned: track.related(album.related(artist), genre, mediaType, playlists)📈 view plot
🚷 view threshold
64.13 ops/s
unplanned: track.where(OR album=BigOnes, genre=Rock).related(album, genre)📈 view plot
🚷 view threshold
48.80 ops/s
unplanned: track.whereExists(invoiceLines).related(album, genre)📈 view plot
🚷 view threshold
10.61 ops/s
zpg: all playlists📈 view plot
🚷 view threshold
🚨 view alert (🔔)
4.06 ops/s
(-29.31%)Baseline: 5.74 ops/s
5.18 ops/s
(127.61%)

zql: all playlists📈 view plot
🚷 view threshold
8.14 ops/s
(+61.17%)Baseline: 5.05 ops/s
2.25 ops/s
(27.66%)
zql: edit for limited query, inside the bound📈 view plot
🚷 view threshold
217,761.96 ops/s
(-7.72%)Baseline: 235,979.38 ops/s
210,627.31 ops/s
(96.72%)
zql: edit for limited query, outside the bound📈 view plot
🚷 view threshold
🚨 view alert (🔔)
207,272.90 ops/s
(-16.99%)Baseline: 249,694.94 ops/s
207,768.50 ops/s
(100.24%)

zql: push into limited query, inside the bound📈 view plot
🚷 view threshold
113,014.21 ops/s
(-1.92%)Baseline: 115,231.31 ops/s
106,521.97 ops/s
(94.26%)
zql: push into limited query, outside the bound📈 view plot
🚷 view threshold
417,627.72 ops/s
(-6.57%)Baseline: 446,980.97 ops/s
370,963.06 ops/s
(88.83%)
zql: push into unlimited query📈 view plot
🚷 view threshold
349,216.76 ops/s
(-4.04%)Baseline: 363,915.75 ops/s
327,020.11 ops/s
(93.64%)
zqlite: all playlists📈 view plot
🚷 view threshold
3.07 ops/s
(+97.44%)Baseline: 1.56 ops/s
0.94 ops/s
(30.69%)
zqlite: edit for limited query, inside the bound📈 view plot
🚷 view threshold
79,975.53 ops/s
(-17.52%)Baseline: 96,963.91 ops/s
46,686.59 ops/s
(58.38%)
zqlite: edit for limited query, outside the bound📈 view plot
🚷 view threshold
81,972.46 ops/s
(-15.59%)Baseline: 97,107.41 ops/s
41,284.46 ops/s
(50.36%)
zqlite: push into limited query, inside the bound📈 view plot
🚷 view threshold
4,368.85 ops/s
(+4.27%)Baseline: 4,189.77 ops/s
3,929.62 ops/s
(89.95%)
zqlite: push into limited query, outside the bound📈 view plot
🚷 view threshold
95,030.86 ops/s
(-14.33%)Baseline: 110,922.07 ops/s
47,564.21 ops/s
(50.05%)
zqlite: push into unlimited query📈 view plot
🚷 view threshold
134,890.94 ops/s
(+1.38%)Baseline: 133,051.37 ops/s
121,041.83 ops/s
(89.73%)
🐰 View full continuous benchmarking report in Bencher

@github-actions
Copy link

github-actions bot commented Dec 4, 2025

🐰 Bencher Report

Branchmlaw/query-cache
Testbedself-hosted
Click to view all benchmark results
BenchmarkThroughputBenchmark Result
operations / second (ops/s) x 1e3
(Result Δ%)
Lower Boundary
operations / second (ops/s) x 1e3
(Limit %)
src/client/custom.bench.ts > big schema📈 view plot
🚷 view threshold
905.27 ops/s x 1e3
(+6.52%)Baseline: 849.83 ops/s x 1e3
588.22 ops/s x 1e3
(64.98%)
src/client/zero.bench.ts > basics > All 1000 rows x 10 columns (numbers)📈 view plot
🚷 view threshold
2.73 ops/s x 1e3
(-0.00%)Baseline: 2.73 ops/s x 1e3
2.22 ops/s x 1e3
(81.31%)
src/client/zero.bench.ts > pk compare > pk = N📈 view plot
🚷 view threshold
64.51 ops/s x 1e3
(+31.44%)Baseline: 49.08 ops/s x 1e3
36.81 ops/s x 1e3
(57.07%)
src/client/zero.bench.ts > with filter > Lower rows 500 x 10 columns (numbers)📈 view plot
🚷 view threshold
4.04 ops/s x 1e3
(+0.93%)Baseline: 4.00 ops/s x 1e3
3.47 ops/s x 1e3
(85.82%)
🐰 View full continuous benchmarking report in Bencher

@tantaman tantaman marked this pull request as ready for review December 4, 2025 20:17
@tantaman tantaman requested a review from arv December 4, 2025 20:20
Copy link
Contributor

@arv arv left a comment

Choose a reason for hiding this comment

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

Still WIP?

"@op-engineering/op-sqlite": ">=15",
"@rocicorp/prettier-config": "^0.4.0",
"@rocicorp/zero-sqlite3": "^1.0.12",
"@rocicorp/zero-sqlite3": "file:../../../zero-sqlite3/rocicorp-zero-sqlite3-1.0.13.tgz",
Copy link
Contributor

Choose a reason for hiding this comment

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

Oops

id: body.id,
value: result,
});
console.log('WFDFSDFSDFS');
Copy link
Contributor

Choose a reason for hiding this comment

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

log

});
console.log(result);
} catch (e) {
console.log(e);
Copy link
Contributor

Choose a reason for hiding this comment

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

undo this debugging

Copy link
Contributor

@arv arv left a comment

Choose a reason for hiding this comment

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

LGTM

There is a lot of moving parts here. Are you sure that the perf gain is from what you think it is from?

Also, it would be interesting to look at the performance trace to see where we are spending all this the time, We are doing a lot and I think that will impact performance in the end.

expect(named('num', 123.45).value).toBe(123.45);
expect(named('bool', true).value).toBe(true);
expect(named('nil', null).value).toBe(null);
expect(named('obj', {a: 1}).value).toEqual({a: 1});
Copy link
Contributor

Choose a reason for hiding this comment

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

are these ===?

/**
* Formats a SQL query using named placeholders (`:name` syntax).
* Returns an object with text and a values Record for use with
* better-sqlite3's named parameter binding.
Copy link
Contributor

Choose a reason for hiding this comment

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

How does this impact performance?

Having to create these extra objects everywhere might cause more GC churn?

I guess it should not matter because the positional params also use an object.

@tantaman
Copy link
Contributor Author

tantaman commented Dec 9, 2025

There is a lot of moving parts here. Are you sure that the perf gain is from what you think it is from?

I'll double check. I'm also hesitant to land this ahead of 0.25 given the gains are modest and the complexity is high.

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.

3 participants