SLING-6739 : copied bundles/extensions/event into event/api and event/resources - done via svn cp to preserve history of both new parts
git-svn-id: https://svn.apache.org/repos/asf/sling/trunk@1789290 13f79535-47bb-0310-9956-ffa450edef68
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..33554c7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+jackrabbit
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a017cb5
--- /dev/null
+++ b/README.md
@@ -0,0 +1,85 @@
+# Sling Event (Jobs) bundle.
+
+For user documentation see https://sling.apache.org/documentation/bundles/apache-sling-eventing-and-job-handling.html.
+This README contains information on the bundle, APIs and implementation details.
+
+# Bundle
+
+Sling Event contains support for Jobs. It provides an Api for Job, JobManager and Queue, as well as consumer Apis for a
+JobConsumer. There are ancillary APIs to support the work of these core interfaces. The core APIs are exported from
+org.apache.sling.event.jobs with the consumers exported from org.apache.sling.event.jobs.consumer.
+
+
+# Design and implementation
+
+
+## Processing model
+
+Jobs are created using the JobManager API. When a Job is created the JobManager writes an entry into the resource tree
+(usually backed up by JCR) via the ResourceResolver.
+
+For notification of interested parties - not for processing the jobs(!) - adding a job emits an OSGi
+Event on org/apache/sling/api/resource/Resource/ADDED topic, which is picked up by the
+[NewJobSender](src/main/java/org/apache/sling/event/impl/jobs/notifications/NewJobSender.java) which emits a new OSGi
+Event on the org/apache/sling/event/notification/job/ADDED topic.
+
+Similar other notification events are sent out if the state of a job changes. However these events are just FYI events.
+The events used are contained in [NotificationConstants.java](src/main/java/org/apache/sling/event/jobs/NotificationConstants.java).
+
+The QueueManager which identifies the queue from s job is periodically scanning the resource tree for new jobs. In
+addition it listens to the org/apache/sling/event/notification/job/ADDED topic for optimization and picking up new
+jobs faster (than by scanning). Once a new job is found, the manager triggers the JobQueueImpl to start processing.
+Various other operations ensure that the JobQueueImpl runs jobs according to its configuration. These are either
+periodic maintenance classes or triggered by calls to the QueueManager or triggered by Jobs on the queue changing state.
+
+The queue is maintained by the JobManagerImpl, but each Queue is managed by a JobQueueImpl that receives calls from the
+QueueManager to process jobs. Any thread may update the persisted job state, by resolving the Job name and performing the operation.
+
+## Storage
+
+The JobManager uses the resource tree and therefore by default the JCR repository provided by Oak for persisting Jobs. The
+content tree structure was developed in conjunction with advice from the Oak team to avoid write concurrency issues and the
+need for maintaining in Oak repository locks. To avoid OakMergeConflicts on write, each job gets a UUID. If a new job is
+created on a Sling instance, this instance decides - based on configuration - which Sling instance will process the job
+and writes the new job to an area dedicated to that instance (/var/eventing/jobs/assigned/<SlingID>). Each Sling instance
+only reads and modifies its own subtree under /var/eventing/jobs/assigned/<SlingID>. This prevents more than one instance from
+attempting to process a Job at the same time. This means that when a Job is started, the path of the job is formed from
+the target SlingID and the queue name. The target SlingID is formed from the queue configuration informed
+by properties contained within Topology. Every Sling instance advertises is capabilities for processing jobs via topology,
+hence the JobManager pre-allocates Jobs to instance when the job is created.
+
+The JobQueueImpl then receives the job. The JobQueueImpl only considers jobs allocated to the local instance, and runs
+those jobs.
+
+Since the SlingID is part of the JobID, there is no risk of 2 instances writing to the same job at the same time when the
+job is allocated or re-allocated to an instance. In the case of first allocation, the creating sling instance will perform
+the write operation and the target sling instance wont know about the job until after Oak commits. In the case of re-allocation
+the target sling instance is dead, the cluster leader performs the write and the new target sling instnance wont see the job
+until after Oak commits.
+
+## Topology changes
+
+When a Sling instance in a cluster is shutdown, it will stop processing all the jobs allocated to it. When it shuts down
+a Topology change event propagates and the cluster leader scans all instances under /var/eventing/jobs/assigned/ to see
+if there are any instances that don't exist any more. If there are, the topology leader moves those jobs to a different
+node by deleting the Oak node and writing a new node into the new targetId assigned location. Any jobs that cant be re-assigned
+are written to the unassigned location.
+
+## Known issues with current implementation and design
+
+These issues may have been addressed since this document was written, if they have please remove the known issues.
+
+1. Pre-allocation of jobs to queues bound to instances will not ensure load is distributed amongst available instances
+especially when the queues are large, as jobs complexity and resource requirements vary wildly.
+2. When the topology changes, with many jobs the cost of reallocating jobs may be prohibitive.
+
+
+# Scheduled Jobs.
+
+In addition to one off jobs the bundle has support for scheduled jobs. The schedule is stored in /var/eventing/scheduled-jobs,
+and the cluster leader uses the Sling commons Scheduler service to run a schedules which add jobs to the appropriate queues
+using the job manager. For info see org.apache.sling.event.impl.jobs.scheduling.JobSchedulerImpl.execute which is called by the
+Sling commons Scheduler service.
+
+
+
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..4c12105
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,377 @@
+<?xml version="1.0" encoding="ISO-8859-1"?>
+<!--
+ 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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.apache.sling</groupId>
+ <artifactId>sling</artifactId>
+ <version>30</version>
+ <relativePath />
+ </parent>
+
+ <artifactId>org.apache.sling.event</artifactId>
+ <packaging>bundle</packaging>
+ <version>4.2.3-SNAPSHOT</version>
+
+ <name>Apache Sling Event Support</name>
+ <description>
+ Support for eventing.
+ </description>
+
+ <scm>
+ <connection>scm:svn:http://svn.apache.org/repos/asf/sling/trunk/bundles/extensions/event</connection>
+ <developerConnection>scm:svn:https://svn.apache.org/repos/asf/sling/trunk/bundles/extensions/event</developerConnection>
+ <url>http://svn.apache.org/viewvc/sling/trunk/bundles/extensions/event</url>
+ </scm>
+
+ <properties>
+ <site.jira.version.id>12315369</site.jira.version.id>
+ <exam.version>4.4.0</exam.version>
+ <url.version>2.4.5</url.version>
+ <bundle.build.dir>${basedir}/target</bundle.build.dir>
+ <bundle.file.name>${bundle.build.dir}/${project.build.finalName}.jar</bundle.file.name>
+ <min.port>37000</min.port>
+ <max.port>37999</max.port>
+ </properties>
+
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.felix</groupId>
+ <artifactId>maven-bundle-plugin</artifactId>
+ <extensions>true</extensions>
+ <configuration>
+ <instructions>
+ <Import-Package>
+ javax.servlet;javax.servlet.http;resolution:=optional,
+ org.apache.felix.inventory;resolution:=optional,
+ *
+ </Import-Package>
+ <DynamicImport-Package>
+ javax.servlet,
+ javax.servlet.http,
+ org.apache.felix.inventory
+ </DynamicImport-Package>
+ <Sling-Nodetypes>
+ SLING-INF/nodetypes/event.cnd
+ </Sling-Nodetypes>
+ <Sling-Namespaces>
+ slingevent=http://sling.apache.org/jcr/event/1.0
+ </Sling-Namespaces>
+ <Embed-Dependency>
+ jackrabbit-jcr-commons;inline="org/apache/jackrabbit/util/ISO9075.*|org/apache/jackrabbit/util/ISO8601.*|org/apache/jackrabbit/util/XMLChar.*",
+ org.apache.sling.commons.osgi;inline="org/apache/sling/commons/osgi/PropertiesUtil.*",
+ quartz;inline="org/quartz/CronExpression.*|org/quartz/ValueSet.*"
+ </Embed-Dependency>
+ <_plugin>org.apache.felix.scrplugin.bnd.SCRDescriptorBndPlugin;destdir=${project.build.outputDirectory};</_plugin>
+ </instructions>
+ </configuration>
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.felix</groupId>
+ <artifactId>org.apache.felix.scr.bnd</artifactId>
+ <version>1.7.2</version>
+ </dependency>
+ </dependencies>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.rat</groupId>
+ <artifactId>apache-rat-plugin</artifactId>
+ <configuration>
+ <excludes>
+ <exclude>derby.log</exclude>
+ </excludes>
+ </configuration>
+ </plugin>
+ <!-- plain unit tests -->
+ <plugin>
+ <artifactId>maven-surefire-plugin</artifactId>
+ <configuration>
+ <excludes>
+ <exclude>**/it/**</exclude>
+ </excludes>
+ </configuration>
+ </plugin>
+ <plugin>
+ <groupId>org.codehaus.mojo</groupId>
+ <artifactId>build-helper-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>reserve-network-port</id>
+ <goals>
+ <goal>reserve-network-port</goal>
+ </goals>
+ <phase>pre-integration-test</phase>
+ <configuration>
+ <portNames>
+ <portName>http.port</portName>
+ </portNames>
+ <minPortNumber>${min.port}</minPortNumber>
+ <maxPortNumber>${max.port}</maxPortNumber>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <!-- integration tests run with pax-exam -->
+ <plugin>
+ <artifactId>maven-failsafe-plugin</artifactId>
+ <executions>
+ <execution>
+ <goals>
+ <goal>integration-test</goal>
+ <goal>verify</goal>
+ </goals>
+ </execution>
+ </executions>
+ <configuration>
+ <systemProperties>
+ <property>
+ <name>project.bundle.file</name>
+ <value>${bundle.file.name}</value>
+ </property>
+ <property>
+ <name>bundle.build.dir</name>
+ <value>${bundle.build.dir}</value>
+ </property>
+ <property>
+ <name>org.osgi.service.http.port</name>
+ <value>${http.port}</value>
+ </property>
+ </systemProperties>
+ <argLine>
+ -Xmx2048m -XX:MaxPermSize=512m
+ </argLine>
+ <includes>
+ <include>**/it/*</include>
+ </includes>
+ </configuration>
+ </plugin>
+ <plugin>
+ <artifactId>maven-clean-plugin</artifactId>
+ <configuration>
+ <filesets>
+ <fileset>
+ <directory>${basedir}</directory>
+ <includes>
+ <include>derby.log</include>
+ </includes>
+ </fileset>
+ <fileset>
+ <directory>jackrabbit</directory>
+ </fileset>
+ </filesets>
+ </configuration>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-javadoc-plugin</artifactId>
+ <configuration>
+ <excludePackageNames>org.apache.sling.event.impl:org.apache.sling.event.impl.jobs:org.apache.sling.event.impl.jobs.config:org.apache.sling.event.impl.jobs.console:org.apache.sling.event.impl.jobs.jmx:org.apache.sling.event.impl.jobs.notifications:org.apache.sling.event.impl.jobs.queues:org.apache.sling.event.impl.jobs.scheduling:org.apache.sling.event.impl.jobs.stats:org.apache.sling.event.impl.jobs.tasks:org.apache.sling.event.impl.support</excludePackageNames>
+ </configuration>
+ </plugin>
+ </plugins>
+ </build>
+ <profiles>
+ <profile>
+ <id>port-java8</id>
+ <activation>
+ <activeByDefault>false</activeByDefault>
+ <jdk>1.8</jdk>
+ </activation>
+ <properties>
+ <min.port>38000</min.port>
+ <max.port>38999</max.port>
+ </properties>
+ </profile>
+ </profiles>
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.sling</groupId>
+ <artifactId>org.apache.sling.discovery.api</artifactId>
+ <version>1.0.0</version>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.felix</groupId>
+ <artifactId>org.apache.felix.inventory</artifactId>
+ <version>1.0.0</version>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-api</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.osgi</groupId>
+ <artifactId>osgi.core</artifactId>
+ <version>6.0.0</version>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.osgi</groupId>
+ <artifactId>org.osgi.service.event</artifactId>
+ <version>1.3.1</version>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.felix</groupId>
+ <artifactId>org.apache.felix.scr.annotations</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.sling</groupId>
+ <artifactId>org.apache.sling.settings</artifactId>
+ <version>1.0.0</version>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.sling</groupId>
+ <artifactId>org.apache.sling.api</artifactId>
+ <version>2.11.0</version>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.sling</groupId>
+ <artifactId>org.apache.sling.commons.osgi</artifactId>
+ <version>2.1.0</version>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.sling</groupId>
+ <artifactId>org.apache.sling.commons.scheduler</artifactId>
+ <version>2.4.0</version>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.sling</groupId>
+ <artifactId>org.apache.sling.commons.threads</artifactId>
+ <version>3.1.0</version>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.sling</groupId>
+ <artifactId>org.apache.sling.discovery.commons</artifactId>
+ <version>1.0.12</version>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.sling</groupId>
+ <artifactId>org.apache.sling.serviceusermapper</artifactId>
+ <version>1.2.0</version>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.jackrabbit</groupId>
+ <artifactId>jackrabbit-jcr-commons</artifactId>
+ <version>2.11.2</version>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.quartz-scheduler</groupId>
+ <artifactId>quartz</artifactId>
+ <version>2.2.0</version>
+ <scope>provided</scope>
+ </dependency>
+ <!-- Webconsole -->
+ <dependency>
+ <groupId>javax.servlet</groupId>
+ <artifactId>javax.servlet-api</artifactId>
+ </dependency>
+ <!-- Testing -->
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-simple</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.osgi</groupId>
+ <artifactId>org.osgi.service.cm</artifactId>
+ <version>1.5.0</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-all</artifactId>
+ <version>1.10.19</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.sling</groupId>
+ <artifactId>org.apache.sling.testing.tools</artifactId>
+ <version>1.0.6</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.sling</groupId>
+ <artifactId>org.apache.sling.testing.sling-mock</artifactId>
+ <version>1.6.0</version>
+ <scope>test</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>org.ops4j.pax.exam</groupId>
+ <artifactId>pax-exam-container-forked</artifactId>
+ <version>${exam.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>org.ops4j.pax.exam</groupId>
+ <artifactId>pax-exam-junit4</artifactId>
+ <version>${exam.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>org.ops4j.pax.exam</groupId>
+ <artifactId>pax-exam-link-mvn</artifactId>
+ <version>${exam.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>org.ops4j.pax.exam</groupId>
+ <artifactId>pax-exam-cm</artifactId>
+ <version>${exam.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>org.ops4j.pax.url</groupId>
+ <artifactId>pax-url-aether</artifactId>
+ <version>${url.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>org.apache.felix</groupId>
+ <artifactId>org.apache.felix.framework</artifactId>
+ <version>5.4.0</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>javax.inject</groupId>
+ <artifactId>javax.inject</artifactId>
+ <version>1</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+</project>
diff --git a/src/main/java/org/apache/sling/event/impl/EnvironmentComponent.java b/src/main/java/org/apache/sling/event/impl/EnvironmentComponent.java
new file mode 100644
index 0000000..b3f7929
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/EnvironmentComponent.java
@@ -0,0 +1,82 @@
+/*
+ * 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.event.impl;
+
+import org.apache.felix.scr.annotations.Activate;
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.Deactivate;
+import org.apache.felix.scr.annotations.Reference;
+import org.apache.felix.scr.annotations.Service;
+import org.apache.sling.commons.threads.ThreadPool;
+import org.apache.sling.event.impl.support.Environment;
+import org.apache.sling.settings.SlingSettingsService;
+
+/**
+ * Environment component. This component provides "global settings"
+ * to all services, like the application id and the thread pool.
+ * @since 3.0
+ *
+ * This component needs to be immediate to set the global variables
+ * (application id and thread pool).
+ */
+@Component(immediate=true)
+@Service(value=EnvironmentComponent.class)
+public class EnvironmentComponent {
+
+ /**
+ * Our thread pool.
+ */
+ @Reference(referenceInterface=EventingThreadPool.class)
+ private ThreadPool threadPool;
+
+ /** Sling settings service. */
+ @Reference
+ private SlingSettingsService settingsService;
+
+ /**
+ * Activate this component.
+ */
+ @Activate
+ protected void activate() {
+ // Set the application id and the thread pool
+ Environment.APPLICATION_ID = this.settingsService.getSlingId();
+ Environment.THREAD_POOL = this.threadPool;
+ }
+
+ /**
+ * Deactivate this component.
+ */
+ @Deactivate
+ protected void deactivate() {
+ // Unset the thread pool
+ if ( Environment.THREAD_POOL == this.threadPool ) {
+ Environment.THREAD_POOL = null;
+ }
+ }
+
+ protected void bindThreadPool(final EventingThreadPool etp) {
+ this.threadPool = etp;
+ }
+
+ protected void unbindThreadPool(final EventingThreadPool etp) {
+ if ( this.threadPool == etp ) {
+ this.threadPool = null;
+ }
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/EventingThreadPool.java b/src/main/java/org/apache/sling/event/impl/EventingThreadPool.java
new file mode 100644
index 0000000..0821866
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/EventingThreadPool.java
@@ -0,0 +1,129 @@
+/*
+ * 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.event.impl;
+
+import java.util.Map;
+
+import org.apache.felix.scr.annotations.Activate;
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.Deactivate;
+import org.apache.felix.scr.annotations.Modified;
+import org.apache.felix.scr.annotations.Property;
+import org.apache.felix.scr.annotations.Reference;
+import org.apache.felix.scr.annotations.Service;
+import org.apache.sling.commons.osgi.PropertiesUtil;
+import org.apache.sling.commons.threads.ModifiableThreadPoolConfig;
+import org.apache.sling.commons.threads.ThreadPool;
+import org.apache.sling.commons.threads.ThreadPoolConfig;
+import org.apache.sling.commons.threads.ThreadPoolConfig.ThreadPriority;
+import org.apache.sling.commons.threads.ThreadPoolManager;
+
+
+/**
+ * The configurable eventing thread pool.
+ */
+@Component(label="Apache Sling Job Thread Pool",
+ description="This is the thread pool used by the Apache Sling job handling. The "
+ + "threads from this pool are merely used for executing jobs. By limiting this pool, it is "
+ + "possible to limit the maximum number of parallel processed jobs - regardless of the queue "
+ + "configuration.",
+ metatype=true)
+@Service(value=EventingThreadPool.class)
+public class EventingThreadPool implements ThreadPool {
+
+ @Reference
+ private ThreadPoolManager threadPoolManager;
+
+ /** The real thread pool used. */
+ private org.apache.sling.commons.threads.ThreadPool threadPool;
+
+ private static final int DEFAULT_POOL_SIZE = 35;
+
+ @Property(intValue=DEFAULT_POOL_SIZE,
+ label="Pool Size",
+ description="The size of the thread pool. This pool is used to execute jobs and therefore "
+ + "limits the maximum number of jobs executed in parallel.")
+ private static final String PROPERTY_POOL_SIZE = "minPoolSize";
+
+ public EventingThreadPool() {
+ // default constructor
+ }
+
+ public EventingThreadPool(final ThreadPoolManager tpm, final int poolSize) {
+ this.threadPoolManager = tpm;
+ this.configure(poolSize);
+ }
+
+ public void release() {
+ this.deactivate();
+ }
+
+ /**
+ * Activate this component.
+ */
+ @Activate
+ @Modified
+ protected void activate(final Map<String, Object> props) {
+ final int maxPoolSize = PropertiesUtil.toInteger(props.get(PROPERTY_POOL_SIZE), DEFAULT_POOL_SIZE);
+ this.configure(maxPoolSize);
+ }
+
+ private void configure(final int maxPoolSize) {
+ final ModifiableThreadPoolConfig config = new ModifiableThreadPoolConfig();
+ config.setMinPoolSize(maxPoolSize);
+ config.setMaxPoolSize(config.getMinPoolSize());
+ config.setQueueSize(-1); // unlimited
+ config.setShutdownGraceful(true);
+ config.setPriority(ThreadPriority.NORM);
+ config.setDaemon(true);
+ this.threadPool = threadPoolManager.create(config, "Apache Sling Job Thread Pool");
+ }
+
+ /**
+ * Deactivate this component.
+ */
+ @Deactivate
+ protected void deactivate() {
+ this.threadPoolManager.release(this.threadPool);
+ }
+
+ /**
+ * @see org.apache.sling.commons.threads.ThreadPool#execute(java.lang.Runnable)
+ */
+ @Override
+ public void execute(final Runnable runnable) {
+ threadPool.execute(runnable);
+ }
+
+ /**
+ * @see org.apache.sling.commons.threads.ThreadPool#getConfiguration()
+ */
+ @Override
+ public ThreadPoolConfig getConfiguration() {
+ return threadPool.getConfiguration();
+ }
+
+ /**
+ * @see org.apache.sling.commons.threads.ThreadPool#getName()
+ */
+ @Override
+ public String getName() {
+ return threadPool.getName();
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/InternalJobState.java b/src/main/java/org/apache/sling/event/impl/jobs/InternalJobState.java
new file mode 100644
index 0000000..6fe1994
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/InternalJobState.java
@@ -0,0 +1,42 @@
+/*
+ * 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.event.impl.jobs;
+
+import org.apache.sling.event.jobs.NotificationConstants;
+import org.apache.sling.event.jobs.consumer.JobExecutor;
+
+/**
+ * The state of the job after it has been processed by a {@link JobExecutor}.
+ */
+public enum InternalJobState {
+
+ SUCCEEDED(NotificationConstants.TOPIC_JOB_FINISHED), // processing finished successfully
+ FAILED(NotificationConstants.TOPIC_JOB_FAILED), // processing failed, can be retried
+ CANCELLED(NotificationConstants.TOPIC_JOB_CANCELLED); // processing failed permanently
+
+ private final String topic;
+
+ InternalJobState(final String topic) {
+ this.topic = topic;
+ }
+
+ public String getTopic() {
+ return this.topic;
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/JobBuilderImpl.java b/src/main/java/org/apache/sling/event/impl/jobs/JobBuilderImpl.java
new file mode 100644
index 0000000..a662066
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/JobBuilderImpl.java
@@ -0,0 +1,70 @@
+/*
+ * 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.event.impl.jobs;
+
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import org.apache.sling.event.impl.jobs.scheduling.JobScheduleBuilderImpl;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.JobBuilder;
+
+/**
+ * Fluent builder API
+ */
+public class JobBuilderImpl implements JobBuilder {
+
+ private final String topic;
+
+ private final JobManagerImpl jobManager;
+
+ private Map<String, Object> properties;
+
+ public JobBuilderImpl(final JobManagerImpl manager, final String topic) {
+ this.jobManager = manager;
+ this.topic = topic;
+ }
+
+
+ @Override
+ public JobBuilder properties(final Map<String, Object> props) {
+ this.properties = props;
+ return this;
+ }
+
+ @Override
+ public Job add() {
+ return this.add(null);
+ }
+
+ @Override
+ public Job add(final List<String> errors) {
+ return this.jobManager.addJob(this.topic, this.properties, errors);
+ }
+
+ @Override
+ public ScheduleBuilder schedule() {
+ return new JobScheduleBuilderImpl(
+ this.topic,
+ this.properties,
+ UUID.randomUUID().toString(),
+ this.jobManager.getJobScheduler());
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/JobConsumerManager.java b/src/main/java/org/apache/sling/event/impl/jobs/JobConsumerManager.java
new file mode 100644
index 0000000..08660e8
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/JobConsumerManager.java
@@ -0,0 +1,511 @@
+/*
+ * 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.event.impl.jobs;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Dictionary;
+import java.util.HashMap;
+import java.util.Hashtable;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.felix.scr.annotations.Activate;
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.Deactivate;
+import org.apache.felix.scr.annotations.Modified;
+import org.apache.felix.scr.annotations.Property;
+import org.apache.felix.scr.annotations.PropertyUnbounded;
+import org.apache.felix.scr.annotations.Reference;
+import org.apache.felix.scr.annotations.ReferenceCardinality;
+import org.apache.felix.scr.annotations.ReferencePolicy;
+import org.apache.felix.scr.annotations.References;
+import org.apache.felix.scr.annotations.Service;
+import org.apache.sling.commons.osgi.PropertiesUtil;
+import org.apache.sling.discovery.PropertyProvider;
+import org.apache.sling.event.impl.jobs.config.TopologyCapabilities;
+import org.apache.sling.event.impl.support.TopicMatcher;
+import org.apache.sling.event.impl.support.TopicMatcherHelper;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.consumer.JobConsumer;
+import org.apache.sling.event.jobs.consumer.JobConsumer.JobResult;
+import org.apache.sling.event.jobs.consumer.JobExecutionContext;
+import org.apache.sling.event.jobs.consumer.JobExecutionResult;
+import org.apache.sling.event.jobs.consumer.JobExecutor;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.Constants;
+import org.osgi.framework.ServiceReference;
+import org.osgi.framework.ServiceRegistration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This component manages/keeps track of all job consumer services.
+ */
+@Component(label="Apache Sling Job Consumer Manager",
+ description="The consumer manager controls the job consumer (= processors). "
+ + "It can be used to temporarily disable job processing on the current instance. Other instances "
+ + "in a cluster are not affected.",
+ metatype=true)
+@Service(value=JobConsumerManager.class)
+@References({
+ @Reference(referenceInterface=JobConsumer.class,
+ cardinality=ReferenceCardinality.OPTIONAL_MULTIPLE,
+ policy=ReferencePolicy.DYNAMIC),
+ @Reference(referenceInterface=JobExecutor.class,
+ cardinality=ReferenceCardinality.OPTIONAL_MULTIPLE,
+ policy=ReferencePolicy.DYNAMIC)
+})
+@Property(name="org.apache.sling.installer.configuration.persist", boolValue=false,
+ label="Distribute config",
+ description="If this is disabled, the configuration is not persisted on save in the cluster and is "
+ + "only used on the current instance. This option should always be disabled!")
+public class JobConsumerManager {
+
+ private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+ @Property(unbounded=PropertyUnbounded.ARRAY, value = "*",
+ label="Topic Whitelist",
+ description="This is a list of topics which currently should be "
+ + "processed by this instance. Leaving it empty, all job consumers are disabled. Putting a '*' as "
+ + "one entry, enables all job consumers. Adding separate topics enables job consumers for exactly "
+ + "this topic.")
+ private static final String PROPERTY_WHITELIST = "job.consumermanager.whitelist";
+
+ @Property(unbounded=PropertyUnbounded.ARRAY,
+ label="Topic Blacklist",
+ description="This is a list of topics which currently shouldn't be "
+ + "processed by this instance. Leaving it empty, all job consumers are enabled. Putting a '*' as "
+ + "one entry, disables all job consumers. Adding separate topics disables job consumers for exactly "
+ + "this topic.")
+ private static final String PROPERTY_BLACKLIST = "job.consumermanager.blacklist";
+
+ /** The map with the consumers, keyed by topic, sorted by service ranking. */
+ private final Map<String, List<ConsumerInfo>> topicToConsumerMap = new HashMap<String, List<ConsumerInfo>>();
+
+ /** ServiceRegistration for propagation. */
+ private ServiceRegistration propagationService;
+
+ private String topics;
+
+ private TopicMatcher[] whitelistMatchers;
+
+ private TopicMatcher[] blacklistMatchers;
+
+ private volatile long changeCount;
+
+ private BundleContext bundleContext;
+
+ private final Map<String, Object[]> listenerMap = new HashMap<String, Object[]>();
+
+ private Dictionary<String, Object> getRegistrationProperties() {
+ final Dictionary<String, Object> serviceProps = new Hashtable<String, Object>();
+ serviceProps.put(PropertyProvider.PROPERTY_PROPERTIES, TopologyCapabilities.PROPERTY_TOPICS);
+ // we add a changing property to the service registration
+ // to make sure a modification event is really sent
+ synchronized ( this ) {
+ serviceProps.put("changeCount", this.changeCount++);
+ }
+ return serviceProps;
+ }
+
+ @Activate
+ protected void activate(final BundleContext bc, final Map<String, Object> props) {
+ this.bundleContext = bc;
+ this.modified(bc, props);
+ }
+
+ @Modified
+ protected void modified(final BundleContext bc, final Map<String, Object> props) {
+ final boolean wasEnabled = this.propagationService != null;
+ this.whitelistMatchers = TopicMatcherHelper.buildMatchers(PropertiesUtil.toStringArray(props.get(PROPERTY_WHITELIST)));
+ this.blacklistMatchers = TopicMatcherHelper.buildMatchers(PropertiesUtil.toStringArray(props.get(PROPERTY_BLACKLIST)));
+
+ final boolean enable = this.whitelistMatchers != null && this.blacklistMatchers != TopicMatcherHelper.MATCH_ALL;
+ if ( wasEnabled != enable ) {
+ synchronized ( this.topicToConsumerMap ) {
+ this.calculateTopics(enable);
+ }
+ if ( enable ) {
+ logger.debug("Registering property provider with: {}", this.topics);
+ this.propagationService = bc.registerService(PropertyProvider.class.getName(),
+ new PropertyProvider() {
+
+ @Override
+ public String getProperty(final String name) {
+ if ( TopologyCapabilities.PROPERTY_TOPICS.equals(name) ) {
+ return topics;
+ }
+ return null;
+ }
+ }, this.getRegistrationProperties());
+ } else {
+ logger.debug("Unregistering property provider with");
+ this.propagationService.unregister();
+ this.propagationService = null;
+ }
+ } else if ( enable ) {
+ // update properties
+ synchronized ( this.topicToConsumerMap ) {
+ this.calculateTopics(true);
+ }
+ logger.debug("Updating property provider with: {}", this.topics);
+ this.propagationService.setProperties(this.getRegistrationProperties());
+ }
+ }
+
+ @Deactivate
+ protected void deactivate() {
+ if ( this.propagationService != null ) {
+ this.propagationService.unregister();
+ this.propagationService = null;
+ }
+ this.bundleContext = null;
+ synchronized ( this.topicToConsumerMap ) {
+ this.topicToConsumerMap.clear();
+ this.listenerMap.clear();
+ }
+ }
+
+ /**
+ * Get the executor for the topic.
+ * @param topic The job topic
+ * @return A consumer or <code>null</code>
+ */
+ public JobExecutor getExecutor(final String topic) {
+ synchronized ( this.topicToConsumerMap ) {
+ final List<ConsumerInfo> consumers = this.topicToConsumerMap.get(topic);
+ if ( consumers != null ) {
+ return consumers.get(0).getExecutor(this.bundleContext);
+ }
+ int pos = topic.lastIndexOf('/');
+ if ( pos > 0 ) {
+ final String category = topic.substring(0, pos + 1).concat("*");
+ final List<ConsumerInfo> categoryConsumers = this.topicToConsumerMap.get(category);
+ if ( categoryConsumers != null ) {
+ return categoryConsumers.get(0).getExecutor(this.bundleContext);
+ }
+
+ // search deep consumers (since 1.2 of the consumer package)
+ do {
+ final String subCategory = topic.substring(0, pos + 1).concat("**");
+ final List<ConsumerInfo> subCategoryConsumers = this.topicToConsumerMap.get(subCategory);
+ if ( subCategoryConsumers != null ) {
+ return subCategoryConsumers.get(0).getExecutor(this.bundleContext);
+ }
+ pos = topic.lastIndexOf('/', pos - 1);
+ } while ( pos > 0 );
+ }
+ }
+ return null;
+ }
+
+ public void registerListener(final String key, final JobExecutor consumer, final JobExecutionContext handler) {
+ synchronized ( this.topicToConsumerMap ) {
+ this.listenerMap.put(key, new Object[] {consumer, handler});
+ }
+ }
+
+ public void unregisterListener(final String key) {
+ synchronized ( this.topicToConsumerMap ) {
+ this.listenerMap.remove(key);
+ }
+ }
+
+ /**
+ * Return the topics information of this instance.
+ */
+ public String getTopics() {
+ return this.topics;
+ }
+
+ /**
+ * Bind a new consumer
+ * @param serviceReference The service reference to the consumer.
+ */
+ protected void bindJobConsumer(final ServiceReference serviceReference) {
+ this.bindService(serviceReference, true);
+ }
+
+ /**
+ * Unbind a consumer
+ * @param serviceReference The service reference to the consumer.
+ */
+ protected void unbindJobConsumer(final ServiceReference serviceReference) {
+ this.unbindService(serviceReference, true);
+ }
+
+ /**
+ * Bind a new executor
+ * @param serviceReference The service reference to the executor.
+ */
+ protected void bindJobExecutor(final ServiceReference serviceReference) {
+ this.bindService(serviceReference, false);
+ }
+
+ /**
+ * Unbind a executor
+ * @param serviceReference The service reference to the executor.
+ */
+ protected void unbindJobExecutor(final ServiceReference serviceReference) {
+ this.unbindService(serviceReference, false);
+ }
+
+ /**
+ * Bind a consumer or executor
+ * @param serviceReference The service reference to the consumer or executor.
+ * @param isConsumer Indicating whether this is a JobConsumer or JobExecutor
+ */
+ private void bindService(final ServiceReference serviceReference, final boolean isConsumer) {
+ final String[] topics = PropertiesUtil.toStringArray(serviceReference.getProperty(JobConsumer.PROPERTY_TOPICS));
+ if ( topics != null && topics.length > 0 ) {
+ final ConsumerInfo info = new ConsumerInfo(serviceReference, isConsumer);
+ boolean changed = false;
+ synchronized ( this.topicToConsumerMap ) {
+ for(final String t : topics) {
+ if ( t != null ) {
+ final String topic = t.trim();
+ if ( topic.length() > 0 ) {
+ List<ConsumerInfo> consumers = this.topicToConsumerMap.get(topic);
+ if ( consumers == null ) {
+ consumers = new ArrayList<JobConsumerManager.ConsumerInfo>();
+ this.topicToConsumerMap.put(topic, consumers);
+ changed = true;
+ }
+ consumers.add(info);
+ Collections.sort(consumers);
+ }
+ }
+ }
+ if ( changed ) {
+ this.calculateTopics(this.propagationService != null);
+ }
+ }
+ if ( changed && this.propagationService != null ) {
+ logger.debug("Updating property provider with: {}", this.topics);
+ this.propagationService.setProperties(this.getRegistrationProperties());
+ }
+ }
+ }
+
+ /**
+ * Unbind a consumer or executor
+ * @param serviceReference The service reference to the consumer or executor.
+ * @param isConsumer Indicating whether this is a JobConsumer or JobExecutor
+ */
+ private void unbindService(final ServiceReference serviceReference, final boolean isConsumer) {
+ final String[] topics = PropertiesUtil.toStringArray(serviceReference.getProperty(JobConsumer.PROPERTY_TOPICS));
+ if ( topics != null && topics.length > 0 ) {
+ final ConsumerInfo info = new ConsumerInfo(serviceReference, isConsumer);
+ boolean changed = false;
+ synchronized ( this.topicToConsumerMap ) {
+ for(final String t : topics) {
+ if ( t != null ) {
+ final String topic = t.trim();
+ if ( topic.length() > 0 ) {
+ final List<ConsumerInfo> consumers = this.topicToConsumerMap.get(topic);
+ if ( consumers != null ) { // sanity check
+ for(final ConsumerInfo oldConsumer : consumers) {
+ if ( oldConsumer.equals(info) && oldConsumer.executor != null ) {
+ // notify listener
+ for(final Object[] listenerObjects : this.listenerMap.values()) {
+ if ( listenerObjects[0] == oldConsumer.executor ) {
+ final JobExecutionContext context = (JobExecutionContext)listenerObjects[1];
+ context.asyncProcessingFinished(context.result().failed());
+ break;
+ }
+ }
+ }
+ }
+ consumers.remove(info);
+ if ( consumers.size() == 0 ) {
+ this.topicToConsumerMap.remove(topic);
+ changed = true;
+ }
+ }
+ }
+ }
+ }
+ if ( changed ) {
+ this.calculateTopics(this.propagationService != null);
+ }
+ }
+ if ( changed && this.propagationService != null ) {
+ logger.debug("Updating property provider with: {}", this.topics);
+ this.propagationService.setProperties(this.getRegistrationProperties());
+ }
+ }
+ }
+
+ private boolean match(final String topic, final TopicMatcher[] matchers) {
+ for(final TopicMatcher m : matchers) {
+ if ( m.match(topic) != null ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void calculateTopics(final boolean enabled) {
+ if ( enabled ) {
+ // create a sorted list - this ensures that the property value
+ // is always the same for the same topics.
+ final List<String> topicList = new ArrayList<String>();
+ for(final String topic : this.topicToConsumerMap.keySet() ) {
+ // check whitelist
+ if ( this.match(topic, this.whitelistMatchers) ) {
+ // and blacklist
+ if ( this.blacklistMatchers == null || !this.match(topic, this.blacklistMatchers) ) {
+ topicList.add(topic);
+ }
+ }
+ }
+ Collections.sort(topicList);
+
+ final StringBuilder sb = new StringBuilder();
+ boolean first = true;
+ for(final String topic : topicList ) {
+ if ( first ) {
+ first = false;
+ } else {
+ sb.append(',');
+ }
+ sb.append(topic);
+ }
+ this.topics = sb.toString();
+ } else {
+ this.topics = null;
+ }
+ }
+
+ /**
+ * Internal class caching some consumer infos like service id and ranking.
+ */
+ private final static class ConsumerInfo implements Comparable<ConsumerInfo> {
+
+ public final ServiceReference serviceReference;
+ private final boolean isConsumer;
+ public JobExecutor executor;
+ public final int ranking;
+ public final long serviceId;
+
+ public ConsumerInfo(final ServiceReference serviceReference, final boolean isConsumer) {
+ this.serviceReference = serviceReference;
+ this.isConsumer = isConsumer;
+ final Object sr = serviceReference.getProperty(Constants.SERVICE_RANKING);
+ if ( sr == null || !(sr instanceof Integer)) {
+ this.ranking = 0;
+ } else {
+ this.ranking = (Integer)sr;
+ }
+ this.serviceId = (Long)serviceReference.getProperty(Constants.SERVICE_ID);
+ }
+
+ @Override
+ public int compareTo(final ConsumerInfo o) {
+ if ( this.ranking < o.ranking ) {
+ return 1;
+ } else if (this.ranking > o.ranking ) {
+ return -1;
+ }
+ // If ranks are equal, then sort by service id in descending order.
+ return (this.serviceId < o.serviceId) ? -1 : 1;
+ }
+
+ @Override
+ public boolean equals(final Object obj) {
+ if ( obj instanceof ConsumerInfo ) {
+ return ((ConsumerInfo)obj).serviceId == this.serviceId;
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return serviceReference.hashCode();
+ }
+
+ public JobExecutor getExecutor(final BundleContext bundleContext) {
+ if ( executor == null ) {
+ if ( this.isConsumer ) {
+ executor = new JobConsumerWrapper((JobConsumer) bundleContext.getService(this.serviceReference));
+ } else {
+ executor = (JobExecutor) bundleContext.getService(this.serviceReference);
+ }
+ }
+ return executor;
+ }
+ }
+
+ private final static class JobConsumerWrapper implements JobExecutor {
+
+ private final JobConsumer consumer;
+
+ public JobConsumerWrapper(final JobConsumer consumer) {
+ this.consumer = consumer;
+ }
+
+ @Override
+ public JobExecutionResult process(final Job job, final JobExecutionContext context) {
+ final JobConsumer.AsyncHandler asyncHandler =
+ new JobConsumer.AsyncHandler() {
+
+ final Object asyncLock = new Object();
+ final AtomicBoolean asyncDone = new AtomicBoolean(false);
+
+ private void check(final JobExecutionResult result) {
+ synchronized ( asyncLock ) {
+ if ( !asyncDone.get() ) {
+ asyncDone.set(true);
+ context.asyncProcessingFinished(result);
+ } else {
+ throw new IllegalStateException("Job is already marked as processed");
+ }
+ }
+ }
+
+ @Override
+ public void ok() {
+ this.check(context.result().succeeded());
+ }
+
+ @Override
+ public void failed() {
+ this.check(context.result().failed());
+ }
+
+ @Override
+ public void cancel() {
+ this.check(context.result().cancelled());
+ }
+ };
+ ((JobImpl)job).setProperty(JobConsumer.PROPERTY_JOB_ASYNC_HANDLER, asyncHandler);
+ final JobConsumer.JobResult result = this.consumer.process(job);
+ if ( result == JobResult.ASYNC ) {
+ return null;
+ } else if ( result == JobResult.FAILED) {
+ return context.result().failed();
+ } else if ( result == JobResult.OK) {
+ return context.result().succeeded();
+ }
+ return context.result().cancelled();
+ }
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/JobHandler.java b/src/main/java/org/apache/sling/event/impl/jobs/JobHandler.java
new file mode 100644
index 0000000..6337b6c
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/JobHandler.java
@@ -0,0 +1,284 @@
+/*
+ * 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.event.impl.jobs;
+
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.sling.api.resource.ModifiableValueMap;
+import org.apache.sling.api.resource.PersistenceException;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.ValueMap;
+import org.apache.sling.event.impl.jobs.config.JobManagerConfiguration;
+import org.apache.sling.event.impl.jobs.config.QueueConfigurationManager.QueueInfo;
+import org.apache.sling.event.impl.jobs.config.TopologyCapabilities;
+import org.apache.sling.event.impl.support.ResourceHelper;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.Queue;
+import org.apache.sling.event.jobs.consumer.JobExecutor;
+
+
+/**
+ * This object adds actions to a {@link JobImpl}.
+ */
+public class JobHandler {
+
+ private final JobImpl job;
+
+ public volatile long started = -1;
+
+ private volatile boolean isStopped = false;
+
+ private final JobManagerConfiguration configuration;
+
+ private final JobExecutor consumer;
+
+ public JobHandler(final JobImpl job,
+ final JobExecutor consumer,
+ final JobManagerConfiguration configuration) {
+ this.job = job;
+ this.consumer = consumer;
+ this.configuration = configuration;
+ }
+
+ public JobImpl getJob() {
+ return this.job;
+ }
+
+ public JobExecutor getConsumer() {
+ return this.consumer;
+ }
+
+ public boolean startProcessing(final Queue queue) {
+ this.isStopped = false;
+ return this.persistJobProperties(this.job.prepare(queue));
+ }
+
+ /**
+ * Reschedule the job
+ * Update the retry count and remove the started time.
+ * @return <code>true</code> if rescheduling was successful, <code>false</code> otherwise.
+ */
+ public boolean reschedule() {
+ final ResourceResolver resolver = this.configuration.createResourceResolver();
+ try {
+ final Resource jobResource = resolver.getResource(job.getResourcePath());
+ if ( jobResource != null ) {
+ final ModifiableValueMap mvm = jobResource.adaptTo(ModifiableValueMap.class);
+ mvm.put(Job.PROPERTY_JOB_RETRY_COUNT, job.getProperty(Job.PROPERTY_JOB_RETRY_COUNT, Integer.class));
+ if ( job.getProperty(Job.PROPERTY_RESULT_MESSAGE) != null ) {
+ mvm.put(Job.PROPERTY_RESULT_MESSAGE, job.getProperty(Job.PROPERTY_RESULT_MESSAGE));
+ }
+ mvm.remove(Job.PROPERTY_JOB_STARTED_TIME);
+ mvm.put(JobImpl.PROPERTY_JOB_QUEUED, Calendar.getInstance());
+ try {
+ resolver.commit();
+ return true;
+ } catch ( final PersistenceException pe ) {
+ this.configuration.getMainLogger().debug("Unable to update reschedule properties for job " + job.getId(), pe);
+ }
+ }
+ } finally {
+ resolver.close();
+ }
+
+ return false;
+ }
+
+ /**
+ * Finish a job.
+ * @param state The state of the processing
+ * @param keepJobInHistory whether to keep the job in the job history.
+ * @param duration the duration of the processing.
+ */
+ public void finished(final Job.JobState state,
+ final boolean keepJobInHistory,
+ final Long duration) {
+ final boolean isSuccess = (state == Job.JobState.SUCCEEDED);
+ final ResourceResolver resolver = this.configuration.createResourceResolver();
+ try {
+ final Resource jobResource = resolver.getResource(job.getResourcePath());
+ if ( jobResource != null ) {
+ try {
+ String newPath = null;
+ if ( keepJobInHistory ) {
+ final ValueMap vm = ResourceHelper.getValueMap(jobResource);
+ newPath = this.configuration.getStoragePath(job.getTopic(), job.getId(), isSuccess);
+ final Map<String, Object> props = new HashMap<String, Object>(vm);
+ props.put(JobImpl.PROPERTY_FINISHED_STATE, state.name());
+ if ( isSuccess ) {
+ // we set the finish date to start date + duration
+ final Date finishDate = new Date();
+ finishDate.setTime(job.getProcessingStarted().getTime().getTime() + duration);
+ final Calendar finishCal = Calendar.getInstance();
+ finishCal.setTime(finishDate);
+ props.put(JobImpl.PROPERTY_FINISHED_DATE, finishCal);
+ } else {
+ // current time is good enough
+ props.put(JobImpl.PROPERTY_FINISHED_DATE, Calendar.getInstance());
+ }
+ if ( job.getProperty(Job.PROPERTY_RESULT_MESSAGE) != null ) {
+ props.put(Job.PROPERTY_RESULT_MESSAGE, job.getProperty(Job.PROPERTY_RESULT_MESSAGE));
+ }
+ ResourceHelper.getOrCreateResource(resolver, newPath, props);
+ }
+ resolver.delete(jobResource);
+ resolver.commit();
+
+ if ( keepJobInHistory && configuration.getMainLogger().isDebugEnabled() ) {
+ if ( isSuccess ) {
+ configuration.getMainLogger().debug("Kept successful job {} at {}", Utility.toString(job), newPath);
+ } else {
+ configuration.getMainLogger().debug("Moved cancelled job {} to {}", Utility.toString(job), newPath);
+ }
+ }
+ } catch ( final PersistenceException pe ) {
+ this.configuration.getMainLogger().warn("Unable to finish job " + job.getId(), pe);
+ } catch (final InstantiationException ie) {
+ // something happened with the resource in the meantime
+ this.configuration.getMainLogger().debug("Unable to instantiate job", ie);
+ }
+ }
+ } finally {
+ resolver.close();
+ }
+ }
+
+ /**
+ * Reassign to a new instance.
+ */
+ public void reassign() {
+ final QueueInfo queueInfo = this.configuration.getQueueConfigurationManager().getQueueInfo(job.getTopic());
+ // Sanity check if queue configuration has changed
+ final TopologyCapabilities caps = this.configuration.getTopologyCapabilities();
+ final String targetId = (caps == null ? null : caps.detectTarget(job.getTopic(), job.getProperties(), queueInfo));
+
+ final ResourceResolver resolver = this.configuration.createResourceResolver();
+ try {
+ final Resource jobResource = resolver.getResource(job.getResourcePath());
+ if ( jobResource != null ) {
+ try {
+ final ValueMap vm = ResourceHelper.getValueMap(jobResource);
+ final String newPath = this.configuration.getUniquePath(targetId, job.getTopic(), job.getId(), job.getProperties());
+
+ final Map<String, Object> props = new HashMap<String, Object>(vm);
+ props.remove(Job.PROPERTY_JOB_QUEUE_NAME);
+ if ( targetId == null ) {
+ props.remove(Job.PROPERTY_JOB_TARGET_INSTANCE);
+ } else {
+ props.put(Job.PROPERTY_JOB_TARGET_INSTANCE, targetId);
+ }
+ props.remove(Job.PROPERTY_JOB_STARTED_TIME);
+
+ try {
+ ResourceHelper.getOrCreateResource(resolver, newPath, props);
+ resolver.delete(jobResource);
+ resolver.commit();
+ } catch ( final PersistenceException pe ) {
+ this.configuration.getMainLogger().warn("Unable to reassign job " + job.getId(), pe);
+ }
+ } catch (final InstantiationException ie) {
+ // something happened with the resource in the meantime
+ this.configuration.getMainLogger().debug("Unable to instantiate job", ie);
+ }
+ }
+ } finally {
+ resolver.close();
+ }
+ }
+
+ /**
+ * Update the property of a job in the resource tree
+ * @param propNames the property names to update
+ * @return {@code true} if the update was successful.
+ */
+ public boolean persistJobProperties(final String... propNames) {
+ if ( propNames != null ) {
+ final ResourceResolver resolver = this.configuration.createResourceResolver();
+ try {
+ final Resource jobResource = resolver.getResource(job.getResourcePath());
+ if ( jobResource != null ) {
+ final ModifiableValueMap mvm = jobResource.adaptTo(ModifiableValueMap.class);
+ for(final String propName : propNames) {
+ final Object val = job.getProperty(propName);
+ if ( val != null ) {
+ if ( val.getClass().isEnum() ) {
+ mvm.put(propName, val.toString());
+ } else {
+ mvm.put(propName, val);
+ }
+ } else {
+ mvm.remove(propName);
+ }
+ }
+ resolver.commit();
+
+ return true;
+ } else {
+ this.configuration.getMainLogger().debug("No job resource found at {}", job.getResourcePath());
+ }
+ } catch ( final PersistenceException ignore ) {
+ this.configuration.getMainLogger().debug("Unable to persist properties", ignore);
+ } finally {
+ resolver.close();
+ }
+ return false;
+ }
+ return true;
+ }
+
+ public boolean isStopped() {
+ return this.isStopped;
+ }
+
+ public void stop() {
+ this.isStopped = true;
+ }
+
+ public void addToRetryList() {
+ this.configuration.addJobToRetryList(this.job);
+
+ }
+
+ public boolean removeFromRetryList() {
+ return this.configuration.removeJobFromRetryList(this.job);
+ }
+
+ @Override
+ public int hashCode() {
+ return this.job.getId().hashCode();
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if ( ! (obj instanceof JobHandler) ) {
+ return false;
+ }
+ return this.job.getId().equals(((JobHandler)obj).job.getId());
+ }
+
+ @Override
+ public String toString() {
+ return "JobHandler(" + this.job.getId() + ")";
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/JobImpl.java b/src/main/java/org/apache/sling/event/impl/jobs/JobImpl.java
new file mode 100644
index 0000000..5514ebf
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/JobImpl.java
@@ -0,0 +1,411 @@
+/*
+ * 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.event.impl.jobs;
+
+import java.text.MessageFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.sling.api.resource.ValueMap;
+import org.apache.sling.api.wrappers.ValueMapDecorator;
+import org.apache.sling.event.impl.support.ResourceHelper;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.NotificationConstants;
+import org.apache.sling.event.jobs.Queue;
+
+/**
+ * This object encapsulates all information about a job.
+ */
+public class JobImpl implements Job, Comparable<JobImpl> {
+
+ /** Internal job property containing the resource path. */
+ public static final String PROPERTY_RESOURCE_PATH = "slingevent:path";
+
+ /** Internal job property containing optional delay override. */
+ public static final String PROPERTY_DELAY_OVERRIDE = ":slingevent:delayOverride";
+
+ /**
+ * Internal job property specifying when the job was put into the queue.
+ */
+ public static final String PROPERTY_JOB_QUEUED = "event.job.queued.time";
+
+ /**
+ * This property contains the finished state of a job once it's marked as finished.
+ * The value is either "CANCELLED" or "SUCCEEDED".
+ * This property is read-only and can't be specified when the job is created.
+ */
+ public static final String PROPERTY_FINISHED_STATE = "slingevent:finishedState";
+
+ private final ValueMap properties;
+
+ private final String topic;
+
+ private final String path;
+
+ private final String jobId;
+
+ private final List<Exception> readErrorList;
+
+ private final long counter;
+
+ /**
+ * Create a new job instance
+ *
+ * @param topic The job topic
+ * @param name The unique job name (optional)
+ * @param jobId The unique (internal) job id
+ * @param properties Non-null map of properties, at least containing {@link #PROPERTY_RESOURCE_PATH}
+ */
+ @SuppressWarnings("unchecked")
+ public JobImpl(final String topic,
+ final String jobId,
+ final Map<String, Object> properties) {
+ this.topic = topic;
+ this.jobId = jobId;
+ this.path = (String)properties.remove(PROPERTY_RESOURCE_PATH);
+ this.readErrorList = (List<Exception>) properties.remove(ResourceHelper.PROPERTY_MARKER_READ_ERROR_LIST);
+
+ this.properties = new ValueMapDecorator(properties);
+ this.properties.put(NotificationConstants.NOTIFICATION_PROPERTY_JOB_ID, jobId);
+ final int lastPos = jobId.lastIndexOf('_');
+ this.counter = Long.valueOf(jobId.substring(lastPos + 1));
+ }
+
+ /**
+ * Get the full resource path.
+ */
+ public String getResourcePath() {
+ return this.path;
+ }
+
+ /**
+ * Did we have read errors?
+ */
+ public boolean hasReadErrors() {
+ return this.readErrorList != null;
+ }
+
+ /**
+ * Is the error recoverable?
+ */
+ public boolean isReadErrorRecoverable() {
+ boolean result = true;
+ if ( this.readErrorList != null ) {
+ for(final Exception e : this.readErrorList) {
+ if ( e instanceof RuntimeException ) {
+ result = false;
+ break;
+ }
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Get all properties
+ */
+ public Map<String, Object> getProperties() {
+ return this.properties;
+ }
+
+ /**
+ * Update the information for a retry
+ */
+ public void retry() {
+ final int retries = this.getProperty(Job.PROPERTY_JOB_RETRY_COUNT, Integer.class);
+ this.properties.put(Job.PROPERTY_JOB_RETRY_COUNT, retries + 1);
+ this.properties.remove(Job.PROPERTY_JOB_STARTED_TIME);
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Job#getTopic()
+ */
+ @Override
+ public String getTopic() {
+ return this.topic;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Job#getId()
+ */
+ @Override
+ public String getId() {
+ return this.jobId;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Job#getProperty(java.lang.String)
+ */
+ @Override
+ public Object getProperty(final String name) {
+ return this.properties.get(name);
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Job#getProperty(java.lang.String, java.lang.Class)
+ */
+ @Override
+ public <T> T getProperty(final String name, final Class<T> type) {
+ return this.properties.get(name, type);
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Job#getProperty(java.lang.String, java.lang.Object)
+ */
+ @Override
+ public <T> T getProperty(final String name, final T defaultValue) {
+ return this.properties.get(name, defaultValue);
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Job#getPropertyNames()
+ */
+ @Override
+ public Set<String> getPropertyNames() {
+ return this.properties.keySet();
+ }
+
+ @Override
+ public int getRetryCount() {
+ return this.getProperty(Job.PROPERTY_JOB_RETRY_COUNT, Integer.class);
+ }
+
+ @Override
+ public int getNumberOfRetries() {
+ return this.getProperty(Job.PROPERTY_JOB_RETRIES, Integer.class);
+ }
+
+ @Override
+ public String getQueueName() {
+ return this.getProperty(Job.PROPERTY_JOB_QUEUE_NAME, String.class);
+ }
+
+ @Override
+ public String getTargetInstance() {
+ return this.getProperty(Job.PROPERTY_JOB_TARGET_INSTANCE, String.class);
+ }
+
+ @Override
+ public Calendar getProcessingStarted() {
+ return this.getProperty(Job.PROPERTY_JOB_STARTED_TIME, Calendar.class);
+ }
+
+ @Override
+ public Calendar getCreated() {
+ return this.getProperty(Job.PROPERTY_JOB_CREATED, Calendar.class);
+ }
+
+ @Override
+ public String getCreatedInstance() {
+ return this.getProperty(Job.PROPERTY_JOB_CREATED_INSTANCE, String.class);
+ }
+
+ /**
+ * Update information about the queue.
+ */
+ public void updateQueueInfo(final Queue queue) {
+ this.properties.put(Job.PROPERTY_JOB_QUEUE_NAME, queue.getName());
+ this.properties.put(Job.PROPERTY_JOB_RETRIES, queue.getConfiguration().getMaxRetries());
+ }
+
+ public void setProperty(final String name, final Object value) {
+ if ( value == null ) {
+ this.properties.remove(name);
+ } else {
+ this.properties.put(name, value);
+ }
+ }
+
+ /**
+ * Prepare a new job execution
+ */
+ public String[] prepare(final Queue queue) {
+ this.updateQueueInfo(queue);
+ this.properties.remove(JobImpl.PROPERTY_DELAY_OVERRIDE);
+ this.properties.remove(Job.PROPERTY_JOB_PROGRESS_LOG);
+ this.properties.remove(Job.PROPERTY_JOB_PROGRESS_ETA);
+ this.properties.remove(Job.PROPERTY_JOB_PROGRESS_STEPS);
+ this.properties.remove(Job.PROPERTY_JOB_PROGRESS_STEP);
+ this.properties.remove(Job.PROPERTY_RESULT_MESSAGE);
+ this.properties.put(Job.PROPERTY_JOB_STARTED_TIME, Calendar.getInstance());
+ return new String[] {Job.PROPERTY_JOB_QUEUE_NAME, Job.PROPERTY_JOB_RETRIES,
+ Job.PROPERTY_JOB_PROGRESS_LOG, Job.PROPERTY_JOB_PROGRESS_ETA, PROPERTY_JOB_PROGRESS_STEPS,
+ PROPERTY_JOB_PROGRESS_STEP, Job.PROPERTY_RESULT_MESSAGE, Job.PROPERTY_JOB_STARTED_TIME};
+ }
+
+ public String[] startProgress(final int steps, final long eta) {
+ if ( steps > 0 ) {
+ this.setProperty(Job.PROPERTY_JOB_PROGRESS_STEPS, steps);
+ }
+ if ( eta > 0 ) {
+ final Date finishDate = new Date(System.currentTimeMillis() + eta * 1000);
+ final Calendar finishCal = Calendar.getInstance();
+ finishCal.setTime(finishDate);
+ this.setProperty(Job.PROPERTY_JOB_PROGRESS_ETA, finishCal);
+ }
+ return new String[] {Job.PROPERTY_JOB_PROGRESS_ETA, PROPERTY_JOB_PROGRESS_STEPS};
+ }
+
+ public String[] setProgress(final int step) {
+ final int steps = this.getProperty(Job.PROPERTY_JOB_PROGRESS_STEPS, -1);
+ if ( steps > 0 && step > 0 ) {
+ int current = this.getProperty(Job.PROPERTY_JOB_PROGRESS_STEP, 0);
+ current += step;
+ if ( current > steps ) {
+ current = steps;
+ }
+ this.setProperty(Job.PROPERTY_JOB_PROGRESS_STEP, current);
+
+ final Calendar now = Calendar.getInstance();
+ final long elapsed = now.getTimeInMillis() - this.getProcessingStarted().getTimeInMillis();
+
+ final long eta = elapsed * steps / step;
+ now.setTimeInMillis(eta);
+ this.setProperty(Job.PROPERTY_JOB_PROGRESS_ETA, now);
+ return new String[] {Job.PROPERTY_JOB_PROGRESS_STEP, Job.PROPERTY_JOB_PROGRESS_ETA};
+ }
+ return null;
+ }
+
+ public String update(final long eta) {
+ if ( eta > 0 ) {
+ final Date finishDate = new Date(System.currentTimeMillis() + eta * 1000);
+ final Calendar finishCal = Calendar.getInstance();
+ finishCal.setTime(finishDate);
+ this.setProperty(Job.PROPERTY_JOB_PROGRESS_ETA, eta);
+ } else {
+ this.properties.remove(Job.PROPERTY_JOB_PROGRESS_ETA);
+ }
+ return Job.PROPERTY_JOB_PROGRESS_ETA;
+ }
+
+ public String log(final String message, final Object... args) {
+ final String logEntry = MessageFormat.format(message, args);
+ final String[] entries = this.getProperty(Job.PROPERTY_JOB_PROGRESS_LOG, String[].class);
+ if ( entries == null ) {
+ this.setProperty(Job.PROPERTY_JOB_PROGRESS_LOG, new String[] {logEntry});
+ } else {
+ final String[] newEntries = new String[entries.length + 1];
+ System.arraycopy(entries, 0, newEntries, 0, entries.length);
+ newEntries[entries.length] = logEntry;
+ this.setProperty(Job.PROPERTY_JOB_PROGRESS_LOG, newEntries);
+ }
+ return Job.PROPERTY_JOB_PROGRESS_LOG;
+ }
+
+ @Override
+ public JobState getJobState() {
+ final String enumValue = this.getProperty(JobImpl.PROPERTY_FINISHED_STATE, String.class);
+ if ( enumValue == null ) {
+ if ( this.getProcessingStarted() != null ) {
+ return JobState.ACTIVE;
+ }
+ return JobState.QUEUED;
+ }
+ return JobState.valueOf(enumValue);
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Job#getFinishedDate()
+ */
+ @Override
+ public Calendar getFinishedDate() {
+ return this.getProperty(Job.PROPERTY_FINISHED_DATE, Calendar.class);
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Job#getResultMessage()
+ */
+ @Override
+ public String getResultMessage() {
+ return this.getProperty(Job.PROPERTY_RESULT_MESSAGE, String.class);
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Job#getProgressLog()
+ */
+ @Override
+ public String[] getProgressLog() {
+ return this.getProperty(Job.PROPERTY_JOB_PROGRESS_LOG, String[].class);
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Job#getProgressStepCount()
+ */
+ @Override
+ public int getProgressStepCount() {
+ return this.getProperty(Job.PROPERTY_JOB_PROGRESS_STEPS, -1);
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Job#getFinishedProgressStep()
+ */
+ @Override
+ public int getFinishedProgressStep() {
+ return this.getProperty(Job.PROPERTY_JOB_PROGRESS_STEP, 0);
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Job#getProgressETA()
+ */
+ @Override
+ public Calendar getProgressETA() {
+ return this.getProperty(Job.PROPERTY_JOB_PROGRESS_ETA, Calendar.class);
+ }
+
+ @Override
+ public int compareTo(final JobImpl o) {
+ int result = this.getCreated().compareTo(o.getCreated());
+ if ( result == 0 ) {
+ if ( this.counter < o.counter ) {
+ result = -1;
+ } else if ( this.counter > o.counter ) {
+ result = 1;
+ } else {
+ result = this.jobId.compareTo(o.jobId);
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public int hashCode() {
+ return this.jobId.hashCode();
+ }
+
+ @Override
+ public boolean equals(final Object obj) {
+ if ( obj == this ) {
+ return true;
+ }
+ if ( obj instanceof JobImpl ) {
+ return this.jobId.equals(((JobImpl)obj).jobId);
+ }
+ return false;
+ }
+
+ @Override
+ public String toString() {
+ return "JobImpl [properties=" + properties + ", topic=" + topic
+ + ", path=" + path + ", jobId=" + jobId + "]";
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/JobManagerImpl.java b/src/main/java/org/apache/sling/event/impl/jobs/JobManagerImpl.java
new file mode 100644
index 0000000..b76fda4
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/JobManagerImpl.java
@@ -0,0 +1,759 @@
+/*
+ * 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.event.impl.jobs;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collection;
+import java.util.Dictionary;
+import java.util.HashMap;
+import java.util.Hashtable;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.felix.scr.annotations.Activate;
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.Deactivate;
+import org.apache.felix.scr.annotations.Properties;
+import org.apache.felix.scr.annotations.Property;
+import org.apache.felix.scr.annotations.Reference;
+import org.apache.felix.scr.annotations.Service;
+import org.apache.jackrabbit.util.ISO9075;
+import org.apache.sling.api.resource.LoginException;
+import org.apache.sling.api.resource.PersistenceException;
+import org.apache.sling.api.resource.QuerySyntaxException;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.observation.ResourceChange;
+import org.apache.sling.api.resource.observation.ResourceChangeListener;
+import org.apache.sling.commons.scheduler.Scheduler;
+import org.apache.sling.commons.threads.ThreadPoolManager;
+import org.apache.sling.event.impl.jobs.config.JobManagerConfiguration;
+import org.apache.sling.event.impl.jobs.config.QueueConfigurationManager.QueueInfo;
+import org.apache.sling.event.impl.jobs.config.TopologyCapabilities;
+import org.apache.sling.event.impl.jobs.notifications.NotificationUtility;
+import org.apache.sling.event.impl.jobs.queues.JobQueueImpl;
+import org.apache.sling.event.impl.jobs.queues.QueueManager;
+import org.apache.sling.event.impl.jobs.scheduling.JobSchedulerImpl;
+import org.apache.sling.event.impl.jobs.stats.StatisticsManager;
+import org.apache.sling.event.impl.jobs.tasks.CleanUpTask;
+import org.apache.sling.event.impl.support.Environment;
+import org.apache.sling.event.impl.support.ResourceHelper;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.Job.JobState;
+import org.apache.sling.event.jobs.JobBuilder;
+import org.apache.sling.event.jobs.JobManager;
+import org.apache.sling.event.jobs.NotificationConstants;
+import org.apache.sling.event.jobs.Queue;
+import org.apache.sling.event.jobs.ScheduledJobInfo;
+import org.apache.sling.event.jobs.Statistics;
+import org.apache.sling.event.jobs.TopicStatistics;
+import org.apache.sling.event.jobs.jmx.QueuesMBean;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.Constants;
+import org.osgi.framework.ServiceRegistration;
+import org.osgi.service.event.Event;
+import org.osgi.service.event.EventAdmin;
+import org.osgi.service.event.EventConstants;
+import org.osgi.service.event.EventHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/**
+ * Implementation of the job manager.
+ */
+@Component(immediate=true)
+@Service(value={JobManager.class, EventHandler.class, Runnable.class})
+@Properties({
+ @Property(name="scheduler.period", longValue=60),
+ @Property(name="scheduler.concurrent", boolValue=false),
+ @Property(name=EventConstants.EVENT_TOPIC,
+ value={ResourceHelper.BUNDLE_EVENT_STARTED,
+ ResourceHelper.BUNDLE_EVENT_UPDATED})
+})
+public class JobManagerImpl
+ implements JobManager, EventHandler, Runnable {
+
+ /** Default logger. */
+ private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+ @Reference
+ private EventAdmin eventAdmin;
+
+ @Reference
+ private Scheduler scheduler;
+
+ @Reference
+ private JobConsumerManager jobConsumerManager;
+
+ @Reference
+ private QueuesMBean queuesMBean;
+
+ @Reference
+ private ThreadPoolManager threadPoolManager;
+
+ /** The job manager configuration. */
+ @Reference
+ private JobManagerConfiguration configuration;
+
+ @Reference
+ private StatisticsManager statisticsManager;
+
+ @Reference
+ private QueueManager qManager;
+
+ private volatile CleanUpTask maintenanceTask;
+
+ /** Job Scheduler. */
+ private org.apache.sling.event.impl.jobs.scheduling.JobSchedulerImpl jobScheduler;
+
+ private volatile ServiceRegistration<ResourceChangeListener> changeListenerReg;
+
+ /**
+ * Activate this component.
+ * @param props Configuration properties
+ */
+ @Activate
+ protected void activate(final BundleContext ctx, final Map<String, Object> props) throws LoginException {
+ this.jobScheduler = new org.apache.sling.event.impl.jobs.scheduling.JobSchedulerImpl(this.configuration, this.scheduler, this);
+ this.maintenanceTask = new CleanUpTask(this.configuration, this.jobScheduler);
+
+ final Dictionary<String, Object> regProps = new Hashtable<>();
+ regProps.put(ResourceChangeListener.PATHS, this.configuration.getScheduledJobsPath(false));
+ regProps.put(ResourceChangeListener.CHANGES, new String[] {
+ ResourceChange.ChangeType.ADDED.name(),
+ ResourceChange.ChangeType.CHANGED.name(),
+ ResourceChange.ChangeType.REMOVED.name()
+ });
+ regProps.put(Constants.SERVICE_VENDOR, "The Apache Software Foundation");
+ regProps.put(Constants.SERVICE_DESCRIPTION, "Resource change listener for scheduled jobs");
+ this.changeListenerReg = ctx.registerService(ResourceChangeListener.class, this.jobScheduler, regProps);
+ logger.info("Apache Sling Job Manager started on instance {}", Environment.APPLICATION_ID);
+ }
+
+ /**
+ * Deactivate this component.
+ */
+ @Deactivate
+ protected void deactivate() {
+ logger.debug("Apache Sling Job Manager stopping on instance {}", Environment.APPLICATION_ID);
+
+ if ( this.changeListenerReg != null ) {
+ this.changeListenerReg.unregister();
+ this.changeListenerReg = null;
+ }
+
+ this.jobScheduler.deactivate();
+
+ this.maintenanceTask = null;
+ logger.info("Apache Sling Job Manager stopped on instance {}", Environment.APPLICATION_ID);
+ }
+
+ /**
+ * This method is invoked periodically by the scheduler.
+ * In the default configuration every minute
+ * @see java.lang.Runnable#run()
+ */
+ @Override
+ public void run() {
+ // invoke maintenance task
+ final CleanUpTask task = this.maintenanceTask;
+ if ( task != null ) {
+ task.run();
+ }
+ }
+
+ /**
+ * @see org.osgi.service.event.EventHandler#handleEvent(org.osgi.service.event.Event)
+ */
+ @Override
+ public void handleEvent(final Event event) {
+ this.jobScheduler.handleEvent(event);
+ }
+
+ /**
+ * Return our internal statistics object.
+ *
+ * @see org.apache.sling.event.jobs.JobManager#getStatistics()
+ */
+ @Override
+ public synchronized Statistics getStatistics() {
+ return this.statisticsManager.getGlobalStatistics();
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.JobManager#getTopicStatistics()
+ */
+ @Override
+ public Iterable<TopicStatistics> getTopicStatistics() {
+ return this.statisticsManager.getTopicStatistics().values();
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.JobManager#getQueue(java.lang.String)
+ */
+ @Override
+ public Queue getQueue(final String name) {
+ return qManager.getQueue(ResourceHelper.filterQueueName(name));
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.JobManager#getQueues()
+ */
+ @Override
+ public Iterable<Queue> getQueues() {
+ return qManager.getQueues();
+ }
+
+ /**
+ * Remove a job.
+ * If the job is already in the storage area, it's removed forever.
+ * Otherwise it's moved to the storage area.
+ */
+ private boolean internalRemoveJobById(final String jobId, final boolean forceRemove) {
+ logger.debug("Trying to remove job {}", jobId);
+ boolean result = true;
+ JobImpl job = (JobImpl)this.getJobById(jobId);
+ if ( job != null ) {
+ if ( logger.isDebugEnabled() ) {
+ logger.debug("Found removal job: {}", Utility.toString(job));
+ }
+ final JobImpl retryJob = (JobImpl)this.configuration.getJobFromRetryList(jobId);
+ if ( retryJob != null ) {
+ job = retryJob;
+ }
+ // currently running?
+ if ( !forceRemove && job.getProcessingStarted() != null ) {
+ if ( logger.isDebugEnabled() ) {
+ logger.debug("Unable to remove job - job is started: {}", Utility.toString(job));
+ }
+ result = false;
+ } else {
+ final boolean isHistoryJob = this.configuration.isStoragePath(job.getResourcePath());
+ // if history job, simply remove - otherwise move to history!
+ if ( isHistoryJob ) {
+ final ResourceResolver resolver = this.configuration.createResourceResolver();
+ try {
+ final Resource jobResource = resolver.getResource(job.getResourcePath());
+ if ( jobResource != null ) {
+ resolver.delete(jobResource);
+ resolver.commit();
+ logger.debug("Removed job with id: {}", jobId);
+ } else {
+ logger.debug("Unable to remove job with id - resource already removed: {}", jobId);
+ }
+ NotificationUtility.sendNotification(this.eventAdmin, NotificationConstants.TOPIC_JOB_REMOVED, job, null);
+ } catch ( final PersistenceException pe) {
+ logger.warn("Unable to remove job at " + job.getResourcePath(), pe);
+ result = false;
+ } finally {
+ resolver.close();
+ }
+ } else {
+ final JobHandler jh = new JobHandler(job, null, this.configuration);
+ jh.finished(Job.JobState.DROPPED, true, null);
+ }
+ this.configuration.getAuditLogger().debug("REMOVE OK : {}", jobId);
+ }
+ } else {
+ logger.debug("Job for removal does not exist (anymore): {}", jobId);
+ }
+ return result;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.JobManager#addJob(java.lang.String, java.util.Map)
+ */
+ @Override
+ public Job addJob(String topic, Map<String, Object> properties) {
+ return this.addJob(topic, properties, null);
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.JobManager#getJobById(java.lang.String)
+ */
+ @Override
+ public Job getJobById(final String id) {
+ logger.debug("Getting job by id: {}", id);
+ final ResourceResolver resolver = this.configuration.createResourceResolver();
+ final StringBuilder buf = new StringBuilder(64);
+ try {
+
+ buf.append("/jcr:root");
+ buf.append(this.configuration.getJobsBasePathWithSlash());
+ buf.append("/element(*,");
+ buf.append(ResourceHelper.RESOURCE_TYPE_JOB);
+ buf.append(")[@");
+ buf.append(ResourceHelper.PROPERTY_JOB_ID);
+ buf.append(" = '");
+ buf.append(id);
+ buf.append("']");
+ if ( logger.isDebugEnabled() ) {
+ logger.debug("Exceuting query: {}", buf.toString());
+ }
+ final Iterator<Resource> result = resolver.findResources(buf.toString(), "xpath");
+
+ while ( result.hasNext() ) {
+ final Resource jobResource = result.next();
+ // sanity check for the path
+ if ( this.configuration.isJob(jobResource.getPath()) ) {
+ final JobImpl job = Utility.readJob(logger, jobResource);
+ if ( job != null ) {
+ if ( logger.isDebugEnabled() ) {
+ logger.debug("Found job with id {} = {}", id, Utility.toString(job));
+ }
+ return job;
+ }
+ }
+ }
+ } catch (final QuerySyntaxException qse) {
+ logger.warn("Query syntax wrong " + buf.toString(), qse);
+ } finally {
+ resolver.close();
+ }
+ logger.debug("Job not found with id: {}", id);
+ return null;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.JobManager#getJob(java.lang.String, java.util.Map)
+ */
+ @SuppressWarnings("unchecked")
+ @Override
+ public Job getJob(final String topic, final Map<String, Object> template) {
+ final Iterable<Job> iter;
+ if ( template == null ) {
+ iter = this.findJobs(QueryType.ALL, topic, 1, (Map<String, Object>[])null);
+ } else {
+ iter = this.findJobs(QueryType.ALL, topic, 1, template);
+ }
+ final Iterator<Job> i = iter.iterator();
+ if ( i.hasNext() ) {
+ return i.next();
+ }
+ return null;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.JobManager#removeJobById(java.lang.String)
+ */
+ @Override
+ public boolean removeJobById(final String jobId) {
+ return this.internalRemoveJobById(jobId, true);
+ }
+
+ private enum Operation {
+ LESS,
+ LESS_OR_EQUALS,
+ EQUALS,
+ GREATER_OR_EQUALS,
+ GREATER
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.JobManager#findJobs(org.apache.sling.event.jobs.JobManager.QueryType, java.lang.String, long, java.util.Map[])
+ */
+ @Override
+ public Collection<Job> findJobs(final QueryType type,
+ final String topic,
+ final long limit,
+ final Map<String, Object>... templates) {
+ final boolean isHistoryQuery = type == QueryType.HISTORY
+ || type == QueryType.SUCCEEDED
+ || type == QueryType.CANCELLED
+ || type == QueryType.DROPPED
+ || type == QueryType.ERROR
+ || type == QueryType.GIVEN_UP
+ || type == QueryType.STOPPED;
+ final List<Job> result = new ArrayList<Job>();
+ final ResourceResolver resolver = this.configuration.createResourceResolver();
+ final StringBuilder buf = new StringBuilder(64);
+ try {
+
+ buf.append("/jcr:root");
+ buf.append(this.configuration.getJobsBasePathWithSlash());
+ buf.append("/element(*,");
+ buf.append(ResourceHelper.RESOURCE_TYPE_JOB);
+ buf.append(")[@");
+ buf.append(ISO9075.encode(ResourceHelper.PROPERTY_JOB_TOPIC));
+ if (topic != null) {
+ buf.append(" = '");
+ buf.append(topic);
+ buf.append("'");
+ }
+
+ // restricting on the type - history or unfinished
+ if ( isHistoryQuery ) {
+ buf.append(" and @");
+ buf.append(ISO9075.encode(JobImpl.PROPERTY_FINISHED_STATE));
+ if ( type == QueryType.SUCCEEDED || type == QueryType.DROPPED || type == QueryType.ERROR || type == QueryType.GIVEN_UP || type == QueryType.STOPPED ) {
+ buf.append(" = '");
+ buf.append(type.name());
+ buf.append("'");
+ } else if ( type == QueryType.CANCELLED ) {
+ buf.append(" and (@");
+ buf.append(ISO9075.encode(JobImpl.PROPERTY_FINISHED_STATE));
+ buf.append(" = '");
+ buf.append(QueryType.DROPPED.name());
+ buf.append("' or @");
+ buf.append(ISO9075.encode(JobImpl.PROPERTY_FINISHED_STATE));
+ buf.append(" = '");
+ buf.append(QueryType.ERROR.name());
+ buf.append("' or @");
+ buf.append(ISO9075.encode(JobImpl.PROPERTY_FINISHED_STATE));
+ buf.append(" = '");
+ buf.append(QueryType.GIVEN_UP.name());
+ buf.append("' or @");
+ buf.append(ISO9075.encode(JobImpl.PROPERTY_FINISHED_STATE));
+ buf.append(" = '");
+ buf.append(QueryType.STOPPED.name());
+ buf.append("')");
+ }
+ } else {
+ buf.append(" and not(@");
+ buf.append(ISO9075.encode(JobImpl.PROPERTY_FINISHED_STATE));
+ buf.append(")");
+ if ( type == QueryType.ACTIVE ) {
+ buf.append(" and @");
+ buf.append(ISO9075.encode(Job.PROPERTY_JOB_STARTED_TIME));
+ } else if ( type == QueryType.QUEUED ) {
+ buf.append(" and not(@");
+ buf.append(ISO9075.encode(Job.PROPERTY_JOB_STARTED_TIME));
+ buf.append(")");
+ }
+ }
+
+ if ( templates != null && templates.length > 0 ) {
+ int index = 0;
+ for (final Map<String,Object> template : templates) {
+ // skip empty templates
+ if ( template.size() == 0 ) {
+ continue;
+ }
+ if ( index == 0 ) {
+ buf.append(" and (");
+ } else {
+ buf.append(" or ");
+ }
+ buf.append('(');
+ final Iterator<Map.Entry<String, Object>> i = template.entrySet().iterator();
+ boolean first = true;
+ while ( i.hasNext() ) {
+ final Map.Entry<String, Object> current = i.next();
+ final String key = ISO9075.encode(current.getKey());
+ final char firstChar = key.length() > 0 ? key.charAt(0) : 0;
+ final String propName;
+ final Operation op;
+ if ( firstChar == '=' ) {
+ propName = key.substring(1);
+ op = Operation.EQUALS;
+ } else if ( firstChar == '<' ) {
+ final char secondChar = key.length() > 1 ? key.charAt(1) : 0;
+ if ( secondChar == '=' ) {
+ op = Operation.LESS_OR_EQUALS;
+ propName = key.substring(2);
+ } else {
+ op = Operation.LESS;
+ propName = key.substring(1);
+ }
+ } else if ( firstChar == '>' ) {
+ final char secondChar = key.length() > 1 ? key.charAt(1) : 0;
+ if ( secondChar == '=' ) {
+ op = Operation.GREATER_OR_EQUALS;
+ propName = key.substring(2);
+ } else {
+ op = Operation.GREATER;
+ propName = key.substring(1);
+ }
+ } else {
+ propName = key;
+ op = Operation.EQUALS;
+ }
+
+ if ( first ) {
+ first = false;
+ buf.append('@');
+ } else {
+ buf.append(" and @");
+ }
+ buf.append(propName);
+ buf.append(' ');
+ switch ( op ) {
+ case EQUALS : buf.append('=');break;
+ case LESS : buf.append('<'); break;
+ case LESS_OR_EQUALS : buf.append("<="); break;
+ case GREATER : buf.append('>'); break;
+ case GREATER_OR_EQUALS : buf.append(">="); break;
+ }
+ buf.append(" '");
+ buf.append(current.getValue());
+ buf.append("'");
+ }
+ buf.append(')');
+ index++;
+ }
+ if ( index > 0 ) {
+ buf.append(')');
+ }
+ }
+ buf.append("] order by @");
+ if ( isHistoryQuery ) {
+ buf.append(JobImpl.PROPERTY_FINISHED_DATE);
+ buf.append(" descending");
+ } else {
+ buf.append(Job.PROPERTY_JOB_CREATED);
+ buf.append(" ascending");
+ }
+ final Iterator<Resource> iter = resolver.findResources(buf.toString(), "xpath");
+ long count = 0;
+
+ while ( iter.hasNext() && (limit < 1 || count < limit) ) {
+ final Resource jobResource = iter.next();
+ // sanity check for the path
+ if ( this.configuration.isJob(jobResource.getPath()) ) {
+ final JobImpl job = Utility.readJob(logger, jobResource);
+ if ( job != null ) {
+ count++;
+ result.add(job);
+ }
+ }
+ }
+ } catch (final QuerySyntaxException qse) {
+ logger.warn("Query syntax wrong " + buf.toString(), qse);
+ } finally {
+ resolver.close();
+ }
+ return result;
+ }
+
+ /**
+ * Persist the job in the resource tree
+ * @param jobTopic The required job topic
+ * @param jobName The optional job name
+ * @param passedJobProperties The optional job properties
+ * @return The persisted job or <code>null</code>.
+ */
+ private Job addJobInternal(final String jobTopic,
+ final Map<String, Object> jobProperties,
+ final List<String> errors) {
+ final QueueInfo info = this.configuration.getQueueConfigurationManager().getQueueInfo(jobTopic);
+
+ final TopologyCapabilities caps = this.configuration.getTopologyCapabilities();
+ info.targetId = (caps == null ? null : caps.detectTarget(jobTopic, jobProperties, info));
+
+ if ( logger.isDebugEnabled() ) {
+ if ( info.targetId != null ) {
+ logger.debug("Persisting job {} into queue {}, target={}", new Object[] {Utility.toString(jobTopic, jobProperties), info.queueName, info.targetId});
+ } else {
+ logger.debug("Persisting job {} into queue {}", Utility.toString(jobTopic, jobProperties), info.queueName);
+ }
+ }
+ final ResourceResolver resolver = this.configuration.createResourceResolver();
+ try {
+ final JobImpl job = this.writeJob(resolver,
+ jobTopic,
+ jobProperties,
+ info);
+ if ( info.targetId != null ) {
+ this.configuration.getAuditLogger().debug("ASSIGN OK {} : {}",
+ info.targetId, job.getId());
+ } else {
+ this.configuration.getAuditLogger().debug("UNASSIGN OK : {}",
+ job.getId());
+ }
+ return job;
+ } catch (final PersistenceException re ) {
+ // something went wrong, so let's log it
+ this.logger.error("Exception during persisting new job '" + Utility.toString(jobTopic, jobProperties) + "'", re);
+ } finally {
+ resolver.close();
+ }
+ if ( errors != null ) {
+ errors.add("Unable to persist new job.");
+ }
+
+ return null;
+ }
+
+ /**
+ * Write a job to the resource tree.
+ * @param resolver The resolver resolver
+ * @param event The event
+ * @param info The queue information (queue name etc.)
+ * @throws PersistenceException
+ */
+ private JobImpl writeJob(final ResourceResolver resolver,
+ final String jobTopic,
+ final Map<String, Object> jobProperties,
+ final QueueInfo info)
+ throws PersistenceException {
+ final String jobId = this.configuration.getUniqueId(jobTopic);
+ final String path = this.configuration.getUniquePath(info.targetId, jobTopic, jobId, jobProperties);
+
+ // create properties
+ final Map<String, Object> properties = new HashMap<String, Object>();
+
+ if ( jobProperties != null ) {
+ for(final Map.Entry<String, Object> entry : jobProperties.entrySet() ) {
+ final String propName = entry.getKey();
+ if ( !ResourceHelper.ignoreProperty(propName) ) {
+ properties.put(propName, entry.getValue());
+ }
+ }
+ }
+
+ properties.put(ResourceHelper.PROPERTY_JOB_ID, jobId);
+ properties.put(ResourceHelper.PROPERTY_JOB_TOPIC, jobTopic);
+ properties.put(Job.PROPERTY_JOB_QUEUE_NAME, info.queueConfiguration.getName());
+ properties.put(Job.PROPERTY_JOB_RETRY_COUNT, 0);
+ properties.put(Job.PROPERTY_JOB_RETRIES, info.queueConfiguration.getMaxRetries());
+
+ properties.put(Job.PROPERTY_JOB_CREATED, Calendar.getInstance());
+ properties.put(JobImpl.PROPERTY_JOB_QUEUED, Calendar.getInstance());
+ properties.put(Job.PROPERTY_JOB_CREATED_INSTANCE, Environment.APPLICATION_ID);
+ if ( info.targetId != null ) {
+ properties.put(Job.PROPERTY_JOB_TARGET_INSTANCE, info.targetId);
+ } else {
+ properties.remove(Job.PROPERTY_JOB_TARGET_INSTANCE);
+ }
+
+ // create path and resource
+ properties.put(ResourceResolver.PROPERTY_RESOURCE_TYPE, ResourceHelper.RESOURCE_TYPE_JOB);
+ if ( logger.isDebugEnabled() ) {
+ logger.debug("Storing new job {} at {}", Utility.toString(jobTopic, properties), path);
+ }
+ ResourceHelper.getOrCreateResource(resolver,
+ path,
+ properties);
+
+ // update property types - priority, add path and create job
+ properties.put(JobImpl.PROPERTY_RESOURCE_PATH, path);
+ return new JobImpl(jobTopic, jobId, properties);
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.JobManager#stopJobById(java.lang.String)
+ */
+ @Override
+ public void stopJobById(final String jobId) {
+ this.stopJobById(jobId, true);
+ }
+
+ private void stopJobById(final String jobId, final boolean forward) {
+ final JobImpl job = (JobImpl)this.getJobById(jobId);
+ if ( job != null && !this.configuration.isStoragePath(job.getResourcePath()) ) {
+ // get the queue configuration
+ final QueueInfo queueInfo = this.configuration.getQueueConfigurationManager().getQueueInfo(job.getTopic());
+ final JobQueueImpl queue = (JobQueueImpl)this.qManager.getQueue(queueInfo.queueName);
+
+ boolean stopped = false;
+ if ( queue != null ) {
+ stopped = queue.stopJob(job);
+ }
+ if ( forward && !stopped ) {
+ // mark the job as stopped
+ final JobHandler jh = new JobHandler(job, null, this.configuration);
+ jh.finished(JobState.STOPPED, true, null);
+ }
+ }
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.JobManager#createJob(java.lang.String)
+ */
+ @Override
+ public JobBuilder createJob(final String topic) {
+ return new JobBuilderImpl(this, topic);
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.JobManager#getScheduledJobs()
+ */
+ @Override
+ public Collection<ScheduledJobInfo> getScheduledJobs() {
+ return this.jobScheduler.getScheduledJobs(null, -1, (Map<String, Object>[])null);
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.JobManager#getScheduledJobs()
+ */
+ @Override
+ public Collection<ScheduledJobInfo> getScheduledJobs(final String topic,
+ final long limit,
+ final Map<String, Object>... templates) {
+ return this.jobScheduler.getScheduledJobs(topic, limit, templates);
+ }
+
+ /**
+ * Internal method to add a job
+ */
+ public Job addJob(final String topic,
+ final Map<String, Object> properties,
+ final List<String> errors) {
+ final String errorMessage = Utility.checkJob(topic, properties);
+ if ( errorMessage != null ) {
+ logger.warn("{}", errorMessage);
+ if ( errors != null ) {
+ errors.add(errorMessage);
+ }
+ this.configuration.getAuditLogger().debug("ADD FAILED topic={}, properties={} : {}",
+ new Object[] {topic,
+ properties,
+ errorMessage});
+ return null;
+ }
+ final List<String> errorList = new ArrayList<String>();
+ Job result = this.addJobInternal(topic, properties, errorList);
+ if ( errors != null ) {
+ errors.addAll(errorList);
+ }
+ if ( result == null ) {
+ this.configuration.getAuditLogger().debug("ADD FAILED topic={}, properties={} : {}",
+ new Object[] {topic,
+ properties,
+ errorList});
+ } else {
+ this.configuration.getAuditLogger().debug("ADD OK topic={}, properties={} : {}",
+ new Object[] {topic,
+ properties,
+ result.getId()});
+ }
+
+ return result;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.JobManager#retryJobById(java.lang.String)
+ */
+ @Override
+ public Job retryJobById(final String jobId) {
+ final JobImpl job = (JobImpl)this.getJobById(jobId);
+ if ( job != null && this.configuration.isStoragePath(job.getResourcePath()) ) {
+ this.internalRemoveJobById(jobId, true);
+ return this.addJob(job.getTopic(), job.getProperties());
+ }
+ return null;
+ }
+
+ public JobSchedulerImpl getJobScheduler() {
+ return this.jobScheduler;
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/JobTopicTraverser.java b/src/main/java/org/apache/sling/event/impl/jobs/JobTopicTraverser.java
new file mode 100644
index 0000000..1981e77
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/JobTopicTraverser.java
@@ -0,0 +1,176 @@
+/*
+ * 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.event.impl.jobs;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+import org.apache.sling.api.resource.Resource;
+import org.slf4j.Logger;
+
+/**
+ * The job topic traverser is an utility class to traverse all jobs
+ * of a specific topic in order of creation.
+ *
+ * The traverser can be used with two different callbacks,
+ * the resource callback is called with a resource object,
+ * the job callback with a job object created from the
+ * resource.
+ */
+public class JobTopicTraverser {
+
+ /**
+ * Callback called for each found job.
+ */
+ public interface JobCallback {
+
+ /**
+ * Callback handle for a job.
+ * If the callback signals to stop traversing, the current minute is still
+ * processed completely (to ensure correct ordering of jobs).
+ * @param job The job to handle
+ * @return <code>true</code> If processing should continue, <code>false</code> otherwise.
+ */
+ boolean handle(final JobImpl job);
+ }
+
+ /**
+ * Callback called for each found resource.
+ */
+ public interface ResourceCallback {
+
+ /**
+ * Callback handle for a resource.
+ * The callback is called in sorted order on a minute base, all resources within a minute
+ * are not necessarily called in correct time order!
+ * If the callback signals to stop traversing, the traversal is stopped
+ * immediately.
+ * @param rsrc The resource to handle
+ * @return <code>true</code> If processing should continue, <code>false</code> otherwise.
+ */
+ boolean handle(final Resource rsrc);
+ }
+
+ /**
+ * Traverse the topic and call the callback for each found job.
+ *
+ * Once the callback notifies to stop traversing by returning false, the current minute
+ * will be processed completely (to ensure correct ordering of jobs) and then the
+ * traversal stops.
+ *
+ * @param logger The logger to use for debug logging
+ * @param topicResource The topic resource
+ * @param handler The callback
+ */
+ public static void traverse(final Logger logger,
+ final Resource topicResource,
+ final JobCallback handler) {
+ traverse(logger, topicResource, handler, null);
+ }
+
+ /**
+ * Traverse the topic and call the callback for each found resource.
+ *
+ * Once the callback notifies to stop traversing by returning false, the
+ * traversal stops.
+ *
+ * @param logger The logger to use for debug logging
+ * @param topicResource The topic resource
+ * @param handler The callback
+ */
+ public static void traverse(final Logger logger,
+ final Resource topicResource,
+ final ResourceCallback handler) {
+ traverse(logger, topicResource, null, handler);
+ }
+
+ /**
+ * Internal method for traversal
+ * @param logger The logger to use for debug logging
+ * @param topicResource The topic resource
+ * @param jobHandler The job callback
+ * @param resourceHandler The resource callback
+ */
+ private static void traverse(final Logger logger,
+ final Resource topicResource,
+ final JobCallback jobHandler,
+ final ResourceCallback resourceHandler) {
+ logger.debug("Processing topic {}", topicResource.getName().replace('.', '/'));
+ // now years
+ for(final Resource yearResource: Utility.getSortedChildren(logger, "year", topicResource)) {
+ logger.debug("Processing year {}", yearResource.getName());
+
+ // now months
+ for(final Resource monthResource: Utility.getSortedChildren(logger, "month", yearResource)) {
+ logger.debug("Processing month {}", monthResource.getName());
+
+ // now days
+ for(final Resource dayResource: Utility.getSortedChildren(logger, "day", monthResource)) {
+ logger.debug("Processing day {}", dayResource.getName());
+
+ // now hours
+ for(final Resource hourResource: Utility.getSortedChildren(logger, "hour", dayResource)) {
+ logger.debug("Processing hour {}", hourResource.getName());
+
+ // now minutes
+ for(final Resource minuteResource: Utility.getSortedChildren(logger, "minute", hourResource)) {
+ logger.debug("Processing minute {}", minuteResource.getName());
+
+ // now jobs
+ final List<JobImpl> jobs = new ArrayList<JobImpl>();
+ // we use an iterator to skip removed entries
+ // see SLING-4073
+ final Iterator<Resource> jobIter = minuteResource.listChildren();
+ while ( jobIter.hasNext() ) {
+ final Resource jobResource = jobIter.next();
+ if ( resourceHandler != null ) {
+ if ( !resourceHandler.handle(jobResource) ) {
+ return;
+ }
+ } else {
+ final JobImpl job = Utility.readJob(logger, jobResource);
+ if ( job != null ) {
+ logger.debug("Found job {}", jobResource.getName());
+ jobs.add(job);
+ }
+ }
+ }
+
+ if ( jobHandler != null ) {
+ Collections.sort(jobs);
+
+ boolean stop = false;
+ for(final JobImpl job : jobs) {
+ if ( !jobHandler.handle(job) ) {
+ stop = true;
+ }
+ }
+ if ( stop ) {
+ return;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/Utility.java b/src/main/java/org/apache/sling/event/impl/jobs/Utility.java
new file mode 100644
index 0000000..c93fe51
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/Utility.java
@@ -0,0 +1,281 @@
+/*
+ * 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.event.impl.jobs;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Dictionary;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.sling.api.resource.PersistenceException;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ValueMap;
+import org.apache.sling.event.impl.support.ResourceHelper;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.consumer.JobConsumer;
+import org.osgi.service.event.Event;
+import org.slf4j.Logger;
+
+public abstract class Utility {
+
+ public static volatile boolean LOG_DEPRECATION_WARNINGS = true;
+
+ /**
+ * Check if the job topic is a valid OSGI event name (see 113.3.1 of the OSGI spec)
+ * @return <code>null</code> if the topic is syntactically correct otherwise an error description is returned
+ */
+ public static String checkJobTopic(final Object jobTopic) {
+ String message = null;
+ if ( jobTopic != null ) {
+ if ( jobTopic instanceof String ) {
+ try {
+ new Event((String)jobTopic, (Dictionary<String, Object>)null);
+ } catch (final IllegalArgumentException iae) {
+ message = String.format("Discarding job - job has an illegal job topic '%s'",jobTopic);
+ }
+
+ } else {
+ message = "Discarding job - job topic is not of type string";
+ }
+ } else {
+ message = "Discarding job - job topic is missing";
+ }
+ return message;
+ }
+
+ /**
+ * Check the job.
+ * @return <code>null</code> if the topic topic is correct and all properties are serializable,
+ * otherwise an error description is returned
+ */
+ public static String checkJob(final Object jobTopic, final Map<String, Object> properties) {
+ final String msg = checkJobTopic(jobTopic);
+ if ( msg == null ) {
+ if ( properties != null ) {
+ for(final Object val : properties.values()) {
+ if ( val != null && !(val instanceof Serializable) ) {
+ return "Discarding job - properties must be serializable: " + jobTopic + " : " + properties;
+ }
+ }
+ }
+ }
+ return msg;
+ }
+
+ /**
+ * Create an event from a job
+ * @param job The job
+ * @return New event object.
+ */
+ public static Event toEvent(final Job job) {
+ final Map<String, Object> eventProps = new HashMap<String, Object>();
+ eventProps.putAll(((JobImpl)job).getProperties());
+ eventProps.put(ResourceHelper.PROPERTY_JOB_ID, job.getId());
+ eventProps.remove(JobConsumer.PROPERTY_JOB_ASYNC_HANDLER);
+ return new Event(job.getTopic(), eventProps);
+ }
+
+ /**
+ * Append properties to the string builder
+ */
+ private static void appendProperties(final StringBuilder sb,
+ final Map<String, Object> properties) {
+ if ( properties != null ) {
+ sb.append(", properties=");
+ boolean first = true;
+ for(final String propName : properties.keySet()) {
+ if ( propName.equals(ResourceHelper.PROPERTY_JOB_ID)
+ || propName.equals(ResourceHelper.PROPERTY_JOB_TOPIC) ) {
+ continue;
+ }
+ if ( first ) {
+ first = false;
+ } else {
+ sb.append(",");
+ }
+ sb.append(propName);
+ sb.append('=');
+ final Object value = properties.get(propName);
+ // the toString() method of Calendar is very verbose
+ // therefore we do a toString for these objects based
+ // on a date
+ if ( value instanceof Calendar ) {
+ sb.append(value.getClass().getName());
+ sb.append('(');
+ sb.append(((Calendar)value).getTime());
+ sb.append(')');
+ } else {
+ sb.append(value);
+ }
+ }
+ }
+ }
+
+ /**
+ * Improved toString method for a job.
+ * This method prints out the job topic and all of the properties.
+ */
+ public static String toString(final String jobTopic,
+ final Map<String, Object> properties) {
+ final StringBuilder sb = new StringBuilder("Sling Job ");
+ sb.append("[topic=");
+ sb.append(jobTopic);
+ appendProperties(sb, properties);
+
+ sb.append("]");
+ return sb.toString();
+ }
+
+ /**
+ * Improved toString method for a job.
+ * This method prints out the job topic and all of the properties.
+ */
+ public static String toString(final Job job) {
+ if ( job != null ) {
+ final StringBuilder sb = new StringBuilder("Sling Job ");
+ sb.append("[topic=");
+ sb.append(job.getTopic());
+ sb.append(", id=");
+ sb.append(job.getId());
+ appendProperties(sb, ((JobImpl)job).getProperties());
+ sb.append("]");
+ return sb.toString();
+ }
+ return "<null>";
+ }
+
+ /**
+ * Read a job
+ */
+ public static JobImpl readJob(final Logger logger, final Resource resource) {
+ JobImpl job = null;
+ if ( resource != null ) {
+ try {
+ final ValueMap vm = ResourceHelper.getValueMap(resource);
+
+ // check job topic and job id
+ final String errorMessage = Utility.checkJobTopic(vm.get(ResourceHelper.PROPERTY_JOB_TOPIC));
+ final String jobId = vm.get(ResourceHelper.PROPERTY_JOB_ID, String.class);
+ if ( errorMessage == null && jobId != null ) {
+ final String topic = vm.get(ResourceHelper.PROPERTY_JOB_TOPIC, String.class);
+ final Map<String, Object> jobProperties = ResourceHelper.cloneValueMap(vm);
+
+ jobProperties.put(JobImpl.PROPERTY_RESOURCE_PATH, resource.getPath());
+ // convert to integers (JCR supports only long...)
+ jobProperties.put(Job.PROPERTY_JOB_RETRIES, vm.get(Job.PROPERTY_JOB_RETRIES, Integer.class));
+ jobProperties.put(Job.PROPERTY_JOB_RETRY_COUNT, vm.get(Job.PROPERTY_JOB_RETRY_COUNT, Integer.class));
+ if ( vm.get(Job.PROPERTY_JOB_PROGRESS_STEPS) != null ) {
+ jobProperties.put(Job.PROPERTY_JOB_PROGRESS_STEPS, vm.get(Job.PROPERTY_JOB_PROGRESS_STEPS, Integer.class));
+ }
+ if ( vm.get(Job.PROPERTY_JOB_PROGRESS_STEP) != null ) {
+ jobProperties.put(Job.PROPERTY_JOB_PROGRESS_STEP, vm.get(Job.PROPERTY_JOB_PROGRESS_STEP, Integer.class));
+ }
+ @SuppressWarnings("unchecked")
+ final List<Exception> readErrorList = (List<Exception>) jobProperties.get(ResourceHelper.PROPERTY_MARKER_READ_ERROR_LIST);
+ if ( readErrorList != null ) {
+ for(final Exception e : readErrorList) {
+ logger.warn("Unable to read job from " + resource.getPath(), e);
+ }
+ }
+ job = new JobImpl(topic,
+ jobId,
+ jobProperties);
+ } else {
+ if ( errorMessage != null ) {
+ logger.warn("{} : {}", errorMessage, resource.getPath());
+ } else if ( jobId == null ) {
+ logger.warn("Discarding job - no job id found : {}", resource.getPath());
+ }
+ // remove the job as the topic is invalid anyway
+ try {
+ resource.getResourceResolver().delete(resource);
+ resource.getResourceResolver().commit();
+ } catch ( final PersistenceException ignore) {
+ logger.debug("Unable to remove job resource.", ignore);
+ }
+ }
+ } catch (final InstantiationException ie) {
+ // something happened with the resource in the meantime
+ logger.debug("Unable to instantiate resource.", ie);
+ } catch (final RuntimeException re) {
+ logger.debug("Unable to read resource.", re);
+ }
+
+ }
+ return job;
+ }
+
+ private static final Comparator<Resource> RESOURCE_COMPARATOR = new Comparator<Resource>() {
+
+ @Override
+ public int compare(final Resource o1, final Resource o2) {
+ Integer value1 = null;
+ try {
+ value1 = Integer.valueOf(o1.getName());
+ } catch ( final NumberFormatException nfe) {
+ // ignore
+ }
+ Integer value2 = null;
+ try {
+ value2 = Integer.valueOf(o2.getName());
+ } catch ( final NumberFormatException nfe) {
+ // ignore
+ }
+ if ( value1 != null && value2 != null ) {
+ return value1.compareTo(value2);
+ }
+ return o1.getName().compareTo(o2.getName());
+ }
+ };
+
+ /**
+ * Helper method to read all children of a resource and sort them by name
+ * @param type The type of resources (for debugging)
+ * @param rsrc The parent resource
+ * @return Sorted list of children.
+ */
+ public static List<Resource> getSortedChildren(final Logger logger, final String type, final Resource rsrc) {
+ final List<Resource> children = new ArrayList<Resource>();
+ final Iterator<Resource> monthIter = rsrc.listChildren();
+ while ( monthIter.hasNext() ) {
+ final Resource monthResource = monthIter.next();
+ children.add(monthResource);
+ logger.debug("Found {} : {}", type, monthResource.getName());
+ }
+ Collections.sort(children, RESOURCE_COMPARATOR);
+ return children;
+ }
+
+ /**
+ * Log a deprecation warning on level info into the log
+ * @param logger The logger to use
+ * @param message The message.
+ */
+ public static void logDeprecated(final Logger logger, final String message) {
+ if ( LOG_DEPRECATION_WARNINGS && logger.isInfoEnabled() ) {
+ logger.info("DEPRECATION-WARNING: " + message, new Exception(message));
+ }
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/config/ConfigurationChangeListener.java b/src/main/java/org/apache/sling/event/impl/jobs/config/ConfigurationChangeListener.java
new file mode 100644
index 0000000..71b6fe6
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/config/ConfigurationChangeListener.java
@@ -0,0 +1,33 @@
+/*
+ * 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.event.impl.jobs.config;
+
+/**
+ * Listener interface to get topology / queue changes.
+ * Components interested in configuration changes can subscribe
+ * themselves using the {@link JobManagerConfiguration}.
+ */
+public interface ConfigurationChangeListener {
+
+ /**
+ * Notify about a configuration change.
+ * @param active {@code true} if job processing is active, otherwise {@code false}
+ */
+ void configurationChanged(final boolean active);
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/config/ConfigurationConstants.java b/src/main/java/org/apache/sling/event/impl/jobs/config/ConfigurationConstants.java
new file mode 100644
index 0000000..18f4886
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/config/ConfigurationConstants.java
@@ -0,0 +1,48 @@
+/*
+ * 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.event.impl.jobs.config;
+
+/**
+ * Constants for the queue configuration.
+ */
+public abstract class ConfigurationConstants {
+
+ public static final int NUMBER_OF_PROCESSORS = Runtime.getRuntime().availableProcessors();
+
+ public static final String DEFAULT_TYPE = "UNORDERED";
+ public static final String DEFAULT_PRIORITY = "NORM";
+ public static final int DEFAULT_RETRIES = 10;
+ public static final long DEFAULT_RETRY_DELAY = 2000;
+ public static final int DEFAULT_MAX_PARALLEL = 15;
+ public static final boolean DEFAULT_KEEP_JOBS = false;
+ public static final int DEFAULT_THREAD_POOL_SIZE = 0;
+ public static final boolean DEFAULT_PREFER_RUN_ON_CREATION_INSTANCE = false;
+
+ public static final String PROP_NAME = "queue.name";
+ public static final String PROP_TYPE = "queue.type";
+ public static final String PROP_TOPICS = "queue.topics";
+ public static final String PROP_MAX_PARALLEL = "queue.maxparallel";
+ public static final String PROP_RETRIES = "queue.retries";
+ public static final String PROP_RETRY_DELAY = "queue.retrydelay";
+ public static final String PROP_PRIORITY = "queue.priority";
+ public static final String PROP_KEEP_JOBS = "queue.keepJobs";
+ public static final String PROP_THREAD_POOL_SIZE = "queue.threadPoolSize";
+ public static final String PROP_PREFER_RUN_ON_CREATION_INSTANCE = "queue.preferRunOnCreationInstance";
+
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/config/InternalQueueConfiguration.java b/src/main/java/org/apache/sling/event/impl/jobs/config/InternalQueueConfiguration.java
new file mode 100644
index 0000000..c8457a2
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/config/InternalQueueConfiguration.java
@@ -0,0 +1,402 @@
+/*
+ * 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.event.impl.jobs.config;
+
+import java.util.Arrays;
+import java.util.Map;
+
+import org.apache.felix.scr.annotations.Activate;
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.ConfigurationPolicy;
+import org.apache.felix.scr.annotations.Properties;
+import org.apache.felix.scr.annotations.Property;
+import org.apache.felix.scr.annotations.PropertyOption;
+import org.apache.felix.scr.annotations.PropertyUnbounded;
+import org.apache.felix.scr.annotations.Service;
+import org.apache.sling.commons.osgi.PropertiesUtil;
+import org.apache.sling.event.impl.support.TopicMatcher;
+import org.apache.sling.event.impl.support.TopicMatcherHelper;
+import org.apache.sling.event.jobs.QueueConfiguration;
+import org.osgi.framework.Constants;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Component(metatype=true,
+ name="org.apache.sling.event.jobs.QueueConfiguration",
+ label="Apache Sling Job Queue Configuration",
+ description="The configuration of a job processing queue.",
+ configurationFactory=true, policy=ConfigurationPolicy.REQUIRE)
+@Service(value={InternalQueueConfiguration.class})
+@Properties({
+ @Property(name=ConfigurationConstants.PROP_NAME,
+ label="Name",
+ description="The name of the queue. If matching is used the token {0} can be used to substitute the real value."),
+ @Property(name=ConfigurationConstants.PROP_TOPICS,
+ unbounded=PropertyUnbounded.ARRAY,
+ label="Topics",
+ description="This value is required and lists the topics processed by "
+ + "this queue. The value is a list of strings. If a string ends with a dot, "
+ + "all topics in exactly this package match. If the string ends with a star, "
+ + "all topics in this package and all subpackages match. If the string neither "
+ + "ends with a dot nor with a star, this is assumed to define an exact topic."),
+ @Property(name=ConfigurationConstants.PROP_TYPE,
+ value=ConfigurationConstants.DEFAULT_TYPE,
+ options={@PropertyOption(name="UNORDERED",value="Parallel"),
+ @PropertyOption(name="ORDERED",value="Ordered"),
+ @PropertyOption(name="TOPIC_ROUND_ROBIN",value="Topic Round Robin")},
+ label="Type",
+ description="The queue type."),
+ @Property(name=ConfigurationConstants.PROP_MAX_PARALLEL,
+ doubleValue=ConfigurationConstants.DEFAULT_MAX_PARALLEL,
+ label="Maximum Parallel Jobs",
+ description="The maximum number of parallel jobs started for this queue. "
+ + "A value of -1 is substituted with the number of available processors. "
+ + "Positive integer values specify number of processors to use. Can be greater than number of processors. "
+ + "A decimal number between 0.0 and 1.0 is treated as a fraction of available processors. "
+ + "For example 0.5 means half of the available processors. For ordered queue types this value is ignored (always enforced to be 1)."),
+ @Property(name=ConfigurationConstants.PROP_RETRIES,
+ intValue=ConfigurationConstants.DEFAULT_RETRIES,
+ label="Maximum Retries",
+ description="The maximum number of times a failed job slated "
+ + "for retries is actually retried. If a job has been retried this number of "
+ + "times and still fails, it is not rescheduled and assumed to have failed. The "
+ + "default value is 10."),
+ @Property(name=ConfigurationConstants.PROP_RETRY_DELAY,
+ longValue=ConfigurationConstants.DEFAULT_RETRY_DELAY,
+ label="Retry Delay",
+ description="The number of milliseconds to sleep between two "
+ + "consecutive retries of a job which failed and was set to be retried. The "
+ + "default value is 2 seconds. This value is only relevant if there is a single "
+ + "failed job in the queue. If there are multiple failed jobs, each job is "
+ + "retried in turn without an intervening delay."),
+ @Property(name=ConfigurationConstants.PROP_PRIORITY,
+ value=ConfigurationConstants.DEFAULT_PRIORITY,
+ options={@PropertyOption(name="NORM",value="Norm"),
+ @PropertyOption(name="MIN",value="Min"),
+ @PropertyOption(name="MAX",value="Max")},
+ label="Priority",
+ description="The priority for the threads used by this queue. Default is norm."),
+ @Property(name=ConfigurationConstants.PROP_KEEP_JOBS,
+ boolValue=ConfigurationConstants.DEFAULT_KEEP_JOBS,
+ label="Keep History",
+ description="If this option is enabled, successful finished jobs are kept "
+ + "to provide a complete history."),
+ @Property(name=ConfigurationConstants.PROP_PREFER_RUN_ON_CREATION_INSTANCE,
+ boolValue=ConfigurationConstants.DEFAULT_PREFER_RUN_ON_CREATION_INSTANCE,
+ label="Prefer Creation Instance",
+ description="If this option is enabled, the jobs are tried to "
+ + "be run on the instance where the job was created."),
+ @Property(name=ConfigurationConstants.PROP_THREAD_POOL_SIZE,
+ intValue=ConfigurationConstants.DEFAULT_THREAD_POOL_SIZE,
+ label="Thread Pool Size",
+ description="Optional configuration value for a thread pool to be used by "
+ + "this queue. If this is value has a positive number of threads configuration, this queue uses "
+ + "an own thread pool with the configured number of threads."),
+ @Property(name=Constants.SERVICE_RANKING,
+ intValue=0,
+ propertyPrivate=false,
+ label="Ranking",
+ description="Integer value defining the ranking of this queue configuration. "
+ + "If more than one queue matches a job topic, the one with the highest ranking is used."),
+ @Property(name="webconsole.configurationFactory.nameHint", value="Queue: {" + ConfigurationConstants.PROP_NAME + "}")
+})
+public class InternalQueueConfiguration
+ implements QueueConfiguration, Comparable<InternalQueueConfiguration> {
+
+ /** Logger. */
+ private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+ /** The name of the queue. */
+ private String name;
+
+ /** The queue type. */
+ private Type type;
+
+ /** Number of retries. */
+ private int retries;
+
+ /** Retry delay. */
+ private long retryDelay;
+
+ /** Thread priority. */
+ private ThreadPriority priority;
+
+ /** The maximum number of parallel processes (for non ordered queues) */
+ private int maxParallelProcesses;
+
+ /** The ordering. */
+ private int serviceRanking;
+
+ /** The matchers for topics. */
+ private TopicMatcher[] matchers;
+
+ /** The configured topics. */
+ private String[] topics;
+
+ /** Keep jobs. */
+ private boolean keepJobs;
+
+ /** Valid flag. */
+ private boolean valid = false;
+
+ /** Optional thread pool size. */
+ private int ownThreadPoolSize;
+
+ /** Prefer creation instance. */
+ private boolean preferCreationInstance;
+
+ private String pid;
+
+ /**
+ * Create a new configuration from a config
+ */
+ public static InternalQueueConfiguration fromConfiguration(final Map<String, Object> params) {
+ final InternalQueueConfiguration c = new InternalQueueConfiguration();
+ c.activate(params);
+ return c;
+ }
+
+ public InternalQueueConfiguration() {
+ // nothing to do, see activate
+ }
+
+ /**
+ * Create a new queue configuration
+ */
+ @Activate
+ protected void activate(final Map<String, Object> params) {
+ this.name = PropertiesUtil.toString(params.get(ConfigurationConstants.PROP_NAME), null);
+ try {
+ this.priority = ThreadPriority.valueOf(PropertiesUtil.toString(params.get(ConfigurationConstants.PROP_PRIORITY), ConfigurationConstants.DEFAULT_PRIORITY));
+ } catch ( final IllegalArgumentException iae) {
+ logger.warn("Invalid value for queue priority. Using default instead of : {}", params.get(ConfigurationConstants.PROP_PRIORITY));
+ this.priority = ThreadPriority.valueOf(ConfigurationConstants.DEFAULT_PRIORITY);
+ }
+ try {
+ this.type = Type.valueOf(PropertiesUtil.toString(params.get(ConfigurationConstants.PROP_TYPE), ConfigurationConstants.DEFAULT_TYPE));
+ } catch ( final IllegalArgumentException iae) {
+ logger.error("Invalid value for queue type configuration: {}", params.get(ConfigurationConstants.PROP_TYPE));
+ this.type = null;
+ }
+ this.retries = PropertiesUtil.toInteger(params.get(ConfigurationConstants.PROP_RETRIES), ConfigurationConstants.DEFAULT_RETRIES);
+ this.retryDelay = PropertiesUtil.toLong(params.get(ConfigurationConstants.PROP_RETRY_DELAY), ConfigurationConstants.DEFAULT_RETRY_DELAY);
+
+ // Float values are treated as percentage. int values are treated as number of cores, -1 == all available
+ // Note: the value is based on the core count at startup. It will not change dynamically if core count changes.
+ int cores = ConfigurationConstants.NUMBER_OF_PROCESSORS;
+ final double inMaxParallel = PropertiesUtil.toDouble(params.get(ConfigurationConstants.PROP_MAX_PARALLEL),
+ ConfigurationConstants.DEFAULT_MAX_PARALLEL);
+ logger.debug("Max parallel for queue {} is {}", this.name, inMaxParallel);
+ if ((inMaxParallel == Math.floor(inMaxParallel)) && !Double.isInfinite(inMaxParallel)) {
+ // integral type
+ if ((int) inMaxParallel == 0) {
+ logger.warn("Max threads property for {} set to zero.", this.name);
+ }
+ this.maxParallelProcesses = (inMaxParallel <= -1 ? cores : (int) inMaxParallel);
+ } else {
+ // percentage (rounded)
+ if ((inMaxParallel > 0.0) && (inMaxParallel < 1.0)) {
+ this.maxParallelProcesses = (int) Math.round(cores * inMaxParallel);
+ } else {
+ logger.warn("Invalid queue max parallel value for queue {}. Using {}", this.name, cores);
+ this.maxParallelProcesses = cores;
+ }
+ }
+ logger.debug("Thread pool size for {} was set to {}", this.name, this.maxParallelProcesses);
+
+ // ignore parallel setting for ordered queues
+ if ( this.type == Type.ORDERED ) {
+ this.maxParallelProcesses = 1;
+ }
+ final String[] topicsParam = PropertiesUtil.toStringArray(params.get(ConfigurationConstants.PROP_TOPICS));
+ this.matchers = TopicMatcherHelper.buildMatchers(topicsParam);
+ if ( this.matchers == null ) {
+ this.topics = null;
+ } else {
+ this.topics = topicsParam;
+ }
+ this.keepJobs = PropertiesUtil.toBoolean(params.get(ConfigurationConstants.PROP_KEEP_JOBS), ConfigurationConstants.DEFAULT_KEEP_JOBS);
+ this.serviceRanking = PropertiesUtil.toInteger(params.get(Constants.SERVICE_RANKING), 0);
+ this.ownThreadPoolSize = PropertiesUtil.toInteger(params.get(ConfigurationConstants.PROP_THREAD_POOL_SIZE), ConfigurationConstants.DEFAULT_THREAD_POOL_SIZE);
+ this.preferCreationInstance = PropertiesUtil.toBoolean(params.get(ConfigurationConstants.PROP_PREFER_RUN_ON_CREATION_INSTANCE), ConfigurationConstants.DEFAULT_PREFER_RUN_ON_CREATION_INSTANCE);
+ this.pid = (String)params.get(Constants.SERVICE_PID);
+ this.valid = this.checkIsValid();
+ }
+
+ /**
+ * Check if this configuration is valid,
+ * If it is invalid, it is ignored.
+ */
+ private boolean checkIsValid() {
+ if ( type == null ) {
+ return false;
+ }
+ boolean hasMatchers = false;
+ if ( this.matchers != null ) {
+ for(final TopicMatcher m : this.matchers ) {
+ if ( m != null ) {
+ hasMatchers = true;
+ break;
+ }
+ }
+ }
+ if ( !hasMatchers ) {
+ return false;
+ }
+ if ( name == null || name.length() == 0 ) {
+ return false;
+ }
+ if ( retries < -1 ) {
+ return false;
+ }
+ if ( maxParallelProcesses < 1 ) {
+ return false;
+ }
+ return true;
+ }
+
+ public boolean isValid() {
+ return this.valid;
+ }
+
+ /**
+ * Check if the queue processes the event.
+ * @param topic The topic of the event
+ * @return The queue name or <code>null</code>
+ */
+ public String match(final String topic) {
+ if ( this.matchers != null ) {
+ for(final TopicMatcher m : this.matchers ) {
+ if ( m != null ) {
+ final String rep = m.match(topic);
+ if ( rep != null ) {
+ return this.name.replace("{0}", rep);
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Return the name of the queue.
+ */
+ public String getName() {
+ return this.name;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.QueueConfiguration#getRetryDelayInMs()
+ */
+ @Override
+ public long getRetryDelayInMs() {
+ return this.retryDelay;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.QueueConfiguration#getMaxRetries()
+ */
+ @Override
+ public int getMaxRetries() {
+ return this.retries;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.QueueConfiguration#getType()
+ */
+ @Override
+ public Type getType() {
+ return this.type;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.QueueConfiguration#getMaxParallel()
+ */
+ @Override
+ public int getMaxParallel() {
+ return this.maxParallelProcesses;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.QueueConfiguration#getTopics()
+ */
+ @Override
+ public String[] getTopics() {
+ return this.topics;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.QueueConfiguration#getRanking()
+ */
+ @Override
+ public int getRanking() {
+ return this.serviceRanking;
+ }
+
+ public String getPid() {
+ return this.pid;
+ }
+
+ @Override
+ public boolean isKeepJobs() {
+ return this.keepJobs;
+ }
+
+ @Override
+ public int getOwnThreadPoolSize() {
+ return this.ownThreadPoolSize;
+ }
+
+ @Override
+ public boolean isPreferRunOnCreationInstance() {
+ return this.preferCreationInstance;
+ }
+
+ @Override
+ public String toString() {
+ return "Queue-Configuration(" + this.hashCode() + ") : {" +
+ "name=" + this.name +
+ ", type=" + this.type +
+ ", topics=" + (this.matchers == null ? "[]" : Arrays.toString(this.matchers)) +
+ ", maxParallelProcesses=" + this.maxParallelProcesses +
+ ", retries=" + this.retries +
+ ", retryDelayInMs=" + this.retryDelay +
+ ", keepJobs=" + this.keepJobs +
+ ", preferRunOnCreationInstance=" + this.preferCreationInstance +
+ ", ownThreadPoolSize=" + this.ownThreadPoolSize +
+ ", serviceRanking=" + this.serviceRanking +
+ ", pid=" + this.pid +
+ ", isValid=" + this.isValid() + "}";
+ }
+
+ @Override
+ public int compareTo(final InternalQueueConfiguration other) {
+ if ( this.serviceRanking < other.serviceRanking ) {
+ return 1;
+ } else if ( this.serviceRanking > other.serviceRanking ) {
+ return -1;
+ }
+ return 0;
+ }
+
+ @Override
+ public ThreadPriority getThreadPriority() {
+ return this.priority;
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/config/JobManagerConfiguration.java b/src/main/java/org/apache/sling/event/impl/jobs/config/JobManagerConfiguration.java
new file mode 100644
index 0000000..3cc8b72
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/config/JobManagerConfiguration.java
@@ -0,0 +1,661 @@
+/*
+ * 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.event.impl.jobs.config;
+
+import java.sql.Date;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.apache.felix.scr.annotations.Activate;
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.Deactivate;
+import org.apache.felix.scr.annotations.Modified;
+import org.apache.felix.scr.annotations.Properties;
+import org.apache.felix.scr.annotations.Property;
+import org.apache.felix.scr.annotations.Reference;
+import org.apache.felix.scr.annotations.Service;
+import org.apache.sling.api.resource.LoginException;
+import org.apache.sling.api.resource.PersistenceException;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.ResourceResolverFactory;
+import org.apache.sling.commons.osgi.PropertiesUtil;
+import org.apache.sling.commons.scheduler.Scheduler;
+import org.apache.sling.discovery.TopologyEvent;
+import org.apache.sling.discovery.TopologyEvent.Type;
+import org.apache.sling.discovery.TopologyEventListener;
+import org.apache.sling.discovery.commons.InitDelayingTopologyEventListener;
+import org.apache.sling.event.impl.EnvironmentComponent;
+import org.apache.sling.event.impl.jobs.Utility;
+import org.apache.sling.event.impl.jobs.tasks.CheckTopologyTask;
+import org.apache.sling.event.impl.jobs.tasks.FindUnfinishedJobsTask;
+import org.apache.sling.event.impl.jobs.tasks.UpgradeTask;
+import org.apache.sling.event.impl.support.Environment;
+import org.apache.sling.event.impl.support.ResourceHelper;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.serviceusermapping.ServiceUserMapped;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Configuration of the job handling
+ *
+ */
+@Component(immediate=true, metatype=true,
+ label="Apache Sling Job Manager",
+ description="This is the central service of the job handling.",
+ name="org.apache.sling.event.impl.jobs.jcr.PersistenceHandler")
+@Service(value={JobManagerConfiguration.class})
+@Properties({
+ @Property(name=JobManagerConfiguration.PROPERTY_DISABLE_DISTRIBUTION,
+ boolValue=JobManagerConfiguration.DEFAULT_DISABLE_DISTRIBUTION,
+ label="Disable Distribution",
+ description="If the distribution is disabled, all jobs will be processed on the leader only! "
+ + "Please use this switch with care."),
+ @Property(name=JobManagerConfiguration.PROPERTY_LOG_DEPRECATION_WARNINGS,
+ boolValue=JobManagerConfiguration.DEFAULT_LOG_DEPRECATION_WARNINGS,
+ label="Deprecation Warnings",
+ description="If this switch is enabled, deprecation warnings will be logged with the INFO level."),
+ @Property(name=JobManagerConfiguration.PROPERTY_STARTUP_DELAY,
+ longValue=JobManagerConfiguration.DEFAULT_STARTUP_DELAY,
+ label="Startup Delay",
+ description="Specify amount in seconds that job manager waits on startup before starting with job handling. "
+ + "This can be used to allow enough time to restart a cluster before jobs are eventually reassigned."),
+ @Property(name=JobManagerConfiguration.PROPERTY_REPOSITORY_PATH,
+ value=JobManagerConfiguration.DEFAULT_REPOSITORY_PATH, propertyPrivate=true),
+ @Property(name=JobManagerConfiguration.PROPERTY_SCHEDULED_JOBS_PATH,
+ value=JobManagerConfiguration.DEFAULT_SCHEDULED_JOBS_PATH, propertyPrivate=true),
+ @Property(name=JobManagerConfiguration.PROPERTY_BACKGROUND_LOAD_DELAY,
+ longValue=JobManagerConfiguration.DEFAULT_BACKGROUND_LOAD_DELAY, propertyPrivate=true),
+})
+public class JobManagerConfiguration {
+
+ /** Logger. */
+ private final Logger logger = LoggerFactory.getLogger("org.apache.sling.event.impl.jobs");
+
+ /** Audit Logger. */
+ private final Logger auditLogger = LoggerFactory.getLogger("org.apache.sling.event.jobs.audit");
+
+ /** Default resource path for jobs. */
+ public static final String DEFAULT_REPOSITORY_PATH = "/var/eventing/jobs";
+
+ /** Default background load delay. */
+ public static final long DEFAULT_BACKGROUND_LOAD_DELAY = 10;
+
+ /** Default startup delay. */
+ public static final long DEFAULT_STARTUP_DELAY = 30;
+
+ /** Default for disabling the distribution. */
+ public static final boolean DEFAULT_DISABLE_DISTRIBUTION = false;
+
+ /** Default resource path for scheduled jobs. */
+ public static final String DEFAULT_SCHEDULED_JOBS_PATH = "/var/eventing/scheduled-jobs";
+
+ /** The path where all jobs are stored. */
+ public static final String PROPERTY_REPOSITORY_PATH = "repository.path";
+
+ /** The background loader waits this time of seconds after startup before loading events from the repository. (in secs) */
+ public static final String PROPERTY_BACKGROUND_LOAD_DELAY = "load.delay";
+
+ /** The entire job handling waits time amount of seconds until it starts - to allow avoiding reassign on restart of a cluster */
+ public static final String PROPERTY_STARTUP_DELAY = "startup.delay";
+
+ /** Configuration switch for distributing the jobs. */
+ public static final String PROPERTY_DISABLE_DISTRIBUTION = "job.consumermanager.disableDistribution";
+
+ /** Configuration property for the scheduled jobs path. */
+ public static final String PROPERTY_SCHEDULED_JOBS_PATH = "job.scheduled.jobs.path";
+
+ /** Default value for background loading. */
+ public static final boolean DEFAULT_BACKGROUND_LOAD_SEARCH = true;
+
+ /** Configuration property for deprecation warnings. */
+ public static final String PROPERTY_LOG_DEPRECATION_WARNINGS = "job.log.deprecation";
+
+ /** Default value for deprecation warnings. */
+ public static final boolean DEFAULT_LOG_DEPRECATION_WARNINGS = true;
+
+ /** The jobs base path with a slash. */
+ private String jobsBasePathWithSlash;
+
+ /** The base path for assigned jobs. */
+ private String assignedJobsPath;
+
+ /** The base path for unassigned jobs. */
+ private String unassignedJobsPath;
+
+ /** The base path for assigned jobs to the current instance. */
+ private String localJobsPath;
+
+ /** The base path for assigned jobs to the current instance - ending with a slash. */
+ private String localJobsPathWithSlash;
+
+ private String previousVersionAnonPath;
+
+ private String previousVersionIdentifiedPath;
+
+ private volatile long backgroundLoadDelay;
+
+ private volatile long startupDelay;
+
+ private volatile InitDelayingTopologyEventListener startupDelayListener;
+
+ private volatile boolean disabledDistribution;
+
+ private String storedCancelledJobsPath;
+
+ private String storedSuccessfulJobsPath;
+
+ /** The resource path where scheduled jobs are stored. */
+ private String scheduledJobsPath;
+
+ /** The resource path where scheduled jobs are stored - ending with a slash. */
+ private String scheduledJobsPathWithSlash;
+
+ /** List of topology awares. */
+ private final List<ConfigurationChangeListener> listeners = new ArrayList<ConfigurationChangeListener>();
+
+ /** The environment component. */
+ @Reference
+ private EnvironmentComponent environment;
+
+ @Reference
+ private ResourceResolverFactory resourceResolverFactory;
+
+ @Reference
+ private QueueConfigurationManager queueConfigManager;
+
+ @Reference
+ private Scheduler scheduler;
+
+ @Reference
+ private ServiceUserMapped serviceUserMapped;
+
+ /** Is this still active? */
+ private final AtomicBoolean active = new AtomicBoolean(false);
+
+ /** The topology capabilities. */
+ private volatile TopologyCapabilities topologyCapabilities;
+
+ /**
+ * Activate this component.
+ * @param props Configuration properties
+ * @throws RuntimeException If the default paths can't be created
+ */
+ @Activate
+ protected void activate(final Map<String, Object> props) {
+ this.update(props);
+ this.jobsBasePathWithSlash = PropertiesUtil.toString(props.get(PROPERTY_REPOSITORY_PATH),
+ DEFAULT_REPOSITORY_PATH) + '/';
+
+ // create initial resources
+ this.assignedJobsPath = this.jobsBasePathWithSlash + "assigned";
+ this.unassignedJobsPath = this.jobsBasePathWithSlash + "unassigned";
+
+ this.localJobsPath = this.assignedJobsPath.concat("/").concat(Environment.APPLICATION_ID);
+ this.localJobsPathWithSlash = this.localJobsPath.concat("/");
+
+ this.previousVersionAnonPath = this.jobsBasePathWithSlash + "anon";
+ this.previousVersionIdentifiedPath = this.jobsBasePathWithSlash + "identified";
+
+ this.storedCancelledJobsPath = this.jobsBasePathWithSlash + "cancelled";
+ this.storedSuccessfulJobsPath = this.jobsBasePathWithSlash + "finished";
+
+ this.scheduledJobsPath = PropertiesUtil.toString(props.get(PROPERTY_SCHEDULED_JOBS_PATH),
+ DEFAULT_SCHEDULED_JOBS_PATH);
+ this.scheduledJobsPathWithSlash = this.scheduledJobsPath + "/";
+
+ // create initial resources
+ final ResourceResolver resolver = this.createResourceResolver();
+ try {
+ ResourceHelper.getOrCreateBasePath(resolver, this.getLocalJobsPath());
+ ResourceHelper.getOrCreateBasePath(resolver, this.getUnassignedJobsPath());
+ } catch ( final PersistenceException pe ) {
+ logger.error("Unable to create default paths: " + pe.getMessage(), pe);
+ throw new RuntimeException(pe);
+ } finally {
+ resolver.close();
+ }
+ this.active.set(true);
+
+ // SLING-5560 : use an InitDelayingTopologyEventListener
+ if (this.startupDelay > 0) {
+ logger.debug("activate: job manager will start in {} sec. ({})", this.startupDelay, PROPERTY_STARTUP_DELAY);
+ this.startupDelayListener = new InitDelayingTopologyEventListener(startupDelay, new TopologyEventListener() {
+
+ @Override
+ public void handleTopologyEvent(TopologyEvent event) {
+ doHandleTopologyEvent(event);
+ }
+ }, this.scheduler, logger);
+ } else {
+ logger.debug("activate: job manager will start without delay. ({}:{})", PROPERTY_STARTUP_DELAY, this.startupDelay);
+ }
+ }
+
+ /**
+ * Update with a new configuration
+ */
+ @Modified
+ protected void update(final Map<String, Object> props) {
+ this.disabledDistribution = PropertiesUtil.toBoolean(props.get(PROPERTY_DISABLE_DISTRIBUTION), DEFAULT_DISABLE_DISTRIBUTION);
+ this.backgroundLoadDelay = PropertiesUtil.toLong(props.get(PROPERTY_BACKGROUND_LOAD_DELAY), DEFAULT_BACKGROUND_LOAD_DELAY);
+ // SLING-5560: note that currently you can't change the startupDelay to have
+ // an immediate effect - it will only have an effect on next activation.
+ // (as 'startup delay runnable' is already scheduled in activate)
+ this.startupDelay = PropertiesUtil.toLong(props.get(PROPERTY_STARTUP_DELAY), DEFAULT_STARTUP_DELAY);
+ Utility.LOG_DEPRECATION_WARNINGS = PropertiesUtil.toBoolean(props.get(PROPERTY_LOG_DEPRECATION_WARNINGS), DEFAULT_LOG_DEPRECATION_WARNINGS);
+ }
+
+ /**
+ * Deactivate
+ */
+ @Deactivate
+ protected void deactivate() {
+ this.active.set(false);
+ if ( this.startupDelayListener != null) {
+ this.startupDelayListener.dispose();
+ this.startupDelayListener = null;
+ }
+ this.stopProcessing();
+ }
+
+ /**
+ * Is this component still active?
+ * @return Active?
+ */
+ public boolean isActive() {
+ return this.active.get();
+ }
+
+ /**
+ * Create a new resource resolver for reading and writing the resource tree.
+ * The resolver needs to be closed by the client.
+ * @return A resource resolver or {@code null} if the component is already deactivated.
+ * @throws RuntimeException if the resolver can't be created.
+ */
+ public ResourceResolver createResourceResolver() {
+ ResourceResolver resolver = null;
+ final ResourceResolverFactory factory = this.resourceResolverFactory;
+ if ( factory != null ) {
+ try {
+ resolver = this.resourceResolverFactory.getServiceResourceResolver(null);
+ } catch ( final LoginException le) {
+ logger.error("Unable to create new resource resolver: " + le.getMessage(), le);
+ throw new RuntimeException(le);
+ }
+ }
+ return resolver;
+ }
+
+ /**
+ * Get the current topology capabilities.
+ * @return The capabilities or {@code null}
+ */
+ public TopologyCapabilities getTopologyCapabilities() {
+ return this.topologyCapabilities;
+ }
+
+ public QueueConfigurationManager getQueueConfigurationManager() {
+ return this.queueConfigManager;
+ }
+
+ /**
+ * Get main logger.
+ * @return The main logger.
+ */
+ public Logger getMainLogger() {
+ return this.logger;
+ }
+
+ /**
+ * Get the resource path for all assigned jobs.
+ * @return The path - does not end with a slash.
+ */
+ public String getAssginedJobsPath() {
+ return this.assignedJobsPath;
+ }
+
+ /**
+ * Get the resource path for all unassigned jobs.
+ * @return The path - does not end with a slash.
+ */
+ public String getUnassignedJobsPath() {
+ return this.unassignedJobsPath;
+ }
+
+ /**
+ * Get the resource path for all jobs assigned to the current instance
+ * @return The path - does not end with a slash
+ */
+ public String getLocalJobsPath() {
+ return this.localJobsPath;
+ }
+
+ /** Counter for jobs without an id. */
+ private final AtomicLong jobCounter = new AtomicLong(0);
+
+ /**
+ * Create a unique job path (folder and name) for the job.
+ */
+ public String getUniquePath(final String targetId,
+ final String topic,
+ final String jobId,
+ final Map<String, Object> jobProperties) {
+ final String topicName = topic.replace('/', '.');
+ final StringBuilder sb = new StringBuilder();
+ if ( targetId != null ) {
+ sb.append(this.assignedJobsPath);
+ sb.append('/');
+ sb.append(targetId);
+ } else {
+ sb.append(this.unassignedJobsPath);
+ }
+ sb.append('/');
+ sb.append(topicName);
+ sb.append('/');
+ sb.append(jobId);
+
+ return sb.toString();
+ }
+
+ /**
+ * Get the unique job id
+ */
+ public String getUniqueId(final String jobTopic) {
+ final Calendar now = Calendar.getInstance();
+ final StringBuilder sb = new StringBuilder();
+ sb.append(now.get(Calendar.YEAR));
+ sb.append('/');
+ sb.append(now.get(Calendar.MONTH) + 1);
+ sb.append('/');
+ sb.append(now.get(Calendar.DAY_OF_MONTH));
+ sb.append('/');
+ sb.append(now.get(Calendar.HOUR_OF_DAY));
+ sb.append('/');
+ sb.append(now.get(Calendar.MINUTE));
+ sb.append('/');
+ sb.append(Environment.APPLICATION_ID);
+ sb.append('_');
+ sb.append(jobCounter.getAndIncrement());
+
+ return sb.toString();
+ }
+
+ public boolean isLocalJob(final String jobPath) {
+ return jobPath != null && jobPath.startsWith(this.localJobsPathWithSlash);
+ }
+
+ public boolean isJob(final String jobPath) {
+ return jobPath.startsWith(this.jobsBasePathWithSlash);
+ }
+
+ public String getJobsBasePathWithSlash() {
+ return this.jobsBasePathWithSlash;
+ }
+
+ public String getPreviousVersionAnonPath() {
+ return this.previousVersionAnonPath;
+ }
+
+ public String getPreviousVersionIdentifiedPath() {
+ return this.previousVersionIdentifiedPath;
+ }
+
+ public boolean disableDistribution() {
+ return this.disabledDistribution;
+ }
+
+ public String getStoredCancelledJobsPath() {
+ return this.storedCancelledJobsPath;
+ }
+
+ public String getStoredSuccessfulJobsPath() {
+ return this.storedSuccessfulJobsPath;
+ }
+
+ /**
+ * Get the storage path for finished jobs.
+ * @param topic Topic of the finished job
+ * @param jobId The job id of the finished job.
+ * @param isSuccess Whether processing was successful or not
+ * @return The complete storage path
+ */
+ public String getStoragePath(final String topic, final String jobId, final boolean isSuccess) {
+ final String topicName = topic.replace('/', '.');
+ final StringBuilder sb = new StringBuilder();
+ if ( isSuccess ) {
+ sb.append(this.storedSuccessfulJobsPath);
+ } else {
+ sb.append(this.storedCancelledJobsPath);
+ }
+ sb.append('/');
+ sb.append(topicName);
+ sb.append('/');
+ sb.append(jobId);
+
+ return sb.toString();
+
+ }
+
+ /**
+ * Check whether this is a storage path.
+ */
+ public boolean isStoragePath(final String path) {
+ return path.startsWith(this.storedCancelledJobsPath) || path.startsWith(this.storedSuccessfulJobsPath);
+ }
+
+ /**
+ * Get the scheduled jobs path
+ * @param slash If {@code false} the path is returned, if {@code true} the path appended with a slash is returned.
+ * @return The path for the scheduled jobs
+ */
+ public String getScheduledJobsPath(final boolean slash) {
+ return (slash ? this.scheduledJobsPathWithSlash : this.scheduledJobsPath);
+ }
+
+ /**
+ * Stop processing
+ * @param deactivate Whether to deactivate the capabilities
+ */
+ private void stopProcessing() {
+ logger.debug("Stopping job processing...");
+ final TopologyCapabilities caps = this.topologyCapabilities;
+
+ if ( caps != null ) {
+ // deactivate old capabilities - this stops all background processes
+ caps.deactivate();
+ this.topologyCapabilities = null;
+
+ // stop all listeners
+ this.notifiyListeners();
+ }
+ logger.debug("Job processing stopped");
+ }
+
+ /**
+ * Start processing
+ * @param eventType The event type
+ * @param newCaps The new capabilities
+ */
+ private void startProcessing(final Type eventType, final TopologyCapabilities newCaps) {
+ logger.debug("Starting job processing...");
+ // create new capabilities and update view
+ this.topologyCapabilities = newCaps;
+
+ // before we propagate the new topology we do some maintenance
+ if ( eventType == Type.TOPOLOGY_INIT ) {
+ final UpgradeTask task = new UpgradeTask(this);
+ task.run();
+
+ final FindUnfinishedJobsTask rt = new FindUnfinishedJobsTask(this);
+ rt.run();
+
+ final CheckTopologyTask mt = new CheckTopologyTask(this);
+ mt.fullRun();
+
+ notifiyListeners();
+ } else {
+ // and run checker again in some seconds (if leader)
+ // notify listeners afterwards
+ final Scheduler local = this.scheduler;
+ if ( local != null ) {
+ final Runnable r = new Runnable() {
+
+ @Override
+ public void run() {
+ if ( newCaps == topologyCapabilities && newCaps.isActive()) {
+ // start listeners
+ notifiyListeners();
+ if ( newCaps.isLeader() && newCaps.isActive() ) {
+ final CheckTopologyTask mt = new CheckTopologyTask(JobManagerConfiguration.this);
+ mt.fullRun();
+ }
+ }
+ }
+ };
+ if ( !local.schedule(r, local.AT(new Date(System.currentTimeMillis() + this.backgroundLoadDelay * 1000))) ) {
+ // if for whatever reason scheduling doesn't work, let's run now
+ r.run();
+ }
+ }
+ }
+ logger.debug("Job processing started");
+ }
+
+ /**
+ * Notify all listeners
+ */
+ private void notifiyListeners() {
+ synchronized ( this.listeners ) {
+ final TopologyCapabilities caps = this.topologyCapabilities;
+ for(final ConfigurationChangeListener l : this.listeners) {
+ l.configurationChanged(caps != null);
+ }
+ }
+ }
+
+ /**
+ * This method is invoked asynchronously from the TopologyHandler.
+ * Therefore this method can't be invoked concurrently
+ * @see org.apache.sling.discovery.TopologyEventListener#handleTopologyEvent(org.apache.sling.discovery.TopologyEvent)
+ */
+ public void handleTopologyEvent(TopologyEvent event) {
+ if ( this.startupDelayListener != null ) {
+ // with startup.delay > 0
+ this.startupDelayListener.handleTopologyEvent(event);
+ } else {
+ // classic (startup.delay <= 0)
+ this.logger.debug("Received topology event {}", event);
+ doHandleTopologyEvent(event);
+ }
+ }
+
+ void doHandleTopologyEvent(final TopologyEvent event) {
+
+ // check if there is a change of properties which doesn't affect us
+ // but we need to use the new view !
+ boolean stopProcessing = true;
+ if ( event.getType() == Type.PROPERTIES_CHANGED ) {
+ final Map<String, String> newAllInstances = TopologyCapabilities.getAllInstancesMap(event.getNewView());
+ if ( this.topologyCapabilities != null && this.topologyCapabilities.isSame(newAllInstances) ) {
+ logger.debug("No changes in capabilities - updating topology capabilities with new view");
+ stopProcessing = false;
+ }
+ }
+
+ final TopologyEvent.Type eventType = event.getType();
+
+ if ( eventType == Type.TOPOLOGY_CHANGING ) {
+ this.stopProcessing();
+
+ } else if ( eventType == Type.TOPOLOGY_INIT
+ || event.getType() == Type.TOPOLOGY_CHANGED
+ || event.getType() == Type.PROPERTIES_CHANGED ) {
+
+ if ( stopProcessing ) {
+ this.stopProcessing();
+ }
+
+ this.startProcessing(eventType, new TopologyCapabilities(event.getNewView(), this));
+ }
+ }
+
+ /**
+ * Add a topology aware listener
+ * @param service Listener to notify about changes.
+ */
+ public void addListener(final ConfigurationChangeListener service) {
+ synchronized ( this.listeners ) {
+ this.listeners.add(service);
+ service.configurationChanged(this.topologyCapabilities != null);
+ }
+ }
+
+ /**
+ * Remove a topology aware listener
+ * @param service Listener to notify about changes.
+ */
+ public void removeListener(final ConfigurationChangeListener service) {
+ synchronized ( this.listeners ) {
+ this.listeners.remove(service);
+ }
+ }
+
+ private final Map<String, Job> retryList = new HashMap<String, Job>();
+
+ public void addJobToRetryList(final Job job) {
+ synchronized ( retryList ) {
+ retryList.put(job.getId(), job);
+ }
+ }
+
+ public List<Job> clearJobRetryList() {
+ final List<Job> result = new ArrayList<Job>();
+ synchronized ( this.retryList ) {
+ result.addAll(retryList.values());
+ retryList.clear();
+ }
+ return result;
+ }
+
+ public boolean removeJobFromRetryList(final Job job) {
+ synchronized ( retryList ) {
+ return retryList.remove(job.getId()) != null;
+ }
+ }
+
+ public Job getJobFromRetryList(final String jobId) {
+ synchronized ( retryList ) {
+ return retryList.get(jobId);
+ }
+ }
+
+ /**
+ * The audit logger is logging actions for auditing.
+ * @return The logger
+ */
+ public Logger getAuditLogger() {
+ return this.auditLogger;
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/config/MainQueueConfiguration.java b/src/main/java/org/apache/sling/event/impl/jobs/config/MainQueueConfiguration.java
new file mode 100644
index 0000000..9287e6b
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/config/MainQueueConfiguration.java
@@ -0,0 +1,115 @@
+/*
+ * 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.event.impl.jobs.config;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.felix.scr.annotations.Activate;
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.Modified;
+import org.apache.felix.scr.annotations.Properties;
+import org.apache.felix.scr.annotations.Property;
+import org.apache.felix.scr.annotations.PropertyOption;
+import org.apache.felix.scr.annotations.Service;
+import org.osgi.framework.Constants;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/**
+ * This is the configuration for the main queue.
+ *
+ */
+@Component(label="Apache Sling Job Default Queue",
+ description="The configuration of the default job queue.",
+ name="org.apache.sling.event.impl.jobs.DefaultJobManager",
+ metatype=true)
+@Service(value=MainQueueConfiguration.class)
+@Properties({
+ @Property(name=ConfigurationConstants.PROP_PRIORITY,
+ value=ConfigurationConstants.DEFAULT_PRIORITY,
+ options={@PropertyOption(name="NORM",value="Norm"),
+ @PropertyOption(name="MIN",value="Min"),
+ @PropertyOption(name="MAX",value="Max")},
+ label="Priority",
+ description="The priority for the threads used by this queue. Default is norm."),
+ @Property(name=ConfigurationConstants.PROP_RETRIES,
+ intValue=ConfigurationConstants.DEFAULT_RETRIES,
+ label="Maximum Retries",
+ description="The maximum number of times a failed job slated "
+ + "for retries is actually retried. If a job has been retried this number of "
+ + "times and still fails, it is not rescheduled and assumed to have failed. The "
+ + "default value is 10."),
+ @Property(name=ConfigurationConstants.PROP_RETRY_DELAY,
+ longValue=ConfigurationConstants.DEFAULT_RETRY_DELAY,
+ label="Retry Delay",
+ description="The number of milliseconds to sleep between two "
+ + "consecutive retries of a job which failed and was set to be retried. The "
+ + "default value is 2 seconds. This value is only relevant if there is a single "
+ + "failed job in the queue. If there are multiple failed jobs, each job is "
+ + "retried in turn without an intervening delay."),
+ @Property(name=ConfigurationConstants.PROP_MAX_PARALLEL,
+ intValue=ConfigurationConstants.DEFAULT_MAX_PARALLEL,
+ label="Maximum Parallel Jobs",
+ description="The maximum number of parallel jobs started for this queue. "
+ + "A value of -1 is substituted with the number of available processors."),
+})
+public class MainQueueConfiguration {
+
+ public static final String MAIN_QUEUE_NAME = "<main queue>";
+
+ /** Default logger. */
+ private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+ private InternalQueueConfiguration mainConfiguration;
+
+ /**
+ * Activate this component.
+ * @param props Configuration properties
+ */
+ @Activate
+ protected void activate(final Map<String, Object> props) {
+ this.update(props);
+ }
+
+ /**
+ * Configure this component.
+ * @param props Configuration properties
+ */
+ @Modified
+ protected void update(final Map<String, Object> props) {
+ // create a new dictionary with the missing info and do some sanity puts
+ final Map<String, Object> queueProps = new HashMap<String, Object>(props);
+ queueProps.put(ConfigurationConstants.PROP_TOPICS, "*");
+ queueProps.put(ConfigurationConstants.PROP_NAME, MAIN_QUEUE_NAME);
+ queueProps.put(ConfigurationConstants.PROP_TYPE, InternalQueueConfiguration.Type.UNORDERED);
+ queueProps.put(Constants.SERVICE_PID, "org.apache.sling.event.impl.jobs.DefaultJobManager");
+ logger.debug("properties for queue {}: {}", MAIN_QUEUE_NAME, queueProps);
+ this.mainConfiguration = InternalQueueConfiguration.fromConfiguration(queueProps);
+ }
+
+ /**
+ * Return the main queue configuration object.
+ * @return The main queue configuration object.
+ */
+ public InternalQueueConfiguration getMainConfiguration() {
+ return this.mainConfiguration;
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/config/QueueConfigurationManager.java b/src/main/java/org/apache/sling/event/impl/jobs/config/QueueConfigurationManager.java
new file mode 100644
index 0000000..ce2b79a
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/config/QueueConfigurationManager.java
@@ -0,0 +1,169 @@
+/*
+ * 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.event.impl.jobs.config;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.Reference;
+import org.apache.felix.scr.annotations.ReferenceCardinality;
+import org.apache.felix.scr.annotations.ReferencePolicy;
+import org.apache.felix.scr.annotations.Service;
+import org.apache.sling.event.impl.support.ResourceHelper;
+
+
+/**
+ * The queue manager manages queue configurations.
+ */
+@Component
+@Service(value=QueueConfigurationManager.class)
+@Reference(referenceInterface=InternalQueueConfiguration.class, policy=ReferencePolicy.DYNAMIC,
+ cardinality=ReferenceCardinality.OPTIONAL_MULTIPLE,
+ bind="bindConfig", unbind="unbindConfig", updated="updateConfig")
+public class QueueConfigurationManager {
+
+ /** Empty configuration array. */
+ private static final InternalQueueConfiguration[] EMPTY_CONFIGS = new InternalQueueConfiguration[0];
+
+ /** Configurations - ordered by service ranking. */
+ private volatile InternalQueueConfiguration[] orderedConfigs = EMPTY_CONFIGS;
+
+ /** All configurations. */
+ private final List<InternalQueueConfiguration> configurations = new ArrayList<InternalQueueConfiguration>();
+
+ /** The main queue configuration. */
+ @Reference
+ private MainQueueConfiguration mainQueueConfiguration;
+
+ /**
+ * Add a new queue configuration.
+ * @param config A new queue configuration.
+ */
+ protected void bindConfig(final InternalQueueConfiguration config) {
+ synchronized ( configurations ) {
+ configurations.add(config);
+ this.createConfigurationCache();
+ }
+ }
+
+ /**
+ * Remove a queue configuration.
+ * @param config The queue configuration.
+ */
+ protected void unbindConfig(final InternalQueueConfiguration config) {
+ synchronized ( configurations ) {
+ configurations.remove(config);
+ this.createConfigurationCache();
+ }
+ }
+
+ /**
+ * Update a queue configuration.
+ * @param config The queue configuration.
+ */
+ protected void updateConfig(final InternalQueueConfiguration config) {
+ // InternalQueueConfiguration does not implement modified atm,
+ // but we handle this case anyway
+ synchronized ( configurations ) {
+ this.createConfigurationCache();
+ }
+ }
+
+ /**
+ * Create the configurations cache used by clients.
+ */
+ private void createConfigurationCache() {
+ if ( this.configurations.isEmpty() ) {
+ this.orderedConfigs = EMPTY_CONFIGS;
+ } else {
+ Collections.sort(configurations);
+ orderedConfigs = configurations.toArray(new InternalQueueConfiguration[configurations.size()]);
+ }
+ }
+
+ /**
+ * Return all configurations.
+ * @return An array with all queue configurations except the main queue. Array might be empty.
+ */
+ public InternalQueueConfiguration[] getConfigurations() {
+ return orderedConfigs;
+ }
+
+ /**
+ * Get the configuration for the main queue.
+ * @return The configuration for the main queue.
+ */
+ public InternalQueueConfiguration getMainQueueConfiguration() {
+ return this.mainQueueConfiguration.getMainConfiguration();
+ }
+
+ public static final class QueueInfo {
+ public InternalQueueConfiguration queueConfiguration;
+ public String queueName;
+ public String targetId;
+
+ @Override
+ public String toString() {
+ return queueName;
+ }
+
+ @Override
+ public int hashCode() {
+ return queueName.hashCode();
+ }
+
+ @Override
+ public boolean equals(final Object obj) {
+ if ( obj == this ) {
+ return true;
+ }
+ if ( obj instanceof QueueInfo ) {
+ return ((QueueInfo)obj).queueName.equals(this.queueName);
+ }
+ return false;
+ }
+ }
+
+ /**
+ * Find the queue configuration for the job.
+ * This method only returns a configuration if one matches.
+ */
+ public QueueInfo getQueueInfo(final String topic) {
+ final InternalQueueConfiguration[] configurations = this.getConfigurations();
+ for(final InternalQueueConfiguration config : configurations) {
+ if ( config.isValid() ) {
+ final String qn = config.match(topic);
+ if ( qn != null ) {
+ final QueueInfo result = new QueueInfo();
+ result.queueConfiguration = config;
+ result.queueName = ResourceHelper.filterName(qn);
+
+ return result;
+ }
+ }
+ }
+ final QueueInfo result = new QueueInfo();
+ result.queueConfiguration = this.mainQueueConfiguration.getMainConfiguration();
+ result.queueName = result.queueConfiguration.getName();
+
+ return result;
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/config/TopologyCapabilities.java b/src/main/java/org/apache/sling/event/impl/jobs/config/TopologyCapabilities.java
new file mode 100644
index 0000000..75532fb
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/config/TopologyCapabilities.java
@@ -0,0 +1,324 @@
+/*
+ * 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.event.impl.jobs.config;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+import org.apache.sling.discovery.InstanceDescription;
+import org.apache.sling.discovery.TopologyView;
+import org.apache.sling.event.impl.jobs.config.QueueConfigurationManager.QueueInfo;
+import org.apache.sling.event.impl.support.Environment;
+import org.apache.sling.event.jobs.QueueConfiguration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The capabilities of a topology.
+ */
+public class TopologyCapabilities {
+
+ public static final String PROPERTY_TOPICS = "org.apache.sling.event.jobs.consumer.topics";
+
+ /** Logger. */
+ private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+ /** Map: key: topic, value: sling IDs */
+ private final Map<String, List<InstanceDescription>> instanceCapabilities;
+
+ /** Round robin map. */
+ private final Map<String, Integer> roundRobinMap = new HashMap<String, Integer>();
+
+ /** Instance map. */
+ private final Map<String, InstanceDescription> instanceMap = new HashMap<String, InstanceDescription>();
+
+ /** Is this the leader of the cluster? */
+ private final boolean isLeader;
+
+ /** Is this still active? */
+ private volatile boolean active = true;
+
+ /** All instances. */
+ private final Map<String, String> allInstances;
+
+ /** Instance comparator. */
+ private final InstanceDescriptionComparator instanceComparator;
+
+ /** JobManagerConfiguration. */
+ private final JobManagerConfiguration jobManagerConfiguration;
+
+ /** Topology view. */
+ private final TopologyView view;
+
+ public static final class InstanceDescriptionComparator implements Comparator<InstanceDescription> {
+
+ private final String localClusterId;
+
+
+ public InstanceDescriptionComparator(final String clusterId) {
+ this.localClusterId = clusterId;
+ }
+
+ @Override
+ public int compare(final InstanceDescription o1, final InstanceDescription o2) {
+ if ( o1.getSlingId().equals(o2.getSlingId()) ) {
+ return 0;
+ }
+ final boolean o1IsLocalCluster = localClusterId.equals(o1.getClusterView().getId());
+ final boolean o2IsLocalCluster = localClusterId.equals(o2.getClusterView().getId());
+ if ( o1IsLocalCluster && !o2IsLocalCluster ) {
+ return -1;
+ }
+ if ( !o1IsLocalCluster && o2IsLocalCluster ) {
+ return 1;
+ }
+ if ( o1IsLocalCluster ) {
+ if ( o1.isLeader() && !o2.isLeader() ) {
+ return -1;
+ } else if ( o2.isLeader() && !o1.isLeader() ) {
+ return 1;
+ }
+ }
+ return o1.getSlingId().compareTo(o2.getSlingId());
+ }
+ }
+
+ public static Map<String, String> getAllInstancesMap(final TopologyView view) {
+ final Map<String, String> allInstances = new TreeMap<String, String>();
+
+ for(final InstanceDescription desc : view.getInstances() ) {
+ final String topics = desc.getProperty(PROPERTY_TOPICS);
+ if ( topics != null && topics.length() > 0 ) {
+ allInstances.put(desc.getSlingId(), topics);
+ } else {
+ allInstances.put(desc.getSlingId(), "");
+ }
+ }
+ return allInstances;
+ }
+
+ /**
+ * Create a new instance
+ * @param view The new view
+ * @param config The current job manager configuration.
+ */
+ public TopologyCapabilities(final TopologyView view,
+ final JobManagerConfiguration config) {
+ this.jobManagerConfiguration = config;
+ this.instanceComparator = new InstanceDescriptionComparator(view.getLocalInstance().getClusterView().getId());
+ this.isLeader = view.getLocalInstance().isLeader();
+ this.allInstances = getAllInstancesMap(view);
+ final Map<String, List<InstanceDescription>> newCaps = new HashMap<String, List<InstanceDescription>>();
+ for(final InstanceDescription desc : view.getInstances() ) {
+ final String topics = desc.getProperty(PROPERTY_TOPICS);
+ if ( topics != null && topics.length() > 0 ) {
+ this.logger.debug("Detected capabilities of {} : {}", desc.getSlingId(), topics);
+ for(final String topic : topics.split(",") ) {
+ List<InstanceDescription> list = newCaps.get(topic);
+ if ( list == null ) {
+ list = new ArrayList<InstanceDescription>();
+ newCaps.put(topic, list);
+ }
+ list.add(desc);
+ Collections.sort(list, this.instanceComparator);
+ }
+ }
+ this.instanceMap.put(desc.getSlingId(), desc);
+ }
+ this.instanceCapabilities = newCaps;
+ this.view = view;
+ }
+
+ /**
+ * Is this capabilities the same as represented by the provided instance map?
+ * @param newAllInstancesMap The instance map
+ * @return {@code true} if they represent the same state.
+ */
+ public boolean isSame(final Map<String, String> newAllInstancesMap) {
+ return this.allInstances.equals(newAllInstancesMap);
+ }
+
+ /**
+ * Deactivate this object.
+ */
+ public void deactivate() {
+ this.active = false;
+ }
+
+ /**
+ * Is this object still active?
+ * If it is not active anymore it should not be used!
+ * @return {@code true} if still active.
+ */
+ public boolean isActive() {
+ return this.active && this.jobManagerConfiguration.isActive() && this.view.isCurrent();
+ }
+
+ /**
+ * Is this instance still active?
+ * @param instanceId The instance id
+ * @return {@code true} if the instance is active.
+ */
+ public boolean isActive(final String instanceId) {
+ return this.allInstances.containsKey(instanceId);
+ }
+ /**
+ * Is the current instance the leader?
+ */
+ public boolean isLeader() {
+ return this.isLeader;
+ }
+
+ /**
+ * Add instances to the list if not already included
+ */
+ private void addAll(final List<InstanceDescription> potentialTargets, final List<InstanceDescription> newTargets) {
+ if ( newTargets != null ) {
+ for(final InstanceDescription desc : newTargets) {
+ boolean found = false;
+ for(final InstanceDescription existingDesc : potentialTargets) {
+ if ( desc.getSlingId().equals(existingDesc.getSlingId()) ) {
+ found = true;
+ break;
+ }
+ }
+ if ( !found ) {
+ potentialTargets.add(desc);
+ }
+ }
+ }
+ }
+
+ /**
+ * Return the potential targets (Sling IDs) sorted by ID
+ * @return A list of instance descriptions. The list might be empty.
+ */
+ public List<InstanceDescription> getPotentialTargets(final String jobTopic) {
+ // calculate potential targets
+ final List<InstanceDescription> potentialTargets = new ArrayList<InstanceDescription>();
+
+ // first: topic targets - directly handling the topic
+ addAll(potentialTargets, this.instanceCapabilities.get(jobTopic));
+
+ // second: category targets - handling the topic category
+ int pos = jobTopic.lastIndexOf('/');
+ if ( pos > 0 ) {
+ final String category = jobTopic.substring(0, pos + 1).concat("*");
+ addAll(potentialTargets, this.instanceCapabilities.get(category));
+
+ // search deep consumers (since 1.2 of the consumer package)
+ do {
+ final String subCategory = jobTopic.substring(0, pos + 1).concat("**");
+ addAll(potentialTargets, this.instanceCapabilities.get(subCategory));
+
+ pos = jobTopic.lastIndexOf('/', pos - 1);
+ } while ( pos > 0 );
+ }
+ Collections.sort(potentialTargets, this.instanceComparator);
+
+ return potentialTargets;
+ }
+
+ /**
+ * Detect the target instance.
+ */
+ public String detectTarget(final String jobTopic, final Map<String, Object> jobProperties,
+ final QueueInfo queueInfo) {
+ final List<InstanceDescription> potentialTargets = this.getPotentialTargets(jobTopic);
+ logger.debug("Potential targets for {} : {}", jobTopic, potentialTargets);
+ String createdOn = null;
+ if ( jobProperties != null ) {
+ createdOn = (String) jobProperties.get(org.apache.sling.event.jobs.Job.PROPERTY_JOB_CREATED_INSTANCE);
+ }
+ if ( createdOn == null ) {
+ createdOn = Environment.APPLICATION_ID;
+ }
+ final InstanceDescription createdOnInstance = this.instanceMap.get(createdOn);
+
+ if ( potentialTargets != null && potentialTargets.size() > 0 ) {
+ if ( createdOnInstance != null ) {
+ // create a list with local targets first.
+ final List<InstanceDescription> localTargets = new ArrayList<InstanceDescription>();
+ for(final InstanceDescription desc : potentialTargets) {
+ if ( desc.getClusterView().getId().equals(createdOnInstance.getClusterView().getId()) ) {
+ if ( !this.jobManagerConfiguration.disableDistribution() || desc.isLeader() ) {
+ localTargets.add(desc);
+ }
+ }
+ }
+ if ( localTargets.size() > 0 ) {
+ potentialTargets.clear();
+ potentialTargets.addAll(localTargets);
+ logger.debug("Potential targets filtered for {} : {}", jobTopic, potentialTargets);
+ }
+ }
+ // check prefer run on creation instance
+ if ( queueInfo.queueConfiguration.isPreferRunOnCreationInstance() ) {
+ InstanceDescription creationDesc = null;
+ for(final InstanceDescription desc : potentialTargets) {
+ if ( desc.getSlingId().equals(createdOn) ) {
+ creationDesc = desc;
+ break;
+ }
+ }
+ if ( creationDesc != null ) {
+ potentialTargets.clear();
+ potentialTargets.add(creationDesc);
+ logger.debug("Potential targets reduced to creation instance for {} : {}", jobTopic, potentialTargets);
+ }
+ }
+ if ( queueInfo.queueConfiguration.getType() == QueueConfiguration.Type.ORDERED ) {
+ // for ordered queues we always pick the first as we have to pick the same target on each cluster view
+ // on all instances (TODO - we could try to do some round robin of the whole queue)
+ final String result = potentialTargets.get(0).getSlingId();
+ logger.debug("Target for {} : {}", jobTopic, result);
+
+ return result;
+ }
+ // TODO - this is a simple round robin which is not based on the actual load
+ // of the instances
+ Integer index = this.roundRobinMap.get(jobTopic);
+ if ( index == null ) {
+ index = 0;
+ }
+ if ( index >= potentialTargets.size() ) {
+ index = 0;
+ }
+ this.roundRobinMap.put(jobTopic, index + 1);
+ final String result = potentialTargets.get(index).getSlingId();
+ logger.debug("Target for {} : {}", jobTopic, result);
+ return result;
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the instance capabilities.
+ * @return The map of instance capabilities.
+ */
+ public Map<String, List<InstanceDescription>> getInstanceCapabilities() {
+ return this.instanceCapabilities;
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/config/TopologyHandler.java b/src/main/java/org/apache/sling/event/impl/jobs/config/TopologyHandler.java
new file mode 100644
index 0000000..2c49ca2
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/config/TopologyHandler.java
@@ -0,0 +1,114 @@
+/*
+ * 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.event.impl.jobs.config;
+
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.felix.scr.annotations.Activate;
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.Deactivate;
+import org.apache.felix.scr.annotations.Reference;
+import org.apache.felix.scr.annotations.Service;
+import org.apache.sling.discovery.TopologyEvent;
+import org.apache.sling.discovery.TopologyEventListener;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This topology handler is handling the topology change events asynchronously
+ * and processes them by queuing them.
+ */
+@Component
+@Service(value = TopologyEventListener.class)
+public class TopologyHandler implements TopologyEventListener, Runnable {
+
+ /** The logger. */
+ private final Logger logger = LoggerFactory.getLogger(this.getClass().getName());
+
+ @Reference
+ private JobManagerConfiguration configuration;
+
+ /** A local queue for async handling of the events */
+ private final BlockingQueue<QueueItem> queue = new LinkedBlockingQueue<QueueItem>();
+
+ /** Active flag. */
+ private final AtomicBoolean isActive = new AtomicBoolean(false);
+
+ @Activate
+ protected void activate() {
+ this.isActive.set(true);
+ final Thread thread = new Thread(this, "Apache Sling Job Topology Listener Thread");
+ thread.setDaemon(true);
+
+ thread.start();
+ }
+
+ @Deactivate
+ protected void deactivate() {
+ this.isActive.set(false);
+ this.queue.clear();
+ try {
+ this.queue.put(new QueueItem());
+ } catch ( final InterruptedException ie) {
+ logger.warn("Thread got interrupted.", ie);
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ @Override
+ public void handleTopologyEvent(final TopologyEvent event) {
+ final QueueItem item = new QueueItem();
+ item.event = event;
+ try {
+ this.queue.put(item);
+ } catch ( final InterruptedException ie) {
+ logger.warn("Thread got interrupted.", ie);
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ @Override
+ public void run() {
+ while ( isActive.get() ) {
+ QueueItem item = null;
+ try {
+ item = this.queue.take();
+ } catch ( final InterruptedException ie) {
+ logger.warn("Thread got interrupted.", ie);
+ Thread.currentThread().interrupt();
+ isActive.set(false);
+ }
+ if ( isActive.get() && item != null && item.event != null ) {
+ final JobManagerConfiguration config = this.configuration;
+ if ( config != null ) {
+ config.handleTopologyEvent(item.event);
+ }
+ }
+ }
+ }
+
+ /**
+ * We need a holder class to be able to put something into the queue to stop it.
+ */
+ public static final class QueueItem {
+ public TopologyEvent event;
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/console/InventoryPlugin.java b/src/main/java/org/apache/sling/event/impl/jobs/console/InventoryPlugin.java
new file mode 100644
index 0000000..619136c
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/console/InventoryPlugin.java
@@ -0,0 +1,471 @@
+/*
+ * 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.event.impl.jobs.console;
+
+import java.io.PrintWriter;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Date;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.felix.inventory.Format;
+import org.apache.felix.inventory.InventoryPrinter;
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.Properties;
+import org.apache.felix.scr.annotations.Property;
+import org.apache.felix.scr.annotations.Reference;
+import org.apache.felix.scr.annotations.Service;
+import org.apache.sling.discovery.InstanceDescription;
+import org.apache.sling.event.impl.jobs.JobConsumerManager;
+import org.apache.sling.event.impl.jobs.config.InternalQueueConfiguration;
+import org.apache.sling.event.impl.jobs.config.JobManagerConfiguration;
+import org.apache.sling.event.impl.jobs.config.TopologyCapabilities;
+import org.apache.sling.event.jobs.JobManager;
+import org.apache.sling.event.jobs.Queue;
+import org.apache.sling.event.jobs.QueueConfiguration;
+import org.apache.sling.event.jobs.ScheduleInfo;
+import org.apache.sling.event.jobs.ScheduledJobInfo;
+import org.apache.sling.event.jobs.Statistics;
+import org.apache.sling.event.jobs.TopicStatistics;
+
+/**
+ * This is a inventory plugin displaying the active queues, some statistics
+ * and the configurations.
+ * @since 3.2
+ */
+@Component
+@Service(value={InventoryPrinter.class})
+@Properties({
+ @Property(name=InventoryPrinter.NAME, value="slingjobs"),
+ @Property(name=InventoryPrinter.TITLE, value="Sling Jobs"),
+ @Property(name=InventoryPrinter.FORMAT, value={"TEXT", "JSON"}),
+ @Property(name=InventoryPrinter.WEBCONSOLE, boolValue=false)
+})
+public class InventoryPlugin implements InventoryPrinter {
+
+ @Reference
+ private JobManager jobManager;
+
+ @Reference
+ private JobManagerConfiguration configuration;
+
+ @Reference
+ private JobConsumerManager jobConsumerManager;
+
+ /**
+ * Format an array.
+ */
+ private String formatArrayAsText(final String[] array) {
+ if ( array == null || array.length == 0 ) {
+ return "";
+ }
+ return Arrays.toString(array);
+ }
+
+ private String formatType(final QueueConfiguration.Type type) {
+ switch ( type ) {
+ case ORDERED : return "Ordered";
+ case TOPIC_ROUND_ROBIN : return "Topic Round Robin";
+ case UNORDERED : return "Parallel";
+ }
+ return type.toString();
+ }
+
+ /** Default date format used. */
+ private final DateFormat dateFormat = new SimpleDateFormat("HH:mm:ss:SSS yyyy-MMM-dd");
+
+ /**
+ * Format a date
+ */
+ private synchronized String formatDate(final long time) {
+ if ( time == -1 ) {
+ return "-";
+ }
+ final Date d = new Date(time);
+ return dateFormat.format(d);
+ }
+
+ /**
+ * Format time (= duration)
+ */
+ private String formatTime(final long time) {
+ if ( time == 0 ) {
+ return "-";
+ }
+ if ( time < 1000 ) {
+ return time + " ms";
+ } else if ( time < 1000 * 60 ) {
+ return time / 1000 + " secs";
+ }
+ final long min = time / 1000 / 60;
+ final long secs = (time - min * 1000 * 60);
+ return min + " min " + secs / 1000 + " secs";
+ }
+
+ /**
+ * @see org.apache.felix.inventory.InventoryPrinter#print(java.io.PrintWriter, org.apache.felix.inventory.Format, boolean)
+ */
+ @Override
+ public void print(final PrintWriter pw, final Format format, final boolean isZip) {
+ if ( format.equals(Format.TEXT) ) {
+ printText(pw);
+ } else if ( format.equals(Format.JSON) ) {
+ printJson(pw);
+ }
+ }
+
+ private void printText(final PrintWriter pw) {
+ pw.println("Apache Sling Job Handling");
+ pw.println("-------------------------");
+
+ String topics = this.jobConsumerManager.getTopics();
+ if ( topics == null ) {
+ topics = "";
+ }
+
+ Statistics s = this.jobManager.getStatistics();
+ pw.println("Overall Statistics");
+ pw.printf("Start Time : %s%n", formatDate(s.getStartTime()));
+ pw.printf("Local topic consumers: %s%n", topics);
+ pw.printf("Last Activated : %s%n", formatDate(s.getLastActivatedJobTime()));
+ pw.printf("Last Finished : %s%n", formatDate(s.getLastFinishedJobTime()));
+ pw.printf("Queued Jobs : %s%n", s.getNumberOfQueuedJobs());
+ pw.printf("Active Jobs : %s%n", s.getNumberOfActiveJobs());
+ pw.printf("Jobs : %s%n", s.getNumberOfJobs());
+ pw.printf("Finished Jobs : %s%n", s.getNumberOfFinishedJobs());
+ pw.printf("Failed Jobs : %s%n", s.getNumberOfFailedJobs());
+ pw.printf("Cancelled Jobs : %s%n", s.getNumberOfCancelledJobs());
+ pw.printf("Processed Jobs : %s%n", s.getNumberOfProcessedJobs());
+ pw.printf("Average Processing Time : %s%n", formatTime(s.getAverageProcessingTime()));
+ pw.printf("Average Waiting Time : %s%n", formatTime(s.getAverageWaitingTime()));
+ pw.println();
+
+ pw.println("Topology Capabilities");
+ final TopologyCapabilities cap = this.configuration.getTopologyCapabilities();
+ if ( cap == null ) {
+ pw.print("No topology information available !");
+ } else {
+ final Map<String, List<InstanceDescription>> instanceCaps = cap.getInstanceCapabilities();
+ for(final Map.Entry<String, List<InstanceDescription>> entry : instanceCaps.entrySet()) {
+ final StringBuilder sb = new StringBuilder();
+ for(final InstanceDescription id : entry.getValue()) {
+ if ( sb.length() > 0 ) {
+ sb.append(", ");
+ }
+ if ( id.isLocal() ) {
+ sb.append("local");
+ } else {
+ sb.append(id.getSlingId());
+ }
+ }
+ pw.printf("%s : %s%n", entry.getKey(), sb.toString());
+ }
+ }
+ pw.println();
+
+ pw.println("Scheduled Jobs");
+ final Collection<ScheduledJobInfo> infos = this.jobManager.getScheduledJobs();
+ if ( infos.size() == 0 ) {
+ pw.print("No jobs currently scheduled");
+ } else {
+ for(final ScheduledJobInfo info : infos) {
+ pw.println("Schedule");
+ pw.printf("Job Topic< : %s%n", info.getJobTopic());
+ pw.print("Schedules : ");
+ boolean first = true;
+ for(final ScheduleInfo si : info.getSchedules() ) {
+ if ( !first ) {
+ pw.print(", ");
+ }
+ first = false;
+ switch ( si.getType() ) {
+ case YEARLY : pw.printf("YEARLY %s %s : %s:%s", si.getMonthOfYear(), si.getDayOfMonth(), si.getHourOfDay(), si.getMinuteOfHour());
+ break;
+ case MONTHLY : pw.printf("MONTHLY %s : %s:%s", si.getDayOfMonth(), si.getHourOfDay(), si.getMinuteOfHour());
+ break;
+ case WEEKLY : pw.printf("WEEKLY %s : %s:%s", si.getDayOfWeek(), si.getHourOfDay(), si.getMinuteOfHour());
+ break;
+ case DAILY : pw.printf("DAILY %s:%s", si.getHourOfDay(), si.getMinuteOfHour());
+ break;
+ case HOURLY : pw.printf("HOURLY %s", si.getMinuteOfHour());
+ break;
+ case CRON : pw.printf("CRON %s", si.getExpression());
+ break;
+ default : pw.printf("AT %s", si.getAt());
+ }
+ }
+ pw.println();
+ pw.println();
+ }
+ }
+ pw.println();
+
+ boolean isEmpty = true;
+ for(final Queue q : this.jobManager.getQueues()) {
+ isEmpty = false;
+ pw.printf("Active JobQueue: %s %s%n", q.getName(),
+ q.isSuspended() ? "(SUSPENDED)" : "");
+
+ s = q.getStatistics();
+ final QueueConfiguration c = q.getConfiguration();
+ pw.println("Statistics");
+ pw.printf("Start Time : %s%n", formatDate(s.getStartTime()));
+ pw.printf("Last Activated : %s%n", formatDate(s.getLastActivatedJobTime()));
+ pw.printf("Last Finished : %s%n", formatDate(s.getLastFinishedJobTime()));
+ pw.printf("Queued Jobs : %s%n", s.getNumberOfQueuedJobs());
+ pw.printf("Active Jobs : %s%n", s.getNumberOfActiveJobs());
+ pw.printf("Jobs : %s%n", s.getNumberOfJobs());
+ pw.printf("Finished Jobs : %s%n", s.getNumberOfFinishedJobs());
+ pw.printf("Failed Jobs : %s%n", s.getNumberOfFailedJobs());
+ pw.printf("Cancelled Jobs : %s%n", s.getNumberOfCancelledJobs());
+ pw.printf("Processed Jobs : %s%n", s.getNumberOfProcessedJobs());
+ pw.printf("Average Processing Time : %s%n", formatTime(s.getAverageProcessingTime()));
+ pw.printf("Average Waiting Time : %s%n", formatTime(s.getAverageWaitingTime()));
+ pw.printf("Status Info : %s%n", q.getStateInfo());
+ pw.println("Configuration");
+ pw.printf("Type : %s%n", formatType(c.getType()));
+ pw.printf("Topics : %s%n", formatArrayAsText(c.getTopics()));
+ pw.printf("Max Parallel : %s%n", c.getMaxParallel());
+ pw.printf("Max Retries : %s%n", c.getMaxRetries());
+ pw.printf("Retry Delay : %s ms%n", c.getRetryDelayInMs());
+ pw.printf("Priority : %s%n", c.getThreadPriority());
+ pw.println();
+ }
+ if ( isEmpty ) {
+ pw.println("No active queues.");
+ pw.println();
+ }
+
+ for(final TopicStatistics ts : this.jobManager.getTopicStatistics()) {
+ pw.printf("Topic Statistics - %s%n", ts.getTopic());
+ pw.printf("Last Activated : %s%n", formatDate(ts.getLastActivatedJobTime()));
+ pw.printf("Last Finished : %s%n", formatDate(ts.getLastFinishedJobTime()));
+ pw.printf("Finished Jobs : %s%n", ts.getNumberOfFinishedJobs());
+ pw.printf("Failed Jobs : %s%n", ts.getNumberOfFailedJobs());
+ pw.printf("Cancelled Jobs : %s%n", ts.getNumberOfCancelledJobs());
+ pw.printf("Processed Jobs : %s%n", ts.getNumberOfProcessedJobs());
+ pw.printf("Average Processing Time : %s%n", formatTime(ts.getAverageProcessingTime()));
+ pw.printf("Average Waiting Time : %s%n", formatTime(ts.getAverageWaitingTime()));
+ pw.println();
+ }
+
+ pw.println("Apache Sling Job Handling - Job Queue Configurations");
+ pw.println("----------------------------------------------------");
+ this.printQueueConfiguration(pw, this.configuration.getQueueConfigurationManager().getMainQueueConfiguration());
+ final InternalQueueConfiguration[] configs = this.configuration.getQueueConfigurationManager().getConfigurations();
+ for(final InternalQueueConfiguration c : configs ) {
+ this.printQueueConfiguration(pw, c);
+ }
+ }
+
+ private void printQueueConfiguration(final PrintWriter pw, final InternalQueueConfiguration c) {
+ pw.printf("Job Queue Configuration: %s%n",
+ c.getName());
+ pw.printf("Valid : %s%n", c.isValid());
+ pw.printf("Type : %s%n", formatType(c.getType()));
+ pw.printf("Topics : %s%n", formatArrayAsText(c.getTopics()));
+ pw.printf("Max Parallel : %s%n", c.getMaxParallel());
+ pw.printf("Max Retries : %s%n", c.getMaxRetries());
+ pw.printf("Retry Delay : %s ms%n", c.getRetryDelayInMs());
+ pw.printf("Priority : %s%n", c.getThreadPriority());
+ pw.printf("Ranking : %s%n", c.getRanking());
+
+ pw.println();
+ }
+
+ private void printJson(final PrintWriter pw) {
+ pw.println("{");
+ Statistics s = this.jobManager.getStatistics();
+ pw.println(" \"statistics\" : {");
+ pw.printf(" \"startTime\" : %s,%n", s.getStartTime());
+ pw.printf(" \"startTimeText\" : \"%s\",%n", formatDate(s.getStartTime()));
+ pw.printf(" \"lastActivatedJobTime\" : %s,%n", s.getLastActivatedJobTime());
+ pw.printf(" \"lastActivatedJobTimeText\" : \"%s\",%n", formatDate(s.getLastActivatedJobTime()));
+ pw.printf(" \"lastFinishedJobTime\" : %s,%n", s.getLastFinishedJobTime());
+ pw.printf(" \"lastFinishedJobTimeText\" : \"%s\",%n", formatDate(s.getLastFinishedJobTime()));
+ pw.printf(" \"numberOfQueuedJobs\" : %s,%n", s.getNumberOfQueuedJobs());
+ pw.printf(" \"numberOfActiveJobs\" : %s,%n", s.getNumberOfActiveJobs());
+ pw.printf(" \"numberOfJobs\" : %s,%n", s.getNumberOfJobs());
+ pw.printf(" \"numberOfFinishedJobs\" : %s,%n", s.getNumberOfFinishedJobs());
+ pw.printf(" \"numberOfFailedJobs\" : %s,%n", s.getNumberOfFailedJobs());
+ pw.printf(" \"numberOfCancelledJobs\" : %s,%n", s.getNumberOfCancelledJobs());
+ pw.printf(" \"numberOfProcessedJobs\" : %s,%n", s.getNumberOfProcessedJobs());
+ pw.printf(" \"averageProcessingTime\" : %s,%n", s.getAverageProcessingTime());
+ pw.printf(" \"averageProcessingTimeText\" : \"%s\",%n", formatTime(s.getAverageProcessingTime()));
+ pw.printf(" \"averageWaitingTime\" : %s,%n", s.getAverageWaitingTime());
+ pw.printf(" \"averageWaitingTimeText\" : \"%s\"%n", formatTime(s.getAverageWaitingTime()));
+ pw.print(" }");
+
+ final TopologyCapabilities cap = this.configuration.getTopologyCapabilities();
+ if ( cap != null ) {
+ pw.println(",");
+ pw.println(" \"capabilities\" : [");
+ final Map<String, List<InstanceDescription>> instanceCaps = cap.getInstanceCapabilities();
+ final Iterator<Map.Entry<String, List<InstanceDescription>>> iter = instanceCaps.entrySet().iterator();
+ while ( iter.hasNext() ) {
+ final Map.Entry<String, List<InstanceDescription>> entry = iter.next();
+ final List<String> instances = new ArrayList<String>();
+ for(final InstanceDescription id : entry.getValue()) {
+ if ( id.isLocal() ) {
+ instances.add("local");
+ } else {
+ instances.add(id.getSlingId());
+ }
+ }
+ pw.println(" {");
+ pw.printf(" \"topic\" : \"%s\",%n", entry.getKey());
+ pw.printf(" \"instances\" : %s%n", formatArrayAsJson(instances.toArray(new String[instances.size()])));
+ if ( iter.hasNext() ) {
+ pw.println(" },");
+ } else {
+ pw.println(" }");
+ }
+ }
+ pw.print(" ]");
+ }
+
+ boolean first = true;
+ for(final Queue q : this.jobManager.getQueues()) {
+ pw.println(",");
+ if ( first ) {
+ pw.println(" \"queues\" : [");
+ first = false;
+ }
+ pw.println(" {");
+ pw.printf(" \"name\" : \"%s\",%n", q.getName());
+ pw.printf(" \"suspended\" : %s,%n", q.isSuspended());
+
+ s = q.getStatistics();
+ pw.println(" \"statistics\" : {");
+ pw.printf(" \"startTime\" : %s,%n", s.getStartTime());
+ pw.printf(" \"startTimeText\" : \"%s\",%n", formatDate(s.getStartTime()));
+ pw.printf(" \"lastActivatedJobTime\" : %s,%n", s.getLastActivatedJobTime());
+ pw.printf(" \"lastActivatedJobTimeText\" : \"%s\",%n", formatDate(s.getLastActivatedJobTime()));
+ pw.printf(" \"lastFinishedJobTime\" : %s,%n", s.getLastFinishedJobTime());
+ pw.printf(" \"lastFinishedJobTimeText\" : \"%s\",%n", formatDate(s.getLastFinishedJobTime()));
+ pw.printf(" \"numberOfQueuedJobs\" : %s,%n", s.getNumberOfQueuedJobs());
+ pw.printf(" \"numberOfActiveJobs\" : %s,%n", s.getNumberOfActiveJobs());
+ pw.printf(" \"numberOfJobs\" : %s,%n", s.getNumberOfJobs());
+ pw.printf(" \"numberOfFinishedJobs\" : %s,%n", s.getNumberOfFinishedJobs());
+ pw.printf(" \"numberOfFailedJobs\" : %s,%n", s.getNumberOfFailedJobs());
+ pw.printf(" \"numberOfCancelledJobs\" : %s,%n", s.getNumberOfCancelledJobs());
+ pw.printf(" \"numberOfProcessedJobs\" : %s,%n", s.getNumberOfProcessedJobs());
+ pw.printf(" \"averageProcessingTime\" : %s,%n", s.getAverageProcessingTime());
+ pw.printf(" \"averageProcessingTimeText\" : \"%s\",%n", formatTime(s.getAverageProcessingTime()));
+ pw.printf(" \"averageWaitingTime\" : %s,%n", s.getAverageWaitingTime());
+ pw.printf(" \"averageWaitingTimeText\" : \"%s\"%n", formatTime(s.getAverageWaitingTime()));
+ pw.print(" },");
+
+ final QueueConfiguration c = q.getConfiguration();
+ pw.printf(" \"stateInfo\" : \"%s\",%n", q.getStateInfo());
+ pw.println(" \"configuration\" : {");
+ pw.printf(" \"type\" : \"%s\",%n", c.getType());
+ pw.printf(" \"topics\" : \"%s\",%n", formatArrayAsJson(c.getTopics()));
+ pw.printf(" \"maxParallel\" : %s,%n", c.getMaxParallel());
+ pw.printf(" \"maxRetries\" : %s,%n", c.getMaxRetries());
+ pw.printf(" \"retryDelayInMs\" : %s,%n", c.getRetryDelayInMs());
+ pw.printf(" \"priority\" : \"%s\"%n", c.getThreadPriority());
+ pw.println(" }");
+ pw.print(" }");
+ }
+ if ( !first ) {
+ pw.print(" ]");
+ }
+
+ first = true;
+ for(final TopicStatistics ts : this.jobManager.getTopicStatistics()) {
+ pw.println(",");
+ if ( first ) {
+ pw.println(" \"topicStatistics\" : [");
+ first = false;
+ }
+ pw.println(" {");
+ pw.printf(" \"topic\" : \"%s\",%n", ts.getTopic());
+ pw.printf(" \"lastActivatedJobTime\" : %s,%n", ts.getLastActivatedJobTime());
+ pw.printf(" \"lastActivatedJobTimeText\" : \"%s\",%n", formatDate(ts.getLastActivatedJobTime()));
+ pw.printf(" \"lastFinishedJobTime\" : %s,%n", ts.getLastFinishedJobTime());
+ pw.printf(" \"lastFinishedJobTimeText\" : \"%s\",%n", formatDate(ts.getLastFinishedJobTime()));
+ pw.printf(" \"numberOfFinishedJobs\" : %s,%n", ts.getNumberOfFinishedJobs());
+ pw.printf(" \"numberOfFailedJobs\" : %s,%n", ts.getNumberOfFailedJobs());
+ pw.printf(" \"numberOfCancelledJobs\" : %s,%n", ts.getNumberOfCancelledJobs());
+ pw.printf(" \"numberOfProcessedJobs\" : %s,%n", ts.getNumberOfProcessedJobs());
+ pw.printf(" \"averageProcessingTime\" : %s,%n", ts.getAverageProcessingTime());
+ pw.printf(" \"averageProcessingTimeText\" : \"%s\",%n", formatTime(ts.getAverageProcessingTime()));
+ pw.printf(" \"averageWaitingTime\" : %s,%n", ts.getAverageWaitingTime());
+ pw.printf(" \"averageWaitingTimeText\" : \"%s\"%n", formatTime(ts.getAverageWaitingTime()));
+ pw.print(" }");
+ }
+ if ( !first ) {
+ pw.print(" ]");
+ }
+
+ pw.println(",");
+ pw.println(" \"configurations\" : [");
+ this.printQueueConfigurationJson(pw, this.configuration.getQueueConfigurationManager().getMainQueueConfiguration());
+ final InternalQueueConfiguration[] configs = this.configuration.getQueueConfigurationManager().getConfigurations();
+ for(final InternalQueueConfiguration c : configs ) {
+ pw.println(",");
+ this.printQueueConfigurationJson(pw, c);
+ }
+ pw.println();
+ pw.println(" ]");
+ pw.println("}");
+ }
+
+ private void printQueueConfigurationJson(final PrintWriter pw, final InternalQueueConfiguration c) {
+ pw.println(" {");
+ pw.printf(" \"name\" : \"%s\",%n", c.getName());
+ pw.printf(" \"valid\" : %s,%n", c.isValid());
+ pw.printf(" \"type\" : \"%s\",%n", c.getType());
+ pw.printf(" \"topics\" : %s,%n", formatArrayAsJson(c.getTopics()));
+ pw.printf(" \"maxParallel\" : %s,%n", c.getMaxParallel());
+ pw.printf(" \"maxRetries\" : %s,%n", c.getMaxRetries());
+ pw.printf(" \"retryDelayInMs\" : %s,%n", c.getRetryDelayInMs());
+ pw.printf(" \"priority\" : \"%s\",%n", c.getThreadPriority());
+ pw.printf(" \"ranking\" : %s%n", c.getRanking());
+ pw.print(" }");
+ }
+
+ /**
+ * Format an array.
+ */
+ private String formatArrayAsJson(final String[] array) {
+ if ( array == null || array.length == 0 ) {
+ return "[]";
+ }
+ final StringBuilder sb = new StringBuilder("[");
+ boolean first = true;
+ for(final String s : array) {
+ if ( !first ) {
+ sb.append(", ");
+ }
+ first = false;
+ sb.append("\"");
+ sb.append(s);
+ sb.append("\"");
+ }
+ sb.append("]");
+ return sb.toString();
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/console/WebConsolePlugin.java b/src/main/java/org/apache/sling/event/impl/jobs/console/WebConsolePlugin.java
new file mode 100644
index 0000000..ffe94b5
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/console/WebConsolePlugin.java
@@ -0,0 +1,453 @@
+/*
+ * 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.event.impl.jobs.console;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.net.URLEncoder;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Collection;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.Properties;
+import org.apache.felix.scr.annotations.Property;
+import org.apache.felix.scr.annotations.Reference;
+import org.apache.felix.scr.annotations.Service;
+import org.apache.sling.api.request.ResponseUtil;
+import org.apache.sling.discovery.InstanceDescription;
+import org.apache.sling.event.impl.jobs.JobConsumerManager;
+import org.apache.sling.event.impl.jobs.config.InternalQueueConfiguration;
+import org.apache.sling.event.impl.jobs.config.JobManagerConfiguration;
+import org.apache.sling.event.impl.jobs.config.TopologyCapabilities;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.JobManager;
+import org.apache.sling.event.jobs.Queue;
+import org.apache.sling.event.jobs.QueueConfiguration;
+import org.apache.sling.event.jobs.ScheduleInfo;
+import org.apache.sling.event.jobs.ScheduledJobInfo;
+import org.apache.sling.event.jobs.Statistics;
+import org.apache.sling.event.jobs.TopicStatistics;
+import org.apache.sling.event.jobs.consumer.JobConsumer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This is a web console plugin displaying the active queues, some statistics
+ * and the configurations.
+ * @since 3.0
+ */
+@Component
+@Service(value={javax.servlet.Servlet.class, JobConsumer.class})
+@Properties({
+ @Property(name="felix.webconsole.label", value="slingevent"),
+ @Property(name="felix.webconsole.title", value="Jobs"),
+ @Property(name="felix.webconsole.category", value="Sling"),
+ @Property(name=JobConsumer.PROPERTY_TOPICS, value={"sling/webconsole/test"})
+})
+public class WebConsolePlugin extends HttpServlet implements JobConsumer {
+
+ private static final String SLING_WEBCONSOLE_TEST_JOB_TOPIC = "sling/webconsole/test";
+
+ private static final long serialVersionUID = -6983227434841706385L;
+
+ private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+ @Reference
+ private JobManager jobManager;
+
+ @Reference
+ private JobManagerConfiguration configuration;
+
+ @Reference
+ private JobConsumerManager jobConsumerManager;
+
+ private static final String PAR_QUEUE = "queue";
+
+ private Queue getQueue(final HttpServletRequest req) {
+ final String name = req.getParameter(PAR_QUEUE);
+ if ( name != null ) {
+ for(final Queue q : this.jobManager.getQueues()) {
+ if ( name.equals(q.getName()) ) {
+ return q;
+ }
+ }
+ }
+ return null;
+ }
+
+ private String getQueueErrorMessage(final HttpServletRequest req, final String command) {
+ final String name = req.getParameter(PAR_QUEUE);
+ if ( name == null || name.length() == 0 ) {
+ return "Queue parameter missing for opertation " + command;
+ }
+ return "Queue with name '" + name + "' not found for operation " + command;
+ }
+
+ @Override
+ protected void doPost(final HttpServletRequest req, final HttpServletResponse resp)
+ throws ServletException, IOException {
+ String msg = null;
+ final String cmd = req.getParameter("action");
+ if ( "suspend".equals(cmd) ) {
+ final Queue q = this.getQueue(req);
+ if ( q != null ) {
+ q.suspend();
+ } else {
+ msg = this.getQueueErrorMessage(req, "suspend");
+ }
+ } else if ( "resume".equals(cmd) ) {
+ final Queue q = this.getQueue(req);
+ if ( q != null ) {
+ q.resume();
+ } else {
+ msg = this.getQueueErrorMessage(req, "resume");
+ }
+ } else if ( "reset".equals(cmd) ) {
+ if ( req.getParameter(PAR_QUEUE) == null || req.getParameter(PAR_QUEUE).length() == 0 ) {
+ this.jobManager.getStatistics().reset();
+ } else {
+ final Queue q = this.getQueue(req);
+ if ( q != null ) {
+ q.getStatistics().reset();
+ } else {
+ msg = this.getQueueErrorMessage(req, "reset");
+ }
+ }
+ } else if ( "test".equals(cmd) ) {
+ this.startTestJob();
+ } else if ( "dropall".equals(cmd) ) {
+ final Queue q = this.getQueue(req);
+ if ( q != null ) {
+ q.removeAll();
+ } else {
+ msg = this.getQueueErrorMessage(req, "drop all");
+ }
+ } else {
+ msg = "Unknown command";
+ }
+ final String path = req.getContextPath() + req.getServletPath() + req.getPathInfo();
+ final String redirectTo;
+ if ( msg == null ) {
+ redirectTo = path;
+ } else {
+ redirectTo = path + "?message=" + URLEncoder.encode(msg, "UTF-8");
+ }
+ resp.sendRedirect(resp.encodeRedirectURL(redirectTo));
+ }
+
+ private void startTestJob() {
+ logger.info("Adding test job: {}", SLING_WEBCONSOLE_TEST_JOB_TOPIC);
+ this.jobManager.addJob(SLING_WEBCONSOLE_TEST_JOB_TOPIC, null);
+ }
+
+ @Override
+ protected void doGet(final HttpServletRequest req, final HttpServletResponse res)
+ throws ServletException, IOException {
+ final String msg = req.getParameter("message");
+ final PrintWriter pw = res.getWriter();
+
+ pw.println("<form method='POST' name='eventingcmd'>" +
+ "<input type='hidden' name='action' value=''/>"+
+ "<input type='hidden' name='queue' value=''/>" +
+ "</form>");
+ pw.println("<script type='text/javascript'>");
+ pw.println("function eventingsubmit(action, queue) {" +
+ " document.forms['eventingcmd'].action.value = action;" +
+ " document.forms['eventingcmd'].queue.value = queue;" +
+ " document.forms['eventingcmd'].submit();" +
+ "} </script>");
+
+ pw.printf("<p class='statline ui-state-highlight'>Apache Sling Job Handling%s%n</p>",
+ msg != null ? " : " + ResponseUtil.escapeXml(msg) : "");
+ pw.println("<div class='ui-widget-header ui-corner-top buttonGroup'>");
+ pw.println("<span style='float: left; margin-left: 1em'>Apache Sling Job Handling: Overall Statistics</span>");
+ this.printForm(pw, null, "Reset Stats", "reset");
+ pw.println("</div>");
+
+ pw.println("<table class='nicetable'><tbody>");
+ String topics = this.jobConsumerManager.getTopics();
+ if ( topics == null ) {
+ topics = "";
+ } else {
+ final String[] allTopics = topics.split(",");
+ final StringBuilder sb = new StringBuilder();
+ boolean first = true;
+ for(final String t : allTopics) {
+ if ( first) {
+ first = false;
+ } else {
+ sb.append("<br/>");
+ }
+ sb.append(ResponseUtil.escapeXml(t));
+ }
+ topics = sb.toString();
+ }
+ Statistics s = this.jobManager.getStatistics();
+ pw.printf("<tr><td>Start Time</td><td>%s</td></tr>", formatDate(s.getStartTime()));
+ pw.printf("<tr><td>Local topic consumers: </td><td>%s</td></tr>", topics);
+ pw.printf("<tr><td>Last Activated</td><td>%s</td></tr>", formatDate(s.getLastActivatedJobTime()));
+ pw.printf("<tr><td>Last Finished</td><td>%s</td></tr>", formatDate(s.getLastFinishedJobTime()));
+ pw.printf("<tr><td>Queued Jobs</td><td>%s</td></tr>", s.getNumberOfQueuedJobs());
+ pw.printf("<tr><td>Active Jobs</td><td>%s</td></tr>", s.getNumberOfActiveJobs());
+ pw.printf("<tr><td>Jobs</td><td>%s</td></tr>", s.getNumberOfJobs());
+ pw.printf("<tr><td>Finished Jobs</td><td>%s</td></tr>", s.getNumberOfFinishedJobs());
+ pw.printf("<tr><td>Failed Jobs</td><td>%s</td></tr>", s.getNumberOfFailedJobs());
+ pw.printf("<tr><td>Cancelled Jobs</td><td>%s</td></tr>", s.getNumberOfCancelledJobs());
+ pw.printf("<tr><td>Processed Jobs</td><td>%s</td></tr>", s.getNumberOfProcessedJobs());
+ pw.printf("<tr><td>Average Processing Time</td><td>%s</td></tr>", formatTime(s.getAverageProcessingTime()));
+ pw.printf("<tr><td>Average Waiting Time</td><td>%s</td></tr>", formatTime(s.getAverageWaitingTime()));
+ pw.println("</tbody></table>");
+ pw.println("<br/>");
+
+ pw.println("<table class='nicetable'><tbody>");
+ pw.println("<tr><th colspan='2'>Topology Capabilities</th></tr>");
+ final TopologyCapabilities cap = this.configuration.getTopologyCapabilities();
+ if ( cap == null ) {
+ pw.print("<tr><td colspan='2'>No topology information available !</td></tr>");
+ } else {
+ final Map<String, List<InstanceDescription>> instanceCaps = cap.getInstanceCapabilities();
+ for(final Map.Entry<String, List<InstanceDescription>> entry : instanceCaps.entrySet()) {
+ final StringBuilder sb = new StringBuilder();
+ for(final InstanceDescription id : entry.getValue()) {
+ if ( sb.length() > 0 ) {
+ sb.append("<br/>");
+ }
+ if ( id.isLocal() ) {
+ sb.append("<b>local</b>");
+ } else {
+ sb.append(ResponseUtil.escapeXml(id.getSlingId()));
+ }
+ }
+ pw.printf("<tr><td>%s</td><td>%s</td></tr>", ResponseUtil.escapeXml(entry.getKey()), sb.toString());
+ }
+ }
+ pw.println("</tbody></table>");
+ pw.println("<br/>");
+
+ pw.println("<p class='statline'>Scheduled Jobs</p>");
+ pw.println("<table class='nicetable'><tbody>");
+ final Collection<ScheduledJobInfo> infos = this.jobManager.getScheduledJobs();
+ if ( infos.size() == 0 ) {
+ pw.print("<tr><td colspan='5'>No jobs currently scheduled.</td></tr>");
+ } else {
+ pw.println("<tr><th>Schedule</th><th>Job Topic</th><th>Schedules</th></tr>");
+ int index = 1;
+ for(final ScheduledJobInfo info : infos) {
+ pw.printf("<tr><td><b>%s</b></td><td>%s</td><td>",
+ String.valueOf(index), ResponseUtil.escapeXml(info.getJobTopic()));
+ boolean first = true;
+ for(final ScheduleInfo si : info.getSchedules() ) {
+ if ( !first ) {
+ pw.print("<br/>");
+ }
+ first = false;
+ switch ( si.getType() ) {
+ case YEARLY : pw.printf("YEARLY %s %s : %s:%s", si.getMonthOfYear(), si.getDayOfMonth(), si.getHourOfDay(), si.getMinuteOfHour());
+ break;
+ case MONTHLY : pw.printf("MONTHLY %s : %s:%s", si.getDayOfMonth(), si.getHourOfDay(), si.getMinuteOfHour());
+ break;
+ case WEEKLY : pw.printf("WEEKLY %s : %s:%s", si.getDayOfWeek(), si.getHourOfDay(), si.getMinuteOfHour());
+ break;
+ case DAILY : pw.printf("DAILY %s:%s", si.getHourOfDay(), si.getMinuteOfHour());
+ break;
+ case HOURLY : pw.printf("HOURLY %s", si.getMinuteOfHour());
+ break;
+ case CRON : pw.printf("CRON %s", ResponseUtil.escapeXml(si.getExpression()));
+ break;
+ default : pw.printf("AT %s", si.getAt());
+ }
+ }
+ pw.print("</td></tr>");
+ index++;
+ }
+ }
+ pw.println("</tbody></table>");
+ pw.println("<br/>");
+
+ boolean isEmpty = true;
+ for(final Queue q : this.jobManager.getQueues()) {
+ isEmpty = false;
+ final String queueName = q.getName();
+ pw.println("<div class='ui-widget-header ui-corner-top buttonGroup'>");
+ pw.printf("<span style='float: left; margin-left: 1em'>Active JobQueue: %s %s</span>", ResponseUtil.escapeXml(queueName),
+ q.isSuspended() ? "(SUSPENDED)" : "");
+ this.printForm(pw, queueName, "Reset Stats", "reset");
+ if ( q.isSuspended() ) {
+ this.printForm(pw, queueName, "Resume", "resume");
+ } else {
+ this.printForm(pw, queueName, "Suspend", "suspend");
+ }
+ this.printForm(pw, queueName, "Test", "test");
+ this.printForm(pw, queueName, "Drop All", "dropall");
+ pw.println("</div>");
+ pw.println("<table class='nicetable'><tbody>");
+
+ s = q.getStatistics();
+ final QueueConfiguration c = q.getConfiguration();
+ pw.println("<tr><th colspan='2'>Statistics</th><th colspan='2'>Configuration</th></tr>");
+ pw.printf("<tr><td>Start Time</td><td>%s</td><td>Type</td><td>%s</td></tr>", formatDate(s.getStartTime()), formatType(c.getType()));
+ pw.printf("<tr><td>Last Activated</td><td>%s</td><td>Topics</td><td>%s</td></tr>", formatDate(s.getLastActivatedJobTime()), formatArray(c.getTopics()));
+ pw.printf("<tr><td>Last Finished</td><td>%s</td><td>Max Parallel</td><td>%s</td></tr>", formatDate(s.getLastFinishedJobTime()), c.getMaxParallel());
+ pw.printf("<tr><td>Queued Jobs</td><td>%s</td><td>Max Retries</td><td>%s</td></tr>", s.getNumberOfQueuedJobs(), c.getMaxRetries());
+ pw.printf("<tr><td>Active Jobs</td><td>%s</td><td>Retry Delay</td><td>%s ms</td></tr>", s.getNumberOfActiveJobs(), c.getRetryDelayInMs());
+ pw.printf("<tr><td>Jobs</td><td>%s</td><td>Priority</td><td>%s</td></tr>", s.getNumberOfJobs(), c.getThreadPriority());
+ pw.printf("<tr><td>Finished Jobs</td><td>%s</td><td colspan='2'> </td></tr>", s.getNumberOfFinishedJobs());
+ pw.printf("<tr><td>Failed Jobs</td><td>%s</td><td colspan='2'> </td></tr>", s.getNumberOfFailedJobs());
+ pw.printf("<tr><td>Cancelled Jobs</td><td>%s</td><td colspan='2'> </td></tr>", s.getNumberOfCancelledJobs());
+ pw.printf("<tr><td>Processed Jobs</td><td>%s</td><td colspan='2'> </td></tr>", s.getNumberOfProcessedJobs());
+ pw.printf("<tr><td>Average Processing Time</td><td>%s</td><td colspan='2'> </td></tr>", formatTime(s.getAverageProcessingTime()));
+ pw.printf("<tr><td>Average Waiting Time</td><td>%s</td><td colspan='2'> </td></tr>", formatTime(s.getAverageWaitingTime()));
+ pw.printf("<tr><td>Status Info</td><td colspan='3'>%s</td></tr>", ResponseUtil.escapeXml(q.getStateInfo()));
+ pw.println("</tbody></table>");
+ pw.println("<br/>");
+ }
+ if ( isEmpty ) {
+ pw.println("<p>No active queues.</p>");
+ pw.println("<br/>");
+ }
+
+ for(final TopicStatistics ts : this.jobManager.getTopicStatistics()) {
+ pw.println("<table class='nicetable'><tbody>");
+ pw.printf("<tr><th colspan='2'>Topic Statistics: %s</th></tr>", ResponseUtil.escapeXml(ts.getTopic()));
+
+ pw.printf("<tr><td>Last Activated</td><td>%s</td></tr>", formatDate(ts.getLastActivatedJobTime()));
+ pw.printf("<tr><td>Last Finished</td><td>%s</td></tr>", formatDate(ts.getLastFinishedJobTime()));
+ pw.printf("<tr><td>Finished Jobs</td><td>%s</td></tr>", ts.getNumberOfFinishedJobs());
+ pw.printf("<tr><td>Failed Jobs</td><td>%s</td></tr>", ts.getNumberOfFailedJobs());
+ pw.printf("<tr><td>Cancelled Jobs</td><td>%s</td></tr>", ts.getNumberOfCancelledJobs());
+ pw.printf("<tr><td>Processed Jobs</td><td>%s</td></tr>", ts.getNumberOfProcessedJobs());
+ pw.printf("<tr><td>Average Processing Time</td><td>%s</td></tr>", formatTime(ts.getAverageProcessingTime()));
+ pw.printf("<tr><td>Average Waiting Time</td><td>%s</td></tr>", formatTime(ts.getAverageWaitingTime()));
+ pw.println("</tbody></table>");
+ pw.println("<br/>");
+ }
+
+ pw.println("<p class='statline'>Apache Sling Job Handling - Job Queue Configurations</p>");
+ this.printQueueConfiguration(req, pw, this.configuration.getQueueConfigurationManager().getMainQueueConfiguration());
+ final InternalQueueConfiguration[] configs = this.configuration.getQueueConfigurationManager().getConfigurations();
+ for(final InternalQueueConfiguration c : configs ) {
+ this.printQueueConfiguration(req, pw, c);
+ }
+ }
+
+ private void printQueueConfiguration(final HttpServletRequest req, final PrintWriter pw, final InternalQueueConfiguration c) {
+ pw.println("<div class='ui-widget-header ui-corner-top buttonGroup'>");
+ pw.printf("<span style='float: left; margin-left: 1em'>Job Queue Configuration: %s</span>%n",
+ ResponseUtil.escapeXml(c.getName()));
+ pw.printf("<button id='edit' class='ui-state-default ui-corner-all' onclick='javascript:window.location=\"%s%s/configMgr/%s\";'>Edit</button>",
+ req.getContextPath(), req.getServletPath(), c.getPid());
+ this.printForm(pw, c.getName(), "Test", "test");
+
+ pw.println("</div>");
+ pw.println("<table class='nicetable'><tbody>");
+ pw.println("<tr><th colspan='2'>Configuration</th></tr>");
+ pw.printf("<tr><td>Valid</td><td>%s</td></tr>", c.isValid());
+ pw.printf("<tr><td>Type</td><td>%s</td></tr>", formatType(c.getType()));
+ pw.printf("<tr><td>Topics</td><td>%s</td></tr>", formatArray(c.getTopics()));
+ pw.printf("<tr><td>Max Parallel</td><td>%s</td></tr>", c.getMaxParallel());
+ pw.printf("<tr><td>Max Retries</td><td>%s</td></tr>", c.getMaxRetries());
+ pw.printf("<tr><td>Retry Delay</td><td>%s ms</td></tr>", c.getRetryDelayInMs());
+ pw.printf("<tr><td>Priority</td><td>%s</td></tr>", c.getThreadPriority());
+ pw.printf("<tr><td>Ranking</td><td>%s</td></tr>", c.getRanking());
+
+ pw.println("</tbody></table>");
+ pw.println("<br/>");
+ }
+
+ /**
+ * Format an array for html rendering.
+ */
+ private String formatArray(final String[] array) {
+ if ( array == null || array.length == 0 ) {
+ return "";
+ }
+ final StringBuilder sb = new StringBuilder();
+ boolean first = true;
+ for(final String s : array ) {
+ if ( !first ) {
+ sb.append('\n');
+ }
+ first = false;
+ sb.append(s);
+ }
+ return ResponseUtil.escapeXml(sb.toString());
+ }
+
+ private String formatType(final QueueConfiguration.Type type) {
+ switch ( type ) {
+ case ORDERED : return "Ordered";
+ case TOPIC_ROUND_ROBIN : return "Topic Round Robin";
+ case UNORDERED : return "Parallel";
+ }
+ return type.toString();
+ }
+ /** Default date format used. */
+ private final DateFormat dateFormat = new SimpleDateFormat("HH:mm:ss:SSS yyyy-MMM-dd");
+
+ /**
+ * Format a date
+ */
+ private synchronized String formatDate(final long time) {
+ if ( time == -1 ) {
+ return "-";
+ }
+ final Date d = new Date(time);
+ return dateFormat.format(d);
+ }
+
+ /**
+ * Format time (= duration)
+ */
+ private String formatTime(final long time) {
+ if ( time == 0 ) {
+ return "-";
+ }
+ if ( time < 1000 ) {
+ return time + " ms";
+ } else if ( time < 1000 * 60 ) {
+ return time / 1000 + " secs";
+ }
+ final long min = time / 1000 / 60;
+ final long secs = (time - min * 1000 * 60);
+ return min + " min " + secs / 1000 + " secs";
+ }
+
+ private void printForm(final PrintWriter pw,
+ final String qeueName,
+ final String buttonLabel,
+ final String cmd) {
+ pw.printf("<button class='ui-state-default ui-corner-all' onclick='javascript:eventingsubmit(\"%s\", \"%s\");'>" +
+ "%s</button>", ResponseUtil.escapeXml(cmd), (qeueName != null ? ResponseUtil.escapeXml(qeueName) : ""), ResponseUtil.escapeXml(buttonLabel));
+ }
+
+ @Override
+ public JobResult process(final Job job) {
+ logger.info("Received test job {}", job.getTopic());
+ return JobResult.OK;
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/jmx/AbstractJobStatistics.java b/src/main/java/org/apache/sling/event/impl/jobs/jmx/AbstractJobStatistics.java
new file mode 100644
index 0000000..8bf4033
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/jmx/AbstractJobStatistics.java
@@ -0,0 +1,100 @@
+/*
+ * 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 SF 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.event.impl.jobs.jmx;
+
+import java.util.Date;
+
+import javax.management.StandardMBean;
+
+import org.apache.sling.event.jobs.Statistics;
+import org.apache.sling.event.jobs.jmx.StatisticsMBean;
+
+public abstract class AbstractJobStatistics extends StandardMBean implements
+ StatisticsMBean {
+
+ public AbstractJobStatistics() {
+ super(StatisticsMBean.class, false);
+ }
+
+ protected abstract Statistics getStatistics();
+
+ public long getAverageProcessingTime() {
+ return getStatistics().getAverageProcessingTime();
+ }
+
+ public long getAverageWaitingTime() {
+ return getStatistics().getAverageWaitingTime();
+ }
+
+ public long getLastActivatedJobTime() {
+ return getStatistics().getLastActivatedJobTime();
+ }
+
+ public long getLastFinishedJobTime() {
+ return getStatistics().getLastFinishedJobTime();
+ }
+
+ public long getNumberOfActiveJobs() {
+ return getStatistics().getNumberOfActiveJobs();
+ }
+
+ public long getNumberOfCancelledJobs() {
+ return getStatistics().getNumberOfCancelledJobs();
+ }
+
+ public long getStartTime() {
+ return getStatistics().getStartTime();
+ }
+
+ public Date getStartDate() {
+ return new Date(getStartTime());
+ }
+
+ public long getNumberOfFinishedJobs() {
+ return getStatistics().getNumberOfFinishedJobs();
+ }
+
+ public long getNumberOfFailedJobs() {
+ return getStatistics().getNumberOfFailedJobs();
+ }
+
+ public long getNumberOfProcessedJobs() {
+ return getStatistics().getNumberOfProcessedJobs();
+ }
+
+ public long getNumberOfQueuedJobs() {
+ return getStatistics().getNumberOfQueuedJobs();
+ }
+
+ public long getNumberOfJobs() {
+ return getStatistics().getNumberOfJobs();
+ }
+
+ public void reset() {
+ getStatistics().reset();
+ }
+
+ public Date getLastActivatedJobDate() {
+ return new Date(getStatistics().getLastActivatedJobTime());
+ }
+
+ public Date getLastFinishedJobDate() {
+ return new Date(getStatistics().getLastFinishedJobTime());
+ }
+
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/jmx/AllJobStatisticsMBean.java b/src/main/java/org/apache/sling/event/impl/jobs/jmx/AllJobStatisticsMBean.java
new file mode 100644
index 0000000..0e696d6
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/jmx/AllJobStatisticsMBean.java
@@ -0,0 +1,57 @@
+/*
+ * 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 SF 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.event.impl.jobs.jmx;
+
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.Properties;
+import org.apache.felix.scr.annotations.Property;
+import org.apache.felix.scr.annotations.Reference;
+import org.apache.felix.scr.annotations.Service;
+import org.apache.sling.event.jobs.JobManager;
+import org.apache.sling.event.jobs.Statistics;
+import org.apache.sling.event.jobs.jmx.StatisticsMBean;
+
+@Component
+@Service(value = StatisticsMBean.class)
+@Properties(@Property(name = "jmx.objectname", value = "org.apache.sling:type=queues,name=AllQueues"))
+public class AllJobStatisticsMBean extends AbstractJobStatistics {
+
+ private static final long TTL = 1000L;
+ private long agregateStatisticsTTL = 0L;
+ private Statistics aggregateStatistics;
+ @Reference
+ private JobManager jobManager;
+
+ /**
+ * @return the aggregate stats from the job manager.
+ */
+ @Override
+ protected Statistics getStatistics() {
+ if (System.currentTimeMillis() > agregateStatisticsTTL) {
+ aggregateStatistics = jobManager.getStatistics();
+ agregateStatisticsTTL = System.currentTimeMillis() + TTL;
+ }
+ return aggregateStatistics;
+ }
+
+ @Override
+ public String getName() {
+ return "All Queues";
+ }
+
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/jmx/EmptyStatistics.java b/src/main/java/org/apache/sling/event/impl/jobs/jmx/EmptyStatistics.java
new file mode 100644
index 0000000..35772ba
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/jmx/EmptyStatistics.java
@@ -0,0 +1,79 @@
+/*
+ * 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 SF 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.event.impl.jobs.jmx;
+
+import org.apache.sling.event.jobs.Statistics;
+
+/**
+ * Dummy stats that just returns 0 for all info, used where the queue doesnt
+ * implement the Statistics interface.
+ */
+public class EmptyStatistics implements Statistics {
+
+ public long getStartTime() {
+ return 0;
+ }
+
+ public long getNumberOfFinishedJobs() {
+ return 0;
+ }
+
+ public long getNumberOfCancelledJobs() {
+ return 0;
+ }
+
+ public long getNumberOfFailedJobs() {
+ return 0;
+ }
+
+ public long getNumberOfProcessedJobs() {
+ return 0;
+ }
+
+ public long getNumberOfActiveJobs() {
+ return 0;
+ }
+
+ public long getNumberOfQueuedJobs() {
+ return 0;
+ }
+
+ public long getNumberOfJobs() {
+ return 0;
+ }
+
+ public long getLastActivatedJobTime() {
+ return 0;
+ }
+
+ public long getLastFinishedJobTime() {
+ return 0;
+ }
+
+ public long getAverageWaitingTime() {
+ return 0;
+ }
+
+ public long getAverageProcessingTime() {
+ return 0;
+ }
+
+ public void reset() {
+ }
+
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/jmx/QueueMBeanImpl.java b/src/main/java/org/apache/sling/event/impl/jobs/jmx/QueueMBeanImpl.java
new file mode 100644
index 0000000..615ac90
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/jmx/QueueMBeanImpl.java
@@ -0,0 +1,50 @@
+/*
+ * 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 SF 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.event.impl.jobs.jmx;
+
+import org.apache.sling.event.jobs.Queue;
+import org.apache.sling.event.jobs.Statistics;
+
+/**
+ * An MBean that provides statistics from
+ */
+public class QueueMBeanImpl extends AbstractJobStatistics {
+
+ private final String name;
+
+ private final Statistics statistics;
+
+ public QueueMBeanImpl(Queue queue) {
+ this.name = queue.getName();
+ if (queue instanceof Statistics) {
+ this.statistics = (Statistics) queue;
+ } else {
+ this.statistics = new EmptyStatistics();
+ }
+ }
+
+ @Override
+ protected Statistics getStatistics() {
+ return this.statistics;
+ }
+
+ @Override
+ public String getName() {
+ return name;
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/jmx/QueueStatusEvent.java b/src/main/java/org/apache/sling/event/impl/jobs/jmx/QueueStatusEvent.java
new file mode 100644
index 0000000..dca3e33
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/jmx/QueueStatusEvent.java
@@ -0,0 +1,51 @@
+/*
+ * 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 SF 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.event.impl.jobs.jmx;
+
+import org.apache.sling.event.jobs.Queue;
+
+public class QueueStatusEvent {
+
+ private final Queue queue;
+ private final Queue oldqueue;
+
+
+ public QueueStatusEvent(final Queue queue, final Queue oldqueue) {
+ this.queue = queue;
+ this.oldqueue = oldqueue;
+ }
+ public boolean isNew() {
+ return this.oldqueue == null;
+ }
+
+ public boolean isUpdate() {
+ return this.queue == this.oldqueue;
+ }
+
+ public boolean isRemoved() {
+ return this.queue == null;
+ }
+
+ public Queue getQueue() {
+ return queue;
+ }
+
+ public Queue getOldQueue() {
+ return oldqueue;
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/jmx/QueuesMBeanImpl.java b/src/main/java/org/apache/sling/event/impl/jobs/jmx/QueuesMBeanImpl.java
new file mode 100644
index 0000000..83a56e9
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/jmx/QueuesMBeanImpl.java
@@ -0,0 +1,185 @@
+/*
+ * 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 SF 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.event.impl.jobs.jmx;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Dictionary;
+import java.util.Hashtable;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+import javax.management.AttributeChangeNotification;
+import javax.management.MBeanNotificationInfo;
+import javax.management.Notification;
+import javax.management.NotificationBroadcasterSupport;
+import javax.management.StandardEmitterMBean;
+
+import org.apache.felix.scr.annotations.Activate;
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.Deactivate;
+import org.apache.felix.scr.annotations.Property;
+import org.apache.felix.scr.annotations.Service;
+import org.apache.sling.event.jobs.Queue;
+import org.apache.sling.event.jobs.jmx.QueuesMBean;
+import org.apache.sling.event.jobs.jmx.StatisticsMBean;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.Constants;
+import org.osgi.framework.ServiceRegistration;
+
+@Component
+@Service(value = { QueuesMBean.class })
+@Property(name = "jmx.objectname", value = "org.apache.sling:type=queues,name=QueueNames")
+public class QueuesMBeanImpl extends StandardEmitterMBean implements QueuesMBean {
+
+ private static final String QUEUE_NOTIFICATION = "org.apache.sling.event.queue";
+ private static final String[] NOTIFICATION_TYPES = { QUEUE_NOTIFICATION };
+ private Map<String, QueueMBeanHolder> queues = new ConcurrentHashMap<String, QueueMBeanHolder>();
+ private String[] names;
+ private AtomicLong sequence = new AtomicLong(System.currentTimeMillis());
+ private BundleContext bundleContext;
+
+ class QueueMBeanHolder {
+
+ QueueMBeanHolder(String name, QueueMBeanImpl queueMBean,
+ ServiceRegistration registration) {
+ this.name = name;
+ this.queueMBean = queueMBean;
+ this.registration = registration;
+ }
+
+ QueueMBeanImpl queueMBean;
+ ServiceRegistration registration;
+ String name;
+
+ }
+
+ public QueuesMBeanImpl() {
+ super(QueuesMBean.class, false, new NotificationBroadcasterSupport(
+ new MBeanNotificationInfo(NOTIFICATION_TYPES,
+ Notification.class.getName(),
+ "Notifications about queues")));
+ }
+
+ @Activate
+ public void activate(final BundleContext bc) {
+ bundleContext = bc;
+ }
+
+ @Deactivate
+ public void deactivate() {
+ bundleContext = null;
+ }
+
+ public void sendEvent(final QueueStatusEvent e) {
+ if (e.isNew()) {
+ bindQueueMBean(e);
+ } else if (e.isRemoved()) {
+ unbindQueueMBean(e);
+ } else {
+ updateQueueMBean(e);
+ }
+ }
+
+ private void updateQueueMBean(QueueStatusEvent e) {
+ QueueMBeanHolder queueMBeanHolder = queues.get(e.getQueue().getName());
+ if (queueMBeanHolder != null) {
+ String[] oldQueue = getQueueNames();
+ names = null;
+ this.sendNotification(new AttributeChangeNotification(this,
+ sequence.incrementAndGet(), System.currentTimeMillis(),
+ "Queue " + e.getQueue().getName() + " updated ",
+ "queueNames", "String[]", oldQueue, getQueueNames()));
+ }
+ }
+
+ private void unbindQueueMBean(QueueStatusEvent e) {
+ QueueMBeanHolder queueMBeanHolder = queues.get(e.getOldQueue().getName());
+ if (queueMBeanHolder != null) {
+ removeAndNotify(queueMBeanHolder);
+ }
+ }
+
+ private void bindQueueMBean(QueueStatusEvent e) {
+ QueueMBeanHolder queueMBeanHolder = queues.get(e.getQueue().getName());
+ if (queueMBeanHolder != null) {
+ removeAndNotify(queueMBeanHolder);
+ }
+ addAndNotify(e.getQueue());
+ }
+
+ private void addAndNotify(Queue queue) {
+ String[] oldQueue = getQueueNames();
+ QueueMBeanHolder queueMBeanHolder = add(queue);
+ names = null;
+ this.sendNotification(new AttributeChangeNotification(this, sequence
+ .incrementAndGet(), System.currentTimeMillis(), "Queue "
+ + queueMBeanHolder.name + " added ", "queueNames", "String[]",
+ oldQueue, getQueueNames()));
+ }
+
+ private void removeAndNotify(QueueMBeanHolder queueMBeanHolder) {
+ String[] oldQueue = getQueueNames();
+ remove(queueMBeanHolder);
+ names = null;
+ this.sendNotification(new AttributeChangeNotification(this, sequence
+ .incrementAndGet(), System.currentTimeMillis(), "Queue "
+ + queueMBeanHolder.name + " removed ", "queueNames",
+ "String[]", oldQueue, getQueueNames()));
+ }
+
+ private QueueMBeanHolder add(Queue queue) {
+ QueueMBeanImpl queueMBean = new QueueMBeanImpl(queue);
+ ServiceRegistration serviceRegistration = bundleContext
+ .registerService(StatisticsMBean.class.getName(), queueMBean,
+ createProperties(
+ "jmx.objectname","org.apache.sling:type=queues,name="+queue.getName(),
+ Constants.SERVICE_DESCRIPTION, "QueueMBean for queue "+queue.getName(),
+ Constants.SERVICE_VENDOR, "The Apache Software Foundation"));
+ QueueMBeanHolder queueMBeanHolder = new QueueMBeanHolder(
+ queue.getName(), queueMBean, serviceRegistration);
+ queues.put(queueMBeanHolder.name, queueMBeanHolder);
+ return queueMBeanHolder;
+ }
+
+ private Dictionary<String, Object> createProperties(Object ... values) {
+ Dictionary<String, Object> props = new Hashtable<String, Object>();
+ for ( int i = 0; i < values.length; i+=2) {
+ props.put((String) values[i], values[i+1]);
+ }
+ return props;
+ }
+
+ private void remove(QueueMBeanHolder queueMBeanHolder) {
+ queueMBeanHolder.registration.unregister();
+ queues.remove(queueMBeanHolder.name);
+ }
+
+ @Override
+ public String[] getQueueNames() {
+ if (names == null) {
+ List<String> lnames = new ArrayList<String>(queues.keySet());
+ Collections.sort(lnames);
+ names = lnames.toArray(new String[lnames.size()]);
+ }
+ return names;
+ }
+
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/notifications/NewJobSender.java b/src/main/java/org/apache/sling/event/impl/jobs/notifications/NewJobSender.java
new file mode 100644
index 0000000..590026f
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/notifications/NewJobSender.java
@@ -0,0 +1,123 @@
+/*
+ * 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.event.impl.jobs.notifications;
+
+import java.util.Dictionary;
+import java.util.Hashtable;
+import java.util.List;
+
+import org.apache.felix.scr.annotations.Activate;
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.Deactivate;
+import org.apache.felix.scr.annotations.Reference;
+import org.apache.sling.api.resource.observation.ExternalResourceChangeListener;
+import org.apache.sling.api.resource.observation.ResourceChange;
+import org.apache.sling.api.resource.observation.ResourceChange.ChangeType;
+import org.apache.sling.api.resource.observation.ResourceChangeListener;
+import org.apache.sling.event.impl.jobs.config.JobManagerConfiguration;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.NotificationConstants;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.Constants;
+import org.osgi.framework.ServiceRegistration;
+import org.osgi.service.event.Event;
+import org.osgi.service.event.EventAdmin;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This component receives resource added events and sends a job
+ * created event.
+ */
+@Component
+public class NewJobSender implements ResourceChangeListener, ExternalResourceChangeListener {
+
+ /** Logger. */
+ private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+ /** The job manager configuration. */
+ @Reference
+ private JobManagerConfiguration configuration;
+
+ /** The event admin. */
+ @Reference
+ private EventAdmin eventAdmin;
+
+ /** Service registration for the event handler. */
+ private volatile ServiceRegistration<ResourceChangeListener> listenerRegistration;
+
+ /**
+ * Activate this component.
+ * Register an event handler.
+ */
+ @Activate
+ protected void activate(final BundleContext bundleContext) {
+ final Dictionary<String, Object> properties = new Hashtable<String, Object>();
+ properties.put(Constants.SERVICE_DESCRIPTION, "Apache Sling Job Topic Manager Event Handler");
+ properties.put(Constants.SERVICE_VENDOR, "The Apache Software Foundation");
+ properties.put(ResourceChangeListener.CHANGES, ChangeType.ADDED.toString());
+ properties.put(ResourceChangeListener.PATHS, this.configuration.getLocalJobsPath());
+
+ this.listenerRegistration = bundleContext.registerService(ResourceChangeListener.class, this, properties);
+ }
+
+ /**
+ * Deactivate this component.
+ * Unregister the event handler.
+ */
+ @Deactivate
+ protected void deactivate() {
+ if ( this.listenerRegistration != null ) {
+ this.listenerRegistration.unregister();
+ this.listenerRegistration = null;
+ }
+ }
+
+ @Override
+ public void onChange(final List<ResourceChange> resourceChanges) {
+ for(final ResourceChange resourceChange : resourceChanges) {
+ logger.debug("Received event {}", resourceChange);
+
+ final String path = resourceChange.getPath();
+
+ final int topicStart = this.configuration.getLocalJobsPath().length() + 1;
+ final int topicEnd = path.indexOf('/', topicStart);
+ if ( topicEnd != -1 ) {
+ final String topic = path.substring(topicStart, topicEnd).replace('.', '/');
+ final String jobId = path.substring(topicEnd + 1);
+
+ if ( path.indexOf("_", topicEnd + 1) != -1 ) {
+ // only job id and topic are guaranteed
+ final Dictionary<String, Object> properties = new Hashtable<String, Object>();
+ properties.put(NotificationConstants.NOTIFICATION_PROPERTY_JOB_ID, jobId);
+ properties.put(NotificationConstants.NOTIFICATION_PROPERTY_JOB_TOPIC, topic);
+
+ // we also set internally the queue name
+ final String queueName = this.configuration.getQueueConfigurationManager().getQueueInfo(topic).queueName;
+ properties.put(Job.PROPERTY_JOB_QUEUE_NAME, queueName);
+
+ final Event jobEvent = new Event(NotificationConstants.TOPIC_JOB_ADDED, properties);
+ // as this is send within handling an event, we do sync call
+ this.eventAdmin.sendEvent(jobEvent);
+ }
+ }
+ }
+
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/notifications/NotificationUtility.java b/src/main/java/org/apache/sling/event/impl/jobs/notifications/NotificationUtility.java
new file mode 100644
index 0000000..d45d818
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/notifications/NotificationUtility.java
@@ -0,0 +1,77 @@
+/*
+ * 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.event.impl.jobs.notifications;
+
+import java.util.Dictionary;
+import java.util.Hashtable;
+
+import org.apache.sling.event.impl.jobs.JobImpl;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.NotificationConstants;
+import org.apache.sling.event.jobs.consumer.JobConsumer;
+import org.osgi.service.event.Event;
+import org.osgi.service.event.EventAdmin;
+import org.osgi.service.event.EventConstants;
+
+public abstract class NotificationUtility {
+
+ /** Event property containing the time for job start and job finished events. */
+ public static final String PROPERTY_TIME = ":time";
+
+ /**
+ * Helper method for sending the notification events.
+ */
+ public static void sendNotification(final EventAdmin eventAdmin,
+ final String eventTopic,
+ final Job job,
+ final Long time) {
+ if ( eventAdmin != null ) {
+ // create new copy of job object
+ final Job jobCopy = new JobImpl(job.getTopic(), job.getId(), ((JobImpl)job).getProperties());
+ sendNotificationInternal(eventAdmin, eventTopic, jobCopy, time);
+ }
+ }
+
+ /**
+ * Helper method for sending the notification events.
+ */
+ private static void sendNotificationInternal(final EventAdmin eventAdmin,
+ final String eventTopic,
+ final Job job,
+ final Long time) {
+ final Dictionary<String, Object> eventProps = new Hashtable<String, Object>();
+ // add basic job properties
+ eventProps.put(NotificationConstants.NOTIFICATION_PROPERTY_JOB_ID, job.getId());
+ eventProps.put(NotificationConstants.NOTIFICATION_PROPERTY_JOB_TOPIC, job.getTopic());
+ // copy payload
+ for(final String name : job.getPropertyNames()) {
+ eventProps.put(name, job.getProperty(name));
+ }
+ // remove async handler
+ eventProps.remove(JobConsumer.PROPERTY_JOB_ASYNC_HANDLER);
+ // add timestamp
+ eventProps.put(EventConstants.TIMESTAMP, System.currentTimeMillis());
+ // add internal time information
+ if ( time != null ) {
+ eventProps.put(PROPERTY_TIME, time);
+ }
+ eventAdmin.postEvent(new Event(eventTopic, eventProps));
+ }
+
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/queues/JobExecutionContextImpl.java b/src/main/java/org/apache/sling/event/impl/jobs/queues/JobExecutionContextImpl.java
new file mode 100644
index 0000000..d2c2286
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/queues/JobExecutionContextImpl.java
@@ -0,0 +1,124 @@
+/*
+ * 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.event.impl.jobs.queues;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.sling.event.impl.jobs.JobHandler;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.consumer.JobExecutionContext;
+import org.apache.sling.event.jobs.consumer.JobExecutionResult;
+
+/**
+ * Implementation of the job execution context passed to
+ * job executors.
+ */
+public class JobExecutionContextImpl implements JobExecutionContext {
+
+ /**
+ * Call back interface to the queue.
+ */
+ public interface ASyncHandler {
+ void finished(Job.JobState state);
+ }
+
+ /**
+ * Boolean to check whether init progress has been called.
+ */
+ private final AtomicBoolean initProgressIsCalled = new AtomicBoolean(false);
+
+ /**
+ * Flag to indicate whether this is async processing
+ */
+ private final AtomicBoolean isAsync = new AtomicBoolean(false);
+
+ private final ASyncHandler asyncHandler;
+
+ private final JobHandler handler;
+
+ public JobExecutionContextImpl(final JobHandler handler,
+ final ASyncHandler asyncHandler) {
+ this.handler = handler;
+ this.asyncHandler = asyncHandler;
+ }
+
+ public void markAsync() {
+ this.isAsync.set(true);
+ }
+
+ @Override
+ public void initProgress(final int steps,
+ final long eta) {
+ if ( initProgressIsCalled.compareAndSet(false, true) ) {
+ handler.persistJobProperties(handler.getJob().startProgress(steps, eta));
+ }
+ }
+
+ @Override
+ public void incrementProgressCount(final int steps) {
+ if ( initProgressIsCalled.get() ) {
+ handler.persistJobProperties(handler.getJob().setProgress(steps));
+ }
+ }
+
+ @Override
+ public void updateProgress(final long eta) {
+ if ( initProgressIsCalled.get() ) {
+ handler.persistJobProperties(handler.getJob().update(eta));
+ }
+ }
+
+ @Override
+ public void log(final String message, Object... args) {
+ handler.persistJobProperties(handler.getJob().log(message, args));
+ }
+
+ @Override
+ public boolean isStopped() {
+ return handler.isStopped();
+ }
+
+ @Override
+ public void asyncProcessingFinished(final JobExecutionResult result) {
+ synchronized ( this ) {
+ if ( isAsync.compareAndSet(true, false) ) {
+ Job.JobState state = null;
+ if ( result.succeeded() ) {
+ state = Job.JobState.SUCCEEDED;
+ } else if ( result.failed() ) {
+ state = Job.JobState.QUEUED;
+ } else if ( result.cancelled() ) {
+ if ( handler.isStopped() ) {
+ state = Job.JobState.STOPPED;
+ } else {
+ state = Job.JobState.ERROR;
+ }
+ }
+ asyncHandler.finished(state);
+ } else {
+ throw new IllegalStateException("Job is not processed async or is already finished: " + handler.getJob().getId());
+ }
+ }
+ }
+
+ @Override
+ public ResultBuilder result() {
+ return new ResultBuilderImpl();
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/queues/JobExecutionResultImpl.java b/src/main/java/org/apache/sling/event/impl/jobs/queues/JobExecutionResultImpl.java
new file mode 100644
index 0000000..28083e4
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/queues/JobExecutionResultImpl.java
@@ -0,0 +1,97 @@
+/*
+ * 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.event.impl.jobs.queues;
+
+import org.apache.sling.event.impl.jobs.InternalJobState;
+import org.apache.sling.event.jobs.consumer.JobExecutionResult;
+
+/**
+ * The job execution result.
+ */
+public class JobExecutionResultImpl implements JobExecutionResult {
+
+ /** Constant object for the success case. */
+ public static final JobExecutionResultImpl SUCCEEDED = new JobExecutionResultImpl(InternalJobState.SUCCEEDED, null, null);
+ /** Constant object for the cancelled case. */
+ public static final JobExecutionResultImpl CANCELLED = new JobExecutionResultImpl(InternalJobState.CANCELLED, null, null);
+ /** Constant object for the failed case. */
+ public static final JobExecutionResultImpl FAILED = new JobExecutionResultImpl(InternalJobState.FAILED, null, null);
+
+ /** The state of the execution. */
+ private final InternalJobState state;
+
+ /** Optional message. */
+ private final String message;
+
+ /** Optional retry delay. */
+ private final Long retryDelayInMs;
+
+ /**
+ * Create a new result
+ * @param state The result state
+ * @param message Optional Message
+ * @param retryDelayInMs Optional retry delay
+ */
+ public JobExecutionResultImpl(final InternalJobState state,
+ final String message,
+ final Long retryDelayInMs) {
+ this.state = state;
+ this.message = message;
+ this.retryDelayInMs = retryDelayInMs;
+ }
+
+ /**
+ * Get the internal state
+ * @return The state.
+ */
+ public InternalJobState getState() {
+ return this.state;
+ }
+
+ @Override
+ public boolean succeeded() {
+ return this.state == InternalJobState.SUCCEEDED;
+ }
+
+ @Override
+ public boolean cancelled() {
+ return this.state == InternalJobState.CANCELLED;
+ }
+
+ @Override
+ public boolean failed() {
+ return this.state == InternalJobState.FAILED;
+ }
+
+ @Override
+ public String getMessage() {
+ return this.message;
+ }
+
+ @Override
+ public Long getRetryDelayInMs() {
+ return this.retryDelayInMs;
+ }
+
+ @Override
+ public String toString() {
+ return "JobExecutionResultImpl [state=" + state + ", message="
+ + message + ", retryDelayInMs=" + retryDelayInMs + "]";
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/queues/JobQueueImpl.java b/src/main/java/org/apache/sling/event/impl/jobs/queues/JobQueueImpl.java
new file mode 100644
index 0000000..0ab1a6b
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/queues/JobQueueImpl.java
@@ -0,0 +1,708 @@
+/*
+ * 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.event.impl.jobs.queues;
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+import org.apache.sling.api.resource.PersistenceException;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.commons.threads.ThreadPool;
+import org.apache.sling.event.impl.EventingThreadPool;
+import org.apache.sling.event.impl.jobs.InternalJobState;
+import org.apache.sling.event.impl.jobs.JobHandler;
+import org.apache.sling.event.impl.jobs.JobImpl;
+import org.apache.sling.event.impl.jobs.JobTopicTraverser;
+import org.apache.sling.event.impl.jobs.Utility;
+import org.apache.sling.event.impl.jobs.config.InternalQueueConfiguration;
+import org.apache.sling.event.impl.jobs.notifications.NotificationUtility;
+import org.apache.sling.event.impl.support.BatchResourceRemover;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.Job.JobState;
+import org.apache.sling.event.jobs.NotificationConstants;
+import org.apache.sling.event.jobs.Queue;
+import org.apache.sling.event.jobs.QueueConfiguration.Type;
+import org.apache.sling.event.jobs.Statistics;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The job blocking queue extends the blocking queue by some
+ * functionality for the job event handling.
+ */
+public class JobQueueImpl
+ implements Queue {
+
+ /** Default timeout for suspend. */
+ private static final long MAX_SUSPEND_TIME = 1000 * 60 * 60; // 60 mins
+
+ /** The logger. */
+ private final Logger logger;
+
+ /** Configuration. */
+ private final InternalQueueConfiguration configuration;
+
+ /** The queue name. */
+ private volatile String queueName;
+
+ /** Are we still running? */
+ private volatile boolean running;
+
+ /** Suspended since. */
+ private final AtomicLong suspendedSince = new AtomicLong(-1);
+
+ /** Services used by the queues. */
+ private final QueueServices services;
+
+ /** The map of events we're processing. */
+ private final Map<String, JobHandler> processingJobsLists = new HashMap<String, JobHandler>();
+
+ private final ThreadPool threadPool;
+
+ /** Async counter. */
+ private final AtomicInteger asyncCounter = new AtomicInteger();
+
+ /** Flag for outdated. */
+ private final AtomicBoolean isOutdated = new AtomicBoolean(false);
+
+ /** A marker for closing the queue. */
+ private final AtomicBoolean closeMarker = new AtomicBoolean(false);
+
+ /** A marker for doing a full cache search. */
+ private final AtomicBoolean doFullCacheSearch = new AtomicBoolean(false);
+
+ /** A counter for rescheduling. */
+ private final AtomicInteger waitCounter = new AtomicInteger();
+
+ /** The job cache. */
+ private final QueueJobCache cache;
+
+ /** Semaphore for handling the max number of jobs. */
+ private final Semaphore available;
+
+ /** Guard for having only one thread executing start jobs. */
+ private final AtomicBoolean startJobsGuard = new AtomicBoolean(false);
+
+ /** Lock for close/start. */
+ private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
+
+ /** Sleeping until is only set for ordered queues if a job is rescheduled. */
+ private volatile long isSleepingUntil = -1;
+
+ /**
+ * Create a new queue.
+ *
+ * @param name The queue name
+ * @param config The queue configuration
+ * @param services The queue services
+ * @param topics The topics handled by this queue
+ *
+ * @return {@code JobQueueImpl} if there are jobs to process, {@code null} otherwise.
+ */
+ public static JobQueueImpl createQueue(final String name,
+ final InternalQueueConfiguration config,
+ final QueueServices services,
+ final Set<String> topics) {
+ final QueueJobCache cache = new QueueJobCache(services.configuration, name, services.statisticsManager, config.getType(), topics);
+ if ( cache.isEmpty() ) {
+ return null;
+ }
+ return new JobQueueImpl(name, config, services, cache);
+ }
+
+ /**
+ * Create a new queue.
+ *
+ * @param name The queue name
+ * @param config The queue configuration
+ * @param services The queue services
+ * @param cache The job cache
+ */
+ private JobQueueImpl(final String name,
+ final InternalQueueConfiguration config,
+ final QueueServices services,
+ final QueueJobCache cache) {
+ if ( config.getOwnThreadPoolSize() > 0 ) {
+ this.threadPool = new EventingThreadPool(services.threadPoolManager, config.getOwnThreadPoolSize());
+ } else {
+ this.threadPool = services.eventingThreadPool;
+ }
+ this.queueName = name;
+ this.configuration = config;
+ this.services = services;
+ this.logger = LoggerFactory.getLogger(this.getClass().getName() + '.' + name);
+ this.running = true;
+ this.cache = cache;
+ this.available = new Semaphore(config.getMaxParallel(), true);
+ logger.info("Starting job queue {}", queueName);
+ logger.debug("Configuration for job queue={}", configuration);
+ }
+
+ /**
+ * Return the queue configuration
+ */
+ @Override
+ public InternalQueueConfiguration getConfiguration() {
+ return this.configuration;
+ }
+
+ /**
+ * Get the name of the job queue.
+ */
+ @Override
+ public String getName() {
+ return this.queueName;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Queue#getStatistics()
+ */
+ @Override
+ public Statistics getStatistics() {
+ return this.services.statisticsManager.getQueueStatistics(this.queueName);
+ }
+
+ /**
+ * Start the job queue.
+ * This method might be called concurrently, therefore we use a guard
+ */
+ public void startJobs() {
+ if ( this.startJobsGuard.compareAndSet(false, true) ) {
+ // we start as many jobs in parallel as possible
+ while ( this.running && !this.isOutdated.get() && !this.isSuspended() && this.available.tryAcquire() ) {
+ boolean started = false;
+ this.lock.writeLock().lock();
+ try {
+ final JobHandler handler = this.cache.getNextJob(this.services.jobConsumerManager,
+ this.services.statisticsManager, this, this.doFullCacheSearch.getAndSet(false));
+ if ( handler != null ) {
+ started = true;
+ this.threadPool.execute(new Runnable() {
+
+ @Override
+ public void run() {
+ // update thread priority and name
+ final Thread currentThread = Thread.currentThread();
+ final String oldName = currentThread.getName();
+ final int oldPriority = currentThread.getPriority();
+
+ currentThread.setName(oldName + "-" + handler.getJob().getQueueName() + "(" + handler.getJob().getTopic() + ")");
+ if ( configuration.getThreadPriority() != null ) {
+ switch ( configuration.getThreadPriority() ) {
+ case NORM : currentThread.setPriority(Thread.NORM_PRIORITY);
+ break;
+ case MIN : currentThread.setPriority(Thread.MIN_PRIORITY);
+ break;
+ case MAX : currentThread.setPriority(Thread.MAX_PRIORITY);
+ break;
+ }
+ }
+
+ try {
+ startJob(handler);
+ } finally {
+ currentThread.setPriority(oldPriority);
+ currentThread.setName(oldName);
+ }
+ // and try to launch another job
+ startJobs();
+ }
+ });
+ } else {
+ // no job available, stop look
+ break;
+ }
+
+ } finally {
+ if ( !started ) {
+ this.available.release();
+ }
+ this.lock.writeLock().unlock();
+ }
+ }
+ this.startJobsGuard.set(false);
+ }
+ }
+
+ private void startJob(final JobHandler handler) {
+ try {
+ this.closeMarker.set(false);
+ try {
+ final JobImpl job = handler.getJob();
+ handler.started = System.currentTimeMillis();
+
+ this.services.configuration.getAuditLogger().debug("START OK : {}", job.getId());
+ // sanity check for the queued property
+ Calendar queued = job.getProperty(JobImpl.PROPERTY_JOB_QUEUED, Calendar.class);
+ if ( queued == null ) {
+ // we simply use a date of ten seconds ago
+ queued = Calendar.getInstance();
+ queued.setTimeInMillis(System.currentTimeMillis() - 10000);
+ }
+ final long queueTime = handler.started - queued.getTimeInMillis();
+ // update statistics
+ this.services.statisticsManager.jobStarted(this.queueName, job.getTopic(), queueTime);
+ // send notification
+ NotificationUtility.sendNotification(this.services.eventAdmin, NotificationConstants.TOPIC_JOB_STARTED, job, queueTime);
+
+ synchronized ( this.processingJobsLists ) {
+ this.processingJobsLists.put(job.getId(), handler);
+ }
+
+ JobExecutionResultImpl result = JobExecutionResultImpl.CANCELLED;
+ Job.JobState resultState = Job.JobState.ERROR;
+ final JobExecutionContextImpl ctx = new JobExecutionContextImpl(handler, new JobExecutionContextImpl.ASyncHandler() {
+
+ @Override
+ public void finished(final JobState state) {
+ services.jobConsumerManager.unregisterListener(job.getId());
+ finishedJob(job.getId(), state, true);
+ asyncCounter.decrementAndGet();
+ }
+ });
+
+ try {
+ synchronized ( ctx ) {
+ result = (JobExecutionResultImpl)handler.getConsumer().process(job, ctx);
+ if ( result == null ) { // ASYNC processing
+ services.jobConsumerManager.registerListener(job.getId(), handler.getConsumer(), ctx);
+ asyncCounter.incrementAndGet();
+ ctx.markAsync();
+ } else {
+ if ( result.succeeded() ) {
+ resultState = Job.JobState.SUCCEEDED;
+ } else if ( result.failed() ) {
+ resultState = Job.JobState.QUEUED;
+ } else if ( result.cancelled() ) {
+ if ( handler.isStopped() ) {
+ resultState = Job.JobState.STOPPED;
+ } else {
+ resultState = Job.JobState.ERROR;
+ }
+ }
+ }
+ }
+ } catch (final Throwable t) { //NOSONAR
+ logger.error("Unhandled error occured in job processor " + t.getMessage() + " while processing job " + Utility.toString(job), t);
+ // we don't reschedule if an exception occurs
+ result = JobExecutionResultImpl.CANCELLED;
+ resultState = Job.JobState.ERROR;
+ } finally {
+ if ( result != null ) {
+ if ( result.getRetryDelayInMs() != null ) {
+ job.setProperty(JobImpl.PROPERTY_DELAY_OVERRIDE, result.getRetryDelayInMs());
+ }
+ if ( result.getMessage() != null ) {
+ job.setProperty(Job.PROPERTY_RESULT_MESSAGE, result.getMessage());
+ }
+ this.finishedJob(job.getId(), resultState, false);
+ }
+ }
+ } catch (final Exception re) {
+ // if an exception occurs, we just log
+ this.logger.error("Exception during job processing.", re);
+ }
+ } finally {
+ this.available.release();
+ }
+ }
+
+ /**
+ * Outdate this queue.
+ */
+ public void outdate() {
+ if ( this.isOutdated.compareAndSet(false, true) ) {
+ final String name = this.getName() + "<outdated>(" + this.hashCode() + ")";
+ this.logger.info("Outdating queue {}, renaming to {}.", this.queueName, name);
+ this.queueName = name;
+ }
+ }
+
+ /**
+ * Check if the queue can be closed
+ */
+ public boolean tryToClose() {
+ // resume the queue as we want to close it!
+ this.resume();
+ this.lock.writeLock().lock();
+ try {
+ // check if possible
+ if ( this.canBeClosed() ) {
+ if ( this.closeMarker.get() ) {
+ this.close();
+ return true;
+ }
+ this.closeMarker.set(true);
+ }
+ } finally {
+ this.lock.writeLock().unlock();
+ }
+ return false;
+ }
+
+ /**
+ * Check whether this queue can be closed
+ */
+ private boolean canBeClosed() {
+ return !this.isSuspended()
+ && this.asyncCounter.get() == 0
+ && this.waitCounter.get() == 0
+ && this.available.availablePermits() == this.configuration.getMaxParallel();
+ }
+
+ /**
+ * Close this queue.
+ */
+ public void close() {
+ this.running = false;
+ this.logger.debug("Shutting down job queue {}", queueName);
+ this.resume();
+
+ synchronized ( this.processingJobsLists ) {
+ this.processingJobsLists.clear();
+ }
+ if ( this.configuration.getOwnThreadPoolSize() > 0 ) {
+ ((EventingThreadPool)this.threadPool).release();
+ }
+
+ this.logger.info("Stopped job queue {}", this.queueName);
+ }
+
+ /**
+ * Periodic maintenance
+ */
+ public void maintain() {
+ // check suspended
+ final long since = this.suspendedSince.get();
+ if ( since != -1 && since + MAX_SUSPEND_TIME < System.currentTimeMillis() ) {
+ logger.info("Waking up suspended queue. It has been suspended for more than {}ms", MAX_SUSPEND_TIME);
+ this.resume();
+ }
+
+ // set full cache search
+ this.doFullCacheSearch.set(true);
+
+ this.startJobs();
+ }
+
+ /**
+ * Inform the queue about new job for the given topics.
+ * @param topics the new topics
+ */
+ public void wakeUpQueue(final Set<String> topics) {
+ this.cache.handleNewTopics(topics);
+ }
+
+ /**
+ * Put a job back in the queue
+ * @param handler The job handler
+ */
+ private void requeue(final JobHandler handler) {
+ this.cache.reschedule(this.queueName, handler, this.services.statisticsManager);
+ this.startJobs();
+ }
+
+ private static final class RescheduleInfo {
+ public boolean reschedule = false;
+ // processing time is only set of state is SUCCEEDED
+ public long processingTime;
+ public Job.JobState state;
+ public InternalJobState finalState;
+ }
+
+ private RescheduleInfo handleReschedule(final JobHandler handler, final Job.JobState resultState) {
+ final RescheduleInfo info = new RescheduleInfo();
+ info.state = resultState;
+ switch ( resultState ) {
+ case SUCCEEDED : // job is finished
+ if ( this.logger.isDebugEnabled() ) {
+ this.logger.debug("Finished job {}", Utility.toString(handler.getJob()));
+ }
+ info.processingTime = System.currentTimeMillis() - handler.started;
+ info.finalState = InternalJobState.SUCCEEDED;
+ break;
+ case QUEUED : // check if we exceeded the number of retries
+ final int retries = handler.getJob().getProperty(Job.PROPERTY_JOB_RETRIES, 0);
+ int retryCount = handler.getJob().getProperty(Job.PROPERTY_JOB_RETRY_COUNT, 0);
+
+ retryCount++;
+ if ( retries != -1 && retryCount > retries ) {
+ if ( this.logger.isDebugEnabled() ) {
+ this.logger.debug("Cancelled job {}", Utility.toString(handler.getJob()));
+ }
+ info.finalState = InternalJobState.CANCELLED;
+ } else {
+ info.reschedule = true;
+ handler.getJob().retry();
+ if ( this.logger.isDebugEnabled() ) {
+ this.logger.debug("Failed job {}", Utility.toString(handler.getJob()));
+ }
+ info.finalState = InternalJobState.FAILED;
+ }
+ break;
+ default : // consumer cancelled the job (STOPPED, GIVEN_UP, ERROR)
+ if ( this.logger.isDebugEnabled() ) {
+ this.logger.debug("Cancelled job {}", Utility.toString(handler.getJob()));
+ }
+ info.finalState = InternalJobState.CANCELLED;
+ break;
+ }
+
+ if ( info.state == Job.JobState.QUEUED && !info.reschedule ) {
+ info.state = Job.JobState.GIVEN_UP;
+ }
+ return info;
+ }
+
+ /**
+ * Handle job finish and determine whether to reschedule or cancel the job
+ */
+ private boolean finishedJob(final String jobId,
+ final Job.JobState resultState,
+ final boolean isAsync) {
+ this.services.configuration.getAuditLogger().debug("FINISHED {} : {}", resultState, jobId);
+ this.logger.debug("Received finish for job {}, resultState={}", jobId, resultState);
+
+ // get job handler
+ final JobHandler handler;
+ // let's remove the event from our processing list
+ synchronized ( this.processingJobsLists ) {
+ handler = this.processingJobsLists.remove(jobId);
+ }
+
+ if ( !this.running ) {
+ this.logger.warn("Queue is not running anymore. Discarding finish for {}", jobId);
+ return false;
+ }
+
+ if ( handler == null ) {
+ this.logger.warn("This job has never been started by this queue: {}", jobId);
+ return false;
+ }
+
+ // handle the rescheduling of the job
+ final RescheduleInfo rescheduleInfo = this.handleReschedule(handler, resultState);
+
+ if ( !rescheduleInfo.reschedule ) {
+ // we keep cancelled jobs and succeeded jobs if the queue is configured like this.
+ final boolean keepJobs = rescheduleInfo.state != Job.JobState.SUCCEEDED || this.configuration.isKeepJobs();
+ handler.finished(rescheduleInfo.state, keepJobs, rescheduleInfo.processingTime);
+ } else {
+ this.reschedule(handler);
+ }
+ // update statistics
+ this.services.statisticsManager.jobEnded(this.queueName,
+ handler.getJob().getTopic(),
+ rescheduleInfo.finalState,
+ rescheduleInfo.processingTime);
+ // send notification
+ NotificationUtility.sendNotification(this.services.eventAdmin,
+ rescheduleInfo.finalState.getTopic(),
+ handler.getJob(), rescheduleInfo.processingTime);
+
+ return rescheduleInfo.reschedule;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Queue#resume()
+ */
+ @Override
+ public void resume() {
+ if ( this.suspendedSince.getAndSet(-1) != -1 ) {
+ this.logger.debug("Waking up suspended queue {}", queueName);
+ this.startJobs();
+ }
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Queue#suspend()
+ */
+ @Override
+ public void suspend() {
+ if ( this.suspendedSince.compareAndSet(-1, System.currentTimeMillis()) ) {
+ this.logger.debug("Suspending queue {}", queueName);
+ }
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Queue#isSuspended()
+ */
+ @Override
+ public boolean isSuspended() {
+ return this.suspendedSince.get() != -1;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Queue#removeAll()
+ */
+ @Override
+ public synchronized void removeAll() {
+ final Set<String> topics = this.cache.getTopics();
+ logger.debug("Removing all jobs for queue {} : {}", queueName, topics);
+
+ if ( !topics.isEmpty() ) {
+
+ final ResourceResolver resolver = this.services.configuration.createResourceResolver();
+ try {
+ final Resource baseResource = resolver.getResource(this.services.configuration.getLocalJobsPath());
+
+ // sanity check - should never be null
+ if ( baseResource != null ) {
+ final BatchResourceRemover brr = new BatchResourceRemover();
+
+ for(final String t : topics) {
+ final Resource topicResource = baseResource.getChild(t.replace('/', '.'));
+ if ( topicResource != null ) {
+ JobTopicTraverser.traverse(logger, topicResource, new JobTopicTraverser.JobCallback() {
+
+ @Override
+ public boolean handle(final JobImpl job) {
+ final Resource jobResource = topicResource.getResourceResolver().getResource(job.getResourcePath());
+ // sanity check
+ if ( jobResource != null ) {
+ try {
+ brr.delete(jobResource);
+ } catch ( final PersistenceException ignore) {
+ logger.error("Unable to remove job " + job, ignore);
+ topicResource.getResourceResolver().revert();
+ topicResource.getResourceResolver().refresh();
+ }
+ }
+ return true;
+ }
+ });
+ }
+ }
+ try {
+ resolver.commit();
+ } catch ( final PersistenceException ignore) {
+ logger.error("Unable to remove jobs", ignore);
+ }
+ }
+ } finally {
+ resolver.close();
+ }
+ }
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Queue#getState(java.lang.String)
+ */
+ @Override
+ public Object getState(final String key) {
+ if ( this.configuration.getType() == Type.ORDERED ) {
+ if ( "isSleepingUntil".equals(key) ) {
+ return this.isSleepingUntil;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Queue#getStateInfo()
+ */
+ @Override
+ public String getStateInfo() {
+ return "outdated=" + this.isOutdated.get() +
+ ", suspendedSince=" + this.suspendedSince.get() +
+ ", asyncJobs=" + this.asyncCounter.get() +
+ ", waitCount=" + this.waitCounter.get() +
+ ", jobCount=" + String.valueOf(this.configuration.getMaxParallel() - this.available.availablePermits() +
+ (this.configuration.getType() == Type.ORDERED ? ", isSleepingUntil=" + this.isSleepingUntil : ""));
+ }
+
+ /**
+ * Get the retry delay for a job.
+ * @param handler The job handler.
+ * @return The retry delay
+ */
+ private long getRetryDelay(final JobHandler handler) {
+ long delay = this.configuration.getRetryDelayInMs();
+ if ( handler.getJob().getProperty(JobImpl.PROPERTY_DELAY_OVERRIDE) != null ) {
+ delay = handler.getJob().getProperty(JobImpl.PROPERTY_DELAY_OVERRIDE, Long.class);
+ } else if ( handler.getJob().getProperty(Job.PROPERTY_JOB_RETRY_DELAY) != null ) {
+ delay = handler.getJob().getProperty(Job.PROPERTY_JOB_RETRY_DELAY, Long.class);
+ }
+ return delay;
+ }
+
+ public boolean stopJob(final JobImpl job) {
+ final JobHandler handler;
+ synchronized ( this.processingJobsLists ) {
+ handler = this.processingJobsLists.get(job.getId());
+ }
+ if ( handler != null ) {
+ handler.stop();
+ }
+ return handler != null;
+ }
+
+ private void reschedule(final JobHandler handler) {
+ // we delay putting back the job until the retry delay is over
+ final long delay = this.getRetryDelay(handler);
+ if ( delay > 0 ) {
+ if ( this.configuration.getType() == Type.ORDERED ) {
+ this.cache.setIsBlocked(true);
+ }
+ handler.addToRetryList();
+ final Date fireDate = new Date();
+ fireDate.setTime(System.currentTimeMillis() + delay);
+ if ( this.configuration.getType() == Type.ORDERED ) {
+ this.isSleepingUntil = fireDate.getTime();
+ }
+
+ final String jobName = "Waiting:" + queueName + ":" + handler.hashCode();
+ final Runnable t = new Runnable() {
+ @Override
+ public void run() {
+ try {
+ if ( handler.removeFromRetryList() ) {
+ requeue(handler);
+ }
+ waitCounter.decrementAndGet();
+ } finally {
+ if ( configuration.getType() == Type.ORDERED ) {
+ isSleepingUntil = -1;
+ cache.setIsBlocked(false);
+ startJobs();
+ }
+ }
+ }
+ };
+ this.waitCounter.incrementAndGet();
+ if ( !services.scheduler.schedule(t, services.scheduler.AT(fireDate).name(jobName)) ) {
+ // if scheduling fails run the thread directly
+ t.run();
+ }
+ } else {
+ // put directly into queue
+ this.requeue(handler);
+ }
+ }
+}
+
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/queues/QueueJobCache.java b/src/main/java/org/apache/sling/event/impl/jobs/queues/QueueJobCache.java
new file mode 100644
index 0000000..ebcc92d
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/queues/QueueJobCache.java
@@ -0,0 +1,344 @@
+/*
+ * 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.event.impl.jobs.queues;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentSkipListSet;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.event.impl.jobs.JobConsumerManager;
+import org.apache.sling.event.impl.jobs.JobHandler;
+import org.apache.sling.event.impl.jobs.JobImpl;
+import org.apache.sling.event.impl.jobs.JobTopicTraverser;
+import org.apache.sling.event.impl.jobs.Utility;
+import org.apache.sling.event.impl.jobs.config.JobManagerConfiguration;
+import org.apache.sling.event.impl.jobs.stats.StatisticsManager;
+import org.apache.sling.event.jobs.Job.JobState;
+import org.apache.sling.event.jobs.Queue;
+import org.apache.sling.event.jobs.QueueConfiguration;
+import org.apache.sling.event.jobs.QueueConfiguration.Type;
+import org.apache.sling.event.jobs.consumer.JobExecutor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The queue job cache caches jobs per queue based on the topics the queue is actively
+ * processing.
+ */
+public class QueueJobCache {
+
+ /** Logger. */
+ private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+ /** The maximum of pre loaded jobs for a topic. */
+ private final int maxPreloadLimit = 10;
+
+ /** The job manager configuration. */
+ private final JobManagerConfiguration configuration;
+
+ /** The set of topics handled by this queue. */
+ private final Set<String> topics;
+
+ /** The set of new topics to scan. */
+ private final Set<String> topicsWithNewJobs = new HashSet<String>();
+
+ /** The cache of current objects. */
+ private final List<JobImpl> cache = new ArrayList<JobImpl>();
+
+ /** The queue type. */
+ private final QueueConfiguration.Type queueType;
+
+ /** Block the cache - for ordered queues only. */
+ private final AtomicBoolean queueIsBlocked = new AtomicBoolean(false);
+
+ /**
+ * Create a new queue job cache
+ * @param configuration Current job manager configuration
+ * @param queueName The queue name
+ * @param statisticsManager The statistics manager
+ * @param queueType The queue type
+ * @param topics The topics handled by this queue.
+ */
+ public QueueJobCache(final JobManagerConfiguration configuration,
+ final String queueName,
+ final StatisticsManager statisticsManager,
+ final QueueConfiguration.Type queueType,
+ final Set<String> topics) {
+ this.configuration = configuration;
+ this.queueType = queueType;
+ this.topics = new ConcurrentSkipListSet<String>(topics);
+ this.fillCache(queueName, statisticsManager);
+ }
+
+ /**
+ * All topics of this queue.
+ * @return The topics.
+ */
+ public Set<String> getTopics() {
+ return this.topics;
+ }
+
+ /**
+ * Check whether there are jobs for this queue
+ * @return {@code true} if there is any job outstanding.
+ */
+ public boolean isEmpty() {
+ boolean result = true;
+ synchronized ( this.cache ) {
+ result = this.cache.isEmpty();
+ }
+ if ( result ) {
+ synchronized ( this.topicsWithNewJobs ) {
+ result = this.topicsWithNewJobs.isEmpty();
+ }
+ }
+ return result;
+ }
+
+ public void setIsBlocked(final boolean value) {
+ this.queueIsBlocked.set(value);
+ }
+
+ /**
+ * Fill the cache.
+ * No need to sync as this is called from the constructor.
+ */
+ private void fillCache(final String queueName, final StatisticsManager statisticsManager) {
+ final Set<String> checkingTopics = new HashSet<String>();
+ checkingTopics.addAll(this.topics);
+ if ( !checkingTopics.isEmpty() ) {
+ this.loadJobs(queueName, checkingTopics, statisticsManager);
+ }
+ }
+
+ /**
+ * Get the next job.
+ * This method is potentially called concurrently, and
+ * {@link #reschedule(String, JobHandler, StatisticsManager)} and {@link #handleNewTopics(Set)}
+ * can be called concurrently.
+ * @param jobConsumerManager The job consumer manager
+ * @param statisticsManager The statistics manager
+ * @param queue The queue
+ * @param doFull Whether to do a full scan
+ * @return The job handler or {@code null}.
+ */
+ public JobHandler getNextJob(final JobConsumerManager jobConsumerManager,
+ final StatisticsManager statisticsManager,
+ final Queue queue,
+ final boolean doFull) {
+ JobHandler handler = null;
+
+ if ( !this.queueIsBlocked.get() ) {
+ synchronized ( this.cache ) {
+ boolean retry;
+ do {
+ retry = false;
+ if ( this.cache.isEmpty() ) {
+ final Set<String> checkingTopics = new HashSet<String>();
+ synchronized ( this.topicsWithNewJobs ) {
+ checkingTopics.addAll(this.topicsWithNewJobs);
+ this.topicsWithNewJobs.clear();
+ }
+ if ( doFull ) {
+ checkingTopics.addAll(this.topics);
+ }
+ if ( !checkingTopics.isEmpty() ) {
+ this.loadJobs(queue.getName(), checkingTopics, statisticsManager);
+ }
+ }
+
+ if ( !this.cache.isEmpty() ) {
+ final JobImpl job = this.cache.remove(0);
+ final JobExecutor consumer = jobConsumerManager.getExecutor(job.getTopic());
+
+ handler = new JobHandler(job, consumer, this.configuration);
+ if ( consumer != null ) {
+ if ( !handler.startProcessing(queue) ) {
+ statisticsManager.jobDequeued(queue.getName(), handler.getJob().getTopic());
+ if ( logger.isDebugEnabled() ) {
+ logger.debug("Discarding removed job {}", Utility.toString(job));
+ }
+ handler = null;
+ retry = true;
+ }
+ } else {
+ statisticsManager.jobDequeued(queue.getName(), handler.getJob().getTopic());
+ // no consumer on this instance, assign to another instance
+ handler.reassign();
+
+ handler = null;
+ retry = true;
+ }
+
+ }
+ } while ( handler == null && retry);
+ }
+ }
+ return handler;
+ }
+
+ /**
+ * Load the next N x numberOf(topics) jobs
+ * @param checkingTopics The set of topics to check.
+ */
+ private void loadJobs( final String queueName, final Set<String> checkingTopics,
+ final StatisticsManager statisticsManager) {
+ logger.debug("Starting jobs loading from {}...", checkingTopics);
+
+ final Map<String, List<JobImpl>> topicCache = new HashMap<String, List<JobImpl>>();
+
+ final ResourceResolver resolver = this.configuration.createResourceResolver();
+ try {
+ final Resource baseResource = resolver.getResource(this.configuration.getLocalJobsPath());
+ // sanity check - should never be null
+ if ( baseResource != null ) {
+ for(final String topic : checkingTopics) {
+
+ final Resource topicResource = baseResource.getChild(topic.replace('/', '.'));
+ if ( topicResource != null ) {
+ topicCache.put(topic, loadJobs(queueName, topic, topicResource, statisticsManager));
+ }
+ }
+ }
+ } finally {
+ resolver.close();
+ }
+ orderTopics(topicCache);
+
+ logger.debug("Finished jobs loading {}", this.cache.size());
+ }
+
+ /**
+ * Order the topics based on the queue type and put them in the cache.
+ * @param topicCache The topic based cache
+ */
+ private void orderTopics(final Map<String, List<JobImpl>> topicCache) {
+ if ( this.queueType == Type.ORDERED
+ || this.queueType == Type.UNORDERED) {
+ for(final List<JobImpl> list : topicCache.values()) {
+ this.cache.addAll(list);
+ }
+ Collections.sort(this.cache);
+ } else {
+ // topic round robin
+ boolean done = true;
+ do {
+ done = true;
+ for(final Map.Entry<String, List<JobImpl>> entry : topicCache.entrySet()) {
+ if ( !entry.getValue().isEmpty() ) {
+ this.cache.add(entry.getValue().remove(0));
+ if ( !entry.getValue().isEmpty() ) {
+ done = false;
+ }
+ }
+ }
+ } while ( !done ) ;
+ }
+ }
+
+ /**
+ * Load the next N x numberOf(topics) jobs.
+ * @param topic The topic
+ * @param topicResource The parent resource of the jobs
+ * @return The cache which will be filled with the jobs.
+ */
+ private List<JobImpl> loadJobs(final String queueName, final String topic,
+ final Resource topicResource,
+ final StatisticsManager statisticsManager) {
+ logger.debug("Loading jobs from topic {}", topic);
+ final List<JobImpl> list = new ArrayList<JobImpl>();
+
+ final AtomicBoolean scanTopic = new AtomicBoolean(false);
+
+ JobTopicTraverser.traverse(logger, topicResource, new JobTopicTraverser.JobCallback() {
+
+ @Override
+ public boolean handle(final JobImpl job) {
+ if ( job.getProcessingStarted() == null && !job.hasReadErrors() ) {
+ list.add(job);
+ statisticsManager.jobQueued(queueName, topic);
+ if ( list.size() == maxPreloadLimit ) {
+ scanTopic.set(true);
+ }
+ } else if ( job.getProcessingStarted() != null ) {
+ logger.debug("Ignoring job {} - processing already started.", job);
+ } else {
+ // error reading job
+ scanTopic.set(true);
+ if ( job.isReadErrorRecoverable() ) {
+ logger.debug("Ignoring job {} due to recoverable read errors.", job);
+ } else {
+ logger.debug("Failing job {} due to unrecoverable read errors.", job);
+ final JobHandler handler = new JobHandler(job, null, configuration);
+ handler.finished(JobState.ERROR, true, null);
+ }
+ }
+ return list.size() < maxPreloadLimit;
+ }
+ });
+ if ( scanTopic.get() ) {
+ synchronized ( this.topicsWithNewJobs ) {
+ this.topicsWithNewJobs.add(topic);
+ }
+ }
+ logger.debug("Caching {} jobs for topic {}", list.size(), topic);
+
+ return list;
+ }
+
+ /**
+ * Inform the queue cache about topics containing new jobs
+ * @param topics The set of topics to scan
+ */
+ public void handleNewTopics(final Set<String> topics) {
+ logger.debug("Update cache to handle new event for topics {}", topics);
+ synchronized ( this.topicsWithNewJobs ) {
+ this.topicsWithNewJobs.addAll(topics);
+ }
+ this.topics.addAll(topics);
+ }
+
+ /**
+ * Reschedule a job
+ * Reschedule the job and add it back into the cache.
+ * @param queueName The queue name
+ * @param handler The job handler
+ * @param statisticsManager The statistics manager
+ */
+ public void reschedule(final String queueName, final JobHandler handler, final StatisticsManager statisticsManager) {
+ synchronized ( this.cache ) {
+ if ( handler.reschedule() ) {
+ if ( this.queueType == Type.ORDERED ) {
+ this.cache.add(0, handler.getJob());
+ } else {
+ this.cache.add(handler.getJob());
+ }
+ statisticsManager.jobQueued(queueName, handler.getJob().getTopic());
+ }
+ }
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/queues/QueueManager.java b/src/main/java/org/apache/sling/event/impl/jobs/queues/QueueManager.java
new file mode 100644
index 0000000..fd7fcf8
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/queues/QueueManager.java
@@ -0,0 +1,447 @@
+/*
+ * 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.event.impl.jobs.queues;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.felix.scr.annotations.Activate;
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.Deactivate;
+import org.apache.felix.scr.annotations.Properties;
+import org.apache.felix.scr.annotations.Property;
+import org.apache.felix.scr.annotations.Reference;
+import org.apache.felix.scr.annotations.Service;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.commons.scheduler.Scheduler;
+import org.apache.sling.commons.threads.ThreadPool;
+import org.apache.sling.commons.threads.ThreadPoolManager;
+import org.apache.sling.event.impl.EventingThreadPool;
+import org.apache.sling.event.impl.jobs.JobConsumerManager;
+import org.apache.sling.event.impl.jobs.JobHandler;
+import org.apache.sling.event.impl.jobs.JobImpl;
+import org.apache.sling.event.impl.jobs.config.ConfigurationChangeListener;
+import org.apache.sling.event.impl.jobs.config.InternalQueueConfiguration;
+import org.apache.sling.event.impl.jobs.config.JobManagerConfiguration;
+import org.apache.sling.event.impl.jobs.config.QueueConfigurationManager;
+import org.apache.sling.event.impl.jobs.config.QueueConfigurationManager.QueueInfo;
+import org.apache.sling.event.impl.jobs.jmx.QueueStatusEvent;
+import org.apache.sling.event.impl.jobs.jmx.QueuesMBeanImpl;
+import org.apache.sling.event.impl.jobs.stats.StatisticsManager;
+import org.apache.sling.event.impl.support.Environment;
+import org.apache.sling.event.impl.support.ResourceHelper;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.NotificationConstants;
+import org.apache.sling.event.jobs.Queue;
+import org.apache.sling.event.jobs.jmx.QueuesMBean;
+import org.osgi.service.event.Event;
+import org.osgi.service.event.EventAdmin;
+import org.osgi.service.event.EventConstants;
+import org.osgi.service.event.EventHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/**
+ * Implementation of the queue manager.
+ */
+@Component(immediate=true)
+@Service(value={Runnable.class, QueueManager.class, EventHandler.class})
+@Properties({
+ @Property(name=Scheduler.PROPERTY_SCHEDULER_PERIOD, longValue=60),
+ @Property(name=Scheduler.PROPERTY_SCHEDULER_CONCURRENT, boolValue=false),
+ @Property(name=EventConstants.EVENT_TOPIC, value=NotificationConstants.TOPIC_JOB_ADDED)
+})
+public class QueueManager
+ implements Runnable, EventHandler, ConfigurationChangeListener {
+
+ /** Default logger. */
+ private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+ @Reference
+ private EventAdmin eventAdmin;
+
+ @Reference
+ private Scheduler scheduler;
+
+ @Reference
+ private JobConsumerManager jobConsumerManager;
+
+ @Reference
+ private QueuesMBean queuesMBean;
+
+ @Reference
+ private ThreadPoolManager threadPoolManager;
+
+ /**
+ * Our thread pool.
+ */
+ @Reference(referenceInterface=EventingThreadPool.class)
+ private ThreadPool threadPool;
+
+ /** The job manager configuration. */
+ @Reference
+ private JobManagerConfiguration configuration;
+
+ @Reference
+ private StatisticsManager statisticsManager;
+
+ /** Lock object for the queues map - we don't want to sync directly on the concurrent map. */
+ private final Object queuesLock = new Object();
+
+ /** All active queues. */
+ private final Map<String, JobQueueImpl> queues = new ConcurrentHashMap<String, JobQueueImpl>();
+
+ /** We count the scheduler runs. */
+ private volatile long schedulerRuns;
+
+ /** Flag whether the manager is active or suspended. */
+ private final AtomicBoolean isActive = new AtomicBoolean(false);
+
+ /** The queue services. */
+ private volatile QueueServices queueServices;
+
+ /**
+ * Activate this component.
+ * @param props Configuration properties
+ */
+ @Activate
+ protected void activate(final Map<String, Object> props) {
+ logger.info("Apache Sling Queue Manager starting on instance {}", Environment.APPLICATION_ID);
+ this.queueServices = new QueueServices();
+ queueServices.configuration = this.configuration;
+ queueServices.eventAdmin = this.eventAdmin;
+ queueServices.jobConsumerManager = this.jobConsumerManager;
+ queueServices.scheduler = this.scheduler;
+ queueServices.threadPoolManager = this.threadPoolManager;
+ queueServices.statisticsManager = statisticsManager;
+ queueServices.eventingThreadPool = this.threadPool;
+ this.configuration.addListener(this);
+ logger.info("Apache Sling Queue Manager started on instance {}", Environment.APPLICATION_ID);
+ }
+
+ /**
+ * Deactivate this component.
+ */
+ @Deactivate
+ protected void deactivate() {
+ logger.debug("Apache Sling Queue Manager stopping on instance {}", Environment.APPLICATION_ID);
+
+ this.configuration.removeListener(this);
+ final Iterator<JobQueueImpl> i = this.queues.values().iterator();
+ while ( i.hasNext() ) {
+ final JobQueueImpl jbq = i.next();
+ jbq.close();
+ // update mbeans
+ ((QueuesMBeanImpl)queuesMBean).sendEvent(new QueueStatusEvent(null, jbq));
+ }
+ this.queues.clear();
+ this.queueServices = null;
+ logger.info("Apache Sling Queue Manager stopped on instance {}", Environment.APPLICATION_ID);
+ }
+
+ /**
+ * This method is invoked periodically by the scheduler.
+ * It searches for idle queues and stops them after a timeout. If a queue
+ * is idle for two consecutive clean up calls, it is removed.
+ * @see java.lang.Runnable#run()
+ */
+ private void maintain() {
+ this.schedulerRuns++;
+ logger.debug("Queue manager maintenance: Starting #{}", this.schedulerRuns);
+
+ // queue maintenance
+ if ( this.isActive.get() ) {
+ for(final JobQueueImpl jbq : this.queues.values() ) {
+ jbq.maintain();
+ }
+ }
+
+ // full topic scan is done every third run
+ if ( schedulerRuns % 3 == 0 && this.isActive.get() ) {
+ this.fullTopicScan();
+ }
+
+ // we only do a full clean up on every fifth run
+ final boolean doFullCleanUp = (schedulerRuns % 5 == 0);
+
+ if ( doFullCleanUp ) {
+ // check for idle queue
+ logger.debug("Checking for idle queues...");
+
+ // we synchronize to avoid creating a queue which is about to be removed during cleanup
+ synchronized ( queuesLock ) {
+ final Iterator<Map.Entry<String, JobQueueImpl>> i = this.queues.entrySet().iterator();
+ while ( i.hasNext() ) {
+ final Map.Entry<String, JobQueueImpl> current = i.next();
+ final JobQueueImpl jbq = current.getValue();
+ if ( jbq.tryToClose() ) {
+ logger.debug("Removing idle job queue {}", jbq);
+ // remove
+ i.remove();
+ // update mbeans
+ ((QueuesMBeanImpl)queuesMBean).sendEvent(new QueueStatusEvent(null, jbq));
+ }
+ }
+ }
+ }
+ logger.debug("Queue manager maintenance: Finished #{}", this.schedulerRuns);
+ }
+
+ /**
+ * Start a new queue
+ * This method first searches the corresponding queue - if such a queue
+ * does not exist yet, it is created and started.
+ *
+ * @param queueInfo The queue info
+ * @param topics The topics
+ */
+ private void start(final QueueInfo queueInfo,
+ final Set<String> topics) {
+ final InternalQueueConfiguration config = queueInfo.queueConfiguration;
+ // get or create queue
+ boolean isNewQueue = false;
+ JobQueueImpl queue = null;
+ // we synchronize to avoid creating a queue which is about to be removed during cleanup
+ synchronized ( queuesLock ) {
+ queue = this.queues.get(queueInfo.queueName);
+ // check for reconfiguration, we really do an identity check here(!)
+ if ( queue != null && queue.getConfiguration() != config ) {
+ this.outdateQueue(queue);
+ // we use a new queue with the configuration
+ queue = null;
+ }
+ if ( queue == null ) {
+ queue = JobQueueImpl.createQueue(queueInfo.queueName, config, queueServices, topics);
+ // on startup the queue might be empty and we get null back from createQueue
+ if ( queue != null ) {
+ isNewQueue = true;
+ queues.put(queueInfo.queueName, queue);
+ ((QueuesMBeanImpl)queuesMBean).sendEvent(new QueueStatusEvent(queue, null));
+ }
+ }
+ }
+ if ( queue != null ) {
+ if ( !isNewQueue ) {
+ queue.wakeUpQueue(topics);
+ }
+ queue.startJobs();
+ }
+ }
+
+ /**
+ * This method is invoked periodically by the scheduler.
+ * In the default configuration every minute
+ * @see java.lang.Runnable#run()
+ */
+ @Override
+ public void run() {
+ this.maintain();
+ }
+
+ private void outdateQueue(final JobQueueImpl queue) {
+ // remove the queue with the old name
+ // check for main queue
+ final String oldName = ResourceHelper.filterQueueName(queue.getName());
+ this.queues.remove(oldName);
+ // check if we can close or have to rename
+ if ( queue.tryToClose() ) {
+ // copy statistics
+ // update mbeans
+ ((QueuesMBeanImpl)queuesMBean).sendEvent(new QueueStatusEvent(null, queue));
+ } else {
+ queue.outdate();
+ // readd with new name
+ String newName = ResourceHelper.filterName(queue.getName());
+ int index = 0;
+ while ( this.queues.containsKey(newName) ) {
+ newName = ResourceHelper.filterName(queue.getName()) + '$' + String.valueOf(index++);
+ }
+ this.queues.put(newName, queue);
+ // update mbeans
+ ((QueuesMBeanImpl)queuesMBean).sendEvent(new QueueStatusEvent(queue, queue));
+ }
+ }
+
+ /**
+ * Outdate all queues.
+ */
+ private void restart() {
+ // let's rename/close all queues and clear them
+ synchronized ( queuesLock ) {
+ final List<JobQueueImpl> queues = new ArrayList<JobQueueImpl>(this.queues.values());
+ for(final JobQueueImpl queue : queues ) {
+ this.outdateQueue(queue);
+ }
+ }
+ // check if we're still active
+ final JobManagerConfiguration config = this.configuration;
+ if ( config != null ) {
+ final List<Job> rescheduleList = this.configuration.clearJobRetryList();
+ for(final Job j : rescheduleList) {
+ final JobHandler jh = new JobHandler((JobImpl)j, null, this.configuration);
+ jh.reschedule();
+ }
+ }
+ }
+
+ /**
+ * @param name The queue name
+ * @return The queue or {@code null}.
+ * @see org.apache.sling.event.jobs.JobManager#getQueue(java.lang.String)
+ */
+ public Queue getQueue(final String name) {
+ return this.queues.get(name);
+ }
+
+ /**
+ * @return An iterator for the available queues.
+ * @see org.apache.sling.event.jobs.JobManager#getQueues()
+ */
+ public Iterable<Queue> getQueues() {
+ final Iterator<JobQueueImpl> jqI = this.queues.values().iterator();
+ return new Iterable<Queue>() {
+
+ @Override
+ public Iterator<Queue> iterator() {
+ return new Iterator<Queue>() {
+
+ @Override
+ public boolean hasNext() {
+ return jqI.hasNext();
+ }
+
+ @Override
+ public Queue next() {
+ return jqI.next();
+ }
+
+ @Override
+ public void remove() {
+ throw new UnsupportedOperationException();
+ }
+ };
+ }
+ };
+ }
+
+ /**
+ * This method is called whenever the topology or queue configurations change.
+ * @param active Whether the job handling is active atm.
+ */
+ @Override
+ public void configurationChanged(final boolean active) {
+ // are we still active?
+ if ( this.configuration != null ) {
+ logger.debug("Topology changed {}", active);
+ this.isActive.set(active);
+ if ( active ) {
+ fullTopicScan();
+ } else {
+ this.restart();
+ }
+ }
+ }
+
+ private void fullTopicScan() {
+ logger.debug("Scanning repository for existing topics...");
+ final Set<String> topics = this.scanTopics();
+ final Map<QueueInfo, Set<String>> mapping = this.updateTopicMapping(topics);
+ // start queues
+ for(final Map.Entry<QueueInfo, Set<String>> entry : mapping.entrySet() ) {
+ this.start(entry.getKey(), entry.getValue());
+ }
+ }
+
+ /**
+ * Scan the resource tree for topics.
+ */
+ private Set<String> scanTopics() {
+ final Set<String> topics = new HashSet<String>();
+
+ final ResourceResolver resolver = this.configuration.createResourceResolver();
+ try {
+ final Resource baseResource = resolver.getResource(this.configuration.getLocalJobsPath());
+
+ // sanity check - should never be null
+ if ( baseResource != null ) {
+ final Iterator<Resource> topicIter = baseResource.listChildren();
+ while ( topicIter.hasNext() ) {
+ final Resource topicResource = topicIter.next();
+ final String topic = topicResource.getName().replace('.', '/');
+ logger.debug("Found topic {}", topic);
+ topics.add(topic);
+ }
+ }
+ } finally {
+ resolver.close();
+ }
+ return topics;
+ }
+
+ /**
+ * @see org.osgi.service.event.EventHandler#handleEvent(org.osgi.service.event.Event)
+ */
+ @Override
+ public void handleEvent(final Event event) {
+ final String topic = (String)event.getProperty(NotificationConstants.NOTIFICATION_PROPERTY_JOB_TOPIC);
+ if ( this.isActive.get() && topic != null ) {
+ final QueueInfo info = this.configuration.getQueueConfigurationManager().getQueueInfo(topic);
+ this.start(info, Collections.singleton(topic));
+ }
+ }
+
+ /**
+ * Get the latest mapping from queue name to topics
+ */
+ private Map<QueueInfo, Set<String>> updateTopicMapping(final Set<String> topics) {
+ final Map<QueueInfo, Set<String>> mapping = new HashMap<QueueConfigurationManager.QueueInfo, Set<String>>();
+ for(final String topic : topics) {
+ final QueueInfo queueInfo = this.configuration.getQueueConfigurationManager().getQueueInfo(topic);
+ Set<String> queueTopics = mapping.get(queueInfo);
+ if ( queueTopics == null ) {
+ queueTopics = new HashSet<String>();
+ mapping.put(queueInfo, queueTopics);
+ }
+ queueTopics.add(topic);
+ }
+
+ this.logger.debug("Established new topic mapping: {}", mapping);
+ return mapping;
+ }
+
+ protected void bindThreadPool(final EventingThreadPool etp) {
+ this.threadPool = etp;
+ }
+
+ protected void unbindThreadPool(final EventingThreadPool etp) {
+ if ( this.threadPool == etp ) {
+ this.threadPool = null;
+ }
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/queues/QueueServices.java b/src/main/java/org/apache/sling/event/impl/jobs/queues/QueueServices.java
new file mode 100644
index 0000000..e39235f
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/queues/QueueServices.java
@@ -0,0 +1,49 @@
+/*
+ * 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.event.impl.jobs.queues;
+
+import org.apache.sling.commons.scheduler.Scheduler;
+import org.apache.sling.commons.threads.ThreadPool;
+import org.apache.sling.commons.threads.ThreadPoolManager;
+import org.apache.sling.event.impl.jobs.JobConsumerManager;
+import org.apache.sling.event.impl.jobs.config.JobManagerConfiguration;
+import org.apache.sling.event.impl.jobs.stats.StatisticsManager;
+import org.osgi.service.event.EventAdmin;
+
+/**
+ * The queue services class is a helper class containing all
+ * services used by the queue implementations.
+ * This avoids passing a set of separate objects.
+ */
+public class QueueServices {
+
+ public JobManagerConfiguration configuration;
+
+ public JobConsumerManager jobConsumerManager;
+
+ public EventAdmin eventAdmin;
+
+ public ThreadPoolManager threadPoolManager;
+
+ public Scheduler scheduler;
+
+ public StatisticsManager statisticsManager;
+
+ public ThreadPool eventingThreadPool;
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/queues/ResultBuilderImpl.java b/src/main/java/org/apache/sling/event/impl/jobs/queues/ResultBuilderImpl.java
new file mode 100644
index 0000000..93684d9
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/queues/ResultBuilderImpl.java
@@ -0,0 +1,57 @@
+/*
+ * 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.event.impl.jobs.queues;
+
+import org.apache.sling.event.impl.jobs.InternalJobState;
+import org.apache.sling.event.jobs.consumer.JobExecutionContext.ResultBuilder;
+import org.apache.sling.event.jobs.consumer.JobExecutionResult;
+
+public class ResultBuilderImpl implements ResultBuilder {
+
+ private volatile String message;
+
+ private volatile Long retryDelayInMs;
+
+ @Override
+ public JobExecutionResult failed(final long retryDelayInMs) {
+ this.retryDelayInMs = retryDelayInMs;
+ return new JobExecutionResultImpl(InternalJobState.FAILED, message, retryDelayInMs);
+ }
+
+ @Override
+ public ResultBuilder message(final String message) {
+ this.message = message;
+ return this;
+ }
+
+ @Override
+ public JobExecutionResult succeeded() {
+ return new JobExecutionResultImpl(InternalJobState.SUCCEEDED, message, retryDelayInMs);
+ }
+
+ @Override
+ public JobExecutionResult failed() {
+ return new JobExecutionResultImpl(InternalJobState.FAILED, message, retryDelayInMs);
+ }
+
+ @Override
+ public JobExecutionResult cancelled() {
+ return new JobExecutionResultImpl(InternalJobState.CANCELLED, message, retryDelayInMs);
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/scheduling/JobScheduleBuilderImpl.java b/src/main/java/org/apache/sling/event/impl/jobs/scheduling/JobScheduleBuilderImpl.java
new file mode 100644
index 0000000..7e46985
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/scheduling/JobScheduleBuilderImpl.java
@@ -0,0 +1,120 @@
+/*
+ * 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.event.impl.jobs.scheduling;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.sling.event.impl.support.ScheduleInfoImpl;
+import org.apache.sling.event.jobs.JobBuilder.ScheduleBuilder;
+import org.apache.sling.event.jobs.ScheduledJobInfo;
+
+/**
+ * The builder implementation for scheduled jobs.
+ */
+public final class JobScheduleBuilderImpl implements ScheduleBuilder {
+
+ private final String topic;
+
+ private final Map<String, Object> properties;
+
+ private final String scheduleName;
+
+ private final JobSchedulerImpl jobScheduler;
+
+ private volatile boolean suspend = false;
+
+ private final List<ScheduleInfoImpl> schedules = new ArrayList<ScheduleInfoImpl>();
+
+ public JobScheduleBuilderImpl(
+ final String topic,
+ final Map<String, Object> properties,
+ final String name,
+ final JobSchedulerImpl jobScheduler) {
+ this.topic = topic;
+ this.properties = properties;
+ this.scheduleName = name;
+ this.jobScheduler = jobScheduler;
+ }
+
+ @Override
+ public ScheduleBuilder weekly(final int day, final int hour, final int minute) {
+ schedules.add(ScheduleInfoImpl.WEEKLY(day, hour, minute));
+ return this;
+ }
+
+ @Override
+ public ScheduleBuilder daily(final int hour, final int minute) {
+ schedules.add(ScheduleInfoImpl.DAILY(hour, minute));
+ return this;
+ }
+
+ @Override
+ public ScheduleBuilder hourly(final int minute) {
+ schedules.add(ScheduleInfoImpl.HOURLY(minute));
+ return this;
+ }
+
+ @Override
+ public ScheduleBuilder at(final Date date) {
+ schedules.add(ScheduleInfoImpl.AT(date));
+ return this;
+ }
+
+ @Override
+ public ScheduleBuilder monthly(final int day, final int hour, final int minute) {
+ schedules.add(ScheduleInfoImpl.MONTHLY(day, hour, minute));
+ return this;
+ }
+
+ @Override
+ public ScheduleBuilder yearly(final int month, final int day, final int hour, final int minute) {
+ schedules.add(ScheduleInfoImpl.YEARLY(month, day, hour, minute));
+ return this;
+ }
+
+ @Override
+ public ScheduleBuilder cron(final String expression) {
+ schedules.add(ScheduleInfoImpl.CRON(expression));
+ return this;
+ }
+
+ @Override
+ public ScheduledJobInfo add() {
+ return this.add(null);
+ }
+
+ @Override
+ public ScheduledJobInfo add(final List<String> errors) {
+ return this.jobScheduler.addScheduledJob(topic,
+ properties,
+ scheduleName,
+ suspend,
+ schedules,
+ errors);
+ }
+
+ @Override
+ public ScheduleBuilder suspend() {
+ this.suspend = true;
+ return this;
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/scheduling/JobSchedulerImpl.java b/src/main/java/org/apache/sling/event/impl/jobs/scheduling/JobSchedulerImpl.java
new file mode 100644
index 0000000..b4b7612
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/scheduling/JobSchedulerImpl.java
@@ -0,0 +1,566 @@
+/*
+ * 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.event.impl.jobs.scheduling;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.sling.api.resource.ModifiableValueMap;
+import org.apache.sling.api.resource.PersistenceException;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.observation.ExternalResourceChangeListener;
+import org.apache.sling.api.resource.observation.ResourceChange;
+import org.apache.sling.api.resource.observation.ResourceChangeListener;
+import org.apache.sling.commons.scheduler.JobContext;
+import org.apache.sling.commons.scheduler.ScheduleOptions;
+import org.apache.sling.commons.scheduler.Scheduler;
+import org.apache.sling.event.impl.jobs.JobManagerImpl;
+import org.apache.sling.event.impl.jobs.Utility;
+import org.apache.sling.event.impl.jobs.config.ConfigurationChangeListener;
+import org.apache.sling.event.impl.jobs.config.JobManagerConfiguration;
+import org.apache.sling.event.impl.jobs.config.TopologyCapabilities;
+import org.apache.sling.event.impl.support.ResourceHelper;
+import org.apache.sling.event.impl.support.ScheduleInfoImpl;
+import org.apache.sling.event.jobs.JobBuilder;
+import org.apache.sling.event.jobs.ScheduleInfo;
+import org.apache.sling.event.jobs.ScheduleInfo.ScheduleType;
+import org.apache.sling.event.jobs.ScheduledJobInfo;
+import org.osgi.service.event.Event;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/**
+ * The scheduler for managing scheduled jobs.
+ *
+ * This is not a component by itself, it's directly created from the job manager.
+ * The job manager is also registering itself as an event handler and forwards
+ * the events to this service.
+ */
+public class JobSchedulerImpl
+ implements ConfigurationChangeListener,
+ ResourceChangeListener, ExternalResourceChangeListener,
+ org.apache.sling.commons.scheduler.Job {
+
+ private static final String PROPERTY_READ_JOB = "properties";
+
+ private static final String PROPERTY_SCHEDULE_INDEX = "index";
+
+ /** Default logger */
+ private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+ /** Is this active? */
+ private final AtomicBoolean active = new AtomicBoolean(false);
+
+ /** Central job handling configuration. */
+ private final JobManagerConfiguration configuration;
+
+ /** Scheduler service. */
+ private final Scheduler scheduler;
+
+ /** Job manager. */
+ private final JobManagerImpl jobManager;
+
+ /** Scheduled job handler. */
+ private final ScheduledJobHandler scheduledJobHandler;
+
+ /** All scheduled jobs, by scheduler name */
+ private final Map<String, ScheduledJobInfoImpl> scheduledJobs = new HashMap<String, ScheduledJobInfoImpl>();
+
+ /**
+ * Create the scheduler
+ * @param configuration Central job manager configuration
+ * @param scheduler The scheduler service
+ * @param jobManager The job manager
+ */
+ public JobSchedulerImpl(final JobManagerConfiguration configuration,
+ final Scheduler scheduler,
+ final JobManagerImpl jobManager) {
+ this.configuration = configuration;
+ this.scheduler = scheduler;
+ this.jobManager = jobManager;
+
+ this.configuration.addListener(this);
+
+ this.scheduledJobHandler = new ScheduledJobHandler(configuration, this);
+ }
+
+ /**
+ * Deactivate this component.
+ */
+ public void deactivate() {
+ this.configuration.removeListener(this);
+
+ this.scheduledJobHandler.deactivate();
+
+ if ( this.active.compareAndSet(true, false) ) {
+ this.stopScheduling();
+ }
+ synchronized ( this.scheduledJobs ) {
+ this.scheduledJobs.clear();
+ }
+ }
+
+ /**
+ * @see org.apache.sling.event.impl.jobs.config.ConfigurationChangeListener#configurationChanged(boolean)
+ */
+ @Override
+ public void configurationChanged(final boolean processingActive) {
+ // scheduling is only active if
+ // - processing is active and
+ // - configuration is still available and active
+ // - and current instance is leader
+ final boolean schedulingActive;
+ if ( processingActive ) {
+ final TopologyCapabilities caps = this.configuration.getTopologyCapabilities();
+ if ( caps != null && caps.isActive() ) {
+ schedulingActive = caps.isLeader();
+ } else {
+ schedulingActive = false;
+ }
+ } else {
+ schedulingActive = false;
+ }
+
+ // switch activation based on current state and new state
+ if ( schedulingActive ) {
+ // activate if inactive
+ if ( this.active.compareAndSet(false, true) ) {
+ this.startScheduling();
+ }
+ } else {
+ // deactivate if active
+ if ( this.active.compareAndSet(true, false) ) {
+ this.stopScheduling();
+ }
+ }
+ }
+
+ /**
+ * Start all scheduled jobs
+ */
+ private void startScheduling() {
+ synchronized ( this.scheduledJobs ) {
+ for(final ScheduledJobInfo info : this.scheduledJobs.values()) {
+ this.startScheduledJob(((ScheduledJobInfoImpl)info));
+ }
+ }
+ }
+
+ /**
+ * Stop all scheduled jobs.
+ */
+ private void stopScheduling() {
+ synchronized ( this.scheduledJobs ) {
+ for(final ScheduledJobInfo info : this.scheduledJobs.values()) {
+ this.stopScheduledJob((ScheduledJobInfoImpl)info);
+ }
+ }
+ }
+
+ /**
+ * Add a scheduled job
+ */
+ public void scheduleJob(final ScheduledJobInfoImpl info) {
+ synchronized ( this.scheduledJobs ) {
+ this.scheduledJobs.put(info.getName(), info);
+ this.startScheduledJob(info);
+ }
+ }
+
+ /**
+ * Unschedule a scheduled job
+ */
+ public void unscheduleJob(final ScheduledJobInfoImpl info) {
+ synchronized ( this.scheduledJobs ) {
+ if ( this.scheduledJobs.remove(info.getName()) != null ) {
+ this.stopScheduledJob(info);
+ }
+ }
+ }
+
+ /**
+ * Remove a scheduled job
+ */
+ public void removeJob(final ScheduledJobInfoImpl info) {
+ this.unscheduleJob(info);
+ this.scheduledJobHandler.remove(info);
+ }
+
+ /**
+ * Start a scheduled job
+ * @param info The scheduling info
+ */
+ private void startScheduledJob(final ScheduledJobInfoImpl info) {
+ if ( this.active.get() ) {
+ if ( !info.isSuspended() ) {
+ this.configuration.getAuditLogger().debug("SCHEDULED OK name={}, topic={}, properties={} : {}",
+ new Object[] {info.getName(),
+ info.getJobTopic(),
+ info.getJobProperties()},
+ info.getSchedules());
+ int index = 0;
+ for(final ScheduleInfo si : info.getSchedules()) {
+ final String name = info.getSchedulerJobId() + "-" + String.valueOf(index);
+ ScheduleOptions options = null;
+ switch ( si.getType() ) {
+ case DAILY:
+ case WEEKLY:
+ case HOURLY:
+ case MONTHLY:
+ case YEARLY:
+ case CRON:
+ options = this.scheduler.EXPR(((ScheduleInfoImpl)si).getCronExpression());
+
+ break;
+ case DATE:
+ options = this.scheduler.AT(((ScheduleInfoImpl)si).getNextScheduledExecution());
+ break;
+ }
+ // Create configuration for scheduled job
+ final Map<String, Serializable> config = new HashMap<String, Serializable>();
+ config.put(PROPERTY_READ_JOB, info);
+ config.put(PROPERTY_SCHEDULE_INDEX, index);
+ this.scheduler.schedule(this, options.name(name).config(config).canRunConcurrently(false));
+ index++;
+ }
+ } else {
+ this.configuration.getAuditLogger().debug("SCHEDULED SUSPENDED name={}, topic={}, properties={} : {}",
+ new Object[] {info.getName(),
+ info.getJobTopic(),
+ info.getJobProperties(),
+ info.getSchedules()});
+ }
+ }
+ }
+
+ /**
+ * Stop a scheduled job
+ * @param info The scheduling info
+ */
+ private void stopScheduledJob(final ScheduledJobInfoImpl info) {
+ final Scheduler localScheduler = this.scheduler;
+ if ( localScheduler != null ) {
+ this.configuration.getAuditLogger().debug("SCHEDULED STOP name={}, topic={}, properties={} : {}",
+ new Object[] {info.getName(),
+ info.getJobTopic(),
+ info.getJobProperties(),
+ info.getSchedules()});
+ for(int index = 0; index<info.getSchedules().size(); index++) {
+ final String name = info.getSchedulerJobId() + "-" + String.valueOf(index);
+ localScheduler.unschedule(name);
+ }
+ }
+ }
+
+ /**
+ * @see org.apache.sling.commons.scheduler.Job#execute(org.apache.sling.commons.scheduler.JobContext)
+ */
+ @Override
+ public void execute(final JobContext context) {
+ if ( !active.get() ) {
+ // not active anymore, simply return
+ return;
+ }
+ final ScheduledJobInfoImpl info = (ScheduledJobInfoImpl) context.getConfiguration().get(PROPERTY_READ_JOB);
+
+ if ( info.isSuspended() ) {
+ return;
+ }
+
+ this.jobManager.addJob(info.getJobTopic(), info.getJobProperties());
+ final int index = (Integer)context.getConfiguration().get(PROPERTY_SCHEDULE_INDEX);
+ final Iterator<ScheduleInfo> iter = info.getSchedules().iterator();
+ ScheduleInfo si = iter.next();
+ for(int i=0; i<index; i++) {
+ si = iter.next();
+ }
+ // if scheduled once (DATE), remove from schedule
+ if ( si.getType() == ScheduleType.DATE ) {
+ if ( index == 0 && info.getSchedules().size() == 1 ) {
+ // remove
+ this.scheduledJobHandler.remove(info);
+ } else {
+ // update schedule list
+ final List<ScheduleInfo> infos = new ArrayList<ScheduleInfo>();
+ for(final ScheduleInfo i : info.getSchedules() ) {
+ if ( i != si ) { // no need to use equals
+ infos.add(i);
+ }
+ }
+ info.update(infos);
+ this.scheduledJobHandler.updateSchedule(info.getName(), infos);
+ }
+ }
+ }
+
+ /**
+ * @see org.osgi.service.event.EventHandler#handleEvent(org.osgi.service.event.Event)
+ */
+ public void handleEvent(final Event event) {
+ if ( ResourceHelper.BUNDLE_EVENT_STARTED.equals(event.getTopic())
+ || ResourceHelper.BUNDLE_EVENT_UPDATED.equals(event.getTopic()) ) {
+ this.scheduledJobHandler.bundleEvent();
+ }
+ }
+
+ /**
+ * Helper method which just logs the exception in debug mode.
+ * @param e
+ */
+ private void ignoreException(final Exception e) {
+ if ( this.logger.isDebugEnabled() ) {
+ this.logger.debug("Ignored exception " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Create a schedule builder for a currently scheduled job
+ */
+ public JobBuilder.ScheduleBuilder createJobBuilder(final ScheduledJobInfoImpl info) {
+ final JobBuilder.ScheduleBuilder sb = new JobScheduleBuilderImpl(info.getJobTopic(),
+ info.getJobProperties(), info.getName(), this);
+ return (info.isSuspended() ? sb.suspend() : sb);
+ }
+
+ private enum Operation {
+ LESS,
+ LESS_OR_EQUALS,
+ EQUALS,
+ GREATER_OR_EQUALS,
+ GREATER
+ }
+
+ /**
+ * Check if the job matches the template
+ */
+ private boolean match(final ScheduledJobInfoImpl job, final Map<String, Object> template) {
+ if ( template != null ) {
+ for(final Map.Entry<String, Object> current : template.entrySet()) {
+ final String key = current.getKey();
+ final char firstChar = key.length() > 0 ? key.charAt(0) : 0;
+ final String propName;
+ final Operation op;
+ if ( firstChar == '=' ) {
+ propName = key.substring(1);
+ op = Operation.EQUALS;
+ } else if ( firstChar == '<' ) {
+ final char secondChar = key.length() > 1 ? key.charAt(1) : 0;
+ if ( secondChar == '=' ) {
+ op = Operation.LESS_OR_EQUALS;
+ propName = key.substring(2);
+ } else {
+ op = Operation.LESS;
+ propName = key.substring(1);
+ }
+ } else if ( firstChar == '>' ) {
+ final char secondChar = key.length() > 1 ? key.charAt(1) : 0;
+ if ( secondChar == '=' ) {
+ op = Operation.GREATER_OR_EQUALS;
+ propName = key.substring(2);
+ } else {
+ op = Operation.GREATER;
+ propName = key.substring(1);
+ }
+ } else {
+ propName = key;
+ op = Operation.EQUALS;
+ }
+ final Object value = current.getValue();
+
+ if ( op == Operation.EQUALS ) {
+ if ( !value.equals(job.getJobProperties().get(propName)) ) {
+ return false;
+ }
+ } else {
+ if ( value instanceof Comparable ) {
+ @SuppressWarnings({ "unchecked", "rawtypes" })
+ final int result = ((Comparable)value).compareTo(job.getJobProperties().get(propName));
+ if ( op == Operation.LESS && result > -1 ) {
+ return false;
+ } else if ( op == Operation.LESS_OR_EQUALS && result > 0 ) {
+ return false;
+ } else if ( op == Operation.GREATER_OR_EQUALS && result < 0 ) {
+ return false;
+ } else if ( op == Operation.GREATER && result < 1 ) {
+ return false;
+ }
+ } else {
+ // if the value is not comparable we simply don't match
+ return false;
+ }
+ }
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Get all scheduled jobs
+ */
+ public Collection<ScheduledJobInfo> getScheduledJobs(final String topic,
+ final long limit,
+ final Map<String, Object>... templates) {
+ final List<ScheduledJobInfo> jobs = new ArrayList<ScheduledJobInfo>();
+ long count = 0;
+ synchronized ( this.scheduledJobs ) {
+ for(final ScheduledJobInfoImpl job : this.scheduledJobs.values() ) {
+ boolean add = true;
+ if ( topic != null && !topic.equals(job.getJobTopic()) ) {
+ add = false;
+ }
+ if ( add && templates != null && templates.length != 0 ) {
+ add = false;
+ for (Map<String,Object> template : templates) {
+ add = this.match(job, template);
+ if ( add ) {
+ break;
+ }
+ }
+ }
+ if ( add ) {
+ jobs.add(job);
+ count++;
+ if ( limit > 0 && count == limit ) {
+ break;
+ }
+ }
+ }
+ }
+ return jobs;
+ }
+
+ /**
+ * Change the suspended flag for a scheduled job
+ * @param info The schedule info
+ * @param flag The corresponding flag
+ */
+ public void setSuspended(final ScheduledJobInfoImpl info, final boolean flag) {
+ final ResourceResolver resolver = configuration.createResourceResolver();
+ try {
+ final StringBuilder sb = new StringBuilder(this.configuration.getScheduledJobsPath(true));
+ sb.append(ResourceHelper.filterName(info.getName()));
+ final String path = sb.toString();
+
+ final Resource eventResource = resolver.getResource(path);
+ if ( eventResource != null ) {
+ final ModifiableValueMap mvm = eventResource.adaptTo(ModifiableValueMap.class);
+ if ( flag ) {
+ mvm.put(ResourceHelper.PROPERTY_SCHEDULE_SUSPENDED, Boolean.TRUE);
+ } else {
+ mvm.remove(ResourceHelper.PROPERTY_SCHEDULE_SUSPENDED);
+ }
+ resolver.commit();
+ }
+ if ( flag ) {
+ this.stopScheduledJob(info);
+ } else {
+ this.startScheduledJob(info);
+ }
+ } catch (final PersistenceException pe) {
+ // we ignore the exception if removing fails
+ ignoreException(pe);
+ } finally {
+ resolver.close();
+ }
+ }
+
+ /**
+ * Add a scheduled job
+ * @param topic The job topic
+ * @param properties The job properties
+ * @param scheduleName The schedule name
+ * @param isSuspended Whether it is suspended
+ * @param scheduleInfos The scheduling information
+ * @param errors Optional list to contain potential errors
+ * @return A new job info or {@code null}
+ */
+ public ScheduledJobInfo addScheduledJob(final String topic,
+ final Map<String, Object> properties,
+ final String scheduleName,
+ final boolean isSuspended,
+ final List<ScheduleInfoImpl> scheduleInfos,
+ final List<String> errors) {
+ final List<String> msgs = new ArrayList<String>();
+ if ( scheduleName == null || scheduleName.length() == 0 ) {
+ msgs.add("Schedule name not specified");
+ }
+ final String errorMessage = Utility.checkJob(topic, properties);
+ if ( errorMessage != null ) {
+ msgs.add(errorMessage);
+ }
+ if ( scheduleInfos.size() == 0 ) {
+ msgs.add("No schedule defined for " + scheduleName);
+ }
+ for(final ScheduleInfoImpl info : scheduleInfos) {
+ info.check(msgs);
+ }
+ if ( msgs.size() == 0 ) {
+ try {
+ final ScheduledJobInfo info = this.scheduledJobHandler.addOrUpdateJob(topic, properties, scheduleName, isSuspended, scheduleInfos);
+ if ( info != null ) {
+ return info;
+ }
+ msgs.add("Unable to persist scheduled job.");
+ } catch ( final PersistenceException pe) {
+ msgs.add("Unable to persist scheduled job: " + scheduleName);
+ logger.warn("Unable to persist scheduled job", pe);
+ }
+ } else {
+ for(final String msg : msgs) {
+ logger.warn(msg);
+ }
+ }
+ if ( errors != null ) {
+ errors.addAll(msgs);
+ }
+ return null;
+ }
+
+ public void maintenance() {
+ this.scheduledJobHandler.maintenance();
+ }
+
+ /**
+ * @see org.apache.sling.api.resource.observation.ResourceChangeListener#onChange(java.util.List)
+ */
+ @Override
+ public void onChange(List<ResourceChange> changes) {
+ for(final ResourceChange change : changes ) {
+ if ( change.getPath() != null && change.getPath().startsWith(this.configuration.getScheduledJobsPath(true)) ) {
+ if ( change.getType() == ResourceChange.ChangeType.REMOVED ) {
+ // removal
+ logger.debug("Remove scheduled job {}", change.getPath());
+ this.scheduledJobHandler.handleRemove(change.getPath());
+ } else {
+ // add or update
+ logger.debug("Add or update scheduled job {}, event {}", change.getPath(), change.getType());
+ this.scheduledJobHandler.handleAddUpdate(change.getPath());
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/scheduling/ScheduledJobHandler.java b/src/main/java/org/apache/sling/event/impl/jobs/scheduling/ScheduledJobHandler.java
new file mode 100644
index 0000000..deb3955
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/scheduling/ScheduledJobHandler.java
@@ -0,0 +1,545 @@
+/*
+ * 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.event.impl.jobs.scheduling;
+
+import java.util.Calendar;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.apache.sling.api.resource.ModifiableValueMap;
+import org.apache.sling.api.resource.PersistenceException;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.ResourceUtil;
+import org.apache.sling.api.resource.ValueMap;
+import org.apache.sling.event.impl.jobs.config.JobManagerConfiguration;
+import org.apache.sling.event.impl.support.Environment;
+import org.apache.sling.event.impl.support.ResourceHelper;
+import org.apache.sling.event.impl.support.ScheduleInfoImpl;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.ScheduleInfo;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ *
+ */
+public class ScheduledJobHandler implements Runnable {
+
+ public static final class Holder {
+ public Calendar created;
+ public ScheduledJobInfoImpl info;
+ public long read;
+ }
+
+ /** Logger. */
+ private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+ /** The job manager configuration. */
+ private final JobManagerConfiguration configuration;
+
+ /** The job scheduler. */
+ private final JobSchedulerImpl jobScheduler;
+
+ /** The map of all scheduled jobs, key is the filtered schedule name */
+ private final Map<String, Holder> scheduledJobs = new HashMap<String, Holder>();
+
+ private final AtomicLong lastBundleActivity = new AtomicLong();
+
+ private final AtomicBoolean isRunning = new AtomicBoolean(true);
+
+ /** A local queue for serializing the event processing. */
+ private final BlockingQueue<Runnable> queue = new LinkedBlockingQueue<Runnable>();
+
+ /**
+ * @param configuration Current job manager configuration
+ */
+ public ScheduledJobHandler(final JobManagerConfiguration configuration,
+ final JobSchedulerImpl jobScheduler) {
+ this.configuration = configuration;
+ this.jobScheduler = jobScheduler;
+ final Thread t = new Thread(this, "Apache Sling Scheduled Job Handler Thread");
+ t.setDaemon(true);
+ t.start();
+
+ this.addFullScan();
+ }
+
+ /**
+ * Add a task/runnable to the queue
+ */
+ private void addTask(final Runnable r) {
+ try {
+ this.queue.put(r);
+ } catch (final InterruptedException e) {
+ this.ignoreException(e);
+ Thread.currentThread().interrupt();
+ }
+ }
+ /**
+ * Add a full scan to the task queue
+ */
+ private void addFullScan() {
+ this.addTask(new Runnable() {
+ @Override
+ public void run() {
+ scan();
+ }
+ });
+ }
+
+ public void deactivate() {
+ this.isRunning.set(false);
+ this.queue.clear();
+ // put a NOP runnable to wake up the queue
+ this.addTask(new Runnable() {
+ @Override
+ public void run() {
+ // do nothing
+ }
+ });
+ }
+
+ @Override
+ public void run() {
+ while ( this.isRunning.get() ) {
+ Runnable r = null;
+ try {
+ r = this.queue.take();
+ } catch (final InterruptedException e) {
+ this.ignoreException(e);
+ Thread.currentThread().interrupt();
+ this.isRunning.set(false);
+ }
+ if ( this.isRunning.get() && r != null) {
+ r.run();
+ }
+ }
+ }
+
+ private void scan() {
+ final ResourceResolver resolver = configuration.createResourceResolver();
+ if ( resolver != null ) {
+ try {
+ logger.debug("Scanning for scheduled jobs...");
+ final String path = this.configuration.getScheduledJobsPath(false);
+ final Resource startResource = resolver.getResource(path);
+ if ( startResource != null ) {
+ final Map<String, Holder> newScheduledJobs = new HashMap<String, Holder>();
+ synchronized ( this.scheduledJobs ) {
+ for(final Resource rsrc : startResource.getChildren()) {
+ if ( !isRunning.get() ) {
+ break;
+ }
+ handleAddOrUpdate(newScheduledJobs, rsrc);
+ }
+ if ( isRunning.get() ) {
+ for(final Holder h : this.scheduledJobs.values()) {
+ if ( h.info != null ) {
+ this.jobScheduler.unscheduleJob(h.info);
+ }
+ }
+ this.scheduledJobs.clear();
+ this.scheduledJobs.putAll(newScheduledJobs);
+ }
+ }
+ }
+ logger.debug("Finished scanning for scheduled jobs...");
+ } finally {
+ resolver.close();
+ }
+ }
+ }
+
+ /**
+ * Read a scheduled job from the resource
+ * @return The job or <code>null</code>
+ */
+ private Map<String, Object> readScheduledJob(final Resource eventResource) {
+ try {
+ final ValueMap vm = ResourceHelper.getValueMap(eventResource);
+ final Map<String, Object> properties = ResourceHelper.cloneValueMap(vm);
+
+ @SuppressWarnings("unchecked")
+ final List<Exception> readErrorList = (List<Exception>) properties.remove(ResourceHelper.PROPERTY_MARKER_READ_ERROR_LIST);
+ if ( readErrorList != null ) {
+ for(final Exception e : readErrorList) {
+ logger.warn("Unable to read scheduled job from " + eventResource.getPath(), e);
+ }
+ } else {
+ return properties;
+ }
+ } catch (final InstantiationException ie) {
+ // something happened with the resource in the meantime
+ this.ignoreException(ie);
+ }
+ return null;
+ }
+
+ /**
+ * Write a scheduled job to the resource tree.
+ * @throws PersistenceException
+ */
+ public ScheduledJobInfoImpl addOrUpdateJob(
+ final String jobTopic,
+ final Map<String, Object> jobProperties,
+ final String scheduleName,
+ final boolean suspend,
+ final List<ScheduleInfoImpl> scheduleInfos)
+ throws PersistenceException {
+ final Map<String, Object> properties = this.writeScheduledJob(jobTopic, jobProperties, scheduleName, suspend, scheduleInfos);
+
+ final String key = ResourceHelper.filterName(scheduleName);
+ synchronized ( this.scheduledJobs ) {
+ final Holder h = this.scheduledJobs.remove(key);
+ if ( h != null && h.info != null ) {
+ this.jobScheduler.unscheduleJob(h.info);
+ }
+ final Holder holder = new Holder();
+ holder.created = (Calendar) properties.get(Job.PROPERTY_JOB_CREATED);
+ holder.read = System.currentTimeMillis();
+ holder.info = this.addOrUpdateScheduledJob(properties, h == null ? null : h.info);
+
+ this.jobScheduler.scheduleJob(holder.info);
+ return holder.info;
+ }
+ }
+
+ private Map<String, Object> writeScheduledJob(final String jobTopic,
+ final Map<String, Object> jobProperties,
+ final String scheduleName,
+ final boolean suspend,
+ final List<ScheduleInfoImpl> scheduleInfos)
+ throws PersistenceException {
+ final ResourceResolver resolver = this.configuration.createResourceResolver();
+ try {
+ // create properties
+ final Map<String, Object> properties = new HashMap<String, Object>();
+
+ if ( jobProperties != null ) {
+ for(final Map.Entry<String, Object> entry : jobProperties.entrySet() ) {
+ final String propName = entry.getKey();
+ if ( !ResourceHelper.ignoreProperty(propName) ) {
+ properties.put(propName, entry.getValue());
+ }
+ }
+ }
+
+ properties.put(ResourceHelper.PROPERTY_JOB_TOPIC, jobTopic);
+ properties.put(Job.PROPERTY_JOB_CREATED, Calendar.getInstance());
+ properties.put(Job.PROPERTY_JOB_CREATED_INSTANCE, Environment.APPLICATION_ID);
+
+ // put scheduler name and scheduler info
+ properties.put(ResourceHelper.PROPERTY_SCHEDULE_NAME, scheduleName);
+ final String[] infoArray = new String[scheduleInfos.size()];
+ int index = 0;
+ for(final ScheduleInfoImpl info : scheduleInfos) {
+ infoArray[index] = info.getSerializedString();
+ index++;
+ }
+ properties.put(ResourceHelper.PROPERTY_SCHEDULE_INFO, infoArray);
+ if ( suspend ) {
+ properties.put(ResourceHelper.PROPERTY_SCHEDULE_SUSPENDED, Boolean.TRUE);
+ }
+
+ // create path and resource
+ properties.put(ResourceResolver.PROPERTY_RESOURCE_TYPE, ResourceHelper.RESOURCE_TYPE_SCHEDULED_JOB);
+
+ final String path = this.configuration.getScheduledJobsPath(true) + ResourceHelper.filterName(scheduleName);
+
+ // update existing resource
+ final Resource existingInfo = resolver.getResource(path);
+ if ( existingInfo != null ) {
+ resolver.delete(existingInfo);
+ logger.debug("Updating scheduled job {} at {}", properties, path);
+ } else {
+ logger.debug("Storing new scheduled job {} at {}", properties, path);
+ }
+ ResourceHelper.getOrCreateResource(resolver,
+ path,
+ properties);
+ // put back real schedule infos
+ properties.put(ResourceHelper.PROPERTY_SCHEDULE_INFO, scheduleInfos);
+
+ return properties;
+ } finally {
+ resolver.close();
+ }
+ }
+
+ private ScheduledJobInfoImpl addOrUpdateScheduledJob(
+ final Map<String, Object> properties,
+ final ScheduledJobInfoImpl oldInfo) {
+ properties.remove(ResourceResolver.PROPERTY_RESOURCE_TYPE);
+ properties.remove(Job.PROPERTY_JOB_CREATED);
+ properties.remove(Job.PROPERTY_JOB_CREATED_INSTANCE);
+
+ final String jobTopic = (String) properties.remove(ResourceHelper.PROPERTY_JOB_TOPIC);
+ final String schedulerName = (String) properties.remove(ResourceHelper.PROPERTY_SCHEDULE_NAME);
+
+ final ScheduledJobInfoImpl info;
+ if ( oldInfo == null ) {
+ info = new ScheduledJobInfoImpl(jobScheduler, schedulerName);
+ } else {
+ info = oldInfo;
+ }
+ info.update(jobTopic, properties);
+
+ return info;
+ }
+
+ /**
+ * A bundle event occurred which means we can try loading jobs that previously
+ * failed because of missing classes.
+ */
+ public void bundleEvent() {
+ this.lastBundleActivity.set(System.currentTimeMillis());
+ this.addTask(new Runnable() {
+ @Override
+ public void run() {
+ final Map<String, Holder> updateJobs = new HashMap<String, ScheduledJobHandler.Holder>();
+ synchronized ( scheduledJobs ) {
+ for(final Map.Entry<String, Holder> entry : scheduledJobs.entrySet()) {
+ if ( entry.getValue().info == null && entry.getValue().read < lastBundleActivity.get() ) {
+ updateJobs.put(entry.getKey(), entry.getValue());
+ }
+ }
+ }
+ if ( !updateJobs.isEmpty() && isRunning.get() ) {
+ ResourceResolver resolver = configuration.createResourceResolver();
+ if ( resolver != null ) {
+ try {
+ for(final Map.Entry<String, Holder> entry : updateJobs.entrySet()) {
+ final String path = configuration.getScheduledJobsPath(true) + entry.getKey();
+ final Resource rsrc = resolver.getResource(path);
+ if ( !isRunning.get() ) {
+ break;
+ }
+ if ( rsrc != null ) {
+ synchronized ( scheduledJobs ) {
+ handleAddOrUpdate(scheduledJobs, rsrc);
+ }
+ }
+ }
+ } finally {
+ resolver.close();
+ }
+ }
+ }
+ }
+ });
+ }
+
+ /**
+ * Handle observation event for removing a scheduled job
+ * @param path The path to the job
+ */
+ public void handleRemove(final String path) {
+ this.addTask(new Runnable() {
+ @Override
+ public void run() {
+ if ( isRunning.get() ) {
+ final String scheduleKey = ResourceHelper.filterName(ResourceUtil.getName(path));
+ if ( scheduleKey != null ) {
+ synchronized ( scheduledJobs ) {
+ final Holder h = scheduledJobs.remove(scheduleKey);
+ if ( h != null && h.info != null ) {
+ jobScheduler.unscheduleJob(h.info);
+ }
+ }
+ }
+ }
+ }
+ });
+ }
+
+ /**
+ * Handle observation event for adding or updating a scheduled job
+ * @param path The path to the job
+ */
+ public void handleAddUpdate(final String path) {
+ this.addTask(new Runnable() {
+ @Override
+ public void run() {
+ if ( isRunning.get() ) {
+ final ResourceResolver resolver = configuration.createResourceResolver();
+ if ( resolver != null ) {
+ try {
+ final Resource rsrc = resolver.getResource(path);
+ if ( rsrc != null ) {
+ synchronized ( scheduledJobs ) {
+ handleAddOrUpdate(scheduledJobs, rsrc);
+ }
+ }
+ } finally {
+ resolver.close();
+ }
+ }
+ }
+ }
+ });
+ }
+
+ /**
+ * Handle add or update of a resource
+ * @param newScheduledJobs The map to store the jobs
+ * @param rsrc The resource containing the job
+ */
+ private void handleAddOrUpdate(final Map<String, Holder> newScheduledJobs, final Resource rsrc) {
+ final String id = ResourceHelper.filterName(rsrc.getName());
+ final Holder scheduled = this.scheduledJobs.remove(id);
+ boolean read = false;
+ if ( scheduled != null ) {
+ // check if loading failed and we can retry
+ if ( scheduled.info == null || scheduled.read < this.lastBundleActivity.get() ) {
+ read = true;
+ }
+ // check if this is an update
+ if ( scheduled.info != null ) {
+ final ValueMap vm = ResourceUtil.getValueMap(rsrc);
+ final Calendar changed = (Calendar) vm.get(Job.PROPERTY_JOB_CREATED);
+ if ( changed != null && scheduled.created.compareTo(changed) < 0 ) {
+ read = true;
+ }
+ }
+ if ( !read ) {
+ // nothing changes
+ newScheduledJobs.put(id, scheduled);
+ }
+ } else {
+ read = true;
+ }
+ if ( read ) {
+ // read
+ final Holder holder = new Holder();
+ holder.read = System.currentTimeMillis();
+
+ final Map<String, Object> properties = this.readScheduledJob(rsrc);
+ if ( properties != null ) {
+ holder.created = (Calendar) properties.get(Job.PROPERTY_JOB_CREATED);
+ holder.info = this.addOrUpdateScheduledJob(properties, scheduled != null ? scheduled.info : null);
+ }
+ newScheduledJobs.put(id, holder);
+
+ if ( holder.info == null && scheduled != null && scheduled.info != null ) {
+ this.jobScheduler.unscheduleJob(scheduled.info);
+ }
+ if ( holder.info != null ) {
+ this.jobScheduler.scheduleJob(holder.info);
+ }
+ }
+ }
+
+ /**
+ * Remove a scheduled job
+ * @param info The schedule info
+ */
+ public void remove(final ScheduledJobInfoImpl info) {
+ final String scheduleKey = ResourceHelper.filterName(info.getName());
+
+ final ResourceResolver resolver = configuration.createResourceResolver();
+ try {
+ final StringBuilder sb = new StringBuilder(configuration.getScheduledJobsPath(true));
+ sb.append(scheduleKey);
+ final String path = sb.toString();
+
+ final Resource eventResource = resolver.getResource(path);
+ if ( eventResource != null ) {
+ resolver.delete(eventResource);
+ resolver.commit();
+ }
+ } catch (final PersistenceException pe) {
+ // we ignore the exception if removing fails
+ ignoreException(pe);
+ } finally {
+ resolver.close();
+ }
+
+ synchronized ( this.scheduledJobs ) {
+ final Holder h = scheduledJobs.remove(scheduleKey);
+ if ( h != null && h.info != null ) {
+ jobScheduler.unscheduleJob(h.info);
+ }
+ }
+ }
+
+ public void updateSchedule(final String scheduleName, final Collection<ScheduleInfo> scheduleInfo) {
+
+ final ResourceResolver resolver = configuration.createResourceResolver();
+ try {
+ final String scheduleKey = ResourceHelper.filterName(scheduleName);
+
+ final StringBuilder sb = new StringBuilder(configuration.getScheduledJobsPath(true));
+ sb.append(scheduleKey);
+ final String path = sb.toString();
+
+ final Resource rsrc = resolver.getResource(path);
+ // This is an update, if we can't find the resource we ignore it
+ if ( rsrc != null ) {
+ final Calendar now = Calendar.getInstance();
+
+ // update holder first
+ synchronized ( scheduledJobs ) {
+ final Holder h = scheduledJobs.get(scheduleKey);
+ if ( h != null ) {
+ h.created = now;
+ }
+ }
+
+ final ModifiableValueMap mvm = rsrc.adaptTo(ModifiableValueMap.class);
+ mvm.put(Job.PROPERTY_JOB_CREATED, now);
+ final String[] infoArray = new String[scheduleInfo.size()];
+ int index = 0;
+ for(final ScheduleInfo si : scheduleInfo) {
+ infoArray[index] = ((ScheduleInfoImpl)si).getSerializedString();
+ index++;
+ }
+ mvm.put(ResourceHelper.PROPERTY_SCHEDULE_INFO, infoArray);
+
+ try {
+ resolver.commit();
+ } catch ( final PersistenceException pe) {
+ logger.warn("Unable to update scheduled job " + scheduleName, pe);
+ }
+ }
+ } finally {
+ resolver.close();
+ }
+ }
+
+ /**
+ * Helper method which just logs the exception in debug mode.
+ * @param e The exception
+ */
+ private void ignoreException(final Exception e) {
+ if ( this.logger.isDebugEnabled() ) {
+ this.logger.debug("Ignored exception " + e.getMessage(), e);
+ }
+ }
+
+ public void maintenance() {
+ this.addFullScan();
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/scheduling/ScheduledJobInfoImpl.java b/src/main/java/org/apache/sling/event/impl/jobs/scheduling/ScheduledJobInfoImpl.java
new file mode 100644
index 0000000..99fcdb4
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/scheduling/ScheduledJobInfoImpl.java
@@ -0,0 +1,193 @@
+/*
+ * 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.event.impl.jobs.scheduling;
+
+import java.io.Serializable;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.sling.event.impl.support.ResourceHelper;
+import org.apache.sling.event.impl.support.ScheduleInfoImpl;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.JobBuilder.ScheduleBuilder;
+import org.apache.sling.event.jobs.ScheduleInfo;
+import org.apache.sling.event.jobs.ScheduledJobInfo;
+
+/**
+ * The job schedule information.
+ * It holds all required information like
+ * - the name of the schedule
+ * - the job topic
+ * - the job properties
+ * - scheduling information
+ */
+public class ScheduledJobInfoImpl implements ScheduledJobInfo, Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private final String scheduleName;
+
+ private final JobSchedulerImpl jobScheduler;
+
+ private final AtomicBoolean isSuspended = new AtomicBoolean(false);
+
+ private volatile List<ScheduleInfo> scheduleInfos;
+
+ private volatile String jobTopic;
+
+ private volatile Map<String, Object> jobProperties;
+
+ /**
+ * Create a new info object
+ * @param jobScheduler The job scheduler
+ * @param scheduleName The unique name
+ */
+ public ScheduledJobInfoImpl(final JobSchedulerImpl jobScheduler,
+ final String scheduleName) {
+ this.jobScheduler = jobScheduler;
+ this.scheduleName = scheduleName;
+ }
+
+ /**
+ * Update/set the job related information
+ * @param jobTopic The job topic
+ * @param jobProperties The job properties
+ */
+ public void update(final String jobTopic,
+ final Map<String, Object> jobProperties) {
+ final boolean isSuspended = jobProperties.remove(ResourceHelper.PROPERTY_SCHEDULE_SUSPENDED) != null;
+ @SuppressWarnings("unchecked")
+ final List<ScheduleInfo> scheduleInfos = (List<ScheduleInfo>) jobProperties.remove(ResourceHelper.PROPERTY_SCHEDULE_INFO);
+
+ this.jobTopic = jobTopic;
+ this.jobProperties = jobProperties;
+ this.scheduleInfos = Collections.unmodifiableList(scheduleInfos);
+
+ this.isSuspended.set(isSuspended);
+ }
+
+ /**
+ * Update the scheduling information
+ * @param scheduleInfos The new schedule
+ */
+ public void update(final List<ScheduleInfo> scheduleInfos) {
+ this.scheduleInfos = Collections.unmodifiableList(scheduleInfos);
+ }
+
+ /**
+ * Get the schedule name
+ */
+ public String getName() {
+ return this.scheduleName;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.ScheduledJobInfo#getSchedules()
+ */
+ @Override
+ public Collection<ScheduleInfo> getSchedules() {
+ return this.scheduleInfos;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.ScheduledJobInfo#getNextScheduledExecution()
+ */
+ @Override
+ public Date getNextScheduledExecution() {
+ Date result = null;
+ for(final ScheduleInfo info : this.scheduleInfos) {
+ final Date newResult = ((ScheduleInfoImpl)info).getNextScheduledExecution();
+ if ( result == null || result.getTime() > newResult.getTime() ) {
+ result = newResult;
+ }
+ }
+ return result;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.ScheduledJobInfo#getJobTopic()
+ */
+ @Override
+ public String getJobTopic() {
+ return this.jobTopic;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.ScheduledJobInfo#getJobProperties()
+ */
+ @Override
+ public Map<String, Object> getJobProperties() {
+ return this.jobProperties;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.ScheduledJobInfo#unschedule()
+ */
+ @Override
+ public void unschedule() {
+ this.jobScheduler.removeJob(this);
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.ScheduledJobInfo#reschedule()
+ */
+ @Override
+ public ScheduleBuilder reschedule() {
+ return this.jobScheduler.createJobBuilder(this);
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.ScheduledJobInfo#suspend()
+ */
+ @Override
+ public void suspend() {
+ if ( this.isSuspended.compareAndSet(false, true) ) {
+ this.jobScheduler.setSuspended(this, true);
+ }
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.ScheduledJobInfo#resume()
+ */
+ @Override
+ public void resume() {
+ if ( this.isSuspended.compareAndSet(true, false) ) {
+ this.jobScheduler.setSuspended(this, false);
+ }
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.ScheduledJobInfo#isSuspended()
+ */
+ @Override
+ public boolean isSuspended() {
+ return this.isSuspended.get();
+ }
+
+ /**
+ * Get the scheduler job id
+ */
+ public String getSchedulerJobId() {
+ return Job.class.getName() + ":" + this.scheduleName;
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/stats/StatisticsImpl.java b/src/main/java/org/apache/sling/event/impl/jobs/stats/StatisticsImpl.java
new file mode 100644
index 0000000..00a278f
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/stats/StatisticsImpl.java
@@ -0,0 +1,319 @@
+/*
+ * 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.event.impl.jobs.stats;
+
+import org.apache.sling.event.jobs.Statistics;
+
+/**
+ * Implementation of the statistics.
+ */
+public class StatisticsImpl implements Statistics {
+
+ private volatile long startTime;
+
+ private volatile long activeJobs;
+
+ private volatile long queuedJobs;
+
+ private volatile long lastActivated = -1;
+
+ private volatile long lastFinished = -1;
+
+ private volatile long averageWaitingTime;
+
+ private volatile long averageProcessingTime;
+
+ private volatile long waitingTime;
+
+ private volatile long processingTime;
+
+ private volatile long waitingCount;
+
+ private volatile long processingCount;
+
+ private volatile long finishedJobs;
+
+ private volatile long failedJobs;
+
+ private volatile long cancelledJobs;
+
+ public StatisticsImpl() {
+ this(System.currentTimeMillis());
+ }
+
+ public StatisticsImpl(final long startTime) {
+ this.startTime = startTime;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Statistics#getStartTime()
+ */
+ @Override
+ public synchronized long getStartTime() {
+ return startTime;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Statistics#getNumberOfProcessedJobs()
+ */
+ @Override
+ public synchronized long getNumberOfProcessedJobs() {
+ return getNumberOfCancelledJobs() + getNumberOfFailedJobs() + getNumberOfFinishedJobs();
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Statistics#getNumberOfActiveJobs()
+ */
+ @Override
+ public synchronized long getNumberOfActiveJobs() {
+ return activeJobs;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Statistics#getNumberOfQueuedJobs()
+ */
+ @Override
+ public synchronized long getNumberOfQueuedJobs() {
+ return queuedJobs;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Statistics#getNumberOfJobs()
+ */
+ @Override
+ public synchronized long getNumberOfJobs() {
+ return activeJobs + queuedJobs;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Statistics#getAverageWaitingTime()
+ */
+ @Override
+ public synchronized long getAverageWaitingTime() {
+ return averageWaitingTime;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Statistics#getAverageProcessingTime()
+ */
+ @Override
+ public synchronized long getAverageProcessingTime() {
+ return averageProcessingTime;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Statistics#getNumberOfFinishedJobs()
+ */
+ @Override
+ public synchronized long getNumberOfFinishedJobs() {
+ return finishedJobs;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Statistics#getNumberOfCancelledJobs()
+ */
+ @Override
+ public synchronized long getNumberOfCancelledJobs() {
+ return cancelledJobs;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Statistics#getNumberOfFailedJobs()
+ */
+ @Override
+ public synchronized long getNumberOfFailedJobs() {
+ return failedJobs;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Statistics#getLastActivatedJobTime()
+ */
+ @Override
+ public synchronized long getLastActivatedJobTime() {
+ return this.lastActivated;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Statistics#getLastFinishedJobTime()
+ */
+ @Override
+ public synchronized long getLastFinishedJobTime() {
+ return this.lastFinished;
+ }
+
+ /**
+ * Add a finished job
+ * @param jobTime The processing time for this job.
+ */
+ public synchronized void finishedJob(final long jobTime) {
+ this.lastFinished = System.currentTimeMillis();
+ this.processingTime += jobTime;
+ this.processingCount++;
+ this.averageProcessingTime = this.processingTime / this.processingCount;
+ this.finishedJobs++;
+ this.activeJobs--;
+ }
+
+ /**
+ * Add a failed job.
+ */
+ public synchronized void failedJob() {
+ this.failedJobs++;
+ this.activeJobs--;
+ }
+
+ /**
+ * Add a cancelled job.
+ */
+ public synchronized void cancelledJob() {
+ this.cancelledJobs++;
+ this.activeJobs--;
+ }
+
+ /**
+ * New job in the queue
+ */
+ public synchronized void incQueued() {
+ this.queuedJobs++;
+ }
+
+ /**
+ * Job not processed by us
+ */
+ public synchronized void decQueued() {
+ this.queuedJobs--;
+ }
+
+ /**
+ * Clear all queued
+ */
+ public synchronized void clearQueued() {
+ this.queuedJobs = 0;
+ }
+
+ /**
+ * Add a job from the queue to status active
+ * @param queueTime The time the job stayed in the queue.
+ */
+ public synchronized void addActive(final long queueTime) {
+ this.queuedJobs--;
+ this.activeJobs++;
+ this.waitingCount++;
+ this.waitingTime += queueTime;
+ this.averageWaitingTime = this.waitingTime / this.waitingCount;
+ this.lastActivated = System.currentTimeMillis();
+ }
+
+ /**
+ * Add another statistics information.
+ */
+ public synchronized void add(final StatisticsImpl other) {
+ synchronized ( other ) {
+ if ( other.lastActivated > this.lastActivated ) {
+ this.lastActivated = other.lastActivated;
+ }
+ if ( other.lastFinished > this.lastFinished ) {
+ this.lastFinished = other.lastFinished;
+ }
+ this.queuedJobs += other.queuedJobs;
+ this.waitingTime += other.waitingTime;
+ this.waitingCount += other.waitingCount;
+ if ( this.waitingCount > 0 ) {
+ this.averageWaitingTime = this.waitingTime / this.waitingCount;
+ }
+ this.processingTime += other.processingTime;
+ this.processingCount += other.processingCount;
+ if ( this.processingCount > 0 ) {
+ this.averageProcessingTime = this.processingTime / this.processingCount;
+ }
+ this.finishedJobs += other.finishedJobs;
+ this.failedJobs += other.failedJobs;
+ this.cancelledJobs += other.cancelledJobs;
+ this.activeJobs += other.activeJobs;
+ }
+ }
+
+ /**
+ * Create a new statistics object with exactly the same values.
+ */
+ public void copyFrom(final StatisticsImpl other) {
+ final long localQueuedJobs;
+ final long localLastActivated;
+ final long localLastFinished;
+ final long localAverageWaitingTime;
+ final long localAverageProcessingTime;
+ final long localWaitingTime;
+ final long localProcessingTime;
+ final long localWaitingCount;
+ final long localProcessingCount;
+ final long localFinishedJobs;
+ final long localFailedJobs;
+ final long localCancelledJobs;
+ final long localActiveJobs;
+ synchronized ( other ) {
+ localQueuedJobs = other.queuedJobs;
+ localLastActivated = other.lastActivated;
+ localLastFinished = other.lastFinished;
+ localAverageWaitingTime = other.averageWaitingTime;
+ localAverageProcessingTime = other.averageProcessingTime;
+ localWaitingTime = other.waitingTime;
+ localProcessingTime = other.processingTime;
+ localWaitingCount = other.waitingCount;
+ localProcessingCount = other.processingCount;
+ localFinishedJobs = other.finishedJobs;
+ localFailedJobs = other.failedJobs;
+ localCancelledJobs = other.cancelledJobs;
+ localActiveJobs = other.activeJobs;
+ }
+ synchronized ( this ) {
+ this.queuedJobs = localQueuedJobs;
+ this.lastActivated = localLastActivated;
+ this.lastFinished = localLastFinished;
+ this.averageWaitingTime = localAverageWaitingTime;
+ this.averageProcessingTime = localAverageProcessingTime;
+ this.waitingTime = localWaitingTime;
+ this.processingTime = localProcessingTime;
+ this.waitingCount = localWaitingCount;
+ this.processingCount = localProcessingCount;
+ this.finishedJobs = localFinishedJobs;
+ this.failedJobs = localFailedJobs;
+ this.cancelledJobs = localCancelledJobs;
+ this.activeJobs = localActiveJobs;
+ }
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.Statistics#reset()
+ */
+ @Override
+ public synchronized void reset() {
+ this.startTime = System.currentTimeMillis();
+ this.lastActivated = -1;
+ this.lastFinished = -1;
+ this.averageWaitingTime = 0;
+ this.averageProcessingTime = 0;
+ this.waitingTime = 0;
+ this.processingTime = 0;
+ this.waitingCount = 0;
+ this.processingCount = 0;
+ this.finishedJobs = 0;
+ this.failedJobs = 0;
+ this.cancelledJobs = 0;
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/stats/StatisticsManager.java b/src/main/java/org/apache/sling/event/impl/jobs/stats/StatisticsManager.java
new file mode 100644
index 0000000..c2d3d15
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/stats/StatisticsManager.java
@@ -0,0 +1,182 @@
+/*
+ * 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.event.impl.jobs.stats;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.Reference;
+import org.apache.felix.scr.annotations.Service;
+import org.apache.sling.event.impl.jobs.InternalJobState;
+import org.apache.sling.event.impl.jobs.config.JobManagerConfiguration;
+import org.apache.sling.event.jobs.Statistics;
+import org.apache.sling.event.jobs.TopicStatistics;
+
+/**
+ * The statistics manager keeps track of all statistics related tasks.
+ */
+@Component
+@Service(value=StatisticsManager.class)
+public class StatisticsManager {
+
+ /** The job manager configuration. */
+ @Reference
+ private JobManagerConfiguration configuration;
+
+ /** Global statistics. */
+ private final StatisticsImpl globalStatistics = new StatisticsImpl() {
+
+ @Override
+ public synchronized void reset() {
+ super.reset();
+ topicStatistics.clear();
+ for(final Statistics s : queueStatistics.values()) {
+ s.reset();
+ }
+ }
+
+ };
+
+ /** Statistics per topic. */
+ private final ConcurrentMap<String, TopicStatistics> topicStatistics = new ConcurrentHashMap<String, TopicStatistics>();
+
+ /** Statistics per queue. */
+ private final ConcurrentMap<String, Statistics> queueStatistics = new ConcurrentHashMap<String, Statistics>();
+
+ /**
+ * Get the global statistics.
+ * @return The global statistics.
+ */
+ public Statistics getGlobalStatistics() {
+ return this.globalStatistics;
+ }
+
+ /**
+ * Get all topic statistics.
+ * @return The map of topic statistics by topic.
+ */
+ public Map<String, TopicStatistics> getTopicStatistics() {
+ return topicStatistics;
+ }
+
+ /**
+ * Get a single queue statistics.
+ * @param queueName The queue name.
+ * @return The statistics for that queue.
+ */
+ public Statistics getQueueStatistics(final String queueName) {
+ Statistics queueStats = queueStatistics.get(queueName);
+ if ( queueStats == null ) {
+ queueStats = new StatisticsImpl();
+ }
+ return queueStats;
+ }
+
+ /**
+ * Internal method to get the statistics of a queue.
+ * @param queueName The queue name.
+ * @return The statistics or {@code null} if queue name is {@code null}.
+ */
+ private StatisticsImpl getStatisticsForQueue(final String queueName) {
+ if ( queueName == null ) {
+ return null;
+ }
+ StatisticsImpl queueStats = (StatisticsImpl)queueStatistics.get(queueName);
+ if ( queueStats == null ) {
+ queueStatistics.putIfAbsent(queueName, new StatisticsImpl());
+ queueStats = (StatisticsImpl)queueStatistics.get(queueName);
+ }
+ return queueStats;
+ }
+
+ public void jobEnded(final String queueName,
+ final String topic,
+ final InternalJobState state,
+ final long processingTime) {
+ final StatisticsImpl queueStats = getStatisticsForQueue(queueName);
+
+ TopicStatisticsImpl ts = (TopicStatisticsImpl)this.topicStatistics.get(topic);
+ if ( ts == null ) {
+ this.topicStatistics.putIfAbsent(topic, new TopicStatisticsImpl(topic));
+ ts = (TopicStatisticsImpl)this.topicStatistics.get(topic);
+ }
+
+ if ( state == InternalJobState.CANCELLED ) {
+ ts.addCancelled();
+ this.globalStatistics.cancelledJob();
+ if ( queueStats != null ) {
+ queueStats.cancelledJob();
+ }
+
+ } else if ( state == InternalJobState.FAILED ) {
+ ts.addFailed();
+ this.globalStatistics.failedJob();
+ if ( queueStats != null ) {
+ queueStats.failedJob();
+ }
+
+ } else if ( state == InternalJobState.SUCCEEDED ) {
+ ts.addFinished(processingTime);
+ this.globalStatistics.finishedJob(processingTime);
+ if ( queueStats != null ) {
+ queueStats.finishedJob(processingTime);
+ }
+
+ }
+ }
+
+ public void jobStarted(final String queueName,
+ final String topic,
+ final long queueTime) {
+ final StatisticsImpl queueStats = getStatisticsForQueue(queueName);
+
+ TopicStatisticsImpl ts = (TopicStatisticsImpl)this.topicStatistics.get(topic);
+ if ( ts == null ) {
+ this.topicStatistics.putIfAbsent(topic, new TopicStatisticsImpl(topic));
+ ts = (TopicStatisticsImpl)this.topicStatistics.get(topic);
+ }
+
+ ts.addActivated(queueTime);
+ this.globalStatistics.addActive(queueTime);
+ if ( queueStats != null ) {
+ queueStats.addActive(queueTime);
+ }
+ }
+
+ public void jobQueued(final String queueName,
+ final String topic) {
+ final StatisticsImpl queueStats = getStatisticsForQueue(queueName);
+
+ this.globalStatistics.incQueued();
+ if ( queueStats != null ) {
+ queueStats.incQueued();
+ }
+ }
+
+ public void jobDequeued(final String queueName, final String topic) {
+ final StatisticsImpl queueStats = getStatisticsForQueue(queueName);
+
+ this.globalStatistics.decQueued();
+ if ( queueStats != null ) {
+ queueStats.decQueued();
+ }
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/stats/TopicStatisticsImpl.java b/src/main/java/org/apache/sling/event/impl/jobs/stats/TopicStatisticsImpl.java
new file mode 100644
index 0000000..3b0a32e
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/stats/TopicStatisticsImpl.java
@@ -0,0 +1,169 @@
+/*
+ * 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.event.impl.jobs.stats;
+
+import org.apache.sling.event.jobs.TopicStatistics;
+
+/**
+ * Implementation of the statistics.
+ */
+public class TopicStatisticsImpl implements TopicStatistics {
+
+ private final String topic;
+
+ private volatile long lastActivated = -1;
+
+ private volatile long lastFinished = -1;
+
+ private volatile long averageWaitingTime;
+
+ private volatile long averageProcessingTime;
+
+ private volatile long waitingTime;
+
+ private volatile long processingTime;
+
+ private volatile long waitingCount;
+
+ private volatile long processingCount;
+
+ private volatile long finishedJobs;
+
+ private volatile long failedJobs;
+
+ private volatile long cancelledJobs;
+
+ /** Constructor. */
+ public TopicStatisticsImpl(final String topic) {
+ this.topic = topic;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.TopicStatistics#getTopic()
+ */
+ @Override
+ public String getTopic() {
+ return this.topic;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.TopicStatistics#getNumberOfProcessedJobs()
+ */
+ @Override
+ public synchronized long getNumberOfProcessedJobs() {
+ return getNumberOfCancelledJobs() + getNumberOfFailedJobs() + getNumberOfFinishedJobs();
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.TopicStatistics#getAverageWaitingTime()
+ */
+ @Override
+ public synchronized long getAverageWaitingTime() {
+ return averageWaitingTime;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.TopicStatistics#getAverageProcessingTime()
+ */
+ @Override
+ public synchronized long getAverageProcessingTime() {
+ return averageProcessingTime;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.TopicStatistics#getNumberOfFinishedJobs()
+ */
+ @Override
+ public synchronized long getNumberOfFinishedJobs() {
+ return finishedJobs;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.TopicStatistics#getNumberOfCancelledJobs()
+ */
+ @Override
+ public synchronized long getNumberOfCancelledJobs() {
+ return cancelledJobs;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.TopicStatistics#getNumberOfFailedJobs()
+ */
+ @Override
+ public synchronized long getNumberOfFailedJobs() {
+ return failedJobs;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.TopicStatistics#getLastActivatedJobTime()
+ */
+ @Override
+ public synchronized long getLastActivatedJobTime() {
+ return this.lastActivated;
+ }
+
+ /**
+ * @see org.apache.sling.event.jobs.TopicStatistics#getLastFinishedJobTime()
+ */
+ @Override
+ public synchronized long getLastFinishedJobTime() {
+ return this.lastFinished;
+ }
+
+ /**
+ * Add a finished job.
+ * @param jobTime The time of the job processing.
+ */
+ public synchronized void addFinished(final long jobTime) {
+ this.finishedJobs++;
+ this.lastFinished = System.currentTimeMillis();
+ if ( jobTime > 0 ) {
+ this.processingTime += jobTime;
+ this.processingCount++;
+ this.averageProcessingTime = this.processingTime / this.processingCount;
+ }
+ }
+
+ /**
+ * Add a started job.
+ * @param queueTime The time of the job in the queue.
+ */
+ public synchronized void addActivated(final long queueTime) {
+ this.lastActivated = System.currentTimeMillis();
+ if ( queueTime > 0 ) {
+ this.waitingTime += queueTime;
+ this.waitingCount++;
+ this.averageWaitingTime = this.waitingTime / this.waitingCount;
+ }
+ }
+
+ /**
+ * Add a failed job.
+ */
+ public synchronized void addFailed() {
+ this.failedJobs++;
+ }
+
+ /**
+ * Add a cancelled job.
+ */
+ public synchronized void addCancelled() {
+ this.cancelledJobs++;
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/tasks/CheckTopologyTask.java b/src/main/java/org/apache/sling/event/impl/jobs/tasks/CheckTopologyTask.java
new file mode 100644
index 0000000..879ee2a
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/tasks/CheckTopologyTask.java
@@ -0,0 +1,333 @@
+/*
+ * 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.event.impl.jobs.tasks;
+
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.sling.api.resource.PersistenceException;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.ValueMap;
+import org.apache.sling.discovery.InstanceDescription;
+import org.apache.sling.event.impl.jobs.JobTopicTraverser;
+import org.apache.sling.event.impl.jobs.config.JobManagerConfiguration;
+import org.apache.sling.event.impl.jobs.config.QueueConfigurationManager;
+import org.apache.sling.event.impl.jobs.config.QueueConfigurationManager.QueueInfo;
+import org.apache.sling.event.impl.jobs.config.TopologyCapabilities;
+import org.apache.sling.event.impl.support.ResourceHelper;
+import org.apache.sling.event.jobs.Job;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The check topology task checks for changes in the topology and queue configuration
+ * and reassigns jobs.
+ * If the leader instance finds a dead instance it reassigns its jobs to live instances.
+ * The leader instance also checks for unassigned jobs and tries to assign them.
+ * If an instance detects jobs which it doesn't process anymore it reassigns them as
+ * well.
+ */
+public class CheckTopologyTask {
+
+ /** Logger. */
+ private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+ /** Job manager configuration. */
+ private final JobManagerConfiguration configuration;
+
+ /** The capabilities. */
+ private final TopologyCapabilities caps;
+
+ /**
+ * Constructor
+ * @param config The configuration
+ */
+ public CheckTopologyTask(final JobManagerConfiguration config) {
+ this.configuration = config;
+ this.caps = this.configuration.getTopologyCapabilities();
+ }
+
+ /**
+ * Reassign jobs from stopped instance.
+ */
+ private void reassignJobsFromStoppedInstances() {
+ if ( caps.isLeader() && caps.isActive() ) {
+ this.logger.debug("Checking for stopped instances...");
+ final ResourceResolver resolver = this.configuration.createResourceResolver();
+ if ( resolver != null ) {
+ try {
+ final Resource jobsRoot = resolver.getResource(this.configuration.getAssginedJobsPath());
+ this.logger.debug("Got jobs root {}", jobsRoot);
+
+ // this resource should exist, but we check anyway
+ if ( jobsRoot != null ) {
+ final Iterator<Resource> instanceIter = jobsRoot.listChildren();
+ while ( caps.isActive() && instanceIter.hasNext() ) {
+ final Resource instanceResource = instanceIter.next();
+
+ final String instanceId = instanceResource.getName();
+ if ( !caps.isActive(instanceId) ) {
+ logger.debug("Found stopped instance {}", instanceId);
+ assignJobs(instanceResource, true);
+ }
+ }
+ }
+ } finally {
+ resolver.close();
+ }
+ }
+ }
+ }
+
+ /**
+ * Reassign stale jobs from this instance
+ */
+ private void reassignStaleJobs() {
+ if ( caps.isActive() ) {
+ this.logger.debug("Checking for stale jobs...");
+ final ResourceResolver resolver = this.configuration.createResourceResolver();
+ if ( resolver != null ) {
+ try {
+ final Resource jobsRoot = resolver.getResource(this.configuration.getLocalJobsPath());
+
+ // this resource should exist, but we check anyway
+ if ( jobsRoot != null ) {
+ final Iterator<Resource> topicIter = jobsRoot.listChildren();
+ while ( caps.isActive() && topicIter.hasNext() ) {
+ final Resource topicResource = topicIter.next();
+
+ final String topicName = topicResource.getName().replace('.', '/');
+ this.logger.debug("Checking topic {}..." , topicName);
+ final List<InstanceDescription> potentialTargets = caps.getPotentialTargets(topicName);
+ boolean reassign = true;
+ for(final InstanceDescription desc : potentialTargets) {
+ if ( desc.isLocal() ) {
+ reassign = false;
+ break;
+ }
+ }
+ if ( reassign ) {
+ final QueueConfigurationManager qcm = this.configuration.getQueueConfigurationManager();
+ if ( qcm == null ) {
+ break;
+ }
+ final QueueInfo info = qcm.getQueueInfo(topicName);
+ logger.info ("Start reassigning stale jobs");
+ JobTopicTraverser.traverse(this.logger, topicResource, new JobTopicTraverser.ResourceCallback() {
+
+ @Override
+ public boolean handle(final Resource rsrc) {
+ try {
+ final ValueMap vm = ResourceHelper.getValueMap(rsrc);
+ final String targetId = caps.detectTarget(topicName, vm, info);
+
+ final Map<String, Object> props = new HashMap<String, Object>(vm);
+ props.remove(Job.PROPERTY_JOB_STARTED_TIME);
+
+ final String newPath;
+ if ( targetId != null ) {
+ newPath = configuration.getAssginedJobsPath() + '/' + targetId + '/' + topicResource.getName() + rsrc.getPath().substring(topicResource.getPath().length());
+ props.put(Job.PROPERTY_JOB_QUEUE_NAME, info.queueName);
+ props.put(Job.PROPERTY_JOB_TARGET_INSTANCE, targetId);
+ } else {
+ newPath = configuration.getUnassignedJobsPath() + '/' + topicResource.getName() + rsrc.getPath().substring(topicResource.getPath().length());
+ props.remove(Job.PROPERTY_JOB_QUEUE_NAME);
+ props.remove(Job.PROPERTY_JOB_TARGET_INSTANCE);
+ }
+ try {
+ ResourceHelper.getOrCreateResource(resolver, newPath, props);
+ resolver.delete(rsrc);
+ resolver.commit();
+ final String jobId = vm.get(ResourceHelper.PROPERTY_JOB_ID, String.class);
+ if ( targetId != null ) {
+ configuration.getAuditLogger().debug("REASSIGN OK {} : {}", targetId, jobId);
+ } else {
+ configuration.getAuditLogger().debug("REUNASSIGN OK : {}", jobId);
+ }
+ } catch ( final PersistenceException pe ) {
+ logger.warn("Unable to move stale job from " + rsrc.getPath() + " to " + newPath, pe);
+ resolver.refresh();
+ resolver.revert();
+ }
+ } catch (final InstantiationException ie) {
+ // something happened with the resource in the meantime
+ logger.warn("Unable to move stale job from " + rsrc.getPath(), ie);
+ resolver.refresh();
+ resolver.revert();
+ }
+ return caps.isActive();
+ }
+ });
+
+ }
+ }
+ }
+ } finally {
+ resolver.close();
+ }
+ }
+ }
+ }
+
+ /**
+ * Try to assign unassigned jobs as there might be changes in:
+ * - queue configurations
+ * - topology
+ * - capabilities
+ */
+ public void assignUnassignedJobs() {
+ if ( caps != null && caps.isLeader() && caps.isActive() ) {
+ logger.debug("Checking unassigned jobs...");
+ final ResourceResolver resolver = this.configuration.createResourceResolver();
+ if ( resolver != null ) {
+ try {
+ final Resource unassignedRoot = resolver.getResource(this.configuration.getUnassignedJobsPath());
+ logger.debug("Got unassigned root {}", unassignedRoot);
+
+ // this resource should exist, but we check anyway
+ if ( unassignedRoot != null ) {
+ assignJobs(unassignedRoot, false);
+ }
+ } finally {
+ resolver.close();
+ }
+ }
+ }
+ }
+
+ /**
+ * Try to assign all jobs from the jobs root.
+ * The jobs are stored by topic
+ * @param jobsRoot The root of the jobs
+ * @param unassign Whether to unassign the job if no instance is found.
+ */
+ private void assignJobs(final Resource jobsRoot,
+ final boolean unassign) {
+ final ResourceResolver resolver = jobsRoot.getResourceResolver();
+
+ final Iterator<Resource> topicIter = jobsRoot.listChildren();
+ while ( caps.isActive() && topicIter.hasNext() ) {
+ final Resource topicResource = topicIter.next();
+
+ final String topicName = topicResource.getName().replace('.', '/');
+ logger.debug("Found topic {}", topicName);
+
+ // first check if there is an instance for these topics
+ final List<InstanceDescription> potentialTargets = caps.getPotentialTargets(topicName);
+ if ( potentialTargets != null && potentialTargets.size() > 0 ) {
+ final QueueConfigurationManager qcm = this.configuration.getQueueConfigurationManager();
+ if ( qcm == null ) {
+ break;
+ }
+ final QueueInfo info = qcm.getQueueInfo(topicName);
+ logger.debug("Found queue {} for {}", info.queueConfiguration, topicName);
+
+ JobTopicTraverser.traverse(this.logger, topicResource, new JobTopicTraverser.ResourceCallback() {
+
+ @Override
+ public boolean handle(final Resource rsrc) {
+ try {
+ final ValueMap vm = ResourceHelper.getValueMap(rsrc);
+ final String targetId = caps.detectTarget(topicName, vm, info);
+
+ if ( targetId != null ) {
+ final String newPath = configuration.getAssginedJobsPath() + '/' + targetId + '/' + topicResource.getName() + rsrc.getPath().substring(topicResource.getPath().length());
+ final Map<String, Object> props = new HashMap<String, Object>(vm);
+ props.put(Job.PROPERTY_JOB_QUEUE_NAME, info.queueName);
+ props.put(Job.PROPERTY_JOB_TARGET_INSTANCE, targetId);
+ props.remove(Job.PROPERTY_JOB_STARTED_TIME);
+ try {
+ ResourceHelper.getOrCreateResource(resolver, newPath, props);
+ resolver.delete(rsrc);
+ resolver.commit();
+ final String jobId = vm.get(ResourceHelper.PROPERTY_JOB_ID, String.class);
+ configuration.getAuditLogger().debug("REASSIGN OK {} : {}", targetId, jobId);
+ } catch ( final PersistenceException pe ) {
+ logger.warn("Unable to move unassigned job from " + rsrc.getPath() + " to " + newPath, pe);
+ resolver.refresh();
+ resolver.revert();
+ }
+ }
+ } catch (final InstantiationException ie) {
+ // something happened with the resource in the meantime
+ logger.warn("Unable to move unassigned job from " + rsrc.getPath(), ie);
+ resolver.refresh();
+ resolver.revert();
+ }
+ return caps.isActive();
+ }
+ });
+ }
+ // now unassign if there are still jobs
+ if ( caps.isActive() && unassign ) {
+ // we have to move everything to the unassigned area
+ JobTopicTraverser.traverse(this.logger, topicResource, new JobTopicTraverser.ResourceCallback() {
+
+ @Override
+ public boolean handle(final Resource rsrc) {
+ try {
+ final ValueMap vm = ResourceHelper.getValueMap(rsrc);
+ final String newPath = configuration.getUnassignedJobsPath() + '/' + topicResource.getName() + rsrc.getPath().substring(topicResource.getPath().length());
+ final Map<String, Object> props = new HashMap<String, Object>(vm);
+ props.remove(Job.PROPERTY_JOB_QUEUE_NAME);
+ props.remove(Job.PROPERTY_JOB_TARGET_INSTANCE);
+ props.remove(Job.PROPERTY_JOB_STARTED_TIME);
+
+ try {
+ ResourceHelper.getOrCreateResource(resolver, newPath, props);
+ resolver.delete(rsrc);
+ resolver.commit();
+ final String jobId = vm.get(ResourceHelper.PROPERTY_JOB_ID, String.class);
+ configuration.getAuditLogger().debug("REUNASSIGN OK : {}", jobId);
+ } catch ( final PersistenceException pe ) {
+ logger.warn("Unable to unassigned job from " + rsrc.getPath() + " to " + newPath, pe);
+ resolver.refresh();
+ resolver.revert();
+ }
+ } catch (final InstantiationException ie) {
+ // something happened with the resource in the meantime
+ logger.warn("Unable to unassigned job from " + rsrc.getPath(), ie);
+ resolver.refresh();
+ resolver.revert();
+ }
+ return caps.isActive();
+ }
+ });
+ }
+ }
+ }
+
+ /**
+ * One maintenance run
+ */
+ public void fullRun() {
+ if ( this.caps != null ) {
+ this.reassignJobsFromStoppedInstances();
+
+ // check for all topics
+ this.reassignStaleJobs();
+
+ // try to assign unassigned jobs
+ this.assignUnassignedJobs();
+ }
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/tasks/CleanUpTask.java b/src/main/java/org/apache/sling/event/impl/jobs/tasks/CleanUpTask.java
new file mode 100644
index 0000000..7fdcb88
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/tasks/CleanUpTask.java
@@ -0,0 +1,275 @@
+/*
+ * 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.event.impl.jobs.tasks;
+
+import java.util.Calendar;
+import java.util.Iterator;
+
+import org.apache.sling.api.resource.PersistenceException;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.event.impl.jobs.config.JobManagerConfiguration;
+import org.apache.sling.event.impl.jobs.config.TopologyCapabilities;
+import org.apache.sling.event.impl.jobs.scheduling.JobSchedulerImpl;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Maintenance task...
+ *
+ * In the default configuration, this task runs every minute
+ */
+public class CleanUpTask {
+
+ /** Logger. */
+ private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+ /** Job manager configuration. */
+ private final JobManagerConfiguration configuration;
+
+ /** Job scheduler. */
+ private final JobSchedulerImpl jobScheduler;
+
+ /** We count the scheduler runs. */
+ private volatile long schedulerRuns;
+
+ /**
+ * Constructor
+ */
+ public CleanUpTask(final JobManagerConfiguration config, final JobSchedulerImpl jobScheduler) {
+ this.configuration = config;
+ this.jobScheduler = jobScheduler;
+ }
+
+ /**
+ * One maintenance run
+ */
+ public void run() {
+ this.schedulerRuns++;
+ logger.debug("Job manager maintenance: Starting #{}", this.schedulerRuns);
+
+ final TopologyCapabilities topologyCapabilities = configuration.getTopologyCapabilities();
+ if ( topologyCapabilities != null ) {
+ // Clean up
+ final String cleanUpUnassignedPath;;
+ if ( topologyCapabilities.isLeader() ) {
+ cleanUpUnassignedPath = this.configuration.getUnassignedJobsPath();
+ } else {
+ cleanUpUnassignedPath = null;
+ }
+
+ // job scheduler is handled every third run
+ if ( schedulerRuns % 3 == 1 ) {
+ this.jobScheduler.maintenance();
+ }
+ if ( schedulerRuns % 60 == 0 ) { // full clean up is done every hour
+ this.fullEmptyFolderCleanup(topologyCapabilities, this.configuration.getLocalJobsPath());
+ if ( cleanUpUnassignedPath != null ) {
+ this.fullEmptyFolderCleanup(topologyCapabilities, cleanUpUnassignedPath);
+ }
+ } else if ( schedulerRuns % 5 == 0 ) { // simple clean up every 5 minutes
+ this.simpleEmptyFolderCleanup(topologyCapabilities, this.configuration.getLocalJobsPath());
+ if ( cleanUpUnassignedPath != null ) {
+ this.simpleEmptyFolderCleanup(topologyCapabilities, cleanUpUnassignedPath);
+ }
+ }
+ }
+
+ logger.debug("Job manager maintenance: Finished #{}", this.schedulerRuns);
+ }
+
+ /**
+ * Simple empty folder removes empty folders for the last ten minutes
+ * starting five minutes ago.
+ * If folder for minute 59 is removed, we check the hour folder as well.
+ */
+ private void simpleEmptyFolderCleanup(final TopologyCapabilities caps, final String basePath) {
+ this.logger.debug("Cleaning up job resource tree: looking for empty folders");
+ final ResourceResolver resolver = this.configuration.createResourceResolver();
+ try {
+ final Calendar cleanUpDate = Calendar.getInstance();
+ // go back five minutes
+ cleanUpDate.add(Calendar.MINUTE, -5);
+
+ final Resource baseResource = resolver.getResource(basePath);
+ // sanity check - should never be null
+ if ( baseResource != null ) {
+ final Iterator<Resource> topicIter = baseResource.listChildren();
+ while ( caps.isActive() && topicIter.hasNext() ) {
+ final Resource topicResource = topicIter.next();
+
+ for(int i = 0; i < 10; i++) {
+ if ( caps.isActive() ) {
+ final StringBuilder sb = new StringBuilder(topicResource.getPath());
+ sb.append('/');
+ sb.append(cleanUpDate.get(Calendar.YEAR));
+ sb.append('/');
+ sb.append(cleanUpDate.get(Calendar.MONTH) + 1);
+ sb.append('/');
+ sb.append(cleanUpDate.get(Calendar.DAY_OF_MONTH));
+ sb.append('/');
+ sb.append(cleanUpDate.get(Calendar.HOUR_OF_DAY));
+ sb.append('/');
+ sb.append(cleanUpDate.get(Calendar.MINUTE));
+ final String path = sb.toString();
+
+ final Resource dateResource = resolver.getResource(path);
+ if ( dateResource != null && !dateResource.listChildren().hasNext() ) {
+ resolver.delete(dateResource);
+ resolver.commit();
+ }
+ // check hour folder
+ if ( path.endsWith("59") ) {
+ final String hourPath = path.substring(0, path.length() - 3);
+ final Resource hourResource = resolver.getResource(hourPath);
+ if ( hourResource != null && !hourResource.listChildren().hasNext() ) {
+ resolver.delete(hourResource);
+ resolver.commit();
+ }
+ }
+ // go back another minute in time
+ cleanUpDate.add(Calendar.MINUTE, -1);
+ }
+ }
+ }
+ }
+
+ } catch (final PersistenceException pe) {
+ // in the case of an error, we just log this as a warning
+ this.logger.warn("Exception during job resource tree cleanup.", pe);
+ } finally {
+ resolver.close();
+ }
+ }
+
+ /**
+ * Full cleanup - this scans all directories!
+ */
+ private void fullEmptyFolderCleanup(final TopologyCapabilities caps, final String basePath) {
+ this.logger.debug("Cleaning up job resource tree: removing ALL empty folders");
+ final ResourceResolver resolver = this.configuration.createResourceResolver();
+ if ( resolver == null ) {
+ return;
+ }
+ try {
+ final Resource baseResource = resolver.getResource(basePath);
+ // sanity check - should never be null
+ if ( baseResource != null ) {
+ final Calendar now = Calendar.getInstance();
+ final int removeYear = now.get(Calendar.YEAR);
+ final int removeMonth = now.get(Calendar.MONTH) + 1;
+ final int removeDay = now.get(Calendar.DAY_OF_MONTH);
+ final int removeHour = now.get(Calendar.HOUR_OF_DAY);
+
+ final Iterator<Resource> topicIter = baseResource.listChildren();
+ while ( caps.isActive() && topicIter.hasNext() ) {
+ final Resource topicResource = topicIter.next();
+
+ // now years
+ final Iterator<Resource> yearIter = topicResource.listChildren();
+ while ( caps.isActive() && yearIter.hasNext() ) {
+ final Resource yearResource = yearIter.next();
+ final int year = Integer.valueOf(yearResource.getName());
+ // we should not have a year higher than "now", but we test anyway
+ if ( year > removeYear ) {
+ continue;
+ }
+ final boolean oldYear = year < removeYear;
+
+ // months
+ final Iterator<Resource> monthIter = yearResource.listChildren();
+ while ( caps.isActive() && monthIter.hasNext() ) {
+ final Resource monthResource = monthIter.next();
+ final int month = Integer.valueOf(monthResource.getName());
+ if ( !oldYear && month > removeMonth ) {
+ continue;
+ }
+ final boolean oldMonth = oldYear || month < removeMonth;
+
+ // days
+ final Iterator<Resource> dayIter = monthResource.listChildren();
+ while ( caps.isActive() && dayIter.hasNext() ) {
+ final Resource dayResource = dayIter.next();
+ final int day = Integer.valueOf(dayResource.getName());
+ if ( !oldMonth && day > removeDay ) {
+ continue;
+ }
+ final boolean oldDay = oldMonth || day < removeDay;
+
+ // hours
+ final Iterator<Resource> hourIter = dayResource.listChildren();
+ while ( caps.isActive() && hourIter.hasNext() ) {
+ final Resource hourResource = hourIter.next();
+ final int hour = Integer.valueOf(hourResource.getName());
+ if ( !oldDay && hour > removeHour ) {
+ continue;
+ }
+ final boolean oldHour = (oldDay && (oldMonth || removeHour > 0)) || hour < (removeHour -1);
+
+ // we only remove minutes if the hour is old
+ if ( oldHour ) {
+ final Iterator<Resource> minuteIter = hourResource.listChildren();
+ while ( caps.isActive() && minuteIter.hasNext() ) {
+ final Resource minuteResource = minuteIter.next();
+
+ // check if we can delete the minute
+ if ( !minuteResource.listChildren().hasNext() ) {
+ resolver.delete(minuteResource);
+ resolver.commit();
+ }
+ }
+ }
+
+ // check if we can delete the hour
+ if ( caps.isActive() && oldHour && !hourResource.listChildren().hasNext()) {
+ resolver.delete(hourResource);
+ resolver.commit();
+ }
+ }
+ // check if we can delete the day
+ if ( caps.isActive() && oldDay && !dayResource.listChildren().hasNext()) {
+ resolver.delete(dayResource);
+ resolver.commit();
+ }
+ }
+
+ // check if we can delete the month
+ if ( caps.isActive() && oldMonth && !monthResource.listChildren().hasNext() ) {
+ resolver.delete(monthResource);
+ resolver.commit();
+ }
+ }
+
+ // check if we can delete the year
+ if ( caps.isActive() && oldYear && !yearResource.listChildren().hasNext() ) {
+ resolver.delete(yearResource);
+ resolver.commit();
+ }
+ }
+ }
+ }
+
+ } catch (final PersistenceException pe) {
+ // in the case of an error, we just log this as a warning
+ this.logger.warn("Exception during job resource tree cleanup.", pe);
+ } finally {
+ resolver.close();
+ }
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/tasks/FindUnfinishedJobsTask.java b/src/main/java/org/apache/sling/event/impl/jobs/tasks/FindUnfinishedJobsTask.java
new file mode 100644
index 0000000..7bd889b
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/tasks/FindUnfinishedJobsTask.java
@@ -0,0 +1,137 @@
+/*
+ * 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.event.impl.jobs.tasks;
+
+import java.util.Calendar;
+import java.util.Iterator;
+
+import org.apache.sling.api.resource.ModifiableValueMap;
+import org.apache.sling.api.resource.PersistenceException;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.event.impl.jobs.JobImpl;
+import org.apache.sling.event.impl.jobs.JobTopicTraverser;
+import org.apache.sling.event.impl.jobs.config.JobManagerConfiguration;
+import org.apache.sling.event.jobs.Job;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This task is executed when the job handling starts.
+ * It checks for unfinished jobs from a previous start and corrects their state.
+ */
+public class FindUnfinishedJobsTask {
+
+ /** Logger. */
+ private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+ /** Job manager configuration. */
+ private final JobManagerConfiguration configuration;
+
+ /**
+ * Constructor
+ * @param config the configuration
+ */
+ public FindUnfinishedJobsTask(final JobManagerConfiguration config) {
+ this.configuration = config;
+ }
+
+ public void run() {
+ this.initialScan();
+ }
+
+ /**
+ * Scan the resource tree for unfinished jobs from previous runs
+ */
+ private void initialScan() {
+ logger.debug("Scanning repository for unfinished jobs...");
+ final ResourceResolver resolver = configuration.createResourceResolver();
+ if ( resolver == null ) {
+ return;
+ }
+ try {
+ final Resource baseResource = resolver.getResource(configuration.getLocalJobsPath());
+
+ // sanity check - should never be null
+ if ( baseResource != null ) {
+ final Iterator<Resource> topicIter = baseResource.listChildren();
+ while ( topicIter.hasNext() ) {
+ final Resource topicResource = topicIter.next();
+ logger.debug("Found topic {}", topicResource.getName());
+
+ // init topic
+ initTopic(topicResource);
+ }
+ }
+ } finally {
+ resolver.close();
+ }
+ }
+
+ /**
+ * Initialize a topic and update all jobs from that topic.
+ * Reset started time and increase retry count of unfinished jobs
+ * @param topicResource The topic resource
+ */
+ private void initTopic(final Resource topicResource) {
+ logger.debug("Initializing topic {}...", topicResource.getName());
+
+ JobTopicTraverser.traverse(logger, topicResource, new JobTopicTraverser.JobCallback() {
+
+ @Override
+ public boolean handle(final JobImpl job) {
+ if ( job.getProcessingStarted() != null ) {
+ logger.debug("Found unfinished job {}", job.getId());
+ job.retry();
+ try {
+ final Resource jobResource = topicResource.getResourceResolver().getResource(job.getResourcePath());
+ // sanity check
+ if ( jobResource != null ) {
+ final ModifiableValueMap mvm = jobResource.adaptTo(ModifiableValueMap.class);
+ mvm.remove(Job.PROPERTY_JOB_STARTED_TIME);
+ mvm.put(Job.PROPERTY_JOB_RETRY_COUNT, job.getRetryCount());
+ if ( job.getProperty(JobImpl.PROPERTY_JOB_QUEUED, Calendar.class) == null) {
+ mvm.put(JobImpl.PROPERTY_JOB_QUEUED, Calendar.getInstance());
+ }
+ jobResource.getResourceResolver().commit();
+ }
+ } catch ( final PersistenceException ignore) {
+ logger.error("Unable to update unfinished job " + job, ignore);
+ }
+ } else if ( job.getProperty(JobImpl.PROPERTY_JOB_QUEUED, Calendar.class) == null) {
+ logger.debug("Found job without queued date {}", job.getId());
+ try {
+ final Resource jobResource = topicResource.getResourceResolver().getResource(job.getResourcePath());
+ // sanity check
+ if ( jobResource != null ) {
+ final ModifiableValueMap mvm = jobResource.adaptTo(ModifiableValueMap.class);
+ mvm.put(JobImpl.PROPERTY_JOB_QUEUED, Calendar.getInstance());
+ jobResource.getResourceResolver().commit();
+ }
+ } catch ( final PersistenceException ignore) {
+ logger.error("Unable to update queued date for job " + job.getId(), ignore);
+ }
+ }
+
+ return true;
+ }
+ });
+ logger.debug("Topic {} initialized", topicResource.getName());
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/tasks/HistoryCleanUpTask.java b/src/main/java/org/apache/sling/event/impl/jobs/tasks/HistoryCleanUpTask.java
new file mode 100644
index 0000000..6cb136b
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/tasks/HistoryCleanUpTask.java
@@ -0,0 +1,255 @@
+/*
+ * 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.event.impl.jobs.tasks;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Iterator;
+import java.util.List;
+
+import org.apache.felix.scr.annotations.Component;
+import org.apache.felix.scr.annotations.Property;
+import org.apache.felix.scr.annotations.Reference;
+import org.apache.felix.scr.annotations.Service;
+import org.apache.sling.api.resource.PersistenceException;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.ResourceUtil;
+import org.apache.sling.api.resource.ValueMap;
+import org.apache.sling.event.impl.jobs.JobImpl;
+import org.apache.sling.event.impl.jobs.config.JobManagerConfiguration;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.consumer.JobExecutionContext;
+import org.apache.sling.event.jobs.consumer.JobExecutionResult;
+import org.apache.sling.event.jobs.consumer.JobExecutor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Task to clean up the history,
+ * A clean up task can be configured with three properties:
+ * - age : only jobs older than this amount of minutes are removed (default is two days)
+ * - topic : only jobs with this topic are removed (default is no topic, meaning all jobs are removed)
+ * The value should either be a string or an array of string
+ * - state : only jobs in this state are removed (default is no state, meaning all jobs are removed)
+ * The value should either be a string or an array of string. Allowed values are:
+ * SUCCEEDED, STOPPED, GIVEN_UP, ERROR, DROPPED
+ */
+@Component
+@Service(value = JobExecutor.class)
+@Property(name = JobExecutor.PROPERTY_TOPICS, value = "org/apache/sling/event/impl/jobs/tasks/HistoryCleanUpTask")
+public class HistoryCleanUpTask implements JobExecutor {
+
+ private static final String PROPERTY_AGE = "age";
+
+ private static final String PROPERTY_TOPIC = "topic";
+
+ private static final String PROPERTY_STATE = "state";
+
+ private static final int DEFAULT_AGE = 60 * 24 * 2; // older than two days
+
+ private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+ @Reference
+ private JobManagerConfiguration configuration;
+
+ @Override
+ public JobExecutionResult process(final Job job, final JobExecutionContext context) {
+ int age = job.getProperty(PROPERTY_AGE, DEFAULT_AGE);
+ if ( age < 1 ) {
+ age = DEFAULT_AGE;
+ }
+ final Calendar removeDate = Calendar.getInstance();
+ removeDate.add(Calendar.MINUTE, -age);
+
+ final String[] topics = job.getProperty(PROPERTY_TOPIC, String[].class);
+ final String[] states = job.getProperty(PROPERTY_STATE, String[].class);
+ final String logTopics = (topics == null ? "ALL" : Arrays.toString(topics));
+ final String logStates = (states == null ? "ALL" : Arrays.toString(states));
+ context.log("Cleaning up job history. Removing all jobs older than {0}, with topics {1} and states {2}",
+ removeDate, logTopics, logStates);
+
+ final List<String> stateList;
+ if ( states != null ) {
+ stateList = new ArrayList<String>();
+ for(final String s : states) {
+ stateList.add(s);
+ }
+ } else {
+ stateList = null;
+ }
+ final ResourceResolver resolver = this.configuration.createResourceResolver();
+ try {
+ if ( stateList == null || stateList.contains(Job.JobState.SUCCEEDED.name()) ) {
+ this.cleanup(removeDate, resolver, context, configuration.getStoredSuccessfulJobsPath(), topics, null);
+ }
+ if ( stateList == null || stateList.contains(Job.JobState.DROPPED.name())
+ || stateList.contains(Job.JobState.ERROR.name())
+ || stateList.contains(Job.JobState.GIVEN_UP.name())
+ || stateList.contains(Job.JobState.STOPPED.name())) {
+ this.cleanup(removeDate, resolver, context, configuration.getStoredCancelledJobsPath(), topics, stateList);
+ }
+
+ } catch (final PersistenceException pe) {
+ // in the case of an error, we just log this as a warning
+ this.logger.warn("Exception during job resource tree cleanup.", pe);
+ } finally {
+ resolver.close();
+ }
+ return context.result().succeeded();
+ }
+
+ private void cleanup(final Calendar removeDate,
+ final ResourceResolver resolver,
+ final JobExecutionContext context,
+ final String basePath,
+ final String[] topics,
+ final List<String> stateList)
+ throws PersistenceException {
+ final Resource baseResource = resolver.getResource(basePath);
+ // sanity check - should never be null
+ if ( baseResource != null ) {
+ final Iterator<Resource> topicIter = baseResource.listChildren();
+ while ( !context.isStopped() && topicIter.hasNext() ) {
+ final Resource topicResource = topicIter.next();
+
+ // check topic
+ boolean found = topics == null;
+ int index = 0;
+ while ( !found && index < topics.length ) {
+ if ( topicResource.getName().equals(topics[index]) ) {
+ found = true;
+ }
+ index++;
+ }
+ if ( !found ) {
+ continue;
+ }
+
+ final int removeYear = removeDate.get(Calendar.YEAR);
+ final int removeMonth = removeDate.get(Calendar.MONTH) + 1;
+ final int removeDay = removeDate.get(Calendar.DAY_OF_MONTH);
+ final int removeHour = removeDate.get(Calendar.HOUR_OF_DAY);
+ final int removeMinute = removeDate.get(Calendar.MINUTE);
+
+ // start with years
+ final Iterator<Resource> yearIter = topicResource.listChildren();
+ while ( !context.isStopped() && yearIter.hasNext() ) {
+ final Resource yearResource = yearIter.next();
+ final int year = Integer.valueOf(yearResource.getName());
+ if ( year > removeYear ) {
+ continue;
+ }
+ final boolean oldYear = year < removeYear;
+
+ // months
+ final Iterator<Resource> monthIter = yearResource.listChildren();
+ while ( !context.isStopped() && monthIter.hasNext() ) {
+ final Resource monthResource = monthIter.next();
+ final int month = Integer.valueOf(monthResource.getName());
+ if ( !oldYear && month > removeMonth) {
+ continue;
+ }
+ final boolean oldMonth = oldYear || month < removeMonth;
+
+ // days
+ final Iterator<Resource> dayIter = monthResource.listChildren();
+ while ( !context.isStopped() && dayIter.hasNext() ) {
+ final Resource dayResource = dayIter.next();
+ final int day = Integer.valueOf(dayResource.getName());
+ if ( !oldMonth && day > removeDay) {
+ continue;
+ }
+ final boolean oldDay = oldMonth || day < removeDay;
+
+ // hours
+ final Iterator<Resource> hourIter = dayResource.listChildren();
+ while ( !context.isStopped() && hourIter.hasNext() ) {
+ final Resource hourResource = hourIter.next();
+ final int hour = Integer.valueOf(hourResource.getName());
+ if ( !oldDay && hour > removeHour) {
+ continue;
+ }
+ final boolean oldHour = oldDay || hour < removeHour;
+
+ // minutes
+ final Iterator<Resource> minuteIter = hourResource.listChildren();
+ while ( !context.isStopped() && minuteIter.hasNext() ) {
+ final Resource minuteResource = minuteIter.next();
+
+ // check if we can delete the minute
+ final int minute = Integer.valueOf(minuteResource.getName());
+ final boolean oldMinute = oldHour || minute <= removeMinute;
+
+ if ( oldMinute ) {
+ final Iterator<Resource> jobIter = minuteResource.listChildren();
+ while ( !context.isStopped() && jobIter.hasNext() ) {
+ final Resource jobResource = jobIter.next();
+ boolean remove = stateList == null;
+ if ( !remove ) {
+ final ValueMap vm = ResourceUtil.getValueMap(jobResource);
+ final String state = vm.get(JobImpl.PROPERTY_FINISHED_STATE, String.class);
+ if ( state != null && stateList.contains(state) ) {
+ remove = true;
+ }
+ }
+ if ( remove ) {
+ resolver.delete(jobResource);
+ resolver.commit();
+ }
+ }
+ // check if we can delete the minute
+ if ( !context.isStopped() && !minuteResource.listChildren().hasNext()) {
+ resolver.delete(minuteResource);
+ resolver.commit();
+ }
+ }
+ }
+
+ // check if we can delete the hour
+ if ( !context.isStopped() && oldHour && !hourResource.listChildren().hasNext()) {
+ resolver.delete(hourResource);
+ resolver.commit();
+ }
+ }
+ // check if we can delete the day
+ if ( !context.isStopped() && oldDay && !dayResource.listChildren().hasNext()) {
+ resolver.delete(dayResource);
+ resolver.commit();
+ }
+ }
+
+ // check if we can delete the month
+ if ( !context.isStopped() && oldMonth && !monthResource.listChildren().hasNext() ) {
+ resolver.delete(monthResource);
+ resolver.commit();
+ }
+ }
+
+ // check if we can delete the year
+ if ( !context.isStopped() && oldYear && !yearResource.listChildren().hasNext() ) {
+ resolver.delete(yearResource);
+ resolver.commit();
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/tasks/UpgradeTask.java b/src/main/java/org/apache/sling/event/impl/jobs/tasks/UpgradeTask.java
new file mode 100644
index 0000000..99c7b0e
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/jobs/tasks/UpgradeTask.java
@@ -0,0 +1,279 @@
+/*
+ * 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.event.impl.jobs.tasks;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.sling.api.resource.PersistenceException;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.ValueMap;
+import org.apache.sling.discovery.InstanceDescription;
+import org.apache.sling.event.impl.jobs.JobTopicTraverser;
+import org.apache.sling.event.impl.jobs.config.JobManagerConfiguration;
+import org.apache.sling.event.impl.jobs.config.QueueConfigurationManager;
+import org.apache.sling.event.impl.jobs.config.QueueConfigurationManager.QueueInfo;
+import org.apache.sling.event.impl.jobs.config.TopologyCapabilities;
+import org.apache.sling.event.impl.support.Environment;
+import org.apache.sling.event.impl.support.ResourceHelper;
+import org.apache.sling.event.jobs.Job;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Upgrade task
+ *
+ * Upgrade jobs from earlier versions to the new format.
+ */
+public class UpgradeTask {
+
+ /** Logger. */
+ private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+ /** Job manager configuration. */
+ private final JobManagerConfiguration configuration;
+
+ /** The capabilities. */
+ private final TopologyCapabilities caps;
+
+ /**
+ * Constructor
+ * @param config the configuration
+ */
+ public UpgradeTask(final JobManagerConfiguration config) {
+ this.configuration = config;
+ this.caps = this.configuration.getTopologyCapabilities();
+ }
+
+ /**
+ * Upgrade
+ */
+ public void run() {
+ if ( caps.isLeader() ) {
+ this.processJobsFromPreviousVersions();
+ }
+ this.upgradeBridgedJobs();
+ }
+
+ /**
+ * Upgrade bridged jobs.
+ * In previous versions, bridged jobs were stored under a special topic.
+ * This has changed, the jobs are now stored with their real topic.
+ */
+ private void upgradeBridgedJobs() {
+ final String path = configuration.getLocalJobsPath() + "/slingevent:eventadmin";
+ final ResourceResolver resolver = configuration.createResourceResolver();
+ if ( resolver != null ) {
+ try {
+ final Resource rootResource = resolver.getResource(path);
+ if ( rootResource != null ) {
+ upgradeBridgedJobs(rootResource);
+ }
+ if ( caps.isLeader() ) {
+ final Resource unassignedRoot = resolver.getResource(configuration.getUnassignedJobsPath() + "/slingevent:eventadmin");
+ if ( unassignedRoot != null ) {
+ upgradeBridgedJobs(unassignedRoot);
+ }
+ }
+ } finally {
+ resolver.close();
+ }
+ }
+ }
+
+ /**
+ * Upgrade bridged jobs
+ * @param rootResource The root resource (topic resource)
+ */
+ private void upgradeBridgedJobs(final Resource topicResource) {
+ final String topicName = topicResource.getName().replace('.', '/');
+ final QueueConfigurationManager qcm = configuration.getQueueConfigurationManager();
+ if ( qcm == null ) {
+ return;
+ }
+ final QueueInfo info = qcm.getQueueInfo(topicName);
+ JobTopicTraverser.traverse(logger, topicResource, new JobTopicTraverser.ResourceCallback() {
+
+ @Override
+ public boolean handle(final Resource rsrc) {
+ try {
+ final ValueMap vm = ResourceHelper.getValueMap(rsrc);
+ final String targetId = caps.detectTarget(topicName, vm, info);
+
+ final Map<String, Object> props = new HashMap<String, Object>(vm);
+ final String newPath;
+ if ( targetId != null ) {
+ newPath = configuration.getAssginedJobsPath() + '/' + targetId + '/' + topicResource.getName() + rsrc.getPath().substring(topicResource.getPath().length());
+ props.put(Job.PROPERTY_JOB_QUEUE_NAME, info.queueName);
+ props.put(Job.PROPERTY_JOB_TARGET_INSTANCE, targetId);
+ } else {
+ newPath = configuration.getUnassignedJobsPath() + '/' + topicResource.getName() + rsrc.getPath().substring(topicResource.getPath().length());
+ props.remove(Job.PROPERTY_JOB_QUEUE_NAME);
+ props.remove(Job.PROPERTY_JOB_TARGET_INSTANCE);
+ }
+ props.remove(Job.PROPERTY_JOB_STARTED_TIME);
+ try {
+ ResourceHelper.getOrCreateResource(topicResource.getResourceResolver(), newPath, props);
+ topicResource.getResourceResolver().delete(rsrc);
+ topicResource.getResourceResolver().commit();
+ } catch ( final PersistenceException pe ) {
+ logger.warn("Unable to move job from previous version " + rsrc.getPath(), pe);
+ topicResource.getResourceResolver().refresh();
+ topicResource.getResourceResolver().revert();
+ }
+ } catch (final InstantiationException ie) {
+ logger.warn("Unable to move job from previous version " + rsrc.getPath(), ie);
+ topicResource.getResourceResolver().refresh();
+ topicResource.getResourceResolver().revert();
+ }
+ return caps.isActive();
+ }
+ });
+ }
+
+ /**
+ * Handle jobs from previous versions (<= 3.1.4) by moving them to the unassigned area
+ */
+ private void processJobsFromPreviousVersions() {
+ final ResourceResolver resolver = configuration.createResourceResolver();
+ if ( resolver != null ) {
+ try {
+ this.processJobsFromPreviousVersions(resolver.getResource(configuration.getPreviousVersionAnonPath()));
+ this.processJobsFromPreviousVersions(resolver.getResource(configuration.getPreviousVersionIdentifiedPath()));
+ } catch ( final PersistenceException pe ) {
+ this.logger.warn("Problems moving jobs from previous version.", pe);
+ } finally {
+ resolver.close();
+ }
+ }
+ }
+
+ /**
+ * Recursively find jobs and move them
+ */
+ private void processJobsFromPreviousVersions(final Resource rsrc) throws PersistenceException {
+ if ( rsrc != null && caps.isActive() ) {
+ if ( rsrc.isResourceType(ResourceHelper.RESOURCE_TYPE_JOB) ) {
+ this.moveJobFromPreviousVersion(rsrc);
+ } else {
+ for(final Resource child : rsrc.getChildren()) {
+ this.processJobsFromPreviousVersions(child);
+ }
+ if ( caps.isActive() ) {
+ rsrc.getResourceResolver().delete(rsrc);
+ rsrc.getResourceResolver().commit();
+ rsrc.getResourceResolver().refresh();
+ }
+ }
+ }
+ }
+
+ /**
+ * Move a single job
+ */
+ private void moveJobFromPreviousVersion(final Resource jobResource)
+ throws PersistenceException {
+ final ResourceResolver resolver = jobResource.getResourceResolver();
+
+ try {
+ final ValueMap vm = ResourceHelper.getValueMap(jobResource);
+ // check for binary properties
+ Map<String, Object> binaryProperties = new HashMap<String, Object>();
+ final ObjectInputStream ois = vm.get("slingevent:properties", ObjectInputStream.class);
+ if ( ois != null ) {
+ try {
+ int length = ois.readInt();
+ for(int i=0;i<length;i++) {
+ final String key = (String)ois.readObject();
+ final Object value = ois.readObject();
+ binaryProperties.put(key, value);
+ }
+ } catch (final ClassNotFoundException cnfe) {
+ throw new PersistenceException("Class not found.", cnfe);
+ } catch (final java.io.InvalidClassException ice) {
+ throw new PersistenceException("Invalid class.", ice);
+ } catch (final IOException ioe) {
+ throw new PersistenceException("Unable to deserialize job properties.", ioe);
+ } finally {
+ try {
+ ois.close();
+ } catch (final IOException ioe) {
+ throw new PersistenceException("Unable to deserialize job properties.", ioe);
+ }
+ }
+ }
+
+ final Map<String, Object> properties = ResourceHelper.cloneValueMap(vm);
+
+ final String topic = (String)properties.remove("slingevent:topic");
+ properties.put(ResourceHelper.PROPERTY_JOB_TOPIC, topic);
+
+ properties.remove(Job.PROPERTY_JOB_QUEUE_NAME);
+ properties.remove(Job.PROPERTY_JOB_TARGET_INSTANCE);
+ // and binary properties
+ properties.putAll(binaryProperties);
+ properties.remove("slingevent:properties");
+
+ if ( !properties.containsKey(Job.PROPERTY_JOB_RETRIES) ) {
+ properties.put(Job.PROPERTY_JOB_RETRIES, 10); // we put a dummy value here; this gets updated by the queue
+ }
+ if ( !properties.containsKey(Job.PROPERTY_JOB_RETRY_COUNT) ) {
+ properties.put(Job.PROPERTY_JOB_RETRY_COUNT, 0);
+ }
+
+ final List<InstanceDescription> potentialTargets = caps.getPotentialTargets(topic);
+ String targetId = null;
+ if ( potentialTargets != null && potentialTargets.size() > 0 ) {
+ final QueueConfigurationManager qcm = configuration.getQueueConfigurationManager();
+ if ( qcm == null ) {
+ resolver.revert();
+ return;
+ }
+ final QueueInfo info = qcm.getQueueInfo(topic);
+ logger.debug("Found queue {} for {}", info.queueConfiguration, topic);
+ targetId = caps.detectTarget(topic, vm, info);
+ if ( targetId != null ) {
+ properties.put(Job.PROPERTY_JOB_QUEUE_NAME, info.queueName);
+ properties.put(Job.PROPERTY_JOB_TARGET_INSTANCE, targetId);
+ properties.put(Job.PROPERTY_JOB_RETRIES, info.queueConfiguration.getMaxRetries());
+ }
+ }
+
+ properties.put(Job.PROPERTY_JOB_CREATED_INSTANCE, "old:" + Environment.APPLICATION_ID);
+ properties.put(ResourceResolver.PROPERTY_RESOURCE_TYPE, ResourceHelper.RESOURCE_TYPE_JOB);
+
+ final String jobId = configuration.getUniqueId(topic);
+ properties.put(ResourceHelper.PROPERTY_JOB_ID, jobId);
+ properties.remove(Job.PROPERTY_JOB_STARTED_TIME);
+
+ final String newPath = configuration.getUniquePath(targetId, topic, jobId, vm);
+ this.logger.debug("Moving 'old' job from {} to {}", jobResource.getPath(), newPath);
+
+ ResourceHelper.getOrCreateResource(resolver, newPath, properties);
+ resolver.delete(jobResource);
+ resolver.commit();
+ } catch (final InstantiationException ie) {
+ throw new PersistenceException("Exception while reading reasource: " + ie.getMessage(), ie.getCause());
+ }
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/support/BatchResourceRemover.java b/src/main/java/org/apache/sling/event/impl/support/BatchResourceRemover.java
new file mode 100644
index 0000000..f98b65c
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/support/BatchResourceRemover.java
@@ -0,0 +1,55 @@
+/*
+ * 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.event.impl.support;
+
+import org.apache.sling.api.resource.PersistenceException;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+
+/**
+ * This class can be used for batch removal of resources
+ */
+public class BatchResourceRemover {
+
+ private final int max;
+
+ private int count;
+
+ public BatchResourceRemover() {
+ this(50);
+ }
+
+ public BatchResourceRemover(final int batchSize) {
+ this.max = batchSize;
+ }
+
+ public void delete(final Resource rsrc )
+ throws PersistenceException {
+ final ResourceResolver resolver = rsrc.getResourceResolver();
+ for(final Resource child : rsrc.getChildren()) {
+ delete(child);
+ }
+ resolver.delete(rsrc);
+ count++;
+ if ( count >= max ) {
+ resolver.commit();
+ count = 0;
+ }
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/support/Environment.java b/src/main/java/org/apache/sling/event/impl/support/Environment.java
new file mode 100644
index 0000000..7e36b70
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/support/Environment.java
@@ -0,0 +1,35 @@
+/*
+ * 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.event.impl.support;
+
+import org.apache.sling.commons.threads.ThreadPool;
+
+/**
+ * This class provides "global settings"
+ * to all services, like the application id and the thread pool.
+ * @since 3.0
+ */
+public class Environment {
+
+ /** Global application id. */
+ public static String APPLICATION_ID;
+
+ /** Global thread pool. */
+ public static volatile ThreadPool THREAD_POOL;
+}
diff --git a/src/main/java/org/apache/sling/event/impl/support/ExactTopicMatcher.java b/src/main/java/org/apache/sling/event/impl/support/ExactTopicMatcher.java
new file mode 100644
index 0000000..1938b04
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/support/ExactTopicMatcher.java
@@ -0,0 +1,44 @@
+/*
+ * 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.event.impl.support;
+
+/**
+ * The topic must match exactly.
+ */
+public class ExactTopicMatcher implements TopicMatcher {
+
+ private final String topicName;
+
+ public ExactTopicMatcher(final String name) {
+ this.topicName = name;
+ }
+
+ /**
+ * @see org.apache.sling.event.impl.support.TopicMatcher#match(java.lang.String)
+ */
+ @Override
+ public String match(final String topic) {
+ return this.topicName.equals(topic) ? "" : null;
+ }
+
+ @Override
+ public String toString() {
+ return "ExactTopicMatcher [topic=" + topicName + "]";
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/sling/event/impl/support/PackageTopicMatcher.java b/src/main/java/org/apache/sling/event/impl/support/PackageTopicMatcher.java
new file mode 100644
index 0000000..371fd8b
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/support/PackageTopicMatcher.java
@@ -0,0 +1,51 @@
+/*
+ * 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.event.impl.support;
+
+
+/**
+ * Package matcher - the topic must be in the same package.
+ */
+public class PackageTopicMatcher implements TopicMatcher {
+
+ private final String packageName;
+
+ public PackageTopicMatcher(final String name) {
+ // remove last char and maybe a trailing slash
+ int lastPos = name.length() - 1;
+ if ( lastPos > 0 && name.charAt(lastPos - 1) == '/' ) {
+ lastPos--;
+ }
+ this.packageName = name.substring(0, lastPos);
+ }
+
+ /**
+ * @see org.apache.sling.event.impl.support.TopicMatcher#match(java.lang.String)
+ */
+ @Override
+ public String match(final String topic) {
+ final int pos = topic.lastIndexOf('/');
+ return pos > -1 && topic.substring(0, pos).equals(packageName) ? topic.substring(pos + 1) : null;
+ }
+
+ @Override
+ public String toString() {
+ return "PackageTopicMatcher [packageName=" + packageName + "]";
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/support/ResourceHelper.java b/src/main/java/org/apache/sling/event/impl/support/ResourceHelper.java
new file mode 100644
index 0000000..4383480
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/support/ResourceHelper.java
@@ -0,0 +1,426 @@
+/*
+ * 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.event.impl.support;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.BitSet;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.sling.api.resource.PersistenceException;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.ResourceUtil;
+import org.apache.sling.api.resource.ValueMap;
+import org.apache.sling.event.impl.jobs.JobImpl;
+import org.apache.sling.event.impl.jobs.config.MainQueueConfiguration;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.ScheduleInfo;
+import org.apache.sling.event.jobs.consumer.JobConsumer;
+import org.osgi.service.event.EventConstants;
+
+public abstract class ResourceHelper {
+
+ public static final String RESOURCE_TYPE_FOLDER = "sling:Folder";
+
+ public static final String RESOURCE_TYPE_JOB = "slingevent:Job";
+
+ /** We use the same resource type as for timed events. */
+ public static final String RESOURCE_TYPE_SCHEDULED_JOB = "slingevent:TimedEvent";
+
+ public static final String BUNDLE_EVENT_UPDATED = "org/osgi/framework/BundleEvent/UPDATED";
+
+ public static final String BUNDLE_EVENT_STARTED = "org/osgi/framework/BundleEvent/STARTED";
+
+ public static final String PROPERTY_SCHEDULE_NAME = "slingevent:scheduleName";
+ public static final String PROPERTY_SCHEDULE_INFO = "slingevent:scheduleInfo";
+ public static final String PROPERTY_SCHEDULE_INFO_TYPE = "slingevent:scheduleInfoType";
+ public static final String PROPERTY_SCHEDULE_SUSPENDED = "slingevent:scheduleSuspended";
+
+ public static final String PROPERTY_JOB_ID = "slingevent:eventId";
+ public static final String PROPERTY_JOB_TOPIC = "event.job.topic";
+ public static final String PROPERTY_DISTRIBUTE = "event.distribute";
+ public static final String PROPERTY_APPLICATION = "event.application";
+
+ /** List of ignored properties to write to the repository. */
+ private static final String[] IGNORE_PROPERTIES = new String[] {
+ ResourceHelper.PROPERTY_DISTRIBUTE,
+ ResourceHelper.PROPERTY_APPLICATION,
+ EventConstants.EVENT_TOPIC,
+ ResourceHelper.PROPERTY_JOB_ID,
+ JobImpl.PROPERTY_DELAY_OVERRIDE,
+ JobConsumer.PROPERTY_JOB_ASYNC_HANDLER,
+ Job.PROPERTY_JOB_PROGRESS_LOG,
+ Job.PROPERTY_JOB_PROGRESS_ETA,
+ Job.PROPERTY_JOB_PROGRESS_STEP,
+ Job.PROPERTY_JOB_PROGRESS_STEPS,
+ Job.PROPERTY_FINISHED_DATE,
+ JobImpl.PROPERTY_FINISHED_STATE,
+ Job.PROPERTY_RESULT_MESSAGE,
+ PROPERTY_SCHEDULE_INFO,
+ PROPERTY_SCHEDULE_NAME,
+ PROPERTY_SCHEDULE_INFO_TYPE,
+ PROPERTY_SCHEDULE_SUSPENDED
+ };
+
+ /**
+ * Check if this property should be ignored
+ */
+ public static boolean ignoreProperty(final String name) {
+ for(final String prop : IGNORE_PROPERTIES) {
+ if ( prop.equals(name) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Allowed characters for a node name */
+ private static final BitSet ALLOWED_CHARS;
+
+ /** Replacement characters for unallowed characters in a node name */
+ private static final char REPLACEMENT_CHAR = '_';
+
+ // Prepare the ALLOWED_CHARS bitset with bits indicating the unicode
+ // character index of allowed characters. We deliberately only support
+ // a subset of the actually allowed set of characters for nodes ...
+ static {
+ final String allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz0123456789_,.-+#!?$%&()=";
+ final BitSet allowedSet = new BitSet();
+ for (int i = 0; i < allowed.length(); i++) {
+ allowedSet.set(allowed.charAt(i));
+ }
+ ALLOWED_CHARS = allowedSet;
+ }
+
+ /**
+ * Filter the queue name for not allowed characters and replace them
+ * - with the exception of the main queue, which will not be filtered
+ * @param queueName the suggested queue name
+ * @return the filtered queue name
+ */
+ public static String filterQueueName(final String queueName) {
+ if ( queueName.equals(MainQueueConfiguration.MAIN_QUEUE_NAME) ) {
+ return queueName;
+ } else {
+ return ResourceHelper.filterName(queueName);
+ }
+ }
+
+ /**
+ * Filter the node name for not allowed characters and replace them.
+ * @param resourceName The suggested resource name.
+ * @return The filtered node name.
+ */
+ public static String filterName(final String resourceName) {
+ if ( resourceName == null ) {
+ return null;
+ }
+ final StringBuilder sb = new StringBuilder(resourceName.length());
+ char lastAdded = 0;
+
+ for(int i=0; i < resourceName.length(); i++) {
+ final char c = resourceName.charAt(i);
+ char toAdd = c;
+
+ if (!ALLOWED_CHARS.get(c)) {
+ if (lastAdded == REPLACEMENT_CHAR) {
+ // do not add several _ in a row
+ continue;
+ }
+ toAdd = REPLACEMENT_CHAR;
+
+ } else if(i == 0 && Character.isDigit(c)) {
+ sb.append(REPLACEMENT_CHAR);
+ }
+
+ sb.append(toAdd);
+ lastAdded = toAdd;
+ }
+
+ if (sb.length()==0) {
+ sb.append(REPLACEMENT_CHAR);
+ }
+
+ return sb.toString();
+ }
+
+ public static final String PROPERTY_MARKER_READ_ERROR_LIST = ResourceHelper.class.getName() + "/ReadErrorList";
+
+ public static Map<String, Object> cloneValueMap(final ValueMap vm) throws InstantiationException {
+ List<Exception> hasReadError = null;
+ try {
+ final Map<String, Object> result = new HashMap<String, Object>(vm);
+ for(final Map.Entry<String, Object> entry : result.entrySet()) {
+ if ( entry.getKey().equals(PROPERTY_SCHEDULE_INFO) ) {
+ final String[] infoArray = vm.get(entry.getKey(), String[].class);
+ if ( infoArray == null || infoArray.length == 0 ) {
+ if ( hasReadError == null ) {
+ hasReadError = new ArrayList<Exception>();
+ }
+ hasReadError.add(new Exception("Unable to deserialize property '" + entry.getKey() + "' : " + entry.getValue()));
+ } else {
+ final List<ScheduleInfo> infos = new ArrayList<ScheduleInfo>();
+ for(final String i : infoArray) {
+ final ScheduleInfoImpl info = ScheduleInfoImpl.deserialize(i);
+ if ( info != null ) {
+ infos.add(info);
+ }
+ }
+ if ( infos.size() < infoArray.length ) {
+ if ( hasReadError == null ) {
+ hasReadError = new ArrayList<Exception>();
+ }
+ hasReadError.add(new Exception("Unable to deserialize property '" + entry.getKey() + "' : " + Arrays.toString(infoArray)));
+ } else {
+ entry.setValue(infos);
+ }
+ }
+ }
+ if ( entry.getValue() instanceof InputStream ) {
+ final Object value = vm.get(entry.getKey(), Serializable.class);
+ if ( value != null ) {
+ entry.setValue(value);
+ } else {
+ if ( hasReadError == null ) {
+ hasReadError = new ArrayList<Exception>();
+ }
+ // let's find out which class might be missing
+ ObjectInputStream ois = null;
+ try {
+ ois = new ObjectInputStream((InputStream)entry.getValue());
+ ois.readObject();
+
+ hasReadError.add(new Exception("Unable to deserialize property '" + entry.getKey() + "'"));
+ } catch (final ClassNotFoundException cnfe) {
+ hasReadError.add(new Exception("Unable to deserialize property '" + entry.getKey() + "'", cnfe));
+ } catch (final IOException ioe) {
+ hasReadError.add(new RuntimeException("Unable to deserialize property '" + entry.getKey() + "'", ioe));
+ } finally {
+ if ( ois != null ) {
+ try {
+ ois.close();
+ } catch (IOException ignore) {
+ // ignore
+ }
+ }
+ }
+ }
+ }
+ }
+ if ( hasReadError != null ) {
+ result.put(PROPERTY_MARKER_READ_ERROR_LIST, hasReadError);
+ }
+ return result;
+ } catch ( final IllegalArgumentException iae) {
+ // the JCR implementation might throw an IAE if something goes wrong
+ throw (InstantiationException)new InstantiationException(iae.getMessage()).initCause(iae);
+ }
+ }
+
+ public static ValueMap getValueMap(final Resource resource) throws InstantiationException {
+ final ValueMap vm = ResourceUtil.getValueMap(resource);
+ // trigger full loading
+ try {
+ vm.size();
+ } catch ( final IllegalArgumentException iae) {
+ // the JCR implementation might throw an IAE if something goes wrong
+ throw (InstantiationException)new InstantiationException(iae.getMessage()).initCause(iae);
+ }
+ return vm;
+ }
+
+ public static void getOrCreateBasePath(final ResourceResolver resolver,
+ final String path)
+ throws PersistenceException {
+ getOrCreateResource(resolver,
+ path,
+ ResourceHelper.RESOURCE_TYPE_FOLDER,
+ ResourceHelper.RESOURCE_TYPE_FOLDER,
+ true);
+ }
+
+ public static Resource getOrCreateResource(final ResourceResolver resolver,
+ final String path, final Map<String, Object> props)
+ throws PersistenceException {
+ return getOrCreateResource(resolver,
+ path,
+ props,
+ ResourceHelper.RESOURCE_TYPE_FOLDER,
+ true);
+ }
+
+ /**
+ * Creates or gets the resource at the given path.
+ * This is a copy of Sling's API ResourceUtil method to avoid a dependency on the latest
+ * Sling API version! We can remove this once we update to Sling API > 2.8
+ * @param resolver The resource resolver to use for creation
+ * @param path The full path to be created
+ * @param resourceType The optional resource type of the final resource to create
+ * @param intermediateResourceType THe optional resource type of all intermediate resources
+ * @param autoCommit If set to true, a commit is performed after each resource creation.
+ */
+ private static Resource getOrCreateResource(
+ final ResourceResolver resolver,
+ final String path,
+ final String resourceType,
+ final String intermediateResourceType,
+ final boolean autoCommit)
+ throws PersistenceException {
+ final Map<String, Object> props;
+ if ( resourceType == null ) {
+ props = null;
+ } else {
+ props = Collections.singletonMap(ResourceResolver.PROPERTY_RESOURCE_TYPE, (Object)resourceType);
+ }
+ return getOrCreateResource(resolver, path, props, intermediateResourceType, autoCommit);
+ }
+
+ /**
+ * Creates or gets the resource at the given path.
+ * If an exception occurs, it retries the operation up to five times if autoCommit is enabled.
+ * In this case, {@link ResourceResolver#revert()} is called on the resolver before the
+ * creation is retried.
+ * This is a copy of Sling's API ResourceUtil method to avoid a dependency on the latest
+ * Sling API version! We can remove this once we update to Sling API > 2.8
+ *
+ * @param resolver The resource resolver to use for creation
+ * @param path The full path to be created
+ * @param resourceProperties The optional resource properties of the final resource to create
+ * @param intermediateResourceType THe optional resource type of all intermediate resources
+ * @param autoCommit If set to true, a commit is performed after each resource creation.
+ */
+ private static Resource getOrCreateResource(
+ final ResourceResolver resolver,
+ final String path,
+ final Map<String, Object> resourceProperties,
+ final String intermediateResourceType,
+ final boolean autoCommit)
+ throws PersistenceException {
+ PersistenceException mostRecentPE = null;
+ for(int i=0;i<5;i++) {
+ try {
+ return getOrCreateResourceInternal(resolver,
+ path,
+ resourceProperties,
+ intermediateResourceType,
+ autoCommit);
+ } catch ( final PersistenceException pe ) {
+ if ( autoCommit ) {
+ // in case of exception, revert to last clean state and retry
+ resolver.revert();
+ resolver.refresh();
+ mostRecentPE = pe;
+ } else {
+ throw pe;
+ }
+ }
+ }
+ throw mostRecentPE;
+ }
+
+ /**
+ * Creates or gets the resource at the given path.
+ * This is a copy of Sling's API ResourceUtil method to avoid a dependency on the latest
+ * Sling API version! We can remove this once we update to Sling API > 2.8
+ *
+ * @param resolver The resource resolver to use for creation
+ * @param path The full path to be created
+ * @param resourceProperties The optional resource properties of the final resource to create
+ * @param intermediateResourceType THe optional resource type of all intermediate resources
+ * @param autoCommit If set to true, a commit is performed after each resource creation.
+ */
+ private static Resource getOrCreateResourceInternal(
+ final ResourceResolver resolver,
+ final String path,
+ final Map<String, Object> resourceProperties,
+ final String intermediateResourceType,
+ final boolean autoCommit)
+ throws PersistenceException {
+ Resource rsrc = resolver.getResource(path);
+ if ( rsrc == null ) {
+ final int lastPos = path.lastIndexOf('/');
+ final String name = path.substring(lastPos + 1);
+
+ final Resource parentResource;
+ if ( lastPos == 0 ) {
+ parentResource = resolver.getResource("/");
+ } else {
+ final String parentPath = path.substring(0, lastPos);
+ parentResource = getOrCreateResource(resolver,
+ parentPath,
+ intermediateResourceType,
+ intermediateResourceType,
+ autoCommit);
+ }
+ if ( autoCommit ) {
+ resolver.refresh();
+ }
+ try {
+ int retry = 5;
+ while ( retry > 0 && rsrc == null ) {
+ rsrc = resolver.create(parentResource, name, resourceProperties);
+ // check for SNS
+ if ( !name.equals(rsrc.getName()) ) {
+ resolver.refresh();
+ resolver.delete(rsrc);
+ rsrc = resolver.getResource(parentResource, name);
+ }
+ retry--;
+ }
+ if ( rsrc == null ) {
+ throw new PersistenceException("Unable to create resource.");
+ }
+ } catch ( final PersistenceException pe ) {
+ // this could be thrown because someone else tried to create this
+ // node concurrently
+ resolver.refresh();
+ rsrc = resolver.getResource(parentResource, name);
+ if ( rsrc == null ) {
+ throw pe;
+ }
+ }
+ if ( autoCommit ) {
+ try {
+ resolver.commit();
+ resolver.refresh();
+ rsrc = resolver.getResource(parentResource, name);
+ } catch ( final PersistenceException pe ) {
+ // try again - maybe someone else did create the resource in the meantime
+ // or we ran into Jackrabbit's stale item exception in a clustered environment
+ resolver.revert();
+ resolver.refresh();
+ rsrc = resolver.getResource(parentResource, name);
+ if ( rsrc == null ) {
+ rsrc = resolver.create(parentResource, name, resourceProperties);
+ resolver.commit();
+ }
+ }
+ }
+ }
+ return rsrc;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/sling/event/impl/support/ScheduleInfoImpl.java b/src/main/java/org/apache/sling/event/impl/support/ScheduleInfoImpl.java
new file mode 100644
index 0000000..70b45f9
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/support/ScheduleInfoImpl.java
@@ -0,0 +1,421 @@
+/*
+ * 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.event.impl.support;
+
+import java.io.Serializable;
+import java.text.ParseException;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+
+import org.apache.sling.event.jobs.ScheduleInfo;
+import org.quartz.CronExpression;
+
+public class ScheduleInfoImpl implements ScheduleInfo, Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /** Serialization version. */
+ private static final String VERSION = "1";
+
+ public static ScheduleInfoImpl HOURLY(final int minutes) {
+ return new ScheduleInfoImpl(ScheduleType.HOURLY, -1, -1, minutes, null, -1, null);
+ }
+
+ public static ScheduleInfoImpl CRON(final String expr) {
+ return new ScheduleInfoImpl(ScheduleType.CRON, -1, -1, -1, null, -1, expr);
+ }
+
+ public static ScheduleInfoImpl AT(final Date at) {
+ return new ScheduleInfoImpl(ScheduleType.DATE, -1, -1, -1, at, -1, null);
+ }
+
+ public static ScheduleInfoImpl YEARLY(final int month, final int day, final int hour, final int minute) {
+ return new ScheduleInfoImpl(ScheduleType.YEARLY, day, hour, minute, null, month, null);
+ }
+
+ public static ScheduleInfoImpl MONTHLY(final int day, final int hour, final int minute) {
+ return new ScheduleInfoImpl(ScheduleType.MONTHLY, day, hour, minute, null, -1, null);
+ }
+
+ public static ScheduleInfoImpl WEEKLY(final int day, final int hour, final int minute) {
+ return new ScheduleInfoImpl(ScheduleType.WEEKLY, day, hour, minute, null, -1, null);
+ }
+
+ public static ScheduleInfoImpl DAILY(final int hour, final int minute) {
+ return new ScheduleInfoImpl(ScheduleType.DAILY, -1, hour, minute, null, -1, null);
+ }
+
+ private final ScheduleType scheduleType;
+
+ private final int dayOfWeek;
+
+ private final int hourOfDay;
+
+ private final int minuteOfHour;
+
+ private final Date at;
+
+ private final int monthOfYear;
+
+ private final String expression;
+
+ private ScheduleInfoImpl(final ScheduleType scheduleType,
+ final int dayOfWeek,
+ final int hourOfDay,
+ final int minuteOfHour,
+ final Date at,
+ final int monthOfYear,
+ final String expression) {
+ this.scheduleType = scheduleType;
+ this.dayOfWeek = dayOfWeek;
+ this.hourOfDay = hourOfDay;
+ this.minuteOfHour = minuteOfHour;
+ this.at = at;
+ this.monthOfYear = monthOfYear;
+ this.expression = expression;
+ }
+
+ public static ScheduleInfoImpl deserialize(final ScheduleType scheduleType, final String s) {
+ final String[] parts = s.split("|");
+ if ( scheduleType == ScheduleType.YEARLY && parts.length == 4 ) {
+ try {
+ return new ScheduleInfoImpl(scheduleType,
+ Integer.parseInt(parts[0]),
+ Integer.parseInt(parts[1]),
+ Integer.parseInt(parts[2]),
+ null,
+ Integer.parseInt(parts[3]),
+ null);
+ } catch ( final IllegalArgumentException iae) {
+ // ignore and return null
+ }
+ } else if ( scheduleType == ScheduleType.MONTHLY && parts.length == 3 ) {
+ try {
+ return new ScheduleInfoImpl(scheduleType,
+ Integer.parseInt(parts[0]),
+ Integer.parseInt(parts[1]),
+ Integer.parseInt(parts[2]),
+ null,
+ -1,
+ null);
+ } catch ( final IllegalArgumentException iae) {
+ // ignore and return null
+ }
+ } else if ( scheduleType == ScheduleType.WEEKLY && parts.length == 3 ) {
+ try {
+ return new ScheduleInfoImpl(scheduleType,
+ Integer.parseInt(parts[0]),
+ Integer.parseInt(parts[1]),
+ Integer.parseInt(parts[2]),
+ null,
+ -1,
+ null);
+ } catch ( final IllegalArgumentException iae) {
+ // ignore and return null
+ }
+ } else if ( scheduleType == ScheduleType.DAILY && parts.length == 2 ) {
+ try {
+ return new ScheduleInfoImpl(scheduleType,
+ -1,
+ Integer.parseInt(parts[0]),
+ Integer.parseInt(parts[1]),
+ null,
+ -1,
+ null);
+ } catch ( final IllegalArgumentException iae) {
+ // ignore and return null
+ }
+ } else if ( scheduleType == ScheduleType.HOURLY && parts.length == 1 ) {
+ try {
+ return new ScheduleInfoImpl(scheduleType,
+ -1,
+ -1,
+ Integer.parseInt(parts[0]),
+ null,
+ -1,
+ null);
+ } catch ( final IllegalArgumentException iae) {
+ // ignore and return null
+ }
+ } else if ( scheduleType == ScheduleType.CRON && parts.length == 1 ) {
+ try {
+ return new ScheduleInfoImpl(scheduleType,
+ -1,
+ -1,
+ -1,
+ null,
+ -1,
+ parts[0]);
+ } catch ( final IllegalArgumentException iae) {
+ // ignore and return null
+ }
+ }
+
+ return null;
+ }
+
+ public static ScheduleInfoImpl deserialize(final String s) {
+ final String[] parts = s.split("\\|");
+ if ( parts.length == 8 && parts[0].equals(VERSION) ) {
+ try {
+ return new ScheduleInfoImpl(ScheduleType.valueOf(parts[1]),
+ Integer.parseInt(parts[2]),
+ Integer.parseInt(parts[3]),
+ Integer.parseInt(parts[4]),
+ (parts[5].equals("null") ? null : new Date(Long.parseLong(parts[5]))),
+ Integer.parseInt(parts[6]),
+ (parts[7].equals("null") ? null : parts[7])
+ );
+ } catch ( final IllegalArgumentException iae) {
+ // ignore and return null
+ }
+ }
+ return null;
+ }
+
+ public String getSerializedString() {
+ final StringBuilder sb = new StringBuilder();
+ sb.append(VERSION);
+ sb.append("|");
+ sb.append(this.scheduleType.name());
+ sb.append("|");
+ sb.append(String.valueOf(this.dayOfWeek));
+ sb.append("|");
+ sb.append(String.valueOf(this.hourOfDay));
+ sb.append("|");
+ sb.append(String.valueOf(this.minuteOfHour));
+ sb.append("|");
+ if ( at == null ) {
+ sb.append("null");
+ } else {
+ sb.append(String.valueOf(at.getTime()));
+ }
+ sb.append("|");
+ sb.append(String.valueOf(this.monthOfYear));
+ sb.append("|");
+ if ( expression == null ) {
+ sb.append("null");
+ } else {
+ sb.append(String.valueOf(expression));
+ }
+ return sb.toString();
+ }
+
+ @Override
+ public ScheduleType getType() {
+ return this.scheduleType;
+ }
+
+ @Override
+ public Date getAt() {
+ return this.at;
+ }
+
+ @Override
+ public int getDayOfWeek() {
+ return (this.scheduleType == ScheduleType.WEEKLY ? this.dayOfWeek : -1);
+ }
+
+ @Override
+ public int getHourOfDay() {
+ return this.hourOfDay;
+ }
+
+ @Override
+ public int getMinuteOfHour() {
+ return this.minuteOfHour;
+ }
+
+ @Override
+ public String getExpression() {
+ return this.expression;
+ }
+
+ @Override
+ public int getMonthOfYear() {
+ return this.monthOfYear;
+ }
+
+ @Override
+ public int getDayOfMonth() {
+ return (this.scheduleType == ScheduleType.MONTHLY
+ || this.scheduleType == ScheduleType.YEARLY ? this.dayOfWeek : -1);
+ }
+
+ public void check(final List<String> errors) {
+ switch ( this.scheduleType ) {
+ case DAILY : if ( hourOfDay < 0 || hourOfDay > 23 || minuteOfHour < 0 || minuteOfHour > 59 ) {
+ errors.add("Wrong time information : " + minuteOfHour + ":" + minuteOfHour);
+ }
+ break;
+ case DATE : if ( at == null || at.getTime() <= System.currentTimeMillis() + 2000 ) {
+ errors.add("Date must be in the future : " + at);
+ }
+ break;
+ case HOURLY : if ( minuteOfHour < 0 || minuteOfHour > 59 ) {
+ errors.add("Minute must be between 0 and 59 : " + minuteOfHour);
+ }
+ break;
+ case WEEKLY : if ( hourOfDay < 0 || hourOfDay > 23 || minuteOfHour < 0 || minuteOfHour > 59 ) {
+ errors.add("Wrong time information : " + minuteOfHour + ":" + minuteOfHour);
+ }
+ if ( dayOfWeek < 1 || dayOfWeek > 7 ) {
+ errors.add("Day must be between 1 and 7 : " + dayOfWeek);
+ }
+ break;
+ case MONTHLY : if ( hourOfDay < 0 || hourOfDay > 23 || minuteOfHour < 0 || minuteOfHour > 59 ) {
+ errors.add("Wrong time information : " + minuteOfHour + ":" + minuteOfHour);
+ }
+ if ( dayOfWeek < 1 || dayOfWeek > 28 ) {
+ errors.add("Day must be between 1 and 28 : " + dayOfWeek);
+ }
+ break;
+ case YEARLY : if ( hourOfDay < 0 || hourOfDay > 23 || minuteOfHour < 0 || minuteOfHour > 59 ) {
+ errors.add("Wrong time information : " + minuteOfHour + ":" + minuteOfHour);
+ }
+ if ( dayOfWeek < 1 || dayOfWeek > 28 ) {
+ errors.add("Day must be between 1 and 28 : " + dayOfWeek);
+ }
+ if ( monthOfYear < 1 || monthOfYear > 12 ) {
+ errors.add("Month must be between 1 and 12 : " + dayOfWeek);
+ }
+ break;
+ case CRON : if ( expression == null ) {
+ errors.add("Expression must be specified.");
+ }
+ try {
+ new CronExpression(this.expression);
+ } catch (final ParseException e) {
+ errors.add("Expression must be valid: " + this.expression);
+ }
+ }
+ }
+
+ public Date getNextScheduledExecution() {
+ final Calendar now = Calendar.getInstance();
+ switch ( this.scheduleType ) {
+ case DATE : return this.at;
+ case DAILY : final Calendar next = Calendar.getInstance();
+ next.set(Calendar.HOUR_OF_DAY, this.hourOfDay);
+ next.set(Calendar.MINUTE, this.minuteOfHour);
+ if ( next.before(now) ) {
+ next.add(Calendar.DAY_OF_WEEK, 1);
+ }
+ return next.getTime();
+ case WEEKLY : final Calendar nextW = Calendar.getInstance();
+ nextW.set(Calendar.HOUR_OF_DAY, this.hourOfDay);
+ nextW.set(Calendar.MINUTE, this.minuteOfHour);
+ nextW.set(Calendar.DAY_OF_WEEK, this.dayOfWeek);
+ if ( nextW.before(now) ) {
+ nextW.add(Calendar.WEEK_OF_YEAR, 1);
+ }
+ return nextW.getTime();
+ case HOURLY : final Calendar nextH = Calendar.getInstance();
+ nextH.set(Calendar.MINUTE, this.minuteOfHour);
+ if ( nextH.before(now) ) {
+ nextH.add(Calendar.HOUR_OF_DAY, 1);
+ }
+ return nextH.getTime();
+ case MONTHLY : final Calendar nextM = Calendar.getInstance();
+ nextM.set(Calendar.HOUR_OF_DAY, this.hourOfDay);
+ nextM.set(Calendar.MINUTE, this.minuteOfHour);
+ nextM.set(Calendar.DAY_OF_MONTH, this.dayOfWeek);
+ if ( nextM.before(now) ) {
+ nextM.add(Calendar.MONTH, 1);
+ }
+ return nextM.getTime();
+ case YEARLY : final Calendar nextY = Calendar.getInstance();
+ nextY.set(Calendar.HOUR_OF_DAY, this.hourOfDay);
+ nextY.set(Calendar.MINUTE, this.minuteOfHour);
+ nextY.set(Calendar.DAY_OF_MONTH, this.dayOfWeek);
+ nextY.set(Calendar.MONTH, this.monthOfYear - 1);
+ if ( nextY.before(now) ) {
+ nextY.add(Calendar.YEAR, 1);
+ }
+ return nextY.getTime();
+ case CRON : try {
+ final CronExpression exp = new CronExpression(this.expression);
+ return exp.getNextValidTimeAfter(new Date());
+ } catch (final ParseException e) {
+ // as we check the expression in check() everything should be fine here
+ }
+ }
+ return null;
+ }
+
+ /**
+ * If the job is scheduled daily or weekly, return the cron expression
+ */
+ public String getCronExpression() {
+ if ( this.scheduleType == ScheduleType.DAILY ) {
+ final StringBuilder sb = new StringBuilder("0 ");
+ sb.append(String.valueOf(this.minuteOfHour));
+ sb.append(' ');
+ sb.append(String.valueOf(this.hourOfDay));
+ sb.append(" * * ?");
+ return sb.toString();
+ } else if ( this.scheduleType == ScheduleType.WEEKLY ) {
+ final StringBuilder sb = new StringBuilder("0 ");
+ sb.append(String.valueOf(this.minuteOfHour));
+ sb.append(' ');
+ sb.append(String.valueOf(this.hourOfDay));
+ sb.append(" ? * ");
+ sb.append(String.valueOf(this.dayOfWeek));
+ return sb.toString();
+ } else if ( this.scheduleType == ScheduleType.HOURLY ) {
+ final StringBuilder sb = new StringBuilder("0 ");
+ sb.append(String.valueOf(this.minuteOfHour));
+ sb.append(" * * * ?");
+ return sb.toString();
+ } else if ( this.scheduleType == ScheduleType.MONTHLY ) {
+ final StringBuilder sb = new StringBuilder("0 ");
+ sb.append(String.valueOf(this.minuteOfHour));
+ sb.append(' ');
+ sb.append(String.valueOf(this.hourOfDay));
+ sb.append(' ');
+ sb.append(String.valueOf(this.dayOfWeek));
+ sb.append(" * ?");
+ return sb.toString();
+ } else if ( this.scheduleType == ScheduleType.YEARLY ) {
+ final StringBuilder sb = new StringBuilder("0 ");
+ sb.append(String.valueOf(this.minuteOfHour));
+ sb.append(' ');
+ sb.append(String.valueOf(this.hourOfDay));
+ sb.append(' ');
+ sb.append(String.valueOf(this.dayOfWeek));
+ sb.append(' ');
+ sb.append(String.valueOf(this.monthOfYear - 1));
+ sb.append(" ?");
+ return sb.toString();
+ } else if ( this.scheduleType == ScheduleType.CRON ) {
+ return this.expression;
+ }
+ return null;
+ }
+
+ @Override
+ public String toString() {
+ return "ScheduleInfo [scheduleType=" + scheduleType
+ + ", dayOfWeek=" + dayOfWeek + ", hourOfDay=" + hourOfDay
+ + ", minuteOfHour=" + minuteOfHour + ", at=" + at
+ + ", monthOfYear=" + monthOfYear + ", expression=" + expression
+ + "]";
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/impl/support/SubPackagesTopicMatcher.java b/src/main/java/org/apache/sling/event/impl/support/SubPackagesTopicMatcher.java
new file mode 100644
index 0000000..a53ecf8
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/support/SubPackagesTopicMatcher.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.event.impl.support;
+
+
+/**
+ * Sub package matcher - the topic must be in the same package or a sub package.
+ */
+public class SubPackagesTopicMatcher implements TopicMatcher {
+
+ private final String packageName;
+
+ public SubPackagesTopicMatcher(final String name) {
+ // remove last char and maybe a trailing slash
+ int lastPos = name.length() - 1;
+ if ( lastPos > 0 && name.charAt(lastPos - 1) == '/' ) {
+ this.packageName = name.substring(0, lastPos);
+ } else {
+ this.packageName = name.substring(0, lastPos) + '/';
+ }
+ }
+
+ /**
+ * @see org.apache.sling.event.impl.support.TopicMatcher#match(java.lang.String)
+ */
+ @Override
+ public String match(final String topic) {
+ final int pos = topic.lastIndexOf('/');
+ return pos > -1 && topic.substring(0, pos + 1).startsWith(this.packageName) ? topic.substring(this.packageName.length()) : null;
+ }
+
+ @Override
+ public String toString() {
+ return "SubPackageMatcher [packageName=" + packageName + "]";
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/sling/event/impl/support/TopicMatcher.java b/src/main/java/org/apache/sling/event/impl/support/TopicMatcher.java
new file mode 100644
index 0000000..1378f4a
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/support/TopicMatcher.java
@@ -0,0 +1,28 @@
+/*
+ * 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.event.impl.support;
+
+/**
+ * Interface for topic matchers
+ */
+public interface TopicMatcher {
+
+ /** Check if the topic matches and return the variable part - null if not matching. */
+ String match(String topic);
+}
diff --git a/src/main/java/org/apache/sling/event/impl/support/TopicMatcherHelper.java b/src/main/java/org/apache/sling/event/impl/support/TopicMatcherHelper.java
new file mode 100644
index 0000000..b53a4c7
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/impl/support/TopicMatcherHelper.java
@@ -0,0 +1,69 @@
+/*
+ * 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.event.impl.support;
+
+public abstract class TopicMatcherHelper {
+
+ public static final TopicMatcher[] MATCH_ALL = new TopicMatcher[] {
+ new TopicMatcher() {
+
+ @Override
+ public String match(String topic) {
+ return topic;
+ }
+ }
+ };
+
+ /**
+ * Create matchers based on the topic parameters.
+ * If the topic parameters do not contain any definition
+ * <code>null</code> is returned.
+ */
+ public static TopicMatcher[] buildMatchers(final String[] topicsParam) {
+ final TopicMatcher[] matchers;
+ if ( topicsParam == null
+ || topicsParam.length == 0
+ || (topicsParam.length == 1 && (topicsParam[0] == null || topicsParam[0].length() == 0))) {
+ matchers = null;
+ } else {
+ final TopicMatcher[] newMatchers = new TopicMatcher[topicsParam.length];
+ for(int i=0; i < topicsParam.length; i++) {
+ String value = topicsParam[i];
+ if ( value != null ) {
+ value = value.trim();
+ }
+ if ( value != null && value.length() > 0 ) {
+ if ( value.equals("*") ) {
+ return MATCH_ALL;
+ }
+ if ( value.endsWith(".") ) {
+ newMatchers[i] = new PackageTopicMatcher(value);
+ } else if ( value.endsWith("*") ) {
+ newMatchers[i] = new SubPackagesTopicMatcher(value);
+ } else {
+ newMatchers[i] = new ExactTopicMatcher(value);
+ }
+ }
+ }
+ matchers = newMatchers;
+ }
+ return matchers;
+ }
+
+}
diff --git a/src/main/java/org/apache/sling/event/jobs/Job.java b/src/main/java/org/apache/sling/event/jobs/Job.java
new file mode 100644
index 0000000..710e730
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/jobs/Job.java
@@ -0,0 +1,351 @@
+/*
+ 1 * 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.event.jobs;
+
+import java.util.Calendar;
+import java.util.Set;
+
+import org.osgi.annotation.versioning.ProviderType;
+
+
+/**
+ * A job
+ *
+ *
+ * Property Types
+ *
+ * In general all scalar types and all serializable classes are supported as
+ * property types. However, in order for deseralizing classes these must be
+ * exported. Serializable classes are not searchable in the query either.
+ * Due to the above mentioned potential problems, it is advisable to not use
+ * custom classes as job properties, but rather use out of the box supported
+ * types in combination with collections.
+ *
+ * A resource provider might convert numbers to a different type, JCR is well-known
+ * for this behavior as it only supports long but neither integer nor short.
+ * Therefore if you are dealing with numbers, use the {@link #getProperty(String, Class)}
+ * method to get the correct type instead of directly casting it.
+ *
+ * @since 1.2
+ */
+@ProviderType
+public interface Job {
+
+ /**
+ * The name of the job queue processing this job.
+ * This property is set by the job handling when the job is processed.
+ * If this property is set by the client creating the job it's value is ignored
+ */
+ String PROPERTY_JOB_QUEUE_NAME = "event.job.queuename";
+
+ /**
+ * The property to track the retry count for jobs. Value is of type Integer.
+ * On first execution the value of this property is zero.
+ * This property is managed by the job handling.
+ * If this property is set by the client creating the job it's value is ignored
+ */
+ String PROPERTY_JOB_RETRY_COUNT = "event.job.retrycount";
+
+ /**
+ * The property to track the retry maximum retry count for jobs. Value is of type Integer.
+ * This property is managed by the job handling.
+ * If this property is set by the client creating the job it's value is ignored
+ */
+ String PROPERTY_JOB_RETRIES = "event.job.retries";
+
+ /**
+ * This property is set by the job handling and contains a calendar object
+ * specifying the date and time when this job has been created.
+ * If this property is set by the client creating the job it's value is ignored
+ */
+ String PROPERTY_JOB_CREATED = "slingevent:created";
+
+ /**
+ * This property is set by the job handling and contains the Sling instance ID
+ * of the instance where this job has been created.
+ */
+ String PROPERTY_JOB_CREATED_INSTANCE = "slingevent:application";
+
+ /**
+ * This property is set by the job handling and contains the Sling instance ID
+ * of the instance where this job should be processed.
+ */
+ String PROPERTY_JOB_TARGET_INSTANCE = "event.job.application";
+
+ /**
+ * This property is set by the job handling and contains a calendar object
+ * specifying the date and time when this job has been started.
+ * This property is only set if the job is currently in processing
+ * If this property is set by the client creating the job it's value is ignored
+ */
+ String PROPERTY_JOB_STARTED_TIME = "event.job.started.time";
+
+ /**
+ * The property to set a retry delay. Value is of type Long and specifies milliseconds.
+ * This property can be used to override the retry delay from the queue configuration.
+ * But it should only be used very rarely as the queue configuration should be the one
+ * in charge.
+ */
+ String PROPERTY_JOB_RETRY_DELAY = "event.job.retrydelay";
+
+ /**
+ * This property contains the optional output log of a job consumer.
+ * The value of this property is a string array.
+ * This property is read-only and can't be specified when the job is created.
+ * @since 1.3
+ */
+ String PROPERTY_JOB_PROGRESS_LOG = "slingevent:progressLog";
+
+ /**
+ * This property contains the optional ETA for a job.
+ * The value of this property is a {@link Calendar} object.
+ * This property is read-only and can't be specified when the job is created.
+ * @since 1.3
+ */
+ String PROPERTY_JOB_PROGRESS_ETA = "slingevent:progressETA";
+
+ /**
+ * This property contains optional progress information about a job,
+ * the number of steps the job consumer will perform. Each step is
+ * assumed to consume roughly the same amount if time.
+ * The value of this property is an integer.
+ * This property is read-only and can't be specified when the job is created.
+ * @since 1.3
+ */
+ String PROPERTY_JOB_PROGRESS_STEPS = "slingevent:progressSteps";
+
+ /**
+ * This property contains optional progress information about a job,
+ * the number of completed steps.
+ * The value of this property is an integer.
+ * This property is read-only and can't be specified when the job is created.
+ * @since 1.3
+ */
+ String PROPERTY_JOB_PROGRESS_STEP = "slingevent:progressStep";
+
+ /**
+ * This property contains the optional result message of a job consumer.
+ * The value of this property is a string.
+ * This property is read-only and can't be specified when the job is created.
+ * @since 1.3
+ */
+ String PROPERTY_RESULT_MESSAGE = "slingevent:resultMessage";
+
+ /**
+ * This property contains the finished date once a job is marked as finished.
+ * The value of this property is a {@link Calendar} object.
+ * This property is read-only and can't be specified when the job is created.
+ * @since 1.3
+ */
+ String PROPERTY_FINISHED_DATE = "slingevent:finishedDate";
+
+ /**
+ * This is an optional property containing a human readable title for
+ * the job
+ * @since 1.3
+ */
+ String PROPERTY_JOB_TITLE = "slingevent:jobTitle";
+
+ /**
+ * This is an optional property containing a human readable description for
+ * the job
+ * @since 1.3
+ */
+ String PROPERTY_JOB_DESCRIPTION = "slingevent:jobDescription";
+
+ /**
+ * The current job state.
+ * @since 1.3
+ */
+ enum JobState {
+ QUEUED, // waiting in queue after adding or for restart after failing
+ ACTIVE, // job is currently in processing
+ SUCCEEDED, // processing finished successfully
+ STOPPED, // processing was stopped by a user
+ GIVEN_UP, // number of retries reached
+ ERROR, // processing signaled CANCELLED or throw an exception
+ DROPPED // dropped jobs
+ };
+
+ /**
+ * The job topic.
+ * @return The job topic
+ */
+ String getTopic();
+
+ /**
+ * Unique job ID.
+ * @return The unique job ID.
+ */
+ String getId();
+
+ /**
+ * Get the value of a property.
+ * @param name The property name
+ * @return The value of the property or <code>null</code>
+ */
+ Object getProperty(String name);
+
+ /**
+ * Get all property names.
+ * @return A set of property names.
+ */
+ Set<String> getPropertyNames();
+
+ /**
+ * Get a named property and convert it into the given type.
+ * This method does not support conversion into a primitive type or an
+ * array of a primitive type. It should return <code>null</code> in this
+ * case.
+ *
+ * @param name The name of the property
+ * @param type The class of the type
+ * @param <T> The class of the type
+ * @return Return named value converted to type T or <code>null</code> if
+ * non existing or can't be converted.
+ */
+ <T> T getProperty(String name, Class<T> type);
+
+ /**
+ * Get a named property and convert it into the given type.
+ * This method does not support conversion into a primitive type or an
+ * array of a primitive type. It should return the default value in this
+ * case.
+ *
+ * @param name The name of the property
+ * @param defaultValue The default value to use if the named property does
+ * not exist or cannot be converted to the requested type. The
+ * default value is also used to define the type to convert the
+ * value to. If this is <code>null</code> any existing property is
+ * not converted.
+ * @param <T> The class of the type
+ * @return Return named value converted to type T or the default value if
+ * non existing or can't be converted.
+ */
+ <T> T getProperty(String name, T defaultValue);
+
+ /**
+ * On first execution the value of this property is zero.
+ * This property is managed by the job handling.
+ * @return The retry count.
+ */
+ int getRetryCount();
+
+ /**
+ * The property to track the retry maximum retry count for jobs.
+ * This property is managed by the job handling.
+ * @return The number of retries.
+ */
+ int getNumberOfRetries();
+
+ /**
+ * The name of the job queue processing this job.
+ * This property is set by the job handling when the job is processed.
+ * @return The queue name or <code>null</code>
+ */
+ String getQueueName();
+
+ /**
+ * This property is set by the job handling and contains the Sling instance ID
+ * of the instance where this job should be processed.
+ * @return The sling ID or <code>null</code>
+ */
+ String getTargetInstance();
+
+ /**
+ * This property is set by the job handling and contains a calendar object
+ * specifying the date and time when this job has been started.
+ * This property is only set if the job is currently in processing
+ * @return The time the processing started or {@code null}.
+ */
+ Calendar getProcessingStarted();
+
+ /**
+ * This property is set by the job handling and contains a calendar object
+ * specifying the date and time when this job has been created.
+ * @return The time the job was created.
+ */
+ Calendar getCreated();
+
+ /**
+ * This property is set by the job handling and contains the Sling instance ID
+ * of the instance where this job has been created.
+ * @return The instance id the job was created on
+ */
+ String getCreatedInstance();
+
+ /**
+ * Get the job state
+ * @return The job state.
+ * @since 1.3
+ */
+ JobState getJobState();
+
+ /**
+ * If the job is cancelled or succeeded, this method will return the finish date.
+ * @return The finish date or <code>null</code>
+ * @since 1.3
+ */
+ Calendar getFinishedDate();
+
+ /**
+ * This method returns the message from the last job processing, regardless
+ * whether the processing failed, succeeded or was cancelled. The message
+ * is optional and can be set by a job consumer.
+ * @return The result message or <code>null</code>
+ * @since 1.3
+ */
+ String getResultMessage();
+
+ /**
+ * This method returns the optional progress log from the last job
+ * processing. The log is optional and can be set by a job consumer.
+ * @return The log or <code>null</code>
+ * @since 1.3
+ */
+ String[] getProgressLog();
+
+ /**
+ * If the job is in processing, return the optional progress step
+ * count if available. The progress information is optional and
+ * can be set by a job consumer.
+ * @return The progress step count or <code>-1</code>.
+ * @since 1.3
+ */
+ int getProgressStepCount();
+
+ /**
+ * If the job is in processing, return the optional information
+ * about the finished steps. This progress information is optional
+ * and can be set by a job consumer.
+ * In combination with {@link #getProgressStepCount()} this can
+ * be used to calculate a progress bar.
+ * @return The number of the finished progress step or <code>0</code>
+ * @since 1.3
+ */
+ int getFinishedProgressStep();
+
+ /**
+ * If the job is in processing, return the optional ETA for this job.
+ * The progress information is optional and can be set by a job consumer.
+ * @since 1.3
+ * @return The estimated ETA or <code>null</code>
+ */
+ Calendar getProgressETA();
+}
diff --git a/src/main/java/org/apache/sling/event/jobs/JobBuilder.java b/src/main/java/org/apache/sling/event/jobs/JobBuilder.java
new file mode 100644
index 0000000..1455a61
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/jobs/JobBuilder.java
@@ -0,0 +1,161 @@
+/*
+ * 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.event.jobs;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+import org.osgi.annotation.versioning.ProviderType;
+
+/**
+ * This is a builder interface to build jobs and scheduled jobs.
+ * Instances of this class can be retrieved using {@link JobManager#createJob(String)}
+ *
+ * @since 1.3
+ */
+@ProviderType
+public interface JobBuilder {
+
+ /**
+ * Set the optional configuration properties for the job.
+ * @param props The properties of the job. All values must be {@code java.io.Serializable}.
+ * @return The job builder to continue building.
+ */
+ JobBuilder properties(final Map<String, Object> props);
+
+ /**
+ * Add the job.
+ * @return The job or <code>null</code>
+ * @see JobManager#addJob(String, Map)
+ */
+ Job add();
+
+ /**
+ * Add the job.
+ * @param errors Optional list which will be filled with error messages.
+ * @return The job or <code>null</code>
+ * @see JobManager#addJob(String, Map)
+ */
+ Job add(final List<String> errors);
+
+ /**
+ * Schedule the job
+ * @return A schedule builder to schedule the jobs
+ */
+ ScheduleBuilder schedule();
+
+ /**
+ * This is a builder interface for creating schedule information
+ */
+ public interface ScheduleBuilder {
+
+ /**
+ * Suspend this scheduling by default.
+ * Invoking this method several times has the same effect as calling it just once.
+ * @return The schedule builder to continue building.
+ */
+ ScheduleBuilder suspend();
+
+ /**
+ * Schedule the job hourly at the given minute.
+ * If the minutes argument is less than 0 or higher than 59, the job can't be scheduled.
+ * @param minute Between 0 and 59.
+ * @return The schedule builder to continue building.
+ */
+ ScheduleBuilder hourly(final int minute);
+
+ /**
+ * Schedule the job daily at the given time.
+ * If a value less than zero for hour or minute is specified or a value higher than 23 for hour or
+ * a value higher than 59 for minute than the job can't be scheduled.
+ * @param hour Hour of the day ranging from 0 to 23.
+ * @param minute Minute of the hour ranging from 0 to 59.
+ * @return The schedule builder to continue building.
+ */
+ ScheduleBuilder daily(final int hour, final int minute);
+
+ /**
+ * Schedule the job weekly, the time needs to be specified in addition.
+ * If a value lower than 1 or higher than 7 is used for the day, the job can't be scheduled.
+ * If a value less than zero for hour or minute is specified or a value higher than 23 for hour or
+ * a value higher than 59 for minute than the job can't be scheduled.
+ * @param day Day of the week, 1:Sunday, 2:Monday, ... 7:Saturday.
+ * @param hour Hour of the day ranging from 0 to 23.
+ * @param minute Minute of the hour ranging from 0 to 59.
+ * @return The schedule builder to continue building.
+ */
+ ScheduleBuilder weekly(final int day, final int hour, final int minute);
+
+ /**
+ * Schedule the job monthly, the time needs to be specified in addition.
+ * If a value lower than 1 or higher than 28 is used for the day, the job can't be scheduled.
+ * If a value less than zero for hour or minute is specified or a value higher than 23 for hour or
+ * a value higher than 59 for minute than the job can't be scheduled.
+ * @param day Day of the month from 1 to 28.
+ * @param hour Hour of the day ranging from 0 to 23.
+ * @param minute Minute of the hour ranging from 0 to 59.
+ * @return The schedule builder to continue building.
+ */
+ ScheduleBuilder monthly(final int day, final int hour, final int minute);
+
+ /**
+ * Schedule the job yearly, the time needs to be specified in addition.
+ * If a value lower than 1 or higher than 12 is used for the month, the job can't be scheduled.
+ * If a value lower than 1 or higher than 28 is used for the day, the job can't be scheduled.
+ * If a value less than zero for hour or minute is specified or a value higher than 23 for hour or
+ * a value higher than 59 for minute than the job can't be scheduled.
+ * @param month Month of the year from 1 to 12.
+ * @param day Day of the month from 1 to 28.
+ * @param hour Hour of the day ranging from 0 to 23.
+ * @param minute Minute of the hour ranging from 0 to 59.
+ * @return The schedule builder to continue building.
+ */
+ ScheduleBuilder yearly(final int month, final int day, final int hour, final int minute);
+
+ /**
+ * Schedule the job for a specific date.
+ * If no date or a a date in the past is provided, the job can't be scheduled.
+ * @param date The date
+ * @return The schedule builder to continue building.
+ */
+ ScheduleBuilder at(final Date date);
+
+ /**
+ * Schedule the job for according to the cron expression.
+ * If no expression is specified, the job can't be scheduled.
+ * @param expression The cron expression
+ * @return The schedule builder to continue building.
+ */
+ ScheduleBuilder cron(final String expression);
+
+ /**
+ * Finally add the job to the schedule
+ * @return Returns the info object if the job could be scheduled, <code>null</code>otherwise.
+ */
+ ScheduledJobInfo add();
+
+ /**
+ * Finally add the job to the schedule
+ * @param errors Optional list which will be filled with error messages.
+ * @return Returns the info object if the job could be scheduled, <code>null</code>otherwise.
+ */
+ ScheduledJobInfo add(final List<String> errors);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/sling/event/jobs/JobManager.java b/src/main/java/org/apache/sling/event/jobs/JobManager.java
new file mode 100644
index 0000000..105b611
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/jobs/JobManager.java
@@ -0,0 +1,223 @@
+/*
+ * 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.event.jobs;
+
+import java.util.Collection;
+import java.util.Map;
+
+import org.osgi.annotation.versioning.ProviderType;
+
+
+/**
+ * The job manager is the heart of the job processing.
+ * <p>
+ * The job manager allows to create new jobs, search for
+ * jobs and get statistics about the current state.
+ * <p>
+ * The terminology used in the job manager is slightly
+ * different from common terminology:
+ * Each job has a topic and a topic is associated with
+ * a queue. Queues can be created through configuration
+ * and each queue can process one or more topics.
+ *
+ * @since 3.0
+ */
+@ProviderType
+public interface JobManager {
+
+ /**
+ * Return statistics information about all queues.
+ * @return The statistics.
+ */
+ Statistics getStatistics();
+
+ /**
+ * Return statistics information about job topics.
+ * @return The statistics for all topics.
+ */
+ Iterable<TopicStatistics> getTopicStatistics();
+
+ /**
+ * Return a queue with a specific name (if running)
+ * @param name The queue name
+ * @return The queue or <code>null</code>
+ */
+ Queue getQueue(String name);
+
+ /**
+ * Return an iterator for all available queues.
+ * @return An iterator for all queues.
+ */
+ Iterable<Queue> getQueues();
+
+ /**
+ * The requested job types for the query.
+ * This can either be all (unfinished) jobs, all activated (started) or all queued jobs.
+ */
+ enum QueryType {
+ ALL, // all means all active and all queued
+ ACTIVE,
+ QUEUED,
+ HISTORY, // returns the complete history of cancelled and succeeded jobs (if available)
+ CANCELLED, // history of cancelled jobs (STOPPED, GIVEN_UP, ERROR, DROPPED)
+ SUCCEEDED, // history of succeeded jobs
+ STOPPED, // history of stopped jobs
+ GIVEN_UP, // history of given up jobs
+ ERROR, // history of jobs signaled CANCELLED or throw an exception
+ DROPPED // history of dropped jobs
+ }
+
+ /**
+ * Add a new job
+ *
+ * If the topic is <code>null</code> or illegal, no job is created and <code>null</code> is returned.
+ * If properties are provided, all of them must be serializable. If there are non serializable
+ * objects in the properties, no job is created and <code>null</code> is returned.
+ * A job topic is a hierarchical name separated by dashes, each part has to start with a letter,
+ * allowed characters are letters, numbers and the underscore.
+ *
+ * The returned job object is a snapshot of the job state taken at the time of creation. Updates
+ * to the job state are not reflected and the client needs to get a new job object using the job id.
+ *
+ * If the queue for processing this job is configured to drop the job, <code>null</code> is returned
+ * as well.
+ *
+ * @param topic The required job topic.
+ * @param properties Optional job properties. The properties must be serializable.
+ * @return The new job - or <code>null</code> if the job could not be created.
+ * @since 1.2
+ */
+ Job addJob(String topic, Map<String, Object> properties);
+
+ /**
+ * Return a job based on the unique id.
+ *
+ * The returned job object is a snapshot of the job state taken at the time of the call. Updates
+ * to the job state are not reflected and the client needs to get a new job object using the job id.
+ *
+ * @param jobId The unique identifier from {@link Job#getId()}
+ * @return A job or <code>null</code>
+ * @since 1.2
+ */
+ Job getJobById(String jobId);
+
+ /**
+ * Removes the job even if it is currently in processing.
+ *
+ * If the job exists and is not in processing, it gets removed from the processing queue.
+ * If the job exists and is in processing, it is removed from the persistence layer,
+ * however processing is not stopped.
+ *
+ * @param jobId The unique identifier from {@link Job#getId()}
+ * @return <code>true</code> if the job could be removed or does not exist anymore.
+ * <code>false</code> otherwise.
+ * @since 1.2
+ */
+ boolean removeJobById(String jobId);
+
+ /**
+ * Find a job - either queued or active.
+ *
+ * This method searches for a job with the given topic and filter properties. If more than one
+ * job matches, the first one found is returned which could be any of the matching jobs.
+ *
+ * The returned job object is a snapshot of the job state taken at the time of the call. Updates
+ * to the job state are not reflected and the client needs to get a new job object using the job id.
+ *
+ * @param topic Topic is required.
+ * @param template The map acts like a template. The searched job
+ * must match the template (AND query).
+ * @return A job or <code>null</code>
+ * @since 1.2
+ */
+ Job getJob(String topic, Map<String, Object> template);
+
+ /**
+ * Return all jobs of a given type.
+ *
+ * Based on the type parameter, either the history of jobs can be returned or unfinished jobs. The type
+ * parameter can further specify which category of jobs should be returned: for the history either
+ * succeeded jobs, cancelled jobs or both in combination can be returned. For unfinished jobs, either
+ * queued jobs, started jobs or the combination can be returned.
+ * If the history is returned, the result set is sorted in descending order, listening the newest entry
+ * first. For unfinished jobs, the result set is sorted in ascending order.
+ *
+ * The returned job objects are a snapshot of the jobs state taken at the time of the call. Updates
+ * to the job states are not reflected and the client needs to get new job objects.
+ *
+ * @param type Required parameter for the type. See above.
+ * @param topic Topic can be used as a filter, if it is non-null, only jobs with this topic will be returned.
+ * @param limit A positive number indicating the maximum number of jobs returned by the iterator. A value
+ * of zero or less indicates that all jobs should be returned.
+ * @param templates A list of filter property maps. Each map acts like a template. The searched job
+ * must match the template (AND query). By providing several maps, different filters
+ * are possible (OR query).
+ * @return A collection of jobs - the collection might be empty.
+ * @since 1.2
+ */
+ Collection<Job> findJobs(QueryType type, String topic, long limit, Map<String, Object>... templates);
+
+ /**
+ * Stop a job.
+ * When a job is stopped and the job consumer supports stopping the job processing, it is up
+ * to the job consumer how the stopping is handled. The job can be marked as finished successful,
+ * permanently failed or being retried.
+ * @param jobId The job id
+ * @since 1.3
+ */
+ void stopJobById(String jobId);
+
+ /**
+ * Retry a cancelled job.
+ * If a job has failed permanently it can be requeued with this method. The job will be
+ * removed from the history and put into the queue again. The new job will get a new job id.
+ * For all other jobs calling this method has no effect and it simply returns <code>null</code>.
+ * @param jobId The job id.
+ * @return If the job is requeued, the new job object otherwise <code>null</code>
+ */
+ Job retryJobById(String jobId);
+
+ /**
+ * Fluent API to create, start and schedule new jobs
+ * @param topic Required topic
+ * @return A job builder
+ * @since 1.3
+ */
+ JobBuilder createJob(final String topic);
+
+ /**
+ * Return all available job schedules.
+ * @return A collection of scheduled job infos
+ * @since 1.3
+ */
+ Collection<ScheduledJobInfo> getScheduledJobs();
+
+ /**
+ * Return all matching available job schedules.
+ * @param topic Topic can be used as a filter, if it is non-null, only jobs with this topic will be returned.
+ * @param limit A positive number indicating the maximum number of jobs returned by the iterator. A value
+ * of zero or less indicates that all jobs should be returned.
+ * @param templates A list of filter property maps. Each map acts like a template. The searched job
+ * must match the template (AND query). By providing several maps, different filters
+ * are possible (OR query).
+ * @return All matching scheduled job infos.
+ * @since 1.4
+ */
+ Collection<ScheduledJobInfo> getScheduledJobs(String topic, long limit, Map<String, Object>... templates);
+}
diff --git a/src/main/java/org/apache/sling/event/jobs/NotificationConstants.java b/src/main/java/org/apache/sling/event/jobs/NotificationConstants.java
new file mode 100644
index 0000000..58c7c55
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/jobs/NotificationConstants.java
@@ -0,0 +1,106 @@
+/*
+ * 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.event.jobs;
+
+
+/**
+ * This class contains constants for event notifications.
+ *
+ * Notifications for jobs can only be received on the instance where the job
+ * action is taking place. They are not send to other instances using
+ * remove events.
+ *
+ * @since 1.3
+ */
+public abstract class NotificationConstants {
+
+ /**
+ * Asynchronous notification event when a job is started.
+ * The property {@link #NOTIFICATION_PROPERTY_JOB_TOPIC} contains the job topic,
+ * the property {@link #NOTIFICATION_PROPERTY_JOB_ID} contains the unique job id.
+ * The time stamp of the event (as a Long) is available from the property
+ * {@link org.osgi.service.event.EventConstants#TIMESTAMP}.
+ * The payload of the job is available as additional job specific properties.
+ */
+ public static final String TOPIC_JOB_STARTED = "org/apache/sling/event/notification/job/START";
+
+ /**
+ * Asynchronous notification event when a job is finished.
+ * The property {@link #NOTIFICATION_PROPERTY_JOB_TOPIC} contains the job topic,
+ * the property {@link #NOTIFICATION_PROPERTY_JOB_ID} contains the unique job id.
+ * The time stamp of the event (as a Long) is available from the property
+ * {@link org.osgi.service.event.EventConstants#TIMESTAMP}.
+ * The payload of the job is available as additional job specific properties.
+ */
+ public static final String TOPIC_JOB_FINISHED = "org/apache/sling/event/notification/job/FINISHED";
+
+ /**
+ * Asynchronous notification event when a job failed.
+ * If a job execution fails, it is rescheduled for another try.
+ * The property {@link #NOTIFICATION_PROPERTY_JOB_TOPIC} contains the job topic,
+ * the property {@link #NOTIFICATION_PROPERTY_JOB_ID} contains the unique job id.
+ * The time stamp of the event (as a Long) is available from the property
+ * {@link org.osgi.service.event.EventConstants#TIMESTAMP}.
+ * The payload of the job is available as additional job specific properties.
+ */
+ public static final String TOPIC_JOB_FAILED = "org/apache/sling/event/notification/job/FAILED";
+
+ /**
+ * Asynchronous notification event when a job is cancelled.
+ * If a job execution is cancelled it is not rescheduled.
+ * The property {@link #NOTIFICATION_PROPERTY_JOB_TOPIC} contains the job topic,
+ * the property {@link #NOTIFICATION_PROPERTY_JOB_ID} contains the unique job id.
+ * The time stamp of the event (as a Long) is available from the property
+ * {@link org.osgi.service.event.EventConstants#TIMESTAMP}.
+ * The payload of the job is available as additional job specific properties.
+ */
+ public static final String TOPIC_JOB_CANCELLED = "org/apache/sling/event/notification/job/CANCELLED";
+
+ /**
+ * Asynchronous notification event when a job is permanently removed.
+ * The property {@link #NOTIFICATION_PROPERTY_JOB_TOPIC} contains the job topic,
+ * the property {@link #NOTIFICATION_PROPERTY_JOB_ID} contains the unique job id.
+ * The payload of the job is available as additional job specific properties.
+ */
+ public static final String TOPIC_JOB_REMOVED = "org/apache/sling/event/notification/job/REMOVED";
+
+ /**
+ * Asynchronous notification event when a job is added.
+ * The property {@link #NOTIFICATION_PROPERTY_JOB_TOPIC} contains the job topic,
+ * the property {@link #NOTIFICATION_PROPERTY_JOB_ID} contains the unique job id.
+ * @since 1.6
+ */
+ public static final String TOPIC_JOB_ADDED = "org/apache/sling/event/notification/job/ADDED";
+
+ /**
+ * Property containing the job topic. Value is of type String.
+ * @see Job#getTopic()
+ */
+ public static final String NOTIFICATION_PROPERTY_JOB_TOPIC = "event.job.topic";
+
+ /**
+ * Property containing the unique job ID. Value is of type String.
+ * @see Job#getId()
+ */
+ public static final String NOTIFICATION_PROPERTY_JOB_ID = "slingevent:eventId";
+
+ private NotificationConstants() {
+ // avoid instantiation
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/apache/sling/event/jobs/Queue.java b/src/main/java/org/apache/sling/event/jobs/Queue.java
new file mode 100644
index 0000000..2134aba
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/jobs/Queue.java
@@ -0,0 +1,97 @@
+/*
+ * 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.event.jobs;
+
+import org.osgi.annotation.versioning.ProviderType;
+
+
+/**
+ * This is a job queue processing job events.
+ * @since 3.0
+ */
+@ProviderType
+public interface Queue {
+
+ /**
+ * Get the queue name.
+ * @return The queue name
+ */
+ String getName();
+
+ /**
+ * Return statistics information about this queue.
+ * @return The queue statistics
+ */
+ Statistics getStatistics();
+
+ /**
+ * Get the corresponding configuration.
+ * @return The queue configuration
+ */
+ QueueConfiguration getConfiguration();
+
+ /**
+ * Suspend the queue - when a queue is suspended it stops processing
+ * jobs - however already started jobs are finished (but not rescheduled).
+ * Depending on the queue implementation, the queue is only suspended
+ * for a specific time.
+ * A queue can be resumed with {@link #resume()}.
+ */
+ void suspend();
+
+ /**
+ * Resume a suspended queue. {@link #suspend()}. If the queue is not
+ * suspended, calling this method has no effect.
+ * Depending on the queue implementation, if a job failed a job queue might
+ * sleep for a configured time, before a new job is processed. By calling this
+ * method, the job queue can be woken up and force an immediate reprocessing.
+ * This feature is only supported by ordered queues at the moment. If a queue
+ * does not support this feature, calling this method has only an effect if
+ * the queue is really suspended.
+ */
+ void resume();
+
+ /**
+ * Is the queue currently suspended?
+ * @return {code true} if the queue is supsended
+ */
+ boolean isSuspended();
+
+ /**
+ * Remove all outstanding jobs and delete them. This actually cancels
+ * all outstanding jobs.
+ */
+ void removeAll();
+
+ /**
+ * Return some information about the current state of the queue. This
+ * method is meant to see the internal state of the queue for debugging
+ * or monitoring purposes.
+ * @return Additional state info
+ */
+ String getStateInfo();
+
+ /**
+ * For monitoring purposes and possible extensions from the different
+ * queue types. This method allows to query state information.
+ * @param key The key for the state
+ * @return The state or {@code null}.
+ */
+ Object getState(final String key);
+}
diff --git a/src/main/java/org/apache/sling/event/jobs/QueueConfiguration.java b/src/main/java/org/apache/sling/event/jobs/QueueConfiguration.java
new file mode 100644
index 0000000..8990289
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/jobs/QueueConfiguration.java
@@ -0,0 +1,111 @@
+/*
+ * 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.event.jobs;
+
+import org.osgi.annotation.versioning.ProviderType;
+
+
+/**
+ * The configuration of a queue.
+ * @since 3.0
+ */
+@ProviderType
+public interface QueueConfiguration {
+
+ /** The queue type. */
+ static enum Type {
+ UNORDERED, // unordered, parallel processing (push)
+ ORDERED, // ordered, FIFO (push)
+ TOPIC_ROUND_ROBIN // unordered, parallel processing, executed based on topic (push)
+ }
+
+ /**
+ * The thread priority.
+ * @since 1.3
+ */
+ static enum ThreadPriority {
+ NORM,
+ MIN,
+ MAX
+ }
+
+ /**
+ * Return the retry delay in ms
+ * @return The retry delay
+ */
+ long getRetryDelayInMs();
+
+ /**
+ * Return the max number of retries, -1 for endless retry!
+ * @return Max number of retries
+ */
+ int getMaxRetries();
+
+ /**
+ * Return the queue type.
+ * @return The queue type
+ */
+ Type getType();
+
+ /**
+ * Return the thread priority for the job thread
+ * @return Thread priority
+ */
+ ThreadPriority getThreadPriority();
+
+ /**
+ * Return the max number of parallel processes.
+ * @return Max parallel processes
+ */
+ int getMaxParallel();
+
+ /**
+ * The list of topics this queue is bound to.
+ * @return All topics for this queue.
+ */
+ String[] getTopics();
+
+ /**
+ * Whether successful jobs are kept for a complete history
+ * @return <code>true</code> if successful jobs are kept.
+ * @since 1.3
+ */
+ boolean isKeepJobs();
+
+ /**
+ * Return the size for the optional thread pool for this queue.
+ * @return A positive number or <code>0</code> if the default thread pool
+ * should be used.
+ * @since 1.3
+ */
+ int getOwnThreadPoolSize();
+
+ /**
+ * Get the ranking of this configuration.
+ * @return The ranking
+ */
+ int getRanking();
+
+ /**
+ * Prefer to run the job on the same instance it was created on.
+ * @return {@code true} if running on the creation instance is preferred.
+ * @since 1.4
+ */
+ boolean isPreferRunOnCreationInstance();
+}
diff --git a/src/main/java/org/apache/sling/event/jobs/ScheduleInfo.java b/src/main/java/org/apache/sling/event/jobs/ScheduleInfo.java
new file mode 100644
index 0000000..bb390cb
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/jobs/ScheduleInfo.java
@@ -0,0 +1,89 @@
+/*
+ * 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.event.jobs;
+
+import java.util.Date;
+
+import org.osgi.annotation.versioning.ProviderType;
+
+/**
+ * Scheduling information.
+ * @since 1.3
+ */
+@ProviderType
+public interface ScheduleInfo {
+
+ enum ScheduleType {
+ DATE, // scheduled for a date
+ HOURLY, // scheduled hourly
+ DAILY, // scheduled once a day
+ WEEKLY, // scheduled once a week
+ MONTHLY, // scheduled once a month
+ YEARLY, // scheduled once a year,
+ CRON // scheduled according to the cron expression
+ }
+
+ /**
+ * Return the scheduling type
+ * @return The scheduling type
+ */
+ ScheduleType getType();
+
+ /**
+ * Return the scheduled execution date for a schedule of type date.
+ * @return the scheduled execution date
+ */
+ Date getAt();
+
+ /**
+ * If the schedule is a cron expression, return the expression.
+ * @return The cron expression or <code>null</code>
+ */
+ String getExpression();
+
+ /**
+ * If the job is scheduled yearly, returns the month of the year
+ * @return The day of the year (from 1 to 12) or -1
+ */
+ int getMonthOfYear();
+
+ /**
+ * If the job is scheduled monthly, returns the day of the month
+ * @return The day of the month (from 1 to 28) or -1
+ */
+ int getDayOfMonth();
+
+ /**
+ * If the job is scheduled weekly, returns the day of the week
+ * @return The day of the week (from 1 to 7) or -1
+ */
+ int getDayOfWeek();
+
+ /**
+ * Return the hour of the day for daily and weekly scheduled jobs
+ * @return The hour of the day (from 0 to 23) or -1
+ */
+ int getHourOfDay();
+
+ /**
+ * Return the minute of the hour for daily, weekly and hourly scheduled jobs.
+ * @return The minute of the hour (from 0 to 59) or -1
+ */
+ int getMinuteOfHour();
+}
diff --git a/src/main/java/org/apache/sling/event/jobs/ScheduledJobInfo.java b/src/main/java/org/apache/sling/event/jobs/ScheduledJobInfo.java
new file mode 100644
index 0000000..4d37c8b
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/jobs/ScheduledJobInfo.java
@@ -0,0 +1,89 @@
+/*
+ * 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.event.jobs;
+
+import java.util.Collection;
+import java.util.Date;
+import java.util.Map;
+
+import org.osgi.annotation.versioning.ProviderType;
+
+/**
+ * Information about a scheduled job
+ * @since 1.3
+ */
+@ProviderType
+public interface ScheduledJobInfo {
+
+ /**
+ * Get all schedules for this job
+ * @return A non null and non empty list of schedules.
+ */
+ Collection<ScheduleInfo> getSchedules();
+
+ /**
+ * Return the next scheduled execution date.
+ * @return the next scheduled execution date.
+ */
+ Date getNextScheduledExecution();
+
+ /**
+ * Return the job topic.
+ * @return The job topic
+ */
+ String getJobTopic();
+
+ /**
+ * Return the optional job topics.
+ * @return The job topics or <code>null</code>
+ */
+ Map<String, Object> getJobProperties();
+
+ /**
+ * Unschedule this scheduled job.
+ */
+ void unschedule();
+
+ /**
+ * Reschedule this job with a new rescheduling information.
+ * If rescheduling fails (due to wrong arguments), the job
+ * schedule is left as is.
+ * @return The schedule builder
+ */
+ JobBuilder.ScheduleBuilder reschedule();
+
+ /**
+ * Suspend this job scheduling.
+ * Job scheduling can be resumed with {@link #resume()}.
+ * This information is persisted and survives a restart.
+ */
+ void suspend();
+
+ /**
+ * Resume job processing. {@link #suspend()}. If the queue is not
+ * suspended, calling this method has no effect.
+ */
+ void resume();
+
+ /**
+ * Is the processing currently suspended?
+ * @return {@code true} if processing is suspended.
+ */
+ boolean isSuspended();
+}
diff --git a/src/main/java/org/apache/sling/event/jobs/Statistics.java b/src/main/java/org/apache/sling/event/jobs/Statistics.java
new file mode 100644
index 0000000..66b8d55
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/jobs/Statistics.java
@@ -0,0 +1,112 @@
+/*
+ * 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.event.jobs;
+
+import org.osgi.annotation.versioning.ProviderType;
+
+/**
+ * Statistic information.
+ * This information is not preserved between restarts of the service.
+ * Once a service is restarted, the counters start at zero!
+ * @since 3.0
+ */
+@ProviderType
+public interface Statistics {
+
+ /**
+ * The time this service has been started
+ * @return The time this service has been started
+ */
+ long getStartTime();
+
+ /**
+ * Number of successfully finished jobs.
+ * @return Number of successfully finished jobs.
+ */
+ long getNumberOfFinishedJobs();
+
+ /**
+ * Number of permanently failing or cancelled jobs.
+ * @return Number of permanently failing or cancelled jobs
+ */
+ long getNumberOfCancelledJobs();
+
+ /**
+ * Number of failing jobs.
+ * @return Number of failing jobs.
+ */
+ long getNumberOfFailedJobs();
+
+ /**
+ * Number of already processed jobs. This adds
+ * {@link #getNumberOfFinishedJobs()}, {@link #getNumberOfCancelledJobs()}
+ * and {@link #getNumberOfFailedJobs()}
+ * @return Number of already processed jobs
+ */
+ long getNumberOfProcessedJobs();
+
+ /**
+ * Number of jobs currently in processing.
+ * @return Number of jobs currently in processing.
+ */
+ long getNumberOfActiveJobs();
+
+ /**
+ * Number of jobs currently waiting in a queue.
+ * @return Number of jobs currently waiting in a queue.
+ */
+ long getNumberOfQueuedJobs();
+
+ /**
+ * This just adds {@link #getNumberOfActiveJobs()} and {@link #getNumberOfQueuedJobs()}
+ * @return The number of jobs
+ */
+ long getNumberOfJobs();
+
+ /**
+ * The time a job has been started last.
+ * @return The time a job has been started last.
+ */
+ long getLastActivatedJobTime();
+
+ /**
+ * The time a job has been finished/failed/cancelled last.
+ * @return The time a job has been finished/failed/cancelled last.
+ */
+ long getLastFinishedJobTime();
+
+ /**
+ * The average waiting time of a job in the queue.
+ * @return The average waiting time of a job in the queue.
+ */
+ long getAverageWaitingTime();
+
+ /**
+ * The average processing time of a job - this only counts finished jobs.
+ * @return The average processing time of a job
+ */
+ long getAverageProcessingTime();
+
+ /**
+ * Clear all collected statistics and set the starting time to the current time.
+ * Note that not all fields are cleared, last waiting time or number of active and queued
+ * jobs is not cleared as these are currently used.
+ */
+ void reset();
+}
diff --git a/src/main/java/org/apache/sling/event/jobs/TopicStatistics.java b/src/main/java/org/apache/sling/event/jobs/TopicStatistics.java
new file mode 100644
index 0000000..0fed27a
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/jobs/TopicStatistics.java
@@ -0,0 +1,87 @@
+/*
+ * 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.event.jobs;
+
+import org.osgi.annotation.versioning.ProviderType;
+
+/**
+ * Statistic information about a topic.
+ * This information is not preserved between restarts of the service.
+ * Once a service is restarted, the counters start at zero!
+ * @since 3.0
+ */
+@ProviderType
+public interface TopicStatistics {
+
+ /**
+ * The topic this statistics is about.
+ * @return The topic name
+ */
+ String getTopic();
+
+ /**
+ * Number of successfully finished jobs.
+ * @return Number of successfully finished jobs.
+ */
+ long getNumberOfFinishedJobs();
+
+ /**
+ * Number of permanently failing or cancelled jobs.
+ * @return Number of permanently failing or cancelled jobs.
+ */
+ long getNumberOfCancelledJobs();
+
+ /**
+ * Number of failing jobs.
+ * @return Number of failing jobs.
+ */
+ long getNumberOfFailedJobs();
+
+ /**
+ * Number of already processed jobs. This adds
+ * {@link #getNumberOfFinishedJobs()}, {@link #getNumberOfCancelledJobs()}
+ * and {@link #getNumberOfFailedJobs()}
+ * @return Number of already processed jobs.
+ */
+ long getNumberOfProcessedJobs();
+
+ /**
+ * The time a job has been started last.
+ * @return The time a job has been started last.
+ */
+ long getLastActivatedJobTime();
+
+ /**
+ * The time a job has been finished/failed/cancelled last.
+ * @return The time a job has been finished/failed/cancelled last.
+ */
+ long getLastFinishedJobTime();
+
+ /**
+ * The average waiting time of a job in the queue.
+ * @return The average waiting time of a job in the queue.
+ */
+ long getAverageWaitingTime();
+
+ /**
+ * The average processing time of a job - this only counts finished jobs.
+ * @return The average processing time of a job
+ */
+ long getAverageProcessingTime();
+}
diff --git a/src/main/java/org/apache/sling/event/jobs/consumer/JobConsumer.java b/src/main/java/org/apache/sling/event/jobs/consumer/JobConsumer.java
new file mode 100644
index 0000000..8e783d1
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/jobs/consumer/JobConsumer.java
@@ -0,0 +1,133 @@
+/*
+ * 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.event.jobs.consumer;
+
+import org.apache.sling.event.jobs.Job;
+
+import org.osgi.annotation.versioning.ConsumerType;
+
+
+
+/**
+ * A job consumer consumes a job.
+ * <p>
+ * If the job consumer needs more features like providing progress information or adding
+ * more information of the processing, {@link JobExecutor} should be implemented instead.
+ * <p>
+ * A job consumer registers itself with the {@link #PROPERTY_TOPICS} service registration
+ * property. The value of this property defines which topics a consumer is able to process.
+ * Each string value of this property is either
+ * <ul>
+ * <li>a job topic, or
+ * <li>a topic category ending with "/*" which means all topics in this category, or
+ * <li>a topic category ending with "/**" which means all topics in this category and all
+ * sub categories. This matching is new since version 1.2.
+ * </ul>
+ * A consumer registering for just "*" or "**" is not considered.
+ * <p>
+ * For example, the value {@code org/apache/sling/jobs/*} matches the topics
+ * {@code org/apache/sling/jobs/a} and {@code org/apache/sling/jobs/b} but neither
+ * {@code org/apache/sling/jobs} nor {@code org/apache/sling/jobs/subcategory/a}. A value of
+ * {@code org/apache/sling/jobs/**} matches the same topics but also all sub topics
+ * like {@code org/apache/sling/jobs/subcategory/a} or {@code org/apache/sling/jobs/subcategory/a/c/d}.
+ * <p>
+ * If there is more than one job consumer or executor registered for a job topic, the selection is as
+ * follows:
+ * <ul>
+ * <li>If there is a single consumer registering for the exact topic, this one is used.
+ * <li>If there is more than a single consumer registering for the exact topic, the one
+ * with the highest service ranking is used. If the ranking is equal, the one with
+ * the lowest service ID is used.
+ * <li>If there is a single consumer registered for the category, it is used.
+ * <li>If there is more than a single consumer registered for the category, the service
+ * with the highest service ranking is used. If the ranking is equal, the one with
+ * the lowest service ID is used.
+ * <li>The search continues with consumer registered for deep categories. The nearest one
+ * is tried next. If there are several, the one with the highest service ranking is
+ * used. If the ranking is equal, the one with the lowest service ID is used.
+ * </ul>
+ * <p>
+ * If the consumer decides to process the job asynchronously, the processing must finish
+ * within the current lifetime of the job consumer. If the consumer (or the instance
+ * of the consumer) dies, the job processing will mark this processing as failed and
+ * reschedule.
+ *
+ * @since 1.0
+ */
+@ConsumerType
+public interface JobConsumer {
+
+ /**
+ * The result of the job processing.
+ */
+ enum JobResult {
+ /** Processing finished successfully. */
+ OK,
+ /** Processing failed but might be retried. */
+ FAILED,
+ /** Processing failed permanently and must not be retried. */
+ CANCEL,
+ /** Processing will be done asynchronously. */
+ ASYNC
+ }
+
+ /** Job property containing an asynchronous handler. */
+ String PROPERTY_JOB_ASYNC_HANDLER = ":sling:jobs:asynchandler";
+
+ /**
+ * If the consumer decides to process the job asynchronously, this handler
+ * interface can be used to notify finished processing. The asynchronous
+ * handler can be retried using the property name {@link #PROPERTY_JOB_ASYNC_HANDLER}.
+ */
+ interface AsyncHandler {
+
+ void failed();
+
+ void ok();
+
+ void cancel();
+ }
+
+ /**
+ * Service registration property defining the jobs this consumer is able to process.
+ * The value is either a string or an array of strings.
+ */
+ String PROPERTY_TOPICS = "job.topics";
+
+
+ /**
+ * Execute the job.
+ * <p>
+ * If the job has been processed successfully, {@link JobResult#OK} should be returned.
+ * If the job has not been processed completely, but might be rescheduled {@link JobResult#FAILED}
+ * should be returned.
+ * If the job processing failed and should not be rescheduled, {@link JobResult#CANCEL} should
+ * be returned.
+ * <p>
+ * If the consumer decides to process the job asynchronously it should return {@link JobResult#ASYNC}
+ * and notify the job manager by using the {@link AsyncHandler} interface.
+ * <p>
+ * If the processing fails with throwing an exception/throwable, the process will not be rescheduled
+ * and treated like the method would have returned {@link JobResult#CANCEL}.
+ *
+ * @param job The job
+ * @return The job result
+ */
+ JobResult process(Job job);
+}
diff --git a/src/main/java/org/apache/sling/event/jobs/consumer/JobExecutionContext.java b/src/main/java/org/apache/sling/event/jobs/consumer/JobExecutionContext.java
new file mode 100644
index 0000000..9370922
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/jobs/consumer/JobExecutionContext.java
@@ -0,0 +1,144 @@
+/*
+ * 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.event.jobs.consumer;
+
+import org.osgi.annotation.versioning.ProviderType;
+
+/**
+ *
+ * @since 1.1
+ */
+@ProviderType
+public interface JobExecutionContext {
+
+ /**
+ * Report an async result.
+ * @param result Tje job execution result
+ * @throws IllegalStateException If the job is not processed asynchronously
+ * or if this method has already been called.
+ */
+ void asyncProcessingFinished(JobExecutionResult result);
+
+ /**
+ * If a job is stoppable, it should periodically check this method
+ * and stop processing if the method return <code>true</code>.
+ * If a job is stopped and the job executor detects this, its up
+ * to the implementation to decide the result of such a state.
+ * There might be use cases where the job returns {@link JobExecutionResult#succeeded()}
+ * although it didn't process everything, or {@link JobExecutionResult#failed()}
+ * to retry later on or {@link JobExecutionResult#cancelled()}.
+ *
+ * @return Whether this job has been stopped from the outside.
+ */
+ boolean isStopped();
+
+ /**
+ * Indicate that the job executor is able to report the progress.
+ * The progress can either be reported by a step count,
+ * assuming that all steps take roughly the same amount of time.
+ * Or the progress can be reported by an ETA containing the number
+ * of seconds the job needs to finish.
+ * This method should only be called once, consecutive calls
+ * have no effect.
+ * By using a step count of 100, the progress can be displayed
+ * in percentage.
+ * @param steps Number of total steps or -1 if the number of
+ * steps is unknown.
+ * @param eta Number of seconds the process should take or
+ * -1 of it's not known now.
+ */
+ void initProgress(int steps, long eta);
+
+ /**
+ * Update the progress by additionally marking the provided
+ * number of steps as finished. If the total number of finished
+ * steps is equal or higher to the initial number of steps
+ * reported in {@link #initProgress(int, long)}, then the
+ * job progress is assumed to be 100%.
+ * This method has only effect if {@link #initProgress(int, long)}
+ * has been called first with a positive number for steps
+ * @param steps The number of finished steps since the last call.
+ */
+ void incrementProgressCount(int steps);
+
+ /**
+ * Update the progress to the new ETA.
+ * This method has only effect if {@link #initProgress(int, long)}
+ * has been called first.
+ * @param eta The new ETA
+ */
+ void updateProgress(long eta);
+
+ /**
+ * Log a message.
+ * A job consumer can use this method during job processing to add additional information
+ * about the current state of job processing.
+ * As calling this method adds a significant overhead it should only
+ * be used to log a few statements per job processing. If a consumer wants
+ * to output detailed information about the processing it should persists it
+ * by itself and not use this method for it.
+ * The message and the arguments are passed to the {@link java.text.MessageFormat}
+ * class.
+ * @param message A message
+ * @param args Additional arguments
+ */
+ void log(String message, Object...args);
+
+ /**
+ * Build a result for the processing.
+ * @return The build for the result
+ */
+ ResultBuilder result();
+
+ public interface ResultBuilder {
+
+ /**
+ * Add an optional processing message.
+ * This message can be viewed using {@link org.apache.sling.event.jobs.Job#getResultMessage()}.
+ * @param message The message
+ * @return The builder to continue building the result.
+ */
+ ResultBuilder message(String message);
+
+ /**
+ * The job processing finished successfully.
+ * @return The job execution result.
+ */
+ JobExecutionResult succeeded();
+
+ /**
+ * The job processing failed and might be retried.
+ * @return The job execution result.
+ */
+ JobExecutionResult failed();
+
+ /**
+ * The job processing failed and might be retried.
+ * @param retryDelayInMs The new retry delay in ms.
+ * @return The job execution result
+ */
+ JobExecutionResult failed(long retryDelayInMs);
+
+ /**
+ * The job processing failed permanently.
+ * @return The job execution result
+ */
+ JobExecutionResult cancelled();
+ }
+}
diff --git a/src/main/java/org/apache/sling/event/jobs/consumer/JobExecutionResult.java b/src/main/java/org/apache/sling/event/jobs/consumer/JobExecutionResult.java
new file mode 100644
index 0000000..c3da0f0
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/jobs/consumer/JobExecutionResult.java
@@ -0,0 +1,71 @@
+/*
+ * 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.event.jobs.consumer;
+
+import org.osgi.annotation.versioning.ProviderType;
+
+/**
+ * The status of a job after it has been processed by a {@link JobExecutor}.
+ * The job executor uses the {@link JobExecutionContext} to create a result object.
+ *
+ * The result can have three states, succeeded, cancelled or failed whereas
+ * failed means that the execution is potentially retried.
+ *
+ * @since 1.1
+ */
+@ProviderType
+public interface JobExecutionResult {
+
+ /**
+ * If this returns true the job processing finished successfully.
+ * In this case {@link #cancelled()} and {@link #failed()} return
+ * <code>false</code>
+ * @return <code>true</code> for a successful processing
+ */
+ boolean succeeded();
+
+ /**
+ * If this returns true the job processing failed permanently.
+ * In this case {@link #succeeded()} and {@link #failed()} return
+ * <code>false</code>
+ * @return <code>true</code> for a permanently failed processing
+ */
+ boolean cancelled();
+
+ /**
+ * If this returns true the job processing failed but might be
+ * retried..
+ * In this case {@link #cancelled()} and {@link #succeeded()} return
+ * <code>false</code>
+ * @return <code>true</code> for a failedl processing
+ */
+ boolean failed();
+
+ /**
+ * Return the optional message.
+ * @return The message or <code>null</code>
+ */
+ String getMessage();
+
+ /**
+ * Return the retry delay in ms
+ * @return The new retry delay (>= 0) or <code>null</code>
+ */
+ Long getRetryDelayInMs();
+}
diff --git a/src/main/java/org/apache/sling/event/jobs/consumer/JobExecutor.java b/src/main/java/org/apache/sling/event/jobs/consumer/JobExecutor.java
new file mode 100644
index 0000000..b720546
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/jobs/consumer/JobExecutor.java
@@ -0,0 +1,104 @@
+/*
+ * 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.event.jobs.consumer;
+
+import org.apache.sling.event.jobs.Job;
+
+import org.osgi.annotation.versioning.ConsumerType;
+
+/**
+ * A job executor consumes a job.
+ * <p>
+ * A job executor registers itself with the {@link #PROPERTY_TOPICS} service registration
+ * property. The value of this property defines which topics an executor is able to process.
+ * Each string value of this property is either
+ * <ul>
+ * <li>a job topic, or
+ * <li>a topic category ending with "/*" which means all topics in this category, or
+ * <li>a topic category ending with "/**" which means all topics in this category and all
+ * sub categories. This matching is new since version 1.2.
+ * </ul>
+ * A consumer registering for just "*" or "**" is not considered.
+ * <p>
+ * For example, the value {@code org/apache/sling/jobs/*} matches the topics
+ * {@code org/apache/sling/jobs/a} and {@code org/apache/sling/jobs/b} but neither
+ * {@code org/apache/sling/jobs} nor {@code org/apache/sling/jobs/subcategory/a}. A value of
+ * {@code org/apache/sling/jobs/**} matches the same topics but also all sub topics
+ * like {@code org/apache/sling/jobs/subcategory/a} or {@code org/apache/sling/jobs/subcategory/a/c/d}.
+ * <p>
+ * If there is more than one job consumer or executor registered for a job topic, the selection is as
+ * follows:
+ * <ul>
+ * <li>If there is a single consumer registering for the exact topic, this one is used.
+ * <li>If there is more than a single consumer registering for the exact topic, the one
+ * with the highest service ranking is used. If the ranking is equal, the one with
+ * the lowest service ID is used.
+ * <li>If there is a single consumer registered for the category, it is used.
+ * <li>If there is more than a single consumer registered for the category, the service
+ * with the highest service ranking is used. If the ranking is equal, the one with
+ * the lowest service ID is used.
+ * <li>The search continues with consumer registered for deep categories. The nearest one
+ * is tried next. If there are several, the one with the highest service ranking is
+ * used. If the ranking is equal, the one with the lowest service ID is used.
+ * </ul>
+ * <p>
+ * If the executor decides to process the job asynchronously, the processing must finish
+ * within the current lifetime of the job executor. If the executor (or the instance
+ * of the executor) dies, the job processing will mark this processing as failed and
+ * reschedule.
+ *
+ * @since 1.1
+ */
+@ConsumerType
+public interface JobExecutor {
+
+ /**
+ * Service registration property defining the jobs this executor is able to process.
+ * The value is either a string or an array of strings.
+ */
+ String PROPERTY_TOPICS = "job.topics";
+
+ /**
+ * Execute the job.
+ *
+ * If the job has been processed successfully, a job result of "succeeded" should be returned. This result can
+ * be generated by calling <code>JobExecutionContext.result().succeeded()</code>
+ *
+ * If the job has not been processed completely, but might be rescheduled "failed" should be returned.
+ * This result can be generated by calling <code>JobExecutionContext.result().failed()</code>.
+ *
+ * If the job processing failed and should not be rescheduled, "cancelled" should be returned.
+ * This result can be generated by calling <code>JobExecutionContext.result().cancelled()</code>.
+ *
+ * If the executor decides to process the job asynchronously it should return <code>null</code>
+ * and notify the job manager by using the {@link JobExecutionContext#asyncProcessingFinished(JobExecutionResult)}
+ * method of the processing result.
+ *
+ * If the processing fails with throwing an exception/throwable, the process will not be rescheduled
+ * and treated like the method would have returned a "cancelled" result.
+ *
+ * Additional information can be added to the result by using the builder pattern available
+ * from {@link JobExecutionContext#result()}.
+ *
+ * @param job The job
+ * @param context The execution context.
+ * @return The job execution result or <code>null</code> for asynchronous processing.
+ */
+ JobExecutionResult process(Job job, JobExecutionContext context);
+}
diff --git a/src/main/java/org/apache/sling/event/jobs/consumer/package-info.java b/src/main/java/org/apache/sling/event/jobs/consumer/package-info.java
new file mode 100644
index 0000000..5237caa
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/jobs/consumer/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+@org.osgi.annotation.versioning.Version("1.2.1")
+package org.apache.sling.event.jobs.consumer;
+
+
diff --git a/src/main/java/org/apache/sling/event/jobs/jmx/QueuesMBean.java b/src/main/java/org/apache/sling/event/jobs/jmx/QueuesMBean.java
new file mode 100644
index 0000000..9e8af7d
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/jobs/jmx/QueuesMBean.java
@@ -0,0 +1,28 @@
+/*
+ * 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 SF 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.event.jobs.jmx;
+
+/**
+ * A Marker interface to allow the implementation to register as a service with
+ * the JMX whiteboard.
+ */
+public interface QueuesMBean {
+
+ String[] getQueueNames();
+
+}
diff --git a/src/main/java/org/apache/sling/event/jobs/jmx/StatisticsMBean.java b/src/main/java/org/apache/sling/event/jobs/jmx/StatisticsMBean.java
new file mode 100644
index 0000000..321f574
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/jobs/jmx/StatisticsMBean.java
@@ -0,0 +1,34 @@
+/*
+ * 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 SF 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.event.jobs.jmx;
+
+import java.util.Date;
+
+import org.apache.sling.event.jobs.Statistics;
+
+public interface StatisticsMBean extends Statistics {
+
+ Date getLastActivatedJobDate();
+
+ Date getLastFinishedJobDate();
+
+ Date getStartDate();
+
+ String getName();
+
+}
diff --git a/src/main/java/org/apache/sling/event/jobs/jmx/package-info.java b/src/main/java/org/apache/sling/event/jobs/jmx/package-info.java
new file mode 100644
index 0000000..515bde1
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/jobs/jmx/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+@org.osgi.annotation.versioning.Version("1.0.1")
+package org.apache.sling.event.jobs.jmx;
+
+
diff --git a/src/main/java/org/apache/sling/event/jobs/package-info.java b/src/main/java/org/apache/sling/event/jobs/package-info.java
new file mode 100644
index 0000000..6ea3e5c
--- /dev/null
+++ b/src/main/java/org/apache/sling/event/jobs/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+@org.osgi.annotation.versioning.Version("2.0.1")
+package org.apache.sling.event.jobs;
+
+
diff --git a/src/main/resources/SLING-INF/nodetypes/event.cnd b/src/main/resources/SLING-INF/nodetypes/event.cnd
new file mode 100644
index 0000000..b29d76e
--- /dev/null
+++ b/src/main/resources/SLING-INF/nodetypes/event.cnd
@@ -0,0 +1,42 @@
+//
+// 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.
+//
+
+<slingevent='http://sling.apache.org/jcr/event/1.0'>
+<nt='http://www.jcp.org/jcr/nt/1.0'>
+<mix='http://www.jcp.org/jcr/mix/1.0'>
+
+[slingevent:Event] > nt:unstructured, nt:hierarchyNode
+ - slingevent:topic (string)
+ - slingevent:application (string)
+ - slingevent:created (date)
+ - slingevent:properties (binary)
+
+[slingevent:Job] > slingevent:Event, mix:lockable
+ - slingevent:processor (string)
+ - slingevent:id (string)
+ - slingevent:finished (date)
+
+[slingevent:TimedEvent] > slingevent:Event, mix:lockable
+ - slingevent:processor (string)
+ - slingevent:id (string)
+ - slingevent:expression (string)
+ - slingevent:date (date)
+ - slingevent:period (long)
+
+
diff --git a/src/test/java/org/apache/sling/event/impl/Barrier.java b/src/test/java/org/apache/sling/event/impl/Barrier.java
new file mode 100644
index 0000000..20d4ace
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/impl/Barrier.java
@@ -0,0 +1,57 @@
+/*
+ * 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.event.impl;
+
+import java.util.concurrent.BrokenBarrierException;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/** Simplified version of the cyclic barrier class for testing. */
+public class Barrier extends CyclicBarrier {
+
+ public Barrier(int parties) {
+ super(parties);
+ }
+
+ public void block() {
+ try {
+ this.await();
+ } catch (InterruptedException e) {
+ // ignore
+ } catch (BrokenBarrierException e) {
+ // ignore
+ }
+ }
+
+ public boolean block(int seconds) {
+ try {
+ this.await(seconds, TimeUnit.SECONDS);
+ return true;
+ } catch (InterruptedException e) {
+ // ignore
+ } catch (BrokenBarrierException e) {
+ // ignore
+ } catch (TimeoutException e) {
+ // ignore
+ }
+ this.reset();
+ return false;
+ }
+}
diff --git a/src/test/java/org/apache/sling/event/impl/TestUtil.java b/src/test/java/org/apache/sling/event/impl/TestUtil.java
new file mode 100644
index 0000000..4be099f
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/impl/TestUtil.java
@@ -0,0 +1,53 @@
+/*
+ * 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.event.impl;
+
+import java.lang.reflect.Field;
+
+public class TestUtil {
+
+ private static Object getSetField(final Object obj, final String fieldName, final boolean isGet, final Object value) {
+ Class<?> clazz = obj.getClass();
+ while ( clazz != null ) {
+ try {
+ final Field field = clazz.getDeclaredField(fieldName);
+ field.setAccessible(true);
+
+ if ( isGet ) {
+ return field.get(obj);
+ } else {
+ field.set(obj, value);
+ return null;
+ }
+ } catch ( final Exception ignore ) {
+ // ignore
+ }
+ clazz = clazz.getSuperclass();
+ }
+ throw new RuntimeException("Field " + fieldName + " not found on object " + obj);
+ }
+
+ public static void setFieldValue(final Object obj, final String fieldName, final Object value) {
+ getSetField(obj, fieldName, false, value);
+ }
+
+ public static Object getFieldValue(final Object obj, final String fieldName) {
+ return getSetField(obj, fieldName, true, null);
+ }
+}
diff --git a/src/test/java/org/apache/sling/event/impl/jobs/InstanceDescriptionComparatorTest.java b/src/test/java/org/apache/sling/event/impl/jobs/InstanceDescriptionComparatorTest.java
new file mode 100644
index 0000000..77caac8
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/impl/jobs/InstanceDescriptionComparatorTest.java
@@ -0,0 +1,199 @@
+/*
+ * 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.event.impl.jobs;
+
+import static org.junit.Assert.assertEquals;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.sling.discovery.ClusterView;
+import org.apache.sling.discovery.InstanceDescription;
+import org.apache.sling.event.impl.jobs.config.TopologyCapabilities;
+
+public class InstanceDescriptionComparatorTest {
+
+
+ @org.junit.Test public void testSingleClusterThreeInstances() {
+ final Instance cl1in1 = new Instance("1", "A", false, true);
+ final Instance cl1in2 = new Instance("1", "B", true, false);
+ final Instance cl1in3 = new Instance("1", "C", false, false);
+
+ final List<InstanceDescription> desc = new ArrayList<InstanceDescription>();
+ desc.add(cl1in2);
+ desc.add(cl1in1);
+ desc.add(cl1in3);
+ Collections.sort(desc, new TopologyCapabilities.InstanceDescriptionComparator("1"));
+
+ assertEquals("Instance0: ", cl1in2.getSlingId(), desc.get(0).getSlingId());
+ assertEquals("Instance1: ", cl1in1.getSlingId(), desc.get(1).getSlingId());
+ assertEquals("Instance2: ", cl1in3.getSlingId(), desc.get(2).getSlingId());
+ }
+
+ @org.junit.Test public void testTwoClustersThreeInstances() {
+ final Instance cl1in1 = new Instance("1", "A", false, true);
+ final Instance cl1in2 = new Instance("1", "B", true, false);
+ final Instance cl1in3 = new Instance("1", "C", false, false);
+ final Instance cl2in1 = new Instance("2", "D", false, false);
+ final Instance cl2in2 = new Instance("2", "E", false, false);
+ final Instance cl2in3 = new Instance("2", "F", true, false);
+
+ final List<InstanceDescription> desc = new ArrayList<InstanceDescription>();
+ desc.add(cl2in3);
+ desc.add(cl1in2);
+ desc.add(cl2in1);
+ desc.add(cl1in3);
+ desc.add(cl2in2);
+ desc.add(cl1in1);
+ Collections.sort(desc, new TopologyCapabilities.InstanceDescriptionComparator("1"));
+
+ assertEquals("Instance0: ", cl1in2.getSlingId(), desc.get(0).getSlingId());
+ assertEquals("Instance1: ", cl1in1.getSlingId(), desc.get(1).getSlingId());
+ assertEquals("Instance2: ", cl1in3.getSlingId(), desc.get(2).getSlingId());
+ assertEquals("Instance3: ", cl2in1.getSlingId(), desc.get(3).getSlingId());
+ assertEquals("Instance4: ", cl2in2.getSlingId(), desc.get(4).getSlingId());
+ assertEquals("Instance5: ", cl2in3.getSlingId(), desc.get(5).getSlingId());
+
+ Collections.sort(desc, new TopologyCapabilities.InstanceDescriptionComparator("2"));
+ assertEquals("Instance0: ", cl2in3.getSlingId(), desc.get(0).getSlingId());
+ assertEquals("Instance1: ", cl2in1.getSlingId(), desc.get(1).getSlingId());
+ assertEquals("Instance2: ", cl2in2.getSlingId(), desc.get(2).getSlingId());
+ assertEquals("Instance3: ", cl1in1.getSlingId(), desc.get(3).getSlingId());
+ assertEquals("Instance4: ", cl1in2.getSlingId(), desc.get(4).getSlingId());
+ assertEquals("Instance5: ", cl1in3.getSlingId(), desc.get(5).getSlingId());
+ }
+
+ @org.junit.Test public void testThreeClustersThreeInstances() {
+ final Instance cl1in1 = new Instance("1", "A", false, true);
+ final Instance cl1in2 = new Instance("1", "B", true, false);
+ final Instance cl1in3 = new Instance("1", "C", false, false);
+ final Instance cl15in1 = new Instance("15", "Z", true, false);
+ final Instance cl2in1 = new Instance("2", "D", true, false);
+ final Instance cl2in2 = new Instance("2", "E", false, false);
+ final Instance cl2in3 = new Instance("2", "F", false, false);
+
+ final List<InstanceDescription> desc = new ArrayList<InstanceDescription>();
+ desc.add(cl2in3);
+ desc.add(cl1in2);
+ desc.add(cl2in1);
+ desc.add(cl15in1);
+ desc.add(cl1in3);
+ desc.add(cl2in2);
+ desc.add(cl1in1);
+ Collections.sort(desc, new TopologyCapabilities.InstanceDescriptionComparator("1"));
+
+ assertEquals("Instance0: ", cl1in2.getSlingId(), desc.get(0).getSlingId());
+ assertEquals("Instance1: ", cl1in1.getSlingId(), desc.get(1).getSlingId());
+ assertEquals("Instance2: ", cl1in3.getSlingId(), desc.get(2).getSlingId());
+ assertEquals("Instance3: ", cl2in1.getSlingId(), desc.get(3).getSlingId());
+ assertEquals("Instance4: ", cl2in2.getSlingId(), desc.get(4).getSlingId());
+ assertEquals("Instance5: ", cl2in3.getSlingId(), desc.get(5).getSlingId());
+ assertEquals("Instance6: ", cl15in1.getSlingId(), desc.get(6).getSlingId());
+
+ Collections.sort(desc, new TopologyCapabilities.InstanceDescriptionComparator("2"));
+ assertEquals("Instance0: ", cl2in1.getSlingId(), desc.get(0).getSlingId());
+ assertEquals("Instance1: ", cl2in2.getSlingId(), desc.get(1).getSlingId());
+ assertEquals("Instance2: ", cl2in3.getSlingId(), desc.get(2).getSlingId());
+ assertEquals("Instance3: ", cl1in1.getSlingId(), desc.get(3).getSlingId());
+ assertEquals("Instance4: ", cl1in2.getSlingId(), desc.get(4).getSlingId());
+ assertEquals("Instance5: ", cl1in3.getSlingId(), desc.get(5).getSlingId());
+ assertEquals("Instance6: ", cl15in1.getSlingId(), desc.get(6).getSlingId());
+
+ Collections.sort(desc, new TopologyCapabilities.InstanceDescriptionComparator("15"));
+ assertEquals("Instance0: ", cl15in1.getSlingId(), desc.get(0).getSlingId());
+ assertEquals("Instance1: ", cl1in1.getSlingId(), desc.get(1).getSlingId());
+ assertEquals("Instance2: ", cl1in2.getSlingId(), desc.get(2).getSlingId());
+ assertEquals("Instance3: ", cl1in3.getSlingId(), desc.get(3).getSlingId());
+ assertEquals("Instance4: ", cl2in1.getSlingId(), desc.get(4).getSlingId());
+ assertEquals("Instance5: ", cl2in2.getSlingId(), desc.get(5).getSlingId());
+ assertEquals("Instance6: ", cl2in3.getSlingId(), desc.get(6).getSlingId());
+
+ Collections.sort(desc, new TopologyCapabilities.InstanceDescriptionComparator("4"));
+ assertEquals("Instance0: ", cl1in1.getSlingId(), desc.get(0).getSlingId());
+ assertEquals("Instance1: ", cl1in2.getSlingId(), desc.get(1).getSlingId());
+ assertEquals("Instance2: ", cl1in3.getSlingId(), desc.get(2).getSlingId());
+ assertEquals("Instance3: ", cl2in1.getSlingId(), desc.get(3).getSlingId());
+ assertEquals("Instance4: ", cl2in2.getSlingId(), desc.get(4).getSlingId());
+ assertEquals("Instance5: ", cl2in3.getSlingId(), desc.get(5).getSlingId());
+ assertEquals("Instance6: ", cl15in1.getSlingId(), desc.get(6).getSlingId());
+ }
+
+ private static final class Instance implements InstanceDescription {
+
+ private final String clusterId;
+ private final String instanceId;
+ private final boolean isLeader;
+ private final boolean isLocal;
+
+ public Instance(final String clusterId, final String instanceId, final boolean isLeader, final boolean isLocal) {
+ this.clusterId = clusterId;
+ this.instanceId = instanceId;
+ this.isLeader = isLeader;
+ this.isLocal = isLocal;
+ }
+
+ @Override
+ public ClusterView getClusterView() {
+ return new ClusterView() {
+
+ @Override
+ public InstanceDescription getLeader() {
+ return null;
+ }
+
+ @Override
+ public List<InstanceDescription> getInstances() {
+ return null;
+ }
+
+ @Override
+ public String getId() {
+ return clusterId;
+ }
+ };
+ }
+
+ @Override
+ public boolean isLeader() {
+ return this.isLeader;
+ }
+
+ @Override
+ public boolean isLocal() {
+ return this.isLocal;
+ }
+
+ @Override
+ public String getSlingId() {
+ return this.instanceId;
+ }
+
+ @Override
+ public String getProperty(String name) {
+ return null;
+ }
+
+ @Override
+ public Map<String, String> getProperties() {
+ return null;
+ }
+ }
+}
diff --git a/src/test/java/org/apache/sling/event/impl/jobs/JobConsumerManagerTest.java b/src/test/java/org/apache/sling/event/impl/jobs/JobConsumerManagerTest.java
new file mode 100644
index 0000000..f976767
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/impl/jobs/JobConsumerManagerTest.java
@@ -0,0 +1,198 @@
+/*
+ * 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.event.impl.jobs;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+import java.util.Collections;
+
+import org.apache.sling.event.jobs.consumer.JobConsumer;
+import org.apache.sling.event.jobs.consumer.JobExecutor;
+import org.junit.Test;
+import org.mockito.Mockito;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.Constants;
+import org.osgi.framework.ServiceReference;
+
+public class JobConsumerManagerTest {
+
+ @Test public void testSimpleMappingConsumer() {
+ final BundleContext bc = Mockito.mock(BundleContext.class);
+ final JobConsumerManager jcs = new JobConsumerManager();
+ jcs.activate(bc, Collections.EMPTY_MAP);
+
+ final JobConsumer jc1 = Mockito.mock(JobConsumer.class);
+ final ServiceReference ref1 = Mockito.mock(ServiceReference.class);
+ Mockito.when(ref1.getProperty(JobConsumer.PROPERTY_TOPICS)).thenReturn("a/b");
+ Mockito.when(ref1.getProperty(Constants.SERVICE_RANKING)).thenReturn(1);
+ Mockito.when(ref1.getProperty(Constants.SERVICE_ID)).thenReturn(1L);
+ Mockito.when(bc.getService(ref1)).thenReturn(jc1);
+ jcs.bindJobConsumer(ref1);
+
+ assertNotNull(jcs.getExecutor("a/b"));
+ assertNull(jcs.getExecutor("a"));
+ assertNull(jcs.getExecutor("a/c"));
+ assertNull(jcs.getExecutor("a/b/a"));
+ }
+
+ @Test public void testCategoryMappingConsumer() {
+ final BundleContext bc = Mockito.mock(BundleContext.class);
+ final JobConsumerManager jcs = new JobConsumerManager();
+ jcs.activate(bc, Collections.EMPTY_MAP);
+
+ final JobConsumer jc1 = Mockito.mock(JobConsumer.class);
+ final ServiceReference ref1 = Mockito.mock(ServiceReference.class);
+ Mockito.when(ref1.getProperty(JobConsumer.PROPERTY_TOPICS)).thenReturn("a/*");
+ Mockito.when(ref1.getProperty(Constants.SERVICE_RANKING)).thenReturn(1);
+ Mockito.when(ref1.getProperty(Constants.SERVICE_ID)).thenReturn(1L);
+ Mockito.when(bc.getService(ref1)).thenReturn(jc1);
+ jcs.bindJobConsumer(ref1);
+
+ assertNotNull(jcs.getExecutor("a/b"));
+ assertNull(jcs.getExecutor("a"));
+ assertNotNull(jcs.getExecutor("a/c"));
+ assertNull(jcs.getExecutor("a/b/a"));
+ }
+
+ @Test public void testSubCategoryMappingConsumer() {
+ final BundleContext bc = Mockito.mock(BundleContext.class);
+ final JobConsumerManager jcs = new JobConsumerManager();
+ jcs.activate(bc, Collections.EMPTY_MAP);
+
+ final JobConsumer jc1 = Mockito.mock(JobConsumer.class);
+ final ServiceReference ref1 = Mockito.mock(ServiceReference.class);
+ Mockito.when(ref1.getProperty(JobConsumer.PROPERTY_TOPICS)).thenReturn("a/**");
+ Mockito.when(ref1.getProperty(Constants.SERVICE_RANKING)).thenReturn(1);
+ Mockito.when(ref1.getProperty(Constants.SERVICE_ID)).thenReturn(1L);
+ Mockito.when(bc.getService(ref1)).thenReturn(jc1);
+ jcs.bindJobConsumer(ref1);
+
+ assertNotNull(jcs.getExecutor("a/b"));
+ assertNull(jcs.getExecutor("a"));
+ assertNotNull(jcs.getExecutor("a/c"));
+ assertNotNull(jcs.getExecutor("a/b/a"));
+ }
+
+ @Test public void testSimpleMappingExecutor() {
+ final BundleContext bc = Mockito.mock(BundleContext.class);
+ final JobConsumerManager jcs = new JobConsumerManager();
+ jcs.activate(bc, Collections.EMPTY_MAP);
+
+ final JobExecutor jc1 = Mockito.mock(JobExecutor.class);
+ final ServiceReference ref1 = Mockito.mock(ServiceReference.class);
+ Mockito.when(ref1.getProperty(JobConsumer.PROPERTY_TOPICS)).thenReturn("a/b");
+ Mockito.when(ref1.getProperty(Constants.SERVICE_RANKING)).thenReturn(1);
+ Mockito.when(ref1.getProperty(Constants.SERVICE_ID)).thenReturn(1L);
+ Mockito.when(bc.getService(ref1)).thenReturn(jc1);
+ jcs.bindJobExecutor(ref1);
+
+ assertNotNull(jcs.getExecutor("a/b"));
+ assertNull(jcs.getExecutor("a"));
+ assertNull(jcs.getExecutor("a/c"));
+ assertNull(jcs.getExecutor("a/b/a"));
+ }
+
+ @Test public void testCategoryMappingExecutor() {
+ final BundleContext bc = Mockito.mock(BundleContext.class);
+ final JobConsumerManager jcs = new JobConsumerManager();
+ jcs.activate(bc, Collections.EMPTY_MAP);
+
+ final JobExecutor jc1 = Mockito.mock(JobExecutor.class);
+ final ServiceReference ref1 = Mockito.mock(ServiceReference.class);
+ Mockito.when(ref1.getProperty(JobExecutor.PROPERTY_TOPICS)).thenReturn("a/*");
+ Mockito.when(ref1.getProperty(Constants.SERVICE_RANKING)).thenReturn(1);
+ Mockito.when(ref1.getProperty(Constants.SERVICE_ID)).thenReturn(1L);
+ Mockito.when(bc.getService(ref1)).thenReturn(jc1);
+ jcs.bindJobExecutor(ref1);
+
+ assertNotNull(jcs.getExecutor("a/b"));
+ assertNull(jcs.getExecutor("a"));
+ assertNotNull(jcs.getExecutor("a/c"));
+ assertNull(jcs.getExecutor("a/b/a"));
+ }
+
+ @Test public void testSubCategoryMappingExecutor() {
+ final BundleContext bc = Mockito.mock(BundleContext.class);
+ final JobConsumerManager jcs = new JobConsumerManager();
+ jcs.activate(bc, Collections.EMPTY_MAP);
+
+ final JobExecutor jc1 = Mockito.mock(JobExecutor.class);
+ final ServiceReference ref1 = Mockito.mock(ServiceReference.class);
+ Mockito.when(ref1.getProperty(JobExecutor.PROPERTY_TOPICS)).thenReturn("a/**");
+ Mockito.when(ref1.getProperty(Constants.SERVICE_RANKING)).thenReturn(1);
+ Mockito.when(ref1.getProperty(Constants.SERVICE_ID)).thenReturn(1L);
+ Mockito.when(bc.getService(ref1)).thenReturn(jc1);
+ jcs.bindJobExecutor(ref1);
+
+ assertNotNull(jcs.getExecutor("a/b"));
+ assertNull(jcs.getExecutor("a"));
+ assertNotNull(jcs.getExecutor("a/c"));
+ assertNotNull(jcs.getExecutor("a/b/a"));
+ }
+
+ @Test public void testRanking() {
+ final BundleContext bc = Mockito.mock(BundleContext.class);
+ final JobConsumerManager jcs = new JobConsumerManager();
+ jcs.activate(bc, Collections.EMPTY_MAP);
+
+ final JobExecutor jc1 = Mockito.mock(JobExecutor.class);
+ final JobExecutor jc2 = Mockito.mock(JobExecutor.class);
+ final JobExecutor jc3 = Mockito.mock(JobExecutor.class);
+ final JobExecutor jc4 = Mockito.mock(JobExecutor.class);
+ final ServiceReference ref1 = Mockito.mock(ServiceReference.class);
+ Mockito.when(ref1.getProperty(JobExecutor.PROPERTY_TOPICS)).thenReturn("a/b");
+ Mockito.when(ref1.getProperty(Constants.SERVICE_RANKING)).thenReturn(1);
+ Mockito.when(ref1.getProperty(Constants.SERVICE_ID)).thenReturn(1L);
+ Mockito.when(bc.getService(ref1)).thenReturn(jc1);
+ jcs.bindJobExecutor(ref1);
+ assertEquals(jc1, jcs.getExecutor("a/b"));
+
+ final ServiceReference ref2 = Mockito.mock(ServiceReference.class);
+ Mockito.when(ref2.getProperty(JobExecutor.PROPERTY_TOPICS)).thenReturn("a/b");
+ Mockito.when(ref2.getProperty(Constants.SERVICE_RANKING)).thenReturn(10);
+ Mockito.when(ref2.getProperty(Constants.SERVICE_ID)).thenReturn(2L);
+ Mockito.when(bc.getService(ref2)).thenReturn(jc2);
+ jcs.bindJobExecutor(ref2);
+ assertEquals(jc2, jcs.getExecutor("a/b"));
+
+ final ServiceReference ref3 = Mockito.mock(ServiceReference.class);
+ Mockito.when(ref3.getProperty(JobExecutor.PROPERTY_TOPICS)).thenReturn("a/b");
+ Mockito.when(ref3.getProperty(Constants.SERVICE_RANKING)).thenReturn(5);
+ Mockito.when(ref3.getProperty(Constants.SERVICE_ID)).thenReturn(3L);
+ Mockito.when(bc.getService(ref3)).thenReturn(jc3);
+ jcs.bindJobExecutor(ref3);
+ assertEquals(jc2, jcs.getExecutor("a/b"));
+
+ final ServiceReference ref4 = Mockito.mock(ServiceReference.class);
+ Mockito.when(ref4.getProperty(JobExecutor.PROPERTY_TOPICS)).thenReturn("a/b");
+ Mockito.when(ref4.getProperty(Constants.SERVICE_RANKING)).thenReturn(5);
+ Mockito.when(ref4.getProperty(Constants.SERVICE_ID)).thenReturn(4L);
+ Mockito.when(bc.getService(ref4)).thenReturn(jc4);
+ jcs.bindJobExecutor(ref4);
+ assertEquals(jc2, jcs.getExecutor("a/b"));
+
+ jcs.unbindJobExecutor(ref2);
+ assertEquals(jc3, jcs.getExecutor("a/b"));
+
+ jcs.unbindJobExecutor(ref3);
+ assertEquals(jc4, jcs.getExecutor("a/b"));
+ }
+}
diff --git a/src/test/java/org/apache/sling/event/impl/jobs/JobsImplTest.java b/src/test/java/org/apache/sling/event/impl/jobs/JobsImplTest.java
new file mode 100644
index 0000000..4a540b0
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/impl/jobs/JobsImplTest.java
@@ -0,0 +1,62 @@
+/*
+ * 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.event.impl.jobs;
+
+import static org.junit.Assert.assertEquals;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.sling.event.jobs.Job;
+import org.junit.Test;
+
+public class JobsImplTest {
+
+ @Test public void testSorting() {
+ final Calendar now = Calendar.getInstance();
+ final Map<String, Object> properties = new HashMap<String, Object>();
+ properties.put(Job.PROPERTY_JOB_CREATED, now);
+
+ final JobImpl job1 = new JobImpl("test", "hello_1", properties);
+ final JobImpl job2 = new JobImpl("test", "hello_2", properties);
+ final JobImpl job3 = new JobImpl("test", "hello_4", properties);
+ final JobImpl job4 = new JobImpl("test", "hello_30", properties);
+ final JobImpl job5 = new JobImpl("test", "hello_50", properties);
+
+ final List<JobImpl> list = new ArrayList<JobImpl>();
+ list.add(job5);
+ list.add(job2);
+ list.add(job1);
+ list.add(job4);
+ list.add(job3);
+
+ Collections.sort(list);
+
+ assertEquals(job1, list.get(0));
+ assertEquals(job2, list.get(1));
+ assertEquals(job3, list.get(2));
+ assertEquals(job4, list.get(3));
+ assertEquals(job5, list.get(4));
+
+ }
+}
diff --git a/src/test/java/org/apache/sling/event/impl/jobs/StatisticsImplTest.java b/src/test/java/org/apache/sling/event/impl/jobs/StatisticsImplTest.java
new file mode 100644
index 0000000..cf3b12d
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/impl/jobs/StatisticsImplTest.java
@@ -0,0 +1,268 @@
+/*
+ * 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.event.impl.jobs;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.apache.sling.event.impl.jobs.stats.StatisticsImpl;
+
+public class StatisticsImplTest {
+
+ protected StatisticsImpl stat;
+
+ static long START_TIME = System.currentTimeMillis();
+
+ @org.junit.Before public void setup() {
+ this.stat = new StatisticsImpl();
+ }
+
+ @org.junit.Test public void testInitial() {
+ assertTrue(this.stat.getStartTime() >= START_TIME);
+ assertEquals(0, this.stat.getAverageProcessingTime());
+ assertEquals(0, this.stat.getAverageWaitingTime());
+ assertEquals(0, this.stat.getNumberOfActiveJobs());
+ assertEquals(0, this.stat.getNumberOfCancelledJobs());
+ assertEquals(0, this.stat.getNumberOfFailedJobs());
+ assertEquals(0, this.stat.getNumberOfFinishedJobs());
+ assertEquals(0, this.stat.getNumberOfJobs());
+ assertEquals(0, this.stat.getNumberOfProcessedJobs());
+ assertEquals(0, this.stat.getNumberOfQueuedJobs());
+ assertEquals(-1, this.stat.getLastActivatedJobTime());
+ assertEquals(-1, this.stat.getLastFinishedJobTime());
+ }
+
+ @org.junit.Test public void testIncDecQueued() {
+ this.stat.incQueued();
+ assertTrue(this.stat.getStartTime() >= START_TIME);
+ assertEquals(0, this.stat.getAverageProcessingTime());
+ assertEquals(0, this.stat.getAverageWaitingTime());
+ assertEquals(0, this.stat.getNumberOfActiveJobs());
+ assertEquals(0, this.stat.getNumberOfCancelledJobs());
+ assertEquals(0, this.stat.getNumberOfFailedJobs());
+ assertEquals(0, this.stat.getNumberOfFinishedJobs());
+ assertEquals(1, this.stat.getNumberOfJobs());
+ assertEquals(0, this.stat.getNumberOfProcessedJobs());
+ assertEquals(1, this.stat.getNumberOfQueuedJobs());
+ assertEquals(-1, this.stat.getLastActivatedJobTime());
+ assertEquals(-1, this.stat.getLastFinishedJobTime());
+
+ this.stat.incQueued();
+ assertTrue(this.stat.getStartTime() >= START_TIME);
+ assertEquals(0, this.stat.getAverageProcessingTime());
+ assertEquals(0, this.stat.getAverageWaitingTime());
+ assertEquals(0, this.stat.getNumberOfActiveJobs());
+ assertEquals(0, this.stat.getNumberOfCancelledJobs());
+ assertEquals(0, this.stat.getNumberOfFailedJobs());
+ assertEquals(0, this.stat.getNumberOfFinishedJobs());
+ assertEquals(2, this.stat.getNumberOfJobs());
+ assertEquals(0, this.stat.getNumberOfProcessedJobs());
+ assertEquals(2, this.stat.getNumberOfQueuedJobs());
+ assertEquals(-1, this.stat.getLastActivatedJobTime());
+ assertEquals(-1, this.stat.getLastFinishedJobTime());
+
+ this.stat.decQueued();
+ assertTrue(this.stat.getStartTime() >= START_TIME);
+ assertEquals(0, this.stat.getAverageProcessingTime());
+ assertEquals(0, this.stat.getAverageWaitingTime());
+ assertEquals(0, this.stat.getNumberOfActiveJobs());
+ assertEquals(0, this.stat.getNumberOfCancelledJobs());
+ assertEquals(0, this.stat.getNumberOfFailedJobs());
+ assertEquals(0, this.stat.getNumberOfFinishedJobs());
+ assertEquals(1, this.stat.getNumberOfJobs());
+ assertEquals(0, this.stat.getNumberOfProcessedJobs());
+ assertEquals(1, this.stat.getNumberOfQueuedJobs());
+ assertEquals(-1, this.stat.getLastActivatedJobTime());
+ assertEquals(-1, this.stat.getLastFinishedJobTime());
+ }
+
+ @org.junit.Test public void testFinished() {
+ long now = System.currentTimeMillis();
+ this.stat.incQueued();
+ this.stat.addActive(100);
+ this.stat.finishedJob(200);
+ this.stat.incQueued();
+ this.stat.addActive(300);
+ this.stat.finishedJob(800);
+
+ assertTrue(this.stat.getStartTime() >= START_TIME);
+ assertEquals(500, this.stat.getAverageProcessingTime());
+ assertEquals(200, this.stat.getAverageWaitingTime());
+ assertEquals(0, this.stat.getNumberOfActiveJobs());
+ assertEquals(0, this.stat.getNumberOfCancelledJobs());
+ assertEquals(0, this.stat.getNumberOfFailedJobs());
+ assertEquals(2, this.stat.getNumberOfFinishedJobs());
+ assertEquals(0, this.stat.getNumberOfJobs());
+ assertEquals(2, this.stat.getNumberOfProcessedJobs());
+ assertEquals(0, this.stat.getNumberOfQueuedJobs());
+ assertTrue(this.stat.getLastActivatedJobTime() >= now);
+ assertTrue(this.stat.getLastFinishedJobTime() >= now);
+
+ now = System.currentTimeMillis();
+ this.stat.incQueued();
+ this.stat.addActive(200);
+ assertTrue(this.stat.getStartTime() >= START_TIME);
+ assertEquals(500, this.stat.getAverageProcessingTime());
+ assertEquals(200, this.stat.getAverageWaitingTime());
+ assertEquals(1, this.stat.getNumberOfActiveJobs());
+ assertEquals(0, this.stat.getNumberOfCancelledJobs());
+ assertEquals(0, this.stat.getNumberOfFailedJobs());
+ assertEquals(2, this.stat.getNumberOfFinishedJobs());
+ assertEquals(1, this.stat.getNumberOfJobs());
+ assertEquals(2, this.stat.getNumberOfProcessedJobs());
+ assertEquals(0, this.stat.getNumberOfQueuedJobs());
+ assertTrue(this.stat.getLastActivatedJobTime() >= now);
+ assertTrue(this.stat.getLastFinishedJobTime() <= now);
+
+ now = System.currentTimeMillis();
+ this.stat.finishedJob(200);
+ assertTrue(this.stat.getStartTime() >= START_TIME);
+ assertEquals(400, this.stat.getAverageProcessingTime());
+ assertEquals(200, this.stat.getAverageWaitingTime());
+ assertEquals(0, this.stat.getNumberOfActiveJobs());
+ assertEquals(0, this.stat.getNumberOfCancelledJobs());
+ assertEquals(0, this.stat.getNumberOfFailedJobs());
+ assertEquals(3, this.stat.getNumberOfFinishedJobs());
+ assertEquals(0, this.stat.getNumberOfJobs());
+ assertEquals(3, this.stat.getNumberOfProcessedJobs());
+ assertEquals(0, this.stat.getNumberOfQueuedJobs());
+ assertTrue(this.stat.getLastActivatedJobTime() <= now);
+ assertTrue(this.stat.getLastFinishedJobTime() >= now);
+ }
+
+ @org.junit.Test public void testFailAndCancel() {
+ // we start with the results from the previous test!
+ this.testFinished();
+
+ long now = System.currentTimeMillis();
+ this.stat.incQueued();
+ this.stat.addActive(200);
+ this.stat.failedJob();
+ this.stat.incQueued();
+ assertTrue(this.stat.getStartTime() >= START_TIME);
+ assertEquals(400, this.stat.getAverageProcessingTime());
+ assertEquals(200, this.stat.getAverageWaitingTime());
+ assertEquals(0, this.stat.getNumberOfActiveJobs());
+ assertEquals(0, this.stat.getNumberOfCancelledJobs());
+ assertEquals(1, this.stat.getNumberOfFailedJobs());
+ assertEquals(3, this.stat.getNumberOfFinishedJobs());
+ assertEquals(1, this.stat.getNumberOfJobs());
+ assertEquals(4, this.stat.getNumberOfProcessedJobs());
+ assertEquals(1, this.stat.getNumberOfQueuedJobs());
+ assertTrue(this.stat.getLastActivatedJobTime() >= now);
+ assertTrue(this.stat.getLastFinishedJobTime() <= now);
+
+ now = System.currentTimeMillis();
+ this.stat.addActive(200);
+ this.stat.cancelledJob();
+ assertTrue(this.stat.getStartTime() >= START_TIME);
+ assertEquals(400, this.stat.getAverageProcessingTime());
+ assertEquals(200, this.stat.getAverageWaitingTime());
+ assertEquals(0, this.stat.getNumberOfActiveJobs());
+ assertEquals(1, this.stat.getNumberOfCancelledJobs());
+ assertEquals(1, this.stat.getNumberOfFailedJobs());
+ assertEquals(3, this.stat.getNumberOfFinishedJobs());
+ assertEquals(0, this.stat.getNumberOfJobs());
+ assertEquals(5, this.stat.getNumberOfProcessedJobs());
+ assertEquals(0, this.stat.getNumberOfQueuedJobs());
+ assertTrue(this.stat.getLastActivatedJobTime() >= now);
+ assertTrue(this.stat.getLastFinishedJobTime() <= now);
+ }
+
+ @org.junit.Test public void testMisc() {
+ final StatisticsImpl stat2 = new StatisticsImpl(200);
+ assertEquals(200, stat2.getStartTime());
+
+ // update stat
+ this.testFailAndCancel();
+
+ long now = System.currentTimeMillis();
+ final StatisticsImpl copy = new StatisticsImpl();
+ copy.copyFrom(this.stat);
+ assertTrue(copy.getStartTime() >= now);
+ assertEquals(400, copy.getAverageProcessingTime());
+ assertEquals(200, copy.getAverageWaitingTime());
+ assertEquals(0, copy.getNumberOfActiveJobs());
+ assertEquals(1, copy.getNumberOfCancelledJobs());
+ assertEquals(1, copy.getNumberOfFailedJobs());
+ assertEquals(3, copy.getNumberOfFinishedJobs());
+ assertEquals(0, copy.getNumberOfJobs());
+ assertEquals(5, copy.getNumberOfProcessedJobs());
+ assertEquals(0, copy.getNumberOfQueuedJobs());
+ assertTrue(copy.getLastActivatedJobTime() <= now);
+ assertTrue(copy.getLastFinishedJobTime() <= now);
+
+ now = System.currentTimeMillis();
+ this.stat.incQueued();
+ this.stat.addActive(200);
+ this.stat.finishedJob(400);
+ assertEquals(400, this.stat.getAverageProcessingTime());
+ assertEquals(200, this.stat.getAverageWaitingTime());
+ assertEquals(0, this.stat.getNumberOfActiveJobs());
+ assertEquals(1, this.stat.getNumberOfCancelledJobs());
+ assertEquals(1, this.stat.getNumberOfFailedJobs());
+ assertEquals(4, this.stat.getNumberOfFinishedJobs());
+ assertEquals(0, this.stat.getNumberOfJobs());
+ assertEquals(6, this.stat.getNumberOfProcessedJobs());
+ assertEquals(0, this.stat.getNumberOfQueuedJobs());
+ assertTrue(this.stat.getLastActivatedJobTime() >= now);
+ assertTrue(this.stat.getLastFinishedJobTime() >= now);
+
+ copy.add(this.stat);
+ assertTrue(copy.getStartTime() <= now);
+ assertEquals(400, copy.getAverageProcessingTime());
+ assertEquals(200, copy.getAverageWaitingTime());
+ assertEquals(0, copy.getNumberOfActiveJobs());
+ assertEquals(2, copy.getNumberOfCancelledJobs());
+ assertEquals(2, copy.getNumberOfFailedJobs());
+ assertEquals(7, copy.getNumberOfFinishedJobs());
+ assertEquals(0, copy.getNumberOfJobs());
+ assertEquals(11, copy.getNumberOfProcessedJobs());
+ assertEquals(0, copy.getNumberOfQueuedJobs());
+ assertTrue(copy.getLastActivatedJobTime() >= now);
+ assertTrue(copy.getLastFinishedJobTime() >= now);
+
+ this.stat.incQueued();
+ this.stat.incQueued();
+ assertEquals(400, this.stat.getAverageProcessingTime());
+ assertEquals(200, this.stat.getAverageWaitingTime());
+ assertEquals(0, this.stat.getNumberOfActiveJobs());
+ assertEquals(1, this.stat.getNumberOfCancelledJobs());
+ assertEquals(1, this.stat.getNumberOfFailedJobs());
+ assertEquals(4, this.stat.getNumberOfFinishedJobs());
+ assertEquals(2, this.stat.getNumberOfJobs());
+ assertEquals(6, this.stat.getNumberOfProcessedJobs());
+ assertEquals(2, this.stat.getNumberOfQueuedJobs());
+ assertTrue(this.stat.getLastActivatedJobTime() >= now);
+ assertTrue(this.stat.getLastFinishedJobTime() >= now);
+
+ this.stat.clearQueued();
+ assertEquals(400, this.stat.getAverageProcessingTime());
+ assertEquals(200, this.stat.getAverageWaitingTime());
+ assertEquals(0, this.stat.getNumberOfActiveJobs());
+ assertEquals(1, this.stat.getNumberOfCancelledJobs());
+ assertEquals(1, this.stat.getNumberOfFailedJobs());
+ assertEquals(4, this.stat.getNumberOfFinishedJobs());
+ assertEquals(0, this.stat.getNumberOfJobs());
+ assertEquals(6, this.stat.getNumberOfProcessedJobs());
+ assertEquals(0, this.stat.getNumberOfQueuedJobs());
+ assertTrue(this.stat.getLastActivatedJobTime() >= now);
+ assertTrue(this.stat.getLastFinishedJobTime() >= now);
+ }
+}
diff --git a/src/test/java/org/apache/sling/event/impl/jobs/UtilityTest.java b/src/test/java/org/apache/sling/event/impl/jobs/UtilityTest.java
new file mode 100644
index 0000000..94c60ae
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/impl/jobs/UtilityTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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.event.impl.jobs;
+
+import junit.framework.TestCase;
+
+import org.apache.sling.event.impl.support.ResourceHelper;
+
+public class UtilityTest extends TestCase {
+
+ public void test_filter_allowed() {
+ final String allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz0123456789_,.-+#!?$%&()=";
+ assertEquals("Allowed Characters must not be filtered", allowed,
+ ResourceHelper.filterName(allowed));
+ }
+
+ public void test_filter_illegal_jcr() {
+ assertEquals("_", ResourceHelper.filterName("["));
+ assertEquals("_", ResourceHelper.filterName("]"));
+ assertEquals("_", ResourceHelper.filterName("*"));
+ assertEquals("_", ResourceHelper.filterName("/"));
+ assertEquals("_", ResourceHelper.filterName(":"));
+ assertEquals("_", ResourceHelper.filterName("'"));
+ assertEquals("_", ResourceHelper.filterName("\""));
+
+ assertEquals("a_b", ResourceHelper.filterName("a[b"));
+ assertEquals("a_b", ResourceHelper.filterName("a]b"));
+ assertEquals("a_b", ResourceHelper.filterName("a*b"));
+ assertEquals("a_b", ResourceHelper.filterName("a/b"));
+ assertEquals("a_b", ResourceHelper.filterName("a:b"));
+ assertEquals("a_b", ResourceHelper.filterName("a'b"));
+ assertEquals("a_b", ResourceHelper.filterName("a\"b"));
+
+ assertEquals("_b", ResourceHelper.filterName("[b"));
+ assertEquals("_b", ResourceHelper.filterName("]b"));
+ assertEquals("_b", ResourceHelper.filterName("*b"));
+ assertEquals("_b", ResourceHelper.filterName("/b"));
+ assertEquals("_b", ResourceHelper.filterName(":b"));
+ assertEquals("_b", ResourceHelper.filterName("'b"));
+ assertEquals("_b", ResourceHelper.filterName("\"b"));
+
+ assertEquals("a_", ResourceHelper.filterName("a["));
+ assertEquals("a_", ResourceHelper.filterName("a]"));
+ assertEquals("a_", ResourceHelper.filterName("a*"));
+ assertEquals("a_", ResourceHelper.filterName("a/"));
+ assertEquals("a_", ResourceHelper.filterName("a:"));
+ assertEquals("a_", ResourceHelper.filterName("a'"));
+ assertEquals("a_", ResourceHelper.filterName("a\""));
+ }
+
+ public void test_filter_consecutive_replace() {
+ assertEquals("a_b_", ResourceHelper.filterName("a/[b]"));
+ }
+
+ public void test_checkJobTopic() {
+ assertNull (Utility.checkJobTopic("simpleTopic"));
+ final String result = Utility.checkJobTopic("simpleTopic.withDots");
+ assertNotNull(result);
+ assertTrue ("Discarding job - job has an illegal job topic 'simpleTopic.withDots'".equals(result));
+ assertNotNull (Utility.checkJobTopic(new StringBuilder("simpleTopic")));
+ assertNotNull (Utility.checkJobTopic(null));
+ }
+
+}
diff --git a/src/test/java/org/apache/sling/event/impl/jobs/config/InternalQueueConfigurationTest.java b/src/test/java/org/apache/sling/event/impl/jobs/config/InternalQueueConfigurationTest.java
new file mode 100644
index 0000000..d485b2b
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/impl/jobs/config/InternalQueueConfigurationTest.java
@@ -0,0 +1,195 @@
+/*
+ * 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.event.impl.jobs.config;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class InternalQueueConfigurationTest {
+
+ @org.junit.Test public void testMaxParallel() {
+ final Map<String, Object> p = new HashMap<String, Object>();
+ p.put(ConfigurationConstants.PROP_NAME, "QueueConfigurationTest");
+ p.put(ConfigurationConstants.PROP_MAX_PARALLEL, -1);
+
+ InternalQueueConfiguration c = InternalQueueConfiguration.fromConfiguration(p);
+ assertEquals(Runtime.getRuntime().availableProcessors(), c.getMaxParallel());
+
+ // Edge cases 0.0 and 1.0 (treated as int numbers)
+ p.put(ConfigurationConstants.PROP_MAX_PARALLEL, 0.0);
+ c = InternalQueueConfiguration.fromConfiguration(p);
+ assertEquals(0, c.getMaxParallel());
+
+ p.put(ConfigurationConstants.PROP_MAX_PARALLEL, 1.0);
+ c = InternalQueueConfiguration.fromConfiguration(p);
+ assertEquals(1, c.getMaxParallel());
+
+ // percentage (50%)
+ p.put(ConfigurationConstants.PROP_MAX_PARALLEL, 0.5);
+ c = InternalQueueConfiguration.fromConfiguration(p);
+ assertEquals((int) Math.round(Runtime.getRuntime().availableProcessors() * 0.5), c.getMaxParallel());
+
+ // rounding
+ p.put(ConfigurationConstants.PROP_MAX_PARALLEL, 0.90);
+ c = InternalQueueConfiguration.fromConfiguration(p);
+ assertEquals((int) Math.round(Runtime.getRuntime().availableProcessors() * 0.9), c.getMaxParallel());
+
+ p.put(ConfigurationConstants.PROP_MAX_PARALLEL, 0.99);
+ c = InternalQueueConfiguration.fromConfiguration(p);
+ assertEquals((int) Math.round(Runtime.getRuntime().availableProcessors() * 0.99), c.getMaxParallel());
+
+ // Percentages can't go over 99% (0.99)
+ p.put(ConfigurationConstants.PROP_MAX_PARALLEL, 1.01);
+ c = InternalQueueConfiguration.fromConfiguration(p);
+ assertEquals(Runtime.getRuntime().availableProcessors(), c.getMaxParallel());
+
+ // Treat negative values same a -1 (all cores)
+ p.put(ConfigurationConstants.PROP_MAX_PARALLEL, -0.5);
+ c = InternalQueueConfiguration.fromConfiguration(p);
+ assertEquals(Runtime.getRuntime().availableProcessors(), c.getMaxParallel());
+
+ p.put(ConfigurationConstants.PROP_MAX_PARALLEL, -2);
+ c = InternalQueueConfiguration.fromConfiguration(p);
+ assertEquals(Runtime.getRuntime().availableProcessors(), c.getMaxParallel());
+
+ // Invalid number results in ConfigurationConstants.DEFAULT_MAX_PARALLEL
+ p.put(ConfigurationConstants.PROP_MAX_PARALLEL, "a string");
+ c = InternalQueueConfiguration.fromConfiguration(p);
+ assertEquals(ConfigurationConstants.DEFAULT_MAX_PARALLEL, c.getMaxParallel());
+ }
+
+ @org.junit.Test public void testTopicMatchersDot() {
+ final Map<String, Object> p = new HashMap<String, Object>();
+ p.put(ConfigurationConstants.PROP_TOPICS, new String[] {"a."});
+ p.put(ConfigurationConstants.PROP_NAME, "test");
+
+ InternalQueueConfiguration c = InternalQueueConfiguration.fromConfiguration(p);
+ assertTrue(c.isValid());
+ assertNotNull(c.match("a/b"));
+ assertNotNull(c.match("a/c"));
+ assertNull(c.match("a"));
+ assertNull(c.match("a/b/c"));
+ assertNull(c.match("t"));
+ assertNull(c.match("t/x"));
+ }
+
+ @org.junit.Test public void testTopicMatchersStar() {
+ final Map<String, Object> p = new HashMap<String, Object>();
+ p.put(ConfigurationConstants.PROP_TOPICS, new String[] {"a*"});
+ p.put(ConfigurationConstants.PROP_NAME, "test");
+
+ InternalQueueConfiguration c = InternalQueueConfiguration.fromConfiguration(p);
+ assertTrue(c.isValid());
+ assertNotNull(c.match("a/b"));
+ assertNotNull(c.match("a/c"));
+ assertNull(c.match("a"));
+ assertNotNull(c.match("a/b/c"));
+ assertNull(c.match("t"));
+ assertNull(c.match("t/x"));
+ }
+
+ @org.junit.Test public void testTopicMatchers() {
+ final Map<String, Object> p = new HashMap<String, Object>();
+ p.put(ConfigurationConstants.PROP_TOPICS, new String[] {"a"});
+ p.put(ConfigurationConstants.PROP_NAME, "test");
+
+ InternalQueueConfiguration c = InternalQueueConfiguration.fromConfiguration(p);
+ assertTrue(c.isValid());
+ assertNull(c.match("a/b"));
+ assertNull(c.match("a/c"));
+ assertNotNull(c.match("a"));
+ assertNull(c.match("a/b/c"));
+ assertNull(c.match("t"));
+ assertNull(c.match("t/x"));
+ }
+
+ @org.junit.Test public void testTopicMatcherAndReplacement() {
+ final Map<String, Object> p = new HashMap<String, Object>();
+ p.put(ConfigurationConstants.PROP_TOPICS, new String[] {"a."});
+ p.put(ConfigurationConstants.PROP_NAME, "test-queue-{0}");
+
+ InternalQueueConfiguration c = InternalQueueConfiguration.fromConfiguration(p);
+ assertTrue(c.isValid());
+ final String b = "a/b";
+ assertNotNull(c.match(b));
+ assertEquals("test-queue-b", c.match(b));
+ final String d = "a/d";
+ assertNotNull(c.match(d));
+ assertEquals("test-queue-d", c.match(d));
+ }
+
+ @org.junit.Test public void testTopicMatchersDotAndSlash() {
+ final Map<String, Object> p = new HashMap<String, Object>();
+ p.put(ConfigurationConstants.PROP_TOPICS, new String[] {"a/."});
+ p.put(ConfigurationConstants.PROP_NAME, "test");
+
+ InternalQueueConfiguration c = InternalQueueConfiguration.fromConfiguration(p);
+ assertTrue(c.isValid());
+ assertNotNull(c.match("a/b"));
+ assertNotNull(c.match("a/c"));
+ assertNull(c.match("a"));
+ assertNull(c.match("a/b/c"));
+ assertNull(c.match("t"));
+ assertNull(c.match("t/x"));
+ }
+
+ @org.junit.Test public void testTopicMatchersStarAndSlash() {
+ final Map<String, Object> p = new HashMap<String, Object>();
+ p.put(ConfigurationConstants.PROP_TOPICS, new String[] {"a/*"});
+ p.put(ConfigurationConstants.PROP_NAME, "test");
+
+ InternalQueueConfiguration c = InternalQueueConfiguration.fromConfiguration(p);
+ assertTrue(c.isValid());
+ assertNotNull(c.match("a/b"));
+ assertNotNull(c.match("a/c"));
+ assertNull(c.match("a"));
+ assertNotNull(c.match("a/b/c"));
+ assertNull(c.match("t"));
+ assertNull(c.match("t/x"));
+ }
+
+ @org.junit.Test public void testTopicMatcherAndReplacementAndSlash() {
+ final Map<String, Object> p = new HashMap<String, Object>();
+ p.put(ConfigurationConstants.PROP_TOPICS, new String[] {"a/."});
+ p.put(ConfigurationConstants.PROP_NAME, "test-queue-{0}");
+
+ InternalQueueConfiguration c = InternalQueueConfiguration.fromConfiguration(p);
+ assertTrue(c.isValid());
+ final String b = "a/b";
+ assertNotNull(c.match(b));
+ assertEquals("test-queue-b", c.match(b));
+ final String d = "a/d";
+ assertNotNull(c.match(d));
+ assertEquals("test-queue-d", c.match(d));
+ }
+
+ @org.junit.Test public void testNoTopicMatchers() {
+ final Map<String, Object> p = new HashMap<String, Object>();
+ p.put(ConfigurationConstants.PROP_NAME, "test");
+
+ InternalQueueConfiguration c = InternalQueueConfiguration.fromConfiguration(p);
+ assertFalse(c.isValid());
+ }
+}
diff --git a/src/test/java/org/apache/sling/event/impl/jobs/config/JobManagerConfigurationTest.java b/src/test/java/org/apache/sling/event/impl/jobs/config/JobManagerConfigurationTest.java
new file mode 100644
index 0000000..486b823
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/impl/jobs/config/JobManagerConfigurationTest.java
@@ -0,0 +1,259 @@
+/*
+ * 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.event.impl.jobs.config;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Timer;
+import java.util.TimerTask;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.sling.commons.scheduler.ScheduleOptions;
+import org.apache.sling.commons.scheduler.Scheduler;
+import org.apache.sling.discovery.ClusterView;
+import org.apache.sling.discovery.InstanceDescription;
+import org.apache.sling.discovery.TopologyEvent;
+import org.apache.sling.discovery.TopologyEventListener;
+import org.apache.sling.discovery.TopologyView;
+import org.apache.sling.discovery.commons.InitDelayingTopologyEventListener;
+import org.apache.sling.event.impl.TestUtil;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+public class JobManagerConfigurationTest {
+
+ private TopologyView createView() {
+ final TopologyView view = Mockito.mock(TopologyView.class);
+ Mockito.when(view.isCurrent()).thenReturn(true);
+ final InstanceDescription local = Mockito.mock(InstanceDescription.class);
+ Mockito.when(local.isLeader()).thenReturn(true);
+ Mockito.when(local.isLocal()).thenReturn(true);
+ Mockito.when(local.getSlingId()).thenReturn("id");
+
+ Mockito.when(view.getLocalInstance()).thenReturn(local);
+ final ClusterView localView = Mockito.mock(ClusterView.class);
+ Mockito.when(localView.getId()).thenReturn("1");
+ Mockito.when(localView.getInstances()).thenReturn(Collections.singletonList(local));
+ Mockito.when(view.getClusterViews()).thenReturn(Collections.singleton(localView));
+ Mockito.when(local.getClusterView()).thenReturn(localView);
+
+ return view;
+ }
+
+ private static class ChangeListener implements ConfigurationChangeListener {
+
+ public final List<Boolean> events = new ArrayList<Boolean>();
+ private volatile CountDownLatch latch;
+
+ public void init(final int count) {
+ events.clear();
+ latch = new CountDownLatch(count);
+ }
+
+ public void await() throws Exception {
+ if ( !latch.await(8000, TimeUnit.MILLISECONDS) ) {
+ throw new Exception("No configuration event within 8 seconds.");
+ }
+ }
+
+ @Override
+ public void configurationChanged(boolean active) {
+ events.add(active);
+ latch.countDown();
+ }
+ }
+
+ private Scheduler createScheduler() {
+ return new Scheduler() {
+
+ @Override
+ public boolean unschedule(String jobName) {
+ // TODO Auto-generated method stub
+ return false;
+ }
+
+ @Override
+ public boolean schedule(final Object job, ScheduleOptions options) {
+ if ( job instanceof Runnable ) {
+ final Timer t = new Timer();
+ t.schedule(new TimerTask() {
+
+ @Override
+ public void run() {
+ ((Runnable)job).run();
+ }
+ }, 3000);
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public void removeJob(String name) throws NoSuchElementException {
+ // TODO Auto-generated method stub
+
+ }
+
+ @Override
+ public boolean fireJobAt(String name, Object job, Map<String, Serializable> config, Date date, int times,
+ long period) {
+ // TODO Auto-generated method stub
+ return false;
+ }
+
+ @Override
+ public void fireJobAt(String name, Object job, Map<String, Serializable> config, Date date) throws Exception {
+ // TODO Auto-generated method stub
+
+ }
+
+ @Override
+ public boolean fireJob(Object job, Map<String, Serializable> config, int times, long period) {
+ // TODO Auto-generated method stub
+ return false;
+ }
+
+ @Override
+ public void fireJob(Object job, Map<String, Serializable> config) throws Exception {
+ // TODO Auto-generated method stub
+
+ }
+
+ @Override
+ public void addPeriodicJob(String name, Object job, Map<String, Serializable> config, long period,
+ boolean canRunConcurrently, boolean startImmediate) throws Exception {
+ // TODO Auto-generated method stub
+
+ }
+
+ @Override
+ public void addPeriodicJob(String name, Object job, Map<String, Serializable> config, long period,
+ boolean canRunConcurrently) throws Exception {
+ // TODO Auto-generated method stub
+
+ }
+
+ @Override
+ public void addJob(String name, Object job, Map<String, Serializable> config, String schedulingExpression,
+ boolean canRunConcurrently) throws Exception {
+ // TODO Auto-generated method stub
+
+ }
+
+ @Override
+ public ScheduleOptions NOW(int times, long period) {
+ // TODO Auto-generated method stub
+ return null;
+ }
+
+ @Override
+ public ScheduleOptions NOW() {
+ // TODO Auto-generated method stub
+ return null;
+ }
+
+ @Override
+ public ScheduleOptions EXPR(String expression) {
+ // TODO Auto-generated method stub
+ return null;
+ }
+
+ @Override
+ public ScheduleOptions AT(Date date, int times, long period) {
+ // TODO Auto-generated method stub
+ return null;
+ }
+
+ @Override
+ public ScheduleOptions AT(Date date) {
+ // TODO Auto-generated method stub
+ return null;
+ }
+ };
+ }
+
+ @Test public void testTopologyChange() throws Exception {
+ // mock scheduler
+ final Scheduler scheduler = this.createScheduler();
+ final ChangeListener ccl = new ChangeListener();
+
+ // add change listener and verify
+ ccl.init(1);
+ final JobManagerConfiguration config = new JobManagerConfiguration();
+ TestUtil.setFieldValue(config, "scheduler", scheduler);
+ ((AtomicBoolean)TestUtil.getFieldValue(config, "active")).set(true);
+ InitDelayingTopologyEventListener startupDelayListener = new InitDelayingTopologyEventListener(1, new TopologyEventListener() {
+
+ @Override
+ public void handleTopologyEvent(TopologyEvent event) {
+ config.doHandleTopologyEvent(event);
+ }
+ }, scheduler);;
+ TestUtil.setFieldValue(config, "startupDelayListener", startupDelayListener);
+
+ config.addListener(ccl);
+ ccl.await();
+
+ assertEquals(1, ccl.events.size());
+ assertFalse(ccl.events.get(0));
+
+ // create init view
+ ccl.init(1);
+ final TopologyView initView = createView();
+ final TopologyEvent init = new TopologyEvent(TopologyEvent.Type.TOPOLOGY_INIT, null, initView);
+ config.handleTopologyEvent(init);
+ ccl.await();
+
+ assertEquals(1, ccl.events.size());
+ assertTrue(ccl.events.get(0));
+
+ // change view, followed by change props
+ ccl.init(2);
+ final TopologyView view2 = createView();
+ Mockito.when(initView.isCurrent()).thenReturn(false);
+ final TopologyEvent change1 = new TopologyEvent(TopologyEvent.Type.TOPOLOGY_CHANGED, initView, view2);
+ final TopologyView view3 = createView();
+ final TopologyEvent change2 = new TopologyEvent(TopologyEvent.Type.PROPERTIES_CHANGED, view2, view3);
+
+ config.handleTopologyEvent(change1);
+ Mockito.when(view2.isCurrent()).thenReturn(false);
+ config.handleTopologyEvent(change2);
+
+ ccl.await();
+ assertEquals(2, ccl.events.size());
+ assertFalse(ccl.events.get(0));
+ assertTrue(ccl.events.get(1));
+
+ // we wait another 4 secs to see if there is no another event
+ Thread.sleep(4000);
+ assertEquals(2, ccl.events.size());
+
+ }
+}
diff --git a/src/test/java/org/apache/sling/event/impl/jobs/config/TopologyCapabilitiesTest.java b/src/test/java/org/apache/sling/event/impl/jobs/config/TopologyCapabilitiesTest.java
new file mode 100644
index 0000000..1e54f3d
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/impl/jobs/config/TopologyCapabilitiesTest.java
@@ -0,0 +1,71 @@
+/*
+ * 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.event.impl.jobs.config;
+
+import static org.junit.Assert.assertEquals;
+
+import java.util.Collections;
+
+import org.apache.sling.discovery.ClusterView;
+import org.apache.sling.discovery.InstanceDescription;
+import org.apache.sling.discovery.TopologyView;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+public class TopologyCapabilitiesTest {
+
+ private TopologyCapabilities caps;
+
+ @Before
+ public void setup() {
+ // local cluster view
+ final ClusterView cv = Mockito.mock(ClusterView.class);
+ Mockito.when(cv.getId()).thenReturn("cluster");
+
+ // local description
+ final InstanceDescription local = Mockito.mock(InstanceDescription.class);
+ Mockito.when(local.isLeader()).thenReturn(true);
+ Mockito.when(local.getSlingId()).thenReturn("local");
+ Mockito.when(local.getProperty(TopologyCapabilities.PROPERTY_TOPICS)).thenReturn("foo,bar/*,a/**,d/1/2,d/1/*,d/**");
+ Mockito.when(local.getClusterView()).thenReturn(cv);
+
+ // topology view
+ final TopologyView tv = Mockito.mock(TopologyView.class);
+ Mockito.when(tv.getInstances()).thenReturn(Collections.singleton(local));
+ Mockito.when(tv.getLocalInstance()).thenReturn(local);
+
+ final JobManagerConfiguration config = Mockito.mock(JobManagerConfiguration.class);
+
+ caps = new TopologyCapabilities(tv, config);
+ }
+
+ @Test public void testMatching() {
+ assertEquals(1, caps.getPotentialTargets("foo").size());
+ assertEquals(0, caps.getPotentialTargets("foo/a").size());
+ assertEquals(0, caps.getPotentialTargets("bar").size());
+ assertEquals(1, caps.getPotentialTargets("bar/foo").size());
+ assertEquals(0, caps.getPotentialTargets("bar/foo/a").size());
+ assertEquals(1, caps.getPotentialTargets("a/b").size());
+ assertEquals(1, caps.getPotentialTargets("a/b(c").size());
+ assertEquals(0, caps.getPotentialTargets("x").size());
+ assertEquals(0, caps.getPotentialTargets("x/y").size());
+ assertEquals(1, caps.getPotentialTargets("d/1/2").size());
+ }
+}
diff --git a/src/test/java/org/apache/sling/event/impl/jobs/jmx/AllJobStatisticsMBeanTest.java b/src/test/java/org/apache/sling/event/impl/jobs/jmx/AllJobStatisticsMBeanTest.java
new file mode 100644
index 0000000..f2e6762
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/impl/jobs/jmx/AllJobStatisticsMBeanTest.java
@@ -0,0 +1,69 @@
+/*
+ * 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 SF 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.event.impl.jobs.jmx;
+
+import java.util.Date;
+
+import org.apache.sling.event.impl.TestUtil;
+import org.apache.sling.event.jobs.JobManager;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+public class AllJobStatisticsMBeanTest {
+
+ private AllJobStatisticsMBean mbean;
+ @Mock
+ private JobManager jobManager;
+ private long seed;
+
+ public AllJobStatisticsMBeanTest() {
+ MockitoAnnotations.initMocks(this);
+ }
+
+ @Before
+ public void setup() throws NoSuchFieldException {
+ mbean = new AllJobStatisticsMBean();
+ TestUtil.setFieldValue(mbean, "jobManager", jobManager);
+ seed = System.currentTimeMillis();
+ Mockito.when(jobManager.getStatistics()).thenReturn(
+ new DummyStatistics(seed));
+ }
+
+ @Test
+ public void testStatistics() {
+ Assert.assertEquals(seed + 1, mbean.getStartTime());
+ Assert.assertEquals(seed + 2, mbean.getNumberOfFinishedJobs());
+ Assert.assertEquals(seed + 3, mbean.getNumberOfCancelledJobs());
+ Assert.assertEquals(seed + 4, mbean.getNumberOfFailedJobs());
+ Assert.assertEquals(seed + 5, mbean.getNumberOfProcessedJobs());
+ Assert.assertEquals(seed + 6, mbean.getNumberOfActiveJobs());
+ Assert.assertEquals(seed + 7, mbean.getNumberOfQueuedJobs());
+ Assert.assertEquals(seed + 8, mbean.getNumberOfJobs());
+ Assert.assertEquals(seed + 9, mbean.getLastActivatedJobTime());
+ Assert.assertEquals(new Date(seed + 9), mbean.getLastActivatedJobDate());
+ Assert.assertEquals(seed + 10, mbean.getLastFinishedJobTime());
+ Assert.assertEquals(new Date(seed + 10), mbean.getLastFinishedJobDate());
+ Assert.assertEquals(seed + 11, mbean.getAverageWaitingTime());
+ Assert.assertEquals(seed + 12, mbean.getAverageProcessingTime());
+ }
+
+}
diff --git a/src/test/java/org/apache/sling/event/impl/jobs/jmx/DummyStatistics.java b/src/test/java/org/apache/sling/event/impl/jobs/jmx/DummyStatistics.java
new file mode 100644
index 0000000..f774bf3
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/impl/jobs/jmx/DummyStatistics.java
@@ -0,0 +1,84 @@
+/*
+ * 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 SF 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.event.impl.jobs.jmx;
+
+import org.apache.sling.event.jobs.Statistics;
+
+/**
+ * Dummy statistics for testing purposes.
+ */
+public class DummyStatistics implements Statistics {
+
+ private long base;
+
+ public DummyStatistics(long base) {
+ this.base = base;
+
+ }
+ public long getStartTime() {
+ return base+1;
+ }
+
+ public long getNumberOfFinishedJobs() {
+ return base+2;
+ }
+
+ public long getNumberOfCancelledJobs() {
+ return base+3;
+ }
+
+ public long getNumberOfFailedJobs() {
+ return base+4;
+ }
+
+ public long getNumberOfProcessedJobs() {
+ return base+5;
+ }
+
+ public long getNumberOfActiveJobs() {
+ return base+6;
+ }
+
+ public long getNumberOfQueuedJobs() {
+ return base+7;
+ }
+
+ public long getNumberOfJobs() {
+ return base+8;
+ }
+
+ public long getLastActivatedJobTime() {
+ return base+9;
+ }
+
+ public long getLastFinishedJobTime() {
+ return base+10;
+ }
+
+ public long getAverageWaitingTime() {
+ return base+11;
+ }
+
+ public long getAverageProcessingTime() {
+ return base+12;
+ }
+
+ public void reset() {
+ }
+
+}
diff --git a/src/test/java/org/apache/sling/event/impl/jobs/jmx/QueuesMBeanImplTest.java b/src/test/java/org/apache/sling/event/impl/jobs/jmx/QueuesMBeanImplTest.java
new file mode 100644
index 0000000..f8b5740
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/impl/jobs/jmx/QueuesMBeanImplTest.java
@@ -0,0 +1,134 @@
+/*
+ * 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 SF 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.event.impl.jobs.jmx;
+
+import java.util.Date;
+import java.util.Dictionary;
+
+import org.apache.sling.event.jobs.Queue;
+import org.apache.sling.event.jobs.Statistics;
+import org.apache.sling.event.jobs.jmx.StatisticsMBean;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.ServiceRegistration;
+
+public class QueuesMBeanImplTest {
+
+ private QueuesMBeanImpl mbean;
+ @Mock
+ private BundleContext bundleContext;
+ @Captor
+ private ArgumentCaptor<String> serviceClass;
+ @Captor
+ private ArgumentCaptor<Object> serviceObject;
+ @SuppressWarnings("rawtypes")
+ @Captor
+ private ArgumentCaptor<Dictionary> serviceProperties;
+ @Mock
+ private ServiceRegistration serviceRegistration;
+
+ public QueuesMBeanImplTest() {
+ MockitoAnnotations.initMocks(this);
+ }
+
+ @Before
+ public void setup() throws NoSuchFieldException {
+ mbean = new QueuesMBeanImpl();
+ mbean.activate(bundleContext);
+ }
+
+
+ @Test
+ public void testAddQueue() {
+ addQueue();
+ }
+
+ public Queue addQueue() {
+ Queue queue = Mockito.mock(Queue.class, Mockito.withSettings().extraInterfaces(Statistics.class));
+ mockStatistics((Statistics) queue);
+ Mockito.when(queue.getName()).thenReturn("queue-name");
+ Mockito.when(bundleContext.registerService(Mockito.anyString(), Mockito.any(StatisticsMBean.class), Mockito.any(Dictionary.class))).thenReturn(serviceRegistration);
+ mbean.sendEvent(new QueueStatusEvent(queue,null));
+ Mockito.verify(bundleContext, Mockito.only()).registerService(serviceClass.capture(), serviceObject.capture(), serviceProperties.capture());
+ Assert.assertEquals("Expected bean to be registerd as a StatisticsMBean ", StatisticsMBean.class.getName(), serviceClass.getValue());
+ Assert.assertTrue("Expected service to be an instance of SatisticsMBean", serviceObject.getValue() instanceof StatisticsMBean);
+ Assert.assertNotNull("Expected properties to have a jmx.objectname", serviceProperties.getValue().get("jmx.objectname"));
+ testStatistics((StatisticsMBean) serviceObject.getValue());
+ return queue;
+ }
+
+
+ @Test
+ public void updateQueue() {
+ Queue firstQueue = addQueue();
+ Queue queue = Mockito.mock(Queue.class, Mockito.withSettings().extraInterfaces(Statistics.class));
+ Mockito.when(queue.getName()).thenReturn("queue-name-changed");
+ Mockito.reset(bundleContext);
+ mbean.sendEvent(new QueueStatusEvent(queue,firstQueue));
+ Mockito.verify(bundleContext, Mockito.never()).registerService(serviceClass.capture(), serviceObject.capture(), serviceProperties.capture());
+ }
+
+ @Test
+ public void removeQueue() {
+ Queue firstQueue = addQueue();
+ mbean.sendEvent(new QueueStatusEvent(null,firstQueue));
+ Mockito.verify(serviceRegistration, Mockito.only()).unregister();
+
+ }
+
+ private void mockStatistics(Statistics queue) {
+ Mockito.when(queue.getStartTime()).thenReturn(1L);
+ Mockito.when(queue.getNumberOfFinishedJobs()).thenReturn(2L);
+ Mockito.when(queue.getNumberOfCancelledJobs()).thenReturn(3L);
+ Mockito.when(queue.getNumberOfFailedJobs()).thenReturn(4L);
+ Mockito.when(queue.getNumberOfProcessedJobs()).thenReturn(5L);
+ Mockito.when(queue.getNumberOfActiveJobs()).thenReturn(6L);
+ Mockito.when(queue.getNumberOfQueuedJobs()).thenReturn(7L);
+ Mockito.when(queue.getNumberOfJobs()).thenReturn(8L);
+ Mockito.when(queue.getLastActivatedJobTime()).thenReturn(9L);
+ Mockito.when(queue.getLastFinishedJobTime()).thenReturn(10L);
+ Mockito.when(queue.getAverageWaitingTime()).thenReturn(11L);
+ Mockito.when(queue.getAverageProcessingTime()).thenReturn(12L);
+ }
+
+ public void testStatistics(StatisticsMBean statisticsMbean) {
+ Assert.assertEquals(1, statisticsMbean.getStartTime());
+ Assert.assertEquals(2, statisticsMbean.getNumberOfFinishedJobs());
+ Assert.assertEquals(3, statisticsMbean.getNumberOfCancelledJobs());
+ Assert.assertEquals(4, statisticsMbean.getNumberOfFailedJobs());
+ Assert.assertEquals(5, statisticsMbean.getNumberOfProcessedJobs());
+ Assert.assertEquals(6, statisticsMbean.getNumberOfActiveJobs());
+ Assert.assertEquals(7, statisticsMbean.getNumberOfQueuedJobs());
+ Assert.assertEquals(8, statisticsMbean.getNumberOfJobs());
+ Assert.assertEquals(9, statisticsMbean.getLastActivatedJobTime());
+ Assert.assertEquals(new Date(9), statisticsMbean.getLastActivatedJobDate());
+ Assert.assertEquals(10, statisticsMbean.getLastFinishedJobTime());
+ Assert.assertEquals(new Date(10), statisticsMbean.getLastFinishedJobDate());
+ Assert.assertEquals(11, statisticsMbean.getAverageWaitingTime());
+ Assert.assertEquals(12, statisticsMbean.getAverageProcessingTime());
+ }
+
+
+}
diff --git a/src/test/java/org/apache/sling/event/impl/jobs/tasks/HistoryCleanUpTaskTest.java b/src/test/java/org/apache/sling/event/impl/jobs/tasks/HistoryCleanUpTaskTest.java
new file mode 100644
index 0000000..6f92c1d
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/impl/jobs/tasks/HistoryCleanUpTaskTest.java
@@ -0,0 +1,106 @@
+/*
+ * 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.event.impl.jobs.tasks;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Map;
+
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.event.impl.jobs.JobImpl;
+import org.apache.sling.event.impl.jobs.config.JobManagerConfiguration;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.consumer.JobExecutionContext;
+import org.apache.sling.testing.mock.sling.junit.SlingContext;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Answers;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.runners.MockitoJUnitRunner;
+
+import com.google.common.collect.Maps;
+
+@RunWith(MockitoJUnitRunner.class)
+public class HistoryCleanUpTaskTest {
+
+ private static final String JCR_PATH = JobManagerConfiguration.DEFAULT_REPOSITORY_PATH + "/finished";
+ private static final String JCR_TOPIC = "test";
+ private static final String JCR_JOB_NAME = "test-job";
+ private static final SimpleDateFormat DATE_FORMATTER = new SimpleDateFormat("yyyy/MM/dd/HH/mm");
+ private static final int MAX_AGE_IN_DAYS = 60;
+
+ @Rule
+ public final SlingContext ctx = new SlingContext();
+
+ @Mock
+ private JobManagerConfiguration configuration;
+ private Job job;
+ @Mock(answer = Answers.RETURNS_MOCKS)
+ private JobExecutionContext jobContext;
+
+ private HistoryCleanUpTask task;
+
+ @Before
+ public void setUp() {
+ setUpJob();
+ setupConfiguration();
+ task = ctx.registerInjectActivateService(new HistoryCleanUpTask());
+ }
+
+ private void setupConfiguration() {
+ Mockito.when(configuration.getStoredSuccessfulJobsPath()).thenReturn(JCR_PATH);
+ Mockito.when(configuration.createResourceResolver()).thenReturn(ctx.resourceResolver());
+ ctx.registerService(JobManagerConfiguration.class, configuration);
+ }
+
+ private void setUpJob() {
+ Map<String, Object> parameters = Maps.<String, Object> newHashMap();
+ parameters.put("age", MAX_AGE_IN_DAYS * 24 * 60);
+ job = new JobImpl("not-relevant", "not-relevant_123", parameters);
+ Mockito.when(jobContext.isStopped()).thenReturn(false);
+ }
+
+ @Test
+ public void shouldNotDeleteResourcesYoungerThanRemoveDate() {
+ Resource resource = createResourceWithDaysBeforeDate(MAX_AGE_IN_DAYS / 2);
+ task.process(job, jobContext);
+ assertNotNull(ctx.resourceResolver().getResource(resource.getPath()));
+ }
+
+ @Test
+ public void shouldDeleteResourcesOlderThanRemoveDate() {
+ Resource resource = createResourceWithDaysBeforeDate(MAX_AGE_IN_DAYS * 2);
+ task.process(job, jobContext);
+ assertNull(ctx.resourceResolver().getResource(resource.getPath()));
+ }
+
+ private Resource createResourceWithDaysBeforeDate(int days) {
+ Calendar cal = Calendar.getInstance();
+ cal.add(Calendar.DAY_OF_YEAR, -days);
+ String path = JCR_PATH + '/' + JCR_TOPIC + '/' + DATE_FORMATTER.format(cal.getTime()) + '/' + JCR_JOB_NAME;
+ return ctx.create().resource(path);
+ }
+
+}
diff --git a/src/test/java/org/apache/sling/event/it/AbstractJobHandlingTest.java b/src/test/java/org/apache/sling/event/it/AbstractJobHandlingTest.java
new file mode 100644
index 0000000..3a77364
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/it/AbstractJobHandlingTest.java
@@ -0,0 +1,462 @@
+/*
+ * 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.event.it;
+
+
+import static org.ops4j.pax.exam.CoreOptions.frameworkProperty;
+import static org.ops4j.pax.exam.CoreOptions.junitBundles;
+import static org.ops4j.pax.exam.CoreOptions.mavenBundle;
+import static org.ops4j.pax.exam.CoreOptions.options;
+import static org.ops4j.pax.exam.CoreOptions.systemProperty;
+import static org.ops4j.pax.exam.CoreOptions.when;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Dictionary;
+import java.util.Hashtable;
+import java.util.List;
+
+import javax.inject.Inject;
+
+import org.apache.sling.api.resource.LoginException;
+import org.apache.sling.api.resource.PersistenceException;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.ResourceResolverFactory;
+import org.apache.sling.discovery.PropertyProvider;
+import org.apache.sling.event.impl.jobs.config.JobManagerConfiguration;
+import org.apache.sling.event.jobs.JobManager;
+import org.apache.sling.event.jobs.consumer.JobConsumer;
+import org.apache.sling.event.jobs.consumer.JobExecutor;
+import org.ops4j.pax.exam.Configuration;
+import org.ops4j.pax.exam.CoreOptions;
+import org.ops4j.pax.exam.Option;
+import org.ops4j.pax.exam.cm.ConfigurationAdminOptions;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.BundleException;
+import org.osgi.framework.InvalidSyntaxException;
+import org.osgi.framework.ServiceReference;
+import org.osgi.framework.ServiceRegistration;
+import org.osgi.service.cm.ConfigurationAdmin;
+import org.osgi.service.event.EventAdmin;
+import org.osgi.service.event.EventConstants;
+import org.osgi.service.event.EventHandler;
+import org.slf4j.LoggerFactory;
+
+public abstract class AbstractJobHandlingTest {
+
+ private static final String BUNDLE_JAR_SYS_PROP = "project.bundle.file";
+
+ /** The property containing the build directory. */
+ private static final String SYS_PROP_BUILD_DIR = "bundle.build.dir";
+
+ private static final String DEFAULT_BUILD_DIR = "target";
+
+ private static final String PORT_CONFIG = "org.osgi.service.http.port";
+
+ protected static final int DEFAULT_TEST_TIMEOUT = 1000*60*5;
+
+ @Inject
+ protected EventAdmin eventAdmin;
+
+ @Inject
+ protected ConfigurationAdmin configAdmin;
+
+ @Inject
+ protected BundleContext bc;
+
+ protected List<ServiceRegistration<?>> registrations = new ArrayList<>();
+
+ @Configuration
+ public Option[] config() {
+ final String buildDir = System.getProperty(SYS_PROP_BUILD_DIR, DEFAULT_BUILD_DIR);
+ final String bundleFileName = System.getProperty( BUNDLE_JAR_SYS_PROP );
+ final File bundleFile = new File( bundleFileName );
+ if ( !bundleFile.canRead() ) {
+ throw new IllegalArgumentException( "Cannot read from bundle file " + bundleFileName + " specified in the "
+ + BUNDLE_JAR_SYS_PROP + " system property" );
+ }
+
+ String localRepo = System.getProperty("maven.repo.local", "");
+
+ final String jackrabbitVersion = "2.13.1";
+ final String oakVersion = "1.5.7";
+
+ final String slingHome = new File(buildDir + File.separatorChar + "sling_" + System.currentTimeMillis()).getAbsolutePath();
+
+ return options(
+ frameworkProperty("sling.home").value(slingHome),
+ frameworkProperty("repository.home").value(slingHome + File.separatorChar + "repository"),
+ when( localRepo.length() > 0 ).useOptions(
+ systemProperty("org.ops4j.pax.url.mvn.localRepository").value(localRepo)
+ ),
+ when( System.getProperty(PORT_CONFIG) != null ).useOptions(
+ systemProperty(PORT_CONFIG).value(System.getProperty(PORT_CONFIG))),
+ systemProperty("pax.exam.osgi.unresolved.fail").value("true"),
+
+ ConfigurationAdminOptions.newConfiguration("org.apache.felix.jaas.ConfigurationSpi")
+ .create(true)
+ .put("jaas.defaultRealmName", "jackrabbit.oak")
+ .put("jaas.configProviderName", "FelixJaasProvider")
+ .asOption(),
+ ConfigurationAdminOptions.factoryConfiguration("org.apache.felix.jaas.Configuration.factory")
+ .create(true)
+ .put("jaas.controlFlag", "optional")
+ .put("jaas.classname", "org.apache.jackrabbit.oak.spi.security.authentication.GuestLoginModule")
+ .put("jaas.ranking", 300)
+ .asOption(),
+ ConfigurationAdminOptions.factoryConfiguration("org.apache.felix.jaas.Configuration.factory")
+ .create(true)
+ .put("jaas.controlFlag", "required")
+ .put("jaas.classname", "org.apache.jackrabbit.oak.security.authentication.user.LoginModuleImpl")
+ .asOption(),
+ ConfigurationAdminOptions.factoryConfiguration("org.apache.felix.jaas.Configuration.factory")
+ .create(true)
+ .put("jaas.controlFlag", "sufficient")
+ .put("jaas.classname", "org.apache.jackrabbit.oak.security.authentication.token.TokenLoginModule")
+ .put("jaas.ranking", 200)
+ .asOption(),
+ ConfigurationAdminOptions.newConfiguration("org.apache.jackrabbit.oak.security.authentication.AuthenticationConfigurationImpl")
+ .create(true)
+ .put("org.apache.jackrabbit.oak.authentication.configSpiName", "FelixJaasProvider")
+ .asOption(),
+ ConfigurationAdminOptions.newConfiguration("org.apache.jackrabbit.oak.security.user.UserConfigurationImpl")
+ .create(true)
+ .put("groupsPath", "/home/groups")
+ .put("usersPath", "/home/users")
+ .put("defaultPath", "1")
+ .put("importBehavior", "besteffort")
+ .asOption(),
+ ConfigurationAdminOptions.newConfiguration("org.apache.jackrabbit.oak.security.user.RandomAuthorizableNodeName")
+ .create(true)
+ .put("enabledActions", new String[] {"org.apache.jackrabbit.oak.spi.security.user.action.AccessControlAction"})
+ .put("userPrivilegeNames", new String[] {"jcr:all"})
+ .put("groupPrivilegeNames", new String[] {"jcr:read"})
+ .asOption(),
+ ConfigurationAdminOptions.newConfiguration("org.apache.jackrabbit.oak.spi.security.user.action.DefaultAuthorizableActionProvider")
+ .create(true)
+ .put("length", 21)
+ .asOption(),
+ ConfigurationAdminOptions.newConfiguration("org.apache.jackrabbit.oak.plugins.segment.SegmentNodeStoreService")
+ .create(true)
+ .put("name", "Default NodeStore")
+ .asOption(),
+
+ ConfigurationAdminOptions.factoryConfiguration("org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended")
+ .create(true)
+ .put("user.mapping", "org.apache.sling.event=admin")
+ .asOption(),
+ ConfigurationAdminOptions.newConfiguration("org.apache.sling.jcr.resource.internal.JcrSystemUserValidator")
+ .create(true)
+ .put("allow.only.system.user", "false")
+ .asOption(),
+
+ // logging
+ systemProperty("pax.exam.logging").value("none"),
+ mavenBundle("org.apache.sling", "org.apache.sling.commons.log", "4.0.6"),
+ mavenBundle("org.apache.sling", "org.apache.sling.commons.logservice", "1.0.6"),
+ mavenBundle("org.slf4j", "slf4j-api", "1.7.13"),
+ mavenBundle("org.slf4j", "jcl-over-slf4j", "1.7.13"),
+ mavenBundle("org.slf4j", "log4j-over-slf4j", "1.7.13"),
+
+ mavenBundle("commons-io", "commons-io", "2.4"),
+ mavenBundle("commons-fileupload", "commons-fileupload", "1.3.1"),
+ mavenBundle("commons-collections", "commons-collections", "3.2.2"),
+ mavenBundle("commons-codec", "commons-codec", "1.10"),
+ mavenBundle("commons-lang", "commons-lang", "2.6"),
+ mavenBundle("commons-pool", "commons-pool", "1.6"),
+
+ mavenBundle("org.apache.servicemix.bundles", "org.apache.servicemix.bundles.concurrent", "1.3.4_1"),
+
+ mavenBundle("org.apache.geronimo.bundles", "commons-httpclient", "3.1_1"),
+ mavenBundle("org.apache.tika", "tika-core", "1.9"),
+ mavenBundle("org.apache.tika", "tika-bundle", "1.9"),
+
+ // infrastructure
+ mavenBundle("org.apache.felix", "org.apache.felix.http.servlet-api", "1.1.2"),
+ mavenBundle("org.apache.felix", "org.apache.felix.http.jetty", "3.1.6"),
+ mavenBundle("org.apache.felix", "org.apache.felix.eventadmin", "1.4.8"),
+ mavenBundle("org.apache.felix", "org.apache.felix.scr", "2.0.6"),
+ mavenBundle("org.apache.felix", "org.apache.felix.configadmin", "1.8.10"),
+ mavenBundle("org.apache.felix", "org.apache.felix.inventory", "1.0.4"),
+ mavenBundle("org.apache.felix", "org.apache.felix.metatype", "1.1.2"),
+
+ // sling
+ mavenBundle("org.apache.sling", "org.apache.sling.settings", "1.3.8"),
+ mavenBundle("org.apache.sling", "org.apache.sling.commons.osgi", "2.3.0"),
+ mavenBundle("org.apache.sling", "org.apache.sling.commons.json", "2.0.16"),
+ mavenBundle("org.apache.sling", "org.apache.sling.commons.mime", "2.1.8"),
+ mavenBundle("org.apache.sling", "org.apache.sling.commons.classloader", "1.3.2"),
+ mavenBundle("org.apache.sling", "org.apache.sling.commons.scheduler", "2.4.14"),
+ mavenBundle("org.apache.sling", "org.apache.sling.commons.threads", "3.2.4"),
+
+ mavenBundle("org.apache.sling", "org.apache.sling.auth.core", "1.3.12"),
+ mavenBundle("org.apache.sling", "org.apache.sling.discovery.api", "1.0.2"),
+ mavenBundle("org.apache.sling", "org.apache.sling.discovery.commons", "1.0.12"),
+ mavenBundle("org.apache.sling", "org.apache.sling.discovery.standalone", "1.0.2"),
+
+ mavenBundle("org.apache.sling", "org.apache.sling.api", "2.14.2"),
+ mavenBundle("org.apache.sling", "org.apache.sling.resourceresolver", "1.4.18"),
+ mavenBundle("org.apache.sling", "org.apache.sling.adapter", "2.1.2"),
+ mavenBundle("org.apache.sling", "org.apache.sling.jcr.resource", "2.8.0"),
+ mavenBundle("org.apache.sling", "org.apache.sling.jcr.classloader", "3.2.2"),
+ mavenBundle("org.apache.sling", "org.apache.sling.jcr.contentloader", "2.1.8"),
+ mavenBundle("org.apache.sling", "org.apache.sling.engine", "2.6.2"),
+ mavenBundle("org.apache.sling", "org.apache.sling.serviceusermapper", "1.2.2"),
+
+ mavenBundle("org.apache.sling", "org.apache.sling.jcr.jcr-wrapper", "2.0.0"),
+ mavenBundle("org.apache.sling", "org.apache.sling.jcr.api", "2.4.0"),
+ mavenBundle("org.apache.sling", "org.apache.sling.jcr.base", "2.4.0"),
+
+ mavenBundle("com.google.guava", "guava", "15.0"),
+ mavenBundle("org.apache.jackrabbit", "jackrabbit-api", jackrabbitVersion),
+ mavenBundle("org.apache.jackrabbit", "jackrabbit-jcr-commons", jackrabbitVersion),
+ mavenBundle("org.apache.jackrabbit", "jackrabbit-spi", jackrabbitVersion),
+ mavenBundle("org.apache.jackrabbit", "jackrabbit-spi-commons", jackrabbitVersion),
+ mavenBundle("org.apache.jackrabbit", "jackrabbit-jcr-rmi", jackrabbitVersion),
+
+ mavenBundle("org.apache.felix", "org.apache.felix.jaas", "0.0.4"),
+
+ mavenBundle("org.apache.jackrabbit", "oak-core", oakVersion),
+ mavenBundle("org.apache.jackrabbit", "oak-commons", oakVersion),
+ mavenBundle("org.apache.jackrabbit", "oak-lucene", oakVersion),
+ mavenBundle("org.apache.jackrabbit", "oak-blob", oakVersion),
+ mavenBundle("org.apache.jackrabbit", "oak-jcr", oakVersion),
+
+ mavenBundle("org.apache.jackrabbit", "oak-segment", oakVersion),
+
+ mavenBundle("org.apache.sling", "org.apache.sling.jcr.oak.server", "1.1.0"),
+
+ mavenBundle("org.apache.sling", "org.apache.sling.testing.tools", "1.0.6"),
+ mavenBundle("org.apache.httpcomponents", "httpcore-osgi", "4.1.2"),
+ mavenBundle("org.apache.httpcomponents", "httpclient-osgi", "4.1.2"),
+
+
+ // SLING-5560: delaying start of the sling.event bundle to
+ // ensure the parameter 'startup.delay' is properly set to 1sec
+ // for these ITs - as otherwise, the default of 30sec applies -
+ // which will cause the tests to fail
+ // @see setup() where the bundle is finally started - after reconfig
+ CoreOptions.bundle( bundleFile.toURI().toString() ).start(false),
+
+ junitBundles()
+ );
+ }
+
+ protected JobManager getJobManager() {
+ JobManager result = null;
+ int count = 0;
+ do {
+ final ServiceReference<JobManager> sr = this.bc.getServiceReference(JobManager.class);
+ if ( sr != null ) {
+ result = this.bc.getService(sr);
+ } else {
+ count++;
+ if ( count == 10 ) {
+ break;
+ }
+ sleep(500);
+ }
+
+ } while ( result == null );
+ return result;
+ }
+
+ protected void sleep(final long time) {
+ try {
+ Thread.sleep(time);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ // ignore
+ }
+ }
+
+ public void setup() throws IOException {
+ // set load delay to 3 sec
+ final org.osgi.service.cm.Configuration c2 = this.configAdmin.getConfiguration("org.apache.sling.event.impl.jobs.jcr.PersistenceHandler", null);
+ Dictionary<String, Object> p2 = new Hashtable<String, Object>();
+ p2.put(JobManagerConfiguration.PROPERTY_BACKGROUND_LOAD_DELAY, 3L);
+ // and startup.delay to 1sec - otherwise default of 30sec breaks tests!
+ p2.put(JobManagerConfiguration.PROPERTY_STARTUP_DELAY, 1L);
+ c2.update(p2);
+
+ // SLING-5560 : since the above (re)config is now applied, we're safe
+ // to go ahead and start the sling.event bundle.
+ // this time, the JobManagerConfiguration will be activated
+ // with the 'startup.delay' set to 1sec - so that ITs actually succeed
+ try {
+ Bundle[] bundles = bc.getBundles();
+ for (Bundle bundle : bundles) {
+ if (bundle.getSymbolicName().contains("sling.event")) {
+ // assuming we only have 1 bundle that contains 'sling.event'
+ LoggerFactory.getLogger(getClass()).info("starting bundle... "+bundle);
+ bundle.start();
+ break;
+ }
+ }
+ } catch (BundleException e) {
+ LoggerFactory.getLogger(getClass()).error("could not start sling.event bundle: "+e, e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ private int deleteCount;
+
+ private void delete(final Resource rsrc )
+ throws PersistenceException {
+ final ResourceResolver resolver = rsrc.getResourceResolver();
+ for(final Resource child : rsrc.getChildren()) {
+ delete(child);
+ }
+ resolver.delete(rsrc);
+ deleteCount++;
+ if ( deleteCount >= 20 ) {
+ resolver.commit();
+ deleteCount = 0;
+ }
+ }
+
+ public void cleanup() {
+ // clean job area
+ final ServiceReference<ResourceResolverFactory> ref = this.bc.getServiceReference(ResourceResolverFactory.class);
+ final ResourceResolverFactory factory = this.bc.getService(ref);
+ ResourceResolver resolver = null;
+ try {
+ resolver = factory.getAdministrativeResourceResolver(null);
+ final Resource rsrc = resolver.getResource("/var/eventing");
+ if ( rsrc != null ) {
+ delete(rsrc);
+ resolver.commit();
+ }
+ } catch ( final LoginException le ) {
+ // ignore
+ } catch (final PersistenceException e) {
+ // ignore
+ } catch ( final Exception e ) {
+ // sometimes an NPE is thrown from the repository, as we
+ // are in the cleanup, we can ignore this
+ } finally {
+ if ( resolver != null ) {
+ resolver.close();
+ }
+ }
+ // unregister all services
+ for(final ServiceRegistration<?> reg : this.registrations) {
+ reg.unregister();
+ }
+ this.registrations.clear();
+
+ // remove all configurations
+ try {
+ final org.osgi.service.cm.Configuration[] cfgs = this.configAdmin.listConfigurations(null);
+ if ( cfgs != null ) {
+ for(final org.osgi.service.cm.Configuration c : cfgs) {
+ try {
+ c.delete();
+ } catch (final IOException io) {
+ // ignore
+ }
+ }
+ }
+ } catch (final IOException io) {
+ // ignore
+ } catch (final InvalidSyntaxException e) {
+ // ignore
+ }
+ this.sleep(1000);
+ }
+
+ /**
+ * Helper method to register an event handler
+ */
+ protected ServiceRegistration<EventHandler> registerEventHandler(final String topic,
+ final EventHandler handler) {
+ final Dictionary<String, Object> props = new Hashtable<String, Object>();
+ props.put(EventConstants.EVENT_TOPIC, topic);
+ final ServiceRegistration<EventHandler> reg = this.bc.registerService(EventHandler.class,
+ handler, props);
+ this.registrations.add(reg);
+ return reg;
+ }
+
+ protected long getConsumerChangeCount() {
+ long result = -1;
+ try {
+ final Collection<ServiceReference<PropertyProvider>> refs = this.bc.getServiceReferences(PropertyProvider.class, "(changeCount=*)");
+ if ( !refs.isEmpty() ) {
+ result = (Long)refs.iterator().next().getProperty("changeCount");
+ }
+ } catch ( final InvalidSyntaxException ignore ) {
+ // ignore
+ }
+ return result;
+ }
+
+ protected void waitConsumerChangeCount(final long minimum) {
+ do {
+ final long cc = getConsumerChangeCount();
+ if ( cc >= minimum ) {
+ // we need to wait for the topology events (TODO)
+ sleep(200);
+ return;
+ }
+ sleep(50);
+ } while ( true );
+ }
+
+ /**
+ * Helper method to register a job consumer
+ */
+ protected ServiceRegistration<JobConsumer> registerJobConsumer(final String topic,
+ final JobConsumer handler) {
+ long cc = this.getConsumerChangeCount();
+ final Dictionary<String, Object> props = new Hashtable<String, Object>();
+ props.put(JobConsumer.PROPERTY_TOPICS, topic);
+ final ServiceRegistration<JobConsumer> reg = this.bc.registerService(JobConsumer.class,
+ handler, props);
+ this.registrations.add(reg);
+ this.waitConsumerChangeCount(cc + 1);
+ return reg;
+ }
+
+ /**
+ * Helper method to register a job executor
+ */
+ protected ServiceRegistration<JobExecutor> registerJobExecutor(final String topic,
+ final JobExecutor handler) {
+ long cc = this.getConsumerChangeCount();
+ final Dictionary<String, Object> props = new Hashtable<String, Object>();
+ props.put(JobConsumer.PROPERTY_TOPICS, topic);
+ final ServiceRegistration<JobExecutor> reg = this.bc.registerService(JobExecutor.class,
+ handler, props);
+ this.registrations.add(reg);
+ this.waitConsumerChangeCount(cc + 1);
+ return reg;
+ }
+
+ protected void unregister(final ServiceRegistration<?> reg) {
+ if ( reg != null ) {
+ this.registrations.remove(reg);
+ reg.unregister();
+ }
+ }
+}
diff --git a/src/test/java/org/apache/sling/event/it/ChaosTest.java b/src/test/java/org/apache/sling/event/it/ChaosTest.java
new file mode 100644
index 0000000..100beec
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/it/ChaosTest.java
@@ -0,0 +1,402 @@
+/*
+ * 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.event.it;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Dictionary;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Hashtable;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.apache.sling.discovery.TopologyEvent;
+import org.apache.sling.discovery.TopologyEvent.Type;
+import org.apache.sling.discovery.TopologyEventListener;
+import org.apache.sling.discovery.TopologyView;
+import org.apache.sling.event.impl.jobs.config.ConfigurationConstants;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.JobManager;
+import org.apache.sling.event.jobs.NotificationConstants;
+import org.apache.sling.event.jobs.QueueConfiguration;
+import org.apache.sling.event.jobs.consumer.JobConsumer;
+import org.apache.sling.testing.tools.sling.TimeoutsProvider;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.osgi.framework.InvalidSyntaxException;
+import org.osgi.framework.ServiceReference;
+import org.osgi.framework.ServiceRegistration;
+import org.osgi.service.event.Event;
+import org.osgi.service.event.EventHandler;
+
+@RunWith(PaxExam.class)
+public class ChaosTest extends AbstractJobHandlingTest {
+
+ /** Duration for firing jobs in seconds. */
+ private static final long DURATION = 1 * 60;
+
+ private static final int NUM_ORDERED_THREADS = 3;
+ private static final int NUM_PARALLEL_THREADS = 6;
+ private static final int NUM_ROUND_THREADS = 6;
+
+ private static final int NUM_ORDERED_TOPICS = 2;
+ private static final int NUM_PARALLEL_TOPICS = 8;
+ private static final int NUM_ROUND_TOPICS = 8;
+
+ private static final String ORDERED_TOPIC_PREFIX = "sling/chaos/ordered/";
+ private static final String PARALLEL_TOPIC_PREFIX = "sling/chaos/parallel/";
+ private static final String ROUND_TOPIC_PREFIX = "sling/chaos/round/";
+
+ private static final String[] ORDERED_TOPICS = new String[NUM_ORDERED_TOPICS];
+ private static final String[] PARALLEL_TOPICS = new String[NUM_PARALLEL_TOPICS];
+ private static final String[] ROUND_TOPICS = new String[NUM_ROUND_TOPICS];
+
+ static {
+ for(int i=0; i<NUM_ORDERED_TOPICS; i++) {
+ ORDERED_TOPICS[i] = ORDERED_TOPIC_PREFIX + String.valueOf(i);
+ }
+ for(int i=0; i<NUM_PARALLEL_TOPICS; i++) {
+ PARALLEL_TOPICS[i] = PARALLEL_TOPIC_PREFIX + String.valueOf(i);
+ }
+ for(int i=0; i<NUM_ROUND_TOPICS; i++) {
+ ROUND_TOPICS[i] = ROUND_TOPIC_PREFIX + String.valueOf(i);
+ }
+ }
+
+ @Override
+ @Before
+ public void setup() throws IOException {
+ super.setup();
+
+ // create ordered test queue
+ final org.osgi.service.cm.Configuration orderedConfig = this.configAdmin.createFactoryConfiguration("org.apache.sling.event.jobs.QueueConfiguration", null);
+ final Dictionary<String, Object> orderedProps = new Hashtable<String, Object>();
+ orderedProps.put(ConfigurationConstants.PROP_NAME, "chaos-ordered");
+ orderedProps.put(ConfigurationConstants.PROP_TYPE, QueueConfiguration.Type.ORDERED.name());
+ orderedProps.put(ConfigurationConstants.PROP_TOPICS, ORDERED_TOPICS);
+ orderedProps.put(ConfigurationConstants.PROP_RETRIES, 2);
+ orderedProps.put(ConfigurationConstants.PROP_RETRY_DELAY, 2000L);
+ orderedConfig.update(orderedProps);
+
+ // create round robin test queue
+ final org.osgi.service.cm.Configuration rrConfig = this.configAdmin.createFactoryConfiguration("org.apache.sling.event.jobs.QueueConfiguration", null);
+ final Dictionary<String, Object> rrProps = new Hashtable<String, Object>();
+ rrProps.put(ConfigurationConstants.PROP_NAME, "chaos-roundrobin");
+ rrProps.put(ConfigurationConstants.PROP_TYPE, QueueConfiguration.Type.TOPIC_ROUND_ROBIN.name());
+ rrProps.put(ConfigurationConstants.PROP_TOPICS, ROUND_TOPICS);
+ rrProps.put(ConfigurationConstants.PROP_RETRIES, 2);
+ rrProps.put(ConfigurationConstants.PROP_RETRY_DELAY, 2000L);
+ rrProps.put(ConfigurationConstants.PROP_MAX_PARALLEL, 5);
+ rrConfig.update(rrProps);
+
+ this.sleep(1000L);
+ }
+
+ @Override
+ @After
+ public void cleanup() {
+ super.cleanup();
+ }
+
+ /**
+ * Setup consumers
+ */
+ private void setupJobConsumers() {
+ for(int i=0; i<NUM_ORDERED_TOPICS; i++) {
+ this.registerJobConsumer(ORDERED_TOPICS[i],
+
+ new JobConsumer() {
+
+ @Override
+ public JobResult process(final Job job) {
+ return JobResult.OK;
+ }
+ });
+ }
+ for(int i=0; i<NUM_PARALLEL_TOPICS; i++) {
+ this.registerJobConsumer(PARALLEL_TOPICS[i],
+
+ new JobConsumer() {
+
+ @Override
+ public JobResult process(final Job job) {
+ return JobResult.OK;
+ }
+ });
+ }
+ for(int i=0; i<NUM_ROUND_TOPICS; i++) {
+ this.registerJobConsumer(ROUND_TOPICS[i],
+
+ new JobConsumer() {
+
+ @Override
+ public JobResult process(final Job job) {
+ return JobResult.OK;
+ }
+ });
+ }
+ }
+
+ private static final class CreateJobThread extends Thread {
+
+ private final String[] topics;
+
+ private final JobManager jobManager;
+
+ private final Random random = new Random();
+
+ final Map<String, AtomicLong> created;
+
+ final AtomicLong finishedThreads;
+
+ public CreateJobThread(final JobManager jobManager,
+ final String[] topics,
+ final Map<String, AtomicLong> created,
+ final AtomicLong finishedThreads) {
+ this.topics = topics;
+ this.jobManager = jobManager;
+ this.created = created;
+ this.finishedThreads = finishedThreads;
+ }
+
+ @Override
+ public void run() {
+ int index = 0;
+ final long startTime = System.currentTimeMillis();
+ final long endTime = startTime + DURATION * 1000;
+ while ( System.currentTimeMillis() < endTime ) {
+ final String topic = topics[index];
+ if ( jobManager.addJob(topic, null) != null ) {
+ created.get(topic).incrementAndGet();
+
+ index++;
+ if ( index == topics.length ) {
+ index = 0;
+ }
+ }
+ final int sleepTime = random.nextInt(200);
+ try {
+ Thread.sleep(sleepTime);
+ } catch ( final InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ finishedThreads.incrementAndGet();
+ }
+
+ }
+
+ /**
+ * Setup job creation threads
+ */
+ private void setupJobCreationThreads(final List<Thread> threads,
+ final JobManager jobManager,
+ final Map<String, AtomicLong> created,
+ final AtomicLong finishedThreads) {
+ for(int i=0;i<NUM_ORDERED_THREADS;i++) {
+ threads.add(new CreateJobThread(jobManager, ORDERED_TOPICS, created, finishedThreads));
+ }
+ for(int i=0;i<NUM_PARALLEL_THREADS;i++) {
+ threads.add(new CreateJobThread(jobManager, PARALLEL_TOPICS, created, finishedThreads));
+ }
+ for(int i=0;i<NUM_ROUND_THREADS;i++) {
+ threads.add(new CreateJobThread(jobManager, ROUND_TOPICS, created, finishedThreads));
+ }
+ }
+
+ /**
+ * Setup chaos thread(s)
+ *
+ * Chaos is right now created by sending topology changing/changed events randomly
+ */
+ private void setupChaosThreads(final List<Thread> threads,
+ final AtomicLong finishedThreads) {
+ final List<TopologyView> views = new ArrayList<TopologyView>();
+ // register topology listener
+ final ServiceRegistration<TopologyEventListener> reg = this.bc.registerService(TopologyEventListener.class, new TopologyEventListener() {
+
+ @Override
+ public void handleTopologyEvent(final TopologyEvent event) {
+ if ( event.getType() == Type.TOPOLOGY_INIT ) {
+ views.add(event.getNewView());
+ }
+ }
+ }, null);
+ while ( views.isEmpty() ) {
+ this.sleep(10);
+ }
+ reg.unregister();
+ final TopologyView view = views.get(0);
+
+ try {
+ final Collection<ServiceReference<TopologyEventListener>> refs = this.bc.getServiceReferences(TopologyEventListener.class, null);
+ assertNotNull(refs);
+ assertFalse(refs.isEmpty());
+ TopologyEventListener found = null;
+ for(final ServiceReference<TopologyEventListener> ref : refs) {
+ final TopologyEventListener listener = this.bc.getService(ref);
+ if ( listener != null && listener.getClass().getName().equals("org.apache.sling.event.impl.jobs.config.TopologyHandler") ) {
+ found = listener;
+ break;
+ }
+ bc.ungetService(ref);
+ }
+ assertNotNull(found);
+ final TopologyEventListener tel = found;
+
+ threads.add(new Thread() {
+
+ private final Random random = new Random();
+
+ @Override
+ public void run() {
+ final long startTime = System.currentTimeMillis();
+ // this thread runs 30 seconds longer than the job creation thread
+ final long endTime = startTime + (DURATION +30) * 1000;
+ while ( System.currentTimeMillis() < endTime ) {
+ final int sleepTime = random.nextInt(25) + 15;
+ try {
+ Thread.sleep(sleepTime * 1000);
+ } catch ( final InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ }
+ tel.handleTopologyEvent(new TopologyEvent(Type.TOPOLOGY_CHANGING, view, null));
+ final int changingTime = random.nextInt(20) + 3;
+ try {
+ Thread.sleep(changingTime * 1000);
+ } catch ( final InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ }
+ tel.handleTopologyEvent(new TopologyEvent(Type.TOPOLOGY_CHANGED, view, view));
+ }
+ tel.getClass().getName();
+ finishedThreads.incrementAndGet();
+ }
+ });
+ } catch (InvalidSyntaxException e) {
+ e.printStackTrace();
+ }
+ }
+
+ @Test(timeout=DURATION * 16000L)
+ public void testDoChaos() throws Exception {
+ final JobManager jobManager = this.getJobManager();
+
+ // setup added, created and finished map
+ // added and finished are filled by notifications
+ // created is filled by the threads starting jobs
+ final Map<String, AtomicLong> added = new HashMap<String, AtomicLong>();
+ final Map<String, AtomicLong> created = new HashMap<String, AtomicLong>();
+ final Map<String, AtomicLong> finished = new HashMap<String, AtomicLong>();
+ final List<String> topics = new ArrayList<String>();
+ for(int i=0;i<NUM_ORDERED_TOPICS;i++) {
+ added.put(ORDERED_TOPICS[i], new AtomicLong());
+ created.put(ORDERED_TOPICS[i], new AtomicLong());
+ finished.put(ORDERED_TOPICS[i], new AtomicLong());
+ topics.add(ORDERED_TOPICS[i]);
+ }
+ for(int i=0;i<NUM_PARALLEL_TOPICS;i++) {
+ added.put(PARALLEL_TOPICS[i], new AtomicLong());
+ created.put(PARALLEL_TOPICS[i], new AtomicLong());
+ finished.put(PARALLEL_TOPICS[i], new AtomicLong());
+ topics.add(PARALLEL_TOPICS[i]);
+ }
+ for(int i=0;i<NUM_ROUND_TOPICS;i++) {
+ added.put(ROUND_TOPICS[i], new AtomicLong());
+ created.put(ROUND_TOPICS[i], new AtomicLong());
+ finished.put(ROUND_TOPICS[i], new AtomicLong());
+ topics.add(ROUND_TOPICS[i]);
+ }
+
+ final List<Thread> threads = new ArrayList<Thread>();
+ final AtomicLong finishedThreads = new AtomicLong();
+
+ this.registerEventHandler("org/apache/sling/event/notification/job/*",
+ new EventHandler() {
+
+ @Override
+ public void handleEvent(final Event event) {
+ final String topic = (String) event.getProperty(NotificationConstants.NOTIFICATION_PROPERTY_JOB_TOPIC);
+ if ( NotificationConstants.TOPIC_JOB_FINISHED.equals(event.getTopic())) {
+ finished.get(topic).incrementAndGet();
+ } else if ( NotificationConstants.TOPIC_JOB_ADDED.equals(event.getTopic())) {
+ added.get(topic).incrementAndGet();
+ }
+ }
+ });
+
+ // setup job consumers
+ this.setupJobConsumers();
+
+ // setup job creation tests
+ this.setupJobCreationThreads(threads, jobManager, created, finishedThreads);
+
+ this.setupChaosThreads(threads, finishedThreads);
+
+ System.out.println("Starting threads...");
+ // start threads
+ for(final Thread t : threads) {
+ t.setDaemon(true);
+ t.start();
+ }
+
+ System.out.println("Sleeping for " + DURATION + " seconds to wait for threads to finish...");
+ // for sure we can sleep for the duration
+ this.sleep(DURATION * 1000);
+
+ System.out.println("Polling for threads to finish...");
+ // wait until threads are finished
+ while ( finishedThreads.get() < threads.size() ) {
+ this.sleep(100);
+ }
+
+ System.out.println("Waiting for job handling to finish...");
+ final Set<String> allTopics = new HashSet<String>(topics);
+ while ( !allTopics.isEmpty() ) {
+ final Iterator<String> iter = allTopics.iterator();
+ while ( iter.hasNext() ) {
+ final String topic = iter.next();
+ if ( finished.get(topic).get() == created.get(topic).get() ) {
+ iter.remove();
+ }
+ }
+ this.sleep(100);
+ }
+/* We could try to enable this with Oak again - but right now JR observation handler is too
+ * slow.
+ System.out.println("Checking notifications...");
+ for(final String topic : topics) {
+ assertEquals("Checking topic " + topic, created.get(topic).get(), added.get(topic).get());
+ }
+ */
+
+ }
+}
diff --git a/src/test/java/org/apache/sling/event/it/ClassloadingTest.java b/src/test/java/org/apache/sling/event/it/ClassloadingTest.java
new file mode 100644
index 0000000..b182401
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/it/ClassloadingTest.java
@@ -0,0 +1,211 @@
+/*
+ * 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.event.it;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+
+import java.io.IOException;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Dictionary;
+import java.util.HashMap;
+import java.util.Hashtable;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.sling.event.impl.jobs.config.ConfigurationConstants;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.JobManager;
+import org.apache.sling.event.jobs.NotificationConstants;
+import org.apache.sling.event.jobs.QueueConfiguration;
+import org.apache.sling.event.jobs.consumer.JobConsumer;
+import org.apache.sling.testing.tools.retry.RetryLoop;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.osgi.service.event.Event;
+import org.osgi.service.event.EventHandler;
+
+@RunWith(PaxExam.class)
+public class ClassloadingTest extends AbstractJobHandlingTest {
+
+ private static final int CONDITION_INTERVAL_MILLIS = 50;
+ private static final int CONDITION_TIMEOUT_SECONDS = 5;
+
+ private static final String QUEUE_NAME = "cltest";
+ private static final String TOPIC = "sling/cltest";
+
+ @Override
+ @Before
+ public void setup() throws IOException {
+ super.setup();
+
+ // create ignore test queue
+ final org.osgi.service.cm.Configuration orderedConfig = this.configAdmin.createFactoryConfiguration("org.apache.sling.event.jobs.QueueConfiguration", null);
+ final Dictionary<String, Object> orderedProps = new Hashtable<String, Object>();
+ orderedProps.put(ConfigurationConstants.PROP_NAME, QUEUE_NAME);
+ orderedProps.put(ConfigurationConstants.PROP_TYPE, QueueConfiguration.Type.UNORDERED.name());
+ orderedProps.put(ConfigurationConstants.PROP_TOPICS, TOPIC);
+ orderedConfig.update(orderedProps);
+
+ this.sleep(1000L);
+ }
+
+ @Override
+ @After
+ public void cleanup() {
+ super.cleanup();
+ }
+
+ @Test(timeout = DEFAULT_TEST_TIMEOUT)
+ public void testSimpleClassloading() throws Exception {
+ final AtomicInteger processedJobsCount = new AtomicInteger(0);
+ final List<Event> finishedEvents = Collections.synchronizedList(new ArrayList<Event>());
+ final CountDownLatch latch = new CountDownLatch(1);
+ this.registerJobConsumer(TOPIC,
+ new JobConsumer() {
+ @Override
+ public JobResult process(Job job) {
+ processedJobsCount.incrementAndGet();
+ return JobResult.OK;
+ }
+ });
+ this.registerEventHandler(NotificationConstants.TOPIC_JOB_FINISHED,
+ new EventHandler() {
+
+ @Override
+ public void handleEvent(Event event) {
+ finishedEvents.add(event);
+ latch.countDown();
+ }
+ });
+ final JobManager jobManager = this.getJobManager();
+
+ final List<String> list = new ArrayList<String>();
+ list.add("1");
+ list.add("2");
+
+ final Map<String, String> map = new HashMap<String, String>();
+ map.put("a", "a1");
+ map.put("b", "b2");
+
+ // we start a single job
+ final Map<String, Object> props = new HashMap<String, Object>();
+ props.put("string", "Hello");
+ props.put("int", new Integer(5));
+ props.put("long", new Long(7));
+ props.put("list", list);
+ props.put("map", map);
+
+ final String jobId = jobManager.addJob(TOPIC, props).getId();
+ try {
+ latch.await(5, TimeUnit.SECONDS);
+ assertFalse("At least one finished job", finishedEvents.isEmpty());
+ assertEquals(1, processedJobsCount.get());
+
+ final String jobTopic = (String)finishedEvents.get(0).getProperty(NotificationConstants.NOTIFICATION_PROPERTY_JOB_TOPIC);
+ assertNotNull(jobTopic);
+ assertEquals("Hello", finishedEvents.get(0).getProperty("string"));
+ assertEquals(new Integer(5), Integer.valueOf(finishedEvents.get(0).getProperty("int").toString()));
+ assertEquals(new Long(7), Long.valueOf(finishedEvents.get(0).getProperty("long").toString()));
+ assertEquals(list, finishedEvents.get(0).getProperty("list"));
+ assertEquals(map, finishedEvents.get(0).getProperty("map"));
+ } finally {
+ jobManager.removeJobById(jobId);
+ }
+ }
+
+ @Test(timeout = DEFAULT_TEST_TIMEOUT)
+ public void testFailedClassloading() throws Exception {
+ final AtomicInteger failedJobsCount = new AtomicInteger(0);
+ final List<Event> finishedEvents = Collections.synchronizedList(new ArrayList<Event>());
+ this.registerJobConsumer(TOPIC + "/failed",
+ new JobConsumer() {
+
+ @Override
+ public JobResult process(Job job) {
+ failedJobsCount.incrementAndGet();
+ return JobResult.OK;
+ }
+ });
+ this.registerEventHandler(NotificationConstants.TOPIC_JOB_FINISHED,
+ new EventHandler() {
+
+ @Override
+ public void handleEvent(Event event) {
+ finishedEvents.add(event);
+ }
+ });
+ final JobManager jobManager = this.getJobManager();
+
+ // dao is an invisible class for the dynamic class loader as it is not public
+ // therefore scheduling this job should fail!
+ final DataObject dao = new DataObject();
+
+ // we start a single job
+ final Map<String, Object> props = new HashMap<String, Object>();
+ props.put("dao", dao);
+
+ final String id = jobManager.addJob(TOPIC + "/failed", props).getId();
+
+ try {
+ // wait until the conditions are met
+ new RetryLoop(new RetryLoop.Condition() {
+
+ @Override
+ public boolean isTrue() throws Exception {
+ return failedJobsCount.get() == 0
+ && finishedEvents.size() == 0
+ && jobManager.findJobs(JobManager.QueryType.ALL, TOPIC + "/failed", -1,
+ (Map<String, Object>[]) null).size() == 1
+ && jobManager.getStatistics().getNumberOfQueuedJobs() == 0
+ && jobManager.getStatistics().getNumberOfActiveJobs() == 0;
+ }
+
+ @Override
+ public String getDescription() {
+ return "Waiting for job failure to be recorded. Conditions " +
+ "faildJobsCount=" + failedJobsCount.get() +
+ ", finishedEvents=" + finishedEvents.size() +
+ ", findJobs= " + jobManager.findJobs(JobManager.QueryType.ALL, TOPIC + "/failed", -1,
+ (Map<String, Object>[]) null).size()
+ +", queuedJobs=" + jobManager.getStatistics().getNumberOfQueuedJobs()
+ +", activeJobs=" + jobManager.getStatistics().getNumberOfActiveJobs();
+ }
+ }, CONDITION_TIMEOUT_SECONDS, CONDITION_INTERVAL_MILLIS);
+
+ jobManager.removeJobById(id); // moves the job to the history section
+ assertEquals(0, jobManager.findJobs(JobManager.QueryType.ALL, TOPIC + "/failed", -1, (Map<String, Object>[])null).size());
+ } finally {
+ jobManager.removeJobById(id); // removes the job permanently
+ }
+ }
+
+ private static final class DataObject implements Serializable {
+ private static final long serialVersionUID = 1L;
+ }
+}
diff --git a/src/test/java/org/apache/sling/event/it/HistoryTest.java b/src/test/java/org/apache/sling/event/it/HistoryTest.java
new file mode 100644
index 0000000..d797f35
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/it/HistoryTest.java
@@ -0,0 +1,141 @@
+/*
+ * 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.event.it;
+
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Dictionary;
+import java.util.HashMap;
+import java.util.Hashtable;
+import java.util.Map;
+
+import org.apache.sling.event.impl.jobs.config.ConfigurationConstants;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.JobManager;
+import org.apache.sling.event.jobs.QueueConfiguration;
+import org.apache.sling.event.jobs.consumer.JobExecutionContext;
+import org.apache.sling.event.jobs.consumer.JobExecutionResult;
+import org.apache.sling.event.jobs.consumer.JobExecutor;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+
+@RunWith(PaxExam.class)
+public class HistoryTest extends AbstractJobHandlingTest {
+
+ private static final String TOPIC = "sling/test/history";
+
+ private static final String PROP_COUNTER = "counter";
+
+ @Override
+ @Before
+ public void setup() throws IOException {
+ super.setup();
+
+ // create test queue - we use an ordered queue to have a stable processing order
+ // keep the jobs in the history
+ final org.osgi.service.cm.Configuration config = this.configAdmin.createFactoryConfiguration("org.apache.sling.event.jobs.QueueConfiguration", null);
+ Dictionary<String, Object> props = new Hashtable<String, Object>();
+ props.put(ConfigurationConstants.PROP_NAME, "test");
+ props.put(ConfigurationConstants.PROP_TYPE, QueueConfiguration.Type.ORDERED.name());
+ props.put(ConfigurationConstants.PROP_TOPICS, new String[] {TOPIC});
+ props.put(ConfigurationConstants.PROP_RETRIES, 2);
+ props.put(ConfigurationConstants.PROP_RETRY_DELAY, 2L);
+ props.put(ConfigurationConstants.PROP_KEEP_JOBS, true);
+ config.update(props);
+
+ this.sleep(1000L);
+ }
+
+ @Override
+ @After
+ public void cleanup() {
+ super.cleanup();
+ }
+
+ private Job addJob(final long counter) {
+ final Map<String, Object> props = new HashMap<String, Object>();
+ props.put(PROP_COUNTER, counter);
+ return this.getJobManager().addJob(TOPIC, props );
+ }
+
+ /**
+ * Test history.
+ * Start 10 jobs and cancel some of them and succeed others
+ */
+ @Test(timeout = DEFAULT_TEST_TIMEOUT)
+ public void testHistory() throws Exception {
+ this.registerJobExecutor(TOPIC,
+ new JobExecutor() {
+
+ @Override
+ public JobExecutionResult process(final Job job, final JobExecutionContext context) {
+ sleep(5L);
+ final long count = job.getProperty(PROP_COUNTER, Long.class);
+ if ( count == 2 || count == 5 || count == 7 ) {
+ return context.result().message(Job.JobState.ERROR.name()).cancelled();
+ }
+ return context.result().message(Job.JobState.SUCCEEDED.name()).succeeded();
+ }
+
+ });
+ for(int i = 0; i< 10; i++) {
+ this.addJob(i);
+ }
+ this.sleep(200L);
+ while ( this.getJobManager().findJobs(JobManager.QueryType.HISTORY, TOPIC, -1, (Map<String, Object>[])null).size() < 10 ) {
+ this.sleep(20L);
+ }
+ Collection<Job> col = this.getJobManager().findJobs(JobManager.QueryType.HISTORY, TOPIC, -1, (Map<String, Object>[])null);
+ assertEquals(10, col.size());
+ assertEquals(0, this.getJobManager().findJobs(JobManager.QueryType.ACTIVE, TOPIC, -1, (Map<String, Object>[])null).size());
+ assertEquals(0, this.getJobManager().findJobs(JobManager.QueryType.QUEUED, TOPIC, -1, (Map<String, Object>[])null).size());
+ assertEquals(0, this.getJobManager().findJobs(JobManager.QueryType.ALL, TOPIC, -1, (Map<String, Object>[])null).size());
+ assertEquals(3, this.getJobManager().findJobs(JobManager.QueryType.CANCELLED, TOPIC, -1, (Map<String, Object>[])null).size());
+ assertEquals(0, this.getJobManager().findJobs(JobManager.QueryType.DROPPED, TOPIC, -1, (Map<String, Object>[])null).size());
+ assertEquals(3, this.getJobManager().findJobs(JobManager.QueryType.ERROR, TOPIC, -1, (Map<String, Object>[])null).size());
+ assertEquals(0, this.getJobManager().findJobs(JobManager.QueryType.GIVEN_UP, TOPIC, -1, (Map<String, Object>[])null).size());
+ assertEquals(0, this.getJobManager().findJobs(JobManager.QueryType.STOPPED, TOPIC, -1, (Map<String, Object>[])null).size());
+ assertEquals(7, this.getJobManager().findJobs(JobManager.QueryType.SUCCEEDED, TOPIC, -1, (Map<String, Object>[])null).size());
+
+ // find all topics
+ assertEquals(7, this.getJobManager().findJobs(JobManager.QueryType.SUCCEEDED, null, -1, (Map<String, Object>[])null).size());
+
+ // verify order, message and state
+ long last = 9;
+ for(final Job j : col) {
+ assertNotNull(j.getFinishedDate());
+ final long count = j.getProperty(PROP_COUNTER, Long.class);
+ assertEquals(last, count);
+ if ( count == 2 || count == 5 || count == 7 ) {
+ assertEquals(Job.JobState.ERROR, j.getJobState());
+ } else {
+ assertEquals(Job.JobState.SUCCEEDED, j.getJobState());
+ }
+ assertEquals(j.getJobState().name(), j.getResultMessage());
+ last--;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/sling/event/it/JobHandlingTest.java b/src/test/java/org/apache/sling/event/it/JobHandlingTest.java
new file mode 100644
index 0000000..54867ce
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/it/JobHandlingTest.java
@@ -0,0 +1,436 @@
+/*
+ * 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.event.it;
+
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Dictionary;
+import java.util.Hashtable;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.sling.event.impl.Barrier;
+import org.apache.sling.event.impl.jobs.config.ConfigurationConstants;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.JobManager;
+import org.apache.sling.event.jobs.NotificationConstants;
+import org.apache.sling.event.jobs.QueueConfiguration;
+import org.apache.sling.event.jobs.consumer.JobConsumer;
+import org.apache.sling.event.jobs.consumer.JobExecutionContext;
+import org.apache.sling.event.jobs.consumer.JobExecutionResult;
+import org.apache.sling.event.jobs.consumer.JobExecutor;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.osgi.service.event.Event;
+import org.osgi.service.event.EventHandler;
+
+@RunWith(PaxExam.class)
+public class JobHandlingTest extends AbstractJobHandlingTest {
+
+ public static final String TOPIC = "sling/test";
+
+ @Override
+ @Before
+ public void setup() throws IOException {
+ super.setup();
+
+ // create test queue
+ final org.osgi.service.cm.Configuration config = this.configAdmin.createFactoryConfiguration("org.apache.sling.event.jobs.QueueConfiguration", null);
+ Dictionary<String, Object> props = new Hashtable<String, Object>();
+ props.put(ConfigurationConstants.PROP_NAME, "test");
+ props.put(ConfigurationConstants.PROP_TYPE, QueueConfiguration.Type.UNORDERED.name());
+ props.put(ConfigurationConstants.PROP_TOPICS, new String[] {TOPIC, TOPIC + "2"});
+ props.put(ConfigurationConstants.PROP_RETRIES, 2);
+ props.put(ConfigurationConstants.PROP_RETRY_DELAY, 2000L);
+ config.update(props);
+
+ this.sleep(1000L);
+ }
+
+ @Override
+ @After
+ public void cleanup() {
+ super.cleanup();
+ }
+
+ /**
+ * Test simple job execution.
+ * The job is executed once and finished successfully.
+ */
+ @Test(timeout = DEFAULT_TEST_TIMEOUT)
+ public void testSimpleJobExecutionUsingJobConsumer() throws Exception {
+ final Barrier cb = new Barrier(2);
+
+ this.registerJobConsumer(TOPIC,
+ new JobConsumer() {
+
+ @Override
+ public JobResult process(final Job job) {
+ cb.block();
+ return JobResult.OK;
+ }
+ });
+
+ this.getJobManager().addJob(TOPIC, null);
+ assertTrue("No event received in the given time.", cb.block(5));
+ cb.reset();
+ assertFalse("Unexpected event received in the given time.", cb.block(5));
+ }
+
+ /**
+ * Test simple job execution.
+ * The job is executed once and finished successfully.
+ */
+ @Test(timeout = DEFAULT_TEST_TIMEOUT)
+ public void testSimpleJobExecutionUsingJobExecutor() throws Exception {
+ final Barrier cb = new Barrier(2);
+
+ this.registerJobExecutor(TOPIC,
+ new JobExecutor() {
+
+ @Override
+ public JobExecutionResult process(final Job job, final JobExecutionContext context) {
+ cb.block();
+ return context.result().succeeded();
+ }
+ });
+
+ this.getJobManager().addJob(TOPIC, null);
+ assertTrue("No event received in the given time.", cb.block(5));
+ cb.reset();
+ assertFalse("Unexpected event received in the given time.", cb.block(5));
+ }
+
+ @Test(timeout = DEFAULT_TEST_TIMEOUT)
+ public void testManyJobs() throws Exception {
+ this.registerJobConsumer(TOPIC,
+ new JobConsumer() {
+
+ @Override
+ public JobResult process(final Job job) {
+ return JobResult.OK;
+ }
+
+ });
+ final AtomicInteger count = new AtomicInteger(0);
+ this.registerEventHandler(NotificationConstants.TOPIC_JOB_FINISHED,
+ new EventHandler() {
+ @Override
+ public void handleEvent(final Event event) {
+ count.incrementAndGet();
+ }
+ });
+
+ // we start "some" jobs
+ final int COUNT = 300;
+ for(int i = 0; i < COUNT; i++ ) {
+ this.getJobManager().addJob(TOPIC, null);
+ }
+ while ( count.get() < COUNT ) {
+ this.sleep(50);
+ }
+ assertEquals("Finished count", COUNT, count.get());
+ assertEquals("Finished count", COUNT, this.getJobManager().getStatistics().getNumberOfFinishedJobs());
+ }
+
+ /**
+ * Test canceling a job
+ * The job execution always fails
+ */
+ @Test(timeout = DEFAULT_TEST_TIMEOUT)
+ public void testCancelJob() throws Exception {
+ final Barrier cb = new Barrier(2);
+ final Barrier cb2 = new Barrier(2);
+ this.registerJobConsumer(TOPIC,
+ new JobConsumer() {
+
+ @Override
+ public JobResult process(Job job) {
+ cb.block();
+ cb2.block();
+ return JobResult.FAILED;
+ }
+ });
+
+ final Map<String, Object> jobProperties = Collections.singletonMap("id", (Object)"cancelJobId");
+ @SuppressWarnings("unchecked")
+ final Map<String, Object>[] jobPropertiesAsArray = new Map[1];
+ jobPropertiesAsArray[0] = jobProperties;
+
+ // create job
+ final JobManager jobManager = this.getJobManager();
+ jobManager.addJob(TOPIC, jobProperties);
+ cb.block();
+
+ assertEquals(1, jobManager.findJobs(JobManager.QueryType.ALL, TOPIC, -1, jobPropertiesAsArray).size());
+ // job is currently waiting, therefore cancel fails
+ final Job e1 = jobManager.getJob(TOPIC, jobProperties);
+ assertNotNull(e1);
+ cb2.block(); // and continue job
+
+ sleep(200);
+
+ // the job is now in the queue again
+ final Job e2 = jobManager.getJob(TOPIC, jobProperties);
+ assertNotNull(e2);
+ assertTrue(jobManager.removeJobById(e2.getId()));
+ assertEquals(0, jobManager.findJobs(JobManager.QueryType.ALL, TOPIC, -1, jobPropertiesAsArray).size());
+ final Collection<Job> col = jobManager.findJobs(JobManager.QueryType.HISTORY, TOPIC, -1,
+ jobPropertiesAsArray);
+ try {
+ assertEquals(1, col.size());
+ } finally {
+ for(final Job j : col) {
+ jobManager.removeJobById(j.getId());
+ }
+ }
+ }
+
+ /**
+ * Test get a job
+ */
+ @Test(timeout = DEFAULT_TEST_TIMEOUT)
+ public void testGetJob() throws Exception {
+ final Barrier cb = new Barrier(2);
+ final Barrier cb2 = new Barrier(2);
+ this.registerJobConsumer(TOPIC,
+ new JobConsumer() {
+
+ @Override
+ public JobResult process(Job job) {
+ cb.block();
+ cb2.block();
+ return JobResult.OK;
+ }
+ });
+ final JobManager jobManager = this.getJobManager();
+ final Job j = jobManager.addJob(TOPIC, null);
+ cb.block();
+
+ assertNotNull(jobManager.getJob(TOPIC, null));
+
+ cb2.block(); // and continue job
+
+ jobManager.removeJobById(j.getId());
+ }
+
+ /**
+ * Reschedule test.
+ * The job is rescheduled two times before it fails.
+ */
+ @Test(timeout = DEFAULT_TEST_TIMEOUT)
+ public void testStartJobAndReschedule() throws Exception {
+ final List<Integer> retryCountList = new ArrayList<Integer>();
+ final Barrier cb = new Barrier(2);
+
+ this.registerJobConsumer(TOPIC,
+ new JobConsumer() {
+ int retryCount;
+
+ @Override
+ public JobResult process(Job job) {
+ int retry = 0;
+ if ( job.getProperty(Job.PROPERTY_JOB_RETRY_COUNT) != null ) {
+ retry = (Integer)job.getProperty(Job.PROPERTY_JOB_RETRY_COUNT);
+ }
+ if ( retry == retryCount ) {
+ retryCountList.add(retry);
+ }
+ retryCount++;
+ cb.block();
+ return JobResult.FAILED;
+ }
+ });
+
+ final JobManager jobManager = this.getJobManager();
+ final Job job = jobManager.addJob(TOPIC, null);
+
+ assertTrue("No event received in the given time.", cb.block(5));
+ cb.reset();
+ // the job is retried after two seconds, so we wait again
+ assertTrue("No event received in the given time.", cb.block(5));
+ cb.reset();
+ // the job is retried after two seconds, so we wait again
+ assertTrue("No event received in the given time.", cb.block(5));
+ // we have reached the retry so we expect to not get an event
+ cb.reset();
+ assertFalse("Unexpected event received in the given time.", cb.block(5));
+ assertEquals("Unexpected number of retries", 3, retryCountList.size());
+
+ jobManager.removeJobById(job.getId());
+ }
+
+ /**
+ * Notifications.
+ * We send several jobs which are treated different and then see
+ * how many invocations have been sent.
+ */
+ @Test(timeout = DEFAULT_TEST_TIMEOUT)
+ public void testNotifications() throws Exception {
+ final List<String> cancelled = Collections.synchronizedList(new ArrayList<String>());
+ final List<String> failed = Collections.synchronizedList(new ArrayList<String>());
+ final List<String> finished = Collections.synchronizedList(new ArrayList<String>());
+ final List<String> started = Collections.synchronizedList(new ArrayList<String>());
+ this.registerJobConsumer(TOPIC,
+ new JobConsumer() {
+
+ @Override
+ public JobResult process(Job job) {
+ // events 1 and 4 finish the first time
+ final String id = (String)job.getProperty("id");
+ if ( "1".equals(id) || "4".equals(id) ) {
+ return JobResult.OK;
+
+ // 5 fails always
+ } else if ( "5".equals(id) ) {
+ return JobResult.FAILED;
+ } else {
+ int retry = 0;
+ if ( job.getProperty(Job.PROPERTY_JOB_RETRY_COUNT) != null ) {
+ retry = (Integer)job.getProperty(Job.PROPERTY_JOB_RETRY_COUNT);
+ }
+ // 2 fails the first time
+ if ( "2".equals(id) ) {
+ if ( retry == 0 ) {
+ return JobResult.FAILED;
+ } else {
+ return JobResult.OK;
+ }
+ }
+ // 3 fails the first and second time
+ if ( "3".equals(id) ) {
+ if ( retry == 0 || retry == 1 ) {
+ return JobResult.FAILED;
+ } else {
+ return JobResult.OK;
+ }
+ }
+ }
+ return JobResult.FAILED;
+ }
+ });
+ this.registerEventHandler(NotificationConstants.TOPIC_JOB_CANCELLED,
+ new EventHandler() {
+
+ @Override
+ public void handleEvent(Event event) {
+ final String id = (String)event.getProperty("id");
+ cancelled.add(id);
+ }
+ });
+ this.registerEventHandler(NotificationConstants.TOPIC_JOB_FAILED,
+ new EventHandler() {
+
+ @Override
+ public void handleEvent(Event event) {
+ final String id = (String)event.getProperty("id");
+ failed.add(id);
+ }
+ });
+ this.registerEventHandler(NotificationConstants.TOPIC_JOB_FINISHED,
+ new EventHandler() {
+
+ @Override
+ public void handleEvent(Event event) {
+ final String id = (String)event.getProperty("id");
+ finished.add(id);
+ }
+ });
+ this.registerEventHandler(NotificationConstants.TOPIC_JOB_STARTED,
+ new EventHandler() {
+
+ @Override
+ public void handleEvent(Event event) {
+ final String id = (String)event.getProperty("id");
+ started.add(id);
+ }
+ });
+
+ final JobManager jobManager = this.getJobManager();
+
+ jobManager.addJob(TOPIC, Collections.singletonMap("id", (Object)"1"));
+ jobManager.addJob(TOPIC, Collections.singletonMap("id", (Object)"2"));
+ jobManager.addJob(TOPIC, Collections.singletonMap("id", (Object)"3"));
+ jobManager.addJob(TOPIC, Collections.singletonMap("id", (Object)"4"));
+ jobManager.addJob(TOPIC, Collections.singletonMap("id", (Object)"5"));
+
+ int count = 0;
+ final long startTime = System.currentTimeMillis();
+ do {
+ count = finished.size() + cancelled.size();
+ // after 25 seconds we cancel the test
+ if ( System.currentTimeMillis() - startTime > 25000 ) {
+ throw new Exception("Timeout during notification test.");
+ }
+ } while ( count < 5 || started.size() < 10 );
+ assertEquals("Finished count", 4, finished.size());
+ assertEquals("Cancelled count", 1, cancelled.size());
+ assertEquals("Started count", 10, started.size());
+ assertEquals("Failed count", 5, failed.size());
+ }
+
+ /**
+ * Test sending of jobs with and without a processor
+ */
+ @Test(timeout = DEFAULT_TEST_TIMEOUT)
+ public void testNoJobProcessor() throws Exception {
+ final AtomicInteger count = new AtomicInteger(0);
+
+ this.registerJobConsumer(TOPIC,
+ new JobConsumer() {
+
+ @Override
+ public JobResult process(final Job job) {
+ count.incrementAndGet();
+
+ return JobResult.OK;
+ }
+ });
+
+ final JobManager jobManager = this.getJobManager();
+
+ // we start 20 jobs, every second job has no processor
+ final int COUNT = 20;
+ for(int i = 0; i < COUNT; i++ ) {
+ final String jobTopic = (i % 2 == 0 ? TOPIC : TOPIC + "2");
+
+ jobManager.addJob(jobTopic, null);
+ }
+ while ( jobManager.getStatistics().getNumberOfFinishedJobs() < COUNT / 2) {
+ this.sleep(50);
+ }
+
+ assertEquals("Finished count", COUNT / 2, count.get());
+ // unprocessed count should be 0 as there is no job consumer for this job
+ assertEquals("Unprocessed count", 0, jobManager.getStatistics().getNumberOfJobs());
+ assertEquals("Finished count", COUNT / 2, jobManager.getStatistics().getNumberOfFinishedJobs());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/sling/event/it/OrderedQueueTest.java b/src/test/java/org/apache/sling/event/it/OrderedQueueTest.java
new file mode 100644
index 0000000..be23f69
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/it/OrderedQueueTest.java
@@ -0,0 +1,173 @@
+/*
+ * 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.event.it;
+
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.util.Dictionary;
+import java.util.HashMap;
+import java.util.Hashtable;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.sling.event.impl.Barrier;
+import org.apache.sling.event.impl.jobs.config.ConfigurationConstants;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.JobManager;
+import org.apache.sling.event.jobs.NotificationConstants;
+import org.apache.sling.event.jobs.Queue;
+import org.apache.sling.event.jobs.QueueConfiguration;
+import org.apache.sling.event.jobs.consumer.JobConsumer;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.osgi.service.event.Event;
+import org.osgi.service.event.EventHandler;
+
+@RunWith(PaxExam.class)
+public class OrderedQueueTest extends AbstractJobHandlingTest {
+
+ @Override
+ @Before
+ public void setup() throws IOException {
+ super.setup();
+
+ // create ordered test queue
+ final org.osgi.service.cm.Configuration orderedConfig = this.configAdmin.createFactoryConfiguration("org.apache.sling.event.jobs.QueueConfiguration", null);
+ final Dictionary<String, Object> orderedProps = new Hashtable<String, Object>();
+ orderedProps.put(ConfigurationConstants.PROP_NAME, "orderedtest");
+ orderedProps.put(ConfigurationConstants.PROP_TYPE, QueueConfiguration.Type.ORDERED.name());
+ orderedProps.put(ConfigurationConstants.PROP_TOPICS, "sling/orderedtest/*");
+ orderedProps.put(ConfigurationConstants.PROP_RETRIES, 2);
+ orderedProps.put(ConfigurationConstants.PROP_RETRY_DELAY, 2000L);
+ orderedConfig.update(orderedProps);
+
+ this.sleep(1000L);
+ }
+
+ @Override
+ @After
+ public void cleanup() {
+ super.cleanup();
+ }
+
+ /**
+ * Ordered Queue Test
+ */
+ @Test(timeout = DEFAULT_TEST_TIMEOUT)
+ public void testOrderedQueue() throws Exception {
+ final JobManager jobManager = this.getJobManager();
+
+ // register consumer and event handler
+ final Barrier cb = new Barrier(2);
+ final AtomicInteger count = new AtomicInteger(0);
+ final AtomicInteger parallelCount = new AtomicInteger(0);
+ this.registerJobConsumer("sling/orderedtest/*",
+ new JobConsumer() {
+
+ private volatile int lastCounter = -1;
+
+ @Override
+ public JobResult process(final Job job) {
+ final int counter = job.getProperty("counter", -10);
+ assertNotEquals("Counter property is missing", -10, counter);
+ assertTrue("Counter should only increment by max of 1 " + counter + " - " + lastCounter,
+ counter == lastCounter || counter == lastCounter +1);
+ lastCounter = counter;
+ if ("sling/orderedtest/start".equals(job.getTopic()) ) {
+ cb.block();
+ return JobResult.OK;
+ }
+ if ( parallelCount.incrementAndGet() > 1 ) {
+ parallelCount.decrementAndGet();
+ return JobResult.FAILED;
+ }
+ final String topic = job.getTopic();
+ if ( topic.endsWith("sub1") ) {
+ final int i = (Integer)job.getProperty(Job.PROPERTY_JOB_RETRY_COUNT);
+ if ( i == 0 ) {
+ parallelCount.decrementAndGet();
+ return JobResult.FAILED;
+ }
+ }
+ try {
+ Thread.sleep(30);
+ } catch (InterruptedException ie) {
+ // ignore
+ }
+ parallelCount.decrementAndGet();
+ return JobResult.OK;
+ }
+ });
+ this.registerEventHandler(NotificationConstants.TOPIC_JOB_FINISHED,
+ new EventHandler() {
+
+ @Override
+ public void handleEvent(final Event event) {
+ count.incrementAndGet();
+ }
+ });
+
+ // we first sent one event to get the queue started
+ final Map<String, Object> properties = new HashMap<String, Object>();
+ properties.put("counter", -1);
+ jobManager.addJob("sling/orderedtest/start", properties);
+ assertTrue("No event received in the given time.", cb.block(5));
+ cb.reset();
+
+ // get the queue
+ final Queue q = jobManager.getQueue("orderedtest");
+ assertNotNull("Queue 'orderedtest' should exist!", q);
+
+ // suspend it
+ q.suspend();
+
+ final int NUM_JOBS = 30;
+
+ // we start "some" jobs:
+ for(int i = 0; i < NUM_JOBS; i++ ) {
+ final String subTopic = "sling/orderedtest/sub" + (i % 10);
+ properties.clear();
+ properties.put("counter", i);
+ jobManager.addJob(subTopic, properties);
+ }
+ // start the queue
+ q.resume();
+ while ( count.get() < NUM_JOBS +1 ) {
+ try {
+ Thread.sleep(500);
+ } catch (InterruptedException ie) {
+ // ignore
+ }
+ }
+ // we started one event before the test, so add one
+ assertEquals("Finished count", NUM_JOBS + 1, count.get());
+ assertEquals("Finished count", NUM_JOBS + 1, jobManager.getStatistics().getNumberOfFinishedJobs());
+ assertEquals("Finished count", NUM_JOBS + 1, q.getStatistics().getNumberOfFinishedJobs());
+ assertEquals("Failed count", NUM_JOBS / 10, q.getStatistics().getNumberOfFailedJobs());
+ assertEquals("Cancelled count", 0, q.getStatistics().getNumberOfCancelledJobs());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/org/apache/sling/event/it/RoundRobinQueueTest.java b/src/test/java/org/apache/sling/event/it/RoundRobinQueueTest.java
new file mode 100644
index 0000000..00dfe2d
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/it/RoundRobinQueueTest.java
@@ -0,0 +1,172 @@
+/*
+ * 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.event.it;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.util.Dictionary;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Hashtable;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.sling.event.impl.Barrier;
+import org.apache.sling.event.impl.jobs.config.ConfigurationConstants;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.JobManager;
+import org.apache.sling.event.jobs.NotificationConstants;
+import org.apache.sling.event.jobs.Queue;
+import org.apache.sling.event.jobs.QueueConfiguration;
+import org.apache.sling.event.jobs.consumer.JobConsumer;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.osgi.service.event.Event;
+import org.osgi.service.event.EventHandler;
+
+@RunWith(PaxExam.class)
+public class RoundRobinQueueTest extends AbstractJobHandlingTest {
+
+ private static final String QUEUE_NAME = "roundrobintest";
+ private static final String TOPIC = "sling/roundrobintest";
+ private static int MAX_PAR = 5;
+ private static int NUM_JOBS = 300;
+
+ @Override
+ @Before
+ public void setup() throws IOException {
+ super.setup();
+
+ // create round robin test queue
+ final org.osgi.service.cm.Configuration rrConfig = this.configAdmin.createFactoryConfiguration("org.apache.sling.event.jobs.QueueConfiguration", null);
+ final Dictionary<String, Object> rrProps = new Hashtable<String, Object>();
+ rrProps.put(ConfigurationConstants.PROP_NAME, QUEUE_NAME);
+ rrProps.put(ConfigurationConstants.PROP_TYPE, QueueConfiguration.Type.TOPIC_ROUND_ROBIN.name());
+ rrProps.put(ConfigurationConstants.PROP_TOPICS, TOPIC + "/*");
+ rrProps.put(ConfigurationConstants.PROP_RETRIES, 2);
+ rrProps.put(ConfigurationConstants.PROP_RETRY_DELAY, 2000L);
+ rrProps.put(ConfigurationConstants.PROP_MAX_PARALLEL, MAX_PAR);
+ rrConfig.update(rrProps);
+
+ this.sleep(1000L);
+ }
+
+ @Override
+ @After
+ public void cleanup() {
+ super.cleanup();
+ }
+
+ @Test(timeout = DEFAULT_TEST_TIMEOUT)
+ public void testRoundRobinQueue() throws Exception {
+ final JobManager jobManager = this.getJobManager();
+
+ final Barrier cb = new Barrier(2);
+
+ this.registerJobConsumer(TOPIC + "/start",
+ new JobConsumer() {
+
+ @Override
+ public JobResult process(final Job job) {
+ cb.block();
+ return JobResult.OK;
+ }
+ });
+
+ // register new consumer and event handle
+ final AtomicInteger count = new AtomicInteger(0);
+ final AtomicInteger parallelCount = new AtomicInteger(0);
+ final Set<Integer> maxParticipants = new HashSet<Integer>();
+
+ this.registerJobConsumer(TOPIC + "/*",
+ new JobConsumer() {
+
+ @Override
+ public JobResult process(final Job job) {
+ final int max = parallelCount.incrementAndGet();
+ if ( max > MAX_PAR ) {
+ parallelCount.decrementAndGet();
+ return JobResult.FAILED;
+ }
+ synchronized ( maxParticipants ) {
+ maxParticipants.add(max);
+ }
+ sleep(job.getProperty("sleep", 30));
+ parallelCount.decrementAndGet();
+ return JobResult.OK;
+ }
+ });
+ this.registerEventHandler(NotificationConstants.TOPIC_JOB_FINISHED,
+ new EventHandler() {
+
+ @Override
+ public void handleEvent(final Event event) {
+ count.incrementAndGet();
+ }
+ });
+
+ // we first sent one event to get the queue started
+ jobManager.addJob(TOPIC + "/start", null);
+ assertTrue("No event received in the given time.", cb.block(5));
+ cb.reset();
+
+ // get the queue
+ final Queue q = jobManager.getQueue(QUEUE_NAME);
+ assertNotNull("Queue '" + QUEUE_NAME + "' should exist!", q);
+
+ // suspend it
+ q.suspend();
+
+ // we start "some" jobs:
+ for(int i = 0; i < NUM_JOBS; i++ ) {
+ final String subTopic = TOPIC + "/sub" + (i % 10);
+ final Map<String, Object> props = new HashMap<String, Object>();
+ if ( i < 10 ) {
+ props.put("sleep", 300);
+ } else {
+ props.put("sleep", 30);
+ }
+ jobManager.addJob(subTopic, props);
+ }
+ // start the queue
+ q.resume();
+ while ( count.get() < NUM_JOBS + 1 ) {
+ assertEquals("Failed count", 0, q.getStatistics().getNumberOfFailedJobs());
+ assertEquals("Cancelled count", 0, q.getStatistics().getNumberOfCancelledJobs());
+ sleep(300);
+ }
+ // we started one event before the test, so add one
+ assertEquals("Finished count", NUM_JOBS + 1, count.get());
+ assertEquals("Finished count", NUM_JOBS + 1, jobManager.getStatistics().getNumberOfFinishedJobs());
+ assertEquals("Finished count", NUM_JOBS + 1, q.getStatistics().getNumberOfFinishedJobs());
+ assertEquals("Failed count", 0, q.getStatistics().getNumberOfFailedJobs());
+ assertEquals("Cancelled count", 0, q.getStatistics().getNumberOfCancelledJobs());
+ for(int i=1; i <= MAX_PAR; i++) {
+ assertTrue("# Participants " + String.valueOf(i) + " not in " + maxParticipants,
+ maxParticipants.contains(i));
+ }
+ }
+}
diff --git a/src/test/java/org/apache/sling/event/it/SchedulingTest.java b/src/test/java/org/apache/sling/event/it/SchedulingTest.java
new file mode 100644
index 0000000..567ee70
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/it/SchedulingTest.java
@@ -0,0 +1,87 @@
+/*
+ * 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.event.it;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.ScheduledJobInfo;
+import org.apache.sling.event.jobs.consumer.JobConsumer;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+
+@RunWith(PaxExam.class)
+public class SchedulingTest extends AbstractJobHandlingTest {
+
+ private static final String TOPIC = "job/scheduled/topic";
+
+ @Override
+ @Before
+ public void setup() throws IOException {
+ super.setup();
+
+ this.sleep(1000L);
+ }
+
+ @Override
+ @After
+ public void cleanup() {
+ super.cleanup();
+ }
+
+ @Test(timeout = DEFAULT_TEST_TIMEOUT)
+ public void testScheduling() throws Exception {
+ final AtomicInteger counter = new AtomicInteger();
+
+ this.registerJobConsumer(TOPIC, new JobConsumer() {
+
+ @Override
+ public JobResult process(final Job job) {
+ if ( job.getTopic().equals(TOPIC) ) {
+ counter.incrementAndGet();
+ }
+ return JobResult.OK;
+ }
+
+ });
+
+ // we schedule three jobs
+ final ScheduledJobInfo info1 = this.getJobManager().createJob(TOPIC).schedule().hourly(5).add();
+ assertNotNull(info1);
+ final ScheduledJobInfo info2 = this.getJobManager().createJob(TOPIC).schedule().daily(10, 5).add();
+ assertNotNull(info2);
+ final ScheduledJobInfo info3 = this.getJobManager().createJob(TOPIC).schedule().weekly(3, 19, 12).add();
+ assertNotNull(info3);
+
+ assertEquals(3, this.getJobManager().getScheduledJobs().size()); // scheduled jobs
+ info3.unschedule();
+ assertEquals(2, this.getJobManager().getScheduledJobs().size()); // scheduled jobs
+ info1.unschedule();
+ assertEquals(1, this.getJobManager().getScheduledJobs().size()); // scheduled jobs
+ info2.unschedule();
+ assertEquals(0, this.getJobManager().getScheduledJobs().size()); // scheduled jobs
+ }
+}
diff --git a/src/test/java/org/apache/sling/event/it/TimedJobsTest.java b/src/test/java/org/apache/sling/event/it/TimedJobsTest.java
new file mode 100644
index 0000000..0b2d556
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/it/TimedJobsTest.java
@@ -0,0 +1,86 @@
+/*
+ * 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.event.it;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+import java.io.IOException;
+import java.util.Date;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.ScheduledJobInfo;
+import org.apache.sling.event.jobs.consumer.JobConsumer;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+
+@RunWith(PaxExam.class)
+public class TimedJobsTest extends AbstractJobHandlingTest {
+
+ private static final String TOPIC = "timed/test/topic";
+
+ @Override
+ @Before
+ public void setup() throws IOException {
+ super.setup();
+
+ this.sleep(1000L);
+ }
+
+ @Override
+ @After
+ public void cleanup() {
+ super.cleanup();
+ }
+
+ @Test(timeout = DEFAULT_TEST_TIMEOUT)
+ public void testTimedJob() throws Exception {
+ final AtomicInteger counter = new AtomicInteger();
+
+ this.registerJobConsumer(TOPIC, new JobConsumer() {
+
+ @Override
+ public JobResult process(final Job job) {
+ if ( job.getTopic().equals(TOPIC) ) {
+ counter.incrementAndGet();
+ }
+ return JobResult.OK;
+ }
+
+ });
+
+ final Date d = new Date();
+ d.setTime(System.currentTimeMillis() + 3000); // run in 3 seconds
+
+ // create scheduled job
+ final ScheduledJobInfo info = this.getJobManager().createJob(TOPIC).schedule().at(d).add();
+ assertNotNull(info);
+
+ while ( counter.get() == 0 ) {
+ this.sleep(1000);
+ }
+ assertEquals(0, this.getJobManager().getScheduledJobs().size()); // job is not scheduled anymore
+ info.unschedule();
+ }
+
+}
diff --git a/src/test/java/org/apache/sling/event/it/TopicMatchingTest.java b/src/test/java/org/apache/sling/event/it/TopicMatchingTest.java
new file mode 100644
index 0000000..40f7439
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/it/TopicMatchingTest.java
@@ -0,0 +1,168 @@
+/*
+ * 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.event.it;
+
+import java.io.IOException;
+
+import org.apache.sling.event.impl.Barrier;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.NotificationConstants;
+import org.apache.sling.event.jobs.consumer.JobExecutionContext;
+import org.apache.sling.event.jobs.consumer.JobExecutionResult;
+import org.apache.sling.event.jobs.consumer.JobExecutor;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.osgi.framework.ServiceRegistration;
+import org.osgi.service.event.Event;
+import org.osgi.service.event.EventHandler;
+
+@RunWith(PaxExam.class)
+public class TopicMatchingTest extends AbstractJobHandlingTest {
+
+ public static final String TOPIC = "sling/test/a";
+
+ @Override
+ @Before
+ public void setup() throws IOException {
+ super.setup();
+
+ this.sleep(1000L);
+ }
+
+ @Override
+ @After
+ public void cleanup() {
+ super.cleanup();
+ }
+
+ /**
+ * Test simple pattern matching /*
+ */
+ @Test(timeout = DEFAULT_TEST_TIMEOUT)
+ public void testSimpleMatching() throws Exception {
+ final Barrier barrier = new Barrier(2);
+
+ this.registerJobExecutor("sling/test/*",
+ new JobExecutor() {
+
+ @Override
+ public JobExecutionResult process(final Job job, final JobExecutionContext context) {
+ return context.result().succeeded();
+ }
+ });
+ this.registerEventHandler(NotificationConstants.TOPIC_JOB_FINISHED,
+ new EventHandler() {
+
+ @Override
+ public void handleEvent(final Event event) {
+ barrier.block();
+ }
+ });
+
+ this.getJobManager().addJob(TOPIC, null);
+ barrier.block();
+ }
+
+ /**
+ * Test deep pattern matching /**
+ */
+ @Test(timeout = DEFAULT_TEST_TIMEOUT)
+ public void testDeepMatching() throws Exception {
+ final Barrier barrier = new Barrier(2);
+
+ this.registerJobExecutor("sling/**",
+ new JobExecutor() {
+
+ @Override
+ public JobExecutionResult process(final Job job, final JobExecutionContext context) {
+ return context.result().succeeded();
+ }
+ });
+ this.registerEventHandler(NotificationConstants.TOPIC_JOB_FINISHED,
+ new EventHandler() {
+
+ @Override
+ public void handleEvent(final Event event) {
+ barrier.block();
+ }
+ });
+
+ this.getJobManager().addJob(TOPIC, null);
+ barrier.block();
+ }
+
+ /**
+ * Test ordering of matchers
+ */
+ @Test(timeout = DEFAULT_TEST_TIMEOUT)
+ public void testOrdering() throws Exception {
+ final Barrier barrier1 = new Barrier(2);
+ final Barrier barrier2 = new Barrier(2);
+ final Barrier barrier3 = new Barrier(2);
+
+ this.registerJobExecutor("sling/**",
+ new JobExecutor() {
+
+ @Override
+ public JobExecutionResult process(final Job job, final JobExecutionContext context) {
+ barrier1.block();
+ return context.result().succeeded();
+ }
+ });
+ final ServiceRegistration<JobExecutor> reg2 = this.registerJobExecutor("sling/test/*",
+ new JobExecutor() {
+
+ @Override
+ public JobExecutionResult process(final Job job, final JobExecutionContext context) {
+ barrier2.block();
+ return context.result().succeeded();
+ }
+ });
+ final ServiceRegistration<JobExecutor> reg3 = this.registerJobExecutor(TOPIC,
+ new JobExecutor() {
+
+ @Override
+ public JobExecutionResult process(final Job job, final JobExecutionContext context) {
+ barrier3.block();
+ return context.result().succeeded();
+ }
+ });
+
+ // first test, all three registered, reg3 should get the precedence
+ this.getJobManager().addJob(TOPIC, null);
+ barrier3.block();
+
+ // second test, unregister reg3, now it should be reg2
+ long cc = this.getConsumerChangeCount();
+ this.unregister(reg3);
+ this.waitConsumerChangeCount(cc + 1);
+ this.getJobManager().addJob(TOPIC, null);
+ barrier2.block();
+
+ // third test, unregister reg2, reg1 is now the only one
+ cc = this.getConsumerChangeCount();
+ this.unregister(reg2);
+ this.waitConsumerChangeCount(cc + 1);
+ this.getJobManager().addJob(TOPIC, null);
+ barrier1.block();
+ }
+}
diff --git a/src/test/java/org/apache/sling/event/it/UnorderedQueueTest.java b/src/test/java/org/apache/sling/event/it/UnorderedQueueTest.java
new file mode 100644
index 0000000..bd0eea3
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/it/UnorderedQueueTest.java
@@ -0,0 +1,172 @@
+/*
+ * 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.event.it;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.util.Dictionary;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Hashtable;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.sling.event.impl.Barrier;
+import org.apache.sling.event.impl.jobs.config.ConfigurationConstants;
+import org.apache.sling.event.jobs.Job;
+import org.apache.sling.event.jobs.JobManager;
+import org.apache.sling.event.jobs.NotificationConstants;
+import org.apache.sling.event.jobs.Queue;
+import org.apache.sling.event.jobs.QueueConfiguration;
+import org.apache.sling.event.jobs.consumer.JobConsumer;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.osgi.service.event.Event;
+import org.osgi.service.event.EventHandler;
+
+@RunWith(PaxExam.class)
+public class UnorderedQueueTest extends AbstractJobHandlingTest {
+
+ private static final String QUEUE_NAME = "unorderedtestqueue";
+ private static final String TOPIC = "sling/unorderedtest";
+ private static int MAX_PAR = 5;
+ private static int NUM_JOBS = 300;
+
+ @Override
+ @Before
+ public void setup() throws IOException {
+ super.setup();
+
+ // create round robin test queue
+ final org.osgi.service.cm.Configuration rrConfig = this.configAdmin.createFactoryConfiguration("org.apache.sling.event.jobs.QueueConfiguration", null);
+ final Dictionary<String, Object> rrProps = new Hashtable<String, Object>();
+ rrProps.put(ConfigurationConstants.PROP_NAME, QUEUE_NAME);
+ rrProps.put(ConfigurationConstants.PROP_TYPE, QueueConfiguration.Type.UNORDERED.name());
+ rrProps.put(ConfigurationConstants.PROP_TOPICS, TOPIC + "/*");
+ rrProps.put(ConfigurationConstants.PROP_RETRIES, 2);
+ rrProps.put(ConfigurationConstants.PROP_RETRY_DELAY, 2000L);
+ rrProps.put(ConfigurationConstants.PROP_MAX_PARALLEL, MAX_PAR);
+ rrConfig.update(rrProps);
+
+ this.sleep(1000L);
+ }
+
+ @Override
+ @After
+ public void cleanup() {
+ super.cleanup();
+ }
+
+ @Test(timeout = DEFAULT_TEST_TIMEOUT)
+ public void testUnorderedQueue() throws Exception {
+ final JobManager jobManager = this.getJobManager();
+
+ final Barrier cb = new Barrier(2);
+
+ this.registerJobConsumer(TOPIC + "/start",
+ new JobConsumer() {
+
+ @Override
+ public JobResult process(final Job job) {
+ cb.block();
+ return JobResult.OK;
+ }
+ });
+
+ // register new consumer and event handle
+ final AtomicInteger count = new AtomicInteger(0);
+ final AtomicInteger parallelCount = new AtomicInteger(0);
+ final Set<Integer> maxParticipants = new HashSet<Integer>();
+
+ this.registerJobConsumer(TOPIC + "/*",
+ new JobConsumer() {
+
+ @Override
+ public JobResult process(final Job job) {
+ final int max = parallelCount.incrementAndGet();
+ if ( max > MAX_PAR ) {
+ parallelCount.decrementAndGet();
+ return JobResult.FAILED;
+ }
+ synchronized ( maxParticipants ) {
+ maxParticipants.add(max);
+ }
+ sleep(job.getProperty("sleep", 30));
+ parallelCount.decrementAndGet();
+ return JobResult.OK;
+ }
+ });
+ this.registerEventHandler(NotificationConstants.TOPIC_JOB_FINISHED,
+ new EventHandler() {
+
+ @Override
+ public void handleEvent(final Event event) {
+ count.incrementAndGet();
+ }
+ });
+
+ // we first sent one event to get the queue started
+ jobManager.addJob(TOPIC + "/start", null);
+ assertTrue("No event received in the given time.", cb.block(5));
+ cb.reset();
+
+ // get the queue
+ final Queue q = jobManager.getQueue(QUEUE_NAME);
+ assertNotNull("Queue '" + QUEUE_NAME + "' should exist!", q);
+
+ // suspend it
+ q.suspend();
+
+ // we start "some" jobs:
+ for(int i = 0; i < NUM_JOBS; i++ ) {
+ final String subTopic = TOPIC + "/sub" + (i % 10);
+ final Map<String, Object> props = new HashMap<String, Object>();
+ if ( i < 10 ) {
+ props.put("sleep", 300);
+ } else {
+ props.put("sleep", 30);
+ }
+ jobManager.addJob(subTopic, props);
+ }
+ // start the queue
+ q.resume();
+ while ( count.get() < NUM_JOBS + 1 ) {
+ assertEquals("Failed count", 0, q.getStatistics().getNumberOfFailedJobs());
+ assertEquals("Cancelled count", 0, q.getStatistics().getNumberOfCancelledJobs());
+ sleep(300);
+ }
+ // we started one event before the test, so add one
+ assertEquals("Finished count", NUM_JOBS + 1, count.get());
+ assertEquals("Finished count", NUM_JOBS + 1, jobManager.getStatistics().getNumberOfFinishedJobs());
+ assertEquals("Finished count", NUM_JOBS + 1, q.getStatistics().getNumberOfFinishedJobs());
+ assertEquals("Failed count", 0, q.getStatistics().getNumberOfFailedJobs());
+ assertEquals("Cancelled count", 0, q.getStatistics().getNumberOfCancelledJobs());
+ for(int i=1; i <= MAX_PAR; i++) {
+ assertTrue("# Participants " + String.valueOf(i) + " not in " + maxParticipants,
+ maxParticipants.contains(i));
+ }
+ }
+}