Skip to content
Closed
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
6 changes: 6 additions & 0 deletions nodedb-cluster/src/rpc_codec/data_plane_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,10 @@ pub enum DataPlaneErrorCode {
status_column: String,
row_identity: String,
},
/// A request the client can fix (malformed value, unreadable literal
/// element). Crosses verbatim so the coordinator answers `42601` instead of
/// the generic `XX000` an `Internal` renders.
BadRequest {
detail: String,
},
}
7 changes: 7 additions & 0 deletions nodedb/src/bridge/envelope/error_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ pub enum ErrorCode {
/// this op-type. Distinguished from `Internal` so pgwire surfaces it as
/// `0A000` (feature_not_supported) rather than `XX000`.
Unsupported { detail: String },
/// A request the client can fix: a malformed value, an unreadable literal
/// element, a clause that names nothing. Distinct from `Internal` so it
/// survives the Data Plane → pgwire boundary and reaches the client as
/// `42601` (syntax_error) — the SQLSTATE the direct path already assigns
/// `crate::Error::BadRequest` — rather than the generic `XX000`.
BadRequest { detail: String },
/// Transaction rollback failed: at least one undo entry could not be
/// applied. The shard state is unknown — the client must treat this as a
/// fatal error and the operator must restart the shard (WAL replay restores
Expand Down Expand Up @@ -222,6 +228,7 @@ impl From<crate::Error> for ErrorCode {
Self::TxnOverlayMemoryExceeded { limit }
}
crate::Error::DivisionByZero => Self::DivisionByZero,
crate::Error::BadRequest { detail } => Self::BadRequest { detail },
crate::Error::UndefinedColumn { column } => Self::UndefinedColumn { column },
// Same condition an undefined column reports at plan time, raised
// here by the strict encoder for a transport the planner never
Expand Down
13 changes: 13 additions & 0 deletions nodedb/src/control/cluster/data_plane_error_wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ impl From<ErrorCode> for DataPlaneErrorCode {
ErrorCode::UndefinedColumn { column } => Self::UndefinedColumn { column },
ErrorCode::Internal { detail } => Self::Internal { detail },
ErrorCode::Unsupported { detail } => Self::Unsupported { detail },
ErrorCode::BadRequest { detail } => Self::BadRequest { detail },
ErrorCode::RollbackFailed {
entry_index,
detail,
Expand Down Expand Up @@ -235,6 +236,7 @@ impl From<DataPlaneErrorCode> for ErrorCode {
DataPlaneErrorCode::UndefinedColumn { column } => Self::UndefinedColumn { column },
DataPlaneErrorCode::Internal { detail } => Self::Internal { detail },
DataPlaneErrorCode::Unsupported { detail } => Self::Unsupported { detail },
DataPlaneErrorCode::BadRequest { detail } => Self::BadRequest { detail },
DataPlaneErrorCode::RollbackFailed {
entry_index,
detail,
Expand Down Expand Up @@ -273,6 +275,17 @@ mod tests {
assert_eq!(ErrorCode::from(wire), original);
}

/// A client value error keeps its detail across the node hop, so the
/// coordinator renders the same `42601` a single node renders.
#[test]
fn a_bad_request_roundtrips_verbatim() {
let original = ErrorCode::BadRequest {
detail: "column 'embedding': VECTOR element 1: expected a numeric element".into(),
};
let wire = DataPlaneErrorCode::from(original.clone());
assert_eq!(ErrorCode::from(wire), original);
}

#[test]
fn execution_error_keeps_a_data_plane_verdict_typed() {
let typed = execution_error_to_typed(crate::Error::DataPlane(ErrorCode::DivisionByZero));
Expand Down
10 changes: 9 additions & 1 deletion nodedb/src/control/server/dispatch_utils/write_abort.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,14 +166,19 @@ pub(crate) fn write_definitely_not_applied(code: &ErrorCode) -> bool {
// * `DuplicateWrite` — the idempotency gate fired because the write
// ALREADY applied under the original request; nothing to undo, and
// the duplicate record replays to the same state.
// * `BadRequest` — a validation verdict on the path that raised it, but
// the same code also comes from a phase that can run after an earlier
// step of a multi-step plan applied, so refusal-without-apply is not
// established by the code alone.
ErrorCode::DeadlineExceeded
| ErrorCode::RollbackFailed { .. }
| ErrorCode::ResourcesExhausted
| ErrorCode::Internal { .. }
| ErrorCode::CrdtFrontierMismatch { .. }
| ErrorCode::FanOutExceeded
| ErrorCode::RecursionDepthExceeded { .. }
| ErrorCode::DuplicateWrite => false,
| ErrorCode::DuplicateWrite
| ErrorCode::BadRequest { .. } => false,
}
}

Expand Down Expand Up @@ -221,5 +226,8 @@ mod tests {
detail: "io_uring".into(),
}));
assert!(!write_definitely_not_applied(&ErrorCode::DuplicateWrite));
assert!(!write_definitely_not_applied(&ErrorCode::BadRequest {
detail: "column 'embedding': expected a numeric element".into(),
}));
}
}
1 change: 1 addition & 0 deletions nodedb/src/control/server/shared/ddl/sqlstate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ pub fn error_code_to_sqlstate(code: &ErrorCode) -> (&'static str, &'static str,
ErrorCode::Unsupported { detail } => {
("ERROR", sqlstate::FEATURE_NOT_SUPPORTED, detail.clone())
}
ErrorCode::BadRequest { detail } => ("ERROR", sqlstate::SYNTAX_ERROR, detail.clone()),
ErrorCode::RollbackFailed {
entry_index,
detail,
Expand Down
17 changes: 9 additions & 8 deletions nodedb/src/data/executor/handlers/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,14 +155,15 @@ impl CoreLoop {
) {
Ok(bytes) => bytes,
Err(e) => {
return self.response_error(
task,
ErrorCode::Internal {
detail: format!(
"collection '{collection}': row '{identity}' failed to convert to document_strict: {e}"
),
},
);
let code = match e {
crate::Error::BadRequest { detail } => ErrorCode::BadRequest { detail },
other => ErrorCode::Internal {
detail: format!(
"collection '{collection}': row '{identity}' failed to convert to document_strict: {other}"
),
},
};
return self.response_error(task, code);
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ impl CoreLoop {
strict_format::bytes_to_binary_tuple(&value, schema, collection)
}
.map_err(|e| match e {
crate::Error::UnknownStrictField { .. } => e,
crate::Error::UnknownStrictField { .. } | crate::Error::BadRequest { .. } => e,
other => crate::Error::Serialization {
format: "binary_tuple".into(),
detail: other.to_string(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ impl CoreLoop {
crate::Error::UnknownStrictField { column, .. } => {
ErrorCode::UndefinedColumn { column }
}
crate::Error::BadRequest { detail } => ErrorCode::BadRequest { detail },
other => ErrorCode::Internal {
detail: format!("strict re-encode: {other}"),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ impl CoreLoop {
strict_format::bytes_to_binary_tuple(&encoded_input, schema, collection)
}
.map_err(|e| match e {
crate::Error::UnknownStrictField { .. } => e,
crate::Error::UnknownStrictField { .. } | crate::Error::BadRequest { .. } => e,
other => crate::Error::Serialization {
format: "binary_tuple".into(),
detail: other.to_string(),
Expand Down Expand Up @@ -245,7 +245,7 @@ impl CoreLoop {
strict_format::value_to_binary_tuple(&ndb_val, schema, collection)
}
.map_err(|e| match e {
crate::Error::UnknownStrictField { .. } => e,
crate::Error::UnknownStrictField { .. } | crate::Error::BadRequest { .. } => e,
other => crate::Error::Serialization {
format: "binary_tuple".into(),
detail: other.to_string(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ impl CoreLoop {
strict_format::value_to_binary_tuple(&merged, schema, ctx.collection)
};
result.map_err(|e| match e {
crate::Error::UnknownStrictField { .. } => e,
crate::Error::UnknownStrictField { .. } | crate::Error::BadRequest { .. } => e,
other => crate::Error::Serialization {
format: "binary_tuple".into(),
detail: other.to_string(),
Expand Down
14 changes: 9 additions & 5 deletions nodedb/src/data/executor/handlers/update_from_join.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,16 @@ impl CoreLoop {
) {
Ok(r) => r,
Err(e) => {
return self.response_error(
task,
ErrorCode::Internal {
detail: e.to_string(),
// The collect pass encodes strict post-images, so a client
// value error can reach here. Keep it typed: a bad value is
// not an internal fault.
let code = match e {
crate::Error::BadRequest { detail } => ErrorCode::BadRequest { detail },
other => ErrorCode::Internal {
detail: other.to_string(),
},
);
};
return self.response_error(task, code);
}
};

Expand Down
107 changes: 97 additions & 10 deletions nodedb/src/data/executor/strict_format/coerce.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ pub fn coerce_value(val: &Value, col_type: &ColumnType, col_name: &str) -> crate
ColumnType::Vector(dim) => match val {
Value::Bytes(b) if b.len() == *dim as usize * 4 => Ok(val.clone()),
Value::Array(arr) => {
let floats = extract_vector_floats(arr);
let floats = extract_vector_floats(col_name, arr)?;
validate_and_encode_vector(col_name, *dim, &floats)
}
Value::String(s) => {
Expand Down Expand Up @@ -229,15 +229,55 @@ pub fn coerce_value(val: &Value, col_type: &ColumnType, col_name: &str) -> crate
}
}

/// Extract f32 floats from a `Value::Array`.
fn extract_vector_floats(arr: &[Value]) -> Vec<f32> {
arr.iter()
.filter_map(|v| match v {
Value::Float(f) => Some(*f as f32),
Value::Integer(n) => Some(*n as f32),
_ => None,
})
.collect()
/// Extract `f32` floats from a `Value::Array` for a VECTOR column.
///
/// Accepts the element shapes the planner actually produces for an
/// `ARRAY[...]` literal — `Float`, `Integer`, `Decimal` (a folded numeric
/// literal arrives as `Decimal`), and numeric `String` — matching the
/// schemaless point-put path (`vector_string::floats_from_value`), so one
/// literal behaves the same on every engine.
///
/// A non-numeric element is rejected by index and type. Dropping one silently
/// would report the literal as having fewer elements than the user wrote and
/// surface the mismatch as an internal error.
fn extract_vector_floats(col_name: &str, arr: &[Value]) -> crate::Result<Vec<f32>> {
let mut floats = Vec::with_capacity(arr.len());
for (i, v) in arr.iter().enumerate() {
let f = match v {
Value::Float(f) => *f as f32,
Value::Integer(n) => *n as f32,
Value::Decimal(d) => {
use rust_decimal::prelude::ToPrimitive;
d.to_f32().ok_or_else(|| crate::Error::BadRequest {
detail: format!(
"column '{col_name}': VECTOR element {i}: cannot represent DECIMAL {d} as FLOAT"
),
})?
}
Value::String(s) => s.parse::<f32>().map_err(|_| crate::Error::BadRequest {
detail: format!(
"column '{col_name}': VECTOR element {i}: expected a numeric element, got String({s:?})"
),
})?,
other => {
return Err(crate::Error::BadRequest {
detail: format!(
"column '{col_name}': VECTOR element {i}: expected a numeric element, got {}",
other.type_name()
),
});
}
};
if !f.is_finite() {
return Err(crate::Error::BadRequest {
detail: format!(
"column '{col_name}': VECTOR element {i}: expected a finite element, got {f}"
),
});
}
floats.push(f);
}
Ok(floats)
}

/// Validate dimension count and encode as little-endian bytes.
Expand Down Expand Up @@ -415,4 +455,51 @@ mod tests {
let msg = result.unwrap_err().to_string();
assert!(msg.contains("\"extra\"") && msg.contains("does not exist"));
}

#[test]
fn vector_accepts_decimal_and_numeric_string_elements() {
// `ARRAY[0.1, 0.2, 0.3]` folds its numeric literals to `Decimal`
// elements; numeric strings are accepted like the schemaless path.
let arr = vec![
Value::Decimal(rust_decimal::Decimal::new(1, 1)),
Value::String("0.2".into()),
Value::Integer(3),
];
let floats = extract_vector_floats("embedding", &arr).unwrap();
assert_eq!(floats.len(), 3);
assert!((floats[0] - 0.1).abs() < 1e-6, "{floats:?}");
assert!((floats[1] - 0.2).abs() < 1e-6, "{floats:?}");
assert!((floats[2] - 3.0).abs() < 1e-6, "{floats:?}");
}

#[test]
fn vector_element_error_names_index_and_type() {
let arr = vec![Value::Float(0.1), Value::String("x".into())];
let err = extract_vector_floats("embedding", &arr)
.unwrap_err()
.to_string();
assert!(err.contains("VECTOR element 1"), "{err}");
assert!(err.contains("String"), "{err}");
}

#[test]
fn vector_rejects_non_finite_elements() {
let arr = vec![Value::Float(f64::NAN)];
assert!(extract_vector_floats("embedding", &arr).is_err());
}

#[test]
fn vector_encodes_three_decimals_at_full_width() {
let arr = vec![
Value::Decimal(rust_decimal::Decimal::new(1, 1)),
Value::Decimal(rust_decimal::Decimal::new(2, 1)),
Value::Decimal(rust_decimal::Decimal::new(3, 1)),
];
let floats = extract_vector_floats("embedding", &arr).unwrap();
let encoded = validate_and_encode_vector("embedding", 3, &floats).unwrap();
match encoded {
Value::Bytes(b) => assert_eq!(b.len(), 12),
other => panic!("expected Bytes, got {other:?}"),
}
}
}
1 change: 1 addition & 0 deletions nodedb/src/error_from_data_plane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ pub(crate) fn data_plane_code_to_public(code: ErrorCode) -> NodeDbError {
)),
ErrorCode::UndefinedColumn { column } => NodeDbError::undefined_column(column),
ErrorCode::Unsupported { detail } => NodeDbError::bad_request(detail),
ErrorCode::BadRequest { detail } => NodeDbError::bad_request(detail),
ErrorCode::DivisionByZero => NodeDbError::division_by_zero(),
ErrorCode::TxnOverlayMemoryExceeded { limit } => NodeDbError::bad_request(format!(
"transaction staging overlay exceeded its {limit}-byte per-core budget; \
Expand Down
55 changes: 55 additions & 0 deletions nodedb/tests/wire/cases/engine_surface_document_strict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,58 @@ async fn wal_restart_durability() {
assert_eq!(rows.len(), 1);
assert_eq!(rows[0][0], "persisted");
}

/// Create a `document_strict` collection with a `VECTOR(3)` column, the shape
/// the `ARRAY[...]` literal case needs.
async fn create_strict_vector(server: &TestServer, name: &str) {
server
.exec(&format!(
"CREATE COLLECTION {name} (id TEXT PRIMARY KEY, embedding VECTOR(3)) \
WITH (engine = 'document_strict')"
))
.await
.unwrap_or_else(|e| panic!("create {name}: {e}"));
}

/// The planner folds `ARRAY[0.1, 0.2, 0.3]` to `Decimal` elements. Coercion
/// reads Float, Integer, Decimal and numeric String elements, so the literal
/// keeps its element count and reaches the column intact.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn document_strict_inserts_a_decimal_array_literal_into_a_vector_column() {
let server = TestServer::start().await;
create_strict_vector(&server, "vec_array_literal").await;

server
.exec("INSERT INTO vec_array_literal (id, embedding) VALUES ('a1', ARRAY[0.1, 0.2, 0.3])")
.await
.expect("ARRAY[0.1, 0.2, 0.3] must insert into a VECTOR(3) column");

let rows = server
.query_rows("SELECT id FROM vec_array_literal")
.await
.unwrap();
assert_eq!(rows.len(), 1, "the row must be stored: {rows:?}");
}

/// An element the coercion cannot read is a value error: it names the element
/// and its type, and it never surfaces as `XX000`. The house mapping for a
/// bad request is `42601`; the assertion pins the code so a reclassification
/// cannot regress silently.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn document_strict_reports_a_non_numeric_element_by_index_and_type() {
let server = TestServer::start().await;
create_strict_vector(&server, "vec_array_bad").await;

server
.expect_error(
"INSERT INTO vec_array_bad (id, embedding) VALUES ('b1', ARRAY[0.1, 'nope', 0.3])",
"VECTOR element 1",
)
.await;
server
.expect_error(
"INSERT INTO vec_array_bad (id, embedding) VALUES ('b2', ARRAY[0.1, 'nope', 0.3])",
"SQLSTATE 42601",
)
.await;
}
Loading