1use std::collections::{BTreeMap, HashMap, HashSet};
16use std::future::pending;
17use std::pin::Pin;
18use std::time::{Duration, Instant as StdInstant};
19
20use anyhow::{Context, anyhow};
21use async_trait::async_trait;
22use futures::future::try_join_all;
23use itertools::Itertools;
24use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
25use risingwave_common::array::{Op, StreamChunk};
26use risingwave_common::catalog::Schema;
27use risingwave_common::row::Row;
28use risingwave_common::session_config::sink_decouple::SinkDecouple;
29use risingwave_common::types::{DataType, ScalarRefImpl};
30use serde::{Deserialize, Serialize};
31use serde_json::{Map, Value};
32use serde_with::{DisplayFromStr, serde_as};
33use thiserror_ext::AsReport;
34use tokio::time::Sleep;
35use with_options::WithOptions;
36
37use crate::enforce_secret::EnforceSecret;
38use crate::sink::decouple_checkpoint_log_sink::should_force_commit_on_checkpoint_barrier;
39use crate::sink::encoder::{JsonEncoder, RowEncoder};
40use crate::sink::log_store::{LogStoreReadItem, TruncateOffset};
41use crate::sink::{
42 LogSinker, Result, Sink, SinkError, SinkLogReader, SinkParam, SinkWriterMetrics,
43 SinkWriterParam,
44};
45
46const DEFAULT_WRITE_BATCH_SIZE: usize = 1000;
47const DEFAULT_MAX_LINGER_SECOND: u64 = 1;
48
49pub const TURBOPUFFER_SINK: &str = "turbopuffer";
50
51fn default_write_batch_size() -> usize {
52 DEFAULT_WRITE_BATCH_SIZE
53}
54
55fn default_max_linger_second() -> u64 {
56 DEFAULT_MAX_LINGER_SECOND
57}
58
59#[serde_as]
60#[derive(Clone, Debug, Deserialize, WithOptions)]
61pub struct TurbopufferConfig {
62 pub base_url: String,
63 pub namespace: Option<String>,
64 pub namespace_column: Option<String>,
65 pub api_key: String,
66 pub distance_metric: Option<String>,
67 #[serde_as(as = "Option<DisplayFromStr>")]
68 pub disable_backpressure: Option<bool>,
69 pub full_text_search_columns: Option<String>,
70 pub filterable_columns: Option<String>,
71 #[serde(default = "default_write_batch_size")]
72 #[serde_as(as = "DisplayFromStr")]
73 #[with_option(allow_alter_on_fly)]
74 pub write_batch_size: usize,
75 #[serde(default = "default_max_linger_second")]
76 #[serde_as(as = "DisplayFromStr")]
77 #[with_option(allow_alter_on_fly)]
78 pub max_linger_second: u64,
79 pub r#type: String, }
81
82impl EnforceSecret for TurbopufferConfig {
83 const ENFORCE_SECRET_PROPERTIES: phf::Set<&'static str> = phf::phf_set! {
84 "api_key",
85 };
86}
87
88impl TurbopufferConfig {
89 fn from_btreemap(values: BTreeMap<String, String>) -> Result<Self> {
90 let config = serde_json::from_value::<TurbopufferConfig>(
91 serde_json::to_value(values).expect("serialize sink properties"),
92 )
93 .map_err(|e| SinkError::Config(anyhow!(e)))?;
94 if config.write_batch_size == 0 {
95 return Err(SinkError::Config(anyhow!(
96 "`write_batch_size` must be greater than 0"
97 )));
98 }
99 if config.max_linger_second == 0 {
100 return Err(SinkError::Config(anyhow!(
101 "`max_linger_second` must be greater than 0"
102 )));
103 }
104 Ok(config)
105 }
106}
107
108#[derive(Clone, Debug)]
109enum TurbopufferNamespace {
110 Static(String),
111 Dynamic { index: usize },
112}
113
114#[derive(Clone, Debug)]
115pub struct TurbopufferSink {
116 config: TurbopufferConfig,
117 schema: Schema,
118 pk_index: usize,
119 namespace: TurbopufferNamespace,
120 attribute_indices: Vec<usize>,
121 generated_schema: Value,
122}
123
124impl EnforceSecret for TurbopufferSink {
125 fn enforce_secret<'a>(
126 prop_iter: impl Iterator<Item = &'a str>,
127 ) -> crate::error::ConnectorResult<()> {
128 for prop in prop_iter {
129 TurbopufferConfig::enforce_one(prop)?;
130 }
131 Ok(())
132 }
133}
134
135impl TryFrom<SinkParam> for TurbopufferSink {
136 type Error = SinkError;
137
138 fn try_from(param: SinkParam) -> std::result::Result<Self, Self::Error> {
139 let schema = param.schema();
140 let pk_indices = param.downstream_pk_or_empty();
141 let [pk_index] = pk_indices.as_slice() else {
142 return Err(SinkError::Config(anyhow!(
143 "Turbopuffer sink requires exactly one primary_key column"
144 )));
145 };
146 let pk_index = *pk_index;
147 match schema[pk_index].data_type() {
148 DataType::Int16
149 | DataType::Int32
150 | DataType::Int64
151 | DataType::Serial
152 | DataType::Varchar => {}
153 data_type => {
154 return Err(SinkError::Config(anyhow!(
155 "Turbopuffer document id column must be an integer or varchar, got {:?}",
156 data_type
157 )));
158 }
159 };
160 let config = TurbopufferConfig::from_btreemap(param.properties)?;
161
162 let namespace = match (&config.namespace, &config.namespace_column) {
163 (Some(namespace), None) => {
164 validate_namespace(namespace)?;
165 TurbopufferNamespace::Static(namespace.clone())
166 }
167 (None, Some(namespace_column)) => {
168 let index = schema
169 .fields()
170 .iter()
171 .position(|field| field.name == *namespace_column)
172 .ok_or_else(|| {
173 SinkError::Config(anyhow!(
174 "Turbopuffer namespace_column '{}' not found in sink schema",
175 namespace_column
176 ))
177 })?;
178 if schema[index].data_type != DataType::Varchar {
179 return Err(SinkError::Config(anyhow!(
180 "Turbopuffer namespace_column must be varchar, got {:?}",
181 schema[index].data_type
182 )));
183 }
184 TurbopufferNamespace::Dynamic { index }
185 }
186 (Some(_), Some(_)) => {
187 return Err(SinkError::Config(anyhow!(
188 "Turbopuffer sink requires only one of namespace or namespace_column"
189 )));
190 }
191 (None, None) => {
192 return Err(SinkError::Config(anyhow!(
193 "Turbopuffer sink requires either namespace or namespace_column"
194 )));
195 }
196 };
197
198 let excluded_indices = match &namespace {
201 TurbopufferNamespace::Static(_) => HashSet::from([pk_index]),
202 TurbopufferNamespace::Dynamic { index } => HashSet::from([pk_index, *index]),
203 };
204 let attribute_indices = (0..schema.len())
205 .filter(|idx| !excluded_indices.contains(idx))
206 .collect_vec();
207 for index in &attribute_indices {
208 if schema[*index].name == "id" {
209 return Err(SinkError::Config(anyhow!(
210 "Turbopuffer attribute column must not be named id"
211 )));
212 }
213 }
214 let full_text_search_columns = parse_column_selection(
215 config.full_text_search_columns.as_deref(),
216 &schema,
217 &attribute_indices,
218 )?;
219 let filterable_columns = parse_column_selection(
220 config.filterable_columns.as_deref(),
221 &schema,
222 &attribute_indices,
223 )?;
224 let has_vector = attribute_indices
225 .iter()
226 .any(|idx| matches!(schema[*idx].data_type, DataType::Vector(_)));
227 if has_vector && config.distance_metric.is_none() {
228 return Err(SinkError::Config(anyhow!(
229 "Turbopuffer sink requires distance_metric when sink schema contains vector columns"
230 )));
231 }
232 let generated_schema = build_turbopuffer_schema(
236 &schema,
237 &attribute_indices,
238 &full_text_search_columns,
239 &filterable_columns,
240 )?;
241
242 Ok(Self {
243 config,
244 schema,
245 pk_index,
246 namespace,
247 attribute_indices,
248 generated_schema,
249 })
250 }
251}
252
253impl Sink for TurbopufferSink {
254 type LogSinker = TurbopufferLogSinker;
255
256 const SINK_NAME: &'static str = TURBOPUFFER_SINK;
257
258 async fn validate(&self) -> Result<()> {
259 Ok(())
260 }
261
262 fn is_sink_decouple(user_specified: &SinkDecouple) -> Result<bool> {
263 match user_specified {
264 SinkDecouple::Default | SinkDecouple::Enable => Ok(true),
265 SinkDecouple::Disable => Err(SinkError::Config(anyhow!(
266 "Turbopuffer sink can only be created with sink_decouple enabled"
267 ))),
268 }
269 }
270
271 async fn new_log_sinker(&self, writer_param: SinkWriterParam) -> Result<Self::LogSinker> {
272 let write_batch_size = self.config.write_batch_size;
273 let max_linger = Duration::from_secs(self.config.max_linger_second);
274 let writer = TurbopufferSinkWriter::new(
275 self.config.clone(),
276 self.schema.clone(),
277 self.pk_index,
278 self.namespace.clone(),
279 self.attribute_indices.clone(),
280 self.generated_schema.clone(),
281 write_batch_size,
282 max_linger,
283 )?;
284 Ok(TurbopufferLogSinker::new(
285 writer,
286 SinkWriterMetrics::new(&writer_param),
287 ))
288 }
289}
290
291pub struct TurbopufferLogSinker {
292 writer: TurbopufferSinkWriter,
293 sink_writer_metrics: SinkWriterMetrics,
294}
295
296impl TurbopufferLogSinker {
297 fn new(writer: TurbopufferSinkWriter, sink_writer_metrics: SinkWriterMetrics) -> Self {
298 Self {
299 writer,
300 sink_writer_metrics,
301 }
302 }
303
304 fn ensure_linger_timer(&self, linger_timer: &mut Pin<&mut Option<Sleep>>) {
305 if linger_timer.as_ref().get_ref().is_none() {
306 linger_timer
307 .as_mut()
308 .set(Some(tokio::time::sleep(self.writer.max_linger)));
309 }
310 }
311
312 async fn flush_all_and_truncate(
313 &mut self,
314 log_reader: &mut impl SinkLogReader,
315 latest_truncate_offset: &mut Option<TruncateOffset>,
316 linger_timer: &mut Pin<&mut Option<Sleep>>,
317 ) -> Result<()> {
318 let start_time = StdInstant::now();
319 self.writer.flush_all().await?;
320 self.sink_writer_metrics
321 .sink_commit_duration
322 .observe(start_time.elapsed().as_secs_f64());
323 linger_timer.as_mut().set(None);
324 if let Some(offset) = latest_truncate_offset.take() {
325 log_reader.truncate(offset)?;
326 }
327 Ok(())
328 }
329}
330
331#[async_trait]
332impl LogSinker for TurbopufferLogSinker {
333 async fn consume_log_and_sink(mut self, mut log_reader: impl SinkLogReader) -> Result<!> {
334 log_reader.start_from(None).await?;
335 let mut latest_truncate_offset = None;
336 let linger_timer = None;
337 let mut linger_timer = std::pin::pin!(linger_timer);
338
339 loop {
340 let (epoch, item) = tokio::select! {
341 item = log_reader.next_item() => item?,
342 _ = async {
343 match linger_timer.as_mut().as_pin_mut() {
344 Some(timer) => timer.await,
345 None => pending().await,
346 }
347 } => {
348 self.flush_all_and_truncate(
349 &mut log_reader,
350 &mut latest_truncate_offset,
351 &mut linger_timer,
352 )
353 .await?;
354 continue;
355 }
356 };
357 match item {
358 LogStoreReadItem::StreamChunk { chunk, chunk_id } => {
359 let offset = TruncateOffset::Chunk { epoch, chunk_id };
360 let has_pending_update = self.writer.write_chunk(chunk)?;
361 latest_truncate_offset = Some(offset);
362 if self.writer.should_flush_by_size() {
363 self.flush_all_and_truncate(
364 &mut log_reader,
365 &mut latest_truncate_offset,
366 &mut linger_timer,
367 )
368 .await?;
369 } else if has_pending_update {
370 self.ensure_linger_timer(&mut linger_timer);
371 }
372 }
373 LogStoreReadItem::Barrier {
374 new_vnode_bitmap,
375 is_stop,
376 schema_change,
377 ..
378 } => {
379 let offset = TruncateOffset::Barrier { epoch };
380 let should_flush = should_force_commit_on_checkpoint_barrier(
381 new_vnode_bitmap.is_some(),
382 is_stop,
383 schema_change.is_some(),
384 );
385 if self.writer.is_empty() {
386 log_reader.truncate(offset)?;
387 } else if should_flush {
388 latest_truncate_offset = Some(offset);
389 self.flush_all_and_truncate(
390 &mut log_reader,
391 &mut latest_truncate_offset,
392 &mut linger_timer,
393 )
394 .await?;
395 } else {
396 latest_truncate_offset = Some(offset);
397 }
398
399 if is_stop {
400 return pending().await;
401 }
402 }
403 }
404 }
405 }
406}
407
408pub struct TurbopufferSinkWriter {
409 client: reqwest::Client,
410 base_url: String,
411 distance_metric: Option<String>,
412 disable_backpressure: Option<bool>,
413 schema: Value,
414 pk_index: usize,
415 namespace: TurbopufferNamespace,
416 row_encoder: JsonEncoder,
417 write_batch_size: usize,
418 max_linger: Duration,
419 pending_batches: BTreeMap<String, HashMap<DocumentId, CompactedOp>>,
420}
421
422impl TurbopufferSinkWriter {
423 fn new(
424 config: TurbopufferConfig,
425 schema: Schema,
426 pk_index: usize,
427 namespace: TurbopufferNamespace,
428 attribute_indices: Vec<usize>,
429 generated_schema: Value,
430 write_batch_size: usize,
431 max_linger: Duration,
432 ) -> Result<Self> {
433 let mut header_map = HeaderMap::new();
434 header_map.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
435 let authorization = format!("Bearer {}", config.api_key);
436 header_map.insert(
437 AUTHORIZATION,
438 authorization
439 .parse()
440 .context("invalid turbopuffer api_key")
441 .map_err(SinkError::Config)?,
442 );
443 let client = reqwest::Client::builder()
444 .default_headers(header_map)
445 .build()
446 .context("failed to build turbopuffer HTTP client")
447 .map_err(SinkError::Http)?;
448 let base_url = config
449 .base_url
450 .parse::<reqwest::Url>()
451 .context("invalid turbopuffer base_url")
452 .map_err(SinkError::Config)?
453 .to_string()
454 .trim_end_matches('/')
455 .to_owned();
456 let row_encoder = JsonEncoder::new_with_turbopuffer(schema, Some(attribute_indices));
457 Ok(Self {
458 client,
459 base_url,
460 distance_metric: config.distance_metric,
461 disable_backpressure: config.disable_backpressure,
462 schema: generated_schema,
463 pk_index,
464 namespace,
465 row_encoder,
466 write_batch_size,
467 max_linger,
468 pending_batches: BTreeMap::new(),
469 })
470 }
471
472 fn url_for_row(&self, row: &impl Row) -> Result<String> {
473 match &self.namespace {
474 TurbopufferNamespace::Static(namespace) => Ok(format!(
475 "{}/v2/namespaces/{}",
476 self.base_url,
477 namespace.as_str()
478 )),
479 TurbopufferNamespace::Dynamic { index } => {
480 let namespace = match row.datum_at(*index) {
481 Some(ScalarRefImpl::Utf8(namespace)) => namespace,
482 None => {
483 return Err(SinkError::Http(anyhow!(
484 "Turbopuffer namespace_column cannot be null"
485 )));
486 }
487 Some(_) => {
488 return Err(SinkError::Http(anyhow!(
489 "unexpected namespace_column type, expected varchar"
490 )));
491 }
492 };
493 validate_namespace(namespace)?;
494 Ok(format!("{}/v2/namespaces/{}", self.base_url, namespace))
495 }
496 }
497 }
498
499 fn id_for_row(&self, row: &impl Row) -> Result<DocumentId> {
502 let datum = row.datum_at(self.pk_index).ok_or_else(|| {
503 SinkError::Http(anyhow!("Turbopuffer document id column cannot be null"))
504 })?;
505 match datum {
506 ScalarRefImpl::Int16(value) => Ok(document_id_from_i64(value as i64)),
507 ScalarRefImpl::Int32(value) => Ok(document_id_from_i64(value as i64)),
508 ScalarRefImpl::Int64(value) => Ok(document_id_from_i64(value)),
509 ScalarRefImpl::Serial(value) => Ok(document_id_from_i64(value.into_inner())),
510 ScalarRefImpl::Utf8(value) => {
511 if value.len() > 64 {
512 return Err(SinkError::Http(anyhow!(
513 "Turbopuffer string document id exceeds 64 bytes"
514 )));
515 }
516 Ok(DocumentId::String(value.to_owned()))
517 }
518 _ => Err(SinkError::Http(anyhow!(
519 "Turbopuffer document id column must be an integer or varchar"
520 ))),
521 }
522 }
523
524 fn upsert_row(&self, row: &impl Row, id: DocumentId) -> Result<Map<String, Value>> {
525 let mut value = self.row_encoder.encode(row)?;
526 value.insert(
527 "id".to_owned(),
528 serde_json::to_value(id).expect("serialize document id"),
529 );
530 Ok(value)
531 }
532
533 fn request_body(
534 &self,
535 upsert_rows: Vec<Map<String, Value>>,
536 deletes: Vec<DocumentId>,
537 ) -> Value {
538 let mut body = Map::new();
539 if let Some(distance_metric) = &self.distance_metric {
540 body.insert(
541 "distance_metric".to_owned(),
542 Value::String(distance_metric.clone()),
543 );
544 }
545 if !upsert_rows.is_empty() {
546 if let Some(disable_backpressure) = self.disable_backpressure {
547 body.insert(
548 "disable_backpressure".to_owned(),
549 Value::Bool(disable_backpressure),
550 );
551 }
552 body.insert("schema".to_owned(), self.schema.clone());
553 body.insert(
554 "upsert_rows".to_owned(),
555 Value::Array(upsert_rows.into_iter().map(Value::Object).collect()),
556 );
557 }
558 if !deletes.is_empty() {
559 body.insert(
560 "deletes".to_owned(),
561 Value::Array(
562 deletes
563 .into_iter()
564 .map(|id| serde_json::to_value(id).expect("serialize document id"))
565 .collect(),
566 ),
567 );
568 }
569 Value::Object(body)
570 }
571
572 fn write_chunk(&mut self, chunk: StreamChunk) -> Result<bool> {
573 let mut has_pending_update = false;
574 for (op, row) in chunk.rows() {
575 let id = match self.id_for_row(&row) {
576 Ok(id) => id,
577 Err(err) => {
578 tracing::warn!(error = %err.as_report(), "skip turbopuffer row with invalid document id");
579 continue;
580 }
581 };
582 let url = match self.url_for_row(&row) {
583 Ok(url) => url,
584 Err(err) => {
585 tracing::warn!(error = %err.as_report(), "skip turbopuffer row with invalid namespace");
586 continue;
587 }
588 };
589 let compacted_op = match op {
590 Op::Insert | Op::UpdateInsert => {
591 let upsert_row = match self.upsert_row(&row, id.clone()) {
592 Ok(row) => row,
593 Err(err) => {
594 tracing::warn!(error = %err.as_report(), "skip turbopuffer row failed to encode upsert payload");
595 continue;
596 }
597 };
598 CompactedOp::Upsert(upsert_row)
599 }
600 Op::Delete | Op::UpdateDelete => CompactedOp::Delete,
601 };
602 self.pending_batches
603 .entry(url)
604 .or_default()
605 .insert(id, compacted_op);
606 has_pending_update = true;
607 }
608
609 Ok(has_pending_update)
610 }
611
612 fn pending_row_count(&self) -> usize {
613 self.pending_batches.values().map(HashMap::len).sum()
614 }
615
616 fn should_flush_by_size(&self) -> bool {
617 self.pending_row_count() >= self.write_batch_size
618 }
619
620 fn is_empty(&self) -> bool {
621 self.pending_batches.is_empty()
622 }
623
624 async fn flush_all(&mut self) -> Result<()> {
625 let batches = std::mem::take(&mut self.pending_batches);
626 let client = self.client.clone();
627 try_join_all(batches.into_iter().filter_map(|(url, batch)| {
628 let (upsert_rows, deletes) = batch_into_request_parts(batch);
629 if upsert_rows.is_empty() && deletes.is_empty() {
630 return None;
631 }
632
633 let client = client.clone();
634 let body = self.request_body(upsert_rows, deletes);
635 Some(async move { send_turbopuffer_request(client, url, body).await })
636 }))
637 .await?;
638 Ok(())
639 }
640}
641
642async fn send_turbopuffer_request(client: reqwest::Client, url: String, body: Value) -> Result<()> {
643 let resp = client
644 .post(url)
645 .json(&body)
646 .send()
647 .await
648 .context("turbopuffer write request failed")
649 .map_err(SinkError::Http)?;
650
651 if !resp.status().is_success() {
652 let status = resp.status();
653 let body = resp.text().await.unwrap_or_default();
654 return Err(SinkError::Http(anyhow!(
655 "Turbopuffer sink received non-success response: {} {}",
656 status,
657 body
658 )));
659 }
660 Ok(())
661}
662
663#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize)]
664#[serde(untagged)]
665enum DocumentId {
666 U64(u64),
667 String(String),
668}
669
670#[derive(Debug)]
671enum CompactedOp {
672 Upsert(Map<String, Value>),
673 Delete,
674}
675
676fn batch_into_request_parts(
677 batch: HashMap<DocumentId, CompactedOp>,
678) -> (Vec<Map<String, Value>>, Vec<DocumentId>) {
679 let mut upsert_rows = Vec::new();
680 let mut deletes = Vec::new();
681 for (id, op) in batch {
682 match op {
683 CompactedOp::Upsert(row) => upsert_rows.push(row),
684 CompactedOp::Delete => deletes.push(id),
685 }
686 }
687 (upsert_rows, deletes)
688}
689
690fn document_id_from_i64(value: i64) -> DocumentId {
691 if value < 0 {
692 tracing::warn!(
693 value,
694 "cast negative turbopuffer integer document id to unsigned integer"
695 );
696 }
697 DocumentId::U64(value as u64)
698}
699
700fn validate_namespace(namespace: &str) -> Result<()> {
701 if namespace.is_empty() || namespace.len() > 128 {
702 return Err(SinkError::Config(anyhow!(
703 "Turbopuffer namespace must be 1 to 128 bytes"
704 )));
705 }
706 if !namespace
707 .bytes()
708 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
709 {
710 return Err(SinkError::Config(anyhow!(
711 "Turbopuffer namespace must match [A-Za-z0-9-_.]{{1,128}}"
712 )));
713 }
714 Ok(())
715}
716
717fn parse_column_selection(
718 value: Option<&str>,
719 schema: &Schema,
720 attribute_indices: &[usize],
721) -> Result<HashSet<String>> {
722 let attribute_names = attribute_indices
723 .iter()
724 .map(|index| schema[*index].name.as_str())
725 .collect::<HashSet<_>>();
726 let columns: HashSet<String> = match value {
727 Some(value) if value.trim() == "*" => {
728 return Ok(attribute_names
729 .into_iter()
730 .map(str::to_owned)
731 .collect::<HashSet<_>>());
732 }
733 Some(value) => value
734 .split(',')
735 .map(str::trim)
736 .filter(|name| !name.is_empty())
737 .map(str::to_owned)
738 .collect(),
739 None => return Ok(HashSet::new()),
740 };
741 for column in &columns {
742 if !attribute_names.contains(column.as_str()) {
743 return Err(SinkError::Config(anyhow!(
744 "Turbopuffer schema option references unknown attribute column '{}'",
745 column
746 )));
747 }
748 }
749 Ok(columns)
750}
751
752fn build_turbopuffer_schema(
753 schema: &Schema,
754 attribute_indices: &[usize],
755 full_text_search_columns: &HashSet<String>,
756 filterable_columns: &HashSet<String>,
757) -> Result<Value> {
758 let mut result = Map::new();
759 for index in attribute_indices {
760 let field = &schema[*index];
761 let mut config = Map::new();
762 let data_type = field.data_type();
763 let turbopuffer_type = turbopuffer_type(&data_type)?;
764 let is_vector = matches!(data_type, DataType::Vector(_));
765 let is_full_text_search = full_text_search_columns.contains(&field.name);
766 if is_full_text_search && !supports_full_text_search(&data_type) {
767 return Err(SinkError::Config(anyhow!(
768 "Turbopuffer full_text_search column '{}' must be string or []string",
769 field.name
770 )));
771 }
772 config.insert("type".to_owned(), Value::String(turbopuffer_type));
773 if filterable_columns.contains(&field.name) {
774 config.insert("filterable".to_owned(), Value::Bool(true));
775 }
776 if is_full_text_search {
777 config.insert("full_text_search".to_owned(), Value::Bool(true));
778 }
779 if is_vector {
780 config.insert("ann".to_owned(), Value::Bool(true));
781 }
782 result.insert(field.name.clone(), Value::Object(config));
783 }
784 Ok(Value::Object(result))
785}
786
787fn supports_full_text_search(data_type: &DataType) -> bool {
788 match data_type {
789 DataType::Varchar => true,
790 DataType::List(list_type) => matches!(list_type.elem(), DataType::Varchar),
791 _ => false,
792 }
793}
794
795fn turbopuffer_type(data_type: &DataType) -> Result<String> {
821 match data_type {
822 DataType::Boolean => Ok("bool".to_owned()),
823 DataType::Int16 | DataType::Int32 | DataType::Int64 | DataType::Serial => {
824 Ok("int".to_owned())
825 }
826 DataType::Float32 | DataType::Float64 | DataType::Decimal => Ok("float".to_owned()),
827 DataType::Varchar => Ok("string".to_owned()),
828 DataType::Date | DataType::Timestamp | DataType::Timestamptz => Ok("datetime".to_owned()),
829 DataType::List(list_type) => match list_type.elem() {
830 DataType::Boolean => Ok("[]bool".to_owned()),
831 DataType::Int16 | DataType::Int32 | DataType::Int64 | DataType::Serial => {
832 Ok("[]int".to_owned())
833 }
834 DataType::Float32 | DataType::Float64 | DataType::Decimal => Ok("[]float".to_owned()),
835 DataType::Varchar => Ok("[]string".to_owned()),
836 DataType::Date | DataType::Timestamp | DataType::Timestamptz => {
837 Ok("[]datetime".to_owned())
838 }
839 elem_type => Err(unsupported_type(&format!("list element {:?}", elem_type))),
840 },
841 DataType::Vector(dimension) => Ok(format!("[{}]f32", dimension)),
842 data_type => Err(unsupported_type(&format!("{:?}", data_type))),
843 }
844}
845
846fn unsupported_type(data_type: &str) -> SinkError {
847 SinkError::Config(anyhow!(
848 "Turbopuffer sink does not support column type {}",
849 data_type
850 ))
851}
852
853#[cfg(test)]
854mod tests {
855 #[cfg(not(madsim))]
856 use std::collections::VecDeque;
857 #[cfg(not(madsim))]
858 use std::io::{Read, Write};
859 #[cfg(not(madsim))]
860 use std::net::TcpListener;
861 #[cfg(not(madsim))]
862 use std::sync::{Arc, Mutex, mpsc};
863 #[cfg(not(madsim))]
864 use std::thread;
865
866 #[cfg(not(madsim))]
867 use risingwave_common::array::StreamChunk;
868 #[cfg(not(madsim))]
869 use risingwave_common::array::stream_chunk::StreamChunkTestExt as _;
870 use risingwave_common::array::{ListValue, VectorVal};
871 #[cfg(not(madsim))]
872 use risingwave_common::bitmap::Bitmap;
873 use risingwave_common::catalog::Field;
874 use risingwave_common::row::OwnedRow;
875 use risingwave_common::types::{ListType, ScalarImpl, Timestamp, Timestamptz};
876 use serde_json::json;
877
878 use super::*;
879 #[cfg(not(madsim))]
880 use crate::sink::log_store::LogStoreResult;
881
882 #[test]
883 fn test_build_schema_flags() {
884 let schema = Schema::new(vec![
885 Field::with_name(DataType::Varchar, "id"),
886 Field::with_name(DataType::Varchar, "body"),
887 Field::with_name(DataType::List(ListType::new(DataType::Varchar)), "tags"),
888 Field::with_name(DataType::Boolean, "flag"),
889 Field::with_name(DataType::Vector(384), "vector"),
890 ]);
891 let generated = build_turbopuffer_schema(
892 &schema,
893 &[1, 2, 3, 4],
894 &parse_column_selection(Some("body,tags"), &schema, &[1, 2, 3, 4]).unwrap(),
895 &parse_column_selection(Some("*"), &schema, &[1, 2, 3, 4]).unwrap(),
896 )
897 .unwrap();
898
899 assert_eq!(generated["body"]["type"], json!("string"));
900 assert_eq!(generated["body"]["filterable"], json!(true));
901 assert_eq!(generated["body"]["full_text_search"], json!(true));
902 assert_eq!(generated["tags"]["type"], json!("[]string"));
903 assert_eq!(generated["tags"]["full_text_search"], json!(true));
904 assert_eq!(generated["vector"]["type"], json!("[384]f32"));
905 assert_eq!(generated["vector"]["ann"], json!(true));
906 }
907
908 #[cfg(not(madsim))]
909 #[tokio::test]
910 async fn test_write_chunk_buffers_until_flush_and_posts_payload_and_headers() {
911 let (base_url, request_rx, server_thread) = spawn_mock_http_server(1);
912 let schema = Schema::new(vec![
913 Field::with_name(DataType::Varchar, "id"),
914 Field::with_name(DataType::Varchar, "body"),
915 Field::with_name(DataType::Varchar, "workspace_id"),
916 ]);
917 let payload_indices = vec![1];
918 let generated_schema = build_turbopuffer_schema(
919 &schema,
920 &payload_indices,
921 &parse_column_selection(Some("body"), &schema, &payload_indices).unwrap(),
922 &parse_column_selection(Some("*"), &schema, &payload_indices).unwrap(),
923 )
924 .unwrap();
925 let config = TurbopufferConfig {
926 base_url,
927 namespace: None,
928 namespace_column: Some("workspace_id".to_owned()),
929 api_key: "tpuf_test_key".to_owned(),
930 distance_metric: None,
931 disable_backpressure: Some(true),
932 full_text_search_columns: Some("body".to_owned()),
933 filterable_columns: Some("*".to_owned()),
934 write_batch_size: DEFAULT_WRITE_BATCH_SIZE,
935 max_linger_second: DEFAULT_MAX_LINGER_SECOND,
936 r#type: "upsert".to_owned(),
937 };
938 let mut writer = TurbopufferSinkWriter::new(
939 config,
940 schema,
941 0,
942 TurbopufferNamespace::Dynamic { index: 2 },
943 payload_indices,
944 generated_schema,
945 DEFAULT_WRITE_BATCH_SIZE,
946 Duration::from_secs(DEFAULT_MAX_LINGER_SECOND),
947 )
948 .unwrap();
949 let chunk = StreamChunk::from_pretty(
950 "T T T
951 U- old-id old_body ns_1
952 U+ new-id new_body ns_1",
953 );
954
955 writer.write_chunk(chunk).unwrap();
956 assert!(request_rx.try_recv().is_err());
957 writer.flush_all().await.unwrap();
958 let request = request_rx.recv().unwrap();
959 server_thread.join().unwrap();
960
961 assert!(request.starts_with("post /v2/namespaces/ns_1 http/1.1"));
962 assert!(request.contains("authorization: bearer tpuf_test_key"));
963 assert!(request.contains("content-type: application/json"));
964
965 let body = request.split("\r\n\r\n").nth(1).unwrap();
966 let body: Value = serde_json::from_str(body).unwrap();
967 assert_eq!(body["disable_backpressure"], json!(true));
968 assert_eq!(body["schema"]["body"]["type"], json!("string"));
969 assert_eq!(body["schema"]["body"]["filterable"], json!(true));
970 assert_eq!(body["schema"]["body"]["full_text_search"], json!(true));
971 assert_eq!(body["deletes"], json!(["old-id"]));
972 assert_eq!(body["upsert_rows"].as_array().unwrap().len(), 1);
973 assert_eq!(body["upsert_rows"][0]["id"], json!("new-id"));
974 assert_eq!(body["upsert_rows"][0]["body"], json!("new_body"));
975 assert!(body.get("distance_metric").is_none());
976 }
977
978 #[test]
979 fn test_config_defaults_and_validation() {
980 let config = TurbopufferConfig::from_btreemap(BTreeMap::from([
981 ("base_url".to_owned(), "http://127.0.0.1:0".to_owned()),
982 ("namespace".to_owned(), "ns".to_owned()),
983 ("api_key".to_owned(), "key".to_owned()),
984 ("type".to_owned(), "upsert".to_owned()),
985 ]))
986 .unwrap();
987 assert_eq!(config.write_batch_size, DEFAULT_WRITE_BATCH_SIZE);
988 assert_eq!(config.max_linger_second, DEFAULT_MAX_LINGER_SECOND);
989
990 let err = TurbopufferConfig::from_btreemap(BTreeMap::from([
991 ("base_url".to_owned(), "http://127.0.0.1:0".to_owned()),
992 ("namespace".to_owned(), "ns".to_owned()),
993 ("api_key".to_owned(), "key".to_owned()),
994 ("type".to_owned(), "upsert".to_owned()),
995 ("write_batch_size".to_owned(), "0".to_owned()),
996 ]))
997 .unwrap_err();
998 assert!(err.to_string().contains("write_batch_size"));
999
1000 let err = TurbopufferConfig::from_btreemap(BTreeMap::from([
1001 ("base_url".to_owned(), "http://127.0.0.1:0".to_owned()),
1002 ("namespace".to_owned(), "ns".to_owned()),
1003 ("api_key".to_owned(), "key".to_owned()),
1004 ("type".to_owned(), "upsert".to_owned()),
1005 ("max_linger_second".to_owned(), "0".to_owned()),
1006 ]))
1007 .unwrap_err();
1008 assert!(err.to_string().contains("max_linger_second"));
1009 }
1010
1011 #[test]
1012 fn test_requires_sink_decouple() {
1013 assert!(TurbopufferSink::is_sink_decouple(&SinkDecouple::Default).unwrap());
1014 assert!(TurbopufferSink::is_sink_decouple(&SinkDecouple::Enable).unwrap());
1015 let err = TurbopufferSink::is_sink_decouple(&SinkDecouple::Disable).unwrap_err();
1016 assert!(
1017 err.to_string()
1018 .contains("Turbopuffer sink can only be created with sink_decouple enabled")
1019 );
1020 }
1021
1022 #[cfg(not(madsim))]
1023 #[tokio::test]
1024 async fn test_global_threshold_flushes_all_namespaces() {
1025 let (base_url, request_rx, server_thread) = spawn_mock_http_server(2);
1026 let schema = Schema::new(vec![
1027 Field::with_name(DataType::Varchar, "id"),
1028 Field::with_name(DataType::Varchar, "body"),
1029 Field::with_name(DataType::Varchar, "workspace_id"),
1030 ]);
1031 let generated_schema =
1032 build_turbopuffer_schema(&schema, &[1], &HashSet::new(), &HashSet::new()).unwrap();
1033 let config = TurbopufferConfig {
1034 base_url,
1035 namespace: None,
1036 namespace_column: Some("workspace_id".to_owned()),
1037 api_key: "tpuf_test_key".to_owned(),
1038 distance_metric: None,
1039 disable_backpressure: None,
1040 full_text_search_columns: None,
1041 filterable_columns: None,
1042 write_batch_size: 2,
1043 max_linger_second: DEFAULT_MAX_LINGER_SECOND,
1044 r#type: "upsert".to_owned(),
1045 };
1046 let mut writer = TurbopufferSinkWriter::new(
1047 config,
1048 schema,
1049 0,
1050 TurbopufferNamespace::Dynamic { index: 2 },
1051 vec![1],
1052 generated_schema,
1053 2,
1054 Duration::from_secs(DEFAULT_MAX_LINGER_SECOND),
1055 )
1056 .unwrap();
1057
1058 writer
1059 .write_chunk(StreamChunk::from_pretty(
1060 " T T T
1061 + a1 body ns_a
1062 + b1 body ns_b",
1063 ))
1064 .unwrap();
1065 assert!(writer.should_flush_by_size());
1066 writer.flush_all().await.unwrap();
1067
1068 let requests = [request_rx.recv().unwrap(), request_rx.recv().unwrap()];
1069 server_thread.join().unwrap();
1070 assert!(
1071 requests
1072 .iter()
1073 .any(|request| request.starts_with("post /v2/namespaces/ns_a http/1.1"))
1074 );
1075 assert!(
1076 requests
1077 .iter()
1078 .any(|request| request.starts_with("post /v2/namespaces/ns_b http/1.1"))
1079 );
1080 assert!(writer.pending_batches.is_empty());
1081 }
1082
1083 #[cfg(not(madsim))]
1084 #[tokio::test]
1085 async fn test_upsert_compacts_across_chunks() {
1086 let (base_url, request_rx, server_thread) = spawn_mock_http_server(1);
1087 let schema = Schema::new(vec![
1088 Field::with_name(DataType::Varchar, "id"),
1089 Field::with_name(DataType::Varchar, "body"),
1090 ]);
1091 let generated_schema =
1092 build_turbopuffer_schema(&schema, &[1], &HashSet::new(), &HashSet::new()).unwrap();
1093 let config = TurbopufferConfig {
1094 base_url,
1095 namespace: Some("ns".to_owned()),
1096 namespace_column: None,
1097 api_key: "tpuf_test_key".to_owned(),
1098 distance_metric: None,
1099 disable_backpressure: None,
1100 full_text_search_columns: None,
1101 filterable_columns: None,
1102 write_batch_size: DEFAULT_WRITE_BATCH_SIZE,
1103 max_linger_second: DEFAULT_MAX_LINGER_SECOND,
1104 r#type: "upsert".to_owned(),
1105 };
1106 let mut writer = TurbopufferSinkWriter::new(
1107 config,
1108 schema,
1109 0,
1110 TurbopufferNamespace::Static("ns".to_owned()),
1111 vec![1],
1112 generated_schema,
1113 DEFAULT_WRITE_BATCH_SIZE,
1114 Duration::from_secs(DEFAULT_MAX_LINGER_SECOND),
1115 )
1116 .unwrap();
1117
1118 writer
1119 .write_chunk(StreamChunk::from_pretty(
1120 " T T
1121 + id body1",
1122 ))
1123 .unwrap();
1124 writer
1125 .write_chunk(StreamChunk::from_pretty(
1126 " T T
1127 - id body1
1128 + id body2",
1129 ))
1130 .unwrap();
1131 writer.flush_all().await.unwrap();
1132
1133 let request = request_rx.recv().unwrap();
1134 server_thread.join().unwrap();
1135 let body = request.split("\r\n\r\n").nth(1).unwrap();
1136 let body: Value = serde_json::from_str(body).unwrap();
1137 assert!(body.get("deletes").is_none());
1138 assert_eq!(body["upsert_rows"].as_array().unwrap().len(), 1);
1139 assert_eq!(body["upsert_rows"][0]["id"], json!("id"));
1140 assert_eq!(body["upsert_rows"][0]["body"], json!("body2"));
1141 }
1142
1143 #[cfg(not(madsim))]
1144 #[tokio::test]
1145 async fn test_log_sinker_flushes_after_linger() {
1146 let (base_url, request_rx, server_thread) = spawn_mock_http_server(1);
1147 let writer = new_test_static_writer_with_linger(
1148 base_url,
1149 DEFAULT_WRITE_BATCH_SIZE,
1150 Duration::from_millis(1),
1151 );
1152 let truncates = Arc::new(Mutex::new(Vec::new()));
1153 let reader = TestSinkLogReader::new(
1154 vec![
1155 (
1156 1,
1157 LogStoreReadItem::StreamChunk {
1158 chunk: StreamChunk::from_pretty(
1159 " T T
1160 + id body",
1161 ),
1162 chunk_id: 0,
1163 },
1164 ),
1165 (
1166 2,
1167 LogStoreReadItem::Barrier {
1168 is_checkpoint: false,
1169 new_vnode_bitmap: None,
1170 is_stop: false,
1171 schema_change: None,
1172 },
1173 ),
1174 ],
1175 truncates.clone(),
1176 )
1177 .pending_on_empty();
1178 tokio::time::timeout(
1179 Duration::from_millis(200),
1180 TurbopufferLogSinker::new(writer, SinkWriterMetrics::for_test())
1181 .consume_log_and_sink(reader),
1182 )
1183 .await
1184 .unwrap_err();
1185
1186 let request = request_rx.recv().unwrap();
1187 server_thread.join().unwrap();
1188 let body = request.split("\r\n\r\n").nth(1).unwrap();
1189 let body: Value = serde_json::from_str(body).unwrap();
1190 assert_eq!(body["upsert_rows"].as_array().unwrap().len(), 1);
1191 assert_eq!(
1192 *truncates.lock().unwrap(),
1193 vec![TruncateOffset::Barrier { epoch: 2 }]
1194 );
1195 }
1196
1197 #[cfg(not(madsim))]
1198 #[tokio::test]
1199 async fn test_log_sinker_truncates_latest_chunk_after_threshold_flush() {
1200 let (base_url, _request_rx, server_thread) = spawn_mock_http_server(1);
1201 let writer = new_test_static_writer(base_url, 1);
1202 let truncates = Arc::new(Mutex::new(Vec::new()));
1203 let reader = TestSinkLogReader::new(
1204 vec![(
1205 1,
1206 LogStoreReadItem::StreamChunk {
1207 chunk: StreamChunk::from_pretty(
1208 " T T
1209 + id body",
1210 ),
1211 chunk_id: 7,
1212 },
1213 )],
1214 truncates.clone(),
1215 );
1216 let err = TurbopufferLogSinker::new(writer, SinkWriterMetrics::for_test())
1217 .consume_log_and_sink(reader)
1218 .await
1219 .unwrap_err();
1220 assert!(err.to_string().contains("done"));
1221 server_thread.join().unwrap();
1222 assert_eq!(
1223 *truncates.lock().unwrap(),
1224 vec![TruncateOffset::Chunk {
1225 epoch: 1,
1226 chunk_id: 7
1227 }]
1228 );
1229 }
1230
1231 #[cfg(not(madsim))]
1232 #[tokio::test]
1233 async fn test_log_sinker_flushes_on_vnode_bitmap_change() {
1234 let (base_url, request_rx, server_thread) = spawn_mock_http_server(1);
1235 let writer = new_test_static_writer(base_url, DEFAULT_WRITE_BATCH_SIZE);
1236 let truncates = Arc::new(Mutex::new(Vec::new()));
1237 let reader = TestSinkLogReader::new(
1238 vec![
1239 (
1240 1,
1241 LogStoreReadItem::StreamChunk {
1242 chunk: StreamChunk::from_pretty(
1243 " T T
1244 + id body",
1245 ),
1246 chunk_id: 0,
1247 },
1248 ),
1249 (
1250 2,
1251 LogStoreReadItem::Barrier {
1252 is_checkpoint: false,
1253 new_vnode_bitmap: Some(Arc::new(Bitmap::ones(1))),
1254 is_stop: false,
1255 schema_change: None,
1256 },
1257 ),
1258 ],
1259 truncates.clone(),
1260 );
1261 let err = TurbopufferLogSinker::new(writer, SinkWriterMetrics::for_test())
1262 .consume_log_and_sink(reader)
1263 .await
1264 .unwrap_err();
1265 assert!(err.to_string().contains("done"));
1266
1267 let request = request_rx.recv().unwrap();
1268 server_thread.join().unwrap();
1269 let body = request.split("\r\n\r\n").nth(1).unwrap();
1270 let body: Value = serde_json::from_str(body).unwrap();
1271 assert_eq!(body["upsert_rows"].as_array().unwrap().len(), 1);
1272 assert_eq!(
1273 *truncates.lock().unwrap(),
1274 vec![TruncateOffset::Barrier { epoch: 2 }]
1275 );
1276 }
1277
1278 #[test]
1279 fn test_decimal_and_serial_schema_types() {
1280 assert_eq!(turbopuffer_type(&DataType::Decimal).unwrap(), "float");
1281 assert_eq!(turbopuffer_type(&DataType::Serial).unwrap(), "int");
1282 assert_eq!(
1283 turbopuffer_type(&DataType::List(ListType::new(DataType::Decimal))).unwrap(),
1284 "[]float"
1285 );
1286 assert_eq!(
1287 turbopuffer_type(&DataType::List(ListType::new(DataType::Serial))).unwrap(),
1288 "[]int"
1289 );
1290 }
1291
1292 #[test]
1293 fn test_manual_http_sink_schema_and_payload_shape() {
1294 let schema = Schema::new(vec![
1295 Field::with_name(DataType::Varchar, "id"),
1296 Field::with_name(DataType::Varchar, "namespace_id"),
1297 Field::with_name(DataType::Varchar, "record_id"),
1298 Field::with_name(DataType::Varchar, "content"),
1299 Field::with_name(
1300 DataType::List(ListType::new(DataType::Varchar)),
1301 "content_segments",
1302 ),
1303 Field::with_name(DataType::Varchar, "user_name"),
1304 Field::with_name(DataType::Varchar, "user_identifier"),
1305 Field::with_name(DataType::Varchar, "title"),
1306 Field::with_name(DataType::Boolean, "is_flagged"),
1307 Field::with_name(DataType::Boolean, "is_resolved"),
1308 Field::with_name(DataType::Timestamp, "local_event_time"),
1309 Field::with_name(DataType::Timestamptz, "event_time"),
1310 Field::with_name(DataType::Int64, "metric_a_count"),
1311 Field::with_name(DataType::Int64, "metric_b_count"),
1312 Field::with_name(DataType::List(ListType::new(DataType::Varchar)), "labels"),
1313 Field::with_name(DataType::Varchar, "group_id"),
1314 Field::with_name(DataType::Vector(384), "vector"),
1315 ]);
1316 let attribute_indices = (2..schema.len()).collect_vec();
1317 let full_text_search_columns = parse_column_selection(
1318 Some("content,content_segments,user_name,user_identifier,title"),
1319 &schema,
1320 &attribute_indices,
1321 )
1322 .unwrap();
1323 let filterable_columns =
1324 parse_column_selection(Some("*"), &schema, &attribute_indices).unwrap();
1325 let generated_schema = build_turbopuffer_schema(
1326 &schema,
1327 &attribute_indices,
1328 &full_text_search_columns,
1329 &filterable_columns,
1330 )
1331 .unwrap();
1332
1333 assert_eq!(
1334 generated_schema,
1335 json!({
1336 "record_id": {"type": "string", "filterable": true},
1337 "content": {"type": "string", "filterable": true, "full_text_search": true},
1338 "content_segments": {"type": "[]string", "filterable": true, "full_text_search": true},
1339 "user_name": {"type": "string", "filterable": true, "full_text_search": true},
1340 "user_identifier": {"type": "string", "filterable": true, "full_text_search": true},
1341 "title": {"type": "string", "filterable": true, "full_text_search": true},
1342 "is_flagged": {"type": "bool", "filterable": true},
1343 "is_resolved": {"type": "bool", "filterable": true},
1344 "local_event_time": {"type": "datetime", "filterable": true},
1345 "event_time": {"type": "datetime", "filterable": true},
1346 "metric_a_count": {"type": "int", "filterable": true},
1347 "metric_b_count": {"type": "int", "filterable": true},
1348 "labels": {"type": "[]string", "filterable": true},
1349 "group_id": {"type": "string", "filterable": true},
1350 "vector": {"type": "[384]f32", "filterable": true, "ann": true}
1351 })
1352 );
1353
1354 let config = TurbopufferConfig {
1355 base_url: "http://127.0.0.1:0".to_owned(),
1356 namespace: None,
1357 namespace_column: Some("namespace_id".to_owned()),
1358 api_key: "tpuf_test_key".to_owned(),
1359 distance_metric: Some("cosine_distance".to_owned()),
1360 disable_backpressure: Some(true),
1361 full_text_search_columns: Some(
1362 "content,content_segments,user_name,user_identifier,title".to_owned(),
1363 ),
1364 filterable_columns: Some("*".to_owned()),
1365 write_batch_size: DEFAULT_WRITE_BATCH_SIZE,
1366 max_linger_second: DEFAULT_MAX_LINGER_SECOND,
1367 r#type: "upsert".to_owned(),
1368 };
1369 let writer = TurbopufferSinkWriter::new(
1370 config,
1371 schema,
1372 0,
1373 TurbopufferNamespace::Dynamic { index: 1 },
1374 attribute_indices,
1375 generated_schema.clone(),
1376 DEFAULT_WRITE_BATCH_SIZE,
1377 Duration::from_secs(DEFAULT_MAX_LINGER_SECOND),
1378 )
1379 .unwrap();
1380 let vector =
1381 VectorVal::from_text(&format!("[{}]", vec!["0.25"; 384].join(",")), 384).unwrap();
1382 let row = OwnedRow::new(vec![
1383 Some(ScalarImpl::Utf8("doc-1".into())),
1384 Some(ScalarImpl::Utf8("namespace-1".into())),
1385 Some(ScalarImpl::Utf8("record-1".into())),
1386 Some(ScalarImpl::Utf8("content text".into())),
1387 Some(ScalarImpl::List(ListValue::from_iter([
1388 "segment a",
1389 "segment b",
1390 ]))),
1391 Some(ScalarImpl::Utf8("user-a".into())),
1392 Some(ScalarImpl::Utf8("user-1".into())),
1393 Some(ScalarImpl::Utf8("title".into())),
1394 Some(ScalarImpl::Bool(true)),
1395 Some(ScalarImpl::Bool(false)),
1396 Some(ScalarImpl::Timestamp(Timestamp::from_timestamp_uncheck(
1397 1_781_582_706,
1398 123_456_789,
1399 ))),
1400 Some(ScalarImpl::Timestamptz(Timestamptz::from_micros(
1401 1_781_598_707_000_000,
1402 ))),
1403 Some(ScalarImpl::Int64(12345)),
1404 Some(ScalarImpl::Int64(67890)),
1405 Some(ScalarImpl::List(ListValue::from_iter([
1406 "label-a", "label-b",
1407 ]))),
1408 Some(ScalarImpl::Utf8("group-1".into())),
1409 Some(ScalarImpl::Vector(vector)),
1410 ]);
1411 let id = writer.id_for_row(&row).unwrap();
1412 let upsert_row = writer.upsert_row(&row, id).unwrap();
1413 let body = writer.request_body(vec![upsert_row], Vec::new());
1414
1415 assert_eq!(body["distance_metric"], json!("cosine_distance"));
1416 assert_eq!(body["disable_backpressure"], json!(true));
1417 assert_eq!(body["schema"], generated_schema);
1418 assert_eq!(body["upsert_rows"][0]["id"], json!("doc-1"));
1419 assert_eq!(body["upsert_rows"][0]["content"], json!("content text"));
1420 assert_eq!(
1421 body["upsert_rows"][0]["content_segments"],
1422 json!(["segment a", "segment b"])
1423 );
1424 assert_eq!(body["upsert_rows"][0]["is_flagged"], json!(true));
1425 assert_eq!(body["upsert_rows"][0]["is_resolved"], json!(false));
1426 assert_eq!(body["upsert_rows"][0]["metric_a_count"], json!(12345));
1427 assert_eq!(
1428 body["upsert_rows"][0]["local_event_time"],
1429 json!("2026-06-16T04:05:06.123456")
1430 );
1431 assert_eq!(
1432 body["upsert_rows"][0]["event_time"],
1433 json!("2026-06-16T08:31:47.000000Z")
1434 );
1435 assert_eq!(
1436 body["upsert_rows"][0]["vector"].as_array().unwrap().len(),
1437 384
1438 );
1439 assert_eq!(body["upsert_rows"][0]["vector"][0], json!(0.25));
1440 }
1441
1442 #[cfg(not(madsim))]
1443 fn spawn_mock_http_server(
1444 expected_requests: usize,
1445 ) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
1446 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1447 let addr = listener.local_addr().unwrap();
1448 let (request_tx, request_rx) = mpsc::channel();
1449 let server_thread = thread::spawn(move || {
1450 for _ in 0..expected_requests {
1451 let (mut stream, _) = listener.accept().unwrap();
1452 let mut buf = Vec::new();
1453 let header_end = loop {
1454 let mut tmp = [0; 1024];
1455 let read = stream.read(&mut tmp).unwrap();
1456 assert_ne!(read, 0);
1457 buf.extend_from_slice(&tmp[..read]);
1458 if let Some(header_end) = find_header_end(&buf) {
1459 break header_end;
1460 }
1461 };
1462 let headers = String::from_utf8_lossy(&buf[..header_end]);
1463 let content_length = headers
1464 .lines()
1465 .find_map(|line| {
1466 let (name, value) = line.split_once(':')?;
1467 name.eq_ignore_ascii_case("content-length")
1468 .then(|| value.trim().parse::<usize>().unwrap())
1469 })
1470 .unwrap();
1471 while buf.len() < header_end + 4 + content_length {
1472 let mut tmp = [0; 1024];
1473 let read = stream.read(&mut tmp).unwrap();
1474 assert_ne!(read, 0);
1475 buf.extend_from_slice(&tmp[..read]);
1476 }
1477 let request = String::from_utf8(buf[..header_end + 4 + content_length].to_vec())
1478 .expect("HTTP request should be utf8");
1479 request_tx.send(request.to_lowercase()).unwrap();
1480 stream
1481 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
1482 .unwrap();
1483 }
1484 });
1485 (format!("http://{}", addr), request_rx, server_thread)
1486 }
1487
1488 #[cfg(not(madsim))]
1489 fn find_header_end(buf: &[u8]) -> Option<usize> {
1490 buf.windows(4).position(|window| window == b"\r\n\r\n")
1491 }
1492
1493 #[cfg(not(madsim))]
1494 fn new_test_static_writer(base_url: String, write_batch_size: usize) -> TurbopufferSinkWriter {
1495 new_test_static_writer_with_linger(
1496 base_url,
1497 write_batch_size,
1498 Duration::from_secs(DEFAULT_MAX_LINGER_SECOND),
1499 )
1500 }
1501
1502 #[cfg(not(madsim))]
1503 fn new_test_static_writer_with_linger(
1504 base_url: String,
1505 write_batch_size: usize,
1506 max_linger: Duration,
1507 ) -> TurbopufferSinkWriter {
1508 let schema = Schema::new(vec![
1509 Field::with_name(DataType::Varchar, "id"),
1510 Field::with_name(DataType::Varchar, "body"),
1511 ]);
1512 let generated_schema =
1513 build_turbopuffer_schema(&schema, &[1], &HashSet::new(), &HashSet::new()).unwrap();
1514 let config = TurbopufferConfig {
1515 base_url,
1516 namespace: Some("ns".to_owned()),
1517 namespace_column: None,
1518 api_key: "tpuf_test_key".to_owned(),
1519 distance_metric: None,
1520 disable_backpressure: None,
1521 full_text_search_columns: None,
1522 filterable_columns: None,
1523 write_batch_size,
1524 max_linger_second: DEFAULT_MAX_LINGER_SECOND,
1525 r#type: "upsert".to_owned(),
1526 };
1527 TurbopufferSinkWriter::new(
1528 config,
1529 schema,
1530 0,
1531 TurbopufferNamespace::Static("ns".to_owned()),
1532 vec![1],
1533 generated_schema,
1534 write_batch_size,
1535 max_linger,
1536 )
1537 .unwrap()
1538 }
1539
1540 #[cfg(not(madsim))]
1541 struct TestSinkLogReader {
1542 items: VecDeque<(u64, LogStoreReadItem)>,
1543 truncates: Arc<Mutex<Vec<TruncateOffset>>>,
1544 pending_on_empty: bool,
1545 }
1546
1547 #[cfg(not(madsim))]
1548 impl TestSinkLogReader {
1549 fn new(
1550 items: Vec<(u64, LogStoreReadItem)>,
1551 truncates: Arc<Mutex<Vec<TruncateOffset>>>,
1552 ) -> Self {
1553 Self {
1554 items: items.into(),
1555 truncates,
1556 pending_on_empty: false,
1557 }
1558 }
1559
1560 fn pending_on_empty(mut self) -> Self {
1561 self.pending_on_empty = true;
1562 self
1563 }
1564 }
1565
1566 #[cfg(not(madsim))]
1567 impl SinkLogReader for TestSinkLogReader {
1568 async fn start_from(&mut self, _start_offset: Option<u64>) -> LogStoreResult<()> {
1569 Ok(())
1570 }
1571
1572 async fn next_item(&mut self) -> LogStoreResult<(u64, LogStoreReadItem)> {
1573 match self.items.pop_front() {
1574 Some(item) => Ok(item),
1575 None if self.pending_on_empty => pending().await,
1576 None => Err(anyhow!("done")),
1577 }
1578 }
1579
1580 fn truncate(&mut self, offset: TruncateOffset) -> LogStoreResult<()> {
1581 self.truncates.lock().unwrap().push(offset);
1582 Ok(())
1583 }
1584 }
1585}