Writing Attributes#

ovstage.Stage.write_attribute() writes attribute values for the prims selected by a query. Supply an ordinal above the attribute’s watermark. The call returns an ovstage.Operation; wait for successful completion and advance the watermark before reading.

Under STRICTLY_INCREASING, every affected (prim path, canonical attribute) cell must use an ordinal newer than its retained committed changes, as well as clearing the applicable floors. This applies across writes, map commits, clones and deletes. Disjoint cells may share an ordinal. A clone followed by customization of a copied cell therefore needs a newer ordinal for the customization, even if neither operation is sealed.

WRITE and MAP enqueue check floors without deciding retained value conflicts. Observe execution through wait_op / wait(): WRITE admits the whole batch before its first mutation; MAP checks before exposing writable groups and again when publishing them.

Writes are not transactional. Completed entries survive a later failure. Repair a batch that may have partially committed at a newer ordinal. A same-ordinal retry remains subject to current watermarks, admission policy, and stage state. See Error Handling and Diagnostics for partial changes, reserved metadata, and INSERT/UPSERT recovery.

Ordinal Admission Policies#

Process configuration selects the policy for all new instances. For a mutation at ordinal n intersecting a retained change at ordinal h:

Policy

Retained-history rejection

FLOOR_ONLY (backward compatibility)

None

NONDECREASING (default)

n < h

STRICTLY_INCREASING (experimental)

n <= h

Note

NONDECREASING remains the default. STRICTLY_INCREASING is experimental and represents the intended direction for a future default; FLOOR_ONLY is retained for backward compatibility.

All modes require the ordinal to be above the operation’s write floors and preserve change tracking, type checks and INSERT requirements. Both history policies also check applicable prim-lifecycle history and the usd-path lifecycle floor; FLOOR_ONLY skips that additional lifecycle check. Canonical aliases share history, and disjoint cells can share an ordinal. Allowing equal ordinals does not make writes transactional or add historical payload versions: a read observes the latest representable committed payload.

With FLOOR_ONLY, an older-ordinal write or delete that executes later can replace or remove newer state. For example, if a write to a cell completes at ordinal 10 and then another completes at ordinal 9 while both exceed its floors, its current payload comes from the ordinal-9 write. Reported latest ordinals may decrease. Retained change history still uses numeric ordinals, so range selection and OVSTAGE_ERROR_OUT_OF_RANGE checks need not match payload completion order. Ordinal ranges do not reconstruct historical payload snapshots. Coordinate overlapping mutations in ordinal order when that ordering is required.

Choose the policy through C process configuration or Python StageConfig before creating instances. The selection remains fixed until every initialization and instance reference has been released. For example, NONDECREASING permits customization of cloned cells at the clone’s ordinal while it remains unsealed; STRICTLY_INCREASING requires a newer ordinal.

Use placement= to select the destination device independently of source tensor residency; it applies to the entire write call. Availability events can extend the source-buffer lifetime beyond wait(). See Experimental Device Placement for this experimental extension and Placement Availability Failures for its post-commit failure exception.

Write and Advance the Watermark#

This example writes one float per prim at ordinal 1, advances the watermark, and reads the values back:

# 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

Key Parameters#

The examples below run in order with a query containing /World/Writes/A, /World/Writes/B, and /World/Writes/C, in that order. They use an existing stage and its PathDictionary as paths (dict in C). The first Python snippet includes the imports used by the later examples. The C helper waitOp() waits for completion, checks errors, and releases operation tracking; checkOvx() checks path-dictionary calls.

  • attribute — a string or token naming the attribute.

  • ordinal — must clear the applicable watermarks and satisfy the configured Ordinal Admission Policies for each affected cell and prim lifecycle; otherwise admission fails with ErrorCode.WRITE_FLOOR_VIOLATION.

  • is_array — False for one fixed-size value per prim; True for an array per prim. Shape and tensor count do not infer this choice.

  • tensors — CPU or CUDA data, supplied as NumPy arrays, DLPack producers, ovstage.DLTensor objects, or lists of tensors for array attributes. Keep the inputs alive and unchanged until the write completes, or through device availability when a placement event is supplied. See DLPack Tensor Exchange for ownership and synchronization.

These snippets write heat values of 10, 20, and 30 to A, B, and C. The source array stays alive until the write completes:

import numpy as np

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

# One scalar per prim; wait while values is still alive.
values = np.array([10, 20, 30], dtype=np.float32)
stage.write_attribute(query, "heat", ordinal=9, tensors=values, is_array=False).wait()
float values[] = { 10, 20, 30 };
int64_t shape[] = { 3 };
DLTensor tensor{};
tensor.data = values;
tensor.device = { kDLCPU, 0 };
tensor.ndim = 1;
tensor.dtype = { kDLFloat, 32, 1 };
tensor.shape = shape;
// Keep the source buffer and descriptors alive until completion.
ovstage_write_data_t write{};
write.tensors = &tensor;
write.tensor_count = 1;
write.is_array = false;

waitOp(stage, ovstage_write_attribute(stage, query, { 0, literal_to_ovx_string("heat") },
    9, write, OVSTAGE_PRIM_MODE_UPSERT), "write heat");

Fixed-Size Write Shapes#

With is_array=False, the first dimension counts source rows, normally one per selected prim. A compact (N, 3) NumPy array supplies N vectors; (N, 4, 4) supplies N matrices. With index_map, several prims can use the same source row. Without it, N must equal the number of addressed prims.

Raw tensors can instead use shape=[N] with the full component count in dtype.lanes. Writes fold trailing dimensions into storage lanes. Reads and maps return scalar-lane tensors: (N, 3) for vectors and (N, 4, 4) for square matrices with MATRIX semantic. Output dimensions follow the stored type and semantic, not the original input shape. A flat (N * L,) one-lane tensor does not mean N values of L components. Supply (N, L) or explicit lanes.

These snippets write one vector per prim from a compact (3, 3) buffer:

# Three rows, each containing one three-component vector.
velocities = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]], dtype=np.float32)
stage.write_attribute(query, "velocity", ordinal=10,
                      tensors=velocities, is_array=False).wait()
// Three rows, each containing one three-component vector.
float values[3][3] = { { 1, 0, 0 }, { 0, 1, 0 }, { 0, 0, 1 } };
int64_t shape[] = { 3, 3 };
DLTensor tensor{};
tensor.data = values;
tensor.device = { kDLCPU, 0 };
tensor.ndim = 2;
tensor.dtype = { kDLFloat, 32, 1 };
tensor.shape = shape;
// Keep the source buffer and descriptors alive until completion.
ovstage_write_data_t write{};
write.tensors = &tensor;
write.tensor_count = 1;
write.is_array = false;

waitOp(stage, ovstage_write_attribute(stage, query, { 0, literal_to_ovx_string("velocity") },
    10, write, OVSTAGE_PRIM_MODE_UPSERT), "write velocity");

Array Write Element Widths#

With is_array=True, dtype.lanes specifies the number of components in each array element. Shape does not infer that width. The element count is total_bytes / (bits * lanes / 8).

For example, an (N, 3) one-lane tensor contains 3N scalar elements, not N vectors. It fails against an existing float3[] attribute, but creates a float[] attribute if the name is new. The same rule applies to matrices: use 16 lanes for each 4×4 matrix.

Use ovstage.make_dltensor() with an explicit ovstage.DLDataType to describe a compatible compact buffer without copying it. For three-component float32 elements, use DLDataType(code=DLDataTypeCode.kDLFloat, bits=32, lanes=3). See DLPack Tensor Exchange for the conversion rules.

Supply one tensor per prim when array lengths differ. A single tensor packs all addressed arrays back to back; each must have the same byte length. The payload must divide evenly into complete elements for every source row.

These snippets write arrays of two, one, and three vectors to A, B, and C. The explicit three-lane dtype makes each array element a vector:

# Two float3 elements for A, one for B, and three for C.
points = [
    np.array([[0, 0, 0], [1, 0, 0]], dtype=np.float32),
    np.array([[0, 1, 0]], dtype=np.float32),
    np.array([[0, 0, 1], [1, 0, 1], [2, 0, 1]], dtype=np.float32),
]
tensors = [make_dltensor(row,
    dtype=DLDataType(code=DLDataTypeCode.kDLFloat, bits=32, lanes=3),
    shape=[len(row)]) for row in points]
stage.write_attribute(query, "points", ordinal=11,
                      tensors=tensors, is_array=True).wait()
// Two float3 elements for A, one for B, and three for C.
float a[] = { 0, 0, 0, 1, 0, 0 };
float b[] = { 0, 1, 0 };
float c[] = { 0, 0, 1, 1, 0, 1, 2, 0, 1 };
int64_t lengths[] = { 2, 1, 3 };
DLTensor tensors[] = {
    { a, { kDLCPU, 0 }, 1, { kDLFloat, 32, 3 }, &lengths[0], nullptr, 0 },
    { b, { kDLCPU, 0 }, 1, { kDLFloat, 32, 3 }, &lengths[1], nullptr, 0 },
    { c, { kDLCPU, 0 }, 1, { kDLFloat, 32, 3 }, &lengths[2], nullptr, 0 },
};
ovstage_write_data_t write{};
write.tensors = tensors;
write.tensor_count = 3;
write.is_array = true; // Each tensor describes one prim's array.
waitOp(stage, ovstage_write_attribute(stage, query, { 0, literal_to_ovx_string("points") },
    11, write, OVSTAGE_PRIM_MODE_UPSERT), "write points");

Targeting a Subset of the Query#

By default, the first row of the input tensor supplies the value for the first prim in the query, the second row supplies the second prim, and so on. Use count to write to only the first count prims; it cannot exceed the number of prims in the query. Use index_map to choose which input row each prim receives, or mask to skip selected prims and leave their values unchanged. A write can use either index_map or mask, but not both.

For fixed-size attributes (is_array=False), the required number of input rows depends on these options. For a query containing three prims:

Options

Required input

No count, index_map, or mask

Exactly three rows, one per prim.

count=2

Exactly two rows; only the first two prims are written.

index_map=[0, 0, 0]

One row is sufficient; all three prims receive it.

index_map=[0, 0], count=2

One row is sufficient; only the first two prims receive it.

mask=[0b010], count=3

Exactly three rows, although only the second prim is written.

With index_map, every referenced input row must exist, but extra, unreferenced rows are allowed. Python defaults count to the length of index_map; C requires an explicit count.

Source Rows: index_map#

index_map[i] selects the input row for prim i. For example, [0, 0] writes row 0 to the first two prims. The input tensor can contain just that one row; it does not need a separate row for each prim. A scalar value uses shape (1,); one three-component vector uses shape (1, 3). Unreferenced rows are unused.

For fixed-size values, each index must be less than shape[0]. For one tensor per array, it must be less than the tensor count. An invalid index fails with ErrorCode.INVALID_ARGUMENT.

A single packed array tensor has no explicit source-row count. With an index map, it is divided into max(index_map) + 1 equal rows. Without one, it is divided by the number of addressed prims. Extra packed rows beyond the highest index cannot be represented; use one tensor per row when that distinction matters.

These snippets reuse one input value for A and B, and a second value for C:

# Reuse source row 0 for A and B, and source row 1 for C.
values = np.array([100, 200], dtype=np.float32)
stage.write_attribute(query, "heat", ordinal=12, tensors=values,
                      is_array=False, index_map=[0, 0, 1], count=3).wait()
# A = 100, B = 100, C = 200.
// A and B use row 0; C uses row 1.
float values[] = { 100, 200 };
int64_t shape[] = { 2 };
DLTensor tensor{};
tensor.data = values;
tensor.device = { kDLCPU, 0 };
tensor.ndim = 1;
tensor.dtype = { kDLFloat, 32, 1 };
tensor.shape = shape;
// Keep the source buffer and descriptors alive until completion.
ovstage_write_data_t write{};
write.tensors = &tensor;
write.tensor_count = 1;
write.is_array = false;
const uint32_t indexMap[] = { 0, 0, 1 };
write.index_map = indexMap;
write.count = 3;
waitOp(stage, ovstage_write_attribute(stage, query, { 0, literal_to_ovx_string("heat") },
    12, write, OVSTAGE_PRIM_MODE_UPSERT), "write heat");
// A = 100, B = 100, C = 200.

Target Prims: mask#

Each mask bit selects a target prim. Unset bits leave those prims unchanged. The payload must still contain a row for every addressed prim, including those not selected. For example, mask=[0b10], count=2 writes only the second prim. You can also use a query that selects just that prim.

A mask is a list of 64-bit words in Python and a uint64_t array in C. Prim i uses bit i % 64 of word i // 64, starting at the least-significant bit. Supply at least (count + 63) // 64 words. Python checks this length; C callers must provide it or the runtime can read past the buffer.

These snippets update only B. The payload still contains three rows:

# Only bit 1 is set, so only B receives a value. A and C keep theirs.
values = np.array([0, 250, 0], dtype=np.float32)
stage.write_attribute(query, "heat", ordinal=13, tensors=values,
                      is_array=False, mask=[0b010], count=3).wait()
# A = 100, B = 250, C = 200.
// Only B receives a value, but the source still has a row for each prim.
float values[] = { 0, 250, 0 };
int64_t shape[] = { 3 };
DLTensor tensor{};
tensor.data = values;
tensor.device = { kDLCPU, 0 };
tensor.ndim = 1;
tensor.dtype = { kDLFloat, 32, 1 };
tensor.shape = shape;
// Keep the source buffer and descriptors alive until completion.
ovstage_write_data_t write{};
write.tensors = &tensor;
write.tensor_count = 1;
write.is_array = false;
const uint64_t mask[] = { 0b010 };
write.mask = mask;
write.count = 3;
waitOp(stage, ovstage_write_attribute(stage, query, { 0, literal_to_ovx_string("heat") },
    13, write, OVSTAGE_PRIM_MODE_UPSERT), "write heat");
// A = 100, B = 250, C = 200.

Choosing count#

  • With index_map, Python defaults to len(index_map). If supplied, count cannot exceed that length.

  • With mask, Python requires an explicit count.

  • With neither, Python defaults to the full query. An explicit count must be positive; Python rejects 0 so an empty selection cannot become a full write.

  • In C, 0 means the full query when neither option is present. Either option requires an explicit nonzero count.

These snippets use count=2 to update only A and B; C keeps its value:

# Address just the first two prims in query order: A and B.
values = np.array([300, 400], dtype=np.float32)
stage.write_attribute(query, "heat", ordinal=14, tensors=values,
                      is_array=False, count=2).wait()
# A = 300, B = 400, C = 200.
// Address only A and B, the first two prims in query order.
float values[] = { 300, 400 };
int64_t shape[] = { 2 };
DLTensor tensor{};
tensor.data = values;
tensor.device = { kDLCPU, 0 };
tensor.ndim = 1;
tensor.dtype = { kDLFloat, 32, 1 };
tensor.shape = shape;
// Keep the source buffer and descriptors alive until completion.
ovstage_write_data_t write{};
write.tensors = &tensor;
write.tensor_count = 1;
write.is_array = false;
write.count = 2;
waitOp(stage, ovstage_write_attribute(stage, query, { 0, literal_to_ovx_string("heat") },
    14, write, OVSTAGE_PRIM_MODE_UPSERT), "write heat");
// A = 300, B = 400, C = 200.

Attribute Semantics#

The semantic parameter describes what the values mean. It uses ovstage.AttributeSemantic and defaults to NONE. For example, MATRIX identifies a matrix independently of its component count.

Identifier semantics require values interned through the Path Dictionary, not text. Supply the dtype and element count listed below; mismatches fail with ErrorCode.INVALID_ARGUMENT.

Semantic

Required dtype

Notes

TOKEN_ID

uint64, 1 lane

One token id per value.

RELATIONSHIP_PATH_ID

uint64, 1 lane

Path ids: is_array=True for a target list, or False for exactly one path per prim.

CONNECTION_PATH_ID

uint64, 2 lanes

One (path id, token id) pair per target prim/property. Scalar and per-prim tensor forms are accepted, but each prim must still carry exactly one pair; extra pairs are rejected.

ASSET_PATH_ID

uint64, 2 lanes

One (authored, resolved) token-id pair. is_array=False stores one asset; True stores asset[] with one pair per element.

PATH_EXPRESSION_STRING

uint64, 1 lane

One id for the authored expression text. is_array=False is a scalar pathExpression, True is pathExpression[] with one id per element. ovstage stores the id and does not evaluate the expression.

An id of 0 means “authored, but empty” — no token, no path, no expression — so a value that is legitimately empty is written as 0 rather than omitted.

The first four semantics select specific runtime types. Without ASSET_PATH_ID, for example, a token pair is just two uint64 values. PATH_EXPRESSION_STRING labels the meaning without selecting a distinct storage type.

For transform authoring, see Transforms. For a worked local-transform write over successive ordinals, see Runtime Loop.

These snippets write token identifiers for the materials steel, rubber, and wood. TOKEN_ID makes the values tokens rather than ordinary integers:

# TOKEN_ID requires interned token identifiers stored as uint64 values.
materials = np.array([paths.intern_token(name) for name in ["steel", "rubber", "wood"]],
                     dtype=np.uint64)
stage.write_attribute(query, "material", ordinal=15, tensors=materials,
                      is_array=False, semantic=AttributeSemantic.TOKEN_ID).wait()
// TOKEN_ID requires interned token identifiers, not character strings.
const ovx_string_t names[] = { literal_to_ovx_string("steel"),
    literal_to_ovx_string("rubber"), literal_to_ovx_string("wood") };
ovx_token_t values[3]{};
checkOvx(dict, path_dictionary_create_tokens_from_strings(dict, names, 3, values), "intern materials");
int64_t shape[] = { 3 };
DLTensor tensor{};
tensor.data = values;
tensor.device = { kDLCPU, 0 };
tensor.ndim = 1;
tensor.dtype = { kDLUInt, 64, 1 };
tensor.shape = shape;
// Keep the source buffer and descriptors alive until completion.
ovstage_write_data_t write{};
write.tensors = &tensor;
write.tensor_count = 1;
write.is_array = false;
write.semantic = OVSTAGE_SEMANTIC_TOKEN_ID;
waitOp(stage, ovstage_write_attribute(stage, query, { 0, literal_to_ovx_string("material") },
    15, write, OVSTAGE_PRIM_MODE_UPSERT), "write material");

Advancing the Watermark#

Call ovstage.Stage.advance_write_floor() and wait for completion before reading the written values. The watermark never moves backwards; an advance to a lower ordinal has no effect. It can cover all attributes or a selected set. See ovstage Overview for the full write and read rules.

These snippets first advance the watermark for heat alone, then for all attributes, including the material values written above:

# Make heat writes through ordinal 14 available for reads.
heat = paths.intern_token("heat")
stage.advance_write_floor(ordinal=14, scope=ovstage.Scope.INCLUDE, attributes=[heat]).wait()
# Finish all attribute writes through ordinal 15, including material.
stage.advance_write_floor(ordinal=15).wait()
// Make heat writes through ordinal 14 available for reads.
const ovx_string_t name = literal_to_ovx_string("heat");
ovx_token_t heat = OVX_INVALID_TOKEN;
checkOvx(dict, path_dictionary_create_tokens_from_strings(dict, &name, 1, &heat), "intern heat");
ovstage_write_floor_desc_t watermark{};
watermark.ordinal = 14;
watermark.scope = OVSTAGE_SCOPE_INCLUDE;
watermark.attributes = &heat;
watermark.attribute_count = 1;
waitOp(stage, ovstage_advance_write_floor(stage, &watermark), "advance heat watermark");
// Finish all attribute writes through ordinal 15, including material.
watermark = {};
watermark.ordinal = 15;
watermark.scope = OVSTAGE_SCOPE_ALL;
waitOp(stage, ovstage_advance_write_floor(stage, &watermark), "advance all watermarks");

Passing the Attribute as String or Token#

The following snippets pass an attribute by name or by its interned token:

# Attribute arguments accept an interned token (int) or a plain str:
# a token skips the per-call dictionary lookup, a str is interned for
# you at call time.
stage.write_attribute(
    query, "temperature", ordinal=2, tensors=np.array([4.0, 5.0, 6.0], np.float32),
    is_array=False,
).wait()
stage.advance_write_floor(ordinal=2).wait()
// Attributes pass as ovx_string_or_token_t. We already hold an interned
// token, so set it (token != 0) and leave the string empty to skip a lookup.
ovx_string_or_token_t attrArg{ attr, {} };

Further Reading#

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