feat: unify object observation API

This commit is contained in:
2026-09-30 18:19:12 +09:00
parent 2e4196a1ec
commit ea7b0f00ea
32 changed files with 1035 additions and 1251 deletions
+120 -239
View File
@@ -15,14 +15,13 @@ use std::fmt::{self, Display, Formatter};
use wip_https::http::{Request, Response};
use wip_https::{
ClientResponseError, DecodedResponse, Endpoint, Limits, decode_call_operation_response,
decode_fetch_interface_response, decode_fetch_response, decode_fetch_tree_response,
encode_call_operation_request, encode_fetch_interface_request, encode_fetch_request,
encode_fetch_tree_request,
decode_fetch_interface_response, decode_observe_response, encode_call_operation_request,
encode_fetch_interface_request, encode_observe_request,
};
use wip_protocol::{
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, FetchRequest,
FetchTreeRequest, FetchTreeResponse, InterfaceDescriptor, InterfaceTarget, Object,
ObjectTreeNode, OperationDeclaration, ProtocolError, ProtocolErrorCode, Target, Value,
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, InterfaceDescriptor,
InterfaceTarget, Object, ObjectObservation as ProtocolObjectObservation, ObserveRequest,
OperationDeclaration, ProtocolError, ProtocolErrorCode, Target, Value,
};
/// Monotonic identity assigned to one logical request.
@@ -84,7 +83,7 @@ impl SessionId {
pub struct ClientLimits {
/// Maximum simultaneously retained endpoint/security-context sessions.
pub sessions: usize,
/// Maximum object, path, and lazy-tree observations retained by each session.
/// Maximum object, path, and observed-children state retained by each session.
pub objects_per_session: usize,
/// Maximum interface observations retained by each session.
pub interfaces_per_session: usize,
@@ -217,8 +216,6 @@ pub struct TreeObservation {
pub path: String,
/// Materialized direct child paths in Host order.
pub children: Vec<String>,
/// Whether the latest expansion rooted here omitted indexable entries.
pub truncated: bool,
/// Request revision that most recently owned this tree subject.
pub revision: RequestId,
/// Current expansion lifecycle.
@@ -375,8 +372,6 @@ pub struct PreparedRequest {
pub enum Completion {
/// An object observation was updated.
Object(ObjectObservation),
/// A lazy tree expansion was updated.
Tree(TreeObservation),
/// An interface observation was updated.
Interface(InterfaceObservation),
/// An operation reached a terminal outcome.
@@ -501,13 +496,9 @@ impl Session {
#[derive(Debug)]
enum Pending {
Fetch {
Observe {
session: SessionId,
request: FetchRequest,
},
Tree {
session: SessionId,
request: FetchTreeRequest,
request: ObserveRequest,
},
Interface {
session: SessionId,
@@ -522,9 +513,7 @@ enum Pending {
impl Pending {
fn session(&self) -> &SessionId {
match self {
Self::Fetch { session, .. }
| Self::Tree { session, .. }
| Self::Interface { session, .. } => session,
Self::Observe { session, .. } | Self::Interface { session, .. } => session,
Self::Call { context, .. } => context.session(),
}
}
@@ -641,70 +630,30 @@ impl Client {
}
}
/// Starts or refreshes an object observation.
pub fn prepare_fetch(
&mut self,
session: &SessionId,
path: impl Into<String>,
) -> Result<PreparedRequest, ClientError> {
self.ensure_capacity()?;
let request = FetchRequest { path: path.into() };
let endpoint = session_endpoint(session)?;
let encoded =
encode_fetch_request(&endpoint, &request, self.wire_limits).map_err(|error| {
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
})?;
let id = self.allocate_request_id();
let maximum = self.limits.objects_per_session;
let state = self.session_mut(session)?;
ensure_entry_slot(state, &request.path, maximum)?;
let entry = state
.entries
.entry(request.path.clone())
.or_insert(EntryRecord {
object_key: None,
revision: id,
state: ObservationState::Loading(id),
});
entry.revision = id;
entry.state = ObservationState::Loading(id);
self.pending.insert(
id,
Pending::Fetch {
session: session.clone(),
request,
},
);
Ok(PreparedRequest {
id,
request: encoded,
})
}
/// Starts or refreshes a bounded lazy tree expansion.
pub fn prepare_tree(
/// Starts or refreshes an object observation through the requested depth.
pub fn prepare_observe(
&mut self,
session: &SessionId,
path: impl Into<String>,
depth: u32,
limit: u32,
) -> Result<PreparedRequest, ClientError> {
self.ensure_capacity()?;
let request = FetchTreeRequest {
let request = ObserveRequest {
path: path.into(),
depth,
limit,
};
let endpoint = session_endpoint(session)?;
let encoded =
encode_fetch_tree_request(&endpoint, &request, self.wire_limits).map_err(|error| {
encode_observe_request(&endpoint, &request, self.wire_limits).map_err(|error| {
ClientError::new(ClientErrorKind::InvalidRequest, error.to_string())
})?;
let id = self.allocate_request_id();
let maximum = self.limits.objects_per_session;
let state = self.session_mut(session)?;
ensure_entry_slot(state, &request.path, maximum)?;
ensure_tree_slot(state, &request.path, maximum)?;
if request.depth > 0 {
ensure_tree_slot(state, &request.path, maximum)?;
}
let entry = state
.entries
.entry(request.path.clone())
@@ -715,21 +664,26 @@ impl Client {
});
entry.revision = id;
entry.state = ObservationState::Loading(id);
let tree = state
.trees
.entry(request.path.clone())
.or_insert(TreeObservation {
path: request.path.clone(),
children: Vec::new(),
truncated: false,
revision: id,
state: ObservationState::Loading(id),
});
tree.revision = id;
tree.state = ObservationState::Loading(id);
if request.depth > 0 {
let tree = state
.trees
.entry(request.path.clone())
.or_insert(TreeObservation {
path: request.path.clone(),
children: Vec::new(),
revision: id,
state: ObservationState::Loading(id),
});
tree.revision = id;
tree.state = ObservationState::Loading(id);
} else if let Some(tree) = state.trees.get_mut(&request.path)
&& matches!(tree.state, ObservationState::Loading(_))
{
tree.state = ObservationState::Stale;
}
self.pending.insert(
id,
Pending::Tree {
Pending::Observe {
session: session.clone(),
request,
},
@@ -928,11 +882,8 @@ impl Client {
) -> Result<Completion, ClientError> {
let pending = self.pending.remove(&id).ok_or_else(unknown_request)?;
match pending {
Pending::Fetch { session, request } => {
self.complete_fetch(id, &session, request, response)
}
Pending::Tree { session, request } => {
self.complete_tree(id, &session, request, response)
Pending::Observe { session, request } => {
self.complete_observe(id, &session, request, response)
}
Pending::Interface { session, request } => {
self.complete_interface(id, &session, request, response)
@@ -994,12 +945,9 @@ impl Client {
other => {
let session = other.session().clone();
let is_current = match &other {
Pending::Fetch { request, .. } => {
Pending::Observe { request, .. } => {
self.entry_request_is_current(&session, &request.path, id)
}
Pending::Tree { request, .. } => {
self.tree_request_is_current(&session, &request.path, id)
}
Pending::Interface { request, .. } => {
self.interface_request_is_current(&session, &request.interface, id)
}
@@ -1054,28 +1002,29 @@ impl Client {
result
}
fn complete_fetch(
fn complete_observe(
&mut self,
id: RequestId,
session: &SessionId,
request: FetchRequest,
request: ObserveRequest,
response: Response<Vec<u8>>,
) -> Result<Completion, ClientError> {
if !self.entry_request_is_current(session, &request.path, id) {
return self.reject_stale(session, id);
}
match decode_fetch_response(&request, &response, self.wire_limits) {
Ok(DecodedResponse::Success(object)) => {
match decode_observe_response(&request, &response, self.wire_limits) {
Ok(DecodedResponse::Success(value)) => {
let maximum = self.limits.objects_per_session;
let mut candidate = self.session(session)?.clone();
if let Err(error) =
insert_object(&mut candidate, &request.path, object, maximum, id)
apply_observation(&mut candidate, &request.path, value, maximum, id)
{
self.session_mut(session)?
.entries
.get_mut(&request.path)
.expect("loading entry")
.state = ObservationState::Error(error.clone());
set_observe_subject_state(
self.session_mut(session)?,
&request,
id,
ObservationState::Error(error.clone()),
);
self.push_failure_diagnostic(session, id, &error)?;
return Err(error);
}
@@ -1085,62 +1034,6 @@ impl Client {
self.sessions.insert(session.clone(), candidate);
Ok(Completion::Object(observation))
}
Ok(DecodedResponse::ProtocolFailure {
error,
status_mismatch,
}) => {
self.record_status_mismatch(session, id, status_mismatch)?;
self.session_mut(session)?
.entries
.get_mut(&request.path)
.expect("loading entry")
.state = if error.code == ProtocolErrorCode::NotFound {
ObservationState::Stale
} else {
ObservationState::Error(ClientError::new(
ClientErrorKind::ProtocolFailure,
error.to_string(),
))
};
Ok(Completion::ProtocolFailure(error))
}
Err(error) => self.retrieval_decode_error(
id,
session,
RetrievalSubject::Object(request.path),
error,
),
}
}
fn complete_tree(
&mut self,
id: RequestId,
session: &SessionId,
request: FetchTreeRequest,
response: Response<Vec<u8>>,
) -> Result<Completion, ClientError> {
if !self.tree_request_is_current(session, &request.path, id) {
return self.reject_stale(session, id);
}
match decode_fetch_tree_response(&request, &response, self.wire_limits) {
Ok(DecodedResponse::Success(tree)) => {
let maximum = self.limits.objects_per_session;
let mut candidate = self.session(session)?.clone();
if let Err(error) = apply_tree(&mut candidate, &request.path, tree, maximum, id) {
set_tree_subject_state(
self.session_mut(session)?,
&request.path,
id,
ObservationState::Error(error.clone()),
);
self.push_failure_diagnostic(session, id, &error)?;
return Err(error);
}
let observation = candidate.trees[&request.path].clone();
self.sessions.insert(session.clone(), candidate);
Ok(Completion::Tree(observation))
}
Ok(DecodedResponse::ProtocolFailure {
error,
status_mismatch,
@@ -1154,20 +1047,17 @@ impl Client {
error.to_string(),
))
};
set_tree_subject_state(
set_observe_subject_state(
self.session_mut(session)?,
&request.path,
&request,
id,
observation_state,
);
Ok(Completion::ProtocolFailure(error))
}
Err(error) => self.retrieval_decode_error(
id,
session,
RetrievalSubject::Tree(request.path),
error,
),
Err(error) => {
self.retrieval_decode_error(id, session, RetrievalSubject::Observe(request), error)
}
}
}
@@ -1372,17 +1262,10 @@ impl Client {
) -> Result<Completion, ClientError> {
let error = classify_response_error(error);
match subject {
RetrievalSubject::Object(path) => {
self.session_mut(session)?
.entries
.get_mut(&path)
.expect("entry exists")
.state = ObservationState::Error(error.clone());
}
RetrievalSubject::Tree(path) => {
set_tree_subject_state(
RetrievalSubject::Observe(request) => {
set_observe_subject_state(
self.session_mut(session)?,
&path,
&request,
id,
ObservationState::Error(error.clone()),
);
@@ -1407,17 +1290,10 @@ impl Client {
error: ClientError,
) -> Result<(), ClientError> {
match pending {
Pending::Fetch { session, request } => {
self.session_mut(&session)?
.entries
.get_mut(&request.path)
.expect("entry exists")
.state = ObservationState::Error(error);
}
Pending::Tree { session, request } => {
set_tree_subject_state(
Pending::Observe { session, request } => {
set_observe_subject_state(
self.session_mut(&session)?,
&request.path,
&request,
id,
ObservationState::Error(error),
);
@@ -1503,16 +1379,6 @@ impl Client {
)
}
fn tree_request_is_current(&self, session: &SessionId, path: &str, id: RequestId) -> bool {
matches!(
self.sessions
.get(session)
.and_then(|state| state.trees.get(path))
.map(|tree| &tree.state),
Some(ObservationState::Loading(current)) if *current == id
)
}
fn interface_request_is_current(
&self,
session: &SessionId,
@@ -1560,8 +1426,7 @@ impl Client {
#[derive(Debug)]
enum RetrievalSubject {
Object(String),
Tree(String),
Observe(ObserveRequest),
Interface(String),
}
@@ -1589,18 +1454,19 @@ fn classify_response_error(error: ClientResponseError) -> ClientError {
}
}
fn set_tree_subject_state(
fn set_observe_subject_state(
state: &mut Session,
path: &str,
request: &ObserveRequest,
revision: RequestId,
observation_state: ObservationState,
) {
if let Some(tree) = state.trees.get_mut(path)
if request.depth > 0
&& let Some(tree) = state.trees.get_mut(&request.path)
&& tree.revision == revision
{
tree.state = observation_state.clone();
}
if let Some(entry) = state.entries.get_mut(path)
if let Some(entry) = state.entries.get_mut(&request.path)
&& entry.revision == revision
{
entry.state = observation_state;
@@ -1916,9 +1782,10 @@ fn remove_object(state: &mut Session, key: u64) {
}
}
fn reserve_tree_metadata(
fn reserve_observation_metadata(
state: &mut Session,
paths: &[String],
observed_children_paths: &[String],
maximum: usize,
) -> Result<(), ClientError> {
while state.entries.len()
@@ -1955,7 +1822,7 @@ fn reserve_tree_metadata(
remove_entry(state, &candidate);
}
while state.trees.len()
+ paths
+ observed_children_paths
.iter()
.filter(|path| !state.trees.contains_key(*path))
.count()
@@ -1965,7 +1832,8 @@ fn reserve_tree_metadata(
.trees
.iter()
.find(|(path, tree)| {
!paths.contains(path) && !matches!(tree.state, ObservationState::Loading(_))
!observed_children_paths.contains(path)
&& !matches!(tree.state, ObservationState::Loading(_))
})
.map(|(path, _)| path.clone())
.ok_or_else(|| {
@@ -1993,45 +1861,50 @@ fn mark_subtree_stale(state: &mut Session, root: &str, request_id: RequestId) {
}
}
fn apply_tree(
fn apply_observation(
state: &mut Session,
root_path: &str,
response: FetchTreeResponse,
response: ProtocolObjectObservation,
maximum: usize,
request_id: RequestId,
) -> Result<(), ClientError> {
let mut nodes = Vec::new();
flatten_tree(root_path, &response.root, &mut nodes);
flatten_observation(root_path, &response, &mut nodes);
if nodes.len() > maximum {
return Err(ClientError::new(
ClientErrorKind::Capacity,
"tree response exceeds the local observation bound",
"observation response exceeds the local observation bound",
));
}
let paths: Vec<String> = nodes.iter().map(|(path, _, _)| path.clone()).collect();
reserve_tree_metadata(state, &paths, maximum)?;
let observed_children_paths: Vec<String> = nodes
.iter()
.filter_map(|(path, _, children)| children.as_ref().map(|_| path.clone()))
.collect();
reserve_observation_metadata(state, &paths, &observed_children_paths, maximum)?;
if !response.truncated {
let mut omitted = Vec::new();
for (path, _, children) in &nodes {
if let Some(previous) = state
.trees
.get(path)
.filter(|previous| previous.revision <= request_id)
{
omitted.extend(
previous
.children
.iter()
.filter(|child| !children.contains(child))
.cloned(),
);
}
}
for path in omitted {
mark_subtree_stale(state, &path, request_id);
let mut omitted = Vec::new();
for (path, _, children) in &nodes {
let Some(children) = children else {
continue;
};
if let Some(previous) = state
.trees
.get(path)
.filter(|previous| previous.revision <= request_id)
{
omitted.extend(
previous
.children
.iter()
.filter(|child| !children.contains(child))
.cloned(),
);
}
}
for path in omitted {
mark_subtree_stale(state, &path, request_id);
}
for (path, object, _) in &nodes {
validate_object_consistency(state, object)?;
@@ -2044,6 +1917,9 @@ fn apply_tree(
}
}
for (path, _, children) in nodes {
let Some(children) = children else {
continue;
};
let newer_owner = state
.trees
.get(&path)
@@ -2051,13 +1927,11 @@ fn apply_tree(
if newer_owner {
continue;
}
let is_root = path == root_path;
state.trees.insert(
path.clone(),
TreeObservation {
path,
children,
truncated: is_root && response.truncated,
revision: request_id,
state: ObservationState::Fresh,
},
@@ -2066,19 +1940,26 @@ fn apply_tree(
Ok(())
}
fn flatten_tree(
fn flatten_observation(
path: &str,
node: &ObjectTreeNode,
output: &mut Vec<(String, Object, Vec<String>)>,
observation: &ProtocolObjectObservation,
output: &mut Vec<(String, Object, Option<Vec<String>>)>,
) {
let children: Vec<String> = node
.children
.iter()
.map(|child| child_path(path, &child.object.name))
.collect();
output.push((path.to_owned(), node.object.clone(), children.clone()));
for (child, child_path) in node.children.iter().zip(children) {
flatten_tree(&child_path, child, output);
let children = observation.children.as_ref().map(|children| {
children
.iter()
.map(|child| child_path(path, &child.object.name))
.collect::<Vec<_>>()
});
output.push((
path.to_owned(),
observation.object.clone(),
children.clone(),
));
if let (Some(observations), Some(paths)) = (&observation.children, children) {
for (child, child_path) in observations.iter().zip(paths) {
flatten_observation(&child_path, child, output);
}
}
}
+19 -14
View File
@@ -6,12 +6,13 @@ use wip_client::{
};
use wip_https::http::{Response, StatusCode};
use wip_https::{
Limits, encode_fetch_interface_response, encode_fetch_response, encode_protocol_error_response,
Limits, encode_fetch_interface_response, encode_observe_response,
encode_protocol_error_response,
};
use wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest, INTERFACE_FORMAT_V1,
InterfaceDescriptor, Object, OperationDeclaration, ParameterDeclaration, ProtocolError,
ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value,
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor,
Object, ObjectObservation, ObserveRequest, OperationDeclaration, ParameterDeclaration,
ProtocolError, ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value,
};
fn limits() -> Limits {
@@ -76,7 +77,7 @@ fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_resp
];
for body in cases {
let (mut client, session) = setup();
let request = client.prepare_fetch(&session, "/item").unwrap();
let request = client.prepare_observe(&session, "/item", 0).unwrap();
let error = client
.complete(request.id, response(StatusCode::OK, body))
.unwrap_err();
@@ -106,7 +107,7 @@ fn disallowed_code_and_status_success_contradictions_are_invalid_response() {
];
for (status, body) in cases {
let (mut client, session) = setup();
let request = client.prepare_fetch(&session, "/item").unwrap();
let request = client.prepare_observe(&session, "/item", 0).unwrap();
assert_eq!(
client
.complete(request.id, response(status, body))
@@ -120,13 +121,13 @@ fn disallowed_code_and_status_success_contradictions_are_invalid_response() {
#[test]
fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() {
let (mut client, session) = setup();
let request = client.prepare_fetch(&session, "/item").unwrap();
let request = client.prepare_observe(&session, "/item", 0).unwrap();
let error = ProtocolError {
code: ProtocolErrorCode::NotFound,
message: "missing".into(),
};
let mut encoded =
encode_protocol_error_response(ProtocolInteraction::Fetch, &error, limits()).unwrap();
encode_protocol_error_response(ProtocolInteraction::Observe, &error, limits()).unwrap();
*encoded.status_mut() = StatusCode::IM_A_TEAPOT;
assert_eq!(
@@ -146,7 +147,7 @@ fn valid_error_envelope_is_authoritative_and_status_mismatch_is_diagnostic() {
#[test]
fn non_success_without_valid_envelope_remains_transport_failure() {
let (mut client, session) = setup();
let request = client.prepare_fetch(&session, "/item").unwrap();
let request = client.prepare_observe(&session, "/item", 0).unwrap();
let error = client
.complete(
request.id,
@@ -176,15 +177,19 @@ fn unsupported_descriptor_format_is_distinct_from_invalid_response() {
#[test]
fn malformed_call_success_after_dispatch_is_outcome_unknown_and_stales_object() {
let (mut client, session) = setup();
let fetch = client.prepare_fetch(&session, "/item").unwrap();
let observation = client.prepare_observe(&session, "/item", 0).unwrap();
client
.complete(
fetch.id,
encode_fetch_response(
&FetchRequest {
observation.id,
encode_observe_response(
&ObserveRequest {
path: "/item".into(),
depth: 0,
},
&ObjectObservation {
object: object(),
children: None,
},
&object(),
limits(),
)
.unwrap(),
+271 -140
View File
@@ -6,20 +6,54 @@ use wip_client::{
};
use wip_https::http::Response;
use wip_https::{
Limits, encode_call_operation_response, encode_fetch_interface_response, encode_fetch_response,
encode_fetch_tree_response, encode_protocol_error_response,
EncodeHttpError, Limits, encode_call_operation_response, encode_fetch_interface_response,
encode_observe_response, encode_protocol_error_response,
};
use wip_protocol::{
CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse, FetchRequest,
FetchTreeRequest, FetchTreeResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object,
ObjectTreeNode, OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode,
ProtocolInteraction, ReturnDeclaration, TypeExpr, Value,
CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1,
InterfaceDescriptor, Object, ObjectObservation, ObserveRequest, OperationDeclaration,
ParameterDeclaration, ProtocolError, ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration,
TypeExpr, Value,
};
fn wire_limits() -> Limits {
Limits::new(64 * 1024, 64 * 1024, 64, 64).unwrap()
}
#[derive(Clone)]
struct TestObservation {
object: Object,
children: Vec<TestObservation>,
}
struct TestObservationResponse {
root: TestObservation,
}
fn protocol_observation(node: &TestObservation, depth: u32) -> ObjectObservation {
ObjectObservation {
object: node.object.clone(),
children: (depth > 0).then(|| {
node.children
.iter()
.map(|child| protocol_observation(child, depth - 1))
.collect()
}),
}
}
fn encode_test_observation_response(
request: &ObserveRequest,
value: &TestObservationResponse,
limits: Limits,
) -> Result<Response<Vec<u8>>, EncodeHttpError> {
encode_observe_response(
request,
&protocol_observation(&value.root, request.depth),
limits,
)
}
fn client(objects: usize, interfaces: usize, in_flight: usize, history: usize) -> Client {
Client::new(
ClientLimits::new(8, objects, interfaces, in_flight, history).unwrap(),
@@ -59,14 +93,25 @@ fn descriptor(return_type: TypeExpr) -> InterfaceDescriptor {
}
}
fn fetch_response(path: &str, value: &Object) -> Response<Vec<u8>> {
encode_fetch_response(&FetchRequest { path: path.into() }, value, wire_limits()).unwrap()
fn observation_response(path: &str, value: &Object) -> Response<Vec<u8>> {
encode_observe_response(
&ObserveRequest {
path: path.into(),
depth: 0,
},
&ObjectObservation {
object: value.clone(),
children: None,
},
wire_limits(),
)
.unwrap()
}
fn observe_object(client: &mut Client, session: &wip_client::SessionId, path: &str, value: Object) {
let request = client.prepare_fetch(session, path).unwrap();
let request = client.prepare_observe(session, path, 0).unwrap();
client
.complete(request.id, fetch_response(path, &value))
.complete(request.id, observation_response(path, &value))
.unwrap();
}
@@ -134,14 +179,14 @@ fn newer_refresh_rejects_out_of_order_response_for_the_same_subject() {
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
let old = client.prepare_fetch(&session, "/item").unwrap();
let new = client.prepare_fetch(&session, "/item").unwrap();
let old = client.prepare_observe(&session, "/item", 0).unwrap();
let new = client.prepare_observe(&session, "/item", 0).unwrap();
assert_eq!(
client
.complete(
old.id,
fetch_response("/item", &object("item", None, b"old"))
observation_response("/item", &object("item", None, b"old"))
)
.unwrap(),
Completion::StaleResponseRejected(old.id)
@@ -154,7 +199,7 @@ fn newer_refresh_rejects_out_of_order_response_for_the_same_subject() {
client
.complete(
new.id,
fetch_response("/item", &object("item", None, b"new")),
observation_response("/item", &object("item", None, b"new")),
)
.unwrap();
assert_eq!(
@@ -182,11 +227,11 @@ fn normalized_object_cache_rejects_conflicts_and_evicts_to_its_bound() {
object("shared", Some("shared-ref"), b"v1"),
);
let conflict = client.prepare_fetch(&session, "/b/shared").unwrap();
let conflict = client.prepare_observe(&session, "/b/shared", 0).unwrap();
let mut changed = object("shared", Some("shared-ref"), b"v1");
changed.description = Some("contradiction".into());
let error = client
.complete(conflict.id, fetch_response("/b/shared", &changed))
.complete(conflict.id, observation_response("/b/shared", &changed))
.unwrap_err();
assert_eq!(error.kind, ClientErrorKind::InvalidResponse);
@@ -220,34 +265,33 @@ fn normalized_aliases_preserve_newer_request_ownership() {
"/b/shared",
object("shared", Some("shared-ref"), b"initial"),
);
let request = FetchTreeRequest {
let request = ObserveRequest {
path: "/a".into(),
depth: 1,
limit: 4,
};
let older_tree_response = || FetchTreeResponse {
root: ObjectTreeNode {
let older_tree_response = || TestObservationResponse {
root: TestObservation {
object: object("a", None, b"root"),
children: vec![ObjectTreeNode {
children: vec![TestObservation {
object: object("shared", Some("shared-ref"), b"older"),
children: vec![],
}],
},
truncated: false,
};
let older_tree = client.prepare_tree(&session, "/a", 1, 4).unwrap();
let newer_alias = client.prepare_fetch(&session, "/b/shared").unwrap();
let older_tree = client.prepare_observe(&session, "/a", 1).unwrap();
let newer_alias = client.prepare_observe(&session, "/b/shared", 0).unwrap();
client
.complete(
newer_alias.id,
fetch_response("/b/shared", &object("shared", Some("shared-ref"), b"newer")),
observation_response("/b/shared", &object("shared", Some("shared-ref"), b"newer")),
)
.unwrap();
client
.complete(
older_tree.id,
encode_fetch_tree_response(&request, &older_tree_response(), wire_limits()).unwrap(),
encode_test_observation_response(&request, &older_tree_response(), wire_limits())
.unwrap(),
)
.unwrap();
assert_eq!(
@@ -259,12 +303,13 @@ fn normalized_aliases_preserve_newer_request_ownership() {
Some(b"newer".to_vec())
);
let older_tree = client.prepare_tree(&session, "/a", 1, 4).unwrap();
let newer_alias = client.prepare_fetch(&session, "/b/shared").unwrap();
let older_tree = client.prepare_observe(&session, "/a", 1).unwrap();
let newer_alias = client.prepare_observe(&session, "/b/shared", 0).unwrap();
client
.complete(
older_tree.id,
encode_fetch_tree_response(&request, &older_tree_response(), wire_limits()).unwrap(),
encode_test_observation_response(&request, &older_tree_response(), wire_limits())
.unwrap(),
)
.unwrap();
assert!(matches!(
@@ -278,7 +323,7 @@ fn normalized_aliases_preserve_newer_request_ownership() {
client
.complete(
newer_alias.id,
fetch_response(
observation_response(
"/b/shared",
&object("shared", Some("shared-ref"), b"newest"),
),
@@ -289,12 +334,12 @@ fn normalized_aliases_preserve_newer_request_ownership() {
Some(b"newest".to_vec())
);
let older_tree = client.prepare_tree(&session, "/a", 1, 4).unwrap();
let newer_alias = client.prepare_fetch(&session, "/b/shared").unwrap();
let older_tree = client.prepare_observe(&session, "/a", 1).unwrap();
let newer_alias = client.prepare_observe(&session, "/b/shared", 0).unwrap();
client
.complete(
newer_alias.id,
fetch_response(
observation_response(
"/b/shared",
&object("shared", Some("shared-ref"), b"newest"),
),
@@ -305,17 +350,16 @@ fn normalized_aliases_preserve_newer_request_ownership() {
let error = client
.complete(
older_tree.id,
encode_fetch_tree_response(
encode_test_observation_response(
&request,
&FetchTreeResponse {
root: ObjectTreeNode {
&TestObservationResponse {
root: TestObservation {
object: object("a", None, b"root"),
children: vec![ObjectTreeNode {
children: vec![TestObservation {
object: contradictory,
children: vec![],
}],
},
truncated: false,
},
wire_limits(),
)
@@ -334,35 +378,33 @@ fn normalized_aliases_preserve_newer_request_ownership() {
"/child",
object("child", Some("child-ref"), b"stable"),
);
let older_tree = client.prepare_tree(&session, "/", 1, 4).unwrap();
let newer_child = client.prepare_fetch(&session, "/child").unwrap();
let older_tree = client.prepare_observe(&session, "/", 1).unwrap();
let newer_child = client.prepare_observe(&session, "/child", 0).unwrap();
client
.complete(
newer_child.id,
fetch_response("/child", &object("child", Some("child-ref"), b"stable")),
observation_response("/child", &object("child", Some("child-ref"), b"stable")),
)
.unwrap();
let mut contradictory = object("child", Some("child-ref"), b"stable");
contradictory.description = Some("same-path contradiction".into());
let root_request = FetchTreeRequest {
let root_request = ObserveRequest {
path: "/".into(),
depth: 1,
limit: 4,
};
let error = client
.complete(
older_tree.id,
encode_fetch_tree_response(
encode_test_observation_response(
&root_request,
&FetchTreeResponse {
root: ObjectTreeNode {
&TestObservationResponse {
root: TestObservation {
object: object("", None, b"root"),
children: vec![ObjectTreeNode {
children: vec![TestObservation {
object: contradictory,
children: vec![],
}],
},
truncated: false,
},
wire_limits(),
)
@@ -377,61 +419,151 @@ fn normalized_aliases_preserve_newer_request_ownership() {
}
#[test]
fn tree_expansion_is_lazy_truncated_and_refreshes_edges() {
fn observation_expansion_is_complete_and_refreshes_edges() {
let mut client = client(16, 8, 8, 8);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
assert!(client.tree(&session, "/").is_none());
let request = client.prepare_tree(&session, "/", 1, 4).unwrap();
let logical_request = FetchTreeRequest {
let request = client.prepare_observe(&session, "/", 1).unwrap();
let logical_request = ObserveRequest {
path: "/".into(),
depth: 1,
limit: 4,
};
let tree = FetchTreeResponse {
root: ObjectTreeNode {
let tree = TestObservationResponse {
root: TestObservation {
object: object("", None, b"root"),
children: vec![ObjectTreeNode {
children: vec![TestObservation {
object: object("items", None, b"items"),
children: vec![],
}],
},
truncated: true,
};
client
.complete(
request.id,
encode_fetch_tree_response(&logical_request, &tree, wire_limits()).unwrap(),
encode_test_observation_response(&logical_request, &tree, wire_limits()).unwrap(),
)
.unwrap();
assert_eq!(client.tree(&session, "/").unwrap().children, ["/items"]);
assert!(client.tree(&session, "/").unwrap().truncated);
assert!(client.object(&session, "/items").is_some());
let refresh = client.prepare_tree(&session, "/", 1, 4).unwrap();
let refreshed = FetchTreeResponse {
root: ObjectTreeNode {
let refresh = client.prepare_observe(&session, "/", 1).unwrap();
let refreshed = TestObservationResponse {
root: TestObservation {
object: object("", None, b"root-2"),
children: vec![],
},
truncated: false,
};
client
.complete(
refresh.id,
encode_fetch_tree_response(&logical_request, &refreshed, wire_limits()).unwrap(),
encode_test_observation_response(&logical_request, &refreshed, wire_limits()).unwrap(),
)
.unwrap();
assert!(client.tree(&session, "/").unwrap().children.is_empty());
assert!(!client.tree(&session, "/").unwrap().truncated);
assert_eq!(
client.object(&session, "/items").unwrap().state,
ObservationState::Stale
);
}
#[test]
fn depth_zero_observation_preserves_previously_observed_children() {
let mut client = client(16, 8, 8, 8);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
let deep_request = ObserveRequest {
path: "/".into(),
depth: 1,
};
let deep = client.prepare_observe(&session, "/", 1).unwrap();
client
.complete(
deep.id,
encode_test_observation_response(
&deep_request,
&TestObservationResponse {
root: TestObservation {
object: object("", None, b"root-v1"),
children: vec![TestObservation {
object: object("child", None, b"child-v1"),
children: vec![],
}],
},
},
wire_limits(),
)
.unwrap(),
)
.unwrap();
let shallow = client.prepare_observe(&session, "/", 0).unwrap();
client
.complete(
shallow.id,
observation_response("/", &object("", None, b"root-v2")),
)
.unwrap();
assert_eq!(client.tree(&session, "/").unwrap().children, ["/child"]);
assert_eq!(
client.object(&session, "/child").unwrap().state,
ObservationState::Fresh
);
assert_eq!(
client.object(&session, "/").unwrap().validator,
Some(b"root-v2".to_vec())
);
}
#[test]
fn shallow_observation_supersedes_loading_deep_observation_without_leaking_loading_state() {
let mut client = client(16, 8, 8, 8);
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
let deep_request = ObserveRequest {
path: "/".into(),
depth: 1,
};
let deep_value = TestObservationResponse {
root: TestObservation {
object: object("", None, b"old-root"),
children: vec![],
},
};
let deep = client.prepare_observe(&session, "/", 1).unwrap();
let shallow = client.prepare_observe(&session, "/", 0).unwrap();
assert_eq!(
client.tree(&session, "/").unwrap().state,
ObservationState::Stale
);
assert_eq!(
client
.complete(
deep.id,
encode_test_observation_response(&deep_request, &deep_value, wire_limits())
.unwrap(),
)
.unwrap(),
Completion::StaleResponseRejected(deep.id)
);
client
.complete(
shallow.id,
observation_response("/", &object("", None, b"new-root")),
)
.unwrap();
assert_eq!(
client.tree(&session, "/").unwrap().state,
ObservationState::Stale
);
}
#[test]
fn call_context_fixes_membership_validators_descriptor_and_result_validation() {
let mut client = client(8, 8, 8, 8);
@@ -686,7 +818,7 @@ fn older_retrieval_cannot_overwrite_success_validator() {
},
);
let older_alias = client.prepare_fetch(&session, "/alias/item").unwrap();
let older_alias = client.prepare_observe(&session, "/alias/item", 0).unwrap();
let call = client
.prepare_call(&session, "/item", "math", "calculate", args(7))
.unwrap();
@@ -723,7 +855,7 @@ fn older_retrieval_cannot_overwrite_success_validator() {
client
.complete(
older_alias.id,
fetch_response(
observation_response(
"/alias/item",
&object("item", Some("shared-ref"), b"object-v1"),
),
@@ -746,7 +878,7 @@ fn older_retrieval_cannot_overwrite_success_validator() {
"/item",
object("item", Some("shared-ref"), b"object-v2"),
);
let older_alias = client.prepare_fetch(&session, "/second/item").unwrap();
let older_alias = client.prepare_observe(&session, "/second/item", 0).unwrap();
let call = client
.prepare_call(&session, "/item", "math", "calculate", args(8))
.unwrap();
@@ -769,7 +901,7 @@ fn older_retrieval_cannot_overwrite_success_validator() {
client
.complete(
older_alias.id,
fetch_response(
observation_response(
"/second/item",
&object("item", Some("shared-ref"), b"object-v2"),
),
@@ -808,7 +940,10 @@ fn in_flight_calls_pin_target_identity_until_staleness_is_applied() {
client.mark_dispatched(call.id).unwrap();
assert_eq!(
client.prepare_fetch(&session, "/other").unwrap_err().kind,
client
.prepare_observe(&session, "/other", 0)
.unwrap_err()
.kind,
ClientErrorKind::Capacity
);
let unknown = ProtocolError {
@@ -980,57 +1115,55 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() {
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
let tree_request = FetchTreeRequest {
let tree_request = ObserveRequest {
path: "/".into(),
depth: 1,
limit: 4,
};
let tree_value = FetchTreeResponse {
root: ObjectTreeNode {
let tree_value = TestObservationResponse {
root: TestObservation {
object: object("", None, b"root"),
children: vec![ObjectTreeNode {
children: vec![TestObservation {
object: object("child", None, b"tree-child"),
children: vec![],
}],
},
truncated: false,
};
let old_tree = client.prepare_tree(&session, "/", 1, 4).unwrap();
let newer_fetch = client.prepare_fetch(&session, "/child").unwrap();
let old_tree = client.prepare_observe(&session, "/", 1).unwrap();
let newer_observation = client.prepare_observe(&session, "/child", 0).unwrap();
client
.complete(
old_tree.id,
encode_fetch_tree_response(&tree_request, &tree_value, wire_limits()).unwrap(),
encode_test_observation_response(&tree_request, &tree_value, wire_limits()).unwrap(),
)
.unwrap();
assert!(matches!(
client.object(&session, "/child").unwrap().state,
ObservationState::Loading(id) if id == newer_fetch.id
ObservationState::Loading(id) if id == newer_observation.id
));
client
.complete(
newer_fetch.id,
fetch_response("/child", &object("child", None, b"newer-fetch")),
newer_observation.id,
observation_response("/child", &object("child", None, b"newer-observation")),
)
.unwrap();
assert_eq!(
client.object(&session, "/child").unwrap().validator,
Some(b"newer-fetch".to_vec())
Some(b"newer-observation".to_vec())
);
let older_tree = client.prepare_tree(&session, "/", 1, 4).unwrap();
let completed_newer_fetch = client.prepare_fetch(&session, "/child").unwrap();
let older_tree = client.prepare_observe(&session, "/", 1).unwrap();
let completed_newer_observation = client.prepare_observe(&session, "/child", 0).unwrap();
client
.complete(
completed_newer_fetch.id,
fetch_response("/child", &object("child", None, b"completed-newer")),
completed_newer_observation.id,
observation_response("/child", &object("child", None, b"completed-newer")),
)
.unwrap();
client
.complete(
older_tree.id,
encode_fetch_tree_response(&tree_request, &tree_value, wire_limits()).unwrap(),
encode_test_observation_response(&tree_request, &tree_value, wire_limits()).unwrap(),
)
.unwrap();
assert_eq!(
@@ -1038,25 +1171,24 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() {
Some(b"completed-newer".to_vec())
);
let older_omitting_tree = client.prepare_tree(&session, "/", 1, 4).unwrap();
let newer_fetch = client.prepare_fetch(&session, "/child").unwrap();
let older_omitting_tree = client.prepare_observe(&session, "/", 1).unwrap();
let newer_observation = client.prepare_observe(&session, "/child", 0).unwrap();
client
.complete(
newer_fetch.id,
fetch_response("/child", &object("child", None, b"newer-than-omission")),
newer_observation.id,
observation_response("/child", &object("child", None, b"newer-than-omission")),
)
.unwrap();
client
.complete(
older_omitting_tree.id,
encode_fetch_tree_response(
encode_test_observation_response(
&tree_request,
&FetchTreeResponse {
root: ObjectTreeNode {
&TestObservationResponse {
root: TestObservation {
object: object("", None, b"older-root"),
children: vec![],
},
truncated: false,
},
wire_limits(),
)
@@ -1067,12 +1199,12 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() {
assert_eq!(child.state, ObservationState::Fresh);
assert_eq!(child.validator, Some(b"newer-than-omission".to_vec()));
let old_tree = client.prepare_tree(&session, "/", 1, 4).unwrap();
let newer_child_tree = client.prepare_tree(&session, "/child", 0, 1).unwrap();
let old_tree = client.prepare_observe(&session, "/", 1).unwrap();
let newer_child_tree = client.prepare_observe(&session, "/child", 1).unwrap();
client
.complete(
old_tree.id,
encode_fetch_tree_response(&tree_request, &tree_value, wire_limits()).unwrap(),
encode_test_observation_response(&tree_request, &tree_value, wire_limits()).unwrap(),
)
.unwrap();
assert!(matches!(
@@ -1088,22 +1220,20 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() {
child_observation.validator,
Some(b"newer-than-omission".to_vec())
);
let child_request = FetchTreeRequest {
let child_request = ObserveRequest {
path: "/child".into(),
depth: 0,
limit: 1,
depth: 1,
};
let child_value = FetchTreeResponse {
root: ObjectTreeNode {
let child_value = TestObservationResponse {
root: TestObservation {
object: object("child", None, b"newer-tree"),
children: vec![],
},
truncated: false,
};
client
.complete(
newer_child_tree.id,
encode_fetch_tree_response(&child_request, &child_value, wire_limits()).unwrap(),
encode_test_observation_response(&child_request, &child_value, wire_limits()).unwrap(),
)
.unwrap();
assert_eq!(
@@ -1111,19 +1241,18 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() {
ObservationState::Fresh
);
let older_parent = client.prepare_tree(&session, "/", 1, 4).unwrap();
let newer_child = client.prepare_tree(&session, "/child", 0, 1).unwrap();
let older_parent = client.prepare_observe(&session, "/", 1).unwrap();
let newer_child = client.prepare_observe(&session, "/child", 1).unwrap();
client
.complete(
newer_child.id,
encode_fetch_tree_response(
encode_test_observation_response(
&child_request,
&FetchTreeResponse {
root: ObjectTreeNode {
&TestObservationResponse {
root: TestObservation {
object: object("child", None, b"completed-newer-tree"),
children: vec![],
},
truncated: false,
},
wire_limits(),
)
@@ -1133,7 +1262,7 @@ fn overlapping_tree_work_preserves_newer_subject_request_ownership() {
client
.complete(
older_parent.id,
encode_fetch_tree_response(&tree_request, &tree_value, wire_limits()).unwrap(),
encode_test_observation_response(&tree_request, &tree_value, wire_limits()).unwrap(),
)
.unwrap();
assert_eq!(
@@ -1152,8 +1281,8 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
let old = client.prepare_fetch(&session, "/item").unwrap();
let newer = client.prepare_fetch(&session, "/item").unwrap();
let old = client.prepare_observe(&session, "/item", 0).unwrap();
let newer = client.prepare_observe(&session, "/item", 0).unwrap();
assert_eq!(
client
.fail_transport(old.id, "old connection failed")
@@ -1167,12 +1296,12 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
client
.complete(
newer.id,
fetch_response("/item", &object("item", None, b"new")),
observation_response("/item", &object("item", None, b"new")),
)
.unwrap();
let old = client.prepare_tree(&session, "/", 0, 1).unwrap();
let newer_tree = client.prepare_tree(&session, "/", 0, 1).unwrap();
let old = client.prepare_observe(&session, "/", 1).unwrap();
let newer_tree = client.prepare_observe(&session, "/", 1).unwrap();
assert_eq!(
client
.fail_transport(old.id, "old tree connection failed")
@@ -1186,18 +1315,16 @@ fn superseded_transport_failure_does_not_poison_newer_refresh() {
client
.complete(
newer_tree.id,
encode_fetch_tree_response(
&FetchTreeRequest {
encode_test_observation_response(
&ObserveRequest {
path: "/".into(),
depth: 0,
limit: 1,
depth: 1,
},
&FetchTreeResponse {
root: ObjectTreeNode {
&TestObservationResponse {
root: TestObservation {
object: object("", None, b"root"),
children: vec![],
},
truncated: false,
},
wire_limits(),
)
@@ -1283,9 +1410,12 @@ fn entry_eviction_never_discards_a_loading_tree_subject() {
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
observe_object(&mut client, &session, "/", object("", None, b"root"));
let tree = client.prepare_tree(&session, "/", 0, 1).unwrap();
let tree = client.prepare_observe(&session, "/", 1).unwrap();
assert_eq!(
client.prepare_fetch(&session, "/other").unwrap_err().kind,
client
.prepare_observe(&session, "/other", 0)
.unwrap_err()
.kind,
ClientErrorKind::Capacity
);
assert!(matches!(
@@ -1295,18 +1425,16 @@ fn entry_eviction_never_discards_a_loading_tree_subject() {
client
.complete(
tree.id,
encode_fetch_tree_response(
&FetchTreeRequest {
encode_test_observation_response(
&ObserveRequest {
path: "/".into(),
depth: 0,
limit: 1,
depth: 1,
},
&FetchTreeResponse {
root: ObjectTreeNode {
&TestObservationResponse {
root: TestObservation {
object: object("", None, b"tree"),
children: vec![],
},
truncated: false,
},
wire_limits(),
)
@@ -1326,7 +1454,7 @@ fn failed_entry_and_tree_observation_metadata_is_bounded() {
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
for path in ["/one", "/two", "/three"] {
let request = client.prepare_fetch(&session, path).unwrap();
let request = client.prepare_observe(&session, path, 0).unwrap();
assert_eq!(
client
.fail_transport(request.id, "retrieval failed")
@@ -1340,7 +1468,7 @@ fn failed_entry_and_tree_observation_metadata_is_bounded() {
assert!(client.object(&session, "/three").is_some());
for path in ["/alpha", "/beta", "/gamma"] {
let request = client.prepare_tree(&session, path, 0, 1).unwrap();
let request = client.prepare_observe(&session, path, 1).unwrap();
assert_eq!(
client
.fail_transport(request.id, "tree failed")
@@ -1359,16 +1487,19 @@ fn in_flight_and_history_are_bounded() {
let session = client
.open_session("https://example.test/wip", SecurityContext::new("subject"))
.unwrap();
let first = client.prepare_fetch(&session, "/one").unwrap();
let error = client.prepare_fetch(&session, "/two").unwrap_err();
let first = client.prepare_observe(&session, "/one", 0).unwrap();
let error = client.prepare_observe(&session, "/two", 0).unwrap_err();
assert_eq!(error.kind, ClientErrorKind::Capacity);
client
.complete(first.id, fetch_response("/one", &object("one", None, b"1")))
.complete(
first.id,
observation_response("/one", &object("one", None, b"1")),
)
.unwrap();
// Diagnostic history evicts oldest entries at the configured bound.
let stale_old = client.prepare_fetch(&session, "/one").unwrap();
let stale_new = client.prepare_fetch(&session, "/one").unwrap_err();
let stale_old = client.prepare_observe(&session, "/one", 0).unwrap();
let stale_new = client.prepare_observe(&session, "/one", 0).unwrap_err();
assert_eq!(stale_new.kind, ClientErrorKind::Capacity);
client
.complete(