1use 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
25const INTERNAL_SCHEMES: &[&str] = &["upload"];
27
28#[derive(Debug, Clone, PartialEq)]
30pub enum JobItem {
31 Text(String),
33 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#[derive(Debug, Clone, PartialEq)]
66pub enum JobSource {
67 Items(Vec<JobItem>),
69 Connector(String),
71}
72
73impl JobSource {
74 pub fn items(items: impl IntoIterator<Item = impl Into<JobItem>>) -> Self {
76 Self::Items(items.into_iter().map(Into::into).collect())
77 }
78
79 pub fn connector(uri: impl Into<String>) -> Self {
81 Self::Connector(uri.into())
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Default)]
87pub enum JobSink {
88 #[default]
90 Return,
91 InPlace,
93 Connector(String),
95}
96
97impl JobSink {
98 pub fn connector(uri: impl Into<String>) -> Self {
100 Self::Connector(uri.into())
101 }
102}
103
104pub 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
122pub 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
143pub 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
184pub 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#[derive(Debug, Clone)]
207pub struct Jobs {
208 client: Client,
209}
210
211impl Client {
212 pub fn jobs(&self) -> Jobs {
214 Jobs {
215 client: self.clone(),
216 }
217 }
218}
219
220impl Jobs {
221 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 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 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 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 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 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 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 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 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 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 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 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
482fn 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 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 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
517fn 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
544pub 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 pub fn operation(mut self, operation: impl Into<String>) -> Self {
564 self.operation = operation.into();
565 self
566 }
567
568 pub fn sink(mut self, sink: JobSink) -> Self {
570 self.sink = sink;
571 self
572 }
573
574 pub fn connection(mut self, connection: impl Into<String>) -> Self {
576 self.connection = Some(connection.into());
577 self
578 }
579
580 pub fn sink_connection(mut self, connection: impl Into<String>) -> Self {
582 self.sink_connection = Some(connection.into());
583 self
584 }
585
586 pub fn field_map(mut self, field_map: JobFieldMap) -> Self {
588 self.field_map = field_map;
589 self
590 }
591
592 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 pub fn execution(mut self, execution: JobExecution) -> Self {
600 self.execution = Some(execution);
601 self
602 }
603
604 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 pub fn options(mut self, options: Value) -> Self {
612 self.options = Some(options);
613 self
614 }
615
616 pub fn idempotency_key(mut self, key: impl Into<String>) -> Self {
618 self.idempotency_key = Some(key.into());
619 self
620 }
621
622 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 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 (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 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 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
840pub fn decode_chunk_payload(raw: &[u8]) -> Result<Vec<JobResultItem>> {
842 decode_chunk(raw)
843}
844
845pub 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 #![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 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 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 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 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}