Skip to content

Commit 6f90ad8

Browse files
committed
first working version
0 parents  commit 6f90ad8

7 files changed

+373
-0
lines changed

.gitignore

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
*.py[cod]
2+
3+
# C extensions
4+
*.so
5+
6+
# Packages
7+
*.egg
8+
*.egg-info
9+
dist
10+
build
11+
eggs
12+
parts
13+
bin
14+
var
15+
sdist
16+
develop-eggs
17+
.installed.cfg
18+
lib
19+
lib64
20+
__pycache__
21+
22+
# Installer logs
23+
pip-log.txt
24+
25+
# Unit test / coverage reports
26+
.coverage
27+
.tox
28+
nosetests.xml

README.md

+147
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
Tyron
2+
======
3+
Tyron is a web app to send push events to connected clients
4+
5+
A client connects to tyron and subscribes to a specific channel,
6+
tyron will keep the http connection open until something get published
7+
to that channel.
8+
9+
To keep the connection count as little as possible open connection will
10+
timeout after a configurable interval of time.
11+
12+
The messaging backend utilises the redis pub/sub feature.
13+
14+
Subscribe to channels
15+
====================
16+
Clients subscribes to channel using the /<channel>/ entry point
17+
18+
Publish to clients
19+
==================
20+
To publish to a client you need to connect to redis and publish
21+
on the channel tyron subscribes (see CHANNEL_NAME setting)
22+
23+
NOTE: The user channel is part of the message you publish to redis
24+
25+
Message format
26+
==============
27+
Messages must follow this json format:
28+
29+
`{"channel": "user_channel", "data": "...."}`
30+
31+
32+
Example:
33+
========
34+
35+
client side: POST to /hiphop/
36+
tyron subscribes the client to hiphop channel
37+
38+
`{"channel": "hiphop", "data": "...."}`
39+
40+
41+
Install
42+
=======
43+
44+
Just do pip install tyron or do a setup.py install
45+
46+
Note: If you are using OSX you might need something like this:
47+
48+
`CFLAGS="-I /opt/local/include -L /opt/local/lib" pip install tyron`
49+
50+
Settings
51+
========
52+
53+
**DEBUG**
54+
55+
Run Flask in debug mode
56+
57+
**LONGPOLLING_TIMEOUT**
58+
59+
`Default: 3`
60+
61+
The timeout in seconds for connections waiting for
62+
63+
**TIMEOUT\_RESPONSE_MESSAGE**
64+
65+
`Default: str('')`
66+
67+
The message the webserver will return to clients when LONGPOLLING_TIMEOUT occours
68+
69+
**CHANNEL_NAME**
70+
71+
`Default: tyron_pubsub`
72+
73+
The redis pub/sub channel tyron subscribes to
74+
75+
**REDIS_HOSTNAME**
76+
77+
`Default: localhost`
78+
79+
80+
**REDIS_PORT**
81+
82+
`Default: 6379`
83+
84+
85+
**REDIS_DB**
86+
87+
`Default: 0`
88+
89+
90+
**LOG_LEVEL**
91+
92+
`Default: logging.INFO`
93+
94+
The loglevel (from python logging module)
95+
96+
You can override settings pointing tyron to your own settings module.
97+
The settings module must be specified in the TYRON_CONF environment variable.
98+
99+
eg.
100+
101+
`TYRON_CONF=my_settings.py python tyron.py`
102+
103+
104+
DEPLOY
105+
======
106+
107+
tyron is a WSGI app so you have lots of choice in terms of web servers :)
108+
You definetely wants to opt for some async worker as tyron will keep an high amount of open idle connections (so gevent/libevent ...)
109+
110+
I personally like the semplicity of green unicorn:
111+
112+
113+
`
114+
pip install gunicorn
115+
gunicorn -b :8091 -w 9 -k gevent --worker-connections=2000 tyron --log-level=critical
116+
`
117+
118+
few notes about chosen parameters:
119+
120+
-k gevent tells gunicorn to use the gevent worker
121+
-w 9 means, spawn 9 worker processes (2-4 * cpu_cores is suggested)
122+
--worker-connections 2000 every worker process will handle 2000 connections
123+
124+
125+
DEPLOY CAVEATS
126+
==============
127+
128+
On high traffic web sites is quite easy to hit some common limit of your kernel
129+
limits, the first limit you are probably going to hit is the file descriptor limit.
130+
By default Ubuntu (and many other OS) have the very low file descriptor limit of 1024
131+
I suggest to benchmark your machine running tyron to find the best value for this limit and update it.
132+
133+
Verbose logging can lead to issues, unless the logging facility you are using handles that for you.
134+
Logging INFO/DEBUG messages can easily lead to high cpu usage.
135+
136+
137+
BENCHMARKS
138+
==========
139+
140+
coming soon ...
141+
142+
.using an EC2 medium instance and bees with machine guns
143+
144+
145+
146+
147+

setup.py

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from setuptools import find_packages
2+
from setuptools import setup
3+
4+
description="""
5+
Gevent redis/pubsub event notifier written in flask and gevent
6+
"""
7+
8+
setup(
9+
name="tyron",
10+
version='0.0.2',
11+
url='https://github.com/tbarbugli/tyron',
12+
license='BSD',
13+
platforms=['OS Independent'],
14+
description = description.strip(),
15+
author = 'Tommaso Barbugli',
16+
author_email = '[email protected]',
17+
maintainer = 'Tommaso Barbugli',
18+
maintainer_email = '[email protected]',
19+
packages=find_packages(),
20+
tests_require=[
21+
'mock',
22+
],
23+
test_suite='tests',
24+
dependency_links = [
25+
'https://github.com/downloads/SiteSupport/gevent/gevent-1.0rc2.tar.gz#egg=gevent'
26+
],
27+
requires = [
28+
'Flask (==0.9)',
29+
'Jinja2 (==2.6)',
30+
'Werkzeug (==0.8.3)',
31+
'greenlet (==0.4.0)',
32+
'gunicorn (==0.17.2)',
33+
'redis (==2.7.2)',
34+
'ujson (==1.30)',
35+
],
36+
entry_points={
37+
'console_scripts': [
38+
'tyron = tyron:main',
39+
],
40+
},
41+
classifiers=[
42+
'Development Status :: 4 - Beta',
43+
'Framework :: Django',
44+
'Intended Audience :: Developers',
45+
'License :: OSI Approved :: BSD License',
46+
'Operating System :: OS Independent',
47+
'Programming Language :: Python',
48+
'Programming Language :: Python :: 2.7',
49+
'Topic :: Internet :: WWW/HTTP',
50+
]
51+
)

tests.py

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import json
2+
import mock
3+
import redis
4+
from redis.client import PubSub
5+
from mock import MagicMock
6+
from mock import patch
7+
import unittest
8+
from tyron.tyron import subscriptions
9+
from tyron.tyron import RedisSub
10+
11+
redis_pubsub = RedisSub('channel', 'localhost', 6379, 1)
12+
13+
class TestRedisPubSub(unittest.TestCase):
14+
15+
@patch.object(PubSub, 'listen')
16+
@patch.object(PubSub, 'execute_command')
17+
def test_subscribes_to_redis(self, pubsub_exec, *args):
18+
redis_pubsub.subscribe()
19+
pubsub_exec.assert_called_with('SUBSCRIBE', 'channel')
20+
21+
def test_redis_connection_from_init(self):
22+
redis_pubsub = RedisSub('channel', 'redis.local', 6380, 1, 'xxx')
23+
client = redis_pubsub.get_redis_connection()
24+
configs = client.connection_pool.connection_kwargs
25+
assert configs['host'] == 'redis.local'
26+
assert configs['port'] == 6380
27+
assert configs['db'] == 1
28+
assert configs['password'] == 'xxx'
29+
30+
def test_message_parsing(self):
31+
message = {
32+
'channel': 'channelname',
33+
'data': 'data'
34+
}
35+
encoded_message = json.dumps(message)
36+
r_message = redis_pubsub.parse_message(encoded_message)
37+
assert 'channelname', 'data' == r_message
38+
39+
def test_redis_config(self):
40+
pass
41+
42+
def test_timeout(self):
43+
pass

tyron/__init__.py

Whitespace-only changes.

tyron/default_settings.py

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import logging
2+
3+
DEBUG = False
4+
TIMEOUT_RESPONSE_MESSAGE = ''
5+
LONGPOLLING_TIMEOUT = 15
6+
CHANNEL_NAME = 'notifications_pubsub'
7+
REDIS_HOSTNAME = 'localhost'
8+
REDIS_PORT = 6379
9+
REDIS_DB = 0
10+
HTTP_PORT = 8080
11+
LOG_LEVEL = logging.INFO

tyron/tyron.py

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
from collections import defaultdict
2+
3+
try:
4+
import ujson as json
5+
except ImportError:
6+
import json
7+
8+
from flask import Flask
9+
import gevent
10+
from gevent import monkey
11+
from gevent.queue import Channel
12+
import os
13+
import logging
14+
import redis
15+
16+
monkey.patch_all()
17+
18+
application = Flask(__name__)
19+
application.config.from_object('tyron.default_settings')
20+
if 'TYRON_CONF' in os.environ:
21+
application.config.from_envvar('TYRON_CONF')
22+
application.logger.setLevel(application.config['LOG_LEVEL'])
23+
24+
subscriptions = defaultdict(Channel)
25+
26+
class RedisSub(gevent.Greenlet):
27+
"""
28+
subscribes to a redis pubsub channel and routes
29+
messages to subscribers
30+
31+
messages have this format
32+
{'channel': ..., 'data': ...}
33+
34+
"""
35+
36+
def __init__(self, pubsub_channel, redis_hostname, redis_port, redis_db, redis_password=None):
37+
gevent.Greenlet.__init__(self)
38+
self.pubsub_channel = pubsub_channel
39+
self.redis_hostname = redis_hostname
40+
self.redis_port = redis_port
41+
self.redis_db = redis_db
42+
self.redis_password = redis_password
43+
44+
def get_redis_connection(self):
45+
return redis.Redis(self.redis_hostname, self.redis_port, self.redis_db, self.redis_password)
46+
47+
def parse_message(self, message):
48+
msg = json.loads(message)
49+
return msg['channel'], msg['data']
50+
51+
def handle_message(self, message):
52+
channel, data = self.parse_message(message)
53+
gevent_channel = subscriptions[channel]
54+
while gevent_channel.getters:
55+
gevent_channel.put_nowait(data)
56+
57+
def subscribe(self):
58+
connection = self.get_redis_connection()
59+
pubsub = connection.pubsub()
60+
pubsub.subscribe(self.pubsub_channel)
61+
for message in pubsub.listen():
62+
if message['type'] != 'message':
63+
continue
64+
self.handle_message(message['data'])
65+
66+
def _run(self):
67+
self.subscribe()
68+
69+
@application.route('/')
70+
def health():
71+
return 'OK'
72+
73+
@application.route('/<channel>/', methods=('GET', 'POST', 'OPTIONS'))
74+
def subscribe(channel):
75+
timeout = application.config['LONGPOLLING_TIMEOUT']
76+
try:
77+
message = subscriptions[channel].get(timeout=timeout)
78+
except Timeout:
79+
message = application.config['TIMEOUT_RESPONSE_MESSAGE']
80+
return message
81+
82+
@application.before_first_request
83+
def start_subscribe_loop():
84+
pubsub = RedisSub(
85+
pubsub_channel=application.config['CHANNEL_NAME'],
86+
redis_hostname=application.config['REDIS_HOSTNAME'],
87+
redis_port=application.config['REDIS_PORT'],
88+
redis_db=application.config['REDIS_DB']
89+
)
90+
gevent.spawn(pubsub.start)
91+
92+
if __name__ == '__main__':
93+
application.run()

0 commit comments

Comments
 (0)