Under the hood

Sharing and memory

Understand shared ownership, build budgets, retained roots and measured memory behavior.

Loading diagram…

Derivation adds the required nodes without deep-copying the tree. Each new root, literal and Python owner still has a cost.

Retaining a root retains its reachable children. Shared data is released after its final owner, as defined by the Arc ownership contract. Retaining many queries is application state, not automatically a leak.

Query construction contract

Choose a source metric once with Q.from_metric(name). There is no .metric() setter/replacement method or native rename_metric operation. Reuse selection rules as immutable data and pure application functions:

from metricraft import QueryBuilder as Q

filters = (("job", "api"), ("env", "prod"))

def select(name, filters):
    query = Q.from_metric(name)
    for label, value in filters:
        query = query.where_eq(label, value)
    return query

requests = select("http_requests_total", filters)
errors = select("http_errors_total", filters)
rate = requests.rate("5m")  # requests is unchanged

Different metrics constructed this way are separate selectors. No hidden interning is promised across independently constructed queries. Derivations from the same expression do share the relevant children and selector data.

Expansion limits and deep trees

A small shared DAG can generate enormous text: repeatedly composing q + q doubles the output while adding only one binary node. Every node therefore caches saturating output-byte and expanded-node counts, without storing its rendered text. build() checks these before reserving the output buffer.

Defaults are 1 MiB UTF-8 output and 100,000 expanded node occurrences, including matchers. Override budgets for a known workload on a single build call:

text = rate.build("promql", max_output_bytes=2 * 1024 * 1024,
                  max_expanded_nodes=200_000)

These limits do not cap the number of live Python expressions or total process memory. Native rendering and last-owner release are iterative. Destruction dismantles only uniquely owned nodes after their lifetime ends; it never mutates any live shared query. Tests build/render/drop 100,000-deep function and matcher chains on a 256 KiB thread stack, and cover concurrent last owners and overflowed expansion counts.

Measurements on 2026-10-08

The unified suite measures release builds with validation and garbage collection active. Its reports retain source and binary hashes. These synthetic workloads replace the unavailable application trace; they do not establish production latency.

Read-only graph diagnostics visit unique nodes and matcher links without recreating a Python AST. Rendered position diagnostics expand occurrences and use the same native build limits. See the diagnostic reference.

The native allocation probe starts from 256 labels with 128-byte values. Counts include requested native allocations and retained root containers, but exclude allocator overhead, Python heaps and OS RSS. Windows GNU release measurements:

WorkloadRetained native bytesBytes above baseline after release
512 branches from one selector179,9000
512 independently constructed selectors with equivalent filters30,620,0500
1,000 branches from one selector294,5800
10,000 branches from one selector2,418,5800
10,000 label-copy branches sharing the source1,679,9430
10,000 WITH branches sharing a binding2,629,9320
1,000 retained selector versions235,2340
10,000 create/build/drop operations, keeping the base7,5660
100,000-deep function chain13,100,1520
100,000-deep WITH chain24,900,3930
60 shared self-compositions, rejected before rendering7,4120
1,000 bounded graph snapshots of a shared DAG14,8260
256 selector OR self-compositions with a common filter, rejected before rendering94,7520

All thirteen cases returned to their native allocation baseline. Structural sharing substantially reduces retained data, but every new root and operation still costs memory. Defaults reject expanded text from the deep WITH chain before rendering; last-owner release remains iterative. WITH bindings are emitted, not expanded in the SDK, so build budgets do not constrain the backend’s macro-expansion work.

Selector OR alternatives and common filters also remain shared nodes. An observed fuzzer timeout exposed eager alternative-list expansion; the failing input is now a regression case. Composition and adding a filter do not enumerate alternatives. The renderer distributes filters iteratively only after checking cached expansion budgets, including nested OR branches and computed string values.

A Python 3.13/Windows run retained, rendered and released 2,000 branches per batch, ten batches each for selector, label-copy and WITH branches: 60,000 owners in total. Every sampled owner became unreachable. After selector batches, traced Python live memory ranged from 75,417 to 79,235 bytes; after WITH batches it ranged from 157,766 to 161,153 bytes, including accumulated measurement records. Process private memory after the WITH batches ranged from about 23.5 to 25.1 MiB. Allocator high-water memory is distinct from reachable query data.

A four-worker Python/ctypes run released 83,456 query owners and handled 93,888 expected validation/budget failures over 60.1 seconds. Each batch checked owner weak references and the unchanged shared base. Post-batch process private memory ranged from about 21.3 to 22.4 MiB and ended at 21.6 MiB after releasing workers and the base, compared with 20.6 MiB after warmup. Traced Python live memory ended at 42,778 bytes, including measurement records. This bounded run showed no continuing growth in retained query state; it is not an application-wide memory guarantee.

Linux ASan with leak detection passed all 26 Rust tests and the thirteen native allocation scenarios for the current implementation, including small-stack and concurrent last-owner release tests. A subsequent libFuzzer run completed 100,950 inputs in 181 seconds without a failure, seeded with the earlier corpus and the selector-OR timeout input. The timeout input also runs deterministically in the normal Rust regression suite.

Reproduce these checks with the benchmark commands. The benchmark Action records the full concurrent memory curve. Native allocation checks, weak-reference assertions, sanitizer execution and fuzzing provide different evidence; none alone proves every workload or arbitrary FFI misuse safe.

Explore the docs

Find your next step

Search guides, examples and the Python API.