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