PageRenderTime 61ms CodeModel.GetById 32ms RepoModel.GetById 1ms app.codeStats 0ms

/modules/messagequeue/models/messagequeue.php

https://github.com/mozilla/byob
PHP | 453 lines | 260 code | 55 blank | 138 comment | 40 complexity | 2fa78fdd723ce472a51deb39da16fbe1 MD5 | raw file
  1. <?php
  2. /**
  3. * Model managing message queue
  4. *
  5. * @TODO un-reserve a message to release it
  6. * @TODO mark a message as a failure
  7. * @todo tie a profile to a message as owner
  8. * @todo methods to find statistics on messages, ie. avg time of execution, messages over time, etc.
  9. *
  10. * @TODO allow selective dequeue of messages based on subscription pattern
  11. * @TODO implement dependent batches that are processed in sequence
  12. * @TODO somehow exercise the lock on finding a new message?
  13. *
  14. * @package MessageQueue
  15. * @subpackage models
  16. * @author l.m.orchard <l.m.orchard@pobox.com>
  17. */
  18. class MessageQueue_Model extends Model
  19. {
  20. protected $_table_name = 'message_queue';
  21. protected $_subscriptions;
  22. protected $_objs;
  23. // Flag allowing global disabling of deferred messages.
  24. public $disable_deferred = false;
  25. const DUPLICATE_IGNORE = 0;
  26. const DUPLICATE_REPLACE = 1;
  27. const DUPLICATE_DISCARD = 2;
  28. /**
  29. * Initialize the model.
  30. */
  31. function __construct()
  32. {
  33. parent::__construct();
  34. $this->_batch_uuid = uuid::uuid();
  35. $this->_batch_seq = 0;
  36. $this->_subscriptions = array();
  37. $this->_objs = array();
  38. }
  39. /**
  40. * Subscribe to a message topic
  41. *
  42. * @param string message topic
  43. * @param string|object name of a class to instantiate, or an object instance
  44. * @param string method to invoke on the instance
  45. * @param mixed context data passed as second parameter to instance method
  46. * @return mixed Opaque subscription handle, for use with unsubscribe
  47. */
  48. public function subscribe($params)
  49. {
  50. // Accept named parameters with defaults.
  51. extract(array_merge(array(
  52. 'topic' => null,
  53. 'object' => null,
  54. 'method' => 'handleMessage',
  55. 'context' => null,
  56. 'deferred' => false,
  57. 'priority' => 0,
  58. 'duplicate' => self::DUPLICATE_IGNORE
  59. ), $params));
  60. // Create an array for this topic, if none exists
  61. if (!isset($this->_subscriptions[$topic]))
  62. $this->_subscriptions[$topic] = array();
  63. // Punt on serializing object instances in deferred subscriptions that
  64. // can be handled out of process.
  65. if ($deferred && !is_string($object)) {
  66. throw new Exception(
  67. 'Object instances cannot be used in deferred subscriptions.'
  68. );
  69. }
  70. // Add a new subscription record.
  71. $this->_subscriptions[$topic][] = array(
  72. $object, $method, $context, $deferred, $priority, $duplicate
  73. );
  74. // Return a pointer to this subscription usable by unsubscribe.
  75. return array($topic, count($this->_subscriptions[$topic])-1);
  76. }
  77. /**
  78. * Cancel a subscription to a message topic
  79. *
  80. * @param mixed Opaque subscription handle returned by the subscribe message.
  81. */
  82. public function unsubscribe($details)
  83. {
  84. list($topic, $idx) = $details;
  85. // HACK: Just set the subscription to null, rather than deal with
  86. // resorting the array or whatnot.
  87. $this->_subscriptions[$topic][$idx] = null;
  88. }
  89. /**
  90. * Publish a message to a topic
  91. *
  92. * @todo Allow topic pattern matching
  93. *
  94. * @param string message topic
  95. * @param mixed message data
  96. */
  97. public function publish($topic, $data=null, $scheduled_for=null, $owner=null) {
  98. if (isset($this->_subscriptions[$topic])) {
  99. // Distribute the published message to subscriptions.
  100. foreach ($this->_subscriptions[$topic] as $subscription) {
  101. // Skip cancelled subscriptions
  102. if (null == $subscription) continue;
  103. // Unpack the subscription array.
  104. list($object, $method, $context, $deferred, $priority, $duplicate) = $subscription;
  105. // Check if deferred jobs have been disabled for this message queue instance.
  106. if ($this->disable_deferred)
  107. $deferred = false;
  108. if (!$deferred) {
  109. // Handle non-deferred messages immediately.
  110. $this->handle($topic, $object, $method, $context, $data);
  111. } else {
  112. // Queue deferred messages.
  113. $this->queue($topic, $object, $method, $context, $data, $priority, $scheduled_for, $duplicate, $owner);
  114. }
  115. }
  116. }
  117. }
  118. /**
  119. * Handle a message by calling the appropriate method on the specified
  120. * object, instantiating it first if need be.
  121. *
  122. * @param string topic
  123. * @param mixed class name or object instance
  124. * @param string method name
  125. * @param mixed context data from subscription
  126. * @param mixed message data
  127. */
  128. public function handle($topic, $object=null, $method=null, $context=null, $body=null)
  129. {
  130. // If the first param is an array, assume it's a message array
  131. if (is_array($topic)) extract($topic);
  132. // One way or another, get an object for this subscription.
  133. if (is_object($object)) {
  134. $obj = $object;
  135. } else {
  136. if (!isset($this->_objs[$object]))
  137. $this->_objs[$object] = new $object();
  138. $obj = $this->_objs[$object];
  139. }
  140. // Make a static call to default method name, or call the specified
  141. // name dynamically.
  142. if (NULL == $method || $method == 'handleMessage') {
  143. $obj->handleMessage($topic, $body, $context);
  144. } else {
  145. call_user_func(array($obj, $method), $topic, $body, $context);
  146. }
  147. }
  148. /**
  149. * Queue a message for deferred processing.
  150. *
  151. * @param string topic
  152. * @param mixed class name or object instance
  153. * @param string method name
  154. * @param mixed context data from subscription
  155. * @param mixed message data
  156. * @param integer message priority
  157. * @param string scheduled time for message
  158. * @param integer duplicate message handling behavior
  159. * @param string optional ownership info
  160. *
  161. * @return array queued message data
  162. */
  163. public function queue($topic, $object, $method, $context, $data, $priority, $scheduled_for, $duplicate=self::DUPLICATE_IGNORE, $owner=null)
  164. {
  165. if (!is_string($object)) {
  166. throw new Exception(
  167. 'Object instances cannot be used in deferred subscriptions.'
  168. );
  169. }
  170. // Encode the context and body data as JSON.
  171. $context = json_encode($context);
  172. $body = json_encode($data);
  173. // Build a signature hash for this message.
  174. $signature = md5(join(':::', array(
  175. $object, $method, $context, $body
  176. )));
  177. // Check to see if anything should be done with signature duplicates.
  178. if ($duplicate != self::DUPLICATE_IGNORE) {
  179. // Look for an unreserved message with the same signature as the
  180. // one about to be queued.
  181. $this->lock();
  182. $row = $this->db->select()
  183. ->from($this->_table_name)
  184. ->where('reserved_at IS', NULL)
  185. ->where('signature', $signature)
  186. ->get()->current();
  187. if ($row) {
  188. if ($duplicate == self::DUPLICATE_REPLACE) {
  189. // In replace case, delete the existing message.
  190. $this->db->delete(
  191. $this->_table_name,
  192. array('uuid' => $row->uuid)
  193. );
  194. $this->unlock();
  195. } else if ($duplicate == self::DUPLICATE_DISCARD) {
  196. // In discard case, fail silently.
  197. $this->unlock();
  198. return false;
  199. }
  200. }
  201. }
  202. // Finally insert a new message.
  203. $row = array(
  204. 'owner' => $owner,
  205. 'created' => gmdate('c'),
  206. 'modified' => gmdate('c'),
  207. 'uuid' => uuid::uuid(),
  208. 'batch_uuid' => $this->_batch_uuid,
  209. 'batch_seq' => ($this->_batch_seq++),
  210. 'priority' => $priority,
  211. 'scheduled_for' => $scheduled_for,
  212. 'topic' => $topic,
  213. 'object' => $object,
  214. 'method' => $method,
  215. 'context' => $context,
  216. 'body' => $body,
  217. 'signature' => $signature
  218. );
  219. $this->db->insert($this->_table_name, $row);
  220. return $row;
  221. }
  222. /**
  223. * Reserve a message from the queue for handling.
  224. *
  225. * @return array Message data
  226. */
  227. public function reserve()
  228. {
  229. $this->lock();
  230. try {
  231. // Start building query to find an unreserved message. Account for
  232. // priority and FIFO.
  233. $now = gmdate('c');
  234. $msg = $this->db->query("
  235. SELECT * FROM {$this->_table_name}
  236. WHERE
  237. ( scheduled_for IS NULL OR scheduled_for < '{$now}' ) AND
  238. reserved_at IS NULL AND
  239. finished_at IS NULL AND
  240. batch_uuid NOT IN (
  241. SELECT DISTINCT l1.batch_uuid
  242. FROM message_queue AS l1
  243. WHERE
  244. l1.reserved_at IS NOT NULL AND
  245. l1.finished_at IS NULL
  246. )
  247. ORDER BY
  248. priority ASC, created ASC, batch_seq ASC
  249. LIMIT 1
  250. ")->result(FALSE)->current();
  251. if (!$msg) {
  252. $msg = null;
  253. } else {
  254. // Decode the data blobs.
  255. $msg['context'] = json_decode($msg['context'], true);
  256. $msg['body'] = json_decode($msg['body'], true);
  257. // Update the timestamp to reserve the message.
  258. $msg['reserved_at'] = gmdate('c');
  259. $this->db->update(
  260. $this->_table_name,
  261. array(
  262. 'modified' => gmdate('c'),
  263. 'reserved_at' => $msg['reserved_at']
  264. ),
  265. array('uuid' => $msg['uuid'])
  266. );
  267. }
  268. // Finally, unlock the table and return the message.
  269. $this->unlock();
  270. return $msg;
  271. } catch (Exception $e) {
  272. // If anything goes wrong, be sure to unlock the table.
  273. $this->unlock();
  274. throw $e;
  275. }
  276. }
  277. /**
  278. * Mark a message as finished.
  279. *
  280. * @param string Message UUID.
  281. */
  282. public function finish($msg)
  283. {
  284. $row = $this->db->select()
  285. ->from($this->_table_name)
  286. ->where('uuid', $msg['uuid'])
  287. ->get()->current();
  288. if (!$row) {
  289. throw new Exception("No such message $uuid found.");
  290. }
  291. $this->db->update(
  292. $this->_table_name,
  293. array(
  294. 'modified' => gmdate('c'),
  295. 'finished_at' => gmdate('c')
  296. ),
  297. array('uuid' => $msg['uuid'])
  298. );
  299. }
  300. /**
  301. * Process messages continually.
  302. */
  303. public function run()
  304. {
  305. while (True) {
  306. $msg = $this->runOnce();
  307. if (!$msg) sleep(1);
  308. }
  309. }
  310. /**
  311. * Process messages until the queue comes up empty.
  312. */
  313. public function exhaust($max_runs=NULL)
  314. {
  315. $cnt = 0;
  316. while ($msg = $this->runOnce()) {
  317. if ($max_runs != NULL && ( ++$cnt > $max_runs ) )
  318. throw new Exception('Too many runs');
  319. }
  320. if ($cnt > 0) {
  321. Kohana::log('info', "Message queue exhaust() processed {$cnt} messages.");
  322. } else {
  323. // Kohana::log('debug', "Message queue exhaust() processed {$cnt} messages.");
  324. }
  325. return $cnt;
  326. }
  327. /**
  328. * Attempt to reserve and handle one message.
  329. */
  330. public function runOnce()
  331. {
  332. $msg = $this->reserve();
  333. if ($msg) try {
  334. Kohana::log('info', "Message queue runOnce() processing " .
  335. "{$msg['topic']} {$msg['uuid']} {$msg['object']} {$msg['method']}");
  336. Kohana::log_save();
  337. $this->handle($msg);
  338. $this->finish($msg);
  339. Kohana::log('info', "Message queue runOnce() processed " .
  340. "{$msg['topic']} {$msg['uuid']} {$msg['object']} {$msg['method']}");
  341. Kohana::log_save();
  342. } catch (Exception $e) {
  343. Kohana::log('error',
  344. "EXCEPTION! {$msg['topic']} {$msg['uuid']} ".
  345. "{$msg['object']} {$msg['method']} " .
  346. $e->getMessage()
  347. );
  348. Kohana::log_save();
  349. }
  350. return $msg;
  351. }
  352. /**
  353. * Lock the table for read/write.
  354. */
  355. public function lock()
  356. {
  357. //$adapter_name = strtolower(get_class($db));
  358. //if (strpos($adapter_name, 'mysql') !== false) {
  359. $this->db->query(
  360. "LOCK TABLES {$this->_table_name} WRITE, ".
  361. // HACK: Throw in a few aliased locks for subqueries.
  362. "{$this->_table_name} AS l1 WRITE, ".
  363. "{$this->_table_name} AS l2 WRITE, ".
  364. "{$this->_table_name} AS l3 WRITE"
  365. );
  366. //}
  367. }
  368. public function unlock()
  369. {
  370. //$db = $this->getAdapter();
  371. //$adapter_name = strtolower(get_class($db));
  372. //if (strpos($adapter_name, 'mysql') !== false) {
  373. $this->db->query('UNLOCK TABLES');
  374. //}
  375. }
  376. /**
  377. * Delete all. Useful for tests, but dangerous otherwise.
  378. */
  379. public function deleteAll()
  380. {
  381. if (!Kohana::config('model.enable_delete_all'))
  382. throw new Exception('Mass deletion not enabled');
  383. $this->db->query('DELETE FROM ' . $this->_table_name);
  384. }
  385. /**
  386. * Find queued messages by owner.
  387. *
  388. * @param string Ownership key
  389. */
  390. public function findByOwner($owner)
  391. {
  392. $rows = $this->db->select()
  393. ->from($this->_table_name)
  394. ->where('owner', $owner)
  395. ->get();
  396. return $rows;
  397. }
  398. }