BackupScheduler.java
/*
 * Copyright (c) 2007-2024 MetaSolutions AB
 *
 * 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 org.entrystore.repository.backup;
import lombok.Getter;
import org.eclipse.rdf4j.rio.RDFFormat;
import org.entrystore.config.Config;
import org.entrystore.repository.RepositoryManager;
import org.entrystore.repository.config.Settings;
import org.entrystore.repository.util.MetadataUtil;
import org.quartz.CronTrigger;
import org.quartz.JobDetail;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.impl.StdSchedulerFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.text.ParseException;
import java.util.LinkedList;
import java.util.OptionalInt;
import java.util.concurrent.ThreadLocalRandom;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
 * Supports continuously backing up the repository.
 *
 * @author Hannes Ebner
 */
public class BackupScheduler {
	private static final Logger log = LoggerFactory.getLogger(BackupScheduler.class);
	Scheduler scheduler;
	JobDetail job;
	RepositoryManager rm;
	boolean gzip;
	@Getter
	String cronExpression;
	boolean maintenance;
	int upperLimit;
	int lowerLimit;
	int expiresAfterDays;
	boolean simple;
	boolean deleteAfter;
	boolean includeFiles;
	RDFFormat format;
	public static BackupScheduler instance;
	private BackupScheduler(RepositoryManager rm, String cronExp, boolean gzip, boolean deleteAfter, boolean includeFiles, boolean maintenance, int upperLimit, int lowerLimit, int expiresAfterDays, RDFFormat format) {
		try {
			scheduler = StdSchedulerFactory.getDefaultScheduler();
		} catch (SchedulerException e) {
			log.error(e.getMessage());
		}
		this.rm = rm;
		this.gzip = gzip;
		this.deleteAfter = deleteAfter;
		this.cronExpression = cronExp;
		this.includeFiles = includeFiles;
		this.maintenance = maintenance;
		this.upperLimit = upperLimit;
		this.lowerLimit = lowerLimit;
		this.expiresAfterDays = expiresAfterDays;
		this.format = format;
		if (upperLimit < 2 && lowerLimit < 2 && expiresAfterDays < 2) {
			log.info("Switching to simple backup strategy with one folder without date and time in name");
			this.simple = true;
			this.maintenance = false;
		}
		log.info("Created backup scheduler");
	}
	public static synchronized BackupScheduler getInstance(RepositoryManager rm) {
		if (instance == null) {
			log.info("Loading backup configuration");
			Config config = rm.getConfiguration();
			String cronExp = config.getString(Settings.BACKUP_CRONEXP, config.getString(Settings.BACKUP_TIMEREGEXP_DEPRECATED));
			if (cronExp == null) {
				return null;
			}
			boolean gzip = config.getBoolean(Settings.BACKUP_GZIP, false);
			boolean maintenance = config.getBoolean(Settings.BACKUP_MAINTENANCE, false);
			boolean deleteAfter = config.getBoolean(Settings.BACKUP_DELETE_AFTER, false);
			boolean includeFiles = config.getBoolean(Settings.BACKUP_INCLUDE_FILES, true);
			int upperLimit = config.getInt(Settings.BACKUP_MAINTENANCE_UPPER_LIMIT, -1);
			int lowerLimit = config.getInt(Settings.BACKUP_MAINTENANCE_LOWER_LIMIT, -1);
			int expiresAfterDays = config.getInt(Settings.BACKUP_MAINTENANCE_EXPIRES_AFTER_DAYS, -1);
			RDFFormat format = MetadataUtil.getRDFFormat(config.getString(Settings.BACKUP_FORMAT, RDFFormat.TRIX.getName()));
			if (format == null) {
				log.warn("Invalid backup format {}, falling back to TriX", config.getString(Settings.BACKUP_FORMAT));
				format = RDFFormat.TRIX;
			}
			if (cronExp.toLowerCase().contains("rnd")) {
				cronExp = randomizeCronString(cronExp);
			}
			log.info("Cron expression: {}", cronExp);
			log.info("GZIP: {}", gzip);
			log.info("Include files: {}", includeFiles);
			log.info("Delete previous backup after new backup: {}", deleteAfter);
			log.info("Maintenance: {}", maintenance);
			log.info("Maintenance upper limit: {}", upperLimit);
			log.info("Maintenance lower limit: {}", lowerLimit);
			log.info("Maintenance expires after days: {}", expiresAfterDays);
			instance = new BackupScheduler(rm, cronExp, gzip, deleteAfter, includeFiles, maintenance, upperLimit, lowerLimit, expiresAfterDays, format);
		}
		return instance;
	}
	private static String randomizeCronString(String cronExp) {
		String[] parts = cronExp.split("\\s+");
		if (parts.length < 6) {
			log.warn("Cron expression seems to be incorrect and cannot be parsed correctly: {}", cronExp);
			return cronExp;
		}
		LinkedList<String> result = new LinkedList<>();
		Pattern pattern = Pattern.compile("rnd\\(([\\*0-9]+)[\\-]?([0-9]*)\\)", Pattern.CASE_INSENSITIVE);
		for (int i = 0; i < parts.length; i++) {
			String p = parts[i];
			Matcher matcher = pattern.matcher(p);
			if (!matcher.matches()) {
				result.add(p);
			} else {
				String first = matcher.group(1);
				String second = matcher.group(2);
				if ("*".equals(first)) {
					if (i == 0 || i == 1) {
						// second or minute
						OptionalInt findFirst = ThreadLocalRandom.current().ints(0, 60).limit(1).findFirst();
						if (findFirst.isPresent()) {
							result.add(Integer.toString(findFirst.getAsInt()));
						}
					} else if (i == 2) {
						// hour
						OptionalInt findFirst = ThreadLocalRandom.current().ints(0, 24).limit(1).findFirst();
						if (findFirst.isPresent()) {
							result.add(Integer.toString(findFirst.getAsInt()));
						}
					} else {
						result.add(first);
					}
				} else if (isInt(first) && isInt(second)) {
					int i1 = Integer.parseInt(first);
					int i2 = Integer.parseInt(second);
					if (i == 0 || i == 1) {
						// second or minute
						OptionalInt findFirst = ThreadLocalRandom.current().ints(i1, i2 + 1).limit(1).findFirst();
						if (findFirst.isPresent()) {
							result.add(Integer.toString(findFirst.getAsInt()));
						}
					} else if (i == 2) {
						// hour
						OptionalInt findFirst = ThreadLocalRandom.current().ints(i1, i2 + 1).limit(1).findFirst();
						if (findFirst.isPresent()) {
							result.add(Integer.toString(findFirst.getAsInt()));
						}
					} else {
						result.add(first);
					}
				} else {
					result.add(p);
				}
			}
		}
		return String.join(" ", result);
	}
	private static boolean isInt(String s) {
		try {
			Integer.parseInt(s);
		} catch (NumberFormatException nfe) {
			return false;
		}
		return true;
	}
	public void run() {
		if (!rm.getConfiguration().getBoolean(Settings.BACKUP_SCHEDULER, false)) {
			log.warn("Backup is disabled in configuration");
			return;
		}
		try {
			String[] names = scheduler.getJobNames("backupGroup");
			int index = 1;
			if (names.length > 0) {
				// this only works for up to 10 jobs in this group
				index = Integer.parseInt(names[names.length - 1]);
				index++;
			}
			String jobIndex = String.valueOf(index);
			job = new JobDetail(jobIndex, "backupGroup", BackupJob.class);
			job.getJobDataMap().put("rm", this.rm);
			job.getJobDataMap().put("gzip", this.gzip);
			job.getJobDataMap().put("includeFiles", this.includeFiles);
			job.getJobDataMap().put("deleteAfter", this.deleteAfter);
			job.getJobDataMap().put("maintenance", this.maintenance);
			job.getJobDataMap().put("upperLimit", this.upperLimit);
			job.getJobDataMap().put("lowerLimit", this.lowerLimit);
			job.getJobDataMap().put("expiresAfterDays", this.expiresAfterDays);
			job.getJobDataMap().put("format", this.format);
			job.getJobDataMap().put("simple", this.simple);
			CronTrigger trigger = new CronTrigger("trigger" + jobIndex, "backupGroup", jobIndex, "backupGroup", this.cronExpression);
			scheduler.addJob(job, true);
			scheduler.scheduleJob(trigger);
			scheduler.start();
		} catch (ParseException | SchedulerException e) {
			log.error(e.getMessage());
		}
	}
//	public void stop() {
//		try {
//			scheduler.standby();
//		} catch (SchedulerException e) {
//			log.error(e.getMessage());
//			e.printStackTrace();
//		}
//	}
//
//	public void start() {
//		try {
//			scheduler.start();
//		} catch (SchedulerException e) {
//			log.error(e.getMessage());
//		}
//	}
	public boolean delete() {
		try {
			if (job != null) {
				log.info("Deleting backup job");
				scheduler.deleteJob(job.getName(), job.getGroup());
				job = null;
			}
		} catch (SchedulerException e) {
			log.error(e.getMessage());
			return false;
		}
		return true;
	}
}