/** * Licensed to The Apereo Foundation under one or more contributor license * agreements. See the NOTICE file distributed with this work for additional * information regarding copyright ownership. * * * The Apereo Foundation licenses this file to you under the Educational * Community 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://opensource.org/licenses/ecl2.txt * * 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.opencastproject.capture.admin.impl; import static org.apache.commons.lang3.StringUtils.isBlank; import static org.opencastproject.capture.admin.api.AgentState.KNOWN_STATES; import static org.opencastproject.capture.admin.api.AgentState.UNKNOWN; import static org.opencastproject.util.OsgiUtil.getOptContextProperty; import org.opencastproject.capture.admin.api.Agent; import org.opencastproject.capture.admin.api.AgentState; import org.opencastproject.capture.admin.api.CaptureAgentStateService; import org.opencastproject.capture.admin.api.Recording; import org.opencastproject.capture.admin.api.RecordingState; import org.opencastproject.message.broker.api.MessageSender; import org.opencastproject.message.broker.api.agent.RecordingItem; import org.opencastproject.scheduler.api.SchedulerException; import org.opencastproject.scheduler.api.SchedulerService; import org.opencastproject.security.api.Organization; import org.opencastproject.security.api.Role; import org.opencastproject.security.api.SecurityConstants; import org.opencastproject.security.api.SecurityService; import org.opencastproject.security.api.User; import org.opencastproject.util.NotFoundException; import org.opencastproject.util.data.Option; import org.opencastproject.util.data.Tuple3; import com.entwinemedia.fn.data.Opt; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import com.google.common.cache.RemovalCause; import com.google.common.cache.RemovalListener; import com.google.common.cache.RemovalNotification; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.exception.ExceptionUtils; import org.osgi.service.cm.ConfigurationException; import org.osgi.service.cm.ManagedServiceFactory; import org.osgi.service.component.ComponentContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.Arrays; import java.util.Dictionary; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Properties; import java.util.Set; import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import javax.persistence.EntityManager; import javax.persistence.EntityManagerFactory; import javax.persistence.EntityTransaction; import javax.persistence.NoResultException; import javax.persistence.Query; import javax.persistence.RollbackException; /** * IMPL for the capture-admin service (MH-1336, MH-1394, MH-1457, MH-1475 and MH-1476). */ public class CaptureAgentStateServiceImpl implements CaptureAgentStateService, ManagedServiceFactory { private static final Logger logger = LoggerFactory.getLogger(CaptureAgentStateServiceImpl.class); /** The name of the persistence unit for this class */ public static final String PERSISTENCE_UNIT = "org.opencastproject.capture.admin.impl.CaptureAgentStateServiceImpl"; /** The delimiter for the CA configuration cache */ private static final String DELIMITER = ";==;"; /** The factory used to generate the entity manager */ protected EntityManagerFactory emf = null; /** The scheduler service */ protected SchedulerService schedulerService; /** The security service */ protected SecurityService securityService; /** The message broker service sender */ protected MessageSender messageSender; // TODO: Remove the in-memory recordings map, and use the database instead private HashMap<String, Recording> recordings; /** Maps the configuration PID to the agent ID, so agents can be updated via the configuration factory pattern */ protected Map<String, String> pidMap = new ConcurrentHashMap<String, String>(); /** A cache of CA properties, which lightens the load on the SQL server */ private LoadingCache<String, Object> agentCache = null; /** Configuration key for capture agent timeout in minutes before being marked offline */ public static final String CAPTURE_AGENT_TIMEOUT_KEY = "org.opencastproject.capture.admin.timeout"; /** A token to store in the miss cache */ protected Object nullToken = new Object(); /** OSGi DI */ void setEntityManagerFactory(EntityManagerFactory emf) { this.emf = emf; } /** OSGi DI */ void setMessageSender(MessageSender messageSender) { this.messageSender = messageSender; } /** * Sets the scheduler service * * @param schedulerService * the schedulerService to set */ public void setSchedulerService(SchedulerService schedulerService) { this.schedulerService = schedulerService; } /** * @param securityService * the securityService to set */ public void setSecurityService(SecurityService securityService) { this.securityService = securityService; } public CaptureAgentStateServiceImpl() { logger.info("CaptureAgentStateServiceImpl starting."); recordings = new HashMap<String, Recording>(); } public void activate(ComponentContext cc) { // Set up the agent cache int timeoutInMinutes = 120; Option<String> timeout = getOptContextProperty(cc, CAPTURE_AGENT_TIMEOUT_KEY); if (timeout.isSome()) { try { timeoutInMinutes = Integer.parseInt(timeout.get()); } catch (NumberFormatException e) { logger.warn("Invalid configuration for capture agent status timeout (minutes) ({}={})", CAPTURE_AGENT_TIMEOUT_KEY, timeout.get()); } } setupAgentCache(timeoutInMinutes, TimeUnit.MINUTES); logger.info("Capture agent status timeout is {} minutes", timeoutInMinutes); } public void deactivate() { agentCache.invalidateAll(); } /** * {@inheritDoc} * * @see org.opencastproject.capture.admin.api.CaptureAgentStateService#getAgent(java.lang.String) */ @Override public Agent getAgent(String name) throws NotFoundException { String org = securityService.getOrganization().getId(); Agent agent = getAgent(name, org); return updateCachedLastHeardFrom(agent, org); } /** * {@inheritDoc} * * @see org.opencastproject.capture.admin.api.CaptureAgentStateService#updateAgent(Agent) */ @Override public void updateAgent(Agent agent) { updateAgentInDatabase((AgentImpl) agent); } /** * Gets an agent by name and organization. * * @param name * the unique agent name * @param org * the organization identifier * @return the agent */ protected AgentImpl getAgent(String name, String org) throws NotFoundException { EntityManager em = null; try { em = emf.createEntityManager(); AgentImpl agent = getAgentEntity(name, org, em); if (agent == null) throw new NotFoundException(); return agent; } finally { if (em != null) em.close(); } } /** * Gets an agent by name and organization, using an open entitymanager. * * @param name * the unique agent name * @param organization * the organization * @param em * the entity manager * @return the agent or <code>null</code> if no agent has been found */ protected AgentImpl getAgentEntity(String name, String organization, EntityManager em) { try { Query q = em.createNamedQuery("Agent.get"); q.setParameter("id", name); q.setParameter("org", organization); return (AgentImpl) q.getSingleResult(); } catch (NoResultException e) { return null; } } /** * Mix in the last-seen timestamp from the agent cache * * @param agent * The Agent you wish to update * @param org * the organization * @return the agent */ protected Agent updateCachedLastHeardFrom(Agent agent, String org) { String agentKey = agent.getName().concat(DELIMITER).concat(org); Tuple3<String, Properties, Long> cachedAgent = (Tuple3) agentCache.getUnchecked(agentKey); if (cachedAgent != null) { agent.setLastHeardFrom(cachedAgent.getC()); } return agent; } /** * {@inheritDoc} * * @see org.opencastproject.capture.admin.api.CaptureAgentStateService#getAgentState(java.lang.String) */ @Override public String getAgentState(String agentName) throws NotFoundException { String orgId = securityService.getOrganization().getId(); Tuple3<String, Properties, Long> agent = getAgentFromCache(agentName, orgId); return agent.getA(); } /** * {@inheritDoc} * * @see org.opencastproject.capture.admin.api.CaptureAgentStateService#setAgentState(java.lang.String, * java.lang.String) */ @Override public boolean setAgentState(String agentName, String state) { if (StringUtils.isBlank(agentName)) throw new IllegalArgumentException("Unable to set agent state, agent name is blank or null."); if (StringUtils.isBlank(state)) throw new IllegalArgumentException("Unable to set agent state, state is blank or null."); if (!KNOWN_STATES.contains(state)) throw new IllegalArgumentException("Can not set agent to an invalid state: ".concat(state)); logger.debug("Agent '{}' state set to '{}'", agentName, state); AgentImpl agent; String orgId = securityService.getOrganization().getId(); try { //Check the return code, if it's false then we don't need to update the DB, and we should also return false if (!updateAgentInCache(agentName, state, orgId)) { return false; } agent = (AgentImpl) getAgent(agentName); // the agent is known, so set the state logger.debug("Setting Agent {} to state {}.", agentName, state); agent.setState(state); if (!AgentState.UNKNOWN.equals(state)) { agent.setLastHeardFrom(System.currentTimeMillis()); } } catch (NotFoundException e) { // If the agent doesn't exists, but the name is not null nor empty, create a new one. logger.debug("Creating Agent {} with state {}.", agentName, state); agent = new AgentImpl(agentName, orgId, state, "", new Properties()); } updateAgentInDatabase(agent); return true; } /** * Updates the agent cache, and tells you whether you need to update the database as well * * @param agentName * The name of the agent in thecache * @param state * The new state for the agent * @param orgId * The organization the agent is a part of * @return * True if the agent state database needs to be updated, false otherwise */ private boolean updateAgentInCache(String agentName, String state, String orgId) { return updateAgentInCache(agentName, state, orgId, null); } /** * Updates the agent cache, and tells you whether you need to update the database as well * * @param agentName * The name of the agent in thecache * @param state * The new state for the agent * @param orgId * The organization the agent is a part of * @param configuration * The agent's configuration * @return * True if the agent state database needs to be updated, false otherwise */ private boolean updateAgentInCache(String agentName, String state, String orgId, Properties configuration) { try { String agentState = getAgentFromCache(agentName, orgId).getA(); Properties config = getAgentConfiguration(agentName); if (configuration != null) { config = configuration; } if (!AgentState.UNKNOWN.equals(state)) { agentCache.put(agentName.concat(DELIMITER).concat(orgId), Tuple3.tuple3(state, config, Long.valueOf(System.currentTimeMillis()))); } else { //If we're putting the agent into an unknown state we're assuming that we didn't get a check in // therefore we don't update the timestamp and persist to the DB agentCache.put(agentName.concat(DELIMITER).concat(orgId), Tuple3.tuple3(state, config, getAgentFromCache(agentName, orgId).getC())); } if (agentState.equals(state)) { return false; } return true; } catch (NotFoundException e) { agentCache.put(agentName.concat(DELIMITER).concat(orgId), Tuple3.tuple3(state, configuration, Long.valueOf(System.currentTimeMillis()))); return true; } } /** * {@inheritDoc} * * @see org.opencastproject.capture.admin.api.CaptureAgentStateService#setAgentUrl(String, String) */ @Override public boolean setAgentUrl(String agentName, String agentUrl) throws NotFoundException { Agent agent = getAgent(agentName); if (agent.getUrl().equals(agentUrl)) return false; agent.setUrl(agentUrl); updateAgentInDatabase((AgentImpl) agent); return true; } /** * {@inheritDoc} * * @see org.opencastproject.capture.admin.api.CaptureAgentStateService#removeAgent(java.lang.String) */ @Override public void removeAgent(String agentName) throws NotFoundException { agentCache.invalidate(agentName); deleteAgentFromDatabase(agentName); } /** * {@inheritDoc} * * @see org.opencastproject.capture.admin.api.CaptureAgentStateService#getKnownAgents() */ @Override public Map<String, Agent> getKnownAgents() { agentCache.cleanUp(); EntityManager em = null; User user = securityService.getUser(); Organization org = securityService.getOrganization(); String orgAdmin = org.getAdminRole(); Set<Role> roles = user.getRoles(); try { em = emf.createEntityManager(); Query q = em.createNamedQuery("Agent.byOrganization"); q.setParameter("org", securityService.getOrganization().getId()); // Filter the results in memory if this user is not an administrator List<AgentImpl> agents = q.getResultList(); if (!user.hasRole(SecurityConstants.GLOBAL_ADMIN_ROLE) && !user.hasRole(orgAdmin)) { for (Iterator<AgentImpl> iter = agents.iterator(); iter.hasNext();) { AgentImpl agent = iter.next(); Set<String> schedulerRoles = agent.getSchedulerRoles(); // If there are no roles associated with this capture agent, it is available to anyone who can pass the // coarse-grained web layer security if (schedulerRoles == null || schedulerRoles.isEmpty()) { continue; } boolean hasSchedulerRole = false; for (Role role : roles) { if (schedulerRoles.contains(role.getName())) { hasSchedulerRole = true; break; } } if (!hasSchedulerRole) { iter.remove(); } } } // Build the map that the API defines as agent name->agent Map<String, Agent> map = new TreeMap<String, Agent>(); for (AgentImpl agent : agents) { map.put(agent.getName(), updateCachedLastHeardFrom(agent, org.getId())); } return map; } finally { if (em != null) em.close(); } } /** * {@inheritDoc} * * @see org.opencastproject.capture.admin.api.CaptureAgentStateService#getAgentCapabilities(java.lang.String) */ @Override public Properties getAgentCapabilities(String agentName) throws NotFoundException { return getAgent(agentName).getCapabilities(); } /** * {@inheritDoc} * * @see org.opencastproject.capture.admin.api.CaptureAgentStateService#getAgentConfiguration(java.lang.String) */ @Override public Properties getAgentConfiguration(String agentName) throws NotFoundException { String orgId = securityService.getOrganization().getId(); Tuple3<String, Properties, Long> agent = getAgentFromCache(agentName, orgId); return agent.getB(); } @SuppressWarnings("unchecked") private Tuple3<String, Properties, Long> getAgentFromCache(String agentName, String orgId) throws NotFoundException { Object agent = agentCache.getUnchecked(agentName.concat(DELIMITER).concat(orgId)); if (agent == nullToken) { throw new NotFoundException(); } else { return (Tuple3<String, Properties, Long>) agent; } } /** * {@inheritDoc} * * @see org.opencastproject.capture.admin.api.CaptureAgentStateService#setAgentConfiguration */ @Override public boolean setAgentConfiguration(String agentName, Properties configuration) { if (StringUtils.isBlank(agentName)) throw new IllegalArgumentException("Unable to set agent state, agent name is blank or null."); String orgId = securityService.getOrganization().getId(); AgentImpl agent; try { Properties agentConfig = getAgentFromCache(agentName, orgId).getB(); if (agentConfig.equals(configuration)) { agentCache.put(agentName.concat(DELIMITER).concat(orgId), Tuple3.tuple3(getAgentState(agentName), agentConfig, Long.valueOf(System.currentTimeMillis()))); return false; } agent = (AgentImpl) getAgent(agentName); logger.debug("Setting Agent {}'s capabilities", agentName); agent.setConfiguration(configuration); } catch (NotFoundException e) { // If the agent doesn't exists, but the name is not null nor empty, create a new one. logger.debug("Creating Agent {} with state {}.", agentName, UNKNOWN); agent = new AgentImpl(agentName, orgId, UNKNOWN, "", configuration); } updateAgentInDatabase(agent); return true; } /** * Updates or adds an agent to the database. * * @param agent * The Agent you wish to modify or add in the database. */ protected void updateAgentInDatabase(AgentImpl agent) { updateAgentInDatabase(agent, true); } /** * Updates or adds an agent to the database. * * @param agent * The Agent you wish to modify or add in the database. * @param updateFromCache * True to update the last heard from timestamp from the agentCache, false to avoid this. * Note that you should nearly always update the cache, this was added to avoid deadlocks when removing agents from the cache. */ private void updateAgentInDatabase(AgentImpl agent, boolean updateFromCache) { EntityManager em = null; EntityTransaction tx = null; try { em = emf.createEntityManager(); tx = em.getTransaction(); tx.begin(); AgentImpl existing = getAgentEntity(agent.getName(), agent.getOrganization(), em); // Update the last seen property from the agent cache if (existing != null && updateFromCache) { try { Tuple3<String, Properties, Long> cachedAgent = getAgentFromCache(existing.getName(), existing.getOrganization()); if (agent != null && cachedAgent != null) { agent.setLastHeardFrom(cachedAgent.getC()); } } catch (NotFoundException e) { // That's fine } } if (existing == null) { em.persist(agent); } else { existing.setConfiguration(agent.getConfiguration()); if (!AgentState.UNKNOWN.equals(agent.getState())) { existing.setLastHeardFrom(agent.getLastHeardFrom()); } existing.setState(agent.getState()); existing.setSchedulerRoles(agent.getSchedulerRoles()); existing.setUrl(agent.getUrl()); em.merge(existing); } tx.commit(); if (updateFromCache) { updateAgentInCache(agent.getName(), agent.getState(), agent.getOrganization(), agent.getConfiguration()); } } catch (RollbackException e) { logger.warn("Unable to commit to DB in updateAgent."); throw e; } finally { if (em != null) em.close(); } } /** * Removes an agent from the database. * * @param agentName * The name of the agent you wish to remove. */ private void deleteAgentFromDatabase(String agentName) throws NotFoundException { EntityManager em = null; EntityTransaction tx = null; try { em = emf.createEntityManager(); tx = em.getTransaction(); tx.begin(); String org = securityService.getOrganization().getId(); Agent existing = getAgentEntity(agentName, org, em); if (existing == null) throw new NotFoundException(); em.remove(existing); tx.commit(); agentCache.invalidate(agentName.concat(DELIMITER).concat(org)); } catch (RollbackException e) { logger.warn("Unable to commit to DB in deleteAgent."); } finally { if (em != null) em.close(); } } /** * {@inheritDoc} * * @see org.opencastproject.capture.admin.api.CaptureAgentStateService#getRecordingState(java.lang.String) */ @Override public Recording getRecordingState(String id) throws NotFoundException { Recording req = recordings.get(id); // If that recording doesn't exist, return null if (req == null) { logger.debug("Recording {} does not exist in the system.", id); throw new NotFoundException(); } logger.debug("Recording {} found, returning state.", id); return req; } /** * {@inheritDoc} * * @see org.opencastproject.capture.admin.api.CaptureAgentStateService#setRecordingState(java.lang.String, * java.lang.String) * @throws IllegalArgumentException */ @Override public boolean setRecordingState(String id, String state) { if (StringUtils.isBlank(id)) throw new IllegalArgumentException("id can not be null"); if (StringUtils.isBlank(state)) throw new IllegalArgumentException("state can not be null"); if (!RecordingState.KNOWN_STATES.contains(state)) { logger.warn("Invalid recording state: {}.", state); return false; } Recording req = recordings.get(id); if (req != null) { if (state.equals(req.getState())) { logger.debug("Recording state not changed"); // Reset the state anyway so that the last-heard-from time is correct... req.setState(state); return true; } else { logger.debug("Setting Recording {} to state {}.", id, state); req.setState(state); sendRecordingUpdate(req); return true; } } else { logger.debug("Creating Recording {} with state {}.", id, state); Recording r = new RecordingImpl(id, state); recordings.put(id, r); sendRecordingUpdate(r); return true; } } private void sendRecordingUpdate(Recording recording) { if (RecordingState.UNKNOWN.equals(recording.getState())) return; Opt<String> eventId = getEventId(recording.getID()); if (eventId.isNone()) return; messageSender.sendObjectMessage(RecordingItem.RECORDING_QUEUE, MessageSender.DestinationType.Queue, RecordingItem.updateRecording(eventId.get(), recording.getState(), recording.getLastCheckinTime())); } private Opt<String> getEventId(String recordingId) { Opt<String> eventId = Opt.<String> none(); try { eventId = Opt.some(schedulerService.getMediaPackageId(Long.parseLong(recordingId))); } catch (NumberFormatException e) { logger.info("Recording id '{}' is not a long, assuming an unscheduled capture", recordingId); } catch (NotFoundException e) { logger.warn("Unable to find a scheduling with id='{}'", recordingId); } catch (SchedulerException e) { logger.warn("Unable to get scheduling for recording {}: {}", recordingId, ExceptionUtils.getStackTrace(e)); } return eventId; } /** * {@inheritDoc} * * @see org.opencastproject.capture.admin.api.CaptureAgentStateService#removeRecording(java.lang.String) */ @Override public void removeRecording(String id) throws NotFoundException { logger.debug("Removing Recording {}.", id); Recording removed = recordings.remove(id); if (removed == null) throw new NotFoundException(); Opt<String> eventId = getEventId(id); if (eventId.isSome()) messageSender.sendObjectMessage(RecordingItem.RECORDING_QUEUE, MessageSender.DestinationType.Queue, RecordingItem.delete(eventId.get())); } /** * {@inheritDoc} * * @see org.opencastproject.capture.admin.api.CaptureAgentStateService#getKnownRecordings() */ @Override public Map<String, Recording> getKnownRecordings() { return recordings; } @Override public List<String> getKnownRecordingsIds() { LinkedList<String> ids = new LinkedList<String>(); for (Entry<String, Recording> e : recordings.entrySet()) { ids.add(e.getValue().getID()); } return ids; } // // ManagedServiceFactory Methods //// /** * {@inheritDoc} * * @see org.osgi.service.cm.ManagedServiceFactory#getName() */ @Override public String getName() { return "org.opencastproject.capture.agent"; } protected void setupAgentCache(int count, TimeUnit unit) { // Setup the agent cache RemovalListener<String, Object> removalListener = new RemovalListener<String, Object>() { private Set<String> ignoredStates = new LinkedHashSet<String>(Arrays.asList(AgentState.UNKNOWN, AgentState.OFFLINE)); @Override public void onRemoval(RemovalNotification<String, Object> removal) { if (RemovalCause.EXPIRED.equals(removal.getCause())) { String org = securityService.getOrganization().getId(); try { String agentName = removal.getKey().split(DELIMITER)[0]; AgentImpl agent = getAgent(agentName, org); if (!ignoredStates.contains(agent.getState())) { agent.setState(AgentState.OFFLINE); updateAgentInDatabase(agent, false); } } catch (NotFoundException e) { //Ignore this //It should not happen, and if it does we just don't update the non-existant agent in the DB } } } }; agentCache = CacheBuilder.newBuilder().expireAfterWrite(count, unit).removalListener(removalListener).build(new CacheLoader<String, Object>() { @Override public Object load(String id) { String[] key = id.split(DELIMITER); AgentImpl agent; try { agent = getAgent(key[0], key[1]); } catch (NotFoundException e) { return nullToken; } return Tuple3.tuple3(agent.getState(), agent.getConfiguration(), agent.getLastHeardFrom()); } }); } /** * {@inheritDoc} * * @see org.osgi.service.cm.ManagedServiceFactory#updated(java.lang.String, java.util.Dictionary) */ @Override public void updated(String pid, Dictionary properties) throws ConfigurationException { // Get the agent properties String nameConfig = (String) properties.get("id"); if (isBlank(nameConfig)) throw new ConfigurationException("id", "must be specified"); nameConfig = nameConfig.trim(); String urlConfig = (String) properties.get("url"); if (isBlank(urlConfig)) throw new ConfigurationException("url", "must be specified"); urlConfig = urlConfig.trim(); String orgConfig = (String) properties.get("organization"); if (isBlank(orgConfig)) throw new ConfigurationException("organization", "must be specified"); orgConfig = orgConfig.trim(); String schedulerRolesConfig = (String) properties.get("schedulerRoles"); if (isBlank(schedulerRolesConfig)) throw new ConfigurationException("schedulerRoles", "must be specified"); String[] schedulerRoles = schedulerRolesConfig.trim().split(","); // If we don't already have a mapping for this PID, create one if (!pidMap.containsKey(pid)) { pidMap.put(pid, nameConfig); } AgentImpl agent; try { agent = getAgent(nameConfig, orgConfig); agent.setUrl(urlConfig); agent.setState(UNKNOWN); } catch (NotFoundException e) { agent = new AgentImpl(nameConfig, orgConfig, UNKNOWN, urlConfig, new Properties()); } for (String role : schedulerRoles) { agent.schedulerRoles.add(role.trim()); } // Update the database logger.info("Roles '{}' may schedule '{}'", schedulerRolesConfig, agent.name); updateAgentInDatabase(agent); } /** * {@inheritDoc} * * @see org.osgi.service.cm.ManagedServiceFactory#deleted(java.lang.String) */ @Override public void deleted(String pid) { String agentId = pidMap.remove(pid); if (agentId == null) { logger.warn("{} was not a managed capture agent pid", pid); } else { try { agentCache.invalidate(agentId); deleteAgentFromDatabase(agentId); } catch (NotFoundException e) { logger.warn("Unable to delete capture agent '{}'", agentId); } } } }