Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/reference/failure-reasons.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ instead of re-deriving category from code or reason on its own.

| Reason | gRPC code | Category | Metadata |
| --- | --- | --- | --- |
| `NOT_FOUND` | `NOT_FOUND` | `not_found` | none |
| `NOT_FOUND` | `NOT_FOUND` | `not_found` | `did_you_mean`, up to 3 comma-separated slugs of the same kind in the same namespace that are near the requested slug, closest first. Only `get_entity`, `get_slice_projection`, `get_storyboard_projection` and `get_event_model_projection` set it, and only for a namespace the caller can read. Absent when nothing is near. The client gives it as a list in the failure details (MCP `next_action_detail.did_you_mean`). |
| `ALREADY_EXISTS` | `ALREADY_EXISTS` | `precondition` | none |
| `STALE_PRECONDITION` | `ABORTED` | `stale_state` | none |
| `VALIDATION_FAILED` | `INVALID_ARGUMENT` | `validation` | none |
Expand Down
35 changes: 32 additions & 3 deletions rsworkspace/crates/trogon-atlas-client/src/failure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,9 +397,15 @@ impl Failure {
),
_ => return None,
};
let details = match metadata.get("journal") {
Some(journal) => FailureDetails::from_json(json!({ "journal": journal })),
None => FailureDetails::empty(),
let details = if let Some(journal) = metadata.get("journal") {
FailureDetails::from_json(json!({ "journal": journal }))
} else if let Some(slugs) = metadata.get("did_you_mean") {
// ErrorInfo metadata is string-to-string, so the server joins the
// slugs with a comma, which a valid slug can never contain.
let slugs: Vec<&str> = slugs.split(',').filter(|s| !s.is_empty()).collect();
FailureDetails::from_json(json!({ "did_you_mean": slugs }))
} else {
FailureDetails::empty()
};
let failure = Self::new(category, status.message().to_string())
.with_code(FailureCode::known(code))
Expand Down Expand Up @@ -640,6 +646,29 @@ mod tests {
assert_eq!(failure.details.0["journal"], json!("journal-123"));
}

#[test]
fn not_found_with_did_you_mean_carries_the_slugs_as_a_list() {
use std::collections::HashMap;

use tonic_types::{ErrorDetails, StatusExt};

let details = ErrorDetails::with_error_info(
"NOT_FOUND",
SERVER_ERROR_DOMAIN,
HashMap::from([(
"did_you_mean".to_string(),
"place-order-slice,place-orders".to_string(),
)]),
);
let status = Status::with_error_details(Code::NotFound, "entity not found", details);
let failure = Failure::from_status(&status);
assert_eq!(failure.category, FailureCategory::NotFound);
assert_eq!(
failure.details.0["did_you_mean"],
json!(["place-order-slice", "place-orders"])
);
}

#[test]
fn status_with_not_writer_reason_classifies_as_unavailable_with_its_metadata() {
use std::collections::HashMap;
Expand Down
15 changes: 15 additions & 0 deletions rsworkspace/crates/trogon-atlas-server/src/conv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,21 @@ pub fn cursor_rejected_err(message: impl Into<String>) -> Status {
with_reason(Code::InvalidArgument, message, reason::CURSOR_REJECTED)
}

/// `NOT_FOUND` carrying near-match slugs under `did_you_mean`, comma-joined
/// because `ErrorInfo` metadata values are strings. With no suggestions it is
/// the same status `store_err` gives, so the two cannot be told apart.
pub fn not_found_with_suggestions(suggestions: &[String]) -> Status {
if suggestions.is_empty() {
return store_err(StoreError::NotFound);
}
with_reason_and_metadata(
Code::NotFound,
"entity not found",
reason::NOT_FOUND,
HashMap::from([("did_you_mean".to_string(), suggestions.join(","))]),
)
}

pub fn store_err(err: StoreError) -> Status {
match err {
StoreError::NotFound => with_reason(Code::NotFound, "entity not found", reason::NOT_FOUND),
Expand Down
1 change: 1 addition & 0 deletions rsworkspace/crates/trogon-atlas-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub mod scope;
pub mod search;
pub mod service;
pub mod spicedb;
pub mod suggest;
pub mod telemetry;
pub mod tls;
pub mod token_reload;
Expand Down
63 changes: 43 additions & 20 deletions rsworkspace/crates/trogon-atlas-server/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1189,6 +1189,29 @@ impl EventModelServiceImpl {
Err(Status::not_found("entity not found"))
}

/// Map a direct read's store error, adding near-match slugs to a
/// `NOT_FOUND`. Call only after `require_visible` has passed: the
/// suggestions come from the lensed snapshot, but a hidden namespace must
/// get the bare `NOT_FOUND` that `require_visible` already gave it.
async fn read_err(
&self,
err: trogon_atlas_store::error::StoreError,
vis: &Visibility,
branch: Option<&str>,
kind: pb::EntityKind,
id: &pb::Id,
) -> Status {
if !matches!(err, trogon_atlas_store::error::StoreError::NotFound) {
return store_err(err);
}
match self.snapshot_for(branch, vis).await {
Ok(all) => crate::conv::not_found_with_suggestions(&crate::suggest::did_you_mean(
kind, id, &all,
)),
Err(_) => store_err(err),
}
}

/// The set of namespaces this request may read, resolved once so the
/// per-record filters that follow stay synchronous.
async fn lens(&self, vis: &Visibility) -> Result<Lens, Status> {
Expand Down Expand Up @@ -2730,11 +2753,10 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl
// Direct-by-key reads never touch the snapshot, so the visibility
// filter that covers every other read path does not reach them.
self.require_visible(&vis, &id.namespace).await?;
let stored = self
.store
.get(kind, id, branch.as_deref())
.await
.map_err(store_err)?;
let stored = match self.store.get(kind, id, branch.as_deref()).await {
Ok(stored) => stored,
Err(err) => return Err(self.read_err(err, &vis, branch.as_deref(), kind, id).await),
};
Ok(Response::new(pb::GetEntityResponse {
entity: Some(stored.entity),
etag: stored.etag,
Expand Down Expand Up @@ -3651,11 +3673,10 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl
// The root is fetched by key, so the lensed snapshot below
// narrows the neighbourhood but never the root itself.
self.require_visible(&vis, &id.namespace).await?;
let stored = self
.store
.get(kind, id, branch.as_deref())
.await
.map_err(store_err)?;
let stored = match self.store.get(kind, id, branch.as_deref()).await {
Ok(stored) => stored,
Err(err) => return Err(self.read_err(err, &vis, branch.as_deref(), kind, id).await),
};
let slice = slice_from_entity(&stored.entity)
.ok_or_else(|| Status::internal("slice entity kind mismatch"))?;

Expand Down Expand Up @@ -3693,11 +3714,11 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl
// The root is fetched by key, so the lensed snapshot below
// narrows the neighbourhood but never the root itself.
self.require_visible(&vis, &id.namespace).await?;
let stored = self
.store
.get(pb::EntityKind::Storyboard, id, branch.as_deref())
.await
.map_err(store_err)?;
let kind = pb::EntityKind::Storyboard;
let stored = match self.store.get(kind, id, branch.as_deref()).await {
Ok(stored) => stored,
Err(err) => return Err(self.read_err(err, &vis, branch.as_deref(), kind, id).await),
};
let Some(pb::entity::Kind::Storyboard(storyboard)) = stored.entity.kind.clone() else {
return Err(Status::internal("storyboard entity kind mismatch"));
};
Expand Down Expand Up @@ -3767,11 +3788,13 @@ impl pb::event_model_service_server::EventModelService for EventModelServiceImpl
// narrows the neighbourhood but never the root itself.
self.require_visible(&vis, &id.namespace).await?;
let (em, all, as_of_incomplete) = if req.as_of_changeset.is_empty() {
let stored = self
.store
.get(pb::EntityKind::EventModel, id, branch.as_deref())
.await
.map_err(store_err)?;
let kind = pb::EntityKind::EventModel;
let stored = match self.store.get(kind, id, branch.as_deref()).await {
Ok(stored) => stored,
Err(err) => {
return Err(self.read_err(err, &vis, branch.as_deref(), kind, id).await)
}
};
let Some(pb::entity::Kind::EventModel(em)) = stored.entity.kind.clone() else {
return Err(Status::internal("event_model entity kind mismatch"));
};
Expand Down
167 changes: 167 additions & 0 deletions rsworkspace/crates/trogon-atlas-server/src/suggest.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
//! Near-match slugs for a direct read whose key does not resolve.

use trogon_atlas_proto as pb;
use trogon_atlas_store::StoredEntity;

use crate::conv::{entity_id, entity_kind};

/// Enough to catch a typo or a missing suffix without burying the answer.
pub const MAX_SUGGESTIONS: usize = 3;

/// Slugs of the same kind in the same namespace as `missing`, closest first.
///
/// `entities` must already be the caller's lensed snapshot: anything in it
/// can be named back to the caller, so it must hold nothing they cannot see.
pub fn did_you_mean(
kind: pb::EntityKind,
missing: &pb::Id,
entities: &[StoredEntity],
) -> Vec<String> {
let mut ranked: Vec<(u8, usize, &str)> = entities
.iter()
.filter(|se| entity_kind(&se.entity).ok() == Some(kind))
.filter_map(|se| entity_id(&se.entity).ok())
.filter(|id| id.namespace == missing.namespace)
.filter_map(|id| {
let (tier, distance) = rank(&missing.slug, &id.slug)?;
Some((tier, distance, id.slug.as_str()))
})
.collect();
ranked.sort_unstable();
ranked.dedup_by(|a, b| a.2 == b.2);
ranked
.into_iter()
.take(MAX_SUGGESTIONS)
.map(|(_, _, slug)| slug.to_string())
.collect()
}

/// A prefix or substring match beats a raw edit distance, because a slug
/// that is missing its suffix is the common mistake and can be far apart in
/// edits (`place-order` vs `place-order-slice`).
fn rank(missing: &str, candidate: &str) -> Option<(u8, usize)> {
if candidate == missing {
return None;
}
let distance = edit_distance(missing, candidate);
let tier = if candidate.starts_with(missing) || missing.starts_with(candidate) {
0
} else if candidate.contains(missing) || missing.contains(candidate) {
1
} else if distance <= (missing.len() / 3).max(1) {
2
} else {
return None;
};
Some((tier, distance))
}

/// Levenshtein distance over bytes; slugs are ASCII by validation.
fn edit_distance(a: &str, b: &str) -> usize {
let (a, b) = (a.as_bytes(), b.as_bytes());
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
cur[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let substitute = prev[j] + usize::from(ca != cb);
cur[j + 1] = substitute.min(prev[j + 1] + 1).min(cur[j] + 1);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}

#[cfg(test)]
mod tests {
use super::*;

fn id(namespace: &str, slug: &str) -> pb::Id {
pb::Id {
namespace: namespace.into(),
slug: slug.into(),
version: 1,
}
}

fn stored(kind: pb::entity::Kind) -> StoredEntity {
StoredEntity {
entity: pb::Entity {
system: None,
kind: Some(kind),
},
etag: String::new(),
}
}

fn command_slice(namespace: &str, slug: &str) -> StoredEntity {
stored(pb::entity::Kind::CommandSlice(pb::CommandSlice {
id: Some(id(namespace, slug)),
..Default::default()
}))
}

fn event(namespace: &str, slug: &str) -> StoredEntity {
stored(pb::entity::Kind::Event(pb::Event {
id: Some(id(namespace, slug)),
..Default::default()
}))
}

#[test]
fn a_missing_suffix_ranks_above_a_closer_typo() {
let entities = [
command_slice("acme-storefront", "plac-order"),
command_slice("acme-storefront", "place-order-slice"),
];
let got = did_you_mean(
pb::EntityKind::CommandSlice,
&id("acme-storefront", "place-order"),
&entities,
);
assert_eq!(got, ["place-order-slice", "plac-order"]);
}

#[test]
fn only_the_same_kind_and_namespace_are_suggested() {
let entities = [
event("acme-storefront", "place-order-slice"),
command_slice("acme-billing", "place-order-slice"),
command_slice("acme-storefront", "place-order-v2"),
];
let got = did_you_mean(
pb::EntityKind::CommandSlice,
&id("acme-storefront", "place-order"),
&entities,
);
assert_eq!(got, ["place-order-v2"]);
}

#[test]
fn at_most_three_distinct_slugs_are_suggested() {
let entities: Vec<_> = ["order-a", "order-b", "order-c", "order-d", "order-a"]
.into_iter()
.map(|slug| command_slice("acme-storefront", slug))
.collect();
let got = did_you_mean(
pb::EntityKind::CommandSlice,
&id("acme-storefront", "order"),
&entities,
);
assert_eq!(got, ["order-a", "order-b", "order-c"]);
}

#[test]
fn a_typo_is_suggested_but_an_unrelated_slug_is_not() {
let entities = [
command_slice("acme-storefront", "place_order"),
command_slice("acme-storefront", "refund-payment"),
];
let got = did_you_mean(
pb::EntityKind::CommandSlice,
&id("acme-storefront", "place-order"),
&entities,
);
assert_eq!(got, ["place_order"]);
}
}
Loading
Loading