Java tutorial
/** * Licensed to Apereo under one or more contributor license * agreements. See the NOTICE file distributed with this work * for additional information regarding copyright ownership. * Apereo licenses this file to you 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 the following location: * * 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.jasig.ssp.service.impl; import com.google.common.collect.Sets; import org.apache.commons.lang.StringUtils; import org.hibernate.FlushMode; import org.hibernate.Session; import org.hibernate.SessionFactory; import org.jasig.portal.api.permissions.Assignment; import org.jasig.portal.api.permissions.PermissionsService; import org.jasig.ssp.model.Person; import org.jasig.ssp.security.SspUser; import org.jasig.ssp.security.uportal.UPortalSecurityFilter; import org.jasig.ssp.service.EarlyAlertService; import org.jasig.ssp.service.ObjectNotFoundException; import org.jasig.ssp.service.PersonService; import org.jasig.ssp.service.PruneMessageQueueTask; import org.jasig.ssp.service.RefreshDirectoryPersonBlueTask; import org.jasig.ssp.service.RefreshDirectoryPersonTask; import org.jasig.ssp.service.ScheduledApplicationTaskStatusService; import org.jasig.ssp.service.ScheduledTaskWrapperService; import org.jasig.ssp.service.SecurityService; import org.jasig.ssp.service.SendQueuedMessagesTask; import org.jasig.ssp.service.TaskService; import org.jasig.ssp.service.external.BatchedTask; import org.jasig.ssp.service.external.ExternalPersonSyncTask; import org.jasig.ssp.service.external.MapStatusReportCalcTask; import org.jasig.ssp.service.jobqueue.JobService; import org.jasig.ssp.service.reference.ConfigService; import org.jasig.ssp.service.security.oauth.OAuth1NonceServiceMaintenance; import org.jasig.ssp.util.CallableExecutor; import org.jasig.ssp.util.collections.Pair; import org.jasig.ssp.util.sort.PagingWrapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.core.NestedRuntimeException; import org.springframework.orm.hibernate4.SessionFactoryUtils; import org.springframework.orm.hibernate4.SessionHolder; import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.Trigger; import org.springframework.scheduling.TriggerContext; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.support.CronTrigger; import org.springframework.scheduling.support.PeriodicTrigger; import org.springframework.security.access.intercept.RunAsUserToken; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.event.AuthenticationSuccessEvent; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.GrantedAuthorityImpl; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import org.springframework.transaction.support.TransactionSynchronizationManager; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.Callable; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Matcher; import java.util.regex.Pattern; @Service public class ScheduledTaskWrapperServiceImpl implements ScheduledTaskWrapperService, InitializingBean { private static final Logger LOGGER = LoggerFactory.getLogger(ScheduledTaskWrapperServiceImpl.class); public static final String TASK_NAME_MDC_KEY = "ssp.taskName"; public static final String SEND_MESSAGES_TASK_NAME = "send-messages"; public static final String SYNC_COACHES_TASK_NAME = "sync-coaches"; public static final String SYNC_EXTERNAL_PERSONS_TASK_NAME = "sync-external-persons"; public static final String REFRESH_DIRECTORY_PERSON_TASK_NAME = "directory-person-refresh"; public static final String REFRESH_DIRECTORY_PERSON_BLUE_TASK_NAME = "directory-person-refresh-blue"; public static final String CALC_MAP_STATUS_REPORTS_TASK_NAME = "calc-map-status-reports"; public static final String BULK_JOB_QUEUE_TASK_NAME = "bulk-job-queue"; public static final String SEND_TASK_REMINDERS_TASK_NAME = "send-task-reminders"; public static final String SEND_EARLY_ALERT_REMINDERS_TASK_NAME = "send-early-alert-reminders"; public static final String OAUTH1_CULL_NONCE_TABLE_TASK_NAME = "cull-oauth1-nonces"; private static final String EVERY_DAY_10_PM = "0 0 22 * * *"; private static final String EVERY_DAY_1_AM = "0 0 1 * * *"; private static final String EVERY_DAY_3_AM = "0 0 3 * * *"; private static final String EVERY_DAY_4_AM = "0 0 4 * * *"; private static final String EVERY_15_SECONDS_WITH_30_SECOND_DELAY = "15000/30000"; private static final String FIFTEEN_MINUTES_IN_MILLIS = 15 * 60 * 1000 + ""; private static final String NEVER = "0 0 0 31 12 *"; // Not a fan of the underscores but matches convention for existing // ConfigService records, and will probably be convenient for our IDs here // to match prefixes on that config private static final String EXTERNAL_PERSON_SYNC_TASK_ID = "task_external_person_sync"; private static final String EXTERNAL_PERSON_SYNC_TASK_TRIGGER_CONFIG_NAME = "task_external_person_sync_trigger"; private static final String EXTERNAL_PERSON_SYNC_TASK_DEFAULT_TRIGGER = EVERY_DAY_1_AM; private static final String DIRECTORY_PERSON_REFRESH_TASK_ID = "task_directory_person_refresh"; private static final String DIRECTORY_PERSON_REFRESH_TASK_TRIGGER_CONFIG_NAME = "task_directory_person_refresh_trigger"; private static final String DIRECTORY_PERSON_REFRESH_TASK_DEFAULT_TRIGGER = NEVER; private static final String DIRECTORY_PERSON_REFRESH_STARTUP_TASK_ID = "task_directory_person_refresh_on_start_up"; private static final String STARTUP_PERSON_REFRESH_TASK_TRIGGER_CONFIG_NAME = "task_directory_person_refresh_start_up_trigger"; private static final String SCHEDULER_CONFIG_POLL_TASK_ID = "task_scheduler_config_poll"; private static final String SCHEDULER_CONFIG_POLL_TASK_TRIGGER_CONFIG_NAME = "task_scheduler_config_poll_trigger"; private static final String SCHEDULER_CONFIG_POLL_TASK_DEFAULT_TRIGGER = FIFTEEN_MINUTES_IN_MILLIS; private static final String MAP_STATUS_REPORT_CALC_TASK_ID = "task_map_plan_status_calc"; private static final String MAP_STATUS_REPORT_CALC_TASK_TRIGGER_CONFIG_NAME = "task_scheduler_map_plan_status_calculation_trigger"; private static final String MAP_STATUS_REPORT_CALC_TASK_DEFAULT_TRIGGER = EVERY_DAY_3_AM; private static final String BULK_JOB_QUEUE_TASK_ID = "task_bulk_job_queue"; private static final String BULK_JOB_QUEUE_TASK_CONFIG_NAME = "task_bulk_job_queue_trigger"; private static final String BULK_JOB_QUEUE_TASK_DEFAULT_TRIGGER = EVERY_15_SECONDS_WITH_30_SECOND_DELAY; private static final String MESSAGE_QUEUE_PRUNING_TASK_ID = "task_message_queue_pruning"; private static final String MESSAGE_QUEUE_PRUNING_TASK_NAME = "task_message_queue_pruning_trigger"; private static final String MESSAGE_QUEUE_PRUNING_TASK_DEFAULT_TRIGGER = EVERY_DAY_10_PM; private static final String EARLY_ALERT_TASK_ID = "task_early_alert_scheduled_tasks"; private static final String EARLY_ALERT_TASK_TRIGGER_CONFIG_NAME = "task_scheduler_early_alert_trigger"; private static final String EARLY_ALERT_TASK_DEFAULT_TRIGGER = EVERY_DAY_4_AM; private static final String DISABLED_TRIGGER_CONFIG_VALUE = "DISABLED"; private static final String RUN_ONCE_TRIGGER_CONFIG_VALUE = "RUN_ONCE_ON_STARTUP"; private static final String OAUTH1_CULL_NONCE_TASK_ID = "task_oauth1_nonce_cull"; private static final String OAUTH1_CULL_NONCE_TASK_TRIGGER_CONFIG_NAME = "task_scheduler_oauth_nonce_cull_trigger"; private static final String OAUTH1_CULL_NONCE_TASK_DEFAULT_TRIGGER = EVERY_DAY_4_AM; // see assumptions about grouping in tryExpressionAsPeriodicTrigger() private static final Pattern PERIODIC_TRIGGER_WITH_INITIAL_DELAY_PATTERN = Pattern.compile("^(\\d+)/(\\d+)$"); @Autowired private transient ExternalPersonSyncTask externalPersonSyncTask; @Autowired private transient OAuth1NonceServiceMaintenance oAuth1NonceServiceMaintenance; @Autowired private transient RefreshDirectoryPersonTask directoryPersonRefreshTask; @Autowired private transient RefreshDirectoryPersonBlueTask directoryPersonRefreshBlueTask; @Autowired private transient MapStatusReportCalcTask mapStatusReportCalcTask; @Autowired private transient PruneMessageQueueTask pruneMessageQueueTask; @Autowired private transient SendQueuedMessagesTask sendQueuedMessagesTask; @Autowired private transient PersonService personService; @Autowired private transient ScheduledApplicationTaskStatusService taskStatusService; @Autowired private transient SecurityService securityService; @Autowired private transient TaskService taskService; @Autowired private transient JobService jobService; @Autowired private transient EarlyAlertService earlyAlertService; @Autowired private transient TaskScheduler taskScheduler; @Autowired private transient ConfigService configService; @Autowired private transient SessionFactory sessionFactory; @Autowired private transient AuthenticationManager authenticationManager; @Autowired private ApplicationEventPublisher eventPublisher; @Value("#{configProperties.scheduled_coach_sync_enabled}") private boolean scheduledCoachSyncEnabled; @Value("#{configProperties.ssp_trusted_code_run_as_key}") private String runAsKey; private HashMap<String, Task> tasks; @Override public void afterPropertiesSet() { initTasks(); updateTasks(); } protected synchronized void initTasks() { this.tasks = new LinkedHashMap<String, Task>(); this.tasks.put(EXTERNAL_PERSON_SYNC_TASK_ID, new Task(EXTERNAL_PERSON_SYNC_TASK_ID, new Runnable() { @Override public void run() { syncExternalPersons(); refreshDirectoryPersonBlue(); refreshDirectoryPerson(); } }, EXTERNAL_PERSON_SYNC_TASK_DEFAULT_TRIGGER, EXTERNAL_PERSON_SYNC_TASK_TRIGGER_CONFIG_NAME)); this.tasks.put(DIRECTORY_PERSON_REFRESH_TASK_ID, new Task(DIRECTORY_PERSON_REFRESH_TASK_ID, new Runnable() { @Override public void run() { refreshDirectoryPersonBlue(); refreshDirectoryPerson(); } }, DIRECTORY_PERSON_REFRESH_TASK_DEFAULT_TRIGGER, DIRECTORY_PERSON_REFRESH_TASK_TRIGGER_CONFIG_NAME)); //Schedule task to be run once on startup this.tasks.put(DIRECTORY_PERSON_REFRESH_STARTUP_TASK_ID, new Task(DIRECTORY_PERSON_REFRESH_STARTUP_TASK_ID, new Runnable() { @Override public void run() { resetTaskStatus(); refreshDirectoryPersonBlue(); refreshDirectoryPerson(); } }, DIRECTORY_PERSON_REFRESH_TASK_DEFAULT_TRIGGER, STARTUP_PERSON_REFRESH_TASK_TRIGGER_CONFIG_NAME)); this.tasks.put(MAP_STATUS_REPORT_CALC_TASK_ID, new Task(MAP_STATUS_REPORT_CALC_TASK_ID, new Runnable() { @Override public void run() { calcMapStatusReports(); } }, MAP_STATUS_REPORT_CALC_TASK_DEFAULT_TRIGGER, MAP_STATUS_REPORT_CALC_TASK_TRIGGER_CONFIG_NAME)); this.tasks.put(BULK_JOB_QUEUE_TASK_ID, new Task(BULK_JOB_QUEUE_TASK_ID, new Runnable() { @Override public void run() { scheduledQueuedJobs(); } }, BULK_JOB_QUEUE_TASK_DEFAULT_TRIGGER, BULK_JOB_QUEUE_TASK_CONFIG_NAME)); this.tasks.put(MESSAGE_QUEUE_PRUNING_TASK_ID, new Task(MESSAGE_QUEUE_PRUNING_TASK_ID, new Runnable() { @Override public void run() { pruneMessageQueue(); } }, MESSAGE_QUEUE_PRUNING_TASK_DEFAULT_TRIGGER, MESSAGE_QUEUE_PRUNING_TASK_NAME)); this.tasks.put(SCHEDULER_CONFIG_POLL_TASK_ID, new Task(SCHEDULER_CONFIG_POLL_TASK_ID, new Runnable() { @Override public void run() { updateTasksTask(); } }, SCHEDULER_CONFIG_POLL_TASK_DEFAULT_TRIGGER, SCHEDULER_CONFIG_POLL_TASK_TRIGGER_CONFIG_NAME)); this.tasks.put(EARLY_ALERT_TASK_ID, new Task(EARLY_ALERT_TASK_ID, new Runnable() { @Override public void run() { sendEarlyAlertReminders(); } }, EARLY_ALERT_TASK_DEFAULT_TRIGGER, EARLY_ALERT_TASK_TRIGGER_CONFIG_NAME)); this.tasks.put(OAUTH1_CULL_NONCE_TASK_ID, new Task(OAUTH1_CULL_NONCE_TASK_ID, new Runnable() { @Override public void run() { cullOAuth1Nonces(); } }, OAUTH1_CULL_NONCE_TASK_DEFAULT_TRIGGER, OAUTH1_CULL_NONCE_TASK_TRIGGER_CONFIG_NAME)); // Can't interrupt this on cancel b/c it's responsible for rescheduling // itself. A scheduling attempt on an interrupted thread is very // likely to be refused when using java.util.concurrent schedulers // under the covers. this.tasks.get(SCHEDULER_CONFIG_POLL_TASK_ID).mayInterrupt = false; } public synchronized void updateTasks() { if (Thread.currentThread().isInterrupted()) { LOGGER.info("Skipping task scheduling updates because of thread interruption"); return; } else { LOGGER.info("Starting task scheduling updates"); } for (Map.Entry<String, Task> taskEntry : this.tasks.entrySet()) { final Task task = taskEntry.getValue(); if (Thread.currentThread().isInterrupted()) { LOGGER.info("Abandoning updateTasksTask before processing" + " task [{}] because of thread interruption", task.id); return; } LOGGER.debug("Checking for updated config for task [{}]", task.id); mergeLatestTriggerConfig(task); maybeReschedule(task); } } /** * Really just a pass through to {@link #updateTasks()} but allows for * extra hooks (esp logging) before that background job does its work */ protected synchronized void updateTasksTask() { LOGGER.info("Starting task scheduling updates from within a scheduled job"); updateTasks(); } /** * Reads latest config from {@link ConfigService} for the given {@link Task} * and caches it in that {@link Task}. If config is found but can't be * parsed, the {@link Task}'s {@code configured*} fields will include the * bad {@link Trigger} expression and a {@link BadConfigTrigger}. If config * simply can't be read at all, e.g. network outage, that will just be * logged and the {@link Task}'s config will be unchanged. * * <p>This also handles initialization of the {@link Task's} * default {@link Trigger} <em>every time</em>. Which means you can * technically change the default and it will be picked up.</p> * * @param task * @return * @throws BadTriggerConfigException if the default configuration for the * given {@link Task} is broken. Exceptions during read of latest config * from {@link ConfigService} are caught and handled. */ protected void mergeLatestTriggerConfig(Task task) throws BadTriggerConfigException { // Null will mean "have no idea what the latest config would be or // if it even exists." Everything else will mean "either found good // config, use it, or found bad config and it's up to you what to do // with it." Pair<String, Trigger> newTriggerConfig = null; try { newTriggerConfig = readNewTriggerConfig(task.triggerExpressionConfigName); } catch (BadTriggerConfigException e) { LOGGER.warn("Unable to parse task [{}] trigger config named [{}].", new Object[] { task.id, task.triggerExpressionConfigName, e }); newTriggerConfig = new Pair<String, Trigger>(null, new BadConfigTrigger(e.getConfig(), e)); } catch (RuntimeException e) { // Probably db connection or other systemic issue issue. Will end up // leaving this task alone. LOGGER.error( "Unable to read trigger config named [{}]. This" + " was probably a transient and/or systemic issue " + " rather than a parse issue.", task.triggerExpressionConfigName, e); } if (newTriggerConfig != null && newTriggerConfig.getSecond() == null) { // Really a programmer error but let's just quietly normalize to // simplify boolean expressions below. newTriggerConfig = null; } if (newTriggerConfig == null) { task.configuredTriggerExpression = null; task.configuredTrigger = null; } else { task.configuredTriggerExpression = newTriggerConfig.getFirst(); task.configuredTrigger = newTriggerConfig.getSecond(); } // Do this every time to avoid weird stuff from mis-use where the // expression and trigger don't match. task.defaultTrigger = parseTriggerConfig(task.defaultTriggerExpression); } protected void maybeReschedule(Task task) { Pair<String, Trigger> configuredOrDefault = configuredOrDefaultTrigger(task); Pair<String, Trigger> newTrigger = null; if (task.executingTrigger == null) { // first time execution newTrigger = configuredOrDefault; LOGGER.debug( "Preparing to schedule task [{}] for" + " first-time execution with trigger expression [{}]", task.id, newTrigger.getFirst()); } else if (configuredOrDefault.getSecond() instanceof BadConfigTrigger) { // broken config. nothing to do. LOGGER.info("Skipping scheduling for task [{}] because it has" + " a bad trigger expression [{}]", task.id, configuredOrDefault.getFirst()); } else if (!(configuredOrDefault.getFirst().equals(task.executingTriggerExpression))) { // schedule change! newTrigger = configuredOrDefault; LOGGER.debug( "Preparing to re-schedule task [{}] with trigger" + " expression [{}]. Previous expression: [{}]", new Object[] { task.id, newTrigger.getFirst(), task.executingTriggerExpression }); } else { LOGGER.debug( "Skipping scheduling for task [{}] because no" + " changes have been requested. Currently executing" + " trigger expression: [{}]", task.id, task.executingTriggerExpression); return; } cancel(task); schedule(task, newTrigger); } protected void cancel(Task task) { if (task.execution != null) { LOGGER.info("Attempting task [{}] cancellation", task.id); task.execution.cancel(task.mayInterrupt); } task.executingTrigger = null; task.executingTriggerExpression = null; } protected void schedule(Task task, Pair<String, Trigger> triggerAndExpression) { if (triggerAndExpression == null) { throw new IllegalArgumentException("Must specify a Trigger and its expression"); } LOGGER.info("Scheduling task [{}] with trigger expression [{}]", task.id, triggerAndExpression.getFirst()); task.execution = taskScheduler.schedule(task.runnable, triggerAndExpression.getSecond()); task.executingTriggerExpression = triggerAndExpression.getFirst(); task.executingTrigger = triggerAndExpression.getSecond(); } protected Pair<String, Trigger> configuredOrDefaultTrigger(Task task) { if (task.configuredTrigger == null || task.configuredTrigger instanceof BadConfigTrigger) { return new Pair<String, Trigger>(task.defaultTriggerExpression, task.defaultTrigger); } return new Pair<String, Trigger>(task.configuredTriggerExpression, task.configuredTrigger); } protected Pair<String, Trigger> readNewTriggerConfig(String configName) throws BadTriggerConfigException { final String configValue = StringUtils.trimToNull(configService.getByNameNullOrDefaultValue(configName)); if (configValue == null) { return null; } if (configValue.toUpperCase().equals(DISABLED_TRIGGER_CONFIG_VALUE)) return new Pair<String, Trigger>(configValue, new DisabledTrigger()); if (configValue.toUpperCase().equals(RUN_ONCE_TRIGGER_CONFIG_VALUE)) return new Pair<String, Trigger>(configValue, new OnStartUpTrigger()); return new Pair<String, Trigger>(configValue, parseTriggerConfig(configValue)); } protected Trigger parseTriggerConfig(String configValue) throws BadTriggerConfigException { BadTriggerConfigException badPeriodicTiggerException = null; BadTriggerConfigException badCronTiggerException = null; try { return tryExpressionAsPeriodicTrigger(configValue); } catch (BadTriggerConfigException e) { badPeriodicTiggerException = e; } try { return tryExpressionAsCronTrigger(configValue); } catch (BadTriggerConfigException e) { badCronTiggerException = e; } throw new BadTriggerConfigException("All trigger config parsing attempts failed. First reason: [" + badPeriodicTiggerException.getMessage() + "]. Second reason: [" + badCronTiggerException.getMessage() + "]", configValue); } protected Trigger tryExpressionAsPeriodicTrigger(String configValue) throws BadTriggerConfigException { BadTriggerConfigException longParseException = null; try { final long period = Long.parseLong(configValue); if (period < 0) { return new DisabledTrigger(); } // millis since that's what @Scheduled methods were historically // configured in return new PeriodicTrigger(period, TimeUnit.MILLISECONDS); } catch (NumberFormatException e) { longParseException = new BadTriggerConfigException( "Config [" + configValue + "] did not parse to a long.", configValue, e); } catch (IllegalArgumentException e) { longParseException = new BadTriggerConfigException( "Config [" + configValue + "] could not be used to initialize a PeriodicTrigger", configValue, e); } final Matcher matcher = PERIODIC_TRIGGER_WITH_INITIAL_DELAY_PATTERN.matcher(configValue); if (!(matcher.matches())) { throw new BadTriggerConfigException("Trigger expression could not be parsed as either a" + " simple period or as a period with an initial " + "offset. Original parse failure: [" + longParseException.getMessage() + "]. To be considered a period with an offset, " + "the expression must match this regexp" + "(without brackets): [" + PERIODIC_TRIGGER_WITH_INITIAL_DELAY_PATTERN + "]", configValue); } try { final String periodStr = matcher.group(1); final long periodLong = Long.parseLong(periodStr); if (periodLong < 0) { return new DisabledTrigger(); } final String offsetStr = matcher.group(2); final long offsetLong = Long.parseLong(offsetStr); final PeriodicTrigger trigger = new PeriodicTrigger(periodLong, TimeUnit.MILLISECONDS); trigger.setInitialDelay(offsetLong); return trigger; } catch (NumberFormatException e) { throw new BadTriggerConfigException("Trigger expression could not be parsed as either a" + " simple period or as a period with an initial " + "offset. Original parse failure: [" + longParseException.getMessage() + "]. To be considered a period with an initial " + "delay, the expression must match this regexp" + "(without brackets): [" + PERIODIC_TRIGGER_WITH_INITIAL_DELAY_PATTERN + "]", configValue, e); } catch (IllegalArgumentException e) { throw new BadTriggerConfigException( "Trigger expression parsed as a period [{}] with an " + "initial delay [{}] but could not be used to " + "initialize a PeriodicTrigger", configValue, e); } } protected Trigger tryExpressionAsCronTrigger(String configValue) throws BadTriggerConfigException { try { return new CronTrigger(configValue); } catch (IllegalArgumentException e) { throw new BadTriggerConfigException( "Config [" + configValue + "] could not be used to initialize a CronTrigger", configValue, e); } } /** * Decorates the given {@link Runnable} with {@link #withSudo(Runnable)} * if the current {@link SecurityContext} is not considered "auditable", * otherwise the returned {@code Runnable} has no added behavior. * * <p>Design note: currently there is only this and the "raw" * {@link #withSudo(Runnable)}... nothing that restores the previous * {@link Authentication}, if any. Only reason is that we don't currently * need such a thing, so there's no point in spending time making sure * the replace/restore actually works.</p> * * @see #isCurrentAuthenticationAuditable() * @param work * @throws AuthenticationException */ protected Runnable withMaybeSudo(final Runnable work, final UUID runAsId) throws AuthenticationException { return new Runnable() { @Override public void run() { if (!(isCurrentAuthenticationAuditable())) { LOGGER.debug("Insufficient Authentication in SecurityContext. Executing task via sudo."); withSudo(work, runAsId).run(); } else { LOGGER.debug( "Sufficient Authentication already present in SecurityContext. Skipping sudo and executing task in that context."); work.run(); } } }; } /** * Checks to see if our Hibernate flush interceptor will consider the * current {@link Authentication} sufficient for assigning to persistent * entities as either a "creator" or "modifier", <em>and is not the anonymous * user.</em> * * <p>Note that this implementation depends heavily on some quirky * behavior in {@link org.jasig.ssp.service.SecurityService#currentlyAuthenticatedUser()}. * Specifically, that method is assumed to return null if any of the following * are true:</p> * * <ol> * <li>The current {@link SecurityContext} {@link Authentication} * is null, or</li> * <li>The current {@link SecurityContext} {@link Authentication} is * unauthenticated, or</li> * <li>The current {@link SecurityContext} {@link Authentication} is * the anonymous user, or</li> * <li>The current {@link SecurityContext} {@link Authentication} does * not resolve to a known {@link Person}.</li> * </ol> * * @see #withSudo(Runnable) * @return */ protected boolean isCurrentAuthenticationAuditable() { return securityService.currentlyAuthenticatedUser() != null; } /** * Decorates the given {@code Runnable} with a login and logout of * {@link org.jasig.ssp.service.SecurityService#noAuthAdminUser()}. * * <p>Prior to <a href="https://issues.jasig.org/browse/SSP-2241">SSP-2241</a> * we didn't attempt to ensure any particular {@link SecurityContext} state * prior to running jobs. This ended up causing a memory leak because our * Hibernate flush interceptor would generate a new {@link SspUser} for * every flushed "auditer" field, and every time that happened, that * {@link SspUser} was added to a {@code ThreadLocal} list. For a large * job like {@link #syncExternalPersons()}, the growth of that list was * particularly explosive. {@link SspUser} is definitely due for a refactor * to eliminate it's {@code ThreadLocal} dependencies, but for the time * being we're able to short-circuit the leak by ensuring that there is * a current {@link Authentication} that the Hibernate flush interceptor * will honor. (It will not honor the anonymous user.) And this is good * practice anyway - to always explicitly set up a security context rather * than let obscure Hibernate extension internals make up the rules as we * go.</p> * * @see #withMaybeSudo(Runnable) * @param work * @return * @throws AuthenticationException */ protected Runnable withSudo(final Runnable work, final UUID runAsId) throws AuthenticationException { return new Runnable() { @Override public void run() { final SspUser runAs; if (runAsId == null) { runAs = securityService.noAuthAdminUser(); } else { try { final Person person = personService.get(runAsId); if (person == null) { throw new ObjectNotFoundException(runAsId, Person.class.getName()); } // mostly copy/paste from UPortalSecurityFilter final Set<Assignment> assignments = PermissionsService.IMPL.get() .getAssignmentsForPerson(person.getUsername(), true); // Find SSP-related permissions in the assignments collection final Set<GrantedAuthority> authorities = Sets.newHashSet(); for (Assignment a : assignments) { if (a.getOwner().getKey().equals(UPortalSecurityFilter.SSP_OWNER)) { // This one pertains to us... String activity = a.getActivity().getKey(); authorities.add(new GrantedAuthorityImpl("ROLE_" + activity)); } } final SspUser user = new SspUser(person.getUsername(), "", true, true, true, true, authorities); user.setPerson(person); runAs = user; } catch (ObjectNotFoundException e) { throw new UsernameNotFoundException("Could not find Person by ID [" + runAsId + "]", e); } } Authentication auth = new RunAsUserToken(runAsKey, runAs, null, runAs.getAuthorities(), null); auth = authenticationManager.authenticate(auth); // Not sure why/if we need this. Just trying to mimic long-time // legacy behavior in UPortalPreAuthenticatedProcessingFilter if (eventPublisher != null) { eventPublisher.publishEvent(new AuthenticationSuccessEvent(auth)); } // AuthenticationManager doesn't do this for you SecurityContextHolder.getContext().setAuthentication(auth); try { work.run(); } finally { SecurityContextHolder.getContext().setAuthentication(null); } } }; } protected Runnable withSudo(Runnable work) throws AuthenticationException { return withSudo(work, null); } /** * Wraps the given {@code Runnable} with the sort of cleanup you'd normally * depend on after a HTTP request. In particular, this is necessary to * ensure release of {@code ThreadLocals} set by virtue of {@link SspUser} * interactions. */ protected Runnable withTaskCleanup(final Runnable work) { return new Runnable() { @Override public void run() { try { LOGGER.debug("SspUser cleanup queue size (pre-task): {}", SspUser.cleanupQueueSize()); work.run(); } finally { LOGGER.debug("SspUser cleanup queue size (post-task): {}", SspUser.cleanupQueueSize()); securityService.afterRequest(); LOGGER.debug("SspUser cleanup queue size (post-task-cleanup): " + SspUser.cleanupQueueSize()); } } }; } protected Runnable withHibernateSession(final Runnable work) { return new Runnable() { @Override public void run() { // Basically a copy/paste of Spring's // OpenSessionInViewFilter#doFilterInternal, with the // web-specific stuff removed boolean participate = false; try { if (TransactionSynchronizationManager.hasResource(sessionFactory)) { // Do not modify the Session: just set the participate flag. LOGGER.debug("Scheduled task joining existing Hibernate session/transaction"); participate = true; } else { LOGGER.debug("Scheduled task creating new Hibernate session"); Session session = sessionFactory.openSession(); session.setFlushMode(FlushMode.MANUAL); SessionHolder sessionHolder = new SessionHolder(session); TransactionSynchronizationManager.bindResource(sessionFactory, sessionHolder); } work.run(); } finally { if (!participate) { SessionHolder sessionHolder = (SessionHolder) TransactionSynchronizationManager .unbindResource(sessionFactory); LOGGER.debug("Scheduled task closing Hibernate session"); SessionFactoryUtils.closeSession(sessionHolder.getSession()); } else { LOGGER.debug( "Scheduled task joined existing Hibernate session/transaction so skipping that cleanup step"); } } } }; } protected Runnable withTaskName(final String taskName, final Runnable work, final boolean isStatusedTask) { return new Runnable() { @Override public void run() { if (StringUtils.isBlank(taskName)) { work.run(); return; } final String currentThreadName = Thread.currentThread().getName(); final String currentMdcEntry = MDC.get(TASK_NAME_MDC_KEY); if (isStatusedTask) { taskStatusService.beginTask(taskName); } try { final String newThreadName = currentThreadName == null ? taskName : currentThreadName + ":" + taskName; Thread.currentThread().setName(newThreadName); final String newMdcEntry = currentMdcEntry == null ? taskName : currentMdcEntry + ":" + taskName; MDC.put(TASK_NAME_MDC_KEY, newMdcEntry); work.run(); } finally { if (currentMdcEntry == null) { MDC.remove(TASK_NAME_MDC_KEY); } else { MDC.put(TASK_NAME_MDC_KEY, currentMdcEntry); } Thread.currentThread().setName(currentThreadName); if (isStatusedTask) { taskStatusService.completeTask(taskName); } } } }; } /** * Wraps the given {@code Runnable} in the "standard" decorators you'd * typically need for execution of a background task and returns the * resulting {@code Runnable} for subsequent execution. * * @param work */ protected Runnable withTaskContext(String taskName, Runnable work, boolean isStatusedTask, UUID runAsId) { return withTaskName(taskName, withHibernateSession(withTaskCleanup(withMaybeSudo(work, runAsId))), isStatusedTask); } /** * Wraps the given {@code Runnable} in the "standard" decorators you'd * typically need for execution of a background task, and then * executed the result. Please see {@link #withSudo(Runnable)} in particular * for why it's important to have a non-anonymous current * {@link Authentication} before actually executing a background task. * * @param work */ @Override public void execWithTaskContext(String taskName, Runnable work, boolean isStatusedTask, UUID runAsId) { withTaskContext(taskName, work, isStatusedTask, runAsId).run(); } public void execWithTaskContext(String taskName, Runnable work) { execWithTaskContext(taskName, work, true, null); } /** * Basically a deferred form of {@link #execWithTaskContext(Runnable)}. * Useful when you have a scheduled job that does its work in batches and * you'd like the effect of {@link #execWithTaskContext(Runnable)} * (except for the thread naming decoration) applied * independently for each batch. This is advisable for any long-running * job (which is probably why it was batched in the first place) b/c * otherwise you can end up with a system doing a great impression of * a memory leak as the Hib session grows indefinitely. * * <p>Batched jobs often need results from each batch to know what to * do next, hence the use of {@code Callable} rather than * {@link Runnable} here.</p> * * <p>Since thread naming needs to happen prior to individual batch * executions, the caller is responsible for wrapping the actual * task invocation with that behavior, if necessary. E.g. see * {@link #execBatchedTaskWithName(String, org.jasig.ssp.service.external.BatchedTask)}</p> * * @param batchReturnType * @param <T> * @return */ protected <T> CallableExecutor<T> newTaskBatchExecutor(final Class<T> batchReturnType, final boolean isStatusedTask, final UUID runAsId) { return new CallableExecutor<T>() { @Override public T exec(final Callable<T> work) throws Exception { final AtomicReference<T> resultHolder = new AtomicReference<T>(); final AtomicReference<Exception> exceptionHolder = new AtomicReference<Exception>(); execWithTaskContext(null, new Runnable() { @Override public void run() { try { resultHolder.set(work.call()); } catch (Exception e) { exceptionHolder.set(e); } } }, isStatusedTask, runAsId); if (exceptionHolder.get() != null) { throw exceptionHolder.get(); } return resultHolder.get(); } }; } @Override public void execBatchedTaskWithName(final String taskName, final BatchedTask batchedTask, final boolean isStatusedTask, final UUID runAsId) { withTaskName(taskName, new Runnable() { @Override public void run() { batchedTask .exec(newTaskBatchExecutor(batchedTask.getBatchExecReturnType(), isStatusedTask, runAsId)); } }, isStatusedTask).run(); } protected void execBatchedTaskWithName(final String taskName, final BatchedTask batchedTask) { execBatchedTaskWithName(taskName, batchedTask, true, null); } @Override @Scheduled(fixedDelay = 150000) // run 2.5 minutes after the end of the last invocation public void sendMessages() { execBatchedTaskWithName(SEND_MESSAGES_TASK_NAME, sendQueuedMessagesTask); } @Override @Scheduled(fixedDelay = 300000) // run every 5 minutes public void syncCoaches() { execWithTaskContext(SYNC_COACHES_TASK_NAME, new Runnable() { @Override public void run() { if (!(scheduledCoachSyncEnabled)) { LOGGER.debug("Scheduled coach sync disabled. Abandoning sync job"); return; } LOGGER.info("Scheduled coach sync starting."); PagingWrapper<Person> localCoaches = personService.syncCoaches(); LOGGER.info("Scheduled coach sync complete. Local coach count [{}]", localCoaches.getResults()); } }); } @Override @Scheduled(cron = "0 0 1 * * *") // run at 1 am every day public void sendTaskReminders() { execWithTaskContext(SEND_TASK_REMINDERS_TASK_NAME, new Runnable() { @Override public void run() { taskService.sendAllTaskReminderNotifications(); } }); } /** * Not {@code @Scheduled} b/c its scheduling is now handled by the * config polling job. */ @Override public void syncExternalPersons() { execBatchedTaskWithName(SYNC_EXTERNAL_PERSONS_TASK_NAME, externalPersonSyncTask); } @Override public void refreshDirectoryPerson() { execBatchedTaskWithName(REFRESH_DIRECTORY_PERSON_TASK_NAME, directoryPersonRefreshTask); } //Reset Tasks schedule where used for control if possiblity completion is interrupted by termination @Override public void resetTaskStatus() { taskStatusService.completeTask(REFRESH_DIRECTORY_PERSON_TASK_NAME); } @Override public void refreshDirectoryPersonBlue() { execBatchedTaskWithName(REFRESH_DIRECTORY_PERSON_BLUE_TASK_NAME, directoryPersonRefreshBlueTask); } @Override public void calcMapStatusReports() { execBatchedTaskWithName(CALC_MAP_STATUS_REPORTS_TASK_NAME, mapStatusReportCalcTask); } @Override public void scheduledQueuedJobs() { execWithTaskContext(BULK_JOB_QUEUE_TASK_NAME, new Runnable() { public void run() { jobService.scheduleQueuedJobs(); } }); } @Override public void pruneMessageQueue() { execBatchedTaskWithName(MESSAGE_QUEUE_PRUNING_TASK_NAME, pruneMessageQueueTask); } @Override public void sendEarlyAlertReminders() { execWithTaskContext(SEND_EARLY_ALERT_REMINDERS_TASK_NAME, new Runnable() { @Override public void run() { earlyAlertService.sendAllEarlyAlertReminderNotifications(); } }); } @Override public void cullOAuth1Nonces() { execWithTaskContext(OAUTH1_CULL_NONCE_TABLE_TASK_NAME, new Runnable() { @Override public void run() { oAuth1NonceServiceMaintenance.removeExpired(); } }); } protected static class Task { public String id; public String triggerExpressionConfigName; public String configuredTriggerExpression; public Trigger configuredTrigger; public String defaultTriggerExpression; public Trigger defaultTrigger; public String executingTriggerExpression; public Trigger executingTrigger; public ScheduledFuture execution; public Runnable runnable; public boolean mayInterrupt = true; public Task(String id, Runnable task, String defaultTriggerExpression, String triggerExpressionConfigName) { this.id = id; this.runnable = task; this.defaultTriggerExpression = defaultTriggerExpression; this.triggerExpressionConfigName = triggerExpressionConfigName; } } protected static class DisabledTrigger implements Trigger { @Override public Date nextExecutionTime(TriggerContext triggerContext) { return null; } @Override public boolean equals(Object o) { if (o == null) { return false; } if (o == this) { return true; } return o.getClass().equals(DisabledTrigger.class); } @Override public int hashCode() { return DisabledTrigger.class.getName().hashCode(); } } protected static class OnStartUpTrigger implements Trigger { boolean hasRun = false; @Override public Date nextExecutionTime(TriggerContext triggerContext) { if (hasRun) return null; else { hasRun = true; } return new Date(); } @Override public boolean equals(Object o) { if (o == null) { return false; } if (o == this) { return true; } return o.getClass().equals(OnStartUpTrigger.class); } @Override public int hashCode() { return OnStartUpTrigger.class.getName().hashCode(); } } protected static class BadConfigTrigger extends DisabledTrigger { public String config; public Throwable cause; public BadConfigTrigger(String config, Throwable cause) { this.config = config; this.cause = cause; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; if (!super.equals(o)) return false; BadConfigTrigger that = (BadConfigTrigger) o; if (cause != null ? !cause.equals(that.cause) : that.cause != null) return false; if (config != null ? !config.equals(that.config) : that.config != null) return false; return true; } @Override public int hashCode() { int result = super.hashCode(); result = 31 * result + (config != null ? config.hashCode() : 0); result = 31 * result + (cause != null ? cause.hashCode() : 0); return result; } } protected static class BadTriggerConfigException extends NestedRuntimeException { private String config; public BadTriggerConfigException(String message, String config, Throwable cause) { super(message, cause); this.config = config; } public BadTriggerConfigException(String message, String config) { super(message); this.config = config; } public String getConfig() { return config; } } }