py_hamt
1from .encryption_hamt_store import SimpleEncryptedZarrHAMTStore 2from .hamt import HAMT, blake3_hashfn 3from .hamt_to_sharded_converter import convert_hamt_to_sharded, sharded_converter_cli 4from .sharded_zarr_store import ( 5 ShardedZarrStore, 6 ShardedZarrV1DeprecationWarning, 7 ShardReadMode, 8) 9from .store_httpx import ( 10 ContentAddressedStore, 11 GatewayContentMismatch, 12 GatewayContentUnverifiable, 13 InMemoryCAS, 14 KuboCAS, 15) 16from .zarr_hamt_store import ZarrHAMTStore 17 18__all__ = [ 19 "blake3_hashfn", 20 "HAMT", 21 "ContentAddressedStore", 22 "GatewayContentMismatch", 23 "GatewayContentUnverifiable", 24 "InMemoryCAS", 25 "KuboCAS", 26 "ZarrHAMTStore", 27 "SimpleEncryptedZarrHAMTStore", 28 "ShardedZarrStore", 29 "ShardReadMode", 30 "ShardedZarrV1DeprecationWarning", 31 "convert_hamt_to_sharded", 32 "sharded_converter_cli", 33]
54def blake3_hashfn(input_bytes: bytes) -> bytes: 55 """Return the HAMT's default raw 32-byte BLAKE3 digest.""" 56 return blake3(input_bytes).digest(length=32)
Return the HAMT's default raw 32-byte BLAKE3 digest.
361class HAMT: 362 """ 363 An implementation of a Hash Array Mapped Trie for an arbitrary Content Addressed Storage (CAS) system, e.g. IPFS. This uses the IPLD data model. 364 365 Use this to store arbitrarily large key-value mappings in your CAS of choice. 366 367 For writing, this HAMT is async safe but NOT thread safe. Only write in an async event loop within the same thread. 368 369 When in read-only mode, the HAMT is both async and thread safe. 370 371 #### A note about memory management, read+write and read-only modes 372 The HAMT can be in either read+write mode or read-only mode. For either of these modes, the HAMT has some internal performance optimizations. 373 374 Note that in read+write, the real root node id IS NOT VALID. You should call `make_read_only()` to convert to read only mode and then read `root_node_id`. 375 376 These optimizations also trade off performance for memory use. Use `cache_size` to monitor the approximate memory usage. Be warned that for large key-value mapping sets this may take a bit to run. Use `cache_vacate` if you are over your memory limits. 377 378 #### IPFS HAMT Sample Code 379 ```python 380 kubo_cas = KuboCAS() # connects to a local kubo node with the default endpoints 381 hamt = await HAMT.build(cas=kubo_cas) 382 await hamt.set("foo", "bar") 383 assert (await hamt.get("foo")) == "bar" 384 await hamt.make_read_only() 385 cid = hamt.root_node_id # our root node CID 386 print(cid) 387 ``` 388 """ 389 390 def __init__( 391 self, 392 cas: ContentAddressedStore, 393 hash_fn: Callable[[bytes], bytes] = blake3_hashfn, 394 root_node_id: IPLDKind | None = None, 395 read_only: bool = False, 396 max_bucket_size: int = 4, 397 values_are_bytes: bool = False, 398 ): 399 """ 400 Use `build` if you need to create a completely empty HAMT, as this requires some async operations with the CAS. For what each of the constructor input variables refer to, check the documentation with the matching names below. 401 """ 402 403 self.cas: ContentAddressedStore = cas 404 """The backing storage system. py-hamt provides an implementation `KuboCAS` for IPFS.""" 405 406 self.hash_fn: Callable[[bytes], bytes] = hash_fn 407 """ 408 This is the hash function used to place a key-value within the HAMT. 409 410 To provide your own hash function, create a function that takes in arbitrarily long bytes and returns the hash bytes. 411 412 It's important to note that the resulting hash must must always be a multiple of 8 bits since python bytes object can only represent in segments of bytes, and thus 8 bits. 413 414 Theoretically your hash size must only be a minimum of 1 byte, and there can be less than or the same number of hash collisions as the bucket size. Any more and the HAMT will most likely throw errors. 415 """ 416 417 self.lock: asyncio.Lock = asyncio.Lock() 418 """@private""" 419 420 self.values_are_bytes: bool = values_are_bytes 421 """Set this to true if you are only going to be storing python bytes objects into the hamt. This will improve performance by skipping a serialization step from IPLDKind. 422 423 This is theoretically safe to change in between operations, but this has not been verified in testing, so only do this at your own risk. 424 """ 425 426 if max_bucket_size < 1: 427 raise ValueError("Bucket size maximum must be a positive integer") 428 self.max_bucket_size: int = max_bucket_size 429 """ 430 This is only important for tuning performance when writing! For reading a HAMT that was written with a different max bucket size, this does not need to match and can be left unprovided. 431 432 This is an internal detail that has been exposed for performance tuning. The HAMT handles large key-value mapping sets even on a content addressed system by essentially sharding all the mappings across many smaller Nodes. The memory footprint of each of these Nodes footprint is a linear function of the maximum bucket size. Larger bucket sizes will result in larger Nodes, but more time taken to retrieve and decode these nodes from your backing CAS. 433 434 This must be a positive integer with a minimum of 1. 435 """ 436 437 self.root_node_id: IPLDKind = root_node_id 438 """ 439 This is type IPLDKind but the documentation generator pdoc mangles it a bit. 440 441 Read from this only when in read mode to get something valid! 442 """ 443 444 self.read_only: bool = read_only 445 """Clients should NOT modify this. 446 447 This is here for checking whether the HAMT is in read only or read/write mode. 448 449 The distinction is made for performance and correctness reasons. In read only mode, the HAMT has an internal read cache that can speed up operations. In read/write mode, for reads the HAMT maintains strong consistency for reads by using async locks, and for writes the HAMT writes to an in memory buffer rather than performing (possibly) network calls to the underlying CAS. 450 """ 451 self.node_store: NodeStore 452 """@private""" 453 if read_only: 454 self.node_store = ReadCacheStore(self) 455 else: 456 self.node_store = InMemoryTreeStore(self) 457 458 @classmethod 459 async def build(cls, *args: Any, **kwargs: Any) -> "HAMT": 460 """ 461 Use this if you are initializing a completely empty HAMT! That means passing in None for the root_node_id. Method arguments are the exact same as `__init__`. If the root_node_id is not None, this will have no difference than creating a HAMT instance with __init__. 462 463 This separate async method is required since initializing an empty HAMT means sending some internal objects to the underlying CAS, which requires async operations. python does not allow for an async __init__, so this method is separately provided. 464 """ 465 hamt = cls(*args, **kwargs) 466 if hamt.root_node_id is None: 467 hamt.root_node_id = await hamt.node_store.save(None, Node()) 468 return hamt 469 470 # This is typically a massive blocking operation, you dont want to be running this concurrently with a bunch of other operations, so it's ok to have it not be async 471 async def make_read_only(self) -> None: 472 """ 473 Makes the HAMT read only, which allows for more parallel read operations. The HAMT also needs to be in read only mode to get the real root node ID. 474 475 In read+write mode, the HAMT normally has to block separate get calls to enable strong consistency in case a set/delete operation falls in between. 476 """ 477 async with self.lock: 478 inmemory_tree: InMemoryTreeStore = cast(InMemoryTreeStore, self.node_store) 479 await inmemory_tree.vacate() 480 481 self.read_only = True 482 self.node_store = ReadCacheStore(self) 483 484 async def enable_write(self) -> None: 485 """ 486 Enable both reads and writes. Calling this while writes are already enabled is a no-op that preserves any buffered changes. The read-only to writable transition creates an internal structure for performance optimizations which will result in the root node ID no longer being valid; to read it at the end of your operations, first use `make_read_only`. 487 """ 488 async with self.lock: 489 if not self.read_only: 490 return 491 492 # The read cache has no writes that need to be sent upstream so we can remove it without vacating 493 self.read_only = False 494 self.node_store = InMemoryTreeStore(self) 495 496 async def cache_size(self) -> int: 497 """ 498 Returns the memory used by some internal performance optimization tools in bytes. 499 500 This is async concurrency safe, so call it whenever. This does mean it will block and wait for other writes to finish however. 501 502 Be warned that this may take a while to run for large HAMTs. 503 504 For more on memory management, see the `HAMT` class documentation. 505 """ 506 if self.read_only: 507 return self.node_store.size() 508 async with self.lock: 509 return self.node_store.size() 510 511 async def cache_vacate(self) -> None: 512 """ 513 Vacate and completely empty out the internal read/write cache. 514 515 Be warned that this may take a while if there have been a lot of write operations. 516 517 For more on memory management, see the `HAMT` class documentation. 518 """ 519 if self.read_only: 520 await self.node_store.vacate() 521 else: 522 async with self.lock: 523 await self.node_store.vacate() 524 525 async def _reserialize_and_link( 526 self, 527 node_stack: list[tuple[IPLDKind, Node]], 528 link_path: list[int], 529 ) -> None: 530 """ 531 This function starts from the node at the end of the list and reserializes so that each node holds valid new IDs after insertion into the store 532 Takes a stack of nodes, we represent a stack with a list where the first element is the root element and the last element is the top of the stack 533 Each element in the list is a tuple where the first element is the ID from the store and the second element is the Node in python 534 `link_path[i]` is the index in `node_stack[i - 1]` through which 535 `node_stack[i]` is linked. The root entry at index zero is a sentinel. 536 If a node ends up being empty, then it is deleted entirely, unless it is the root node 537 Modifies in place 538 """ 539 # iterate in the reverse direction, this range goes from n-1 to 0, from the bottommost tree node to the root 540 for stack_index in range(len(node_stack) - 1, -1, -1): 541 old_id, node = node_stack[stack_index] 542 543 # If this node is empty, and it's not the root node, then we can delete it entirely from the list 544 is_root: bool = stack_index == 0 545 if node.is_empty() and not is_root: 546 # Unlink from the rest of the tree using the recorded parent slot. 547 _, prev_node = node_stack[stack_index - 1] 548 prev_node.data[link_path[stack_index]] = {} 549 550 # Remove from our stack, continue reserializing up the tree 551 node_stack.pop(stack_index) 552 link_path.pop(stack_index) 553 continue 554 555 # If not an empty node, just reserialize like normal and replace this one 556 new_store_id: IPLDKind = await self.node_store.save(old_id, node) 557 node_stack[stack_index] = (new_store_id, node) 558 559 # If this is not the last i.e. root node, we need to change the linking of the node prior in the list since we just reserialized 560 if not is_root: 561 _, prev_node = node_stack[stack_index - 1] 562 prev_node.set_link(link_path[stack_index], new_store_id) 563 564 async def _collect_subtree_entries( 565 self, node: Node, limit: int 566 ) -> dict[str, IPLDKind] | None: 567 """Collect a subtree's entries when they fit in a single bucket.""" 568 entries: dict[str, IPLDKind] = {} 569 570 for bucket in node.iter_buckets(): 571 if len(entries) + len(bucket) > limit: 572 return None 573 entries.update(bucket) 574 575 for link in node.iter_links(): 576 child = await self.node_store.load(link) 577 child_entries = await self._collect_subtree_entries( 578 child, limit - len(entries) 579 ) 580 if child_entries is None: 581 return None 582 entries.update(child_entries) 583 584 return entries 585 586 async def _collapse_delete_path( 587 self, 588 node_stack: list[tuple[IPLDKind, Node]], 589 link_path: list[int], 590 ) -> None: 591 """Collapse small subtrees into parent buckets after a deletion. 592 593 Applying this bottom-up restores the same shape produced by a fresh build: 594 every linked subtree contains more entries than ``max_bucket_size``. This 595 can change CIDs only for trees that previously retained a non-canonical 596 post-delete shape. 597 """ 598 node_store = cast(InMemoryTreeStore, self.node_store) 599 for stack_index in range(len(node_stack) - 1, 0, -1): 600 old_id, node = node_stack[stack_index] 601 entries = await self._collect_subtree_entries(node, self.max_bucket_size) 602 if entries is None: 603 continue 604 605 _, parent = node_stack[stack_index - 1] 606 parent.data[link_path[stack_index]] = entries 607 node_store.remove_clean_node(old_id) 608 node_stack.pop(stack_index) 609 link_path.pop(stack_index) 610 611 # automatically skip encoding if the value provided is of the bytes variety 612 async def set(self, key: str, val: IPLDKind) -> None: 613 """Write a key-value mapping.""" 614 if self.read_only: 615 raise Exception("Cannot call set on a read only HAMT") 616 617 data: bytes 618 if self.values_are_bytes: 619 data = cast( 620 bytes, val 621 ) # let users get an exception if they pass in a non bytes when they want to skip encoding 622 else: 623 data = dag_cbor.encode(val) 624 625 pointer: IPLDKind = await self.cas.save(data, codec="raw") 626 await self._set_pointer(key, pointer) 627 628 async def _build_overflow_subtree( 629 self, 630 kvs_queue: list[tuple[str, IPLDKind, bytes]], 631 depth: int, 632 key_hashes: dict[str, bytes], 633 ) -> IPLDKind: 634 """Build a detached subtree for a full bucket. 635 636 Every node touched here is new to the current write. Hash exhaustion can 637 therefore fail without exposing a partial mutation in the existing tree. 638 """ 639 root_node = Node() 640 root_node_id = await self.node_store.save(None, root_node) 641 node_stack: list[tuple[IPLDKind, Node]] = [(root_node_id, root_node)] 642 643 while kvs_queue: 644 _, top_node = node_stack[-1] 645 curr_key, curr_val_ptr, raw_hash = kvs_queue[0] 646 map_key = extract_bits(raw_hash, depth + len(node_stack) - 1, 8) 647 648 item = top_node.data[map_key] 649 if isinstance(item, list): 650 next_node_id = item[0] 651 next_node = await self.node_store.load(next_node_id) 652 node_stack.append((next_node_id, next_node)) 653 continue 654 655 bucket = cast(dict[str, IPLDKind], item) 656 if curr_key in bucket or len(bucket) < self.max_bucket_size: 657 bucket[curr_key] = curr_val_ptr 658 kvs_queue.pop(0) 659 continue 660 661 for bucket_key, bucket_value in bucket.items(): 662 kvs_queue.append((bucket_key, bucket_value, key_hashes[bucket_key])) 663 664 new_node = Node() 665 new_node_id = await self.node_store.save(None, new_node) 666 top_node.set_link(map_key, new_node_id) 667 668 return root_node_id 669 670 async def _set_pointer(self, key: str, val_ptr: IPLDKind) -> None: 671 """Set a value pointer without exposing partial tree mutations on failure.""" 672 async with self.lock: 673 node_store = cast(InMemoryTreeStore, self.node_store) 674 node_stack: list[tuple[IPLDKind, Node]] = [] 675 link_path: list[int] = [-1] 676 root_node: Node = await self.node_store.load(self.root_node_id) 677 node_stack.append((self.root_node_id, root_node)) 678 679 raw_hash = self.hash_fn(key.encode()) 680 while True: 681 _, top_node = node_stack[-1] 682 map_key = extract_bits(raw_hash, len(node_stack) - 1, 8) 683 684 item = top_node.data[map_key] 685 if isinstance(item, list): 686 next_node_id = item[0] 687 next_node = await self.node_store.load(next_node_id) 688 node_stack.append((next_node_id, next_node)) 689 link_path.append(map_key) 690 continue 691 692 bucket = cast(dict[str, IPLDKind], item) 693 if key in bucket or len(bucket) < self.max_bucket_size: 694 bucket[key] = val_ptr 695 break 696 697 key_hashes = {key: raw_hash} 698 kvs_queue = [(key, val_ptr, raw_hash)] 699 for bucket_key, bucket_value in bucket.items(): 700 key_hashes[bucket_key] = self.hash_fn(bucket_key.encode()) 701 kvs_queue.append((bucket_key, bucket_value, key_hashes[bucket_key])) 702 703 original_buffer_ids = set(node_store.buffer) 704 try: 705 new_node_id = await self._build_overflow_subtree( 706 kvs_queue, len(node_stack), key_hashes 707 ) 708 except BaseException: 709 for buffer_id in set(node_store.buffer) - original_buffer_ids: 710 del node_store.buffer[buffer_id] 711 raise 712 713 top_node.set_link(map_key, new_node_id) 714 break 715 716 # Finally, reserialize and fix all links. 717 await self._reserialize_and_link(node_stack, link_path) 718 self.root_node_id = node_stack[0][0] 719 720 async def delete(self, key: str) -> None: 721 """Delete a key-value mapping. 722 723 Failure-atomic with respect to storage errors: all fallible CAS loads 724 happen before any node is mutated, so if deletion raises, the 725 observable tree remains unchanged. 726 """ 727 728 # Also deletes the pointer at the same time so this doesn't have a _delete_pointer duo 729 if self.read_only: 730 raise Exception("Cannot call delete on a read only HAMT") 731 732 async with self.lock: 733 raw_hash: bytes = self.hash_fn(key.encode()) 734 735 node_stack: list[tuple[IPLDKind, Node]] = [] 736 link_path: list[int] = [-1] 737 root_node: Node = await self.node_store.load(self.root_node_id) 738 node_stack.append((self.root_node_id, root_node)) 739 740 created_change: bool = False 741 while True: 742 _, top_node = node_stack[-1] 743 map_key: int = extract_bits(raw_hash, len(node_stack) - 1, 8) 744 745 item = top_node.data[map_key] 746 if isinstance(item, dict): 747 bucket = item 748 if key in bucket: 749 # Collapse may inspect sibling subtrees after the bucket is 750 # changed. Load them first so a CAS failure cannot leave a 751 # shared cached node partially mutated. The pre-delete tree 752 # has one extra entry along this path, hence the +1 budget. 753 for _, path_node in node_stack[1:]: 754 await self._collect_subtree_entries( 755 path_node, self.max_bucket_size + 1 756 ) 757 del bucket[key] 758 created_change = True 759 # Break out since whether or not the key is in the bucket, it should have been here so either now reserialize or raise a KeyError 760 break 761 elif isinstance(item, list): 762 link: IPLDKind = item[0] 763 next_node: Node = await self.node_store.load(link) 764 node_stack.append((link, next_node)) 765 link_path.append(map_key) 766 767 # Finally, restore the canonical shape and fix all remaining links. 768 if created_change: 769 await self._collapse_delete_path(node_stack, link_path) 770 await self._reserialize_and_link(node_stack, link_path) 771 self.root_node_id = node_stack[0][0] 772 else: 773 # If we didn't make a change, then this key must not exist within the HAMT 774 raise KeyError 775 776 async def get( 777 self, 778 key: str, 779 offset: Optional[int] = None, 780 length: Optional[int] = None, 781 suffix: Optional[int] = None, 782 ) -> IPLDKind: 783 """Get a value.""" 784 pointer: IPLDKind = await self.get_pointer(key) 785 data: bytes = await self.cas.load( 786 pointer, offset=offset, length=length, suffix=suffix 787 ) 788 if self.values_are_bytes: 789 return data 790 else: 791 return dag_cbor.decode(data) 792 793 async def get_pointer(self, key: str) -> IPLDKind: 794 """ 795 Get a store ID that points to the value for this key. 796 797 This is useful for some applications that want to implement a read cache. Due to the restrictions of `ContentAddressedStore` on IDs, pointers are regarded as immutable by python so they can be easily used as IDs for read caches. This is utilized in `ZarrHAMTStore` for example. 798 """ 799 # If read only, no need to acquire a lock 800 pointer: IPLDKind 801 if self.read_only: 802 pointer = await self._get_pointer(key) 803 else: 804 async with self.lock: 805 pointer = await self._get_pointer(key) 806 807 return pointer 808 809 # Callers MUST handle acquiring a lock 810 async def _get_pointer(self, key: str) -> IPLDKind: 811 with instrumentation.span( 812 "py_hamt.hamt.lookup", {"py_hamt.hamt.lookup.key": key} 813 ): 814 lookup_started_at = time.perf_counter() 815 raw_hash: bytes = self.hash_fn(key.encode()) 816 817 current_id: IPLDKind = self.root_node_id 818 current_depth: int = 0 819 node_loads = 0 820 node_cache_hits = 0 821 822 # 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 823 result_ptr: IPLDKind = None 824 found_a_result: bool = False 825 try: 826 while True: 827 top_id: IPLDKind = current_id 828 if ( 829 isinstance(self.node_store, ReadCacheStore) 830 and top_id in self.node_store.cache 831 ): 832 node_cache_hits += 1 833 node_loads += 1 834 top_node: Node = await self.node_store.load(top_id) 835 map_key: int = extract_bits(raw_hash, current_depth, 8) 836 837 # Check if this key is in one of the buckets 838 item = top_node.data[map_key] 839 if isinstance(item, dict): 840 bucket = item 841 if key in bucket: 842 result_ptr = bucket[key] 843 found_a_result = True 844 break 845 846 if isinstance(item, list): 847 link: IPLDKind = item[0] 848 current_id = link 849 current_depth += 1 850 continue 851 852 # Nowhere left to go, stop walking down the tree 853 break 854 855 if not found_a_result: 856 raise KeyError 857 858 return result_ptr 859 finally: 860 instrumentation.record_hamt_lookup( 861 key, 862 depth=current_depth, 863 node_loads=node_loads, 864 node_cache_hits=node_cache_hits, 865 found=found_a_result, 866 seconds=time.perf_counter() - lookup_started_at, 867 ) 868 869 # Callers MUST handle locking or not on their own 870 async def _iter_nodes(self) -> AsyncIterator[tuple[IPLDKind, Node]]: 871 node_id_stack: list[IPLDKind] = [self.root_node_id] 872 while len(node_id_stack) > 0: 873 top_id: IPLDKind = node_id_stack.pop() 874 node: Node = await self.node_store.load(top_id) 875 yield (top_id, node) 876 node_id_stack.extend(list(node.iter_links())) 877 878 async def keys(self) -> AsyncIterator[str]: 879 """ 880 AsyncIterator returning all keys in the HAMT. 881 882 If the HAMT is write enabled, the keys present when iteration starts are 883 copied while holding the async lock. The lock is released before any key 884 is yielded, so reads and mutations are safe between iterations and do not 885 affect the keys returned by an iteration already in progress. 886 887 When the HAMT is in read only mode however, this can be run concurrently with get operations. 888 """ 889 if self.read_only: 890 async for k in self._keys_no_locking(): 891 yield k 892 else: 893 # Buffered nodes are mutable, so copying only the root ID would not 894 # isolate iteration from mutations made after a caller-visible yield. 895 async with self.lock: 896 keys_snapshot = [key async for key in self._keys_no_locking()] 897 for key in keys_snapshot: 898 yield key 899 900 async def _keys_no_locking(self) -> AsyncIterator[str]: 901 async for _, node in self._iter_nodes(): 902 for bucket in node.iter_buckets(): 903 for key in bucket: 904 yield key 905 906 async def len(self) -> int: 907 """ 908 Return the number of key value mappings in this HAMT. 909 910 When the HAMT is write enabled, keys are counted directly while holding 911 the async lock, without materializing a snapshot. If read only, counting 912 can run concurrently with other operations. 913 """ 914 count: int = 0 915 if self.read_only: 916 async for _ in self._keys_no_locking(): 917 count += 1 918 else: 919 async with self.lock: 920 async for _ in self._keys_no_locking(): 921 count += 1 922 923 return count
An implementation of a Hash Array Mapped Trie for an arbitrary Content Addressed Storage (CAS) system, e.g. IPFS. This uses the IPLD data model.
Use this to store arbitrarily large key-value mappings in your CAS of choice.
For writing, this HAMT is async safe but NOT thread safe. Only write in an async event loop within the same thread.
When in read-only mode, the HAMT is both async and thread safe.
A note about memory management, read+write and read-only modes
The HAMT can be in either read+write mode or read-only mode. For either of these modes, the HAMT has some internal performance optimizations.
Note that in read+write, the real root node id IS NOT VALID. You should call make_read_only() to convert to read only mode and then read root_node_id.
These optimizations also trade off performance for memory use. Use cache_size to monitor the approximate memory usage. Be warned that for large key-value mapping sets this may take a bit to run. Use cache_vacate if you are over your memory limits.
IPFS HAMT Sample Code
kubo_cas = KuboCAS() # connects to a local kubo node with the default endpoints
hamt = await HAMT.build(cas=kubo_cas)
await hamt.set("foo", "bar")
assert (await hamt.get("foo")) == "bar"
await hamt.make_read_only()
cid = hamt.root_node_id # our root node CID
print(cid)
390 def __init__( 391 self, 392 cas: ContentAddressedStore, 393 hash_fn: Callable[[bytes], bytes] = blake3_hashfn, 394 root_node_id: IPLDKind | None = None, 395 read_only: bool = False, 396 max_bucket_size: int = 4, 397 values_are_bytes: bool = False, 398 ): 399 """ 400 Use `build` if you need to create a completely empty HAMT, as this requires some async operations with the CAS. For what each of the constructor input variables refer to, check the documentation with the matching names below. 401 """ 402 403 self.cas: ContentAddressedStore = cas 404 """The backing storage system. py-hamt provides an implementation `KuboCAS` for IPFS.""" 405 406 self.hash_fn: Callable[[bytes], bytes] = hash_fn 407 """ 408 This is the hash function used to place a key-value within the HAMT. 409 410 To provide your own hash function, create a function that takes in arbitrarily long bytes and returns the hash bytes. 411 412 It's important to note that the resulting hash must must always be a multiple of 8 bits since python bytes object can only represent in segments of bytes, and thus 8 bits. 413 414 Theoretically your hash size must only be a minimum of 1 byte, and there can be less than or the same number of hash collisions as the bucket size. Any more and the HAMT will most likely throw errors. 415 """ 416 417 self.lock: asyncio.Lock = asyncio.Lock() 418 """@private""" 419 420 self.values_are_bytes: bool = values_are_bytes 421 """Set this to true if you are only going to be storing python bytes objects into the hamt. This will improve performance by skipping a serialization step from IPLDKind. 422 423 This is theoretically safe to change in between operations, but this has not been verified in testing, so only do this at your own risk. 424 """ 425 426 if max_bucket_size < 1: 427 raise ValueError("Bucket size maximum must be a positive integer") 428 self.max_bucket_size: int = max_bucket_size 429 """ 430 This is only important for tuning performance when writing! For reading a HAMT that was written with a different max bucket size, this does not need to match and can be left unprovided. 431 432 This is an internal detail that has been exposed for performance tuning. The HAMT handles large key-value mapping sets even on a content addressed system by essentially sharding all the mappings across many smaller Nodes. The memory footprint of each of these Nodes footprint is a linear function of the maximum bucket size. Larger bucket sizes will result in larger Nodes, but more time taken to retrieve and decode these nodes from your backing CAS. 433 434 This must be a positive integer with a minimum of 1. 435 """ 436 437 self.root_node_id: IPLDKind = root_node_id 438 """ 439 This is type IPLDKind but the documentation generator pdoc mangles it a bit. 440 441 Read from this only when in read mode to get something valid! 442 """ 443 444 self.read_only: bool = read_only 445 """Clients should NOT modify this. 446 447 This is here for checking whether the HAMT is in read only or read/write mode. 448 449 The distinction is made for performance and correctness reasons. In read only mode, the HAMT has an internal read cache that can speed up operations. In read/write mode, for reads the HAMT maintains strong consistency for reads by using async locks, and for writes the HAMT writes to an in memory buffer rather than performing (possibly) network calls to the underlying CAS. 450 """ 451 self.node_store: NodeStore 452 """@private""" 453 if read_only: 454 self.node_store = ReadCacheStore(self) 455 else: 456 self.node_store = InMemoryTreeStore(self)
Use build if you need to create a completely empty HAMT, as this requires some async operations with the CAS. For what each of the constructor input variables refer to, check the documentation with the matching names below.
The backing storage system. py-hamt provides an implementation KuboCAS for IPFS.
This is the hash function used to place a key-value within the HAMT.
To provide your own hash function, create a function that takes in arbitrarily long bytes and returns the hash bytes.
It's important to note that the resulting hash must must always be a multiple of 8 bits since python bytes object can only represent in segments of bytes, and thus 8 bits.
Theoretically your hash size must only be a minimum of 1 byte, and there can be less than or the same number of hash collisions as the bucket size. Any more and the HAMT will most likely throw errors.
Set this to true if you are only going to be storing python bytes objects into the hamt. This will improve performance by skipping a serialization step from IPLDKind.
This is theoretically safe to change in between operations, but this has not been verified in testing, so only do this at your own risk.
This is only important for tuning performance when writing! For reading a HAMT that was written with a different max bucket size, this does not need to match and can be left unprovided.
This is an internal detail that has been exposed for performance tuning. The HAMT handles large key-value mapping sets even on a content addressed system by essentially sharding all the mappings across many smaller Nodes. The memory footprint of each of these Nodes footprint is a linear function of the maximum bucket size. Larger bucket sizes will result in larger Nodes, but more time taken to retrieve and decode these nodes from your backing CAS.
This must be a positive integer with a minimum of 1.
This is type IPLDKind but the documentation generator pdoc mangles it a bit.
Read from this only when in read mode to get something valid!
Clients should NOT modify this.
This is here for checking whether the HAMT is in read only or read/write mode.
The distinction is made for performance and correctness reasons. In read only mode, the HAMT has an internal read cache that can speed up operations. In read/write mode, for reads the HAMT maintains strong consistency for reads by using async locks, and for writes the HAMT writes to an in memory buffer rather than performing (possibly) network calls to the underlying CAS.
458 @classmethod 459 async def build(cls, *args: Any, **kwargs: Any) -> "HAMT": 460 """ 461 Use this if you are initializing a completely empty HAMT! That means passing in None for the root_node_id. Method arguments are the exact same as `__init__`. If the root_node_id is not None, this will have no difference than creating a HAMT instance with __init__. 462 463 This separate async method is required since initializing an empty HAMT means sending some internal objects to the underlying CAS, which requires async operations. python does not allow for an async __init__, so this method is separately provided. 464 """ 465 hamt = cls(*args, **kwargs) 466 if hamt.root_node_id is None: 467 hamt.root_node_id = await hamt.node_store.save(None, Node()) 468 return hamt
Use this if you are initializing a completely empty HAMT! That means passing in None for the root_node_id. Method arguments are the exact same as __init__. If the root_node_id is not None, this will have no difference than creating a HAMT instance with __init__.
This separate async method is required since initializing an empty HAMT means sending some internal objects to the underlying CAS, which requires async operations. python does not allow for an async __init__, so this method is separately provided.
471 async def make_read_only(self) -> None: 472 """ 473 Makes the HAMT read only, which allows for more parallel read operations. The HAMT also needs to be in read only mode to get the real root node ID. 474 475 In read+write mode, the HAMT normally has to block separate get calls to enable strong consistency in case a set/delete operation falls in between. 476 """ 477 async with self.lock: 478 inmemory_tree: InMemoryTreeStore = cast(InMemoryTreeStore, self.node_store) 479 await inmemory_tree.vacate() 480 481 self.read_only = True 482 self.node_store = ReadCacheStore(self)
Makes the HAMT read only, which allows for more parallel read operations. The HAMT also needs to be in read only mode to get the real root node ID.
In read+write mode, the HAMT normally has to block separate get calls to enable strong consistency in case a set/delete operation falls in between.
484 async def enable_write(self) -> None: 485 """ 486 Enable both reads and writes. Calling this while writes are already enabled is a no-op that preserves any buffered changes. The read-only to writable transition creates an internal structure for performance optimizations which will result in the root node ID no longer being valid; to read it at the end of your operations, first use `make_read_only`. 487 """ 488 async with self.lock: 489 if not self.read_only: 490 return 491 492 # The read cache has no writes that need to be sent upstream so we can remove it without vacating 493 self.read_only = False 494 self.node_store = InMemoryTreeStore(self)
Enable both reads and writes. Calling this while writes are already enabled is a no-op that preserves any buffered changes. The read-only to writable transition creates an internal structure for performance optimizations which will result in the root node ID no longer being valid; to read it at the end of your operations, first use make_read_only.
496 async def cache_size(self) -> int: 497 """ 498 Returns the memory used by some internal performance optimization tools in bytes. 499 500 This is async concurrency safe, so call it whenever. This does mean it will block and wait for other writes to finish however. 501 502 Be warned that this may take a while to run for large HAMTs. 503 504 For more on memory management, see the `HAMT` class documentation. 505 """ 506 if self.read_only: 507 return self.node_store.size() 508 async with self.lock: 509 return self.node_store.size()
Returns the memory used by some internal performance optimization tools in bytes.
This is async concurrency safe, so call it whenever. This does mean it will block and wait for other writes to finish however.
Be warned that this may take a while to run for large HAMTs.
For more on memory management, see the HAMT class documentation.
511 async def cache_vacate(self) -> None: 512 """ 513 Vacate and completely empty out the internal read/write cache. 514 515 Be warned that this may take a while if there have been a lot of write operations. 516 517 For more on memory management, see the `HAMT` class documentation. 518 """ 519 if self.read_only: 520 await self.node_store.vacate() 521 else: 522 async with self.lock: 523 await self.node_store.vacate()
Vacate and completely empty out the internal read/write cache.
Be warned that this may take a while if there have been a lot of write operations.
For more on memory management, see the HAMT class documentation.
612 async def set(self, key: str, val: IPLDKind) -> None: 613 """Write a key-value mapping.""" 614 if self.read_only: 615 raise Exception("Cannot call set on a read only HAMT") 616 617 data: bytes 618 if self.values_are_bytes: 619 data = cast( 620 bytes, val 621 ) # let users get an exception if they pass in a non bytes when they want to skip encoding 622 else: 623 data = dag_cbor.encode(val) 624 625 pointer: IPLDKind = await self.cas.save(data, codec="raw") 626 await self._set_pointer(key, pointer)
Write a key-value mapping.
720 async def delete(self, key: str) -> None: 721 """Delete a key-value mapping. 722 723 Failure-atomic with respect to storage errors: all fallible CAS loads 724 happen before any node is mutated, so if deletion raises, the 725 observable tree remains unchanged. 726 """ 727 728 # Also deletes the pointer at the same time so this doesn't have a _delete_pointer duo 729 if self.read_only: 730 raise Exception("Cannot call delete on a read only HAMT") 731 732 async with self.lock: 733 raw_hash: bytes = self.hash_fn(key.encode()) 734 735 node_stack: list[tuple[IPLDKind, Node]] = [] 736 link_path: list[int] = [-1] 737 root_node: Node = await self.node_store.load(self.root_node_id) 738 node_stack.append((self.root_node_id, root_node)) 739 740 created_change: bool = False 741 while True: 742 _, top_node = node_stack[-1] 743 map_key: int = extract_bits(raw_hash, len(node_stack) - 1, 8) 744 745 item = top_node.data[map_key] 746 if isinstance(item, dict): 747 bucket = item 748 if key in bucket: 749 # Collapse may inspect sibling subtrees after the bucket is 750 # changed. Load them first so a CAS failure cannot leave a 751 # shared cached node partially mutated. The pre-delete tree 752 # has one extra entry along this path, hence the +1 budget. 753 for _, path_node in node_stack[1:]: 754 await self._collect_subtree_entries( 755 path_node, self.max_bucket_size + 1 756 ) 757 del bucket[key] 758 created_change = True 759 # Break out since whether or not the key is in the bucket, it should have been here so either now reserialize or raise a KeyError 760 break 761 elif isinstance(item, list): 762 link: IPLDKind = item[0] 763 next_node: Node = await self.node_store.load(link) 764 node_stack.append((link, next_node)) 765 link_path.append(map_key) 766 767 # Finally, restore the canonical shape and fix all remaining links. 768 if created_change: 769 await self._collapse_delete_path(node_stack, link_path) 770 await self._reserialize_and_link(node_stack, link_path) 771 self.root_node_id = node_stack[0][0] 772 else: 773 # If we didn't make a change, then this key must not exist within the HAMT 774 raise KeyError
Delete a key-value mapping.
Failure-atomic with respect to storage errors: all fallible CAS loads happen before any node is mutated, so if deletion raises, the observable tree remains unchanged.
776 async def get( 777 self, 778 key: str, 779 offset: Optional[int] = None, 780 length: Optional[int] = None, 781 suffix: Optional[int] = None, 782 ) -> IPLDKind: 783 """Get a value.""" 784 pointer: IPLDKind = await self.get_pointer(key) 785 data: bytes = await self.cas.load( 786 pointer, offset=offset, length=length, suffix=suffix 787 ) 788 if self.values_are_bytes: 789 return data 790 else: 791 return dag_cbor.decode(data)
Get a value.
793 async def get_pointer(self, key: str) -> IPLDKind: 794 """ 795 Get a store ID that points to the value for this key. 796 797 This is useful for some applications that want to implement a read cache. Due to the restrictions of `ContentAddressedStore` on IDs, pointers are regarded as immutable by python so they can be easily used as IDs for read caches. This is utilized in `ZarrHAMTStore` for example. 798 """ 799 # If read only, no need to acquire a lock 800 pointer: IPLDKind 801 if self.read_only: 802 pointer = await self._get_pointer(key) 803 else: 804 async with self.lock: 805 pointer = await self._get_pointer(key) 806 807 return pointer
Get a store ID that points to the value for this key.
This is useful for some applications that want to implement a read cache. Due to the restrictions of ContentAddressedStore on IDs, pointers are regarded as immutable by python so they can be easily used as IDs for read caches. This is utilized in ZarrHAMTStore for example.
878 async def keys(self) -> AsyncIterator[str]: 879 """ 880 AsyncIterator returning all keys in the HAMT. 881 882 If the HAMT is write enabled, the keys present when iteration starts are 883 copied while holding the async lock. The lock is released before any key 884 is yielded, so reads and mutations are safe between iterations and do not 885 affect the keys returned by an iteration already in progress. 886 887 When the HAMT is in read only mode however, this can be run concurrently with get operations. 888 """ 889 if self.read_only: 890 async for k in self._keys_no_locking(): 891 yield k 892 else: 893 # Buffered nodes are mutable, so copying only the root ID would not 894 # isolate iteration from mutations made after a caller-visible yield. 895 async with self.lock: 896 keys_snapshot = [key async for key in self._keys_no_locking()] 897 for key in keys_snapshot: 898 yield key
AsyncIterator returning all keys in the HAMT.
If the HAMT is write enabled, the keys present when iteration starts are copied while holding the async lock. The lock is released before any key is yielded, so reads and mutations are safe between iterations and do not affect the keys returned by an iteration already in progress.
When the HAMT is in read only mode however, this can be run concurrently with get operations.
906 async def len(self) -> int: 907 """ 908 Return the number of key value mappings in this HAMT. 909 910 When the HAMT is write enabled, keys are counted directly while holding 911 the async lock, without materializing a snapshot. If read only, counting 912 can run concurrently with other operations. 913 """ 914 count: int = 0 915 if self.read_only: 916 async for _ in self._keys_no_locking(): 917 count += 1 918 else: 919 async with self.lock: 920 async for _ in self._keys_no_locking(): 921 count += 1 922 923 return count
Return the number of key value mappings in this HAMT.
When the HAMT is write enabled, keys are counted directly while holding the async lock, without materializing a snapshot. If read only, counting can run concurrently with other operations.
395class ContentAddressedStore(ABC): 396 """ 397 Abstract class that represents a content addressed storage that the `HAMT` can use for keeping data. 398 399 Note that the return type of save and input to load is really type `IPLDKind`, but the documentation generator pdoc mangles it unfortunately. 400 401 #### A note on the IPLDKind return types 402 Save and load return the type IPLDKind and not just a CID. As long as python regards the underlying type as immutable it can be used, allowing for more flexibility. There are two exceptions: 403 1. No lists or dicts, since python does not classify these as immutable. 404 2. No `None` values since this is used in HAMT's `__init__` to indicate that an empty HAMT needs to be initialized. 405 """ 406 407 CodecInput = Literal["raw", "dag-cbor"] 408 409 @abstractmethod 410 async def save(self, data: bytes, codec: CodecInput) -> IPLDKind: 411 """Save data to a storage mechanism, and return an ID for the data in the IPLDKind type. 412 413 `codec` will be set to "dag-cbor" if this data should be marked as special linked data a la IPLD data model. 414 """ 415 416 @abstractmethod 417 async def load( 418 self, 419 id: IPLDKind, 420 offset: Optional[int] = None, 421 length: Optional[int] = None, 422 suffix: Optional[int] = None, 423 ) -> bytes: 424 """Retrieve data.""" 425 426 async def pin_cid(self, id: IPLDKind, target_rpc: str) -> None: 427 """Pin a CID in the storage.""" 428 pass # pragma: no cover 429 430 async def unpin_cid(self, id: IPLDKind, target_rpc: str) -> None: 431 """Unpin a CID in the storage.""" 432 pass # pragma: no cover 433 434 async def pin_update( 435 self, old_id: IPLDKind, new_id: IPLDKind, target_rpc: str 436 ) -> None: 437 """Update the pinned CID in the storage.""" 438 pass # pragma: no cover 439 440 async def pin_ls(self, target_rpc: str) -> list[Dict[str, Any]]: 441 """List all pinned CIDs in the storage.""" 442 return [] # pragma: no cover
Abstract class that represents a content addressed storage that the HAMT can use for keeping data.
Note that the return type of save and input to load is really type IPLDKind, but the documentation generator pdoc mangles it unfortunately.
A note on the IPLDKind return types
Save and load return the type IPLDKind and not just a CID. As long as python regards the underlying type as immutable it can be used, allowing for more flexibility. There are two exceptions:
- No lists or dicts, since python does not classify these as immutable.
- No
Nonevalues since this is used in HAMT's__init__to indicate that an empty HAMT needs to be initialized.
409 @abstractmethod 410 async def save(self, data: bytes, codec: CodecInput) -> IPLDKind: 411 """Save data to a storage mechanism, and return an ID for the data in the IPLDKind type. 412 413 `codec` will be set to "dag-cbor" if this data should be marked as special linked data a la IPLD data model. 414 """
Save data to a storage mechanism, and return an ID for the data in the IPLDKind type.
codec will be set to "dag-cbor" if this data should be marked as special linked data a la IPLD data model.
416 @abstractmethod 417 async def load( 418 self, 419 id: IPLDKind, 420 offset: Optional[int] = None, 421 length: Optional[int] = None, 422 suffix: Optional[int] = None, 423 ) -> bytes: 424 """Retrieve data."""
Retrieve data.
426 async def pin_cid(self, id: IPLDKind, target_rpc: str) -> None: 427 """Pin a CID in the storage.""" 428 pass # pragma: no cover
Pin a CID in the storage.
430 async def unpin_cid(self, id: IPLDKind, target_rpc: str) -> None: 431 """Unpin a CID in the storage.""" 432 pass # pragma: no cover
Unpin a CID in the storage.
434 async def pin_update( 435 self, old_id: IPLDKind, new_id: IPLDKind, target_rpc: str 436 ) -> None: 437 """Update the pinned CID in the storage.""" 438 pass # pragma: no cover
Update the pinned CID in the storage.
199class GatewayContentMismatch(Exception): 200 """A gateway returned bytes that do not hash to the requested CID. 201 202 Raised only when content verification is enabled. Treated as a per-gateway 203 failure, so a multi-gateway ``KuboCAS`` fails over to the next gateway 204 rather than returning corrupt data to the caller. 205 """
A gateway returned bytes that do not hash to the requested CID.
Raised only when content verification is enabled. Treated as a per-gateway
failure, so a multi-gateway KuboCAS fails over to the next gateway
rather than returning corrupt data to the caller.
208class GatewayContentUnverifiable(GatewayContentMismatch): 209 """Verification was requested but could not be carried out. 210 211 Subclasses ``GatewayContentMismatch`` so it fails over and is caught by 212 existing handlers. Distinct because the cause differs: the content is not 213 known to be wrong, only unproven. ``verify_content=True`` must still fail 214 closed here -- returning unverified bytes would silently downgrade the 215 guarantee exactly when the hashing backend is broken or the algorithm is 216 unavailable. 217 """
Verification was requested but could not be carried out.
Subclasses GatewayContentMismatch so it fails over and is caught by
existing handlers. Distinct because the cause differs: the content is not
known to be wrong, only unproven. verify_content=True must still fail
closed here -- returning unverified bytes would silently downgrade the
guarantee exactly when the hashing backend is broken or the algorithm is
unavailable.
445class InMemoryCAS(ContentAddressedStore): 446 """Used mostly for faster testing, this is why this is not exported. It hashes all inputs and uses that as a key to an in-memory python dict, mimicking a content addressed storage system. The hash bytes are the ID that `save` returns and `load` takes in.""" 447 448 store: dict[bytes, bytes] 449 hash_alg: Multihash 450 451 def __init__(self): 452 self.store = dict() 453 self.hash_alg = multihash.get("blake3") 454 455 async def save(self, data: bytes, codec: ContentAddressedStore.CodecInput) -> bytes: 456 hash: bytes = self.hash_alg.digest(data, size=32) 457 self.store[hash] = data 458 return hash 459 460 async def load( 461 self, 462 id: IPLDKind, 463 offset: Optional[int] = None, 464 length: Optional[int] = None, 465 suffix: Optional[int] = None, 466 ) -> bytes: 467 """ 468 Retrieve all or part of an object using Python slice semantics. 469 470 A zero ``length`` or ``suffix`` returns an empty byte string. 471 472 `ContentAddressedStore` allows any IPLD scalar key. For the in-memory 473 backend we *require* a `bytes` hash; anything else is rejected at run 474 time. In OO type-checking, a subclass may widen (make more general) argument types, 475 but it must never narrow them; otherwise callers that expect the base-class contract can break. 476 Mypy enforces this contra-variance rule and emits the "violates Liskov substitution principle" error. 477 This is why we use `cast` here, to tell mypy that we know what we are doing. 478 h/t https://stackoverflow.com/questions/75209249/overriding-a-method-mypy-throws-an-incompatible-with-super-type-error-when-ch 479 """ 480 if (offset is not None and length == 0) or (offset is None and suffix == 0): 481 return b"" 482 483 key = cast(bytes, id) 484 if not isinstance(key, (bytes, bytearray)): # defensive guard 485 raise TypeError( 486 f"InMemoryCAS only supports byte‐hash keys; got {type(id).__name__}" 487 ) 488 data: bytes 489 try: 490 data = self.store[key] 491 except KeyError as exc: 492 raise KeyError("Object not found in in-memory store") from exc 493 494 return _slice_requested_range(data, offset, length, suffix)
Used mostly for faster testing, this is why this is not exported. It hashes all inputs and uses that as a key to an in-memory python dict, mimicking a content addressed storage system. The hash bytes are the ID that save returns and load takes in.
455 async def save(self, data: bytes, codec: ContentAddressedStore.CodecInput) -> bytes: 456 hash: bytes = self.hash_alg.digest(data, size=32) 457 self.store[hash] = data 458 return hash
Save data to a storage mechanism, and return an ID for the data in the IPLDKind type.
codec will be set to "dag-cbor" if this data should be marked as special linked data a la IPLD data model.
460 async def load( 461 self, 462 id: IPLDKind, 463 offset: Optional[int] = None, 464 length: Optional[int] = None, 465 suffix: Optional[int] = None, 466 ) -> bytes: 467 """ 468 Retrieve all or part of an object using Python slice semantics. 469 470 A zero ``length`` or ``suffix`` returns an empty byte string. 471 472 `ContentAddressedStore` allows any IPLD scalar key. For the in-memory 473 backend we *require* a `bytes` hash; anything else is rejected at run 474 time. In OO type-checking, a subclass may widen (make more general) argument types, 475 but it must never narrow them; otherwise callers that expect the base-class contract can break. 476 Mypy enforces this contra-variance rule and emits the "violates Liskov substitution principle" error. 477 This is why we use `cast` here, to tell mypy that we know what we are doing. 478 h/t https://stackoverflow.com/questions/75209249/overriding-a-method-mypy-throws-an-incompatible-with-super-type-error-when-ch 479 """ 480 if (offset is not None and length == 0) or (offset is None and suffix == 0): 481 return b"" 482 483 key = cast(bytes, id) 484 if not isinstance(key, (bytes, bytearray)): # defensive guard 485 raise TypeError( 486 f"InMemoryCAS only supports byte‐hash keys; got {type(id).__name__}" 487 ) 488 data: bytes 489 try: 490 data = self.store[key] 491 except KeyError as exc: 492 raise KeyError("Object not found in in-memory store") from exc 493 494 return _slice_requested_range(data, offset, length, suffix)
Retrieve all or part of an object using Python slice semantics.
A zero length or suffix returns an empty byte string.
ContentAddressedStore allows any IPLD scalar key. For the in-memory
backend we require a bytes hash; anything else is rejected at run
time. In OO type-checking, a subclass may widen (make more general) argument types,
but it must never narrow them; otherwise callers that expect the base-class contract can break.
Mypy enforces this contra-variance rule and emits the "violates Liskov substitution principle" error.
This is why we use cast here, to tell mypy that we know what we are doing.
h/t https://stackoverflow.com/questions/75209249/overriding-a-method-mypy-throws-an-incompatible-with-super-type-error-when-ch
497class KuboCAS(ContentAddressedStore): 498 """ 499 Connects to an **IPFS Kubo** daemon. 500 501 The IDs in save and load are IPLD CIDs. 502 503 * **save()** → RPC (`/api/v0/add`) 504 * **load()** → HTTP gateway (`/ipfs/{cid}`) 505 506 `save` uses the RPC API and `load` uses the HTTP Gateway. This means that read-only HAMTs will only access the HTTP Gateway, so no RPC endpoint is required for use. 507 508 ### Authentication / custom headers 509 You have two options: 510 511 1. **Bring your own `httpx.AsyncClient` or client factory** 512 Pass a client via `client=...` for use on one event loop, or pass 513 `client_factory=...` to build a fully configured client for each event 514 loop. Reusing a supplied client from a later loop emits a warning and 515 falls back to an internal client that preserves only headers, auth, 516 timeout, redirect policy, and event hooks. 517 2. **Let `KuboCAS` build the client** but pass 518 `headers=` *and*/or `auth=` kwargs; they are forwarded to the 519 internally-created `AsyncClient`. 520 521 ```python 522 import httpx 523 from py_hamt import KuboCAS 524 525 # Option 1: user-supplied client 526 client = httpx.AsyncClient( 527 headers={"Authorization": "Bearer <token>"}, 528 auth=("user", "pass"), 529 follow_redirects=True, 530 ) 531 cas = KuboCAS(client=client) 532 533 # Option 2: let KuboCAS create the client 534 cas = KuboCAS( 535 headers={"X-My-Header": "yes"}, 536 auth=("user", "pass"), 537 ) 538 ``` 539 540 ### Parameters 541 - **hasher** (str): multihash name (defaults to *blake3*). 542 - **client** (`httpx.AsyncClient | None`): reuse an existing 543 client and its configured timeout and redirect policy. User-supplied 544 clients should set ``follow_redirects=True`` when gateways may redirect. 545 If *None*, KuboCAS will create one lazily with a 60-second timeout and 546 redirect following and HTTP/2 enabled. Plaintext endpoints continue to 547 use HTTP/1.1 because HTTP/2 negotiation requires TLS/ALPN. 548 - **client_factory** (`Callable[[], httpx.AsyncClient] | None`): create a 549 separate, fully configured client for each event loop. KuboCAS owns and 550 closes clients returned by the factory. Mutually exclusive with 551 **client**. 552 - **headers** (dict[str, str] | None): default headers for the 553 internally-created client. 554 - **auth** (`tuple[str, str] | None`): authentication tuple (username, password) 555 for the internally-created client. 556 - **rpc_base_url / gateway_base_url** (str | None): override daemon 557 endpoints (defaults match the local daemon ports). Gateway URLs may end 558 with `/ipfs` and may include a trailing slash. 559 - **gateway_base_urls** (list[str] | None): read from several gateways with 560 automatic failover. Mutually exclusive with `gateway_base_url`. Each read 561 tries one gateway at a time, healthy gateways first, until one succeeds; 562 requests are not raced in parallel. A gateway that fails three times in a 563 row is moved to the back of the rotation for 30 seconds and then probed 564 again. `concurrency` applies per gateway. If every gateway fails, an 565 `ExceptionGroup` of the underlying errors is raised. 566 - **verify_content** (bool): check that returned bytes hash to the 567 requested CID, raising `GatewayContentMismatch` (and failing over to the 568 next gateway) when they do not. Worth enabling when reading from public 569 gateways you do not control. Only full-body reads of non-`dag-pb` CIDs 570 can be verified; Range reads and `dag-pb` reads are passed through 571 unchecked because neither returns the exact bytes the CID commits to. 572 Requests to a gateway outside the origin of 573 `gateway_base_url`/`rpc_base_url` forward only content-negotiation 574 headers (`Accept`, `Accept-Encoding`, `Accept-Language`, `User-Agent`) 575 and drop client-level `auth`. Because any header name may carry a 576 credential, everything else is withheld -- so a private primary can 577 safely be paired with public fallbacks, but a foreign gateway that needs 578 its own custom header will not receive one. 579 - **chunker** (str): chunking algorithm specification for Kubo's `add` 580 RPC. Accepted formats are `"size-<positive int>"`, `"rabin"`, or 581 `"rabin-<min>-<avg>-<max>"`. 582 583 ... 584 """ 585 586 KUBO_DEFAULT_LOCAL_GATEWAY_BASE_URL: str = "http://127.0.0.1:8080" 587 KUBO_DEFAULT_LOCAL_RPC_BASE_URL: str = "http://127.0.0.1:5001" 588 589 DAG_PB_MARKER: int = 0x70 590 """@private""" 591 592 # Take in a httpx client that can be reused across POSTs and GETs to a specific IPFS daemon 593 def __init__( 594 self, 595 hasher: str = "blake3", 596 client: httpx.AsyncClient | None = None, 597 rpc_base_url: str | None = None, 598 gateway_base_url: str | None = None, 599 concurrency: int = 32, 600 *, 601 gateway_base_urls: list[str] | None = None, 602 verify_content: bool = False, 603 client_factory: Optional[Callable[[], httpx.AsyncClient]] = None, 604 headers: dict[str, str] | None = None, 605 auth: Tuple[str, str] | None = None, 606 pin_on_add: bool = False, 607 chunker: str = "size-1048576", 608 max_retries: int = 3, 609 initial_delay: float = 1.0, 610 backoff_factor: float = 2.0, 611 ): 612 """ 613 If None is passed into the rpc or gateway base url, then the default for kubo local daemons will be used. The default local values will also be used if nothing is passed in at all. 614 615 ### `httpx.AsyncClient` Management 616 If `client` is not provided, it will be automatically initialized. It is the responsibility of the user to close this at an appropriate time, using `await cas.aclose()` 617 as a class instance cannot know when it will no longer be in use, unless explicitly told to do so. 618 619 A supplied client is associated with the running event loop lazily on 620 first use, so constructing ``KuboCAS`` does not require an async 621 context. On a later event loop, KuboCAS warns and uses an internally 622 created fallback that preserves only the supplied client's headers, 623 auth, timeout, redirect policy, and event hooks. Pass 624 ``client_factory`` instead when every event loop needs the client's 625 full configuration. Factory clients are owned and closed by KuboCAS. 626 Clients created internally by ``KuboCAS`` use a 60-second timeout, 627 follow redirects, and negotiate HTTP/2 for HTTPS endpoints that 628 support it. 629 630 If you are using the `KuboCAS` instance in an `async with` block, it will automatically close the client when the block is exited which is what we suggest below: 631 ```python 632 async with httpx.AsyncClient() as client, KuboCAS( 633 rpc_base_url=rpc_base_url, 634 gateway_base_url=gateway_base_url, 635 client=client, 636 ) as kubo_cas: 637 hamt = await HAMT.build(cas=kubo_cas, values_are_bytes=True) 638 zhs = ZarrHAMTStore(hamt) 639 # Use the KuboCAS instance as needed 640 # ... 641 ``` 642 As mentioned, if you do not use the `async with` syntax, you should call `await cas.aclose()` when you are done using the instance to ensure that all resources are cleaned up. 643 ``` python 644 cas = KuboCAS(rpc_base_url=rpc_base_url, gateway_base_url=gateway_base_url) 645 # Use the KuboCAS instance as needed 646 # ... 647 await cas.aclose() # Ensure resources are cleaned up 648 ``` 649 650 ### Authenticated RPC/Gateway Access 651 Users can set whatever headers and auth credentials they need if they are connecting to an authenticated kubo instance by setting them in their own `httpx.AsyncClient` and then passing that in. 652 Alternatively, they can pass in `headers` and `auth` parameters to the constructor, which will be used to create a new `httpx.AsyncClient` if one is not provided. 653 If you do not need authentication, you can leave these parameters as `None`. 654 655 ### RPC and HTTP Gateway Base URLs 656 These are the first part of the url, defaults that refer to the default that kubo launches with on a local machine are provided. 657 """ 658 659 if client is not None and client_factory is not None: 660 raise ValueError("client and client_factory are mutually exclusive") 661 if client_factory is not None and (headers is not None or auth is not None): 662 raise ValueError( 663 "client_factory is mutually exclusive with headers/auth; " 664 "configure them on the clients the factory builds" 665 ) 666 667 self._owns_client: bool = False 668 self._closed: bool = True 669 self._client_per_loop: Dict[asyncio.AbstractEventLoop, httpx.AsyncClient] = {} 670 self._internally_created_clients: set[httpx.AsyncClient] = set() 671 # Serializes first-use client binding so concurrent event loops on 672 # different threads cannot both consume ``_supplied_client`` and bind 673 # one httpx.AsyncClient to two loops. 674 self._first_use_lock: threading.Lock = threading.Lock() 675 self._semaphore_per_loop: Dict[ 676 asyncio.AbstractEventLoop, asyncio.Semaphore 677 ] = {} 678 # Gateway reads get a semaphore per (loop, gateway) so ``concurrency`` 679 # means "in-flight requests per gateway". Sharing one budget across 680 # gateways would divide effective parallelism by the gateway count and 681 # let a slow gateway starve the healthy ones of slots. 682 self._gateway_semaphore_per_loop: Dict[ 683 Tuple[asyncio.AbstractEventLoop, str], asyncio.Semaphore 684 ] = {} 685 686 # Now, perform validation that might raise an exception 687 chunker_pattern = r"(?:size-[1-9]\d*|rabin(?:-[1-9]\d*-[1-9]\d*-[1-9]\d*)?)" 688 if re.fullmatch(chunker_pattern, chunker) is None: 689 raise ValueError("Invalid chunker specification") 690 self.chunker: str = chunker 691 692 self.hasher: str = hasher 693 """The hash function to send to IPFS when storing bytes. Cannot be changed after initialization. The default blake3 follows the default hashing algorithm used by HAMT.""" 694 695 if rpc_base_url is None: 696 rpc_base_url = KuboCAS.KUBO_DEFAULT_LOCAL_RPC_BASE_URL # pragma 697 698 if gateway_base_urls is not None: 699 if gateway_base_url is not None: 700 raise ValueError( 701 "gateway_base_url and gateway_base_urls are mutually " 702 "exclusive; pass every gateway in gateway_base_urls" 703 ) 704 if not gateway_base_urls: 705 raise ValueError("gateway_base_urls must not be empty") 706 normalized = [_normalize_gateway_base_url(url) for url in gateway_base_urls] 707 # Preserve caller order while dropping duplicates: a repeated 708 # gateway would otherwise get several rotation slots and several 709 # independent concurrency budgets pointed at one host. 710 self.gateway_base_urls: list[str] = list(dict.fromkeys(normalized)) 711 else: 712 if gateway_base_url is None: 713 gateway_base_url = KuboCAS.KUBO_DEFAULT_LOCAL_GATEWAY_BASE_URL 714 self.gateway_base_urls = [_normalize_gateway_base_url(gateway_base_url)] 715 716 pin_string: str = "true" if pin_on_add else "false" 717 # cid-version=1 is required, not cosmetic. Kubo returns a CIDv0 for any 718 # add that does not ask for v1, and a CIDv0 is dag-pb by definition -- 719 # so with sha2-256 (a CIDv0-representable hasher) the daemon would wrap 720 # the payload in a UnixFS dag-pb node and hand back Qm... regardless of 721 # the codec this store asked for. That breaks two things: 722 # * save() cannot relabel the CID's codec, because the stored block is 723 # the protobuf wrapper rather than the bytes passed in, so the digest 724 # would no longer match the block. 725 # * _cid_is_verifiable() declines to check dag-pb responses, so 726 # verify_content would silently pass every block through unverified. 727 # Requesting v1 makes Kubo store the raw bytes under the codec asked 728 # for, which is what blake3 (not CIDv0-representable) already got. 729 self.rpc_url: str = f"{rpc_base_url}/api/v0/add?hash={self.hasher}&chunker={self.chunker}&pin={pin_string}&cid-version=1" 730 """@private""" 731 self.gateway_base_url: str = self.gateway_base_urls[0] 732 """@private""" 733 734 # Origins the caller's credentials were configured for: the primary 735 # gateway and the RPC endpoint. Reads to any other gateway drop 736 # credentialed headers and client auth (see _load_from_gateway). 737 self._credentialed_origins: set[tuple[str, str, int | None]] = { 738 _origin_of(self.gateway_base_url), 739 _origin_of(rpc_base_url), 740 } 741 742 self.verify_content: bool = verify_content 743 """@private""" 744 # Health is per gateway but shared across event loops: a gateway that is 745 # rate-limiting or down is doing so regardless of which loop observed it. 746 self._gateway_health: Dict[str, _GatewayHealth] = { 747 url: _GatewayHealth() for url in self.gateway_base_urls 748 } 749 750 if client is not None: 751 # Bind the user-supplied client lazily on first async use. 752 self._owns_client = False 753 self._supplied_client: httpx.AsyncClient | None = client 754 self._user_client: httpx.AsyncClient | None = client 755 self._default_headers: httpx.Headers | dict[str, str] | None = ( 756 httpx.Headers(client.headers) 757 ) 758 self._default_auth: httpx.Auth | Tuple[str, str] | None = client.auth 759 self._default_timeout: httpx.Timeout | float = client.timeout 760 self._default_limits = self._copy_client_limits(client) 761 self._default_follow_redirects: bool = client.follow_redirects 762 # Snapshot the hooks like the headers above: later mutations of the 763 # supplied client must not leak into fallback clients. 764 self._default_event_hooks: dict[str, list[Callable[..., Any]]] | None = { 765 event: list(hooks) for event, hooks in client.event_hooks.items() 766 } 767 else: 768 # No client supplied. We will own any clients we create. 769 self._owns_client = True 770 self._supplied_client = None 771 self._user_client = None 772 self._default_headers = headers 773 self._default_auth = auth 774 self._default_timeout = 60.0 775 self._default_limits = httpx.Limits( 776 max_connections=64, max_keepalive_connections=32 777 ) 778 self._default_follow_redirects = True 779 self._default_event_hooks = None 780 self._client_factory: Optional[Callable[[], httpx.AsyncClient]] = client_factory 781 782 if concurrency <= 0: 783 raise ValueError("concurrency must be a positive integer") 784 self._concurrency: int = concurrency 785 self._closed = False 786 787 # Validate retry parameters 788 if max_retries < 0: 789 raise ValueError("max_retries must be non-negative") 790 if initial_delay <= 0: 791 raise ValueError("initial_delay must be positive") 792 if backoff_factor < 1.0: 793 raise ValueError("backoff_factor must be >= 1.0 for exponential backoff") 794 795 self.max_retries = max_retries 796 self.initial_delay = initial_delay 797 self.backoff_factor = backoff_factor 798 799 @staticmethod 800 def _copy_client_limits(client: httpx.AsyncClient) -> httpx.Limits: 801 """Copy connection limits from a standard HTTPX async transport. 802 803 HTTPX does not expose client limits publicly, so custom transports fall 804 back to the limits KuboCAS uses for its own clients. 805 """ 806 transport: Any = client._transport 807 pool: Any = getattr(transport, "_pool", None) 808 return httpx.Limits( 809 max_connections=getattr(pool, "_max_connections", 64), 810 max_keepalive_connections=getattr(pool, "_max_keepalive_connections", 32), 811 keepalive_expiry=getattr(pool, "_keepalive_expiry", 5.0), 812 ) 813 814 # --------------------------------------------------------------------- # 815 # helper: get or create the client bound to the current running loop # 816 # --------------------------------------------------------------------- # 817 def _loop_semaphore(self) -> asyncio.Semaphore: 818 """Get or create the concurrency semaphore for the running event loop. 819 820 Semaphores cannot be shared safely across event loops once contended, 821 so their lifecycle mirrors the per-loop HTTP clients. 822 """ 823 if self._closed: 824 if not self._owns_client: 825 raise RuntimeError("KuboCAS is closed; create a new instance") 826 self._closed = False 827 self._client_per_loop = {} 828 self._internally_created_clients = set() 829 self._semaphore_per_loop = {} 830 self._gateway_semaphore_per_loop = {} 831 832 loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() 833 try: 834 return self._semaphore_per_loop[loop] 835 except KeyError: 836 semaphore = asyncio.Semaphore(self._concurrency) 837 self._semaphore_per_loop[loop] = semaphore 838 return semaphore 839 840 def _gateway_semaphore(self, gateway_base_url: str) -> asyncio.Semaphore: 841 """Get or create the concurrency semaphore for one gateway on this loop. 842 843 With a single gateway this is equivalent to ``_loop_semaphore``; with 844 several it keeps each gateway's ``concurrency`` budget independent. 845 """ 846 loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() 847 key = (loop, gateway_base_url) 848 try: 849 return self._gateway_semaphore_per_loop[key] 850 except KeyError: 851 semaphore = asyncio.Semaphore(self._concurrency) 852 self._gateway_semaphore_per_loop[key] = semaphore 853 return semaphore 854 855 def _ordered_gateways(self) -> list[str]: 856 """Gateways to try, best first. 857 858 Ordered by health, then by whether a concurrency slot is free right now. 859 The second key avoids head-of-line blocking: attempts wait on their 860 gateway's semaphore, so without it a read queued behind a saturated 861 gateway would stall even when an idle gateway could serve it 862 immediately -- precisely the case multiple gateways exist to handle. 863 864 Deprioritizing rather than dropping unhealthy gateways means a run where 865 every gateway has tripped still attempts them all instead of failing 866 with nothing tried. 867 """ 868 if len(self.gateway_base_urls) == 1: 869 return self.gateway_base_urls 870 871 now = time.monotonic() 872 # is_healthy() clears an expired trip, so evaluate it exactly once per 873 # gateway rather than inside a sort key, which may call it repeatedly. 874 ranks: Dict[str, tuple[int, int]] = {} 875 for url in self.gateway_base_urls: 876 unhealthy = 0 if self._gateway_health[url].is_healthy(now) else 1 877 busy = 1 if self._gateway_semaphore(url).locked() else 0 878 ranks[url] = (unhealthy, busy) 879 880 # Stable sort, so configured order breaks ties within a rank. 881 return sorted(self.gateway_base_urls, key=lambda url: ranks[url]) 882 883 def _loop_client(self) -> httpx.AsyncClient: 884 """Get or create a client for the current event loop. 885 886 A user-supplied client is bound to the first loop that requests it. 887 If the instance was previously closed but owns its clients, a fresh 888 client mapping is lazily created on demand. Users that supplied their 889 own ``httpx.AsyncClient`` still receive an error when the instance has 890 been closed, as we cannot safely recreate their client. Internally 891 created clients enable HTTP/2 negotiation for HTTPS endpoints. 892 """ 893 if self._closed: 894 if not self._owns_client: 895 raise RuntimeError("KuboCAS is closed; create a new instance") 896 # We previously closed all internally-owned clients. Reset the 897 # state so that new clients can be created lazily. 898 self._closed = False 899 self._client_per_loop = {} 900 self._semaphore_per_loop = {} 901 self._gateway_semaphore_per_loop = {} 902 903 loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() 904 try: 905 return self._client_per_loop[loop] 906 except KeyError: 907 # First use on this loop. Hold the lock across supplied-client 908 # consumption, client creation, and the per-loop assignment so two 909 # loops racing on different threads cannot bind the same client. 910 with self._first_use_lock: 911 if self._supplied_client is not None: 912 client = self._supplied_client 913 self._supplied_client = None 914 elif self._client_factory is not None: 915 client = self._client_factory() 916 self._internally_created_clients.add(client) 917 else: 918 if self._user_client is not None: 919 warnings.warn( 920 "A user-supplied httpx.AsyncClient cannot be reused " 921 "across event loops; falling back to an internally " 922 "created client that preserves only headers, auth, " 923 "timeout, limits, redirect policy, and event hooks. " 924 "Pass client_factory to preserve full configuration.", 925 RuntimeWarning, 926 stacklevel=2, 927 ) 928 client = httpx.AsyncClient( 929 timeout=self._default_timeout, 930 headers=self._default_headers, 931 auth=self._default_auth, 932 limits=self._default_limits, 933 follow_redirects=self._default_follow_redirects, 934 event_hooks=self._default_event_hooks, 935 http2=True, 936 ) 937 self._internally_created_clients.add(client) 938 self._client_per_loop[loop] = client 939 return client 940 941 # --------------------------------------------------------------------- # 942 # graceful shutdown: close **all** clients we own # 943 # --------------------------------------------------------------------- # 944 async def aclose(self) -> None: 945 """ 946 Close every internally-created client, leaving a supplied client open. 947 948 Must be called from an async context. 949 950 For clients owned by closed loops with stock async-only transports, 951 cleanup degenerates to a warning. The OS-level socket is shut down 952 with a FIN, but its local file descriptor is released at garbage 953 collection. Callers that require deterministic release should call 954 ``aclose()`` on the owning loop before it exits. 955 """ 956 try: 957 current_loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop() 958 except RuntimeError: 959 current_loop = None 960 961 for owner_loop, client in list(self._client_per_loop.items()): 962 if client not in self._internally_created_clients: 963 continue 964 965 try: 966 if owner_loop is current_loop: 967 await client.aclose() 968 continue 969 970 if not owner_loop.is_closed(): 971 if owner_loop.is_running(): 972 close_future = asyncio.run_coroutine_threadsafe( 973 client.aclose(), owner_loop 974 ) 975 try: 976 await asyncio.wait_for( 977 asyncio.wrap_future(close_future), 978 timeout=_CROSS_LOOP_ACLOSE_TIMEOUT_S, 979 ) 980 except TimeoutError: 981 # The owner loop stopped (or stalled) after 982 # is_running() succeeded, so the scheduled close can 983 # never complete. Cancel it and fall through to the 984 # synchronous transport shutdown below. 985 close_future.cancel() 986 else: 987 continue 988 else: 989 await asyncio.to_thread( 990 _close_client_on_stopped_loop, owner_loop, client 991 ) 992 continue 993 994 # AsyncClient marks itself closed before awaiting its transport. 995 # A dead owner loop therefore needs the transport's sync fallback. 996 transport: Any = client._transport 997 close_transport = getattr(transport, "close", None) 998 if close_transport is None: 999 await client.aclose() 1000 continue 1001 1002 close_transport() 1003 try: 1004 await client.aclose() 1005 except Exception: 1006 pass # The transport was already closed synchronously. 1007 except Exception as exc: 1008 warnings.warn( 1009 f"Failed to close an internally created HTTP client: {exc}", 1010 RuntimeWarning, 1011 stacklevel=2, 1012 ) 1013 1014 self._client_per_loop.clear() 1015 self._internally_created_clients.clear() 1016 self._semaphore_per_loop.clear() 1017 self._gateway_semaphore_per_loop.clear() 1018 self._closed = True 1019 1020 # At this point, _client_per_loop should be empty or only contain 1021 # clients from loops we haven't seen (which shouldn't happen in practice) 1022 async def __aenter__(self) -> "KuboCAS": 1023 return self 1024 1025 async def __aexit__(self, *exc: Any) -> None: 1026 await self.aclose() 1027 1028 def __del__(self) -> None: 1029 """Best-effort close for internally-created clients.""" 1030 if not hasattr(self, "_owns_client") or not hasattr(self, "_closed"): 1031 return 1032 1033 if ( 1034 not self._owns_client 1035 and not getattr(self, "_internally_created_clients", set()) 1036 ) or self._closed: 1037 return 1038 1039 # Attempt proper cleanup if possible 1040 try: 1041 loop = asyncio.get_running_loop() 1042 except RuntimeError: 1043 # No running loop - can't do async cleanup 1044 # Just clear the client references synchronously 1045 if hasattr(self, "_client_per_loop"): 1046 # We can't await client.aclose() without a loop, 1047 # so just clear the references 1048 self._client_per_loop.clear() 1049 self._semaphore_per_loop.clear() 1050 self._gateway_semaphore_per_loop.clear() 1051 self._closed = True 1052 return 1053 1054 # If we get here, we have a running loop 1055 try: 1056 if loop.is_running(): 1057 # Schedule cleanup in the existing loop 1058 loop.create_task(self.aclose()) 1059 else: 1060 # Loop exists but not running - try asyncio.run 1061 coro = self.aclose() # Create the coroutine 1062 try: 1063 asyncio.run(coro) 1064 except Exception: 1065 # If asyncio.run fails, we need to close the coroutine properly 1066 coro.close() # This prevents the RuntimeWarning 1067 raise # Re-raise to hit the outer except block 1068 except Exception: 1069 # If all else fails, just clear references 1070 if hasattr(self, "_client_per_loop"): 1071 self._client_per_loop.clear() 1072 self._semaphore_per_loop.clear() 1073 self._gateway_semaphore_per_loop.clear() 1074 self._closed = True 1075 1076 # --------------------------------------------------------------------- # 1077 # save() - now uses the per-loop client # 1078 # --------------------------------------------------------------------- # 1079 async def save(self, data: bytes, codec: ContentAddressedStore.CodecInput) -> CID: 1080 """Add data to Kubo and return its CID. 1081 1082 Transient request failures and gateway statuses are retried. Retrying 1083 the ``/api/v0/add`` POST is safe because the uploaded content is 1084 content-addressed, making repeated additions idempotent. Concurrency 1085 slots are held per HTTP attempt and released during retry backoff. 1086 """ 1087 files = {"file": data} 1088 client = self._loop_client() 1089 semaphore = self._loop_semaphore() 1090 retry_count = 0 1091 1092 while retry_count <= self.max_retries: 1093 try: 1094 async with semaphore: 1095 response = await client.post(self.rpc_url, files=files) 1096 response.raise_for_status() 1097 cid_str: str = response.json()["Hash"] 1098 cid: CID = CID.decode(cid_str) 1099 if cid.codec.code != self.DAG_PB_MARKER: 1100 cid = cid.set(codec=codec) 1101 elif self.verify_content: 1102 # Kubo splits payloads larger than ``chunker`` into a UnixFS 1103 # dag-pb tree, so the root block is the protobuf node rather 1104 # than the bytes handed in. The requested codec cannot be 1105 # applied (the digest would stop matching the block), and 1106 # _cid_is_verifiable() skips dag-pb, so verify_content 1107 # silently does nothing for this object. Warn rather than 1108 # fail: the data still round-trips correctly, and the 1109 # threshold depends on the caller's chunker setting. 1110 warnings.warn( 1111 f"Saved {len(data)} bytes exceeded the '{self.chunker}' " 1112 f"chunker, so Kubo returned a dag-pb CID ({cid}). " 1113 "Content verification is not possible for this object; " 1114 "raise the chunker size to keep payloads in one block.", 1115 RuntimeWarning, 1116 stacklevel=2, 1117 ) 1118 return cid 1119 1120 except httpx.RequestError: 1121 if retry_count >= self.max_retries: 1122 raise 1123 retry_count += 1 1124 await asyncio.sleep( 1125 _retry_delay(self.initial_delay, self.backoff_factor, retry_count) 1126 ) 1127 1128 except httpx.HTTPStatusError as error: 1129 if error.response.status_code not in _RETRYABLE_STATUS_CODES: 1130 raise 1131 if retry_count >= self.max_retries: 1132 raise 1133 retry_count += 1 1134 await asyncio.sleep( 1135 _retry_delay( 1136 self.initial_delay, 1137 self.backoff_factor, 1138 retry_count, 1139 error.response, 1140 ) 1141 ) 1142 raise RuntimeError("Exited the retry loop unexpectedly.") # pragma: no cover 1143 1144 def _request_headers_for( 1145 self, client: httpx.AsyncClient, url: str, headers: Dict[str, str] 1146 ) -> Tuple[Dict[str, str], bool]: 1147 """Build headers for ``url``, dropping credentials on a foreign origin. 1148 1149 Returns the headers and whether credentials were withheld (which also 1150 means client-level ``auth`` must be suppressed). 1151 1152 httpx merges client-level headers into every request and offers no way 1153 to drop one per-request: ``Client._merge_headers`` starts from 1154 ``self.headers`` and only ``update()``s, so an omitted or blanked entry 1155 is reinstated. Building the ``Request`` explicitly is the only reliable 1156 way to withhold a credential. 1157 """ 1158 # Read from .raw: httpx.Headers.items() lower-cases names, and building 1159 # a Request from that would silently rewrite every outgoing header name 1160 # compared with the client.get() path this replaces. 1161 client_headers = [ 1162 (name.decode("latin-1"), value.decode("latin-1")) 1163 for name, value in client.headers.raw 1164 ] 1165 1166 if _origin_of(url) in self._credentialed_origins: 1167 merged = dict(client_headers) 1168 merged.update(headers) 1169 return merged, False 1170 1171 safe_headers = { 1172 name: value 1173 for name, value in client_headers 1174 if name.lower() in _FORWARDABLE_HEADERS 1175 } 1176 # Range headers are computed by load() for this request, never 1177 # caller-supplied credentials, so they are always safe to send. 1178 safe_headers.update(headers) 1179 return safe_headers, True 1180 1181 async def _get_with_origin_scoped_credentials( 1182 self, client: httpx.AsyncClient, url: str, headers: Dict[str, str] 1183 ) -> httpx.Response: 1184 """GET ``url``, re-deciding credential scope at every redirect hop. 1185 1186 Redirects are followed manually because httpx's own redirect handling 1187 strips only ``Authorization`` and ``Cookie`` when crossing origins (see 1188 ``Client._redirect_headers``). Custom credentials -- ``X-API-Key`` and 1189 friends, which ``KuboCAS`` documents as a supported way to 1190 authenticate -- survive its stripping, so a gateway could redirect to an 1191 origin of its choosing and harvest them. Following each hop ourselves 1192 re-applies the full origin check to the *redirect target*. 1193 """ 1194 if not _carries_credentials(client): 1195 # Nothing to withhold: no credentialed header and no client auth, so 1196 # no origin can harvest anything by redirecting. Keep the plain 1197 # client.get() path, which preserves httpx's own redirect, auth, and 1198 # header-casing behaviour for the overwhelmingly common case. 1199 return await client.get(url, headers=headers or None) 1200 1201 redirects_remaining = client.max_redirects if client.follow_redirects else 0 1202 current_url = url 1203 1204 while True: 1205 request_headers, stripped = self._request_headers_for( 1206 client, current_url, headers 1207 ) 1208 # auth=None also suppresses client-level httpx.Auth, which would 1209 # otherwise re-add an Authorization header after our filtering. 1210 response = await client.send( 1211 httpx.Request("GET", current_url, headers=request_headers), 1212 auth=None if stripped else httpx.USE_CLIENT_DEFAULT, 1213 follow_redirects=False, 1214 ) 1215 1216 location = response.headers.get("Location") 1217 if not (response.is_redirect and location): 1218 return response 1219 if redirects_remaining <= 0: 1220 # Mirror httpx's own behaviour rather than silently returning 1221 # the 3xx as if it were the block. 1222 raise httpx.TooManyRedirects( 1223 "Exceeded maximum allowed redirects.", request=response.request 1224 ) 1225 1226 redirects_remaining -= 1 1227 await response.aread() 1228 await response.aclose() 1229 current_url = str(response.url.join(location)) 1230 1231 async def _load_from_gateway( 1232 self, 1233 gateway_base_url: str, 1234 cid: CID, 1235 headers: Dict[str, str], 1236 offset: Optional[int], 1237 length: Optional[int], 1238 suffix: Optional[int], 1239 stats: "_LoadStats", 1240 ) -> bytes: 1241 """Fetch ``cid`` from one gateway, retrying that gateway's transients. 1242 1243 Raises on failure so the caller can fail over. ``stats`` accumulates the 1244 byte count and retry total across every gateway attempted, so the trace 1245 emitted by ``load`` reflects the whole operation rather than the last leg. 1246 1247 Credentials configured for the primary gateway or the RPC endpoint are 1248 stripped when this gateway is on a different origin, so failing over to 1249 a public fallback cannot disclose a private gateway's token. 1250 """ 1251 url = f"{gateway_base_url}{cid}" 1252 client = self._loop_client() 1253 semaphore = self._gateway_semaphore(gateway_base_url) 1254 retry_count = 0 1255 1256 while retry_count <= self.max_retries: 1257 try: 1258 async with semaphore: # Throttle each gateway attempt 1259 response = await self._get_with_origin_scoped_credentials( 1260 client, url, headers 1261 ) 1262 # An unsatisfiable range is answered with 416 by a compliant 1263 # gateway; return b"" to match Python-slice semantics (and 1264 # InMemoryCAS) instead of raising. 1265 if ( 1266 response.status_code == httpx.codes.REQUESTED_RANGE_NOT_SATISFIABLE 1267 and _range_not_satisfiable_is_empty(response, offset, suffix) 1268 ): 1269 return b"" 1270 response.raise_for_status() 1271 content = response.content 1272 stats.response_bytes = len(content) 1273 if headers: 1274 if response.status_code == httpx.codes.OK: 1275 logger.debug( 1276 "Gateway ignored Range request for CID %s; " 1277 "slicing the complete response locally", 1278 cid, 1279 ) 1280 return _slice_requested_range(content, offset, length, suffix) 1281 if response.status_code == httpx.codes.PARTIAL_CONTENT: 1282 # Trust the partial body only after proving its 1283 # Content-Range matches the requested byte window. 1284 _validate_partial_content( 1285 response, offset, length, suffix, stats.response_bytes 1286 ) 1287 return content 1288 # Any other 2xx to a Range request is unexpected: we 1289 # cannot know which bytes it carries, so fail rather than 1290 # return a possibly-wrong window. 1291 raise httpx.HTTPStatusError( 1292 f"unexpected {response.status_code} response to a " 1293 "Range request", 1294 request=response.request, 1295 response=response, 1296 ) 1297 if self.verify_content and _cid_is_verifiable(cid, offset, suffix): 1298 # A mismatch means this gateway served wrong bytes. Raising 1299 # here routes it through the caller's failover path like any 1300 # other per-gateway failure. 1301 try: 1302 _verify_cid_content(cid, content) 1303 except GatewayContentMismatch: 1304 stats.status = "content_mismatch" 1305 raise 1306 return content 1307 1308 except httpx.RequestError: 1309 if retry_count >= self.max_retries: 1310 stats.status = "request_error" 1311 raise 1312 retry_count += 1 1313 stats.retries += 1 1314 await asyncio.sleep( 1315 _retry_delay(self.initial_delay, self.backoff_factor, retry_count) 1316 ) 1317 1318 except httpx.HTTPStatusError as error: 1319 if ( 1320 error.response.status_code not in _RETRYABLE_STATUS_CODES 1321 or retry_count >= self.max_retries 1322 ): 1323 stats.status = "http_error" 1324 raise 1325 retry_count += 1 1326 stats.retries += 1 1327 await asyncio.sleep( 1328 _retry_delay( 1329 self.initial_delay, 1330 self.backoff_factor, 1331 retry_count, 1332 error.response, 1333 ) 1334 ) 1335 raise RuntimeError("Exited the retry loop unexpectedly.") # pragma: no cover 1336 1337 async def load( 1338 self, 1339 id: IPLDKind, 1340 offset: Optional[int] = None, 1341 length: Optional[int] = None, 1342 suffix: Optional[int] = None, 1343 ) -> bytes: 1344 """Load all or part of a CID using the IPFS gateway. 1345 1346 Gateways that ignore a Range header and return a complete ``200`` body 1347 are handled by applying the requested byte window locally. Transient 1348 request failures, rate limits, and gateway server errors are retried; 1349 other HTTP errors fail immediately. Zero-length and zero-suffix reads 1350 return immediately without a gateway request. Concurrency slots are 1351 held per HTTP attempt and released during retry backoff. 1352 1353 When several gateways are configured, each is tried in turn -- healthy 1354 ones first -- until one succeeds. Requests are *not* raced in parallel: 1355 fanning every read out to every gateway would multiply egress and burn 1356 each gateway's rate-limit budget N times over, which is the opposite of 1357 what helps when rate limiting is the problem being solved. A gateway 1358 that fails ``_GATEWAY_FAILURE_THRESHOLD`` times consecutively is moved 1359 to the back of the rotation for a cooldown. If every gateway fails, the 1360 collected errors are raised together as an ``ExceptionGroup``. 1361 """ 1362 if (offset is not None and length == 0) or (offset is None and suffix == 0): 1363 return b"" 1364 1365 cid = cast(CID, id) 1366 headers: Dict[str, str] = {} 1367 1368 # Construct the Range header if required 1369 if offset is not None: 1370 start = offset 1371 if length is not None: 1372 # Standard HTTP Range: bytes=start-end (inclusive) 1373 end = start + length - 1 1374 headers["Range"] = f"bytes={start}-{end}" 1375 else: 1376 # Standard HTTP Range: bytes=start- (from start to end) 1377 headers["Range"] = f"bytes={start}-" 1378 elif suffix is not None: 1379 # Standard HTTP Range: bytes=-N (last N bytes) 1380 headers["Range"] = f"bytes=-{suffix}" 1381 1382 trace_started_at = instrumentation.begin_cas_load(cid, bool(headers)) 1383 stats = _LoadStats() 1384 gateways = self._ordered_gateways() 1385 failures: list[Exception] = [] 1386 try: 1387 for gateway_base_url in gateways: 1388 health = self._gateway_health[gateway_base_url] 1389 try: 1390 content = await self._load_from_gateway( 1391 gateway_base_url, cid, headers, offset, length, suffix, stats 1392 ) 1393 except (httpx.HTTPError, GatewayContentMismatch) as error: 1394 health.record_failure(time.monotonic()) 1395 failures.append(error) 1396 if len(gateways) > 1: 1397 logger.debug( 1398 "Gateway %s failed for CID %s (%s); trying the next one", 1399 gateway_base_url, 1400 cid, 1401 error, 1402 ) 1403 continue 1404 else: 1405 health.record_success() 1406 # A gateway leg may have set a failure status before a later 1407 # gateway succeeded; the operation as a whole is a success. 1408 stats.status = "ok" 1409 return content 1410 1411 # Every gateway failed. With one configured, re-raise its error 1412 # unchanged so existing single-gateway callers keep seeing the exact 1413 # httpx exception type they handle today. 1414 if len(failures) == 1: 1415 raise failures[0] 1416 raise ExceptionGroup( 1417 f"all {len(gateways)} gateways failed for CID {cid}", failures 1418 ) 1419 finally: 1420 instrumentation.end_cas_load( 1421 trace_started_at, 1422 byte_count=stats.response_bytes, 1423 retries=stats.retries, 1424 status=stats.status, 1425 ) 1426 1427 # --------------------------------------------------------------------- # 1428 # pin_cid() - method to pin a CID # 1429 # --------------------------------------------------------------------- # 1430 async def pin_cid( 1431 self, 1432 cid: CID, 1433 target_rpc: str = "http://127.0.0.1:5001", 1434 ) -> None: 1435 """ 1436 Pins a CID to the local Kubo node via the RPC API. 1437 1438 This call is recursive by default, pinning all linked objects. 1439 1440 Args: 1441 cid (CID): The Content ID to pin. 1442 target_rpc (str): The RPC URL of the Kubo node. 1443 """ 1444 params = {"arg": str(cid), "recursive": "true"} 1445 pin_add_url_base: str = f"{target_rpc}/api/v0/pin/add" 1446 1447 async with self._loop_semaphore(): # throttle RPC 1448 client = self._loop_client() 1449 response = await client.post(pin_add_url_base, params=params) 1450 response.raise_for_status() 1451 1452 async def unpin_cid( 1453 self, cid: CID, target_rpc: str = "http://127.0.0.1:5001" 1454 ) -> None: 1455 """ 1456 Unpins a CID from the local Kubo node via the RPC API. 1457 1458 Args: 1459 cid (CID): The Content ID to unpin. 1460 """ 1461 params = {"arg": str(cid), "recursive": "true"} 1462 unpin_url_base: str = f"{target_rpc}/api/v0/pin/rm" 1463 async with self._loop_semaphore(): # throttle RPC 1464 client = self._loop_client() 1465 response = await client.post(unpin_url_base, params=params) 1466 response.raise_for_status() 1467 1468 async def pin_update( 1469 self, 1470 old_id: IPLDKind, 1471 new_id: IPLDKind, 1472 target_rpc: str = "http://127.0.0.1:5001", 1473 ) -> None: 1474 """ 1475 Updates the pinned CID in the storage. 1476 1477 Args: 1478 old_id (IPLDKind): The old Content ID to replace. 1479 new_id (IPLDKind): The new Content ID to pin. 1480 """ 1481 params = {"arg": [str(old_id), str(new_id)]} 1482 pin_update_url_base: str = f"{target_rpc}/api/v0/pin/update" 1483 async with self._loop_semaphore(): # throttle RPC 1484 client = self._loop_client() 1485 response = await client.post(pin_update_url_base, params=params) 1486 response.raise_for_status() 1487 1488 async def pin_ls( 1489 self, target_rpc: str = "http://127.0.0.1:5001" 1490 ) -> list[Dict[str, Any]]: 1491 """ 1492 Lists all pinned CIDs on the local Kubo node via the RPC API. 1493 1494 Args: 1495 target_rpc (str): The RPC URL of the Kubo node. 1496 1497 Returns: 1498 List[CID]: A list of pinned CIDs. 1499 """ 1500 pin_ls_url_base: str = f"{target_rpc}/api/v0/pin/ls" 1501 async with self._loop_semaphore(): # throttle RPC 1502 client = self._loop_client() 1503 response = await client.post(pin_ls_url_base) 1504 response.raise_for_status() 1505 pins = response.json().get("Keys", []) 1506 return pins
Connects to an IPFS Kubo daemon.
The IDs in save and load are IPLD CIDs.
- save() → RPC (
/api/v0/add) - load() → HTTP gateway (
/ipfs/{cid})
save uses the RPC API and load uses the HTTP Gateway. This means that read-only HAMTs will only access the HTTP Gateway, so no RPC endpoint is required for use.
Authentication / custom headers
You have two options:
- Bring your own
httpx.AsyncClientor client factory Pass a client viaclient=...for use on one event loop, or passclient_factory=...to build a fully configured client for each event loop. Reusing a supplied client from a later loop emits a warning and falls back to an internal client that preserves only headers, auth, timeout, redirect policy, and event hooks. - Let
KuboCASbuild the client but passheaders=and/orauth=kwargs; they are forwarded to the internally-createdAsyncClient.
import httpx
from py_hamt import KuboCAS
# Option 1: user-supplied client
client = httpx.AsyncClient(
headers={"Authorization": "Bearer <token>"},
auth=("user", "pass"),
follow_redirects=True,
)
cas = KuboCAS(client=client)
# Option 2: let KuboCAS create the client
cas = KuboCAS(
headers={"X-My-Header": "yes"},
auth=("user", "pass"),
)
Parameters
- hasher (str): multihash name (defaults to blake3).
- client (
httpx.AsyncClient | None): reuse an existing client and its configured timeout and redirect policy. User-supplied clients should setfollow_redirects=Truewhen gateways may redirect. If None, KuboCAS will create one lazily with a 60-second timeout and redirect following and HTTP/2 enabled. Plaintext endpoints continue to use HTTP/1.1 because HTTP/2 negotiation requires TLS/ALPN. - client_factory (
Callable[[], httpx.AsyncClient] | None): create a separate, fully configured client for each event loop. KuboCAS owns and closes clients returned by the factory. Mutually exclusive with client. - headers (dict[str, str] | None): default headers for the internally-created client.
- auth (
tuple[str, str] | None): authentication tuple (username, password) for the internally-created client. - rpc_base_url / gateway_base_url (str | None): override daemon
endpoints (defaults match the local daemon ports). Gateway URLs may end
with
/ipfsand may include a trailing slash. - gateway_base_urls (list[str] | None): read from several gateways with
automatic failover. Mutually exclusive with
gateway_base_url. Each read tries one gateway at a time, healthy gateways first, until one succeeds; requests are not raced in parallel. A gateway that fails three times in a row is moved to the back of the rotation for 30 seconds and then probed again.concurrencyapplies per gateway. If every gateway fails, anExceptionGroupof the underlying errors is raised. - verify_content (bool): check that returned bytes hash to the
requested CID, raising
GatewayContentMismatch(and failing over to the next gateway) when they do not. Worth enabling when reading from public gateways you do not control. Only full-body reads of non-dag-pbCIDs can be verified; Range reads anddag-pbreads are passed through unchecked because neither returns the exact bytes the CID commits to. Requests to a gateway outside the origin ofgateway_base_url/rpc_base_urlforward only content-negotiation headers (Accept,Accept-Encoding,Accept-Language,User-Agent) and drop client-levelauth. Because any header name may carry a credential, everything else is withheld -- so a private primary can safely be paired with public fallbacks, but a foreign gateway that needs its own custom header will not receive one. - chunker (str): chunking algorithm specification for Kubo's
addRPC. Accepted formats are"size-<positive int>","rabin", or"rabin-<min>-<avg>-<max>".
...
593 def __init__( 594 self, 595 hasher: str = "blake3", 596 client: httpx.AsyncClient | None = None, 597 rpc_base_url: str | None = None, 598 gateway_base_url: str | None = None, 599 concurrency: int = 32, 600 *, 601 gateway_base_urls: list[str] | None = None, 602 verify_content: bool = False, 603 client_factory: Optional[Callable[[], httpx.AsyncClient]] = None, 604 headers: dict[str, str] | None = None, 605 auth: Tuple[str, str] | None = None, 606 pin_on_add: bool = False, 607 chunker: str = "size-1048576", 608 max_retries: int = 3, 609 initial_delay: float = 1.0, 610 backoff_factor: float = 2.0, 611 ): 612 """ 613 If None is passed into the rpc or gateway base url, then the default for kubo local daemons will be used. The default local values will also be used if nothing is passed in at all. 614 615 ### `httpx.AsyncClient` Management 616 If `client` is not provided, it will be automatically initialized. It is the responsibility of the user to close this at an appropriate time, using `await cas.aclose()` 617 as a class instance cannot know when it will no longer be in use, unless explicitly told to do so. 618 619 A supplied client is associated with the running event loop lazily on 620 first use, so constructing ``KuboCAS`` does not require an async 621 context. On a later event loop, KuboCAS warns and uses an internally 622 created fallback that preserves only the supplied client's headers, 623 auth, timeout, redirect policy, and event hooks. Pass 624 ``client_factory`` instead when every event loop needs the client's 625 full configuration. Factory clients are owned and closed by KuboCAS. 626 Clients created internally by ``KuboCAS`` use a 60-second timeout, 627 follow redirects, and negotiate HTTP/2 for HTTPS endpoints that 628 support it. 629 630 If you are using the `KuboCAS` instance in an `async with` block, it will automatically close the client when the block is exited which is what we suggest below: 631 ```python 632 async with httpx.AsyncClient() as client, KuboCAS( 633 rpc_base_url=rpc_base_url, 634 gateway_base_url=gateway_base_url, 635 client=client, 636 ) as kubo_cas: 637 hamt = await HAMT.build(cas=kubo_cas, values_are_bytes=True) 638 zhs = ZarrHAMTStore(hamt) 639 # Use the KuboCAS instance as needed 640 # ... 641 ``` 642 As mentioned, if you do not use the `async with` syntax, you should call `await cas.aclose()` when you are done using the instance to ensure that all resources are cleaned up. 643 ``` python 644 cas = KuboCAS(rpc_base_url=rpc_base_url, gateway_base_url=gateway_base_url) 645 # Use the KuboCAS instance as needed 646 # ... 647 await cas.aclose() # Ensure resources are cleaned up 648 ``` 649 650 ### Authenticated RPC/Gateway Access 651 Users can set whatever headers and auth credentials they need if they are connecting to an authenticated kubo instance by setting them in their own `httpx.AsyncClient` and then passing that in. 652 Alternatively, they can pass in `headers` and `auth` parameters to the constructor, which will be used to create a new `httpx.AsyncClient` if one is not provided. 653 If you do not need authentication, you can leave these parameters as `None`. 654 655 ### RPC and HTTP Gateway Base URLs 656 These are the first part of the url, defaults that refer to the default that kubo launches with on a local machine are provided. 657 """ 658 659 if client is not None and client_factory is not None: 660 raise ValueError("client and client_factory are mutually exclusive") 661 if client_factory is not None and (headers is not None or auth is not None): 662 raise ValueError( 663 "client_factory is mutually exclusive with headers/auth; " 664 "configure them on the clients the factory builds" 665 ) 666 667 self._owns_client: bool = False 668 self._closed: bool = True 669 self._client_per_loop: Dict[asyncio.AbstractEventLoop, httpx.AsyncClient] = {} 670 self._internally_created_clients: set[httpx.AsyncClient] = set() 671 # Serializes first-use client binding so concurrent event loops on 672 # different threads cannot both consume ``_supplied_client`` and bind 673 # one httpx.AsyncClient to two loops. 674 self._first_use_lock: threading.Lock = threading.Lock() 675 self._semaphore_per_loop: Dict[ 676 asyncio.AbstractEventLoop, asyncio.Semaphore 677 ] = {} 678 # Gateway reads get a semaphore per (loop, gateway) so ``concurrency`` 679 # means "in-flight requests per gateway". Sharing one budget across 680 # gateways would divide effective parallelism by the gateway count and 681 # let a slow gateway starve the healthy ones of slots. 682 self._gateway_semaphore_per_loop: Dict[ 683 Tuple[asyncio.AbstractEventLoop, str], asyncio.Semaphore 684 ] = {} 685 686 # Now, perform validation that might raise an exception 687 chunker_pattern = r"(?:size-[1-9]\d*|rabin(?:-[1-9]\d*-[1-9]\d*-[1-9]\d*)?)" 688 if re.fullmatch(chunker_pattern, chunker) is None: 689 raise ValueError("Invalid chunker specification") 690 self.chunker: str = chunker 691 692 self.hasher: str = hasher 693 """The hash function to send to IPFS when storing bytes. Cannot be changed after initialization. The default blake3 follows the default hashing algorithm used by HAMT.""" 694 695 if rpc_base_url is None: 696 rpc_base_url = KuboCAS.KUBO_DEFAULT_LOCAL_RPC_BASE_URL # pragma 697 698 if gateway_base_urls is not None: 699 if gateway_base_url is not None: 700 raise ValueError( 701 "gateway_base_url and gateway_base_urls are mutually " 702 "exclusive; pass every gateway in gateway_base_urls" 703 ) 704 if not gateway_base_urls: 705 raise ValueError("gateway_base_urls must not be empty") 706 normalized = [_normalize_gateway_base_url(url) for url in gateway_base_urls] 707 # Preserve caller order while dropping duplicates: a repeated 708 # gateway would otherwise get several rotation slots and several 709 # independent concurrency budgets pointed at one host. 710 self.gateway_base_urls: list[str] = list(dict.fromkeys(normalized)) 711 else: 712 if gateway_base_url is None: 713 gateway_base_url = KuboCAS.KUBO_DEFAULT_LOCAL_GATEWAY_BASE_URL 714 self.gateway_base_urls = [_normalize_gateway_base_url(gateway_base_url)] 715 716 pin_string: str = "true" if pin_on_add else "false" 717 # cid-version=1 is required, not cosmetic. Kubo returns a CIDv0 for any 718 # add that does not ask for v1, and a CIDv0 is dag-pb by definition -- 719 # so with sha2-256 (a CIDv0-representable hasher) the daemon would wrap 720 # the payload in a UnixFS dag-pb node and hand back Qm... regardless of 721 # the codec this store asked for. That breaks two things: 722 # * save() cannot relabel the CID's codec, because the stored block is 723 # the protobuf wrapper rather than the bytes passed in, so the digest 724 # would no longer match the block. 725 # * _cid_is_verifiable() declines to check dag-pb responses, so 726 # verify_content would silently pass every block through unverified. 727 # Requesting v1 makes Kubo store the raw bytes under the codec asked 728 # for, which is what blake3 (not CIDv0-representable) already got. 729 self.rpc_url: str = f"{rpc_base_url}/api/v0/add?hash={self.hasher}&chunker={self.chunker}&pin={pin_string}&cid-version=1" 730 """@private""" 731 self.gateway_base_url: str = self.gateway_base_urls[0] 732 """@private""" 733 734 # Origins the caller's credentials were configured for: the primary 735 # gateway and the RPC endpoint. Reads to any other gateway drop 736 # credentialed headers and client auth (see _load_from_gateway). 737 self._credentialed_origins: set[tuple[str, str, int | None]] = { 738 _origin_of(self.gateway_base_url), 739 _origin_of(rpc_base_url), 740 } 741 742 self.verify_content: bool = verify_content 743 """@private""" 744 # Health is per gateway but shared across event loops: a gateway that is 745 # rate-limiting or down is doing so regardless of which loop observed it. 746 self._gateway_health: Dict[str, _GatewayHealth] = { 747 url: _GatewayHealth() for url in self.gateway_base_urls 748 } 749 750 if client is not None: 751 # Bind the user-supplied client lazily on first async use. 752 self._owns_client = False 753 self._supplied_client: httpx.AsyncClient | None = client 754 self._user_client: httpx.AsyncClient | None = client 755 self._default_headers: httpx.Headers | dict[str, str] | None = ( 756 httpx.Headers(client.headers) 757 ) 758 self._default_auth: httpx.Auth | Tuple[str, str] | None = client.auth 759 self._default_timeout: httpx.Timeout | float = client.timeout 760 self._default_limits = self._copy_client_limits(client) 761 self._default_follow_redirects: bool = client.follow_redirects 762 # Snapshot the hooks like the headers above: later mutations of the 763 # supplied client must not leak into fallback clients. 764 self._default_event_hooks: dict[str, list[Callable[..., Any]]] | None = { 765 event: list(hooks) for event, hooks in client.event_hooks.items() 766 } 767 else: 768 # No client supplied. We will own any clients we create. 769 self._owns_client = True 770 self._supplied_client = None 771 self._user_client = None 772 self._default_headers = headers 773 self._default_auth = auth 774 self._default_timeout = 60.0 775 self._default_limits = httpx.Limits( 776 max_connections=64, max_keepalive_connections=32 777 ) 778 self._default_follow_redirects = True 779 self._default_event_hooks = None 780 self._client_factory: Optional[Callable[[], httpx.AsyncClient]] = client_factory 781 782 if concurrency <= 0: 783 raise ValueError("concurrency must be a positive integer") 784 self._concurrency: int = concurrency 785 self._closed = False 786 787 # Validate retry parameters 788 if max_retries < 0: 789 raise ValueError("max_retries must be non-negative") 790 if initial_delay <= 0: 791 raise ValueError("initial_delay must be positive") 792 if backoff_factor < 1.0: 793 raise ValueError("backoff_factor must be >= 1.0 for exponential backoff") 794 795 self.max_retries = max_retries 796 self.initial_delay = initial_delay 797 self.backoff_factor = backoff_factor
If None is passed into the rpc or gateway base url, then the default for kubo local daemons will be used. The default local values will also be used if nothing is passed in at all.
httpx.AsyncClient Management
If client is not provided, it will be automatically initialized. It is the responsibility of the user to close this at an appropriate time, using await cas.aclose()
as a class instance cannot know when it will no longer be in use, unless explicitly told to do so.
A supplied client is associated with the running event loop lazily on
first use, so constructing KuboCAS does not require an async
context. On a later event loop, KuboCAS warns and uses an internally
created fallback that preserves only the supplied client's headers,
auth, timeout, redirect policy, and event hooks. Pass
client_factory instead when every event loop needs the client's
full configuration. Factory clients are owned and closed by KuboCAS.
Clients created internally by KuboCAS use a 60-second timeout,
follow redirects, and negotiate HTTP/2 for HTTPS endpoints that
support it.
If you are using the KuboCAS instance in an async with block, it will automatically close the client when the block is exited which is what we suggest below:
async with httpx.AsyncClient() as client, KuboCAS(
rpc_base_url=rpc_base_url,
gateway_base_url=gateway_base_url,
client=client,
) as kubo_cas:
hamt = await HAMT.build(cas=kubo_cas, values_are_bytes=True)
zhs = ZarrHAMTStore(hamt)
# Use the KuboCAS instance as needed
# ...
As mentioned, if you do not use the async with syntax, you should call await cas.aclose() when you are done using the instance to ensure that all resources are cleaned up.
cas = KuboCAS(rpc_base_url=rpc_base_url, gateway_base_url=gateway_base_url)
# Use the KuboCAS instance as needed
# ...
await cas.aclose() # Ensure resources are cleaned up
Authenticated RPC/Gateway Access
Users can set whatever headers and auth credentials they need if they are connecting to an authenticated kubo instance by setting them in their own httpx.AsyncClient and then passing that in.
Alternatively, they can pass in headers and auth parameters to the constructor, which will be used to create a new httpx.AsyncClient if one is not provided.
If you do not need authentication, you can leave these parameters as None.
RPC and HTTP Gateway Base URLs
These are the first part of the url, defaults that refer to the default that kubo launches with on a local machine are provided.
The hash function to send to IPFS when storing bytes. Cannot be changed after initialization. The default blake3 follows the default hashing algorithm used by HAMT.
944 async def aclose(self) -> None: 945 """ 946 Close every internally-created client, leaving a supplied client open. 947 948 Must be called from an async context. 949 950 For clients owned by closed loops with stock async-only transports, 951 cleanup degenerates to a warning. The OS-level socket is shut down 952 with a FIN, but its local file descriptor is released at garbage 953 collection. Callers that require deterministic release should call 954 ``aclose()`` on the owning loop before it exits. 955 """ 956 try: 957 current_loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop() 958 except RuntimeError: 959 current_loop = None 960 961 for owner_loop, client in list(self._client_per_loop.items()): 962 if client not in self._internally_created_clients: 963 continue 964 965 try: 966 if owner_loop is current_loop: 967 await client.aclose() 968 continue 969 970 if not owner_loop.is_closed(): 971 if owner_loop.is_running(): 972 close_future = asyncio.run_coroutine_threadsafe( 973 client.aclose(), owner_loop 974 ) 975 try: 976 await asyncio.wait_for( 977 asyncio.wrap_future(close_future), 978 timeout=_CROSS_LOOP_ACLOSE_TIMEOUT_S, 979 ) 980 except TimeoutError: 981 # The owner loop stopped (or stalled) after 982 # is_running() succeeded, so the scheduled close can 983 # never complete. Cancel it and fall through to the 984 # synchronous transport shutdown below. 985 close_future.cancel() 986 else: 987 continue 988 else: 989 await asyncio.to_thread( 990 _close_client_on_stopped_loop, owner_loop, client 991 ) 992 continue 993 994 # AsyncClient marks itself closed before awaiting its transport. 995 # A dead owner loop therefore needs the transport's sync fallback. 996 transport: Any = client._transport 997 close_transport = getattr(transport, "close", None) 998 if close_transport is None: 999 await client.aclose() 1000 continue 1001 1002 close_transport() 1003 try: 1004 await client.aclose() 1005 except Exception: 1006 pass # The transport was already closed synchronously. 1007 except Exception as exc: 1008 warnings.warn( 1009 f"Failed to close an internally created HTTP client: {exc}", 1010 RuntimeWarning, 1011 stacklevel=2, 1012 ) 1013 1014 self._client_per_loop.clear() 1015 self._internally_created_clients.clear() 1016 self._semaphore_per_loop.clear() 1017 self._gateway_semaphore_per_loop.clear() 1018 self._closed = True
Close every internally-created client, leaving a supplied client open.
Must be called from an async context.
For clients owned by closed loops with stock async-only transports,
cleanup degenerates to a warning. The OS-level socket is shut down
with a FIN, but its local file descriptor is released at garbage
collection. Callers that require deterministic release should call
aclose() on the owning loop before it exits.
1079 async def save(self, data: bytes, codec: ContentAddressedStore.CodecInput) -> CID: 1080 """Add data to Kubo and return its CID. 1081 1082 Transient request failures and gateway statuses are retried. Retrying 1083 the ``/api/v0/add`` POST is safe because the uploaded content is 1084 content-addressed, making repeated additions idempotent. Concurrency 1085 slots are held per HTTP attempt and released during retry backoff. 1086 """ 1087 files = {"file": data} 1088 client = self._loop_client() 1089 semaphore = self._loop_semaphore() 1090 retry_count = 0 1091 1092 while retry_count <= self.max_retries: 1093 try: 1094 async with semaphore: 1095 response = await client.post(self.rpc_url, files=files) 1096 response.raise_for_status() 1097 cid_str: str = response.json()["Hash"] 1098 cid: CID = CID.decode(cid_str) 1099 if cid.codec.code != self.DAG_PB_MARKER: 1100 cid = cid.set(codec=codec) 1101 elif self.verify_content: 1102 # Kubo splits payloads larger than ``chunker`` into a UnixFS 1103 # dag-pb tree, so the root block is the protobuf node rather 1104 # than the bytes handed in. The requested codec cannot be 1105 # applied (the digest would stop matching the block), and 1106 # _cid_is_verifiable() skips dag-pb, so verify_content 1107 # silently does nothing for this object. Warn rather than 1108 # fail: the data still round-trips correctly, and the 1109 # threshold depends on the caller's chunker setting. 1110 warnings.warn( 1111 f"Saved {len(data)} bytes exceeded the '{self.chunker}' " 1112 f"chunker, so Kubo returned a dag-pb CID ({cid}). " 1113 "Content verification is not possible for this object; " 1114 "raise the chunker size to keep payloads in one block.", 1115 RuntimeWarning, 1116 stacklevel=2, 1117 ) 1118 return cid 1119 1120 except httpx.RequestError: 1121 if retry_count >= self.max_retries: 1122 raise 1123 retry_count += 1 1124 await asyncio.sleep( 1125 _retry_delay(self.initial_delay, self.backoff_factor, retry_count) 1126 ) 1127 1128 except httpx.HTTPStatusError as error: 1129 if error.response.status_code not in _RETRYABLE_STATUS_CODES: 1130 raise 1131 if retry_count >= self.max_retries: 1132 raise 1133 retry_count += 1 1134 await asyncio.sleep( 1135 _retry_delay( 1136 self.initial_delay, 1137 self.backoff_factor, 1138 retry_count, 1139 error.response, 1140 ) 1141 ) 1142 raise RuntimeError("Exited the retry loop unexpectedly.") # pragma: no cover
Add data to Kubo and return its CID.
Transient request failures and gateway statuses are retried. Retrying
the /api/v0/add POST is safe because the uploaded content is
content-addressed, making repeated additions idempotent. Concurrency
slots are held per HTTP attempt and released during retry backoff.
1337 async def load( 1338 self, 1339 id: IPLDKind, 1340 offset: Optional[int] = None, 1341 length: Optional[int] = None, 1342 suffix: Optional[int] = None, 1343 ) -> bytes: 1344 """Load all or part of a CID using the IPFS gateway. 1345 1346 Gateways that ignore a Range header and return a complete ``200`` body 1347 are handled by applying the requested byte window locally. Transient 1348 request failures, rate limits, and gateway server errors are retried; 1349 other HTTP errors fail immediately. Zero-length and zero-suffix reads 1350 return immediately without a gateway request. Concurrency slots are 1351 held per HTTP attempt and released during retry backoff. 1352 1353 When several gateways are configured, each is tried in turn -- healthy 1354 ones first -- until one succeeds. Requests are *not* raced in parallel: 1355 fanning every read out to every gateway would multiply egress and burn 1356 each gateway's rate-limit budget N times over, which is the opposite of 1357 what helps when rate limiting is the problem being solved. A gateway 1358 that fails ``_GATEWAY_FAILURE_THRESHOLD`` times consecutively is moved 1359 to the back of the rotation for a cooldown. If every gateway fails, the 1360 collected errors are raised together as an ``ExceptionGroup``. 1361 """ 1362 if (offset is not None and length == 0) or (offset is None and suffix == 0): 1363 return b"" 1364 1365 cid = cast(CID, id) 1366 headers: Dict[str, str] = {} 1367 1368 # Construct the Range header if required 1369 if offset is not None: 1370 start = offset 1371 if length is not None: 1372 # Standard HTTP Range: bytes=start-end (inclusive) 1373 end = start + length - 1 1374 headers["Range"] = f"bytes={start}-{end}" 1375 else: 1376 # Standard HTTP Range: bytes=start- (from start to end) 1377 headers["Range"] = f"bytes={start}-" 1378 elif suffix is not None: 1379 # Standard HTTP Range: bytes=-N (last N bytes) 1380 headers["Range"] = f"bytes=-{suffix}" 1381 1382 trace_started_at = instrumentation.begin_cas_load(cid, bool(headers)) 1383 stats = _LoadStats() 1384 gateways = self._ordered_gateways() 1385 failures: list[Exception] = [] 1386 try: 1387 for gateway_base_url in gateways: 1388 health = self._gateway_health[gateway_base_url] 1389 try: 1390 content = await self._load_from_gateway( 1391 gateway_base_url, cid, headers, offset, length, suffix, stats 1392 ) 1393 except (httpx.HTTPError, GatewayContentMismatch) as error: 1394 health.record_failure(time.monotonic()) 1395 failures.append(error) 1396 if len(gateways) > 1: 1397 logger.debug( 1398 "Gateway %s failed for CID %s (%s); trying the next one", 1399 gateway_base_url, 1400 cid, 1401 error, 1402 ) 1403 continue 1404 else: 1405 health.record_success() 1406 # A gateway leg may have set a failure status before a later 1407 # gateway succeeded; the operation as a whole is a success. 1408 stats.status = "ok" 1409 return content 1410 1411 # Every gateway failed. With one configured, re-raise its error 1412 # unchanged so existing single-gateway callers keep seeing the exact 1413 # httpx exception type they handle today. 1414 if len(failures) == 1: 1415 raise failures[0] 1416 raise ExceptionGroup( 1417 f"all {len(gateways)} gateways failed for CID {cid}", failures 1418 ) 1419 finally: 1420 instrumentation.end_cas_load( 1421 trace_started_at, 1422 byte_count=stats.response_bytes, 1423 retries=stats.retries, 1424 status=stats.status, 1425 )
Load all or part of a CID using the IPFS gateway.
Gateways that ignore a Range header and return a complete 200 body
are handled by applying the requested byte window locally. Transient
request failures, rate limits, and gateway server errors are retried;
other HTTP errors fail immediately. Zero-length and zero-suffix reads
return immediately without a gateway request. Concurrency slots are
held per HTTP attempt and released during retry backoff.
When several gateways are configured, each is tried in turn -- healthy
ones first -- until one succeeds. Requests are not raced in parallel:
fanning every read out to every gateway would multiply egress and burn
each gateway's rate-limit budget N times over, which is the opposite of
what helps when rate limiting is the problem being solved. A gateway
that fails _GATEWAY_FAILURE_THRESHOLD times consecutively is moved
to the back of the rotation for a cooldown. If every gateway fails, the
collected errors are raised together as an ExceptionGroup.
1430 async def pin_cid( 1431 self, 1432 cid: CID, 1433 target_rpc: str = "http://127.0.0.1:5001", 1434 ) -> None: 1435 """ 1436 Pins a CID to the local Kubo node via the RPC API. 1437 1438 This call is recursive by default, pinning all linked objects. 1439 1440 Args: 1441 cid (CID): The Content ID to pin. 1442 target_rpc (str): The RPC URL of the Kubo node. 1443 """ 1444 params = {"arg": str(cid), "recursive": "true"} 1445 pin_add_url_base: str = f"{target_rpc}/api/v0/pin/add" 1446 1447 async with self._loop_semaphore(): # throttle RPC 1448 client = self._loop_client() 1449 response = await client.post(pin_add_url_base, params=params) 1450 response.raise_for_status()
Pins a CID to the local Kubo node via the RPC API.
This call is recursive by default, pinning all linked objects.
Args: cid (CID): The Content ID to pin. target_rpc (str): The RPC URL of the Kubo node.
1452 async def unpin_cid( 1453 self, cid: CID, target_rpc: str = "http://127.0.0.1:5001" 1454 ) -> None: 1455 """ 1456 Unpins a CID from the local Kubo node via the RPC API. 1457 1458 Args: 1459 cid (CID): The Content ID to unpin. 1460 """ 1461 params = {"arg": str(cid), "recursive": "true"} 1462 unpin_url_base: str = f"{target_rpc}/api/v0/pin/rm" 1463 async with self._loop_semaphore(): # throttle RPC 1464 client = self._loop_client() 1465 response = await client.post(unpin_url_base, params=params) 1466 response.raise_for_status()
Unpins a CID from the local Kubo node via the RPC API.
Args: cid (CID): The Content ID to unpin.
1468 async def pin_update( 1469 self, 1470 old_id: IPLDKind, 1471 new_id: IPLDKind, 1472 target_rpc: str = "http://127.0.0.1:5001", 1473 ) -> None: 1474 """ 1475 Updates the pinned CID in the storage. 1476 1477 Args: 1478 old_id (IPLDKind): The old Content ID to replace. 1479 new_id (IPLDKind): The new Content ID to pin. 1480 """ 1481 params = {"arg": [str(old_id), str(new_id)]} 1482 pin_update_url_base: str = f"{target_rpc}/api/v0/pin/update" 1483 async with self._loop_semaphore(): # throttle RPC 1484 client = self._loop_client() 1485 response = await client.post(pin_update_url_base, params=params) 1486 response.raise_for_status()
Updates the pinned CID in the storage.
Args: old_id (IPLDKind): The old Content ID to replace. new_id (IPLDKind): The new Content ID to pin.
1488 async def pin_ls( 1489 self, target_rpc: str = "http://127.0.0.1:5001" 1490 ) -> list[Dict[str, Any]]: 1491 """ 1492 Lists all pinned CIDs on the local Kubo node via the RPC API. 1493 1494 Args: 1495 target_rpc (str): The RPC URL of the Kubo node. 1496 1497 Returns: 1498 List[CID]: A list of pinned CIDs. 1499 """ 1500 pin_ls_url_base: str = f"{target_rpc}/api/v0/pin/ls" 1501 async with self._loop_semaphore(): # throttle RPC 1502 client = self._loop_client() 1503 response = await client.post(pin_ls_url_base) 1504 response.raise_for_status() 1505 pins = response.json().get("Keys", []) 1506 return pins
Lists all pinned CIDs on the local Kubo node via the RPC API.
Args: target_rpc (str): The RPC URL of the Kubo node.
Returns: List[CID]: A list of pinned CIDs.
16class ZarrHAMTStore(zarr.abc.store.Store): 17 """ 18 Write and read Zarr v3s with a HAMT. 19 20 Read **or** write a Zarr-v3 store whose key/value pairs live inside a 21 py-hamt mapping. 22 23 Keys are stored verbatim (``"temp/c/0/0/0"`` → same string in HAMT) and 24 the value is the raw byte payload produced by Zarr. No additional 25 framing, compression, or encryption is applied by this class. For a zarr encryption example 26 see where metadata is available use the method in https://github.com/dClimate/jupyter-notebooks/blob/main/notebooks/202b%20-%20Encryption%20Example%20(Encryption%20with%20Zarr%20Codecs).ipynb 27 For a fully encrypted zarr store, where metadata is not available, please see 28 :class:`SimpleEncryptedZarrHAMTStore` but we do not recommend using it. 29 30 #### A note about using the same `ZarrHAMTStore` for writing and then reading again 31 If you write a Zarr to a HAMT, and then change it to read only mode, it's best to reinitialize a new ZarrHAMTStore with the proper read only setting. This is because this class, to err on the safe side, will not touch its super class's settings. 32 33 #### Sample Code 34 ```python 35 # --- Write --- 36 ds: xarray.Dataset = # ... 37 cas: ContentAddressedStore = # ... 38 hamt: HAMT = # ... make sure values_are_bytes is True and read_only is False to enable writes 39 hamt = await HAMT.build(cas, values_are_bytes=True) # write-enabled 40 zhs = ZarrHAMTStore(hamt, read_only=False) 41 ds.to_zarr(store=zhs, mode="w", zarr_format=3) 42 await hamt.make_read_only() # flush + freeze 43 root_node_id = hamt.root_node_id 44 print(root_node_id) 45 46 # --- read --- 47 hamt_ro = await HAMT.build( 48 cas, root_node_id=root_cid, read_only=True, values_are_bytes=True 49 ) 50 zhs_ro = ZarrHAMTStore(hamt_ro, read_only=True) 51 ds_ro = xarray.open_zarr(store=zhs_ro) 52 53 54 print(ds_ro) 55 xarray.testing.assert_identical(ds, ds_ro) 56 ``` 57 """ 58 59 _forced_read_only: bool | None = None # sentinel for wrapper clones 60 61 def __init__(self, hamt: HAMT, read_only: bool = False) -> None: 62 """ 63 ### `hamt` and `read_only` 64 You need to make sure the following two things are true: 65 66 1. The HAMT is in the same read only mode that you are passing into the Zarr store. This means that `hamt.read_only == read_only`. This is because making a HAMT read only automatically requires async operations, but `__init__` cannot be async. 67 2. The HAMT has `hamt.values_are_bytes == True`. This improves efficiency with Zarr v3 operations. 68 69 ##### A note about the zarr chunk separator, "/" vs "." 70 While Zarr v2 used periods by default, Zarr v3 uses forward slashes, and that is assumed here as well. 71 72 #### Metadata Read Cache 73 `ZarrHAMTStore` has an internal read cache for metadata. In practice metadata "zarr.json" files are very very frequently and duplicately requested compared to all other keys, and there are significant speed improvements gotten by implementing this cache. In terms of memory management, in practice this cache does not need an eviction step since "zarr.json" files are much smaller than the memory requirement of the zarr data. 74 """ 75 super().__init__(read_only=read_only) 76 77 assert hamt.read_only == read_only 78 assert hamt.values_are_bytes 79 self.hamt: HAMT = hamt 80 """ 81 The internal HAMT. 82 Once done with write operations, the hamt can be set to read only mode as usual to get your root node ID. 83 """ 84 85 self.metadata_read_cache: dict[str, bytes] = {} 86 """@private""" 87 88 def _map_byte_request( 89 self, byte_range: Optional[zarr.abc.store.ByteRequest] 90 ) -> tuple[Optional[int], Optional[int], Optional[int]]: 91 """Helper to map Zarr ByteRequest to offset, length, suffix.""" 92 offset: Optional[int] = None 93 length: Optional[int] = None 94 suffix: Optional[int] = None 95 96 if byte_range: 97 if isinstance(byte_range, zarr.abc.store.RangeByteRequest): 98 offset = byte_range.start 99 length = byte_range.end - byte_range.start 100 if length is not None and length < 0: 101 raise ValueError("End must be >= start for RangeByteRequest") 102 elif isinstance(byte_range, zarr.abc.store.OffsetByteRequest): 103 offset = byte_range.offset 104 elif isinstance(byte_range, zarr.abc.store.SuffixByteRequest): 105 suffix = byte_range.suffix 106 else: 107 raise TypeError(f"Unsupported ByteRequest type: {type(byte_range)}") 108 109 return offset, length, suffix 110 111 @property 112 def read_only(self) -> bool: # type: ignore[override] 113 if self._forced_read_only is not None: # instance attr overrides 114 return self._forced_read_only 115 return self.hamt.read_only 116 117 def with_read_only(self, read_only: bool = False) -> "ZarrHAMTStore": 118 """ 119 Return this store (if the flag already matches) or a *shallow* 120 clone that presents the requested read‑only status. 121 122 The clone **shares** the same :class:`~py_hamt.hamt.HAMT` 123 instance; no flushing, network traffic or async work is done. 124 """ 125 # Fast path 126 if read_only == self.read_only: 127 return self # Same mode, return same instance 128 129 # Create new instance with different read_only flag 130 # Creates a *bare* instance without running its __init__ 131 clone = type(self).__new__(type(self)) 132 133 # Copy attributes that matter 134 clone.hamt = self.hamt # Share the HAMT 135 clone._forced_read_only = read_only 136 clone.metadata_read_cache = self.metadata_read_cache.copy() 137 138 # Re‑initialise the zarr base class so that Zarr sees the flag 139 zarr.abc.store.Store.__init__(clone, read_only=read_only) 140 return clone 141 142 def __eq__(self, other: object) -> bool: 143 """@private""" 144 if not isinstance(other, ZarrHAMTStore): 145 return False 146 return self.hamt.root_node_id == other.hamt.root_node_id 147 148 async def get( 149 self, 150 key: str, 151 prototype: zarr.core.buffer.BufferPrototype, 152 byte_range: zarr.abc.store.ByteRequest | None = None, 153 ) -> zarr.core.buffer.Buffer | None: 154 """@private""" 155 with instrumentation.span( 156 "py_hamt.hamt_store.get", 157 { 158 "py_hamt.zarr.key": key, 159 "py_hamt.zarr.byte_range": byte_range is not None, 160 }, 161 ): 162 started_at = time.perf_counter() 163 hit = False 164 is_metadata = len(key) >= 9 and key[-9:] == "zarr.json" 165 try: 166 val: bytes 167 # do len check to avoid indexing into overly short strings, 3.12 does not throw errors but we dont know if other versions will 168 # if path ends with zarr.json 169 170 if ( 171 is_metadata 172 and byte_range is None 173 and key in self.metadata_read_cache 174 ): 175 val = self.metadata_read_cache[key] 176 else: 177 offset, length, suffix = self._map_byte_request(byte_range) 178 val = cast( 179 bytes, 180 await self.hamt.get( 181 key, offset=offset, length=length, suffix=suffix 182 ), 183 ) # We know values received will always be bytes since we only store bytes in the HAMT 184 if is_metadata and byte_range is None: 185 self.metadata_read_cache[key] = val 186 187 hit = True 188 return prototype.buffer.from_bytes(val) 189 except KeyError: 190 # Sometimes zarr queries keys that don't exist anymore, just return nothing on those cases 191 return None 192 except Exception as e: 193 print(f"Error getting key '{key}' with range {byte_range}: {e}") 194 raise 195 finally: 196 instrumentation.record_zarr_get( 197 store="hamt_store", 198 key=key, 199 kind="metadata" if is_metadata else "chunk", 200 hit=hit, 201 seconds=time.perf_counter() - started_at, 202 byte_range=byte_range is not None, 203 ) 204 205 async def get_partial_values( 206 self, 207 prototype: zarr.core.buffer.BufferPrototype, 208 key_ranges: Iterable[tuple[str, zarr.abc.store.ByteRequest | None]], 209 ) -> list[zarr.core.buffer.Buffer | None]: 210 """ 211 Retrieves multiple keys or byte ranges concurrently using asyncio.gather. 212 """ 213 tasks = [self.get(key, prototype, byte_range) for key, byte_range in key_ranges] 214 results = await asyncio.gather( 215 *tasks, return_exceptions=False 216 ) # Set return_exceptions=True for debugging 217 return results 218 219 async def exists(self, key: str) -> bool: 220 """@private""" 221 try: 222 await self.hamt.get(key) 223 return True 224 except KeyError: 225 return False 226 227 @property 228 def supports_writes(self) -> bool: 229 """@private""" 230 return not self.hamt.read_only 231 232 @property 233 def supports_partial_writes(self) -> bool: 234 """@private""" 235 return False 236 237 async def set(self, key: str, value: zarr.core.buffer.Buffer) -> None: 238 """Store a value and update any existing metadata cache entry.""" 239 if self.read_only: 240 raise Exception("Cannot write to a read-only store.") 241 242 raw_bytes = value.to_bytes() 243 await self.hamt.set(key, raw_bytes) 244 if key in self.metadata_read_cache: 245 self.metadata_read_cache[key] = raw_bytes 246 247 async def set_if_not_exists(self, key: str, value: zarr.core.buffer.Buffer) -> None: 248 """@private""" 249 if not (await self.exists(key)): 250 await self.set(key, value) 251 252 async def set_partial_values( 253 self, key_start_values: Iterable[tuple[str, int, BytesLike]] 254 ) -> None: 255 """@private""" 256 raise NotImplementedError 257 258 @property 259 def supports_deletes(self) -> bool: 260 """@private""" 261 return not self.hamt.read_only 262 263 async def delete(self, key: str) -> None: 264 """Delete a key and evict any cached metadata for it.""" 265 if self.read_only: 266 raise Exception("Cannot write to a read-only store.") 267 try: 268 await self.hamt.delete(key) 269 # It's fine if the key was not in the HAMT 270 # Sometimes zarr v3 calls deletes on keys that don't exist (or have already been deleted) for some reason, probably concurrency issues 271 except KeyError: 272 pass 273 self.metadata_read_cache.pop(key, None) 274 275 @property 276 def supports_listing(self) -> bool: 277 """@private""" 278 return True 279 280 async def list(self) -> AsyncIterator[str]: 281 """@private""" 282 async for key in self.hamt.keys(): 283 yield key 284 285 async def list_prefix(self, prefix: str) -> AsyncIterator[str]: 286 """@private""" 287 async for key in self.hamt.keys(): 288 if key.startswith(prefix): 289 yield key 290 291 async def list_dir(self, prefix: str) -> AsyncIterator[str]: 292 """ 293 @private 294 List *immediate* children that live directly under **prefix**. 295 296 This is similar to :py:meth:`list_prefix` but collapses everything 297 below the first ``"/"`` after *prefix*. Each child name is yielded 298 **exactly once** in the order of first appearance while scanning the 299 HAMT keys. 300 301 Parameters 302 ---------- 303 prefix : str 304 Logical directory path. *Must* end with ``"/"`` for the result to 305 make sense (e.g. ``"a/b/"``). 306 307 Yields 308 ------ 309 str 310 The name of each direct child (file or sub-directory) of *prefix*. 311 312 Examples 313 -------- 314 With keys :: 315 316 a/b/c/d 317 a/b/c/e 318 a/b/f 319 a/b/g/h/i 320 321 ``await list_dir("a/b/")`` produces :: 322 323 c 324 f 325 g 326 327 Notes 328 ----- 329 • Internally uses a :class:`set` to deduplicate names; memory grows 330 with the number of *unique* children, not the total number of keys. 331 • Order is **not** sorted; it reflects the first encounter while 332 iterating over :py:meth:`HAMT.keys`. 333 """ 334 seen_names: set[str] = set() 335 async for key in self.hamt.keys(): 336 if key.startswith(prefix): 337 suffix: str = key[len(prefix) :] 338 first_slash: int = suffix.find("/") 339 if first_slash == -1: 340 if suffix not in seen_names: 341 seen_names.add(suffix) 342 yield suffix 343 else: 344 name: str = suffix[0:first_slash] 345 if name not in seen_names: 346 seen_names.add(name) 347 yield name
Write and read Zarr v3s with a HAMT.
Read or write a Zarr-v3 store whose key/value pairs live inside a py-hamt mapping.
Keys are stored verbatim ("temp/c/0/0/0" → same string in HAMT) and
the value is the raw byte payload produced by Zarr. No additional
framing, compression, or encryption is applied by this class. For a zarr encryption example
see where metadata is available use the method in https://github.com/dClimate/jupyter-notebooks/blob/main/notebooks/202b%20-%20Encryption%20Example%20(Encryption%20with%20Zarr%20Codecs).ipynb
For a fully encrypted zarr store, where metadata is not available, please see
SimpleEncryptedZarrHAMTStore but we do not recommend using it.
A note about using the same ZarrHAMTStore for writing and then reading again
If you write a Zarr to a HAMT, and then change it to read only mode, it's best to reinitialize a new ZarrHAMTStore with the proper read only setting. This is because this class, to err on the safe side, will not touch its super class's settings.
Sample Code
# --- Write ---
ds: xarray.Dataset = # ...
cas: ContentAddressedStore = # ...
hamt: HAMT = # ... make sure values_are_bytes is True and read_only is False to enable writes
hamt = await HAMT.build(cas, values_are_bytes=True) # write-enabled
zhs = ZarrHAMTStore(hamt, read_only=False)
ds.to_zarr(store=zhs, mode="w", zarr_format=3)
await hamt.make_read_only() # flush + freeze
root_node_id = hamt.root_node_id
print(root_node_id)
# --- read ---
hamt_ro = await HAMT.build(
cas, root_node_id=root_cid, read_only=True, values_are_bytes=True
)
zhs_ro = ZarrHAMTStore(hamt_ro, read_only=True)
ds_ro = xarray.open_zarr(store=zhs_ro)
print(ds_ro)
xarray.testing.assert_identical(ds, ds_ro)
61 def __init__(self, hamt: HAMT, read_only: bool = False) -> None: 62 """ 63 ### `hamt` and `read_only` 64 You need to make sure the following two things are true: 65 66 1. The HAMT is in the same read only mode that you are passing into the Zarr store. This means that `hamt.read_only == read_only`. This is because making a HAMT read only automatically requires async operations, but `__init__` cannot be async. 67 2. The HAMT has `hamt.values_are_bytes == True`. This improves efficiency with Zarr v3 operations. 68 69 ##### A note about the zarr chunk separator, "/" vs "." 70 While Zarr v2 used periods by default, Zarr v3 uses forward slashes, and that is assumed here as well. 71 72 #### Metadata Read Cache 73 `ZarrHAMTStore` has an internal read cache for metadata. In practice metadata "zarr.json" files are very very frequently and duplicately requested compared to all other keys, and there are significant speed improvements gotten by implementing this cache. In terms of memory management, in practice this cache does not need an eviction step since "zarr.json" files are much smaller than the memory requirement of the zarr data. 74 """ 75 super().__init__(read_only=read_only) 76 77 assert hamt.read_only == read_only 78 assert hamt.values_are_bytes 79 self.hamt: HAMT = hamt 80 """ 81 The internal HAMT. 82 Once done with write operations, the hamt can be set to read only mode as usual to get your root node ID. 83 """ 84 85 self.metadata_read_cache: dict[str, bytes] = {} 86 """@private"""
hamt and read_only
You need to make sure the following two things are true:
- The HAMT is in the same read only mode that you are passing into the Zarr store. This means that
hamt.read_only == read_only. This is because making a HAMT read only automatically requires async operations, but__init__cannot be async. - The HAMT has
hamt.values_are_bytes == True. This improves efficiency with Zarr v3 operations.
A note about the zarr chunk separator, "/" vs "."
While Zarr v2 used periods by default, Zarr v3 uses forward slashes, and that is assumed here as well.
Metadata Read Cache
ZarrHAMTStore has an internal read cache for metadata. In practice metadata "zarr.json" files are very very frequently and duplicately requested compared to all other keys, and there are significant speed improvements gotten by implementing this cache. In terms of memory management, in practice this cache does not need an eviction step since "zarr.json" files are much smaller than the memory requirement of the zarr data.
The internal HAMT. Once done with write operations, the hamt can be set to read only mode as usual to get your root node ID.
111 @property 112 def read_only(self) -> bool: # type: ignore[override] 113 if self._forced_read_only is not None: # instance attr overrides 114 return self._forced_read_only 115 return self.hamt.read_only
Is the store read-only?
117 def with_read_only(self, read_only: bool = False) -> "ZarrHAMTStore": 118 """ 119 Return this store (if the flag already matches) or a *shallow* 120 clone that presents the requested read‑only status. 121 122 The clone **shares** the same :class:`~py_hamt.hamt.HAMT` 123 instance; no flushing, network traffic or async work is done. 124 """ 125 # Fast path 126 if read_only == self.read_only: 127 return self # Same mode, return same instance 128 129 # Create new instance with different read_only flag 130 # Creates a *bare* instance without running its __init__ 131 clone = type(self).__new__(type(self)) 132 133 # Copy attributes that matter 134 clone.hamt = self.hamt # Share the HAMT 135 clone._forced_read_only = read_only 136 clone.metadata_read_cache = self.metadata_read_cache.copy() 137 138 # Re‑initialise the zarr base class so that Zarr sees the flag 139 zarr.abc.store.Store.__init__(clone, read_only=read_only) 140 return clone
Return this store (if the flag already matches) or a shallow clone that presents the requested read‑only status.
The clone shares the same ~py_hamt.hamt.HAMT
instance; no flushing, network traffic or async work is done.
205 async def get_partial_values( 206 self, 207 prototype: zarr.core.buffer.BufferPrototype, 208 key_ranges: Iterable[tuple[str, zarr.abc.store.ByteRequest | None]], 209 ) -> list[zarr.core.buffer.Buffer | None]: 210 """ 211 Retrieves multiple keys or byte ranges concurrently using asyncio.gather. 212 """ 213 tasks = [self.get(key, prototype, byte_range) for key, byte_range in key_ranges] 214 results = await asyncio.gather( 215 *tasks, return_exceptions=False 216 ) # Set return_exceptions=True for debugging 217 return results
Retrieves multiple keys or byte ranges concurrently using asyncio.gather.
237 async def set(self, key: str, value: zarr.core.buffer.Buffer) -> None: 238 """Store a value and update any existing metadata cache entry.""" 239 if self.read_only: 240 raise Exception("Cannot write to a read-only store.") 241 242 raw_bytes = value.to_bytes() 243 await self.hamt.set(key, raw_bytes) 244 if key in self.metadata_read_cache: 245 self.metadata_read_cache[key] = raw_bytes
Store a value and update any existing metadata cache entry.
263 async def delete(self, key: str) -> None: 264 """Delete a key and evict any cached metadata for it.""" 265 if self.read_only: 266 raise Exception("Cannot write to a read-only store.") 267 try: 268 await self.hamt.delete(key) 269 # It's fine if the key was not in the HAMT 270 # Sometimes zarr v3 calls deletes on keys that don't exist (or have already been deleted) for some reason, probably concurrency issues 271 except KeyError: 272 pass 273 self.metadata_read_cache.pop(key, None)
Delete a key and evict any cached metadata for it.
13class SimpleEncryptedZarrHAMTStore(ZarrHAMTStore): 14 """ 15 Write and read Zarr v3s with a HAMT, encrypting *everything* for maximum privacy. 16 17 This store uses ChaCha20-Poly1305 to encrypt every single key-value pair 18 stored in the Zarr, including all metadata (`zarr.json`, `.zarray`, etc.) 19 and data chunks. This provides strong privacy but means the Zarr store is 20 completely opaque and unusable without the correct encryption key and header. 21 22 Note: For standard zarr encryption and decryption where metadata is available use the method in https://github.com/dClimate/jupyter-notebooks/blob/main/notebooks/202b%20-%20Encryption%20Example%20(Encryption%20with%20Zarr%20Codecs).ipynb 23 24 #### Encryption Details 25 - Uses XChaCha20_Poly1305 (via pycryptodome's ChaCha20_Poly1305 with a 24-byte nonce). 26 - Requires a 32-byte encryption key and a header. 27 - Each encrypted value includes a 24-byte nonce and a 16-byte tag. 28 29 #### Important Considerations 30 - Since metadata is encrypted, standard Zarr tools cannot inspect the 31 dataset without prior decryption using this store class. 32 - There is no support for partial encryption or excluding variables. 33 - There is no metadata caching. 34 35 #### Sample Code 36 ```python 37 import xarray 38 from py_hamt import HAMT, KuboCAS # Assuming an KuboCAS or similar 39 from Crypto.Random import get_random_bytes 40 import numpy as np 41 42 # Setup 43 ds = xarray.Dataset( 44 {"data": (("y", "x"), np.arange(12).reshape(3, 4))}, 45 coords={"y": [1, 2, 3], "x": [10, 20, 30, 40]} 46 ) 47 cas = KuboCAS() # Example ContentAddressedStore 48 encryption_key = get_random_bytes(32) 49 header = b"fully-encrypted-zarr" 50 51 # --- Write --- 52 hamt_write = await HAMT.build(cas=cas, values_are_bytes=True) 53 ezhs_write = SimpleEncryptedZarrHAMTStore( 54 hamt_write, False, encryption_key, header 55 ) 56 print("Writing fully encrypted Zarr...") 57 ds.to_zarr(store=ezhs_write, mode="w") 58 await hamt_write.make_read_only() 59 root_node_id = hamt_write.root_node_id 60 print(f"Wrote Zarr with root: {root_node_id}") 61 62 # --- Read --- 63 hamt_read = await HAMT.build( 64 cas=cas, root_node_id=root_node_id, values_are_bytes=True, read_only=True 65 ) 66 ezhs_read = SimpleEncryptedZarrHAMTStore( 67 hamt_read, True, encryption_key, header 68 ) 69 print("\nReading fully encrypted Zarr...") 70 ds_read = xarray.open_zarr(store=ezhs_read) 71 print("Read back dataset:") 72 print(ds_read) 73 xarray.testing.assert_identical(ds, ds_read) 74 print("Read successful and data verified.") 75 76 # --- Read with wrong key (demonstrates failure) --- 77 wrong_key = get_random_bytes(32) 78 hamt_bad = await HAMT.build( 79 cas=cas, root_node_id=root_node_id, read_only=True, values_are_bytes=True 80 ) 81 ezhs_bad = SimpleEncryptedZarrHAMTStore( 82 hamt_bad, True, wrong_key, header 83 ) 84 print("\nAttempting to read with wrong key...") 85 try: 86 ds_bad = xarray.open_zarr(store=ezhs_bad) 87 print(ds_bad) 88 except Exception as e: 89 print(f"Failed to read as expected: {type(e).__name__} - {e}") 90 ``` 91 """ 92 93 def __init__( 94 self, hamt: HAMT, read_only: bool, encryption_key: bytes, header: bytes 95 ) -> None: 96 """ 97 Initializes the SimpleEncryptedZarrHAMTStore. 98 99 Args: 100 hamt: The HAMT instance for storage. Must have `values_are_bytes=True`. 101 Its `read_only` status must match the `read_only` argument. 102 read_only: If True, the store is in read-only mode. 103 encryption_key: A 32-byte key for ChaCha20-Poly1305. 104 header: A header (bytes) used as associated data in encryption. 105 """ 106 super().__init__(hamt, read_only=read_only) 107 108 if len(encryption_key) != 32: 109 raise ValueError("Encryption key must be exactly 32 bytes long.") 110 self.encryption_key = encryption_key 111 self.header = header 112 self.metadata_read_cache: dict[str, bytes] = {} 113 114 def with_read_only(self, read_only: bool = False) -> "SimpleEncryptedZarrHAMTStore": 115 if read_only == self.read_only: 116 return self 117 118 clone = type(self).__new__(type(self)) 119 clone.hamt = self.hamt 120 clone.encryption_key = self.encryption_key 121 clone.header = self.header 122 clone.metadata_read_cache = self.metadata_read_cache 123 clone._forced_read_only = read_only # safe; attribute is declared 124 zarr.abc.store.Store.__init__(clone, read_only=read_only) 125 return clone 126 127 def _encrypt(self, val: bytes) -> bytes: 128 """Encrypts data using ChaCha20-Poly1305.""" 129 nonce = get_random_bytes(24) # XChaCha20 uses a 24-byte nonce 130 cipher = ChaCha20_Poly1305.new(key=self.encryption_key, nonce=nonce) 131 cipher.update(self.header) 132 ciphertext, tag = cipher.encrypt_and_digest(val) 133 return nonce + tag + ciphertext 134 135 def _decrypt(self, val: bytes) -> bytes: 136 """Decrypts data using ChaCha20-Poly1305.""" 137 try: 138 # Extract nonce (24), tag (16), and ciphertext 139 nonce, tag, ciphertext = val[:24], val[24:40], val[40:] 140 cipher = ChaCha20_Poly1305.new(key=self.encryption_key, nonce=nonce) 141 cipher.update(self.header) 142 plaintext = cipher.decrypt_and_verify(ciphertext, tag) 143 return plaintext 144 except Exception as e: 145 # Catching a broad exception as various issues (key, tag, length) can occur. 146 raise ValueError( 147 "Decryption failed. Check key, header, or data integrity." 148 ) from e 149 150 def __eq__(self, other: object) -> bool: 151 """@private""" 152 if not isinstance(other, SimpleEncryptedZarrHAMTStore): 153 return False 154 return ( 155 self.hamt.root_node_id == other.hamt.root_node_id 156 and self.encryption_key == other.encryption_key 157 and self.header == other.header 158 ) 159 160 async def get( 161 self, 162 key: str, 163 prototype: zarr.core.buffer.BufferPrototype, 164 byte_range: zarr.abc.store.ByteRequest | None = None, 165 ) -> zarr.core.buffer.Buffer | None: 166 """@private""" 167 try: 168 decrypted_val: bytes 169 is_metadata: bool = ( 170 len(key) >= 9 and key[-9:] == "zarr.json" 171 ) # if path ends with zarr.json 172 173 if is_metadata and key in self.metadata_read_cache: 174 decrypted_val = self.metadata_read_cache[key] 175 else: 176 raw_val = cast( 177 bytes, await self.hamt.get(key) 178 ) # We know values received will always be bytes since we only store bytes in the HAMT 179 decrypted_val = self._decrypt(raw_val) 180 if is_metadata: 181 self.metadata_read_cache[key] = decrypted_val 182 return prototype.buffer.from_bytes(decrypted_val) 183 except KeyError: 184 return None 185 186 async def set(self, key: str, value: zarr.core.buffer.Buffer) -> None: 187 """Encrypt and store a value, then update cached metadata.""" 188 if self.read_only: 189 raise Exception("Cannot write to a read-only store.") 190 191 raw_bytes = value.to_bytes() 192 # Encrypt it 193 encrypted_bytes = self._encrypt(raw_bytes) 194 await self.hamt.set(key, encrypted_bytes) 195 if key in self.metadata_read_cache: 196 self.metadata_read_cache[key] = raw_bytes
Write and read Zarr v3s with a HAMT, encrypting everything for maximum privacy.
This store uses ChaCha20-Poly1305 to encrypt every single key-value pair
stored in the Zarr, including all metadata (`zarr.json`, `.zarray`, etc.)
and data chunks. This provides strong privacy but means the Zarr store is
completely opaque and unusable without the correct encryption key and header.
Note: For standard zarr encryption and decryption where metadata is available use the method in https://github.com/dClimate/jupyter-notebooks/blob/main/notebooks/202b%20-%20Encryption%20Example%20(Encryption%20with%20Zarr%20Codecs).ipynb
#### Encryption Details
- Uses XChaCha20_Poly1305 (via pycryptodome's ChaCha20_Poly1305 with a 24-byte nonce).
- Requires a 32-byte encryption key and a header.
- Each encrypted value includes a 24-byte nonce and a 16-byte tag.
#### Important Considerations
- Since metadata is encrypted, standard Zarr tools cannot inspect the
dataset without prior decryption using this store class.
- There is no support for partial encryption or excluding variables.
- There is no metadata caching.
#### Sample Code
import xarray
from py_hamt import HAMT, KuboCAS # Assuming an KuboCAS or similar
from Crypto.Random import get_random_bytes
import numpy as np
# Setup
ds = xarray.Dataset(
{"data": (("y", "x"), np.arange(12).reshape(3, 4))},
coords={"y": [1, 2, 3], "x": [10, 20, 30, 40]}
)
cas = KuboCAS() # Example ContentAddressedStore
encryption_key = get_random_bytes(32)
header = b"fully-encrypted-zarr"
# --- Write ---
hamt_write = await HAMT.build(cas=cas, values_are_bytes=True)
ezhs_write = SimpleEncryptedZarrHAMTStore(
hamt_write, False, encryption_key, header
)
print("Writing fully encrypted Zarr...")
ds.to_zarr(store=ezhs_write, mode="w")
await hamt_write.make_read_only()
root_node_id = hamt_write.root_node_id
print(f"Wrote Zarr with root: {root_node_id}")
# --- Read ---
hamt_read = await HAMT.build(
cas=cas, root_node_id=root_node_id, values_are_bytes=True, read_only=True
)
ezhs_read = SimpleEncryptedZarrHAMTStore(
hamt_read, True, encryption_key, header
)
print("
Reading fully encrypted Zarr...")
ds_read = xarray.open_zarr(store=ezhs_read)
print("Read back dataset:")
print(ds_read)
xarray.testing.assert_identical(ds, ds_read)
print("Read successful and data verified.")
# --- Read with wrong key (demonstrates failure) ---
wrong_key = get_random_bytes(32)
hamt_bad = await HAMT.build(
cas=cas, root_node_id=root_node_id, read_only=True, values_are_bytes=True
)
ezhs_bad = SimpleEncryptedZarrHAMTStore(
hamt_bad, True, wrong_key, header
)
print("
Attempting to read with wrong key...")
try:
ds_bad = xarray.open_zarr(store=ezhs_bad)
print(ds_bad)
except Exception as e:
print(f"Failed to read as expected: {type(e).__name__} - {e}")
93 def __init__( 94 self, hamt: HAMT, read_only: bool, encryption_key: bytes, header: bytes 95 ) -> None: 96 """ 97 Initializes the SimpleEncryptedZarrHAMTStore. 98 99 Args: 100 hamt: The HAMT instance for storage. Must have `values_are_bytes=True`. 101 Its `read_only` status must match the `read_only` argument. 102 read_only: If True, the store is in read-only mode. 103 encryption_key: A 32-byte key for ChaCha20-Poly1305. 104 header: A header (bytes) used as associated data in encryption. 105 """ 106 super().__init__(hamt, read_only=read_only) 107 108 if len(encryption_key) != 32: 109 raise ValueError("Encryption key must be exactly 32 bytes long.") 110 self.encryption_key = encryption_key 111 self.header = header 112 self.metadata_read_cache: dict[str, bytes] = {}
Initializes the SimpleEncryptedZarrHAMTStore.
Args:
hamt: The HAMT instance for storage. Must have values_are_bytes=True.
Its read_only status must match the read_only argument.
read_only: If True, the store is in read-only mode.
encryption_key: A 32-byte key for ChaCha20-Poly1305.
header: A header (bytes) used as associated data in encryption.
114 def with_read_only(self, read_only: bool = False) -> "SimpleEncryptedZarrHAMTStore": 115 if read_only == self.read_only: 116 return self 117 118 clone = type(self).__new__(type(self)) 119 clone.hamt = self.hamt 120 clone.encryption_key = self.encryption_key 121 clone.header = self.header 122 clone.metadata_read_cache = self.metadata_read_cache 123 clone._forced_read_only = read_only # safe; attribute is declared 124 zarr.abc.store.Store.__init__(clone, read_only=read_only) 125 return clone
Return this store (if the flag already matches) or a shallow clone that presents the requested read‑only status.
The clone shares the same ~py_hamt.hamt.HAMT
instance; no flushing, network traffic or async work is done.
186 async def set(self, key: str, value: zarr.core.buffer.Buffer) -> None: 187 """Encrypt and store a value, then update cached metadata.""" 188 if self.read_only: 189 raise Exception("Cannot write to a read-only store.") 190 191 raw_bytes = value.to_bytes() 192 # Encrypt it 193 encrypted_bytes = self._encrypt(raw_bytes) 194 await self.hamt.set(key, encrypted_bytes) 195 if key in self.metadata_read_cache: 196 self.metadata_read_cache[key] = raw_bytes
Encrypt and store a value, then update cached metadata.
445class ShardedZarrStore(zarr.abc.store.Store): 446 """ 447 Implements the Zarr Store API using a sharded layout for chunk CIDs. 448 449 ``sharded_zarr_v1`` roots keep the original single global shard index for 450 compatibility. ``sharded_zarr_v2`` roots keep one shard index per Zarr array 451 path, allowing grouped arrays to reuse chunk coordinates without collisions. 452 """ 453 454 _V1_COORDINATE_ARRAY_PREFIXES: ClassVar[frozenset[str]] = frozenset({ 455 "time", 456 "lat", 457 "lon", 458 "latitude", 459 "longitude", 460 "forecast_reference_time", 461 "step", 462 }) 463 _V1_DEPRECATION_MESSAGE: ClassVar[str] = ( 464 "sharded_zarr_v1 is deprecated and will be removed in a future py-hamt " 465 "release. Prefer sharded_zarr_v2 for new stores; pyramid Zarr readers " 466 "should open the desired group explicitly, for example group='0'." 467 ) 468 _V2_MULTI_GROUP_READ_MESSAGE: ClassVar[str] = ( 469 "sharded_zarr_v2 stores with multiple top-level groups require an " 470 "explicit Zarr group when reading. Open the desired pyramid level with " 471 "xr.open_zarr(..., group='0') or another available group." 472 ) 473 _V2_WRITE_GROUP_MESSAGE: ClassVar[str] = ( 474 "sharded_zarr_v2 writes require an explicit Zarr group. Write the " 475 "dataset with ds.to_zarr(..., group='0') or another group name." 476 ) 477 # Sparse reads of a *single* shard before "auto" latches the whole store to 478 # full decodes. Measured crossover, one CAS round trip per sparse entry: 479 # 480 # lookups full sparse 481 # 1 775ms 3.6ms 482 # 32 775ms 82.2ms 483 # 128 830ms 340.0ms 484 # 256 861ms 706.3ms <- sparse still ahead 485 # 512 832ms 1334.9ms <- full ahead 486 # 487 # Sparse costs ~2.6ms/lookup against a ~800ms flat full decode, so the 488 # break-even is ~300; 256 trips just before it. Unlike the jaxray reference 489 # (threshold 32, local blockstore reads), this is a *scan-detection* 490 # threshold rather than a per-shard promotion point: it is paid once for the 491 # whole store, not once per shard. 492 _SPARSE_PROMOTE_THRESHOLD: ClassVar[int] = 256 493 494 def __init__( 495 self, 496 cas: ContentAddressedStore, 497 read_only: bool, 498 root_cid: Optional[str] = None, 499 *, 500 max_cache_memory_bytes: int = 100 * 1024 * 1024, # 100MB default 501 shard_read_mode: ShardReadMode = "auto", 502 ): 503 """Use the async `open()` classmethod to instantiate this class.""" 504 super().__init__(read_only=read_only) 505 if shard_read_mode not in {"full", "sparse", "auto"}: 506 raise ValueError( 507 f"Unsupported shard_read_mode: {shard_read_mode!r}. " 508 "Expected 'full', 'sparse', or 'auto'." 509 ) 510 self.cas = cas 511 self._root_cid = root_cid 512 self.shard_read_mode = shard_read_mode 513 self._root_obj: dict = {} 514 self._manifest_version = SHARDED_ZARR_V1 515 516 self._resize_lock = asyncio.Lock() 517 self._resize_complete = asyncio.Event() 518 self._resize_complete.set() 519 self._write_lock = asyncio.Lock() 520 self._shard_locks: DefaultDict[ShardCacheKey, asyncio.Lock] = defaultdict( 521 asyncio.Lock 522 ) 523 524 self._shard_data_cache = MemoryBoundedLRUCache(max_cache_memory_bytes) 525 self._pending_shard_loads: Dict[ShardCacheKey, asyncio.Event] = {} 526 # Per-shard sparse-read counts, used only to detect the scan pattern in 527 # "auto" mode. Detection is per-shard because 256 reads spread across 528 # 256 distinct shards is a point-read workload, not a scan. 529 self._sparse_read_counts: Dict[ShardCacheKey, int] = {} 530 # Latched once any single shard crosses the threshold: the caller is 531 # scanning, so every shard gets the full path from here on. Store-wide 532 # because the access pattern belongs to the caller, not the shard. 533 # 534 # Held in a one-element list so with_read_only clones share the *cell* 535 # rather than a copied bool. They already share the counters and the 536 # cache, and a clone that latched would otherwise clear those shared 537 # counters while leaving its siblings believing they were still sparse 538 # -- so the next clone would resume sparse reads with no counter left 539 # to re-earn promotion. See _full_mode_latched. 540 self._full_mode_latched_cell: List[bool] = [False] 541 self._metadata_read_cache: Dict[str, bytes] = {} 542 543 self.array_indices: Dict[str, ArrayIndex] = {} 544 self._primary_array_path: Optional[str] = None 545 # True only when the primary path was heuristically inferred for a 546 # legacy V1 root (never recorded on disk). Tracked separately from the 547 # path's truthiness because an inferred *root* primary is "", which is 548 # indistinguishable from "not inferred" under a plain truthiness check. 549 self._primary_inferred: bool = False 550 self._default_chunks_per_shard: Optional[int] = None 551 552 self._array_shape: Tuple[int, ...] = () 553 self._chunk_shape: Tuple[int, ...] = () 554 self._chunks_per_dim: Tuple[int, ...] = () 555 self._chunks_per_shard: int = 0 556 self._num_shards: int = 0 557 self._total_chunks: int = 0 558 559 self._dirty_root = False 560 self._v2_pending_root_group_write = False 561 562 @staticmethod 563 def _normalize_array_path(array_path: str) -> str: 564 return array_path.strip("/") 565 566 @staticmethod 567 def _array_path_from_metadata_key(key: str) -> Optional[str]: 568 if key in {"zarr.json", ".zarray"}: 569 return "" 570 if key.endswith("/zarr.json"): 571 return key[: -len("/zarr.json")] 572 if key.endswith("/.zarray"): 573 return key[: -len("/.zarray")] 574 return None 575 576 @staticmethod 577 def _group_path_from_metadata_key(key: str) -> Optional[str]: 578 if key in {"zarr.json", ".zgroup"}: 579 return "" 580 if key.endswith("/zarr.json"): 581 return key[: -len("/zarr.json")] 582 if key.endswith("/.zgroup"): 583 return key[: -len("/.zgroup")] 584 return None 585 586 @staticmethod 587 def _format_chunk_key(array_path: str, coords: tuple[int, ...]) -> str: 588 coord_path = "/".join(str(coord) for coord in coords) 589 if array_path: 590 return f"{array_path}/c/{coord_path}" 591 return f"c/{coord_path}" 592 593 @staticmethod 594 def _v2_group_metadata_key(group_path: str) -> str: 595 return ".zgroup" if group_path == "" else f"{group_path}/.zgroup" 596 597 @staticmethod 598 def _v3_group_metadata_key(group_path: str) -> str: 599 return "zarr.json" if group_path == "" else f"{group_path}/zarr.json" 600 601 @staticmethod 602 def _coords_from_linear_index( 603 linear_index: int, chunks_per_dim: tuple[int, ...] 604 ) -> tuple[int, ...]: 605 coords: list[int] = [] 606 remaining = linear_index 607 for stride in reversed(chunks_per_dim): 608 coords.append(remaining % stride) 609 remaining //= stride 610 return tuple(reversed(coords)) 611 612 def __update_geometry(self) -> None: 613 """Calculates legacy v1 geometric properties from the base shapes.""" 614 index = ArrayIndex.new( 615 array_path="", 616 array_shape=self._array_shape, 617 chunk_shape=self._chunk_shape, 618 chunks_per_shard=self._chunks_per_shard, 619 ) 620 self._chunks_per_dim = index.chunks_per_dim 621 self._total_chunks = index.total_chunks 622 self._num_shards = index.num_shards 623 624 @classmethod 625 def _warn_v1_deprecated(cls, *, stacklevel: int) -> None: 626 warnings.warn( 627 cls._V1_DEPRECATION_MESSAGE, 628 ShardedZarrV1DeprecationWarning, 629 stacklevel=stacklevel, 630 ) 631 632 @classmethod 633 async def open( 634 cls, 635 cas: ContentAddressedStore, 636 read_only: bool, 637 root_cid: Optional[str] = None, 638 *, 639 array_shape: Optional[Tuple[int, ...]] = None, 640 chunk_shape: Optional[Tuple[int, ...]] = None, 641 chunks_per_shard: Optional[int] = None, 642 max_cache_memory_bytes: int = 100 * 1024 * 1024, # 100MB default 643 manifest_version: Optional[str] = None, 644 primary_array_path: str = "", 645 shard_read_mode: ShardReadMode = "auto", 646 ) -> "ShardedZarrStore": 647 """ 648 Asynchronously opens an existing ShardedZarrStore or initializes a new one. 649 650 Shape-based creation remains the v1 compatibility path. To create a new 651 path-aware v2 store, pass ``manifest_version="sharded_zarr_v2"`` or omit 652 ``array_shape``/``chunk_shape`` and provide ``chunks_per_shard``. 653 654 ``shard_read_mode`` controls how a **read-only** cache miss resolves a 655 chunk pointer. It has no effect on writes: a writable store always goes 656 through the shard cache so pending writes stay visible, so writes behave 657 as ``"full"`` does regardless of this setting. 658 659 - ``"auto"`` (the default) starts sparse, then latches the **entire 660 store** to full decodes once any *single* shard has been read 661 ``_SPARSE_PROMOTE_THRESHOLD`` times. The latch is store-wide because 662 the access pattern belongs to the caller rather than the shard: a 663 caller reading one shard that heavily is scanning and will scan the 664 rest too, so making every other shard re-learn that independently 665 would re-pay the detection cost on each one. It is permanent for the 666 store's lifetime and unaffected by cache eviction. Below the 667 threshold it is byte-for-byte the ``"sparse"`` path, so point reads 668 pay nothing for the safety net. 669 - ``"sparse"`` fetches only the requested entry and caches nothing. Far 670 cheaper for point reads, but degrades without bound on a scan, 671 eventually costing more than ``"full"``. Pin this when you know the 672 workload is point reads and want to rule out the latch entirely -- 673 for instance a long-lived reader that hammers one hot shard without 674 ever scanning, which ``"auto"`` would latch on. 675 - ``"full"`` decodes and caches the whole shard. Flat cost regardless of 676 how many chunks are then read from it, so it suits known scans and 677 skips ``"auto"``'s detection cost. 678 """ 679 store = cls( 680 cas, 681 read_only, 682 root_cid, 683 max_cache_memory_bytes=max_cache_memory_bytes, 684 shard_read_mode=shard_read_mode, 685 ) 686 if root_cid: 687 await store._load_root_from_cid() 688 elif not read_only: 689 if manifest_version not in {None, SHARDED_ZARR_V1, SHARDED_ZARR_V2}: 690 raise ValueError(f"Incompatible manifest version: {manifest_version}.") 691 692 if ( 693 manifest_version in {None, SHARDED_ZARR_V1} 694 and array_shape is None 695 and chunk_shape is None 696 and chunks_per_shard is None 697 ): 698 raise ValueError( 699 "array_shape and chunk_shape must be provided for a new store." 700 ) 701 if manifest_version in {None, SHARDED_ZARR_V1} and ( 702 (array_shape is None) != (chunk_shape is None) 703 ): 704 raise ValueError( 705 "array_shape and chunk_shape must be provided for a new store." 706 ) 707 if manifest_version == SHARDED_ZARR_V1 and ( 708 array_shape is None or chunk_shape is None 709 ): 710 raise ValueError( 711 "array_shape and chunk_shape must be provided for a new store." 712 ) 713 714 if not isinstance(chunks_per_shard, int) or chunks_per_shard <= 0: 715 raise ValueError("chunks_per_shard must be a positive integer.") 716 717 use_v2 = manifest_version == SHARDED_ZARR_V2 or ( 718 array_shape is None and chunk_shape is None 719 ) 720 if use_v2: 721 if (array_shape is None) != (chunk_shape is None): 722 raise ValueError( 723 "array_shape and chunk_shape must both be provided when seeding a v2 array index." 724 ) 725 store._initialize_new_root_v2( 726 chunks_per_shard=chunks_per_shard, 727 array_shape=array_shape, 728 chunk_shape=chunk_shape, 729 primary_array_path=primary_array_path, 730 ) 731 else: 732 if array_shape is None or chunk_shape is None: # pragma: no cover 733 raise ValueError( 734 "array_shape and chunk_shape must be provided for a new store." 735 ) 736 store._initialize_new_root(array_shape, chunk_shape, chunks_per_shard) 737 else: 738 raise ValueError("root_cid must be provided for a read-only store.") 739 return store 740 741 def _initialize_new_root( 742 self, 743 array_shape: Tuple[int, ...], 744 chunk_shape: Tuple[int, ...], 745 chunks_per_shard: int, 746 ) -> None: 747 self._warn_v1_deprecated(stacklevel=4) 748 self._manifest_version = SHARDED_ZARR_V1 749 self._array_shape = tuple(array_shape) 750 self._chunk_shape = tuple(chunk_shape) 751 self._chunks_per_shard = chunks_per_shard 752 self._default_chunks_per_shard = chunks_per_shard 753 754 self.__update_geometry() 755 756 self._root_obj = { 757 "manifest_version": SHARDED_ZARR_V1, 758 "metadata": {}, 759 "chunks": { 760 "array_shape": list(self._array_shape), 761 "chunk_shape": list(self._chunk_shape), 762 "sharding_config": { 763 "chunks_per_shard": self._chunks_per_shard, 764 }, 765 "shard_cids": [None] * self._num_shards, 766 }, 767 } 768 self.array_indices = { 769 "": ArrayIndex( 770 array_path="", 771 array_shape=self._array_shape, 772 chunk_shape=self._chunk_shape, 773 chunks_per_shard=self._chunks_per_shard, 774 shard_cids=self._root_obj["chunks"]["shard_cids"], 775 ) 776 } 777 self._primary_array_path = "" 778 self._dirty_root = True 779 780 def _initialize_new_root_v2( 781 self, 782 *, 783 chunks_per_shard: int, 784 array_shape: Optional[Tuple[int, ...]] = None, 785 chunk_shape: Optional[Tuple[int, ...]] = None, 786 primary_array_path: str = "", 787 ) -> None: 788 self._manifest_version = SHARDED_ZARR_V2 789 self._default_chunks_per_shard = chunks_per_shard 790 self._root_obj = { 791 "manifest_version": SHARDED_ZARR_V2, 792 "store_type": "py_hamt.sharded_zarr", 793 "zarr_format": 3, 794 "sharding_config": { 795 "chunks_per_shard": chunks_per_shard, 796 "order": "C", 797 }, 798 "metadata": {}, 799 "arrays": {}, 800 } 801 self.array_indices = {} 802 self._primary_array_path = None 803 self._array_shape = () 804 self._chunk_shape = () 805 self._chunks_per_dim = () 806 self._chunks_per_shard = chunks_per_shard 807 self._num_shards = 0 808 self._total_chunks = 0 809 810 if array_shape is not None and chunk_shape is not None: 811 self._register_or_update_array_index( 812 array_path=primary_array_path, 813 array_shape=tuple(array_shape), 814 chunk_shape=tuple(chunk_shape), 815 chunks_per_shard=chunks_per_shard, 816 ) 817 self._dirty_root = True 818 819 async def _load_root_from_cid(self) -> None: 820 root_bytes = await self.cas.load(self._root_cid) 821 try: 822 decoded_root = dag_cbor.decode(root_bytes) 823 if not isinstance(decoded_root, dict): 824 raise ValueError("Root object is not a valid dictionary.") 825 self._root_obj = decoded_root 826 except Exception as e: 827 raise ValueError(f"Failed to decode root object: {e}") from e 828 829 manifest_version = self._root_obj.get("manifest_version") 830 if manifest_version == SHARDED_ZARR_V1: 831 self._load_v1_root() 832 await self._infer_v1_legacy_primary_array_path() 833 elif manifest_version == SHARDED_ZARR_V2: 834 self._load_v2_root() 835 else: 836 raise ValueError( 837 f"Incompatible manifest version: {manifest_version!r}. Expected '{SHARDED_ZARR_V1}' or '{SHARDED_ZARR_V2}'." 838 ) 839 840 def _load_v1_root(self) -> None: 841 self._warn_v1_deprecated(stacklevel=5) 842 if "chunks" not in self._root_obj: 843 raise ValueError("Root object is not a valid dictionary with 'chunks' key.") 844 chunk_info = self._root_obj["chunks"] 845 if not isinstance(chunk_info.get("shard_cids"), list): 846 raise ValueError("shard_cids is not a list.") 847 848 self._manifest_version = SHARDED_ZARR_V1 849 self._array_shape = tuple(chunk_info["array_shape"]) 850 self._chunk_shape = tuple(chunk_info["chunk_shape"]) 851 self._chunks_per_shard = chunk_info["sharding_config"]["chunks_per_shard"] 852 self._default_chunks_per_shard = self._chunks_per_shard 853 854 self.__update_geometry() 855 856 if len(chunk_info["shard_cids"]) != self._num_shards: 857 raise ValueError( 858 f"Inconsistent number of shards. Expected {self._num_shards}, found {len(chunk_info['shard_cids'])}." 859 ) 860 self.array_indices = { 861 "": ArrayIndex( 862 array_path="", 863 array_shape=self._array_shape, 864 chunk_shape=self._chunk_shape, 865 chunks_per_shard=self._chunks_per_shard, 866 shard_cids=chunk_info["shard_cids"], 867 ) 868 } 869 primary_array_path = chunk_info.get("primary_array_path", "") 870 self._primary_array_path = ( 871 self._normalize_array_path(primary_array_path) 872 if isinstance(primary_array_path, str) 873 else "" 874 ) 875 876 async def _infer_v1_legacy_primary_array_path(self) -> None: 877 chunk_info = self._root_obj["chunks"] 878 # Only legacy roots missing the field may be inferred; recorded values win. 879 if "primary_array_path" in chunk_info: 880 return 881 882 metadata = self._root_obj.get("metadata") 883 if not isinstance(metadata, dict): 884 return 885 886 candidates: list[tuple[str, IPLDKind]] = [] 887 for key, metadata_cid in metadata.items(): 888 if not isinstance(key, str): 889 continue 890 array_path = self._array_path_from_metadata_key(key) 891 if array_path is None: 892 continue 893 normalized_path = self._normalize_array_path(array_path) 894 if normalized_path.rsplit("/", 1)[-1] in self._V1_COORDINATE_ARRAY_PREFIXES: 895 continue 896 897 candidates.append((normalized_path, metadata_cid)) 898 899 matching_paths: set[str] = set() 900 for batch_start in range(0, len(candidates), _V1_INFERENCE_CONCURRENCY): 901 batch = candidates[batch_start : batch_start + _V1_INFERENCE_CONCURRENCY] 902 metadata_results = await asyncio.gather( 903 *(self.cas.load(metadata_cid) for _, metadata_cid in batch), 904 return_exceptions=True, 905 ) 906 907 for (normalized_path, _), metadata_result in zip(batch, metadata_results): 908 if isinstance(metadata_result, BaseException): 909 if isinstance(metadata_result, asyncio.CancelledError): 910 raise metadata_result 911 # A candidate we could not read might have been the true 912 # primary. Guessing from the survivors could rebind shard 913 # data under the wrong prefix, so abort the inference and 914 # keep the legacy default instead. 915 return 916 metadata_json = self._decode_metadata_json(metadata_result) 917 if metadata_json is None: 918 continue 919 declared_shape = metadata_json.get("shape") 920 if not ( 921 isinstance(declared_shape, (list, tuple)) 922 and tuple(declared_shape) == tuple(self._array_shape) 923 ): 924 continue 925 declared_chunk_shape = self._declared_chunk_shape(metadata_json) 926 if declared_chunk_shape != tuple(self._chunk_shape): 927 continue 928 matching_paths.add(normalized_path) 929 930 # Once two different paths match, later metadata cannot make the 931 # inference unambiguous. Avoid issuing more remote CAS requests. 932 if len(matching_paths) > 1: 933 return 934 935 if len(matching_paths) == 1: 936 # Inference only lands here when exactly one candidate matches, so 937 # the identified primary is correct. The flag keeps the 938 # (possibly empty-string) inferred primary exclusive so foreign 939 # chunk writes route to metadata instead of rebinding over it. 940 self._primary_array_path = next(iter(matching_paths)) 941 self._primary_inferred = True 942 if not self.read_only: 943 # Persist the unambiguous inference so a later reopen routes from 944 # a recorded primary rather than re-inferring. This is what makes 945 # routing deterministic: without it, adding a second 946 # same-geometry array would make the reopen inference ambiguous 947 # and misroute a metadata-stored chunk into the shard slot. 948 # Read-only opens cannot flush, so they rely on the flag alone. 949 self._root_obj["chunks"]["primary_array_path"] = ( 950 self._primary_array_path 951 ) 952 self._dirty_root = True 953 954 @staticmethod 955 def _declared_chunk_shape(metadata_json: dict) -> Optional[tuple[int, ...]]: 956 """Extract the chunk shape a zarr v2/v3 array metadata document declares.""" 957 chunk_grid = metadata_json.get("chunk_grid") 958 if isinstance(chunk_grid, dict): 959 configuration = chunk_grid.get("configuration") 960 if isinstance(configuration, dict): 961 chunk_shape = configuration.get("chunk_shape") 962 if isinstance(chunk_shape, (list, tuple)): 963 return tuple(chunk_shape) 964 chunks = metadata_json.get("chunks") 965 if isinstance(chunks, (list, tuple)): 966 return tuple(chunks) 967 return None 968 969 def _load_v2_root(self) -> None: 970 metadata = self._root_obj.get("metadata") 971 arrays = self._root_obj.get("arrays") 972 if not isinstance(metadata, dict) or not isinstance(arrays, dict): 973 raise ValueError( 974 "Root object is not a valid v2 dictionary with 'metadata' and 'arrays' keys." 975 ) 976 977 self._manifest_version = SHARDED_ZARR_V2 978 self.array_indices = {} 979 self._primary_array_path = None 980 root_sharding_config = self._root_obj.get("sharding_config", {}) 981 if isinstance(root_sharding_config, dict): 982 self._default_chunks_per_shard = root_sharding_config.get( 983 "chunks_per_shard" 984 ) 985 else: 986 self._default_chunks_per_shard = None 987 988 for array_path, array_manifest in arrays.items(): 989 if not isinstance(array_path, str) or not isinstance(array_manifest, dict): 990 raise ValueError("arrays must map string paths to dictionaries.") 991 try: 992 array_index = ArrayIndex.from_manifest(array_path, array_manifest) 993 except ValueError as exc: 994 if str(exc).startswith("Inconsistent number of shards"): 995 raise ValueError( 996 f"Inconsistent number of shards for array '{array_path}'. {exc}" 997 ) from exc 998 raise 999 self.array_indices[array_index.array_path] = array_index 1000 if self._primary_array_path is None: 1001 self._primary_array_path = array_index.array_path 1002 1003 if self.array_indices: 1004 primary_index = self.array_indices[self._primary_array_path or ""] 1005 self._default_chunks_per_shard = primary_index.chunks_per_shard 1006 self._set_legacy_geometry_from_index(primary_index) 1007 else: 1008 self._array_shape = () 1009 self._chunk_shape = () 1010 self._chunks_per_dim = () 1011 self._chunks_per_shard = 0 1012 self._num_shards = 0 1013 self._total_chunks = 0 1014 1015 def _set_legacy_geometry_from_index(self, array_index: ArrayIndex) -> None: 1016 self._array_shape = array_index.array_shape 1017 self._chunk_shape = array_index.chunk_shape 1018 self._chunks_per_dim = array_index.chunks_per_dim 1019 self._chunks_per_shard = array_index.chunks_per_shard 1020 self._num_shards = array_index.num_shards 1021 self._total_chunks = array_index.total_chunks 1022 1023 def _sync_arrays_to_root(self) -> None: 1024 if self._manifest_version == SHARDED_ZARR_V2: 1025 self._root_obj["arrays"] = { 1026 array_path: array_index.to_manifest() 1027 for array_path, array_index in self.array_indices.items() 1028 } 1029 1030 def _register_or_update_array_index( 1031 self, 1032 *, 1033 array_path: str, 1034 array_shape: tuple[int, ...], 1035 chunk_shape: tuple[int, ...], 1036 chunks_per_shard: Optional[int] = None, 1037 ) -> ArrayIndex: 1038 normalized_path = self._normalize_array_path(array_path) 1039 if chunks_per_shard is None: 1040 chunks_per_shard = self._default_chunks_per_shard 1041 if chunks_per_shard is None: 1042 raise RuntimeError("Store is missing a default chunks_per_shard value.") 1043 1044 existing = self.array_indices.get(normalized_path) 1045 if existing is None: 1046 array_index = ArrayIndex.new( 1047 array_path=normalized_path, 1048 array_shape=array_shape, 1049 chunk_shape=chunk_shape, 1050 chunks_per_shard=chunks_per_shard, 1051 ) 1052 self.array_indices[normalized_path] = array_index 1053 if self._primary_array_path is None: 1054 self._primary_array_path = normalized_path 1055 self._set_legacy_geometry_from_index(array_index) 1056 else: 1057 new_chunk_shape = tuple(chunk_shape) 1058 if existing.chunk_shape != new_chunk_shape: 1059 raise ValueError( 1060 f"Cannot change chunk_shape for existing array index '{normalized_path}'." 1061 ) 1062 existing.resize(tuple(array_shape)) 1063 array_index = existing 1064 1065 if self._primary_array_path == normalized_path: 1066 self._set_legacy_geometry_from_index(array_index) 1067 self._sync_arrays_to_root() 1068 self._dirty_root = True 1069 return array_index 1070 1071 def _v2_top_level_groups(self) -> set[str]: 1072 if self._manifest_version != SHARDED_ZARR_V2: 1073 return set() 1074 return { 1075 array_path.split("/", 1)[0] 1076 for array_path in self.array_indices 1077 if "/" in array_path 1078 } 1079 1080 def _v2_is_grouped_only(self) -> bool: 1081 if self._manifest_version != SHARDED_ZARR_V2 or not self.array_indices: 1082 return False 1083 return all("/" in array_path for array_path in self.array_indices) 1084 1085 def _v2_default_group_for_root_read(self) -> Optional[str]: 1086 if not self._v2_is_grouped_only(): 1087 return None 1088 groups = self._v2_top_level_groups() 1089 if len(groups) != 1: 1090 return None 1091 return next(iter(groups)) 1092 1093 def _v2_requires_explicit_group_for_root_read(self) -> bool: 1094 return self._v2_is_grouped_only() and len(self._v2_top_level_groups()) > 1 1095 1096 def _v2_effective_read_key(self, key: str) -> str: 1097 default_group = self._v2_default_group_for_root_read() 1098 if default_group is None: 1099 return key 1100 1101 normalized_key = key.strip("/") 1102 if normalized_key in {"zarr.json", ".zgroup", ".zattrs", ".zmetadata", ""}: 1103 return key 1104 if normalized_key == default_group or normalized_key.startswith( 1105 f"{default_group}/" 1106 ): 1107 return key 1108 return f"{default_group}/{normalized_key}" 1109 1110 def _v2_effective_list_dir_prefix(self, normalized_prefix: str) -> str: 1111 default_group = self._v2_default_group_for_root_read() 1112 if default_group is None: 1113 return normalized_prefix 1114 if normalized_prefix == "": 1115 return default_group 1116 if normalized_prefix == default_group or normalized_prefix.startswith( 1117 f"{default_group}/" 1118 ): 1119 return normalized_prefix 1120 return f"{default_group}/{normalized_prefix}" 1121 1122 def _strip_v2_root_consolidated_metadata(self, key: str, raw_data: bytes) -> bytes: 1123 if key != "zarr.json" or not self._v2_is_grouped_only(): 1124 return raw_data 1125 1126 metadata_json = self._decode_metadata_json(raw_data) 1127 if ( 1128 metadata_json is None 1129 or metadata_json.get("node_type") != "group" 1130 or "consolidated_metadata" not in metadata_json 1131 ): 1132 return raw_data 1133 1134 metadata_json.pop("consolidated_metadata") 1135 return json.dumps(metadata_json).encode("utf-8") 1136 1137 @staticmethod 1138 def _v2_path_has_group(array_path: str) -> bool: 1139 normalized_path = ShardedZarrStore._normalize_array_path(array_path) 1140 return "/" in normalized_path 1141 1142 def _raise_if_v2_write_without_group(self, key: str, raw_data: bytes) -> None: 1143 if self._manifest_version != SHARDED_ZARR_V2: 1144 return 1145 1146 metadata_json = self._decode_metadata_json(raw_data) 1147 if metadata_json is None: 1148 return 1149 1150 group_path = self._group_path_from_metadata_key(key) 1151 metadata_path = self._array_path_from_metadata_key(key) 1152 array_metadata = self._extract_array_metadata(metadata_json) 1153 is_group_metadata = group_path is not None and array_metadata is None 1154 1155 if is_group_metadata and group_path == "": 1156 self._v2_pending_root_group_write = True 1157 return 1158 if is_group_metadata: 1159 self._v2_pending_root_group_write = False 1160 return 1161 1162 if metadata_path is None or array_metadata is None: 1163 return 1164 1165 if self._v2_path_has_group(metadata_path): 1166 self._v2_pending_root_group_write = False 1167 return 1168 1169 if self._v2_pending_root_group_write: 1170 self._v2_pending_root_group_write = False 1171 raise ValueError(self._V2_WRITE_GROUP_MESSAGE) 1172 1173 async def _snapshot_shards_for_resize( 1174 self, 1175 array_index: ArrayIndex, 1176 ) -> dict[int, list[Optional[CID]]]: 1177 shards_by_index: dict[int, list[Optional[CID]]] = {} 1178 for shard_idx, shard_cid_obj in enumerate(array_index.shard_cids): 1179 cache_key = self._cache_key(array_index.array_path, shard_idx) 1180 shard_lock = self._shard_locks[cache_key] 1181 # Pin across fetch+read: an over-budget cache whose older entries are 1182 # all dirty or pinned would otherwise make the just-fetched clean 1183 # shard the sole eviction candidate, evicting it before the snapshot 1184 # read below and turning the load into a spurious RuntimeError. 1185 async with shard_lock, self._shard_data_cache.pin(cache_key): 1186 shard_data = await self._shard_data_cache.get(cache_key) 1187 if shard_data is None and shard_cid_obj is not None: 1188 await self._fetch_and_cache_full_shard( 1189 cache_key, 1190 shard_idx, 1191 str(shard_cid_obj), 1192 array_index.chunks_per_shard, 1193 ) 1194 shard_data = await self._shard_data_cache.get(cache_key) 1195 if shard_data is None: # pragma: no cover 1196 raise RuntimeError(f"Failed to load shard {shard_idx}") 1197 if shard_data is not None: 1198 shards_by_index[shard_idx] = list(shard_data) 1199 return shards_by_index 1200 1201 @staticmethod 1202 def _remap_shards_for_resize( 1203 old_shards_by_index: dict[int, list[Optional[CID]]], 1204 old_chunks_per_dim: tuple[int, ...], 1205 old_total_chunks: int, 1206 new_array_index: ArrayIndex, 1207 ) -> dict[int, list[Optional[CID]]]: 1208 new_shards_by_index = { 1209 shard_idx: [None] * new_array_index.chunks_per_shard 1210 for shard_idx in range(new_array_index.num_shards) 1211 } 1212 for old_shard_idx, old_shard in old_shards_by_index.items(): 1213 for old_index_in_shard, pointer_cid_obj in enumerate(old_shard): 1214 if pointer_cid_obj is None: 1215 continue 1216 old_linear_index = ( 1217 old_shard_idx * new_array_index.chunks_per_shard 1218 + old_index_in_shard 1219 ) 1220 if old_linear_index >= old_total_chunks: 1221 continue 1222 coords = ShardedZarrStore._coords_from_linear_index( 1223 old_linear_index, old_chunks_per_dim 1224 ) 1225 if any( 1226 coord >= chunks 1227 for coord, chunks in zip( 1228 coords, new_array_index.chunks_per_dim, strict=True 1229 ) 1230 ): 1231 continue 1232 1233 new_linear_index = ShardedZarrStore._get_linear_chunk_index_for_index( 1234 coords, new_array_index 1235 ) 1236 new_shard_idx, new_index_in_shard = ( 1237 ShardedZarrStore._get_shard_info_for_index( 1238 new_linear_index, new_array_index 1239 ) 1240 ) 1241 new_shards_by_index[new_shard_idx][new_index_in_shard] = pointer_cid_obj 1242 return new_shards_by_index 1243 1244 async def _replace_shards_after_resize( 1245 self, 1246 array_index: ArrayIndex, 1247 old_num_shards: int, 1248 old_shard_cids: list[Optional[CID]], 1249 old_shards_by_index: dict[int, list[Optional[CID]]], 1250 new_shards_by_index: dict[int, list[Optional[CID]]], 1251 ) -> None: 1252 async with self._shard_data_cache._cache_lock: 1253 dirty_cache_keys = set(self._shard_data_cache._dirty_shards) 1254 1255 for shard_idx in range(array_index.num_shards): 1256 cache_key = self._cache_key(array_index.array_path, shard_idx) 1257 shard_lock = self._shard_locks[cache_key] 1258 new_shard = new_shards_by_index[shard_idx] 1259 old_shard = old_shards_by_index.get(shard_idx) 1260 old_shard_cid = ( 1261 old_shard_cids[shard_idx] if shard_idx < len(old_shard_cids) else None 1262 ) 1263 async with shard_lock: 1264 if all(pointer_cid_obj is None for pointer_cid_obj in new_shard): 1265 array_index.shard_cids[shard_idx] = None 1266 await self._shard_data_cache.discard(cache_key) 1267 elif old_shard == new_shard and ( 1268 old_shard_cid is not None or cache_key in dirty_cache_keys 1269 ): 1270 array_index.shard_cids[shard_idx] = old_shard_cid 1271 else: 1272 array_index.shard_cids[shard_idx] = None 1273 await self._shard_data_cache.put( 1274 cache_key, new_shard, is_dirty=True 1275 ) 1276 1277 for shard_idx in range(array_index.num_shards, old_num_shards): 1278 cache_key = self._cache_key(array_index.array_path, shard_idx) 1279 shard_lock = self._shard_locks[cache_key] 1280 async with shard_lock: 1281 await self._shard_data_cache.discard(cache_key) 1282 1283 @staticmethod 1284 def _can_fast_resize_leading_dimension( 1285 array_index: ArrayIndex, new_shape: tuple[int, ...] 1286 ) -> bool: 1287 """Return whether resizing only appends to the row-major chunk grid.""" 1288 old_shape = array_index.array_shape 1289 return ( 1290 len(old_shape) > 0 1291 and len(new_shape) == len(old_shape) 1292 and new_shape[0] >= old_shape[0] 1293 and new_shape[1:] == old_shape[1:] 1294 and array_index.order == "C" 1295 ) 1296 1297 async def _resize_array_index( 1298 self, array_index: ArrayIndex, new_shape: tuple[int, ...] 1299 ) -> None: 1300 new_shape = tuple(new_shape) 1301 if self._can_fast_resize_leading_dimension(array_index, new_shape): 1302 array_index.resize(new_shape) 1303 if self._primary_array_path == array_index.array_path: 1304 self._set_legacy_geometry_from_index(array_index) 1305 self._sync_arrays_to_root() 1306 self._dirty_root = True 1307 return 1308 1309 old_num_shards = array_index.num_shards 1310 old_total_chunks = array_index.total_chunks 1311 old_chunks_per_dim = array_index.chunks_per_dim 1312 old_shard_cids = list(array_index.shard_cids) 1313 old_shards_by_index = await self._snapshot_shards_for_resize(array_index) 1314 array_index.resize(new_shape) 1315 new_shards_by_index = self._remap_shards_for_resize( 1316 old_shards_by_index, 1317 old_chunks_per_dim, 1318 old_total_chunks, 1319 array_index, 1320 ) 1321 await self._replace_shards_after_resize( 1322 array_index, 1323 old_num_shards, 1324 old_shard_cids, 1325 old_shards_by_index, 1326 new_shards_by_index, 1327 ) 1328 if self._primary_array_path == array_index.array_path: 1329 self._set_legacy_geometry_from_index(array_index) 1330 self._sync_arrays_to_root() 1331 self._dirty_root = True 1332 1333 async def _resize_array_index_guarded( 1334 self, array_index: ArrayIndex, new_shape: tuple[int, ...] 1335 ) -> None: 1336 async with self._resize_lock: 1337 self._resize_complete.clear() 1338 try: 1339 await self._resize_array_index(array_index, new_shape) 1340 finally: 1341 self._resize_complete.set() 1342 1343 @staticmethod 1344 def _decode_metadata_json(raw_data: bytes) -> Optional[dict]: 1345 try: 1346 decoded = json.loads(raw_data.decode("utf-8")) 1347 except (UnicodeDecodeError, json.JSONDecodeError): 1348 return None 1349 return decoded if isinstance(decoded, dict) else None 1350 1351 @staticmethod 1352 def _extract_array_metadata( 1353 metadata_json: dict, 1354 ) -> Optional[tuple[tuple[int, ...], tuple[int, ...]]]: 1355 shape = metadata_json.get("shape") 1356 if shape is None: 1357 return None 1358 1359 chunk_shape = None 1360 chunk_grid = metadata_json.get("chunk_grid") 1361 if isinstance(chunk_grid, dict): 1362 configuration = chunk_grid.get("configuration") 1363 if isinstance(configuration, dict): 1364 chunk_shape = configuration.get("chunk_shape") 1365 1366 if chunk_shape is None: 1367 chunk_shape = metadata_json.get("chunks") 1368 1369 if chunk_shape is None: 1370 return None 1371 1372 return tuple(int(dim) for dim in shape), tuple(int(dim) for dim in chunk_shape) 1373 1374 def _infer_v1_migration_source_array_path(self, primary_array_path: str) -> str: 1375 metadata = self._root_obj.get("metadata", {}) 1376 candidates = [primary_array_path] 1377 primary_leaf = primary_array_path.rsplit("/", 1)[-1] 1378 if primary_leaf not in candidates: 1379 candidates.append(primary_leaf) 1380 candidates.append("") 1381 1382 for candidate in candidates: 1383 metadata_keys = ( 1384 ("zarr.json", ".zarray") 1385 if candidate == "" 1386 else (f"{candidate}/zarr.json", f"{candidate}/.zarray") 1387 ) 1388 if any(key in metadata for key in metadata_keys): 1389 return candidate 1390 return primary_leaf 1391 1392 @staticmethod 1393 def _rewrite_v1_metadata_key_for_migration( 1394 key: str, source_array_path: str, primary_array_path: str 1395 ) -> str: 1396 parent_path = ( 1397 primary_array_path.rsplit("/", 1)[0] if "/" in primary_array_path else "" 1398 ) 1399 1400 if source_array_path: 1401 source_prefix = f"{source_array_path}/" 1402 if key.startswith(source_prefix): 1403 return f"{primary_array_path}/{key[len(source_prefix) :]}" 1404 elif key.startswith("c/"): 1405 return f"{primary_array_path}/{key}" 1406 elif key in {"zarr.json", ".zarray"}: 1407 return f"{primary_array_path}/{key}" 1408 1409 if parent_path and "/" in key and not key.startswith(f"{parent_path}/"): 1410 return f"{parent_path}/{key}" 1411 return key 1412 1413 async def _add_missing_group_metadata( 1414 self, metadata: dict[str, IPLDKind], array_path: str 1415 ) -> None: 1416 parts = array_path.split("/") 1417 group_paths = ["/".join(parts[:idx]) for idx in range(len(parts))] 1418 uses_zarr_v2_metadata = f"{array_path}/.zarray" in metadata 1419 if uses_zarr_v2_metadata: 1420 group_metadata_key = self._v2_group_metadata_key 1421 group_metadata = json.dumps({"zarr_format": 2}).encode("utf-8") 1422 else: 1423 group_metadata_key = self._v3_group_metadata_key 1424 group_metadata = json.dumps({ 1425 "zarr_format": 3, 1426 "node_type": "group", 1427 "attributes": {}, 1428 }).encode("utf-8") 1429 1430 for group_path in group_paths: 1431 metadata_key = group_metadata_key(group_path) 1432 if metadata_key in metadata: 1433 if not uses_zarr_v2_metadata: 1434 await self._strip_consolidated_metadata(metadata, metadata_key) 1435 continue 1436 metadata[metadata_key] = await self.cas.save(group_metadata, codec="raw") 1437 1438 async def _strip_consolidated_metadata( 1439 self, metadata: dict[str, IPLDKind], metadata_key: str 1440 ) -> None: 1441 raw_metadata = await self.cas.load(str(metadata[metadata_key])) 1442 metadata_json = self._decode_metadata_json(raw_metadata) 1443 if ( 1444 metadata_json is None 1445 or metadata_json.get("node_type") != "group" 1446 or "consolidated_metadata" not in metadata_json 1447 ): 1448 return 1449 1450 metadata_json.pop("consolidated_metadata") 1451 metadata[metadata_key] = await self.cas.save( 1452 json.dumps(metadata_json).encode("utf-8"), codec="raw" 1453 ) 1454 1455 async def _register_array_metadata_from_bytes( 1456 self, key: str, raw_data: bytes 1457 ) -> None: 1458 array_path = self._array_path_from_metadata_key(key) 1459 if array_path is None: 1460 return 1461 1462 metadata_json = self._decode_metadata_json(raw_data) 1463 if metadata_json is None: 1464 return 1465 1466 array_metadata = self._extract_array_metadata(metadata_json) 1467 if array_metadata is None and self._manifest_version == SHARDED_ZARR_V2: 1468 return 1469 if array_metadata is None: 1470 shape = metadata_json.get("shape") 1471 if shape is None: 1472 return 1473 new_array_shape = tuple(int(dim) for dim in shape) 1474 new_chunk_shape = self._chunk_shape 1475 else: 1476 new_array_shape, new_chunk_shape = array_metadata 1477 1478 if self._manifest_version == SHARDED_ZARR_V2: 1479 normalized_path = self._normalize_array_path(array_path) 1480 existing = self.array_indices.get(normalized_path) 1481 if existing is None: 1482 self._register_or_update_array_index( 1483 array_path=array_path, 1484 array_shape=new_array_shape, 1485 chunk_shape=new_chunk_shape, 1486 ) 1487 else: 1488 if existing.chunk_shape != new_chunk_shape: 1489 raise ValueError( 1490 f"Cannot change chunk_shape for existing array index '{normalized_path}'." 1491 ) 1492 if existing.array_shape != new_array_shape: 1493 await self._resize_array_index_guarded(existing, new_array_shape) 1494 return 1495 1496 if ( 1497 len(new_array_shape) == len(self._array_shape) 1498 and new_array_shape != self._array_shape 1499 ): 1500 await self._resize_store_unlocked(new_shape=new_array_shape) 1501 1502 async def _ensure_v2_parent_group_metadata(self, key: str) -> None: 1503 if self._manifest_version != SHARDED_ZARR_V2: 1504 return 1505 1506 metadata_path = self._array_path_from_metadata_key(key) 1507 if metadata_path is None: 1508 return 1509 1510 if key == ".zarray" or key.endswith("/.zarray"): 1511 group_metadata_key = self._v2_group_metadata_key 1512 group_metadata = json.dumps({"zarr_format": 2}).encode("utf-8") 1513 else: 1514 group_metadata_key = self._v3_group_metadata_key 1515 group_metadata = json.dumps({ 1516 "zarr_format": 3, 1517 "node_type": "group", 1518 "attributes": {}, 1519 }).encode("utf-8") 1520 1521 normalized_path = self._normalize_array_path(metadata_path) 1522 parent_paths = [""] 1523 if normalized_path: 1524 parts = normalized_path.split("/") 1525 parent_paths.extend("/".join(parts[:idx]) for idx in range(1, len(parts))) 1526 1527 for parent_path in parent_paths: 1528 metadata_key = group_metadata_key(parent_path) 1529 if metadata_key in self._root_obj["metadata"]: 1530 continue 1531 metadata_cid = await self.cas.save(group_metadata, codec="raw") 1532 self._root_obj["metadata"][metadata_key] = metadata_cid 1533 self._metadata_read_cache[metadata_key] = group_metadata 1534 self._dirty_root = True 1535 1536 async def _fetch_and_cache_full_shard( 1537 self, 1538 cache_key: ShardCacheKey, 1539 shard_idx: int, 1540 shard_cid: IPLDKind, 1541 expected_entries: int, 1542 max_retries: int = 3, 1543 retry_delay: float = 1.0, 1544 ) -> None: 1545 """ 1546 Fetch a shard from CAS and cache it, with retry logic for transient errors. 1547 """ 1548 for attempt in range(max_retries): 1549 try: 1550 shard_data_bytes = await self.cas.load(shard_cid) 1551 decoded_shard = dag_cbor.decode(shard_data_bytes) 1552 if not isinstance(decoded_shard, list): 1553 raise TypeError(f"Shard {shard_idx} did not decode to a list.") 1554 if len(decoded_shard) != expected_entries: 1555 raise ValueError( 1556 f"Shard {shard_idx} contains {len(decoded_shard)} entries; expected {expected_entries}." 1557 ) 1558 shard_data: List[Optional[CID]] = [] 1559 for item in decoded_shard: 1560 if item is not None and not isinstance(item, CID): 1561 raise TypeError(f"Shard {shard_idx} contains a non-CID entry.") 1562 shard_data.append(item) 1563 await self._shard_data_cache.put(cache_key, shard_data) 1564 if cache_key in self._pending_shard_loads: 1565 self._pending_shard_loads[cache_key].set() 1566 del self._pending_shard_loads[cache_key] 1567 return 1568 except (ConnectionError, TimeoutError) as e: 1569 if attempt < max_retries - 1: 1570 await asyncio.sleep(retry_delay * (2**attempt)) 1571 continue 1572 raise RuntimeError( 1573 f"Failed to fetch shard {shard_idx} after {max_retries} attempts: {e}" 1574 ) from e 1575 1576 @property 1577 def _full_mode_latched(self) -> bool: 1578 """Whether ``auto`` mode has committed this store to full decodes. 1579 1580 Backed by a cell shared with every ``with_read_only`` clone, so a latch 1581 earned by one clone is immediately visible to all of them. 1582 """ 1583 return self._full_mode_latched_cell[0] 1584 1585 @_full_mode_latched.setter 1586 def _full_mode_latched(self, value: bool) -> None: 1587 self._full_mode_latched_cell[0] = value 1588 1589 def _sparse_read_is_eligible( 1590 self, 1591 array_index: ArrayIndex, 1592 shard_idx: int, 1593 cached_shard: Optional[List[Optional[CID]]], 1594 byte_range: Optional[zarr.abc.store.ByteRequest], 1595 ) -> bool: 1596 """ 1597 Whether a single-entry shard decode is structurally legal here. 1598 1599 Independent of ``shard_read_mode``: a writable store must go through the 1600 cache so pending writes stay visible, a cache hit is already cheaper 1601 than any fetch, a byte range needs the full CID resolution path, and an 1602 absent or out-of-range shard CID has nothing to sparsely decode. 1603 """ 1604 return ( 1605 self.read_only 1606 and cached_shard is None 1607 and byte_range is None 1608 and 0 <= shard_idx < array_index.num_shards 1609 and array_index.shard_cids[shard_idx] is not None 1610 ) 1611 1612 def _auto_mode_wants_sparse(self, cache_key: ShardCacheKey) -> bool: 1613 """ 1614 Record one ``auto``-mode sparse read and report whether to stay sparse. 1615 1616 Called only under ``self._shard_locks[cache_key]``, so each shard's 1617 read-modify-write of the counter is serialized. Once any single shard 1618 crosses the threshold the whole store latches to full mode: a caller 1619 reading one shard that heavily is scanning, and will scan the rest too. 1620 1621 ``_full_mode_latched`` is written under one shard's lock and read under 1622 others, so two shards can latch concurrently. That race is benign and 1623 deliberate — the write is idempotent (never ``True`` back to ``False``) 1624 and the worst outcome is one extra sparse read on a shard that was about 1625 to latch anyway, so it does not warrant a second lock. 1626 """ 1627 if self._full_mode_latched: 1628 return False 1629 count = self._sparse_read_counts.get(cache_key, 0) + 1 1630 if count >= self._SPARSE_PROMOTE_THRESHOLD: 1631 self._full_mode_latched = True 1632 # Never read again once latched; drop whatever it accumulated. 1633 self._sparse_read_counts.clear() 1634 return False 1635 self._sparse_read_counts[cache_key] = count 1636 return True 1637 1638 async def _load_sparse_shard_entry( 1639 self, 1640 cache_key: ShardCacheKey, 1641 shard_idx: int, 1642 shard_cid: IPLDKind, 1643 index_in_shard: int, 1644 expected_entries: int, 1645 ) -> Optional[CID]: 1646 if await self._shard_data_cache.get(cache_key) is not None: 1647 return None 1648 1649 shard_data_bytes = await self.cas.load(shard_cid) 1650 entry_count, _ = _read_cbor_list_header(shard_data_bytes) 1651 if entry_count != expected_entries: 1652 raise ValueError( 1653 f"Shard {shard_idx} contains {entry_count} entries; expected {expected_entries}." 1654 ) 1655 return decode_shard_entry(shard_data_bytes, index_in_shard) 1656 1657 def _parse_chunk_key(self, key: str) -> Optional[ChunkKey]: 1658 if key.endswith(ZARR_METADATA_SUFFIXES): 1659 return None 1660 1661 chunk_marker = "/c/" 1662 marker_idx = key.rfind(chunk_marker) 1663 if marker_idx != -1: 1664 array_path = key[:marker_idx] 1665 coord_part = key[marker_idx + len(chunk_marker) :] 1666 elif key.startswith("c/"): 1667 if self._manifest_version == SHARDED_ZARR_V1: 1668 recorded_path = self._root_obj.get("chunks", {}).get( 1669 "primary_array_path" 1670 ) 1671 if not ( 1672 isinstance(recorded_path, str) 1673 and self._normalize_array_path(recorded_path) == "" 1674 ): 1675 return None 1676 array_path = "" 1677 coord_part = key[len("c/") :] 1678 else: 1679 return self._parse_classic_v2_chunk_key(key) 1680 1681 normalized_path = self._normalize_array_path(array_path) 1682 if self._manifest_version == SHARDED_ZARR_V1: 1683 actual_array_name = ( 1684 normalized_path.split("/")[-1] if normalized_path else "" 1685 ) 1686 if actual_array_name in self._V1_COORDINATE_ARRAY_PREFIXES: 1687 return None 1688 recorded_path = self._root_obj.get("chunks", {}).get("primary_array_path") 1689 if isinstance(recorded_path, str): 1690 primary_path_is_exclusive = True 1691 effective_primary_path = recorded_path 1692 elif self._primary_inferred or self._primary_array_path: 1693 # An inferred primary is exclusive even when it is the root 1694 # ("") — the flag, not the truthiness, decides. Without it a 1695 # foreign same-rank named chunk would parse against the shard 1696 # index and rebind the primary to itself. 1697 primary_path_is_exclusive = True 1698 effective_primary_path = self._primary_array_path or "" 1699 else: 1700 primary_path_is_exclusive = False 1701 effective_primary_path = "" 1702 if ( 1703 primary_path_is_exclusive 1704 and normalized_path 1705 != self._normalize_array_path(effective_primary_path) 1706 ): 1707 return None 1708 1709 parts = coord_part.split("/") 1710 try: 1711 coords = tuple(map(int, parts)) 1712 except ValueError: 1713 classic_chunk = self._parse_classic_v2_chunk_key(key) 1714 if classic_chunk is not None: 1715 return classic_chunk 1716 raise 1717 1718 if self._manifest_version == SHARDED_ZARR_V1: 1719 key_is_primary = ( 1720 primary_path_is_exclusive 1721 and normalized_path 1722 == self._normalize_array_path(effective_primary_path) 1723 ) 1724 named_array_metadata = self._root_obj.get("metadata", {}) 1725 if ( 1726 normalized_path 1727 and not key_is_primary 1728 and len(coords) != len(self.array_indices[""].chunks_per_dim) 1729 and ( 1730 f"{normalized_path}/zarr.json" in named_array_metadata 1731 or f"{normalized_path}/.zarray" in named_array_metadata 1732 ) 1733 ): 1734 # A named array that registered its own metadata and whose 1735 # rank differs from the primary geometry can never be a 1736 # primary chunk: classify it as metadata instead of failing 1737 # coordinate validation. Keys under the effective primary path 1738 # still validate strictly so malformed primary keys fail loud. 1739 return None 1740 self._validate_chunk_coords(coords, self.array_indices[""]) 1741 elif normalized_path in self.array_indices: 1742 self._validate_chunk_coords(coords, self.array_indices[normalized_path]) 1743 1744 return ChunkKey(array_path=normalized_path, coords=coords) 1745 1746 def _parse_classic_v2_chunk_key(self, key: str) -> Optional[ChunkKey]: 1747 if self._manifest_version != SHARDED_ZARR_V2 or not self.array_indices: 1748 return None 1749 1750 dotted_chunk = self._parse_classic_dotted_v2_chunk_key(key) 1751 if dotted_chunk is not None: 1752 return dotted_chunk 1753 1754 for array_path, array_index in sorted( 1755 self.array_indices.items(), key=lambda item: len(item[0]), reverse=True 1756 ): 1757 prefix = f"{array_path}/" if array_path else "" 1758 if prefix: 1759 if not key.startswith(prefix): 1760 continue 1761 coord_part = key[len(prefix) :] 1762 else: 1763 coord_part = key 1764 1765 parts = coord_part.split("/") 1766 if len(parts) != len(array_index.chunks_per_dim): 1767 continue 1768 if not all(part.isdecimal() for part in parts): 1769 continue 1770 coords = tuple(int(part) for part in parts) 1771 self._validate_chunk_coords(coords, array_index) 1772 return ChunkKey(array_path=array_path, coords=coords) 1773 return None 1774 1775 def _parse_classic_dotted_v2_chunk_key(self, key: str) -> Optional[ChunkKey]: 1776 array_path, _, coord_part = key.rpartition("/") 1777 if "." not in coord_part: 1778 return None 1779 1780 parts = coord_part.split(".") 1781 if not parts or not all(part.isdecimal() for part in parts): 1782 return None 1783 1784 normalized_path = self._normalize_array_path(array_path) 1785 array_index = self.array_indices.get(normalized_path) 1786 if array_index is None or len(parts) != len(array_index.chunks_per_dim): 1787 return None 1788 1789 coords = tuple(int(part) for part in parts) 1790 self._validate_chunk_coords(coords, array_index) 1791 return ChunkKey(array_path=normalized_path, coords=coords) 1792 1793 @staticmethod 1794 def _validate_chunk_coords( 1795 chunk_coords: tuple[int, ...], array_index: ArrayIndex 1796 ) -> None: 1797 if len(chunk_coords) != len(array_index.chunks_per_dim): 1798 raise IndexError("tuple index out of range") 1799 for i, c_coord in enumerate(chunk_coords): 1800 if not (0 <= c_coord < array_index.chunks_per_dim[i]): 1801 raise IndexError( 1802 f"Chunk coordinate {c_coord} at dimension {i} is out of bounds for dimension size {array_index.chunks_per_dim[i]}." 1803 ) 1804 1805 def _get_linear_chunk_index(self, chunk_coords: Tuple[int, ...]) -> int: 1806 return self._get_linear_chunk_index_for_index( 1807 tuple(chunk_coords), self.array_indices[""] 1808 ) 1809 1810 @staticmethod 1811 def _get_linear_chunk_index_for_index( 1812 chunk_coords: tuple[int, ...], array_index: ArrayIndex 1813 ) -> int: 1814 linear_index = 0 1815 multiplier = 1 1816 for i in reversed(range(len(array_index.chunks_per_dim))): 1817 linear_index += chunk_coords[i] * multiplier 1818 multiplier *= array_index.chunks_per_dim[i] 1819 return linear_index 1820 1821 def _get_shard_info(self, linear_chunk_index: int) -> Tuple[int, int]: 1822 shard_idx = linear_chunk_index // self._chunks_per_shard 1823 index_in_shard = linear_chunk_index % self._chunks_per_shard 1824 return shard_idx, index_in_shard 1825 1826 @staticmethod 1827 def _get_shard_info_for_index( 1828 linear_chunk_index: int, array_index: ArrayIndex 1829 ) -> Tuple[int, int]: 1830 shard_idx = linear_chunk_index // array_index.chunks_per_shard 1831 index_in_shard = linear_chunk_index % array_index.chunks_per_shard 1832 return shard_idx, index_in_shard 1833 1834 def _array_index_for_path(self, array_path: Optional[str]) -> ArrayIndex: 1835 if self._manifest_version == SHARDED_ZARR_V1: 1836 return self.array_indices[""] 1837 1838 normalized_path = self._normalize_array_path(array_path or "") 1839 try: 1840 return self.array_indices[normalized_path] 1841 except KeyError as exc: 1842 raise KeyError( 1843 f"No array index registered for chunk path '{normalized_path}'." 1844 ) from exc 1845 1846 def _cache_key(self, array_path: Optional[str], shard_idx: int) -> ShardCacheKey: 1847 if self._manifest_version == SHARDED_ZARR_V1: 1848 return shard_idx 1849 return (self._normalize_array_path(array_path or ""), shard_idx) 1850 1851 def _map_byte_request( 1852 self, byte_range: Optional[zarr.abc.store.ByteRequest] 1853 ) -> tuple[Optional[int], Optional[int], Optional[int]]: 1854 req_offset = None 1855 req_length = None 1856 req_suffix = None 1857 1858 if byte_range: 1859 if isinstance(byte_range, RangeByteRequest): 1860 req_offset = byte_range.start 1861 if byte_range.end is not None: 1862 if byte_range.start > byte_range.end: 1863 raise ValueError( 1864 f"Byte range start ({byte_range.start}) cannot be greater than end ({byte_range.end})" 1865 ) 1866 req_length = byte_range.end - byte_range.start 1867 elif isinstance(byte_range, OffsetByteRequest): 1868 req_offset = byte_range.offset 1869 elif isinstance(byte_range, SuffixByteRequest): 1870 req_suffix = byte_range.suffix 1871 return req_offset, req_length, req_suffix 1872 1873 async def _get_legacy_metadata_chunk( 1874 self, 1875 key: str, 1876 prototype: zarr.core.buffer.BufferPrototype, 1877 byte_range: Optional[zarr.abc.store.ByteRequest], 1878 ) -> Optional[zarr.core.buffer.Buffer]: 1879 metadata_cid_obj = self._root_obj["metadata"].get(key) 1880 if metadata_cid_obj is None: 1881 return None 1882 req_offset, req_length, req_suffix = self._map_byte_request(byte_range) 1883 data = await self.cas.load( 1884 metadata_cid_obj, 1885 offset=req_offset, 1886 length=req_length, 1887 suffix=req_suffix, 1888 ) 1889 return prototype.buffer.from_bytes(data) 1890 1891 async def _load_or_initialize_shard_cache( 1892 self, shard_idx: int, array_path: Optional[str] = None 1893 ) -> List[Optional[CID]]: 1894 """Return a shard after keeping it pinned throughout cache population.""" 1895 array_index = self._array_index_for_path(array_path) 1896 cache_key = self._cache_key(array_index.array_path, shard_idx) 1897 async with self._shard_data_cache.pin(cache_key): 1898 return await self._load_or_initialize_shard_cache_pinned( 1899 shard_idx, array_path 1900 ) 1901 1902 async def _load_or_initialize_shard_cache_pinned( 1903 self, shard_idx: int, array_path: Optional[str] = None 1904 ) -> List[Optional[CID]]: 1905 """ 1906 Load a shard into the cache or initialize an empty shard if it doesn't exist. 1907 """ 1908 started_at = time.perf_counter() 1909 array_index = self._array_index_for_path(array_path) 1910 cache_key = self._cache_key(array_index.array_path, shard_idx) 1911 1912 cached_shard = await self._shard_data_cache.get(cache_key) 1913 if cached_shard is not None: 1914 instrumentation.record_shard_load( 1915 shard_idx=shard_idx, 1916 cache_hit=True, 1917 seconds=time.perf_counter() - started_at, 1918 entries=len(cached_shard), 1919 ) 1920 return cached_shard 1921 1922 if cache_key in self._pending_shard_loads: 1923 try: 1924 await asyncio.wait_for( 1925 self._pending_shard_loads[cache_key].wait(), timeout=60.0 1926 ) 1927 cached_shard = await self._shard_data_cache.get(cache_key) 1928 if cached_shard is not None: 1929 return cached_shard 1930 raise RuntimeError( 1931 f"Shard {shard_idx} not found in cache after pending load completed." 1932 ) 1933 except asyncio.TimeoutError as exc: 1934 if cache_key in self._pending_shard_loads: 1935 self._pending_shard_loads[cache_key].set() 1936 del self._pending_shard_loads[cache_key] 1937 raise RuntimeError( 1938 f"Timeout waiting for shard {shard_idx} to load." 1939 ) from exc 1940 1941 if not (0 <= shard_idx < array_index.num_shards): 1942 raise ValueError(f"Shard index {shard_idx} out of bounds.") 1943 1944 shard_cid_obj = array_index.shard_cids[shard_idx] 1945 if shard_cid_obj: 1946 self._pending_shard_loads[cache_key] = asyncio.Event() 1947 try: 1948 await self._fetch_and_cache_full_shard( 1949 cache_key, shard_idx, shard_cid_obj, array_index.chunks_per_shard 1950 ) 1951 finally: 1952 pending_load = self._pending_shard_loads.pop(cache_key, None) 1953 if pending_load is not None: 1954 pending_load.set() 1955 else: 1956 empty_shard: List[Optional[CID]] = [None] * array_index.chunks_per_shard 1957 await self._shard_data_cache.put(cache_key, empty_shard) 1958 1959 result = await self._shard_data_cache.get(cache_key) 1960 if result is None: 1961 raise RuntimeError(f"Failed to load or initialize shard {shard_idx}") 1962 instrumentation.record_shard_load( 1963 shard_idx=shard_idx, 1964 cache_hit=False, 1965 seconds=time.perf_counter() - started_at, 1966 entries=len(result), 1967 ) 1968 return result 1969 1970 @asynccontextmanager 1971 async def _use_shard( 1972 self, shard_idx: int, array_path: Optional[str] = None 1973 ) -> AsyncIterator[List[Optional[CID]]]: 1974 """Yield a pinned shard while serializing access to its contents.""" 1975 array_index = self._array_index_for_path(array_path) 1976 cache_key = self._cache_key(array_index.array_path, shard_idx) 1977 async with self._shard_data_cache.pin(cache_key): 1978 async with self._shard_locks[cache_key]: 1979 yield await self._load_or_initialize_shard_cache( 1980 shard_idx, array_index.array_path 1981 ) 1982 1983 async def set_partial_values( 1984 self, key_start_values: Iterable[Tuple[str, int, BytesLike]] 1985 ) -> None: 1986 raise NotImplementedError( 1987 "Partial writes are not supported by ShardedZarrStore." 1988 ) 1989 1990 async def get_partial_values( 1991 self, 1992 prototype: zarr.core.buffer.BufferPrototype, 1993 key_ranges: Iterable[Tuple[str, zarr.abc.store.ByteRequest | None]], 1994 ) -> List[Optional[zarr.core.buffer.Buffer]]: 1995 tasks = [self.get(key, prototype, byte_range) for key, byte_range in key_ranges] 1996 results = await asyncio.gather(*tasks) 1997 return results 1998 1999 def with_read_only(self, read_only: bool = False) -> "ShardedZarrStore": 2000 """ 2001 Return this store (if the flag already matches) or a shallow clone with 2002 the requested read-only status. 2003 """ 2004 if read_only == self.read_only: 2005 return self 2006 2007 clone = type(self).__new__(type(self)) 2008 2009 clone.cas = self.cas 2010 clone._root_cid = self._root_cid 2011 clone._root_obj = self._root_obj 2012 clone._manifest_version = self._manifest_version 2013 clone.shard_read_mode = self.shard_read_mode 2014 2015 clone._resize_lock = self._resize_lock 2016 clone._resize_complete = self._resize_complete 2017 clone._write_lock = self._write_lock 2018 clone._shard_locks = self._shard_locks 2019 2020 clone._shard_data_cache = self._shard_data_cache 2021 clone._pending_shard_loads = self._pending_shard_loads 2022 # Both shared by reference, like the cache above. The counters must be 2023 # shared so a clone does not restart counting while reading through the 2024 # *same* cache; the latch cell must be shared for the same reason in 2025 # reverse -- latching clears the shared counters, so a sibling holding 2026 # a copied False would resume sparse reads with nothing left to re-earn 2027 # promotion from. 2028 clone._sparse_read_counts = self._sparse_read_counts 2029 clone._full_mode_latched_cell = self._full_mode_latched_cell 2030 clone._metadata_read_cache = self._metadata_read_cache 2031 2032 clone.array_indices = self.array_indices 2033 clone._primary_array_path = self._primary_array_path 2034 clone._primary_inferred = self._primary_inferred 2035 clone._default_chunks_per_shard = self._default_chunks_per_shard 2036 2037 clone._array_shape = self._array_shape 2038 clone._chunk_shape = self._chunk_shape 2039 clone._chunks_per_dim = self._chunks_per_dim 2040 clone._chunks_per_shard = self._chunks_per_shard 2041 clone._num_shards = self._num_shards 2042 clone._total_chunks = self._total_chunks 2043 2044 clone._dirty_root = self._dirty_root 2045 clone._v2_pending_root_group_write = self._v2_pending_root_group_write 2046 2047 # A V1 primary that was inferred against a read-only open never reached 2048 # the persistence branch in _infer_v1_legacy_primary_array_path (that 2049 # branch is gated on `not self.read_only`), so it lives only in the 2050 # in-memory `_primary_inferred` flag. Making a writable clone would carry 2051 # that flag forward while leaving the shared root unrecorded: the first 2052 # chunk write skips its seal-on-write (it is gated on 2053 # `not self._primary_inferred`), so a same-geometry secondary array could 2054 # flush with no recorded primary. On reopen inference would then be 2055 # ambiguous and misroute the secondary's metadata chunk into the primary 2056 # shard slot. Perform the deferred persistence the read-only open 2057 # skipped, reproducing the state a writable open would have produced. 2058 if ( 2059 not read_only 2060 and clone._manifest_version == SHARDED_ZARR_V1 2061 and clone._primary_inferred 2062 and isinstance(clone._root_obj.get("chunks"), dict) 2063 and "primary_array_path" not in clone._root_obj["chunks"] 2064 ): 2065 clone._root_obj["chunks"]["primary_array_path"] = ( 2066 clone._primary_array_path or "" 2067 ) 2068 clone._dirty_root = True 2069 2070 zarr.abc.store.Store.__init__(clone, read_only=read_only) 2071 return clone 2072 2073 def __eq__(self, other: object) -> bool: 2074 if not isinstance(other, ShardedZarrStore): 2075 return False 2076 return self._root_cid == other._root_cid 2077 2078 async def flush(self) -> str: 2079 async with self._write_lock: 2080 return await self._flush_unlocked() 2081 2082 async def _flush_unlocked(self) -> str: 2083 async with self._shard_data_cache._cache_lock: 2084 dirty_shards = list(self._shard_data_cache._dirty_shards) 2085 if dirty_shards: 2086 sorted_dirty_shards = sorted(dirty_shards, key=str) 2087 next_shard_index = 0 2088 2089 async def flush_shards() -> None: 2090 nonlocal next_shard_index 2091 while next_shard_index < len(sorted_dirty_shards): 2092 shard_index = next_shard_index 2093 next_shard_index += 1 2094 cache_key = sorted_dirty_shards[shard_index] 2095 async with self._shard_data_cache.pin(cache_key): 2096 shard_lock = self._shard_locks[cache_key] 2097 async with shard_lock: 2098 shard_data_list = await self._shard_data_cache.get( 2099 cache_key 2100 ) 2101 if shard_data_list is None: 2102 raise RuntimeError( 2103 f"Dirty shard {cache_key} not found in cache" 2104 ) 2105 2106 shard_data_bytes = dag_cbor.encode( 2107 cast(IPLDKind, shard_data_list) 2108 ) 2109 new_shard_cid_obj = await self.cas.save( 2110 shard_data_bytes, 2111 codec="dag-cbor", 2112 ) 2113 if not isinstance( 2114 new_shard_cid_obj, CID 2115 ): # pragma: no cover 2116 raise TypeError( 2117 "ShardedZarrStore requires CAS.save to return CIDs." 2118 ) 2119 2120 if self._manifest_version == SHARDED_ZARR_V1: 2121 if not isinstance(cache_key, int): # pragma: no cover 2122 raise TypeError( 2123 "v1 shard cache keys must be integers." 2124 ) 2125 shard_idx = int(cache_key) 2126 if ( 2127 self._root_obj["chunks"]["shard_cids"][shard_idx] 2128 != new_shard_cid_obj 2129 ): 2130 self._root_obj["chunks"]["shard_cids"][ 2131 shard_idx 2132 ] = new_shard_cid_obj 2133 self.array_indices[""].shard_cids[shard_idx] = ( 2134 new_shard_cid_obj 2135 ) 2136 self._dirty_root = True 2137 else: 2138 if isinstance(cache_key, int): # pragma: no cover 2139 raise TypeError( 2140 "v2 shard cache keys must include array paths." 2141 ) 2142 array_path, shard_idx = cache_key 2143 array_index = self.array_indices[array_path] 2144 if ( 2145 array_index.shard_cids[shard_idx] 2146 != new_shard_cid_obj 2147 ): 2148 array_index.shard_cids[shard_idx] = ( 2149 new_shard_cid_obj 2150 ) 2151 self._dirty_root = True 2152 2153 await self._shard_data_cache.mark_clean(cache_key) 2154 2155 flush_tasks = [ 2156 asyncio.ensure_future(flush_shards()) 2157 for _ in range(min(_FLUSH_CONCURRENCY, len(sorted_dirty_shards))) 2158 ] 2159 try: 2160 await asyncio.gather(*flush_tasks) 2161 except BaseException: 2162 # No sibling task may outlive a failed flush: they would keep 2163 # mutating store state (and using the CAS) without the write 2164 # lock after the caller has already observed the failure. 2165 for flush_task in flush_tasks: 2166 flush_task.cancel() 2167 await asyncio.gather(*flush_tasks, return_exceptions=True) 2168 raise 2169 2170 if self._dirty_root: 2171 self._root_obj["metadata"] = { 2172 k: (CID.decode(v) if isinstance(v, str) else v) 2173 for k, v in self._root_obj["metadata"].items() 2174 } 2175 self._sync_arrays_to_root() 2176 root_obj_bytes = dag_cbor.encode(self._root_obj) 2177 new_root_cid = await self.cas.save(root_obj_bytes, codec="dag-cbor") 2178 self._root_cid = str(new_root_cid) 2179 self._dirty_root = False 2180 2181 return self._root_cid # type: ignore[return-value] 2182 2183 async def get( 2184 self, 2185 key: str, 2186 prototype: zarr.core.buffer.BufferPrototype, 2187 byte_range: Optional[zarr.abc.store.ByteRequest] = None, 2188 ) -> Optional[zarr.core.buffer.Buffer]: 2189 with instrumentation.span( 2190 "py_hamt.sharded_store.get", 2191 { 2192 "py_hamt.zarr.key": key, 2193 "py_hamt.zarr.byte_range": byte_range is not None, 2194 }, 2195 ): 2196 started_at = time.perf_counter() 2197 hit = False 2198 kind = "metadata" 2199 shard_idx_for_trace: int | None = None 2200 lookup_key = self._v2_effective_read_key(key) 2201 try: 2202 parsed_chunk = self._parse_chunk_key(lookup_key) 2203 except (ValueError, IndexError): 2204 if self._manifest_version != SHARDED_ZARR_V2: 2205 raise 2206 return None 2207 try: 2208 if parsed_chunk is None: 2209 metadata_cid_obj = self._root_obj["metadata"].get(lookup_key) 2210 if metadata_cid_obj is None: 2211 return None 2212 data = ( 2213 self._metadata_read_cache.get(lookup_key) 2214 if byte_range is None 2215 else None 2216 ) 2217 if data is None: 2218 req_offset, req_length, req_suffix = self._map_byte_request( 2219 byte_range 2220 ) 2221 data = await self.cas.load( 2222 metadata_cid_obj, 2223 offset=req_offset, 2224 length=req_length, 2225 suffix=req_suffix, 2226 ) 2227 if byte_range is None: 2228 self._metadata_read_cache[lookup_key] = data 2229 hit = True 2230 return prototype.buffer.from_bytes(data) 2231 2232 kind = "chunk" 2233 try: 2234 array_index = self._array_index_for_path(parsed_chunk.array_path) 2235 except KeyError: 2236 return await self._get_legacy_metadata_chunk( 2237 lookup_key, prototype, byte_range 2238 ) 2239 linear_chunk_index = self._get_linear_chunk_index_for_index( 2240 parsed_chunk.coords, array_index 2241 ) 2242 shard_idx, index_in_shard = self._get_shard_info_for_index( 2243 linear_chunk_index, array_index 2244 ) 2245 shard_idx_for_trace = shard_idx 2246 2247 cache_key = self._cache_key(array_index.array_path, shard_idx) 2248 shard_lock = self._shard_locks[cache_key] 2249 async with shard_lock: 2250 cached_shard = await self._shard_data_cache.get(cache_key) 2251 use_sparse = ( 2252 self.shard_read_mode != "full" 2253 and self._sparse_read_is_eligible( 2254 array_index, shard_idx, cached_shard, byte_range 2255 ) 2256 ) 2257 if use_sparse and self.shard_read_mode == "auto": 2258 use_sparse = self._auto_mode_wants_sparse(cache_key) 2259 if use_sparse: 2260 chunk_cid_obj = await self._load_sparse_shard_entry( 2261 cache_key, 2262 shard_idx, 2263 cast(CID, array_index.shard_cids[shard_idx]), 2264 index_in_shard, 2265 array_index.chunks_per_shard, 2266 ) 2267 else: 2268 if cached_shard is None: 2269 cached_shard = await self._load_or_initialize_shard_cache( 2270 shard_idx, array_index.array_path 2271 ) 2272 chunk_cid_obj = cached_shard[index_in_shard] 2273 if chunk_cid_obj is None: 2274 legacy_buffer = await self._get_legacy_metadata_chunk( 2275 lookup_key, prototype, byte_range 2276 ) 2277 hit = legacy_buffer is not None 2278 return legacy_buffer 2279 2280 req_offset, req_length, req_suffix = self._map_byte_request(byte_range) 2281 data = await self.cas.load( 2282 chunk_cid_obj, 2283 offset=req_offset, 2284 length=req_length, 2285 suffix=req_suffix, 2286 ) 2287 hit = True 2288 return prototype.buffer.from_bytes(data) 2289 finally: 2290 instrumentation.record_zarr_get( 2291 store="sharded_store", 2292 key=key, 2293 kind=kind, 2294 hit=hit, 2295 seconds=time.perf_counter() - started_at, 2296 byte_range=byte_range is not None, 2297 shard_idx=shard_idx_for_trace, 2298 ) 2299 2300 async def set(self, key: str, value: zarr.core.buffer.Buffer) -> None: 2301 if self.read_only: 2302 raise PermissionError("Cannot write to a read-only store.") 2303 async with self._write_lock: 2304 await self._set_unlocked(key, value) 2305 return None # type: ignore[return-value] 2306 2307 async def _set_unlocked(self, key: str, value: zarr.core.buffer.Buffer) -> None: 2308 await self._resize_complete.wait() 2309 2310 raw_data_bytes = self._strip_v2_root_consolidated_metadata( 2311 key, value.to_bytes() 2312 ) 2313 self._raise_if_v2_write_without_group(key, raw_data_bytes) 2314 await self._register_array_metadata_from_bytes(key, raw_data_bytes) 2315 await self._ensure_v2_parent_group_metadata(key) 2316 2317 try: 2318 parsed_chunk = self._parse_chunk_key(key) 2319 except (ValueError, IndexError): 2320 if self._manifest_version != SHARDED_ZARR_V2: 2321 raise 2322 return None 2323 2324 try: 2325 data_cid_obj = await self.cas.save(raw_data_bytes, codec="raw") 2326 if not isinstance(data_cid_obj, CID): 2327 raise TypeError("ShardedZarrStore requires CAS.save to return CIDs.") 2328 await self._set_pointer_cid(key, data_cid_obj, register_metadata=False) 2329 if parsed_chunk is None: 2330 self._metadata_read_cache[key] = raw_data_bytes 2331 except Exception as e: 2332 raise RuntimeError(f"Failed to save data for key {key}: {e}") from e 2333 return None # type: ignore[return-value] 2334 2335 async def set_pointer(self, key: str, pointer: str) -> None: 2336 if self.read_only: 2337 raise PermissionError("Cannot write to a read-only store.") 2338 async with self._write_lock: 2339 await self._resize_complete.wait() 2340 await self._set_pointer(key, pointer, register_metadata=True) 2341 2342 async def _set_pointer( 2343 self, key: str, pointer: str, *, register_metadata: bool 2344 ) -> None: 2345 await self._set_pointer_cid( 2346 key, CID.decode(pointer), register_metadata=register_metadata 2347 ) 2348 2349 async def _set_pointer_cid( 2350 self, key: str, pointer_cid_obj: CID, *, register_metadata: bool 2351 ) -> None: 2352 try: 2353 parsed_chunk = self._parse_chunk_key(key) 2354 except (ValueError, IndexError): 2355 if self._manifest_version != SHARDED_ZARR_V2: 2356 raise 2357 return None 2358 if parsed_chunk is None: 2359 if register_metadata and self._manifest_version == SHARDED_ZARR_V2: 2360 raw_metadata = await self.cas.load(pointer_cid_obj) 2361 stripped_metadata = self._strip_v2_root_consolidated_metadata( 2362 key, raw_metadata 2363 ) 2364 if stripped_metadata != raw_metadata: 2365 stripped_pointer = await self.cas.save( 2366 stripped_metadata, codec="raw" 2367 ) 2368 if not isinstance(stripped_pointer, CID): # pragma: no cover 2369 raise TypeError( 2370 "ShardedZarrStore requires CAS.save to return CIDs." 2371 ) 2372 pointer_cid_obj = stripped_pointer 2373 if ( 2374 register_metadata 2375 and self._array_path_from_metadata_key(key) is not None 2376 ): 2377 raw_metadata = await self.cas.load(pointer_cid_obj) 2378 await self._register_array_metadata_from_bytes(key, raw_metadata) 2379 if register_metadata: 2380 await self._ensure_v2_parent_group_metadata(key) 2381 self._root_obj["metadata"][key] = pointer_cid_obj 2382 self._metadata_read_cache.pop(key, None) 2383 self._dirty_root = True 2384 return None 2385 2386 array_index = self._array_index_for_path(parsed_chunk.array_path) 2387 linear_chunk_index = self._get_linear_chunk_index_for_index( 2388 parsed_chunk.coords, array_index 2389 ) 2390 shard_idx, index_in_shard = self._get_shard_info_for_index( 2391 linear_chunk_index, array_index 2392 ) 2393 2394 cache_key = self._cache_key(array_index.array_path, shard_idx) 2395 async with self._use_shard(shard_idx, array_index.array_path): 2396 await self._shard_data_cache.update_entry( 2397 cache_key, index_in_shard, pointer_cid_obj 2398 ) 2399 # A legacy root may also carry this chunk key in the metadata 2400 # mapping; drop it so the superseded CID is not pinned forever and 2401 # cannot resurface through the legacy fallback once the shard slot 2402 # empties again. 2403 if self._root_obj["metadata"].pop(key, None) is not None: 2404 self._metadata_read_cache.pop(key, None) 2405 self._dirty_root = True 2406 2407 if self._manifest_version == SHARDED_ZARR_V1 and not self._primary_inferred: 2408 # A genuinely-unrecorded root's first chunk write establishes and 2409 # persists its primary. An *inferred* primary is never sealed here: 2410 # inference is in-memory only, and the exclusivity flag guarantees 2411 # only true-primary chunks reach this point anyway. 2412 chunk_info = self._root_obj["chunks"] 2413 if "primary_array_path" not in chunk_info: 2414 self._primary_array_path = parsed_chunk.array_path 2415 chunk_info["primary_array_path"] = parsed_chunk.array_path 2416 self._dirty_root = True 2417 return None 2418 2419 async def exists(self, key: str) -> bool: 2420 lookup_key = self._v2_effective_read_key(key) 2421 try: 2422 parsed_chunk = self._parse_chunk_key(lookup_key) 2423 if parsed_chunk is None: 2424 return lookup_key in self._root_obj.get("metadata", {}) 2425 try: 2426 array_index = self._array_index_for_path(parsed_chunk.array_path) 2427 except KeyError: 2428 return lookup_key in self._root_obj.get("metadata", {}) 2429 linear_chunk_index = self._get_linear_chunk_index_for_index( 2430 parsed_chunk.coords, array_index 2431 ) 2432 shard_idx, index_in_shard = self._get_shard_info_for_index( 2433 linear_chunk_index, array_index 2434 ) 2435 async with self._use_shard( 2436 shard_idx, array_index.array_path 2437 ) as target_shard_list: 2438 return target_shard_list[ 2439 index_in_shard 2440 ] is not None or lookup_key in self._root_obj.get("metadata", {}) 2441 except (ValueError, IndexError, KeyError): 2442 return False 2443 2444 @property 2445 def supports_writes(self) -> bool: 2446 return not self.read_only 2447 2448 @property 2449 def supports_partial_writes(self) -> bool: 2450 return False 2451 2452 @property 2453 def supports_deletes(self) -> bool: 2454 return not self.read_only 2455 2456 async def delete(self, key: str) -> None: 2457 if self.read_only: 2458 raise PermissionError("Cannot delete from a read-only store.") 2459 async with self._write_lock: 2460 await self._delete_unlocked(key) 2461 2462 async def _delete_unlocked(self, key: str) -> None: 2463 await self._resize_complete.wait() 2464 2465 try: 2466 parsed_chunk = self._parse_chunk_key(key) 2467 except (ValueError, IndexError): 2468 if self._manifest_version != SHARDED_ZARR_V2: 2469 raise 2470 return None 2471 if parsed_chunk is None: 2472 if self._root_obj["metadata"].pop(key, None) is not None: 2473 self._metadata_read_cache.pop(key, None) 2474 self._dirty_root = True 2475 return None 2476 2477 try: 2478 array_index = self._array_index_for_path(parsed_chunk.array_path) 2479 except KeyError: 2480 if self._root_obj["metadata"].pop(key, None) is not None: 2481 self._metadata_read_cache.pop(key, None) 2482 self._dirty_root = True 2483 return None 2484 linear_chunk_index = self._get_linear_chunk_index_for_index( 2485 parsed_chunk.coords, array_index 2486 ) 2487 shard_idx, index_in_shard = self._get_shard_info_for_index( 2488 linear_chunk_index, array_index 2489 ) 2490 2491 cache_key = self._cache_key(array_index.array_path, shard_idx) 2492 async with self._use_shard(shard_idx, array_index.array_path): 2493 await self._shard_data_cache.update_entry(cache_key, index_in_shard, None) 2494 if self._root_obj["metadata"].pop(key, None) is not None: 2495 self._metadata_read_cache.pop(key, None) 2496 self._dirty_root = True 2497 2498 async def delete_dir(self, prefix: str) -> None: 2499 if self._manifest_version != SHARDED_ZARR_V2: 2500 await zarr.abc.store.Store.delete_dir(self, prefix) 2501 return 2502 if self.read_only: 2503 raise PermissionError("Cannot delete from a read-only store.") 2504 2505 async with self._write_lock: 2506 await self._resize_complete.wait() 2507 normalized_prefix = prefix.strip("/") 2508 if normalized_prefix == "": 2509 await self._clear_v2_unlocked() 2510 return 2511 2512 match_prefix = f"{normalized_prefix}/" 2513 metadata_keys_to_delete = [ 2514 key 2515 for key in self._root_obj.get("metadata", {}) 2516 if key.startswith(match_prefix) 2517 ] 2518 for key in metadata_keys_to_delete: 2519 await self._delete_unlocked(key) 2520 await self._prune_v2_array_indices_for_prefix(normalized_prefix) 2521 2522 async def clear(self) -> None: 2523 if self._manifest_version != SHARDED_ZARR_V2: 2524 await zarr.abc.store.Store.clear(self) 2525 return 2526 if self.read_only: 2527 raise PermissionError("Cannot clear a read-only store.") 2528 2529 async with self._write_lock: 2530 await self._resize_complete.wait() 2531 await self._clear_v2_unlocked() 2532 2533 async def _clear_v2_unlocked(self) -> None: 2534 if self._manifest_version != SHARDED_ZARR_V2: 2535 return 2536 for pending_load in self._pending_shard_loads.values(): 2537 pending_load.set() 2538 self._pending_shard_loads.clear() 2539 await self._shard_data_cache.clear() 2540 # A cleared store has a new access pattern to learn. 2541 self._sparse_read_counts.clear() 2542 self._full_mode_latched = False 2543 self._root_obj["metadata"] = {} 2544 self._root_obj["arrays"] = {} 2545 self.array_indices.clear() 2546 self._primary_array_path = None 2547 self._metadata_read_cache.clear() 2548 self._array_shape = () 2549 self._chunk_shape = () 2550 self._chunks_per_dim = () 2551 self._chunks_per_shard = 0 2552 self._num_shards = 0 2553 self._total_chunks = 0 2554 self._dirty_root = True 2555 2556 async def _prune_v2_array_indices_for_prefix(self, prefix: str) -> None: 2557 if self._manifest_version != SHARDED_ZARR_V2: 2558 return 2559 2560 normalized_prefix = self._normalize_array_path(prefix) 2561 array_paths = [ 2562 array_path 2563 for array_path in self.array_indices 2564 if array_path == normalized_prefix 2565 or array_path.startswith(f"{normalized_prefix}/") 2566 ] 2567 if not array_paths: 2568 return 2569 2570 for array_path in array_paths: 2571 array_index = self.array_indices.pop(array_path) 2572 self._root_obj["arrays"].pop(array_path, None) 2573 for shard_idx in range(array_index.num_shards): 2574 cache_key = self._cache_key(array_path, shard_idx) 2575 pending_load = self._pending_shard_loads.pop(cache_key, None) 2576 if pending_load is not None: 2577 pending_load.set() 2578 shard_lock = self._shard_locks[cache_key] 2579 async with shard_lock: 2580 await self._shard_data_cache.discard(cache_key) 2581 2582 if self.array_indices: 2583 self._primary_array_path = next(iter(self.array_indices)) 2584 self._set_legacy_geometry_from_index( 2585 self.array_indices[self._primary_array_path] 2586 ) 2587 else: 2588 self._primary_array_path = None 2589 self._array_shape = () 2590 self._chunk_shape = () 2591 self._chunks_per_dim = () 2592 self._chunks_per_shard = 0 2593 self._num_shards = 0 2594 self._total_chunks = 0 2595 self._sync_arrays_to_root() 2596 self._dirty_root = True 2597 2598 @property 2599 def supports_listing(self) -> bool: 2600 return True 2601 2602 async def list(self) -> AsyncIterator[str]: 2603 yielded: set[str] = set() 2604 for key in list(self._root_obj.get("metadata", {})): 2605 yielded.add(key) 2606 yield key 2607 2608 async for chunk_key in self._iter_chunk_keys(): 2609 if chunk_key not in yielded: 2610 yield chunk_key 2611 2612 async def _iter_chunk_keys(self) -> AsyncIterator[str]: 2613 for array_path, array_index in self.array_indices.items(): 2614 listed_array_path = ( 2615 self._primary_array_path 2616 if self._manifest_version == SHARDED_ZARR_V1 2617 else array_path 2618 ) 2619 for shard_idx in range(array_index.num_shards): 2620 cache_key = self._cache_key(array_path, shard_idx) 2621 shard_data = await self._shard_data_cache.get(cache_key) 2622 if shard_data is None: 2623 if array_index.shard_cids[shard_idx] is None: 2624 continue 2625 shard_data = await self._load_or_initialize_shard_cache( 2626 shard_idx, array_path 2627 ) 2628 2629 for index_in_shard, cid_obj in enumerate(shard_data): 2630 if cid_obj is None: 2631 continue 2632 linear_index = ( 2633 shard_idx * array_index.chunks_per_shard + index_in_shard 2634 ) 2635 if linear_index >= array_index.total_chunks: 2636 continue 2637 coords = self._coords_from_linear_index( 2638 linear_index, array_index.chunks_per_dim 2639 ) 2640 chunk_key = self._format_chunk_key(listed_array_path or "", coords) 2641 if ( 2642 self._manifest_version == SHARDED_ZARR_V1 2643 and not listed_array_path 2644 ): 2645 recorded_path = self._root_obj.get("chunks", {}).get( 2646 "primary_array_path" 2647 ) 2648 if not ( 2649 isinstance(recorded_path, str) 2650 and self._normalize_array_path(recorded_path) == "" 2651 ): 2652 # Unrecorded V1 roots distinguish shard chunks 2653 # ("/c/...") from legacy metadata keys ("c/..."). 2654 chunk_key = f"/{chunk_key}" 2655 yield chunk_key 2656 2657 async def list_prefix(self, prefix: str) -> AsyncIterator[str]: 2658 async for key in self.list(): 2659 if key.startswith(prefix): 2660 yield key 2661 2662 def _list_dir_candidate_keys(self) -> Set[str]: 2663 keys = set(self._root_obj.get("metadata", {})) 2664 if self._manifest_version != SHARDED_ZARR_V2: 2665 chunk_prefix = ( 2666 "c" if not self._primary_array_path else f"{self._primary_array_path}/c" 2667 ) 2668 keys.add(chunk_prefix) 2669 return keys 2670 2671 for array_path in self.array_indices: 2672 if array_path: 2673 parts = array_path.split("/") 2674 keys.update("/".join(parts[:idx]) for idx in range(1, len(parts) + 1)) 2675 keys.add("c" if array_path == "" else f"{array_path}/c") 2676 return keys 2677 2678 def _chunk_listing_prefixes(self) -> Iterator[str]: 2679 """The chunk-directory prefixes ``_iter_chunk_keys`` emits keys under.""" 2680 if self._manifest_version == SHARDED_ZARR_V2: 2681 for array_path in self.array_indices: 2682 yield "c" if array_path == "" else f"{array_path}/c" 2683 else: 2684 # V1 emits a single primary chunk tree, rooted at "c" or 2685 # "<primary>/c" to match the recorded/inferred primary path. 2686 yield ( 2687 "c" if not self._primary_array_path else f"{self._primary_array_path}/c" 2688 ) 2689 2690 def _is_chunk_listing_prefix(self, normalized_prefix: str) -> bool: 2691 for chunk_prefix in self._chunk_listing_prefixes(): 2692 if normalized_prefix == chunk_prefix or normalized_prefix.startswith( 2693 f"{chunk_prefix}/" 2694 ): 2695 return True 2696 return False 2697 2698 async def graft_store( 2699 self, 2700 store_to_graft_cid: str, 2701 chunk_offset: Tuple[int, ...], 2702 *, 2703 source_array_path: Optional[str] = None, 2704 target_array_path: Optional[str] = None, 2705 ) -> None: 2706 if self.read_only: 2707 raise PermissionError("Cannot graft onto a read-only store.") 2708 async with self._write_lock: 2709 await self._graft_store_unlocked( 2710 store_to_graft_cid, 2711 chunk_offset, 2712 source_array_path=source_array_path, 2713 target_array_path=target_array_path, 2714 ) 2715 2716 async def _graft_store_unlocked( 2717 self, 2718 store_to_graft_cid: str, 2719 chunk_offset: Tuple[int, ...], 2720 *, 2721 source_array_path: Optional[str] = None, 2722 target_array_path: Optional[str] = None, 2723 ) -> None: 2724 await self._resize_complete.wait() 2725 2726 store_to_graft = await ShardedZarrStore.open( 2727 cas=self.cas, read_only=True, root_cid=store_to_graft_cid 2728 ) 2729 source_path = ( 2730 source_array_path 2731 if source_array_path is not None 2732 else store_to_graft._primary_array_path 2733 ) 2734 if source_path is None: 2735 return None 2736 2737 source_index = store_to_graft._array_index_for_path(source_path) 2738 target_path = ( 2739 target_array_path if target_array_path is not None else source_path 2740 ) 2741 target_index = self._array_index_for_path(target_path) 2742 if len(chunk_offset) != len(source_index.chunks_per_dim) or len( 2743 chunk_offset 2744 ) != len(target_index.chunks_per_dim): 2745 raise ValueError( 2746 "chunk_offset must have the same number of dimensions as both source and target arrays." 2747 ) 2748 2749 for local_coords in itertools.product(*[ 2750 range(s) for s in source_index.chunks_per_dim 2751 ]): 2752 linear_local_index = self._get_linear_chunk_index_for_index( 2753 tuple(local_coords), source_index 2754 ) 2755 local_shard_idx, index_in_local_shard = self._get_shard_info_for_index( 2756 linear_local_index, source_index 2757 ) 2758 source_shard_list = await store_to_graft._load_or_initialize_shard_cache( 2759 local_shard_idx, source_index.array_path 2760 ) 2761 2762 pointer_cid_obj = source_shard_list[index_in_local_shard] 2763 if pointer_cid_obj is None: 2764 continue 2765 2766 global_coords = tuple( 2767 c_local + c_offset 2768 for c_local, c_offset in zip(local_coords, chunk_offset, strict=True) 2769 ) 2770 try: 2771 self._validate_chunk_coords(global_coords, target_index) 2772 except IndexError as exc: 2773 raise ValueError( 2774 f"Graft target chunk coordinates {global_coords} are out of bounds." 2775 ) from exc 2776 linear_global_index = self._get_linear_chunk_index_for_index( 2777 global_coords, target_index 2778 ) 2779 global_shard_idx, index_in_global_shard = self._get_shard_info_for_index( 2780 linear_global_index, target_index 2781 ) 2782 2783 cache_key = self._cache_key(target_index.array_path, global_shard_idx) 2784 async with self._use_shard(global_shard_idx, target_index.array_path): 2785 await self._shard_data_cache.update_entry( 2786 cache_key, index_in_global_shard, pointer_cid_obj 2787 ) 2788 2789 async def resize_store( 2790 self, new_shape: Tuple[int, ...], *, array_path: Optional[str] = None 2791 ) -> None: 2792 if self.read_only: 2793 raise PermissionError("Cannot resize a read-only store.") 2794 async with self._write_lock: 2795 await self._resize_store_unlocked(new_shape, array_path=array_path) 2796 2797 async def _resize_store_unlocked( 2798 self, new_shape: Tuple[int, ...], *, array_path: Optional[str] = None 2799 ) -> None: 2800 """ 2801 Resizes one shard index to accommodate a new array shape. 2802 """ 2803 2804 if self._manifest_version == SHARDED_ZARR_V2: 2805 target_path = ( 2806 array_path if array_path is not None else self._primary_array_path 2807 ) 2808 if target_path is None: 2809 raise RuntimeError("Store is not properly initialized for resizing.") 2810 array_index = self._array_index_for_path(target_path) 2811 await self._resize_array_index_guarded(array_index, tuple(new_shape)) 2812 return None 2813 2814 if ( 2815 self._chunk_shape is None 2816 or self._chunks_per_shard is None 2817 or self._array_shape is None 2818 ): 2819 raise RuntimeError("Store is not properly initialized for resizing.") 2820 if len(new_shape) != len(self._array_shape): 2821 raise ValueError( 2822 "New shape must have the same number of dimensions as the old shape." 2823 ) 2824 2825 array_index = self.array_indices[""] 2826 await self._resize_array_index_guarded(array_index, tuple(new_shape)) 2827 self._root_obj["chunks"]["array_shape"] = list(array_index.array_shape) 2828 self._root_obj["chunks"]["shard_cids"] = array_index.shard_cids 2829 return None 2830 2831 async def resize_variable( 2832 self, variable_name: str, new_shape: Tuple[int, ...] 2833 ) -> None: 2834 if self.read_only: 2835 raise PermissionError("Cannot resize a read-only store.") 2836 async with self._write_lock: 2837 await self._resize_variable_unlocked(variable_name, new_shape) 2838 2839 async def _resize_variable_unlocked( 2840 self, variable_name: str, new_shape: Tuple[int, ...] 2841 ) -> None: 2842 """ 2843 Resizes the Zarr metadata and shard index for a specific variable. 2844 """ 2845 await self._resize_complete.wait() 2846 2847 normalized_name = self._normalize_array_path(variable_name) 2848 zarr_metadata_key = ( 2849 "zarr.json" if normalized_name == "" else f"{normalized_name}/zarr.json" 2850 ) 2851 2852 old_zarr_metadata_cid = self._root_obj["metadata"].get(zarr_metadata_key) 2853 if not old_zarr_metadata_cid: 2854 raise KeyError( 2855 f"Cannot find metadata for key '{zarr_metadata_key}' to resize." 2856 ) 2857 2858 old_zarr_metadata_bytes = await self.cas.load(old_zarr_metadata_cid) 2859 zarr_metadata_json = json.loads(old_zarr_metadata_bytes) 2860 zarr_metadata_json["shape"] = list(new_shape) 2861 2862 new_zarr_metadata_bytes = json.dumps(zarr_metadata_json, indent=2).encode( 2863 "utf-8" 2864 ) 2865 new_zarr_metadata_cid = await self.cas.save( 2866 new_zarr_metadata_bytes, codec="raw" 2867 ) 2868 2869 self._root_obj["metadata"][zarr_metadata_key] = new_zarr_metadata_cid 2870 self._metadata_read_cache[zarr_metadata_key] = new_zarr_metadata_bytes 2871 if self._manifest_version == SHARDED_ZARR_V2: 2872 await self._register_array_metadata_from_bytes( 2873 zarr_metadata_key, new_zarr_metadata_bytes 2874 ) 2875 self._dirty_root = True 2876 2877 async def migrate_v1_to_v2(self, primary_array_path: str) -> str: 2878 if self.read_only: 2879 raise PermissionError("Cannot migrate a read-only store.") 2880 async with self._write_lock: 2881 return await self._migrate_v1_to_v2_unlocked(primary_array_path) 2882 2883 async def _migrate_v1_to_v2_unlocked(self, primary_array_path: str) -> str: 2884 """ 2885 Rewrite this store root as a v2 manifest, reusing the existing v1 shards 2886 under ``primary_array_path``. 2887 """ 2888 normalized_path = self._normalize_array_path(primary_array_path) 2889 if not normalized_path: 2890 raise ValueError("primary_array_path must be a non-empty array path.") 2891 if self._manifest_version != SHARDED_ZARR_V1: 2892 raise ValueError("Only sharded_zarr_v1 stores can be migrated to v2.") 2893 2894 await self._flush_unlocked() 2895 await self._shard_data_cache.clear() 2896 # Cache keys change shape from int to tuple[str, int] across the 2897 # migration, so stale counter entries would be unreachable garbage. 2898 self._sparse_read_counts.clear() 2899 self._full_mode_latched = False 2900 2901 source_array_path = self._infer_v1_migration_source_array_path(normalized_path) 2902 old_metadata = dict(self._root_obj.get("metadata", {})) 2903 migrated_metadata = { 2904 self._rewrite_v1_metadata_key_for_migration( 2905 key, source_array_path, normalized_path 2906 ): cid 2907 for key, cid in old_metadata.items() 2908 } 2909 await self._add_missing_group_metadata(migrated_metadata, normalized_path) 2910 old_shard_cids = list(self._root_obj["chunks"]["shard_cids"]) 2911 migrated_index = ArrayIndex( 2912 array_path=normalized_path, 2913 array_shape=self._array_shape, 2914 chunk_shape=self._chunk_shape, 2915 chunks_per_shard=self._chunks_per_shard, 2916 shard_cids=old_shard_cids, 2917 ) 2918 2919 self._manifest_version = SHARDED_ZARR_V2 2920 self.array_indices = {normalized_path: migrated_index} 2921 self._primary_array_path = normalized_path 2922 self._default_chunks_per_shard = migrated_index.chunks_per_shard 2923 self._set_legacy_geometry_from_index(migrated_index) 2924 self._root_obj = { 2925 "manifest_version": SHARDED_ZARR_V2, 2926 "store_type": "py_hamt.sharded_zarr", 2927 "zarr_format": 3, 2928 "sharding_config": { 2929 "chunks_per_shard": migrated_index.chunks_per_shard, 2930 "order": migrated_index.order, 2931 }, 2932 "metadata": migrated_metadata, 2933 "arrays": {normalized_path: migrated_index.to_manifest()}, 2934 } 2935 self._metadata_read_cache.clear() 2936 self._dirty_root = True 2937 return await self._flush_unlocked() 2938 2939 async def list_dir(self, prefix: str) -> AsyncIterator[str]: 2940 seen: Set[str] = set() 2941 normalized_prefix = prefix.strip("/") 2942 if ( 2943 self.read_only 2944 and normalized_prefix == "" 2945 and self._v2_requires_explicit_group_for_root_read() 2946 ): 2947 raise ValueError(self._V2_MULTI_GROUP_READ_MESSAGE) 2948 effective_prefix = self._v2_effective_list_dir_prefix(normalized_prefix) 2949 match_prefix = f"{effective_prefix}/" if effective_prefix else "" 2950 2951 if self._is_chunk_listing_prefix(effective_prefix): 2952 async for key in self._iter_chunk_keys(): 2953 # Unrecorded V1 roots emit the primary shard tree with a 2954 # leading slash ("/c/...") to distinguish it from legacy 2955 # metadata keys, but list_dir prefixes are slash-stripped. 2956 # Normalize the emitted key so list_dir("c") matches "/c/...". 2957 normalized_key = key[1:] if key.startswith("/") else key 2958 if not normalized_key.startswith(match_prefix): 2959 continue 2960 suffix = normalized_key[len(match_prefix) :] 2961 first_component = suffix.split("/", 1)[0] 2962 if first_component not in seen: 2963 seen.add(first_component) 2964 yield first_component 2965 return 2966 2967 for key in self._list_dir_candidate_keys(): 2968 if not key.startswith(match_prefix): 2969 continue 2970 suffix = key[len(match_prefix) :] 2971 if suffix == "": 2972 continue 2973 first_component = suffix.split("/", 1)[0] 2974 if first_component not in seen: 2975 seen.add(first_component) 2976 yield first_component
Implements the Zarr Store API using a sharded layout for chunk CIDs.
sharded_zarr_v1 roots keep the original single global shard index for
compatibility. sharded_zarr_v2 roots keep one shard index per Zarr array
path, allowing grouped arrays to reuse chunk coordinates without collisions.
494 def __init__( 495 self, 496 cas: ContentAddressedStore, 497 read_only: bool, 498 root_cid: Optional[str] = None, 499 *, 500 max_cache_memory_bytes: int = 100 * 1024 * 1024, # 100MB default 501 shard_read_mode: ShardReadMode = "auto", 502 ): 503 """Use the async `open()` classmethod to instantiate this class.""" 504 super().__init__(read_only=read_only) 505 if shard_read_mode not in {"full", "sparse", "auto"}: 506 raise ValueError( 507 f"Unsupported shard_read_mode: {shard_read_mode!r}. " 508 "Expected 'full', 'sparse', or 'auto'." 509 ) 510 self.cas = cas 511 self._root_cid = root_cid 512 self.shard_read_mode = shard_read_mode 513 self._root_obj: dict = {} 514 self._manifest_version = SHARDED_ZARR_V1 515 516 self._resize_lock = asyncio.Lock() 517 self._resize_complete = asyncio.Event() 518 self._resize_complete.set() 519 self._write_lock = asyncio.Lock() 520 self._shard_locks: DefaultDict[ShardCacheKey, asyncio.Lock] = defaultdict( 521 asyncio.Lock 522 ) 523 524 self._shard_data_cache = MemoryBoundedLRUCache(max_cache_memory_bytes) 525 self._pending_shard_loads: Dict[ShardCacheKey, asyncio.Event] = {} 526 # Per-shard sparse-read counts, used only to detect the scan pattern in 527 # "auto" mode. Detection is per-shard because 256 reads spread across 528 # 256 distinct shards is a point-read workload, not a scan. 529 self._sparse_read_counts: Dict[ShardCacheKey, int] = {} 530 # Latched once any single shard crosses the threshold: the caller is 531 # scanning, so every shard gets the full path from here on. Store-wide 532 # because the access pattern belongs to the caller, not the shard. 533 # 534 # Held in a one-element list so with_read_only clones share the *cell* 535 # rather than a copied bool. They already share the counters and the 536 # cache, and a clone that latched would otherwise clear those shared 537 # counters while leaving its siblings believing they were still sparse 538 # -- so the next clone would resume sparse reads with no counter left 539 # to re-earn promotion. See _full_mode_latched. 540 self._full_mode_latched_cell: List[bool] = [False] 541 self._metadata_read_cache: Dict[str, bytes] = {} 542 543 self.array_indices: Dict[str, ArrayIndex] = {} 544 self._primary_array_path: Optional[str] = None 545 # True only when the primary path was heuristically inferred for a 546 # legacy V1 root (never recorded on disk). Tracked separately from the 547 # path's truthiness because an inferred *root* primary is "", which is 548 # indistinguishable from "not inferred" under a plain truthiness check. 549 self._primary_inferred: bool = False 550 self._default_chunks_per_shard: Optional[int] = None 551 552 self._array_shape: Tuple[int, ...] = () 553 self._chunk_shape: Tuple[int, ...] = () 554 self._chunks_per_dim: Tuple[int, ...] = () 555 self._chunks_per_shard: int = 0 556 self._num_shards: int = 0 557 self._total_chunks: int = 0 558 559 self._dirty_root = False 560 self._v2_pending_root_group_write = False
Use the async open() classmethod to instantiate this class.
632 @classmethod 633 async def open( 634 cls, 635 cas: ContentAddressedStore, 636 read_only: bool, 637 root_cid: Optional[str] = None, 638 *, 639 array_shape: Optional[Tuple[int, ...]] = None, 640 chunk_shape: Optional[Tuple[int, ...]] = None, 641 chunks_per_shard: Optional[int] = None, 642 max_cache_memory_bytes: int = 100 * 1024 * 1024, # 100MB default 643 manifest_version: Optional[str] = None, 644 primary_array_path: str = "", 645 shard_read_mode: ShardReadMode = "auto", 646 ) -> "ShardedZarrStore": 647 """ 648 Asynchronously opens an existing ShardedZarrStore or initializes a new one. 649 650 Shape-based creation remains the v1 compatibility path. To create a new 651 path-aware v2 store, pass ``manifest_version="sharded_zarr_v2"`` or omit 652 ``array_shape``/``chunk_shape`` and provide ``chunks_per_shard``. 653 654 ``shard_read_mode`` controls how a **read-only** cache miss resolves a 655 chunk pointer. It has no effect on writes: a writable store always goes 656 through the shard cache so pending writes stay visible, so writes behave 657 as ``"full"`` does regardless of this setting. 658 659 - ``"auto"`` (the default) starts sparse, then latches the **entire 660 store** to full decodes once any *single* shard has been read 661 ``_SPARSE_PROMOTE_THRESHOLD`` times. The latch is store-wide because 662 the access pattern belongs to the caller rather than the shard: a 663 caller reading one shard that heavily is scanning and will scan the 664 rest too, so making every other shard re-learn that independently 665 would re-pay the detection cost on each one. It is permanent for the 666 store's lifetime and unaffected by cache eviction. Below the 667 threshold it is byte-for-byte the ``"sparse"`` path, so point reads 668 pay nothing for the safety net. 669 - ``"sparse"`` fetches only the requested entry and caches nothing. Far 670 cheaper for point reads, but degrades without bound on a scan, 671 eventually costing more than ``"full"``. Pin this when you know the 672 workload is point reads and want to rule out the latch entirely -- 673 for instance a long-lived reader that hammers one hot shard without 674 ever scanning, which ``"auto"`` would latch on. 675 - ``"full"`` decodes and caches the whole shard. Flat cost regardless of 676 how many chunks are then read from it, so it suits known scans and 677 skips ``"auto"``'s detection cost. 678 """ 679 store = cls( 680 cas, 681 read_only, 682 root_cid, 683 max_cache_memory_bytes=max_cache_memory_bytes, 684 shard_read_mode=shard_read_mode, 685 ) 686 if root_cid: 687 await store._load_root_from_cid() 688 elif not read_only: 689 if manifest_version not in {None, SHARDED_ZARR_V1, SHARDED_ZARR_V2}: 690 raise ValueError(f"Incompatible manifest version: {manifest_version}.") 691 692 if ( 693 manifest_version in {None, SHARDED_ZARR_V1} 694 and array_shape is None 695 and chunk_shape is None 696 and chunks_per_shard is None 697 ): 698 raise ValueError( 699 "array_shape and chunk_shape must be provided for a new store." 700 ) 701 if manifest_version in {None, SHARDED_ZARR_V1} and ( 702 (array_shape is None) != (chunk_shape is None) 703 ): 704 raise ValueError( 705 "array_shape and chunk_shape must be provided for a new store." 706 ) 707 if manifest_version == SHARDED_ZARR_V1 and ( 708 array_shape is None or chunk_shape is None 709 ): 710 raise ValueError( 711 "array_shape and chunk_shape must be provided for a new store." 712 ) 713 714 if not isinstance(chunks_per_shard, int) or chunks_per_shard <= 0: 715 raise ValueError("chunks_per_shard must be a positive integer.") 716 717 use_v2 = manifest_version == SHARDED_ZARR_V2 or ( 718 array_shape is None and chunk_shape is None 719 ) 720 if use_v2: 721 if (array_shape is None) != (chunk_shape is None): 722 raise ValueError( 723 "array_shape and chunk_shape must both be provided when seeding a v2 array index." 724 ) 725 store._initialize_new_root_v2( 726 chunks_per_shard=chunks_per_shard, 727 array_shape=array_shape, 728 chunk_shape=chunk_shape, 729 primary_array_path=primary_array_path, 730 ) 731 else: 732 if array_shape is None or chunk_shape is None: # pragma: no cover 733 raise ValueError( 734 "array_shape and chunk_shape must be provided for a new store." 735 ) 736 store._initialize_new_root(array_shape, chunk_shape, chunks_per_shard) 737 else: 738 raise ValueError("root_cid must be provided for a read-only store.") 739 return store
Asynchronously opens an existing ShardedZarrStore or initializes a new one.
Shape-based creation remains the v1 compatibility path. To create a new
path-aware v2 store, pass manifest_version="sharded_zarr_v2" or omit
array_shape/chunk_shape and provide chunks_per_shard.
shard_read_mode controls how a read-only cache miss resolves a
chunk pointer. It has no effect on writes: a writable store always goes
through the shard cache so pending writes stay visible, so writes behave
as "full" does regardless of this setting.
"auto"(the default) starts sparse, then latches the entire store to full decodes once any single shard has been read_SPARSE_PROMOTE_THRESHOLDtimes. The latch is store-wide because the access pattern belongs to the caller rather than the shard: a caller reading one shard that heavily is scanning and will scan the rest too, so making every other shard re-learn that independently would re-pay the detection cost on each one. It is permanent for the store's lifetime and unaffected by cache eviction. Below the threshold it is byte-for-byte the"sparse"path, so point reads pay nothing for the safety net."sparse"fetches only the requested entry and caches nothing. Far cheaper for point reads, but degrades without bound on a scan, eventually costing more than"full". Pin this when you know the workload is point reads and want to rule out the latch entirely -- for instance a long-lived reader that hammers one hot shard without ever scanning, which"auto"would latch on."full"decodes and caches the whole shard. Flat cost regardless of how many chunks are then read from it, so it suits known scans and skips"auto"'s detection cost.
1983 async def set_partial_values( 1984 self, key_start_values: Iterable[Tuple[str, int, BytesLike]] 1985 ) -> None: 1986 raise NotImplementedError( 1987 "Partial writes are not supported by ShardedZarrStore." 1988 )
Store values at a given key, starting at byte range_start.
Parameters
key_start_values : list[tuple[str, int, BytesLike]] set of key, range_start, values triples, a key may occur multiple times with different range_starts, range_starts (considering the length of the respective values) must not specify overlapping ranges for the same key
1990 async def get_partial_values( 1991 self, 1992 prototype: zarr.core.buffer.BufferPrototype, 1993 key_ranges: Iterable[Tuple[str, zarr.abc.store.ByteRequest | None]], 1994 ) -> List[Optional[zarr.core.buffer.Buffer]]: 1995 tasks = [self.get(key, prototype, byte_range) for key, byte_range in key_ranges] 1996 results = await asyncio.gather(*tasks) 1997 return results
Retrieve possibly partial values from given key_ranges.
Parameters
prototype : BufferPrototype The prototype of the output buffer. Stores may support a default buffer prototype. key_ranges : Iterable[tuple[str, tuple[int | None, int | None]]] Ordered set of key, range pairs, a key may occur multiple times with different ranges
Returns
list of values, in the order of the key_ranges, may contain null/none for missing keys
1999 def with_read_only(self, read_only: bool = False) -> "ShardedZarrStore": 2000 """ 2001 Return this store (if the flag already matches) or a shallow clone with 2002 the requested read-only status. 2003 """ 2004 if read_only == self.read_only: 2005 return self 2006 2007 clone = type(self).__new__(type(self)) 2008 2009 clone.cas = self.cas 2010 clone._root_cid = self._root_cid 2011 clone._root_obj = self._root_obj 2012 clone._manifest_version = self._manifest_version 2013 clone.shard_read_mode = self.shard_read_mode 2014 2015 clone._resize_lock = self._resize_lock 2016 clone._resize_complete = self._resize_complete 2017 clone._write_lock = self._write_lock 2018 clone._shard_locks = self._shard_locks 2019 2020 clone._shard_data_cache = self._shard_data_cache 2021 clone._pending_shard_loads = self._pending_shard_loads 2022 # Both shared by reference, like the cache above. The counters must be 2023 # shared so a clone does not restart counting while reading through the 2024 # *same* cache; the latch cell must be shared for the same reason in 2025 # reverse -- latching clears the shared counters, so a sibling holding 2026 # a copied False would resume sparse reads with nothing left to re-earn 2027 # promotion from. 2028 clone._sparse_read_counts = self._sparse_read_counts 2029 clone._full_mode_latched_cell = self._full_mode_latched_cell 2030 clone._metadata_read_cache = self._metadata_read_cache 2031 2032 clone.array_indices = self.array_indices 2033 clone._primary_array_path = self._primary_array_path 2034 clone._primary_inferred = self._primary_inferred 2035 clone._default_chunks_per_shard = self._default_chunks_per_shard 2036 2037 clone._array_shape = self._array_shape 2038 clone._chunk_shape = self._chunk_shape 2039 clone._chunks_per_dim = self._chunks_per_dim 2040 clone._chunks_per_shard = self._chunks_per_shard 2041 clone._num_shards = self._num_shards 2042 clone._total_chunks = self._total_chunks 2043 2044 clone._dirty_root = self._dirty_root 2045 clone._v2_pending_root_group_write = self._v2_pending_root_group_write 2046 2047 # A V1 primary that was inferred against a read-only open never reached 2048 # the persistence branch in _infer_v1_legacy_primary_array_path (that 2049 # branch is gated on `not self.read_only`), so it lives only in the 2050 # in-memory `_primary_inferred` flag. Making a writable clone would carry 2051 # that flag forward while leaving the shared root unrecorded: the first 2052 # chunk write skips its seal-on-write (it is gated on 2053 # `not self._primary_inferred`), so a same-geometry secondary array could 2054 # flush with no recorded primary. On reopen inference would then be 2055 # ambiguous and misroute the secondary's metadata chunk into the primary 2056 # shard slot. Perform the deferred persistence the read-only open 2057 # skipped, reproducing the state a writable open would have produced. 2058 if ( 2059 not read_only 2060 and clone._manifest_version == SHARDED_ZARR_V1 2061 and clone._primary_inferred 2062 and isinstance(clone._root_obj.get("chunks"), dict) 2063 and "primary_array_path" not in clone._root_obj["chunks"] 2064 ): 2065 clone._root_obj["chunks"]["primary_array_path"] = ( 2066 clone._primary_array_path or "" 2067 ) 2068 clone._dirty_root = True 2069 2070 zarr.abc.store.Store.__init__(clone, read_only=read_only) 2071 return clone
Return this store (if the flag already matches) or a shallow clone with the requested read-only status.
2183 async def get( 2184 self, 2185 key: str, 2186 prototype: zarr.core.buffer.BufferPrototype, 2187 byte_range: Optional[zarr.abc.store.ByteRequest] = None, 2188 ) -> Optional[zarr.core.buffer.Buffer]: 2189 with instrumentation.span( 2190 "py_hamt.sharded_store.get", 2191 { 2192 "py_hamt.zarr.key": key, 2193 "py_hamt.zarr.byte_range": byte_range is not None, 2194 }, 2195 ): 2196 started_at = time.perf_counter() 2197 hit = False 2198 kind = "metadata" 2199 shard_idx_for_trace: int | None = None 2200 lookup_key = self._v2_effective_read_key(key) 2201 try: 2202 parsed_chunk = self._parse_chunk_key(lookup_key) 2203 except (ValueError, IndexError): 2204 if self._manifest_version != SHARDED_ZARR_V2: 2205 raise 2206 return None 2207 try: 2208 if parsed_chunk is None: 2209 metadata_cid_obj = self._root_obj["metadata"].get(lookup_key) 2210 if metadata_cid_obj is None: 2211 return None 2212 data = ( 2213 self._metadata_read_cache.get(lookup_key) 2214 if byte_range is None 2215 else None 2216 ) 2217 if data is None: 2218 req_offset, req_length, req_suffix = self._map_byte_request( 2219 byte_range 2220 ) 2221 data = await self.cas.load( 2222 metadata_cid_obj, 2223 offset=req_offset, 2224 length=req_length, 2225 suffix=req_suffix, 2226 ) 2227 if byte_range is None: 2228 self._metadata_read_cache[lookup_key] = data 2229 hit = True 2230 return prototype.buffer.from_bytes(data) 2231 2232 kind = "chunk" 2233 try: 2234 array_index = self._array_index_for_path(parsed_chunk.array_path) 2235 except KeyError: 2236 return await self._get_legacy_metadata_chunk( 2237 lookup_key, prototype, byte_range 2238 ) 2239 linear_chunk_index = self._get_linear_chunk_index_for_index( 2240 parsed_chunk.coords, array_index 2241 ) 2242 shard_idx, index_in_shard = self._get_shard_info_for_index( 2243 linear_chunk_index, array_index 2244 ) 2245 shard_idx_for_trace = shard_idx 2246 2247 cache_key = self._cache_key(array_index.array_path, shard_idx) 2248 shard_lock = self._shard_locks[cache_key] 2249 async with shard_lock: 2250 cached_shard = await self._shard_data_cache.get(cache_key) 2251 use_sparse = ( 2252 self.shard_read_mode != "full" 2253 and self._sparse_read_is_eligible( 2254 array_index, shard_idx, cached_shard, byte_range 2255 ) 2256 ) 2257 if use_sparse and self.shard_read_mode == "auto": 2258 use_sparse = self._auto_mode_wants_sparse(cache_key) 2259 if use_sparse: 2260 chunk_cid_obj = await self._load_sparse_shard_entry( 2261 cache_key, 2262 shard_idx, 2263 cast(CID, array_index.shard_cids[shard_idx]), 2264 index_in_shard, 2265 array_index.chunks_per_shard, 2266 ) 2267 else: 2268 if cached_shard is None: 2269 cached_shard = await self._load_or_initialize_shard_cache( 2270 shard_idx, array_index.array_path 2271 ) 2272 chunk_cid_obj = cached_shard[index_in_shard] 2273 if chunk_cid_obj is None: 2274 legacy_buffer = await self._get_legacy_metadata_chunk( 2275 lookup_key, prototype, byte_range 2276 ) 2277 hit = legacy_buffer is not None 2278 return legacy_buffer 2279 2280 req_offset, req_length, req_suffix = self._map_byte_request(byte_range) 2281 data = await self.cas.load( 2282 chunk_cid_obj, 2283 offset=req_offset, 2284 length=req_length, 2285 suffix=req_suffix, 2286 ) 2287 hit = True 2288 return prototype.buffer.from_bytes(data) 2289 finally: 2290 instrumentation.record_zarr_get( 2291 store="sharded_store", 2292 key=key, 2293 kind=kind, 2294 hit=hit, 2295 seconds=time.perf_counter() - started_at, 2296 byte_range=byte_range is not None, 2297 shard_idx=shard_idx_for_trace, 2298 )
Retrieve the value associated with a given key.
Parameters
key : str prototype : BufferPrototype The prototype of the output buffer. Stores may support a default buffer prototype. byte_range : ByteRequest, optional ByteRequest may be one of the following. If not provided, all data associated with the key is retrieved. - RangeByteRequest(int, int): Request a specific range of bytes in the form (start, end). The end is exclusive. If the given range is zero-length or starts after the end of the object, an error will be returned. Additionally, if the range ends after the end of the object, the entire remainder of the object will be returned. Otherwise, the exact requested range will be returned. - OffsetByteRequest(int): Request all bytes starting from a given byte offset. This is equivalent to bytes={int}- as an HTTP header. - SuffixByteRequest(int): Request the last int bytes. Note that here, int is the size of the request, not the byte offset. This is equivalent to bytes=-{int} as an HTTP header.
Returns
Buffer
2300 async def set(self, key: str, value: zarr.core.buffer.Buffer) -> None: 2301 if self.read_only: 2302 raise PermissionError("Cannot write to a read-only store.") 2303 async with self._write_lock: 2304 await self._set_unlocked(key, value) 2305 return None # type: ignore[return-value]
Store a (key, value) pair.
Parameters
key : str value : Buffer
2419 async def exists(self, key: str) -> bool: 2420 lookup_key = self._v2_effective_read_key(key) 2421 try: 2422 parsed_chunk = self._parse_chunk_key(lookup_key) 2423 if parsed_chunk is None: 2424 return lookup_key in self._root_obj.get("metadata", {}) 2425 try: 2426 array_index = self._array_index_for_path(parsed_chunk.array_path) 2427 except KeyError: 2428 return lookup_key in self._root_obj.get("metadata", {}) 2429 linear_chunk_index = self._get_linear_chunk_index_for_index( 2430 parsed_chunk.coords, array_index 2431 ) 2432 shard_idx, index_in_shard = self._get_shard_info_for_index( 2433 linear_chunk_index, array_index 2434 ) 2435 async with self._use_shard( 2436 shard_idx, array_index.array_path 2437 ) as target_shard_list: 2438 return target_shard_list[ 2439 index_in_shard 2440 ] is not None or lookup_key in self._root_obj.get("metadata", {}) 2441 except (ValueError, IndexError, KeyError): 2442 return False
Check if a key exists in the store.
Parameters
key : str
Returns
bool
2456 async def delete(self, key: str) -> None: 2457 if self.read_only: 2458 raise PermissionError("Cannot delete from a read-only store.") 2459 async with self._write_lock: 2460 await self._delete_unlocked(key)
Remove a key from the store
Parameters
key : str
2498 async def delete_dir(self, prefix: str) -> None: 2499 if self._manifest_version != SHARDED_ZARR_V2: 2500 await zarr.abc.store.Store.delete_dir(self, prefix) 2501 return 2502 if self.read_only: 2503 raise PermissionError("Cannot delete from a read-only store.") 2504 2505 async with self._write_lock: 2506 await self._resize_complete.wait() 2507 normalized_prefix = prefix.strip("/") 2508 if normalized_prefix == "": 2509 await self._clear_v2_unlocked() 2510 return 2511 2512 match_prefix = f"{normalized_prefix}/" 2513 metadata_keys_to_delete = [ 2514 key 2515 for key in self._root_obj.get("metadata", {}) 2516 if key.startswith(match_prefix) 2517 ] 2518 for key in metadata_keys_to_delete: 2519 await self._delete_unlocked(key) 2520 await self._prune_v2_array_indices_for_prefix(normalized_prefix)
Remove all keys and prefixes in the store that begin with a given prefix.
2522 async def clear(self) -> None: 2523 if self._manifest_version != SHARDED_ZARR_V2: 2524 await zarr.abc.store.Store.clear(self) 2525 return 2526 if self.read_only: 2527 raise PermissionError("Cannot clear a read-only store.") 2528 2529 async with self._write_lock: 2530 await self._resize_complete.wait() 2531 await self._clear_v2_unlocked()
Clear the store.
Remove all keys and values from the store.
2602 async def list(self) -> AsyncIterator[str]: 2603 yielded: set[str] = set() 2604 for key in list(self._root_obj.get("metadata", {})): 2605 yielded.add(key) 2606 yield key 2607 2608 async for chunk_key in self._iter_chunk_keys(): 2609 if chunk_key not in yielded: 2610 yield chunk_key
Retrieve all keys in the store.
Returns
AsyncIterator[str]
2657 async def list_prefix(self, prefix: str) -> AsyncIterator[str]: 2658 async for key in self.list(): 2659 if key.startswith(prefix): 2660 yield key
Retrieve all keys in the store that begin with a given prefix. Keys are returned relative to the root of the store.
Parameters
prefix : str
Returns
AsyncIterator[str]
2698 async def graft_store( 2699 self, 2700 store_to_graft_cid: str, 2701 chunk_offset: Tuple[int, ...], 2702 *, 2703 source_array_path: Optional[str] = None, 2704 target_array_path: Optional[str] = None, 2705 ) -> None: 2706 if self.read_only: 2707 raise PermissionError("Cannot graft onto a read-only store.") 2708 async with self._write_lock: 2709 await self._graft_store_unlocked( 2710 store_to_graft_cid, 2711 chunk_offset, 2712 source_array_path=source_array_path, 2713 target_array_path=target_array_path, 2714 )
2789 async def resize_store( 2790 self, new_shape: Tuple[int, ...], *, array_path: Optional[str] = None 2791 ) -> None: 2792 if self.read_only: 2793 raise PermissionError("Cannot resize a read-only store.") 2794 async with self._write_lock: 2795 await self._resize_store_unlocked(new_shape, array_path=array_path)
2939 async def list_dir(self, prefix: str) -> AsyncIterator[str]: 2940 seen: Set[str] = set() 2941 normalized_prefix = prefix.strip("/") 2942 if ( 2943 self.read_only 2944 and normalized_prefix == "" 2945 and self._v2_requires_explicit_group_for_root_read() 2946 ): 2947 raise ValueError(self._V2_MULTI_GROUP_READ_MESSAGE) 2948 effective_prefix = self._v2_effective_list_dir_prefix(normalized_prefix) 2949 match_prefix = f"{effective_prefix}/" if effective_prefix else "" 2950 2951 if self._is_chunk_listing_prefix(effective_prefix): 2952 async for key in self._iter_chunk_keys(): 2953 # Unrecorded V1 roots emit the primary shard tree with a 2954 # leading slash ("/c/...") to distinguish it from legacy 2955 # metadata keys, but list_dir prefixes are slash-stripped. 2956 # Normalize the emitted key so list_dir("c") matches "/c/...". 2957 normalized_key = key[1:] if key.startswith("/") else key 2958 if not normalized_key.startswith(match_prefix): 2959 continue 2960 suffix = normalized_key[len(match_prefix) :] 2961 first_component = suffix.split("/", 1)[0] 2962 if first_component not in seen: 2963 seen.add(first_component) 2964 yield first_component 2965 return 2966 2967 for key in self._list_dir_candidate_keys(): 2968 if not key.startswith(match_prefix): 2969 continue 2970 suffix = key[len(match_prefix) :] 2971 if suffix == "": 2972 continue 2973 first_component = suffix.split("/", 1)[0] 2974 if first_component not in seen: 2975 seen.add(first_component) 2976 yield first_component
Retrieve all keys and prefixes with a given prefix and which do not contain the character “/” after the given prefix.
Parameters
prefix : str
Returns
AsyncIterator[str]
135class ShardedZarrV1DeprecationWarning(FutureWarning): 136 """Warning emitted when using deprecated sharded_zarr_v1 roots."""
Warning emitted when using deprecated sharded_zarr_v1 roots.
81async def convert_hamt_to_sharded( 82 cas: ContentAddressedStore, hamt_root_cid: str, chunks_per_shard: int 83) -> str: 84 """ 85 Converts a Zarr dataset from a HAMT-based store to a ShardedZarrStore. 86 87 Args: 88 cas: An initialized ContentAddressedStore instance (KuboCAS). 89 hamt_root_cid: The root CID of the source ZarrHAMTStore. 90 chunks_per_shard: The number of chunks to group into a single shard in the new store. 91 92 Returns: 93 The root CID of the newly created ShardedZarrStore. 94 """ 95 print(f"--- Starting Conversion from HAMT Root {hamt_root_cid} ---") 96 start_time = time.perf_counter() 97 # 1. Open the source HAMT store for reading 98 print("Opening source HAMT store...") 99 hamt_ro = await HAMT.build( 100 cas=cas, root_node_id=hamt_root_cid, values_are_bytes=True, read_only=True 101 ) 102 103 # 2. Create the destination ShardedZarrStore for writing. 104 print( 105 f"Initializing new ShardedZarrStore v2 with {chunks_per_shard} chunks per shard..." 106 ) 107 dest_store = await ShardedZarrStore.open( 108 cas=cas, 109 read_only=False, 110 chunks_per_shard=chunks_per_shard, 111 manifest_version=SHARDED_ZARR_V2, 112 ) 113 114 print("Destination store initialized.") 115 116 # 3. Copy metadata first so each chunked array path registers its own shard 117 # index before chunk pointers are inserted. 118 print("Starting data migration...") 119 count = 0 120 async for key in hamt_ro.keys(): 121 if not _is_zarr_metadata_key(key): 122 continue 123 count += 1 124 cid = await hamt_ro.get_pointer(key) 125 if not isinstance(cid, CID): # pragma: no cover 126 raise TypeError(f"Expected CID pointer for key {key!r}.") 127 cid_base32_str = str(cid.encode("base32")) 128 await dest_store.set_pointer(key, cid_base32_str) 129 if count % 200 == 0: # pragma: no cover 130 print(f"Migrated {count} keys...") # pragma: no cover 131 132 async for key in hamt_ro.keys(): 133 chunk_key = _normalize_zarr_chunk_key(key, dest_store.array_indices) 134 if chunk_key is None: 135 if _is_zarr_metadata_key(key): 136 continue 137 raise ValueError( 138 f"Cannot classify Zarr key {key!r} as metadata or chunk during conversion." 139 ) 140 count += 1 141 cid = await hamt_ro.get_pointer(key) 142 if not isinstance(cid, CID): # pragma: no cover 143 raise TypeError(f"Expected CID pointer for key {key!r}.") 144 cid_base32_str = str(cid.encode("base32")) 145 await dest_store.set_pointer(chunk_key, cid_base32_str) 146 if count % 200 == 0: # pragma: no cover 147 print(f"Migrated {count} keys...") # pragma: no cover 148 149 print(f"Migration of {count} total keys complete.") 150 151 # 5. Finalize the new store by flushing it to the CAS 152 print("Flushing new store to get final root CID...") 153 new_root_cid = await dest_store.flush() 154 end_time = time.perf_counter() 155 156 print("\n--- Conversion Complete! ---") 157 print(f"Total time: {end_time - start_time:.2f} seconds") 158 print(f"New ShardedZarrStore Root CID: {new_root_cid}") 159 return new_root_cid
Converts a Zarr dataset from a HAMT-based store to a ShardedZarrStore.
Args: cas: An initialized ContentAddressedStore instance (KuboCAS). hamt_root_cid: The root CID of the source ZarrHAMTStore. chunks_per_shard: The number of chunks to group into a single shard in the new store.
Returns: The root CID of the newly created ShardedZarrStore.