diff --git a/openusdconnect/adapters.py b/openusdconnect/adapters.py index b6f2aeb..ebb492c 100644 --- a/openusdconnect/adapters.py +++ b/openusdconnect/adapters.py @@ -394,6 +394,7 @@ def set_connectable_input( ``info_id`` is the UsdShade ``info:id`` (Sdr identifier) for ``UsdShade.Shader`` prims; empty string for non-shader connectables. ``time`` selects a USD time sample; ``None`` writes the static opinion. + Numeric array values use Python lists on the receive path. """ raise NotImplementedError diff --git a/openusdconnect/codec.py b/openusdconnect/codec.py index 4c33f6d..9481da7 100644 --- a/openusdconnect/codec.py +++ b/openusdconnect/codec.py @@ -1667,6 +1667,7 @@ def message_to_dict(buf: bytes | bytearray, *, numpy_arrays: bool = False) -> di With *numpy_arrays*, geometry arrays remain zero-copy views into the FlatBuffer. Otherwise they become lists for JSON-safe compatibility. + Connectable input values retain Python lists in either mode. """ envelope = decode_envelope(buf) msg_type, obj = resolve_payload(envelope) @@ -1850,7 +1851,7 @@ def decode_broadcast_event( numpy_arrays: bool = False, ) -> dict: """Convert a broadcast to the public dictionary representation.""" - record = _decode_received_event(broadcast, numpy_arrays=numpy_arrays) + record = decode_received_event(broadcast, numpy_arrays=numpy_arrays) message = {"type": MSG_EVENT, "seq": record.seq, "event": record.event} for name, value in ( ("origin", record.origin), @@ -1875,10 +1876,10 @@ class ReceivedEvent: client: str | None = None -def _decode_received_event( +def decode_received_event( broadcast: BroadcastEvent, *, - numpy_arrays: bool, + numpy_arrays: bool = False, ) -> ReceivedEvent: """Read routing directly and convert only the event needed by USD/adapters. @@ -1982,7 +1983,7 @@ def decode_messages( continue if msg_type == MSG_EVENT: - record = _decode_received_event(payload, numpy_arrays=numpy_arrays) + record = decode_received_event(payload, numpy_arrays=numpy_arrays) seq = record.seq elif msg_type == MSG_LAYER_GRAPH_STATE: state = _dict_layer_graph_state(payload, msg_type) @@ -2517,9 +2518,9 @@ def _dict_set_connectable_input(sci, kind): input_types[name] = _str(civ.TypeName()) vt = civ.ValueType() if vt == ConnectableInputValueType.FloatArray: - inputs[name] = [civ.FloatArray(j) for j in range(civ.FloatArrayLength())] + inputs[name] = civ.FloatArrayAsNumpy().tolist() if civ.FloatArrayLength() else [] elif vt == ConnectableInputValueType.IntArray: - inputs[name] = [civ.IntArray(j) for j in range(civ.IntArrayLength())] + inputs[name] = civ.IntArrayAsNumpy().tolist() if civ.IntArrayLength() else [] elif vt == ConnectableInputValueType.StringArray: inputs[name] = [_str(civ.StringArray(j)) for j in range(civ.StringArrayLength())] elif vt == ConnectableInputValueType.ScalarString: diff --git a/openusdconnect/server/_txn_barrier.py b/openusdconnect/server/_txn_barrier.py index 5f7d0f9..b1ff2c1 100644 --- a/openusdconnect/server/_txn_barrier.py +++ b/openusdconnect/server/_txn_barrier.py @@ -1,14 +1,17 @@ -"""Read-write barrier for serializing compaction against in-flight transactions.""" +"""Read-write barrier separating history maintenance from admitted transactions.""" from __future__ import annotations import threading +from contextlib import contextmanager class _TxnBarrier: - """Read-write barrier: multiple shared (txn) holders, exclusive for compaction. + """Multiple shared transaction holders, exclusive history maintenance. - Lock ordering: _TxnBarrier is acquired BEFORE stage_lock / _seq_lock. + Acquire this barrier before EventJournal.commit_scope() and SceneState.lock. + Queued requests retain shared ownership until publication and checkpoint + capture finish; maintenance must exclude both queued and active requests. """ def __init__(self): @@ -16,6 +19,24 @@ def __init__(self): self._readers = 0 self._exclusive = False + @contextmanager + def shared(self): + """Scope a synchronous operation; queued work transfers ownership explicitly.""" + self.acquire_shared() + try: + yield + finally: + self.release_shared() + + @contextmanager + def exclusive(self): + """Wait for admitted transactions and exclude new ones until exit.""" + self.acquire_exclusive() + try: + yield + finally: + self.release_exclusive() + def acquire_shared(self): with self._cond: while self._exclusive: diff --git a/openusdconnect/server/collaboration.py b/openusdconnect/server/collaboration.py new file mode 100644 index 0000000..c15cb8d --- /dev/null +++ b/openusdconnect/server/collaboration.py @@ -0,0 +1,281 @@ +"""Department assignments and replay compatibility for a managed layer stack.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable + +from pxr import Sdf + +from .scene import SceneState +from .types import ReplayModeConflictError + +LOG = logging.getLogger(__name__) +_DEFAULT_LAYER_KEY = "default" +_DEPARTMENT_LAYER_KEY_PREFIX = "department:" + + +def _layer_key_for_department(department: str | None) -> str: + if not department: + return _DEFAULT_LAYER_KEY + return f"{_DEPARTMENT_LAYER_KEY_PREFIX}{department}" + + +def department_for_layer_key(layer_key: str) -> str | None: + """Return department policy metadata for one OpenUSDConnect layer key.""" + if layer_key == _DEFAULT_LAYER_KEY: + return None + if layer_key.startswith(_DEPARTMENT_LAYER_KEY_PREFIX): + department = layer_key[len(_DEPARTMENT_LAYER_KEY_PREFIX) :] + if department: + return department + return None + + +def label_for_layer_key(layer_key: str) -> str: + department = department_for_layer_key(layer_key) + if department: + return department + if layer_key == _DEFAULT_LAYER_KEY: + return "Default" + return layer_key + + +class CollaborationPolicy: + """Own policy state; the scene owns USD layers and their composition. + + The scene lock protects assignments, replay admission, and layer lifecycle + together. Mutation methods release it before publishing changes. + """ + + def __init__( + self, scene: SceneState, *, department_priority: list[str] | None, + bump_snapshot_epoch: Callable[[str], None], + broadcast_layer_stack_state: Callable[[], None], + ): + self._scene = scene + self._client_layer_keys: dict[str, str] = {} + self.department_priority = self._validate_priority(department_priority or []) + self._flat_receiver_count = 0 + self._bump_snapshot_epoch = bump_snapshot_epoch + self._broadcast_layer_stack_state = broadcast_layer_stack_state + + @staticmethod + def _validate_priority(departments: list[str]) -> list[str]: + departments = list(departments) + if any(not department for department in departments): + raise ValueError("department names must be non-empty") + if len(set(departments)) != len(departments): + raise ValueError("department priority contains duplicates") + return departments + + def restore_assignment(self, client_id: str, layer_key: str) -> None: + """Restore routing from the durable log before workers start.""" + with self._scene.lock: + self._client_layer_keys[client_id] = layer_key + + @property + def flat_receiver_count(self) -> int: + with self._scene.lock: + return self._flat_receiver_count + + @property + def client_layers(self) -> dict[str, Sdf.Layer]: + """Return a snapshot of client assignments to shared collaboration layers.""" + with self._scene.lock: + return { + client_id: self._scene.layer_stack.layer_for(layer_key) + for client_id, layer_key in self._client_layer_keys.items() + } + + def get_or_create_client_layer( + self, + client_id: str, + department: str | None = None, + ) -> Sdf.Layer: + """Get or create a layer for this client. + + With department: clients share a department layer (last-write-wins + within the department, department priority controls strength). + Without department: uses the shared edit_layer (weakest, last-write-wins). + """ + layer_key = _layer_key_for_department(department) + added = False + with self._scene.lock: + if department: + if not self._scene.layer_stack.has_layer(layer_key): + self._reject_layer_stack_change_for_flat_receivers() + layer, added = self._scene.layer_stack.ensure_layer(layer_key, label=department) + if added: + self._apply_department_order_locked() + else: + layer = self._scene.edit_layer + self._client_layer_keys[client_id] = layer_key + if added: + self._bump_snapshot_epoch(f"create_department_layer:{department}") + self._broadcast_layer_stack_state() + LOG.info("Created shared layer for department %s", department) + return layer + + def _flat_replay_rejection_reason_locked(self) -> str: + """Return why a new flat receiver cannot mirror this server.""" + if self.department_priority: + return "department collaboration requires layered replay" + if len(self._scene.layer_stack.layer_keys) != 1: + return "multiple collaboration layers require layered replay" + layer = self._scene.layer_stack.ordered_layers[0] + if self._scene.stage.IsLayerMuted(layer.identifier): + return "muted collaboration layers require layered replay" + return "" + + def reserve_receiver_replay_mode(self, layered: bool) -> tuple[bool, str]: + """Atomically admit a receiver under the current layer-stack contract.""" + if layered: + return True, "" + with self._scene.lock: + reason = self._flat_replay_rejection_reason_locked() + if reason: + return False, reason + self._flat_receiver_count += 1 + return True, "" + + def release_receiver_replay_mode(self, layered: bool) -> None: + """Release a replay-mode reservation when a receiver disconnects.""" + if layered: + return + with self._scene.lock: + if self._flat_receiver_count <= 0: + raise RuntimeError("flat receiver reservation underflow") + self._flat_receiver_count -= 1 + + def _reject_layer_stack_change_for_flat_receivers(self) -> None: + if self._flat_receiver_count: + raise ReplayModeConflictError( + "layer-stack changes require all connected receivers to use layered replay" + ) + + def apply_department_order(self) -> bool: + """Restore configured ordering after materializing layers during startup.""" + with self._scene.lock: + return self._apply_department_order_locked() + + def _apply_department_order_locked(self) -> bool: + priority_keys = [] + for department in self.department_priority: + layer_key = _layer_key_for_department(department) + if self._scene.layer_stack.has_layer(layer_key): + priority_keys.append(layer_key) + priority_set = set(priority_keys) + unlisted_keys = [ + layer_key + for layer_key in self._scene.layer_stack.layer_keys + if layer_key != _DEFAULT_LAYER_KEY and layer_key not in priority_set + ] + return self._scene.layer_stack.set_order( + [*priority_keys, *unlisted_keys, _DEFAULT_LAYER_KEY] + ) + + def resolve_layer_key(self, key: str) -> str | None: + """Resolve a client ID, department name, or layer key.""" + with self._scene.lock: + client_key = self._client_layer_keys.get(key) + if client_key: + return client_key + department_key = _layer_key_for_department(key) + if self._scene.layer_stack.has_layer(department_key): + return department_key + if self._scene.layer_stack.has_layer(key): + return key + return None + + def resolve_layer(self, key: str) -> Sdf.Layer | None: + """Resolve a layer by client ID, department name, or layer key.""" + with self._scene.lock: + layer_key = self.resolve_layer_key(key) + return self._scene.layer_stack.layer_for(layer_key) if layer_key else None + + def department_for_layer(self, layer: Sdf.Layer) -> str | None: + """Return department policy metadata for a managed layer.""" + with self._scene.lock: + layer_key = self._scene.layer_stack.key_for_layer(layer) + return department_for_layer_key(layer_key) if layer_key is not None else None + + def release_client_assignment(self, client_id: str, *, merge_into_root: bool) -> bool: + """Release a department assignment, optionally merging its opinions first. + + Remove the layer only after its last client leaves. Default-layer + assignments are unaffected. Publish changes after releasing the scene lock. + """ + with self._scene.lock: + layer_key = self._client_layer_keys.get(client_id) + if not layer_key or layer_key == _DEFAULT_LAYER_KEY: + return False + if merge_into_root: + self._scene.merge_layer_into_root(self._scene.layer_stack.layer_for(layer_key)) + del self._client_layer_keys[client_id] + removed = layer_key not in self._client_layer_keys.values() + if removed: + self._scene.layer_stack.remove_layer(layer_key) + action = "merge_layer" if merge_into_root else "delete_layer" + self._bump_snapshot_epoch(f"{action}:{client_id}") + if removed: + self._broadcast_layer_stack_state() + LOG.info("Released department assignment for client %s (%s)", client_id, action) + return True + + def set_department_priority(self, ordered_departments: list[str]) -> None: + """Set department priority ordering (strongest first).""" + ordered_departments = self._validate_priority(ordered_departments) + with self._scene.lock: + policy_changed = ordered_departments != self.department_priority + if policy_changed: + self._reject_layer_stack_change_for_flat_receivers() + self.department_priority = ordered_departments + order_changed = self._apply_department_order_locked() + if not order_changed and not policy_changed: + return + self._bump_snapshot_epoch("set_department_priority") + self._broadcast_layer_stack_state() + + def get_layer_stack_info(self) -> list[dict]: + """Return ordered layer stack info for the dashboard. + + Department policy metadata is projected over the generic stack. + Layers without clients or authored content stay out of the dashboard. + """ + with self._scene.lock: + muted = set(self._scene.stage.GetMutedLayers()) + clients_by_key: dict[str, list[str]] = {} + for client_id, layer_key in self._client_layer_keys.items(): + clients_by_key.setdefault(layer_key, []).append(client_id) + rows = [] + for layer_key in self._scene.layer_stack.layer_keys: + layer = self._scene.layer_stack.layer_for(layer_key) + clients = clients_by_key.get(layer_key, []) + if clients or not layer.empty: + rows.append({ + "layer_key": layer_key, + "label": self._scene.layer_stack.label_for(layer_key), + "department": department_for_layer_key(layer_key), + "clients": clients, + "identifier": layer.identifier, + "muted": layer.identifier in muted, + "shared": layer_key == _DEFAULT_LAYER_KEY, + }) + return rows + + def set_muted(self, key: str, muted: bool) -> bool: + """Change muting only when the admitted receivers can represent it.""" + with self._scene.lock: + layer_key = self.resolve_layer_key(key) + if layer_key is None: + return False + layer = self._scene.layer_stack.layer_for(layer_key) + if self._scene.stage.IsLayerMuted(layer.identifier) == muted: + return True + self._reject_layer_stack_change_for_flat_receivers() + self._scene.layer_stack.set_muted(layer_key, muted) + action = "mute_layer" if muted else "unmute_layer" + self._bump_snapshot_epoch(f"{action}:{key}") + self._broadcast_layer_stack_state() + return True diff --git a/openusdconnect/server/commit.py b/openusdconnect/server/commit.py new file mode 100644 index 0000000..b94d80d --- /dev/null +++ b/openusdconnect/server/commit.py @@ -0,0 +1,394 @@ +"""Atomic USD mutation and durable transaction commits. + +The caller holds journal.commit_scope() through commit and live publication. +This component owns rollback and durable producer progress; it has no sockets, +worker threads, or reference back to the server facade. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pxr import Sdf, Usd + +from ..codec import encode_message +from ..event_store import LayerIdentity +from ..protocol_constants import ( + K_RENAME_PRIM, + K_SET_SDF_SPEC_FIELDS, + K_SET_SUBLAYERS, + MSG_LAYER_GRAPH_STATE, + NON_COLLABORATION_KINDS, + LayerMode, +) +from ..shared_layer_graph import PreparedSublayers, StaleLayerGraphError +from .journal import EncodedEvents, EncodedRecord, EventJournal +from .scene import SceneState, apply_with_deletion_fields +from .transactions import Transaction, TransactionRequest +from .types import TransactionCommit, TransactionOutcome, TransactionRejectedError + + +@dataclass(slots=True) +class PreparedTransaction: + request: Transaction + target_layer: Sdf.Layer + layer_key: str + collaboration_paths: set[str] + has_session_events: bool + + +def _managed_rollback_paths(events: list[dict]) -> set[str]: + """Return every prim path a managed transaction can structurally change.""" + paths = { + event["prim"] + for event in events + if event.get("k") not in NON_COLLABORATION_KINDS and event.get("prim") + } + for event in events: + if event["k"] != K_RENAME_PRIM: + continue + source = Sdf.Path(event["prim"]) + paths.add(str(source.GetParentPath().AppendChild(event["new_name"]))) + return paths + + +def _check_transaction_id( + request: TransactionRequest, committed_through: int, +) -> TransactionCommit | TransactionRejectedError | None: + """Return a duplicate or rejection; None admits the next producer write.""" + if request.txn_id <= committed_through: + return TransactionCommit("duplicate", committed_through) + expected = committed_through + 1 + if request.txn_id != expected: + return TransactionRejectedError( + "unexpected_id", + f"expected transaction {expected}, received {request.txn_id}", + expected_txn_id=expected, + ) + return None + + +class TransactionCommitter: + def __init__(self, scene: SceneState, journal: EventJournal): + self.scene = scene + self.journal = journal + + def commit(self, request: TransactionRequest) -> TransactionCommit: + """Commit one admitted request inside the caller's journal commit scope.""" + + outcome = _check_transaction_id( + request, self.journal.committed_through(request.client_id, request.session_id), + ) + if isinstance(outcome, TransactionRejectedError): + raise outcome + if outcome is not None: + return outcome + + records = self.commit_events(request) + self.journal.remember_progress( + {(request.client_id, request.session_id): request.txn_id}, + ) + return TransactionCommit("committed", request.txn_id, tuple(records)) + + def commit_group( + self, + requests: list[TransactionRequest], + ) -> list[TransactionOutcome]: + """Return one outcome per request, in input order, without completing them. + + The caller holds the journal commit scope through subsequent publication. + Accepted managed transactions share one persistence and rollback boundary. + """ + accepted: list[tuple[int, TransactionRequest]] = [] + outcomes: dict[int, TransactionOutcome] = {} + next_by_session: dict[tuple[str, str], int] = {} + + for index, request in enumerate(requests): + producer = (request.client_id, request.session_id) + committed_through = next_by_session.get(producer) + if committed_through is None: + committed_through = self.journal.committed_through(*producer) + outcome = _check_transaction_id(request, committed_through) + if outcome is not None: + outcomes[index] = outcome + continue + next_by_session[producer] = request.txn_id + accepted.append((index, request)) + + if not accepted: + return [outcomes[index] for index in range(len(requests))] + + with self.journal.reserve_sequences(): + prepared = [ + self._prepare_managed_transaction(request) + for _index, request in accepted + ] + encoded_transactions = self._apply_and_persist_managed(prepared) + + self.journal.remember_progress(next_by_session) + for (index, request), encoded in zip(accepted, encoded_transactions, strict=True): + outcomes[index] = TransactionCommit( + "committed", request.txn_id, tuple(encoded.records), + ) + return [outcomes[index] for index in range(len(requests))] + + def _prepare_managed_transaction( + self, request: Transaction, + ) -> PreparedTransaction: + """Resolve routing and rollback coverage without mutating the scene.""" + if request.layer_key: + raise ValueError("managed transactions cannot select an arbitrary layer key") + events = request.events + target_layer = request.layer or self.scene.edit_layer + layer_key = self.scene.layer_stack.key_for_layer(target_layer) + if layer_key is None and any(event["k"] not in NON_COLLABORATION_KINDS for event in events): + raise ValueError("transaction target is not a managed collaboration layer") + + collaboration_paths = _managed_rollback_paths(events) + has_session_events = any(event["k"] in NON_COLLABORATION_KINDS for event in events) + return PreparedTransaction( + request, + target_layer, + layer_key or "", + collaboration_paths, + has_session_events, + ) + + def _encode_managed(self, transaction: PreparedTransaction) -> EncodedEvents: + request = transaction.request + return self.journal.assign_and_encode_events( + ( + (transaction.layer_key, event) + if event["k"] not in NON_COLLABORATION_KINDS + else ("", event) + for event in request.events + ), + client_id=request.client_id, + origin=request.origin, + client_addr=request.client_addr, + ) + + def _apply_and_persist_managed( + self, prepared: list[PreparedTransaction], + ) -> list[EncodedEvents]: + """Apply and persist a group atomically within the journal commit scope. + + Snapshot only touched collaboration prims, plus session state when + needed. Internal commits also persist synchronously: rollback must + cover a storage failure even without producer progress. + """ + if len(prepared) == 1: + transaction = prepared[0] + layer_paths = ( + ((transaction.target_layer, transaction.collaboration_paths),) + if transaction.collaboration_paths + else () + ) + snapshot_session = transaction.has_session_events + progress = transaction.request.progress + producer_progress = (progress,) if progress is not None else () + else: + paths_by_layer: dict[str, tuple[Sdf.Layer, set[str]]] = {} + snapshot_session = False + progress_by_producer = {} + for transaction in prepared: + if transaction.collaboration_paths: + layer_id = transaction.target_layer.identifier + if layer_id not in paths_by_layer: + paths_by_layer[layer_id] = ( + transaction.target_layer, + set(transaction.collaboration_paths), + ) + else: + _layer, paths = paths_by_layer[layer_id] + paths.update(transaction.collaboration_paths) + snapshot_session |= transaction.has_session_events + if (progress := transaction.request.progress) is not None: + progress_by_producer[(progress.client_id, progress.session_id)] = progress + layer_paths = paths_by_layer.values() + producer_progress = tuple(progress_by_producer.values()) + + needs_scene_fields = any( + event["k"] == K_SET_SDF_SPEC_FIELDS and event["removed"] + for transaction in prepared + for event in transaction.request.events + ) + # Keep large geometry encoding outside the scene lock. If any deletion + # needs authored fields, apply and encode the entire batch in order so + # later requests cannot reserve sequences ahead of that deletion. + encoded_transactions = ( + [] if needs_scene_fields + else [self._encode_managed(transaction) for transaction in prepared] + ) + with self.scene.atomic_edit(layer_paths, include_session=snapshot_session): + for transaction in prepared: + request = transaction.request + self.scene.apply_validated( + request.events, + layer=transaction.target_layer, + update_tracking=False, + ) + if needs_scene_fields: + encoded_transactions.append(self._encode_managed(transaction)) + records = ( + encoded_transactions[0].store_rows + if len(encoded_transactions) == 1 + else [row for encoded in encoded_transactions for row in encoded.store_rows] + ) + self.journal.append_batch( + records, + producer_progress=producer_progress, + synchronous=True, + ) + for transaction in prepared: + self.scene.update_prim_tracking(transaction.request.events) + return encoded_transactions + + def commit_events( + self, request: Transaction, + ) -> list[EncodedRecord]: + """Apply validated events and persist them in the caller's commit scope. + + Each mode owns sequence rollback if preparation or persistence fails. + Managed writes and writes with producer progress persist synchronously. + Shared-stage internal writes follow the journal's durability policy. + + Returns encoded broadcast records in input order. Callers that only + need authoritative state plus a populated log may ignore the result. + + Collaboration records derive their portable layer key from the actual + edit target. Client policy selects that target before this method is + called; cached client metadata is not authoritative for persistence. + """ + if self.scene.layer_mode is LayerMode.SHARED_STAGE: + if request.layer is not None: + raise ValueError("managed layer routing is unavailable in shared-stage mode") + return self._commit_shared(request) + with self.journal.reserve_sequences(): + transaction = self._prepare_managed_transaction(request) + (encoded,) = self._apply_and_persist_managed([transaction]) + return encoded.records + + def _commit_shared( + self, request: Transaction, + ) -> list[EncodedRecord]: + """Apply one validated authored-layer transaction against the current graph.""" + from ..event_apply import atomic_apply + + layer_key = request.layer_key + graph = self.scene.shared_layer_graph + if graph is None: + raise RuntimeError("shared-stage transaction requires a layer graph") + if not layer_key: + raise ValueError("shared-stage transactions require layer_key") + target = graph.layer_for(layer_key) + if target is None or layer_key not in graph.reachable_layer_keys(): + raise TransactionRejectedError( + "stale_layer_graph", + f"unknown or unresolved shared layer key {layer_key!r}", + ) + + prepared: PreparedSublayers | None = None + canonical_events = [] + try: + for event in request.events: + if event["k"] == K_SET_SUBLAYERS: + prepared = graph.canonicalize_sublayers(layer_key, event) + canonical = prepared.event + else: + canonical = dict(event) + canonical_events.append(canonical) + except StaleLayerGraphError as exc: + raise TransactionRejectedError("stale_layer_graph", str(exc)) from exc + + routed_events = [(layer_key, event) for event in canonical_events] + try: + with ( + self.journal.reserve_sequences(), + self.scene.lock, + Usd.EditContext(self.scene.stage, Usd.EditTarget(target)), + graph.transaction(), + ): + with atomic_apply(self.scene.stage): + apply_with_deletion_fields(self.scene.stage, target, canonical_events) + if prepared is not None: + graph.accept_sublayers(prepared) + routed_events.extend(graph.discover_sublayer_states(prepared.mappings)) + records = self.persist_shared_events(routed_events, request=request) + self.scene.invalidate_prim_count() + except StaleLayerGraphError as exc: + raise TransactionRejectedError("stale_layer_graph", str(exc)) from exc + return records + + def persist_shared_events( + self, + routed_events: list[tuple[str, dict]], + *, + request: Transaction | None = None, + ) -> list[EncodedRecord]: + """Persist routed records in the caller's commit scope. + + Producer progress and graph identities require synchronous persistence; + other records follow the configured journal durability policy. + """ + encoded = self.journal.assign_and_encode_events( + routed_events, + client_id=request.client_id if request else None, + origin=request.origin if request else None, + client_addr=request.client_addr if request else None, + ) + progress = request.progress if request else None + topology_keys = set() + for event_layer_key, event in routed_events: + if event.get("k") != K_SET_SUBLAYERS: + continue + topology_keys.add(event_layer_key) + topology_keys.update( + entry["layer_key"] for entry in event.get("sublayers", ()) if entry.get("layer_key") + ) + layer_identities = ( + self._shared_layer_identity_updates(topology_keys) if topology_keys else () + ) + self.journal.append_batch( + encoded.store_rows, + producer_progress=(progress,) if progress is not None else (), + layer_identities=layer_identities, + ) + return encoded.records + + def _shared_layer_identity_updates( + self, + layer_keys: set[str] | tuple[str, ...], + ) -> tuple[LayerIdentity, ...]: + graph = self.scene.shared_layer_graph + if graph is None: + raise RuntimeError("shared layer identity requires shared-stage mode") + identities = [] + for layer_key in sorted(set(layer_keys)): + identifier = graph.identifier_for_key(layer_key) + if identifier is not None: + identities.append(LayerIdentity(identifier=identifier, layer_key=layer_key)) + return tuple(identities) + + def append_graph_baseline(self) -> None: + """Persist the initial graph baseline and its stable keys atomically.""" + graph = self.scene.shared_layer_graph + if graph is None: + raise RuntimeError("shared graph baseline requires shared-stage mode") + record = graph.state_message(seq=self.journal.assign_seq()) + record_bin = encode_message(record) + self.journal.append_batch( + [ + ( + record["seq"], + record_bin, + None, + MSG_LAYER_GRAPH_STATE, + None, + ) + ], + layer_identities=tuple( + LayerIdentity(identifier, layer_key) + for identifier, layer_key in graph.identity_records() + ), + ) diff --git a/openusdconnect/server/compaction.py b/openusdconnect/server/compaction.py index 90d1e36..efc88ae 100644 --- a/openusdconnect/server/compaction.py +++ b/openusdconnect/server/compaction.py @@ -4,9 +4,17 @@ from collections.abc import Callable, Iterable from dataclasses import dataclass, field +from heapq import merge +from itertools import chain from typing import NamedTuple -from ..codec import message_to_dict +from ..codec import ( + ReceivedEvent, + decode_envelope, + decode_received_event, + resolve_payload, +) +from ..event_store import EventStore from ..protocol_constants import ( K_DELETE_PRIM, K_ENSURE_PRIM, @@ -27,9 +35,11 @@ K_SET_VISIBILITY, K_SET_XFORM_TRS, K_UNLOAD_PAYLOAD, + MSG_EVENT, MSG_LAYER_GRAPH_STATE, NON_COLLABORATION_KINDS, STAGE_METADATA_KEYS, + event_apply_tier, ) from ..sdf_spec_delta import merge_spec_events from ..time_sample_delta import is_sample_history_barrier @@ -54,6 +64,63 @@ _TRS_ATTRIBUTES = {"t": "xformOp:translate", "r": "xformOp:orient", "s": "xformOp:scale"} _INSTANCER_ATTRIBUTES = {wire: usd for usd, wire in POINT_INSTANCER_USD_TO_WIRE.items()} +_CHILD_REPLAY_KINDS = frozenset({ + K_ENSURE_PRIM, K_ENSURE_XFORM_OPS, K_SET_XFORM_TRS, K_SET_VISIBILITY, + K_SET_MATERIAL_BINDING, K_SET_CONNECTABLE_INPUT, K_SET_CONNECTABLE_CONNECTION, + K_DELETE_PRIM, K_RENAME_PRIM, K_ERASE_TIME_SAMPLES, K_SET_SDF_SPEC_FIELDS, +}) + + +def child_replay_records(store: EventStore, prim_path: str) -> list[ReceivedEvent]: + """Reduce child opinions without merging across deleted prims or samples.""" + prefix = prim_path.rstrip("/") + "/" + + def decode(blobs: Iterable[bytes]) -> Iterable[ReceivedEvent]: + for blob in blobs: + _message_type, broadcast = resolve_payload(decode_envelope(blob)) + yield decode_received_event(broadcast, numpy_arrays=True) + + descendants = decode(store.get_by_prim_prefix(prefix, _CHILD_REPLAY_KINDS)) + # Deleting the payload root or one of its ancestors also removes earlier + # child opinions. Read these small lifecycle records without scanning the + # geometry or shader-array histories of unrelated prims. + ancestors = ( + record for record in decode( + store.get_by_prim_prefix("/", {K_DELETE_PRIM, K_RENAME_PRIM}) + ) + if prim_path == record.event["prim"] + or prim_path.startswith(record.event["prim"].rstrip("/") + "/") + ) + compaction = LogCompaction() + for record in merge(descendants, ancestors, key=lambda record: record.seq): + compaction.add_decoded_record(record) + + ordered: list[ReceivedEvent] = [] + segment: list[ReceivedEvent] = [] + + def flush_segment() -> None: + # Creates precede bindings to siblings; parents precede descendants. + ordered.extend(sorted( + segment, key=lambda record: (event_apply_tier(record.event["k"]), record.event["prim"]), + )) + segment.clear() + + for record in compaction.replay_records(): + event = record.event + if ( + event["k"] in (K_DELETE_PRIM, K_RENAME_PRIM) + or is_sample_history_barrier(event) + ): + # Erasures must stay after the values they clear and before later + # partial writes. Sorting the entire replay by tier revives samples. + flush_segment() + if event["prim"].startswith(prefix): + ordered.append(record) + elif event["prim"].startswith(prefix): + segment.append(record) + flush_segment() + return ordered + class _MergeKey(NamedTuple): prim: str @@ -64,52 +131,51 @@ class _MergeKey(NamedTuple): material_purpose: str = "" @classmethod - def from_event(cls, event: dict, metadata: dict) -> _MergeKey: + def from_event(cls, event: dict, layer_key: str) -> _MergeKey: kind = event["k"] - return cls( - prim=event.get("prim", ""), - kind=kind, - time=event.get("time"), - layer_key="" if kind in NON_COLLABORATION_KINDS else metadata.get("layer_key", ""), - spec_path=event["spec_path"] - if kind in (K_SET_SDF_SPEC_FIELDS, K_ERASE_TIME_SAMPLES) - else "", - material_purpose=(event.get("material_purpose") or "") - if kind == K_SET_MATERIAL_BINDING - else "", + prim = event.get("prim", "") + time = event.get("time") + if kind in NON_COLLABORATION_KINDS: + layer_key = "" + spec_path = ( + event["spec_path"] if kind in (K_SET_SDF_SPEC_FIELDS, K_ERASE_TIME_SAMPLES) else "" + ) + material_purpose = ( + (event.get("material_purpose") or "") if kind == K_SET_MATERIAL_BINDING else "" ) + return cls(prim, kind, time, layer_key, spec_path, material_purpose) -@dataclass(slots=True) -class CompactedEntry: +class _PreservedEvent(NamedTuple): key: _MergeKey - event: dict - metadata: dict - sequence: int + record: ReceivedEvent @dataclass class LogCompaction: """Pending entries can merge; preserved entries keep their replay position.""" - _pending: dict[_MergeKey, CompactedEntry] = field(default_factory=dict) - _preserved: list[CompactedEntry] = field(default_factory=list) + _pending: dict[_MergeKey, ReceivedEvent] = field(default_factory=dict) + _preserved: list[_PreservedEvent] = field(default_factory=list) - def add_record(self, sequence: int, record_bin: bytes) -> None: + def add_stored_record(self, sequence: int, record_bin: bytes) -> None: # Keep geometry arrays as buffer views instead of expanding them into lists. - record = message_to_dict(record_bin, numpy_arrays=True) - if record.get("type") == MSG_LAYER_GRAPH_STATE: + message_type, payload = resolve_payload(decode_envelope(record_bin)) + if message_type == MSG_LAYER_GRAPH_STATE: return - event = record.get("event", record) + if message_type != MSG_EVENT: + raise ValueError("event log contains an unsupported record") + record = decode_received_event(payload, numpy_arrays=True) + record.seq = sequence + self.add_decoded_record(record) + + def add_decoded_record(self, record: ReceivedEvent) -> None: + """Take ownership of a decoded record, retaining its routing through merges.""" + event = record.event kind = event["k"] if kind == K_SET_SUBLAYERS: return - metadata = { - name: record[name] - for name in ("origin", "client", "client_id", "layer_key") - if record.get(name) - } - key = _MergeKey.from_event(event, metadata) + key = _MergeKey.from_event(event, record.layer_key or "") self._preserve_sample_order(key, event) if _is_exact_deletion(event): @@ -117,7 +183,7 @@ def add_record(self, sequence: int, record_bin: bytes) -> None: self._discard( lambda old: old.layer_key == key.layer_key and old.spec_path == key.spec_path ) - self._preserved.append(CompactedEntry(key, event, metadata, sequence)) + self._preserved.append(_PreservedEvent(key, record)) return if kind in (K_DELETE_PRIM, K_RENAME_PRIM): self._discard( @@ -137,20 +203,20 @@ def add_record(self, sequence: int, record_bin: bytes) -> None: previous = self._pending.get(key) if previous and kind == K_ENSURE_XFORM_OPS: return - merged = _merge_payload(previous.event if previous else None, event) + record.event = _merge_payload(previous.event if previous else None, event) if previous and kind == K_ENSURE_PRIM: # Creates must still replay before events that use the prim. - sequence = previous.sequence - self._pending[key] = CompactedEntry(key, merged, metadata, sequence) + record.seq = previous.seq + self._pending[key] = record - def replay_entries(self) -> list[CompactedEntry]: + def replay_records(self) -> list[ReceivedEvent]: return sorted( - [*self._preserved, *self._pending.values()], - key=lambda entry: entry.sequence, + chain((entry.record for entry in self._preserved), self._pending.values()), + key=lambda record: record.seq, ) def _preserve(self, key: _MergeKey) -> None: - self._preserved.append(self._pending.pop(key)) + self._preserved.append(_PreservedEvent(key, self._pending.pop(key))) def _discard(self, matches: Callable[[_MergeKey], bool]) -> None: self._pending = {key: entry for key, entry in self._pending.items() if not matches(key)} @@ -236,6 +302,9 @@ def _merge_payload(previous: dict | None, event: dict) -> dict: if previous is None: return event if kind in (K_SET_XFORM_TRS, K_SET_POINT_INSTANCER): + if previous["fields"] == event["fields"]: + # Every previously authored field is replaced, so no merge is needed. + return event for name in event.get("fields", ()): previous[name] = event[name] if name not in previous["fields"]: diff --git a/openusdconnect/server/connection.py b/openusdconnect/server/connection.py index 6a29314..54c12e2 100644 --- a/openusdconnect/server/connection.py +++ b/openusdconnect/server/connection.py @@ -48,6 +48,7 @@ if TYPE_CHECKING: from .state import UsdSyncServer + from .transactions import TransactionRequest from .types import TransactionRejectedError LOG = logging.getLogger(__name__) @@ -57,8 +58,8 @@ @dataclass(slots=True) class _PendingTransactionResult: - request: object | None - result: dict | None + request: TransactionRequest | None + reply: dict | None txn_id: int event_count: int @@ -81,6 +82,21 @@ def _transaction_rejection(txn_id: int, error: TypeError | ValueError) -> dict: ) +def _resolve_transaction_reply( + sync_server: UsdSyncServer, request: TransactionRequest, +) -> dict: + """Map a durable outcome to a protocol reply, preserving unexpected errors.""" + try: + commit = sync_server.wait_for_transaction(request) + return make_transaction_result( + commit.txn_id, + status="acknowledged", + checkpoint=commit.checkpoint, + ) + except (TypeError, ValueError) as exc: + return _transaction_rejection(request.txn_id, exc) + + def _send_transaction_results( sock: socket.socket, results: list[dict], @@ -105,6 +121,11 @@ class ConnectionHandler(socketserver.StreamRequestHandler): """Handles a single client connection (emitter or receiver).""" server: ThreadedTCPServer + # Set by the validated hello before receiver admission. State consumers + # can rely on these fields and release_receiver_replay_reservation(). + _client_id: str | None + _origin: str | None + _layered_replay: bool def setup(self): super().setup() @@ -384,30 +405,30 @@ def _broadcast_playback_state(self, sync_server: UsdSyncServer): def _run_pipelined_read_loop(self, sync_server: UsdSyncServer) -> None: """Read producer transactions while an ordered worker delivers results.""" - results: queue.Queue[_PendingTransactionResult | None] = queue.Queue( + pending_replies: queue.Queue[_PendingTransactionResult | None] = queue.Queue( # One producer can fill one durable group, but cannot monopolize # the coordinator with an arbitrarily deep per-connection backlog. maxsize=max(1, sync_server.txn_batch_size), ) worker = threading.Thread( target=self._transaction_result_loop, - args=(sync_server, results), + args=(sync_server, pending_replies), name=f"ouc-results-{self._client_id}", daemon=True, ) worker.start() try: - self._read_loop(sync_server, results) + self._read_loop(sync_server, pending_replies) finally: # FIFO placement after the last submitted request makes the result # worker drain every commit/barrier even after an abrupt peer close. - results.put(None) + pending_replies.put(None) worker.join() def _read_loop( self, sync_server: UsdSyncServer, - results: queue.Queue[_PendingTransactionResult | None] | None, + pending_replies: queue.Queue[_PendingTransactionResult | None] | None, ): while True: try: @@ -470,11 +491,11 @@ def _read_loop( ) continue - if results is None: + if pending_replies is None: LOG.warning("Receiver connection attempted to submit a transaction") break - result = None + reply = None request = None try: request = sync_server.submit_idempotent_txn( @@ -488,11 +509,11 @@ def _read_loop( layer_key=txn_layer_key, ) except (TypeError, ValueError) as exc: - result = _transaction_rejection(txn_id, exc) - results.put( + reply = _transaction_rejection(txn_id, exc) + pending_replies.put( _PendingTransactionResult( request=request, - result=result, + reply=reply, txn_id=txn_id, event_count=len(events), ) @@ -506,11 +527,11 @@ def _send_control_response(self, message: dict) -> None: def _transaction_result_loop( self, sync_server: UsdSyncServer, - results: queue.Queue[_PendingTransactionResult | None], + pending_replies: queue.Queue[_PendingTransactionResult | None], ) -> None: delivery_failed = False while True: - pending = results.get() + pending = pending_replies.get() if pending is None: return outgoing: list[dict] = [] @@ -519,17 +540,10 @@ def _transaction_result_loop( max_batch = max(1, sync_server.txn_batch_size) while pending is not None and processed < max_batch: processed += 1 - result = pending.result - if result is None: + reply = pending.reply + if pending.request is not None: try: - commit = sync_server.wait_for_transaction(pending.request) - result = make_transaction_result( - commit.txn_id, - status="acknowledged", - checkpoint=commit.checkpoint, - ) - except (TypeError, ValueError) as exc: - result = _transaction_rejection(pending.txn_id, exc) + reply = _resolve_transaction_reply(sync_server, pending.request) except Exception: LOG.exception( "Transaction %s/%d failed without a protocol result", @@ -538,28 +552,29 @@ def _transaction_result_loop( ) delivery_failed = True self._shutdown_request_socket() - result = None + reply = None - if result is not None: + if reply is not None: + acknowledged = reply["status"] == "acknowledged" if ( - result.get("status") == "acknowledged" + acknowledged and outgoing - and outgoing[-1].get("status") == "acknowledged" + and outgoing[-1]["status"] == "acknowledged" ): - outgoing[-1] = result + outgoing[-1] = reply else: - outgoing.append(result) + outgoing.append(reply) with sync_server.clients_lock: info = sync_server.clients.get(self._addr_key) if info: info.last_activity = time.time() - if result.get("status") == "acknowledged": + if acknowledged: info.event_count += pending.event_count if processed >= max_batch: break try: - pending = results.get_nowait() + pending = pending_replies.get_nowait() except queue.Empty: break if pending is None: diff --git a/openusdconnect/server/inspection.py b/openusdconnect/server/inspection.py new file mode 100644 index 0000000..618b81b --- /dev/null +++ b/openusdconnect/server/inspection.py @@ -0,0 +1,196 @@ +"""Read-only USD inspection and formatting for server query APIs. + +Stage readers require the caller to hold its stage lock. Tree formatting +accepts snapshots of the incremental indexes and performs no USD reads. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Set + +from pxr import Usd, UsdGeom + +from ..emitter import read_payloads, read_references +from ..usd_state import read_material_binding, read_variant_selections + + +def _prim_xform_trs(prim) -> dict | None: + """Composed translate/orient/scale of a prim.""" + xf = UsdGeom.Xformable(prim) + if not xf: + return None + trs: dict = {} + for op in xf.GetOrderedXformOps(): + name = op.GetAttr().GetName() + value = op.Get() + if value is None: + continue + if name == "xformOp:translate": + trs["t"] = [round(float(x), 5) for x in value] + elif name == "xformOp:orient": + trs["r"] = [round(float(value.GetReal()), 5)] + [ + round(float(x), 5) for x in value.GetImaginary() + ] + elif name == "xformOp:scale": + trs["s"] = [round(float(x), 5) for x in value] + return trs or None + + +def _abbrev_scalar(value) -> str: + if value is None: + return "" + text = str(value) + return text if len(text) <= 120 else text[:117] + "…" + + +_PI_ARRAY_ATTRS = ( + "protoIndices", + "positions", + "orientations", + "orientationsf", + "scales", + "velocities", + "accelerations", + "angularVelocities", + "ids", + "invisibleIds", +) + + +def _point_instancer_summary(prim) -> dict: + """Bounded read of UsdGeomPointInstancer state for the inspector. + + Returns prototypes targets, instance count (from protoIndices length), + which arrays are animated, and the size of the inactiveIds prim + metadata when authored. + """ + instancer = UsdGeom.PointInstancer(prim) + proto_rel = instancer.GetPrototypesRel() + targets = [t.pathString for t in proto_rel.GetTargets()] if proto_rel else [] + proto_indices = instancer.GetProtoIndicesAttr() + if proto_indices and proto_indices.HasAuthoredValue(): + sample_value = proto_indices.Get() + instance_count = len(sample_value) if sample_value is not None else 0 + else: + instance_count = 0 + animated = [] + for name in _PI_ARRAY_ATTRS: + attr = prim.GetAttribute(name) + if attr and attr.IsAuthored() and attr.GetNumTimeSamples() > 0: + animated.append(name) + inactive_count = None + if prim.HasAuthoredMetadata("inactiveIds"): + list_op = prim.GetMetadata("inactiveIds") + if list_op is not None: + inactive_count = len(list_op.ApplyOperations([])) + return { + "prototypes": targets, + "instanceCount": instance_count, + "animatedArrays": animated, + "inactiveIdCount": inactive_count, + } + + +def _authored_attr_rows(prim) -> list[dict]: + """Authored attributes as ``{name, type, value, numTimeSamples}`` rows. + + Array values are reported by type only, so inspecting a heavy prim does + not copy its geometry buffers while the caller holds the stage lock. + """ + rows = [] + for attr in sorted(prim.GetAuthoredAttributes(), key=lambda a: a.GetName()): + value_type = attr.GetTypeName() + rows.append( + { + "name": attr.GetName(), + "type": str(value_type), + "value": "[array]" if value_type.isArray else _abbrev_scalar(attr.Get()), + "numTimeSamples": attr.GetNumTimeSamples(), + } + ) + return rows + + +def read_prim_types(stage: Usd.Stage) -> dict[str, str]: + """Read composed prim types for snapshot comparison.""" + return { + str(prim.GetPath()): prim.GetTypeName() + for prim in stage.Traverse() + if str(prim.GetPath()) != "/" + } + + +def build_prim_tree( + prims: Mapping[str, str], + *, + instanceable_paths: Set[str], +) -> list[dict]: + """Build tree rows from tracked paths and instancing flags without USD reads.""" + child_counts: dict[str, int] = {} + for path in prims: + parent = path.rsplit("/", 1)[0] or "/" + child_counts[parent] = child_counts.get(parent, 0) + 1 + + result = [] + for path in sorted(prims): + parent = path.rsplit("/", 1)[0] or "/" + result.append( + { + "path": path, + "typeName": prims[path], + "parent": parent, + "depth": path.count("/"), + "has_children": child_counts.get(path, 0) > 0, + "instanceable": path in instanceable_paths, + "is_point_instancer": prims[path] == "PointInstancer", + } + ) + return result + + +def read_prim_detail(stage: Usd.Stage, path: str) -> dict: + """Read composed details, omitting array values from attribute rows. + + PointInstancer counts additionally read the authored protoIndices array. + """ + prim = stage.GetPrimAtPath(path) + if not prim or not prim.IsValid(): + return {"path": path, "exists": False} + imageable = UsdGeom.Imageable(prim) + visibility = imageable.GetVisibilityAttr().Get() if imageable else None + prototype = prim.GetPrototype() if prim.IsInstance() else None + detail = { + "path": path, + "exists": True, + "typeName": str(prim.GetTypeName()), + "active": prim.IsActive(), + "visibility": str(visibility) if visibility is not None else None, + "apiSchemas": [str(s) for s in prim.GetAppliedSchemas()], + "xform": _prim_xform_trs(prim), + "references": [ + asset_path or prim_path for asset_path, prim_path in read_references(stage, path) + ], + "payloads": [ + asset_path or prim_path for asset_path, prim_path in read_payloads(stage, path) + ], + "variantSelections": dict(read_variant_selections(stage, path)), + "materialBinding": read_material_binding(stage, path) or None, + "attributes": _authored_attr_rows(prim), + "isInstanceable": prim.IsInstanceable(), + "isInstance": prim.IsInstance(), + "isInstanceProxy": prim.IsInstanceProxy(), + "prototype": (prototype.GetPath().pathString if prototype else None), + } + if prim.IsA(UsdGeom.PointInstancer): + detail["pointInstancer"] = _point_instancer_summary(prim) + return detail + + +def read_transforms(stage: Usd.Stage) -> list[dict]: + """Read composed TRS rows for Xformable prims with authored operations.""" + rows = [] + for prim in stage.Traverse(): + trs = _prim_xform_trs(prim) + if trs: + rows.append({"path": str(prim.GetPath()), **trs}) + return rows diff --git a/openusdconnect/server/journal.py b/openusdconnect/server/journal.py new file mode 100644 index 0000000..0dc289e --- /dev/null +++ b/openusdconnect/server/journal.py @@ -0,0 +1,260 @@ +"""Event sequencing, durable history, and optional asynchronous persistence. + +The commit scope serializes sequence reservations through live publication. +The smaller state lock protects counters read by observers and maintenance. +""" + +from __future__ import annotations + +import logging +import queue +import threading +from collections.abc import Callable, Iterable +from contextlib import contextmanager +from dataclasses import dataclass, field + +from ..checkpoints import TransactionCheckpoint +from ..codec import BroadcastEventEncoder, encode_message +from ..event_store import EventStore, LayerIdentity, ProducerProgress +from ..protocol_constants import MSG_EVENT +from .metrics import WireMetrics + +LOG = logging.getLogger(__name__) +type StoreRow = tuple[int, bytes, str | None, str | None, str | None] +type EncodedRecord = tuple[dict, bytes] + + +def encode_event_record( + sequence: int, event: dict, *, client: str | None = None, + client_id: str | None = None, origin: str | None = None, + layer_key: str | None = None, encoder: Callable[[dict], bytes] = encode_message, +) -> EncodedRecord: + """Encode an explicit sequence without allocating it or changing journal state.""" + record = {"type": MSG_EVENT, "seq": sequence, "event": event, + "client": client, "client_id": client_id} + if origin: + record["origin"] = origin + if layer_key: + record["layer_key"] = layer_key + return record, encoder(record) + + +@dataclass(slots=True) +class EncodedEvents: + records: list[EncodedRecord] = field(default_factory=list) + store_rows: list[StoreRow] = field(default_factory=list) + + def append(self, record: dict, record_bin: bytes) -> None: + event = record["event"] + self.records.append((record, record_bin)) + self.store_rows.append( + (record["seq"], record_bin, record.get("client_id"), event["k"], event.get("prim")) + ) + + +class EventJournal: + def __init__(self, store: EventStore, *, durability: str, metrics: WireMetrics | None): + self.store = store + self.lock = threading.Lock() + self._commit_lock = threading.RLock() + self.next_seq = store.get_max_seq() + 1 + self.event_count = store.get_count() + self.seq_at_last_compact = 1 + # Epochs invalidate identities independently of the sequence head: + # snapshots also expire on layer changes, replay only on history rewrites. + self.snapshot_epoch = 0 + self.replay_epoch = 0 + self._producer_progress: dict[tuple[str, str], int] = {} + self._encoder = BroadcastEventEncoder() + self._metrics = metrics + self._queue: queue.Queue[list[StoreRow] | None] = queue.Queue(maxsize=10_000) + self._thread = ( + threading.Thread(target=self._persist_loop, name="ouc-event-persist", daemon=True) + if durability == "realtime" + else None + ) + + @property + def running(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + def start(self) -> None: + if self._thread is not None: + self._thread.start() + + def drain(self) -> None: + self._queue.join() + + def shutdown(self) -> None: + """Drain after transaction admission and maintenance workers have stopped.""" + if self.running: + self._queue.put(None) + self._thread.join() + + @contextmanager + def commit_scope(self): + """Serialize a commit and its publication as one ordering boundary. + + Transaction callers acquire the maintenance barrier first, then this + scope, then the scene lock when accessing USD. Keep this scope open + until live records are enqueued so later sequences cannot publish first. + The journal's counter lock is held only for short state accesses and + never while acquiring the barrier, this scope, or the scene lock. + """ + with self._commit_lock: + yield + + def assign_seq(self) -> int: + with self.lock: + sequence = self.next_seq + self.next_seq += 1 + return sequence + + @contextmanager + def reserve_sequences(self): + """Restore reservations on failure inside the caller's commit scope.""" + with self.lock: + first_sequence = self.next_seq + try: + yield + except Exception: + with self.lock: + self.next_seq = first_sequence + raise + + def snapshot_token(self) -> tuple[int, int]: + with self.lock: + return self.snapshot_epoch, max(0, self.next_seq - 1) + + def bump_snapshot_epoch(self) -> int: + with self.lock: + self.snapshot_epoch += 1 + return self.snapshot_epoch + + def needs_compaction(self) -> bool: + with self.lock: + return self.next_seq > self.seq_at_last_compact + + def replay_token(self) -> tuple[int, int]: + with self.lock: + return self.replay_epoch, max(0, self.next_seq - 1) + + def durable_checkpoint(self) -> TransactionCheckpoint: + """Capture persisted history while the caller excludes maintenance.""" + # Reservations can roll back: visibility must describe persisted history. + return TransactionCheckpoint(epoch=self.replay_epoch, head_seq=self.store.get_max_seq()) + + def reset_history(self, record_count: int) -> None: + """Publish a durable replacement while the exclusive barrier is held.""" + with self.lock: + self.event_count = record_count + self.next_seq = record_count + 1 + self.seq_at_last_compact = self.next_seq + self.snapshot_epoch += 1 + self.replay_epoch += 1 + + def committed_through(self, client_id: str, session_id: str) -> int: + """Read progress safely, including outside a transaction commit scope.""" + with self._commit_lock: + producer = (client_id, session_id) + cached = self._producer_progress.get(producer) + if cached is None: + cached = self.store.get_producer_progress(client_id, session_id) + self._producer_progress[producer] = cached + return cached + + def remember_progress(self, updates: dict[tuple[str, str], int]) -> None: + """Cache persisted progress; committing callers retain their outer scope.""" + with self._commit_lock: + self._producer_progress.update(updates) + + def assign_and_encode_events( + self, + routed_events: Iterable[tuple[str, dict]], + *, + client_id: str | None, + origin: str | None, + client_addr: str | None, + ) -> EncodedEvents: + encoded = EncodedEvents() + encoder = self._encoder.encode + for layer_key, event in routed_events: + encoded.append(*self.assign_and_encode_event( + event, client_id=client_id, origin=origin, + client_addr=client_addr, layer_key=layer_key, encoder=encoder, + )) + return encoded + + def assign_and_encode_event( + self, event: dict, *, client_id: str | None = None, + origin: str | None = None, client_addr: str | None = None, + layer_key: str | None = None, + encoder: Callable[[dict], bytes] = encode_message, + ) -> EncodedRecord: + """Sequence and encode one event within the caller's commit scope.""" + record, record_bin = encode_event_record( + self.assign_seq(), event, client=client_addr, client_id=client_id, + origin=origin, layer_key=layer_key, encoder=encoder, + ) + if self._metrics is not None: + self._metrics.record(event["k"], len(record_bin)) + return record, record_bin + + def append_record(self, record: dict) -> bytes: + event = record.get("event", {}) + record_bin = encode_message(record) + self.store.append( + record["seq"], + record_bin, + kind=event.get("k"), + prim=event.get("prim"), + ) + with self.lock: + self.event_count += 1 + return record_bin + + def append_batch( + self, + rows: list[StoreRow], + *, + producer_progress: tuple[ProducerProgress, ...] = (), + layer_identities: tuple[LayerIdentity, ...] = (), + synchronous: bool = False, + ) -> None: + """Persist synchronously when requested or when durable metadata requires it. + + Other writes may be queued in realtime mode. A return from a queued + write confirms admission only; use drain() before replacing history. + """ + # Producer acknowledgements and graph identities always require a + # durable commit. Only independent records may use realtime persistence. + can_queue = self._thread is not None and not ( + synchronous or producer_progress or layer_identities + ) + if can_queue: + self._queue.put(rows) + return + if layer_identities: + self.store.append_batch( + rows, + producer_progress=producer_progress, + layer_identities=layer_identities, + ) + else: + self.store.append_batch(rows, producer_progress=producer_progress) + with self.lock: + self.event_count += len(rows) + + def _persist_loop(self) -> None: + while True: + rows = self._queue.get() + try: + if rows is None: + return + self.store.append_batch(rows) + with self.lock: + self.event_count += len(rows) + except Exception: + LOG.exception("Unexpected error in persist loop") + finally: + self._queue.task_done() diff --git a/openusdconnect/server/layer_stack.py b/openusdconnect/server/layer_stack.py index 8412bc5..c2e92b1 100644 --- a/openusdconnect/server/layer_stack.py +++ b/openusdconnect/server/layer_stack.py @@ -43,12 +43,10 @@ def __init__( default_layer.identifier: default_key, } self._labels: dict[str, str] = {default_key: default_label} - self._order: list[str] = [default_key] - self._order_view: tuple[str, ...] = (default_key,) - self._ordered_layers: tuple[Sdf.Layer, ...] = (default_layer,) + self._order: tuple[str, ...] = (default_key,) self._generation = uuid.uuid4().hex self._revision = 1 - self._install() + self._update_session_sublayers() @property def default_key(self) -> str: @@ -64,11 +62,11 @@ def revision(self) -> int: @property def layer_keys(self) -> tuple[str, ...]: - return self._order_view + return self._order @property def ordered_layers(self) -> tuple[Sdf.Layer, ...]: - return self._ordered_layers + return tuple(self._layers[key] for key in self._order) @property def managed_layers(self) -> tuple[Sdf.Layer, ...]: @@ -110,10 +108,9 @@ def ensure_layer( self._layers[layer_key] = layer self._keys_by_identifier[layer.identifier] = layer_key self._labels[layer_key] = display_label - self._order.insert(len(self._order) - 1, layer_key) - self._refresh_order_cache() + self._order = (*self._order[:-1], layer_key, self._default_key) self._revision += 1 - self._install() + self._update_session_sublayers() return layer, True def set_order(self, ordered_keys: Iterable[str]) -> bool: @@ -122,7 +119,7 @@ def set_order(self, ordered_keys: Iterable[str]) -> bool: Every managed key must appear exactly once and the default key must be last. Policy code is responsible for preserving unlisted keys. """ - order = list(ordered_keys) + order = tuple(ordered_keys) order_set = set(order) managed_keys = set(self._layers) if len(order) != len(order_set): @@ -140,9 +137,8 @@ def set_order(self, ordered_keys: Iterable[str]) -> bool: return False self._order = order - self._refresh_order_cache() self._revision += 1 - self._install() + self._update_session_sublayers() return True def set_muted(self, layer_key: str, muted: bool) -> bool: @@ -170,10 +166,9 @@ def remove_layer(self, layer_key: str) -> Sdf.Layer: del self._layers[layer_key] del self._keys_by_identifier[removed_identifier] del self._labels[layer_key] - self._order.remove(layer_key) - self._refresh_order_cache() + self._order = tuple(key for key in self._order if key != layer_key) self._revision += 1 - self._install(detach_identifiers={removed_identifier}) + self._update_session_sublayers(detach_identifiers={removed_identifier}) return layer def clear(self) -> None: @@ -198,22 +193,13 @@ def state(self) -> dict: ], } - def _managed_identifiers(self) -> set[str]: - return {layer.identifier for layer in self._layers.values()} - - def _refresh_order_cache(self) -> None: - self._order_view = tuple(self._order) - self._ordered_layers = tuple( - self._layers[layer_key] for layer_key in self._order - ) - - def _install( + def _update_session_sublayers( self, *, detach_identifiers: set[str] | None = None, ) -> None: session = self._stage.GetSessionLayer() - managed_identifiers = self._managed_identifiers() + managed_identifiers = set(self._keys_by_identifier) if detach_identifiers: managed_identifiers.update(detach_identifiers) managed_paths = [ diff --git a/openusdconnect/server/maintenance.py b/openusdconnect/server/maintenance.py new file mode 100644 index 0000000..aacbe72 --- /dev/null +++ b/openusdconnect/server/maintenance.py @@ -0,0 +1,234 @@ +"""Exclusive history replacement and background compaction scheduling. + +HistoryMaintenance owns the durable rewrite boundary. The caller retains its +exclusive window through receiver publication; networking stays in the server. +""" + +from __future__ import annotations + +import logging +import threading +import time +from collections.abc import Callable +from contextlib import contextmanager, nullcontext + +from ..codec import encode_message +from ..protocol_constants import MSG_LAYER_GRAPH_STATE, NON_COLLABORATION_KINDS +from ._txn_barrier import _TxnBarrier +from .compaction import LogCompaction +from .journal import EncodedEvents, EventJournal, encode_event_record +from .scene import SceneState +from .snapshots import PreparedSnapshot + +LOG = logging.getLogger(__name__) + + +class PeriodicCompactor: + """Schedule optional compaction without owning the scene or publication.""" + + def __init__(self, compact: Callable[[], None], journal: EventJournal, interval: float): + self._compact = compact + self._journal = journal + self._compact_interval = max(0.0, float(interval or 0)) + self._compact_stop = False + self._compact_wake = threading.Event() + self._compact_thread: threading.Thread | None = None + + @property + def running(self) -> bool: + return self._compact_thread is not None and self._compact_thread.is_alive() + + def request_stop(self) -> None: + self._compact_stop = True + self._compact_wake.set() + + def join(self) -> None: + if self._compact_thread is not None and self._compact_thread.ident is not None: + self._compact_thread.join() + + def start(self) -> None: + if self._compact_interval > 0 and self._compact_thread is None: + self._start_compaction_thread() + + def _start_compaction_thread(self): + self._compact_thread = threading.Thread( + target=self._compaction_loop, + daemon=True, + ) + self._compact_thread.start() + + def set_compact_interval(self, seconds: float) -> None: + """Set the periodic compaction interval in seconds (0 disables). + + Takes effect immediately: the compaction thread re-reads the + interval on wake, so shortening, lengthening, and disabling all + apply without waiting out the previous period. + """ + self._compact_interval = max(0.0, float(seconds or 0)) + if self._compact_interval > 0 and self._compact_thread is None: + self._start_compaction_thread() + self._compact_wake.set() + + def get_compact_interval(self) -> float: + return self._compact_interval + + def _compaction_loop(self): + """Compact every interval, skipping when no event arrived since the + last compaction so idle servers don't resync receivers for nothing.""" + while not self._compact_stop: + interval = self._compact_interval + if interval <= 0: + self._compact_wake.wait() + self._compact_wake.clear() + continue + if self._compact_wake.wait(timeout=interval): + self._compact_wake.clear() + continue + if self._compact_stop: + return + if not self._journal.needs_compaction(): + continue + try: + self._compact() + except Exception: + LOG.exception("Periodic compaction failed") + + +class HistoryMaintenance: + def __init__( + self, scene: SceneState, journal: EventJournal, barrier: _TxnBarrier, *, + drain_broadcasts: Callable[[], None], reclaim_interval: float, + ): + self._scene = scene + self._journal = journal + self._barrier = barrier + self._drain_broadcasts = drain_broadcasts + self.store = journal.store + self._reclaim_interval = max(0.0, float(reclaim_interval or 0)) + self._last_reclaim = time.monotonic() + + @contextmanager + def rewrite_window(self): + """Drain earlier transactions before rewriting or publishing a new history.""" + with self._barrier.exclusive(): + self.drain() + yield + + def drain(self) -> None: + """Finish earlier writes and publication while holding the exclusive barrier.""" + self._journal.drain() + self._drain_broadcasts() + + def _finish_rewrite(self, count: int) -> None: + """Publish the new epoch after durable history and scene caches agree.""" + self._journal.reset_history(count) + self._maybe_reclaim_storage() + + def set_reclaim_interval(self, seconds: float) -> None: + """Set the storage-reclaim interval in seconds (0 disables). + + Reclaim runs at compaction and purge commits, so an enabled + interval needs compaction (periodic or manual) to take effect. + """ + self._reclaim_interval = max(0.0, float(seconds or 0)) + + def get_reclaim_interval(self) -> float: + return self._reclaim_interval + + def _maybe_reclaim_storage(self) -> None: + """Reclaim store disk space when the interval has elapsed. + + Called right after a log rewrite while the exclusive barrier is + held; reclaiming then is cheap because only live data is copied. + """ + if self._reclaim_interval <= 0: + return + if time.monotonic() - self._last_reclaim < self._reclaim_interval: + return + try: + reclaimed = self.store.reclaim_storage() + except Exception: + LOG.exception("Failed to reclaim event-store storage") + return + self._last_reclaim = time.monotonic() + if reclaimed: + LOG.info("Reclaimed %.1f MB of event log storage", reclaimed / 1048576) + + @staticmethod + def build_compacted(rows: list[tuple[int, bytes]]) -> LogCompaction: + compaction = LogCompaction() + for seq, record_bin in rows: + compaction.add_stored_record(seq, record_bin) + return compaction + + def commit_compaction( + self, + compaction: LogCompaction, + original_count: int, + ): + """Replace compacted history; the caller holds the exclusive window.""" + compacted = compaction.replay_records() + graph = self._scene.shared_layer_graph + if graph is not None: + reachable = set(graph.reachable_layer_keys()) + compacted = [record for record in compacted if record.layer_key in reachable] + + graph_transaction = graph.transaction() if graph is not None else nullcontext() + with graph_transaction: + records = [] + first_event_seq = 1 + if graph is not None: + # Start a new replay baseline without changing layer identities. + graph.start_new_generation() + graph_record = graph.state_message(seq=1) + records.append( + (1, encode_message(graph_record), None, MSG_LAYER_GRAPH_STATE, None) + ) + first_event_seq = 2 + + encoded = EncodedEvents() + for seq, record in enumerate(compacted, start=first_event_seq): + encoded.append(*encode_event_record( + seq, record.event, origin=record.origin, client=record.client, + client_id=record.client_id, layer_key=record.layer_key, + )) + records.extend(encoded.store_rows) + self.store.clear_and_rewrite(records) + self._scene.rebuild_caches(record.event for record in compacted) + self._finish_rewrite(len(records)) + + LOG.info("Compacted event log: %d -> %d records", original_count, len(records)) + + def purge(self): + # Producer high-water marks survive a scene purge. Otherwise an old + # ambiguous retry could resurrect pre-purge edits, and still-connected + # producers would be rejected for starting above transaction 1. + self.store.clear_and_rewrite([]) + self._scene.clear_collaboration_layers() + self._finish_rewrite(0) + LOG.info("Purged event log and reset authored collaboration layers") + + def commit_snapshot( + self, prepared: PreparedSnapshot, *, client_id: str, origin: str, + ) -> EncodedEvents: + """Persist a prepared snapshot before changing the authoritative scene.""" + encoded = EncodedEvents() + for seq, event in enumerate(prepared.events, start=1): + layer_key = None if event["k"] in NON_COLLABORATION_KINDS else "default" + encoded.append(*encode_event_record( + seq, event, client_id=client_id, origin=origin, layer_key=layer_key, + )) + + # Validation and preparation can reject or identify an unchanged save + # without waiting for outgoing traffic. Drain only an actual rewrite. + self.drain() + + # This is the durability boundary. EventStore implementations must + # leave the previous log intact if the atomic replacement fails. + # Authoritative in-memory state is deliberately unchanged until it + # returns successfully. + self.store.clear_and_rewrite(encoded.store_rows) + + self._scene.install_snapshot(prepared) + self._finish_rewrite(len(encoded.records)) + return encoded diff --git a/openusdconnect/server/metrics.py b/openusdconnect/server/metrics.py new file mode 100644 index 0000000..2b18dae --- /dev/null +++ b/openusdconnect/server/metrics.py @@ -0,0 +1,50 @@ +"""Thread-safe server wire traffic counters.""" + +import threading + + +class WireMetrics: + """Thread-safe logical-record and actual transport byte counters.""" + + def __init__(self): + self._lock = threading.Lock() + self._counts: dict[str, int] = {} + self._bytes: dict[str, int] = {} + self._transport_counts: dict[str, int] = {} + self._transport_bytes: dict[str, int] = {} + + def record(self, kind: str, nbytes: int) -> None: + with self._lock: + self._counts[kind] = self._counts.get(kind, 0) + 1 + self._bytes[kind] = self._bytes.get(kind, 0) + nbytes + + def record_transport(self, channel: str, nbytes: int, *, count: int = 1) -> None: + """Record framed bytes actually read or successfully written.""" + with self._lock: + self._transport_counts[channel] = ( + self._transport_counts.get(channel, 0) + count + ) + self._transport_bytes[channel] = ( + self._transport_bytes.get(channel, 0) + nbytes + ) + + def snapshot(self) -> dict: + with self._lock: + kinds = { + k: {"count": self._counts[k], "bytes": self._bytes[k]} for k in sorted(self._counts) + } + transport = { + channel: { + "count": self._transport_counts[channel], + "bytes": self._transport_bytes[channel], + } + for channel in sorted(self._transport_counts) + } + return { + "kinds": kinds, + "total_count": sum(v["count"] for v in kinds.values()), + "total_bytes": sum(v["bytes"] for v in kinds.values()), + "transport": transport, + "transport_total_count": sum(v["count"] for v in transport.values()), + "transport_total_bytes": sum(v["bytes"] for v in transport.values()), + } diff --git a/openusdconnect/server/playback.py b/openusdconnect/server/playback.py new file mode 100644 index 0000000..ff3ebfa --- /dev/null +++ b/openusdconnect/server/playback.py @@ -0,0 +1,89 @@ +"""Playback leadership and playhead state, independent of server transport.""" + +from __future__ import annotations + +import threading + + +class PlaybackController: + """Serialize leader changes and controls under the shared playback lock.""" + + def __init__(self) -> None: + self.lock = threading.Lock() + self.state = { + "time": 0.0, + "playing": False, + "rate": 1.0, + "leader_client_id": None, + } + + def _snapshot_locked(self) -> dict: + return { + "time": self.state["time"], + "playing": self.state["playing"], + "rate": self.state["rate"], + "leader_client_id": self.state["leader_client_id"] or "", + } + + def snapshot(self) -> dict: + """Return an independent snapshot in the protocol's dictionary shape.""" + with self.lock: + return self._snapshot_locked() + + def claim(self, client_id: str, initial_time: float | None = None) -> tuple[bool, str]: + """Grant a vacant role or renew the current leader's claim. + + Setting an initial playhead and granting leadership are atomic. The + returned leader also describes a rejected claim without another read. + """ + with self.lock: + current = self.state["leader_client_id"] or "" + if not client_id: + return False, current + if current and current != client_id: + return False, current + self.state["leader_client_id"] = client_id + if initial_time is not None: + self.state["time"] = float(initial_time) + return True, client_id + + def apply_control( + self, + client_id: str, + action: str, + time_value: float = 0.0, + rate: float = 1.0, + ) -> tuple[bool, dict | str, str]: + """Apply a leader's command and return its resulting state or rejection. + + Both outcomes include the leader observed under the same lock, so the + connection can construct its response without reading mutable state. + """ + with self.lock: + leader = self.state["leader_client_id"] or "" + if leader != client_id: + return False, f"not the playback leader (current: {leader})", leader + if action == "play": + self.state["playing"] = True + elif action == "pause": + self.state["playing"] = False + elif action == "stop": + self.state["playing"] = False + self.state["time"] = 0.0 + elif action == "set_time": + self.state["time"] = float(time_value) + elif action == "set_rate": + self.state["rate"] = float(rate) + else: + return False, f"unknown playback action {action!r}", leader + return True, self._snapshot_locked(), leader + + def release(self, client_id: str) -> bool: + """Release leadership on disconnect, retaining the current playhead.""" + if not client_id: + return False + with self.lock: + if self.state["leader_client_id"] == client_id: + self.state["leader_client_id"] = None + return True + return False diff --git a/openusdconnect/server/scene.py b/openusdconnect/server/scene.py new file mode 100644 index 0000000..8305645 --- /dev/null +++ b/openusdconnect/server/scene.py @@ -0,0 +1,414 @@ +"""Live USD scene, edit targets, and caches owned by the authoritative server. + +The scene lock protects stage access and its derived indexes. Event admission, +durable persistence, and network publication belong to the caller. +""" + +from __future__ import annotations + +import threading +from bisect import bisect_left +from collections.abc import Iterable +from contextlib import ExitStack, contextmanager +from itertools import groupby +from typing import TYPE_CHECKING + +from pxr import Sdf, Usd + +from ..protocol_constants import ( + K_DEACTIVATE_PRIM, + K_DELETE_PRIM, + K_ENSURE_PRIM, + K_RENAME_PRIM, + K_SET_INSTANCEABLE, + K_SET_SDF_SPEC_FIELDS, + NON_COLLABORATION_KINDS, + STRUCTURAL_EVENT_KINDS, + LayerMode, +) +from ..shared_layer_graph import SharedLayerGraph +from . import inspection +from .layer_stack import CollaborationLayerStack + +if TYPE_CHECKING: + from .snapshots import PreparedSnapshot + + +_PRIM_COUNT_CHANGE_KINDS = STRUCTURAL_EVENT_KINDS | { + K_DELETE_PRIM, K_DEACTIVATE_PRIM, K_RENAME_PRIM, +} + + +def apply_with_deletion_fields( + stage: Usd.Stage, layer: Sdf.Layer, events: list[dict], op_cache=None, +) -> None: + """Complete deletion fields after preceding edits, then remove the spec. + + Removals are already application barriers. Split only there, preserving + dependency ordering and the original event list for ordinary transactions. + """ + from ..event_apply import apply_events + + if op_cache is None: + op_cache = {} + start = 0 + for index, event in enumerate(events): + if event["k"] != K_SET_SDF_SPEC_FIELDS or not event["removed"]: + continue + if index > start: + apply_events(stage, events[start:index], op_cache=op_cache, prevalidated=True) + spec = layer.GetObjectAtPath(Sdf.Path(event["spec_path"])) + if spec: + event["fields"] = sorted( + set(event["fields"]) | {str(key) for key in spec.ListInfoKeys()} + ) + apply_events(stage, [event], op_cache=op_cache, prevalidated=True) + start = index + 1 + if start < len(events): + apply_events( + stage, events if start == 0 else events[start:], + op_cache=op_cache, prevalidated=True, + ) + + +class SceneState: + def __init__(self, stage: Usd.Stage, *, layer_mode: LayerMode, op_cache_size: int): + # Client-only packages import server types without installing its cache dependency. + from cachetools import LRUCache + + self.stage = stage + self.lock = threading.RLock() + self.layer_mode = layer_mode + self.edit_layer = self._create_edit_layer() + self.layer_stack = CollaborationLayerStack(stage, self.edit_layer) + self.shared_layer_graph: SharedLayerGraph | None = None + self.op_cache = LRUCache(maxsize=op_cache_size) + self._op_cache_layer: str | None = None + self._prim_paths: dict[str, str] = {} + self._instanceable_paths: set[str] = set() + self._namespace_paths: tuple[str, ...] | None = None + self._prim_count = 0 + self._prim_count_dirty = True + self._prim_count_layer_revision = -1 + + def clear_op_cache(self) -> None: + """Discard cached attribute handles while excluding scene mutations.""" + with self.lock: + self.op_cache.clear() + self._op_cache_layer = None + + def invalidate_prim_count(self) -> None: + with self.lock: + self._prim_count_dirty = True + + def _create_edit_layer(self) -> Sdf.Layer: + """Create an override sublayer on the session layer and set it as the edit target. + + The session layer is stronger than the entire root layer stack, so + opinions authored here always compose on top of the base file and + its sublayers. The override is inserted as a sublayer of the session + layer (rather than using the session layer directly) so that + collaboration can add shared department layers alongside it. + """ + layer = Sdf.Layer.CreateAnonymous("server-edits") + session = self.stage.GetSessionLayer() + session.subLayerPaths.insert(0, layer.identifier) + self.stage.SetEditTarget(Usd.EditTarget(layer)) + return layer + + def _op_cache_for(self, layer: Sdf.Layer): + """Reuse op setup only for consecutive edits to the same layer. + + XformOp.Set uses the stage's current edit target; the op is not bound + to a layer. This cache also skips setup of that layer's xformOpOrder, + which must run again after switching layers. + + The caller holds the scene lock through use of the returned cache. + """ + if self._op_cache_layer != layer.identifier: + self.op_cache.clear() + self._op_cache_layer = layer.identifier + return self.op_cache + + def _tracked_subtree(self, prim: str) -> Iterable[str]: + """Find tracked descendants without scanning the whole tree per deletion.""" + if self._namespace_paths is None: + self._namespace_paths = tuple(sorted( + self._prim_paths.keys() | self._instanceable_paths, + )) + paths = self._namespace_paths + yield prim + prefix = prim + "/" + index = bisect_left(paths, prefix) + while index < len(paths) and paths[index].startswith(prefix): + yield paths[index] + index += 1 + + def _track_prim_event(self, ev: dict): + """Update incremental prim trackers from a single event. + + This is the specialized-event tree, not a traversal of base or referenced + content. The dashboard can refresh it without querying pxr. + The caller holds the scene lock. + """ + k = ev.get("k") + prim = ev.get("prim", "") + if k == K_ENSURE_PRIM: + self._prim_paths[prim] = ev["typeName"] + self._namespace_paths = None + elif k in (K_DELETE_PRIM, K_RENAME_PRIM): + new_root = ( + f"{prim.rsplit('/', 1)[0]}/{ev['new_name']}" if k == K_RENAME_PRIM else None + ) + for path in self._tracked_subtree(prim): + type_name = self._prim_paths.pop(path, None) + was_instanceable = path in self._instanceable_paths + self._instanceable_paths.discard(path) + if new_root is not None: + if type_name is not None: + self._prim_paths[new_root + path[len(prim):]] = type_name + if was_instanceable: + self._instanceable_paths.add(new_root + path[len(prim):]) + if new_root is not None: + self._prim_paths.setdefault(new_root, "Xform") + self._namespace_paths = None + # Deleted entries can stay in the index until the next insertion; + # subsequent deletes skip them without rebuilding a large tree. + elif k == K_SET_INSTANCEABLE: + if ev.get("instanceable", True): + self._instanceable_paths.add(prim) + self._namespace_paths = None + else: + self._instanceable_paths.discard(prim) + + def update_prim_tracking(self, events: list[dict]) -> None: + """Publish dashboard indexes for successfully applied events.""" + with self.lock: + for event in events: + kind = event.get("k") + if kind in _PRIM_COUNT_CHANGE_KINDS: + self._prim_count_dirty = True + if kind in (K_ENSURE_PRIM, K_DELETE_PRIM, K_RENAME_PRIM, K_SET_INSTANCEABLE): + self._track_prim_event(event) + + def rebuild_caches(self, events: Iterable[dict] = ()) -> None: + """Discard cached USD handles and rebuild indexes from replacement history.""" + with self.lock: + self.clear_op_cache() + self._prim_paths.clear() + self._instanceable_paths.clear() + self._namespace_paths = None + for event in events: + self._track_prim_event(event) + self._prim_count_dirty = True + + @contextmanager + def atomic_edit( + self, + layer_paths: Iterable[tuple[Sdf.Layer, set[str]]], + *, + include_session: bool = False, + ): + """Roll back touched opinions if mutation or persistence fails. + + Apply with update_tracking=False until persistence succeeds, then + publish derived indexes before leaving this scope. Only USD opinions + roll back. The scene restores the edit target and invalidates cached + USD handles after rollback. + """ + from ..event_apply import atomic_apply_layer + + with self.lock: + original_target = self.stage.GetEditTarget() + try: + with ExitStack() as rollback: + for layer, paths in layer_paths: + rollback.enter_context(atomic_apply_layer(layer, paths)) + if include_session: + rollback.enter_context(atomic_apply_layer(self.stage.GetSessionLayer())) + yield + except Exception: + self.clear_op_cache() + raise + finally: + self.stage.SetEditTarget(original_target) + + def replay_events(self, routed_events: Iterable[tuple[Sdf.Layer, dict]]) -> None: + """Restore persisted opinions in order, batching adjacent layer targets.""" + from ..event_apply import apply_events + + with self.lock: + try: + for layer, run in groupby(routed_events, key=lambda item: item[0]): + events = [event for _layer, event in run] + with Usd.EditContext(self.stage, Usd.EditTarget(layer)): + apply_events(self.stage, events, op_cache=self._op_cache_for(layer)) + self.update_prim_tracking(events) + except Exception: + self.clear_op_cache() + raise + finally: + self._prim_count_dirty = True + + def snapshot_layer(self, layer: Sdf.Layer) -> Sdf.Layer: + """Copy authored content under the scene lock for serialization outside it.""" + with self.lock: + snapshot = Sdf.Layer.CreateAnonymous("server-export.usda") + snapshot.TransferContent(layer) + return snapshot + + def clear_collaboration_layers(self) -> None: + with self.lock: + self.layer_stack.clear() + self.rebuild_caches() + + def merge_layer_into_root(self, layer: Sdf.Layer) -> None: + """Copy leaf opinions while preserving existing root siblings. + + Hold the scene lock for both source traversal and destination writes; + reading an Sdf layer also needs exclusion from concurrent mutation. + """ + with self.lock: + pending = list(reversed(list(layer.rootPrims))) + root = self.stage.GetRootLayer() + while pending: + spec = pending.pop() + if spec.nameChildren: + pending.extend(reversed(list(spec.nameChildren))) + continue + path = spec.path + parent = path.GetParentPath() + if parent != Sdf.Path.absoluteRootPath and not root.GetPrimAtPath(parent): + Sdf.CreatePrimInLayer(root, parent) + if root.GetPrimAtPath(path): + for prop in spec.properties: + Sdf.CopySpec(layer, prop.path, root, prop.path) + else: + Sdf.CopySpec(layer, path, root, path) + self._prim_count_dirty = True + + def install_snapshot(self, prepared: PreparedSnapshot) -> None: + """Install a prepared snapshot after its history has been persisted.""" + from ..event_apply import apply_events + + with self.lock: + self.edit_layer.TransferContent(prepared.edit_layer) + edit_target = Usd.EditTarget(self.edit_layer) + self.stage.SetEditTarget(edit_target) + if prepared.session_events: + self.stage.SetEditTarget(Usd.EditTarget(self.stage.GetSessionLayer())) + try: + apply_events(self.stage, prepared.session_events, prevalidated=True) + finally: + self.stage.SetEditTarget(edit_target) + self.rebuild_caches(prepared.events) + + def apply_validated( + self, + events: list[dict], + layer: Sdf.Layer | None = None, + *, + update_tracking: bool = True, + ) -> None: + """Complete deletion fields and apply admitted events under the scene lock. + + Spec deletions acquire field names from the current scene, including + preceding edits in this request. Encode those events only after this method returns. + """ + target = layer or self.edit_layer + + with self.lock: + edit_target = Usd.EditTarget(target) + target_was_muted = self.stage.IsLayerMuted(target.identifier) + needs_unmute = target_was_muted and any( + ev["k"] not in NON_COLLABORATION_KINDS for ev in events + ) + post_apply_target = ( + Usd.EditTarget(self.stage.GetSessionLayer()) if target_was_muted else edit_target + ) + if needs_unmute: + self.stage.UnmuteLayer(target.identifier) + try: + if any(event["k"] in NON_COLLABORATION_KINDS for event in events): + self._apply_routed_runs(events, target, edit_target) + else: + # Most transactions author one collaboration layer. Avoid + # session targets and copied runs when no routing is needed. + self.stage.SetEditTarget(edit_target) + apply_with_deletion_fields( + self.stage, target, events, op_cache=self._op_cache_for(target), + ) + finally: + self.stage.SetEditTarget(post_apply_target) + if needs_unmute: + self.stage.MuteLayer(target.identifier) + + if update_tracking: + self.update_prim_tracking(events) + + def _apply_routed_runs( + self, events: list[dict], target: Sdf.Layer, edit_target: Usd.EditTarget, + ) -> None: + """Route consecutive runs without reordering session and layer opinions.""" + session_layer = self.stage.GetSessionLayer() + session_target = Usd.EditTarget(session_layer) + for session_events, run in groupby( + events, key=lambda event: event["k"] in NON_COLLABORATION_KINDS, + ): + run_layer = session_layer if session_events else target + self.stage.SetEditTarget(session_target if session_events else edit_target) + apply_with_deletion_fields( + self.stage, run_layer, list(run), op_cache=self._op_cache_for(run_layer), + ) + + def get_prim_count(self) -> int: + """Return the number of prims on the composed stage (thread-safe, cached).""" + with self.lock: + if ( + self._prim_count_dirty + or self._prim_count_layer_revision != self.layer_stack.revision + ): + self._prim_count = sum(1 for _ in self.stage.Traverse()) + self._prim_count_dirty = False + self._prim_count_layer_revision = self.layer_stack.revision + return self._prim_count + + def get_tracked_prim_count(self) -> int: + """Return the number of prims tracked (incremental, no log scan).""" + with self.lock: + return len(self._prim_paths) + + def get_prim_tree(self) -> list[dict]: + """Build inspector tree rows from snapshots of the incremental indexes.""" + with self.lock: + prims = dict(self._prim_paths) + instanceable_paths = set(self._instanceable_paths) + return inspection.build_prim_tree( + prims, + instanceable_paths=instanceable_paths, + ) + + def get_instance_count(self) -> int: + """Count of prims with authored ``instanceable=true``. + + Whether each actually composes into an instance depends on a + composition arc; this is the upper bound and matches what the + tree's badge shows. + """ + with self.lock: + return len(self._instanceable_paths) + + def get_prototype_count(self) -> int: + """Number of implicit prototype prims the stage composed.""" + with self.lock: + return len(self.stage.GetPrototypes()) + + def get_prim_detail(self, path: str) -> dict: + """Return composed prim details while holding the stage lock.""" + with self.lock: + return inspection.read_prim_detail(self.stage, path) + + def get_transforms_snapshot(self) -> list[dict]: + """Return composed TRS rows while holding the stage lock.""" + with self.lock: + return inspection.read_transforms(self.stage) diff --git a/openusdconnect/server/snapshots.py b/openusdconnect/server/snapshots.py new file mode 100644 index 0000000..2cea825 --- /dev/null +++ b/openusdconnect/server/snapshots.py @@ -0,0 +1,280 @@ +"""Validate and prepare full-file VFS saves without changing authoritative state. + +The caller captures SnapshotState under the stage lock and holds the exclusive +transaction barrier until the replacement has been committed. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import NoReturn + +from pxr import Sdf, Usd + +from ..emitter import NoticeEmitter +from ..protocol_constants import K_DEACTIVATE_PRIM, K_SET_SDF_SPEC_FIELDS, NON_COLLABORATION_KINDS +from . import inspection +from .collaboration import department_for_layer_key +from .types import ( + AmbiguousVfsWriteError, + InvalidVfsWriteError, + StaleVfsWriteError, + UnsupportedVfsWriteError, + VfsWriteAnalysis, + VfsWriteRejectedError, +) + + +@dataclass(frozen=True, slots=True) +class SnapshotMetadata: + scene_id: str + epoch: int + seq: int + + +@dataclass(frozen=True, slots=True) +class SnapshotState: + scene_id: str + epoch: int + seq: int + prim_types: dict[str, str] + non_default_layers: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class PreparedSnapshot: + events: list[dict] + edit_layer: Sdf.Layer + session_events: list[dict] + + +def read_snapshot_metadata(stage: Usd.Stage) -> SnapshotMetadata | None: + """Read and validate the optional identity carried by a VFS snapshot.""" + layer_data = stage.GetRootLayer().customLayerData or {} + if "openusdconnect" not in layer_data: + return None + metadata = layer_data["openusdconnect"] + if not isinstance(metadata, dict): + raise InvalidVfsWriteError("uploaded openusdconnect metadata must be a dictionary") + scene_id = metadata.get("scene_id") + if not isinstance(scene_id, str) or not scene_id: + raise InvalidVfsWriteError( + "uploaded openusdconnect metadata field 'scene_id' must be a non-empty string" + ) + return SnapshotMetadata( + scene_id=scene_id, + epoch=_metadata_int(metadata, "epoch"), + seq=_metadata_int(metadata, "snapshot_seq"), + ) + + +def _metadata_int(metadata: dict, key: str) -> int: + value = metadata.get(key) + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise InvalidVfsWriteError( + f"uploaded openusdconnect metadata field {key!r} must be a non-negative integer" + ) + return value + + +def validate_stage_snapshot( + uploaded_stage: Usd.Stage, + uploaded_meta: SnapshotMetadata | None, + current: SnapshotState, + *, + reject_stale: bool, + reject_ambiguous: bool, +) -> VfsWriteAnalysis: + """Analyze a full-file save; rejection errors carry the analysis for diagnostics.""" + uploaded_scene_id = uploaded_meta.scene_id if uploaded_meta else None + uploaded_epoch = uploaded_meta.epoch if uploaded_meta else None + uploaded_seq = uploaded_meta.seq if uploaded_meta else None + current_epoch, current_seq = current.epoch, current.seq + before_types = current.prim_types + uploaded_types = inspection.read_prim_types(uploaded_stage) + before_paths = set(before_types) + uploaded_paths = set(uploaded_types) + created_paths = sorted(uploaded_paths - before_paths) + removed_paths = sorted( + before_paths - uploaded_paths, + key=lambda p: p.count("/"), + reverse=True, + ) + type_changed_paths = sorted( + p for p in before_paths & uploaded_paths if before_types[p] != uploaded_types[p] + ) + + analysis = VfsWriteAnalysis( + status="translated", + current_epoch=current_epoch, + current_seq=current_seq, + uploaded_epoch=uploaded_epoch, + uploaded_seq=uploaded_seq, + before_prim_count=len(before_paths), + uploaded_prim_count=len(uploaded_paths), + created_prims=created_paths, + removed_prims=removed_paths, + type_changed_prims=type_changed_paths, + ) + + def reject(status: str, note: str, error: VfsWriteRejectedError) -> NoReturn: + error.analysis = replace(analysis, status=status, notes=[note]) + raise error + + if current.non_default_layers: + department_layers = [] + additional_layers = [] + for layer_key in current.non_default_layers: + department = department_for_layer_key(layer_key) + if department: + department_layers.append(department) + else: + additional_layers.append(layer_key) + details = [] + if department_layers: + details.append(f"department layers: {', '.join(department_layers)}") + if additional_layers: + details.append(f"collaboration layers: {', '.join(additional_layers)}") + reject( + "unsupported_rejected", + "translate write fallback is disabled while non-default " + f"collaboration layers are active ({'; '.join(details)})", + UnsupportedVfsWriteError( + "VFS translate writes are disabled while non-default " + "collaboration layers are active" + ), + ) + + if uploaded_meta is None: + reject( + "metadata_rejected", + "uploaded snapshot is missing openusdconnect metadata", + InvalidVfsWriteError("uploaded VFS snapshot is missing openusdconnect metadata"), + ) + + if uploaded_scene_id != current.scene_id: + reject( + "metadata_rejected", + "uploaded snapshot belongs to a different live scene", + InvalidVfsWriteError( + "uploaded VFS snapshot scene_id does not match this server: " + f"file={uploaded_scene_id!r}, server={current.scene_id!r}" + ), + ) + + uploaded_token = (uploaded_epoch, uploaded_seq) + current_token = (current_epoch, current_seq) + if uploaded_token != current_token: + if uploaded_token < current_token and not reject_stale: + analysis = replace( + analysis, + notes=[ + "stale snapshot token accepted because stale-write rejection was disabled" + ], + ) + else: + relation = "older" if uploaded_token < current_token else "newer" + reject( + "stale_rejected" if relation == "older" else "future_rejected", + f"uploaded snapshot is {relation} than the current live server state", + StaleVfsWriteError( + "uploaded VFS snapshot token does not match the current server: " + f"file epoch/seq={uploaded_epoch}/{uploaded_seq}, " + f"server epoch/seq={current_epoch}/{current_seq}" + ), + ) + + if uploaded_stage.GetEditTarget().GetLayer().subLayerPaths: + reject( + "unsupported_rejected", + "uploaded snapshot contains sublayer topology, which cannot " + "be mapped into the managed collaboration layer stack", + UnsupportedVfsWriteError( + "uploaded VFS snapshot contains unsupported sublayer topology" + ), + ) + + if reject_ambiguous and _looks_destructively_incomplete(analysis): + reject( + "ambiguous_rejected", + "uploaded snapshot removes a root-level prim or most of the scene; " + "refusing automatic fallback translation", + AmbiguousVfsWriteError( + "uploaded VFS snapshot looks destructively incomplete; " + f"removed {len(removed_paths)} of {len(before_paths)} prims" + ), + ) + + return analysis + + +def _looks_destructively_incomplete(analysis: VfsWriteAnalysis) -> bool: + removed_fraction = len(analysis.removed_prims) / max(1, analysis.before_prim_count) + removes_rootish_prim = any( + path.count("/") <= 1 and path != "/Root" for path in analysis.removed_prims + ) + return bool(analysis.removed_prims) and ( + analysis.uploaded_prim_count == 0 + or removes_rootish_prim + or (analysis.before_prim_count >= 10 and removed_fraction >= 0.8) + ) + + +def create_replacement_stage(stage: Usd.Stage) -> Usd.Stage: + """Open an isolated edit/session stack; the caller must hold the stage lock.""" + edit_layer = Sdf.Layer.CreateAnonymous("vfs-replacement-edits") + session_layer = Sdf.Layer.CreateAnonymous("vfs-replacement-session") + session_layer.subLayerPaths = [edit_layer.identifier] + replacement = Usd.Stage.Open( + stage.GetRootLayer(), session_layer, stage.GetPathResolverContext(), + ) + if replacement is None: + raise RuntimeError("failed to create the VFS replacement stage") + replacement.SetEditTarget(Usd.EditTarget(edit_layer)) + return replacement + + +def prepare_stage_snapshot( + uploaded_stage: Usd.Stage, + removed_paths: list[str], + *, + replacement_stage: Usd.Stage, +) -> PreparedSnapshot: + """Translate and validate a replacement without changing live USD state.""" + emitter = NoticeEmitter(uploaded_stage) + try: + events = emitter.snapshot_events() + finally: + emitter.cleanup() + + # Hide prims that existed in the previous composed stage but are absent + # from the uploaded snapshot. This lets full-file saves express deletes + # even when the original prim lives in the immutable base layer. + for prim_path in removed_paths: + events.append({"k": K_DEACTIVATE_PRIM, "prim": prim_path, "active": False}) + + # Build the complete replacement off-stage first. This validates + # every generated event and gives us an authored layer that can be + # installed without incrementally mutating authoritative state. + from ..event_apply import apply_events + from ..sdf_spec_delta import validate_spec_delta + + replacement_events = [] + session_events = [] + for event in events: + kind = event["k"] + if kind == K_SET_SDF_SPEC_FIELDS: + validate_spec_delta(event) + if kind in NON_COLLABORATION_KINDS: + session_events.append(event) + else: + replacement_events.append(event) + + replacement_layer = replacement_stage.GetEditTarget().GetLayer() + if replacement_events: + apply_events(replacement_stage, replacement_events, prevalidated=True) + if session_events: + replacement_stage.SetEditTarget(Usd.EditTarget(replacement_stage.GetSessionLayer())) + apply_events(replacement_stage, session_events, prevalidated=True) + + return PreparedSnapshot(events, replacement_layer, session_events) diff --git a/openusdconnect/server/state.py b/openusdconnect/server/state.py index 58a4283..047b5cf 100644 --- a/openusdconnect/server/state.py +++ b/openusdconnect/server/state.py @@ -1,8 +1,10 @@ """UsdSyncServer: authoritative state for the sync protocol. -Holds the in-memory ``Usd.Stage``, the collaboration layer stack, the SQLite -event log, the broadcast and persistence threads, and the TOFU token store. -Network handling lives in ``connection.py``; the CLI lives in ``cli.py``. +Coordinates the scene, event journal, durable commits, maintenance, and receiver publication. +USD mutation and caches live in ``scene.py``; history lives in ``journal.py``; +atomic transaction application lives in ``commit.py``. Collaboration policy +and history replacement live in ``collaboration.py`` and ``maintenance.py``. +Network request handling lives in ``connection.py``; the CLI lives in ``cli.py``. """ from __future__ import annotations @@ -14,39 +16,29 @@ import threading import time import uuid -from collections.abc import Iterable -from contextlib import ExitStack, contextmanager, nullcontext -from dataclasses import dataclass, field, replace - -from pxr import Ar, Sdf, Usd, UsdGeom - -from ..checkpoints import TransactionCheckpoint -from ..codec import BroadcastEventEncoder, encode_message, message_to_dict -from ..emitter import ( - NoticeEmitter, - read_payloads, - read_references, - read_stage_metadata, +from collections import Counter +from collections.abc import Iterable, Sequence +from contextlib import ExitStack, contextmanager +from dataclasses import dataclass, replace +from typing import TYPE_CHECKING + +from pxr import Ar, Sdf, Usd + +from ..codec import ( + decode_envelope, + decode_received_event, + encode_message, + message_to_dict, + resolve_payload, ) +from ..emitter import read_stage_metadata from ..event_store import EventStore, LayerIdentity, ProducerProgress, SqliteEventStore from ..framing import frame_batch from ..protocol_constants import ( - K_DEACTIVATE_PRIM, - K_DELETE_PRIM, - K_ENSURE_PRIM, - K_ENSURE_XFORM_OPS, K_ERASE_TIME_SAMPLES, - K_LOAD_PAYLOAD, - K_RENAME_PRIM, K_REPLACE_SDF_LAYER_CONTENT, - K_SET_CONNECTABLE_CONNECTION, - K_SET_CONNECTABLE_INPUT, - K_SET_INSTANCEABLE, - K_SET_MATERIAL_BINDING, K_SET_SDF_SPEC_FIELDS, K_SET_SUBLAYERS, - K_SET_VISIBILITY, - K_SET_XFORM_TRS, MSG_EVENT, MSG_LAYER_GRAPH_STATE, MSG_LAYER_STACK_STATE, @@ -56,33 +48,46 @@ MSG_RESYNC, NON_COLLABORATION_KINDS, LayerMode, - event_apply_tier, ) from ..protocol_validation import validate_events -from ..shared_layer_graph import PreparedSublayers, SharedLayerGraph, StaleLayerGraphError -from ..usd_state import read_material_binding, read_variant_selections +from ..shared_layer_graph import SharedLayerGraph +from . import inspection from ._txn_barrier import _TxnBarrier -from .compaction import LogCompaction -from .layer_stack import CollaborationLayerStack +from .collaboration import ( + CollaborationPolicy, + label_for_layer_key, +) +from .commit import TransactionCommitter +from .compaction import child_replay_records +from .journal import EncodedEvents, EncodedRecord, EventJournal +from .maintenance import HistoryMaintenance, PeriodicCompactor +from .metrics import WireMetrics +from .playback import PlaybackController from .rate_limit import validate_rate_limit_config +from .scene import SceneState +from .snapshots import ( + SnapshotState, + create_replacement_stage, + prepare_stage_snapshot, + read_snapshot_metadata, + validate_stage_snapshot, +) +from .transactions import Transaction, TransactionCoordinator, TransactionRequest from .types import ( - AmbiguousVfsWriteError, ClientInfo, - InvalidVfsWriteError, - ReplayModeConflictError, - StaleVfsWriteError, TransactionCommit, + TransactionOutcome, TransactionRejectedError, - UnsupportedVfsWriteError, - VfsWriteAnalysis, + VfsWriteRejectedError, ) +if TYPE_CHECKING: + from .connection import ConnectionHandler + LOG = logging.getLogger(__name__) -# Bounded queue limits provides natural backpressure when receivers or -# persistence can't keep up, preventing unbounded memory growth. +# A bounded queue applies backpressure when receiver delivery can't keep up. _BROADCAST_QUEUE_MAX = 10_000 -_PERSIST_QUEUE_MAX = 10_000 _PING_INTERVAL = 30.0 # seconds between heartbeat pings during idle _AUDIENCE_ALL = "all" @@ -90,7 +95,6 @@ _AUDIENCE_LAYERED = "layered" _AUDIENCES = frozenset({_AUDIENCE_ALL, _AUDIENCE_FLAT, _AUDIENCE_LAYERED}) _DEFAULT_LAYER_KEY = "default" -_DEPARTMENT_LAYER_KEY_PREFIX = "department:" @dataclass(frozen=True, slots=True) @@ -102,257 +106,14 @@ class _ReceiverReplay: resync_reason: str | None -@dataclass(slots=True) -class _TransactionRequest: - events: list[dict] - session_id: str - txn_id: int - client_id: str - origin: str | None - client_addr: str | None - layer: Sdf.Layer | None - layer_key: str - done: threading.Event = field(default_factory=threading.Event) - commit: TransactionCommit | None = None - error: BaseException | None = None - - -@dataclass(slots=True) -class _PreparedTransaction: - events: list[dict] - target_layer: Sdf.Layer - collaboration_paths: set[str] - has_session_events: bool - records: list[tuple[dict, bytes]] - persist_tuples: list[tuple[int, bytes, str | None, str | None, str | None]] - progress: ProducerProgress | None - - -def _managed_rollback_paths(events: list[dict]) -> set[str]: - """Return every prim path a managed transaction can structurally change.""" - paths = { - event["prim"] - for event in events - if event.get("k") not in NON_COLLABORATION_KINDS and event.get("prim") - } - for event in events: - if event.get("k") != K_RENAME_PRIM or not event.get("prim"): - continue - new_name = event.get("new_name") - if new_name and Sdf.Path.IsValidIdentifier(new_name): - source = Sdf.Path(event["prim"]) - paths.add(str(source.GetParentPath().AppendChild(new_name))) - return paths - - -def _layer_key_for_department(department: str | None) -> str: - if not department: - return _DEFAULT_LAYER_KEY - return f"{_DEPARTMENT_LAYER_KEY_PREFIX}{department}" - - -def _department_for_layer_key(layer_key: str) -> str | None: - """Return department policy metadata for one OpenUSDConnect layer key.""" - if layer_key == _DEFAULT_LAYER_KEY: - return None - if layer_key.startswith(_DEPARTMENT_LAYER_KEY_PREFIX): - department = layer_key[len(_DEPARTMENT_LAYER_KEY_PREFIX) :] - if department: - return department - return None - - -def _label_for_layer_key(layer_key: str) -> str: - department = _department_for_layer_key(layer_key) - if department: - return department - if layer_key == _DEFAULT_LAYER_KEY: - return "Default" - return layer_key - - -def _prim_xform_trs(prim) -> dict | None: - """Composed translate/orient/scale of a prim.""" - xf = UsdGeom.Xformable(prim) - if not xf: - return None - trs: dict = {} - for op in xf.GetOrderedXformOps(): - name = op.GetAttr().GetName() - val = op.Get() - if val is None: - continue - if name == "xformOp:translate": - trs["t"] = [round(float(x), 5) for x in val] - elif name == "xformOp:orient": - trs["r"] = [round(float(val.GetReal()), 5)] + [ - round(float(x), 5) for x in val.GetImaginary() - ] - elif name == "xformOp:scale": - trs["s"] = [round(float(x), 5) for x in val] - return trs or None - - -def _abbrev_scalar(value) -> str: - if value is None: - return "" - s = str(value) - return s if len(s) <= 120 else s[:117] + "…" - - -_PI_ARRAY_ATTRS = ( - "protoIndices", - "positions", - "orientations", - "orientationsf", - "scales", - "velocities", - "accelerations", - "angularVelocities", - "ids", - "invisibleIds", -) - - -def _point_instancer_summary(prim) -> dict: - """Bounded read of UsdGeomPointInstancer state for the inspector. +class UsdSyncServer: + """Coordinate scene edits, durable history, and receiver publication. - Returns prototypes targets, instance count (from protoIndices length), - which arrays are animated, and the size of the inactiveIds prim - metadata when authored. + Producer requests pass through admission, commit, publication, then a durable + checkpoint and acknowledgement. Receiver delivery runs asynchronously. + ``apply_txn`` only mutates USD; ``_commit_events`` also persists internal + edits; producer entry points additionally deduplicate and publish them. """ - pi = UsdGeom.PointInstancer(prim) - proto_rel = pi.GetPrototypesRel() - targets = [t.pathString for t in proto_rel.GetTargets()] if proto_rel else [] - proto_indices = pi.GetProtoIndicesAttr() - if proto_indices and proto_indices.HasAuthoredValue(): - sample_value = proto_indices.Get() - instance_count = len(sample_value) if sample_value is not None else 0 - else: - instance_count = 0 - animated = [] - for name in _PI_ARRAY_ATTRS: - attr = prim.GetAttribute(name) - if attr and attr.IsAuthored() and attr.GetNumTimeSamples() > 0: - animated.append(name) - inactive_count = None - if prim.HasAuthoredMetadata("inactiveIds"): - list_op = prim.GetMetadata("inactiveIds") - if list_op is not None: - inactive_count = len(list_op.ApplyOperations([])) - return { - "prototypes": targets, - "instanceCount": instance_count, - "animatedArrays": animated, - "inactiveIdCount": inactive_count, - } - - -def _authored_attr_rows(prim) -> list[dict]: - """Authored attributes as ``{name, type, value, numTimeSamples}`` rows. - - Array-typed values are reported by type only never materialized so - inspecting a heavy prim (e.g. a million-point mesh) copies no buffers - while stage_lock is held. - """ - rows = [] - for attr in sorted(prim.GetAuthoredAttributes(), key=lambda a: a.GetName()): - tn = attr.GetTypeName() - rows.append( - { - "name": attr.GetName(), - "type": str(tn), - "value": "[array]" if tn.isArray else _abbrev_scalar(attr.Get()), - "numTimeSamples": attr.GetNumTimeSamples(), - } - ) - return rows - - -def _stage_prim_types(stage: Usd.Stage) -> dict[str, str]: - return { - str(prim.GetPath()): prim.GetTypeName() - for prim in stage.Traverse() - if str(prim.GetPath()) != "/" - } - - -def _stage_live_metadata(stage: Usd.Stage) -> dict | None: - """Read and validate the optional identity carried by a VFS snapshot.""" - layer_data = stage.GetRootLayer().customLayerData or {} - if "openusdconnect" not in layer_data: - return None - metadata = layer_data["openusdconnect"] - if not isinstance(metadata, dict): - raise InvalidVfsWriteError("uploaded openusdconnect metadata must be a dictionary") - scene_id = metadata.get("scene_id") - if not isinstance(scene_id, str) or not scene_id: - raise InvalidVfsWriteError( - "uploaded openusdconnect metadata field 'scene_id' must be a non-empty string" - ) - _metadata_int(metadata, "epoch") - _metadata_int(metadata, "snapshot_seq") - return metadata - - -def _metadata_int(metadata: dict, key: str) -> int: - value = metadata.get(key) - if isinstance(value, bool) or not isinstance(value, int) or value < 0: - raise InvalidVfsWriteError( - f"uploaded openusdconnect metadata field {key!r} must be a non-negative integer" - ) - return value - - -class _WireMetrics: - """Thread-safe logical-record and actual transport byte counters.""" - - def __init__(self): - self._lock = threading.Lock() - self._counts: dict[str, int] = {} - self._bytes: dict[str, int] = {} - self._transport_counts: dict[str, int] = {} - self._transport_bytes: dict[str, int] = {} - - def record(self, kind: str, nbytes: int) -> None: - with self._lock: - self._counts[kind] = self._counts.get(kind, 0) + 1 - self._bytes[kind] = self._bytes.get(kind, 0) + nbytes - - def record_transport(self, channel: str, nbytes: int, *, count: int = 1) -> None: - """Record framed bytes actually read or successfully written.""" - with self._lock: - self._transport_counts[channel] = ( - self._transport_counts.get(channel, 0) + count - ) - self._transport_bytes[channel] = ( - self._transport_bytes.get(channel, 0) + nbytes - ) - - def snapshot(self) -> dict: - with self._lock: - kinds = { - k: {"count": self._counts[k], "bytes": self._bytes[k]} for k in sorted(self._counts) - } - transport = { - channel: { - "count": self._transport_counts[channel], - "bytes": self._transport_bytes[channel], - } - for channel in sorted(self._transport_counts) - } - return { - "kinds": kinds, - "total_count": sum(v["count"] for v in kinds.values()), - "total_bytes": sum(v["bytes"] for v in kinds.values()), - "transport": transport, - "transport_total_count": sum(v["count"] for v in transport.values()), - "transport_total_bytes": sum(v["bytes"] for v in transport.values()), - } - - -class UsdSyncServer: - """Holds all shared server state: stage, sequence counter, client list, event store.""" DEFAULT_OP_CACHE_SIZE = 4096 @@ -379,288 +140,154 @@ def __init__( ): self._shutdown_lock = threading.Lock() self._shutdown_complete = False - self._transaction_queue = None - self._transaction_thread = None - self._compact_thread = None - self._compact_wake = threading.Event() - self._persist_queue = None - self._persist_thread = None + self._transactions: TransactionCoordinator | None = None + self._compactor: PeriodicCompactor | None = None + self._journal: EventJournal | None = None self._broadcast_thread = None self._owns_store = event_store is None try: - self._initialize( - base_usd_path=base_usd_path, log_path=log_path, event_store=event_store, - op_cache_size=op_cache_size, department_priority=department_priority, - require_token=require_token, token_db_path=token_db_path, - durability=durability, txn_rate=txn_rate, txn_burst=txn_burst, - txn_batch_size=txn_batch_size, txn_batch_delay=txn_batch_delay, - compact_interval=compact_interval, reclaim_interval=reclaim_interval, - wire_metrics=wire_metrics, stage=stage, resolver_context=resolver_context, - layer_mode=layer_mode, - ) - except BaseException: - try: - self.shutdown() - finally: - if self._owns_store and hasattr(self, "store"): - self.store.close() - raise + if stage is not None and base_usd_path: + raise ValueError("stage and base_usd_path are mutually exclusive") + if stage is not None and resolver_context is not None: + raise ValueError("a supplied stage already owns its resolver context") + validate_rate_limit_config(txn_rate, txn_burst) + + self.layer_mode = LayerMode(layer_mode) + if self.layer_mode is LayerMode.SHARED_STAGE and department_priority: + raise ValueError("department policy is not available in shared-stage mode") + + if stage is not None: + self.stage = stage + elif base_usd_path: + self.stage = ( + Usd.Stage.Open(base_usd_path, resolver_context) + if resolver_context is not None + else Usd.Stage.Open(base_usd_path) + ) + if self.stage is None: + raise RuntimeError(f"Failed to open base USD: {base_usd_path}") + else: + self.stage = ( + Usd.Stage.CreateInMemory("openusdconnect-server.usda", resolver_context) + if resolver_context is not None + else Usd.Stage.CreateInMemory() + ) + self.stage.DefinePrim("/Root", "Xform") + if self.layer_mode is LayerMode.SHARED_STAGE and Sdf.Layer.IsAnonymousLayerIdentifier( + self.stage.GetRootLayer().identifier + ): + raise ValueError("shared-stage mode requires a portable root layer") - def _initialize( - self, - base_usd_path: str | None = None, - log_path: str = "usd_events.db", - event_store: EventStore | None = None, - op_cache_size: int | None = None, - department_priority: list[str] | None = None, - require_token: bool = False, - token_db_path: str | None = None, - durability: str = "strict", - txn_rate: float = 0, - txn_burst: int = 0, - txn_batch_size: int = 256, - txn_batch_delay: float = 0.0005, - wire_metrics: bool = False, - compact_interval: float = 0, - reclaim_interval: float = 0, - stage: Usd.Stage | None = None, - resolver_context: Ar.ResolverContext | None = None, - layer_mode: LayerMode | str = LayerMode.MANAGED, - ): - if stage is not None and base_usd_path: - raise ValueError("stage and base_usd_path are mutually exclusive") - if stage is not None and resolver_context is not None: - raise ValueError("a supplied stage already owns its resolver context") - validate_rate_limit_config(txn_rate, txn_burst) - - self.layer_mode = LayerMode(layer_mode) - if self.layer_mode is LayerMode.SHARED_STAGE and department_priority: - raise ValueError("department policy is not available in shared-stage mode") - - if stage is not None: - self.stage = stage - elif base_usd_path: - self.stage = ( - Usd.Stage.Open(base_usd_path, resolver_context) - if resolver_context is not None - else Usd.Stage.Open(base_usd_path) + self._scene = SceneState( + self.stage, layer_mode=self.layer_mode, + op_cache_size=op_cache_size or self.DEFAULT_OP_CACHE_SIZE, ) - if self.stage is None: - raise RuntimeError(f"Failed to open base USD: {base_usd_path}") - else: - self.stage = ( - Usd.Stage.CreateInMemory("openusdconnect-server.usda", resolver_context) - if resolver_context is not None - else Usd.Stage.CreateInMemory() + self.stage_lock = self._scene.lock + self.edit_layer = self._scene.edit_layer + self.layer_stack = self._scene.layer_stack + self.op_cache = self._scene.op_cache + + self._collaboration = CollaborationPolicy( + self._scene, department_priority=department_priority, + bump_snapshot_epoch=self.bump_snapshot_epoch, + broadcast_layer_stack_state=self.broadcast_layer_stack_state, ) - self.stage.DefinePrim("/Root", "Xform") - if self.layer_mode is LayerMode.SHARED_STAGE and Sdf.Layer.IsAnonymousLayerIdentifier( - self.stage.GetRootLayer().identifier - ): - raise ValueError("shared-stage mode requires a portable root layer") - - self.stage_lock = threading.RLock() - - # Non-destructive editing: fallback layer for clients without an ID. - self.edit_layer = self._create_edit_layer() - - # Department assignment is collaboration policy. The layer stack itself - # is keyed generically so replay does not depend on department concepts. - self._client_layer_keys: dict[str, str] = {} - self.department_priority: list[str] = list(department_priority or []) - if any(not department for department in self.department_priority): - raise ValueError("department names must be non-empty") - if len(set(self.department_priority)) != len(self.department_priority): - raise ValueError("department priority contains duplicates") - - self.layer_stack = CollaborationLayerStack( - self.stage, - self.edit_layer, - default_key=_DEFAULT_LAYER_KEY, - ) - - # Flat replay cannot represent layer order or muting. Reservations and - # stack-policy changes share this lock so neither can invalidate the - # other's contract during a handshake. - self._replay_mode_lock = threading.Lock() - self._flat_receiver_count = 0 - - self.clients_lock = threading.Lock() - self.receivers: set = set() - self.clients: dict[str, ClientInfo] = {} - self._event_listeners: list = [] - self._start_time = time.time() - self._seq_lock = threading.Lock() - self.txn_barrier = _TxnBarrier() - # Identity lookup, USD mutation, sequence assignment, and producer - # progress form one failure boundary across connection threads. The - # same lock also covers live enqueue: a later durable sequence must - # never become visible before an earlier one. - self._transaction_commit_lock = threading.RLock() - self.txn_batch_size = max(1, int(txn_batch_size)) - self.txn_batch_delay = max(0.0, float(txn_batch_delay)) - self._transaction_queue: queue.Queue[_TransactionRequest | None] | None = None - self._transaction_thread: threading.Thread | None = None - self._transaction_stopping = False - - # Pluggable event store defaults to SQLite - self.store: EventStore = event_store or SqliteEventStore(log_path) - # Lazy durable highwater cache. Commit serialization makes this an - # exact mirror of producer_sessions after the first lookup; reconnect - # handshakes and hot transaction batches avoid one SQLite query per - # producer per group while restart still recovers from the store. - self._producer_progress_cache: dict[tuple[str, str], int] = {} - self._next_seq = self.store.get_max_seq() + 1 - self._event_count = self.store.get_count() - # Periodic compaction skips when no seq was assigned since the last - # compaction. Starts at 1 so a pre-existing uncompacted log gets one - # compaction on the first tick. - self._seq_at_last_compact = 1 - # Virtual-file snapshots are keyed by an epoch plus the latest assigned - # sequence. Epoch disambiguates compaction/purge resetting sequence IDs. - self._snapshot_epoch = 0 - self._replay_epoch = 0 - self.server_instance = uuid.uuid4().hex - self.scene_id = self._make_scene_id(base_usd_path) - self.last_vfs_write_analysis: dict | None = None - - # TOFU token authentication - self.require_token = require_token - self.token_store = None - if require_token: - from ..token_store import TokenStore - - _token_path = token_db_path or log_path.replace(".db", "_tokens.db") - self.token_store = TokenStore(_token_path) - - # Cached prim count invalidated on structural events to avoid - # full Traverse on every dashboard poll. - self._prim_count: int = 0 - self._prim_count_dirty: bool = True - - # Playback synchronization a single leader drives the shared - # playhead. apply_playback_control rejects writes from non-leaders. - self.playback_lock = threading.Lock() - self.playback: dict = { - "time": 0.0, - "playing": False, - "rate": 1.0, - "leader_client_id": None, - } - - # Async broadcast emitter threads push to the queue, a dedicated - # thread handles the actual network sends so emitters are never - # blocked by slow receivers. - # Each queued item retains the receiver membership captured when the - # broadcast was authored. A receiver joining later obtains older - # records only through its bounded replay window. - # Item: (payload_bytes, receiver_handlers, exclude_origin, audience) - self._broadcast_queue: queue.Queue = queue.Queue(maxsize=_BROADCAST_QUEUE_MAX) - self._broadcast_thread = threading.Thread( - target=self._broadcast_loop, - daemon=True, - ) - # Durability mode for writes without producer progress. Idempotent - # producer transactions always persist their event and cumulative - # high-water mark atomically before acknowledgement and publication. - # "strict" persists every write before broadcast - # "realtime" allows eligible server-internal writes to persist async - self.durability = durability - # Per-client rate limiting (0 = disabled) - self.txn_rate = txn_rate - self.txn_burst = txn_burst - - # Opt-in wire traffic diagnostics (--wire-metrics) - self.wire_metrics: _WireMetrics | None = _WireMetrics() if wire_metrics else None - - # Periodic compaction (--compact-interval; 0 = disabled). Runtime - # adjustable via set_compact_interval. compact_log itself is safe - # against concurrent txns: its exclusive phase holds txn_barrier, so - # incoming txns queue and proceed against the compacted log. - self._compact_interval = max(0.0, float(compact_interval or 0)) - self._compact_stop = False - self._compact_wake = threading.Event() - self._compact_thread: threading.Thread | None = None - - # Storage reclaim (--reclaim-interval; 0 = disabled). Evaluated at - # compaction and purge commits, where the log was just rewritten and - # the exclusive barrier is already held; no thread of its own. - self._reclaim_interval = max(0.0, float(reclaim_interval or 0)) - self._last_reclaim = time.monotonic() - self._persist_queue: queue.Queue | None = None - if durability == "realtime": - self._persist_queue = queue.Queue(maxsize=_PERSIST_QUEUE_MAX) - self._persist_thread = threading.Thread( - target=self._persist_loop, + self.clients_lock = threading.Lock() + self.receivers: set[ConnectionHandler] = set() + self.clients: dict[str, ClientInfo] = {} + self._event_listeners: list = [] + self._start_time = time.time() + self.txn_barrier = _TxnBarrier() + self.txn_batch_size = max(1, int(txn_batch_size)) + self.txn_batch_delay = max(0.0, float(txn_batch_delay)) + + # Pluggable event store defaults to SQLite + self.store: EventStore = event_store or SqliteEventStore(log_path) + self.wire_metrics = WireMetrics() if wire_metrics else None + self._journal = EventJournal( + self.store, durability=durability, metrics=self.wire_metrics, + ) + self._committer = TransactionCommitter(self._scene, self._journal) + self.server_instance = uuid.uuid4().hex + self.scene_id = self._make_scene_id(base_usd_path) + self.last_vfs_write_analysis: dict | None = None + + # TOFU token authentication + self.require_token = require_token + self.token_store = None + if require_token: + from ..token_store import TokenStore + + _token_path = token_db_path or log_path.replace(".db", "_tokens.db") + self.token_store = TokenStore(_token_path) + + self._playback = PlaybackController() + + # A dedicated thread sends queued broadcasts. Slow receivers apply + # backpressure to publishers when the bounded queue fills. + # Each queued item retains the receiver membership captured when the + # broadcast was authored. A receiver joining later obtains older + # records only through its bounded replay window. + # Item: (payload_bytes, receiver_handlers); routing is settled at enqueue. + self._broadcast_queue: queue.Queue = queue.Queue(maxsize=_BROADCAST_QUEUE_MAX) + self._broadcast_thread = threading.Thread( + target=self._broadcast_loop, daemon=True, ) - # prim_path → (translate_op, orient_op, scale_op). Cache hits skip op - # setup, including authoring xformOpOrder in the current layer. Clear - # on layer changes so each department gets its own transform opinions. - from cachetools import LRUCache - - self.op_cache: LRUCache = LRUCache( - maxsize=op_cache_size or self.DEFAULT_OP_CACHE_SIZE, - ) - self._op_cache_layer: str | None = None - self._broadcast_encoder = BroadcastEventEncoder() - - # Incremental prim tracking avoids full log scans on dashboard polls. - self._prim_paths: dict[str, str] = {} # prim_path → typeName - # Per-prim flags maintained incrementally from events so dashboard - # tree refreshes never have to query pxr per prim. - self._instanceable_paths: set[str] = set() - self._point_instancer_paths: set[str] = set() - - self.shared_layer_graph: SharedLayerGraph | None = None - if self.layer_mode is LayerMode.SHARED_STAGE: - existing_graph_log = self.store.get_count() > 0 - self.shared_layer_graph = SharedLayerGraph( - self.stage, - authoritative=not existing_graph_log, + # Durability mode for writes without producer progress. Idempotent + # producer transactions always persist their event and cumulative + # high-water mark atomically before acknowledgement and publication. + # "strict" persists every write before broadcast + # "realtime" allows eligible server-internal writes to persist async + self.durability = durability + # Per-client rate limiting (0 = disabled) + self.txn_rate = txn_rate + self.txn_burst = txn_burst + + self._maintenance = HistoryMaintenance( + self._scene, self._journal, self.txn_barrier, + drain_broadcasts=self._broadcast_queue.join, + reclaim_interval=reclaim_interval, ) - if not existing_graph_log: - self._append_shared_graph_baseline() - - # Rebuild stage from the event log so the composed stage matches - # what receivers would get on replay. - self._replay_log_into_stage() - if self.shared_layer_graph is not None: - if existing_graph_log: - identities = self.store.get_layer_identities() - if not identities: - raise ValueError( - "shared-stage database has no durable layer identity registry; " - "recreate databases written before this protocol version" - ) - self.shared_layer_graph.restore_identity_records( - (identity.identifier, identity.layer_key) - for identity in identities - ) - self.shared_layer_graph.authoritative = True - self.refresh_shared_layer_dependencies() - - if ( - self.txn_batch_size > 1 - and self.layer_mode is LayerMode.MANAGED - ): - self._transaction_queue = queue.Queue(maxsize=_PERSIST_QUEUE_MAX) - self._transaction_thread = threading.Thread( - target=self._transaction_batch_loop, - name="ouc-transaction-commit", - daemon=True, + self._compactor = PeriodicCompactor( + lambda: self.compact_log(), self._journal, compact_interval, + ) + self._restore_history() + + self._transactions = TransactionCoordinator( + barrier=self.txn_barrier, + commit=self._commit_and_publish, + commit_group=( + self._commit_and_publish_batch + if self.layer_mode is LayerMode.MANAGED else None + ), + checkpoint=self._journal.durable_checkpoint, + batch_size=self.txn_batch_size, + batch_delay=self.txn_batch_delay, ) - # No worker sees partially initialized state or replay in progress. - self._broadcast_thread.start() - if self._persist_thread is not None: - self._persist_thread.start() - if self._compact_interval > 0: - self._start_compaction_thread() - if self._transaction_thread is not None: - self._transaction_thread.start() + # No worker sees partially initialized state or replay in progress. + self._broadcast_thread.start() + self._journal.start() + self._compactor.start() + self._transactions.start() + except BaseException: + try: + self.shutdown() + finally: + if self._owns_store and hasattr(self, "store"): + self.store.close() + raise + + @property + def shared_layer_graph(self) -> SharedLayerGraph | None: + return self._scene.shared_layer_graph + + @shared_layer_graph.setter + def shared_layer_graph(self, graph: SharedLayerGraph | None) -> None: + self._scene.shared_layer_graph = graph @staticmethod def _make_scene_id(base_usd_path: str | None) -> str: @@ -674,284 +301,33 @@ def _make_scene_id(base_usd_path: str | None) -> str: digest = hashlib.sha1(digest_src.encode("utf-8")).hexdigest()[:12] return f"{label}-{digest}" - def shutdown(self): - """Idempotently drain workers, including after incomplete initialization. - - Successful construction leaves store ownership with the caller, as before. - Failed construction closes only the store created by this state object. - """ - with self._shutdown_lock: - if self._shutdown_complete: - return - self._shutdown_complete = True - self._transaction_stopping = True - self._compact_stop = True - self._compact_wake.set() - for prefix in ("transaction", "compact", "persist", "broadcast"): - thread = getattr(self, f"_{prefix}_thread") - if thread is None or thread.ident is None: - continue - queue = getattr(self, f"_{prefix}_queue", None) - if queue is not None: - queue.put(None) - thread.join() - - # ------------------------------------------------------------------ - # Periodic compaction - # ------------------------------------------------------------------ - - def _start_compaction_thread(self): - self._compact_thread = threading.Thread( - target=self._compaction_loop, - daemon=True, - ) - self._compact_thread.start() - - def set_reclaim_interval(self, seconds: float) -> None: - """Set the storage-reclaim interval in seconds (0 disables). - - Reclaim runs at compaction and purge commits, so an enabled - interval needs compaction (periodic or manual) to take effect. - """ - self._reclaim_interval = max(0.0, float(seconds or 0)) - - def get_reclaim_interval(self) -> float: - return self._reclaim_interval - - def _maybe_reclaim_storage(self) -> None: - """Reclaim store disk space when the interval has elapsed. + def _restore_history(self) -> None: + """Restore the scene and durable layer identities before starting workers.""" + existing_graph_log = False + if self.layer_mode is LayerMode.SHARED_STAGE: + existing_graph_log = self.store.get_count() > 0 + self._scene.shared_layer_graph = SharedLayerGraph( + self.stage, authoritative=not existing_graph_log, + ) + if not existing_graph_log: + self._committer.append_graph_baseline() - Called right after a log rewrite while the exclusive barrier is - held; reclaiming then is cheap because only live data is copied. - """ - if self._reclaim_interval <= 0: - return - if time.monotonic() - self._last_reclaim < self._reclaim_interval: - return - try: - reclaimed = self.store.reclaim_storage() - except Exception: - LOG.exception("Failed to reclaim event-store storage") + self._replay_log_into_stage() + graph = self.shared_layer_graph + if graph is None: return - self._last_reclaim = time.monotonic() - if reclaimed: - LOG.info("Reclaimed %.1f MB of event log storage", reclaimed / 1048576) - - def set_compact_interval(self, seconds: float) -> None: - """Set the periodic compaction interval in seconds (0 disables). - - Takes effect immediately: the compaction thread re-reads the - interval on wake, so shortening, lengthening, and disabling all - apply without waiting out the previous period. - """ - self._compact_interval = max(0.0, float(seconds or 0)) - if self._compact_interval > 0 and self._compact_thread is None: - self._start_compaction_thread() - self._compact_wake.set() - - def get_compact_interval(self) -> float: - return self._compact_interval - - def _compaction_loop(self): - """Compact every interval, skipping when no event arrived since the - last compaction so idle servers don't resync receivers for nothing.""" - while not self._compact_stop: - interval = self._compact_interval - if interval <= 0: - self._compact_wake.wait() - self._compact_wake.clear() - continue - if self._compact_wake.wait(timeout=interval): - self._compact_wake.clear() - continue - if self._compact_stop: - return - with self._seq_lock: - pending = self._next_seq > self._seq_at_last_compact - if not pending: - continue - try: - self.compact_log() - except Exception: - LOG.exception("Periodic compaction failed") - - # ------------------------------------------------------------------ - # Playback synchronization - # ------------------------------------------------------------------ - - def get_stage_metadata_payload(self) -> dict: - """Return the stage's authored metadata snapshot for hello_ok.""" - return read_stage_metadata(self.stage) - - def get_playback_state(self) -> dict: - """Return a wire-shaped snapshot of the current playback state.""" - with self.playback_lock: - return { - "time": self.playback["time"], - "playing": self.playback["playing"], - "rate": self.playback["rate"], - "leader_client_id": self.playback["leader_client_id"] or "", - } - - def claim_playback( - self, - client_id: str, - initial_time: float | None = None, - ) -> tuple[bool, str]: - """Grant the playback-leader role if vacant. - - Returns ``(granted, current_leader)``; on rejection the second value - is the existing leader's client id so the caller can include it in - a PlaybackRejected message. ``initial_time`` (optional) sets the - shared playback timecode atomically with the grant so followers - sync to the new leader's current playhead instead of the stale - server-side value. - """ - if not client_id: - return False, self.playback["leader_client_id"] or "" - with self.playback_lock: - current = self.playback["leader_client_id"] - if current and current != client_id: - return False, current - self.playback["leader_client_id"] = client_id - if initial_time is not None: - self.playback["time"] = float(initial_time) - return True, client_id - - def apply_playback_control( - self, - client_id: str, - action: str, - time_value: float = 0.0, - rate: float = 1.0, - ) -> tuple[bool, dict | str, str]: - """Apply a control command from the playback leader. - - Returns ``(True, new_state_dict, leader)`` on success or - ``(False, reason, current_leader)`` when the requesting client is - not the current leader. Always returns the current leader as the - third element so callers don't have to re-read ``self.playback`` - outside the lock. - """ - with self.playback_lock: - leader = self.playback["leader_client_id"] or "" - if leader != client_id: - return False, f"not the playback leader (current: {leader})", leader - if action == "play": - self.playback["playing"] = True - elif action == "pause": - self.playback["playing"] = False - elif action == "stop": - self.playback["playing"] = False - self.playback["time"] = 0.0 - elif action == "set_time": - self.playback["time"] = float(time_value) - elif action == "set_rate": - self.playback["rate"] = float(rate) - else: - return False, f"unknown playback action {action!r}", leader - return ( - True, - { - "time": self.playback["time"], - "playing": self.playback["playing"], - "rate": self.playback["rate"], - "leader_client_id": self.playback["leader_client_id"] or "", - }, - leader, + if existing_graph_log: + identities = self.store.get_layer_identities() + if not identities: + raise ValueError( + "shared-stage database has no durable layer identity registry; " + "recreate databases written before this protocol version" + ) + graph.restore_identity_records( + (identity.identifier, identity.layer_key) for identity in identities ) - - def release_playback(self, client_id: str) -> bool: - """Release the leader role when a leader disconnects. - - Returns True if the role was actually released (so the caller knows - to broadcast a PlaybackState with an empty leader_client_id). - """ - if not client_id: - return False - with self.playback_lock: - if self.playback["leader_client_id"] == client_id: - self.playback["leader_client_id"] = None - return True - return False - - def _create_edit_layer(self, label: str = "server-edits") -> Sdf.Layer: - """Create an override sublayer on the session layer and set it as the edit target. - - The session layer is stronger than the entire root layer stack, so - opinions authored here always compose on top of the base file and - its sublayers. The override is inserted as a sublayer of the session - layer (rather than using the session layer directly) so that - multi-user mode can add per-client sublayers alongside it. - - Accepts an optional *label* for the layer identifier this is the - extension point for per-client layers. - """ - layer = Sdf.Layer.CreateAnonymous(label) - session = self.stage.GetSessionLayer() - session.subLayerPaths.insert(0, layer.identifier) - self.stage.SetEditTarget(Usd.EditTarget(layer)) - return layer - - def _op_cache_for(self, layer: Sdf.Layer): - """Reuse op setup only for consecutive edits to the same layer. - - XformOp.Set uses the stage's current edit target; the op is not bound - to a layer. This cache also skips setup of that layer's xformOpOrder, - which must run again after switching layers. - """ - if self._op_cache_layer != layer.identifier: - self.op_cache.clear() - self._op_cache_layer = layer.identifier - return self.op_cache - - def _track_prim_event(self, ev: dict): - """Update incremental prim trackers from a single event. - - Covers ensure/delete/rename plus instancing flags. The dashboard - relies on this so its tree refresh never has to query pxr. - """ - k = ev.get("k") - prim = ev.get("prim", "") - if k == K_ENSURE_PRIM: - type_name = ev["typeName"] - self._prim_paths[prim] = type_name - if type_name == "PointInstancer": - self._point_instancer_paths.add(prim) - elif k == K_DELETE_PRIM: - self._prim_paths.pop(prim, None) - self._instanceable_paths.discard(prim) - self._point_instancer_paths.discard(prim) - elif k == K_RENAME_PRIM: - type_name = self._prim_paths.pop(prim, "Xform") - was_instanceable = prim in self._instanceable_paths - was_pi = prim in self._point_instancer_paths - self._instanceable_paths.discard(prim) - self._point_instancer_paths.discard(prim) - new_name = ev.get("new_name", "") - if new_name: - parent = prim.rsplit("/", 1)[0] or "/" - new_path = f"{parent}/{new_name}" if parent != "/" else f"/{new_name}" - self._prim_paths[new_path] = type_name - if was_instanceable: - self._instanceable_paths.add(new_path) - if was_pi: - self._point_instancer_paths.add(new_path) - elif k == K_SET_INSTANCEABLE: - if ev.get("instanceable", True): - self._instanceable_paths.add(prim) - else: - self._instanceable_paths.discard(prim) - - def _update_prim_tracking(self, events: list[dict]) -> None: - """Publish dashboard indexes for successfully applied events.""" - for event in events: - kind = event.get("k") - if kind in (K_ENSURE_PRIM, K_DELETE_PRIM, K_RENAME_PRIM): - self._prim_count_dirty = True - self._track_prim_event(event) - elif kind == K_SET_INSTANCEABLE: - self._track_prim_event(event) + graph.authoritative = True + self.refresh_shared_layer_dependencies() def _replay_log_into_stage(self): """Apply all events from the event store to restore stage on startup. @@ -961,8 +337,6 @@ def _replay_log_into_stage(self): order is preserved across layers; adjacent events for one layer are batched. Shared session and stage-state events use the session layer. """ - from ..event_apply import apply_events - rows = self.store.get_all_asc() if not rows: return @@ -972,57 +346,33 @@ def _replay_log_into_stage(self): routed: list[tuple[Sdf.Layer, dict]] = [] for _seq, record_bin in rows: - rec = message_to_dict(record_bin, numpy_arrays=True) - ev = rec.get("event", rec) - client_id = rec.get("client_id") - if ev.get("k") in NON_COLLABORATION_KINDS: + message_type, broadcast = resolve_payload(decode_envelope(record_bin)) + if message_type != MSG_EVENT: + raise ValueError("managed log contains an unsupported record") + record = decode_received_event(broadcast, numpy_arrays=True) + ev = record.event + if ev["k"] in NON_COLLABORATION_KINDS: layer = self.stage.GetSessionLayer() else: - layer_key = rec.get("layer_key") or "" + layer_key = record.layer_key if not layer_key: raise ValueError("persisted collaboration opinion is missing layer_key") layer, _added = self.layer_stack.ensure_layer( layer_key, - label=_label_for_layer_key(layer_key), + label=label_for_layer_key(layer_key), ) - if client_id: - self._client_layer_keys[client_id] = layer_key + if record.client_id: + self._collaboration.restore_assignment(record.client_id, layer_key) routed.append((layer, ev)) - self._apply_department_order() - - current_layer = None - run: list[dict] = [] - - def _apply_run(): - if not run: - return - self.stage.SetEditTarget(Usd.EditTarget(current_layer)) - apply_events( - self.stage, - run, - op_cache=self._op_cache_for(current_layer), - ) - - for layer, ev in routed: - if current_layer is not None and layer is not current_layer: - _apply_run() - run = [] - current_layer = layer - run.append(ev) - _apply_run() - self.stage.SetEditTarget(Usd.EditTarget(self.edit_layer)) + self._collaboration.apply_department_order() - # Populate incremental prim tracking from replayed events. - for _layer, ev in routed: - self._track_prim_event(ev) + self._scene.replay_events(routed) LOG.info("Restored stage from event log: %d events", len(routed)) def _replay_shared_log(self, rows: list[tuple[int, bytes]]) -> None: """Restore exact authored-layer opinions in persisted order.""" - from ..event_apply import apply_events - graph = self.shared_layer_graph if graph is None: raise RuntimeError("shared-stage replay requires a layer graph") @@ -1031,38 +381,35 @@ def _replay_shared_log(self, rows: list[tuple[int, bytes]]) -> None: current_key = "" run: list[dict] = [] - def _apply_run() -> None: + def flush_run() -> None: if not run: return layer = graph.layer_for(current_key) if layer is None: raise ValueError(f"persisted event targets unresolved layer key {current_key!r}") - with Usd.EditContext(self.stage, Usd.EditTarget(layer)): - apply_events(self.stage, run) + self._scene.replay_events((layer, event) for event in run) + run.clear() with self.stage_lock: for _seq, record_bin in rows: - record = message_to_dict(record_bin, numpy_arrays=True) - if record.get("type") == MSG_LAYER_GRAPH_STATE: - _apply_run() - run = [] - current_key = "" - graph.apply_state(record) + message_type, payload = resolve_payload(decode_envelope(record_bin)) + if message_type == MSG_LAYER_GRAPH_STATE: + flush_run() + graph.apply_state(message_to_dict(record_bin)) baseline_seen = True continue - if record.get("type") != MSG_EVENT: + if message_type != MSG_EVENT: raise ValueError("shared-stage log contains an unsupported record") if not baseline_seen: raise ValueError("shared-stage log must begin with a layer graph baseline") - event = record.get("event", {}) - layer_key = record.get("layer_key") or "" - if event.get("k") == K_SET_SUBLAYERS: - _apply_run() - run = [] - current_key = "" + record = decode_received_event(payload, numpy_arrays=True) + event = record.event + layer_key = record.layer_key or "" + if event["k"] == K_SET_SUBLAYERS: + flush_run() graph.apply_sublayers(layer_key, event) continue - if event.get("k") not in ( + if event["k"] not in ( K_SET_SDF_SPEC_FIELDS, K_ERASE_TIME_SAMPLES, K_REPLACE_SDF_LAYER_CONTENT, @@ -1071,1181 +418,321 @@ def _apply_run() -> None: f"shared-stage log contains unsupported event {event.get('k')!r}" ) if run and layer_key != current_key: - _apply_run() - run = [] + flush_run() current_key = layer_key run.append(event) - _apply_run() + flush_run() if not baseline_seen: raise ValueError("shared-stage log has no layer graph baseline") - self.stage.SetEditTarget(Usd.EditTarget(self.edit_layer)) - self._prim_count_dirty = True + self._scene.invalidate_prim_count() LOG.info("Restored shared stage from event log: %d records", len(rows)) - # -- TOFU authentication ------------------------------------------- - - def authenticate( - self, client_id: str | None, token: str | None, department: str | None = None - ) -> tuple[bool, str | None]: - """Authenticate a client using TOFU. + def shutdown(self): + """Idempotently drain workers, including after incomplete initialization. - Returns (accepted, issued_token). - - First connect (no token stored): issues a new token → (True, new_token) - - Reconnect with valid token: accepted → (True, None) - - Reconnect with wrong/missing token: rejected → (False, None) - - Token not required: always accepted → (True, None) - - Token required and no client_id: rejected → (False, None) + Successful construction leaves store ownership with the caller, as before. + Failed construction closes only the store created by this state object. """ - if not self.require_token or not self.token_store: - return True, None - - if not client_id: - LOG.warning("Auth rejected: missing client_id") - return False, None - - if not self.token_store.has_token(client_id): - # First connect issue token (TOFU) - new_token = self.token_store.issue(client_id, department) - return True, new_token + with self._shutdown_lock: + if self._shutdown_complete: + return + self._shutdown_complete = True + if self._compactor is not None: + self._compactor.request_stop() + if self._transactions is not None: + self._transactions.shutdown() + if self._compactor is not None: + self._compactor.join() + if self._journal is not None: + self._journal.shutdown() + if self._broadcast_thread is not None and self._broadcast_thread.ident is not None: + self._broadcast_queue.put(None) + self._broadcast_thread.join() + + # -- Transaction admission, commit, and publication -- - if token and self.token_store.verify(client_id, token): - return True, None + def process_idempotent_txn( + self, + events: list[dict], + *, + session_id: str, + txn_id: int, + client_id: str, + origin: str | None = None, + client_addr: str | None = None, + layer: Sdf.Layer | None = None, + layer_key: str = "", + ) -> TransactionCommit: + """Commit once for an ordered producer session and return its result.""" + request = self.submit_idempotent_txn( + events, + session_id=session_id, + txn_id=txn_id, + client_id=client_id, + origin=origin, + client_addr=client_addr, + layer=layer, + layer_key=layer_key, + ) + return self.wait_for_transaction(request) - LOG.warning("Auth rejected for %s invalid or missing token", client_id) - return False, None - - def revoke_token(self, client_id: str) -> bool: - """Revoke a client's token via dashboard/API.""" - if not self.token_store: - return False - return self.token_store.revoke(client_id) - - def bump_snapshot_epoch(self, reason: str = "") -> None: - """Invalidate cached virtual-file snapshots after non-log stage changes.""" - with self._seq_lock: - self._snapshot_epoch += 1 - epoch = self._snapshot_epoch - if reason: - LOG.debug("Snapshot epoch bumped to %d (%s)", epoch, reason) - else: - LOG.debug("Snapshot epoch bumped to %d", epoch) - - def get_token_list(self) -> list[dict]: - """Return all token records for the dashboard.""" - if not self.token_store: - return [] - return self.token_store.get_all() - - # -- Client assignments to collaboration layers --------------------- - - @property - def client_layers(self) -> dict[str, Sdf.Layer]: - """Return a snapshot of client assignments to shared collaboration layers.""" - return { - client_id: self.layer_stack.layer_for(layer_key) - for client_id, layer_key in self._client_layer_keys.items() - } - - def get_or_create_client_layer( + def submit_idempotent_txn( self, + events: list[dict], + *, + session_id: str, + txn_id: int, client_id: str, - department: str | None = None, - ) -> Sdf.Layer: - """Get or create a layer for this client. - - With department: clients share a department layer (last-write-wins - within the department, department priority controls strength). - Without department: uses the shared edit_layer (weakest, last-write-wins). - """ - layer_key = _layer_key_for_department(department) - if department: - layer = self._get_or_create_department_layer(department) - else: - layer = self.edit_layer - self._client_layer_keys[client_id] = layer_key - return layer - - def _flat_replay_rejection_reason_unlocked(self) -> str: - """Return why a new flat receiver cannot mirror this server.""" - if self.department_priority: - return "department collaboration requires layered replay" - if len(self.layer_stack.layer_keys) != 1: - return "multiple collaboration layers require layered replay" - layer = self.layer_stack.ordered_layers[0] - with self.stage_lock: - if self.stage.IsLayerMuted(layer.identifier): - return "muted collaboration layers require layered replay" - return "" - - def reserve_receiver_replay_mode(self, layered: bool) -> tuple[bool, str]: - """Atomically admit a receiver under the current layer-stack contract.""" - if layered: - return True, "" - with self._replay_mode_lock: - reason = self._flat_replay_rejection_reason_unlocked() - if reason: - return False, reason - self._flat_receiver_count += 1 - return True, "" - - def release_receiver_replay_mode(self, layered: bool) -> None: - """Release a replay-mode reservation when a receiver disconnects.""" - if layered: - return - with self._replay_mode_lock: - if self._flat_receiver_count <= 0: - raise RuntimeError("flat receiver reservation underflow") - self._flat_receiver_count -= 1 - - def _reject_layer_stack_change_for_flat_receivers(self) -> None: - if self._flat_receiver_count: - raise ReplayModeConflictError( - "layer-stack changes require all connected receivers to use layered replay" + origin: str | None = None, + client_addr: str | None = None, + layer: Sdf.Layer | None = None, + layer_key: str = "", + ) -> TransactionRequest: + """Submit without waiting; the coordinator owns its maintenance barrier.""" + if not client_id or not session_id or len(session_id) > 128 or txn_id < 1: + raise TransactionRejectedError( + "invalid_identity", + "client_id and session_id are required and txn_id must be positive", ) + validate_events(events, layer_mode=self.layer_mode) - def _get_or_create_department_layer(self, department: str) -> Sdf.Layer: - """Resolve department policy to one shared collaboration layer.""" - layer_key = _layer_key_for_department(department) - with self._replay_mode_lock: - if not self.layer_stack.has_layer(layer_key): - self._reject_layer_stack_change_for_flat_receivers() - with self.stage_lock: - layer, added = self.layer_stack.ensure_layer( - layer_key, - label=department, - ) - if added: - self._apply_department_order() - if not added: - return layer - - self.bump_snapshot_epoch(f"create_department_layer:{department}") - self.broadcast_layer_stack_state() - LOG.info("Created shared layer for department %s", department) - return layer - - def _apply_department_order(self) -> bool: - """Project department priority onto the currently materialized keys.""" - priority_keys = [] - for department in self.department_priority: - layer_key = _layer_key_for_department(department) - if self.layer_stack.has_layer(layer_key): - priority_keys.append(layer_key) - priority_set = set(priority_keys) - unlisted_keys = [ - layer_key - for layer_key in self.layer_stack.layer_keys - if layer_key != _DEFAULT_LAYER_KEY and layer_key not in priority_set - ] - return self.layer_stack.set_order([*priority_keys, *unlisted_keys, _DEFAULT_LAYER_KEY]) - - def _ordered_department_names(self) -> list[str]: - """Return department policy entries in composed strength order.""" - departments = [] - for layer_key in self.layer_stack.layer_keys: - department = _department_for_layer_key(layer_key) - if department: - departments.append(department) - return departments - - def resolve_layer_key(self, key: str) -> str | None: - """Resolve a client ID, department name, or layer key.""" - client_key = self._client_layer_keys.get(key) - if client_key: - return client_key - department_key = _layer_key_for_department(key) - if self.layer_stack.has_layer(department_key): - return department_key - if self.layer_stack.has_layer(key): - return key - return None - - def resolve_layer(self, key: str) -> Sdf.Layer | None: - """Resolve a layer by client ID, department name, or layer key.""" - layer_key = self.resolve_layer_key(key) - return self.layer_stack.layer_for(layer_key) if layer_key else None - - def department_for_layer(self, layer: Sdf.Layer) -> str | None: - """Return department policy metadata for a managed layer.""" - layer_key = self.layer_stack.key_for_layer(layer) - return _department_for_layer_key(layer_key) if layer_key is not None else None - - def mute_layer(self, key: str) -> bool: - """Mute a layer by client_id or department opinions hidden but preserved.""" - layer_key = self.resolve_layer_key(key) - if not layer_key: - return False - with self._replay_mode_lock: - with self.stage_lock: - layer = self.layer_stack.layer_for(layer_key) - if self.stage.IsLayerMuted(layer.identifier): - changed = False - else: - self._reject_layer_stack_change_for_flat_receivers() - changed = self.layer_stack.set_muted(layer_key, True) - if not changed: - return True - self.bump_snapshot_epoch(f"mute_layer:{key}") - self.broadcast_layer_stack_state() - return True - - def unmute_layer(self, key: str) -> bool: - """Unmute a layer by client_id or department.""" - layer_key = self.resolve_layer_key(key) - if not layer_key: - return False - with self._replay_mode_lock: - with self.stage_lock: - layer = self.layer_stack.layer_for(layer_key) - if not self.stage.IsLayerMuted(layer.identifier): - changed = False - else: - self._reject_layer_stack_change_for_flat_receivers() - changed = self.layer_stack.set_muted(layer_key, False) - if not changed: - return True - self.bump_snapshot_epoch(f"unmute_layer:{key}") - self.broadcast_layer_stack_state() - return True - - def merge_layer(self, client_id: str) -> bool: - """Merge the client's department opinions into the root layer. + request = TransactionRequest( + events=events, + session_id=session_id, + txn_id=txn_id, + client_id=client_id, + origin=origin, + client_addr=client_addr, + layer=layer, + layer_key=layer_key, + ) + return self._transactions.submit(request) - Releases this client; the department layer remains while other clients - use it. Copies each leaf prim spec individually via Sdf.CopySpec so - existing root opinions on sibling prims are preserved. - Returns False for clients on the shared edit_layer (no-op). + @staticmethod + def wait_for_transaction(request: TransactionRequest) -> TransactionCommit: + """Wait for a previously submitted transaction's terminal outcome.""" + return request.wait() + + def _commit_and_publish(self, request: TransactionRequest) -> TransactionCommit: + """Keep sequence assignment ordered through producer and child publication.""" + with self._journal.commit_scope(): + commit = self._committer.commit(request) + if commit.status != "committed": + return commit + self._publish_commit(request, commit) + return commit + + def _commit_and_publish_batch( + self, requests: list[TransactionRequest], + ) -> list[TransactionOutcome]: + """Apply, persist, and publish before another commit can assign sequences. + + The coordinator ends each batch at a payload load. Child replay is + persisted and enqueued here before a later batch reserves its sequences; + the coordinator then captures the checkpoint and completes the requests. """ - layer_key = self._client_layer_keys.get(client_id) - layer = self.layer_stack.layer_for(layer_key) if layer_key else None - if not layer or layer is self.edit_layer: - return False + with self._journal.commit_scope(): + outcomes = self._committer.commit_group(requests) + self._publish_batch(requests, outcomes) + return outcomes - # Collect leaf prim paths outside the lock this is a read-only - # walk of the source layer, no stage mutation involved. - leaves = [] + def _publish_commit( + self, request: TransactionRequest, commit: TransactionCommit, + ) -> None: + """Enqueue producer records, then persist and enqueue any payload replay.""" + try: + self.broadcast_transaction_views(commit.records) + for prim_path in request.payload_load_paths: + self.replay_children_after_load(prim_path) + except Exception: + LOG.exception( + "Transaction %s/%d committed but its live broadcast failed", + request.session_id, + request.txn_id, + ) - def _walk(spec): - if not spec.nameChildren: - leaves.append(spec.path) + def _publish_batch( + self, + requests: list[TransactionRequest], + outcomes: list[TransactionOutcome], + ) -> None: + committed_batches = [] + payload_replay = None + for request, commit in zip(requests, outcomes, strict=True): + if not isinstance(commit, TransactionCommit): + continue + if commit.status != "committed" or not commit.records: + continue + if request.payload_load_paths: + # The coordinator ends the batch here, before later transactions + # can reserve sequences that would precede the child replay. + payload_replay = request, commit else: - for child in spec.nameChildren: - _walk(child) - - for prim_spec in layer.rootPrims: - _walk(prim_spec) - - # Only hold stage_lock for the actual Sdf copy operations. - with self.stage_lock: - root = self.stage.GetRootLayer() - for path in leaves: - parent = path.GetParentPath() - if parent != Sdf.Path.absoluteRootPath and not root.GetPrimAtPath(parent): - Sdf.CreatePrimInLayer(root, parent) - - dst_spec = root.GetPrimAtPath(path) - if dst_spec: - src_spec = layer.GetPrimAtPath(path) - for prop in src_spec.properties: - Sdf.CopySpec(layer, prop.path, root, prop.path) - else: - Sdf.CopySpec(layer, path, root, path) - self._cleanup_client_refs(client_id) - self.bump_snapshot_epoch(f"merge_layer:{client_id}") - LOG.info("Merged and removed layer for client %s", client_id) - return True + committed_batches.append(commit.records) + try: + self.broadcast_transaction_group_views(committed_batches) + except Exception: + LOG.exception("Committed transaction group could not be broadcast live") + if payload_replay is not None: + self._publish_commit(*payload_replay) - def delete_layer(self, client_id: str) -> bool: - """Release a client's department assignment. + def replay_children_after_load(self, prim_path: str): + """Persist and publish surviving child opinions after load_payload. - The department layer is discarded only when its last client leaves. + Reduces logged child edits to their replay state, assigns new sequence + numbers, and publishes them so receivers restore authoritative opinions. - Returns False for clients on the shared edit_layer (no-op). + Also reactivates children on the server's stage that may have been + deactivated by _detect_deletions during a previous unload cycle. + Transaction callers retain their maintenance barrier through this replay. """ - layer_key = self._client_layer_keys.get(client_id) - layer = self.layer_stack.layer_for(layer_key) if layer_key else None - if not layer or layer is self.edit_layer: - return False - self._cleanup_client_refs(client_id) - self.bump_snapshot_epoch(f"delete_layer:{client_id}") - LOG.info("Deleted layer for client %s", client_id) - return True + # Reactivate children on the server's stage (clear stale SetActive(False)) + with self.stage_lock: + prim = self.stage.GetPrimAtPath(prim_path) + if prim and prim.IsValid(): + for child in Usd.PrimRange(prim, Usd.PrimAllPrimsPredicate): + if not child.IsActive(): + child.SetActive(True) + self._scene.invalidate_prim_count() + + with self._journal.commit_scope(): + records = child_replay_records(self.store, prim_path) + encoded = EncodedEvents() + with self._journal.reserve_sequences(): + for record in records: + encoded.append(*self._journal.assign_and_encode_event( + record.event, origin=record.origin, layer_key=record.layer_key, + )) + self._journal.append_batch(encoded.store_rows, synchronous=True) + self.broadcast_transaction_views(encoded.records) - def _cleanup_client_refs(self, client_id: str): - """Remove a client from all tracking dicts. + LOG.info( + "Replayed %d child events after load_payload %s", + len(records), + prim_path, + ) - If this was the last client in a department, also removes the - orphaned department layer reference. - """ - layer_key = self._client_layer_keys.pop(client_id, None) - if not layer_key or layer_key == _DEFAULT_LAYER_KEY: - return - if layer_key in self._client_layer_keys.values(): - return + def broadcast_transaction_views( + self, + records: Sequence[EncodedRecord], + ) -> None: + """Enqueue one authored transaction and notify in-process listeners.""" + self.broadcast_transaction_group_views([records]) - with self.stage_lock: - if self.layer_stack.has_layer(layer_key): - self.layer_stack.remove_layer(layer_key) - self.broadcast_layer_stack_state() + def broadcast_transaction_group_views( + self, + transactions: Iterable[Sequence[EncodedRecord]], + ) -> None: + """Enqueue a committed group as one complete ordered stream. - def set_department_priority(self, ordered_departments: list[str]) -> None: - """Set department priority ordering (strongest first).""" - ordered_departments = list(ordered_departments) - if any(not department for department in ordered_departments): - raise ValueError("department names must be non-empty") - if len(set(ordered_departments)) != len(ordered_departments): - raise ValueError("department priority contains duplicates") - with self._replay_mode_lock: - if ordered_departments != self.department_priority: - self._reject_layer_stack_change_for_flat_receivers() - with self.stage_lock: - policy_changed = ordered_departments != self.department_priority - self.department_priority = ordered_departments - order_changed = self._apply_department_order() - if not order_changed and not policy_changed: - return - self.bump_snapshot_epoch("set_department_priority") - self.broadcast_layer_stack_state() + Every receiver consumes every durable USD record, including records + authored by the same origin. Origin is diagnostic metadata rather than + a delivery filter. This gives live delivery the same contract as replay + and lets every replica apply the server's total order. + """ + all_records = [] + encoded_records = [] + for records in transactions: + for record, encoded in records: + all_records.append(record) + encoded_records.append(encoded) + if all_records: + self.broadcast_bytes(frame_batch(encoded_records), all_records) - def get_layer_stack_state(self) -> dict: - """Return the portable collaboration stack for capable receivers.""" - with self.stage_lock: - return { - "type": MSG_LAYER_STACK_STATE, - **self.layer_stack.state(), - } + def apply_txn( + self, + events: list[dict], + layer: Sdf.Layer | None = None, + *, + update_tracking: bool = True, + ) -> None: + """Validate and apply USD edits without persisting or publishing them. - def broadcast_layer_stack_state(self) -> None: - """Publish current logical layer order and muting.""" - if not self._has_layered_receivers(): - return - self.broadcast_message( - self.get_layer_stack_state(), - audience=_AUDIENCE_LAYERED, + Layer opinions are authored into *layer* (defaults to + ``self.edit_layer``). Shared session metadata and stage-state events + are applied under the primary session edit target. + """ + validate_events(events, layer_mode=self.layer_mode) + self._scene.apply_validated( + events, + layer=layer, + update_tracking=update_tracking, ) - def get_layer_stack_info(self) -> list[dict]: - """Return ordered layer stack info for the dashboard. + def _commit_events( + self, + events: list[dict], + *, + client_id: str | None = None, + origin: str | None = None, + client_addr: str | None = None, + layer: Sdf.Layer | None = None, + layer_key: str = "", + ) -> list[EncodedRecord]: + """Commit events for in-process tests and maintenance utilities. - Department policy metadata is projected over the generic stack. - Unused configured slots stay out of the dashboard until they have a - client or authored content, matching the existing product behavior. + Network producers must use :meth:`submit_idempotent_txn`. This private + helper deliberately omits producer identity and acknowledgements, but + still participates in the maintenance barrier and global commit order. + Managed edits persist synchronously within the USD rollback scope. + Shared-stage internal edits follow the configured durability policy. """ - with self.stage_lock: - muted = set(self.stage.GetMutedLayers()) - layer_keys = self.layer_stack.layer_keys - layers = {layer_key: self.layer_stack.layer_for(layer_key) for layer_key in layer_keys} - labels = {layer_key: self.layer_stack.label_for(layer_key) for layer_key in layer_keys} - client_keys = dict(self._client_layer_keys) - authored = {layer_key: not layers[layer_key].empty for layer_key in layer_keys} - - clients_by_key: dict[str, list[str]] = {} - for client_id, layer_key in client_keys.items(): - clients_by_key.setdefault(layer_key, []).append(client_id) - - return [ - { - "layer_key": layer_key, - "label": labels[layer_key], - "department": _department_for_layer_key(layer_key), - "clients": clients_by_key.get(layer_key, []), - "identifier": layers[layer_key].identifier, - "muted": layers[layer_key].identifier in muted, - "shared": layer_key == _DEFAULT_LAYER_KEY, - } - for layer_key in layer_keys - if clients_by_key.get(layer_key) or authored[layer_key] - ] + if not events: + return [] + validate_events(events, layer_mode=self.layer_mode) + with self.txn_barrier.shared(), self._journal.commit_scope(): + return self._committer.commit_events(Transaction( + events=events, + client_id=client_id, + origin=origin, + client_addr=client_addr, + layer=layer, + layer_key=layer_key, + )) - def compact_log(self): - """Build compacted history off-lock, then catch up and replace the log.""" - # Phase 1 snapshot + compute (no txn_barrier, emitters keep running) - rows = self.store.get_all_asc() - if not rows: - return - max_seq = rows[-1][0] - compaction = self._build_compacted(rows) - original_count = len(rows) + def refresh_shared_layer_dependencies(self) -> tuple[str, ...]: + """Resolve newly available sublayers and publish their routing state.""" + graph = self.shared_layer_graph + if self.layer_mode is not LayerMode.SHARED_STAGE or graph is None: + raise RuntimeError("shared layer dependency refresh requires shared-stage mode") - # Phase 2 merge delta + commit (exclusive, emitters blocked) - self.txn_barrier.acquire_exclusive() - try: - # Every transaction that began before the exclusive barrier is now - # complete. Drain its async side effects before reading the delta - # or resetting sequence numbers, otherwise an old high-sequence - # broadcast could arrive after the compacted replay. - if self._persist_queue is not None: - self._persist_queue.join() - self._broadcast_queue.join() - - # Catch any events that arrived during phase 1 - delta = self.store.get_from_seq_asc(max_seq + 1) - for seq, record_bin in delta: - compaction.add_record(seq, record_bin) - original_count += len(delta) + with self.txn_barrier.shared(): + with self._journal.commit_scope(): + before = set(graph.reachable_layer_keys()) + with self.stage_lock, graph.transaction(): + Ar.GetResolver().RefreshContext(self.stage.GetPathResolverContext()) + routed_events = list(graph.refresh_resolved_sublayers()) + if routed_events: + records = self._committer.persist_shared_events(routed_events) + self.broadcast_transaction_views(records) + return tuple( + layer_key + for layer_key in graph.reachable_layer_keys() + if layer_key not in before + ) - self._commit_compaction(compaction, original_count) - finally: - self.txn_barrier.release_exclusive() + def producer_committed_through(self, client_id: str, session_id: str) -> int: + return self._journal.committed_through(client_id, session_id) - @staticmethod - def _build_compacted(rows: list[tuple[int, bytes]]) -> LogCompaction: - compaction = LogCompaction() - for seq, record_bin in rows: - compaction.add_record(seq, record_bin) - return compaction + def assign_seq(self) -> int: + return self._journal.assign_seq() + + def append_log(self, rec: dict) -> bytes: + return self._journal.append_record(rec) - def _commit_compaction( + def append_log_batch( self, - compaction: LogCompaction, - original_count: int, + tuples: list[tuple[int, bytes, str | None, str | None, str | None]], + *, + producer_progress: tuple[ProducerProgress, ...] = (), + layer_identities: tuple[LayerIdentity, ...] = (), ): - """Replace the log and resync receivers; requires the exclusive txn barrier.""" - sorted_entries = compaction.replay_entries() - graph = None - if self.layer_mode is LayerMode.SHARED_STAGE: - graph = self.shared_layer_graph - if graph is None: - raise RuntimeError("shared-stage compaction requires a layer graph") - reachable = set(graph.reachable_layer_keys()) - sorted_entries = [ - entry for entry in sorted_entries if entry.metadata.get("layer_key") in reachable - ] - - graph_transaction = graph.transaction() if graph is not None else nullcontext() - with graph_transaction: - records = [] - first_event_seq = 1 - if graph is not None: - # Start a new replay baseline without changing layer identities. - graph.start_new_generation() - graph_record = graph.state_message(seq=1) - records.append( - (1, encode_message(graph_record), None, MSG_LAYER_GRAPH_STATE, None) - ) - first_event_seq = 2 + self._journal.append_batch( + tuples, producer_progress=producer_progress, layer_identities=layer_identities, + ) - for seq, entry in enumerate( - sorted_entries, - start=first_event_seq, - ): - rec = {"type": MSG_EVENT, "seq": seq, "event": entry.event} - rec.update(entry.metadata) - records.append( - ( - seq, - encode_message(rec), - entry.metadata.get("client_id"), - entry.event.get("k"), - entry.event.get("prim"), - ) - ) - self.store.clear_and_rewrite(records) - self._reset_replay_history(len(records)) - self._maybe_reclaim_storage() - self._rebuild_scene_caches(entry.event for entry in sorted_entries) - - LOG.info("Compacted event log: %d -> %d records", original_count, len(records)) - - # Reset and replay each receiver while holding its send lock. Sending - # the control message directly avoids an async-broadcast race where - # replay records could otherwise overtake the resync. - with self.clients_lock: - targets = list(self.receivers) - disconnected = [] - for handler in targets: - try: - with handler.send_lock: - controls = [encode_message({"type": MSG_RESYNC})] - if getattr(handler, "_layered_replay", False): - controls.append(encode_message(self.get_layer_stack_state())) - handler.request.sendall(frame_batch(controls)) - self.replay_from(handler, 1) - replay_epoch, replay_head = self.get_replay_token() - handler.request.sendall( - frame_batch( - [ - encode_message( - { - "type": MSG_REPLAY_COMPLETE, - "head_seq": replay_head, - "epoch": replay_epoch, - } - ) - ] - ) - ) - except (OSError, TimeoutError): - LOG.info( - "Receiver disconnected during compaction replay: %s", - handler.client_address, - ) - disconnected.append(handler) - self._discard_unreachable_receivers(disconnected) - - def purge(self): - """Clear all events, reset the edit layer, and resync receivers.""" - if self.layer_mode is LayerMode.SHARED_STAGE: - raise RuntimeError( - "purge is unavailable in shared-stage mode because application-owned " - "authored opinions are not server-owned" - ) - self.txn_barrier.acquire_exclusive() - try: - if self._persist_queue is not None: - self._persist_queue.join() - self._broadcast_queue.join() - self._purge_inner() - finally: - self.txn_barrier.release_exclusive() - - def _purge_inner(self): - # Producer high-water marks survive a scene purge. Otherwise an old - # ambiguous retry could resurrect pre-purge edits, and still-connected - # producers would be rejected for starting above transaction 1. - self.store.clear_and_rewrite([]) - self._maybe_reclaim_storage() - self._reset_replay_history(0) - with self.stage_lock: - self.layer_stack.clear() - self._rebuild_scene_caches() - LOG.info("Purged event log and reset authored collaboration layers") - replay_epoch, replay_head = self.get_replay_token() - with self.clients_lock: - targets = list(self.receivers) - disconnected = [] - for handler in targets: - try: - with handler.send_lock: - controls = [encode_message({"type": MSG_RESYNC, "reason": "purge"})] - if getattr(handler, "_layered_replay", False): - controls.append(encode_message(self.get_layer_stack_state())) - controls.append( - encode_message( - { - "type": MSG_REPLAY_COMPLETE, - "head_seq": replay_head, - "epoch": replay_epoch, - } - ) - ) - handler.request.sendall(frame_batch(controls)) - except (OSError, TimeoutError): - LOG.info( - "Receiver disconnected during purge resync: %s", - handler.client_address, - ) - disconnected.append(handler) - self._discard_unreachable_receivers(disconnected) - - def _reset_replay_history(self, record_count: int) -> None: - """Publish a durable log replacement while holding the exclusive barrier.""" - with self._seq_lock: - self._event_count = record_count - self._next_seq = record_count + 1 - self._seq_at_last_compact = self._next_seq - self._snapshot_epoch += 1 - self._replay_epoch += 1 - - def _rebuild_scene_caches(self, events: Iterable[dict] = ()) -> None: - """Discard cached USD handles and rebuild indexes from replacement history.""" - self.op_cache.clear() - self._op_cache_layer = None - self._prim_paths.clear() - self._instanceable_paths.clear() - self._point_instancer_paths.clear() - for event in events: - self._track_prim_event(event) - self._prim_count_dirty = True - - def assign_seq(self) -> int: - with self._seq_lock: - s = self._next_seq - self._next_seq += 1 - return s - - def get_snapshot_token(self) -> tuple[int, int]: - """Return ``(epoch, latest_seq)`` for virtual-file cache keys.""" - with self._seq_lock: - return self._snapshot_epoch, max(0, self._next_seq - 1) - - def get_replay_token(self) -> tuple[int, int]: - """Return the sequence domain and head, independent of VFS cache invalidation.""" - with self._seq_lock: - return self._replay_epoch, max(0, self._next_seq - 1) - - def replace_from_stage_snapshot( - self, - uploaded_stage: Usd.Stage, - *, - client_id: str = "vfs-write", - origin: str = "vfs-write", - reject_stale: bool = True, - reject_ambiguous: bool = True, - unchanged_snapshot: bool = False, - ) -> int: - """Replace the live edit state from a complete uploaded USD snapshot. - - This is the WebDAV write-fallback path. A plain DCC save gives us a - complete USD layer, not semantic incremental events, so the server - translates it into a full event snapshot, resets the live edit log, - broadcasts a resync, then broadcasts the translated events. - - Returns the number of translated events persisted to the event log. - """ - from ..event_apply import apply_events - - uploaded_meta = _stage_live_metadata(uploaded_stage) - self.txn_barrier.acquire_exclusive() - try: - analysis = self._validate_stage_snapshot( - uploaded_stage, - uploaded_meta, - reject_stale=reject_stale, - reject_ambiguous=reject_ambiguous, - ) - if unchanged_snapshot: - self.last_vfs_write_analysis = replace( - analysis, - status="unchanged", - notes=["uploaded bytes match the current virtual snapshot"], - ).to_dict() - LOG.info("VFS snapshot write is unchanged; no events generated") - return 0 - - events, replacement_layer, shared_events = self._prepare_stage_snapshot( - uploaded_stage, analysis.removed_prims, - ) - event_counts: dict[str, int] = {} - for event in events: - kind = event.get("k", "") - if kind: - event_counts[kind] = event_counts.get(kind, 0) + 1 - analysis = replace(analysis, event_counts=event_counts) - - records: list[tuple[dict, bytes]] = [] - persist_tuples = [] - for seq, event in enumerate(events, start=1): - record: dict = { - "type": MSG_EVENT, - "seq": seq, - "event": event, - "client": None, - "client_id": client_id, - "origin": origin, - } - if event.get("k") not in NON_COLLABORATION_KINDS: - record["layer_key"] = _DEFAULT_LAYER_KEY - record_bin = encode_message(record) - records.append((record, record_bin)) - persist_tuples.append( - ( - seq, - record_bin, - client_id, - event.get("k"), - event.get("prim"), - ) - ) - - # Realtime durability may still have accepted transactions in its - # queue from before this exclusive barrier was acquired. - if self._persist_queue is not None: - self._persist_queue.join() - self._broadcast_queue.join() - - # This is the durability boundary. EventStore implementations must - # leave the previous log intact if the atomic replacement fails. - # Authoritative in-memory state is deliberately unchanged until it - # returns successfully. - self.store.clear_and_rewrite(persist_tuples) - - with self.stage_lock: - self.edit_layer.TransferContent(replacement_layer) - self.stage.SetEditTarget(Usd.EditTarget(self.edit_layer)) - if shared_events: - self.stage.SetEditTarget(Usd.EditTarget(self.stage.GetSessionLayer())) - try: - apply_events(self.stage, shared_events, prevalidated=True) - finally: - self.stage.SetEditTarget(Usd.EditTarget(self.edit_layer)) - - self._rebuild_scene_caches(events) - self._reset_replay_history(len(records)) - - self.last_vfs_write_analysis = analysis.to_dict() - self._maybe_reclaim_storage() - - # Queue the reset, replacement records, and completion marker - # together so the same receivers observe them in order without - # another broadcast interleaving. ReplayComplete marks the new - # durable head and restores readiness, even with no records. - replay_epoch, replay_head = self.get_replay_token() - resync_bin = encode_message({"type": MSG_RESYNC, "reason": "vfs-write"}) - complete_bin = encode_message( - { - "type": MSG_REPLAY_COMPLETE, - "head_seq": replay_head, - "epoch": replay_epoch, - } - ) - record_dicts = [record for record, _record_bin in records] - record_bins = [record_bin for _record, record_bin in records] - if self.wire_metrics is not None: - self.wire_metrics.record(MSG_RESYNC, len(resync_bin)) - for record, record_bin in records: - self.wire_metrics.record(record["event"].get("k", ""), len(record_bin)) - self.wire_metrics.record(MSG_REPLAY_COMPLETE, len(complete_bin)) - self.broadcast_bytes( - frame_batch([resync_bin, *record_bins, complete_bin]), - record_dicts, - ) - LOG.info( - "Translated VFS snapshot write into %d live events " - "(created=%d removed=%d type_changed=%d)", - len(events), - len(analysis.created_prims), - len(analysis.removed_prims), - len(analysis.type_changed_prims), - ) - return len(events) - finally: - self.txn_barrier.release_exclusive() - - def _validate_stage_snapshot( - self, - uploaded_stage: Usd.Stage, - uploaded_meta: dict | None, - *, - reject_stale: bool, - reject_ambiguous: bool, - ) -> VfsWriteAnalysis: - """Compare an upload with live state while holding the exclusive barrier.""" - metadata = uploaded_meta or {} - uploaded_scene_id = metadata.get("scene_id") - uploaded_epoch = metadata.get("epoch") - uploaded_seq = metadata.get("snapshot_seq") - current_epoch, current_seq = self.get_snapshot_token() - with self.stage_lock: - department_layers = self._ordered_department_names() - additional_layers = [ - layer_key - for layer_key in self.layer_stack.layer_keys - if layer_key != _DEFAULT_LAYER_KEY - and _department_for_layer_key(layer_key) is None - ] - before_types = _stage_prim_types(self.stage) - uploaded_types = _stage_prim_types(uploaded_stage) - before_paths = set(before_types) - uploaded_paths = set(uploaded_types) - created_paths = sorted(uploaded_paths - before_paths) - removed_paths = sorted( - before_paths - uploaded_paths, - key=lambda p: p.count("/"), - reverse=True, - ) - type_changed_paths = sorted( - p for p in before_paths & uploaded_paths if before_types[p] != uploaded_types[p] - ) - - analysis = VfsWriteAnalysis( - status="translated", - current_epoch=current_epoch, - current_seq=current_seq, - uploaded_epoch=uploaded_epoch, - uploaded_seq=uploaded_seq, - before_prim_count=len(before_paths), - uploaded_prim_count=len(uploaded_paths), - created_prims=created_paths, - removed_prims=removed_paths, - type_changed_prims=type_changed_paths, - ) - - def reject(status: str, note: str, error: RuntimeError) -> None: - self.last_vfs_write_analysis = replace( - analysis, status=status, notes=[note], - ).to_dict() - raise error - - if department_layers or additional_layers: - details = [] - if department_layers: - details.append(f"department layers: {', '.join(department_layers)}") - if additional_layers: - details.append(f"collaboration layers: {', '.join(additional_layers)}") - reject( - "unsupported_rejected", - "translate write fallback is disabled while non-default " - f"collaboration layers are active ({'; '.join(details)})", - UnsupportedVfsWriteError( - "VFS translate writes are disabled while non-default " - "collaboration layers are active" - ), - ) - - if uploaded_meta is None: - reject( - "metadata_rejected", - "uploaded snapshot is missing openusdconnect metadata", - InvalidVfsWriteError("uploaded VFS snapshot is missing openusdconnect metadata"), - ) - - if uploaded_scene_id != self.scene_id: - reject( - "metadata_rejected", - "uploaded snapshot belongs to a different live scene", - InvalidVfsWriteError( - "uploaded VFS snapshot scene_id does not match this server: " - f"file={uploaded_scene_id!r}, server={self.scene_id!r}" - ), - ) - - uploaded_token = (uploaded_epoch, uploaded_seq) - current_token = (current_epoch, current_seq) - if uploaded_token != current_token: - if uploaded_token < current_token and not reject_stale: - analysis = replace( - analysis, - notes=[ - "stale snapshot token accepted because stale-write rejection was disabled" - ], - ) - else: - relation = "older" if uploaded_token < current_token else "newer" - reject( - "stale_rejected" if relation == "older" else "future_rejected", - f"uploaded snapshot is {relation} than the current live server state", - StaleVfsWriteError( - "uploaded VFS snapshot token does not match the current server: " - f"file epoch/seq={uploaded_epoch}/{uploaded_seq}, " - f"server epoch/seq={current_epoch}/{current_seq}" - ), - ) - - if uploaded_stage.GetEditTarget().GetLayer().subLayerPaths: - reject( - "unsupported_rejected", - "uploaded snapshot contains sublayer topology, which cannot " - "be mapped into the managed collaboration layer stack", - UnsupportedVfsWriteError( - "uploaded VFS snapshot contains unsupported sublayer topology" - ), - ) - - removed_fraction = len(removed_paths) / max(1, len(before_paths)) - removes_rootish_prim = any( - path.count("/") <= 1 and path != "/Root" for path in removed_paths - ) - ambiguous_destructive = bool(removed_paths) and ( - not uploaded_paths - or removes_rootish_prim - or (len(before_paths) >= 10 and removed_fraction >= 0.8) - ) - if reject_ambiguous and ambiguous_destructive: - reject( - "ambiguous_rejected", - "uploaded snapshot removes a root-level prim or most of the scene; " - "refusing automatic fallback translation", - AmbiguousVfsWriteError( - "uploaded VFS snapshot looks destructively incomplete; " - f"removed {len(removed_paths)} of {len(before_paths)} prims" - ), - ) - - return analysis - - def _prepare_stage_snapshot( - self, - uploaded_stage: Usd.Stage, - removed_paths: list[str], - ) -> tuple[list[dict], Sdf.Layer, list[dict]]: - """Translate and validate a replacement without changing live USD state.""" - emitter = NoticeEmitter(uploaded_stage) - try: - events = emitter.snapshot_events() - finally: - emitter.cleanup() - - # Hide prims that existed in the previous composed stage but are absent - # from the uploaded snapshot. This lets full-file saves express deletes - # even when the original prim lives in the immutable base layer. - for prim_path in removed_paths: - events.append({"k": K_DEACTIVATE_PRIM, "prim": prim_path, "active": False}) - - # Build the complete replacement off-stage first. This validates - # every generated event and gives us an authored layer that can be - # installed without incrementally mutating authoritative state. - from ..event_apply import apply_events - from ..sdf_spec_delta import validate_spec_delta - - for event in events: - if event.get("k") == K_SET_SDF_SPEC_FIELDS: - validate_spec_delta(event) - - replacement_layer = Sdf.Layer.CreateAnonymous("vfs-replacement-edits") - replacement_session = Sdf.Layer.CreateAnonymous("vfs-replacement-session") - replacement_session.subLayerPaths = [replacement_layer.identifier] - with self.stage_lock: - replacement_stage = Usd.Stage.Open( - self.stage.GetRootLayer(), - replacement_session, - self.stage.GetPathResolverContext(), - ) - if replacement_stage is None: - raise RuntimeError("failed to create the VFS replacement stage") - replacement_stage.SetEditTarget(Usd.EditTarget(replacement_layer)) - replacement_events = [ - event for event in events if event.get("k") not in NON_COLLABORATION_KINDS - ] - shared_events = [event for event in events if event.get("k") in NON_COLLABORATION_KINDS] - if replacement_events: - apply_events(replacement_stage, replacement_events, prevalidated=True) - if shared_events: - replacement_stage.SetEditTarget(Usd.EditTarget(replacement_stage.GetSessionLayer())) - apply_events(replacement_stage, shared_events, prevalidated=True) - - return events, replacement_layer, shared_events - - def _shared_layer_identity_updates( - self, - layer_keys: set[str] | tuple[str, ...], - ) -> tuple[LayerIdentity, ...]: - graph = self.shared_layer_graph - if graph is None: - raise RuntimeError("shared layer identity requires shared-stage mode") - identities = [] - for layer_key in sorted(set(layer_keys)): - identifier = graph.identifier_for_key(layer_key) - if identifier is not None: - identities.append( - LayerIdentity(identifier=identifier, layer_key=layer_key) - ) - return tuple(identities) - - def _append_shared_graph_baseline(self) -> None: - """Persist the initial graph baseline and its stable keys atomically.""" - graph = self.shared_layer_graph - if graph is None: - raise RuntimeError("shared graph baseline requires shared-stage mode") - record = graph.state_message(seq=self.assign_seq()) - record_bin = encode_message(record) - self.append_log_batch( - [ - ( - record["seq"], - record_bin, - None, - MSG_LAYER_GRAPH_STATE, - None, - ) - ], - layer_identities=tuple( - LayerIdentity(identifier, layer_key) - for identifier, layer_key in graph.identity_records() - ), - ) - - def append_log(self, rec: dict) -> bytes: - """Append an event record and return its encoded representation. - - Raises on persistence failure callers should not broadcast - events that were not successfully persisted. - """ - ev = rec.get("event", {}) - rec_bin = encode_message(rec) - self.store.append(rec["seq"], rec_bin, kind=ev.get("k"), prim=ev.get("prim")) - with self._seq_lock: - self._event_count += 1 - return rec_bin - - def append_log_batch( - self, - tuples: list[tuple[int, bytes, str | None, str | None, str | None]], - *, - producer_progress: tuple[ProducerProgress, ...] = (), - layer_identities: tuple[LayerIdentity, ...] = (), - ): - """Persist pre-serialized event records. - - Each tuple is (seq, record_bin, client_id, kind, prim). - In strict mode, writes synchronously (caller blocks until DB commit). - In realtime mode, enqueues for async write (caller returns immediately). - """ - if ( - self._persist_queue is not None - and not producer_progress - and not layer_identities - ): - self._persist_queue.put(tuples) - else: - # A transaction result is acknowledged only after the atomic - # event+producer-progress commit returns, including in realtime mode. - if layer_identities: - self.store.append_batch( - tuples, - producer_progress=producer_progress, - layer_identities=layer_identities, - ) - else: - self.store.append_batch( - tuples, - producer_progress=producer_progress, - ) - with self._seq_lock: - self._event_count += len(tuples) - - def replay_children_after_load(self, prim_path: str): - """After load_payload, re-broadcast the latest events for children. - - Queries the event log for the most recent structural and TRS events - for each child of prim_path, assigns new sequence numbers, and - broadcasts them so receivers re-apply the authoritative state. - - Also reactivates children on the server's stage that may have been - deactivated by _detect_deletions during a previous unload cycle. - """ - # Reactivate children on the server's stage (clear stale SetActive(False)) - with self.stage_lock: - prim = self.stage.GetPrimAtPath(prim_path) - if prim and prim.IsValid(): - for child in Usd.PrimRange(prim, Usd.PrimAllPrimsPredicate): - if not child.IsActive(): - child.SetActive(True) - - prefix = prim_path + "/" - replay_kinds = { - K_ENSURE_PRIM, - K_ENSURE_XFORM_OPS, - K_SET_XFORM_TRS, - K_SET_VISIBILITY, - K_SET_MATERIAL_BINDING, - K_SET_CONNECTABLE_INPUT, - K_SET_CONNECTABLE_CONNECTION, - } - - record_blobs = self.store.get_by_prim_prefix(prefix, replay_kinds) - - # Collect the latest event of each relevant kind per child prim. - # Keep the logical layer target so replayed broadcasts preserve - # authored-layer routing. - latest: dict[ - tuple[str, str], - tuple[dict, str | None, str | None], - ] = {} - for blob in record_blobs: - rec = message_to_dict(blob) - ev = rec.get("event", rec) - ep = ev.get("prim", "") - ek = ev.get("k", "") - if ep.startswith(prefix) and ek in replay_kinds: - latest[(ep, ek)] = ( - ev, - rec.get("origin"), - rec.get("layer_key"), - ) - - if not latest: - return - - # Tier-major: every create replays before the bindings and - # connections that reference sibling prims; within a tier, path - # order puts parents before children. - sorted_events = sorted( - latest.values(), - key=lambda e: (event_apply_tier(e[0]["k"]), e[0]["prim"]), - ) - - for ev, origin, layer_key in sorted_events: - rec = {"type": MSG_EVENT, "seq": self.assign_seq(), "event": ev} - if origin: - rec["origin"] = origin - if layer_key: - rec["layer_key"] = layer_key - rec_bin = self.append_log(rec) - if self.wire_metrics is not None: - self.wire_metrics.record(ev.get("k", ""), len(rec_bin)) - self.broadcast_transaction_views([(rec, rec_bin)]) - - LOG.info( - "Replayed %d child events after load_payload %s", - len(sorted_events), - prim_path, - ) - - def add_event_listener(self, callback) -> None: - """Subscribe to broadcast events. Callback receives the event record dict.""" - self._event_listeners.append(callback) - - def remove_event_listener(self, callback) -> None: - """Unsubscribe from broadcast events.""" - if callback in self._event_listeners: - self._event_listeners.remove(callback) - - @contextmanager - def receiver_replay_window( - self, - handler, - sync_from: int = 1, - *, - replay_server_instance: str | None = None, - replay_epoch: int | None = None, - ): - """Register a receiver at a stable replay-to-live boundary. - - The exclusive barrier is held only long enough to make prior realtime - writes durable, capture immutable records and routing with their - watermark, and join the live receiver set. Only the handler's send - lock remains held during delivery, so newer broadcasts follow replay - without network I/O holding the maintenance barrier. - """ - self.txn_barrier.acquire_exclusive() - send_lock_acquired = False - receiver_registered = False - try: - if self._persist_queue is not None: - self._persist_queue.join() - handler.send_lock.acquire() - send_lock_acquired = True - with self.clients_lock: - self.receivers.add(handler) - receiver_registered = True - replay_end = self.store.get_max_seq() - epoch, _latest_seq = self.get_replay_token() - # Absent identity fields preserve legacy explicit snapshot cursors. - prefix_mismatch = sync_from > 1 and replay_server_instance is not None and ( - replay_server_instance != self.server_instance or replay_epoch != epoch - ) - resync_reason = None - if prefix_mismatch or sync_from > replay_end + 1: - resync_reason = "replay_identity_changed" if prefix_mismatch else "seq_overflow" - sync_from = 1 - with self.stage_lock: - layer_stack_state = ( - encode_message(self.get_layer_stack_state()) - if self.layer_mode is LayerMode.MANAGED - and getattr(handler, "_layered_replay", False) else None - ) - # Freeze only the selected suffix. Bytes are retained as-is; - # framing stays chunked and happens after the barrier releases. - records = tuple(self.store.get_from_seq_bin(sync_from, replay_end)) - replay = _ReceiverReplay( - replay_end, epoch, records, layer_stack_state, resync_reason, - ) - except Exception: - if receiver_registered: - with self.clients_lock: - self.receivers.discard(handler) - if send_lock_acquired: - handler.send_lock.release() - raise - finally: - self.txn_barrier.release_exclusive() - - try: - yield replay - finally: - handler.send_lock.release() + # -- Receiver admission, replay, and live delivery -- def register_client( self, @@ -2272,96 +759,108 @@ def unregister_client(self, address: tuple): with self.clients_lock: self.clients.pop(key, None) - def broadcast_transaction_views( - self, - records: list[tuple[dict, bytes]], - ) -> None: - """Deliver one authored transaction to the complete commit stream.""" - self.broadcast_transaction_group_views([records]) + def reserve_receiver_replay_mode(self, layered: bool) -> tuple[bool, str]: + """Atomically admit a receiver under the current layer-stack contract.""" + return self._collaboration.reserve_receiver_replay_mode(layered) - def broadcast_transaction_group_views( + def release_receiver_replay_mode(self, layered: bool) -> None: + """Release a replay-mode reservation when a receiver disconnects.""" + self._collaboration.release_receiver_replay_mode(layered) + + @contextmanager + def receiver_replay_window( self, - transactions: list[list[tuple[dict, bytes]]], - ) -> None: - """Deliver a committed group as one complete ordered stream. + handler, + sync_from: int = 1, + *, + replay_server_instance: str | None = None, + replay_epoch: int | None = None, + ): + """Register a receiver at a stable replay-to-live boundary. - Every receiver consumes every durable USD record, including records - authored by the same origin. Origin is diagnostic metadata rather than - a delivery filter. This gives live delivery the same contract as replay - and lets every replica apply the server's total order. + The exclusive barrier is held only long enough to make prior realtime + writes durable, capture immutable records and routing with their + watermark, and join the live receiver set. Only the handler's send + lock remains held during delivery, so newer broadcasts follow replay + without network I/O holding the maintenance barrier. """ - transactions = [records for records in transactions if records] - if not transactions: - return - - all_records = [ - record - for records in transactions - for record, _encoded in records - ] - all_payload = frame_batch( - [ - encoded - for records in transactions - for _record, encoded in records - ] - ) - if self.layer_mode is LayerMode.SHARED_STAGE: - self.broadcast_bytes(all_payload, all_records) - return - - # Keep the managed-mode receiver cohorts separate. They consume the - # same complete commit stream, while retaining independently captured - # target sets for their different replay/application contracts. - has_flat_receivers, has_layered_receivers = self._receiver_audience_presence() - if has_layered_receivers: - self.broadcast_bytes( - all_payload, - all_records, - audience=_AUDIENCE_LAYERED, - notify_listeners=False, - ) - if has_flat_receivers: - self.broadcast_bytes( - all_payload, - all_records, - audience=_AUDIENCE_FLAT, - notify_listeners=False, - ) - self._notify_event_listeners(all_records) - - @staticmethod - def _validate_audience(audience: str) -> None: - if audience not in _AUDIENCES: - raise ValueError(f"unknown receiver audience {audience!r}") + with ExitStack() as replay_lock: + with self.txn_barrier.exclusive(): + self._journal.drain() + replay_lock.enter_context(handler.send_lock) + with self.clients_lock: + self.receivers.add(handler) + try: + replay_end = self.store.get_max_seq() + epoch, _latest_seq = self.get_replay_token() + # Absent identity fields preserve legacy explicit snapshot cursors. + resume_identity_changed = ( + sync_from > 1 + and replay_server_instance is not None + and ( + replay_server_instance != self.server_instance or replay_epoch != epoch + ) + ) + cursor_out_of_range = sync_from > replay_end + 1 + resync_reason = None + if resume_identity_changed or cursor_out_of_range: + resync_reason = ( + "replay_identity_changed" if resume_identity_changed else "seq_overflow" + ) + sync_from = 1 + with self.stage_lock: + layer_stack_state = ( + encode_message(self.get_layer_stack_state()) + if self.layer_mode is LayerMode.MANAGED + and handler._layered_replay else None + ) + # Freeze only the selected suffix. Bytes are retained as-is; + # framing stays chunked and happens after the barrier releases. + records = tuple(self.store.get_from_seq_bin(sync_from, replay_end)) + replay = _ReceiverReplay( + head_seq=replay_end, epoch=epoch, records=records, + layer_stack_state=layer_stack_state, resync_reason=resync_reason, + ) + except Exception: + with self.clients_lock: + self.receivers.discard(handler) + raise + yield replay - def _receiver_audience_presence(self) -> tuple[bool, bool]: - """Return whether flat and layered receivers are connected.""" - with self.clients_lock: - has_flat = False - has_layered = False - for handler in self.receivers: - if getattr(handler, "_layered_replay", False): - has_layered = True - else: - has_flat = True - if has_flat and has_layered: - break - return has_flat, has_layered + def replay_from( + self, + handler, + seq_start: int, + *, + seq_end: int | None = None, + ): + """Replay events from the event store in an inclusive sequence range. - def _has_layered_receivers(self) -> bool: - return self._receiver_audience_presence()[1] + All events are replayed regardless of origin, matching live delivery. + The receiver needs its own prior edits in the complete server order to + restore and converge state. + Layered receivers get every persisted authored record with its logical + layer target. Flat receivers are admitted only for a single unmuted + collaboration layer, so both modes replay stored records directly. + """ + blobs = self.store.get_from_seq_bin(seq_start, seq_end) + self.replay_records(handler, blobs) - def _notify_event_listeners(self, records: list[dict]) -> None: - """Notify in-process event observers once, in record order.""" - for listener in list(self._event_listeners): - for rec in records: - try: - listener(rec) - except Exception: - LOG.exception("Event listener failed, removing") - self._event_listeners.remove(listener) - break + def replay_records(self, handler, records: Iterable[bytes]) -> None: + """Send already captured records without rereading mutable history.""" + _REPLAY_CHUNK = 65536 + buf_parts: list[bytes] = [] + buf_size = 0 + for blob in records: + framed = frame_batch([blob]) + buf_parts.append(framed) + buf_size += len(framed) + if buf_size >= _REPLAY_CHUNK: + handler.request.sendall(b"".join(buf_parts)) + buf_parts.clear() + buf_size = 0 + if buf_parts: + handler.request.sendall(b"".join(buf_parts)) def broadcast( self, @@ -2386,23 +885,19 @@ def broadcast_batch( ): """Enqueue records for async broadcast to all receivers. - The actual network sends happen on the dedicated broadcast thread, - so the calling emitter thread is never blocked by slow receivers. + Network sends happen on the dedicated broadcast thread. The caller + waits for queue capacity if receiver delivery falls behind. """ if not records: return - self._validate_audience(audience) - framed_payloads = [encode_message(rec) for rec in records] + encoded_messages = [encode_message(rec) for rec in records] if self.wire_metrics is not None: - for rec, buf in zip(records, framed_payloads, strict=True): + for rec, buf in zip(records, encoded_messages, strict=True): kind = rec.get("event", {}).get("k") or rec.get("type", "") self.wire_metrics.record(kind, len(buf)) - payload = frame_batch(framed_payloads) - self._enqueue_broadcast(payload, exclude_origin, audience) - # Notify event listeners synchronously these are in-process - # callbacks (e.g. dashboard) that are fast and must see events - # in order. - self._notify_event_listeners(records) + self.broadcast_bytes( + frame_batch(encoded_messages), records, exclude_origin, audience=audience, + ) def broadcast_bytes( self, @@ -2414,7 +909,6 @@ def broadcast_bytes( notify_listeners: bool = True, ): """Enqueue pre-framed payload for broadcast and notify listeners.""" - self._validate_audience(audience) self._enqueue_broadcast(payload, exclude_origin, audience) if not notify_listeners: return @@ -2433,7 +927,6 @@ def broadcast_message( signals, not USD scene events, so they shouldn't appear in the dashboard event log. """ - self._validate_audience(audience) payload = frame_batch([encode_message(msg)]) self._enqueue_broadcast(payload, exclude_origin, audience) @@ -2448,7 +941,7 @@ def _enqueue_broadcast( audience=audience, ) if targets: - self._broadcast_queue.put((payload, targets, exclude_origin, audience)) + self._broadcast_queue.put((payload, targets)) def _broadcast_loop(self): """Dedicated thread: drain the broadcast queue and send to receivers. @@ -2457,54 +950,34 @@ def _broadcast_loop(self): Exits cleanly when a None sentinel is enqueued via shutdown(). """ _ping_payload = frame_batch([encode_message({"type": MSG_PING})]) - + stopping = False while True: try: - item = self._broadcast_queue.get(timeout=_PING_INTERVAL) + item = ( + self._broadcast_queue.get_nowait() if stopping + else self._broadcast_queue.get(timeout=_PING_INTERVAL) + ) except queue.Empty: + if stopping: + return # Idle send pings to detect dead receivers - self._send_to_all(_ping_payload) + self._send_to_receivers(_ping_payload) continue - if item is None: - # Drain remaining items before exiting - while True: - try: - remaining = self._broadcast_queue.get_nowait() - except queue.Empty: - break - if remaining is None: - continue - payload, targets, exclude_origin, audience = remaining - try: - self._send_to_all( - payload, - exclude_origin=exclude_origin, - audience=audience, - targets=targets, - ) - except Exception: - LOG.exception("Error draining broadcast queue") - return - - payload, targets, exclude_origin, audience = item try: - self._send_to_all( - payload, - exclude_origin=exclude_origin, - audience=audience, - targets=targets, - ) + if item is None: + stopping = True + else: + payload, targets = item + self._send_to_receivers(payload, targets=targets) except Exception: LOG.exception("Unexpected error in broadcast loop") finally: self._broadcast_queue.task_done() - def _send_to_all( + def _send_to_receivers( self, payload: bytes, - exclude_origin: str | None = None, - audience: str = _AUDIENCE_ALL, targets: tuple | None = None, ): """Send payload to matching receivers, removing dead ones. @@ -2515,10 +988,7 @@ def _send_to_all( dead handler's recv loop exits separately once keepalive expires. """ if targets is None: - targets = self._receiver_targets( - exclude_origin=exclude_origin, - audience=audience, - ) + targets = self._receiver_targets() dead = [] for h in targets: try: @@ -2541,19 +1011,25 @@ def _receiver_targets( audience: str = _AUDIENCE_ALL, ) -> tuple: """Capture receivers matching one broadcast's delivery contract.""" + if audience not in _AUDIENCES: + raise ValueError(f"unknown receiver audience {audience!r}") with self.clients_lock: targets = [] for handler in self.receivers: - layered = bool(getattr(handler, "_layered_replay", False)) + layered = handler._layered_replay if audience == _AUDIENCE_LAYERED and not layered: continue if audience == _AUDIENCE_FLAT and layered: continue - if exclude_origin and getattr(handler, "_origin", None) == exclude_origin: + if exclude_origin and handler._origin == exclude_origin: continue targets.append(handler) return tuple(targets) + def _has_layered_receivers(self) -> bool: + with self.clients_lock: + return any(handler._layered_replay for handler in self.receivers) + def _discard_unreachable_receivers(self, handlers: list) -> None: """Remove failed receiver sockets and release their server-side roles.""" if not handlers: @@ -2563,960 +1039,444 @@ def _discard_unreachable_receivers(self, handlers: list) -> None: self.receivers.discard(handler) released_any = False for handler in handlers: - release_replay = getattr( - handler, - "release_receiver_replay_reservation", - None, - ) - if release_replay is not None: - release_replay() - client_id = getattr(handler, "_client_id", "") or "" - if client_id and self.release_playback(client_id): + handler.release_receiver_replay_reservation() + if handler._client_id and self.release_playback(handler._client_id): released_any = True if released_any: self.broadcast_message( {"type": MSG_PLAYBACK_STATE, **self.get_playback_state()}, ) - def _persist_loop(self): - """Dedicated thread for realtime durability: drain persistence queue - and write to SQLite without blocking emitter threads. - Exits cleanly when a None sentinel is enqueued via shutdown().""" - while True: - tuples = self._persist_queue.get() - if tuples is None: - # Drain remaining items before exiting - while True: - try: - remaining = self._persist_queue.get_nowait() - except queue.Empty: - break - if remaining is None: - continue - try: - self.store.append_batch(remaining) - with self._seq_lock: - self._event_count += len(remaining) - except Exception: - LOG.exception("Error draining persist queue") - return + def add_event_listener(self, callback) -> None: + """Subscribe to broadcast events. Callback receives the event record dict.""" + self._event_listeners.append(callback) + + def remove_event_listener(self, callback) -> None: + """Unsubscribe from broadcast events.""" + if callback in self._event_listeners: + self._event_listeners.remove(callback) + + def _notify_event_listeners(self, records: list[dict]) -> None: + """Notify in-process event observers once, in record order.""" + for listener in list(self._event_listeners): + for rec in records: + try: + listener(rec) + except Exception: + LOG.exception("Event listener failed, removing") + self._event_listeners.remove(listener) + break + + # -- History replacement and snapshot identity -- + + def compact_log(self): + """Compact a captured history, then merge later writes if it is still current.""" + # Capture the identity and rows together. Producers can keep appending; + # history replacement must wait until this short capture finishes. + with self.txn_barrier.shared(): + replay_epoch, _head = self.get_replay_token() + rows = self.store.get_all_asc() + if not rows: + return + max_seq = rows[-1][0] + # The expensive reduction holds no barrier, so producers keep running. + compaction = self._maintenance.build_compacted(rows) + original_count = len(rows) + + with self._maintenance.rewrite_window(): + current_epoch, _head = self.get_replay_token() + if current_epoch != replay_epoch: + # Purge, snapshot replacement, or another compaction has already + # installed a new history. Its sequences cannot extend this one. + LOG.info("Skipped compaction because replay history changed") + return + # Append-only changes belong to the same history and can be merged. + delta = self.store.get_from_seq_asc(max_seq + 1) + for seq, record_bin in delta: + compaction.add_stored_record(seq, record_bin) + original_count += len(delta) + + self._maintenance.commit_compaction(compaction, original_count) + self._resync_receivers() + + def purge(self): + """Clear all events, reset the edit layer, and resync receivers.""" + if self.layer_mode is LayerMode.SHARED_STAGE: + raise RuntimeError( + "purge is unavailable in shared-stage mode because application-owned " + "authored opinions are not server-owned" + ) + with self._maintenance.rewrite_window(): + self._maintenance.purge() + self._resync_receivers() + + def _resync_receivers(self) -> None: + """Send replaced history while the caller holds the exclusive maintenance window.""" + with self.clients_lock: + targets = list(self.receivers) + if not targets: + return + replay_epoch, replay_head = self.get_replay_token() + reset_bin = encode_message({"type": MSG_RESYNC}) + complete_bin = encode_message({ + "type": MSG_REPLAY_COMPLETE, "head_seq": replay_head, "epoch": replay_epoch, + }) + disconnected = [] + for handler in targets: try: - self.store.append_batch(tuples) - with self._seq_lock: - self._event_count += len(tuples) - except Exception: - LOG.exception("Unexpected error in persist loop") - finally: - self._persist_queue.task_done() + with handler.send_lock: + controls = [reset_bin] + if handler._layered_replay: + controls.append(encode_message(self.get_layer_stack_state())) + # Keep reset, replay and completion in one send-lock window. + # Empty history needs only the control messages. + if replay_head: + handler.request.sendall(frame_batch(controls)) + self.replay_from(handler, 1, seq_end=replay_head) + controls.clear() + controls.append(complete_bin) + handler.request.sendall(frame_batch(controls)) + except (OSError, TimeoutError): + LOG.info( + "Receiver disconnected during history resync: %s", + handler.client_address, + ) + disconnected.append(handler) + self._discard_unreachable_receivers(disconnected) - def apply_txn( + def replace_from_stage_snapshot( self, - events: list[dict], - layer: Sdf.Layer | None = None, + uploaded_stage: Usd.Stage, *, - update_tracking: bool = True, - ) -> None: - """Apply a transaction to the stage. + client_id: str = "vfs-write", + origin: str = "vfs-write", + reject_stale: bool = True, + reject_ambiguous: bool = True, + unchanged_snapshot: bool = False, + ) -> int: + """Replace the live edit state from a complete uploaded USD snapshot. - Layer opinions are authored into *layer* (defaults to - ``self.edit_layer``). Shared session metadata and stage-state events - are applied under the primary session edit target. + This is the WebDAV write-fallback path. A plain DCC save gives us a + complete USD layer, not semantic incremental events, so the server + translates it into a full event snapshot, resets the live edit log, + broadcasts a resync, then broadcasts the translated events. + + Returns the number of translated events persisted to the event log. """ - validate_events(events, layer_mode=self.layer_mode) - self._apply_validated_txn( - events, - layer=layer, - update_tracking=update_tracking, + uploaded_meta = read_snapshot_metadata(uploaded_stage) + with self.txn_barrier.exclusive(): + epoch, seq = self.get_snapshot_token() + with self.stage_lock: + current = SnapshotState( + scene_id=self.scene_id, + epoch=epoch, + seq=seq, + prim_types=inspection.read_prim_types(self.stage), + non_default_layers=tuple( + key for key in self.layer_stack.layer_keys + if key != _DEFAULT_LAYER_KEY + ), + ) + try: + analysis = validate_stage_snapshot( + uploaded_stage, uploaded_meta, current, + reject_stale=reject_stale, + reject_ambiguous=reject_ambiguous, + ) + except VfsWriteRejectedError as exc: + self.last_vfs_write_analysis = exc.analysis.to_dict() + raise + if unchanged_snapshot: + self.last_vfs_write_analysis = replace( + analysis, + status="unchanged", + notes=["uploaded bytes match the current virtual snapshot"], + ).to_dict() + LOG.info("VFS snapshot write is unchanged; no events generated") + return 0 + + with self.stage_lock: + replacement_stage = create_replacement_stage(self.stage) + prepared = prepare_stage_snapshot( + uploaded_stage, analysis.removed_prims, + replacement_stage=replacement_stage, + ) + events = prepared.events + analysis = replace(analysis, event_counts=dict(Counter(event["k"] for event in events))) + + encoded = self._maintenance.commit_snapshot( + prepared, client_id=client_id, origin=origin, + ) + self.last_vfs_write_analysis = analysis.to_dict() + self._publish_snapshot(encoded.records) + LOG.info( + "Translated VFS snapshot write into %d live events " + "(created=%d removed=%d type_changed=%d)", + len(events), + len(analysis.created_prims), + len(analysis.removed_prims), + len(analysis.type_changed_prims), + ) + return len(events) + + def _publish_snapshot(self, records: Sequence[EncodedRecord]) -> None: + """Enqueue the replacement while the caller still excludes transactions.""" + # One queue item keeps reset, records, and completion together for the + # same receivers. Completion restores readiness even for an empty scene. + replay_epoch, replay_head = self.get_replay_token() + resync_bin = encode_message({"type": MSG_RESYNC, "reason": "vfs-write"}) + complete_bin = encode_message({ + "type": MSG_REPLAY_COMPLETE, "head_seq": replay_head, "epoch": replay_epoch, + }) + record_dicts = [record for record, _record_bin in records] + record_bins = [record_bin for _record, record_bin in records] + if self.wire_metrics is not None: + self.wire_metrics.record(MSG_RESYNC, len(resync_bin)) + for record, record_bin in records: + self.wire_metrics.record(record["event"]["k"], len(record_bin)) + self.wire_metrics.record(MSG_REPLAY_COMPLETE, len(complete_bin)) + self.broadcast_bytes( + frame_batch([resync_bin, *record_bins, complete_bin]), record_dicts, ) - def _apply_validated_txn( - self, - events: list[dict], - layer: Sdf.Layer | None = None, - *, - update_tracking: bool = True, - ) -> None: - """Apply events admitted by a public transaction boundary.""" - from ..event_apply import apply_events + def get_snapshot_token(self) -> tuple[int, int]: + return self._journal.snapshot_token() - target = layer or self.edit_layer + def get_replay_token(self) -> tuple[int, int]: + return self._journal.replay_token() - with self.stage_lock: - edit_target = Usd.EditTarget(target) - session_target = Usd.EditTarget(self.stage.GetSessionLayer()) - has_layer_opinions = any(ev["k"] not in NON_COLLABORATION_KINDS for ev in events) - target_was_muted = self.stage.IsLayerMuted(target.identifier) - was_muted = has_layer_opinions and target_was_muted - if was_muted: - self.stage.UnmuteLayer(target.identifier) - try: - for ev in events: - if ev["k"] != K_SET_SDF_SPEC_FIELDS or not ev["removed"]: - continue - event_path = Sdf.Path(ev["spec_path"]) - spec = target.GetObjectAtPath(event_path) - if spec: - ev["fields"] = sorted( - set(ev["fields"]) | {str(key) for key in spec.ListInfoKeys()} - ) + def bump_snapshot_epoch(self, reason: str = "") -> None: + """Invalidate cached virtual-file snapshots after non-log stage changes.""" + epoch = self._journal.bump_snapshot_epoch() + if reason: + LOG.debug("Snapshot epoch bumped to %d (%s)", epoch, reason) + else: + LOG.debug("Snapshot epoch bumped to %d", epoch) - start = 0 - while start < len(events): - non_collaboration = events[start]["k"] in NON_COLLABORATION_KINDS - end = start + 1 - while ( - end < len(events) - and (events[end]["k"] in NON_COLLABORATION_KINDS) == non_collaboration - ): - end += 1 - run = events[start:end] - run_target = session_target if non_collaboration else edit_target - run_layer = self.stage.GetSessionLayer() if non_collaboration else target - self.stage.SetEditTarget(run_target) - apply_events( - self.stage, - run, - op_cache=self._op_cache_for(run_layer), - prevalidated=True, - ) - start = end - finally: - if target_was_muted: - self.stage.SetEditTarget(session_target) - else: - self.stage.SetEditTarget(edit_target) - if was_muted: - self.stage.MuteLayer(target.identifier) + def set_compact_interval(self, seconds: float) -> None: + """Set the periodic compaction interval in seconds (0 disables). - if update_tracking: - self._update_prim_tracking(events) + Takes effect immediately: the compaction thread re-reads the + interval on wake, so shortening, lengthening, and disabling all + apply without waiting out the previous period. + """ + self._compactor.set_compact_interval(seconds) - def process_idempotent_txn( - self, - events: list[dict], - *, - session_id: str, - txn_id: int, - client_id: str, - origin: str | None = None, - client_addr: str | None = None, - layer: Sdf.Layer | None = None, - layer_key: str = "", - ) -> TransactionCommit: - """Commit once for an ordered producer session and return its result.""" - request = self.submit_idempotent_txn( - events, - session_id=session_id, - txn_id=txn_id, - client_id=client_id, - origin=origin, - client_addr=client_addr, - layer=layer, - layer_key=layer_key, - ) - return self.wait_for_transaction(request) + def get_compact_interval(self) -> float: + return self._compactor.get_compact_interval() - def submit_idempotent_txn( - self, - events: list[dict], - *, - session_id: str, - txn_id: int, - client_id: str, - origin: str | None = None, - client_addr: str | None = None, - layer: Sdf.Layer | None = None, - layer_key: str = "", - ) -> _TransactionRequest: - """Submit without waiting; the coordinator owns its maintenance barrier.""" - if not client_id or not session_id or len(session_id) > 128 or txn_id < 1: - raise TransactionRejectedError( - "invalid_identity", - "client_id and session_id are required and txn_id must be positive", - ) - validate_events(events, layer_mode=self.layer_mode) + def set_reclaim_interval(self, seconds: float) -> None: + """Set the storage-reclaim interval in seconds (0 disables). - if self._transaction_stopping: - raise RuntimeError("transaction coordinator is shutting down") + Reclaim runs at compaction and purge commits, so an enabled + interval needs compaction (periodic or manual) to take effect. + """ + self._maintenance.set_reclaim_interval(seconds) - request = _TransactionRequest( - events=events, - session_id=session_id, - txn_id=txn_id, - client_id=client_id, - origin=origin, - client_addr=client_addr, - layer=layer, - layer_key=layer_key, - ) - # Maintenance cannot pass this request while it is queued, applying, - # persisting, or publishing. Ownership transfers to the coordinator, - # which releases the shared barrier immediately before setting done. - self.txn_barrier.acquire_shared() - if self._transaction_queue is None: - try: - self._commit_one_transaction_request(request) - finally: - self._complete_transaction_requests((request,)) - return request - try: - self._transaction_queue.put(request) - except BaseException: - self.txn_barrier.release_shared() - raise - return request + def get_reclaim_interval(self) -> float: + return self._maintenance.get_reclaim_interval() - def _complete_transaction_requests(self, requests: Iterable[_TransactionRequest]) -> None: - """Share one durable checkpoint before releasing the batch's maintenance barrier.""" - checkpoint = None - checkpoint_attempted = False - for request in requests: - try: - commit = request.commit - if request.error is None and commit is not None and commit.status == "committed": - # Every commit in this batch is already durable. One head covers - # them all; reservations may roll back, so query persisted history. - if not checkpoint_attempted: - checkpoint_attempted = True - checkpoint = TransactionCheckpoint( - epoch=self._replay_epoch, - head_seq=self.store.get_max_seq(), - ) - if checkpoint is not None: - request.commit = replace(commit, checkpoint=checkpoint) - # Duplicates prove producer progress, not their original replay epoch. - except Exception: - LOG.exception("Could not capture transaction visibility checkpoint") - finally: - self.txn_barrier.release_shared() - request.done.set() + # -- Collaboration layers -- - @staticmethod - def wait_for_transaction(request: _TransactionRequest) -> TransactionCommit: - """Wait for a previously submitted transaction's terminal outcome.""" - request.done.wait() - if request.error is not None: - raise request.error - if request.commit is None: - raise RuntimeError("transaction coordinator returned no result") - return request.commit - - def _process_idempotent_txn_now( + @property + def department_priority(self) -> list[str]: + return self._collaboration.department_priority + + @department_priority.setter + def department_priority(self, ordered_departments: list[str]) -> None: + self._collaboration.department_priority = ordered_departments + + @property + def client_layers(self) -> dict[str, Sdf.Layer]: + """Return a snapshot of client assignments to shared collaboration layers.""" + return self._collaboration.client_layers + + def get_or_create_client_layer( self, - events: list[dict], - *, - session_id: str, - txn_id: int, client_id: str, - origin: str | None = None, - client_addr: str | None = None, - layer: Sdf.Layer | None = None, - layer_key: str = "", - ) -> TransactionCommit: - """Execute one transaction synchronously on the commit worker.""" - - with self._transaction_commit_lock: - committed_through = self._producer_progress_locked(client_id, session_id) - if txn_id <= committed_through: - return TransactionCommit("duplicate", committed_through) - - expected = committed_through + 1 - if txn_id != expected: - raise TransactionRejectedError( - "unexpected_id", - f"expected transaction {expected}, received {txn_id}", - expected_txn_id=expected, - ) + department: str | None = None, + ) -> Sdf.Layer: + """Get or create a layer for this client. - records = self._process_txn_locked( - events, - client_id=client_id, - origin=origin, - client_addr=client_addr, - layer=layer, - layer_key=layer_key, - transaction_identity=(session_id, txn_id), - ) - self._producer_progress_cache[(client_id, session_id)] = txn_id - commit = TransactionCommit( - "committed", - txn_id, - tuple(records), - ) - return self._publish_transaction_commit( - commit, - events=events, - session_id=session_id, - txn_id=txn_id, - origin=origin, - ) + With department: clients share a department layer (last-write-wins + within the department, department priority controls strength). + Without department: uses the shared edit_layer (weakest, last-write-wins). + """ + return self._collaboration.get_or_create_client_layer(client_id, department) - def _publish_transaction_commit( - self, - commit: TransactionCommit, - *, - events: list[dict], - session_id: str, - txn_id: int, - origin: str | None, - ) -> TransactionCommit: - """Enqueue one durable commit before releasing global commit order.""" - try: - self.broadcast_transaction_views(list(commit.records)) - for event in events: - if event.get("k") == K_LOAD_PAYLOAD: - self.replay_children_after_load(event["prim"]) - except Exception: - LOG.exception( - "Transaction %s/%d committed but its live broadcast failed", - session_id, - txn_id, - ) - return commit + def resolve_layer_key(self, key: str) -> str | None: + """Resolve a client ID, department name, or layer key.""" + return self._collaboration.resolve_layer_key(key) - def _transaction_batch_loop(self) -> None: - """Collect a bounded set of producer transactions for one DB commit.""" - transaction_queue = self._transaction_queue - if transaction_queue is None: - return - stop = False - while not stop: - first = transaction_queue.get() - if first is None: - break - requests = [first] - deadline = time.monotonic() + self.txn_batch_delay - while len(requests) < self.txn_batch_size: - try: - request = transaction_queue.get_nowait() - except queue.Empty: - if time.monotonic() >= deadline: - break - # Sub-millisecond Queue.get timeouts round up to the OS - # scheduler quantum on Windows. A cooperative zero sleep - # gives connection threads a chance to enqueue without a - # 10-16 ms interactive-latency penalty. - time.sleep(0) - continue - if request is None: - stop = True - break - requests.append(request) - self._execute_transaction_requests(requests) + def resolve_layer(self, key: str) -> Sdf.Layer | None: + """Resolve a layer by client ID, department name, or layer key.""" + return self._collaboration.resolve_layer(key) - def _execute_transaction_requests( - self, - requests: list[_TransactionRequest], - ) -> None: - try: - if self.layer_mode is LayerMode.SHARED_STAGE: - for request in requests: - self._commit_one_transaction_request(request) - else: - try: - self._commit_managed_transaction_group(requests) - except Exception: - # The group failure boundary restored every USD layer and - # sequence reservation. Re-run individually so one invalid - # payload or storage failure does not reject its neighbors. - LOG.debug( - "Grouped transaction commit failed; retrying individually", - exc_info=True, - ) - for request in requests: - self._commit_one_transaction_request(request) - finally: - self._complete_transaction_requests(requests) - - def _commit_one_transaction_request(self, request: _TransactionRequest) -> None: - request.commit = None - request.error = None - try: - request.commit = self._process_idempotent_txn_now( - request.events, - session_id=request.session_id, - txn_id=request.txn_id, - client_id=request.client_id, - origin=request.origin, - client_addr=request.client_addr, - layer=request.layer, - layer_key=request.layer_key, - ) - except Exception as exc: - request.error = exc + def department_for_layer(self, layer: Sdf.Layer) -> str | None: + """Return department policy metadata for a managed layer.""" + return self._collaboration.department_for_layer(layer) - def _commit_managed_transaction_group( - self, - requests: list[_TransactionRequest], - ) -> None: - """Commit queued managed transactions through one failure boundary.""" - with self._transaction_commit_lock: - accepted: list[_TransactionRequest] = [] - next_by_session: dict[tuple[str, str], int] = {} - - for request in requests: - producer = (request.client_id, request.session_id) - committed_through = next_by_session.get(producer) - if committed_through is None: - committed_through = self._producer_progress_locked(*producer) - if request.txn_id <= committed_through: - request.commit = TransactionCommit("duplicate", committed_through) - continue + def mute_layer(self, key: str) -> bool: + """Mute a layer by client_id or department opinions hidden but preserved.""" + return self._collaboration.set_muted(key, True) - expected = committed_through + 1 - if request.txn_id != expected: - request.error = TransactionRejectedError( - "unexpected_id", - f"expected transaction {expected}, received {request.txn_id}", - expected_txn_id=expected, - ) - continue - next_by_session[producer] = request.txn_id - accepted.append(request) + def unmute_layer(self, key: str) -> bool: + """Unmute a layer by client_id or department.""" + return self._collaboration.set_muted(key, False) - if not accepted: - return + def merge_layer(self, client_id: str) -> bool: + """Merge the client's department opinions into the root layer. - with self._managed_sequence_reservation(): - prepared = [ - self._prepare_managed_transaction( - request.events, - client_id=request.client_id, - origin=request.origin, - client_addr=request.client_addr, - layer=request.layer, - layer_key=request.layer_key, - transaction_identity=(request.session_id, request.txn_id), - ) - for request in accepted - ] - self._persist_managed_transactions(prepared) - - self._producer_progress_cache.update(next_by_session) - for request, transaction in zip(accepted, prepared, strict=True): - records = tuple(transaction.records) - commit = TransactionCommit( - "committed", - request.txn_id, - records, - ) - request.commit = commit - # Persistence and live enqueue are one ordering boundary. Keep - # this inside _transaction_commit_lock so another transaction can - # neither reserve a later sequence nor publish ahead of the group. - self._broadcast_grouped_transactions(requests) - - def _producer_progress_locked(self, client_id: str, session_id: str) -> int: - """Return durable producer progress while the commit lock is held.""" - producer = (client_id, session_id) - cached = self._producer_progress_cache.get(producer) - if cached is None: - cached = self.store.get_producer_progress(client_id, session_id) - self._producer_progress_cache[producer] = cached - return cached + Releases this client; the department layer remains while other clients + use it. Existing root opinions on sibling prims are preserved. + Returns False for clients on the shared edit_layer (no-op). + """ + return self._collaboration.release_client_assignment(client_id, merge_into_root=True) - def producer_committed_through(self, client_id: str, session_id: str) -> int: - """Return the cumulative durable acknowledgement for a handshake.""" - with self._transaction_commit_lock: - return self._producer_progress_locked(client_id, session_id) + def delete_layer(self, client_id: str) -> bool: + """Release a client's department assignment. - @contextmanager - def _managed_sequence_reservation(self): - """Roll back sequence reservations and op setup if a managed commit fails. + The department layer is discarded only when its last client leaves. - The caller holds the commit lock throughout preparation and persistence. - USD rollback is handled by _persist_managed_transactions. + Returns False for clients on the shared edit_layer (no-op). """ - first_reserved_seq = self._next_seq - try: - yield - except Exception: - with self._seq_lock: - self._next_seq = first_reserved_seq - self.op_cache.clear() - self._op_cache_layer = None - raise + return self._collaboration.release_client_assignment(client_id, merge_into_root=False) - def _prepare_managed_transaction( - self, - events: list[dict], - *, - client_id: str | None, - origin: str | None, - client_addr: str | None, - layer: Sdf.Layer | None, - layer_key: str, - transaction_identity: tuple[str, int] | None, - ) -> _PreparedTransaction: - """Check routing and encode previously validated managed events. - - Requires the commit lock and _managed_sequence_reservation so failures - in this method or subsequent persistence release the reserved sequences. - """ - if layer_key: - raise ValueError("managed transactions cannot select an arbitrary layer key") - target_layer = layer or self.edit_layer - layer_key = self.layer_stack.key_for_layer(target_layer) - if layer_key is None and any( - event["k"] not in NON_COLLABORATION_KINDS for event in events - ): - raise ValueError("transaction target is not a managed collaboration layer") - - collaboration_paths = _managed_rollback_paths(events) - has_session_events = any( - event["k"] in NON_COLLABORATION_KINDS for event in events - ) - records, persist_tuples = self._encode_managed_txn_records( - events, - client_id=client_id, - origin=origin, - client_addr=client_addr, - layer_key=layer_key or "", - ) - progress = None - if transaction_identity is not None: - if not client_id: - raise ValueError("idempotent transaction persistence requires client_id") - session_id, txn_id = transaction_identity - progress = ProducerProgress(client_id, session_id, txn_id) - return _PreparedTransaction( - events, - target_layer, - collaboration_paths, - has_session_events, - records, - persist_tuples, - progress, + def set_department_priority(self, ordered_departments: list[str]) -> None: + """Set department priority ordering (strongest first).""" + self._collaboration.set_department_priority(ordered_departments) + + def get_layer_stack_state(self) -> dict: + """Return the portable collaboration stack for capable receivers.""" + with self.stage_lock: + return { + "type": MSG_LAYER_STACK_STATE, + **self.layer_stack.state(), + } + + def broadcast_layer_stack_state(self) -> None: + """Publish current logical layer order and muting.""" + if not self._has_layered_receivers(): + return + self.broadcast_message( + self.get_layer_stack_state(), + audience=_AUDIENCE_LAYERED, ) - def _persist_managed_transactions(self, prepared: list[_PreparedTransaction]) -> None: - """Apply and persist a group atomically while the commit lock is held. + def get_layer_stack_info(self) -> list[dict]: + """Return ordered layer stack info for the dashboard. - Snapshot only touched collaboration prims, plus session state when - needed. Internal commits also persist synchronously: rollback must - cover a storage failure even without producer progress. + Department policy metadata is projected over the generic stack. + Unused configured slots stay out of the dashboard until they have a + client or authored content, matching the existing product behavior. """ - if len(prepared) == 1: - transaction = prepared[0] - layer_paths = ( - ((transaction.target_layer, transaction.collaboration_paths),) - if transaction.collaboration_paths else () - ) - snapshot_session = transaction.has_session_events - records = transaction.persist_tuples - producer_progress = (transaction.progress,) if transaction.progress is not None else () - else: - paths_by_layer: dict[str, tuple[Sdf.Layer, set[str]]] = {} - snapshot_session = False - records = [] - progress_by_producer = {} - for transaction in prepared: - if transaction.collaboration_paths: - layer_id = transaction.target_layer.identifier - if layer_id not in paths_by_layer: - paths_by_layer[layer_id] = ( - transaction.target_layer, set(transaction.collaboration_paths) - ) - else: - _layer, paths = paths_by_layer[layer_id] - paths.update(transaction.collaboration_paths) - snapshot_session |= transaction.has_session_events - records.extend(transaction.persist_tuples) - if (progress := transaction.progress) is not None: - progress_by_producer[(progress.client_id, progress.session_id)] = progress - layer_paths = paths_by_layer.values() - producer_progress = tuple(progress_by_producer.values()) - - from ..event_apply import atomic_apply_layer + return self._collaboration.get_layer_stack_info() - with self.stage_lock: - original_target = self.stage.GetEditTarget() - try: - with ExitStack() as rollback: - for target_layer, paths in layer_paths: - rollback.enter_context(atomic_apply_layer(target_layer, paths)) - if snapshot_session: - rollback.enter_context( - atomic_apply_layer(self.stage.GetSessionLayer()) - ) - for transaction in prepared: - self._apply_validated_txn( - transaction.events, - layer=transaction.target_layer, - update_tracking=False, - ) - self.store.append_batch( - records, - producer_progress=producer_progress, - ) - for transaction in prepared: - self._update_prim_tracking(transaction.events) - with self._seq_lock: - self._event_count += len(records) - finally: - self.stage.SetEditTarget(original_target) + # -- Playback -- - def _broadcast_grouped_transactions( - self, - requests: list[_TransactionRequest], - ) -> None: - pending: list[_TransactionRequest] = [] + @property + def playback(self) -> dict: + return self._playback.state - def flush_pending() -> None: - if not pending: - return - try: - self.broadcast_transaction_group_views( - [ - list(request.commit.records) - for request in pending - ] - ) - except Exception: - LOG.exception( - "Committed transaction group could not be broadcast live" - ) - pending.clear() + @playback.setter + def playback(self, state: dict) -> None: + self._playback.state = state - for request in requests: - commit = request.commit - if commit is None or commit.status != "committed" or not commit.records: - continue - load_events = [ - event for event in request.events if event.get("k") == K_LOAD_PAYLOAD - ] - if not load_events: - pending.append(request) - continue + @property + def playback_lock(self): + return self._playback.lock - # Payload child replay must stay immediately after the transaction - # that loaded it, so it forms a boundary between broadcast groups. - flush_pending() - try: - self.broadcast_transaction_views(list(commit.records)) - for event in load_events: - self.replay_children_after_load(event["prim"]) - except Exception: - LOG.exception( - "Transaction %s/%d committed but its live broadcast failed", - request.session_id, - request.txn_id, - ) - flush_pending() + @playback_lock.setter + def playback_lock(self, lock) -> None: + self._playback.lock = lock - def _commit_events( + def get_stage_metadata_payload(self) -> dict: + """Return the stage's authored metadata snapshot for hello_ok.""" + return read_stage_metadata(self.stage) + + def get_playback_state(self) -> dict: + """Return a wire-shaped snapshot of the current playback state.""" + return self._playback.snapshot() + + def claim_playback( self, - events: list[dict], - *, - client_id: str | None = None, - origin: str | None = None, - client_addr: str | None = None, - layer: Sdf.Layer | None = None, - layer_key: str = "", - ) -> list[tuple[dict, bytes]]: - """Commit events for in-process tests and maintenance utilities. + client_id: str, + initial_time: float | None = None, + ) -> tuple[bool, str]: + """Claim leadership, optionally setting the initial playhead atomically. - Network producers must use :meth:`submit_idempotent_txn`. This private - helper deliberately omits producer identity and acknowledgements, but - still participates in the maintenance barrier, global commit order, - atomic USD rollback, and synchronous durable persistence. + Returns (granted, current_leader), including on rejection. """ - if not events: - return [] - validate_events(events, layer_mode=self.layer_mode) - self.txn_barrier.acquire_shared() - try: - with self._transaction_commit_lock: - return self._process_txn_locked( - events, - client_id=client_id, - origin=origin, - client_addr=client_addr, - layer=layer, - layer_key=layer_key, - ) - finally: - self.txn_barrier.release_shared() + return self._playback.claim(client_id, initial_time) - def _process_txn_locked( + def apply_playback_control( self, - events: list[dict], - *, - client_id: str | None = None, - origin: str | None = None, - client_addr: str | None = None, - layer: Sdf.Layer | None = None, - layer_key: str = "", - transaction_identity: tuple[str, int] | None = None, - ) -> list[tuple[dict, bytes]]: - """Apply validated events and durably persist them under the commit lock. + client_id: str, + action: str, + time_value: float = 0.0, + rate: float = 1.0, + ) -> tuple[bool, dict | str, str]: + """Return (accepted, new_state_or_reason, current_leader) for a control.""" + return self._playback.apply_control(client_id, action, time_value, rate) + + def release_playback(self, client_id: str) -> bool: + """Release a disconnected leader; return whether followers need an update.""" + return self._playback.release(client_id) - Each mode owns sequence rollback if preparation or persistence fails. + # -- Authentication -- - Returns encoded broadcast records in input order. Callers that only - need authoritative state plus a populated log may ignore the result. + def authenticate( + self, client_id: str | None, token: str | None, department: str | None = None + ) -> tuple[bool, str | None]: + """Authenticate a client using TOFU. - Collaboration records derive their portable layer key from the actual - edit target. Client policy selects that target before this method is - called; cached client metadata is not authoritative for persistence. + Returns (accepted, issued_token). + - First connect (no token stored): issues a new token → (True, new_token) + - Reconnect with valid token: accepted → (True, None) + - Reconnect with wrong/missing token: rejected → (False, None) + - Token not required: always accepted → (True, None) + - Token required and no client_id: rejected → (False, None) """ - if self.layer_mode is LayerMode.SHARED_STAGE: - if layer is not None: - raise ValueError("managed layer routing is unavailable in shared-stage mode") - return self._process_shared_txn( - events, - layer_key=layer_key, - client_id=client_id, - origin=origin, - client_addr=client_addr, - transaction_identity=transaction_identity, - ) - with self._managed_sequence_reservation(): - transaction = self._prepare_managed_transaction( - events, - client_id=client_id, - origin=origin, - client_addr=client_addr, - layer=layer, - layer_key=layer_key, - transaction_identity=transaction_identity, - ) - self._persist_managed_transactions([transaction]) - return transaction.records + if not self.require_token or not self.token_store: + return True, None - def _encode_managed_txn_records( - self, - events: list[dict], - *, - client_id: str | None, - origin: str | None, - client_addr: str | None, - layer_key: str, - ) -> tuple[ - list[tuple[dict, bytes]], - list[tuple[int, bytes, str | None, str | None, str | None]], - ]: - """Assign sequences and encode one managed transaction.""" - records = [] - persist_tuples = [] - for event in events: - record: dict = { - "type": MSG_EVENT, - "seq": self.assign_seq(), - "event": event, - "client": client_addr, - "client_id": client_id, - } - if origin: - record["origin"] = origin - if event["k"] not in NON_COLLABORATION_KINDS: - record["layer_key"] = layer_key - record_bin = self._broadcast_encoder.encode(record) - if self.wire_metrics is not None: - self.wire_metrics.record(event["k"], len(record_bin)) - records.append((record, record_bin)) - persist_tuples.append( - ( - record["seq"], - record_bin, - client_id, - event["k"], - event.get("prim"), - ) - ) - return records, persist_tuples + if not client_id: + LOG.warning("Auth rejected: missing client_id") + return False, None - def _process_shared_txn( - self, - events: list[dict], - *, - layer_key: str, - client_id: str | None, - origin: str | None, - client_addr: str | None, - transaction_identity: tuple[str, int] | None = None, - ) -> list[tuple[dict, bytes]]: - """Apply one validated authored-layer transaction against the current graph.""" - from ..event_apply import apply_events, atomic_apply + if not self.token_store.has_token(client_id): + # First connect issue token (TOFU) + new_token = self.token_store.issue(client_id, department) + return True, new_token - graph = self.shared_layer_graph - if graph is None: - raise RuntimeError("shared-stage transaction requires a layer graph") - if not layer_key: - raise ValueError("shared-stage transactions require layer_key") - target = graph.layer_for(layer_key) - if target is None or layer_key not in graph.reachable_layer_keys(): - raise TransactionRejectedError( - "stale_layer_graph", - f"unknown or unresolved shared layer key {layer_key!r}", - ) + if token and self.token_store.verify(client_id, token): + return True, None - prepared: PreparedSublayers | None = None - canonical_events = [] - try: - for event in events: - if event["k"] == K_SET_SUBLAYERS: - prepared = graph.canonicalize_sublayers(layer_key, event) - canonical = prepared.event - else: - canonical = dict(event) - canonical_events.append(canonical) - except StaleLayerGraphError as exc: - raise TransactionRejectedError("stale_layer_graph", str(exc)) from exc - - routed_events = [(layer_key, event) for event in canonical_events] - with self._seq_lock: - first_reserved_seq = self._next_seq - try: - with ( - self.stage_lock, - Usd.EditContext(self.stage, Usd.EditTarget(target)), - graph.transaction(), - ): - for event in canonical_events: - if event["k"] != K_SET_SDF_SPEC_FIELDS or not event["removed"]: - continue - path = Sdf.Path(event["spec_path"]) - spec = target.GetObjectAtPath(path) - if spec: - event["fields"] = sorted( - set(event["fields"]) - | {str(key) for key in spec.ListInfoKeys()} - ) - with atomic_apply(self.stage): - apply_events(self.stage, canonical_events, prevalidated=True) - if prepared is not None: - graph.accept_sublayers(prepared) - routed_events.extend(graph.discover_sublayer_states(prepared.mappings)) - records = self._persist_shared_events( - routed_events, - client_id=client_id, - origin=origin, - client_addr=client_addr, - transaction_identity=transaction_identity, - ) - except StaleLayerGraphError as exc: - with self._seq_lock: - self._next_seq = first_reserved_seq - raise TransactionRejectedError("stale_layer_graph", str(exc)) from exc - except Exception: - with self._seq_lock: - self._next_seq = first_reserved_seq - raise - self._prim_count_dirty = True - return records + LOG.warning("Auth rejected for %s invalid or missing token", client_id) + return False, None - def _persist_shared_events( - self, - routed_events: list[tuple[str, dict]], - *, - client_id: str | None, - origin: str | None, - client_addr: str | None, - transaction_identity: tuple[str, int] | None = None, - ) -> list[tuple[dict, bytes]]: - records = [] - persist_tuples = [] - for event_layer_key, event in routed_events: - record = { - "type": MSG_EVENT, - "seq": self.assign_seq(), - "event": event, - "client": client_addr, - "client_id": client_id, - "layer_key": event_layer_key, - } - if origin: - record["origin"] = origin - record_bin = self._broadcast_encoder.encode(record) - if self.wire_metrics is not None: - self.wire_metrics.record(event.get("k", ""), len(record_bin)) - records.append((record, record_bin)) - persist_tuples.append( - ( - record["seq"], - record_bin, - client_id, - event.get("k"), - event.get("prim"), - ) - ) - producer_progress: tuple[ProducerProgress, ...] = () - if transaction_identity is not None: - if not client_id: - raise ValueError("idempotent transaction persistence requires client_id") - session_id, txn_id = transaction_identity - producer_progress = (ProducerProgress( - client_id, - session_id, - txn_id, - ),) - topology_keys = set() - for event_layer_key, event in routed_events: - if event.get("k") != K_SET_SUBLAYERS: - continue - topology_keys.add(event_layer_key) - topology_keys.update( - entry["layer_key"] - for entry in event.get("sublayers", ()) - if entry.get("layer_key") - ) - layer_identities = ( - self._shared_layer_identity_updates(topology_keys) - if topology_keys - else () - ) - self.append_log_batch( - persist_tuples, - producer_progress=producer_progress, - layer_identities=layer_identities, - ) - return records + def revoke_token(self, client_id: str) -> bool: + """Revoke a client's token via dashboard/API.""" + if not self.token_store: + return False + return self.token_store.revoke(client_id) - def refresh_shared_layer_dependencies(self) -> tuple[str, ...]: - """Resolve newly available sublayers and publish their routing state.""" - graph = self.shared_layer_graph - if self.layer_mode is not LayerMode.SHARED_STAGE or graph is None: - raise RuntimeError("shared layer dependency refresh requires shared-stage mode") + def get_token_list(self) -> list[dict]: + """Return all token records for the dashboard.""" + if not self.token_store: + return [] + return self.token_store.get_all() - self.txn_barrier.acquire_shared() - try: - with self._transaction_commit_lock: - before = set(graph.reachable_layer_keys()) - with self.stage_lock, graph.transaction(): - Ar.GetResolver().RefreshContext(self.stage.GetPathResolverContext()) - routed_events = list(graph.refresh_resolved_sublayers()) - if routed_events: - records = self._persist_shared_events( - routed_events, - client_id=None, - origin=None, - client_addr=None, - ) - self.broadcast_transaction_views(records) - return tuple( - layer_key - for layer_key in graph.reachable_layer_keys() - if layer_key not in before - ) - finally: - self.txn_barrier.release_shared() + # -- Inspection and exports -- def get_prim_count(self) -> int: - """Return the number of prims on the composed stage (thread-safe, cached).""" - if self._prim_count_dirty: - with self.stage_lock: - self._prim_count = sum(1 for _ in self.stage.Traverse()) - self._prim_count_dirty = False - return self._prim_count + return self._scene.get_prim_count() def get_tracked_prim_count(self) -> int: - """Return the number of prims tracked (incremental, no log scan).""" - return len(self._prim_paths) + return self._scene.get_tracked_prim_count() def get_event_count(self) -> int: - """Return the number of events in the log (thread-safe, cached).""" - return self._event_count + return self._journal.event_count def get_wire_metrics(self) -> dict: """Encoded record bytes per event kind since startup. @@ -3579,114 +1539,28 @@ def get_server_info(self) -> dict: } def get_prim_tree(self) -> list[dict]: - """Build the prim tree from the incremental _prim_paths dict. - - No event log scan needed uses the in-memory prim tracking. - Instancing flags come from set lookups, and ``has_children`` - falls out of a single-pass parent count, so the tree refresh - stays linear and free of per-prim pxr calls. - """ - prims = dict(self._prim_paths) # snapshot - instanceable = set(self._instanceable_paths) - point_instancer = set(self._point_instancer_paths) - - # Parent path -> direct-child count. Builds in O(N) and turns the - # has_children lookup into O(1) per row. - child_counts: dict[str, int] = {} - for path in prims: - parent = path.rsplit("/", 1)[0] or "/" - child_counts[parent] = child_counts.get(parent, 0) + 1 - - result = [] - for path in sorted(prims): - parent = path.rsplit("/", 1)[0] or "/" - result.append( - { - "path": path, - "typeName": prims[path], - "parent": parent, - "depth": path.count("/"), - "has_children": child_counts.get(path, 0) > 0, - "instanceable": path in instanceable, - "is_point_instancer": path in point_instancer, - } - ) - return result + return self._scene.get_prim_tree() def get_instance_count(self) -> int: - """Count of prims with authored ``instanceable=true``. - - Whether each actually composes into an instance depends on a - composition arc; this is the upper bound and matches what the - tree's badge shows. - """ - return len(self._instanceable_paths) + return self._scene.get_instance_count() def get_prototype_count(self) -> int: - """Number of implicit prototype prims the stage composed.""" - with self.stage_lock: - return len(self.stage.GetPrototypes()) + return self._scene.get_prototype_count() def get_prim_detail(self, path: str) -> dict: - """Composed snapshot of one prim for the dashboard inspector. - - Reads are bounded (array buffers are never materialized, no flatten) - so stage_lock is held only briefly safe on the realtime path. - """ - with self.stage_lock: - prim = self.stage.GetPrimAtPath(path) - if not prim or not prim.IsValid(): - return {"path": path, "exists": False} - img = UsdGeom.Imageable(prim) - vis = img.GetVisibilityAttr().Get() if img else None - prototype = prim.GetPrototype() if prim.IsInstance() else None - detail = { - "path": path, - "exists": True, - "typeName": str(prim.GetTypeName()), - "active": prim.IsActive(), - "visibility": str(vis) if vis is not None else None, - "apiSchemas": [str(s) for s in prim.GetAppliedSchemas()], - "xform": _prim_xform_trs(prim), - "references": [a or p for a, p in read_references(self.stage, path)], - "payloads": [a or p for a, p in read_payloads(self.stage, path)], - "variantSelections": dict(read_variant_selections(self.stage, path)), - "materialBinding": read_material_binding(self.stage, path) or None, - "attributes": _authored_attr_rows(prim), - "isInstanceable": prim.IsInstanceable(), - "isInstance": prim.IsInstance(), - "isInstanceProxy": prim.IsInstanceProxy(), - "prototype": (prototype.GetPath().pathString if prototype else None), - } - if prim.IsA(UsdGeom.PointInstancer): - detail["pointInstancer"] = _point_instancer_summary(prim) - return detail + return self._scene.get_prim_detail(path) def get_transforms_snapshot(self) -> list[dict]: - """Composed translate/orient/scale for every Xformable prim with ops. - - Bounded scalar reads only, so stage_lock is held briefly. Returns raw - TRS rows (path plus t/r/s); the caller formats for display. - """ - rows = [] - with self.stage_lock: - for prim in self.stage.Traverse(): - trs = _prim_xform_trs(prim) - if trs: - rows.append({"path": str(prim.GetPath()), **trs}) - return rows + return self._scene.get_transforms_snapshot() def export_edit_layer(self, file_path: str | None = None) -> str: """Export the server's edit layer as a USDA string (thread-safe). If *file_path* is given, also writes the layer to disk. The exported - layer contains only the opinions authored by the server the base + layer contains only the opinions authored by the server; the base layer and its sublayers are not included. """ - # Snapshot layer ref under lock, serialize outside avoids holding - # stage_lock during the (potentially slow) ExportToString call. - with self.stage_lock: - layer = self.edit_layer + layer = self._scene.snapshot_layer(self.edit_layer) usda = layer.ExportToString() if file_path: layer.Export(file_path) @@ -3696,11 +1570,12 @@ def export_edit_layer(self, file_path: str | None = None) -> str: def export_layer(self, key: str) -> str: """Export one client/department layer as USDA (thread-safe). - Resolves the layer ref under stage_lock, serializes outside it, the - same discipline as export_edit_layer. + Copies the layer under the scene lock, then serializes the detached copy. """ with self.stage_lock: layer = self.resolve_layer(key) + if layer is not None: + layer = self._scene.snapshot_layer(layer) return layer.ExportToString() if layer else "# layer not found" def export_flattened(self, file_path: str) -> None: @@ -3722,38 +1597,3 @@ def export_flattened_string(self) -> str: with self.stage_lock: flat = self.stage.Flatten() return flat.ExportToString() - - def replay_from( - self, - handler, - seq_start: int, - *, - seq_end: int | None = None, - ): - """Replay events from the event store in an inclusive sequence range. - - All events are replayed regardless of origin, matching live delivery. - The receiver needs its own prior edits in the complete server order to - restore and converge state. - Layered receivers get every persisted authored record with its logical - layer target. Flat receivers are admitted only for a single unmuted - collaboration layer, so both modes replay stored records directly. - """ - blobs = self.store.get_from_seq_bin(seq_start, seq_end) - self.replay_records(handler, blobs) - - def replay_records(self, handler, records: Iterable[bytes]) -> None: - """Send already captured records without rereading mutable history.""" - _REPLAY_CHUNK = 65536 - buf_parts: list[bytes] = [] - buf_size = 0 - for blob in records: - framed = frame_batch([blob]) - buf_parts.append(framed) - buf_size += len(framed) - if buf_size >= _REPLAY_CHUNK: - handler.request.sendall(b"".join(buf_parts)) - buf_parts.clear() - buf_size = 0 - if buf_parts: - handler.request.sendall(b"".join(buf_parts)) diff --git a/openusdconnect/server/transactions.py b/openusdconnect/server/transactions.py new file mode 100644 index 0000000..ff64569 --- /dev/null +++ b/openusdconnect/server/transactions.py @@ -0,0 +1,225 @@ +"""Transaction admission, batching, and completion for the server commit worker. + +Stage mutation and durable publication stay in the supplied commit callbacks. +This coordinator owns each request's maintenance barrier until completion. +""" + +from __future__ import annotations + +import logging +import queue +import threading +import time +from collections.abc import Callable +from dataclasses import dataclass, field, replace + +from pxr import Sdf + +from ..checkpoints import TransactionCheckpoint +from ..event_store import ProducerProgress +from ..protocol_constants import K_LOAD_PAYLOAD +from ._txn_barrier import _TxnBarrier +from .types import TransactionCommit, TransactionOutcome + +LOG = logging.getLogger(__name__) +_QUEUE_MAX = 10_000 + + +@dataclass(slots=True, kw_only=True) +class Transaction: + """Validated edits and their routing metadata, retained through persistence.""" + + events: list[dict] + client_id: str | None = None + origin: str | None = None + client_addr: str | None = None + layer: Sdf.Layer | None = None + layer_key: str = "" + + @property + def progress(self) -> ProducerProgress | None: + """Internal edits have no producer acknowledgement to persist.""" + return None + + +@dataclass(slots=True, kw_only=True) +class TransactionRequest(Transaction): + """Producer command and wait handle; only the coordinator sets its outcome.""" + + client_id: str = field() + session_id: str + txn_id: int + done: threading.Event = field(default_factory=threading.Event) + commit: TransactionCommit | None = None + error: BaseException | None = None + payload_load_paths: tuple[str, ...] = field(init=False) + + def __post_init__(self) -> None: + # Derive publication boundaries once, from the admitted command. + self.payload_load_paths = tuple( + event["prim"] for event in self.events if event["k"] == K_LOAD_PAYLOAD + ) + + @property + def progress(self) -> ProducerProgress: + return ProducerProgress(self.client_id, self.session_id, self.txn_id) + + def wait(self) -> TransactionCommit: + """Wait for the durable outcome, propagating a failed commit to its caller.""" + self.done.wait() + if self.error is not None: + raise self.error + if self.commit is None: + raise RuntimeError("transaction coordinator returned no result") + return self.commit + + +class TransactionCoordinator: + def __init__( + self, + *, + barrier: _TxnBarrier, + commit: Callable[[TransactionRequest], TransactionCommit], + commit_group: Callable[[list[TransactionRequest]], list[TransactionOutcome]] | None, + checkpoint: Callable[[], TransactionCheckpoint], + batch_size: int, + batch_delay: float, + ): + self._barrier = barrier + self._commit = commit + self._commit_group = commit_group + self._checkpoint = checkpoint + self._batch_size = batch_size + self._batch_delay = batch_delay + self._submission_lock = threading.RLock() + self._stopping = False + self._queue: queue.Queue[TransactionRequest | None] = queue.Queue(maxsize=_QUEUE_MAX) + self._thread = ( + threading.Thread(target=self._run, name="ouc-transaction-commit", daemon=True) + if batch_size > 1 and commit_group is not None else None + ) + + @property + def running(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + def start(self) -> None: + if self._thread is not None: + self._thread.start() + + def shutdown(self) -> None: + # Serialize the stop marker with admission so no request can be queued + # behind it and retain a maintenance barrier forever. + with self._submission_lock: + if self._stopping: + return + self._stopping = True + if self._thread is None or self._thread.ident is None: + return + self._queue.put(None) + self._thread.join() + + def submit(self, request: TransactionRequest) -> TransactionRequest: + with self._submission_lock: + if self._stopping: + raise RuntimeError("transaction coordinator is shutting down") + # Ownership transfers to the worker after enqueue. Maintenance + # waits for queued requests as well as active commits/publication. + self._barrier.acquire_shared() + if self._thread is None: + try: + outcome = self._commit_one(request) + except BaseException as exc: + self._complete([request], [exc]) + raise + self._complete([request], [outcome]) + else: + try: + self._queue.put(request) + except BaseException: + self._barrier.release_shared() + raise + return request + + def _run(self) -> None: + stop = False + while not stop: + first = self._queue.get() + if first is None: + return + requests = [first] + deadline = time.monotonic() + self._batch_delay + # Loading a payload ends the batch: publication appends its child + # replay, which must receive sequences before the next transaction. + # The group callback can therefore handle payload replay once, last. + while len(requests) < self._batch_size and not requests[-1].payload_load_paths: + try: + request = self._queue.get_nowait() + except queue.Empty: + if time.monotonic() >= deadline: + break + # Sub-millisecond Queue.get timeouts round up to the OS + # scheduler quantum on Windows; yield without that delay. + time.sleep(0) + continue + if request is None: + stop = True + break + requests.append(request) + self._execute(requests) + + def _execute(self, requests: list[TransactionRequest]) -> None: + try: + try: + outcomes = self._commit_group(requests) + if len(outcomes) != len(requests): + raise RuntimeError("group commit must return one outcome per request") + except Exception: + # The group callback restores layers and sequence reservations + # on failure. Retry separately so one bad request cannot reject + # its neighbors. + LOG.debug("Grouped transaction commit failed; retrying individually", exc_info=True) + outcomes = [self._commit_one(request) for request in requests] + except BaseException as exc: + self._complete(requests, [exc] * len(requests)) + raise + self._complete(requests, outcomes) + + def _commit_one(self, request: TransactionRequest) -> TransactionOutcome: + try: + return self._commit(request) + except Exception as exc: + return exc + + def _complete( + self, requests: list[TransactionRequest], outcomes: list[TransactionOutcome], + ) -> None: + checkpoint = None + try: + if any( + isinstance(outcome, TransactionCommit) and outcome.status == "committed" + for outcome in outcomes + ): + # Capture once per batch, while maintenance is still excluded. + # A checkpoint failure must not turn a durable commit into failure. + try: + checkpoint = self._checkpoint() + except Exception: + LOG.exception("Could not capture transaction visibility checkpoint") + finally: + # Duplicates prove producer progress, not their original replay epoch. + for request, outcome in zip(requests, outcomes, strict=True): + try: + if isinstance(outcome, BaseException): + request.error = outcome + request.commit = None + else: + request.error = None + request.commit = ( + replace(outcome, checkpoint=checkpoint) + if outcome.status == "committed" and checkpoint is not None + else outcome + ) + finally: + self._barrier.release_shared() + request.done.set() diff --git a/openusdconnect/server/types.py b/openusdconnect/server/types.py index 3d7c17d..b8f336b 100644 --- a/openusdconnect/server/types.py +++ b/openusdconnect/server/types.py @@ -24,6 +24,10 @@ class ClientInfo: class VfsWriteRejectedError(RuntimeError): """Base class for VFS write fallback rejections.""" + def __init__(self, *args: object, analysis: VfsWriteAnalysis | None = None): + super().__init__(*args) + self.analysis = analysis + class StaleVfsWriteError(VfsWriteRejectedError): """Raised when an uploaded VFS snapshot is older than server state.""" @@ -55,6 +59,9 @@ class TransactionCommit: checkpoint: TransactionCheckpoint | None = None +type TransactionOutcome = TransactionCommit | BaseException + + class TransactionRejectedError(ValueError): """A transaction identity or payload cannot be accepted.""" diff --git a/tests/helpers.py b/tests/helpers.py index fa2870b..91064ea 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -17,6 +17,17 @@ PROJECT_ROOT = os.path.dirname(TESTS_DIR) +class ReceiverStub: + """Receiver metadata and cleanup contract established by a successful hello.""" + + _client_id: str | None = None + _origin: str | None = None + _layered_replay = False + + def release_receiver_replay_reservation(self): + pass + + def _wait_for_server(port, timeout=5): """Poll until the server accepts TCP connections.""" import socket diff --git a/tests/integration/scripts/blender_adapter_test_script.py b/tests/integration/scripts/blender_adapter_test_script.py index 8f37625..c5bfd15 100644 --- a/tests/integration/scripts/blender_adapter_test_script.py +++ b/tests/integration/scripts/blender_adapter_test_script.py @@ -556,6 +556,39 @@ def test_set_connectable_input_writes_light_attributes(r): r.ok(name) +def test_decoded_connectable_inputs_update_native_colors(r): + """Receiver decoding keeps shader and light colors as Python lists.""" + from openusdconnect.codec import encode_message, message_to_dict + + _clear_scene() + adapter = BlenderAdapter() + adapter.ensure_prim("/World/Light", "SphereLight") + adapter.ensure_prim("/World/Cube", "Cube") + material_path = "/World/Looks/DecodedInputs" + shader_path = material_path + "/Surface" + adapter.set_material_binding("/World/Cube", material_path) + color = [0.25, 0.5, 0.75] + for prim, info_id, input_name in ( + ("/World/Light", "", "color"), + (shader_path, "UsdPreviewSurface", "diffuseColor"), + ): + event = {"k": "set_connectable_input", "prim": prim, "info_id": info_id, + "inputs": {input_name: color}, "input_types": {input_name: "color3f"}} + wire = encode_message({"type": "event", "seq": 1, "event": event}) + decoded = message_to_dict(wire, numpy_arrays=True)["event"] + assert isinstance(decoded["inputs"][input_name], list) + assert adapter.apply_event(decoded) + + light = _find_by_prim(adapter, "/World/Light") + assert tuple(light.data.color) == tuple(color) + material = adapter._find_material_for_shader(shader_path) + principled = next(node for node in material.node_tree.nodes + if node.type == "BSDF_PRINCIPLED" + and node.get("usd_shader_path") == shader_path) + assert tuple(principled.inputs["Base Color"].default_value) == (*color, 1.0) + r.ok("test_decoded_connectable_inputs_update_native_colors") + + def test_set_connectable_input_on_non_light_is_noop(r): """Empty info_id on a non-light prim should not raise the light input handler returns True silently when the prim isn't a Blender LIGHT.""" @@ -1806,6 +1839,7 @@ def main(): test_ensure_prim_skipped_light_types_create_no_object, test_ensure_prim_light_with_api_schemas, test_set_connectable_input_writes_light_attributes, + test_decoded_connectable_inputs_update_native_colors, test_set_connectable_input_on_non_light_is_noop, test_ensure_prim_domelight_sets_up_world_network, test_set_connectable_input_domelight_writes_world_shader, diff --git a/tests/integration/test_department_projection.py b/tests/integration/test_department_projection.py index aae873f..4491a69 100644 --- a/tests/integration/test_department_projection.py +++ b/tests/integration/test_department_projection.py @@ -85,12 +85,12 @@ def test_flat_receiver_is_admitted_only_for_single_layer( assert receiver.rejection_code == HelloRejectionCode.LayeredReplayRequired assert "department" in receiver.rejection_reason assert not receiver.auth_rejected - assert _wait_until(lambda: sync_server._flat_receiver_count == 0) + assert _wait_until(lambda: sync_server._collaboration.flat_receiver_count == 0) assert not sync_server.receivers else: assert _wait_until(lambda: receiver.connected) assert not receiver.layered_replay_active - assert sync_server._flat_receiver_count == 1 + assert sync_server._collaboration.flat_receiver_count == 1 finally: receiver.stop() receiver.join(timeout=2) @@ -109,7 +109,7 @@ def test_layered_receiver_is_admitted_for_both_server_modes(server_factory, depa try: assert _wait_until(lambda: receiver.connected) assert receiver.layered_replay_active - assert sync_server._flat_receiver_count == 0 + assert sync_server._collaboration.flat_receiver_count == 0 finally: receiver.stop() receiver.join(timeout=2) @@ -144,7 +144,7 @@ def test_single_layer_flat_receiver_gets_live_and_replayed_records(server_factor live.stop() live.join(timeout=2) - assert _wait_until(lambda: sync_server._flat_receiver_count == 0) + assert _wait_until(lambda: sync_server._collaboration.flat_receiver_count == 0) late = ReceiverThread( port=port, @@ -211,7 +211,7 @@ def test_flat_receiver_blocks_enabling_department_policy(server_factory): receiver.stop() receiver.join(timeout=2) - assert _wait_until(lambda: sync_server._flat_receiver_count == 0) + assert _wait_until(lambda: sync_server._collaboration.flat_receiver_count == 0) sync_server.set_department_priority(["animation", "layout"]) assert sync_server.department_priority == ["animation", "layout"] @@ -271,7 +271,7 @@ def _fail_replay(_handler, _seq_start, *, seq_end=None): sync_server.compact_log() assert not sync_server.receivers - assert sync_server._flat_receiver_count == 0 + assert sync_server._collaboration.flat_receiver_count == 0 finally: sender.disconnect() receiver.stop() diff --git a/tests/integration/test_managed_client.py b/tests/integration/test_managed_client.py index 684ea5f..3383c04 100644 --- a/tests/integration/test_managed_client.py +++ b/tests/integration/test_managed_client.py @@ -237,14 +237,14 @@ def test_managed_client_recovers_rejection_with_fresh_producer_session( persist_token=False, reconnect=False, ) - original_apply = sync_server._apply_validated_txn + original_apply = sync_server._scene.apply_validated def reject_marked_transaction(events, *args, **kwargs): if any(event.get("prim") == "/World/Rejected" for event in events): raise ValueError("injected invalid managed transaction") return original_apply(events, *args, **kwargs) - monkeypatch.setattr(sync_server, "_apply_validated_txn", reject_marked_transaction) + monkeypatch.setattr(sync_server._scene, "apply_validated", reject_marked_transaction) try: client.start() assert client.connect(timeout=5) diff --git a/tests/integration/test_mcp_roundtrip.py b/tests/integration/test_mcp_roundtrip.py index 1621726..b3a6682 100644 --- a/tests/integration/test_mcp_roundtrip.py +++ b/tests/integration/test_mcp_roundtrip.py @@ -37,14 +37,14 @@ def test_foreign_commit_cannot_confirm_blocked_own_commit(tmp_path, monkeypatch) foreign = EventSender("127.0.0.1", tcp.server_address[1], client_id="foreign") observed_foreign = threading.Event() release_own = threading.Event() - apply = state._apply_validated_txn + apply = state._scene.apply_validated def gated_apply(events, *args, **kwargs): if any(event.get("prim") == "/Own" for event in events): assert release_own.wait(5) return apply(events, *args, **kwargs) - monkeypatch.setattr(state, "_apply_validated_txn", gated_apply) + monkeypatch.setattr(state._scene, "apply_validated", gated_apply) def release_after_foreign(): if observed_foreign.wait(5): diff --git a/tests/integration/test_per_client_layers.py b/tests/integration/test_per_client_layers.py index ca82c1c..af6c691 100644 --- a/tests/integration/test_per_client_layers.py +++ b/tests/integration/test_per_client_layers.py @@ -578,7 +578,9 @@ def test_replay_restores_unlisted_departments_without_configured_priority( srv2 = UsdSyncServer(log_path=db) try: - assert srv2._ordered_department_names() == ["animation", "layout"] + assert srv2.layer_stack.layer_keys == ( + "department:animation", "department:layout", "default", + ) assert _read_translate(srv2.stage, "/World/Cube") == (1, 0, 0) assert srv2.mute_layer("animation") assert _read_translate(srv2.stage, "/World/Cube") == (2, 0, 0) @@ -814,24 +816,21 @@ def _writer(client_id, dept, prim, base, count): ) def test_op_cache_invalidated_on_edit_target_change(self, tmp_path): - """Deterministic pin for the fix behind the flaky concurrent failures. - - Cache hits skip op setup, including authoring xformOpOrder in the - current layer. ``_op_cache_for`` clears on layer changes so each - department gets its own setup, and keeps consecutive same-layer edits - fast. XformOp.Set itself always uses the current stage edit target. - """ + """Alternating departments must each author their own transform setup.""" srv = _make_server(tmp_path, department_priority=["animation", "lighting"]) la = srv.get_or_create_client_layer("alice", "animation") lb = srv.get_or_create_client_layer("bob", "lighting") - cache = srv._op_cache_for(la) - cache["/World/P"] = object() - # switching the edit target invalidates the cache - assert "/World/P" not in srv._op_cache_for(lb) - # staying on the same layer keeps it (no needless re-fetch) - srv._op_cache_for(lb)["/World/Q"] = object() - assert "/World/Q" in srv._op_cache_for(lb) + for layer, value in ((la, 1), (lb, 2), (lb, 3), (la, 4)): + srv.apply_txn(_make_trs_events("/World/P", (value, 0, 0)), layer=layer) + + for layer, expected in ((la, 4), (lb, 3)): + assert list(layer.GetAttributeAtPath("/World/P.xformOpOrder").default) == [ + "xformOp:translate", "xformOp:orient", "xformOp:scale", + ] + assert tuple(layer.GetAttributeAtPath("/World/P.xformOp:translate").default) == ( + expected, 0, 0, + ) def test_concurrent_writes_to_same_prim(self, tmp_path): """Two departments writing to the same prim concurrently. diff --git a/tests/integration/test_replay_capture_identity.py b/tests/integration/test_replay_capture_identity.py index 91b3153..72a25dc 100644 --- a/tests/integration/test_replay_capture_identity.py +++ b/tests/integration/test_replay_capture_identity.py @@ -13,6 +13,7 @@ from openusdconnect.sender import EventSender from openusdconnect.server import connection as connection_mod from tests.helpers import ( + ReceiverStub, ensure_prim_event, in_process_server, mcp_session_with_receiver, @@ -128,7 +129,7 @@ def mute_before_hello(sock, message): assert updated_stack["layers"][0]["muted"] is True -class _CaptureHandler: +class _CaptureHandler(ReceiverStub): _layered_replay = True def __init__(self): diff --git a/tests/integration/test_transaction_reconnect.py b/tests/integration/test_transaction_reconnect.py index 210ea5c..272aa95 100644 --- a/tests/integration/test_transaction_reconnect.py +++ b/tests/integration/test_transaction_reconnect.py @@ -390,14 +390,14 @@ def test_pipeline_rejection_preserves_result_order_and_quarantines_suffix( monkeypatch, ): state, port = transaction_server - apply_txn = state._apply_validated_txn + apply_txn = state._scene.apply_validated def reject_second(events, *args, **kwargs): if any(event.get("prim") == "/World/Reject2" for event in events): raise ValueError("injected invalid transaction") return apply_txn(events, *args, **kwargs) - monkeypatch.setattr(state, "_apply_validated_txn", reject_second) + monkeypatch.setattr(state._scene, "apply_validated", reject_second) sender = EventSender( "127.0.0.1", port, diff --git a/tests/unit/test_api_schemas.py b/tests/unit/test_api_schemas.py index a6b9f62..11ee700 100644 --- a/tests/unit/test_api_schemas.py +++ b/tests/unit/test_api_schemas.py @@ -33,7 +33,7 @@ ) from openusdconnect.event_apply import apply_event, apply_events from openusdconnect.protocol_constants import K_ENSURE_PRIM -from openusdconnect.server.state import UsdSyncServer +from openusdconnect.server.maintenance import HistoryMaintenance def _roundtrip(ev: dict) -> dict: @@ -202,7 +202,7 @@ def test_compaction_unions_api_schemas(self, tmp_path): msg = {"type": MSG_EVENT, "seq": i + 1, "event": ev} rows.append((i + 1, encode_message(msg))) - entries = UsdSyncServer._build_compacted(rows).replay_entries() + entries = HistoryMaintenance.build_compacted(rows).replay_records() assert len(entries) == 1 merged_ev = entries[0].event assert sorted(merged_ev["api_schemas"]) == ["ShadowAPI", "ShapingAPI"] @@ -224,7 +224,7 @@ def test_compaction_multi_apply_unique_instances(self, tmp_path): msg = {"type": MSG_EVENT, "seq": i + 1, "event": ev} rows.append((i + 1, encode_message(msg))) - entries = UsdSyncServer._build_compacted(rows).replay_entries() + entries = HistoryMaintenance.build_compacted(rows).replay_records() assert len(entries) == 1 merged_ev = entries[0].event assert sorted(merged_ev["api_schemas"]) == [ diff --git a/tests/unit/test_codec.py b/tests/unit/test_codec.py index e40ac83..460823a 100644 --- a/tests/unit/test_codec.py +++ b/tests/unit/test_codec.py @@ -14,8 +14,10 @@ PayloadType, decode_envelope, decode_hello, + decode_messages, decode_transaction, encode_message, + event_to_dict, is_ping, message_to_dict, payload_type, @@ -672,6 +674,51 @@ def test_roundtrip(self): class TestSetConnectableInput: + @pytest.mark.parametrize("numpy_arrays", [False, True]) + def test_numeric_inputs_remain_python_lists(self, numpy_arrays): + event = { + "k": "set_connectable_input", + "prim": "/Shader", + "info_id": "TestShader", + "inputs": { + "color": [0.25, 0.5, 1.0], + "weights": [0.25, 0.5], + "counts": [1, 2], + "empty_weights": [], + "empty_counts": [], + }, + "input_types": { + "color": "color3f", + "weights": "float[]", + "counts": "int[]", + "empty_weights": "float[]", + "empty_counts": "int[]", + }, + } + broadcast = encode_message({"type": "event", "seq": 1, "event": event}) + transaction = encode_message({"type": "txn", "events": [event]}) + _, payload = resolve_payload(decode_envelope(broadcast)) + decoded = [ + message_to_dict(broadcast, numpy_arrays=numpy_arrays)["event"], + message_to_dict(transaction, numpy_arrays=numpy_arrays)["events"][0], + event_to_dict(payload.Event(), numpy_arrays=numpy_arrays), + ] + for preserve_envelopes in (False, True): + result = decode_messages( + [broadcast], numpy_arrays=numpy_arrays, + preserve_envelopes=preserve_envelopes, + ) + assert result.errors == [] + decoded.extend(result.received) + if preserve_envelopes: + decoded.append(result.received_records[0].event) + for item in decoded: + assert all(isinstance(value, list) for value in item["inputs"].values()) + assert all(isinstance(value, float) for value in item["inputs"]["color"]) + assert all(isinstance(value, float) for value in item["inputs"]["weights"]) + assert all(isinstance(value, int) for value in item["inputs"]["counts"]) + assert item == event + def test_roundtrip(self): ev = { "k": "set_connectable_input", diff --git a/tests/unit/test_collaboration_layer_stack.py b/tests/unit/test_collaboration_layer_stack.py index c6ed57e..29fe692 100644 --- a/tests/unit/test_collaboration_layer_stack.py +++ b/tests/unit/test_collaboration_layer_stack.py @@ -47,12 +47,21 @@ def test_reorder_and_mute_change_composition_without_moving_opinions(): _set_value(stage, default, 0) _set_value(stage, layout, 1) _set_value(stage, animation, 2) + original_keys = stack.layer_keys + original_layers = stack.ordered_layers attribute = stage.GetAttributeAtPath( "/World/Thing.userProperties:value" ) assert attribute.Get() == 2 assert stack.set_order(["layout", "animation", "default"]) + assert stack.layer_keys == ("layout", "animation", "default") + assert stack.ordered_layers == (layout, animation, default) + assert original_keys == ("animation", "layout", "default") + assert original_layers == (animation, layout, default) + revision = stack.revision + assert not stack.set_order(iter(stack.layer_keys)) + assert stack.revision == revision assert attribute.Get() == 1 assert stack.set_muted("layout", True) assert attribute.Get() == 2 diff --git a/tests/unit/test_compaction.py b/tests/unit/test_compaction.py index 7002e91..6b8e8d1 100644 --- a/tests/unit/test_compaction.py +++ b/tests/unit/test_compaction.py @@ -1,8 +1,9 @@ """Tests for server event log compaction.""" import pytest +from pxr import Usd -from openusdconnect.codec import message_to_dict +from openusdconnect.codec import encode_message, message_to_dict from openusdconnect.protocol_constants import ( K_DEACTIVATE_PRIM, K_DELETE_PRIM, @@ -19,6 +20,7 @@ MSG_EVENT, ) from openusdconnect.server import UsdSyncServer +from openusdconnect.server.compaction import LogCompaction def _make_server(tmp_path): @@ -45,6 +47,123 @@ def _read_log(server): class TestCompaction: + def test_merged_records_retain_latest_routing_and_first_creation_order(self, tmp_path): + server = _make_server(tmp_path) + try: + for department in ("layout", "animation"): + layer = server.get_or_create_client_layer(department, department) + for author, field, value in (("first", "t", [1, 2, 3]), ("last", "s", [2, 2, 2])): + server._commit_events([ + {"k": K_ENSURE_PRIM, "prim": "/Object", "typeName": "Xform"}, + {"k": K_SET_XFORM_TRS, "prim": "/Object", "fields": [field], field: value}, + ], layer=layer, client_id=author, origin=f"{department}-{author}", + client_addr=f"{author}:1234") + + server.compact_log() + + records = [message_to_dict(blob) for _seq, blob in server.store.get_all_asc()] + assert [record["seq"] for record in records] == [1, 2, 3, 4] + assert [record["event"]["k"] for record in records] == [ + K_ENSURE_PRIM, K_SET_XFORM_TRS, K_ENSURE_PRIM, K_SET_XFORM_TRS, + ] + for record, department in zip( + records, ("layout", "layout", "animation", "animation"), strict=True, + ): + assert record["layer_key"] == f"department:{department}" + assert record["origin"] == f"{department}-last" + assert record["client_id"] == "last" + assert record["client"] == "last:1234" + if record["event"]["k"] == K_SET_XFORM_TRS: + assert record["event"]["t"] == [1, 2, 3] + assert record["event"]["s"] == [2, 2, 2] + finally: + server.shutdown() + server.store.close() + + @pytest.mark.parametrize(("kind", "first", "second", "values"), [ + ("set_xform_trs", "t", "s", [[i, i, i] for i in range(1, 6)]), + ("set_point_instancer", "positions", "scales", [[[i, i, i]] for i in range(1, 6)]), + ]) + def test_complete_and_partial_updates_can_alternate(self, kind, first, second, values): + updates = [ + {"fields": [first], first: values[0]}, + {"fields": [first], first: values[1]}, + {"fields": [second], second: values[2]}, + {"fields": [first, second], first: values[3], second: values[3]}, + {"fields": [first], first: values[4]}, + ] + compaction = LogCompaction() + for sequence, update in enumerate(updates, start=1): + event = {"k": kind, "prim": "/World/A", "time": 1.0, **update} + compaction.add_stored_record(sequence, encode_message({ + "type": MSG_EVENT, "seq": sequence, "event": event, + })) + entries = compaction.replay_records() + assert len(entries) == 1 + event = message_to_dict(encode_message({ + "type": MSG_EVENT, "seq": 1, "event": entries[0].event, + }))["event"] + assert event["fields"] == [first, second] + assert event[first] == values[4] + assert event[second] == values[3] + assert event["time"] == 1.0 + + @pytest.mark.parametrize("replacement", ["purge", "snapshot"]) + def test_history_replacement_during_compaction_preserves_new_history( + self, tmp_path, monkeypatch, replacement, + ): + server = _make_server(tmp_path) + replacement_rows = [] + replacement_token = None + try: + server._commit_events([ + {"k": K_ENSURE_PRIM, "prim": "/Old", "typeName": "Xform"}, + ]) + original_build = server._maintenance.build_compacted + + def replace_history_during_build(rows): + nonlocal replacement_rows, replacement_token + compacted = original_build(rows) + if replacement == "purge": + server.purge() + else: + uploaded = Usd.Stage.CreateInMemory() + uploaded.DefinePrim("/Replacement", "Xform") + epoch, seq = server.get_snapshot_token() + uploaded.GetRootLayer().customLayerData = {"openusdconnect": { + "scene_id": server.scene_id, "epoch": epoch, "snapshot_seq": seq, + }} + server.replace_from_stage_snapshot( + uploaded, reject_ambiguous=False, + ) + replacement_rows = server.store.get_all_asc() + replacement_token = server.get_replay_token() + return compacted + + # Interleave at the off-lock calculation boundary without timing + # assumptions. The completed plan belongs to the discarded history. + monkeypatch.setattr( + server._maintenance, "build_compacted", replace_history_during_build, + ) + server.compact_log() + + assert server.store.get_all_asc() == replacement_rows + assert server.get_replay_token() == replacement_token + assert not server.stage.GetPrimAtPath("/Old") + assert bool(server.stage.GetPrimAtPath("/Replacement")) == (replacement == "snapshot") + finally: + server.shutdown() + server.store.close() + + restarted = _make_server(tmp_path) + try: + assert not restarted.stage.GetPrimAtPath("/Old") + replacement_prim = restarted.stage.GetPrimAtPath("/Replacement") + assert bool(replacement_prim) == (replacement == "snapshot") + finally: + restarted.shutdown() + restarted.store.close() + def test_failed_rewrite_preserves_sequence_state(self, tmp_path, monkeypatch): srv = _make_server(tmp_path) _inject_events( diff --git a/tests/unit/test_dashboard_instancing.py b/tests/unit/test_dashboard_instancing.py index cd6ccb6..edec317 100644 --- a/tests/unit/test_dashboard_instancing.py +++ b/tests/unit/test_dashboard_instancing.py @@ -25,6 +25,7 @@ def srv(tmp_path): s = UsdSyncServer(log_path=str(tmp_path / "instancing.db")) yield s + s.shutdown() s.store.close() @@ -34,13 +35,23 @@ def srv(tmp_path): class TestInstancingTrackers: + def test_point_instancer_badge_follows_redefined_type(self, srv): + for type_name in ("PointInstancer", "Xform", "PointInstancer"): + srv._commit_events([ + {"k": "ensure_prim", "prim": "/Object", "typeName": type_name}, + ]) + row = next(row for row in srv.get_prim_tree() if row["path"] == "/Object") + assert row["typeName"] == type_name + assert row["is_point_instancer"] == (type_name == "PointInstancer") + def test_ensure_prim_point_instancer_marks_set(self, srv): srv._commit_events([ {"k": "ensure_prim", "prim": "/PI", "typeName": "PointInstancer"}, {"k": "ensure_prim", "prim": "/Xform", "typeName": "Xform"}, ]) - assert "/PI" in srv._point_instancer_paths - assert "/Xform" not in srv._point_instancer_paths + tree = {row["path"]: row for row in srv.get_prim_tree()} + assert tree["/PI"]["is_point_instancer"] + assert not tree["/Xform"]["is_point_instancer"] def test_set_instanceable_toggles_set(self, srv): srv._commit_events([ @@ -50,12 +61,12 @@ def test_set_instanceable_toggles_set(self, srv): "refs": [{"asset_path": "", "prim_path": "/Proto"}]}, {"k": "set_instanceable", "prim": "/Inst", "instanceable": True}, ]) - assert srv._instanceable_paths == {"/Inst"} + assert {row["path"] for row in srv.get_prim_tree() if row["instanceable"]} == {"/Inst"} srv._commit_events([ {"k": "set_instanceable", "prim": "/Inst", "instanceable": False}, ]) - assert srv._instanceable_paths == set() + assert srv.get_instance_count() == 0 def test_delete_removes_from_all_trackers(self, srv): srv._commit_events([ @@ -65,8 +76,8 @@ def test_delete_removes_from_all_trackers(self, srv): {"k": "delete_prim", "prim": "/Inst"}, {"k": "delete_prim", "prim": "/PI"}, ]) - assert srv._instanceable_paths == set() - assert srv._point_instancer_paths == set() + assert srv.get_instance_count() == 0 + assert not srv.get_prim_tree() def test_rename_preserves_flags(self, srv): srv._commit_events([ @@ -77,7 +88,7 @@ def test_rename_preserves_flags(self, srv): {"k": "set_instanceable", "prim": "/Inst", "instanceable": True}, {"k": "rename_prim", "prim": "/Inst", "new_name": "Renamed"}, ]) - assert srv._instanceable_paths == {"/Renamed"} + assert {row["path"] for row in srv.get_prim_tree() if row["instanceable"]} == {"/Renamed"} def test_compaction_rebuilds_trackers(self, srv): srv._commit_events([ @@ -89,8 +100,9 @@ def test_compaction_rebuilds_trackers(self, srv): {"k": "set_instanceable", "prim": "/Inst", "instanceable": True}, ]) srv.compact_log() - assert srv._point_instancer_paths == {"/PI"} - assert srv._instanceable_paths == {"/Inst"} + tree = srv.get_prim_tree() + assert {row["path"] for row in tree if row["is_point_instancer"]} == {"/PI"} + assert {row["path"] for row in tree if row["instanceable"]} == {"/Inst"} # --------------------------------------------------------------------------- diff --git a/tests/unit/test_dispatcher.py b/tests/unit/test_dispatcher.py index 5e919e1..965eee0 100644 --- a/tests/unit/test_dispatcher.py +++ b/tests/unit/test_dispatcher.py @@ -83,6 +83,34 @@ def test_on_applied_optional(): assert dispatcher._apply([{"k": K_ENSURE_PRIM, "prim": "/World/A", "typeName": "Xform"}]) == 1 +def test_custom_adapter_receives_connectable_inputs_as_python_lists(): + received_inputs = [] + + class ListInputAdapter(MockAdapter): + def set_connectable_input(self, prim_path, info_id, inputs, input_types, time=None): + # Existing adapters may use list truthiness or concatenate values. + received_inputs.append({name: value + [] for name, value in inputs.items()}) + return super().set_connectable_input( + prim_path, info_id, inputs, input_types, time=time, + ) + + inputs = {"color": [0.25, 0.5, 1.0], "weights": [0.25, 0.5], "counts": [1, 2]} + event = { + "k": "set_connectable_input", "prim": "/Shader", "info_id": "TestShader", + "inputs": inputs, + "input_types": {"color": "color3f", "weights": "float[]", "counts": "int[]"}, + } + receiver = _QueuedReceiver([ + _event(1, "/Shader"), + encode_message({"type": "event", "seq": 2, "event": event}), + ]) + dispatcher = EventDispatcher(receiver=receiver, adapter=ListInputAdapter()) + + assert dispatcher.drain_and_apply() == 2 + assert received_inputs == [inputs] + assert receiver.replay_requests == [] + + def test_post_apply_callbacks_run_in_documented_order(): calls = [] event = { diff --git a/tests/unit/test_erase_time_samples.py b/tests/unit/test_erase_time_samples.py index 85d487b..a953e53 100644 --- a/tests/unit/test_erase_time_samples.py +++ b/tests/unit/test_erase_time_samples.py @@ -13,8 +13,8 @@ ) from openusdconnect.protocol_validation import validate_event_or_raise from openusdconnect.sdf_spec_delta import serialize_spec_fields -from openusdconnect.server import UsdSyncServer from openusdconnect.server.compaction import LogCompaction +from openusdconnect.server.maintenance import HistoryMaintenance from openusdconnect.time_sample_delta import erase_time_samples_event @@ -151,8 +151,8 @@ def test_compaction_does_not_move_partial_writes_across_erasure(exact_samples): apply_events(expected, events) rows = [(seq, _encode(event, seq)) for seq, event in enumerate(events, 1)] for _ in range(3): - compacted = UsdSyncServer._build_compacted(rows) - replay = [entry.event for entry in compacted.replay_entries()] + compacted = HistoryMaintenance.build_compacted(rows) + replay = [entry.event for entry in compacted.replay_records()] target = Usd.Stage.CreateInMemory() apply_events(target, replay) assert target.GetRootLayer().ExportToString() == expected.GetRootLayer().ExportToString() @@ -166,12 +166,12 @@ def test_compaction_keeps_erasures_in_their_own_layer(): (3, _encode(_erase(1.0), 3, "strong")), (4, _encode(_write(1.0, extent=[[-1.0] * 3, [1.0] * 3]), 4, "strong")), ] - entries = UsdSyncServer._build_compacted(rows).replay_entries() + entries = HistoryMaintenance.build_compacted(rows).replay_records() for layer_key, expected_times in (("weak", [1.0]), ("strong", [])): target = Usd.Stage.CreateInMemory() UsdGeom.Cube.Define(target, "/Cube") apply_events( - target, [entry.event for entry in entries if entry.metadata["layer_key"] == layer_key] + target, [entry.event for entry in entries if entry.layer_key == layer_key] ) assert target.GetAttributeAtPath("/Cube.size").GetTimeSamples() == expected_times @@ -182,7 +182,7 @@ def test_compaction_still_collapses_repeated_exact_table_writes(): for seq in range(1, 5): attr.Set(float(seq), 1.0) rows.append((seq, _encode(_spec_event(source.GetRootLayer(), ["timeSamples"]), seq))) - entries = UsdSyncServer._build_compacted(rows).replay_entries() + entries = HistoryMaintenance.build_compacted(rows).replay_records() assert len(entries) == 1 target = Usd.Stage.CreateInMemory() apply_events(target, [entries[0].event]) @@ -197,7 +197,7 @@ def test_compaction_does_not_move_exact_tables_past_later_sample_writes(): _spec_event(source.GetRootLayer(), ["documentation"]), ] rows = [(seq, _encode(event, seq)) for seq, event in enumerate(events, 1)] - entries = UsdSyncServer._build_compacted(rows).replay_entries() + entries = HistoryMaintenance.build_compacted(rows).replay_records() target = Usd.Stage.CreateInMemory() apply_events(target, [entry.event for entry in entries]) assert target.GetAttributeAtPath("/Cube.size").Get(1.0) == 42.0 @@ -216,12 +216,12 @@ def test_subtree_removal_discards_preserved_history_only_in_its_layer(kind): ({"k": kind, "prim": "/Cube", "new_name": "Renamed"}, "strong"), ] for seq, (event, layer) in enumerate(records, 1): - compaction.add_record(seq, _encode(event, seq, layer)) - entries = compaction.replay_entries() - assert [entry.event["k"] for entry in entries if entry.metadata["layer_key"] == "strong"] == [ + compaction.add_stored_record(seq, _encode(event, seq, layer)) + entries = compaction.replay_records() + assert [entry.event["k"] for entry in entries if entry.layer_key == "strong"] == [ kind ] - assert [entry.event["k"] for entry in entries if entry.metadata["layer_key"] == "weak"] == [ + assert [entry.event["k"] for entry in entries if entry.layer_key == "weak"] == [ "set_sdf_spec_fields", "erase_time_samples", ] @@ -236,5 +236,5 @@ def test_layer_replacement_discards_preserved_sample_history(): {"k": "replace_sdf_layer_content", "prim": "/", "fragment": "#usda 1.0\n"}, ] for seq, event in enumerate(events, 1): - compaction.add_record(seq, _encode(event, seq)) - assert [entry.event for entry in compaction.replay_entries()] == events[-1:] + compaction.add_stored_record(seq, _encode(event, seq)) + assert [entry.event for entry in compaction.replay_records()] == events[-1:] diff --git a/tests/unit/test_payload_replay.py b/tests/unit/test_payload_replay.py new file mode 100644 index 0000000..5642a9c --- /dev/null +++ b/tests/unit/test_payload_replay.py @@ -0,0 +1,281 @@ +"""Payload reload preserves partial opinions, authored layers, and samples.""" + +import pytest +from pxr import Gf, Sdf, Usd + +from openusdconnect.codec import message_to_dict +from openusdconnect.event_apply import apply_events +from openusdconnect.sdf_spec_delta import serialize_spec_fields +from openusdconnect.server import UsdSyncServer +from openusdconnect.server.compaction import child_replay_records + + +@pytest.fixture +def server(): + server = UsdSyncServer(log_path=":memory:") + try: + yield server + finally: + server.shutdown() + server.store.close() + + +def test_child_replay_preserves_latest_opinion_in_each_layer(server): + for department, values in (("layout", (1, 2)), ("animation", (3, 4))): + layer = server.get_or_create_client_layer(department, department) + server._commit_events([ + {"k": "ensure_prim", "prim": "/Payload/Child", "typeName": "Xform"}, + {"k": "ensure_xform_ops", "prim": "/Payload/Child"}, + *({"k": "set_xform_trs", "prim": "/Payload/Child", "fields": ["t"], + "t": [value, 0, 0]} for value in values), + ], client_id=department, origin=department, layer=layer) + + previous_head = server.store.get_max_seq() + server.replay_children_after_load("/Payload") + replayed = [message_to_dict(blob) + for _, blob in server.store.get_from_seq_asc(previous_head + 1)] + assert len(replayed) == 6 + assert [record["event"]["k"] for record in replayed] == [ + "ensure_prim", "ensure_prim", "ensure_xform_ops", "ensure_xform_ops", + "set_xform_trs", "set_xform_trs", + ] + assert [(record["layer_key"], record["origin"], record["event"]["t"]) + for record in replayed if record["event"]["k"] == "set_xform_trs"] == [ + ("department:layout", "layout", [2, 0, 0]), + ("department:animation", "animation", [4, 0, 0]), + ] + + +def test_child_replay_reduces_shader_arrays_and_excludes_non_descendants(server): + for i in range(100): + server.append_log({ + "type": "event", "seq": server.assign_seq(), "layer_key": "default", + "event": {"k": "set_connectable_input", "prim": "/Payload/Child", + "info_id": "Shader", "inputs": {"weights": [float(i)] * 16}, + "input_types": {"weights": "float[]"}}, + }) + for path in ("/Payload", "/PayloadSibling/Child"): + server.append_log({ + "type": "event", "seq": server.assign_seq(), "layer_key": "default", + "event": {"k": "ensure_prim", "prim": path, "typeName": "Xform"}, + }) + + records = child_replay_records(server.store, "/Payload") + assert len(records) == 1 + values = records[0].event["inputs"]["weights"] + assert isinstance(values, list) + assert values == [99.0] * 16 + + +def _replay_events(server, events, prim_path="/Payload"): + for event in events: + server.append_log({ + "type": "event", "seq": server.assign_seq(), "layer_key": "default", + "event": {"prim": f"{prim_path}/Child", **event}, + }) + return [record.event for record in child_replay_records(server.store, prim_path)] + + +def test_child_replay_merges_partial_transforms_per_time_sample(server): + events = _replay_events(server, [ + {"k": "set_xform_trs", "fields": ["t"], "t": [1, 2, 3]}, + {"k": "set_xform_trs", "fields": ["r"], "r": [1, 0, 0, 0]}, + {"k": "set_xform_trs", "fields": ["t"], "t": [4, 5, 6], "time": 1}, + {"k": "set_xform_trs", "fields": ["t"], "t": [7, 8, 9], "time": 2}, + {"k": "set_xform_trs", "fields": ["s"], "s": [2, 2, 2], "time": 1}, + ]) + by_time = {event.get("time"): event for event in events} + assert len(events) == 3 + assert by_time[None]["fields"] == ["t", "r"] + assert by_time[None]["t"] == [1, 2, 3] + assert by_time[None]["r"] == [1, 0, 0, 0] + assert by_time[1]["fields"] == ["t", "s"] + assert by_time[1]["t"] == [4, 5, 6] + assert by_time[1]["s"] == [2, 2, 2] + assert by_time[2]["t"] == [7, 8, 9] + + +def test_child_replay_keeps_latest_envelope_when_merging(server): + for sequence, update in enumerate([ + {"fields": ["t"], "t": [1, 2, 3]}, + {"fields": ["r"], "r": [1, 0, 0, 0]}, + ], start=1): + record = {"type": "event", "seq": sequence, "layer_key": "department:layout", + "event": {"k": "set_xform_trs", "prim": "/Payload/Child", **update}} + if sequence == 1: + record.update(origin="artist", client_id="first-client", client="first-address") + server.append_log(record) + records = child_replay_records(server.store, "/Payload") + assert len(records) == 1 + latest = records[0] + assert latest.seq == 2 + assert latest.layer_key == "department:layout" + assert latest.origin is latest.client_id is latest.client is None + assert latest.event["fields"] == ["t", "r"] + assert latest.event["t"] == [1, 2, 3] + assert latest.event["r"] == [1, 0, 0, 0] + + +def test_child_replay_merges_partial_shader_inputs_per_time_sample(server): + events = _replay_events(server, [ + {"k": "set_connectable_input", "info_id": "UsdPreviewSurface", + "inputs": {"roughness": 0.25}, "input_types": {"roughness": "float"}}, + {"k": "set_connectable_input", "info_id": "", + "inputs": {"metallic": 0.5}, "input_types": {"metallic": "float"}}, + *({"k": "set_connectable_input", "info_id": "", "time": time, + "inputs": {"roughness": value}, "input_types": {"roughness": "float"}} + for time, value in ((1, 0.125), (2, 0.75))), + ]) + by_time = {event.get("time"): event for event in events} + assert len(events) == 3 + assert by_time[None]["info_id"] == "UsdPreviewSurface" + assert by_time[None]["inputs"] == {"roughness": 0.25, "metallic": 0.5} + assert by_time[None]["input_types"] == {"roughness": "float", "metallic": "float"} + assert by_time[1]["inputs"] == {"roughness": 0.125} + assert by_time[2]["inputs"] == {"roughness": 0.75} + + +def test_child_replay_keeps_material_purposes_separate(server): + events = _replay_events(server, [ + {"k": "set_material_binding", "material_path": material, "material_purpose": purpose} + for purpose, material in (("preview", "/Preview"), ("full", "/Full"), ("preview", "/New")) + ]) + assert {event["material_purpose"]: event["material_path"] for event in events} == { + "preview": "/New", "full": "/Full", + } + + +def test_child_replay_merges_connections_and_disconnects(server): + source = {"source_prim": "/Payload/Source", "source_attr": "outputs:out"} + events = _replay_events(server, [ + {"k": "set_connectable_connection", "connections": {"inputs:a": source}}, + {"k": "set_connectable_connection", "connections": {"inputs:b": source}}, + {"k": "set_connectable_connection", "connections": {}, "disconnections": ["inputs:a"]}, + {"k": "ensure_prim", "prim": "/Payload/Source", "typeName": "Shader"}, + ]) + assert events[0]["k"] == "ensure_prim" + assert events[1]["connections"] == {"inputs:a": source, "inputs:b": source} + assert events[1]["disconnections"] == ["inputs:a"] + + +def _assert_replayed_attributes_match_history(server, events, paths, prim_path="/Payload"): + replayed = _replay_events(server, events, prim_path) + original = Usd.Stage.CreateInMemory() + replay = Usd.Stage.CreateInMemory() + apply_events(original, events) + apply_events(replay, replayed) + for path in paths: + expected = original.GetAttributeAtPath(path) + actual = replay.GetAttributeAtPath(path) + assert bool(actual) == bool(expected) + if expected: + assert actual.Get() == expected.Get() + assert actual.GetTimeSamples() == expected.GetTimeSamples() + for time in expected.GetTimeSamples(): + assert actual.Get(time) == expected.Get(time) + return replayed + + +@pytest.mark.parametrize(("payload_path", "deleted_path"), [ + ("/Payload", "/Payload/Group/Child"), + ("/Payload", "/Payload/Group"), + ("/Payload", "/Payload"), + ("/World/Payload", "/World"), +]) +def test_child_replay_does_not_revive_values_before_subtree_deletion( + server, payload_path, deleted_path, +): + path = f"{payload_path}/Group/Child" + create = [ + {"k": "ensure_prim", "prim": path, "typeName": "Xform"}, + {"k": "ensure_xform_ops", "prim": path}, + ] + events = [ + *create, + {"k": "set_xform_trs", "prim": path, "fields": ["t"], "t": [99, 0, 0]}, + {"k": "delete_prim", "prim": deleted_path}, + *create, + {"k": "set_xform_trs", "prim": path, "fields": ["r"], "r": [1, 0, 0, 0]}, + ] + replayed = _assert_replayed_attributes_match_history( + server, events, [f"{path}.xformOp:translate", f"{path}.xformOp:orient"], + prim_path=payload_path, + ) + assert all(event["prim"].startswith(payload_path + "/") for event in replayed) + + +@pytest.mark.parametrize("exact_table", [False, True]) +def test_child_replay_preserves_sample_erasure_before_later_partial_writes(server, exact_table): + path = "/Payload/Child" + events = [ + {"k": "ensure_prim", "prim": path, "typeName": "Xform"}, + {"k": "ensure_xform_ops", "prim": path}, + {"k": "set_xform_trs", "prim": path, "fields": ["t"], "t": [5, 0, 0]}, + {"k": "set_xform_trs", "prim": path, "fields": ["t", "s"], + "t": [99, 0, 0], "s": [2, 2, 2], "time": 1}, + {"k": "set_xform_trs", "prim": path, "fields": ["t"], + "t": [3, 0, 0], "time": 2}, + ] + if exact_table: + source = Usd.Stage.CreateInMemory() + apply_events(source, events[:2]) + source.GetAttributeAtPath(f"{path}.xformOp:translate").Set(Gf.Vec3d(4, 0, 0), 2) + events.append({ + "k": "set_sdf_spec_fields", "prim": path, + "spec_path": f"{path}.xformOp:translate", "spec_kind": "attribute", + "fields": ["timeSamples"], "removed": False, + "fragment": serialize_spec_fields( + source.GetRootLayer(), Sdf.Path(f"{path}.xformOp:translate"), + "attribute", ["timeSamples"], stabilize_asset_paths=False, + ), + }) + else: + events.append({ + "k": "erase_time_samples", "prim": path, + "spec_path": f"{path}.xformOp:translate", "times": [1], + }) + events.append({ + "k": "set_xform_trs", "prim": path, "fields": ["r"], + "r": [1, 0, 0, 0], "time": 1, + }) + _assert_replayed_attributes_match_history( + server, events, [f"{path}.xformOp:{name}" for name in ("translate", "orient", "scale")], + ) + + +def test_payload_root_deletion_only_discards_opinions_in_its_authored_layer(server): + path = "/Payload/Child" + histories = { + "weak": [ + {"k": "ensure_prim", "prim": path, "typeName": "Xform"}, + {"k": "ensure_xform_ops", "prim": path}, + {"k": "set_xform_trs", "prim": path, "fields": ["t"], "t": [1, 0, 0]}, + ], + "strong": [ + {"k": "ensure_prim", "prim": path, "typeName": "Xform"}, + {"k": "ensure_xform_ops", "prim": path}, + {"k": "set_xform_trs", "prim": path, "fields": ["t"], "t": [99, 0, 0]}, + {"k": "delete_prim", "prim": "/Payload"}, + {"k": "ensure_prim", "prim": path, "typeName": "Xform"}, + {"k": "ensure_xform_ops", "prim": path}, + {"k": "set_xform_trs", "prim": path, "fields": ["r"], "r": [1, 0, 0, 0]}, + ], + } + for layer_key, events in histories.items(): + for event in events: + server.append_log({ + "type": "event", "seq": server.assign_seq(), "layer_key": layer_key, + "event": event, + }) + records = child_replay_records(server.store, "/Payload") + for layer_key, events in histories.items(): + original = Usd.Stage.CreateInMemory() + replay = Usd.Stage.CreateInMemory() + apply_events(original, events) + apply_events(replay, [record.event for record in records if record.layer_key == layer_key]) + for name in ("translate", "orient"): + attr_path = f"{path}.xformOp:{name}" + assert ( + replay.GetAttributeAtPath(attr_path).Get() + == original.GetAttributeAtPath(attr_path).Get() + ) diff --git a/tests/unit/test_periodic_compaction.py b/tests/unit/test_periodic_compaction.py index a5f9d2e..9e2546f 100644 --- a/tests/unit/test_periodic_compaction.py +++ b/tests/unit/test_periodic_compaction.py @@ -71,7 +71,7 @@ def test_runtime_enable_and_disable(tmp_path): srv = UsdSyncServer(log_path=str(tmp_path / "rt.db")) calls = _instrument(srv) try: - assert srv._compact_thread is None + assert not srv._compactor.running srv._commit_events(EVENTS, client_id="c", origin="o", client_addr="a:1") srv.set_compact_interval(0.2) @@ -102,14 +102,14 @@ def test_txn_during_compaction_survives_via_delta_merge(tmp_path): srv._commit_events(EVENTS, client_id="c", origin="o", client_addr="a:1") phase1_started = threading.Event() - original_build = srv._build_compacted + original_build = srv._maintenance.build_compacted def slow_build(rows): phase1_started.set() time.sleep(0.4) return original_build(rows) - srv._build_compacted = slow_build + srv._maintenance.build_compacted = slow_build compactor = threading.Thread(target=srv.compact_log) compactor.start() diff --git a/tests/unit/test_playback_leader.py b/tests/unit/test_playback_leader.py index 147e82a..4559e1e 100644 --- a/tests/unit/test_playback_leader.py +++ b/tests/unit/test_playback_leader.py @@ -2,6 +2,8 @@ from __future__ import annotations +import threading + import pytest try: @@ -42,6 +44,46 @@ def test_initial_playback_state(server): } +def test_public_playback_dictionary_stays_live(server): + state = server.playback + assert state["leader_client_id"] is None + with server.playback_lock: + state["time"] = 42.0 + state["rate"] = 2.0 + + assert server.get_playback_state()["time"] == 42.0 + assert server.get_playback_state()["rate"] == 2.0 + assert server.claim_playback("leader") == (True, "leader") + assert state["leader_client_id"] == "leader" + server.apply_playback_control("leader", "play") + assert state["playing"] is True + assert server.release_playback("leader") + assert state["leader_client_id"] is None + assert server.get_playback_state()["leader_client_id"] == "" + + +def test_public_playback_assignment_replaces_state_and_lock(server): + lock = threading.Lock() + + class LockedState(dict): + def __getitem__(self, key): + assert lock.locked(), "playback access ignored the assigned lock" + return super().__getitem__(key) + + state = LockedState(time=12.0, playing=False, rate=1.5, leader_client_id=None) + server.playback = state + server.playback_lock = lock + assert server.playback is state + assert server.playback_lock is lock + assert server.claim_playback("leader") == (True, "leader") + assert server.get_playback_state()["time"] == 12.0 + accepted, snapshot, _leader = server.apply_playback_control("leader", "play") + assert accepted + assert snapshot["playing"] is True + assert snapshot["rate"] == 1.5 + assert server.release_playback("leader") + + def test_first_claim_grants(server): ok, leader = server.claim_playback("c1") assert ok is True diff --git a/tests/unit/test_replay_mode_contract.py b/tests/unit/test_replay_mode_contract.py index c67ef55..bed4dcc 100644 --- a/tests/unit/test_replay_mode_contract.py +++ b/tests/unit/test_replay_mode_contract.py @@ -22,15 +22,15 @@ def test_flat_replay_reservation_is_available_for_default_stack(server): accepted, reason = server.reserve_receiver_replay_mode(False) assert accepted assert reason == "" - assert server._flat_receiver_count == 1 + assert server._collaboration.flat_receiver_count == 1 server.release_receiver_replay_mode(False) - assert server._flat_receiver_count == 0 + assert server._collaboration.flat_receiver_count == 0 def test_layered_replay_does_not_reserve_flat_capacity(server): assert server.reserve_receiver_replay_mode(True) == (True, "") - assert server._flat_receiver_count == 0 + assert server._collaboration.flat_receiver_count == 0 server.release_receiver_replay_mode(True) @@ -40,7 +40,7 @@ def test_department_policy_requires_layered_replay(server): accepted, reason = server.reserve_receiver_replay_mode(False) assert not accepted assert "department" in reason - assert server._flat_receiver_count == 0 + assert server._collaboration.flat_receiver_count == 0 def test_materialized_layer_stack_requires_layered_replay(server): @@ -73,7 +73,6 @@ def test_flat_receiver_blocks_layer_stack_changes(server): assert server.department_priority == [] assert "artist" not in server.client_layers - assert "artist" not in server._client_layer_keys server.set_department_priority([]) assert server.unmute_layer("default") diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index e4f51fb..0fac6bb 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -12,6 +12,7 @@ from openusdconnect.codec import message_to_dict from openusdconnect.protocol_constants import NON_COLLABORATION_KINDS from openusdconnect.server import TokenBucket, UsdSyncServer +from tests.helpers import ReceiverStub @pytest.fixture @@ -23,6 +24,39 @@ def srv(tmp_path): s.store.close() +class TestPublicDepartmentPriority: + def test_assignment_keeps_live_list_without_applying_order(self, srv): + original_token = srv.get_snapshot_token() + priority = ["animation"] + + srv.department_priority = priority + assert srv.department_priority is priority + priority.append("layout") + assert srv.department_priority == ["animation", "layout"] + assert not srv.reserve_receiver_replay_mode(False)[0] + assert srv.get_snapshot_token() == original_token + + srv.department_priority.clear() + assert priority == [] + assert srv.reserve_receiver_replay_mode(False) == (True, "") + srv.release_receiver_replay_mode(False) + + @pytest.mark.parametrize("priority", [[""], ["animation", "animation"]]) + def test_raw_assignment_and_validated_update_remain_distinct(self, srv, priority): + srv.department_priority = priority + assert srv.department_priority is priority + with pytest.raises(ValueError): + srv.set_department_priority(priority) + + def test_validated_update_copies_input(self, srv): + priority = ["animation"] + srv.set_department_priority(priority) + assert srv.department_priority == priority + assert srv.department_priority is not priority + priority.clear() + assert srv.department_priority == ["animation"] + + # --------------------------------------------------------------------------- # Sequence assignment # --------------------------------------------------------------------------- @@ -445,7 +479,7 @@ def test_load_rules_compact_in_global_runtime_scope(self, srv): from openusdconnect.server.compaction import LogCompaction compaction = LogCompaction() - compaction.add_record( + compaction.add_stored_record( 1, encode_message( { @@ -455,7 +489,7 @@ def test_load_rules_compact_in_global_runtime_scope(self, srv): } ), ) - compaction.add_record( + compaction.add_stored_record( 2, encode_message( { @@ -466,7 +500,7 @@ def test_load_rules_compact_in_global_runtime_scope(self, srv): ), ) - assert [entry.event["k"] for entry in compaction.replay_entries()] == ["unload_payload"] + assert [entry.event["k"] for entry in compaction.replay_records()] == ["unload_payload"] def test_stage_metadata_compaction_merges_sparse_fields(self, srv): self._insert_events( @@ -676,7 +710,7 @@ class BrokenRequest: def sendall(self, _payload): raise OSError("receiver disconnected") - class FakeHandler: + class FakeHandler(ReceiverStub): def __init__(self, request, address): self.request = request self.client_address = address @@ -712,7 +746,7 @@ def test_compaction_drains_queued_broadcast_before_resync(self, srv): [{"k": "ensure_prim", "prim": "/A", "typeName": "Xform"}], ) - class FakeHandler: + class FakeHandler(ReceiverStub): def __init__(self): self.request = io.BytesIO() self.request.sendall = self.request.write @@ -760,17 +794,26 @@ def __init__(self): assert messages[3]["type"] == "replay_complete" assert messages[3]["head_seq"] == messages[2]["seq"] - def test_purge_sends_resync_and_ready_watermark_under_one_send_lock(self, srv): + @pytest.mark.parametrize("operation", ["purge", "compact_log"]) + @pytest.mark.parametrize("layered", [False, True]) + def test_history_resync_sends_controls_and_records_under_one_send_lock( + self, srv, operation, layered, + ): import io from openusdconnect.framing import recv_framed_rfile - class FakeHandler: + class FakeHandler(ReceiverStub): def __init__(self): self.request = io.BytesIO() - self.request.sendall = self.request.write - self.client_address = ("purge", 1) + self.request.sendall = self.sendall + self.client_address = ("resync", 1) self.send_lock = threading.Lock() + self._layered_replay = layered + + def sendall(self, payload): + assert self.send_lock.locked() + self.request.write(payload) self._insert_events( srv, @@ -779,13 +822,22 @@ def __init__(self): handler = FakeHandler() srv.receivers.add(handler) - srv.purge() + getattr(srv, operation)() handler.request.seek(0) - resync = message_to_dict(recv_framed_rfile(handler.request)) - ready = message_to_dict(recv_framed_rfile(handler.request)) - assert resync["type"] == "resync" - assert ready == {"type": "replay_complete", "head_seq": 0, "epoch": 1} + messages = [] + while handler.request.tell() < len(handler.request.getvalue()): + messages.append(message_to_dict(recv_framed_rfile(handler.request))) + expected_types = ["resync"] + if layered: + expected_types.append("layer_stack_state") + if operation == "compact_log": + expected_types.append("event") + assert messages[-2]["event"]["prim"] == "/BeforePurge" + assert [message["type"] for message in messages] == [*expected_types, "replay_complete"] + assert messages[-1] == { + "type": "replay_complete", "head_seq": int(operation == "compact_log"), "epoch": 1, + } def test_purge_invalidates_cached_composed_prim_count(self, srv): srv._commit_events([ @@ -840,7 +892,7 @@ def test_replay_from_sends_events(self, srv): import io - class FakeHandler: + class FakeHandler(ReceiverStub): def __init__(self): self.request = io.BytesIO() @@ -877,7 +929,7 @@ def test_replay_from_with_offset(self, srv): import io - class FakeHandler: + class FakeHandler(ReceiverStub): def __init__(self): self.request = io.BytesIO() @@ -912,7 +964,7 @@ def test_replay_from_stops_at_captured_tail(self, srv): } ) - class FakeHandler: + class FakeHandler(ReceiverStub): def __init__(self): self.request = io.BytesIO() @@ -942,7 +994,7 @@ class BrokenRequest: def sendall(self, _payload): raise OSError("receiver disconnected") - class FakeHandler: + class FakeHandler(ReceiverStub): request = BrokenRequest() with pytest.raises(OSError, match="receiver disconnected"): @@ -955,11 +1007,49 @@ class FakeHandler: class TestBroadcast: + def test_shutdown_drains_playback_release_enqueued_after_stop_marker(self, srv): + import io + + from openusdconnect.framing import recv_framed_rfile + + # Drive the queue synchronously to put the disconnect before the stop + # marker, and the resulting playback update after it. + srv.shutdown() + + class FakeHandler(ReceiverStub): + def __init__(self, client_id): + self._client_id = client_id + self.client_address = (client_id, 1) + self.send_lock = threading.Lock() + self.request = io.BytesIO() + self.request.sendall = self.sendall + + def sendall(self, payload): + if self._client_id == "leader": + raise OSError("disconnected") + self.request.write(payload) + + leader = FakeHandler("leader") + follower = FakeHandler("follower") + srv.receivers.update((leader, follower)) + srv.claim_playback("leader") + srv._broadcast_queue.put((b"queued event", (leader,))) + srv._broadcast_queue.put(None) + + srv._broadcast_loop() + + assert srv.receivers == {follower} + follower.request.seek(0) + update = message_to_dict(recv_framed_rfile(follower.request)) + assert update["type"] == "playback_state" + assert not update["leader_client_id"] + assert srv._broadcast_queue.unfinished_tasks == 0 + def test_broadcast_to_receivers(self, srv): """broadcast sends to all registered receivers.""" import io - class FakeHandler: + class FakeHandler(ReceiverStub): def __init__(self): self.request = io.BytesIO() self.client_address = ("fake", 0) @@ -995,7 +1085,7 @@ def __init__(self): def test_broadcast_removes_dead_receivers(self, srv): """broadcast discards receivers whose socket is broken.""" - class DeadHandler: + class DeadHandler(ReceiverStub): client_address = ("dead", 0) send_lock = threading.Lock() @@ -1019,7 +1109,7 @@ def sendall(data): def test_queued_broadcast_excludes_receivers_that_join_later(self, srv): import io - class FakeHandler: + class FakeHandler(ReceiverStub): def __init__(self, address): self.request = io.BytesIO() self.request.sendall = self.request.write @@ -1049,7 +1139,7 @@ def __init__(self, address): def test_broadcast_targets_negotiated_receiver_audience(self, srv): import io - class FakeHandler: + class FakeHandler(ReceiverStub): def __init__(self, layered): self.request = io.BytesIO() self.request.sendall = self.request.write @@ -1087,37 +1177,28 @@ def test_transaction_uses_one_complete_stream_and_notifies_listeners( ): import io - class LayeredHandler: - def __init__(self): + class FakeHandler(ReceiverStub): + def __init__(self, layered): self.request = io.BytesIO() self.request.sendall = self.request.write - self.client_address = ("layered", 1) + self.client_address = ("fake", int(layered)) self.send_lock = threading.Lock() - self._layered_replay = True + self._layered_replay = layered - layered = LayeredHandler() - srv.receivers.add(layered) + flat = FakeHandler(False) + layered = FakeHandler(True) + srv.receivers.update((flat, layered)) observed = [] srv.add_event_listener(observed.append) - audiences = [] - send_to_all = srv._send_to_all + deliveries = [] + send_to_receivers = srv._send_to_receivers - def _record_audience( - payload, - exclude_origin=None, - audience="all", - targets=None, - ): - audiences.append(audience) - return send_to_all( - payload, - exclude_origin=exclude_origin, - audience=audience, - targets=targets, - ) + def record_delivery(payload, targets=None): + deliveries.append((payload, targets)) + return send_to_receivers(payload, targets=targets) - monkeypatch.setattr(srv, "_send_to_all", _record_audience) + monkeypatch.setattr(srv, "_send_to_receivers", record_delivery) event = { "k": "ensure_prim", "prim": "/LayeredOnly", @@ -1128,14 +1209,15 @@ def _record_audience( srv.broadcast_transaction_views(records) srv._broadcast_queue.join() - assert audiences == ["layered"] + assert len(deliveries) == 1 + assert set(deliveries[0][1]) == {flat, layered} assert observed == [records[0][0]] - assert layered.request.getvalue() + assert flat.request.getvalue() == layered.request.getvalue() == deliveries[0][0] def test_transaction_includes_the_authoring_origin_for_every_receiver(self, srv): import io - class FakeHandler: + class FakeHandler(ReceiverStub): def __init__(self, layered): self.request = io.BytesIO() self.request.sendall = self.request.write @@ -1171,7 +1253,7 @@ def test_grouped_layered_transactions_share_one_receiver_send( ): import io - class LayeredHandler: + class LayeredHandler(ReceiverStub): def __init__(self): self.request = io.BytesIO() self.request.sendall = self.request.write @@ -1184,13 +1266,13 @@ def __init__(self): observed = [] srv.add_event_listener(observed.append) send_calls = [] - send_to_all = srv._send_to_all + send_to_receivers = srv._send_to_receivers def observe_send(payload, **kwargs): send_calls.append(payload) - return send_to_all(payload, **kwargs) + return send_to_receivers(payload, **kwargs) - monkeypatch.setattr(srv, "_send_to_all", observe_send) + monkeypatch.setattr(srv, "_send_to_receivers", observe_send) first = srv._commit_events( [{"k": "ensure_prim", "prim": "/First", "typeName": "Xform"}] ) @@ -1210,7 +1292,7 @@ def test_grouped_transactions_share_one_complete_payload(self, srv, monkeypatch) from openusdconnect.framing import recv_framed_rfile - class FlatHandler: + class FlatHandler(ReceiverStub): def __init__(self, origin, port): self.request = io.BytesIO() self.request.sendall = self.request.write @@ -1225,13 +1307,13 @@ def __init__(self, origin, port): other = FlatHandler("other-origin", 4) srv.receivers.update((plain_a, plain_b, author, other)) send_calls = [] - send_to_all = srv._send_to_all + send_to_receivers = srv._send_to_receivers def observe_send(payload, **kwargs): send_calls.append((payload, kwargs["targets"])) - return send_to_all(payload, **kwargs) + return send_to_receivers(payload, **kwargs) - monkeypatch.setattr(srv, "_send_to_all", observe_send) + monkeypatch.setattr(srv, "_send_to_receivers", observe_send) first = srv._commit_events( [{"k": "ensure_prim", "prim": "/FirstFlat", "typeName": "Xform"}] ) @@ -1272,7 +1354,7 @@ def test_replayed_payload_children_follow_receiver_replay_mode(self, srv): from openusdconnect.framing import recv_framed_rfile - class FakeHandler: + class FakeHandler(ReceiverStub): def __init__(self, layered): self.request = io.BytesIO() self.request.sendall = self.request.write @@ -1313,6 +1395,19 @@ def __init__(self, layered): class TestDBResume: + def test_rejects_non_event_record_in_managed_history(self, tmp_path): + from openusdconnect.codec import encode_message + from openusdconnect.event_store import SqliteEventStore + + store = SqliteEventStore(str(tmp_path / "invalid-history.db")) + try: + store.append(1, encode_message({"type": "ping"})) + with pytest.raises(ValueError, match="managed log contains an unsupported record"): + UsdSyncServer(event_store=store) + assert store.get_count() == 1 + finally: + store.close() + def test_resumes_seq_from_existing_db(self, tmp_path): """Server resumes sequence counter from existing DB.""" db = str(tmp_path / "resume.db") @@ -2248,4 +2343,4 @@ def test_tracks_structural_events(self, srv): {"k": "set_visibility", "prim": "/World/X", "visible": False}, ] assert srv.apply_txn(events) is None - assert "/World/X" in srv._prim_paths + assert "/World/X" in {row["path"] for row in srv.get_prim_tree()} diff --git a/tests/unit/test_server_collaboration.py b/tests/unit/test_server_collaboration.py new file mode 100644 index 0000000..e993050 --- /dev/null +++ b/tests/unit/test_server_collaboration.py @@ -0,0 +1,118 @@ +"""Collaboration policy keeps client assignments and layer lifecycle consistent.""" + +import threading +from concurrent.futures import ThreadPoolExecutor + +import pytest +from pxr import Usd + +from openusdconnect.protocol_constants import LayerMode +from openusdconnect.server.collaboration import CollaborationPolicy +from openusdconnect.server.scene import SceneState + + +@pytest.fixture +def scene(): + return SceneState(Usd.Stage.CreateInMemory(), layer_mode=LayerMode.MANAGED, op_cache_size=16) + + +def _policy(scene, **callbacks): + return CollaborationPolicy( + scene, + department_priority=[], + bump_snapshot_epoch=callbacks.get("bump_snapshot_epoch", lambda _reason: None), + broadcast_layer_stack_state=callbacks.get("broadcast_layer_stack_state", lambda: None), + ) + + +@pytest.mark.parametrize("merge_into_root", [False, True]) +def test_join_cannot_lose_layer_to_last_client_departure(scene, monkeypatch, merge_into_root): + policy = _policy(scene) + original_layer = policy.get_or_create_client_layer("leaving", "animation") + assigning = threading.Event() + resume_join = threading.Event() + departure_started = threading.Event() + departure_finished = threading.Event() + + class PausedAssignments(dict): + def __setitem__(self, client_id, layer_key): + if client_id == "joining": + # Pause after resolving the existing layer, before recording + # its new user: this was the last-client removal race window. + assigning.set() + assert resume_join.wait(5) + super().__setitem__(client_id, layer_key) + + monkeypatch.setattr(policy, "_client_layer_keys", PausedAssignments(policy._client_layer_keys)) + + def depart(): + departure_started.set() + try: + return policy.release_client_assignment("leaving", merge_into_root=merge_into_root) + finally: + departure_finished.set() + + with ThreadPoolExecutor(max_workers=2) as workers: + joined = workers.submit(policy.get_or_create_client_layer, "joining", "animation") + try: + assert assigning.wait(5) + departed = workers.submit(depart) + assert departure_started.wait(5) + assert not departure_finished.wait(0.1) + finally: + resume_join.set() + assert joined.result(timeout=5) is original_layer + assert departed.result(timeout=5) + + assert policy.client_layers == {"joining": original_layer} + assert policy.resolve_layer("animation") is original_layer + assert policy.get_layer_stack_info()[0]["clients"] == ["joining"] + + +def test_callbacks_observe_complete_assignments_without_holding_scene_lock(scene): + snapshots = [] + + def observe(*_args): + finished = threading.Event() + result = [] + + def read(): + try: + result.append((policy.client_layers, policy.get_layer_stack_info())) + finally: + finished.set() + + reader = threading.Thread(target=read, daemon=True) + reader.start() + assert finished.wait(5), "publication held the scene lock and blocked an observer" + reader.join() + snapshots.extend(result) + + policy = _policy(scene, bump_snapshot_epoch=observe, broadcast_layer_stack_state=observe) + layer = policy.get_or_create_client_layer("artist", "animation") + assert all(assignments == {"artist": layer} for assignments, _info in snapshots) + assert all(info[0]["clients"] == ["artist"] for _assignments, info in snapshots) + + policy.set_department_priority(["animation"]) + assert policy.set_muted("artist", True) + assert policy.set_muted("artist", False) + assert policy.release_client_assignment("artist", merge_into_root=True) + assert snapshots[-1] == ({}, []) + + policy.get_or_create_client_layer("artist", "animation") + assert policy.release_client_assignment("artist", merge_into_root=False) + assert snapshots[-1] == ({}, []) + + +def test_priority_list_mutation_updates_policy(scene): + policy = _policy(scene) + priority = policy.department_priority + priority.append("animation") + + assert policy.department_priority is priority + assert policy.reserve_receiver_replay_mode(False) == ( + False, "department collaboration requires layered replay", + ) + priority.clear() + assert policy.reserve_receiver_replay_mode(False) == (True, "") + policy.release_receiver_replay_mode(False) diff --git a/tests/unit/test_server_journal.py b/tests/unit/test_server_journal.py new file mode 100644 index 0000000..94d9aef --- /dev/null +++ b/tests/unit/test_server_journal.py @@ -0,0 +1,85 @@ +"""Persistence-worker recovery and shutdown behavior.""" + +import threading + +import pytest + +from openusdconnect.event_store import SqliteEventStore +from openusdconnect.server.journal import EventJournal + + +def test_commit_scope_preserves_order_through_publication_after_rollback(tmp_path): + store = SqliteEventStore(str(tmp_path / "ordered.db")) + journal = EventJournal(store, durability="strict", metrics=None) + attempted = threading.Event() + entered = threading.Event() + next_sequences = [] + + def next_commit(): + attempted.set() + with journal.commit_scope(): + next_sequences.append(journal.assign_seq()) + entered.set() + + worker = threading.Thread(target=next_commit, daemon=True) + try: + with journal.commit_scope(): + with pytest.raises(ValueError, match="rejected"): + with journal.reserve_sequences(): + assert journal.assign_seq() == 1 + worker.start() + assert attempted.wait(5) + raise ValueError("rejected") + # Reservation cleanup must not release the outer publication boundary. + assert not entered.wait(0.05) + assert entered.wait(5) + worker.join(5) + assert next_sequences == [1] + finally: + worker.join(5) + journal.shutdown() + store.close() + + +def test_realtime_shutdown_drains_after_a_failed_write(tmp_path, monkeypatch): + store = SqliteEventStore(str(tmp_path / "events.db")) + journal = EventJournal(store, durability="realtime", metrics=None) + append = store.append_batch + attempted = [] + + def fail_first(rows, **kwargs): + attempted.append(rows[0][0]) + if len(attempted) == 1: + raise OSError("injected asynchronous write failure") + return append(rows, **kwargs) + + monkeypatch.setattr(store, "append_batch", fail_first) + journal.start() + try: + for path in ("/Failed", "/Persisted"): + encoded = journal.assign_and_encode_events( + [("default", {"k": "ensure_prim", "prim": path, "typeName": "Xform"})], + client_id=None, origin=None, client_addr=None, + ) + journal.append_batch(encoded.store_rows) + journal.shutdown() + journal.shutdown() + + assert attempted == [1, 2] + assert store.get_count() == journal.event_count == 1 + assert store.get_max_seq() == 2 + assert not journal.running + + drained = threading.Event() + + def drain(): + journal.drain() + drained.set() + + waiter = threading.Thread(target=drain, daemon=True) + waiter.start() + assert drained.wait(2), "shutdown left persistence work permanently pending" + waiter.join() + finally: + journal.shutdown() + store.close() diff --git a/tests/unit/test_server_playback.py b/tests/unit/test_server_playback.py new file mode 100644 index 0000000..da4d9ad --- /dev/null +++ b/tests/unit/test_server_playback.py @@ -0,0 +1,68 @@ +"""Playback ownership and concurrency without a running server.""" + +import threading +from concurrent.futures import ThreadPoolExecutor + +from openusdconnect.server.playback import PlaybackController + + +def test_competing_claims_grant_one_leader_and_its_initial_time(): + playback = PlaybackController() + ready = threading.Barrier(8) + + def claim(index): + ready.wait(timeout=5) + return playback.claim(f"client-{index}", initial_time=float(index)) + + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(claim, range(8))) + + winners = [index for index, (granted, _leader) in enumerate(results) if granted] + assert len(winners) == 1 + winner = winners[0] + assert {leader for _granted, leader in results} == {f"client-{winner}"} + assert playback.snapshot() == { + "time": float(winner), + "playing": False, + "rate": 1.0, + "leader_client_id": f"client-{winner}", + } + + +def test_returned_snapshots_cannot_mutate_playback(): + playback = PlaybackController() + playback.claim("leader", initial_time=12.0) + accepted, state, _leader = playback.apply_control("leader", "play") + assert accepted + state["time"] = -1.0 + state["leader_client_id"] = "other" + playback.snapshot()["playing"] = False + + assert playback.snapshot() == { + "time": 12.0, + "playing": True, + "rate": 1.0, + "leader_client_id": "leader", + } + + +def test_leadership_handoff_preserves_playhead_and_rate(): + playback = PlaybackController() + playback.claim("first", initial_time=24.0) + playback.apply_control("first", "set_rate", rate=2.0) + playback.apply_control("first", "play") + assert not playback.release("other") + assert playback.release("first") + assert playback.claim("second") == (True, "second") + + assert playback.snapshot() == { + "time": 24.0, + "playing": True, + "rate": 2.0, + "leader_client_id": "second", + } + assert playback.apply_control("first", "stop") == ( + False, + "not the playback leader (current: second)", + "second", + ) diff --git a/tests/unit/test_server_runtime.py b/tests/unit/test_server_runtime.py index 2307aaa..46f1fb1 100644 --- a/tests/unit/test_server_runtime.py +++ b/tests/unit/test_server_runtime.py @@ -30,10 +30,18 @@ def assert_released(runtime): if runtime.server is not None: assert runtime.server.socket.fileno() == -1 state = runtime.sync_server - for name in ("_broadcast_thread", "_transaction_thread", "_persist_thread", "_compact_thread"): - thread = getattr(state, name, None) - if thread is not None: - assert not thread.is_alive() + thread = getattr(state, "_broadcast_thread", None) + if thread is not None: + assert not thread.is_alive() + compactor = getattr(state, "_compactor", None) + if compactor is not None: + assert not compactor.running + coordinator = getattr(state, "_transactions", None) + if coordinator is not None: + assert not coordinator.running + journal = getattr(state, "_journal", None) + if journal is not None: + assert not journal.running if getattr(state, "store", None) is not None: with pytest.raises(sqlite3.ProgrammingError, match="closed"): state.store.get_count() @@ -207,9 +215,10 @@ def fail(self): assert len(stores) == len(states) == 1 with pytest.raises(sqlite3.ProgrammingError, match="closed"): stores[0].get_count() - for name in ("_broadcast_thread", "_persist_thread", "_compact_thread"): - thread = getattr(states[0], name) - assert thread is None or thread.ident is None + assert not states[0]._compactor.running + assert not states[0]._journal.running + thread = states[0]._broadcast_thread + assert thread is None or thread.ident is None def test_failed_state_preserves_caller_owned_store(config, monkeypatch): @@ -232,21 +241,27 @@ def test_worker_start_failure_stops_started_workers(config, monkeypatch): import openusdconnect.server.state as state_module states = [] - original = state_module.UsdSyncServer._start_compaction_thread + original = state_module.PeriodicCompactor.start + original_initialize = state_module.UsdSyncServer.__init__ - def fail(self): + def initialize(self, **kwargs): states.append(self) + original_initialize(self, **kwargs) + + def fail(self): original(self) raise RuntimeError("worker startup failed") config.durability = "realtime" config.compact_interval = 60 - monkeypatch.setattr(state_module.UsdSyncServer, "_start_compaction_thread", fail) + monkeypatch.setattr(state_module.UsdSyncServer, "__init__", initialize) + monkeypatch.setattr(state_module.PeriodicCompactor, "start", fail) with pytest.raises(RuntimeError, match="worker startup failed"): start_server(config) state = states[0] - for name in ("_broadcast_thread", "_persist_thread", "_compact_thread"): - assert not getattr(state, name).is_alive() + assert not state._compactor.running + assert not state._journal.running + assert not state._broadcast_thread.is_alive() with pytest.raises(sqlite3.ProgrammingError, match="closed"): state.store.get_count() state.shutdown() diff --git a/tests/unit/test_server_scene.py b/tests/unit/test_server_scene.py new file mode 100644 index 0000000..fd990db --- /dev/null +++ b/tests/unit/test_server_scene.py @@ -0,0 +1,199 @@ +"""Scene ownership of rollback, replay targets, and detached exports.""" + +import pytest +from pxr import Sdf, Usd + +from openusdconnect.protocol_constants import LayerMode +from openusdconnect.server.scene import SceneState + + +@pytest.fixture +def scene(): + return SceneState(Usd.Stage.CreateInMemory(), layer_mode=LayerMode.MANAGED, op_cache_size=8) + + +def transform_events(value): + return [ + {"k": "ensure_prim", "prim": "/Object", "typeName": "Xform"}, + {"k": "ensure_xform_ops", "prim": "/Object"}, + {"k": "set_xform_trs", "prim": "/Object", "fields": ["t"], "t": [value, 0, 0]}, + ] + + +def test_replay_restores_target_and_authors_each_layer(scene): + other, _added = scene.layer_stack.ensure_layer("department:anim", label="anim") + original_target = Usd.EditTarget(scene.stage.GetRootLayer()) + scene.stage.SetEditTarget(original_target) + routed = [ + *((scene.edit_layer, event) for event in transform_events(1)), + *((other, event) for event in transform_events(2)), + (scene.edit_layer, transform_events(3)[-1]), + ] + + scene.replay_events(routed) + + assert scene.stage.GetEditTarget() == original_target + assert tuple(scene.edit_layer.GetAttributeAtPath("/Object.xformOp:translate").default) == ( + 3, 0, 0, + ) + assert tuple(other.GetAttributeAtPath("/Object.xformOp:translate").default) == (2, 0, 0) + assert scene.edit_layer.GetAttributeAtPath("/Object.xformOpOrder").default + assert other.GetAttributeAtPath("/Object.xformOpOrder").default + assert [row["path"] for row in scene.get_prim_tree()] == ["/Object"] + + +def test_failed_persistence_rolls_back_opinions_and_allows_subsequent_edits(scene): + scene.apply_validated(transform_events(1)) + original_target = Usd.EditTarget(scene.stage.GetRootLayer()) + scene.stage.SetEditTarget(original_target) + before_tree = scene.get_prim_tree() + session = scene.stage.GetSessionLayer() + before_session = session.ExportToString() + + with pytest.raises(RuntimeError, match="persistence failed"): + with scene.atomic_edit([(scene.edit_layer, {"/Object"})], include_session=True): + scene.apply_validated(transform_events(2), update_tracking=False) + session.customLayerData = {"test": "changed"} + raise RuntimeError("persistence failed") + + assert scene.stage.GetEditTarget() == original_target + assert session.ExportToString() == before_session + assert scene.get_prim_tree() == before_tree + translation = scene.stage.GetPrimAtPath("/Object").GetAttribute("xformOp:translate") + assert tuple(translation.Get()) == (1, 0, 0) + + with scene.atomic_edit([(scene.edit_layer, {"/Object"})]): + scene.apply_validated(transform_events(3)) + assert scene.stage.GetEditTarget() == original_target + assert tuple(scene.stage.GetPrimAtPath("/Object").GetAttribute("xformOp:translate").Get()) == ( + 3, 0, 0, + ) + + +def test_export_snapshot_does_not_change_with_live_layer(scene): + scene.apply_validated(transform_events(1)) + snapshot = scene.snapshot_layer(scene.edit_layer) + + scene.apply_validated(transform_events(2)) + + exported = Usd.Stage.Open(snapshot) + value = exported.GetPrimAtPath("/Object").GetAttribute("xformOp:translate").Get() + assert tuple(value) == (1, 0, 0) + assert tuple(scene.stage.GetPrimAtPath("/Object").GetAttribute("xformOp:translate").Get()) == ( + 2, 0, 0, + ) + + +@pytest.mark.parametrize("rename", [False, True]) +def test_namespace_edits_update_tracked_descendants(scene, rename): + events = [ + {"k": "ensure_prim", "prim": path, "typeName": type_name} + for path, type_name in ( + ("/Parent", "Xform"), ("/Parent/Child", "Sphere"), ("/ParentOther", "Scope"), + ) + ] + events.append({"k": "set_instanceable", "prim": "/Parent/Child", "instanceable": True}) + scene.apply_validated(events) + assert scene.get_prim_count() == 3 + edit = ( + {"k": "rename_prim", "prim": "/Parent", "new_name": "Renamed"} + if rename else {"k": "delete_prim", "prim": "/Parent"} + ) + scene.apply_validated([edit]) + + def assert_tracking(): + tree = {row["path"]: row for row in scene.get_prim_tree()} + expected = {"/ParentOther", "/Renamed", "/Renamed/Child"} if rename else {"/ParentOther"} + assert set(tree) == expected + assert scene.get_tracked_prim_count() == len(expected) + assert scene.get_prim_count() == len(expected) + assert scene.get_instance_count() == int(rename) + if rename: + assert tree["/Renamed/Child"]["typeName"] == "Sphere" + assert tree["/Renamed/Child"]["instanceable"] + + assert_tracking() + scene.rebuild_caches([*events, edit]) + assert_tracking() + + +def test_namespace_tracking_handles_repeated_deletes_and_new_descendants(scene): + scene.apply_validated([ + {"k": "ensure_prim", "prim": "/Parent/First", "typeName": "Sphere"}, + {"k": "ensure_prim", "prim": "/Parent/Second", "typeName": "Cube"}, + ]) + scene.apply_validated([{"k": "delete_prim", "prim": "/Parent/First"}]) + scene.apply_validated([{"k": "delete_prim", "prim": "/Parent/First"}]) + scene.apply_validated([ + {"k": "ensure_prim", "prim": "/Parent/New", "typeName": "Xform"}, + {"k": "rename_prim", "prim": "/Parent", "new_name": "Renamed"}, + ]) + assert {row["path"] for row in scene.get_prim_tree()} == { + "/Renamed", "/Renamed/Second", "/Renamed/New", + } + scene.apply_validated([{"k": "delete_prim", "prim": "/Renamed"}]) + assert scene.get_prim_tree() == [] + + +def test_generic_spec_edits_invalidate_composed_count_without_importing_base_tree(scene): + scene.stage.GetRootLayer().ImportFromString('#usda 1.0\ndef Xform "Base" {}') + assert scene.get_prim_count() == 1 + fragment = Sdf.Layer.CreateAnonymous() + spec = Sdf.PrimSpec(fragment, "Created", Sdf.SpecifierDef, "Xform") + event = { + "k": "set_sdf_spec_fields", "prim": "/Created", "spec_path": "/Created", + "spec_kind": "prim", "fields": list(spec.ListInfoKeys()), + "fragment": fragment.ExportToString(), "removed": False, + } + scene.apply_validated([event]) + assert scene.get_prim_count() == 2 + assert scene.get_prim_tree() == [] # This API remains the specialized-event tree. + + scene.apply_validated([{**event, "removed": True, "fragment": ""}]) + assert scene.get_prim_count() == 1 + + +def test_composition_changes_invalidate_count(scene): + source = Usd.Stage.CreateInMemory() + source.DefinePrim("/Source", "Xform") + source.DefinePrim("/Source/Child", "Sphere") + scene.apply_validated([{"k": "ensure_prim", "prim": "/Target", "typeName": "Xform"}]) + assert scene.get_prim_count() == 1 + + scene.apply_validated([{ + "k": "set_payload", "prim": "/Target", + "payloads": [{"asset_path": source.GetRootLayer().identifier, "prim_path": "/Source"}], + }]) + assert scene.get_prim_count() == 2 + for kind, expected in (("unload_payload", 0), ("load_payload", 2)): + scene.apply_validated([{"k": kind, "prim": "/Target"}]) + assert scene.get_prim_count() == expected + for active, expected in ((False, 0), (True, 2)): + scene.apply_validated([{"k": "deactivate_prim", "prim": "/Target", "active": active}]) + assert scene.get_prim_count() == expected + + +def test_count_follows_layer_muting_removal_and_root_merge(scene): + layer, _ = scene.layer_stack.ensure_layer("department:anim") + scene.apply_validated([{"k": "ensure_prim", "prim": "/Object", "typeName": "Xform"}], layer) + assert scene.get_prim_count() == 1 + scene.layer_stack.set_muted("department:anim", True) + assert scene.get_prim_count() == 0 + scene.layer_stack.set_muted("department:anim", False) + assert scene.get_prim_count() == 1 + scene.layer_stack.remove_layer("department:anim") + assert scene.get_prim_count() == 0 + scene.merge_layer_into_root(layer) + assert scene.get_prim_count() == 1 + + +def test_transform_update_keeps_composed_count_cached(scene, monkeypatch): + scene.apply_validated(transform_events(1)) + assert scene.get_prim_count() == 1 + + def unexpected_traversal(): + pytest.fail("an ordinary transform update should not rescan the stage") + + monkeypatch.setattr(scene.stage, "Traverse", unexpected_traversal) + scene.apply_validated([transform_events(2)[-1]]) + assert scene.get_prim_count() == 1 diff --git a/tests/unit/test_server_transactions.py b/tests/unit/test_server_transactions.py new file mode 100644 index 0000000..5a2c64f --- /dev/null +++ b/tests/unit/test_server_transactions.py @@ -0,0 +1,183 @@ +"""Commit worker admission and maintenance-barrier ownership.""" + +import threading +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from openusdconnect.checkpoints import TransactionCheckpoint +from openusdconnect.server._txn_barrier import _TxnBarrier +from openusdconnect.server.transactions import TransactionCoordinator, TransactionRequest +from openusdconnect.server.types import TransactionCommit + + +def request(txn_id): + return TransactionRequest( + events=[], session_id="session", txn_id=txn_id, client_id="client", + origin=None, client_addr=None, layer=None, layer_key="", + ) + + +def commit_one(item): + return TransactionCommit("committed", item.txn_id) + + +def coordinator(*, commit=commit_one, commit_group=None, batch_size=1): + return TransactionCoordinator( + barrier=_TxnBarrier(), commit=commit, commit_group=commit_group, + checkpoint=lambda: TransactionCheckpoint(epoch=0, head_seq=1), + batch_size=batch_size, batch_delay=0, + ) + + +def assert_maintenance_can_enter(barrier): + entered = threading.Event() + + def maintenance(): + with barrier.exclusive(): + entered.set() + + worker = threading.Thread(target=maintenance, daemon=True) + worker.start() + assert entered.wait(5), "completed requests still block maintenance" + worker.join(5) + + +def test_shutdown_drains_queued_requests_and_releases_their_barriers(): + entered = threading.Event() + release = threading.Event() + + def commit_group(requests): + entered.set() + assert release.wait(5) + assert all(item.commit is None and not item.done.is_set() for item in requests) + return [TransactionCommit("committed", item.txn_id) for item in requests] + + worker = coordinator(commit_group=commit_group, batch_size=8) + worker.start() + try: + first = worker.submit(request(1)) + assert entered.wait(5) + second = worker.submit(request(2)) + with ThreadPoolExecutor(max_workers=1) as executor: + stopped = executor.submit(worker.shutdown) + try: + assert not first.done.is_set() + assert not second.done.is_set() + finally: + release.set() + stopped.result(timeout=5) + assert first.done.is_set() and second.done.is_set() + assert first.wait().txn_id == 1 + assert second.wait().txn_id == 2 + assert_maintenance_can_enter(worker._barrier) + assert not worker.running + with pytest.raises(RuntimeError, match="shutting down"): + worker.submit(request(3)) + finally: + release.set() + worker.shutdown() + + +def test_submission_cannot_race_past_shutdown_marker(monkeypatch): + worker = coordinator( + commit_group=lambda requests: [commit_one(item) for item in requests], batch_size=8, + ) + stop_entered = threading.Event() + allow_stop = threading.Event() + submit_entered = threading.Event() + put = worker._queue.put + + def hold_stop_marker(item): + if item is None: + stop_entered.set() + assert allow_stop.wait(5) + put(item) + + def submit(): + submit_entered.set() + return worker.submit(request(1)) + + monkeypatch.setattr(worker._queue, "put", hold_stop_marker) + worker.start() + try: + with ThreadPoolExecutor(max_workers=2) as executor: + stopped = executor.submit(worker.shutdown) + try: + assert stop_entered.wait(5) + submitted = executor.submit(submit) + assert submit_entered.wait(5) + finally: + allow_stop.set() + stopped.result(timeout=5) + with pytest.raises(RuntimeError, match="shutting down"): + submitted.result(timeout=5) + assert_maintenance_can_enter(worker._barrier) + assert not worker.running + finally: + allow_stop.set() + worker.shutdown() + + +def test_synchronous_commit_can_submit_from_an_in_process_callback(): + nested = [] + + def commit(item): + if item.txn_id == 1: + nested.append(worker.submit(request(2)).wait()) + return TransactionCommit("committed", item.txn_id) + + worker = coordinator(commit=commit) + try: + assert worker.submit(request(1)).wait().txn_id == 1 + assert nested[0].txn_id == 2 + assert_maintenance_can_enter(worker._barrier) + finally: + worker.shutdown() + + +def test_group_outcomes_are_completed_by_the_coordinator(): + failure = ValueError("request rejected") + + def commit_group(requests): + assert all(item.commit is None and item.error is None for item in requests) + return [ + TransactionCommit("committed", item.txn_id) if item.txn_id == 1 else failure + for item in requests + ] + + worker = coordinator(commit_group=commit_group, batch_size=8) + worker.start() + try: + committed = worker.submit(request(1)) + rejected = worker.submit(request(2)) + assert committed.done.wait(5) + assert rejected.done.wait(5) + assert committed.wait().checkpoint == TransactionCheckpoint(epoch=0, head_seq=1) + with pytest.raises(ValueError, match="request rejected"): + rejected.wait() + assert_maintenance_can_enter(worker._barrier) + finally: + worker.shutdown() + + +@pytest.mark.parametrize("grouped", [False, True]) +def test_interrupted_callback_completes_handles_and_releases_maintenance(grouped): + def interrupt(_request): + raise KeyboardInterrupt("interrupted commit") + + worker = coordinator(commit=interrupt, commit_group=interrupt, batch_size=8 if grouped else 1) + item = request(1) + try: + with pytest.raises(KeyboardInterrupt, match="interrupted commit"): + if grouped: + worker._barrier.acquire_shared() + worker._execute([item]) + else: + worker.submit(item) + assert item.done.is_set() + with pytest.raises(KeyboardInterrupt, match="interrupted commit"): + item.wait() + assert_maintenance_can_enter(worker._barrier) + finally: + worker.shutdown() diff --git a/tests/unit/test_shared_stage_server.py b/tests/unit/test_shared_stage_server.py index b433ed3..0d42201 100644 --- a/tests/unit/test_shared_stage_server.py +++ b/tests/unit/test_shared_stage_server.py @@ -78,6 +78,49 @@ def _child_key(server: UsdSyncServer) -> str: return next(key for key in graph.reachable_layer_keys() if key != graph.root_layer_key) +def test_public_graph_assignment_is_used_by_transactions(tmp_path): + with _shared_server(_create_stage(tmp_path), tmp_path / "assigned-graph.db") as server: + graph = server.shared_layer_graph + child_key = _child_key(server) + event = _value_event(graph.layer_for(child_key), 8.0) + + server.shared_layer_graph = None + with pytest.raises(RuntimeError, match="requires a layer graph"): + server._commit_events([event], layer_key=child_key) + + server.shared_layer_graph = graph + server._commit_events([event], layer_key=child_key) + assert server.stage.GetAttributeAtPath("/World.value").Get() == 8.0 + + +def test_restart_preserves_edits_across_layer_runs_and_topology_boundaries(tmp_path): + base = _create_stage(tmp_path) + db = tmp_path / "interleaved-replay.db" + with _shared_server(base, db) as server: + graph = server.shared_layer_graph + child_key = _child_key(server) + child = graph.layer_for(child_key) + server._commit_events([_value_event(child, 2.0)], layer_key=graph.root_layer_key) + server._commit_events([_value_event(child, 3.0)], layer_key=child_key) + for sublayers in ([], [{"authored_path": "./asset.usda"}]): + server._commit_events([{ + "k": "set_sublayers", "prim": "/", "generation": graph.generation, + "revision": graph.parent_revision(graph.root_layer_key), "sublayers": sublayers, + }], layer_key=graph.root_layer_key) + server._commit_events([_value_event(child, 4.0)], layer_key=child_key) + expected_layers = { + key: graph.layer_for(key).ExportToString() for key in graph.reachable_layer_keys() + } + + with _shared_server(base, db) as restored: + graph = restored.shared_layer_graph + assert { + key: graph.layer_for(key).ExportToString() for key in graph.reachable_layer_keys() + } == expected_layers + assert graph.layer_for(child_key).GetAttributeAtPath("/World.value").default == 4.0 + assert restored.stage.GetAttributeAtPath("/World.value").Get() == 2.0 + + def test_restart_and_compaction_restore_exact_target_layer(tmp_path): base = _create_stage(tmp_path) db = tmp_path / "events.db" @@ -525,7 +568,7 @@ def _fail(*_args, **_kwargs): assert graph.identity_records() == identities assert server.store.get_layer_identities() == durable_identities assert server.store.get_count() == 1 - assert server._next_seq == 2 + assert server._journal.next_seq == 2 def test_concurrent_same_parent_topology_edits_reject_the_stale_base( @@ -537,7 +580,7 @@ def test_concurrent_same_parent_topology_edits_reject_the_stale_base( with _shared_server(root.identifier, tmp_path / "events.db") as server: graph = server.shared_layer_graph first_is_ready_to_persist = threading.Event() - persist = server._persist_shared_events + persist = server._committer.persist_shared_events def _delayed_persist(routed_events, **kwargs): if routed_events[0][1]["revision"] == 2: @@ -545,7 +588,7 @@ def _delayed_persist(routed_events, **kwargs): time.sleep(0.05) return persist(routed_events, **kwargs) - monkeypatch.setattr(server, "_persist_shared_events", _delayed_persist) + monkeypatch.setattr(server._committer, "persist_shared_events", _delayed_persist) base_revision = graph.parent_revision(graph.root_layer_key) failures = [] diff --git a/tests/unit/test_storage_reclaim.py b/tests/unit/test_storage_reclaim.py index 6208948..8c06eb3 100644 --- a/tests/unit/test_storage_reclaim.py +++ b/tests/unit/test_storage_reclaim.py @@ -97,7 +97,7 @@ def test_compaction_reclaims_when_interval_elapsed(tmp_path): try: grown = _disk_size(db) assert grown > 1_000_000 - srv._last_reclaim = time.monotonic() - 7200 + srv._maintenance._last_reclaim = time.monotonic() - 7200 srv.compact_log() assert _disk_size(db) < grown / 4, "compaction did not reclaim" finally: @@ -121,7 +121,7 @@ def test_purge_reclaims_when_due(tmp_path): try: grown = _disk_size(db) srv.set_reclaim_interval(3600) - srv._last_reclaim = time.monotonic() - 7200 + srv._maintenance._last_reclaim = time.monotonic() - 7200 srv.purge() assert srv.get_event_count() == 0 assert _disk_size(db) < grown / 4, "purge did not reclaim" diff --git a/tests/unit/test_transaction_checkpoint.py b/tests/unit/test_transaction_checkpoint.py index 13edb24..1ba0f07 100644 --- a/tests/unit/test_transaction_checkpoint.py +++ b/tests/unit/test_transaction_checkpoint.py @@ -10,7 +10,7 @@ from openusdconnect.protocol import make_transaction_result from openusdconnect.sender import EventSender from openusdconnect.server import UsdSyncServer -from openusdconnect.server.state import _TransactionRequest +from openusdconnect.server.transactions import TransactionRequest @pytest.mark.parametrize("batch_size", [1, 8]) @@ -49,7 +49,7 @@ def test_durable_checkpoint_excludes_uncommitted_sequence_reservations(tmp_path, def fail_persistence(*args, **kwargs): assert server.get_replay_token()[1] > head - observed.append((server._replay_epoch, server.store.get_max_seq())) + observed.append((server.get_replay_token()[0], server.store.get_max_seq())) raise RuntimeError("injected persistence failure") monkeypatch.setattr(server.store, "append_batch", fail_persistence) @@ -78,7 +78,7 @@ def test_batch_shares_one_checkpoint_for_successful_commits( session_id="seed", txn_id=1, client_id="seed", ) requests = [ - _TransactionRequest( + TransactionRequest( events=[{"k": "ensure_prim", "prim": path, "typeName": "Xform"}], session_id=client, txn_id=txn_id, client_id=client, origin=None, client_addr=None, layer=None, layer_key="", @@ -109,11 +109,10 @@ def fail_group_once(*args, **kwargs): patch.setattr(server.store, "get_max_seq", read_head) for _request in requests: server.txn_barrier.acquire_shared() - server._execute_transaction_requests(requests) + server._transactions._execute(requests) read_head.assert_called_once_with() assert all(request.done.is_set() for request in requests) - assert server.txn_barrier._readers == 0 duplicate, first, rejected, last = requests assert duplicate.commit.status == "duplicate" assert duplicate.commit.checkpoint is None diff --git a/tests/unit/test_transaction_receipts.py b/tests/unit/test_transaction_receipts.py index 7310050..952015c 100644 --- a/tests/unit/test_transaction_receipts.py +++ b/tests/unit/test_transaction_receipts.py @@ -4,9 +4,14 @@ import threading import pytest +from pxr import Sdf +from openusdconnect.codec import message_to_dict from openusdconnect.event_store import LayerIdentity, ProducerProgress, SqliteEventStore -from openusdconnect.server.state import UsdSyncServer, _TransactionRequest +from openusdconnect.protocol_constants import LayerMode +from openusdconnect.sdf_spec_delta import serialize_spec_fields +from openusdconnect.server.state import UsdSyncServer +from openusdconnect.server.transactions import TransactionRequest from openusdconnect.server.types import TransactionRejectedError @@ -23,6 +28,189 @@ def _commit(server, path: str, *, client: str, session: str, txn_id: int): ) +@pytest.mark.parametrize("grouped", [False, True]) +def test_removed_spec_fields_match_listener_wire_and_history(tmp_path, grouped): + server = UsdSyncServer(log_path=str(tmp_path / "deletion-fields.db"), txn_batch_size=1) + source = Sdf.Layer.CreateAnonymous() + prim = Sdf.CreatePrimInLayer(source, "/World") + attribute = Sdf.AttributeSpec(prim, "value", Sdf.ValueTypeNames.Float) + attribute.default = 5.0 + expected_fields = sorted(str(key) for key in attribute.ListInfoKeys()) + create = { + "k": "set_sdf_spec_fields", "prim": "/World", "spec_path": "/World.value", + "spec_kind": "attribute", "fields": expected_fields, + "fragment": serialize_spec_fields( + source, "/World.value", "attribute", expected_fields, + ), + "removed": False, + } + remove = { + "k": "set_sdf_spec_fields", "prim": "/World", "spec_path": "/World.value", + "spec_kind": "property", "fields": [], "fragment": "", "removed": True, + } + requests = [ + TransactionRequest( + events=[event], client_id="client", session_id="session", txn_id=txn_id, + ) + for txn_id, event in enumerate((create, remove), start=1) + ] + observed = [] + server.add_event_listener(observed.append) + try: + if grouped: + # The second request must see fields authored by the first request + # in this same batch, rather than its initial empty scene. + outcomes = server._commit_and_publish_batch(requests) + else: + outcomes = [server._commit_and_publish(request) for request in requests] + + assert [outcome.status for outcome in outcomes] == ["committed", "committed"] + assert not server.stage.GetPropertyAtPath("/World.value") + record, record_bytes = outcomes[-1].records[0] + stored_bytes = server.store.get_all_asc()[-1][1] + assert record_bytes == stored_bytes + assert observed[-1]["event"]["fields"] == expected_fields + assert record["event"]["fields"] == expected_fields + assert message_to_dict(record_bytes)["event"]["fields"] == expected_fields + assert message_to_dict(stored_bytes)["event"]["fields"] == expected_fields + finally: + server.shutdown() + server.store.close() + + +@pytest.mark.parametrize("layer_mode", list(LayerMode)) +def test_removed_spec_fields_follow_edits_in_the_same_transaction(tmp_path, layer_mode): + root = Sdf.Layer.CreateNew(str(tmp_path / "root.usda")) + root.Save() + server = UsdSyncServer( + base_usd_path=root.identifier, + layer_mode=layer_mode, + log_path=str(tmp_path / "ordered-deletion-fields.db"), + txn_batch_size=1, + ) + source = Sdf.Layer.CreateAnonymous() + prim = Sdf.CreatePrimInLayer(source, "/World") + attribute = Sdf.AttributeSpec(prim, "value", Sdf.ValueTypeNames.Float) + attribute.default = 5.0 + events = [] + expected_fields = [] + for documentation in (None, "recreated property"): + if documentation is not None: + attribute.documentation = documentation + fields = sorted(str(key) for key in attribute.ListInfoKeys()) + expected_fields.append(fields) + events.extend([ + { + "k": "set_sdf_spec_fields", "prim": "/World", + "spec_path": "/World.value", "spec_kind": "attribute", "fields": fields, + "fragment": serialize_spec_fields(source, "/World.value", "attribute", fields), + "removed": False, + }, + { + "k": "set_sdf_spec_fields", "prim": "/World", + "spec_path": "/World.value", "spec_kind": "property", "fields": [], + "fragment": "", "removed": True, + }, + ]) + events.append(dict(events[0])) + graph = server.shared_layer_graph + observed = [] + server.add_event_listener(observed.append) + try: + outcome = server.process_idempotent_txn( + events, client_id="client", session_id="session", txn_id=1, + layer_key=graph.root_layer_key if graph is not None else "", + ) + assert outcome.status == "committed" + assert server.stage.GetAttributeAtPath("/World.value").Get() == 5.0 + assert not server.stage.GetAttributeAtPath("/World.value").GetDocumentation() + stored_rows = server.store.get_all_asc()[-len(events):] + for index, fields in zip((1, 3), expected_fields, strict=True): + record, wire_bytes = outcome.records[index] + assert record["event"]["fields"] == fields + assert observed[index]["event"]["fields"] == fields + assert message_to_dict(wire_bytes)["event"]["fields"] == fields + assert stored_rows[index][1] == wire_bytes + finally: + server.shutdown() + server.store.close() + + +@pytest.mark.parametrize("grouped", [False, True]) +@pytest.mark.parametrize("with_deletion", [False, True]) +def test_encoding_failure_preserves_scene_history_and_allows_retry( + tmp_path, monkeypatch, grouped, with_deletion, +): + from openusdconnect.server import journal + + server = UsdSyncServer(log_path=str(tmp_path / "encoding-failure.db"), txn_batch_size=1) + server._commit_events([_event("/World/Existing")]) + before_layer = server.edit_layer.ExportToString() + before_rows = server.store.get_all_asc() + before_token = server.get_replay_token() + before_tree = server.get_prim_tree() + events = [_event("/World/First")] + if with_deletion: + events.append({ + "k": "set_sdf_spec_fields", "prim": "/World/Existing", + "spec_path": "/World/Existing", "spec_kind": "prim", + "fields": [], "fragment": "", "removed": True, + }) + events.append(_event("/World/Second")) + requests = [ + TransactionRequest( + events=batch, client_id="client", session_id="session", txn_id=txn_id, + ) + for txn_id, batch in enumerate( + [[event] for event in events] if grouped else [events], start=1, + ) + ] + encode = journal.encode_event_record + + def fail_second(sequence, event, **kwargs): + if event["prim"] == "/World/Second": + if with_deletion: + assert server.stage.GetPrimAtPath("/World/First") + assert not server.stage.GetPrimAtPath("/World/Existing") + raise RuntimeError("injected encoding failure") + return encode(sequence, event, **kwargs) + + try: + with monkeypatch.context() as patch: + patch.setattr(journal, "encode_event_record", fail_second) + with pytest.raises(RuntimeError, match="injected encoding failure"): + if grouped: + server._commit_and_publish_batch(requests) + else: + server._commit_and_publish(requests[0]) + + assert not server.stage.GetPrimAtPath("/World/First") + assert not server.stage.GetPrimAtPath("/World/Second") + assert server.edit_layer.ExportToString() == before_layer + assert server.get_prim_tree() == before_tree + assert server.store.get_all_asc() == before_rows + assert server.get_replay_token() == before_token + assert server.producer_committed_through("client", "session") == 0 + + outcomes = ( + server._commit_and_publish_batch(requests) + if grouped else [server._commit_and_publish(requests[0])] + ) + assert all(outcome.status == "committed" for outcome in outcomes) + assert server.stage.GetPrimAtPath("/World/First") + assert server.stage.GetPrimAtPath("/World/Second") + assert bool(server.stage.GetPrimAtPath("/World/Existing")) is not with_deletion + records = [record for outcome in outcomes for record, _bytes in outcome.records] + assert [record["seq"] for record in records] == list(range(2, 2 + len(events))) + assert [record["event"]["prim"] for record in records] == [ + event["prim"] for event in events + ] + assert server.get_replay_token()[1] == 1 + len(events) + finally: + server.shutdown() + server.store.close() + + def _run_concurrently(calls): barrier = threading.Barrier(len(calls) + 1) results = [None] * len(calls) @@ -223,6 +411,230 @@ def test_gap_is_rejected_with_expected_transaction(tmp_path): server.store.close() +@pytest.mark.parametrize("grouped", [False, True]) +def test_producer_order_and_metadata_survive_duplicate_and_gap_requests(tmp_path, grouped): + """A rejected gap cannot advance progress or change neighboring writes.""" + from openusdconnect.codec import message_to_dict + + server = UsdSyncServer(log_path=str(tmp_path / "ordering.db"), txn_batch_size=1) + try: + layer = server.get_or_create_client_layer("artist", "layout") + requests = [ + TransactionRequest( + events=[_event(f"/World/Edit{index}")], client_id="artist", + session_id="session", txn_id=txn_id, layer=layer, + origin="dcc", client_addr="127.0.0.1:7200", + ) + for index, txn_id in enumerate((1, 1, 3, 2, 1)) + ] + if grouped: + outcomes = server._commit_and_publish_batch(requests) + else: + outcomes = [] + for request in requests: + try: + outcomes.append(server._commit_and_publish(request)) + except TransactionRejectedError as exc: + outcomes.append(exc) + assert [outcomes[i].status for i in (0, 1, 3, 4)] == [ + "committed", "duplicate", "committed", "duplicate", + ] + assert [outcomes[i].txn_id for i in (0, 1, 3, 4)] == [1, 1, 2, 2] + assert isinstance(outcomes[2], TransactionRejectedError) + assert outcomes[2].expected_txn_id == 2 + assert server.store.get_producer_progress("artist", "session") == 2 + records = [message_to_dict(blob) for _seq, blob in server.store.get_all_asc()] + assert [record["event"]["prim"] for record in records] == ["/World/Edit0", "/World/Edit3"] + assert [record["seq"] for record in records] == [1, 2] + for record in records: + assert record["client_id"] == "artist" + assert record["origin"] == "dcc" + assert record["client"] == "127.0.0.1:7200" + assert record["layer_key"] == server.layer_stack.key_for_layer(layer) + assert layer.GetPrimAtPath("/World/Edit0") and layer.GetPrimAtPath("/World/Edit3") + assert not any(server.stage.GetPrimAtPath(f"/World/Edit{i}") for i in (1, 2, 4)) + finally: + server.shutdown() + server.store.close() + + +@pytest.mark.parametrize("grouped", [False, True]) +@pytest.mark.parametrize("broadcast_fails", [False, True]) +def test_payload_publication_keeps_commit_order_and_durable_outcomes( + tmp_path, monkeypatch, grouped, broadcast_fails, +): + server = UsdSyncServer(log_path=str(tmp_path / "publication.db"), txn_batch_size=1) + try: + server._commit_events([_event("/Payload")]) + observed = [] + broadcast = server.broadcast_transaction_group_views + + def publish(transactions): + for records in transactions: + for record, _encoded in records: + observed.append(record["event"]["prim"]) + if broadcast_fails and record["event"]["k"] == "load_payload": + raise OSError("injected broadcast failure") + broadcast(transactions) + + monkeypatch.setattr(server, "broadcast_transaction_group_views", publish) + monkeypatch.setattr( + server, "replay_children_after_load", lambda path: observed.append(path + "/Child"), + ) + requests = [ + TransactionRequest(events=[event], client_id="client", session_id="s", txn_id=index) + for index, event in enumerate([ + _event("/Before"), {"k": "load_payload", "prim": "/Payload"}, _event("/After"), + ], start=1) + ] + outcomes = ( + # The coordinator closes each batch at a payload load. + server._commit_and_publish_batch(requests[:2]) + + server._commit_and_publish_batch(requests[2:]) if grouped + else [server._commit_and_publish(request) for request in requests] + ) + assert observed == [ + "/Before", "/Payload", *([] if broadcast_fails else ["/Payload/Child"]), "/After", + ] + assert [outcome.status for outcome in outcomes] == ["committed"] * 3 + assert server.store.get_producer_progress("client", "s") == 3 + assert server.stage.GetPrimAtPath("/After") + finally: + server.shutdown() + server.store.close() + + +@pytest.mark.parametrize("durability", ["strict", "realtime"]) +def test_child_replay_preserves_routing_and_persists_before_publication(tmp_path, durability): + from openusdconnect.codec import message_to_dict + + server = UsdSyncServer( + log_path=str(tmp_path / "children.db"), durability=durability, wire_metrics=True, + ) + try: + for name in ("A", "B"): + layer = server.get_or_create_client_layer(name, department=name) + server._commit_events( + [_event(f"/Payload/{name}")], client_id=name, origin=f"origin-{name}", layer=layer, + ) + observed = [] + server.add_event_listener( + lambda record: observed.append((record, server.store.get_max_seq())), + ) + before_metrics = server.get_wire_metrics()["total_count"] + + server.replay_children_after_load("/Payload") + + replayed = [message_to_dict(blob) for blob in server.store.get_from_seq_bin(3)] + assert [record["seq"] for record in replayed] == [3, 4] + assert [record["event"]["prim"] for record in replayed] == ["/Payload/A", "/Payload/B"] + assert [record["origin"] for record in replayed] == ["origin-A", "origin-B"] + assert [record["layer_key"] for record in replayed] == ["department:A", "department:B"] + assert all("client_id" not in record for record in replayed) + assert [(record["seq"], head) for record, head in observed] == [(3, 4), (4, 4)] + assert server.get_event_count() == 4 + assert server.get_wire_metrics()["total_count"] == before_metrics + 2 + finally: + server.shutdown() + server.store.close() + + +@pytest.mark.parametrize("batch_size", [1, 8]) +@pytest.mark.parametrize("durability", ["strict", "realtime"]) +def test_child_replay_storage_failure_preserves_parent_commit_and_sequence( + tmp_path, monkeypatch, batch_size, durability, +): + server = UsdSyncServer( + log_path=str(tmp_path / "replay-failure.db"), + txn_batch_size=batch_size, durability=durability, + ) + try: + server._commit_events([_event("/Payload"), _event("/Payload/Child")]) + append = server.store.append_batch + observed = [] + server.add_event_listener(observed.append) + + def fail_replay(rows, **kwargs): + if not kwargs.get("producer_progress"): + raise OSError("replay persistence failed") + return append(rows, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(server.store, "append_batch", fail_replay) + outcome = server.process_idempotent_txn( + [{"k": "load_payload", "prim": "/Payload"}], + client_id="client", session_id="session", txn_id=1, + ) + assert outcome.status == "committed" + assert outcome.checkpoint.head_seq == 3 + assert server.producer_committed_through("client", "session") == 1 + assert server.get_replay_token()[1] == server.store.get_max_seq() == 3 + assert server.get_event_count() == server.store.get_count() == 3 + assert [record["seq"] for record in observed] == [3] + + following = _commit(server, "/Following", client="client", session="session", txn_id=2) + assert following.status == "committed" + assert following.records[0][0]["seq"] == 4 + finally: + server.shutdown() + server.store.close() + + +@pytest.mark.parametrize("paths,expected", [ + (["/Before", "/Payload", "/After"], ["/Before", "/Payload", "/Payload/Child", "/After"]), + (["/Payload", "/After"], ["/Payload", "/Payload/Child", "/After"]), + (["/Payload", "/Payload", "/After"], + ["/Payload", "/Payload/Child", "/Payload", "/Payload/Child", "/After"]), +]) +def test_queued_payload_replay_keeps_wire_sequences_in_commit_order( + tmp_path, monkeypatch, paths, expected, +): + import io + + from openusdconnect.codec import message_to_dict + from openusdconnect.framing import recv_framed_rfile + from openusdconnect.server.transactions import TransactionCoordinator + from tests.helpers import ReceiverStub + + # Admit the whole batch before starting the worker, without a timing race. + start = TransactionCoordinator.start + with monkeypatch.context() as patch: + patch.setattr(TransactionCoordinator, "start", lambda self: None) + server = UsdSyncServer(log_path=str(tmp_path / "replay-order.db"), txn_batch_size=8) + try: + server._commit_events([_event("/Payload"), _event("/Payload/Child")]) + observed = [] + server.add_event_listener(observed.append) + receiver = ReceiverStub() + receiver.send_lock = threading.Lock() + receiver.client_address = ("replay-order", 0) + receiver.request = io.BytesIO() + receiver.request.sendall = receiver.request.write + server.receivers.add(receiver) + requests = [ + server.submit_idempotent_txn( + [{"k": "load_payload", "prim": path} if path == "/Payload" else _event(path)], + client_id="client", session_id="session", txn_id=index, + ) + for index, path in enumerate(paths, start=1) + ] + start(server._transactions) + outcomes = [request.wait() for request in requests] + assert [outcome.status for outcome in outcomes] == ["committed"] * len(paths) + assert [record["event"]["prim"] for record in observed] == expected + sequences = list(range(3, 3 + len(expected))) + assert [record["seq"] for record in observed] == sequences + assert server.store.get_max_seq() == sequences[-1] + server._broadcast_queue.join() + receiver.request.seek(0) + wire = [message_to_dict(recv_framed_rfile(receiver.request)) for _ in expected] + assert [record["seq"] for record in wire] == sequences + assert receiver.request.read() == b"" + finally: + server.shutdown() + server.store.close() + + def test_invalid_transaction_is_rejected_before_queue_or_sequence_reservation(tmp_path): server = UsdSyncServer(log_path=str(tmp_path / "invalid.db"), txn_batch_size=1) try: @@ -235,7 +647,7 @@ def test_invalid_transaction_is_rejected_before_queue_or_sequence_reservation(tm ) assert server.store.get_count() == 0 assert server.store.get_producer_progress("client", "producer") == 0 - assert server._next_seq == 1 + assert server.get_replay_token()[1] == 0 finally: server.shutdown() server.store.close() @@ -313,7 +725,7 @@ def fail_once(records, *, producer_progress=()): assert not server.stage.GetPrimAtPath("/World/Rollback").IsValid() assert server.store.get_count() == 0 assert server.store.get_producer_progress("client", "producer") == 0 - assert server._next_seq == 1 + assert server.get_replay_token()[1] == 0 committed = _commit( server, "/World/Rollback", client="client", session="producer", txn_id=1 @@ -345,12 +757,7 @@ def fail_once(records, *, producer_progress=()): {"k": "ensure_prim", "prim": "/World/Rollback", "typeName": "PointInstancer"}, {"k": "set_instanceable", "prim": "/World/Rollback", "instanceable": True}, ] - before = ( - dict(server._prim_paths), - set(server._instanceable_paths), - set(server._point_instancer_paths), - server._prim_count_dirty, - ) + before = (server.get_prim_tree(), server.get_prim_count(), server.get_instance_count()) monkeypatch.setattr(server.store, "append_batch", fail_once) try: with pytest.raises(sqlite3.OperationalError, match="injected tracking"): @@ -363,10 +770,7 @@ def fail_once(records, *, producer_progress=()): assert not server.stage.GetPrimAtPath("/World/Rollback").IsValid() assert ( - server._prim_paths, - server._instanceable_paths, - server._point_instancer_paths, - server._prim_count_dirty, + server.get_prim_tree(), server.get_prim_count(), server.get_instance_count() ) == before committed = server.process_idempotent_txn( @@ -376,9 +780,10 @@ def fail_once(records, *, producer_progress=()): txn_id=1, ) assert committed.status == "committed" - assert server._prim_paths["/World/Rollback"] == "PointInstancer" - assert server._instanceable_paths == {"/World/Rollback"} - assert server._point_instancer_paths == {"/World/Rollback"} + prim = next(row for row in server.get_prim_tree() if row["path"] == "/World/Rollback") + assert prim["typeName"] == "PointInstancer" + assert prim["instanceable"] and prim["is_point_instancer"] + assert server.get_instance_count() == 1 finally: server.shutdown() server.store.close() @@ -405,7 +810,7 @@ def fail_once(records, *, producer_progress=()): assert server.stage.GetPrimAtPath("/World/Old").IsValid() assert not server.stage.GetPrimAtPath("/World/New").IsValid() assert server.store.get_count() == 1 - assert server._next_seq == 2 + assert server.get_replay_token()[1] == 1 monkeypatch.setattr(server.store, "append_batch", append_batch) records = server._commit_events([_event("/World/After")], client_id="maintenance") @@ -481,7 +886,7 @@ def commit_private(): assert [seq for seq, _payload in server.store.get_all_asc()] == [1] assert not server.stage.GetPrimAtPath("/World/Rejected").IsValid() assert server.stage.GetPrimAtPath("/World/Private").IsValid() - assert server._next_seq == 2 + assert server.get_replay_token()[1] == 1 finally: release_failure.set() identified.join(timeout=5) @@ -517,7 +922,7 @@ def fail_rename(records, *, producer_progress=()): assert not server.stage.GetPrimAtPath("/World/New").IsValid() assert server.store.get_count() == 1 assert server.store.get_producer_progress("client", "producer") == 1 - assert server._next_seq == 2 + assert server.get_replay_token()[1] == 1 finally: server.shutdown() server.store.close() @@ -528,19 +933,14 @@ def test_group_store_failure_rolls_back_both_rename_paths(tmp_path, monkeypatch) append_batch = server.store.append_batch server.apply_txn([_event("/World/Old")]) before = server.edit_layer.ExportToString() - before_tracking = ( - dict(server._prim_paths), - set(server._instanceable_paths), - set(server._point_instancer_paths), - server._prim_count_dirty, - ) + before_tracking = (server.get_prim_tree(), server.get_prim_count(), server.get_instance_count()) def fail_group(_records, *, producer_progress=()): raise sqlite3.OperationalError("injected grouped persistence failure") monkeypatch.setattr(server.store, "append_batch", fail_group) requests = [ - _TransactionRequest( + TransactionRequest( events=[{"k": "rename_prim", "prim": "/World/Old", "new_name": "New"}], session_id="session", txn_id=1, @@ -550,7 +950,7 @@ def fail_group(_records, *, producer_progress=()): layer=None, layer_key="", ), - _TransactionRequest( + TransactionRequest( events=[_event("/World/Other")], session_id="session", txn_id=1, @@ -563,26 +963,25 @@ def fail_group(_records, *, producer_progress=()): ] try: with pytest.raises(sqlite3.OperationalError, match="injected grouped"): - server._commit_managed_transaction_group(requests) + server._commit_and_publish_batch(requests) assert server.edit_layer.ExportToString() == before assert server.stage.GetPrimAtPath("/World/Old").IsValid() assert not server.stage.GetPrimAtPath("/World/New").IsValid() assert not server.stage.GetPrimAtPath("/World/Other").IsValid() assert ( - server._prim_paths, - server._instanceable_paths, - server._point_instancer_paths, - server._prim_count_dirty, + server.get_prim_tree(), server.get_prim_count(), server.get_instance_count() ) == before_tracking assert server.store.get_count() == 0 - assert server._next_seq == 1 + assert server.get_replay_token()[1] == 0 for request in requests: assert server.producer_committed_through(request.client_id, request.session_id) == 0 monkeypatch.setattr(server.store, "append_batch", append_batch) - server._commit_managed_transaction_group(requests) - assert [request.commit.status for request in requests] == ["committed", "committed"] + outcomes = server._commit_and_publish_batch(requests) + assert [outcome.status for outcome in outcomes] == ["committed", "committed"] + assert all(request.commit is None and request.error is None for request in requests) + assert all(not request.done.is_set() for request in requests) assert not server.stage.GetPrimAtPath("/World/Old").IsValid() assert server.stage.GetPrimAtPath("/World/New").IsValid() assert server.stage.GetPrimAtPath("/World/Other").IsValid() @@ -591,10 +990,10 @@ def fail_group(_records, *, producer_progress=()): assert server.producer_committed_through(request.client_id, request.session_id) == 1 assert server.store.get_producer_progress(request.client_id, request.session_id) == 1 - server._commit_managed_transaction_group(requests) - assert [request.commit.status for request in requests] == ["duplicate", "duplicate"] + outcomes = server._commit_and_publish_batch(requests) + assert [outcome.status for outcome in outcomes] == ["duplicate", "duplicate"] assert server.store.get_count() == 2 - assert server._next_seq == 3 + assert server.get_replay_token()[1] == 2 finally: server.shutdown() server.store.close() diff --git a/tests/unit/test_txn_barrier.py b/tests/unit/test_txn_barrier.py index f33835a..e6ec91f 100644 --- a/tests/unit/test_txn_barrier.py +++ b/tests/unit/test_txn_barrier.py @@ -2,9 +2,35 @@ import threading +import pytest + from openusdconnect.server._txn_barrier import _TxnBarrier +@pytest.mark.parametrize("mode", ["shared", "exclusive"]) +def test_failed_operation_releases_waiting_maintenance(mode): + barrier = _TxnBarrier() + attempted = threading.Event() + entered = threading.Event() + + def maintenance(): + attempted.set() + with barrier.exclusive(): + entered.set() + + worker = threading.Thread(target=maintenance, daemon=True) + with pytest.raises(RuntimeError, match="operation failed"): + with getattr(barrier, mode)(): + worker.start() + assert attempted.wait(timeout=5) + assert not entered.is_set() + raise RuntimeError("operation failed") + + assert entered.wait(timeout=5) + worker.join(timeout=5) + assert not worker.is_alive() + + def test_exclusive_owners_never_overlap(): barrier = _TxnBarrier() first_entered = threading.Event() diff --git a/tests/unit/test_usd_authoring.py b/tests/unit/test_usd_authoring.py index 8b4509b..3a73783 100644 --- a/tests/unit/test_usd_authoring.py +++ b/tests/unit/test_usd_authoring.py @@ -4,6 +4,7 @@ from pxr import Gf, Sdf, Usd, UsdShade, Vt from openusdconnect import event_apply +from openusdconnect.codec import encode_message, message_to_dict from openusdconnect.connectable_attrs import input_attr, output_attr from openusdconnect.usd_authoring import ( resolve_shader_port_type, @@ -58,6 +59,35 @@ def test_converts_python_values(type_name, value, expected): assert inp.Get() == expected +@pytest.mark.parametrize(("type_name", "value", "expected"), [ + ("float[]", [0.25, 0.5], Vt.FloatArray([0.25, 0.5])), + ("int[]", [1, 2], Vt.IntArray([1, 2])), + ("float[]", [], Vt.FloatArray()), + ("int[]", [], Vt.IntArray()), + ("color3f", [0.25, 0.5, 1], Gf.Vec3f(0.25, 0.5, 1)), + ("matrix4d", [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], Gf.Matrix4d(1)), +]) +@pytest.mark.parametrize("numpy_arrays", [False, True]) +def test_list_inputs_roundtrip_and_apply(type_name, value, expected, numpy_arrays): + event = {"k": "set_connectable_input", "prim": "/Shader", "info_id": "TestShader", + "inputs": {"value": value}, "input_types": {"value": type_name}, "time": 12} + wire = encode_message({"type": "event", "seq": 1, "event": event}) + decoded = message_to_dict(wire, numpy_arrays=numpy_arrays)["event"] + array = decoded["inputs"]["value"] + assert isinstance(array, list) + assert array == value + assert message_to_dict(encode_message({"type": "event", "seq": 2, "event": decoded}))[ + "event" + ] == event + stage = Usd.Stage.CreateInMemory() + shader = UsdShade.Shader.Define(stage, "/Shader") + event_apply.apply_events(stage, [decoded]) + inp = shader.GetInput("value") + assert inp.GetTypeName() == Sdf.ValueTypeNames.Find(type_name) + assert inp.Get(Usd.TimeCode(12)) == expected + assert inp.GetAttr().GetTimeSamples() == [12.0] + + def test_asset_paths_can_be_set_and_cleared(): stage = Usd.Stage.CreateInMemory() connectable = UsdShade.Shader.Define(stage, "/Shader").ConnectableAPI() diff --git a/tests/unit/test_vfs.py b/tests/unit/test_vfs.py index 747733c..80c2cb6 100644 --- a/tests/unit/test_vfs.py +++ b/tests/unit/test_vfs.py @@ -6,6 +6,7 @@ import io import json +import pickle import threading import pytest @@ -16,13 +17,39 @@ from openusdconnect.protocol_constants import PROTOCOL_VERSION from openusdconnect.server import UsdSyncServer from openusdconnect.server.types import ( + AmbiguousVfsWriteError, InvalidVfsWriteError, StaleVfsWriteError, UnsupportedVfsWriteError, + VfsWriteAnalysis, + VfsWriteRejectedError, ) from openusdconnect.server.vfs import VirtualStageFile, VirtualStageFileSet, WriteMode from openusdconnect.server.vfs.provider import VfsSnapshot, VfsStat from openusdconnect.server.vfs.webdav import _StageFileResource +from tests.helpers import ReceiverStub + + +@pytest.mark.parametrize("error_type", [ + VfsWriteRejectedError, InvalidVfsWriteError, StaleVfsWriteError, + UnsupportedVfsWriteError, AmbiguousVfsWriteError, +]) +@pytest.mark.parametrize("args", [(), ("rejected",), ("rejected", 42)]) +def test_vfs_errors_preserve_runtime_error_arguments(error_type, args): + error = error_type(*args) + assert error.args == args + assert str(error) == str(RuntimeError(*args)) + restored = pickle.loads(pickle.dumps(error)) + assert type(restored) is error_type + assert restored.args == args + + +def test_vfs_error_analysis_survives_serialization(): + analysis = VfsWriteAnalysis(status="unsupported_rejected", current_epoch=1, current_seq=2) + error = UnsupportedVfsWriteError("rejected", 42, analysis=analysis) + restored = pickle.loads(pickle.dumps(error)) + assert restored.args == ("rejected", 42) + assert restored.analysis == analysis @pytest.fixture @@ -468,11 +495,15 @@ def test_unknown_format_rejected(self, srv): class TestWriteTranslate: - def test_writing_current_snapshot_is_noop(self, srv, translate_vfile): + def test_writing_current_snapshot_is_noop(self, srv, translate_vfile, monkeypatch): before = translate_vfile.read() before_count = srv.get_event_count() before_token = srv.get_snapshot_token() + def unexpected_drain(): + pytest.fail("an unchanged save must not wait for outgoing traffic") + + monkeypatch.setattr(srv._maintenance, "drain", unexpected_drain) translate_vfile.write(before) assert srv.get_event_count() == before_count @@ -522,7 +553,7 @@ def __init__(self): def sendall(self, payload): self.payloads.append(payload) - class CaptureReceiver: + class CaptureReceiver(ReceiverStub): def __init__(self): self.request = CaptureRequest() self.client_address = ("vfs-capture", 1) diff --git a/tests/unit/test_wire_metrics.py b/tests/unit/test_wire_metrics.py index b8a0b6e..bf6b198 100644 --- a/tests/unit/test_wire_metrics.py +++ b/tests/unit/test_wire_metrics.py @@ -12,6 +12,7 @@ from openusdconnect.server import connection as connection_module from openusdconnect.server.connection import ConnectionHandler, ThreadedTCPServer from openusdconnect.server.state import UsdSyncServer +from tests.helpers import ReceiverStub EVENTS = [ {"k": "ensure_prim", "prim": "/World/A", "typeName": "Xform"}, @@ -106,7 +107,7 @@ def test_records_server_broadcasts_by_message_type(): def test_transport_metrics_count_actual_receiver_fanout(): - class FakeHandler: + class FakeHandler(ReceiverStub): def __init__(self): self.request = io.BytesIO() self.request.sendall = self.request.write @@ -117,7 +118,7 @@ def __init__(self): receivers = (FakeHandler(), FakeHandler()) payload = b"framed-payload" - s._send_to_all(payload, targets=receivers) + s._send_to_receivers(payload, targets=receivers) transport = s.get_wire_metrics()["transport"] assert transport["receiver_egress"] == {