Skip to main content

risingwave_connector/sink/
turbopuffer.rs

1// Copyright 2026 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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, // accept "append-only" or "upsert"
82
83    #[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        // Turbopuffer treats `id` as the document ID in write requests; it is not a schema
211        // attribute. Dynamic namespace is also metadata for routing, not a document attribute.
212        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        // This validates every document attribute type before the writer is created:
245        // `build_turbopuffer_schema` calls `turbopuffer_type` for each attribute and
246        // returns a config error for unsupported types.
247        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    // Turbopuffer document IDs are unsigned 64-bit integers, UUIDs, or strings up to 64 bytes.
516    // RisingWave UUID IDs can be represented with varchar.
517    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
819// Mapping from RisingWave attribute types to generated turbopuffer schema types and
820// the JSON value shapes sent to turbopuffer:
821//
822// | RisingWave type                  | turbopuffer type | JSON payload                  |
823// |----------------------------------|------------------|-------------------------------|
824// | boolean                          | bool             | boolean                       |
825// | int16, int32, int64              | int              | number                        |
826// | float32, float64                 | float            | number                        |
827// | varchar                          | string           | string                        |
828// | date                             | datetime         | string: YYYY-MM-DD            |
829// | timestamp                        | datetime         | ISO 8601 string without zone  |
830// | timestamptz                      | datetime         | RFC3339 UTC string            |
831// | boolean[]                        | []bool           | array of booleans             |
832// | int16[], int32[], int64[]        | []int            | array of numbers              |
833// | float32[], float64[]             | []float          | array of numbers              |
834// | varchar[]                        | []string         | array of strings              |
835// | date[], timestamp[], timestamptz[] | []datetime      | array of datetime strings     |
836// | vector(N)                        | [N]f32           | array of numbers              |
837// | serial                           | int              | number                        |
838// | decimal                          | float            | number, converted through f64 |
839// | serial[]                         | []int            | array of numbers              |
840// | decimal[]                        | []float          | array of f64-converted numbers|
841//
842// The primary key column is encoded separately as the turbopuffer document id, so
843// it does not participate in this schema mapping.
844fn 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}