Search index: Fix caching pages

This commit is contained in:
Simon Binder
2025-10-18 14:12:17 +02:00
parent c17f51fdb4
commit e16b0be7a1
5 changed files with 144 additions and 70 deletions
+36 -29
View File
@@ -16,12 +16,9 @@ final class SearchDatabase {
static Future<SearchDatabase> open(
CommonSqlite3 sqlite3,
Uri databaseUri,
SearchIndexLoader loader,
) async {
final vfs = HttpFileSystem(
name: 'http',
loader: SearchIndexLoader.http(databaseUri),
);
final vfs = HttpFileSystem(name: 'http', loader: loader);
sqlite3.registerVirtualFileSystem(vfs);
final db = await vfs.asyncify(() {
return sqlite3.open('/database', vfs: 'http', mode: OpenMode.readOnly);
@@ -235,7 +232,7 @@ final class _HttpFile extends BaseVfsFile {
@override
int xFileSize() {
if (_vfs._cache._info case final info?) {
return info.blocks * SearchIndexLoader.pageSize;
return info.pages * SearchIndexLoader.pageSize;
}
_vfs.blockOn(_vfs._cache.resolveTotalSize());
@@ -277,39 +274,49 @@ final class _BlockCache {
Future<void> resolveTotalSize() async {
final meta = _info = await loader.fetchMeta();
_cachedPages = List.filled(meta.blocks, null);
_cachedPages = List.filled(meta.pages, null);
// Load the first 10 pages to fetch schema and inner btree pages.
await ensureHasRange(0, _pageSize * min(10, meta.pages));
}
/// Ensures that the range from `offset` until `offset + length` (exclusive)
/// is cached.
FutureOr<void> ensureHasRange(int offset, int length) {
var page = _pageIndex(offset);
var endPageInclusive = _pageIndex(offset + length - 1);
var endPageExclusive = _pageIndex(offset + length + _pageSize - 1);
var cachedPages = _cachedPages!;
for (var i = page; i <= endPageInclusive; i++) {
if (cachedPages[page] == null) {
// We could fetch multiple pages concurrently, but most of the time
// SQLite will only read a single page at the time anyway.
return loader.fetchPage(_info!, i).then((response) {
final (partial, bytes) = response;
if (partial) {
assert(bytes.length == _pageSize);
cachedPages[i] = bytes;
} else {
assert(bytes.length == _pageSize * cachedPages.length);
for (var i = 0; i < cachedPages.length; i++) {
cachedPages[i] = bytes.buffer.asUint8List(
i * _pageSize,
_pageSize,
);
}
}
});
}
// Trim the range from page, endPageInclusive to remove pages at both ends
// that have already been cached.
while (cachedPages[page] != null && page < endPageExclusive) {
page++;
}
while (cachedPages[endPageExclusive - 1] != null &&
endPageExclusive > page) {
endPageExclusive--;
}
return null;
if (page >= endPageExclusive) {
// All pages have already been cached, no need to fetch anything.
return null;
}
return loader
.fetchPage(
PageFetchQuery(
info: _info!,
startPage: page,
endPage: endPageExclusive,
),
)
.then((response) {
var startPage = response.startPage;
var endPage = response.endPage;
for (var foundPage = startPage; foundPage < endPage; foundPage++) {
cachedPages[foundPage] = response.viewPage(foundPage);
}
});
}
int _pageIndex(int offset) {
+68 -24
View File
@@ -3,20 +3,9 @@ import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart';
import 'package:jaspr/jaspr.dart';
import 'web_cache_loader.dart';
abstract interface class SearchIndexLoader {
factory SearchIndexLoader.http(Uri metaUri) {
final loader = HttpSearchIndexLoader(metaUri);
if (kIsWeb) {
return CachedIndexLoader(loader);
} else {
return loader;
}
}
factory SearchIndexLoader.http(Uri metaUri) = HttpSearchIndexLoader;
/// Resolves meta information (size and hash) of the search database.
Future<SearchDatabaseInfo> fetchMeta();
@@ -25,14 +14,66 @@ abstract interface class SearchIndexLoader {
///
/// Returns whether range requests are supported and contents. If range
/// requests are not supported, the response is for the entire database.
Future<(bool, Uint8List)> fetchPage(SearchDatabaseInfo info, int pageNo);
Future<FetchedPages> fetchPage(PageFetchQuery query);
void close();
static const pageSize = 4096;
}
typedef SearchDatabaseInfo = ({String hash, int blocks});
final class SearchDatabaseInfo {
final String hash;
/// The total amount of pages in the search database.
final int pages;
SearchDatabaseInfo({required this.hash, required this.pages});
}
final class PageFetchQuery {
final SearchDatabaseInfo info;
/// Index of the first page to load.
final int startPage;
/// Exclusive end index, i.e. the first page to not load.
final int endPage;
int get length => (endPage - startPage) * SearchIndexLoader.pageSize;
PageFetchQuery({
required this.info,
required this.startPage,
required this.endPage,
});
}
final class FetchedPages {
/// The first page that has actually been loaded.
///
/// This is usually the [PageFetchQuery.startPage], but can also be `0` if the
/// server doesn't support range requests.
final int startPage;
/// Contents of pages, starting from [startPage].
final Uint8List pages;
int get pageCount => pages.length ~/ SearchIndexLoader.pageSize;
int get endPage => startPage + pageCount;
FetchedPages({required this.startPage, required this.pages});
Uint8List viewPage(int no) {
final index =
RangeError.checkValueInInterval(no, startPage, endPage - 1) - startPage;
return pages.buffer.asUint8List(
pages.offsetInBytes + index * SearchIndexLoader.pageSize,
SearchIndexLoader.pageSize,
);
}
}
/// A [SearchIndexLoader] implemented by one HTTP request per page.
final class HttpSearchIndexLoader implements SearchIndexLoader {
@@ -49,24 +90,27 @@ final class HttpSearchIndexLoader implements SearchIndexLoader {
}
final parsed = json.decode(response.body);
return (hash: parsed['hash'] as String, blocks: parsed['blocks'] as int);
return SearchDatabaseInfo(
hash: parsed['hash'] as String,
pages: parsed['blocks'] as int,
);
}
@override
Future<(bool, Uint8List)> fetchPage(
SearchDatabaseInfo info,
int pageNo,
) async {
final startOffset = pageNo * SearchIndexLoader.pageSize;
final endOffset = (pageNo + 1) * SearchIndexLoader.pageSize - 1;
Future<FetchedPages> fetchPage(PageFetchQuery query) async {
final startOffset = query.startPage * SearchIndexLoader.pageSize;
final endOffset = query.endPage * SearchIndexLoader.pageSize - 1;
final response = await _client.get(
metaUri.resolve('./${info.hash}.db'),
metaUri.resolve('./${query.info.hash}.db'),
headers: {'Range': 'bytes=$startOffset-$endOffset'},
);
return switch (response.statusCode) {
200 => (false, response.bodyBytes),
206 => (true, response.bodyBytes),
200 => FetchedPages(startPage: 0, pages: response.bodyBytes),
206 => FetchedPages(
startPage: query.startPage,
pages: response.bodyBytes,
),
final status => throw ClientException('Unexpected result code $status'),
};
}
+6 -1
View File
@@ -5,10 +5,15 @@ import 'package:jaspr_riverpod/jaspr_riverpod.dart';
import 'package:sqlite3/wasm.dart';
import 'database.dart';
import 'loader.dart';
import 'web_cache_loader.dart';
final searchDatabase = FutureProvider((ref) async {
final sqlite = await WasmSqlite3.loadFromUrl(Uri.parse('/sqlite3.wasm'));
return await SearchDatabase.open(sqlite, Uri.parse('/search.db.json'));
return await SearchDatabase.open(
sqlite,
CachedIndexLoader(SearchIndexLoader.http(Uri.parse('/search.db.json'))),
);
});
final class SearchTermNotifier extends Notifier<String> {
+32 -15
View File
@@ -1,7 +1,7 @@
import 'dart:js_interop';
import 'dart:typed_data';
import 'package:universal_web/web.dart' as web;
import 'package:web/web.dart' as web;
import 'loader.dart';
@@ -33,27 +33,44 @@ final class CachedIndexLoader implements SearchIndexLoader {
}
@override
Future<(bool, Uint8List)> fetchPage(
SearchDatabaseInfo info,
int pageNo,
) async {
JSString? cacheKey;
Future<FetchedPages> fetchPage(PageFetchQuery query) async {
final cache = _cache;
if (cache == null) {
return await _fallback.fetchPage(query);
}
if (_cache case final cache?) {
cacheKey = '/${info.hash}/$pageNo'.toJS;
var fromCache = Uint8List(query.length);
var hasMissingPages = false;
for (var page = query.startPage; page < query.endPage; page++) {
final cacheKey = '/${query.info.hash}/$page'.toJS;
final cached = await cache.match(cacheKey).toDart;
if (cached case final response?) {
final bytes = await response.bytes().toDart;
return (true, bytes.toDart);
final startOffset =
(page - query.startPage) * SearchIndexLoader.pageSize;
fromCache.setRange(
startOffset,
startOffset + SearchIndexLoader.pageSize,
bytes.toDart,
);
} else {
hasMissingPages = true;
break;
}
}
final source = await _fallback.fetchPage(info, pageNo);
if (_cache case final cache?) {
if (source.$1) {
cache.put(cacheKey!, web.Response(source.$2.toJS));
}
if (!hasMissingPages) {
return FetchedPages(startPage: query.startPage, pages: fromCache);
}
final source = await _fallback.fetchPage(query);
final endPage = source.endPage;
for (var page = source.startPage; page < endPage; page++) {
final cacheKey = '/${query.info.hash}/$page'.toJS;
cache.put(cacheKey, web.Response(source.viewPage(page).toJS));
}
return source;
}
@@ -80,5 +97,5 @@ final class CachedIndexLoader implements SearchIndexLoader {
}
}
@JS('cache')
@JS('caches')
external web.CacheStorage? get _cacheStorage;
+2 -1
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:drift_website/src/search/database.dart';
import 'package:drift_website/src/search/loader.dart';
import 'package:sqlite3/sqlite3.dart';
/// Testing the `SearchDatabase` by running a search. To test this,
@@ -11,7 +12,7 @@ import 'package:sqlite3/sqlite3.dart';
void main(List<String> args) async {
final db = await SearchDatabase.open(
sqlite3,
Uri.parse('http://localhost:8080/search.db.json'),
SearchIndexLoader.http(Uri.parse('http://localhost:8080/search.db.json')),
);
final term = args.join(' ');