Skip to content

Commit cac7ef6

Browse files
Parallelize app batch workflows
- Add a bounded worker pool that drains operations before cleanup - Run bulk lifecycle actions with configurable concurrency - Reserve game and RCON ports during concurrent launches - Coalesce shared watcher startup - Delete independent instance trees asynchronously without following links - Parallelize independent build refreshes - Sync separate upstream repositories concurrently - Prepare addon downloads concurrently before atomic installation - Overlap remote polling while stopping queued requests on auth or rate limits - Run Drive inventory and status checks concurrently - Hash independent transfer files concurrently - Cover concurrency limits, ordering, cleanup, and port safety with tests - Document parallel workflows and the bulk concurrency flag
1 parent 33d896d commit cac7ef6

19 files changed

Lines changed: 2235 additions & 645 deletions

‎MultiplexorApp/lib/cli/command_help.dart‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ const List<CommandHelpGroup> commandHelpGroups = <CommandHelpGroup>[
6868
]),
6969
CommandHelpGroup('instance', <String>[
7070
'list',
71-
'bulk <start|stop|restart|delete> <name>... [--confirm <token>]',
71+
'bulk <start|stop|restart|delete> <name>... [--concurrency <1-8>] [--confirm <token>]',
7272
'current',
7373
'create <name> [--isolated]',
7474
'clone <source> <target>',

‎MultiplexorApp/lib/services/addons/addon_installer.dart‎

Lines changed: 91 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'dart:io';
44
import 'package:crypto/crypto.dart';
55
import 'package:path/path.dart' as p;
66

7+
import '../../utils/async_work_pool.dart';
78
import 'addon_catalog.dart';
89
import 'addon_resolver.dart';
910

@@ -186,68 +187,103 @@ final class AddonInstaller {
186187
stage = Directory(
187188
instancePath,
188189
).createTempSync('.multiplexor-addons-stage-');
189-
final Map<String, InstalledAddon> next = <String, InstalledAddon>{};
190-
final Map<String, File> downloads = <String, File>{};
191-
for (final String id in selected) {
190+
final List<String> refreshIds = selected
191+
.where(refresh.contains)
192+
.toList(growable: false);
193+
for (final String id in refreshIds) {
192194
final AddonDefinition definition = catalog.entries[id]!;
193-
final InstalledAddon? old = previous.entries[id];
194-
if (!refresh.contains(id) &&
195-
old != null &&
196-
old.json['minecraft'] == minecraft &&
197-
old.json['serverType'] == serverType &&
198-
File(p.join(instancePath, old.file)).existsSync()) {
199-
next[id] = old;
200-
continue;
201-
}
202195
report(
203196
'[INFO] Resolving ${definition.name} for $serverType Minecraft $minecraft',
204197
);
205-
final ResolvedAddon resolved = await resolver.resolve(
206-
definition,
207-
serverType,
208-
minecraft,
209-
);
210-
for (final String project in resolved.requiredProjects) {
211-
final bool included = next.values.any(
212-
(InstalledAddon dependency) =>
213-
dependency.json['projectId'] == project,
198+
}
199+
final List<ResolvedAddon> resolutions =
200+
await boundedMap<String, ResolvedAddon>(
201+
refreshIds,
202+
(String id) =>
203+
resolver.resolve(catalog.entries[id]!, serverType, minecraft),
214204
);
215-
if (!included) {
205+
final Map<String, ResolvedAddon> resolvedById = <String, ResolvedAddon>{
206+
for (int index = 0; index < refreshIds.length; index++)
207+
refreshIds[index]: resolutions[index],
208+
};
209+
210+
// Resolve independently, then validate in dependency order before any
211+
// downloads. Completion order cannot change which dependencies qualify.
212+
final Map<String, Set<String?>> projectVersions =
213+
<String, Set<String?>>{};
214+
for (final String id in selected) {
215+
final ResolvedAddon? resolved = resolvedById[id];
216+
for (final String project
217+
in resolved?.requiredProjects ?? const <String>[]) {
218+
final Set<String?>? versions = projectVersions[project];
219+
final String name = catalog.entries[id]!.name;
220+
if (versions == null) {
216221
throw StateError(
217-
'${definition.name} requires Modrinth project $project. Add it to the catalog and dependencies.',
222+
'$name requires Modrinth project $project. Add it to the catalog and dependencies.',
218223
);
219224
}
220-
final String? requiredVersion = resolved.requiredVersions[project];
221-
if (requiredVersion != null &&
222-
!next.values.any(
223-
(InstalledAddon dependency) =>
224-
dependency.json['projectId'] == project &&
225-
dependency.json['versionId'] == requiredVersion,
226-
)) {
225+
final String? requiredVersion = resolved!.requiredVersions[project];
226+
if (requiredVersion != null && !versions.contains(requiredVersion)) {
227227
throw StateError(
228-
'${definition.name} requires Modrinth project $project version $requiredVersion. Set that dependency source\'s versionId to $requiredVersion.',
228+
'$name requires Modrinth project $project version $requiredVersion. Set that dependency source\'s versionId to $requiredVersion.',
229229
);
230230
}
231231
}
232-
final File download = File(p.join(stage.path, '$id.jar'));
233-
await resolver.download(resolved, download);
234-
next[id] = InstalledAddon(<String, Object?>{
235-
'id': id,
236-
'file': definition.file,
237-
'sha256': _hash(download),
238-
'version': resolved.version,
239-
'source': resolved.location,
240-
if (resolved.projectId != null) 'projectId': resolved.projectId,
241-
if (resolved.versionId != null) 'versionId': resolved.versionId,
242-
'minecraft': minecraft,
243-
'serverType': serverType,
244-
'filePrefixes': definition.filePrefixes,
245-
});
246-
downloads[id] = download;
247-
report('[INFO] Ready: ${definition.name} ${resolved.version}');
232+
final String? projectId = resolved == null
233+
? previous.entries[id]!.json['projectId'] as String?
234+
: resolved.projectId;
235+
final String? versionId = resolved == null
236+
? previous.entries[id]!.json['versionId'] as String?
237+
: resolved.versionId;
238+
if (projectId != null) {
239+
projectVersions
240+
.putIfAbsent(projectId, () => <String?>{})
241+
.add(versionId);
242+
}
248243
}
249-
await beforeCommit();
244+
250245
final Directory transaction = stage;
246+
final List<_PreparedAddon> prepared =
247+
await boundedMap<String, _PreparedAddon>(refreshIds, (
248+
String id,
249+
) async {
250+
final AddonDefinition definition = catalog.entries[id]!;
251+
final ResolvedAddon resolved = resolvedById[id]!;
252+
final File download = File(p.join(transaction.path, '$id.jar'));
253+
await resolver.download(resolved, download);
254+
final InstalledAddon entry = InstalledAddon(<String, Object?>{
255+
'id': id,
256+
'file': definition.file,
257+
'sha256': (await sha256.bind(download.openRead()).first)
258+
.toString(),
259+
'version': resolved.version,
260+
'source': resolved.location,
261+
if (resolved.projectId != null) 'projectId': resolved.projectId,
262+
if (resolved.versionId != null) 'versionId': resolved.versionId,
263+
'minecraft': minecraft,
264+
'serverType': serverType,
265+
'filePrefixes': definition.filePrefixes,
266+
});
267+
return _PreparedAddon(entry, download);
268+
});
269+
final Map<String, InstalledAddon> preparedEntries =
270+
<String, InstalledAddon>{
271+
for (final _PreparedAddon item in prepared)
272+
item.entry.id: item.entry,
273+
};
274+
final Map<String, InstalledAddon> next = <String, InstalledAddon>{
275+
for (final String id in selected)
276+
id: preparedEntries[id] ?? previous.entries[id]!,
277+
};
278+
final Map<String, File> downloads = <String, File>{
279+
for (final _PreparedAddon item in prepared) item.entry.id: item.file,
280+
};
281+
for (final _PreparedAddon item in prepared) {
282+
report(
283+
'[INFO] Ready: ${catalog.entries[item.entry.id]!.name} ${item.entry.json['version']}',
284+
);
285+
}
286+
await beforeCommit();
251287
commit(() {
252288
_regularDirectory(instancePath);
253289
_checkExisting(previous);
@@ -383,6 +419,13 @@ final class AddonInstaller {
383419
}
384420
}
385421

422+
final class _PreparedAddon {
423+
const _PreparedAddon(this.entry, this.file);
424+
425+
final InstalledAddon entry;
426+
final File file;
427+
}
428+
386429
String _hash(File file) => sha256.convert(file.readAsBytesSync()).toString();
387430

388431
void _regularDirectory(String path) {

‎MultiplexorApp/lib/services/addons/addon_resolver.dart‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ final class AddonResolver {
253253
await sink.close();
254254
}
255255
} else {
256-
File(resolved.location).copySync(target.path);
256+
await File(resolved.location).copy(target.path);
257257
}
258258
final RandomAccessFile input = target.openSync();
259259
final List<int> magic;

0 commit comments

Comments
 (0)