3636import static org .hisp .dhis .datastore .DatastoreNamespaceProtection .ProtectionType .RESTRICTED ;
3737
3838import com .fasterxml .jackson .databind .ObjectMapper ;
39+ import com .google .common .util .concurrent .ThreadFactoryBuilder ;
3940import java .io .*;
4041import java .io .InputStream ;
4142import java .net .URISyntaxException ;
4748import java .nio .file .StandardCopyOption ;
4849import java .util .ArrayList ;
4950import java .util .Collection ;
51+ import java .util .Comparator ;
5052import java .util .HashSet ;
5153import java .util .List ;
5254import java .util .Map ;
5355import java .util .Optional ;
5456import java .util .Set ;
5557import java .util .UUID ;
58+ import java .util .concurrent .CompletableFuture ;
59+ import java .util .concurrent .ExecutorService ;
60+ import java .util .concurrent .Executors ;
5661import java .util .function .Predicate ;
5762import java .util .stream .Stream ;
5863import 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