diff --git a/Cargo.lock b/Cargo.lock index c6eeeb8ba..992afde43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2310,6 +2310,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quent-constraints", + "quent-ref-target", "quent-schema", "quote", "syn", @@ -2783,6 +2784,8 @@ version = "0.1.0" dependencies = [ "indexmap", "quent-constraints", + "quent-ref-target", + "quent-ref-tree", "quent-schema", "serde", "serde-saphyr", diff --git a/crates/instrumentation-build/Cargo.toml b/crates/instrumentation-build/Cargo.toml index 1450b7bcb..6d120ae97 100644 --- a/crates/instrumentation-build/Cargo.toml +++ b/crates/instrumentation-build/Cargo.toml @@ -9,6 +9,7 @@ convert_case = "0.11" prettyplease = "0.2" proc-macro2 = "1" quent-constraints = { path = "../constraints" } +quent-ref-target = { path = "../ref-target" } quent-schema = { path = "../schema" } quote = "1" syn = { version = "2", features = ["full", "parsing"] } diff --git a/crates/instrumentation-build/example/Cargo.lock b/crates/instrumentation-build/example/Cargo.lock index f8f74b720..8fff128c7 100644 --- a/crates/instrumentation-build/example/Cargo.lock +++ b/crates/instrumentation-build/example/Cargo.lock @@ -379,6 +379,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quent-constraints", + "quent-ref-target", "quent-schema", "quote", "syn", @@ -426,6 +427,27 @@ dependencies = [ "uuid", ] +[[package]] +name = "quent-ref-target" +version = "0.1.0" +dependencies = [ + "quent-constraints", + "quent-schema", + "thiserror", +] + +[[package]] +name = "quent-ref-tree" +version = "0.1.0" +dependencies = [ + "petgraph", + "quent-constraints", + "quent-ref-target", + "quent-schema", + "rustc-hash", + "thiserror", +] + [[package]] name = "quent-schema" version = "0.1.0" @@ -449,6 +471,8 @@ version = "0.1.0" dependencies = [ "indexmap", "quent-constraints", + "quent-ref-target", + "quent-ref-tree", "quent-schema", "serde", "serde-saphyr", diff --git a/crates/instrumentation-build/example/model.yaml b/crates/instrumentation-build/example/model.yaml index a0073c72a..61e6d9d54 100644 --- a/crates/instrumentation-build/example/model.yaml +++ b/crates/instrumentation-build/example/model.yaml @@ -14,8 +14,17 @@ records: fields: tags: { list: string } extra: dynamic + Route: + doc: Details of one routing edge to an upstream server. + fields: + hops: u8 entities: + Server: + doc: A server that accepts connections. + events: + booted: once + Connection: doc: A client connection. events: @@ -24,8 +33,13 @@ entities: once: peer: Endpoint session: uuid + host: { scope-ref: Server } data: multi: bytes: u64 meta: { option: Meta } + routed: + doc: Forwarded to an upstream server. + once: + upstream: { ref: Server, data: Route } closed: once diff --git a/crates/instrumentation-build/example/src/main.rs b/crates/instrumentation-build/example/src/main.rs index 112cedf26..8f1ba78cb 100644 --- a/crates/instrumentation-build/example/src/main.rs +++ b/crates/instrumentation-build/example/src/main.rs @@ -12,6 +12,11 @@ mod demo { fn main() -> Result<(), Box> { let context: DemoContext = demo::DemoContext::try_new(Some(debug_printing_exporter()))?; + + // Boot a server; its instance id is the target the connection references. + let mut server = context.server_observer().handle(); + server.booted()?; + let observer: ConnectionObserver = context.connection_observer(); // The handle (may) hold per-instance state that enforces once-cardinality, @@ -24,6 +29,7 @@ fn main() -> Result<(), Box> { port: 8080, }, Uuid::nil(), + server.as_entity_ref(), )?; conn.data(1234, None)?; @@ -41,6 +47,9 @@ fn main() -> Result<(), Box> { }), )?; + // `ref` field carrying data: the server reference plus details of the edge. + conn.routed(server.as_entity_ref_with(demo::Route { hops: 3 }))?; + conn.closed()?; // Emitting a once-event a second time fails. @@ -53,7 +62,12 @@ fn main() -> Result<(), Box> { /// Return an exporter that debug-prints each emitted event's payload. fn debug_printing_exporter() -> ExporterOptions { ExporterOptions::Callback(EventCallback::new(|recorded| { - if let Ok(event) = recorded.event.downcast::>() { + if let Some(event) = recorded + .event + .downcast_ref::>() + { + println!("[{} @ {}] {:?}", event.id, event.timestamp, event.data); + } else if let Some(event) = recorded.event.downcast_ref::>() { println!("[{} @ {}] {:?}", event.id, event.timestamp, event.data); } else { unreachable!() diff --git a/crates/instrumentation-build/src/data_type.rs b/crates/instrumentation-build/src/data_type.rs index 228618c7c..1835ebe8f 100644 --- a/crates/instrumentation-build/src/data_type.rs +++ b/crates/instrumentation-build/src/data_type.rs @@ -3,9 +3,11 @@ //! Mapping from schema [`DataType`]s to Rust type tokens. -use convert_case::Case; +use convert_case::{Case, Casing}; use proc_macro2::TokenStream; -use quent_schema::DataType; +use quent_constraints::Constraint; +use quent_ref_target::RefTargetConstraint; +use quent_schema::{Annotations, DataType}; use quote::quote; use crate::common::{raw_ident, to_case}; @@ -54,13 +56,32 @@ pub(crate) fn map_data_type(ty: &DataType, depth: usize) -> TokenStream { quote! { #ident } } DataType::DynamicRecord => quote! { ::quent_instrumentation::CustomAttributes }, - DataType::EntityRef { data, .. } => match data { - Some(inner) => { - let inner = map_data_type(inner, depth + 1); - quote! { ::quent_instrumentation::EntityRef<#inner> } + DataType::EntityRef { data, annotations } => { + let target = ref_target_marker(annotations); + match data { + Some(inner) => { + let inner = map_data_type(inner, depth + 1); + quote! { ::quent_instrumentation::EntityRef<#target, #inner> } + } + None => quote! { ::quent_instrumentation::EntityRef<#target> }, } - None => quote! { ::quent_instrumentation::EntityRef }, - }, + } + } +} + +/// The target-entity marker type for an entity reference, taken from its +/// ref-target constraint, or the `AnyEntity` marker when it is not restricted +/// to a target entity. +fn ref_target_marker(annotations: &Annotations) -> TokenStream { + match annotations + .constraint(RefTargetConstraint::NAME) + .and_then(|c| c.data()) + { + Some(entity) => { + let marker = raw_ident(entity.to_case(Case::Pascal)); + quote! { #marker } + } + None => quote! { AnyEntity }, } } @@ -78,4 +99,18 @@ mod tests { } let _ = map_data_type(&ty, 0); } + + #[test] + fn entity_ref_uses_its_ref_target_marker() { + use quent_schema::builder::AnnotationsBuilder; + + let mut annotations = AnnotationsBuilder::new(); + annotations.set_constraint(RefTargetConstraint::NAME, Some("Cluster".to_string())); + let ty = DataType::EntityRef { + data: Some(Box::new(DataType::U64)), + annotations: annotations.build(), + }; + let tokens = map_data_type(&ty, 0).to_string(); + assert!(tokens.contains("EntityRef < Cluster , u64 >"), "{tokens}"); + } } diff --git a/crates/instrumentation-build/src/events.rs b/crates/instrumentation-build/src/events.rs index f750b17f5..3474d4873 100644 --- a/crates/instrumentation-build/src/events.rs +++ b/crates/instrumentation-build/src/events.rs @@ -125,8 +125,8 @@ mod tests { list: Vec, rec: SomeRecord, dynrec: ::quent_instrumentation::CustomAttributes, - eref: ::quent_instrumentation::EntityRef, - eref_payload: ::quent_instrumentation::EntityRef + eref: ::quent_instrumentation::EntityRef, + eref_payload: ::quent_instrumentation::EntityRef } } }; @@ -237,7 +237,7 @@ mod tests { #[doc = "The `ev` event."] Ev { nested: Option>>, - eref_list: ::quent_instrumentation::EntityRef> + eref_list: ::quent_instrumentation::EntityRef> } } }; diff --git a/crates/instrumentation-build/src/runtime/handle.rs b/crates/instrumentation-build/src/runtime/handle.rs index 43db68760..0c8a9898c 100644 --- a/crates/instrumentation-build/src/runtime/handle.rs +++ b/crates/instrumentation-build/src/runtime/handle.rs @@ -8,7 +8,7 @@ use proc_macro2::{Literal, TokenStream}; use quent_schema::{Cardinality, Entity}; use quote::quote; -use super::{event_ident, handle_ident}; +use super::{event_ident, handle_ident, marker_ident}; use crate::GenerateError; use crate::common::{doc_attr_or, raw_ident, to_case}; use crate::data_type::map_data_type; @@ -27,6 +27,7 @@ pub(super) fn entity_handle(entity: &Entity) -> Result Result ::quent_instrumentation::EntityRef<#marker_ty> { + ::quent_instrumentation::EntityRef::new(self.uuid(), ()) + } + + /// A typed reference to this instance, carrying `data`. + pub fn as_entity_ref_with(&self, data: T) -> ::quent_instrumentation::EntityRef<#marker_ty, T> { + ::quent_instrumentation::EntityRef::new(self.uuid(), data) + } + + /// A reference to this instance for a field not restricted to a + /// target entity type, carrying no data. + pub fn as_any_entity_ref(&self) -> ::quent_instrumentation::EntityRef<::quent_instrumentation::AnyEntity> { + ::quent_instrumentation::EntityRef::new(self.uuid(), ()) + } + + /// A reference to this instance for a field not restricted to a + /// target entity type, carrying `data`. + pub fn as_any_entity_ref_with(&self, data: T) -> ::quent_instrumentation::EntityRef<::quent_instrumentation::AnyEntity, T> { + ::quent_instrumentation::EntityRef::new(self.uuid(), data) + } + #(#methods)* } }) diff --git a/crates/instrumentation-build/src/runtime/mod.rs b/crates/instrumentation-build/src/runtime/mod.rs index dca7ad080..78d36af09 100644 --- a/crates/instrumentation-build/src/runtime/mod.rs +++ b/crates/instrumentation-build/src/runtime/mod.rs @@ -31,10 +31,12 @@ pub(crate) fn generate_runtime_types(schema: &Schema) -> Result = schema .entities() .map(|entity| { + let marker = entity_marker(entity); let event_impl = entity_event_impl(entity); let observer = observer::entity_observer(entity); let handle = handle::entity_handle(entity)?; Ok::<_, GenerateError>(quote! { + #marker #event_impl #observer #handle @@ -55,11 +57,26 @@ pub(crate) fn generate_runtime_types(schema: &Schema) -> Result TokenStream { quote! { pub use ::quent_instrumentation::{ - CustomAttributes, EntityRef, Event, HandleError, Uuid, + AnyEntity, CustomAttributes, EntityRef, Event, HandleError, Uuid, }; } } +/// `{Entity}` — the zero-size marker naming the entity, used as the target +/// type of [`EntityRef`](quent_instrumentation::EntityRef) fields that point at it. +fn entity_marker(entity: &Entity) -> TokenStream { + let marker = marker_ident(entity); + let doc = format!( + "Marker type for the `{}` entity.", + to_case(entity.name(), Case::Pascal) + ); + quote! { + #[doc = #doc] + #[derive(Debug, Clone, Copy)] + pub struct #marker; + } +} + /// Tie an entity's event enum to its stream name (the entity's snake-case name). fn entity_event_impl(entity: &Entity) -> TokenStream { let event_ty = event_ident(entity); @@ -76,6 +93,11 @@ fn event_ident(entity: &Entity) -> Ident { raw_ident(format!("{}Event", to_case(entity.name(), Case::Pascal))) } +/// `{Entity}` — the entity's ref-target marker type. +fn marker_ident(entity: &Entity) -> Ident { + raw_ident(to_case(entity.name(), Case::Pascal)) +} + /// `{Entity}Observer`. fn observer_ident(entity: &Entity) -> Ident { raw_ident(format!("{}Observer", to_case(entity.name(), Case::Pascal))) diff --git a/crates/instrumentation/src/entity_ref.rs b/crates/instrumentation/src/entity_ref.rs index aab0e38bc..4ba43ad2b 100644 --- a/crates/instrumentation/src/entity_ref.rs +++ b/crates/instrumentation/src/entity_ref.rs @@ -3,17 +3,40 @@ //! Reference from one entity instance to another. +use std::marker::PhantomData; + use uuid::Uuid; -/// Reference from one entity instance to another by id, optionally carrying -/// payload data `T`. +/// Reference to an entity instance of type `E`, optionally carrying payload +/// data `T`. /// -/// Placeholder backing the schema generator's `DataType::EntityRef` fields. +/// In instrumentation libraries generated with `instrumentation-build`, `E` is +/// typically a marker type. #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Debug, Clone)] -pub struct EntityRef { +pub struct EntityRef { + #[cfg_attr(feature = "serde", serde(skip))] + _entity: PhantomData, /// Identifier of the referenced entity instance. pub target: Uuid, /// Payload carried alongside the reference. pub data: T, } + +impl EntityRef { + /// A reference to the entity instance identified by `target`, carrying `data`. + pub fn new(target: Uuid, data: T) -> Self { + Self { + _entity: PhantomData, + target, + data, + } + } +} + +/// Entity marker for a reference not restricted to a single entity type. +/// +/// Untargeted entity reference fields in instrumentation have the type: +/// `EntityRef`. +#[derive(Debug, Clone, Copy)] +pub struct AnyEntity; diff --git a/crates/instrumentation/src/lib.rs b/crates/instrumentation/src/lib.rs index 4e69adad4..3802564d5 100644 --- a/crates/instrumentation/src/lib.rs +++ b/crates/instrumentation/src/lib.rs @@ -14,7 +14,7 @@ mod observer; mod sidecar; pub use context::Context; -pub use entity_ref::EntityRef; +pub use entity_ref::{AnyEntity, EntityRef}; pub use handle::{Handle, HandleError}; pub use observer::{EventSender, Observer}; pub use sidecar::write_sidecar; diff --git a/crates/yaml/Cargo.toml b/crates/yaml/Cargo.toml index c16aeb9f6..d0e7f53b0 100644 --- a/crates/yaml/Cargo.toml +++ b/crates/yaml/Cargo.toml @@ -7,6 +7,8 @@ publish.workspace = true [dependencies] indexmap = { workspace = true, features = ["serde"] } quent-constraints = { path = "../constraints" } +quent-ref-target = { path = "../ref-target" } +quent-ref-tree = { path = "../ref-tree" } quent-schema = { path = "../schema" } serde = { workspace = true } serde-saphyr = "0.0.29" diff --git a/crates/yaml/src/ast.rs b/crates/yaml/src/ast.rs index b2afd7103..3e8ae98f8 100644 --- a/crates/yaml/src/ast.rs +++ b/crates/yaml/src/ast.rs @@ -128,8 +128,11 @@ pub(crate) struct FieldBody { /// A field's type. /// /// A bare name is a built-in type (including `ref`, a plain entity reference) -/// or the name of a record. The list and option forms wrap another type. Nested -/// types are written as nested YAML, not packed into one string. +/// or the name of a record. The list and option forms wrap another type. A +/// `ref` or `scope-ref` form names the entity a reference points at and may +/// carry a `data` type; a `scope-ref` additionally marks the reference as +/// tree-forming. Nested types are written as nested YAML, not packed into one +/// string. #[derive(Debug, Deserialize)] #[serde(untagged)] pub(crate) enum TypeExpr { @@ -137,6 +140,8 @@ pub(crate) enum TypeExpr { Record(String), List(ListType), Option(OptionType), + Ref(RefForm), + Scope(ScopeForm), } /// The bare names that stand for a built-in type. Each is written lowercase in @@ -175,6 +180,28 @@ pub(crate) struct OptionType { pub(crate) option: Box, } +/// A targeted entity reference: `ref` names the entity it points at, with an +/// optional `data` type the reference carries. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct RefForm { + pub(crate) r#ref: String, + #[serde(default)] + pub(crate) data: Option>, +} + +/// A tree-forming targeted reference: `scope-ref` names the entity it points +/// at and marks the reference as part of the scoping tree, with an optional +/// `data` type the reference carries. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ScopeForm { + #[serde(rename = "scope-ref")] + pub(crate) scope_ref: String, + #[serde(default)] + pub(crate) data: Option>, +} + impl From for quent_schema::Cardinality { fn from(c: Cardinality) -> Self { match c { diff --git a/crates/yaml/src/lib.rs b/crates/yaml/src/lib.rs index 6d8f5e49e..66240aa23 100644 --- a/crates/yaml/src/lib.rs +++ b/crates/yaml/src/lib.rs @@ -10,6 +10,8 @@ use std::path::Path; use quent_constraints::validate; +use quent_ref_target::{RefTargetConstraint, RefTargetError}; +use quent_ref_tree::{RefTreeConstraint, RefTreeError}; use quent_schema::Schema; use serde_saphyr::{MessageFormatter, UserMessageFormatter}; @@ -64,7 +66,7 @@ pub fn parse_from_str(src: impl AsRef, source: Option<&str>) -> Result(&schema); + let report = validate::<(RefTargetConstraint, RefTreeConstraint)>(&schema); if let Err(e) = report.base_constraints { for record in e.recursive_records { sink.error( @@ -80,6 +82,13 @@ pub fn parse_from_str(src: impl AsRef, source: Option<&str>) -> Result) -> Result { let src = std::fs::read_to_string(path)?; parse_from_str(&src, Some(&path.display().to_string())) } + +/// Report one diagnostic per ref-target violation, flattening `Multiple`. +fn ref_target_diagnostics(error: RefTargetError, sink: &mut Diagnostics) { + match error { + RefTargetError::Multiple(errors) => { + errors + .into_iter() + .for_each(|error| ref_target_diagnostics(error, sink)); + } + error => sink.error("", error.to_string(), None), + } +} + +/// Report one diagnostic per ref-tree violation, flattening `Multiple`. +fn ref_tree_diagnostics(error: RefTreeError, sink: &mut Diagnostics) { + match error { + RefTreeError::Multiple(errors) => { + errors + .into_iter() + .for_each(|error| ref_tree_diagnostics(error, sink)); + } + error => sink.error("", error.to_string(), None), + } +} diff --git a/crates/yaml/src/lower.rs b/crates/yaml/src/lower.rs index 98714b0a6..f8618c1c2 100644 --- a/crates/yaml/src/lower.rs +++ b/crates/yaml/src/lower.rs @@ -14,6 +14,9 @@ //! interpreted. use indexmap::IndexMap; +use quent_constraints::Constraint; +use quent_ref_target::RefTargetConstraint; +use quent_ref_tree::RefTreeConstraint; use quent_schema::builder::{ AnnotationsBuilder, EntityBuilder, EventBuilder, RecordBuilder, SchemaBuilder, }; @@ -202,9 +205,40 @@ fn type_of(expr: &TypeExpr, path: &str, sink: &mut Diagnostics) -> Option record_ref(name, path, sink), TypeExpr::List(t) => Some(DataType::List(Box::new(type_of(&t.list, path, sink)?))), TypeExpr::Option(t) => Some(DataType::Option(Box::new(type_of(&t.option, path, sink)?))), + TypeExpr::Ref(f) => entity_ref(&f.r#ref, f.data.as_deref(), false, path, sink), + TypeExpr::Scope(f) => entity_ref(&f.scope_ref, f.data.as_deref(), true, path, sink), } } +/// An entity reference targeting `target`, optionally carrying `data` and +/// optionally tree-forming. +/// +/// The target is stored as the ref-target constraint, and a tree-forming +/// reference also gets the ref-tree marker. Whether `target` names a declared +/// entity and whether the references form a valid tree is checked by +/// validation, not here. +fn entity_ref( + target: &str, + data: Option<&TypeExpr>, + tree: bool, + path: &str, + sink: &mut Diagnostics, +) -> Option { + let data = match data { + Some(expr) => Some(Box::new(type_of(expr, path, sink)?)), + None => None, + }; + let mut builder = AnnotationsBuilder::new(); + builder.set_constraint(RefTargetConstraint::NAME, Some(target.to_string())); + if tree { + builder.set_constraint(RefTreeConstraint::NAME, None); + } + Some(DataType::EntityRef { + data, + annotations: builder.build(), + }) +} + /// A bare name that is not a [`BuiltinType`], lowered as a record reference. fn record_ref(name: &str, path: &str, sink: &mut Diagnostics) -> Option { match Identifier::try_new(name) { diff --git a/crates/yaml/tests/references.rs b/crates/yaml/tests/references.rs new file mode 100644 index 000000000..27e0ea4b9 --- /dev/null +++ b/crates/yaml/tests/references.rs @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Reference tests: `ref:` and `scope-ref:` emit the right constraints and are +//! validated against the schema. + +use quent_schema::test_utils::ident; +use quent_schema::{Annotations, DataType, Schema}; +use quent_yaml::parse_from_str; + +const REF_TARGET: &str = "quent.ref-target.v0.1.0"; +const REF_TREE: &str = "quent.ref-tree.v0.1.0"; + +fn schema_of(src: &str) -> Schema { + parse_from_str(src, None).expect("parses").schema +} + +/// The annotations on `entity.event.field`, which must be an entity reference. +fn ref_annotations<'s>( + schema: &'s Schema, + entity: &str, + event: &str, + field: &str, +) -> &'s Annotations { + let field = schema + .entity(&ident(entity)) + .unwrap() + .event(&ident(event)) + .unwrap() + .field(&ident(field)) + .unwrap(); + let DataType::EntityRef { annotations, .. } = field.ty() else { + panic!("expected an entity ref, got {:?}", field.ty()); + }; + annotations +} + +#[test] +fn ref_emits_target_only() { + let schema = schema_of( + "\ +quent: alpha +model: m +entities: + Cluster: + events: + up: once + Engine: + events: + started: + once: + cluster: { ref: Cluster } +", + ); + let anns = ref_annotations(&schema, "Engine", "started", "cluster"); + assert_eq!(anns.constraint(REF_TARGET).unwrap().data(), Some("Cluster")); + assert!(!anns.has_constraint(REF_TREE)); +} + +#[test] +fn ref_can_carry_data() { + let schema = schema_of( + "\ +quent: alpha +model: m +entities: + Cluster: + events: + up: once + Engine: + events: + started: + once: + cluster: + ref: Cluster + data: u64 +", + ); + let field = schema + .entity(&ident("Engine")) + .unwrap() + .event(&ident("started")) + .unwrap() + .field(&ident("cluster")) + .unwrap(); + let DataType::EntityRef { data, annotations } = field.ty() else { + panic!("expected an entity ref"); + }; + assert_eq!(data.as_deref(), Some(&DataType::U64)); + assert_eq!( + annotations.constraint(REF_TARGET).unwrap().data(), + Some("Cluster") + ); +} + +#[test] +fn scope_emits_target_and_tree() { + // Cluster is the root (no scope), Engine is scoped by it — a valid tree. + let schema = schema_of( + "\ +quent: alpha +model: m +entities: + Cluster: + events: + up: once + Engine: + events: + started: + once: + parent: + scope-ref: Cluster + data: u64 +", + ); + let field = schema + .entity(&ident("Engine")) + .unwrap() + .event(&ident("started")) + .unwrap() + .field(&ident("parent")) + .unwrap(); + let DataType::EntityRef { data, annotations } = field.ty() else { + panic!("expected an entity ref"); + }; + assert_eq!(data.as_deref(), Some(&DataType::U64)); + assert_eq!( + annotations.constraint(REF_TARGET).unwrap().data(), + Some("Cluster") + ); + assert!(annotations.has_constraint(REF_TREE)); +}