1package sqlite_test
2
3import (
4 "context"
5 "database/sql"
6 "fmt"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/bnema/dumber/internal/application/usecase"
13 "github.com/bnema/dumber/internal/domain/repository"
14 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
15 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
16)
17
18const (
19 // historyBenchmarkCorpusSize is the number of rows seeded for the
20 // recent/search persistence benchmarks.
21 historyBenchmarkCorpusSize = 200
22 // historyBenchmarkEligibleRows is the number of rows left inside the bounded
23 // window; the remainder are stale so bounded benchmarks still exercise a
24 // large history with few eligible rows.
25 historyBenchmarkEligibleRows = 40
26 // historyBenchmarkStaleRows is the number of stale, high-visit-count rows
27 // seeded for bounded most-visited benchmarks. They outrank every eligible
28 // row, so an unfiltered most-visited scan would never reach the eligible rows
29 // within the result limit.
30 historyBenchmarkStaleRows = 2000
31
32 historyBenchmarkRecentLimit = 50
33 historyBenchmarkSearchLimit = 10
34 historyBenchmarkMostVisitedLimit = 10
35 historyBenchmarkMaxAgeDays = 30
36 // historyBenchmarkStaleAgeDays places corpus rows well outside the window.
37 historyBenchmarkStaleAgeDays = 400
38)
39
40// historyBenchmarkRow is one explicitly categorized corpus row. Category is
41// encoded in the URL, title, and timestamp rather than an AUTOINCREMENT id so
42// benchmarks do not depend on insertion order.
43type historyBenchmarkRow struct {
44 url string
45 title string
46 lastVisited time.Time
47 visitCount int
48}
49
50// historyBenchmarkURL returns the stable URL for corpus row i.
51func historyBenchmarkURL(i int) string {
52 return fmt.Sprintf("https://example%d.test/dumber/history/%d", i%20, i)
53}
54
55// newHistoryBenchmarkDB opens an isolated benchmark database and registers
56// cleanup.
57func newHistoryBenchmarkDB(b *testing.B) *sql.DB {
58 b.Helper()
59
60 db, err := sqlite.NewConnection(historyTestCtx(), filepath.Join(b.TempDir(), "dumber.db"))
61 if err != nil {
62 b.Fatal(err)
63 }
64 b.Cleanup(func() {
65 if closeErr := db.Close(); closeErr != nil {
66 b.Errorf("close benchmark database: %v", closeErr)
67 }
68 })
69
70 return db
71}
72
73// seedHistoryBenchmarkRows inserts corpus rows in a single transaction without
74// going through the logging repository path.
75func seedHistoryBenchmarkRows(b *testing.B, ctx context.Context, db *sql.DB, rows []historyBenchmarkRow) {
76 b.Helper()
77
78 tx, err := db.BeginTx(ctx, nil)
79 if err != nil {
80 b.Fatal(err)
81 }
82 stmt, err := tx.PrepareContext(ctx, `INSERT INTO history (url, title, last_visited, visit_count) VALUES (?, ?, ?, ?)`)
83 if err != nil {
84 _ = tx.Rollback()
85 b.Fatal(err)
86 }
87 defer func() { _ = stmt.Close() }()
88
89 for _, row := range rows {
90 if _, err := stmt.ExecContext(
91 ctx,
92 row.url,
93 row.title,
94 sqliteTimestamp(row.lastVisited),
95 row.visitCount,
96 ); err != nil {
97 _ = tx.Rollback()
98 b.Fatal(err)
99 }
100 }
101 if err := tx.Commit(); err != nil {
102 b.Fatal(err)
103 }
104}
105
106// historyBenchmarkCorpusRows builds historyBenchmarkCorpusSize rows with the
107// first eligibleRows rows recent and the rest stale.
108func historyBenchmarkCorpusRows(now time.Time, eligibleRows int) []historyBenchmarkRow {
109 recent := now.Add(-time.Hour)
110 stale := now.AddDate(0, 0, -historyBenchmarkStaleAgeDays)
111
112 rows := make([]historyBenchmarkRow, 0, historyBenchmarkCorpusSize)
113 for i := range historyBenchmarkCorpusSize {
114 lastVisited := stale
115 if i < eligibleRows {
116 lastVisited = recent
117 }
118 rows = append(rows, historyBenchmarkRow{
119 url: historyBenchmarkURL(i),
120 title: fmt.Sprintf("Dumber history entry %d", i),
121 lastVisited: lastVisited,
122 visitCount: 1,
123 })
124 }
125 return rows
126}
127
128// newHistoryBenchmarkRepository seeds an unbounded corpus where every row is
129// inside the window.
130func newHistoryBenchmarkRepository(b *testing.B) repository.HistoryRepository {
131 b.Helper()
132
133 db := newHistoryBenchmarkDB(b)
134 seedHistoryBenchmarkRows(b, historyTestCtx(), db, historyBenchmarkCorpusRows(time.Now().UTC(), historyBenchmarkCorpusSize))
135
136 return sqlite.NewHistoryRepository(db)
137}
138
139// newBoundedHistoryBenchmarkRepository seeds a corpus where only
140// historyBenchmarkEligibleRows rows fall inside the window, and returns the
141// cutoff for the bounded repository paths.
142func newBoundedHistoryBenchmarkRepository(b *testing.B) (repository.HistoryRepository, time.Time) {
143 b.Helper()
144
145 now := time.Now().UTC()
146 db := newHistoryBenchmarkDB(b)
147 seedHistoryBenchmarkRows(b, historyTestCtx(), db, historyBenchmarkCorpusRows(now, historyBenchmarkEligibleRows))
148
149 return sqlite.NewHistoryRepository(db), now.AddDate(0, 0, -historyBenchmarkMaxAgeDays)
150}
151
152// newMostVisitedBenchmarkUseCase seeds thousands of stale high-count rows plus
153// eligibleRows recent low-count rows, then returns the application use case so
154// benchmarks exercise the real omnibox most-visited path.
155func newMostVisitedBenchmarkUseCase(b *testing.B, eligibleRows int) *usecase.SearchHistoryUseCase {
156 b.Helper()
157
158 now := time.Now().UTC()
159 db := newHistoryBenchmarkDB(b)
160
161 rows := make([]historyBenchmarkRow, 0, historyBenchmarkStaleRows+eligibleRows)
162 for i := range historyBenchmarkStaleRows {
163 rows = append(rows, historyBenchmarkRow{
164 url: fmt.Sprintf("https://stale%d.test/page", i),
165 title: fmt.Sprintf("Stale %d", i),
166 lastVisited: now.AddDate(0, 0, -historyBenchmarkStaleAgeDays),
167 visitCount: 10_000 + i,
168 })
169 }
170 for i := range eligibleRows {
171 rows = append(rows, historyBenchmarkRow{
172 url: fmt.Sprintf("https://eligible%d.test/page", i),
173 title: fmt.Sprintf("Eligible %d", i),
174 lastVisited: now.Add(-time.Hour),
175 visitCount: 1 + i,
176 })
177 }
178 seedHistoryBenchmarkRows(b, historyTestCtx(), db, rows)
179
180 logBoundedMostVisitedPlan(b, historyTestCtx(), db, now.AddDate(0, 0, -historyBenchmarkMaxAgeDays))
181
182 return usecase.NewSearchHistoryUseCase(sqlite.NewHistoryRepository(db))
183}
184
185// requireBenchmarkLen fails the benchmark if a fixture or query regression
186// changes the expected result count, so benchmarks never silently measure empty
187// work.
188func requireBenchmarkLen(b *testing.B, got, want int, label string) {
189 b.Helper()
190
191 if got != want {
192 b.Fatalf("%s: got %d results, want %d", label, got, want)
193 }
194}
195
196// logBoundedMostVisitedPlan reports (without asserting) the planner choice for
197// the bounded most-visited query, which is informational and not part of the
198// contract.
199func logBoundedMostVisitedPlan(b *testing.B, ctx context.Context, db *sql.DB, cutoff time.Time) {
200 b.Helper()
201
202 rows, err := db.QueryContext(
203 ctx,
204 "EXPLAIN QUERY PLAN "+sqlc.GetMostVisitedHistorySinceCutoff,
205 sqliteTimestamp(cutoff),
206 historyBenchmarkMostVisitedLimit,
207 )
208 if err != nil {
209 b.Logf("bounded most-visited explain failed: %v", err)
210 return
211 }
212 defer func() { _ = rows.Close() }()
213
214 var details []string
215 for rows.Next() {
216 var id, parent, notUsed int
217 var detail string
218 if err := rows.Scan(&id, &parent, ¬Used, &detail); err != nil {
219 b.Logf("bounded most-visited explain scan failed: %v", err)
220 return
221 }
222 details = append(details, detail)
223 }
224 b.Logf("bounded most-visited plan: %s", strings.Join(details, "; "))
225}
226
227func BenchmarkHistorySQLiteSidebarRecent(b *testing.B) {
228 repo := newHistoryBenchmarkRepository(b)
229 ctx := historyTestCtx()
230
231 results, err := repo.GetRecent(ctx, historyBenchmarkRecentLimit, 0, repository.HistoryScope{})
232 if err != nil {
233 b.Fatal(err)
234 }
235 requireBenchmarkLen(b, len(results), historyBenchmarkRecentLimit, "unbounded recent")
236
237 b.ReportAllocs()
238 b.ResetTimer()
239 for range b.N {
240 if _, err := repo.GetRecent(ctx, historyBenchmarkRecentLimit, 0, repository.HistoryScope{}); err != nil {
241 b.Fatal(err)
242 }
243 }
244}
245
246func BenchmarkHistorySQLiteSidebarRecentBounded(b *testing.B) {
247 repo, cutoff := newBoundedHistoryBenchmarkRepository(b)
248 ctx := historyTestCtx()
249 scope := repository.HistoryScope{Cutoff: cutoff}
250
251 results, err := repo.GetRecent(ctx, historyBenchmarkRecentLimit, 0, scope)
252 if err != nil {
253 b.Fatal(err)
254 }
255 requireBenchmarkLen(b, len(results), historyBenchmarkEligibleRows, "bounded recent")
256
257 b.ReportAllocs()
258 b.ResetTimer()
259 for range b.N {
260 if _, err := repo.GetRecent(ctx, historyBenchmarkRecentLimit, 0, scope); err != nil {
261 b.Fatal(err)
262 }
263 }
264}
265
266// BenchmarkHistorySQLiteOmniboxMostVisitedBoundedFewEligible measures the
267// bounded most-visited path when only a handful of rows fall inside the window
268// despite thousands of higher-count stale rows.
269func BenchmarkHistorySQLiteOmniboxMostVisitedBoundedFewEligible(b *testing.B) {
270 const eligibleRows = 5
271
272 uc := newMostVisitedBenchmarkUseCase(b, eligibleRows)
273 ctx := historyTestCtx()
274
275 results, err := uc.GetMostVisitedWithin(ctx, historyBenchmarkMostVisitedLimit, historyBenchmarkMaxAgeDays)
276 if err != nil {
277 b.Fatal(err)
278 }
279 requireBenchmarkLen(b, len(results), eligibleRows, "bounded most-visited few eligible")
280
281 b.ReportAllocs()
282 b.ResetTimer()
283 for range b.N {
284 if _, err := uc.GetMostVisitedWithin(ctx, historyBenchmarkMostVisitedLimit, historyBenchmarkMaxAgeDays); err != nil {
285 b.Fatal(err)
286 }
287 }
288}
289
290// BenchmarkHistorySQLiteOmniboxMostVisitedBoundedNoEligible measures the
291// bounded most-visited path when no row falls inside the window.
292func BenchmarkHistorySQLiteOmniboxMostVisitedBoundedNoEligible(b *testing.B) {
293 uc := newMostVisitedBenchmarkUseCase(b, 0)
294 ctx := historyTestCtx()
295
296 results, err := uc.GetMostVisitedWithin(ctx, historyBenchmarkMostVisitedLimit, historyBenchmarkMaxAgeDays)
297 if err != nil {
298 b.Fatal(err)
299 }
300 requireBenchmarkLen(b, len(results), 0, "bounded most-visited no eligible")
301
302 b.ReportAllocs()
303 b.ResetTimer()
304 for range b.N {
305 if _, err := uc.GetMostVisitedWithin(ctx, historyBenchmarkMostVisitedLimit, historyBenchmarkMaxAgeDays); err != nil {
306 b.Fatal(err)
307 }
308 }
309}
310
311func BenchmarkHistorySQLiteOmniboxSearch(b *testing.B) {
312 repo := newHistoryBenchmarkRepository(b)
313 ctx := historyTestCtx()
314
315 results, err := repo.Search(ctx, "dumber history", historyBenchmarkSearchLimit, repository.HistoryScope{})
316 if err != nil {
317 b.Fatal(err)
318 }
319 requireBenchmarkLen(b, len(results), historyBenchmarkSearchLimit, "unbounded search")
320
321 b.ReportAllocs()
322 b.ResetTimer()
323 for range b.N {
324 if _, err := repo.Search(ctx, "dumber history", historyBenchmarkSearchLimit, repository.HistoryScope{}); err != nil {
325 b.Fatal(err)
326 }
327 }
328}
329
330func BenchmarkHistorySQLiteOmniboxSearchBounded(b *testing.B) {
331 repo, cutoff := newBoundedHistoryBenchmarkRepository(b)
332 ctx := historyTestCtx()
333 scope := repository.HistoryScope{Cutoff: cutoff}
334
335 results, err := repo.Search(ctx, "dumber history", historyBenchmarkSearchLimit, scope)
336 if err != nil {
337 b.Fatal(err)
338 }
339 requireBenchmarkLen(b, len(results), historyBenchmarkSearchLimit, "bounded search")
340
341 b.ReportAllocs()
342 b.ResetTimer()
343 for range b.N {
344 if _, err := repo.Search(ctx, "dumber history", historyBenchmarkSearchLimit, scope); err != nil {
345 b.Fatal(err)
346 }
347 }
348}