Skip to content

Commit 990c11c

Browse files
authored
feat: add TS.READ command support to the timeseries module (#4170)
* feat: add TS.READ command support to the timeseries module * Fix linters after conflict resolution
1 parent a1f0a03 commit 990c11c

4 files changed

Lines changed: 294 additions & 0 deletions

File tree

redis/commands/timeseries/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
NREVRANGE_CMD,
2525
QUERYINDEX_CMD,
2626
RANGE_CMD,
27+
READ_CMD,
2728
REVRANGE_CMD,
2829
TimeSeriesCommands,
2930
)
@@ -59,6 +60,7 @@ def __init__(self, client=None, **kwargs):
5960
CREATE_CMD: bool_ok,
6061
CREATERULE_CMD: bool_ok,
6162
DELETERULE_CMD: bool_ok,
63+
READ_CMD: parse_range_unified,
6264
NRANGE_CMD: parse_n_range,
6365
NREVRANGE_CMD: parse_n_range,
6466
}
@@ -83,6 +85,7 @@ def __init__(self, client=None, **kwargs):
8385
MRANGE_CMD: parse_m_range_unified,
8486
MREVRANGE_CMD: parse_m_range_unified,
8587
RANGE_CMD: parse_range_unified,
88+
READ_CMD: parse_range_unified,
8689
REVRANGE_CMD: parse_range_unified,
8790
}
8891
_RESP3_UNIFIED_MODULE_CALLBACKS = {

redis/commands/timeseries/commands.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
NREVRANGE_CMD = "TS.NREVRANGE"
3434
QUERYINDEX_CMD = "TS.QUERYINDEX"
3535
RANGE_CMD = "TS.RANGE"
36+
READ_CMD = "TS.READ"
3637
REVRANGE_CMD = "TS.REVRANGE"
3738

3839

@@ -996,6 +997,93 @@ def revrange(
996997
)
997998
return self.execute_command(REVRANGE_CMD, *params, keys=[key])
998999

1000+
@overload
1001+
def read(
1002+
self: SyncClientProtocol,
1003+
key: KeyT,
1004+
timestamp: int | str,
1005+
block_milliseconds: int | None = None,
1006+
block_min_count: int | None = None,
1007+
max_count: int | None = None,
1008+
) -> TimeSeriesRangeResponse: ...
1009+
1010+
@overload
1011+
def read(
1012+
self: AsyncClientProtocol,
1013+
key: KeyT,
1014+
timestamp: int | str,
1015+
block_milliseconds: int | None = None,
1016+
block_min_count: int | None = None,
1017+
max_count: int | None = None,
1018+
) -> Awaitable[TimeSeriesRangeResponse]: ...
1019+
1020+
def read(
1021+
self,
1022+
key: KeyT,
1023+
timestamp: int | str,
1024+
block_milliseconds: int | None = None,
1025+
block_min_count: int | None = None,
1026+
max_count: int | None = None,
1027+
) -> TimeSeriesRangeResponse | Awaitable[TimeSeriesRangeResponse]:
1028+
"""
1029+
Read a batch of samples with timestamps at or after `timestamp`, in
1030+
ascending timestamp order.
1031+
1032+
Without blocking, returns immediately with whatever qualifies (possibly an
1033+
empty list). With the `block_milliseconds` group, waits until at least
1034+
`block_min_count` qualifying samples exist or until the timeout elapses.
1035+
This allows consuming historical and newly-appended samples continuously,
1036+
in batches, without polling `TS.RANGE`.
1037+
1038+
For more information see https://redis.io/commands/ts.read/
1039+
1040+
Args:
1041+
key:
1042+
Key name for the time series (a regular series or a compaction
1043+
destination).
1044+
timestamp:
1045+
Inclusive cursor. Samples with `timestamp >= timestamp` qualify.
1046+
A non-negative integer (Unix milliseconds, `0` reads from the
1047+
beginning) or one of the sentinels `-` (earliest), `+` (latest
1048+
existing sample, inclusive) or `$` (only samples added after the
1049+
command is received). Sentinels are sent to the server as-is and
1050+
resolved server-side; `$` is only meaningful together with
1051+
`block_milliseconds`, since nothing can qualify at execution time
1052+
without blocking.
1053+
block_milliseconds:
1054+
Opt into blocking. Maximum time to wait, in whole milliseconds;
1055+
a non-negative integer where `0` means wait indefinitely. When
1056+
`None` (default) the command does not block.
1057+
block_min_count:
1058+
The unblock threshold: the call returns once this many samples
1059+
qualify. A positive integer, defaulting to `1` when blocking is
1060+
requested. Only used when `block_milliseconds` is set; the value is
1061+
always emitted on the wire inside the BLOCK group.
1062+
max_count:
1063+
Reply cap, a positive integer. When more samples qualify than
1064+
`max_count`, the oldest `max_count` are returned so callers can page
1065+
forward. `None` (default) means unlimited.
1066+
1067+
Returns:
1068+
A list of `[timestamp, value]` samples in ascending timestamp order.
1069+
An empty list is a successful reply (returned when nothing qualifies,
1070+
or when a blocking call times out with nothing available).
1071+
1072+
.. warning::
1073+
A blocking call keeps the connection parked for up to
1074+
`block_milliseconds`. The client's `socket_timeout` still applies: with
1075+
the default (5 seconds) a longer block raises `TimeoutError` before the
1076+
server replies. When using `block_milliseconds`, configure the client
1077+
with a `socket_timeout` larger than the block window (or `None`), as with
1078+
other blocking commands. This command must not be retried automatically
1079+
after an empty or partial reply.
1080+
"""
1081+
params: list[EncodableT] = [key, timestamp]
1082+
self._append_block(params, block_milliseconds, block_min_count)
1083+
self._append_max_count(params, max_count)
1084+
1085+
return self.execute_command(READ_CMD, *params, keys=[key])
1086+
9991087
def __n_range_params(
10001088
self,
10011089
keys: List[KeyT],
@@ -1785,6 +1873,35 @@ def _append_count(params: list[EncodableT], count: int | None):
17851873
if count is not None:
17861874
params.extend(["COUNT", count])
17871875

1876+
@staticmethod
1877+
def _append_block(
1878+
params: list[EncodableT],
1879+
block_milliseconds: int | None,
1880+
block_min_count: int | None,
1881+
):
1882+
"""Append the BLOCK group to params.
1883+
1884+
The BLOCK group is all-or-nothing: when blocking is requested
1885+
(`block_milliseconds` is set), both `milliseconds` and `min_count` are
1886+
always emitted, with `min_count` defaulting to 1. There is no standalone
1887+
MIN_COUNT keyword in this command.
1888+
"""
1889+
if block_milliseconds is None:
1890+
if block_min_count is not None:
1891+
raise DataError(
1892+
"block_min_count requires block_milliseconds to be set; the "
1893+
"BLOCK group is all-or-nothing."
1894+
)
1895+
return
1896+
min_count = 1 if block_min_count is None else block_min_count
1897+
params.extend(["BLOCK", block_milliseconds, min_count])
1898+
1899+
@staticmethod
1900+
def _append_max_count(params: list[EncodableT], max_count: int | None):
1901+
"""Append MAX_COUNT property to params."""
1902+
if max_count is not None:
1903+
params.extend(["MAX_COUNT", max_count])
1904+
17881905
@staticmethod
17891906
def _append_timestamp(params: list[EncodableT], timestamp: int | None):
17901907
"""Append TIMESTAMP property to params."""

tests/test_asyncio/test_timeseries.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,94 @@ async def test_rev_range(decoded_r: redis.Redis):
365365
)
366366

367367

368+
@pytest.mark.redismod
369+
@skip_if_server_version_lt("8.9.0")
370+
async def test_read(decoded_r: redis.Redis):
371+
await decoded_r.ts().create(1)
372+
await decoded_r.ts().add(1, 100, 1.0)
373+
await decoded_r.ts().add(1, 200, 2.0)
374+
await decoded_r.ts().add(1, 300, 3.0)
375+
376+
# Read everything at or after the cursor. TS.READ always returns the same
377+
# unified sample shape (list of [timestamp, value]) regardless of protocol.
378+
assert await decoded_r.ts().read(1, 0) == [[100, 1.0], [200, 2.0], [300, 3.0]]
379+
380+
# The cursor is inclusive.
381+
assert await decoded_r.ts().read(1, 200) == [[200, 2.0], [300, 3.0]]
382+
383+
384+
@pytest.mark.redismod
385+
@skip_if_server_version_lt("8.9.0")
386+
async def test_read_max_count(decoded_r: redis.Redis):
387+
await decoded_r.ts().create(1)
388+
await decoded_r.ts().add(1, 100, 1.0)
389+
await decoded_r.ts().add(1, 200, 2.0)
390+
await decoded_r.ts().add(1, 300, 3.0)
391+
392+
# Bounded paging: read the oldest max_count, then page from last_ts + 1.
393+
assert await decoded_r.ts().read(1, "-", max_count=2) == [[100, 1.0], [200, 2.0]]
394+
assert await decoded_r.ts().read(1, 201, max_count=2) == [[300, 3.0]]
395+
396+
397+
@pytest.mark.redismod
398+
@skip_if_server_version_lt("8.9.0")
399+
async def test_read_sentinels(decoded_r: redis.Redis):
400+
await decoded_r.ts().create(1)
401+
await decoded_r.ts().add(1, 100, 1.0)
402+
await decoded_r.ts().add(1, 200, 2.0)
403+
await decoded_r.ts().add(1, 300, 3.0)
404+
405+
# `+` resolves to the latest sample, inclusive; returned even without BLOCK.
406+
assert await decoded_r.ts().read(1, "+") == [[300, 3.0]]
407+
408+
# `-` reads from the earliest sample.
409+
assert len(await decoded_r.ts().read(1, "-")) == 3
410+
411+
412+
@pytest.mark.redismod
413+
@skip_if_server_version_lt("8.9.0")
414+
async def test_read_empty(decoded_r: redis.Redis):
415+
await decoded_r.ts().create(1)
416+
await decoded_r.ts().add(1, 100, 1.0)
417+
418+
# A cursor past the newest sample yields an empty (successful) reply.
419+
assert [] == await decoded_r.ts().read(1, 301)
420+
# A missing key is also an empty reply, not an error.
421+
assert [] == await decoded_r.ts().read("missing", 0)
422+
423+
424+
@pytest.mark.redismod
425+
@skip_if_server_version_lt("8.9.0")
426+
async def test_read_block(decoded_r: redis.Redis):
427+
await decoded_r.ts().create(1)
428+
await decoded_r.ts().add(1, 100, 1.0)
429+
await decoded_r.ts().add(1, 200, 2.0)
430+
await decoded_r.ts().add(1, 300, 3.0)
431+
432+
# min_count is already met, so the blocking call returns immediately.
433+
res = await decoded_r.ts().read(1, 0, block_milliseconds=1000, block_min_count=1)
434+
assert len(res) == 3
435+
436+
# min_count cannot be reached; after the timeout the available samples flush.
437+
res = await decoded_r.ts().read(1, 101, block_milliseconds=100, block_min_count=10)
438+
assert res == [[200, 2.0], [300, 3.0]]
439+
440+
# A blocking timeout with nothing available is a successful empty reply.
441+
assert [] == await decoded_r.ts().read(
442+
1, 301, block_milliseconds=100, block_min_count=1
443+
)
444+
445+
446+
@pytest.mark.redismod
447+
@skip_if_server_version_lt("8.9.0")
448+
async def test_read_block_min_count_requires_milliseconds(decoded_r: redis.Redis):
449+
# BLOCK is all-or-nothing: min_count without milliseconds is invalid usage.
450+
with pytest.raises(
451+
redis.DataError, match="block_min_count requires block_milliseconds"
452+
):
453+
await decoded_r.ts().read(1, 0, block_min_count=5)
454+
455+
368456
@pytest.mark.onlynoncluster
369457
@pytest.mark.redismod
370458
async def test_multi_range(decoded_r: redis.Redis):

tests/test_timeseries.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,92 @@ def test_revrange_empty(client: redis.Redis):
551551
assert_resp_response(client, res, resp2_expected, resp3_expected)
552552

553553

554+
@pytest.mark.redismod
555+
@skip_if_server_version_lt("8.9.0")
556+
def test_read(client):
557+
client.ts().create(1)
558+
client.ts().add(1, 100, 1.0)
559+
client.ts().add(1, 200, 2.0)
560+
client.ts().add(1, 300, 3.0)
561+
562+
# Read everything at or after the cursor. TS.READ always returns the same
563+
# unified sample shape (list of [timestamp, value]) regardless of protocol.
564+
assert client.ts().read(1, 0) == [[100, 1.0], [200, 2.0], [300, 3.0]]
565+
566+
# The cursor is inclusive.
567+
assert client.ts().read(1, 200) == [[200, 2.0], [300, 3.0]]
568+
569+
570+
@pytest.mark.redismod
571+
@skip_if_server_version_lt("8.9.0")
572+
def test_read_max_count(client):
573+
client.ts().create(1)
574+
client.ts().add(1, 100, 1.0)
575+
client.ts().add(1, 200, 2.0)
576+
client.ts().add(1, 300, 3.0)
577+
578+
# Bounded paging: read the oldest max_count, then page from last_ts + 1.
579+
assert client.ts().read(1, "-", max_count=2) == [[100, 1.0], [200, 2.0]]
580+
assert client.ts().read(1, 201, max_count=2) == [[300, 3.0]]
581+
582+
583+
@pytest.mark.redismod
584+
@skip_if_server_version_lt("8.9.0")
585+
def test_read_sentinels(client):
586+
client.ts().create(1)
587+
client.ts().add(1, 100, 1.0)
588+
client.ts().add(1, 200, 2.0)
589+
client.ts().add(1, 300, 3.0)
590+
591+
# `+` resolves to the latest sample, inclusive; returned even without BLOCK.
592+
assert client.ts().read(1, "+") == [[300, 3.0]]
593+
594+
# `-` reads from the earliest sample.
595+
assert len(client.ts().read(1, "-")) == 3
596+
597+
598+
@pytest.mark.redismod
599+
@skip_if_server_version_lt("8.9.0")
600+
def test_read_empty(client):
601+
client.ts().create(1)
602+
client.ts().add(1, 100, 1.0)
603+
604+
# A cursor past the newest sample yields an empty (successful) reply.
605+
assert [] == client.ts().read(1, 301)
606+
# A missing key is also an empty reply, not an error.
607+
assert [] == client.ts().read("missing", 0)
608+
609+
610+
@pytest.mark.redismod
611+
@skip_if_server_version_lt("8.9.0")
612+
def test_read_block(client):
613+
client.ts().create(1)
614+
client.ts().add(1, 100, 1.0)
615+
client.ts().add(1, 200, 2.0)
616+
client.ts().add(1, 300, 3.0)
617+
618+
# min_count is already met, so the blocking call returns immediately.
619+
res = client.ts().read(1, 0, block_milliseconds=1000, block_min_count=1)
620+
assert 3 == len(res)
621+
622+
# min_count cannot be reached; after the timeout the available samples flush.
623+
res = client.ts().read(1, 101, block_milliseconds=100, block_min_count=10)
624+
assert res == [[200, 2.0], [300, 3.0]]
625+
626+
# A blocking timeout with nothing available is a successful empty reply.
627+
assert [] == client.ts().read(1, 301, block_milliseconds=100, block_min_count=1)
628+
629+
630+
@pytest.mark.redismod
631+
@skip_if_server_version_lt("8.9.0")
632+
def test_read_block_min_count_requires_milliseconds(client):
633+
# BLOCK is all-or-nothing: min_count without milliseconds is invalid usage.
634+
with pytest.raises(
635+
redis.exceptions.DataError, match="block_min_count requires block_milliseconds"
636+
):
637+
client.ts().read(1, 0, block_min_count=5)
638+
639+
554640
@pytest.mark.onlynoncluster
555641
@pytest.mark.redismod
556642
def test_mrange(client):

0 commit comments

Comments
 (0)