Merge pull request #22636 from eileenmcnaughton/exampley
[civicrm-core.git] / CRM / Queue / Runner.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
10 */
11
12 /**
13 * `CRM_Queue_Runner` runs a list tasks from a queue. It originally supported the database-upgrade
14 * queue. Consequently, this runner is optimal for queues which are:
15 *
16 * - Short lived and discrete. You have a fixed list of tasks that will be run to completion.
17 * - Strictly linear. Tasks must run 1-by-1. Often, one task depends on the success of a previous task.
18 * - Slightly dangerous. An error, omission, or mistake indicates that the database is in an
19 * inconsistent state. Errors call for skilled human intervention.
20 *
21 * This runner supports a few modes of operation, eg
22 *
23 * - `runAllViaWeb()`: Use a web-browser and a series of AJAX requests to perform all steps.
24 * If there is an error, prompt the sysadmin/user to decide how to handle it.
25 * - `runAll()`: Run all tasks, 1-by-1, back-to-back. If there is an error, give up.
26 * This is used by some CLI upgrades.
27 *
28 * This runner is not appropriate for all queues or workloads, so you might choose or create
29 * a different runner. For example, `CRM_Queue_Autorunner` is geared toward background task lists.
30 *
31 * @see CRM_Queue_Autorunner
32 */
33 class CRM_Queue_Runner {
34
35 /**
36 * The failed task should be discarded, and queue processing should continue.
37 */
38 const ERROR_CONTINUE = 1;
39
40 /**
41 * The failed task should be kept in the queue, and queue processing should
42 * abort.
43 */
44 const ERROR_ABORT = 2;
45
46 /**
47 * @var string
48 */
49 public $title;
50
51 /**
52 * @var CRM_Queue_Queue
53 */
54 public $queue;
55 public $errorMode;
56 public $isMinimal;
57 public $onEnd;
58 public $onEndUrl;
59 public $pathPrefix;
60 /**
61 * queue-runner id; used for persistence
62 * @var int
63 */
64 public $qrid;
65
66 /**
67 * @var array
68 * Whether to display buttons, eg ('retry' => TRUE, 'skip' => FALSE)
69 */
70 public $buttons;
71
72 /**
73 * @var CRM_Queue_TaskContext
74 */
75 public $taskCtx;
76
77 /**
78 * Locate a previously-created instance of the queue-runner.
79 *
80 * @param string $qrid
81 * The queue-runner ID.
82 *
83 * @return CRM_Queue_Runner|NULL
84 */
85 public static function instance($qrid) {
86 if (!empty($_SESSION['queueRunners'][$qrid])) {
87 return unserialize($_SESSION['queueRunners'][$qrid]);
88 }
89 else {
90 return NULL;
91 }
92 }
93
94 /**
95 *
96 * FIXME: parameter validation
97 * FIXME: document signature of onEnd callback
98 *
99 * @param array $runnerSpec
100 * Array with keys:
101 * - queue: CRM_Queue_Queue
102 * - errorMode: int, ERROR_CONTINUE or ERROR_ABORT.
103 * - onEnd: mixed, a callback to update the UI after running; should be
104 * both callable and serializable.
105 * - onEndUrl: string, the URL to which one redirects.
106 * - pathPrefix: string, prepended to URLs for the web-runner;
107 * default: 'civicrm/queue'.
108 */
109 public function __construct($runnerSpec) {
110 $this->title = CRM_Utils_Array::value('title', $runnerSpec, ts('Queue Runner'));
111 $this->queue = $runnerSpec['queue'];
112 $this->errorMode = CRM_Utils_Array::value('errorMode', $runnerSpec, self::ERROR_ABORT);
113 $this->isMinimal = CRM_Utils_Array::value('isMinimal', $runnerSpec, FALSE);
114 $this->onEnd = $runnerSpec['onEnd'] ?? NULL;
115 $this->onEndUrl = $runnerSpec['onEndUrl'] ?? NULL;
116 $this->pathPrefix = CRM_Utils_Array::value('pathPrefix', $runnerSpec, 'civicrm/queue');
117 $this->buttons = CRM_Utils_Array::value('buttons', $runnerSpec, ['retry' => TRUE, 'skip' => TRUE]);
118 // perhaps this value should be randomized?
119 $this->qrid = $this->queue->getName();
120 }
121
122 /**
123 * @return array
124 */
125 public function __sleep() {
126 // exclude taskCtx
127 return [
128 'title',
129 'queue',
130 'errorMode',
131 'isMinimal',
132 'onEnd',
133 'onEndUrl',
134 'pathPrefix',
135 'qrid',
136 'buttons',
137 ];
138 }
139
140 /**
141 * Redirect to the web-based queue-runner and evaluate all tasks in a queue.
142 */
143 public function runAllViaWeb() {
144 $_SESSION['queueRunners'][$this->qrid] = serialize($this);
145 $url = CRM_Utils_System::url($this->pathPrefix . '/runner', 'reset=1&qrid=' . urlencode($this->qrid));
146 CRM_Utils_System::redirect($url);
147 // TODO: evaluate items incrementally via AJAX polling, cleanup session
148 }
149
150 /**
151 * Immediately run all tasks in a queue (until either reaching the end
152 * of the queue or encountering an error)
153 *
154 * If the runner has an onEndUrl, then this function will not return
155 *
156 * @return mixed
157 * TRUE if all tasks complete normally; otherwise, an array describing the
158 * failed task
159 */
160 public function runAll() {
161 $taskResult = $this->formatTaskResult(TRUE);
162 while ($taskResult['is_continue']) {
163 // setRaiseException should't be necessary here, but there's a bug
164 // somewhere which causes this setting to be lost. Observed while
165 // upgrading 4.0=>4.2. This preference really shouldn't be a global
166 // setting -- it should be more of a contextual/stack-based setting.
167 // This should be appropriate because queue-runners are not used with
168 // basic web pages -- they're used with CLI/REST/AJAX.
169 $taskResult = $this->runNext();
170 $errorScope = NULL;
171 }
172
173 if ($taskResult['numberOfItems'] == 0) {
174 $result = $this->handleEnd();
175 if (!empty($result['redirect_url'])) {
176 CRM_Utils_System::redirect($result['redirect_url']);
177 }
178 return TRUE;
179 }
180 else {
181 return $taskResult;
182 }
183 }
184
185 /**
186 * Take the next item from the queue and attempt to run it.
187 *
188 * Individual tasks may also throw exceptions -- caller should watch for
189 * exceptions.
190 *
191 * @param bool $useSteal
192 * Whether to steal active locks.
193 *
194 * @return array
195 * - is_error => bool,
196 * - is_continue => bool,
197 * - numberOfItems => int,
198 * - 'last_task_title' => $,
199 * - 'exception' => $
200 */
201 public function runNext($useSteal = FALSE) {
202 if ($useSteal) {
203 $item = $this->queue->stealItem();
204 }
205 else {
206 $item = $this->queue->claimItem();
207 }
208
209 if ($item) {
210 $this->lastTaskTitle = $item->data->title;
211
212 $exception = NULL;
213 try {
214 CRM_Core_Error::debug_log_message("Running task: " . $this->lastTaskTitle);
215 $isOK = $item->data->run($this->getTaskContext());
216 if (!$isOK) {
217 $exception = new Exception('Task returned false');
218 }
219 }
220 catch (Exception $e) {
221 $isOK = FALSE;
222 $exception = $e;
223 }
224
225 if ($isOK) {
226 $this->queue->deleteItem($item);
227 }
228 else {
229 $this->releaseErrorItem($item);
230 }
231
232 return $this->formatTaskResult($isOK, $exception);
233 }
234 else {
235 return $this->formatTaskResult(FALSE, new Exception('Failed to claim next task'));
236 }
237 }
238
239 /**
240 * Take the next item from the queue and attempt to run it.
241 *
242 * Individual tasks may also throw exceptions -- caller should watch for
243 * exceptions.
244 *
245 * @param bool $useSteal
246 * Whether to steal active locks.
247 *
248 * @return array
249 * - is_error => bool,
250 * - is_continue => bool,
251 * - numberOfItems => int)
252 */
253 public function skipNext($useSteal = FALSE) {
254 if ($useSteal) {
255 $item = $this->queue->stealItem();
256 }
257 else {
258 $item = $this->queue->claimItem();
259 }
260
261 if ($item) {
262 $this->lastTaskTitle = $item->data->title;
263 $this->queue->deleteItem($item);
264 return $this->formatTaskResult(TRUE);
265 }
266 else {
267 return $this->formatTaskResult(FALSE, new Exception('Failed to claim next task'));
268 }
269 }
270
271 /**
272 * Release an item in keeping with the error mode.
273 *
274 * @param object $item
275 * The item previously produced by Queue::claimItem.
276 */
277 protected function releaseErrorItem($item) {
278 switch ($this->errorMode) {
279 case self::ERROR_CONTINUE:
280 $this->queue->deleteItem($item);
281 case self::ERROR_ABORT:
282 default:
283 $this->queue->releaseItem($item);
284 }
285 }
286
287 /**
288 * @return array
289 * - is_error => bool,
290 * - is_continue => bool,
291 * - numberOfItems => int,
292 * - redirect_url => string
293 */
294 public function handleEnd() {
295 if (is_callable($this->onEnd)) {
296 call_user_func($this->onEnd, $this->getTaskContext());
297 }
298 // Don't remove queueRunner until onEnd succeeds
299 if (!empty($_SESSION['queueRunners'][$this->qrid])) {
300 unset($_SESSION['queueRunners'][$this->qrid]);
301 }
302
303 // Fallback; web UI does redirect in Javascript
304 $result = [];
305 $result['is_error'] = 0;
306 $result['numberOfItems'] = 0;
307 $result['is_continue'] = 0;
308 if (!empty($this->onEndUrl)) {
309 $result['redirect_url'] = $this->onEndUrl;
310 }
311 return $result;
312 }
313
314 /**
315 * Format a result record which describes whether the task completed.
316 *
317 * @param bool $isOK
318 * TRUE if the task completed successfully.
319 * @param Exception|null $exception
320 * If applicable, an unhandled exception that arose during execution.
321 *
322 * @return array
323 * (is_error => bool, is_continue => bool, numberOfItems => int)
324 */
325 public function formatTaskResult($isOK, $exception = NULL) {
326 $numberOfItems = $this->queue->numberOfItems();
327
328 $result = [];
329 $result['is_error'] = $isOK ? 0 : 1;
330 $result['exception'] = $exception;
331 $result['last_task_title'] = $this->lastTaskTitle ?? '';
332 $result['numberOfItems'] = $this->queue->numberOfItems();
333 if ($result['numberOfItems'] <= 0) {
334 // nothing to do
335 $result['is_continue'] = 0;
336 }
337 elseif ($isOK) {
338 // more tasks remain, and this task succeeded
339 $result['is_continue'] = 1;
340 }
341 elseif ($this->errorMode == CRM_Queue_Runner::ERROR_CONTINUE) {
342 // more tasks remain, and we can disregard this error
343 $result['is_continue'] = 1;
344 }
345 else {
346 // more tasks remain, but we can't disregard the error
347 $result['is_continue'] = 0;
348 }
349
350 return $result;
351 }
352
353 /**
354 * @return CRM_Queue_TaskContext
355 */
356 protected function getTaskContext() {
357 if (!is_object($this->taskCtx)) {
358 $this->taskCtx = new CRM_Queue_TaskContext();
359 $this->taskCtx->queue = $this->queue;
360 // $this->taskCtx->log = CRM_Core_Config::getLog();
361 $this->taskCtx->log = CRM_Core_Error::createDebugLogger();
362 }
363 return $this->taskCtx;
364 }
365
366 }