Selaa lähdekoodia

AWS DynamoDB result backend (#3736)

* Add result backend for AWS DynamoDB

* Dependencies for DynamoDB result backend

* Add DynamoDB backend in aliases

* Test cases for DynamoDB result backend

* Documentation for DynamoDB backend

* Configurable endpoint URL for DynamoDB local instance

* Enable integration tests for DynamoDB result backend

- Run before_install script only for integration environments

* Fix invalid type error for primary key in Python3

* Add Python 3.6 in Travis CI build matrix

- Instruct Travis CI to include Python 3.6 interpreter in jobs
- Optimize Travis CI build matrix

* Optimize Travis CI build matrix

* Fix endless loop in logger_isa (Python 3.6)

* Add test cases for AWS client construction

- Add/improve log messages during table initialization
- Enable skipped unit tests due to missing dependency boto3

* Use explicit hash seed value for apicheck tox environment

- Related Sphinx issue: https://github.com/sphinx-doc/sphinx/issues/2324
George Psarakis 8 vuotta sitten
vanhempi
commit
9c950b47ec

+ 44 - 19
.travis.yml

@@ -2,31 +2,42 @@ language: python
 sudo: required
 cache: false
 python:
-    - '3.5'
+  - '2.7'
+  - '3.4'
+  - '3.5'
+  - '3.6'
 os:
     - linux
 env:
   global:
-    PYTHONUNBUFFERED=yes
+  - PYTHONUNBUFFERED=yes
   matrix:
-    - TOXENV=2.7-unit
-    - TOXENV=2.7-integration-rabbitmq
-    - TOXENV=2.7-integration-redis
-    - TOXENV=3.4-unit
-    - TOXENV=3.4-integration-rabbitmq
-    - TOXENV=3.4-integration-redis
-    - TOXENV=3.5-unit
-    - TOXENV=3.5-integration-rabbitmq
-    - TOXENV=3.5-integration-redis
-    - TOXENV=pypy-unit PYPY_VERSION="5.3"
-    - TOXENV=pypy-integration-rabbitmq PYPY_VERSION="5.3"
-    - TOXENV=pypy-integration-redis PYPY_VERSION="5.3"
-    - TOXENV=flake8
-    - TOXENV=flakeplus
-    - TOXENV=apicheck
-    - TOXENV=configcheck
-    - TOXENV=pydocstyle
+  - MATRIX_TOXENV=unit
+  - MATRIX_TOXENV=integration-rabbitmq
+  - MATRIX_TOXENV=integration-redis
+  - MATRIX_TOXENV=integration-dynamodb
+matrix:
+  include:
+  - python: '3.5'
+    env: TOXENV=pypy-unit PYPY_VERSION="5.3"
+  - python: '3.5'
+    env: TOXENV=pypy-integration-rabbitmq PYPY_VERSION="5.3"
+  - python: '3.5'
+    env: TOXENV=pypy-integration-redis PYPY_VERSION="5.3"
+  - python: '3.5'
+    env: TOXENV=pypy-integration-dynamodb PYPY_VERSION="5.3"
+  - python: '3.5'
+    env: TOXENV=flake8
+  - python: '3.5'
+    env: TOXENV=flakeplus
+  - python: '3.5'
+    env: TOXENV=apicheck
+  - python: '3.5'
+    env: TOXENV=configcheck
+  - python: '3.5'
+    env: TOXENV=pydocstyle
 before_install:
+    - if [[ -v MATRIX_TOXENV ]]; then export TOXENV=${TRAVIS_PYTHON_VERSION}-${MATRIX_TOXENV}; fi; env
     - |
           if [ "$TOXENV" = "pypy" ]; then
             export PYENV_ROOT="$HOME/.pyenv"
@@ -39,6 +50,20 @@ before_install:
             virtualenv --python="$PYENV_ROOT/versions/pypy-$PYPY_VERSION/bin/python" "$HOME/virtualenvs/pypy-$PYPY_VERSION"
             source "$HOME/virtualenvs/pypy-$PYPY_VERSION/bin/activate"
           fi
+    - |
+          if [[ "$TOXENV" == *dynamodb ]]; then
+              sudo apt-get install -y default-jre supervisor
+              mkdir /opt/dynamodb-local
+              cd /opt/dynamodb-local && curl -L http://dynamodb-local.s3-website-us-west-2.amazonaws.com/dynamodb_local_latest.tar.gz | tar zx
+              cd -
+              echo '[program:dynamodb-local]' | sudo tee /etc/supervisor/conf.d/dynamodb-local.conf
+              echo 'command=java -Djava.library.path=./DynamoDBLocal_lib -jar DynamoDBLocal.jar -inMemory' | sudo tee -a /etc/supervisor/conf.d/dynamodb-local.conf
+              echo 'directory=/opt/dynamodb-local' | sudo tee -a /etc/supervisor/conf.d/dynamodb-local.conf
+              sudo service supervisor stop
+              sudo service supervisor start
+              sleep 10
+              curl localhost:8000
+          fi
 after_success:
   - .tox/$TRAVIS_PYTHON_VERSION/bin/coverage xml
   - .tox/$TRAVIS_PYTHON_VERSION/bin/codecov -e TOXENV

+ 2 - 1
celery/app/backends.py

@@ -29,7 +29,8 @@ BACKEND_ALIASES = {
     'riak': 'celery.backends.riak:RiakBackend',
     'file': 'celery.backends.filesystem:FilesystemBackend',
     'disabled': 'celery.backends.base:DisabledBackend',
-    'consul': 'celery.backends.consul:ConsulBackend'
+    'consul': 'celery.backends.consul:ConsulBackend',
+    'dynamodb': 'celery.backends.dynamodb:DynamoDBBackend',
 }
 
 

+ 275 - 0
celery/backends/dynamodb.py

@@ -0,0 +1,275 @@
+# -*- coding: utf-8 -*-
+"""AWS DynamoDB result store backend."""
+from __future__ import absolute_import, unicode_literals
+from collections import namedtuple
+from time import time, sleep
+
+from kombu.utils.url import _parse_url as parse_url
+from celery.exceptions import ImproperlyConfigured
+from celery.utils.log import get_logger
+from celery.five import string
+from .base import KeyValueStoreBackend
+try:
+    import boto3
+    from botocore.exceptions import ClientError
+except ImportError:  # pragma: no cover
+    boto3 = ClientError = None  # noqa
+
+__all__ = ['DynamoDBBackend']
+
+
+# Helper class that describes a DynamoDB attribute
+DynamoDBAttribute = namedtuple('DynamoDBAttribute', ('name', 'data_type'))
+
+logger = get_logger(__name__)
+
+
+class DynamoDBBackend(KeyValueStoreBackend):
+    """AWS DynamoDB result backend.
+
+    Raises:
+        celery.exceptions.ImproperlyConfigured:
+            if module :pypi:`boto3` is not available.
+    """
+
+    #: default DynamoDB table name (`default`)
+    table_name = 'celery'
+
+    #: Read Provisioned Throughput (`default`)
+    read_capacity_units = 1
+
+    #: Write Provisioned Throughput (`default`)
+    write_capacity_units = 1
+
+    #: AWS region (`default`)
+    aws_region = None
+
+    #: The endpoint URL that is passed to boto3 (local DynamoDB) (`default`)
+    endpoint_url = None
+
+    _key_field = DynamoDBAttribute(name='id', data_type='S')
+    _value_field = DynamoDBAttribute(name='result', data_type='B')
+    _timestamp_field = DynamoDBAttribute(name='timestamp', data_type='N')
+    _available_fields = None
+
+    def __init__(self, url=None, table_name=None, *args, **kwargs):
+        super(DynamoDBBackend, self).__init__(*args, **kwargs)
+
+        self.url = url
+        self.table_name = table_name or self.table_name
+
+        if not boto3:
+            raise ImproperlyConfigured(
+                'You need to install the boto3 library to use the '
+                'DynamoDB backend.')
+
+        aws_credentials_given = False
+        aws_access_key_id = None
+        aws_secret_access_key = None
+
+        if url is not None:
+            scheme, region, port, username, password, table, query = \
+                parse_url(url)
+
+            aws_access_key_id = username
+            aws_secret_access_key = password
+
+            access_key_given = aws_access_key_id is not None
+            secret_key_given = aws_secret_access_key is not None
+
+            if access_key_given != secret_key_given:
+                raise ImproperlyConfigured(
+                    'You need to specify both the Access Key ID '
+                    'and Secret.')
+
+            aws_credentials_given = access_key_given
+
+            if region == 'localhost':
+                # We are using the downloadable, local version of DynamoDB
+                self.endpoint_url = 'http://localhost:{}'.format(port)
+                self.aws_region = 'us-east-1'
+                logger.warning(
+                    'Using local-only DynamoDB endpoint URL: {}'.format(
+                        self.endpoint_url
+                    )
+                )
+            else:
+                self.aws_region = region
+
+            self.read_capacity_units = int(
+                query.get(
+                    'read',
+                    self.read_capacity_units
+                )
+            )
+            self.write_capacity_units = int(
+                query.get(
+                    'write',
+                    self.write_capacity_units
+                )
+            )
+            self.table_name = table or self.table_name
+
+        self._available_fields = (
+            self._key_field,
+            self._value_field,
+            self._timestamp_field
+        )
+
+        self._client = None
+        if aws_credentials_given:
+            self._get_client(
+                access_key_id=aws_access_key_id,
+                secret_access_key=aws_secret_access_key
+            )
+
+    def _get_client(self, access_key_id=None, secret_access_key=None):
+        """Get client connection."""
+        if self._client is None:
+            client_parameters = dict(
+                region_name=self.aws_region
+            )
+            if access_key_id is not None:
+                client_parameters.update(dict(
+                    aws_access_key_id=access_key_id,
+                    aws_secret_access_key=secret_access_key
+                ))
+
+            if self.endpoint_url is not None:
+                client_parameters['endpoint_url'] = self.endpoint_url
+
+            self._client = boto3.client(
+                'dynamodb',
+                **client_parameters
+            )
+            self._get_or_create_table()
+        return self._client
+
+    def _get_table_schema(self):
+        """Get the boto3 structure describing the DynamoDB table schema."""
+        return dict(
+            AttributeDefinitions=[
+                {
+                    'AttributeName': self._key_field.name,
+                    'AttributeType': self._key_field.data_type
+                }
+            ],
+            TableName=self.table_name,
+            KeySchema=[
+                {
+                    'AttributeName': self._key_field.name,
+                    'KeyType': 'HASH'
+                }
+            ],
+            ProvisionedThroughput={
+                'ReadCapacityUnits': self.read_capacity_units,
+                'WriteCapacityUnits': self.write_capacity_units
+            }
+        )
+
+    def _get_or_create_table(self):
+        """Create table if not exists, otherwise return the description."""
+        table_schema = self._get_table_schema()
+        try:
+            table_description = self._client.create_table(**table_schema)
+            logger.info(
+                'DynamoDB Table {} did not exist, creating.'.format(
+                    self.table_name
+                )
+            )
+            # In case we created the table, wait until it becomes available.
+            self._wait_for_table_status('ACTIVE')
+            logger.info(
+                'DynamoDB Table {} is now available.'.format(
+                    self.table_name
+                )
+            )
+            return table_description
+        except ClientError as e:
+            error_code = e.response['Error'].get('Code', 'Unknown')
+
+            # If table exists, do not fail, just return the description.
+            if error_code == 'ResourceInUseException':
+                return self._client.describe_table(
+                    TableName=self.table_name
+                )
+            else:
+                raise e
+
+    def _wait_for_table_status(self, expected='ACTIVE'):
+        """Poll for the expected table status."""
+        achieved_state = False
+        while not achieved_state:
+            table_description = self.client.describe_table(
+                TableName=self.table_name
+            )
+            logger.debug(
+                'Waiting for DynamoDB table {} to become {}.'.format(
+                    self.table_name,
+                    expected
+                )
+            )
+            current_status = table_description['Table']['TableStatus']
+            achieved_state = current_status == expected
+            sleep(1)
+
+    def _prepare_get_request(self, key):
+        """Construct the item retrieval request parameters."""
+        return dict(
+            TableName=self.table_name,
+            Key={
+                self._key_field.name: {
+                    self._key_field.data_type: key
+                }
+            }
+        )
+
+    def _prepare_put_request(self, key, value):
+        """Construct the item creation request parameters."""
+        return dict(
+            TableName=self.table_name,
+            Item={
+                self._key_field.name: {
+                    self._key_field.data_type: key
+                },
+                self._value_field.name: {
+                    self._value_field.data_type: value
+                },
+                self._timestamp_field.name: {
+                    self._timestamp_field.data_type: str(time())
+                }
+            }
+        )
+
+    def _item_to_dict(self, raw_response):
+        """Convert get_item() response to field-value pairs."""
+        if 'Item' not in raw_response:
+            return {}
+        return {
+            field.name: raw_response['Item'][field.name][field.data_type]
+            for field in self._available_fields
+        }
+
+    @property
+    def client(self):
+        return self._get_client()
+
+    def get(self, key):
+        key = string(key)
+        request_parameters = self._prepare_get_request(key)
+        item_response = self.client.get_item(**request_parameters)
+        item = self._item_to_dict(item_response)
+        return item.get(self._value_field.name)
+
+    def set(self, key, value):
+        key = string(key)
+        request_parameters = self._prepare_put_request(key, value)
+        self.client.put_item(**request_parameters)
+
+    def mget(self, keys):
+        return [self.get(key) for key in keys]
+
+    def delete(self, key):
+        key = string(key)
+        request_parameters = self._prepare_get_request(key)
+        self.client.delete_item(**request_parameters)

+ 1 - 1
celery/utils/log.py

@@ -82,7 +82,7 @@ def logger_isa(l, p, max=1000):
         else:
             if this in seen:
                 raise RuntimeError(
-                    'Logger {0!r} parents recursive'.format(l),
+                    'Logger {0!r} parents recursive'.format(l.name),
                 )
             seen.add(this)
             this = this.parent

+ 3 - 0
docs/includes/installation.txt

@@ -88,6 +88,9 @@ Transports and Backends
 :``celery[riak]``:
     for using Riak as a result backend.
 
+:``celery[dynamodb]``:
+    for using AWS DynamoDB as a result backend.
+
 :``celery[zookeeper]``:
     for using Zookeeper as a message transport.
 

+ 11 - 0
docs/internals/reference/celery.backends.dynamodb.rst

@@ -0,0 +1,11 @@
+===========================================
+ ``celery.backends.dynamodb``
+===========================================
+
+.. contents::
+    :local:
+.. currentmodule:: celery.backends.dynamodb
+
+.. automodule:: celery.backends.dynamodb
+    :members:
+    :undoc-members:

+ 1 - 0
docs/internals/reference/index.rst

@@ -35,6 +35,7 @@
     celery.backends.riak
     celery.backends.cassandra
     celery.backends.couchbase
+    celery.backends.dynamodb
     celery.backends.filesystem
     celery.app.trace
     celery.app.annotations

+ 69 - 0
docs/userguide/configuration.rst

@@ -1129,6 +1129,75 @@ This is a dict supporting the following keys:
     The protocol to use to connect to the Riak server. This isn't configurable
     via :setting:`result_backend`
 
+.. _conf-dynamodb-result-backend:
+
+AWS DynamoDB backend settings
+-----------------------------
+
+.. note::
+
+    The Dynamodb backend requires the :pypi:`boto3` library.
+
+    To install this package use :command:`pip`:
+
+    .. code-block:: console
+
+        $ pip install celery[dynamodb]
+
+    See :ref:`bundles` for information on combining multiple extension
+    requirements.
+
+This backend requires the :setting:`result_backend`
+setting to be set to a DynamoDB URL::
+
+    result_backend = 'dynamodb://aws_access_key_id:aws_secret_access_key@region:port/table?read=n&write=m'
+
+For example, specifying the AWS region and the table name::
+
+    result_backend = 'dynamodb://@us-east-1/celery_results
+
+or retrieving AWS configuration parameters from the environment, using the default table name (``celery``)
+and specifying read and write provisioned throughput::
+
+    result_backend = 'dynamodb://@/?read=5&write=5'
+
+or using the `downloadable version <https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.html>`_
+of DynamoDB
+`locally <https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.Endpoint.html>`_::
+
+    result_backend = 'dynamodb://@localhost:8000
+
+The fields of the URL are defined as follows:
+
+#. ``aws_access_key_id & aws_secret_access_key``
+
+    The credentials for accessing AWS API resources. These can also be resolved
+    by the :pypi:`boto3` library from various sources, as
+    described `here <http://boto3.readthedocs.io/en/latest/guide/configuration.html#configuring-credentials>`_.
+
+#. ``region``
+
+    The AWS region, e.g. ``us-east-1`` or ``localhost`` for the `Downloadable Version <https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.html>`_.
+    See the :pypi:`boto3` library `documentation <http://boto3.readthedocs.io/en/latest/guide/configuration.html#environment-variable-configuration>`_
+    for definition options.
+
+#. ``port``
+
+   The listening port of the local DynamoDB instance, if you are using the downloadable version.
+   If you have not specified the ``region`` parameter as ``localhost``,
+   setting this parameter has **no effect**.
+
+#. ``table``
+
+    Table name to use. Default is ``celery``.
+    See the `DynamoDB Naming Rules <http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Limits.html#limits-naming-rules>`_
+    for information on the allowed characters and length.
+
+#. ``read & write``
+
+    The Read & Write Capacity Units for the created DynamoDB table. Default is ``1`` for both read and write.
+    More details can be found in the `Provisioned Throughput documentation <http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.ProvisionedThroughput.html>`_.
+
 .. _conf-ironcache-result-backend:
 
 IronCache backend settings

+ 1 - 0
requirements/extras/dynamodb.txt

@@ -0,0 +1 @@
+boto3==1.4.3

+ 1 - 1
requirements/test-ci-default.txt

@@ -16,4 +16,4 @@
 -r extras/couchdb.txt
 -r extras/consul.txt
 -r extras/cassandra.txt
-
+-r extras/dynamodb.txt

+ 1 - 0
requirements/test-integration.txt

@@ -1,2 +1,3 @@
 simplejson
 -r extras/redis.txt
+-r extras/dynamodb.txt

+ 2 - 1
setup.py

@@ -73,7 +73,8 @@ EXTENSIONS = {
     'pyro',
     'slmq',
     'tblib',
-    'consul'
+    'consul',
+    'dynamodb'
 }
 
 # -*- Classifiers -*-

+ 243 - 0
t/unit/backends/test_dynamodb.py

@@ -0,0 +1,243 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import, unicode_literals
+from decimal import Decimal
+
+import pytest
+from case import MagicMock, Mock, patch, sentinel, skip
+
+from celery.backends import dynamodb as module
+from celery.backends.dynamodb import DynamoDBBackend
+from celery.exceptions import ImproperlyConfigured
+from celery.five import string
+
+
+@skip.unless_module('boto3')
+class test_DynamoDBBackend:
+    def setup(self):
+        self._static_timestamp = Decimal(1483425566.52)  # noqa
+        self.app.conf.result_backend = 'dynamodb://'
+
+    @property
+    def backend(self):
+        """:rtype: DynamoDBBackend"""
+        return self.app.backend
+
+    def test_init_no_boto3(self):
+        prev, module.boto3 = module.boto3, None
+        try:
+            with pytest.raises(ImproperlyConfigured):
+                DynamoDBBackend(app=self.app)
+        finally:
+            module.boto3 = prev
+
+    def test_init_aws_credentials(self):
+        with pytest.raises(ImproperlyConfigured):
+            DynamoDBBackend(
+                app=self.app,
+                url='dynamodb://a:@'
+            )
+
+    def test_get_client_local(self):
+        table_creation_path = \
+            'celery.backends.dynamodb.DynamoDBBackend._get_or_create_table'
+        with patch('boto3.client') as mock_boto_client, \
+                patch(table_creation_path):
+                backend = DynamoDBBackend(
+                    app=self.app,
+                    url='dynamodb://@localhost:8000'
+                )
+                client = backend._get_client()
+                assert backend.client is client
+                mock_boto_client.assert_called_once_with(
+                    'dynamodb',
+                    endpoint_url='http://localhost:8000',
+                    region_name='us-east-1'
+                )
+                assert backend.endpoint_url == 'http://localhost:8000'
+
+    def test_get_client_credentials(self):
+        table_creation_path = \
+            'celery.backends.dynamodb.DynamoDBBackend._get_or_create_table'
+        with patch('boto3.client') as mock_boto_client, \
+                patch(table_creation_path):
+                backend = DynamoDBBackend(
+                    app=self.app,
+                    url='dynamodb://key:secret@test'
+                )
+                client = backend._get_client()
+                assert client is backend.client
+                mock_boto_client.assert_called_once_with(
+                    'dynamodb',
+                    aws_access_key_id='key',
+                    aws_secret_access_key='secret',
+                    region_name='test'
+                )
+                assert backend.aws_region == 'test'
+
+    def test_get_or_create_table_not_exists(self):
+        self.backend._client = MagicMock()
+        mock_create_table = self.backend._client.create_table = MagicMock()
+        mock_describe_table = self.backend._client.describe_table = \
+            MagicMock()
+
+        mock_describe_table.return_value = {
+            'Table': {
+                'TableStatus': 'ACTIVE'
+            }
+        }
+
+        self.backend._get_or_create_table()
+        mock_create_table.assert_called_once_with(
+            **self.backend._get_table_schema()
+        )
+
+    def test_get_or_create_table_already_exists(self):
+        from botocore.exceptions import ClientError
+
+        self.backend._client = MagicMock()
+        mock_create_table = self.backend._client.create_table = MagicMock()
+        client_error = ClientError(
+            {
+                'Error': {
+                    'Code': 'ResourceInUseException',
+                    'Message': 'Table already exists: {}'.format(
+                        self.backend.table_name
+                    )
+                }
+            },
+            'CreateTable'
+        )
+        mock_create_table.side_effect = client_error
+        mock_describe_table = self.backend._client.describe_table = \
+            MagicMock()
+
+        mock_describe_table.return_value = {
+            'Table': {
+                'TableStatus': 'ACTIVE'
+            }
+        }
+
+        self.backend._get_or_create_table()
+        mock_describe_table.assert_called_once_with(
+            TableName=self.backend.table_name
+        )
+
+    def test_wait_for_table_status(self):
+        self.backend._client = MagicMock()
+        mock_describe_table = self.backend._client.describe_table = \
+            MagicMock()
+        mock_describe_table.side_effect = [
+            {'Table': {
+                'TableStatus': 'CREATING'
+            }},
+            {'Table': {
+                'TableStatus': 'SOME_STATE'
+            }}
+        ]
+        self.backend._wait_for_table_status(expected='SOME_STATE')
+        assert mock_describe_table.call_count == 2
+
+    def test_prepare_get_request(self):
+        expected = {
+            'TableName': u'celery',
+            'Key': {u'id': {u'S': u'abcdef'}}
+        }
+        assert self.backend._prepare_get_request('abcdef') == expected
+
+    def test_prepare_put_request(self):
+        expected = {
+            'TableName': u'celery',
+            'Item': {
+                u'id': {u'S': u'abcdef'},
+                u'result': {u'B': u'val'},
+                u'timestamp': {
+                    u'N': str(Decimal(self._static_timestamp))
+                }
+            }
+        }
+        with patch('celery.backends.dynamodb.time', self._mock_time):
+            result = self.backend._prepare_put_request('abcdef', 'val')
+        assert result == expected
+
+    def test_item_to_dict(self):
+        boto_response = {
+            'Item': {
+                'id': {
+                    'S': sentinel.key
+                },
+                'result': {
+                    'B': sentinel.value
+                },
+                'timestamp': {
+                    'N': Decimal(1)
+                }
+            }
+        }
+        converted = self.backend._item_to_dict(boto_response)
+        assert converted == {
+            'id': sentinel.key,
+            'result': sentinel.value,
+            'timestamp': Decimal(1)
+        }
+
+    def test_get(self):
+        self.backend._client = Mock(name='_client')
+        self.backend._client.get_item = MagicMock()
+
+        assert self.backend.get('1f3fab') is None
+        self.backend.client.get_item.assert_called_once_with(
+            Key={u'id': {u'S': u'1f3fab'}},
+            TableName='celery'
+        )
+
+    def _mock_time(self):
+        return self._static_timestamp
+
+    def test_set(self):
+
+        self.backend._client = MagicMock()
+        self.backend._client.put_item = MagicMock()
+
+        # should return None
+        with patch('celery.backends.dynamodb.time', self._mock_time):
+            assert self.backend.set(sentinel.key, sentinel.value) is None
+
+        assert self.backend._client.put_item.call_count == 1
+        _, call_kwargs = self.backend._client.put_item.call_args
+        expected_kwargs = dict(
+            Item={
+                u'timestamp': {u'N': str(self._static_timestamp)},
+                u'id': {u'S': string(sentinel.key)},
+                u'result': {u'B': sentinel.value}
+            },
+            TableName='celery'
+        )
+        assert call_kwargs['Item'] == expected_kwargs['Item']
+        assert call_kwargs['TableName'] == 'celery'
+
+    def test_delete(self):
+        self.backend._client = Mock(name='_client')
+        mocked_delete = self.backend._client.delete = Mock('client.delete')
+        mocked_delete.return_value = None
+        # should return None
+        assert self.backend.delete('1f3fab') is None
+        self.backend.client.delete_item.assert_called_once_with(
+            Key={u'id': {u'S': u'1f3fab'}},
+            TableName='celery'
+        )
+
+    def test_backend_by_url(self, url='dynamodb://'):
+        from celery.app import backends
+        from celery.backends.dynamodb import DynamoDBBackend
+        backend, url_ = backends.by_url(url, self.app.loader)
+        assert backend is DynamoDBBackend
+        assert url_ == url
+
+    def test_backend_params_by_url(self):
+        self.app.conf.result_backend = \
+            'dynamodb://@us-east-1/celery_results?read=10&write=20'
+        assert self.backend.aws_region == 'us-east-1'
+        assert self.backend.table_name == 'celery_results'
+        assert self.backend.read_capacity_units == 10
+        assert self.backend.write_capacity_units == 20
+        assert self.backend.endpoint_url is None

+ 8 - 1
tox.ini

@@ -1,7 +1,7 @@
 [tox]
 envlist =
     {2.7,pypy,3.4,3.5,3.6}-unit
-    {2.7,pypy,3.4,3.5,3.6}-integration-{rabbitmq,redis}
+    {2.7,pypy,3.4,3.5,3.6}-integration-{rabbitmq,redis,dynamodb}
 
     flake8
     flakeplus
@@ -36,6 +36,11 @@ setenv =
     redis: TEST_BROKER=redis://
     redis: TEST_BACKEND=redis://
 
+    dynamodb: TEST_BROKER=redis://
+    dynamodb: TEST_BACKEND=dynamodb://@localhost:8000
+    dynamodb: AWS_ACCESS_KEY_ID=test_aws_key_id
+    dynamodb: AWS_SECRET_ACCESS_KEY=test_aws_secret_key
+
 basepython =
     2.7: python2.7
     3.4: python3.4
@@ -45,6 +50,8 @@ basepython =
     flake8,flakeplus,apicheck,linkcheck,configcheck,pydocstyle: python2.7
 
 [testenv:apicheck]
+setenv =
+    PYTHONHASHSEED = 100
 commands =
     sphinx-build -b apicheck -d {envtmpdir}/doctrees docs docs/_build/apicheck