redis-sizer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
redis_sizer/cli.py ADDED
@@ -0,0 +1,345 @@
1
+ import time
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from redis import Redis
8
+ from redis.exceptions import RedisError
9
+ from rich import box
10
+ from rich.console import Console
11
+ from rich.progress import track
12
+ from rich.table import Table
13
+
14
+ app = typer.Typer()
15
+
16
+
17
+ class MemoryUnit(str, Enum):
18
+ """
19
+ For displaying memory usage in different units.
20
+ """
21
+
22
+ B = "B"
23
+ KB = "KB"
24
+ MB = "MB"
25
+ GB = "GB"
26
+
27
+
28
+ @dataclass
29
+ class TableRow:
30
+ """
31
+ Represent a row in the result table.
32
+ """
33
+
34
+ key: str
35
+ count: str
36
+ size: str
37
+ avg_size: str
38
+ min_size: str
39
+ max_size: str
40
+ percentage: str
41
+
42
+
43
+ @app.command()
44
+ def analyze(
45
+ host: str,
46
+ port: Annotated[int, typer.Option(help="Port number")] = 6379,
47
+ db: Annotated[int, typer.Option(help="DB number")] = 0,
48
+ password: Annotated[str | None, typer.Option(help="Password")] = None,
49
+ socket_timeout: Annotated[int, typer.Option(help="Socket timeout in seconds")] = 10,
50
+ socket_connect_timeout: Annotated[
51
+ int, typer.Option(help="Socket connect timeout in seconds")
52
+ ] = 10,
53
+ pattern: Annotated[str, typer.Option(help="Pattern to filter keys")] = "*",
54
+ sample_size: Annotated[int | None, typer.Option(help="Number of keys to sample")] = None,
55
+ namespace_separator: Annotated[str, typer.Option(help="Separator for key namespaces")] = ":",
56
+ namespace_level: Annotated[
57
+ int,
58
+ typer.Option(
59
+ min=0, help="Number of namespace levels to aggregate keys by. 0 means no aggregation."
60
+ ),
61
+ ] = 0,
62
+ memory_unit: Annotated[
63
+ MemoryUnit, typer.Option(help="Memory unit for display in result table")
64
+ ] = MemoryUnit.B,
65
+ top: Annotated[
66
+ int | None, typer.Option(help="Maximum number of rows to display in result table")
67
+ ] = 10,
68
+ scan_count: Annotated[int, typer.Option(help="COUNT option for scanning keys")] = 1000,
69
+ batch_size: Annotated[int, typer.Option(help="Batch size for calculating memory usage")] = 1000,
70
+ ):
71
+ """
72
+ Analyze memory usage across keys in a Redis database and display the results in a table.
73
+ """
74
+ # Start the timer to measure execution time
75
+ start_time = time.time()
76
+
77
+ # Create Console instance for rich output
78
+ console = Console()
79
+
80
+ # Create Redis client
81
+ redis = Redis(
82
+ host=host,
83
+ port=port,
84
+ db=db,
85
+ password=password,
86
+ socket_timeout=socket_timeout,
87
+ socket_connect_timeout=socket_connect_timeout,
88
+ )
89
+
90
+ # Get the total number of keys in the database
91
+ try:
92
+ total_size: int = redis.dbsize() # type: ignore
93
+ except RedisError as error:
94
+ console.print(f"[red]Error occured: {error}[/red]")
95
+ redis.close()
96
+ exit(1)
97
+
98
+ # If the total size is 0, stop the process
99
+ if total_size == 0:
100
+ console.print("[yellow]No keys found in the database.[/yellow]")
101
+ redis.close()
102
+ return
103
+
104
+ console.print(f"The total number of keys: {total_size}")
105
+
106
+ # Scan the keys in the database
107
+ keys = _scan_keys(
108
+ redis=redis, pattern=pattern, count=scan_count, sample_size=sample_size, console=console
109
+ )
110
+ if not keys:
111
+ console.print(f"[yellow]No keys found matching the pattern: {pattern}[/yellow]")
112
+ redis.close()
113
+ return
114
+
115
+ # Get memory usage for each key (in batches)
116
+ memory_usage_by_key: dict[str, int | None] = {} # key -> memory usage
117
+ for i in track(
118
+ range(0, len(keys), batch_size),
119
+ description="Calculating memory usage...",
120
+ console=console,
121
+ ):
122
+ batch = keys[i : i + batch_size]
123
+ try:
124
+ memory_usage_by_key.update(_get_memory_usage(redis=redis, keys=batch))
125
+ except RedisError as error:
126
+ console.print(f"[red]Error occured: {error}[/red]")
127
+ redis.close()
128
+ exit(2)
129
+
130
+ redis.close()
131
+
132
+ # Aggregate memory usage by key groups
133
+ group_data: dict[str, dict] = {} # group -> {keys: [], size: 0, sizes: []}
134
+ overall_min: int | None = None
135
+ overall_max: int = 0
136
+ valid_keys_count: int = 0
137
+ for key in keys:
138
+ memory_usage: int | None = memory_usage_by_key[key]
139
+ # Memory usage is None for expired keys
140
+ if memory_usage is None:
141
+ continue
142
+ group: str = _parse_key_group(key, namespace_separator, namespace_level)
143
+ if group not in group_data:
144
+ group_data[group] = {"keys": [], "size": 0, "sizes": []}
145
+ group_data[group]["keys"].append(key)
146
+ group_data[group]["size"] += memory_usage
147
+ group_data[group]["sizes"].append(memory_usage)
148
+ overall_min = memory_usage if overall_min is None else min(overall_min, memory_usage)
149
+ overall_max = max(overall_max, memory_usage)
150
+ valid_keys_count += 1
151
+
152
+ if valid_keys_count == 0:
153
+ console.print("[yellow]No valid keys found. The scanned keys might have expired. [/yellow]")
154
+ return
155
+
156
+ # Sort the groups by size in descending order
157
+ sorted_groups = sorted(
158
+ (
159
+ (group, data["size"], data["sizes"], len(data["keys"]))
160
+ for group, data in group_data.items()
161
+ ),
162
+ key=lambda x: x[1],
163
+ reverse=True,
164
+ )
165
+
166
+ # Get hidden_count to limit the number of displayed groups if top is specified
167
+ total_key_groups: int = len(sorted_groups)
168
+ if top is not None and top > 0 and top < total_key_groups:
169
+ group_data_for_rows = sorted_groups[:top]
170
+ hidden_count = total_key_groups - top
171
+ else:
172
+ group_data_for_rows = sorted_groups
173
+ hidden_count = 0
174
+
175
+ # Prepare the rows
176
+ rows: list[TableRow] = []
177
+ factor: int = _get_memory_unit_factor(memory_unit)
178
+ total_size: int = sum(group["size"] for group in group_data.values())
179
+ for group, size, sizes, count in group_data_for_rows:
180
+ display_key: str = group if namespace_level == 0 else f"{group}[dim]*[/dim]"
181
+ size_converted: float = size / factor
182
+ display_size: str = (
183
+ f"{size_converted:.2f}" if memory_unit != MemoryUnit.B else f"{int(size_converted)}"
184
+ )
185
+ avg_usage = (sum(sizes) / len(sizes)) / factor if sizes else 0
186
+ min_usage = min(sizes) / factor if sizes else 0
187
+ max_usage = max(sizes) / factor if sizes else 0
188
+ percentage = (size / total_size * 100) if total_size else 0
189
+ rows.append(
190
+ TableRow(
191
+ key=display_key,
192
+ count=str(count),
193
+ size=display_size,
194
+ avg_size=f"{avg_usage:.4f}" if memory_unit != MemoryUnit.B else f"{int(avg_usage)}",
195
+ min_size=f"{min_usage:.4f}" if memory_unit != MemoryUnit.B else f"{int(min_usage)}",
196
+ max_size=f"{max_usage:.4f}" if memory_unit != MemoryUnit.B else f"{int(max_usage)}",
197
+ percentage=f"{percentage:.2f}",
198
+ )
199
+ )
200
+
201
+ # Prepare the total row
202
+ total_usage_display: str = (
203
+ f"{total_size / factor:.2f}"
204
+ if memory_unit != MemoryUnit.B
205
+ else f"{int(total_size / factor)}"
206
+ )
207
+ overall_avg: float = (total_size / valid_keys_count) / factor
208
+ overall_min_conv: float = (overall_min or 0) / factor
209
+ overall_max_conv: float = overall_max / factor
210
+ overall_avg_display: str = (
211
+ f"{overall_avg:.4f}" if memory_unit != MemoryUnit.B else f"{int(overall_avg)}"
212
+ )
213
+ overall_min_display: str = (
214
+ f"{overall_min_conv:.4f}" if memory_unit != MemoryUnit.B else f"{int(overall_min_conv)}"
215
+ )
216
+ overall_max_display: str = (
217
+ f"{overall_max_conv:.4f}" if memory_unit != MemoryUnit.B else f"{int(overall_max_conv)}"
218
+ )
219
+ total_count: int = sum(len(group["keys"]) for group in group_data.values())
220
+ total_row: TableRow = TableRow(
221
+ key="Total Keys Scanned",
222
+ count=str(total_count),
223
+ size=total_usage_display,
224
+ avg_size=overall_avg_display,
225
+ min_size=overall_min_display,
226
+ max_size=overall_max_display,
227
+ percentage="100.00",
228
+ )
229
+
230
+ _print_memory_usage_table(
231
+ rows=rows,
232
+ total_row=total_row,
233
+ hidden_count=hidden_count,
234
+ memory_unit=memory_unit,
235
+ console=console,
236
+ )
237
+
238
+ # Print the total execution time taken
239
+ console.print(f"Took {(time.time() - start_time):.2f} seconds")
240
+
241
+
242
+ def _scan_keys(
243
+ redis: Redis, pattern: str, count: int, sample_size: int | None, console: Console
244
+ ) -> list[str]:
245
+ """
246
+ Scan keys in the Redis database using the given pattern.
247
+ If sample_size is None, perform a full iteration.
248
+ """
249
+ keys = []
250
+ for key in track(
251
+ redis.scan_iter(match=pattern, count=count),
252
+ description="Scanning keys...",
253
+ total=sample_size,
254
+ console=console,
255
+ show_speed=False,
256
+ ):
257
+ keys.append(key.decode())
258
+ if sample_size and sample_size <= len(keys):
259
+ break
260
+ return keys
261
+
262
+
263
+ def _parse_key_group(key: str, separator: str, level: int) -> str:
264
+ """
265
+ Parse the key group of a key up to the specified level using the given separator.
266
+ For example, _parse_key_group("a:b:c:d", ":", 2) -> "a:b"
267
+
268
+ If level is 0, return the entire key.
269
+ """
270
+ if level == 0:
271
+ return key
272
+ return separator.join(key.split(separator)[:level])
273
+
274
+
275
+ def _get_memory_usage(redis: Redis, keys: list[str]) -> dict[str, int | None]:
276
+ """
277
+ Get memory usage for a list of keys using Lua script.
278
+ If the key doesn't exist (expired), memory usage is None.
279
+ """
280
+ script = """
281
+ local result = {}
282
+ for i, key in ipairs(KEYS) do
283
+ result[i] = redis.call('MEMORY', 'USAGE', key, 'SAMPLES', 0)
284
+ end
285
+ return result
286
+ """
287
+ func = redis.register_script(script)
288
+ return dict(zip(keys, func(keys=keys))) # type: ignore
289
+
290
+
291
+ def _get_memory_unit_factor(memory_unit: MemoryUnit) -> int:
292
+ """
293
+ Get the conversion factor for the specified memory unit.
294
+ """
295
+ if memory_unit == MemoryUnit.B:
296
+ return 1
297
+ elif memory_unit == MemoryUnit.KB:
298
+ return 1024
299
+ elif memory_unit == MemoryUnit.MB:
300
+ return 1024 * 1024
301
+ elif memory_unit == MemoryUnit.GB:
302
+ return 1024 * 1024 * 1024
303
+ else:
304
+ raise ValueError("Invalid value for memory_unit. Use B, KB, MB, or GB.")
305
+
306
+
307
+ def _print_memory_usage_table(
308
+ rows: list[TableRow],
309
+ total_row: TableRow,
310
+ hidden_count: int,
311
+ memory_unit: MemoryUnit,
312
+ console: Console,
313
+ ):
314
+ table: Table = Table(box=box.MINIMAL)
315
+ table.add_column("Key", justify="left")
316
+ table.add_column("Count", justify="right", style="green")
317
+ table.add_column(f"Size ({memory_unit.upper()})", justify="right", style="magenta")
318
+ table.add_column(f"Avg Size ({memory_unit.upper()})", justify="right", style="orange1")
319
+ table.add_column(f"Min Size ({memory_unit.upper()})", justify="right", style="yellow")
320
+ table.add_column(f"Max Size ({memory_unit.upper()})", justify="right", style="red")
321
+ table.add_column("Memory Usage (%)", justify="right", style="cyan")
322
+ for row in rows:
323
+ table.add_row(
324
+ row.key, row.count, row.size, row.avg_size, row.min_size, row.max_size, row.percentage
325
+ )
326
+ if 0 < hidden_count:
327
+ table.add_row(
328
+ f"... {hidden_count} more entries not shown ...", "", "", "", "", "", "", style="dim"
329
+ )
330
+ table.add_section()
331
+ table.add_row(
332
+ total_row.key,
333
+ total_row.count,
334
+ total_row.size,
335
+ total_row.avg_size,
336
+ total_row.min_size,
337
+ total_row.max_size,
338
+ total_row.percentage,
339
+ style="bold",
340
+ )
341
+ console.print(table)
342
+
343
+
344
+ if __name__ == "__main__":
345
+ app()
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: redis-sizer
3
+ Version: 0.1.0
4
+ Summary: A simple command-line tool for analyzing memory usage across keys in a Redis database.
5
+ Author-Email: Mienxiu <mienxiu@gmail.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Project-URL: Homepage, https://github.com/mienxiu/redis-sizer
9
+ Project-URL: Documentation, https://github.com/mienxiu/redis-sizer
10
+ Project-URL: Repository, https://github.com/mienxiu/redis-sizer
11
+ Requires-Python: >=3.11
12
+ Requires-Dist: typer<0.16.0,>=0.15.2
13
+ Requires-Dist: redis<6.0.0,>=5.2.1
14
+ Description-Content-Type: text/markdown
15
+
16
+ # redis-sizer
17
+
18
+ A simple command-line tool for analyzing memory usage across keys in a Redis database.
19
+
20
+
21
+ ![Demo](docs/demo.webp)
22
+
23
+ redis-sizer can help with the following problems:
24
+ - Optimize caching strategy.
25
+ - Gain insights into memory usage at different namespace levels.
26
+ - Identify which parts of your application consume the most memory.
27
+
28
+ ## Installation
29
+
30
+ Install via pip:
31
+
32
+ ```bash
33
+ pip install redis-sizer
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ Run redis-sizer by specifying the Redis host along with any desired options:
39
+
40
+ ```bash
41
+ redis-sizer [OPTIONS] HOST
42
+ ```
43
+
44
+ Options:
45
+
46
+ - `--port`: Port number [default: 6379]
47
+ - `--db`: DB number [default: 0]
48
+ - `--password`: Password [default: None]
49
+ - `--socket-timeout`: Socket timeout in seconds [default: 10]
50
+ - `--socket-connect-timeout`: Socket connect timeout in seconds [default: 10]
51
+ - `--pattern`: Pattern to filter keys [default: *]
52
+ - `--sample-size`: Number of keys to sample [default: None]
53
+ - `--namespace-separator`: Separator for key namespaces [default: :]
54
+ - `--namespace-level`: Number of namespace levels to aggregate keys by. 0 means no aggregation. [default: 0]
55
+ - `--memory-unit`: Memory unit for display in result table [default: B]
56
+ - `--top`: Maximum number of rows to display in result table [default: 10]
57
+ - `--scan-count`: `COUNT` option for scanning keys [default: 1000]
58
+ - `--batch-size`: Batch size for calculating memory usage [default: 1000]
59
+
60
+ ### Aggregating Memory Usage by Key Groups
61
+
62
+ Many applications using Redis adopt hierarchical key naming conventions.
63
+ redis-sizer leverages this structure to aggregate and analyze memory usage, helping you quickly identify which key groups consume the most memory.
64
+
65
+ For example, if your keys follow a colon-delimited format (e.g., `users:profiles:001`, `users:logs:login:002`), the hierarchy can be visualized like a tree structure:
66
+
67
+ ```
68
+ DB
69
+ ├── users:
70
+ │ ├── profiles:
71
+ │ │ ├── 001
72
+ │ │ └── 002
73
+ │ └── logs:
74
+ │ ├── 001
75
+ │ └── 002
76
+ ├── sessions:
77
+ │ └── active:
78
+ ...
79
+ ```
80
+
81
+ Use `--namespace-level 1` to aggregate memory usage at the top-level namespace:
82
+
83
+ ```console
84
+ Key │ Count │ Size (MB) │ ... │ Memory Usage (%)
85
+ ╶────────────────────┼─────────┼───────────┼─────┼──────────────────╴
86
+ users │ 350000 │ 2460.86 │ ... │ 93.82
87
+ sessions │ 60000 │ 140.23 │ ... │ 5.35
88
+ cache │ 10000 │ 20.07 │ ... │ 0.77
89
+ tokens │ 700 │ 1.75 │ ... │ 0.07
90
+ ╶────────────────────┼─────────┼───────────┼─────┼──────────────────╴
91
+ Total Keys Scanned │ 420700 │ 2622.90 │ ... │ 100.00
92
+ ```
93
+
94
+ Use `--namespace-level 2` to aggregate memory usage at the second-level namespace:
95
+
96
+ ```console
97
+ Key │ Count │ Size (MB) │ ... │ Memory Usage (%)
98
+ ╶────────────────────┼─────────┼───────────┼─────┼──────────────────╴
99
+ users:profiles │ 100000 │ 1177.22 │ ... │ 44.88
100
+ users:logs │ 200000 │ 988.01 │ ... │ 37.67
101
+ sessions:active │ 50000 │ 295.64 │ ... │ 11.27
102
+ cache:images │ 40000 │ 100.02 │ ... │ 3.81
103
+ cache:queries │ 20000 │ 40.21 │ ... │ 1.53
104
+ tokens:auth │ 700 │ 1.75 │ ... │ 0.07
105
+ ╶────────────────────┼─────────┼───────────┼─────┼──────────────────╴
106
+ Total Keys Scanned │ 420700 │ 2622.90 │ ... | 100.00
107
+ ```
108
+
109
+ ## Considerations
110
+
111
+ - Since data can change during analysis, the final result may not accurately reflect the database’s state at the beginning of the process.
112
+ - For databases with large collections of keys, consider using the `--sample-size` option to balance processing time with accuracy. If `--sample-size` is not specified, redis-sizer will perform perform a full iteration.
113
+ - Increasing `--scan-count` or `--batch-size` can reduce network overhead. However, this can increase the load on the Redis server without providing meaningful performance improvements and, in some cases, may even lead to server blocking. Adjust these values carefully based on your server’s capacity.
114
+
115
+ ## Redis Version Compatibility
116
+
117
+ - This tool relies on the Redis [`MEMORY USAGE`](https://redis.io/docs/latest/commands/memory-usage/) command, which has been available since version 4.0.0.
118
+ - It has been tested and verified to work with Redis versions 4, 5, 6, and 7.
119
+
120
+ ## Dependencies
121
+
122
+ redis-sizer depends on:
123
+
124
+ - [redis-py](https://github.com/redis/redis-py): to communicate with the Redis database.
125
+ - [typer](https://github.com/fastapi/typer): to build CLI application, with output formatting supported by [rich](https://github.com/Textualize/rich)
@@ -0,0 +1,7 @@
1
+ redis_sizer-0.1.0.dist-info/METADATA,sha256=1YVz7J73PCvoZ4wuXjwZKL4i5-u7KeqsHliVHKuYCLM,5959
2
+ redis_sizer-0.1.0.dist-info/WHEEL,sha256=thaaA2w1JzcGC48WYufAs8nrYZjJm8LqNfnXFOFyCC4,90
3
+ redis_sizer-0.1.0.dist-info/entry_points.txt,sha256=t5Sc1RaT98-2dbgQPtD_Ti7OS6xWkoli3UFMhYAY1IQ,68
4
+ redis_sizer-0.1.0.dist-info/licenses/LICENSE,sha256=8293ucsd6ba-E6wZGqPN9rUhSFJF50AHfBuvr7rVGYA,1064
5
+ redis_sizer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ redis_sizer/cli.py,sha256=FB1whUBWXrUt4udouXEcZI9EhGfVIjN5MHEN0ARCOrs,11585
7
+ redis_sizer-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: pdm-backend (2.4.3)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ redis-sizer = redis_sizer.cli:app
3
+
4
+ [gui_scripts]
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mienxiu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.