feat: add stable function error codes

This commit is contained in:
Xuanwo
2026-08-11 22:43:39 +08:00
parent 82b82711ba
commit 1798ece362
5 changed files with 545 additions and 1 deletions
+104
View File
@@ -6,10 +6,91 @@ use std::sync::{Arc, PoisonError};
use arrow_schema::ArrowError;
use datafusion_common::DataFusionError;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use snafu::Snafu;
pub(crate) type BoxError = Box<dyn std::error::Error + Send + Sync>;
/// Stable Function error category (FF-006).
///
/// The known variants serialize to fixed JSON strings. Any other wire string
/// decodes as [`Self::Unrecognized`] with the exact value preserved, and
/// re-serializes unchanged. Category judgment is structural: do not infer a
/// code from diagnostic message text, HTTP status, job phase, or retryability.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum FunctionErrorCode {
/// Function definition failed validation.
DefinitionValidationFailure,
/// A named Function or Function reference was not found.
NameOrFunctionNotFound,
/// A Function name conflicts with an existing name.
NameConflict,
/// The requested runtime or capability is not supported.
UnsupportedRuntimeOrCapability,
/// The Function has been revoked and cannot be used.
RevokedFunction,
/// User-defined Function execution failed.
UdfExecutionFailure,
/// A generated column was not fully materialized.
GeneratedColumnIncomplete,
/// Input was stale or conflicted with the current state.
StaleOrConflictingInput,
/// A wire string this client version does not recognize.
///
/// The inner value is preserved exactly for forward compatibility.
Unrecognized(String),
}
impl FunctionErrorCode {
/// The stable JSON / wire string for this code.
pub fn as_str(&self) -> &str {
match self {
Self::DefinitionValidationFailure => "definition_validation_failure",
Self::NameOrFunctionNotFound => "name_or_function_not_found",
Self::NameConflict => "name_conflict",
Self::UnsupportedRuntimeOrCapability => "unsupported_runtime_or_capability",
Self::RevokedFunction => "revoked_function",
Self::UdfExecutionFailure => "udf_execution_failure",
Self::GeneratedColumnIncomplete => "generated_column_incomplete",
Self::StaleOrConflictingInput => "stale_or_conflicting_input",
Self::Unrecognized(raw) => raw.as_str(),
}
}
fn from_wire(value: &str) -> Self {
match value {
"definition_validation_failure" => Self::DefinitionValidationFailure,
"name_or_function_not_found" => Self::NameOrFunctionNotFound,
"name_conflict" => Self::NameConflict,
"unsupported_runtime_or_capability" => Self::UnsupportedRuntimeOrCapability,
"revoked_function" => Self::RevokedFunction,
"udf_execution_failure" => Self::UdfExecutionFailure,
"generated_column_incomplete" => Self::GeneratedColumnIncomplete,
"stale_or_conflicting_input" => Self::StaleOrConflictingInput,
other => Self::Unrecognized(other.to_string()),
}
}
}
impl Display for FunctionErrorCode {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl Serialize for FunctionErrorCode {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for FunctionErrorCode {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
Ok(Self::from_wire(String::deserialize(deserializer)?.as_str()))
}
}
/// Why a job failed, to whatever precision the backend provides.
///
/// A job run in this process carries the error it failed with in [`Self::source`].
@@ -18,6 +99,12 @@ pub(crate) type BoxError = Box<dyn std::error::Error + Send + Sync>;
/// backend does not supply it.
#[derive(Debug, Clone, Default)]
pub struct JobFailure {
/// Stable Function error category, when the backend supplied one.
///
/// Present only when copied from [`Error::Function`] or decoded from a
/// remote `error_code` field. Never inferred from message, phase,
/// retryable, HTTP status, or other diagnostics.
pub error_code: Option<FunctionErrorCode>,
/// The stage the job was in, when known.
pub phase: Option<String>,
/// A human-readable reason, when known.
@@ -30,8 +117,16 @@ pub struct JobFailure {
impl JobFailure {
/// A failure whose only known detail is the error that caused it.
///
/// When `source` is [`Error::Function`], [`Self::error_code`] is copied
/// from that error. Other error kinds leave `error_code` as [`None`].
pub(crate) fn from_source(source: Arc<Error>) -> Self {
let error_code = match source.as_ref() {
Error::Function { code, .. } => Some(code.clone()),
_ => None,
};
Self {
error_code,
message: Some(source.to_string()),
source: Some(source),
..Default::default()
@@ -92,6 +187,15 @@ pub enum Error {
},
#[snafu(display("Job{} was cancelled", job_id.as_ref().map(|id| format!(" {id}")).unwrap_or_default()))]
JobCancelled { job_id: Option<String> },
/// A first-class Function operation failed with a stable category.
///
/// [`Self::Function::code`] is the semantic category. [`Self::Function::message`]
/// is diagnostic only and must not be used to recover or override the code.
#[snafu(display("Function error ({code}): {message}"))]
Function {
code: FunctionErrorCode,
message: String,
},
// 3rd party / external errors
#[snafu(display("object_store error: {source}"))]
+61
View File
@@ -180,3 +180,64 @@ impl JobHandle for SpawnedJob {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::FunctionErrorCode;
#[tokio::test]
async fn spawned_job_function_failure_returns_job_failed_with_same_code() {
let job = Job::spawned(tokio::spawn(async {
Err(Error::Function {
code: FunctionErrorCode::UdfExecutionFailure,
// Message names a different category on purpose; code is structural.
message: "looks like name_conflict to a string parser".to_string(),
})
}));
let err = job
.wait()
.await
.expect_err("Function failure must fail the job");
match err {
Error::JobFailed { failure, .. } => match &failure.error_code {
Some(code) => {
assert_eq!(code, &FunctionErrorCode::UdfExecutionFailure);
assert_ne!(code, &FunctionErrorCode::NameConflict);
}
None => panic!("local Function failure must project error_code onto JobFailure"),
},
other => panic!("expected Error::JobFailed, got {other:?}"),
}
}
#[tokio::test]
async fn spawned_job_preserves_unrecognized_function_error_code() {
let raw = "enterprise_future_category_xyz";
let job = Job::spawned(tokio::spawn({
let raw = raw.to_string();
async move {
Err(Error::Function {
code: FunctionErrorCode::Unrecognized(raw),
message: "future server category".to_string(),
})
}
}));
let err = job
.wait()
.await
.expect_err("Function failure must fail the job");
match err {
Error::JobFailed { failure, .. } => match &failure.error_code {
Some(FunctionErrorCode::Unrecognized(preserved)) => {
assert_eq!(preserved, raw);
}
Some(other) => panic!("unrecognized code must not become known: {other:?}"),
None => panic!("unrecognized Function code must be preserved on JobFailure"),
},
other => panic!("expected Error::JobFailed, got {other:?}"),
}
}
}
+105
View File
@@ -457,6 +457,9 @@ struct RemoteListJobsResponse {
/// which report only the terminal state.
#[derive(serde::Deserialize)]
struct RemoteReportedFailure {
/// Stable Function error category when the server supplied one.
#[serde(default)]
error_code: Option<crate::error::FunctionErrorCode>,
#[serde(default)]
phase: Option<String>,
#[serde(default)]
@@ -575,6 +578,7 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
creation_ms: body.creation_ms,
spec: body.spec,
failure: body.failure.map(|reported| crate::error::JobFailure {
error_code: reported.error_code,
phase: reported.phase,
message: reported.message,
retryable: reported.retryable,
@@ -2424,4 +2428,105 @@ mod tests {
assert_eq!(job.status().await.unwrap(), "finished");
assert!(polls.load(Ordering::SeqCst) >= 3);
}
#[tokio::test]
async fn test_get_job_decodes_known_failure_error_code() {
use crate::error::FunctionErrorCode;
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.url().path(), "/v1/jobs/describe");
http::Response::builder()
.status(200)
.body(
r#"{"job_id":"job-1","job_type":"create_index","job_state":"FAILED","creation_ms":1000,"spec":{},"failure":{"error_code":"name_or_function_not_found","phase":"validate","message":"looks like definition_validation_failure","retryable":false}}"#,
)
.unwrap()
});
let job = conn.get_job("job-1").await.unwrap().unwrap();
let failure = job.failure.expect("failure payload present");
match &failure.error_code {
Some(code) => {
assert_eq!(code, &FunctionErrorCode::NameOrFunctionNotFound);
assert_ne!(code, &FunctionErrorCode::DefinitionValidationFailure);
}
None => panic!("known error_code must be decoded by get_job"),
}
assert_eq!(failure.phase.as_deref(), Some("validate"));
assert_eq!(failure.retryable, Some(false));
}
#[tokio::test]
async fn test_get_job_preserves_unrecognized_failure_error_code() {
use crate::error::FunctionErrorCode;
let conn = Connection::new_with_handler(|_| {
http::Response::builder()
.status(200)
.body(
r#"{"job_id":"job-1","job_type":"create_index","job_state":"FAILED","creation_ms":1000,"spec":{},"failure":{"error_code":"enterprise_future_category_xyz","phase":"execute","message":"new category","retryable":true}}"#,
)
.unwrap()
});
let job = conn.get_job("job-1").await.unwrap().unwrap();
let failure = job.failure.expect("failure payload present");
match &failure.error_code {
Some(FunctionErrorCode::Unrecognized(raw)) => {
assert_eq!(raw, "enterprise_future_category_xyz");
}
Some(other) => panic!("unknown error_code must stay Unrecognized, got {other:?}"),
None => panic!("unknown error_code must not be dropped by get_job"),
}
}
#[tokio::test]
async fn test_get_job_allows_older_failure_payload_without_error_code() {
let conn = Connection::new_with_handler(|_| {
http::Response::builder()
.status(200)
.body(
r#"{"job_id":"job-1","job_type":"create_index","job_state":"FAILED","creation_ms":1000,"spec":{},"failure":{"phase":"execute","message":"stale_or_conflicting_input in logs","retryable":true}}"#,
)
.unwrap()
});
let job = conn.get_job("job-1").await.unwrap().unwrap();
let failure = job.failure.expect("failure payload present");
assert!(
failure.error_code.is_none(),
"older get_job payloads without error_code must not invent a category: {failure:?}"
);
assert_eq!(failure.phase.as_deref(), Some("execute"));
assert_eq!(failure.retryable, Some(true));
}
#[tokio::test]
async fn test_conn_job_wait_decodes_failure_error_code_without_transport_override() {
use crate::error::FunctionErrorCode;
let conn = Connection::new_with_handler(|request| {
assert_eq!(request.url().path(), "/v1/jobs/describe");
// Transport is HTTP 200 with FAILED state; category comes only from error_code.
http::Response::builder()
.status(200)
.body(
r#"{"job_id":"job-1","job_type":"create_index","job_state":"FAILED","creation_ms":1,"failure":{"error_code":"unsupported_runtime_or_capability","phase":"dispatch","message":"revoked_function in transport logs","retryable":false}}"#,
)
.unwrap()
});
let err = conn
.job("job-1")
.unwrap()
.wait()
.await
.expect_err("FAILED must surface JobFailed");
match err {
Error::JobFailed { failure, .. } => match &failure.error_code {
Some(code) => {
assert_eq!(code, &FunctionErrorCode::UnsupportedRuntimeOrCapability);
assert_ne!(code, &FunctionErrorCode::RevokedFunction);
}
None => panic!("Database job wait must decode failure.error_code"),
},
other => panic!("expected Error::JobFailed, got {other:?}"),
}
}
}
+89 -1
View File
@@ -10,7 +10,7 @@ use tokio::time::sleep;
use serde::{Deserialize, Deserializer};
use crate::error::{Error, JobFailure, Result};
use crate::error::{Error, FunctionErrorCode, JobFailure, Result};
use crate::job::JobHandle;
use crate::remote::client::{HttpSend, RequestResultExt, RestfulLanceDbClient};
@@ -67,6 +67,9 @@ impl From<&str> for JobState {
/// report only the terminal state.
#[derive(Deserialize)]
struct ReportedFailure {
/// Stable Function error category when the server supplied one.
#[serde(default)]
error_code: Option<FunctionErrorCode>,
#[serde(default)]
phase: Option<String>,
#[serde(default)]
@@ -133,6 +136,7 @@ impl<S: HttpSend> JobHandle for RemoteJob<S> {
failure: description
.failure
.map(|reported| JobFailure {
error_code: reported.error_code,
phase: reported.phase,
message: reported.message,
retryable: reported.retryable,
@@ -168,3 +172,87 @@ impl<S: HttpSend> JobHandle for RemoteJob<S> {
.map(|_| ())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::FunctionErrorCode;
use crate::remote::client::test_utils::client_with_handler;
#[tokio::test]
async fn wait_decodes_known_failure_error_code() {
let client = client_with_handler(|_| {
http::Response::builder()
.status(200)
.body(
r#"{"job_id":"job-err","job_state":"FAILED","failure":{"error_code":"stale_or_conflicting_input","phase":"commit","message":"looks like udf_execution_failure","retryable":true}}"#,
)
.unwrap()
});
let job = RemoteJob::new(client, "job-err".into());
let err = job.wait().await.expect_err("FAILED must surface JobFailed");
match err {
Error::JobFailed { failure, .. } => {
match &failure.error_code {
Some(code) => {
assert_eq!(code, &FunctionErrorCode::StaleOrConflictingInput);
assert_ne!(code, &FunctionErrorCode::UdfExecutionFailure);
}
None => panic!("known error_code must be decoded"),
}
assert_eq!(failure.phase.as_deref(), Some("commit"));
assert_eq!(failure.retryable, Some(true));
}
other => panic!("expected Error::JobFailed, got {other:?}"),
}
}
#[tokio::test]
async fn wait_preserves_unrecognized_failure_error_code() {
let client = client_with_handler(|_| {
http::Response::builder()
.status(200)
.body(
r#"{"job_id":"job-err","job_state":"FAILED","failure":{"error_code":"enterprise_future_category_xyz","phase":"execute","message":"new category","retryable":false}}"#,
)
.unwrap()
});
let job = RemoteJob::new(client, "job-err".into());
let err = job.wait().await.expect_err("FAILED must surface JobFailed");
match err {
Error::JobFailed { failure, .. } => match &failure.error_code {
Some(FunctionErrorCode::Unrecognized(raw)) => {
assert_eq!(raw, "enterprise_future_category_xyz");
}
Some(other) => panic!("unknown error_code must stay Unrecognized, got {other:?}"),
None => panic!("unknown error_code must not be dropped"),
},
other => panic!("expected Error::JobFailed, got {other:?}"),
}
}
#[tokio::test]
async fn wait_allows_older_failure_payload_without_error_code() {
let client = client_with_handler(|_| {
http::Response::builder()
.status(200)
.body(
r#"{"job_id":"job-err","job_state":"FAILED","failure":{"phase":"execute","message":"generated_column_incomplete in logs","retryable":true}}"#,
)
.unwrap()
});
let job = RemoteJob::new(client, "job-err".into());
let err = job.wait().await.expect_err("FAILED must surface JobFailed");
match err {
Error::JobFailed { failure, .. } => {
assert!(
failure.error_code.is_none(),
"older payloads without error_code must not invent a category from message/phase/retryable: {failure:?}"
);
assert_eq!(failure.phase.as_deref(), Some("execute"));
assert_eq!(failure.retryable, Some(true));
}
other => panic!("expected Error::JobFailed, got {other:?}"),
}
}
}
@@ -0,0 +1,186 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
//! Public contract tests for first-class Function error codes (FF-006).
//!
//! These tests pin the stable `FunctionErrorCode` wire strings, direct
//! `Error::Function` projection, and optional `JobFailure.error_code`.
//! They intentionally fail to compile until that public API exists.
//!
//! Categories are judged only by structural enum matching / equality, never
//! by parsing diagnostic message text.
use lancedb::error::FunctionErrorCode;
use lancedb::{Error, JobFailure};
use serde_json::{Value, json};
/// Exact stable wire strings for the eight known Function error categories.
const KNOWN_WIRE_CODES: &[(&str, FunctionErrorCode)] = &[
(
"definition_validation_failure",
FunctionErrorCode::DefinitionValidationFailure,
),
(
"name_or_function_not_found",
FunctionErrorCode::NameOrFunctionNotFound,
),
("name_conflict", FunctionErrorCode::NameConflict),
(
"unsupported_runtime_or_capability",
FunctionErrorCode::UnsupportedRuntimeOrCapability,
),
("revoked_function", FunctionErrorCode::RevokedFunction),
(
"udf_execution_failure",
FunctionErrorCode::UdfExecutionFailure,
),
(
"generated_column_incomplete",
FunctionErrorCode::GeneratedColumnIncomplete,
),
(
"stale_or_conflicting_input",
FunctionErrorCode::StaleOrConflictingInput,
),
];
fn assert_known_variant(code: &FunctionErrorCode, expected: &FunctionErrorCode) {
assert_eq!(
code, expected,
"FunctionErrorCode must match structurally; got {code:?}, expected {expected:?}"
);
assert!(
!matches!(code, FunctionErrorCode::Unrecognized(_)),
"known wire string must not deserialize as Unrecognized: {code:?}"
);
}
#[test]
fn function_error_code_known_variants_use_exact_stable_json_strings() {
for (wire, expected) in KNOWN_WIRE_CODES {
let encoded = serde_json::to_value(expected).expect("serialize FunctionErrorCode");
assert_eq!(
encoded,
Value::String((*wire).to_string()),
"stable JSON string for {expected:?}"
);
let decoded: FunctionErrorCode = serde_json::from_value(Value::String((*wire).to_string()))
.unwrap_or_else(|e| panic!("deserialize `{wire}`: {e}"));
assert_known_variant(&decoded, expected);
let round_trip = serde_json::to_value(&decoded).expect("re-serialize");
assert_eq!(round_trip, Value::String((*wire).to_string()));
}
}
#[test]
fn unrecognized_error_code_preserves_exact_string_and_does_not_become_known() {
let raw = "enterprise_future_category_xyz";
let decoded: FunctionErrorCode = serde_json::from_value(json!(raw))
.unwrap_or_else(|e| panic!("unknown code must deserialize, not fail: {e}"));
match &decoded {
FunctionErrorCode::Unrecognized(preserved) => {
assert_eq!(preserved, raw, "unknown code must be preserved verbatim");
}
other => panic!("expected FunctionErrorCode::Unrecognized, got {other:?}"),
}
for (_, known) in KNOWN_WIRE_CODES {
assert_ne!(
&decoded, known,
"unrecognized code must not equal known variant {known:?}"
);
}
let encoded = serde_json::to_value(&decoded).expect("serialize Unrecognized");
assert_eq!(encoded, json!(raw));
let again: FunctionErrorCode =
serde_json::from_value(encoded).expect("Unrecognized must round-trip");
match again {
FunctionErrorCode::Unrecognized(preserved) => assert_eq!(preserved, raw),
other => panic!("round-trip must stay Unrecognized, got {other:?}"),
}
}
#[test]
fn error_function_carries_code_plus_diagnostic_message() {
let err = Error::Function {
code: FunctionErrorCode::NameConflict,
message: "sanitized diagnostic only".to_string(),
};
match err {
Error::Function { code, message } => {
assert_known_variant(&code, &FunctionErrorCode::NameConflict);
assert_eq!(message, "sanitized diagnostic only");
}
other => panic!("expected Error::Function, got {other:?}"),
}
}
#[test]
fn error_function_category_is_the_code_field_not_the_message() {
// Message text deliberately names a different category; structural code wins.
let err = Error::Function {
code: FunctionErrorCode::GeneratedColumnIncomplete,
message: "looks like udf_execution_failure to a string parser".to_string(),
};
match err {
Error::Function { code, .. } => {
assert_known_variant(&code, &FunctionErrorCode::GeneratedColumnIncomplete);
assert_ne!(code, FunctionErrorCode::UdfExecutionFailure);
}
other => panic!("expected Error::Function, got {other:?}"),
}
}
#[test]
fn job_failure_has_optional_error_code() {
let with_code = JobFailure {
error_code: Some(FunctionErrorCode::RevokedFunction),
phase: Some("execute".to_string()),
message: Some("revoked".to_string()),
retryable: Some(false),
source: None,
};
match &with_code.error_code {
Some(code) => assert_known_variant(code, &FunctionErrorCode::RevokedFunction),
None => panic!("error_code must be present when set"),
}
let without_code = JobFailure {
phase: Some("execute".to_string()),
message: Some("older backend failure without a category".to_string()),
retryable: Some(true),
..Default::default()
};
assert!(
without_code.error_code.is_none(),
"missing error_code must stay None; diagnostics must not invent a category"
);
}
#[test]
fn job_failure_diagnostics_do_not_overwrite_error_code() {
let failure = JobFailure {
error_code: Some(FunctionErrorCode::StaleOrConflictingInput),
phase: Some("commit".to_string()),
message: Some("definition_validation_failure in worker logs".to_string()),
retryable: Some(true),
source: None,
};
match &failure.error_code {
Some(code) => {
assert_known_variant(code, &FunctionErrorCode::StaleOrConflictingInput);
assert_ne!(code, &FunctionErrorCode::DefinitionValidationFailure);
}
None => panic!("explicit error_code must remain set"),
}
assert_eq!(failure.phase.as_deref(), Some("commit"));
assert_eq!(failure.retryable, Some(true));
}