Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Redis Sentinel #262

Open
wants to merge 6 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 1 addition & 26 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,9 @@ jobs:
strategy:
matrix:
python-version: [3.8, 3.9, 3.10, 3.11, 3.12]
services:
mongodb:
image: mongo
ports:
- 27017:27017
dynamodb:
image: amazon/dynamodb-local
ports:
- 8000:8000

postgresql:
image: postgres:latest
ports:
- 5433:5432
env:
POSTGRES_PASSWORD: pwd
POSTGRES_USER: root
POSTGRES_DB: dummy
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: supercharge/[email protected]
- uses: niden/actions-memcached@v7
- uses: hoverkraft-tech/[email protected]
- name: Install testing requirements
run: pip3 install -r requirements/dev.txt
- name: Run tests
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def get():

## Supported Storage Types

- Redis
- Redis (standalone and Sentinel)
- Memcached
- FileSystem
- MongoDB
Expand Down
152 changes: 148 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,152 @@ services:
volumes:
- postgres_data:/var/lib/postgresql/data

redis-master:
image: redis:latest
container_name: redis-master
command:
[
"redis-server",
"--port",
"6380",
"--protected-mode",
"no"
]
network_mode: host
volumes:
- redis_ha_master_data:/data


redis-slave-1:
image: redis:latest
container_name: redis-slave-1
depends_on:
- redis-master
command:
[
"redis-server",
"--port",
"6381",
"--replicaof",
"127.0.0.1",
"6380",
"--protected-mode",
"no"
]
network_mode: host
volumes:
- redis_ha_slave_1_data:/data


redis-slave-2:
image: redis:latest
container_name: redis-slave-2
depends_on:
- redis-master
command:
[
"redis-server",
"--port",
"6382",
"--replicaof",
"127.0.0.1",
"6380",
"--protected-mode",
"no"
]
network_mode: host
volumes:
- redis_ha_slave_2_data:/data


sentinel-1:
image: redis:latest
container_name: sentinel-1
depends_on:
- redis-master
configs:
- source: sentinel
target: /data/sentinel.conf
mode: 0660
uid: "999"
command:
[
"redis-server",
"sentinel.conf",
"--port",
"26379",
"--sentinel",
]
network_mode: host
ports:
- "26379:26379"
volumes:
- redis_ha_sentinel_1_data:/data


sentinel-2:
image: redis:latest
container_name: sentinel-2
depends_on:
- redis-master
configs:
- source: sentinel
target: /data/sentinel.conf
mode: 0660
uid: "999"
command:
[
"redis-server",
"sentinel.conf",
"--port",
"26380",
"--sentinel",
]
network_mode: host
volumes:
- redis_ha_sentinel_2_data:/data


sentinel-3:
image: redis:latest
container_name: sentinel-3
depends_on:
- redis-master
configs:
- source: sentinel
target: /data/sentinel.conf
mode: 0660
uid: "999"
command:
[
"redis-server",
"sentinel.conf",
"--port",
"26381",
"--sentinel",
]
network_mode: host
volumes:
- redis_ha_sentinel_3_data:/data

volumes:
postgres_data:
mongo_data:
redis_data:
dynamodb_data:
postgres_data:
mongo_data:
redis_data:
dynamodb_data:
redis_ha_master_data:
redis_ha_slave_1_data:
redis_ha_slave_2_data:
redis_ha_sentinel_1_data:
redis_ha_sentinel_2_data:
redis_ha_sentinel_3_data:

configs:
sentinel:
content: |
bind 0.0.0.0
sentinel monitor mymaster 127.0.0.1 6380 2
sentinel resolve-hostnames yes
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 5000
sentinel parallel-syncs mymaster 1
1 change: 1 addition & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Anything documented here is part of the public API that Flask-Session provides,
:members: regenerate

.. autoclass:: flask_session.redis.RedisSessionInterface
.. autoclass:: flask_session.redis.RedisSentinelSessionInterface
.. autoclass:: flask_session.memcached.MemcachedSessionInterface
.. autoclass:: flask_session.filesystem.FileSystemSessionInterface
.. autoclass:: flask_session.cachelib.CacheLibSessionInterface
Expand Down
15 changes: 15 additions & 0 deletions docs/config_example.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,21 @@ If you do not set ``SESSION_REDIS``, Flask-Session will assume you are developin
:meth:`redis.Redis` instance for you. It is expected you supply an instance of
:meth:`redis.Redis` in production.

Similarly, if you use a high-availability setup for Redis using Sentinel you can use the following setup

.. code-block:: python

from redis import Sentinel
app.config['SESSION_TYPE'] = 'redissentinel'
app.config['SESSION_REDIS_SENTINEL'] = Sentinel(
[("127.0.0.1", 26379), ("127.0.0.1", 26380), ("127.0.0.1", 26381)],
)

It is expected that you set ``SESSION_REDIS_SENTINEL`` to your own :meth:`redis.Sentinel` instance.
The name of the master set is obtained via the config ``SESSION_REDIS_SENTINEL_MASTER_SET`` which defaults to ``mymaster``.



.. note::

By default, sessions in Flask-Session are permanent with an expiration of 31 days.
1 change: 1 addition & 0 deletions docs/config_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ These are specific to Flask-Session.
Specifies which type of session interface to use. Built-in session types:

- **redis**: RedisSessionInterface
- **redissentinel**: RedisSentinelSessionInterface
- **memcached**: MemcachedSessionInterface
- **filesystem**: FileSystemSessionInterface (Deprecated in 0.7.0, will be removed in 1.0.0 in favor of CacheLibSessionInterface)
- **cachelib**: CacheLibSessionInterface
Expand Down
11 changes: 11 additions & 0 deletions src/flask_session/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ def _get_interface(self, app):
# Redis settings
SESSION_REDIS = config.get("SESSION_REDIS", Defaults.SESSION_REDIS)

SESSION_REDIS_SENTINEL = config.get("SESSION_REDIS_SENTINEL", Defaults.SESSION_REDIS_SENTINEL)
SESSION_REDIS_SENTINEL_MASTER_SET = config.get("SESSION_REDIS_SENTINEL_MASTER_SET", Defaults.SESSION_REDIS_SENTINEL_MASTER_SET)

# Memcached settings
SESSION_MEMCACHED = config.get("SESSION_MEMCACHED", Defaults.SESSION_MEMCACHED)

Expand Down Expand Up @@ -144,6 +147,14 @@ def _get_interface(self, app):
**common_params,
client=SESSION_REDIS,
)
elif SESSION_TYPE == "redissentinel":
from .redis import RedisSentinelSessionInterface

session_interface = RedisSentinelSessionInterface(
**common_params,
client=SESSION_REDIS_SENTINEL,
master=SESSION_REDIS_SENTINEL_MASTER_SET,
)
elif SESSION_TYPE == "memcached":
from .memcached import MemcachedSessionInterface

Expand Down
4 changes: 4 additions & 0 deletions src/flask_session/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ class Defaults:
# Redis settings
SESSION_REDIS = None

# Redis Sentinal settings
SESSION_REDIS_SENTINEL = None
SESSION_REDIS_SENTINEL_MASTER_SET = "mymaster"

# Memcached settings
SESSION_MEMCACHED = None

Expand Down
1 change: 1 addition & 0 deletions src/flask_session/redis/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from .redis import RedisSession, RedisSessionInterface # noqa: F401
from .redis_sentinel import RedisSentinelSession, RedisSentinelSessionInterface
57 changes: 57 additions & 0 deletions src/flask_session/redis/redis_sentinel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from typing import Optional

from flask import Flask
from redis import Sentinel

from .redis import RedisSessionInterface
from ..base import ServerSideSession
from ..defaults import Defaults


class RedisSentinelSession(ServerSideSession):
pass


class RedisSentinelSessionInterface(RedisSessionInterface):
"""Uses the Redis key-value store deployed in a high availability mode as a session storage. (`redis-py` required)

:param client: A ``redis.Sentinel`` instance.
:param master: The name of the master node.
:param key_prefix: A prefix that is added to all storage keys.
:param use_signer: Whether to sign the session id cookie or not.
:param permanent: Whether to use permanent session or not.
:param sid_length: The length of the generated session id in bytes.
:param serialization_format: The serialization format to use for the session data.
"""

session_class = RedisSentinelSession
ttl = True

def __init__(
self,
app: Flask,
client: Optional[Sentinel] = Defaults.SESSION_REDIS_SENTINEL,
master: str = Defaults.SESSION_REDIS_SENTINEL_MASTER_SET,
key_prefix: str = Defaults.SESSION_KEY_PREFIX,
use_signer: bool = Defaults.SESSION_USE_SIGNER,
permanent: bool = Defaults.SESSION_PERMANENT,
sid_length: int = Defaults.SESSION_ID_LENGTH,
serialization_format: str = Defaults.SESSION_SERIALIZATION_FORMAT,
):
if client is None or not isinstance(client, Sentinel):
raise TypeError("No valid Sentinel instance provided.")
self.sentinel = client
self.master = master
super().__init__(
app, self.client, key_prefix, use_signer, permanent, sid_length, serialization_format
)
self._client = None

@property
def client(self):
return self.sentinel.master_for(self.master)

@client.setter
def client(self, value):
# the _client is only needed and the setter only needed for the inheritance to work
self._client = value
48 changes: 46 additions & 2 deletions tests/test_redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
from contextlib import contextmanager

import flask
from flask_session.redis import RedisSession
from redis import Redis
from flask_session.defaults import Defaults
from flask_session.redis import RedisSession, RedisSentinelSession
from redis import Redis, Sentinel


class TestRedisSession:
Expand Down Expand Up @@ -40,3 +41,46 @@ def test_redis_default(self, app_utils):
json.loads(byte_string.decode("utf-8")) if byte_string else {}
)
assert stored_session.get("value") == "44"


class TestRedisSentinelSession:
"""This requires package: redis"""

@contextmanager
def setup_sentinel(self):
self.sentinel = Sentinel(
[("127.0.0.1", 26379), ("127.0.0.1", 26380), ("127.0.0.1", 26381)],
# sentinel_kwargs={"password": "redispassword"},
# socket_timeout=1
)
self.master: Redis = self.sentinel.master_for(
Defaults.SESSION_REDIS_SENTINEL_MASTER_SET
)
self.master.flushall()
yield
self.master.flushall()

def retrieve_stored_session(self, key):
master = self.sentinel.master_for(
Defaults.SESSION_REDIS_SENTINEL_MASTER_SET
)
return master.get(key)

def test_redis_default(self, app_utils):
with self.setup_sentinel():
app = app_utils.create_app(
{"SESSION_TYPE": "redissentinel", "SESSION_REDIS_SENTINEL": self.sentinel}
)

with app.test_request_context():
assert isinstance(flask.session, RedisSentinelSession)
app_utils.test_session(app)

# Check if the session is stored in Redis
cookie = app_utils.test_session_with_cookie(app)
session_id = cookie.split(";")[0].split("=")[1]
byte_string = self.retrieve_stored_session(f"session:{session_id}")
stored_session = (
json.loads(byte_string.decode("utf-8")) if byte_string else {}
)
assert stored_session.get("value") == "44"
Loading