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.
- {py_hamt-3.3.0 → py_hamt-3.3.2}/PKG-INFO +2 -1
- {py_hamt-3.3.0 → py_hamt-3.3.2}/py_hamt/hamt.py +68 -32
- py_hamt-3.3.2/py_hamt/instrumentation.py +490 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/py_hamt/sharded_zarr_store.py +139 -74
- {py_hamt-3.3.0 → py_hamt-3.3.2}/py_hamt/store_httpx.py +52 -32
- {py_hamt-3.3.0 → py_hamt-3.3.2}/py_hamt/zarr_hamt_store.py +52 -27
- {py_hamt-3.3.0 → py_hamt-3.3.2}/pyproject.toml +2 -1
- py_hamt-3.3.2/tests/test_instrumentation.py +207 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_sharded_store_deleting.py +0 -4
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_sharded_store_resizing.py +0 -12
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_sharded_zarr_store.py +230 -76
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_sharded_zarr_store_coverage.py +4 -5
- {py_hamt-3.3.0 → py_hamt-3.3.2}/uv.lock +21 -2
- py_hamt-3.3.0/.claude/agents/claude-planner.md +0 -35
- py_hamt-3.3.0/.claude/agents/consensus-compiler.md +0 -61
- py_hamt-3.3.0/.claude/agents/gemini-planner.md +0 -35
- py_hamt-3.3.0/.claude/agents/gpt-planner.md +0 -36
- py_hamt-3.3.0/.claude/commands/consensus.md +0 -61
- py_hamt-3.3.0/.claude/settings.local.json +0 -6
- py_hamt-3.3.0/.mcp.json +0 -13
- {py_hamt-3.3.0 → py_hamt-3.3.2}/.github/workflows/pages-main.yaml +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/.github/workflows/run-checks.yaml +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/.gitignore +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/.pre-commit-config.yaml +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/.python-version +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/AGENTS.md +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/Architecture.md +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/CLAUDE.md +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/LICENSE +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/README.md +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/fsgs.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/public_gateway_example.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/py_hamt/__init__.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/py_hamt/encryption_hamt_store.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/py_hamt/hamt_to_sharded_converter.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/py_hamt/reference/flat_zarr_store.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/pytest.ini +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/run-checks.sh +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/conftest.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_async.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_benchmark_stores.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_branch_anchors.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_converter.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_cpc_compare.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_extract_bits.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_hamt.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_kubo_cas.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_kubo_pin.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_kubocas_auth.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_kubocas_session.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_performance_tests.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_public_gateway.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_read_only_guards.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_sharded_store_grafting.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_zarr_ipfs.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_zarr_ipfs_encrypted.py +0 -0
- {py_hamt-3.3.0 → py_hamt-3.3.2}/tests/test_zarr_ipfs_partial.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
+
}
|