/** * ============================================================================= * * ORCID (R) Open Source * http://orcid.org * * Copyright (c) 2012-2014 ORCID, Inc. * Licensed under an MIT-Style License (MIT) * http://orcid.org/open-source-license * * This copyright and license information (including a link to the full license) * shall be included in its entirety in all copies or substantial portion of * the software. * * ============================================================================= */ package org.orcid.core.manager.impl; import java.io.IOException; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import javax.annotation.Resource; import org.apache.http.HttpResponse; import org.apache.http.client.HttpClient; import org.apache.http.client.methods.HttpPost; import org.apache.http.util.EntityUtils; import org.orcid.core.manager.WebhookManager; import org.orcid.persistence.dao.WebhookDao; import org.orcid.persistence.jpa.entities.WebhookEntity; import org.orcid.persistence.jpa.entities.keys.WebhookEntityPk; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.support.TransactionCallbackWithoutResult; import org.springframework.transaction.support.TransactionTemplate; public class WebhookManagerImpl implements WebhookManager { private int maxJobsPerClient; private int numberOfWebhookThreads; private int retryDelayMinutes; private int maxPerRun; @Resource private HttpClient httpClient; @Resource private WebhookDao webhookDao; @Resource private WebhookDao webhookDaoReadOnly; @Resource private TransactionTemplate transactionTemplate; private Map<String, Integer> clientWebhooks = new HashMap<String, Integer>(); private Object mainWebhooksLock = new Object(); private static final Logger LOGGER = LoggerFactory.getLogger(WebhookManagerImpl.class); private static final int WEBHOOKS_BATCH_SIZE = 1000; public void setMaxJobsPerClient(int maxJobs) { this.maxJobsPerClient = maxJobs; } public void setNumberOfWebhookThreads(int numberOfWebhookThreads) { this.numberOfWebhookThreads = numberOfWebhookThreads; } public void setRetryDelayMinutes(int retryDelayMinutes) { this.retryDelayMinutes = retryDelayMinutes; } public void setMaxPerRun(int maxPerRun) { this.maxPerRun = maxPerRun; } public void setHttpClient(HttpClient httpClient) { this.httpClient = httpClient; } public void setWebhookDao(WebhookDao webhookDao) { this.webhookDao = webhookDao; } @Override public void processWebhooks() { // Only want one of these running at a time, otherwise we will // potentially have two threads retrieving the same stuff from the DB // for processing. LOGGER.info("Waiting for main webhooks lock"); synchronized (mainWebhooksLock) { LOGGER.info("Obtained main webhooks lock"); processWebhooksInternal(); } LOGGER.info("Released main webhooks lock"); } private void processWebhooksInternal() { // Log start time LOGGER.info("About to process webhooks"); Date startTime = new Date(); long count = webhookDaoReadOnly.countWebhooksReadyToProcess(startTime, retryDelayMinutes); LOGGER.info("Total number of webhooks ready to process={}", count); // Create thread pool of size determined by runtime property ExecutorService executorService = createThreadPoolForWebhooks(); List<WebhookEntity> webhooks = new ArrayList<>(0); Map<WebhookEntityPk, WebhookEntity> mapOfpreviousBatch = null; int executedCount = 0; OUTER: do { mapOfpreviousBatch = WebhookEntity.mapById(webhooks); // Get chunk of webhooks to process for records that changed before // start time webhooks = webhookDaoReadOnly.findWebhooksReadyToProcess(startTime, retryDelayMinutes, WEBHOOKS_BATCH_SIZE); // Log the chunk size LOGGER.info("Found batch of {} webhooks to process", webhooks.size()); int executedCountAtStartOfChunk = executedCount; // For each callback in chunk for (final WebhookEntity webhook : webhooks) { if (executedCount == maxPerRun) { LOGGER.info("Reached maxiumum of {} webhooks for this run", executedCount); break OUTER; } // Need to ignore anything in previous chunk if (mapOfpreviousBatch.containsKey(webhook.getId())) { LOGGER.debug("Skipping webhook as was in previous batch: {}", webhook.getId()); continue; } // Submit job to thread pool executorService.execute(new Runnable() { public void run() { processWebhookInTransaction(webhook); } }); executedCount++; } if (executedCount == executedCountAtStartOfChunk) { LOGGER.info("No more webhooks added to pool, because all were in previous chunk"); break; } } while (!webhooks.isEmpty()); executorService.shutdown(); try { LOGGER.info("Waiting for webhooks thread pool to finish"); executorService.awaitTermination(120, TimeUnit.SECONDS); } catch (InterruptedException e) { LOGGER.warn("Received an interupt exception whilst waiting for the webhook processing complete", e); } LOGGER.info("Finished processing webhooks. Number of webhooks processed={}", executedCount); } private ExecutorService createThreadPoolForWebhooks() { // The queue size is half the batch size, to make sure the thread pool // has a chance to do some stuff before we go back to the DB for more, return new ThreadPoolExecutor(numberOfWebhookThreads, numberOfWebhookThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>( WEBHOOKS_BATCH_SIZE / 2), Executors.defaultThreadFactory(), new ThreadPoolExecutor.CallerRunsPolicy()); } private void processWebhookInTransaction(final WebhookEntity webhook) { transactionTemplate.execute(new TransactionCallbackWithoutResult() { protected void doInTransactionWithoutResult(TransactionStatus status) { processWebhook(webhook); } }); } @Override public void processWebhook(WebhookEntity webhook) { String clientId = webhook.getClientDetails().getClientId(); String orcid = webhook.getProfile().getId(); String uri = webhook.getUri(); if (webhookMaxed(clientId)) { LOGGER.warn("Thread limit exceeded by Client: {} With ORCID: {}; cannot process webhook: {}", new Object[] { clientId, orcid, webhook.getUri() }); return; } increaseWebhook(clientId); // Log attempt to process webhook LOGGER.info("Processing webhook {} for Client: {} With ORCID: {}", new Object[] { webhook.getUri(), clientId, orcid }); // Execute the request and get the client response try { int statusCode = doPost(uri); if (statusCode >= 200 && statusCode < 300) { LOGGER.info("Webhook {} for Client: {} With ORCID: {} has been processed", new Object[] { webhook.getUri(), clientId, orcid }); webhook.setLastSent(new Date()); webhook.setFailedAttemptCount(0); } else { LOGGER.warn("Webhook {} for Client: {} With ORCID: {} could not be processed because of response status code: {}", new Object[] { webhook.getUri(), clientId, orcid, statusCode }); webhook.setLastFailed(new Date()); webhook.setFailedAttemptCount(webhook.getFailedAttemptCount() + 1); } webhookDao.merge(webhook); } finally { decreaseWebhook(clientId); } } /** * Increases webhooks count by 1 for the specific client; * * @param clientId * */ private synchronized void increaseWebhook(String clientId) { clientWebhooks.put(clientId, webhookCount(clientId) + 1); } /** * Decreases webhooks count by 1 for the specific client; * * @param clientId * */ private synchronized void decreaseWebhook(String clientId) { clientWebhooks.put(clientId, webhookCount(clientId) - 1); } /** * Return the number of webhooks associated with a specific user * * @param clientId * @return the number of webhooks associated with the client * */ private synchronized int webhookCount(String clientId) { if (!clientWebhooks.containsKey(clientId)) clientWebhooks.put(clientId, 0); return clientWebhooks.get(clientId); } /** * Indicates if the max number of hooks has been reached by a client * * @param clientId * @return true if there are more than this.maxJobsPerClient threads running * for a client * */ private synchronized boolean webhookMaxed(String clientId) { return webhookCount(clientId) > this.maxJobsPerClient ? true : false; } /** * Executes a post request to a specific URL. * * @param url * the URL where the post request will be sent * @return httpResponse the response from the URL after executing the * request * */ private int doPost(String url) { if (!url.toLowerCase().startsWith("http")) { url = "http://" + url; } HttpResponse response = null; try { HttpPost httpPost = new HttpPost(url); response = httpClient.execute(httpPost); return response.getStatusLine().getStatusCode(); } catch (IllegalStateException | IOException e) { LOGGER.error(String.format("Error processing webhook %s", url), e); } finally { if (response != null && response.getEntity() != null) { try { EntityUtils.consume(response.getEntity()); } catch (IOException e) { LOGGER.error(String.format("Unable to release connection for webhook %s", url), e); } } } return 0; } @Override public void update(WebhookEntity webhook) { webhookDao.merge(webhook); webhookDao.flush(); } @Override public void delete(WebhookEntityPk webhookPk) { webhookDao.remove(webhookPk); webhookDao.flush(); } @Override public WebhookEntity find(WebhookEntityPk webhookPk) { return webhookDao.find(webhookPk); } }