Skip to content

Commit a7f307e

Browse files
authored
Merge branch 'master' into fix-ssl-verification
2 parents 0fe87e6 + 91be4a0 commit a7f307e

16 files changed

+1721
-381
lines changed

.github/actions/run-tests/action.yml

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ inputs:
1414
description: 'hiredis version to test against'
1515
required: false
1616
default: '>3.0.0'
17+
hiredis-branch:
18+
description: 'hiredis branch to test against'
19+
required: false
20+
default: 'master'
1721
event-loop:
1822
description: 'Event loop to use'
1923
required: false
@@ -28,6 +32,14 @@ runs:
2832
python-version: ${{ inputs.python-version }}
2933
cache: 'pip'
3034

35+
- uses: actions/checkout@v4
36+
if: ${{ inputs.parser-backend == 'hiredis' && inputs.hiredis-version == 'unstable' }}
37+
with:
38+
repository: redis/hiredis-py
39+
submodules: true
40+
path: hiredis-py
41+
ref: ${{ inputs.hiredis-branch }}
42+
3143
- name: Setup Test environment
3244
env:
3345
REDIS_VERSION: ${{ inputs.redis-version }}
@@ -40,15 +52,21 @@ runs:
4052
pip uninstall -y redis # uninstall Redis package installed via redis-entraid
4153
pip install -e .[jwt] # install the working copy
4254
if [ "${{inputs.parser-backend}}" == "hiredis" ]; then
43-
pip install "hiredis${{inputs.hiredis-version}}"
44-
echo "PARSER_BACKEND=$(echo "${{inputs.parser-backend}}_${{inputs.hiredis-version}}" | sed 's/[^a-zA-Z0-9]/_/g')" >> $GITHUB_ENV
55+
if [[ "${{inputs.hiredis-version}}" == "unstable" ]]; then
56+
echo "Installing unstable version of hiredis from local directory"
57+
pip install -e ./hiredis-py
58+
else
59+
pip install "hiredis${{inputs.hiredis-version}}"
60+
fi
61+
echo "PARSER_BACKEND=$(echo "${{inputs.parser-backend}}_${{inputs.hiredis-version}}" | sed 's/[^a-zA-Z0-9]/_/g')" >> $GITHUB_ENV
4562
else
4663
echo "PARSER_BACKEND=${{inputs.parser-backend}}" >> $GITHUB_ENV
4764
fi
4865
echo "::endgroup::"
4966
5067
echo "::group::Starting Redis servers"
5168
redis_major_version=$(echo "$REDIS_VERSION" | grep -oP '^\d+')
69+
echo "REDIS_MAJOR_VERSION=${redis_major_version}" >> $GITHUB_ENV
5270
5371
if (( redis_major_version < 8 )); then
5472
echo "Using redis-stack for module tests"
@@ -70,8 +88,7 @@ runs:
7088
7189
if (( redis_major_version < 7 )); then
7290
export REDIS_STACK_EXTRA_ARGS="--tls-auth-clients optional --save ''"
73-
export REDIS_EXTRA_ARGS="--tls-auth-clients optional --save ''"
74-
echo "REDIS_MAJOR_VERSION=${redis_major_version}" >> $GITHUB_ENV
91+
export REDIS_EXTRA_ARGS="--tls-auth-clients optional --save ''"
7592
fi
7693
7794
invoke devenv --endpoints=all-stack
@@ -108,12 +125,10 @@ runs:
108125
fi
109126

110127
echo "::endgroup::"
111-
112-
if [ "$protocol" == "2" ] || [ "${{inputs.parser-backend}}" != 'hiredis' ]; then
113-
echo "::group::RESP${protocol} cluster tests"
114-
invoke cluster-tests $eventloop --protocol=${protocol}
115-
echo "::endgroup::"
116-
fi
128+
129+
echo "::group::RESP${protocol} cluster tests"
130+
invoke cluster-tests $eventloop --protocol=${protocol}
131+
echo "::endgroup::"
117132
}
118133

119134
run_tests 2 "${{inputs.event-loop}}"

.github/wordlist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ APM
22
ARGV
33
BFCommands
44
CacheImpl
5+
CAS
56
CFCommands
67
CMSCommands
78
ClusterNode
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: Hiredis-py integration tests
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
redis-py-branch:
7+
description: 'redis-py branch to run tests on'
8+
required: true
9+
default: 'master'
10+
hiredis-branch:
11+
description: 'hiredis-py branch to run tests on'
12+
required: true
13+
default: 'master'
14+
15+
concurrency:
16+
group: ${{ github.event.pull_request.number || github.ref }}-hiredis-integration
17+
cancel-in-progress: true
18+
19+
permissions:
20+
contents: read # to fetch code (actions/checkout)
21+
22+
env:
23+
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
24+
# this speeds up coverage with Python 3.12: https://github.com/nedbat/coveragepy/issues/1665
25+
COVERAGE_CORE: sysmon
26+
CURRENT_CLIENT_LIBS_TEST_STACK_IMAGE_TAG: 'rs-7.4.0-v2'
27+
CURRENT_REDIS_VERSION: '7.4.2'
28+
29+
jobs:
30+
redis_version:
31+
runs-on: ubuntu-latest
32+
outputs:
33+
CURRENT: ${{ env.CURRENT_REDIS_VERSION }}
34+
steps:
35+
- name: Compute outputs
36+
run: |
37+
echo "CURRENT=${{ env.CURRENT_REDIS_VERSION }}" >> $GITHUB_OUTPUT
38+
39+
hiredis-tests:
40+
runs-on: ubuntu-latest
41+
needs: [redis_version]
42+
timeout-minutes: 60
43+
strategy:
44+
max-parallel: 15
45+
fail-fast: false
46+
matrix:
47+
redis-version: [ '${{ needs.redis_version.outputs.CURRENT }}' ]
48+
python-version: [ '3.8', '3.13']
49+
parser-backend: [ 'hiredis' ]
50+
hiredis-version: [ 'unstable' ]
51+
event-loop: [ 'asyncio' ]
52+
env:
53+
ACTIONS_ALLOW_UNSECURE_COMMANDS: true
54+
name: Redis ${{ matrix.redis-version }}; Python ${{ matrix.python-version }}; RESP Parser:${{matrix.parser-backend}} (${{ matrix.hiredis-version }}); EL:${{matrix.event-loop}}
55+
steps:
56+
- uses: actions/checkout@v4
57+
with:
58+
ref: ${{ inputs.redis-py-branch }}
59+
- name: Run tests
60+
uses: ./.github/actions/run-tests
61+
with:
62+
python-version: ${{ matrix.python-version }}
63+
parser-backend: ${{ matrix.parser-backend }}
64+
redis-version: ${{ matrix.redis-version }}
65+
hiredis-version: ${{ matrix.hiredis-version }}
66+
hiredis-branch: ${{ inputs.hiredis-branch }}

.github/workflows/integration.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ jobs:
7474
max-parallel: 15
7575
fail-fast: false
7676
matrix:
77-
redis-version: ['8.0-RC2-pre', '${{ needs.redis_version.outputs.CURRENT }}', '7.2.7', '6.2.17']
77+
redis-version: ['8.0.1-pre', '${{ needs.redis_version.outputs.CURRENT }}', '7.2.7', '6.2.17']
7878
python-version: ['3.8', '3.13']
7979
parser-backend: ['plain']
8080
event-loop: ['asyncio']

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ vagrant/.vagrant
99
.cache
1010
.eggs
1111
.idea
12+
.vscode
1213
.coverage
1314
env
1415
venv

CHANGES

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
* Support transactions in ClusterPipeline
12
* Removing support for RedisGraph module. RedisGraph support is deprecated since Redis Stack 7.2 (https://redis.com/blog/redisgraph-eol/)
23
* Fix lock.extend() typedef to accept float TTL extension
34
* Update URL in the readme linking to Redis University
@@ -71,6 +72,7 @@
7172
* Close SSL sockets if the connection attempt fails, or if validations fail. (#3317)
7273
* Eliminate mutable default arguments in the `redis.commands.core.Script` class. (#3332)
7374
* Fix SSL verification with `ssl_cert_reqs="none"` and `ssl_check_hostname=True` by automatically setting `check_hostname=False` when `verify_mode=ssl.CERT_NONE` (#3635)
75+
* Allow newer versions of PyJWT as dependency. (#3630)
7476

7577
* 4.1.3 (Feb 8, 2022)
7678
* Fix flushdb and flushall (#1926)

docs/advanced_features.rst

Lines changed: 105 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ the server.
167167

168168
.. code:: python
169169
170+
>>> rc = RedisCluster()
170171
>>> with rc.pipeline() as pipe:
171172
... pipe.set('foo', 'value1')
172173
... pipe.set('bar', 'value2')
@@ -177,20 +178,110 @@ the server.
177178
... pipe.set('foo1', 'bar1').get('foo1').execute()
178179
[True, b'bar1']
179180
180-
Please note: - RedisCluster pipelines currently only support key-based
181-
commands. - The pipeline gets its ‘read_from_replicas’ value from the
182-
cluster’s parameter. Thus, if read from replications is enabled in the
183-
cluster instance, the pipeline will also direct read commands to
184-
replicas. - The ‘transaction’ option is NOT supported in cluster-mode.
185-
In non-cluster mode, the ‘transaction’ option is available when
186-
executing pipelines. This wraps the pipeline commands with MULTI/EXEC
187-
commands, and effectively turns the pipeline commands into a single
188-
transaction block. This means that all commands are executed
189-
sequentially without any interruptions from other clients. However, in
190-
cluster-mode this is not possible, because commands are partitioned
191-
according to their respective destination nodes. This means that we can
192-
not turn the pipeline commands into one transaction block, because in
193-
most cases they are split up into several smaller pipelines.
181+
Please note:
182+
183+
- RedisCluster pipelines currently only support key-based commands.
184+
- The pipeline gets its ‘load_balancing_strategy’ value from the
185+
cluster’s parameter. Thus, if read from replications is enabled in
186+
the cluster instance, the pipeline will also direct read commands to
187+
replicas.
188+
189+
190+
Transactions in clusters
191+
~~~~~~~~~~~~~~~~~~~~~~~~
192+
193+
Transactions are supported in cluster-mode with one caveat: all keys of
194+
all commands issued on a transaction pipeline must reside on the
195+
same slot. This is similar to the limitation of multikey commands in
196+
cluster. The reason behind this is that the Redis engine does not offer
197+
a mechanism to block or exchange key data across nodes on the fly. A
198+
client may add some logic to abstract engine limitations when running
199+
on a cluster, such as the pipeline behavior explained on the previous
200+
block, but there is no simple way that a client can enforce atomicity
201+
across nodes on a distributed system.
202+
203+
The compromise of limiting the transaction pipeline to same-slot keys
204+
is exactly that: a compromise. While this behavior is different from
205+
non-transactional cluster pipelines, it simplifies migration of clients
206+
from standalone to cluster under some circumstances. Note that application
207+
code that issues multi/exec commands on a standalone client without
208+
embedding them within a pipeline would eventually get ‘AttributeError’.
209+
With this approach, if the application uses ‘client.pipeline(transaction=True)’,
210+
then switching the client with a cluster-aware instance would simplify
211+
code changes (to some extent). This may be true for application code that
212+
makes use of hash keys, since its transactions may already be
213+
mapping all commands to the same slot.
214+
215+
An alternative is some kind of two-step commit solution, where a slot
216+
validation is run before the actual commands are run. This could work
217+
with controlled node maintenance but does not cover single node failures.
218+
219+
Given the cluster limitations for transactions, by default pipeline isn't in
220+
transactional mode. To enable transactional context set:
221+
222+
.. code:: python
223+
224+
>>> p = rc.pipeline(transaction=True)
225+
226+
After entering the transactional context you can add commands to a transactional
227+
context, by one of the following ways:
228+
229+
.. code:: python
230+
231+
>>> p = rc.pipeline(transaction=True) # Chaining commands
232+
>>> p.set("key", "value")
233+
>>> p.get("key")
234+
>>> response = p.execute()
235+
236+
Or
237+
238+
.. code:: python
239+
240+
>>> with rc.pipeline(transaction=True) as pipe: # Using context manager
241+
... pipe.set("key", "value")
242+
... pipe.get("key")
243+
... response = pipe.execute()
244+
245+
As you see there's no need to explicitly send `MULTI/EXEC` commands to control context start/end
246+
`ClusterPipeline` will take care of it.
247+
248+
To ensure that different keys will be mapped to a same hash slot on the server side
249+
prepend your keys with the same hash tag, the technique that allows you to control
250+
keys distribution.
251+
More information `here <https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/#hash-tags>`_
252+
253+
.. code:: python
254+
255+
>>> with rc.pipeline(transaction=True) as pipe:
256+
... pipe.set("{tag}foo", "bar")
257+
... pipe.set("{tag}bar", "foo")
258+
... pipe.get("{tag}foo")
259+
... pipe.get("{tag}bar")
260+
... response = pipe.execute()
261+
262+
CAS Transactions
263+
~~~~~~~~~~~~~~~~~~~~~~~~
264+
265+
If you want to apply optimistic locking for certain keys, you have to execute
266+
`WATCH` command in transactional context. `WATCH` command follows the same limitations
267+
as any other multi key command - all keys should be mapped to the same hash slot.
268+
269+
However, the difference between CAS transaction and normal one is that you have to
270+
explicitly call MULTI command to indicate the start of transactional context, WATCH
271+
command itself and any subsequent commands before MULTI will be immediately executed
272+
on the server side so you can apply optimistic locking and get necessary data before
273+
transaction execution.
274+
275+
.. code:: python
276+
277+
>>> with rc.pipeline(transaction=True) as pipe:
278+
... pipe.watch("mykey") # Apply locking by immediately executing command
279+
... val = pipe.get("mykey") # Immediately retrieves value
280+
... val = val + 1 # Increment value
281+
... pipe.multi() # Starting transaction context
282+
... pipe.set("mykey", val) # Command will be pipelined
283+
... response = pipe.execute() # Returns OK or None if key was modified in the meantime
284+
194285
195286
Publish / Subscribe
196287
-------------------

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ ocsp = [
4141
"requests>=2.31.0",
4242
]
4343
jwt = [
44-
"PyJWT~=2.9.0",
44+
"PyJWT>=2.9.0",
4545
]
4646

4747
[project.urls]

redis/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,14 @@
1616
BusyLoadingError,
1717
ChildDeadlockedError,
1818
ConnectionError,
19+
CrossSlotTransactionError,
1920
DataError,
21+
InvalidPipelineStack,
2022
InvalidResponse,
2123
OutOfMemoryError,
2224
PubSubError,
2325
ReadOnlyError,
26+
RedisClusterException,
2427
RedisError,
2528
ResponseError,
2629
TimeoutError,
@@ -56,15 +59,18 @@ def int_or_str(value):
5659
"ConnectionError",
5760
"ConnectionPool",
5861
"CredentialProvider",
62+
"CrossSlotTransactionError",
5963
"DataError",
6064
"from_url",
6165
"default_backoff",
66+
"InvalidPipelineStack",
6267
"InvalidResponse",
6368
"OutOfMemoryError",
6469
"PubSubError",
6570
"ReadOnlyError",
6671
"Redis",
6772
"RedisCluster",
73+
"RedisClusterException",
6874
"RedisError",
6975
"ResponseError",
7076
"Sentinel",

redis/asyncio/cluster.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1313,7 +1313,9 @@ async def initialize(self) -> None:
13131313
startup_nodes_reachable = False
13141314
fully_covered = False
13151315
exception = None
1316-
for startup_node in self.startup_nodes.values():
1316+
# Convert to tuple to prevent RuntimeError if self.startup_nodes
1317+
# is modified during iteration
1318+
for startup_node in tuple(self.startup_nodes.values()):
13171319
try:
13181320
# Make sure cluster mode is enabled on this node
13191321
try:

0 commit comments

Comments
 (0)