Skip to content

Commit a2d0b33

Browse files
Merge pull request #24 from Redislabs-Solution-Architects/gti-608-fix-clusterid-collision
fix: prefix ClusterId with project_id to prevent collisions across projects (GTI-685)
2 parents 76aa806 + 954cf97 commit a2d0b33

2 files changed

Lines changed: 122 additions & 27 deletions

File tree

memorystore.py

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,26 @@ def _pick(labels: Dict[str, str], keys) -> Optional[str]:
8383
return None
8484

8585

86+
def _resolve_inst_key(rlabels: dict, project_id: str = "") -> str:
87+
"""Build a globally unique instance key.
88+
89+
Redis standalone instance_id is a full GCP path (projects/…/instances/name)
90+
which is already globally unique. Valkey and Redis Cluster return short names
91+
via instance_id or cluster_id, so we prefix with project_id to avoid
92+
collisions across projects.
93+
"""
94+
raw_id = (
95+
rlabels.get("instance_id")
96+
or rlabels.get("cluster_id")
97+
or rlabels.get("resource_name")
98+
or "unknown"
99+
)
100+
# Full GCP path (starts with "projects/") is already globally unique
101+
if raw_id.startswith("projects/"):
102+
return raw_id
103+
return f"{project_id}/{raw_id}" if project_id else raw_id
104+
105+
86106
def _point_value(point, default=0):
87107
"""Extract a numeric value from a GCP monitoring point, handling both int64 and double types."""
88108
try:
@@ -163,12 +183,7 @@ def _accumulate_commands(results, table, product_name: str, project_id: str):
163183
mlabels = dict(ts.metric.labels)
164184

165185
# Identify instance/cluster id & node
166-
inst_key = (
167-
rlabels.get("instance_id")
168-
or rlabels.get("cluster_id")
169-
or rlabels.get("resource_name")
170-
or "unknown"
171-
)
186+
inst_key = _resolve_inst_key(rlabels, project_id)
172187
node_id = rlabels.get("node_id") or rlabels.get("shard_id") or "unknown"
173188
entry = _ensure_node_entry(table, inst_key, node_id)
174189

@@ -229,15 +244,10 @@ def _apply_processed_categories(table):
229244
del entry["points"]
230245

231246

232-
def _attach_memory_usage(results, table, key_name="BytesUsedForCache"):
247+
def _attach_memory_usage(results, table, project_id="", key_name="BytesUsedForCache"):
233248
for ts in results:
234249
rlabels = dict(ts.resource.labels)
235-
inst_key = (
236-
rlabels.get("instance_id")
237-
or rlabels.get("cluster_id")
238-
or rlabels.get("resource_name")
239-
or "unknown"
240-
)
250+
inst_key = _resolve_inst_key(rlabels, project_id)
241251
node_id = rlabels.get("node_id") or rlabels.get("shard_id") or "unknown"
242252
if inst_key not in table or node_id not in table[inst_key]:
243253
_ensure_node_entry(table, inst_key, node_id)
@@ -252,17 +262,12 @@ def _attach_memory_usage(results, table, key_name="BytesUsedForCache"):
252262
entry[key_name] = max(prev, maxv)
253263

254264

255-
def _attach_capacity_scalar(results, table, key_name="MaxMemory"):
265+
def _attach_capacity_scalar(results, table, project_id="", key_name="MaxMemory"):
256266
"""Attach a capacity scalar (e.g., memory size); applies to all nodes within the instance/cluster."""
257267
cap_by_inst = defaultdict(int)
258268
for ts in results:
259269
rlabels = dict(ts.resource.labels)
260-
inst_key = (
261-
rlabels.get("instance_id")
262-
or rlabels.get("cluster_id")
263-
or rlabels.get("resource_name")
264-
or "unknown"
265-
)
270+
inst_key = _resolve_inst_key(rlabels, project_id)
266271
v_max = 0
267272
for point in ts.points:
268273
v = int(_point_value(point))
@@ -277,7 +282,7 @@ def _attach_capacity_scalar(results, table, key_name="MaxMemory"):
277282
nodes[node_id][key_name] = cap_by_inst[inst_key]
278283

279284

280-
def _attach_node_role(results, table):
285+
def _attach_node_role(results, table, project_id=""):
281286
"""Set NodeRole using the dedicated replication/role metric.
282287
283288
The 'role' label on commands/calls is metadata — not its purpose to report
@@ -289,8 +294,8 @@ def _attach_node_role(results, table):
289294
"""
290295
for ts in results:
291296
rlabels = dict(ts.resource.labels)
292-
inst_key = rlabels.get("instance_id") or "unknown"
293-
node_id = rlabels.get("node_id") or "unknown"
297+
inst_key = _resolve_inst_key(rlabels, project_id)
298+
node_id = rlabels.get("node_id") or rlabels.get("shard_id") or "unknown"
294299
if inst_key not in table or node_id not in table[inst_key]:
295300
continue
296301

@@ -350,7 +355,7 @@ def collect_for_product(
350355
)
351356
except Exception:
352357
mem_results = []
353-
_attach_memory_usage(mem_results, table)
358+
_attach_memory_usage(mem_results, table, project_id=project_id)
354359
for inst_key, nodes in table.items():
355360
for node_id, entry in nodes.items():
356361
entry["InstanceType"] = instance_type_label
@@ -361,14 +366,16 @@ def collect_for_product(
361366
mem_results = _list_ts(
362367
client, project_name, metric_map["memory_usage"], interval
363368
)
364-
_attach_memory_usage(mem_results, table)
369+
_attach_memory_usage(mem_results, table, project_id=project_id)
365370
except Exception:
366371
pass
367372

368373
# Capacity (MaxMemory) - instance/cluster level
369374
try:
370375
cap_results = _list_ts(client, project_name, metric_map["max_memory"], interval)
371-
_attach_capacity_scalar(cap_results, table, key_name="MaxMemory")
376+
_attach_capacity_scalar(
377+
cap_results, table, project_id=project_id, key_name="MaxMemory"
378+
)
372379
except Exception:
373380
pass
374381

@@ -378,7 +385,7 @@ def collect_for_product(
378385
role_results = _list_ts(
379386
client, project_name, metric_map["replication_role"], interval
380387
)
381-
_attach_node_role(role_results, table)
388+
_attach_node_role(role_results, table, project_id=project_id)
382389
except Exception:
383390
pass
384391

test_msstats.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
create_workbooks,
1717
)
1818

19+
from memorystore import (
20+
_resolve_inst_key,
21+
_attach_memory_usage,
22+
_attach_capacity_scalar,
23+
)
24+
1925

2026
class TestMSSStats(unittest.TestCase):
2127
"""Test suite for msstats utility functions"""
@@ -469,5 +475,87 @@ def test_missing_service_account_file(self):
469475
self.assertIsNone(result)
470476

471477

478+
class TestMemorystore(unittest.TestCase):
479+
"""Test suite for memorystore.py functions"""
480+
481+
def test_resolve_inst_key_redis_standalone_full_path(self):
482+
"""Redis standalone returns full GCP path via instance_id — should be used as-is"""
483+
rlabels = {
484+
"instance_id": "projects/my-project/locations/us-central1/instances/my-redis",
485+
"node_id": "node-0",
486+
}
487+
result = _resolve_inst_key(rlabels, "my-project")
488+
self.assertEqual(
489+
result,
490+
"projects/my-project/locations/us-central1/instances/my-redis",
491+
)
492+
493+
def test_resolve_inst_key_valkey_short_name_prefixed(self):
494+
"""Valkey returns short name via instance_id — should be prefixed with project_id"""
495+
rlabels = {"instance_id": "memorystore-valkey", "node_id": "abc123"}
496+
result = _resolve_inst_key(rlabels, "my-project")
497+
self.assertEqual(result, "my-project/memorystore-valkey")
498+
499+
def test_resolve_inst_key_redis_cluster_short_name_prefixed(self):
500+
"""Redis Cluster returns short name via cluster_id — should be prefixed"""
501+
rlabels = {"cluster_id": "memorystore-redis-cluster", "shard_id": "xyz789"}
502+
result = _resolve_inst_key(rlabels, "my-project")
503+
self.assertEqual(result, "my-project/memorystore-redis-cluster")
504+
505+
def test_resolve_inst_key_fallback_to_unknown(self):
506+
"""When no identifiers are present, return 'unknown'"""
507+
rlabels = {"region": "us-central1"}
508+
result = _resolve_inst_key(rlabels, "my-project")
509+
self.assertEqual(result, "my-project/unknown")
510+
511+
def test_resolve_inst_key_no_collision_across_projects(self):
512+
"""Same short name in different projects produces different keys"""
513+
rlabels = {"instance_id": "memorystore-valkey"}
514+
key_a = _resolve_inst_key(rlabels, "project-a")
515+
key_b = _resolve_inst_key(rlabels, "project-b")
516+
self.assertNotEqual(key_a, key_b)
517+
self.assertEqual(key_a, "project-a/memorystore-valkey")
518+
self.assertEqual(key_b, "project-b/memorystore-valkey")
519+
520+
def test_attach_memory_usage_uses_resolve_inst_key(self):
521+
"""_attach_memory_usage should create entries with project-prefixed keys for short names"""
522+
table = {}
523+
mock_ts = MagicMock()
524+
mock_ts.resource.labels = {
525+
"instance_id": "memorystore-valkey",
526+
"node_id": "abc123",
527+
}
528+
mock_point = MagicMock()
529+
mock_point.value.int64_value = 5000000
530+
mock_point.value.double_value = 0
531+
mock_ts.points = [mock_point]
532+
533+
_attach_memory_usage([mock_ts], table, project_id="my-project")
534+
535+
self.assertIn("my-project/memorystore-valkey", table)
536+
self.assertNotIn("memorystore-valkey", table)
537+
538+
def test_attach_capacity_scalar_uses_resolve_inst_key(self):
539+
"""_attach_capacity_scalar should match entries using project-prefixed keys"""
540+
table = {"my-project/memorystore-valkey": {"abc123": {"MaxMemory": 0}}}
541+
mock_ts = MagicMock()
542+
mock_ts.resource.labels = {
543+
"instance_id": "memorystore-valkey",
544+
"node_id": "abc123",
545+
}
546+
mock_point = MagicMock()
547+
mock_point.value.int64_value = 10000000
548+
mock_point.value.double_value = 0
549+
mock_ts.points = [mock_point]
550+
551+
_attach_capacity_scalar(
552+
[mock_ts], table, project_id="my-project", key_name="MaxMemory"
553+
)
554+
555+
self.assertEqual(
556+
table["my-project/memorystore-valkey"]["abc123"]["MaxMemory"], 10000000
557+
)
558+
559+
472560
if __name__ == "__main__":
473561
unittest.main()

0 commit comments

Comments
 (0)