Skip to main content

sie_sdk/client/
jobs.rs

1//! `/v1/jobs`: batch jobs over inline items or over a connector's data store.
2//!
3//! A job is either *inline* (items travel in the request, results come back) or
4//! *connector-driven* (a `scheme://connection/...` URI names a store the server reads from
5//! and writes to). The two shapes accept disjoint fields, and mixing them is rejected
6//! before anything is sent.
7
8use std::path::Path;
9use std::time::Duration;
10
11use reqwest::Method;
12use rmpv::Value as MsgValue;
13use serde_json::{Map, Value};
14
15use crate::client::files::TRANSFER_TIMEOUT_FLOOR;
16use crate::client::{Client, meta::parse_json};
17use crate::error::{Error, Result};
18use crate::http::{PreparedRequest, headers};
19use crate::retry::RetryPolicy;
20use crate::types::{
21    JobChunk, JobExecution, JobFieldMap, JobResultItem, JobResults, JobStatus, OutputType,
22};
23use crate::wire::{msg, ndarray};
24
25/// URI schemes the server resolves itself, without a caller-named connection.
26const INTERNAL_SCHEMES: &[&str] = &["upload"];
27
28/// One inline item.
29#[derive(Debug, Clone, PartialEq)]
30pub enum JobItem {
31    /// Plain text; the server wraps it as `{"text": ...}`.
32    Text(String),
33    /// A pre-built item object.
34    Object(Value),
35}
36
37impl From<&str> for JobItem {
38    fn from(value: &str) -> Self {
39        Self::Text(value.to_string())
40    }
41}
42
43impl From<String> for JobItem {
44    fn from(value: String) -> Self {
45        Self::Text(value)
46    }
47}
48
49impl From<Value> for JobItem {
50    fn from(value: Value) -> Self {
51        Self::Object(value)
52    }
53}
54
55impl JobItem {
56    fn to_json(&self) -> Value {
57        match self {
58            Self::Text(text) => serde_json::json!({"text": text}),
59            Self::Object(value) => value.clone(),
60        }
61    }
62}
63
64/// Where a job reads its input from.
65#[derive(Debug, Clone, PartialEq)]
66pub enum JobSource {
67    /// Items carried in the request body.
68    Items(Vec<JobItem>),
69    /// A `scheme://connection/...` URI naming a data store.
70    Connector(String),
71}
72
73impl JobSource {
74    /// Inline items.
75    pub fn items(items: impl IntoIterator<Item = impl Into<JobItem>>) -> Self {
76        Self::Items(items.into_iter().map(Into::into).collect())
77    }
78
79    /// A connector URI.
80    pub fn connector(uri: impl Into<String>) -> Self {
81        Self::Connector(uri.into())
82    }
83}
84
85/// Where a job writes its output.
86#[derive(Debug, Clone, PartialEq, Eq, Default)]
87pub enum JobSink {
88    /// Results come back through [`Jobs::results`]. The default.
89    #[default]
90    Return,
91    /// Results are written back over the source rows.
92    InPlace,
93    /// Results are written to a second data store.
94    Connector(String),
95}
96
97impl JobSink {
98    /// A connector URI.
99    pub fn connector(uri: impl Into<String>) -> Self {
100        Self::Connector(uri.into())
101    }
102}
103
104/// The connection a connector URI names.
105///
106/// Deliberately not a URL parse: control characters must reach the validator and be
107/// rejected, rather than being normalized away into something that looks valid.
108pub fn connection_name(uri: &str) -> Result<String> {
109    let after_scheme = uri.split_once("://").map_or("", |(_, rest)| rest);
110    let name = after_scheme
111        .split(['/', '?', '#'])
112        .next()
113        .filter(|name| !name.is_empty())
114        .ok_or_else(|| {
115            Error::invalid(format!(
116                "connector URI {uri:?} names no connection (expected 'scheme://<connection>/…')"
117            ))
118        })?;
119    require_connection_name(name)
120}
121
122/// Validate a connection name and return its canonical form.
123pub fn require_connection_name(name: &str) -> Result<String> {
124    let valid = (1..=128).contains(&name.len())
125        && name.is_ascii()
126        && name
127            .as_bytes()
128            .first()
129            .is_some_and(u8::is_ascii_alphanumeric)
130        && name
131            .bytes()
132            .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-');
133    if valid {
134        Ok(name.to_string())
135    } else {
136        Err(Error::invalid(
137            "connection name must be 1-128 ASCII letters, digits, '.', '_', or '-', \
138             and start with a letter or digit",
139        ))
140    }
141}
142
143/// Validate the `PostgreSQL` schema pair, which is all-or-nothing.
144pub fn require_connection_schema_policy(
145    connection_type: &str,
146    source_schema: Option<&str>,
147    sink_schema: Option<&str>,
148) -> Result<Option<(String, String)>> {
149    match (source_schema, sink_schema) {
150        (None, None) => Ok(None),
151        (Some(_), None) | (None, Some(_)) => Err(Error::invalid(
152            "source_schema and sink_schema must be supplied together",
153        )),
154        (Some(source), Some(sink)) => {
155            if connection_type != "postgres" {
156                return Err(Error::invalid(format!(
157                    "source_schema and sink_schema apply only to postgres connections, got {connection_type:?}"
158                )));
159            }
160            Ok(Some((valid_schema(source)?, valid_schema(sink)?)))
161        }
162    }
163}
164
165fn valid_schema(schema: &str) -> Result<String> {
166    let valid = (1..=63).contains(&schema.len())
167        && schema.is_ascii()
168        && schema
169            .as_bytes()
170            .first()
171            .is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_')
172        && schema
173            .bytes()
174            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'$');
175    if valid {
176        Ok(schema.to_string())
177    } else {
178        Err(Error::invalid(format!(
179            "schema {schema:?} must be 1-63 ASCII letters, digits, '_' or '$', and start with a letter or '_'"
180        )))
181    }
182}
183
184/// Validate a connector idempotency key.
185pub fn require_connector_idempotency_key(key: &str) -> Result<String> {
186    let bytes = key.as_bytes();
187    if (1..=256).contains(&bytes.len()) && bytes.iter().all(|b| (0x20..=0x7e).contains(b)) {
188        Ok(key.to_string())
189    } else {
190        Err(Error::invalid(
191            "idempotency_key must be 1-256 printable ASCII bytes",
192        ))
193    }
194}
195
196fn is_connector_uri(value: &str) -> bool {
197    value.contains("://")
198}
199
200fn is_internal_uri(uri: &str) -> bool {
201    uri.split_once("://")
202        .is_some_and(|(scheme, _)| INTERNAL_SCHEMES.contains(&scheme))
203}
204
205/// The jobs namespace. Obtain one with [`Client::jobs`].
206#[derive(Debug, Clone)]
207pub struct Jobs {
208    client: Client,
209}
210
211impl Client {
212    /// Operations on batch jobs.
213    pub fn jobs(&self) -> Jobs {
214        Jobs {
215            client: self.clone(),
216        }
217    }
218}
219
220impl Jobs {
221    /// Queue a job.
222    pub fn submit(&self, source: JobSource, model: impl Into<String>) -> JobSubmit {
223        JobSubmit {
224            client: self.client.clone(),
225            source,
226            model: model.into(),
227            operation: "encode".to_string(),
228            sink: JobSink::Return,
229            connection: None,
230            sink_connection: None,
231            field_map: JobFieldMap::default(),
232            output_field: None,
233            execution: None,
234            output_types: None,
235            options: None,
236            idempotency_key: None,
237        }
238    }
239
240    /// Fetch one job.
241    pub async fn get(&self, job_id: &str) -> Result<JobStatus> {
242        let response = self
243            .client
244            .send_once(
245                self.job_request(Method::GET, job_id, "")?,
246                RetryPolicy::NONE,
247            )
248            .await?;
249        parse_json(&response, "job")
250    }
251
252    /// List this account's jobs.
253    pub async fn list(&self) -> Result<Vec<JobStatus>> {
254        let request = self
255            .client
256            .request(Method::GET, "/v1/jobs")?
257            .header("accept", headers::JSON_CONTENT_TYPE);
258        let response = self.client.send_once(request, RetryPolicy::NONE).await?;
259        if let Ok(jobs) = serde_json::from_slice::<Vec<JobStatus>>(&response.body) {
260            return Ok(jobs);
261        }
262        let envelope: Value = parse_json(&response, "job list")?;
263        let data = envelope
264            .get("data")
265            .cloned()
266            .ok_or_else(|| Error::decode("job list is missing its `data` array"))?;
267        serde_json::from_value(data)
268            .map_err(|err| Error::decode(format!("malformed job list: {err}")))
269    }
270
271    /// Ask the server to stop a job.
272    pub async fn cancel(&self, job_id: &str) -> Result<JobStatus> {
273        let request = self
274            .job_request(Method::POST, job_id, "/cancel")?
275            .json_headers();
276        let response = self.client.send_once(request, RetryPolicy::NONE).await?;
277        parse_json(&response, "job")
278    }
279
280    /// Execute a plan a previous `execution = plan` submit produced.
281    pub async fn execute(
282        &self,
283        job_id: &str,
284        plan_revision: u64,
285        idempotency_key: &str,
286    ) -> Result<JobStatus> {
287        self.plan_action(
288            job_id,
289            "/execute",
290            serde_json::json!({"plan_revision": plan_revision}),
291            idempotency_key,
292        )
293        .await
294    }
295
296    /// Re-run the failed part of an attempt, inside the job's repair window.
297    pub async fn repair(
298        &self,
299        job_id: &str,
300        plan_revision: u64,
301        recovery_attempt_ordinal: u64,
302        idempotency_key: &str,
303    ) -> Result<JobStatus> {
304        self.plan_action(
305            job_id,
306            "/repair",
307            serde_json::json!({
308                "plan_revision": plan_revision,
309                "recovery_attempt_ordinal": recovery_attempt_ordinal,
310            }),
311            idempotency_key,
312        )
313        .await
314    }
315
316    async fn plan_action(
317        &self,
318        job_id: &str,
319        suffix: &str,
320        body: Value,
321        idempotency_key: &str,
322    ) -> Result<JobStatus> {
323        let key = require_connector_idempotency_key(idempotency_key)?;
324        let request = self
325            .job_request(Method::POST, job_id, suffix)?
326            .json_headers()
327            .header(headers::IDEMPOTENCY_KEY, &key)
328            .body(serde_json::to_vec(&body).unwrap_or_default());
329        let response = self.client.send_once(request, RetryPolicy::NONE).await?;
330        parse_json(&response, "job")
331    }
332
333    /// Poll until the job settles, or the deadline passes.
334    pub async fn wait(
335        &self,
336        job_id: &str,
337        timeout: Duration,
338        poll_interval: Duration,
339    ) -> Result<JobStatus> {
340        let start = std::time::Instant::now();
341        loop {
342            let status = self.get(job_id).await?;
343            if status.is_settled() {
344                return Ok(status);
345            }
346            let elapsed = start.elapsed();
347            if elapsed >= timeout {
348                return Err(Error::Request {
349                    message: format!(
350                        "job {job_id} is still {:?} after {:.0}s",
351                        status.state,
352                        timeout.as_secs_f64()
353                    ),
354                    code: Some("job_wait_timeout".to_string()),
355                    status: 504,
356                    request: None,
357                });
358            }
359            tokio::time::sleep(poll_interval.min(timeout.checked_sub(elapsed).unwrap())).await;
360        }
361    }
362
363    /// Fetch a job's status and decode every chunk it can still retrieve.
364    ///
365    /// Chunks that failed, or whose payload has expired, are reported in `chunks` but
366    /// contribute no items; `retrieved` says how many were actually read.
367    pub async fn results(&self, job_id: &str) -> Result<JobResults> {
368        let status = self.get(job_id).await?;
369        let chunks = status.chunks();
370
371        let mut items = Vec::new();
372        let mut retrieved = 0;
373        for chunk in &chunks {
374            if !chunk_is_retrievable(chunk) {
375                continue;
376            }
377            let Some(reference) = chunk.r#ref.as_deref() else {
378                continue;
379            };
380            let raw = self.read_ref(reference).await?;
381            items.extend(decode_chunk(&raw)?);
382            retrieved += 1;
383        }
384
385        Ok(JobResults {
386            job_id: status.id.clone(),
387            state: status.state,
388            total_items: status.total_items,
389            settled_credits: status.settled_credits,
390            dims: items.iter().find_map(|item| item.dims),
391            chunks,
392            retrieved,
393            items,
394        })
395    }
396
397    fn job_request(&self, method: Method, job_id: &str, suffix: &str) -> Result<PreparedRequest> {
398        // A job id is one path segment, so every reserved character is escaped.
399        let encoded: String =
400            percent_encoding::utf8_percent_encode(job_id, percent_encoding::NON_ALPHANUMERIC)
401                .collect();
402        Ok(self
403            .client
404            .request(method, &format!("/v1/jobs/{encoded}{suffix}"))?
405            .header("accept", headers::JSON_CONTENT_TYPE))
406    }
407
408    /// Fetch one chunk's payload.
409    ///
410    /// A payload reference points at a signed, time-limited object-store URL that is not
411    /// the gateway. It is fetched without any credential: sending `Authorization` to a
412    /// location the server named would hand the token to whatever host is behind it.
413    async fn read_ref(&self, reference: &str) -> Result<bytes::Bytes> {
414        if !reference.starts_with("http://") && !reference.starts_with("https://") {
415            let path = Path::new(reference);
416            return std::fs::read(path)
417                .map(bytes::Bytes::from)
418                .map_err(|_| Error::Request {
419                    message: format!("job payload reference {reference:?} could not be resolved"),
420                    code: Some("bad_ref".to_string()),
421                    status: 400,
422                    request: None,
423                });
424        }
425        self.client.fetch_payload_ref(reference).await
426    }
427}
428
429impl Client {
430    /// Fetch a payload reference with a bare, unauthenticated request.
431    async fn fetch_payload_ref(&self, reference: &str) -> Result<bytes::Bytes> {
432        let url = reqwest::Url::parse(reference).map_err(|err| {
433            Error::invalid(format!("invalid payload reference {reference:?}: {err}"))
434        })?;
435
436        let bare = reqwest::Client::builder()
437            .redirect(reqwest::redirect::Policy::none())
438            .build()
439            .map_err(|err| {
440                Error::invalid(format!("could not build the payload-ref client: {err}"))
441            })?;
442
443        let mut request = bare
444            .get(url.clone())
445            .header(reqwest::header::ACCEPT, headers::OCTET_STREAM_CONTENT_TYPE)
446            .timeout(self.timeout().max(TRANSFER_TIMEOUT_FLOOR));
447        // Edge credentials still apply when the reference happens to be the gateway itself.
448        if self.edge_headers_apply_to(&url) {
449            for (name, value) in &self.inner.edge_headers {
450                request = request.header(name.clone(), value.clone());
451            }
452        }
453
454        let response = request.send().await.map_err(|error| {
455            Error::connection(
456                crate::error::TransportErrorKind::Connect,
457                format!("could not fetch payload reference {reference}: {error}"),
458                error,
459            )
460        })?;
461        let status = response.status().as_u16();
462        let body = response.bytes().await.map_err(|error| {
463            Error::connection(
464                crate::error::TransportErrorKind::MidFlight,
465                format!("could not read payload reference {reference}: {error}"),
466                error,
467            )
468        })?;
469
470        if status >= 400 {
471            return Err(Error::Request {
472                message: format!("payload reference {reference} returned HTTP {status}"),
473                code: Some("bad_ref".to_string()),
474                status,
475                request: None,
476            });
477        }
478        Ok(body)
479    }
480}
481
482/// Decode a chunk's msgpack array of worker results.
483fn decode_chunk(raw: &[u8]) -> Result<Vec<JobResultItem>> {
484    let decoded: MsgValue = rmp_serde::from_slice(raw)
485        .map_err(|err| Error::decode(format!("malformed job chunk: {err}")))?;
486    let MsgValue::Array(entries) = decoded else {
487        return Ok(Vec::new());
488    };
489    entries.iter().map(decode_result_item).collect()
490}
491
492fn decode_result_item(entry: &MsgValue) -> Result<JobResultItem> {
493    let mut item = JobResultItem {
494        id: msg::get_string(entry, "id"),
495        success: msg::get(entry, "success").and_then(rmpv::Value::as_bool),
496        units: msg::get(entry, "units").map(msg::to_json),
497        error: msg::get_string(entry, "error"),
498        ..JobResultItem::default()
499    };
500
501    // The worker's own result is an opaque msgpack blob the gateway never unpacks.
502    let Some(MsgValue::Binary(payload)) = msg::get(entry, "result_msgpack") else {
503        return Ok(item);
504    };
505    let Ok(inner) = rmp_serde::from_slice::<MsgValue>(payload) else {
506        // A blob the SDK cannot read must not discard the row's outcome.
507        return Ok(item);
508    };
509    if let Some(dense) = msg::get(&inner, "dense") {
510        let (dims, values) = dense_info(dense)?;
511        item.dims = dims;
512        item.dense = values;
513    }
514    Ok(item)
515}
516
517/// Read a `dense` field, which may be a bare array or a `{dims, values}` envelope.
518fn dense_info(dense: &MsgValue) -> Result<(Option<u32>, Option<Vec<f32>>)> {
519    if ndarray::is_array(dense) {
520        let array = ndarray::decode(dense)?;
521        let values = array.to_f32();
522        return Ok((Some(values.len() as u32), Some(values)));
523    }
524    if let MsgValue::Array(items) = dense {
525        let values: Vec<f32> = items
526            .iter()
527            .filter_map(|value| value.as_f64().map(|v| v as f32))
528            .collect();
529        return Ok((Some(values.len() as u32), Some(values)));
530    }
531    if matches!(dense, MsgValue::Map(_)) {
532        let declared = msg::get_u64(dense, "dims").map(|dims| dims as u32);
533        for key in ["values", "vector", "dense"] {
534            if let Some(node) = msg::get(dense, key) {
535                let (derived, values) = dense_info(node)?;
536                return Ok((declared.or(derived), values));
537            }
538        }
539        return Ok((declared, None));
540    }
541    Ok((None, None))
542}
543
544/// Queues a job. Build with [`Jobs::submit`].
545pub struct JobSubmit {
546    client: Client,
547    source: JobSource,
548    model: String,
549    operation: String,
550    sink: JobSink,
551    connection: Option<String>,
552    sink_connection: Option<String>,
553    field_map: JobFieldMap,
554    output_field: Option<String>,
555    execution: Option<JobExecution>,
556    output_types: Option<Vec<OutputType>>,
557    options: Option<Value>,
558    idempotency_key: Option<String>,
559}
560
561impl JobSubmit {
562    /// Which inference operation to run. Defaults to `encode`.
563    pub fn operation(mut self, operation: impl Into<String>) -> Self {
564        self.operation = operation.into();
565        self
566    }
567
568    /// Where results go. Defaults to [`JobSink::Return`].
569    pub fn sink(mut self, sink: JobSink) -> Self {
570        self.sink = sink;
571        self
572    }
573
574    /// Override the connection the source URI names.
575    pub fn connection(mut self, connection: impl Into<String>) -> Self {
576        self.connection = Some(connection.into());
577        self
578    }
579
580    /// Override the connection the sink URI names.
581    pub fn sink_connection(mut self, connection: impl Into<String>) -> Self {
582        self.sink_connection = Some(connection.into());
583        self
584    }
585
586    /// How the source's columns map onto job items.
587    pub fn field_map(mut self, field_map: JobFieldMap) -> Self {
588        self.field_map = field_map;
589        self
590    }
591
592    /// Which sink column receives the result.
593    pub fn output_field(mut self, output_field: impl Into<String>) -> Self {
594        self.output_field = Some(output_field.into());
595        self
596    }
597
598    /// Which half of a connector job to run. Required for connector sources.
599    pub fn execution(mut self, execution: JobExecution) -> Self {
600        self.execution = Some(execution);
601        self
602    }
603
604    /// Which representations to produce.
605    pub fn output_types(mut self, types: impl IntoIterator<Item = OutputType>) -> Self {
606        self.output_types = Some(types.into_iter().collect());
607        self
608    }
609
610    /// Adapter-specific runtime options.
611    pub fn options(mut self, options: Value) -> Self {
612        self.options = Some(options);
613        self
614    }
615
616    /// Deduplication key. Required for connector jobs, rejected for inline ones.
617    pub fn idempotency_key(mut self, key: impl Into<String>) -> Self {
618        self.idempotency_key = Some(key.into());
619        self
620    }
621
622    /// Assemble the request body, validating that the inline and connector shapes are not
623    /// mixed. Only the fields that were set ride the wire.
624    pub(crate) fn body(&self) -> Result<Value> {
625        let mut body = Map::new();
626        body.insert(
627            "operation".to_string(),
628            Value::String(self.operation.clone()),
629        );
630        body.insert("model".to_string(), Value::String(self.model.clone()));
631
632        let source_connection = self.write_source(&mut body)?;
633        let inline = body.contains_key("items");
634        let sink_fields = self.sink_fields(source_connection.as_ref())?;
635
636        if inline
637            && (self.connection.is_some()
638                || self.sink_connection.is_some()
639                || !sink_fields.is_empty())
640        {
641            return Err(Error::invalid(
642                "connection/sink/sink_connection apply only to connector-src jobs; inline items return results",
643            ));
644        }
645        body.extend(sink_fields);
646
647        self.write_execution(&mut body, inline)?;
648        let mapping = self.mapping_fields()?;
649        if !mapping.is_empty() {
650            if inline {
651                return Err(Error::invalid(
652                    "field_map/output_field apply to connector-src jobs; an inline items job maps nothing",
653                ));
654            }
655            body.extend(mapping);
656        }
657
658        if let Some(types) = &self.output_types
659            && !types.is_empty()
660        {
661            body.insert(
662                "output_types".to_string(),
663                serde_json::to_value(types).unwrap_or(Value::Null),
664            );
665        }
666        if let Some(options) = &self.options
667            && options.as_object().is_some_and(|map| !map.is_empty())
668        {
669            body.insert("options".to_string(), options.clone());
670        }
671
672        Ok(Value::Object(body))
673    }
674
675    /// Write the source fields and return the connection the source resolved to.
676    fn write_source(&self, body: &mut Map<String, Value>) -> Result<Option<String>> {
677        match &self.source {
678            JobSource::Items(items) => {
679                if items.is_empty() {
680                    return Err(Error::invalid("inline source has no items"));
681                }
682                body.insert(
683                    "items".to_string(),
684                    Value::Array(items.iter().map(JobItem::to_json).collect()),
685                );
686                Ok(None)
687            }
688            JobSource::Connector(uri) => {
689                if !is_connector_uri(uri) {
690                    return Err(Error::invalid(format!(
691                        "connector source must be a 'scheme://<connection>/…' URI, got {uri:?}"
692                    )));
693                }
694                body.insert("src".to_string(), Value::String(uri.clone()));
695                let resolved = match (&self.connection, is_internal_uri(uri)) {
696                    (Some(name), _) => Some(require_connection_name(name)?),
697                    // An internal scheme is resolved by the server, so it names no connection.
698                    (None, true) => None,
699                    (None, false) => Some(connection_name(uri)?),
700                };
701                if let Some(name) = &resolved {
702                    body.insert("connection".to_string(), Value::String(name.clone()));
703                }
704                Ok(resolved)
705            }
706        }
707    }
708
709    fn sink_fields(&self, source_connection: Option<&String>) -> Result<Map<String, Value>> {
710        let mut fields = Map::new();
711        match &self.sink {
712            JobSink::Return => {}
713            JobSink::InPlace => {
714                fields.insert("sink".to_string(), Value::String("inplace".to_string()));
715            }
716            JobSink::Connector(uri) => {
717                if !is_connector_uri(uri) {
718                    return Err(Error::invalid(format!(
719                        "sink must be 'return', 'inplace', or a connector URI, got {uri:?}"
720                    )));
721                }
722                fields.insert("sink".to_string(), Value::String(uri.clone()));
723                if is_internal_uri(uri) {
724                    if let Some(name) = &self.sink_connection {
725                        fields.insert(
726                            "sink_connection".to_string(),
727                            Value::String(require_connection_name(name)?),
728                        );
729                    }
730                } else {
731                    let resolved = match &self.sink_connection {
732                        Some(name) => require_connection_name(name)?,
733                        None => connection_name(uri)?,
734                    };
735                    // A sink in the same store as the source needs no second connection.
736                    if self.sink_connection.is_some() || Some(&resolved) != source_connection {
737                        fields.insert("sink_connection".to_string(), Value::String(resolved));
738                    }
739                }
740            }
741        }
742        Ok(fields)
743    }
744
745    fn write_execution(&self, body: &mut Map<String, Value>, inline: bool) -> Result<()> {
746        match (inline, self.execution) {
747            (false, None) => Err(Error::invalid(
748                "connector jobs require execution = Plan or execution = Run",
749            )),
750            (false, Some(execution)) => {
751                let uses_internal = matches!(&self.source, JobSource::Connector(uri) if is_internal_uri(uri))
752                    || matches!(&self.sink, JobSink::Connector(uri) if is_internal_uri(uri));
753                if uses_internal && execution != JobExecution::Run {
754                    return Err(Error::invalid(
755                        "upload:// connector jobs are run-only; set execution = Run",
756                    ));
757                }
758                body.insert(
759                    "execution".to_string(),
760                    serde_json::to_value(execution).unwrap_or(Value::Null),
761                );
762                Ok(())
763            }
764            (true, Some(_)) => Err(Error::invalid(
765                "execution applies only to connector-src jobs; inline items must omit it",
766            )),
767            (true, None) => Ok(()),
768        }
769    }
770
771    fn mapping_fields(&self) -> Result<Map<String, Value>> {
772        let mut mapping = Map::new();
773        if !self.field_map.is_empty() {
774            if let Some(input_type) = &self.field_map.input_type
775                && input_type != "text"
776                && input_type != "document"
777            {
778                return Err(Error::invalid(format!(
779                    "field_map.input_type must be 'text' or 'document', got {input_type:?}"
780                )));
781            }
782            if self.field_map.carry.iter().any(String::is_empty) {
783                return Err(Error::invalid(
784                    "field_map.carry must not contain empty field names",
785                ));
786            }
787            mapping.insert(
788                "field_map".to_string(),
789                serde_json::to_value(&self.field_map)
790                    .map_err(|err| Error::invalid(format!("could not encode field_map: {err}")))?,
791            );
792        }
793        if let Some(output_field) = &self.output_field {
794            if output_field.is_empty() {
795                return Err(Error::invalid("output_field must not be empty"));
796            }
797            mapping.insert(
798                "output_field".to_string(),
799                Value::String(output_field.clone()),
800            );
801        }
802        Ok(mapping)
803    }
804
805    /// Send the request.
806    pub async fn send(self) -> Result<JobStatus> {
807        let body = self.body()?;
808        let connector = body.get("src").is_some();
809
810        let key = match (&self.idempotency_key, connector) {
811            (Some(key), true) => Some(require_connector_idempotency_key(key)?),
812            (None, true) => {
813                return Err(Error::invalid(
814                    "connector-src jobs require an idempotency_key so a retried submit cannot run twice",
815                ));
816            }
817            (Some(_), false) => {
818                return Err(Error::invalid(
819                    "idempotency_key applies only to connector-src jobs; inline items must omit it",
820                ));
821            }
822            (None, false) => None,
823        };
824
825        let request = self
826            .client
827            .request(Method::POST, "/v1/jobs")?
828            .json_headers()
829            .maybe_header(headers::IDEMPOTENCY_KEY, key.as_deref())
830            .body(serde_json::to_vec(&body).unwrap_or_default());
831
832        let response = self
833            .client
834            .send_with_timeout(request, RetryPolicy::NONE, TRANSFER_TIMEOUT_FLOOR)
835            .await?;
836        parse_json(&response, "job")
837    }
838}
839
840/// Read a chunk's payload for callers holding one outside [`Jobs::results`].
841pub fn decode_chunk_payload(raw: &[u8]) -> Result<Vec<JobResultItem>> {
842    decode_chunk(raw)
843}
844
845/// Whether a chunk has a payload worth fetching.
846pub fn chunk_is_retrievable(chunk: &JobChunk) -> bool {
847    chunk.state == "succeeded" && chunk.r#ref.as_ref().is_some_and(|value| !value.is_empty())
848}
849
850#[cfg(test)]
851mod tests {
852    // These assertions are about exact values, so exact comparison is the point.
853    #![allow(clippy::float_cmp)]
854
855    use super::*;
856    use serde_json::json;
857
858    fn client() -> Client {
859        Client::new("https://sie.invalid").unwrap()
860    }
861
862    fn submit(source: JobSource) -> JobSubmit {
863        client().jobs().submit(source, "BAAI/bge-m3")
864    }
865
866    #[test]
867    fn an_inline_job_carries_only_operation_model_and_items() {
868        let body = submit(JobSource::items(["a", "b"])).body().unwrap();
869        assert_eq!(
870            body,
871            json!({"operation": "encode", "model": "BAAI/bge-m3",
872                   "items": [{"text": "a"}, {"text": "b"}]})
873        );
874    }
875
876    #[test]
877    fn inline_object_items_pass_through_unchanged() {
878        let body = submit(JobSource::items([json!({"id": "1", "text": "hi"})]))
879            .body()
880            .unwrap();
881        assert_eq!(body["items"], json!([{"id": "1", "text": "hi"}]));
882    }
883
884    #[test]
885    fn an_empty_inline_source_is_rejected() {
886        let empty: Vec<&str> = Vec::new();
887        assert!(submit(JobSource::items(empty)).body().is_err());
888    }
889
890    #[test]
891    fn a_connector_source_derives_its_connection_from_the_uri() {
892        let body = submit(JobSource::connector("postgres://warehouse?query=SELECT+1"))
893            .execution(JobExecution::Plan)
894            .body()
895            .unwrap();
896        assert_eq!(body["src"], json!("postgres://warehouse?query=SELECT+1"));
897        assert_eq!(body["connection"], json!("warehouse"));
898        assert_eq!(body["execution"], json!("plan"));
899    }
900
901    #[test]
902    fn a_sink_in_the_same_store_needs_no_second_connection() {
903        let body = submit(JobSource::connector("postgres://warehouse?query=SELECT+1"))
904            .sink(JobSink::connector("postgres://warehouse?table=vecs"))
905            .execution(JobExecution::Plan)
906            .body()
907            .unwrap();
908        assert_eq!(body["sink"], json!("postgres://warehouse?table=vecs"));
909        assert!(body.get("sink_connection").is_none());
910    }
911
912    #[test]
913    fn a_sink_in_another_store_names_its_own_connection() {
914        let body = submit(JobSource::connector("postgres://warehouse?query=SELECT+1"))
915            .sink(JobSink::connector("s3://out-bucket/vecs"))
916            .execution(JobExecution::Run)
917            .body()
918            .unwrap();
919        assert_eq!(body["sink_connection"], json!("out-bucket"));
920    }
921
922    #[test]
923    fn the_sink_variants_render_their_wire_forms() {
924        let inplace = submit(JobSource::connector("postgres://wh?query=x"))
925            .sink(JobSink::InPlace)
926            .execution(JobExecution::Run)
927            .body()
928            .unwrap();
929        assert_eq!(inplace["sink"], json!("inplace"));
930
931        let returned = submit(JobSource::items(["a"])).body().unwrap();
932        assert!(returned.get("sink").is_none());
933    }
934
935    #[test]
936    fn upload_jobs_name_no_connection_and_are_run_only() {
937        let body = submit(JobSource::connector("upload://file-abc?format=csv"))
938            .sink(JobSink::connector("upload://file-out"))
939            .field_map(JobFieldMap {
940                id_field: Some("doc_id".to_string()),
941                input_field: Some("text".to_string()),
942                input_type: Some("text".to_string()),
943                ..JobFieldMap::default()
944            })
945            .execution(JobExecution::Run)
946            .body()
947            .unwrap();
948        assert!(body.get("connection").is_none());
949        assert!(body.get("sink_connection").is_none());
950        assert_eq!(body["field_map"]["id_field"], json!("doc_id"));
951
952        let planned = submit(JobSource::connector("upload://file-abc"))
953            .execution(JobExecution::Plan)
954            .body()
955            .unwrap_err();
956        assert!(planned.to_string().contains("run-only"), "{planned}");
957    }
958
959    #[test]
960    fn connector_and_inline_shapes_cannot_be_mixed() {
961        let with_connection = submit(JobSource::items(["a"]))
962            .connection("warehouse")
963            .body()
964            .unwrap_err();
965        assert!(
966            with_connection.to_string().contains("connector-src"),
967            "{with_connection}"
968        );
969
970        let with_sink = submit(JobSource::items(["a"]))
971            .sink(JobSink::connector("s3://out/vecs"))
972            .body()
973            .unwrap_err();
974        assert!(
975            with_sink.to_string().contains("connector-src"),
976            "{with_sink}"
977        );
978
979        let with_mapping = submit(JobSource::items(["a"]))
980            .output_field("embedding")
981            .body()
982            .unwrap_err();
983        assert!(
984            with_mapping.to_string().contains("maps nothing"),
985            "{with_mapping}"
986        );
987    }
988
989    #[test]
990    fn execution_is_required_for_connectors_and_forbidden_inline() {
991        let missing = submit(JobSource::connector("postgres://wh?query=x"))
992            .body()
993            .unwrap_err();
994        assert!(
995            missing.to_string().contains("require execution"),
996            "{missing}"
997        );
998
999        let extra = submit(JobSource::items(["a"]))
1000            .execution(JobExecution::Run)
1001            .body()
1002            .unwrap_err();
1003        assert!(extra.to_string().contains("must omit it"), "{extra}");
1004    }
1005
1006    #[test]
1007    fn optional_fields_only_appear_when_set() {
1008        let bare = submit(JobSource::items(["a"])).body().unwrap();
1009        assert!(bare.get("output_types").is_none());
1010        assert!(bare.get("options").is_none());
1011
1012        let full = submit(JobSource::items(["a"]))
1013            .output_types([OutputType::Dense])
1014            .options(json!({"is_query": true}))
1015            .body()
1016            .unwrap();
1017        assert_eq!(full["output_types"], json!(["dense"]));
1018        assert_eq!(full["options"], json!({"is_query": true}));
1019
1020        // An empty options object rides nothing.
1021        let empty = submit(JobSource::items(["a"]))
1022            .options(json!({}))
1023            .body()
1024            .unwrap();
1025        assert!(empty.get("options").is_none());
1026    }
1027
1028    #[test]
1029    fn field_map_input_type_is_constrained() {
1030        let err = submit(JobSource::connector("postgres://wh?query=x"))
1031            .execution(JobExecution::Run)
1032            .field_map(JobFieldMap {
1033                input_type: Some("image".to_string()),
1034                ..JobFieldMap::default()
1035            })
1036            .body()
1037            .unwrap_err();
1038        assert!(err.to_string().contains("'text' or 'document'"), "{err}");
1039    }
1040
1041    #[test]
1042    fn connection_names_reject_traversal_and_non_ascii() {
1043        assert_eq!(
1044            connection_name("postgres://warehouse?query=x").unwrap(),
1045            "warehouse"
1046        );
1047        assert_eq!(
1048            connection_name("s3://customer-bucket/in/").unwrap(),
1049            "customer-bucket"
1050        );
1051        assert_eq!(connection_name("gs://my-bucket").unwrap(), "my-bucket");
1052
1053        for uri in [
1054            "postgres://../other",
1055            "postgres://warehouse\\name",
1056            "postgres://warehouse%2fname",
1057            "postgres://_leading",
1058            "postgres://café",
1059            "postgres://",
1060            "not-a-uri",
1061        ] {
1062            assert!(connection_name(uri).is_err(), "{uri:?} should be rejected");
1063        }
1064        // A control character must fail the validator rather than be normalized away.
1065        assert!(connection_name("postgres://warehouse\n").is_err());
1066        assert!(require_connection_name(&"a".repeat(129)).is_err());
1067        assert!(require_connection_name(&"a".repeat(128)).is_ok());
1068    }
1069
1070    #[test]
1071    fn schema_policy_is_all_or_nothing_and_postgres_only() {
1072        assert!(
1073            require_connection_schema_policy("postgres", None, None)
1074                .unwrap()
1075                .is_none()
1076        );
1077        assert!(require_connection_schema_policy("postgres", Some("src"), None).is_err());
1078        assert!(require_connection_schema_policy("s3", Some("src"), Some("dst")).is_err());
1079        assert_eq!(
1080            require_connection_schema_policy("postgres", Some("src"), Some("dst")).unwrap(),
1081            Some(("src".to_string(), "dst".to_string()))
1082        );
1083        assert!(require_connection_schema_policy("postgres", Some("1bad"), Some("dst")).is_err());
1084    }
1085
1086    #[test]
1087    fn idempotency_keys_must_be_printable_ascii() {
1088        assert!(require_connector_idempotency_key("run-2026-08-07").is_ok());
1089        assert!(require_connector_idempotency_key("").is_err());
1090        assert!(require_connector_idempotency_key(&"k".repeat(257)).is_err());
1091        assert!(require_connector_idempotency_key(&"k".repeat(256)).is_ok());
1092        assert!(require_connector_idempotency_key("with\nnewline").is_err());
1093        assert!(require_connector_idempotency_key("café").is_err());
1094    }
1095
1096    #[tokio::test]
1097    async fn idempotency_keys_are_required_for_connectors_and_refused_inline() {
1098        let missing = submit(JobSource::connector("postgres://wh?query=x"))
1099            .execution(JobExecution::Run)
1100            .send()
1101            .await
1102            .unwrap_err();
1103        assert!(missing.to_string().contains("idempotency_key"), "{missing}");
1104
1105        let extra = submit(JobSource::items(["a"]))
1106            .idempotency_key("k")
1107            .send()
1108            .await
1109            .unwrap_err();
1110        assert!(extra.to_string().contains("must omit it"), "{extra}");
1111    }
1112
1113    /// Build a worker-result chunk the way the gateway packs one: the inner result is an
1114    /// opaque msgpack `bin`, not a nested structure.
1115    fn work_result_chunk(entries: Vec<Vec<(&str, MsgValue)>>) -> Vec<u8> {
1116        let array = MsgValue::Array(
1117            entries
1118                .into_iter()
1119                .map(|fields| {
1120                    MsgValue::Map(
1121                        fields
1122                            .into_iter()
1123                            .map(|(key, value)| (MsgValue::from(key), value))
1124                            .collect(),
1125                    )
1126                })
1127                .collect(),
1128        );
1129        rmp_serde::to_vec(&array).unwrap()
1130    }
1131
1132    #[test]
1133    fn chunk_payloads_decode_into_result_items() {
1134        let inner = rmp_serde::to_vec(&MsgValue::Map(vec![(
1135            MsgValue::from("dense"),
1136            MsgValue::Map(vec![
1137                (MsgValue::from("dims"), MsgValue::from(3u64)),
1138                (
1139                    MsgValue::from("values"),
1140                    MsgValue::Array(vec![
1141                        MsgValue::F32(0.1),
1142                        MsgValue::F32(0.2),
1143                        MsgValue::F32(0.3),
1144                    ]),
1145                ),
1146            ]),
1147        )]))
1148        .unwrap();
1149
1150        let chunk = work_result_chunk(vec![vec![
1151            ("success", MsgValue::Boolean(true)),
1152            ("id", MsgValue::from("0")),
1153            (
1154                "units",
1155                MsgValue::Map(vec![(MsgValue::from("input_tokens"), MsgValue::from(5u64))]),
1156            ),
1157            ("result_msgpack", MsgValue::Binary(inner)),
1158        ]]);
1159
1160        let items = decode_chunk_payload(&chunk).unwrap();
1161        assert_eq!(items.len(), 1);
1162        assert_eq!(items[0].id.as_deref(), Some("0"));
1163        assert_eq!(items[0].success, Some(true));
1164        assert_eq!(items[0].dims, Some(3));
1165        assert_eq!(
1166            items[0].dense.as_deref(),
1167            Some([0.1f32, 0.2, 0.3].as_slice())
1168        );
1169        assert_eq!(items[0].units, Some(json!({"input_tokens": 5})));
1170    }
1171
1172    #[test]
1173    fn an_unreadable_inner_payload_still_reports_the_rows_outcome() {
1174        // 0xc1 is the one byte msgpack never emits, so the inner decode must fail.
1175        let chunk = work_result_chunk(vec![vec![
1176            ("success", MsgValue::Boolean(false)),
1177            ("id", MsgValue::from("7")),
1178            ("error", MsgValue::from("worker crashed")),
1179            ("result_msgpack", MsgValue::Binary(vec![0xc1])),
1180        ]]);
1181        let items = decode_chunk_payload(&chunk).unwrap();
1182        assert_eq!(items[0].success, Some(false));
1183        assert_eq!(items[0].error.as_deref(), Some("worker crashed"));
1184        assert!(items[0].dense.is_none());
1185    }
1186
1187    #[test]
1188    fn a_chunk_that_is_not_an_array_decodes_to_nothing() {
1189        let chunk = rmp_serde::to_vec(&MsgValue::Map(vec![(
1190            MsgValue::from("unexpected"),
1191            MsgValue::Boolean(true),
1192        )]))
1193        .unwrap();
1194        assert!(decode_chunk_payload(&chunk).unwrap().is_empty());
1195    }
1196
1197    #[test]
1198    fn a_numpy_encoded_dense_result_decodes_too() {
1199        let inner = rmp_serde::to_vec(&MsgValue::Map(vec![(
1200            MsgValue::from("dense"),
1201            ndarray::fixtures::f32_array(&[2], &[1.5, -0.5]),
1202        )]))
1203        .unwrap();
1204        let chunk = work_result_chunk(vec![vec![
1205            ("id", MsgValue::from("0")),
1206            ("result_msgpack", MsgValue::Binary(inner)),
1207        ]]);
1208        let items = decode_chunk_payload(&chunk).unwrap();
1209        assert_eq!(items[0].dims, Some(2));
1210        assert_eq!(items[0].dense.as_deref(), Some([1.5f32, -0.5].as_slice()));
1211    }
1212
1213    #[test]
1214    fn only_succeeded_chunks_with_a_ref_are_worth_fetching() {
1215        let succeeded = JobChunk {
1216            state: "succeeded".to_string(),
1217            r#ref: Some("https://store/0".to_string()),
1218            ..JobChunk::default()
1219        };
1220        assert!(chunk_is_retrievable(&succeeded));
1221        assert!(!chunk_is_retrievable(&JobChunk {
1222            state: "failed".to_string(),
1223            ..succeeded.clone()
1224        }));
1225        assert!(!chunk_is_retrievable(&JobChunk {
1226            r#ref: None,
1227            ..succeeded
1228        }));
1229    }
1230}