Skip to content

Commit 3a3c42c

Browse files
perf: Parallelize bundled app install at startup (#24109)
* Parallelize file upload for bundled apps * install largest apps first * Add number to each new thread for clarity
1 parent 16cbb39 commit 3a3c42c

3 files changed

Lines changed: 212 additions & 35 deletions

File tree

dhis-2/dhis-api/src/main/java/org/hisp/dhis/appmanager/AppStorageService.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,19 @@ public interface AppStorageService {
6767
* @param file the zip file containing the app
6868
* @param appCache The app cache
6969
* @param bundledAppInfo bundled app info, can be null
70+
* @param removeExisting whether to scan storage for and remove other already-installed apps that
71+
* share the new app's key (i.e. older versions in a different folder). Pass {@code false} to
72+
* skip this scan when it is known there can be no duplicates — e.g. installing bundled apps
73+
* into an empty store on first startup — since the scan is an expensive per-app rescan of all
74+
* installed apps.
7075
* @return The status of the installation
7176
*/
7277
@Nonnull
7378
App installApp(
7479
@Nonnull File file,
7580
@Nonnull Cache<App> appCache,
76-
@CheckForNull BundledAppInfo bundledAppInfo);
81+
@CheckForNull BundledAppInfo bundledAppInfo,
82+
boolean removeExisting);
7783

7884
/**
7985
* Deletes the app from storage.

dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/appmanager/BlobStoreAppStorageService.java

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,15 @@
3232
import static org.hisp.dhis.util.ZipFileUtils.getFilePath;
3333

3434
import com.fasterxml.jackson.databind.ObjectMapper;
35+
import com.google.common.util.concurrent.ThreadFactoryBuilder;
3536
import java.io.ByteArrayInputStream;
3637
import java.io.ByteArrayOutputStream;
3738
import java.io.File;
3839
import java.io.IOException;
3940
import java.io.InputStream;
41+
import java.io.UncheckedIOException;
4042
import java.net.URI;
43+
import java.util.ArrayList;
4144
import java.util.Collections;
4245
import java.util.Enumeration;
4346
import java.util.HashMap;
@@ -46,6 +49,12 @@
4649
import java.util.Map;
4750
import java.util.Optional;
4851
import java.util.Set;
52+
import java.util.concurrent.CompletableFuture;
53+
import java.util.concurrent.CompletionException;
54+
import java.util.concurrent.ExecutorService;
55+
import java.util.concurrent.LinkedBlockingQueue;
56+
import java.util.concurrent.ThreadPoolExecutor;
57+
import java.util.concurrent.TimeUnit;
4958
import java.util.function.BiConsumer;
5059
import java.util.zip.ZipEntry;
5160
import java.util.zip.ZipFile;
@@ -85,11 +94,43 @@ public class BlobStoreAppStorageService implements AppStorageService {
8594
private static final String BUNDLED_APP_INFO_FILENAME = "bundled-app-info.json";
8695
public static final String MANIFEST_WEBAPP_FILENAME = "manifest.webapp";
8796

97+
/**
98+
* Max concurrent file uploads to blob storage across <em>all</em> apps installing at once. Apps
99+
* may install in parallel (see {@code DefaultAppManager}); this is a single shared pool so the
100+
* total number of in-flight uploads (and buffered file payloads) stays bounded regardless of how
101+
* many apps install concurrently.
102+
*
103+
* <p>Note this value also bounds transient memory: the S3 backend buffers each non-resetable zip
104+
* entry stream fully in memory, so worst case is this many file payloads on the heap at once.
105+
* Benchmarks against AWS S3 showed no install-time gain from 64 over 32, so 32 is used to halve
106+
* the worst-case memory footprint.
107+
*/
108+
private static final int APP_UPLOAD_PARALLELISM = 32;
109+
88110
private final BlobStoreService blobStore;
89111
private final LocationManager locationManager;
90112
private final ObjectMapper jsonMapper;
91113
private final FileResourceContentStore fileResourceContentStore;
92114

115+
/**
116+
* Shared, lazily-populated pool for concurrent file uploads. Core threads time out when idle so
117+
* the pool holds no threads outside of (re)install activity, e.g. after startup.
118+
*/
119+
private final ExecutorService uploadExecutor = newUploadExecutor();
120+
121+
private static ExecutorService newUploadExecutor() {
122+
ThreadPoolExecutor executor =
123+
new ThreadPoolExecutor(
124+
APP_UPLOAD_PARALLELISM,
125+
APP_UPLOAD_PARALLELISM,
126+
60L,
127+
TimeUnit.SECONDS,
128+
new LinkedBlockingQueue<>(),
129+
new ThreadFactoryBuilder().setNameFormat("app-upload-%d").setDaemon(true).build());
130+
executor.allowCoreThreadTimeOut(true);
131+
return executor;
132+
}
133+
93134
@Override
94135
@Nonnull
95136
public Map<String, Pair<App, BundledAppInfo>> discoverInstalledApps() {
@@ -224,7 +265,8 @@ private void validateAppAdditionalNamespacesAreWellDefined(App app) {
224265
public App installApp(
225266
@Nonnull File file,
226267
@Nonnull Cache<App> appCache,
227-
@CheckForNull BundledAppInfo bundledAppInfo) {
268+
@CheckForNull BundledAppInfo bundledAppInfo,
269+
boolean removeExisting) {
228270
App app;
229271
AppFolderName folder;
230272
String topLevelFolder;
@@ -259,7 +301,9 @@ public App installApp(
259301
ZipFileUtils.validateZip(file, folder.path(), topLevelFolder);
260302
unzipFile(file, folder, topLevelFolder);
261303

262-
removeOtherAppsWithSameKey(app);
304+
if (removeExisting) {
305+
removeOtherAppsWithSameKey(app);
306+
}
263307

264308
app.setAppState(AppStatus.OK);
265309
logInstallSuccess(app, folder.path());
@@ -286,6 +330,8 @@ public App installApp(
286330
private void unzipFile(File file, AppFolderName folder, String topLevelFolder)
287331
throws IOException, ZipSlipException {
288332
try (ZipFile zipFile = new ZipFile(file)) {
333+
List<ZipEntry> fileEntries = new ArrayList<>();
334+
List<String> filePaths = new ArrayList<>();
289335
Enumeration<? extends ZipEntry> entries = zipFile.entries();
290336
while (entries.hasMoreElements()) {
291337
ZipEntry zipEntry = entries.nextElement();
@@ -298,11 +344,48 @@ private void unzipFile(File file, AppFolderName folder, String topLevelFolder)
298344
blobStore.createDirectory(BlobKeyPrefix.of(filePath));
299345
continue;
300346
}
301-
try (InputStream zipInputStream = zipFile.getInputStream(zipEntry)) {
302-
blobStore.putBlob(
303-
BlobKey.of(filePath), zipInputStream, zipEntry.getSize(), null, null, null);
304-
}
347+
fileEntries.add(zipEntry);
348+
filePaths.add(filePath);
349+
}
350+
uploadEntriesInParallel(zipFile, fileEntries, filePaths);
351+
}
352+
}
353+
354+
/**
355+
* Uploads the given zip file entries to blob storage concurrently via the shared {@link
356+
* #uploadExecutor}. App zips contain many small files and each upload is dominated by per-request
357+
* latency (network round-trip on object stores), so uploading them in parallel rather than
358+
* one-at-a-time substantially reduces install time. The pool is shared across concurrently
359+
* installing apps so total in-flight uploads stay bounded; this call only waits on its own files.
360+
*/
361+
private void uploadEntriesInParallel(
362+
ZipFile zipFile, List<ZipEntry> fileEntries, List<String> filePaths) throws IOException {
363+
if (fileEntries.isEmpty()) return;
364+
365+
try {
366+
CompletableFuture<?>[] futures = new CompletableFuture<?>[fileEntries.size()];
367+
for (int i = 0; i < fileEntries.size(); i++) {
368+
ZipEntry zipEntry = fileEntries.get(i);
369+
String filePath = filePaths.get(i);
370+
futures[i] =
371+
CompletableFuture.runAsync(
372+
() -> uploadZipEntry(zipFile, zipEntry, filePath), uploadExecutor);
305373
}
374+
CompletableFuture.allOf(futures).join();
375+
} catch (CompletionException e) {
376+
Throwable cause = e.getCause() != null ? e.getCause() : e;
377+
if (cause instanceof UncheckedIOException uioe) throw uioe.getCause();
378+
if (cause instanceof IOException ioe) throw ioe;
379+
if (cause instanceof RuntimeException re) throw re;
380+
throw new IOException("Failed to upload app files", cause);
381+
}
382+
}
383+
384+
private void uploadZipEntry(ZipFile zipFile, ZipEntry zipEntry, String filePath) {
385+
try (InputStream zipInputStream = zipFile.getInputStream(zipEntry)) {
386+
blobStore.putBlob(BlobKey.of(filePath), zipInputStream, zipEntry.getSize(), null, null, null);
387+
} catch (IOException e) {
388+
throw new UncheckedIOException(e);
306389
}
307390
}
308391

dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/appmanager/DefaultAppManager.java

Lines changed: 116 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import static org.hisp.dhis.datastore.DatastoreNamespaceProtection.ProtectionType.RESTRICTED;
3737

3838
import com.fasterxml.jackson.databind.ObjectMapper;
39+
import com.google.common.util.concurrent.ThreadFactoryBuilder;
3940
import java.io.*;
4041
import java.io.InputStream;
4142
import java.net.URISyntaxException;
@@ -47,12 +48,16 @@
4748
import java.nio.file.StandardCopyOption;
4849
import java.util.ArrayList;
4950
import java.util.Collection;
51+
import java.util.Comparator;
5052
import java.util.HashSet;
5153
import java.util.List;
5254
import java.util.Map;
5355
import java.util.Optional;
5456
import java.util.Set;
5557
import java.util.UUID;
58+
import java.util.concurrent.CompletableFuture;
59+
import java.util.concurrent.ExecutorService;
60+
import java.util.concurrent.Executors;
5661
import java.util.function.Predicate;
5762
import java.util.stream.Stream;
5863
import javax.annotation.CheckForNull;
@@ -99,6 +104,13 @@ public class DefaultAppManager implements AppManager {
99104
public static final String INVALID_FILTER_MSG = "Invalid filter: ";
100105
private static final Set<String> EXCLUSION_APPS = Set.of("Line Listing");
101106

107+
/**
108+
* Max number of bundled apps installed concurrently on startup. File uploads across all of these
109+
* share a single bounded pool in {@link BlobStoreAppStorageService}, so this only governs how
110+
* many app zips are read/decompressed at once, not total upload concurrency.
111+
*/
112+
private static final int APP_INSTALL_PARALLELISM = 16;
113+
102114
@Autowired private UserService userService;
103115
@Autowired private ObjectMapper jsonMapper;
104116
@Autowired private LocaleManager localeManager;
@@ -154,49 +166,123 @@ public void reloadApps() {
154166
Map<String, Pair<App, BundledAppInfo>> installedApps =
155167
blobStoreAppStorageService.discoverInstalledApps();
156168

157-
installBundledApps(installedApps);
169+
// When the store starts empty (typical first startup) there can be no pre-existing apps to
170+
// replace, so the per-app duplicate-removal scan is skipped to avoid an expensive O(n^2)
171+
// rescan of storage during bundled app installation.
172+
boolean storeStartedEmpty = installedApps.isEmpty();
173+
installBundledApps(installedApps, storeStartedEmpty);
158174
// Invalidate the previous app cache
159175
appCache.invalidateAll();
160176
// Cache all discovered apps
161177
installedApps.values().forEach(app -> cacheApp(app.getLeft()));
162178
log.info("Loaded {} apps.", installedApps.size());
163179
}
164180

181+
/**
182+
* A bundled app that needs to be (re)installed, together with its zip resource and the zip's
183+
* compressed size in bytes (computed once; used to order installs largest-first).
184+
*/
185+
private record BundledAppInstall(
186+
String appKey, BundledAppInfo bundledAppInfo, Resource zipFileResource, long zipSize) {}
187+
165188
/**
166189
* Installs bundled apps, by looking in the classpath for app .zip files. If the bundled app is
167190
* already installed, it can be overwritten with a newer one if the Etag is different.
168191
*
192+
* <p>Apps are installed concurrently: each app is large enough that its files already upload in
193+
* parallel, but the apps themselves used to install one-after-another, so the many small apps
194+
* waited behind the few large ones. Installing apps in parallel lets the small apps upload in the
195+
* shadow of the large ones (each app reads its own zip, so decompression also parallelizes). The
196+
* shared upload pool in {@link BlobStoreAppStorageService} keeps total in-flight uploads bounded.
197+
*
169198
* @param installedApps the Map with all existing apps, we overwrite existing apps in this Map
170199
* with new ones.
200+
* @param storeStartedEmpty whether the app store was empty before this install run; when {@code
201+
* true} the per-app duplicate-removal scan is skipped as there can be nothing to replace.
171202
*/
172-
private void installBundledApps(@Nonnull Map<String, Pair<App, BundledAppInfo>> installedApps) {
203+
private void installBundledApps(
204+
@Nonnull Map<String, Pair<App, BundledAppInfo>> installedApps, boolean storeStartedEmpty) {
205+
boolean removeExisting = !storeStartedEmpty;
206+
207+
// First collect which bundled apps actually need (re)installing, without touching storage. An
208+
// app is (re)installed if it isn't installed yet, or if its installed Etag differs from the
209+
// bundled one. Apps that are already up to date keep their existing map entry untouched.
210+
List<BundledAppInstall> appsToInstall = new ArrayList<>();
173211
bundledAppManager.installBundledApps(
174212
(app, bundledAppInfo, zipFileResource) -> {
175-
String appKey = app.getKey();
176-
installedApps.computeIfAbsent(
177-
appKey,
178-
x ->
179-
Pair.of(
180-
installBundledAppResource(zipFileResource, bundledAppInfo), bundledAppInfo));
181-
182-
// If the bundled app is already installed and the Etag is different, overwrite the
183-
// existing.
184-
if (installedApps.containsKey(appKey)) {
185-
BundledAppInfo installedAppInfo = installedApps.get(appKey).getRight();
186-
if (installedAppInfo != null
187-
&& installedAppInfo.getEtag() != null
188-
&& !installedAppInfo.getEtag().equals(bundledAppInfo.getEtag())) {
189-
installedApps.put(
190-
appKey,
191-
Pair.of(
192-
installBundledAppResource(zipFileResource, bundledAppInfo), bundledAppInfo));
193-
213+
Pair<App, BundledAppInfo> existing = installedApps.get(app.getKey());
214+
if (needsInstall(existing, bundledAppInfo)) {
215+
if (existing != null) {
194216
log.info(
195-
"A bundled app with a different Etag was installed and replaced the existing one. App name: '{}'",
196-
installedAppInfo.getName());
217+
"Bundled app '{}' has a different Etag than the installed one and will replace it",
218+
bundledAppInfo.getName());
197219
}
220+
appsToInstall.add(
221+
new BundledAppInstall(
222+
app.getKey(), bundledAppInfo, zipFileResource, zipSize(zipFileResource)));
198223
}
199224
});
225+
226+
if (appsToInstall.isEmpty()) return;
227+
228+
// Install the largest apps first. A few apps (e.g. Maintenance) are far bigger than the rest,
229+
// if they start last they end up running alone at the tail with no smaller apps left to overlap
230+
// them. Starting them first lets the many small apps install in their shadow, so total time
231+
// approaches the largest app's runtime rather than largest + the rest.
232+
appsToInstall.sort(Comparator.comparingLong(BundledAppInstall::zipSize).reversed());
233+
234+
// Install in parallel, then merge results into the map on this thread (single-writer).
235+
ExecutorService appExecutor =
236+
Executors.newFixedThreadPool(
237+
Math.min(APP_INSTALL_PARALLELISM, appsToInstall.size()),
238+
new ThreadFactoryBuilder().setNameFormat("app-install-%d").setDaemon(true).build());
239+
try {
240+
List<CompletableFuture<Map.Entry<String, Pair<App, BundledAppInfo>>>> futures =
241+
new ArrayList<>(appsToInstall.size());
242+
for (BundledAppInstall item : appsToInstall) {
243+
futures.add(
244+
CompletableFuture.supplyAsync(
245+
() ->
246+
Map.entry(
247+
item.appKey(),
248+
Pair.of(
249+
installBundledAppResource(
250+
item.zipFileResource(), item.bundledAppInfo(), removeExisting),
251+
item.bundledAppInfo())),
252+
appExecutor));
253+
}
254+
CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();
255+
for (CompletableFuture<Map.Entry<String, Pair<App, BundledAppInfo>>> future : futures) {
256+
Map.Entry<String, Pair<App, BundledAppInfo>> result = future.join();
257+
installedApps.put(result.getKey(), result.getValue());
258+
}
259+
} finally {
260+
appExecutor.shutdown();
261+
}
262+
}
263+
264+
/**
265+
* Whether a bundled app needs to be (re)installed: {@code true} when it is not yet installed, or
266+
* when its installed bundled-app Etag differs from the one being offered.
267+
*/
268+
private static boolean needsInstall(
269+
@CheckForNull Pair<App, BundledAppInfo> existing, @Nonnull BundledAppInfo bundledAppInfo) {
270+
if (existing == null) {
271+
return true;
272+
}
273+
BundledAppInfo installedAppInfo = existing.getRight();
274+
return installedAppInfo != null
275+
&& installedAppInfo.getEtag() != null
276+
&& !installedAppInfo.getEtag().equals(bundledAppInfo.getEtag());
277+
}
278+
279+
/** Compressed size of the app's zip in bytes, or {@code 0} when it cannot be determined. */
280+
private static long zipSize(Resource zipFileResource) {
281+
try {
282+
return zipFileResource.contentLength();
283+
} catch (IOException e) {
284+
return 0L;
285+
}
200286
}
201287

202288
private void cacheApp(@Nonnull App app) {
@@ -434,12 +520,14 @@ public App getApp(@Nonnull String key, @Nonnull String contextPath) {
434520
@Override
435521
@Nonnull
436522
public App installApp(@Nonnull File file) {
437-
return installAppZipFile(file, null);
523+
// User-initiated installs may be upgrading an existing app, so always remove duplicates.
524+
return installAppZipFile(file, null, true);
438525
}
439526

440527
@Nonnull
441-
private App installAppZipFile(@Nonnull File file, @CheckForNull BundledAppInfo bundledAppInfo) {
442-
App app = blobStoreAppStorageService.installApp(file, appCache, bundledAppInfo);
528+
private App installAppZipFile(
529+
@Nonnull File file, @CheckForNull BundledAppInfo bundledAppInfo, boolean removeExisting) {
530+
App app = blobStoreAppStorageService.installApp(file, appCache, bundledAppInfo, removeExisting);
443531
log.debug(
444532
String.format(
445533
"Installed App with AppHub ID %s (status: %s)", app.getAppHubId(), app.getAppState()));
@@ -449,12 +537,12 @@ private App installAppZipFile(@Nonnull File file, @CheckForNull BundledAppInfo b
449537

450538
@Nonnull
451539
public App installBundledAppResource(
452-
@Nonnull Resource resource, @Nonnull BundledAppInfo bundledAppInfo) {
540+
@Nonnull Resource resource, @Nonnull BundledAppInfo bundledAppInfo, boolean removeExisting) {
453541
try {
454542
Path tempFile = Files.createTempFile("tmp-bundled-app-", CodeGenerator.generateUid());
455543
Files.copy(resource.getInputStream(), tempFile, StandardCopyOption.REPLACE_EXISTING);
456544
try {
457-
return installAppZipFile(tempFile.toFile(), bundledAppInfo);
545+
return installAppZipFile(tempFile.toFile(), bundledAppInfo, removeExisting);
458546
} finally {
459547
Files.deleteIfExists(tempFile);
460548
}

0 commit comments

Comments
 (0)