feat: submit generated column refresh jobs

This commit is contained in:
Xuanwo
2026-08-12 19:25:18 +08:00
parent 208787ae7b
commit a9617bf830
2 changed files with 614 additions and 1 deletions
+579
View File
@@ -15,6 +15,7 @@ use crate::expr::expr_to_sql_string;
use crate::function::schema_admission::reject_caller_authored_generated_column_schema_on_overwrite;
use crate::function::{
ChangeGeneratedColumnJobSpec, CreateGeneratedColumnJobSpec, GeneratedColumnBindingSnapshot,
RefreshGeneratedColumnJobSpec,
};
use crate::index::Index;
use crate::index::IndexStatistics;
@@ -2128,6 +2129,72 @@ impl<S: HttpSend> BaseTable for RemoteTable<S> {
))))
}
async fn submit_refresh_generated_column(
&self,
source_table_version: u64,
spec: RefreshGeneratedColumnJobSpec,
) -> Result<Job> {
// Fixed-version handles fail before any submit request.
self.check_mutable().await?;
// Envelope keys are exactly source_table_version + nested FF-010 spec.
// Branch identity (when present) is additive table identity, not part of
// the operation payload. table_ref is derived server-side.
let mut body = serde_json::json!({
"source_table_version": source_table_version,
"spec": spec,
});
self.apply_branch_body(&mut body);
let request = self
.client
.post(&format!(
"/v1/table/{}/generated_columns/refresh/",
self.identifier
))
.json(&body);
// Typed literals may carry business data: reuse sensitive retry/check.
let (request_id, response) = self
.client
.send_sensitive_with_retry(request, None, true)
.await?;
let response = self
.client
.check_sensitive_response(&request_id, response)
.await?;
// Payload-free protocol failure: never fold response bytes into Error::Http.
let bytes = response.bytes().await.err_to_http(request_id.clone())?;
let value: serde_json::Value = match serde_json::from_slice(&bytes) {
Ok(value) => value,
Err(_) => {
return Err(Error::Http {
source: "refresh generated column response is not valid JSON".into(),
request_id,
status_code: None,
});
}
};
let job_id = match value.get("job_id") {
Some(serde_json::Value::String(job_id)) if !job_id.is_empty() => job_id.clone(),
_ => {
return Err(Error::Http {
source: "refresh generated column response missing or invalid job_id".into(),
request_id,
status_code: None,
});
}
};
// Submit acceptance is not table mutation: do not touch schema cache or
// read/write freshness watermarks.
Ok(Job::new(Box::new(RemoteJob::new(
self.client.clone(),
job_id,
))))
}
async fn create_branch(
&self,
name: &str,
@@ -9950,6 +10017,518 @@ mod tests {
}
}
// -------------------------------------------------------------------------
// RefreshGeneratedColumnJobSpec remote table submit transport
// -------------------------------------------------------------------------
const REFRESH_GEN_COL_LITERAL_MARKER: &str = "SENSITIVE_REFRESH_GEN_COL_LITERAL_MARKER";
const REFRESH_GEN_COL_RESPONSE_MARKER: &str = "SENSITIVE_REFRESH_GEN_COL_RESPONSE_MARKER";
/// Incomplete definition (dependency_epoch != materialized_epoch) with a
/// typed UTF-8 literal marker for request redaction assertions.
fn sample_refresh_generated_column_definition() -> crate::function::GeneratedColumnDefinition {
use crate::function::{
Function, FunctionArgument, FunctionCall, FunctionId, FunctionOutput,
FunctionParameter, FunctionSignature, GeneratedColumnDefinition,
};
use arrow_array::StringArray;
let function = Function::new(
FunctionId::try_new("fn.exact.refresh-submit.remote").expect("valid FunctionId"),
FunctionSignature::try_new(
vec![
FunctionParameter::new("x", DataType::Int32),
FunctionParameter::new("label", DataType::Utf8),
],
FunctionOutput::new(DataType::Int32, true),
)
.expect("valid FunctionSignature"),
);
let call = FunctionCall::try_new(
&function,
vec![
(
"x".to_string(),
FunctionArgument::try_field(1, DataType::Int32).expect("field arg"),
),
(
"label".to_string(),
FunctionArgument::try_literal(Arc::new(StringArray::from(vec![Some(
REFRESH_GEN_COL_LITERAL_MARKER,
)])) as _)
.expect("literal arg"),
),
],
)
.expect("valid FunctionCall");
// Incomplete: dependency_epoch (41) != materialized_epoch (37).
GeneratedColumnDefinition::try_new(17, call, 41, 37).expect("valid incomplete definition")
}
fn sample_refresh_generated_column_job_spec() -> crate::function::RefreshGeneratedColumnJobSpec
{
use crate::function::{
Function, FunctionId, FunctionOutput, FunctionParameter, FunctionSignature,
RefreshGeneratedColumnJobSpec,
};
let function = Function::new(
FunctionId::try_new("fn.exact.refresh-submit.remote").expect("valid FunctionId"),
FunctionSignature::try_new(
vec![
FunctionParameter::new("x", DataType::Int32),
FunctionParameter::new("label", DataType::Utf8),
],
FunctionOutput::new(DataType::Int32, true),
)
.expect("valid FunctionSignature"),
);
RefreshGeneratedColumnJobSpec::try_new(
&function,
sample_refresh_generated_column_definition(),
)
.expect("valid refresh spec")
}
fn refresh_gen_col_error_chain_text(err: &Error) -> String {
let mut text = format!("{err}\n{err:?}");
let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
while let Some(e) = current {
text.push('\n');
text.push_str(&e.to_string());
text.push('\n');
text.push_str(&format!("{e:?}"));
current = e.source();
}
text
}
fn assert_refresh_gen_col_markers_absent(err: &Error) {
let text = refresh_gen_col_error_chain_text(err);
assert!(
!text.contains(REFRESH_GEN_COL_LITERAL_MARKER),
"request literal marker must be absent from error/debug/source chain: {text}"
);
assert!(
!text.contains(REFRESH_GEN_COL_RESPONSE_MARKER),
"response marker must be absent from error/debug/source chain: {text}"
);
}
fn assert_refresh_gen_col_envelope(
body: &serde_json::Value,
expected_source_version: u64,
expected_spec: &crate::function::RefreshGeneratedColumnJobSpec,
expected_branch: Option<&str>,
) {
let object = body
.as_object()
.expect("refresh generated column body must be a JSON object");
let mut expected_keys = vec!["source_table_version", "spec"];
if expected_branch.is_some() {
expected_keys.push("branch");
}
expected_keys.sort_unstable();
let mut actual_keys: Vec<&str> = object.keys().map(|k| k.as_str()).collect();
actual_keys.sort_unstable();
assert_eq!(
actual_keys, expected_keys,
"envelope keys must be exact; got {actual_keys:?}"
);
assert_eq!(
body["source_table_version"],
serde_json::json!(expected_source_version),
"source_table_version must be preserved exactly"
);
assert!(
body.get("table_ref").is_none(),
"table_ref must not be sent; server derives it"
);
match expected_branch {
Some(branch) => assert_eq!(body["branch"], branch),
None => assert!(body.get("branch").is_none()),
}
let nested = body.get("spec").expect("nested spec");
let nested_object = nested.as_object().expect("spec must be object");
let mut nested_keys: Vec<&str> = nested_object.keys().map(|k| k.as_str()).collect();
nested_keys.sort_unstable();
assert_eq!(
nested_keys,
vec!["format_version", "generated_column_definition"],
"nested RefreshGeneratedColumnJobSpec keys must be exact"
);
let expected_spec_json =
serde_json::to_value(expected_spec).expect("serialize RefreshGeneratedColumnJobSpec");
assert_eq!(
nested, &expected_spec_json,
"nested spec must be the exact RefreshGeneratedColumnJobSpec wire"
);
let expected_definition_json =
serde_json::to_value(sample_refresh_generated_column_definition())
.expect("serialize GeneratedColumnDefinition");
assert_eq!(
nested["generated_column_definition"], expected_definition_json,
"nested generated_column_definition must match the incomplete sample definition wire"
);
for forbidden in [
"table_ref",
"source_table_version",
"table",
"version",
"name",
"column_name",
"handle",
"output",
"output_field_id",
"dependency_epoch",
"materialized_epoch",
"artifact",
"job_id",
"idempotency_key",
"retry",
"function_call",
"function_name",
"function",
"status",
] {
assert!(
nested_object.get(forbidden).is_none(),
"nested spec must not contain forbidden key `{forbidden}`"
);
}
}
/// Remote main posts exact refresh route and two-key envelope; returns opaque Job ID.
#[tokio::test]
async fn test_submit_refresh_generated_column_main_exact_route_envelope_and_job_id() {
let spec = sample_refresh_generated_column_job_spec();
let expected_spec = sample_refresh_generated_column_job_spec();
let paths = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let paths_ref = paths.clone();
let table = Table::new_with_handler("my_table", move |request| {
let path = request.url().path().to_string();
paths_ref.lock().unwrap().push(path.clone());
assert_eq!(request.method(), reqwest::Method::POST);
assert_eq!(path, "/v1/table/my_table/generated_columns/refresh/");
let body = request_body_json(&request);
assert_refresh_gen_col_envelope(&body, 42, &expected_spec, None);
http::Response::builder()
.status(200)
.body(r#"{"job_id":"job-refresh-gen-col-1","server_extra":{"ok":true}}"#)
.unwrap()
});
let job = table
.submit_refresh_generated_column(42, spec)
.await
.expect("main submit must succeed");
assert_eq!(job.id(), Some("job-refresh-gen-col-1"));
assert_eq!(
*paths.lock().unwrap(),
vec!["/v1/table/my_table/generated_columns/refresh/".to_string()],
"submit must issue exactly one refresh request and no describe/read"
);
}
/// Branch identity adds exact `branch` without changing source version or nested spec.
#[tokio::test]
async fn test_submit_refresh_generated_column_branch_adds_exact_branch() {
use lance::dataset::refs::Ref;
let spec = sample_refresh_generated_column_job_spec();
let expected_spec = sample_refresh_generated_column_job_spec();
let table =
Table::new_with_handler("my_table", move |request| match request.url().path() {
"/v1/table/my_table/branches/create/" => http::Response::builder()
.status(200)
.body("{}".to_string())
.unwrap(),
"/v1/table/my_table/generated_columns/refresh/" => {
let body = request_body_json(&request);
assert_refresh_gen_col_envelope(&body, 7, &expected_spec, Some("exp"));
http::Response::builder()
.status(200)
.body(r#"{"job_id":"job-refresh-gen-col-branch"}"#.to_string())
.unwrap()
}
path => panic!("unexpected path: {path}"),
});
let branch = table
.create_branch("exp", Ref::Version(None, None))
.await
.expect("create branch");
let job = branch
.submit_refresh_generated_column(7, spec)
.await
.expect("branch submit must succeed");
assert_eq!(job.id(), Some("job-refresh-gen-col-branch"));
}
/// Explicit-version handles fail mutation preflight before any submit request.
#[tokio::test]
async fn test_submit_refresh_generated_column_fixed_version_fails_before_submit() {
let describe_body =
describe_response(&Schema::new(vec![Field::new("a", DataType::Int32, false)]));
let submit_hits = Arc::new(AtomicUsize::new(0));
let submit_hits_ref = submit_hits.clone();
let table =
Table::new_with_handler("my_table", move |request| match request.url().path() {
"/v1/table/my_table/describe/" => http::Response::builder()
.status(200)
.body(describe_body.clone())
.unwrap(),
"/v1/table/my_table/generated_columns/refresh/" => {
submit_hits_ref.fetch_add(1, Ordering::SeqCst);
panic!("fixed-version handle must not reach submit");
}
path => panic!("unexpected path: {path}"),
});
table.checkout(42).await.expect("checkout fixed version");
let err = table
.submit_refresh_generated_column(42, sample_refresh_generated_column_job_spec())
.await
.expect_err("fixed-version submit must fail preflight");
assert!(
matches!(err, Error::NotSupported { .. }),
"expected NotSupported preflight, got {err:?}"
);
assert_eq!(
submit_hits.load(Ordering::SeqCst),
0,
"preflight must happen before any submit request"
);
}
/// Retry reuses request ID and byte-equivalent JSON body.
#[tokio::test]
async fn test_submit_refresh_generated_column_retry_preserves_request_id_and_body() {
use std::sync::OnceLock;
let spec = sample_refresh_generated_column_job_spec();
let seen_request_id = Arc::new(OnceLock::new());
let seen_request_id_ref = seen_request_id.clone();
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_ref = attempts.clone();
let first_body = Arc::new(OnceLock::new());
let first_body_ref = first_body.clone();
let table = Table::new_with_handler_and_config(
"my_table",
move |request| {
assert_eq!(
request.url().path(),
"/v1/table/my_table/generated_columns/refresh/"
);
let body = request
.body()
.and_then(|b| b.as_bytes())
.expect("submit must carry JSON body")
.to_vec();
let actual: serde_json::Value =
serde_json::from_slice(&body).expect("body must be JSON");
assert_refresh_gen_col_envelope(
&actual,
11,
&sample_refresh_generated_column_job_spec(),
None,
);
let seen_body = first_body_ref.get_or_init(|| body.clone());
assert_eq!(
&body, seen_body,
"retry must reuse byte-equivalent JSON body"
);
let request_id = request.headers()["x-request-id"]
.to_str()
.unwrap()
.to_string();
assert!(!request_id.is_empty());
let seen = seen_request_id_ref.get_or_init(|| request_id.clone());
assert_eq!(
&request_id, seen,
"request id must be identical across retries"
);
let n = attempts_ref.fetch_add(1, Ordering::SeqCst);
if n == 0 {
http::Response::builder()
.status(500)
.body("transient refresh gen column failure")
.unwrap()
} else {
http::Response::builder()
.status(200)
.body(r#"{"job_id":"job-refresh-gen-col-retry"}"#)
.unwrap()
}
},
ClientConfig {
retry_config: RetryConfig {
retries: Some(2),
backoff_factor: Some(0.0),
backoff_jitter: Some(0.0),
..Default::default()
},
..Default::default()
},
);
let job = table
.submit_refresh_generated_column(11, spec)
.await
.expect("submit must succeed after one retry");
assert_eq!(job.id(), Some("job-refresh-gen-col-retry"));
assert_eq!(attempts.load(Ordering::SeqCst), 2);
assert!(seen_request_id.get().is_some());
}
/// Malformed/missing/empty/non-string job_id and 4xx/5xx markers stay payload-free.
#[tokio::test]
async fn test_submit_refresh_generated_column_errors_omit_request_and_response_markers() {
let invalid_job_id_cases: Vec<(&str, String)> = vec![
(
"missing",
format!(r#"{{"server_extra":"{REFRESH_GEN_COL_RESPONSE_MARKER}"}}"#),
),
(
"null",
format!(r#"{{"job_id":null,"server_extra":"{REFRESH_GEN_COL_RESPONSE_MARKER}"}}"#),
),
(
"empty",
format!(r#"{{"job_id":"","server_extra":"{REFRESH_GEN_COL_RESPONSE_MARKER}"}}"#),
),
(
"wrong_type",
format!(r#"{{"job_id":123,"server_extra":"{REFRESH_GEN_COL_RESPONSE_MARKER}"}}"#),
),
(
"malformed",
format!("not-json {REFRESH_GEN_COL_RESPONSE_MARKER}"),
),
];
let mut unexpected = Vec::new();
for (label, response_body) in invalid_job_id_cases {
let body_for_handler = response_body.clone();
let expected_request_id = Arc::new(std::sync::Mutex::new(None::<String>));
let expected_request_id_c = expected_request_id.clone();
let table = Table::new_with_handler("my_table", move |request| {
assert_eq!(
request.url().path(),
"/v1/table/my_table/generated_columns/refresh/"
);
let request_id = request
.headers()
.get("x-request-id")
.expect("client must set x-request-id")
.to_str()
.unwrap()
.to_string();
*expected_request_id_c.lock().unwrap() = Some(request_id);
http::Response::builder()
.status(200)
.body(body_for_handler.clone())
.unwrap()
});
match table
.submit_refresh_generated_column(1, sample_refresh_generated_column_job_spec())
.await
{
Err(err) => match &err {
Error::Http {
request_id,
status_code,
..
} => {
let expected = expected_request_id
.lock()
.unwrap()
.clone()
.expect("handler must observe request id");
assert_eq!(
request_id, &expected,
"{label}: Error::Http.request_id must equal outgoing x-request-id"
);
assert!(
!request_id.is_empty(),
"{label}: Error::Http.request_id must be nonempty"
);
assert!(
status_code.is_none(),
"{label}: local protocol validation must not invent a status code"
);
assert_refresh_gen_col_markers_absent(&err);
let text = refresh_gen_col_error_chain_text(&err);
assert!(
!text.contains(&response_body),
"{label}: raw response body must be absent from error/debug/source chain: {text}"
);
}
other => unexpected.push(format!("{label}: {other:?}")),
},
Ok(ok) => unexpected.push(format!("{label}: Ok({ok:?})")),
}
}
assert!(
unexpected.is_empty(),
"invalid job_id shapes must fail closed as Error::Http: {unexpected:?}"
);
let echoed = format!(
"refresh failed with {REFRESH_GEN_COL_LITERAL_MARKER} and {REFRESH_GEN_COL_RESPONSE_MARKER}"
);
{
let body = echoed.clone();
let table = Table::new_with_handler("my_table", move |_| {
http::Response::builder()
.status(400)
.body(body.clone())
.unwrap()
});
let err = table
.submit_refresh_generated_column(1, sample_refresh_generated_column_job_spec())
.await
.expect_err("4xx submit must fail");
assert!(matches!(err, Error::Http { .. }), "got {err:?}");
assert_refresh_gen_col_markers_absent(&err);
}
{
let body = echoed.clone();
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_ref = attempts.clone();
let table = Table::new_with_handler_and_config(
"my_table",
move |_| {
attempts_ref.fetch_add(1, Ordering::SeqCst);
http::Response::builder()
.status(500)
.body(body.clone())
.unwrap()
},
ClientConfig {
retry_config: RetryConfig {
retries: Some(2),
backoff_factor: Some(0.0),
backoff_jitter: Some(0.0),
..Default::default()
},
..Default::default()
},
);
let err = table
.submit_refresh_generated_column(1, sample_refresh_generated_column_job_spec())
.await
.expect_err("exhausted 5xx submit must fail");
assert!(matches!(err, Error::Retry { .. }), "got {err:?}");
assert_eq!(attempts.load(Ordering::SeqCst), 2);
assert_refresh_gen_col_markers_absent(&err);
}
}
/// Test that schema cache expires after 30 seconds TTL
#[tokio::test]
async fn test_schema_cache_invalidation_after_ttl() {
+35 -1
View File
@@ -57,7 +57,7 @@ use crate::error::{Error, Result};
use crate::function::schema_admission::reject_caller_authored_generated_column_schema_on_overwrite;
use crate::function::{
ChangeGeneratedColumnJobSpec, CreateGeneratedColumnJobSpec, GeneratedColumnBindingSnapshot,
GeneratedColumnStatus,
GeneratedColumnStatus, RefreshGeneratedColumnJobSpec,
};
use crate::index::IndexStatistics;
use crate::index::{Index, IndexBuilder};
@@ -673,6 +673,23 @@ pub trait BaseTable: std::fmt::Display + std::fmt::Debug + Send + Sync {
message: "submit_change_generated_column is not supported on this table type".into(),
})
}
/// Submit a refresh-generated-column Job from an already-bound snapshot pair.
///
/// Consumes `(source_table_version, RefreshGeneratedColumnJobSpec)` produced
/// by one binding snapshot. Default returns [`Error::NotSupported`] so
/// existing third-party [`BaseTable`] implementations keep compiling.
/// Implementations must not re-read schema/version, rebind the call,
/// catalog-lookup, execute, stage, or mutate locally.
#[doc(hidden)]
async fn submit_refresh_generated_column(
&self,
_source_table_version: u64,
_spec: RefreshGeneratedColumnJobSpec,
) -> Result<Job> {
Err(Error::NotSupported {
message: "submit_refresh_generated_column is not supported on this table type".into(),
})
}
/// Count the number of rows in this table.
async fn count_rows(&self, filter: Option<Filter>) -> Result<usize>;
/// Create a physical plan for the query.
@@ -1272,6 +1289,23 @@ impl Table {
.await
}
/// Submit a refresh-generated-column Job from an already-bound snapshot pair.
///
/// Hidden table submit seam for enterprise generated-column refresh. Input
/// must already be the `(source_table_version, RefreshGeneratedColumnJobSpec)`
/// pairing from one binding snapshot. Returns the unified [`Job`]; does not
/// expose a public request/envelope type.
#[doc(hidden)]
pub async fn submit_refresh_generated_column(
&self,
source_table_version: u64,
spec: RefreshGeneratedColumnJobSpec,
) -> Result<Job> {
self.inner
.submit_refresh_generated_column(source_table_version, spec)
.await
}
/// Count the number of rows in this dataset.
///
/// # Arguments