One index per type and parallel indexing (#1781)

Before this change the search uses a single index which distinguishes types (repositories, users, etc.) with a field (_type).
But it has turned out that this could lead to problems, in particular if different types have the same field and uses different analyzers for those fields. The following links show even more problems of a combined index:

    https://www.elastic.co/blog/index-vs-type
    https://www.elastic.co/guide/en/elasticsearch/reference/6.0/removal-of-types.html

With this change every type becomes its own index and the SearchEngine gets an api to modify multiple indices at once to remove all documents from all indices, which are related to a specific repository, for example.

The search uses another new api to coordinate the indexing, the central work queue.
The central work queue is able to coordinate long-running or resource intensive tasks. It is able to run tasks in parallel, but can also run tasks which targets the same resources in sequence. The queue is also persistent and can restore queued tasks after restart.

Co-authored-by: Konstantin Schaper <konstantin.schaper@cloudogu.com>
This commit is contained in:
Sebastian Sdorra
2021-08-25 15:40:11 +02:00
committed by GitHub
parent 44f25d6b15
commit 0a26741ebd
72 changed files with 4536 additions and 1420 deletions

View File

@@ -0,0 +1,8 @@
- type: Added
description: Central Work Queue for coordinating long-running tasks ([#1781](https://github.com/scm-manager/scm-manager/pull/1781))
- type: Changed
description: One index per type instead of one index for all types ([#1781](https://github.com/scm-manager/scm-manager/pull/1781))
- type: Added
description: Api to modify mutliple indices at once ([#1781](https://github.com/scm-manager/scm-manager/pull/1781))
- type: Changed
description: Use central work queue for all indexing tasks ([#1781](https://github.com/scm-manager/scm-manager/pull/1781))

View File

@@ -24,6 +24,7 @@
package sonia.scm.search;
import com.google.common.annotations.Beta;
import sonia.scm.HandlerEventType;
import sonia.scm.event.HandlerEvent;
@@ -33,11 +34,14 @@ import sonia.scm.event.HandlerEvent;
* @param <T> type of indexed item
* @since 2.22.0
*/
@Beta
public final class HandlerEventIndexSyncer<T> {
private final SearchEngine searchEngine;
private final Indexer<T> indexer;
public HandlerEventIndexSyncer(Indexer<T> indexer) {
public HandlerEventIndexSyncer(SearchEngine searchEngine, Indexer<T> indexer) {
this.searchEngine = searchEngine;
this.indexer = indexer;
}
@@ -49,17 +53,16 @@ public final class HandlerEventIndexSyncer<T> {
public void handleEvent(HandlerEvent<T> event) {
HandlerEventType type = event.getEventType();
if (type.isPost()) {
updateIndex(type, event.getItem());
SerializableIndexTask<T> task = createTask(type, event.getItem());
searchEngine.forType(indexer.getType()).update(task);
}
}
private void updateIndex(HandlerEventType type, T item) {
try (Indexer.Updater<T> updater = indexer.open()) {
private SerializableIndexTask<T> createTask(HandlerEventType type, T item) {
if (type == HandlerEventType.DELETE) {
updater.delete(item);
return indexer.createDeleteTask(item);
} else {
updater.store(item);
}
return indexer.createStoreTask(item);
}
}

View File

@@ -25,6 +25,7 @@
package sonia.scm.search;
import com.google.common.annotations.Beta;
import sonia.scm.repository.Repository;
/**
* Can be used to index objects for full text searches.
@@ -32,7 +33,15 @@ import com.google.common.annotations.Beta;
* @since 2.21.0
*/
@Beta
public interface Index<T> extends AutoCloseable {
public interface Index<T> {
/**
* Returns details such as name and type of index.
*
* @return details of index
* @since 2.23.0
*/
IndexDetails getDetails();
/**
* Store the given object in the index.
@@ -53,12 +62,6 @@ public interface Index<T> extends AutoCloseable {
*/
Deleter delete();
/**
* Close index and commit changes.
*/
@Override
void close();
/**
* Deleter provides an api to delete object from index.
*
@@ -66,27 +69,6 @@ public interface Index<T> extends AutoCloseable {
*/
interface Deleter {
/**
* Returns an api which allows deletion of objects from the type of this index.
* @return type restricted delete api
*/
ByTypeDeleter byType();
/**
* Returns an api which allows deletion of objects of every type.
* @return unrestricted delete api for all types.
*/
AllTypesDeleter allTypes();
}
/**
* Delete api for the type of the index. This means, that only entries for this
* type will be deleted.
*
* @since 2.23.0
*/
interface ByTypeDeleter {
/**
* Delete the object with the given id and type from index.
* @param id id of object
@@ -104,27 +86,15 @@ public interface Index<T> extends AutoCloseable {
* @param repositoryId id of repository
*/
void byRepository(String repositoryId);
}
/**
* Delete api for the overall index regarding all types.
* Delete all objects which are related the given type and repository from index.
*
* @since 2.23.0
* @param repository repository
*/
interface AllTypesDeleter {
/**
* Delete all objects which are related to the given repository from index regardless of their type.
* @param repositoryId repository id
*/
void byRepository(String repositoryId);
/**
* Delete all objects with the given type from index.
* This method is mostly useful if the index type has changed and the old type (in form of a class)
* is no longer available.
* @param typeName type name of objects
*/
void byTypeName(String typeName);
default void byRepository(Repository repository) {
byRepository(repository.getId());
}
}
}

View File

@@ -0,0 +1,47 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import com.google.common.annotations.Beta;
/**
* Details of an index.
* @since 2.23.0
*/
@Beta
public interface IndexDetails {
/**
* Returns type of objects which are indexed.
* @return type of objects
*/
Class<?> getType();
/**
* Returns the name of the index (e.g. `default`)
* @return name
*/
String getName();
}

View File

@@ -27,6 +27,7 @@ package sonia.scm.search;
import com.google.common.annotations.Beta;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.util.Locale;
/**
@@ -36,7 +37,7 @@ import java.util.Locale;
*/
@Beta
@EqualsAndHashCode
public class IndexOptions {
public class IndexOptions implements Serializable {
private final Type type;
private final Locale locale;

View File

@@ -0,0 +1,51 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import com.google.common.annotations.Beta;
/**
* A task which updates an index.
* @param <T> type of indexed objects
* @since 2.23.0
*/
@Beta
@FunctionalInterface
public interface IndexTask<T> {
/**
* Execute operations on the index.
* @param index index to update
*/
void update(Index<T> index);
/**
* This method is called after work is committed to the index.
*/
default void afterUpdate() {
// Do nothing
}
}

View File

@@ -24,6 +24,7 @@
package sonia.scm.search;
import com.google.common.annotations.Beta;
import sonia.scm.plugin.ExtensionPoint;
/**
@@ -35,6 +36,7 @@ import sonia.scm.plugin.ExtensionPoint;
* @since 2.22.0
* @see HandlerEventIndexSyncer
*/
@Beta
@ExtensionPoint
public interface Indexer<T> {
@@ -54,42 +56,48 @@ public interface Indexer<T> {
int getVersion();
/**
* Opens the index and return an updater for the given type.
*
* @return updater with open index
* Returns task which re index all items.
* @return task to re index all
* @since 2.23.0
*/
Updater<T> open();
Class<? extends ReIndexAllTask<T>> getReIndexAllTask();
/**
* Updater for index.
*
* @param <T> type to index
* Creates a task which stores the given item in the index.
* @param item item to store
* @return task which stores the item
* @since 2.23.0
*/
interface Updater<T> extends AutoCloseable {
SerializableIndexTask<T> createStoreTask(T item);
/**
* Stores the given item in the index.
*
* @param item item to index
*/
void store(T item);
/**
* Delete the given item from the index
*
* Creates a task which deletes the given item from index.
* @param item item to delete
* @return task which deletes the item
* @since 2.23.0
*/
void delete(T item);
SerializableIndexTask<T> createDeleteTask(T item);
/**
* Re index all existing items.
* Abstract class which builds the foundation for tasks which re-index all items.
*
* @since 2.23.0
*/
void reIndexAll();
abstract class ReIndexAllTask<T> implements IndexTask<T> {
/**
* Close the given index.
*/
void close();
private final IndexLogStore logStore;
private final Class<T> type;
private final int version;
protected ReIndexAllTask(IndexLogStore logStore, Class<T> type, int version) {
this.logStore = logStore;
this.type = type;
this.version = version;
}
@Override
public void afterUpdate() {
logStore.defaultIndex().log(type, version);
}
}
}

View File

@@ -25,8 +25,10 @@
package sonia.scm.search;
import com.google.common.annotations.Beta;
import sonia.scm.ModelObject;
import java.util.Collection;
import java.util.function.Predicate;
/**
* The {@link SearchEngine} is the main entry point for indexing and searching.
@@ -61,6 +63,80 @@ public interface SearchEngine {
*/
ForType<Object> forType(String name);
/**
* Returns an api which allows the modification of multiple indices at once.
* @return api to modify multiple indices
* @since 2.23.0
*/
ForIndices forIndices();
/**
* Api for modifying multiple indices at once.
* @since 2.23.0
*/
interface ForIndices {
/**
* This method can be used to filter the indices.
* If no predicate is used the tasks are enqueued for every existing index.
*
* @param predicate predicate to filter indices
* @return {@code this}
*/
ForIndices matching(Predicate<IndexDetails> predicate);
/**
* Apply a lock for a specific resource. By default, a lock for the whole index is used.
* If one or more specific resources are locked, than the lock is applied only for those resources
* and tasks which targets other resources of the same index can run in parallel.
*
* @param resource specific resource to lock
* @return {@code this}
*/
ForIndices forResource(String resource);
/**
* This method is a shortcut for {@link #forResource(String)} with the id of the given resource.
*
* @param resource resource in form of model object
* @return {@code this}
*/
default ForIndices forResource(ModelObject resource) {
return forResource(resource.getId());
}
/**
* Specify options for the index.
* If not used the default options will be used.
* @param options index options
* @return {@code this}
* @see IndexOptions#defaults()
*/
ForIndices withOptions(IndexOptions options);
/**
* Submits the task and execute it for every index
* which are matching the predicate ({@link #matching(Predicate)}.
* The task is executed asynchronous and will be finished some time in the future.
* <strong>Note:</strong> the task must be serializable because it is submitted to the
* {@link sonia.scm.work.CentralWorkQueue}.
* For more information on task serialization have a look at the
* {@link sonia.scm.work.CentralWorkQueue} documentation.
*
* @param task serializable task for updating multiple indices
*/
void batch(SerializableIndexTask<?> task);
/**
* Submits the task and executes it for every index
* which are matching the predicate ({@link #matching(Predicate)}.
* The task is executed asynchronously and will finish at some unknown point in the future.
*
* @param task task for updating multiple indices
*/
void batch(Class<? extends IndexTask<?>> task);
}
/**
* Search and index api.
*
@@ -87,10 +163,44 @@ public interface SearchEngine {
ForType<T> withIndex(String name);
/**
* Returns an index object which provides method to update the search index.
* @return index object
* Apply a lock for a specific resource. By default, a lock for the whole index is used.
* If one or more specific resources are locked, then the lock is applied only for those resources
* and tasks which target other resources of the same index can run in parallel.
*
* @param resource specific resource to lock
* @return {@code this}
*/
Index<T> getOrCreate();
ForType<T> forResource(String resource);
/**
* This method is a shortcut for {@link #forResource(String)} with the id of the given resource.
*
* @param resource resource in form of model object
* @return {@code this}
*/
default ForType<T> forResource(ModelObject resource) {
return forResource(resource.getId());
}
/**
* Submits a task to update the index.
* The task is executed asynchronously and will finish at some unknown point in the future.
* <strong>Note:</strong> the task must be serializable because it is submitted to the
* {@link sonia.scm.work.CentralWorkQueue},
* for more information about the task serialization have a look at the
* {@link sonia.scm.work.CentralWorkQueue} documentation.
*
* @param task serializable task for updating the index
*/
void update(SerializableIndexTask<T> task);
/**
* Submits a task to update the index.
* The task is executed asynchronous and will be finished some time in the future.
*
* @param task task for updating multiple indices
*/
void update(Class<? extends IndexTask<T>> task);
/**
* Returns a query builder object which can be used to search the index.

View File

@@ -0,0 +1,40 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import com.google.common.annotations.Beta;
import java.io.Serializable;
/**
* A serializable version of {@link IndexTask}.
*
* @param <T> type of indexed objects
* @since 2.23.0
*/
@Beta
@FunctionalInterface
public interface SerializableIndexTask<T> extends IndexTask<T>, Serializable {
}

View File

@@ -0,0 +1,174 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.work;
import com.google.common.annotations.Beta;
import sonia.scm.ModelObject;
import javax.annotation.Nullable;
/**
* The {@link CentralWorkQueue} provides an api to submit and coordinate long-running or resource intensive tasks.
*
* The tasks are executed in parallel, but if some tasks access the same resource this can become a problem.
* To avoid this problem a task can be enqueued with a lock e.g.:
*
* <pre>{@code
* queue.append().locks("my-resources").enqueue(MyTask.class)
* }</pre>
*
* No tasks with the same lock will run in parallel. The example above locks a whole group of resources.
* It is possible to assign the lock to a more specific resource by adding the id parameter to the lock e.g.:
*
* <pre>{@code
* queue.append().locks("my-resources", "42").enqueue(MyTask.class)
* }</pre>
*
* This will ensure, that no task with a lock for the <i>my-resource 42</i> or for <i>my-resources</i> will run at the
* same time as the task which is enqueued in the example above.
* But this will also allow a task for <i>my-resources</i> with an id other than <i>42</i> can run in parallel.
*
* All tasks are executed with the permissions of the user which enqueues the task.
* If the task should run as admin, the {@link Enqueue#runAsAdmin()} method can be used.
*
* Tasks which could not be finished,
* before a restart of shutdown of the server, will be restored and executed on startup.
* In order to achieve the persistence of tasks,
* the enqueued task must be provided as a class or it must be serializable.
* This could become unhandy if the task has parameters and dependencies which must be injected.
* The injected objects should not be serialized with the task.
* In order to avoid this, dependencies should be declared as {@code transient}
* and injected via setters instead of the constructor parameters e.g.:
*
* <pre><code>
* public class MyTask implements Task {
*
* private final Repository repository;
* private transient RepositoryServiceFactory repositoryServiceFactory;
*
* public MyTask(Repository repository) {
* this.repository = repository;
* }
*
* {@code @}Inject
* public void setRepositoryServiceFactory(RepositoryServiceFactory repositoryServiceFactory) {
* this.repositoryServiceFactory = repositoryServiceFactory;
* }
*
* {@code @}Override
* public void run() {
* // do something with the repository and the repositoryServiceFactory
* }
*
* }
* </code></pre>
*
* The {@link CentralWorkQueue} will inject the requested members before the {@code run} method of the task is executed.
*
* @since 2.23.0
*/
@Beta
public interface CentralWorkQueue {
/**
* Append a new task to the central work queue.
* The method will return a builder interface to configure how the task will be enqueued.
*
* @return builder api for enqueue a task
*/
Enqueue append();
/**
* Returns the count of pending or running tasks.
*
* @return count of pending or running tasks
*/
int getSize();
/**
* Builder interface for the enqueueing of a new task.
*/
interface Enqueue {
/**
* Configure a lock for the given resource type.
* For more information on locks see the class documentation ({@link CentralWorkQueue}).
*
* @param resourceType resource type to lock
* @return {@code this}
* @see CentralWorkQueue
*/
Enqueue locks(String resourceType);
/**
* Configure a lock for the resource with the given type and id.
* Note if the id is {@code null} the whole resource type is locked.
* For more information on locks see the class documentation ({@link CentralWorkQueue}).
*
* @param resourceType resource type to lock
* @param id id of resource to lock
* @return {@code this}
* @see CentralWorkQueue
*/
Enqueue locks(String resourceType, @Nullable String id);
/**
* Configure a lock for the resource with the given type and the id from the given {@link ModelObject}.
* For more information on locks see the class documentation ({@link CentralWorkQueue}).
*
* @param resourceType resource type to lock
* @param object which holds the id of the resource to lock
* @return {@code this}
* @see CentralWorkQueue
*/
default Enqueue locks(String resourceType, ModelObject object) {
return locks(resourceType, object.getId());
}
/**
* Run the enqueued task with administrator permission.
*
* @return {@code this}
*/
Enqueue runAsAdmin();
/**
* Enqueue the given task to {@link CentralWorkQueue}.
* <strong>Warning: </strong> Ensure that the task is serializable.
* If the task is not serializable an {@link NonPersistableTaskException} will be thrown.
* For more information about the persistence of tasks see class documentation.
*
* @param task serializable task to enqueue
* @see CentralWorkQueue
*/
void enqueue(Task task);
/**
* Enqueue the given task to {@link CentralWorkQueue}.
* @param task task to enqueue
*/
void enqueue(Class<? extends Runnable> task);
}
}

View File

@@ -0,0 +1,39 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.work;
import com.google.common.annotations.Beta;
/**
* Exception thrown when a task is enqueued to the {@link CentralWorkQueue} which cannot persisted.
*
* @since 2.23.0
*/
@Beta
public final class NonPersistableTaskException extends RuntimeException {
public NonPersistableTaskException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,38 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.work;
import com.google.common.annotations.Beta;
import java.io.Serializable;
/**
* Serializable task which can be enqueued to the {@link CentralWorkQueue}.
*
* @since 2.23.0
*/
@Beta
public interface Task extends Runnable, Serializable {
}

View File

@@ -36,6 +36,24 @@ public class StoreConstants
public static final String CONFIG_DIRECTORY_NAME = "config";
/**
* Name of the parent of data or blob directories.
* @since 2.23.0
*/
public static final String VARIABLE_DATA_DIRECTORY_NAME = "var";
/**
* Name of data directories.
* @since 2.23.0
*/
public static final String DATA_DIRECTORY_NAME = "data";
/**
* Name of blob directories.
* @since 2.23.0
*/
public static final String BLOG_DIRECTORY_NAME = "data";
public static final String REPOSITORY_METADATA = "metadata";
public static final String FILE_EXTENSION = ".xml";

View File

@@ -24,6 +24,8 @@
package sonia.scm.store;
import com.google.common.collect.ImmutableList;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FileNotFoundException;
@@ -59,7 +61,7 @@ public class InMemoryBlobStore implements BlobStore {
@Override
public List<Blob> getAll() {
return blobs;
return ImmutableList.copyOf(blobs);
}
@Override

View File

@@ -28,7 +28,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.authz.UnauthorizedException;
import org.jboss.resteasy.mock.MockDispatcherFactory;
import org.jboss.resteasy.mock.MockHttpRequest;
import org.jboss.resteasy.spi.Dispatcher;
import org.jboss.resteasy.spi.HttpRequest;
import org.jboss.resteasy.spi.HttpResponse;

View File

@@ -99,7 +99,7 @@ const AvatarSection: FC<HitProps> = ({ hit }) => {
const name = useStringHitFieldValue(hit, "name");
const type = useStringHitFieldValue(hit, "type");
const repository = hit._embedded.repository;
const repository = hit._embedded?.repository;
if (!namespace || !name || !type || !repository) {
return null;
}

View File

@@ -44,7 +44,7 @@ const RepositoryHit: FC<HitProps> = ({ hit }) => {
// the embedded repository is only a subset of the repository (RepositoryCoordinates),
// so we should use the fields to get more information
const repository = hit._embedded.repository;
const repository = hit._embedded?.repository;
if (!namespace || !name || !type || !repository) {
return null;
}

View File

@@ -30,8 +30,10 @@ import sonia.scm.plugin.Extension;
import sonia.scm.search.HandlerEventIndexSyncer;
import sonia.scm.search.Id;
import sonia.scm.search.Index;
import sonia.scm.search.IndexLogStore;
import sonia.scm.search.Indexer;
import sonia.scm.search.SearchEngine;
import sonia.scm.search.SerializableIndexTask;
import javax.inject.Inject;
import javax.inject.Singleton;
@@ -43,12 +45,10 @@ public class GroupIndexer implements Indexer<Group> {
@VisibleForTesting
static final int VERSION = 1;
private final GroupManager groupManager;
private final SearchEngine searchEngine;
@Inject
public GroupIndexer(GroupManager groupManager, SearchEngine searchEngine) {
this.groupManager = groupManager;
public GroupIndexer(SearchEngine searchEngine) {
this.searchEngine = searchEngine;
}
@@ -62,47 +62,47 @@ public class GroupIndexer implements Indexer<Group> {
return VERSION;
}
@Override
public Class<? extends ReIndexAllTask<Group>> getReIndexAllTask() {
return ReIndexAll.class;
}
@Override
public SerializableIndexTask<Group> createStoreTask(Group group) {
return index -> store(index, group);
}
@Override
public SerializableIndexTask<Group> createDeleteTask(Group group) {
return index -> index.delete().byId(Id.of(group));
}
@Subscribe(async = false)
public void handleEvent(GroupEvent event) {
new HandlerEventIndexSyncer<>(this).handleEvent(event);
new HandlerEventIndexSyncer<>(searchEngine, this).handleEvent(event);
}
@Override
public Updater<Group> open() {
return new GroupIndexUpdater(groupManager, searchEngine.forType(Group.class).getOrCreate());
}
public static class GroupIndexUpdater implements Updater<Group> {
private final GroupManager groupManager;
private final Index<Group> index;
private GroupIndexUpdater(GroupManager groupManager, Index<Group> index) {
this.groupManager = groupManager;
this.index = index;
}
@Override
public void store(Group group) {
public static void store(Index<Group> index, Group group) {
index.store(Id.of(group), GroupPermissions.read(group).asShiroString(), group);
}
@Override
public void delete(Group group) {
index.delete().byType().byId(Id.of(group));
public static class ReIndexAll extends ReIndexAllTask<Group> {
private final GroupManager groupManager;
@Inject
public ReIndexAll(IndexLogStore logStore, GroupManager groupManager) {
super(logStore, Group.class, VERSION);
this.groupManager = groupManager;
}
@Override
public void reIndexAll() {
index.delete().byType().all();
public void update(Index<Group> index) {
index.delete().all();
for (Group group : groupManager.getAll()) {
store(group);
store(index, group);
}
}
}
@Override
public void close() {
index.close();
}
}
}

View File

@@ -134,6 +134,8 @@ import sonia.scm.web.cgi.DefaultCGIExecutorFactory;
import sonia.scm.web.filter.LoggingFilter;
import sonia.scm.web.security.AdministrationContext;
import sonia.scm.web.security.DefaultAdministrationContext;
import sonia.scm.work.CentralWorkQueue;
import sonia.scm.work.DefaultCentralWorkQueue;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
@@ -290,6 +292,8 @@ class ScmServletModule extends ServletModule {
bind(SearchEngine.class, LuceneSearchEngine.class);
bind(IndexLogStore.class, DefaultIndexLogStore.class);
bind(CentralWorkQueue.class, DefaultCentralWorkQueue.class);
bind(ContentTypeResolver.class).to(DefaultContentTypeResolver.class);
}

View File

@@ -26,12 +26,14 @@ package sonia.scm.repository;
import com.github.legman.Subscribe;
import com.google.common.annotations.VisibleForTesting;
import sonia.scm.HandlerEventType;
import sonia.scm.plugin.Extension;
import sonia.scm.search.HandlerEventIndexSyncer;
import sonia.scm.search.Id;
import sonia.scm.search.Index;
import sonia.scm.search.IndexLogStore;
import sonia.scm.search.Indexer;
import sonia.scm.search.SearchEngine;
import sonia.scm.search.SerializableIndexTask;
import javax.inject.Inject;
import javax.inject.Singleton;
@@ -43,12 +45,10 @@ public class RepositoryIndexer implements Indexer<Repository> {
@VisibleForTesting
static final int VERSION = 3;
private final RepositoryManager repositoryManager;
private final SearchEngine searchEngine;
@Inject
public RepositoryIndexer(RepositoryManager repositoryManager, SearchEngine searchEngine) {
this.repositoryManager = repositoryManager;
public RepositoryIndexer(SearchEngine searchEngine) {
this.searchEngine = searchEngine;
}
@@ -62,49 +62,58 @@ public class RepositoryIndexer implements Indexer<Repository> {
return Repository.class;
}
@Override
public Class<? extends ReIndexAllTask<Repository>> getReIndexAllTask() {
return ReIndexAll.class;
}
@Subscribe(async = false)
public void handleEvent(RepositoryEvent event) {
new HandlerEventIndexSyncer<>(this).handleEvent(event);
HandlerEventType type = event.getEventType();
if (type.isPost()) {
Repository repository = event.getItem();
if (type == HandlerEventType.DELETE) {
searchEngine.forIndices()
.forResource(repository)
.batch(createDeleteTask(repository));
} else {
searchEngine.forType(Repository.class)
.update(createStoreTask(repository));
}
}
}
@Override
public Updater<Repository> open() {
return new RepositoryIndexUpdater(repositoryManager, searchEngine.forType(getType()).getOrCreate());
}
public static class RepositoryIndexUpdater implements Updater<Repository> {
private final RepositoryManager repositoryManager;
private final Index<Repository> index;
public RepositoryIndexUpdater(RepositoryManager repositoryManager, Index<Repository> index) {
this.repositoryManager = repositoryManager;
this.index = index;
public SerializableIndexTask<Repository> createStoreTask(Repository repository) {
return index -> store(index, repository);
}
@Override
public void store(Repository repository) {
public SerializableIndexTask<Repository> createDeleteTask(Repository repository) {
return index -> index.delete().byRepository(repository);
}
private static void store(Index<Repository> index, Repository repository) {
index.store(Id.of(repository), RepositoryPermissions.read(repository).asShiroString(), repository);
}
@Override
public void delete(Repository repository) {
index.delete().allTypes().byRepository(repository.getId());
public static class ReIndexAll extends ReIndexAllTask<Repository> {
private final RepositoryManager repositoryManager;
@Inject
public ReIndexAll(IndexLogStore logStore, RepositoryManager repositoryManager) {
super(logStore, Repository.class, VERSION);
this.repositoryManager = repositoryManager;
}
@Override
public void reIndexAll() {
// v1 used the whole classname as type
index.delete().allTypes().byTypeName(Repository.class.getName());
index.delete().byType().all();
public void update(Index<Repository> index) {
index.delete().all();
for (Repository repository : repositoryManager.getAll()) {
store(repository);
store(index, repository);
}
}
}
@Override
public void close() {
index.close();
}
}
}

View File

@@ -27,9 +27,7 @@ package sonia.scm.search;
final class FieldNames {
private FieldNames(){}
static final String UID = "_uid";
static final String ID = "_id";
static final String TYPE = "_type";
static final String REPOSITORY = "_repository";
static final String PERMISSION = "_permission";
}

View File

@@ -27,7 +27,6 @@ package sonia.scm.search;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.plugin.Extension;
import sonia.scm.web.security.AdministrationContext;
import javax.inject.Inject;
import javax.inject.Singleton;
@@ -43,13 +42,13 @@ public class IndexBootstrapListener implements ServletContextListener {
private static final Logger LOG = LoggerFactory.getLogger(IndexBootstrapListener.class);
private final AdministrationContext administrationContext;
private final SearchEngine searchEngine;
private final IndexLogStore indexLogStore;
private final Set<Indexer> indexers;
@Inject
public IndexBootstrapListener(AdministrationContext administrationContext, IndexLogStore indexLogStore, Set<Indexer> indexers) {
this.administrationContext = administrationContext;
public IndexBootstrapListener(SearchEngine searchEngine, IndexLogStore indexLogStore, Set<Indexer> indexers) {
this.searchEngine = searchEngine;
this.indexLogStore = indexLogStore;
this.indexers = indexers;
}
@@ -65,8 +64,11 @@ public class IndexBootstrapListener implements ServletContextListener {
Optional<IndexLog> indexLog = indexLogStore.defaultIndex().get(indexer.getType());
if (indexLog.isPresent()) {
int version = indexLog.get().getVersion();
if (version < indexer.getVersion()) {
LOG.debug("index version {} is older then {}, start reindexing of all {}", version, indexer.getVersion(), indexer.getType());
if (version != indexer.getVersion()) {
LOG.debug(
"index version {} is older then {}, start reindexing of all {}",
version, indexer.getVersion(), indexer.getType()
);
indexAll(indexer);
}
} else {
@@ -75,14 +77,9 @@ public class IndexBootstrapListener implements ServletContextListener {
}
}
@SuppressWarnings("unchecked")
private void indexAll(Indexer indexer) {
administrationContext.runAsAdmin(() -> {
try (Indexer.Updater updater = indexer.open()) {
updater.reIndexAll();
}
});
indexLogStore.defaultIndex().log(indexer.getType(), indexer.getVersion());
searchEngine.forType(indexer.getType()).update(indexer.getReIndexAllTask());
}
@Override

View File

@@ -0,0 +1,139 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import lombok.Data;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.FSDirectory;
import sonia.scm.SCMContextProvider;
import sonia.scm.plugin.PluginLoader;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.xml.bind.JAXB;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlRootElement;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
@Singleton
public class IndexManager {
private final Path directory;
private final AnalyzerFactory analyzerFactory;
private final IndexXml indexXml;
@Inject
public IndexManager(SCMContextProvider context, PluginLoader pluginLoader, AnalyzerFactory analyzerFactory) {
directory = context.resolve(Paths.get("index"));
this.analyzerFactory = analyzerFactory;
this.indexXml = readIndexXml(pluginLoader.getUberClassLoader());
}
private IndexXml readIndexXml(ClassLoader uberClassLoader) {
Path path = directory.resolve("index.xml");
if (Files.exists(path)) {
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
try {
Thread.currentThread().setContextClassLoader(uberClassLoader);
return JAXB.unmarshal(path.toFile(), IndexXml.class);
} finally {
Thread.currentThread().setContextClassLoader(contextClassLoader);
}
}
return new IndexXml();
}
public Collection<? extends IndexDetails> all() {
return Collections.unmodifiableSet(indexXml.indices);
}
public IndexReader openForRead(LuceneSearchableType type, String indexName) throws IOException {
Path path = resolveIndexDirectory(type, indexName);
return DirectoryReader.open(FSDirectory.open(path));
}
public IndexWriter openForWrite(IndexParams indexParams) {
IndexWriterConfig config = new IndexWriterConfig(analyzerFactory.create(indexParams.getSearchableType(), indexParams.getOptions()));
config.setOpenMode(IndexWriterConfig.OpenMode.CREATE_OR_APPEND);
Path path = resolveIndexDirectory(indexParams);
if (!Files.exists(path)) {
store(new LuceneIndexDetails(indexParams.getType(), indexParams.getIndex()));
}
try {
return new IndexWriter(FSDirectory.open(path), config);
} catch (IOException ex) {
throw new SearchEngineException("failed to open index at " + path, ex);
}
}
private Path resolveIndexDirectory(IndexParams indexParams) {
return directory.resolve(indexParams.getSearchableType().getName()).resolve(indexParams.getIndex());
}
private Path resolveIndexDirectory(LuceneSearchableType searchableType, String indexName) {
return directory.resolve(searchableType.getName()).resolve(indexName);
}
private synchronized void store(LuceneIndexDetails details) {
if (!indexXml.getIndices().add(details)) {
return;
}
if (!Files.exists(directory)) {
try {
Files.createDirectory(directory);
} catch (IOException e) {
throw new SearchEngineException("failed to create index directory", e);
}
}
Path path = directory.resolve("index.xml");
JAXB.marshal(indexXml, path.toFile());
}
@Data
@XmlRootElement(name = "indices")
@XmlAccessorType(XmlAccessType.FIELD)
public static class IndexXml {
@XmlElement(name = "index")
private Set<LuceneIndexDetails> indices = new HashSet<>();
}
}

View File

@@ -27,10 +27,24 @@ package sonia.scm.search;
import lombok.Value;
@Value
public class IndexParams {
public class IndexParams implements IndexDetails {
String index;
LuceneSearchableType searchableType;
IndexOptions options;
@Override
public Class<?> getType() {
return searchableType.getType();
}
@Override
public String getName() {
return index;
}
@Override
public String toString() {
return searchableType.getName() + "/" + index;
}
}

View File

@@ -24,54 +24,71 @@
package sonia.scm.search;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.BooleanClause;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.TermQuery;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import java.io.IOException;
import java.util.function.Supplier;
import static sonia.scm.search.FieldNames.ID;
import static sonia.scm.search.FieldNames.PERMISSION;
import static sonia.scm.search.FieldNames.REPOSITORY;
import static sonia.scm.search.FieldNames.TYPE;
import static sonia.scm.search.FieldNames.UID;
class LuceneIndex<T> implements Index<T> {
class LuceneIndex<T> implements Index<T>, AutoCloseable {
private static final Logger LOG = LoggerFactory.getLogger(LuceneIndex.class);
private final IndexDetails details;
private final LuceneSearchableType searchableType;
private final IndexWriter writer;
private final SharableIndexWriter writer;
LuceneIndex(LuceneSearchableType searchableType, IndexWriter writer) {
this.searchableType = searchableType;
this.writer = writer;
LuceneIndex(IndexParams params, Supplier<IndexWriter> writerFactory) {
this.details = params;
this.searchableType = params.getSearchableType();
this.writer = new SharableIndexWriter(writerFactory);
this.open();
}
void open() {
writer.open();
}
@VisibleForTesting
SharableIndexWriter getWriter() {
return writer;
}
@Override
public IndexDetails getDetails() {
return details;
}
@Override
public void store(Id id, String permission, Object object) {
String uid = createUid(id, searchableType);
Document document = searchableType.getTypeConverter().convert(object);
try {
field(document, UID, uid);
field(document, ID, id.getValue());
field(document, ID, id.asString());
id.getRepository().ifPresent(repository -> field(document, REPOSITORY, repository));
field(document, TYPE, searchableType.getName());
if (!Strings.isNullOrEmpty(permission)) {
field(document, PERMISSION, permission);
}
writer.updateDocument(new Term(UID, uid), document);
writer.updateDocument(idTerm(id), document);
} catch (IOException e) {
throw new SearchEngineException("failed to add document to index", e);
}
}
private String createUid(Id id, LuceneSearchableType type) {
return id.asString() + "/" + type.getName();
@Nonnull
private Term idTerm(Id id) {
return new Term(ID, id.asString());
}
private void field(Document document, String type, String name) {
@@ -94,24 +111,11 @@ class LuceneIndex<T> implements Index<T> {
private class LuceneDeleter implements Deleter {
@Override
public ByTypeDeleter byType() {
return new LuceneByTypeDeleter();
}
@Override
public AllTypesDeleter allTypes() {
return new LuceneAllTypesDelete();
}
}
@SuppressWarnings("java:S1192")
private class LuceneByTypeDeleter implements ByTypeDeleter {
@Override
public void byId(Id id) {
try {
writer.deleteDocuments(new Term(UID, createUid(id, searchableType)));
long count = writer.deleteDocuments(idTerm(id));
LOG.debug("delete {} document(s) by id {} from index {}", count, id, details);
} catch (IOException e) {
throw new SearchEngineException("failed to delete document from index", e);
}
@@ -120,7 +124,8 @@ class LuceneIndex<T> implements Index<T> {
@Override
public void all() {
try {
writer.deleteDocuments(new Term(TYPE, searchableType.getName()));
long count = writer.deleteAll();
LOG.debug("deleted all {} documents from index {}", count, details);
} catch (IOException ex) {
throw new SearchEngineException("failed to delete documents by type " + searchableType.getName() + " from index", ex);
}
@@ -129,35 +134,16 @@ class LuceneIndex<T> implements Index<T> {
@Override
public void byRepository(String repositoryId) {
try {
BooleanQuery query = new BooleanQuery.Builder()
.add(new TermQuery(new Term(TYPE, searchableType.getName())), BooleanClause.Occur.MUST)
.add(new TermQuery(new Term(REPOSITORY, repositoryId)), BooleanClause.Occur.MUST)
.build();
writer.deleteDocuments(query);
long count = writer.deleteDocuments(repositoryTerm(repositoryId));
LOG.debug("deleted {} documents by repository {} from index {}", count, repositoryId, details);
} catch (IOException ex) {
throw new SearchEngineException("failed to delete documents by repository " + repositoryId + " from index", ex);
}
}
}
private class LuceneAllTypesDelete implements AllTypesDeleter {
@Override
public void byRepository(String repositoryId) {
try {
writer.deleteDocuments(new Term(REPOSITORY, repositoryId));
} catch (IOException ex) {
throw new SearchEngineException("failed to delete all documents by repository " + repositoryId + " from index", ex);
}
}
@Override
public void byTypeName(String typeName) {
try {
writer.deleteDocuments(new Term(TYPE, typeName));
} catch (IOException ex) {
throw new SearchEngineException("failed to delete documents by type " + typeName + " from index", ex);
}
@Nonnull
private Term repositoryTerm(String repositoryId) {
return new Term(REPOSITORY, repositoryId);
}
}
}

View File

@@ -0,0 +1,43 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlRootElement;
@Data
@XmlRootElement
@NoArgsConstructor
@AllArgsConstructor
@XmlAccessorType(XmlAccessType.FIELD)
public class LuceneIndexDetails implements IndexDetails {
private Class<?> type;
private String name;
}

View File

@@ -24,23 +24,50 @@
package sonia.scm.search;
import javax.inject.Inject;
import java.io.IOException;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Singleton
@SuppressWarnings("unchecked")
public class LuceneIndexFactory {
private final IndexOpener indexOpener;
private final IndexManager indexManager;
@SuppressWarnings("rawtypes")
private final Map<IndexKey, LuceneIndex> indexes = new ConcurrentHashMap<>();
@Inject
public LuceneIndexFactory(IndexOpener indexOpener) {
this.indexOpener = indexOpener;
public LuceneIndexFactory(IndexManager indexManager) {
this.indexManager = indexManager;
}
public <T> LuceneIndex<T> create(IndexParams indexParams) {
try {
return new LuceneIndex<>(indexParams.getSearchableType(), indexOpener.openForWrite(indexParams));
} catch (IOException ex) {
throw new SearchEngineException("failed to open index " + indexParams.getIndex(), ex);
return indexes.compute(keyOf(indexParams), (key, index) -> {
if (index != null) {
index.open();
return index;
}
return new LuceneIndex<>(
indexParams,
() -> indexManager.openForWrite(indexParams)
);
});
}
private IndexKey keyOf(IndexParams indexParams) {
return new IndexKey(
indexParams.getSearchableType().getName(), indexParams.getIndex()
);
}
@EqualsAndHashCode
@AllArgsConstructor
private static class IndexKey {
String type;
String name;
}
}

View File

@@ -0,0 +1,76 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import com.google.inject.Injector;
import javax.inject.Inject;
import java.io.Serializable;
public abstract class LuceneIndexTask implements Runnable, Serializable {
private final Class<?> type;
private final String indexName;
private final IndexOptions options;
private transient LuceneIndexFactory indexFactory;
private transient SearchableTypeResolver searchableTypeResolver;
private transient Injector injector;
protected LuceneIndexTask(IndexParams params) {
this.type = params.getSearchableType().getType();
this.indexName = params.getIndex();
this.options = params.getOptions();
}
@Inject
public void setIndexFactory(LuceneIndexFactory indexFactory) {
this.indexFactory = indexFactory;
}
@Inject
public void setSearchableTypeResolver(SearchableTypeResolver searchableTypeResolver) {
this.searchableTypeResolver = searchableTypeResolver;
}
@Inject
public void setInjector(Injector injector) {
this.injector = injector;
}
public abstract IndexTask<?> task(Injector injector);
@SuppressWarnings({"unchecked", "rawtypes"})
public void run() {
LuceneSearchableType searchableType = searchableTypeResolver.resolve(type);
IndexTask<?> task = task(injector);
try (LuceneIndex index = indexFactory.create(new IndexParams(indexName, searchableType, options))) {
task.update(index);
}
task.afterUpdate();
}
}

View File

@@ -24,31 +24,21 @@
package sonia.scm.search;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.inject.Injector;
import sonia.scm.work.Task;
public final class IndexQueueTaskWrapper<T> implements Runnable {
@SuppressWarnings("rawtypes")
public class LuceneInjectingIndexTask extends LuceneIndexTask implements Task {
private static final Logger LOG = LoggerFactory.getLogger(IndexQueueTaskWrapper.class);
private final Class<? extends IndexTask> taskClass;
private final LuceneIndexFactory indexFactory;
private final IndexParams indexParams;
private final Iterable<IndexQueueTask<T>> tasks;
IndexQueueTaskWrapper(LuceneIndexFactory indexFactory, IndexParams indexParams, Iterable<IndexQueueTask<T>> tasks) {
this.indexFactory = indexFactory;
this.indexParams = indexParams;
this.tasks = tasks;
LuceneInjectingIndexTask(IndexParams params, Class<? extends IndexTask> taskClass) {
super(params);
this.taskClass = taskClass;
}
@Override
public void run() {
try (Index<T> index = indexFactory.create(indexParams)) {
for (IndexQueueTask<T> task : tasks) {
task.updateIndex(index);
}
} catch (Exception e) {
LOG.warn("failure during execution of index task for index {}", indexParams.getIndex(), e);
}
public IndexTask<?> task(Injector injector) {
return injector.getInstance(taskClass);
}
}

View File

@@ -54,12 +54,12 @@ public class LuceneQueryBuilder<T> extends QueryBuilder<T> {
private static final Logger LOG = LoggerFactory.getLogger(LuceneQueryBuilder.class);
private final IndexOpener opener;
private final IndexManager opener;
private final LuceneSearchableType searchableType;
private final String indexName;
private final Analyzer analyzer;
LuceneQueryBuilder(IndexOpener opener, String indexName, LuceneSearchableType searchableType, Analyzer analyzer) {
LuceneQueryBuilder(IndexManager opener, String indexName, LuceneSearchableType searchableType, Analyzer analyzer) {
this.opener = opener;
this.indexName = indexName;
this.searchableType = searchableType;
@@ -88,11 +88,11 @@ public class LuceneQueryBuilder<T> extends QueryBuilder<T> {
String queryString = Strings.nullToEmpty(queryParams.getQueryString());
Query parsedQuery = createQuery(searchableType, queryParams, queryString);
Query query = Queries.filter(parsedQuery, searchableType, queryParams);
Query query = Queries.filter(parsedQuery, queryParams);
if (LOG.isDebugEnabled()) {
LOG.debug("execute lucene query: {}", query);
}
try (IndexReader reader = opener.openForRead(indexName)) {
try (IndexReader reader = opener.openForRead(searchableType, indexName)) {
IndexSearcher searcher = new IndexSearcher(reader);
searcher.search(query, new PermissionAwareCollector(reader, collector));

View File

@@ -28,18 +28,18 @@ import javax.inject.Inject;
public class LuceneQueryBuilderFactory {
private final IndexOpener indexOpener;
private final IndexManager indexManager;
private final AnalyzerFactory analyzerFactory;
@Inject
public LuceneQueryBuilderFactory(IndexOpener indexOpener, AnalyzerFactory analyzerFactory) {
this.indexOpener = indexOpener;
public LuceneQueryBuilderFactory(IndexManager indexManager, AnalyzerFactory analyzerFactory) {
this.indexManager = indexManager;
this.analyzerFactory = analyzerFactory;
}
public <T> LuceneQueryBuilder<T> create(IndexParams indexParams) {
return new LuceneQueryBuilder<>(
indexOpener,
indexManager,
indexParams.getIndex(),
indexParams.getSearchableType(),
analyzerFactory.create(indexParams.getSearchableType(), indexParams.getOptions())

View File

@@ -24,24 +24,34 @@
package sonia.scm.search;
import com.google.common.base.Joiner;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.Subject;
import sonia.scm.work.CentralWorkQueue;
import sonia.scm.work.CentralWorkQueue.Enqueue;
import sonia.scm.work.Task;
import javax.inject.Inject;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Collectors;
public class LuceneSearchEngine implements SearchEngine {
private final IndexManager indexManager;
private final SearchableTypeResolver resolver;
private final IndexQueue indexQueue;
private final LuceneQueryBuilderFactory queryBuilderFactory;
private final CentralWorkQueue centralWorkQueue;
@Inject
public LuceneSearchEngine(SearchableTypeResolver resolver, IndexQueue indexQueue, LuceneQueryBuilderFactory queryBuilderFactory) {
public LuceneSearchEngine(IndexManager indexManager, SearchableTypeResolver resolver, LuceneQueryBuilderFactory queryBuilderFactory, CentralWorkQueue centralWorkQueue) {
this.indexManager = indexManager;
this.resolver = resolver;
this.indexQueue = indexQueue;
this.queryBuilderFactory = queryBuilderFactory;
this.centralWorkQueue = centralWorkQueue;
}
@Override
@@ -67,11 +77,79 @@ public class LuceneSearchEngine implements SearchEngine {
return new LuceneForType<>(searchableType);
}
private void enqueue(LuceneSearchableType searchableType, String index, List<String> resources, Task task) {
Enqueue enqueuer = centralWorkQueue.append();
String resourceName = Joiner.on('-').join(searchableType.getName(), index, "index");
if (resources.isEmpty()) {
enqueuer.locks(resourceName);
} else {
for (String resource : resources) {
enqueuer.locks(resourceName, resource);
}
}
enqueuer.runAsAdmin().enqueue(task);
}
@Override
public ForIndices forIndices() {
return new LuceneForIndices();
}
class LuceneForIndices implements ForIndices {
private final List<String> resources = new ArrayList<>();
private Predicate<IndexDetails> predicate = details -> true;
private IndexOptions options = IndexOptions.defaults();
@Override
public ForIndices matching(Predicate<IndexDetails> predicate) {
this.predicate = predicate;
return this;
}
@Override
public ForIndices withOptions(IndexOptions options) {
this.options = options;
return this;
}
@Override
public ForIndices forResource(String resource) {
this.resources.add(resource);
return this;
}
@Override
public void batch(SerializableIndexTask<?> task) {
exec(params -> batch(params, new LuceneSimpleIndexTask(params, task)));
}
@Override
public void batch(Class<? extends IndexTask<?>> task) {
exec(params -> batch(params, new LuceneInjectingIndexTask(params, task)));
}
private void exec(Consumer<IndexParams> consumer) {
indexManager.all()
.stream()
.filter(predicate)
.map(details -> new IndexParams(details.getName(), resolver.resolve(details.getType()), options))
.forEach(consumer);
}
private void batch(IndexParams params, Task task) {
LuceneSearchEngine.this.enqueue(params.getSearchableType(), params.getIndex(), resources, task);
}
}
class LuceneForType<T> implements ForType<T> {
private final LuceneSearchableType searchableType;
private IndexOptions options = IndexOptions.defaults();
private String index = "default";
private final List<String> resources = new ArrayList<>();
private LuceneForType(LuceneSearchableType searchableType) {
this.searchableType = searchableType;
@@ -94,8 +172,23 @@ public class LuceneSearchEngine implements SearchEngine {
}
@Override
public Index<T> getOrCreate() {
return indexQueue.getQueuedIndex(params());
public ForType<T> forResource(String resource) {
resources.add(resource);
return this;
}
@Override
public void update(Class<? extends IndexTask<T>> task) {
enqueue(new LuceneInjectingIndexTask(params(), task));
}
@Override
public void update(SerializableIndexTask<T> task) {
enqueue(new LuceneSimpleIndexTask(params(), task));
}
private void enqueue(Task task) {
LuceneSearchEngine.this.enqueue(searchableType, index, resources, task);
}
@Override

View File

@@ -24,53 +24,21 @@
package sonia.scm.search;
import com.google.common.annotations.VisibleForTesting;
import com.google.inject.Injector;
import sonia.scm.work.Task;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.io.Closeable;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;
public final class LuceneSimpleIndexTask extends LuceneIndexTask implements Task {
@Singleton
public class IndexQueue implements Closeable {
private final SerializableIndexTask<?> task;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final AtomicLong size = new AtomicLong(0);
private final LuceneIndexFactory indexFactory;
@Inject
public IndexQueue(LuceneIndexFactory indexFactory) {
this.indexFactory = indexFactory;
}
public <T> Index<T> getQueuedIndex(IndexParams indexParams) {
return new QueuedIndex<>(this, indexParams);
}
public LuceneIndexFactory getIndexFactory() {
return indexFactory;
}
<T> void enqueue(IndexQueueTaskWrapper<T> task) {
size.incrementAndGet();
executor.execute(() -> {
task.run();
size.decrementAndGet();
});
}
@VisibleForTesting
long getSize() {
return size.get();
LuceneSimpleIndexTask(IndexParams params, SerializableIndexTask<?> task) {
super(params);
this.task = task;
}
@Override
public void close() throws IOException {
executor.shutdown();
public IndexTask<?> task(Injector injector) {
injector.injectMembers(task);
return task;
}
}

View File

@@ -29,6 +29,8 @@ import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import java.util.Optional;
import static org.apache.lucene.search.BooleanClause.Occur.MUST;
final class Queries {
@@ -36,19 +38,18 @@ final class Queries {
private Queries() {
}
private static Query typeQuery(LuceneSearchableType type) {
return new TermQuery(new Term(FieldNames.TYPE, type.getName()));
}
private static Query repositoryQuery(String repositoryId) {
return new TermQuery(new Term(FieldNames.REPOSITORY, repositoryId));
}
static Query filter(Query query, LuceneSearchableType searchableType, QueryBuilder.QueryParams params) {
BooleanQuery.Builder builder = new BooleanQuery.Builder()
static Query filter(Query query, QueryBuilder.QueryParams params) {
Optional<String> repositoryId = params.getRepositoryId();
if (repositoryId.isPresent()) {
return new BooleanQuery.Builder()
.add(query, MUST)
.add(typeQuery(searchableType), MUST);
params.getRepositoryId().ifPresent(repo -> builder.add(repositoryQuery(repo), MUST));
return builder.build();
.add(repositoryQuery(repositoryId.get()), MUST)
.build();
}
return query;
}
}

View File

@@ -1,102 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import java.util.ArrayList;
import java.util.List;
class QueuedIndex<T> implements Index<T> {
private final IndexQueue queue;
private final IndexParams indexParams;
private final List<IndexQueueTask<T>> tasks = new ArrayList<>();
QueuedIndex(IndexQueue queue, IndexParams indexParams) {
this.queue = queue;
this.indexParams = indexParams;
}
@Override
public void store(Id id, String permission, T object) {
tasks.add(index -> index.store(id, permission, object));
}
@Override
public Deleter delete() {
return new QueueDeleter();
}
@Override
public void close() {
IndexQueueTaskWrapper<T> wrappedTask = new IndexQueueTaskWrapper<>(
queue.getIndexFactory(), indexParams, tasks
);
queue.enqueue(wrappedTask);
}
private class QueueDeleter implements Deleter {
@Override
public ByTypeDeleter byType() {
return new QueueByTypeDeleter();
}
@Override
public AllTypesDeleter allTypes() {
return new QueueAllTypesDeleter();
}
}
private class QueueByTypeDeleter implements ByTypeDeleter {
@Override
public void byId(Id id) {
tasks.add(index -> index.delete().byType().byId(id));
}
@Override
public void all() {
tasks.add(index -> index.delete().byType().all());
}
@Override
public void byRepository(String repositoryId) {
tasks.add(index -> index.delete().byType().byRepository(repositoryId));
}
}
private class QueueAllTypesDeleter implements AllTypesDeleter {
@Override
public void byRepository(String repositoryId) {
tasks.add(index -> index.delete().allTypes().byRepository(repositoryId));
}
@Override
public void byTypeName(String typeName) {
tasks.add(index -> index.delete().allTypes().byTypeName(typeName));
}
}
}

View File

@@ -0,0 +1,90 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import com.google.common.annotations.VisibleForTesting;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.Term;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.function.Supplier;
class SharableIndexWriter {
private static final Logger LOG = LoggerFactory.getLogger(SharableIndexWriter.class);
private int usageCounter = 0;
private final Supplier<IndexWriter> writerFactory;
private IndexWriter writer;
SharableIndexWriter(Supplier<IndexWriter> writerFactory) {
this.writerFactory = writerFactory;
}
synchronized void open() {
usageCounter++;
if (usageCounter == 1) {
LOG.trace("open writer, because usage increased from zero to one");
writer = writerFactory.get();
} else {
LOG.trace("new task is using the writer, counter is now at {}", usageCounter);
}
}
@VisibleForTesting
int getUsageCounter() {
return usageCounter;
}
void updateDocument(Term term, Document document) throws IOException {
writer.updateDocument(term, document);
}
long deleteDocuments(Term term) throws IOException {
return writer.deleteDocuments(term);
}
long deleteAll() throws IOException {
return writer.deleteAll();
}
synchronized void close() throws IOException {
usageCounter--;
if (usageCounter == 0) {
LOG.trace("no one seems to use index any longer, closing underlying writer");
writer.close();
writer = null;
} else if (usageCounter > 0) {
LOG.trace("index is still used by {} task(s), commit work but keep writer open", usageCounter);
writer.commit();
} else {
LOG.warn("index is already closed");
}
}
}

View File

@@ -0,0 +1,128 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.security;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.subject.support.SubjectThreadState;
import org.apache.shiro.util.ThreadContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.inject.Inject;
/**
* Impersonator allows the usage of scm-manager api in the context of another user.
*
* @since 2.23.0
*/
public final class Impersonator {
private static final Logger LOG = LoggerFactory.getLogger(Impersonator.class);
private final SecurityManager securityManager;
@Inject
public Impersonator(SecurityManager securityManager) {
this.securityManager = securityManager;
}
public Session impersonate(PrincipalCollection principal) {
Subject subject = createSubject(principal);
if (ThreadContext.getSecurityManager() != null) {
return new WebImpersonator(subject);
}
return new NonWebImpersonator(securityManager, subject);
}
private Subject createSubject(PrincipalCollection principal) {
return new Subject.Builder(securityManager)
.authenticated(true)
.principals(principal)
.buildSubject();
}
public interface Session extends AutoCloseable {
void close();
}
private static class WebImpersonator implements Session {
private final Subject subject;
private final Subject previousSubject;
private WebImpersonator(Subject subject) {
this.subject = subject;
this.previousSubject = SecurityUtils.getSubject();
bind();
}
private void bind() {
LOG.debug("user {} start impersonate session as {}", previousSubject.getPrincipal(), subject.getPrincipal());
// do not use runas, because we want only bind the session to this thread.
// Runas could affect other threads.
ThreadContext.bind(this.subject);
}
@Override
public void close() {
LOG.debug("release impersonate session from user {} to {}", previousSubject.getPrincipal(), subject.getPrincipal());
ThreadContext.bind(previousSubject);
}
}
private static class NonWebImpersonator implements Session {
private final SecurityManager securityManager;
private final SubjectThreadState state;
private final Subject subject;
private NonWebImpersonator(SecurityManager securityManager, Subject subject) {
this.securityManager = securityManager;
this.state = new SubjectThreadState(subject);
this.subject = subject;
bind();
}
private void bind() {
LOG.debug("start impersonate session as user {}", subject.getPrincipal());
SecurityUtils.setSecurityManager(securityManager);
state.bind();
}
@Override
public void close() {
LOG.debug("release impersonate session of {}", subject.getPrincipal());
state.restore();
SecurityUtils.setSecurityManager(null);
}
}
}

View File

@@ -22,44 +22,59 @@
* SOFTWARE.
*/
package sonia.scm.search;
package sonia.scm.update.index;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import sonia.scm.SCMContextProvider;
import sonia.scm.migration.UpdateStep;
import sonia.scm.plugin.Extension;
import sonia.scm.util.IOUtil;
import sonia.scm.version.Version;
import javax.annotation.Nonnull;
import javax.inject.Inject;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class IndexOpener {
import static sonia.scm.store.StoreConstants.DATA_DIRECTORY_NAME;
import static sonia.scm.store.StoreConstants.VARIABLE_DATA_DIRECTORY_NAME;
private final Path directory;
private final AnalyzerFactory analyzerFactory;
@Extension
public class RemoveCombinedIndex implements UpdateStep {
private final SCMContextProvider contextProvider;
@Inject
public IndexOpener(SCMContextProvider context, AnalyzerFactory analyzerFactory) {
directory = context.resolve(Paths.get("index"));
this.analyzerFactory = analyzerFactory;
public RemoveCombinedIndex(SCMContextProvider contextProvider) {
this.contextProvider = contextProvider;
}
public IndexReader openForRead(String name) throws IOException {
return DirectoryReader.open(directory(name));
@Override
public void doUpdate() throws IOException {
Path index = contextProvider.resolve(Paths.get("index"));
if (Files.exists(index)) {
IOUtil.delete(index.toFile());
}
public IndexWriter openForWrite(IndexParams indexParams) throws IOException {
IndexWriterConfig config = new IndexWriterConfig(analyzerFactory.create(indexParams.getSearchableType(), indexParams.getOptions()));
config.setOpenMode(IndexWriterConfig.OpenMode.CREATE_OR_APPEND);
return new IndexWriter(directory(indexParams.getIndex()), config);
Path indexLog = contextProvider.resolve(indexLogPath());
if (Files.exists(indexLog)) {
IOUtil.delete(indexLog.toFile());
}
}
private Directory directory(String name) throws IOException {
return FSDirectory.open(directory.resolve(name));
@Nonnull
private Path indexLogPath() {
return Paths.get(VARIABLE_DATA_DIRECTORY_NAME).resolve(DATA_DIRECTORY_NAME).resolve("index-log");
}
@Override
public Version getTargetVersion() {
return Version.parse("2.0.0");
}
@Override
public String getAffectedDataType() {
return "sonia.scm.index";
}
}

View File

@@ -30,8 +30,10 @@ import sonia.scm.plugin.Extension;
import sonia.scm.search.HandlerEventIndexSyncer;
import sonia.scm.search.Id;
import sonia.scm.search.Index;
import sonia.scm.search.IndexLogStore;
import sonia.scm.search.Indexer;
import sonia.scm.search.SearchEngine;
import sonia.scm.search.SerializableIndexTask;
import javax.inject.Inject;
import javax.inject.Singleton;
@@ -43,12 +45,10 @@ public class UserIndexer implements Indexer<User> {
@VisibleForTesting
static final int VERSION = 1;
private final UserManager userManager;
private final SearchEngine searchEngine;
@Inject
public UserIndexer(UserManager userManager, SearchEngine searchEngine) {
this.userManager = userManager;
public UserIndexer(SearchEngine searchEngine) {
this.searchEngine = searchEngine;
}
@@ -62,47 +62,46 @@ public class UserIndexer implements Indexer<User> {
return VERSION;
}
@Override
public Class<? extends ReIndexAllTask<User>> getReIndexAllTask() {
return ReIndexAll.class;
}
@Override
public SerializableIndexTask<User> createStoreTask(User user) {
return index -> store(index, user);
}
@Override
public SerializableIndexTask<User> createDeleteTask(User item) {
return index -> index.delete().byId(Id.of(item));
}
@Subscribe(async = false)
public void handleEvent(UserEvent event) {
new HandlerEventIndexSyncer<>(this).handleEvent(event);
new HandlerEventIndexSyncer<>(searchEngine, this).handleEvent(event);
}
@Override
public Updater<User> open() {
return new UserIndexUpdater(userManager, searchEngine.forType(User.class).getOrCreate());
}
public static class UserIndexUpdater implements Updater<User> {
private final UserManager userManager;
private final Index<User> index;
private UserIndexUpdater(UserManager userManager, Index<User> index) {
this.userManager = userManager;
this.index = index;
}
@Override
public void store(User user) {
private static void store(Index<User> index, User user) {
index.store(Id.of(user), UserPermissions.read(user).asShiroString(), user);
}
@Override
public void delete(User user) {
index.delete().byType().byId(Id.of(user));
public static class ReIndexAll extends ReIndexAllTask<User> {
private final UserManager userManager;
@Inject
public ReIndexAll(IndexLogStore logStore, UserManager userManager) {
super(logStore, User.class, VERSION);
this.userManager = userManager;
}
@Override
public void reIndexAll() {
index.delete().byType().all();
public void update(Index<User> index) {
index.delete().all();
for (User user : userManager.getAll()) {
store(user);
store(index, user);
}
}
@Override
public void close() {
index.close();
}
}
}

View File

@@ -24,7 +24,9 @@
package sonia.scm.web.security;
final class AdministrationContextMarker {
import java.io.Serializable;
final class AdministrationContextMarker implements Serializable {
static final AdministrationContextMarker MARKER = new AdministrationContextMarker();

View File

@@ -24,223 +24,70 @@
package sonia.scm.web.security;
//~--- non-JDK imports --------------------------------------------------------
import com.google.inject.Inject;
import com.google.inject.Injector;
import com.google.inject.Singleton;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.SimplePrincipalCollection;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.subject.support.SubjectThreadState;
import org.apache.shiro.util.ThreadContext;
import org.apache.shiro.util.ThreadState;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.SCMContext;
import sonia.scm.security.Authentications;
import sonia.scm.security.Role;
import sonia.scm.security.Impersonator;
import sonia.scm.user.User;
import sonia.scm.util.AssertUtil;
//~--- JDK imports ------------------------------------------------------------
/**
*
* @author Sebastian Sdorra
*/
@Singleton
public class DefaultAdministrationContext implements AdministrationContext
{
public class DefaultAdministrationContext implements AdministrationContext {
/** Field description */
private static final User SYSTEM_ACCOUNT = new User(
Authentications.PRINCIPAL_SYSTEM,
"SCM-Manager System Account",
null
);
/** Field description */
static final String REALM = "AdminRealm";
/** the logger for DefaultAdministrationContext */
private static final Logger logger =
LoggerFactory.getLogger(DefaultAdministrationContext.class);
private static final Logger LOG = LoggerFactory.getLogger(DefaultAdministrationContext.class);
//~--- constructors ---------------------------------------------------------
private final Injector injector;
private final Impersonator impersonator;
private final PrincipalCollection adminPrincipal;
/**
* Constructs ...
*
*
* @param injector
* @param securityManager
*/
@Inject
public DefaultAdministrationContext(Injector injector,
org.apache.shiro.mgt.SecurityManager securityManager)
{
public DefaultAdministrationContext(Injector injector, Impersonator impersonator) {
this.injector = injector;
this.securityManager = securityManager;
principalCollection = createAdminCollection(SYSTEM_ACCOUNT);
this.impersonator = impersonator;
this.adminPrincipal = createAdminPrincipal();
}
//~--- methods --------------------------------------------------------------
/**
* Method description
*
*
* @param action
*/
@Override
public void runAsAdmin(PrivilegedAction action)
{
AssertUtil.assertIsNotNull(action);
if (ThreadContext.getSecurityManager() != null)
{
doRunAsInWebSessionContext(action);
}
else
{
doRunAsInNonWebSessionContext(action);
}
}
/**
* Method description
*
*
* @param actionClass
*/
@Override
public void runAsAdmin(Class<? extends PrivilegedAction> actionClass)
{
PrivilegedAction action = injector.getInstance(actionClass);
runAsAdmin(action);
}
/**
* Method description
*
*
* @param adminUser
*
* @return
*/
private PrincipalCollection createAdminCollection(User adminUser)
{
public static PrincipalCollection createAdminPrincipal() {
SimplePrincipalCollection collection = new SimplePrincipalCollection();
collection.add(adminUser.getId(), REALM);
collection.add(adminUser, REALM);
collection.add(SYSTEM_ACCOUNT.getId(), REALM);
collection.add(SYSTEM_ACCOUNT, REALM);
collection.add(AdministrationContextMarker.MARKER, REALM);
return collection;
}
/**
* Method description
*
*
* @return
*/
private Subject createAdminSubject()
{
//J-
return new Subject.Builder(securityManager)
.authenticated(true)
.principals(principalCollection)
.buildSubject();
//J+
}
private void doRunAsInNonWebSessionContext(PrivilegedAction action) {
logger.trace("bind shiro security manager to current thread");
try {
SecurityUtils.setSecurityManager(securityManager);
Subject subject = createAdminSubject();
ThreadState state = new SubjectThreadState(subject);
state.bind();
try
{
logger.debug("execute action {} in administration context", action.getClass().getName());
action.run();
} finally {
logger.trace("restore current thread state");
state.restore();
}
} finally {
SecurityUtils.setSecurityManager(null);
}
}
/**
* Method description
*
*
* @param action
*/
private void doRunAsInWebSessionContext(PrivilegedAction action)
{
Subject subject = SecurityUtils.getSubject();
String principal = (String) subject.getPrincipal();
if (logger.isInfoEnabled())
{
String username;
if (subject.hasRole(Role.USER))
{
username = principal;
}
else
{
username = SCMContext.USER_ANONYMOUS;
}
logger.debug("user {} executes {} as admin", username, action.getClass().getName());
}
Subject adminSubject = createAdminSubject();
// do not use runas, because we want only execute this action in this
// thread as administrator. Runas could affect other threads
ThreadContext.bind(adminSubject);
try
{
@Override
public void runAsAdmin(PrivilegedAction action) {
AssertUtil.assertIsNotNull(action);
LOG.debug("execute action {} in administration context", action.getClass().getName());
try (Impersonator.Session session = impersonator.impersonate(adminPrincipal)) {
action.run();
}
finally
{
logger.debug("release administration context for user {}/{}", principal,
subject.getPrincipal());
ThreadContext.bind(subject);
}
}
//~--- fields ---------------------------------------------------------------
@Override
public void runAsAdmin(Class<? extends PrivilegedAction> actionClass) {
PrivilegedAction action = injector.getInstance(actionClass);
runAsAdmin(action);
}
/** Field description */
private final Injector injector;
/** Field description */
private final org.apache.shiro.mgt.SecurityManager securityManager;
/** Field description */
private PrincipalCollection principalCollection;
}

View File

@@ -0,0 +1,213 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.work;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import com.google.inject.Inject;
import com.google.inject.Injector;
import io.micrometer.core.instrument.MeterRegistry;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.PrincipalCollection;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.metrics.Metrics;
import sonia.scm.web.security.DefaultAdministrationContext;
import javax.annotation.Nullable;
import javax.inject.Singleton;
import java.io.Closeable;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.IntSupplier;
@Singleton
public class DefaultCentralWorkQueue implements CentralWorkQueue, Closeable {
private static final Logger LOG = LoggerFactory.getLogger(DefaultCentralWorkQueue.class);
private final List<UnitOfWork> queue = new ArrayList<>();
private final List<Resource> lockedResources = new ArrayList<>();
private final AtomicInteger size = new AtomicInteger();
private final AtomicLong order = new AtomicLong();
private final Injector injector;
private final Persistence persistence;
private final ExecutorService executor;
private final MeterRegistry meterRegistry;
@Inject
public DefaultCentralWorkQueue(Injector injector, Persistence persistence, MeterRegistry meterRegistry) {
this(injector, persistence, meterRegistry, new ThreadCountProvider());
}
@VisibleForTesting
DefaultCentralWorkQueue(Injector injector, Persistence persistence, MeterRegistry meterRegistry, IntSupplier threadCountProvider) {
this.injector = injector;
this.persistence = persistence;
this.executor = createExecutorService(meterRegistry, threadCountProvider.getAsInt());
this.meterRegistry = meterRegistry;
loadFromDisk();
}
private static ExecutorService createExecutorService(MeterRegistry registry, int threadCount) {
ExecutorService executorService = Executors.newFixedThreadPool(
threadCount,
new ThreadFactoryBuilder()
.setNameFormat("CentralWorkQueue-%d")
.build()
);
Metrics.executor(registry, executorService, "CentralWorkQueue", "fixed");
return executorService;
}
@Override
public Enqueue append() {
return new DefaultEnqueue();
}
@Override
public int getSize() {
return size.get();
}
@Override
public void close() {
executor.shutdown();
}
private void loadFromDisk() {
for (UnitOfWork unitOfWork : persistence.loadAll()) {
unitOfWork.restore(order.incrementAndGet());
append(unitOfWork);
}
run();
}
private synchronized void append(UnitOfWork unitOfWork) {
persistence.store(unitOfWork);
int queueSize = size.incrementAndGet();
queue.add(unitOfWork);
LOG.debug("add task {} to queue, queue size is now {}", unitOfWork, queueSize);
}
private synchronized void run() {
Iterator<UnitOfWork> iterator = queue.iterator();
while (iterator.hasNext()) {
UnitOfWork unitOfWork = iterator.next();
if (isRunnable(unitOfWork)) {
run(unitOfWork);
iterator.remove();
} else {
unitOfWork.blocked();
}
}
}
private void run(UnitOfWork unitOfWork) {
lockedResources.addAll(unitOfWork.getLocks());
unitOfWork.init(injector, this::finalizeWork, meterRegistry);
LOG.trace("pass task {} to executor", unitOfWork);
executor.execute(unitOfWork);
}
private synchronized void finalizeWork(UnitOfWork unitOfWork) {
for (Resource lock : unitOfWork.getLocks()) {
lockedResources.remove(lock);
}
persistence.remove(unitOfWork);
int queueSize = size.decrementAndGet();
LOG.debug("finish task, queue size is now {}", queueSize);
run();
}
private boolean isRunnable(UnitOfWork unitOfWork) {
for (Resource resource : unitOfWork.getLocks()) {
for (Resource lock : lockedResources) {
if (resource.isBlockedBy(lock)) {
LOG.trace("skip {}, because resource {} is locked by {}", unitOfWork, resource, lock);
return false;
}
}
}
return true;
}
private class DefaultEnqueue implements Enqueue {
private final Set<Resource> locks = new HashSet<>();
private boolean runAsAdmin = false;
@Override
public Enqueue locks(String resourceType) {
locks.add(new Resource(resourceType));
return this;
}
@Override
public Enqueue locks(String resource, @Nullable String id) {
locks.add(new Resource(resource, id));
return this;
}
@Override
public Enqueue runAsAdmin() {
this.runAsAdmin = true;
return this;
}
@Override
public void enqueue(Task task) {
appendAndRun(new SimpleUnitOfWork(order.incrementAndGet(), principal(), locks, task));
}
@Override
public void enqueue(Class<? extends Runnable> task) {
appendAndRun(new InjectingUnitOfWork(order.incrementAndGet(), principal(), locks, task));
}
private PrincipalCollection principal() {
if (runAsAdmin) {
return DefaultAdministrationContext.createAdminPrincipal();
}
return SecurityUtils.getSubject().getPrincipals();
}
private synchronized void appendAndRun(UnitOfWork unitOfWork) {
append(unitOfWork);
run();
}
}
}

View File

@@ -22,11 +22,11 @@
* SOFTWARE.
*/
package sonia.scm.search;
package sonia.scm.work;
@FunctionalInterface
public interface IndexQueueTask<T> {
interface Finalizer {
void updateIndex(Index<T> index);
void finalizeWork(UnitOfWork unitOfWork);
}

View File

@@ -0,0 +1,47 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.work;
import com.google.inject.Injector;
import lombok.EqualsAndHashCode;
import org.apache.shiro.subject.PrincipalCollection;
import java.util.Set;
@EqualsAndHashCode(callSuper = true)
class InjectingUnitOfWork extends UnitOfWork {
private final Class<? extends Runnable> task;
InjectingUnitOfWork(long order, PrincipalCollection principal, Set<Resource> locks, Class<? extends Runnable> task) {
super(order, principal, locks);
this.task = task;
}
@Override
protected Runnable task(Injector injector) {
return injector.getInstance(task);
}
}

View File

@@ -0,0 +1,108 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.work;
import com.google.common.annotations.VisibleForTesting;
import com.google.inject.Inject;
import org.apache.commons.io.input.ClassLoaderObjectInputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.plugin.PluginLoader;
import sonia.scm.store.Blob;
import sonia.scm.store.BlobStore;
import sonia.scm.store.BlobStoreFactory;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
class Persistence {
private static final Logger LOG = LoggerFactory.getLogger(Persistence.class);
private static final String STORE_NAME = "central-work-queue";
private final ClassLoader classLoader;
private final BlobStore store;
@Inject
public Persistence(PluginLoader pluginLoader, BlobStoreFactory storeFactory) {
this(pluginLoader.getUberClassLoader(), storeFactory.withName(STORE_NAME).build());
}
@VisibleForTesting
Persistence(ClassLoader classLoader, BlobStore store) {
this.classLoader = classLoader;
this.store = store;
}
Collection<UnitOfWork> loadAll() {
List<UnitOfWork> chunks = new ArrayList<>();
for (Blob blob : store.getAll()) {
load(blob).ifPresent(chunkOfWork -> {
chunkOfWork.assignStorageId(null);
chunks.add(chunkOfWork);
});
store.remove(blob);
}
Collections.sort(chunks);
return chunks;
}
private Optional<UnitOfWork> load(Blob blob) {
try (ObjectInputStream stream = new ClassLoaderObjectInputStream(classLoader, blob.getInputStream())) {
Object o = stream.readObject();
if (o instanceof UnitOfWork) {
return Optional.of((UnitOfWork) o);
} else {
LOG.error("loaded object is not a instance of {}: {}", UnitOfWork.class, o);
}
} catch (IOException | ClassNotFoundException ex) {
LOG.error("failed to load task from store", ex);
}
return Optional.empty();
}
void store(UnitOfWork unitOfWork) {
Blob blob = store.create();
try (ObjectOutputStream outputStream = new ObjectOutputStream(blob.getOutputStream())) {
outputStream.writeObject(unitOfWork);
blob.commit();
unitOfWork.assignStorageId(blob.getId());
} catch (IOException ex) {
throw new NonPersistableTaskException("Failed to persist task", ex);
}
}
void remove(UnitOfWork unitOfWork) {
unitOfWork.getStorageId().ifPresent(store::remove);
}
}

View File

@@ -0,0 +1,66 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.work;
import lombok.EqualsAndHashCode;
import javax.annotation.Nullable;
import java.io.Serializable;
@EqualsAndHashCode
final class Resource implements Serializable {
private final String name;
@Nullable
private final String id;
Resource(String name) {
this.name = name;
this.id = null;
}
Resource(String name, @Nullable String id) {
this.name = name;
this.id = id;
}
boolean isBlockedBy(Resource resource) {
if (name.equals(resource.name)) {
if (id != null && resource.id != null) {
return id.equals(resource.id);
}
return true;
}
return false;
}
@Override
public String toString() {
if (id != null) {
return name + ":" + id;
}
return name;
}
}

View File

@@ -0,0 +1,48 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.work;
import com.google.inject.Injector;
import lombok.EqualsAndHashCode;
import org.apache.shiro.subject.PrincipalCollection;
import java.util.Set;
@EqualsAndHashCode(callSuper = true)
class SimpleUnitOfWork extends UnitOfWork {
private final Task task;
SimpleUnitOfWork(long order, PrincipalCollection principal, Set<Resource> locks, Task task) {
super(order, principal, locks);
this.task = task;
}
@Override
protected Task task(Injector injector) {
injector.injectMembers(task);
return task;
}
}

View File

@@ -0,0 +1,79 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.work;
import com.google.common.annotations.VisibleForTesting;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.function.IntSupplier;
public class ThreadCountProvider implements IntSupplier {
private static final Logger LOG = LoggerFactory.getLogger(ThreadCountProvider.class);
@VisibleForTesting
static final String PROPERTY = "scm.central-work-queue.workers";
private final IntSupplier cpuCountProvider;
public ThreadCountProvider() {
this(() -> Runtime.getRuntime().availableProcessors());
}
@VisibleForTesting
ThreadCountProvider(IntSupplier cpuCountProvider) {
this.cpuCountProvider = cpuCountProvider;
}
@Override
public int getAsInt() {
Integer systemProperty = Integer.getInteger(PROPERTY);
if (systemProperty == null) {
LOG.debug("derive worker count from cpu count");
return deriveFromCPUCount();
}
if (isInvalid(systemProperty)) {
LOG.warn(
"system property {} contains a invalid value {}, fall back and derive worker count from cpu count",
PROPERTY, systemProperty
);
return deriveFromCPUCount();
}
return systemProperty;
}
private boolean isInvalid(int value) {
return value <= 0 || value > 64;
}
private int deriveFromCPUCount() {
int cpus = cpuCountProvider.getAsInt();
if (cpus > 1) {
return 4;
}
return 2;
}
}

View File

@@ -0,0 +1,149 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.work;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Stopwatch;
import com.google.inject.Injector;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import lombok.EqualsAndHashCode;
import org.apache.shiro.subject.PrincipalCollection;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.security.Impersonator;
import sonia.scm.security.Impersonator.Session;
import java.io.Serializable;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@EqualsAndHashCode
abstract class UnitOfWork implements Runnable, Serializable, Comparable<UnitOfWork> {
@VisibleForTesting
static final String METRIC_EXECUTION = "cwq.task.execution.duration";
@VisibleForTesting
static final String METRIC_WAIT = "cwq.task.wait.duration";
private static final Logger LOG = LoggerFactory.getLogger(UnitOfWork.class);
private long order;
private int blockCount = 0;
private int restoreCount = 0;
private final Set<Resource> locks;
private final PrincipalCollection principal;
private transient Finalizer finalizer;
private transient Runnable task;
private transient MeterRegistry meterRegistry;
private transient Impersonator impersonator;
private transient long createdAt;
private transient String storageId;
protected UnitOfWork(long order, PrincipalCollection principal, Set<Resource> locks) {
this.order = order;
this.principal = principal;
this.locks = locks;
this.createdAt = System.nanoTime();
}
public long getOrder() {
return order;
}
public void restore(long newOrderId) {
this.order = newOrderId;
this.createdAt = System.nanoTime();
this.restoreCount++;
}
public int getRestoreCount() {
return restoreCount;
}
public void blocked() {
blockCount++;
}
public void assignStorageId(String storageId) {
this.storageId = storageId;
}
public Optional<String> getStorageId() {
return Optional.ofNullable(storageId);
}
public Set<Resource> getLocks() {
return locks;
}
void init(Injector injector, Finalizer finalizer, MeterRegistry meterRegistry) {
this.task = task(injector);
this.finalizer = finalizer;
this.meterRegistry = meterRegistry;
this.impersonator = injector.getInstance(Impersonator.class);
}
protected abstract Runnable task(Injector injector);
@Override
public void run() {
Stopwatch sw = Stopwatch.createStarted();
Timer.Sample sample = Timer.start(meterRegistry);
try (Session session = impersonator.impersonate(principal)) {
task.run();
LOG.debug("task {} finished successful after {}", task, sw.stop());
} catch (Exception ex) {
LOG.error("task {} failed after {}", task, sw.stop(), ex);
} finally {
sample.stop(createExecutionTimer());
createWaitTimer().record(System.nanoTime() - createdAt, TimeUnit.NANOSECONDS);
finalizer.finalizeWork(this);
}
}
private Timer createExecutionTimer() {
return Timer.builder(METRIC_EXECUTION)
.description("Central work queue task execution duration")
.tags("task", task.getClass().getName())
.register(meterRegistry);
}
private Timer createWaitTimer() {
return Timer.builder(METRIC_WAIT)
.description("Central work queue task wait duration")
.tags("task", task.getClass().getName(), "restores", String.valueOf(restoreCount), "blocked", String.valueOf(blockCount))
.register(meterRegistry);
}
@Override
public int compareTo(UnitOfWork o) {
return Long.compare(order, o.order);
}
}

View File

@@ -24,20 +24,23 @@
package sonia.scm.group;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Answers;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.HandlerEventType;
import sonia.scm.search.Id;
import sonia.scm.search.Index;
import sonia.scm.search.IndexLogStore;
import sonia.scm.search.SearchEngine;
import sonia.scm.search.SerializableIndexTask;
import java.util.Arrays;
import static java.util.Collections.singletonList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -54,6 +57,19 @@ class GroupIndexerTest {
@InjectMocks
private GroupIndexer indexer;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private Index<Group> index;
@Mock
private IndexLogStore indexLogStore;
@Captor
private ArgumentCaptor<SerializableIndexTask<Group>> captor;
private final Group astronauts = new Group("xml", "astronauts");
private final Group planetCreators = new Group("xml", "planet-creators");
@Test
void shouldReturnClass() {
assertThat(indexer.getType()).isEqualTo(Group.class);
@@ -64,58 +80,47 @@ class GroupIndexerTest {
assertThat(indexer.getVersion()).isEqualTo(GroupIndexer.VERSION);
}
@Nested
class UpdaterTests {
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private Index<Group> index;
private final Group group = new Group("xml", "astronauts");
@BeforeEach
void open() {
when(searchEngine.forType(Group.class).getOrCreate()).thenReturn(index);
@Test
void shouldReturnReIndexAllClass() {
assertThat(indexer.getReIndexAllTask()).isEqualTo(GroupIndexer.ReIndexAll.class);
}
@Test
void shouldStore() {
indexer.open().store(group);
void shouldCreateGroup() {
indexer.createStoreTask(astronauts).update(index);
verify(index).store(Id.of(group), "group:read:astronauts", group);
verify(index).store(Id.of(astronauts), GroupPermissions.read(astronauts).asShiroString(), astronauts);
}
@Test
void shouldDeleteById() {
indexer.open().delete(group);
void shouldDeleteGroup() {
indexer.createDeleteTask(astronauts).update(index);
verify(index.delete().byType()).byId(Id.of(group));
verify(index.delete()).byId(Id.of(astronauts));
}
@Test
void shouldReIndexAll() {
when(groupManager.getAll()).thenReturn(singletonList(group));
when(groupManager.getAll()).thenReturn(Arrays.asList(astronauts, planetCreators));
indexer.open().reIndexAll();
GroupIndexer.ReIndexAll reIndexAll = new GroupIndexer.ReIndexAll(indexLogStore, groupManager);
reIndexAll.update(index);
verify(index.delete().byType()).all();
verify(index).store(Id.of(group), "group:read:astronauts", group);
verify(index.delete()).all();
verify(index).store(Id.of(astronauts), GroupPermissions.read(astronauts).asShiroString(), astronauts);
verify(index).store(Id.of(planetCreators), GroupPermissions.read(planetCreators).asShiroString(), planetCreators);
}
@Test
void shouldHandleEvent() {
GroupEvent event = new GroupEvent(HandlerEventType.DELETE, group);
void shouldHandleEvents() {
GroupEvent event = new GroupEvent(HandlerEventType.DELETE, astronauts);
indexer.handleEvent(event);
verify(index.delete().byType()).byId(Id.of(group));
}
@Test
void shouldCloseIndex() {
indexer.open().close();
verify(index).close();
}
verify(searchEngine.forType(Group.class)).update(captor.capture());
captor.getValue().update(index);
verify(index.delete()).byId(Id.of(astronauts));
}
}

View File

@@ -24,20 +24,25 @@
package sonia.scm.repository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.mockito.Answers;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.HandlerEventType;
import sonia.scm.search.Id;
import sonia.scm.search.Index;
import sonia.scm.search.IndexLogStore;
import sonia.scm.search.SearchEngine;
import sonia.scm.search.SerializableIndexTask;
import java.util.Arrays;
import static java.util.Collections.singletonList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -54,6 +59,15 @@ class RepositoryIndexerTest {
@InjectMocks
private RepositoryIndexer indexer;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private Index<Repository> index;
@Mock
private IndexLogStore indexLogStore;
@Captor
private ArgumentCaptor<SerializableIndexTask<Repository>> captor;
@Test
void shouldReturnRepositoryClass() {
assertThat(indexer.getType()).isEqualTo(Repository.class);
@@ -64,61 +78,65 @@ class RepositoryIndexerTest {
assertThat(indexer.getVersion()).isEqualTo(RepositoryIndexer.VERSION);
}
@Nested
class UpdaterTests {
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private Index<Repository> index;
private Repository repository;
@BeforeEach
void open() {
when(searchEngine.forType(Repository.class).getOrCreate()).thenReturn(index);
repository = new Repository();
repository.setId("42");
@Test
void shouldReturnReIndexAllClass() {
assertThat(indexer.getReIndexAllTask()).isEqualTo(RepositoryIndexer.ReIndexAll.class);
}
@Test
void shouldStoreRepository() {
indexer.open().store(repository);
void shouldCreateRepository() {
Repository heartOfGold = RepositoryTestData.createHeartOfGold();
verify(index).store(Id.of(repository), "repository:read:42", repository);
indexer.createStoreTask(heartOfGold).update(index);
verify(index).store(Id.of(heartOfGold), RepositoryPermissions.read(heartOfGold).asShiroString(), heartOfGold);
}
@Test
void shouldDeleteByRepository() {
indexer.open().delete(repository);
void shouldDeleteRepository() {
Repository heartOfGold = RepositoryTestData.createHeartOfGold();
verify(index.delete().allTypes()).byRepository("42");
indexer.createDeleteTask(heartOfGold).update(index);
verify(index.delete()).byRepository(heartOfGold);
}
@Test
void shouldReIndexAll() {
when(repositoryManager.getAll()).thenReturn(singletonList(repository));
Repository heartOfGold = RepositoryTestData.createHeartOfGold();
Repository puzzle = RepositoryTestData.create42Puzzle();
when(repositoryManager.getAll()).thenReturn(Arrays.asList(heartOfGold, puzzle));
indexer.open().reIndexAll();
RepositoryIndexer.ReIndexAll reIndexAll = new RepositoryIndexer.ReIndexAll(indexLogStore, repositoryManager);
reIndexAll.update(index);
verify(index.delete().allTypes()).byTypeName(Repository.class.getName());
verify(index.delete().byType()).all();
verify(index).store(Id.of(repository), "repository:read:42", repository);
verify(index.delete()).all();
verify(index).store(Id.of(heartOfGold), RepositoryPermissions.read(heartOfGold).asShiroString(), heartOfGold);
verify(index).store(Id.of(puzzle), RepositoryPermissions.read(puzzle).asShiroString(), puzzle);
}
@Test
void shouldHandleEvent() {
RepositoryEvent event = new RepositoryEvent(HandlerEventType.DELETE, repository);
void shouldHandleDeleteEvents() {
Repository heartOfGold = RepositoryTestData.createHeartOfGold();
RepositoryEvent event = new RepositoryEvent(HandlerEventType.DELETE, heartOfGold);
indexer.handleEvent(event);
verify(index.delete().allTypes()).byRepository("42");
verify(searchEngine.forIndices().forResource(heartOfGold)).batch(captor.capture());
captor.getValue().update(index);
verify(index.delete()).byRepository(heartOfGold);
}
@Test
void shouldCloseIndex() {
indexer.open().close();
void shouldHandleUpdateEvents() {
Repository heartOfGold = RepositoryTestData.createHeartOfGold();
RepositoryEvent event = new RepositoryEvent(HandlerEventType.CREATE, heartOfGold);
verify(index).close();
}
indexer.handleEvent(event);
verify(searchEngine.forType(Repository.class)).update(captor.capture());
captor.getValue().update(index);
verify(index).store(Id.of(heartOfGold), RepositoryPermissions.read(heartOfGold).asShiroString(), heartOfGold);
}
}

View File

@@ -24,10 +24,12 @@
package sonia.scm.search;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.mockito.Answers;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.HandlerEventType;
@@ -35,6 +37,7 @@ import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryEvent;
import sonia.scm.repository.RepositoryTestData;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -42,18 +45,23 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class HandlerEventIndexSyncerTest {
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private SearchEngine searchEngine;
@Mock
private Indexer<Repository> indexer;
@Mock
private Indexer.Updater<Repository> updater;
@BeforeEach
void setUpIndexer() {
lenient().when(indexer.getType()).thenReturn(Repository.class);
}
@ParameterizedTest
@EnumSource(value = HandlerEventType.class, mode = EnumSource.Mode.MATCH_ANY, names = "BEFORE_.*")
void shouldIgnoreBeforeEvents(HandlerEventType type) {
RepositoryEvent event = new RepositoryEvent(type, RepositoryTestData.create42Puzzle());
new HandlerEventIndexSyncer<>(indexer).handleEvent(event);
new HandlerEventIndexSyncer<>(searchEngine, indexer).handleEvent(event);
verifyNoInteractions(indexer);
}
@@ -61,28 +69,29 @@ class HandlerEventIndexSyncerTest {
@ParameterizedTest
@EnumSource(value = HandlerEventType.class, mode = EnumSource.Mode.INCLUDE, names = {"CREATE", "MODIFY"})
void shouldStore(HandlerEventType type) {
when(indexer.open()).thenReturn(updater);
SerializableIndexTask<Repository> store = index -> {};
Repository puzzle = RepositoryTestData.create42Puzzle();
when(indexer.createStoreTask(puzzle)).thenReturn(store);
RepositoryEvent event = new RepositoryEvent(type, puzzle);
new HandlerEventIndexSyncer<>(indexer).handleEvent(event);
new HandlerEventIndexSyncer<>(searchEngine, indexer).handleEvent(event);
verify(updater).store(puzzle);
verify(updater).close();
verify(searchEngine.forType(Repository.class)).update(store);
}
@Test
void shouldDelete() {
when(indexer.open()).thenReturn(updater);
SerializableIndexTask<Repository> delete = index -> {};
Repository puzzle = RepositoryTestData.create42Puzzle();
when(indexer.createDeleteTask(puzzle)).thenReturn(delete);
RepositoryEvent event = new RepositoryEvent(HandlerEventType.DELETE, puzzle);
new HandlerEventIndexSyncer<>(searchEngine, indexer).handleEvent(event);
new HandlerEventIndexSyncer<>(indexer).handleEvent(event);
verify(updater).delete(puzzle);
verify(updater).close();
verify(searchEngine.forType(Repository.class)).update(delete);
}
}

View File

@@ -32,8 +32,6 @@ import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.group.Group;
import sonia.scm.repository.Repository;
import sonia.scm.user.User;
import sonia.scm.web.security.AdministrationContext;
import sonia.scm.web.security.PrivilegedAction;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
@@ -42,47 +40,39 @@ import java.util.HashSet;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class IndexBootstrapListenerTest {
@Mock
private AdministrationContext administrationContext;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private SearchEngine searchEngine;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private IndexLogStore indexLogStore;
@Test
void shouldReIndexWithoutLog() {
mockAdminContext();
Indexer<Repository> indexer = indexer(Repository.class, 1);
Indexer.Updater<Repository> updater = updater(indexer);
mockEmptyIndexLog(Repository.class);
doInitialization(indexer);
verify(updater).reIndexAll();
verify(updater).close();
verify(indexLogStore.defaultIndex()).log(Repository.class, 1);
verify(searchEngine.forType(Repository.class)).update(RepositoryReIndexAllTask.class);
}
@Test
void shouldReIndexIfVersionWasUpdated() {
mockAdminContext();
Indexer<User> indexer = indexer(User.class, 2);
Indexer.Updater<User> updater = updater(indexer);
mockIndexLog(User.class, 1);
doInitialization(indexer);
verify(updater).reIndexAll();
verify(updater).close();
verify(indexLogStore.defaultIndex()).log(User.class, 2);
verify(searchEngine.forType(User.class)).update(UserReIndexAllTask.class);
}
@Test
@@ -92,7 +82,7 @@ class IndexBootstrapListenerTest {
mockIndexLog(Group.class, 3);
doInitialization(indexer);
verify(indexer, never()).open();
verifyNoInteractions(searchEngine);
}
private <T> void mockIndexLog(Class<T> type, int version) {
@@ -107,13 +97,6 @@ class IndexBootstrapListenerTest {
when(indexLogStore.defaultIndex().get(type)).thenReturn(Optional.ofNullable(indexLog));
}
private void mockAdminContext() {
doAnswer(ic -> {
PrivilegedAction action = ic.getArgument(0);
action.run();
return null;
}).when(administrationContext).runAsAdmin(any(PrivilegedAction.class));
}
@SuppressWarnings("rawtypes")
private void doInitialization(Indexer... indexers) {
@@ -125,7 +108,7 @@ class IndexBootstrapListenerTest {
@SuppressWarnings("rawtypes")
private IndexBootstrapListener listener(Indexer... indexers) {
return new IndexBootstrapListener(
administrationContext, indexLogStore, new HashSet<>(Arrays.asList(indexers))
searchEngine, indexLogStore, new HashSet<>(Arrays.asList(indexers))
);
}
@@ -133,15 +116,38 @@ class IndexBootstrapListenerTest {
private <T> Indexer<T> indexer(Class<T> type, int version) {
Indexer<T> indexer = mock(Indexer.class);
when(indexer.getType()).thenReturn(type);
when(indexer.getVersion()).thenReturn(version);
lenient().when(indexer.getVersion()).thenReturn(version);
lenient().when(indexer.getReIndexAllTask()).thenAnswer(ic -> {
if (type == User.class) {
return UserReIndexAllTask.class;
}
return RepositoryReIndexAllTask.class;
});
return indexer;
}
@SuppressWarnings("unchecked")
private <T> Indexer.Updater<T> updater(Indexer<T> indexer) {
Indexer.Updater<T> updater = mock(Indexer.Updater.class);
when(indexer.open()).thenReturn(updater);
return updater;
public static class RepositoryReIndexAllTask extends Indexer.ReIndexAllTask<Repository> {
public RepositoryReIndexAllTask(IndexLogStore logStore, Class<Repository> type, int version) {
super(logStore, type, version);
}
@Override
public void update(Index<Repository> index) {
}
}
public static class UserReIndexAllTask extends Indexer.ReIndexAllTask<User> {
public UserReIndexAllTask(IndexLogStore logStore, Class<User> type, int version) {
super(logStore, type, version);
}
@Override
public void update(Index<User> index) {
}
}
}

View File

@@ -0,0 +1,188 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import org.apache.lucene.analysis.core.SimpleAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.assertj.core.api.AssertionsForClassTypes;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.SCMContextProvider;
import sonia.scm.plugin.PluginLoader;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Locale;
import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class IndexManagerTest {
private Path directory;
@Mock
private AnalyzerFactory analyzerFactory;
@Mock
private LuceneSearchableType searchableType;
@Mock
private SCMContextProvider context;
@Mock
private PluginLoader pluginLoader;
private IndexManager indexManager;
@BeforeEach
void createIndexWriterFactory(@TempDir Path tempDirectory) {
this.directory = tempDirectory;
when(context.resolve(Paths.get("index"))).thenReturn(tempDirectory.resolve("index"));
when(analyzerFactory.create(any(LuceneSearchableType.class), any(IndexOptions.class))).thenReturn(new SimpleAnalyzer());
when(pluginLoader.getUberClassLoader()).thenReturn(IndexManagerTest.class.getClassLoader());
indexManager = new IndexManager(context, pluginLoader, analyzerFactory);
}
@Test
void shouldCreateNewIndex() throws IOException {
try (IndexWriter writer = open(Songs.class, "new-index")) {
addDoc(writer, "Trillian");
}
assertThat(directory.resolve("index").resolve("songs").resolve("new-index")).exists();
}
@Test
void shouldCreateNewIndexForEachType() throws IOException {
try (IndexWriter writer = open(Songs.class, "new-index")) {
addDoc(writer, "Trillian");
}
try (IndexWriter writer = open(Lyrics.class, "new-index")) {
addDoc(writer, "Trillian");
}
assertThat(directory.resolve("index").resolve("songs").resolve("new-index")).exists();
assertThat(directory.resolve("index").resolve("lyrics").resolve("new-index")).exists();
}
@Test
void shouldReturnAllCreatedIndices() throws IOException {
try (IndexWriter writer = open(Songs.class, "special")) {
addDoc(writer, "Trillian");
}
try (IndexWriter writer = open(Lyrics.class, "awesome")) {
addDoc(writer, "Trillian");
}
assertThat(indexManager.all())
.anySatisfy(details -> {
assertThat(details.getType()).isEqualTo(Songs.class);
assertThat(details.getName()).isEqualTo("special");
})
.anySatisfy(details -> {
assertThat(details.getType()).isEqualTo(Lyrics.class);
assertThat(details.getName()).isEqualTo("awesome");
});
}
@Test
void shouldRestoreIndicesOnCreation() throws IOException {
try (IndexWriter writer = open(Songs.class, "special")) {
addDoc(writer, "Trillian");
}
try (IndexWriter writer = open(Lyrics.class, "awesome")) {
addDoc(writer, "Trillian");
}
assertThat(new IndexManager(context, pluginLoader, analyzerFactory).all())
.anySatisfy(details -> {
AssertionsForClassTypes.assertThat(details.getType()).isEqualTo(Songs.class);
AssertionsForClassTypes.assertThat(details.getName()).isEqualTo("special");
})
.anySatisfy(details -> {
AssertionsForClassTypes.assertThat(details.getType()).isEqualTo(Lyrics.class);
AssertionsForClassTypes.assertThat(details.getName()).isEqualTo("awesome");
});
}
@SuppressWarnings({"rawtypes", "unchecked"})
private IndexWriter open(Class type, String indexName) throws IOException {
lenient().when(searchableType.getType()).thenReturn(type);
when(searchableType.getName()).thenReturn(type.getSimpleName().toLowerCase(Locale.ENGLISH));
return indexManager.openForWrite(new IndexParams(indexName, searchableType, IndexOptions.defaults()));
}
@Test
void shouldOpenExistingIndex() throws IOException {
try (IndexWriter writer = open(Songs.class, "reused")) {
addDoc(writer, "Dent");
}
try (IndexWriter writer = open(Songs.class, "reused")) {
assertThat(writer.getFieldNames()).contains("hitchhiker");
}
}
@Test
void shouldUseAnalyzerFromFactory() throws IOException {
try (IndexWriter writer = open(Songs.class, "new-index")) {
assertThat(writer.getAnalyzer()).isInstanceOf(SimpleAnalyzer.class);
}
}
@Test
void shouldOpenIndexForRead() throws IOException {
try (IndexWriter writer = open(Songs.class, "idx-for-read")) {
addDoc(writer, "Dent");
}
try (IndexReader reader = indexManager.openForRead(searchableType, "idx-for-read")) {
assertThat(reader.numDocs()).isOne();
}
}
private void addDoc(IndexWriter writer, String name) throws IOException {
Document doc = new Document();
doc.add(new TextField("hitchhiker", name, Field.Store.YES));
writer.addDocument(doc);
}
public static class Songs {
}
public static class Lyrics {
}
}

View File

@@ -1,118 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import org.apache.lucene.analysis.core.SimpleAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.SCMContextProvider;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class IndexOpenerTest {
private Path directory;
@Mock
private AnalyzerFactory analyzerFactory;
@Mock
private LuceneSearchableType searchableType;
private IndexOpener indexOpener;
@BeforeEach
void createIndexWriterFactory(@TempDir Path tempDirectory) {
this.directory = tempDirectory;
SCMContextProvider context = mock(SCMContextProvider.class);
when(context.resolve(Paths.get("index"))).thenReturn(tempDirectory);
when(analyzerFactory.create(any(LuceneSearchableType.class), any(IndexOptions.class))).thenReturn(new SimpleAnalyzer());
indexOpener = new IndexOpener(context, analyzerFactory);
}
@Test
void shouldCreateNewIndex() throws IOException {
try (IndexWriter writer = open("new-index")) {
addDoc(writer, "Trillian");
}
assertThat(directory.resolve("new-index")).exists();
}
private IndexWriter open(String index) throws IOException {
return indexOpener.openForWrite(new IndexParams(index, searchableType, IndexOptions.defaults()));
}
@Test
void shouldOpenExistingIndex() throws IOException {
try (IndexWriter writer = open("reused")) {
addDoc(writer, "Dent");
}
try (IndexWriter writer = open("reused")) {
assertThat(writer.getFieldNames()).contains("hitchhiker");
}
}
@Test
void shouldUseAnalyzerFromFactory() throws IOException {
try (IndexWriter writer = open("new-index")) {
assertThat(writer.getAnalyzer()).isInstanceOf(SimpleAnalyzer.class);
}
}
@Test
void shouldOpenIndexForRead() throws IOException {
try (IndexWriter writer = open("idx-for-read")) {
addDoc(writer, "Dent");
}
try (IndexReader reader = indexOpener.openForRead("idx-for-read")) {
assertThat(reader.numDocs()).isOne();
}
}
private void addDoc(IndexWriter writer, String name) throws IOException {
Document doc = new Document();
doc.add(new TextField("hitchhiker", name, Field.Store.YES));
writer.addDocument(doc);
}
}

View File

@@ -1,153 +0,0 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import lombok.Value;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.in;
import static org.awaitility.Awaitility.await;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class IndexQueueTest {
private Directory directory;
private IndexQueue queue;
@BeforeEach
void createQueue() throws IOException {
directory = new ByteBuffersDirectory();
IndexOpener opener = mock(IndexOpener.class);
when(opener.openForWrite(any(IndexParams.class))).thenAnswer(ic -> {
IndexWriterConfig config = new IndexWriterConfig(new StandardAnalyzer());
config.setOpenMode(IndexWriterConfig.OpenMode.CREATE_OR_APPEND);
return new IndexWriter(directory, config);
});
LuceneIndexFactory indexFactory = new LuceneIndexFactory(opener);
queue = new IndexQueue(indexFactory);
}
@AfterEach
void closeQueue() throws IOException {
queue.close();
directory.close();
}
@Test
void shouldWriteToIndex() throws Exception {
try (Index<Account> index = getIndex(Account.class)) {
index.store(Id.of("tricia"), null, new Account("tricia", "Trillian", "McMillan"));
index.store(Id.of("dent"), null, new Account("dent", "Arthur", "Dent"));
}
assertDocCount(2);
}
private <T> Index<T> getIndex(Class<T> type) {
SearchableTypeResolver resolver = new SearchableTypeResolver(type);
LuceneSearchableType searchableType = resolver.resolve(type);
IndexParams indexParams = new IndexParams("default", searchableType, IndexOptions.defaults());
return queue.getQueuedIndex(indexParams);
}
@Test
void shouldWriteMultiThreaded() throws Exception {
ExecutorService executorService = Executors.newFixedThreadPool(4);
for (int i = 0; i < 20; i++) {
executorService.execute(new IndexNumberTask(i));
}
executorService.execute(() -> {
try (Index<IndexedNumber> index = getIndex(IndexedNumber.class)) {
index.delete().byType().byId(Id.of(String.valueOf(12)));
}
});
executorService.shutdown();
assertDocCount(19);
}
private void assertDocCount(int expectedCount) throws IOException {
// wait until all tasks are finished
await().until(() -> queue.getSize() == 0);
try (DirectoryReader reader = DirectoryReader.open(directory)) {
assertThat(reader.numDocs()).isEqualTo(expectedCount);
}
}
@Value
@IndexedType
public static class Account {
@Indexed
String username;
@Indexed
String firstName;
@Indexed
String lastName;
}
@Value
@IndexedType
public static class IndexedNumber {
@Indexed
int value;
}
public class IndexNumberTask implements Runnable {
private final int number;
public IndexNumberTask(int number) {
this.number = number;
}
@Override
public void run() {
try (Index<IndexedNumber> index = getIndex(IndexedNumber.class)) {
index.store(Id.of(String.valueOf(number)), null, new IndexedNumber(number));
}
}
}
}

View File

@@ -0,0 +1,87 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.Repository;
import sonia.scm.user.User;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(MockitoExtension.class)
class LuceneIndexFactoryTest {
@Mock
private IndexManager indexManager;
@InjectMocks
private LuceneIndexFactory indexFactory;
@Test
void shouldCallOpenOnCreation() {
LuceneIndex<Repository> index = indexFactory.create(params("default", Repository.class));
assertThat(index.getWriter().getUsageCounter()).isOne();
}
@Test
void shouldCallOpenOnReturn() {
indexFactory.create(params("default", Repository.class));
indexFactory.create(params("default", Repository.class));
LuceneIndex<Repository> index = indexFactory.create(params("default", Repository.class));
assertThat(index.getWriter().getUsageCounter()).isEqualTo(3);
}
@Test
@SuppressWarnings("AssertBetweenInconvertibleTypes")
void shouldReturnDifferentIndexForDifferentTypes() {
LuceneIndex<Repository> repository = indexFactory.create(params("default", Repository.class));
LuceneIndex<User> user = indexFactory.create(params("default", User.class));
assertThat(repository).isNotSameAs(user);
}
@Test
void shouldReturnDifferentIndexForDifferentIndexNames() {
LuceneIndex<Repository> def = indexFactory.create(params("default", Repository.class));
LuceneIndex<Repository> other = indexFactory.create(params("other", Repository.class));
assertThat(def).isNotSameAs(other);
}
@Test
void shouldReturnSameIndex() {
LuceneIndex<Repository> one = indexFactory.create(params("default", Repository.class));
LuceneIndex<Repository> two = indexFactory.create(params("default", Repository.class));
assertThat(one).isSameAs(two);
}
private IndexParams params(String indexName, Class<?> type) {
return new IndexParams(indexName, SearchableTypes.create(type), IndexOptions.defaults());
}
}

View File

@@ -27,7 +27,6 @@ package sonia.scm.search;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import lombok.Value;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
@@ -39,12 +38,26 @@ import org.apache.lucene.search.TopDocs;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryTestData;
import sonia.scm.repository.RepositoryType;
import java.io.IOException;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static sonia.scm.search.FieldNames.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.when;
import static sonia.scm.search.FieldNames.ID;
import static sonia.scm.search.FieldNames.PERMISSION;
import static sonia.scm.search.FieldNames.REPOSITORY;
class LuceneIndexTest {
@@ -77,15 +90,6 @@ class LuceneIndexTest {
assertHits("value", "content", 1);
}
@Test
void shouldStoreUidOfObject() throws IOException {
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.store(ONE, null, new Storable("Awesome content which should be indexed"));
}
assertHits(UID, "one/storable", 1);
}
@Test
void shouldStoreIdOfObject() throws IOException {
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
@@ -104,15 +108,6 @@ class LuceneIndexTest {
assertHits(REPOSITORY, "4211", 1);
}
@Test
void shouldStoreTypeOfObject() throws IOException {
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.store(ONE, null, new Storable("Some other text"));
}
assertHits(TYPE, "storable", 1);
}
@Test
void shouldDeleteById() throws IOException {
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
@@ -120,131 +115,54 @@ class LuceneIndexTest {
}
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.delete().byType().byId(ONE);
index.delete().byId(ONE);
}
assertHits(ID, "one", 0);
}
@Test
void shouldDeleteAllByType() throws IOException {
void shouldDeleteByIdAndRepository() throws IOException {
Repository heartOfGold = RepositoryTestData.createHeartOfGold();
Repository puzzle42 = RepositoryTestData.createHeartOfGold();
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.store(ONE, null, new Storable("content"));
index.store(Id.of("two"), null, new Storable("content"));
}
try (LuceneIndex<OtherStorable> index = createIndex(OtherStorable.class)) {
index.store(Id.of("three"), null, new OtherStorable("content"));
index.store(ONE.withRepository(heartOfGold), null, new Storable("content"));
index.store(ONE.withRepository(puzzle42), null, new Storable("content"));
}
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.delete().byType().all();
index.delete().byId(ONE.withRepository(heartOfGold));
}
assertHits("value", "content", 1);
}
@Test
void shouldDeleteByIdAnyType() throws IOException {
void shouldDeleteAll() throws IOException {
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.store(ONE, null, new Storable("Some text"));
}
try (LuceneIndex<OtherStorable> index = createIndex(OtherStorable.class)) {
index.store(ONE, null, new OtherStorable("Some other text"));
index.store(ONE, null, new Storable("content"));
index.store(TWO, null, new Storable("content"));
}
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.delete().byType().byId(ONE);
index.delete().all();
}
assertHits(ID, "one", 1);
ScoreDoc[] docs = assertHits(ID, "one", 1);
Document doc = doc(docs[0].doc);
assertThat(doc.get("value")).isEqualTo("Some other text");
}
@Test
void shouldDeleteByIdAndRepository() throws IOException {
Id withRepository = ONE.withRepository("4211");
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.store(ONE, null, new Storable("Some other text"));
index.store(withRepository, null, new Storable("New stuff"));
}
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.delete().byType().byId(withRepository);
}
ScoreDoc[] docs = assertHits(ID, "one", 1);
Document doc = doc(docs[0].doc);
assertThat(doc.get("value")).isEqualTo("Some other text");
assertHits("value", "content", 0);
}
@Test
void shouldDeleteByRepository() throws IOException {
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.store(ONE.withRepository("4211"), null, new Storable("Some other text"));
index.store(ONE.withRepository("4212"), null, new Storable("New stuff"));
index.store(ONE.withRepository("4211"), null, new Storable("content"));
index.store(TWO.withRepository("4212"), null, new Storable("content"));
}
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.delete().byType().byRepository("4212");
index.delete().byRepository("4212");
}
assertHits(ID, "one", 1);
}
@Test
void shouldDeleteByRepositoryAndType() throws IOException {
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.store(ONE.withRepository("4211"), null, new Storable("some text"));
index.store(TWO.withRepository("4211"), null, new Storable("some text"));
}
try (LuceneIndex<OtherStorable> index = createIndex(OtherStorable.class)) {
index.store(ONE.withRepository("4211"), null, new OtherStorable("some text"));
}
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.delete().byType().byRepository("4211");
}
ScoreDoc[] docs = assertHits("value", "text", 1);
Document doc = doc(docs[0].doc);
assertThat(doc.get(TYPE)).isEqualTo("otherStorable");
}
@Test
void shouldDeleteAllByRepository() throws IOException {
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.store(ONE.withRepository("4211"), null, new Storable("some text"));
index.store(TWO.withRepository("4211"), null, new Storable("some text"));
}
try (LuceneIndex<OtherStorable> index = createIndex(OtherStorable.class)) {
index.store(ONE.withRepository("4211"), null, new OtherStorable("some text"));
}
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.delete().allTypes().byRepository("4211");
}
assertHits("value", "text", 0);
}
@Test
void shouldDeleteAllByTypeName() throws IOException {
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
index.store(ONE, null, new Storable("some text"));
index.store(TWO, null, new Storable("some text"));
}
try (LuceneIndex<OtherStorable> index = createIndex(OtherStorable.class)) {
index.delete().allTypes().byTypeName("storable");
}
assertHits("value", "text", 0);
assertHits("value", "content", 1);
}
@Test
@@ -256,12 +174,69 @@ class LuceneIndexTest {
assertHits(PERMISSION, "repo:4211:read", 1);
}
private Document doc(int doc) throws IOException {
try (DirectoryReader reader = DirectoryReader.open(directory)) {
return reader.document(doc);
@Test
void shouldReturnDetails() {
try (LuceneIndex<Storable> index = createIndex(Storable.class)) {
IndexDetails details = index.getDetails();
assertThat(details.getType()).isEqualTo(Storable.class);
assertThat(details.getName()).isEqualTo("default");
}
}
@Nested
@ExtendWith(MockitoExtension.class)
class ExceptionTests {
@Mock
private IndexWriter writer;
private LuceneIndex<Storable> index;
@BeforeEach
void setUpIndex() {
index = createIndex(Storable.class, () -> writer);
}
@Test
void shouldThrowSearchEngineExceptionOnStore() throws IOException {
when(writer.updateDocument(any(), any())).thenThrow(new IOException("failed to store"));
Storable storable = new Storable("Some other text");
assertThrows(SearchEngineException.class, () -> index.store(ONE, null, storable));
}
@Test
void shouldThrowSearchEngineExceptionOnDeleteById() throws IOException {
when(writer.deleteDocuments(any(Term.class))).thenThrow(new IOException("failed to delete"));
Index.Deleter deleter = index.delete();
assertThrows(SearchEngineException.class, () -> deleter.byId(ONE));
}
@Test
void shouldThrowSearchEngineExceptionOnDeleteAll() throws IOException {
when(writer.deleteAll()).thenThrow(new IOException("failed to delete"));
Index.Deleter deleter = index.delete();
assertThrows(SearchEngineException.class, deleter::all);
}
@Test
void shouldThrowSearchEngineExceptionOnDeleteByRepository() throws IOException {
when(writer.deleteDocuments(any(Term.class))).thenThrow(new IOException("failed to delete"));
Index.Deleter deleter = index.delete();
assertThrows(SearchEngineException.class, () -> deleter.byRepository("42"));
}
@Test
void shouldThrowSearchEngineExceptionOnClose() throws IOException {
doThrow(new IOException("failed to delete")).when(writer).close();
assertThrows(SearchEngineException.class, () -> index.close());
}
}
@CanIgnoreReturnValue
private ScoreDoc[] assertHits(String field, String value, int expectedHits) throws IOException {
try (DirectoryReader reader = DirectoryReader.open(directory)) {
@@ -272,15 +247,25 @@ class LuceneIndexTest {
}
}
private <T> LuceneIndex<T> createIndex(Class<T> type) throws IOException {
SearchableTypeResolver resolver = new SearchableTypeResolver(type);
return new LuceneIndex<>(resolver.resolve(type), createWriter());
private <T> LuceneIndex<T> createIndex(Class<T> type) {
return createIndex(type, this::createWriter);
}
private IndexWriter createWriter() throws IOException {
private <T> LuceneIndex<T> createIndex(Class<T> type, Supplier<IndexWriter> writerFactor) {
SearchableTypeResolver resolver = new SearchableTypeResolver(type);
return new LuceneIndex<>(
new IndexParams("default", resolver.resolve(type), IndexOptions.defaults()), writerFactor
);
}
private IndexWriter createWriter() {
IndexWriterConfig config = new IndexWriterConfig(new StandardAnalyzer());
config.setOpenMode(IndexWriterConfig.OpenMode.CREATE_OR_APPEND);
try {
return new IndexWriter(directory, config);
} catch (IOException ex) {
throw new SearchEngineException("failed to open index writer", ex);
}
}
@Value
@@ -290,11 +275,4 @@ class LuceneIndexTest {
String value;
}
@Value
@IndexedType
private static class OtherStorable {
@Indexed
String value;
}
}

View File

@@ -0,0 +1,98 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import com.google.inject.AbstractModule;
import com.google.inject.Guice;
import com.google.inject.Injector;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Answers;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.user.User;
import javax.inject.Inject;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(MockitoExtension.class)
class LuceneInjectingIndexTaskTest {
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private LuceneIndexFactory indexFactory;
private static String capturedValue;
private final SearchableTypeResolver resolver = new SearchableTypeResolver(User.class);
@BeforeEach
void cleanUp() {
capturedValue = "notAsExpected";
}
@Test
void shouldInjectAndUpdate() {
Injector injector = createInjector();
LuceneSearchableType searchableType = resolver.resolve(User.class);
IndexParams params = new IndexParams("default", searchableType, IndexOptions.defaults());
LuceneInjectingIndexTask task = new LuceneInjectingIndexTask(params, InjectingTask.class);
injector.injectMembers(task);
task.run();
assertThat(capturedValue).isEqualTo("runAsExpected");
}
private Injector createInjector() {
return Guice.createInjector(new AbstractModule() {
@Override
protected void configure() {
bind(SearchableTypeResolver.class).toInstance(resolver);
bind(LuceneIndexFactory.class).toInstance(indexFactory);
bind(String.class).toInstance("runAsExpected");
}
});
}
public static class InjectingTask implements IndexTask<String> {
private final String value;
@Inject
public InjectingTask(String value) {
this.value = value;
}
@Override
public void update(Index<String> index) {
capturedValue = value;
}
}
}

View File

@@ -42,7 +42,6 @@ import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.shiro.authz.AuthorizationException;
import org.github.sdorra.jse.ShiroExtension;
import org.github.sdorra.jse.SubjectAware;
import org.junit.jupiter.api.BeforeEach;
@@ -70,7 +69,7 @@ class LuceneQueryBuilderTest {
private Directory directory;
@Mock
private IndexOpener opener;
private IndexManager opener;
@BeforeEach
void setUpDirectory() {
@@ -181,17 +180,6 @@ class LuceneQueryBuilderTest {
assertThat(result.getTotalHits()).isOne();
}
@Test
void shouldIgnoreHitsOfOtherType() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(inetOrgPersonDoc("Arthur", "Dent", "Arthur Dent", "4211"));
writer.addDocument(personDoc("Dent"));
}
QueryResult result = query(InetOrgPerson.class, "Dent");
assertThat(result.getTotalHits()).isOne();
}
@Test
void shouldThrowQueryParseExceptionOnInvalidQuery() throws IOException {
try (IndexWriter writer = writer()) {
@@ -251,17 +239,6 @@ class LuceneQueryBuilderTest {
assertThat(result.getHits()).hasSize(3);
}
@Test
void shouldReturnOnlyHitsOfTypeForExpertQuery() throws IOException {
try (IndexWriter writer = writer()) {
writer.addDocument(inetOrgPersonDoc("Ford", "Prefect", "Ford Prefect", "4211"));
writer.addDocument(personDoc("Prefect"));
}
QueryResult result = query(InetOrgPerson.class, "lastName:prefect");
assertThat(result.getTotalHits()).isEqualTo(1L);
}
@Test
void shouldReturnOnlyPermittedHits() throws IOException {
try (IndexWriter writer = writer()) {
@@ -302,10 +279,11 @@ class LuceneQueryBuilderTest {
QueryResult result;
try (DirectoryReader reader = DirectoryReader.open(directory)) {
when(opener.openForRead("default")).thenReturn(reader);
SearchableTypeResolver resolver = new SearchableTypeResolver(Simple.class);
LuceneSearchableType searchableType = resolver.resolve(Simple.class);
when(opener.openForRead(searchableType, "default")).thenReturn(reader);
LuceneQueryBuilder<Simple> builder = new LuceneQueryBuilder<>(
opener, "default", resolver.resolve(Simple.class), new StandardAnalyzer()
opener, "default", searchableType, new StandardAnalyzer()
);
result = builder.repository("cde").execute("content:awesome");
}
@@ -560,9 +538,9 @@ class LuceneQueryBuilderTest {
private <T> long count(Class<T> type, String queryString) throws IOException {
try (DirectoryReader reader = DirectoryReader.open(directory)) {
lenient().when(opener.openForRead("default")).thenReturn(reader);
SearchableTypeResolver resolver = new SearchableTypeResolver(type);
LuceneSearchableType searchableType = resolver.resolve(type);
lenient().when(opener.openForRead(searchableType, "default")).thenReturn(reader);
LuceneQueryBuilder<T> builder = new LuceneQueryBuilder<T>(
opener, "default", searchableType, new StandardAnalyzer()
);
@@ -572,10 +550,11 @@ class LuceneQueryBuilderTest {
private <T> QueryResult query(Class<?> type, String queryString, Integer start, Integer limit) throws IOException {
try (DirectoryReader reader = DirectoryReader.open(directory)) {
lenient().when(opener.openForRead("default")).thenReturn(reader);
SearchableTypeResolver resolver = new SearchableTypeResolver(type);
LuceneSearchableType searchableType = resolver.resolve(type);
LuceneQueryBuilder<T> builder = new LuceneQueryBuilder<T>(
lenient().when(opener.openForRead(searchableType, "default")).thenReturn(reader);
LuceneQueryBuilder<T> builder = new LuceneQueryBuilder<>(
opener, "default", searchableType, new StandardAnalyzer()
);
if (start != null) {
@@ -597,14 +576,14 @@ class LuceneQueryBuilderTest {
private Document simpleDoc(String content) {
Document document = new Document();
document.add(new TextField("content", content, Field.Store.YES));
document.add(new StringField(FieldNames.TYPE, "simple", Field.Store.YES));
// document.add(new StringField(FieldNames.TYPE, "simple", Field.Store.YES));
return document;
}
private Document permissionDoc(String content, String permission) {
Document document = new Document();
document.add(new TextField("content", content, Field.Store.YES));
document.add(new StringField(FieldNames.TYPE, "simple", Field.Store.YES));
// document.add(new StringField(FieldNames.TYPE, "simple", Field.Store.YES));
document.add(new StringField(FieldNames.PERMISSION, permission, Field.Store.YES));
return document;
}
@@ -612,7 +591,7 @@ class LuceneQueryBuilderTest {
private Document repositoryDoc(String content, String repository) {
Document document = new Document();
document.add(new TextField("content", content, Field.Store.YES));
document.add(new StringField(FieldNames.TYPE, "simple", Field.Store.YES));
// document.add(new StringField(FieldNames.TYPE, "simple", Field.Store.YES));
document.add(new StringField(FieldNames.REPOSITORY, repository, Field.Store.YES));
return document;
}
@@ -624,14 +603,14 @@ class LuceneQueryBuilderTest {
document.add(new TextField("displayName", displayName, Field.Store.YES));
document.add(new TextField("carLicense", carLicense, Field.Store.YES));
document.add(new StringField(FieldNames.ID, lastName, Field.Store.YES));
document.add(new StringField(FieldNames.TYPE, "inetOrgPerson", Field.Store.YES));
// document.add(new StringField(FieldNames.TYPE, "inetOrgPerson", Field.Store.YES));
return document;
}
private Document personDoc(String lastName) {
Document document = new Document();
document.add(new TextField("lastName", lastName, Field.Store.YES));
document.add(new StringField(FieldNames.TYPE, "person", Field.Store.YES));
// document.add(new StringField(FieldNames.TYPE, "person", Field.Store.YES));
return document;
}
@@ -644,14 +623,14 @@ class LuceneQueryBuilderTest {
document.add(new StringField("boolValue", String.valueOf(boolValue), Field.Store.YES));
document.add(new LongPoint("instantValue", instantValue.toEpochMilli()));
document.add(new StoredField("instantValue", instantValue.toEpochMilli()));
document.add(new StringField(FieldNames.TYPE, "types", Field.Store.YES));
// document.add(new StringField(FieldNames.TYPE, "types", Field.Store.YES));
return document;
}
private Document denyDoc(String value) {
Document document = new Document();
document.add(new TextField("value", value, Field.Store.YES));
document.add(new StringField(FieldNames.TYPE, "deny", Field.Store.YES));
// document.add(new StringField(FieldNames.TYPE, "deny", Field.Store.YES));
return document;
}

View File

@@ -27,12 +27,20 @@ package sonia.scm.search;
import org.apache.shiro.authz.AuthorizationException;
import org.github.sdorra.jse.ShiroExtension;
import org.github.sdorra.jse.SubjectAware;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Answers;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.Repository;
import sonia.scm.user.User;
import sonia.scm.work.CentralWorkQueue;
import sonia.scm.work.Task;
import java.util.Arrays;
import java.util.Collection;
@@ -46,27 +54,35 @@ import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SubjectAware("trillian")
@ExtendWith({MockitoExtension.class, ShiroExtension.class})
class LuceneSearchEngineTest {
@Mock
private IndexManager indexManager;
@Mock
private SearchableTypeResolver resolver;
@Mock
private IndexQueue indexQueue;
@Mock
private LuceneQueryBuilderFactory queryBuilderFactory;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private CentralWorkQueue centralWorkQueue;
@InjectMocks
private LuceneSearchEngine searchEngine;
@Mock
private LuceneSearchableType searchableType;
@Nested
class GetSearchableTypesTests {
@Test
void shouldDelegateGetSearchableTypes() {
List<LuceneSearchableType> mockedTypes = Collections.singletonList(searchableType("repository"));
@@ -101,46 +117,10 @@ class LuceneSearchEngineTest {
when(searchableType.getPermission()).thenReturn(Optional.ofNullable(permission));
return searchableType;
}
@Test
@SuppressWarnings("unchecked")
void shouldDelegateGetOrCreateWithDefaultIndex() {
Index<Repository> index = mock(Index.class);
when(resolver.resolve(Repository.class)).thenReturn(searchableType);
IndexParams params = new IndexParams("default", searchableType, IndexOptions.defaults());
when(indexQueue.<Repository>getQueuedIndex(params)).thenReturn(index);
Index<Repository> idx = searchEngine.forType(Repository.class).getOrCreate();
assertThat(idx).isSameAs(index);
}
@Test
@SuppressWarnings("unchecked")
void shouldDelegateGetOrCreateIndexWithDefaults() {
Index<Repository> index = mock(Index.class);
when(resolver.resolve(Repository.class)).thenReturn(searchableType);
IndexParams params = new IndexParams("idx", searchableType, IndexOptions.defaults());
when(indexQueue.<Repository>getQueuedIndex(params)).thenReturn(index);
Index<Repository> idx = searchEngine.forType(Repository.class).withIndex("idx").getOrCreate();
assertThat(idx).isSameAs(index);
}
@Test
@SuppressWarnings("unchecked")
void shouldDelegateGetOrCreateIndex() {
Index<Repository> index = mock(Index.class);
IndexOptions options = IndexOptions.naturalLanguage(Locale.ENGLISH);
when(resolver.resolve(Repository.class)).thenReturn(searchableType);
IndexParams params = new IndexParams("default", searchableType, options);
when(indexQueue.<Repository>getQueuedIndex(params)).thenReturn(index);
Index<Repository> idx = searchEngine.forType(Repository.class).withOptions(options).getOrCreate();
assertThat(idx).isSameAs(index);
}
@Nested
class SearchTests {
@Test
@SuppressWarnings("unchecked")
@@ -217,5 +197,186 @@ class LuceneSearchEngineTest {
SearchEngine.ForType<Object> forType = searchEngine.forType("repository");
assertThat(forType.search()).isNotNull();
}
}
@Nested
class IndexTests {
@Captor
private ArgumentCaptor<Task> taskCaptor;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private CentralWorkQueue.Enqueue enqueue;
@BeforeEach
void setUp() {
when(centralWorkQueue.append()).thenReturn(enqueue);
}
@Test
void shouldSubmitSimpleTask() {
mockType();
searchEngine.forType(Repository.class).update(index -> {});
verifyTaskSubmitted(LuceneSimpleIndexTask.class);
}
@Test
void shouldSubmitInjectingTask() {
mockType();
searchEngine.forType(Repository.class).update(DummyIndexTask.class);
verifyTaskSubmitted(LuceneInjectingIndexTask.class);
}
@Test
void shouldLockTypeAndDefaultIndex() {
mockType();
searchEngine.forType(Repository.class).update(DummyIndexTask.class);
verify(enqueue).locks("repository-default-index");
}
@Test
void shouldLockTypeAndIndex() {
mockType();
searchEngine.forType(Repository.class).withIndex("sample").update(DummyIndexTask.class);
verify(enqueue).locks("repository-sample-index");
}
@Test
void shouldLockSpecificResource() {
mockType();
searchEngine.forType(Repository.class).forResource("one").update(DummyIndexTask.class);
verify(enqueue).locks("repository-default-index", "one");
}
@Test
void shouldLockMultipleSpecificResources() {
mockType();
searchEngine.forType(Repository.class)
.forResource("one")
.forResource("two")
.update(DummyIndexTask.class);
verify(enqueue).locks("repository-default-index", "one");
verify(enqueue).locks("repository-default-index", "two");
}
@Test
void shouldBatchSimpleTask() {
mockDetails(new LuceneIndexDetails(Repository.class, "default"));
searchEngine.forIndices().batch(index -> {});
verifyTaskSubmitted(LuceneSimpleIndexTask.class);
}
@Test
void shouldBatchAndLock() {
mockDetails(new LuceneIndexDetails(Repository.class, "default"));
searchEngine.forIndices().batch(index -> {});
verify(enqueue).locks("repository-default-index");
}
@Test
void shouldBatchAndLockSpecificResource() {
mockDetails(new LuceneIndexDetails(Repository.class, "default"));
searchEngine.forIndices().forResource("one").batch(index -> {});
verify(enqueue).locks("repository-default-index", "one");
}
@Test
void shouldBatchAndLockMultipleSpecificResources() {
mockDetails(new LuceneIndexDetails(Repository.class, "default"));
searchEngine.forIndices().forResource("one").forResource("two").batch(index -> {});
verify(enqueue).locks("repository-default-index", "one");
verify(enqueue).locks("repository-default-index", "two");
}
@Test
void shouldBatchInjectingTask() {
mockDetails(new LuceneIndexDetails(Repository.class, "default"));
searchEngine.forIndices().batch(DummyIndexTask.class);
verifyTaskSubmitted(LuceneInjectingIndexTask.class);
}
@Test
void shouldBatchMultipleTasks() {
mockDetails(
new LuceneIndexDetails(Repository.class, "default"),
new LuceneIndexDetails(User.class, "default")
);
searchEngine.forIndices().batch(index -> {});
verify(enqueue.runAsAdmin(), times(2)).enqueue(any(Task.class));
}
@Test
void shouldFilterWithPredicate() {
mockDetails(
new LuceneIndexDetails(Repository.class, "default"),
new LuceneIndexDetails(User.class, "default")
);
searchEngine.forIndices()
.matching(details -> details.getType() == Repository.class)
.batch(index -> {});
verify(enqueue.runAsAdmin()).enqueue(any(Task.class));
}
private <T extends IndexDetails> void mockDetails(LuceneIndexDetails... details) {
for (LuceneIndexDetails detail : details) {
mockType(detail.getType());
}
when(indexManager.all()).thenAnswer(ic -> Arrays.asList(details));
}
private void verifyTaskSubmitted(Class<? extends Task> typeOfTask) {
verify(enqueue.runAsAdmin()).enqueue(taskCaptor.capture());
Task task = taskCaptor.getValue();
assertThat(task).isInstanceOf(typeOfTask);
}
private void mockType() {
mockType(Repository.class);
}
private void mockType(Class<?> type){
LuceneSearchableType searchableType = mock(LuceneSearchableType.class);
lenient().when(searchableType.getType()).thenAnswer(ic -> type);
lenient().when(searchableType.getName()).thenReturn(type.getSimpleName().toLowerCase(Locale.ENGLISH));
lenient().when(resolver.resolve(type)).thenReturn(searchableType);
}
}
public static class DummyIndexTask implements IndexTask<Repository> {
@Override
public void update(Index<Repository> index) {
}
}
}

View File

@@ -0,0 +1,83 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import com.google.inject.AbstractModule;
import com.google.inject.Guice;
import com.google.inject.Injector;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.Repository;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LuceneSimpleIndexTaskTest {
@Mock
private LuceneIndexFactory indexFactory;
@Mock
private LuceneIndex<Repository> index;
private final SearchableTypeResolver resolver = new SearchableTypeResolver(Repository.class);
@Test
void shouldUpdate() {
Injector injector = createInjector();
LuceneSearchableType searchableType = resolver.resolve(Repository.class);
IndexParams params = new IndexParams("default", searchableType, IndexOptions.defaults());
AtomicReference<Index<?>> ref = new AtomicReference<>();
LuceneSimpleIndexTask task = new LuceneSimpleIndexTask(params, ref::set);
injector.injectMembers(task);
when(indexFactory.create(params)).then(ic -> index);
task.run();
assertThat(index).isSameAs(ref.get());
verify(index).close();
}
private Injector createInjector() {
return Guice.createInjector(new AbstractModule() {
@Override
protected void configure() {
bind(SearchableTypeResolver.class).toInstance(resolver);
bind(LuceneIndexFactory.class).toInstance(indexFactory);
}
});
}
}

View File

@@ -0,0 +1,249 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.search;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.Term;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SharableIndexWriterTest {
@Mock
private IndexWriter underlyingWriter;
@Test
@SuppressWarnings("unchecked")
void shouldCreateIndexOnOpen() {
Supplier<IndexWriter> supplier = mock(Supplier.class);
SharableIndexWriter writer = new SharableIndexWriter(supplier);
verifyNoInteractions(supplier);
writer.open();
verify(supplier).get();
}
@Test
@SuppressWarnings("unchecked")
void shouldOpenWriterOnlyOnce() {
Supplier<IndexWriter> supplier = mock(Supplier.class);
SharableIndexWriter writer = new SharableIndexWriter(supplier);
writer.open();
writer.open();
writer.open();
verify(supplier).get();
}
@Test
void shouldIncreaseUsageCounter() {
SharableIndexWriter writer = new SharableIndexWriter(() -> underlyingWriter);
writer.open();
writer.open();
writer.open();
assertThat(writer.getUsageCounter()).isEqualTo(3);
}
@Test
void shouldDecreaseUsageCounter() throws IOException {
SharableIndexWriter writer = new SharableIndexWriter(() -> underlyingWriter);
writer.open();
writer.open();
writer.open();
assertThat(writer.getUsageCounter()).isEqualTo(3);
writer.close();
writer.close();
assertThat(writer.getUsageCounter()).isOne();
}
@Test
void shouldNotCloseWriterIfUsageCounterIsGreaterZero() throws IOException {
SharableIndexWriter writer = new SharableIndexWriter(() -> underlyingWriter);
writer.open();
writer.open();
writer.open();
writer.close();
writer.close();
verify(underlyingWriter, never()).close();
}
@Test
void shouldCloseIfUsageCounterIsZero() throws IOException {
SharableIndexWriter writer = new SharableIndexWriter(() -> underlyingWriter);
writer.open();
writer.open();
writer.close();
writer.close();
verify(underlyingWriter).close();
}
@Test
@SuppressWarnings("unchecked")
void shouldReOpen() throws IOException {
Supplier<IndexWriter> supplier = mock(Supplier.class);
when(supplier.get()).thenReturn(underlyingWriter);
SharableIndexWriter writer = new SharableIndexWriter(supplier);
writer.open();
writer.close();
writer.open();
verify(supplier, times(2)).get();
verify(underlyingWriter).close();
}
@Test
void shouldDelegateUpdates() throws IOException {
SharableIndexWriter writer = new SharableIndexWriter(() -> underlyingWriter);
writer.open();
Term term = new Term("field", "value");
Document document = new Document();
writer.updateDocument(term, document);
verify(underlyingWriter).updateDocument(term, document);
}
@Test
void shouldDelegateDeleteAll() throws IOException {
SharableIndexWriter writer = new SharableIndexWriter(() -> underlyingWriter);
writer.open();
writer.deleteAll();
verify(underlyingWriter).deleteAll();
}
@Test
void shouldDelegateDeletes() throws IOException {
SharableIndexWriter writer = new SharableIndexWriter(() -> underlyingWriter);
writer.open();
Term term = new Term("field", "value");
writer.deleteDocuments(term);
verify(underlyingWriter).deleteDocuments(term);
}
@Nested
class ConcurrencyTests {
private ExecutorService executorService;
private final AtomicInteger openCounter = new AtomicInteger();
private final AtomicInteger commitCounter = new AtomicInteger();
private final AtomicInteger closeCounter = new AtomicInteger();
private final AtomicInteger invocations = new AtomicInteger();
private SharableIndexWriter writer;
@BeforeEach
void setUp() throws IOException {
executorService = Executors.newFixedThreadPool(4);
writer = new SharableIndexWriter(() -> {
openCounter.incrementAndGet();
return underlyingWriter;
});
doAnswer(ic -> commitCounter.incrementAndGet()).when(underlyingWriter).commit();
doAnswer(ic -> closeCounter.incrementAndGet()).when(underlyingWriter).close();
}
@Test
@SuppressWarnings("java:S2925") // sleep is ok to simulate some work
void shouldKeepIndexOpen() {
AtomicBoolean fail = new AtomicBoolean(false);
for (int i = 0; i < 50; i++) {
executorService.submit(() -> {
writer.open();
try {
Thread.sleep(25);
writer.deleteAll();
writer.close();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
fail.set(true);
} catch (IOException e) {
fail.set(true);
} finally {
invocations.incrementAndGet();
}
});
}
executorService.shutdown();
await().atMost(2, TimeUnit.SECONDS).until(() -> invocations.get() == 50);
assertThat(fail.get()).isFalse();
// It should be one, but it is possible that tasks finish before new added to the queue.
// This behaviour depends heavily on the cpu's of the machine which executes this test.
assertThat(openCounter.get()).isPositive().isLessThan(10);
// should be 49, but see comment above
assertThat(commitCounter.get()).isGreaterThan(40);
// should be 1, but see comment above
assertThat(closeCounter.get()).isPositive().isLessThan(10);
}
}
}

View File

@@ -0,0 +1,91 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.security;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.UnavailableSecurityManagerException;
import org.apache.shiro.mgt.DefaultSecurityManager;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.subject.SimplePrincipalCollection;
import org.github.sdorra.jse.ShiroExtension;
import org.github.sdorra.jse.SubjectAware;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import sonia.scm.security.Impersonator.Session;
import javax.annotation.Nonnull;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
class ImpersonatorTest {
private SecurityManager securityManager = new DefaultSecurityManager();
private Impersonator impersonator;
@BeforeEach
void setUp() {
impersonator = new Impersonator(securityManager);
}
@Test
void shouldBindAndRestoreNonWebThread() {
try (Session session = impersonator.impersonate(principal("dent"))) {
assertPrincipal("dent");
}
assertThrows(UnavailableSecurityManagerException.class, SecurityUtils::getSubject);
}
@Nonnull
private SimplePrincipalCollection principal(String principal) {
return new SimplePrincipalCollection(principal, "test");
}
private void assertPrincipal(String principal) {
assertThat(SecurityUtils.getSubject().getPrincipal()).isEqualTo(principal);
}
@Nested
@ExtendWith(ShiroExtension.class)
class WithSecurityManager {
@Test
@SubjectAware("trillian")
void shouldBindAndRestoreWebThread() {
assertPrincipal("trillian");
try (Session session = impersonator.impersonate(principal("slarti"))) {
assertPrincipal("slarti");
}
assertPrincipal("trillian");
}
}
}

View File

@@ -0,0 +1,91 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.update.index;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.SCMContextProvider;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class RemoveCombinedIndexTest {
private Path home;
@Mock
private SCMContextProvider contextProvider;
@InjectMocks
private RemoveCombinedIndex updateStep;
@BeforeEach
void setUp(@TempDir Path home) {
this.home = home;
when(contextProvider.resolve(any())).then(
ic -> home.resolve(ic.getArgument(0, Path.class))
);
}
@Test
void shouldRemoveIndexDirectory() throws IOException {
Path indexDirectory = home.resolve("index");
Path specificIndexDirectory = indexDirectory.resolve("repository").resolve("default");
Files.createDirectories(specificIndexDirectory);
Path helloTxt = specificIndexDirectory.resolve("hello.txt");
Files.write(helloTxt, "hello".getBytes(StandardCharsets.UTF_8));
updateStep.doUpdate();
assertThat(helloTxt).doesNotExist();
assertThat(indexDirectory).doesNotExist();
}
@Test
void shouldRemoveIndexLogDirectory() throws IOException {
Path logDirectory = home.resolve("var").resolve("data").resolve("index-log");
Files.createDirectories(logDirectory);
Path helloXml = logDirectory.resolve("hello.xml");
Files.write(helloXml, "<hello>world</hello>".getBytes(StandardCharsets.UTF_8));
updateStep.doUpdate();
assertThat(helloXml).doesNotExist();
assertThat(logDirectory).doesNotExist();
}
}

View File

@@ -24,20 +24,23 @@
package sonia.scm.user;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Answers;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.HandlerEventType;
import sonia.scm.search.Id;
import sonia.scm.search.Index;
import sonia.scm.search.IndexLogStore;
import sonia.scm.search.SearchEngine;
import sonia.scm.search.SerializableIndexTask;
import java.util.Arrays;
import static java.util.Collections.singletonList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -54,6 +57,16 @@ class UserIndexerTest {
@InjectMocks
private UserIndexer indexer;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private Index<User> index;
@Mock
private IndexLogStore indexLogStore;
@Captor
private ArgumentCaptor<SerializableIndexTask<User>> captor;
@Test
void shouldReturnType() {
assertThat(indexer.getType()).isEqualTo(User.class);
@@ -64,58 +77,54 @@ class UserIndexerTest {
assertThat(indexer.getVersion()).isEqualTo(UserIndexer.VERSION);
}
@Nested
class UpdaterTests {
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private Index<User> index;
private final User user = UserTestData.createTrillian();
@BeforeEach
void open() {
when(searchEngine.forType(User.class).getOrCreate()).thenReturn(index);
@Test
void shouldReturnReIndexAllClass() {
assertThat(indexer.getReIndexAllTask()).isEqualTo(UserIndexer.ReIndexAll.class);
}
@Test
void shouldStore() {
indexer.open().store(user);
void shouldCreateUser() {
User trillian = UserTestData.createTrillian();
verify(index).store(Id.of(user), "user:read:trillian", user);
indexer.createStoreTask(trillian).update(index);
verify(index).store(Id.of(trillian), UserPermissions.read(trillian).asShiroString(), trillian);
}
@Test
void shouldDeleteById() {
indexer.open().delete(user);
void shouldDeleteUser() {
User trillian = UserTestData.createTrillian();
verify(index.delete().byType()).byId(Id.of(user));
indexer.createDeleteTask(trillian).update(index);
verify(index.delete()).byId(Id.of(trillian));
}
@Test
void shouldReIndexAll() {
when(userManager.getAll()).thenReturn(singletonList(user));
User trillian = UserTestData.createTrillian();
User slarti = UserTestData.createSlarti();
when(userManager.getAll()).thenReturn(Arrays.asList(trillian, slarti));
indexer.open().reIndexAll();
UserIndexer.ReIndexAll reIndexAll = new UserIndexer.ReIndexAll(indexLogStore, userManager);
reIndexAll.update(index);
verify(index.delete().byType()).all();
verify(index).store(Id.of(user), "user:read:trillian", user);
verify(index.delete()).all();
verify(index).store(Id.of(trillian), UserPermissions.read(trillian).asShiroString(), trillian);
verify(index).store(Id.of(slarti), UserPermissions.read(slarti).asShiroString(), slarti);
}
@Test
void shouldHandleEvent() {
UserEvent event = new UserEvent(HandlerEventType.DELETE, user);
void shouldHandleEvents() {
User trillian = UserTestData.createTrillian();
UserEvent event = new UserEvent(HandlerEventType.DELETE, trillian);
indexer.handleEvent(event);
verify(index.delete().byType()).byId(Id.of(user));
}
@Test
void shouldCloseIndex() {
indexer.open().close();
verify(index).close();
}
verify(searchEngine.forType(User.class)).update(captor.capture());
captor.getValue().update(index);
verify(index.delete()).byId(Id.of(trillian));
}
}

View File

@@ -37,6 +37,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.security.Authentications;
import sonia.scm.security.Impersonator;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@@ -54,7 +55,7 @@ class DefaultAdministrationContextTest {
Injector injector = Guice.createInjector();
SecurityManager securityManager = new DefaultSecurityManager();
context = new DefaultAdministrationContext(injector, securityManager);
context = new DefaultAdministrationContext(injector, new Impersonator(securityManager));
}
@Test

View File

@@ -0,0 +1,375 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.work;
import com.google.inject.AbstractModule;
import com.google.inject.Guice;
import com.google.inject.Inject;
import io.micrometer.core.instrument.Meter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.SimplePrincipalCollection;
import org.github.sdorra.jse.ShiroExtension;
import org.github.sdorra.jse.SubjectAware;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.Repository;
import sonia.scm.security.Authentications;
import javax.annotation.Nonnull;
import java.util.Arrays;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.mockito.Mockito.when;
@SubjectAware("trillian")
@ExtendWith({MockitoExtension.class, ShiroExtension.class})
class DefaultCentralWorkQueueTest {
private final PrincipalCollection principal = new SimplePrincipalCollection("trillian", "test");
private static final int ITERATIONS = 50;
private static final int TIMEOUT = 1; // seconds
@Mock
private Persistence persistence;
@Nested
class WithDefaultInjector {
private MeterRegistry meterRegistry;
private DefaultCentralWorkQueue queue;
@BeforeEach
void setUp() {
meterRegistry = new SimpleMeterRegistry();
queue = new DefaultCentralWorkQueue(Guice.createInjector(new SecurityModule()), persistence, meterRegistry);
}
private final AtomicInteger runs = new AtomicInteger();
private int counter = 0;
private int copy = -1;
@Test
void shouldRunInSequenceWithBlock() {
for (int i = 0; i < ITERATIONS; i++) {
queue.append().locks("counter").enqueue(new Increase());
}
waitForTasks();
assertThat(counter).isEqualTo(ITERATIONS);
}
private void waitForTasks() {
await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> queue.getSize() == 0);
assertThat(runs.get()).isEqualTo(ITERATIONS);
}
@Test
void shouldRunInParallel() {
for (int i = 0; i < ITERATIONS; i++) {
queue.append().enqueue(new Increase());
}
waitForTasks();
// we test if the resulting counter is less than the iteration,
// because it is extremely likely that we miss a counter update
// when we run in parallel
assertThat(counter)
.isPositive()
.isLessThan(ITERATIONS);
}
@Test
void shouldNotBlocked() {
for (int i = 0; i < ITERATIONS; i++) {
queue.append().locks("counter").enqueue(new Increase());
}
queue.append().enqueue(() -> copy = counter);
waitForTasks();
assertThat(counter).isEqualTo(ITERATIONS);
assertThat(copy).isNotNegative().isLessThan(ITERATIONS);
}
@Test
void shouldNotBlockedByDifferentResource() {
for (int i = 0; i < ITERATIONS; i++) {
queue.append().locks("counter").enqueue(new Increase());
}
queue.append().locks("copy").enqueue(() -> copy = counter);
waitForTasks();
assertThat(counter).isEqualTo(ITERATIONS);
assertThat(copy).isNotNegative().isLessThan(ITERATIONS);
}
@Test
void shouldBeBlockedByParentResource() {
for (int i = 0; i < ITERATIONS; i++) {
queue.append().locks("counter").enqueue(new Increase());
}
queue.append().locks("counter", "one").enqueue(() -> copy = counter);
waitForTasks();
assertThat(counter).isEqualTo(ITERATIONS);
assertThat(copy).isEqualTo(ITERATIONS);
}
@Test
void shouldBeBlockedByParentAndExactResource() {
for (int i = 0; i < ITERATIONS; i++) {
if (i % 2 == 0) {
queue.append().locks("counter", "c").enqueue(new Increase());
} else {
queue.append().locks("counter").enqueue(new Increase());
}
}
waitForTasks();
assertThat(counter).isEqualTo(ITERATIONS);
}
@Test
void shouldBeBlockedByParentResourceWithModelObject() {
Repository one = repository("one");
for (int i = 0; i < ITERATIONS; i++) {
queue.append().locks("counter").enqueue(new Increase());
}
queue.append().locks("counter", one).enqueue(() -> copy = counter);
waitForTasks();
assertThat(counter).isEqualTo(ITERATIONS);
assertThat(copy).isEqualTo(ITERATIONS);
}
@Test
void shouldFinalizeOnError() {
queue.append().enqueue(() -> {
throw new IllegalStateException("failed");
});
await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> queue.getSize() == 0);
}
@Test
void shouldSetThreadName() {
AtomicReference<String> threadName = new AtomicReference<>();
queue.append().enqueue(() -> threadName.set(Thread.currentThread().getName()));
await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> threadName.get() != null);
assertThat(threadName.get()).startsWith("CentralWorkQueue");
}
@Test
void shouldCaptureExecutorMetrics() {
for (int i = 0; i < ITERATIONS; i++) {
queue.append().enqueue(new Increase());
}
waitForTasks();
double count = meterRegistry.get("executor.completed").functionCounter().count();
assertThat(count).isEqualTo(ITERATIONS);
}
@Test
void shouldCaptureExecutionDuration() {
queue.append().enqueue(new Increase());
await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> queue.getSize() == 0);
Timer timer = meterRegistry.get(UnitOfWork.METRIC_EXECUTION).timer();
assertThat(timer.count()).isEqualTo(1);
}
@Test
void shouldCaptureWaitDuration() {
queue.append().enqueue(new Increase());
await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> queue.getSize() == 0);
Timer timer = meterRegistry.get(UnitOfWork.METRIC_WAIT).timer();
assertThat(timer.count()).isEqualTo(1);
}
@Test
void shouldIncreaseBlockCount() {
for (int i = 0; i < ITERATIONS; i++) {
queue.append().locks("counter").enqueue(new Increase());
}
waitForTasks();
int blockCount = 0;
for (Meter meter : meterRegistry.getMeters()) {
Meter.Id id = meter.getId();
if ("cwq.task.wait.duration".equals(id.getName())) {
String blocked = id.getTag("blocked");
if (blocked != null) {
blockCount += Integer.parseInt(blocked);
}
}
}
assertThat(blockCount).isPositive();
}
@Nonnull
private Repository repository(String id) {
Repository one = new Repository();
one.setId(id);
return one;
}
@AfterEach
void tearDown() {
queue.close();
}
private class Increase implements Task {
@Override
@SuppressWarnings("java:S2925")
public void run() {
int currentCounter = counter;
runs.incrementAndGet();
try {
Thread.sleep(5);
counter = currentCounter + 1;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
@Test
void shouldInjectDependencies() {
Context ctx = new Context();
DefaultCentralWorkQueue queue = new DefaultCentralWorkQueue(
Guice.createInjector(new SecurityModule(), binder -> binder.bind(Context.class).toInstance(ctx)),
persistence,
new SimpleMeterRegistry(),
() -> 2
);
queue.append().enqueue(InjectingTask.class);
await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> ctx.value != null);
assertThat(ctx.value).isEqualTo("Hello");
}
@Test
void shouldLoadFromPersistence() {
Context context = new Context();
SimpleUnitOfWork one = new SimpleUnitOfWork(
21L, principal, Collections.singleton(new Resource("a")), new InjectingTask(context, "one")
);
SimpleUnitOfWork two = new SimpleUnitOfWork(
42L, principal, Collections.singleton(new Resource("a")), new InjectingTask(context, "two")
);
two.restore(42L);
when(persistence.loadAll()).thenReturn(Arrays.asList(one, two));
new DefaultCentralWorkQueue(Guice.createInjector(new SecurityModule()), persistence, new SimpleMeterRegistry());
await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> context.value != null);
assertThat(context.value).isEqualTo("two");
assertThat(one.getOrder()).isEqualTo(1L);
assertThat(one.getRestoreCount()).isEqualTo(1);
assertThat(two.getOrder()).isEqualTo(2L);
assertThat(two.getRestoreCount()).isEqualTo(2);
}
@Test
void shouldRunAsUser() {
DefaultCentralWorkQueue workQueue = new DefaultCentralWorkQueue(
Guice.createInjector(new SecurityModule()), persistence, new SimpleMeterRegistry()
);
AtomicReference<Object> ref = new AtomicReference<>();
workQueue.append().enqueue(() -> ref.set(SecurityUtils.getSubject().getPrincipal()));
await().atMost(1, TimeUnit.SECONDS).until(() -> "trillian".equals(ref.get()));
}
@Test
void shouldRunAsAdminUser() {
DefaultCentralWorkQueue workQueue = new DefaultCentralWorkQueue(
Guice.createInjector(new SecurityModule()), persistence, new SimpleMeterRegistry()
);
AtomicReference<Object> ref = new AtomicReference<>();
workQueue.append().runAsAdmin().enqueue(() -> ref.set(SecurityUtils.getSubject().getPrincipal()));
await().atMost(1, TimeUnit.SECONDS).until(() -> Authentications.PRINCIPAL_SYSTEM.equals(ref.get()));
}
public static class Context {
private String value;
public void setValue(String value) {
this.value = value;
}
}
public static class InjectingTask implements Task {
private final Context context;
private final String value;
@Inject
public InjectingTask(Context context) {
this(context, "Hello");
}
public InjectingTask(Context context, String value) {
this.context = context;
this.value = value;
}
@Override
public void run() {
context.setValue(value);
}
}
public static class SecurityModule extends AbstractModule {
@Override
protected void configure() {
bind(SecurityManager.class).toInstance(SecurityUtils.getSecurityManager());
}
}
}

View File

@@ -0,0 +1,178 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.work;
import lombok.EqualsAndHashCode;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.SimplePrincipalCollection;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.plugin.PluginLoader;
import sonia.scm.store.Blob;
import sonia.scm.store.InMemoryBlobStore;
import sonia.scm.store.InMemoryBlobStoreFactory;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.util.Collections;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PersistenceTest {
private final PrincipalCollection principal = new SimplePrincipalCollection("trillian", "test");
@Nested
class Default {
@Mock
private PluginLoader pluginLoader;
private Persistence persistence;
@BeforeEach
void setUp() {
when(pluginLoader.getUberClassLoader()).thenReturn(PersistenceTest.class.getClassLoader());
persistence = new Persistence(pluginLoader, new InMemoryBlobStoreFactory());
}
@Test
void shouldStoreSimpleChunkOfWork() {
UnitOfWork work = new SimpleUnitOfWork(
1L, principal, Collections.singleton(new Resource("a")), new MyTask()
);
persistence.store(work);
UnitOfWork loaded = persistence.loadAll().iterator().next();
assertThat(loaded).isEqualTo(work);
}
@Test
void shouldStoreInjectingChunkOfWork() {
UnitOfWork work = new InjectingUnitOfWork(
1L, principal, Collections.singleton(new Resource("a")), MyTask.class
);
persistence.store(work);
UnitOfWork loaded = persistence.loadAll().iterator().next();
assertThat(loaded).isEqualTo(work);
}
@Test
void shouldLoadInOrder() {
store(5, 3, 1, 4, 2);
long[] orderIds = persistence.loadAll()
.stream()
.mapToLong(UnitOfWork::getOrder)
.toArray();
assertThat(orderIds).containsExactly(1, 2, 3, 4, 5);
}
@Test
void shouldRemoveAfterLoad() {
store(1, 2);
assertThat(persistence.loadAll()).hasSize(2);
assertThat(persistence.loadAll()).isEmpty();
}
@Test
void shouldFailIfNotSerializable() {
store(1);
SimpleUnitOfWork unitOfWork = new SimpleUnitOfWork(
2L, principal, Collections.emptySet(), new NotSerializable()
);
assertThrows(NonPersistableTaskException.class, () -> persistence.store(unitOfWork));
}
@Test
void shouldRemoveStored() {
store(1);
SimpleUnitOfWork chunkOfWork = new SimpleUnitOfWork(
2L, principal, Collections.emptySet(), new MyTask()
);
persistence.store(chunkOfWork);
persistence.remove(chunkOfWork);
assertThat(persistence.loadAll()).hasSize(1);
}
private void store(long... orderIds) {
for (long order : orderIds) {
persistence.store(new SimpleUnitOfWork(
order, principal, Collections.emptySet(), new MyTask()
));
}
}
}
@Test
void shouldNotFailForNonChunkOfWorkItems() throws IOException {
InMemoryBlobStore blobStore = new InMemoryBlobStore();
Persistence persistence = new Persistence(PersistenceTest.class.getClassLoader(), blobStore);
persistence.store(new SimpleUnitOfWork(
1L, principal, Collections.emptySet(), new MyTask())
);
Blob blob = blobStore.create();
try (ObjectOutputStream stream = new ObjectOutputStream(blob.getOutputStream())) {
stream.writeObject(new MyTask());
blob.commit();
}
assertThat(persistence.loadAll()).hasSize(1);
}
@EqualsAndHashCode
public static class MyTask implements Task {
@Override
public void run() {
}
}
// non static inner classes are not serializable
private class NotSerializable implements Task {
@Override
public void run() {
}
}
}

View File

@@ -0,0 +1,72 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.work;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ResourceTest {
@Test
void shouldReturnResourceName() {
assertThat(res("a")).hasToString("a");
}
@Test
void shouldReturnResourceNameAndId() {
assertThat(res("a", "b")).hasToString("a:b");
}
@Nested
class IsBlockedByTests {
@Test
void shouldReturnTrue() {
assertThat(res("a").isBlockedBy(res("a"))).isTrue();
assertThat(res("a", "b").isBlockedBy(res("a", "b"))).isTrue();
assertThat(res("a").isBlockedBy(res("a", "b"))).isTrue();
assertThat(res("a", "b").isBlockedBy(res("a"))).isTrue();
}
@Test
void shouldReturnFalse() {
assertThat(res("a").isBlockedBy(res("b"))).isFalse();
assertThat(res("a", "b").isBlockedBy(res("a", "c"))).isFalse();
assertThat(res("a", "b").isBlockedBy(res("c", "b"))).isFalse();
}
}
private Resource res(String name) {
return new Resource(name);
}
private Resource res(String name, String id) {
return new Resource(name, id);
}
}

View File

@@ -0,0 +1,87 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.work;
import com.google.inject.AbstractModule;
import com.google.inject.Guice;
import com.google.inject.Injector;
import lombok.Value;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.SimplePrincipalCollection;
import org.junit.jupiter.api.Test;
import javax.inject.Inject;
import java.util.Collections;
import static org.assertj.core.api.Assertions.assertThat;
class SimpleUnitOfWorkTest {
private PrincipalCollection principal = new SimplePrincipalCollection("trillian", "test");
@Test
void shouldInjectMember() {
Context context = new Context("awesome");
Injector injector = Guice.createInjector(new AbstractModule() {
@Override
protected void configure() {
bind(Context.class).toInstance(context);
}
});
SimpleTask simpleTask = new SimpleTask();
SimpleUnitOfWork unitOfWork = new SimpleUnitOfWork(1L, principal, Collections.emptySet(), simpleTask);
unitOfWork.task(injector);
simpleTask.run();
assertThat(simpleTask.value).isEqualTo("awesome");
}
@Value
public class Context {
String value;
}
public class SimpleTask implements Task {
private Context context;
private String value = "no value set";
@Inject
public void setContext(Context context) {
this.context = context;
}
@Override
public void run() {
if (context != null) {
value = context.getValue();
}
}
}
}

View File

@@ -0,0 +1,80 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.work;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(MockitoExtension.class)
class ThreadCountProviderTest {
@Test
void shouldUseTwoWorkersForOneCPU() {
ThreadCountProvider provider = new ThreadCountProvider(() -> 1);
assertThat(provider.getAsInt()).isEqualTo(2);
}
@ParameterizedTest(name = "shouldUseFourWorkersFor{argumentsWithNames}CPU")
@ValueSource(ints = {2, 4, 8, 16})
void shouldUseFourWorkersForMoreThanOneCPU(int cpus) {
ThreadCountProvider provider = new ThreadCountProvider(() -> cpus);
assertThat(provider.getAsInt()).isEqualTo(4);
}
@Nested
class SystemPropertyTests {
@BeforeEach
void setUp() {
System.clearProperty(ThreadCountProvider.PROPERTY);
}
@Test
void shouldUseCountFromSystemProperty() {
ThreadCountProvider provider = new ThreadCountProvider();
System.setProperty(ThreadCountProvider.PROPERTY, "6");
assertThat(provider.getAsInt()).isEqualTo(6);
}
@ParameterizedTest
@ValueSource(strings = {"-1", "0", "100", "a", ""})
void shouldUseDefaultForInvalidValue(String value) {
ThreadCountProvider provider = new ThreadCountProvider(() -> 1);
System.setProperty(ThreadCountProvider.PROPERTY, value);
assertThat(provider.getAsInt()).isEqualTo(2);
}
}
}