This is an automated email from the ASF dual-hosted git repository.

cschneider pushed a commit to branch SLING-13396-batch-install
in repository 
https://gitbox.apache.org/repos/asf/sling-org-apache-sling-distribution-journal.git

commit ba0ed319fa04f9127c069dd74c0d9265d074f68b
Author: Christian Schneider <[email protected]>
AuthorDate: Thu Oct 22 19:38:13 2026 +0200

    SLING-13396 - Subscriber batch import for slow package builders
    
    Add an opt-in batch import to the journal subscriber. A package builder
    implementing the new exported SPI
    org.apache.sling.distribution.journal.spi.BatchDistributionPackageBuilder
    receives consecutive ADD/DELETE packages in one installPackages call and
    may install independent packages concurrently.
    
    - New subscriber setting maxBatchSize (default 1 = disabled). Active only
      for non editable agents with packageHandling=Off and a batch capable
      package builder; otherwise a warning is logged.
    - A bounded queue decouples the poller from an importer thread, which
      takes whatever is already queued (up to maxBatchSize), so there is no
      extra latency for single packages.
    - The offset of the last package is stored in the same commit as the
      batch. On failure no offset is stored and the packages are imported
      one by one with the existing retry/error queue handling (at least once).
    - New metrics imported_batch_size and imported_batch_duration.
    
    Co-authored-by: Copilot App <[email protected]>
---
 docs/documentation.md                              |  17 +
 docs/metrics_overview.md                           |  12 +
 .../journal/bookkeeper/BatchEntry.java             |  52 ++++
 .../journal/bookkeeper/BookKeeper.java             |  83 +++++
 .../journal/bookkeeper/PackageHandler.java         |  27 ++
 .../journal/bookkeeper/SubscriberMetrics.java      |  20 ++
 .../impl/subscriber/DistributionSubscriber.java    | 224 +++++++++++++-
 .../impl/subscriber/SubscriberConfiguration.java   |   8 +
 .../spi/BatchDistributionPackageBuilder.java       |  67 ++++
 .../distribution/journal/spi/package-info.java     |  25 ++
 .../journal/bookkeeper/BookKeeperBatchTest.java    | 175 +++++++++++
 .../impl/subscriber/SubscriberBatchTest.java       | 343 +++++++++++++++++++++
 12 files changed, 1050 insertions(+), 3 deletions(-)

diff --git a/docs/documentation.md b/docs/documentation.md
index a64666b..6d8588f 100644
--- a/docs/documentation.md
+++ b/docs/documentation.md
@@ -162,6 +162,23 @@ Unlike other error queues in SCD, this error queue does 
_not_ allow to retry a f
 
 The implementation of the _Subscriber_ agent queue is a blocking queue that 
fetches a small (currently 8) batch of packages. Using a batch batch reduces 
the processing delay between two items (avoids the request delay) but keep the 
memory footprint small by not fetching the entire queue.
 
+### Batch import
+
+By default the _Subscriber_ agent installs one package at a time. When 
installing a package is slow, for example because the package builder calls a 
remote system for every package, a backlog of many small packages is processed 
slowly even though most of them are independent.
+
+A package builder can implement 
`org.apache.sling.distribution.journal.spi.BatchDistributionPackageBuilder` to 
receive several consecutive packages in one call. The subscriber uses it when 
`maxBatchSize` is set to a value greater than 1, the agent is not editable and 
`packageHandling` is `Off`. Otherwise it logs a warning and imports packages 
one by one.
+
+With batching enabled:
+
+* The poller thread hands packages to a separate importer thread through a 
queue of `maxBatchSize` entries, so the poller reads at most `maxBatchSize` 
packages ahead.
+* The importer takes all packages that are already queued, up to 
`maxBatchSize`. It does not wait for a batch to fill, so a single package is 
imported without delay and a backlog is imported in batches.
+* Consecutive ADD and DELETE packages that pass the precondition are installed 
together. Any other package (for example INVALIDATE, a package skipped by the 
precondition or a package for another agent) ends the current batch, which is 
imported first, and is then processed as before.
+* The offset of the last package of a batch is stored in the same commit as 
the batch, so offsets are always stored in journal order.
+* If the batch fails, no offset is stored and each package of the batch is 
imported again one by one, with the usual retry and error queue handling. 
Packages of a failed batch can therefore be installed twice, so installing a 
package must be idempotent.
+* The package builder receives the packages in journal order and must produce 
the same result as installing them one by one. It may only install packages 
concurrently when that cannot change the result, for example when their paths 
do not overlap.
+
+Batching is not supported for editable agents, because they store a single 
package status per commit and must report a status for every package.
+
 ## Handling of blocked Queue
 
 The queues sometimes get blocked, because a package can't be imported at 
destination. The root cause for a blocked queue can vary but generally is one 
of I. lack of authorisation, II. repository constraint violation.
diff --git a/docs/metrics_overview.md b/docs/metrics_overview.md
index ebed07f..62719eb 100644
--- a/docs/metrics_overview.md
+++ b/docs/metrics_overview.md
@@ -98,6 +98,18 @@ All subscriber metrics are prefixed with 
`sling_distribution_journal_subscriber_
 - **Description**: Duration of successful package import operations
 - **Tags**: `sub_name`, `pub_name`, `editable`
 
+#### `sling_distribution_journal_subscriber_imported_batch_size` (Histogram)
+- **Type**: Histogram
+- **Unit**: Packages
+- **Description**: Number of packages imported together in one successful 
batch (only with `maxBatchSize` > 1)
+- **Tags**: `sub_name`, `pub_name`, `editable`
+
+#### `sling_distribution_journal_subscriber_imported_batch_duration` (Timer)
+- **Type**: Timer
+- **Unit**: Milliseconds
+- **Description**: Duration of batch import operations, successful or not 
(only with `maxBatchSize` > 1)
+- **Tags**: `sub_name`, `pub_name`, `editable`
+
 #### `sling_distribution_journal_subscriber_current_import_duration` (Gauge)
 - **Type**: Gauge
 - **Unit**: Milliseconds
diff --git 
a/src/main/java/org/apache/sling/distribution/journal/bookkeeper/BatchEntry.java
 
b/src/main/java/org/apache/sling/distribution/journal/bookkeeper/BatchEntry.java
new file mode 100644
index 0000000..591675d
--- /dev/null
+++ 
b/src/main/java/org/apache/sling/distribution/journal/bookkeeper/BatchEntry.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.distribution.journal.bookkeeper;
+
+import java.util.Date;
+
+import org.apache.sling.distribution.journal.messages.PackageMessage;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * A package message together with its journal offset, as part of a batch 
import.
+ */
+public final class BatchEntry {
+    private final PackageMessage message;
+    private final long offset;
+    private final Date createdTime;
+
+    public BatchEntry(PackageMessage message, long offset, Date createdTime) {
+        this.message = requireNonNull(message);
+        this.offset = offset;
+        this.createdTime = requireNonNull(createdTime);
+    }
+
+    public PackageMessage getMessage() {
+        return message;
+    }
+
+    public long getOffset() {
+        return offset;
+    }
+
+    public Date getCreatedTime() {
+        return createdTime;
+    }
+}
diff --git 
a/src/main/java/org/apache/sling/distribution/journal/bookkeeper/BookKeeper.java
 
b/src/main/java/org/apache/sling/distribution/journal/bookkeeper/BookKeeper.java
index a0e1192..5c07c8d 100644
--- 
a/src/main/java/org/apache/sling/distribution/journal/bookkeeper/BookKeeper.java
+++ 
b/src/main/java/org/apache/sling/distribution/journal/bookkeeper/BookKeeper.java
@@ -22,8 +22,10 @@ import java.io.IOException;
 import java.io.PrintWriter;
 import java.io.StringWriter;
 import java.time.Duration;
+import java.util.ArrayList;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.concurrent.TimeUnit;
@@ -208,6 +210,87 @@ public class BookKeeper {
         }
     }
 
+    /**
+     * Import several consecutive packages with a single call to the package 
builder and a single
+     * commit that stores the offset of the last package.
+     *
+     * <p>Unlike {@link #importPackage(PackageMessage, long, Date, Date)} this 
method does not
+     * apply the retry or error queue handling. If it throws, no offset has 
been stored for the
+     * batch, unless an import post processor failed after the commit, and the 
caller is expected
+     * to import the packages one by one.</p>
+     *
+     * <p>Batch imports are only supported for non editable subscribers, 
because the status store
+     * holds a single status and editable subscribers must report a status for 
every package.</p>
+     */
+    public void importPackages(List<BatchEntry> entries, Date importStartTime) 
throws DistributionException {
+        if (entries.isEmpty()) {
+            return;
+        }
+        if (config.isEditable()) {
+            throw new IllegalStateException("Batch import is not supported for 
editable subscribers");
+        }
+        BatchEntry first = entries.get(0);
+        BatchEntry last = entries.get(entries.size() - 1);
+        log.debug(
+                "Importing batch of {} distribution packages at offsets {}-{}",
+                entries.size(),
+                first.getOffset(),
+                last.getOffset());
+        List<PackageMessage> pkgMsgs = new ArrayList<>(entries.size());
+        for (BatchEntry entry : entries) {
+            pkgMsgs.add(entry.getMessage());
+        }
+        try (Timer.Context context =
+                        subscriberMetrics.getImportedBatchDuration().time();
+                ResourceResolver importerResolver = 
getServiceResolver(SUBSERVICE_IMPORTER)) {
+            for (PackageMessage pkgMsg : pkgMsgs) {
+                preProcess(pkgMsg);
+            }
+            subscriberMetrics.setCurrentImport(
+                    new CurrentImportInfo(first.getMessage(), 
first.getOffset(), importStartTime.getTime()));
+            packageHandler.applyBatch(importerResolver, pkgMsgs);
+            storeOffset(importerResolver, last.getOffset());
+            importerResolver.commit();
+            subscriberMetrics.getImportedBatchSize().update(entries.size());
+
+            for (BatchEntry entry : entries) {
+                PackageMessage pkgMsg = entry.getMessage();
+                
subscriberMetrics.getImportedPackageSize().update(pkgMsg.getPkgLength());
+                subscriberMetrics
+                        .getPackageDistributedDuration()
+                        .update((currentTimeMillis() - 
entry.getCreatedTime().getTime()), TimeUnit.MILLISECONDS);
+                postProcess(pkgMsg);
+                clearPackageRetriesOnSuccess(pkgMsg);
+                eventAdmin.postEvent(new AppliedEvent(pkgMsg, 
config.getSubAgentName()).toEvent());
+                subscriberMetrics
+                        .getPackageStatusCounter(pkgMsg.getPubAgentName(), 
Status.IMPORTED)
+                        .increment();
+            }
+            log.info(
+                    "Imported batch of {} distribution packages at offsets 
{}-{} took importDurationMs={}",
+                    entries.size(),
+                    first.getOffset(),
+                    last.getOffset(),
+                    System.currentTimeMillis() - importStartTime.getTime());
+        } catch (LoginException
+                | IOException
+                | RuntimeException
+                | ImportPreProcessException
+                | ImportPostProcessException e) {
+            throw new DistributionException(
+                    format(
+                            "Failed to import batch of %d distribution 
packages at offsets %d-%d because of '%s'",
+                            entries.size(), first.getOffset(), 
last.getOffset(), e.getMessage()),
+                    e);
+        } finally {
+            subscriberMetrics.clearCurrentImport();
+        }
+    }
+
+    public boolean supportsBatchImport() {
+        return !config.isEditable() && packageHandler.supportsBatch();
+    }
+
     public void invalidateCache(PackageMessage pkgMsg, long offset, Date 
createdTime, Date importStartTime)
             throws DistributionException {
         log.debug("Invalidating the cache for the package {} at offset={}", 
pkgMsg, offset);
diff --git 
a/src/main/java/org/apache/sling/distribution/journal/bookkeeper/PackageHandler.java
 
b/src/main/java/org/apache/sling/distribution/journal/bookkeeper/PackageHandler.java
index 920a544..14df5ae 100644
--- 
a/src/main/java/org/apache/sling/distribution/journal/bookkeeper/PackageHandler.java
+++ 
b/src/main/java/org/apache/sling/distribution/journal/bookkeeper/PackageHandler.java
@@ -23,6 +23,8 @@ import javax.annotation.Nonnull;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
 
 import org.apache.sling.api.resource.ResourceResolver;
 import org.apache.sling.distribution.common.DistributionException;
@@ -30,6 +32,7 @@ import org.apache.sling.distribution.journal.BinaryStore;
 import org.apache.sling.distribution.journal.messages.PackageMessage;
 import org.apache.sling.distribution.journal.messages.PackageMessage.ReqType;
 import org.apache.sling.distribution.journal.shared.JournalDistributionPackage;
+import 
org.apache.sling.distribution.journal.spi.BatchDistributionPackageBuilder;
 import org.apache.sling.distribution.packaging.DistributionPackage;
 import org.apache.sling.distribution.packaging.DistributionPackageBuilder;
 import org.apache.sling.distribution.packaging.DistributionPackageInfo;
@@ -65,6 +68,30 @@ class PackageHandler {
         }
     }
 
+    public boolean supportsBatch() {
+        return packageBuilder instanceof BatchDistributionPackageBuilder;
+    }
+
+    /**
+     * Install all packages with one call to the {@link 
BatchDistributionPackageBuilder}.
+     */
+    public void applyBatch(ResourceResolver resolver, List<PackageMessage> 
pkgMsgs) throws DistributionException {
+        if (!supportsBatch()) {
+            throw new IllegalStateException("Package builder " + 
packageBuilder.getType() + " does not implement "
+                    + BatchDistributionPackageBuilder.class.getName());
+        }
+        List<DistributionPackage> distributionPackages = new 
ArrayList<>(pkgMsgs.size());
+        for (PackageMessage pkgMsg : pkgMsgs) {
+            distributionPackages.add(toDistributionPackage(pkgMsg));
+        }
+        ((BatchDistributionPackageBuilder) 
packageBuilder).installPackages(resolver, distributionPackages);
+        for (PackageMessage pkgMsg : pkgMsgs) {
+            if (pkgMsg.getReqType() == ADD) {
+                extractor.handle(resolver, pkgMsg.getPaths());
+            }
+        }
+    }
+
     private DistributionPackage toDistributionPackage(PackageMessage pkgMsg) 
throws DistributionException {
         LOG.debug("Importing paths {}", pkgMsg.getPaths());
         final byte[] data;
diff --git 
a/src/main/java/org/apache/sling/distribution/journal/bookkeeper/SubscriberMetrics.java
 
b/src/main/java/org/apache/sling/distribution/journal/bookkeeper/SubscriberMetrics.java
index 887390a..2bb5c9b 100644
--- 
a/src/main/java/org/apache/sling/distribution/journal/bookkeeper/SubscriberMetrics.java
+++ 
b/src/main/java/org/apache/sling/distribution/journal/bookkeeper/SubscriberMetrics.java
@@ -90,6 +90,8 @@ public class SubscriberMetrics {
     private static final String IMPORT_PRE_PROCESS_SUCCESS_COUNT = 
SUB_COMPONENT + "import_pre_process_success_count";
 
     private static final String IMPORTED_PACKAGE_DURATION = SUB_COMPONENT + 
"imported_package_duration";
+    private static final String IMPORTED_BATCH_SIZE = SUB_COMPONENT + 
"imported_batch_size";
+    private static final String IMPORTED_BATCH_DURATION = SUB_COMPONENT + 
"imported_batch_duration";
     private static final String IMPORT_PACKAGE_INSTALL_DURATION = 
SUB_COMPONENT + "package_install_duration";
     private static final String IMPORT_PACKAGE_INSTALL_COUNT = SUB_COMPONENT + 
"package_install_count";
     private static final String REMOVED_PACKAGE_DURATION = SUB_COMPONENT + 
"removed_package_duration";
@@ -145,6 +147,24 @@ public class SubscriberMetrics {
         return metricsService.timer(getMetricName(IMPORTED_PACKAGE_DURATION, 
tags));
     }
 
+    /**
+     * Histogram of the number of packages imported together in one batch.
+     *
+     * @return a Sling Metrics histogram
+     */
+    public Histogram getImportedBatchSize() {
+        return metricsService.histogram(getMetricName(IMPORTED_BATCH_SIZE, 
tags));
+    }
+
+    /**
+     * Timer capturing the duration in ms of batch import operations, 
successful or not.
+     *
+     * @return a Sling Metrics timer
+     */
+    public Timer getImportedBatchDuration() {
+        return metricsService.timer(getMetricName(IMPORTED_BATCH_DURATION, 
tags));
+    }
+
     /**
      * Timer capturing the duration in ms of packages successfully removed 
from an editable subscriber.
      *
diff --git 
a/src/main/java/org/apache/sling/distribution/journal/impl/subscriber/DistributionSubscriber.java
 
b/src/main/java/org/apache/sling/distribution/journal/impl/subscriber/DistributionSubscriber.java
index 2575fbe..f8aad40 100644
--- 
a/src/main/java/org/apache/sling/distribution/journal/impl/subscriber/DistributionSubscriber.java
+++ 
b/src/main/java/org/apache/sling/distribution/journal/impl/subscriber/DistributionSubscriber.java
@@ -24,11 +24,15 @@ import java.io.Closeable;
 import java.io.IOException;
 import java.net.URI;
 import java.time.Duration;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Date;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
@@ -51,9 +55,11 @@ import org.apache.sling.distribution.journal.HandlerAdapter;
 import org.apache.sling.distribution.journal.MessageInfo;
 import org.apache.sling.distribution.journal.MessagingProvider;
 import org.apache.sling.distribution.journal.Reset;
+import org.apache.sling.distribution.journal.bookkeeper.BatchEntry;
 import org.apache.sling.distribution.journal.bookkeeper.BookKeeper;
 import org.apache.sling.distribution.journal.bookkeeper.BookKeeperConfig;
 import org.apache.sling.distribution.journal.bookkeeper.BookKeeperFactory;
+import org.apache.sling.distribution.journal.bookkeeper.PackageHandling;
 import org.apache.sling.distribution.journal.bookkeeper.SubscriberMetrics;
 import org.apache.sling.distribution.journal.impl.precondition.Precondition;
 import 
org.apache.sling.distribution.journal.impl.precondition.Precondition.Decision;
@@ -65,6 +71,7 @@ import 
org.apache.sling.distribution.journal.messages.PackageStatusMessage;
 import org.apache.sling.distribution.journal.shared.Delay;
 import org.apache.sling.distribution.journal.shared.OnlyOnLeader;
 import org.apache.sling.distribution.journal.shared.Topics;
+import 
org.apache.sling.distribution.journal.spi.BatchDistributionPackageBuilder;
 import org.apache.sling.distribution.packaging.DistributionPackageBuilder;
 import org.apache.sling.settings.SlingSettingsService;
 import org.osgi.framework.BundleContext;
@@ -83,6 +90,8 @@ import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.MINUTES;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.toSet;
+import static 
org.apache.sling.distribution.journal.messages.PackageMessage.ReqType.ADD;
+import static 
org.apache.sling.distribution.journal.messages.PackageMessage.ReqType.DELETE;
 import static 
org.apache.sling.distribution.journal.messages.PackageMessage.ReqType.INVALIDATE;
 import static org.apache.sling.distribution.journal.shared.Delay.exponential;
 import static 
org.apache.sling.distribution.journal.shared.Strings.requireNotBlank;
@@ -104,6 +113,7 @@ public class DistributionSubscriber {
     static long RETRY_DELAY_MILLIS = SECONDS.toMillis(5);
     static long MAX_RETRY_DELAY_MILLIS = MINUTES.toMillis(15);
     static long QUEUE_FETCH_DELAY_MILLIS = SECONDS.toMillis(1);
+    static long BATCH_IMPORTER_STOP_WAIT_MILLIS = SECONDS.toMillis(30);
     private static final Supplier<LongSupplier> catchAllDelays =
             () -> exponential(RETRY_DELAY_MILLIS, MAX_RETRY_DELAY_MILLIS);
 
@@ -136,6 +146,14 @@ public class DistributionSubscriber {
     private LongSupplier catchAllDelay = catchAllDelays.get();
 
     private final Delay delay = new Delay();
+
+    private int maxBatchSize = 1;
+
+    // Only used when batching is enabled: packages read by the poller, 
waiting for the batch importer
+    private BlockingQueue<QueuedPackage> pendingPackages;
+
+    private Thread batchImporter;
+
     private AtomicReference<DistributionAgentState> state =
             new 
AtomicReference<DistributionAgentState>(DistributionAgentState.IDLE);
 
@@ -207,11 +225,18 @@ public class DistributionSubscriber {
         long startOffset = bookKeeper.loadOffset() + 1;
         String assign = startOffset > 0 ? 
messagingProvider.assignTo(startOffset) : null;
 
+        maxBatchSize = effectiveBatchSize(config);
+        if (maxBatchSize > 1) {
+            pendingPackages = new ArrayBlockingQueue<>(maxBatchSize);
+        }
+
         packagePoller = messagingProvider.createPoller(
                 Topics.PACKAGE_TOPIC,
                 Reset.latest,
                 assign,
-                HandlerAdapter.create(PackageMessage.class, 
this::handlePackageMessage),
+                HandlerAdapter.create(
+                        PackageMessage.class,
+                        maxBatchSize > 1 ? this::enqueuePackageMessage : 
this::handlePackageMessage),
                 HandlerAdapter.create(OffsetMessage.class, 
this::handleOffsetMessage));
 
         int announceDelay = Converters.standardConverter()
@@ -228,12 +253,45 @@ public class DistributionSubscriber {
                 config.editable(),
                 announceDelay);
 
+        if (maxBatchSize > 1) {
+            batchImporter =
+                    new Thread(this::runBatchImporter, "Distribution 
subscriber batch importer " + subAgentName);
+            batchImporter.setDaemon(true);
+            batchImporter.start();
+        }
+
         LOG.info(
-                "Started Subscriber agent={} at offset={}, subscribed to agent 
names {}, readyCheck={}",
+                "Started Subscriber agent={} at offset={}, subscribed to agent 
names {}, readyCheck={}, maxBatchSize={}",
                 subAgentName,
                 startOffset,
                 queueNames,
-                config.subscriberIdleCheck());
+                config.subscriberIdleCheck(),
+                maxBatchSize);
+    }
+
+    private int effectiveBatchSize(SubscriberConfiguration config) {
+        int requested = config.maxBatchSize();
+        if (requested <= 1) {
+            return 1;
+        }
+        String reason = null;
+        if (config.editable()) {
+            reason = "the agent is editable";
+        } else if (config.packageHandling() != PackageHandling.Off) {
+            reason = "packageHandling is " + config.packageHandling();
+        } else if (!bookKeeper.supportsBatchImport()) {
+            reason = "the package builder " + pkgType + " does not implement "
+                    + BatchDistributionPackageBuilder.class.getName();
+        }
+        if (reason != null) {
+            LOG.warn(
+                    "Subscriber agent {} is configured with maxBatchSize={} 
but imports packages one by one because {}",
+                    subAgentName,
+                    requested,
+                    reason);
+            return 1;
+        }
+        return requested;
     }
 
     private String getFirst(String[] agentNames) {
@@ -265,6 +323,7 @@ public class DistributionSubscriber {
 
         IOUtils.closeQuietly(announcer, packagePoller, idleReadyCheck, 
idleCheck, commandPoller);
         running = false;
+        awaitBatchImporter();
         LOG.info(
                 "Stopped Subscriber agent {}, subscribed to Publisher agent 
names {} with package builder {}",
                 subAgentName,
@@ -284,6 +343,155 @@ public class DistributionSubscriber {
         }
     }
 
+    /**
+     * Wait (bounded) for the batch importer to finish the batch it is 
processing. Packages still
+     * waiting in the in-memory queue are dropped. Their offsets have not been 
stored, so they are
+     * read again from the journal when the subscriber starts the next time.
+     */
+    private void awaitBatchImporter() {
+        if (batchImporter == null) {
+            return;
+        }
+        try {
+            batchImporter.join(BATCH_IMPORTER_STOP_WAIT_MILLIS);
+            if (batchImporter.isAlive()) {
+                LOG.warn(
+                        "Batch importer of subscriber agent {} is still 
running after waiting {} ms on deactivation",
+                        subAgentName,
+                        BATCH_IMPORTER_STOP_WAIT_MILLIS);
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+    }
+
+    /**
+     * Called by the poller thread when batching is enabled. Blocks while the 
queue is full, so the
+     * poller reads at most {@code maxBatchSize} packages ahead of the 
importer.
+     */
+    private void enqueuePackageMessage(MessageInfo info, PackageMessage 
message) {
+        QueuedPackage queued = new QueuedPackage(info, message);
+        try {
+            while (running) {
+                if (pendingPackages.offer(queued, QUEUE_FETCH_DELAY_MILLIS, 
MILLISECONDS)) {
+                    return;
+                }
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+    }
+
+    /**
+     * Importer loop used when batching is enabled. Takes all packages that 
are already queued, up
+     * to {@code maxBatchSize}, so a backlog is imported in batches while 
single packages are
+     * imported without waiting.
+     */
+    private void runBatchImporter() {
+        List<QueuedPackage> batch = new ArrayList<>(maxBatchSize);
+        while (running) {
+            try {
+                QueuedPackage first = 
pendingPackages.poll(QUEUE_FETCH_DELAY_MILLIS, MILLISECONDS);
+                if (first == null) {
+                    continue;
+                }
+                batch.add(first);
+                pendingPackages.drainTo(batch, maxBatchSize - 1);
+                processBatch(batch);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                return;
+            } catch (RuntimeException e) {
+                // Must not happen as processing retries until success. Stop 
rather than losing packages.
+                LOG.error("Unexpected error in batch importer of subscriber 
agent {}", subAgentName, e);
+                return;
+            } finally {
+                batch.clear();
+            }
+        }
+    }
+
+    /**
+     * Imports consecutive batchable packages together. Any other package ends 
the current run,
+     * which is imported first, and is then processed on its own, so offsets 
are always stored in
+     * journal order.
+     */
+    private void processBatch(List<QueuedPackage> batch) {
+        List<QueuedPackage> run = new ArrayList<>(batch.size());
+        for (QueuedPackage queued : batch) {
+            if (!running) {
+                return;
+            }
+            if (isBatchable(queued)) {
+                run.add(queued);
+            } else {
+                importRun(run);
+                run.clear();
+                handlePackageMessage(queued.info, queued.message);
+            }
+        }
+        importRun(run);
+    }
+
+    private boolean isBatchable(QueuedPackage queued) {
+        PackageMessage message = queued.message;
+        PackageMessage.ReqType type = message.getReqType();
+        if (type != ADD && type != DELETE) {
+            return false;
+        }
+        if (!queueNames.contains(message.getPubAgentName()) || 
!pkgType.equals(message.getPkgType())) {
+            return false;
+        }
+        try {
+            return !shouldRemove(queued.info.getOffset());
+        } catch (PreConditionTimeoutException e) {
+            return false;
+        }
+    }
+
+    private void importRun(List<QueuedPackage> run) {
+        if (run.isEmpty() || !running) {
+            return;
+        }
+        if (run.size() == 1 || !tryImportBatch(run)) {
+            for (QueuedPackage queued : run) {
+                handlePackageMessage(queued.info, queued.message);
+            }
+        }
+    }
+
+    private boolean tryImportBatch(List<QueuedPackage> run) {
+        List<BatchEntry> entries = new ArrayList<>(run.size());
+        for (QueuedPackage queued : run) {
+            MessageInfo info = queued.info;
+            entries.add(new BatchEntry(queued.message, info.getOffset(), new 
Date(info.getCreateTime())));
+            subscriberMetrics
+                    .getPackageJournalDistributionDuration()
+                    .update((currentTimeMillis() - info.getCreateTime()), 
TimeUnit.MILLISECONDS);
+        }
+        try {
+            this.state.set(DistributionAgentState.RUNNING);
+            idleCheck.busy(
+                    
bookKeeper.getRetries(run.get(0).message.getPubAgentName()),
+                    run.get(0).info.getCreateTime());
+            bookKeeper.importPackages(entries, new Date());
+            catchAllDelay = catchAllDelays.get();
+            return true;
+        } catch (Exception e) {
+            LOG.warn(
+                    "Failed to import batch of {} packages at offsets {}-{}, 
importing them one by one",
+                    run.size(),
+                    run.get(0).info.getOffset(),
+                    run.get(run.size() - 1).info.getOffset(),
+                    e);
+            return false;
+        } finally {
+            idleCheck.idle();
+            this.state.set(DistributionAgentState.IDLE);
+            announcer.run();
+        }
+    }
+
     public boolean tryProcess(MessageInfo info, PackageMessage message) {
         if (shouldSkip(info, message)) {
             try {
@@ -407,4 +615,14 @@ public class DistributionSubscriber {
                 MILLISECONDS.toSeconds(PRECONDITION_TIMEOUT_MILLIS), offset);
         throw new PreConditionTimeoutException(msg);
     }
+
+    private static final class QueuedPackage {
+        private final MessageInfo info;
+        private final PackageMessage message;
+
+        QueuedPackage(MessageInfo info, PackageMessage message) {
+            this.info = info;
+            this.message = message;
+        }
+    }
 }
diff --git 
a/src/main/java/org/apache/sling/distribution/journal/impl/subscriber/SubscriberConfiguration.java
 
b/src/main/java/org/apache/sling/distribution/journal/impl/subscriber/SubscriberConfiguration.java
index 81bf0bb..9f841c7 100644
--- 
a/src/main/java/org/apache/sling/distribution/journal/impl/subscriber/SubscriberConfiguration.java
+++ 
b/src/main/java/org/apache/sling/distribution/journal/impl/subscriber/SubscriberConfiguration.java
@@ -89,4 +89,12 @@ public @interface SubscriberConfiguration {
 
     @AttributeDefinition(description = "Number of ms to wait before retrying 
to process a package.")
     int acceptableAgeDiffMs() default 120 * 1000;
+
+    @AttributeDefinition(
+            name = "maxBatchSize",
+            description =
+                    "Maximum number of consecutive ADD and DELETE packages 
that are installed together with one call to the package builder."
+                            + " Only applies if the package builder implements 
BatchDistributionPackageBuilder, the agent is not editable and"
+                            + " packageHandling is Off. Default is 1 (no 
batching).")
+    int maxBatchSize() default 1;
 }
diff --git 
a/src/main/java/org/apache/sling/distribution/journal/spi/BatchDistributionPackageBuilder.java
 
b/src/main/java/org/apache/sling/distribution/journal/spi/BatchDistributionPackageBuilder.java
new file mode 100644
index 0000000..45cd179
--- /dev/null
+++ 
b/src/main/java/org/apache/sling/distribution/journal/spi/BatchDistributionPackageBuilder.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.distribution.journal.spi;
+
+import javax.annotation.Nonnull;
+
+import java.util.List;
+
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.distribution.common.DistributionException;
+import org.apache.sling.distribution.packaging.DistributionPackage;
+import org.apache.sling.distribution.packaging.DistributionPackageBuilder;
+import org.osgi.annotation.versioning.ConsumerType;
+
+/**
+ * A {@link DistributionPackageBuilder} that can install several packages in 
one call.
+ *
+ * <p>A journal subscriber configured with {@code maxBatchSize > 1} hands 
consecutive
+ * ADD and DELETE packages to {@link #installPackages(ResourceResolver, List)} 
instead of
+ * installing them one by one. This lets a builder whose install step is slow 
(for example
+ * because it calls a remote system per package) process independent packages 
concurrently.</p>
+ *
+ * <p>Contract for implementations:</p>
+ * <ul>
+ *     <li>The packages are passed in journal order. After a successful call, 
the result must be
+ *     the same as installing the packages one by one in that order. Packages 
may only be installed
+ *     concurrently when that cannot change the result, for example when their 
paths do not overlap
+ *     (a package deleting {@code /a} and a package updating {@code /a/b} do 
overlap).</li>
+ *     <li>Changes made through the given resource resolver are committed by 
the subscriber after the
+ *     call returns, together with the journal offset of the last package. 
Implementations must not
+ *     commit the resolver themselves.</li>
+ *     <li>If the call throws, the subscriber does not store the offset and 
installs each package of
+ *     the batch again through {@link #installPackage(ResourceResolver, 
DistributionPackage)}, with the
+ *     usual retry and error-queue handling. Packages of a failed batch can 
therefore be installed more
+ *     than once, so installing a package must be idempotent.</li>
+ * </ul>
+ */
+@ConsumerType
+public interface BatchDistributionPackageBuilder extends 
DistributionPackageBuilder {
+
+    /**
+     * Install the given packages.
+     *
+     * @param resourceResolver the resource resolver used to install the 
packages
+     * @param distributionPackages the packages to install, in journal order
+     * @throws DistributionException if any package of the batch could not be 
installed
+     */
+    void installPackages(
+            @Nonnull ResourceResolver resourceResolver, @Nonnull 
List<DistributionPackage> distributionPackages)
+            throws DistributionException;
+}
diff --git 
a/src/main/java/org/apache/sling/distribution/journal/spi/package-info.java 
b/src/main/java/org/apache/sling/distribution/journal/spi/package-info.java
new file mode 100644
index 0000000..8b12872
--- /dev/null
+++ b/src/main/java/org/apache/sling/distribution/journal/spi/package-info.java
@@ -0,0 +1,25 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+/**
+ * Extension points that other bundles can implement to customise the journal 
subscriber.
+ */
+@Version("1.0.0")
+package org.apache.sling.distribution.journal.spi;
+
+import org.osgi.annotation.versioning.Version;
diff --git 
a/src/test/java/org/apache/sling/distribution/journal/bookkeeper/BookKeeperBatchTest.java
 
b/src/test/java/org/apache/sling/distribution/journal/bookkeeper/BookKeeperBatchTest.java
new file mode 100644
index 0000000..6d071ed
--- /dev/null
+++ 
b/src/test/java/org/apache/sling/distribution/journal/bookkeeper/BookKeeperBatchTest.java
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.distribution.journal.bookkeeper;
+
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.function.Consumer;
+
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.ResourceResolverFactory;
+import org.apache.sling.commons.metrics.MetricsService;
+import org.apache.sling.commons.metrics.internal.MetricsServiceImpl;
+import org.apache.sling.distribution.ImportPostProcessor;
+import org.apache.sling.distribution.ImportPreProcessor;
+import org.apache.sling.distribution.InvalidationProcessor;
+import org.apache.sling.distribution.common.DistributionException;
+import org.apache.sling.distribution.journal.messages.LogMessage;
+import org.apache.sling.distribution.journal.messages.PackageMessage;
+import org.apache.sling.distribution.journal.messages.PackageStatusMessage;
+import org.apache.sling.testing.mock.osgi.junit.OsgiContext;
+import org.apache.sling.testing.resourceresolver.MockResourceResolverFactory;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.osgi.service.event.Event;
+import org.osgi.service.event.EventAdmin;
+
+import static java.util.Collections.singletonList;
+import static org.hamcrest.CoreMatchers.equalTo;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+@RunWith(MockitoJUnitRunner.class)
+public class BookKeeperBatchTest {
+
+    private static final String PUB_AGENT_NAME = "pubAgent";
+
+    private final ResourceResolverFactory resolverFactory = new 
MockResourceResolverFactory();
+
+    private final OsgiContext context = new OsgiContext();
+
+    @Mock
+    private EventAdmin eventAdmin;
+
+    @Mock
+    private Consumer<PackageStatusMessage> sender;
+
+    @Mock
+    private Consumer<LogMessage> logSender;
+
+    @Mock
+    private PackageHandler packageHandler;
+
+    @Mock
+    private ImportPreProcessor importPreProcessor;
+
+    @Mock
+    private ImportPostProcessor importPostProcessor;
+
+    @Mock
+    private InvalidationProcessor invalidationProcessor;
+
+    @Test
+    public void testImportPackagesStoresLastOffsetOnce() throws Exception {
+        BookKeeper bookKeeper = createBookKeeper(false);
+        List<BatchEntry> entries = Arrays.asList(entry("a", 10), entry("b", 
11), entry("c", 12));
+
+        bookKeeper.importPackages(entries, new Date());
+
+        verify(packageHandler)
+                .applyBatch(
+                        any(ResourceResolver.class),
+                        eq(Arrays.asList(
+                                entries.get(0).getMessage(),
+                                entries.get(1).getMessage(),
+                                entries.get(2).getMessage())));
+        verify(importPreProcessor, times(3)).process(any());
+        verify(importPostProcessor, times(3)).process(any());
+        verify(eventAdmin, times(3)).postEvent(any(Event.class));
+        verifyNoInteractions(sender);
+        assertThat(bookKeeper.loadOffset(), equalTo(12L));
+    }
+
+    @Test
+    public void testFailedBatchStoresNoOffsetAndDoesNotCountRetries() throws 
Exception {
+        BookKeeper bookKeeper = createBookKeeper(false);
+        doThrow(new DistributionException("install failed"))
+                .when(packageHandler)
+                .applyBatch(any(ResourceResolver.class), any());
+        List<BatchEntry> entries = Arrays.asList(entry("a", 10), entry("b", 
11));
+
+        assertThrows(DistributionException.class, () -> 
bookKeeper.importPackages(entries, new Date()));
+
+        assertThat(bookKeeper.loadOffset(), equalTo(-1L));
+        assertThat(bookKeeper.getRetries(PUB_AGENT_NAME), equalTo(0));
+        verify(importPostProcessor, never()).process(any());
+        verify(eventAdmin, never()).postEvent(any(Event.class));
+        verifyNoInteractions(logSender);
+    }
+
+    @Test
+    public void testEditableSubscriberDoesNotSupportBatch() {
+        BookKeeper bookKeeper = createBookKeeper(true);
+
+        assertThat(bookKeeper.supportsBatchImport(), equalTo(false));
+        assertThrows(
+                IllegalStateException.class,
+                () -> bookKeeper.importPackages(singletonList(entry("a", 10)), 
new Date()));
+    }
+
+    @Test
+    public void testSupportsBatchImportFollowsPackageHandler() {
+        when(packageHandler.supportsBatch()).thenReturn(true);
+
+        assertThat(createBookKeeper(false).supportsBatchImport(), 
equalTo(true));
+    }
+
+    private BookKeeper createBookKeeper(boolean editable) {
+        MetricsService metricsService = 
context.registerInjectActivateService(MetricsServiceImpl.class);
+        BookKeeperConfig bkConfig = new BookKeeperConfig(
+                "subAgentName", "subSlingId", editable, 10, 
PackageHandling.Off, "package", "command", true);
+        SubscriberMetrics subscriberMetrics =
+                new SubscriberMetrics(metricsService, 
bkConfig.getSubAgentName(), "publish", bkConfig.isEditable());
+        return new BookKeeper(
+                resolverFactory,
+                subscriberMetrics,
+                packageHandler,
+                eventAdmin,
+                sender,
+                logSender,
+                bkConfig,
+                importPreProcessor,
+                importPostProcessor,
+                invalidationProcessor);
+    }
+
+    private static BatchEntry entry(String pkgId, long offset) {
+        PackageMessage message = PackageMessage.builder()
+                .pkgId(pkgId)
+                .pubSlingId("pubSlingId")
+                .pubAgentName(PUB_AGENT_NAME)
+                .reqType(PackageMessage.ReqType.ADD)
+                .pkgType("journal")
+                .paths(singletonList("/content/" + pkgId))
+                .pkgBinary(new byte[10])
+                .build();
+        return new BatchEntry(message, offset, new Date());
+    }
+}
diff --git 
a/src/test/java/org/apache/sling/distribution/journal/impl/subscriber/SubscriberBatchTest.java
 
b/src/test/java/org/apache/sling/distribution/journal/impl/subscriber/SubscriberBatchTest.java
new file mode 100644
index 0000000..0ddaf11
--- /dev/null
+++ 
b/src/test/java/org/apache/sling/distribution/journal/impl/subscriber/SubscriberBatchTest.java
@@ -0,0 +1,343 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.distribution.journal.impl.subscriber;
+
+import java.io.Closeable;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.ResourceResolverFactory;
+import org.apache.sling.commons.metrics.MetricsService;
+import org.apache.sling.distribution.ImportPostProcessor;
+import org.apache.sling.distribution.ImportPreProcessor;
+import org.apache.sling.distribution.InvalidationProcessor;
+import org.apache.sling.distribution.common.DistributionException;
+import org.apache.sling.distribution.journal.HandlerAdapter;
+import org.apache.sling.distribution.journal.MessageHandler;
+import org.apache.sling.distribution.journal.MessageSender;
+import org.apache.sling.distribution.journal.MessagingProvider;
+import org.apache.sling.distribution.journal.Reset;
+import org.apache.sling.distribution.journal.bookkeeper.BookKeeper;
+import org.apache.sling.distribution.journal.bookkeeper.BookKeeperFactory;
+import org.apache.sling.distribution.journal.bookkeeper.LocalStore;
+import org.apache.sling.distribution.journal.impl.precondition.Precondition;
+import 
org.apache.sling.distribution.journal.impl.precondition.Precondition.Decision;
+import org.apache.sling.distribution.journal.messages.DiscoveryMessage;
+import org.apache.sling.distribution.journal.messages.PackageMessage;
+import org.apache.sling.distribution.journal.messages.PackageMessage.ReqType;
+import org.apache.sling.distribution.journal.messages.PackageStatusMessage;
+import org.apache.sling.distribution.journal.messages.PingMessage;
+import org.apache.sling.distribution.journal.shared.NoOpImportPostProcessor;
+import org.apache.sling.distribution.journal.shared.NoOpImportPreProcessor;
+import org.apache.sling.distribution.journal.shared.NoOpInvalidationProcessor;
+import org.apache.sling.distribution.journal.shared.OnlyOnLeader;
+import org.apache.sling.distribution.journal.shared.TestMessageInfo;
+import org.apache.sling.distribution.journal.shared.Topics;
+import 
org.apache.sling.distribution.journal.spi.BatchDistributionPackageBuilder;
+import org.apache.sling.distribution.packaging.DistributionPackage;
+import org.apache.sling.settings.SlingSettingsService;
+import org.apache.sling.testing.resourceresolver.MockResourceResolverFactory;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.osgi.framework.BundleContext;
+import org.osgi.service.event.EventAdmin;
+import org.osgi.util.converter.Converters;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.awaitility.Awaitility.await;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.equalTo;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@RunWith(MockitoJUnitRunner.class)
+public class SubscriberBatchTest {
+
+    private static final String SUB1_SLING_ID = "sub1sling";
+    private static final String SUB1_AGENT_NAME = "sub1agent";
+    private static final String PUB1_AGENT_NAME = "pub1agent";
+    private static final String STORE_PACKAGE_NODE_NAME = 
"myserver.apache.org_somepath_package";
+
+    @Mock
+    private BundleContext context;
+
+    @Mock
+    private BatchDistributionPackageBuilder packageBuilder;
+
+    @Mock
+    private Precondition precondition;
+
+    @Mock
+    private SlingSettingsService slingSettings;
+
+    @Spy
+    private ResourceResolverFactory resolverFactory = new 
MockResourceResolverFactory();
+
+    @Mock
+    private MessagingProvider clientProvider;
+
+    @Mock
+    private EventAdmin eventAdmin;
+
+    @Mock
+    private MessageSender<DiscoveryMessage> discoverySender;
+
+    @Mock
+    private MessageSender<PackageStatusMessage> statusSender;
+
+    @Spy
+    private MetricsService metricsService = MetricsService.NOOP;
+
+    @Spy
+    private ImportPreProcessor importPreProcessor = new 
NoOpImportPreProcessor();
+
+    @Spy
+    private ImportPostProcessor importPostProcessor = new 
NoOpImportPostProcessor();
+
+    @Spy
+    private InvalidationProcessor invalidationProcessor = new 
NoOpInvalidationProcessor();
+
+    @Spy
+    private SubscriberReadyStore subscriberReadyStore = new 
SubscriberReadyStore();
+
+    @InjectMocks
+    private BookKeeperFactory bookKeeperFactory;
+
+    @Captor
+    private ArgumentCaptor<HandlerAdapter<PackageMessage>> packageCaptor;
+
+    @Captor
+    private ArgumentCaptor<HandlerAdapter<PingMessage>> pingCaptor;
+
+    @Mock
+    private Closeable poller;
+
+    private DistributionSubscriber subscriber;
+
+    private MessageHandler<PackageMessage> packageHandler;
+
+    private final List<List<String>> installedBatches = 
Collections.synchronizedList(new ArrayList<>());
+
+    @Before
+    public void before() throws Exception {
+        DistributionSubscriber.QUEUE_FETCH_DELAY_MILLIS = 100;
+        DistributionSubscriber.RETRY_DELAY_MILLIS = 100;
+        when(packageBuilder.getType()).thenReturn("journal");
+        when(slingSettings.getSlingId()).thenReturn(SUB1_SLING_ID);
+        when(clientProvider.getServerUri()).thenReturn(new 
URI("http://myserver.apache.org:1234/somepath";));
+        
when(clientProvider.<PackageStatusMessage>createSender(Topics.STATUS_TOPIC))
+                .thenReturn(statusSender);
+        
when(clientProvider.<DiscoveryMessage>createSender(Topics.DISCOVERY_TOPIC))
+                .thenReturn(discoverySender);
+        when(precondition.canProcess(eq(SUB1_AGENT_NAME), 
anyLong())).thenReturn(Decision.ACCEPT);
+    }
+
+    @After
+    public void after() {
+        subscriber.deactivate();
+    }
+
+    @Test
+    public void testBacklogIsImportedAsBatch() throws Exception {
+        recordInstalledBatches();
+        CountDownLatch firstImportStarted = new CountDownLatch(1);
+        CountDownLatch releaseFirstImport = new CountDownLatch(1);
+        when(packageBuilder.installPackage(any(ResourceResolver.class), 
any(DistributionPackage.class)))
+                .thenAnswer(invocation -> {
+                    firstImportStarted.countDown();
+                    releaseFirstImport.await();
+                    return true;
+                });
+        initSubscriber(3);
+
+        // The importer picks up the first package alone and blocks in the 
single install
+        packageHandler.handle(new TestMessageInfo("", 1, 0, 0), message("p0", 
ReqType.ADD));
+        assertTrue(firstImportStarted.await(10, SECONDS));
+
+        // Meanwhile the next packages queue up and are imported together
+        packageHandler.handle(new TestMessageInfo("", 1, 1, 0), message("p1", 
ReqType.ADD));
+        packageHandler.handle(new TestMessageInfo("", 1, 2, 0), message("p2", 
ReqType.DELETE));
+        packageHandler.handle(new TestMessageInfo("", 1, 3, 0), message("p3", 
ReqType.ADD));
+        releaseFirstImport.countDown();
+
+        await().atMost(10, SECONDS).until(this::getStoredOffset, equalTo(3L));
+        assertThat(installedBatches, contains(Arrays.asList("p1", "p2", 
"p3")));
+        verify(packageBuilder, 
times(1)).installPackage(any(ResourceResolver.class), 
any(DistributionPackage.class));
+    }
+
+    @Test
+    public void testFailedBatchFallsBackToSinglePackages() throws Exception {
+        doThrow(new DistributionException("batch failed"))
+                .when(packageBuilder)
+                .installPackages(any(ResourceResolver.class), anyList());
+        CountDownLatch firstImportStarted = new CountDownLatch(1);
+        CountDownLatch releaseFirstImport = new CountDownLatch(1);
+        when(packageBuilder.installPackage(any(ResourceResolver.class), 
any(DistributionPackage.class)))
+                .thenAnswer(invocation -> {
+                    firstImportStarted.countDown();
+                    releaseFirstImport.await();
+                    return true;
+                });
+        initSubscriber(3);
+
+        packageHandler.handle(new TestMessageInfo("", 1, 0, 0), message("p0", 
ReqType.ADD));
+        assertTrue(firstImportStarted.await(10, SECONDS));
+        packageHandler.handle(new TestMessageInfo("", 1, 1, 0), message("p1", 
ReqType.ADD));
+        packageHandler.handle(new TestMessageInfo("", 1, 2, 0), message("p2", 
ReqType.ADD));
+        releaseFirstImport.countDown();
+
+        await().atMost(10, SECONDS).until(this::getStoredOffset, equalTo(2L));
+        verify(packageBuilder, 
timeout(10000).times(1)).installPackages(any(ResourceResolver.class), 
anyList());
+        verify(packageBuilder, 
times(3)).installPackage(any(ResourceResolver.class), 
any(DistributionPackage.class));
+    }
+
+    @Test
+    public void testInvalidateEndsBatch() throws Exception {
+        recordInstalledBatches();
+        CountDownLatch firstImportStarted = new CountDownLatch(1);
+        CountDownLatch releaseFirstImport = new CountDownLatch(1);
+        when(packageBuilder.installPackage(any(ResourceResolver.class), 
any(DistributionPackage.class)))
+                .thenAnswer(invocation -> {
+                    firstImportStarted.countDown();
+                    releaseFirstImport.await();
+                    return true;
+                });
+        initSubscriber(10);
+
+        packageHandler.handle(new TestMessageInfo("", 1, 0, 0), message("p0", 
ReqType.ADD));
+        assertTrue(firstImportStarted.await(10, SECONDS));
+        packageHandler.handle(new TestMessageInfo("", 1, 1, 0), message("p1", 
ReqType.ADD));
+        packageHandler.handle(new TestMessageInfo("", 1, 2, 0), message("p2", 
ReqType.ADD));
+        packageHandler.handle(new TestMessageInfo("", 1, 3, 0), message("p3", 
ReqType.INVALIDATE));
+        packageHandler.handle(new TestMessageInfo("", 1, 4, 0), message("p4", 
ReqType.ADD));
+        packageHandler.handle(new TestMessageInfo("", 1, 5, 0), message("p5", 
ReqType.ADD));
+        releaseFirstImport.countDown();
+
+        await().atMost(10, SECONDS).until(this::getStoredOffset, equalTo(5L));
+        assertThat(installedBatches, contains(Arrays.asList("p1", "p2"), 
Arrays.asList("p4", "p5")));
+    }
+
+    @Test
+    public void testNoBatchingForEditableAgent() throws Exception {
+        Map<String, String> overrides = new HashMap<>();
+        overrides.put("editable", "true");
+        when(clientProvider.createPoller(Mockito.eq(Topics.COMMAND_TOPIC), 
Mockito.eq(Reset.earliest), any()))
+                .thenReturn(poller);
+        initSubscriber(3, overrides);
+
+        packageHandler.handle(new TestMessageInfo("", 1, 0, 0), message("p0", 
ReqType.ADD));
+        packageHandler.handle(new TestMessageInfo("", 1, 1, 0), message("p1", 
ReqType.ADD));
+
+        assertThat(getStoredOffset(), equalTo(1L));
+        verify(packageBuilder, 
times(2)).installPackage(any(ResourceResolver.class), 
any(DistributionPackage.class));
+        verify(packageBuilder, 
never()).installPackages(any(ResourceResolver.class), anyList());
+    }
+
+    private void recordInstalledBatches() throws DistributionException {
+        doAnswer(invocation -> {
+                    List<DistributionPackage> packages = 
invocation.getArgument(1);
+                    List<String> ids = new ArrayList<>();
+                    for (DistributionPackage pkg : packages) {
+                        ids.add(pkg.getId());
+                    }
+                    installedBatches.add(ids);
+                    return null;
+                })
+                .when(packageBuilder)
+                .installPackages(any(ResourceResolver.class), anyList());
+    }
+
+    private static PackageMessage message(String pkgId, ReqType reqType) {
+        return PackageMessage.builder()
+                .pkgId(pkgId)
+                .pubSlingId("pub1sling")
+                .pubAgentName(PUB1_AGENT_NAME)
+                .reqType(reqType)
+                .pkgType("journal")
+                .paths(Collections.singletonList("/content/" + pkgId))
+                .pkgBinary(new byte[10])
+                .build();
+    }
+
+    private Long getStoredOffset() {
+        LocalStore store = new LocalStore(resolverFactory, 
STORE_PACKAGE_NODE_NAME, SUB1_AGENT_NAME);
+        return store.load(BookKeeper.KEY_OFFSET, Long.class);
+    }
+
+    private void initSubscriber(int maxBatchSize) {
+        initSubscriber(maxBatchSize, Collections.emptyMap());
+    }
+
+    private void initSubscriber(int maxBatchSize, Map<String, String> 
overrides) {
+        Map<String, Object> props = new HashMap<>();
+        props.put("name", SUB1_AGENT_NAME);
+        props.put("agentNames", PUB1_AGENT_NAME);
+        props.put("maxBatchSize", maxBatchSize);
+        props.putAll(overrides);
+        SubscriberConfiguration config =
+                
Converters.standardConverter().convert(props).to(SubscriberConfiguration.class);
+        subscriber = new DistributionSubscriber(
+                packageBuilder,
+                slingSettings,
+                clientProvider,
+                precondition,
+                metricsService,
+                bookKeeperFactory,
+                subscriberReadyStore,
+                new OnlyOnLeader(context),
+                config,
+                context,
+                props);
+        verify(clientProvider)
+                .createPoller(
+                        Mockito.eq(Topics.PACKAGE_TOPIC),
+                        Mockito.eq(Reset.latest),
+                        Mockito.isNull(String.class),
+                        packageCaptor.capture(),
+                        pingCaptor.capture());
+        packageHandler = packageCaptor.getValue().getHandler();
+    }
+}

Reply via email to