You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

690 lines
24 KiB

  1. # -*- coding: utf-8 -*-
  2. from __future__ import (absolute_import, division, print_function,
  3. unicode_literals)
  4. import inspect
  5. import warnings
  6. import zlib
  7. from functools import partial
  8. from uuid import uuid4
  9. from rq.compat import as_text, decode_redis_hash, string_types, text_type
  10. from .connections import resolve_connection
  11. from .exceptions import NoSuchJobError, UnpickleError
  12. from .local import LocalStack
  13. from .utils import (enum, import_attribute, parse_timeout, str_to_date,
  14. utcformat, utcnow)
  15. try:
  16. import cPickle as pickle
  17. except ImportError: # noqa # pragma: no cover
  18. import pickle
  19. # Serialize pickle dumps using the highest pickle protocol (binary, default
  20. # uses ascii)
  21. dumps = partial(pickle.dumps, protocol=pickle.HIGHEST_PROTOCOL)
  22. loads = pickle.loads
  23. JobStatus = enum(
  24. 'JobStatus',
  25. QUEUED='queued',
  26. FINISHED='finished',
  27. FAILED='failed',
  28. STARTED='started',
  29. DEFERRED='deferred'
  30. )
  31. # Sentinel value to mark that some of our lazily evaluated properties have not
  32. # yet been evaluated.
  33. UNEVALUATED = object()
  34. def unpickle(pickled_string):
  35. """Unpickles a string, but raises a unified UnpickleError in case anything
  36. fails.
  37. This is a helper method to not have to deal with the fact that `loads()`
  38. potentially raises many types of exceptions (e.g. AttributeError,
  39. IndexError, TypeError, KeyError, etc.)
  40. """
  41. try:
  42. obj = loads(pickled_string)
  43. except Exception as e:
  44. raise UnpickleError('Could not unpickle', pickled_string, e)
  45. return obj
  46. def cancel_job(job_id, connection=None):
  47. """Cancels the job with the given job ID, preventing execution. Discards
  48. any job info (i.e. it can't be requeued later).
  49. """
  50. Job.fetch(job_id, connection=connection).cancel()
  51. def get_current_job(connection=None, job_class=None):
  52. """Returns the Job instance that is currently being executed. If this
  53. function is invoked from outside a job context, None is returned.
  54. """
  55. if job_class:
  56. warnings.warn("job_class argument for get_current_job is deprecated.",
  57. DeprecationWarning)
  58. return _job_stack.top
  59. def requeue_job(job_id, connection):
  60. job = Job.fetch(job_id, connection=connection)
  61. return job.requeue()
  62. class Job(object):
  63. """A Job is just a convenient datastructure to pass around job (meta) data.
  64. """
  65. redis_job_namespace_prefix = 'rq:job:'
  66. # Job construction
  67. @classmethod
  68. def create(cls, func, args=None, kwargs=None, connection=None,
  69. result_ttl=None, ttl=None, status=None, description=None,
  70. depends_on=None, timeout=None, id=None, origin=None, meta=None,
  71. failure_ttl=None):
  72. """Creates a new Job instance for the given function, arguments, and
  73. keyword arguments.
  74. """
  75. if args is None:
  76. args = ()
  77. if kwargs is None:
  78. kwargs = {}
  79. if not isinstance(args, (tuple, list)):
  80. raise TypeError('{0!r} is not a valid args list'.format(args))
  81. if not isinstance(kwargs, dict):
  82. raise TypeError('{0!r} is not a valid kwargs dict'.format(kwargs))
  83. job = cls(connection=connection)
  84. if id is not None:
  85. job.set_id(id)
  86. if origin is not None:
  87. job.origin = origin
  88. # Set the core job tuple properties
  89. job._instance = None
  90. if inspect.ismethod(func):
  91. job._instance = func.__self__
  92. job._func_name = func.__name__
  93. elif inspect.isfunction(func) or inspect.isbuiltin(func):
  94. job._func_name = '{0}.{1}'.format(func.__module__, func.__name__)
  95. elif isinstance(func, string_types):
  96. job._func_name = as_text(func)
  97. elif not inspect.isclass(func) and hasattr(func, '__call__'): # a callable class instance
  98. job._instance = func
  99. job._func_name = '__call__'
  100. else:
  101. raise TypeError('Expected a callable or a string, but got: {0}'.format(func))
  102. job._args = args
  103. job._kwargs = kwargs
  104. # Extra meta data
  105. job.description = description or job.get_call_string()
  106. job.result_ttl = result_ttl
  107. job.failure_ttl = failure_ttl
  108. job.ttl = ttl
  109. job.timeout = parse_timeout(timeout)
  110. job._status = status
  111. job.meta = meta or {}
  112. # dependency could be job instance or id
  113. if depends_on is not None:
  114. job._dependency_id = depends_on.id if isinstance(depends_on, Job) else depends_on
  115. return job
  116. def get_status(self):
  117. self._status = as_text(self.connection.hget(self.key, 'status'))
  118. return self._status
  119. def set_status(self, status, pipeline=None):
  120. self._status = status
  121. connection = pipeline or self.connection
  122. connection.hset(self.key, 'status', self._status)
  123. @property
  124. def is_finished(self):
  125. return self.get_status() == JobStatus.FINISHED
  126. @property
  127. def is_queued(self):
  128. return self.get_status() == JobStatus.QUEUED
  129. @property
  130. def is_failed(self):
  131. return self.get_status() == JobStatus.FAILED
  132. @property
  133. def is_started(self):
  134. return self.get_status() == JobStatus.STARTED
  135. @property
  136. def is_deferred(self):
  137. return self.get_status() == JobStatus.DEFERRED
  138. @property
  139. def dependency(self):
  140. """Returns a job's dependency. To avoid repeated Redis fetches, we cache
  141. job.dependency as job._dependency.
  142. """
  143. if self._dependency_id is None:
  144. return None
  145. if hasattr(self, '_dependency'):
  146. return self._dependency
  147. job = self.fetch(self._dependency_id, connection=self.connection)
  148. self._dependency = job
  149. return job
  150. @property
  151. def dependent_ids(self):
  152. """Returns a list of ids of jobs whose execution depends on this
  153. job's successful execution."""
  154. return list(map(as_text, self.connection.smembers(self.dependents_key)))
  155. @property
  156. def func(self):
  157. func_name = self.func_name
  158. if func_name is None:
  159. return None
  160. if self.instance:
  161. return getattr(self.instance, func_name)
  162. return import_attribute(self.func_name)
  163. def _unpickle_data(self):
  164. self._func_name, self._instance, self._args, self._kwargs = unpickle(self.data)
  165. @property
  166. def data(self):
  167. if self._data is UNEVALUATED:
  168. if self._func_name is UNEVALUATED:
  169. raise ValueError('Cannot build the job data')
  170. if self._instance is UNEVALUATED:
  171. self._instance = None
  172. if self._args is UNEVALUATED:
  173. self._args = ()
  174. if self._kwargs is UNEVALUATED:
  175. self._kwargs = {}
  176. job_tuple = self._func_name, self._instance, self._args, self._kwargs
  177. self._data = dumps(job_tuple)
  178. return self._data
  179. @data.setter
  180. def data(self, value):
  181. self._data = value
  182. self._func_name = UNEVALUATED
  183. self._instance = UNEVALUATED
  184. self._args = UNEVALUATED
  185. self._kwargs = UNEVALUATED
  186. @property
  187. def func_name(self):
  188. if self._func_name is UNEVALUATED:
  189. self._unpickle_data()
  190. return self._func_name
  191. @func_name.setter
  192. def func_name(self, value):
  193. self._func_name = value
  194. self._data = UNEVALUATED
  195. @property
  196. def instance(self):
  197. if self._instance is UNEVALUATED:
  198. self._unpickle_data()
  199. return self._instance
  200. @instance.setter
  201. def instance(self, value):
  202. self._instance = value
  203. self._data = UNEVALUATED
  204. @property
  205. def args(self):
  206. if self._args is UNEVALUATED:
  207. self._unpickle_data()
  208. return self._args
  209. @args.setter
  210. def args(self, value):
  211. self._args = value
  212. self._data = UNEVALUATED
  213. @property
  214. def kwargs(self):
  215. if self._kwargs is UNEVALUATED:
  216. self._unpickle_data()
  217. return self._kwargs
  218. @kwargs.setter
  219. def kwargs(self, value):
  220. self._kwargs = value
  221. self._data = UNEVALUATED
  222. @classmethod
  223. def exists(cls, job_id, connection=None):
  224. """Returns whether a job hash exists for the given job ID."""
  225. conn = resolve_connection(connection)
  226. return conn.exists(cls.key_for(job_id))
  227. @classmethod
  228. def fetch(cls, id, connection=None):
  229. """Fetches a persisted job from its corresponding Redis key and
  230. instantiates it.
  231. """
  232. job = cls(id, connection=connection)
  233. job.refresh()
  234. return job
  235. @classmethod
  236. def fetch_many(cls, job_ids, connection=None):
  237. """Bulk version of Job.fetch"""
  238. with connection.pipeline() as pipeline:
  239. for job_id in job_ids:
  240. pipeline.hgetall(cls.key_for(job_id))
  241. results = pipeline.execute()
  242. jobs = []
  243. for i, job_id in enumerate(job_ids):
  244. if results[i]:
  245. job = cls(job_id, connection=connection)
  246. job.restore(results[i])
  247. jobs.append(job)
  248. else:
  249. jobs.append(None)
  250. return jobs
  251. def __init__(self, id=None, connection=None):
  252. self.connection = resolve_connection(connection)
  253. self._id = id
  254. self.created_at = utcnow()
  255. self._data = UNEVALUATED
  256. self._func_name = UNEVALUATED
  257. self._instance = UNEVALUATED
  258. self._args = UNEVALUATED
  259. self._kwargs = UNEVALUATED
  260. self.description = None
  261. self.origin = None
  262. self.enqueued_at = None
  263. self.started_at = None
  264. self.ended_at = None
  265. self._result = None
  266. self.exc_info = None
  267. self.timeout = None
  268. self.result_ttl = None
  269. self.failure_ttl = None
  270. self.ttl = None
  271. self._status = None
  272. self._dependency_id = None
  273. self.meta = {}
  274. def __repr__(self): # noqa # pragma: no cover
  275. return '{0}({1!r}, enqueued_at={2!r})'.format(self.__class__.__name__,
  276. self._id,
  277. self.enqueued_at)
  278. def __str__(self):
  279. return '<{0} {1}: {2}>'.format(self.__class__.__name__,
  280. self.id,
  281. self.description)
  282. # Job equality
  283. def __eq__(self, other): # noqa
  284. return isinstance(other, self.__class__) and self.id == other.id
  285. def __hash__(self): # pragma: no cover
  286. return hash(self.id)
  287. # Data access
  288. def get_id(self): # noqa
  289. """The job ID for this job instance. Generates an ID lazily the
  290. first time the ID is requested.
  291. """
  292. if self._id is None:
  293. self._id = text_type(uuid4())
  294. return self._id
  295. def set_id(self, value):
  296. """Sets a job ID for the given job."""
  297. if not isinstance(value, string_types):
  298. raise TypeError('id must be a string, not {0}'.format(type(value)))
  299. self._id = value
  300. id = property(get_id, set_id)
  301. @classmethod
  302. def key_for(cls, job_id):
  303. """The Redis key that is used to store job hash under."""
  304. return (cls.redis_job_namespace_prefix + job_id).encode('utf-8')
  305. @classmethod
  306. def dependents_key_for(cls, job_id):
  307. """The Redis key that is used to store job dependents hash under."""
  308. return '{0}{1}:dependents'.format(cls.redis_job_namespace_prefix, job_id)
  309. @property
  310. def key(self):
  311. """The Redis key that is used to store job hash under."""
  312. return self.key_for(self.id)
  313. @property
  314. def dependents_key(self):
  315. """The Redis key that is used to store job dependents hash under."""
  316. return self.dependents_key_for(self.id)
  317. @property
  318. def result(self):
  319. """Returns the return value of the job.
  320. Initially, right after enqueueing a job, the return value will be
  321. None. But when the job has been executed, and had a return value or
  322. exception, this will return that value or exception.
  323. Note that, when the job has no return value (i.e. returns None), the
  324. ReadOnlyJob object is useless, as the result won't be written back to
  325. Redis.
  326. Also note that you cannot draw the conclusion that a job has _not_
  327. been executed when its return value is None, since return values
  328. written back to Redis will expire after a given amount of time (500
  329. seconds by default).
  330. """
  331. if self._result is None:
  332. rv = self.connection.hget(self.key, 'result')
  333. if rv is not None:
  334. # cache the result
  335. self._result = loads(rv)
  336. return self._result
  337. """Backwards-compatibility accessor property `return_value`."""
  338. return_value = result
  339. def restore(self, raw_data):
  340. """Overwrite properties with the provided values stored in Redis"""
  341. obj = decode_redis_hash(raw_data)
  342. try:
  343. raw_data = obj['data']
  344. except KeyError:
  345. raise NoSuchJobError('Unexpected job format: {0}'.format(obj))
  346. try:
  347. self.data = zlib.decompress(raw_data)
  348. except zlib.error:
  349. # Fallback to uncompressed string
  350. self.data = raw_data
  351. self.created_at = str_to_date(obj.get('created_at'))
  352. self.origin = as_text(obj.get('origin'))
  353. self.description = as_text(obj.get('description'))
  354. self.enqueued_at = str_to_date(obj.get('enqueued_at'))
  355. self.started_at = str_to_date(obj.get('started_at'))
  356. self.ended_at = str_to_date(obj.get('ended_at'))
  357. self._result = unpickle(obj.get('result')) if obj.get('result') else None # noqa
  358. self.timeout = parse_timeout(obj.get('timeout')) if obj.get('timeout') else None
  359. self.result_ttl = int(obj.get('result_ttl')) if obj.get('result_ttl') else None # noqa
  360. self.failure_ttl = int(obj.get('failure_ttl')) if obj.get('failure_ttl') else None # noqa
  361. self._status = obj.get('status') if obj.get('status') else None
  362. self._dependency_id = as_text(obj.get('dependency_id', None))
  363. self.ttl = int(obj.get('ttl')) if obj.get('ttl') else None
  364. self.meta = unpickle(obj.get('meta')) if obj.get('meta') else {}
  365. raw_exc_info = obj.get('exc_info')
  366. if raw_exc_info:
  367. try:
  368. self.exc_info = as_text(zlib.decompress(raw_exc_info))
  369. except zlib.error:
  370. # Fallback to uncompressed string
  371. self.exc_info = as_text(raw_exc_info)
  372. # Persistence
  373. def refresh(self): # noqa
  374. """Overwrite the current instance's properties with the values in the
  375. corresponding Redis key.
  376. Will raise a NoSuchJobError if no corresponding Redis key exists.
  377. """
  378. data = self.connection.hgetall(self.key)
  379. if not data:
  380. raise NoSuchJobError('No such job: {0}'.format(self.key))
  381. self.restore(data)
  382. def to_dict(self, include_meta=True):
  383. """
  384. Returns a serialization of the current job instance
  385. You can exclude serializing the `meta` dictionary by setting
  386. `include_meta=False`.
  387. """
  388. obj = {}
  389. obj['created_at'] = utcformat(self.created_at or utcnow())
  390. obj['data'] = zlib.compress(self.data)
  391. if self.origin is not None:
  392. obj['origin'] = self.origin
  393. if self.description is not None:
  394. obj['description'] = self.description
  395. if self.enqueued_at is not None:
  396. obj['enqueued_at'] = utcformat(self.enqueued_at)
  397. if self.started_at is not None:
  398. obj['started_at'] = utcformat(self.started_at)
  399. if self.ended_at is not None:
  400. obj['ended_at'] = utcformat(self.ended_at)
  401. if self._result is not None:
  402. try:
  403. obj['result'] = dumps(self._result)
  404. except:
  405. obj['result'] = 'Unpickleable return value'
  406. if self.exc_info is not None:
  407. obj['exc_info'] = zlib.compress(str(self.exc_info).encode('utf-8'))
  408. if self.timeout is not None:
  409. obj['timeout'] = self.timeout
  410. if self.result_ttl is not None:
  411. obj['result_ttl'] = self.result_ttl
  412. if self.failure_ttl is not None:
  413. obj['failure_ttl'] = self.failure_ttl
  414. if self._status is not None:
  415. obj['status'] = self._status
  416. if self._dependency_id is not None:
  417. obj['dependency_id'] = self._dependency_id
  418. if self.meta and include_meta:
  419. obj['meta'] = dumps(self.meta)
  420. if self.ttl:
  421. obj['ttl'] = self.ttl
  422. return obj
  423. def save(self, pipeline=None, include_meta=True):
  424. """
  425. Dumps the current job instance to its corresponding Redis key.
  426. Exclude saving the `meta` dictionary by setting
  427. `include_meta=False`. This is useful to prevent clobbering
  428. user metadata without an expensive `refresh()` call first.
  429. Redis key persistence may be altered by `cleanup()` method.
  430. """
  431. key = self.key
  432. connection = pipeline if pipeline is not None else self.connection
  433. connection.hmset(key, self.to_dict(include_meta=include_meta))
  434. def save_meta(self):
  435. """Stores job meta from the job instance to the corresponding Redis key."""
  436. meta = dumps(self.meta)
  437. self.connection.hset(self.key, 'meta', meta)
  438. def cancel(self, pipeline=None):
  439. """Cancels the given job, which will prevent the job from ever being
  440. ran (or inspected).
  441. This method merely exists as a high-level API call to cancel jobs
  442. without worrying about the internals required to implement job
  443. cancellation.
  444. """
  445. from .queue import Queue
  446. pipeline = pipeline or self.connection.pipeline()
  447. if self.origin:
  448. q = Queue(name=self.origin, connection=self.connection)
  449. q.remove(self, pipeline=pipeline)
  450. pipeline.execute()
  451. def requeue(self):
  452. """Requeues job."""
  453. self.failed_job_registry.requeue(self)
  454. def delete(self, pipeline=None, remove_from_queue=True,
  455. delete_dependents=False):
  456. """Cancels the job and deletes the job hash from Redis. Jobs depending
  457. on this job can optionally be deleted as well."""
  458. if remove_from_queue:
  459. self.cancel(pipeline=pipeline)
  460. connection = pipeline if pipeline is not None else self.connection
  461. if self.is_finished:
  462. from .registry import FinishedJobRegistry
  463. registry = FinishedJobRegistry(self.origin,
  464. connection=self.connection,
  465. job_class=self.__class__)
  466. registry.remove(self, pipeline=pipeline)
  467. elif self.is_deferred:
  468. from .registry import DeferredJobRegistry
  469. registry = DeferredJobRegistry(self.origin,
  470. connection=self.connection,
  471. job_class=self.__class__)
  472. registry.remove(self, pipeline=pipeline)
  473. elif self.is_started:
  474. from .registry import StartedJobRegistry
  475. registry = StartedJobRegistry(self.origin,
  476. connection=self.connection,
  477. job_class=self.__class__)
  478. registry.remove(self, pipeline=pipeline)
  479. elif self.is_failed:
  480. self.failed_job_registry.remove(self, pipeline=pipeline)
  481. if delete_dependents:
  482. self.delete_dependents(pipeline=pipeline)
  483. connection.delete(self.key)
  484. connection.delete(self.dependents_key)
  485. def delete_dependents(self, pipeline=None):
  486. """Delete jobs depending on this job."""
  487. connection = pipeline if pipeline is not None else self.connection
  488. for dependent_id in self.dependent_ids:
  489. try:
  490. job = Job.fetch(dependent_id, connection=self.connection)
  491. job.delete(pipeline=pipeline,
  492. remove_from_queue=False)
  493. except NoSuchJobError:
  494. # It could be that the dependent job was never saved to redis
  495. pass
  496. connection.delete(self.dependents_key)
  497. # Job execution
  498. def perform(self): # noqa
  499. """Invokes the job function with the job arguments."""
  500. self.connection.persist(self.key)
  501. _job_stack.push(self)
  502. try:
  503. self._result = self._execute()
  504. finally:
  505. assert self is _job_stack.pop()
  506. return self._result
  507. def _execute(self):
  508. return self.func(*self.args, **self.kwargs)
  509. def get_ttl(self, default_ttl=None):
  510. """Returns ttl for a job that determines how long a job will be
  511. persisted. In the future, this method will also be responsible
  512. for determining ttl for repeated jobs.
  513. """
  514. return default_ttl if self.ttl is None else self.ttl
  515. def get_result_ttl(self, default_ttl=None):
  516. """Returns ttl for a job that determines how long a jobs result will
  517. be persisted. In the future, this method will also be responsible
  518. for determining ttl for repeated jobs.
  519. """
  520. return default_ttl if self.result_ttl is None else self.result_ttl
  521. # Representation
  522. def get_call_string(self): # noqa
  523. """Returns a string representation of the call, formatted as a regular
  524. Python function invocation statement.
  525. """
  526. if self.func_name is None:
  527. return None
  528. arg_list = [as_text(repr(arg)) for arg in self.args]
  529. kwargs = ['{0}={1}'.format(k, as_text(repr(v))) for k, v in self.kwargs.items()]
  530. # Sort here because python 3.3 & 3.4 makes different call_string
  531. arg_list += sorted(kwargs)
  532. args = ', '.join(arg_list)
  533. return '{0}({1})'.format(self.func_name, args)
  534. def cleanup(self, ttl=None, pipeline=None, remove_from_queue=True):
  535. """Prepare job for eventual deletion (if needed). This method is usually
  536. called after successful execution. How long we persist the job and its
  537. result depends on the value of ttl:
  538. - If ttl is 0, cleanup the job immediately.
  539. - If it's a positive number, set the job to expire in X seconds.
  540. - If ttl is negative, don't set an expiry to it (persist
  541. forever)
  542. """
  543. if ttl == 0:
  544. self.delete(pipeline=pipeline, remove_from_queue=remove_from_queue)
  545. elif not ttl:
  546. return
  547. elif ttl > 0:
  548. connection = pipeline if pipeline is not None else self.connection
  549. connection.expire(self.key, ttl)
  550. @property
  551. def failed_job_registry(self):
  552. from .registry import FailedJobRegistry
  553. return FailedJobRegistry(self.origin, connection=self.connection,
  554. job_class=self.__class__)
  555. def register_dependency(self, pipeline=None):
  556. """Jobs may have dependencies. Jobs are enqueued only if the job they
  557. depend on is successfully performed. We record this relation as
  558. a reverse dependency (a Redis set), with a key that looks something
  559. like:
  560. rq:job:job_id:dependents = {'job_id_1', 'job_id_2'}
  561. This method adds the job in its dependency's dependents set
  562. and adds the job to DeferredJobRegistry.
  563. """
  564. from .registry import DeferredJobRegistry
  565. registry = DeferredJobRegistry(self.origin,
  566. connection=self.connection,
  567. job_class=self.__class__)
  568. registry.add(self, pipeline=pipeline)
  569. connection = pipeline if pipeline is not None else self.connection
  570. connection.sadd(self.dependents_key_for(self._dependency_id), self.id)
  571. _job_stack = LocalStack()