mongodb.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. # -*- coding: utf-8 -*-
  2. """MongoDB result store backend."""
  3. from __future__ import absolute_import, unicode_literals
  4. from datetime import datetime, timedelta
  5. from kombu.exceptions import EncodeError
  6. from kombu.utils.objects import cached_property
  7. from kombu.utils.url import maybe_sanitize_url
  8. from celery import states
  9. from celery.exceptions import ImproperlyConfigured
  10. from celery.five import items, string_t
  11. from .base import BaseBackend
  12. try:
  13. import pymongo
  14. except ImportError: # pragma: no cover
  15. pymongo = None # noqa
  16. if pymongo:
  17. try:
  18. from bson.binary import Binary
  19. except ImportError: # pragma: no cover
  20. from pymongo.binary import Binary # noqa
  21. from pymongo.errors import InvalidDocument # noqa
  22. else: # pragma: no cover
  23. Binary = None # noqa
  24. class InvalidDocument(Exception): # noqa
  25. pass
  26. __all__ = ('MongoBackend',)
  27. BINARY_CODECS = frozenset(['pickle', 'msgpack'])
  28. class MongoBackend(BaseBackend):
  29. """MongoDB result backend.
  30. Raises:
  31. celery.exceptions.ImproperlyConfigured:
  32. if module :pypi:`pymongo` is not available.
  33. """
  34. mongo_host = None
  35. host = 'localhost'
  36. port = 27017
  37. user = None
  38. password = None
  39. database_name = 'celery'
  40. taskmeta_collection = 'celery_taskmeta'
  41. groupmeta_collection = 'celery_groupmeta'
  42. max_pool_size = 10
  43. options = None
  44. supports_autoexpire = False
  45. _connection = None
  46. def __init__(self, app=None, **kwargs):
  47. self.options = {}
  48. super(MongoBackend, self).__init__(app, **kwargs)
  49. if not pymongo:
  50. raise ImproperlyConfigured(
  51. 'You need to install the pymongo library to use the '
  52. 'MongoDB backend.')
  53. # Set option defaults
  54. for key, value in items(self._prepare_client_options()):
  55. self.options.setdefault(key, value)
  56. # update conf with mongo uri data, only if uri was given
  57. if self.url:
  58. if self.url == 'mongodb://':
  59. self.url += 'localhost'
  60. uri_data = pymongo.uri_parser.parse_uri(self.url)
  61. # build the hosts list to create a mongo connection
  62. hostslist = [
  63. '{0}:{1}'.format(x[0], x[1]) for x in uri_data['nodelist']
  64. ]
  65. self.user = uri_data['username']
  66. self.password = uri_data['password']
  67. self.mongo_host = hostslist
  68. if uri_data['database']:
  69. # if no database is provided in the uri, use default
  70. self.database_name = uri_data['database']
  71. self.options.update(uri_data['options'])
  72. # update conf with specific settings
  73. config = self.app.conf.get('mongodb_backend_settings')
  74. if config is not None:
  75. if not isinstance(config, dict):
  76. raise ImproperlyConfigured(
  77. 'MongoDB backend settings should be grouped in a dict')
  78. config = dict(config) # don't modify original
  79. if 'host' in config or 'port' in config:
  80. # these should take over uri conf
  81. self.mongo_host = None
  82. self.host = config.pop('host', self.host)
  83. self.port = config.pop('port', self.port)
  84. self.mongo_host = config.pop('mongo_host', self.mongo_host)
  85. self.user = config.pop('user', self.user)
  86. self.password = config.pop('password', self.password)
  87. self.database_name = config.pop('database', self.database_name)
  88. self.taskmeta_collection = config.pop(
  89. 'taskmeta_collection', self.taskmeta_collection,
  90. )
  91. self.groupmeta_collection = config.pop(
  92. 'groupmeta_collection', self.groupmeta_collection,
  93. )
  94. self.options.update(config.pop('options', {}))
  95. self.options.update(config)
  96. def _prepare_client_options(self):
  97. if pymongo.version_tuple >= (3,):
  98. return {'maxPoolSize': self.max_pool_size}
  99. else: # pragma: no cover
  100. return {'max_pool_size': self.max_pool_size,
  101. 'auto_start_request': False}
  102. def _get_connection(self):
  103. """Connect to the MongoDB server."""
  104. if self._connection is None:
  105. from pymongo import MongoClient
  106. host = self.mongo_host
  107. if not host:
  108. # The first pymongo.Connection() argument (host) can be
  109. # a list of ['host:port'] elements or a mongodb connection
  110. # URI. If this is the case, don't use self.port
  111. # but let pymongo get the port(s) from the URI instead.
  112. # This enables the use of replica sets and sharding.
  113. # See pymongo.Connection() for more info.
  114. host = self.host
  115. if isinstance(host, string_t) \
  116. and not host.startswith('mongodb://'):
  117. host = 'mongodb://{0}:{1}'.format(host, self.port)
  118. # don't change self.options
  119. conf = dict(self.options)
  120. conf['host'] = host
  121. self._connection = MongoClient(**conf)
  122. return self._connection
  123. def encode(self, data):
  124. if self.serializer == 'bson':
  125. # mongodb handles serialization
  126. return data
  127. payload = super(MongoBackend, self).encode(data)
  128. # serializer which are in a unsupported format (pickle/binary)
  129. if self.serializer in BINARY_CODECS:
  130. payload = Binary(payload)
  131. return payload
  132. def decode(self, data):
  133. if self.serializer == 'bson':
  134. return data
  135. return super(MongoBackend, self).decode(data)
  136. def _store_result(self, task_id, result, state,
  137. traceback=None, request=None, **kwargs):
  138. """Store return value and state of an executed task."""
  139. meta = {
  140. '_id': task_id,
  141. 'status': state,
  142. 'result': self.encode(result),
  143. 'date_done': datetime.utcnow(),
  144. 'traceback': self.encode(traceback),
  145. 'children': self.encode(
  146. self.current_task_children(request),
  147. ),
  148. }
  149. try:
  150. self.collection.save(meta)
  151. except InvalidDocument as exc:
  152. raise EncodeError(exc)
  153. return result
  154. def _get_task_meta_for(self, task_id):
  155. """Get task meta-data for a task by id."""
  156. obj = self.collection.find_one({'_id': task_id})
  157. if obj:
  158. return self.meta_from_decoded({
  159. 'task_id': obj['_id'],
  160. 'status': obj['status'],
  161. 'result': self.decode(obj['result']),
  162. 'date_done': obj['date_done'],
  163. 'traceback': self.decode(obj['traceback']),
  164. 'children': self.decode(obj['children']),
  165. })
  166. return {'status': states.PENDING, 'result': None}
  167. def _save_group(self, group_id, result):
  168. """Save the group result."""
  169. self.group_collection.save({
  170. '_id': group_id,
  171. 'result': self.encode([i.id for i in result]),
  172. 'date_done': datetime.utcnow(),
  173. })
  174. return result
  175. def _restore_group(self, group_id):
  176. """Get the result for a group by id."""
  177. obj = self.group_collection.find_one({'_id': group_id})
  178. if obj:
  179. return {
  180. 'task_id': obj['_id'],
  181. 'date_done': obj['date_done'],
  182. 'result': [
  183. self.app.AsyncResult(task)
  184. for task in self.decode(obj['result'])
  185. ],
  186. }
  187. def _delete_group(self, group_id):
  188. """Delete a group by id."""
  189. self.group_collection.remove({'_id': group_id})
  190. def _forget(self, task_id):
  191. """Remove result from MongoDB.
  192. Raises:
  193. pymongo.exceptions.OperationsError:
  194. if the task_id could not be removed.
  195. """
  196. # By using safe=True, this will wait until it receives a response from
  197. # the server. Likewise, it will raise an OperationsError if the
  198. # response was unable to be completed.
  199. self.collection.remove({'_id': task_id})
  200. def cleanup(self):
  201. """Delete expired meta-data."""
  202. self.collection.remove(
  203. {'date_done': {'$lt': self.app.now() - self.expires_delta}},
  204. )
  205. self.group_collection.remove(
  206. {'date_done': {'$lt': self.app.now() - self.expires_delta}},
  207. )
  208. def __reduce__(self, args=(), kwargs={}):
  209. return super(MongoBackend, self).__reduce__(
  210. args, dict(kwargs, expires=self.expires, url=self.url))
  211. def _get_database(self):
  212. conn = self._get_connection()
  213. db = conn[self.database_name]
  214. if self.user and self.password:
  215. if not db.authenticate(self.user, self.password):
  216. raise ImproperlyConfigured(
  217. 'Invalid MongoDB username or password.')
  218. return db
  219. @cached_property
  220. def database(self):
  221. """Get database from MongoDB connection.
  222. performs authentication if necessary.
  223. """
  224. return self._get_database()
  225. @cached_property
  226. def collection(self):
  227. """Get the meta-data task collection."""
  228. collection = self.database[self.taskmeta_collection]
  229. # Ensure an index on date_done is there, if not process the index
  230. # in the background. Once completed cleanup will be much faster
  231. collection.ensure_index('date_done', background='true')
  232. return collection
  233. @cached_property
  234. def group_collection(self):
  235. """Get the meta-data task collection."""
  236. collection = self.database[self.groupmeta_collection]
  237. # Ensure an index on date_done is there, if not process the index
  238. # in the background. Once completed cleanup will be much faster
  239. collection.ensure_index('date_done', background='true')
  240. return collection
  241. @cached_property
  242. def expires_delta(self):
  243. return timedelta(seconds=self.expires)
  244. def as_uri(self, include_password=False):
  245. """Return the backend as an URI.
  246. Arguments:
  247. include_password (bool): Password censored if disabled.
  248. """
  249. if not self.url:
  250. return 'mongodb://'
  251. if include_password:
  252. return self.url
  253. if ',' not in self.url:
  254. return maybe_sanitize_url(self.url)
  255. uri1, remainder = self.url.split(',', 1)
  256. return ','.join([maybe_sanitize_url(uri1), remainder])