SearchIndexEventHandler.java

/*
 * Copyright (C) 2024 B3Partners B.V.
 *
 * SPDX-License-Identifier: MIT
 */
package org.tailormap.api.repository.events;

import java.lang.invoke.MethodHandles;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import org.quartz.JobDataMap;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.impl.matchers.GroupMatcher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.rest.core.annotation.HandleAfterDelete;
import org.springframework.data.rest.core.annotation.HandleBeforeSave;
import org.springframework.data.rest.core.annotation.RepositoryEventHandler;
import org.springframework.stereotype.Component;
import org.tailormap.api.persistence.SearchIndex;
import org.tailormap.api.scheduling.IndexTask;
import org.tailormap.api.scheduling.TMJobDataMap;
import org.tailormap.api.scheduling.Task;
import org.tailormap.api.scheduling.TaskManagerService;
import org.tailormap.api.scheduling.TaskType;

/**
 * Event handler for Solr indexes; when a {@code SearchIndex} is created, updated or deleted a {@code Task} is
 * associated.
 */
@Component
@RepositoryEventHandler
public class SearchIndexEventHandler {
  private static final Logger logger =
      LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());

  private final Scheduler scheduler;
  private final TaskManagerService taskManagerService;

  public SearchIndexEventHandler(@Autowired Scheduler scheduler, @Autowired TaskManagerService taskManagerService) {
    this.scheduler = scheduler;
    this.taskManagerService = taskManagerService;
  }

  /**
   * Handle after delete. Delete any associated task.
   *
   * @param searchIndex the search index that was deleted
   */
  @HandleAfterDelete
  public void afterDeleteSearchIndexEventHandler(SearchIndex searchIndex) throws SchedulerException {
    if (null != searchIndex.getSchedule()) {
      JobKey jobKey = taskManagerService.getJobKey(
          TaskType.INDEX, searchIndex.getSchedule().getUuid());

      if (null != jobKey && scheduler.checkExists(jobKey)) {
        logger.info(
            "Deleting index task {} associated with search index: {}",
            searchIndex.getSchedule().getUuid(),
            searchIndex.getName());
        boolean succes = scheduler.deleteJob(jobKey);
        logger.info(
            "Task {}:{} deletion {}",
            jobKey.getGroup(),
            jobKey.getName(),
            (succes ? "succeeded" : "failed"));
      }
    }
  }

  /**
   * Handle before save. Create or update the associated task.
   *
   * @param searchIndex the search index that was saved
   * @throws SchedulerException if the task could not be created or updated
   */
  @HandleBeforeSave
  public void beforeSaveSearchIndexEventHandler(SearchIndex searchIndex) throws SchedulerException {
    // TODO we don't handle the case where the schedule is null here; we would need to determine if
    //   a task exists that was associated with the search index before;
    //   this case can already be handled requesting a delete of the scheduled task instead
    if (null != searchIndex.getSchedule()) {
      if (null == searchIndex.getSchedule().getUuid()) {
        validateNoTaskExistsForIndex(searchIndex);
        // no task exists yet, create one
        logger.info("Creating new task associated with search index: {}", searchIndex.getName());
        TMJobDataMap jobDataMap = new TMJobDataMap(Map.of(
            Task.TYPE_KEY,
            TaskType.INDEX,
            Task.DESCRIPTION_KEY,
            searchIndex.getSchedule().getDescription(),
            IndexTask.INDEX_KEY,
            searchIndex.getId().toString()));
        if (null != searchIndex.getSchedule().getPriority()
            && searchIndex.getSchedule().getPriority() > 0) {
          jobDataMap.put(Task.PRIORITY_KEY, searchIndex.getSchedule().getPriority());
        }
        final UUID uuid = taskManagerService.createTask(
            IndexTask.class, jobDataMap, searchIndex.getSchedule().getCronExpression());
        searchIndex.getSchedule().setUuid(uuid);
      } else {
        // UUID given, task should exist; update it
        logger.info(
            "Updating task {} associated with search index: {}",
            searchIndex.getSchedule().getUuid(),
            searchIndex.getName());

        JobKey jobKey = taskManagerService.getJobKey(
            TaskType.INDEX, searchIndex.getSchedule().getUuid());
        if (null != jobKey && scheduler.checkExists(jobKey)) {
          // the only things that may have changed are the cron expression, priority and description
          JobDataMap jobDataMap = scheduler.getJobDetail(jobKey).getJobDataMap();
          jobDataMap.put(
              Task.DESCRIPTION_KEY, searchIndex.getSchedule().getDescription());
          jobDataMap.put(
              Task.CRON_EXPRESSION_KEY, searchIndex.getSchedule().getCronExpression());
          if (null != searchIndex.getSchedule().getPriority()
              && searchIndex.getSchedule().getPriority() > 0) {
            jobDataMap.put(
                Task.PRIORITY_KEY, searchIndex.getSchedule().getPriority());
          }

          taskManagerService.updateTask(jobKey, new TMJobDataMap(jobDataMap));
        }
      }
    }
  }

  /**
   * Validate that no scheduled task exists for the given index.
   *
   * @param searchIndex the search index to validate for scheduling a task
   * @throws SchedulerException if there is a task that is already associated with the given index
   */
  private void validateNoTaskExistsForIndex(SearchIndex searchIndex) throws SchedulerException {
    Optional<JobDataMap> jobDataMapOptional =
        scheduler.getJobKeys(GroupMatcher.groupEquals(TaskType.INDEX.getValue())).stream()
            .map(jobKey -> {
              try {
                return scheduler.getJobDetail(jobKey).getJobDataMap();
              } catch (SchedulerException e) {
                logger.error("Error getting task detail", e);
                return null;
              }
            })
            .filter(Objects::nonNull)
            .filter(jobDataMap -> searchIndex.getId().equals(jobDataMap.getLongValue(IndexTask.INDEX_KEY)))
            .findFirst();

    if (jobDataMapOptional.isPresent()) {
      logger.warn("A scheduled task already exists for search index: {}", searchIndex.getName());
      throw new SchedulerException(
          "A scheduled task already exists for search index: '%s'".formatted(searchIndex.getName()));
    }
  }
}