model
blobmap.model ¶
Manifest format and the array metadata that feeds it.
The manifest is pure definition: it changes only when the set of blobs changes. Anything measured, such as tier, last access or current size, lives in blobtier's state table keyed by blob id.
Stdlib only. Nothing in this module knows that storage exists, which is what makes the partitioner testable without a backend.
Attributes:
| Name | Type | Description |
|---|---|---|
SCHEMA_VERSION |
Manifest format version. Checked strictly on read; a mismatch raises rather than attempting a best-effort parse, because silently misreading a blob definition would misroute chunks. |
|
VERSION |
Package version, recorded in each manifest as provenance. |
|
GiB |
Convenience constant, 1024**3. |
|
KEY_ENCODINGS |
dict[str, str]
|
Supported chunk key layouts, mapped to an example key.
See |
METADATA_BASENAMES |
tuple[str, ...]
|
Object basenames that are never archivable. This is
what keeps |
Array
dataclass
¶
Array(
path: str,
shape: tuple[int, ...],
chunks: tuple[int, ...],
itemsize: int,
shards: tuple[int, ...] | None = None,
stored_bytes: int | None = None,
nobjects_seen: int | None = None,
is_coordinate: bool = False,
key_encoding: str = "v3_slash",
)
One zarr array: metadata for structure, LIST for size.
This is the partitioner's input. It is deliberately a plain dataclass with no storage behind it, so the partitioning rules can be tested by writing these out by hand.
Attributes:
| Name | Type | Description |
|---|---|---|
path |
str
|
Array path relative to the manifest scope, such as |
shape |
tuple[int, ...]
|
Full array shape, from the zarr metadata. |
chunks |
tuple[int, ...]
|
The inner chunk shape. This is not the storage unit when
the array is sharded; see
|
itemsize |
int
|
Bytes per element, derived from the dtype. |
shards |
tuple[int, ...] | None
|
Shard shape when sharding is in use, else |
stored_bytes |
int | None
|
Observed compressed total, summed from a LIST. |
nobjects_seen |
int | None
|
Objects actually present. Differs from
|
is_coordinate |
bool
|
True for a dimension coordinate. Coordinates are pinned hot regardless of size, or opening the store hits tape. |
key_encoding |
str
|
How to locate the chunk index in an object key. One of
|
Example
tas = Array("tas", (400, 4, 4), (10, 4, 4), 4, stored_bytes=2560) tas.nobjects 40 tas.chunk_prefix 'tas/c'
object_shape
property
¶
object_shape: tuple[int, ...]
Shape of one stored object: the shard if sharded, else the chunk.
zarr-python inverts the intuitive naming. Array.chunks is the
inner chunk of a sharded array and Array.shards is what becomes an
object. Using the inner chunk here understates object size by the
shard factor, which silently produces blobs many times too large. Raw
v3 metadata does not invert: chunk_grid.chunk_shape is already the
object, and the inner shape sits in the sharding codec config.
Returns:
| Type | Description |
|---|---|
tuple[int, ...]
|
The shape of a single stored object. |
Example
Array("tas", (400, 4), (10, 4), 4).object_shape (10, 4) Array("tas", (400, 4), (10, 4), 4, shards=(50, 4)).object_shape (50, 4)
object_grid
property
¶
object_grid: tuple[int, ...]
Number of objects along each dimension.
Returns:
| Type | Description |
|---|---|
int
|
Object counts per dimension, using |
...
|
|
Example
Array("tas", (400, 4, 4), (10, 4, 4), 4).object_grid (40, 1, 1)
nobjects
property
¶
nobjects: int
Objects this array would have if fully written.
Deliberately not nobjects_seen: a sparsely written array should be
partitioned for the shape it will have, not the shape it has today.
Returns:
| Type | Description |
|---|---|
int
|
Total object count across the full grid. |
Example
Array("tas", (400, 4, 4), (10, 4, 4), 4, nobjects_seen=3).nobjects 40
uncompressed_object_bytes
property
¶
uncompressed_object_bytes: int
Size of one object with no compression at all.
Fixed for the lifetime of the array, since changing it means
rewriting every chunk. That is what makes it usable as a hard ceiling
in bucket_width: whatever the
codec does later, an object cannot exceed this.
Returns:
| Type | Description |
|---|---|
int
|
Uncompressed bytes per stored object. |
Example
Array("tas", (400, 4, 4), (10, 4, 4), 4).uncompressed_object_bytes 640
total_bytes
property
¶
total_bytes: int
Total size of the array, observed if known and estimated otherwise.
Returns:
| Type | Description |
|---|---|
int
|
|
Example
Array("tas", (400, 4, 4), (10, 4, 4), 4).total_bytes 25600 Array("tas", (400, 4, 4), (10, 4, 4), 4, stored_bytes=99).total_bytes 99
avg_object_bytes
property
¶
avg_object_bytes: int
Mean size of one stored object.
Divides by the objects actually seen rather than the full grid, so a partially written array is not reported as compressing better than it does.
Returns:
| Type | Description |
|---|---|
int
|
Average bytes per object, at least 1. |
Example
Array("tas", (400, 4, 4), (10, 4, 4), 4, ... stored_bytes=2560).avg_object_bytes 64
chunk_prefix
property
¶
chunk_prefix: str
Where this array's chunk keys begin, relative to the scope.
Returns:
| Type | Description |
|---|---|
str
|
The prefix a blob should claim to cover this array's chunks. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Example
Array("tas", (1,), (1,), 4, key_encoding="v3_slash").chunk_prefix 'tas/c' Array("tas", (1,), (1,), 4, key_encoding="v2_flat").chunk_prefix 'tas'
Policy
dataclass
¶
Policy(
t_max_bytes: int = 100 * GiB,
t_min_bytes: int = 0,
t_hot_bytes: int = 16 * 1024**2,
width_clamp: int = 8,
pow2_floor: int = 64,
)
Thresholds that shape the cut.
Defaults are a starting point, not a recommendation. The right values depend on tape mount and positioning times, so check them against what the HSM actually does before trusting them.
Attributes:
| Name | Type | Description |
|---|---|---|
t_max_bytes |
int
|
Target upper bound for one blob. A subtree at or under this becomes a single blob; above it the partitioner descends and eventually buckets. Too large and a restore takes hours. |
t_min_bytes |
int
|
Floor below which arrays are coalesced with their neighbours rather than each becoming a blob. Defaults to 0, which disables coalescing: it groups by path adjacency, which is a guess about access correlation, and the cost of guessing wrong is restoring data nobody asked for. Aggregating small objects is the tape layer's job. Raise it only if the HSM cannot bundle recalls. |
t_hot_bytes |
int
|
Arrays smaller than this are never archived, regardless of the blobs. This is a "not worth a row" threshold, not a "small variable" one: keeping every sub-gigabyte array hot leaves an unbounded amount of data permanently on disk. Coordinates are pinned by detection, not by size, so this can be small. |
width_clamp |
int
|
Bound on the worst case. A blob may not exceed
|
pow2_floor |
int
|
Widths at or above this are rounded down to a power of two, so a small drift in the observed compression ratio does not renumber every blob. Below it, rounding would throw away too much of the target, which is the common case for sharded arrays. |
Example
Policy().t_max_bytes == 100 * GiB True Policy().t_min_bytes # no floor: the tape layer bundles 0
to_json ¶
to_json() -> dict[str, int]
Serialise for embedding in a manifest.
Returns:
| Type | Description |
|---|---|
dict[str, int]
|
A plain dict of the five thresholds. |
Source code in src/blobmap/model.py
304 305 306 307 308 309 310 311 312 313 314 315 316 | |
from_json
classmethod
¶
from_json(d: dict[str, Any]) -> Policy
Rebuild from a manifest, ignoring fields this version does not know.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
d
|
dict[str, Any]
|
The |
required |
Returns:
| Type | Description |
|---|---|
Policy
|
A |
Example
Policy.from_json({"t_max_bytes": 5, "added_in_future": 9}) Policy(t_max_bytes=5, t_min_bytes=0, t_hot_bytes=16777216, width_clamp=8, pow2_floor=64)
Source code in src/blobmap/model.py
318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 | |
Pin
dataclass
¶
Pin(
prefix: str,
reason: str,
by: str = "",
at: str = "",
until: str | None = None,
)
A deliberate instruction to keep a prefix hot.
Distinct from hot_always, which is derived: metadata objects and
coordinates are recomputed on every partition because they follow from the
store's structure. A pin follows from someone's intent, so it is preserved
across repartitions and can only be removed by removing it.
That also means a pin is the one part of a manifest that cannot be reconstructed by re-scanning. Blob definitions can be recomputed; intent cannot. Back the manifest bucket up.
Attributes:
| Name | Type | Description |
|---|---|---|
prefix |
str
|
What to keep hot, relative to the manifest scope. Empty string pins the whole scope. |
reason |
str
|
Why. Required, because a pin with no stated reason is indistinguishable from one nobody remembers setting. |
by |
str
|
Who set it. |
at |
str
|
ISO 8601 UTC timestamp when it was set. |
until |
str | None
|
ISO 8601 UTC timestamp after which it should be reviewed, or
|
Example
pin = Pin("multiscales/zoom_9", "active ICON analysis", "wilfred", ... "2026-08-19T09:12:00+00:00", "2026-12-01T00:00:00+00:00") pin.covers("multiscales/zoom_9/tas/c/0") True pin.covers("multiscales/zoom_8/tas/c/0") False
to_json ¶
to_json() -> dict[str, Any]
Serialise for the manifest.
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A dict with |
Source code in src/blobmap/model.py
376 377 378 379 380 381 382 383 384 385 386 387 388 | |
from_json
classmethod
¶
from_json(d: dict[str, Any]) -> Pin
Rebuild from a manifest entry.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
d
|
dict[str, Any]
|
One element of the manifest's |
required |
Returns:
| Type | Description |
|---|---|
Pin
|
A |
Source code in src/blobmap/model.py
390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 | |
covers ¶
covers(key: str) -> bool
Whether this pin applies to a key, relative to the scope.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
str
|
Scope-relative object key. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if the key is at or below the pinned prefix. |
Source code in src/blobmap/model.py
408 409 410 411 412 413 414 415 416 417 418 419 | |
expired ¶
expired(now_iso: str | None = None) -> bool
Whether the review date has passed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
now_iso
|
str | None
|
Time to compare against, defaulting to now. ISO 8601. |
None
|
Returns:
| Type | Description |
|---|---|
bool
|
False for an open-ended pin, which never expires but also never |
bool
|
gets reviewed -- which is its own problem, so |
bool
|
those out separately. |
Example
Pin("x", "why", until="2020-01-01T00:00:00+00:00").expired() True Pin("x", "why").expired() False
Source code in src/blobmap/model.py
421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 | |
Bucket
dataclass
¶
Bucket(index: int, width: int, key_encoding: str)
The arithmetic that turns a chunk index into a blob number.
This is what makes a manifest a rule rather than a table: chunk 5,000,000 resolves without anyone having enumerated it, so appending to a store needs no manifest change at all.
Attributes:
| Name | Type | Description |
|---|---|---|
index |
int
|
Which segment after the blob prefix holds the chunk index. Almost always 0, since time is conventionally the first dimension and is what people slice on. |
width |
int
|
How many objects along that dimension go into one blob.
Chosen by |
key_encoding |
str
|
How to parse the index out of the key. One of
|
Example
Bucket(0, 2048, "v3_slash").to_json()["width"] 2048
to_json ¶
to_json() -> dict[str, Any]
Serialise for the manifest.
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A dict with |
Source code in src/blobmap/model.py
469 470 471 472 473 474 475 476 477 478 479 | |
from_json
classmethod
¶
from_json(d: dict[str, Any]) -> Bucket
Rebuild from a manifest.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
d
|
dict[str, Any]
|
The |
required |
Returns:
| Type | Description |
|---|---|
Bucket
|
A |
Source code in src/blobmap/model.py
481 482 483 484 485 486 487 488 489 490 491 | |
Blob
dataclass
¶
Blob(
id: str,
prefixes: tuple[str, ...],
bucket: Bucket | None = None,
)
A set of objects that move to and from tape together.
Every blob has exactly these three fields. bucket is None means one
bucket, so the resolved id is always f"{id}_{n}". Unbucketed is the
degenerate case of bucketed rather than a different kind of thing, which
gives the resolver a single code path and means filling in bucket later
does not change existing blob ids.
Attributes:
| Name | Type | Description |
|---|---|---|
id |
str
|
Stable identifier, |
prefixes |
tuple[str, ...]
|
Key prefixes this blob claims, relative to the manifest scope. More than one when small arrays were coalesced. A bucketed blob must have exactly one. |
bucket |
Bucket | None
|
Arithmetic for splitting inside an array, or |
Example
Blob("b_pr", ("pr/c",)).instance(0) 'b_pr_0' Blob("b_tas", ("tas/c",), Bucket(0, 2048, "v3_slash")).instance(2) 'b_tas_2'
to_json ¶
to_json() -> dict[str, Any]
Serialise for the manifest.
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A dict with exactly |
Example
sorted(Blob("b_pr", ("pr/c",)).to_json()) ['bucket', 'id', 'prefixes']
Source code in src/blobmap/model.py
525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 | |
from_json
classmethod
¶
from_json(d: dict[str, Any]) -> Blob
Rebuild from a manifest entry.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
d
|
dict[str, Any]
|
One element of the manifest's |
required |
Returns:
| Type | Description |
|---|---|
Blob
|
A |
Source code in src/blobmap/model.py
541 542 543 544 545 546 547 548 549 550 551 552 553 554 | |
instance ¶
instance(n: int) -> str
Get the concrete blob id for bucket number n.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Bucket number, always 0 when |
required |
Returns:
| Type | Description |
|---|---|
str
|
The id blobtier stores tier state against. |
Source code in src/blobmap/model.py
556 557 558 559 560 561 562 563 564 565 | |
Manifest
dataclass
¶
Manifest(
scope: str,
blobs: tuple[Blob, ...],
hot_always: tuple[str, ...],
pinned: tuple[Pin, ...] = (),
policy: Policy = Policy(),
epoch: int = 1,
generated_at: str = "",
generated_by: str = f"blobmap {VERSION}",
provenance: dict[str, Any] = dict(),
)
The blob definitions for one scope.
Lives as a single JSON object in a bucket you own, at a path mirroring the data. Never inside the store, because data arrives that must not be altered.
A manifest is pure definition. It changes only when the set of blobs changes: a new variable, a new cut, a forced repartition. Not on reads, writes, tiering or restores.
Attributes:
| Name | Type | Description |
|---|---|---|
scope |
str
|
Prefix these definitions apply to, relative to the data store. Usually one zarr store, but may be a parent prefix covering many small stores that should restore together. |
blobs |
tuple[Blob, ...]
|
The blob definitions. A list of rules, so its length tracks the number of cut decisions, not the number of blobs and certainly not the number of objects. |
hot_always |
tuple[str, ...]
|
Glob patterns that are never archivable, whatever the blobs say. Metadata objects and dimension coordinates. Derived from the store's structure and recomputed on every partition, so this is not where deliberate decisions belong. |
pinned |
tuple[Pin, ...]
|
Prefixes someone has deliberately kept hot. Preserved across
repartitions, unlike |
policy |
Policy
|
Thresholds used to produce this cut, recorded so a later run can tell whether they changed. |
epoch |
int
|
Bumped whenever the definitions change. Lets a resolver decide if it needs to reload without diffing. |
generated_at |
str
|
ISO 8601 UTC timestamp of the run that produced this. |
generated_by |
str
|
Package and version that produced this. |
provenance |
dict[str, Any]
|
Measured numbers per array, for debugging only. These can go stale while the manifest is untouched, so the resolver must never read them. |
Example
m = Manifest("cordex/a.zarr", (Blob("b_pr", ("pr/c",)),), ... ("**/zarr.json",)) m.epoch 1 m.bumped().epoch 2
to_json ¶
to_json() -> dict[str, Any]
Serialise, including the schema version.
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
The full manifest as a JSON-compatible dict. |
Source code in src/blobmap/model.py
634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 | |
dumps ¶
dumps(indent: int | None = 2) -> str
Serialise to JSON text.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
indent
|
int | None
|
Passed to |
2
|
Returns:
| Type | Description |
|---|---|
str
|
JSON text ready to write to object storage. |
Source code in src/blobmap/model.py
653 654 655 656 657 658 659 660 661 662 663 | |
from_json
classmethod
¶
from_json(d: dict[str, Any]) -> Manifest
Rebuild from a parsed manifest.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
d
|
dict[str, Any]
|
A parsed manifest object. |
required |
Returns:
| Type | Description |
|---|---|
Manifest
|
A |
Raises:
| Type | Description |
|---|---|
SchemaError
|
If the document does not match
|
Example
Manifest.from_json({"schema_version": 99, "scope": "s", ... "epoch": 1, "hot_always": [], "blobs": []}) Traceback (most recent call last): ... blobmap.schema.SchemaError: schema_version: expected 3, got 99
Source code in src/blobmap/model.py
665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 | |
loads
classmethod
¶
loads(text: str | bytes) -> Manifest
Parse from JSON text.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
text
|
str | bytes
|
JSON as read from object storage. |
required |
Returns:
| Type | Description |
|---|---|
Manifest
|
A |
Raises:
| Type | Description |
|---|---|
SchemaError
|
If the document does not match the schema. |
JSONDecodeError
|
If the text is not JSON at all. |
Example
m = Manifest("s", (Blob("b", ("x",)),), (), ... (Pin("x", "under active analysis"),), ... generated_at="2026-01-01T00:00:00+00:00") Manifest.loads(m.dumps()) == m True
Source code in src/blobmap/model.py
702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 | |
by_id ¶
by_id() -> dict[str, Blob]
Index the blobs by id.
Returns:
| Type | Description |
|---|---|
dict[str, Blob]
|
Mapping of blob id to |
Source code in src/blobmap/model.py
725 726 727 728 729 730 731 | |
bumped ¶
bumped(**changes: Any) -> Manifest
Copy with the epoch incremented and the timestamp refreshed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
**changes
|
Any
|
Any other fields to replace. |
{}
|
Returns:
| Type | Description |
|---|---|
Manifest
|
A new |
Manifest
|
frozen. |
Source code in src/blobmap/model.py
733 734 735 736 737 738 739 740 741 742 743 | |
pin_for ¶
pin_for(key: str) -> Pin | None
Find the pin covering a scope-relative key, if any.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
str
|
Scope-relative object key. |
required |
Returns:
| Type | Description |
|---|---|
Pin | None
|
The first matching |
Source code in src/blobmap/model.py
745 746 747 748 749 750 751 752 753 754 755 756 757 | |
pinned_blob_ids ¶
pinned_blob_ids() -> set[str]
Blob ids whose definition is covered by a pin.
Pinning a prefix that already has a blob cannot remove that blob: repartitioning is additive, and dropping a blob id would orphan whatever tape copy is held against it. So the definition survives, nothing resolves to it any more, and its last-read timestamp goes stale -- at which point an age-based tiering policy would archive exactly the data someone asked to keep on disk.
A consumer must therefore exclude these before archiving. Resolution alone is not enough, because the policy query runs over blob state rather than over keys.
Returns:
| Type | Description |
|---|---|
set[str]
|
Ids of blobs covered by at least one pin. Note these are |
set[str]
|
definition ids, not instance ids: a bucketed blob contributes |
set[str]
|
|
Example
m = Manifest("s", (Blob("b_tas", ("tas/c",)),), (), ... (Pin("tas", "under analysis"),)) m.pinned_blob_ids() {'b_tas'}
Source code in src/blobmap/model.py
759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 | |
validate ¶
validate() -> None
Check the invariants that the resolver relies on.
Called automatically on construction. These are the cross-field rules the JSON Schema cannot express: uniqueness of ids, and the constraint that a bucketed blob has exactly one prefix.
Raises:
| Type | Description |
|---|---|
ValueError
|
On a duplicate or malformed blob id, a blob with no prefixes, an unknown key encoding, a bucketed blob with more than one prefix, or a non-positive width. |
Example
Manifest("s", (Blob("b", ("x",)), Blob("b", ("y",))), ... ()).validate() Traceback (most recent call last): ... ValueError: duplicate blob id 'b'
Source code in src/blobmap/model.py
791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 | |
now ¶
now() -> str
Get current UTC time as an ISO 8601 string, to second resolution.
Returns:
| Type | Description |
|---|---|
str
|
Timestamp such as |
Example
stamp = now() stamp.endswith("+00:00") True
Source code in src/blobmap/model.py
56 57 58 59 60 61 62 63 64 65 66 67 | |
default_hot_always ¶
default_hot_always() -> list[str]
Patterns for metadata objects, which are never archivable.
This is the rule that keeps xr.open_zarr from triggering a tape mount,
and incidentally why re-reading a store to partition it cannot feed its
own event loop: everything the partitioner touches is already ineligible.
Returns:
| Type | Description |
|---|---|
list[str]
|
Glob patterns, one per known metadata basename. |
Example
"**/zarr.json" in default_hot_always() True
Source code in src/blobmap/model.py
847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 | |