Skip to content

Commit

Permalink
Merge pull request #2 from cloudblue/kombu_transport
Browse files Browse the repository at this point in the history
Add Kombu transport
  • Loading branch information
d3rky authored Sep 7, 2020
2 parents ad2a58f + b32c65b commit db816e1
Show file tree
Hide file tree
Showing 28 changed files with 1,005 additions and 133 deletions.
24 changes: 22 additions & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,24 +1,44 @@
dist: bionic
language: python
matrix:
services:
- docker
jobs:
include:
- os: linux
python: 3.6
sudo: true
env: BUILD=PYPI
env:
- BUILD=PYPI
- DJANGO_VERSION=3.1.*
- os: linux
python: 3.7
sudo: true
env: DJANGO_VERSION=3.1.*
- os: linux
python: 3.8
sudo: true
env: DJANGO_VERSION=1.11.*
- os: linux
python: 3.8
sudo: true
env: DJANGO_VERSION=2.2.*
- os: linux
python: 3.8
sudo: true
env:
- DJANGO_VERSION=3.1.*
- INTEGRATION_TESTS=yes
- COMPAT_TESTS=yes
install:
- pip install -r requirements/dev.txt
- pip install -r requirements/test.txt
- pip install pytest-cov
- pip install django==$DJANGO_VERSION
script:
- flake8
- python setup.py test
- ./travis_integration_tests.sh
- ./travis_compat_tests.sh
- sonar-scanner
after_success:
- bash <(curl -s https://codecov.io/bash)
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Django CQRS
===========
![pyversions](https://img.shields.io/pypi/pyversions/django-cqrs.svg) [![PyPi Status](https://img.shields.io/pypi/v/django-cqrs.svg)](https://pypi.org/project/django-cqrs/) [![codecov](https://codecov.io/gh/cloudblue/django-cqrs/branch/master/graph/badge.svg)](https://codecov.io/gh/cloudblue/django-cqrs) [![Build Status](https://travis-ci.org/cloudblue/django-cqrs.svg?branch=master)](https://travis-ci.org/cloudblue/django-cqrs) [![PyPI status](https://img.shields.io/pypi/status/django-cqrs.svg)](https://pypi.python.org/pypi/django-cqrs/) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=django-cqrs&metric=alert_status)](https://sonarcloud.io/dashboard?id=django-cqrs)
![pyversions](https://img.shields.io/pypi/pyversions/django-cqrs.svg) [![PyPi Status](https://img.shields.io/pypi/v/django-cqrs.svg)](https://pypi.org/project/django-cqrs/)) [![Docs](https://readthedocs.org/projects/django-cqrs/badge/?version=latest)](https://readthedocs.org/projects/django-cqrs) [![codecov](https://codecov.io/gh/cloudblue/django-cqrs/branch/master/graph/badge.svg)](https://codecov.io/gh/cloudblue/django-cqrs) [![Build Status](https://travis-ci.org/cloudblue/django-cqrs.svg?branch=master)](https://travis-ci.org/cloudblue/django-cqrs) [![PyPI status](https://img.shields.io/pypi/status/django-cqrs.svg)](https://pypi.python.org/pypi/django-cqrs/) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=django-cqrs&metric=alert_status)](https://sonarcloud.io/dashboard?id=django-cqrs)

`django-cqrs` is an Django application, that implements CQRS data synchronisation between several Django microservices.

Expand All @@ -12,6 +12,12 @@ In Connect we have a rather complex Domain Model. There are many microservices,
The pattern, that solves this issue is called [CQRS - Command Query Responsibility Segregation](https://microservices.io/patterns/data/cqrs.html). Core idea behind this pattern is that view databases (replicas) are defined for efficient querying and DB joins. Applications keep their replicas up to data by subscribing to [Domain events](https://microservices.io/patterns/data/domain-event.html) published by the service that owns the data. Data is [eventually consistent](https://en.wikipedia.org/wiki/Eventual_consistency) and that's okay for non-critical business transactions.


Documentation
=============

Full documentation is available at [https://django-cqrs.readthedocs.org](https://django-cqrs.readthedocs.org).


Examples
========

Expand Down
3 changes: 2 additions & 1 deletion dj_cqrs/transport/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.utils.module_loading import import_string

from dj_cqrs.transport.base import BaseTransport
from dj_cqrs.transport.kombu import KombuTransport
from dj_cqrs.transport.rabbit_mq import RabbitMQTransport


Expand All @@ -22,4 +23,4 @@
raise ImportError('Bad CQRS transport class.')


__all__ = [BaseTransport, RabbitMQTransport, current_transport]
__all__ = [BaseTransport, KombuTransport, RabbitMQTransport, current_transport]
191 changes: 191 additions & 0 deletions dj_cqrs/transport/kombu.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
# Copyright © 2020 Ingram Micro Inc. All rights reserved.

import logging

import ujson
from django.conf import settings
from kombu import Connection, Exchange, Producer, Queue
from kombu.exceptions import KombuError
from kombu.mixins import ConsumerMixin


from dj_cqrs.constants import SignalType
from dj_cqrs.controller import consumer
from dj_cqrs.dataclasses import TransportPayload
from dj_cqrs.registries import ReplicaRegistry
from dj_cqrs.transport import BaseTransport
from dj_cqrs.transport.mixins import LoggingMixin

logger = logging.getLogger('django-cqrs')


class _KombuConsumer(ConsumerMixin):

def __init__(self, url, exchange_name, queue_name, prefetch_count, callback):
self.connection = Connection(url)
self.exchange = Exchange(
exchange_name,
type='topic',
durable=True,
)
self.queue_name = queue_name
self.prefetch_count = prefetch_count
self.callback = callback
self.queues = []
self._init_queues()

def _init_queues(self):
channel = self.connection.channel()
for cqrs_id in ReplicaRegistry.models.keys():
q = Queue(
self.queue_name,
exchange=self.exchange,
routing_key=cqrs_id,
)
q.maybe_bind(channel)
q.declare()
self.queues.append(q)

sync_q = Queue(
self.queue_name,
exchange=self.exchange,
routing_key='cqrs.{}.{}'.format(self.queue_name, cqrs_id),
)
sync_q.maybe_bind(channel)
sync_q.declare()
self.queues.append(sync_q)

def get_consumers(self, Consumer, channel):
return [
Consumer(
queues=self.queues,
callbacks=[self.callback],
prefetch_count=self.prefetch_count,
auto_declare=True,
),
]


class KombuTransport(LoggingMixin, BaseTransport):
CONSUMER_RETRY_TIMEOUT = 5

@classmethod
def consume(cls):
queue_name, prefetch_count = cls._get_consumer_settings()
url, exchange_name = cls._get_common_settings()

consumer = _KombuConsumer(
url,
exchange_name,
queue_name,
prefetch_count,
cls._consume_message,
)
consumer.run()

@classmethod
def produce(cls, payload):
url, exchange_name = cls._get_common_settings()

connection = None
try:
# Decided not to create context-manager to stay within the class
connection, channel = cls._get_producer_kombu_objects(url, exchange_name)
exchange = cls._create_exchange(exchange_name)
cls._produce_message(channel, exchange, payload)
cls.log_produced(payload)
except KombuError:
logger.error("CQRS couldn't be published: pk = {} ({}).".format(
payload.pk, payload.cqrs_id,
))
finally:
if connection:
connection.close()

@classmethod
def _consume_message(cls, body, message):
try:
dct = ujson.loads(body)
for key in ('signal_type', 'cqrs_id', 'instance_data'):
if key not in dct:
raise ValueError

if 'instance_pk' not in dct:
logger.warning('CQRS deprecated package structure.')

except ValueError:
logger.error("CQRS couldn't be parsed: {}.".format(body))
message.reject()
return

payload = TransportPayload(
dct['signal_type'], dct['cqrs_id'], dct['instance_data'], dct.get('instance_pk'),
previous_data=dct.get('previous_data'),
)

cls.log_consumed(payload)
instance = consumer.consume(payload)

if instance:
message.ack()
cls.log_consumed_accepted(payload)
else:
message.reject()
cls.log_consumed_denied(payload)

@classmethod
def _produce_message(cls, channel, exchange, payload):
routing_key = cls._get_produced_message_routing_key(payload)
producer = Producer(
channel,
exchange=exchange,
auto_declare=True,
)
producer.publish(
ujson.dumps(payload.to_dict()),
routing_key=routing_key,
mandatory=True,
content_type='text/plain',
delivery_mode=2,
)

@staticmethod
def _get_produced_message_routing_key(payload):
routing_key = payload.cqrs_id

if payload.signal_type == SignalType.SYNC and payload.queue:
routing_key = 'cqrs.{}.{}'.format(payload.queue, routing_key)

return routing_key

@classmethod
def _get_producer_kombu_objects(cls, url, exchange_name):
connection = Connection(url)
channel = connection.channel()
return connection, channel

@staticmethod
def _create_exchange(exchange_name):
return Exchange(
exchange_name,
type='topic',
durable=True,
)

@staticmethod
def _get_common_settings():
url = settings.CQRS.get('url', 'amqp://localhost')
exchange = settings.CQRS.get('exchange', 'cqrs')
return (
url,
exchange,
)

@staticmethod
def _get_consumer_settings():
queue_name = settings.CQRS['queue']
consumer_prefetch_count = settings.CQRS.get('consumer_prefetch_count', 10)
return (
queue_name,
consumer_prefetch_count,
)
38 changes: 38 additions & 0 deletions dj_cqrs/transport/mixins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import logging


logger = logging.getLogger('django-cqrs')


class LoggingMixin:

@staticmethod
def log_consumed(payload):
"""
:param dj_cqrs.dataclasses.TransportPayload payload: Transport payload from master model.
"""
if payload.pk:
logger.info('CQRS is received: pk = {} ({}).'.format(payload.pk, payload.cqrs_id))

@staticmethod
def log_consumed_accepted(payload):
"""
:param dj_cqrs.dataclasses.TransportPayload payload: Transport payload from master model.
"""
if payload.pk:
logger.info('CQRS is applied: pk = {} ({}).'.format(payload.pk, payload.cqrs_id))

@staticmethod
def log_consumed_denied(payload):
"""
:param dj_cqrs.dataclasses.TransportPayload payload: Transport payload from master model.
"""
if payload.pk:
logger.info('CQRS is denied: pk = {} ({}).'.format(payload.pk, payload.cqrs_id))

@staticmethod
def log_produced(payload):
"""
:param dj_cqrs.dataclasses.TransportPayload payload: Transport payload from master model.
"""
logger.info('CQRS is published: pk = {} ({}).'.format(payload.pk, payload.cqrs_id))
Loading

0 comments on commit db816e1

Please sign in to comment.