Reading Attributes#

ovstage.Stage.read_attributes() reads attribute values for a query. It returns a read handle; fetch its groups of results and release each group after use.

By default, reads return CPU buffers. The experimental placement= argument can select a CUDA copy instead. A placed read with an availability event requires device synchronization after fetching a group and before using its payload; see Experimental Device Placement. NumPy group.array() is CPU-only.

First advance the watermark to cover the changes you need, and wait for that advance. See ovstage Overview for the read model.

Read → Fetch → Release#

This example writes values, advances the watermark, and reads them through a NumPy view in Python or a tensor pointer in C. It then releases the result group. Python releases the read handle on context-manager exit; the C example also releases it during cleanup.

# Write one value per prim, then advance the watermark before reading.
# wait() reports asynchronous failures; the context manager and finally
# block release the read and its result even if consuming the data fails.
stage.write_attribute(
    query, attr, ordinal=1, tensors=np.array([1.0, 2.0, 3.0], np.float32), is_array=False
).wait()
stage.advance_write_floor(ordinal=1).wait()

with stage.read_attributes(query, [attr], OrdinalRange.latest(1)) as read:
    read.wait()
    group = read.fetch_next()
    if group is None:
        raise SystemExit("read returned no group at ordinal 1")
    try:
        # group.array(i) is a zero-copy numpy view of tensor i (CPU).
        print("read back ordinal", group.ordinal, group.array(0))  # -> [1. 2. 3.]
    finally:
        stage.release_group(group)
// Write one float per prim into the "temperature" column (UPSERT creates
// the prims on first write), advance the watermark to ordinal 1,
// then read the column back.
// Describe three contiguous CPU floats, one per prim in query order.
// The tensor references this array; keep it alive until the write completes.
float values[] = { 1.0f, 2.0f, 3.0f };
int64_t shape[] = { 3 };
int64_t strides[] = { 1 };
DLTensor tensor{};
tensor.data = values;
tensor.device = { kDLCPU, 0 };
tensor.ndim = 1;
tensor.dtype = { kDLFloat, 32, 1 }; // {code, bits, lanes}
tensor.shape = shape;
tensor.strides = strides;

// This is one scalar per prim, rather than a variable-length array per prim.
ovstage_write_data_t write{};
write.tensors = &tensor;
write.tensor_count = 1;
write.is_array = false;

// Submit the values at ordinal 1. UPSERT creates missing prims/attributes.
// waitOp checks both acceptance and completion before we proceed.
ovstage_enqueue_result_t enq =
    ovstage_write_attribute(stage, query, attrArg, /*ordinal*/ 1, write, OVSTAGE_PRIM_MODE_UPSERT);
waitOp(stage, enq, "write_attribute");

// Finish writes through ordinal 1 for all attributes. Once this completes,
// the values can be read and further writes at ordinals <= 1 are rejected.
ovstage_write_floor_desc_t writeFloor{};
writeFloor.ordinal = 1;
writeFloor.scope = OVSTAGE_SCOPE_ALL;
enq = ovstage_advance_write_floor(stage, &writeFloor);
waitOp(stage, enq, "advance_write_floor");

// Read the values at ordinal 1 (a snapshot: no start ordinal). A later change
// to this attribute would make this read fail with OUT_OF_RANGE; use
// UINT64_MAX as the end to read current values.
ovstage_ordinal_range_t range{};
range.end_ordinal = 1;
range.has_start_ordinal = false;
ovstage_read_handle_t read = OVSTAGE_INVALID_READ_HANDLE;
enq = ovstage_read_attributes(stage, query, &attr, 1, range, &read);
waitOp(stage, enq, "read_attributes");

// Fetch the result data after the read completes. This uniform example
// returns one group; general readers fetch until END_OF_ITERATION.
ovstage_read_group_t group{};
status = ovstage_fetch_read_next(stage, read, OVSTAGE_TIMEOUT_INFINITE, &group);
check(stage, status, "fetch_read_next");
if (group.data.tensor_count != 1 || !group.data.tensors[0].data)
{
    std::fprintf(stderr, "unexpected read layout\n");
    return EXIT_FAILURE;
}
// Borrow the returned floats while the group is alive. Releasing the group
// invalidates this pointer; copy the values first if they must live longer.
const float* out = static_cast<const float*>(group.data.tensors[0].data);
std::printf("read back ordinal %llu: %.1f %.1f %.1f\n", (unsigned long long)group.ordinal, out[0], out[1], out[2]);
ovstage_release_group(stage, &group); // the tensor data is only valid until the group is released

Groups and Lifetime#

  1. Keep the read handle in a with block and call read.wait().

  2. Fetch all results with read.groups(), or call read.fetch_next() until it returns None. Do not assume one group contains the whole result.

  3. Release each group with its own with block or ovstage.Stage.release_group().

  4. Release the read handle after fetching its results. Context-manager exit does this for you.

read.wait() observes execution errors and releases operation tracking. It returns the handle, so read.wait().fetch_next() is valid inside the read’s with block. Keep the handle in a variable; chaining directly from stage.read_attributes(...) leaves it to the finalizer.

group.semantic gives the attribute’s ovstage.AttributeSemantic. If the native library supplies a value unknown to the Python bindings, it returns that value as an integer.

Groups own their storage separately from the read handle. Releasing the handle does not release its groups. An unreleased group causes overlapping writes to the same attributes and prims to fail. Dropping a group without releasing it triggers a finalizer and ResourceWarning; release it explicitly instead.

Both group.array(i) and DLPack consumer arrays borrow the group’s memory. They do not keep the group alive. Never take a view from a temporary such as read.fetch_next().array(0) or np.from_dlpack(read.fetch_next().dlpack(0)). Keep the group alive while using its views, and copy any values that must outlive it. The group rejects access after release, but previously returned views cannot detect that release. See DLPack Tensor Exchange.

In C, ovstage_fetch_read_next() returns groups until OVSTAGE_ERROR_END_OF_ITERATION. Release each with ovstage_release_group(), then release the handle with ovstage_release_read().

These snippets copy each selected heat value before releasing its group. The query selects /World/Writes/A, /World/Writes/B, and /World/Writes/C; the earlier write examples set their values to 300, 400, and 200, and advance the watermark to 15. paths is the stage’s PathDictionary (dict in C). The C helpers waitOp(), check(), and checkOvx() wait for operations and check stage or dictionary errors. The copies remain usable after cleanup:

import numpy as np

import ovstage
from ovstage import (AttributeSemantic, DLDataType, DLDataTypeCode, OrdinalRange, OvstageError,
                     PrimMode, WriteDesc, make_dltensor)

# Copy selected values before releasing each group; copies outlive the read.
heat = paths.intern_token("heat")
saved = []
with stage.read_attributes(query, [heat], OrdinalRange.latest(15)) as read:
    read.wait()
    for group in read.groups():
        with group:
            rows = group.array(0)
            for i in range(group.prim_count):
                row = group.data_row_index(i) if group.has_data_index_map else i
                saved.append(float(rows[row]))
# Sorting is for display; read groups need not arrive in query order.
print("Copied heat values:", sorted(saved))
# Copied heat values: [200.0, 300.0, 400.0]
const ovx_string_t name = literal_to_ovx_string("heat");
ovx_token_t attribute = OVX_INVALID_TOKEN;
checkOvx(dict, path_dictionary_create_tokens_from_strings(dict, &name, 1, &attribute), "intern attribute");
ovstage_ordinal_range_t range{};
range.end_ordinal = 15;
ovstage_read_handle_t read = OVSTAGE_INVALID_READ_HANDLE;
waitOp(stage, ovstage_read_attributes(stage, query, &attribute, 1, range, &read), "read attributes");
std::vector<float> saved;
for (;;)
{
    ovstage_read_group_t group{};
    const auto status = ovstage_fetch_read_next(stage, read, OVSTAGE_TIMEOUT_INFINITE, &group);
    if (status == OVSTAGE_ERROR_END_OF_ITERATION)
        break;
    check(stage, status, "fetch group");
    const DLTensor& tensor = group.data.tensors[0];
    const auto* values = reinterpret_cast<const float*>(
        static_cast<const uint8_t*>(tensor.data) + tensor.byte_offset);
    for (uint32_t i = 0; i < group.prims.count; ++i)
    {
        const uint32_t row = group.data.index_map ? group.data.index_map[i] : i;
        saved.push_back(values[row]); // Copy the value before releasing its group.
    }
    check(stage, ovstage_release_group(stage, &group), "release group");
}
waitOp(stage, ovstage_release_read(stage, read), "release read");
// Copied values remain valid after both groups and the read are released.
std::sort(saved.begin(), saved.end());
std::printf("Copied heat values:");
for (float value : saved)
    std::printf(" %.1f", value);
std::printf("\n");
// Copied heat values: 200.0 300.0 400.0

Fixed-Size Result Shape#

A raw fixed-size read group has one scalar-lane tensor with a leading transported data-row axis. A point3f reads as (N, 3) and a matrix4d with MATRIX semantic as (N, 4, 4); both have dtype.lanes == 1. N is the transported data-row count and may differ from data.count (the logical prim count): it can be smaller when data.index_map shares rows, or larger when a query touches only part of a transported bucket. A convenience write shape is not stored; the trailing read dimensions derive from the stored component count and semantic. Python DLPack preserves them.

These snippets read the three-component velocity values written by the same example. They select the correct tensor row for each prim before accessing its components. For matrices, reshape the Python view to (-1, 4, 4) or use 16 components per row in C:

velocity = paths.intern_token("velocity")
with stage.read_attributes(query, [velocity], OrdinalRange.latest(15)) as read:
    read.wait()
    for group in read.groups():
        with group:
            # Raw and NumPy shapes are (N, 3), with scalar lanes.
            vectors = np.from_dlpack(group.dlpack(0))
            for i in range(group.prim_count):
                row = group.data_row_index(i) if group.has_data_index_map else i
                print("Velocity:", vectors[row].tolist())
                # One of [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0].
const ovx_string_t name = literal_to_ovx_string("velocity");
ovx_token_t attribute = OVX_INVALID_TOKEN;
checkOvx(dict, path_dictionary_create_tokens_from_strings(dict, &name, 1, &attribute), "intern attribute");
ovstage_ordinal_range_t range{};
range.end_ordinal = 15;
ovstage_read_handle_t read = OVSTAGE_INVALID_READ_HANDLE;
waitOp(stage, ovstage_read_attributes(stage, query, &attribute, 1, range, &read), "read attributes");

for (;;)
{
    ovstage_read_group_t group{};
    const auto status = ovstage_fetch_read_next(stage, read, OVSTAGE_TIMEOUT_INFINITE, &group);
    if (status == OVSTAGE_ERROR_END_OF_ITERATION)
        break;
    check(stage, status, "fetch group");
    // Raw tensor shape is (N, 3), with scalar float32 lanes.
    const DLTensor& tensor = group.data.tensors[0];
    const auto* values = reinterpret_cast<const float*>(
        static_cast<const uint8_t*>(tensor.data) + tensor.byte_offset);
    for (uint32_t i = 0; i < group.prims.count; ++i)
    {
        const uint32_t row = group.data.index_map ? group.data.index_map[i] : i;
        const float* vector = values + row * 3;
        // Use vector[0..2] here, while the group still owns the buffer.
        (void)vector;
    }
    check(stage, ovstage_release_group(stage, &group), "release group");
}
waitOp(stage, ovstage_release_read(stage, read), "release read");

Reading Built-in Metadata#

The reserved attributes usd-path, usd-schemas, usd-prim-type, usd-ancestor-types, usd-parent, and usd-children use 64-bit identifiers. Resolve names and paths through the Path Dictionary. The Runtime Loop reads usd-prim-type after population.

usd-path, usd-parent, and usd-children are computed from current scene structure. Latest reads return them with the requested end ordinal on the result, but that ordinal does not select historical structure. These attributes have no per-write change records, so change-range reads return no groups for them. They do not require a watermark advance.

usd-children can span multiple groups; fetch them all. A prim without a value contributes no row: for example, the root has no parent and a leaf has no children. Writes, deletes, and maps of these three names or usd-active fail with ErrorCode.NOT_SUPPORTED. Reads and filters of usd-active also fail: live prims are always active. The name is retained in the API for now and may be removed.

These snippets query the direct children of /World/Group, read usd-path, and resolve the identifiers to strings. This is the separate Queries example, whose scene contains /World/Group/Left and /World/Group/Right. The Python tab includes its imports. The C helper resolvePath() resolves a path identifier through the dictionary:

import pathlib

import numpy as np

import ovstage
from ovstage import Filter, FilterOp, Predicate
from ovstage import OrdinalRange
from ovstage import ErrorCode, OvstageError, PopulationDomain, instancing, population

# Query the direct children, then read their current path identifiers.
children_filter = Filter([
    Predicate("usd-parent", FilterOp.IN, ["/World/Group"]),
])
path_attribute = paths.intern_token("usd-path")
matched_paths = []
with stage.query(filter=children_filter) as query:
    query.wait()
    with stage.read_attributes(query, [path_attribute], OrdinalRange.latest(2)) as read:
        read.wait()
        for group in read.groups():
            with group:  # Release every group after resolving its identifiers.
                path_ids = group.array(0)
                for i in range(group.prim_count):
                    row = group.data_row_index(i) if group.has_data_index_map else i
                    matched_paths.append(paths.path_to_string(int(path_ids[row])))
print("Matched paths:", " ".join(sorted(matched_paths)))
# Matched paths: /World/Group/Left /World/Group/Right
// Query the direct children of /World/Group.
const ovx_string_t parent = literal_to_ovx_string("/World/Group");
ovstage_predicate_t predicate{};
predicate.attribute.string = literal_to_ovx_string("usd-parent");
predicate.op = OVSTAGE_FILTER_OP_IN;
predicate.values = &parent;
predicate.value_count = 1;
ovstage_filter_t filter{};
filter.predicates = &predicate;
filter.count = 1;
ovstage_query_handle_t query = OVSTAGE_INVALID_QUERY_HANDLE;
waitOp(stage, ovstage_query(stage, &filter, nullptr, 0, &query), "query paths");

// usd-path contains prim-path identifiers resolved through the stage dictionary.
const ovx_string_t name = literal_to_ovx_string("usd-path");
ovx_token_t pathAttribute = OVX_INVALID_TOKEN;
checkOvx(dict, path_dictionary_create_tokens_from_strings(dict, &name, 1, &pathAttribute),
         "intern usd-path");
ovstage_ordinal_range_t range{};
range.end_ordinal = 2; // No start ordinal: read current paths.
ovstage_read_handle_t read = OVSTAGE_INVALID_READ_HANDLE;
waitOp(stage, ovstage_read_attributes(stage, query, &pathAttribute, 1, range, &read), "read paths");

std::vector<std::string> matchedPaths;
for (;;)
{
    ovstage_read_group_t group{};
    const ovstage_api_status_t fetched =
        ovstage_fetch_read_next(stage, read, OVSTAGE_TIMEOUT_INFINITE, &group);
    if (fetched == OVSTAGE_ERROR_END_OF_ITERATION)
        break;
    check(stage, fetched, "fetch_read_next");

    const DLTensor& tensor = group.data.tensors[0];
    const auto* pathIds = reinterpret_cast<const uint64_t*>(
        static_cast<const uint8_t*>(tensor.data) + tensor.byte_offset);
    for (uint32_t i = 0; i < group.prims.count; ++i)
    {
        const uint32_t row = group.data.index_map ? group.data.index_map[i] : i;
        matchedPaths.push_back(resolvePath(dict, pathIds[row]));
    }
    check(stage, ovstage_release_group(stage, &group), "release_group");
}
waitOp(stage, ovstage_release_read(stage, read), "release_read");
waitOp(stage, ovstage_release_query(stage, query), "release_query");

std::sort(matchedPaths.begin(), matchedPaths.end());
std::printf("Matched paths:");
for (const std::string& path : matchedPaths)
    std::printf(" %s", path.c_str());
std::printf("\n");
// Matched paths: /World/Group/Left /World/Group/Right

Read Ranges and Errors#

A snapshot selects every queried path for each requested attribute. An inclusive change range [start, end] selects only pairs changed in that interval. Neither form restores earlier values. The checks below describe the underlying read failure; asynchronous wait/fetch can instead expose OP_FAILED with a diagnostic. See Error Handling and Diagnostics before branching on an error code.

Note

usd-active is currently not supported: a live prim is always active, so the attribute carries no information, and reads or filter predicates naming it return NOT_SUPPORTED. The name remains in the header contract for stability only and is subject to removal in a future release.

Note

This build stores only current attribute values. A snapshot (OrdinalRange.latest(end) in Python) checks every queried path for each requested attribute. A change range [start, end] checks only queried (attribute, path) pairs changed within that range.

For each requested attribute, both forms apply these checks in order:

  1. A selected, unsealed change at or before end returns OVSTAGE_ERROR_WRITE_FLOOR_VIOLATION.

  2. A recorded change after end on a selected path returns OVSTAGE_ERROR_OUT_OF_RANGE, whether that later change is sealed or not.

Changes on unrelated paths do not affect these checks. For example, seal a write at 3, then overwrite it at 5. Reading that attribute with end 4 returns OUT_OF_RANGE. Sealing 5 does not restore the old value. Use a newer end or UINT64_MAX ((1 << 64) - 1 in Python) for current state. An end of UINT64_MAX skips the second check, but a selected unsealed change still returns WRITE_FLOOR_VIOLATION. Because a later change after the end makes a bounded read return OUT_OF_RANGE, a bounded read is not a replay point. A zero-initialized C range is a snapshot at ordinal 0, not a request for current values.

While the end is above the attribute’s write floor, changes between the floor and the end can still be admitted and, once sealed, change what the same request returns. Once the end is at or below the floor, later writes, deletes and maps cannot change the recorded values that a successful read of the same request returns for paths the query still selects. This does not cover derived built-ins or query membership, which reflect current state, changes that ovstage_compute_hierarchy records at or below the floor, or history below the retention frontier.

A returned group’s ordinal is informational. It may be above or below the requested end and is not, by itself, proof of which state the payload represents.

These checks use recorded changes. Snapshot reads of values without change records, including derived built-ins, return current state. Queries select current paths, not the paths present at an earlier ordinal. Under FLOOR_ONLY admission, a write or delete above the applicable floors can be admitted at an older ordinal than an existing change to the same attribute and path; reads then return the most recently applied change. STRICTLY_INCREASING and the default NONDECREASING policy reject such older changes against retained history.

Before relying on an old snapshot or interval, query the retention frontier with ovstage_get_oldest_preserved_ordinal (Stage.get_oldest_preserved_ordinal() in Python). History below that frontier may have been discarded, so a successful read does not guarantee the requested earlier state. For example, after an attribute’s deletion history is discarded, a snapshot from before that deletion may return no row for the attribute instead of OUT_OF_RANGE.

In the write-options example, A and B last changed heat at ordinal 14, while C last changed at 12. These snippets select the inclusive interval [14, 14], so only A and B contribute values. The watermark is already 15, and no later writes have replaced those values:

# Only A and B changed heat at ordinal 14; C last changed at ordinal 12.
heat = paths.intern_token("heat")
changed = []
with stage.read_attributes(query, [heat], OrdinalRange.between(14, 14)) as read:
    read.wait()
    for group in read.groups():
        with group:
            if group.is_delete:
                continue  # Deletions carry no value tensor.
            values = group.array(0)
            for i in range(group.prim_count):
                row = group.data_row_index(i) if group.has_data_index_map else i
                changed.append(float(values[row]))
print("Heat values changed at 14:", sorted(changed))
# Heat values changed at 14: [300.0, 400.0]
const ovx_string_t name = literal_to_ovx_string("heat");
ovx_token_t attribute = OVX_INVALID_TOKEN;
checkOvx(dict, path_dictionary_create_tokens_from_strings(dict, &name, 1, &attribute), "intern attribute");
ovstage_ordinal_range_t range{};
// A and B changed at 14; C last changed at 12.
range.has_start_ordinal = true;
range.start_ordinal = 14;
range.end_ordinal = 14;
ovstage_read_handle_t read = OVSTAGE_INVALID_READ_HANDLE;
waitOp(stage, ovstage_read_attributes(stage, query, &attribute, 1, range, &read), "read attributes");
std::vector<float> saved;
for (;;)
{
    ovstage_read_group_t group{};
    const auto status = ovstage_fetch_read_next(stage, read, OVSTAGE_TIMEOUT_INFINITE, &group);
    if (status == OVSTAGE_ERROR_END_OF_ITERATION)
        break;
    check(stage, status, "fetch group");
    if (group.is_delete)
    {
        // Deletions carry no value tensor; still release their group.
        check(stage, ovstage_release_group(stage, &group), "release deletion group");
        continue;
    }
    const DLTensor& tensor = group.data.tensors[0];
    const auto* values = reinterpret_cast<const float*>(
        static_cast<const uint8_t*>(tensor.data) + tensor.byte_offset);
    for (uint32_t i = 0; i < group.prims.count; ++i)
    {
        const uint32_t row = group.data.index_map ? group.data.index_map[i] : i;
        saved.push_back(values[row]); // Copy the value before releasing its group.
    }
    check(stage, ovstage_release_group(stage, &group), "release group");
}
waitOp(stage, ovstage_release_read(stage, read), "release read");
std::sort(saved.begin(), saved.end());
std::printf("Heat values changed at 14:");
for (float value : saved)
    std::printf(" %.1f", value);
std::printf("\n");
// Heat values changed at 14: 300.0 400.0

Retained Change Records#

Before relying on an old interval, call ovstage.Stage.get_oldest_preserved_ordinal(), wait for the returned ordinal query, fetch its value, and release the query. Records of which attributes changed on which prims are exact at or above that value. Older records may have been combined or discarded; do not assume a fixed retention depth.

This boundary describes change records, not stored versions of values. A value can be unavailable even when its change record is retained.

These snippets check whether an interval starting at ordinal 14 is within the retained change records. They release the ordinal query after fetching its result; the check does not guarantee that the interval’s values still exist:

# Query the boundary before relying on exact membership of an old interval.
with stage.get_oldest_preserved_ordinal() as ordinal_query:
    ordinal_query.wait()
    oldest = ordinal_query.fetch()
requested_start = 14
if requested_start < oldest:
    raise RuntimeError("Change records for this interval are no longer exact")
# A subsequent range read can still fail if a newer write replaced its values.
// Query the boundary before relying on exact membership of an old interval.
ovstage_ordinal_query_handle_t ordinalQuery{};
waitOp(stage, ovstage_get_oldest_preserved_ordinal(stage, &ordinalQuery), "query oldest ordinal");
ovstage_ordinal_t oldest = 0;
check(stage, ovstage_fetch_ordinal(stage, ordinalQuery, OVSTAGE_TIMEOUT_INFINITE, &oldest), "fetch ordinal");
waitOp(stage, ovstage_release_ordinal_query(stage, ordinalQuery), "release ordinal query");
const ovstage_ordinal_t requestedStart = 14;
if (requestedStart < oldest)
{
    std::fprintf(stderr, "Change records for this interval are no longer exact\n");
    std::exit(EXIT_FAILURE);
}
// A subsequent range read can still fail if a newer write replaced its values.

Further Reading#

See Device Placement (Experimental) for the experimental GPU-to-GPU workflow, extension access, event ordering, and buffer lifetimes.