py-hamt 3.3.0__tar.gz → 3.3.2__tar.gz

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.
Files changed (58) hide show
  1. {py_hamt-3.3.0 → py_hamt-3.3.2}/PKG-INFO +2 -1
  2. {py_hamt-3.3.0 → py_hamt-3.3.2}/py_hamt/hamt.py +68 -32
  3. py_hamt-3.3.2/py_hamt/instrumentation.py +490 -0
  4. {py_hamt-3.3.0 → py_hamt-3.3.2}/py_hamt/sharded_zarr_store.py +139 -74
  5. {py_hamt-3.3.0 → py_hamt-3.3.2}/py_hamt/store_httpx.py +52 -32
  6. {py_hamt-3.3.0 → py_hamt-3.3.2}/py_hamt/zarr_hamt_store.py +52 -27
  7. {py_hamt-3.3.0 → py_hamt-3.3.2}/pyproject.toml +2 -1
  8. py_hamt-3.3.2/tests/test_instrumentation.py +207 -0
  9. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_sharded_store_deleting.py +0 -4
  10. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_sharded_store_resizing.py +0 -12
  11. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_sharded_zarr_store.py +230 -76
  12. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_sharded_zarr_store_coverage.py +4 -5
  13. {py_hamt-3.3.0 → py_hamt-3.3.2}/uv.lock +21 -2
  14. py_hamt-3.3.0/.claude/agents/claude-planner.md +0 -35
  15. py_hamt-3.3.0/.claude/agents/consensus-compiler.md +0 -61
  16. py_hamt-3.3.0/.claude/agents/gemini-planner.md +0 -35
  17. py_hamt-3.3.0/.claude/agents/gpt-planner.md +0 -36
  18. py_hamt-3.3.0/.claude/commands/consensus.md +0 -61
  19. py_hamt-3.3.0/.claude/settings.local.json +0 -6
  20. py_hamt-3.3.0/.mcp.json +0 -13
  21. {py_hamt-3.3.0 → py_hamt-3.3.2}/.github/workflows/pages-main.yaml +0 -0
  22. {py_hamt-3.3.0 → py_hamt-3.3.2}/.github/workflows/run-checks.yaml +0 -0
  23. {py_hamt-3.3.0 → py_hamt-3.3.2}/.gitignore +0 -0
  24. {py_hamt-3.3.0 → py_hamt-3.3.2}/.pre-commit-config.yaml +0 -0
  25. {py_hamt-3.3.0 → py_hamt-3.3.2}/.python-version +0 -0
  26. {py_hamt-3.3.0 → py_hamt-3.3.2}/AGENTS.md +0 -0
  27. {py_hamt-3.3.0 → py_hamt-3.3.2}/Architecture.md +0 -0
  28. {py_hamt-3.3.0 → py_hamt-3.3.2}/CLAUDE.md +0 -0
  29. {py_hamt-3.3.0 → py_hamt-3.3.2}/LICENSE +0 -0
  30. {py_hamt-3.3.0 → py_hamt-3.3.2}/README.md +0 -0
  31. {py_hamt-3.3.0 → py_hamt-3.3.2}/fsgs.py +0 -0
  32. {py_hamt-3.3.0 → py_hamt-3.3.2}/public_gateway_example.py +0 -0
  33. {py_hamt-3.3.0 → py_hamt-3.3.2}/py_hamt/__init__.py +0 -0
  34. {py_hamt-3.3.0 → py_hamt-3.3.2}/py_hamt/encryption_hamt_store.py +0 -0
  35. {py_hamt-3.3.0 → py_hamt-3.3.2}/py_hamt/hamt_to_sharded_converter.py +0 -0
  36. {py_hamt-3.3.0 → py_hamt-3.3.2}/py_hamt/reference/flat_zarr_store.py +0 -0
  37. {py_hamt-3.3.0 → py_hamt-3.3.2}/pytest.ini +0 -0
  38. {py_hamt-3.3.0 → py_hamt-3.3.2}/run-checks.sh +0 -0
  39. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/conftest.py +0 -0
  40. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_async.py +0 -0
  41. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_benchmark_stores.py +0 -0
  42. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_branch_anchors.py +0 -0
  43. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_converter.py +0 -0
  44. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_cpc_compare.py +0 -0
  45. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_extract_bits.py +0 -0
  46. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_hamt.py +0 -0
  47. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_kubo_cas.py +0 -0
  48. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_kubo_pin.py +0 -0
  49. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_kubocas_auth.py +0 -0
  50. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_kubocas_session.py +0 -0
  51. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_performance_tests.py +0 -0
  52. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_public_gateway.py +0 -0
  53. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_read_only_guards.py +0 -0
  54. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_sharded_store_grafting.py +0 -0
  55. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_zarr_ipfs.py +0 -0
  56. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_zarr_ipfs_encrypted.py +0 -0
  57. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_zarr_ipfs_partial.py +0 -0
  58. {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/testing_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py-hamt
3
- Version: 3.3.0
3
+ Version: 3.3.2
4
4
  Summary: HAMT implementation for a content-addressed storage system.
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -8,6 +8,7 @@ Requires-Dist: dag-cbor>=0.3.3
8
8
  Requires-Dist: httpx[http2]>=0.28.1
9
9
  Requires-Dist: msgspec>=0.18.6
10
10
  Requires-Dist: multiformats[full]>=0.3.1.post4
11
+ Requires-Dist: opentelemetry-api>=1.30.0
11
12
  Requires-Dist: pycryptodome>=3.21.0
12
13
  Requires-Dist: zarr==3.0.9
13
14
  Description-Content-Type: text/markdown
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import time
2
3
  import uuid
3
4
  from abc import ABC, abstractmethod
4
5
  from copy import deepcopy
@@ -16,6 +17,7 @@ import dag_cbor
16
17
  from dag_cbor.ipld import IPLDKind
17
18
  from multiformats import multihash
18
19
 
20
+ from . import instrumentation
19
21
  from .store_httpx import ContentAddressedStore
20
22
 
21
23
 
@@ -159,12 +161,24 @@ class ReadCacheStore(NodeStore):
159
161
  async def load(self, id: IPLDKind) -> Node:
160
162
  # Cache Hit
161
163
  if id in self.cache:
164
+ start = time.perf_counter()
162
165
  node: Node = self.cache[id]
166
+ instrumentation.record_hamt_node_load(
167
+ cache_hit=True,
168
+ seconds=time.perf_counter() - start,
169
+ )
163
170
  return node
164
171
 
165
172
  # Cache Miss
166
- node = Node.deserialize(await self.hamt.cas.load(id))
173
+ start = time.perf_counter()
174
+ node_bytes = await self.hamt.cas.load(id)
175
+ node = Node.deserialize(node_bytes)
167
176
  self.cache[id] = node
177
+ instrumentation.record_hamt_node_load(
178
+ cache_hit=False,
179
+ seconds=time.perf_counter() - start,
180
+ byte_count=len(node_bytes),
181
+ )
168
182
  return node
169
183
 
170
184
  def size(self) -> int:
@@ -625,41 +639,63 @@ class HAMT:
625
639
 
626
640
  # Callers MUST handle acquiring a lock
627
641
  async def _get_pointer(self, key: str) -> IPLDKind:
628
- raw_hash: bytes = self.hash_fn(key.encode())
629
-
630
- current_id: IPLDKind = self.root_node_id
631
- current_depth: int = 0
642
+ with instrumentation.span(
643
+ "py_hamt.hamt.lookup", {"py_hamt.hamt.lookup.key": key}
644
+ ):
645
+ lookup_started_at = time.perf_counter()
646
+ raw_hash: bytes = self.hash_fn(key.encode())
632
647
 
633
- # Don't check if result is none but use a boolean to indicate finding something, this is because None is a possible value of IPLDKind
634
- result_ptr: IPLDKind = None
635
- found_a_result: bool = False
636
- while True:
637
- top_id: IPLDKind = current_id
638
- top_node: Node = await self.node_store.load(top_id)
639
- map_key: int = extract_bits(raw_hash, current_depth, 8)
648
+ current_id: IPLDKind = self.root_node_id
649
+ current_depth: int = 0
650
+ node_loads = 0
651
+ node_cache_hits = 0
652
+
653
+ # Don't check if result is none but use a boolean to indicate finding something, this is because None is a possible value of IPLDKind
654
+ result_ptr: IPLDKind = None
655
+ found_a_result: bool = False
656
+ try:
657
+ while True:
658
+ top_id: IPLDKind = current_id
659
+ if (
660
+ isinstance(self.node_store, ReadCacheStore)
661
+ and top_id in self.node_store.cache
662
+ ):
663
+ node_cache_hits += 1
664
+ node_loads += 1
665
+ top_node: Node = await self.node_store.load(top_id)
666
+ map_key: int = extract_bits(raw_hash, current_depth, 8)
667
+
668
+ # Check if this key is in one of the buckets
669
+ item = top_node.data[map_key]
670
+ if isinstance(item, dict):
671
+ bucket = item
672
+ if key in bucket:
673
+ result_ptr = bucket[key]
674
+ found_a_result = True
675
+ break
676
+
677
+ if isinstance(item, list):
678
+ link: IPLDKind = item[0]
679
+ current_id = link
680
+ current_depth += 1
681
+ continue
640
682
 
641
- # Check if this key is in one of the buckets
642
- item = top_node.data[map_key]
643
- if isinstance(item, dict):
644
- bucket = item
645
- if key in bucket:
646
- result_ptr = bucket[key]
647
- found_a_result = True
683
+ # Nowhere left to go, stop walking down the tree
648
684
  break
649
685
 
650
- if isinstance(item, list):
651
- link: IPLDKind = item[0]
652
- current_id = link
653
- current_depth += 1
654
- continue
655
-
656
- # Nowhere left to go, stop walking down the tree
657
- break
658
-
659
- if not found_a_result:
660
- raise KeyError
661
-
662
- return result_ptr
686
+ if not found_a_result:
687
+ raise KeyError
688
+
689
+ return result_ptr
690
+ finally:
691
+ instrumentation.record_hamt_lookup(
692
+ key,
693
+ depth=current_depth,
694
+ node_loads=node_loads,
695
+ node_cache_hits=node_cache_hits,
696
+ found=found_a_result,
697
+ seconds=time.perf_counter() - lookup_started_at,
698
+ )
663
699
 
664
700
  # Callers MUST handle locking or not on their own
665
701
  async def _iter_nodes(self) -> AsyncIterator[tuple[IPLDKind, Node]]:
@@ -0,0 +1,490 @@
1
+ import os
2
+ import statistics
3
+ import threading
4
+ import time
5
+ from collections import Counter, defaultdict
6
+ from collections.abc import Generator
7
+ from contextlib import contextmanager
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ from opentelemetry import metrics, trace
12
+ from opentelemetry.trace import Span, Status, StatusCode
13
+
14
+ TRUE_VALUES = {"True", "true", "1", "yes", "Y", "T"}
15
+ OTEL_ATTRIBUTE_VALUE = str | bool | int | float
16
+
17
+ _TRACER = trace.get_tracer("py_hamt")
18
+ _METER = metrics.get_meter("py_hamt")
19
+ _CAS_LOAD_COUNTER = _METER.create_counter(
20
+ "py_hamt.cas.load.requests",
21
+ unit="1",
22
+ description="Number of content-addressed store load requests.",
23
+ )
24
+ _CAS_LOAD_BYTES = _METER.create_counter(
25
+ "py_hamt.cas.load.response_bytes",
26
+ unit="By",
27
+ description="Bytes returned by content-addressed store load requests.",
28
+ )
29
+ _CAS_LOAD_RETRIES = _METER.create_counter(
30
+ "py_hamt.cas.load.retries",
31
+ unit="1",
32
+ description="Retry attempts used by content-addressed store load requests.",
33
+ )
34
+ _CAS_LOAD_DURATION = _METER.create_histogram(
35
+ "py_hamt.cas.load.duration",
36
+ unit="s",
37
+ description="Content-addressed store load latency.",
38
+ )
39
+ _HAMT_LOOKUP_COUNTER = _METER.create_counter(
40
+ "py_hamt.hamt.lookup.requests",
41
+ unit="1",
42
+ description="HAMT key lookup requests.",
43
+ )
44
+ _HAMT_LOOKUP_DURATION = _METER.create_histogram(
45
+ "py_hamt.hamt.lookup.duration",
46
+ unit="s",
47
+ description="HAMT key lookup latency.",
48
+ )
49
+ _HAMT_LOOKUP_DEPTH = _METER.create_histogram(
50
+ "py_hamt.hamt.lookup.depth",
51
+ unit="1",
52
+ description="HAMT traversal depth per key lookup.",
53
+ )
54
+ _HAMT_NODE_LOAD_COUNTER = _METER.create_counter(
55
+ "py_hamt.hamt.node_load.requests",
56
+ unit="1",
57
+ description="HAMT node load requests.",
58
+ )
59
+ _HAMT_NODE_LOAD_BYTES = _METER.create_counter(
60
+ "py_hamt.hamt.node_load.bytes",
61
+ unit="By",
62
+ description="Bytes returned by HAMT node loads.",
63
+ )
64
+ _HAMT_NODE_LOAD_DURATION = _METER.create_histogram(
65
+ "py_hamt.hamt.node_load.duration",
66
+ unit="s",
67
+ description="HAMT node load latency.",
68
+ )
69
+ _ZARR_GET_COUNTER = _METER.create_counter(
70
+ "py_hamt.zarr.get.requests",
71
+ unit="1",
72
+ description="Zarr store get requests.",
73
+ )
74
+ _ZARR_GET_DURATION = _METER.create_histogram(
75
+ "py_hamt.zarr.get.duration",
76
+ unit="s",
77
+ description="Zarr store get latency.",
78
+ )
79
+ _SHARD_LOAD_COUNTER = _METER.create_counter(
80
+ "py_hamt.sharded_store.shard_load.requests",
81
+ unit="1",
82
+ description="Sharded Zarr shard load requests.",
83
+ )
84
+ _SHARD_LOAD_DURATION = _METER.create_histogram(
85
+ "py_hamt.sharded_store.shard_load.duration",
86
+ unit="s",
87
+ description="Sharded Zarr shard load latency.",
88
+ )
89
+ _SHARD_ENTRIES = _METER.create_histogram(
90
+ "py_hamt.sharded_store.shard_entries",
91
+ unit="1",
92
+ description="Number of entries loaded per shard.",
93
+ )
94
+
95
+
96
+ @dataclass
97
+ class TraceContext:
98
+ started_at: float
99
+ span: Span
100
+ span_context_manager: Any
101
+ metric_attributes: dict[str, OTEL_ATTRIBUTE_VALUE]
102
+ custom_metrics_enabled: bool
103
+
104
+
105
+ def _attributes(
106
+ attributes: dict[str, Any] | None = None,
107
+ ) -> dict[str, OTEL_ATTRIBUTE_VALUE]:
108
+ if not attributes:
109
+ return {}
110
+ cleaned: dict[str, OTEL_ATTRIBUTE_VALUE] = {}
111
+ for key, value in attributes.items():
112
+ if value is None:
113
+ continue
114
+ if isinstance(value, (str, bool, int, float)):
115
+ cleaned[key] = value
116
+ else:
117
+ cleaned[key] = str(value)
118
+ return cleaned
119
+
120
+
121
+ @contextmanager
122
+ def span(
123
+ name: str, attributes: dict[str, Any] | None = None
124
+ ) -> Generator[Span, None, None]:
125
+ with _TRACER.start_as_current_span(
126
+ name, attributes=_attributes(attributes)
127
+ ) as active_span:
128
+ try:
129
+ yield active_span
130
+ except Exception as exc:
131
+ if active_span.is_recording():
132
+ active_span.record_exception(exc)
133
+ active_span.set_status(Status(StatusCode.ERROR, str(exc)))
134
+ raise
135
+
136
+
137
+ def _current_span() -> Span:
138
+ return trace.get_current_span()
139
+
140
+
141
+ def _set_current_span_attributes(attributes: dict[str, Any]) -> None:
142
+ active_span = _current_span()
143
+ if not active_span.is_recording():
144
+ return
145
+ for key, value in _attributes(attributes).items():
146
+ active_span.set_attribute(key, value)
147
+
148
+
149
+ def enabled() -> bool:
150
+ return (
151
+ os.getenv("IPFS_RETRIEVAL_TRACE", "False") in TRUE_VALUES
152
+ or os.getenv("PY_HAMT_TRACE", "False") in TRUE_VALUES
153
+ )
154
+
155
+
156
+ def _sample_limit() -> int:
157
+ try:
158
+ return max(0, int(os.getenv("IPFS_RETRIEVAL_TRACE_SAMPLE_LIMIT", "2000")))
159
+ except ValueError:
160
+ return 2000
161
+
162
+
163
+ class _Metrics:
164
+ def __init__(self, label: str | None = None) -> None:
165
+ self.label = label
166
+ self.started_at = time.perf_counter()
167
+ self.counters: defaultdict[str, float] = defaultdict(float)
168
+ self.bytes: defaultdict[str, float] = defaultdict(float)
169
+ self.timing_totals: defaultdict[str, float] = defaultdict(float)
170
+ self.timing_counts: defaultdict[str, int] = defaultdict(int)
171
+ self.samples: dict[str, list[float]] = defaultdict(list)
172
+ self.cid_counts: Counter[str] = Counter()
173
+ self.key_counts: Counter[str] = Counter()
174
+ self.inflight_cas_loads = 0
175
+ self.max_inflight_cas_loads = 0
176
+ self.sample_limit = _sample_limit()
177
+
178
+ def observe(self, name: str, value: float) -> None:
179
+ self.timing_totals[name] += value
180
+ self.timing_counts[name] += 1
181
+ if len(self.samples[name]) < self.sample_limit:
182
+ self.samples[name].append(value)
183
+
184
+ def value(self, name: str, value: float) -> None:
185
+ self.counters[f"{name}.total"] += value
186
+ self.counters[f"{name}.count"] += 1
187
+ if len(self.samples[name]) < self.sample_limit:
188
+ self.samples[name].append(value)
189
+
190
+
191
+ _LOCK = threading.RLock()
192
+ _METRICS = _Metrics()
193
+
194
+
195
+ def reset(label: str | None = None) -> None:
196
+ if not enabled():
197
+ return
198
+ global _METRICS
199
+ with _LOCK:
200
+ _METRICS = _Metrics(label=label)
201
+
202
+
203
+ def increment(name: str, amount: int = 1) -> None:
204
+ if not enabled():
205
+ return
206
+ with _LOCK:
207
+ _METRICS.counters[name] += amount
208
+
209
+
210
+ def add_bytes(name: str, amount: int) -> None:
211
+ if not enabled():
212
+ return
213
+ with _LOCK:
214
+ _METRICS.bytes[name] += amount
215
+
216
+
217
+ def observe(name: str, seconds: float) -> None:
218
+ if not enabled():
219
+ return
220
+ with _LOCK:
221
+ _METRICS.observe(name, seconds)
222
+
223
+
224
+ def value(name: str, measured_value: float) -> None:
225
+ if not enabled():
226
+ return
227
+ with _LOCK:
228
+ _METRICS.value(name, measured_value)
229
+
230
+
231
+ def begin_cas_load(cid: Any, has_range: bool) -> TraceContext:
232
+ started_at = time.perf_counter()
233
+ metric_attributes: dict[str, OTEL_ATTRIBUTE_VALUE] = {
234
+ "py_hamt.cas.has_range": has_range
235
+ }
236
+ custom_metrics_enabled = enabled()
237
+ span_context_manager = _TRACER.start_as_current_span(
238
+ "py_hamt.cas.load",
239
+ attributes=_attributes({
240
+ "py_hamt.cid": str(cid),
241
+ **metric_attributes,
242
+ }),
243
+ )
244
+ active_span = span_context_manager.__enter__()
245
+ if custom_metrics_enabled:
246
+ with _LOCK:
247
+ _METRICS.counters["cas_load.range" if has_range else "cas_load.full"] += 1
248
+ _METRICS.counters["cas_load.total"] += 1
249
+ _METRICS.cid_counts[str(cid)] += 1
250
+ _METRICS.inflight_cas_loads += 1
251
+ _METRICS.max_inflight_cas_loads = max(
252
+ _METRICS.max_inflight_cas_loads, _METRICS.inflight_cas_loads
253
+ )
254
+ return TraceContext(
255
+ started_at=started_at,
256
+ span=active_span,
257
+ span_context_manager=span_context_manager,
258
+ metric_attributes=metric_attributes,
259
+ custom_metrics_enabled=custom_metrics_enabled,
260
+ )
261
+
262
+
263
+ def end_cas_load(
264
+ trace_context: TraceContext | None,
265
+ *,
266
+ byte_count: int = 0,
267
+ retries: int = 0,
268
+ status: str = "ok",
269
+ ) -> None:
270
+ if trace_context is None:
271
+ return
272
+ elapsed = time.perf_counter() - trace_context.started_at
273
+ attributes = {**trace_context.metric_attributes, "py_hamt.cas.status": status}
274
+ _CAS_LOAD_COUNTER.add(1, attributes)
275
+ _CAS_LOAD_BYTES.add(byte_count, attributes)
276
+ _CAS_LOAD_RETRIES.add(retries, attributes)
277
+ _CAS_LOAD_DURATION.record(elapsed, attributes)
278
+ if trace_context.span.is_recording():
279
+ trace_context.span.set_attribute("py_hamt.cas.status", status)
280
+ trace_context.span.set_attribute("py_hamt.cas.response_bytes", byte_count)
281
+ trace_context.span.set_attribute("py_hamt.cas.retries", retries)
282
+ if status != "ok":
283
+ trace_context.span.set_status(Status(StatusCode.ERROR, status))
284
+ if trace_context.custom_metrics_enabled:
285
+ with _LOCK:
286
+ _METRICS.inflight_cas_loads = max(0, _METRICS.inflight_cas_loads - 1)
287
+ _METRICS.counters[f"cas_load.status.{status}"] += 1
288
+ _METRICS.counters["cas_load.retries"] += retries
289
+ _METRICS.bytes["cas_load.response_bytes"] += byte_count
290
+ _METRICS.observe("cas_load.latency_seconds", elapsed)
291
+ trace_context.span_context_manager.__exit__(None, None, None)
292
+
293
+
294
+ def record_hamt_node_load(
295
+ *, cache_hit: bool, seconds: float, byte_count: int = 0
296
+ ) -> None:
297
+ attributes = {"py_hamt.cache_hit": cache_hit}
298
+ _HAMT_NODE_LOAD_COUNTER.add(1, attributes)
299
+ _HAMT_NODE_LOAD_BYTES.add(byte_count, attributes)
300
+ _HAMT_NODE_LOAD_DURATION.record(seconds, attributes)
301
+ _set_current_span_attributes({
302
+ "py_hamt.hamt.node_load.cache_hit": cache_hit,
303
+ "py_hamt.hamt.node_load.bytes": byte_count,
304
+ })
305
+ if not enabled():
306
+ return
307
+ with _LOCK:
308
+ _METRICS.counters[
309
+ "hamt_node_load.cache_hit" if cache_hit else "hamt_node_load.cache_miss"
310
+ ] += 1
311
+ _METRICS.counters["hamt_node_load.total"] += 1
312
+ _METRICS.bytes["hamt_node_load.bytes"] += byte_count
313
+ _METRICS.observe("hamt_node_load.latency_seconds", seconds)
314
+
315
+
316
+ def record_hamt_lookup(
317
+ key: str,
318
+ *,
319
+ depth: int,
320
+ node_loads: int,
321
+ node_cache_hits: int,
322
+ found: bool,
323
+ seconds: float,
324
+ ) -> None:
325
+ attributes = {"py_hamt.hamt.lookup.found": found}
326
+ _HAMT_LOOKUP_COUNTER.add(1, attributes)
327
+ _HAMT_LOOKUP_DURATION.record(seconds, attributes)
328
+ _HAMT_LOOKUP_DEPTH.record(depth, attributes)
329
+ _set_current_span_attributes({
330
+ "py_hamt.hamt.lookup.key": key,
331
+ "py_hamt.hamt.lookup.depth": depth,
332
+ "py_hamt.hamt.lookup.node_loads": node_loads,
333
+ "py_hamt.hamt.lookup.node_cache_hits": node_cache_hits,
334
+ "py_hamt.hamt.lookup.found": found,
335
+ })
336
+ if not enabled():
337
+ return
338
+ with _LOCK:
339
+ _METRICS.counters["hamt_lookup.total"] += 1
340
+ _METRICS.counters["hamt_lookup.found" if found else "hamt_lookup.missing"] += 1
341
+ _METRICS.observe("hamt_lookup.latency_seconds", seconds)
342
+ _METRICS.value("hamt_lookup.depth", depth)
343
+ _METRICS.value("hamt_lookup.node_loads", node_loads)
344
+ _METRICS.value("hamt_lookup.node_cache_hits", node_cache_hits)
345
+ _METRICS.key_counts[f"hamt:{key}"] += 1
346
+
347
+
348
+ def record_zarr_get(
349
+ *,
350
+ store: str,
351
+ key: str,
352
+ kind: str,
353
+ hit: bool,
354
+ seconds: float,
355
+ byte_range: bool = False,
356
+ shard_idx: int | None = None,
357
+ ) -> None:
358
+ attributes = {
359
+ "py_hamt.zarr.store": store,
360
+ "py_hamt.zarr.kind": kind,
361
+ "py_hamt.zarr.hit": hit,
362
+ "py_hamt.zarr.byte_range": byte_range,
363
+ }
364
+ _ZARR_GET_COUNTER.add(1, attributes)
365
+ _ZARR_GET_DURATION.record(seconds, attributes)
366
+ _set_current_span_attributes({
367
+ "py_hamt.zarr.store": store,
368
+ "py_hamt.zarr.key": key,
369
+ "py_hamt.zarr.kind": kind,
370
+ "py_hamt.zarr.hit": hit,
371
+ "py_hamt.zarr.byte_range": byte_range,
372
+ "py_hamt.zarr.shard_idx": shard_idx,
373
+ })
374
+ if not enabled():
375
+ return
376
+ prefix = f"{store}.get.{kind}"
377
+ with _LOCK:
378
+ _METRICS.counters[f"{prefix}.total"] += 1
379
+ _METRICS.counters[f"{prefix}.hit" if hit else f"{prefix}.miss"] += 1
380
+ if byte_range:
381
+ _METRICS.counters[f"{prefix}.byte_range"] += 1
382
+ if shard_idx is not None:
383
+ _METRICS.counters[f"{store}.shard.{shard_idx}.requests"] += 1
384
+ _METRICS.observe(f"{prefix}.latency_seconds", seconds)
385
+ _METRICS.key_counts[f"{store}:{key}"] += 1
386
+
387
+
388
+ def record_shard_load(
389
+ *, shard_idx: int, cache_hit: bool, seconds: float, entries: int = 0
390
+ ) -> None:
391
+ attributes = {"py_hamt.cache_hit": cache_hit}
392
+ _SHARD_LOAD_COUNTER.add(1, attributes)
393
+ _SHARD_LOAD_DURATION.record(seconds, attributes)
394
+ if entries:
395
+ _SHARD_ENTRIES.record(entries, attributes)
396
+ _set_current_span_attributes({
397
+ "py_hamt.sharded_store.shard_idx": shard_idx,
398
+ "py_hamt.sharded_store.shard_cache_hit": cache_hit,
399
+ "py_hamt.sharded_store.shard_entries": entries,
400
+ })
401
+ if not enabled():
402
+ return
403
+ with _LOCK:
404
+ _METRICS.counters[
405
+ "sharded_store.shard_cache.hit"
406
+ if cache_hit
407
+ else "sharded_store.shard_cache.miss"
408
+ ] += 1
409
+ _METRICS.counters["sharded_store.shard_cache.total"] += 1
410
+ _METRICS.observe("sharded_store.shard_cache.latency_seconds", seconds)
411
+ if entries:
412
+ _METRICS.value("sharded_store.shard_entries", entries)
413
+ _METRICS.counters[f"sharded_store.shard.{shard_idx}.loads"] += 1
414
+
415
+
416
+ def _distribution(
417
+ values: list[float], total: float, count: int
418
+ ) -> dict[str, float | int]:
419
+ if count == 0:
420
+ return {"count": 0}
421
+ sorted_values = sorted(values)
422
+
423
+ def percentile(pct: float) -> float:
424
+ index = min(
425
+ len(sorted_values) - 1, max(0, round((len(sorted_values) - 1) * pct))
426
+ )
427
+ return sorted_values[index]
428
+
429
+ result: dict[str, float | int] = {
430
+ "count": count,
431
+ "avg": total / count,
432
+ "sample_count": len(sorted_values),
433
+ }
434
+ if sorted_values:
435
+ result.update({
436
+ "min": sorted_values[0],
437
+ "p50": statistics.median(sorted_values),
438
+ "p95": percentile(0.95),
439
+ "p99": percentile(0.99),
440
+ "max": sorted_values[-1],
441
+ })
442
+ return result
443
+
444
+
445
+ def _counter_distribution(metrics: _Metrics, name: str) -> dict[str, float | int]:
446
+ count = int(metrics.counters.get(f"{name}.count", 0))
447
+ total = float(metrics.counters.get(f"{name}.total", 0.0))
448
+ return _distribution(metrics.samples.get(name, []), total, count)
449
+
450
+
451
+ def snapshot() -> dict[str, Any]:
452
+ if not enabled():
453
+ return {}
454
+ with _LOCK:
455
+ metrics = _METRICS
456
+ duplicate_cid_requests = sum(
457
+ count - 1 for count in metrics.cid_counts.values() if count > 1
458
+ )
459
+ timing_summary = {
460
+ name: _distribution(
461
+ metrics.samples.get(name, []),
462
+ float(total),
463
+ int(metrics.timing_counts[name]),
464
+ )
465
+ for name, total in metrics.timing_totals.items()
466
+ }
467
+ value_summary = {
468
+ name: _counter_distribution(metrics, name)
469
+ for name in {
470
+ key.rsplit(".", 1)[0]
471
+ for key in metrics.counters
472
+ if key.endswith(".count")
473
+ and f"{key.rsplit('.', 1)[0]}.total" in metrics.counters
474
+ }
475
+ }
476
+ return {
477
+ "enabled": True,
478
+ "label": metrics.label,
479
+ "elapsed_seconds": time.perf_counter() - metrics.started_at,
480
+ "counters": dict(metrics.counters),
481
+ "bytes": dict(metrics.bytes),
482
+ "timings": timing_summary,
483
+ "values": value_summary,
484
+ "cas_load_unique_cids": len(metrics.cid_counts),
485
+ "cas_load_duplicate_requests": duplicate_cid_requests,
486
+ "cas_load_top_cids": metrics.cid_counts.most_common(20),
487
+ "zarr_top_keys": metrics.key_counts.most_common(50),
488
+ "max_inflight_cas_loads": metrics.max_inflight_cas_loads,
489
+ "sample_limit": metrics.sample_limit,
490
+ }