This commit is contained in:
macbook_max
2026-10-15 20:36:59 +08:00
commit 6bf949949c
7923 changed files with 1369582 additions and 0 deletions
@@ -0,0 +1,182 @@
from typing import Literal
import redis
from redis._parsers.helpers import bool_ok
from ..helpers import (
apply_module_callbacks,
get_legacy_responses,
get_protocol_version,
parse_to_list,
)
from .commands import (
ALTER_CMD,
CREATE_CMD,
CREATERULE_CMD,
DEL_CMD,
DELETERULE_CMD,
GET_CMD,
INFO_CMD,
MGET_CMD,
MRANGE_CMD,
MREVRANGE_CMD,
NRANGE_CMD,
NREVRANGE_CMD,
QUERYINDEX_CMD,
QUERYLABELS_CMD,
RANGE_CMD,
READ_CMD,
REVRANGE_CMD,
TimeSeriesCommands,
)
from .info import TSInfo
from .utils import (
parse_get,
parse_get_unified,
parse_m_get,
parse_m_get_resp3_to_resp2_legacy,
parse_m_get_unified,
parse_m_range,
parse_m_range_resp3_to_resp2_legacy,
parse_m_range_unified,
parse_n_range,
parse_query_labels,
parse_range,
parse_range_unified,
)
class _TimeSeriesBase(TimeSeriesCommands):
"""
This class subclasses redis-py's `Redis` and implements RedisTimeSeries's
commands (prefixed with "ts").
The client allows to interact with RedisTimeSeries and use all of it's
functionality.
"""
def __init__(self, client=None, **kwargs):
"""Create a new RedisTimeSeries client."""
# Set the module commands' callbacks
_MODULE_CALLBACKS = {
ALTER_CMD: bool_ok,
CREATE_CMD: bool_ok,
CREATERULE_CMD: bool_ok,
DELETERULE_CMD: bool_ok,
READ_CMD: parse_range_unified,
NRANGE_CMD: parse_n_range,
NREVRANGE_CMD: parse_n_range,
QUERYLABELS_CMD: parse_query_labels,
}
_RESP2_MODULE_CALLBACKS = {
DEL_CMD: int,
GET_CMD: parse_get,
INFO_CMD: TSInfo,
MGET_CMD: parse_m_get,
MRANGE_CMD: parse_m_range,
MREVRANGE_CMD: parse_m_range,
QUERYINDEX_CMD: parse_to_list,
RANGE_CMD: parse_range,
REVRANGE_CMD: parse_range,
}
_RESP3_MODULE_CALLBACKS = {}
_RESP2_UNIFIED_MODULE_CALLBACKS = {
DEL_CMD: int,
GET_CMD: parse_get_unified,
INFO_CMD: TSInfo,
MGET_CMD: parse_m_get_unified,
MRANGE_CMD: parse_m_range_unified,
MREVRANGE_CMD: parse_m_range_unified,
RANGE_CMD: parse_range_unified,
READ_CMD: parse_range_unified,
REVRANGE_CMD: parse_range_unified,
}
_RESP3_UNIFIED_MODULE_CALLBACKS = {
DEL_CMD: int,
GET_CMD: parse_get_unified,
INFO_CMD: TSInfo,
MGET_CMD: parse_m_get_unified,
MRANGE_CMD: parse_m_range_unified,
MREVRANGE_CMD: parse_m_range_unified,
RANGE_CMD: parse_range_unified,
REVRANGE_CMD: parse_range_unified,
}
_RESP3_TO_RESP2_LEGACY_MODULE_CALLBACKS = {
DEL_CMD: int,
GET_CMD: parse_get,
INFO_CMD: TSInfo,
MGET_CMD: parse_m_get_resp3_to_resp2_legacy,
MRANGE_CMD: parse_m_range_resp3_to_resp2_legacy,
MREVRANGE_CMD: parse_m_range_resp3_to_resp2_legacy,
QUERYINDEX_CMD: parse_to_list,
RANGE_CMD: parse_range,
REVRANGE_CMD: parse_range,
}
self.client = client
self.execute_command = client.execute_command
self._MODULE_CALLBACKS = apply_module_callbacks(
get_protocol_version(self.client),
get_legacy_responses(self.client),
common=_MODULE_CALLBACKS,
resp2=_RESP2_MODULE_CALLBACKS,
resp3=_RESP3_MODULE_CALLBACKS,
resp2_unified=_RESP2_UNIFIED_MODULE_CALLBACKS,
resp3_unified=_RESP3_UNIFIED_MODULE_CALLBACKS,
resp3_to_resp2_legacy=_RESP3_TO_RESP2_LEGACY_MODULE_CALLBACKS,
)
for k, v in self._MODULE_CALLBACKS.items():
self.client.set_response_callback(k, v)
def pipeline(self, transaction=True, shard_hint=None):
"""Creates a pipeline for the TimeSeries module, that can be used
for executing only TimeSeries commands and core commands.
Usage example:
r = redis.Redis()
pipe = r.ts().pipeline()
for i in range(100):
pipeline.add("with_pipeline", i, 1.1 * i)
pipeline.execute()
"""
if isinstance(self.client, redis.RedisCluster):
p = ClusterPipeline(
nodes_manager=self.client.nodes_manager,
commands_parser=self.client.commands_parser,
startup_nodes=self.client.nodes_manager.startup_nodes,
result_callbacks=self.client.result_callbacks,
cluster_response_callbacks=self.client.cluster_response_callbacks,
cluster_error_retry_attempts=self.client.retry.get_retries(),
read_from_replicas=self.client.read_from_replicas,
reinitialize_steps=self.client.reinitialize_steps,
lock=self.client._lock,
)
else:
p = Pipeline(
connection_pool=self.client.connection_pool,
response_callbacks=self._MODULE_CALLBACKS,
transaction=transaction,
shard_hint=shard_hint,
)
return p
class ClusterPipeline(TimeSeriesCommands, redis.cluster.ClusterPipeline):
"""Cluster pipeline for the module."""
class Pipeline(TimeSeriesCommands, redis.client.Pipeline):
"""Pipeline for the module."""
class TimeSeries(_TimeSeriesBase):
_is_async_client: Literal[False] = False
class AsyncTimeSeries(_TimeSeriesBase):
_is_async_client: Literal[True] = True
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,116 @@
from ..helpers import nativestr
from .utils import list_to_dict
# Mapping from RESP3 camelCase field names to the legacy snake_case
# attribute names. Used so callers can fetch the same value with either
# spelling regardless of which wire format produced the ``TSInfo``.
_FIELD_ALIASES = {
"sourceKey": "source_key",
"chunkCount": "chunk_count",
"memoryUsage": "memory_usage",
"totalSamples": "total_samples",
"retentionTime": "retention_msecs",
"lastTimestamp": "last_timestamp",
"firstTimestamp": "first_timestamp",
"maxSamplesPerChunk": "max_samples_per_chunk",
"chunkSize": "chunk_size",
"duplicatePolicy": "duplicate_policy",
}
class TSInfo:
"""
Hold information and statistics on the time-series.
Can be created using ``tsinfo`` command
https://redis.io/docs/latest/commands/ts.info/
Handles both RESP2 (flat list) and RESP3 (dict) responses.
"""
rules = []
labels = []
sourceKey = None
chunk_count = None
memory_usage = None
total_samples = None
retention_msecs = None
last_time_stamp = None
first_time_stamp = None
max_samples_per_chunk = None
chunk_size = None
duplicate_policy = None
def __init__(self, args):
"""
Hold information and statistics on the time-series.
The supported params that can be passed as args:
rules:
A list of compaction rules of the time series.
sourceKey:
Key name for source time series in case the current series
is a target of a rule.
chunkCount:
Number of Memory Chunks used for the time series.
memoryUsage:
Total number of bytes allocated for the time series.
totalSamples:
Total number of samples in the time series.
labels:
A list of label-value pairs that represent the metadata
labels of the time series.
retentionTime:
Retention time, in milliseconds, for the time series.
lastTimestamp:
Last timestamp present in the time series.
firstTimestamp:
First timestamp present in the time series.
maxSamplesPerChunk:
Deprecated.
chunkSize:
Amount of memory, in bytes, allocated for data.
duplicatePolicy:
Policy that will define handling of duplicate samples.
Can read more about on
https://redis.io/docs/latest/develop/data-types/timeseries/configuration/#duplicate_policy
"""
if isinstance(args, dict):
# RESP3 wire: response is a native map.
response = args
self.rules = response.get("rules") or {}
self.labels = response.get("labels") or {}
else:
# RESP2 wire: flat list of alternating key-value pairs.
response = dict(zip(map(nativestr, args[::2]), args[1::2]))
self.rules = response.get("rules")
self.labels = list_to_dict(response.get("labels"))
self.source_key = response.get("sourceKey")
self.chunk_count = response.get("chunkCount")
self.memory_usage = response.get("memoryUsage")
self.total_samples = response.get("totalSamples")
self.retention_msecs = response.get("retentionTime")
self.last_timestamp = response.get("lastTimestamp")
self.first_timestamp = response.get("firstTimestamp")
if "maxSamplesPerChunk" in response:
self.max_samples_per_chunk = response["maxSamplesPerChunk"]
self.chunk_size = (
self.max_samples_per_chunk * 16
) # backward compatible changes
if "chunkSize" in response:
self.chunk_size = response["chunkSize"]
if "duplicatePolicy" in response:
self.duplicate_policy = response["duplicatePolicy"]
if isinstance(self.duplicate_policy, bytes):
self.duplicate_policy = self.duplicate_policy.decode()
def get(self, item):
try:
return self.__getitem__(item)
except AttributeError:
return None
def __getitem__(self, item):
return getattr(self, _FIELD_ALIASES.get(item, item))
@@ -0,0 +1,207 @@
from ..helpers import nativestr
def list_to_dict(aList):
return {nativestr(aList[i][0]): nativestr(aList[i][1]) for i in range(len(aList))}
def _pairs_to_dict(pairs):
"""Convert a list of [key, value] pairs to a dict without forcing str."""
return {pairs[i][0]: pairs[i][1] for i in range(len(pairs))}
def _nativestr_dict(d):
"""Apply ``nativestr`` to every key and string-typed value of ``d``.
Used by the RESP3-to-RESP2-legacy adapters so labels coming from a
RESP3 native map match today's RESP2 ``list_to_dict`` semantics.
"""
return {
nativestr(k): nativestr(v) if isinstance(v, (bytes, str)) else v
for k, v in d.items()
}
def parse_query_labels(response, **kwargs):
"""Normalize the TS.QUERYLABELS reply to a ``set`` of unique strings.
The reply is a flat array under RESP2 and a set under RESP3; both are
surfaced as a Python ``set`` so the collection type is identical across
protocols and reflects that the server returns a unique, unordered
collection. Elements are kept byte-exact (never coerced or normalized) and
the server already deduplicates them. A missing reply maps to an empty
``set`` so an empty result is a normal success rather than an error.
"""
if response is None:
return set()
return set(response)
def parse_range(response, **kwargs):
"""Parse range response. Used by TS.RANGE and TS.REVRANGE (legacy shape)."""
if not response:
return []
# Multi-aggregator: samples have >2 elements [timestamp, val1, val2, ...]
if len(response[0]) > 2:
return [tuple([r[0]] + [float(v) for v in r[1:]]) for r in response]
return [tuple((r[0], float(r[1]))) for r in response]
def parse_range_unified(response, **kwargs):
"""Unified parser for TS.RANGE / TS.REVRANGE.
Returns ``list[list]`` rather than ``list[tuple]`` so the unified
shape is symmetric with the RESP3 wire format.
"""
if not response:
return []
if len(response[0]) > 2:
return [[r[0]] + [float(v) for v in r[1:]] for r in response]
return [[r[0], float(r[1])] for r in response]
def parse_n_range(response, **kwargs):
"""Parse the TS.NRANGE / TS.NREVRANGE response.
The wire shape is ``[[timestamp, [value_0, value_1, ...]], ...]`` where the
value array preserves input key order. Its length equals the number of
queried keys, except when a per-key AGGREGATION spec lists multiple
aggregators: each such key then contributes one value per aggregator (in
spec order), concatenated in key order. Rows are returned in server order
(never re-sorted or reversed), and a missing raw sample or missing
aggregation bucket is kept as ``float('nan')`` rather than converted to
``None``.
"""
if not response:
return []
return [[int(row[0]), [float(v) for v in row[1]]] for row in response]
def parse_get(response):
"""Parse get response. Used by TS.GET (legacy shape)."""
if not response:
return None
return int(response[0]), float(response[1])
def parse_get_unified(response, **kwargs):
"""Unified parser for TS.GET. Returns ``[int, float]``."""
if not response:
return None
return [int(response[0]), float(response[1])]
def parse_m_get(response):
"""Parse multi get response (RESP2 wire)."""
res = []
for item in response:
if not item[2]:
res.append({nativestr(item[0]): [list_to_dict(item[1]), None, None]})
else:
res.append(
{
nativestr(item[0]): [
list_to_dict(item[1]),
int(item[2][0]),
float(item[2][1]),
]
}
)
return sorted(res, key=lambda d: list(d.keys()))
def parse_m_get_unified(response, **kwargs):
"""Unified parser for TS.MGET.
Emits ``{key: [labels_dict, sample]}`` where ``sample`` is
``[int, float]`` or ``[]`` when no sample exists. Handles both wire
formats: the RESP2 wire arrives as a list of ``[key, label_pairs,
sample]`` triples; the RESP3 wire is already keyed by name.
"""
if isinstance(response, dict):
res = {}
for key, item in response.items():
sample = item[1] if len(item) > 1 else []
if not sample:
res[key] = [item[0], []]
else:
res[key] = [item[0], [int(sample[0]), float(sample[1])]]
return res
res = {}
for item in response:
if not item[2]:
res[item[0]] = [_pairs_to_dict(item[1]), []]
else:
res[item[0]] = [
_pairs_to_dict(item[1]),
[int(item[2][0]), float(item[2][1])],
]
return res
def parse_m_get_resp3_to_resp2_legacy(response, **kwargs):
"""RESP3 wire → today's RESP2 legacy shape for TS.MGET."""
res = []
for key, item in response.items():
labels = _nativestr_dict(item[0]) if item[0] else {}
sample = item[1] if len(item) > 1 else []
if not sample:
res.append({nativestr(key): [labels, None, None]})
else:
res.append({nativestr(key): [labels, int(sample[0]), float(sample[1])]})
return sorted(res, key=lambda d: list(d.keys()))
def parse_m_range(response, **kwargs):
"""Parse multi range response (RESP2 wire)."""
res = []
for item in response:
res.append({nativestr(item[0]): [list_to_dict(item[1]), parse_range(item[2])]})
return sorted(res, key=lambda d: list(d.keys()))
def _m_range_metadata(aggregation_type=None):
if aggregation_type is None:
# Aggregators are empty when TS.MRANGE/TS.MREVRANGE is called without
# AGGREGATION; this mirrors RESP3 metadata such as {"aggregators": []}.
return {"aggregators": []}
if isinstance(aggregation_type, list):
aggregators = aggregation_type
else:
aggregators = [aggregation_type]
return {"aggregators": [nativestr(agg).lower() for agg in aggregators]}
def parse_m_range_unified(response, **kwargs):
"""Unified parser for TS.MRANGE / TS.MREVRANGE.
Emits ``{key: [labels_dict, metadata, samples]}`` regardless of wire
format. RESP2 has no metadata element on the wire, so the command options
are used to synthesize the same ``{"aggregators": ...}`` structure that
RESP3 returns.
"""
if isinstance(response, dict):
res = {}
for key, item in response.items():
metadata = item[1] if len(item) > 2 else []
res[key] = [item[0], metadata, parse_range_unified(item[-1])]
return res
res = {}
metadata = _m_range_metadata(kwargs.get("aggregation_type"))
for item in response:
res[item[0]] = [
_pairs_to_dict(item[1]),
metadata,
parse_range_unified(item[2]),
]
return res
def parse_m_range_resp3_to_resp2_legacy(response, **kwargs):
"""RESP3 wire → today's RESP2 legacy shape for TS.MRANGE / TS.MREVRANGE."""
res = []
for key, item in response.items():
labels = _nativestr_dict(item[0]) if item[0] else {}
samples = item[-1]
res.append({nativestr(key): [labels, parse_range(samples)]})
return sorted(res, key=lambda d: list(d.keys()))