Skip to main content

servers/grpc/
flight.rs

1// Copyright 2023 Greptime Team
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
15mod stream;
16
17use std::collections::HashMap;
18use std::pin::Pin;
19use std::sync::Arc;
20use std::task::{Context, Poll};
21
22use api::v1::GreptimeRequest;
23use arrow_flight::flight_service_server::FlightService;
24use arrow_flight::{
25    Action, ActionType, Criteria, Empty, FlightData, FlightDescriptor, FlightInfo,
26    HandshakeRequest, HandshakeResponse, PollInfo, PutResult, SchemaResult, Ticket,
27};
28use async_trait::async_trait;
29use bytes::{self, Bytes};
30use common_error::ext::ErrorExt;
31use common_grpc::flight::do_put::{DoPutMetadata, DoPutResponse};
32use common_grpc::flight::{
33    FLOW_EXTENSIONS_METADATA_KEY, FlightDecoder, FlightMessage, SNAPSHOT_SEQS_METADATA_KEY,
34};
35use common_memory_manager::MemoryGuard;
36use common_query::{Output, OutputData};
37use common_recordbatch::DfRecordBatch;
38use common_telemetry::debug;
39use common_telemetry::tracing::info_span;
40use common_telemetry::tracing_context::{FutureExt, TracingContext};
41use datatypes::arrow::datatypes::SchemaRef;
42use futures::{Stream, future, ready};
43use futures_util::{StreamExt, TryStreamExt};
44use prost::Message;
45use query::metrics::terminal_recordbatch_metrics_from_plan_if_requested;
46use query::options::FlowQueryExtensions;
47use session::context::{Channel, QueryContextRef};
48use snafu::{IntoError, OptionExt, ResultExt, ensure};
49use table::requests::AUTO_CREATE_TABLE_KEY;
50use table::table_name::TableName;
51use tokio::sync::mpsc;
52use tokio_stream::wrappers::ReceiverStream;
53use tonic::{Request, Response, Status, Streaming};
54
55use crate::error::{InvalidParameterSnafu, InvalidQuerySnafu, Result, ToJsonSnafu};
56pub use crate::grpc::flight::stream::{
57    FlightRecordBatchSource, FlightRecordBatchStream, FlightRecordBatchStreamInput,
58};
59use crate::grpc::greptime_handler::{
60    GreptimeRequestHandler, create_query_context, get_request_type,
61};
62use crate::grpc::{FlightCompression, TonicResult, context_auth};
63use crate::request_memory_limiter::ServerMemoryLimiter;
64use crate::request_memory_metrics::RequestMemoryMetrics;
65use crate::{error, hint_headers};
66
67pub type TonicStream<T> = Pin<Box<dyn Stream<Item = TonicResult<T>> + Send + 'static>>;
68
69/// A subset of [FlightService]
70#[async_trait]
71pub trait FlightCraft: Send + Sync + 'static {
72    async fn do_get(
73        &self,
74        request: Request<Ticket>,
75    ) -> TonicResult<Response<TonicStream<FlightData>>>;
76
77    async fn do_put(
78        &self,
79        request: Request<Streaming<FlightData>>,
80    ) -> TonicResult<Response<TonicStream<PutResult>>> {
81        let _ = request;
82        Err(Status::unimplemented("Not yet implemented"))
83    }
84}
85
86pub type FlightCraftRef = Arc<dyn FlightCraft>;
87
88pub struct FlightCraftWrapper<T: FlightCraft>(pub T);
89
90impl<T: FlightCraft> From<T> for FlightCraftWrapper<T> {
91    fn from(t: T) -> Self {
92        Self(t)
93    }
94}
95
96#[async_trait]
97impl FlightCraft for FlightCraftRef {
98    async fn do_get(
99        &self,
100        request: Request<Ticket>,
101    ) -> TonicResult<Response<TonicStream<FlightData>>> {
102        (**self).do_get(request).await
103    }
104
105    async fn do_put(
106        &self,
107        request: Request<Streaming<FlightData>>,
108    ) -> TonicResult<Response<TonicStream<PutResult>>> {
109        self.as_ref().do_put(request).await
110    }
111}
112
113#[async_trait]
114impl<T: FlightCraft> FlightService for FlightCraftWrapper<T> {
115    type HandshakeStream = TonicStream<HandshakeResponse>;
116
117    async fn handshake(
118        &self,
119        _: Request<Streaming<HandshakeRequest>>,
120    ) -> TonicResult<Response<Self::HandshakeStream>> {
121        Err(Status::unimplemented("Not yet implemented"))
122    }
123
124    type ListFlightsStream = TonicStream<FlightInfo>;
125
126    async fn list_flights(
127        &self,
128        _: Request<Criteria>,
129    ) -> TonicResult<Response<Self::ListFlightsStream>> {
130        Err(Status::unimplemented("Not yet implemented"))
131    }
132
133    async fn get_flight_info(
134        &self,
135        _: Request<FlightDescriptor>,
136    ) -> TonicResult<Response<FlightInfo>> {
137        Err(Status::unimplemented("Not yet implemented"))
138    }
139
140    async fn poll_flight_info(
141        &self,
142        _: Request<FlightDescriptor>,
143    ) -> TonicResult<Response<PollInfo>> {
144        Err(Status::unimplemented("Not yet implemented"))
145    }
146
147    async fn get_schema(
148        &self,
149        _: Request<FlightDescriptor>,
150    ) -> TonicResult<Response<SchemaResult>> {
151        Err(Status::unimplemented("Not yet implemented"))
152    }
153
154    type DoGetStream = TonicStream<FlightData>;
155
156    async fn do_get(&self, request: Request<Ticket>) -> TonicResult<Response<Self::DoGetStream>> {
157        self.0.do_get(request).await
158    }
159
160    type DoPutStream = TonicStream<PutResult>;
161
162    async fn do_put(
163        &self,
164        request: Request<Streaming<FlightData>>,
165    ) -> TonicResult<Response<Self::DoPutStream>> {
166        self.0.do_put(request).await
167    }
168
169    type DoExchangeStream = TonicStream<FlightData>;
170
171    async fn do_exchange(
172        &self,
173        _: Request<Streaming<FlightData>>,
174    ) -> TonicResult<Response<Self::DoExchangeStream>> {
175        Err(Status::unimplemented("Not yet implemented"))
176    }
177
178    type DoActionStream = TonicStream<arrow_flight::Result>;
179
180    async fn do_action(&self, _: Request<Action>) -> TonicResult<Response<Self::DoActionStream>> {
181        Err(Status::unimplemented("Not yet implemented"))
182    }
183
184    type ListActionsStream = TonicStream<ActionType>;
185
186    async fn list_actions(
187        &self,
188        _: Request<Empty>,
189    ) -> TonicResult<Response<Self::ListActionsStream>> {
190        Err(Status::unimplemented("Not yet implemented"))
191    }
192}
193
194#[async_trait]
195impl FlightCraft for GreptimeRequestHandler {
196    async fn do_get(
197        &self,
198        request: Request<Ticket>,
199    ) -> TonicResult<Response<TonicStream<FlightData>>> {
200        let mut hints = hint_headers::extract_hints(request.metadata());
201        hints = hint_headers::validate_public_hints(hints)?;
202        hints.extend(extract_flow_extensions(request.metadata())?);
203        let snapshot_seqs = extract_snapshot_seqs(request.metadata())?;
204        let channel = request
205            .extensions()
206            .get::<Channel>()
207            .copied()
208            .unwrap_or(Channel::Grpc);
209
210        let ticket = request.into_inner().ticket;
211        let request =
212            GreptimeRequest::decode(ticket.as_ref()).context(error::InvalidFlightTicketSnafu)?;
213        let query_ctx =
214            create_query_context(channel, request.header.as_ref(), hints, snapshot_seqs)?;
215        // Validate flow hint syntax at the transport boundary before dispatching the request.
216        // This does not authorize or execute anything; `handle_request()` below still performs
217        // the normal frontend handling and auth checks before query execution.
218        let flow_extensions = FlowQueryExtensions::parse_flow_extensions(&query_ctx.extensions())
219            .map_err(|e| Status::invalid_argument(e.output_msg()))?;
220        let should_emit_terminal_metrics = flow_extensions
221            .as_ref()
222            .is_some_and(|extensions| extensions.should_collect_region_watermark());
223
224        // The Grpc protocol pass query by Flight. It needs to be wrapped under a span, in order to record stream
225        let span = info_span!(
226            "GreptimeRequestHandler::do_get",
227            protocol = "grpc",
228            request_type = get_request_type(&request)
229        );
230        let flight_compression = self.flight_compression;
231        async {
232            let query = request.request.context(InvalidQuerySnafu {
233                reason: "Expecting non-empty GreptimeRequest.",
234            })?;
235            self.authenticate_request_with_query_ctx(request.header.as_ref(), &query_ctx)
236                .await?;
237            let output = self.handle_request_with_query_ctx(query, query_ctx.clone());
238            let stream = to_flight_data_stream(
239                output,
240                TracingContext::from_current_span(),
241                flight_compression,
242                query_ctx,
243                should_emit_terminal_metrics,
244            );
245            Ok(Response::new(stream))
246        }
247        .trace(span)
248        .await
249    }
250
251    async fn do_put(
252        &self,
253        request: Request<Streaming<FlightData>>,
254    ) -> TonicResult<Response<TonicStream<PutResult>>> {
255        let (headers, extensions, stream) = request.into_parts();
256
257        let limiter = extensions.get::<ServerMemoryLimiter>().cloned();
258
259        let mut query_ctx =
260            context_auth::create_query_context_from_grpc_metadata(&headers, &extensions)?;
261        // Bulk streams use the same schema-on-write switch as row inserts.
262        for (key, value) in hint_headers::extract_hints(&headers) {
263            if key == AUTO_CREATE_TABLE_KEY {
264                Arc::make_mut(&mut query_ctx).set_extension(key, value);
265            }
266        }
267        context_auth::check_auth(self.user_provider.clone(), &headers, query_ctx.clone()).await?;
268
269        const MAX_PENDING_RESPONSES: usize = 32;
270        let (tx, rx) = mpsc::channel::<TonicResult<DoPutResponse>>(MAX_PENDING_RESPONSES);
271
272        let stream = PutRecordBatchRequestStream::new(
273            stream,
274            query_ctx.current_catalog().to_string(),
275            query_ctx.current_schema(),
276            limiter,
277        )
278        .await?;
279        // Ack immediately when stream is created successfully (in Init state)
280        let _ = tx.send(Ok(DoPutResponse::new(0, 0, 0.0))).await;
281        self.put_record_batches(stream, tx, query_ctx).await;
282
283        let response = ReceiverStream::new(rx)
284            .and_then(|response| {
285                future::ready({
286                    serde_json::to_vec(&response)
287                        .context(ToJsonSnafu)
288                        .map(|x| PutResult {
289                            app_metadata: Bytes::from(x),
290                        })
291                        .map_err(Into::into)
292                })
293            })
294            .boxed();
295        Ok(Response::new(response))
296    }
297}
298
299pub struct PutRecordBatchRequest {
300    pub table_name: TableName,
301    pub request_id: i64,
302    pub timestamp_range: Option<(i64, i64)>,
303    pub record_batch: DfRecordBatch,
304    pub schema_bytes: Bytes,
305    pub flight_data: FlightData,
306    pub(crate) _guard: Option<MemoryGuard<RequestMemoryMetrics>>,
307}
308
309impl PutRecordBatchRequest {
310    fn try_new(
311        table_name: TableName,
312        record_batch: DfRecordBatch,
313        request_id: i64,
314        timestamp_range: Option<(i64, i64)>,
315        schema_bytes: Bytes,
316        flight_data: FlightData,
317        limiter: Option<&ServerMemoryLimiter>,
318    ) -> Result<Self> {
319        let memory_usage = flight_data.data_body.len()
320            + flight_data.app_metadata.len()
321            + flight_data.data_header.len();
322
323        let _guard = if let Some(limiter) = limiter {
324            let guard = limiter.try_acquire(memory_usage as u64).ok_or_else(|| {
325                let inner_err = common_memory_manager::Error::MemoryLimitExceeded {
326                    requested_bytes: memory_usage as u64,
327                    limit_bytes: limiter.limit_bytes(),
328                };
329                error::MemoryLimitExceededSnafu.into_error(inner_err)
330            })?;
331            Some(guard)
332        } else {
333            None
334        };
335
336        Ok(Self {
337            table_name,
338            request_id,
339            timestamp_range,
340            record_batch,
341            schema_bytes,
342            flight_data,
343            _guard,
344        })
345    }
346}
347
348pub struct PutRecordBatchRequestStream {
349    flight_data_stream: Streaming<FlightData>,
350    catalog: String,
351    schema_name: String,
352    limiter: Option<ServerMemoryLimiter>,
353    // Client now lazily sends schema data so we cannot eagerly wait for it.
354    // Instead, we need to decode while receiving record batches.
355    state: StreamState,
356}
357
358enum StreamState {
359    Init,
360    Ready {
361        table_name: TableName,
362        schema: SchemaRef,
363        schema_bytes: Bytes,
364        decoder: FlightDecoder,
365    },
366}
367
368impl PutRecordBatchRequestStream {
369    /// Creates a new `PutRecordBatchRequestStream` in Init state.
370    /// The stream will transition to Ready state when it receives the schema message.
371    pub async fn new(
372        flight_data_stream: Streaming<FlightData>,
373        catalog: String,
374        schema: String,
375        limiter: Option<ServerMemoryLimiter>,
376    ) -> TonicResult<Self> {
377        Ok(Self {
378            flight_data_stream,
379            catalog,
380            schema_name: schema,
381            limiter,
382            state: StreamState::Init,
383        })
384    }
385
386    /// Returns the table name extracted from the flight descriptor.
387    /// Returns None if the stream is still in Init state.
388    pub fn table_name(&self) -> Option<&TableName> {
389        match &self.state {
390            StreamState::Init => None,
391            StreamState::Ready { table_name, .. } => Some(table_name),
392        }
393    }
394
395    /// Returns the Arrow schema decoded from the first flight message.
396    /// Returns None if the stream is still in Init state.
397    pub fn schema(&self) -> Option<&SchemaRef> {
398        match &self.state {
399            StreamState::Init => None,
400            StreamState::Ready { schema, .. } => Some(schema),
401        }
402    }
403
404    /// Returns the raw schema bytes in IPC format.
405    /// Returns None if the stream is still in Init state.
406    pub fn schema_bytes(&self) -> Option<&Bytes> {
407        match &self.state {
408            StreamState::Init => None,
409            StreamState::Ready { schema_bytes, .. } => Some(schema_bytes),
410        }
411    }
412
413    fn extract_table_name(mut descriptor: FlightDescriptor) -> Result<String> {
414        ensure!(
415            descriptor.r#type == arrow_flight::flight_descriptor::DescriptorType::Path as i32,
416            InvalidParameterSnafu {
417                reason: "expect FlightDescriptor::type == 'Path' only",
418            }
419        );
420        ensure!(
421            descriptor.path.len() == 1,
422            InvalidParameterSnafu {
423                reason: "expect FlightDescriptor::path has only one table name",
424            }
425        );
426        Ok(descriptor.path.remove(0))
427    }
428}
429
430impl Stream for PutRecordBatchRequestStream {
431    type Item = TonicResult<PutRecordBatchRequest>;
432
433    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
434        loop {
435            let poll = ready!(self.flight_data_stream.poll_next_unpin(cx));
436
437            match poll {
438                Some(Ok(flight_data)) => {
439                    let limiter = self.limiter.clone();
440
441                    match &mut self.state {
442                        StreamState::Init => {
443                            // First message - expecting schema
444                            let flight_descriptor = match flight_data.flight_descriptor.as_ref() {
445                                Some(descriptor) => descriptor.clone(),
446                                None => {
447                                    return Poll::Ready(Some(Err(Status::failed_precondition(
448                                        "table to put is not found in flight descriptor",
449                                    ))));
450                                }
451                            };
452
453                            let table_name_str = match Self::extract_table_name(flight_descriptor) {
454                                Ok(name) => name,
455                                Err(e) => {
456                                    return Poll::Ready(Some(Err(Status::invalid_argument(
457                                        e.to_string(),
458                                    ))));
459                                }
460                            };
461                            let table_name = TableName::new(
462                                self.catalog.clone(),
463                                self.schema_name.clone(),
464                                table_name_str,
465                            );
466
467                            // Decode the schema
468                            let mut decoder = FlightDecoder::default();
469                            let schema_message = decoder.try_decode(&flight_data).map_err(|e| {
470                                Status::invalid_argument(format!("Failed to decode schema: {}", e))
471                            })?;
472
473                            match schema_message {
474                                Some(FlightMessage::Schema(schema)) => {
475                                    let schema_bytes = decoder.schema_bytes().ok_or_else(|| {
476                                        Status::internal(
477                                            "decoder should have schema bytes after decoding schema",
478                                        )
479                                    })?;
480
481                                    // Transition to Ready state with all necessary data
482                                    self.state = StreamState::Ready {
483                                        table_name,
484                                        schema,
485                                        schema_bytes,
486                                        decoder,
487                                    };
488                                    // Continue to next iteration to process RecordBatch messages
489                                    continue;
490                                }
491                                _ => {
492                                    return Poll::Ready(Some(Err(Status::failed_precondition(
493                                        "first message must be a Schema message",
494                                    ))));
495                                }
496                            }
497                        }
498                        StreamState::Ready {
499                            table_name,
500                            schema: _,
501                            schema_bytes,
502                            decoder,
503                        } => {
504                            // Extract request_id and time range from FlightData before decoding
505                            let metadata = if !flight_data.app_metadata.is_empty() {
506                                serde_json::from_slice::<DoPutMetadata>(&flight_data.app_metadata)
507                                    .ok()
508                            } else {
509                                None
510                            };
511                            let request_id = metadata
512                                .as_ref()
513                                .map(|meta| meta.request_id())
514                                .unwrap_or_default();
515                            let timestamp_range = metadata.and_then(|meta| meta.timestamp_range());
516
517                            // Decode FlightData to RecordBatch
518                            match decoder.try_decode(&flight_data) {
519                                Ok(Some(FlightMessage::RecordBatch(record_batch))) => {
520                                    let table_name = table_name.clone();
521                                    let schema_bytes = schema_bytes.clone();
522                                    return Poll::Ready(Some(
523                                        PutRecordBatchRequest::try_new(
524                                            table_name,
525                                            record_batch,
526                                            request_id,
527                                            timestamp_range,
528                                            schema_bytes,
529                                            flight_data,
530                                            limiter.as_ref(),
531                                        )
532                                        .map_err(|e| Status::invalid_argument(e.to_string())),
533                                    ));
534                                }
535                                Ok(Some(other)) => {
536                                    debug!("Unexpected flight message: {:?}", other);
537                                    return Poll::Ready(Some(Err(Status::invalid_argument(
538                                        "Expected RecordBatch message, got other message type",
539                                    ))));
540                                }
541                                Ok(None) => {
542                                    // Dictionary batch - processed internally by decoder, continue polling
543                                    continue;
544                                }
545                                Err(e) => {
546                                    return Poll::Ready(Some(Err(Status::invalid_argument(
547                                        format!("Failed to decode RecordBatch: {}", e),
548                                    ))));
549                                }
550                            }
551                        }
552                    }
553                }
554                Some(Err(e)) => {
555                    return Poll::Ready(Some(Err(e)));
556                }
557                None => {
558                    return Poll::Ready(None);
559                }
560            }
561        }
562    }
563}
564
565fn extract_flow_extensions(
566    metadata: &tonic::metadata::MetadataMap,
567) -> TonicResult<Vec<(String, String)>> {
568    Ok(extract_json_metadata(metadata, FLOW_EXTENSIONS_METADATA_KEY)?.unwrap_or_default())
569}
570
571fn extract_snapshot_seqs(
572    metadata: &tonic::metadata::MetadataMap,
573) -> TonicResult<HashMap<u64, u64>> {
574    Ok(extract_json_metadata(metadata, SNAPSHOT_SEQS_METADATA_KEY)?.unwrap_or_default())
575}
576
577fn extract_json_metadata<T: serde::de::DeserializeOwned>(
578    metadata: &tonic::metadata::MetadataMap,
579    key: &'static str,
580) -> TonicResult<Option<T>> {
581    let Some(value) = metadata.get(key) else {
582        return Ok(None);
583    };
584
585    let value = value
586        .to_str()
587        .map_err(|e| Status::invalid_argument(format!("Invalid {key} metadata value: {e}")))?;
588
589    let parsed = serde_json::from_str::<T>(value)
590        .map_err(|e| Status::invalid_argument(format!("Invalid {key} metadata JSON: {e}")))?;
591    Ok(Some(parsed))
592}
593
594fn to_flight_data_stream<F>(
595    output: F,
596    tracing_context: TracingContext,
597    flight_compression: FlightCompression,
598    query_ctx: QueryContextRef,
599    should_emit_terminal_metrics: bool,
600) -> TonicStream<FlightData>
601where
602    F: std::future::Future<Output = Result<Output>> + Send + 'static,
603{
604    let initializer = async move {
605        let output = output.await.map_err(Status::from)?;
606        output_to_flight_record_batch_source(output, should_emit_terminal_metrics)
607    };
608    let stream = FlightRecordBatchStream::new(
609        FlightRecordBatchStreamInput::initializer(initializer),
610        tracing_context,
611        flight_compression,
612        query_ctx,
613    );
614    Box::pin(stream) as _
615}
616
617fn output_to_flight_record_batch_source(
618    output: Output,
619    should_emit_terminal_metrics: bool,
620) -> TonicResult<FlightRecordBatchSource> {
621    match output.data {
622        OutputData::Stream(stream) => Ok(FlightRecordBatchSource::RecordBatches(stream)),
623        OutputData::RecordBatches(x) => Ok(FlightRecordBatchSource::RecordBatches(x.as_stream())),
624        OutputData::AffectedRows(rows) => {
625            let terminal_metrics = match terminal_recordbatch_metrics_from_plan_if_requested(
626                output.meta.plan,
627                should_emit_terminal_metrics,
628            ) {
629                Some(metrics) => match serde_json::to_string(&metrics) {
630                    Ok(metrics) => Some(metrics),
631                    Err(e) => {
632                        return Err(Status::internal(format!(
633                            "Failed to serialize terminal metrics: {e}"
634                        )));
635                    }
636                },
637                None => None,
638            };
639            Ok(FlightRecordBatchSource::AffectedRows {
640                rows,
641                metrics: terminal_metrics,
642            })
643        }
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use query::options::{
650        FLOW_INCREMENTAL_AFTER_SEQS, FLOW_INCREMENTAL_MODE, FLOW_RETURN_REGION_SEQ,
651        FLOW_SCHEDULED_TIME_MILLIS, FLOW_SINK_TABLE_ID, FlowIncrementalMode,
652    };
653    use tonic::metadata::{AsciiMetadataValue, MetadataMap};
654
655    use super::*;
656
657    #[test]
658    fn test_extract_flow_extensions_preserves_comma_bearing_values() {
659        let mut metadata = MetadataMap::new();
660        metadata.insert(
661            FLOW_EXTENSIONS_METADATA_KEY,
662            AsciiMetadataValue::try_from(
663                r#"[["flow.return_region_seq","true"],["flow.incremental_after_seqs","{\"1\":10,\"2\":20}"]]"#,
664            )
665            .unwrap(),
666        );
667
668        let extensions = extract_flow_extensions(&metadata).unwrap();
669        assert_eq!(
670            extensions,
671            vec![
672                ("flow.return_region_seq".to_string(), "true".to_string()),
673                (
674                    "flow.incremental_after_seqs".to_string(),
675                    r#"{"1":10,"2":20}"#.to_string()
676                ),
677            ]
678        );
679    }
680
681    #[test]
682    fn test_flow_extensions_can_carry_scheduled_time() {
683        let mut metadata = MetadataMap::new();
684        metadata.insert(
685            FLOW_EXTENSIONS_METADATA_KEY,
686            AsciiMetadataValue::try_from(r#"[["flow.scheduled_time_millis","1700000000000"]]"#)
687                .unwrap(),
688        );
689
690        let flow_extensions = extract_flow_extensions(&metadata).unwrap();
691        let query_ctx =
692            create_query_context(Channel::Grpc, None, flow_extensions, HashMap::new()).unwrap();
693
694        assert_eq!(
695            query_ctx.extension(FLOW_SCHEDULED_TIME_MILLIS),
696            Some("1700000000000")
697        );
698    }
699
700    #[test]
701    fn test_flow_extensions_forward_sequence_range_to_query_context() {
702        let mut metadata = MetadataMap::new();
703        metadata.insert(
704            FLOW_EXTENSIONS_METADATA_KEY,
705            AsciiMetadataValue::try_from(
706                r#"[["flow.return_region_seq","true"],["flow.incremental_mode","sequence_range"],["flow.incremental_after_seqs","{\"1\":10,\"2\":20}"],["flow.sink_table_id","42"]]"#,
707            )
708            .unwrap(),
709        );
710
711        let flow_extensions = extract_flow_extensions(&metadata).unwrap();
712        let query_ctx =
713            create_query_context(Channel::Grpc, None, flow_extensions, HashMap::new()).unwrap();
714        let parsed =
715            query::options::FlowQueryExtensions::parse_flow_extensions(&query_ctx.extensions())
716                .unwrap()
717                .unwrap();
718
719        assert_eq!(
720            parsed.incremental_mode,
721            Some(FlowIncrementalMode::SequenceRange)
722        );
723        assert_eq!(
724            parsed.incremental_after_seqs,
725            Some(HashMap::from([(1, 10), (2, 20)]))
726        );
727        assert!(parsed.return_region_seq);
728        assert_eq!(parsed.sink_table_id, Some(42));
729        assert_eq!(
730            query_ctx.extension(FLOW_INCREMENTAL_MODE),
731            Some("sequence_range")
732        );
733        assert_eq!(
734            query_ctx.extension(FLOW_INCREMENTAL_AFTER_SEQS),
735            Some(r#"{"1":10,"2":20}"#)
736        );
737        assert_eq!(query_ctx.extension(FLOW_RETURN_REGION_SEQ), Some("true"));
738        assert_eq!(query_ctx.extension(FLOW_SINK_TABLE_ID), Some("42"));
739    }
740
741    #[test]
742    fn test_extract_flow_extensions_rejects_invalid_json() {
743        let mut metadata = MetadataMap::new();
744        metadata.insert(
745            FLOW_EXTENSIONS_METADATA_KEY,
746            AsciiMetadataValue::try_from("not-json").unwrap(),
747        );
748
749        let err = extract_flow_extensions(&metadata).unwrap_err();
750        assert_eq!(err.code(), tonic::Code::InvalidArgument);
751    }
752}