Commit | Line | Data |
---|---|---|
6a488035 TO |
1 | <?php |
2 | /* | |
3 | +--------------------------------------------------------------------+ | |
bc77d7c0 | 4 | | Copyright CiviCRM LLC. All rights reserved. | |
6a488035 | 5 | | | |
bc77d7c0 TO |
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 | | |
6a488035 | 9 | +--------------------------------------------------------------------+ |
d25dd0ee | 10 | */ |
6a488035 TO |
11 | |
12 | /** | |
47cd9404 TO |
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: | |
6a488035 | 15 | * |
47cd9404 TO |
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 | |
6a488035 TO |
32 | */ |
33 | class CRM_Queue_Runner { | |
34 | ||
35 | /** | |
4523a2f5 | 36 | * The failed task should be discarded, and queue processing should continue. |
6a488035 | 37 | */ |
4523a2f5 | 38 | const ERROR_CONTINUE = 1; |
6a488035 TO |
39 | |
40 | /** | |
4523a2f5 TO |
41 | * The failed task should be kept in the queue, and queue processing should |
42 | * abort. | |
6a488035 | 43 | */ |
4523a2f5 | 44 | const ERROR_ABORT = 2; |
6a488035 TO |
45 | |
46 | /** | |
47 | * @var string | |
48 | */ | |
4523a2f5 | 49 | public $title; |
6a488035 TO |
50 | |
51 | /** | |
52 | * @var CRM_Queue_Queue | |
53 | */ | |
4523a2f5 TO |
54 | public $queue; |
55 | public $errorMode; | |
56 | public $isMinimal; | |
57 | public $onEnd; | |
58 | public $onEndUrl; | |
59 | public $pathPrefix; | |
c86d4e7c SL |
60 | /** |
61 | * queue-runner id; used for persistence | |
62 | * @var int | |
63 | */ | |
4523a2f5 | 64 | public $qrid; |
6a488035 TO |
65 | |
66 | /** | |
51dda21e SL |
67 | * @var array |
68 | * Whether to display buttons, eg ('retry' => TRUE, 'skip' => FALSE) | |
6a488035 | 69 | */ |
4523a2f5 | 70 | public $buttons; |
6a488035 TO |
71 | |
72 | /** | |
73 | * @var CRM_Queue_TaskContext | |
74 | */ | |
4523a2f5 | 75 | public $taskCtx; |
6a488035 TO |
76 | |
77 | /** | |
4523a2f5 | 78 | * Locate a previously-created instance of the queue-runner. |
6a488035 | 79 | * |
4523a2f5 TO |
80 | * @param string $qrid |
81 | * The queue-runner ID. | |
6a488035 | 82 | * |
4523a2f5 | 83 | * @return CRM_Queue_Runner|NULL |
6a488035 | 84 | */ |
4523a2f5 | 85 | public static function instance($qrid) { |
6a488035 TO |
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 | * | |
4523a2f5 TO |
99 | * @param array $runnerSpec |
100 | * Array with keys: | |
6a488035 | 101 | * - queue: CRM_Queue_Queue |
4523a2f5 TO |
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'. | |
6a488035 TO |
108 | */ |
109 | public function __construct($runnerSpec) { | |
4523a2f5 TO |
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); | |
9c1bc317 CW |
114 | $this->onEnd = $runnerSpec['onEnd'] ?? NULL; |
115 | $this->onEndUrl = $runnerSpec['onEndUrl'] ?? NULL; | |
6a488035 | 116 | $this->pathPrefix = CRM_Utils_Array::value('pathPrefix', $runnerSpec, 'civicrm/queue'); |
be2fb01f | 117 | $this->buttons = CRM_Utils_Array::value('buttons', $runnerSpec, ['retry' => TRUE, 'skip' => TRUE]); |
6a488035 TO |
118 | // perhaps this value should be randomized? |
119 | $this->qrid = $this->queue->getName(); | |
120 | } | |
121 | ||
e0ef6999 EM |
122 | /** |
123 | * @return array | |
124 | */ | |
4523a2f5 | 125 | public function __sleep() { |
6a488035 | 126 | // exclude taskCtx |
be2fb01f | 127 | return [ |
4523a2f5 TO |
128 | 'title', |
129 | 'queue', | |
130 | 'errorMode', | |
131 | 'isMinimal', | |
132 | 'onEnd', | |
133 | 'onEndUrl', | |
134 | 'pathPrefix', | |
135 | 'qrid', | |
136 | 'buttons', | |
be2fb01f | 137 | ]; |
6a488035 TO |
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); | |
4523a2f5 | 147 | // TODO: evaluate items incrementally via AJAX polling, cleanup session |
6a488035 TO |
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 | * | |
4523a2f5 TO |
156 | * @return mixed |
157 | * TRUE if all tasks complete normally; otherwise, an array describing the | |
158 | * failed task | |
6a488035 TO |
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 | |
03e04002 | 166 | // setting -- it should be more of a contextual/stack-based setting. |
6a488035 TO |
167 | // This should be appropriate because queue-runners are not used with |
168 | // basic web pages -- they're used with CLI/REST/AJAX. | |
6a488035 TO |
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 | * | |
4523a2f5 TO |
188 | * Individual tasks may also throw exceptions -- caller should watch for |
189 | * exceptions. | |
6a488035 | 190 | * |
4523a2f5 TO |
191 | * @param bool $useSteal |
192 | * Whether to steal active locks. | |
6a488035 | 193 | * |
4523a2f5 TO |
194 | * @return array |
195 | * - is_error => bool, | |
196 | * - is_continue => bool, | |
197 | * - numberOfItems => int, | |
198 | * - 'last_task_title' => $, | |
199 | * - 'exception' => $ | |
6a488035 TO |
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 { | |
9901c22f | 214 | CRM_Core_Error::debug_log_message("Running task: " . $this->lastTaskTitle); |
6a488035 TO |
215 | $isOK = $item->data->run($this->getTaskContext()); |
216 | if (!$isOK) { | |
217 | $exception = new Exception('Task returned false'); | |
4523a2f5 | 218 | } |
0db6c3e1 | 219 | } |
47cd9404 | 220 | catch (Exception $e) { |
6a488035 TO |
221 | $isOK = FALSE; |
222 | $exception = $e; | |
4523a2f5 | 223 | } |
6a488035 TO |
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 | * | |
4523a2f5 TO |
242 | * Individual tasks may also throw exceptions -- caller should watch for |
243 | * exceptions. | |
6a488035 | 244 | * |
4523a2f5 TO |
245 | * @param bool $useSteal |
246 | * Whether to steal active locks. | |
6a488035 | 247 | * |
4523a2f5 TO |
248 | * @return array |
249 | * - is_error => bool, | |
250 | * - is_continue => bool, | |
251 | * - numberOfItems => int) | |
6a488035 TO |
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 | ||
e0ef6999 | 271 | /** |
4523a2f5 TO |
272 | * Release an item in keeping with the error mode. |
273 | * | |
274 | * @param object $item | |
275 | * The item previously produced by Queue::claimItem. | |
e0ef6999 | 276 | */ |
6a488035 TO |
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 | /** | |
4523a2f5 TO |
288 | * @return array |
289 | * - is_error => bool, | |
290 | * - is_continue => bool, | |
291 | * - numberOfItems => int, | |
292 | * - redirect_url => string | |
6a488035 TO |
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 | |
be2fb01f | 304 | $result = []; |
6a488035 TO |
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 | /** | |
4523a2f5 | 315 | * Format a result record which describes whether the task completed. |
6a488035 | 316 | * |
4523a2f5 TO |
317 | * @param bool $isOK |
318 | * TRUE if the task completed successfully. | |
2024d5b9 | 319 | * @param Exception|null $exception |
4523a2f5 TO |
320 | * If applicable, an unhandled exception that arose during execution. |
321 | * | |
322 | * @return array | |
323 | * (is_error => bool, is_continue => bool, numberOfItems => int) | |
6a488035 | 324 | */ |
4523a2f5 | 325 | public function formatTaskResult($isOK, $exception = NULL) { |
6a488035 TO |
326 | $numberOfItems = $this->queue->numberOfItems(); |
327 | ||
be2fb01f | 328 | $result = []; |
6a488035 TO |
329 | $result['is_error'] = $isOK ? 0 : 1; |
330 | $result['exception'] = $exception; | |
2e1f50d6 | 331 | $result['last_task_title'] = $this->lastTaskTitle ?? ''; |
6a488035 TO |
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 | ||
e0ef6999 EM |
353 | /** |
354 | * @return CRM_Queue_TaskContext | |
355 | */ | |
6a488035 TO |
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 | } | |
96025800 | 365 | |
6a488035 | 366 | } |