All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.opensearch.jobscheduler.spi.utils.LockService Maven / Gradle / Ivy

There is a newer version: 2.18.0.0
Show newest version
/*
 * Copyright OpenSearch Contributors
 * SPDX-License-Identifier: Apache-2.0
 *
 * The OpenSearch Contributors require contributions made to
 * this file be licensed under the Apache-2.0 license or a
 * compatible open source license.
 */
package org.opensearch.jobscheduler.spi.utils;

import org.opensearch.jobscheduler.spi.JobExecutionContext;
import org.opensearch.jobscheduler.spi.LockModel;
import org.opensearch.jobscheduler.spi.ScheduledJobParameter;
import com.cronutils.utils.VisibleForTesting;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.opensearch.ResourceAlreadyExistsException;
import org.opensearch.action.ActionListener;
import org.opensearch.action.DocWriteResponse;
import org.opensearch.action.admin.indices.create.CreateIndexRequest;
import org.opensearch.action.delete.DeleteRequest;
import org.opensearch.action.get.GetRequest;
import org.opensearch.action.index.IndexRequest;
import org.opensearch.action.update.UpdateRequest;
import org.opensearch.client.Client;
import org.opensearch.cluster.service.ClusterService;
import org.opensearch.common.xcontent.LoggingDeprecationHandler;
import org.opensearch.core.xcontent.NamedXContentRegistry;
import org.opensearch.core.xcontent.ToXContent;
import org.opensearch.common.xcontent.XContentFactory;
import org.opensearch.core.xcontent.XContentParser;
import org.opensearch.common.xcontent.XContentType;
import org.opensearch.index.IndexNotFoundException;
import org.opensearch.index.engine.DocumentMissingException;
import org.opensearch.index.engine.VersionConflictEngineException;
import org.opensearch.index.seqno.SequenceNumbers;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.Instant;

public final class LockService {
    private static final Logger logger = LogManager.getLogger(LockService.class);
    private static final String LOCK_INDEX_NAME = ".opendistro-job-scheduler-lock";

    private final Client client;
    private final ClusterService clusterService;

    // This is used in tests to control time.
    private Instant testInstant = null;

    public LockService(final Client client, final ClusterService clusterService) {
        this.client = client;
        this.clusterService = clusterService;
    }

    private String lockMapping() {
        try {
            InputStream in = LockService.class.getResourceAsStream("opensearch_job_scheduler_lock.json");
            StringBuilder stringBuilder = new StringBuilder();
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
            for (String line; (line = bufferedReader.readLine()) != null;) {
                stringBuilder.append(line);
            }
            return stringBuilder.toString();
        } catch (IOException e) {
            throw new IllegalArgumentException("Lock Mapping cannot be read correctly.");
        }
    }

    public boolean lockIndexExist() {
        return clusterService.state().routingTable().hasIndex(LOCK_INDEX_NAME);
    }

    @VisibleForTesting
    void createLockIndex(ActionListener listener) {
        if (lockIndexExist()) {
            listener.onResponse(true);
        } else {
            final CreateIndexRequest request = new CreateIndexRequest(LOCK_INDEX_NAME).mapping(lockMapping());
            client.admin()
                .indices()
                .create(request, ActionListener.wrap(response -> listener.onResponse(response.isAcknowledged()), exception -> {
                    if (exception instanceof ResourceAlreadyExistsException
                        || exception.getCause() instanceof ResourceAlreadyExistsException) {
                        listener.onResponse(true);
                    } else {
                        listener.onFailure(exception);
                    }
                }));
        }
    }

    /**
     * Attempts to acquire lock the job. If the lock does not exists it attempts to create the lock document.
     * If the Lock document exists, it will try to update and acquire lock.
     *
     * @param jobParameter a {@code ScheduledJobParameter} containing the lock duration.
     * @param context a {@code JobExecutionContext} containing job index name and job id.
     * @param listener an {@code ActionListener} that has onResponse and onFailure that is used to return the lock if it was acquired
     *                 or else null. Passes {@code IllegalArgumentException} to onFailure if the {@code ScheduledJobParameter} does not
     *                 have {@code LockDurationSeconds}.
     */
    public void acquireLock(
        final ScheduledJobParameter jobParameter,
        final JobExecutionContext context,
        ActionListener listener
    ) {
        final String jobIndexName = context.getJobIndexName();
        final String jobId = context.getJobId();
        final long lockDurationSeconds = jobParameter.getLockDurationSeconds();
        acquireLockWithId(jobIndexName, lockDurationSeconds, jobId, listener);
    }

    /**
     * Attempts to acquire a lock with a specific lock Id. If the lock does not exist it attempts to create the lock document.
     * If the Lock document exists, it will try to update and acquire the lock.
     *
     * @param jobIndexName a non-null job index name.
     * @param lockDurationSeconds the amount of time in seconds that the lock should exist
     * @param lockId the unique Id for the lock. This should represent the resource that the lock is on, whether it be
     *               a job, or some other arbitrary resource. If the lockID matches a jobID, then the lock will be deleted
     *               when the job is deleted.
     * @param listener an {@code ActionListener} that has onResponse and onFailure that is used to return the lock if it was acquired
     *                 or else null. Passes {@code IllegalArgumentException} to onFailure if the {@code ScheduledJobParameter} does not
     *                 have {@code LockDurationSeconds}.
     */
    public void acquireLockWithId(
        final String jobIndexName,
        final Long lockDurationSeconds,
        final String lockId,
        ActionListener listener
    ) {
        if (lockDurationSeconds == null) {
            listener.onFailure(new IllegalArgumentException("Job LockDuration should not be null"));
        } else if (jobIndexName == null) {
            listener.onFailure(new IllegalArgumentException("Job index name should not be null"));
        } else if (lockId == null) {
            listener.onFailure(new IllegalArgumentException("Lock ID should not be null"));
        } else {
            createLockIndex(ActionListener.wrap(created -> {
                if (created) {
                    try {
                        findLock(LockModel.generateLockId(jobIndexName, lockId), ActionListener.wrap(existingLock -> {
                            if (existingLock != null) {
                                if (isLockReleasedOrExpired(existingLock)) {
                                    // Lock is expired. Attempt to acquire lock.
                                    logger.debug("lock is released or expired: " + existingLock);
                                    LockModel updateLock = new LockModel(existingLock, getNow(), lockDurationSeconds, false);
                                    updateLock(updateLock, listener);
                                } else {
                                    logger.debug("Lock is NOT released or expired. " + existingLock);
                                    // Lock is still not expired. Return null as we cannot acquire lock.
                                    listener.onResponse(null);
                                }
                            } else {
                                // There is no lock object and it is first time. Create new lock.
                                // Note that the lockID will be set to {jobIndexName}-{lockId}
                                LockModel tempLock = new LockModel(jobIndexName, lockId, getNow(), lockDurationSeconds, false);
                                logger.debug("Lock does not exist. Creating new lock" + tempLock);
                                createLock(tempLock, listener);
                            }
                        }, listener::onFailure));
                    } catch (VersionConflictEngineException e) {
                        logger.debug("could not acquire lock {}", e.getMessage());
                        listener.onResponse(null);
                    }
                } else {
                    listener.onResponse(null);
                }
            }, listener::onFailure));
        }
    }

    private boolean isLockReleasedOrExpired(final LockModel lock) {
        return lock.isReleased() || lock.isExpired();
    }

    private void updateLock(final LockModel updateLock, ActionListener listener) {
        try {
            UpdateRequest updateRequest = new UpdateRequest().index(LOCK_INDEX_NAME)
                .id(updateLock.getLockId())
                .setIfSeqNo(updateLock.getSeqNo())
                .setIfPrimaryTerm(updateLock.getPrimaryTerm())
                .doc(updateLock.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS))
                .fetchSource(true);

            client.update(
                updateRequest,
                ActionListener.wrap(
                    response -> listener.onResponse(new LockModel(updateLock, response.getSeqNo(), response.getPrimaryTerm())),
                    exception -> {
                        if (exception instanceof VersionConflictEngineException) {
                            logger.debug("could not acquire lock {}", exception.getMessage());
                        }
                        if (exception instanceof DocumentMissingException) {
                            logger.debug(
                                "Document is deleted. This happens if the job is already removed and" + " this is the last run." + "{}",
                                exception.getMessage()
                            );
                        }
                        if (exception instanceof IOException) {
                            logger.error("IOException occurred updating lock.", exception);
                        }
                        listener.onResponse(null);
                    }
                )
            );
        } catch (IOException e) {
            logger.error("IOException occurred updating lock.", e);
            listener.onResponse(null);
        }
    }

    private void createLock(final LockModel tempLock, ActionListener listener) {
        try {
            final IndexRequest request = new IndexRequest(LOCK_INDEX_NAME).id(tempLock.getLockId())
                .source(tempLock.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS))
                .setIfSeqNo(SequenceNumbers.UNASSIGNED_SEQ_NO)
                .setIfPrimaryTerm(SequenceNumbers.UNASSIGNED_PRIMARY_TERM)
                .create(true);
            client.index(
                request,
                ActionListener.wrap(
                    response -> listener.onResponse(new LockModel(tempLock, response.getSeqNo(), response.getPrimaryTerm())),
                    exception -> {
                        if (exception instanceof VersionConflictEngineException) {
                            logger.debug("Lock is already created. {}", exception.getMessage());
                        }
                        if (exception instanceof IOException) {
                            logger.error("IOException occurred creating lock", exception);
                        }
                        listener.onResponse(null);
                    }
                )
            );
        } catch (IOException e) {
            logger.error("IOException occurred creating lock", e);
            listener.onResponse(null);
        }
    }

    public void findLock(final String lockId, ActionListener listener) {
        GetRequest getRequest = new GetRequest(LOCK_INDEX_NAME).id(lockId);
        client.get(getRequest, ActionListener.wrap(response -> {
            if (!response.isExists()) {
                listener.onResponse(null);
            } else {
                try {
                    XContentParser parser = XContentType.JSON.xContent()
                        .createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, response.getSourceAsString());
                    parser.nextToken();
                    listener.onResponse(LockModel.parse(parser, response.getSeqNo(), response.getPrimaryTerm()));
                } catch (IOException e) {
                    logger.error("IOException occurred finding lock", e);
                    listener.onResponse(null);
                }
            }
        }, exception -> {
            logger.error("Exception occurred finding lock", exception);
            listener.onFailure(exception);
        }));
    }

    /**
     * Attempt to release the lock.
     * Most failure cases are due to {@code lock.seqNo} and {@code lock.primaryTerm} not matching with the existing
     * document.
     *
     * @param lock a {@code LockModel} to be released.
     * @param listener a {@code ActionListener} that has onResponse and onFailure that is used to return whether
     *                 or not the release was successful
     */
    public void release(final LockModel lock, ActionListener listener) {
        if (lock == null) {
            logger.debug("Lock is null. Nothing to release.");
            listener.onResponse(false);
        } else {
            logger.debug("Releasing lock: " + lock);
            final LockModel lockToRelease = new LockModel(lock, true);
            updateLock(lockToRelease, ActionListener.wrap(releasedLock -> listener.onResponse(releasedLock != null), listener::onFailure));
        }
    }

    /**
     * Attempt to delete lock.
     * This should be called as part of clean up when the job for corresponding lock is deleted.
     *
     * @param lockId a {@code String} to be deleted.
     * @param listener an {@code ActionListener} that has onResponse and onFailure that is used to return whether
     *                 or not the delete was successful
     */
    public void deleteLock(final String lockId, ActionListener listener) {
        DeleteRequest deleteRequest = new DeleteRequest(LOCK_INDEX_NAME).id(lockId);
        client.delete(deleteRequest, ActionListener.wrap(response -> {
            listener.onResponse(
                response.getResult() == DocWriteResponse.Result.DELETED || response.getResult() == DocWriteResponse.Result.NOT_FOUND
            );
        }, exception -> {
            if (exception instanceof IndexNotFoundException || exception.getCause() instanceof IndexNotFoundException) {
                logger.debug("Index is not found to delete lock. {}", exception.getMessage());
                listener.onResponse(true);
            } else {
                listener.onFailure(exception);
            }
        }));
    }

    /**
     * Attempt to renew a lock.
     * It is used to give an extended valid period to a lock. The start time of the lock will be updated to
     * the current time when the method get called, and the duration of the lock remains.
     * It works as long as the lock is not acquired by others, and no matter if the lock is expired of not.
     *
     * @param lock a {@code LockModel} to be renewed.
     * @param listener a {@code ActionListener} that has onResponse and onFailure that is used to
     *                 return the renewed lock if renewal succeed, otherwise return null.
     */
    public void renewLock(final LockModel lock, ActionListener listener) {
        if (lock == null) {
            logger.debug("Lock is null. Nothing to renew.");
            listener.onResponse(null);
        } else {
            logger.debug(
                "Renewing lock: {}. The lock was acquired or renewed on: {}, and the duration was {} sec.",
                lock,
                lock.getLockTime(),
                lock.getLockDurationSeconds()
            );
            final LockModel lockToRenew = new LockModel(lock, getNow(), lock.getLockDurationSeconds(), false);
            updateLock(lockToRenew, ActionListener.wrap(renewedLock -> {
                logger.debug(
                    "Renewed lock: {}. It is supposed to be valid for another {} sec from {}.",
                    renewedLock,
                    renewedLock.getLockDurationSeconds(),
                    renewedLock.getLockTime()
                );
                listener.onResponse(renewedLock);
            }, exception -> {
                logger.debug("Failed to renew lock: {}.", lock);
                listener.onFailure(exception);
            }));
        }
    }

    private Instant getNow() {
        return testInstant != null ? testInstant : Instant.now();
    }

    @VisibleForTesting
    void setTime(final Instant testInstant) {
        this.testInstant = testInstant;
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy