Skip to content

Commit a6b467f

Browse files
authored
Search index isolate without text indexing for predicate-only queries. (#8823)
1 parent 974228d commit a6b467f

File tree

8 files changed

+203
-43
lines changed

8 files changed

+203
-43
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ AppEngine version, listed here to ease deployment and troubleshooting.
55
* Bump runtimeVersion to `2025.06.20`.
66
* Upgraded stable Flutter analysis SDK to `3.32.4`.
77
* Note: started to export `/api/packages/<package>/[likes|options|publisher|score]` endpoints.
8+
* Note: search instance uses separate isolate for predicate-only queries.
89

910
## `20250619t085100-all`
1011
* Bump runtimeVersion to `2025.06.03`.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:math';
6+
7+
import 'package:_pub_shared/search/search_form.dart';
8+
import 'package:collection/collection.dart';
9+
import 'package:pub_dev/search/search_service.dart';
10+
import 'package:pub_dev/service/entrypoint/_isolate.dart';
11+
import 'package:pub_dev/service/entrypoint/search_index.dart';
12+
13+
// NOTE: please add more queries to this list, especially if there is a performance bottleneck.
14+
final queries = [
15+
'sdk:dart',
16+
'sdk:flutter platform:android',
17+
'is:flutter-favorite',
18+
'chart',
19+
'json',
20+
'camera',
21+
'android camera',
22+
'sql database',
23+
];
24+
25+
Future<void> main(List<String> args) async {
26+
print('Loading...');
27+
final primaryRunner = await startSearchIsolate(snapshot: args.first);
28+
final reducedRunner = await startSearchIsolate(
29+
snapshot: args.first,
30+
removeTextContent: true,
31+
);
32+
print('Loaded.');
33+
34+
for (var i = 0; i < 5; i++) {
35+
await _benchmark(primaryRunner, primaryRunner);
36+
await _benchmark(primaryRunner, reducedRunner);
37+
print('--');
38+
}
39+
40+
await primaryRunner.close();
41+
await reducedRunner.close();
42+
}
43+
44+
Future<void> _benchmark(IsolateRunner primary, IsolateRunner reduced) async {
45+
final index = IsolateSearchIndex(primary, reduced);
46+
final durations = <String, List<int>>{};
47+
for (var i = 0; i < 100; i++) {
48+
final random = Random(i);
49+
final items = queries
50+
.map((q) => ServiceSearchQuery.parse(
51+
query: q,
52+
tagsPredicate: TagsPredicate.regularSearch(),
53+
))
54+
.toList();
55+
items.shuffle(random);
56+
await Future.wait(items.map((q) async {
57+
final sw = Stopwatch()..start();
58+
await index.search(q);
59+
final d = sw.elapsed.inMicroseconds;
60+
durations.putIfAbsent('all', () => []).add(d);
61+
final key = q.parsedQuery.hasFreeText ? 'primary' : 'reduced';
62+
durations.putIfAbsent(key, () => []).add(d);
63+
}));
64+
}
65+
for (final e in durations.entries) {
66+
e.value.sort();
67+
print('${e.key.padLeft(10)}: '
68+
'${e.value.average.round().toString().padLeft(10)} avg '
69+
'${e.value[e.value.length * 90 ~/ 100].toString().padLeft(10)} p90');
70+
}
71+
}

app/lib/search/search_service.dart

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,29 @@ class PackageDocument {
137137
Map<String, dynamic> toJson() => _$PackageDocumentToJson(this);
138138

139139
late final packageNameLowerCased = package.toLowerCase();
140+
141+
/// Removes the text-heavy content from the current object,
142+
/// making it lightweight for no-text indexing.
143+
PackageDocument removeTextContent() => PackageDocument(
144+
package: package,
145+
version: version,
146+
created: created,
147+
updated: updated,
148+
sourceUpdated: sourceUpdated,
149+
timestamp: timestamp,
150+
apiDocPages: null,
151+
dependencies: dependencies,
152+
description: null,
153+
downloadCount: downloadCount,
154+
downloadScore: downloadScore,
155+
grantedPoints: grantedPoints,
156+
likeCount: likeCount,
157+
likeScore: likeScore,
158+
maxPoints: maxPoints,
159+
readme: null,
160+
tags: tags,
161+
trendScore: trendScore,
162+
);
140163
}
141164

142165
/// A reference to an API doc page
@@ -299,9 +322,6 @@ class ServiceSearchQuery {
299322
!_isFlutterFavorite &&
300323
(textMatchExtent ?? TextMatchExtent.api).shouldMatchApi();
301324

302-
bool get considerHighlightedHit => _hasOnlyFreeText && _hasNoOwnershipScope;
303-
bool get includeHighlightedHit => considerHighlightedHit && offset == 0;
304-
305325
/// Returns the validity status of the query.
306326
QueryValidity evaluateValidity() {
307327
// Block search on unreasonably long search queries (when the free-form

app/lib/search/updater.dart

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,19 @@ IndexUpdater get indexUpdater => ss.lookup(#_indexUpdater) as IndexUpdater;
3030

3131
/// Loads a local search snapshot file and builds an in-memory package index from it.
3232
Future<InMemoryPackageIndex> loadInMemoryPackageIndexFromFile(
33-
String path) async {
33+
String path, {
34+
bool removeTextContent = false,
35+
}) async {
3436
final file = File(path);
3537
final content =
3638
json.decode(utf8.decode(gzip.decode(await file.readAsBytes())))
3739
as Map<String, Object?>;
3840
final snapshot = SearchSnapshot.fromJson(content);
3941
return InMemoryPackageIndex(
40-
documents:
41-
snapshot.documents!.values.where((d) => !isSdkPackage(d.package)));
42+
documents: snapshot.documents!.values
43+
.where((d) => !isSdkPackage(d.package))
44+
.map((d) => removeTextContent ? d.removeTextContent() : d),
45+
);
4246
}
4347

4448
/// Saves the provided [documents] into a local search snapshot file.
@@ -60,8 +64,8 @@ class IndexUpdater {
6064

6165
/// Loads the package index snapshot, or if it fails, creates a minimal
6266
/// package index with only package names and minimal information.
63-
Future<void> init() async {
64-
final isReady = await _initSnapshot();
67+
Future<void> init({bool removeTextContent = false}) async {
68+
final isReady = await _initSnapshot(removeTextContent);
6569
if (!isReady) {
6670
_logger.info('Loading minimum package index...');
6771
final documents = await searchBackend.loadMinimumPackageIndex().toList();
@@ -85,14 +89,17 @@ class IndexUpdater {
8589
}
8690

8791
/// Returns whether the snapshot was initialized and loaded properly.
88-
Future<bool> _initSnapshot() async {
92+
Future<bool> _initSnapshot(bool removeTextContent) async {
8993
try {
9094
_logger.info('Loading snapshot...');
9195
final documents = await searchBackend.fetchSnapshotDocuments();
9296
if (documents == null) {
9397
return false;
9498
}
95-
updatePackageIndex(InMemoryPackageIndex(documents: documents));
99+
updatePackageIndex(InMemoryPackageIndex(
100+
documents:
101+
documents.map((d) => removeTextContent ? d.removeTextContent() : d),
102+
));
96103
// Arbitrary sanity check that the snapshot is not entirely bogus.
97104
// Index merge will enable search.
98105
if (documents.length > 10) {

app/lib/service/entrypoint/search.dart

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,14 @@ class SearchCommand extends Command {
3838

3939
envConfig.checkServiceEnvironment(name);
4040
await withServices(() async {
41-
final packageIsolate = await startQueryIsolate(
41+
final primaryIsolate = await startSearchIsolate(logger: _logger);
42+
registerScopeExitCallback(primaryIsolate.close);
43+
44+
final reducedIsolate = await startSearchIsolate(
4245
logger: _logger,
43-
kind: 'package',
44-
spawnUri:
45-
Uri.parse('package:pub_dev/service/entrypoint/search_index.dart'),
46+
removeTextContent: true,
4647
);
47-
registerScopeExitCallback(packageIsolate.close);
48+
registerScopeExitCallback(reducedIsolate.close);
4849

4950
final sdkIsolate = await startQueryIsolate(
5051
logger: _logger,
@@ -56,8 +57,9 @@ class SearchCommand extends Command {
5657

5758
registerSearchIndex(
5859
SearchResultCombiner(
59-
primaryIndex:
60-
LatencyAwareSearchIndex(IsolateSearchIndex(packageIsolate)),
60+
primaryIndex: LatencyAwareSearchIndex(
61+
IsolateSearchIndex(primaryIsolate, reducedIsolate),
62+
),
6163
sdkIndex: SdkIsolateIndex(sdkIsolate),
6264
),
6365
);
@@ -70,7 +72,10 @@ class SearchCommand extends Command {
7072
await Future.delayed(delay);
7173

7274
// create a new index and handover with a 2-minute maximum wait
73-
await packageIsolate.renew(count: 1, wait: Duration(minutes: 2));
75+
await Future.wait([
76+
primaryIsolate.renew(count: 1, wait: Duration(minutes: 2)),
77+
reducedIsolate.renew(count: 1, wait: Duration(minutes: 2)),
78+
]);
7479

7580
// schedule the renewal again
7681
scheduleRenew();

app/lib/service/entrypoint/search_index.dart

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ import 'package:pub_dev/shared/utils.dart';
2222
final _logger = Logger('search_index');
2323

2424
final _argParser = ArgParser()
25+
..addFlag(
26+
'remove-text-content',
27+
defaultsTo: false,
28+
help: 'When set, the text content of the index will be removed.',
29+
)
2530
..addOption(
2631
'snapshot',
2732
help:
@@ -34,6 +39,7 @@ Future<void> main(List<String> args, var message) async {
3439

3540
final argv = _argParser.parse(args);
3641
final snapshot = argv['snapshot'] as String?;
42+
final removeTextContent = (argv['remove-text-content'] as bool?) ?? false;
3743

3844
final ServicesWrapperFn servicesWrapperFn;
3945
if (envConfig.isRunningInAppengine) {
@@ -46,9 +52,10 @@ Future<void> main(List<String> args, var message) async {
4652
await fork(() async {
4753
await servicesWrapperFn(() async {
4854
if (snapshot == null) {
49-
await indexUpdater.init();
55+
await indexUpdater.init(removeTextContent: removeTextContent);
5056
} else {
51-
updatePackageIndex(await loadInMemoryPackageIndexFromFile(snapshot));
57+
updatePackageIndex(await loadInMemoryPackageIndexFromFile(snapshot,
58+
removeTextContent: removeTextContent));
5259
}
5360

5461
await runIsolateFunctions(
@@ -75,12 +82,30 @@ Future<void> main(List<String> args, var message) async {
7582
timer.cancel();
7683
}
7784

85+
/// Starts a new search isolate with optional overrides.
86+
Future<IsolateRunner> startSearchIsolate({
87+
Logger? logger,
88+
bool removeTextContent = false,
89+
String? snapshot,
90+
}) async {
91+
return await startQueryIsolate(
92+
logger: logger ?? _logger,
93+
kind: removeTextContent ? 'reduced' : 'primary',
94+
spawnUri: Uri.parse('package:pub_dev/service/entrypoint/search_index.dart'),
95+
spawnArgs: [
96+
if (snapshot != null) ...['--snapshot', snapshot],
97+
if (removeTextContent) '--remove-text-content',
98+
],
99+
);
100+
}
101+
78102
/// Implementation of [SearchIndex] that uses [RequestMessage]s to send requests
79103
/// across isolate boundaries. The instance should be registered inside the
80104
/// `frontend` isolate, and it calls the `index` isolate as a delegate.
81105
class IsolateSearchIndex implements SearchIndex {
82-
final IsolateRunner _runner;
83-
IsolateSearchIndex(this._runner);
106+
final IsolateRunner _primary;
107+
final IsolateRunner _reduced;
108+
IsolateSearchIndex(this._primary, this._reduced);
84109
var _isReady = false;
85110

86111
@override
@@ -96,7 +121,7 @@ class IsolateSearchIndex implements SearchIndex {
96121
@override
97122
FutureOr<IndexInfo> indexInfo() async {
98123
try {
99-
final info = await _runner.sendRequest(
124+
final info = await _primary.sendRequest(
100125
'info',
101126
timeout: Duration(seconds: 5),
102127
);
@@ -114,7 +139,8 @@ class IsolateSearchIndex implements SearchIndex {
114139
@override
115140
FutureOr<PackageSearchResult> search(ServiceSearchQuery query) async {
116141
try {
117-
final rs = await _runner.sendRequest(
142+
final runner = query.parsedQuery.hasFreeText ? _primary : _reduced;
143+
final rs = await runner.sendRequest(
118144
Uri(queryParameters: query.toUriQueryParameters()).toString(),
119145
timeout: Duration(minutes: 1),
120146
);

0 commit comments

Comments
 (0)