mirror of
https://github.com/immich-app/drift.git
synced 2026-09-30 13:22:57 +08:00
Search index: Fix caching pages
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(' ');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user