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