com.b2international.snowowl.snomed.api.impl.SnomedClassificationServiceImpl.java Source code

Java tutorial

Introduction

Here is the source code for com.b2international.snowowl.snomed.api.impl.SnomedClassificationServiceImpl.java

Source

/*
 * Copyright 2011-2017 B2i Healthcare Pte Ltd, http://b2i.sg
 * 
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.b2international.snowowl.snomed.api.impl;

import static com.google.common.collect.Sets.newHashSet;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toMap;
import static java.util.stream.Collectors.toSet;

import java.io.File;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.text.MessageFormat;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import javax.annotation.Resource;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.b2international.commons.http.ExtendedLocale;
import com.b2international.snowowl.core.ApplicationContext;
import com.b2international.snowowl.core.SnowOwlApplication;
import com.b2international.snowowl.core.branch.Branch;
import com.b2international.snowowl.core.config.SnowOwlConfiguration;
import com.b2international.snowowl.core.domain.TransactionContext;
import com.b2international.snowowl.core.events.Notifications;
import com.b2international.snowowl.core.events.bulk.BulkRequest;
import com.b2international.snowowl.core.events.bulk.BulkRequestBuilder;
import com.b2international.snowowl.core.events.util.Promise;
import com.b2international.snowowl.core.exceptions.BadRequestException;
import com.b2international.snowowl.core.exceptions.ConflictException;
import com.b2international.snowowl.core.ft.FeatureToggles;
import com.b2international.snowowl.core.ft.Features;
import com.b2international.snowowl.datastore.BranchPathUtils;
import com.b2international.snowowl.datastore.oplock.IOperationLockTarget;
import com.b2international.snowowl.datastore.oplock.OperationLockException;
import com.b2international.snowowl.datastore.oplock.OperationLockRunner;
import com.b2international.snowowl.datastore.oplock.impl.DatastoreLockContext;
import com.b2international.snowowl.datastore.oplock.impl.DatastoreLockContextDescriptions;
import com.b2international.snowowl.datastore.oplock.impl.DatastoreOperationLockException;
import com.b2international.snowowl.datastore.oplock.impl.IDatastoreOperationLockManager;
import com.b2international.snowowl.datastore.oplock.impl.SingleRepositoryAndBranchLockTarget;
import com.b2international.snowowl.datastore.remotejobs.RemoteJobEntry;
import com.b2international.snowowl.datastore.remotejobs.RemoteJobNotification;
import com.b2international.snowowl.datastore.request.CommitResult;
import com.b2international.snowowl.datastore.request.RepositoryRequests;
import com.b2international.snowowl.datastore.request.job.JobRequests;
import com.b2international.snowowl.datastore.server.index.SingleDirectoryIndexManager;
import com.b2international.snowowl.eventbus.IEventBus;
import com.b2international.snowowl.snomed.api.ISnomedClassificationService;
import com.b2international.snowowl.snomed.api.browser.ISnomedBrowserService;
import com.b2international.snowowl.snomed.api.domain.browser.ISnomedBrowserConcept;
import com.b2international.snowowl.snomed.api.domain.browser.ISnomedBrowserRelationship;
import com.b2international.snowowl.snomed.api.domain.classification.ClassificationStatus;
import com.b2international.snowowl.snomed.api.domain.classification.IClassificationRun;
import com.b2international.snowowl.snomed.api.domain.classification.IEquivalentConcept;
import com.b2international.snowowl.snomed.api.domain.classification.IEquivalentConceptSet;
import com.b2international.snowowl.snomed.api.impl.domain.browser.SnomedBrowserConcept;
import com.b2international.snowowl.snomed.api.impl.domain.browser.SnomedBrowserRelationship;
import com.b2international.snowowl.snomed.api.impl.domain.browser.SnomedBrowserRelationshipTarget;
import com.b2international.snowowl.snomed.api.impl.domain.browser.SnomedBrowserRelationshipType;
import com.b2international.snowowl.snomed.api.impl.domain.classification.ClassificationRun;
import com.b2international.snowowl.snomed.api.impl.domain.classification.EquivalentConcept;
import com.b2international.snowowl.snomed.common.SnomedRf2Headers;
import com.b2international.snowowl.snomed.core.domain.BranchMetadataResolver;
import com.b2international.snowowl.snomed.core.domain.CharacteristicType;
import com.b2international.snowowl.snomed.core.domain.SnomedConcept;
import com.b2international.snowowl.snomed.core.domain.SnomedConcepts;
import com.b2international.snowowl.snomed.core.domain.SnomedDescription;
import com.b2international.snowowl.snomed.core.domain.SnomedRelationship;
import com.b2international.snowowl.snomed.core.domain.SnomedRelationships;
import com.b2international.snowowl.snomed.core.domain.classification.ChangeNature;
import com.b2international.snowowl.snomed.core.domain.classification.RelationshipChange;
import com.b2international.snowowl.snomed.core.domain.classification.RelationshipChanges;
import com.b2international.snowowl.snomed.core.domain.refset.SnomedReferenceSetMember;
import com.b2international.snowowl.snomed.core.domain.refset.SnomedReferenceSetMembers;
import com.b2international.snowowl.snomed.datastore.SnomedDatastoreActivator;
import com.b2international.snowowl.snomed.datastore.config.SnomedCoreConfiguration;
import com.b2international.snowowl.snomed.datastore.id.SnomedIdentifiers;
import com.b2international.snowowl.snomed.datastore.request.SnomedRefSetMemberUpdateRequestBuilder;
import com.b2international.snowowl.snomed.datastore.request.SnomedRelationshipCreateRequestBuilder;
import com.b2international.snowowl.snomed.datastore.request.SnomedRelationshipUpdateRequestBuilder;
import com.b2international.snowowl.snomed.datastore.request.SnomedRequests;
import com.b2international.snowowl.snomed.reasoner.classification.AbstractResponse.Type;
import com.b2international.snowowl.snomed.reasoner.classification.ClassificationSettings;
import com.b2international.snowowl.snomed.reasoner.classification.GetResultResponse;
import com.b2international.snowowl.snomed.reasoner.classification.SnomedExternalReasonerService;
import com.b2international.snowowl.snomed.reasoner.classification.SnomedInternalReasonerService;
import com.b2international.snowowl.snomed.reasoner.classification.SnomedReasonerService;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Function;
import com.google.common.base.Preconditions;
import com.google.common.base.Stopwatch;
import com.google.common.base.Strings;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Multimap;
import com.google.common.collect.Multimaps;
import com.google.common.collect.Sets;
import com.google.common.io.Closeables;

import io.reactivex.disposables.Disposable;

/**
 */
public class SnomedClassificationServiceImpl implements ISnomedClassificationService {

    private static final Logger LOG = LoggerFactory.getLogger(SnomedClassificationServiceImpl.class);

    private static final long BRANCH_READ_TIMEOUT = 5000L;
    private static final long BRANCH_LOCK_TIMEOUT = 500L;

    public static final String CLASSIFIED_ONTOLOGY = "Classified ontology.";

    private final class PersistChangesRunnable implements Runnable {
        private final String branchPath;
        private final String classificationId;
        private final String userId;

        private PersistChangesRunnable(final String branchPath, final String classificationId,
                final String userId) {
            this.branchPath = branchPath;
            this.classificationId = classificationId;
            this.userId = userId;
        }

        @Override
        public void run() {

            final Branch branch = getBranchIfExists(branchPath);
            final IClassificationRun classificationRun = getClassificationRun(branchPath, classificationId);

            if (!ClassificationStatus.COMPLETED.equals(classificationRun.getStatus())) {
                return;
            }

            if (classificationRun.getLastCommitDate() != null
                    && (branch.headTimestamp() > classificationRun.getLastCommitDate().getTime())) {
                updateStatus(classificationId, ClassificationStatus.STALE);
                return;
            } else {
                updateStatus(classificationId, ClassificationStatus.SAVING_IN_PROGRESS);
            }

            final Stopwatch persistStopwatch = Stopwatch.createStarted();

            final String defaultModuleId = BranchMetadataResolver.getEffectiveBranchMetadataValue(branch,
                    SnomedCoreConfiguration.BRANCH_DEFAULT_MODULE_ID_KEY);
            final String defaultNamespace = BranchMetadataResolver.getEffectiveBranchMetadataValue(branch,
                    SnomedCoreConfiguration.BRANCH_DEFAULT_REASONER_NAMESPACE_KEY);

            RelationshipChanges relationshipChanges = getRelationshipChanges(branchPath, classificationId, 0,
                    Integer.MAX_VALUE);

            Set<String> inferredSourceIds = relationshipChanges.getItems().stream()
                    .filter(change -> change.getChangeNature() == ChangeNature.INFERRED)
                    .map(change -> change.getSourceId()).collect(toSet());

            Map<String, String> sourceToModuleMap = SnomedRequests.prepareSearchConcept()
                    .filterByIds(inferredSourceIds).setLimit(inferredSourceIds.size())
                    .build(SnomedDatastoreActivator.REPOSITORY_UUID, branchPath).execute(getBus()).getSync()
                    .stream().collect(toMap(SnomedConcept::getId, SnomedConcept::getModuleId));

            final BulkRequestBuilder<TransactionContext> builder = BulkRequest.create();
            final Set<String> removeOrDeactivateIds = Sets.newHashSet();

            for (RelationshipChange change : relationshipChanges.getItems()) {
                switch (change.getChangeNature()) {
                case INFERRED:
                    builder.add(createInferredRelationship(change, sourceToModuleMap, defaultModuleId,
                            defaultNamespace));
                    break;
                case REDUNDANT:
                    removeOrDeactivateIds.add(change.getId());
                    break;
                default:
                    throw new IllegalStateException(
                            "Unhandled relationship change value '" + change.getChangeNature() + "'.");
                }
            }

            if (!removeOrDeactivateIds.isEmpty()) {
                removeOrDeactivate(builder, defaultModuleId, removeOrDeactivateIds);
            }

            String classifyFeatureToggle = Features
                    .getClassifyFeatureToggle(SnomedDatastoreActivator.REPOSITORY_UUID, branchPath);

            getFeatureToggles().enable(classifyFeatureToggle);

            commitChanges(branchPath, userId, builder, persistStopwatch).then(new Function<CommitResult, Void>() {
                @Override
                public Void apply(final CommitResult input) {
                    LOG.info("Classification changes saved on branch {}.", branchPath);
                    getFeatureToggles().disable(classifyFeatureToggle);
                    return updateStatus(classificationId, ClassificationStatus.SAVED);
                }
            }).fail(new Function<Throwable, Void>() {
                @Override
                public Void apply(final Throwable input) {
                    LOG.error("Failed to save classification changes on branch {}.", branchPath, input);
                    getFeatureToggles().disable(classifyFeatureToggle);
                    return updateStatus(classificationId, ClassificationStatus.SAVE_FAILED);
                }
            }).getSync();
        }

        private FeatureToggles getFeatureToggles() {
            return ApplicationContext.getServiceForClass(FeatureToggles.class);
        }

        private SnomedRelationshipCreateRequestBuilder createInferredRelationship(
                RelationshipChange relationshipChange, final Map<String, String> moduleMap,
                final String defaultModuleId, final String defaultNamespace) {

            // Use module and/or namespace from source concept, if not given
            final String moduleId = (defaultModuleId != null) ? defaultModuleId
                    : moduleMap.get(relationshipChange.getSourceId());

            final String namespace = (defaultNamespace != null) ? defaultNamespace
                    : SnomedIdentifiers.create(relationshipChange.getSourceId()).getNamespace();

            final SnomedRelationshipCreateRequestBuilder inferredRelationshipBuilder = SnomedRequests
                    .prepareNewRelationship().setActive(true)
                    .setCharacteristicType(CharacteristicType.INFERRED_RELATIONSHIP)
                    .setDestinationId(relationshipChange.getDestinationId()).setDestinationNegated(false)
                    .setGroup(relationshipChange.getGroup()).setModifier(relationshipChange.getModifier())
                    .setSourceId(relationshipChange.getSourceId()).setTypeId(relationshipChange.getTypeId())
                    .setUnionGroup(relationshipChange.getUnionGroup()).setModuleId(moduleId)
                    .setIdFromNamespace(namespace);

            return inferredRelationshipBuilder;
        }

        private Promise<CommitResult> commitChanges(final String branchPath, final String userId,
                final BulkRequestBuilder<TransactionContext> builder, final Stopwatch persistStopwatch) {

            return SnomedRequests.prepareCommit().setUserId(userId).setCommitComment(CLASSIFIED_ONTOLOGY) // Same message in PersistChangesRemoteJob
                    .setPreparationTime(persistStopwatch.elapsed(TimeUnit.MILLISECONDS))
                    .setParentContextDescription(DatastoreLockContextDescriptions.CLASSIFY_WITH_REVIEW)
                    .setBody(builder).build(SnomedDatastoreActivator.REPOSITORY_UUID, branchPath).execute(getBus());
        }

        private void removeOrDeactivate(final BulkRequestBuilder<TransactionContext> builder,
                final String defaultModuleId, final Set<String> removeOrDeactivateIds) {

            final SnomedRelationships relationships = SnomedRequests.prepareSearchRelationship()
                    .filterByIds(removeOrDeactivateIds).setLimit(removeOrDeactivateIds.size())
                    .build(SnomedDatastoreActivator.REPOSITORY_UUID, branchPath).execute(getBus()).getSync();

            // handle released relationships
            List<SnomedRelationship> releasedRelationships = relationships.stream()
                    .filter(relationship -> relationship.isReleased()).collect(toList());

            if (!releasedRelationships.isEmpty()) {

                Set<String> releasedRelationshipIds = releasedRelationships.stream().map(SnomedRelationship::getId)
                        .collect(toSet());

                SnomedReferenceSetMembers referringMembers = SnomedRequests.prepareSearchMember().all()
                        .filterByActive(true).filterByReferencedComponent(releasedRelationshipIds)
                        .build(SnomedDatastoreActivator.REPOSITORY_UUID, branchPath).execute(getBus()).getSync();

                Multimap<String, SnomedReferenceSetMember> relationshipToMemberMap = Multimaps
                        .index(referringMembers, SnomedReferenceSetMember::getReferencedComponentId);

                for (SnomedRelationship relationship : releasedRelationships) {

                    for (SnomedReferenceSetMember member : relationshipToMemberMap.get(relationship.getId())) {
                        SnomedRefSetMemberUpdateRequestBuilder updateMemberBuilder = SnomedRequests
                                .prepareUpdateMember().setMemberId(member.getId()).setSource(ImmutableMap
                                        .<String, Object>of(SnomedRf2Headers.FIELD_ACTIVE, Boolean.FALSE));
                        builder.add(updateMemberBuilder);
                    }

                    SnomedRelationshipUpdateRequestBuilder updateRequestBuilder = SnomedRequests
                            .prepareUpdateRelationship(relationship.getId()).setActive(false);

                    if (!Strings.isNullOrEmpty(defaultModuleId)) {
                        updateRequestBuilder.setModuleId(defaultModuleId);
                    }

                    builder.add(updateRequestBuilder);
                }

            }

            // handle unreleased relationships
            relationships.stream().filter(relationship -> !relationship.isReleased()).forEach(
                    relationship -> builder.add(SnomedRequests.prepareDeleteRelationship(relationship.getId())));

        }
    }

    private ClassificationRunIndex indexService;
    private ExecutorService executorService;
    private Disposable remoteJobSubscription;
    private volatile boolean initialized = false;

    @Resource
    private SnomedBrowserService browserService;

    @Resource
    private Integer maxReasonerRuns;

    @Resource
    private IEventBus bus;

    private ObjectMapper mapper;

    @PostConstruct
    protected void init() {

        LOG.info("Initializing classification service; keeping indexed data for {} recent run(s).",
                getMaxReasonerRuns());
        this.mapper = new ObjectMapper();

        final File dir = new File(
                new File(SnowOwlApplication.INSTANCE.getEnviroment().getDataDirectory(), "indexes"),
                "classification_runs");
        indexService = new ClassificationRunIndex(dir, mapper);
        ApplicationContext.getInstance().getServiceChecked(SingleDirectoryIndexManager.class)
                .registerIndex(indexService);

        try {
            indexService.trimIndex(getMaxReasonerRuns());
            indexService.invalidateClassificationRuns();
        } catch (final IOException e) {
            LOG.error("Failed to run housekeeping tasks for the classification index.", e);
        }

        // TODO: common ExecutorService for asynchronous work?
        executorService = Executors.newCachedThreadPool();
        remoteJobSubscription = getNotifications().ofType(RemoteJobNotification.class)
                .subscribe(this::onRemoteJobNotification);

        initialized = true;
    }

    private void checkServices() {
        if (!initialized) {
            init();
        }
    }

    private void onRemoteJobNotification(RemoteJobNotification notification) {

        if (!RemoteJobNotification.isChanged(notification)) {
            return;
        }

        JobRequests.prepareSearch().all().filterByIds(notification.getJobIds()).buildAsync().execute(getBus())
                .then(remoteJobs -> {
                    for (RemoteJobEntry remoteJob : remoteJobs) {
                        onRemoteJobChanged(remoteJob);
                    }
                    return remoteJobs;
                });
    }

    private void onRemoteJobChanged(RemoteJobEntry remoteJob) {
        String type = (String) remoteJob.getParameters(mapper).get("type");

        switch (type) {
        case "ExternalClassifyRequest": // fall through
        case "ClassifyRequest":
            onClassifyJobChanged(remoteJob);
            break;
        default:
            break;
        }
    }

    private void onClassifyJobChanged(RemoteJobEntry remoteJob) {
        checkServices();
        try {

            switch (remoteJob.getState()) {
            case CANCELED:
                indexService.updateClassificationRunStatus(remoteJob.getId(), ClassificationStatus.CANCELED);
                break;
            case FAILED:
                indexService.updateClassificationRunStatus(remoteJob.getId(), ClassificationStatus.FAILED);
                break;
            case FINISHED:
                onClassifyJobFinished(remoteJob);
                break;
            case RUNNING:
                indexService.updateClassificationRunStatus(remoteJob.getId(), ClassificationStatus.RUNNING);
                break;
            case SCHEDULED:
                indexService.updateClassificationRunStatus(remoteJob.getId(), ClassificationStatus.SCHEDULED);
                break;
            case CANCEL_REQUESTED:
                // Nothing to do for this state change
                break;
            default:
                throw new IllegalStateException(
                        MessageFormat.format("Unexpected remote job state ''{0}''.", remoteJob.getState()));
            }

        } catch (final IOException e) {
            LOG.error("Caught IOException while updating classification status.", e);
        }
    }

    private void onClassifyJobFinished(RemoteJobEntry remoteJob) {
        checkServices();
        executorService.submit(new Runnable() {
            @Override
            public void run() {
                try {

                    boolean isExternalClassificationRequest = isExternalClassificationRequest(remoteJob);
                    final GetResultResponse result = getReasonerService(isExternalClassificationRequest)
                            .getResult(remoteJob.getId());
                    final Type responseType = result.getType();

                    switch (responseType) {
                    case NOT_AVAILABLE:
                        indexService.updateClassificationRunStatus(remoteJob.getId(), ClassificationStatus.FAILED);
                        break;
                    case SUCCESS:
                        indexService.updateClassificationRunStatus(remoteJob.getId(),
                                ClassificationStatus.COMPLETED, result.getChanges());
                        break;
                    default:
                        throw new IllegalStateException(
                                MessageFormat.format("Unexpected response type ''{0}''.", responseType));
                    }

                    // Remove reasoner taxonomy immediately after processing it
                    getReasonerService(isExternalClassificationRequest).removeResult(remoteJob.getId());

                } catch (final IOException e) {
                    LOG.error("Caught IOException while registering classification data.", e);
                }
            }

        });
    }

    @SuppressWarnings("unchecked")
    private boolean isExternalClassificationRequest(RemoteJobEntry remoteJobEntry) {
        Map<String, Object> settings = (Map<String, Object>) remoteJobEntry.getParameters(mapper).get("settings");
        return (Boolean) settings.get("useExternalService");
    }

    private void onPersistJobChanged(RemoteJobEntry remoteJob) {
        try {

            String classificationJobId = (String) remoteJob.getParameters(mapper).get("classificationId");

            switch (remoteJob.getState()) {
            case CANCELED: //$FALL-THROUGH$
            case FAILED:
                indexService.updateClassificationRunStatus(classificationJobId, ClassificationStatus.SAVE_FAILED);
                break;
            case FINISHED:
                indexService.updateClassificationRunStatus(classificationJobId, ClassificationStatus.SAVED);
                break;
            case RUNNING: //$FALL-THROUGH$
            case SCHEDULED: //$FALL-THROUGH$
            case CANCEL_REQUESTED:
                // Nothing to do for these state changes
                break;
            default:
                throw new IllegalStateException(
                        MessageFormat.format("Unexpected remote job state ''{0}''.", remoteJob.getState()));
            }

        } catch (final IOException e) {
            LOG.error("Caught IOException while updating classification status after save.", e);
        }
    }

    @PreDestroy
    protected void shutdown() {
        if (null != remoteJobSubscription) {
            remoteJobSubscription.dispose();
            remoteJobSubscription = null;
        }

        if (null != executorService) {
            executorService.shutdown();
            executorService = null;
        }

        if (null != indexService) {
            ApplicationContext.getInstance().getServiceChecked(SingleDirectoryIndexManager.class)
                    .unregisterIndex(indexService);
            try {
                Closeables.close(indexService, true);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
            indexService = null;
        }

        LOG.info("Classification service shut down.");
    }

    private SnomedBrowserService getBrowserService() {
        if (browserService == null) {
            browserService = (SnomedBrowserService) ApplicationContext.getInstance()
                    .getServiceChecked(ISnomedBrowserService.class);
        }
        return Preconditions.checkNotNull(browserService, "browserService cannot be null!");
    }

    private IEventBus getBus() {
        if (bus == null) {
            bus = ApplicationContext.getInstance().getServiceChecked(IEventBus.class);
        }
        return Preconditions.checkNotNull(bus, "bus cannot be null!");
    }

    private Integer getMaxReasonerRuns() {
        if (maxReasonerRuns == null) {
            maxReasonerRuns = ApplicationContext.getInstance().getServiceChecked(SnowOwlConfiguration.class)
                    .getModuleConfig(SnomedCoreConfiguration.class).getClassificationConfig().getMaxReasonerRuns();
        }
        return Preconditions.checkNotNull(maxReasonerRuns, "maximum number of reasoner runs must be configured");
    }

    private Branch getBranchIfExists(final String branchPath) {
        final Branch branch = RepositoryRequests.branching().prepareGet(branchPath)
                .build(SnomedDatastoreActivator.REPOSITORY_UUID).execute(getBus())
                .getSync(BRANCH_READ_TIMEOUT, TimeUnit.MILLISECONDS);

        if (branch.isDeleted()) {
            throw new BadRequestException("Branch '%s' has been deleted and cannot accept further modifications.",
                    branchPath);
        } else {
            return branch;
        }
    }

    private static SnomedReasonerService getReasonerService(boolean isExternalClassificationRequest) {
        if (isExternalClassificationRequest) {
            return ApplicationContext.getServiceForClass(SnomedExternalReasonerService.class);
        }
        return ApplicationContext.getServiceForClass(SnomedInternalReasonerService.class);
    }

    private static Notifications getNotifications() {
        return ApplicationContext.getServiceForClass(Notifications.class);
    }

    @Override
    public List<IClassificationRun> getAllClassificationRuns(final String branchPath) {
        checkServices();
        getBranchIfExists(branchPath);
        try {
            return indexService.getAllClassificationRuns(branchPath);
        } catch (final IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public IClassificationRun beginClassification(final String branchPath, final String reasonerId,
            final boolean useExternalService, final String userId) {
        checkServices();
        Branch branch = getBranchIfExists(branchPath);

        final ClassificationSettings settings = new ClassificationSettings(userId,
                BranchPathUtils.createPath(branchPath))
                        .withParentContextDescription(DatastoreLockContextDescriptions.ROOT)
                        .withExternalService(useExternalService).withReasonerId(reasonerId);

        final ClassificationRun classificationRun = new ClassificationRun();
        classificationRun.setId(settings.getClassificationId());
        classificationRun.setReasonerId(reasonerId);
        classificationRun.setLastCommitDate(new Date(branch.headTimestamp()));
        classificationRun.setCreationDate(new Date());
        classificationRun.setUserId(userId);
        classificationRun.setStatus(ClassificationStatus.SCHEDULED);

        try {
            indexService.upsertClassificationRun(branchPath, classificationRun);
        } catch (final IOException e) {
            throw new RuntimeException(e);
        }

        getReasonerService(useExternalService).beginClassification(settings);
        return classificationRun;
    }

    @Override
    public IClassificationRun getClassificationRun(final String branchPath, final String classificationId) {
        checkServices();
        try {
            return indexService.getClassificationRun(branchPath, classificationId);
        } catch (final IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public List<IEquivalentConceptSet> getEquivalentConceptSets(final String branchPath,
            final String classificationId, final List<ExtendedLocale> locales) {
        checkServices();
        getClassificationRun(branchPath, classificationId);

        try {
            final List<IEquivalentConceptSet> conceptSets = indexService.getEquivalentConceptSets(branchPath,
                    classificationId);
            final Set<String> conceptIds = newHashSet();

            for (final IEquivalentConceptSet conceptSet : conceptSets) {
                for (final IEquivalentConcept equivalentConcept : conceptSet.getEquivalentConcepts()) {
                    conceptIds.add(equivalentConcept.getId());
                }
            }

            final Map<String, SnomedDescription> fsnMap = new DescriptionService(getBus(), branchPath)
                    .getFullySpecifiedNames(conceptIds, locales);
            for (final IEquivalentConceptSet conceptSet : conceptSets) {
                for (final IEquivalentConcept equivalentConcept : conceptSet.getEquivalentConcepts()) {
                    final String equivalentConceptId = equivalentConcept.getId();
                    final SnomedDescription fsn = fsnMap.get(equivalentConceptId);
                    if (fsn != null) {
                        ((EquivalentConcept) equivalentConcept).setLabel(fsn.getTerm());
                    } else {
                        ((EquivalentConcept) equivalentConcept).setLabel(equivalentConceptId);
                    }
                }
            }

            return conceptSets;
        } catch (final IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public RelationshipChanges getRelationshipChanges(final String branchPath, final String classificationId,
            final int offset, final int limit) {
        return getRelationshipChanges(branchPath, classificationId, null, offset, limit);
    }

    private RelationshipChanges getRelationshipChanges(String branchPath, String classificationId, String conceptId,
            int offset, int limit) {
        checkServices();
        getClassificationRun(branchPath, classificationId);
        try {
            return indexService.getRelationshipChanges(branchPath, classificationId, conceptId, offset, limit);
        } catch (final IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public ISnomedBrowserConcept getConceptPreview(String branchPath, String classificationId, String conceptId,
            List<ExtendedLocale> locales) {
        final SnomedBrowserConcept conceptDetails = (SnomedBrowserConcept) getBrowserService()
                .getConceptDetails(branchPath, conceptId, locales);

        final List<ISnomedBrowserRelationship> relationships = Lists
                .newArrayList(conceptDetails.getRelationships());
        final RelationshipChanges relationshipChanges = getRelationshipChanges(branchPath, classificationId,
                conceptId, 0, 10000);

        /* 
         * XXX: We don't want to match anything that is part of the inferred set below, so we remove relationships from the existing list, 
         * all in advance. (Revisit should this assumption prove to be incorrect.)
         */
        for (RelationshipChange relationshipChange : relationshipChanges.getItems()) {
            switch (relationshipChange.getChangeNature()) {
            case REDUNDANT:
                relationships.remove(findRelationship(relationships, relationshipChange));
                break;
            default:
                break;
            }
        }

        // Collect all concept representations that will be required for the conversion
        final Set<String> relatedIds = Sets.newHashSet();
        for (RelationshipChange relationshipChange : relationshipChanges.getItems()) {
            switch (relationshipChange.getChangeNature()) {
            case INFERRED:
                relatedIds.add(relationshipChange.getDestinationId());
                relatedIds.add(relationshipChange.getTypeId());
                break;
            default:
                break;
            }
        }

        final SnomedConcepts relatedConcepts = SnomedRequests.prepareSearchConcept().setLimit(relatedIds.size())
                .filterByIds(relatedIds).setLocales(locales).setExpand("fsn()")
                .build(SnomedDatastoreActivator.REPOSITORY_UUID, branchPath).execute(getBus()).getSync();

        final Map<String, SnomedConcept> relatedConceptsById = Maps.uniqueIndex(relatedConcepts,
                input -> input.getId());

        final LoadingCache<SnomedConcept, SnomedBrowserRelationshipType> types = CacheBuilder.newBuilder()
                .build(new CacheLoader<SnomedConcept, SnomedBrowserRelationshipType>() {
                    @Override
                    public SnomedBrowserRelationshipType load(SnomedConcept key) throws Exception {
                        return getBrowserService().convertBrowserRelationshipType(key);
                    }
                });

        final LoadingCache<SnomedConcept, SnomedBrowserRelationshipTarget> targets = CacheBuilder.newBuilder()
                .build(new CacheLoader<SnomedConcept, SnomedBrowserRelationshipTarget>() {
                    @Override
                    public SnomedBrowserRelationshipTarget load(SnomedConcept key) throws Exception {
                        return getBrowserService().convertBrowserRelationshipTarget(key);
                    }
                });

        for (RelationshipChange relationshipChange : relationshipChanges.getItems()) {
            switch (relationshipChange.getChangeNature()) {
            case INFERRED:
                final SnomedBrowserRelationship inferred = new SnomedBrowserRelationship();

                // XXX: Default and/or not populated values are shown as commented lines below
                inferred.setActive(true);
                inferred.setCharacteristicType(CharacteristicType.INFERRED_RELATIONSHIP);
                // inferred.setEffectiveTime(null);
                inferred.setGroupId(relationshipChange.getGroup());
                inferred.setModifier(relationshipChange.getModifier());
                // inferred.setModuleId(null);
                // inferred.setRelationshipId(null);
                // inferred.setReleased(false);
                inferred.setSourceId(relationshipChange.getSourceId());

                SnomedConcept destinationConcept = relatedConceptsById.get(relationshipChange.getDestinationId());
                SnomedConcept typeConcept = relatedConceptsById.get(relationshipChange.getTypeId());
                inferred.setTarget(targets.getUnchecked(destinationConcept));
                inferred.setType(types.getUnchecked(typeConcept));

                relationships.add(inferred);
                break;
            default:
                break;
            }
        }

        // Replace immutable relationship list with preview
        conceptDetails.setRelationships(relationships);
        return conceptDetails;
    }

    private ISnomedBrowserRelationship findRelationship(List<ISnomedBrowserRelationship> relationships,
            RelationshipChange relationshipChange) {
        for (ISnomedBrowserRelationship relationship : relationships) {
            if (relationship.isActive() && relationship.getSourceId().equals(relationshipChange.getSourceId())
                    && relationship.getType().getConceptId().equals(relationshipChange.getTypeId())
                    && relationship.getTarget().getConceptId().equals(relationshipChange.getDestinationId())
                    && relationship.getGroupId() == relationshipChange.getGroup()
                    && relationship.getCharacteristicType().equals(CharacteristicType.INFERRED_RELATIONSHIP)
                    && relationship.getModifier().equals(relationshipChange.getModifier())) {
                return relationship;
            }
        }
        return null;
    }

    @Override
    public void persistChanges(final String branchPath, final String classificationId, final String userId) {
        checkServices();
        IClassificationRun classificationRun = getClassificationRun(branchPath, classificationId);

        if (ClassificationStatus.COMPLETED.equals(classificationRun.getStatus())) {

            final DatastoreLockContext context = new DatastoreLockContext(userId,
                    DatastoreLockContextDescriptions.CLASSIFY_WITH_REVIEW);
            final IOperationLockTarget target = new SingleRepositoryAndBranchLockTarget(
                    SnomedDatastoreActivator.REPOSITORY_UUID, BranchPathUtils.createPath(branchPath));

            executorService.execute(new Runnable() {
                @Override
                public void run() {
                    try {
                        OperationLockRunner.with(getLockManager()).run(
                                new PersistChangesRunnable(branchPath, classificationId, userId), context,
                                BRANCH_LOCK_TIMEOUT, target);
                    } catch (DatastoreOperationLockException e) {
                        final DatastoreLockContext otherContext = e.getContext(target);
                        throw new ConflictException(
                                "Failed to acquire or release lock for branch %s because %s is %s.", branchPath,
                                otherContext.getUserId(), otherContext.getDescription());
                    } catch (OperationLockException e) {
                        throw new ConflictException("Failed to acquire or release lock for branch %s.", branchPath);
                    } catch (InvocationTargetException e) {
                        LOG.error("Caught exception while persisting changes for ID {}.", classificationId, e);
                        updateStatus(classificationId, ClassificationStatus.SAVE_FAILED);
                    } catch (InterruptedException e) {
                        throw new ConflictException("Interrupted while acquiring or releasing lock for branch %s.",
                                branchPath);
                    }
                }
            });
        }

    }

    private static IDatastoreOperationLockManager getLockManager() {
        return ApplicationContext.getServiceForClass(IDatastoreOperationLockManager.class);
    }

    private Void updateStatus(final String id, final ClassificationStatus status) {
        try {
            indexService.updateClassificationRunStatus(id, status);
            return null;
        } catch (final IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void removeClassificationRun(final String branchPath, final String classificationId) {
        checkServices();
        JobRequests.prepareDelete(classificationId).buildAsync().execute(getBus()).then(ignored -> {
            try {
                indexService.deleteClassificationData(classificationId);
            } catch (IOException e) {
                LOG.error("Caught IOException while deleting classification data for ID {}.", classificationId, e);
            }
            return ignored;
        }).getSync();

    }

}